@openchambery/web 1.19.3-beta.5 → 1.19.3-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/assets/{AssistantView-0VuZD1WE.js → AssistantView-CPF6Nfb6.js} +1 -1
  2. package/dist/assets/{ChatContainer-DLVqAYSc.js → ChatContainer-CldTRJmB.js} +4 -4
  3. package/dist/assets/{ContextPanel-WEaLnfGc.js → ContextPanel-BJnRefSW.js} +3 -3
  4. package/dist/assets/{DiagramView-BqmWuPGO.js → DiagramView-Co5-JMSr.js} +1 -1
  5. package/dist/assets/{MarkdownRendererImpl-DmZhTDxm.js → MarkdownRendererImpl-Cx10d-dT.js} +1 -1
  6. package/dist/assets/{MobileDetailNavigation-1Q8_I0Ys.js → MobileDetailNavigation-ZJtJMDT3.js} +1 -1
  7. package/dist/assets/{MobileShareBridge-DhG_umux.js → MobileShareBridge-3NBuOdWs.js} +1 -1
  8. package/dist/assets/{MobileSurface-DSDyKHqq.js → MobileSurface-BZQDdrqA.js} +1 -1
  9. package/dist/assets/{MultiRunWindow-Bp5TlAEq.js → MultiRunWindow-BY2_dGd_.js} +1 -1
  10. package/dist/assets/{SettingsView-CIa0TvNx.js → SettingsView-BsnYPviY.js} +1 -1
  11. package/dist/assets/{SettingsWindow-DLD9SpBk.js → SettingsWindow-DT92dg19.js} +1 -1
  12. package/dist/assets/{TerminalView-Bi26Ea9z.js → TerminalView-uqPJOhzP.js} +1 -1
  13. package/dist/assets/{ToolOutputDialog-Dt5qPnmx.js → ToolOutputDialog-_9sExuXj.js} +1 -1
  14. package/dist/assets/{appThemeRegistry-DT_7iUXg.js → appThemeRegistry-DL8zx2Tf.js} +2 -2
  15. package/dist/assets/{gitApi-Dt0-GiBd.js → gitApi-C5a0FHg7.js} +1 -1
  16. package/dist/assets/{insertionBoundaries-CdbLoTBV.js → insertionBoundaries-CN8Y824W.js} +1 -1
  17. package/dist/assets/{main-jF2oP_Wf.js → main-BOEazbCD.js} +3 -3
  18. package/dist/assets/{main-CkPuVRol.js → main-ej4kLP0y.js} +2 -2
  19. package/dist/assets/{miniChat-oGjSmwle.js → miniChat-h4TCod13.js} +2 -2
  20. package/dist/assets/{mobile-DXmjkSEM.js → mobile-D3ev3kF7.js} +2 -2
  21. package/dist/assets/{multirun-BppWsdIG.js → multirun-Bb6ZUBgz.js} +1 -1
  22. package/dist/assets/{projectMeta-BjgEm9X0.js → projectMeta-DAnaHeES.js} +1 -1
  23. package/dist/assets/{radio-Dv97SxyN.js → radio-iuo8w3_4.js} +1 -1
  24. package/dist/assets/{renderElectronMiniChatApp-Cx0vx0wA.js → renderElectronMiniChatApp-aIjl55lT.js} +2 -2
  25. package/dist/assets/{renderMobileApp-BtNv6I2r.js → renderMobileApp-MTX_4lox.js} +5 -5
  26. package/dist/assets/runtimeConfig-DY2RRqNN.js +2 -0
  27. package/dist/assets/{runtimeEndpointReset-bE9HMBRf.js → runtimeEndpointReset-CM9B-gNb.js} +1 -1
  28. package/dist/assets/{shikiHighlight-BCt3A4JA.js → shikiHighlight-jCavhi4H.js} +1 -1
  29. package/dist/assets/{useAppFontEffects-D7X7pSIl.js → useAppFontEffects-I4H3yYVm.js} +2 -2
  30. package/dist/assets/{useDesktopWindowControlsLayout-Cnrx9lrf.js → useDesktopWindowControlsLayout-DGrq4UFu.js} +1 -1
  31. package/dist/assets/{useEffectiveDirectory-CM0CRezH.js → useEffectiveDirectory-Cf6WHNi9.js} +1 -1
  32. package/dist/assets/{useMobileNavigationStore-CJK1RtBV.js → useMobileNavigationStore-9NzHuLJl.js} +1 -1
  33. package/dist/assets/{useSessionAutoCleanup-CrtZ-ooK.js → useSessionAutoCleanup-QOfL2Fdn.js} +1 -1
  34. package/dist/assets/{useWorkerHighlightedLines-CxkUt11X.js → useWorkerHighlightedLines-NWmW5faW.js} +1 -1
  35. package/dist/index.html +2 -2
  36. package/dist/mini-chat.html +2 -2
  37. package/dist/mobile.html +2 -2
  38. package/package.json +1 -1
  39. package/server/index.js +7 -0
  40. package/server/lib/notifications/APNS.md +35 -0
  41. package/server/lib/notifications/DOCUMENTATION.md +8 -2
  42. package/server/lib/notifications/apns-runtime.js +558 -7
  43. package/server/lib/notifications/apns-runtime.test.js +475 -0
  44. package/server/lib/notifications/routes.js +55 -0
  45. package/server/lib/notifications/runtime.js +15 -4
  46. package/server/lib/notifications/runtime.test.js +108 -0
  47. package/server/lib/opencode/bootstrap-runtime.js +6 -0
  48. package/server/lib/opencode/bootstrap-runtime.test.js +21 -0
  49. package/dist/assets/runtimeConfig-CPeD_UHI.js +0 -2
