diff --git a/src/main/ipc/runtime-environments/runtime-environment-request-connections.ts b/src/main/ipc/runtime-environments/runtime-environment-request-connections.ts index e866d2a3b25..78f09b3dc3d 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-request-connections.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-request-connections.ts @@ -313,7 +313,7 @@ function getSharedControlConnection( sharedControlConnections.set(environmentId, cached) const entry = cached // Why ready is enough: a socket reaches ready only on its own server frame, which stamps it. - setSharedControlAnswerSource(environmentId, () => + setSharedControlAnswerSource(environmentId, entry.connection, () => entry.connection.getDiagnostics().state === 'ready' ? entry.answeredAt() : null ) } diff --git a/src/main/ipc/runtime-environments/runtime-environment-shared-control-answers.ts b/src/main/ipc/runtime-environments/runtime-environment-shared-control-answers.ts index 5b310e9294d..992c22704cb 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-shared-control-answers.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-shared-control-answers.ts @@ -1,14 +1,33 @@ /** - * When each environment's ready control socket last heard from its server. A leaf module: the - * serving gate reads it without importing the connection cache, which imports the gate back. + * Each environment's control socket and when it last heard from its server while ready. A leaf + * module: the serving gate reads it without importing the connection cache, which imports the gate back. */ -const sources = new Map number | null>() +export type SharedControlSocket = { + isReady(): boolean + readonly socketGeneration: { peek(): number } + request(method: string, params: unknown, timeoutMs: number): Promise +} + +/** A ready control socket; `socket` changes whenever it reconnects or the connection is replaced. */ +export type ReadySharedControlSocket = { + environmentId: string + socket: string + request: SharedControlSocket['request'] +} + +const sources = new Map< + string, + { connection: SharedControlSocket; answeredAt: () => number | null; instance: number } +>() +// Why: a replacement connection restarts its socket generation, so the key needs the instance too. +let nextInstance = 0 export function setSharedControlAnswerSource( environmentId: string, - source: () => number | null + connection: SharedControlSocket, + answeredAt: () => number | null ): void { - sources.set(environmentId, source) + sources.set(environmentId, { connection, answeredAt, instance: ++nextInstance }) } export function clearSharedControlAnswerSource(environmentId: string): void { @@ -17,5 +36,21 @@ export function clearSharedControlAnswerSource(environmentId: string): void { /** Null while there is no ready control socket for the environment. */ export function getRemoteRuntimeSharedControlAnsweredAt(environmentId: string): number | null { - return sources.get(environmentId)?.() ?? null + return sources.get(environmentId)?.answeredAt() ?? null +} + +/** Every ready control socket, without opening one. */ +export function listReadySharedControlSockets(): ReadySharedControlSocket[] { + return [...sources.entries()].flatMap(([environmentId, { connection, instance }]) => + connection.isReady() + ? [ + { + environmentId, + socket: `${instance}:${connection.socketGeneration.peek()}`, + request: (method: string, params: unknown, timeoutMs: number) => + connection.request(method, params, timeoutMs) + } + ] + : [] + ) } diff --git a/src/main/notifications/desktop-presence-reporter.test.ts b/src/main/notifications/desktop-presence-reporter.test.ts new file mode 100644 index 00000000000..2f8873343fc --- /dev/null +++ b/src/main/notifications/desktop-presence-reporter.test.ts @@ -0,0 +1,82 @@ +import { expect, it, vi } from 'vitest' +import { DESKTOP_PRESENCE_REPORT_INTERVAL_MS } from '../../shared/desktop-client-presence' +import { + clearSharedControlAnswerSource, + listReadySharedControlSockets, + setSharedControlAnswerSource +} from '../ipc/runtime-environments/runtime-environment-shared-control-answers' +import { createDesktopPresenceReporter } from './desktop-presence-reporter' + +it('reports on change, on a new socket and as a keepalive, and never reports unknown as present', async () => { + let away: boolean | undefined = false + let now = 0 + let socket = '1' + const request = vi.fn(async (_method: string, _params: unknown, _timeoutMs: number) => ({ + ok: true + })) + const listTargets = vi.fn(() => [{ environmentId: 'ssh-host', socket, request }]) + const reporter = createDesktopPresenceReporter({ + readAway: () => away, + listTargets, + now: () => now + }) + const tick = reporter.tick + const reported = () => request.mock.calls.map((call) => call[1]) + + tick() + tick() + expect(listTargets).toHaveBeenCalledWith('notifications.desktop-presence.v1') + expect(reported()).toEqual([{ away: false }]) + away = undefined + tick() + expect(reported()).toEqual([{ away: false }, { away: true }]) + now += DESKTOP_PRESENCE_REPORT_INTERVAL_MS + tick() + expect(reported()).toHaveLength(3) + away = false + socket = '2' + now += 1 + tick() + socket = '3' + tick() + expect(reported()).toEqual([ + { away: false }, + { away: true }, + { away: true }, + { away: false }, + { away: false } + ]) + + request.mockRejectedValueOnce(new Error('socket closed')) + socket = '4' + tick() + await Promise.resolve() + await Promise.resolve() + tick() + expect(reported()).toHaveLength(7) + // Suspend reports away at once and holds it while the idle clock still reads active. + reporter.setForcedAway(true) + expect(reported().at(-1)).toEqual({ away: true }) + now += DESKTOP_PRESENCE_REPORT_INTERVAL_MS + tick() + expect(reported().at(-1)).toEqual({ away: true }) + reporter.setForcedAway(false) + expect(reported().at(-1)).toEqual({ away: false }) +}) + +it('keys a replaced connection apart from the one it replaced, though both start at generation 1', () => { + const connection = () => ({ + isReady: () => true, + socketGeneration: { peek: () => 1 }, + request: async () => ({ ok: true }) + }) + try { + setSharedControlAnswerSource('replaced-host', connection(), () => null) + const [before] = listReadySharedControlSockets() + setSharedControlAnswerSource('replaced-host', connection(), () => null) + const [after] = listReadySharedControlSockets() + expect(after.socket).not.toBe(before.socket) + } finally { + clearSharedControlAnswerSource('replaced-host') + } +}) diff --git a/src/main/notifications/desktop-presence-reporter.ts b/src/main/notifications/desktop-presence-reporter.ts new file mode 100644 index 00000000000..3977ed7ebd5 --- /dev/null +++ b/src/main/notifications/desktop-presence-reporter.ts @@ -0,0 +1,113 @@ +import { + DESKTOP_PRESENCE_REPORT_INTERVAL_MS, + NOTIFICATIONS_DESKTOP_PRESENCE_RUNTIME_CAPABILITY +} from '../../shared/desktop-client-presence' +import { getRuntimeEnvironmentStatusSnapshots } from '../ipc/runtime-environments/runtime-environment-request-connections' +import { + listReadySharedControlSockets, + type ReadySharedControlSocket +} from '../ipc/runtime-environments/runtime-environment-shared-control-answers' + +/** How often local idle state is sampled, so a return to the desk reaches hosts within seconds. */ +const SAMPLE_INTERVAL_MS = 5_000 +const REPORT_TIMEOUT_MS = 10_000 + +type Sent = { socket: string; away: boolean; at: number } + +export type DesktopPresenceReporterDeps = { + readAway: () => boolean | undefined + listTargets: (capability: string) => ReadySharedControlSocket[] + now?: () => number +} + +/** + * Tells each connected host whether someone is at this desktop, so a headless host can honor a + * phone's "only when desktop is away" (#31570). Sent on change, on a new socket, and as a keepalive. + */ +export type DesktopPresenceReporter = { + tick(): void + /** Latched across ticks, so a sample between suspend and sleep cannot report present. */ + setForcedAway(forced: boolean): void +} + +export function createDesktopPresenceReporter( + deps: DesktopPresenceReporterDeps +): DesktopPresenceReporter { + const now = deps.now ?? Date.now + const sent = new Map() + let forcedAway = false + const tick = (): void => { + const targets = deps.listTargets(NOTIFICATIONS_DESKTOP_PRESENCE_RUNTIME_CAPABILITY) + if (targets.length === 0) { + sent.clear() + return + } + // Why: unknown local presence must never silence a phone, so only a proven "active" is present. + const away = forcedAway || deps.readAway() !== false + const at = now() + const live = new Set(targets.map((target) => target.environmentId)) + for (const environmentId of sent.keys()) { + if (!live.has(environmentId)) { + sent.delete(environmentId) + } + } + for (const target of targets) { + const last = sent.get(target.environmentId) + if ( + last && + last.socket === target.socket && + last.away === away && + at - last.at < DESKTOP_PRESENCE_REPORT_INTERVAL_MS + ) { + continue + } + const entry = { socket: target.socket, away, at } + sent.set(target.environmentId, entry) + // Why: a refused report waits for the keepalive; only an unsent one retries on the next tick. + void target + .request('notifications.reportDesktopPresence', { away }, REPORT_TIMEOUT_MS) + .catch(() => { + if (sent.get(target.environmentId) === entry) { + sent.delete(target.environmentId) + } + }) + } + } + return { + tick, + setForcedAway: (forced) => { + forcedAway = forced + tick() + } + } +} + +/** Ready control sockets whose host's last status advertised `capability`. */ +export function listCapableSharedControlSockets(capability: string): ReadySharedControlSocket[] { + const capable = new Set( + getRuntimeEnvironmentStatusSnapshots() + .filter((snapshot) => snapshot.status?.capabilities?.includes(capability)) + .map((snapshot) => snapshot.environmentId) + ) + return listReadySharedControlSockets().filter((target) => capable.has(target.environmentId)) +} + +export function startDesktopPresenceReporter( + deps: DesktopPresenceReporterDeps & { + /** Sleep and screen lock report away at once and hold it until resume or unlock. */ + subscribePowerEvents: (handlers: { away: () => void; back: () => void }) => () => void + } +): () => void { + const reporter = createDesktopPresenceReporter(deps) + const timer = setInterval(reporter.tick, SAMPLE_INTERVAL_MS) + // Why: presence reporting must never hold the app open at quit. + timer.unref?.() + const unsubscribe = deps.subscribePowerEvents({ + away: () => reporter.setForcedAway(true), + back: () => reporter.setForcedAway(false) + }) + return () => { + clearInterval(timer) + unsubscribe() + } +} diff --git a/src/main/runtime/desktop-client-presence-tracker.ts b/src/main/runtime/desktop-client-presence-tracker.ts new file mode 100644 index 00000000000..b2139553e16 --- /dev/null +++ b/src/main/runtime/desktop-client-presence-tracker.ts @@ -0,0 +1,32 @@ +import { DESKTOP_PRESENCE_TTL_MS } from '../../shared/desktop-client-presence' + +/** The last presence each attached desktop connection reported to this host. */ +export class DesktopClientPresenceTracker { + private readonly reports = new Map() + + has(connectionId: string): boolean { + return this.reports.has(connectionId) + } + + report(connectionId: string, away: boolean, now: number): void { + this.reports.set(connectionId, { away, at: now }) + } + + forget(connectionId: string): void { + this.reports.delete(connectionId) + } + + /** True only on a fresh "present" report; silence, staleness and older desktops read as away. */ + anyPresent(now: number): boolean { + let present = false + for (const [connectionId, { away, at }] of this.reports) { + if (now - at >= DESKTOP_PRESENCE_TTL_MS) { + // Why prune: the next report re-registers the forget-on-close, so nothing can pile up. + this.reports.delete(connectionId) + } else if (!away) { + present = true + } + } + return present + } +} diff --git a/src/main/runtime/push/push-policy-pipeline.integration.test.ts b/src/main/runtime/push/push-policy-pipeline.integration.test.ts index 47ba42052f8..0e806205bfc 100644 --- a/src/main/runtime/push/push-policy-pipeline.integration.test.ts +++ b/src/main/runtime/push/push-policy-pipeline.integration.test.ts @@ -6,6 +6,9 @@ import { readDesktopAwayState } from '../../notifications/desktop-away-state' import { DeviceRegistry } from '../device-registry' import { RuntimeMobileNotificationController } from '../runtime-mobile-notification-controller' import { setRuntimeDesktopSurface } from '../runtime-desktop-surface' +import { RuntimeSubscriptionRegistry } from '../runtime-subscription-registry' +import { reportDesktopPresence } from '../rpc/methods/notifications' +import { DESKTOP_PRESENCE_TTL_MS } from '../../../shared/desktop-client-presence' import { DesktopPushService } from './desktop-push-service' import { PushUnregisterOutbox } from './push-unregister-outbox' import { createPushHostKeypair } from './push-host-challenge-fixtures' @@ -103,15 +106,55 @@ it('carries the native idle boundary through replay and push dispatch', async () ) }) -it('keeps headless presence unknown and legacy socket events readable', async () => { +it('decides headless away from attached desktops, so the away-only filter holds on orca serve', async () => { + const now = 1_800_000_000_000 + const clock = vi.spyOn(Date, 'now').mockReturnValue(now) setRuntimeDesktopSurface(null) const h = await pipeline() - const events: unknown[] = [] - h.controller.onDispatched((event) => events.push(JSON.parse(JSON.stringify(event)))) - h.dispatch() - await flush() - expect(events[0]).not.toHaveProperty('desktopAway') - expect(h.client.send).toHaveBeenCalledTimes(1) + const subscriptions = new RuntimeSubscriptionRegistry() + const runtime = { + reportDesktopClientPresence: h.controller.reportDesktopClientPresence.bind(h.controller), + registerSubscriptionCleanup: subscriptions.register.bind(subscriptions) + } + const desktop: { + runtime: typeof runtime + clientKind?: 'mobile' | 'runtime' + connectionId: string + } = { runtime, clientKind: 'runtime', connectionId: 'desktop-socket' } + const report = ( + away: boolean, + context: Partial & { signal?: AbortSignal } = {} + ) => reportDesktopPresence({ away }, { ...desktop, ...context }) + const sends = async () => { + h.dispatch() + await flush() + return h.client.send.mock.calls.length + } + + // No desktop has ever reported: nobody is at one. + expect(await sends()).toBe(1) + // Neither a phone nor a socket that already closed can vouch for a desktop. + expect(report(false, { clientKind: 'mobile' })).toEqual({ recorded: false }) + expect(report(false, { signal: AbortSignal.abort() })).toEqual({ recorded: false }) + expect(await sends()).toBe(2) + report(false) + expect(await sends()).toBe(2) + report(true) + expect(await sends()).toBe(3) + report(false) + expect(await sends()).toBe(3) + // A desktop that stops reporting (or an older one that never did) lapses to away. + clock.mockReturnValue(now + DESKTOP_PRESENCE_TTL_MS) + expect(await sends()).toBe(4) + report(false) + expect(await sends()).toBe(4) + subscriptions.cleanupForConnection('desktop-socket') + expect(await sends()).toBe(5) + expect( + h.controller + .getMissedSince(0) + .map((event) => (event.type === 'notification' ? event.desktopAway : null)) + ).toEqual([true, true, false, true, false, true, false, true]) }) it('expires persisted registration at seven days despite host activity and renews explicitly', async () => { diff --git a/src/main/runtime/rpc/methods/notifications.ts b/src/main/runtime/rpc/methods/notifications.ts index cca473aa975..8959d0d4cca 100644 --- a/src/main/runtime/rpc/methods/notifications.ts +++ b/src/main/runtime/rpc/methods/notifications.ts @@ -1,8 +1,10 @@ import { createNotificationStreamFilter } from './notification-stream-policy' -import { defineStreamingMethod, defineMethod } from '../core' +import { defineStreamingMethod, defineMethod, type RpcContext } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' import { NotificationGetMissedSinceParams, NotificationRegisterPushParams, + NotificationReportDesktopPresenceParams, NotificationUnsubscribeParams, NotificationsSubscribeParams } from '../../../../shared/rpc-contract/notifications-params' @@ -12,6 +14,25 @@ import { // notifications.subscribe calls landed on the same millisecond. let notificationsSubscriptionSeq = 0 +type DesktopPresenceReportContext = Pick & { + runtime: Pick +} + +/** Only a paired desktop's own socket speaks for a desktop; the report lives as long as it. */ +export function reportDesktopPresence( + params: { away: boolean }, + { runtime, clientKind, connectionId, signal }: DesktopPresenceReportContext +): { recorded: boolean } { + // A closed socket already ran its cleanups, so a late report would never be forgotten. + if (clientKind !== 'runtime' || !connectionId || signal?.aborted) { + return { recorded: false } + } + runtime.reportDesktopClientPresence(connectionId, params.away, (forget) => + runtime.registerSubscriptionCleanup(`desktop-presence-${connectionId}`, forget, connectionId) + ) + return { recorded: true } +} + // Legacy callers retain filtered socket alerts; push clients opt into the full event stream. export const NOTIFICATION_METHODS = [ defineStreamingMethod({ @@ -76,6 +97,12 @@ export const NOTIFICATION_METHODS = [ } } }), + defineMethod({ + name: 'notifications.reportDesktopPresence', + permission: 'workspace', + params: NotificationReportDesktopPresenceParams, + handler: async (params, context) => reportDesktopPresence(params, context) + }), defineMethod({ name: 'notifications.registerPush', permission: 'pairing-admin', diff --git a/src/main/runtime/runtime-desktop-surface.ts b/src/main/runtime/runtime-desktop-surface.ts index 4738da82cd0..94b1d007ea0 100644 --- a/src/main/runtime/runtime-desktop-surface.ts +++ b/src/main/runtime/runtime-desktop-surface.ts @@ -18,8 +18,9 @@ import type { BrowserWindow, IpcMainEvent } from 'electron' export type RuntimeDesktopSurface = { /** Headless hosts retain the formatter's English defaults. */ translateNotification?(key: string, fallback: string): string - /** Show a native notification. Returns false when the host cannot, so callers can say so. */ + /** This machine's own idle state; undefined is unknown. Absent on hosts with no desktop. */ isAwayForMobileNotifications?(): boolean | undefined + /** Show a native notification. Returns false when the host cannot, so callers can say so. */ showNotification(input: { title: string; body: string }): boolean /** The renderer window with this id, or null when there is no desktop. */ findWindowById(id: number): BrowserWindow | null diff --git a/src/main/runtime/runtime-mobile-notification-controller.ts b/src/main/runtime/runtime-mobile-notification-controller.ts index 90792771dbe..3849466ca78 100644 --- a/src/main/runtime/runtime-mobile-notification-controller.ts +++ b/src/main/runtime/runtime-mobile-notification-controller.ts @@ -16,6 +16,7 @@ import { MobileNotificationReplayBuffer } from './mobile-notification-replay' import { notifyRuntimeListeners } from './runtime-async-boundaries' import { getRuntimeDesktopSurface } from './runtime-desktop-surface' import { MobileNotificationDismissalStore } from './mobile-notification-dismissal-store' +import { DesktopClientPresenceTracker } from './desktop-client-presence-tracker' import type { DeliveredNotificationIdentity } from '../../shared/mobile-notification-identity' export type MobileNotificationDispatchEvent = { @@ -66,6 +67,7 @@ export class RuntimeMobileNotificationController { private readonly replay = new MobileNotificationReplayBuffer() private pushRegistrar: MobilePushRegistrar | null = null private dismissalStore: MobileNotificationDismissalStore | null = null + private readonly desktopClients = new DesktopClientPresenceTracker() configureDismissalStore(userDataPath: string): void { this.dismissalStore = new MobileNotificationDismissalStore(userDataPath) @@ -107,6 +109,29 @@ export class RuntimeMobileNotificationController { return (await this.pushRegistrar?.unregister(deviceId)) ?? { unregistered: false } } + /** `bindForget` runs for a connection without a live report, before it is recorded. */ + reportDesktopClientPresence( + connectionId: string, + away: boolean, + bindForget: (forget: () => void) => void + ): void { + if (!this.desktopClients.has(connectionId)) { + bindForget(() => this.desktopClients.forget(connectionId)) + } + this.desktopClients.report(connectionId, away, Date.now()) + } + + // Why: undefined means unknown and must never silence a phone; only a fresh report or the local + // idle state proves someone is at a desktop. A host with no desktop of its own (orca serve) has + // only its attached desktops to ask, so their silence is away rather than unknown (#31570). + private resolveDesktopAway(): boolean | undefined { + if (this.desktopClients.anyPresent(Date.now())) { + return false + } + const surface = getRuntimeDesktopSurface() + return surface.isAwayForMobileNotifications ? surface.isAwayForMobileNotifications() : true + } + onDispatched(listener: (event: MobileNotificationEvent) => void): () => void { this.listeners.add(listener) return () => this.listeners.delete(listener) @@ -130,7 +155,7 @@ export class RuntimeMobileNotificationController { event = { ...event, legacySocketAllowed, - desktopAway: getRuntimeDesktopSurface().isAwayForMobileNotifications?.() + desktopAway: this.resolveDesktopAway() } } const seq = this.replay.record(event) diff --git a/src/main/runtime/runtime-service-command-surface.ts b/src/main/runtime/runtime-service-command-surface.ts index f0a66279f64..461090f7dbd 100644 --- a/src/main/runtime/runtime-service-command-surface.ts +++ b/src/main/runtime/runtime-service-command-surface.ts @@ -30,6 +30,7 @@ export type RuntimeServiceCommandSurface = { onNotificationDispatched: RuntimeMobileNotificationController['onDispatched'] getMobileNotificationListenerCount: RuntimeMobileNotificationController['getListenerCount'] dispatchMobileNotification: RuntimeMobileNotificationController['dispatch'] + reportDesktopClientPresence: RuntimeMobileNotificationController['reportDesktopClientPresence'] getMissedNotificationsSince: RuntimeMobileNotificationController['getMissedSince'] configureNotificationDismissalStore: RuntimeMobileNotificationController['configureDismissalStore'] closeNotificationDismissals: RuntimeMobileNotificationController['closeDismissals'] @@ -138,6 +139,7 @@ export function installRuntimeServiceCommandSurface( onNotificationDispatched: notifications.onDispatched.bind(notifications), getMobileNotificationListenerCount: notifications.getListenerCount.bind(notifications), dispatchMobileNotification: notifications.dispatch.bind(notifications), + reportDesktopClientPresence: notifications.reportDesktopClientPresence.bind(notifications), getMissedNotificationsSince: notifications.getMissedSince.bind(notifications), configureNotificationDismissalStore: notifications.configureDismissalStore.bind(notifications), closeNotificationDismissals: notifications.closeDismissals.bind(notifications), diff --git a/src/main/startup/main-process-observers-hang-breadcrumb.test.ts b/src/main/startup/main-process-observers-hang-breadcrumb.test.ts index 70f84476f3d..3f0d32593f1 100644 --- a/src/main/startup/main-process-observers-hang-breadcrumb.test.ts +++ b/src/main/startup/main-process-observers-hang-breadcrumb.test.ts @@ -37,6 +37,10 @@ vi.mock('../agent-awake-service', () => ({ vi.mock('../system-resume-broadcast', () => ({ registerSystemResumeBroadcast: () => () => {} })) +vi.mock('../notifications/desktop-presence-reporter', () => ({ + listCapableSharedControlSockets: () => [], + startDesktopPresenceReporter: () => () => {} +})) vi.mock('../agent-hooks/server', () => ({ agentHookServer: { subscribeStatusChanges: () => () => {}, diff --git a/src/main/startup/main-process-observers.ts b/src/main/startup/main-process-observers.ts index 2204de397e5..ed8084e4986 100644 --- a/src/main/startup/main-process-observers.ts +++ b/src/main/startup/main-process-observers.ts @@ -1,8 +1,13 @@ -import { app } from 'electron' +import { app, powerMonitor } from 'electron' import { join } from 'node:path' import { AgentAwakeService } from '../agent-awake-service' import { normalizeComputerAwakeMode } from '../../shared/computer-awake-mode' import { registerSystemResumeBroadcast } from '../system-resume-broadcast' +import { readDesktopAwayState } from '../notifications/desktop-away-state' +import { + listCapableSharedControlSockets, + startDesktopPresenceReporter +} from '../notifications/desktop-presence-reporter' import { agentHookServer } from '../agent-hooks/server' import { installHookStatusSessionTabsRepublish } from '../agent-hooks/hook-status-session-tabs-republish' import { initTelemetry, track } from '../telemetry/client' @@ -29,6 +34,22 @@ export function initializeMainProcessObservers(): void { throw new Error('Store must be initialized before observers') } state.unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast() + state.stopDesktopPresenceReporter = startDesktopPresenceReporter({ + readAway: () => readDesktopAwayState(powerMonitor), + listTargets: listCapableSharedControlSockets, + subscribePowerEvents: ({ away, back }) => { + powerMonitor.on('suspend', away) + powerMonitor.on('lock-screen', away) + powerMonitor.on('resume', back) + powerMonitor.on('unlock-screen', back) + return () => { + powerMonitor.off('suspend', away) + powerMonitor.off('lock-screen', away) + powerMonitor.off('resume', back) + powerMonitor.off('unlock-screen', back) + } + } + }) state.agentAwakeService = new AgentAwakeService() state.agentAwakeService.setMode( normalizeComputerAwakeMode( diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index e4be252abdd..12ce061671b 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -120,6 +120,8 @@ function installWillQuitHandler(): void { state.desktopPushService?.stop() state.unsubscribeSystemResumeBroadcast?.() state.unsubscribeSystemResumeBroadcast = null + state.stopDesktopPresenceReporter?.() + state.stopDesktopPresenceReporter = null // Why: renderer guards can still cancel before this committed phase; `log stream` must survive those vetoes. stopTccPromptNotice() const updateQuitInProgress = isQuittingForUpdate() diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts index 94f2b1bb0e2..09fa8b10fbc 100644 --- a/src/main/startup/main-process-state.ts +++ b/src/main/startup/main-process-state.ts @@ -103,6 +103,8 @@ export const mainProcessState = { crashReports: null as CrashReportStore | null, unsubscribeAgentAwakeStatusChanges: null as (() => void) | null, unsubscribeSystemResumeBroadcast: null as (() => void) | null, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: widens the null slot to the stop function assigned by main-process-observers. + stopDesktopPresenceReporter: null as (() => void) | null, watcherShutdownPromise: null as Promise | null, watcherShutdownDone: false, automations: null as AutomationService | null, diff --git a/src/shared/desktop-client-presence.ts b/src/shared/desktop-client-presence.ts new file mode 100644 index 00000000000..a5c9e7fbdee --- /dev/null +++ b/src/shared/desktop-client-presence.ts @@ -0,0 +1,10 @@ +// Why: an older host answers notifications.reportDesktopPresence with method-not-found, so a desktop +// reports only to a host advertising this; an older desktop never reports and reads as away. +export const NOTIFICATIONS_DESKTOP_PRESENCE_RUNTIME_CAPABILITY = + 'notifications.desktop-presence.v1' as const + +/** A desktop re-reports this often while connected, so the host can expire a silent one. */ +export const DESKTOP_PRESENCE_REPORT_INTERVAL_MS = 30_000 + +/** A report older than this no longer counts: three missed reports read as away. */ +export const DESKTOP_PRESENCE_TTL_MS = 3 * DESKTOP_PRESENCE_REPORT_INTERVAL_MS diff --git a/src/shared/mobile-runtime-capabilities.ts b/src/shared/mobile-runtime-capabilities.ts index 34ce8f765e9..b0571300d7d 100644 --- a/src/shared/mobile-runtime-capabilities.ts +++ b/src/shared/mobile-runtime-capabilities.ts @@ -1,3 +1,4 @@ +import { NOTIFICATIONS_DESKTOP_PRESENCE_RUNTIME_CAPABILITY } from './desktop-client-presence' import { MOBILE_RELAY_RUNTIME_CAPABILITIES } from './mobile-desktop-relay-contract' // Hosts without this capability have no notifications.registerPush RPC. @@ -6,5 +7,6 @@ export const NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY = 'notifications.remot export const MOBILE_RUNTIME_CAPABILITIES = [ 'mobile.tasks.v1', NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY, + NOTIFICATIONS_DESKTOP_PRESENCE_RUNTIME_CAPABILITY, ...MOBILE_RELAY_RUNTIME_CAPABILITIES ] as const diff --git a/src/shared/rpc-contract/notifications-params.ts b/src/shared/rpc-contract/notifications-params.ts index 6ae2cd45f5e..e436ffcfb67 100644 --- a/src/shared/rpc-contract/notifications-params.ts +++ b/src/shared/rpc-contract/notifications-params.ts @@ -59,3 +59,5 @@ export const NotificationRegisterPushParams = z export const NotificationsSubscribeParams = z .object({ includeDesktopSuppressed: z.boolean().optional() }) .optional() + +export const NotificationReportDesktopPresenceParams = z.object({ away: z.boolean() }) diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 311d0ae208a..9de0bee6e74 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -404,6 +404,7 @@ import { NativeChatSession, NativeChatUnsubscribe } from './native-chat-params' import { NotificationGetMissedSinceParams, NotificationRegisterPushParams, + NotificationReportDesktopPresenceParams, NotificationUnsubscribeParams, NotificationsSubscribeParams } from './notifications-params' @@ -1165,6 +1166,7 @@ export const RPC_PARAMS_BY_METHOD = { 'network.browserTunnel': BrowserNetworkTunnelAttachParams, 'notifications.getMissedSince': NotificationGetMissedSinceParams, 'notifications.registerPush': NotificationRegisterPushParams, + 'notifications.reportDesktopPresence': NotificationReportDesktopPresenceParams, 'notifications.subscribe': NotificationsSubscribeParams, 'notifications.testPush': null, 'notifications.unregisterPush': null,