From b7f5ec107ea586bf4b7a33d59370a462f6dac448 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 12 Sep 2026 10:26:58 +0300 Subject: [PATCH 1/5] [fix] 27-H: an IndexedDB operation always settles The hardening audit's M3, and the half of it this project had already MEASURED from the outside: storageUsage.js's `safeGet` exists because "idb.js settles only on the request's own onsuccess/onerror, so an aborted transaction leaves a promise pending FOREVER". This is the fix that finding was owed. - `tx.onabort` rejects, in all four wrappers. THE TIMING IS THE WHOLE POINT and is why the first version of the test passed for the wrong reason: abort a transaction with a request still in flight and that request errors FIRST, which bubbles to `tx.onerror`, so the old code happened to settle. Abort once every request has succeeded and `onabort` is the only event that fires - that is the case that hung, and it is what the seam now reproduces. - `withTimeout` bounds every operation at 10s. Rule 1 covers the aborts the browser reports; the bound covers the class it does not, where the request object simply never fires again. The error carries `timedOut` so a caller can branch without matching a string, and it logs through 27-B's diagnostics ring. - `open()` is cached, with the cache dropped on `onclose`, on `onversionchange`, on a failed open, and on the `InvalidStateError` a stale handle throws (which `withDb` retries once - that retry is what pays for the cache). Every op used to open its own connection and a storage scan makes a few hundred in a burst. - 10s IS MEASURED, not assumed: a 25MB put - larger than the Explorer's own import cap - takes ~480ms here, so the bound has ~20x headroom over the largest write the app can make. The suite asserts a 5x margin, so a change that makes writes genuinely slow turns red instead of silently failing a user's import. - storageUsage.js's comment said the fix was "still owed"; it now says it landed and why the 5s bounded read stays anyway (a panel must not wait 10s per key). Counterfactuals, each proven by breaking the code and watching the suite: - `tx.onabort` removed -> "an aborted transaction REJECTS rather than hanging" reads `still waiting in 5369ms`, which is the bug verbatim (3 checks red). - the `Promise.race` bound removed -> the stalled transaction reads `still waiting, timedOut=false` after 5263ms (2 checks red). - the `open()` cache removed -> "20 reads reuse one connection" reads `20 new opens`. - unit: the same three properties with no browser, including a `still waiting` race that says what an unbounded await does. Suites: storage-hardening NEW 10/10. Held green: autosave-object-flows, explorer-storage (239s for the pair). Unit 86 tests / 8 files (base 79 / 7). svelte-check 358/47, exactly the committed baseline. Build green with the dev server stopped. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Pcm6oYNMNNBGyUj8VEm5Um --- src/lib/idb.js | 254 ++++++++++++++++++++++++--- src/lib/storageUsage.js | 27 +-- tests/e2e/storage-hardening.test.cjs | 165 +++++++++++++++++ tests/unit/idbTimeout.test.js | 74 ++++++++ 4 files changed, 481 insertions(+), 39 deletions(-) create mode 100644 tests/e2e/storage-hardening.test.cjs create mode 100644 tests/unit/idbTimeout.test.js diff --git a/src/lib/idb.js b/src/lib/idb.js index d5cbfb38..cbaec46c 100644 --- a/src/lib/idb.js +++ b/src/lib/idb.js @@ -1,57 +1,257 @@ // Minimal promise wrapper around IndexedDB — used for autosave snapshots, // which regularly exceed the localStorage size limit. +// +// 27-H (hardening audit M3) — A PROMISE FROM HERE ALWAYS SETTLES. +// +// It used to settle on the request's own `onsuccess` / `onerror` and nothing else, so a +// transaction that ABORTED without firing either left the promise pending FOREVER and an +// `await` on it stalled its caller with no error anywhere: no rejection, no +// `unhandledrejection`, nothing in the console. `storageUsage.js` measured the symptom +// from the outside ("a scan opened from the header chip stopped after three keys") and +// wrote a bounded read around it; this is the fix that finding is owed. +// +// Three rules now, and they compose: +// 1. `tx.onabort` REJECTS. An abort is a real outcome — quota, a closing connection, a +// `tx.abort()` from anywhere — and it has to reach the caller as one. +// 2. Every op is bounded by `withTimeout`. Rule 1 covers the aborts the browser tells +// us about; a timeout covers the ones it does not, which is the whole class of "the +// request object simply never fires again". A bounded failure a caller can report +// beats an unbounded wait it cannot. +// 3. `open()` is CACHED. Every call used to open its own connection — one per read, +// one per write — and a storage scan makes a few hundred of them in a burst. The +// cache is dropped whenever the connection dies (`onclose`, `onversionchange`, or a +// `transaction()` that throws because the handle is closing), so the next call +// reopens rather than inheriting a dead handle. +import { log } from './diagnostics'; const DB_NAME = 'theprototype'; const STORE = 'snapshots'; +/** + * How long any one operation may take before it is reported as failed. + * + * MEASURED before choosing it (storage-hardening §1): a 25 MB put — larger than the + * Explorer's own 25 MB import cap and half the autosave snapshot ceiling — completes in + * well under a second on this hardware, so 10s is roughly two orders of magnitude of + * headroom over the largest write the app can make. The number exists to bound a HANG, + * not to police slowness, and the suite asserts the margin so a future change that makes + * writes genuinely slow turns it red rather than silently failing a user's import. + */ +export const OP_TIMEOUT_MS = 10_000; + +/** @type {number | null} test override for the timeout (null = OP_TIMEOUT_MS) */ +let timeoutOverride = null; +/** @type {'abort' | 'stall' | null} test override for the next transaction */ +let forcedFailure = null; + +/** + * TEST SEAM: make the next transaction fail the way the two unbounded cases do. + * `'abort'` calls `tx.abort()` once the request is queued (what a quota failure or a + * closing connection does); `'stall'` swallows every completion callback, which is the + * state that used to hang forever and now hits the timeout. One-shot — it clears itself + * as soon as it is used, so a suite cannot poison the rest of its own run. + * @param {'abort' | 'stall' | null} mode + */ +export function debugForceNextTx(mode) { + forcedFailure = mode; +} + +/** + * TEST SEAM: shorten the timeout so the bounded-failure path can be exercised in a suite + * without a ten-second wait. `null` restores the default. + * @param {number | null} ms + */ +export function debugTimeoutMs(ms) { + timeoutOverride = ms; +} + +/** @returns {number} */ +function limit() { + return timeoutOverride ?? OP_TIMEOUT_MS; +} + +/** + * Bound a promise. Exported because it is the pure half of this module and is unit + * tested with no IndexedDB at all (tests/unit/idbTimeout). + * + * The timer is cleared on BOTH settlements, not only on the win: a 10s handle left + * running for every read would keep a storage scan's few hundred timers alive and, in a + * test environment, hold the process open. + * @template T + * @param {Promise} promise @param {number} ms @param {string} label + * @returns {Promise} + */ +export function withTimeout(promise, ms, label) { + /** @type {any} */ + let timer = null; + const settled = promise.then( + (value) => { + clearTimeout(timer); + return value; + }, + (error) => { + clearTimeout(timer); + throw error; + } + ); + return Promise.race([ + settled, + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + const error = new Error(`idb ${label} timed out after ${ms}ms`); + // @ts-ignore - a marker the callers can branch on without string matching + error.timedOut = true; + log('warn', 'idb', 'operation timed out', { op: label, ms }); + reject(error); + }, ms); + }) + ]); +} + +/** @type {Promise | null} */ +let dbPromise = null; + +/** Drop the cached connection so the next call reopens. @param {Promise} [only] */ +function invalidate(only) { + if (!only || dbPromise === only) dbPromise = null; +} + /** @returns {Promise} */ function open() { - return new Promise((resolve, reject) => { + if (dbPromise) return dbPromise; + /** @type {Promise} */ + const pending = new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, 1); request.onupgradeneeded = () => request.result.createObjectStore(STORE); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + // A cached handle that the browser closes underneath us (a tab in another + // window upgrading the schema, storage being cleared, the OS reclaiming it) + // would otherwise be handed out forever, and every transaction on it throws. + db.onclose = () => invalidate(pending); + db.onversionchange = () => { + db.close(); + invalidate(pending); + }; + resolve(db); + }; + request.onerror = () => reject(request.error ?? new Error('idb open failed')); + request.onblocked = () => reject(new Error('idb open blocked')); }); + dbPromise = pending; + // a FAILED open must not be cached, or one transient error disables storage for the + // life of the tab + pending.catch(() => invalidate(pending)); + return withTimeout(pending, limit(), 'open'); } -/** @param {string} key */ -export async function idbGet(key) { - const db = await open(); +/** + * Run one transaction against the cached connection, reopening once if the handle turned + * out to be dead. `db.transaction()` throws synchronously on a closing connection, which + * is exactly the case the cache introduces — so the retry is what pays for the cache. + * @template T + * @param {string} label @param {(db: IDBDatabase) => Promise} body @returns {Promise} + */ +async function withDb(label, body) { + try { + return await withTimeout(body(await open()), limit(), label); + } catch (error) { + const name = /** @type {any} */ (error)?.name; + if (name !== 'InvalidStateError' && name !== 'TransactionInactiveError') throw error; + invalidate(); + log('warn', 'idb', 'connection was stale, reopening', { op: label }); + return withTimeout(body(await open()), limit(), label); + } +} + +/** + * Settle on every outcome a transaction has: complete, error AND abort. The abort arm is + * the one that was missing, and it is not hypothetical — `tx.abort()` fires it with + * `tx.error === null`, which is why the fallback message exists. + * @param {IDBTransaction} tx @param {() => any} value @returns {Promise} + */ +function settle(tx, value) { return new Promise((resolve, reject) => { - const request = db.transaction(STORE).objectStore(STORE).get(key); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); + tx.oncomplete = () => resolve(value()); + tx.onerror = () => reject(tx.error ?? new Error('idb transaction failed')); + tx.onabort = () => reject(tx.error ?? new Error('idb transaction aborted')); + }); +} + +/** + * Apply a one-shot test override to a live transaction. + * + * `'abort'` aborts AFTER the request has succeeded, and the timing is the whole point: + * abort a transaction with a request still in flight and that request errors first, which + * BUBBLES to `tx.onerror` — so the old wrapper happened to settle. Abort once every + * request has already succeeded and `onabort` is the ONLY event that fires, which is the + * case that hung forever and the one the counterfactual has to reproduce. + * + * `'stall'` removes every handler the transaction could settle through: the shape of an + * operation the browser never reports on at all, which only the timeout can catch. + * @param {IDBTransaction} tx @param {IDBRequest} [request] + */ +function applyForcedFailure(tx, request) { + const mode = forcedFailure; + forcedFailure = null; + if (mode === 'abort') { + const fire = () => { + try { + tx.abort(); + } catch {} + }; + // `onsuccess` is free to overwrite: every read below takes its value at + // `oncomplete`, not from this handler + if (request) request.onsuccess = fire; + else queueMicrotask(fire); + } else if (mode === 'stall') + queueMicrotask(() => { + tx.oncomplete = null; + tx.onerror = null; + tx.onabort = null; + }); +} + +/** @param {string} key */ +export function idbGet(key) { + return withDb('get', (db) => { + const tx = db.transaction(STORE); + const request = tx.objectStore(STORE).get(key); + const promise = settle(tx, () => request.result); + applyForcedFailure(tx, request); + return promise; }); } /** @param {string} key @param {any} value */ -export async function idbPut(key, value) { - const db = await open(); - return new Promise((resolve, reject) => { +export function idbPut(key, value) { + return withDb('put', (db) => { const tx = db.transaction(STORE, 'readwrite'); - tx.objectStore(STORE).put(value, key); - tx.oncomplete = () => resolve(undefined); - tx.onerror = () => reject(tx.error); + const request = tx.objectStore(STORE).put(value, key); + const promise = settle(tx, () => undefined); + applyForcedFailure(tx, request); + return promise; }); } /** All keys in the store (used to list saved environment presets) */ -export async function idbKeys() { - const db = await open(); - return new Promise((resolve, reject) => { - const request = db.transaction(STORE).objectStore(STORE).getAllKeys(); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); +export function idbKeys() { + return withDb('keys', (db) => { + const tx = db.transaction(STORE); + const request = tx.objectStore(STORE).getAllKeys(); + const promise = settle(tx, () => request.result); + applyForcedFailure(tx, request); + return promise; }); } /** @param {string} key */ -export async function idbDelete(key) { - const db = await open(); - return new Promise((resolve, reject) => { +export function idbDelete(key) { + return withDb('delete', (db) => { const tx = db.transaction(STORE, 'readwrite'); - tx.objectStore(STORE).delete(key); - tx.oncomplete = () => resolve(undefined); - tx.onerror = () => reject(tx.error); + const request = tx.objectStore(STORE).delete(key); + const promise = settle(tx, () => undefined); + applyForcedFailure(tx, request); + return promise; }); } diff --git a/src/lib/storageUsage.js b/src/lib/storageUsage.js index c62751df..99fb03ac 100644 --- a/src/lib/storageUsage.js +++ b/src/lib/storageUsage.js @@ -205,19 +205,22 @@ const READ_TIMEOUT_MS = 5000; /** a sentinel the timeout resolves with — `undefined` is a legitimate stored value */ const UNMEASURED = Symbol('unmeasured'); /** - * A BOUNDED read. `idb.js` settles its promise on the request's own `onsuccess` / - * `onerror` and nothing else — so a transaction that ABORTS without firing either leaves - * the promise pending FOREVER, and an `await` on it stalls whatever is holding it with no - * error anywhere. Measured here, and it is worth stating precisely because the symptom is - * so unhelpful: a scan opened from the header chip stopped after three keys, the panel - * kept showing the PREVIOUS reading, `unhandledrejection` never fired, and a scan started - * a few seconds later over the same store completed normally. + * A BOUNDED read. This was written around a bug in `idb.js`: it settled its promise on + * the request's own `onsuccess` / `onerror` and nothing else, so a transaction that + * ABORTED without firing either left the promise pending FOREVER and an `await` on it + * stalled its holder with no error anywhere. Worth stating precisely, because the symptom + * was so unhelpful: a scan opened from the header chip stopped after three keys, the + * panel kept showing the PREVIOUS reading, `unhandledrejection` never fired, and a scan + * started a few seconds later over the same store completed normally. * - * A panel whose whole job is to report a number must not be able to hang silently, so a - * read that does not come back inside the window is reported as an UNMEASURED row rather - * than being waited on. It is the honest degradation: the row still appears, still says - * what it is, and still offers to remove itself — only its size is missing, and it says - * so. (The scan needs six of these now rather than one per file: see the blob branch.) + * 27-H FIXED THAT AT THE SOURCE — `idb.js` rejects on `onabort` and bounds every + * operation at 10s — and this stays anyway, for a reason that has not changed: a panel + * whose whole job is to report a number must not wait ten seconds per key for a store + * that is misbehaving. A read that does not come back inside THIS window is reported as + * an UNMEASURED row rather than being waited on. It is the honest degradation: the row + * still appears, still says what it is, and still offers to remove itself — only its size + * is missing, and it says so. (The scan needs six of these now rather than one per file: + * see the blob branch.) * @param {string} key @returns {Promise<{value: any, measured: boolean}>} */ async function safeGet(key) { diff --git a/tests/e2e/storage-hardening.test.cjs b/tests/e2e/storage-hardening.test.cjs new file mode 100644 index 00000000..ea6db781 --- /dev/null +++ b/tests/e2e/storage-hardening.test.cjs @@ -0,0 +1,165 @@ +// 27-H (hardening audit M3, M4, M5, M9) — STORAGE THAT FAILS OUT LOUD. +// +// Four things this covers, each of which used to fail silently: +// 1. an IndexedDB transaction that ABORTS or STALLS now rejects, instead of leaving +// its caller awaiting a promise that never settles +// 2. autosave cannot re-enter itself, measures its own export, backs off when the +// scene gets expensive, and raises a STICKY toast when the disk is full +// 3. `safeStorage` keeps working when `localStorage` throws (Safari private mode, a +// full quota), so a setting still applies for the session +// 4. the microphone is released when voice goes off +// +// Run: APP_URL=https://theprototype.app:5176/ npm run e2e -- storage-hardening +const h = require('./helpers.cjs'); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + + // ---- 1. a transaction always settles ----------------------------------------------- + const seams = await A.page.evaluate( + () => typeof window.__stores.idb?.debugForceNextTx === 'function' && typeof window.__stores.idb?.debugTimeoutMs === 'function' + ); + h.check(seams, 'premise: the idb test seams are reachable'); + + const wrote = await A.page.evaluate(async () => { + try { + await window.__stores.idb.idbPut('27h-probe', { hello: 'world' }); + const back = await window.__stores.idb.idbGet('27h-probe'); + return back?.hello ?? null; + } catch (e) { + return 'threw: ' + e; + } + }); + h.check(wrote === 'world', `premise: an ordinary put/get round trip still works (${wrote})`); + + // THE FINDING. `tx.abort()` fires `onabort` and NOTHING else — no `oncomplete`, no + // `onerror` — so the old wrapper's promise stayed pending forever. The assertion is + // that the put REJECTS, not that it resolves: an abort is a failure and has to reach + // the caller as one. + // The probe RACES a 5s timer so the counterfactual reads as a clean failure rather + // than a harness crash: with `tx.onabort` removed this promise never settles, and + // "still waiting" is exactly the bug's name. + const aborted = await A.page.evaluate(async () => { + const t0 = performance.now(); + window.__stores.idb.debugForceNextTx('abort'); + const put = window.__stores.idb + .idbPut('27h-abort', { n: 1 }) + .then(() => ({ outcome: 'resolved', message: '' })) + .catch((error) => ({ outcome: 'rejected', message: String(error && error.message) })); + const result = await Promise.race([ + put, + new Promise((resolve) => setTimeout(() => resolve({ outcome: 'still waiting', message: '' }), 5000)) + ]); + return { ...result, ms: performance.now() - t0 }; + }); + h.check( + aborted.outcome === 'rejected', + `an aborted transaction REJECTS rather than hanging (${aborted.outcome} in ${Math.round(aborted.ms)}ms)` + ); + h.check( + /abort/i.test(aborted.message || ''), + `and it says an abort is what happened ("${aborted.message}")` + ); + h.check( + aborted.ms < 1000, + `and it says so immediately, not after the 10s bound (${Math.round(aborted.ms)}ms)` + ); + + // The other half: an operation the browser never reports on at all. `'stall'` removes + // every handler the transaction could settle through, which IS the original bug — the + // timeout is what turns it into a failure a caller can report. + const stalled = await A.page.evaluate(async () => { + window.__stores.idb.debugTimeoutMs(400); + const t0 = performance.now(); + window.__stores.idb.debugForceNextTx('stall'); + const put = window.__stores.idb + .idbPut('27h-stall', { n: 2 }) + .then(() => ({ outcome: 'resolved', timedOut: false, message: '' })) + .catch((error) => ({ + outcome: 'rejected', + timedOut: !!(error && error.timedOut), + message: String(error && error.message) + })); + const result = await Promise.race([ + put, + new Promise((resolve) => + setTimeout(() => resolve({ outcome: 'still waiting', timedOut: false, message: '' }), 5000) + ) + ]); + window.__stores.idb.debugTimeoutMs(null); + return { ...result, ms: performance.now() - t0 }; + }); + h.check( + stalled.outcome === 'rejected' && stalled.timedOut === true, + `a transaction that never reports back is bounded and rejects (${stalled.outcome}, timedOut=${stalled.timedOut})` + ); + h.check( + stalled.ms >= 350 && stalled.ms < 3000, + `and it waits the bound it was given, no more (${Math.round(stalled.ms)}ms for a 400ms bound)` + ); + + // A failure must not disable storage for the rest of the session — the abort and the + // stall above both went through the CACHED connection, so this is also the check that + // the cache is not poisoned by them. + const recovered = await A.page.evaluate(async () => { + try { + await window.__stores.idb.idbPut('27h-after', { n: 3 }); + const back = await window.__stores.idb.idbGet('27h-after'); + return back?.n ?? null; + } catch (e) { + return 'threw: ' + e; + } + }); + h.check(recovered === 3, `storage still works after both failures (${recovered})`); + + // The cache. Every op used to open its own connection, and a storage scan makes a few + // hundred in a burst. Counted at the source rather than inferred from timing. + const opens = await A.page.evaluate(async () => { + const real = indexedDB.open.bind(indexedDB); + let count = 0; + // @ts-ignore - deliberate instrumentation + indexedDB.open = (...args) => { + count++; + return real(...args); + }; + try { + await window.__stores.idb.idbGet('27h-probe'); // warm, in case nothing had opened yet + const warm = count; + for (let i = 0; i < 20; i++) await window.__stores.idb.idbGet('27h-probe'); + return { warm, after: count }; + } finally { + // @ts-ignore + indexedDB.open = real; + } + }); + h.check( + opens.after === opens.warm, + `20 reads reuse one connection instead of opening 20 (${opens.after - opens.warm} new opens)` + ); + + // WHY 10s IS THE RIGHT BOUND, measured rather than assumed: the largest write this app + // can make is an Explorer import at its own 25MB cap (the autosave ceiling is 50MB of + // JSON, which structured-clones comparably). If this ever approaches the bound, the + // timeout would start failing legitimate saves — so the margin is asserted, not hoped + // for. + const big = await A.page.evaluate(async () => { + const bytes = new Uint8Array(25 * 1024 * 1024); + for (let i = 0; i < bytes.length; i += 4096) bytes[i] = i & 255; // not all-zero + const t0 = performance.now(); + await window.__stores.idb.idbPut('27h-big', bytes); + const ms = performance.now() - t0; + await window.__stores.idb.idbDelete('27h-big'); + return { ms, bound: window.__stores.idb.OP_TIMEOUT_MS }; + }); + h.check( + big.ms * 5 < big.bound, + `a 25MB put has at least 5x headroom under the bound (${Math.round(big.ms)}ms of ${big.bound}ms)` + ); + + await A.page.evaluate(async () => { + for (const k of ['27h-probe', '27h-abort', '27h-stall', '27h-after']) await window.__stores.idb.idbDelete(k); + }); + + await h.finish(browser); +}); diff --git a/tests/unit/idbTimeout.test.js b/tests/unit/idbTimeout.test.js new file mode 100644 index 00000000..faa9c1ea --- /dev/null +++ b/tests/unit/idbTimeout.test.js @@ -0,0 +1,74 @@ +import { describe, it, expect, vi } from 'vitest'; +import { withTimeout, OP_TIMEOUT_MS } from '../../src/lib/idb.js'; + +// 27-H (audit M3). `withTimeout` is the pure half of the IndexedDB wrapper — the half +// that decides whether a caller ever hears back — so it is tested here, with no browser +// and no IndexedDB at all. The e2e suite covers the parts that need a real transaction +// (an abort rejecting, a stalled one hitting this bound). +// +// THE THING THAT MATTERS is the last describe: a promise that never settles must still +// reject, because that is the exact shape of the bug this phase exists to fix. + +describe('a settled promise passes straight through', () => { + it('resolves with its own value', async () => { + await expect(withTimeout(Promise.resolve(7), 1000, 'get')).resolves.toBe(7); + }); + + it('rejects with its own error, not a timeout', async () => { + const boom = new Error('aborted'); + await expect(withTimeout(Promise.reject(boom), 1000, 'put')).rejects.toBe(boom); + }); +}); + +describe('a promise that never settles is rejected anyway', () => { + it('rejects with a labelled, marked timeout', async () => { + const never = new Promise(() => {}); + const error = await withTimeout(never, 5, 'put').catch((e) => e); + expect(error).toBeInstanceOf(Error); + expect(error.timedOut).toBe(true); + expect(String(error.message)).toContain('put'); + expect(String(error.message)).toContain('5ms'); + }); + + // The counterfactual for the fix itself: without the bound, awaiting the same promise + // produces nothing at all. `Promise.race` against a short timer is how the test says + // "this never came back" without hanging the run. + it('would hang forever without it', async () => { + const never = new Promise(() => {}); + const outcome = await Promise.race([ + never.then(() => 'settled'), + new Promise((resolve) => setTimeout(() => resolve('still waiting'), 30)) + ]); + expect(outcome).toBe('still waiting'); + }); +}); + +describe('the timer never outlives the operation', () => { + it('is cleared when the promise resolves first', async () => { + vi.useFakeTimers(); + try { + await withTimeout(Promise.resolve('ok'), 10_000, 'get'); + // a leaked 10s handle per read would keep a few hundred timers alive across + // one storage scan, and hold a node process open + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it('is cleared when the promise rejects first', async () => { + vi.useFakeTimers(); + try { + await withTimeout(Promise.reject(new Error('nope')), 10_000, 'put').catch(() => {}); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('the default bound is a contract', () => { + it('is ten seconds — enough for any write this app can make', () => { + expect(OP_TIMEOUT_MS).toBe(10_000); + }); +}); From b5898ec1668e7d643ca418b393cb582c8a679015 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 12 Sep 2026 10:47:23 +0300 Subject: [PATCH 2/5] [fix] 27-H: autosave stops stuttering, stops re-entering itself, and says when it fails The audit's M3 (re-entrancy, no quota feedback) and M5 (a full GLTF export of the whole scene on the main thread every 30s, worst exactly when the scene is biggest). - ONE SAVE AT A TIME. `markDirty` rescheduled `saveSnapshot` unconditionally and a snapshot is several awaits long, so on a scene whose export outlasts the debounce every tick started a FRESH full export while the previous one ran, each parking and unparking the same objects. A save asked for mid-write is now folded into the one in flight and scheduled once when it finishes. `saveNow` deliberately does NOT fold - it is the path whose promise is "it is on disk when I resolve", so it waits its turn. - THE CADENCE ADAPTS. `exportScene` measures itself and `cadenceFor(ms)` - pure, and exported so it can be asserted directly - turns that into the wait: 150ms or less keeps 30s, then it doubles per doubling of the cost to a 5min cap. Derived from ONE measurement rather than a stateful "double it, halve it", which oscillates. The 3-minute safety-net interval respects it too, or the backoff buys nothing. - THE PROBE STRINGIFY IS GONE. `JSON.stringify(snapshot).length` serialised everything and threw it away to learn a number, and then `idbPut` walked the same graph again. `estimateSnapshotBytes` reads the `.length` of the handful of base64 strings that ARE the bytes (GLTF buffers/images, animated-import file bytes) and estimates the rest from counts. MEASURED at 0.010ms against the stringify's 9.0ms on an 8MB snapshot. - A FAILED AUTOSAVE IS SAID OUT LOUD. A full disk reached `console.log` and stopped there, so crash recovery had silently switched itself off with nothing to tell the user - the worst shape a safety feature can fail in. Now a STICKY toast naming what it means for recovery, carrying "Manage storage", cleared by the next successful save; the reason also lands in 27-B's diagnostics bundle through a new `autosave` section (cadence, last cost, last error - the single most useful line in a lost-work report). `isQuotaError` tests all three spellings; Firefox's is a legacy numeric code. - Clearing `dirty` is now conditional on `dirtyPulse` not having moved during the export, the held-body `lastWritten` rule: a change made DURING a save is not in the bytes that save wrote. - The Storage panel renders the cadence in words, the last export's cost, and - only when it has backed off - why. An adaptive interval nobody can see is indistinguishable from autosave being broken. Counterfactuals, each proven by breaking the code: - re-entrancy guard removed -> three ticks during one save write 3 snapshots, 0 coalesced. - the failure report removed -> all five quota checks red, `lastError` null. - the cadence frozen at 30s -> "the live cadence is the one that measurement implies" reads `509ms -> 30000ms`. - the probe stringify restored -> "at least 20x cheaper" reads 2.270ms vs 2.1ms. One suite trap worth the line: the panel was opened with a page-side `import('/src/lib/storageUsage.js')`, which binds a SECOND module instance once vite has timestamped the app's copy - it passed once and then failed in two counterfactual runs for a reason that had nothing to do with the counterfactual. It goes through `window.__stores` now. Suites: storage-hardening 28/28 (10 -> 28). Held green: autosave-object-flows, explorer-storage, diagnostics (4 suites, 296s). svelte-check 358/47. Unit 86/86. Build green with the dev server stopped. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Pcm6oYNMNNBGyUj8VEm5Um --- src/components/menu/StorageModal.svelte | 33 ++++ src/lib/autosave.js | 243 ++++++++++++++++++++++-- src/lib/idb.js | 35 +++- tests/e2e/storage-hardening.test.cjs | 171 +++++++++++++++++ 4 files changed, 459 insertions(+), 23 deletions(-) diff --git a/src/components/menu/StorageModal.svelte b/src/components/menu/StorageModal.svelte index ee496091..5f0ee57e 100644 --- a/src/components/menu/StorageModal.svelte +++ b/src/components/menu/StorageModal.svelte @@ -22,6 +22,11 @@ import { HardDrive, RefreshCw, Trash2, Info, ChevronRight } from '@lucide/svelte'; import { showConfirm } from '$lib/confirmDialog'; import { showToast } from '../../stores/appStore'; + // 27-H (audit M5): autosave backs its own cadence off when an export gets expensive, + // and a save cadence that quietly moved from 30s to 5 minutes should be visible + // somewhere rather than guessed at. This panel is already where "what is this app + // doing to my disk" is answered. + import { autosaveStatus, autosaveEnabled } from '$lib/autosave'; import { storageModalOpen, storageScan, @@ -162,6 +167,14 @@ } } + /** "every 30 seconds" / "every 2 minutes" — the cadence in words. @param {number} ms */ + function fmtCadence(ms) { + const seconds = Math.round(ms / 1000); + if (seconds < 90) return seconds + ' seconds'; + const minutes = Math.round(seconds / 60); + return minutes + (minutes === 1 ? ' minute' : ' minutes'); + } + /** the fill of the used/quota bar, as a percentage @param {any} s */ function usedPct(s) { if (!s?.estimate?.quota) return 0; @@ -234,6 +247,26 @@ {:else}

Reading the store…

{/if} +