@@ -21,16 +21,27 @@ import {
21
21
  normalizeApnsLocale,
22
22
  } from './apns-titles.js';
23
23
 
24
- const APNS_TOKENS_VERSION = 1;
24
+ const APNS_TOKENS_VERSION = 2;
25
+ const APNS_TOKENS_SUPPORTED_VERSIONS = new Set([1, APNS_TOKENS_VERSION]);
25
26
  const APNS_HOST_PRODUCTION = 'https://api.push.apple.com';
26
27
  const APNS_HOST_SANDBOX = 'https://api.sandbox.push.apple.com';
27
28
  // APNs rejects auth tokens older than 1h; refresh well inside that window.
28
29
  const JWT_TTL_MS = 50 * 60 * 1000;
29
30
  const DEFAULT_BUNDLE_ID = 'com.yee94.openchamber';
30
31
  const MAX_TOKENS_PER_SESSION = 10;
32
+ const MAX_LIVE_ACTIVITY_TOKENS_PER_SESSION = 8;
33
+ const MAX_LIVE_ACTIVITY_TOKEN_CHARS = 512;
34
+ const MAX_LIVE_ACTIVITY_ID_CHARS = 128;
35
+ const LIVE_ACTIVITY_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
36
+ const LIVE_ACTIVITY_COMPLETE_DISMISSAL_SECONDS = 15 * 60;
37
+ const LIVE_ACTIVITY_ERROR_DISMISSAL_SECONDS = 60 * 60;
38
+ const LIVE_ACTIVITY_STALE_SECONDS = 20 * 60;
31
39
  const MAX_RELAY_SEND_TOKENS = 100;
32
40
  const RELAY_REGISTER_CONCURRENCY = 16;
33
41
  const APNS_COLLAPSE_ID_MAX_BYTES = 64;
42
+ const LIVE_ACTIVITY_KIND = 'liveactivity';
43
+ const LIVE_ACTIVITY_PLATFORM = 'ios';
44
+ const LIVE_ACTIVITY_TERMINAL_STATUSES = new Set(['complete', 'error']);
34
45
  // APNs reasons that mean the token is permanently invalid → drop it.
35
46
  const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic']);
36
47
 
