From 70c8ea6584f3471b56f6aeaa80669fb231b49b9f Mon Sep 17 00:00:00 2001 From: timmy Date: Mon, 17 Aug 2026 03:37:21 +0000 Subject: [PATCH] feat: manage private device storage (Closes #998) --- frontend/dashboard.css | 4 + frontend/dashboard.js | 1 + frontend/device-storage.js | 96 ++++++++++++++++ frontend/index.html | 13 +++ frontend/private-device-data.js | 2 +- frontend/service-worker.js | 2 + src/frontend_bundle.py | 5 +- .../e2e/test_mobile_home_bootstrap_release.py | 12 ++ tests/test_device_storage.py | 108 ++++++++++++++++++ tests/test_mobile_device_setup.py | 9 ++ tests/test_private_device_data.py | 7 +- tests/test_service_worker.py | 2 + 12 files changed, 256 insertions(+), 5 deletions(-) create mode 100644 frontend/device-storage.js create mode 100644 tests/test_device_storage.py diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 4951270..e5ff135 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -60,6 +60,10 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid # .device-setup-deadline-controls label { font-size:12px; color:#bfdbfe; } .device-setup-deadline-controls select, .device-setup-deadline-controls button, #push-deadline-hour, #push-deadline-days { min-height:44px; } .device-setup-ready { margin:0; padding:12px; border-radius:10px; background:#0f2237; color:#bfdbfe; font-weight:700; } +.device-storage { display:grid; gap:12px; margin:14px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#0d1d31; } +.device-storage h3, .device-storage p { margin:0 0 5px; } +.device-storage-actions { display:flex; gap:8px; flex-wrap:wrap; } +.device-storage-actions button { min-height:44px; flex:1 1 180px; } .device-readiness-card { display:none; min-width:0; margin:10px 0; padding:12px; border:1px solid #3b82b8; border-radius:12px; background:#102b46; overflow-x:hidden; } .device-readiness-card p { margin:5px 0 0; overflow-wrap:anywhere; } .device-readiness-summary { color:#bfdbfe; font-weight:700; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 9a13d51..73707bf 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -7744,6 +7744,7 @@ await controller.enableDeadline(); }, }); + await createDeviceStorage.mount(document).start(); await deviceSetup.start(); return deviceSetup; } diff --git a/frontend/device-storage.js b/frontend/device-storage.js new file mode 100644 index 0000000..afd938e --- /dev/null +++ b/frontend/device-storage.js @@ -0,0 +1,96 @@ +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory; + else { + root.createDeviceStorage = factory; + root.createDeviceStorage.mount = document => factory({ + summary:document.querySelector('#device-storage-summary'), + detail:document.querySelector('#device-storage-detail'), + clearCachesButton:document.querySelector('#clear-device-caches'), + clearAllButton:document.querySelector('#clear-private-device-data'), + localStorage:root.localStorage, + sessionStorage:root.sessionStorage, + caches:root.caches, + storageManager:root.navigator?.storage, + privateDatabases:root.stackchainPrivateDatabases, + clearPrivateDeviceData:root.stackchainPrivateDeviceData, + }); + } +})(typeof self !== 'undefined' ? self : this, function createDeviceStorage(options) { + let privateItemCount = 0; + let fullClearArmed = false; + const ownedCaches = async () => (await options.caches?.keys?.() || []) + .filter(name => name.startsWith('stackchain-dashboard-')); + + function ownedStorageCount(storage) { + if (!storage) return 0; + let count = 0; + for (let index = 0; index < storage.length; index += 1) { + if (storage.key(index)?.startsWith('stackchain.')) count += 1; + } + return count; + } + + function megabytes(value) { + return Math.round((Number(value) || 0) / (1024 * 1024)); + } + + async function refresh() { + let estimate = null; + try { estimate = await options.storageManager?.estimate?.(); } + catch (_error) { /* Storage estimates are optional. */ } + options.summary.textContent = estimate?.quota + ? `${megabytes(estimate.usage)} MB of ${megabytes(estimate.quota)} MB browser storage used.` + : 'Browser storage usage is unavailable.'; + const itemCount = ownedStorageCount(options.localStorage) + + (options.sessionStorage === options.localStorage ? 0 : ownedStorageCount(options.sessionStorage)); + privateItemCount = itemCount; + const cacheCount = (await ownedCaches()).length; + options.detail.textContent = `${itemCount} private browser item${itemCount === 1 ? '' : 's'} · ` + + `${options.privateDatabases.length} private work stores · ` + + `${cacheCount} cached app cop${cacheCount === 1 ? 'y' : 'ies'}`; + return { itemCount, cacheCount }; + } + + async function clearCaches() { + options.clearCachesButton.disabled = true; + try { + await Promise.all((await ownedCaches()).map(name => options.caches.delete(name))); + await refresh(); + options.summary.textContent = 'Cached app copies cleared. Private work was kept.'; + } catch (error) { + options.summary.textContent = `Cached copies could not be cleared: ${error.message}`; + } finally { + options.clearCachesButton.disabled = false; + } + } + + async function clearAll() { + if (privateItemCount > 0 && !fullClearArmed) { + fullClearArmed = true; + options.clearAllButton.textContent = 'Confirm: clear private work'; + options.summary.textContent = 'Private drafts or queued work may not be synced. Press confirm to clear them from this device.'; + return; + } + options.clearAllButton.disabled = true; + try { + await options.clearPrivateDeviceData(); + privateItemCount = 0; + fullClearArmed = false; + options.clearAllButton.textContent = 'Clear all private data'; + options.detail.textContent = '0 private browser items · 0 private work stores · 0 cached app copies'; + options.summary.textContent = 'All Stackchain private data was cleared from this device.'; + } catch (error) { + options.summary.textContent = `Private data was not fully cleared: ${error.message}`; + } finally { + options.clearAllButton.disabled = false; + } + } + + async function start() { + options.clearCachesButton.addEventListener('click', clearCaches); + options.clearAllButton.addEventListener('click', clearAll); + return refresh(); + } + + return { refresh, start }; +}); diff --git a/frontend/index.html b/frontend/index.html index 64d77c1..c74b08a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -108,6 +108,17 @@ +
+
+

Private device storage

+

Checking private storage…

+

+
+
+ + +
+

@@ -1638,6 +1649,8 @@ + + diff --git a/frontend/private-device-data.js b/frontend/private-device-data.js index 8fe4b94..580f329 100644 --- a/frontend/private-device-data.js +++ b/frontend/private-device-data.js @@ -55,9 +55,9 @@ } return async function clearPrivateDeviceData() { + await stopWorkerOutbox(); removeOwnedStorage(localStorage); if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage); - await stopWorkerOutbox(); for (const name of privateDatabases) await deletePrivateDatabase(name); const keys = await caches?.keys?.() || []; await Promise.all( diff --git a/frontend/service-worker.js b/frontend/service-worker.js index bbdeb68..342e8f4 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -118,6 +118,8 @@ const SHELL = [ BASE + 'static/mobile-insights.js', BASE + 'static/mobile-app-shortcuts.js', BASE + 'static/install-app.js', + BASE + 'static/private-device-data.js', + BASE + 'static/device-storage.js', BASE + 'static/mobile-device-setup.js', BASE + 'static/mobile-search-viewport.js', BASE + 'static/mobile-composer-viewport.js', diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index d2c8ede..4fd51c6 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -27,7 +27,10 @@ FEATURE_SOURCES = { ), "pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"), "push-notifications": ("static/push-notifications.js",), - "device-setup": ("static/install-app.js", "static/mobile-device-setup.js"), + "device-setup": ( + "static/install-app.js", "static/private-device-data.js", + "static/device-storage.js", "static/mobile-device-setup.js", + ), "security-center": ("static/security-center.js",), "today-timer": ( "static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", diff --git a/tests/e2e/test_mobile_home_bootstrap_release.py b/tests/e2e/test_mobile_home_bootstrap_release.py index 413e2b0..e7f1aed 100644 --- a/tests/e2e/test_mobile_home_bootstrap_release.py +++ b/tests/e2e/test_mobile_home_bootstrap_release.py @@ -80,6 +80,18 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights( expect(page.locator("#my-work")).to_be_visible() expect(dock).to_be_visible() + page.locator("#app-menu-toggle").click() + page.locator("#open-device-setup").click() + expect(page.locator("#device-setup-sheet")).to_be_visible() + expect(page.locator("#device-storage-heading")).to_have_text("Private device storage") + expect(page.locator("#device-storage-detail")).to_contain_text("private work stores") + for selector in ("#clear-device-caches", "#clear-private-device-data"): + bounds = page.locator(selector).bounding_box() + assert bounds and bounds["height"] >= 44 + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") + page.locator("#close-device-setup").click() + expect(page.locator("#device-setup-sheet")).to_be_hidden() + assert len(workspace_requests) == 1, workspace_requests assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") assert browser_errors == [] diff --git a/tests/test_device_storage.py b/tests/test_device_storage.py new file mode 100644 index 0000000..c27f73f --- /dev/null +++ b/tests/test_device_storage.py @@ -0,0 +1,108 @@ +import json +import subprocess +from pathlib import Path + + +MODULE = Path(__file__).parents[1] / "frontend" / "device-storage.js" + + +def run_scenario(script: str) -> dict: + harness = r""" +const createDeviceStorage = require(__MODULE__); +class Target { + constructor() { this.listeners={}; this.textContent=''; this.hidden=false; this.disabled=false; this.dataset={}; } + addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); } + async dispatch(name) { for (const callback of this.listeners[name] || []) await callback(); } +} +const summary=new Target(), detail=new Target(), clearCachesButton=new Target(), clearAllButton=new Target(); +let purgeCalls=0, purgeError=''; +const localStorage={ + values:new Map([['stackchain.draft.1','private'],['other.preference','keep']]), + get length(){return this.values.size;}, + key(index){return Array.from(this.values.keys())[index] || null;}, +}; +const caches={ + values:new Set(['stackchain-dashboard-shell-a1','other-app']), + async keys(){return Array.from(this.values);}, + async delete(key){return this.values.delete(key);}, +}; +const controller=createDeviceStorage({ + summary, detail, clearCachesButton, clearAllButton, localStorage, caches, + privateDatabases:['stackchain-background-outbox-v1','stackchain-offline-work-v2'], + storageManager:{estimate:async()=>({usage:2 * 1024 * 1024, quota:10 * 1024 * 1024})}, + clearPrivateDeviceData:async()=>{purgeCalls++;if(purgeError)throw new Error(purgeError);}, +}); +(async()=>{ __SCENARIO__ })().catch(error=>{console.error(error);process.exit(1);}); +""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script) + completed = subprocess.run(["node", "-e", harness], capture_output=True, text=True) + assert completed.returncode == 0, completed.stderr + return json.loads(completed.stdout) + + +def test_inventory_reports_private_categories_and_quota_without_content(): + result = run_scenario(""" +await controller.refresh(); +process.stdout.write(JSON.stringify({summary:summary.textContent,detail:detail.textContent})); +""") + + assert result == { + "summary": "2 MB of 10 MB browser storage used.", + "detail": "1 private browser item · 2 private work stores · 1 cached app copy", + } + + +def test_cache_only_cleanup_preserves_private_work_and_other_apps(): + result = run_scenario(""" +await controller.start(); +await clearCachesButton.dispatch('click'); +process.stdout.write(JSON.stringify({ + caches:Array.from(caches.values), + storage:Array.from(localStorage.values.keys()), + status:summary.textContent, + detail:detail.textContent, +})); +""") + + assert result == { + "caches": ["other-app"], + "storage": ["stackchain.draft.1", "other.preference"], + "status": "Cached app copies cleared. Private work was kept.", + "detail": "1 private browser item · 2 private work stores · 0 cached app copies", + } + + +def test_full_cleanup_requires_explicit_second_press_when_private_work_exists(): + result = run_scenario(""" +await controller.start(); +await clearAllButton.dispatch('click'); +const warning={calls:purgeCalls,label:clearAllButton.textContent,status:summary.textContent}; +await clearAllButton.dispatch('click'); +process.stdout.write(JSON.stringify({warning,calls:purgeCalls,status:summary.textContent})); +""") + + assert result == { + "warning": { + "calls": 0, + "label": "Confirm: clear private work", + "status": "Private drafts or queued work may not be synced. Press confirm to clear them from this device.", + }, + "calls": 1, + "status": "All Stackchain private data was cleared from this device.", + } + + +def test_full_cleanup_reports_blocked_deletion_without_claiming_success(): + result = run_scenario(""" +purgeError='IndexedDB deletion was blocked.'; +await controller.start(); +await clearAllButton.dispatch('click'); +let escaped=''; +try { await clearAllButton.dispatch('click'); } catch (error) { escaped=error.message; } +process.stdout.write(JSON.stringify({escaped,status:summary.textContent,disabled:clearAllButton.disabled})); +""") + + assert result == { + "escaped": "", + "status": "Private data was not fully cleared: IndexedDB deletion was blocked.", + "disabled": False, + } diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py index 950cfd2..174b1d0 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -197,6 +197,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): root = MODULE.parents[1] html = (root / "frontend" / "index.html").read_text() dashboard = (root / "frontend" / "dashboard.js").read_text() + storage_module = (root / "frontend" / "device-storage.js").read_text() worker = (root / "frontend" / "service-worker.js").read_text() css = (root / "frontend" / "dashboard.css").read_text() @@ -208,9 +209,17 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert 'id="device-setup-sheet"' in html assert 'aria-labelledby="device-setup-heading"' in html assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "push", "deadline")) + assert 'id="device-storage-summary"' in html + assert 'id="device-storage-detail"' in html + assert 'id="clear-device-caches"' in html + assert 'id="clear-private-device-data"' in html + assert '' in html + assert '' in html assert 'id="device-setup-deadline-hour"' in html assert '' in html assert "createMobileDeviceSetup.mount({" in dashboard + assert "createDeviceStorage.mount(document)" in dashboard + assert "clearPrivateDeviceData:root.stackchainPrivateDeviceData" in storage_module assert "promptStorage:localStorage" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "BASE + 'static/mobile-device-setup.js'" in worker diff --git a/tests/test_private_device_data.py b/tests/test_private_device_data.py index cef89cf..33fe93e 100644 --- a/tests/test_private_device_data.py +++ b/tests/test_private_device_data.py @@ -9,12 +9,12 @@ PRIVATE_DATA_JS = Path(__file__).resolve().parents[1] / "frontend" / "private-de def test_private_device_data_purger_waits_for_owned_outbox_and_cache_deletion(): harness = f""" const createPrivateDeviceDataPurger = require({json.dumps(str(PRIVATE_DATA_JS))}); -const state = {{removed:[], databases:[], caches:[], workerMessages:[], complete:false}}; +const state = {{removed:[], databases:[], caches:[], workerMessages:[], order:[], complete:false}}; const storage = {{ values:new Map([['stackchain.draft','private'],['other.preference','keep']]), get length(){{return this.values.size;}}, key(index){{return Array.from(this.values.keys())[index] || null;}}, - removeItem(key){{state.removed.push(key);this.values.delete(key);}}, + removeItem(key){{state.order.push('storage');state.removed.push(key);this.values.delete(key);}}, }}; const clear = createPrivateDeviceDataPurger({{ localStorage:storage, sessionStorage:storage, @@ -25,7 +25,7 @@ const clear = createPrivateDeviceDataPurger({{ return request; }}}}, caches:{{keys:async()=>['stackchain-dashboard-shell-v37','other-app'],delete:async key=>state.caches.push(key)}}, - serviceWorker:{{ready:Promise.resolve({{active:{{postMessage:(message,ports)=>{{state.workerMessages.push(message);ports[0].postMessage({{ok:true}});}}}}}})}}, + serviceWorker:{{ready:Promise.resolve({{active:{{postMessage:(message,ports)=>{{state.order.push('worker');state.workerMessages.push(message);ports[0].postMessage({{ok:true}});}}}}}})}}, MessageChannel:class{{constructor(){{ const first={{onmessage:null,postMessage:data=>queueMicrotask(()=>second.onmessage?.({{data}}))}}; const second={{onmessage:null,postMessage:data=>queueMicrotask(()=>first.onmessage?.({{data}}))}}; @@ -53,4 +53,5 @@ const clear = createPrivateDeviceDataPurger({{ ] assert state["caches"] == ["stackchain-dashboard-shell-v37"] assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}] + assert state["order"][0] == "worker" assert state["complete"] is True diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 0d22430..ff94d0d 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -1055,6 +1055,8 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/mobile-insights.js", "/dashboard/static/mobile-app-shortcuts.js", "/dashboard/static/install-app.js", + "/dashboard/static/private-device-data.js", + "/dashboard/static/device-storage.js", "/dashboard/static/mobile-device-setup.js", "/dashboard/static/mobile-search-viewport.js", "/dashboard/static/mobile-composer-viewport.js", -- 2.43.0