// src/mobile/chrome-shim.ts — Ring 1 (god document §4). // // Installs `globalThis.chrome` before any other module in this bundle // evaluates (see src/mobile/entry.ts, where this is the first import). Every // one of the codebase's existing `chrome.*` call sites — storage, runtime // messaging, alarms, idle, tabs — keeps working unmodified; this file is the // only thing that knows the host is a native shell, not a browser extension // runtime (§4.1). // // Grounded against the real call sites in this repo, not invented (§14): // - src/background/messages.ts's setupMessageHandler() already contains a // mobile hook (`globalThis.__peachRegisterMessageHandler`) with a comment // confirming this exact wiring was anticipated. We deliberately do NOT // use it for dispatch (see the long comment below `sendMessage` for why — // using it would invoke `handleMessage` twice per request). It's wired // as a harmless no-op so that code path doesn't need touching. // - src/popup/App.tsx registers `chrome.runtime.onMessage.addListener` and // `chrome.storage.onChanged.addListener` unconditionally on mount — both // must be real, not undefined, or the popup throws at mount time. // - src/background/commands.ts's setupCommands() and // src/background/clipboard.ts's setupClipboardClearAlarm() call // `chrome.commands.onCommand.addListener` / `chrome.alarms.onAlarm.addListener` // with no optional chaining — both must exist as real objects or // `background/entry.ts`'s start() throws synchronously at boot. // - src/background/vault.ts's storage calls (`chrome.storage.local.set({ key: object })`) // pass plain JS objects, not pre-serialized strings — this shim // JSON-encodes at the native KV boundary (§5's kvGet/kvSet are string-only). import { nativePlugin } from "../shared/native"; declare const __VERSION__: string; type MessageListener = ( message: unknown, sender: unknown, sendResponse: (response: unknown) => void ) => boolean | void; type NativeKV = { kvGet(key: string): Promise; kvSet(key: string, value: string): Promise; kvRemove(key: string): Promise; kvClear(): Promise; kvKeys(): Promise; }; function nativeKV(): NativeKV | null { return nativePlugin("PeachNative"); } const SYNTHETIC_SENDER = Object.freeze({ id: "peach-shell", origin: "peach-shell" }); // ----------------------------------------------------------------------- // runtime.sendMessage / onMessage — in-process dispatch (§2.3, §2.4). // // This is real onMessage.addListener/broadcast semantics, matching actual // Chrome: every registered listener is invoked for every sendMessage call; // whichever listener calls `sendResponse` first supplies the resolved value; // if no listener returns `true` (none intends to answer asynchronously), the // message resolves as `undefined` immediately, matching Chrome's "no // listener responded" case (src/shared/bridge.ts already treats that as // SERVICE_WORKER_UNAVAILABLE). // // messages.ts's setupMessageHandler() registers exactly one real listener // this way (wrapping handleMessage, always returning `true`) — since nothing // else in the mobile bundle calls sendResponse, that listener always wins, // and handleMessage runs exactly once per message. Popup-side broadcast // listeners (App.tsx's VAULT_LOCKED/PRO_GATE_TRIGGERED handler) sit in the // same array, see every message, and safely ignore the ones they don't // recognize — they never call sendResponse, so they never race the real // answer. // ----------------------------------------------------------------------- const messageListeners: MessageListener[] = []; const pendingSends: Array<{ message: unknown; resolve: (v: unknown) => void }> = []; function deliver(message: unknown): Promise { if (messageListeners.length === 0) { return new Promise((resolve) => pendingSends.push({ message, resolve })); } return new Promise((resolve) => { let responded = false; let anyAsync = false; const respond = (response: unknown) => { if (responded) return; responded = true; resolve(response); }; for (const listener of messageListeners.slice()) { try { const keepChannelOpen = listener(message, SYNTHETIC_SENDER, respond); if (keepChannelOpen === true) anyAsync = true; } catch (err) { console.error("[peach-shell] chrome.runtime.onMessage listener threw:", err); } } if (!responded && !anyAsync) respond(undefined); }); } function flushPendingSends() { if (messageListeners.length === 0) return; while (pendingSends.length) { const { message, resolve } = pendingSends.shift()!; void deliver(message).then(resolve); } } function sendMessage(message: unknown, callback?: (response: unknown) => void): Promise { const promise = deliver(message); if (callback) void promise.then(callback); return promise; } // Deliberately a no-op: see the file header. messages.ts calls this in // addition to the real onMessage.addListener registration above; wiring it // to actually dispatch would run handleMessage a second time per message. (globalThis as unknown as Record).__peachRegisterMessageHandler = () => {}; // ----------------------------------------------------------------------- // storage — three areas per §4.2: session is in-process (cleared on process // death, matching "locked when the app is killed"); local/sync route to the // native KV (§5), falling back to an in-memory store with a one-time warning // if native kv* isn't available yet (expected until peach-android Stage 1). // ----------------------------------------------------------------------- type StorageChangeMap = Record; type StorageChangeListener = (changes: StorageChangeMap, areaName: string) => void; const storageChangeListeners: StorageChangeListener[] = []; function notifyStorageChanged(changes: StorageChangeMap, areaName: string) { if (Object.keys(changes).length === 0) return; for (const listener of storageChangeListeners.slice()) { try { listener(changes, areaName); } catch (err) { console.error("[peach-shell] chrome.storage.onChanged listener threw:", err); } } } function keyListFrom( keys: string | string[] | Record | null | undefined, allKeys: () => string[] ): string[] { if (keys == null) return allKeys(); if (typeof keys === "string") return [keys]; if (Array.isArray(keys)) return keys; return Object.keys(keys); } function makeMapArea(store: Map, areaName: string) { return { async get(keys?: string | string[] | Record | null) { const keyList = keyListFrom(keys, () => Array.from(store.keys())); const out: Record = {}; for (const k of keyList) if (store.has(k)) out[k] = store.get(k); return out; }, async set(items: Record) { const changes: StorageChangeMap = {}; for (const [k, v] of Object.entries(items)) { changes[k] = { oldValue: store.get(k), newValue: v }; store.set(k, v); } notifyStorageChanged(changes, areaName); }, async remove(keys: string | string[]) { const changes: StorageChangeMap = {}; for (const k of Array.isArray(keys) ? keys : [keys]) { if (!store.has(k)) continue; changes[k] = { oldValue: store.get(k), newValue: undefined }; store.delete(k); } notifyStorageChanged(changes, areaName); }, async clear() { const changes: StorageChangeMap = {}; for (const [k, v] of store.entries()) changes[k] = { oldValue: v, newValue: undefined }; store.clear(); notifyStorageChanged(changes, areaName); }, }; } const sessionStore = new Map(); const sessionArea = makeMapArea(sessionStore, "session"); // Local storage's in-memory fallback, used only until native kv works or // when a native call fails. Real persistence is peach-android Stage 1's job. const localFallbackStore = new Map(); const localFallbackArea = makeMapArea(localFallbackStore, "local"); let warnedNativeKVUnavailable = false; function warnNativeKVFallback(reason: unknown) { if (warnedNativeKVUnavailable) return; warnedNativeKVUnavailable = true; console.warn( "[peach-shell] PeachNative kv* unavailable or failing — chrome.storage.local is " + "falling back to an in-memory store that will NOT survive an app restart. " + "Expected until peach-android Stage 1 (VaultStore) ships.", reason ); } const localArea = { async get(keys?: string | string[] | Record | null) { const kv = nativeKV(); if (!kv) return localFallbackArea.get(keys); try { const keyList = keyListFrom(keys, () => { throw new Error("native-keys-listing-not-requested"); }); const resolvedKeys = keys == null ? await kv.kvKeys() : keyList; const out: Record = {}; for (const k of resolvedKeys) { const raw = await kv.kvGet(k); if (raw != null) out[k] = JSON.parse(raw); } return out; } catch (err) { warnNativeKVFallback(err); return localFallbackArea.get(keys); } }, async set(items: Record) { const kv = nativeKV(); if (!kv) return localFallbackArea.set(items); try { const changes: StorageChangeMap = {}; for (const [k, v] of Object.entries(items)) { changes[k] = { newValue: v }; await kv.kvSet(k, JSON.stringify(v)); } notifyStorageChanged(changes, "local"); } catch (err) { warnNativeKVFallback(err); return localFallbackArea.set(items); } }, async remove(keys: string | string[]) { const kv = nativeKV(); if (!kv) return localFallbackArea.remove(keys); try { const keyList = Array.isArray(keys) ? keys : [keys]; for (const k of keyList) await kv.kvRemove(k); notifyStorageChanged( Object.fromEntries(keyList.map((k) => [k, { newValue: undefined }])), "local" ); } catch (err) { warnNativeKVFallback(err); return localFallbackArea.remove(keys); } }, async clear() { const kv = nativeKV(); if (!kv) return localFallbackArea.clear(); try { await kv.kvClear(); } catch (err) { warnNativeKVFallback(err); return localFallbackArea.clear(); } }, }; // ----------------------------------------------------------------------- // alarms — in-process setTimeout emulation. This does NOT survive the // WebView being frozen in the background (§4.4 point 2) — that's exactly // the hazard peach-android's native Scheduler (Stage 2) exists to close. // Kept only so background/entry.ts's synchronous, non-optional-chained // `chrome.alarms.onAlarm.addListener` calls (clipboard.ts) don't throw at // boot. App.tsx already carries its own foreground idle-autolock poll // specifically because "chrome.alarms is no-op'd in Capacitor" (its words) — // this shim is a strict improvement on that (it actually fires while // foregrounded) but is not a substitute for Stage 2. // ----------------------------------------------------------------------- type AlarmInfo = { when?: number; delayInMinutes?: number; periodInMinutes?: number }; type Alarm = { name: string; scheduledTime: number }; const alarmTimers = new Map>(); const alarmListeners: Array<(alarm: Alarm) => void> = []; function alarmsCreate(name: string, info: AlarmInfo) { alarmsClearTimer(name); const delayMs = info.when != null ? Math.max(0, info.when - Date.now()) : Math.max(0, (info.delayInMinutes ?? 0) * 60_000); const timer = setTimeout(() => { alarmTimers.delete(name); const alarm: Alarm = { name, scheduledTime: Date.now() }; for (const listener of alarmListeners.slice()) listener(alarm); if (info.periodInMinutes) alarmsCreate(name, { delayInMinutes: info.periodInMinutes, periodInMinutes: info.periodInMinutes }); }, delayMs); alarmTimers.set(name, timer); } function alarmsClearTimer(name: string): boolean { const timer = alarmTimers.get(name); if (!timer) return false; clearTimeout(timer); alarmTimers.delete(name); return true; } const alarmsShim = { create(name: string, info: AlarmInfo) { alarmsCreate(name, info); }, async clear(name: string) { return alarmsClearTimer(name); }, async clearAll() { for (const name of Array.from(alarmTimers.keys())) alarmsClearTimer(name); return true; }, onAlarm: { addListener(fn: (alarm: Alarm) => void) { alarmListeners.push(fn); }, }, }; // ----------------------------------------------------------------------- // idle — derived from the native lifecycle/screen-state push channels // (peach-android's NativeBridge, already wired via BridgeScript). Screen // off maps to "locked" (the Chrome idle API's own term for "screen locked"), // matching alarms.ts's own handling of that state. // ----------------------------------------------------------------------- type IdleState = "active" | "idle" | "locked"; const idleStateListeners: Array<(state: IdleState) => void> = []; const idleShim = { setDetectionInterval(_seconds: number) { // No JS-side idle timer to configure — idle/locked is driven entirely by // the native screen-state signal, not a polling interval. }, onStateChanged: { addListener(fn: (state: IdleState) => void) { idleStateListeners.push(fn); }, }, }; nativePlugin<{ onScreenState(cb: (s: "on" | "off") => void): void }>("PeachNative")?.onScreenState((s) => { const state: IdleState = s === "off" ? "locked" : "active"; for (const listener of idleStateListeners.slice()) listener(state); }); // ----------------------------------------------------------------------- // Everything else in §4.2's inventory that isn't load-bearing for Stage 0/1: // stubs sized to what the real call sites in this repo actually touch. // ----------------------------------------------------------------------- const tabsShim = { async query() { return [] as unknown[]; }, async sendMessage() { throw new Error("chrome.tabs.sendMessage: no browser tabs on the mobile shell"); }, }; const commandsShim = { onCommand: { addListener(_fn: (command: string) => void) {} }, }; const actionShim = { openPopup: undefined as (() => Promise) | undefined, }; const scriptingShim = { async executeScript() { throw new Error("chrome.scripting.executeScript is not available on the mobile shell"); }, }; const offscreenShim = { async createDocument() {}, async closeDocument() {}, async hasDocument() { return false; }, }; const windowsShim = { async create() { return undefined; }, }; const permissionsShim = { async request() { return false; }, async contains() { return false; }, }; // ----------------------------------------------------------------------- // Assemble and install. Cast through `unknown` — @types/chrome's ambient // `chrome` is the full extension surface; this is a deliberately partial // polyfill of the slice this codebase actually calls (§4.2). // ----------------------------------------------------------------------- const chromeShim = { runtime: { sendMessage, onMessage: { addListener(fn: MessageListener) { messageListeners.push(fn); flushPendingSends(); }, removeListener(fn: MessageListener) { const i = messageListeners.indexOf(fn); if (i >= 0) messageListeners.splice(i, 1); }, }, onInstalled: { addListener(_fn: () => void) {} }, onConnect: { addListener(_fn: (port: unknown) => void) {} }, getURL(path: string): string { return `https://appassets.androidplatform.net/assets/web/${path.replace(/^\/+/, "")}`; }, getManifest() { return { version: __VERSION__ }; }, get lastError() { return undefined; }, id: "peach-shell", }, storage: { session: sessionArea, local: localArea, sync: localArea, onChanged: { addListener(fn: StorageChangeListener) { storageChangeListeners.push(fn); }, removeListener(fn: StorageChangeListener) { const i = storageChangeListeners.indexOf(fn); if (i >= 0) storageChangeListeners.splice(i, 1); }, }, }, alarms: alarmsShim, idle: idleShim, tabs: tabsShim, commands: commandsShim, action: actionShim, scripting: scriptingShim, offscreen: offscreenShim, windows: windowsShim, permissions: permissionsShim, }; (globalThis as unknown as { chrome: typeof chrome }).chrome = chromeShim as unknown as typeof chrome;