按建议,继续完成后续工作

结论:已将 INI 面板的 OPFS 文本文件读写抽为 host-side file-service,并新增 Node mock 验证,确认 OPFS 边界不进入 WASM core 且可覆盖保存、读取、缺失路径和非法路径行为。
This commit is contained in:
2026-06-08 00:19:52 +08:00
parent 0a8bf7d5e2
commit 9a9fb5d4c1
8 changed files with 180 additions and 47 deletions

View File

@@ -18,6 +18,12 @@ The current WASM smoke validation command is:
wasm-port/tests/wasm/node/verify_ini_wasm.sh wasm-port/tests/wasm/node/verify_ini_wasm.sh
``` ```
The current OPFS host-boundary validation command is:
```bash
wasm-port/tests/opfs/node/verify_file_service.sh
```
## Validation Chain ## Validation Chain
The native validation script runs these checks in order: The native validation script runs these checks in order:
@@ -46,6 +52,11 @@ The WASM INI smoke script builds `runtime/ui/ini-panel/linuxcnc_ini.js` and
module in Node and verifies `lcini_get_string()` against an in-memory INI file module in Node and verifies `lcini_get_string()` against an in-memory INI file
written to the Emscripten filesystem. written to the Emscripten filesystem.
The OPFS host-boundary script validates the JavaScript file-service adapter
with a Node mock of the browser File System Access handles. It covers nested
directory creation, text save/load, missing file behavior, invalid relative
paths, and unavailable OPFS storage.
## Source Coverage ## Source Coverage
Every `.c` and `.cc` entry in `tools/source-manifest.txt` must have a Every `.c` and `.cc` entry in `tools/source-manifest.txt` must have a
@@ -93,6 +104,7 @@ The validation fails if:
| Harness | Purpose | | Harness | Purpose |
| --- | --- | | --- | --- |
| `tests/wasm/node/verify_ini_wasm.sh` | Validates the browser-facing INI WASM module can be built from vendored LinuxCNC `inifile.cc`, loaded in Node, and queried through the exported C ABI. | | `tests/wasm/node/verify_ini_wasm.sh` | Validates the browser-facing INI WASM module can be built from vendored LinuxCNC `inifile.cc`, loaded in Node, and queried through the exported C ABI. |
| `tests/opfs/node/verify_file_service.sh` | Validates the host-owned OPFS text-file adapter used by the browser INI panel without moving file persistence into the WASM core. |
## Fixture Coverage ## Fixture Coverage
@@ -136,8 +148,9 @@ Negative fixtures currently cover:
## Validation Boundaries ## Validation Boundaries
Current full-core validation is native-only. WASM validation is limited to the Current full-core validation is native-only. WASM validation is limited to the
INI parser smoke harness. Browser, SDK, OPFS, and full machine-session INI parser smoke harness. OPFS validation is limited to the JavaScript
validation remain future work. host-boundary adapter. Browser, SDK, and full machine-session validation
remain future work.
The current fixture expectations validate standalone behavior against both the The current fixture expectations validate standalone behavior against both the
vendored LinuxCNC source path and an upstream `rs274` side-by-side baseline for vendored LinuxCNC source path and an upstream `rs274` side-by-side baseline for

View File

@@ -42,7 +42,7 @@ semantic rewrites:
| Kinematics component lifecycle | Kinematics modules are initialized through LinuxCNC module entry points where native runtime probes exist, while HAL component init/ready/exit, HAL pin allocation, and RTAPI module metadata are handled by standalone shims. | | Kinematics component lifecycle | Kinematics modules are initialized through LinuxCNC module entry points where native runtime probes exist, while HAL component init/ready/exit, HAL pin allocation, and RTAPI module metadata are handled by standalone shims. |
| Go math C/C++ linkage | `genserkins` runtime probing compiles vendored `gomath.c` through a narrow C++ wrapper so LinuxCNC `genserfuncs.c` can link to the upstream Go math symbols without editing vendored source. | | Go math C/C++ linkage | `genserkins` runtime probing compiles vendored `gomath.c` through a narrow C++ wrapper so LinuxCNC `genserfuncs.c` can link to the upstream Go math symbols without editing vendored source. |
| Switchkins iterative forward | `genhexkins` runtime probing follows LinuxCNC switchkins iterative-forward behavior, including the first-call warmup path before asserting roundtrip convergence. | | Switchkins iterative forward | `genhexkins` runtime probing follows LinuxCNC switchkins iterative-forward behavior, including the first-call warmup path before asserting roundtrip convergence. |
| Browser storage | OPFS remains outside the native core and is not yet connected. | | Browser storage | OPFS remains outside the native core; `runtime/opfs/file-service.js` owns browser text-file persistence for host-managed INI content. |
## Enforced Non-Drift Rules ## Enforced Non-Drift Rules
@@ -59,7 +59,8 @@ semantic rewrites:
- No browser/full-core WASM parity tests yet. The INI parser now has a Node - No browser/full-core WASM parity tests yet. The INI parser now has a Node
WASM smoke harness against vendored LinuxCNC `inifile.cc`. WASM smoke harness against vendored LinuxCNC `inifile.cc`.
- No JS SDK validation yet. - No JS SDK validation yet.
- No OPFS persistence validation yet. - OPFS validation is currently limited to a Node mock of the browser
file-service adapter; browser-level OPFS validation is not yet established.
- Identity/trivial, `5axiskins`, TRT `xyzac`/`xyzbc`, delta, SCARA, PUMA, - Identity/trivial, `5axiskins`, TRT `xyzac`/`xyzbc`, delta, SCARA, PUMA,
serial, hexapod, pentapod, and related kinematics sources now have native serial, hexapod, pentapod, and related kinematics sources now have native
source-probe coverage. source-probe coverage.

