From 1bb74e4599ea056ce9bf37deb05783feb15b9765 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 7 Sep 2026 14:20:14 -0400 Subject: [PATCH] fix(notifications): preserve away alerts and recover missed dismissals --- config/reliability-gates.jsonc | 8 +- docs/reference/mobile-push-contract.md | 56 +++++++-- .../expo-module.config.json | 7 ++ .../ios/OrcaNotificationDismissal.podspec | 15 +++ .../ios/OrcaNotificationDismissalModule.swift | 14 +++ .../OrcaNotificationDismissalSubscriber.swift | 27 +++++ .../ios/PushDismissalLedger.swift | 61 ++++++++++ .../orca-notification-dismissal/package.json | 5 + .../tests/PushDismissalLedgerChecks.swift | 23 ++++ .../src/notifications/mobile-notifications.ts | 14 ++- .../native-push-dismissal.native.ts | 6 + .../notifications/native-push-dismissal.ts | 8 ++ .../push-dismissal-reconciliation.test.ts | 82 +++++++++++++ .../push-dismissal-reconciliation.ts | 92 +++++++++++++++ .../push-dismissal-watermarks.ts | 16 ++- src/main/ipc/notifications.ts | 5 +- ...obile-notification-dismissal-store.test.ts | 57 +++++++++ .../mobile-notification-dismissal-store.ts | 109 ++++++++++++++++++ src/main/runtime/rpc/methods/notifications.ts | 17 ++- .../runtime-mobile-notification-controller.ts | 24 ++++ ...me-rpc-mobile-method-allowlist-fixtures.ts | 1 + .../runtime/runtime-rpc/runtime-rpc-state.ts | 1 + .../runtime-service-command-surface.ts | 4 + src/preload/api/notifications-bridge.ts | 2 + src/preload/api/os-permission-api.ts | 1 + .../src/hooks/agent-auto-ack-presence.ts | 53 +++++++++ .../src/hooks/agent-auto-ack-targets.ts | 34 ++++++ .../hooks/useAutoAckViewedAgent.away.test.ts | 86 ++++++++++++++ .../useAutoAckViewedAgent.clock-skew.test.ts | 10 ++ ...eAutoAckViewedAgent.floating-panel.test.ts | 10 ++ .../src/hooks/useAutoAckViewedAgent.ts | 62 ++++------ .../web/preload-api/web-notifications-api.ts | 1 + 32 files changed, 849 insertions(+), 62 deletions(-) create mode 100644 mobile/modules/orca-notification-dismissal/expo-module.config.json create mode 100644 mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissal.podspec create mode 100644 mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissalModule.swift create mode 100644 mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissalSubscriber.swift create mode 100644 mobile/modules/orca-notification-dismissal/ios/PushDismissalLedger.swift create mode 100644 mobile/modules/orca-notification-dismissal/package.json create mode 100644 mobile/modules/orca-notification-dismissal/tests/PushDismissalLedgerChecks.swift create mode 100644 mobile/src/notifications/native-push-dismissal.native.ts create mode 100644 mobile/src/notifications/native-push-dismissal.ts create mode 100644 mobile/src/notifications/push-dismissal-reconciliation.test.ts create mode 100644 mobile/src/notifications/push-dismissal-reconciliation.ts create mode 100644 src/main/runtime/mobile-notification-dismissal-store.test.ts create mode 100644 src/main/runtime/mobile-notification-dismissal-store.ts create mode 100644 src/renderer/src/hooks/agent-auto-ack-presence.ts create mode 100644 src/renderer/src/hooks/agent-auto-ack-targets.ts create mode 100644 src/renderer/src/hooks/useAutoAckViewedAgent.away.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 2bf733764d1..f628b889454 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -27,11 +27,14 @@ "invariant": "Headless startup installs and disposes push delivery; push and replay preserve the three-minute away policy, and host activity cannot extend the seven-day mobile lease.", "oracle": "Require registration after RPC identity initialization and shutdown cleanup; idle 179/180/0 yields false/true/false for socket and replay, and exactly one push. Persisted lease expires exactly at seven days and only explicit registration renews it.", "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/mobile-notification-dismissal-store.test.ts src/renderer/src/hooks/useAutoAckViewedAgent.away.test.ts", "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/orcad/orcad-push-startup.test.ts src/main/runtime/push/push-policy-pipeline.integration.test.ts" ], "testFiles": [ "src/main/orcad/orcad-push-startup.test.ts", - "src/main/runtime/push/push-policy-pipeline.integration.test.ts" + "src/main/runtime/push/push-policy-pipeline.integration.test.ts", + "src/main/runtime/mobile-notification-dismissal-store.test.ts", + "src/renderer/src/hooks/useAutoAckViewedAgent.away.test.ts" ], "assertionRefs": [ { @@ -79,7 +82,8 @@ "knownGaps": [ "Does not prove APNs silent background wakeup or actual operating-system idle transitions.", "Rendererless agent/bell event generation remains outside the documented feature contract.", - "No live Windows or Linux policy evidence." + "No live Windows or Linux policy evidence.", + "Native iOS dismissal callback processing is verified separately; real APNs background wakeup is not established. Coalesced summaries require membership-aware dismissal before they can be cleared safely." ], "demotionRule": "Keep experimental if lifecycle or policy assertions fail; do not weaken them to bypass platform delivery gaps." }, diff --git a/docs/reference/mobile-push-contract.md b/docs/reference/mobile-push-contract.md index 61e45a80e1a..2565ffd3ebf 100644 --- a/docs/reference/mobile-push-contract.md +++ b/docs/reference/mobile-push-contract.md @@ -30,14 +30,18 @@ All schemas are zod, `.strict()`, exported from `cloud/packages/push-contract`. 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": "", "gatewayEphemeralPublicKeyB64": "<32 b64>", "nonceB64": "<24 b64>", "ciphertextB64": "", "expiresAt": } ``` + - 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)` @@ -57,16 +61,20 @@ The host keypair is X25519 (box), so it cannot sign. Reuse the relay's challenge verifies, from the public key the challenge row carries. `POST /v1/host/session` + ```json { "v": 1, "challengeId": "", "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": "", "expiresAt": , "hostFingerprint": "<16 chars>" } ``` + - Session TTL 24 h. Stored hashed (sha256) in DB. Bearer on every other call: `Authorization: Bearer `. 401 with `{ "error": "session_expired" }` on expiry; host re-runs the challenge. @@ -74,12 +82,14 @@ The host keypair is X25519 (box), so it cannot sign. Reuse the relay's challenge ### Device registration `POST /v1/devices` (Bearer) + ```json { "v": 1, "deviceId": "", "platform": "ios" | "android", "token": "", "apnsEnvironment": "sandbox" | "production", // ios only, required for ios "filter": { "sources": ["agent-task-complete", "terminal-bell", "plugin"], "agentStates": ["needs-input", "finished"] } } ``` + → 200 `{ "registrationId": "" }`. 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 @@ -96,6 +106,7 @@ tokens are FCM registration strings. ### Send `POST /v1/send` (Bearer) + ```json { "v": 1, "registrationIds": ["", "..."], @@ -107,10 +118,13 @@ tokens are FCM registration strings. "title": "", "body": "", "worktreeId": "" } } ``` + → 200 + ```json { "results": [{ "registrationId": "", "status": "queued" | "dead" | "rate_limited" | "error" }] } ``` + - `queued` means the logical event, recipient, and pending delivery payload have committed to SQL. A worker resumes pending work after restarts; provider acceptance is not proof of visible delivery. - Each host gets 300 logical alerts and, independently, 300 dismissals per rolling 15 minutes. Fanout @@ -168,18 +182,20 @@ promising exactly-once delivery. 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: fixed event deadline (at most five minutes)`, `apns-collapse-id: >` - body: `{"aps":{"alert":{"title","body"},"sound":"default","thread-id":""}, - "orca":{ hostFingerprint, worktreeId, notificationId, notificationSeq, notificationEpoch, source, - agentState, coalescedCount }}` +"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":"", - "collapse_key":"","notification":{"channel_id":"orca-desktop","tag":""}}, - "data":{ all orca fields as strings }}}` +"collapse_key":"","notification":{"channel_id":"orca-desktop","tag":""}}, +"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`) @@ -190,10 +206,10 @@ metadata server or `GOOGLE_APPLICATION_CREDENTIALS` locally): - `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)` +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))` +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 @@ -217,13 +233,13 @@ Secret Manager names (already exist in `onorca-cloud`): `orca-cloud-push-apns-ke - 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 +'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 +{ 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 @@ -360,3 +376,27 @@ the wait without claiming delivery. Hosts without push registration keep local d 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. + +### Dismissal recovery and desktop presence + +Automatic acknowledgement of a visible agent pane requires confirmed desktop presence from the +same three-minute native idle check used for push delivery. A focused window alone is insufficient. +Trusted input in the renderer confirms the user has returned. Unknown presence preserves unread +attention; explicit mark-read actions remain available, including in browser clients. + +The runtime persists notification identities and dismissal sequence fences in its own user-data +directory before fanout. History is bounded to 4,096 records retained for seven days. It stores no +notification text or push tokens. On reconnect, mobile optionally includes up to 256 `deliveredPushes` +identities in `notifications.getMissedSince`; updated hosts return optional `dismissedPushes` for +confirmed handled identities. This recovers dismissals after event replay eviction or host restart +within retained history. Unknown IDs, newer sequences and different epochs are preserved. Older +hosts ignore the optional request field, and older clients ignore the additional response field. +No new RPC method, stream opcode or gateway deployment is required. + +On iOS, a local Expo module handles silent dismissals directly through the native notification +center, independent of JavaScript initialization. Native and JavaScript dismissal paths use the +same host/epoch/sequence fences; native watermarks retain up to 512 entries for 24 hours. Older +native shells and Android retain the JavaScript implementation. A native callback test proves +processing only when invoked: iOS background push delivery remains best-effort, including while +suspended or force-quit. Coalesced summaries are preserved because a single member's dismissal +cannot establish that every alert represented by the summary was handled. diff --git a/mobile/modules/orca-notification-dismissal/expo-module.config.json b/mobile/modules/orca-notification-dismissal/expo-module.config.json new file mode 100644 index 00000000000..dbf5942dbd5 --- /dev/null +++ b/mobile/modules/orca-notification-dismissal/expo-module.config.json @@ -0,0 +1,7 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["OrcaNotificationDismissalModule"], + "appDelegateSubscribers": ["OrcaNotificationDismissalSubscriber"] + } +} diff --git a/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissal.podspec b/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissal.podspec new file mode 100644 index 00000000000..7e2aee8ebd7 --- /dev/null +++ b/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissal.podspec @@ -0,0 +1,15 @@ +Pod::Spec.new do |s| + s.name = 'OrcaNotificationDismissal' + s.version = '0.0.1' + s.summary = 'Native notification dismissal and sequence fencing' + s.description = s.summary + s.license = { :type => 'MIT' } + s.author = 'Orca' + s.homepage = 'https://onorca.dev' + s.source = { :git => 'https://github.com/stablyai/orca.git' } + s.platforms = { :ios => '15.1' } + s.swift_version = '5.9' + s.static_framework = true + s.dependency 'ExpoModulesCore' + s.source_files = '**/*.swift' +end diff --git a/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissalModule.swift b/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissalModule.swift new file mode 100644 index 00000000000..f86fe8bf891 --- /dev/null +++ b/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissalModule.swift @@ -0,0 +1,14 @@ +import ExpoModulesCore + +public class OrcaNotificationDismissalModule: Module { + public func definition() -> ModuleDefinition { + Name("OrcaNotificationDismissal") + AsyncFunction("remember") { (payload: [String: Any]) in + if let identity = PushDismissalIdentity(payload) { PushDismissalLedger.shared.remember(identity) } + } + AsyncFunction("wasDismissed") { (payload: [String: Any]) -> Bool in + guard let identity = PushDismissalIdentity(payload) else { return false } + return PushDismissalLedger.shared.contains(identity) + } + } +} diff --git a/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissalSubscriber.swift b/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissalSubscriber.swift new file mode 100644 index 00000000000..69396d3d87b --- /dev/null +++ b/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissalSubscriber.swift @@ -0,0 +1,27 @@ +import ExpoModulesCore +import UserNotifications + +public class OrcaNotificationDismissalSubscriber: ExpoAppDelegateSubscriber { + public func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + guard let payload = userInfo["orca"] as? [String: Any], + payload["kind"] as? String == "dismiss", let fence = PushDismissalIdentity(payload) + else { completionHandler(.noData); return } + PushDismissalLedger.shared.remember(fence) + let center = UNUserNotificationCenter.current() + center.getDeliveredNotifications { notifications in + let ids = notifications.compactMap { notification -> String? in + guard let data = notification.request.content.userInfo["orca"] as? [String: Any], + ((data["coalescedCount"] as? NSNumber)?.intValue ?? 1) <= 1, + let delivered = PushDismissalIdentity(data), fence.matches(delivered), + delivered.notificationSeq <= fence.notificationSeq else { return nil } + return notification.request.identifier + } + center.removeDeliveredNotifications(withIdentifiers: ids) + completionHandler(ids.isEmpty ? .noData : .newData) + } + } +} diff --git a/mobile/modules/orca-notification-dismissal/ios/PushDismissalLedger.swift b/mobile/modules/orca-notification-dismissal/ios/PushDismissalLedger.swift new file mode 100644 index 00000000000..a0388199058 --- /dev/null +++ b/mobile/modules/orca-notification-dismissal/ios/PushDismissalLedger.swift @@ -0,0 +1,61 @@ +import Foundation +import CoreFoundation + +struct PushDismissalIdentity: Codable { + let hostFingerprint: String + let notificationId: String + let notificationEpoch: String + let notificationSeq: Int64 + + init?(_ value: [String: Any]) { + guard let host = value["hostFingerprint"] as? String, !host.isEmpty, host.count <= 512, + let id = value["notificationId"] as? String, !id.isEmpty, id.count <= 512, + let epoch = value["notificationEpoch"] as? String, !epoch.isEmpty, epoch.count <= 128, + let seq = value["notificationSeq"] as? NSNumber, + CFGetTypeID(seq) != CFBooleanGetTypeID(), seq.doubleValue.isFinite, + seq.doubleValue >= 0, seq.doubleValue <= 9_007_199_254_740_991, + seq.doubleValue.rounded(.down) == seq.doubleValue else { return nil } + hostFingerprint = host; notificationId = id; notificationEpoch = epoch + notificationSeq = seq.int64Value + } + + func matches(_ other: PushDismissalIdentity) -> Bool { + hostFingerprint == other.hostFingerprint && notificationId == other.notificationId && + notificationEpoch == other.notificationEpoch + } +} + +final class PushDismissalLedger { + static let shared = PushDismissalLedger() + private struct Entry: Codable { let identity: PushDismissalIdentity; let expiresAt: TimeInterval } + private let defaults: UserDefaults + private let lock = NSLock() + private let storageKey = "orca.pushDismissals.v1" + init(defaults: UserDefaults = .standard) { self.defaults = defaults } + + private func read(now: TimeInterval) -> [Entry] { + guard let data = defaults.data(forKey: storageKey), + let entries = try? JSONDecoder().decode([Entry].self, from: data) else { return [] } + return entries.filter { $0.expiresAt > now } + } + + func remember(_ identity: PushDismissalIdentity, now: TimeInterval = Date().timeIntervalSince1970) { + lock.lock(); defer { lock.unlock() } + let entries = read(now: now) + let previous = entries.first { $0.identity.matches(identity) } + let newest = (previous?.identity.notificationSeq ?? -1) > identity.notificationSeq + ? previous!.identity : identity + let next = entries.filter { !$0.identity.matches(identity) } + + [Entry(identity: newest, expiresAt: now + 86400)] + if let data = try? JSONEncoder().encode(Array(next.suffix(512))) { + defaults.set(data, forKey: storageKey) + } + } + + func contains(_ identity: PushDismissalIdentity, now: TimeInterval = Date().timeIntervalSince1970) -> Bool { + lock.lock(); defer { lock.unlock() } + return read(now: now).contains { + $0.identity.matches(identity) && $0.identity.notificationSeq >= identity.notificationSeq + } + } +} diff --git a/mobile/modules/orca-notification-dismissal/package.json b/mobile/modules/orca-notification-dismissal/package.json new file mode 100644 index 00000000000..6710e7adbf6 --- /dev/null +++ b/mobile/modules/orca-notification-dismissal/package.json @@ -0,0 +1,5 @@ +{ + "name": "orca-notification-dismissal", + "version": "0.0.1", + "private": true +} diff --git a/mobile/modules/orca-notification-dismissal/tests/PushDismissalLedgerChecks.swift b/mobile/modules/orca-notification-dismissal/tests/PushDismissalLedgerChecks.swift new file mode 100644 index 00000000000..174ef5690b2 --- /dev/null +++ b/mobile/modules/orca-notification-dismissal/tests/PushDismissalLedgerChecks.swift @@ -0,0 +1,23 @@ +import Foundation +@main struct PushDismissalLedgerChecks { + static func main() { + let suite = "orca.qa.dismissal." + UUID().uuidString + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + func identity(_ seq: Int, _ host: String = "qa-host", _ epoch: String = "qa-epoch") -> PushDismissalIdentity { + PushDismissalIdentity(["hostFingerprint": host, "notificationId": "qa-alert", "notificationEpoch": epoch, "notificationSeq": seq])! + } + let ledger = PushDismissalLedger(defaults: defaults) + ledger.remember(identity(2), now: 100) + ledger.remember(identity(1), now: 101) + let restored = PushDismissalLedger(defaults: defaults) + precondition(restored.contains(identity(1), now: 102)) + precondition(restored.contains(identity(2), now: 102)) + precondition(!restored.contains(identity(3), now: 102)) + precondition(!restored.contains(identity(1, "other"), now: 102)) + precondition(!restored.contains(identity(1, "qa-host", "other"), now: 102)) + precondition(!restored.contains(identity(1), now: 86501)) + precondition(PushDismissalIdentity(["hostFingerprint":"h", "notificationId":"n", "notificationEpoch":"e", "notificationSeq":true]) == nil) + print("Native persisted fence: restart, ordering, identity isolation, expiry and invalid sequence checks passed") + } +} diff --git a/mobile/src/notifications/mobile-notifications.ts b/mobile/src/notifications/mobile-notifications.ts index 1ab9c6fcd94..508952afb7f 100644 --- a/mobile/src/notifications/mobile-notifications.ts +++ b/mobile/src/notifications/mobile-notifications.ts @@ -1,3 +1,4 @@ +import { requestNotificationCatchup } from './push-dismissal-reconciliation' import { waitForSocketPushHandoff } from './socket-push-delivery-handoff' import type { RpcClient } from '../transport/rpc-client' export { @@ -41,7 +42,6 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin let subscriptionId: string | null = null let disposed = false const deliveryAbort = new AbortController() - // Preserve the watermark across socket reconnects. const session = getHostNotificationSession(hostId) /** @@ -148,14 +148,16 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin const askFrom = catchUpWatermarkSeq(session) // Read concurrently; claim inside the queue after epoch adoption to avoid stale keys. const presentedPushKeys = readPresentedPushSeenKeys(hostId) - const missed = await client - .sendRequest('notifications.getMissedSince', { + const missed = await requestNotificationCatchup( + client, + hostId, + { lastSeenSeq: askFrom, includeDesktopSuppressed: true, - // Why: sending the epoch lets the desktop reject a watermark from a counter - // it no longer has and return the whole retained buffer instead of nothing. ...(session.lastDeliveredEpoch != null ? { epoch: session.lastDeliveredEpoch } : {}) - }) + }, + () => disposed + ) .then((response) => { if (!response.ok) { return null diff --git a/mobile/src/notifications/native-push-dismissal.native.ts b/mobile/src/notifications/native-push-dismissal.native.ts new file mode 100644 index 00000000000..c62ec10d52b --- /dev/null +++ b/mobile/src/notifications/native-push-dismissal.native.ts @@ -0,0 +1,6 @@ +import { requireOptionalNativeModule } from 'expo-modules-core' +import type { NativeDismissal } from './native-push-dismissal' + +export const nativePushDismissal = requireOptionalNativeModule( + 'OrcaNotificationDismissal' +) diff --git a/mobile/src/notifications/native-push-dismissal.ts b/mobile/src/notifications/native-push-dismissal.ts new file mode 100644 index 00000000000..f3d64eb4084 --- /dev/null +++ b/mobile/src/notifications/native-push-dismissal.ts @@ -0,0 +1,8 @@ +import type { OrcaPushPayload } from './push-payload' + +export type NativeDismissal = { + remember(payload: OrcaPushPayload): Promise + wasDismissed(payload: OrcaPushPayload): Promise +} +// Web has no native notification center; native shells resolve the .native module. +export const nativePushDismissal: NativeDismissal | null = null diff --git a/mobile/src/notifications/push-dismissal-reconciliation.test.ts b/mobile/src/notifications/push-dismissal-reconciliation.test.ts new file mode 100644 index 00000000000..f063ec32859 --- /dev/null +++ b/mobile/src/notifications/push-dismissal-reconciliation.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import * as Notifications from 'expo-notifications' +import { loadHostCatalog } from '../transport/host-store' +import { deriveHostFingerprint } from './push-host-fingerprint' +import { requestNotificationCatchup } from './push-dismissal-reconciliation' +vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn() })) +vi.mock('expo-notifications', () => ({ + getPresentedNotificationsAsync: vi.fn(), + dismissNotificationAsync: vi.fn() +})) +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: async () => null, setItem: async () => {} } +})) +const publicKeyB64 = Buffer.alloc(32, 1).toString('base64') +const hostFingerprint = deriveHostFingerprint(publicKeyB64) +const id = { + notificationId: 'old-alert', + notificationEpoch: 'previous-host-process', + notificationSeq: 12 +} +function presented(identifier: string, overrides = {}) { + return { request: { identifier, content: { data: { hostFingerprint, ...id, ...overrides } } } } +} +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(loadHostCatalog).mockResolvedValue([{ id: 'host-a', publicKeyB64 }] as never) + vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([ + presented('old'), + presented('new', { notificationSeq: 14 }), + presented('other', { hostFingerprint: 'other-host' }), + presented('summary', { coalescedCount: 2 }) + ] as never) + vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) +}) +it('clears a confirmed prior-epoch alert even with empty replay and preserves newer, other-host and summary entries', async () => { + const sendRequest = vi.fn(async () => ({ + ok: true, + result: { notifications: [], epoch: 'new-process', dismissedPushes: [id] } + })) + await requestNotificationCatchup( + { sendRequest } as never, + 'host-a', + { lastSeenSeq: 20 }, + () => false + ) + expect(sendRequest).toHaveBeenCalledWith('notifications.getMissedSince', { + lastSeenSeq: 20, + deliveredPushes: [id, { ...id, notificationSeq: 14 }] + }) + expect(Notifications.dismissNotificationAsync).toHaveBeenCalledExactlyOnceWith('old') +}) +it('keeps alerts when an old host omits reconciliation or the request fails', async () => { + for (const response of [{ ok: true, result: { notifications: [] } }, { ok: false }]) { + 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 () => { + const sendRequest = vi.fn(async () => ({ + ok: true, + result: { dismissedPushes: [{ ...id, notificationSeq: 99 }] } + })) + 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 + ) + expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled() +}) diff --git a/mobile/src/notifications/push-dismissal-reconciliation.ts b/mobile/src/notifications/push-dismissal-reconciliation.ts new file mode 100644 index 00000000000..619b1af9c46 --- /dev/null +++ b/mobile/src/notifications/push-dismissal-reconciliation.ts @@ -0,0 +1,92 @@ +import * as Notifications from 'expo-notifications' +import type { RpcClient } from '../transport/rpc-client' +import { loadHostCatalog } from '../transport/host-store' +import { resolveHostIdForFingerprint } from './push-host-fingerprint' +import { readNativeNotificationData } from './native-notification-data' +import { readOrcaPushPayload, type OrcaPushPayload } from './push-payload' +import { dismissPresentedPushNotification } from './push-tray-dismissal' + +type Identity = { notificationId: string; notificationEpoch: string; notificationSeq: number } +const key = (item: Identity) => + JSON.stringify([item.notificationId, item.notificationEpoch, item.notificationSeq]) +function identity(value: unknown): Identity | null { + if (!value || typeof value !== 'object') { + return null + } + const item = value as Identity + return typeof item.notificationId === 'string' && + item.notificationId.length > 0 && + item.notificationId.length <= 512 && + typeof item.notificationEpoch === 'string' && + item.notificationEpoch.length > 0 && + item.notificationEpoch.length <= 128 && + Number.isSafeInteger(item.notificationSeq) && + item.notificationSeq >= 0 + ? { + notificationId: item.notificationId, + notificationEpoch: item.notificationEpoch, + notificationSeq: item.notificationSeq + } + : null +} +async function readDelivered(hostId: string): Promise> { + const selected = new Map() + try { + const [presented, hosts] = await Promise.all([ + Notifications.getPresentedNotificationsAsync(), + loadHostCatalog() + ]) + for (const notification of presented) { + const payload = readOrcaPushPayload(readNativeNotificationData(notification.request)) + const id = identity(payload) + if ( + !payload || + !id || + (payload.coalescedCount ?? 0) > 1 || + resolveHostIdForFingerprint(payload.hostFingerprint, hosts) !== hostId + ) { + continue + } + selected.set(key(id), payload) + if (selected.size === 256) { + break + } + } + } catch { + // Legacy shells can still use ordinary event replay without tray inspection. + } + return selected +} + +export async function requestNotificationCatchup( + client: Pick, + hostId: string, + params: { lastSeenSeq: number; epoch?: string; includeDesktopSuppressed?: boolean }, + isDisposed: () => boolean +) { + const delivered = await readDelivered(hostId) + const response = await client.sendRequest('notifications.getMissedSince', { + ...params, + ...(delivered.size + ? { deliveredPushes: [...delivered.values()].map((payload) => identity(payload)!) } + : {}) + }) + if (!response.ok || isDisposed()) { + return response + } + const result = response.result as { dismissedPushes?: unknown } | undefined + // Older hosts ignore the optional request field and return no reconciliation result. + if (Array.isArray(result?.dismissedPushes)) { + for (const raw of result.dismissedPushes.slice(0, 256)) { + if (isDisposed()) { + break + } + const id = identity(raw) + const payload = id ? delivered.get(key(id)) : undefined + if (payload && id) { + await dismissPresentedPushNotification(id.notificationId, payload.hostFingerprint, id) + } + } + } + return response +} diff --git a/mobile/src/notifications/push-dismissal-watermarks.ts b/mobile/src/notifications/push-dismissal-watermarks.ts index 3c1272d6938..6a370d795a1 100644 --- a/mobile/src/notifications/push-dismissal-watermarks.ts +++ b/mobile/src/notifications/push-dismissal-watermarks.ts @@ -1,5 +1,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage' import type { OrcaPushPayload } from './push-payload' +import { nativePushDismissal } from './native-push-dismissal' const STORAGE_KEY = 'orca:pushDismissalWatermarks:v1' const RETENTION_MS = 24 * 60 * 60 * 1000 @@ -45,10 +46,18 @@ async function readEntries(): Promise { } } -export function rememberPushDismissal(payload: OrcaPushPayload): Promise { +export async function rememberPushDismissal(payload: OrcaPushPayload): Promise { const key = eventKey(payload) if (!key) { - return Promise.resolve() + return + } + if (nativePushDismissal) { + try { + await nativePushDismissal.remember(payload) + return + } catch { + // Keep recovery available if the native bridge is unavailable during reload. + } } const pending = writes.then(async () => { const entries = await readEntries() @@ -77,6 +86,9 @@ export async function wasPushDismissed(payload: OrcaPushPayload): Promise false))) { + return true + } return (await readEntries()).some( (entry) => entry.key === key && entry.seq >= payload.notificationSeq! ) diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index 8d8098f538b..274ab2719d8 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -1,4 +1,5 @@ -import { BrowserWindow, Notification, ipcMain } from 'electron' +import { BrowserWindow, Notification, ipcMain, powerMonitor } from 'electron' +import { readDesktopAwayState } from '../notifications/desktop-away-state' import type { Store } from '../persistence' import type { NotificationDeliveryProbeResult, @@ -26,6 +27,8 @@ import { } from './notification-permission-probe' export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntimeService): void { + ipcMain.removeHandler('notifications:getDesktopAwayState') + ipcMain.handle('notifications:getDesktopAwayState', () => readDesktopAwayState(powerMonitor)) const recentDesktopNotifications = new Map() const recentMobileNotifications = new Map() resetNotificationPermissionEvidence() diff --git a/src/main/runtime/mobile-notification-dismissal-store.test.ts b/src/main/runtime/mobile-notification-dismissal-store.test.ts new file mode 100644 index 00000000000..7679c55d31d --- /dev/null +++ b/src/main/runtime/mobile-notification-dismissal-store.test.ts @@ -0,0 +1,57 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { MobileNotificationDismissalStore } from './mobile-notification-dismissal-store' +const paths: string[] = [] +afterEach(() => { + paths.splice(0).forEach((path) => rmSync(path, { recursive: true, force: true })) + vi.restoreAllMocks() +}) +function fixture() { + const path = mkdtempSync(join(tmpdir(), 'orca-dismissals-')) + paths.push(path) + return { path, store: new MobileNotificationDismissalStore(path) } +} +const shown = { notificationId: 'same', notificationEpoch: 'old', notificationSeq: 12 } +const alert = { + type: 'notification' as const, + source: 'terminal-bell' as const, + title: 'QA', + body: '' +} +it('reconciles an old delivered alert after desktop restart and preserves unrelated identities', () => { + const h = fixture() + h.store.record({ ...alert, ...shown }) + const restarted = new MobileNotificationDismissalStore(h.path) + restarted.record({ + type: 'dismiss', + notificationId: 'same', + notificationEpoch: 'new', + notificationSeq: 1 + }) + const loaded = new MobileNotificationDismissalStore(h.path) + expect( + loaded.reconcile([ + shown, + { ...shown, notificationEpoch: 'other' }, + { ...shown, notificationId: 'other' }, + { ...shown, notificationSeq: 13 } + ]) + ).toEqual([shown]) +}) +it('does not dismiss a newer replacement and does not treat missing or expired history as dismissal', () => { + const h = fixture() + const now = Date.now() + vi.spyOn(Date, 'now').mockReturnValue(now) + h.store.record({ ...alert, ...shown }) + h.store.record({ type: 'dismiss', ...shown, notificationSeq: 13 }) + expect(h.store.reconcile([shown])).toEqual([shown]) + h.store.record({ ...alert, ...shown, notificationSeq: 14 }) + expect(h.store.reconcile([{ ...shown, notificationSeq: 14 }])).toEqual([]) + expect(h.store.reconcile([shown])).toEqual([shown]) + h.store.record({ type: 'dismiss', ...shown, notificationSeq: 15 }) + vi.mocked(Date.now).mockReturnValue(now + 7 * 86400_000) + expect(h.store.reconcile([shown])).toEqual([]) + expect(new MobileNotificationDismissalStore(`${h.path}-unknown`).reconcile([shown])).toEqual([]) +}) diff --git a/src/main/runtime/mobile-notification-dismissal-store.ts b/src/main/runtime/mobile-notification-dismissal-store.ts new file mode 100644 index 00000000000..3c3b1e4f6f9 --- /dev/null +++ b/src/main/runtime/mobile-notification-dismissal-store.ts @@ -0,0 +1,109 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { writeSecureJsonFile, hardenExistingSecureFile } from '../../shared/secure-file' +import type { MobileNotificationEvent } from './runtime-mobile-notification-controller' + +export type DeliveredNotificationIdentity = { + notificationId: string + notificationEpoch: string + notificationSeq: number +} +type RecordEntry = DeliveredNotificationIdentity & { dismissedThrough: number; expiresAt: number } +const LIMIT = 4096 +const RETENTION_MS = 7 * 86400_000 + +export class MobileNotificationDismissalStore { + private readonly path: string + private entries: RecordEntry[] = [] + constructor(userDataPath: string) { + this.path = join(userDataPath, 'mobile-notification-dismissals.json') + if (!existsSync(this.path)) { + return + } + try { + hardenExistingSecureFile(this.path) + const value: unknown = JSON.parse(readFileSync(this.path, 'utf8')) + if (Array.isArray(value)) { + this.entries = value.filter(isEntry).slice(-LIMIT) + } + } catch { + // Missing history cannot establish that a delivered alert was dismissed. + } + } + + record( + event: MobileNotificationEvent & { notificationEpoch: string; notificationSeq: number } + ): void { + if (!event.notificationId) { + return + } + const now = Date.now() + const kept = this.entries.filter((entry) => entry.expiresAt > now) + const same = (entry: RecordEntry) => + entry.notificationId === event.notificationId && + entry.notificationEpoch === event.notificationEpoch + let next: RecordEntry[] + if (event.type === 'notification') { + next = [ + ...kept.filter((entry) => !same(entry)), + { + notificationId: event.notificationId, + notificationEpoch: event.notificationEpoch, + notificationSeq: event.notificationSeq, + dismissedThrough: kept.find(same)?.dismissedThrough ?? -1, + expiresAt: now + RETENTION_MS + } + ] + } else { + next = kept + .filter((entry) => !same(entry)) + .map((entry) => + entry.notificationId === event.notificationId + ? { ...entry, dismissedThrough: entry.notificationSeq, expiresAt: now + RETENTION_MS } + : entry + ) + next.push({ + notificationId: event.notificationId, + notificationEpoch: event.notificationEpoch, + notificationSeq: event.notificationSeq, + dismissedThrough: event.notificationSeq, + expiresAt: now + RETENTION_MS + }) + } + next = next.slice(-LIMIT) + writeSecureJsonFile(this.path, next) + this.entries = next + } + + reconcile(delivered: readonly DeliveredNotificationIdentity[]): DeliveredNotificationIdentity[] { + const now = Date.now() + return delivered.filter((item) => + this.entries.some( + (entry) => + entry.dismissedThrough >= 0 && + entry.expiresAt > now && + entry.notificationId === item.notificationId && + entry.notificationEpoch === item.notificationEpoch && + entry.dismissedThrough >= item.notificationSeq + ) + ) + } +} + +function isEntry(value: unknown): value is RecordEntry { + if (!value || typeof value !== 'object') { + return false + } + const item = value as RecordEntry + return ( + typeof item.notificationId === 'string' && + item.notificationId.length > 0 && + typeof item.notificationEpoch === 'string' && + item.notificationEpoch.length > 0 && + Number.isSafeInteger(item.notificationSeq) && + item.notificationSeq >= 0 && + Number.isSafeInteger(item.dismissedThrough) && + item.dismissedThrough >= -1 && + Number.isFinite(item.expiresAt) + ) +} diff --git a/src/main/runtime/rpc/methods/notifications.ts b/src/main/runtime/rpc/methods/notifications.ts index b8d7a01647e..faa97cf31d0 100644 --- a/src/main/runtime/rpc/methods/notifications.ts +++ b/src/main/runtime/rpc/methods/notifications.ts @@ -34,7 +34,17 @@ const NotificationUnsubscribeParams = z.object({ const NotificationGetMissedSinceParams = z.object({ lastSeenSeq: z.number().int().min(0, 'lastSeenSeq must be a non-negative integer'), epoch: z.string().optional(), - includeDesktopSuppressed: z.boolean().optional() + includeDesktopSuppressed: z.boolean().optional(), + deliveredPushes: z + .array( + z.object({ + notificationId: z.string().min(1).max(512), + notificationEpoch: z.string().min(1).max(128), + notificationSeq: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER) + }) + ) + .max(256) + .optional() }) // Why: the phone owns which alerts are worth waking it for; the host stores the @@ -122,7 +132,10 @@ export const NOTIFICATION_METHODS: readonly RpcAnyMethod[] = [ notifications: missed.filter( createNotificationStreamFilter(params.includeDesktopSuppressed) ), - epoch: runtime.getMobileNotificationEpoch() + epoch: runtime.getMobileNotificationEpoch(), + ...(params.deliveredPushes + ? { dismissedPushes: runtime.reconcileDismissedPushes(params.deliveredPushes) } + : {}) } } }), diff --git a/src/main/runtime/runtime-mobile-notification-controller.ts b/src/main/runtime/runtime-mobile-notification-controller.ts index 5f8192aa0d6..9c58f0de683 100644 --- a/src/main/runtime/runtime-mobile-notification-controller.ts +++ b/src/main/runtime/runtime-mobile-notification-controller.ts @@ -6,6 +6,10 @@ import type { import { MobileNotificationReplayBuffer } from './mobile-notification-replay' import { notifyRuntimeListeners } from './runtime-async-boundaries' import { getRuntimeDesktopSurface } from './runtime-desktop-surface' +import { + MobileNotificationDismissalStore, + type DeliveredNotificationIdentity +} from './mobile-notification-dismissal-store' export type MobileNotificationDispatchEvent = { type: 'notification' @@ -45,6 +49,17 @@ export class RuntimeMobileNotificationController { private readonly listeners = new Set<(event: MobileNotificationEvent) => void>() private readonly replay = new MobileNotificationReplayBuffer() private pushRegistrar: MobilePushRegistrar | null = null + private dismissalStore: MobileNotificationDismissalStore | null = null + + configureDismissalStore(userDataPath: string): void { + this.dismissalStore = new MobileNotificationDismissalStore(userDataPath) + } + + reconcileDismissedPushes( + delivered: readonly DeliveredNotificationIdentity[] + ): DeliveredNotificationIdentity[] { + return this.dismissalStore?.reconcile(delivered) ?? [] + } setPushRegistrar(registrar: MobilePushRegistrar | null): void { this.pushRegistrar = registrar @@ -77,6 +92,15 @@ export class RuntimeMobileNotificationController { event = { ...event, desktopAway: getRuntimeDesktopSurface().isAwayForMobileNotifications?.() } } const seq = this.replay.record(event) + try { + this.dismissalStore?.record({ + ...event, + notificationSeq: seq, + notificationEpoch: this.replay.epoch + }) + } catch { + console.warn('[notifications] Could not persist dismissal recovery state') + } notifyRuntimeListeners( this.listeners, (listener) => diff --git a/src/main/runtime/runtime-rpc-mobile-method-allowlist-fixtures.ts b/src/main/runtime/runtime-rpc-mobile-method-allowlist-fixtures.ts index 7a97745764d..00bf253fe89 100644 --- a/src/main/runtime/runtime-rpc-mobile-method-allowlist-fixtures.ts +++ b/src/main/runtime/runtime-rpc-mobile-method-allowlist-fixtures.ts @@ -117,6 +117,7 @@ export function createMobileRpcSurfaceRuntime() { .fn() .mockResolvedValue({ ok: true, id: 'comment-1' }) const runtime = { + configureNotificationDismissalStore: () => {}, getRuntimeId: () => 'test-runtime', getStatus, pushRuntimeGit, diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts index dc54275dcde..e7f56ceed13 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts @@ -132,5 +132,6 @@ export class RuntimeRpcState { this.specializedLongPollCap = Math.max(1, Math.floor(longPollCap * SPECIALIZED_LONG_POLL_SHARE)) this.relayRevokeOutbox = new RelayRevokeOutbox(userDataPath) this.pushUnregisterOutbox = new PushUnregisterOutbox(userDataPath) + this.runtime.configureNotificationDismissalStore(userDataPath) } } diff --git a/src/main/runtime/runtime-service-command-surface.ts b/src/main/runtime/runtime-service-command-surface.ts index 23d5b9e0686..b7811dbc073 100644 --- a/src/main/runtime/runtime-service-command-surface.ts +++ b/src/main/runtime/runtime-service-command-surface.ts @@ -27,6 +27,8 @@ export type RuntimeServiceCommandSurface = { getMobileNotificationListenerCount: RuntimeMobileNotificationController['getListenerCount'] dispatchMobileNotification: RuntimeMobileNotificationController['dispatch'] getMissedNotificationsSince: RuntimeMobileNotificationController['getMissedSince'] + configureNotificationDismissalStore: RuntimeMobileNotificationController['configureDismissalStore'] + reconcileDismissedPushes: RuntimeMobileNotificationController['reconcileDismissedPushes'] getMobileNotificationEpoch: RuntimeMobileNotificationController['getEpoch'] dismissMobileNotification: RuntimeMobileNotificationController['dismiss'] dispatchPluginNotification: RuntimeMobileNotificationController['dispatchPlugin'] @@ -110,6 +112,8 @@ export function installRuntimeServiceCommandSurface( getMobileNotificationListenerCount: notifications.getListenerCount.bind(notifications), dispatchMobileNotification: notifications.dispatch.bind(notifications), getMissedNotificationsSince: notifications.getMissedSince.bind(notifications), + configureNotificationDismissalStore: notifications.configureDismissalStore.bind(notifications), + reconcileDismissedPushes: notifications.reconcileDismissedPushes.bind(notifications), getMobileNotificationEpoch: notifications.getEpoch.bind(notifications), dismissMobileNotification: notifications.dismiss.bind(notifications), dispatchPluginNotification: notifications.dispatchPlugin.bind(notifications), diff --git a/src/preload/api/notifications-bridge.ts b/src/preload/api/notifications-bridge.ts index 70c64d4ce0d..210352ff981 100644 --- a/src/preload/api/notifications-bridge.ts +++ b/src/preload/api/notifications-bridge.ts @@ -37,6 +37,8 @@ function disposeCachedNotificationSound(): void { } export const notificationsApi = { + getDesktopAwayState: (): Promise => + ipcRenderer.invoke('notifications:getDesktopAwayState'), dispatch: (args: Record): Promise => ipcRenderer.invoke('notifications:dispatch', args), dismiss: (ids: string[]): Promise => diff --git a/src/preload/api/os-permission-api.ts b/src/preload/api/os-permission-api.ts index 8718cfc29a5..f2033c2fe1c 100644 --- a/src/preload/api/os-permission-api.ts +++ b/src/preload/api/os-permission-api.ts @@ -20,6 +20,7 @@ import type { } from '../../shared/notification-settings-types' export type NotificationsApi = { + getDesktopAwayState: () => Promise dispatch: (args: NotificationDispatchRequest) => Promise dismiss: (ids: string[]) => Promise openSystemSettings: () => Promise diff --git a/src/renderer/src/hooks/agent-auto-ack-presence.ts b/src/renderer/src/hooks/agent-auto-ack-presence.ts new file mode 100644 index 00000000000..135f9f9b71c --- /dev/null +++ b/src/renderer/src/hooks/agent-auto-ack-presence.ts @@ -0,0 +1,53 @@ +export function createAutoAckPresenceCheck( + readAway: () => Promise, + onPresent: () => void +): { request: () => void; dispose: () => void } { + let disposed = false + let pending = false + return { + request() { + if (disposed || pending) { + return + } + pending = true + void readAway() + .then((away) => { + // Unknown presence must not clear unread attention. + if (!disposed && away === false) { + onPresent() + } + }) + .catch(() => {}) + .finally(() => { + pending = false + }) + }, + dispose() { + disposed = true + } + } +} + +export function subscribeAutoAckPresenceSignals( + onRescan: () => void, + onInput: () => void +): () => void { + const input = (event: Event): void => { + if (event.isTrusted) { + onInput() + } + } + document.addEventListener('visibilitychange', onRescan) + window.addEventListener('focus', onRescan) + const events = ['pointerdown', 'keydown', 'pointermove'] as const + for (const event of events) { + window.addEventListener(event, input) + } + return () => { + document.removeEventListener('visibilitychange', onRescan) + window.removeEventListener('focus', onRescan) + for (const event of events) { + window.removeEventListener(event, input) + } + } +} diff --git a/src/renderer/src/hooks/agent-auto-ack-targets.ts b/src/renderer/src/hooks/agent-auto-ack-targets.ts new file mode 100644 index 00000000000..7aab6946c9b --- /dev/null +++ b/src/renderer/src/hooks/agent-auto-ack-targets.ts @@ -0,0 +1,34 @@ +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' + +export type AutoAckTabTarget = { tabId: string; worktreeId: string | null } + +/** + * Tabs whose visible pane counts as "seen" right now, each paired with the worktree that owns it. + * + * Why the floating workspace is gated on panel visibility rather than `activeView`: the panel is an + * overlay that sits above every view and stays mounted while closed, and its active tab never + * becomes the global `activeTabId` — so neither the view nor the tab id can stand in for "on screen". + */ +export function resolveAutoAckTabTargets( + state: { + activeView: string + activeTabId: string | null + activeWorktreeId: string | null + activeTabIdByWorktree: Record + }, + options: { floatingPanelVisible: boolean } +): AutoAckTabTarget[] { + const targets: AutoAckTabTarget[] = [] + if (state.activeView === 'terminal' && state.activeTabId) { + targets.push({ tabId: state.activeTabId, worktreeId: state.activeWorktreeId }) + } + if (options.floatingPanelVisible) { + const floatingTabId = state.activeTabIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? null + // Why first-wins on a tab-id collision: tab ids can be claimed by two worktrees + // (see active-tab-owner-worktree), and acking under the wrong one strands its unread dot. + if (floatingTabId && !targets.some((target) => target.tabId === floatingTabId)) { + targets.push({ tabId: floatingTabId, worktreeId: FLOATING_TERMINAL_WORKTREE_ID }) + } + } + return targets +} diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.away.test.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.away.test.ts new file mode 100644 index 00000000000..ad7fb1e255e --- /dev/null +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.away.test.ts @@ -0,0 +1,86 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useAutoAckViewedAgent } from './useAutoAckViewedAgent' +import { useAppStore } from '../store' +import { makeTab } from '../store/slices/store-test-helpers' +import { makePaneKey } from '../../../shared/stable-pane-id' + +const leaf = '11111111-1111-4111-8111-111111111111' +const pane = makePaneKey('away-tab', leaf) +const readAway = vi.fn<() => Promise>() +const dismiss = vi.fn() +const previousApi = window.api +beforeEach(() => { + readAway.mockReset().mockResolvedValue(true) + dismiss.mockReset() + Object.assign(window, { api: { notifications: { getDesktopAwayState: readAway, dismiss } } }) + vi.spyOn(document, 'hasFocus').mockReturnValue(true) + useAppStore.setState({ + activeView: 'terminal', + activeTabId: 'away-tab', + activeWorktreeId: 'away-workspace', + activeTabIdByWorktree: {}, + tabsByWorktree: { + 'away-workspace': [makeTab({ id: 'away-tab', worktreeId: 'away-workspace' })] + }, + terminalLayoutsByTabId: { + 'away-tab': { root: null, activeLeafId: leaf, expandedLeafId: null } + }, + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {}, + acknowledgedAgentsByPaneKey: {}, + unreadAgentCompletionPanes: {}, + unreadTerminalTabs: {}, + manuallyUnreadTurnsByPaneKey: {} + }) + useAppStore + .getState() + .setAgentStatus(pane, { state: 'done', prompt: 'away test', agentType: 'codex' }) + useAppStore.getState().markAgentCompletionPaneUnread(pane) +}) +afterEach(() => { + cleanup() + Object.assign(window, { api: previousApi }) + vi.restoreAllMocks() +}) + +it('leaves the focused pane unread while desktop is away, then acknowledges on user return', async () => { + renderHook(() => useAutoAckViewedAgent(false)) + await act(async () => { + await Promise.resolve() + }) + expect(useAppStore.getState().unreadAgentCompletionPanes[pane]).toBe(true) + expect(dismiss).not.toHaveBeenCalled() + readAway.mockResolvedValue(false) + const input = new Event('pointerdown') + Object.defineProperty(input, 'isTrusted', { value: true }) + act(() => window.dispatchEvent(input)) + await waitFor(() => + expect(useAppStore.getState().unreadAgentCompletionPanes[pane]).toBeUndefined() + ) + expect(dismiss).toHaveBeenCalledTimes(1) +}) + +it('does not acknowledge when the presence query fails or the hook unmounts', async () => { + let resolve!: (away: boolean) => void + readAway.mockImplementation( + () => + new Promise((r) => { + resolve = r + }) + ) + const hook = renderHook(() => useAutoAckViewedAgent(false)) + hook.unmount() + await act(async () => { + resolve(false) + }) + expect(useAppStore.getState().unreadAgentCompletionPanes[pane]).toBe(true) + readAway.mockRejectedValue(new Error('unavailable')) + renderHook(() => useAutoAckViewedAgent(false)) + await act(async () => { + await Promise.resolve() + }) + expect(useAppStore.getState().unreadAgentCompletionPanes[pane]).toBe(true) + expect(dismiss).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts index 2c69593563e..2664536c756 100644 --- a/src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts @@ -2,6 +2,7 @@ import { cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as AgentAutoAckPresence from './agent-auto-ack-presence' import { useAutoAckViewedAgent } from './useAutoAckViewedAgent' import { useAppStore } from '../store' import { makeTab } from '../store/slices/store-test-helpers' @@ -13,6 +14,15 @@ import type { AgentStatusEntry } from '../../../shared/agent-status-types' // acknowledgeAgents returned the same object within one millisecond — a scan costing >=1ms with a // turn stamped ahead of the local clock (SSH/remote host) re-acked forever (React #185). +// These suites isolate synchronous acknowledgement and layout behavior. +vi.mock('./agent-auto-ack-presence', async (importOriginal) => ({ + ...(await importOriginal()), + createAutoAckPresenceCheck: (_read: unknown, onPresent: () => void) => ({ + request: onPresent, + dispose() {} + }) +})) + const TAB_ID = 'tab-main' const LEAF_ID = '11111111-1111-4111-8111-111111111111' const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID) diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.floating-panel.test.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.floating-panel.test.ts index fc31eaa9506..d9cbff6a292 100644 --- a/src/renderer/src/hooks/useAutoAckViewedAgent.floating-panel.test.ts +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.floating-panel.test.ts @@ -2,6 +2,7 @@ import { cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as AgentAutoAckPresence from './agent-auto-ack-presence' import { useAutoAckViewedAgent } from './useAutoAckViewedAgent' import { useAppStore } from '../store' import { selectFloatingWorkspaceHasUnread } from '../store/selectors' @@ -9,6 +10,15 @@ import { makeTab } from '../store/slices/store-test-helpers' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { makePaneKey } from '../../../shared/stable-pane-id' +// These suites isolate synchronous acknowledgement and layout behavior. +vi.mock('./agent-auto-ack-presence', async (importOriginal) => ({ + ...(await importOriginal()), + createAutoAckPresenceCheck: (_read: unknown, onPresent: () => void) => ({ + request: onPresent, + dispose() {} + }) +})) + const FLOATING_TAB_ID = 'tab-floating' const MAIN_TAB_ID = 'tab-main' const LEAF_ID = '11111111-1111-4111-8111-111111111111' diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.ts index 40a3591e2b0..cc96c1d1d49 100644 --- a/src/renderer/src/hooks/useAutoAckViewedAgent.ts +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.ts @@ -1,4 +1,10 @@ +import { resolveAutoAckTabTargets } from './agent-auto-ack-targets' +export { resolveAutoAckTabTargets, type AutoAckTabTarget } from './agent-auto-ack-targets' import { useEffect, useRef } from 'react' +import { + createAutoAckPresenceCheck, + subscribeAutoAckPresenceSignals +} from './agent-auto-ack-presence' import { useAppStore } from '@/store' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import type { AgentStatusEntry } from '../../../shared/agent-status-types' @@ -189,39 +195,6 @@ export function acknowledgeViewedAgentAttention( } } -export type AutoAckTabTarget = { tabId: string; worktreeId: string | null } - -/** - * Tabs whose visible pane counts as "seen" right now, each paired with the worktree that owns it. - * - * Why the floating workspace is gated on panel visibility rather than `activeView`: the panel is an - * overlay that sits above every view and stays mounted while closed, and its active tab never - * becomes the global `activeTabId` — so neither the view nor the tab id can stand in for "on screen". - */ -export function resolveAutoAckTabTargets( - state: { - activeView: string - activeTabId: string | null - activeWorktreeId: string | null - activeTabIdByWorktree: Record - }, - options: { floatingPanelVisible: boolean } -): AutoAckTabTarget[] { - const targets: AutoAckTabTarget[] = [] - if (state.activeView === 'terminal' && state.activeTabId) { - targets.push({ tabId: state.activeTabId, worktreeId: state.activeWorktreeId }) - } - if (options.floatingPanelVisible) { - const floatingTabId = state.activeTabIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? null - // Why first-wins on a tab-id collision: tab ids can be claimed by two worktrees - // (see active-tab-owner-worktree), and acking under the wrong one strands its unread dot. - if (floatingTabId && !targets.some((target) => target.tabId === floatingTabId)) { - targets.push({ tabId: floatingTabId, worktreeId: FLOATING_TERMINAL_WORKTREE_ID }) - } - } - return targets -} - // Auto-ack an agent row as "seen" when the user is already on its tab, so the dashboard/Dock don't stay bold for an event they watched happen. // Scans live + retained maps: Codex's title-revert (pty-connection.ts:onAgentExited) migrates `done` rows to retained mid-race — see docs/codex-agent-row-bold-stuck.md. export function useAutoAckViewedAgent(floatingPanelVisible: boolean): void { @@ -243,7 +216,11 @@ export function useAutoAckViewedAgent(floatingPanelVisible: boolean): void { let lastUnreadAgentCompletionPanes: unknown = undefined // `force` re-scans after a signal the store never sees: panel open/closed is React-local state. - const maybeAck = (options?: { force?: boolean }): void => { + const presence = createAutoAckPresenceCheck( + async () => window.api?.notifications?.getDesktopAwayState?.(), + () => maybeAck({ force: true, presenceConfirmed: true }) + ) + const maybeAck = (options?: { force?: boolean; presenceConfirmed?: boolean }): void => { const s = useAppStore.getState() const floatingWorkspaceActiveTabId = s.activeTabIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? null @@ -279,6 +256,10 @@ export function useAutoAckViewedAgent(floatingPanelVisible: boolean): void { if (targets.length === 0) { return } + if (!options?.presenceConfirmed) { + presence.request() + return + } // Why: advance refs only after gates pass, else the diff is consumed and a gated-out transition never re-acks when focus returns. lastActiveView = s.activeView lastActiveTabId = s.activeTabId @@ -341,16 +322,15 @@ export function useAutoAckViewedAgent(floatingPanelVisible: boolean): void { maybeAck() // Subscribe to all store changes; the ref-equality guard above skips unrelated updates. const unsubscribe = useAppStore.subscribe(() => maybeAck()) - // Why: focus/visibility don't flow through zustand, so re-run the scan on these DOM events when focus returns. - const onVisibility = (): void => maybeAck() - const onFocus = (): void => maybeAck() - document.addEventListener('visibilitychange', onVisibility) - window.addEventListener('focus', onFocus) + const stopPresenceSignals = subscribeAutoAckPresenceSignals( + () => maybeAck(), + () => maybeAck({ presenceConfirmed: true }) + ) return () => { + presence.dispose() rescanRef.current = null unsubscribe() - document.removeEventListener('visibilitychange', onVisibility) - window.removeEventListener('focus', onFocus) + stopPresenceSignals() } }, []) diff --git a/src/renderer/src/web/preload-api/web-notifications-api.ts b/src/renderer/src/web/preload-api/web-notifications-api.ts index 813d694ba82..bf3d525f743 100644 --- a/src/renderer/src/web/preload-api/web-notifications-api.ts +++ b/src/renderer/src/web/preload-api/web-notifications-api.ts @@ -3,6 +3,7 @@ import { getBrowserPlatform } from './web-storage' export function createNotificationsApi(): NonNullable['notifications']> { return { + getDesktopAwayState: async () => undefined, dispatch: () => Promise.resolve({ delivered: false, reason: 'not-supported' }), dismiss: () => Promise.resolve({ dismissed: 0 }), openSystemSettings: () => Promise.resolve(),