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
5 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 −2
src/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+});

view file

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;

view file

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+);

view file

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>

view file

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

view file