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'))}
Reset to {cid?.avatar ? 'account picture' : 'default'}
{/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 660f5b0b..d750552e 100644
--- a/src/lib/autosave.js
+++ b/src/lib/autosave.js
@@ -22,15 +22,16 @@ 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';
+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
@@ -39,9 +40,85 @@ 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'
+ typeof localStorage === 'undefined' || safeStorage.getItem('autosave') !== 'false'
);
/**
* 18-A: restore the snapshot on boot instead of asking. OFF by default — an
@@ -49,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
} */
@@ -95,6 +172,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 +208,76 @@ 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() {
+ // 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.
@@ -212,18 +353,71 @@ async function saveSnapshot() {
? { position: camera.position.toArray(), target: controls?.target?.toArray() ?? [0, 0, 0] }
: null
};
+ 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 +442,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) */
@@ -281,7 +483,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 */
}
@@ -376,7 +578,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 */
}
@@ -481,9 +683,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,13 +745,26 @@ 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
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.
+ registerDiagnosticsSection('autosave', () => ({
+ ...get(autosaveStatus),
+ enabled: get(autosaveEnabled),
+ dirty,
+ saving
+ }));
checkRestore();
}
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/idb.js b/src/lib/idb.js
index d5cbfb38..3c63c1df 100644
--- a/src/lib/idb.js
+++ b/src/lib/idb.js
@@ -1,57 +1,272 @@
// 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' | '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 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;
+}
+
+/**
+ * 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);
+ /** @param {string} fallback */
+ const fail = (fallback) => {
+ const forced = forcedError;
+ forcedError = null;
+ reject(forced ?? tx.error ?? new Error(fallback));
+ };
+ tx.oncomplete = () => resolve(value());
+ tx.onerror = () => fail('idb transaction failed');
+ tx.onabort = () => fail('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.
+ *
+ * `'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' || mode === 'quota') {
+ if (mode === 'quota')
+ forcedError = new DOMException('The quota has been exceeded.', 'QuotaExceededError');
+ 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/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/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/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/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/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..4c3c3de7 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');
@@ -37,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
@@ -51,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);
@@ -224,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 });
@@ -234,6 +240,7 @@ async function ensureStream() {
applyTrackState();
callEveryone();
watchStream('self', localStream);
+ syncPoll();
return true;
} catch (error) {
console.log('mic denied', error);
@@ -242,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));
@@ -265,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 */
@@ -274,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
@@ -294,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');
}
@@ -336,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 ---
@@ -387,7 +503,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);
});
@@ -416,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/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
new file mode 100644
index 00000000..e5422261
--- /dev/null
+++ b/tests/e2e/storage-hardening.test.cjs
@@ -0,0 +1,566 @@
+// 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 () => {
+ // 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 -----------------------------------------------
+ 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)`
+ );
+
+ // ---- 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 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
+ // 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));
+
+ // ---- 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());
+
+ // ---- 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);
+ });
+
+ 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);
+ });
+});
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);
+ });
+});