refactor(push): remove redundant client state and replay options

This commit is contained in:
Jinwoo-H
2026-09-09 16:22:35 -04:00
parent bba1b53447
commit ce087ea433
30 changed files with 166 additions and 317 deletions
+14
View File
@@ -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"
+11 -10
View File
@@ -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.
-7
View File
@@ -156,13 +156,6 @@ export default function NotificationsScreen() {
)}
</View>
{remotePushSupport.resolved &&
remotePushSupport.supported &&
!remotePushSupport.policySupported && (
<Text style={styles.hint}>
Update your paired desktops to use the away and 7-day pause rules.
</Text>
)}
<NotificationDeliverySection
value={delivery}
disabled={saving || !switchEnabled}
@@ -7,7 +7,7 @@ vi.mock('./push-socket-dismissal', () => ({
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()
})
@@ -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') {
@@ -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(
@@ -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<string>
credentialStatusByHostId?: ReadonlyMap<string, HostCredentialStatus>
@@ -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/<id>` push is shallow enough to need no host-stack coordination. */
export type NotificationNavigationTarget = Readonly<{
@@ -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
@@ -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')
}
)
@@ -42,35 +42,24 @@ async function readDelivered(hostId: string): Promise<Map<string, OrcaPushPayloa
export async function requestNotificationCatchup(
client: Pick<RpcClient, 'sendRequest'>,
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<string, OrcaPushPayload>) {
if (!reply.ok) {
): Promise<void> {
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
}
@@ -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 () => {
@@ -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,
+2 -2
View File
@@ -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<boole
if (!hostId) {
return true
}
if (!(await loadPushNotificationsEnabled()) || !(await loadRemotePushEnabled())) {
if (!(await loadPushNotificationsEnabled())) {
return true
}
if (await shouldSuppressNotificationWhileViewing(payload, hostId)) {
@@ -56,7 +56,7 @@ beforeEach(() => {
vi.clearAllMocks()
resetPushRegistrationForTests()
storage.clear()
storage.set('orca:remotePushEnabled', 'true')
storage.set('orca:pushNotificationsEnabled', 'true')
vi.mocked(getDevicePushToken).mockResolvedValue(token)
})
@@ -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 () => ({}))
@@ -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<void> {
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<void> {
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<void> {
consentGeneration++
await saveRemotePushEnabled(enabled)
await savePushNotificationsEnabled(enabled)
await mutateRecords((current) => {
if (!enabled) {
for (const hostId of current.registered) {
@@ -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<string | null> {
return host ? deriveHostFingerprint(host.publicKeyB64) : null
}
export async function wasHostPushDismissed(
event: { notificationId?: string; notificationEpoch?: string; notificationSeq?: number },
hostId: string
): Promise<boolean> {
const fingerprint = await hostFingerprint(hostId)
return fingerprint ? wasPushDismissed({ ...event, hostFingerprint: fingerprint }) : false
}
export async function dismissHostPushNotification(
event: DismissNotificationEvent,
hostId: string
@@ -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<string, (capabilities: readonly string[]) => 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 })
})
})
@@ -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<string[]>([])
const [hostsLoaded, setHostsLoaded] = useState(false)
const [supportedByHostId, setSupportedByHostId] = useState<
Record<string, { push: boolean; policy: boolean }>
>({})
const [supportedByHostId, setSupportedByHostId] = useState<Record<string, boolean>>({})
const probesRef = useRef(new Map<string, { client: RpcClient; stop: () => 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.
+22 -7
View File
@@ -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<string, string>()
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()
}
})
})
-19
View File
@@ -34,32 +34,13 @@ export async function loadPushNotificationsEnabled(): Promise<boolean> {
export async function savePushNotificationsEnabled(enabled: boolean): Promise<void> {
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<boolean> {
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<void> {
await savePushNotificationsEnabled(enabled)
}
export async function loadRemotePushFilter(): Promise<RemotePushFilter> {
return notificationPreferencesFilter(await loadNotificationDeliveryPreferences())
}
@@ -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)
}
@@ -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)
@@ -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)
})
@@ -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: [
@@ -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)
})
})
+3 -5
View File
@@ -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<boolean> {
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: {
@@ -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({
@@ -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()
}
+2 -9
View File
@@ -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 & {})