diff --git a/src/main/index.ts b/src/main/index.ts index 729cfbbf..58517287 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -101,6 +101,10 @@ import { isAbortedNavigationError, shouldLoadHarnessUrl } from './window-navigation' +import { + DesktopStorageManager, + type DesktopStorageAction +} from './state/desktop-storage' import { raiseWindowWithoutStealingFocus, type WindowFocusIntent @@ -156,6 +160,7 @@ let windowsMenuDark = false let mobileWindow: BrowserWindow | undefined let tray: Tray | undefined let runtime: HarnessRuntime +let desktopStorageManager: DesktopStorageManager | undefined let mobileBridge: LanMobileBridge let launchDirectory: string let quitting = false @@ -919,6 +924,7 @@ function createWindow(): BrowserWindow { window.setMenuBarVisibility(false) } window.on('close', (event) => { + desktopStorageManager?.flushSync() if (!shouldKeepRunningInBackground(process.platform, quitting)) return event.preventDefault() window.hide() @@ -1204,6 +1210,7 @@ function launchHarness(): Promise { maintenanceAllowedRestoreId = undefined await refreshMigrationRecoveryLock(dshHome) await auditInstalledLaunchAgents(dshHome) + desktopStorageManager?.switchProfile(join(dshHome, 'profiles', 'web')) await runtime.start(launchDirectory) // A failed launch must not rewrite the user's enabled plugin set. Recovery @@ -1246,6 +1253,7 @@ function launchSafeHarness(): Promise { await runtime.stop() await ensureSafeModeProfile(dshHome) runtime.note('[desktop] safe mode: third-party web profile bundles are blocked') + desktopStorageManager?.switchProfile(join(dshHome, 'profiles', SAFE_MODE_PROFILE)) await runtime.start(launchDirectory, SAFE_MODE_PROFILE) if (runtime.snapshot().phase === 'ready') { void mobileBridge.start().catch(showUnexpectedError) @@ -1288,6 +1296,18 @@ async function uninstallMarketAndRestart(): Promise<{ ok: boolean }> { } function registerHarnessHandlers(): void { + ipcMain.removeAllListeners('dsh:storage-load-sync') + ipcMain.on('dsh:storage-load-sync', (event) => { + event.returnValue = desktopStorageManager?.getAll() ?? {} + }) + + ipcMain.removeAllListeners('dsh:storage-sync') + ipcMain.on('dsh:storage-sync', (_event, action) => { + if (action && typeof action === 'object') { + desktopStorageManager?.applyAction(action as DesktopStorageAction) + } + }) + ipcMain.removeHandler('harness:restart') ipcMain.handle('harness:restart', async (event) => { if (!mainWindow || mainWindow.isDestroyed() || event.sender !== mainWindow.webContents) { @@ -2410,6 +2430,12 @@ async function bootstrap(): Promise { registerUpdateHandlers() nativeTheme.themeSource = harnessThemePreference() ensureTray() + const dshHome = join(app.getPath('userData'), 'harness') + desktopStorageManager = new DesktopStorageManager(join(dshHome, 'profiles', 'web'), { + onError: (error, context) => { + console.warn(`[desktop-storage] error during ${context}:`, error) + } + }) createWindow() runtime = new HarnessRuntime({ dshEntryPath: dshEntryPath(), @@ -2692,6 +2718,7 @@ if (isDaemonLaunch(process.env, process.platform)) { if (quitting || !runtime) return event.preventDefault() quitting = true + desktopStorageManager?.flushSync() stopUpdateManager() // Windows leaves the tray icon behind as a ghost until the user hovers // over it unless it is destroyed explicitly before the process exits. diff --git a/src/main/state/desktop-storage.ts b/src/main/state/desktop-storage.ts new file mode 100644 index 00000000..721d1a94 --- /dev/null +++ b/src/main/state/desktop-storage.ts @@ -0,0 +1,187 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' + +export const STORAGE_FILENAME = 'desktop-storage.json' + +export type DesktopStorageAction = + | { type: 'set'; key: string; val: string } + | { type: 'remove'; key: string } + | { type: 'clear' } + +export interface DesktopStorageOptions { + debounceMs?: number + onError?: (error: Error, context: string) => void +} + +export class DesktopStorageManager { + private memoryStore: Map = new Map() + private filePath: string + private debounceMs: number + private flushTimer?: NodeJS.Timeout + private isDirty = false + private onError?: (error: Error, context: string) => void + + constructor(profileDirectory: string, options: DesktopStorageOptions = {}) { + this.filePath = join(profileDirectory, STORAGE_FILENAME) + this.debounceMs = options.debounceMs ?? 200 + this.onError = options.onError + this.loadFromDiskSync() + } + + getStorageFilePath(): string { + return this.filePath + } + + /** + * Returns a snapshot of all stored keys and values. + */ + getAll(): Record { + const result: Record = {} + for (const [key, value] of this.memoryStore.entries()) { + result[key] = value + } + return result + } + + getItem(key: string): string | null { + return this.memoryStore.get(key) ?? null + } + + setItem(key: string, value: string): void { + const stringKey = String(key) + const stringVal = String(value) + if (this.memoryStore.get(stringKey) === stringVal) return + this.memoryStore.set(stringKey, stringVal) + this.markDirty() + } + + removeItem(key: string): void { + const stringKey = String(key) + if (!this.memoryStore.has(stringKey)) return + this.memoryStore.delete(stringKey) + this.markDirty() + } + + clear(): void { + if (this.memoryStore.size === 0) return + this.memoryStore.clear() + this.markDirty() + } + + applyAction(action: DesktopStorageAction): void { + switch (action.type) { + case 'set': + this.setItem(action.key, action.val) + break + case 'remove': + this.removeItem(action.key) + break + case 'clear': + this.clear() + break + } + } + + /** + * Switch the storage manager to a new profile directory. + * Flushes any pending changes for the previous profile first. + */ + switchProfile(profileDirectory: string): void { + this.flushSync() + this.filePath = join(profileDirectory, STORAGE_FILENAME) + this.memoryStore.clear() + this.loadFromDiskSync() + } + + /** + * Flushes any dirty state asynchronously. + */ + async flush(): Promise { + if (this.flushTimer !== undefined) { + clearTimeout(this.flushTimer) + this.flushTimer = undefined + } + if (!this.isDirty) return + + this.isDirty = false + const serialized = JSON.stringify(this.getAll(), null, 2) + const tmpPath = `${this.filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2, 8)}` + + try { + await mkdir(dirname(this.filePath), { recursive: true }) + await writeFile(tmpPath, serialized, 'utf8') + await rename(tmpPath, this.filePath) + } catch (error) { + this.isDirty = true + this.handleError(error, 'async-flush') + } + } + + /** + * Flushes any dirty state synchronously (e.g., during app before-quit or window close). + */ + flushSync(): void { + if (this.flushTimer !== undefined) { + clearTimeout(this.flushTimer) + this.flushTimer = undefined + } + if (!this.isDirty) return + + this.isDirty = false + const serialized = JSON.stringify(this.getAll(), null, 2) + const tmpPath = `${this.filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2, 8)}` + + try { + mkdirSync(dirname(this.filePath), { recursive: true }) + writeFileSync(tmpPath, serialized, 'utf8') + renameSync(tmpPath, this.filePath) + } catch (error) { + this.isDirty = true + try { + if (existsSync(tmpPath)) unlinkSync(tmpPath) + } catch {} + this.handleError(error, 'sync-flush') + } + } + + private markDirty(): void { + this.isDirty = true + if (this.flushTimer !== undefined) return + this.flushTimer = setTimeout(() => { + this.flushTimer = undefined + void this.flush() + }, this.debounceMs) + } + + private loadFromDiskSync(): void { + this.isDirty = false + if (!existsSync(this.filePath)) { + return + } + + try { + const raw = readFileSync(this.filePath, 'utf8').trim() + if (!raw) return + const parsed = JSON.parse(raw) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + for (const [k, v] of Object.entries(parsed)) { + if (typeof v === 'string') { + this.memoryStore.set(k, v) + } else { + this.memoryStore.set(k, String(v)) + } + } + } + } catch (error) { + this.handleError(error, 'load-from-disk') + } + } + + private handleError(error: unknown, context: string): void { + const err = error instanceof Error ? error : new Error(String(error)) + if (this.onError) { + this.onError(err, context) + } + } +} diff --git a/src/preload/desktop-storage.ts b/src/preload/desktop-storage.ts new file mode 100644 index 00000000..16f88e7a --- /dev/null +++ b/src/preload/desktop-storage.ts @@ -0,0 +1,159 @@ +import { ipcRenderer } from 'electron' + +export function setupDesktopStoragePersistence(): void { + let initialData: Record = {} + try { + initialData = ipcRenderer.sendSync('dsh:storage-load-sync') ?? {} + } catch (error) { + console.warn('[desktop-storage] Failed to load initial storage sync:', error) + } + + const serializedInitial = JSON.stringify(initialData) + + const injectionScript = ` +(function() { + try { + const initial = ${serializedInitial}; + const memoryStore = new Map(Object.entries(initial)); + + const originalGetItem = Storage.prototype.getItem; + const originalSetItem = Storage.prototype.setItem; + const originalRemoveItem = Storage.prototype.removeItem; + const originalClear = Storage.prototype.clear; + const originalKey = Storage.prototype.key; + + Object.defineProperty(Storage.prototype, 'length', { + get: function() { + if (this === window.localStorage) return memoryStore.size; + return 0; + }, + configurable: true + }); + + Storage.prototype.getItem = function(key) { + if (this === window.localStorage) { + const k = String(key); + return memoryStore.has(k) ? memoryStore.get(k) : null; + } + return originalGetItem.apply(this, arguments); + }; + + Storage.prototype.setItem = function(key, val) { + if (this === window.localStorage) { + const k = String(key); + const v = String(val); + memoryStore.set(k, v); + window.dispatchEvent(new CustomEvent('__dsh_storage_sync__', { + detail: { type: 'set', key: k, val: v } + })); + return; + } + return originalSetItem.apply(this, arguments); + }; + + Storage.prototype.removeItem = function(key) { + if (this === window.localStorage) { + const k = String(key); + if (!memoryStore.has(k)) return; + memoryStore.delete(k); + window.dispatchEvent(new CustomEvent('__dsh_storage_sync__', { + detail: { type: 'remove', key: k } + })); + return; + } + return originalRemoveItem.apply(this, arguments); + }; + + Storage.prototype.clear = function() { + if (this === window.localStorage) { + if (memoryStore.size === 0) return; + memoryStore.clear(); + window.dispatchEvent(new CustomEvent('__dsh_storage_sync__', { + detail: { type: 'clear' } + })); + return; + } + return originalClear.apply(this, arguments); + }; + + Storage.prototype.key = function(index) { + if (this === window.localStorage) { + const keys = Array.from(memoryStore.keys()); + return keys[index] ?? null; + } + return originalKey.apply(this, arguments); + }; + + try { + const storageProxy = new Proxy(window.localStorage, { + get(target, prop, receiver) { + if ( + prop in target || + typeof prop === 'symbol' || + prop === 'getItem' || + prop === 'setItem' || + prop === 'removeItem' || + prop === 'clear' || + prop === 'key' || + prop === 'length' + ) { + return Reflect.get(target, prop, receiver); + } + return target.getItem(String(prop)); + }, + set(target, prop, value, receiver) { + if (prop in target) { + return Reflect.set(target, prop, value, receiver); + } + target.setItem(String(prop), String(value)); + return true; + }, + deleteProperty(target, prop) { + target.removeItem(String(prop)); + return true; + } + }); + + Object.defineProperty(window, 'localStorage', { + value: storageProxy, + configurable: true, + writable: true + }); + } catch { + // Ignore if Object.defineProperty on window.localStorage is restricted; + // Storage.prototype overrides handle .getItem/.setItem regardless. + } + } catch (err) { + console.error('[desktop-storage] Injected persistence setup failed:', err); + } +})(); +` + + function inject(): boolean { + const container = document.documentElement || document.head + if (container) { + const script = document.createElement('script') + script.textContent = injectionScript + container.appendChild(script) + script.remove() + return true + } + return false + } + + if (!inject()) { + const observer = new MutationObserver(() => { + if (inject()) { + observer.disconnect() + } + }) + observer.observe(document, { childList: true }) + } + + window.addEventListener('__dsh_storage_sync__', (event: Event) => { + const detail = (event as CustomEvent).detail + if (detail && typeof detail === 'object') { + ipcRenderer.send('dsh:storage-sync', detail) + } + }) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 0a08df4f..804b9dc9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,6 @@ import { contextBridge, ipcRenderer } from 'electron' import type { AvailableRelease, UpdateStatus } from '../shared/contracts' +import { setupDesktopStoragePersistence } from './desktop-storage' import { isUpdateDismissed, shouldShowUpdate, @@ -10,6 +11,9 @@ import { isPluginLoadError } from './plugin-error-view' import { findBootFailureText } from './boot-failure' import { mountWindowsTitlebarLayout } from './windows-titlebar' +// Intercept and persist localStorage to disk storage before any page script executes +setupDesktopStoragePersistence() + const ROOT_ID = 'dsh-desktop-update-root' const MOBILE_BUTTON_ID = 'dsh-desktop-mobile-button' const SAFE_MODE_BANNER_ID = 'dsh-desktop-safe-mode-banner' diff --git a/test/desktop-storage.test.ts b/test/desktop-storage.test.ts new file mode 100644 index 00000000..c0e17ce5 --- /dev/null +++ b/test/desktop-storage.test.ts @@ -0,0 +1,149 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { DesktopStorageManager, STORAGE_FILENAME } from '../src/main/state/desktop-storage' + +describe('DesktopStorageManager', () => { + it('starts with empty store when storage file does not exist', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'dsh-storage-test-')) + try { + const manager = new DesktopStorageManager(tempDir) + expect(manager.getAll()).toEqual({}) + expect(manager.getItem('foo')).toBeNull() + } finally { + await rm(tempDir, { recursive: true, force: true }) + } + }) + + it('sets, gets, removes and clears in-memory storage', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'dsh-storage-test-')) + try { + const manager = new DesktopStorageManager(tempDir) + + manager.setItem('key1', 'value1') + manager.setItem('key2', '123') + expect(manager.getItem('key1')).toBe('value1') + expect(manager.getItem('key2')).toBe('123') + expect(manager.getAll()).toEqual({ key1: 'value1', key2: '123' }) + + manager.removeItem('key1') + expect(manager.getItem('key1')).toBeNull() + expect(manager.getAll()).toEqual({ key2: '123' }) + + manager.clear() + expect(manager.getAll()).toEqual({}) + } finally { + await rm(tempDir, { recursive: true, force: true }) + } + }) + + it('flushes synchronously and restores data on reload', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'dsh-storage-test-')) + try { + const manager1 = new DesktopStorageManager(tempDir) + manager1.setItem('plugin-theme', 'dark') + manager1.setItem('plugin-token', 'abc-xyz-999') + manager1.flushSync() + + const fileContent = await readFile(join(tempDir, STORAGE_FILENAME), 'utf8') + const parsed = JSON.parse(fileContent) + expect(parsed).toEqual({ + 'plugin-theme': 'dark', + 'plugin-token': 'abc-xyz-999' + }) + + // Simulate a new app launch reading the same profile directory + const manager2 = new DesktopStorageManager(tempDir) + expect(manager2.getItem('plugin-theme')).toBe('dark') + expect(manager2.getItem('plugin-token')).toBe('abc-xyz-999') + } finally { + await rm(tempDir, { recursive: true, force: true }) + } + }) + + it('debounces async flushes', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'dsh-storage-test-')) + try { + const manager = new DesktopStorageManager(tempDir, { debounceMs: 50 }) + manager.setItem('async-key', 'first') + manager.setItem('async-key', 'second') + + // Wait for debounce timeout + await new Promise((resolve) => setTimeout(resolve, 100)) + + const fileContent = await readFile(join(tempDir, STORAGE_FILENAME), 'utf8') + expect(JSON.parse(fileContent)).toEqual({ 'async-key': 'second' }) + } finally { + await rm(tempDir, { recursive: true, force: true }) + } + }) + + it('handles corrupted JSON files gracefully without throwing', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'dsh-storage-test-')) + try { + await writeFile(join(tempDir, STORAGE_FILENAME), 'INVALID_NOT_A_JSON{', 'utf8') + + let reportedError = false + const manager = new DesktopStorageManager(tempDir, { + onError: (_err, context) => { + if (context === 'load-from-disk') reportedError = true + } + }) + + expect(reportedError).toBe(true) + expect(manager.getAll()).toEqual({}) + + // Still allows writing and overwrites corrupted file cleanly + manager.setItem('recovered', 'true') + manager.flushSync() + + const restored = new DesktopStorageManager(tempDir) + expect(restored.getItem('recovered')).toBe('true') + } finally { + await rm(tempDir, { recursive: true, force: true }) + } + }) + + it('applies batch actions correctly', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'dsh-storage-test-')) + try { + const manager = new DesktopStorageManager(tempDir) + manager.applyAction({ type: 'set', key: 'a', val: '1' }) + manager.applyAction({ type: 'set', key: 'b', val: '2' }) + expect(manager.getAll()).toEqual({ a: '1', b: '2' }) + + manager.applyAction({ type: 'remove', key: 'a' }) + expect(manager.getAll()).toEqual({ b: '2' }) + + manager.applyAction({ type: 'clear' }) + expect(manager.getAll()).toEqual({}) + } finally { + await rm(tempDir, { recursive: true, force: true }) + } + }) + + it('supports switching profiles', async () => { + const dir1 = await mkdtemp(join(tmpdir(), 'dsh-profile1-')) + const dir2 = await mkdtemp(join(tmpdir(), 'dsh-profile2-')) + try { + const manager = new DesktopStorageManager(dir1) + manager.setItem('p1', 'val1') + manager.flushSync() + + manager.switchProfile(dir2) + expect(manager.getAll()).toEqual({}) + + manager.setItem('p2', 'val2') + manager.flushSync() + + const restored1 = new DesktopStorageManager(dir1) + const restored2 = new DesktopStorageManager(dir2) + expect(restored1.getItem('p1')).toBe('val1') + expect(restored2.getItem('p2')).toBe('val2') + } finally { + await rm(dir1, { recursive: true, force: true }) + await rm(dir2, { recursive: true, force: true }) + } + }) +})