| 1 | import { type LiveActivity } from "expo-widgets"; |
| 2 | import { |
| 3 | configureAndroidAgentNotifications, |
| 4 | clearAndroidAgentNotifications, |
| 5 | } from "./androidNotifications"; |
| 6 | import Constants from "expo-constants"; |
| 7 | import * as Notifications from "expo-notifications"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Schema from "effect/Schema"; |
| 10 | import { AppState, Platform } from "react-native"; |
| 11 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 12 | import { |
| 13 | type RelayDeviceRegistrationRequest, |
| 14 | type RelayAgentActivitySnapshotResponse, |
| 15 | type RelayLiveActivityRegistrationRequest, |
| 16 | } from "@t3tools/contracts/relay"; |
| 17 | import { findErrorTraceId } from "@t3tools/client-runtime/errors"; |
| 18 | import { ManagedRelay } from "@t3tools/client-runtime/relay"; |
| 19 | import { |
| 20 | isAtomCommandInterrupted, |
| 21 | settleAsyncResult, |
| 22 | squashAtomCommandFailure, |
| 23 | } from "@t3tools/client-runtime/state/runtime"; |
| 24 | |
| 25 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 26 | import { runtime } from "../../lib/runtime"; |
| 27 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 28 | import { environmentServerConfigsAtom } from "../../state/server"; |
| 29 | import type { Preferences } from "../../persistence/mobile-preferences"; |
| 30 | import { |
| 31 | clearAgentAwarenessRegistrationRecord, |
| 32 | loadAgentAwarenessDeviceId, |
| 33 | loadAgentAwarenessRegistrationRecord, |
| 34 | loadOrCreateAgentAwarenessDeviceId, |
| 35 | loadPreferences, |
| 36 | saveAgentAwarenessRegistrationRecord, |
| 37 | } from "../../persistence/imperative"; |
| 38 | import type { AgentActivityProps } from "../../widgets/AgentActivity"; |
| 39 | import { getAgentLiveActivities, startAgentLiveActivity } from "./agentLiveActivity"; |
| 40 | import { resolveCloudPublicConfig } from "../cloud/publicConfig"; |
| 41 | import { supportsAgentAwarenessPush } from "./capabilities"; |
| 42 | import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; |
| 43 | |
| 44 | const REMOTE_ACTIVITY_REGISTRATION_RETRY_MS = 15_000; |
| 45 | |
| 46 | const AgentAwarenessOperation = Schema.Literals([ |
| 47 | "read-notification-permissions", |
| 48 | "read-native-push-token", |
| 49 | "read-device-registration-relay-token", |
| 50 | "read-device-unregistration-relay-token", |
| 51 | "read-live-activity-registration-relay-token", |
| 52 | "load-device-registration-identifier", |
| 53 | "load-device-registration-preferences", |
| 54 | "load-device-unregistration-identifier", |
| 55 | "read-live-activity-push-token", |
| 56 | "load-live-activity-registration-identifier", |
| 57 | "list-active-live-activities", |
| 58 | "load-live-activity-prime-preferences", |
| 59 | "prime-live-activity", |
| 60 | ]); |
| 61 | |
| 62 | export class AgentAwarenessOperationError extends Schema.TaggedError<AgentAwarenessOperationError>()( |
| 63 | "AgentAwarenessOperationError", |
| 64 | { |
| 65 | operation: AgentAwarenessOperation, |
| 66 | cause: Schema.Defect(), |
| 67 | }, |
| 68 | ) { |
| 69 | override get message(): string { |
| 70 | return `Agent awareness operation ${this.operation} failed.`; |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | const environmentConnections = new Map<EnvironmentId, SavedRemoteConnection>(); |
| 75 | const activityPushTokenListeners = new WeakSet<LiveActivity<AgentActivityProps>>(); |
| 76 | // Activity tokens the relay recently accepted, by acceptance time. The refresh |
| 77 | // runs on sign-in, every app foreground, and every environment-connection |
| 78 | // update, which arrive in bursts and spammed identical registrations. But the |
| 79 | // registration is not a pure no-op: the relay replays the current aggregate to |
| 80 | // this device on every accepted registration, and that replay is the |
| 81 | // foreground reconciliation that repairs drifted or orphaned activities. So |
| 82 | // dedupe only within a short window — bursts collapse to one request, while a |
| 83 | // foreground after real time away still triggers a replay. Cleared on |
| 84 | // sign-out/identity change alongside the device registration state. |
| 85 | const ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS = 60_000; |
| 86 | // Locally started activities carry the same stale window the relay puts on |
| 87 | // every push (STALE_AFTER_SECONDS in ApnsClient.ts), so a card whose relay |
| 88 | // registration never lands still degrades instead of looking alive forever. |
| 89 | const LIVE_ACTIVITY_STALE_AFTER_MS = 10 * 60_000; |
| 90 | const liveActivityStaleDate = () => new Date(Date.now() + LIVE_ACTIVITY_STALE_AFTER_MS); |
| 91 | const registeredActivityPushTokens = new Map<string, number>(); |
| 92 | let androidDeviceReplayedAt: number | null = null; |
| 93 | let pushTokenSubscription: { remove: () => void } | null = null; |
| 94 | let appStateSubscription: { remove: () => void } | null = null; |
| 95 | |
| 96 | // Whether the relay has actually accepted this device's registration. The |
| 97 | // notification/Live Activity settings toggles must reflect this rather than |
| 98 | // only local iOS permission or saved preferences: if the registration request |
| 99 | // never succeeded, the device cannot receive anything, so the switches must |
| 100 | // not read as enabled. |
| 101 | export type AgentAwarenessRegistrationStatus = "unknown" | "pending" | "registered" | "failed"; |
| 102 | let registrationStatus: AgentAwarenessRegistrationStatus = "unknown"; |
| 103 | const registrationStatusListeners = new Set<() => void>(); |
| 104 | |
| 105 | function setRegistrationStatus(next: AgentAwarenessRegistrationStatus): void { |
| 106 | if (registrationStatus === next) { |
| 107 | return; |
| 108 | } |
| 109 | registrationStatus = next; |
| 110 | for (const listener of registrationStatusListeners) { |
| 111 | listener(); |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | export function getAgentAwarenessRegistrationStatus(): AgentAwarenessRegistrationStatus { |
| 116 | return registrationStatus; |
| 117 | } |
| 118 | |
| 119 | export function subscribeAgentAwarenessRegistrationStatus(listener: () => void): () => void { |
| 120 | registrationStatusListeners.add(listener); |
| 121 | return () => { |
| 122 | registrationStatusListeners.delete(listener); |
| 123 | }; |
| 124 | } |
| 125 | let activeLiveActivityRegistrationRetry: ReturnType<typeof setTimeout> | null = null; |
| 126 | let relayTokenProvider: (() => Promise<string | null>) | null = null; |
| 127 | let relayTokenProviderIdentity: string | null = null; |
| 128 | let deviceRegistrationGeneration = 0; |
| 129 | let activeDeviceRegistration: { |
| 130 | readonly input: DeviceRegistrationInput; |
| 131 | operation: Promise<void>; |
| 132 | } | null = null; |
| 133 | let pendingDeviceRegistration: { |
| 134 | readonly input: DeviceRegistrationInput; |
| 135 | readonly context: string; |
| 136 | } | null = null; |
| 137 | |
| 138 | interface DeviceRegistrationInput { |
| 139 | readonly observedPushToken?: string; |
| 140 | } |
| 141 | |
| 142 | interface RegisterDeviceInput extends DeviceRegistrationInput { |
| 143 | readonly preferencesOverride?: Partial<Preferences>; |
| 144 | } |
| 145 | |
| 146 | export function mergeAgentAwarenessRegistrationPreferences( |
| 147 | stored: Preferences, |
| 148 | override: Partial<Preferences> | undefined, |
| 149 | ): Preferences { |
| 150 | return { ...stored, ...override }; |
| 151 | } |
| 152 | |
| 153 | function readRelayConfig(): { readonly url: string } | null { |
| 154 | const relayUrl = resolveCloudPublicConfig().relay.url; |
| 155 | if (!relayUrl) { |
| 156 | logRegistrationDebug("relay registration skipped; relay config missing"); |
| 157 | return null; |
| 158 | } |
| 159 | |
| 160 | return { url: relayUrl }; |
| 161 | } |
| 162 | |
| 163 | function canRegisterRemoteLiveActivities(): boolean { |
| 164 | return Platform.OS === "ios"; |
| 165 | } |
| 166 | |
| 167 | function canRegisterPushNotifications(): boolean { |
| 168 | return Platform.OS === "ios" || Platform.OS === "android"; |
| 169 | } |
| 170 | |
| 171 | export function shouldRegisterAgentAwarenessDeviceForProvider( |
| 172 | previousIdentity: string | null, |
| 173 | identity: string | undefined, |
| 174 | ): boolean { |
| 175 | return identity === undefined || identity !== previousIdentity; |
| 176 | } |
| 177 | |
| 178 | export function setAgentAwarenessRelayTokenProvider( |
| 179 | provider: (() => Promise<string | null>) | null, |
| 180 | identity?: string, |
| 181 | ): void { |
| 182 | const isExistingIdentity = |
| 183 | provider !== null && |
| 184 | !shouldRegisterAgentAwarenessDeviceForProvider(relayTokenProviderIdentity, identity); |
| 185 | if (!isExistingIdentity) { |
| 186 | // Native configure compares the persisted account on cold start. An |
| 187 | // unset JS identity is a remount, not evidence of a different account. |
| 188 | if (relayTokenProviderIdentity && identity !== relayTokenProviderIdentity) { |
| 189 | clearAndroidAgentNotifications(); |
| 190 | } |
| 191 | androidDeviceReplayedAt = null; |
| 192 | deviceRegistrationGeneration++; |
| 193 | activeDeviceRegistration = null; |
| 194 | pendingDeviceRegistration = null; |
| 195 | registeredActivityPushTokens.clear(); |
| 196 | } |
| 197 | relayTokenProvider = provider; |
| 198 | relayTokenProviderIdentity = provider ? (identity ?? null) : null; |
| 199 | if (!provider) { |
| 200 | clearAndroidAgentNotifications(); |
| 201 | pushTokenSubscription?.remove(); |
| 202 | pushTokenSubscription = null; |
| 203 | appStateSubscription?.remove(); |
| 204 | appStateSubscription = null; |
| 205 | if (activeLiveActivityRegistrationRetry) { |
| 206 | clearTimeout(activeLiveActivityRegistrationRetry); |
| 207 | activeLiveActivityRegistrationRetry = null; |
| 208 | } |
| 209 | // Without a signed-in user the relay can no longer update or end these |
| 210 | // activities, so they would sit orphaned on the lock screen. |
| 211 | endLocalLiveActivities("live activity cleanup after cloud sign-out failed"); |
| 212 | setRegistrationStatus("unknown"); |
| 213 | // Sign-out is the only thing that invalidates a stored registration, so the |
| 214 | // next sign-in re-registers. |
| 215 | void clearAgentAwarenessRegistrationRecord().catch((error: unknown) => { |
| 216 | logRegistrationError("clear registration record on sign-out failed", error); |
| 217 | }); |
| 218 | return; |
| 219 | } |
| 220 | ensurePushTokenListener(); |
| 221 | ensureAppStateListener(); |
| 222 | runRegistrationInBackground( |
| 223 | refreshActiveLiveActivityRemoteRegistration(), |
| 224 | "active live activity registration after cloud sign-in failed", |
| 225 | ); |
| 226 | if (isExistingIdentity) { |
| 227 | // Same account re-activating (e.g. Clerk token refresh) normally needs no |
| 228 | // re-registration — but if the previous attempt never succeeded, this is |
| 229 | // the only trigger that will retry it before the next cold start. |
| 230 | if (registrationStatus !== "registered") { |
| 231 | enqueueDeviceRegistration({}, "device registration retry after cloud session refresh failed"); |
| 232 | } |
| 233 | return; |
| 234 | } |
| 235 | enqueueDeviceRegistration({}, "device registration after cloud sign-in failed"); |
| 236 | } |
| 237 | |
| 238 | // Detach the provider and native listeners without the destructive sign-out |
| 239 | // cleanup. For provider teardown while the user is still signed in (e.g. the |
| 240 | // auth bridge unmounting/remounting), ending lock-screen activities and wiping |
| 241 | // the persisted registration would be wrong — the relay still holds a valid |
| 242 | // registration and the next mount reuses it. |
| 243 | export function releaseAgentAwarenessRelayTokenProvider(): void { |
| 244 | relayTokenProvider = null; |
| 245 | relayTokenProviderIdentity = null; |
| 246 | deviceRegistrationGeneration++; |
| 247 | activeDeviceRegistration = null; |
| 248 | pendingDeviceRegistration = null; |
| 249 | androidDeviceReplayedAt = null; |
| 250 | pushTokenSubscription?.remove(); |
| 251 | pushTokenSubscription = null; |
| 252 | appStateSubscription?.remove(); |
| 253 | appStateSubscription = null; |
| 254 | if (activeLiveActivityRegistrationRetry) { |
| 255 | clearTimeout(activeLiveActivityRegistrationRetry); |
| 256 | activeLiveActivityRegistrationRetry = null; |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | function iosMajorVersion(): number { |
| 261 | const version = Platform.Version; |
| 262 | if (typeof version === "number") { |
| 263 | return Math.floor(version); |
| 264 | } |
| 265 | const major = Number.parseInt(version.split(".")[0] ?? "", 10); |
| 266 | return Number.isFinite(major) ? major : 18; |
| 267 | } |
| 268 | |
| 269 | function nativePushTokenRegistration(observedPushToken?: string) { |
| 270 | return Effect.gen(function* () { |
| 271 | if (!canRegisterPushNotifications() || !supportsAgentAwarenessPush()) { |
| 272 | return { notificationsEnabled: false, pushToken: null }; |
| 273 | } |
| 274 | const permissions = yield* Effect.tryPromise({ |
| 275 | try: () => Notifications.getPermissionsAsync(), |
| 276 | catch: (cause) => |
| 277 | new AgentAwarenessOperationError({ |
| 278 | operation: "read-notification-permissions", |
| 279 | cause, |
| 280 | }), |
| 281 | }); |
| 282 | if (!permissions.granted) { |
| 283 | return { notificationsEnabled: false, pushToken: null }; |
| 284 | } |
| 285 | if (observedPushToken) { |
| 286 | return { notificationsEnabled: true, pushToken: observedPushToken }; |
| 287 | } |
| 288 | const token = yield* Effect.tryPromise({ |
| 289 | try: () => Notifications.getDevicePushTokenAsync(), |
| 290 | catch: (cause) => |
| 291 | new AgentAwarenessOperationError({ |
| 292 | operation: "read-native-push-token", |
| 293 | cause, |
| 294 | }), |
| 295 | }).pipe( |
| 296 | Effect.tapError((error) => |
| 297 | Effect.sync(() => { |
| 298 | logRegistrationError("native push token lookup failed", error); |
| 299 | }), |
| 300 | ), |
| 301 | Effect.orElseSucceed(() => null), |
| 302 | ); |
| 303 | const pushToken = |
| 304 | token?.type === Platform.OS && typeof token.data === "string" && token.data.trim().length > 0 |
| 305 | ? token.data.trim() |
| 306 | : null; |
| 307 | return { notificationsEnabled: pushToken !== null, pushToken }; |
| 308 | }); |
| 309 | } |
| 310 | |
| 311 | const relayToken = ( |
| 312 | operation: "read-device-registration-relay-token" | "read-live-activity-registration-relay-token", |
| 313 | ) => |
| 314 | Effect.gen(function* () { |
| 315 | const provider = relayTokenProvider; |
| 316 | if (!provider) { |
| 317 | return null; |
| 318 | } |
| 319 | return yield* Effect.tryPromise({ |
| 320 | try: provider, |
| 321 | catch: (cause) => new AgentAwarenessOperationError({ operation, cause }), |
| 322 | }); |
| 323 | }); |
| 324 | |
| 325 | // Stable fingerprint of everything the relay stores for this device. When it |
| 326 | // matches the last accepted registration for the same account, re-registering |
| 327 | // is a no-op, so a launch that changed nothing skips the request entirely. |
| 328 | function registrationSignature(body: RelayDeviceRegistrationRequest): string { |
| 329 | return [ |
| 330 | body.deviceId, |
| 331 | body.pushToken ?? "", |
| 332 | body.bundleId ?? "", |
| 333 | body.apsEnvironment ?? "", |
| 334 | body.appVersion ?? "", |
| 335 | body.label, |
| 336 | body.platform, |
| 337 | body.iosMajorVersion, |
| 338 | body.androidApiLevel, |
| 339 | body.preferences.notificationsEnabled, |
| 340 | body.preferences.liveActivitiesEnabled, |
| 341 | body.preferences.notifyOnApproval, |
| 342 | body.preferences.notifyOnInput, |
| 343 | body.preferences.notifyOnCompletion, |
| 344 | body.preferences.notifyOnFailure, |
| 345 | ].join("|"); |
| 346 | } |
| 347 | |
| 348 | function registerDeviceWithRelay( |
| 349 | body: RelayDeviceRegistrationRequest, |
| 350 | expectedGeneration: number, |
| 351 | ): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> { |
| 352 | return Effect.gen(function* () { |
| 353 | if (expectedGeneration !== deviceRegistrationGeneration) { |
| 354 | logRegistrationDebug("device registration cancelled before relay request", { |
| 355 | expectedGeneration, |
| 356 | currentGeneration: deviceRegistrationGeneration, |
| 357 | }); |
| 358 | return; |
| 359 | } |
| 360 | const relayConfig = readRelayConfig(); |
| 361 | if (!relayConfig) { |
| 362 | // Nothing is in flight and nothing can succeed until configuration |
| 363 | // appears; "pending" would otherwise stick forever. |
| 364 | setRegistrationStatus("unknown"); |
| 365 | return; |
| 366 | } |
| 367 | const token = yield* relayToken("read-device-registration-relay-token"); |
| 368 | if (expectedGeneration !== deviceRegistrationGeneration) { |
| 369 | logRegistrationDebug("device registration cancelled after auth lookup", { |
| 370 | expectedGeneration, |
| 371 | currentGeneration: deviceRegistrationGeneration, |
| 372 | }); |
| 373 | return; |
| 374 | } |
| 375 | if (!token) { |
| 376 | logRegistrationDebug("relay device registration skipped; user is not signed in"); |
| 377 | setRegistrationStatus("unknown"); |
| 378 | return; |
| 379 | } |
| 380 | |
| 381 | // Skip the request when this account already registered an identical |
| 382 | // payload; the relay upsert would be a no-op. The record is only cleared on |
| 383 | // sign-out, so a device stays registered across launches without re-hitting |
| 384 | // the relay every time the app opens. |
| 385 | const identity = relayTokenProviderIdentity ?? ""; |
| 386 | const persisted = yield* Effect.tryPromise({ |
| 387 | try: () => loadAgentAwarenessRegistrationRecord(), |
| 388 | catch: (cause) => cause, |
| 389 | }).pipe(Effect.orElseSucceed(() => null)); |
| 390 | if (expectedGeneration !== deviceRegistrationGeneration) { |
| 391 | // Signed out while the record loaded — do not resurrect the cleared |
| 392 | // record or report the previous account's registration as current. |
| 393 | logRegistrationDebug("device registration cancelled after record lookup", { |
| 394 | expectedGeneration, |
| 395 | currentGeneration: deviceRegistrationGeneration, |
| 396 | }); |
| 397 | return; |
| 398 | } |
| 399 | const payload = body; |
| 400 | // The relay URL participates so pointing the app at a different relay |
| 401 | // invalidates the record and re-registers there. |
| 402 | const signature = `${relayConfig.url}|${registrationSignature(payload)}`; |
| 403 | // Android registration also silently replays the current card. Collapse |
| 404 | // foreground bursts, but repair missed pushes on cold start or a return |
| 405 | // after time away, just like re-registering an iOS activity token. |
| 406 | const needsAndroidReplay = |
| 407 | body.platform === "android" && |
| 408 | (androidDeviceReplayedAt === null || |
| 409 | Date.now() - androidDeviceReplayedAt >= ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS); |
| 410 | if ( |
| 411 | persisted && |
| 412 | persisted.identity === identity && |
| 413 | persisted.signature === signature && |
| 414 | !needsAndroidReplay |
| 415 | ) { |
| 416 | setRegistrationStatus("registered"); |
| 417 | logRegistrationDebug("relay device registration skipped; already registered for account", { |
| 418 | expectedGeneration, |
| 419 | }); |
| 420 | return; |
| 421 | } |
| 422 | |
| 423 | const client = yield* ManagedRelay.ManagedRelayClient; |
| 424 | logRegistrationDebug("relay device registration request started", { |
| 425 | expectedGeneration, |
| 426 | }); |
| 427 | yield* client.registerDevice({ |
| 428 | clerkToken: token, |
| 429 | payload, |
| 430 | }); |
| 431 | if (expectedGeneration !== deviceRegistrationGeneration) { |
| 432 | // Signed out while the request was in flight: the sign-out path already |
| 433 | // reset the status and cleared the record for the next account, so a |
| 434 | // stale success must not overwrite either. |
| 435 | logRegistrationDebug("device registration completed after sign-out; result discarded", { |
| 436 | expectedGeneration, |
| 437 | currentGeneration: deviceRegistrationGeneration, |
| 438 | }); |
| 439 | return; |
| 440 | } |
| 441 | if (body.platform === "android") androidDeviceReplayedAt = Date.now(); |
| 442 | setRegistrationStatus("registered"); |
| 443 | yield* Effect.promise(() => |
| 444 | saveAgentAwarenessRegistrationRecord({ |
| 445 | identity, |
| 446 | signature, |
| 447 | }).catch((error: unknown) => { |
| 448 | logRegistrationError("persist registration record failed", error); |
| 449 | }), |
| 450 | ); |
| 451 | logRegistrationDebug("relay device registration request completed", { |
| 452 | expectedGeneration, |
| 453 | }); |
| 454 | }); |
| 455 | } |
| 456 | |
| 457 | function unregisterDeviceWithRelay(input: { |
| 458 | readonly deviceId: string; |
| 459 | readonly tokenProvider: () => Promise<string | null>; |
| 460 | }): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> { |
| 461 | return Effect.gen(function* () { |
| 462 | if (!readRelayConfig()) return; |
| 463 | const token = yield* Effect.tryPromise({ |
| 464 | try: input.tokenProvider, |
| 465 | catch: (cause) => |
| 466 | new AgentAwarenessOperationError({ |
| 467 | operation: "read-device-unregistration-relay-token", |
| 468 | cause, |
| 469 | }), |
| 470 | }); |
| 471 | if (!token) { |
| 472 | logRegistrationDebug("relay device unregistration skipped; user is not signed in"); |
| 473 | return; |
| 474 | } |
| 475 | |
| 476 | const client = yield* ManagedRelay.ManagedRelayClient; |
| 477 | yield* client.unregisterDevice({ |
| 478 | clerkToken: token, |
| 479 | deviceId: input.deviceId, |
| 480 | }); |
| 481 | }); |
| 482 | } |
| 483 | |
| 484 | // The environment descriptor advertises whether agent-activity publishes |
| 485 | // currently leave that server (`capabilities.agentActivityPublishing`). Only |
| 486 | // an explicit false skips the seed card: older servers omit the capability |
| 487 | // but may still publish. |
| 488 | function environmentPublishesAgentActivity(environmentId: EnvironmentId): boolean { |
| 489 | return ( |
| 490 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities |
| 491 | .agentActivityPublishing !== false |
| 492 | ); |
| 493 | } |
| 494 | |
| 495 | // Arms the lock-screen card the moment the user starts agent work from this |
| 496 | // phone, while the app is still foregrounded and the fresh activity's token |
| 497 | // can be registered immediately. The seeded row is a best-effort placeholder; |
| 498 | // the relay's registration replay repaints it with the authoritative |
| 499 | // aggregate within seconds. No-ops when a card is already armed, and skips |
| 500 | // environments that report publishing disabled — the seed would sit on |
| 501 | // "Connecting" forever with no update ever arriving to repaint or end it. |
| 502 | export function armAgentAwarenessLiveActivityForLocalWork(input: { |
| 503 | readonly environmentId: EnvironmentId; |
| 504 | readonly threadTitle: string; |
| 505 | readonly projectTitle: string; |
| 506 | }): void { |
| 507 | if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) { |
| 508 | return; |
| 509 | } |
| 510 | if (!environmentPublishesAgentActivity(input.environmentId)) { |
| 511 | logRegistrationDebug("live activity arming skipped; environment does not publish", { |
| 512 | environmentId: input.environmentId, |
| 513 | }); |
| 514 | return; |
| 515 | } |
| 516 | void loadPreferences() |
| 517 | .catch(() => null) |
| 518 | .then((preferences) => { |
| 519 | if (preferences?.liveActivitiesEnabled === false) { |
| 520 | return; |
| 521 | } |
| 522 | armAgentAwarenessLiveActivityForLocalWorkNow(input); |
| 523 | }); |
| 524 | } |
| 525 | |
| 526 | function armAgentAwarenessLiveActivityForLocalWorkNow(input: { |
| 527 | readonly threadTitle: string; |
| 528 | readonly projectTitle: string; |
| 529 | }): void { |
| 530 | try { |
| 531 | if (getAgentLiveActivities().length > 0) { |
| 532 | return; |
| 533 | } |
| 534 | const nowIso = new Date(Date.now()).toISOString(); |
| 535 | const activity = startAgentLiveActivity( |
| 536 | { |
| 537 | title: "The Knife", |
| 538 | subtitle: "Agent work in progress", |
| 539 | activeCount: 1, |
| 540 | updatedAt: nowIso, |
| 541 | activities: [ |
| 542 | { |
| 543 | environmentId: "", |
| 544 | threadId: "", |
| 545 | projectTitle: input.projectTitle, |
| 546 | threadTitle: input.threadTitle, |
| 547 | modelTitle: "", |
| 548 | phase: "starting", |
| 549 | status: "Connecting", |
| 550 | updatedAt: nowIso, |
| 551 | deepLink: "/", |
| 552 | }, |
| 553 | ], |
| 554 | }, |
| 555 | liveActivityStaleDate(), |
| 556 | ); |
| 557 | if (!activity) { |
| 558 | return; |
| 559 | } |
| 560 | logRegistrationDebug("live activity card armed for local work", { |
| 561 | threadTitle: input.threadTitle, |
| 562 | }); |
| 563 | runRegistrationInBackground( |
| 564 | registerLiveActivityPushToken({ activity }).pipe(Effect.asVoid), |
| 565 | "live activity arming after local task start failed", |
| 566 | ); |
| 567 | } catch (error) { |
| 568 | logRegistrationError("live activity arming failed", error); |
| 569 | } |
| 570 | } |
| 571 | |
| 572 | function readAgentActivitySnapshot(): Effect.Effect< |
| 573 | RelayAgentActivitySnapshotResponse | null, |
| 574 | never, |
| 575 | ManagedRelay.ManagedRelayClient |
| 576 | > { |
| 577 | return Effect.gen(function* () { |
| 578 | if (!readRelayConfig()) return null; |
| 579 | const token = yield* relayToken("read-live-activity-registration-relay-token"); |
| 580 | if (!token) { |
| 581 | return null; |
| 582 | } |
| 583 | const client = yield* ManagedRelay.ManagedRelayClient; |
| 584 | return yield* client.getAgentActivitySnapshot({ clerkToken: token }); |
| 585 | }).pipe( |
| 586 | Effect.catch((error) => |
| 587 | Effect.sync(() => { |
| 588 | logRegistrationError("agent activity snapshot read failed", error); |
| 589 | return null; |
| 590 | }), |
| 591 | ), |
| 592 | ); |
| 593 | } |
| 594 | |
| 595 | function registerLiveActivityWithRelay( |
| 596 | body: RelayLiveActivityRegistrationRequest, |
| 597 | ): Effect.Effect<boolean, unknown, ManagedRelay.ManagedRelayClient> { |
| 598 | return Effect.gen(function* () { |
| 599 | if (!readRelayConfig()) return false; |
| 600 | const token = yield* relayToken("read-live-activity-registration-relay-token"); |
| 601 | if (!token) { |
| 602 | logRegistrationDebug("relay live activity registration skipped; user is not signed in"); |
| 603 | return false; |
| 604 | } |
| 605 | |
| 606 | const client = yield* ManagedRelay.ManagedRelayClient; |
| 607 | yield* client.registerLiveActivity({ |
| 608 | clerkToken: token, |
| 609 | payload: body, |
| 610 | }); |
| 611 | return true; |
| 612 | }); |
| 613 | } |
| 614 | |
| 615 | function logRegistrationError(context: string, error: unknown): void { |
| 616 | if (!__DEV__) { |
| 617 | return; |
| 618 | } |
| 619 | console.warn(`[agent-awareness] ${context}`, { |
| 620 | message: error instanceof Error ? error.message : String(error), |
| 621 | traceId: findErrorTraceId(error), |
| 622 | error, |
| 623 | }); |
| 624 | } |
| 625 | |
| 626 | function logRegistrationDebug(context: string, details?: unknown): void { |
| 627 | if (!__DEV__) { |
| 628 | return; |
| 629 | } |
| 630 | console.log(`[agent-awareness] ${context}`, details ?? ""); |
| 631 | } |
| 632 | |
| 633 | function runRegistrationInBackground( |
| 634 | operation: Effect.Effect<unknown, unknown, ManagedRelay.ManagedRelayClient>, |
| 635 | context: string, |
| 636 | ): void { |
| 637 | void (async () => { |
| 638 | const result = await settleAsyncResult(() => runtime.runPromiseExit(operation)); |
| 639 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 640 | logRegistrationError(context, squashAtomCommandFailure(result)); |
| 641 | } |
| 642 | })(); |
| 643 | } |
| 644 | |
| 645 | function mergeDeviceRegistrationInput( |
| 646 | current: DeviceRegistrationInput, |
| 647 | next: DeviceRegistrationInput, |
| 648 | ): DeviceRegistrationInput { |
| 649 | const observedPushToken = next.observedPushToken ?? current.observedPushToken; |
| 650 | return observedPushToken ? { observedPushToken } : {}; |
| 651 | } |
| 652 | |
| 653 | function registrationAddsInformation( |
| 654 | current: DeviceRegistrationInput, |
| 655 | next: DeviceRegistrationInput, |
| 656 | ): boolean { |
| 657 | return ( |
| 658 | next.observedPushToken !== undefined && next.observedPushToken !== current.observedPushToken |
| 659 | ); |
| 660 | } |
| 661 | |
| 662 | function startPendingDeviceRegistration(): void { |
| 663 | if (activeDeviceRegistration || !pendingDeviceRegistration) { |
| 664 | return; |
| 665 | } |
| 666 | |
| 667 | const next = pendingDeviceRegistration; |
| 668 | pendingDeviceRegistration = null; |
| 669 | const generation = deviceRegistrationGeneration; |
| 670 | logRegistrationDebug("device registration started", { |
| 671 | generation, |
| 672 | hasObservedPushToken: next.input.observedPushToken !== undefined, |
| 673 | }); |
| 674 | if (registrationStatus !== "registered") { |
| 675 | setRegistrationStatus("pending"); |
| 676 | } |
| 677 | const registration = { |
| 678 | input: next.input, |
| 679 | operation: Promise.resolve(), |
| 680 | }; |
| 681 | activeDeviceRegistration = registration; |
| 682 | registration.operation = (async () => { |
| 683 | const result = await settleAsyncResult(() => |
| 684 | runtime.runPromiseExit(registerDevice(next.input, generation)), |
| 685 | ); |
| 686 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 687 | // A transient failure on a later refresh (e.g. token rotation) leaves |
| 688 | // the prior accepted registration intact on the relay, so an already |
| 689 | // registered device stays "registered" rather than flipping the |
| 690 | // settings toggles off. |
| 691 | if (registrationStatus !== "registered") { |
| 692 | setRegistrationStatus("failed"); |
| 693 | } |
| 694 | logRegistrationError(next.context, squashAtomCommandFailure(result)); |
| 695 | } |
| 696 | logRegistrationDebug("device registration finished", { generation }); |
| 697 | if (activeDeviceRegistration === registration) { |
| 698 | activeDeviceRegistration = null; |
| 699 | } |
| 700 | startPendingDeviceRegistration(); |
| 701 | })(); |
| 702 | } |
| 703 | |
| 704 | function enqueueDeviceRegistration(input: DeviceRegistrationInput, context: string): void { |
| 705 | if ( |
| 706 | activeDeviceRegistration && |
| 707 | !registrationAddsInformation(activeDeviceRegistration.input, input) |
| 708 | ) { |
| 709 | logRegistrationDebug("device registration coalesced with active request", { |
| 710 | generation: deviceRegistrationGeneration, |
| 711 | }); |
| 712 | return; |
| 713 | } |
| 714 | |
| 715 | logRegistrationDebug("device registration enqueued", { |
| 716 | generation: deviceRegistrationGeneration, |
| 717 | hasActiveRegistration: activeDeviceRegistration !== null, |
| 718 | hasPendingRegistration: pendingDeviceRegistration !== null, |
| 719 | }); |
| 720 | pendingDeviceRegistration = pendingDeviceRegistration |
| 721 | ? { |
| 722 | input: mergeDeviceRegistrationInput(pendingDeviceRegistration.input, input), |
| 723 | context, |
| 724 | } |
| 725 | : { input, context }; |
| 726 | startPendingDeviceRegistration(); |
| 727 | } |
| 728 | |
| 729 | function registerDevice( |
| 730 | input: RegisterDeviceInput = {}, |
| 731 | expectedGeneration = deviceRegistrationGeneration, |
| 732 | ): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> { |
| 733 | return Effect.gen(function* () { |
| 734 | if (!canRegisterPushNotifications()) { |
| 735 | logRegistrationDebug("device registration skipped; platform does not support it"); |
| 736 | return; |
| 737 | } |
| 738 | |
| 739 | logRegistrationDebug("device registration loading local state", { expectedGeneration }); |
| 740 | const [deviceId, storedPreferences] = yield* Effect.all([ |
| 741 | Effect.tryPromise({ |
| 742 | try: () => loadOrCreateAgentAwarenessDeviceId(), |
| 743 | catch: (cause) => |
| 744 | new AgentAwarenessOperationError({ |
| 745 | operation: "load-device-registration-identifier", |
| 746 | cause, |
| 747 | }), |
| 748 | }), |
| 749 | Effect.tryPromise({ |
| 750 | try: () => loadPreferences(), |
| 751 | catch: (cause) => |
| 752 | new AgentAwarenessOperationError({ |
| 753 | operation: "load-device-registration-preferences", |
| 754 | cause, |
| 755 | }), |
| 756 | }), |
| 757 | ]); |
| 758 | const preferences = mergeAgentAwarenessRegistrationPreferences( |
| 759 | storedPreferences, |
| 760 | input.preferencesOverride, |
| 761 | ); |
| 762 | if (expectedGeneration !== deviceRegistrationGeneration) return; |
| 763 | if (relayTokenProvider && relayTokenProviderIdentity) { |
| 764 | configureAndroidAgentNotifications( |
| 765 | deviceId, |
| 766 | relayTokenProviderIdentity, |
| 767 | preferences.liveActivitiesEnabled !== false, |
| 768 | ); |
| 769 | } |
| 770 | const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken); |
| 771 | logRegistrationDebug("device registration local state ready", { |
| 772 | expectedGeneration, |
| 773 | notificationsEnabled: pushTokenRegistration.notificationsEnabled, |
| 774 | }); |
| 775 | const bundleId = |
| 776 | Platform.OS === "android" |
| 777 | ? Constants.expoConfig?.android?.package?.trim() |
| 778 | : Constants.expoConfig?.ios?.bundleIdentifier?.trim(); |
| 779 | yield* registerDeviceWithRelay( |
| 780 | makeRelayDeviceRegistrationRequest({ |
| 781 | deviceId, |
| 782 | label: |
| 783 | Constants.deviceName?.trim() || |
| 784 | (Platform.OS === "android" ? "Android device" : "iOS device"), |
| 785 | ...(Platform.OS === "android" |
| 786 | ? { platform: "android" as const, androidApiLevel: Number(Platform.Version) } |
| 787 | : { platform: "ios" as const, iosMajorVersion: iosMajorVersion() }), |
| 788 | appVersion: Constants.expoConfig?.version, |
| 789 | ...(bundleId ? { bundleId } : {}), |
| 790 | ...(Platform.OS === "ios" |
| 791 | ? { apsEnvironment: resolveApsEnvironment(Constants.expoConfig?.extra?.appVariant) } |
| 792 | : {}), |
| 793 | ...(pushTokenRegistration.pushToken ? { pushToken: pushTokenRegistration.pushToken } : {}), |
| 794 | notificationsEnabled: pushTokenRegistration.notificationsEnabled, |
| 795 | preferences, |
| 796 | }), |
| 797 | expectedGeneration, |
| 798 | ); |
| 799 | }); |
| 800 | } |
| 801 | |
| 802 | function registerDeviceForCurrentUser(): Effect.Effect< |
| 803 | void, |
| 804 | unknown, |
| 805 | ManagedRelay.ManagedRelayClient |
| 806 | > { |
| 807 | return registerDevice(undefined); |
| 808 | } |
| 809 | |
| 810 | function ensurePushTokenListener(): void { |
| 811 | if (pushTokenSubscription || !canRegisterPushNotifications()) { |
| 812 | return; |
| 813 | } |
| 814 | |
| 815 | pushTokenSubscription = Notifications.addPushTokenListener((token) => { |
| 816 | if ( |
| 817 | token.type === Platform.OS && |
| 818 | typeof token.data === "string" && |
| 819 | token.data.trim().length > 0 |
| 820 | ) { |
| 821 | enqueueDeviceRegistration( |
| 822 | { observedPushToken: token.data.trim() }, |
| 823 | "native push token rotation registration failed", |
| 824 | ); |
| 825 | } |
| 826 | }); |
| 827 | } |
| 828 | |
| 829 | // Re-registering activity tokens on foreground makes the relay replay the |
| 830 | // current aggregate to this device, which updates content that drifted while |
| 831 | // pushes could not be delivered and ends orphaned activities whose end push |
| 832 | // never arrived. (Deduped by ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS: rapid |
| 833 | // foreground/sign-in bursts collapse to one registration, but returning after |
| 834 | // real time away still replays.) |
| 835 | function ensureAppStateListener(): void { |
| 836 | if (appStateSubscription || !canRegisterPushNotifications()) { |
| 837 | return; |
| 838 | } |
| 839 | |
| 840 | appStateSubscription = AppState.addEventListener("change", (state) => { |
| 841 | if (state !== "active") { |
| 842 | return; |
| 843 | } |
| 844 | enqueueDeviceRegistration({}, "device registration after app foreground failed"); |
| 845 | runRegistrationInBackground( |
| 846 | refreshActiveLiveActivityRemoteRegistration(), |
| 847 | "active live activity reconciliation after app foreground failed", |
| 848 | ); |
| 849 | }); |
| 850 | } |
| 851 | |
| 852 | function endLocalLiveActivities(context: string): void { |
| 853 | if (!canRegisterRemoteLiveActivities()) { |
| 854 | return; |
| 855 | } |
| 856 | try { |
| 857 | for (const activity of getAgentLiveActivities()) { |
| 858 | activity.end("immediate").catch((error: unknown) => { |
| 859 | logRegistrationError(context, error); |
| 860 | }); |
| 861 | } |
| 862 | } catch (error) { |
| 863 | logRegistrationError(context, error); |
| 864 | } |
| 865 | } |
| 866 | |
| 867 | export function registerAgentAwarenessConnection(connection: SavedRemoteConnection): void { |
| 868 | if (!canRegisterPushNotifications()) { |
| 869 | return; |
| 870 | } |
| 871 | |
| 872 | environmentConnections.set(connection.environmentId, connection); |
| 873 | ensurePushTokenListener(); |
| 874 | ensureAppStateListener(); |
| 875 | enqueueDeviceRegistration({}, "device registration failed"); |
| 876 | runRegistrationInBackground( |
| 877 | refreshActiveLiveActivityRemoteRegistration(), |
| 878 | "active live activity registration after environment connection failed", |
| 879 | ); |
| 880 | } |
| 881 | |
| 882 | function removeAgentAwarenessConnection(environmentId: EnvironmentId): void { |
| 883 | environmentConnections.delete(environmentId); |
| 884 | } |
| 885 | |
| 886 | export function unregisterAgentAwarenessConnection(environmentId: EnvironmentId): void { |
| 887 | removeAgentAwarenessConnection(environmentId); |
| 888 | } |
| 889 | |
| 890 | export function refreshAgentAwarenessRegistration(): Effect.Effect< |
| 891 | void, |
| 892 | never, |
| 893 | ManagedRelay.ManagedRelayClient |
| 894 | > { |
| 895 | return registerDeviceForCurrentUser().pipe( |
| 896 | Effect.catch((error) => |
| 897 | Effect.sync(() => { |
| 898 | // Same rationale as the queued path: a failed refresh does not undo an |
| 899 | // already accepted registration. |
| 900 | if (registrationStatus !== "registered") { |
| 901 | setRegistrationStatus("failed"); |
| 902 | } |
| 903 | logRegistrationError("device registration refresh failed", error); |
| 904 | }), |
| 905 | ), |
| 906 | ); |
| 907 | } |
| 908 | |
| 909 | export function updateAgentAwarenessRegistrationPreferences( |
| 910 | preferencesOverride: Partial<Preferences>, |
| 911 | ): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> { |
| 912 | return registerDevice({ preferencesOverride }).pipe( |
| 913 | Effect.tapError((error) => |
| 914 | Effect.sync(() => { |
| 915 | if (registrationStatus !== "registered") { |
| 916 | setRegistrationStatus("failed"); |
| 917 | } |
| 918 | logRegistrationError("device preference registration refresh failed", error); |
| 919 | }), |
| 920 | ), |
| 921 | ); |
| 922 | } |
| 923 | |
| 924 | export function __resetAgentAwarenessRemoteRegistrationForTest(): void { |
| 925 | environmentConnections.clear(); |
| 926 | pushTokenSubscription?.remove(); |
| 927 | pushTokenSubscription = null; |
| 928 | appStateSubscription?.remove(); |
| 929 | appStateSubscription = null; |
| 930 | if (activeLiveActivityRegistrationRetry) { |
| 931 | clearTimeout(activeLiveActivityRegistrationRetry); |
| 932 | activeLiveActivityRegistrationRetry = null; |
| 933 | } |
| 934 | relayTokenProvider = null; |
| 935 | relayTokenProviderIdentity = null; |
| 936 | deviceRegistrationGeneration++; |
| 937 | activeDeviceRegistration = null; |
| 938 | pendingDeviceRegistration = null; |
| 939 | registrationStatus = "unknown"; |
| 940 | androidDeviceReplayedAt = null; |
| 941 | registrationStatusListeners.clear(); |
| 942 | registeredActivityPushTokens.clear(); |
| 943 | } |
| 944 | |
| 945 | export function unregisterAgentAwarenessDeviceForCurrentUser( |
| 946 | tokenProvider: () => Promise<string | null>, |
| 947 | ): Effect.Effect<void, never, ManagedRelay.ManagedRelayClient> { |
| 948 | return Effect.gen(function* () { |
| 949 | const deviceId = yield* Effect.tryPromise({ |
| 950 | try: () => loadAgentAwarenessDeviceId(), |
| 951 | catch: (cause) => |
| 952 | new AgentAwarenessOperationError({ |
| 953 | operation: "load-device-unregistration-identifier", |
| 954 | cause, |
| 955 | }), |
| 956 | }); |
| 957 | if (!deviceId) { |
| 958 | return; |
| 959 | } |
| 960 | yield* unregisterDeviceWithRelay({ deviceId, tokenProvider }); |
| 961 | }).pipe( |
| 962 | Effect.catch((error) => |
| 963 | Effect.sync(() => { |
| 964 | logRegistrationError("device unregistration failed", error); |
| 965 | }), |
| 966 | ), |
| 967 | ); |
| 968 | } |
| 969 | |
| 970 | export function registerLiveActivityPushToken(input: { |
| 971 | readonly activity: LiveActivity<AgentActivityProps>; |
| 972 | }): Effect.Effect<boolean, unknown, ManagedRelay.ManagedRelayClient> { |
| 973 | return Effect.gen(function* () { |
| 974 | if (!canRegisterRemoteLiveActivities()) { |
| 975 | return false; |
| 976 | } |
| 977 | |
| 978 | const activityPushToken = yield* Effect.tryPromise({ |
| 979 | try: () => input.activity.getPushToken(), |
| 980 | catch: (cause) => |
| 981 | new AgentAwarenessOperationError({ |
| 982 | operation: "read-live-activity-push-token", |
| 983 | cause, |
| 984 | }), |
| 985 | }); |
| 986 | if (!activityPushToken) { |
| 987 | if (activityPushTokenListeners.has(input.activity)) { |
| 988 | logRegistrationDebug( |
| 989 | "live activity push token not available yet; token listener already registered", |
| 990 | { |
| 991 | connectionCount: environmentConnections.size, |
| 992 | }, |
| 993 | ); |
| 994 | return false; |
| 995 | } |
| 996 | |
| 997 | logRegistrationDebug( |
| 998 | "live activity push token not available yet; listening for token event", |
| 999 | { |
| 1000 | connectionCount: environmentConnections.size, |
| 1001 | }, |
| 1002 | ); |
| 1003 | activityPushTokenListeners.add(input.activity); |
| 1004 | input.activity.addPushTokenListener((event) => { |
| 1005 | if (event.pushToken) { |
| 1006 | logRegistrationDebug("live activity push token event received", { |
| 1007 | tokenSuffix: event.pushToken.slice(-8), |
| 1008 | }); |
| 1009 | runRegistrationInBackground( |
| 1010 | registerLiveActivityPushTokenValue({ |
| 1011 | activityPushToken: event.pushToken, |
| 1012 | }), |
| 1013 | "live activity token listener registration failed", |
| 1014 | ); |
| 1015 | } |
| 1016 | }); |
| 1017 | return false; |
| 1018 | } |
| 1019 | |
| 1020 | return yield* registerLiveActivityPushTokenValue({ |
| 1021 | activityPushToken, |
| 1022 | }); |
| 1023 | }); |
| 1024 | } |
| 1025 | |
| 1026 | function registerLiveActivityPushTokenValue(input: { |
| 1027 | readonly activityPushToken: string; |
| 1028 | }): Effect.Effect<boolean, unknown, ManagedRelay.ManagedRelayClient> { |
| 1029 | return Effect.gen(function* () { |
| 1030 | const acceptedAt = registeredActivityPushTokens.get(input.activityPushToken); |
| 1031 | if ( |
| 1032 | acceptedAt !== undefined && |
| 1033 | Date.now() - acceptedAt < ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS |
| 1034 | ) { |
| 1035 | return true; |
| 1036 | } |
| 1037 | const deviceId = yield* Effect.tryPromise({ |
| 1038 | try: () => loadOrCreateAgentAwarenessDeviceId(), |
| 1039 | catch: (cause) => |
| 1040 | new AgentAwarenessOperationError({ |
| 1041 | operation: "load-live-activity-registration-identifier", |
| 1042 | cause, |
| 1043 | }), |
| 1044 | }); |
| 1045 | const registered = yield* registerLiveActivityWithRelay({ |
| 1046 | deviceId, |
| 1047 | activityPushToken: input.activityPushToken, |
| 1048 | }); |
| 1049 | if (registered) { |
| 1050 | registeredActivityPushTokens.set(input.activityPushToken, Date.now()); |
| 1051 | logRegistrationDebug("live activity push token registered", { |
| 1052 | tokenSuffix: input.activityPushToken.slice(-8), |
| 1053 | }); |
| 1054 | } |
| 1055 | return registered; |
| 1056 | }); |
| 1057 | } |
| 1058 | |
| 1059 | function scheduleActiveLiveActivityRegistrationRetry(): void { |
| 1060 | if (activeLiveActivityRegistrationRetry || !relayTokenProvider) { |
| 1061 | return; |
| 1062 | } |
| 1063 | |
| 1064 | activeLiveActivityRegistrationRetry = setTimeout(() => { |
| 1065 | activeLiveActivityRegistrationRetry = null; |
| 1066 | runRegistrationInBackground( |
| 1067 | refreshActiveLiveActivityRemoteRegistration(), |
| 1068 | "active live activity token retry failed", |
| 1069 | ); |
| 1070 | }, REMOTE_ACTIVITY_REGISTRATION_RETRY_MS); |
| 1071 | } |
| 1072 | |
| 1073 | export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< |
| 1074 | void, |
| 1075 | never, |
| 1076 | ManagedRelay.ManagedRelayClient |
| 1077 | > { |
| 1078 | return Effect.gen(function* () { |
| 1079 | if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) { |
| 1080 | return; |
| 1081 | } |
| 1082 | |
| 1083 | let activities = yield* Effect.try({ |
| 1084 | try: () => getAgentLiveActivities(), |
| 1085 | catch: (cause) => |
| 1086 | new AgentAwarenessOperationError({ |
| 1087 | operation: "list-active-live-activities", |
| 1088 | cause, |
| 1089 | }), |
| 1090 | }).pipe( |
| 1091 | Effect.catch((error) => |
| 1092 | Effect.sync(() => { |
| 1093 | logRegistrationError("active live activity lookup failed", error); |
| 1094 | return [] as ReadonlyArray<LiveActivity<AgentActivityProps>>; |
| 1095 | }), |
| 1096 | ), |
| 1097 | ); |
| 1098 | |
| 1099 | // The relay tracks exactly one card per device; if concurrent arming ever |
| 1100 | // produced extras, end them so only one keeps receiving updates. |
| 1101 | if (activities.length > 1) { |
| 1102 | for (const extra of activities.slice(1)) { |
| 1103 | extra.end("immediate").catch((error: unknown) => { |
| 1104 | logRegistrationError("duplicate live activity cleanup failed", error); |
| 1105 | }); |
| 1106 | } |
| 1107 | activities = activities.slice(0, 1); |
| 1108 | } |
| 1109 | |
| 1110 | // Activities are only ever created here, in the foreground, where the |
| 1111 | // update token can be observed and registered immediately — the relay |
| 1112 | // never remote-starts one (background push-to-start wakes proved too |
| 1113 | // unreliable to hand the token over). Arming is conditional: the relay is |
| 1114 | // asked what the card would show first, so an idle open never creates an |
| 1115 | // empty lock-screen card, and an armed card is born with the real |
| 1116 | // aggregate instead of a placeholder. |
| 1117 | if (activities.length === 0) { |
| 1118 | const preferences = yield* Effect.tryPromise({ |
| 1119 | try: () => loadPreferences(), |
| 1120 | catch: (cause) => |
| 1121 | new AgentAwarenessOperationError({ |
| 1122 | operation: "load-live-activity-prime-preferences", |
| 1123 | cause, |
| 1124 | }), |
| 1125 | }).pipe(Effect.orElseSucceed(() => null)); |
| 1126 | // The toggle defaults to on: an unset preference (fresh install) must |
| 1127 | // prime, so only an explicit false blocks it. |
| 1128 | if (preferences?.liveActivitiesEnabled !== false) { |
| 1129 | const snapshot = yield* readAgentActivitySnapshot(); |
| 1130 | // The snapshot request yields; an arm-on-send may have created the |
| 1131 | // card in the meantime. Re-check so two cards are never started. |
| 1132 | const armedMeanwhile = yield* Effect.try({ |
| 1133 | try: () => getAgentLiveActivities(), |
| 1134 | catch: () => [] as ReadonlyArray<LiveActivity<AgentActivityProps>>, |
| 1135 | }).pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<LiveActivity<AgentActivityProps>>)); |
| 1136 | if (armedMeanwhile.length > 0) { |
| 1137 | activities = [...armedMeanwhile]; |
| 1138 | } else if (snapshot?.aggregate && snapshot.aggregate.activeCount > 0) { |
| 1139 | const aggregate = snapshot.aggregate; |
| 1140 | const primed = yield* Effect.try({ |
| 1141 | try: () => |
| 1142 | startAgentLiveActivity( |
| 1143 | { |
| 1144 | title: aggregate.title, |
| 1145 | subtitle: aggregate.subtitle, |
| 1146 | activeCount: aggregate.activeCount, |
| 1147 | updatedAt: aggregate.updatedAt, |
| 1148 | activities: aggregate.activities, |
| 1149 | }, |
| 1150 | liveActivityStaleDate(), |
| 1151 | ), |
| 1152 | catch: (cause) => |
| 1153 | new AgentAwarenessOperationError({ |
| 1154 | operation: "prime-live-activity", |
| 1155 | cause, |
| 1156 | }), |
| 1157 | }).pipe( |
| 1158 | Effect.catch((error) => |
| 1159 | Effect.sync(() => { |
| 1160 | logRegistrationError("live activity priming failed", error); |
| 1161 | return null; |
| 1162 | }), |
| 1163 | ), |
| 1164 | ); |
| 1165 | if (primed) { |
| 1166 | logRegistrationDebug("live activity card primed", { |
| 1167 | activeCount: aggregate.activeCount, |
| 1168 | }); |
| 1169 | activities = [primed]; |
| 1170 | } |
| 1171 | } |
| 1172 | } |
| 1173 | } |
| 1174 | |
| 1175 | const registrationResults = yield* Effect.forEach(activities, (activity) => |
| 1176 | registerLiveActivityPushToken({ activity }).pipe( |
| 1177 | Effect.map((registered) => !registered), |
| 1178 | Effect.catch((error) => |
| 1179 | Effect.sync(() => { |
| 1180 | logRegistrationError("active live activity token registration failed", error); |
| 1181 | return true; |
| 1182 | }), |
| 1183 | ), |
| 1184 | ), |
| 1185 | ); |
| 1186 | |
| 1187 | if (registrationResults.some(Boolean)) { |
| 1188 | scheduleActiveLiveActivityRegistrationRetry(); |
| 1189 | } |
| 1190 | }); |
| 1191 | } |