1234567891011121314151617181920212223242526272829import { 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" });5253545556575859606162636465666768697071727374const 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}114115116117118(globalThis as unknown as Record<string, unknown>).__peachRegisterMessageHandler = () => {};119120121122123124125126127type 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");189190191192const 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};268269270271272273274275276277278279280281type 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};327328329330331332333334335type IdleState = "active" | "idle" | "locked";336const idleStateListeners: Array<(state: IdleState) => void> = [];337338const idleShim = {339 setDetectionInterval(_seconds: number) {340 341 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});354355356357358359360const 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};405406407408409410411412const 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;