fix(server,app,desktop): route in-app notifications by presence cascade
Replaces the per-client stale/visibility policy with one shared
presence cascade so a backgrounded Firefox tab can no longer steal
in-app notifications from an active Electron desktop window.
Server (`agent-attention-policy.ts`): exports `PRESENCE_THRESHOLD_MS`
(3 min) and `computeNotificationPlan({ allStates, agentId, reason,
nowMs })` returning `{ inAppRecipientIndex, shouldPush }`. Cascade:
focus suppression requires presence; otherwise the most-recent
present client wins (lower-index tiebreak); push fires only when no
client is present, and never on `error`. `broadcastAgentAttention`
calls the policy once per event.
Electron (`daemon-manager.ts`, `idle.ts`): registers
`desktop_get_system_idle_time` returning `powerMonitor
.getSystemIdleTime() * 1000`. Renderer wrapper
`getDesktopSystemIdleTimeMs()` is best-effort: rejected IPC, null,
NaN, Infinity, or negative values return null without throwing.
Hook (`use-client-activity.ts`): Electron-only effect polls every 5s
and updates `lastActivityAtRef` only forward, so OS-wide activity
keeps a backgrounded Electron window present while in-window events
still update immediately.
`ClientHeartbeatMessageSchema` is unchanged; old/new clients and
daemons remain wire-compatible.
Tests: 52 focused tests across policy unit, dispatch, e2e, hook,
desktop wrapper, and permissions. Phase-3 dispatch test +
Phase-5 stale-poll test verified by mutation (breaking the focus
check / the `>` guard makes the exact regression fail).
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { invokeDesktopCommandMock } = vi.hoisted(() => ({
|
||||
invokeDesktopCommandMock: vi.fn<() => Promise<unknown>>(async () => 12_000),
|
||||
}));
|
||||
|
||||
vi.mock("@/desktop/electron/invoke", () => ({
|
||||
invokeDesktopCommand: invokeDesktopCommandMock,
|
||||
}));
|
||||
|
||||
import { getDesktopSystemIdleTimeMs } from "./idle";
|
||||
|
||||
describe("getDesktopSystemIdleTimeMs", () => {
|
||||
afterEach(() => {
|
||||
invokeDesktopCommandMock.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("invokes the desktop idle command and returns the millisecond value", async () => {
|
||||
invokeDesktopCommandMock.mockResolvedValueOnce(4_200);
|
||||
|
||||
const idleTimeMs = await getDesktopSystemIdleTimeMs();
|
||||
|
||||
expect(invokeDesktopCommandMock).toHaveBeenCalledWith("desktop_get_system_idle_time");
|
||||
expect(idleTimeMs).toBe(4_200);
|
||||
});
|
||||
|
||||
it("returns null and logs once when IPC rejects", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const error = new Error("ipc failed");
|
||||
invokeDesktopCommandMock.mockRejectedValueOnce(error);
|
||||
|
||||
const idleTimeMs = await getDesktopSystemIdleTimeMs();
|
||||
|
||||
expect(idleTimeMs).toBeNull();
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn).toHaveBeenCalledWith("[DesktopIdle] Failed to read system idle time", error);
|
||||
});
|
||||
|
||||
it("returns null and logs once when IPC returns null", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
invokeDesktopCommandMock.mockResolvedValueOnce(null);
|
||||
|
||||
const idleTimeMs = await getDesktopSystemIdleTimeMs();
|
||||
|
||||
expect(idleTimeMs).toBeNull();
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn).toHaveBeenCalledWith("[DesktopIdle] Invalid system idle time", null);
|
||||
});
|
||||
|
||||
it("returns null and logs once when IPC returns NaN", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
invokeDesktopCommandMock.mockResolvedValueOnce(Number.NaN);
|
||||
|
||||
const idleTimeMs = await getDesktopSystemIdleTimeMs();
|
||||
|
||||
expect(idleTimeMs).toBeNull();
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn).toHaveBeenCalledWith("[DesktopIdle] Invalid system idle time", Number.NaN);
|
||||
});
|
||||
|
||||
it("returns null and logs once when IPC returns a negative value", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
invokeDesktopCommandMock.mockResolvedValueOnce(-1);
|
||||
|
||||
const idleTimeMs = await getDesktopSystemIdleTimeMs();
|
||||
|
||||
expect(idleTimeMs).toBeNull();
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn).toHaveBeenCalledWith("[DesktopIdle] Invalid system idle time", -1);
|
||||
});
|
||||
|
||||
it("returns 0 when IPC returns zero", async () => {
|
||||
invokeDesktopCommandMock.mockResolvedValueOnce(0);
|
||||
|
||||
const idleTimeMs = await getDesktopSystemIdleTimeMs();
|
||||
|
||||
expect(idleTimeMs).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
||||
|
||||
const DESKTOP_SYSTEM_IDLE_COMMAND = "desktop_get_system_idle_time";
|
||||
|
||||
function isValidIdleTimeMs(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
export async function getDesktopSystemIdleTimeMs(): Promise<number | null> {
|
||||
try {
|
||||
const idleTimeMs = await invokeDesktopCommand<unknown>(DESKTOP_SYSTEM_IDLE_COMMAND);
|
||||
if (!isValidIdleTimeMs(idleTimeMs)) {
|
||||
console.warn("[DesktopIdle] Invalid system idle time", idleTimeMs);
|
||||
return null;
|
||||
}
|
||||
return idleTimeMs;
|
||||
} catch (error) {
|
||||
console.warn("[DesktopIdle] Failed to read system idle time", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user