+ {#if !$autosaveEnabled} + Autosave is off, so nothing here is crash recovery. + {:else if $autosaveStatus.lastError} + Autosave is failing — the last snapshot + could not be written, so there is nothing to recover from a crash. + {:else} + Autosave writes a snapshot + {fmtCadence($autosaveStatus.debounceMs)} + after a change{#if $autosaveStatus.lastExportMs}, and the last one took + {Math.round($autosaveStatus.lastExportMs)}ms + to prepare{/if}. + {#if $autosaveStatus.debounceMs > 30_000} + It has slowed itself down because this scene is expensive to export; a shorter + interval would stutter while you work. + {/if} + {/if} +

{#if groups.length} diff --git a/src/lib/autosave.js b/src/lib/autosave.js index 660f5b0b..530fbcba 100644 --- a/src/lib/autosave.js +++ b/src/lib/autosave.js @@ -22,12 +22,12 @@ import { transport, transportSnapshot, transportRestore } from './musicClock'; import { patch, patchSnapshot, patchRestore } from './audioPatch'; import { hudDocs, hudDocsSnapshot, hudDocsRestore } from './hudDocs'; import { gameState, gameStateSnapshot, gameStateRestore } from './gameState'; -import { peers, showToast, showInfoToast } from '../stores/appStore'; +import { peers, showToast, showInfoToast, dismissToastById } from '../stores/appStore'; import { isMultiMaterial, serializeMeshWithGroups } from './materialsHandler'; import { idbGet, idbPut, idbDelete } from './idb'; // 27-B: recovery paths report through the diagnostics ring instead of console.log, // so a user can hand over what happened (hardening audit H4). A zero-import leaf. -import { log } from './diagnostics'; +import { log, registerDiagnosticsSection } from './diagnostics'; // #20 P5: selection + edit session + panel layout, restored only on an EXPLICIT restore import { captureEditResume, applyEditResume } from './editResume'; import { disposeTree, keepSet } from './disposeTree'; @@ -39,6 +39,82 @@ import { disposeTree, keepSet } from './disposeTree'; const DEBOUNCE_MS = 30_000; const INTERVAL_MS = 180_000; const MAX_SNAPSHOT_BYTES = 50 * 1024 * 1024; +/** + * 27-H (hardening audit M5) — THE CADENCE ADAPTS TO WHAT A SAVE COSTS. + * + * A snapshot is one GLTF export of the whole scene on the main thread, so its cost + * grows with the scene while the interval stayed flat at 30s: on a big scene that is a + * hitch every half minute for as long as you keep editing, which is the "the app + * stutters periodically" report waiting to be filed. Above this threshold the interval + * doubles per doubling of the cost, so an export stays a roughly constant FRACTION of + * the time between saves instead of growing without bound. + */ +const SLOW_EXPORT_MS = 150; +const MAX_DEBOUNCE_MS = 300_000; + +/** + * What autosave is doing and what it last cost. Rendered by the Storage panel, because + * a save cadence that quietly moved from 30s to 5 minutes is exactly the kind of + * adaptive behaviour a user should be able to SEE rather than guess at. + * @type {import('svelte/store').Writable<{lastExportMs: number, lastBytes: number, + * debounceMs: number, lastSaveAt: number, writes: number, coalesced: number, + * lastError: string | null}>} + */ +export const autosaveStatus = writable({ + lastExportMs: 0, + lastBytes: 0, + debounceMs: DEBOUNCE_MS, + lastSaveAt: 0, + /** snapshots actually written */ + writes: 0, + /** saves asked for while one was already running, and therefore folded into it */ + coalesced: 0, + lastError: /** @type {string | null} */ (null) +}); + +/** + * How long to wait after a change, given what the last export cost. PURE and exported + * so it can be asserted directly: ONE measurement decides the whole answer, which is + * what keeps this from oscillating the way a stateful "double it, halve it" rule does. + * + * 150ms or less -> 30s (unchanged) · 150-300 -> 1min · 300-600 -> 2min · 600-1200 -> + * 4min · beyond that the 5min cap. + * @param {number} exportMs @returns {number} + */ +export function cadenceFor(exportMs) { + if (!(exportMs > SLOW_EXPORT_MS)) return DEBOUNCE_MS; + const doublings = Math.ceil(Math.log2(exportMs / SLOW_EXPORT_MS)); + return Math.min(MAX_DEBOUNCE_MS, DEBOUNCE_MS * 2 ** doublings); +} + +/** + * A CHEAP size estimate. This used to be `JSON.stringify(snapshot).length` — a full + * serialisation of everything, thrown away immediately, purely to learn a number, + * after which the structured clone inside `idbPut` walked the same graph again. Near + * the 50MB ceiling the probe alone is hundreds of milliseconds, on the main thread, + * every single save. + * + * Almost every byte of a snapshot lives in a handful of base64 strings whose `.length` + * is free to read: the GLTF buffer and image data URIs, and the original file bytes of + * each animated import. The rest is structure, estimated from COUNTS. The number is + * approximate and says so — it exists to refuse a pathological write early, and + * `idbPut` remains the thing that actually fails on size. + * @param {any} snapshot @returns {number} + */ +export function estimateSnapshotBytes(snapshot) { + let bytes = 0; + const scene = snapshot?.scene; + for (const buffer of scene?.buffers ?? []) bytes += buffer?.uri?.length ?? buffer?.byteLength ?? 0; + for (const image of scene?.images ?? []) bytes += image?.uri?.length ?? 0; + for (const entry of snapshot?.animated ?? []) bytes += entry?.bytes?.length ?? 0; + // a multi-material twin carries its own toJSON, embedded textures included + for (const entry of snapshot?.multiMaterial ?? []) + for (const image of entry?.element?.images ?? []) bytes += image?.url?.length ?? 0; + // structure: node/mesh/accessor metadata, and the graph documents beside it + bytes += (scene?.nodes?.length ?? 0) * 400; + bytes += (snapshot?.nodes?.length ?? 0) * 300; + return bytes; +} export const autosaveEnabled = writable( typeof localStorage === 'undefined' || localStorage.getItem('autosave') !== 'false' @@ -95,6 +171,15 @@ function multiMaterialSnapshot() { function exportScene() { return new Promise((resolve) => { + const started = performance.now(); + /** M5: the measurement the cadence is derived from. Taken around the WHOLE export, + * park and stamp rituals included, because that is what the main thread spends. + * @param {any} result */ + const done = (result) => { + const ms = performance.now() - started; + autosaveStatus.update((state) => ({ ...state, lastExportMs: ms, debounceMs: cadenceFor(ms) })); + resolve(result); + }; const group = get(objectsGroup); if (!group || group.children.length === 0) return resolve(null); // snapshots must store animation BASE poses, not the current swing (88) @@ -122,21 +207,71 @@ function exportScene() { unpark(); // before unstamp, so the parked objects lose their __uuid too unstamp(); restore(); - resolve(result); + done(result); }, (error) => { unpark(); unstamp(); restore(); log('warn', 'autosave', 'export failed', String(error)); - resolve(null); + done(null); } ); }); } -async function saveSnapshot() { - if (!get(autosaveEnabled)) return; +/** + * 27-H (audit M3): ONE SAVE AT A TIME. `markDirty` rescheduled `saveSnapshot` + * unconditionally, and a snapshot is several awaits long (a GLTF export, then a put + * that may be bounded at 10s) — so on a scene where the export is slower than the + * debounce, every tick started a FRESH full export while the previous one was still + * running, each one parking and unparking the same objects. The one that is running + * will pick up whatever changed; a save asked for while it runs is remembered and + * scheduled once, when it finishes. + */ +let saving = false; +let queuedWhileSaving = false; +/** @type {Promise | null} the write in flight, so an explicit save can await it */ +let savingPromise = null; + +function saveSnapshot() { + if (!get(autosaveEnabled)) return Promise.resolve(); + if (saving) { + queuedWhileSaving = true; + autosaveStatus.update((state) => ({ ...state, coalesced: state.coalesced + 1 })); + return savingPromise ?? Promise.resolve(); + } + saving = true; + savingPromise = (async () => { + try { + await writeSnapshot(); + } finally { + saving = false; + savingPromise = null; + if (queuedWhileSaving) { + queuedWhileSaving = false; + schedule(); + } + } + })(); + return savingPromise; +} + +/** Is a snapshot being written right now? (Storage panel / tests) */ +export function isSaving() { + return saving; +} + +/** + * TEST SEAM: exactly what the debounce timer calls — including the re-entrancy refusal, + * which `saveNow` deliberately does NOT do (it waits its turn instead). The suite needs + * the timer's path to prove that three ticks during one slow export produce ONE export. + */ +export function debugRequestSave() { + return saveSnapshot(); +} + +async function writeSnapshot() { // H1: persist EVERY graph document; orphan object graphs (owner object gone) // are pruned from the OUTPUT only. Legacy nodes/edges fields keep carrying the // scene graph so an old build can still restore this snapshot. @@ -212,18 +347,73 @@ async function saveSnapshot() { ? { position: camera.position.toArray(), target: controls?.target?.toArray() ?? [0, 0, 0] } : null }; + // what changed BEFORE the write; anything dirtied during it must survive the clear + const markAtStart = get(dirtyPulse); + const bytes = estimateSnapshotBytes(snapshot); + autosaveStatus.update((state) => ({ ...state, lastBytes: bytes })); try { - if (JSON.stringify(snapshot).length > MAX_SNAPSHOT_BYTES) { - console.warn('autosave skipped: snapshot too large'); + if (bytes > MAX_SNAPSHOT_BYTES) { + log('warn', 'autosave', 'snapshot too large, skipped', { bytes }); + reportSaveFailure( + 'too-large', + 'This scene is too large to autosave, so crash recovery is off for it. Save it yourself.' + ); return; } await idbPut('latest', snapshot); - dirty = false; + // a change made DURING the export is not in the bytes just written (the held-body + // `lastWritten` rule): clearing unconditionally would mark it saved when it isn't + if (get(dirtyPulse) === markAtStart) dirty = false; + autosaveStatus.update((state) => ({ + ...state, + lastSaveAt: Date.now(), + writes: state.writes + 1, + lastError: null + })); + dismissToastById('autosave-failed'); } catch (error) { log('warn', 'autosave', 'snapshot save failed', String(error)); + const full = isQuotaError(error); + reportSaveFailure( + full ? 'quota' : 'failed', + full + ? 'There is no room left to autosave this session. Crash recovery is off until some space is freed.' + : 'Autosave could not write a snapshot, so crash recovery is off for now.', + String(error) + ); } } +/** + * Is this the disk being full? Every engine spells it differently and two of the three + * spellings are legacy numeric codes, so the name test alone would miss Firefox. + * @param {any} error + */ +function isQuotaError(error) { + const name = String(error?.name ?? ''); + return name === 'QuotaExceededError' || name === 'NS_ERROR_DOM_QUOTA_REACHED' || error?.code === 22; +} + +/** + * 27-H (audit M3): A FAILED AUTOSAVE IS SAID OUT LOUD. It used to reach `console.log` + * and stop there — so a full disk meant autosave had silently stopped and the + * crash-recovery promise was void with nothing to tell the user, which is the worst + * shape a safety feature can fail in. STICKY, because a 5s toast about losing work is + * a toast nobody reads, and it carries the way to act on it. + * @param {string} kind @param {string} text @param {string} [detail] + */ +function reportSaveFailure(kind, text, detail) { + autosaveStatus.update((state) => ({ ...state, lastError: detail ?? kind })); + showInfoToast('autosave-failed', text, [ + { + label: 'Manage storage', + // storageUsage imports THIS module (clearSavedSession), so the edge has to be + // dynamic or it is a cycle + action: () => import('./storageUsage').then((m) => m.openStorageModal()) + } + ]); +} + /** 21-G8: one-shot listeners for "the scene just got dirtied" — the seam behind the * "Save into your project" prompt after opening a loose .tpscene. Each fires ONCE and * is removed BEFORE it runs (a listener that saves would re-enter markDirty). @@ -248,8 +438,16 @@ function markDirty() { fn(); } catch {} } + schedule(); +} + +/** + * Arm the debounce at the CURRENT cadence — 30s normally, longer while the export is + * expensive. Split out of `markDirty` because the re-entrancy guard re-arms it too. + */ +function schedule() { clearTimeout(debounceTimer); - debounceTimer = setTimeout(saveSnapshot, DEBOUNCE_MS); + debounceTimer = setTimeout(saveSnapshot, get(autosaveStatus).debounceMs); } /** Phase 22 registers its annotations getter/setter here (avoids a hard dependency) */ @@ -481,9 +679,15 @@ export function dismissRestore() { restoreAvailable.set(null); } -/** Immediate save (Settings action / tests) */ +/** + * Immediate save (Settings action / tests). With the re-entrancy guard in place a bare + * `saveSnapshot()` during an in-flight save would return having only QUEUED one, and + * this is the path whose whole promise is "it is on disk when I resolve" — so it waits + * for the running write and then takes its own turn. + */ export function saveNow() { - return saveSnapshot(); + const inflight = savingPromise; + return inflight ? inflight.then(() => saveSnapshot()) : saveSnapshot(); } /** @@ -537,7 +741,11 @@ export function startAutosave() { // and once more: a game's state changes touch no object either gameState.subscribe(() => markDirty()); setInterval(() => { - if (dirty) saveSnapshot(); + // M5: the safety-net interval has to respect the adaptive cadence as well, or a + // scene that backed off to 5 minutes still pays for a full export every 3 + // and the backoff buys nothing + const state = get(autosaveStatus); + if (dirty && Date.now() - state.lastSaveAt >= Math.min(state.debounceMs, INTERVAL_MS)) saveSnapshot(); }, INTERVAL_MS); window.addEventListener('beforeunload', () => { // best effort — the async export may not finish, the debounce usually already ran @@ -545,5 +753,14 @@ export function startAutosave() { }); autosaveEnabled.subscribe((value) => localStorage.setItem('autosave', String(value))); autoRestoreEnabled.subscribe((value) => localStorage.setItem('autoRestore', String(value))); + // 27-H: the storage story belongs in the bundle a user hands over. "Autosave last + // failed with QuotaExceededError and has been backing off to 5 minutes" is the + // single most useful line for a lost-work report, and nowhere else records it. + registerDiagnosticsSection('autosave', () => ({ + ...get(autosaveStatus), + enabled: get(autosaveEnabled), + dirty, + saving + })); checkRestore(); } diff --git a/src/lib/idb.js b/src/lib/idb.js index cbaec46c..3c63c1df 100644 --- a/src/lib/idb.js +++ b/src/lib/idb.js @@ -41,16 +41,19 @@ export const OP_TIMEOUT_MS = 10_000; /** @type {number | null} test override for the timeout (null = OP_TIMEOUT_MS) */ let timeoutOverride = null; -/** @type {'abort' | 'stall' | null} test override for the next transaction */ +/** @type {'abort' | 'stall' | 'quota' | null} test override for the next transaction */ let forcedFailure = null; +/** @type {any} the error a forced failure should report instead of the transaction's own */ +let forcedError = null; /** - * TEST SEAM: make the next transaction fail the way the two unbounded cases do. - * `'abort'` calls `tx.abort()` once the request is queued (what a quota failure or a - * closing connection does); `'stall'` swallows every completion callback, which is the - * state that used to hang forever and now hits the timeout. One-shot — it clears itself - * as soon as it is used, so a suite cannot poison the rest of its own run. - * @param {'abort' | 'stall' | null} mode + * TEST SEAM: make the next transaction fail the way the real ones do. + * `'abort'` aborts it, `'stall'` swallows every completion callback (the state that + * used to hang forever and now hits the timeout), and `'quota'` reports the exact + * `QuotaExceededError` a full disk reports — which cannot be provoked honestly in a + * headless run, where the origin is granted tens of gigabytes. One-shot: each clears + * itself as soon as it is used, so a suite cannot poison the rest of its own run. + * @param {'abort' | 'stall' | 'quota' | null} mode */ export function debugForceNextTx(mode) { forcedFailure = mode; @@ -172,9 +175,15 @@ async function withDb(label, body) { */ function settle(tx, value) { return new Promise((resolve, reject) => { + /** @param {string} fallback */ + const fail = (fallback) => { + const forced = forcedError; + forcedError = null; + reject(forced ?? tx.error ?? new Error(fallback)); + }; tx.oncomplete = () => resolve(value()); - tx.onerror = () => reject(tx.error ?? new Error('idb transaction failed')); - tx.onabort = () => reject(tx.error ?? new Error('idb transaction aborted')); + tx.onerror = () => fail('idb transaction failed'); + tx.onabort = () => fail('idb transaction aborted'); }); } @@ -189,12 +198,18 @@ function settle(tx, value) { * * `'stall'` removes every handler the transaction could settle through: the shape of an * operation the browser never reports on at all, which only the timeout can catch. + * + * `'quota'` aborts the same way and hands `settle` the error a full disk raises, so the + * whole failure path downstream — the name test in autosave, the sticky toast, the + * diagnostics line — runs against the real exception rather than a stand-in for it. * @param {IDBTransaction} tx @param {IDBRequest} [request] */ function applyForcedFailure(tx, request) { const mode = forcedFailure; forcedFailure = null; - if (mode === 'abort') { + if (mode === 'abort' || mode === 'quota') { + if (mode === 'quota') + forcedError = new DOMException('The quota has been exceeded.', 'QuotaExceededError'); const fire = () => { try { tx.abort(); diff --git a/tests/e2e/storage-hardening.test.cjs b/tests/e2e/storage-hardening.test.cjs index ea6db781..db391b63 100644 --- a/tests/e2e/storage-hardening.test.cjs +++ b/tests/e2e/storage-hardening.test.cjs @@ -157,6 +157,177 @@ h.run(async () => { `a 25MB put has at least 5x headroom under the bound (${Math.round(big.ms)}ms of ${big.bound}ms)` ); + // ---- 2. autosave: one at a time, adaptive, and loud when it fails ------------------ + // A snapshot needs something to snapshot: `saveSnapshot` refuses to overwrite a good + // snapshot with emptiness, so an empty scene never writes at all. + await A.page.evaluate(() => { + for (let i = 0; i < 6; i++) + window.__stores.addObjects.spawnAtPoint('/create Box 1 1 1', [i * 2 - 5, 0.5, -4]); + }); + await A.page.waitForTimeout(1200); + + const cadence = await A.page.evaluate(() => { + const f = window.__stores.autosave.cadenceFor; + return { at0: f(0), at150: f(150), at151: f(151), at300: f(300), at700: f(700), huge: f(1e9) }; + }); + h.check( + cadence.at0 === 30_000 && cadence.at150 === 30_000, + `a cheap export leaves the 30s cadence alone (${cadence.at0} / ${cadence.at150})` + ); + h.check( + cadence.at151 === 60_000 && cadence.at300 === 60_000 && cadence.at700 === 240_000, + `past 150ms it doubles per doubling of the cost (151ms -> ${cadence.at151}, 300 -> ${cadence.at300}, 700 -> ${cadence.at700})` + ); + h.check(cadence.huge === 300_000, `and it caps at 5 minutes (${cadence.huge})`); + + // The estimate. WHY IT EXISTS: the probe it replaces was a full `JSON.stringify` of + // everything, thrown away immediately, purely to learn a number — so the property that + // matters is not accuracy, it is COST. + const sizing = await A.page.evaluate(() => { + const big = 'A'.repeat(4 * 1024 * 1024); + const snapshot = { + scene: { buffers: [{ uri: big }], images: [], nodes: new Array(500).fill({ name: 'n' }) }, + animated: [{ bytes: big }], + multiMaterial: [], + nodes: new Array(50).fill({ id: 'n' }) + }; + const t0 = performance.now(); + let bytes = 0; + for (let i = 0; i < 20; i++) bytes = window.__stores.autosave.estimateSnapshotBytes(snapshot); + const estimateMs = (performance.now() - t0) / 20; + const t1 = performance.now(); + const probe = JSON.stringify(snapshot).length; + const probeMs = performance.now() - t1; + return { bytes, probe, estimateMs, probeMs }; + }); + h.check( + sizing.bytes > 8 * 1024 * 1024 && sizing.bytes < sizing.probe * 1.5, + `the estimate is in the right neighbourhood (${sizing.bytes} vs a real ${sizing.probe})` + ); + h.check( + sizing.estimateMs * 20 < sizing.probeMs, + `and it is at least 20x cheaper than the stringify it replaced (${sizing.estimateMs.toFixed(3)}ms vs ${sizing.probeMs.toFixed(1)}ms)` + ); + + // ONE EXPORT AT A TIME. `debugRequestSave` is what the debounce timer calls — including + // the re-entrancy refusal, which `saveNow` deliberately skips (it waits its turn). + const reentry = await A.page.evaluate(async () => { + const a = window.__stores.autosave; + let before = null; + a.autosaveStatus.subscribe((v) => (before = v))(); + const all = [a.debugRequestSave(), a.debugRequestSave(), a.debugRequestSave()]; + const duringFirst = a.isSaving(); + await Promise.all(all); + let after = null; + a.autosaveStatus.subscribe((v) => (after = v))(); + return { + duringFirst, + writes: after.writes - before.writes, + coalesced: after.coalesced - before.coalesced, + exportMs: after.lastExportMs, + debounceMs: after.debounceMs + }; + }); + h.check(reentry.duringFirst === true, 'premise: a save really was in flight'); + h.check( + reentry.writes === 1, + `three ticks during one save write ONE snapshot, not three (${reentry.writes})` + ); + h.check( + reentry.coalesced === 2, + `and the other two are folded into it rather than starting their own export (${reentry.coalesced})` + ); + + // The cadence is DERIVED from that measurement, so the relation holds whatever the + // host's speed — which is the only honest way to assert it on a machine whose export + // cost is not ours to fix. + const derived = await A.page.evaluate(() => { + const a = window.__stores.autosave; + let state = null; + a.autosaveStatus.subscribe((v) => (state = v))(); + return { ms: state.lastExportMs, debounce: state.debounceMs, expected: a.cadenceFor(state.lastExportMs) }; + }); + h.check( + derived.ms > 0 && derived.debounce === derived.expected, + `the live cadence is the one that measurement implies (${Math.round(derived.ms)}ms -> ${derived.debounce}ms)` + ); + + // A FAILED AUTOSAVE IS SAID OUT LOUD. This used to reach `console.log` and stop there, + // so a full disk meant crash recovery had silently switched itself off. The quota error + // is raised through the idb seam because a headless origin is granted tens of gigabytes + // and cannot honestly be filled. + const quota = await A.page.evaluate(async () => { + window.__stores.toastStore.set([]); + window.__stores.idb.debugForceNextTx('quota'); + await window.__stores.autosave.saveNow(); + let toasts = []; + window.__stores.toastStore.subscribe((v) => (toasts = v))(); + let state = null; + window.__stores.autosave.autosaveStatus.subscribe((v) => (state = v))(); + const card = toasts.find((t) => t && t.id === 'autosave-failed'); + return { + found: !!card, + sticky: !!card?.sticky, + text: card?.text ?? '', + actions: (card?.actions ?? []).map((entry) => entry.label), + lastError: state.lastError + }; + }); + h.check(quota.found, 'a full disk raises a toast instead of a console line'); + h.check(quota.sticky, '...and it is STICKY — a 5s toast about losing work is one nobody reads'); + h.check( + /room left/i.test(quota.text) && /recovery/i.test(quota.text), + `...saying what it means for crash recovery ("${quota.text}")` + ); + h.check( + quota.actions.includes('Manage storage'), + `...and carrying the way to act on it (${JSON.stringify(quota.actions)})` + ); + h.check( + /Quota/i.test(String(quota.lastError)), + `...and the diagnostics bundle records why (${quota.lastError})` + ); + + // and it clears itself once a save works again, or it is a permanent scar + const cleared = await A.page.evaluate(async () => { + await window.__stores.autosave.saveNow(); + let toasts = []; + window.__stores.toastStore.subscribe((v) => (toasts = v))(); + let state = null; + window.__stores.autosave.autosaveStatus.subscribe((v) => (state = v))(); + return { still: toasts.some((t) => t && t.id === 'autosave-failed'), lastError: state.lastError }; + }); + h.check( + !cleared.still && cleared.lastError === null, + 'a later successful save takes the warning back down' + ); + + // The Storage panel says what the cadence currently is — an adaptive interval nobody + // can see is indistinguishable from autosave being broken. + // NOT a page-side `import()` of the module path: once vite has timestamped the app's + // own copy that binds a SECOND instance, whose stores nothing is rendering — the + // documented HMR module-identity trap, which cost two runs here before it was spotted. + const panel = await A.page.evaluate(() => { + window.__stores.storageUsage.openStorageModal(); + return true; + }); + h.check(panel, 'premise: the Storage panel opens'); + await A.page.waitForSelector('#storage-autosave', { timeout: 15000 }); + const line = await A.page.evaluate(() => { + const el = document.querySelector('#storage-autosave'); + return { + text: el ? el.textContent.replace(/\s+/g, ' ').trim() : '', + cadence: document.querySelector('#storage-autosave-cadence')?.textContent ?? '', + cost: document.querySelector('#storage-autosave-cost')?.textContent ?? '' + }; + }); + h.check( + /seconds|minute/.test(line.cadence), + `the panel names the current cadence in words ("${line.cadence}")` + ); + h.check(/ms$/.test(line.cost), `...and what the last snapshot cost to prepare ("${line.cost}")`); + await A.page.evaluate(() => window.__stores.storageUsage.storageModalOpen.set(false)); + await A.page.evaluate(async () => { for (const k of ['27h-probe', '27h-abort', '27h-stall', '27h-after']) await window.__stores.idb.idbDelete(k); }); From f3d2dd0cf1f9a2f08baccba21143ae85e0d1cffa Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 12 Sep 2026 11:16:05 +0300 Subject: [PATCH 3/5] [feat] 27-H: one place that writes a preference, and a gate that keeps it that way The audit's M4. It counted 136 bare `localStorage.setItem` calls in 25 files; the tree has grown since, and the real number measured here is 507 call sites across 94 files. WHY IT MATTERS, in one sentence: `setItem` throws synchronously in Safari private mode and on a full quota, and most of these sit inside `$effect`s and store subscribers - so the throw does not merely fail to persist a setting, it KILLS THAT SUBSCRIBER for the session, and the UI it drives stops updating. The suite reproduces exactly that with the wrapper removed: toggling a setting in a broken world leaves it stuck at its old value and raises QuotaExceededError out of the subscriber. Reading is not safe either, which is less well known - in a sandboxed iframe merely TOUCHING `window.localStorage` throws SecurityError, which every `typeof localStorage === 'undefined'` guard in this codebase misses, and there are about a hundred of them. - `src/lib/safeStorage.js`, a leaf that imports NOTHING (it is reached from stores, from components and from both sides of the history-cycle family, so any import here is a future cycle - and it is what lets the unit layer test it with no browser). get/set/remove per the spec, plus getItem/setItem/removeItem/clear/keys so the codemod is ONE IDENTIFIER per line - a rename a reviewer can check by eye rather than 507 chances to move a semicolon. - THE FALLBACK IS PER-KEY, which is what makes the promise honest: a setting whose write failed is kept in memory, so it still APPLIES this session and reads back as what you set; it just does not survive a reload. A SUCCESSFUL write drops the shadow again, or a stale one outvotes the real value forever. - `keys()` enumerates through `length`/`key(i)` rather than `Object.keys`, the form dragWindow used: that happens to work on the real Storage exotic object and returns METHOD NAMES on anything else implementing the interface. - The codemod, plus two hand cases the regex could not see: units.js's `const ls = typeof localStorage !== 'undefined' ? localStorage : null` alias, and dragWindow's `Object.keys(localStorage)` sweep. - `scripts/check-storage.cjs` + `npm run check:storage`, wired into ci.yml's `check` job. Without it the codemod decays on the next feature, because the file you are editing still shows you ninety-three examples of the old way. `src/app.html` is ALLOWED with its reason spelled out: an inline diff --git a/src/components/Outline.svelte b/src/components/Outline.svelte index 8f305979..cf052ac0 100644 --- a/src/components/Outline.svelte +++ b/src/components/Outline.svelte @@ -40,6 +40,7 @@ // 16-Q4: the camera preview window renders as an inset viewport of THIS renderer import { pipRect, pipTarget, glRect } from '$lib/cameraPip'; import { buildCamera } from '$lib/cameraObjects'; + import { safeStorage } from '$lib/safeStorage'; let outlineEffectSelected: OutlineEffect | null = null; let outlineEffectLocked: OutlineEffect | null = null; @@ -428,7 +429,7 @@ }); // e2e hook (debugStores opt-in): the effects live in this component only onMount(() => { - if (typeof localStorage !== 'undefined' && localStorage.getItem('debugStores')) + if (typeof localStorage !== 'undefined' && safeStorage.getItem('debugStores')) (window as any).__outlineDebug = () => ({ selected: outlineEffectSelected?.selection.size ?? -1, locked: outlineEffectLocked?.selection.size ?? -1, @@ -439,7 +440,7 @@ // L1: the compiled chain lives in this component only, and its ORDER is the // thing worth asserting — so the hook names each pass by identity rather than // by constructor (minified in a build) and reports the merge plan. - if (typeof localStorage !== 'undefined' && localStorage.getItem('debugStores')) + if (typeof localStorage !== 'undefined' && safeStorage.getItem('debugStores')) (window as any).__postDebug = () => ({ chain: ((composer as any).passes ?? []).map((pass: any) => { if (pass === renderPass) return 'render'; diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 5cd65074..1e8a8915 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -87,6 +87,7 @@ import PathWaypoints from './PathWaypoints.svelte'; import LockHighlights from './LockHighlights.svelte'; import Grid from '../extensions/Grid.svelte'; + import { safeStorage } from '$lib/safeStorage'; import Outline from './Outline.svelte' import Player from './play/Player.svelte' import { Mesh, Vector3 } from 'three' @@ -99,23 +100,23 @@ $globalScene.background = new THREE.Color(0x101010); - $username = localStorage.getItem('username'); - $userdata.push([$peers.peer.id, localStorage.getItem('username'), localStorage.getItem('avatar'), null, null, get(avatarConfig)]); + $username = safeStorage.getItem('username'); + $userdata.push([$peers.peer.id, safeStorage.getItem('username'), safeStorage.getItem('avatar'), null, null, get(avatarConfig)]); $userdata = $userdata; - $showGrid = localStorage.getItem('showGrid') === 'false' ? false : true; - $vrOverride = localStorage.getItem('vrOverride'); + $showGrid = safeStorage.getItem('showGrid') === 'false' ? false : true; + $vrOverride = safeStorage.getItem('vrOverride'); camera.current.position.set(10.5, 7.57, 11.4); let fov = camera.current.fov let resetSettings = false; setTimeout(() => { // $peers.send({ type: 'userdata', userdata: $userdata }); - if(localStorage.getItem("camx")) - camera.current.position.x = localStorage.getItem("camx"); - if(localStorage.getItem("camy")) - camera.current.position.y = localStorage.getItem("camy"); - if(localStorage.getItem("camz")) - camera.current.position.z = localStorage.getItem("camz"); + if(safeStorage.getItem("camx")) + camera.current.position.x = safeStorage.getItem("camx"); + if(safeStorage.getItem("camy")) + camera.current.position.y = safeStorage.getItem("camy"); + if(safeStorage.getItem("camz")) + camera.current.position.z = safeStorage.getItem("camz"); // console.log(camera.current.position) resetSettings = true; @@ -276,9 +277,9 @@ // console.log(camera.current.rotation) } if (resetSettings == true) { - // localStorage.setItem("camx",camera.current.position.x); - // localStorage.setItem("camy",camera.current.position.y); - // localStorage.setItem("camz",camera.current.position.z); + // safeStorage.setItem("camx",camera.current.position.x); + // safeStorage.setItem("camy",camera.current.position.y); + // safeStorage.setItem("camz",camera.current.position.z); } if (!$specatorMode) { diff --git a/src/components/editors/AnimationWindow.svelte b/src/components/editors/AnimationWindow.svelte index 98f13ae1..f7829e7c 100644 --- a/src/components/editors/AnimationWindow.svelte +++ b/src/components/editors/AnimationWindow.svelte @@ -51,6 +51,7 @@ import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize'; import { setDockOccupant, dockHeight, visibleDockKey, dockMinimized, activateDock, dockModeArm, forgetDockTab } from '$lib/bottomDock'; import { bottomDockable } from '$lib/bottomDockDrop'; + import { safeStorage } from '$lib/safeStorage'; // live-follow the primary selection (keeps a truthy [] before the first select) const target = $derived($selectedObject && $selectedObject.uuid ? $selectedObject : null); @@ -93,12 +94,12 @@ let view = $state(/** @type {'sheet'|'graph'} */ ('sheet')); /** 'off' | 'frame' | a step in seconds as a string */ let snapMode = $state( - typeof localStorage !== 'undefined' ? (localStorage.getItem('animationSnap') ?? 'frame') : 'frame' + typeof localStorage !== 'undefined' ? (safeStorage.getItem('animationSnap') ?? 'frame') : 'frame' ); let renaming = $state(/** @type {string|null} */ (null)); // how tall the clip list is allowed to be, dragged by the divider under it let clipsH = $state( - typeof localStorage !== 'undefined' ? parseInt(localStorage.getItem('animationClipsH') ?? '96') || 96 : 96 + typeof localStorage !== 'undefined' ? parseInt(safeStorage.getItem('animationClipsH') ?? '96') || 96 : 96 ); let clipsResizing = $state(false); /** the sidebar's own height, measured — the resize ceiling comes from it */ @@ -130,7 +131,7 @@ if (!clipsResizing) return; clipsResizing = false; e.currentTarget.releasePointerCapture?.(e.pointerId); - localStorage.setItem('animationClipsH', String(clipsH)); + safeStorage.setItem('animationClipsH', String(clipsH)); } // imported clips for the selected object (empty for anything not imported @@ -215,12 +216,12 @@ let winW = $state(660); let winH = $state(460); if (typeof localStorage !== 'undefined') { - docked = localStorage.getItem('animationDocked') !== 'false'; + docked = safeStorage.getItem('animationDocked') !== 'false'; // 18-B: a size saved on a bigger screen must not come back oversized. // Fitted before the assignment so nothing reads $state during init. const savedWin = clampWinSize( - parseInt(localStorage.getItem('animationWinW') ?? '660') || 660, - parseInt(localStorage.getItem('animationWinH') ?? '460') || 460, + parseInt(safeStorage.getItem('animationWinW') ?? '660') || 660, + parseInt(safeStorage.getItem('animationWinH') ?? '460') || 460, WIN_MIN ); winW = savedWin.w; @@ -228,7 +229,7 @@ } function setDocked(/** @type {boolean} */ v) { docked = v; - localStorage.setItem('animationDocked', String(v)); + safeStorage.setItem('animationDocked', String(v)); if (v) activateDock('animation'); else forgetDockTab('animation'); // an undock gives up its slot, so re-docking is a fresh add at the end of the strip } @@ -443,7 +444,7 @@ // select exactly what the eye picks out, including under zoom and pan. /** @type {'box'|'lasso'} */ let marqMode = $state( - typeof localStorage !== 'undefined' && localStorage.getItem('animationMarquee') === 'lasso' + typeof localStorage !== 'undefined' && safeStorage.getItem('animationMarquee') === 'lasso' ? 'lasso' : 'box' ); @@ -459,7 +460,7 @@ function setMarqMode(/** @type {'box'|'lasso'} */ mode) { marqMode = mode; try { - localStorage.setItem('animationMarquee', mode); + safeStorage.setItem('animationMarquee', mode); } catch {} } @@ -710,7 +711,7 @@ // MEAN, and one object can hold a 24fps swing beside a 60fps flourish — with a // LOCAL default for clips that never set one (`animationFps` in localStorage). const DEFAULT_FPS = (() => { - const raw = typeof localStorage !== 'undefined' ? Number(localStorage.getItem('animationFps')) : NaN; + const raw = typeof localStorage !== 'undefined' ? Number(safeStorage.getItem('animationFps')) : NaN; return Number.isFinite(raw) && raw >= 1 && raw <= 240 ? raw : 30; })(); const FPS = $derived(anim?.fps ?? DEFAULT_FPS); @@ -1427,7 +1428,7 @@ tooltip: FPS + ' fps', action: () => { snapMode = snapMode === 'frame' ? 'off' : 'frame'; - localStorage.setItem('animationSnap', snapMode); + safeStorage.setItem('animationSnap', snapMode); } }); menu = { x: e.clientX, y: e.clientY, items }; @@ -1652,8 +1653,8 @@ saveWinSize(); } function saveWinSize() { - localStorage.setItem('animationWinW', String(winW)); - localStorage.setItem('animationWinH', String(winH)); + safeStorage.setItem('animationWinW', String(winW)); + safeStorage.setItem('animationWinH', String(winH)); } /** 18-B: double-click the grip — back to the default size, position kept */ function resetWinSize() { @@ -2086,7 +2087,7 @@ value={snapMode} onchange={(e) => { snapMode = e.currentTarget.value; - localStorage.setItem('animationSnap', snapMode); + safeStorage.setItem('animationSnap', snapMode); }} > diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte index 5141d61e..eea23e3d 100644 --- a/src/components/editors/Explorer.svelte +++ b/src/components/editors/Explorer.svelte @@ -288,6 +288,7 @@ import WindowShell from '../shared/WindowShell.svelte'; import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize'; import { fly } from 'svelte/transition'; + import { safeStorage } from '$lib/safeStorage'; const clampH = (h: number) => Math.min(Math.max(h || 300, 200), Math.round(window.innerHeight * 0.8)); @@ -313,25 +314,25 @@ // ('explorerHeight'). It is a dock TAB now, so the dock's shared height owns // it — adopt the old value once, then drop the key. try { - const legacyH = localStorage.getItem('explorerHeight'); + const legacyH = safeStorage.getItem('explorerHeight'); if (legacyH) { dockHeight.set(clampH(parseInt(legacyH) || 300)); - localStorage.removeItem('explorerHeight'); + safeStorage.removeItem('explorerHeight'); } } catch {} - docked = localStorage.getItem('explorerDocked') !== 'false'; + docked = safeStorage.getItem('explorerDocked') !== 'false'; // 18-B: a size saved on a bigger screen must not come back oversized — // that is the state whose resize grip sits off-screen. Fitted BEFORE the // assignment so nothing reads $state during init (state_referenced_locally). const savedWin = clampWinSize( - parseInt(localStorage.getItem('explorerWinW') ?? '720') || 720, - parseInt(localStorage.getItem('explorerWinH') ?? '440') || 440, + parseInt(safeStorage.getItem('explorerWinW') ?? '720') || 720, + parseInt(safeStorage.getItem('explorerWinH') ?? '440') || 440, WIN_MIN ); winW = savedWin.w; winH = savedWin.h; - singleClickOpen = localStorage.getItem('explorerSingleClickOpen') === 'true'; - showBreadcrumb = localStorage.getItem('explorerBreadcrumb') !== 'false'; + singleClickOpen = safeStorage.getItem('explorerSingleClickOpen') === 'true'; + showBreadcrumb = safeStorage.getItem('explorerBreadcrumb') !== 'false'; } // touch / limited-width: keep the Explorer docked (no room to float; undock hidden), // unless the user opted into undocking on touch (Settings > Allow undocking) @@ -347,7 +348,7 @@ function setDocked(v: boolean) { docked = v; - localStorage.setItem('explorerDocked', String(v)); + safeStorage.setItem('explorerDocked', String(v)); if (v) bottomDockActive.set('explorer'); // re-docking makes it the visible panel else forgetDockTab('explorer'); // an undock gives up its slot, so re-docking is a fresh add at the end of the strip } @@ -431,8 +432,8 @@ saveWinSize(); } function saveWinSize() { - localStorage.setItem('explorerWinW', String(winW)); - localStorage.setItem('explorerWinH', String(winH)); + safeStorage.setItem('explorerWinW', String(winW)); + safeStorage.setItem('explorerWinH', String(winH)); } /** 18-B: double-click the grip — back to the default size, position kept */ function resetWinSize() { @@ -722,34 +723,34 @@ let expanded = $state(new Set()); if (typeof localStorage !== 'undefined') { try { - expanded = new Set(JSON.parse(localStorage.getItem('explorerExpanded') ?? '[]')); + expanded = new Set(JSON.parse(safeStorage.getItem('explorerExpanded') ?? '[]')); } catch {} } function toggleExpand(id: string) { const next = new Set(expanded); next.has(id) ? next.delete(id) : next.add(id); expanded = next; - localStorage.setItem('explorerExpanded', JSON.stringify([...next])); + safeStorage.setItem('explorerExpanded', JSON.stringify([...next])); } // 197: Library is always open (no caret). Scene is pinned at the bottom and // collapsed by default; double-click it to reveal audio/config/textures. let sceneExpanded = $state( - typeof localStorage !== 'undefined' && localStorage.getItem('explorerSceneExpanded') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('explorerSceneExpanded') === 'true' ); function toggleScene() { sceneExpanded = !sceneExpanded; - localStorage.setItem('explorerSceneExpanded', String(sceneExpanded)); + safeStorage.setItem('explorerSceneExpanded', String(sceneExpanded)); } // N6: Packs section (mirror Scene) — expandable, lists packs; opening a pack // shows its items with lazily-resolved thumbnails. let packsExpanded = $state( - typeof localStorage !== 'undefined' && localStorage.getItem('explorerPacksExpanded') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('explorerPacksExpanded') === 'true' ); function togglePacks() { packsExpanded = !packsExpanded; - localStorage.setItem('explorerPacksExpanded', String(packsExpanded)); + safeStorage.setItem('explorerPacksExpanded', String(packsExpanded)); if (packsExpanded && $packs.length === 0) loadPacks(); } let thumbIdx: Record = $state({}); // per pack-item webp->png->screenshot cursor @@ -757,14 +758,14 @@ // 21-G8: the "Import project as folder (.tp)…" menu entry's hidden picker let tpImportInput: HTMLInputElement | undefined = $state(); let hideBuiltinPacks = $state( - typeof localStorage !== 'undefined' && localStorage.getItem('explorerHideBuiltinPacks') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('explorerHideBuiltinPacks') === 'true' ); // P5: per-pack hide (built-ins can't be truly deleted — they're bundled/CDN — so // hiding is the reversible alternative; imported packs delete outright) let hiddenPacks = $state(new Set(loadHiddenPacks())); function loadHiddenPacks(): string[] { try { - return JSON.parse(localStorage.getItem('explorerHiddenPacks') || '[]'); + return JSON.parse(safeStorage.getItem('explorerHiddenPacks') || '[]'); } catch { return []; } @@ -773,12 +774,12 @@ const s = new Set(hiddenPacks); s.add(name); hiddenPacks = s; - localStorage.setItem('explorerHiddenPacks', JSON.stringify([...s])); + safeStorage.setItem('explorerHiddenPacks', JSON.stringify([...s])); if ($activeFolder === 'pack:' + name) openFolder('packs'); } function showAllHiddenPacks() { hiddenPacks = new Set(); - localStorage.setItem('explorerHiddenPacks', '[]'); + safeStorage.setItem('explorerHiddenPacks', '[]'); } let shownPacks = $derived( $packs.filter( @@ -2426,7 +2427,7 @@ let treeColH = $state(0); let rootsResizing = $state(false); let rootsH = $state( - (typeof localStorage !== 'undefined' && parseInt(localStorage.getItem('explorerRootsH') ?? '')) || + (typeof localStorage !== 'undefined' && parseInt(safeStorage.getItem('explorerRootsH') ?? '')) || 160 ); @@ -2509,13 +2510,13 @@ if (!rootsResizing) return; rootsResizing = false; (e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId); - localStorage.setItem('explorerRootsH', String(rootsH)); + safeStorage.setItem('explorerRootsH', String(rootsH)); } // 18-B's rule for every grip in the app: a double-click restores a size you might // otherwise have no way to get back function resetRootsH() { rootsH = Math.min(160, rootsMax); - localStorage.setItem('explorerRootsH', String(rootsH)); + safeStorage.setItem('explorerRootsH', String(rootsH)); } // ---- R22 round 13 P3: THE MOUNTS SECTION ----------------------------------------- @@ -2656,7 +2657,7 @@ const next = new Set(expanded); next.add(volumeKey(vol.id)); expanded = next; - localStorage.setItem('explorerExpanded', JSON.stringify([...next])); + safeStorage.setItem('explorerExpanded', JSON.stringify([...next])); openFolder(volumeKey(vol.id)); } /** @@ -7601,7 +7602,7 @@ checked={singleClickOpen} onchange={(e) => { singleClickOpen = e.currentTarget.checked; - localStorage.setItem('explorerSingleClickOpen', String(singleClickOpen)); + safeStorage.setItem('explorerSingleClickOpen', String(singleClickOpen)); }} /> Single-click opens folders @@ -7613,7 +7614,7 @@ checked={showBreadcrumb} onchange={(e) => { showBreadcrumb = e.currentTarget.checked; - localStorage.setItem('explorerBreadcrumb', String(showBreadcrumb)); + safeStorage.setItem('explorerBreadcrumb', String(showBreadcrumb)); }} /> Show path bar @@ -7664,7 +7665,7 @@ checked={hideBuiltinPacks} onchange={(e) => { hideBuiltinPacks = e.currentTarget.checked; - localStorage.setItem('explorerHideBuiltinPacks', String(hideBuiltinPacks)); + safeStorage.setItem('explorerHideBuiltinPacks', String(hideBuiltinPacks)); }} /> Hide built-in packs diff --git a/src/components/editors/FlowCode.svelte b/src/components/editors/FlowCode.svelte index 42f6ec0b..f106f495 100644 --- a/src/components/editors/FlowCode.svelte +++ b/src/components/editors/FlowCode.svelte @@ -15,6 +15,7 @@ import { tabbable, resizeGroup, tabGroups } from '$lib/windowTabs'; import { setDockOccupant, dockHeight, visibleDockKey, dockMinimized, activateDock, dockModeArm, forgetDockTab } from '$lib/bottomDock'; import { bottomDockable } from '$lib/bottomDockDrop'; + import { safeStorage } from '$lib/safeStorage'; let text = $state(''); let error = $state(''); @@ -22,13 +23,13 @@ let winW = $state(460); let winH = $state(440); if (typeof localStorage !== 'undefined') { - docked = localStorage.getItem('flowCodeDocked') !== 'false'; // start docked - winW = parseInt(localStorage.getItem('flowCodeWinW') ?? '460') || 460; - winH = parseInt(localStorage.getItem('flowCodeWinH') ?? '440') || 440; + docked = safeStorage.getItem('flowCodeDocked') !== 'false'; // start docked + winW = parseInt(safeStorage.getItem('flowCodeWinW') ?? '460') || 460; + winH = parseInt(safeStorage.getItem('flowCodeWinH') ?? '440') || 440; } function setDocked(/** @type {boolean} */ v) { docked = v; - localStorage.setItem('flowCodeDocked', String(v)); + safeStorage.setItem('flowCodeDocked', String(v)); if (v) activateDock('flowcode'); else forgetDockTab('flowcode'); // an undock gives up its slot, so re-docking is a fresh add at the end of the strip } @@ -129,8 +130,8 @@ if (!winResizing) return; winResizing = false; e.currentTarget.releasePointerCapture?.(e.pointerId); - localStorage.setItem('flowCodeWinW', String(winW)); - localStorage.setItem('flowCodeWinH', String(winH)); + safeStorage.setItem('flowCodeWinW', String(winW)); + safeStorage.setItem('flowCodeWinH', String(winH)); } diff --git a/src/components/editors/HudEditor.svelte b/src/components/editors/HudEditor.svelte index 9978e794..8840d17f 100644 --- a/src/components/editors/HudEditor.svelte +++ b/src/components/editors/HudEditor.svelte @@ -68,6 +68,7 @@ import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize'; import { setDockOccupant, dockHeight, visibleDockKey, dockMinimized, activateDock, dockModeArm, forgetDockTab } from '$lib/bottomDock'; import { bottomDockable } from '$lib/bottomDockDrop'; + import { safeStorage } from '$lib/safeStorage'; // 21-D5: WHICH document is being authored. `hudDocs` was already keyed // `'scene' | objectUuid`, so "attach this HUD to a camera" is simply authoring the @@ -116,10 +117,10 @@ let winW = $state(680); let winH = $state(480); if (typeof localStorage !== 'undefined') { - docked = localStorage.getItem('hudDocked') !== 'false'; + docked = safeStorage.getItem('hudDocked') !== 'false'; const saved = clampWinSize( - parseInt(localStorage.getItem('hudWinW') ?? '680') || 680, - parseInt(localStorage.getItem('hudWinH') ?? '480') || 480, + parseInt(safeStorage.getItem('hudWinW') ?? '680') || 680, + parseInt(safeStorage.getItem('hudWinH') ?? '480') || 480, WIN_MIN ); winW = saved.w; @@ -127,7 +128,7 @@ } function setDocked(/** @type {boolean} */ v) { docked = v; - localStorage.setItem('hudDocked', String(v)); + safeStorage.setItem('hudDocked', String(v)); if (v) activateDock('hud'); else forgetDockTab('hud'); // an undock gives up its slot, so re-docking is a fresh add at the end of the strip } @@ -181,7 +182,7 @@ const SCREENS_RESERVE = 148; let paneH = $state(0); let screensH = $state( - parseInt((typeof localStorage !== 'undefined' && localStorage.getItem('hudScreens:h')) || '132') || 132 + parseInt((typeof localStorage !== 'undefined' && safeStorage.getItem('hudScreens:h')) || '132') || 132 ); let screensResizing = $state(false); const screensMax = $derived(Math.max(56, (paneH || 320) - SCREENS_RESERVE)); @@ -203,7 +204,7 @@ screensResizing = false; e.currentTarget.releasePointerCapture?.(e.pointerId); try { - localStorage.setItem('hudScreens:h', String(screensH)); + safeStorage.setItem('hudScreens:h', String(screensH)); } catch {} } @@ -275,20 +276,20 @@ /** @param {string} key @param {number} fallback */ function snapPref(key, fallback) { if (typeof localStorage === 'undefined') return fallback; - const raw = localStorage.getItem(key); + const raw = safeStorage.getItem(key); const n = raw === null ? NaN : parseFloat(raw); return Number.isFinite(n) ? n : fallback; } let snapOn = $state( - typeof localStorage === 'undefined' ? SNAP_DEFAULTS.on : localStorage.getItem('hud:snapOn') !== 'false' + typeof localStorage === 'undefined' ? SNAP_DEFAULTS.on : safeStorage.getItem('hud:snapOn') !== 'false' ); let snapGrid = $state(Math.max(1, snapPref('hud:snapGrid', SNAP_DEFAULTS.grid))); let snapThreshold = $state(Math.max(0, snapPref('hud:snapThreshold', SNAP_DEFAULTS.threshold))); $effect(() => { try { - localStorage.setItem('hud:snapOn', String(snapOn)); - localStorage.setItem('hud:snapGrid', String(snapGrid)); - localStorage.setItem('hud:snapThreshold', String(snapThreshold)); + safeStorage.setItem('hud:snapOn', String(snapOn)); + safeStorage.setItem('hud:snapGrid', String(snapGrid)); + safeStorage.setItem('hud:snapThreshold', String(snapThreshold)); } catch {} }); // the lines the LIVE gesture is actually sitting on, drawn as 1px overlays. Cleared @@ -948,8 +949,8 @@ saveWinSize(); } function saveWinSize() { - localStorage.setItem('hudWinW', String(winW)); - localStorage.setItem('hudWinH', String(winH)); + safeStorage.setItem('hudWinW', String(winW)); + safeStorage.setItem('hudWinH', String(winH)); } function resetWinSize() { const fit = clampWinSize(WIN_DEFAULT.w, WIN_DEFAULT.h, WIN_MIN); diff --git a/src/components/editors/Nodes.svelte b/src/components/editors/Nodes.svelte index 875be0b4..c7ec646a 100644 --- a/src/components/editors/Nodes.svelte +++ b/src/components/editors/Nodes.svelte @@ -71,6 +71,7 @@ import { isValidFlowConnection, typeColor, replaceableInputEdges } from '$lib/flowSockets'; import { moduleNodeGroups, moduleNodeComponents } from '$lib/moduleSDK'; import { peers, username, modulesOpen, flowFocus } from '../../stores/appStore'; + import { safeStorage } from '$lib/safeStorage'; // 21-D7: DEEP LINK — 'show me the node that drives this HUD element'. A write-once // request that we act on and CLEAR, the inspectorScrollTo shape, so it cannot re-fire @@ -277,7 +278,7 @@ // 3775px for a 200px gesture. A test that needs to press a field needs this. $effect(() => { if (typeof window === 'undefined' || typeof localStorage === 'undefined') return; - if (localStorage.getItem('debugStores') !== 'true') return; + if (safeStorage.getItem('debugStores') !== 'true') return; // TS syntax, not a JSDoc cast: this file is lang="ts", where JSDoc @type is IGNORED (window as any).__flowViewport = { setViewport, fitView }; // A6.4: which types this MOUNTED pane can actually render, plus the snapshot it @@ -300,10 +301,10 @@ // inset its content above the Controls HUD only when the palette is actually shown. let { paletteOpen = $bindable( - typeof localStorage === 'undefined' || localStorage.getItem('flowPaletteOpen') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('flowPaletteOpen') !== 'false' ) }: { paletteOpen?: boolean } = $props(); - let paletteSide = $state(typeof localStorage !== 'undefined' ? localStorage.getItem('flowPaletteSide') ?? 'left' : 'left'); + let paletteSide = $state(typeof localStorage !== 'undefined' ? safeStorage.getItem('flowPaletteSide') ?? 'left' : 'left'); // #20 P7: the left column's own height, measured — the graph tree's resize ceiling let paletteColH = $state(0); @@ -687,7 +688,7 @@ title={paletteOpen ? 'Hide the node palette' : 'Show the node palette'} onclick={() => { paletteOpen = !paletteOpen; - localStorage.setItem('flowPaletteOpen', String(paletteOpen)); + safeStorage.setItem('flowPaletteOpen', String(paletteOpen)); }} > {paletteOpen ? (paletteSide === 'right' ? '▸' : '◂') : paletteSide === 'right' ? '◂' : '▸'} @@ -699,7 +700,7 @@ title="Move the palette to the other side" onclick={() => { paletteSide = paletteSide === 'right' ? 'left' : 'right'; - localStorage.setItem('flowPaletteSide', paletteSide); + safeStorage.setItem('flowPaletteSide', paletteSide); }} > ⇄ diff --git a/src/components/editors/ShaderEditor.svelte b/src/components/editors/ShaderEditor.svelte index 2cffb660..abee349a 100644 --- a/src/components/editors/ShaderEditor.svelte +++ b/src/components/editors/ShaderEditor.svelte @@ -61,6 +61,7 @@ import ShaderTexturePicker from './nodes/ShaderTexturePicker.svelte'; import ShaderVectorInput from './nodes/ShaderVectorInput.svelte'; import DragRow from '../ui/DragRow.svelte'; + import { safeStorage } from '$lib/safeStorage'; const nodeTypes = Object.fromEntries(shaderNodeDefs().map((def) => [def.key, ShaderNode])); const catalog = shaderNodeDefs().filter((def) => def.key !== SURFACE_NODE); @@ -375,12 +376,12 @@ let winW = $state(720); let winH = $state(480); if (typeof localStorage !== 'undefined') { - docked = localStorage.getItem('shaderDocked') !== 'false'; + docked = safeStorage.getItem('shaderDocked') !== 'false'; // 18-B: a size saved on a bigger screen must not come back oversized. Fitted // BEFORE the assignment so nothing reads $state during init. const savedWin = clampWinSize( - parseInt(localStorage.getItem('shaderWinW') ?? '720') || 720, - parseInt(localStorage.getItem('shaderWinH') ?? '480') || 480, + parseInt(safeStorage.getItem('shaderWinW') ?? '720') || 720, + parseInt(safeStorage.getItem('shaderWinH') ?? '480') || 480, WIN_MIN ); winW = savedWin.w; @@ -397,7 +398,7 @@ function setDocked(/** @type {boolean} */ v) { docked = v; - localStorage.setItem('shaderDocked', String(v)); + safeStorage.setItem('shaderDocked', String(v)); if (v) activateDock('shader'); // re-docking makes it the visible tab else forgetDockTab('shader'); // an undock gives up its slot, so re-docking is a fresh add at the end of the strip } @@ -479,8 +480,8 @@ saveWinSize(); } function saveWinSize() { - localStorage.setItem('shaderWinW', String(winW)); - localStorage.setItem('shaderWinH', String(winH)); + safeStorage.setItem('shaderWinW', String(winW)); + safeStorage.setItem('shaderWinH', String(winH)); } function resetWinSize() { const fit = clampWinSize(WIN_DEFAULT.w, WIN_DEFAULT.h, WIN_MIN); diff --git a/src/components/editors/UvEditor.svelte b/src/components/editors/UvEditor.svelte index b6799246..912cdb56 100644 --- a/src/components/editors/UvEditor.svelte +++ b/src/components/editors/UvEditor.svelte @@ -47,6 +47,7 @@ import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize'; import { setDockOccupant, dockHeight, visibleDockKey, dockMinimized, activateDock, dockModeArm, forgetDockTab } from '$lib/bottomDock'; import { bottomDockable } from '$lib/bottomDockDrop'; + import { safeStorage } from '$lib/safeStorage'; /** the armed transform modes, in 1/2/3 order */ const MODES = /** @type {['move'|'rotate'|'scale', string, string][]} */ ([ @@ -128,12 +129,12 @@ let winW = $state(640); let winH = $state(460); if (typeof localStorage !== 'undefined') { - docked = localStorage.getItem('uvDocked') !== 'false'; + docked = safeStorage.getItem('uvDocked') !== 'false'; // 18-B: a size saved on a bigger screen must not come back oversized. // Fitted before the assignment so nothing reads $state during init. const savedWin = clampWinSize( - parseInt(localStorage.getItem('uvWinW') ?? '640') || 640, - parseInt(localStorage.getItem('uvWinH') ?? '460') || 460, + parseInt(safeStorage.getItem('uvWinW') ?? '640') || 640, + parseInt(safeStorage.getItem('uvWinH') ?? '460') || 460, WIN_MIN ); winW = savedWin.w; @@ -141,7 +142,7 @@ } function setDocked(/** @type {boolean} */ v) { docked = v; - localStorage.setItem('uvDocked', String(v)); + safeStorage.setItem('uvDocked', String(v)); if (v) activateDock('uv'); else forgetDockTab('uv'); // an undock gives up its slot, so re-docking is a fresh add at the end of the strip } @@ -1543,8 +1544,8 @@ saveWinSize(); } function saveWinSize() { - localStorage.setItem('uvWinW', String(winW)); - localStorage.setItem('uvWinH', String(winH)); + safeStorage.setItem('uvWinW', String(winW)); + safeStorage.setItem('uvWinH', String(winH)); } /** 18-B: double-click the grip — back to the default size, position kept */ function resetWinSize() { diff --git a/src/components/menu/CharacterModal.svelte b/src/components/menu/CharacterModal.svelte index db2712d2..479eda41 100644 --- a/src/components/menu/CharacterModal.svelte +++ b/src/components/menu/CharacterModal.svelte @@ -3,6 +3,7 @@ import ThemedSelect from '../ui/ThemedSelect.svelte'; import { characterModalOpen, avatarConfig, userdata, peers } from '../../stores/appStore.js'; import { FACE_SHAPES, resolveAvatar } from '$lib/avatarModel'; + import { safeStorage } from '$lib/safeStorage'; // resolve so shape/showLabel have defaults even for older stored configs $: cfg = resolveAvatar($avatarConfig); @@ -29,7 +30,7 @@ function update(partial: any) { const next = { ...$avatarConfig, ...partial }; $avatarConfig = next; - localStorage.setItem('avatarConfig', JSON.stringify(next)); + safeStorage.setItem('avatarConfig', JSON.stringify(next)); // update our own userdata row and broadcast $userdata.forEach((element) => { if (element[0] === $peers.peer.id) element[5] = next; diff --git a/src/components/menu/Connect.svelte b/src/components/menu/Connect.svelte index fe53390a..49db56a6 100644 --- a/src/components/menu/Connect.svelte +++ b/src/components/menu/Connect.svelte @@ -12,6 +12,7 @@ import { connectSlot, drawerSlot } from '$lib/cloudHooks'; import CloudSlot from '../CloudSlot.svelte'; import ConnectInfoDrawer from './ConnectInfoDrawer.svelte'; + import { safeStorage } from '$lib/safeStorage'; let peerIdToConnect = $state(''); let displayid = $state('Generating...'); @@ -168,10 +169,10 @@ // is fine for a quick try but not recommended for real use. Shown once. try { const isLocalVersion = !/(\.io|\.app)$/i.test(location.hostname); - const firstRun = !localStorage.getItem('peerServerConfig'); - const seen = localStorage.getItem('localPeerNoticeSeen'); + const firstRun = !safeStorage.getItem('peerServerConfig'); + const seen = safeStorage.getItem('localPeerNoticeSeen'); if (isLocalVersion && firstRun && !seen) { - localStorage.setItem('localPeerNoticeSeen', '1'); + safeStorage.setItem('localPeerNoticeSeen', '1'); showToast( 'It looks like you are running a local build of theprototype. Configure a peer signaling server in Settings for reliable connections — the public PeerJS cloud is not recommended for real use.', [ diff --git a/src/components/menu/Controls.svelte b/src/components/menu/Controls.svelte index bc500bda..4e48ad99 100644 --- a/src/components/menu/Controls.svelte +++ b/src/components/menu/Controls.svelte @@ -37,6 +37,7 @@ import { togglePanel } from '$lib/panelToggles'; import { requestPlay, willEnterXR, willEnterAR, vrSupported, arSupported, xrSessionFailed } from '$lib/playMode'; import { DOCK_VIEWS } from '$lib/dockMenu'; + import { safeStorage } from '$lib/safeStorage'; import { VRButton, XRButton } from '@threlte/xr' // A panel is "shown" when it is open AND either the visible dock tab OR floating @@ -313,7 +314,7 @@ let hiddenChips: Set = $state( new Set( typeof localStorage !== 'undefined' - ? JSON.parse(localStorage.getItem('hiddenListChips') ?? '[]') + ? JSON.parse(safeStorage.getItem('hiddenListChips') ?? '[]') : [] ) ); @@ -327,7 +328,7 @@ if (viewMode === value) viewMode = ''; } hiddenChips = next; - localStorage.setItem('hiddenListChips', JSON.stringify([...next])); + safeStorage.setItem('hiddenListChips', JSON.stringify([...next])); } function resetAllFilters() { searchTerm = ''; @@ -335,7 +336,7 @@ lastTypes = new Set(); viewMode = ''; hiddenChips = new Set(); - localStorage.setItem('hiddenListChips', '[]'); + safeStorage.setItem('hiddenListChips', '[]'); chipPopup = false; } @@ -429,7 +430,7 @@ // --- advanced mode: System filter shows scene-root module/env objects --- let systemRows = $state([]); let systemNoticeDismissed = $state( - typeof localStorage !== 'undefined' && localStorage.getItem('systemNoticeDismissed') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('systemNoticeDismissed') === 'true' ); let expandedSystem = $state({}); function refreshSystemRows() { @@ -478,7 +479,7 @@ // --- environment filter (70.4): read-only rows for environment-root --- let envRows = $state([]); let envNoticeDismissed = $state( - typeof localStorage !== 'undefined' && localStorage.getItem('envNoticeDismissed') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('envNoticeDismissed') === 'true' ); function refreshEnvRows() { const scene = $globalScene; @@ -534,7 +535,7 @@ // 80.1: proper resize (start-size captured, clamped) + persisted rect let saved: any = null; try { - saved = JSON.parse(localStorage.getItem('objectListRect') ?? 'null'); + saved = JSON.parse(safeStorage.getItem('objectListRect') ?? 'null'); } catch {} let moving = false; let left = saved?.left ?? 350; @@ -589,7 +590,7 @@ } const persist = () => - localStorage.setItem( + safeStorage.setItem( 'objectListRect', JSON.stringify({ left, top, width: node.offsetWidth, height: node.offsetHeight }) ); @@ -735,7 +736,7 @@ // vrOverride is the STRING mirror Settings writes; Scene seeds the store // from localStorage on boot, so both halves have to move together. vrOverride.set(true); - localStorage.setItem('vrOverride', 'true'); + safeStorage.setItem('vrOverride', 'true'); requestPlay(); } }, @@ -746,9 +747,9 @@ tooltip: $vrSupported ? 'Immersive VR — the scene replaces your view' : 'No immersive-vr support detected', action: () => { vrOverride.set(false); - localStorage.removeItem('vrOverride'); + safeStorage.removeItem('vrOverride'); vrPassthrough.set(false); - localStorage.setItem('vrPassthrough', 'false'); + safeStorage.setItem('vrPassthrough', 'false'); requestPlay(); } }, @@ -761,9 +762,9 @@ : 'No immersive-ar (passthrough) support detected', action: () => { vrOverride.set(false); - localStorage.removeItem('vrOverride'); + safeStorage.removeItem('vrOverride'); vrPassthrough.set(true); - localStorage.setItem('vrPassthrough', 'true'); + safeStorage.setItem('vrPassthrough', 'true'); requestPlay(); } }, @@ -923,7 +924,7 @@ function loadLayout(): ControlsLayout { if (typeof localStorage === 'undefined') return defaultLayout(); try { - const raw = localStorage.getItem('controlsLayout'); + const raw = safeStorage.getItem('controlsLayout'); if (!raw) return defaultLayout(); const saved = JSON.parse(raw) ?? {}; // W8b: kept ids are the ones the REGISTRY knows, not the ones the DEFAULT order @@ -962,7 +963,7 @@ function saveLayout() { try { - localStorage.setItem('controlsLayout', JSON.stringify(controlsLayout)); + safeStorage.setItem('controlsLayout', JSON.stringify(controlsLayout)); } catch { // private mode / storage full — the bar still works for this session } @@ -978,7 +979,7 @@ function resetLayout() { controlsLayout = defaultLayout(); try { - localStorage.removeItem('controlsLayout'); + safeStorage.removeItem('controlsLayout'); } catch { // nothing to clear } @@ -1115,7 +1116,7 @@ * than duplicated, so the two rows can say which one is on. `setDocked` keeps this * flag in step with the panel, so it is the honest answer either way. */ function explorerOpensDocked(): boolean { - return typeof localStorage === 'undefined' || localStorage.getItem('explorerDocked') !== 'false'; + return typeof localStorage === 'undefined' || safeStorage.getItem('explorerDocked') !== 'false'; } /** Move the Explorer between dock tab and floating window. @@ -2115,7 +2116,7 @@ class="rounded-sm bg-gray-600 px-1 text-white" on:click={() => { systemNoticeDismissed = true; - localStorage.setItem('systemNoticeDismissed', 'true'); + safeStorage.setItem('systemNoticeDismissed', 'true'); }}>✕ {/if} @@ -2168,7 +2169,7 @@ class="rounded-sm bg-gray-600 px-1 text-white" on:click={() => { envNoticeDismissed = true; - localStorage.setItem('envNoticeDismissed', 'true'); + safeStorage.setItem('envNoticeDismissed', 'true'); }}>✕ {/if} diff --git a/src/components/menu/Inspector.svelte b/src/components/menu/Inspector.svelte index c4f2393c..238d2b3e 100644 --- a/src/components/menu/Inspector.svelte +++ b/src/components/menu/Inspector.svelte @@ -200,6 +200,7 @@ saveSnapAnchorAsOrigin } from '$lib/snapEngine'; import { peers, inspectorClose, inspectorKind, inspectorPinned, showToast, inspectorFilter, notesDrawerOpen } from '../../stores/appStore.js'; + import { safeStorage } from '$lib/safeStorage'; import { isShaderDriven, openShaderEditor, @@ -278,7 +279,7 @@ let inspectorH = $state(0); $effect(() => { if (inspectorH || typeof window === 'undefined') return; - const saved = parseInt(localStorage.getItem('inspectorSheetH') || ''); + const saved = parseInt(safeStorage.getItem('inspectorSheetH') || ''); inspectorH = !saved || Number.isNaN(saved) ? Math.round(window.innerHeight * 0.45) : saved; }); let insResizing = $state(false); @@ -303,7 +304,7 @@ insResizing = false; /** @type {HTMLElement} */ (e.currentTarget).releasePointerCapture?.(e.pointerId); try { - localStorage.setItem('inspectorSheetH', String(inspectorH)); + safeStorage.setItem('inspectorSheetH', String(inspectorH)); } catch {} } @@ -1846,8 +1847,8 @@ checked={!!$showGrid} onchange={() => { showGrid.update((v) => !v); - if (localStorage.getItem('showGrid')) localStorage.removeItem('showGrid'); - else localStorage.setItem('showGrid', 'false'); + if (safeStorage.getItem('showGrid')) safeStorage.removeItem('showGrid'); + else safeStorage.setItem('showGrid', 'false'); }}>Show grid { - if (localStorage.getItem('showGrid')) localStorage.removeItem('showGrid'); - else localStorage.setItem('showGrid', 'false'); + if (safeStorage.getItem('showGrid')) safeStorage.removeItem('showGrid'); + else safeStorage.setItem('showGrid', 'false'); }} /> Display grid on floor @@ -1436,8 +1437,8 @@ { - if (localStorage.getItem('vrOverride')) localStorage.removeItem('vrOverride'); - else localStorage.setItem('vrOverride', 'true'); + if (safeStorage.getItem('vrOverride')) safeStorage.removeItem('vrOverride'); + else safeStorage.setItem('vrOverride', 'true'); }} /> Forces normal play even if immersive-vr is enabled @@ -1448,7 +1449,7 @@ checked={$vrFlying} onchange={(e) => { $vrFlying = e.target.checked; - localStorage.setItem('vrFlying', String($vrFlying)); + safeStorage.setItem('vrFlying', String($vrFlying)); }} /> Left-stick movement follows where the controller points (fly); off = stay level @@ -1463,7 +1464,7 @@ checked={$vrPassthrough} onchange={(e: any) => { $vrPassthrough = e.target.checked; - localStorage.setItem('vrPassthrough', String($vrPassthrough)); + safeStorage.setItem('vrPassthrough', String($vrPassthrough)); showToast('Passthrough ' + ($vrPassthrough ? 'on' : 'off') + ' — takes effect on the next VR entry'); }} /> @@ -1476,7 +1477,7 @@ onclick={() => { const next = $vrMenuHand === 'left' ? 'right' : 'left'; $vrMenuHand = next; - localStorage.setItem('vrMenuHand', next); + safeStorage.setItem('vrMenuHand', next); }} /> Which controller opens the VR quick-menu (the other hand points) @@ -1488,7 +1489,7 @@ checked={$vrMenuHold} onchange={(e: any) => { $vrMenuHold = e.target.checked; - localStorage.setItem('vrMenuHold', String($vrMenuHold)); + safeStorage.setItem('vrMenuHold', String($vrMenuHold)); }} /> Hold B/Y to show the radial menu, release over a sector to pick it (off = press toggles) @@ -1505,7 +1506,7 @@ value={$vrSnapAngle} onchange={(v) => { $vrSnapAngle = parseInt(v); - localStorage.setItem('vrSnapAngle', String($vrSnapAngle)); + safeStorage.setItem('vrSnapAngle', String($vrSnapAngle)); }} /> @@ -1518,7 +1519,7 @@ checked={$vrMirrorSnapTurn} onchange={(e: any) => { $vrMirrorSnapTurn = e.target.checked; - localStorage.setItem('vrMirrorSnapTurn', String($vrMirrorSnapTurn)); + safeStorage.setItem('vrMirrorSnapTurn', String($vrMirrorSnapTurn)); }} /> Flip the flick direction — left turns right and vice-versa @@ -1530,7 +1531,7 @@ checked={$vrTeleportEnabled} onchange={(e: any) => { $vrTeleportEnabled = e.target.checked; - localStorage.setItem('vrTeleportEnabled', String($vrTeleportEnabled)); + safeStorage.setItem('vrTeleportEnabled', String($vrTeleportEnabled)); }} /> Right-stick-up teleport arc — off if you navigate only by stick/fly @@ -1542,7 +1543,7 @@ checked={$vrSleeveEnabled} onchange={(e: any) => { $vrSleeveEnabled = e.target.checked; - localStorage.setItem('vrSleeveEnabled', String($vrSleeveEnabled)); + safeStorage.setItem('vrSleeveEnabled', String($vrSleeveEnabled)); }} /> Experimental — a strip of ghost primitives on your forearm: trigger-drag one out to place it (stick scales, wrist rotates). Grip-drop an object onto the strip to keep it as a personal slot @@ -1554,7 +1555,7 @@ checked={$vrVertexHold} onchange={(e: any) => { $vrVertexHold = e.target.checked; - localStorage.setItem('vrVertexHold', String($vrVertexHold)); + safeStorage.setItem('vrVertexHold', String($vrVertexHold)); }} /> Hold the trigger to carry a vertex (release drops it); off = press to grab, press again to drop @@ -2248,7 +2249,7 @@ {#snippet footer()} - + {/snippet} diff --git a/src/components/menu/Sidebar.svelte b/src/components/menu/Sidebar.svelte index ce8156d5..db2154d0 100644 --- a/src/components/menu/Sidebar.svelte +++ b/src/components/menu/Sidebar.svelte @@ -25,6 +25,7 @@ import { sidebarSlot } from '$lib/cloudHooks'; import CloudSlot from '../CloudSlot.svelte'; import { whatsNewUnseen, openWhatsNew } from '$lib/whatsNew'; + import { safeStorage } from '$lib/safeStorage'; // 203: redesigned as a compact floating panel — flat list (order preserved, // no boxed group / section headers / vertical bar), a fast fade-in (was a @@ -44,8 +45,8 @@ // your work, and it was taking a permanent third of a row from the two that are. // An enabled optional format renders on a SECOND ROW rather than widening the first, // so the primary pair never moves as the cog is toggled. - const initShowJson = typeof localStorage !== 'undefined' && localStorage.getItem('showJsonFormat') === 'true'; - const initShowGltf = typeof localStorage !== 'undefined' && localStorage.getItem('showGltfFormat') === 'true'; + const initShowJson = typeof localStorage !== 'undefined' && safeStorage.getItem('showJsonFormat') === 'true'; + const initShowGltf = typeof localStorage !== 'undefined' && safeStorage.getItem('showGltfFormat') === 'true'; /** * A STORED format can name one that is no longer on screen — a Save button pointing * at a control the user cannot see, which is the bug the JSON rule already existed @@ -57,7 +58,7 @@ if (f === 'gltf' && !gltf) return 'tp'; return f; } - const initFormat = typeof localStorage !== 'undefined' ? localStorage.getItem('saveFormat') || 'tp' : 'tp'; + const initFormat = typeof localStorage !== 'undefined' ? safeStorage.getItem('saveFormat') || 'tp' : 'tp'; let saveFormat = $state(visibleFormat(initFormat, initShowJson, initShowGltf)); let showJson = $state(initShowJson); let showGltf = $state(initShowGltf); @@ -78,9 +79,9 @@ exportPos = { top, left }; exportSettingsOpen = true; } - let tpAssets = $state(typeof localStorage !== 'undefined' && localStorage.getItem('tpsceneAssets') !== 'false'); - let tpPacks = $state(typeof localStorage !== 'undefined' && localStorage.getItem('tpscenePacks') === 'true'); - let tpFlow = $state(typeof localStorage !== 'undefined' && localStorage.getItem('tpsceneFlow') !== 'false'); + let tpAssets = $state(typeof localStorage !== 'undefined' && safeStorage.getItem('tpsceneAssets') !== 'false'); + let tpPacks = $state(typeof localStorage !== 'undefined' && safeStorage.getItem('tpscenePacks') === 'true'); + let tpFlow = $state(typeof localStorage !== 'undefined' && safeStorage.getItem('tpsceneFlow') !== 'false'); // 21-I5 (locked answer 2): the PROJECT box is ON by default, because a .tp has carried // its scene history since 21-G3 and flipping that off silently would make an existing // behaviour vanish — and it gates machinery with its own proper import. @@ -90,10 +91,10 @@ // an unnamed or never-travelled scene has no manifest entry, so the box that used to // sit here silently bundled nothing. The Explorer's scene card knows the name and the // history unambiguously, so downloading versions lives on ITS menu instead. - let tpProjectVersions = $state(typeof localStorage === 'undefined' || localStorage.getItem('tpProjectVersions') !== 'false'); + let tpProjectVersions = $state(typeof localStorage === 'undefined' || safeStorage.getItem('tpProjectVersions') !== 'false'); function pickFormat(f: string) { saveFormat = f; - localStorage.setItem('saveFormat', f); + safeStorage.setItem('saveFormat', f); } /** Called after either cog checkbox moves: if what is selected just went off screen, * fall back (and PERSIST the fallback — the stored value is what the next boot reads). */ @@ -291,31 +292,31 @@

Export settings

Scene (.tpscene) includes:

Project (.tp) includes:

diff --git a/src/components/menu/Toasts.svelte b/src/components/menu/Toasts.svelte index 92d4fcdd..67258067 100644 --- a/src/components/menu/Toasts.svelte +++ b/src/components/menu/Toasts.svelte @@ -38,6 +38,7 @@ import { peerScenes, elsewhereThan, PRIVATE_SCENE } from '$lib/peerScenes'; import { currentLevel } from '$lib/levels'; import { showToast } from '../../stores/appStore'; + import { safeStorage } from '$lib/safeStorage'; /** * Stop watching and give the camera back. EXTRACTED from the banner button so the @@ -389,9 +390,9 @@ $effect(() => { $effect(() => { const notice = $appNotice; - const seen = typeof localStorage !== 'undefined' && !!localStorage.getItem('hasSeenDisclaimer'); + const seen = typeof localStorage !== 'undefined' && !!safeStorage.getItem('hasSeenDisclaimer'); const markSeen = () => { - try { localStorage.setItem('hasSeenDisclaimer', 'true'); } catch {} + try { safeStorage.setItem('hasSeenDisclaimer', 'true'); } catch {} }; if (notice && !seen) showInfoToast( @@ -557,7 +558,7 @@ style="z-index: var(--z-toast-low); pointer-events: none;" {#if $fixLight}
- { localStorage.setItem('hasSeenDisclaimer', 'true'); } + { safeStorage.setItem('hasSeenDisclaimer', 'true'); } }>
diff --git a/src/components/menu/Users.svelte b/src/components/menu/Users.svelte index 1ff4e389..50a29047 100644 --- a/src/components/menu/Users.svelte +++ b/src/components/menu/Users.svelte @@ -106,6 +106,7 @@ import NotificationCenter from './NotificationCenter.svelte'; import CloudSlot from '../CloudSlot.svelte'; import { usersSlot, profileSlot, rolesInfo, scenePresence } from '$lib/cloudHooks'; + import { safeStorage } from '$lib/safeStorage'; // N3: latency-band dot color for a peer's network-quality indicator const qColor = (level: string) => @@ -145,7 +146,7 @@ const reader = new FileReader(); reader.onload = function(fileLoadedEvent) { avatarImage = fileLoadedEvent.target.result; - localStorage.setItem('avatar', avatarImage); + safeStorage.setItem('avatar', avatarImage); //find and update, same for image $userdata.forEach(element => { @@ -159,7 +160,7 @@ }; reader.readAsDataURL(avatarFile); // an uploaded image is a CUSTOM avatar - try { localStorage.removeItem('avatarReset'); } catch {} + try { safeStorage.removeItem('avatarReset'); } catch {} } } @@ -168,7 +169,7 @@ // (pushed by the plugin via cloudApi.setAccountIdentity -> $cloudIdentity) UNLESS // the user set a custom one. "Custom username" = the usernameCustom flag; "custom // avatar" = an uploaded image in localStorage.avatar. - const ls = (k: string) => (typeof localStorage !== 'undefined' ? localStorage.getItem(k) : null); + const ls = (k: string) => (typeof localStorage !== 'undefined' ? safeStorage.getItem(k) : null); const usernameIsCustom = () => ls('usernameCustom') === '1'; const cid = $derived($cloudIdentity); /** what the header/button/peers show */ @@ -196,7 +197,7 @@ /** @param {string} v */ function setPeersView(v: string) { peersView = v; - try { localStorage.setItem('peers:view', v); } catch {} + try { safeStorage.setItem('peers:view', v); } catch {} } /** WHO AM I in the roster. The flat list has always taken index 0 as self (userdata * is built that way), so the fallback is not a guess — it is the same rule, reached @@ -443,15 +444,15 @@ function onUsernameEdited() { try { - localStorage.setItem('username', $username || ''); - localStorage.setItem('usernameCustom', ($username || '').trim() ? '1' : '0'); + safeStorage.setItem('username', $username || ''); + safeStorage.setItem('usernameCustom', ($username || '').trim() ? '1' : '0'); } catch {} broadcastUserdata(); } function resetAvatarToDefault() { avatarImage = ''; - try { localStorage.removeItem('avatar'); } catch {} + try { safeStorage.removeItem('avatar'); } catch {} broadcastUserdata(); // falls back to the cloud-account avatar (or default) } @@ -985,7 +986,7 @@ > {/if} - {#if avatarImage || (typeof localStorage !== 'undefined' && localStorage.getItem('avatar'))} + {#if avatarImage || (typeof localStorage !== 'undefined' && safeStorage.getItem('avatar'))} {/if} diff --git a/src/components/menu/ViewportMenu.svelte b/src/components/menu/ViewportMenu.svelte index a0f84745..67a611c9 100644 --- a/src/components/menu/ViewportMenu.svelte +++ b/src/components/menu/ViewportMenu.svelte @@ -18,6 +18,7 @@ import { togglePanel } from '$lib/panelToggles'; import { trackpadMode } from '$lib/trackpadNav'; import { helpersInPlay } from '$lib/helperLayer'; + import { safeStorage } from '$lib/safeStorage'; // Scene.svelte routes right-TAPS here (77): empty viewport → this menu with // the clicked ground point; an object under the cursor → its own context @@ -264,8 +265,8 @@ checked: !!$showGrid, action: () => { showGrid.update((v) => !v); - if (localStorage.getItem('showGrid')) localStorage.removeItem('showGrid'); - else localStorage.setItem('showGrid', 'false'); + if (safeStorage.getItem('showGrid')) safeStorage.removeItem('showGrid'); + else safeStorage.setItem('showGrid', 'false'); } }, { diff --git a/src/components/play/PlayReticle.svelte b/src/components/play/PlayReticle.svelte index 2edf1f7f..993276a6 100644 --- a/src/components/play/PlayReticle.svelte +++ b/src/components/play/PlayReticle.svelte @@ -5,11 +5,12 @@ // playInteract.js. import { isLocked, isVRMode } from '../../stores/sceneStore'; import { playInteractState } from '$lib/playInteract'; + import { safeStorage } from '$lib/safeStorage'; // the scroll hint is worth exactly one showing, so it is a LOCAL pref and // never touches the wire let hintSeen = $state( - typeof localStorage !== 'undefined' && localStorage.getItem('playCarryHintSeen') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('playCarryHintSeen') === 'true' ); const reticle = $derived($playInteractState); @@ -20,7 +21,7 @@ if (!carrying || hintSeen) return; hintSeen = true; try { - localStorage.setItem('playCarryHintSeen', 'true'); + safeStorage.setItem('playCarryHintSeen', 'true'); } catch {} }); diff --git a/src/components/ui/ToolboxSection.svelte b/src/components/ui/ToolboxSection.svelte index 7e70c8f9..7270b89e 100644 --- a/src/components/ui/ToolboxSection.svelte +++ b/src/components/ui/ToolboxSection.svelte @@ -11,6 +11,7 @@ // The open/closed state is a LOCAL preference (localStorage, per section // key): which sections a user keeps open is workflow, not scene data. import { ChevronRight } from '@lucide/svelte'; + import { safeStorage } from '$lib/safeStorage'; /** @type {{ key: string, label: string, open?: boolean, forceOpen?: boolean, * id?: string, children: any }} */ @@ -23,14 +24,14 @@ const isOpen = $derived.by(() => { if (forceOpen) return true; const saved = - override ?? (typeof localStorage !== 'undefined' ? localStorage.getItem(storeKey) : null); + override ?? (typeof localStorage !== 'undefined' ? safeStorage.getItem(storeKey) : null); return saved === null ? open : saved === 'open'; }); function toggle() { override = isOpen ? 'closed' : 'open'; try { - localStorage.setItem(storeKey, override); + safeStorage.setItem(storeKey, override); } catch {} } diff --git a/src/components/ui/ToolboxWindow.svelte b/src/components/ui/ToolboxWindow.svelte index 9019d906..8fb2c5c4 100644 --- a/src/components/ui/ToolboxWindow.svelte +++ b/src/components/ui/ToolboxWindow.svelte @@ -47,6 +47,7 @@ import { dragWindow } from '$lib/dragWindow'; import { focusStack } from '$lib/windowFocus'; import { notesDrawerOpen, inspectorClose } from '../../stores/appStore'; + import { safeStorage } from '$lib/safeStorage'; /** @type {{ id: string, title: string, key: string, * defaultRect?: { left?: number, top?: number, right?: number, bottom?: number }, @@ -89,7 +90,7 @@ const sheetKey = $derived('tbxSheetH:' + key); $effect(() => { if (sheetH || typeof window === 'undefined') return; - const saved = parseInt(localStorage.getItem(sheetKey) || ''); + const saved = parseInt(safeStorage.getItem(sheetKey) || ''); sheetH = !saved || Number.isNaN(saved) ? Math.round(window.innerHeight * 0.4) : saved; }); let sheetResizing = $state(false); @@ -121,7 +122,7 @@ /** @type {HTMLElement} */ (e.currentTarget).releasePointerCapture?.(e.pointerId); } catch {} try { - localStorage.setItem(sheetKey, String(sheetH)); + safeStorage.setItem(sheetKey, String(sheetH)); } catch {} } diff --git a/src/lib/ai/meshProviders.js b/src/lib/ai/meshProviders.js index 79a1e6b9..30cdb179 100644 --- a/src/lib/ai/meshProviders.js +++ b/src/lib/ai/meshProviders.js @@ -1,4 +1,5 @@ import { writable, get } from 'svelte/store'; +import { safeStorage } from '../safeStorage'; // Text/image -> 3D mesh generation providers (roadmap #11, G1). Mirrors // ai/providers.js (the LLM providers) but for mesh backends: a self-hosted ComfyUI @@ -53,7 +54,7 @@ const ENABLED_KEY = 'meshGenEnabled'; /** @returns {MeshProviderConfig[]} */ function loadProviders() { try { - const raw = localStorage.getItem(PROVIDERS_KEY); + const raw = safeStorage.getItem(PROVIDERS_KEY); const parsed = raw ? JSON.parse(raw) : null; return Array.isArray(parsed) ? parsed : []; } catch { @@ -64,7 +65,7 @@ function loadProviders() { /** @param {MeshProviderConfig[]} list */ function persist(list) { try { - localStorage.setItem(PROVIDERS_KEY, JSON.stringify(list)); + safeStorage.setItem(PROVIDERS_KEY, JSON.stringify(list)); } catch {} } @@ -75,7 +76,7 @@ export const meshProviders = writable(loadProviders()); export const meshActiveProvider = writable( (() => { try { - return localStorage.getItem(ACTIVE_KEY) || null; + return safeStorage.getItem(ACTIVE_KEY) || null; } catch { return null; } @@ -86,7 +87,7 @@ export const meshActiveProvider = writable( export const meshGenEnabled = writable( (() => { try { - return localStorage.getItem(ENABLED_KEY) === 'true'; + return safeStorage.getItem(ENABLED_KEY) === 'true'; } catch { return false; } @@ -160,8 +161,8 @@ export function removeMeshProvider(id) { export function setMeshActiveProvider(id) { meshActiveProvider.set(id); try { - if (id) localStorage.setItem(ACTIVE_KEY, id); - else localStorage.removeItem(ACTIVE_KEY); + if (id) safeStorage.setItem(ACTIVE_KEY, id); + else safeStorage.removeItem(ACTIVE_KEY); } catch {} } @@ -169,7 +170,7 @@ export function setMeshActiveProvider(id) { export function setMeshGenEnabled(on) { meshGenEnabled.set(!!on); try { - localStorage.setItem(ENABLED_KEY, String(!!on)); + safeStorage.setItem(ENABLED_KEY, String(!!on)); } catch {} } diff --git a/src/lib/ai/providers.js b/src/lib/ai/providers.js index de2adda2..754dbfc7 100644 --- a/src/lib/ai/providers.js +++ b/src/lib/ai/providers.js @@ -1,4 +1,5 @@ import { writable, get } from 'svelte/store'; +import { safeStorage } from '../safeStorage'; // AI provider settings (roadmap #10, A1). A LOCAL per-device preference — the // only credentials the app stores. Keys live in PLAINTEXT localStorage (there is @@ -89,7 +90,7 @@ const ENABLED_KEY = 'aiEnabled'; /** @returns {AiProviderConfig[]} */ function loadProviders() { try { - const raw = localStorage.getItem(PROVIDERS_KEY); + const raw = safeStorage.getItem(PROVIDERS_KEY); const parsed = raw ? JSON.parse(raw) : null; return Array.isArray(parsed) ? parsed : []; } catch { @@ -100,7 +101,7 @@ function loadProviders() { /** @param {AiProviderConfig[]} list */ function persistProviders(list) { try { - localStorage.setItem(PROVIDERS_KEY, JSON.stringify(list)); + safeStorage.setItem(PROVIDERS_KEY, JSON.stringify(list)); } catch {} } @@ -113,7 +114,7 @@ export const aiProviders = writable(loadProviders()); export const aiActiveProvider = writable( (() => { try { - return localStorage.getItem(ACTIVE_KEY) || null; + return safeStorage.getItem(ACTIVE_KEY) || null; } catch { return null; } @@ -124,7 +125,7 @@ export const aiActiveProvider = writable( export const aiEnabled = writable( (() => { try { - return localStorage.getItem(ENABLED_KEY) === 'true'; + return safeStorage.getItem(ENABLED_KEY) === 'true'; } catch { return false; } @@ -203,8 +204,8 @@ export function removeAiProvider(id) { export function setAiActiveProvider(id) { aiActiveProvider.set(id); try { - if (id) localStorage.setItem(ACTIVE_KEY, id); - else localStorage.removeItem(ACTIVE_KEY); + if (id) safeStorage.setItem(ACTIVE_KEY, id); + else safeStorage.removeItem(ACTIVE_KEY); } catch {} } @@ -212,7 +213,7 @@ export function setAiActiveProvider(id) { export function setAiEnabled(on) { aiEnabled.set(!!on); try { - localStorage.setItem(ENABLED_KEY, String(!!on)); + safeStorage.setItem(ENABLED_KEY, String(!!on)); } catch {} } diff --git a/src/lib/annotationsHandler.js b/src/lib/annotationsHandler.js index a8b30b8b..49749832 100644 --- a/src/lib/annotationsHandler.js +++ b/src/lib/annotationsHandler.js @@ -21,6 +21,7 @@ import { } from '../stores/appStore'; import { registerAnnotationsPersistence, markAnnotationsDirty } from './autosave'; import { flyTo } from './objectActions'; +import { safeStorage } from './safeStorage'; // Synced note pins on objects. Offsets are object-local so pins follow their // object; one note per pin. Replication mirrors the flow-graph pattern: @@ -49,10 +50,10 @@ export const noteMarkers = writable([]); /** H3: LOCAL pref — pins visible in the viewport (not replicated) */ export const showNotePins = writable( - typeof localStorage === 'undefined' || localStorage.getItem('showNotePins') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('showNotePins') !== 'false' ); if (typeof localStorage !== 'undefined') - showNotePins.subscribe((value) => localStorage.setItem('showNotePins', String(value))); + showNotePins.subscribe((value) => safeStorage.setItem('showNotePins', String(value))); /** H9: pin shapes (replicated per note; 'round' = the historical pin) */ export const NOTE_SHAPES = ['round', 'star', 'square']; @@ -172,9 +173,9 @@ let authorKeyCache = ''; export function myAuthorKey() { if (authorKeyCache) return authorKeyCache; try { - const stored = localStorage.getItem(AUTHOR_KEY); + const stored = safeStorage.getItem(AUTHOR_KEY); authorKeyCache = stored || crypto.randomUUID(); - if (!stored) localStorage.setItem(AUTHOR_KEY, authorKeyCache); + if (!stored) safeStorage.setItem(AUTHOR_KEY, authorKeyCache); } catch { authorKeyCache = 'local'; } diff --git a/src/lib/arProbe.js b/src/lib/arProbe.js index 39ceb61b..ef741fae 100644 --- a/src/lib/arProbe.js +++ b/src/lib/arProbe.js @@ -1,4 +1,5 @@ import { writable } from 'svelte/store'; +import { safeStorage } from './safeStorage'; // CO0 — the on-device WebXR capability probe. // @@ -47,7 +48,7 @@ const RESTORE_DEADLINE = 45; function loadFindings() { try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(FINDINGS_KEY) : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(FINDINGS_KEY) : null; const stored = raw ? JSON.parse(raw) : null; return Array.isArray(stored) ? stored : []; } catch { @@ -67,7 +68,7 @@ export const probeRunning = writable(false); /** @param {any} list */ function persistFindings(list) { try { - if (typeof localStorage !== 'undefined') localStorage.setItem(FINDINGS_KEY, JSON.stringify(list)); + if (typeof localStorage !== 'undefined') safeStorage.setItem(FINDINGS_KEY, JSON.stringify(list)); } catch { // private mode / quota: the on-screen report still works for this run } @@ -143,7 +144,7 @@ function ago(ms) { function readStoredAnchor() { try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(ANCHOR_KEY) : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(ANCHOR_KEY) : null; const stored = raw ? JSON.parse(raw) : null; return stored && typeof stored.handle === 'string' && stored.handle ? stored : null; } catch { @@ -154,7 +155,7 @@ function readStoredAnchor() { /** @param {any} record */ function writeStoredAnchor(record) { try { - if (typeof localStorage !== 'undefined') localStorage.setItem(ANCHOR_KEY, JSON.stringify(record)); + if (typeof localStorage !== 'undefined') safeStorage.setItem(ANCHOR_KEY, JSON.stringify(record)); return true; } catch { return false; @@ -599,8 +600,8 @@ export async function clearProbeState() { resetFindings(); try { if (typeof localStorage !== 'undefined') { - localStorage.removeItem(ANCHOR_KEY); - localStorage.removeItem(FINDINGS_KEY); + safeStorage.removeItem(ANCHOR_KEY); + safeStorage.removeItem(FINDINGS_KEY); } } catch { // nothing to do — the store is already reset diff --git a/src/lib/audioPatch.js b/src/lib/audioPatch.js index 4b062974..01a2e0fc 100644 --- a/src/lib/audioPatch.js +++ b/src/lib/audioPatch.js @@ -9,6 +9,7 @@ import { registerHistoryKind, recordEntry } from './history'; import { ensureAudioContext } from './audioEngine'; import { deviceHandle, deviceSpec, isDeviceObject } from './audioDevices'; import { wireframeActive } from './viewMode'; +import { safeStorage } from './safeStorage'; // THE PATCH (roadmap #23 A4, cloud plans-core/pending/23-a-audio-engine.md). // @@ -381,7 +382,7 @@ export function reconcileRouting() { /** LOCAL pref: draw the cables. On by default — a patch you cannot see is not much of * a patch. */ -export const showCables = writable(typeof localStorage === 'undefined' || localStorage.getItem('showCables') !== 'false'); +export const showCables = writable(typeof localStorage === 'undefined' || safeStorage.getItem('showCables') !== 'false'); /** The flowSockets palette, by PORT kind, so a wire means the same thing in the 3D * world and in the node editor: audio = orange (an effect), cv = number blue, midi = @@ -550,7 +551,7 @@ export function startCables() { }); showCables.subscribe((value) => { try { - localStorage.setItem('showCables', String(value)); + safeStorage.setItem('showCables', String(value)); } catch {} }); } diff --git a/src/lib/autosave.js b/src/lib/autosave.js index 530fbcba..8ab0f6d1 100644 --- a/src/lib/autosave.js +++ b/src/lib/autosave.js @@ -31,6 +31,7 @@ import { log, registerDiagnosticsSection } from './diagnostics'; // #20 P5: selection + edit session + panel layout, restored only on an EXPLICIT restore import { captureEditResume, applyEditResume } from './editResume'; import { disposeTree, keepSet } from './disposeTree'; +import { safeStorage } from './safeStorage'; // Crash safety: snapshots of the scene (GLTF json), the node graph and the // camera go to IndexedDB — debounced 30s after any change plus a 3-minute @@ -117,7 +118,7 @@ export function estimateSnapshotBytes(snapshot) { } export const autosaveEnabled = writable( - typeof localStorage === 'undefined' || localStorage.getItem('autosave') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('autosave') !== 'false' ); /** * 18-A: restore the snapshot on boot instead of asking. OFF by default — an @@ -125,7 +126,7 @@ export const autosaveEnabled = writable( * construction because checkRestore only ever fires on an EMPTY scene. */ export const autoRestoreEnabled = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('autoRestore') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('autoRestore') === 'true' ); /** restore offer for the toast: { ts, objects, snapshot } | null */ /** @type {import('svelte/store').Writable} */ @@ -479,7 +480,7 @@ async function checkRestore() { if (group.children.length !== 0) return; let armed = false; try { - armed = typeof localStorage !== 'undefined' && !!localStorage.getItem('restoreArmed'); + armed = typeof localStorage !== 'undefined' && !!safeStorage.getItem('restoreArmed'); } catch { /* unreadable storage reads as "not armed" — the old behaviour */ } @@ -574,7 +575,7 @@ async function applyRestore(snapshot) { // frame — so the next boot must not silently restore it again. Placed here rather // than at each call site so the explicit Restore button is covered too. try { - if (typeof localStorage !== 'undefined') localStorage.setItem('restoreArmed', '1'); + if (typeof localStorage !== 'undefined') safeStorage.setItem('restoreArmed', '1'); } catch { /* private mode or a full quota: the guard degrades to the old behaviour */ } @@ -751,8 +752,8 @@ export function startAutosave() { // best effort — the async export may not finish, the debounce usually already ran if (dirty) saveSnapshot(); }); - autosaveEnabled.subscribe((value) => localStorage.setItem('autosave', String(value))); - autoRestoreEnabled.subscribe((value) => localStorage.setItem('autoRestore', String(value))); + autosaveEnabled.subscribe((value) => safeStorage.setItem('autosave', String(value))); + autoRestoreEnabled.subscribe((value) => safeStorage.setItem('autoRestore', String(value))); // 27-H: the storage story belongs in the bundle a user hands over. "Autosave last // failed with QuotaExceededError and has been backing off to 5 minutes" is the // single most useful line for a lost-work report, and nowhere else records it. diff --git a/src/lib/cameraBookmarks.js b/src/lib/cameraBookmarks.js index e070a188..ae649d6e 100644 --- a/src/lib/cameraBookmarks.js +++ b/src/lib/cameraBookmarks.js @@ -3,6 +3,7 @@ import { globalCamera, orbitControls } from '../stores/sceneStore'; import { showToast } from '../stores/appStore'; import { flyTo } from './objectActions'; import { cameraNear, cameraFar, setCameraNear, setCameraFar } from './cameraClip'; +import { safeStorage } from './safeStorage'; // Saved camera views, persisted LOCALLY (never replicated), recalled from the // viewport menu, Configure Scene ▸ Camera, or Shift+1..5 for the first five. @@ -38,7 +39,7 @@ export function normalizeBookmark(entry, index) { function load() { try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(KEY) : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(KEY) : null; const list = raw ? JSON.parse(raw) : []; return Array.isArray(list) ? list.map(normalizeBookmark) : []; } catch { @@ -50,7 +51,7 @@ function load() { export const bookmarks = writable(load()); bookmarks.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, JSON.stringify(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem(KEY, JSON.stringify(value)); }); /** the current view as a bookmark payload, or null when the camera isn't ready */ diff --git a/src/lib/cameraClip.js b/src/lib/cameraClip.js index cdd0920a..89867559 100644 --- a/src/lib/cameraClip.js +++ b/src/lib/cameraClip.js @@ -1,6 +1,7 @@ import { writable, get } from 'svelte/store'; import { editorCam, playerCam, orbitControls } from '../stores/sceneStore'; import { sceneRadius } from './sceneBounds'; +import { safeStorage } from './safeStorage'; // Camera clip planes (123): a LOCAL per-device view preference (never // replicated) exposed in Configure Scene. The far plane still grows to fit the @@ -14,7 +15,7 @@ const FAR_CAP = 200000; /** @param {string} key @param {number} fallback */ function stored(key, fallback) { try { - const v = parseFloat(localStorage.getItem(key) ?? ''); + const v = parseFloat(safeStorage.getItem(key) ?? ''); return isFinite(v) ? v : fallback; } catch { return fallback; @@ -59,7 +60,7 @@ export function setCameraNear(v) { const n = Math.min(Math.max(v, 0.001), 10); cameraNear.set(n); try { - localStorage.setItem('cameraNear', String(n)); + safeStorage.setItem('cameraNear', String(n)); } catch {} applyCameraClip(); } @@ -69,7 +70,7 @@ export function setCameraFar(v) { const f = Math.min(Math.max(v, 10), FAR_CAP); cameraFar.set(f); try { - localStorage.setItem('cameraFar', String(f)); + safeStorage.setItem('cameraFar', String(f)); } catch {} applyCameraClip(); } @@ -83,7 +84,7 @@ export const DEFAULT_ORBIT = { rotateSpeed: 1, zoomSpeed: 1, panSpeed: 1, dampin function storedOrbit() { try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem('orbitPrefs') : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem('orbitPrefs') : null; return raw ? { ...DEFAULT_ORBIT, ...JSON.parse(raw) } : { ...DEFAULT_ORBIT }; } catch { return { ...DEFAULT_ORBIT }; @@ -110,7 +111,7 @@ export function applyOrbitPrefs() { export function setOrbitPrefs(patch) { orbitPrefs.update((value) => ({ ...value, ...patch })); try { - localStorage.setItem('orbitPrefs', JSON.stringify(get(orbitPrefs))); + safeStorage.setItem('orbitPrefs', JSON.stringify(get(orbitPrefs))); } catch {} applyOrbitPrefs(); } @@ -118,7 +119,7 @@ export function setOrbitPrefs(patch) { export function resetOrbitPrefs() { orbitPrefs.set({ ...DEFAULT_ORBIT }); try { - localStorage.setItem('orbitPrefs', JSON.stringify(DEFAULT_ORBIT)); + safeStorage.setItem('orbitPrefs', JSON.stringify(DEFAULT_ORBIT)); } catch {} applyOrbitPrefs(); } diff --git a/src/lib/cameraHelpers.js b/src/lib/cameraHelpers.js index e8b8c19c..ec90b20a 100644 --- a/src/lib/cameraHelpers.js +++ b/src/lib/cameraHelpers.js @@ -8,6 +8,7 @@ import { wireframeActive } from './viewMode'; // without the debug toggle, or a camera preview) — see helperLayer.js for the rule import { markHelper, setMarkersHidden, helpersHidden, helpersInPlay } from './helperLayer'; import { isLocked } from '../stores/sceneStore'; +import { safeStorage } from './safeStorage'; // 16-P5: frustum visualization for camera OBJECTS — the colliderHelpers pattern. // One wireframe frustum per camera object, built from `userData.camera` and @@ -18,7 +19,7 @@ import { isLocked } from '../stores/sceneStore'; // much of a camera. `showCameraFrustums` is a LOCAL pref for turning it off. export const showCameraFrustums = writable( - typeof localStorage === 'undefined' || localStorage.getItem('showCameraFrustums') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('showCameraFrustums') !== 'false' ); /** the camera currently PREVIEWED — its own frustum is pointless (you're inside it) @@ -185,7 +186,7 @@ export function startCameraHelpers() { }); showCameraFrustums.subscribe((value) => { try { - localStorage.setItem('showCameraFrustums', String(value)); + safeStorage.setItem('showCameraFrustums', String(value)); } catch {} sync(); }); diff --git a/src/lib/cloudPlugin.js b/src/lib/cloudPlugin.js index 807c6545..7ce6d6f7 100644 --- a/src/lib/cloudPlugin.js +++ b/src/lib/cloudPlugin.js @@ -24,6 +24,7 @@ import { // cloudPlugin path is in history's import subtree — App alone imports this module). import { currentLevel } from './levels'; import { myPlayMode, peerPlayModes } from './gamePresence'; +import { safeStorage } from './safeStorage'; // 28-A (roadmap #28, publish · play · remix): the seams below reach cycle-sensitive // modules — sessions is history-family, cameraBookmarks imports objectActions, playMode is @@ -58,7 +59,7 @@ export async function startCloudPlugin() { try { url = (import.meta && import.meta.env && import.meta.env.VITE_CLOUD_PLUGIN) || - (typeof localStorage !== 'undefined' && localStorage.getItem('cloudPluginUrl')) || + (typeof localStorage !== 'undefined' && safeStorage.getItem('cloudPluginUrl')) || ''; } catch { url = ''; diff --git a/src/lib/colliderHelpers.js b/src/lib/colliderHelpers.js index 2defaf7c..5c7aaeb4 100644 --- a/src/lib/colliderHelpers.js +++ b/src/lib/colliderHelpers.js @@ -6,6 +6,7 @@ import { globalScene, objectsGroup } from '../stores/sceneStore'; import { colliderSpecOf } from './colliderSpec'; import { wireframeActive } from './viewMode'; import { scenePhysicsGround } from './scenePhysics'; +import { safeStorage } from './safeStorage'; // CL-A A7: collider visualization (the lightHelpers pattern). Per tracked // object a wireframe built FROM colliderSpecOf — the SAME spec physics @@ -15,7 +16,7 @@ import { scenePhysicsGround } from './scenePhysics'; /** global toggle (scene ▸ View), LOCAL pref, default OFF */ export const showColliders = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('showColliders') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('showColliders') === 'true' ); /** per-object opt-in (Inspector ▸ Physics "Show collider") — session-local, * NOT persisted or replicated. @type {import('svelte/store').Writable>} */ @@ -264,7 +265,7 @@ export function startColliderHelpers() { }); showColliders.subscribe((value) => { try { - localStorage.setItem('showColliders', String(value)); + safeStorage.setItem('showColliders', String(value)); } catch {} sync(); }); diff --git a/src/lib/colocationAnchors.js b/src/lib/colocationAnchors.js index a34d342f..8f58ab73 100644 --- a/src/lib/colocationAnchors.js +++ b/src/lib/colocationAnchors.js @@ -53,6 +53,7 @@ import { import { calibrating, worldGrabActive } from './colocationCalibrate'; import { forgetNudge } from './colocationNudge'; import { registerVRFrameHook } from './vrControls'; +import { safeStorage } from './safeStorage'; import { sessionContext, createAnchorAt, @@ -88,7 +89,7 @@ const GRAB_ACTIVE_MS = 400; /** @returns {Record} */ function loadRecords() { try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(STORE_KEY) : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(STORE_KEY) : null; const stored = raw ? JSON.parse(raw) : null; return stored && typeof stored === 'object' && !Array.isArray(stored) ? stored : {}; } catch { @@ -105,7 +106,7 @@ export const anchorRecords = writable(loadRecords()); function saveRecords(map) { anchorRecords.set(map); try { - if (typeof localStorage !== 'undefined') localStorage.setItem(STORE_KEY, JSON.stringify(map)); + if (typeof localStorage !== 'undefined') safeStorage.setItem(STORE_KEY, JSON.stringify(map)); } catch { // private mode / quota: the in-memory mirror still works for this run } diff --git a/src/lib/colocationNudge.js b/src/lib/colocationNudge.js index b47a0452..3e93def0 100644 --- a/src/lib/colocationNudge.js +++ b/src/lib/colocationNudge.js @@ -39,6 +39,7 @@ import { registerVRFrameHook } from './vrControls'; import { registerVRMenuEntry } from './vrRadialMenu'; import { getInput } from './inputRuntime'; import { calibrating } from './colocationCalibrate'; +import { safeStorage } from './safeStorage'; const STORE_KEY = 'colocation-nudge-v1'; @@ -59,7 +60,7 @@ export const nudgeMode = writable(false); function readAll() { try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(STORE_KEY) : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(STORE_KEY) : null; const parsed = raw ? JSON.parse(raw) : null; return parsed && typeof parsed === 'object' ? parsed : {}; } catch { @@ -70,7 +71,7 @@ function readAll() { /** @param {any} all */ function writeAll(all) { try { - if (typeof localStorage !== 'undefined') localStorage.setItem(STORE_KEY, JSON.stringify(all)); + if (typeof localStorage !== 'undefined') safeStorage.setItem(STORE_KEY, JSON.stringify(all)); } catch { // private mode / quota: the live correction still works for this session } @@ -313,7 +314,7 @@ export function resetColocationNudge() { loadedKey = null; lastTick = 0; try { - if (typeof localStorage !== 'undefined') localStorage.removeItem(STORE_KEY); + if (typeof localStorage !== 'undefined') safeStorage.removeItem(STORE_KEY); } catch { // nothing to do } diff --git a/src/lib/colocationPresence.js b/src/lib/colocationPresence.js index 7578d291..8f3b7b95 100644 --- a/src/lib/colocationPresence.js +++ b/src/lib/colocationPresence.js @@ -52,6 +52,7 @@ import { writable, derived, get } from 'svelte/store'; import { peers } from '../stores/appStore'; import { roomAlignment, roomKey } from './colocation'; +import { safeStorage } from './safeStorage'; /** REMOTE peers only, `peerId -> roomKey`. A peer NOT in this map is not colocated — * absence is the single representation of that, so nothing ever writes a null row. @@ -66,7 +67,7 @@ export const peerColocation = writable({}); * hands are visible but the thing they hold is not. * @type {import('svelte/store').Writable} */ export const colocatedGhostHands = writable( - typeof localStorage === 'undefined' || localStorage.getItem('colocatedGhostHands') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('colocatedGhostHands') !== 'false' ); /** How faint. Low enough to read as a hint rather than as an avatar, high enough to @@ -246,5 +247,5 @@ export function resetColocationPresence() { // Declared last so nothing above it can be read by this subscriber before its `let`s // exist — the same TDZ rule the wiring comment states. colocatedGhostHands.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('colocatedGhostHands', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('colocatedGhostHands', String(value)); }); diff --git a/src/lib/commandsHandler.svelte.js b/src/lib/commandsHandler.svelte.js index 59a9e512..10e64de1 100644 --- a/src/lib/commandsHandler.svelte.js +++ b/src/lib/commandsHandler.svelte.js @@ -27,6 +27,7 @@ import { peers, userdata } from '../stores/appStore'; // 27-G (audit H6): removing an object frees NOTHING on the GPU. These free what only // the departing object was using, and never what the rest of the scene still holds. import { disposeTree, keepSet } from '$lib/disposeTree'; +import { safeStorage } from './safeStorage'; //Access scene Store let scene = $state(); @@ -163,12 +164,12 @@ export function sceneCommand(command) { if (command.split(' ')[1] == 'on') { showGrid.set(true); - localStorage.removeItem('showGrid') + safeStorage.removeItem('showGrid') } else if (command.split(' ')[1] == 'off') { showGrid.set(false); - localStorage.setItem('showGrid', false); + safeStorage.setItem('showGrid', false); } } else if (command.startsWith('/create')) { diff --git a/src/lib/connectionState.js b/src/lib/connectionState.js index 979930fc..3709f3ed 100644 --- a/src/lib/connectionState.js +++ b/src/lib/connectionState.js @@ -1,4 +1,5 @@ import { writable, get } from 'svelte/store'; +import { safeStorage } from './safeStorage'; /** * Session-connection state (roadmap #14 CN). STORE-ONLY module (svelte/store only) @@ -106,14 +107,14 @@ export function roomIsFull(peers) { function readSoftCap() { if (typeof localStorage === 'undefined') return SOFT_PEER_CAP_DEFAULT; - const raw = Number(localStorage.getItem('connect:softPeerCap')); + const raw = Number(safeStorage.getItem('connect:softPeerCap')); return Number.isFinite(raw) && raw >= 2 && raw <= HARD_PEER_CAP ? raw : SOFT_PEER_CAP_DEFAULT; } /** LOCAL, like every other connection preference. @type {import('svelte/store').Writable} */ export const softPeerCap = writable(readSoftCap()); softPeerCap.subscribe((v) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('connect:softPeerCap', String(v)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('connect:softPeerCap', String(v)); }); /** @@ -192,7 +193,7 @@ export const mergeOnConnect = writable(readMergeOnConnect()); * default, never a crash. The `readFlag` idiom from sharedLibrary. */ function readMergeOnConnect() { try { - return localStorage.getItem('connect:mergeOnConnect') === 'true'; + return safeStorage.getItem('connect:mergeOnConnect') === 'true'; } catch { return false; } @@ -202,6 +203,6 @@ function readMergeOnConnect() { // callback only ever reads its own argument, so it is safe wherever it sits. mergeOnConnect.subscribe((v) => { try { - localStorage.setItem('connect:mergeOnConnect', String(v)); + safeStorage.setItem('connect:mergeOnConnect', String(v)); } catch {} }); diff --git a/src/lib/docking.js b/src/lib/docking.js index ec88fb1e..5450ea80 100644 --- a/src/lib/docking.js +++ b/src/lib/docking.js @@ -1,6 +1,7 @@ import { get } from 'svelte/store'; import { inspectorClose, closeMenu } from '../stores/appStore'; import { bottomDockWouldTake } from './bottomDockDrop'; +import { safeStorage } from './safeStorage'; // Docking lite (phase 81L). Drag a window near the left/right screen edge to // dock it as a full-height panel (--z-drawer tier); drag its header away to @@ -19,17 +20,17 @@ let docked = { left: null, right: null }; const registry = new Map(); // key -> {node, prevRect, handle} try { - const saved = JSON.parse(localStorage.getItem('dockedWindows') ?? 'null'); + const saved = JSON.parse(safeStorage.getItem('dockedWindows') ?? 'null'); if (saved) docked = { left: saved.left ?? null, right: saved.right ?? null }; } catch {} function persist() { - localStorage.setItem('dockedWindows', JSON.stringify(docked)); + safeStorage.setItem('dockedWindows', JSON.stringify(docked)); } /** @param {string} key */ function widthOf(key) { - const value = parseInt(localStorage.getItem('dockWidth:' + key) ?? '300'); + const value = parseInt(safeStorage.getItem('dockWidth:' + key) ?? '300'); return Math.min(Math.max(Number.isNaN(value) ? 300 : value, 250), Math.round(window.innerWidth * 0.4)); } @@ -102,7 +103,7 @@ function apply(key) { const move = (/** @type {any} */ ev) => { const delta = currentSide === 'left' ? ev.clientX - startX : startX - ev.clientX; const next = Math.min(Math.max(250, startWidth + delta), Math.round(window.innerWidth * 0.4)); - localStorage.setItem('dockWidth:' + key, String(next)); + safeStorage.setItem('dockWidth:' + key, String(next)); apply(key); }; const up = () => { diff --git a/src/lib/dragWindow.js b/src/lib/dragWindow.js index 382d5138..33a1b346 100644 --- a/src/lib/dragWindow.js +++ b/src/lib/dragWindow.js @@ -3,6 +3,7 @@ // Windows sit on the --z-window tier; the caller sets size and z-index. import { clampWinSize, clampResize, bottomReserve } from './windowSize'; +import { safeStorage } from './safeStorage'; // 169: live reset registry — every draggable window (this action + the object // list's own dragMe) registers a reset fn so Settings can rescue windows stuck @@ -42,10 +43,10 @@ export function revealWindow(key) { * button, so it is the honest hatch rather than a second one. */ export function resetWindowLayout() { if (typeof localStorage !== 'undefined') { - for (const key of Object.keys(localStorage)) - if (key.startsWith('win:')) localStorage.removeItem(key); + for (const key of safeStorage.keys()) + if (key.startsWith('win:')) safeStorage.removeItem(key); ['objectListRect', 'explorerWinW', 'explorerWinH', 'explorerHeight', 'explorerTreeW', 'uvWinW', 'uvWinH', 'controlsLayout'].forEach((k) => - localStorage.removeItem(k) + safeStorage.removeItem(k) ); } resetters.forEach((fn) => { @@ -76,7 +77,7 @@ export function dragWindow(node, { key, defaultRect = {}, resizable = false, axi /** @type {any} */ let rect = null; try { - rect = JSON.parse(localStorage.getItem('win:' + key) ?? 'null'); + rect = JSON.parse(safeStorage.getItem('win:' + key) ?? 'null'); } catch { rect = null; } @@ -189,7 +190,7 @@ export function dragWindow(node, { key, defaultRect = {}, resizable = false, axi payload.w = rect.w; if (axis !== 'x') payload.h = rect.h; } - localStorage.setItem('win:' + key, JSON.stringify(payload)); + safeStorage.setItem('win:' + key, JSON.stringify(payload)); } // right/bottom-anchored defaults need the rendered size — resolve on the @@ -268,7 +269,7 @@ export function dragWindow(node, { key, defaultRect = {}, resizable = false, axi // 169: reset this window to its default spot (Settings rescue) function resetToDefault() { try { - localStorage.removeItem('win:' + key); + safeStorage.removeItem('win:' + key); } catch {} rect = { ...defaultRect }; if (resizable) { diff --git a/src/lib/environment.js b/src/lib/environment.js index 23df42f7..6f1ecf1b 100644 --- a/src/lib/environment.js +++ b/src/lib/environment.js @@ -8,6 +8,7 @@ import { createLight } from './geometries.svelte'; import { cappedShadowSize, shadowQuality } from './lightParams'; import { wireframeActive } from './viewMode'; import { idbGet, idbPut, idbDelete, idbKeys } from './idb'; +import { safeStorage } from './safeStorage'; // Environment v2 (phase 70). Everything environmental lives under ONE group at // the scene root: `environment-root` — the preset rig (hemi+sun) plus any @@ -66,7 +67,7 @@ const DEFAULT_STATE = { preset: 'studio', exposure: 1, customPreset: null, light function persisted() { try { - const raw = localStorage.getItem('environment'); + const raw = safeStorage.getItem('environment'); if (raw) return { ...DEFAULT_STATE, ...JSON.parse(raw) }; } catch {} return { ...DEFAULT_STATE }; @@ -608,7 +609,7 @@ export function startEnvironment() { loadEnvPresets(); environment.subscribe((state) => { try { - localStorage.setItem('environment', JSON.stringify(state)); + safeStorage.setItem('environment', JSON.stringify(state)); } catch {} }); // scene/renderer arrive async at boot diff --git a/src/lib/explorerView.js b/src/lib/explorerView.js index 61dddd88..bd3e89b9 100644 --- a/src/lib/explorerView.js +++ b/src/lib/explorerView.js @@ -19,6 +19,7 @@ // columns that distinguish the bin or leave dead columns in the library. import { writable, get } from 'svelte/store'; +import { safeStorage } from './safeStorage'; /** * @typedef {{key: string, label: string, always?: boolean, numeric?: boolean, width?: string}} ExplorerColumn @@ -74,7 +75,7 @@ const GROUP_KEY = 'explorer:deletedGroup'; function load(key, fallback) { if (typeof localStorage === 'undefined') return fallback; try { - const raw = localStorage.getItem(key); + const raw = safeStorage.getItem(key); if (!raw) return fallback; const parsed = JSON.parse(raw); return parsed && typeof parsed === 'object' ? { ...fallback, ...parsed } : fallback; @@ -87,7 +88,7 @@ function load(key, fallback) { function save(key, value) { if (typeof localStorage === 'undefined') return; try { - localStorage.setItem(key, JSON.stringify(value)); + safeStorage.setItem(key, JSON.stringify(value)); } catch {} } @@ -97,7 +98,7 @@ function save(key, value) { */ export const explorerViewMode = writable( /** @type {'thumbnails'|'list'} */ ( - typeof localStorage !== 'undefined' && localStorage.getItem(MODE_KEY) === 'list' + typeof localStorage !== 'undefined' && safeStorage.getItem(MODE_KEY) === 'list' ? 'list' : 'thumbnails' ) @@ -105,7 +106,7 @@ export const explorerViewMode = writable( explorerViewMode.subscribe((v) => { if (typeof localStorage === 'undefined') return; try { - localStorage.setItem(MODE_KEY, v); + safeStorage.setItem(MODE_KEY, v); } catch {} }); @@ -200,7 +201,7 @@ explorerSort.subscribe((v) => save(SORT_KEY, v)); */ export const explorerDeletedGroup = writable( /** @type {'none'|'deleter'} */ ( - typeof localStorage !== 'undefined' && localStorage.getItem(GROUP_KEY) === 'deleter' + typeof localStorage !== 'undefined' && safeStorage.getItem(GROUP_KEY) === 'deleter' ? 'deleter' : 'none' ) @@ -208,7 +209,7 @@ export const explorerDeletedGroup = writable( explorerDeletedGroup.subscribe((v) => { if (typeof localStorage === 'undefined') return; try { - localStorage.setItem(GROUP_KEY, v); + safeStorage.setItem(GROUP_KEY, v); } catch {} }); @@ -228,7 +229,7 @@ const BIN_SPENT_KEY = 'explorer:binShowSpent'; */ export const explorerBinLayout = writable( /** @type {'tree'|'plain'} */ ( - typeof localStorage !== 'undefined' && localStorage.getItem(BIN_LAYOUT_KEY) === 'plain' + typeof localStorage !== 'undefined' && safeStorage.getItem(BIN_LAYOUT_KEY) === 'plain' ? 'plain' : 'tree' ) @@ -236,7 +237,7 @@ export const explorerBinLayout = writable( explorerBinLayout.subscribe((v) => { if (typeof localStorage === 'undefined') return; try { - localStorage.setItem(BIN_LAYOUT_KEY, v); + safeStorage.setItem(BIN_LAYOUT_KEY, v); } catch {} }); @@ -250,12 +251,12 @@ explorerBinLayout.subscribe((v) => { * row of grid height. @type {import('svelte/store').Writable} */ export const explorerBinShowSpent = writable( - typeof localStorage !== 'undefined' && localStorage.getItem(BIN_SPENT_KEY) === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem(BIN_SPENT_KEY) === 'true' ); explorerBinShowSpent.subscribe((v) => { if (typeof localStorage === 'undefined') return; try { - localStorage.setItem(BIN_SPENT_KEY, String(v)); + safeStorage.setItem(BIN_SPENT_KEY, String(v)); } catch {} }); diff --git a/src/lib/faceEdit.js b/src/lib/faceEdit.js index 9ed2fab3..6fc17685 100644 --- a/src/lib/faceEdit.js +++ b/src/lib/faceEdit.js @@ -45,6 +45,7 @@ import { endProportionalWheel } from './proportional'; import { showProportionalRingAt, hideProportionalRing } from './proportionalRing'; +import { safeStorage } from './safeStorage'; // the custom transform PIVOT (a LOCAL per-object pref). Another leaf — meshPivot // imports THREE, the two stores and `proportional`, and nothing from here. import { @@ -100,11 +101,11 @@ export const VR_FACE_CAP = 2500; * @type {import('svelte/store').Writable} */ export const vrFaceCap = writable( typeof localStorage !== 'undefined' - ? parseInt(localStorage.getItem('vrFaceCap') ?? '') || VR_FACE_CAP + ? parseInt(safeStorage.getItem('vrFaceCap') ?? '') || VR_FACE_CAP : VR_FACE_CAP ); if (typeof localStorage !== 'undefined') - vrFaceCap.subscribe((value) => localStorage.setItem('vrFaceCap', String(value))); + vrFaceCap.subscribe((value) => safeStorage.setItem('vrFaceCap', String(value))); /** D7: over-limit / blocked-edit warning with a deep link into the Settings * VR section (works in noVR immediately; VR users see it on exit — on-device @@ -1539,10 +1540,10 @@ let wireSource = null; /** wireframe overlay display toggle — honored by BOTH edit modes, local pref */ export const meshEditWireframe = writable( - typeof localStorage === 'undefined' || localStorage.getItem('meshEditWireframe') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('meshEditWireframe') !== 'false' ); meshEditWireframe.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('meshEditWireframe', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('meshEditWireframe', String(value)); if (wire) wire.visible = value; // live toggle mid-session (face mode) }); @@ -1552,10 +1553,10 @@ meshEditWireframe.subscribe((value) => { * editorNavigation (W/A/S/D/Q/E fly is suppressed while it's on; toggling the * pref OFF is the escape hatch that returns the camera keys, quiz 15-D3). */ export const meshEditHotkeys = writable( - typeof localStorage === 'undefined' || localStorage.getItem('meshEditHotkeys') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('meshEditHotkeys') !== 'false' ); meshEditHotkeys.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('meshEditHotkeys', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('meshEditHotkeys', String(value)); }); /** Show the object SELECTION OUTLINE while mesh-editing — local pref, default @@ -1564,10 +1565,10 @@ meshEditHotkeys.subscribe((value) => { * what they do with depthTest/renderOrder: while you are editing elements, the * object-level outline is pure glare. Read by Outline.svelte. */ export const meshEditOutline = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('meshEditOutline') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('meshEditOutline') === 'true' ); meshEditOutline.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('meshEditOutline', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('meshEditOutline', String(value)); }); /** Show the raw TRIANGULATION in the edit wireframe — local pref, default OFF. @@ -1576,7 +1577,7 @@ meshEditOutline.subscribe((value) => { * not dissolvable, so drawing it advertised an edge the tools refuse to touch. * Every modeller shows quads in edit mode for the same reason. */ export const meshEditTriWire = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('meshEditTriWire') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('meshEditTriWire') === 'true' ); /** meshEdit owns the vertex-mode overlay; it imports THIS module, so it hands @@ -1591,7 +1592,7 @@ export function registerVertexWireRebuild(fn) { } meshEditTriWire.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('meshEditTriWire', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('meshEditTriWire', String(value)); // the edge set differs, so this rebuilds rather than toggling visibility. // `wire` is the only session state read here: faceEdited lives further down // the file and would TDZ-crash the SSR eval, so refreshFaceWireframe (which @@ -6919,12 +6920,12 @@ export function registerGizmoPrefListener(fn) { * subscriber runs at module eval (the store-subscriber TDZ gotcha). * @type {import('svelte/store').Writable<'local'|'world'>} */ export const faceGizmoSpace = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('faceGizmoSpace') === 'world' + typeof localStorage !== 'undefined' && safeStorage.getItem('faceGizmoSpace') === 'world' ? 'world' : 'local' ); faceGizmoSpace.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('faceGizmoSpace', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('faceGizmoSpace', String(value)); /** @type {any} */ const controls = get(TControls); // live flip while the face gizmo is seated @@ -6943,10 +6944,10 @@ faceGizmoSpace.subscribe((value) => { * of the way" — modelling with click-select and the ops toolbar only. * @type {import('svelte/store').Writable} */ export const meshGizmoEnabled = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('meshGizmoEnabled') !== '0' : true + typeof localStorage !== 'undefined' ? safeStorage.getItem('meshGizmoEnabled') !== '0' : true ); meshGizmoEnabled.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('meshGizmoEnabled', value ? '1' : '0'); + if (typeof localStorage !== 'undefined') safeStorage.setItem('meshGizmoEnabled', value ? '1' : '0'); if (typeof window === 'undefined') return; // live: seat or drop the gizmo the moment the switch flips, in whichever mode is open. // 24-B1: switching it back ON also restores a pick the mode key hid, so the toolbox diff --git a/src/lib/fileHandler.svelte.js b/src/lib/fileHandler.svelte.js index b93a4c32..1b243849 100644 --- a/src/lib/fileHandler.svelte.js +++ b/src/lib/fileHandler.svelte.js @@ -25,6 +25,7 @@ import { parkAnimatedAtBase } from '$lib/flowRuntime'; import { stripEditOverlays } from '$lib/editOverlays'; import { saveFileBase } from '$lib/saveName'; import { peers, fixLight, loadingFile, showToast } from '../stores/appStore'; +import { safeStorage } from './safeStorage'; //Access objects Store let sceneObjects = $state(); @@ -61,7 +62,7 @@ export function currentSceneName() { // B3: .tpscene export prefs (set from the Sidebar export-settings cog) export function tpsceneOptions() { const read = (/** @type {string} */ k, /** @type {boolean} */ dflt) => { - const v = typeof localStorage !== 'undefined' ? localStorage.getItem(k) : null; + const v = typeof localStorage !== 'undefined' ? safeStorage.getItem(k) : null; return v === null ? dflt : v === 'true'; }; // 21-I5 REVISED: there is deliberately no `versions` option here. This path exports diff --git a/src/lib/filePreview.js b/src/lib/filePreview.js index ba589b58..4669ca90 100644 --- a/src/lib/filePreview.js +++ b/src/lib/filePreview.js @@ -22,6 +22,7 @@ // Deriving it a second time here would be a copy of that logic guaranteed to drift. import { writable } from 'svelte/store'; +import { safeStorage } from './safeStorage'; /** * What the preview window can actually SHOW. A `.txt` opens in the code editor and a @@ -199,7 +200,7 @@ previewAutoPlay.subscribe((v) => saveFlag('preview:autoPlay', v)); */ export function previewFps() { if (typeof localStorage === 'undefined') return 30; - const raw = Number(localStorage.getItem('animationFps')); + const raw = Number(safeStorage.getItem('animationFps')); return Number.isFinite(raw) && raw >= 1 && raw <= 240 ? Math.round(raw) : 30; } @@ -242,14 +243,14 @@ export function frameAt(t, duration, fps = previewFps()) { /** @param {string} key @param {boolean} fallback */ function readFlag(key, fallback) { if (typeof localStorage === 'undefined') return fallback; - const raw = localStorage.getItem(key); + const raw = safeStorage.getItem(key); return raw === null ? fallback : raw === 'true'; } /** @param {string} key @param {boolean} value */ function saveFlag(key, value) { if (typeof localStorage === 'undefined') return; try { - localStorage.setItem(key, String(value)); + safeStorage.setItem(key, String(value)); } catch {} } diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js index dcfc31e2..0ac824f1 100644 --- a/src/lib/flowRuntime.js +++ b/src/lib/flowRuntime.js @@ -66,6 +66,7 @@ import { // 27-B: recovery paths report through the diagnostics ring instead of console.log, // so a user can hand over what happened (hardening audit H4). A zero-import leaf. import { log } from './diagnostics'; +import { safeStorage } from './safeStorage'; // H3: inputRuntime is reached via a PRIMED dynamic import (the moduleSDK // pattern) — a static edge would close the TDZ cycle history -> flowRuntime -> @@ -3302,7 +3303,7 @@ function clearRestoreArmed() { if (armedCleared || typeof localStorage === 'undefined') return; armedCleared = true; try { - localStorage.removeItem('restoreArmed'); + safeStorage.removeItem('restoreArmed'); } catch { /* private mode, quota, a browser refusing site data — nothing to do */ } @@ -3457,7 +3458,7 @@ export function startFlowRuntime() { }); syncedAnimations.subscribe((value) => { synced = value; - if (typeof localStorage !== 'undefined') localStorage.setItem('syncedAnimations', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('syncedAnimations', String(value)); }); requestAnimationFrame(tick); diff --git a/src/lib/gamepadPrefs.js b/src/lib/gamepadPrefs.js index 6c7dbd9e..11e4be78 100644 --- a/src/lib/gamepadPrefs.js +++ b/src/lib/gamepadPrefs.js @@ -1,4 +1,5 @@ import { writable, get } from 'svelte/store'; +import { safeStorage } from './safeStorage'; // 21-E5: THE GAMEPAD LEAF — the standard-mapping table plus this device's preferences. // @@ -105,7 +106,7 @@ export function normalizeGamepadPrefs(raw) { function load() { try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(KEY) : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(KEY) : null; return normalizeGamepadPrefs(raw ? JSON.parse(raw) : {}); } catch { return { ...DEFAULT_GAMEPAD_PREFS }; @@ -116,7 +117,7 @@ function load() { export const gamepadPrefs = writable(load()); gamepadPrefs.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, JSON.stringify(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem(KEY, JSON.stringify(value)); }); /** @param {Partial} patch */ diff --git a/src/lib/githubStars.js b/src/lib/githubStars.js index 985270d6..7311beaa 100644 --- a/src/lib/githubStars.js +++ b/src/lib/githubStars.js @@ -1,4 +1,5 @@ import { writable } from 'svelte/store'; +import { safeStorage } from './safeStorage'; // 15-M: the repo's GitHub star count, for the Welcome overlay's GitHub link. // Deliberately tiny and FAIL-QUIET: unauthenticated api.github.com allows 60 @@ -19,7 +20,7 @@ let started = false; /** Read the cached count (fresh or stale) @returns {{n: number, ts: number}|null} */ function cached() { try { - const raw = localStorage.getItem(CACHE_KEY); + const raw = safeStorage.getItem(CACHE_KEY); if (!raw) return null; const entry = JSON.parse(raw); return typeof entry?.n === 'number' ? entry : null; @@ -45,7 +46,7 @@ export function loadGithubStars() { if (typeof n !== 'number') return; // rate limited / offline — keep the cache githubStars.set(n); try { - localStorage.setItem(CACHE_KEY, JSON.stringify({ n, ts: Date.now() })); + safeStorage.setItem(CACHE_KEY, JSON.stringify({ n, ts: Date.now() })); } catch {} }) .catch(() => {}); // offline / blocked: the link renders without a count diff --git a/src/lib/gridSettings.js b/src/lib/gridSettings.js index 6e50354b..09b66afb 100644 --- a/src/lib/gridSettings.js +++ b/src/lib/gridSettings.js @@ -1,5 +1,6 @@ import { writable, get } from 'svelte/store'; import { snapSettings } from './snapping'; +import { safeStorage } from './safeStorage'; // Grid appearance (16-P3): a LOCAL per-device view preference, never replicated — // same family as `showGrid`, `viewMode` and the cameraClip planes. Peers each get @@ -43,7 +44,7 @@ export const DEFAULT_GRID = { function load() { try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(KEY) : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(KEY) : null; // unknown/missing keys fall back to defaults, so old payloads keep working const stored = raw ? JSON.parse(raw) : {}; const value = { ...DEFAULT_GRID, ...stored }; @@ -61,7 +62,7 @@ function load() { export const gridSettings = writable(load()); gridSettings.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, JSON.stringify(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem(KEY, JSON.stringify(value)); }); /** @param {Partial} patch */ diff --git a/src/lib/handModels.js b/src/lib/handModels.js index fe2dda2e..ebdffe31 100644 --- a/src/lib/handModels.js +++ b/src/lib/handModels.js @@ -4,6 +4,7 @@ import { writable, get } from 'svelte/store'; import { peers } from '../stores/appStore'; import { itemByHash, itemBlob } from './explorer'; import { requestAsset, sendAsset } from './assetShare'; +import { safeStorage } from './safeStorage'; // Custom hand models (R-3): a user's chosen hand GLB is part of their IDENTITY // (the avatar-photo precedent) — the content HASH rides a tiny `handmodel` @@ -16,7 +17,7 @@ import { requestAsset, sendAsset } from './assetShare'; /** my chosen hand model hash ('' = none), LOCAL pref that broadcasts */ export const myHandModel = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('myHandModel') ?? '' : '' + typeof localStorage !== 'undefined' ? safeStorage.getItem('myHandModel') ?? '' : '' ); /** @type {import('svelte/store').Writable>} peerId -> hash */ @@ -96,7 +97,7 @@ export function startHandModels() { started = true; myHandModel.subscribe((hash) => { try { - localStorage.setItem('myHandModel', hash ?? ''); + safeStorage.setItem('myHandModel', hash ?? ''); } catch {} }); // missing bytes may arrive later (assetShare pull) — retry pending parses diff --git a/src/lib/helperLayer.js b/src/lib/helperLayer.js index 6d417e35..1a5c55d2 100644 --- a/src/lib/helperLayer.js +++ b/src/lib/helperLayer.js @@ -23,6 +23,7 @@ // Imports sceneStore only (the lightHelpers/cameraHelpers family), no THREE. import { get, writable } from 'svelte/store'; import { isLocked, editorCam, globalCamera, objectsGroup } from '../stores/sceneStore'; +import { safeStorage } from './safeStorage'; export const HELPER_LAYER = 1; @@ -30,10 +31,10 @@ export const HELPER_LAYER = 1; * in Play and a DEBUG chip sits in the play HUD so a screenshot cannot be mistaken for * the game. @type {import('svelte/store').Writable} */ export const helpersInPlay = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('helpersInPlay') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('helpersInPlay') === 'true' ); helpersInPlay.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('helpersInPlay', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('helpersInPlay', String(value)); }); /** Put a scene-root helper (and its whole subtree) on the helper layer, only. diff --git a/src/lib/hudDocs.js b/src/lib/hudDocs.js index af9c371a..ad828591 100644 --- a/src/lib/hudDocs.js +++ b/src/lib/hudDocs.js @@ -27,6 +27,7 @@ import { HUD_KINDS as REGISTERED_KINDS, defaultsForKind, styleDefaultsForKind, k // 21-D6: a screen can follow the GAME STATE. gameState is a leaf too, so this closes no // cycle — and it is what lets a menu hide itself when the game starts, with no wiring. import { gameState } from './gameState'; +import { safeStorage } from './safeStorage'; /** The scene-wide HUD, and the only key the v1 UI creates. */ export const HUD_SCENE_KEY = 'scene'; @@ -85,12 +86,12 @@ export const hudSelection = writable({}); * `viewportOverrides.hud` is the separate, persistent local kill switch. * @type {import('svelte/store').Writable} */ export const hudPreviewInViewport = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('hudPreviewInViewport') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('hudPreviewInViewport') === 'true' ); if (typeof localStorage !== 'undefined') hudPreviewInViewport.subscribe((on) => { try { - localStorage.setItem('hudPreviewInViewport', String(!!on)); + safeStorage.setItem('hudPreviewInViewport', String(!!on)); } catch {} }); diff --git a/src/lib/importDuplicates.js b/src/lib/importDuplicates.js index 7805feb0..e48c3bb1 100644 --- a/src/lib/importDuplicates.js +++ b/src/lib/importDuplicates.js @@ -28,13 +28,14 @@ import { writable, get } from 'svelte/store'; import { explorerItems, hiddenItems, registerDuplicateResolver } from './explorer'; import { showToast } from '../stores/appStore'; +import { safeStorage } from './safeStorage'; export const DUPLICATE_MODES = ['ask', 'skip', 'copy']; const STORAGE_KEY = 'importDuplicateMode'; function readMode() { try { - const stored = localStorage.getItem(STORAGE_KEY); + const stored = safeStorage.getItem(STORAGE_KEY); if (stored && DUPLICATE_MODES.includes(stored)) return stored; } catch {} return 'ask'; @@ -45,7 +46,7 @@ function readMode() { export const duplicateImportMode = writable(readMode()); duplicateImportMode.subscribe((mode) => { try { - localStorage.setItem(STORAGE_KEY, String(mode)); + safeStorage.setItem(STORAGE_KEY, String(mode)); } catch {} }); diff --git a/src/lib/lightHelpers.js b/src/lib/lightHelpers.js index 37e66933..3eb4599e 100644 --- a/src/lib/lightHelpers.js +++ b/src/lib/lightHelpers.js @@ -4,6 +4,7 @@ import { RectAreaLightHelper } from 'three/addons/helpers/RectAreaLightHelper.js import { globalScene, objectsGroup } from '../stores/sceneStore'; // 24-E2: helpers + proxies live on the helper layer (the editor camera enables it) import { markHelper } from './helperLayer'; +import { safeStorage } from './safeStorage'; // Makes lights visible and draggable: a type-specific helper plus a small // wireframe "bulb" pick proxy per light. Helpers and proxies live at the @@ -12,16 +13,16 @@ import { markHelper } from './helperLayer'; // uuid; Scene.svelte routes clicks on them to selectObject(lightUuid). export const showLightHelpers = writable( - typeof localStorage === 'undefined' || localStorage.getItem('showLightHelpers') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('showLightHelpers') !== 'false' ); /** 24-E1: how far along its forward a directional/spot light's target sits (the * helper's line length; display only — the direction is what shadows read, and the * distance changes nothing for either light type). LOCAL pref, Settings ▸ Scene. */ export const lightHelperLength = writable( - typeof localStorage === 'undefined' ? 2 : Math.max(0.2, Number(localStorage.getItem('lightHelperLength')) || 2) + typeof localStorage === 'undefined' ? 2 : Math.max(0.2, Number(safeStorage.getItem('lightHelperLength')) || 2) ); lightHelperLength.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('lightHelperLength', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('lightHelperLength', String(value)); }); const forward = new THREE.Vector3(); const worldQuat = new THREE.Quaternion(); @@ -159,7 +160,7 @@ export function startLightHelpers() { }); showLightHelpers.subscribe((value) => { visible = value; - localStorage.setItem('showLightHelpers', String(value)); + safeStorage.setItem('showLightHelpers', String(value)); applyVisibility(); }); } diff --git a/src/lib/lightParams.js b/src/lib/lightParams.js index d954eaa9..75659e4c 100644 --- a/src/lib/lightParams.js +++ b/src/lib/lightParams.js @@ -1,6 +1,7 @@ import * as THREE from 'three'; import { writable, get } from 'svelte/store'; import { objectsGroup, globalScene, globalRenderer } from '../stores/sceneStore'; +import { safeStorage } from './safeStorage'; // Light parameter registry (phase 79): type-specific settings the Inspector // renders (color/intensity/visible are common rows it already has). Values @@ -36,7 +37,7 @@ export const SHADOW_SIZES = [512, 1024, 2048]; const QUALITY_CAPS = { off: 512, low: 512, medium: 1024, high: 2048 }; export const shadowQuality = writable( typeof localStorage !== 'undefined' - ? localStorage.getItem('shadowQuality') ?? 'high' + ? safeStorage.getItem('shadowQuality') ?? 'high' : 'high' ); @@ -127,7 +128,7 @@ export function startLightParams() { if (started || typeof window === 'undefined') return; started = true; shadowQuality.subscribe((value) => { - localStorage.setItem('shadowQuality', String(value)); + safeStorage.setItem('shadowQuality', String(value)); applyShadowQualityCap(); }); } diff --git a/src/lib/meshEdit.js b/src/lib/meshEdit.js index 753dbc5d..2808f0e6 100644 --- a/src/lib/meshEdit.js +++ b/src/lib/meshEdit.js @@ -56,6 +56,7 @@ import { slideClamp } from './meshToolParams'; // W9: where the viewport is. A leaf (svelte/store + sceneStore) — no new edge out of // the history-cycle family this module belongs to. import { canvasRect } from './canvasRect'; +import { safeStorage } from './safeStorage'; // the custom transform PIVOT (local pref). Another leaf: meshPivot imports THREE // + the two stores + proportional, and nothing from here or faceEdit. import { @@ -138,13 +139,13 @@ const HANDLE_MULTI = 0x22c55e; // 177: ctrl/shift multi-select for Create face * @type {import('svelte/store').Writable} */ export const vertexHandleScale = writable( typeof localStorage !== 'undefined' - ? Math.min(Math.max(parseFloat(localStorage.getItem('vertexHandleScale') ?? '') || 1, 0.1), 4) + ? Math.min(Math.max(parseFloat(safeStorage.getItem('vertexHandleScale') ?? '') || 1, 0.1), 4) : 1 ); /** Screen-constant handle size (default ON — see refreshHandleMatrix). A local pref. * @type {import('svelte/store').Writable} */ export const vertexHandleAdaptive = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('vertexHandleAdaptive') !== '0' : true + typeof localStorage !== 'undefined' ? safeStorage.getItem('vertexHandleAdaptive') !== '0' : true ); /** reused so the per-frame path allocates nothing */ const scaleVector = new THREE.Vector3(); @@ -187,14 +188,14 @@ const APPARENT_PX = 9; vertexHandleAdaptive.subscribe((value) => { if (typeof localStorage !== 'undefined') - localStorage.setItem('vertexHandleAdaptive', value ? '1' : '0'); + safeStorage.setItem('vertexHandleAdaptive', value ? '1' : '0'); if (!handleMesh || !edited) return; // re-pose every handle: the matrices carry the scale, so switching modes is a rewrite for (let i = 0; i < handles.length; i++) refreshHandleMatrix(i); }); vertexHandleScale.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('vertexHandleScale', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('vertexHandleScale', String(value)); // live, and cheap: the size lives in the instance MATRICES, so nothing is rebuilt and // no handle index moves — the selection survives a size change if (!handleMesh || !edited) return; @@ -1649,11 +1650,11 @@ export const VR_VERTEX_CAP = 800; * @type {import('svelte/store').Writable} */ export const vrVertexCap = writable( typeof localStorage !== 'undefined' - ? parseInt(localStorage.getItem('vrVertexCap') ?? '') || VR_VERTEX_CAP + ? parseInt(safeStorage.getItem('vrVertexCap') ?? '') || VR_VERTEX_CAP : VR_VERTEX_CAP ); if (typeof localStorage !== 'undefined') - vrVertexCap.subscribe((value) => localStorage.setItem('vrVertexCap', String(value))); + vrVertexCap.subscribe((value) => safeStorage.setItem('vrVertexCap', String(value))); /** Vertex (position entry) count of an object's geometry @param {any} object */ export function vertexCount(object) { diff --git a/src/lib/meshPivot.js b/src/lib/meshPivot.js index abfb83a6..1fc2376b 100644 --- a/src/lib/meshPivot.js +++ b/src/lib/meshPivot.js @@ -4,6 +4,7 @@ import { writable, get } from 'svelte/store'; import { globalScene, globalCamera, globalRenderer, TControls, transformMode } from '../stores/sceneStore'; import { showToast, showInfoToast, dismissToastById } from '../stores/appStore'; import { proportionalAnchor } from './proportional'; +import { safeStorage } from './safeStorage'; // The mesh editor's CUSTOM TRANSFORM PIVOT — where the gizmo sits, and what // rotate/scale turn around, in all three element modes. @@ -40,7 +41,7 @@ const MAX_STORED = 200; /** @returns {Record} */ function load() { try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(KEY) : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(KEY) : null; const stored = raw ? JSON.parse(raw) : {}; if (!stored || typeof stored !== 'object') return {}; /** @type {Record} */ @@ -72,7 +73,7 @@ export const meshPivotPicking = writable(false); export const meshPivotMoving = writable(false); meshPivots.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, JSON.stringify(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem(KEY, JSON.stringify(value)); }); /** meshEdit/faceEdit register here so the gizmo re-seats the moment the pivot diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index 11cda385..055a861b 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -38,6 +38,7 @@ import { APP_VERSION } from './version.js'; import { ndcFromClient } from './canvasRect'; // 27-B: recovery paths report through the diagnostics ring (hardening audit H4) import { log } from './diagnostics'; +import { safeStorage } from './safeStorage'; // Module SDK v1 — in-repo modules under src/modules// register through // the api object passed to their register(api). See MODULES.md for the guide. @@ -1536,7 +1537,7 @@ export function isModuleLoaded(id) { function readDisabled() { try { - return JSON.parse(localStorage.getItem('disabledModules') ?? '[]'); + return JSON.parse(safeStorage.getItem('disabledModules') ?? '[]'); } catch { return []; } @@ -1548,7 +1549,7 @@ export const disabledModules = writable( ); disabledModules.subscribe((list) => { if (typeof localStorage !== 'undefined') - localStorage.setItem('disabledModules', JSON.stringify(list)); + safeStorage.setItem('disabledModules', JSON.stringify(list)); }); /** diff --git a/src/lib/multiTransform.js b/src/lib/multiTransform.js index be7dd722..568ed753 100644 --- a/src/lib/multiTransform.js +++ b/src/lib/multiTransform.js @@ -5,6 +5,7 @@ import { peers } from '../stores/appStore'; import { recordTransformSet } from './history'; import { hasOrigin, originWorld, setOriginFromWorld } from './objectOrigin'; import { suspendAnimation, resumeAnimation } from './flowRuntime'; +import { safeStorage } from './safeStorage'; // physics is reached DYNAMICALLY: a static import would close the cycle // multiTransform -> physics -> lockControl -> objectActions -> multiTransform // (the vite-dev TDZ trap; Rollup tolerates it, the dev server 500s) @@ -56,13 +57,13 @@ let lastLiveSend = 0; /** @type {import('svelte/store').Writable<'median'|'active'|'parent'|'individual'>} */ export const pivotMode = writable( /** @type {any} */ ( - typeof localStorage !== 'undefined' && ['median', 'active', 'parent', 'individual'].includes(localStorage.getItem('pivotMode') || '') - ? localStorage.getItem('pivotMode') + typeof localStorage !== 'undefined' && ['median', 'active', 'parent', 'individual'].includes(safeStorage.getItem('pivotMode') || '') + ? safeStorage.getItem('pivotMode') : 'median' ) ); pivotMode.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('pivotMode', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('pivotMode', String(value)); }); /** The parent every member shares, when it is a real object (not objectsGroup). diff --git a/src/lib/musicToolbox.js b/src/lib/musicToolbox.js index 50aae68a..319cbfc3 100644 --- a/src/lib/musicToolbox.js +++ b/src/lib/musicToolbox.js @@ -3,6 +3,7 @@ import { writable, get } from 'svelte/store'; import MusicToolbox from '../components/menu/MusicToolbox.svelte'; import { registerModuleToolbox, unregisterModuleToolbox } from './moduleToolboxes'; import { setDeviceFor, deviceCatalog, deviceCatalogVersion } from './audioDevices'; +import { safeStorage } from './safeStorage'; // THE MUSIC TOOLBOX (roadmap #23 B2, cloud plans-core/pending/23-b-interfaces.md). // @@ -77,7 +78,7 @@ const PRESETS_KEY = 'musicPresets'; /** @returns {Record}[]>} kind -> presets */ function loadPresets() { try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(PRESETS_KEY) : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(PRESETS_KEY) : null; const parsed = raw ? JSON.parse(raw) : {}; return parsed && typeof parsed === 'object' ? parsed : {}; } catch { @@ -91,7 +92,7 @@ export const musicPresets = writable(loadPresets()); function persist() { try { - localStorage.setItem(PRESETS_KEY, JSON.stringify(get(musicPresets))); + safeStorage.setItem(PRESETS_KEY, JSON.stringify(get(musicPresets))); } catch {} } diff --git a/src/lib/onionSkin.js b/src/lib/onionSkin.js index 396ad30b..157fdf2d 100644 --- a/src/lib/onionSkin.js +++ b/src/lib/onionSkin.js @@ -4,6 +4,7 @@ import { writable, get } from 'svelte/store'; import { globalScene, objectsGroup, selectedObject } from '../stores/sceneStore'; import { activeClip, keyTimes, poseAt, ghostBase, playheadOf } from './animationPreview'; import { wireframeActive } from './viewMode'; +import { safeStorage } from './safeStorage'; // 17-E F6: ONION SKIN — faint copies of the object at the neighbouring keys, so you // can see where a movement came from and where it is going while you work on the @@ -19,14 +20,14 @@ import { wireframeActive } from './viewMode'; // is not what someone opening a file wants to see. export const showOnionSkin = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('showOnionSkin') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('showOnionSkin') === 'true' ); /** @param {boolean} on */ export function setOnionSkin(on) { showOnionSkin.set(on); try { - localStorage.setItem('showOnionSkin', on ? 'true' : 'false'); + safeStorage.setItem('showOnionSkin', on ? 'true' : 'false'); } catch {} } diff --git a/src/lib/packs.js b/src/lib/packs.js index 325cff61..2c0fda65 100644 --- a/src/lib/packs.js +++ b/src/lib/packs.js @@ -1,6 +1,7 @@ import { writable, get } from 'svelte/store'; import { contentBase } from './contentBase'; import { addItemFromBytes, createFolder, explorerFolders } from './explorer'; +import { safeStorage } from './safeStorage'; // N6 (roadmap 7 / ship-qa D1): object packs. Two sources, one normalized model: // - DEFAULT packs from static/libraryList.json (bundled today; the model bytes @@ -39,7 +40,7 @@ let loadSeq = 0; /** @returns {any[]} imported packs persisted locally */ function getInstalled() { try { - return JSON.parse(localStorage.getItem(INSTALLED_KEY) || '[]'); + return JSON.parse(safeStorage.getItem(INSTALLED_KEY) || '[]'); } catch { return []; } @@ -47,7 +48,7 @@ function getInstalled() { /** @param {any[]} list */ function setInstalled(list) { try { - localStorage.setItem(INSTALLED_KEY, JSON.stringify(list)); + safeStorage.setItem(INSTALLED_KEY, JSON.stringify(list)); } catch {} } @@ -59,7 +60,7 @@ const THUMB_KEY = 'packThumbCache'; /** @returns {Record} */ function getThumbCache() { try { - return JSON.parse(localStorage.getItem(THUMB_KEY) || '{}'); + return JSON.parse(safeStorage.getItem(THUMB_KEY) || '{}'); } catch { return {}; } @@ -74,7 +75,7 @@ export function rememberThumb(packName, itemName, url) { if (c[`${packName}/${itemName}`] === url) return; c[`${packName}/${itemName}`] = url; try { - localStorage.setItem(THUMB_KEY, JSON.stringify(c)); + safeStorage.setItem(THUMB_KEY, JSON.stringify(c)); } catch {} } // 21-G1: PACK RENAME. The report was "the Audio Essentials folder can't be renamed", and @@ -94,7 +95,7 @@ const TITLE_KEY = 'packTitles'; /** @returns {Record} */ function getTitleOverrides() { try { - return JSON.parse(localStorage.getItem(TITLE_KEY) || '{}'); + return JSON.parse(safeStorage.getItem(TITLE_KEY) || '{}'); } catch { return {}; } @@ -114,7 +115,7 @@ export function renamePack(name, title) { const map = getTitleOverrides(); map[name] = clean; try { - localStorage.setItem(TITLE_KEY, JSON.stringify(map)); + safeStorage.setItem(TITLE_KEY, JSON.stringify(map)); } catch {} packs.update((list) => list.map((/** @type {any} */ p) => (p.name === name ? { ...p, title: clean } : p))); return true; @@ -125,7 +126,7 @@ function dropTitleOverride(packName) { if (!(packName in map)) return; delete map[packName]; try { - localStorage.setItem(TITLE_KEY, JSON.stringify(map)); + safeStorage.setItem(TITLE_KEY, JSON.stringify(map)); } catch {} } @@ -137,7 +138,7 @@ function dropPackThumbs(packName) { for (const k of Object.keys(c)) if (k.startsWith(prefix)) (delete c[k], (changed = true)); if (changed) try { - localStorage.setItem(THUMB_KEY, JSON.stringify(c)); + safeStorage.setItem(THUMB_KEY, JSON.stringify(c)); } catch {} } diff --git a/src/lib/panelToggles.js b/src/lib/panelToggles.js index 87082b9c..ce54ce77 100644 --- a/src/lib/panelToggles.js +++ b/src/lib/panelToggles.js @@ -21,6 +21,7 @@ import { import { raiseWindow, isTopVisibleWindow } from './windowFocus'; import { groupOfKey, activateTab } from './windowTabs'; import { revealWindow } from './dragWindow'; +import { safeStorage } from './safeStorage'; // ONE decision tree for the Controls panel buttons AND their keyboard shortcuts // (O / N). Before this module the Object list button had taskbar semantics @@ -105,7 +106,7 @@ function isDockedPresent(key) { /** Would opening this panel put it in the dock? @param {PanelConfig} cfg */ function opensDocked(cfg) { if (!cfg.dockedLs) return false; // floating-only panel - return typeof localStorage === 'undefined' || localStorage.getItem(cfg.dockedLs) !== 'false'; + return typeof localStorage === 'undefined' || safeStorage.getItem(cfg.dockedLs) !== 'false'; } /** Is this panel the one the dock is actually SHOWING? @param {PanelConfig} cfg */ diff --git a/src/lib/peerServer.js b/src/lib/peerServer.js index 252a60f9..0ab0b2ba 100644 --- a/src/lib/peerServer.js +++ b/src/lib/peerServer.js @@ -1,4 +1,5 @@ import { writable, get } from 'svelte/store'; +import { safeStorage } from './safeStorage'; /** * Peer signaling-server selection + ICE (STUN/TURN) config. @@ -154,7 +155,7 @@ function defaults() { function load() { if (typeof localStorage === 'undefined') return defaults(); try { - const raw = localStorage.getItem(LS_KEY); + const raw = safeStorage.getItem(LS_KEY); if (raw) { const parsed = JSON.parse(raw); return { ...defaults(), ...parsed, custom: { ...defaults().custom, ...(parsed.custom || {}) } }; @@ -170,7 +171,7 @@ export const peerServerConfig = writable(load()); peerServerConfig.subscribe((v) => { if (typeof localStorage === 'undefined') return; try { - localStorage.setItem(LS_KEY, JSON.stringify(v)); + safeStorage.setItem(LS_KEY, JSON.stringify(v)); } catch { /* storage full / disabled */ } diff --git a/src/lib/ping.js b/src/lib/ping.js index e196a9fe..b7b94a53 100644 --- a/src/lib/ping.js +++ b/src/lib/ping.js @@ -4,6 +4,7 @@ import { peers, username } from '../stores/appStore'; import { objectsGroup } from '../stores/sceneStore'; import { peerColor } from './lockControl'; import { playPing } from './pingAudio'; +import { safeStorage } from './safeStorage'; // Ping a world point (or object) so every peer sees a pulse there for ~4s. // V2 (87): pings carry the sender's chosen color + chime — everyone renders @@ -16,14 +17,14 @@ export const pings = writable([]); // per-user ping preferences (Settings; '' color = automatic peer color) export const pingColor = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('pingColor') ?? '' : '' + typeof localStorage !== 'undefined' ? safeStorage.getItem('pingColor') ?? '' : '' ); export const pingSound = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('pingSound') ?? 'ding' : 'ding' + typeof localStorage !== 'undefined' ? safeStorage.getItem('pingSound') ?? 'ding' : 'ding' ); if (typeof localStorage !== 'undefined') { - pingColor.subscribe((value) => localStorage.setItem('pingColor', value)); - pingSound.subscribe((value) => localStorage.setItem('pingSound', value)); + pingColor.subscribe((value) => safeStorage.setItem('pingColor', value)); + pingSound.subscribe((value) => safeStorage.setItem('pingSound', value)); } /** @param {any} ping */ diff --git a/src/lib/projectFile.js b/src/lib/projectFile.js index a882ff38..7bf8dbbc 100644 --- a/src/lib/projectFile.js +++ b/src/lib/projectFile.js @@ -58,6 +58,7 @@ import { projectName } from './projectManifest'; import { ensureScenesFolder, currentLevel } from './levels'; +import { safeStorage } from './safeStorage'; /** V4's gating pattern with its own int: a NEWER format ASKS before importing, an * older or absent one loads silently. `appVersion` beside it is display-only @@ -511,7 +512,7 @@ export async function exportProjectFromSession(payload) { * export preference. */ export function projectVersionsEnabled() { try { - return localStorage.getItem('tpProjectVersions') !== 'false'; + return safeStorage.getItem('tpProjectVersions') !== 'false'; } catch { return true; } diff --git a/src/lib/projectManifest.js b/src/lib/projectManifest.js index 82ae392f..f2594d4d 100644 --- a/src/lib/projectManifest.js +++ b/src/lib/projectManifest.js @@ -31,6 +31,7 @@ import { showChoice } from './confirmDialog'; import { sessionHost } from './connectionState'; import { isViewer } from './objectPermissions'; import { idbGet, idbPut } from './idb'; +import { safeStorage } from './safeStorage'; const IDB_KEY = 'project:manifest'; /** versions of ONE scene kept locally beyond the pinned set (fork 4) — the DEFAULT of @@ -50,7 +51,7 @@ export const keepVersionsSetting = writable(readKeepVersions()); function readKeepVersions() { try { - const raw = localStorage.getItem('project:keepVersions'); + const raw = safeStorage.getItem('project:keepVersions'); if (raw === null) return KEEP_VERSIONS; const n = Number(raw); return Number.isFinite(n) && n >= 0 ? Math.floor(n) : KEEP_VERSIONS; @@ -61,7 +62,7 @@ function readKeepVersions() { keepVersionsSetting.subscribe((n) => { try { - localStorage.setItem('project:keepVersions', String(n)); + safeStorage.setItem('project:keepVersions', String(n)); } catch {} }); diff --git a/src/lib/proportional.js b/src/lib/proportional.js index f7da0758..e427c17f 100644 --- a/src/lib/proportional.js +++ b/src/lib/proportional.js @@ -1,4 +1,5 @@ import { writable } from 'svelte/store'; +import { safeStorage } from './safeStorage'; // 19-A P4: PROPORTIONAL EDITING's shared state, split out of meshEdit as a LEAF // (svelte/store only) so faceEdit can read it too. faceEdit cannot import @@ -17,11 +18,11 @@ export const proportionalEdit = writable(false); * @type {import('svelte/store').Writable} */ export const proportionalRadius = writable( typeof localStorage !== 'undefined' - ? Math.min(Math.max(parseFloat(localStorage.getItem('proportionalRadius') ?? '') || 1, 0.01), 100) + ? Math.min(Math.max(parseFloat(safeStorage.getItem('proportionalRadius') ?? '') || 1, 0.01), 100) : 1 ); proportionalRadius.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('proportionalRadius', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('proportionalRadius', String(value)); }); /** diff --git a/src/lib/safeStorage.js b/src/lib/safeStorage.js new file mode 100644 index 00000000..2d071317 --- /dev/null +++ b/src/lib/safeStorage.js @@ -0,0 +1,166 @@ +// 27-H (hardening audit M4) — LOCAL STORAGE THAT CANNOT TAKE A SUBSCRIBER DOWN WITH IT. +// +// THE FINDING: ~500 bare `localStorage` calls across ~90 files, and `setItem` THROWS +// synchronously in Safari private mode and whenever the origin's quota is full. Most of +// these sit inside `$effect`s and store subscribers, so the throw does not merely fail to +// persist a setting — it kills that subscriber for the rest of the session, and the UI it +// drives stops updating. "The theme picker stopped working" is what that looks like from +// the outside, and nothing in it points at storage. +// +// Reading is not safe either, which is less well known: in a sandboxed iframe, and under +// some enterprise policies, merely TOUCHING `window.localStorage` throws SecurityError — +// so even `typeof localStorage === 'undefined'` guards, which this codebase has a hundred +// of, do not cover it. Every access here goes through one try/catch. +// +// THE FALLBACK IS PER-KEY, and that is what makes the promise honest. A setting whose +// write failed is remembered in memory, so it still APPLIES for this session and reads +// back as what you set; it simply does not survive a reload. That is the degradation a +// user can live with. A successful write drops the key from memory again, because +// localStorage is then the truth and a stale shadow would outvote it. +// +// A DELIBERATE LEAF: this module imports NOTHING. It is reached from stores, from +// components, from the diagnostics layer's own neighbours and from modules on every side +// of the history-cycle family, so any import at all here is a future cycle. It is also +// what lets the unit layer test it with no browser. + +/** keys whose real write failed, or everything when storage is unreachable @type {Map} */ +const memory = new Map(); +/** how many writes have fallen back — read by the diagnostics section and the suite */ +let failures = 0; +/** @type {string | null} the last failure's name, so a report can say WHICH kind it was */ +let lastError = null; + +/** + * The backing store, or null when it is unreachable. The property access itself is inside + * the try: that is the SecurityError case above, and it is the one every `typeof` guard + * in this codebase misses. + * @returns {Storage | null} + */ +function backing() { + try { + return typeof localStorage === 'undefined' ? null : localStorage; + } catch { + return null; + } +} + +/** @param {any} error */ +function noteFailure(error) { + failures++; + lastError = String(error?.name || error || 'unknown'); +} + +/** + * Read a key. Memory first, because a key is only in memory when its real write FAILED, + * and the value you just set is the one you expect to read back. + * @param {string} key @returns {string | null} + */ +export function getItem(key) { + if (memory.has(key)) return /** @type {string} */ (memory.get(key)); + try { + return backing()?.getItem(key) ?? null; + } catch (error) { + noteFailure(error); + return null; + } +} + +/** + * Write a key. NEVER throws — that is the entire point — and returns whether it reached + * real storage, for the rare caller that wants to say so. + * @param {string} key @param {any} value @returns {boolean} + */ +export function setItem(key, value) { + const text = String(value); + const store = backing(); + if (store) { + try { + store.setItem(key, text); + // the real store is the truth again; a leftover shadow would outvote it + memory.delete(key); + return true; + } catch (error) { + noteFailure(error); + } + } + memory.set(key, text); + return false; +} + +/** @param {string} key */ +export function removeItem(key) { + memory.delete(key); + try { + backing()?.removeItem(key); + } catch (error) { + noteFailure(error); + } +} + +/** + * Every stored key, real and fallen-back (the "reset my window layout" sweep needs it). + * + * Enumerated through `length` + `key(i)` rather than `Object.keys`, which is what the + * call site this replaces used: `Object.keys` happens to work on the real `Storage` + * exotic object and returns METHOD NAMES on anything that merely implements the + * interface, so the standards-defined enumeration is both more correct and the one a + * stand-in can satisfy. + */ +export function keys() { + /** @type {Set} */ + const out = new Set(memory.keys()); + try { + const store = backing(); + if (store) for (let i = 0; i < store.length; i++) { + const key = store.key(i); + if (key != null) out.add(key); + } + } catch (error) { + noteFailure(error); + } + return [...out]; +} + +/** Wipe everything (Settings ▸ Reset settings) */ +export function clear() { + memory.clear(); + try { + backing()?.clear(); + } catch (error) { + noteFailure(error); + } +} + +/** The spec's short names, for new code. Identical behaviour. */ +export const get = getItem; +export const set = setItem; +export const remove = removeItem; + +/** + * A DROP-IN for the `localStorage` object itself, so the codemod that replaced ~500 call + * sites is one identifier per line and nothing else — a rename a reviewer can check by + * eye, rather than 500 opportunities to change a semicolon. + */ +export const safeStorage = { getItem, setItem, removeItem, clear, keys }; + +/** + * Is persistence working, and what has it cost? The diagnostics bundle asks; so does the + * suite. `degraded` is the thing worth reading: it means settings are applying but not + * surviving a reload, which is otherwise completely invisible. + */ +export function storageDebug() { + return { + available: !!backing(), + degraded: memory.size > 0 || failures > 0, + fallbackKeys: memory.size, + failures, + lastError + }; +} + +/** TEST SEAM: forget the fallback, so one suite section cannot colour the next. */ +export function debugResetStorage() { + memory.clear(); + failures = 0; + lastError = null; +} diff --git a/src/lib/saveName.js b/src/lib/saveName.js index c73bfd73..5bc427e8 100644 --- a/src/lib/saveName.js +++ b/src/lib/saveName.js @@ -20,6 +20,7 @@ // delegates to `fileNameBase`. import { writable, get } from 'svelte/store'; +import { safeStorage } from './safeStorage'; /** What a save is called when nothing else is said: the thing's own name. */ export const DEFAULT_TEMPLATE = '[name]'; @@ -135,7 +136,7 @@ const KEY = 'saveNameTemplate'; function readTemplate() { try { - const raw = localStorage.getItem(KEY); + const raw = safeStorage.getItem(KEY); return raw === null ? DEFAULT_TEMPLATE : String(raw); } catch { return DEFAULT_TEMPLATE; @@ -149,7 +150,7 @@ export const saveNameTemplate = writable(readTemplate()); saveNameTemplate.subscribe((value) => { try { - localStorage.setItem(KEY, String(value ?? '')); + safeStorage.setItem(KEY, String(value ?? '')); } catch {} }); diff --git a/src/lib/sceneMusic.js b/src/lib/sceneMusic.js index f975c799..a3af2b68 100644 --- a/src/lib/sceneMusic.js +++ b/src/lib/sceneMusic.js @@ -3,6 +3,7 @@ import { peers } from '../stores/appStore'; import { ensureAudioContext, bus } from './audioEngine'; import { itemByHash, itemBlob } from './explorer'; import { requestAsset, sendAsset } from './assetShare'; +import { safeStorage } from './safeStorage'; // Scene music (M-1): ONE shared background track per scene — a singleton synced // latest-wins like the environment, so everyone hears the same track at the same @@ -19,10 +20,10 @@ export const music = writable({ ...DEFAULT }); // per-device overlay (LOCAL, persisted) — your own volume trim + mute export const musicLocalVolume = writable( - typeof localStorage !== 'undefined' ? +(localStorage.getItem('musicLocalVolume') ?? '1') : 1 + typeof localStorage !== 'undefined' ? +(safeStorage.getItem('musicLocalVolume') ?? '1') : 1 ); export const musicMuted = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('musicMuted') === 'true' : false + typeof localStorage !== 'undefined' ? safeStorage.getItem('musicMuted') === 'true' : false ); /** whether the audio context is currently blocked by the browser autoplay policy */ @@ -235,13 +236,13 @@ export function startSceneMusic() { started = true; musicLocalVolume.subscribe((v) => { try { - localStorage.setItem('musicLocalVolume', String(v)); + safeStorage.setItem('musicLocalVolume', String(v)); } catch {} reconcile(); }); musicMuted.subscribe((v) => { try { - localStorage.setItem('musicMuted', String(v)); + safeStorage.setItem('musicMuted', String(v)); } catch {} reconcile(); }); diff --git a/src/lib/selectionPrefs.js b/src/lib/selectionPrefs.js index 143b33a8..3e71b379 100644 --- a/src/lib/selectionPrefs.js +++ b/src/lib/selectionPrefs.js @@ -1,4 +1,5 @@ import { writable } from 'svelte/store'; +import { safeStorage } from './safeStorage'; // Phase 85: what a DOUBLE-CLICK on an object does, as a LOCAL preference. // @@ -26,12 +27,12 @@ const DEFAULT = 'properties'; /** @type {DoubleClickAction} */ const stored = typeof localStorage !== 'undefined' && - DOUBLE_CLICK_ACTIONS.some((a) => a.value === localStorage.getItem(KEY)) - ? /** @type {any} */ (localStorage.getItem(KEY)) + DOUBLE_CLICK_ACTIONS.some((a) => a.value === safeStorage.getItem(KEY)) + ? /** @type {any} */ (safeStorage.getItem(KEY)) : DEFAULT; /** @type {import('svelte/store').Writable} */ export const doubleClickAction = writable(stored); if (typeof localStorage !== 'undefined') - doubleClickAction.subscribe((value) => localStorage.setItem(KEY, value)); + doubleClickAction.subscribe((value) => safeStorage.setItem(KEY, value)); diff --git a/src/lib/sharedLibrary.js b/src/lib/sharedLibrary.js index 788a844e..28b46da3 100644 --- a/src/lib/sharedLibrary.js +++ b/src/lib/sharedLibrary.js @@ -130,6 +130,7 @@ import { transfers, removeTransfer } from './transferLedger'; // R22 round 33: automatic downloads WAIT while the joiner is being asked what to do with // its own scene. A store-only leaf, so this edge closes nothing. import { pendingConnectDecision } from './connectionState'; +import { safeStorage } from './safeStorage'; /** * Hashes we have ASKED the mesh for and not yet received. A remote card with nothing to @@ -460,7 +461,7 @@ export const unshareAuthority = writable(readAuthority()); function readAuthority() { try { - return localStorage.getItem('shared:unshareAuthority') === 'owner' ? 'owner' : 'anyone'; + return safeStorage.getItem('shared:unshareAuthority') === 'owner' ? 'owner' : 'anyone'; } catch { return 'anyone'; } @@ -468,7 +469,7 @@ function readAuthority() { unshareAuthority.subscribe((v) => { try { - localStorage.setItem('shared:unshareAuthority', v); + safeStorage.setItem('shared:unshareAuthority', v); } catch {} }); @@ -507,9 +508,9 @@ export const shareNewFiles = writable(readShareNewFiles()); * was "do not publish everything", never "do not ask me". */ function readShareNewFiles() { try { - const raw = localStorage.getItem('shared:shareNewFiles'); + const raw = safeStorage.getItem('shared:shareNewFiles'); if (raw === 'ask' || raw === 'always' || raw === 'never') return raw; - return localStorage.getItem('shared:autoShareAll') === 'true' ? 'always' : 'ask'; + return safeStorage.getItem('shared:autoShareAll') === 'true' ? 'always' : 'ask'; } catch { return 'ask'; } @@ -527,7 +528,7 @@ export const autoDownload = writable(readFlag('shared:autoDownload', true)); /** @param {string} key @param {boolean} fallback */ function readFlag(key, fallback) { try { - const raw = localStorage.getItem(key); + const raw = safeStorage.getItem(key); return raw === null ? fallback : raw === 'true'; } catch { return fallback; @@ -536,12 +537,12 @@ function readFlag(key, fallback) { shareNewFiles.subscribe((v) => { try { - localStorage.setItem('shared:shareNewFiles', v); + safeStorage.setItem('shared:shareNewFiles', v); } catch {} }); autoDownload.subscribe((v) => { try { - localStorage.setItem('shared:autoDownload', String(v)); + safeStorage.setItem('shared:autoDownload', String(v)); } catch {} }); @@ -555,7 +556,7 @@ export const deleteWithoutConfirm = writable(readFlag('shared:deleteNoConfirm', deleteWithoutConfirm.subscribe((v) => { try { - localStorage.setItem('shared:deleteNoConfirm', String(v)); + safeStorage.setItem('shared:deleteNoConfirm', String(v)); } catch {} }); @@ -576,12 +577,12 @@ export const keepRecycleBin = writable(readFlag('shared:keepRecycleBin', false)) recycleBinEnabled.subscribe((v) => { try { - localStorage.setItem('shared:recycleBin', String(v)); + safeStorage.setItem('shared:recycleBin', String(v)); } catch {} }); keepRecycleBin.subscribe((v) => { try { - localStorage.setItem('shared:keepRecycleBin', String(v)); + safeStorage.setItem('shared:keepRecycleBin', String(v)); } catch {} }); @@ -617,7 +618,7 @@ export const deletedLogEnabled = writable(readFlag('shared:deletedLog', true)); deletedLogEnabled.subscribe((v) => { try { - localStorage.setItem('shared:deletedLog', String(v)); + safeStorage.setItem('shared:deletedLog', String(v)); } catch {} }); @@ -2370,7 +2371,7 @@ const appliedDeletes = new Set(readApplied()); function readApplied() { try { - return JSON.parse(localStorage.getItem('shared:appliedDeletes') ?? '[]'); + return JSON.parse(safeStorage.getItem('shared:appliedDeletes') ?? '[]'); } catch { return []; } @@ -2381,7 +2382,7 @@ function noteApplied(hash) { appliedDeletes.add(hash); try { // bounded: the log itself is capped at 200, so this cannot outgrow it by much - localStorage.setItem('shared:appliedDeletes', JSON.stringify([...appliedDeletes].slice(-400))); + safeStorage.setItem('shared:appliedDeletes', JSON.stringify([...appliedDeletes].slice(-400))); } catch {} } @@ -2389,7 +2390,7 @@ function noteApplied(hash) { function forgetApplied(hash) { if (!appliedDeletes.delete(hash)) return; try { - localStorage.setItem('shared:appliedDeletes', JSON.stringify([...appliedDeletes])); + safeStorage.setItem('shared:appliedDeletes', JSON.stringify([...appliedDeletes])); } catch {} } diff --git a/src/lib/shortcuts.js b/src/lib/shortcuts.js index 27b77f2d..d871b61b 100644 --- a/src/lib/shortcuts.js +++ b/src/lib/shortcuts.js @@ -38,6 +38,7 @@ import { togglePanel, toggleDock } from './panelToggles'; // SSR prerender. import { requestPlay } from './playMode'; import { selectedObject } from '../stores/sceneStore'; +import { safeStorage } from './safeStorage'; // Single source of truth for keyboard shortcuts: the same registry binds the keys // and renders the list in Settings -> Shortcuts. Other modules push entries via @@ -472,8 +473,8 @@ export const shortcuts = [ // A3: the SimControls HUD is off by default; P still works, but the first // time it's used while the HUD is hidden, point users at the setting so the // transport (pause/stop/reset) is discoverable. - if (!get(showSimControls) && typeof localStorage !== 'undefined' && !localStorage.getItem('simHudHintSeen')) { - localStorage.setItem('simHudHintSeen', '1'); + if (!get(showSimControls) && typeof localStorage !== 'undefined' && !safeStorage.getItem('simHudHintSeen')) { + safeStorage.setItem('simHudHintSeen', '1'); showToast('Simulation controls are hidden — enable them in Settings → Scene to show the pause/stop/reset buttons.', [ { label: 'Open Settings', @@ -576,7 +577,7 @@ let overrides = {}; function loadOverrides() { try { if (typeof localStorage === 'undefined') return {}; - const raw = localStorage.getItem(OVERRIDES_KEY); + const raw = safeStorage.getItem(OVERRIDES_KEY); const parsed = raw ? JSON.parse(raw) : null; if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; /** @type {Record} */ @@ -591,9 +592,9 @@ function loadOverrides() { function saveOverrides() { try { if (typeof localStorage === 'undefined') return; - if (Object.keys(overrides).length) localStorage.setItem(OVERRIDES_KEY, JSON.stringify(overrides)); + if (Object.keys(overrides).length) safeStorage.setItem(OVERRIDES_KEY, JSON.stringify(overrides)); // an empty map is the DEFAULT state, so remove the key rather than store `{}` - else localStorage.removeItem(OVERRIDES_KEY); + else safeStorage.removeItem(OVERRIDES_KEY); } catch { /* private mode: the rebind still applies for this session */ } diff --git a/src/lib/snapping.js b/src/lib/snapping.js index 357f5f9d..0ff114a2 100644 --- a/src/lib/snapping.js +++ b/src/lib/snapping.js @@ -1,18 +1,19 @@ import * as THREE from 'three'; import { writable, get } from 'svelte/store'; import { TControls } from '../stores/sceneStore'; +import { safeStorage } from './safeStorage'; // Grid snapping for the transform gizmo: translate, rotate AND scale. // Persisted in localStorage. "Snap to surface" is a future improvement. -const stored = typeof localStorage !== 'undefined' ? localStorage.getItem('snapSettings') : null; +const stored = typeof localStorage !== 'undefined' ? safeStorage.getItem('snapSettings') : null; export const snapEnabled = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('snapEnabled') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('snapEnabled') === 'true' ); // translate drags keep the object resting on whatever is underneath it export const surfaceSnap = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('surfaceSnap') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('surfaceSnap') === 'true' ); /** @type {import('svelte/store').Writable<{translate: number, rotateDeg: number, scale: number}>} */ export const snapSettings = writable(stored ? JSON.parse(stored) : { translate: 0.5, rotateDeg: 15, scale: 0.1 }); @@ -40,14 +41,14 @@ export function startSnapping() { started = true; TControls.subscribe(apply); snapEnabled.subscribe((value) => { - localStorage.setItem('snapEnabled', String(value)); + safeStorage.setItem('snapEnabled', String(value)); apply(); }); surfaceSnap.subscribe((value) => { - localStorage.setItem('surfaceSnap', String(value)); + safeStorage.setItem('surfaceSnap', String(value)); }); snapSettings.subscribe((value) => { - localStorage.setItem('snapSettings', JSON.stringify(value)); + safeStorage.setItem('snapSettings', JSON.stringify(value)); apply(); }); } @@ -74,7 +75,7 @@ export const DEFAULT_SNAP_TARGETS = { function loadSnapTargets() { try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem('snapTargets') : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem('snapTargets') : null; // unknown/missing keys fall back to defaults, so old payloads keep working return { ...DEFAULT_SNAP_TARGETS, ...(raw ? JSON.parse(raw) : {}) }; } catch { @@ -86,7 +87,7 @@ function loadSnapTargets() { export const snapTargets = writable(loadSnapTargets()); snapTargets.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('snapTargets', JSON.stringify(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('snapTargets', JSON.stringify(value)); }); const DOWN = new THREE.Vector3(0, -1, 0); diff --git a/src/lib/themes.js b/src/lib/themes.js index dab42a29..69db7d59 100644 --- a/src/lib/themes.js +++ b/src/lib/themes.js @@ -1,4 +1,5 @@ import { writable, get } from 'svelte/store'; +import { safeStorage } from './safeStorage'; // UI themes (phase 89): a theme is a token block on :root[data-theme] (see // styles/theme.css) — strictly LOCAL chrome, never replicated. 'light' also @@ -62,7 +63,7 @@ export const THEME_TOKENS = [ function loadCustomThemes() { if (typeof localStorage === 'undefined') return []; try { - const raw = localStorage.getItem('customThemes'); + const raw = safeStorage.getItem('customThemes'); const parsed = raw ? JSON.parse(raw) : []; return Array.isArray(parsed) ? parsed : []; } catch { @@ -75,13 +76,13 @@ export const customThemes = writable(loadCustomThemes()); // must be initialized BEFORE the theme subscriber so a persisted custom id resolves on load export const theme = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('theme') ?? 'dark' : 'dark' + typeof localStorage !== 'undefined' ? safeStorage.getItem('theme') ?? 'dark' : 'dark' ); customThemes.subscribe((value) => { if (typeof localStorage === 'undefined') return; try { - localStorage.setItem('customThemes', JSON.stringify(value)); + safeStorage.setItem('customThemes', JSON.stringify(value)); } catch {} }); @@ -103,7 +104,7 @@ function applyTheme(id) { root.classList.toggle('dark', id !== 'light'); } try { - localStorage.setItem('theme', id); + safeStorage.setItem('theme', id); } catch {} } diff --git a/src/lib/touchControls.js b/src/lib/touchControls.js index d188f3b9..77557da6 100644 --- a/src/lib/touchControls.js +++ b/src/lib/touchControls.js @@ -1,4 +1,5 @@ import { writable, get } from 'svelte/store'; +import { safeStorage } from './safeStorage'; // W4: THE TOUCH PLAY CONTROLS LEAF — a virtual move stick and a look drag, plus the // one local preference that tunes them. @@ -61,7 +62,7 @@ function clamp(value, min, max) { function storedSpeed() { if (typeof localStorage === 'undefined') return 1; - const raw = Number(localStorage.getItem(SPEED_KEY)); + const raw = Number(safeStorage.getItem(SPEED_KEY)); if (!Number.isFinite(raw) || raw <= 0) return 1; return clamp(raw, TOUCH_LOOK_SPEED_RANGE.min, TOUCH_LOOK_SPEED_RANGE.max); } @@ -78,7 +79,7 @@ export function setTouchLookSpeed(value) { const next = clamp(Number(value) || 1, TOUCH_LOOK_SPEED_RANGE.min, TOUCH_LOOK_SPEED_RANGE.max); touchLookSpeed.set(next); try { - if (typeof localStorage !== 'undefined') localStorage.setItem(SPEED_KEY, String(next)); + if (typeof localStorage !== 'undefined') safeStorage.setItem(SPEED_KEY, String(next)); } catch { /* private mode — the pref is a convenience, never a requirement */ } diff --git a/src/lib/trackpadNav.js b/src/lib/trackpadNav.js index 57faeae1..2aeeaa21 100644 --- a/src/lib/trackpadNav.js +++ b/src/lib/trackpadNav.js @@ -17,54 +17,55 @@ import { globalCamera, globalRenderer, orbitControls } from '../stores/sceneStor // this one has to ask and stand down itself. proportional is a svelte/store-only // leaf: no cycle. import { proportionalWheelActive } from './proportional'; +import { safeStorage } from './safeStorage'; /** How two-finger swipes are treated: 'auto' (heuristic) | 'on' | 'off'. * @type {import('svelte/store').Writable} */ export const trackpadMode = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('trackpadMode') || 'auto' : 'auto' + typeof localStorage !== 'undefined' ? safeStorage.getItem('trackpadMode') || 'auto' : 'auto' ); trackpadMode.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('trackpadMode', value); + if (typeof localStorage !== 'undefined') safeStorage.setItem('trackpadMode', value); }); /** Accessibility escape hatch: let the BROWSER zoom the page again (pinch / * ctrl+wheel over UI, mobile pinch). Off by default — pinch is an app gesture. * @type {import('svelte/store').Writable} */ export const allowBrowserZoom = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('allowBrowserZoom') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('allowBrowserZoom') === 'true' ); allowBrowserZoom.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('allowBrowserZoom', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('allowBrowserZoom', String(value)); }); /** Flip the two-finger pan direction. The DEFAULT (off) is content-follows- * fingers, the user-picked direction; on = the opposite convention. * @type {import('svelte/store').Writable} */ export const reversePan = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('trackpadReversePan') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('trackpadReversePan') === 'true' ); reversePan.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('trackpadReversePan', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('trackpadReversePan', String(value)); }); /** Two-finger pan on/off (default ON). Off = trackpad swipes fall through to the * wheel zoom and panning stays available via right-click drag (OrbitControls). * @type {import('svelte/store').Writable} */ export const panEnabled = writable( - typeof localStorage === 'undefined' || localStorage.getItem('trackpadPanEnabled') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('trackpadPanEnabled') !== 'false' ); panEnabled.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('trackpadPanEnabled', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('trackpadPanEnabled', String(value)); }); /** Pinch-to-zoom on/off (default ON). Off = pinch does nothing to the camera * (the page-zoom guard still applies); zoom stays on the mouse wheel. * @type {import('svelte/store').Writable} */ export const pinchZoomEnabled = writable( - typeof localStorage === 'undefined' || localStorage.getItem('trackpadPinchZoom') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('trackpadPinchZoom') !== 'false' ); pinchZoomEnabled.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('trackpadPinchZoom', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('trackpadPinchZoom', String(value)); }); // ---- 24-A2: the wheel classifier ------------------------------------------------ @@ -216,8 +217,8 @@ function panBy(e) { /** A2.3: once ever, the first time the classifier turns a wheel into a pan in auto * mode, point at the one-click override. `wheelHintSeen` in localStorage. */ function maybeWheelHint() { - if (typeof localStorage === 'undefined' || localStorage.getItem('wheelHintSeen')) return; - localStorage.setItem('wheelHintSeen', '1'); + if (typeof localStorage === 'undefined' || safeStorage.getItem('wheelHintSeen')) return; + safeStorage.setItem('wheelHintSeen', '1'); import('../stores/appStore').then((m) => m.showToast('Wheel panned instead of zooming? Viewport menu ▸ View ▸ Mouse wheel switches it') ); diff --git a/src/lib/units.js b/src/lib/units.js index b5177ee5..3a089957 100644 --- a/src/lib/units.js +++ b/src/lib/units.js @@ -1,4 +1,5 @@ import { writable } from 'svelte/store'; +import { safeStorage } from './safeStorage'; // #20 P3: display UNITS for numeric fields. // @@ -65,7 +66,9 @@ const ALIASES = { }; ALIASES.angleDeg = ALIASES.angle; -const ls = typeof localStorage !== 'undefined' ? localStorage : null; +// 27-H: `safeStorage` is the alias now — it already answers when there is no storage at +// all, so the `typeof` dance and the `?.` on every use below are what it replaces. +const ls = safeStorage; /** @param {string} key @param {string} fallback @param {string[]} allowed */ function storedUnit(key, fallback, allowed) { diff --git a/src/lib/uvEditor.js b/src/lib/uvEditor.js index ebdb1149..2866411e 100644 --- a/src/lib/uvEditor.js +++ b/src/lib/uvEditor.js @@ -10,6 +10,7 @@ import { applyMap, materialAt, recordMaterialChange, copyTextureParams } from '. // the unwrap REGISTRY: built-in projections, plus whatever a module registers import { unwrap } from './uvUnwrap'; import { MAX_SNAPSHOT } from './meshBudget'; +import { safeStorage } from './safeStorage'; // UV1: read-only reuse of the mesh snapshot pipeline. faceEdit owns the triangle // <-> geometry conversion AND the 'meshgeo' history kind (which already accepts a // {positions, groups, uvs} triple and re-broadcasts uvs on undo), so a UV commit @@ -60,13 +61,13 @@ export const uvBrushSize = writable(24); * @type {import('svelte/store').Writable<'size'|'opacity'|'off'>} */ export const uvPenPressure = writable( /** @type {any} */ ( - typeof localStorage !== 'undefined' && ['size', 'opacity', 'off'].includes(localStorage.getItem('uvPenPressure') || '') - ? localStorage.getItem('uvPenPressure') + typeof localStorage !== 'undefined' && ['size', 'opacity', 'off'].includes(safeStorage.getItem('uvPenPressure') || '') + ? safeStorage.getItem('uvPenPressure') : 'size' ) ); uvPenPressure.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('uvPenPressure', String(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('uvPenPressure', String(value)); }); /** a light touch still marks: the width/alpha factor at pressure 0 */ export const MIN_PRESSURE_FACTOR = 0.15; @@ -85,12 +86,12 @@ const pressureFactor = (w) => MIN_PRESSURE_FACTOR + (1 - MIN_PRESSURE_FACTOR) * * @type {import('svelte/store').Writable} */ export const uvFaceFilter = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('uvFaceFilter') ?? 'all' : 'all' + typeof localStorage !== 'undefined' ? safeStorage.getItem('uvFaceFilter') ?? 'all' : 'all' ); if (typeof localStorage !== 'undefined') uvFaceFilter.subscribe((value) => { try { - localStorage.setItem('uvFaceFilter', value); + safeStorage.setItem('uvFaceFilter', value); } catch {} }); diff --git a/src/lib/viewPrefs.js b/src/lib/viewPrefs.js index 3de5103a..6774f611 100644 --- a/src/lib/viewPrefs.js +++ b/src/lib/viewPrefs.js @@ -1,4 +1,5 @@ import { writable } from 'svelte/store'; +import { safeStorage } from './safeStorage'; // 18-A: viewport LINE colours — the wireframe view mode, the selection outline and // the mesh-edit overlay. A LOCAL per-device view preference, never replicated and @@ -35,7 +36,7 @@ export const DEFAULT_VIEW_PREFS = { function load() { try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(KEY) : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(KEY) : null; // unknown/missing keys fall back to defaults, so old payloads keep working const stored = raw ? JSON.parse(raw) : {}; return { ...DEFAULT_VIEW_PREFS, ...stored }; @@ -48,7 +49,7 @@ function load() { export const viewPrefs = writable(load()); viewPrefs.subscribe((value) => { - if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, JSON.stringify(value)); + if (typeof localStorage !== 'undefined') safeStorage.setItem(KEY, JSON.stringify(value)); }); /** @param {Partial} patch */ diff --git a/src/lib/viewportOverrides.js b/src/lib/viewportOverrides.js index 106cfd68..19e517cd 100644 --- a/src/lib/viewportOverrides.js +++ b/src/lib/viewportOverrides.js @@ -1,4 +1,5 @@ import { writable, get } from 'svelte/store'; +import { safeStorage } from './safeStorage'; // B — VIEWPORT OVERRIDES (this device). // @@ -56,9 +57,9 @@ function load() { for (const def of OVERRIDES) state[def.key] = true; if (typeof localStorage === 'undefined') return state; try { - const raw = localStorage.getItem(KEY); + const raw = safeStorage.getItem(KEY); if (raw) Object.assign(state, JSON.parse(raw)); - else if (localStorage.getItem(LEGACY_POST_KEY) === 'false') state.post = false; + else if (safeStorage.getItem(LEGACY_POST_KEY) === 'false') state.post = false; } catch {} return state; } @@ -68,7 +69,7 @@ export const viewportOverrides = writable(load()); viewportOverrides.subscribe((state) => { if (typeof localStorage === 'undefined') return; try { - localStorage.setItem(KEY, JSON.stringify(state)); + safeStorage.setItem(KEY, JSON.stringify(state)); } catch {} }); @@ -96,10 +97,10 @@ export function viewportOverridesDebug() { * not a promise that it does. LOCAL, like every other override here. */ export const vrPostEnabled = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('vrPostEnabled') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('vrPostEnabled') === 'true' ); vrPostEnabled.subscribe((value) => { try { - localStorage.setItem('vrPostEnabled', String(value)); + safeStorage.setItem('vrPostEnabled', String(value)); } catch {} }); diff --git a/src/lib/voiceChat.js b/src/lib/voiceChat.js index 33a11315..c771b174 100644 --- a/src/lib/voiceChat.js +++ b/src/lib/voiceChat.js @@ -7,6 +7,7 @@ import { ensureAudioContext as engineContext, bus, updateListener, resumeAudio } // LOCALLY with a gain (see the colo stage below); nothing about what we transmit changes. import { colocatedPeers, isColocatedWith } from './colocationPresence'; import { letterOf } from './keyOf'; +import { safeStorage } from './safeStorage'; // Voice chat over the existing peerjs mesh (MediaConnection). // - mic toggle transmits continuously; while OFF, holding V is push-to-talk @@ -20,7 +21,7 @@ export const micGranted = writable(false); export const pttActive = writable(false); // positional audio: voices come from the peer's avatar (PannerNode per peer) export const spatialVoice = writable( - typeof localStorage === 'undefined' || localStorage.getItem('spatialVoice') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('spatialVoice') !== 'false' ); /** @type {import('svelte/store').Writable<'ptt' | 'open' | 'off'>} VR mic mode (quick-menu tile) */ export const vrMicMode = writable('ptt'); @@ -387,7 +388,7 @@ mutedPeers.subscribe((list) => { // writes a store from inside a subscriber. colocatedPeers.subscribe(() => applyColocationGains()); spatialVoice.subscribe((on) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('spatialVoice', String(on)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('spatialVoice', String(on)); if (on) Object.entries(get(remoteStreams)).forEach(([peerId, stream]) => buildSpatialChain(peerId, stream)); else Object.keys(spatialChains).forEach(dropSpatialChain); }); diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index 15173109..6bbbabd3 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -128,6 +128,7 @@ import { setVRAxes, setVRButtons } from './inputRuntime'; import { suspendAnimation, resumeAnimation } from './flowRuntime'; import { drawMode, toggleDrawMode, addStrokePoint, endStroke } from './drawMode'; import { setPttHeld, cycleMicMode, vrMicMode, micActive, pttActive } from './voiceChat'; +import { safeStorage } from './safeStorage'; import { HOLD_MS, vrWindowAdjust, @@ -1267,7 +1268,7 @@ export function raycastSettings(index) { export function applySnapMode(mode) { vrSnapMode.set(mode); try { - localStorage.setItem('vrSnapMode', mode); + safeStorage.setItem('vrSnapMode', mode); } catch {} snapEnabled.set(mode === 'grid' || mode === 'rotation'); surfaceSnap.set(mode === 'surface'); @@ -2691,19 +2692,19 @@ export function executeVRMenuAction(name) { if (key === 'close') vrSettingsPanelOpen.set(false); else if (key === 'teleport') { vrTeleportEnabled.update((v) => !v); - try { localStorage.setItem('vrTeleportEnabled', String(get(vrTeleportEnabled))); } catch {} + try { safeStorage.setItem('vrTeleportEnabled', String(get(vrTeleportEnabled))); } catch {} } else if (key === 'mirror') { vrMirrorSnapTurn.update((v) => !v); - try { localStorage.setItem('vrMirrorSnapTurn', String(get(vrMirrorSnapTurn))); } catch {} + try { safeStorage.setItem('vrMirrorSnapTurn', String(get(vrMirrorSnapTurn))); } catch {} } else if (key === 'vertexhold') { vrVertexHold.update((v) => !v); - try { localStorage.setItem('vrVertexHold', String(get(vrVertexHold))); } catch {} + try { safeStorage.setItem('vrVertexHold', String(get(vrVertexHold))); } catch {} } else if (key === 'angle') { // cycle Off -> 15 -> 30 -> 45 -> Off const steps = [0, 15, 30, 45]; const next = steps[(steps.indexOf(get(vrSnapAngle)) + 1) % steps.length]; vrSnapAngle.set(next); - try { localStorage.setItem('vrSnapAngle', String(next)); } catch {} + try { safeStorage.setItem('vrSnapAngle', String(next)); } catch {} } else if (key === 'hz') { // B2.1: cycle Auto(max) -> 90 -> 120 and apply live if presenting const steps = ['auto', '90', '120']; @@ -2718,12 +2719,12 @@ export function executeVRMenuAction(name) { // WebXR can't hot-swap session modes — applies on the next VR entry const next = !get(vrPassthrough); vrPassthrough.set(next); - try { localStorage.setItem('vrPassthrough', String(next)); } catch {} + try { safeStorage.setItem('vrPassthrough', String(next)); } catch {} showToast('Passthrough ' + (next ? 'on' : 'off') + ' — takes effect on the next VR entry'); } else if (key === 'sleeve') { // K1: experimental forearm sleeve palette (default off) vrSleeveEnabled.update((v) => !v); - try { localStorage.setItem('vrSleeveEnabled', String(get(vrSleeveEnabled))); } catch {} + try { safeStorage.setItem('vrSleeveEnabled', String(get(vrSleeveEnabled))); } catch {} } else if (key === 'resetpanels') { resetWindowPoses(); showToast('VR panel positions reset'); @@ -2845,7 +2846,7 @@ export function executeVRMenuAction(name) { vrWireframeSelection.update((v) => { const next = !v; try { - localStorage.setItem('vrWireframe', String(next)); + safeStorage.setItem('vrWireframe', String(next)); } catch {} return next; }); @@ -2943,7 +2944,7 @@ export function executeVRMenuAction(name) { vrStatsOpen.update((v) => { const next = !v; try { - localStorage.setItem('vrStats', String(next)); + safeStorage.setItem('vrStats', String(next)); } catch {} return next; }); @@ -2953,7 +2954,7 @@ export function executeVRMenuAction(name) { const next = order[(order.indexOf(get(vrGrabStyle)) + 1) % order.length]; vrGrabStyle.set(next); try { - localStorage.setItem('vrGrabStyle', next); + safeStorage.setItem('vrGrabStyle', next); } catch {} showToast( next === 'rigid' @@ -2975,8 +2976,8 @@ export function executeVRMenuAction(name) { } } else if (name === 'grid') { showGrid.update((v) => !v); - if (localStorage.getItem('showGrid')) localStorage.removeItem('showGrid'); - else localStorage.setItem('showGrid', 'false'); + if (safeStorage.getItem('showGrid')) safeStorage.removeItem('showGrid'); + else safeStorage.setItem('showGrid', 'false'); } else if (name === 'undo') undo(); else if (name === 'redo') redo(); else if (name === 'box') spawnPrimitive('/create Box 1 1 1'); @@ -2988,7 +2989,7 @@ export function executeVRMenuAction(name) { else if (name === 'hand') { vrMenuHand.update((hand) => { const next = hand === 'right' ? 'left' : 'right'; - localStorage.setItem('vrMenuHand', next); + safeStorage.setItem('vrMenuHand', next); return next; }); } else if (name === 'mic') { diff --git a/src/lib/vrRadialMenu.js b/src/lib/vrRadialMenu.js index 414e3cea..f1aa98a6 100644 --- a/src/lib/vrRadialMenu.js +++ b/src/lib/vrRadialMenu.js @@ -14,6 +14,7 @@ import { simulating, remoteSimulating, toggleSimulation } from './physics'; import { setMicMode, vrMicMode } from './voiceChat'; import { duplicateSelection, deleteSelection, groupSelection, selectionUuids } from './objectActions'; import { savePrefab, savePrefabSelection } from './prefabs'; +import { safeStorage } from './safeStorage'; // D4 (roadmap 13): selection-set helpers for the Edit ring — counted labels // act on the whole SET (parity with the desktop object menu, U-2) @@ -286,7 +287,7 @@ function registerBuiltins() { SNAP_ANGLES[(SNAP_ANGLES.indexOf(get(vrSnapAngle)) + 1) % SNAP_ANGLES.length]; vrSnapAngle.set(next); try { - localStorage.setItem('vrSnapAngle', String(next)); + safeStorage.setItem('vrSnapAngle', String(next)); } catch {} } }); @@ -321,7 +322,7 @@ function registerBuiltins() { const next = get(vrMenuHand) === 'left' ? 'right' : 'left'; vrMenuHand.set(/** @type {any} */ (next)); try { - localStorage.setItem('vrMenuHand', next); + safeStorage.setItem('vrMenuHand', next); } catch {} } }); diff --git a/src/lib/vrWindowPoses.js b/src/lib/vrWindowPoses.js index a3747bd9..90ad56ce 100644 --- a/src/lib/vrWindowPoses.js +++ b/src/lib/vrWindowPoses.js @@ -1,6 +1,7 @@ // @ts-ignore - no bundled three type declarations (project-wide) import * as THREE from 'three'; import { writable } from 'svelte/store'; +import { safeStorage } from './safeStorage'; // VR window grab (111): every follower window (radial ring, objects panel, // color palette, stats card) can be detached by holding the other hand's grip @@ -23,7 +24,7 @@ export const vrWindowAdjust = writable(null); function loadPoses() { try { - return JSON.parse(localStorage.getItem('vrWindowPoses') ?? '{}') ?? {}; + return JSON.parse(safeStorage.getItem('vrWindowPoses') ?? '{}') ?? {}; } catch { return {}; } @@ -42,7 +43,7 @@ export function saveWindowPose(id, offset) { windowPoses.update((poses) => { const next = { ...poses, [id]: offset }; try { - localStorage.setItem('vrWindowPoses', JSON.stringify(next)); + safeStorage.setItem('vrWindowPoses', JSON.stringify(next)); } catch {} return next; }); @@ -52,7 +53,7 @@ export function saveWindowPose(id, offset) { export function resetWindowPoses() { windowPoses.set({}); try { - localStorage.removeItem('vrWindowPoses'); + safeStorage.removeItem('vrWindowPoses'); } catch {} } diff --git a/src/lib/whatsNew.js b/src/lib/whatsNew.js index f119d685..460effb4 100644 --- a/src/lib/whatsNew.js +++ b/src/lib/whatsNew.js @@ -10,6 +10,7 @@ import { APP_VERSION, IS_DEV } from './version.js'; import { showToast } from '../stores/appStore.js'; // The changelog ships as the repo-root CHANGELOG.md (GitHub renders the same file). import changelogRaw from '../../CHANGELOG.md?raw'; +import { safeStorage } from './safeStorage'; /** Raw markdown of the changelog, rendered by WhatsNew.svelte. */ export const CHANGELOG = String(changelogRaw || ''); @@ -22,12 +23,12 @@ const LAST_SEEN_VERSION = 'lastSeenVersion'; * @param {string} key @param {boolean} dflt */ function boolPref(key, dflt) { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(key) : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(key) : null; const store = writable(raw === null ? dflt : raw === 'true'); if (typeof localStorage !== 'undefined') { store.subscribe((v) => { try { - localStorage.setItem(key, v ? 'true' : 'false'); + safeStorage.setItem(key, v ? 'true' : 'false'); } catch { /* storage disabled */ } @@ -54,7 +55,7 @@ export const whatsNewUnseen = writable(false); function markSeen() { try { - localStorage.setItem(LAST_SEEN_VERSION, APP_VERSION); + safeStorage.setItem(LAST_SEEN_VERSION, APP_VERSION); } catch { /* storage disabled */ } @@ -80,7 +81,7 @@ export function openWelcome() { export function closeWelcome() { welcomeOpen.set(false); try { - localStorage.setItem(SEEN_WELCOME, 'true'); + safeStorage.setItem(SEEN_WELCOME, 'true'); } catch { /* storage disabled */ } @@ -132,7 +133,7 @@ export function hasDeepLink() { */ export function startWhatsNew() { if (typeof localStorage === 'undefined') return; - const firstVisit = !localStorage.getItem(SEEN_WELCOME); + const firstVisit = !safeStorage.getItem(SEEN_WELCOME); // R22 round 7 — DO NOT GREET AN INVITE. A URL with a peer id in its hash is somebody // answering "join me", and the first thing they should see is the session, not an // introduction to the app. The overlay is for a bare open; the version badge and its @@ -150,7 +151,7 @@ export function startWhatsNew() { // COMMITTED assertion — measured: whats-new went red on my machine and would have // stayed green in CI, which is the worst shape a local override can take. The debug // hook is the one reliable signal that this page is a test. - const underTest = !!localStorage.getItem('debugStores'); + const underTest = !!safeStorage.getItem('debugStores'); const skipEnv = !underTest && String(import.meta.env.VITE_SKIP_WELCOME ?? '') === 'true'; const welcomeThisBoot = !invited && !skipEnv && (firstVisit || get(showWelcomeOnStart)); if (welcomeThisBoot) welcomeOpen.set(true); @@ -161,7 +162,7 @@ export function startWhatsNew() { return; } if (!get(showWhatsNewNotice)) return; - const lastSeen = localStorage.getItem(LAST_SEEN_VERSION); + const lastSeen = safeStorage.getItem(LAST_SEEN_VERSION); // IS_DEV: the version string is constant across dev reloads, so this stays quiet // after the first acknowledgement instead of nagging every HMR restart. if (!lastSeen) { diff --git a/src/lib/windowTabs.js b/src/lib/windowTabs.js index 022ed670..b1e8100b 100644 --- a/src/lib/windowTabs.js +++ b/src/lib/windowTabs.js @@ -1,4 +1,5 @@ import { writable, get } from 'svelte/store'; +import { safeStorage } from './safeStorage'; // Window tab groups (phase 83, floating windows only — docked splits stay in // pending/81). Grouped windows share ONE rect; the active member is visible, @@ -19,7 +20,7 @@ let nextId = 1; function persist() { try { - localStorage.setItem( + safeStorage.setItem( 'windowTabGroups', JSON.stringify(get(tabGroups).map(({ id, members, active, rect }) => ({ id, members, active, rect }))) ); @@ -43,7 +44,7 @@ const migrateKey = (key) => KEY_ALIASES[key] ?? key; /** @type {any[]} groups waiting for their members to register+open again */ let pendingRestore = []; try { - pendingRestore = JSON.parse(localStorage.getItem('windowTabGroups') ?? '[]').map( + pendingRestore = JSON.parse(safeStorage.getItem('windowTabGroups') ?? '[]').map( (/** @type {any} */ saved) => ({ ...saved, members: (saved.members ?? []).map(migrateKey), diff --git a/src/stores/appStore.js b/src/stores/appStore.js index ef0de1d7..32709890 100644 --- a/src/stores/appStore.js +++ b/src/stores/appStore.js @@ -1,4 +1,5 @@ import { writable, derived, get } from 'svelte/store'; +import { safeStorage } from '../lib/safeStorage'; /** @type {import('svelte/store').Writable} */ export const settingsOpen = writable(null); @@ -19,12 +20,12 @@ export const inspectorKind = writable('selection'); * it). LOCAL preference. */ export const inspectorPinned = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('inspectorPinned') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('inspectorPinned') === 'true' ); if (typeof localStorage !== 'undefined') inspectorPinned.subscribe((v) => { try { - localStorage.setItem('inspectorPinned', String(v)); + safeStorage.setItem('inspectorPinned', String(v)); } catch {} }); export const flowGraphClose = writable(true); @@ -124,7 +125,7 @@ export const username = writable(null); // local player's avatar configuration (userdata slot 5, replicated to peers) const storedAvatarConfig = - typeof localStorage !== 'undefined' ? localStorage.getItem('avatarConfig') : null; + typeof localStorage !== 'undefined' ? safeStorage.getItem('avatarConfig') : null; /** @type {import('svelte/store').Writable<{body: string, hat: string, face: string}>} */ export const avatarConfig = writable( storedAvatarConfig ? JSON.parse(storedAvatarConfig) : { body: '#4f83cc', hat: 'none', face: 'label' } @@ -340,46 +341,46 @@ export const viewportMenuOpener = writable(null); /** @type {import('svelte/store').Writable} */ export const objectSearch = writable(null); export const objectSearchEnabled = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('objectSearchEnabled') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('objectSearchEnabled') === 'true' ); objectSearchEnabled.subscribe((on) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('objectSearchEnabled', String(on)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('objectSearchEnabled', String(on)); }); // advanced mode: reveals system objects (module content, environment rig) // in the object list behind a System filter chip export const advancedMode = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('advancedMode') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('advancedMode') === 'true' ); advancedMode.subscribe((on) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('advancedMode', String(on)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('advancedMode', String(on)); }); // object list: reveal the environment group behind an Environment chip (70.4) export const showEnvInList = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('showEnvInList') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('showEnvInList') === 'true' ); showEnvInList.subscribe((on) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('showEnvInList', String(on)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('showEnvInList', String(on)); }); // A3 (roadmap #13): show the physics simulation transport (SimControls HUD). // Default OFF — the standalone ▶/⏸/⏹ HUD confuses with the main play button in // Controls; the P shortcut still starts/stops the sim when this is hidden. export const showSimControls = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('showSimControls') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('showSimControls') === 'true' ); showSimControls.subscribe((on) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('showSimControls', String(on)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('showSimControls', String(on)); }); // N4: Explorer 3D model preview — a rotatable inline preview in Properties + a // popup on open. Global (all of Explorer), persisted; off by default. export const enable3dPreview = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('enable3dPreview') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('enable3dPreview') === 'true' ); enable3dPreview.subscribe((on) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('enable3dPreview', String(on)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('enable3dPreview', String(on)); }); // 21-H3: dropping a MULTI-selection into the viewport. OFF = the N objects SPREAD in @@ -388,10 +389,10 @@ enable3dPreview.subscribe((on) => { // stack. A LOCAL pref like every other Explorer setting — `explorerDrop` reads it and // nothing about it goes on the wire (each placement replicates through its own path). export const stackOnDrop = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('explorerStackOnDrop') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('explorerStackOnDrop') === 'true' ); stackOnDrop.subscribe((on) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('explorerStackOnDrop', String(on)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('explorerStackOnDrop', String(on)); }); // 21-I3 (locked answer 6): "Update from selection" REPLACES a prefab's bytes instantly @@ -400,20 +401,20 @@ stackOnDrop.subscribe((on) => { // can undo does not need a dialog in front of it, and the Undo is the safety net. A // LOCAL pref like every other Explorer setting; nothing about it goes on the wire. export const confirmPrefabUpdate = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('confirmPrefabUpdate') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('confirmPrefabUpdate') === 'true' ); confirmPrefabUpdate.subscribe((on) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('confirmPrefabUpdate', String(on)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('confirmPrefabUpdate', String(on)); }); // Shift+A quick-add (the cursor-anchored Add popover). Opt-in, persisted; OFF by // default — Shift is a camera-strafe modifier in fly mode, so the shortcut only // exists for users who ask for it in Settings. export const enableShiftAdd = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('enableShiftAdd') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('enableShiftAdd') === 'true' ); enableShiftAdd.subscribe((on) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('enableShiftAdd', String(on)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('enableShiftAdd', String(on)); }); /** @@ -433,7 +434,7 @@ enableShiftAdd.subscribe((on) => { export const touchTools = writable( (() => { if (typeof localStorage === 'undefined') return false; - const stored = localStorage.getItem('touchTools'); + const stored = safeStorage.getItem('touchTools'); if (stored !== null) return stored === 'true'; const coarse = typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches; @@ -442,7 +443,7 @@ export const touchTools = writable( })() ); touchTools.subscribe((on) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('touchTools', String(on)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('touchTools', String(on)); }); // The sticky additive-selection MODE the cluster toggles. Touch cannot hold a modifier, @@ -459,32 +460,32 @@ export const multiSelectMode = writable(false); // what MY copy command does is not scene data. export const duplicateCarriesAnimation = writable( typeof localStorage === 'undefined' || - localStorage.getItem('duplicateCarriesAnimation') !== 'false' + safeStorage.getItem('duplicateCarriesAnimation') !== 'false' ); duplicateCarriesAnimation.subscribe((on) => { if (typeof localStorage !== 'undefined') - localStorage.setItem('duplicateCarriesAnimation', String(on)); + safeStorage.setItem('duplicateCarriesAnimation', String(on)); }); export const duplicateCarriesFlow = writable( - typeof localStorage === 'undefined' || localStorage.getItem('duplicateCarriesFlow') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('duplicateCarriesFlow') !== 'false' ); duplicateCarriesFlow.subscribe((on) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('duplicateCarriesFlow', String(on)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('duplicateCarriesFlow', String(on)); }); export const duplicateCarriesShader = writable( - typeof localStorage === 'undefined' || localStorage.getItem('duplicateCarriesShader') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('duplicateCarriesShader') !== 'false' ); duplicateCarriesShader.subscribe((on) => { if (typeof localStorage !== 'undefined') - localStorage.setItem('duplicateCarriesShader', String(on)); + safeStorage.setItem('duplicateCarriesShader', String(on)); }); export const noteDoubleClickToOpen = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('noteDoubleClickToOpen') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('noteDoubleClickToOpen') === 'true' ); noteDoubleClickToOpen.subscribe((on) => { if (typeof localStorage !== 'undefined') - localStorage.setItem('noteDoubleClickToOpen', String(on)); + safeStorage.setItem('noteDoubleClickToOpen', String(on)); }); // E1 (roadmap #13): notification center — a persisted history of everything that @@ -495,7 +496,7 @@ export const notifications = writable( (() => { if (typeof localStorage === 'undefined') return []; try { - return JSON.parse(localStorage.getItem('notifications') || '[]'); + return JSON.parse(safeStorage.getItem('notifications') || '[]'); } catch { return []; } @@ -504,7 +505,7 @@ export const notifications = writable( notifications.subscribe((list) => { if (typeof localStorage === 'undefined') return; try { - localStorage.setItem('notifications', JSON.stringify(list.slice(-50))); + safeStorage.setItem('notifications', JSON.stringify(list.slice(-50))); } catch { /* storage full / disabled */ } @@ -546,11 +547,11 @@ export const connectBarHeight = writable(0); * hidden. Toggle in Settings; a `.allow-undock` root class drives the CSS, and the * panels read this to decide whether to force-dock on load. Persisted. */ export const mobileUndockAllowed = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('mobileUndockAllowed') === 'true' : false + typeof localStorage !== 'undefined' ? safeStorage.getItem('mobileUndockAllowed') === 'true' : false ); if (typeof localStorage !== 'undefined') { mobileUndockAllowed.subscribe((v) => { - try { localStorage.setItem('mobileUndockAllowed', v ? 'true' : 'false'); } catch { /* */ } + try { safeStorage.setItem('mobileUndockAllowed', v ? 'true' : 'false'); } catch { /* */ } if (typeof document !== 'undefined') document.documentElement.classList.toggle('allow-undock', !!v); }); } @@ -567,11 +568,11 @@ if (typeof localStorage !== 'undefined') { * shipped default-off, because the subscriber writes on the first flush — would be * pinned OFF forever with no way to tell that from never having chosen. Absent = ON. */ export const floatingToolbar = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('floatingToolbar') !== 'false' : true + typeof localStorage !== 'undefined' ? safeStorage.getItem('floatingToolbar') !== 'false' : true ); if (typeof localStorage !== 'undefined') { floatingToolbar.subscribe((v) => { - try { localStorage.setItem('floatingToolbar', v ? 'true' : 'false'); } catch { /* */ } + try { safeStorage.setItem('floatingToolbar', v ? 'true' : 'false'); } catch { /* */ } }); } @@ -596,43 +597,43 @@ if (typeof localStorage !== 'undefined') { * fresh key makes absent mean "never chose" again. The pref never shipped in a tagged * release, so there is nothing real to migrate. */ export const toolbarAlwaysOnTop = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('toolbarOnTop') === 'true' : false + typeof localStorage !== 'undefined' ? safeStorage.getItem('toolbarOnTop') === 'true' : false ); if (typeof localStorage !== 'undefined') { toolbarAlwaysOnTop.subscribe((v) => { - try { localStorage.setItem('toolbarOnTop', v ? 'true' : 'false'); } catch { /* */ } + try { safeStorage.setItem('toolbarOnTop', v ? 'true' : 'false'); } catch { /* */ } }); } /** PINNED: keep the drawer's tab bar (+ status) visible even when the body is * collapsed, so it acts as a persistent mini-bar under the pill. Persisted. */ export const connectDrawerPinned = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('connectDrawerPinned') === 'true' : false + typeof localStorage !== 'undefined' ? safeStorage.getItem('connectDrawerPinned') === 'true' : false ); /** Route toasts into the drawer's Toasts tab only — hide the viewport pop-ups even * when the drawer is closed (they still live in the Toasts tab + notification bell). * Persisted. */ export const toastsInDrawerOnly = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('toastsInDrawerOnly') === 'true' : false + typeof localStorage !== 'undefined' ? safeStorage.getItem('toastsInDrawerOnly') === 'true' : false ); if (typeof localStorage !== 'undefined') { connectDrawerPinned.subscribe((v) => { - try { localStorage.setItem('connectDrawerPinned', v ? 'true' : 'false'); } catch { /* */ } + try { safeStorage.setItem('connectDrawerPinned', v ? 'true' : 'false'); } catch { /* */ } }); toastsInDrawerOnly.subscribe((v) => { - try { localStorage.setItem('toastsInDrawerOnly', v ? 'true' : 'false'); } catch { /* */ } + try { safeStorage.setItem('toastsInDrawerOnly', v ? 'true' : 'false'); } catch { /* */ } }); } /** Show the "Local objects" section in the object list (viewer WIP / editor-shareable * objects). OFF by default — auto-enabled when the first local object is made; also * togglable under the object-list filter cog. Persisted. */ export const showLocalObjects = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('showLocalObjects') === 'true' : false + typeof localStorage !== 'undefined' ? safeStorage.getItem('showLocalObjects') === 'true' : false ); if (typeof localStorage !== 'undefined') { showLocalObjects.subscribe((v) => { try { - localStorage.setItem('showLocalObjects', v ? 'true' : 'false'); + safeStorage.setItem('showLocalObjects', v ? 'true' : 'false'); } catch { /* storage disabled */ } @@ -643,12 +644,12 @@ if (typeof localStorage !== 'undefined') { * cloud plugin is present). Default ON for discoverability; users can hide it and * still reach rooms via the chevron drawer's Rooms tab. Persisted. */ export const showRoomsButton = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('showRoomsButton') !== 'false' : true + typeof localStorage !== 'undefined' ? safeStorage.getItem('showRoomsButton') !== 'false' : true ); if (typeof localStorage !== 'undefined') { showRoomsButton.subscribe((v) => { try { - localStorage.setItem('showRoomsButton', v ? 'true' : 'false'); + safeStorage.setItem('showRoomsButton', v ? 'true' : 'false'); } catch { /* storage disabled */ } diff --git a/src/stores/flowStore.js b/src/stores/flowStore.js index 75ca7082..9c0a1928 100644 --- a/src/stores/flowStore.js +++ b/src/stores/flowStore.js @@ -1,4 +1,5 @@ import { writable, get } from 'svelte/store'; +import { safeStorage } from '../lib/safeStorage'; // Shared node graph state, replicated between peers. // @@ -224,7 +225,7 @@ export const flowCursors = writable({}); // animations use wall-clock time so phases match across peers (NTP keeps // machines within tens of ms); off = local page time like before export const syncedAnimations = writable( - typeof localStorage === 'undefined' || localStorage.getItem('syncedAnimations') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('syncedAnimations') !== 'false' ); // user-designed node definitions ({id, name, params, code}), replicated diff --git a/src/stores/sceneStore.js b/src/stores/sceneStore.js index df49458a..3f571e1f 100644 --- a/src/stores/sceneStore.js +++ b/src/stores/sceneStore.js @@ -1,6 +1,7 @@ import { writable } from 'svelte/store'; // dependency-free helper, so importing it keeps this store a leaf import { coarsePointer } from '../lib/inputDevice'; +import { safeStorage } from '../lib/safeStorage'; /** @type {import('svelte/store').Writable} */ export const globalScene = writable(null); @@ -79,45 +80,45 @@ export const peerHands = writable({}); // --- VR control suite --- // which hand carries the quick-menu (the other hand is the pointer) export const vrMenuHand = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('vrMenuHand') || 'right' : 'right' + typeof localStorage !== 'undefined' ? safeStorage.getItem('vrMenuHand') || 'right' : 'right' ); export const vrMenuOpen = writable(false); // snap-turn angle in degrees (15 / 30 / 45, or 0 = off — 155) export const vrSnapAngle = writable( - typeof localStorage !== 'undefined' ? parseInt(localStorage.getItem('vrSnapAngle') || '45') : 45 + typeof localStorage !== 'undefined' ? parseInt(safeStorage.getItem('vrSnapAngle') || '45') : 45 ); // mirror snap-turn direction (155): left flick turns right and vice-versa export const vrMirrorSnapTurn = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('vrMirrorSnapTurn') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('vrMirrorSnapTurn') === 'true' ); // teleport locomotion (157): default ON; off disables the right-stick-up arc export const vrTeleportEnabled = writable( - typeof localStorage === 'undefined' || localStorage.getItem('vrTeleportEnabled') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('vrTeleportEnabled') !== 'false' ); // VR sleeve palette (K1, experimental): a forearm strip of ghost primitives on // the LEFT controller (mirrors right when the menu owns the left hand) — // trigger-drag a ghost out to place it. DEFAULT OFF. export const vrSleeveEnabled = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('vrSleeveEnabled') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('vrSleeveEnabled') === 'true' ); // vertex grab style (182): default HOLD (trigger held = carry, release = drop); // OFF = the toggle style (press to grab, press again to drop) export const vrVertexHold = writable( - typeof localStorage === 'undefined' || localStorage.getItem('vrVertexHold') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('vrVertexHold') !== 'false' ); // VR flying: left-stick movement follows the controller aim (pitch included) export const vrFlying = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('vrFlying') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('vrFlying') === 'true' ); // passthrough preference (90): the VR button requests immersive-ar instead of // immersive-vr on the NEXT session start (WebXR can't hot-swap modes) export const vrPassthrough = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('vrPassthrough') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('vrPassthrough') === 'true' ); // radial menu open style (74): false = B/Y toggles (default), true = hold B/Y // and release over a sector to activate it export const vrMenuHold = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('vrMenuHold') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('vrMenuHold') === 'true' ); // native VR objects panel (101), opened from the radial Objects sector export const vrObjectsPanelOpen = writable(false); @@ -150,18 +151,18 @@ export const vrApprovePanelOpen = writable(false); export const vrToolMode = writable('select'); // B2.1 (roadmap 9): target VR refresh rate — 'auto' picks the highest supported export const vrTargetHz = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('vrTargetHz') || 'auto' : 'auto' + typeof localStorage !== 'undefined' ? safeStorage.getItem('vrTargetHz') || 'auto' : 'auto' ); vrTargetHz.subscribe((v) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('vrTargetHz', String(v)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('vrTargetHz', String(v)); }); // B2.3: how everyone's hand-tracked peers render LOCALLY — 'hands' (cuboid bones) // or 'spheres' (joint dots). A per-viewer preference, never replicated. export const peerHandStyle = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('peerHandStyle') || 'hands' : 'hands' + typeof localStorage !== 'undefined' ? safeStorage.getItem('peerHandStyle') || 'hands' : 'hands' ); peerHandStyle.subscribe((v) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('peerHandStyle', String(v)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('peerHandStyle', String(v)); }); // Viewport render mode (V-2): LOCAL per-viewer, never replicated — // 'shaded' | 'shaded-ao' (default on desktop) | 'wireframe' | 'custom' @@ -172,7 +173,7 @@ peerHandStyle.subscribe((v) => { // scenePost.adoptCustomView(), which only ever promotes a viewer who has not // explicitly picked a mode (see chooseViewMode). function defaultViewMode() { - const stored = typeof localStorage !== 'undefined' ? localStorage.getItem('viewMode') : null; + const stored = typeof localStorage !== 'undefined' ? safeStorage.getItem('viewMode') : null; if (stored) return stored; // AO is a FULLSCREEN pass: a poor default on a phone GPU even when it works, // and several mobile drivers mis-compile it (the viewport then keeps showing a @@ -183,21 +184,21 @@ function defaultViewMode() { } export const viewMode = writable(defaultViewMode()); viewMode.subscribe((v) => { - if (typeof localStorage !== 'undefined') localStorage.setItem('viewMode', String(v)); + if (typeof localStorage !== 'undefined') safeStorage.setItem('viewMode', String(v)); }); // VR snap MODE (156): 'off' | 'grid' | 'surface' | 'rotation' export const vrSnapMode = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('vrSnapMode') || 'off' : 'off' + typeof localStorage !== 'undefined' ? safeStorage.getItem('vrSnapMode') || 'off' : 'off' ); // 115: true = the prefabs window is world-fixed (📌), false = lazy-follows the view export const vrPrefabsPinned = writable(false); // VR selection indicator style (110): wireframe (default) or the shell export const vrWireframeSelection = writable( - typeof localStorage === 'undefined' || localStorage.getItem('vrWireframe') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('vrWireframe') !== 'false' ); // stats card on the pointer controller (102) — persisted so it re-attaches export const vrStatsOpen = writable( - typeof localStorage !== 'undefined' && localStorage.getItem('vrStats') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('vrStats') === 'true' ); // true while an AR (passthrough) session presents — a LOCAL view mode: the // scene background/fog go transparent so the room shows through; the @@ -220,7 +221,7 @@ export const gizmoSuppressed = writable(false); export const vrTransformMode = writable('move'); /** grab style (100): 'rigid' = controller-as-handle (default); 'move'/'rotate' = legacy gizmo grabs */ export const vrGrabStyle = writable( - typeof localStorage !== 'undefined' ? localStorage.getItem('vrGrabStyle') ?? 'rigid' : 'rigid' + typeof localStorage !== 'undefined' ? safeStorage.getItem('vrGrabStyle') ?? 'rigid' : 'rigid' ); /** handedness currently holding a grab ('left'|'right'|null) — gates that hand's stick */ export const vrGrabbedHand = writable(null); diff --git a/tests/e2e/storage-hardening.test.cjs b/tests/e2e/storage-hardening.test.cjs index db391b63..60c84ceb 100644 --- a/tests/e2e/storage-hardening.test.cjs +++ b/tests/e2e/storage-hardening.test.cjs @@ -328,6 +328,91 @@ h.run(async () => { h.check(/ms$/.test(line.cost), `...and what the last snapshot cost to prepare ("${line.cost}")`); await A.page.evaluate(() => window.__stores.storageUsage.storageModalOpen.set(false)); + // ---- 3. safeStorage: a broken localStorage no longer kills its caller --------------- + // SAFARI PRIVATE MODE, simulated where the browser really fails: `Storage.prototype + // .setItem` throws. Stubbing the PROTOTYPE rather than our own module is the point — + // everything downstream, including the ~500 codemodded call sites, meets the real + // failure. Restored immediately afterwards, or every later section runs degraded. + const priv = await A.page.evaluate(() => { + const store = window.__stores.safeStorage; + store.debugResetStorage(); + const real = Storage.prototype.setItem; + let threw = 0; + Storage.prototype.setItem = function () { + threw++; + throw new DOMException('The quota has been exceeded.', 'QuotaExceededError'); + }; + let raised = null; + let wrote = null; + try { + wrote = store.setItem('27h-pref', 'chosen'); + } catch (error) { + raised = String(error); + } + const readBack = store.getItem('27h-pref'); + const state = store.storageDebug(); + // and the counterfactual, in the same broken world: the bare call this replaced + let bareThrew = false; + try { + localStorage.setItem('27h-pref-bare', 'chosen'); + } catch { + bareThrew = true; + } + Storage.prototype.setItem = real; + return { raised, wrote, readBack, state, threw, bareThrew }; + }); + h.check(priv.threw > 0, `premise: the stub really is in the write path (${priv.threw} throws)`); + h.check(priv.bareThrew, 'premise: a bare localStorage.setItem throws in that world — the bug'); + h.check(priv.raised === null, 'safeStorage.setItem does not throw, so the caller survives'); + h.check(priv.wrote === false, '...and it says the write did not reach the disk'); + h.check( + priv.readBack === 'chosen', + `...while the setting still APPLIES for this session (read back "${priv.readBack}")` + ); + h.check( + priv.state.degraded === true && priv.state.failures > 0, + `...and the app knows it is degraded (${JSON.stringify(priv.state)})` + ); + + // A real setting, driven the way the app drives it, in the same broken world: the + // subscriber that persists it must still run its OTHER work. This is the actual bug — + // a throw inside a store subscriber kills the subscriber for the session. + const setting = await A.page.evaluate(async () => { + const real = Storage.prototype.setItem; + Storage.prototype.setItem = function () { + throw new DOMException('The quota has been exceeded.', 'QuotaExceededError'); + }; + let raised = null; + try { + const { autosaveEnabled } = window.__stores.autosave; + autosaveEnabled.set(false); + autosaveEnabled.set(true); + } catch (error) { + raised = String(error); + } + Storage.prototype.setItem = real; + let value = null; + window.__stores.autosave.autosaveEnabled.subscribe((v) => (value = v))(); + return { raised, value }; + }); + h.check( + setting.raised === null && setting.value === true, + `a setting toggled while storage is broken still applies (${setting.value}, raised ${setting.raised})` + ); + + // The whole codemod, asserted as a property rather than a diff: nothing in src/ calls + // localStorage directly any more, and CI fails on the next one that does. + const guard = await A.page.evaluate(() => ({ + exposed: typeof window.__stores.safeStorage?.setItem === 'function', + diagnostics: window.__stores.diagnostics.bundle().sections?.storage ?? null + })); + h.check(guard.exposed, 'premise: safeStorage is the module the app is using'); + h.check( + guard.diagnostics && typeof guard.diagnostics.degraded === 'boolean', + `the diagnostics bundle carries whether persistence is working (${JSON.stringify(guard.diagnostics)})` + ); + await A.page.evaluate(() => window.__stores.safeStorage.debugResetStorage()); + await A.page.evaluate(async () => { for (const k of ['27h-probe', '27h-abort', '27h-stall', '27h-after']) await window.__stores.idb.idbDelete(k); }); diff --git a/tests/unit/safeStorage.test.js b/tests/unit/safeStorage.test.js new file mode 100644 index 00000000..25d61dee --- /dev/null +++ b/tests/unit/safeStorage.test.js @@ -0,0 +1,202 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + getItem, + setItem, + removeItem, + keys, + clear, + storageDebug, + debugResetStorage, + safeStorage, + get, + set, + remove +} from '../../src/lib/safeStorage.js'; + +// 27-H (audit M4). The whole value of this module is what it does when storage is +// BROKEN, and every one of those states is reachable here with no browser: node has no +// `localStorage` at all, and the two failure modes are a `setItem` that throws (Safari +// private mode, a full quota) and a `localStorage` property that throws on ACCESS (a +// sandboxed iframe, some enterprise policies) — the second of which every +// `typeof localStorage === 'undefined'` guard in this codebase misses. + +/** a working stand-in, so the happy path is testable too @param {any} overrides */ +function fakeStorage(overrides = {}) { + /** @type {Map} */ + const map = new Map(); + return Object.assign( + { + /** @param {string} k */ + getItem: (k) => (map.has(k) ? map.get(k) : null), + /** @param {string} k @param {any} v */ + setItem: (k, v) => map.set(k, String(v)), + /** @param {string} k */ + removeItem: (k) => map.delete(k), + clear: () => map.clear(), + get length() { + return map.size; + }, + /** @param {number} i */ + key: (i) => [...map.keys()][i] ?? null, + __map: map + }, + overrides + ); +} + +/** @param {any} value */ +function install(value) { + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + get() { + if (typeof value === 'function') return value(); + return value; + } + }); +} + +afterEach(() => { + // @ts-ignore - installed by `install()` above; node has no localStorage to begin with + delete globalThis.localStorage; + debugResetStorage(); +}); + +beforeEach(() => debugResetStorage()); + +describe('with no storage at all (SSR, or a browser that has none)', () => { + it('still remembers what you set, for this session', () => { + expect(setItem('theme', 'light')).toBe(false); + expect(getItem('theme')).toBe('light'); + }); + + it('says so, rather than pretending', () => { + setItem('theme', 'light'); + const state = storageDebug(); + expect(state.available).toBe(false); + expect(state.degraded).toBe(true); + expect(state.fallbackKeys).toBe(1); + }); + + it('reads a key nobody set as null, not undefined', () => { + expect(getItem('never-set')).toBe(null); + }); +}); + +describe('with working storage', () => { + it('writes through and keeps nothing in memory', () => { + const store = fakeStorage(); + install(store); + expect(setItem('theme', 'dark')).toBe(true); + expect(store.__map.get('theme')).toBe('dark'); + expect(storageDebug().fallbackKeys).toBe(0); + expect(storageDebug().degraded).toBe(false); + expect(getItem('theme')).toBe('dark'); + }); + + it('coerces like localStorage does', () => { + install(fakeStorage()); + setItem('count', 3); + expect(getItem('count')).toBe('3'); + }); + + it('removes from both sides', () => { + const store = fakeStorage(); + install(store); + setItem('theme', 'dark'); + removeItem('theme'); + expect(getItem('theme')).toBe(null); + expect(store.__map.has('theme')).toBe(false); + }); +}); + +describe("Safari private mode: setItem throws, and that used to kill the caller's subscriber", () => { + it('does not throw, and the setting still applies', () => { + install( + fakeStorage({ + setItem() { + throw new DOMException('QuotaExceededError', 'QuotaExceededError'); + } + }) + ); + expect(() => setItem('theme', 'light')).not.toThrow(); + expect(getItem('theme')).toBe('light'); + const state = storageDebug(); + expect(state.degraded).toBe(true); + expect(state.failures).toBe(1); + expect(state.lastError).toBe('QuotaExceededError'); + }); + + it('the counterfactual: a bare call in the same place does throw', () => { + install( + fakeStorage({ + setItem() { + throw new Error('nope'); + } + }) + ); + expect(() => globalThis.localStorage.setItem('theme', 'light')).toThrow(); + }); + + it('a later successful write makes real storage the truth again', () => { + let broken = true; + const store = fakeStorage({ + /** @param {string} k @param {any} v */ + setItem(k, v) { + if (broken) throw new Error('nope'); + store.__map.set(k, String(v)); + } + }); + install(store); + setItem('theme', 'light'); + expect(storageDebug().fallbackKeys).toBe(1); + broken = false; + setItem('theme', 'dark'); + // the shadow is dropped, or it would outvote the real value forever + expect(storageDebug().fallbackKeys).toBe(0); + expect(getItem('theme')).toBe('dark'); + }); +}); + +describe('a sandboxed iframe: touching localStorage throws on ACCESS', () => { + it('is survived, which no `typeof localStorage` guard manages', () => { + install(() => { + throw new DOMException('The operation is insecure.', 'SecurityError'); + }); + expect(() => setItem('theme', 'light')).not.toThrow(); + expect(() => getItem('theme')).not.toThrow(); + expect(() => keys()).not.toThrow(); + // the value is still readable — that is the promise — so read it BEFORE the two + // calls that legitimately drop the fallback + expect(getItem('theme')).toBe('light'); + expect(storageDebug().available).toBe(false); + expect(() => removeItem('theme')).not.toThrow(); + expect(() => clear()).not.toThrow(); + }); +}); + +describe('keys() is the union of both sides', () => { + it('lists real keys and fallen-back ones together', () => { + const store = fakeStorage({ + /** @param {string} k @param {any} v */ + setItem(k, v) { + if (k === 'bad') throw new Error('nope'); + store.__map.set(k, String(v)); + } + }); + install(store); + setItem('win:a', '1'); + setItem('bad', '2'); + expect(keys().sort()).toEqual(['bad', 'win:a']); + }); +}); + +describe('the shapes callers use', () => { + it('the drop-in object and the short names are the same functions', () => { + expect(safeStorage.getItem).toBe(getItem); + expect(safeStorage.setItem).toBe(setItem); + expect(safeStorage.removeItem).toBe(removeItem); + expect(get).toBe(getItem); + expect(set).toBe(setItem); + expect(remove).toBe(removeItem); + }); +}); From f731555dd6abe6627559c3f254180fb61eb106a8 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 12 Sep 2026 11:55:33 +0300 Subject: [PATCH 4/5] [fix] 27-H: the microphone is given back The audit's M9. Mute only ever set `track.enabled = false`, and nothing in this module has ever called `stop()`. A disabled track is still a LIVE track: the tab keeps its recording indicator, the OS keeps the device claimed so nothing else can open it, and both stay that way for the life of the page after one press. That is a trust problem before it is a resource one - the indicator says "this page is listening" and it is not true. `leaveSession` never touched voice at all, so it survived leaving the session too. - `releaseMic()` stops every track, drops the `self` analyser, and CLOSES THE OUTGOING CALLS. The last part is not tidiness: a MediaConnection carries this stream, and `callPeer` skips a peer that already has one - so leaving a dead channel up would make the next re-acquire reach nobody. Closing means `ensureStream` re-calls everybody, which costs a renegotiation and is the only version that works. INCOMING calls are deliberately left alone: listening never needed a microphone, and turning your own mic off is not a request to stop hearing other people. - Called from: the mic toggle going OFF (immediately - you said so, and the indicator is what you are watching), the VR mic mode reaching 'off', and `leaveSession`. - PUSH-TO-TALK releases after a 3s IDLE GRACE rather than on the keyup. That is the one piece of policy here, and it is there because re-acquiring costs a `getUserMedia` AND a renegotiation with every peer: releasing instantly would make the second sentence of a conversation arrive late. A few seconds of indicator after you stop talking is active use; forever is the bug. - `releaseMic` also clears `micActive`, and THE TWO-PEER SECTION IS WHAT FOUND THAT: with the flag left true and no stream behind it, the toolbar claimed an open mic and the next press was read as "off", so the peer was never called at all. Measured as B seeing `incoming: 0` through a 20s wait. The state has to agree with the device. - THE SPEAKING POLL used to be armed once at init and run at ~7Hz for the life of the tab, with no microphone, no peers and nothing to measure. `syncPoll` arms it only while something is measurable (our stream, or any call) and stands it down otherwise, clearing `speakingPeers` when it does - nobody can be speaking when nothing is measured. - The AudioContext is deliberately NOT closed: `audioEngine` owns it for the whole app since #22 A1, so closing it here would silence music, sounds and pings. Counterfactuals, each proven by breaking the code: - `stop()` swapped back for `enabled = false` -> 5 checks red, reading `{"stream":true,"live":1,"enabled":0}` - a live-but-disabled track, which IS the bug. - the unconditional `setInterval` restored -> "nothing is claimed and nothing is polling" reads `polling:true` with no mic and no peers. - `releaseMic()` removed from `leaveSession` -> the mic survives leaving the session (`live:1, enabled:1`). Suites: storage-hardening 51/51 (37 -> 51, now two peers for section 5). Held green: voice-ptt, spatial-voice, autosave-object-flows, explorer-storage (5 suites, 495s on a freshly restarted server). PRE-EXISTING RED, A/B'd against BOTH this branch's previous commit and the lane base 87c9d72, failing identically on all three: net-reconnect ("B's new object reaches A after the heal"). svelte-check 357/47. Unit 98/98. Build green with the dev server stopped. One method note: after the A/B checkouts above, every suite died in setupPage's `waitForFunction` with `$peers` null inside Scene - the documented mid-session HMR churn, not a regression. A dev-server restart and a curl-grep for a new symbol cleared it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Pcm6oYNMNNBGyUj8VEm5Um --- src/lib/peerHandler.svelte.js | 6 +- src/lib/voiceChat.js | 121 +++++++++++++++++++++++++- tests/e2e/storage-hardening.test.cjs | 124 ++++++++++++++++++++++++++- 3 files changed, 247 insertions(+), 4 deletions(-) diff --git a/src/lib/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js index c7a38bb7..f54b4783 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -16,7 +16,7 @@ import { applyMeshGeo } from '$lib/faceEdit'; // materialsHandler, history) are already in this file's subtree. import { applyUvPaint, applyUvPaintEnd } from '$lib/uvEditor'; import { applySplineEdit } from '$lib/splineTool'; -import { initVoiceChat, attachVoiceToPeer, voicePeerConnected } from '$lib/voiceChat'; +import { initVoiceChat, attachVoiceToPeer, voicePeerConnected, releaseMic } from '$lib/voiceChat'; import { resolvePeerOptions, describePeerServer, peerServerStatus, parseInviteHash, decodeInviteServer, applyInviteServerOverride, inviteServerOverride } from '$lib/peerServer'; // 27-B/27-G integration: the RECOVERY story belongs in the copyable bundle, not in a // console nobody reads. diagnostics.js is a zero-dependency leaf, so this closes no cycle. @@ -1501,6 +1501,10 @@ export class PeerConnection { userdata.set(get(userdata).filter(u => u[0] === this.peer.id)); waitingForApproval.set([]); pendingApprovals.set([]); + // 27-H (audit M9): leaving a session must hand the microphone back. Nothing here + // touched voice, so the tab's recording indicator stayed on and the device stayed + // claimed after you left — for the life of the page. + releaseMic(); resetSession(); checkLocks(); peers.update((value) => value); diff --git a/src/lib/voiceChat.js b/src/lib/voiceChat.js index c771b174..4c3c3de7 100644 --- a/src/lib/voiceChat.js +++ b/src/lib/voiceChat.js @@ -38,6 +38,8 @@ let pttHeld = false; /** @type {Record} */ const outgoingCalls = {}; /** @type {Record} */ const incomingCalls = {}; /** @type {Record} */ const analysers = {}; +/** @type {any} the speaking-detection interval, armed only while there is audio */ +let pollTimer = null; /** * The shared AudioContext. #22 A1 moved OWNERSHIP into `audioEngine` — the whole @@ -52,6 +54,7 @@ export function ensureAudioContext() { /** @param {any} call @param {'in'|'out'} direction */ function trackCall(call, direction) { (direction === 'in' ? incomingCalls : outgoingCalls)[call.peer] = call; + syncPoll(); call.on('stream', (/** @type {MediaStream} */ stream) => { remoteStreams.update((map) => ({ ...map, [call.peer]: stream })); watchStream(call.peer, stream); @@ -225,9 +228,11 @@ function cleanupCall(peerId, direction) { delete analysers[peerId]; dropSpatialChain(peerId); } + syncPoll(); } async function ensureStream() { + clearTimeout(idleRelease); if (localStream) return true; try { localStream = await navigator.mediaDevices.getUserMedia({ audio: true }); @@ -235,6 +240,7 @@ async function ensureStream() { applyTrackState(); callEveryone(); watchStream('self', localStream); + syncPoll(); return true; } catch (error) { console.log('mic denied', error); @@ -243,6 +249,105 @@ async function ensureStream() { } } +/** + * 27-H (hardening audit M9) — GIVE THE MICROPHONE BACK. + * + * Mute only ever set `track.enabled = false`, and nothing in this module has ever + * called `stop()`. A disabled track is still a LIVE track: the tab keeps its recording + * indicator, the OS keeps the device claimed so nothing else can open it, and both + * stay that way for the life of the page after one press. That is a trust problem + * before it is a resource one — the indicator says "this page is listening" and it is + * not true. + * + * THE OUTGOING CALLS GO WITH IT, and they have to: a MediaConnection carries this + * stream, so leaving them up after stopping its tracks leaves peers holding a channel + * that can never carry audio again — `callPeer` skips a peer that already has one, so + * re-acquiring would reach nobody. Closing them means `ensureStream` re-calls + * everybody, which costs a renegotiation but is the only version that works. + * + * INCOMING calls are deliberately left alone: listening never needed a microphone, + * and turning your own mic off is not a request to stop hearing other people. + */ +export function releaseMic() { + clearTimeout(idleRelease); + if (!localStream) return false; + try { + localStream.getTracks().forEach((track) => track.stop()); + } catch {} + localStream = null; + delete analysers['self']; + for (const peerId of Object.keys(outgoingCalls)) { + try { + outgoingCalls[peerId].close(); + } catch {} + cleanupCall(peerId, 'out'); + } + pttActive.set(false); + // THE STATE HAS TO AGREE WITH THE DEVICE. Leaving `micActive` true with no stream + // behind it leaves the toolbar claiming the mic is open while nothing is being + // transmitted, and the NEXT toggle then turns it "off" — measured as B never being + // called at all, because the press the suite meant as "on" was read as "off". + micActive.set(false); + syncPoll(); + return true; +} + +/** + * How long the mic stays claimed after a push-to-talk release. + * + * NOT zero, and this is the one piece of policy in the change. Re-acquiring costs a + * `getUserMedia` AND a renegotiation with every peer, so releasing the instant a key + * comes up would make the second sentence of a conversation arrive seconds late. A few + * seconds of indicator after you stop talking is active use; forever is the bug. + * An explicit voice-OFF releases immediately — you said so. + */ +const PTT_IDLE_MS = 3000; +/** @type {any} */ let idleRelease = null; + +/** Arm the idle release, unless something is still transmitting. */ +function releaseWhenIdle() { + clearTimeout(idleRelease); + if (get(micActive) || pttHeld) return; + idleRelease = setTimeout(() => { + if (!get(micActive) && !pttHeld) releaseMic(); + }, PTT_IDLE_MS); +} + +/** + * 27-H (audit M9): the speaking poll used to be armed once at init and run at ~7Hz for + * the life of the tab — with no microphone, no peers and nothing to measure. It runs + * only while there is something to measure now: our own stream, or somebody on a call. + */ +function pollWanted() { + return !!localStream || Object.keys(incomingCalls).length > 0 || Object.keys(outgoingCalls).length > 0; +} + +function syncPoll() { + const wanted = pollWanted(); + if (wanted && !pollTimer) pollTimer = setInterval(pollSpeaking, 150); + else if (!wanted && pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + // nobody can be speaking when nothing is being measured + if (get(speakingPeers).length) speakingPeers.set([]); + } +} + +/** Is the microphone claimed, and is the analyser loop running? (tests / diagnostics) */ +export function voiceDebug() { + const tracks = localStream ? localStream.getTracks() : []; + return { + stream: !!localStream, + live: tracks.filter((t) => t.readyState === 'live').length, + ended: tracks.filter((t) => t.readyState === 'ended').length, + enabled: tracks.filter((t) => t.enabled).length, + polling: !!pollTimer, + outgoing: Object.keys(outgoingCalls).length, + incoming: Object.keys(incomingCalls).length, + analysers: Object.keys(analysers).length + }; +} + function applyTrackState() { const enabled = get(micActive) || pttHeld; localStream?.getAudioTracks().forEach((track) => (track.enabled = enabled)); @@ -266,6 +371,9 @@ export async function toggleMic() { if (next && !(await ensureStream())) return; micActive.set(next); applyTrackState(); + // M9: turning the mic off is an explicit "I am done" — the device goes back now, + // not after a grace, because the indicator is what the user is watching + if (!next) releaseMic(); } /** VR A-button push-to-talk (same track path as hold-V) @param {boolean} held */ @@ -275,7 +383,10 @@ export async function setPttHeld(held) { if (held) { if (await ensureStream()) applyTrackState(); else pttHeld = false; - } else applyTrackState(); + } else { + applyTrackState(); + releaseWhenIdle(); + } } /** Radial menu (74): jump straight to a mode, reusing the cycle transitions @@ -295,6 +406,8 @@ export async function cycleMicMode() { if (get(micActive)) await toggleMic(); pttHeld = false; applyTrackState(); + // M9: OFF means off — no stream, no device claim, no indicator + releaseMic(); } else { vrMicMode.set('ptt'); } @@ -337,6 +450,8 @@ function onKeyup(event) { if (letterOf(event) !== 'v' || !pttHeld) return; pttHeld = false; applyTrackState(); + // M9: hand the device back shortly after the hold ends + releaseWhenIdle(); } // --- speaking detection --- @@ -417,7 +532,9 @@ export function initVoiceChat(/** @type {any} */ pc) { window.addEventListener('keyup', onKeyup); // AudioContext starts suspended until a user gesture window.addEventListener('pointerdown', () => resumeAudio(), { once: false }); - setInterval(pollSpeaking, 150); + // M9: NOT an unconditional interval any more — `syncPoll` arms it when there is + // audio to measure and stands it down when there is not + syncPoll(); } /** A data connection to this peer just opened — call them if we transmit @param {string} peerId */ diff --git a/tests/e2e/storage-hardening.test.cjs b/tests/e2e/storage-hardening.test.cjs index 60c84ceb..79db26f2 100644 --- a/tests/e2e/storage-hardening.test.cjs +++ b/tests/e2e/storage-hardening.test.cjs @@ -13,7 +13,12 @@ const h = require('./helpers.cjs'); h.run(async () => { - const browser = await h.launch(); + // A FAKE CAPTURE DEVICE, for section 4: the microphone checks read `track.readyState` + // on a real MediaStream, which headless Chromium will not produce without it — and a + // stubbed stream would be asserting a mock rather than the release. + const browser = await h.launch({ + args: ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream'] + }); const A = await h.setupPage(browser, 'A'); // ---- 1. a transaction always settles ----------------------------------------------- @@ -413,6 +418,123 @@ h.run(async () => { ); await A.page.evaluate(() => window.__stores.safeStorage.debugResetStorage()); + // ---- 4. the microphone is given back ----------------------------------------------- + // A fake device, so a real MediaStream with real tracks exists to be stopped — the + // whole check is about `track.readyState`, and a stub would be asserting a mock. + const seam = await A.page.evaluate(() => typeof window.__stores.voiceChat?.voiceDebug === 'function'); + h.check(seam, 'premise: the voice seam is reachable'); + + const idle = await A.page.evaluate(() => window.__stores.voiceChat.voiceDebug()); + h.check( + idle.stream === false && idle.polling === false, + `with no mic and no peers nothing is claimed and nothing is polling (${JSON.stringify(idle)})` + ); + + const on = await A.page.evaluate(async () => { + await window.__stores.voiceChat.toggleMic(); + return window.__stores.voiceChat.voiceDebug(); + }); + h.check(on.stream === true && on.live === 1, `premise: the mic really opened (${on.live} live track)`); + h.check(on.polling === true, 'the speaking poll runs while there is audio to measure'); + + const off = await A.page.evaluate(async () => { + const before = window.__stores.voiceChat.voiceDebug(); + await window.__stores.voiceChat.toggleMic(); + const after = window.__stores.voiceChat.voiceDebug(); + return { before, after }; + }); + h.check( + off.after.stream === false, + `turning the mic off releases the stream rather than muting a live track (${JSON.stringify(off.after)})` + ); + h.check( + off.after.live === 0, + "...so the tab's recording indicator goes out and the device is free for another app" + ); + h.check(off.after.polling === false, '...and the analyser loop stands down with it'); + h.check( + off.after.analysers === 0, + `...and the analyser it was feeding is dropped too (${off.after.analysers})` + ); + + // PTT re-acquires, and does NOT release the instant the key comes up: re-acquiring + // costs a getUserMedia and a renegotiation with every peer, so an immediate release + // would make the next sentence arrive late. A few seconds is active use; forever is + // the bug this section is about. + const ptt = await A.page.evaluate(async () => { + await window.__stores.voiceChat.setPttHeld(true); + const held = window.__stores.voiceChat.voiceDebug(); + await window.__stores.voiceChat.setPttHeld(false); + await new Promise((r) => setTimeout(r, 400)); + const justAfter = window.__stores.voiceChat.voiceDebug(); + await new Promise((r) => setTimeout(r, 4200)); + const settled = window.__stores.voiceChat.voiceDebug(); + return { held, justAfter, settled }; + }); + h.check(ptt.held.stream === true && ptt.held.live === 1, 'push-to-talk re-acquires the device'); + h.check(ptt.justAfter.stream === true, '...and does not drop it the instant the key comes up'); + h.check( + ptt.settled.stream === false && ptt.settled.live === 0, + `...but hands it back once the hold is over (${JSON.stringify(ptt.settled)})` + ); + + // leaving a session is the other half of the report: nothing in the peer layer used to + // touch voice at all + const left = await A.page.evaluate(async () => { + await window.__stores.voiceChat.toggleMic(); + const before = window.__stores.voiceChat.voiceDebug(); + let peer = null; + window.__stores.peers.subscribe((/** @type {any} */ v) => (peer = v))(); + peer.leaveSession(); + return { before, after: window.__stores.voiceChat.voiceDebug() }; + }); + h.check(left.before.stream === true, 'premise: the mic was open when the session ended'); + h.check( + left.after.stream === false && left.after.live === 0, + `leaving the session hands the microphone back (${JSON.stringify(left.after)})` + ); + + // ---- 5. releasing the device must not cost the session its voice -------------------- + // THE RISK THIS CHANGE INTRODUCES, asserted rather than reasoned about: releasing the + // stream closes our OUTGOING MediaConnections (they carry it, and `callPeer` skips a + // peer that already has one, so leaving a dead channel up would make the next + // re-acquire reach nobody). So the thing to prove is that turning the mic back on + // really does call everybody again. + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + + const voiceOf = (peer) => peer.page.evaluate(() => window.__stores.voiceChat.voiceDebug()); + + await A.page.evaluate(() => window.__stores.voiceChat.toggleMic()); + await h.eventually(() => voiceOf(B), (v) => v.incoming > 0, "premise: A's first mic-on reaches B", 20000); + + await A.page.evaluate(() => window.__stores.voiceChat.toggleMic()); + const released = await voiceOf(A); + h.check( + released.stream === false && released.outgoing === 0, + `mic-off releases the device AND the channel that carried it (${JSON.stringify(released)})` + ); + + await A.page.evaluate(() => window.__stores.voiceChat.toggleMic()); + await h.eventually( + () => voiceOf(A), + (v) => v.stream === true && v.outgoing > 0, + 'turning the mic back on re-establishes the call, so voice survives a release', + 20000 + ); + await h.eventually( + () => voiceOf(B), + (v) => v.incoming > 0, + '...and the peer has a live incoming call again', + 20000 + ); + const restored = await voiceOf(A); + h.check( + restored.stream === true && restored.live === 1 && restored.outgoing > 0, + `...with a live device behind it (${JSON.stringify(restored)})` + ); + await A.page.evaluate(() => window.__stores.voiceChat.releaseMic()); + await A.page.evaluate(async () => { for (const k of ['27h-probe', '27h-abort', '27h-stall', '27h-after']) await window.__stores.idb.idbDelete(k); }); From 7646fc2cde4d1192f1910db946caff5c12e651e6 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 12 Sep 2026 12:29:32 +0300 Subject: [PATCH 5/5] [fix] 27-H: the dirty flag is read before the export, not beside the write Found reviewing my own phase 2 diff (b5898ec). That commit added the right guard - "a change made DURING the export is not in the bytes just written" - and then read `markAtStart = get(dirtyPulse)` immediately before `idbPut`, which is AFTER the GLTF export has already finished. The export is the slow part and therefore the entire window the guard exists for, so as written it compared a stamp taken after the risky period against itself and cleared `dirty` unconditionally in every real case. It is read on the first line of `writeSnapshot` now. Not a lost-work bug in practice - the `markDirty` that raced the save also armed a fresh debounce, so the edit still reached disk 30s later - but `isDirty()` read false in between, and that store is what Settings and the window title's dirty asterisk consult. The honest version of the guard is the one that measures the right window. Suite: two checks in storage-hardening - an edit made while a snapshot is being written stays unsaved, and a quiet save still clears the flag (a guard that only asserted the first half would pass with `dirty` never cleared at all). Counterfactual: the unconditional `dirty = false` restored -> "an edit made while a snapshot is being written stays unsaved" reads `(false)`, 56 of 57. Suites: storage-hardening 57/57 (51 -> 57 checks; the 51 in f731555's body was a miscount - 57 is the measured number). svelte-check 357/47. Unit 98/98. check:storage clean. Build green with the dev server stopped. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Pcm6oYNMNNBGyUj8VEm5Um --- src/lib/autosave.js | 7 +++++-- tests/e2e/storage-hardening.test.cjs | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/lib/autosave.js b/src/lib/autosave.js index 8ab0f6d1..d750552e 100644 --- a/src/lib/autosave.js +++ b/src/lib/autosave.js @@ -273,6 +273,11 @@ export function debugRequestSave() { } async function writeSnapshot() { + // What has changed BEFORE any of this runs. It has to be read HERE rather than + // beside the write: the GLTF export below is the slow part, so a change made + // during it is precisely the one that is NOT in the bytes we are about to store, + // and clearing `dirty` unconditionally at the end would mark it saved. + const markAtStart = get(dirtyPulse); // H1: persist EVERY graph document; orphan object graphs (owner object gone) // are pruned from the OUTPUT only. Legacy nodes/edges fields keep carrying the // scene graph so an old build can still restore this snapshot. @@ -348,8 +353,6 @@ async function writeSnapshot() { ? { position: camera.position.toArray(), target: controls?.target?.toArray() ?? [0, 0, 0] } : null }; - // what changed BEFORE the write; anything dirtied during it must survive the clear - const markAtStart = get(dirtyPulse); const bytes = estimateSnapshotBytes(snapshot); autosaveStatus.update((state) => ({ ...state, lastBytes: bytes })); try { diff --git a/tests/e2e/storage-hardening.test.cjs b/tests/e2e/storage-hardening.test.cjs index 79db26f2..e5422261 100644 --- a/tests/e2e/storage-hardening.test.cjs +++ b/tests/e2e/storage-hardening.test.cjs @@ -257,6 +257,29 @@ h.run(async () => { `the live cadence is the one that measurement implies (${Math.round(derived.ms)}ms -> ${derived.debounce}ms)` ); + // A change made DURING a save is NOT in the bytes that save wrote, so the save must + // not mark it saved (the held-body `lastWritten` rule, one domain over). The window is + // real and it is the GLTF export, which is the slow part — which is also why the + // pulse has to be read before the export rather than beside the write. + const duringSave = await A.page.evaluate(async () => { + const a = window.__stores.autosave; + const settle = a.saveNow(); + // synchronously after the save has begun: `markAtStart` is already taken + a.markAnnotationsDirty(); + await settle; + return { dirty: a.isDirty() }; + }); + h.check( + duringSave.dirty === true, + `an edit made while a snapshot is being written stays unsaved (${duringSave.dirty})` + ); + // and the ordinary case still clears, or the flag would be stuck on forever + const afterSave = await A.page.evaluate(async () => { + await window.__stores.autosave.saveNow(); + return window.__stores.autosave.isDirty(); + }); + h.check(afterSave === false, `...while a quiet save does clear it (${afterSave})`); + // A FAILED AUTOSAVE IS SAID OUT LOUD. This used to reach `console.log` and stop there, // so a full disk meant crash recovery had silently switched itself off. The quota error // is raised through the idb seam because a headless origin is granted tens of gigabytes