diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abf64162..f8e9394f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,14 @@ jobs: # stale by three. - name: svelte-check baseline gate run: node scripts/check-ratchet.cjs + # 27-H (audit M4): 507 bare `localStorage` calls were routed through + # $lib/safeStorage in one pass. Without a gate that codemod decays on the next + # feature, because the file you are editing still shows you ninety-three examples + # of the old way. `setItem` throws in Safari private mode and on a full quota, and + # most of these sit inside $effects and store subscribers, where the throw kills + # the subscriber for the session. + - name: no bare localStorage + run: node scripts/check-storage.cjs unit: runs-on: ubuntu-latest diff --git a/check-baseline.json b/check-baseline.json index fbfda597..222d02fc 100644 --- a/check-baseline.json +++ b/check-baseline.json @@ -1,6 +1,6 @@ { "comment": "27-I: the svelte-check floor, read ONLY by scripts/check-ratchet.cjs. It used to be hardcoded in release.yml's shell block, where it went stale (362 while the tree measured 359). Ratchet it DOWN whenever a change legitimately removes errors - that is the project convention, and --update does it in one command.", - "errors": 358, + "errors": 357, "warnings": 47, "measured": "2026-09-12" } diff --git a/package.json b/package.json index a4581e40..e8b74891 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "e2e": "node tests/e2e/run.cjs", "deps:check": "node scripts/deps-check.cjs", "sync-llms": "node scripts/sync-llms.cjs", - "test:unit": "vitest run" + "test:unit": "vitest run", + "check:storage": "node scripts/check-storage.cjs" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.10", diff --git a/scripts/check-storage.cjs b/scripts/check-storage.cjs new file mode 100644 index 00000000..37ad90a8 --- /dev/null +++ b/scripts/check-storage.cjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +// 27-H (hardening audit M4) — THE GUARD THAT KEEPS THE CODEMOD FROM DECAYING. +// +// 507 bare `localStorage` calls across 94 files were routed through `$lib/safeStorage` in +// one pass. Without something failing on the next bare one, that lasts exactly until the +// next feature: nobody grepping for "how do I persist a setting" finds the wrapper, they +// find ninety-three examples of `localStorage.setItem` in the file they are editing. +// +// This is the whole rule. `localStorage.setItem` THROWS in Safari private mode and on a +// full quota, and most of these sit inside `$effect`s and store subscribers, where a +// throw kills the subscriber for the session — the setting stops persisting AND the UI it +// drives stops updating, with nothing pointing at storage. +// +// Exits 1 on a violation; prints the file, the line and what to write instead. Wired into +// ci.yml's `check` job beside the svelte-check ratchet. +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const SRC = path.join(ROOT, 'src'); + +// `localStorage.x(` for the four methods, and the one enumeration form the codebase used +const CALL = /\blocalStorage\.(getItem|setItem|removeItem|clear)\s*\(/; +const KEYS = /\bObject\.keys\(\s*localStorage\s*\)/; + +/** + * The only files allowed to touch it directly, each for a stated reason. Adding to this + * list is a decision, which is the point of it being a list. + */ +const ALLOWED = new Map([ + ['src/lib/safeStorage.js', 'it IS the wrapper'], + [ + 'src/app.html', + 'an inline diff --git a/src/components/Outline.svelte b/src/components/Outline.svelte index 8f305979..cf052ac0 100644 --- a/src/components/Outline.svelte +++ b/src/components/Outline.svelte @@ -40,6 +40,7 @@ // 16-Q4: the camera preview window renders as an inset viewport of THIS renderer import { pipRect, pipTarget, glRect } from '$lib/cameraPip'; import { buildCamera } from '$lib/cameraObjects'; + import { safeStorage } from '$lib/safeStorage'; let outlineEffectSelected: OutlineEffect | null = null; let outlineEffectLocked: OutlineEffect | null = null; @@ -428,7 +429,7 @@ }); // e2e hook (debugStores opt-in): the effects live in this component only onMount(() => { - if (typeof localStorage !== 'undefined' && localStorage.getItem('debugStores')) + if (typeof localStorage !== 'undefined' && safeStorage.getItem('debugStores')) (window as any).__outlineDebug = () => ({ selected: outlineEffectSelected?.selection.size ?? -1, locked: outlineEffectLocked?.selection.size ?? -1, @@ -439,7 +440,7 @@ // L1: the compiled chain lives in this component only, and its ORDER is the // thing worth asserting — so the hook names each pass by identity rather than // by constructor (minified in a build) and reports the merge plan. - if (typeof localStorage !== 'undefined' && localStorage.getItem('debugStores')) + if (typeof localStorage !== 'undefined' && safeStorage.getItem('debugStores')) (window as any).__postDebug = () => ({ chain: ((composer as any).passes ?? []).map((pass: any) => { if (pass === renderPass) return 'render'; diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 5cd65074..1e8a8915 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -87,6 +87,7 @@ import PathWaypoints from './PathWaypoints.svelte'; import LockHighlights from './LockHighlights.svelte'; import Grid from '../extensions/Grid.svelte'; + import { safeStorage } from '$lib/safeStorage'; import Outline from './Outline.svelte' import Player from './play/Player.svelte' import { Mesh, Vector3 } from 'three' @@ -99,23 +100,23 @@ $globalScene.background = new THREE.Color(0x101010); - $username = localStorage.getItem('username'); - $userdata.push([$peers.peer.id, localStorage.getItem('username'), localStorage.getItem('avatar'), null, null, get(avatarConfig)]); + $username = safeStorage.getItem('username'); + $userdata.push([$peers.peer.id, safeStorage.getItem('username'), safeStorage.getItem('avatar'), null, null, get(avatarConfig)]); $userdata = $userdata; - $showGrid = localStorage.getItem('showGrid') === 'false' ? false : true; - $vrOverride = localStorage.getItem('vrOverride'); + $showGrid = safeStorage.getItem('showGrid') === 'false' ? false : true; + $vrOverride = safeStorage.getItem('vrOverride'); camera.current.position.set(10.5, 7.57, 11.4); let fov = camera.current.fov let resetSettings = false; setTimeout(() => { // $peers.send({ type: 'userdata', userdata: $userdata }); - if(localStorage.getItem("camx")) - camera.current.position.x = localStorage.getItem("camx"); - if(localStorage.getItem("camy")) - camera.current.position.y = localStorage.getItem("camy"); - if(localStorage.getItem("camz")) - camera.current.position.z = localStorage.getItem("camz"); + if(safeStorage.getItem("camx")) + camera.current.position.x = safeStorage.getItem("camx"); + if(safeStorage.getItem("camy")) + camera.current.position.y = safeStorage.getItem("camy"); + if(safeStorage.getItem("camz")) + camera.current.position.z = safeStorage.getItem("camz"); // console.log(camera.current.position) resetSettings = true; @@ -276,9 +277,9 @@ // console.log(camera.current.rotation) } if (resetSettings == true) { - // localStorage.setItem("camx",camera.current.position.x); - // localStorage.setItem("camy",camera.current.position.y); - // localStorage.setItem("camz",camera.current.position.z); + // safeStorage.setItem("camx",camera.current.position.x); + // safeStorage.setItem("camy",camera.current.position.y); + // safeStorage.setItem("camz",camera.current.position.z); } if (!$specatorMode) { diff --git a/src/components/editors/AnimationWindow.svelte b/src/components/editors/AnimationWindow.svelte index 98f13ae1..f7829e7c 100644 --- a/src/components/editors/AnimationWindow.svelte +++ b/src/components/editors/AnimationWindow.svelte @@ -51,6 +51,7 @@ import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize'; import { setDockOccupant, dockHeight, visibleDockKey, dockMinimized, activateDock, dockModeArm, forgetDockTab } from '$lib/bottomDock'; import { bottomDockable } from '$lib/bottomDockDrop'; + import { safeStorage } from '$lib/safeStorage'; // live-follow the primary selection (keeps a truthy [] before the first select) const target = $derived($selectedObject && $selectedObject.uuid ? $selectedObject : null); @@ -93,12 +94,12 @@ let view = $state(/** @type {'sheet'|'graph'} */ ('sheet')); /** 'off' | 'frame' | a step in seconds as a string */ let snapMode = $state( - typeof localStorage !== 'undefined' ? (localStorage.getItem('animationSnap') ?? 'frame') : 'frame' + typeof localStorage !== 'undefined' ? (safeStorage.getItem('animationSnap') ?? 'frame') : 'frame' ); let renaming = $state(/** @type {string|null} */ (null)); // how tall the clip list is allowed to be, dragged by the divider under it let clipsH = $state( - typeof localStorage !== 'undefined' ? parseInt(localStorage.getItem('animationClipsH') ?? '96') || 96 : 96 + typeof localStorage !== 'undefined' ? parseInt(safeStorage.getItem('animationClipsH') ?? '96') || 96 : 96 ); let clipsResizing = $state(false); /** the sidebar's own height, measured — the resize ceiling comes from it */ @@ -130,7 +131,7 @@ if (!clipsResizing) return; clipsResizing = false; e.currentTarget.releasePointerCapture?.(e.pointerId); - localStorage.setItem('animationClipsH', String(clipsH)); + safeStorage.setItem('animationClipsH', String(clipsH)); } // imported clips for the selected object (empty for anything not imported @@ -215,12 +216,12 @@ let winW = $state(660); let winH = $state(460); if (typeof localStorage !== 'undefined') { - docked = localStorage.getItem('animationDocked') !== 'false'; + docked = safeStorage.getItem('animationDocked') !== 'false'; // 18-B: a size saved on a bigger screen must not come back oversized. // Fitted before the assignment so nothing reads $state during init. const savedWin = clampWinSize( - parseInt(localStorage.getItem('animationWinW') ?? '660') || 660, - parseInt(localStorage.getItem('animationWinH') ?? '460') || 460, + parseInt(safeStorage.getItem('animationWinW') ?? '660') || 660, + parseInt(safeStorage.getItem('animationWinH') ?? '460') || 460, WIN_MIN ); winW = savedWin.w; @@ -228,7 +229,7 @@ } function setDocked(/** @type {boolean} */ v) { docked = v; - localStorage.setItem('animationDocked', String(v)); + safeStorage.setItem('animationDocked', String(v)); if (v) activateDock('animation'); else forgetDockTab('animation'); // an undock gives up its slot, so re-docking is a fresh add at the end of the strip } @@ -443,7 +444,7 @@ // select exactly what the eye picks out, including under zoom and pan. /** @type {'box'|'lasso'} */ let marqMode = $state( - typeof localStorage !== 'undefined' && localStorage.getItem('animationMarquee') === 'lasso' + typeof localStorage !== 'undefined' && safeStorage.getItem('animationMarquee') === 'lasso' ? 'lasso' : 'box' ); @@ -459,7 +460,7 @@ function setMarqMode(/** @type {'box'|'lasso'} */ mode) { marqMode = mode; try { - localStorage.setItem('animationMarquee', mode); + safeStorage.setItem('animationMarquee', mode); } catch {} } @@ -710,7 +711,7 @@ // MEAN, and one object can hold a 24fps swing beside a 60fps flourish — with a // LOCAL default for clips that never set one (`animationFps` in localStorage). const DEFAULT_FPS = (() => { - const raw = typeof localStorage !== 'undefined' ? Number(localStorage.getItem('animationFps')) : NaN; + const raw = typeof localStorage !== 'undefined' ? Number(safeStorage.getItem('animationFps')) : NaN; return Number.isFinite(raw) && raw >= 1 && raw <= 240 ? raw : 30; })(); const FPS = $derived(anim?.fps ?? DEFAULT_FPS); @@ -1427,7 +1428,7 @@ tooltip: FPS + ' fps', action: () => { snapMode = snapMode === 'frame' ? 'off' : 'frame'; - localStorage.setItem('animationSnap', snapMode); + safeStorage.setItem('animationSnap', snapMode); } }); menu = { x: e.clientX, y: e.clientY, items }; @@ -1652,8 +1653,8 @@ saveWinSize(); } function saveWinSize() { - localStorage.setItem('animationWinW', String(winW)); - localStorage.setItem('animationWinH', String(winH)); + safeStorage.setItem('animationWinW', String(winW)); + safeStorage.setItem('animationWinH', String(winH)); } /** 18-B: double-click the grip — back to the default size, position kept */ function resetWinSize() { @@ -2086,7 +2087,7 @@ value={snapMode} onchange={(e) => { snapMode = e.currentTarget.value; - localStorage.setItem('animationSnap', snapMode); + safeStorage.setItem('animationSnap', snapMode); }} > diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte index 5141d61e..eea23e3d 100644 --- a/src/components/editors/Explorer.svelte +++ b/src/components/editors/Explorer.svelte @@ -288,6 +288,7 @@ import WindowShell from '../shared/WindowShell.svelte'; import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize'; import { fly } from 'svelte/transition'; + import { safeStorage } from '$lib/safeStorage'; const clampH = (h: number) => Math.min(Math.max(h || 300, 200), Math.round(window.innerHeight * 0.8)); @@ -313,25 +314,25 @@ // ('explorerHeight'). It is a dock TAB now, so the dock's shared height owns // it — adopt the old value once, then drop the key. try { - const legacyH = localStorage.getItem('explorerHeight'); + const legacyH = safeStorage.getItem('explorerHeight'); if (legacyH) { dockHeight.set(clampH(parseInt(legacyH) || 300)); - localStorage.removeItem('explorerHeight'); + safeStorage.removeItem('explorerHeight'); } } catch {} - docked = localStorage.getItem('explorerDocked') !== 'false'; + docked = safeStorage.getItem('explorerDocked') !== 'false'; // 18-B: a size saved on a bigger screen must not come back oversized — // that is the state whose resize grip sits off-screen. Fitted BEFORE the // assignment so nothing reads $state during init (state_referenced_locally). const savedWin = clampWinSize( - parseInt(localStorage.getItem('explorerWinW') ?? '720') || 720, - parseInt(localStorage.getItem('explorerWinH') ?? '440') || 440, + parseInt(safeStorage.getItem('explorerWinW') ?? '720') || 720, + parseInt(safeStorage.getItem('explorerWinH') ?? '440') || 440, WIN_MIN ); winW = savedWin.w; winH = savedWin.h; - singleClickOpen = localStorage.getItem('explorerSingleClickOpen') === 'true'; - showBreadcrumb = localStorage.getItem('explorerBreadcrumb') !== 'false'; + singleClickOpen = safeStorage.getItem('explorerSingleClickOpen') === 'true'; + showBreadcrumb = safeStorage.getItem('explorerBreadcrumb') !== 'false'; } // touch / limited-width: keep the Explorer docked (no room to float; undock hidden), // unless the user opted into undocking on touch (Settings > Allow undocking) @@ -347,7 +348,7 @@ function setDocked(v: boolean) { docked = v; - localStorage.setItem('explorerDocked', String(v)); + safeStorage.setItem('explorerDocked', String(v)); if (v) bottomDockActive.set('explorer'); // re-docking makes it the visible panel else forgetDockTab('explorer'); // an undock gives up its slot, so re-docking is a fresh add at the end of the strip } @@ -431,8 +432,8 @@ saveWinSize(); } function saveWinSize() { - localStorage.setItem('explorerWinW', String(winW)); - localStorage.setItem('explorerWinH', String(winH)); + safeStorage.setItem('explorerWinW', String(winW)); + safeStorage.setItem('explorerWinH', String(winH)); } /** 18-B: double-click the grip — back to the default size, position kept */ function resetWinSize() { @@ -722,34 +723,34 @@ let expanded = $state(new Set()); if (typeof localStorage !== 'undefined') { try { - expanded = new Set(JSON.parse(localStorage.getItem('explorerExpanded') ?? '[]')); + expanded = new Set(JSON.parse(safeStorage.getItem('explorerExpanded') ?? '[]')); } catch {} } function toggleExpand(id: string) { const next = new Set(expanded); next.has(id) ? next.delete(id) : next.add(id); expanded = next; - localStorage.setItem('explorerExpanded', JSON.stringify([...next])); + safeStorage.setItem('explorerExpanded', JSON.stringify([...next])); } // 197: Library is always open (no caret). Scene is pinned at the bottom and // collapsed by default; double-click it to reveal audio/config/textures. let sceneExpanded = $state( - typeof localStorage !== 'undefined' && localStorage.getItem('explorerSceneExpanded') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('explorerSceneExpanded') === 'true' ); function toggleScene() { sceneExpanded = !sceneExpanded; - localStorage.setItem('explorerSceneExpanded', String(sceneExpanded)); + safeStorage.setItem('explorerSceneExpanded', String(sceneExpanded)); } // N6: Packs section (mirror Scene) — expandable, lists packs; opening a pack // shows its items with lazily-resolved thumbnails. let packsExpanded = $state( - typeof localStorage !== 'undefined' && localStorage.getItem('explorerPacksExpanded') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('explorerPacksExpanded') === 'true' ); function togglePacks() { packsExpanded = !packsExpanded; - localStorage.setItem('explorerPacksExpanded', String(packsExpanded)); + safeStorage.setItem('explorerPacksExpanded', String(packsExpanded)); if (packsExpanded && $packs.length === 0) loadPacks(); } let thumbIdx: Record = $state({}); // per pack-item webp->png->screenshot cursor @@ -757,14 +758,14 @@ // 21-G8: the "Import project as folder (.tp)…" menu entry's hidden picker let tpImportInput: HTMLInputElement | undefined = $state(); let hideBuiltinPacks = $state( - typeof localStorage !== 'undefined' && localStorage.getItem('explorerHideBuiltinPacks') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('explorerHideBuiltinPacks') === 'true' ); // P5: per-pack hide (built-ins can't be truly deleted — they're bundled/CDN — so // hiding is the reversible alternative; imported packs delete outright) let hiddenPacks = $state(new Set(loadHiddenPacks())); function loadHiddenPacks(): string[] { try { - return JSON.parse(localStorage.getItem('explorerHiddenPacks') || '[]'); + return JSON.parse(safeStorage.getItem('explorerHiddenPacks') || '[]'); } catch { return []; } @@ -773,12 +774,12 @@ const s = new Set(hiddenPacks); s.add(name); hiddenPacks = s; - localStorage.setItem('explorerHiddenPacks', JSON.stringify([...s])); + safeStorage.setItem('explorerHiddenPacks', JSON.stringify([...s])); if ($activeFolder === 'pack:' + name) openFolder('packs'); } function showAllHiddenPacks() { hiddenPacks = new Set(); - localStorage.setItem('explorerHiddenPacks', '[]'); + safeStorage.setItem('explorerHiddenPacks', '[]'); } let shownPacks = $derived( $packs.filter( @@ -2426,7 +2427,7 @@ let treeColH = $state(0); let rootsResizing = $state(false); let rootsH = $state( - (typeof localStorage !== 'undefined' && parseInt(localStorage.getItem('explorerRootsH') ?? '')) || + (typeof localStorage !== 'undefined' && parseInt(safeStorage.getItem('explorerRootsH') ?? '')) || 160 ); @@ -2509,13 +2510,13 @@ if (!rootsResizing) return; rootsResizing = false; (e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId); - localStorage.setItem('explorerRootsH', String(rootsH)); + safeStorage.setItem('explorerRootsH', String(rootsH)); } // 18-B's rule for every grip in the app: a double-click restores a size you might // otherwise have no way to get back function resetRootsH() { rootsH = Math.min(160, rootsMax); - localStorage.setItem('explorerRootsH', String(rootsH)); + safeStorage.setItem('explorerRootsH', String(rootsH)); } // ---- R22 round 13 P3: THE MOUNTS SECTION ----------------------------------------- @@ -2656,7 +2657,7 @@ const next = new Set(expanded); next.add(volumeKey(vol.id)); expanded = next; - localStorage.setItem('explorerExpanded', JSON.stringify([...next])); + safeStorage.setItem('explorerExpanded', JSON.stringify([...next])); openFolder(volumeKey(vol.id)); } /** @@ -7601,7 +7602,7 @@ checked={singleClickOpen} onchange={(e) => { singleClickOpen = e.currentTarget.checked; - localStorage.setItem('explorerSingleClickOpen', String(singleClickOpen)); + safeStorage.setItem('explorerSingleClickOpen', String(singleClickOpen)); }} /> Single-click opens folders @@ -7613,7 +7614,7 @@ checked={showBreadcrumb} onchange={(e) => { showBreadcrumb = e.currentTarget.checked; - localStorage.setItem('explorerBreadcrumb', String(showBreadcrumb)); + safeStorage.setItem('explorerBreadcrumb', String(showBreadcrumb)); }} /> Show path bar @@ -7664,7 +7665,7 @@ checked={hideBuiltinPacks} onchange={(e) => { hideBuiltinPacks = e.currentTarget.checked; - localStorage.setItem('explorerHideBuiltinPacks', String(hideBuiltinPacks)); + safeStorage.setItem('explorerHideBuiltinPacks', String(hideBuiltinPacks)); }} /> Hide built-in packs diff --git a/src/components/editors/FlowCode.svelte b/src/components/editors/FlowCode.svelte index 42f6ec0b..f106f495 100644 --- a/src/components/editors/FlowCode.svelte +++ b/src/components/editors/FlowCode.svelte @@ -15,6 +15,7 @@ import { tabbable, resizeGroup, tabGroups } from '$lib/windowTabs'; import { setDockOccupant, dockHeight, visibleDockKey, dockMinimized, activateDock, dockModeArm, forgetDockTab } from '$lib/bottomDock'; import { bottomDockable } from '$lib/bottomDockDrop'; + import { safeStorage } from '$lib/safeStorage'; let text = $state(''); let error = $state(''); @@ -22,13 +23,13 @@ let winW = $state(460); let winH = $state(440); if (typeof localStorage !== 'undefined') { - docked = localStorage.getItem('flowCodeDocked') !== 'false'; // start docked - winW = parseInt(localStorage.getItem('flowCodeWinW') ?? '460') || 460; - winH = parseInt(localStorage.getItem('flowCodeWinH') ?? '440') || 440; + docked = safeStorage.getItem('flowCodeDocked') !== 'false'; // start docked + winW = parseInt(safeStorage.getItem('flowCodeWinW') ?? '460') || 460; + winH = parseInt(safeStorage.getItem('flowCodeWinH') ?? '440') || 440; } function setDocked(/** @type {boolean} */ v) { docked = v; - localStorage.setItem('flowCodeDocked', String(v)); + safeStorage.setItem('flowCodeDocked', String(v)); if (v) activateDock('flowcode'); else forgetDockTab('flowcode'); // an undock gives up its slot, so re-docking is a fresh add at the end of the strip } @@ -129,8 +130,8 @@ if (!winResizing) return; winResizing = false; e.currentTarget.releasePointerCapture?.(e.pointerId); - localStorage.setItem('flowCodeWinW', String(winW)); - localStorage.setItem('flowCodeWinH', String(winH)); + safeStorage.setItem('flowCodeWinW', String(winW)); + safeStorage.setItem('flowCodeWinH', String(winH)); } diff --git a/src/components/editors/HudEditor.svelte b/src/components/editors/HudEditor.svelte index 9978e794..8840d17f 100644 --- a/src/components/editors/HudEditor.svelte +++ b/src/components/editors/HudEditor.svelte @@ -68,6 +68,7 @@ import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize'; import { setDockOccupant, dockHeight, visibleDockKey, dockMinimized, activateDock, dockModeArm, forgetDockTab } from '$lib/bottomDock'; import { bottomDockable } from '$lib/bottomDockDrop'; + import { safeStorage } from '$lib/safeStorage'; // 21-D5: WHICH document is being authored. `hudDocs` was already keyed // `'scene' | objectUuid`, so "attach this HUD to a camera" is simply authoring the @@ -116,10 +117,10 @@ let winW = $state(680); let winH = $state(480); if (typeof localStorage !== 'undefined') { - docked = localStorage.getItem('hudDocked') !== 'false'; + docked = safeStorage.getItem('hudDocked') !== 'false'; const saved = clampWinSize( - parseInt(localStorage.getItem('hudWinW') ?? '680') || 680, - parseInt(localStorage.getItem('hudWinH') ?? '480') || 480, + parseInt(safeStorage.getItem('hudWinW') ?? '680') || 680, + parseInt(safeStorage.getItem('hudWinH') ?? '480') || 480, WIN_MIN ); winW = saved.w; @@ -127,7 +128,7 @@ } function setDocked(/** @type {boolean} */ v) { docked = v; - localStorage.setItem('hudDocked', String(v)); + safeStorage.setItem('hudDocked', String(v)); if (v) activateDock('hud'); else forgetDockTab('hud'); // an undock gives up its slot, so re-docking is a fresh add at the end of the strip } @@ -181,7 +182,7 @@ const SCREENS_RESERVE = 148; let paneH = $state(0); let screensH = $state( - parseInt((typeof localStorage !== 'undefined' && localStorage.getItem('hudScreens:h')) || '132') || 132 + parseInt((typeof localStorage !== 'undefined' && safeStorage.getItem('hudScreens:h')) || '132') || 132 ); let screensResizing = $state(false); const screensMax = $derived(Math.max(56, (paneH || 320) - SCREENS_RESERVE)); @@ -203,7 +204,7 @@ screensResizing = false; e.currentTarget.releasePointerCapture?.(e.pointerId); try { - localStorage.setItem('hudScreens:h', String(screensH)); + safeStorage.setItem('hudScreens:h', String(screensH)); } catch {} } @@ -275,20 +276,20 @@ /** @param {string} key @param {number} fallback */ function snapPref(key, fallback) { if (typeof localStorage === 'undefined') return fallback; - const raw = localStorage.getItem(key); + const raw = safeStorage.getItem(key); const n = raw === null ? NaN : parseFloat(raw); return Number.isFinite(n) ? n : fallback; } let snapOn = $state( - typeof localStorage === 'undefined' ? SNAP_DEFAULTS.on : localStorage.getItem('hud:snapOn') !== 'false' + typeof localStorage === 'undefined' ? SNAP_DEFAULTS.on : safeStorage.getItem('hud:snapOn') !== 'false' ); let snapGrid = $state(Math.max(1, snapPref('hud:snapGrid', SNAP_DEFAULTS.grid))); let snapThreshold = $state(Math.max(0, snapPref('hud:snapThreshold', SNAP_DEFAULTS.threshold))); $effect(() => { try { - localStorage.setItem('hud:snapOn', String(snapOn)); - localStorage.setItem('hud:snapGrid', String(snapGrid)); - localStorage.setItem('hud:snapThreshold', String(snapThreshold)); + safeStorage.setItem('hud:snapOn', String(snapOn)); + safeStorage.setItem('hud:snapGrid', String(snapGrid)); + safeStorage.setItem('hud:snapThreshold', String(snapThreshold)); } catch {} }); // the lines the LIVE gesture is actually sitting on, drawn as 1px overlays. Cleared @@ -948,8 +949,8 @@ saveWinSize(); } function saveWinSize() { - localStorage.setItem('hudWinW', String(winW)); - localStorage.setItem('hudWinH', String(winH)); + safeStorage.setItem('hudWinW', String(winW)); + safeStorage.setItem('hudWinH', String(winH)); } function resetWinSize() { const fit = clampWinSize(WIN_DEFAULT.w, WIN_DEFAULT.h, WIN_MIN); diff --git a/src/components/editors/Nodes.svelte b/src/components/editors/Nodes.svelte index 875be0b4..c7ec646a 100644 --- a/src/components/editors/Nodes.svelte +++ b/src/components/editors/Nodes.svelte @@ -71,6 +71,7 @@ import { isValidFlowConnection, typeColor, replaceableInputEdges } from '$lib/flowSockets'; import { moduleNodeGroups, moduleNodeComponents } from '$lib/moduleSDK'; import { peers, username, modulesOpen, flowFocus } from '../../stores/appStore'; + import { safeStorage } from '$lib/safeStorage'; // 21-D7: DEEP LINK — 'show me the node that drives this HUD element'. A write-once // request that we act on and CLEAR, the inspectorScrollTo shape, so it cannot re-fire @@ -277,7 +278,7 @@ // 3775px for a 200px gesture. A test that needs to press a field needs this. $effect(() => { if (typeof window === 'undefined' || typeof localStorage === 'undefined') return; - if (localStorage.getItem('debugStores') !== 'true') return; + if (safeStorage.getItem('debugStores') !== 'true') return; // TS syntax, not a JSDoc cast: this file is lang="ts", where JSDoc @type is IGNORED (window as any).__flowViewport = { setViewport, fitView }; // A6.4: which types this MOUNTED pane can actually render, plus the snapshot it @@ -300,10 +301,10 @@ // inset its content above the Controls HUD only when the palette is actually shown. let { paletteOpen = $bindable( - typeof localStorage === 'undefined' || localStorage.getItem('flowPaletteOpen') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('flowPaletteOpen') !== 'false' ) }: { paletteOpen?: boolean } = $props(); - let paletteSide = $state(typeof localStorage !== 'undefined' ? localStorage.getItem('flowPaletteSide') ?? 'left' : 'left'); + let paletteSide = $state(typeof localStorage !== 'undefined' ? safeStorage.getItem('flowPaletteSide') ?? 'left' : 'left'); // #20 P7: the left column's own height, measured — the graph tree's resize ceiling let paletteColH = $state(0); @@ -687,7 +688,7 @@ title={paletteOpen ? 'Hide the node palette' : 'Show the node palette'} onclick={() => { paletteOpen = !paletteOpen; - localStorage.setItem('flowPaletteOpen', String(paletteOpen)); + safeStorage.setItem('flowPaletteOpen', String(paletteOpen)); }} > {paletteOpen ? (paletteSide === 'right' ? '▸' : '◂') : paletteSide === 'right' ? '◂' : '▸'} @@ -699,7 +700,7 @@ title="Move the palette to the other side" onclick={() => { paletteSide = paletteSide === 'right' ? 'left' : 'right'; - localStorage.setItem('flowPaletteSide', paletteSide); + safeStorage.setItem('flowPaletteSide', paletteSide); }} > ⇄ diff --git a/src/components/editors/ShaderEditor.svelte b/src/components/editors/ShaderEditor.svelte index 2cffb660..abee349a 100644 --- a/src/components/editors/ShaderEditor.svelte +++ b/src/components/editors/ShaderEditor.svelte @@ -61,6 +61,7 @@ import ShaderTexturePicker from './nodes/ShaderTexturePicker.svelte'; import ShaderVectorInput from './nodes/ShaderVectorInput.svelte'; import DragRow from '../ui/DragRow.svelte'; + import { safeStorage } from '$lib/safeStorage'; const nodeTypes = Object.fromEntries(shaderNodeDefs().map((def) => [def.key, ShaderNode])); const catalog = shaderNodeDefs().filter((def) => def.key !== SURFACE_NODE); @@ -375,12 +376,12 @@ let winW = $state(720); let winH = $state(480); if (typeof localStorage !== 'undefined') { - docked = localStorage.getItem('shaderDocked') !== 'false'; + docked = safeStorage.getItem('shaderDocked') !== 'false'; // 18-B: a size saved on a bigger screen must not come back oversized. Fitted // BEFORE the assignment so nothing reads $state during init. const savedWin = clampWinSize( - parseInt(localStorage.getItem('shaderWinW') ?? '720') || 720, - parseInt(localStorage.getItem('shaderWinH') ?? '480') || 480, + parseInt(safeStorage.getItem('shaderWinW') ?? '720') || 720, + parseInt(safeStorage.getItem('shaderWinH') ?? '480') || 480, WIN_MIN ); winW = savedWin.w; @@ -397,7 +398,7 @@ function setDocked(/** @type {boolean} */ v) { docked = v; - localStorage.setItem('shaderDocked', String(v)); + safeStorage.setItem('shaderDocked', String(v)); if (v) activateDock('shader'); // re-docking makes it the visible tab else forgetDockTab('shader'); // an undock gives up its slot, so re-docking is a fresh add at the end of the strip } @@ -479,8 +480,8 @@ saveWinSize(); } function saveWinSize() { - localStorage.setItem('shaderWinW', String(winW)); - localStorage.setItem('shaderWinH', String(winH)); + safeStorage.setItem('shaderWinW', String(winW)); + safeStorage.setItem('shaderWinH', String(winH)); } function resetWinSize() { const fit = clampWinSize(WIN_DEFAULT.w, WIN_DEFAULT.h, WIN_MIN); diff --git a/src/components/editors/UvEditor.svelte b/src/components/editors/UvEditor.svelte index b6799246..912cdb56 100644 --- a/src/components/editors/UvEditor.svelte +++ b/src/components/editors/UvEditor.svelte @@ -47,6 +47,7 @@ import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize'; import { setDockOccupant, dockHeight, visibleDockKey, dockMinimized, activateDock, dockModeArm, forgetDockTab } from '$lib/bottomDock'; import { bottomDockable } from '$lib/bottomDockDrop'; + import { safeStorage } from '$lib/safeStorage'; /** the armed transform modes, in 1/2/3 order */ const MODES = /** @type {['move'|'rotate'|'scale', string, string][]} */ ([ @@ -128,12 +129,12 @@ let winW = $state(640); let winH = $state(460); if (typeof localStorage !== 'undefined') { - docked = localStorage.getItem('uvDocked') !== 'false'; + docked = safeStorage.getItem('uvDocked') !== 'false'; // 18-B: a size saved on a bigger screen must not come back oversized. // Fitted before the assignment so nothing reads $state during init. const savedWin = clampWinSize( - parseInt(localStorage.getItem('uvWinW') ?? '640') || 640, - parseInt(localStorage.getItem('uvWinH') ?? '460') || 460, + parseInt(safeStorage.getItem('uvWinW') ?? '640') || 640, + parseInt(safeStorage.getItem('uvWinH') ?? '460') || 460, WIN_MIN ); winW = savedWin.w; @@ -141,7 +142,7 @@ } function setDocked(/** @type {boolean} */ v) { docked = v; - localStorage.setItem('uvDocked', String(v)); + safeStorage.setItem('uvDocked', String(v)); if (v) activateDock('uv'); else forgetDockTab('uv'); // an undock gives up its slot, so re-docking is a fresh add at the end of the strip } @@ -1543,8 +1544,8 @@ saveWinSize(); } function saveWinSize() { - localStorage.setItem('uvWinW', String(winW)); - localStorage.setItem('uvWinH', String(winH)); + safeStorage.setItem('uvWinW', String(winW)); + safeStorage.setItem('uvWinH', String(winH)); } /** 18-B: double-click the grip — back to the default size, position kept */ function resetWinSize() { diff --git a/src/components/menu/CharacterModal.svelte b/src/components/menu/CharacterModal.svelte index db2712d2..479eda41 100644 --- a/src/components/menu/CharacterModal.svelte +++ b/src/components/menu/CharacterModal.svelte @@ -3,6 +3,7 @@ import ThemedSelect from '../ui/ThemedSelect.svelte'; import { characterModalOpen, avatarConfig, userdata, peers } from '../../stores/appStore.js'; import { FACE_SHAPES, resolveAvatar } from '$lib/avatarModel'; + import { safeStorage } from '$lib/safeStorage'; // resolve so shape/showLabel have defaults even for older stored configs $: cfg = resolveAvatar($avatarConfig); @@ -29,7 +30,7 @@ function update(partial: any) { const next = { ...$avatarConfig, ...partial }; $avatarConfig = next; - localStorage.setItem('avatarConfig', JSON.stringify(next)); + safeStorage.setItem('avatarConfig', JSON.stringify(next)); // update our own userdata row and broadcast $userdata.forEach((element) => { if (element[0] === $peers.peer.id) element[5] = next; diff --git a/src/components/menu/Connect.svelte b/src/components/menu/Connect.svelte index fe53390a..49db56a6 100644 --- a/src/components/menu/Connect.svelte +++ b/src/components/menu/Connect.svelte @@ -12,6 +12,7 @@ import { connectSlot, drawerSlot } from '$lib/cloudHooks'; import CloudSlot from '../CloudSlot.svelte'; import ConnectInfoDrawer from './ConnectInfoDrawer.svelte'; + import { safeStorage } from '$lib/safeStorage'; let peerIdToConnect = $state(''); let displayid = $state('Generating...'); @@ -168,10 +169,10 @@ // is fine for a quick try but not recommended for real use. Shown once. try { const isLocalVersion = !/(\.io|\.app)$/i.test(location.hostname); - const firstRun = !localStorage.getItem('peerServerConfig'); - const seen = localStorage.getItem('localPeerNoticeSeen'); + const firstRun = !safeStorage.getItem('peerServerConfig'); + const seen = safeStorage.getItem('localPeerNoticeSeen'); if (isLocalVersion && firstRun && !seen) { - localStorage.setItem('localPeerNoticeSeen', '1'); + safeStorage.setItem('localPeerNoticeSeen', '1'); showToast( 'It looks like you are running a local build of theprototype. Configure a peer signaling server in Settings for reliable connections — the public PeerJS cloud is not recommended for real use.', [ diff --git a/src/components/menu/Controls.svelte b/src/components/menu/Controls.svelte index bc500bda..4e48ad99 100644 --- a/src/components/menu/Controls.svelte +++ b/src/components/menu/Controls.svelte @@ -37,6 +37,7 @@ import { togglePanel } from '$lib/panelToggles'; import { requestPlay, willEnterXR, willEnterAR, vrSupported, arSupported, xrSessionFailed } from '$lib/playMode'; import { DOCK_VIEWS } from '$lib/dockMenu'; + import { safeStorage } from '$lib/safeStorage'; import { VRButton, XRButton } from '@threlte/xr' // A panel is "shown" when it is open AND either the visible dock tab OR floating @@ -313,7 +314,7 @@ let hiddenChips: Set = $state( new Set( typeof localStorage !== 'undefined' - ? JSON.parse(localStorage.getItem('hiddenListChips') ?? '[]') + ? JSON.parse(safeStorage.getItem('hiddenListChips') ?? '[]') : [] ) ); @@ -327,7 +328,7 @@ if (viewMode === value) viewMode = ''; } hiddenChips = next; - localStorage.setItem('hiddenListChips', JSON.stringify([...next])); + safeStorage.setItem('hiddenListChips', JSON.stringify([...next])); } function resetAllFilters() { searchTerm = ''; @@ -335,7 +336,7 @@ lastTypes = new Set(); viewMode = ''; hiddenChips = new Set(); - localStorage.setItem('hiddenListChips', '[]'); + safeStorage.setItem('hiddenListChips', '[]'); chipPopup = false; } @@ -429,7 +430,7 @@ // --- advanced mode: System filter shows scene-root module/env objects --- let systemRows = $state([]); let systemNoticeDismissed = $state( - typeof localStorage !== 'undefined' && localStorage.getItem('systemNoticeDismissed') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('systemNoticeDismissed') === 'true' ); let expandedSystem = $state({}); function refreshSystemRows() { @@ -478,7 +479,7 @@ // --- environment filter (70.4): read-only rows for environment-root --- let envRows = $state([]); let envNoticeDismissed = $state( - typeof localStorage !== 'undefined' && localStorage.getItem('envNoticeDismissed') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('envNoticeDismissed') === 'true' ); function refreshEnvRows() { const scene = $globalScene; @@ -534,7 +535,7 @@ // 80.1: proper resize (start-size captured, clamped) + persisted rect let saved: any = null; try { - saved = JSON.parse(localStorage.getItem('objectListRect') ?? 'null'); + saved = JSON.parse(safeStorage.getItem('objectListRect') ?? 'null'); } catch {} let moving = false; let left = saved?.left ?? 350; @@ -589,7 +590,7 @@ } const persist = () => - localStorage.setItem( + safeStorage.setItem( 'objectListRect', JSON.stringify({ left, top, width: node.offsetWidth, height: node.offsetHeight }) ); @@ -735,7 +736,7 @@ // vrOverride is the STRING mirror Settings writes; Scene seeds the store // from localStorage on boot, so both halves have to move together. vrOverride.set(true); - localStorage.setItem('vrOverride', 'true'); + safeStorage.setItem('vrOverride', 'true'); requestPlay(); } }, @@ -746,9 +747,9 @@ tooltip: $vrSupported ? 'Immersive VR — the scene replaces your view' : 'No immersive-vr support detected', action: () => { vrOverride.set(false); - localStorage.removeItem('vrOverride'); + safeStorage.removeItem('vrOverride'); vrPassthrough.set(false); - localStorage.setItem('vrPassthrough', 'false'); + safeStorage.setItem('vrPassthrough', 'false'); requestPlay(); } }, @@ -761,9 +762,9 @@ : 'No immersive-ar (passthrough) support detected', action: () => { vrOverride.set(false); - localStorage.removeItem('vrOverride'); + safeStorage.removeItem('vrOverride'); vrPassthrough.set(true); - localStorage.setItem('vrPassthrough', 'true'); + safeStorage.setItem('vrPassthrough', 'true'); requestPlay(); } }, @@ -923,7 +924,7 @@ function loadLayout(): ControlsLayout { if (typeof localStorage === 'undefined') return defaultLayout(); try { - const raw = localStorage.getItem('controlsLayout'); + const raw = safeStorage.getItem('controlsLayout'); if (!raw) return defaultLayout(); const saved = JSON.parse(raw) ?? {}; // W8b: kept ids are the ones the REGISTRY knows, not the ones the DEFAULT order @@ -962,7 +963,7 @@ function saveLayout() { try { - localStorage.setItem('controlsLayout', JSON.stringify(controlsLayout)); + safeStorage.setItem('controlsLayout', JSON.stringify(controlsLayout)); } catch { // private mode / storage full — the bar still works for this session } @@ -978,7 +979,7 @@ function resetLayout() { controlsLayout = defaultLayout(); try { - localStorage.removeItem('controlsLayout'); + safeStorage.removeItem('controlsLayout'); } catch { // nothing to clear } @@ -1115,7 +1116,7 @@ * than duplicated, so the two rows can say which one is on. `setDocked` keeps this * flag in step with the panel, so it is the honest answer either way. */ function explorerOpensDocked(): boolean { - return typeof localStorage === 'undefined' || localStorage.getItem('explorerDocked') !== 'false'; + return typeof localStorage === 'undefined' || safeStorage.getItem('explorerDocked') !== 'false'; } /** Move the Explorer between dock tab and floating window. @@ -2115,7 +2116,7 @@ class="rounded-sm bg-gray-600 px-1 text-white" on:click={() => { systemNoticeDismissed = true; - localStorage.setItem('systemNoticeDismissed', 'true'); + safeStorage.setItem('systemNoticeDismissed', 'true'); }}>✕ {/if} @@ -2168,7 +2169,7 @@ class="rounded-sm bg-gray-600 px-1 text-white" on:click={() => { envNoticeDismissed = true; - localStorage.setItem('envNoticeDismissed', 'true'); + safeStorage.setItem('envNoticeDismissed', 'true'); }}>✕ {/if} diff --git a/src/components/menu/Inspector.svelte b/src/components/menu/Inspector.svelte index c4f2393c..238d2b3e 100644 --- a/src/components/menu/Inspector.svelte +++ b/src/components/menu/Inspector.svelte @@ -200,6 +200,7 @@ saveSnapAnchorAsOrigin } from '$lib/snapEngine'; import { peers, inspectorClose, inspectorKind, inspectorPinned, showToast, inspectorFilter, notesDrawerOpen } from '../../stores/appStore.js'; + import { safeStorage } from '$lib/safeStorage'; import { isShaderDriven, openShaderEditor, @@ -278,7 +279,7 @@ let inspectorH = $state(0); $effect(() => { if (inspectorH || typeof window === 'undefined') return; - const saved = parseInt(localStorage.getItem('inspectorSheetH') || ''); + const saved = parseInt(safeStorage.getItem('inspectorSheetH') || ''); inspectorH = !saved || Number.isNaN(saved) ? Math.round(window.innerHeight * 0.45) : saved; }); let insResizing = $state(false); @@ -303,7 +304,7 @@ insResizing = false; /** @type {HTMLElement} */ (e.currentTarget).releasePointerCapture?.(e.pointerId); try { - localStorage.setItem('inspectorSheetH', String(inspectorH)); + safeStorage.setItem('inspectorSheetH', String(inspectorH)); } catch {} } @@ -1846,8 +1847,8 @@ checked={!!$showGrid} onchange={() => { showGrid.update((v) => !v); - if (localStorage.getItem('showGrid')) localStorage.removeItem('showGrid'); - else localStorage.setItem('showGrid', 'false'); + if (safeStorage.getItem('showGrid')) safeStorage.removeItem('showGrid'); + else safeStorage.setItem('showGrid', 'false'); }}>Show grid { - if (localStorage.getItem('showGrid')) localStorage.removeItem('showGrid'); - else localStorage.setItem('showGrid', 'false'); + if (safeStorage.getItem('showGrid')) safeStorage.removeItem('showGrid'); + else safeStorage.setItem('showGrid', 'false'); }} /> Display grid on floor @@ -1436,8 +1437,8 @@ { - if (localStorage.getItem('vrOverride')) localStorage.removeItem('vrOverride'); - else localStorage.setItem('vrOverride', 'true'); + if (safeStorage.getItem('vrOverride')) safeStorage.removeItem('vrOverride'); + else safeStorage.setItem('vrOverride', 'true'); }} /> Forces normal play even if immersive-vr is enabled @@ -1448,7 +1449,7 @@ checked={$vrFlying} onchange={(e) => { $vrFlying = e.target.checked; - localStorage.setItem('vrFlying', String($vrFlying)); + safeStorage.setItem('vrFlying', String($vrFlying)); }} /> Left-stick movement follows where the controller points (fly); off = stay level @@ -1463,7 +1464,7 @@ checked={$vrPassthrough} onchange={(e: any) => { $vrPassthrough = e.target.checked; - localStorage.setItem('vrPassthrough', String($vrPassthrough)); + safeStorage.setItem('vrPassthrough', String($vrPassthrough)); showToast('Passthrough ' + ($vrPassthrough ? 'on' : 'off') + ' — takes effect on the next VR entry'); }} /> @@ -1476,7 +1477,7 @@ onclick={() => { const next = $vrMenuHand === 'left' ? 'right' : 'left'; $vrMenuHand = next; - localStorage.setItem('vrMenuHand', next); + safeStorage.setItem('vrMenuHand', next); }} /> Which controller opens the VR quick-menu (the other hand points) @@ -1488,7 +1489,7 @@ checked={$vrMenuHold} onchange={(e: any) => { $vrMenuHold = e.target.checked; - localStorage.setItem('vrMenuHold', String($vrMenuHold)); + safeStorage.setItem('vrMenuHold', String($vrMenuHold)); }} /> Hold B/Y to show the radial menu, release over a sector to pick it (off = press toggles) @@ -1505,7 +1506,7 @@ value={$vrSnapAngle} onchange={(v) => { $vrSnapAngle = parseInt(v); - localStorage.setItem('vrSnapAngle', String($vrSnapAngle)); + safeStorage.setItem('vrSnapAngle', String($vrSnapAngle)); }} /> @@ -1518,7 +1519,7 @@ checked={$vrMirrorSnapTurn} onchange={(e: any) => { $vrMirrorSnapTurn = e.target.checked; - localStorage.setItem('vrMirrorSnapTurn', String($vrMirrorSnapTurn)); + safeStorage.setItem('vrMirrorSnapTurn', String($vrMirrorSnapTurn)); }} /> Flip the flick direction — left turns right and vice-versa @@ -1530,7 +1531,7 @@ checked={$vrTeleportEnabled} onchange={(e: any) => { $vrTeleportEnabled = e.target.checked; - localStorage.setItem('vrTeleportEnabled', String($vrTeleportEnabled)); + safeStorage.setItem('vrTeleportEnabled', String($vrTeleportEnabled)); }} /> Right-stick-up teleport arc — off if you navigate only by stick/fly @@ -1542,7 +1543,7 @@ checked={$vrSleeveEnabled} onchange={(e: any) => { $vrSleeveEnabled = e.target.checked; - localStorage.setItem('vrSleeveEnabled', String($vrSleeveEnabled)); + safeStorage.setItem('vrSleeveEnabled', String($vrSleeveEnabled)); }} /> Experimental — a strip of ghost primitives on your forearm: trigger-drag one out to place it (stick scales, wrist rotates). Grip-drop an object onto the strip to keep it as a personal slot @@ -1554,7 +1555,7 @@ checked={$vrVertexHold} onchange={(e: any) => { $vrVertexHold = e.target.checked; - localStorage.setItem('vrVertexHold', String($vrVertexHold)); + safeStorage.setItem('vrVertexHold', String($vrVertexHold)); }} /> Hold the trigger to carry a vertex (release drops it); off = press to grab, press again to drop @@ -2248,7 +2249,7 @@ {#snippet footer()} - + {/snippet} diff --git a/src/components/menu/Sidebar.svelte b/src/components/menu/Sidebar.svelte index ce8156d5..db2154d0 100644 --- a/src/components/menu/Sidebar.svelte +++ b/src/components/menu/Sidebar.svelte @@ -25,6 +25,7 @@ import { sidebarSlot } from '$lib/cloudHooks'; import CloudSlot from '../CloudSlot.svelte'; import { whatsNewUnseen, openWhatsNew } from '$lib/whatsNew'; + import { safeStorage } from '$lib/safeStorage'; // 203: redesigned as a compact floating panel — flat list (order preserved, // no boxed group / section headers / vertical bar), a fast fade-in (was a @@ -44,8 +45,8 @@ // your work, and it was taking a permanent third of a row from the two that are. // An enabled optional format renders on a SECOND ROW rather than widening the first, // so the primary pair never moves as the cog is toggled. - const initShowJson = typeof localStorage !== 'undefined' && localStorage.getItem('showJsonFormat') === 'true'; - const initShowGltf = typeof localStorage !== 'undefined' && localStorage.getItem('showGltfFormat') === 'true'; + const initShowJson = typeof localStorage !== 'undefined' && safeStorage.getItem('showJsonFormat') === 'true'; + const initShowGltf = typeof localStorage !== 'undefined' && safeStorage.getItem('showGltfFormat') === 'true'; /** * A STORED format can name one that is no longer on screen — a Save button pointing * at a control the user cannot see, which is the bug the JSON rule already existed @@ -57,7 +58,7 @@ if (f === 'gltf' && !gltf) return 'tp'; return f; } - const initFormat = typeof localStorage !== 'undefined' ? localStorage.getItem('saveFormat') || 'tp' : 'tp'; + const initFormat = typeof localStorage !== 'undefined' ? safeStorage.getItem('saveFormat') || 'tp' : 'tp'; let saveFormat = $state(visibleFormat(initFormat, initShowJson, initShowGltf)); let showJson = $state(initShowJson); let showGltf = $state(initShowGltf); @@ -78,9 +79,9 @@ exportPos = { top, left }; exportSettingsOpen = true; } - let tpAssets = $state(typeof localStorage !== 'undefined' && localStorage.getItem('tpsceneAssets') !== 'false'); - let tpPacks = $state(typeof localStorage !== 'undefined' && localStorage.getItem('tpscenePacks') === 'true'); - let tpFlow = $state(typeof localStorage !== 'undefined' && localStorage.getItem('tpsceneFlow') !== 'false'); + let tpAssets = $state(typeof localStorage !== 'undefined' && safeStorage.getItem('tpsceneAssets') !== 'false'); + let tpPacks = $state(typeof localStorage !== 'undefined' && safeStorage.getItem('tpscenePacks') === 'true'); + let tpFlow = $state(typeof localStorage !== 'undefined' && safeStorage.getItem('tpsceneFlow') !== 'false'); // 21-I5 (locked answer 2): the PROJECT box is ON by default, because a .tp has carried // its scene history since 21-G3 and flipping that off silently would make an existing // behaviour vanish — and it gates machinery with its own proper import. @@ -90,10 +91,10 @@ // an unnamed or never-travelled scene has no manifest entry, so the box that used to // sit here silently bundled nothing. The Explorer's scene card knows the name and the // history unambiguously, so downloading versions lives on ITS menu instead. - let tpProjectVersions = $state(typeof localStorage === 'undefined' || localStorage.getItem('tpProjectVersions') !== 'false'); + let tpProjectVersions = $state(typeof localStorage === 'undefined' || safeStorage.getItem('tpProjectVersions') !== 'false'); function pickFormat(f: string) { saveFormat = f; - localStorage.setItem('saveFormat', f); + safeStorage.setItem('saveFormat', f); } /** Called after either cog checkbox moves: if what is selected just went off screen, * fall back (and PERSIST the fallback — the stored value is what the next boot reads). */ @@ -291,31 +292,31 @@

