From 9a9fb5d4c102f8af7d819d1bf7f458f66ec83c92 Mon Sep 17 00:00:00 2001 From: wangdequan Date: Mon, 8 Jun 2026 00:19:52 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8C=89=E5=BB=BA=E8=AE=AE=EF=BC=8C=E7=BB=A7?= =?UTF-8?q?=E7=BB=AD=E5=AE=8C=E6=88=90=E5=90=8E=E7=BB=AD=E5=B7=A5=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 结论:已将 INI 面板的 OPFS 文本文件读写抽为 host-side file-service,并新增 Node mock 验证,确认 OPFS 边界不进入 WASM core 且可覆盖保存、读取、缺失路径和非法路径行为。 --- wasm-port/docs/compatibility-validation.md | 17 +++- wasm-port/docs/drift-report.md | 5 +- wasm-port/docs/source-reuse-map.md | 7 +- wasm-port/runtime/opfs/file-service.js | 52 +++++++++++ wasm-port/runtime/opfs/package.json | 4 + wasm-port/runtime/ui/ini-panel/app.js | 47 ++-------- .../tests/opfs/node/verify_file_service.mjs | 89 +++++++++++++++++++ .../tests/opfs/node/verify_file_service.sh | 6 ++ 8 files changed, 180 insertions(+), 47 deletions(-) create mode 100644 wasm-port/runtime/opfs/file-service.js create mode 100644 wasm-port/runtime/opfs/package.json create mode 100644 wasm-port/tests/opfs/node/verify_file_service.mjs create mode 100755 wasm-port/tests/opfs/node/verify_file_service.sh diff --git a/wasm-port/docs/compatibility-validation.md b/wasm-port/docs/compatibility-validation.md index 07d77dd..4f67706 100644 --- a/wasm-port/docs/compatibility-validation.md +++ b/wasm-port/docs/compatibility-validation.md @@ -18,6 +18,12 @@ The current WASM smoke validation command is: 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 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 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 Every `.c` and `.cc` entry in `tools/source-manifest.txt` must have a @@ -93,6 +104,7 @@ The validation fails if: | 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/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 @@ -136,8 +148,9 @@ Negative fixtures currently cover: ## Validation Boundaries Current full-core validation is native-only. WASM validation is limited to the -INI parser smoke harness. Browser, SDK, OPFS, and full machine-session -validation remain future work. +INI parser smoke harness. OPFS validation is limited to the JavaScript +host-boundary adapter. Browser, SDK, and full machine-session validation +remain future work. The current fixture expectations validate standalone behavior against both the vendored LinuxCNC source path and an upstream `rs274` side-by-side baseline for diff --git a/wasm-port/docs/drift-report.md b/wasm-port/docs/drift-report.md index 08dd640..bdd0dd0 100644 --- a/wasm-port/docs/drift-report.md +++ b/wasm-port/docs/drift-report.md @@ -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. | | 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. | -| 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 @@ -59,7 +59,8 @@ semantic rewrites: - No browser/full-core WASM parity tests yet. The INI parser now has a Node WASM smoke harness against vendored LinuxCNC `inifile.cc`. - 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, serial, hexapod, pentapod, and related kinematics sources now have native source-probe coverage. diff --git a/wasm-port/docs/source-reuse-map.md b/wasm-port/docs/source-reuse-map.md index 378ca1d..e6c8fc2 100644 --- a/wasm-port/docs/source-reuse-map.md +++ b/wasm-port/docs/source-reuse-map.md @@ -51,7 +51,7 @@ Current validation is intentionally mechanical: | 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` | | 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/` | @@ -70,6 +70,7 @@ Current validation is intentionally mechanical: 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 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 - G-code program loading. +- OPFS persistence is connected to the INI panel through the host-side + `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. diff --git a/wasm-port/runtime/opfs/file-service.js b/wasm-port/runtime/opfs/file-service.js new file mode 100644 index 0000000..0d200a5 --- /dev/null +++ b/wasm-port/runtime/opfs/file-service.js @@ -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(); +} diff --git a/wasm-port/runtime/opfs/package.json b/wasm-port/runtime/opfs/package.json new file mode 100644 index 0000000..e986b24 --- /dev/null +++ b/wasm-port/runtime/opfs/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/wasm-port/runtime/ui/ini-panel/app.js b/wasm-port/runtime/ui/ini-panel/app.js index 8401d23..8ef676d 100644 --- a/wasm-port/runtime/ui/ini-panel/app.js +++ b/wasm-port/runtime/ui/ini-panel/app.js @@ -1,4 +1,9 @@ import createLinuxCncIniModule from "./linuxcnc_ini.js"; +import { + getOpfsRoot, + loadTextFile, + saveTextFile, +} from "../../opfs/file-service.js"; const SAMPLE_PATH = "../../../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() { const mod = requireModule(); try { @@ -157,7 +124,7 @@ document.getElementById("load-sample").addEventListener("click", async () => { document.getElementById("save-opfs").addEventListener("click", async () => { try { - await saveToOpfs(OPFS_FILE, editor.value); + await saveTextFile(OPFS_FILE, editor.value); setLog(`Saved current INI text to OPFS at ${OPFS_FILE}`); } catch (error) { 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 () => { try { - editor.value = await loadFromOpfs(OPFS_FILE); + editor.value = await loadTextFile(OPFS_FILE); setLog(`Loaded INI text from OPFS path ${OPFS_FILE}`); } catch (error) { setLog(`OPFS load failed: ${error.message}`, true); diff --git a/wasm-port/tests/opfs/node/verify_file_service.mjs b/wasm-port/tests/opfs/node/verify_file_service.mjs new file mode 100644 index 0000000..a5ecec2 --- /dev/null +++ b/wasm-port/tests/opfs/node/verify_file_service.mjs @@ -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"); diff --git a/wasm-port/tests/opfs/node/verify_file_service.sh b/wasm-port/tests/opfs/node/verify_file_service.sh new file mode 100755 index 0000000..d5e4512 --- /dev/null +++ b/wasm-port/tests/opfs/node/verify_file_service.sh @@ -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"