mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 00:02:10 +00:00
353 lines
24 KiB
Markdown
353 lines
24 KiB
Markdown
# Mobile push: contract and build spec
|
|
|
|
Tracking issue: stablyai/orca#8129. Design page: `/tmp/orca-mobile-push/orca-mobile-push.html`.
|
|
This document is the single contract every lane builds against. Do not deviate without updating it.
|
|
|
|
## Summary
|
|
|
|
A small Orca-hosted push gateway (`cloud/apps/push`) holds the APNs key and FCM credentials and sends
|
|
to phones. The desktop host registers each paired phone's native push token with the gateway and asks
|
|
the gateway to push on every mobile notification it already fans out over the socket. The phone dedupes
|
|
by `notificationId#notificationSeq`. No ack gate, no generic mode, no staging gateway, one auth path for
|
|
signed-in and accountless hosts.
|
|
|
|
## Identities
|
|
|
|
- **Host public key**: the desktop's existing X25519 E2EE public key (`src/main/runtime/e2ee-keypair.ts`),
|
|
32 bytes, base64. The phone already stores it per host as `publicKeyB64`.
|
|
- **hostFingerprint**: `sha256(hostPublicKey)` base64url, first 16 chars. Identical derivation to
|
|
`deriveRelayHostId` in `src/main/runtime/relay/relay-http-client.ts`. Both desktop and phone can compute it.
|
|
- **deviceId**: the desktop's `DeviceEntry.deviceId` for the paired phone. Opaque UUID.
|
|
- **registrationId**: gateway-assigned opaque id for one (hostFingerprint, deviceId) pair.
|
|
|
|
## Gateway HTTP API
|
|
|
|
Base URL: `https://push.onorca.dev` (dev override via env). JSON bodies, `Content-Type: application/json`.
|
|
All schemas are zod, `.strict()`, exported from `cloud/packages/push-contract`.
|
|
|
|
### Host authentication: challenge, proof, session
|
|
|
|
The host keypair is X25519 (box), so it cannot sign. Reuse the relay's challenge shape.
|
|
|
|
`POST /v1/host/challenge`
|
|
```json
|
|
{ "v": 1, "hostPublicKeyB64": "<32 bytes b64>" }
|
|
```
|
|
→ 200
|
|
```json
|
|
{ "challengeId": "<opaque>", "gatewayEphemeralPublicKeyB64": "<32 b64>", "nonceB64": "<24 b64>",
|
|
"ciphertextB64": "<b64>", "expiresAt": <epoch ms> }
|
|
```
|
|
- Gateway generates an ephemeral box keypair per challenge, a 24-byte nonce, and a 32-byte secret.
|
|
- `plaintext = "orca-push-host-challenge/v1\0" || u32be(len(transcript)) || transcript || secret(32)`
|
|
- `ciphertext = nacl.box(plaintext, nonce, hostPublicKey, gatewayEphemeralSecretKey)`
|
|
- Transcript is the relay's length-prefixed field encoding (`field(name, value)` =
|
|
u32be(len(name)) || name || u32be(len(value)) || value), fields in this exact order:
|
|
`protocol="orca-push-host-proof/v1"`, `version=0x01`, `gatewayOrigin`, `gatewayEphemeralPublicKey`,
|
|
`challengeNonce`, `challengeId`, `issuedAt` (u64be ms), `expiresAt` (u64be ms), `hostFingerprint`,
|
|
`hostPublicKey`.
|
|
- Challenge TTL 10 s, and 10 s is the whole window the gateway honours. The 30 s clock skew tolerance
|
|
is the host's alone: it validates a timestamp the gateway chose, so it needs the allowance and the
|
|
gateway does not. A gateway that subtracted the tolerance from its own check would run a 40 s TTL.
|
|
Store challenge (id, secret hash, host fingerprint, host public key, expiry) in DB so any Cloud Run
|
|
instance can verify. Expired rows are pruned 30 s late so a slow proof reads as expired rather than
|
|
as an unknown challenge.
|
|
- Issuing a challenge writes no `push_hosts` row. It is unauthenticated, so a `push_hosts` row would be
|
|
a free permanent write for any caller. The row is upserted in `POST /v1/host/session` once the proof
|
|
verifies, from the public key the challenge row carries.
|
|
|
|
`POST /v1/host/session`
|
|
```json
|
|
{ "v": 1, "challengeId": "<opaque>", "proofB64": "<32 b64>" }
|
|
```
|
|
- Host opens the box with its secret key, validates every transcript field (same checks as
|
|
`validateTranscript` in `src/main/runtime/relay/relay-host-proof.ts`, adapted to the push fields),
|
|
and returns `proof = HMAC-SHA256(secret, "orca-push-host-proof/v1\0ack\0" || transcript)`.
|
|
- Gateway verifies with `timingSafeEqual`, consumes the challenge (single use), and returns
|
|
```json
|
|
{ "sessionToken": "<opaque 32 b64url>", "expiresAt": <epoch ms>, "hostFingerprint": "<16 chars>" }
|
|
```
|
|
- Session TTL 24 h. Stored hashed (sha256) in DB. Bearer on every other call:
|
|
`Authorization: Bearer <sessionToken>`. 401 with `{ "error": "session_expired" }` on expiry; host
|
|
re-runs the challenge.
|
|
|
|
### Device registration
|
|
|
|
`POST /v1/devices` (Bearer)
|
|
```json
|
|
{ "v": 1, "deviceId": "<uuid>", "platform": "ios" | "android", "token": "<native token>",
|
|
"apnsEnvironment": "sandbox" | "production", // ios only, required for ios
|
|
"filter": { "sources": ["agent-task-complete", "terminal-bell", "plugin"],
|
|
"agentStates": ["needs-input", "finished"] } }
|
|
```
|
|
→ 200 `{ "registrationId": "<opaque>" }`. Upsert keyed by (hostFingerprint, deviceId); a new token
|
|
replaces the old. `deviceId` is caller-chosen, so a host is capped at 64 registrations: the 65th
|
|
distinct `deviceId` → 409 `{ "error": "too_many_devices" }`. Re-registering a `deviceId` the host
|
|
already owns is always accepted, and deleting a registration frees its slot. `GET /v1/devices` is
|
|
bounded at 1024 rows to match its response schema, which the per-host cap keeps well out of reach.
|
|
`filter` is stored but enforced by the host (see desktop); gateway stores it only so a
|
|
host restart can re-read it. iOS tokens are variable-length, hex-encoded byte strings; Android
|
|
tokens are FCM registration strings.
|
|
|
|
`DELETE /v1/devices/:registrationId` (Bearer) → 204. Only the owning host may delete.
|
|
|
|
`GET /v1/devices` (Bearer) → `{ "devices": [{ registrationId, deviceId, platform, dead: boolean }] }`.
|
|
|
|
### Send
|
|
|
|
`POST /v1/send` (Bearer)
|
|
```json
|
|
{ "v": 1,
|
|
"registrationIds": ["<id>", "..."],
|
|
"notification": {
|
|
"notificationId": "<max 2048 chars, may be absent for terminal-bell>",
|
|
"notificationSeq": <int>, "notificationEpoch": "<uuid>",
|
|
"source": "agent-task-complete" | "terminal-bell" | "plugin",
|
|
"agentState": "needs-input" | "finished" | null,
|
|
"title": "<max 80 chars>", "body": "<max 180 chars>",
|
|
"worktreeId": "<max 2048 chars|absent>" } }
|
|
```
|
|
→ 200
|
|
```json
|
|
{ "results": [{ "registrationId": "<id>", "status": "queued" | "dead" | "rate_limited" | "error" }] }
|
|
```
|
|
- `queued` means accepted into the coalescing window. `dead` means the provider reported the token
|
|
unregistered; the host must drop the registration. Never block the socket fan-out on this call.
|
|
- Quota: 60 sends per hostFingerprint per rolling hour, 200 per registration per rolling day. Over quota
|
|
→ `rate_limited` per result, HTTP 200. Whole request over a hard cap of 20 registrationIds → 400.
|
|
The cap counts the ids as sent; the gateway then dedupes them, so a repeated id spends quota once,
|
|
yields one result, and counts once toward `coalescedCount`. `results` may therefore be shorter than
|
|
`registrationIds`, and callers must match a result by its `registrationId`, never by position.
|
|
- Notification JSON is limited to 3000 UTF-8 bytes to leave provider envelope space; identities
|
|
are preserved exactly, including long filesystem paths. Oversized payloads fail validation.
|
|
- Gateway retries are deduplicated by host, registration, notification epoch, and sequence in the
|
|
quota ledger for its 25-hour retention window. Duplicates return `queued` without reserving
|
|
quota or enqueueing another delivery.
|
|
- Both quota counters are reserved under a per-host lock held for the whole transaction. PostgreSQL
|
|
reads at READ COMMITTED, so a concurrent count-then-insert would otherwise admit a whole burst.
|
|
|
|
### Request limits and unauthenticated abuse
|
|
|
|
- Every POST is capped at 16 KiB by a streaming body limit, not by `Content-Length` alone: a chunked
|
|
body declares no length. Over the cap → 413 `{ "error": "request_too_large" }`.
|
|
- `POST /v1/host/challenge` and `POST /v1/host/session` are the only unauthenticated routes. They share
|
|
one token bucket per client IP, 30 requests per minute, refilling continuously. Over the bucket → 429
|
|
`{ "error": "rate_limited" }`. The client IP is the **last** `x-forwarded-for` hop, not the first:
|
|
Cloud Run appends the connecting peer, so everything left of that value is caller-supplied and can be
|
|
a fresh forgery on every request, which would hand a flood a new bucket each time.
|
|
`ORCA_PUSH_TRUSTED_PROXY_HOPS` (default 0) says how many appenders sit between the platform and the
|
|
client, so a future load balancer sets it to 1. A header with fewer hops than that depth is not
|
|
trusted at all. Falls back to `x-real-ip` and then to a single shared bucket. The bucket is per
|
|
instance and in memory, so the effective cap scales with the instance count; it exists to blunt a
|
|
flood, not to meter.
|
|
- Every other `/v1` route is capped by a second, wider bucket per client IP, 240 requests per minute,
|
|
applied **before** the bearer is looked up. A bearer has to be read from the database before it can
|
|
be refused, and that read takes one of only two pool connections per instance, so without this cap
|
|
a flood of forged bearers would starve real hosts of the pool while every one of them got a 401.
|
|
- The gateway cannot prove that a host owns the token it registers: any host with a session may
|
|
register any well-formed token and send text to it, within its own quota. The phone drops such a push
|
|
in the foreground because the fingerprint resolves to no paired host, and never routes a tap on it,
|
|
but the OS banner shows while the app is backgrounded. Reaching it needs the victim's native token,
|
|
which the gateway never returns and which only the phone and its host ever see.
|
|
|
|
### Coalescing (gateway)
|
|
|
|
Per registrationId, hold sends for 3 s. If one event arrives, send it as-is. If N>1 arrive, send one
|
|
summary: title `Orca`, body `<N> agents need attention` (or `<N> updates` when no needs-input), data
|
|
carries the latest event's fields plus `coalescedCount`. Collapse id for a summary is
|
|
`host:<hostFingerprint>` so a later summary replaces it. The window is held in memory per gateway
|
|
instance, so with more than one instance a burst can produce up to one summary per instance; accepted
|
|
for this release, and the collapse id keeps the phone showing one banner. Transient provider errors
|
|
retry at most three attempts within two minutes, honoring Retry-After and FCM minimum delays. Permanent failures
|
|
are not retried. Unregister/dead-token state is re-read before every attempt. Shutdown stops admission
|
|
and drains admitted requests, pending windows, and active deliveries before closing resources;
|
|
a nine-second hard deadline remains below Cloud Run's termination grace. Delivery remains in memory.
|
|
|
|
### Provider payloads
|
|
|
|
APNs (HTTP/2, `api.push.apple.com` or `api.sandbox.push.apple.com` by `apnsEnvironment`; JWT auth
|
|
from key id + team id + `.p8`, token cached and refreshed every 50 min):
|
|
- headers: `apns-topic: com.stably.orca.mobile`, `apns-push-type: alert`, `apns-priority: 10`,
|
|
`apns-expiration: now+4h`, `apns-collapse-id: <notificationId truncated to 64 bytes, or host:<fp>>`
|
|
- body: `{"aps":{"alert":{"title","body"},"sound":"default","thread-id":"<hostFingerprint>"},
|
|
"orca":{ hostFingerprint, worktreeId, notificationId, notificationSeq, notificationEpoch, source,
|
|
agentState, coalescedCount }}`
|
|
- Dead token: 410, or 400 with `BadDeviceToken`/`Unregistered`/`DeviceTokenNotForTopic`.
|
|
|
|
FCM (V1 `projects/onorca-cloud/messages:send`, bearer from the runtime service account via the GCE
|
|
metadata server or `GOOGLE_APPLICATION_CREDENTIALS` locally):
|
|
- `{"message":{"token","notification":{"title","body"},"android":{"priority":"HIGH","ttl":"14400s",
|
|
"collapse_key":"<sha256(collapseId) hex 32>","notification":{"channel_id":"orca-desktop","tag":"<collapseId>"}},
|
|
"data":{ all orca fields as strings }}}`
|
|
- Dead token: `UNREGISTERED`, or `INVALID_ARGUMENT` whose message names the token.
|
|
|
|
### Gateway storage (Postgres in prod, SQLite in tests, same pattern as `cloud/apps/relay/src/database.ts`)
|
|
|
|
- `push_hosts(host_fingerprint pk, host_public_key, created_at, last_seen_at)`, written only on a
|
|
verified proof and pruned after 1 h of no contact when no `push_devices` row still names the host.
|
|
Nothing reads it, and any keypair mints a host for free, so it is not allowed to accumulate.
|
|
- `push_sessions` holds one row per host, enforced by a unique index and transaction lock. Minting a
|
|
session deletes the host's earlier one, since a desktop holds a single session and only re-proves once it is gone.
|
|
- `push_challenges(challenge_id pk, host_fingerprint, host_public_key, secret_hash, transcript,
|
|
expires_at, consumed_at)`
|
|
- `push_sessions(token_hash pk, host_fingerprint, expires_at, created_at)`
|
|
- `push_devices(registration_id pk, host_fingerprint, device_id, platform, token, apns_environment,
|
|
filter_json, dead_at, created_at, updated_at, unique(host_fingerprint, device_id))`
|
|
- `push_send_log(host_fingerprint, registration_id, sent_at)` for quota, pruned after 25 h.
|
|
|
|
Logging: aggregate counters only. Never log tokens, titles, bodies, or raw fingerprints (log the first
|
|
4 chars of a fingerprint at most).
|
|
|
|
### Gateway env
|
|
|
|
`PORT`, `ORCA_PUSH_PUBLIC_URL`, `ORCA_PUSH_DATABASE_URL` (absent → SQLite under `ORCA_PUSH_DATA_DIR`),
|
|
`ORCA_PUSH_APNS_KEY` (PEM text), `ORCA_PUSH_APNS_KEY_ID`, `ORCA_PUSH_APPLE_TEAM_ID`,
|
|
`ORCA_PUSH_APNS_TOPIC` (default `com.stably.orca.mobile`), `ORCA_PUSH_FCM_PROJECT_ID` (default
|
|
`onorca-cloud`), `ORCA_PUSH_COALESCE_MS` (default 3000), `ORCA_PUSH_TRUSTED_PROXY_HOPS` (default 0,
|
|
proxies appending to `x-forwarded-for` after the client).
|
|
Secret Manager names (already exist in `onorca-cloud`): `orca-cloud-push-apns-key`,
|
|
`orca-cloud-push-apns-key-id`, `orca-cloud-push-apple-team-id`. Runtime SA:
|
|
`orca-cloud-push@onorca-cloud.iam.gserviceaccount.com` (already has FCM admin + secret accessor).
|
|
|
|
## Desktop (`src/main`, `src/shared`)
|
|
|
|
- Capability `NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY = 'notifications.remote-push.v1'` in
|
|
`src/shared/protocol-version.ts`, advertised statically.
|
|
- RPC `notifications.registerPush` params `{ platform, token, apnsEnvironment?, filter }` (same shapes
|
|
as the gateway `POST /v1/devices` minus deviceId, which comes from `ctx.pairedDeviceId`). Returns
|
|
`{ registered: true, registrationId } | { registered: false, reason: 'gateway_unreachable' |
|
|
'gateway_rejected' | 'not_mobile' | 'registration_storage_failed' | 'throttled' }`. A device may
|
|
register at most 10 times per minute (`throttled` beyond that, its earlier registration untouched):
|
|
each call is a gateway write plus a synchronous registry write on the main thread, and a paired
|
|
phone could otherwise loop it. The unregister RPC is not throttled, since with nothing registered it
|
|
is a lookup and with something registered it can only run once per successful register. The params
|
|
schema is strict, so a caller-supplied `deviceId` is an error, not a key silently dropped. Persists `pushRegistration:
|
|
{ registrationId, platform, filter, registeredAt }` on `DeviceEntry` in `device-registry.ts` (new
|
|
optional field, tolerated by old registries). When the gateway accepted the token but the host could
|
|
not store it — the device left mobile scope mid-call (`not_mobile`) or the registry write threw
|
|
(`registration_storage_failed`) — the host queues the gateway delete in the unregister outbox rather
|
|
than leaking a registration nothing will ever push to. Registration, unregister, and outbox deletes
|
|
are serialized per device; re-registration first settles earlier cleanup. Authentication failure
|
|
never drops a durable delete. Stale send responses only clear the exact local registration observed,
|
|
while provider dead-token updates match the token/platform/environment that was sent. Phones must
|
|
treat any `registered: false` as "retry later", so an unknown reason string is safe to add.
|
|
- RPC `notifications.unregisterPush` params null → `{ unregistered: boolean }`. Removes the field and
|
|
enqueues a gateway delete in a durable outbox (`src/main/runtime/push/push-unregister-outbox.ts`,
|
|
modelled on `relay-revoke-outbox.ts`). Unpair/revoke (`revokeMobileDevice`) enqueues the same. The
|
|
drain re-reads the queue as it goes, so a delete queued mid-drain lands in the same pass, and a pass
|
|
that leaves retryable items schedules an unref'd backoff retry (30 s, doubling, capped at 10 min)
|
|
instead of waiting for the next launch.
|
|
- Both RPCs added to `runtime-rpc-mobile-method-allowlist.ts`.
|
|
- Push client `src/main/runtime/push/push-gateway-client.ts`: challenge/proof/session with token cache,
|
|
register, delete, send. Node `fetch`. Gateway URL from `profile-cloud-auth-config.ts`
|
|
(`pushGatewayUrl`, default `https://push.onorca.dev`, env override `ORCA_PUSH_GATEWAY_URL`).
|
|
- Host proof answering: new `src/main/runtime/push/push-host-proof.ts`, a copy of the relay's
|
|
`answerRelayHostChallenge` with the push transcript fields. Shared code with the relay proof is
|
|
welcome if it stays a pure refactor.
|
|
- Dispatch hook: in `RuntimeMobileNotificationController.dispatch`, after the socket fan-out, call
|
|
`pushDispatcher.enqueue(eventWithSeq)`. The dispatcher applies each device's `filter`, skips `dismiss`
|
|
events, maps `agentState` to `needs-input | finished` (blocked/waiting → needs-input, else finished),
|
|
batches matching registrationIds into `POST /v1/send` requests of at most 20 registrations each (the
|
|
gateway's per-request cap; extra devices get their own request rather than being dropped), and drops
|
|
unchanged registrations the gateway reports `dead`. Failure categories are counted without payload
|
|
values and logged at most once per minute (with a final flush on shutdown). Fire-and-forget with
|
|
one retry after 2 s per request; never throws into dispatch.
|
|
- Add `agentState` to `MobileNotificationDispatchEvent` and set it in `src/main/ipc/notifications.ts`
|
|
from `args.agentState`. Fix `buildAgentTaskCompleteNotificationOptions` so `working|running|busy`
|
|
never yields "finished" (title says "working" and the dispatcher treats it as not-final, i.e. no push).
|
|
- Headless serve: no renderer means no `notifications:dispatch`. Document in
|
|
`docs/reference/headless-linux-server.md`; do not fix here.
|
|
|
|
## Mobile (`mobile/`)
|
|
|
|
- Commit `google-services.json` (from `/tmp/orca-mobile-push/google-services.json`) at `mobile/` and set
|
|
`"android": { "googleServicesFile": "./google-services.json" }` in `app.json`. Add `"expo-notifications"`
|
|
to `plugins` so prebuild writes the `aps-environment` entitlement.
|
|
- Token: `Notifications.getDevicePushTokenAsync()`; `data` is the APNs hex or FCM string. iOS
|
|
`apnsEnvironment`: `__DEV__ ? 'sandbox' : 'production'` (dev-client builds are debug, TestFlight and
|
|
App Store are release). Listen with `addPushTokenListener` and re-register on change.
|
|
- Settings (`mobile/app/notifications.tsx`): single "Background notifications" switch, default off,
|
|
hint text exactly: "Get alerts while Orca is closed. Alerts show the same text as on your desktop. That
|
|
text, your phone's push token, and opaque host and device ids pass through Orca's push service and Apple
|
|
or Google. Turning this off or unpairing deletes the token." Event controls live in the shared notification-preferences section and apply to both connected and background notifications.
|
|
Hide the whole section, with copy "Update your desktop app to enable background notifications", when
|
|
no paired host advertises `notifications.remote-push.v1`.
|
|
- Registration: on switch-on (after OS permission), and on every host reaching `connected` while the
|
|
switch is on, call `notifications.registerPush` on that host if it advertises the capability. On
|
|
switch-off call `notifications.unregisterPush` on every connected host and remember to retry on hosts
|
|
that were offline. On host removal, best-effort unregister before deleting credentials.
|
|
- Receive: `addNotificationReceivedListener` (foreground) checks `data.orca.notificationId` +
|
|
`notificationSeq` against the host session seen set in `notification-reconnect-catchup.ts`; if seen,
|
|
suppress via `setNotificationHandler` returning no banner; otherwise show and mark seen. Background and
|
|
killed: OS shows it.
|
|
- Tap: `data.orca.hostFingerprint` → hostId by computing the same sha256/base64url/16 derivation over each
|
|
stored host's `publicKeyB64`; then existing `getNotificationNavigationTarget` + `useOpenNotificationRoute`.
|
|
- Reopen: existing replay catch-up runs unchanged. Dismiss events also
|
|
`dismissNotificationAsync` any presented notification whose `data.orca.notificationId` matches.
|
|
- Old host without the capability: nothing changes.
|
|
|
|
## Infra (`cloud/infra/terraform`, `.github/workflows`)
|
|
|
|
- Cloud Run service `orca-cloud-push`, region `us-central1`, project from the environment tfvars, runtime
|
|
SA `orca-cloud-push@<project>.iam.gserviceaccount.com` (exists in prod; declare and import), the three
|
|
secrets mounted as env (exist; declare and import), Cloud SQL connector to the shared instance with its
|
|
own database `orca_push`, min instances 1, max 4, concurrency 80, ingress all, unauthenticated invoke.
|
|
- IAM: `roles/firebasecloudmessaging.admin` and `roles/serviceusage.serviceUsageConsumer` on the runtime
|
|
SA (exist in prod; declare and import). Secret accessor per secret.
|
|
- Hostname `push.onorca.dev`. The DNS zone lives in the apps root in `stablyai/orca-cloud`; add the
|
|
Cloud Run domain mapping here and leave a TODO comment naming the record the other repo must add.
|
|
- Workflow `.github/workflows/cloud-push-deploy.yml`: gated on `vars.ORCA_CLOUD_OPERATIONS_ENABLED`,
|
|
Workload Identity like `cloud-relay-*`, builds the image, deploys with `--no-traffic`, probes the new
|
|
revision's `/ready` and a validate-only FCM send, then shifts 100% traffic. Uses
|
|
`.github/actions/cloud-sql-rollout-lease` around the schema step.
|
|
- Add the new root files to `cloud/dev/contracts` and `cloud/dev/fixtures` partitions so
|
|
`terraform-root-partition.test.mjs` and `Cloud Verify` pass.
|
|
|
|
## Non-goals for this release
|
|
|
|
Ack gate, generic-alert mode, staging gateway, iOS Notification Service Extension, Android data-only
|
|
messages, Live Activities, account-based quota tiers, dismissal via silent push.
|
|
|
|
### 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` for terminal events disabled by the desktop master or
|
|
source switch. Desktop focus and native authorization remain desktop-only delivery gates.
|
|
|
|
`notifications.subscribe` and `notifications.getMissedSince` accept optional
|
|
`includeDesktopSuppressed: true`. Only opted-in callers receive those events, including replay;
|
|
legacy callers keep the old filtered stream. A new phone against an older host can narrow the
|
|
available events but cannot recover events that host never published.
|
|
|
|
The phone defaults to following each host. `filter.followDesktop` is optional: absent retains
|
|
legacy desktop gating; explicit false permits independent event choices. The desktop persists
|
|
it with the paired registration and evaluates it for every send, so desktop preference changes
|
|
work while the phone is disconnected. This flag is host-local and is not sent to the gateway.
|
|
The phone uses the same shared event predicate for socket/replay delivery as the push dispatcher.
|
|
Optional `emittedAt` carries the event time for per-device five-second burst suppression after
|
|
source filtering. Desktop eligibility, source, and agent state use separate upstream cooldown
|
|
buckets so filtered events cannot suppress the next eligible event. Legacy RPC callers retain
|
|
workspace-wide burst suppression on the host.
|
|
|
|
`filter.sound` is also host-local. False groups that device's requests separately and adds
|
|
optional `notification.sound: false` to gateway sends. The gateway omits APNs `aps.sound` and
|
|
uses Android's `orca-desktop-silent` channel. Missing sound preserves existing audible delivery.
|
|
Deploy the updated gateway before distributing hosts that send the optional sound field: older
|
|
gateways strictly reject unknown notification fields. No token or database migration is needed.
|
|
|
|
The phone's master switch disables background registration as well as local scheduling. Sound
|
|
and viewing preferences belong to the receiving phone. The phone suppresses a banner for its
|
|
currently viewed host/workspace only while active; it never assumes desktop focus means the
|
|
phone is viewing that workspace. Changes to an offline host's persisted filter take effect on
|
|
reconnection. No live APNs/FCM delivery is implied by simulator notification injection.
|
|
|
|
For a phone registered for background push, socket notification delivery waits while the app is
|
|
inactive. On foreground, it checks the native push tray before scheduling a local fallback, so
|
|
a still-connected background socket cannot duplicate APNs/FCM delivery. Unsubscribing cancels
|
|
the wait without claiming delivery. Hosts without push registration keep local delivery.
|
|
|
|
Native notification readers accept Expo's iOS `request.trigger.payload` as well as
|
|
`request.content.data`. APNs custom fields can exist only in the former; foreground deduplication,
|
|
tray replay suppression, dismissal, and tap routing all use the same reader.
|