Export settings

Scene (.tpscene) includes:

Project (.tp) includes:

diff --git a/src/components/menu/StorageModal.svelte b/src/components/menu/StorageModal.svelte index ee496091..5f0ee57e 100644 --- a/src/components/menu/StorageModal.svelte +++ b/src/components/menu/StorageModal.svelte @@ -22,6 +22,11 @@ import { HardDrive, RefreshCw, Trash2, Info, ChevronRight } from '@lucide/svelte'; import { showConfirm } from '$lib/confirmDialog'; import { showToast } from '../../stores/appStore'; + // 27-H (audit M5): autosave backs its own cadence off when an export gets expensive, + // and a save cadence that quietly moved from 30s to 5 minutes should be visible + // somewhere rather than guessed at. This panel is already where "what is this app + // doing to my disk" is answered. + import { autosaveStatus, autosaveEnabled } from '$lib/autosave'; import { storageModalOpen, storageScan, @@ -162,6 +167,14 @@ } } + /** "every 30 seconds" / "every 2 minutes" — the cadence in words. @param {number} ms */ + function fmtCadence(ms) { + const seconds = Math.round(ms / 1000); + if (seconds < 90) return seconds + ' seconds'; + const minutes = Math.round(seconds / 60); + return minutes + (minutes === 1 ? ' minute' : ' minutes'); + } + /** the fill of the used/quota bar, as a percentage @param {any} s */ function usedPct(s) { if (!s?.estimate?.quota) return 0; @@ -234,6 +247,26 @@ {:else}

