| 1 | import { asArray } from "./array"; |
| 2 | import { app } from "./bridge"; |
| 3 | import { startDesktopEventRecovery } from "./desktopEventRecovery"; |
| 4 | import { recordFrontendDiagnostic } from "./frontendDiagnosticBridge"; |
| 5 | import type { Meta, TabMeta } from "./types"; |
| 6 | import { sameSessionIdentity } from "./sessionIdentity"; |
| 7 | |
| 8 | export interface ControllerRecoveryPorts { |
| 9 | navigation(): number; |
| 10 | bindings(): Map<string, string>; |
| 11 | meta(tabId: string): Meta | undefined; |
| 12 | now(): number; |
| 13 | flush(): void; |
| 14 | prepare(tab: TabMeta): void; |
| 15 | runtime(tab: TabMeta, snapshotAt: number): void; |
| 16 | resynchronize?(tab: TabMeta): Promise<void>; |
| 17 | reset(tabId: string): void; |
| 18 | hydrate(tab: TabMeta, isCurrent: () => boolean): Promise<void>; |
| 19 | } |
| 20 | |
| 21 | export function startControllerEventRecovery(ports: ControllerRecoveryPorts, subscribe: (callback: () => void) => () => void): () => void { |
| 22 | let version = 0; |
| 23 | const hydrating = new Set<string>(); |
| 24 | const failed = (error: unknown) => recordFrontendDiagnostic("runtime", "desktop-event-recovery-failed", { |
| 25 | error: error instanceof Error ? error.message : String(error), |
| 26 | }); |
| 27 | const off = startDesktopEventRecovery({ |
| 28 | subscribe: callback => subscribe(() => { version++; callback(); }), |
| 29 | capture: () => ({ version, navigation: ports.navigation(), snapshotAt: ports.now(), bindings: ports.bindings() }), |
| 30 | isCurrent: scope => { |
| 31 | const bindings = ports.bindings(); |
| 32 | return scope.navigation === ports.navigation() && Array.from(scope.bindings).every(([id, value]) => bindings.get(id) === value); |
| 33 | }, |
| 34 | read: async () => asArray(await app.ListTabs()), |
| 35 | apply: (tabs, scope) => { |
| 36 | ports.flush(); |
| 37 | for (const tab of tabs) { |
| 38 | if (!scope.bindings.has(tab.id)) continue; |
| 39 | const meta = ports.meta(tab.id); |
| 40 | const changedSession = !sameSessionIdentity(meta, tab); |
| 41 | ports.prepare(tab); |
| 42 | if (changedSession || hydrating.has(tab.id)) { |
| 43 | hydrating.add(tab.id); |
| 44 | ports.reset(tab.id); |
| 45 | const isCurrent = () => scope.version === version && scope.navigation === ports.navigation(); |
| 46 | const hydration = ports.hydrate(tab, isCurrent); |
| 47 | // hydrate advances sessionLoadSeq synchronously before its first |
| 48 | // await. Capture that new binding, not the pre-hydration snapshot: |
| 49 | // a background session change can supersede it without navigation. |
| 50 | const binding = ports.bindings().get(tab.id); |
| 51 | void hydration.then(() => { |
| 52 | if (isCurrent() && binding !== undefined && ports.bindings().get(tab.id) === binding) { |
| 53 | hydrating.delete(tab.id); ports.runtime(tab, scope.snapshotAt); |
| 54 | } |
| 55 | }).catch(failed); |
| 56 | } else { |
| 57 | // The existing runtime projection requests missing durable turn |
| 58 | // events and pending prompt presentation, never another invocation. |
| 59 | ports.runtime(tab, scope.snapshotAt); |
| 60 | if (ports.resynchronize) void ports.resynchronize(tab).catch(failed); |
| 61 | } |
| 62 | } |
| 63 | }, |
| 64 | failed, |
| 65 | timer: (callback, delay) => window.setTimeout(callback, delay), |
| 66 | clearTimer: timer => { if (timer !== undefined) window.clearTimeout(timer as number); }, |
| 67 | }); |
| 68 | return () => { version++; off(); }; |
| 69 | } |
| 70 |