View File

@@ -51,7 +51,7 @@ Current validation is intentionally mechanical:
| Dependency | LinuxCNC files that expose it | Standalone treatment | | Dependency | LinuxCNC files that expose it | Standalone treatment |
| --- | --- | --- | | --- | --- | --- |
| Native file IO | `inifile.cc`, `rs274ngc_pre.cc`, parameter file paths | Allowed in native probes; browser OPFS remains a host-side future adapter | | Native file IO | `inifile.cc`, `rs274ngc_pre.cc`, parameter file paths | Allowed in native probes; browser OPFS remains a host-side adapter under `runtime/opfs/` |
| RTAPI | `rtapi_*.h`, TP, posemath, motion headers | Minimal standalone shim in `runtime/core/shims/rtapi.h` | | RTAPI | `rtapi_*.h`, TP, posemath, motion headers | Minimal standalone shim in `runtime/core/shims/rtapi.h` |
| NML transport | `emc.hh`, motion/NML type headers | Transport is not ported; only the status/type edges needed by vendored compute code are exposed through standalone shims and probes | | NML transport | `emc.hh`, motion/NML type headers | Transport is not ported; only the status/type edges needed by vendored compute code are exposed through standalone shims and probes |
| HAL runtime | named parameter lookup, kinematics component lifecycle, and runtime status edges | Standalone HAL adapter under `runtime/core/linuxcnc_wrap/` | | HAL runtime | named parameter lookup, kinematics component lifecycle, and runtime status edges | Standalone HAL adapter under `runtime/core/linuxcnc_wrap/` |
@@ -70,6 +70,7 @@ Current validation is intentionally mechanical:
fixture-covered through vendored `interp_convert.cc` and `interp_queue.cc`. fixture-covered through vendored `interp_convert.cc` and `interp_queue.cc`.
- Browser/WASM C ABI and JS SDK layers are not yet built for the full - Browser/WASM C ABI and JS SDK layers are not yet built for the full
interpreter/planner core. The INI parser has a Node WASM smoke harness. interpreter/planner core. The INI parser has a Node WASM smoke harness.
- OPFS persistence is not yet connected to INI, tool table, parameter file, or - OPFS persistence is connected to the INI panel through the host-side
G-code program loading. `runtime/opfs/file-service.js` adapter; tool table, parameter file, G-code
program loading, and browser-level OPFS validation remain future work.
- Native LinuxCNC GUI code remains out of scope for implementation. - Native LinuxCNC GUI code remains out of scope for implementation.

View File