Reading the store…

{/if} +

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

{#if groups.length} diff --git a/src/components/menu/Toasts.svelte b/src/components/menu/Toasts.svelte index 92d4fcdd..67258067 100644 --- a/src/components/menu/Toasts.svelte +++ b/src/components/menu/Toasts.svelte @@ -38,6 +38,7 @@ import { peerScenes, elsewhereThan, PRIVATE_SCENE } from '$lib/peerScenes'; import { currentLevel } from '$lib/levels'; import { showToast } from '../../stores/appStore'; + import { safeStorage } from '$lib/safeStorage'; /** * Stop watching and give the camera back. EXTRACTED from the banner button so the @@ -389,9 +390,9 @@ $effect(() => { $effect(() => { const notice = $appNotice; - const seen = typeof localStorage !== 'undefined' && !!localStorage.getItem('hasSeenDisclaimer'); + const seen = typeof localStorage !== 'undefined' && !!safeStorage.getItem('hasSeenDisclaimer'); const markSeen = () => { - try { localStorage.setItem('hasSeenDisclaimer', 'true'); } catch {} + try { safeStorage.setItem('hasSeenDisclaimer', 'true'); } catch {} }; if (notice && !seen) showInfoToast( @@ -557,7 +558,7 @@ style="z-index: var(--z-toast-low); pointer-events: none;" {#if $fixLight}
- { localStorage.setItem('hasSeenDisclaimer', 'true'); } + { safeStorage.setItem('hasSeenDisclaimer', 'true'); } }>
diff --git a/src/components/menu/Users.svelte b/src/components/menu/Users.svelte index 1ff4e389..50a29047 100644 --- a/src/components/menu/Users.svelte +++ b/src/components/menu/Users.svelte @@ -106,6 +106,7 @@ import NotificationCenter from './NotificationCenter.svelte'; import CloudSlot from '../CloudSlot.svelte'; import { usersSlot, profileSlot, rolesInfo, scenePresence } from '$lib/cloudHooks'; + import { safeStorage } from '$lib/safeStorage'; // N3: latency-band dot color for a peer's network-quality indicator const qColor = (level: string) => @@ -145,7 +146,7 @@ const reader = new FileReader(); reader.onload = function(fileLoadedEvent) { avatarImage = fileLoadedEvent.target.result; - localStorage.setItem('avatar', avatarImage); + safeStorage.setItem('avatar', avatarImage); //find and update, same for image $userdata.forEach(element => { @@ -159,7 +160,7 @@ }; reader.readAsDataURL(avatarFile); // an uploaded image is a CUSTOM avatar - try { localStorage.removeItem('avatarReset'); } catch {} + try { safeStorage.removeItem('avatarReset'); } catch {} } } @@ -168,7 +169,7 @@ // (pushed by the plugin via cloudApi.setAccountIdentity -> $cloudIdentity) UNLESS // the user set a custom one. "Custom username" = the usernameCustom flag; "custom // avatar" = an uploaded image in localStorage.avatar. - const ls = (k: string) => (typeof localStorage !== 'undefined' ? localStorage.getItem(k) : null); + const ls = (k: string) => (typeof localStorage !== 'undefined' ? safeStorage.getItem(k) : null); const usernameIsCustom = () => ls('usernameCustom') === '1'; const cid = $derived($cloudIdentity); /** what the header/button/peers show */ @@ -196,7 +197,7 @@ /** @param {string} v */ function setPeersView(v: string) { peersView = v; - try { localStorage.setItem('peers:view', v); } catch {} + try { safeStorage.setItem('peers:view', v); } catch {} } /** WHO AM I in the roster. The flat list has always taken index 0 as self (userdata * is built that way), so the fallback is not a guess — it is the same rule, reached @@ -443,15 +444,15 @@ function onUsernameEdited() { try { - localStorage.setItem('username', $username || ''); - localStorage.setItem('usernameCustom', ($username || '').trim() ? '1' : '0'); + safeStorage.setItem('username', $username || ''); + safeStorage.setItem('usernameCustom', ($username || '').trim() ? '1' : '0'); } catch {} broadcastUserdata(); } function resetAvatarToDefault() { avatarImage = ''; - try { localStorage.removeItem('avatar'); } catch {} + try { safeStorage.removeItem('avatar'); } catch {} broadcastUserdata(); // falls back to the cloud-account avatar (or default) } @@ -985,7 +986,7 @@ > {/if} - {#if avatarImage || (typeof localStorage !== 'undefined' && localStorage.getItem('avatar'))} + {#if avatarImage || (typeof localStorage !== 'undefined' && safeStorage.getItem('avatar'))} {/if} diff --git a/src/components/menu/ViewportMenu.svelte b/src/components/menu/ViewportMenu.svelte index a0f84745..67a611c9 100644 --- a/src/components/menu/ViewportMenu.svelte +++ b/src/components/menu/ViewportMenu.svelte @@ -18,6 +18,7 @@ import { togglePanel } from '$lib/panelToggles'; import { trackpadMode } from '$lib/trackpadNav'; import { helpersInPlay } from '$lib/helperLayer'; + import { safeStorage } from '$lib/safeStorage'; // Scene.svelte routes right-TAPS here (77): empty viewport → this menu with // the clicked ground point; an object under the cursor → its own context @@ -264,8 +265,8 @@ checked: !!$showGrid, action: () => { showGrid.update((v) => !v); - if (localStorage.getItem('showGrid')) localStorage.removeItem('showGrid'); - else localStorage.setItem('showGrid', 'false'); + if (safeStorage.getItem('showGrid')) safeStorage.removeItem('showGrid'); + else safeStorage.setItem('showGrid', 'false'); } }, { diff --git a/src/components/play/PlayReticle.svelte b/src/components/play/PlayReticle.svelte index 2edf1f7f..993276a6 100644 --- a/src/components/play/PlayReticle.svelte +++ b/src/components/play/PlayReticle.svelte @@ -5,11 +5,12 @@ // playInteract.js. import { isLocked, isVRMode } from '../../stores/sceneStore'; import { playInteractState } from '$lib/playInteract'; + import { safeStorage } from '$lib/safeStorage'; // the scroll hint is worth exactly one showing, so it is a LOCAL pref and // never touches the wire let hintSeen = $state( - typeof localStorage !== 'undefined' && localStorage.getItem('playCarryHintSeen') === 'true' + typeof localStorage !== 'undefined' && safeStorage.getItem('playCarryHintSeen') === 'true' ); const reticle = $derived($playInteractState); @@ -20,7 +21,7 @@ if (!carrying || hintSeen) return; hintSeen = true; try { - localStorage.setItem('playCarryHintSeen', 'true'); + safeStorage.setItem('playCarryHintSeen', 'true'); } catch {} }); diff --git a/src/components/ui/ToolboxSection.svelte b/src/components/ui/ToolboxSection.svelte index 7e70c8f9..7270b89e 100644 --- a/src/components/ui/ToolboxSection.svelte +++ b/src/components/ui/ToolboxSection.svelte @@ -11,6 +11,7 @@ // The open/closed state is a LOCAL preference (localStorage, per section // key): which sections a user keeps open is workflow, not scene data. import { ChevronRight } from '@lucide/svelte'; + import { safeStorage } from '$lib/safeStorage'; /** @type {{ key: string, label: string, open?: boolean, forceOpen?: boolean, * id?: string, children: any }} */ @@ -23,14 +24,14 @@ const isOpen = $derived.by(() => { if (forceOpen) return true; const saved = - override ?? (typeof localStorage !== 'undefined' ? localStorage.getItem(storeKey) : null); + override ?? (typeof localStorage !== 'undefined' ? safeStorage.getItem(storeKey) : null); return saved === null ? open : saved === 'open'; }); function toggle() { override = isOpen ? 'closed' : 'open'; try { - localStorage.setItem(storeKey, override); + safeStorage.setItem(storeKey, override); } catch {} } diff --git a/src/components/ui/ToolboxWindow.svelte b/src/components/ui/ToolboxWindow.svelte index 9019d906..8fb2c5c4 100644 --- a/src/components/ui/ToolboxWindow.svelte +++ b/src/components/ui/ToolboxWindow.svelte @@ -47,6 +47,7 @@ import { dragWindow } from '$lib/dragWindow'; import { focusStack } from '$lib/windowFocus'; import { notesDrawerOpen, inspectorClose } from '../../stores/appStore'; + import { safeStorage } from '$lib/safeStorage'; /** @type {{ id: string, title: string, key: string, * defaultRect?: { left?: number, top?: number, right?: number, bottom?: number }, @@ -89,7 +90,7 @@ const sheetKey = $derived('tbxSheetH:' + key); $effect(() => { if (sheetH || typeof window === 'undefined') return; - const saved = parseInt(localStorage.getItem(sheetKey) || ''); + const saved = parseInt(safeStorage.getItem(sheetKey) || ''); sheetH = !saved || Number.isNaN(saved) ? Math.round(window.innerHeight * 0.4) : saved; }); let sheetResizing = $state(false); @@ -121,7 +122,7 @@ /** @type {HTMLElement} */ (e.currentTarget).releasePointerCapture?.(e.pointerId); } catch {} try { - localStorage.setItem(sheetKey, String(sheetH)); + safeStorage.setItem(sheetKey, String(sheetH)); } catch {} } diff --git a/src/lib/ai/meshProviders.js b/src/lib/ai/meshProviders.js index 79a1e6b9..30cdb179 100644 --- a/src/lib/ai/meshProviders.js +++ b/src/lib/ai/meshProviders.js @@ -1,4 +1,5 @@ import { writable, get } from 'svelte/store'; +import { safeStorage } from '../safeStorage'; // Text/image -> 3D mesh generation providers (roadmap #11, G1). Mirrors // ai/providers.js (the LLM providers) but for mesh backends: a self-hosted ComfyUI @@ -53,7 +54,7 @@ const ENABLED_KEY = 'meshGenEnabled'; /** @returns {MeshProviderConfig[]} */ function loadProviders() { try { - const raw = localStorage.getItem(PROVIDERS_KEY); + const raw = safeStorage.getItem(PROVIDERS_KEY); const parsed = raw ? JSON.parse(raw) : null; return Array.isArray(parsed) ? parsed : []; } catch { @@ -64,7 +65,7 @@ function loadProviders() { /** @param {MeshProviderConfig[]} list */ function persist(list) { try { - localStorage.setItem(PROVIDERS_KEY, JSON.stringify(list)); + safeStorage.setItem(PROVIDERS_KEY, JSON.stringify(list)); } catch {} } @@ -75,7 +76,7 @@ export const meshProviders = writable(loadProviders()); export const meshActiveProvider = writable( (() => { try { - return localStorage.getItem(ACTIVE_KEY) || null; + return safeStorage.getItem(ACTIVE_KEY) || null; } catch { return null; } @@ -86,7 +87,7 @@ export const meshActiveProvider = writable( export const meshGenEnabled = writable( (() => { try { - return localStorage.getItem(ENABLED_KEY) === 'true'; + return safeStorage.getItem(ENABLED_KEY) === 'true'; } catch { return false; } @@ -160,8 +161,8 @@ export function removeMeshProvider(id) { export function setMeshActiveProvider(id) { meshActiveProvider.set(id); try { - if (id) localStorage.setItem(ACTIVE_KEY, id); - else localStorage.removeItem(ACTIVE_KEY); + if (id) safeStorage.setItem(ACTIVE_KEY, id); + else safeStorage.removeItem(ACTIVE_KEY); } catch {} } @@ -169,7 +170,7 @@ export function setMeshActiveProvider(id) { export function setMeshGenEnabled(on) { meshGenEnabled.set(!!on); try { - localStorage.setItem(ENABLED_KEY, String(!!on)); + safeStorage.setItem(ENABLED_KEY, String(!!on)); } catch {} } diff --git a/src/lib/ai/providers.js b/src/lib/ai/providers.js index de2adda2..754dbfc7 100644 --- a/src/lib/ai/providers.js +++ b/src/lib/ai/providers.js @@ -1,4 +1,5 @@ import { writable, get } from 'svelte/store'; +import { safeStorage } from '../safeStorage'; // AI provider settings (roadmap #10, A1). A LOCAL per-device preference — the // only credentials the app stores. Keys live in PLAINTEXT localStorage (there is @@ -89,7 +90,7 @@ const ENABLED_KEY = 'aiEnabled'; /** @returns {AiProviderConfig[]} */ function loadProviders() { try { - const raw = localStorage.getItem(PROVIDERS_KEY); + const raw = safeStorage.getItem(PROVIDERS_KEY); const parsed = raw ? JSON.parse(raw) : null; return Array.isArray(parsed) ? parsed : []; } catch { @@ -100,7 +101,7 @@ function loadProviders() { /** @param {AiProviderConfig[]} list */ function persistProviders(list) { try { - localStorage.setItem(PROVIDERS_KEY, JSON.stringify(list)); + safeStorage.setItem(PROVIDERS_KEY, JSON.stringify(list)); } catch {} } @@ -113,7 +114,7 @@ export const aiProviders = writable(loadProviders()); export const aiActiveProvider = writable( (() => { try { - return localStorage.getItem(ACTIVE_KEY) || null; + return safeStorage.getItem(ACTIVE_KEY) || null; } catch { return null; } @@ -124,7 +125,7 @@ export const aiActiveProvider = writable( export const aiEnabled = writable( (() => { try { - return localStorage.getItem(ENABLED_KEY) === 'true'; + return safeStorage.getItem(ENABLED_KEY) === 'true'; } catch { return false; } @@ -203,8 +204,8 @@ export function removeAiProvider(id) { export function setAiActiveProvider(id) { aiActiveProvider.set(id); try { - if (id) localStorage.setItem(ACTIVE_KEY, id); - else localStorage.removeItem(ACTIVE_KEY); + if (id) safeStorage.setItem(ACTIVE_KEY, id); + else safeStorage.removeItem(ACTIVE_KEY); } catch {} } @@ -212,7 +213,7 @@ export function setAiActiveProvider(id) { export function setAiEnabled(on) { aiEnabled.set(!!on); try { - localStorage.setItem(ENABLED_KEY, String(!!on)); + safeStorage.setItem(ENABLED_KEY, String(!!on)); } catch {} } diff --git a/src/lib/annotationsHandler.js b/src/lib/annotationsHandler.js index a8b30b8b..49749832 100644 --- a/src/lib/annotationsHandler.js +++ b/src/lib/annotationsHandler.js @@ -21,6 +21,7 @@ import { } from '../stores/appStore'; import { registerAnnotationsPersistence, markAnnotationsDirty } from './autosave'; import { flyTo } from './objectActions'; +import { safeStorage } from './safeStorage'; // Synced note pins on objects. Offsets are object-local so pins follow their // object; one note per pin. Replication mirrors the flow-graph pattern: @@ -49,10 +50,10 @@ export const noteMarkers = writable([]); /** H3: LOCAL pref — pins visible in the viewport (not replicated) */ export const showNotePins = writable( - typeof localStorage === 'undefined' || localStorage.getItem('showNotePins') !== 'false' + typeof localStorage === 'undefined' || safeStorage.getItem('showNotePins') !== 'false' ); if (typeof localStorage !== 'undefined') - showNotePins.subscribe((value) => localStorage.setItem('showNotePins', String(value))); + showNotePins.subscribe((value) => safeStorage.setItem('showNotePins', String(value))); /** H9: pin shapes (replicated per note; 'round' = the historical pin) */ export const NOTE_SHAPES = ['round', 'star', 'square']; @@ -172,9 +173,9 @@ let authorKeyCache = ''; export function myAuthorKey() { if (authorKeyCache) return authorKeyCache; try { - const stored = localStorage.getItem(AUTHOR_KEY); + const stored = safeStorage.getItem(AUTHOR_KEY); authorKeyCache = stored || crypto.randomUUID(); - if (!stored) localStorage.setItem(AUTHOR_KEY, authorKeyCache); + if (!stored) safeStorage.setItem(AUTHOR_KEY, authorKeyCache); } catch { authorKeyCache = 'local'; } diff --git a/src/lib/arProbe.js b/src/lib/arProbe.js index 39ceb61b..ef741fae 100644 --- a/src/lib/arProbe.js +++ b/src/lib/arProbe.js @@ -1,4 +1,5 @@ import { writable } from 'svelte/store'; +import { safeStorage } from './safeStorage'; // CO0 — the on-device WebXR capability probe. // @@ -47,7 +48,7 @@ const RESTORE_DEADLINE = 45; function loadFindings() { try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(FINDINGS_KEY) : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(FINDINGS_KEY) : null; const stored = raw ? JSON.parse(raw) : null; return Array.isArray(stored) ? stored : []; } catch { @@ -67,7 +68,7 @@ export const probeRunning = writable(false); /** @param {any} list */ function persistFindings(list) { try { - if (typeof localStorage !== 'undefined') localStorage.setItem(FINDINGS_KEY, JSON.stringify(list)); + if (typeof localStorage !== 'undefined') safeStorage.setItem(FINDINGS_KEY, JSON.stringify(list)); } catch { // private mode / quota: the on-screen report still works for this run } @@ -143,7 +144,7 @@ function ago(ms) { function readStoredAnchor() { try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(ANCHOR_KEY) : null; + const raw = typeof localStorage !== 'undefined' ? safeStorage.getItem(ANCHOR_KEY) : null; const stored = raw ? JSON.parse(raw) : null; return stored && typeof stored.handle === 'string' && stored.handle ? stored : null; } catch { @@ -154,7 +155,7 @@ function readStoredAnchor() { /** @param {any} record */ function writeStoredAnchor(record) { try { - if (typeof localStorage !== 'undefined') localStorage.setItem(ANCHOR_KEY, JSON.stringify(record)); + if (typeof localStorage !== 'undefined') safeStorage.setItem(ANCHOR_KEY, JSON.stringify(record)); return true; } catch { return false; @@ -599,8 +600,8 @@ export async function clearProbeState() { resetFindings(); try { if (typeof localStorage !== 'undefined') { - localStorage.removeItem(ANCHOR_KEY); - localStorage.removeItem(FINDINGS_KEY); + safeStorage.removeItem(ANCHOR_KEY); + safeStorage.removeItem(FINDINGS_KEY); } } catch { // nothing to do — the store is already reset diff --git a/src/lib/audioPatch.js b/src/lib/audioPatch.js index 4b062974..01a2e0fc 100644 --- a/src/lib/audioPatch.js +++ b/src/lib/audioPatch.js @@ -9,6 +9,7 @@ import { registerHistoryKind, recordEntry } from './history'; import { ensureAudioContext } from './audioEngine'; import { deviceHandle, deviceSpec, isDeviceObject } from './audioDevices'; import { wireframeActive } from './viewMode'; +import { safeStorage } from './safeStorage'; // THE PATCH (roadmap #23 A4, cloud plans-core/pending/23-a-audio-engine.md). // @@ -381,7 +382,7 @@ export function reconcileRouting() { /** LOCAL pref: draw the cables. On by default — a patch you cannot see is not much of * a patch. */ -export const showCables = writable(typeof localStorage === 'undefined' || localStorage.getItem('showCables') !== 'false'); +export const showCables = writable(typeof localStorage === 'undefined' || safeStorage.getItem('showCables') !== 'false'); /** The flowSockets palette, by PORT kind, so a wire means the same thing in the 3D * world and in the node editor: audio = orange (an effect), cv = number blue, midi = @@ -550,7 +551,7 @@ export function startCables() { }); showCables.subscribe((value) => { try { - localStorage.setItem('showCables', String(value)); + safeStorage.setItem('showCables', String(value)); } catch {} }); } diff --git a/src/lib/autosave.js b/src/lib/autosave.js index 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); + }); +});