mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 00:02:56 +00:00
fix(notifications): preserve away alerts and recover missed dismissals
This commit is contained in:
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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": "<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)`
|
||||
@@ -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": "<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.
|
||||
@@ -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": "<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
|
||||
@@ -96,6 +106,7 @@ tokens are FCM registration strings.
|
||||
### Send
|
||||
|
||||
`POST /v1/send` (Bearer)
|
||||
|
||||
```json
|
||||
{ "v": 1,
|
||||
"registrationIds": ["<id>", "..."],
|
||||
@@ -107,10 +118,13 @@ tokens are FCM registration strings.
|
||||
"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 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: <sha256(host + notification identity), or host:<fp>>`
|
||||
- body: `{"aps":{"alert":{"title","body"},"sound":"default","thread-id":"<hostFingerprint>"},
|
||||
"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":"<remaining event lifetime, at most 300s>",
|
||||
"collapse_key":"<sha256(collapseId) hex 32>","notification":{"channel_id":"orca-desktop","tag":"<collapseId>"}},
|
||||
"data":{ all orca fields as strings }}}`
|
||||
"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`)
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"platforms": ["apple"],
|
||||
"apple": {
|
||||
"modules": ["OrcaNotificationDismissalModule"],
|
||||
"appDelegateSubscribers": ["OrcaNotificationDismissalSubscriber"]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "orca-notification-dismissal",
|
||||
"version": "0.0.1",
|
||||
"private": true
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { requireOptionalNativeModule } from 'expo-modules-core'
|
||||
import type { NativeDismissal } from './native-push-dismissal'
|
||||
|
||||
export const nativePushDismissal = requireOptionalNativeModule<NativeDismissal>(
|
||||
'OrcaNotificationDismissal'
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { OrcaPushPayload } from './push-payload'
|
||||
|
||||
export type NativeDismissal = {
|
||||
remember(payload: OrcaPushPayload): Promise<void>
|
||||
wasDismissed(payload: OrcaPushPayload): Promise<boolean>
|
||||
}
|
||||
// Web has no native notification center; native shells resolve the .native module.
|
||||
export const nativePushDismissal: NativeDismissal | null = null
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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<Map<string, OrcaPushPayload>> {
|
||||
const selected = new Map<string, OrcaPushPayload>()
|
||||
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<RpcClient, 'sendRequest'>,
|
||||
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
|
||||
}
|
||||
@@ -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<Entry[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberPushDismissal(payload: OrcaPushPayload): Promise<void> {
|
||||
export async function rememberPushDismissal(payload: OrcaPushPayload): Promise<void> {
|
||||
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<boolea
|
||||
return false
|
||||
}
|
||||
await writes
|
||||
if (nativePushDismissal && (await nativePushDismissal.wasDismissed(payload).catch(() => false))) {
|
||||
return true
|
||||
}
|
||||
return (await readEntries()).some(
|
||||
(entry) => entry.key === key && entry.seq >= payload.notificationSeq!
|
||||
)
|
||||
|
||||
@@ -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<string, number>()
|
||||
const recentMobileNotifications = new Map<string, number>()
|
||||
resetNotificationPermissionEvidence()
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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) }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -117,6 +117,7 @@ export function createMobileRpcSurfaceRuntime() {
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, id: 'comment-1' })
|
||||
const runtime = {
|
||||
configureNotificationDismissalStore: () => {},
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
getStatus,
|
||||
pushRuntimeGit,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -37,6 +37,8 @@ function disposeCachedNotificationSound(): void {
|
||||
}
|
||||
|
||||
export const notificationsApi = {
|
||||
getDesktopAwayState: (): Promise<boolean | undefined> =>
|
||||
ipcRenderer.invoke('notifications:getDesktopAwayState'),
|
||||
dispatch: (args: Record<string, unknown>): Promise<NotificationDispatchResult> =>
|
||||
ipcRenderer.invoke('notifications:dispatch', args),
|
||||
dismiss: (ids: string[]): Promise<NotificationDismissResult> =>
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
} from '../../shared/notification-settings-types'
|
||||
|
||||
export type NotificationsApi = {
|
||||
getDesktopAwayState: () => Promise<boolean | undefined>
|
||||
dispatch: (args: NotificationDispatchRequest) => Promise<NotificationDispatchResult>
|
||||
dismiss: (ids: string[]) => Promise<NotificationDismissResult>
|
||||
openSystemSettings: () => Promise<void>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
export function createAutoAckPresenceCheck(
|
||||
readAway: () => Promise<boolean | undefined>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, string | null>
|
||||
},
|
||||
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
|
||||
}
|
||||
@@ -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<boolean | undefined>>()
|
||||
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()
|
||||
})
|
||||
@@ -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<typeof AgentAutoAckPresence>()),
|
||||
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)
|
||||
|
||||
@@ -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<typeof AgentAutoAckPresence>()),
|
||||
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'
|
||||
|
||||
@@ -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<string, string | null>
|
||||
},
|
||||
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()
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getBrowserPlatform } from './web-storage'
|
||||
|
||||
export function createNotificationsApi(): NonNullable<Partial<PreloadApi>['notifications']> {
|
||||
return {
|
||||
getDesktopAwayState: async () => undefined,
|
||||
dispatch: () => Promise.resolve({ delivered: false, reason: 'not-supported' }),
|
||||
dismiss: () => Promise.resolve({ dismissed: 0 }),
|
||||
openSystemSettings: () => Promise.resolve(),
|
||||
|
||||
Reference in New Issue
Block a user