diff --git a/README.md b/README.md
index 6414e34..6e9f84c 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,7 @@
- AXIS 主界面完全对标的源程序功能清单、菜单/工具栏/状态门控、严格差异流程、任务分解和 M0-M7 验收门槛见 [`LinuxCNC AXIS 完全对标规范与执行计划`](docs/linuxcnc-axis-complete-parity-spec.md)。该文档以锁定的 `linuxcnc/share/axis/tcl/axis.tcl`、Gremlin/GLCanon、原始 bitmap 和 `configs/sim/axis` 为唯一 source oracle。
- Web 控制面板操作必须遵守 [`LinuxCNC AXIS Web 铁律`](docs/linuxcnc-axis-web-iron-law.md):AXIS 源码和 LinuxCNC WASM 是唯一操作权威,React/JavaScript/Three.js 不得成为第二套运动或状态实现。
- 每个控制按钮的前置、互斥、后置状态及数据流见 [`LinuxCNC AXIS Web/WASM 详细技术流程图`](docs/linuxcnc-axis-web-wasm-detailed-flowcharts.md)。
+- 页面临时状态、程序草稿和断电 checkpoint 的 Worker-only OPFS 双槽位设计见 [`AXIS Web/WASM OPFS 页面临时缓存设计`](docs/linuxcnc-axis-web-wasm-opfs-ui-cache.md)。
## 当前发布范围
diff --git a/coverage/xyzac-panel-button-parity.report.json b/coverage/xyzac-panel-button-parity.report.json
index 8f4638a..403dc59 100644
--- a/coverage/xyzac-panel-button-parity.report.json
+++ b/coverage/xyzac-panel-button-parity.report.json
@@ -160,6 +160,35 @@
"screenshot_sha256": "dbf6d196cec3fe765a64d5a1d194b7ebb5681a30555f238e084c9ad6deac6032",
"status_detail": "WASM startup, file/package load, POWER, HOME, RESET, switchkins, views, clear plot, MANUAL/MDI, XYZAC JOG, spindle/brake, coolant, tool/probe, overrides, MDI, RUN/PAUSE/RESUME/ABORT/STEP, startup sequence, editor Load/Reopen and ABI-disabled gates all verified"
},
+ "opfs_ui_cache": {
+ "schema": "linuxcnc-ui-temp-json.v1",
+ "storage_owner": "dedicated-worker",
+ "cnc_authority": "linuxcnc-wasm-only",
+ "commands": ["UI_STATE_SAVE", "UI_STATE_LOAD", "UI_STATE_CLEAR"],
+ "features": ["double_slot", "generation", "saved_at_ttl", "state_hash", "record_hash", "corruption_fallback", "size_limit"],
+ "power_off_checkpoint": "opfs_temp_json_v1",
+ "tests": {
+ "state": "node web/worker/test_opfs_ui_state.mjs",
+ "bridge": "node web/worker/test_opfs_worker_bridge.mjs",
+ "status": "verified"
+ },
+ "browser_oracle": "pending_real_quota_crash_reload_offline_matrix",
+ "published_browser_probe": {
+ "command": "node scripts/test_xyzac_opfs_browser.mjs",
+ "release": "20260903-linuxcnc-wasm-opfs-r30",
+ "status": "verified",
+ "view_before": "z",
+ "view_after_reload": "z",
+ "right_tab_before": "DRO",
+ "right_tab_after_reload": "DRO",
+ "selected_axis_after_reload": "A",
+ "opfs_files": ["linuxcnc-web-wasm/ui-state/xyzac-trt/state-b.json", "linuxcnc-web-wasm/ui-state/xyzac-trt/manifest.json"],
+ "local_storage_keys_before": 0,
+ "local_storage_keys_after": 0,
+ "browser_errors": 0,
+ "screenshot": "/tmp/xyzac-opfs-browser.png"
+ }
+ },
"release_status": "STOP_RELEASE",
"scope_gaps": [
"override_limits_command",
diff --git a/doc-rtcp/xyzac-panel-button-parity-test-report.md b/doc-rtcp/xyzac-panel-button-parity-test-report.md
index 6a0d2d3..2ad7b85 100644
--- a/doc-rtcp/xyzac-panel-button-parity-test-report.md
+++ b/doc-rtcp/xyzac-panel-button-parity-test-report.md
@@ -348,3 +348,20 @@ PLAYWRIGHT_MODULE=... node scripts/test_xyzac_panel_buttons_browser.mjs
审计覆盖:WASM startup、Open G-code file、`3D_Chips.ngc`、Load case、POWER ON、HOME ALL、RESET、IDENTITY/TCP:XYZAC/USERK、视图和 Clear plot、MANUAL/MDI、XYZAC 五轴 JOG、主轴正反转/停止/刹车、Mist/Flood、Tool/Probe、Jog/Feed/Spindle/Max velocity overrides、MDI、Run/Pause/Resume/Abort/Step、Run Startup Sequence、编辑器 Load/Reopen 和 ABI-disabled 控件。发布版本 `20260902-linuxcnc-wasm-panel-audit-r27` 最终结果为 20 组 verified、83 个按钮、37 个按 LinuxCNC gate 禁用、`browser_errors=0`;WASM worker authority 标记为 `linuxcnc-wasm-worker` / `wasm_task_canonical`,截图 `/tmp/xyzac-panel-buttons-browser.png`,SHA-256 `dbf6d196cec3fe765a64d5a1d194b7ebb5681a30555f238e084c9ad6deac6032`。
源代码对标结论:AXIS 的可执行操作均通过对应 WASM command/result 和后续 snapshot;`Override Limits`、Block delete/Optional pause、完整 Tool Touch Off/G10 交互以及桌面 tool editor/Quit 仍明确记录为 `blocked_by_abi` 或浏览器宿主边界,不能用 JavaScript 模拟。整体 `release_status` 继续保持 `STOP_RELEASE`,因为本报告不替代完整 TP 数值 oracle。
+
+## OPFS 页面临时缓存
+
+页面上下文和断电 checkpoint 由 Dedicated Worker 的 OPFS 临时 JSON 服务保存,React 不直接访问 OPFS 或 `localStorage`。`UI_STATE_SAVE/LOAD/CLEAR` 是 Worker storage service,不进入 LinuxCNC WASM CNC protocol sequence;缓存中的程序文本恢复时必须再次经过 WASM `PROGRAM_LOAD`。
+
+```text
+node web/worker/test_opfs_ui_state.mjs
+node web/worker/test_opfs_worker_bridge.mjs
+status: verified
+double_slot / generation / TTL / hash / corruption fallback: verified
+power_off_checkpoint OPFS round-trip: verified
+UI cache does not consume WASM sequence: verified
+```
+
+详细字段、文件布局、原子写入和恢复边界见 [`AXIS Web/WASM OPFS 页面临时缓存设计`](../docs/linuxcnc-axis-web-wasm-opfs-ui-cache.md)。
+
+发布版本 `20260903-linuxcnc-wasm-opfs-r30` 的真实 Chromium OPFS 往返:改变 Preview 视角为 `z`、切换 DRO、选择 A 轴,等待 1.8 秒防抖保存后刷新页面;刷新后 `view=z`、DRO 仍激活、A 轴仍选中,OPFS 中实际存在 `state-b.json` 和 `manifest.json`,两次 `localStorage` key 数均为 `0`,`browser_errors=0`。截图 `/tmp/xyzac-opfs-browser.png`。
diff --git a/docs/linuxcnc-axis-web-iron-law.md b/docs/linuxcnc-axis-web-iron-law.md
index 9589107..ce1503a 100644
--- a/docs/linuxcnc-axis-web-iron-law.md
+++ b/docs/linuxcnc-axis-web-iron-law.md
@@ -27,6 +27,8 @@
- 禁止用本地按钮状态覆盖 WASM 的 task、IO、HAL、tool、probe、home/limit 或 planner 状态。
- 禁止把按钮“可点击”、静态截图或单次页面加载当作实现证据;必须有 worker/WASM command result 和后续 snapshot。
- 不具备对应 LinuxCNC/WASM ABI 的功能必须保持禁用,并明确记录 `blocked_by_abi`,不得用 JS 模拟补齐。
+- 页面临时上下文和断电 checkpoint 可以使用 Worker-only OPFS 临时 JSON 保存,但 OPFS 只负责持久化,不得成为 CNC 业务 authority;恢复时必须重新交给 LinuxCNC WASM 校验和执行。
+- React 不得直接访问 OPFS、`localStorage` 或 WASM 指针;所有页面缓存读写必须通过 Worker 消息完成,并带 schema、context、generation 和 hash。
## 验收证据
diff --git a/docs/linuxcnc-axis-web-wasm-detailed-flowcharts.md b/docs/linuxcnc-axis-web-wasm-detailed-flowcharts.md
index a466f02..7b49b2a 100644
--- a/docs/linuxcnc-axis-web-wasm-detailed-flowcharts.md
+++ b/docs/linuxcnc-axis-web-wasm-detailed-flowcharts.md
@@ -270,3 +270,5 @@ flowchart LR
~~~
当前浏览器审计要求:WASM authority 标记正确、browser_errors=0、所有按钮有 command result 或明确 gate/ABI 原因;面板 verified 不等于完整 TP oracle verified。
+
+页面临时上下文使用 Worker-only OPFS 双槽位 JSON;它不进入 CNC protocol sequence,也不保存运动 authority。断电 checkpoint 由 Worker 写入 OPFS,下一次 LOAD_WORKSPACE 时再提交给 LinuxCNC WASM 校验和恢复。
diff --git a/docs/linuxcnc-axis-web-wasm-opfs-ui-cache.md b/docs/linuxcnc-axis-web-wasm-opfs-ui-cache.md
new file mode 100644
index 0000000..a23de4a
--- /dev/null
+++ b/docs/linuxcnc-axis-web-wasm-opfs-ui-cache.md
@@ -0,0 +1,148 @@
+# AXIS Web/WASM OPFS 页面临时缓存设计
+
+## 目标
+
+页面刷新或 Worker 重启后,恢复用户需要的页面上下文,同时不把页面缓存误认为 LinuxCNC 运行时权威。所有 G-code、Task、TP、运动学、轴值、HAL、IO、工具、探针和错误状态仍由 LinuxCNC WASM checkpoint/recovery 恢复;OPFS 临时 JSON 只保存页面上下文和可重新提交给 WASM 的程序草稿。
+
+## 分层
+
+~~~mermaid
+flowchart LR
+ UI[React 页面状态
页签 / 相机 / 图层 / 编辑器] -->|UI_STATE_SAVE| WC[ReactWorkerClient]
+ WC -->|Worker message| W[Dedicated Worker]
+ W --> OPFS[OPFS 双槽位临时 JSON]
+ OPFS -->|UI_STATE_LOAD| W
+ W --> WC
+ WC --> UI
+ P[POWER OFF response] --> W
+ W --> CP[WASM power-off checkpoint]
+ CP --> OPFS
+ OPFS -->|LOAD_WORKSPACE| W
+ W --> WASM[LinuxCNC WASM restore]
+ WASM --> R[Task/TP/pose/IO snapshot]
+ R --> UI
+~~~
+
+## 缓存范围
+
+允许写入 UI 临时 JSON 的字段:
+
+- selectedAxis、leftTab、rightTab;
+- view、zoom、rotateMode;
+- coordType、displayType、metric;
+- preview layers;
+- casePath、program.path、program.source;
+- cache schema version、context_id、generation、saved_at_ms、state_hash。
+
+禁止由 UI JSON 直接恢复并驱动运动的字段:
+
+- task state/mode/program status;
+- TP queue、active segment、velocity、acceleration、jerk;
+- XYZAC pose、J0..J4 joints、RTCP/FK/IK state;
+- HAL、spindle、coolant、tool、probe、home/limit、fault;
+- operation ledger、event cursor 和已提交副作用。
+
+这些字段必须来自 WASM checkpoint;缓存的 program.source 重新恢复时也必须再次发送 PROGRAM_LOAD,由 WASM interpreter 解析。
+
+## 文件布局
+
+```text
+OPFS root/
+ linuxcnc-web-wasm/
+ ui-state/
+ xyzac-trt/
+ state-a.json
+ state-b.json
+ manifest.json
+ xyzac-trt-power-off/
+ state-a.json
+ state-b.json
+ manifest.json
+```
+
+`state-a.json` 和 `state-b.json` 轮换写入。写新槽位成功并 flush 后才更新 manifest;任一写入中断时,旧槽位仍可读取。读取时验证两个槽位,选择 generation 最大且 hash 正确的记录。
+
+## JSON 记录
+
+```json
+{
+ "schema": "linuxcnc-ui-temp-json.v1",
+ "context_id": "linuxcnc-2.10.0-pre0-wasm-m1-py-c1",
+ "key": "xyzac-trt",
+ "generation": 12,
+ "saved_at_ms": 1770000000000,
+ "state": {
+ "version": 1,
+ "leftTab": "manual",
+ "rightTab": "preview",
+ "view": "p",
+ "zoom": 1,
+ "program": { "path": "browser/demo.ngc", "source": "..." }
+ },
+ "state_hash": "fnv1a64:...",
+ "record_hash": "fnv1a64:..."
+}
+```
+
+约束:单记录最大 1 MiB,程序缓存最大 900 KiB,缓存 TTL 为 7 天;超限、过期、JSON 损坏、schema/context/hash 不匹配时返回 empty/blocked,不能回退到旧 UI 假状态。
+
+## 生命周期
+
+~~~mermaid
+sequenceDiagram
+ participant A as App.tsx
+ participant C as WorkerClient
+ participant W as Worker
+ participant O as OPFS
+ participant X as WASM
+ A->>C: ensureSession
+ C->>W: HELLO / SESSION_ATTACH
+ A->>C: UI_STATE_LOAD
+ C->>W: custom Worker cache request
+ W->>O: scan slots + verify hash/TTL
+ O-->>W: newest valid UI state
+ W-->>C: UI_STATE_RESULT
+ C-->>A: restore tabs/camera/editor draft
+ A->>W: LOAD_WORKSPACE
+ W->>O: load power-off checkpoint if present
+ W->>X: restore WASM task/TP/program/IO
+ X-->>W: authoritative snapshot
+ W-->>A: state snapshot
+ A->>C: debounced UI_STATE_SAVE
+ C->>W: save state JSON
+ W->>O: write inactive slot, flush, update manifest
+~~~
+
+高频 SIM_STEP 不写 OPFS。页面状态变化使用 500ms 防抖;程序编辑器输入连续变化只保存最后一个稳定版本。POWER OFF 的 WASM checkpoint 由 Worker 在收到成功回执后写入专用 key;下一次 LOAD_WORKSPACE 由 Worker 自动读取并交给 WASM restore。
+
+## 安全和一致性
+
+1. React 不访问 navigator.storage、WASM 指针或 OPFS 文件。
+2. Worker 是 OPFS 唯一写者;所有消息串行处理,避免控制命令和缓存写入竞争。
+3. UI_STATE_RESULT 不进入 CNC state reducer,不改变 protocol sequence、task tick 或运动状态。
+4. UI cache 的 program.source 只能作为输入草稿;加载和预览仍必须由 WASM PROGRAM_LOAD 生成。
+5. OPFS 不可用时,页面显示可诊断的 blocked/empty 状态,不使用 localStorage 或静默内存替代。
+6. 清理只删除 UI 临时槽位;不会删除 WASM checkpoint、事件日志或程序运行状态。
+
+## 代码和测试
+
+| 能力 | 文件 |
+| --- | --- |
+| 双槽位 JSON、TTL、hash、OPFS API | web/worker/opfs-ui-state.mjs |
+| Worker-only 路由、断电 checkpoint | web/worker/opfs-worker-bridge.mjs |
+| React UI 状态读写调用 | web/react/src/worker-client.ts、web/react/src/App.tsx |
+| 浏览器 Worker 入口 | web/react/src/sim-worker-entry.ts、web/worker/sim-worker.mjs |
+| 单元回归 | web/worker/test_opfs_ui_state.mjs |
+| 原生 OPFS 能力探针 | web/workspace/opfs-runtime.mjs、web/app/browser-opfs-probe.html |
+
+验证命令:
+
+```text
+node web/worker/test_opfs_ui_state.mjs
+npm run typecheck --prefix web/react
+npm run build --prefix web/react
+```
+
+真实浏览器还需验证 OPFS quota、Worker reload、坏槽位、过期记录和离线启动;这些属于独立的浏览器恢复 oracle,不等同于 UI cache 单元测试通过。
+
+已完成的线上浏览器样例:`scripts/test_xyzac_opfs_browser.mjs` 在 `20260903-linuxcnc-wasm-opfs-r30` 上验证视角、DRO 页签、A 轴选择刷新后恢复;实际 OPFS 文件为 `state-b.json`/`manifest.json`,`localStorage` 为空,浏览器错误为 0。quota、崩溃和离线 oracle 仍独立保留为 pending。
diff --git a/scripts/test_xyzac_opfs_browser.mjs b/scripts/test_xyzac_opfs_browser.mjs
new file mode 100644
index 0000000..d7b23a5
--- /dev/null
+++ b/scripts/test_xyzac_opfs_browser.mjs
@@ -0,0 +1,39 @@
+import { pathToFileURL } from "node:url";
+
+const modulePath = process.env.PLAYWRIGHT_MODULE;
+const url = process.env.XYZAC_PANEL_URL ?? "https://49.232.250.35:58010/";
+if (!modulePath) { console.log(JSON.stringify({ schema: "xyzac-opfs-browser.v1", status: "blocked", code: "playwright_unavailable" })); process.exit(0); }
+const imported = await import(pathToFileURL(modulePath).href);
+const playwright = imported.default ?? imported;
+const userDataDir = process.env.XYZAC_OPFS_PROFILE ?? "/tmp/xyzac-opfs-browser-profile";
+const context = await playwright.chromium.launchPersistentContext(userDataDir, { headless: true, executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome", ignoreHTTPSErrors: true, viewport: { width: 1440, height: 1000 }, args: ["--ignore-certificate-errors", "--disable-gpu"] });
+const page = context.pages()[0] ?? await context.newPage();
+const errors = [];
+page.on("pageerror", (error) => errors.push(`pageerror:${error.message}`));
+page.on("console", (message) => { if (message.type() === "error") errors.push(`console:${message.text()}`); });
+const main = page.locator("main.axis-app");
+const waitReady = async () => { for (let index = 0; index < 300; index += 1) { if (await main.count() && await main.getAttribute("data-axis-state-source") === "linuxcnc-wasm") return; await page.waitForTimeout(250); } throw new Error("wasm_startup_timeout"); };
+const opfsFiles = async () => page.evaluate(async () => {
+ const root = await navigator.storage.getDirectory();
+ const names = [];
+ const walk = async (directory, prefix) => {
+ for await (const [name, handle] of directory.entries()) { names.push(`${prefix}/${name}`); if (handle.kind === "directory") await walk(handle, `${prefix}/${name}`); }
+ };
+ await walk(root, "");
+ return names.sort();
+});
+await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
+await waitReady();
+await main.locator('button[aria-label="Top view"]').click();
+await main.getByRole("tab", { name: "DRO", exact: true }).click();
+await main.locator('input[value="a"]').check();
+await page.waitForTimeout(1800);
+const before = await main.evaluate((element) => ({ view: element.dataset.axisView, zoom: element.dataset.axisZoom, cache: element.dataset.axisUiCache, tab: element.querySelector('#axis-tab-dro')?.getAttribute("aria-selected") === "true" ? "DRO" : "Manual", localStorageKeys: Object.keys(localStorage) }));
+const files = await opfsFiles();
+await page.reload({ waitUntil: "domcontentloaded", timeout: 30000 });
+await waitReady();
+await page.waitForTimeout(500);
+const after = await main.evaluate((element) => ({ view: element.dataset.axisView, zoom: element.dataset.axisZoom, cache: element.dataset.axisUiCache, dro: element.querySelector('#axis-tab-dro')?.getAttribute("aria-selected"), axisA: element.querySelector('input[value="a"]')?.checked, localStorageKeys: Object.keys(localStorage) }));
+await page.screenshot({ path: "/tmp/xyzac-opfs-browser.png", fullPage: true });
+console.log(JSON.stringify({ schema: "xyzac-opfs-browser.v1", status: errors.length === 0 && before.cache === "opfs" && after.cache === "opfs" && before.view === after.view && before.tab === "DRO" && after.dro === "true" && after.axisA === true && before.localStorageKeys.length === 0 && after.localStorageKeys.length === 0 && files.some((file) => file.includes("state-")) ? "verified" : "failed", url, before, after, opfs_files: files, browser_errors: errors, screenshot: "/tmp/xyzac-opfs-browser.png" }));
+await context.close();
diff --git a/web/app/worker-client.mjs b/web/app/worker-client.mjs
index e9b5897..786bca2 100644
--- a/web/app/worker-client.mjs
+++ b/web/app/worker-client.mjs
@@ -1,32 +1,11 @@
export const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
export const PROTOCOL_SCHEMA = 1;
-const POWER_OFF_CHECKPOINT_KEY = "linuxcnc-web-wasm:xyzac-power-off-checkpoint";
let CLIENT_INSTANCE = 0;
function isObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
-function persistPowerOffCheckpoint(message) {
- const payload = isObject(message?.payload) ? message.payload : {};
- if (message.kind !== "COMMAND_RESULT" || payload.status !== "verified" || payload.command !== "POWER" || payload.active !== false || !isObject(payload.recovery_checkpoint)) return;
- try { globalThis.localStorage?.setItem(POWER_OFF_CHECKPOINT_KEY, JSON.stringify(payload.recovery_checkpoint)); } catch { /* checkpoint remains visible in the response */ }
-}
-
-function attachPowerOffCheckpoint(payload) {
- if (!isObject(payload)) return payload;
- try {
- const raw = globalThis.localStorage?.getItem(POWER_OFF_CHECKPOINT_KEY);
- return raw ? { ...payload, recovery_checkpoint: JSON.parse(raw) } : payload;
- } catch { return payload; }
-}
-
-function clearPowerOffCheckpoint(message) {
- const payload = isObject(message?.payload) ? message.payload : {};
- if (message.kind !== "COMMAND_RESULT" || payload.status !== "verified" || payload.command !== "POWER" || payload.active !== true) return;
- try { globalThis.localStorage?.removeItem(POWER_OFF_CHECKPOINT_KEY); } catch { /* cleanup is best effort */ }
-}
-
function commandError(message) {
const payload = isObject(message?.payload) ? message.payload : {};
const reason = payload.reason ?? payload.gate_reason ?? payload.message_key ?? payload.code ?? "worker_error";
@@ -64,9 +43,6 @@ export class WorkerClient {
}
#receive(message) {
- const persistence = persistPowerOffCheckpoint(message);
- if (persistence && isObject(message?.payload)) message.payload.recovery_persistence = persistence;
- clearPowerOffCheckpoint(message);
this.#emit(message);
if (!isObject(message)) return;
const operationId = message.operation_id ?? message.payload?.operation_id;
@@ -101,7 +77,7 @@ export class WorkerClient {
seq,
kind,
...(operationId ? { operation_id: operationId } : {}),
- payload: kind === "LOAD_WORKSPACE" ? attachPowerOffCheckpoint(payload) : payload,
+ payload,
};
let resolvePromise;
let rejectPromise;
diff --git a/web/react/src/App.tsx b/web/react/src/App.tsx
index 043dec6..3788157 100644
--- a/web/react/src/App.tsx
+++ b/web/react/src/App.tsx
@@ -186,9 +186,58 @@ export function App() {
const [operationTrace, setOperationTrace] = useState([]);
const feedOverridePending = useRef(null);
const feedOverrideTimer = useRef | null>(null);
+ const uiCacheHydrated = useRef(false);
+ const uiCacheSaveTimer = useRef | null>(null);
+ const UI_CACHE_KEY = "xyzac-trt";
const [previewProgress, setPreviewProgress] = useState<{ percent: number; phase: "reading" | "wasm" | "ready" | "error"; detail: string } | null>(null);
const previewProgressTimer = useRef | null>(null);
- useEffect(() => { const unsubscribe = client.subscribe((message) => setState((previous) => reduceFrontendState(previous, message))); client.ensureSession().then(async () => { const bundle = await loadSimulationCase({ path: DEFAULT_CASE, baseUrl: "." }); if (bundle.status !== "ready") throw new Error(`xyzac_case_blocked:${bundle.missing.join(",")}`); const source = bundle.files?.[PROGRAM_PATH]; if (typeof source !== "string" || !source.trim()) throw new Error("xyzac_demo_program_missing"); const normalizedSource = normalizeProgramSource(source); setProgramText(normalizedSource); setProgramPath(PROGRAM_PATH); const configuration = toWorkerConfiguration(bundle); await operationController.execute("LOAD_WORKSPACE", { root_path: "/workspace", configuration }, { state: {}, gate: false }); await operationController.execute("PROGRAM_LOAD", { source: normalizedSource, path: PROGRAM_PATH }, { state: {}, gate: false }); setOperationTrace(operationController.exportTrace()); setCaseStatus(`${DEFAULT_CASE} + ${PROGRAM_PATH} loaded`); }).catch((error: Error) => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`Error: ${error.message}`); setCaseStatus(`blocked: ${error.message}`); }); return () => { unsubscribe(); if (runTimer.current) clearInterval(runTimer.current); if (jogTimer.current) clearInterval(jogTimer.current); if (previewProgressTimer.current) clearInterval(previewProgressTimer.current); if (feedOverrideTimer.current) clearTimeout(feedOverrideTimer.current); client.close(); }; }, [client, operationController]);
+ useEffect(() => {
+ const unsubscribe = client.subscribe((message) => setState((previous) => reduceFrontendState(previous, message)));
+ client.ensureSession().then(async () => {
+ const cachedResponse = await client.loadUiState(UI_CACHE_KEY).catch(() => null);
+ const cachedPayload = cachedResponse?.payload && typeof cachedResponse.payload === "object" ? cachedResponse.payload as Record : null;
+ const cached = cachedPayload?.status === "verified" && cachedPayload.state && typeof cachedPayload.state === "object" ? cachedPayload.state as Record : null;
+ const cachedProgram = cached?.program && typeof cached.program === "object" ? cached.program as Record : null;
+ if (cached) {
+ if (cached.casePath === DEFAULT_CASE) setCasePath(DEFAULT_CASE);
+ if (["x", "y", "z", "a", "c"].includes(String(cached.selectedAxis))) setSelectedAxis(String(cached.selectedAxis));
+ if (["manual", "mdi", "diagnostics"].includes(String(cached.leftTab))) setLeftTab(cached.leftTab as "manual" | "mdi" | "diagnostics");
+ if (["preview", "dro"].includes(String(cached.rightTab))) setRightTab(cached.rightTab as "preview" | "dro");
+ if (["z", "z2", "x", "y", "y2", "p"].includes(String(cached.view))) setView(cached.view as "z" | "z2" | "x" | "y" | "y2" | "p");
+ if (typeof cached.zoom === "number" && Number.isFinite(cached.zoom)) setZoom(Math.max(.5, Math.min(2, cached.zoom)));
+ if (typeof cached.rotateMode === "boolean") setRotateMode(cached.rotateMode);
+ if (["machine", "relative"].includes(String(cached.coordType))) setCoordType(cached.coordType as "machine" | "relative");
+ if (["actual", "commanded"].includes(String(cached.displayType))) setDisplayType(cached.displayType as "actual" | "commanded");
+ if (typeof cached.metric === "boolean") setMetric(cached.metric);
+ if (cached.layers && typeof cached.layers === "object") {
+ const savedLayers = cached.layers as Record;
+ setLayers((current) => {
+ const next = { ...current };
+ for (const key of Object.keys(current) as Array) if (typeof savedLayers[key] === "boolean") next[key] = savedLayers[key] as boolean;
+ return next;
+ });
+ }
+ }
+ const bundle = await loadSimulationCase({ path: DEFAULT_CASE, baseUrl: "." });
+ if (bundle.status !== "ready") throw new Error(`xyzac_case_blocked:${bundle.missing.join(",")}`);
+ const source = bundle.files?.[PROGRAM_PATH];
+ if (typeof source !== "string" || !source.trim()) throw new Error("xyzac_demo_program_missing");
+ const normalizedSource = normalizeProgramSource(source);
+ setProgramTextState(normalizedSource); setProgramPath(PROGRAM_PATH);
+ const configuration = toWorkerConfiguration(bundle);
+ await operationController.execute("LOAD_WORKSPACE", { root_path: "/workspace", configuration }, { state: {}, gate: false });
+ await operationController.execute("PROGRAM_LOAD", { source: normalizedSource, path: PROGRAM_PATH }, { state: {}, gate: false });
+ if (typeof cachedProgram?.source === "string" && cachedProgram.source.trim() && cachedProgram.source.length <= 900000 && typeof cachedProgram.path === "string" && cachedProgram.path.length <= 512 && (cachedProgram.source !== normalizedSource || cachedProgram.path !== PROGRAM_PATH)) {
+ const cachedSource = normalizeProgramSource(cachedProgram.source);
+ setProgramTextState(cachedSource); setProgramPath(cachedProgram.path);
+ await operationController.execute("PROGRAM_LOAD", { source: cachedSource, path: cachedProgram.path }, { state: {}, gate: false });
+ }
+ uiCacheHydrated.current = true;
+ setOperationTrace(operationController.exportTrace());
+ setCaseStatus(`${DEFAULT_CASE} + ${cachedProgram?.path ?? PROGRAM_PATH} loaded`);
+ }).catch((error: Error) => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`Error: ${error.message}`); setCaseStatus(`blocked: ${error.message}`); });
+ return () => { unsubscribe(); if (runTimer.current) clearInterval(runTimer.current); if (jogTimer.current) clearInterval(jogTimer.current); if (previewProgressTimer.current) clearInterval(previewProgressTimer.current); if (feedOverrideTimer.current) clearTimeout(feedOverrideTimer.current); if (uiCacheSaveTimer.current) clearTimeout(uiCacheSaveTimer.current); client.close(); };
+ }, [client, operationController]);
const axes = state.snapshot?.motion?.axes ?? [];
const taskRecord = state.task ?? {};
const axisState = { task: taskRecord, program: state.program ?? {}, motion: state.snapshot?.motion ?? {}, devices: state.devices ?? {}, xyzac: state.snapshot?.xyzac ?? {}, gates: state.gates };
@@ -493,7 +542,31 @@ export function App() {
const allHomed = homedAxes.length >= 5 && homedAxes.slice(0, 5).every(Boolean);
const xyzacCommand = (kind: string, payload: Record = {}, action?: string) => { void dispatch(kind, payload, true, action ?? null).then(() => { if (kind === "VISUAL_CLEAR") setPlotCleared(true); }).catch(() => undefined); };
const position = xyzacView.pose ? `X${Number(xyzacView.pose.x).toFixed(3)} Y${Number(xyzacView.pose.y).toFixed(3)} Z${Number(xyzacView.pose.z).toFixed(3)} A${Number(xyzacView.pose.a).toFixed(3)} C${Number(xyzacView.pose.c).toFixed(3)}` : "WASM state pending";
- return
+ useEffect(() => {
+ if (!uiCacheHydrated.current || !wasmSnapshotReady) return;
+ if (uiCacheSaveTimer.current) clearTimeout(uiCacheSaveTimer.current);
+ const cacheState = {
+ version: 1,
+ selectedAxis,
+ leftTab,
+ rightTab,
+ view,
+ zoom,
+ rotateMode,
+ coordType,
+ displayType,
+ metric,
+ layers,
+ casePath,
+ program: { path: programPath, source: programText },
+ };
+ uiCacheSaveTimer.current = setTimeout(() => {
+ uiCacheSaveTimer.current = null;
+ void client.saveUiState(cacheState, UI_CACHE_KEY).catch(() => undefined);
+ }, 500);
+ return () => { if (uiCacheSaveTimer.current) { clearTimeout(uiCacheSaveTimer.current); uiCacheSaveTimer.current = null; } };
+ }, [client, wasmSnapshotReady, selectedAxis, leftTab, rightTab, view, zoom, rotateMode, coordType, displayType, metric, layers, casePath, programPath, programText]);
+ return
AXISLinuxCNC Web/WASM {state.status.toUpperCase()}{taskState}
{ setProgramText(DEFAULT_PROGRAM); setProgramPath(PROGRAM_PATH); setPlotCleared(false); }} /> execute("PROGRAM_STEP")} /> execute(programPaused ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} /> setZoom((value) => Math.min(2, value + .2))} /> setZoom((value) => Math.max(.5, value - .2))} /> { setView("z"); setRightTab("preview"); }} /> { setView("z2"); setRightTab("preview"); }} /> { setView("x"); setRightTab("preview"); }} /> { setView("y"); setRightTab("preview"); }} /> { setView("y2"); setRightTab("preview"); }} /> { setView("p"); setRightTab("preview"); }} /> setRotateMode((value) => !value)} /> xyzacCommand("VISUAL_CLEAR", {}, "vismachClear")} />
diff --git a/web/react/src/legacy.d.ts b/web/react/src/legacy.d.ts
index c3f35f8..82b3bbb 100644
--- a/web/react/src/legacy.d.ts
+++ b/web/react/src/legacy.d.ts
@@ -4,6 +4,7 @@ declare module "*.mjs" {
export function toWorkerConfiguration(bundle: unknown): Record;
export class SimSession { contextId: string; sessionId: string; constructor(core: unknown); accept(message: unknown): unknown; }
export class XyzacSession extends SimSession { authority(): Record; }
+ export function handleWorkerMessage(session: unknown, input: unknown): Promise;
export class AxisOperationController {
constructor(client: { send(kind: string, payload?: unknown, operationId?: string): Promise });
execute(kind: string, payload?: Record, options?: { state?: unknown; action?: string | null; gate?: boolean }): Promise<{ operationId: string; response: unknown; gate: { enabled: boolean; reason: string } }>;
diff --git a/web/react/src/protocol.ts b/web/react/src/protocol.ts
index 19ffc1a..1a9823c 100644
--- a/web/react/src/protocol.ts
+++ b/web/react/src/protocol.ts
@@ -4,7 +4,7 @@ export const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1" as const;
export type MessageKind =
| "HELLO" | "CAPABILITIES" | "SESSION_ATTACH" | "SESSION_READY"
| "LOAD_WORKSPACE" | "COMMAND_RESULT" | "STATE_SNAPSHOT" | "EVENT"
- | "ALARM" | "RECOVERY_STATUS" | "ERROR";
+ | "ALARM" | "RECOVERY_STATUS" | "ERROR" | "UI_STATE_RESULT";
export type MessageEnvelope = {
schema_version: typeof PROTOCOL_SCHEMA;
diff --git a/web/react/src/sim-worker-entry.ts b/web/react/src/sim-worker-entry.ts
index a6fad16..6d8201d 100644
--- a/web/react/src/sim-worker-entry.ts
+++ b/web/react/src/sim-worker-entry.ts
@@ -1,18 +1,21 @@
import createModule from "../../../build-wasm/linuxcnc-sim-core.js";
import { XyzacSession as SimSession } from "../../worker/xyzac-session.mjs";
+import { handleWorkerMessage } from "../../worker/opfs-worker-bridge.mjs";
// The boot event is a transport diagnostic; it carries no simulation state.
self.postMessage({ schema_version: 1, kind: "WORKER_BOOT", context_id: "linuxcnc-2.10.0-pre0-wasm-m1-py-c1", session_id: "boot", seq: 0, payload: { status: "booting", runtime: "react-vite-worker" } });
let session: SimSession | null = null;
const queue: MessageEvent[] = [];
-const handle = (event: MessageEvent) => {
+let serial = Promise.resolve();
+const process = async (event: MessageEvent) => {
try {
if (!session) { queue.push(event); return; }
- self.postMessage(session.accept(event.data));
+ self.postMessage(await handleWorkerMessage(session, event.data));
} catch (error) {
self.postMessage({ schema_version: 1, kind: "ERROR", context_id: session?.contextId ?? "linuxcnc-2.10.0-pre0-wasm-m1-py-c1", session_id: session?.sessionId ?? "boot", seq: event.data?.seq ?? 0, payload: { status: "error", code: "worker_exception", message_key: error instanceof Error ? error.message : String(error), retryable: false } });
}
};
+const handle = (event: MessageEvent) => { serial = serial.then(() => process(event)); };
self.onmessage = handle;
createModule().then((core) => {
session = new (SimSession as any)(core, { planner: "linuxcnc-tp" });
diff --git a/web/react/src/store.ts b/web/react/src/store.ts
index 6b70922..7822394 100644
--- a/web/react/src/store.ts
+++ b/web/react/src/store.ts
@@ -202,10 +202,11 @@ function projection(result: Record, message: MessageEnvelope, p
...(planner?.active ? [planner.active] : []),
].filter((segment): segment is NonNullable & { id: number; start: Record; end: Record } => Number.isSafeInteger(segment.id) && Boolean(segment.start) && Boolean(segment.end));
const command = String(result.command ?? "");
- const resetPath = command === "PROGRAM_LOAD" || command === "PROGRAM_RESTART";
+ const visualClear = command === "VISUAL_CLEAR";
+ const resetPath = visualClear || command === "PROGRAM_LOAD" || command === "PROGRAM_RESTART";
const known = resetPath ? new Set() : new Set(previous.toolpath.map((segment) => segment.id));
const nextToolpath = resetPath
- ? previewSegments.map((segment, index) => ({ ...segment, id: index + 1, source_line: typeof segment.source_line === "number" ? segment.source_line : null, start: segment.start as Record, end: segment.end as Record, active: false }))
+ ? visualClear ? [] : previewSegments.map((segment, index) => ({ ...segment, id: index + 1, source_line: typeof segment.source_line === "number" ? segment.source_line : null, start: segment.start as Record, end: segment.end as Record, active: false }))
: previous.toolpath.map((segment) => ({ ...segment, start: { ...segment.start }, end: { ...segment.end } }));
for (const segment of plannerSegments) {
if (!known.has(segment.id)) nextToolpath.push({ id: segment.id, source_line: segment.source_line, start: segment.start, end: segment.end, active: segment.id === planner?.active?.id });
diff --git a/web/react/src/test_store_visual_clear.mjs b/web/react/src/test_store_visual_clear.mjs
new file mode 100644
index 0000000..d733dcc
--- /dev/null
+++ b/web/react/src/test_store_visual_clear.mjs
@@ -0,0 +1,22 @@
+import assert from "node:assert/strict";
+import { reduceFrontendState, INITIAL_STATE } from "./store.ts";
+
+const base = {
+ ...INITIAL_STATE,
+ snapshot: {
+ schema: "axis-panel-snapshot.v1",
+ simulation_tick: 1,
+ state_hash: "fnv1a64:base",
+ task: {}, motion: {}, wasm_motion: {}, gates: {},
+ visual: { schema: "axis-visual.v1", pose: { x: 0 }, joints: [0, 0, 0, 0, 0], toolpath: [{ id: 1, start: { x: 0 }, end: { x: 1 } }], source: "wasm_canonical_motion" },
+ authoritative: {}, motion_authority: "wasm", task_authority: "wasm_task_canonical",
+ },
+ toolpath: [{ id: 1, start: { x: 0 }, end: { x: 1 } }],
+};
+const result = reduceFrontendState(base, {
+ schema_version: 1, kind: "COMMAND_RESULT", context_id: base.contextId, session_id: base.sessionId, seq: 1,
+ payload: { status: "verified", command: "VISUAL_CLEAR", visual_clear: true, panel_snapshot: undefined },
+});
+assert.deepEqual(result.toolpath, []);
+assert.deepEqual(result.snapshot?.visual?.toolpath, []);
+console.log(JSON.stringify({ schema: "store-visual-clear.v1", status: "verified", stale_toolpath_removed: true }));
diff --git a/web/react/src/worker-client.ts b/web/react/src/worker-client.ts
index 493f51e..8646672 100644
--- a/web/react/src/worker-client.ts
+++ b/web/react/src/worker-client.ts
@@ -1,5 +1,4 @@
import { CONTEXT_ID, PROTOCOL_SCHEMA, isEnvelope, type MessageEnvelope } from "./protocol";
-import { attachPersistedPowerOffCheckpoint, clearPersistedPowerOffCheckpoint, persistPowerOffCheckpoint } from "./power-off-checkpoint-storage";
type Listener = (message: MessageEnvelope) => void;
@@ -20,6 +19,7 @@ export class ReactWorkerClient {
private readonly contextId: string;
private readonly sessionId: string;
private sequence = 0;
+ private uiSequence = 2000000000;
private listeners = new Set();
private pending = new Map void; reject: (error: Error) => void }>();
private readyPromise: Promise | null = null;
@@ -32,10 +32,8 @@ export class ReactWorkerClient {
this.sessionId = sessionId;
this.worker.onmessage = (event: MessageEvent) => {
if (!isEnvelope(event.data)) return;
- const persistence = persistPowerOffCheckpoint(event.data);
- if (persistence && event.data.payload && typeof event.data.payload === "object") Object.assign(event.data.payload, { recovery_persistence: persistence });
- clearPersistedPowerOffCheckpoint(event.data);
- for (const listener of this.listeners) listener(event.data);
+ const uiStateResponse = event.data.kind === "UI_STATE_RESULT";
+ if (!uiStateResponse) for (const listener of this.listeners) listener(event.data);
const pending = this.pending.get(event.data.seq);
if (pending) {
this.pending.delete(event.data.seq);
@@ -61,11 +59,20 @@ export class ReactWorkerClient {
subscribe(listener: Listener): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); }
send(kind: string, payload: unknown = {}, operationId?: string): Promise {
- const nextPayload = kind === "LOAD_WORKSPACE" ? attachPersistedPowerOffCheckpoint(payload) : payload;
- const message: MessageEnvelope = { schema_version: PROTOCOL_SCHEMA, kind, context_id: this.contextId, session_id: this.sessionId, seq: ++this.sequence, ...(operationId ? { operation_id: operationId } : {}), payload: nextPayload };
+ const message: MessageEnvelope = { schema_version: PROTOCOL_SCHEMA, kind, context_id: this.contextId, session_id: this.sessionId, seq: ++this.sequence, ...(operationId ? { operation_id: operationId } : {}), payload };
return new Promise((resolve, reject) => { this.pending.set(message.seq, { resolve, reject }); this.worker.postMessage(message); });
}
+ private sendUiState(kind: "UI_STATE_SAVE" | "UI_STATE_LOAD" | "UI_STATE_CLEAR", payload: Record): Promise {
+ const seq = ++this.uiSequence;
+ const message: MessageEnvelope = { schema_version: PROTOCOL_SCHEMA, kind, context_id: this.contextId, session_id: this.sessionId, seq, operation_id: `ui-cache-${seq}`, payload };
+ return new Promise((resolve, reject) => { this.pending.set(seq, { resolve, reject }); this.worker.postMessage(message); });
+ }
+
+ saveUiState(state: Record, key = "xyzac-trt"): Promise { return this.sendUiState("UI_STATE_SAVE", { key, state }); }
+ loadUiState(key = "xyzac-trt"): Promise { return this.sendUiState("UI_STATE_LOAD", { key }); }
+ clearUiState(key = "xyzac-trt"): Promise { return this.sendUiState("UI_STATE_CLEAR", { key }); }
+
hello(): Promise { return this.send("HELLO", { supported_schema_versions: [PROTOCOL_SCHEMA], requested_features: ["snapshot", "trajectory"] }); }
attach(): Promise { return this.send("SESSION_ATTACH", {}, "react-session-attach"); }
ensureSession(): Promise {
diff --git a/web/worker/capabilities.mjs b/web/worker/capabilities.mjs
index 4a408e5..0279cfe 100644
--- a/web/worker/capabilities.mjs
+++ b/web/worker/capabilities.mjs
@@ -34,7 +34,14 @@ export function capabilities(contextId) {
event: 1,
workspace: 1,
commands: [...COMMANDS],
+ worker_storage_commands: ["UI_STATE_SAVE", "UI_STATE_LOAD", "UI_STATE_CLEAR"],
streams: ["STATE_SNAPSHOT", "EVENT", "ALARM"],
+ storage: {
+ owner: "dedicated-worker",
+ ui_state: "opfs_temp_json_v1",
+ power_off_checkpoint: "opfs_temp_json_v1",
+ authority: "linuxcnc-wasm-remains-authoritative",
+ },
limits: { ...LIMITS },
exclusions: EXCLUSIONS.map((entry) => ({ ...entry })),
};
diff --git a/web/worker/opfs-ui-state.mjs b/web/worker/opfs-ui-state.mjs
new file mode 100644
index 0000000..939ec7b
--- /dev/null
+++ b/web/worker/opfs-ui-state.mjs
@@ -0,0 +1,130 @@
+import { fnv1a64 } from "./replay.mjs";
+
+export const OPFS_UI_STATE_SCHEMA = "linuxcnc-ui-temp-json.v1";
+export const OPFS_UI_STATE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
+const ROOT_DIRECTORY = "linuxcnc-web-wasm";
+const STATE_DIRECTORY = "ui-state";
+const MAX_BYTES = 1024 * 1024;
+const SLOT_NAMES = ["state-a.json", "state-b.json"];
+const MANIFEST_NAME = "manifest.json";
+
+function validObject(value) {
+ return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+function safeName(value) {
+ return String(value ?? "default").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "default";
+}
+
+function finiteTree(value) {
+ if (typeof value === "number") return Number.isFinite(value);
+ if (Array.isArray(value)) return value.every(finiteTree);
+ if (validObject(value)) return Object.values(value).every(finiteTree);
+ return true;
+}
+
+function record(contextId, key, generation, state) {
+ const savedAtMs = Date.now();
+ const unsigned = { schema: OPFS_UI_STATE_SCHEMA, context_id: contextId, key, generation, saved_at_ms: savedAtMs, state };
+ return { ...unsigned, state_hash: fnv1a64(state), record_hash: fnv1a64(unsigned) };
+}
+
+function validRecord(value, contextId, key) {
+ if (!validObject(value) || value.schema !== OPFS_UI_STATE_SCHEMA || value.context_id !== contextId || value.key !== key || !Number.isSafeInteger(value.generation) || value.generation < 1 || !Number.isSafeInteger(value.saved_at_ms) || value.saved_at_ms < 0 || Date.now() - value.saved_at_ms > OPFS_UI_STATE_TTL_MS || !validObject(value.state) || !finiteTree(value.state)) return false;
+ const unsigned = { schema: value.schema, context_id: value.context_id, key: value.key, generation: value.generation, saved_at_ms: value.saved_at_ms, state: value.state };
+ return value.state_hash === fnv1a64(value.state) && value.record_hash === fnv1a64(unsigned);
+}
+
+async function handles(key, create = false, storage = globalThis.navigator?.storage) {
+ if (!storage || typeof storage.getDirectory !== "function") return { status: "blocked", code: "opfs_unavailable", reason: "storage_get_directory_missing" };
+ const root = await storage.getDirectory();
+ const rootDirectory = await root.getDirectoryHandle(ROOT_DIRECTORY, { create });
+ const directory = await rootDirectory.getDirectoryHandle(STATE_DIRECTORY, { create });
+ const project = await directory.getDirectoryHandle(safeName(key), { create });
+ return { status: "verified", rootDirectory, directory, project };
+}
+
+function emptyResult(contextId, key) {
+ return { schema: OPFS_UI_STATE_SCHEMA, status: "empty", storage: "opfs", context_id: contextId, key };
+}
+
+async function readFile(project, name) {
+ try {
+ const file = await project.getFileHandle(name, { create: false });
+ const text = await (await file.getFile()).text();
+ if (new TextEncoder().encode(text).byteLength > MAX_BYTES) return null;
+ return JSON.parse(text);
+ } catch {
+ return null;
+ }
+}
+
+async function writeFile(project, name, value) {
+ const file = await project.getFileHandle(name, { create: true });
+ const bytes = new TextEncoder().encode(JSON.stringify(value));
+ if (bytes.byteLength > MAX_BYTES) return { status: "blocked", code: "opfs_ui_state_quota", bytes: bytes.byteLength };
+ if (typeof file.createSyncAccessHandle === "function") {
+ const access = await file.createSyncAccessHandle();
+ try {
+ access.truncate(0);
+ access.write(bytes, { at: 0 });
+ access.flush();
+ } finally { access.close(); }
+ } else if (typeof file.createWritable === "function") {
+ const writable = await file.createWritable();
+ try { await writable.write(bytes); await writable.close(); } catch (error) { try { await writable.abort(); } catch {} throw error; }
+ } else return { status: "blocked", code: "opfs_write_handle_unavailable" };
+ return { status: "verified", bytes: bytes.byteLength };
+}
+
+export async function loadUiState({ contextId, key = "xyzac-trt", storage } = {}) {
+ const resolved = await handles(key, false, storage).catch((error) => ({ status: "blocked", code: "opfs_open_failed", reason: error?.name ?? "unknown" }));
+ if (resolved.status === "blocked" && resolved.reason === "NotFoundError") return emptyResult(contextId, key);
+ if (resolved.status !== "verified") return { schema: OPFS_UI_STATE_SCHEMA, ...resolved, context_id: contextId, key };
+ const candidates = [];
+ for (const name of SLOT_NAMES) {
+ const value = await readFile(resolved.project, name);
+ if (validRecord(value, contextId, key)) candidates.push(value);
+ }
+ if (!candidates.length) return { schema: OPFS_UI_STATE_SCHEMA, status: "empty", storage: "opfs", context_id: contextId, key };
+ candidates.sort((left, right) => right.generation - left.generation);
+ const selected = candidates[0];
+ return { schema: OPFS_UI_STATE_SCHEMA, status: "verified", storage: "opfs", context_id: contextId, key, generation: selected.generation, state: selected.state, state_hash: selected.state_hash };
+}
+
+export async function saveUiState({ contextId, key = "xyzac-trt", state, storage } = {}) {
+ if (!validObject(state) || !finiteTree(state)) return { schema: OPFS_UI_STATE_SCHEMA, status: "blocked", code: "opfs_ui_state_invalid", context_id: contextId, key };
+ const resolved = await handles(key, true, storage).catch((error) => ({ status: "blocked", code: "opfs_open_failed", reason: error?.name ?? "unknown" }));
+ if (resolved.status !== "verified") return { schema: OPFS_UI_STATE_SCHEMA, ...resolved, context_id: contextId, key };
+ const previous = await loadUiState({ contextId, key, storage });
+ const generation = Number.isSafeInteger(previous.generation) ? previous.generation + 1 : 1;
+ const slot = SLOT_NAMES[generation % SLOT_NAMES.length];
+ const next = record(contextId, key, generation, structuredClone(state));
+ const written = await writeFile(resolved.project, slot, next);
+ if (written.status !== "verified") return { schema: OPFS_UI_STATE_SCHEMA, ...written, context_id: contextId, key };
+ const manifest = { schema: OPFS_UI_STATE_SCHEMA, context_id: contextId, key, generation, slot, state_hash: next.state_hash, manifest_hash: fnv1a64({ schema: OPFS_UI_STATE_SCHEMA, context_id: contextId, key, generation, slot, state_hash: next.state_hash }) };
+ const manifestWrite = await writeFile(resolved.project, MANIFEST_NAME, manifest);
+ if (manifestWrite.status !== "verified") return { schema: OPFS_UI_STATE_SCHEMA, ...manifestWrite, context_id: contextId, key };
+ return { schema: OPFS_UI_STATE_SCHEMA, status: "verified", storage: "opfs", context_id: contextId, key, generation, slot, state_hash: next.state_hash, bytes: written.bytes };
+}
+
+export async function clearUiState({ contextId, key = "xyzac-trt", storage } = {}) {
+ const resolved = await handles(key, false, storage).catch((error) => ({ status: "blocked", code: "opfs_open_failed", reason: error?.name ?? "unknown" }));
+ if (resolved.status !== "verified") return { schema: OPFS_UI_STATE_SCHEMA, ...resolved, context_id: contextId, key };
+ for (const name of [...SLOT_NAMES, MANIFEST_NAME]) { try { await resolved.project.removeEntry(name); } catch {} }
+ return { schema: OPFS_UI_STATE_SCHEMA, status: "verified", storage: "opfs", context_id: contextId, key, cleared: true };
+}
+
+export async function savePowerOffCheckpoint({ contextId, checkpoint, key = "xyzac-trt", storage } = {}) {
+ return saveUiState({ contextId, key: `${key}-power-off`, state: { kind: "power_off_checkpoint", checkpoint }, storage });
+}
+
+export async function loadPowerOffCheckpoint({ contextId, key = "xyzac-trt", storage } = {}) {
+ const result = await loadUiState({ contextId, key: `${key}-power-off`, storage });
+ if (result.status !== "verified" || result.state?.kind !== "power_off_checkpoint" || !result.state.checkpoint) return { ...result, checkpoint: null };
+ return { ...result, checkpoint: result.state.checkpoint };
+}
+
+export async function clearPowerOffCheckpoint({ contextId, key = "xyzac-trt", storage } = {}) {
+ return clearUiState({ contextId, key: `${key}-power-off`, storage });
+}
diff --git a/web/worker/opfs-worker-bridge.mjs b/web/worker/opfs-worker-bridge.mjs
new file mode 100644
index 0000000..35d0ec0
--- /dev/null
+++ b/web/worker/opfs-worker-bridge.mjs
@@ -0,0 +1,64 @@
+import {
+ clearPowerOffCheckpoint,
+ clearUiState,
+ loadPowerOffCheckpoint,
+ loadUiState,
+ savePowerOffCheckpoint,
+ saveUiState,
+} from "./opfs-ui-state.mjs";
+
+const UI_COMMANDS = new Set(["UI_STATE_SAVE", "UI_STATE_LOAD", "UI_STATE_CLEAR"]);
+
+function object(value) {
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
+}
+
+function resultEnvelope(input, payload) {
+ return {
+ schema_version: 1,
+ kind: "UI_STATE_RESULT",
+ context_id: input.context_id,
+ session_id: input.session_id,
+ seq: input.seq,
+ operation_id: input.operation_id ?? null,
+ payload: { ...payload, command: input.kind, operation_id: input.operation_id ?? null },
+ };
+}
+
+export function isUiStateCommand(input) {
+ return UI_COMMANDS.has(input?.kind);
+}
+
+export async function handleWorkerMessage(session, input) {
+ if (isUiStateCommand(input)) {
+ // UI cache is a Worker storage service, not a LinuxCNC command. It never
+ // consumes the WASM protocol sequence or changes task/motion authority.
+ const payload = object(input.payload);
+ const options = { contextId: input.context_id, key: payload.key ?? "xyzac-trt" };
+ const result = input.kind === "UI_STATE_SAVE"
+ ? await saveUiState({ ...options, state: payload.state })
+ : input.kind === "UI_STATE_LOAD"
+ ? await loadUiState(options)
+ : await clearUiState(options);
+ return resultEnvelope(input, result);
+ }
+
+ let request = input;
+ if (input?.kind === "LOAD_WORKSPACE" && !object(input.payload).recovery_checkpoint) {
+ const persisted = await loadPowerOffCheckpoint({ contextId: input.context_id, key: "xyzac-trt" });
+ if (persisted.status === "verified" && persisted.checkpoint) request = { ...input, payload: { ...object(input.payload), recovery_checkpoint: persisted.checkpoint } };
+ }
+ const response = session.accept(request);
+ const payload = object(response?.payload);
+ if (input?.kind === "POWER" && response?.kind === "COMMAND_RESULT" && payload.status === "verified") {
+ if (payload.active === false && payload.recovery_checkpoint) {
+ const persisted = await savePowerOffCheckpoint({ contextId: input.context_id, checkpoint: payload.recovery_checkpoint, key: "xyzac-trt" });
+ payload.recovery_persistence = persisted;
+ } else if (payload.active === true) {
+ payload.recovery_persistence = await clearPowerOffCheckpoint({ contextId: input.context_id, key: "xyzac-trt" });
+ }
+ }
+ return response;
+}
+
+export { UI_COMMANDS };
diff --git a/web/worker/sim-worker.mjs b/web/worker/sim-worker.mjs
index f729dc2..bab8a00 100644
--- a/web/worker/sim-worker.mjs
+++ b/web/worker/sim-worker.mjs
@@ -1,12 +1,14 @@
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { XyzacSession } from "./xyzac-session.mjs";
+import { handleWorkerMessage } from "./opfs-worker-bridge.mjs";
const core = await createModule();
const session = new XyzacSession(core, { planner: "linuxcnc-tp" });
-self.onmessage = (event) => {
+let serial = Promise.resolve();
+self.onmessage = (event) => { serial = serial.then(async () => {
try {
- self.postMessage(session.accept(event.data));
+ self.postMessage(await handleWorkerMessage(session, event.data));
} catch (error) {
self.postMessage({
schema_version: 1,
@@ -17,4 +19,5 @@ self.onmessage = (event) => {
payload: { status: "error", code: "worker_exception", message_key: String(error?.message ?? error), retryable: false },
});
}
+});
};
diff --git a/web/worker/test_opfs_ui_state.mjs b/web/worker/test_opfs_ui_state.mjs
new file mode 100644
index 0000000..1a565f4
--- /dev/null
+++ b/web/worker/test_opfs_ui_state.mjs
@@ -0,0 +1,65 @@
+import assert from "node:assert/strict";
+import { clearUiState, loadPowerOffCheckpoint, loadUiState, savePowerOffCheckpoint, saveUiState } from "./opfs-ui-state.mjs";
+
+class FakeAccess {
+ constructor(file) { this.file = file; }
+ truncate(size) { this.file.bytes = this.file.bytes.slice(0, size); }
+ write(bytes, { at = 0 } = {}) { const next = new Uint8Array(Math.max(this.file.bytes.length, at + bytes.byteLength)); next.set(this.file.bytes); next.set(bytes, at); this.file.bytes = next; return bytes.byteLength; }
+ flush() {}
+ close() {}
+}
+
+class FakeFile {
+ constructor() { this.bytes = new Uint8Array(); }
+ async getFile() { const bytes = this.bytes; return { text: async () => new TextDecoder().decode(bytes) }; }
+ async createSyncAccessHandle() { return new FakeAccess(this); }
+}
+
+class FakeDirectory {
+ constructor() { this.directories = new Map(); this.files = new Map(); }
+ async getDirectoryHandle(name, { create = false } = {}) {
+ if (!this.directories.has(name)) {
+ if (!create) { const error = new Error("not found"); error.name = "NotFoundError"; throw error; }
+ this.directories.set(name, new FakeDirectory());
+ }
+ return this.directories.get(name);
+ }
+ async getFileHandle(name, { create = false } = {}) {
+ if (!this.files.has(name)) {
+ if (!create) { const error = new Error("not found"); error.name = "NotFoundError"; throw error; }
+ this.files.set(name, new FakeFile());
+ }
+ return this.files.get(name);
+ }
+ async removeEntry(name) { if (!this.files.delete(name) && !this.directories.delete(name)) { const error = new Error("not found"); error.name = "NotFoundError"; throw error; } }
+}
+
+class FakeStorage {
+ constructor() { this.root = new FakeDirectory(); }
+ async getDirectory() { return this.root; }
+}
+
+const storage = new FakeStorage();
+const contextId = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
+const first = await saveUiState({ storage, contextId, key: "xyzac-trt", state: { view: "p", zoom: 1, program: { path: "demo.ngc", source: "G21\nM2\n" } } });
+assert.equal(first.status, "verified");
+assert.equal(first.storage, "opfs");
+assert.equal((await loadUiState({ storage, contextId, key: "xyzac-trt" })).state.view, "p");
+const second = await saveUiState({ storage, contextId, key: "xyzac-trt", state: { view: "z", zoom: 1.2 } });
+assert.equal(second.generation, 2);
+assert.equal((await loadUiState({ storage, contextId, key: "xyzac-trt" })).state.view, "z");
+
+const root = storage.root.directories.get("linuxcnc-web-wasm");
+const project = root.directories.get("ui-state").directories.get("xyzac-trt");
+project.files.get("state-a.json").bytes = new TextEncoder().encode("{corrupt");
+const fallback = await loadUiState({ storage, contextId, key: "xyzac-trt" });
+assert.equal(fallback.status, "verified");
+assert.equal(fallback.state.view, "p");
+
+const checkpoint = { schema: "xyzac-power-off-checkpoint.v1", simulation_tick: 4, state: { program: { source_text: "G21\nM2\n" } }, state_hash: "test" };
+assert.equal((await savePowerOffCheckpoint({ storage, contextId, checkpoint })).status, "verified");
+assert.deepEqual((await loadPowerOffCheckpoint({ storage, contextId })).checkpoint, checkpoint);
+assert.equal((await clearUiState({ storage, contextId, key: "xyzac-trt" })).status, "verified");
+assert.equal((await loadUiState({ storage, contextId, key: "xyzac-trt" })).status, "empty");
+
+console.log(JSON.stringify({ schema: "opfs-ui-state.v1", status: "verified", storage: "dedicated_worker_fake_opfs", fallback_after_corruption: true, power_off_checkpoint: true }));
diff --git a/web/worker/test_opfs_worker_bridge.mjs b/web/worker/test_opfs_worker_bridge.mjs
new file mode 100644
index 0000000..e1a7f93
--- /dev/null
+++ b/web/worker/test_opfs_worker_bridge.mjs
@@ -0,0 +1,52 @@
+import assert from "node:assert/strict";
+import { handleWorkerMessage } from "./opfs-worker-bridge.mjs";
+import { loadUiState } from "./opfs-ui-state.mjs";
+
+class File {
+ constructor() { this.bytes = new Uint8Array(); }
+ async getFile() { return { text: async () => new TextDecoder().decode(this.bytes) }; }
+ async createSyncAccessHandle() {
+ const file = this;
+ return {
+ truncate(size) { file.bytes = file.bytes.slice(0, size); },
+ write(bytes, { at = 0 } = {}) { const next = new Uint8Array(Math.max(file.bytes.length, at + bytes.byteLength)); next.set(file.bytes); next.set(bytes, at); file.bytes = next; return bytes.byteLength; },
+ flush() {}, close() {},
+ };
+ }
+}
+
+class Directory {
+ constructor() { this.files = new Map(); this.directories = new Map(); }
+ async getDirectoryHandle(name, { create = false } = {}) { if (!this.directories.has(name)) { if (!create) throw Object.assign(new Error("missing"), { name: "NotFoundError" }); this.directories.set(name, new Directory()); } return this.directories.get(name); }
+ async getFileHandle(name, { create = false } = {}) { if (!this.files.has(name)) { if (!create) throw Object.assign(new Error("missing"), { name: "NotFoundError" }); this.files.set(name, new File()); } return this.files.get(name); }
+ async removeEntry(name) { this.files.delete(name); this.directories.delete(name); }
+}
+
+const storage = { root: new Directory(), async getDirectory() { return this.root; } };
+const previousNavigator = globalThis.navigator;
+Object.defineProperty(globalThis, "navigator", { configurable: true, value: { storage } });
+const contextId = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
+const base = { schema_version: 1, context_id: contextId, session_id: "bridge-test" };
+let accepted = 0;
+let lastAccepted = null;
+const session = { accept(input) { accepted += 1; lastAccepted = input; return { schema_version: 1, kind: "COMMAND_RESULT", context_id: input.context_id, session_id: input.session_id, seq: input.seq, payload: { status: "verified", command: input.kind, active: input.payload?.active, recovery_checkpoint: input.payload?.checkpoint ?? undefined } }; } };
+
+const saved = await handleWorkerMessage(session, { ...base, kind: "UI_STATE_SAVE", seq: 2000000001, operation_id: "ui-save", payload: { key: "xyzac-trt", state: { view: "p", zoom: 1 } } });
+assert.equal(saved.kind, "UI_STATE_RESULT");
+assert.equal(saved.payload.storage, "opfs");
+assert.equal(accepted, 0);
+const loaded = await handleWorkerMessage(session, { ...base, kind: "UI_STATE_LOAD", seq: 2000000002, operation_id: "ui-load", payload: { key: "xyzac-trt" } });
+assert.equal(loaded.payload.state.view, "p");
+assert.equal(accepted, 0);
+
+const checkpoint = { schema: "xyzac-power-off-checkpoint.v1", simulation_tick: 3, state: { program: { source_text: "G21\nM2\n" } }, state_hash: "test" };
+const powerOff = await handleWorkerMessage(session, { ...base, kind: "POWER", seq: 1, operation_id: "power-off", payload: { active: false, checkpoint } });
+assert.equal(powerOff.payload.recovery_persistence.storage, "opfs");
+await handleWorkerMessage(session, { ...base, kind: "LOAD_WORKSPACE", seq: 2, operation_id: "load", payload: { root_path: "/workspace" } });
+assert.deepEqual(lastAccepted.payload.recovery_checkpoint, checkpoint);
+assert.equal(accepted, 2);
+assert.equal((await loadUiState({ contextId, key: "xyzac-trt-power-off" })).status, "verified");
+
+if (previousNavigator === undefined) delete globalThis.navigator;
+else Object.defineProperty(globalThis, "navigator", { configurable: true, value: previousNavigator });
+console.log(JSON.stringify({ schema: "opfs-worker-bridge.v1", status: "verified", ui_commands_do_not_consume_wasm_sequence: true, poweroff_checkpoint_roundtrip: true }));
diff --git a/web/worker/test_xyzac_protocol_react.mjs b/web/worker/test_xyzac_protocol_react.mjs
index afd466d..91ed5fd 100644
--- a/web/worker/test_xyzac_protocol_react.mjs
+++ b/web/worker/test_xyzac_protocol_react.mjs
@@ -67,6 +67,8 @@ assert.equal(sent.length, 2);
assert.deepEqual(controller.exportTrace().map((item) => item.phase), ["pending", "result", "pending", "result"]);
const appSource = await readFile(new URL("../react/src/App.tsx", import.meta.url), "utf8");
+const workerClientSource = await readFile(new URL("../react/src/worker-client.ts", import.meta.url), "utf8");
+const opfsBridgeSource = await readFile(new URL("./opfs-worker-bridge.mjs", import.meta.url), "utf8");
const ironLaw = await readFile(new URL("../../docs/linuxcnc-axis-web-iron-law.md", import.meta.url), "utf8");
for (const source of [ironLaw, appSource]) assert.ok(source.includes("linuxcnc"), "axis_source_authority");
for (const symbol of ["manual_ok()", "PROGRAM_RUN/PAUSE/RESUME/ABORT", "JOG_START/JOG_STOP", "MDI_EXECUTE", "COOLANT_COMMAND", "blocked_by_abi"]) assert.ok(ironLaw.includes(symbol), `iron_law_${symbol}`);
@@ -77,8 +79,10 @@ const tpSource = await readFile(new URL("./wasm-linuxcnc-tp.mjs", import.meta.ur
const combined = `${appSource}\n${panelSource}\n${contractSource}`;
for (const symbol of ["XyzacTrtPanel", "XyzacProgramPane", "XyzacKinematicsControl", "XyzacManualPanel", "XyzacLiveParameters", "XyzacDiagnostics", "Spindle", "Feed actual", "TP velocity", "Live parameters", "KINEMATICS_SELECT", "VISUAL_CLEAR", "POWER", "switchkinsXyzac", "Operation trace", "Alarms / recovery"]) assert.ok(combined.includes(symbol), symbol);
for (const symbol of ["wasmSnapshotReady", "manualReady", "sourceJogScale", "sourceVelocityScale", "spindleOverrideSource", "reopenDefaults", "gates.reset.enabled", "SET_JOG_SCALE", "SET_MAX_VELOCITY", "SET_SPINDLE_OVERRIDE", "WASM PENDING", "LinuxCNC WASM override-limits command unavailable"]) assert.ok(appSource.includes(symbol), symbol);
+for (const symbol of ["loadUiState", "saveUiState", "UI_STATE_SAVE", "UI_STATE_LOAD"]) assert.ok(`${appSource}\n${workerClientSource}\n${opfsBridgeSource}`.includes(symbol), symbol);
for (const forbidden of [/defaultValue=\"50\"/, /defaultValue=\"100\"/, /setBlockDelete/, /setOptionalStop/, /state\.status === \"running\" \? \"READING\"/]) assert.equal(forbidden.test(appSource), false, String(forbidden));
-for (const forbidden of [/_sim_/, /_malloc/, /\bHEAP(?:U|F|I)?\d*\b/, /navigator\.storage/, /\bOPFS\b/i, /postMessage\s*\(/]) assert.equal(forbidden.test(combined), false, String(forbidden));
+for (const forbidden of [/_sim_/, /_malloc/, /\bHEAP(?:U|F|I)?\d*\b/, /navigator\.storage/, /localStorage/, /postMessage\s*\(/]) assert.equal(forbidden.test(combined), false, String(forbidden));
+assert.equal(workerClientSource.includes("localStorage"), false);
assert.ok(appSource.includes("SELECTED_XYZAC_CASE"));
assert.equal(appSource.includes("3axis-tutorial"), false);
assert.ok(sessionSource.includes("this.wasmMotion.jog("));