bdd7540953f8
add the Ring 1 chrome shim, mobile entrypoint, and its own HTML shell (src/mobile/**) — in-process runtime.sendMessage dispatch, storage.session/local/sync, alarms/idle backed by native lifecycle signals
- time
- 2026-07-01 23:44 UTC · 3 months ago
- machine
- koh2
- agent
- claude-sonnet-5
- session
- ring 1 chrome shim
- parent
- 8bd20f421175
↓ peach-browser-bdd75409.tar.gzatlas at this save
files changed5 files
src/mobile/chrome-shim.test.ts+137 −0src/mobile/chrome-shim.ts+463 −0src/mobile/entry.ts+41 −0src/mobile/index.html+44 −0vite.config.mobile.ts+8 −2src/mobile/chrome-shim.test.ts+137 −0
@@ -0,0 +1,137 @@1+import { beforeEach, describe, expect, it, vi } from "vitest";2+3+// Fresh shim install per test — the module installs `globalThis.chrome` as a4+// side effect at import time, and internal listener/queue state lives in5+// module-level closures, so each test needs its own module instance.6+async function freshShim() {7+ vi.resetModules();8+ delete (globalThis as Record<string, unknown>).Capacitor;9+ delete (globalThis as Record<string, unknown>).chrome;10+ await import("./chrome-shim");11+ return (globalThis as unknown as { chrome: typeof chrome }).chrome;12+}13+14+describe("chrome-shim runtime.sendMessage dispatch", () => {15+ beforeEach(() => {16+ vi.resetModules();17+ });18+19+ it("queues sendMessage calls until a listener registers, per messages.ts's own comment", async () => {20+ const chromeShim = await freshShim();21+ const sent = chromeShim.runtime.sendMessage({ type: "GET_VAULT_STATUS" });22+23+ let resolved = false;24+ void sent.then(() => {25+ resolved = true;26+ });27+ await Promise.resolve();28+ expect(resolved).toBe(false);29+30+ chromeShim.runtime.onMessage.addListener((_msg, _sender, sendResponse) => {31+ sendResponse({ ok: true, unlocked: false });32+ return true;33+ });34+35+ const result = await sent;36+ expect(result).toEqual({ ok: true, unlocked: false });37+ });38+39+ it("invokes handleMessage exactly once even though messages.ts registers via both onMessage.addListener and __peachRegisterMessageHandler", async () => {40+ const chromeShim = await freshShim();41+ let handleMessageCalls = 0;42+43+ // Mirrors setupMessageHandler()'s exact shape: register a real listener,44+ // then also call the (now-inert) mobile hook a second time.45+ chromeShim.runtime.onMessage.addListener((msg, _sender, sendResponse) => {46+ handleMessageCalls++;47+ Promise.resolve({ ok: true, echoed: msg }).then(sendResponse);48+ return true;49+ });50+ (globalThis as unknown as { __peachRegisterMessageHandler: (fn: unknown) => void })51+ .__peachRegisterMessageHandler(async (m: unknown) => {52+ handleMessageCalls++;53+ return { ok: true, echoed: m };54+ });55+56+ const result = await chromeShim.runtime.sendMessage({ type: "GET_VAULT_STATUS" });57+ expect(result).toEqual({ ok: true, echoed: { type: "GET_VAULT_STATUS" } });58+ expect(handleMessageCalls).toBe(1);59+ });60+61+ it("delivers every message to broadcast-only listeners (App.tsx's VAULT_LOCKED pattern) without them winning the response", async () => {62+ const chromeShim = await freshShim();63+ const seenByBroadcast: unknown[] = [];64+65+ chromeShim.runtime.onMessage.addListener((msg, _sender, sendResponse) => {66+ Promise.resolve({ ok: true, from: "primary" }).then(sendResponse);67+ return true;68+ });69+ chromeShim.runtime.onMessage.addListener((msg) => {70+ seenByBroadcast.push(msg);71+ // Never calls sendResponse and never returns true — matches App.tsx's72+ // handleRuntimeMessage exactly.73+ });74+75+ const result = await chromeShim.runtime.sendMessage({ type: "VAULT_LOCKED" });76+ expect(result).toEqual({ ok: true, from: "primary" });77+ expect(seenByBroadcast).toEqual([{ type: "VAULT_LOCKED" }]);78+ });79+80+ it("resolves undefined immediately when no listener intends to respond (matches Chrome's own semantics)", async () => {81+ const chromeShim = await freshShim();82+ chromeShim.runtime.onMessage.addListener(() => {83+ // No sendResponse call, no `return true`.84+ });85+ const result = await chromeShim.runtime.sendMessage({ type: "UNKNOWN" });86+ expect(result).toBeUndefined();87+ });88+});89+90+describe("chrome-shim storage", () => {91+ it("storage.session round-trips in-memory and fires onChanged", async () => {92+ const chromeShim = await freshShim();93+ const changes: unknown[] = [];94+ chromeShim.storage.onChanged.addListener((c, area) => changes.push({ c, area }));95+96+ await chromeShim.storage.session.set({ foo: "bar" });97+ const got = await chromeShim.storage.session.get("foo");98+ expect(got).toEqual({ foo: "bar" });99+ expect(changes).toEqual([{ c: { foo: { oldValue: undefined, newValue: "bar" } }, area: "session" }]);100+ });101+102+ it("storage.local falls back to an in-memory store when PeachNative is unavailable", async () => {103+ const chromeShim = await freshShim();104+ await chromeShim.storage.local.set({ peach_settings: { autoLockMinutes: 5 } });105+ const got = await chromeShim.storage.local.get("peach_settings");106+ expect(got).toEqual({ peach_settings: { autoLockMinutes: 5 } });107+ });108+109+ it("storage.local routes through PeachNative.kv* when available, JSON-encoding at the boundary", async () => {110+ const chromeShim = await freshShim();111+ const kv = new Map<string, string>();112+ // freshShim() clears globalThis.Capacitor before importing the shim (so113+ // module import order is deterministic across tests) — set it after114+ // import, which is fine since nativePlugin() is read fresh on every call.115+ (globalThis as unknown as { Capacitor: unknown }).Capacitor = {116+ isNativePlatform: () => true,117+ Plugins: {118+ PeachNative: {119+ async kvGet(key: string) {120+ return kv.has(key) ? kv.get(key)! : null;121+ },122+ async kvSet(key: string, value: string) {123+ kv.set(key, value);124+ },125+ async kvKeys() {126+ return Array.from(kv.keys());127+ },128+ },129+ },130+ };131+ await chromeShim.storage.local.set({ peach_vault_a: { ci: "abc", nonce: "def" } });132+ expect(kv.get("peach_vault_a")).toBe(JSON.stringify({ ci: "abc", nonce: "def" }));133+134+ const got = await chromeShim.storage.local.get("peach_vault_a");135+ expect(got).toEqual({ peach_vault_a: { ci: "abc", nonce: "def" } });136+ });137+});src/mobile/chrome-shim.ts+463 −0
@@ -0,0 +1,463 @@1+// src/mobile/chrome-shim.ts — Ring 1 (god document §4).2+//3+// Installs `globalThis.chrome` before any other module in this bundle4+// evaluates (see src/mobile/entry.ts, where this is the first import). Every5+// one of the codebase's existing `chrome.*` call sites — storage, runtime6+// messaging, alarms, idle, tabs — keeps working unmodified; this file is the7+// only thing that knows the host is a native shell, not a browser extension8+// runtime (§4.1).9+//10+// Grounded against the real call sites in this repo, not invented (§14):11+// - src/background/messages.ts's setupMessageHandler() already contains a12+// mobile hook (`globalThis.__peachRegisterMessageHandler`) with a comment13+// confirming this exact wiring was anticipated. We deliberately do NOT14+// use it for dispatch (see the long comment below `sendMessage` for why —15+// using it would invoke `handleMessage` twice per request). It's wired16+// as a harmless no-op so that code path doesn't need touching.17+// - src/popup/App.tsx registers `chrome.runtime.onMessage.addListener` and18+// `chrome.storage.onChanged.addListener` unconditionally on mount — both19+// must be real, not undefined, or the popup throws at mount time.20+// - src/background/commands.ts's setupCommands() and21+// src/background/clipboard.ts's setupClipboardClearAlarm() call22+// `chrome.commands.onCommand.addListener` / `chrome.alarms.onAlarm.addListener`23+// with no optional chaining — both must exist as real objects or24+// `background/entry.ts`'s start() throws synchronously at boot.25+// - src/background/vault.ts's storage calls (`chrome.storage.local.set({ key: object })`)26+// pass plain JS objects, not pre-serialized strings — this shim27+// JSON-encodes at the native KV boundary (§5's kvGet/kvSet are string-only).28+29+import { nativePlugin } from "../shared/native";30+31+declare const __VERSION__: string;32+33+type MessageListener = (34+ message: unknown,35+ sender: unknown,36+ sendResponse: (response: unknown) => void37+) => boolean | void;38+39+type NativeKV = {40+ kvGet(key: string): Promise<string | null>;41+ kvSet(key: string, value: string): Promise<void>;42+ kvRemove(key: string): Promise<void>;43+ kvClear(): Promise<void>;44+ kvKeys(): Promise<string[]>;45+};46+47+function nativeKV(): NativeKV | null {48+ return nativePlugin<NativeKV>("PeachNative");49+}50+51+const SYNTHETIC_SENDER = Object.freeze({ id: "peach-shell", origin: "peach-shell" });52+53+// -----------------------------------------------------------------------54+// runtime.sendMessage / onMessage — in-process dispatch (§2.3, §2.4).55+//56+// This is real onMessage.addListener/broadcast semantics, matching actual57+// Chrome: every registered listener is invoked for every sendMessage call;58+// whichever listener calls `sendResponse` first supplies the resolved value;59+// if no listener returns `true` (none intends to answer asynchronously), the60+// message resolves as `undefined` immediately, matching Chrome's "no61+// listener responded" case (src/shared/bridge.ts already treats that as62+// SERVICE_WORKER_UNAVAILABLE).63+//64+// messages.ts's setupMessageHandler() registers exactly one real listener65+// this way (wrapping handleMessage, always returning `true`) — since nothing66+// else in the mobile bundle calls sendResponse, that listener always wins,67+// and handleMessage runs exactly once per message. Popup-side broadcast68+// listeners (App.tsx's VAULT_LOCKED/PRO_GATE_TRIGGERED handler) sit in the69+// same array, see every message, and safely ignore the ones they don't70+// recognize — they never call sendResponse, so they never race the real71+// answer.72+// -----------------------------------------------------------------------73+74+const messageListeners: MessageListener[] = [];75+const pendingSends: Array<{ message: unknown; resolve: (v: unknown) => void }> = [];76+77+function deliver(message: unknown): Promise<unknown> {78+ if (messageListeners.length === 0) {79+ return new Promise((resolve) => pendingSends.push({ message, resolve }));80+ }81+ return new Promise((resolve) => {82+ let responded = false;83+ let anyAsync = false;84+ const respond = (response: unknown) => {85+ if (responded) return;86+ responded = true;87+ resolve(response);88+ };89+ for (const listener of messageListeners.slice()) {90+ try {91+ const keepChannelOpen = listener(message, SYNTHETIC_SENDER, respond);92+ if (keepChannelOpen === true) anyAsync = true;93+ } catch (err) {94+ console.error("[peach-shell] chrome.runtime.onMessage listener threw:", err);95+ }96+ }97+ if (!responded && !anyAsync) respond(undefined);98+ });99+}100+101+function flushPendingSends() {102+ if (messageListeners.length === 0) return;103+ while (pendingSends.length) {104+ const { message, resolve } = pendingSends.shift()!;105+ void deliver(message).then(resolve);106+ }107+}108+109+function sendMessage(message: unknown, callback?: (response: unknown) => void): Promise<unknown> {110+ const promise = deliver(message);111+ if (callback) void promise.then(callback);112+ return promise;113+}114+115+// Deliberately a no-op: see the file header. messages.ts calls this in116+// addition to the real onMessage.addListener registration above; wiring it117+// to actually dispatch would run handleMessage a second time per message.118+(globalThis as unknown as Record<string, unknown>).__peachRegisterMessageHandler = () => {};119+120+// -----------------------------------------------------------------------121+// storage — three areas per §4.2: session is in-process (cleared on process122+// death, matching "locked when the app is killed"); local/sync route to the123+// native KV (§5), falling back to an in-memory store with a one-time warning124+// if native kv* isn't available yet (expected until peach-android Stage 1).125+// -----------------------------------------------------------------------126+127+type StorageChangeMap = Record<string, { oldValue?: unknown; newValue?: unknown }>;128+type StorageChangeListener = (changes: StorageChangeMap, areaName: string) => void;129+130+const storageChangeListeners: StorageChangeListener[] = [];131+132+function notifyStorageChanged(changes: StorageChangeMap, areaName: string) {133+ if (Object.keys(changes).length === 0) return;134+ for (const listener of storageChangeListeners.slice()) {135+ try {136+ listener(changes, areaName);137+ } catch (err) {138+ console.error("[peach-shell] chrome.storage.onChanged listener threw:", err);139+ }140+ }141+}142+143+function keyListFrom(144+ keys: string | string[] | Record<string, unknown> | null | undefined,145+ allKeys: () => string[]146+): string[] {147+ if (keys == null) return allKeys();148+ if (typeof keys === "string") return [keys];149+ if (Array.isArray(keys)) return keys;150+ return Object.keys(keys);151+}152+153+function makeMapArea(store: Map<string, unknown>, areaName: string) {154+ return {155+ async get(keys?: string | string[] | Record<string, unknown> | null) {156+ const keyList = keyListFrom(keys, () => Array.from(store.keys()));157+ const out: Record<string, unknown> = {};158+ for (const k of keyList) if (store.has(k)) out[k] = store.get(k);159+ return out;160+ },161+ async set(items: Record<string, unknown>) {162+ const changes: StorageChangeMap = {};163+ for (const [k, v] of Object.entries(items)) {164+ changes[k] = { oldValue: store.get(k), newValue: v };165+ store.set(k, v);166+ }167+ notifyStorageChanged(changes, areaName);168+ },169+ async remove(keys: string | string[]) {170+ const changes: StorageChangeMap = {};171+ for (const k of Array.isArray(keys) ? keys : [keys]) {172+ if (!store.has(k)) continue;173+ changes[k] = { oldValue: store.get(k), newValue: undefined };174+ store.delete(k);175+ }176+ notifyStorageChanged(changes, areaName);177+ },178+ async clear() {179+ const changes: StorageChangeMap = {};180+ for (const [k, v] of store.entries()) changes[k] = { oldValue: v, newValue: undefined };181+ store.clear();182+ notifyStorageChanged(changes, areaName);183+ },184+ };185+}186+187+const sessionStore = new Map<string, unknown>();188+const sessionArea = makeMapArea(sessionStore, "session");189+190+// Local storage's in-memory fallback, used only until native kv works or191+// when a native call fails. Real persistence is peach-android Stage 1's job.192+const localFallbackStore = new Map<string, unknown>();193+const localFallbackArea = makeMapArea(localFallbackStore, "local");194+195+let warnedNativeKVUnavailable = false;196+function warnNativeKVFallback(reason: unknown) {197+ if (warnedNativeKVUnavailable) return;198+ warnedNativeKVUnavailable = true;199+ console.warn(200+ "[peach-shell] PeachNative kv* unavailable or failing — chrome.storage.local is " +201+ "falling back to an in-memory store that will NOT survive an app restart. " +202+ "Expected until peach-android Stage 1 (VaultStore) ships.",203+ reason204+ );205+}206+207+const localArea = {208+ async get(keys?: string | string[] | Record<string, unknown> | null) {209+ const kv = nativeKV();210+ if (!kv) return localFallbackArea.get(keys);211+ try {212+ const keyList = keyListFrom(keys, () => {213+ throw new Error("native-keys-listing-not-requested");214+ });215+ const resolvedKeys = keys == null ? await kv.kvKeys() : keyList;216+ const out: Record<string, unknown> = {};217+ for (const k of resolvedKeys) {218+ const raw = await kv.kvGet(k);219+ if (raw != null) out[k] = JSON.parse(raw);220+ }221+ return out;222+ } catch (err) {223+ warnNativeKVFallback(err);224+ return localFallbackArea.get(keys);225+ }226+ },227+ async set(items: Record<string, unknown>) {228+ const kv = nativeKV();229+ if (!kv) return localFallbackArea.set(items);230+ try {231+ const changes: StorageChangeMap = {};232+ for (const [k, v] of Object.entries(items)) {233+ changes[k] = { newValue: v };234+ await kv.kvSet(k, JSON.stringify(v));235+ }236+ notifyStorageChanged(changes, "local");237+ } catch (err) {238+ warnNativeKVFallback(err);239+ return localFallbackArea.set(items);240+ }241+ },242+ async remove(keys: string | string[]) {243+ const kv = nativeKV();244+ if (!kv) return localFallbackArea.remove(keys);245+ try {246+ const keyList = Array.isArray(keys) ? keys : [keys];247+ for (const k of keyList) await kv.kvRemove(k);248+ notifyStorageChanged(249+ Object.fromEntries(keyList.map((k) => [k, { newValue: undefined }])),250+ "local"251+ );252+ } catch (err) {253+ warnNativeKVFallback(err);254+ return localFallbackArea.remove(keys);255+ }256+ },257+ async clear() {258+ const kv = nativeKV();259+ if (!kv) return localFallbackArea.clear();260+ try {261+ await kv.kvClear();262+ } catch (err) {263+ warnNativeKVFallback(err);264+ return localFallbackArea.clear();265+ }266+ },267+};268+269+// -----------------------------------------------------------------------270+// alarms — in-process setTimeout emulation. This does NOT survive the271+// WebView being frozen in the background (§4.4 point 2) — that's exactly272+// the hazard peach-android's native Scheduler (Stage 2) exists to close.273+// Kept only so background/entry.ts's synchronous, non-optional-chained274+// `chrome.alarms.onAlarm.addListener` calls (clipboard.ts) don't throw at275+// boot. App.tsx already carries its own foreground idle-autolock poll276+// specifically because "chrome.alarms is no-op'd in Capacitor" (its words) —277+// this shim is a strict improvement on that (it actually fires while278+// foregrounded) but is not a substitute for Stage 2.279+// -----------------------------------------------------------------------280+281+type AlarmInfo = { when?: number; delayInMinutes?: number; periodInMinutes?: number };282+type Alarm = { name: string; scheduledTime: number };283+284+const alarmTimers = new Map<string, ReturnType<typeof setTimeout>>();285+const alarmListeners: Array<(alarm: Alarm) => void> = [];286+287+function alarmsCreate(name: string, info: AlarmInfo) {288+ alarmsClearTimer(name);289+ const delayMs =290+ info.when != null291+ ? Math.max(0, info.when - Date.now())292+ : Math.max(0, (info.delayInMinutes ?? 0) * 60_000);293+ const timer = setTimeout(() => {294+ alarmTimers.delete(name);295+ const alarm: Alarm = { name, scheduledTime: Date.now() };296+ for (const listener of alarmListeners.slice()) listener(alarm);297+ if (info.periodInMinutes) alarmsCreate(name, { delayInMinutes: info.periodInMinutes, periodInMinutes: info.periodInMinutes });298+ }, delayMs);299+ alarmTimers.set(name, timer);300+}301+302+function alarmsClearTimer(name: string): boolean {303+ const timer = alarmTimers.get(name);304+ if (!timer) return false;305+ clearTimeout(timer);306+ alarmTimers.delete(name);307+ return true;308+}309+310+const alarmsShim = {311+ create(name: string, info: AlarmInfo) {312+ alarmsCreate(name, info);313+ },314+ async clear(name: string) {315+ return alarmsClearTimer(name);316+ },317+ async clearAll() {318+ for (const name of Array.from(alarmTimers.keys())) alarmsClearTimer(name);319+ return true;320+ },321+ onAlarm: {322+ addListener(fn: (alarm: Alarm) => void) {323+ alarmListeners.push(fn);324+ },325+ },326+};327+328+// -----------------------------------------------------------------------329+// idle — derived from the native lifecycle/screen-state push channels330+// (peach-android's NativeBridge, already wired via BridgeScript). Screen331+// off maps to "locked" (the Chrome idle API's own term for "screen locked"),332+// matching alarms.ts's own handling of that state.333+// -----------------------------------------------------------------------334+335+type IdleState = "active" | "idle" | "locked";336+const idleStateListeners: Array<(state: IdleState) => void> = [];337+338+const idleShim = {339+ setDetectionInterval(_seconds: number) {340+ // No JS-side idle timer to configure — idle/locked is driven entirely by341+ // the native screen-state signal, not a polling interval.342+ },343+ onStateChanged: {344+ addListener(fn: (state: IdleState) => void) {345+ idleStateListeners.push(fn);346+ },347+ },348+};349+350+nativePlugin<{ onScreenState(cb: (s: "on" | "off") => void): void }>("PeachNative")?.onScreenState((s) => {351+ const state: IdleState = s === "off" ? "locked" : "active";352+ for (const listener of idleStateListeners.slice()) listener(state);353+});354+355+// -----------------------------------------------------------------------356+// Everything else in §4.2's inventory that isn't load-bearing for Stage 0/1:357+// stubs sized to what the real call sites in this repo actually touch.358+// -----------------------------------------------------------------------359+360+const tabsShim = {361+ async query() {362+ return [] as unknown[];363+ },364+ async sendMessage() {365+ throw new Error("chrome.tabs.sendMessage: no browser tabs on the mobile shell");366+ },367+};368+369+const commandsShim = {370+ onCommand: { addListener(_fn: (command: string) => void) {} },371+};372+373+const actionShim = {374+ openPopup: undefined as (() => Promise<void>) | undefined,375+};376+377+const scriptingShim = {378+ async executeScript() {379+ throw new Error("chrome.scripting.executeScript is not available on the mobile shell");380+ },381+};382+383+const offscreenShim = {384+ async createDocument() {},385+ async closeDocument() {},386+ async hasDocument() {387+ return false;388+ },389+};390+391+const windowsShim = {392+ async create() {393+ return undefined;394+ },395+};396+397+const permissionsShim = {398+ async request() {399+ return false;400+ },401+ async contains() {402+ return false;403+ },404+};405+406+// -----------------------------------------------------------------------407+// Assemble and install. Cast through `unknown` — @types/chrome's ambient408+// `chrome` is the full extension surface; this is a deliberately partial409+// polyfill of the slice this codebase actually calls (§4.2).410+// -----------------------------------------------------------------------411+412+const chromeShim = {413+ runtime: {414+ sendMessage,415+ onMessage: {416+ addListener(fn: MessageListener) {417+ messageListeners.push(fn);418+ flushPendingSends();419+ },420+ removeListener(fn: MessageListener) {421+ const i = messageListeners.indexOf(fn);422+ if (i >= 0) messageListeners.splice(i, 1);423+ },424+ },425+ onInstalled: { addListener(_fn: () => void) {} },426+ onConnect: { addListener(_fn: (port: unknown) => void) {} },427+ getURL(path: string): string {428+ return `https://appassets.androidplatform.net/assets/web/${path.replace(/^\/+/, "")}`;429+ },430+ getManifest() {431+ return { version: __VERSION__ };432+ },433+ get lastError() {434+ return undefined;435+ },436+ id: "peach-shell",437+ },438+ storage: {439+ session: sessionArea,440+ local: localArea,441+ sync: localArea,442+ onChanged: {443+ addListener(fn: StorageChangeListener) {444+ storageChangeListeners.push(fn);445+ },446+ removeListener(fn: StorageChangeListener) {447+ const i = storageChangeListeners.indexOf(fn);448+ if (i >= 0) storageChangeListeners.splice(i, 1);449+ },450+ },451+ },452+ alarms: alarmsShim,453+ idle: idleShim,454+ tabs: tabsShim,455+ commands: commandsShim,456+ action: actionShim,457+ scripting: scriptingShim,458+ offscreen: offscreenShim,459+ windows: windowsShim,460+ permissions: permissionsShim,461+};462+463+(globalThis as unknown as { chrome: typeof chrome }).chrome = chromeShim as unknown as typeof chrome;src/mobile/entry.ts+41 −0
@@ -0,0 +1,41 @@1+// src/mobile/entry.ts — the mobile WebView's single entrypoint (god document §4.3).2+//3+// Order is load-bearing: the chrome shim must install `globalThis.chrome`4+// before any other module evaluates, and the brain boots before the UI5+// mounts so `bridge.sendMessage`'s first call has a handler waiting.6+//7+// `background/entry.ts` already self-invokes `start()` at its own module8+// bottom (verified against source — this diverges from the god document's9+// illustrative sketch, which shows calling `start()` again explicitly; doing10+// that here would run session-restore, setupAutoLock, and11+// setupClipboardClearAlarm a second time). Importing it for the side effect12+// is sufficient. `popup/index.tsx` similarly self-mounts `<App/>` and, on13+// native platforms, also calls `setupMessageHandler()` again — harmless,14+// since that registration is idempotent (see chrome-shim.ts's dispatch15+// comment for why running it twice doesn't invoke `handleMessage` twice).16+import "./chrome-shim";17+import "../background/entry";18+import "../popup/index";19+20+import { ensureSettingsLoaded, isUnlocked, lock } from "../background/vault";21+import { isRecoveryConfirmPending } from "../background/recovery-pending";22+import { denyAllPendingPasskeyRequests } from "../background/passkey";23+import { nativePlugin } from "../shared/native";24+25+// Lock-on-close, mobile-shaped (§4.4 point 1): entry.ts's real lock-on-close26+// listens for the popup's `chrome.runtime.onConnect` port disconnecting —27+// there is no such port here, so peach-android's NativeBridge pushes28+// foreground/background over the bridge instead. Same intent (mirrors29+// entry.ts's onDisconnect body exactly), native trigger.30+nativePlugin<{ onLifecycle(cb: (s: "foreground" | "background") => void): void }>("PeachNative")?.onLifecycle(31+ (state) => {32+ if (state !== "background") return;33+ void (async () => {34+ await denyAllPendingPasskeyRequests();35+ const settings = await ensureSettingsLoaded();36+ if (!isUnlocked() || settings.lockOnClose === false) return;37+ if (await isRecoveryConfirmPending()) return;38+ lock();39+ })();40+ }41+);src/mobile/index.html+44 −0
@@ -0,0 +1,44 @@1+<!DOCTYPE html>2+<html lang="en">3+ <head>4+ <meta charset="UTF-8" />5+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />6+ <link7+ rel="preload"8+ href="/fonts/CormorantGaramond-LightItalic.woff2"9+ as="font"10+ type="font/woff2"11+ crossorigin12+ />13+ <link14+ rel="preload"15+ href="/fonts/CormorantGaramond-RegularItalic.woff2"16+ as="font"17+ type="font/woff2"18+ crossorigin19+ />20+ <link21+ rel="preload"22+ href="/fonts/IBMPlexMono-Light.woff2"23+ as="font"24+ type="font/woff2"25+ crossorigin26+ />27+ <!--28+ Mirrors src/popup/index.html's <head> (fonts, stylesheets) — this repo's29+ hands-off contract (god document §12) keeps src/popup/** read-only, so30+ the mobile entry gets its own HTML shell in src/mobile/** rather than31+ editing the shared one. Keep this list in sync with src/popup/index.html32+ if it changes; src/mobile/entry.ts is what actually boots the app.33+ -->34+ <link rel="stylesheet" href="../popup/styles/vars.css" />35+ <link rel="stylesheet" href="../popup/styles/popup.css" />36+ <link rel="stylesheet" href="../popup/styles/settings-stack.css" />37+ <link rel="stylesheet" href="../popup/styles/codex.css" />38+ <title>Peach</title>39+ </head>40+ <body>41+ <div id="app"></div>42+ <script type="module" src="./entry.ts"></script>43+ </body>44+</html>vite.config.mobile.ts+8 −2
@@ -33,10 +33,16 @@3333 },3434 build: {3535 outDir: "dist-mobile",36- emptyOutDir: false,36+ // The old input (src/popup/index.html) built a pre-Ring1 Capacitor-era37+ // bundle; emptying the dir avoids a stale mix of that output with the38+ // new src/mobile/entry.ts bundle.39+ emptyOutDir: true,3740 target: "esnext",3841 rollupOptions: {39- input: "src/popup/index.html",42+ // src/mobile/index.html (not src/popup/index.html) — the mobile shell43+ // boots through src/mobile/entry.ts so the chrome shim installs before44+ // any other module evaluates (§4.1). src/popup/** stays untouched.45+ input: "src/mobile/index.html",4046 },4147 },4248 });\ No newline at end of file