@@ -82,6 +93,8 @@ export const createApnsRuntime = (deps) => {
82
93
  let warnedUnconfigured = false;
83
94
  let registrationSuccessCache = new Map();
84
95
  let registrationCacheRegisterUrl = null;
96
+ let liveActivityRegistrationSuccessCache = new Map();
97
+ let liveActivityRegistrationCacheRegisterUrl = null;
85
98
 
86
99
  // ---------------------------------------------------------------------------
87
100
  // Per-server relay signing identity (ECDSA P-256). Auto-generated + persisted in settings
@@ -117,6 +130,18 @@ export const createApnsRuntime = (deps) => {
117
130
  registrationCacheRegisterUrl = registerUrl;
118
131
  };
119
132
 
133
+ const rememberLiveActivityRelayRegisterUrl = (registerUrl) => {
134
+ if (liveActivityRegistrationCacheRegisterUrl === registerUrl) return;
135
+ liveActivityRegistrationSuccessCache = new Map();
136
+ liveActivityRegistrationCacheRegisterUrl = registerUrl;
137
+ };
138
+
139
+ const liveActivityRegistrationCacheKey = (registerUrl, token) =>
140
+ `${registerUrl}\0${token}\0${LIVE_ACTIVITY_PLATFORM}\0${LIVE_ACTIVITY_KIND}`;
141
+
142
+ const isLimitedId = (value, maxChars) =>
143
+ typeof value === 'string' && value.length > 0 && value.length <= maxChars;
144
+
120
145
  const boundRegistrationCache = (registerUrl, entries) => {
121
146
  rememberRelayRegisterUrl(registerUrl);
122
147
  const allowed = new Set(
@@ -155,22 +180,111 @@ export const createApnsRuntime = (deps) => {
155
180
  }
156
181
  };
157
182
 
183
+ const registerLiveActivityTokenWithRelay = async (token, relayOverride) => {
184
+ const relay = relayOverride ?? (await resolveRelayConfig());
185
+ if (!relay) return true;
186
+ rememberLiveActivityRelayRegisterUrl(relay.liveActivityRegisterUrl);
187
+ const cacheKey = liveActivityRegistrationCacheKey(relay.liveActivityRegisterUrl, token);
188
+ if (liveActivityRegistrationSuccessCache.has(cacheKey)) return true;
189
+ try {
190
+ const { privateKey, publicJwk } = await getOrCreateRelayKeypair();
191
+ const ts = Date.now();
192
+ const sig = signRelayMessage(
193
+ privateKey,
194
+ `${ts}.${token}.${LIVE_ACTIVITY_PLATFORM}.${LIVE_ACTIVITY_KIND}`,
195
+ );
196
+ const res = await fetch(relay.liveActivityRegisterUrl, {
197
+ method: 'POST',
198
+ headers: { 'content-type': 'application/json' },
199
+ body: JSON.stringify({
200
+ token,
201
+ platform: LIVE_ACTIVITY_PLATFORM,
202
+ kind: LIVE_ACTIVITY_KIND,
203
+ publicKeyJwk: relayPublicJwk(publicJwk),
204
+ ts,
205
+ sig,
206
+ }),
207
+ });
208
+ if (!res.ok) {
209
+ console.warn(`[Push relay] register-live-activity-token failed status=${res.status}`);
210
+ return false;
211
+ }
212
+ liveActivityRegistrationSuccessCache.set(cacheKey, true);
213
+ return true;
214
+ } catch (error) {
215
+ console.warn('[Push relay] register-live-activity-token request failed:', error?.message ?? error);
216
+ return false;
217
+ }
218
+ };
219
+
220
+ const unregisterLiveActivityTokenWithRelay = async (token) => {
221
+ const relay = await resolveRelayConfig();
222
+ if (!relay || !isLimitedId(token, MAX_LIVE_ACTIVITY_TOKEN_CHARS)) return;
223
+ rememberLiveActivityRelayRegisterUrl(relay.liveActivityRegisterUrl);
224
+ liveActivityRegistrationSuccessCache.delete(
225
+ liveActivityRegistrationCacheKey(relay.liveActivityRegisterUrl, token),
226
+ );
227
+ try {
228
+ const { privateKey, publicJwk } = await getOrCreateRelayKeypair();
229
+ const ts = Date.now();
230
+ const sig = signRelayMessage(
231
+ privateKey,
232
+ `${ts}.${token}.${LIVE_ACTIVITY_PLATFORM}.${LIVE_ACTIVITY_KIND}`,
233
+ );
234
+ const res = await fetch(relay.liveActivityUnregisterUrl, {
235
+ method: 'POST',
236
+ headers: { 'content-type': 'application/json' },
237
+ body: JSON.stringify({
238
+ token,
239
+ platform: LIVE_ACTIVITY_PLATFORM,
240
+ kind: LIVE_ACTIVITY_KIND,
241
+ publicKeyJwk: relayPublicJwk(publicJwk),
242
+ ts,
243
+ sig,
244
+ }),
245
+ });
246
+ if (!res.ok) {
247
+ console.warn(`[Push relay] unregister-live-activity-token failed status=${res.status}`);
248
+ }
249
+ } catch (error) {
250
+ console.warn('[Push relay] unregister-live-activity-token request failed:', error?.message ?? error);
251
+ }
252
+ };
253
+
158
254
  // ---------------------------------------------------------------------------
159
255
  // Token persistence (same shape + write-lock pattern as push-runtime.js)
160
256
  // ---------------------------------------------------------------------------
161
257
 
162
- const emptyStore = () => ({ version: APNS_TOKENS_VERSION, tokensBySession: {} });
258
+ const emptyStore = () => ({
259
+ version: APNS_TOKENS_VERSION,
260
+ tokensBySession: {},
261
+ liveActivityTokensBySession: {},
262
+ liveActivityEventVersions: {},
263
+ });
163
264
 
164
265
  const readTokensFromDisk = async () => {
165
266
  try {
166
267
  const raw = await fsPromises.readFile(APNS_TOKENS_FILE_PATH, 'utf8');
167
268
  const parsed = JSON.parse(raw);
168
- if (!parsed || typeof parsed !== 'object' || parsed.version !== APNS_TOKENS_VERSION) {
269
+ if (!parsed || typeof parsed !== 'object' || !APNS_TOKENS_SUPPORTED_VERSIONS.has(parsed.version)) {
169
270
  return emptyStore();
170
271
  }
171
272
  const tokensBySession =
172
273
  parsed.tokensBySession && typeof parsed.tokensBySession === 'object' ? parsed.tokensBySession : {};
173
- return { version: APNS_TOKENS_VERSION, tokensBySession };
274
+ const liveActivityTokensBySession =
275
+ parsed.liveActivityTokensBySession && typeof parsed.liveActivityTokensBySession === 'object'
276
+ ? parsed.liveActivityTokensBySession
277
+ : {};
278
+ const liveActivityEventVersions =
279
+ parsed.liveActivityEventVersions && typeof parsed.liveActivityEventVersions === 'object'
280
+ ? parsed.liveActivityEventVersions
281
+ : {};
282
+ return {
283
+ version: APNS_TOKENS_VERSION,
284
+ tokensBySession,
285
+ liveActivityTokensBySession,
286
+ liveActivityEventVersions,
287
+ };
174
288
  } catch (error) {
175
289
  if (error && typeof error === 'object' && error.code === 'ENOENT') {
176
290
  return emptyStore();
@@ -188,9 +302,21 @@ export const createApnsRuntime = (deps) => {
188
302
  const persistTokenUpdate = async (mutate) => {
189
303
  persistLock = persistLock.then(async () => {
190
304
  const current = await readTokensFromDisk();
191
- const next = mutate({ version: APNS_TOKENS_VERSION, tokensBySession: current.tokensBySession || {} });
192
- await writeTokensToDisk(next);
193
- return next;
305
+ const base = {
306
+ version: APNS_TOKENS_VERSION,
307
+ tokensBySession: current.tokensBySession || {},
308
+ liveActivityTokensBySession: current.liveActivityTokensBySession || {},
309
+ liveActivityEventVersions: current.liveActivityEventVersions || {},
310
+ };
311
+ const next = mutate(base);
312
+ const written = {
313
+ version: APNS_TOKENS_VERSION,
314
+ tokensBySession: next.tokensBySession || {},
315
+ liveActivityTokensBySession: next.liveActivityTokensBySession ?? base.liveActivityTokensBySession,
316
+ liveActivityEventVersions: next.liveActivityEventVersions ?? base.liveActivityEventVersions,
317
+ };
318
+ await writeTokensToDisk(written);
319
+ return written;
194
320
  });
195
321
  return persistLock;
196
322
  };
@@ -216,6 +342,44 @@ export const createApnsRuntime = (deps) => {
216
342
  .filter(Boolean);
217
343
  };
218
344
 
345
+ const normalizeLiveActivityTokens = (record, now = Date.now()) => {
346
+ if (!Array.isArray(record)) return [];
347
+ return record
348
+ .map((entry) => {
349
+ if (!entry || typeof entry !== 'object') return null;
350
+ const token = typeof entry.token === 'string' ? entry.token.trim() : '';
351
+ const activityId = typeof entry.activityId === 'string' ? entry.activityId.trim() : '';
352
+ const sessionId = typeof entry.sessionId === 'string' ? entry.sessionId.trim() : '';
353
+ if (!isLimitedId(token, MAX_LIVE_ACTIVITY_TOKEN_CHARS)) return null;
354
+ if (!isLimitedId(activityId, MAX_LIVE_ACTIVITY_ID_CHARS)) return null;
355
+ if (!isLimitedId(sessionId, MAX_LIVE_ACTIVITY_ID_CHARS)) return null;
356
+ const createdAt = typeof entry.createdAt === 'number' ? entry.createdAt : null;
357
+ const lastSeenAt = typeof entry.lastSeenAt === 'number' ? entry.lastSeenAt : createdAt;
358
+ if (typeof lastSeenAt === 'number' && now - lastSeenAt > LIVE_ACTIVITY_TOKEN_TTL_MS) return null;
359
+ return { token, activityId, sessionId, createdAt, lastSeenAt };
360
+ })
361
+ .filter(Boolean);
362
+ };
363
+
364
+ const pruneLiveActivityTokensBySession = (liveActivityTokensBySession, now = Date.now()) => {
365
+ const next = {};
366
+ for (const [uiSessionToken, record] of Object.entries(liveActivityTokensBySession || {})) {
367
+ const entries = normalizeLiveActivityTokens(record, now);
368
+ if (entries.length > 0) next[uiSessionToken] = entries;
369
+ }
370
+ return next;
371
+ };
372
+
373
+ const countLiveActivityTokenCopies = (liveActivityTokensBySession, token) => {
374
+ let count = 0;
375
+ for (const record of Object.values(liveActivityTokensBySession || {})) {
376
+ for (const entry of record) {
377
+ if (entry?.token === token) count += 1;
378
+ }
379
+ }
380
+ return count;
381
+ };
382
+
219
383
  // Normalize an incoming platform hint to the two we support; default to APNs/iOS since that
220
384
  // was the only registrant before Android/FCM existed.
221
385
  const normalizePlatform = (platform) => (platform === 'android' ? 'android' : 'ios');
@@ -300,6 +464,89 @@ export const createApnsRuntime = (deps) => {
300
464
  });
301
465
  };
302
466
 
467
+ const addOrUpdateLiveActivityToken = async (uiSessionToken, token, activityId, sessionId) => {
468
+ if (!uiSessionToken) return;
469
+ const trimmedToken = typeof token === 'string' ? token.trim() : '';
470
+ const trimmedActivityId = typeof activityId === 'string' ? activityId.trim() : '';
471
+ const trimmedSessionId = typeof sessionId === 'string' ? sessionId.trim() : '';
472
+ if (!isLimitedId(trimmedToken, MAX_LIVE_ACTIVITY_TOKEN_CHARS)) return;
473
+ if (!isLimitedId(trimmedActivityId, MAX_LIVE_ACTIVITY_ID_CHARS)) return;
474
+ if (!isLimitedId(trimmedSessionId, MAX_LIVE_ACTIVITY_ID_CHARS)) return;
475
+ const now = Date.now();
476
+ let replacedToken = null;
477
+
478
+ await persistTokenUpdate((current) => {
479
+ const liveActivityTokensBySession = pruneLiveActivityTokensBySession(
480
+ current.liveActivityTokensBySession,
481
+ now,
482
+ );
483
+ const existing = liveActivityTokensBySession[uiSessionToken] || [];
484
+ const replaced = existing.find((entry) => entry.activityId === trimmedActivityId);
485
+ if (replaced && replaced.token !== trimmedToken) replacedToken = replaced.token;
486
+ const filtered = existing.filter(
487
+ (entry) => entry.activityId !== trimmedActivityId && entry.token !== trimmedToken,
488
+ );
489
+ filtered.unshift({
490
+ token: trimmedToken,
491
+ activityId: trimmedActivityId,
492
+ sessionId: trimmedSessionId,
493
+ createdAt: typeof replaced?.createdAt === 'number' ? replaced.createdAt : now,
494
+ lastSeenAt: now,
495
+ });
496
+ liveActivityTokensBySession[uiSessionToken] = filtered.slice(0, MAX_LIVE_ACTIVITY_TOKENS_PER_SESSION);
497
+ return { ...current, liveActivityTokensBySession };
498
+ });
499
+
500
+ await registerLiveActivityTokenWithRelay(trimmedToken);
501
+ if (replacedToken) {
502
+ const store = await readTokensFromDisk();
503
+ if (countLiveActivityTokenCopies(store.liveActivityTokensBySession, replacedToken) === 0) {
504
+ await unregisterLiveActivityTokenWithRelay(replacedToken);
505
+ }
506
+ }
507
+ };
508
+
509
+ const removeLiveActivityToken = async (uiSessionToken, token) => {
510
+ if (!uiSessionToken || typeof token !== 'string' || token.length === 0) return;
511
+ let removed = false;
512
+ await persistTokenUpdate((current) => {
513
+ const liveActivityTokensBySession = pruneLiveActivityTokensBySession(
514
+ current.liveActivityTokensBySession,
515
+ );
516
+ const existing = liveActivityTokensBySession[uiSessionToken] || [];
517
+ const filtered = existing.filter((entry) => {
518
+ if (entry.token !== token) return true;
519
+ removed = true;
520
+ return false;
521
+ });
522
+ if (filtered.length === 0) delete liveActivityTokensBySession[uiSessionToken];
523
+ else liveActivityTokensBySession[uiSessionToken] = filtered;
524
+ return { ...current, liveActivityTokensBySession };
525
+ });
526
+ if (!removed) return;
527
+ const store = await readTokensFromDisk();
528
+ if (countLiveActivityTokenCopies(store.liveActivityTokensBySession, token) === 0) {
529
+ await unregisterLiveActivityTokenWithRelay(token);
530
+ }
531
+ };
532
+
533
+ const removeLiveActivityTokens = async (tokensToRemove) => {
534
+ const unique = [...new Set((tokensToRemove || []).filter((token) => typeof token === 'string' && token.length > 0))];
535
+ if (unique.length === 0) return;
536
+ const drop = new Set(unique);
537
+ await persistTokenUpdate((current) => {
538
+ const liveActivityTokensBySession = pruneLiveActivityTokensBySession(
539
+ current.liveActivityTokensBySession,
540
+ );
541
+ for (const [uiSessionToken, entries] of Object.entries(liveActivityTokensBySession)) {
542
+ const filtered = entries.filter((entry) => !drop.has(entry.token));
543
+ if (filtered.length === 0) delete liveActivityTokensBySession[uiSessionToken];
544
+ else liveActivityTokensBySession[uiSessionToken] = filtered;
545
+ }
546
+ return { ...current, liveActivityTokensBySession };
547
+ });
548
+ };
549
+
303
550
  // ---------------------------------------------------------------------------
304
551
  // Config (env first, then settings.apnsConfig) — mirrors resolveVapidSubject
305
552
  // ---------------------------------------------------------------------------
@@ -459,6 +706,9 @@ export const createApnsRuntime = (deps) => {
459
706
  const buildRelayConfig = (url) => ({
460
707
  url,
461
708
  registerUrl: url.replace(/\/send$/, '/register-token'),
709
+ liveActivityRegisterUrl: url.replace(/\/send$/, '/register-live-activity-token'),
710
+ liveActivityUnregisterUrl: url.replace(/\/send$/, '/unregister-live-activity-token'),
711
+ liveActivitySendUrl: url.replace(/\/send$/, '/live-activity'),
462
712
  environment:
463
713
  (trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || 'sandbox').toLowerCase() === 'production'
464
714
  ? 'production'
@@ -578,6 +828,304 @@ export const createApnsRuntime = (deps) => {
578
828
  });
579
829
  };
580
830
 
831
+ const toLiveActivityUnixSeconds = (value, fallbackMs) => {
832
+ if (typeof value === 'number' && Number.isFinite(value)) {
833
+ return value > 1e12 ? value / 1000 : value;
834
+ }
835
+ return fallbackMs / 1000;
836
+ };
837
+
838
+ const nextLiveActivityEventVersion = (nowMs, previous, provided) => {
839
+ const prior = typeof previous === 'number' && Number.isFinite(previous) ? previous : 0;
840
+ let next = Math.max(Math.floor(nowMs), prior + 1);
841
+ if (typeof provided === 'number' && Number.isFinite(provided) && provided > 0) {
842
+ next = Math.max(next, Math.floor(provided));
843
+ }
844
+ return next;
845
+ };
846
+
847
+ const liveActivityDismissalDate = (status, nowMs) => {
848
+ const extra = status === 'error'
849
+ ? LIVE_ACTIVITY_ERROR_DISMISSAL_SECONDS
850
+ : LIVE_ACTIVITY_COMPLETE_DISMISSAL_SECONDS;
851
+ return Math.floor(nowMs / 1000) + extra;
852
+ };
853
+
854
+ // End carries dismissalDate only. Update (not sent here) would set staleDate = updatedAt+20min.
855
+ const liveActivityPushDates = (event, status, updatedAtSeconds, nowMs) => {
856
+ if (event === 'update') return { staleDate: updatedAtSeconds + LIVE_ACTIVITY_STALE_SECONDS };
857
+ return { dismissalDate: liveActivityDismissalDate(status, nowMs) };
858
+ };
859
+
860
+ const liveActivityRelaySignMessage = ({ ts, tokens, event, contentState, dismissalDate, staleDate }) => (
861
+ `${ts}.${[...tokens].sort().join(',')}.${event}.${contentState.status}.${contentState.eventVersion}.${contentState.updatedAt}.${contentState.endedAt ?? ''}.${dismissalDate ?? ''}.${staleDate ?? ''}`
862
+ );
863
+
864
+ const sendLiveActivityViaRelay = async (deviceTokens, payload, relay) => {
865
+ if (!Array.isArray(deviceTokens) || deviceTokens.length === 0) return [];
866
+ const accepted = [];
867
+ const { privateKey, publicJwk } = await getOrCreateRelayKeypair();
868
+ for (let start = 0; start < deviceTokens.length; start += MAX_RELAY_SEND_TOKENS) {
869
+ const tokens = deviceTokens.slice(start, start + MAX_RELAY_SEND_TOKENS);
870
+ const ts = Date.now();
871
+ const sig = signRelayMessage(
872
+ privateKey,
873
+ liveActivityRelaySignMessage({
874
+ ts,
875
+ tokens,
876
+ event: payload.event,
877
+ contentState: payload.contentState,
878
+ dismissalDate: payload.dismissalDate,
879
+ staleDate: payload.staleDate,
880
+ }),
881
+ );
882
+ const requestBody = {
883
+ tokens,
884
+ event: payload.event,
885
+ contentState: payload.contentState,
886
+ env: relay.environment,
887
+ publicKeyJwk: relayPublicJwk(publicJwk),
888
+ ts,
889
+ sig,
890
+ };
891
+ if (payload.dismissalDate != null) requestBody.dismissalDate = payload.dismissalDate;
892
+ if (payload.staleDate != null) requestBody.staleDate = payload.staleDate;
893
+ try {
894
+ const res = await fetch(relay.liveActivitySendUrl, {
895
+ method: 'POST',
896
+ headers: { 'content-type': 'application/json' },
897
+ body: JSON.stringify(requestBody),
898
+ });
899
+ if (!res.ok) {
900
+ console.warn(`[APNs relay] live-activity send failed status=${res.status}`);
901
+ continue;
902
+ }
903
+ accepted.push(...tokens);
904
+ const data = await res.json().catch(() => null);
905
+ const results = Array.isArray(data?.results) ? data.results : [];
906
+ for (const result of results) {
907
+ if (result && result.drop === true && typeof result.token === 'string') {
908
+ await removeLiveActivityTokens([result.token]);
909
+ }
910
+ }
911
+ } catch (error) {
912
+ console.warn('[APNs relay] live-activity request failed:', error?.message ?? error);
913
+ }
914
+ }
915
+ return accepted;
916
+ };
917
+
918
+ const sendLiveActivityOne = (client, deviceToken, body, jwt, config) =>
919
+ new Promise((resolve) => {
920
+ const headers = {
921
+ ':method': 'POST',
922
+ ':path': `/3/device/${deviceToken}`,
923
+ authorization: `bearer ${jwt}`,
924
+ 'apns-topic': `${config.bundleId}.push-type.liveactivity`,
925
+ 'apns-push-type': 'liveactivity',
926
+ 'apns-priority': '10',
927
+ };
928
+
929
+ let req;
930
+ try {
931
+ req = client.request(headers);
932
+ } catch (error) {
933
+ console.warn('[APNs] live-activity request open failed:', error?.message ?? error);
934
+ resolve({ token: deviceToken, accepted: false });
935
+ return;
936
+ }
937
+
938
+ let status = 0;
939
+ let responseBody = '';
940
+ req.on('response', (resHeaders) => {
941
+ status = Number(resHeaders[':status']) || 0;
942
+ });
943
+ req.setEncoding('utf8');
944
+ req.on('data', (chunk) => {
945
+ responseBody += chunk;
946
+ });
947
+ req.on('end', async () => {
948
+ if (status === 200) {
949
+ resolve({ token: deviceToken, accepted: true });
950
+ return;
951
+ }
952
+ let reason = '';
953
+ try {
954
+ reason = JSON.parse(responseBody)?.reason || '';
955
+ } catch {
956
+ // non-JSON error body
957
+ }
958
+ if (status === 410 || DEAD_TOKEN_REASONS.has(reason)) {
959
+ await removeLiveActivityTokens([deviceToken]);
960
+ } else {
961
+ console.warn(`[APNs] live-activity push failed status=${status} reason=${reason || 'unknown'}`);
962
+ }
963
+ resolve({ token: deviceToken, accepted: false });
964
+ });
965
+ req.on('error', (error) => {
966
+ console.warn('[APNs] live-activity request error:', error?.message ?? error);
967
+ resolve({ token: deviceToken, accepted: false });
968
+ });
969
+ req.end(body);
970
+ });
971
+
972
+ const buildLiveActivityDirectBody = (payload) => {
973
+ const aps = {
974
+ timestamp: Math.floor(Date.now() / 1000),
975
+ event: payload.event,
976
+ 'content-state': {
977
+ status: payload.contentState.status,
978
+ eventVersion: payload.contentState.eventVersion,
979
+ updatedAt: payload.contentState.updatedAt,
980
+ endedAt: payload.contentState.endedAt,
981
+ },
982
+ };
983
+ if (payload.dismissalDate != null) aps['dismissal-date'] = payload.dismissalDate;
984
+ if (payload.staleDate != null) aps['stale-date'] = payload.staleDate;
985
+ return JSON.stringify({ aps });
986
+ };
987
+
988
+ const sendLiveActivityViaDirectApns = async (deviceTokens, payload) => {
989
+ if (!Array.isArray(deviceTokens) || deviceTokens.length === 0) return [];
990
+ const config = await resolveApnsConfig();
991
+ if (!config) {
992
+ if (!warnedUnconfigured) {
993
+ warnedUnconfigured = true;
994
+ console.warn(
995
+ '[APNs] Relay disabled and no direct config; set OPENCHAMBER_APNS_KEY_ID / OPENCHAMBER_APNS_TEAM_ID / OPENCHAMBER_APNS_P8 for direct send.',
996
+ );
997
+ }
998
+ return [];
999
+ }
1000
+
1001
+ const host = config.environment === 'production' ? APNS_HOST_PRODUCTION : APNS_HOST_SANDBOX;
1002
+ const jwt = getJwt(config);
1003
+ const body = buildLiveActivityDirectBody(payload);
1004
+
1005
+ let client;
1006
+ try {
1007
+ client = http2.connect(host);
1008
+ } catch (error) {
1009
+ console.warn('[APNs] live-activity connect failed:', error?.message ?? error);
1010
+ return [];
1011
+ }
1012
+
1013
+ const outcomes = await new Promise((resolve) => {
1014
+ let settled = false;
1015
+ const finish = (value) => {
1016
+ if (settled) return;
1017
+ settled = true;
1018
+ try {
1019
+ client.close();
1020
+ } catch {
1021
+ // ignore close errors
1022
+ }
1023
+ resolve(value);
1024
+ };
1025
+ client.on('error', (error) => {
1026
+ console.warn('[APNs] live-activity session error:', error?.message ?? error);
1027
+ finish([]);
1028
+ });
1029
+ Promise.all(
1030
+ deviceTokens.map((token) => sendLiveActivityOne(client, token, body, jwt, config)),
1031
+ ).then((results) => finish(results), () => finish([]));
1032
+ });
1033
+
1034
+ return (outcomes || []).filter((result) => result?.accepted).map((result) => result.token);
1035
+ };
1036
+
1037
+ const collectLiveActivityEntriesForSession = (store, sessionId, now = Date.now()) => {
1038
+ const entries = [];
1039
+ const seen = new Set();
1040
+ for (const record of Object.values(pruneLiveActivityTokensBySession(store.liveActivityTokensBySession, now))) {
1041
+ for (const entry of record) {
1042
+ if (entry.sessionId !== sessionId || seen.has(entry.token)) continue;
1043
+ seen.add(entry.token);
1044
+ entries.push(entry);
1045
+ }
1046
+ }
1047
+ return entries;
1048
+ };
1049
+
1050
+ const sendLiveActivityEnd = async ({ sessionId, status, eventVersion, endedAt } = {}) => {
1051
+ const trimmedSessionId = typeof sessionId === 'string' ? sessionId.trim() : '';
1052
+ if (!isLimitedId(trimmedSessionId, MAX_LIVE_ACTIVITY_ID_CHARS)) return;
1053
+ if (!LIVE_ACTIVITY_TERMINAL_STATUSES.has(status)) return;
1054
+
1055
+ const now = Date.now();
1056
+ let pendingEntries = [];
1057
+ let nextVersion = 0;
1058
+ await persistTokenUpdate((current) => {
1059
+ const liveActivityTokensBySession = pruneLiveActivityTokensBySession(
1060
+ current.liveActivityTokensBySession,
1061
+ now,
1062
+ );
1063
+ pendingEntries = collectLiveActivityEntriesForSession(
1064
+ { liveActivityTokensBySession },
1065
+ trimmedSessionId,
1066
+ now,
1067
+ );
1068
+ if (pendingEntries.length === 0) {
1069
+ return { ...current, liveActivityTokensBySession };
1070
+ }
1071
+ const previous = current.liveActivityEventVersions?.[trimmedSessionId];
1072
+ nextVersion = nextLiveActivityEventVersion(now, previous, eventVersion);
1073
+ return {
1074
+ ...current,
1075
+ liveActivityTokensBySession,
1076
+ liveActivityEventVersions: {
1077
+ ...(current.liveActivityEventVersions || {}),
1078
+ [trimmedSessionId]: nextVersion,
1079
+ },
1080
+ };
1081
+ });
1082
+ if (pendingEntries.length === 0) return;
1083
+
1084
+ const updatedAt = now / 1000;
1085
+ const endedAtSeconds = toLiveActivityUnixSeconds(endedAt, now);
1086
+ const contentState = {
1087
+ status,
1088
+ eventVersion: nextVersion,
1089
+ updatedAt,
1090
+ endedAt: endedAtSeconds,
1091
+ };
1092
+ const payload = {
1093
+ event: 'end',
1094
+ contentState,
1095
+ ...liveActivityPushDates('end', status, updatedAt, now),
1096
+ };
1097
+
1098
+ const tokens = pendingEntries.map((entry) => entry.token);
1099
+ let accepted = [];
1100
+ try {
1101
+ const relay = await resolveRelayConfig();
1102
+ if (relay) {
1103
+ rememberLiveActivityRelayRegisterUrl(relay.liveActivityRegisterUrl);
1104
+ const outcomes = await mapWithBoundedConcurrency(
1105
+ tokens,
1106
+ RELAY_REGISTER_CONCURRENCY,
1107
+ async (token) => {
1108
+ const registered = await registerLiveActivityTokenWithRelay(token, relay);
1109
+ return registered ? token : null;
1110
+ },
1111
+ );
1112
+ const readyTokens = outcomes.filter((token) => typeof token === 'string');
1113
+ if (readyTokens.length > 0) {
1114
+ accepted = await sendLiveActivityViaRelay(readyTokens, payload, relay);
1115
+ }
1116
+ } else {
1117
+ accepted = await sendLiveActivityViaDirectApns(tokens, payload);
1118
+ }
1119
+ } catch (error) {
1120
+ console.warn('[Live Activity] end failed:', error?.message ?? error);
1121
+ return;
1122
+ }
1123
+
1124
+ if (accepted.length > 0) {
1125
+ await removeLiveActivityTokens(accepted);
1126
+ }
1127
+ };
1128
+
581
1129
  // NOT gated on UI visibility (unlike web push). A backgrounded WKWebView can't reliably
582
1130
  // report "hidden" before iOS suspends it, so a visibility gate wrongly suppressed
583
1131
  // background push for short responses. Instead we always send, and rely on iOS to NOT
@@ -662,6 +1210,9 @@ export const createApnsRuntime = (deps) => {
662
1210
  addOrUpdateApnsToken,
663
1211
  removeApnsToken,
664
1212
  removeApnsTokenFromAllSessions,
1213
+ addOrUpdateLiveActivityToken,
1214
+ removeLiveActivityToken,
1215
+ sendLiveActivityEnd,
665
1216
  sendApnsToAllUiSessions,
666
1217
  reRegisterAllTokens,
667
1218
  resolveApnsConfig,