mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 00:02:56 +00:00
refactor(push): remove redundant client state and replay options
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 & {})
|
||||
|
||||
Reference in New Issue
Block a user