diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 6dbfc02aa3c..3c32dda64d4 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -83,3 +83,17 @@ jobs: - name: Check formatting run: pnpm format:check + + native-dismissal: + runs-on: macos-15 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Check native dismissal ledger + run: | + swiftc mobile/modules/orca-notification-dismissal/ios/PushDismissalLedger.swift \ + mobile/modules/orca-notification-dismissal/tests/PushDismissalLedgerChecks.swift \ + -o "$RUNNER_TEMP/push-dismissal-ledger-checks" + "$RUNNER_TEMP/push-dismissal-ledger-checks" diff --git a/docs/reference/mobile-push-contract.md b/docs/reference/mobile-push-contract.md index c5ff8955dbf..68730b6f5f3 100644 --- a/docs/reference/mobile-push-contract.md +++ b/docs/reference/mobile-push-contract.md @@ -324,12 +324,13 @@ Secret Manager names (already exist in `onorca-cloud`): `orca-cloud-push-apns-ke controls native push registration. Hint: “Get agent alerts even when the app is closed. Delivered through Orca’s push service and Apple or Google.” Desktop category controls are authoritative and are not duplicated as phone overrides. Phone sound and viewing controls remain - independent. **Only when away from desktop** defaults on (180 seconds of OS input idle, - or locked). Unknown/headless presence does not suppress; it is never inferred from remote CPU + independent. Consent is stored only in `orca:pushNotificationsEnabled`; a missing preference + remains off, and obsolete test-build push keys do not grant consent. **Only when away from desktop** + defaults on (180 seconds of OS input idle, or locked). Unknown/headless presence does not suppress; it is never inferred from remote CPU activity. The detailed payload disclosure remains in the notification documentation. -- `notifications.delivery-policy.v1` advertises the away and mobile-inactivity lease policy. - Filter flags are optional and ignored by older hosts; the UI identifies paired hosts requiring - an update. Category mirroring and seven-day expiry are fixed product rules. +- `notifications.remote-push.v1` is the single push capability, including category mirroring, + away filtering and seven-day expiry. Settings retain pair/update guidance for hosts without + push support and leave unanswered probes unresolved. - All registrations receive a persisted seven-day `expiresAt` on the paired desktop. Delivery and transport retries exclude expired registrations. Only foreground mobile registration renews it: on connection, foreground return, and every 15 minutes while @@ -348,8 +349,9 @@ Secret Manager names (already exist in `onorca-cloud`): `orca-cloud-push-apns-ke stored host's `publicKeyB64`; then existing `getNotificationNavigationTarget` + `useOpenNotificationRoute`. - Reopen: subscribe to socket notifications for live dismissals, but ignore ordinary notification frames for banner presentation. Reconnect reconciliation sends identities currently in - the native tray and applies returned dismissal decisions; it does not replay notifications or create - banners. Live dismiss events also remove matching presented notifications. + the native tray in pages of 256 and applies only confirmed host/epoch/sequence identities; + it requests no historical events and creates no banners. The server replay RPC remains compatible + with independently updated clients. Live dismiss events also remove matching presented notifications. - Old host without the capability: nothing changes. ## Infra (`cloud/infra/terraform`, `.github/workflows`) @@ -381,9 +383,8 @@ alert messages, Live Activities, account-based quota tiers. ### Device delivery preferences -The desktop advertises `notifications.delivery-preferences.v1`. Completion detection remains active -when desktop notifications are off; semantic validity checks still precede delivery. IPC publishes -`desktopAllowed: false` when the desktop master or source/category switch rejects an event. That +Completion detection remains active when desktop notifications are off; semantic validity checks still +precede delivery. IPC publishes `desktopAllowed: false` when the desktop master or source/category switch rejects an event. That desktop category decision is authoritative for both desktop and phone alerts. Desktop focus and native authorization remain desktop-only presentation gates and do not change mobile eligibility. diff --git a/mobile/app/notifications.tsx b/mobile/app/notifications.tsx index 03c44b4c1d5..1d4f54dba08 100644 --- a/mobile/app/notifications.tsx +++ b/mobile/app/notifications.tsx @@ -156,13 +156,6 @@ export default function NotificationsScreen() { )} - {remotePushSupport.resolved && - remotePushSupport.supported && - !remotePushSupport.policySupported && ( - - Update your paired desktops to use the away and 7-day pause rules. - - )} ({ dismissHostPushNotification: vi.fn(async () => {}) })) vi.mock('./push-dismissal-reconciliation', () => ({ - requestNotificationCatchup: vi.fn(async () => ({ ok: true })) + requestNotificationCatchup: vi.fn(async () => {}) })) vi.mock('./notification-permissions', () => ({})) @@ -43,12 +43,7 @@ describe('subscribeToDesktopNotifications', () => { source: 'agent-task-complete' }) await Promise.resolve() - expect(requestNotificationCatchup).toHaveBeenCalledWith( - rpc, - 'host-1', - undefined, - expect.any(Function) - ) + expect(requestNotificationCatchup).toHaveBeenCalledWith(rpc, 'host-1', expect.any(Function)) expect(dismissHostPushNotification).not.toHaveBeenCalled() }) diff --git a/mobile/src/notifications/mobile-notifications.ts b/mobile/src/notifications/mobile-notifications.ts index 62f668871f2..974c9516263 100644 --- a/mobile/src/notifications/mobile-notifications.ts +++ b/mobile/src/notifications/mobile-notifications.ts @@ -36,7 +36,7 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin } // A max watermark asks only which delivered pushes are stale; socket history // never becomes a second OS-notification delivery route. - void requestNotificationCatchup(client, hostId, undefined, () => disposed).catch(() => {}) + void requestNotificationCatchup(client, hostId, () => disposed).catch(() => {}) return } if (!disposed && event.type === 'dismiss') { diff --git a/mobile/src/notifications/notification-routing.test.ts b/mobile/src/notifications/notification-routing.test.ts index 779aa2425e5..3dac953c85b 100644 --- a/mobile/src/notifications/notification-routing.test.ts +++ b/mobile/src/notifications/notification-routing.test.ts @@ -1,29 +1,10 @@ import { describe, expect, it } from 'vitest' import { - buildLocalNotificationData, getNotificationNavigationTarget, notificationCredentialRecoveryRoute } from './notification-routing' describe('notification routing', () => { - it('includes the host id in locally scheduled notification data', () => { - expect( - buildLocalNotificationData( - { - source: 'agent-task-complete', - worktreeId: 'repo::/Users/me/orca/workspaces/feature', - notificationId: 'agent:one' - }, - 'host-1' - ) - ).toEqual({ - source: 'agent-task-complete', - hostId: 'host-1', - worktreeId: 'repo::/Users/me/orca/workspaces/feature', - notificationId: 'agent:one' - }) - }) - // Identities stay raw: the target is dispatched as navigator params, not a URL. it('routes notification taps to the worktree terminal screen', () => { expect( diff --git a/mobile/src/notifications/notification-routing.ts b/mobile/src/notifications/notification-routing.ts index 5f81fb3567d..2136bd69f1b 100644 --- a/mobile/src/notifications/notification-routing.ts +++ b/mobile/src/notifications/notification-routing.ts @@ -2,21 +2,6 @@ import type { HostStackRouteTarget } from '../navigation/host-stack-navigation' import { mobileSessionRouteTarget } from '../session/mobile-session-route' import type { HostCredentialStatus } from '../transport/types' -export type DesktopNotificationSource = 'agent-task-complete' | 'terminal-bell' | 'test' - -export type DesktopNotificationEvent = { - source: DesktopNotificationSource - worktreeId?: string - notificationId?: string -} - -export type LocalNotificationData = { - source: DesktopNotificationSource - hostId: string - worktreeId?: string - notificationId?: string -} - export type NotificationNavigationOptions = { knownHostIds?: ReadonlySet credentialStatusByHostId?: ReadonlyMap @@ -26,23 +11,6 @@ function readNonEmptyString(value: unknown): string | null { return typeof value === 'string' && value.trim().length > 0 ? value : null } -export function buildLocalNotificationData( - event: DesktopNotificationEvent, - hostId: string -): LocalNotificationData { - const data: LocalNotificationData = { - source: event.source, - hostId - } - if (event.worktreeId) { - data.worktreeId = event.worktreeId - } - if (event.notificationId) { - data.notificationId = event.notificationId - } - return data -} - /** Where a tap should land. `sessionTarget` is null for a host-only notification, whose * `/h/` push is shallow enough to need no host-stack coordination. */ export type NotificationNavigationTarget = Readonly<{ diff --git a/mobile/src/notifications/push-dismissal-native-races.test.ts b/mobile/src/notifications/push-dismissal-native-races.test.ts index b20c48adfba..e2746963e0c 100644 --- a/mobile/src/notifications/push-dismissal-native-races.test.ts +++ b/mobile/src/notifications/push-dismissal-native-races.test.ts @@ -41,8 +41,7 @@ vi.mock('expo-notifications', () => ({ getPresentedNotificationsAsync: async () vi.mock('../transport/host-store', () => ({ loadHostCatalog: async () => [{ id: 'host' }] })) vi.mock('./push-host-fingerprint', () => ({ resolveHostIdForFingerprint: () => 'host' })) vi.mock('../storage/preferences', () => ({ - loadPushNotificationsEnabled: async () => true, - loadRemotePushEnabled: async () => true + loadPushNotificationsEnabled: async () => true })) vi.mock('./notification-viewing-policy', () => ({ shouldSuppressNotificationWhileViewing: async () => false diff --git a/mobile/src/notifications/push-dismissal-reconciliation.test.ts b/mobile/src/notifications/push-dismissal-reconciliation.test.ts index 2a9cf9b2ba6..f1e1d34c606 100644 --- a/mobile/src/notifications/push-dismissal-reconciliation.test.ts +++ b/mobile/src/notifications/push-dismissal-reconciliation.test.ts @@ -36,14 +36,9 @@ it('clears a confirmed prior-epoch alert even with empty replay and preserves ne ok: true, result: { notifications: [], epoch: 'new-process', dismissedPushes: [id] } })) - await requestNotificationCatchup( - { sendRequest } as never, - 'host-a', - { lastSeenSeq: 20 }, - () => false - ) + await requestNotificationCatchup({ sendRequest } as never, 'host-a', () => false) expect(sendRequest).toHaveBeenCalledWith('notifications.getMissedSince', { - lastSeenSeq: 20, + lastSeenSeq: Number.MAX_SAFE_INTEGER, deliveredPushes: [id, { ...id, notificationSeq: 14 }] }) expect(Notifications.dismissNotificationAsync).toHaveBeenCalledExactlyOnceWith('old') @@ -53,34 +48,43 @@ it('keeps alerts when an old host omits reconciliation or the request fails', as await requestNotificationCatchup( { sendRequest: async () => response } as never, 'host-a', - { lastSeenSeq: 0 }, () => false ) } expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled() }) it('ignores unrequested identities and a response arriving after disconnect', async () => { + let disposed = false const sendRequest = vi.fn(async () => ({ ok: true, - result: { dismissedPushes: [{ ...id, notificationSeq: 99 }] } + result: { + dismissedPushes: [ + { ...id, notificationSeq: 99 }, + { ...id, notificationEpoch: 'different-epoch' }, + { ...id, notificationId: 'different-alert' } + ] + } })) - await requestNotificationCatchup( - { sendRequest } as never, - 'host-a', - { lastSeenSeq: 0 }, - () => false - ) - sendRequest.mockResolvedValue({ ok: true, result: { dismissedPushes: [id] } }) - await requestNotificationCatchup( - { sendRequest } as never, - 'host-a', - { lastSeenSeq: 0 }, - () => true - ) + await requestNotificationCatchup({ sendRequest } as never, 'host-a', () => disposed) + sendRequest.mockImplementationOnce(async () => { + disposed = true + return { ok: true, result: { dismissedPushes: [id] } } + }) + await requestNotificationCatchup({ sendRequest } as never, 'host-a', () => disposed) + expect(sendRequest).toHaveBeenCalledTimes(2) expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled() }) -it('pages individual tray identities without replaying history twice', async () => { +it('skips the replay RPC when the tray has no alerts for this host', async () => { + vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([ + presented('other', { hostFingerprint: 'other-host' }) + ] as never) + const sendRequest = vi.fn() + await requestNotificationCatchup({ sendRequest } as never, 'host-a', () => false) + expect(sendRequest).not.toHaveBeenCalled() +}) + +it('pages individual tray identities without requesting historical alerts', async () => { const all = Array.from({ length: 288 }, (_, index) => ({ hostFingerprint, notificationId: `paged-${index}`, @@ -94,13 +98,11 @@ it('pages individual tray identities without replaying history twice', async () ok: true, result: { notifications: [], dismissedPushes: params.deliveredPushes ?? [] } })) - await requestNotificationCatchup( - { sendRequest } as never, - 'host-a', - { lastSeenSeq: 4 }, - () => false - ) + await requestNotificationCatchup({ sendRequest } as never, 'host-a', () => false) expect(sendRequest).toHaveBeenCalledTimes(2) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ + lastSeenSeq: Number.MAX_SAFE_INTEGER + }) expect(sendRequest.mock.calls[0]?.[1].deliveredPushes).toHaveLength(256) expect(sendRequest.mock.calls[1]?.[1]).toMatchObject({ lastSeenSeq: Number.MAX_SAFE_INTEGER, @@ -114,3 +116,30 @@ it('pages individual tray identities without replaying history twice', async () }) expect(vi.mocked(Notifications.dismissNotificationAsync)).toHaveBeenCalledTimes(288) }) + +it.each(['failure', 'disconnect'])( + 'stops after a second-page %s without removing unconfirmed alerts', + async (outcome) => { + vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue( + Array.from({ length: 513 }, (_, index) => + presented(`paged-${index}`, { notificationId: `paged-${index}`, notificationSeq: index }) + ) as never + ) + let disposed = false + let pages = 0 + const sendRequest = vi.fn( + async (_method: string, params: { deliveredPushes: (typeof id)[] }) => { + pages++ + disposed = pages === 2 && outcome === 'disconnect' + return { + ok: !(pages === 2 && outcome === 'failure'), + result: { dismissedPushes: params.deliveredPushes } + } + } + ) + await requestNotificationCatchup({ sendRequest } as never, 'host-a', () => disposed) + expect(sendRequest).toHaveBeenCalledTimes(2) + expect(Notifications.dismissNotificationAsync).toHaveBeenCalledTimes(256) + expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalledWith('paged-256') + } +) diff --git a/mobile/src/notifications/push-dismissal-reconciliation.ts b/mobile/src/notifications/push-dismissal-reconciliation.ts index 7269fdd7874..4c39ccfeb0f 100644 --- a/mobile/src/notifications/push-dismissal-reconciliation.ts +++ b/mobile/src/notifications/push-dismissal-reconciliation.ts @@ -42,35 +42,24 @@ async function readDelivered(hostId: string): Promise, hostId: string, - params: { lastSeenSeq: number; epoch?: string; includeDesktopSuppressed?: boolean } | undefined, isDisposed: () => boolean -) { - const delivered = await readDelivered(hostId) - if (!params && (delivered.size === 0 || isDisposed())) { - return { ok: true, result: { notifications: [] } } - } - const entries = [...delivered.entries()] - const response = await client.sendRequest('notifications.getMissedSince', { - // First pairing reconciles tray identities without requesting historical events. - ...(params ?? { lastSeenSeq: Number.MAX_SAFE_INTEGER }), - ...(delivered.size - ? { - deliveredPushes: entries - .slice(0, 256) - .map(([, payload]) => readPushNotificationIdentity(payload)!) - } - : {}) - }) - if (!response.ok || isDisposed()) { - return response - } - async function applyDismissals(reply: typeof response, requested: Map) { - if (!reply.ok) { +): Promise { + const entries = [...(await readDelivered(hostId)).entries()] + for (let offset = 0; offset < entries.length && !isDisposed(); offset += 256) { + const requested = new Map(entries.slice(offset, offset + 256)) + const reply = await client.sendRequest('notifications.getMissedSince', { + // Reconcile the tray without requesting historical alerts. + lastSeenSeq: Number.MAX_SAFE_INTEGER, + deliveredPushes: [...requested.values()].map((payload) => + readPushNotificationIdentity(payload)! + ) + }) + if (!reply.ok || isDisposed()) { return } const result = reply.result as { dismissedPushes?: unknown } | undefined if (!Array.isArray(result?.dismissedPushes)) { - return + continue } const confirmed: OrcaPushPayload[] = [] for (const raw of result.dismissedPushes.slice(0, 256)) { @@ -89,21 +78,4 @@ export async function requestNotificationCatchup( await dismissRememberedPushNotifications(confirmed[0]!.hostFingerprint, confirmed) } } - await applyDismissals(response, new Map(entries.slice(0, 256))) - // Page remaining tray identities without requesting historical events again. - for (let offset = 256; offset < entries.length && !isDisposed(); offset += 256) { - const requested = new Map(entries.slice(offset, offset + 256)) - try { - const reply = await client.sendRequest('notifications.getMissedSince', { - lastSeenSeq: Number.MAX_SAFE_INTEGER, - deliveredPushes: [...requested.values()].map((payload) => - readPushNotificationIdentity(payload)! - ) - }) - await applyDismissals(reply, requested) - } catch { - break - } - } - return response } diff --git a/mobile/src/notifications/push-preference-update.test.ts b/mobile/src/notifications/push-preference-update.test.ts index 8d3acdc0724..22894a96d76 100644 --- a/mobile/src/notifications/push-preference-update.test.ts +++ b/mobile/src/notifications/push-preference-update.test.ts @@ -37,7 +37,7 @@ beforeEach(() => { AppState.currentState = 'active' resetPushRegistrationForTests() storage.clear() - storage.set('orca:remotePushEnabled', 'true') + storage.set('orca:pushNotificationsEnabled', 'true') }) it('replaces an in-flight registration with the latest away and sound preferences', async () => { diff --git a/mobile/src/notifications/push-receive.test.ts b/mobile/src/notifications/push-receive.test.ts index c3871110d58..656e6b5b179 100644 --- a/mobile/src/notifications/push-receive.test.ts +++ b/mobile/src/notifications/push-receive.test.ts @@ -43,7 +43,6 @@ beforeEach(() => { vi.clearAllMocks() storage.clear() storage.set('orca:pushNotificationsEnabled', 'true') - storage.set('orca:remotePushEnabled', 'true') resetForegroundPushClaimsForTests() vi.mocked(loadHostCatalog).mockResolvedValue([ ...hosts, diff --git a/mobile/src/notifications/push-receive.ts b/mobile/src/notifications/push-receive.ts index 918e2ba9c97..66eec660a96 100644 --- a/mobile/src/notifications/push-receive.ts +++ b/mobile/src/notifications/push-receive.ts @@ -1,7 +1,7 @@ import { wasPushDismissed } from './push-dismissal-watermarks' import { dismissPresentedPushNotification } from './push-tray-dismissal' import { shouldSuppressNotificationWhileViewing } from './notification-viewing-policy' -import { loadPushNotificationsEnabled, loadRemotePushEnabled } from '../storage/preferences' +import { loadPushNotificationsEnabled } from '../storage/preferences' import { loadHostCatalog } from '../transport/host-store' import { resolveHostIdForFingerprint } from './push-host-fingerprint' import { readOrcaPushPayload, type OrcaPushPayload } from './push-payload' @@ -90,7 +90,7 @@ export async function shouldSuppressForegroundPush(data: unknown): Promise { vi.clearAllMocks() resetPushRegistrationForTests() storage.clear() - storage.set('orca:remotePushEnabled', 'true') + storage.set('orca:pushNotificationsEnabled', 'true') vi.mocked(getDevicePushToken).mockResolvedValue(token) }) diff --git a/mobile/src/notifications/push-registration.test.ts b/mobile/src/notifications/push-registration.test.ts index 1c3448de3c1..8840b4f6206 100644 --- a/mobile/src/notifications/push-registration.test.ts +++ b/mobile/src/notifications/push-registration.test.ts @@ -6,10 +6,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient, SendRequestOptions } from '../transport/rpc-client' import type { RpcResponse } from '../transport/types' import { - loadRemotePushEnabled, + loadPushNotificationsEnabled, loadRemotePushFilter, loadRemotePushHostRegistrations, - saveRemotePushEnabled, + savePushNotificationsEnabled, saveRemotePushHostRegistrations, type RemotePushHostRegistrations } from '../storage/preferences' @@ -24,8 +24,8 @@ import { } from './push-registration' vi.mock('../storage/preferences', () => ({ - loadRemotePushEnabled: vi.fn(), - saveRemotePushEnabled: vi.fn(), + loadPushNotificationsEnabled: vi.fn(), + savePushNotificationsEnabled: vi.fn(), loadRemotePushFilter: vi.fn(), loadRemotePushHostRegistrations: vi.fn(), saveRemotePushHostRegistrations: vi.fn() @@ -95,8 +95,8 @@ beforeEach(() => { enabled = false stored = { registeredHostIds: [], pendingUnregisterHostIds: [] } - vi.mocked(loadRemotePushEnabled).mockImplementation(async () => enabled) - vi.mocked(saveRemotePushEnabled).mockImplementation(async (value) => { + vi.mocked(loadPushNotificationsEnabled).mockImplementation(async () => enabled) + vi.mocked(savePushNotificationsEnabled).mockImplementation(async (value) => { enabled = value }) vi.mocked(loadRemotePushFilter).mockImplementation(async () => ({})) diff --git a/mobile/src/notifications/push-registration.ts b/mobile/src/notifications/push-registration.ts index d3e6ddb1587..09071cd558a 100644 --- a/mobile/src/notifications/push-registration.ts +++ b/mobile/src/notifications/push-registration.ts @@ -13,10 +13,10 @@ import type { import { NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' import type { RpcClient } from '../transport/rpc-client' import { - loadRemotePushEnabled, + loadPushNotificationsEnabled, loadRemotePushFilter, loadRemotePushHostRegistrations, - saveRemotePushEnabled, + savePushNotificationsEnabled, saveRemotePushHostRegistrations, type RemotePushFilter } from '../storage/preferences' @@ -162,7 +162,7 @@ async function reconcileHost(hostId: string): Promise { current.registered.delete(hostId) }) // A preference change can invalidate a register without disabling push. - if (!(await loadRemotePushEnabled())) { + if (!(await loadPushNotificationsEnabled())) { return } } @@ -179,7 +179,7 @@ async function reconcileHost(hostId: string): Promise { if (!state.supported || !isCurrent()) { return } - if (!(await loadRemotePushEnabled()) || AppState.currentState !== 'active') { + if (!(await loadPushNotificationsEnabled()) || AppState.currentState !== 'active') { return } const token = await currentToken() @@ -235,7 +235,7 @@ export function attachPushRegistration(hostId: string, client: PushClient): () = export async function setRemotePushEnabled(enabled: boolean): Promise { consentGeneration++ - await saveRemotePushEnabled(enabled) + await savePushNotificationsEnabled(enabled) await mutateRecords((current) => { if (!enabled) { for (const hostId of current.registered) { diff --git a/mobile/src/notifications/push-socket-dismissal.ts b/mobile/src/notifications/push-socket-dismissal.ts index 917aa9b0494..93226f296b2 100644 --- a/mobile/src/notifications/push-socket-dismissal.ts +++ b/mobile/src/notifications/push-socket-dismissal.ts @@ -1,4 +1,3 @@ -import { wasPushDismissed } from './push-dismissal-watermarks' import { loadHostCatalog } from '../transport/host-store' import { deriveHostFingerprint } from './push-host-fingerprint' import { dismissPresentedPushNotification } from './push-tray-dismissal' @@ -10,14 +9,6 @@ async function hostFingerprint(hostId: string): Promise { return host ? deriveHostFingerprint(host.publicKeyB64) : null } -export async function wasHostPushDismissed( - event: { notificationId?: string; notificationEpoch?: string; notificationSeq?: number }, - hostId: string -): Promise { - const fingerprint = await hostFingerprint(hostId) - return fingerprint ? wasPushDismissed({ ...event, hostFingerprint: fingerprint }) : false -} - export async function dismissHostPushNotification( event: DismissNotificationEvent, hostId: string diff --git a/mobile/src/notifications/use-remote-push-capable-hosts.test.tsx b/mobile/src/notifications/use-remote-push-capable-hosts.test.tsx index fd5ab17847e..cecde90218a 100644 --- a/mobile/src/notifications/use-remote-push-capable-hosts.test.tsx +++ b/mobile/src/notifications/use-remote-push-capable-hosts.test.tsx @@ -24,7 +24,6 @@ vi.mock('./push-registration', () => ({ })) const CAPABILITY = 'notifications.remote-push.v1' -const POLICY_CAPABILITY = 'notifications.delivery-policy.v1' type ClientEntry = { hostId: string; client: RpcClient; state: string } @@ -34,7 +33,7 @@ function clientFor(hostId: string): RpcClient { } let renderer: ReactTestRenderer | null = null -let latest: RemotePushHostSupport = { policySupported: false, supported: false, resolved: false } +let latest: RemotePushHostSupport = { supported: false, resolved: false } const answerByHostId = new Map void>() const stopProbe = vi.fn() @@ -68,7 +67,7 @@ async function answer(hostId: string, capabilities: readonly string[]): Promise< beforeEach(() => { vi.clearAllMocks() answerByHostId.clear() - latest = { policySupported: false, supported: false, resolved: false } + latest = { supported: false, resolved: false } vi.mocked(useAllHostClients).mockReturnValue([] as never) vi.mocked(startRuntimeCapabilityProbe).mockImplementation((client, onCapabilities) => { answerByHostId.set((client as unknown as { hostId: string }).hostId, onCapabilities) @@ -86,32 +85,6 @@ afterEach(() => { }) describe('useRemotePushCapableHosts', () => { - it('requires policy support from every paired host, including a legacy host after reconnect', async () => { - await mount() - const first = clientFor('host-1') - await setClients([ - { hostId: 'host-1', client: first, state: 'connected' }, - { hostId: 'host-2', client: clientFor('host-2'), state: 'connected' } - ]) - await answer('host-1', [CAPABILITY, POLICY_CAPABILITY]) - await answer('host-2', [CAPABILITY]) - expect(latest).toEqual({ policySupported: false, supported: true, resolved: true }) - - await setClients([ - { hostId: 'host-1', client: first, state: 'connected' }, - { hostId: 'host-2', client: clientFor('host-2'), state: 'connected' } - ]) - await answer('host-2', [CAPABILITY, POLICY_CAPABILITY]) - expect(latest).toEqual({ policySupported: true, supported: true, resolved: true }) - - await setClients([ - { hostId: 'host-1', client: first, state: 'connected' }, - { hostId: 'host-2', client: clientFor('host-2'), state: 'connected' } - ]) - await answer('host-2', [CAPABILITY]) - expect(latest).toEqual({ policySupported: false, supported: true, resolved: true }) - }) - it('stays unresolved when the host catalog cannot be read', async () => { vi.mocked(loadHostCatalog).mockRejectedValue(new Error('keychain locked')) @@ -119,7 +92,7 @@ describe('useRemotePushCapableHosts', () => { // Resolving here would render "Update your desktop app" at someone whose desktop // is already current, on the strength of a catalog read that simply failed. - expect(latest).toEqual({ policySupported: false, supported: false, resolved: false }) + expect(latest).toEqual({ supported: false, resolved: false }) }) it('waits for every connected host before answering', async () => { @@ -133,7 +106,7 @@ describe('useRemotePushCapableHosts', () => { expect(latest.resolved).toBe(false) await answer('host-2', ['some-other.v1']) - expect(latest).toEqual({ policySupported: false, supported: true, resolved: true }) + expect(latest).toEqual({ supported: true, resolved: true }) }) it('keeps the answer of a host that has since disconnected', async () => { @@ -144,7 +117,7 @@ describe('useRemotePushCapableHosts', () => { await setClients([{ hostId: 'host-1', client, state: 'connecting' }]) - expect(latest).toEqual({ policySupported: false, supported: true, resolved: true }) + expect(latest).toEqual({ supported: true, resolved: true }) }) it('resolves immediately when nothing is paired', async () => { @@ -152,7 +125,7 @@ describe('useRemotePushCapableHosts', () => { await mount() - expect(latest).toEqual({ policySupported: false, supported: false, resolved: true }) + expect(latest).toEqual({ supported: false, resolved: true }) }) it('leaves a running probe alone when another host changes state', async () => { @@ -186,6 +159,10 @@ describe('useRemotePushCapableHosts', () => { expect(stopProbe).toHaveBeenCalledTimes(1) expect(startRuntimeCapabilityProbe).toHaveBeenCalledTimes(2) + await answer('host-1', []) + expect(latest).toEqual({ supported: false, resolved: true }) + await answer('host-1', [CAPABILITY]) + expect(latest).toEqual({ supported: true, resolved: true }) }) it('ignores an answer from a host the catalog no longer lists', async () => { @@ -198,6 +175,6 @@ describe('useRemotePushCapableHosts', () => { // An unpaired desktop cannot push to this phone, so its vote must not offer // the switch — nor count as the answer that resolves the section. - expect(latest).toEqual({ policySupported: false, supported: false, resolved: false }) + expect(latest).toEqual({ supported: false, resolved: false }) }) }) diff --git a/mobile/src/notifications/use-remote-push-capable-hosts.ts b/mobile/src/notifications/use-remote-push-capable-hosts.ts index dbad0fe68e3..8c6b35c96ee 100644 --- a/mobile/src/notifications/use-remote-push-capable-hosts.ts +++ b/mobile/src/notifications/use-remote-push-capable-hosts.ts @@ -1,4 +1,3 @@ -import { NOTIFICATION_DELIVERY_POLICY_CAPABILITY } from '../../../src/shared/protocol-version' import { useEffect, useRef, useState } from 'react' import { loadHostCatalog } from '../transport/host-store' import type { RpcClient } from '../transport/rpc-client' @@ -8,7 +7,6 @@ import { NOTIFICATIONS_REMOTE_PUSH_CAPABILITY } from './push-registration' export type RemotePushHostSupport = { /** At least one paired host advertises `notifications.remote-push.v1`. */ - policySupported: boolean supported: boolean /** Whether the answer above is final rather than "nobody has replied yet". */ resolved: boolean @@ -23,9 +21,7 @@ export type RemotePushHostSupport = { export function useRemotePushCapableHosts(): RemotePushHostSupport { const [hostIds, setHostIds] = useState([]) const [hostsLoaded, setHostsLoaded] = useState(false) - const [supportedByHostId, setSupportedByHostId] = useState< - Record - >({}) + const [supportedByHostId, setSupportedByHostId] = useState>({}) const probesRef = useRef(new Map void }>()) useEffect(() => { @@ -77,10 +73,7 @@ export function useRemotePushCapableHosts(): RemotePushHostSupport { const stop = startRuntimeCapabilityProbe(client, (capabilities) => { setSupportedByHostId((previous) => ({ ...previous, - [hostId]: { - push: capabilities.includes(NOTIFICATIONS_REMOTE_PUSH_CAPABILITY), - policy: capabilities.includes(NOTIFICATION_DELIVERY_POLICY_CAPABILITY) - } + [hostId]: capabilities.includes(NOTIFICATIONS_REMOTE_PUSH_CAPABILITY) })) }) probes.set(hostId, { client, stop }) @@ -100,9 +93,7 @@ export function useRemotePushCapableHosts(): RemotePushHostSupport { const answeredHostIds = hostIds.filter((hostId) => hostId in supportedByHostId) return { - policySupported: - hostIds.length > 0 && hostIds.every((hostId) => supportedByHostId[hostId]?.policy), - supported: answeredHostIds.some((hostId) => supportedByHostId[hostId]?.push), + supported: answeredHostIds.some((hostId) => supportedByHostId[hostId]), // A connected host that has not answered yet is exactly the case the silence is // for, so one outstanding probe holds the whole section back. Disconnected hosts // do not: their earlier answer stands, and one that never answered never will. diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts index b636ea12d3e..f8e57d3e667 100644 --- a/mobile/src/storage/preferences.test.ts +++ b/mobile/src/storage/preferences.test.ts @@ -1,3 +1,4 @@ +import { subscribeNotificationConsent } from '../notifications/notification-consent-events' import AsyncStorage from '@react-native-async-storage/async-storage' import { beforeEach, describe, expect, it, vi } from 'vitest' import { @@ -279,7 +280,9 @@ describe('push notification preference', () => { }) it('distinguishes an unset preference from an explicit disabled choice', async () => { - vi.mocked(AsyncStorage.getItem).mockResolvedValue(null) + vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) => + key === 'orca:remotePushEnabled' ? 'true' : null + ) await expect(readPushNotificationsPreference()).resolves.toEqual({ value: null, loaded: true @@ -303,12 +306,24 @@ describe('push notification preference', () => { await expect(loadPushNotificationsEnabled()).resolves.toBe(false) }) - it('persists the onboarding decision in the existing mobile toggle', async () => { - await savePushNotificationsEnabled(true) - expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:pushNotificationsEnabled', 'true') - - await savePushNotificationsEnabled(false) - expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:pushNotificationsEnabled', 'false') + it('persists and reloads master consent and notifies listeners after each choice', async () => { + const storage = new Map() + vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) => storage.get(key) ?? null) + vi.mocked(AsyncStorage.setItem).mockImplementation(async (key, value) => { + storage.set(key, value) + }) + const changed = vi.fn() + const unsubscribe = subscribeNotificationConsent(changed) + try { + for (const enabled of [true, false]) { + await savePushNotificationsEnabled(enabled) + await expect(loadPushNotificationsEnabled()).resolves.toBe(enabled) + } + expect([...storage]).toEqual([['orca:pushNotificationsEnabled', 'false']]) + expect(changed).toHaveBeenCalledTimes(2) + } finally { + unsubscribe() + } }) }) diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index 4c87c89a438..0cbc3396d2c 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -34,32 +34,13 @@ export async function loadPushNotificationsEnabled(): Promise { export async function savePushNotificationsEnabled(enabled: boolean): Promise { await AsyncStorage.setItem(NOTIF_KEY, String(enabled)) - await AsyncStorage.setItem(REMOTE_PUSH_KEY, String(enabled)) notifyNotificationConsentChanged() } -// Retained for older mobile builds; the master preference owns both delivery paths. -const REMOTE_PUSH_KEY = 'orca:remotePushEnabled' const REMOTE_PUSH_HOST_REGISTRATIONS_KEY = 'orca:remotePushHostRegistrations' export type RemotePushFilter = MobilePushFilter -export async function loadRemotePushEnabled(): Promise { - try { - const preference = await readPushNotificationsPreference() - if (!preference.loaded) { - return false - } - return preference.value ?? (await AsyncStorage.getItem(REMOTE_PUSH_KEY)) === 'true' - } catch { - return false - } -} - -export async function saveRemotePushEnabled(enabled: boolean): Promise { - await savePushNotificationsEnabled(enabled) -} - export async function loadRemotePushFilter(): Promise { return notificationPreferencesFilter(await loadNotificationDeliveryPreferences()) } diff --git a/src/main/runtime/push/desktop-push-service.test.ts b/src/main/runtime/push/desktop-push-service.test.ts index 17cf6109730..98a2275a975 100644 --- a/src/main/runtime/push/desktop-push-service.test.ts +++ b/src/main/runtime/push/desktop-push-service.test.ts @@ -65,9 +65,7 @@ function createService( deleteDevice: vi.fn(async (registrationId: string) => { deletes.push(registrationId) options.onDelete?.(registrationId) - return options.deleteFails - ? { deleted: false, retryable: true } - : { deleted: true, retryable: false } + return !options.deleteFails }), send: vi.fn(async () => ({ ok: true, results: [] }) as const) } diff --git a/src/main/runtime/push/desktop-push-service.ts b/src/main/runtime/push/desktop-push-service.ts index ee3188668f6..5037a1bbbaa 100644 --- a/src/main/runtime/push/desktop-push-service.ts +++ b/src/main/runtime/push/desktop-push-service.ts @@ -254,8 +254,8 @@ export class DesktopPushService { if (!this.outbox.pending().some((item) => item.reqId === reqId)) { return true } - const result = await this.client.deleteDevice(registrationId) - if (!result.deleted) { + const deleted = await this.client.deleteDevice(registrationId) + if (!deleted) { return false } this.outbox.remove(reqId) diff --git a/src/main/runtime/push/push-cleanup-auth-expiry.test.ts b/src/main/runtime/push/push-cleanup-auth-expiry.test.ts index b746a03a002..dc7ce5e1883 100644 --- a/src/main/runtime/push/push-cleanup-auth-expiry.test.ts +++ b/src/main/runtime/push/push-cleanup-auth-expiry.test.ts @@ -36,6 +36,6 @@ it('retains a delete when its session proof expires before the DELETE is attempt return new Response(null, { status: 204 }) }) as typeof fetch }) - expect(await client.deleteDevice('registration-1')).toEqual({ deleted: false, retryable: true }) + expect(await client.deleteDevice('registration-1')).toEqual(false) expect(deletes).toBe(0) }) diff --git a/src/main/runtime/push/push-dispatcher.test.ts b/src/main/runtime/push/push-dispatcher.test.ts index 85c1737e163..47373045f28 100644 --- a/src/main/runtime/push/push-dispatcher.test.ts +++ b/src/main/runtime/push/push-dispatcher.test.ts @@ -103,49 +103,6 @@ describe('PushDispatcher', () => { expect(harness.sends).toHaveLength(0) }) - it('ignores obsolete category filters on existing registrations', async () => { - const harness = createHarness({ - devices: [ - { - deviceId: 'first-phone', - pushRegistration: registration({ - registrationId: 'reg-needs' - }) - }, - { - deviceId: 'second-phone', - pushRegistration: registration({ - registrationId: 'reg-bell' - }) - }, - { deviceId: 'everything', pushRegistration: registration({ registrationId: 'reg-all' }) } - ] - }) - - harness.dispatcher.enqueue(notification({ agentState: 'blocked' })) - await flush() - - expect(harness.sends[0]?.registrationIds).toEqual(['reg-needs', 'reg-bell', 'reg-all']) - }) - - it('pushes a desktop-eligible bell despite an obsolete empty agent-state filter', async () => { - const harness = createHarness({ - devices: [ - { - deviceId: 'a', - pushRegistration: registration() - } - ] - }) - - harness.dispatcher.enqueue( - notification({ source: 'terminal-bell', agentState: undefined, title: 'Bell in x' }) - ) - await flush() - - expect(harness.sends[0]?.notification.agentState).toBeNull() - }) - it('drops a registration the gateway reports dead', async () => { const harness = createHarness({ devices: [ diff --git a/src/main/runtime/push/push-gateway-client.test.ts b/src/main/runtime/push/push-gateway-client.test.ts index ac66ce27436..0707f686fae 100644 --- a/src/main/runtime/push/push-gateway-client.test.ts +++ b/src/main/runtime/push/push-gateway-client.test.ts @@ -227,17 +227,14 @@ describe('PushGatewayClient', () => { const gateway = createFakeGateway() await gateway.client.registerDevice(REGISTER_INPUT) - expect(await gateway.client.deleteDevice('reg-1')).toEqual({ deleted: true, retryable: false }) + expect(await gateway.client.deleteDevice('reg-1')).toEqual(true) expect(gateway.calls.at(-1)).toMatchObject({ method: 'DELETE' }) }) it('treats a delete of an unknown registration as done', async () => { const gateway = createFakeGateway() - expect(await gateway.client.deleteDevice('reg-gone')).toEqual({ - deleted: true, - retryable: false - }) + expect(await gateway.client.deleteDevice('reg-gone')).toEqual(true) }) it('reports a 401 that survives the forced re-auth as unreachable', async () => { @@ -254,6 +251,6 @@ describe('PushGatewayClient', () => { it('keeps an unreachable-classified 401 retryable for a queued delete', async () => { const gateway = createFakeGateway({ rejectBearer: true }) - expect(await gateway.client.deleteDevice('reg-1')).toEqual({ deleted: false, retryable: true }) + expect(await gateway.client.deleteDevice('reg-1')).toEqual(false) }) }) diff --git a/src/main/runtime/push/push-gateway-client.ts b/src/main/runtime/push/push-gateway-client.ts index 3560d7421da..33ea76e7164 100644 --- a/src/main/runtime/push/push-gateway-client.ts +++ b/src/main/runtime/push/push-gateway-client.ts @@ -97,18 +97,16 @@ export class PushGatewayClient { return parsed.ok ? { ok: true, registrationId: parsed.value.registrationId } : parsed } - /** `retryable` tells the outbox whether to keep the delete queued. */ - async deleteDevice(registrationId: string): Promise<{ deleted: boolean; retryable: boolean }> { + async deleteDevice(registrationId: string): Promise { const response = await this.authorized(`/v1/devices/${encodeURIComponent(registrationId)}`, { method: 'DELETE' }) if (!response.ok) { - return { deleted: false, retryable: true } + return false } await cancelUnreadResponseBody(response.response) // A gateway that no longer knows the registration is as deleted as it gets. - const gone = response.response.ok || response.response.status === 404 - return { deleted: gone, retryable: !gone } + return response.response.ok || response.response.status === 404 } async send(input: { 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 514edf0427c..47ba42052f8 100644 --- a/src/main/runtime/push/push-policy-pipeline.integration.test.ts +++ b/src/main/runtime/push/push-policy-pipeline.integration.test.ts @@ -32,7 +32,7 @@ async function pipeline() { const controller = new RuntimeMobileNotificationController() const client = { registerDevice: vi.fn(async () => ({ ok: true, registrationId: 'policy-registration' })), - deleteDevice: vi.fn(async () => ({ deleted: true, retryable: false })), + deleteDevice: vi.fn(async () => true), send: vi.fn(async () => ({ ok: true, results: [] })) } const service = DesktopPushService.create({ diff --git a/src/main/runtime/push/push-registration-races.test.ts b/src/main/runtime/push/push-registration-races.test.ts index a7acf79309b..b146a63384b 100644 --- a/src/main/runtime/push/push-registration-races.test.ts +++ b/src/main/runtime/push/push-registration-races.test.ts @@ -36,10 +36,10 @@ function harness() { }), deleteDevice: vi.fn(async () => { if (!reachable) { - return { deleted: false, retryable: true } + return false } live = false - return { deleted: true, retryable: false } + return true }), send: vi.fn() } diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index ff3733bcb68..948bbd77a91 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -196,12 +196,7 @@ export const AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE = 'Editing automations on this host requires a newer Orca server. Update the HUB and try again.' export const AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = 'automation.create-idempotency.v1' as const -// Why: registered on every build, so it is a STATIC capability. Mobile hides its -// background-notification settings entirely unless a paired host advertises it — -// an older host has no notifications.registerPush to call. -export const NOTIFICATION_DELIVERY_PREFERENCES_CAPABILITY = - 'notifications.delivery-preferences.v1' as const -export const NOTIFICATION_DELIVERY_POLICY_CAPABILITY = 'notifications.delivery-policy.v1' as const +// Hosts without this capability have no notifications.registerPush RPC. export const NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY = 'notifications.remote-push.v1' as const // Generic native clients include the CLI and must not claim Electron-only page @@ -300,9 +295,7 @@ export const RUNTIME_CAPABILITIES = [ AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, - NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY, - NOTIFICATION_DELIVERY_PREFERENCES_CAPABILITY, - NOTIFICATION_DELIVERY_POLICY_CAPABILITY + NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY ] as const export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {})