chrome-shim.ts

16.5 KB · raw

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).2829import { nativePlugin } from "../shared/native";3031declare const __VERSION__: string;3233type MessageListener = (34  message: unknown,35  sender: unknown,36  sendResponse: (response: unknown) => void37) => boolean | undefined;3839type 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};4647function nativeKV(): NativeKV | null {48  return nativePlugin<NativeKV>("PeachNative");49}5051const SYNTHETIC_SENDER = Object.freeze({ id: "peach-shell", origin: "peach-shell" });5253// -----------------------------------------------------------------------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// -----------------------------------------------------------------------7374const messageListeners: MessageListener[] = [];75const pendingSends: Array<{ message: unknown; resolve: (v: unknown) => void }> = [];7677function 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}100101function 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}108109function 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}114115// 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 = () => {};119120// -----------------------------------------------------------------------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// -----------------------------------------------------------------------126127type StorageChangeMap = Record<string, { oldValue?: unknown; newValue?: unknown }>;128type StorageChangeListener = (changes: StorageChangeMap, areaName: string) => void;129130const storageChangeListeners: StorageChangeListener[] = [];131132function 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}142143function 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}152153function 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}186187const sessionStore = new Map<string, unknown>();188const sessionArea = makeMapArea(sessionStore, "session");189190// 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.192const localFallbackStore = new Map<string, unknown>();193const localFallbackArea = makeMapArea(localFallbackStore, "local");194195let warnedNativeKVUnavailable = false;196function 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}206207const 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};268269// -----------------------------------------------------------------------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// -----------------------------------------------------------------------280281type AlarmInfo = { when?: number; delayInMinutes?: number; periodInMinutes?: number };282type Alarm = { name: string; scheduledTime: number };283284const alarmTimers = new Map<string, ReturnType<typeof setTimeout>>();285const alarmListeners: Array<(alarm: Alarm) => void> = [];286287function 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}301302function 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}309310const 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};327328// -----------------------------------------------------------------------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// -----------------------------------------------------------------------334335type IdleState = "active" | "idle" | "locked";336const idleStateListeners: Array<(state: IdleState) => void> = [];337338const 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};349350nativePlugin<{ 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});354355// -----------------------------------------------------------------------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// -----------------------------------------------------------------------359360const 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};368369const commandsShim = {370  onCommand: { addListener(_fn: (command: string) => void) {} },371};372373const actionShim = {374  openPopup: undefined as (() => Promise<void>) | undefined,375};376377const scriptingShim = {378  async executeScript() {379    throw new Error("chrome.scripting.executeScript is not available on the mobile shell");380  },381};382383const offscreenShim = {384  async createDocument() {},385  async closeDocument() {},386  async hasDocument() {387    return false;388  },389};390391const windowsShim = {392  async create() {393    return undefined;394  },395};396397const permissionsShim = {398  async request() {399    return false;400  },401  async contains() {402    return false;403  },404};405406// -----------------------------------------------------------------------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// -----------------------------------------------------------------------411412const 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};462463(globalThis as unknown as { chrome: typeof chrome }).chrome = chromeShim as unknown as typeof chrome;