132 lines
8.0 KiB
JavaScript
132 lines
8.0 KiB
JavaScript
import { fnv1a64 } from "./replay.mjs";
|
|
import { DEFAULT_MACHINE } from "../workspace/machine-definitions.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 = DEFAULT_MACHINE.id, 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 = DEFAULT_MACHINE.id, 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 = DEFAULT_MACHINE.id, 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 = DEFAULT_MACHINE.id, storage } = {}) {
|
|
return saveUiState({ contextId, key: `${key}-power-off`, state: { kind: "power_off_checkpoint", checkpoint }, storage });
|
|
}
|
|
|
|
export async function loadPowerOffCheckpoint({ contextId, key = DEFAULT_MACHINE.id, 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 = DEFAULT_MACHINE.id, storage } = {}) {
|
|
return clearUiState({ contextId, key: `${key}-power-off`, storage });
|
|
}
|