mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(push): honor "only when desktop is away" on headless orca serve (#31956)
* fix(push): decide orca serve's desktop-away from attached desktops (#31570) * fix(push): report away on sleep and lock, guard late reports and prune stale presence * chore: justify the presence-reporter state slot widening * fix(push): hold away from suspend or lock until resume or unlock * fix(push): key presence sockets by connection instance so a replaced connection reports at once
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string, () => number | null>()
|
||||
export type SharedControlSocket = {
|
||||
isReady(): boolean
|
||||
readonly socketGeneration: { peek(): number }
|
||||
request(method: string, params: unknown, timeoutMs: number): Promise<unknown>
|
||||
}
|
||||
|
||||
/** 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)
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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<string, Sent>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<string, { away: boolean; at: number }>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<typeof desktop> & { 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 () => {
|
||||
|
||||
@@ -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<RpcContext, 'clientKind' | 'connectionId' | 'signal'> & {
|
||||
runtime: Pick<OrcaRuntimeService, 'reportDesktopClientPresence' | 'registerSubscriptionCleanup'>
|
||||
}
|
||||
|
||||
/** 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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: () => () => {},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<void> | null,
|
||||
watcherShutdownDone: false,
|
||||
automations: null as AutomationService | null,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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() })
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user