@@ -0,0 +1,52 @@
function splitPath(path) {
if (typeof path !== "string" || path.length === 0) {
throw new Error("OPFS path must be a non-empty relative path.");
}
if (path.startsWith("/") || path.includes("//")) {
throw new Error(`Invalid OPFS path: ${path}`);
}
const parts = path.split("/");
if (parts.some((part) => part === "." || part === ".." || part === "")) {
throw new Error(`Invalid OPFS path: ${path}`);
}
return parts;
}
export async function getOpfsRoot(storage = globalThis.navigator?.storage) {
if (!storage?.getDirectory) {
throw new Error("OPFS is not available in this browser.");
}
return storage.getDirectory();
}
export async function ensureParentDir(root, path) {
const parts = splitPath(path);
let current = root;
for (const part of parts.slice(0, -1)) {
current = await current.getDirectoryHandle(part, { create: true });
}
return current;
}
export async function saveTextFile(path, text, storage) {
const root = await getOpfsRoot(storage);
const dir = await ensureParentDir(root, path);
const filename = splitPath(path).at(-1);
const fileHandle = await dir.getFileHandle(filename, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(text);
await writable.close();
}
export async function loadTextFile(path, storage) {
const root = await getOpfsRoot(storage);
const parts = splitPath(path);
let current = root;
for (const part of parts.slice(0, -1)) {
current = await current.getDirectoryHandle(part);
}
const fileHandle = await current.getFileHandle(parts.at(-1));
const file = await fileHandle.getFile();
return file.text();
}

View File

@@ -0,0 +1,4 @@
{
"private": true,
"type": "module"
}

View File

@@ -1,4 +1,9 @@
import createLinuxCncIniModule from "./linuxcnc_ini.js"; import createLinuxCncIniModule from "./linuxcnc_ini.js";
import {
getOpfsRoot,
loadTextFile,
saveTextFile,
} from "../../opfs/file-service.js";
const SAMPLE_PATH = const SAMPLE_PATH =
"../../../linuxcnc/configs/sim/axis/vismach/5axis/table-dual-rotary/xyzab-tdr.ini"; "../../../linuxcnc/configs/sim/axis/vismach/5axis/table-dual-rotary/xyzab-tdr.ini";
@@ -70,44 +75,6 @@ function iniQuery(mod, wasmPath, section, tag) {
} }
} }
async function getOpfsRoot() {
if (!navigator.storage?.getDirectory) {
throw new Error("OPFS is not available in this browser.");
}
return navigator.storage.getDirectory();
}
async function ensureParentDir(root, path) {
const parts = path.split("/");
let current = root;
for (const part of parts.slice(0, -1)) {
current = await current.getDirectoryHandle(part, { create: true });
}
return current;
}
async function saveToOpfs(path, text) {
const root = await getOpfsRoot();
const dir = await ensureParentDir(root, path);
const filename = path.split("/").at(-1);
const fileHandle = await dir.getFileHandle(filename, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(text);
await writable.close();
}
async function loadFromOpfs(path) {
const root = await getOpfsRoot();
const parts = path.split("/");
let current = root;
for (const part of parts.slice(0, -1)) {
current = await current.getDirectoryHandle(part);
}
const fileHandle = await current.getFileHandle(parts.at(-1));
const file = await fileHandle.getFile();
return file.text();
}
function syncEditorToWasmFs() { function syncEditorToWasmFs() {
const mod = requireModule(); const mod = requireModule();
try { try {
@@ -157,7 +124,7 @@ document.getElementById("load-sample").addEventListener("click", async () => {
document.getElementById("save-opfs").addEventListener("click", async () => { document.getElementById("save-opfs").addEventListener("click", async () => {
try { try {
await saveToOpfs(OPFS_FILE, editor.value); await saveTextFile(OPFS_FILE, editor.value);
setLog(`Saved current INI text to OPFS at ${OPFS_FILE}`); setLog(`Saved current INI text to OPFS at ${OPFS_FILE}`);
} catch (error) { } catch (error) {
setLog(`OPFS save failed: ${error.message}`, true); setLog(`OPFS save failed: ${error.message}`, true);
@@ -166,7 +133,7 @@ document.getElementById("save-opfs").addEventListener("click", async () => {
document.getElementById("load-opfs").addEventListener("click", async () => { document.getElementById("load-opfs").addEventListener("click", async () => {
try { try {
editor.value = await loadFromOpfs(OPFS_FILE); editor.value = await loadTextFile(OPFS_FILE);
setLog(`Loaded INI text from OPFS path ${OPFS_FILE}`); setLog(`Loaded INI text from OPFS path ${OPFS_FILE}`);
} catch (error) { } catch (error) {
setLog(`OPFS load failed: ${error.message}`, true); setLog(`OPFS load failed: ${error.message}`, true);

View File

@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import {
getOpfsRoot,
loadTextFile,
saveTextFile,
} from "../../../runtime/opfs/file-service.js";
class MockFileHandle {
constructor(name) {
this.name = name;
this.textValue = "";
}
async createWritable() {
return {
write: async (text) => {
this.textValue = String(text);
},
close: async () => {},
};
}
async getFile() {
return {
text: async () => this.textValue,
};
}
}
class MockDirectoryHandle {
constructor(name = "") {
this.name = name;
this.dirs = new Map();
this.files = new Map();
}
async getDirectoryHandle(name, options = {}) {
if (!this.dirs.has(name)) {
if (!options.create) {
throw new Error(`missing directory: ${name}`);
}
this.dirs.set(name, new MockDirectoryHandle(name));
}
return this.dirs.get(name);
}
async getFileHandle(name, options = {}) {
if (!this.files.has(name)) {
if (!options.create) {
throw new Error(`missing file: ${name}`);
}
this.files.set(name, new MockFileHandle(name));
}
return this.files.get(name);
}
}
const root = new MockDirectoryHandle();
const storage = {
getDirectory: async () => root,
};
assert.equal(await getOpfsRoot(storage), root);
await saveTextFile(
"linuxcnc/machines/xyzab.ini",
"[EMC]\nMACHINE = opfs-smoke\n",
storage,
);
assert.equal(
await loadTextFile("linuxcnc/machines/xyzab.ini", storage),
"[EMC]\nMACHINE = opfs-smoke\n",
);
await assert.rejects(
() => loadTextFile("linuxcnc/machines/missing.ini", storage),
/missing file/,
);
await assert.rejects(
() => saveTextFile("../escape.ini", "", storage),
/Invalid OPFS path/,
);
await assert.rejects(
() => getOpfsRoot({}),
/OPFS is not available/,
);
console.log("opfs_file_service_node_smoke=ok");

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/../../.." && pwd)"
node "$ROOT_DIR/tests/opfs/node/verify_file_service.mjs"