Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/macos/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -250,6 +251,7 @@ app
installAbout();
installApplicationMenu();
installDock();
installPowerEvents();
installTray({
toggleMainWindow: toggleMainWindowVisibility,
ensureMainWindow: ensureMainWindowVisible,
Expand Down
145 changes: 145 additions & 0 deletions apps/macos/src/main/power-events.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, PowerListener>();
const powerOnMock = mock((event: string, listener: PowerListener) => {
powerListeners.set(event, listener);
});

type SendMock = ReturnType<typeof mock>;
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" },
]);
});
});
87 changes: 87 additions & 0 deletions apps/macos/src/main/power-events.ts
Original file line number Diff line number Diff line change
@@ -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<Record<PowerEventKind, number>> = {};

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];
}
};
46 changes: 46 additions & 0 deletions apps/macos/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -86,6 +105,22 @@ export interface VellumBridge {
*/
setSignedIn(signedIn: boolean): Promise<void>;
};
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<never> =>
Expand Down Expand Up @@ -130,6 +165,17 @@ const bridge: VellumBridge = {
setSignedIn: (signedIn: boolean): Promise<void> =>
ipcRenderer.invoke("vellum:dock:setSignedIn", signedIn) as Promise<void>,
},
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);
Expand Down
16 changes: 16 additions & 0 deletions apps/web/docs/ELECTRON.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions apps/web/docs/EVENT_BUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading