diff --git a/apps/macos/src/main/index.ts b/apps/macos/src/main/index.ts index 30a97b02dc2..7c246a905d7 100644 --- a/apps/macos/src/main/index.ts +++ b/apps/macos/src/main/index.ts @@ -14,6 +14,7 @@ import { toggleVisibility as toggleMainWindowVisibility, } from "./main-window"; import { installApplicationMenu } from "./menu"; +import { installPowerEvents } from "./power-events"; import { readSetting, writeSetting } from "./settings"; import { installTray } from "./tray"; @@ -250,6 +251,7 @@ app installAbout(); installApplicationMenu(); installDock(); + installPowerEvents(); installTray({ toggleMainWindow: toggleMainWindowVisibility, ensureMainWindow: ensureMainWindowVisible, diff --git a/apps/macos/src/main/power-events.test.ts b/apps/macos/src/main/power-events.test.ts new file mode 100644 index 00000000000..9fc2c8a2fcb --- /dev/null +++ b/apps/macos/src/main/power-events.test.ts @@ -0,0 +1,145 @@ +import { + afterEach, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; + +// `powerMonitor`'s subscriptions are captured by name so the test can +// fire them at will. `BrowserWindow.getAllWindows` returns a controllable +// stub list. `app.on("before-quit", ...)` is captured the same way. +type PowerListener = () => void; +const powerListeners = new Map(); +const powerOnMock = mock((event: string, listener: PowerListener) => { + powerListeners.set(event, listener); +}); + +type SendMock = ReturnType; +interface StubWindow { + isDestroyed: () => boolean; + webContents: { send: SendMock }; +} +let windows: StubWindow[] = []; + +const appOnMock = mock((_event: string, _handler: () => void) => undefined); + +mock.module("electron", () => ({ + powerMonitor: { on: powerOnMock }, + BrowserWindow: { getAllWindows: () => windows }, + app: { on: appOnMock }, +})); + +const { __resetForTesting, installPowerEvents } = await import( + "./power-events" +); + +const makeWindow = (destroyed = false): StubWindow => ({ + isDestroyed: () => destroyed, + webContents: { send: mock(() => undefined) }, +}); + +beforeEach(() => { + __resetForTesting(); + powerListeners.clear(); + powerOnMock.mockClear(); + appOnMock.mockClear(); + windows = []; +}); + +afterEach(() => { + windows = []; +}); + +describe("installPowerEvents", () => { + test("subscribes to suspend, resume, lock-screen, unlock-screen, user-did-become-active", () => { + installPowerEvents(); + expect(powerListeners.has("suspend")).toBe(true); + expect(powerListeners.has("resume")).toBe(true); + expect(powerListeners.has("lock-screen")).toBe(true); + expect(powerListeners.has("unlock-screen")).toBe(true); + expect(powerListeners.has("user-did-become-active")).toBe(true); + }); + + test("is idempotent — repeated calls don't re-subscribe", () => { + installPowerEvents(); + installPowerEvents(); + installPowerEvents(); + // Five distinct events, three install attempts; only the first + // wires through to powerMonitor.on, total 5 calls. + expect(powerOnMock).toHaveBeenCalledTimes(5); + }); +}); + +describe("broadcast", () => { + test("forwards a kind-discriminated payload to every BrowserWindow's webContents", () => { + installPowerEvents(); + const w1 = makeWindow(); + const w2 = makeWindow(); + windows = [w1, w2]; + + powerListeners.get("suspend")?.(); + + expect(w1.webContents.send).toHaveBeenCalledWith("vellum:power:event", { + kind: "suspend", + }); + expect(w2.webContents.send).toHaveBeenCalledWith("vellum:power:event", { + kind: "suspend", + }); + }); + + test("maps Electron's lock-screen / unlock-screen / user-did-become-active to lock/unlock/active", () => { + installPowerEvents(); + const w = makeWindow(); + windows = [w]; + + powerListeners.get("lock-screen")?.(); + powerListeners.get("unlock-screen")?.(); + powerListeners.get("user-did-become-active")?.(); + + expect(w.webContents.send.mock.calls.map((c) => c[1])).toEqual([ + { kind: "lock" }, + { kind: "unlock" }, + { kind: "active" }, + ]); + }); + + test("skips destroyed windows", () => { + installPowerEvents(); + const alive = makeWindow(); + const dead = makeWindow(true); + windows = [alive, dead]; + + powerListeners.get("resume")?.(); + + expect(alive.webContents.send).toHaveBeenCalled(); + expect(dead.webContents.send).not.toHaveBeenCalled(); + }); + + test("debounces duplicate events of the same kind within 1s", () => { + installPowerEvents(); + const w = makeWindow(); + windows = [w]; + + powerListeners.get("resume")?.(); + powerListeners.get("resume")?.(); + powerListeners.get("resume")?.(); + + expect(w.webContents.send).toHaveBeenCalledTimes(1); + }); + + test("does NOT debounce across kinds — suspend and resume both fire", () => { + installPowerEvents(); + const w = makeWindow(); + windows = [w]; + + powerListeners.get("suspend")?.(); + powerListeners.get("resume")?.(); + + expect(w.webContents.send.mock.calls.map((c) => c[1])).toEqual([ + { kind: "suspend" }, + { kind: "resume" }, + ]); + }); +}); diff --git a/apps/macos/src/main/power-events.ts b/apps/macos/src/main/power-events.ts new file mode 100644 index 00000000000..5f8fe78ac4d --- /dev/null +++ b/apps/macos/src/main/power-events.ts @@ -0,0 +1,87 @@ +import { BrowserWindow, app, powerMonitor } from "electron"; + +/** + * System power-state events: sleep/wake, screen lock/unlock, idle-recover. + * + * Why this exists when the renderer already has `visibilitychange`: + * `visibilitychange` only fires on visibility transitions. A tray-resident + * or full-screen app never goes hidden during sleep, so the renderer + * doesn't see anything. Browser timers also freeze during system suspend; + * on resume, `setInterval` doesn't retroactively fire missed ticks, and + * WebSockets may appear "open" but be half-dead because the remote side + * has TCP-RST'd while we slept. Subscribing to `powerMonitor` in main + * surfaces the system-level signal so the renderer can reconnect + * streams, refresh tokens, and bounce health probes on wake. + * + * Broadcast model: `webContents.send` to every BrowserWindow. The + * About window (and any future auxiliary window the preload is attached + * to) gets the same events; surfaces that don't care simply don't + * subscribe, no handler runs. + * + * Debounce per kind: Electron has historically delivered duplicate + * suspend/resume events on macOS — we collapse repeats within + * `DEBOUNCE_MS` so renderer consumers don't see the same wake twice. + * + * Reference: https://www.electronjs.org/docs/latest/api/power-monitor + */ + +export type PowerEventKind = + | "suspend" + | "resume" + | "lock" + | "unlock" + | "active"; + +export interface PowerEvent { + kind: PowerEventKind; +} + +const DEBOUNCE_MS = 1_000; + +// Most-recent emit timestamp per kind. Module-scope so tests can read +// the debounce behavior indirectly; reset on `installPowerEvents`. +const lastEmittedAt: Partial> = {}; + +const broadcast = (kind: PowerEventKind): void => { + const now = Date.now(); + const last = lastEmittedAt[kind]; + if (last !== undefined && now - last < DEBOUNCE_MS) return; + lastEmittedAt[kind] = now; + const payload: PowerEvent = { kind }; + for (const win of BrowserWindow.getAllWindows()) { + if (win.isDestroyed()) continue; + win.webContents.send("vellum:power:event", payload); + } +}; + +let installed = false; +export const installPowerEvents = (): void => { + if (installed) return; + installed = true; + + // Renderer-relevant `powerMonitor` events. `user-did-become-active` + // fires when the user returns after a period of system-defined idle + // — useful for nudging stale state on a long idle but not on sleep. + powerMonitor.on("suspend", () => broadcast("suspend")); + powerMonitor.on("resume", () => broadcast("resume")); + powerMonitor.on("lock-screen", () => broadcast("lock")); + powerMonitor.on("unlock-screen", () => broadcast("unlock")); + powerMonitor.on("user-did-become-active", () => broadcast("active")); + + // Clear timestamps on quit so a hot-reload (dev) re-arms the debounce + // window from zero on the next install. + app.on("before-quit", () => { + for (const kind of Object.keys(lastEmittedAt) as PowerEventKind[]) { + delete lastEmittedAt[kind]; + } + }); +}; + +// Test seam — exported only for the unit test's setup. Production code +// uses `installPowerEvents` instead. +export const __resetForTesting = (): void => { + installed = false; + for (const kind of Object.keys(lastEmittedAt) as PowerEventKind[]) { + delete lastEmittedAt[kind]; + } +}; diff --git a/apps/macos/src/preload/index.ts b/apps/macos/src/preload/index.ts index 6c33d7912b8..503e9f8d484 100644 --- a/apps/macos/src/preload/index.ts +++ b/apps/macos/src/preload/index.ts @@ -32,6 +32,25 @@ export interface AppVersionInfo { website: string; } +/** + * Mirror of `PowerEventKind` in `apps/macos/src/main/power-events.ts`. + * Inlined for the same reason as `VellumCommand` / `AppVersionInfo`: + * preload + main + renderer each have their own TS project; cheaper + * to maintain a tiny literal union three places than to wire + * cross-project imports. Drift surfaces as a renderer handler not + * narrowing on a new kind — graceful no-op, not a crash. + */ +export type PowerEventKind = + | "suspend" + | "resume" + | "lock" + | "unlock" + | "active"; + +export interface PowerEvent { + kind: PowerEventKind; +} + export interface VellumBridge { platform: "electron"; app: { @@ -86,6 +105,22 @@ export interface VellumBridge { */ setSignedIn(signedIn: boolean): Promise; }; + power: { + /** + * Subscribe to system power-state events: sleep, wake, screen + * lock/unlock, user-did-become-active-after-idle. Returns an + * unsubscribe function; callers should invoke it on cleanup + * (e.g. `useEffect` return) to avoid leaks on window close or + * hot reload. + * + * Long-running renderer consumers (SSE, WebSocket clients, auth + * refresh timers) subscribe to bounce-and-reconnect on `resume` + * / `unlock` — browser timers freeze during system suspend and + * sockets may appear "open" but be half-dead because the remote + * side has TCP-RST'd while we slept. + */ + onEvent(callback: (event: PowerEvent) => void): () => void; + }; } const notImplemented = (name: string) => (): Promise => @@ -130,6 +165,17 @@ const bridge: VellumBridge = { setSignedIn: (signedIn: boolean): Promise => ipcRenderer.invoke("vellum:dock:setSignedIn", signedIn) as Promise, }, + power: { + onEvent: (callback) => { + const handler = (_event: IpcRendererEvent, payload: PowerEvent) => { + callback(payload); + }; + ipcRenderer.on("vellum:power:event", handler); + return () => { + ipcRenderer.off("vellum:power:event", handler); + }; + }, + }, }; contextBridge.exposeInMainWorld("vellum", bridge); diff --git a/apps/web/docs/ELECTRON.md b/apps/web/docs/ELECTRON.md index 2158b3fed14..ee8f06ff988 100644 --- a/apps/web/docs/ELECTRON.md +++ b/apps/web/docs/ELECTRON.md @@ -62,6 +62,22 @@ The main-process handler itself lives in `apps/macos/src/main/`; that's a main-p --- +## Cross-domain push signals route through the event bus, not directly via the bridge + +The runtime wrapper is the surface for **imperative** access (`setDockBadge(count)`, `getAppVersionInfo()`). For **push signals** — main-process events that multiple renderer domains care about — the wrapper publishes into the [event bus](./EVENT_BUS.md), and consumers subscribe via the bus. + +Example (`runtime/power-events.ts` + `BusEventMap`): the system's `powerMonitor` fires `suspend` / `resume` / `lock` / `unlock` / `active`. Multiple renderer subsystems care (SSE reconnect, future auth-refresh on wake, future reachability probe). The right shape is: + +1. `apps/macos/src/main/power-events.ts` — subscribes to `powerMonitor`, broadcasts to all renderers via `webContents.send`. +2. `apps/macos/src/preload/index.ts` — `window.vellum.power.onEvent(callback) → unsubscribe`. +3. `apps/web/src/runtime/power-events.ts` — `subscribeToPowerEvents(callback)` (the no-op-off-Electron wrapper). +4. `apps/web/src/hooks/use-event-bus-init.ts` — calls the wrapper once at mount, fans events in as `power.suspend` / `power.resume` / etc. on the bus. +5. Domain consumers subscribe to `bus.subscribe("power.resume", ...)` — never to the wrapper directly. + +The bus integration means the same subscriber code works whether the signal came from `powerMonitor` (Electron), `visibilitychange` (web), or Capacitor `appStateChange` (iOS). Wrappers that publish into the bus stay tiny — they're just signal sources. + +--- + ## See also - [`CONVENTIONS.md`](./CONVENTIONS.md) — architecture, code organization, component patterns. diff --git a/apps/web/docs/EVENT_BUS.md b/apps/web/docs/EVENT_BUS.md index 150f96c054f..43eea324b90 100644 --- a/apps/web/docs/EVENT_BUS.md +++ b/apps/web/docs/EVENT_BUS.md @@ -65,6 +65,11 @@ which is produced by the burst-limited reachability retry in | `app.online` | `{}` | `window.online` fired. Always accompanies a paired `app.resume{signal:"online"}`. | | `app.offline` | `{}` | `window.offline` fired. | | `reachability.retry-requested` | `{}` | Burst-limited reachability retry succeeded; the bus bounces its SSE. | +| `power.suspend` | `{}` | Electron host: `powerMonitor` `suspend` — system going to sleep. Bus tears down its SSE so the daemon sees a clean disconnect. Off Electron (web / iOS) never fires. | +| `power.resume` | `{}` | Electron host: `powerMonitor` `resume` — system woke. Bus bounces (teardown + reopen) its SSE regardless of `current` state — the renderer may have stayed visible during sleep (tray-resident / full-screen) so the socket may be half-dead. Off Electron never fires. | +| `power.lock` | `{}` | Electron host: screen locked. No bus-owned action today. Off Electron never fires. | +| `power.unlock` | `{}` | Electron host: screen unlocked. Bus bounces its SSE (same shape as `power.resume`). Off Electron never fires. | +| `power.active` | `{}` | Electron host: `user-did-become-active` after idle. No bus-owned action today; future ticket may nudge stale state. Off Electron never fires. | ## Subscribing diff --git a/apps/web/src/hooks/use-event-bus-init.test.tsx b/apps/web/src/hooks/use-event-bus-init.test.tsx index 7603d4ff18f..2adc36dd351 100644 --- a/apps/web/src/hooks/use-event-bus-init.test.tsx +++ b/apps/web/src/hooks/use-event-bus-init.test.tsx @@ -211,6 +211,166 @@ describe("useEventBusInit — SSE ownership", () => { expect(checkAssistant).toHaveBeenCalledTimes(1); }, 5_000); + test("power.suspend tears down a live SSE so the daemon sees us go away cleanly", () => { + renderHook(() => + useEventBusInit({ + assistantId: "asst-1", + isAssistantActive: true, + checkAssistant: () => {}, + }), + ); + expect(subscribeChatEventsMock).toHaveBeenCalledTimes(1); + + useEventBusStore.getState().publish("power.suspend", {}); + + expect(cancelMock).toHaveBeenCalledTimes(1); + }); + + test("power.suspend is a no-op when no SSE is open", () => { + renderHook(() => + useEventBusInit({ + assistantId: null, + isAssistantActive: false, + checkAssistant: () => {}, + }), + ); + expect(subscribeChatEventsMock).toHaveBeenCalledTimes(0); + + useEventBusStore.getState().publish("power.suspend", {}); + + // Nothing to tear down — cancel was never wired in the first place. + expect(cancelMock).toHaveBeenCalledTimes(0); + }); + + test("power.resume bounces a LIVE SSE (no preceding app.hidden) — the tray-resident case", () => { + const checkAssistant = mock(() => {}); + renderHook(() => + useEventBusInit({ + assistantId: "asst-1", + isAssistantActive: true, + checkAssistant, + }), + ); + expect(subscribeChatEventsMock).toHaveBeenCalledTimes(1); + expect(cancelMock).toHaveBeenCalledTimes(0); + + // No app.hidden first — the renderer stayed visible during system + // sleep (tray-resident / full-screen). power.resume must tear down + // and reopen, otherwise the half-dead socket persists. + useEventBusStore.getState().publish("power.resume", {}); + + expect(cancelMock).toHaveBeenCalledTimes(1); + expect(subscribeChatEventsMock).toHaveBeenCalledTimes(2); + expect(checkAssistant).toHaveBeenCalledTimes(1); + }); + + test("power.unlock bounces a LIVE SSE — screen lock can outlast TCP timeouts", () => { + const checkAssistant = mock(() => {}); + renderHook(() => + useEventBusInit({ + assistantId: "asst-1", + isAssistantActive: true, + checkAssistant, + }), + ); + expect(subscribeChatEventsMock).toHaveBeenCalledTimes(1); + + useEventBusStore.getState().publish("power.unlock", {}); + + expect(cancelMock).toHaveBeenCalledTimes(1); + expect(subscribeChatEventsMock).toHaveBeenCalledTimes(2); + }); + + test("power.resume reopens the SSE after teardown — same dedup window as app.resume", async () => { + const checkAssistant = mock(() => {}); + renderHook(() => + useEventBusInit({ + assistantId: "asst-1", + isAssistantActive: true, + checkAssistant, + }), + ); + expect(subscribeChatEventsMock).toHaveBeenCalledTimes(1); + useEventBusStore.getState().publish("app.hidden", { signal: "visibility" }); + expect(cancelMock).toHaveBeenCalledTimes(1); + await new Promise((resolve) => setTimeout(resolve, 1100)); + useEventBusStore.getState().publish("power.resume", {}); + expect(subscribeChatEventsMock).toHaveBeenCalledTimes(2); + expect(checkAssistant).toHaveBeenCalledTimes(1); + }, 5_000); + + test("power.unlock reopens the SSE after teardown", async () => { + const checkAssistant = mock(() => {}); + renderHook(() => + useEventBusInit({ + assistantId: "asst-1", + isAssistantActive: true, + checkAssistant, + }), + ); + expect(subscribeChatEventsMock).toHaveBeenCalledTimes(1); + useEventBusStore.getState().publish("app.hidden", { signal: "visibility" }); + expect(cancelMock).toHaveBeenCalledTimes(1); + await new Promise((resolve) => setTimeout(resolve, 1100)); + useEventBusStore.getState().publish("power.unlock", {}); + expect(subscribeChatEventsMock).toHaveBeenCalledTimes(2); + expect(checkAssistant).toHaveBeenCalledTimes(1); + }, 5_000); + + test("app.resume no-op (current non-null) does NOT suppress a follow-up power.resume bounce", () => { + // Real-world trace: tray-resident Electron, system sleeps, wifi + // reconnects on wake → `online` event → `app.resume(signal:"online")` + // → handleAppResume runs but bails because `current` is still + // non-null (renderer never went hidden). 50ms later `power.resume` + // arrives. The handler MUST bounce — that's the entire point of + // this PR. Independent dedup windows ensure the noop'd + // app.resume doesn't suppress it. + const checkAssistant = mock(() => {}); + renderHook(() => + useEventBusInit({ + assistantId: "asst-1", + isAssistantActive: true, + checkAssistant, + }), + ); + expect(subscribeChatEventsMock).toHaveBeenCalledTimes(1); + + // Fire app.resume — current is non-null, so this is a no-op. + useEventBusStore.getState().publish("app.resume", { signal: "online" }); + expect(cancelMock).toHaveBeenCalledTimes(0); + expect(subscribeChatEventsMock).toHaveBeenCalledTimes(1); + + // Power.resume arrives within the dedup window. MUST still bounce. + useEventBusStore.getState().publish("power.resume", {}); + expect(cancelMock).toHaveBeenCalledTimes(1); + expect(subscribeChatEventsMock).toHaveBeenCalledTimes(2); + }); + + test("app.resume then power.resume — fresh SSE gets bounced (wasted bounce, but the correctness tradeoff)", async () => { + // Independent dedup windows mean the two handlers don't observe + // each other's timestamps. In the rare case where the renderer + // both went hidden AND received a system-power signal on wake, + // app.resume opens a fresh SSE and power.resume then bounces it. + // One extra teardown + reopen, <100ms — acceptable cost for + // closing the missed-bounce bug in the more common tray-resident + // case. + const checkAssistant = mock(() => {}); + renderHook(() => + useEventBusInit({ + assistantId: "asst-1", + isAssistantActive: true, + checkAssistant, + }), + ); + useEventBusStore.getState().publish("app.hidden", { signal: "visibility" }); + await new Promise((resolve) => setTimeout(resolve, 1100)); + useEventBusStore.getState().publish("app.resume", { signal: "visibility" }); + expect(subscribeChatEventsMock).toHaveBeenCalledTimes(2); + useEventBusStore.getState().publish("power.resume", {}); + expect(subscribeChatEventsMock).toHaveBeenCalledTimes(3); + expect(cancelMock).toHaveBeenCalledTimes(2); // app.hidden + power.resume bounce + }, 5_000); + test("reachability.retry-requested bounces the SSE connection", () => { renderHook(() => useEventBusInit({ diff --git a/apps/web/src/hooks/use-event-bus-init.ts b/apps/web/src/hooks/use-event-bus-init.ts index bcd4cd41ce0..e69c47ace51 100644 --- a/apps/web/src/hooks/use-event-bus-init.ts +++ b/apps/web/src/hooks/use-event-bus-init.ts @@ -3,17 +3,26 @@ * * Two concerns, two effects: * - * 1. DOM / Capacitor lifecycle. Listens to `document.visibilitychange`, - * `window.online` / `window.offline`, and Capacitor - * `App.appStateChange`; publishes `"app.resume"` / `"app.hidden"` / - * `"app.online"` / `"app.offline"` on the bus. + * 1. DOM / Capacitor / Electron lifecycle. Listens to + * `document.visibilitychange`, `window.online` / `window.offline`, + * Capacitor `App.appStateChange`, and (on the Electron host) the + * main-process `powerMonitor` bridge. Publishes `"app.resume"` / + * `"app.hidden"` / `"app.online"` / `"app.offline"` and + * `"power.suspend"` / `"power.resume"` / `"power.lock"` / + * `"power.unlock"` / `"power.active"` on the bus. * * 2. Single assistant-scoped SSE connection. Opens one unfiltered * `/v1/events` stream per assistant and re-broadcasts every event * on `"sse.event"`. Publishes `"sse.opened"` after each successful - * open and `"sse.closed"` on transport errors. Tears down + - * reopens on `"app.hidden"` / `"app.resume"` (with a 1s dedup - * window) and on `"reachability.retry-requested"`. + * open and `"sse.closed"` on transport errors. Tears down on + * `"app.hidden"` and `"power.suspend"`. Reopens on `"app.resume"` + * (only if torn down). Force-bounces (teardown + reopen) on + * `"power.resume"` / `"power.unlock"` because the renderer may + * have stayed visible during system sleep — the SSE looks "open" + * but the remote may have TCP-RST'd. All resume paths share a 1s + * dedup window so a sleep that ALSO triggered a visibility change + * doesn't double-bounce. `"reachability.retry-requested"` also + * bounces. * * The daemon dedups SSE subscribers by `clientId`, so this hook MUST * be the only place that opens a connection. Consumers subscribe to @@ -25,6 +34,7 @@ import type { PluginListenerHandle } from "@capacitor/core"; import { subscribeChatEvents } from "@/lib/streaming/stream-transport"; import type { ChatEventStream } from "@/lib/streaming/stream-transport"; +import { subscribeToPowerEvents } from "@/runtime/power-events"; import { useEventBusStore } from "@/stores/event-bus-store"; import { isNativePlatform } from "@/runtime/native-auth"; @@ -104,12 +114,37 @@ export function useEventBusInit({ }); } + // Electron host: subscribe to `powerMonitor` via the runtime + // wrapper. The bridge fans every system-level event in as a + // typed bus event. Off Electron the wrapper is a no-op and the + // unsubscribe-noop is returned — no effect on web / iOS. + const unsubPower = subscribeToPowerEvents(({ kind }) => { + switch (kind) { + case "suspend": + bus.publish("power.suspend", {}); + break; + case "resume": + bus.publish("power.resume", {}); + break; + case "lock": + bus.publish("power.lock", {}); + break; + case "unlock": + bus.publish("power.unlock", {}); + break; + case "active": + bus.publish("power.active", {}); + break; + } + }); + return () => { document.removeEventListener("visibilitychange", handleVisibilityChange); window.removeEventListener("online", handleOnline); window.removeEventListener("offline", handleOffline); appStateCancelled = true; void appStateHandle?.remove(); + unsubPower(); }; }, []); @@ -130,7 +165,13 @@ export function useEventBusInit({ let current: ChatEventStream | null = null; let cancelled = false; - let lastResumeAt = 0; + // Independent dedup windows per handler. A shared timestamp was + // wrong: `app.resume`'s no-op (current already non-null) would + // update the shared mark and then suppress a `power.resume` that + // genuinely needed to bounce a half-dead socket. Each handler now + // self-dedups against its own action's recency. + let lastAppResumeAt = 0; + let lastPowerActionAt = 0; let nextOpenCause: "fresh" | "error" | "watchdog" | "resume" = "fresh"; const open = () => { @@ -181,22 +222,55 @@ export function useEventBusInit({ open(); - // App lifecycle: tear down on hidden, reopen on resume. The 1s - // dedup window collapses double-fires from visibilitychange + + // App lifecycle (renderer-visibility resume): tear down on hidden, + // reopen on resume IF the connection was already torn down. The + // self-dedup window collapses double-fires from visibilitychange + // Capacitor appStateChange (both arrive in close succession on // foregrounding the iOS native shell). - const unsubHidden = bus.subscribe("app.hidden", () => { - if (!current) return; - teardown(); - }); - const unsubResume = bus.subscribe("app.resume", () => { + const handleAppResume = () => { const now = Date.now(); - if (now - lastResumeAt < RESUME_DEDUP_WINDOW_MS) return; - lastResumeAt = now; + if (now - lastAppResumeAt < RESUME_DEDUP_WINDOW_MS) return; + lastAppResumeAt = now; checkAssistant(); + // App-resume means the renderer became visible; if a connection + // is already live, it was either never torn down or just opened + // moments ago — either way, leave it alone. if (current) return; open(); - }); + }; + // System-level resume (Electron host): bounce the connection + // UNCONDITIONALLY. The renderer may have stayed visible during + // system sleep (tray-resident, full-screen) so there's no + // app.hidden → app.resume cycle; `current` is still non-null + // but the socket may be half-dead because the remote side + // TCP-RST'd while we slept. Self-dedups against close-together + // `power.resume` + `power.unlock` (sleep → wake → unlock). + // Independent from `lastAppResumeAt` because an `app.resume` + // no-op (current non-null) MUST NOT suppress a power-driven + // bounce — that was the bug this PR exists to fix. + const handlePowerResume = () => { + const now = Date.now(); + if (now - lastPowerActionAt < RESUME_DEDUP_WINDOW_MS) return; + lastPowerActionAt = now; + checkAssistant(); + teardown(); + open(); + }; + const teardownIfOpen = () => { + if (!current) return; + teardown(); + }; + const unsubHidden = bus.subscribe("app.hidden", teardownIfOpen); + // System-level suspend: gracefully close the SSE so the daemon + // sees us go away cleanly instead of waiting for TCP timeouts. + // The resume / unlock handlers above will reopen on wake; if the + // teardown here is missed (suspend events occasionally drop on + // macOS), the bounce-on-resume path still recovers a half-dead + // socket. + const unsubPowerSuspend = bus.subscribe("power.suspend", teardownIfOpen); + const unsubResume = bus.subscribe("app.resume", handleAppResume); + const unsubPowerResume = bus.subscribe("power.resume", handlePowerResume); + const unsubPowerUnlock = bus.subscribe("power.unlock", handlePowerResume); const unsubReachabilityRetry = bus.subscribe( "reachability.retry-requested", () => { @@ -212,7 +286,10 @@ export function useEventBusInit({ return () => { cancelled = true; unsubHidden(); + unsubPowerSuspend(); unsubResume(); + unsubPowerResume(); + unsubPowerUnlock(); unsubReachabilityRetry(); current?.cancel(); current = null; diff --git a/apps/web/src/runtime/is-electron.ts b/apps/web/src/runtime/is-electron.ts index 0165f7574b7..79602f90d09 100644 --- a/apps/web/src/runtime/is-electron.ts +++ b/apps/web/src/runtime/is-electron.ts @@ -50,6 +50,13 @@ declare global { setBadge(count: number): Promise; setSignedIn(signedIn: boolean): Promise; }; + power: { + onEvent( + callback: (event: { + kind: "suspend" | "resume" | "lock" | "unlock" | "active"; + }) => void, + ): () => void; + }; }; } } diff --git a/apps/web/src/runtime/power-events.ts b/apps/web/src/runtime/power-events.ts new file mode 100644 index 00000000000..91dfaa4d9da --- /dev/null +++ b/apps/web/src/runtime/power-events.ts @@ -0,0 +1,40 @@ +import { isElectron } from "@/runtime/is-electron"; + +/** + * Per-capability wrapper for the Electron host's system power-state + * bridge — sleep, wake, screen lock/unlock, idle-recover. Matches the + * shape in `dock.ts` and `app-info.ts`: feature code never touches + * `window.vellum.*` directly, and the cross-platform branch lives here. + * + * Off Electron (web build, Capacitor iOS): `subscribeToPowerEvents` + * is a no-op that returns an unsubscribe-noop. Web has its own + * resume-detection signals (visibility, online, app-state) and they + * already feed the bus via `use-event-bus-init`; the system-level + * power signal is Electron-specific. + * + * The bus integration in `use-event-bus-init` calls this once at + * mount, narrowing the kind into `power.suspend` / `power.resume` + * / `power.lock` / `power.unlock` / `power.active` events on the + * shared bus. Domain consumers (SSE reconnect, auth refresh, + * reachability probe) subscribe to bus events — NOT to this + * wrapper directly — so the same subscriber code works on web, + * iOS, and Electron. + */ + +export type PowerEventKind = + | "suspend" + | "resume" + | "lock" + | "unlock" + | "active"; + +export interface PowerEvent { + kind: PowerEventKind; +} + +export function subscribeToPowerEvents( + callback: (event: PowerEvent) => void, +): () => void { + if (!isElectron()) return () => undefined; + return window.vellum?.power.onEvent(callback) ?? (() => undefined); +} diff --git a/apps/web/src/stores/event-bus-store.ts b/apps/web/src/stores/event-bus-store.ts index 47aa12b26ed..f174f4101c1 100644 --- a/apps/web/src/stores/event-bus-store.ts +++ b/apps/web/src/stores/event-bus-store.ts @@ -89,6 +89,24 @@ export interface BusEventMap { "app.online": Record; /** Browser reported the network went away. */ "app.offline": Record; + /** + * System-level power events from the Electron host. Distinct from + * `app.resume` / `app.hidden` because a tray-resident or + * full-screen Electron app stays "visible" during system sleep — + * the renderer never sees `visibilitychange`, but `powerMonitor` + * does. Long-running consumers (SSE, WebSockets, refresh timers) + * use these to bounce-and-reconnect because browser timers freeze + * during system suspend and sockets may appear "open" but be + * half-dead on wake. + * + * Off Electron (web build, Capacitor iOS) these never fire — the + * platform's resume signals come through `app.resume` instead. + */ + "power.suspend": Record; + "power.resume": Record; + "power.lock": Record; + "power.unlock": Record; + "power.active": Record; } export type BusEventName = keyof BusEventMap;