@openchambery/web 1.18.7-beta.2 → 1.18.7-beta.3

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 (133) hide show
  1. package/dist/assets/AgentAvatar-CVTgW_rx.js +1 -0
  2. package/dist/assets/AssistantView-CvQ91w2s.js +1 -0
  3. package/dist/assets/{ChatContainer-BHDNtLuy.js → ChatContainer-CERrtaB-.js} +41 -43
  4. package/dist/assets/ContextPanel-CLz4r1G-.js +285 -0
  5. package/dist/assets/DiagramView-DhV4Zthj.js +1 -0
  6. package/dist/assets/{FadeInOnReveal-BpIMrqet.js → FadeInOnReveal-Cy3sf2M5.js} +1 -1
  7. package/dist/assets/{Icon-C0G6afNV.js → Icon-BLWVqBee.js} +1 -1
  8. package/dist/assets/{MarkdownRendererImpl-B-SOb7Za.js → MarkdownRendererImpl-B4qwUu4q.js} +1 -1
  9. package/dist/assets/{MobileDetailNavigation-C7OjF65p.js → MobileDetailNavigation-CuWgEThi.js} +1 -1
  10. package/dist/assets/{MobileShareBridge-CRRrlX0l.js → MobileShareBridge-C3nay5Sq.js} +2 -2
  11. package/dist/assets/MobileSurface-D7v_ZkYO.js +2 -0
  12. package/dist/assets/{MultiRunWindow-Dx4tkVFN.js → MultiRunWindow-Cq8yyiqm.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-BpxD5W4Z.js → OnboardingScreen-CTNi2zuA.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-Bo7MF_jE.js → OverlayScrollbar-GQgJusKL.js} +1 -1
  15. package/dist/assets/SettingsView-CViKCsxJ.js +25 -0
  16. package/dist/assets/{SettingsWindow-BlKxv7c3.js → SettingsWindow-C6vwy4hH.js} +1 -1
  17. package/dist/assets/{TerminalView-xtUbWmdn.js → TerminalView-C1aX22ka.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-DUYcTYYW.js → ToolOutputDialog-BrOqj1sB.js} +4 -4
  19. package/dist/assets/appThemeRegistry-BbqImxSo.js +2 -0
  20. package/dist/assets/{button-DfF088a8.js → button-CeCkEpvn.js} +1 -1
  21. package/dist/assets/{clipboard-RoOQVnSl.js → clipboard-BXC1vZwE.js} +1 -1
  22. package/dist/assets/{collapsible-CVL-5iEB.js → collapsible-B-aagADh.js} +1 -1
  23. package/dist/assets/{desktopHosts-Dzx3McaD.js → desktopHosts-BrrYPqKq.js} +1 -1
  24. package/dist/assets/{desktopRelayRestore-B9S0Rp5s.js → desktopRelayRestore-DR60DATT.js} +1 -1
  25. package/dist/assets/diagram-Cxma_IKI.js +1 -0
  26. package/dist/assets/dialog-wraJHZQ2.js +1 -0
  27. package/dist/assets/es-DQqFAaWT.js +15 -0
  28. package/dist/assets/fileQueries-BJKLdYos.js +1 -0
  29. package/dist/assets/fr-CNfGRpc1.js +13 -0
  30. package/dist/assets/{gitApi-DsdwefuR.js → gitApi-B4z4eSZV.js} +1 -1
  31. package/dist/assets/{input-Ca2tUhgN.js → input-CyG05w-V.js} +1 -1
  32. package/dist/assets/insertionBoundaries-DCYAK5W1.js +4 -0
  33. package/dist/assets/ja-CQqehnVn.js +15 -0
  34. package/dist/assets/ko-kmwi8UJD.js +15 -0
  35. package/dist/assets/main-A8oe9r34.js +29 -0
  36. package/dist/assets/{main-CKb-YXE6.js → main-BJBKpmw3.js} +2 -2
  37. package/dist/assets/{markdown-worker-ZaHiIw5I.js → markdown-worker-DDCUZkz4.js} +1 -1
  38. package/dist/assets/{markdownTheme-DidS-jC5.js → markdownTheme-H7_7lbuh.js} +1 -1
  39. package/dist/assets/miniChat-BObOyFSV.js +2 -0
  40. package/dist/assets/mobile-Ba390Cmc.js +2 -0
  41. package/dist/assets/multirun-CMDYOrmD.js +4 -0
  42. package/dist/assets/{persistence-aCOlo6ew.js → persistence-DE9RnVcA.js} +2 -2
  43. package/dist/assets/pl-Blv5o2QA.js +15 -0
  44. package/dist/assets/platform-MjT8iqKB.js +2 -0
  45. package/dist/assets/projectMeta-uWBWFG5A.js +3 -0
  46. package/dist/assets/pt-BR-BK74t-Bd.js +15 -0
  47. package/dist/assets/radio-CEXUd4RC.js +1 -0
  48. package/dist/assets/renderElectronMiniChatApp-Bpgb2E78.js +2 -0
  49. package/dist/assets/renderMobileApp-BMwRozEC.js +23 -0
  50. package/dist/assets/runtimeConfig-hHuyauKZ.js +2 -0
  51. package/dist/assets/runtimeEndpointReset-CmMI7Xp1.js +4 -0
  52. package/dist/assets/{shikiHighlight-eMLXb99c.js → shikiHighlight-C-kTQvqd.js} +1 -1
  53. package/dist/assets/{sortable-tabs-strip-CDHNwlp9.js → sortable-tabs-strip-BdbKDDAP.js} +1 -1
  54. package/dist/assets/src-CgFGBE_A.css +2 -0
  55. package/dist/assets/store-UweBeN2y.js +15 -0
  56. package/dist/assets/{terminalApi-B36I-z14.js → terminalApi-Bnx0KvaV.js} +1 -1
  57. package/dist/assets/{tooltip-GC3dg3gO.js → tooltip-B__x-HDJ.js} +1 -1
  58. package/dist/assets/uk-6I-KXy3R.js +15 -0
  59. package/dist/assets/{url-BQHM4bC4.js → url-CTuw7Rjf.js} +1 -1
  60. package/dist/assets/{useAppFontEffects-B9Ozsfua.js → useAppFontEffects-8iAE0Yrj.js} +4 -4
  61. package/dist/assets/{useAssistantUIStore-DxZsjSPQ.js → useAssistantUIStore-D2K7ksGj.js} +1 -1
  62. package/dist/assets/{useDesktopWindowControlsLayout-QxNEOYzY.js → useDesktopWindowControlsLayout--oz1VAPt.js} +1 -1
  63. package/dist/assets/useEffectiveDirectory-DmoqXaa4.js +1 -0
  64. package/dist/assets/{useFontPreferences-D9h4M7x4.js → useFontPreferences-CWZnNRjz.js} +1 -1
  65. package/dist/assets/useMobileNavigationStore-BxbEX-es.js +412 -0
  66. package/dist/assets/{useSessionAutoCleanup-CXqhEUbp.js → useSessionAutoCleanup-BwlJlhY7.js} +1 -1
  67. package/dist/assets/{useUIStore-Wyu09VUF.js → useUIStore-DOxLVeGb.js} +1 -1
  68. package/dist/assets/{useWorkerHighlightedLines-2KOHSbaa.js → useWorkerHighlightedLines-B21D9MOD.js} +1 -1
  69. package/dist/assets/{utils-CJdWZupK.js → utils-C2R0YoC5.js} +1 -1
  70. package/dist/assets/vendor-legendapp-list-WE32lwNk.js +1 -0
  71. package/dist/assets/zh-CN-DwlzS4yK.js +15 -0
  72. package/dist/assets/zh-TW-p0VriyfT.js +15 -0
  73. package/dist/index.html +9 -9
  74. package/dist/mini-chat.html +9 -9
  75. package/dist/mobile.html +9 -9
  76. package/package.json +3 -2
  77. package/server/index.js +19 -6
  78. package/server/lib/notifications/APNS.md +88 -38
  79. package/server/lib/notifications/DOCUMENTATION.md +11 -4
  80. package/server/lib/notifications/apns-runtime.js +793 -78
  81. package/server/lib/notifications/apns-runtime.test.js +922 -0
  82. package/server/lib/notifications/apns-titles.js +212 -0
  83. package/server/lib/notifications/apns-titles.test.js +55 -0
  84. package/server/lib/notifications/routes.js +57 -1
  85. package/server/lib/notifications/runtime.js +20 -17
  86. package/server/lib/notifications/runtime.test.js +108 -0
  87. package/server/lib/opencode/DOCUMENTATION.md +1 -1
  88. package/server/lib/opencode/bootstrap-runtime.js +6 -0
  89. package/server/lib/opencode/bootstrap-runtime.test.js +21 -0
  90. package/server/lib/opencode/routes.js +24 -5
  91. package/server/lib/opencode/routes.test.js +78 -1
  92. package/server/lib/relay/DOCUMENTATION.md +2 -2
  93. package/server/lib/relay/service.js +45 -3
  94. package/server/lib/relay/service.test.js +117 -0
  95. package/server/lib/relay/signing-key.js +1 -1
  96. package/server/lib/scheduled-tasks/DOCUMENTATION.md +27 -2
  97. package/server/lib/scheduled-tasks/runtime.js +271 -3
  98. package/server/lib/scheduled-tasks/runtime.test.js +308 -0
  99. package/server/lib/session-goal/runtime.js +2 -2
  100. package/dist/assets/AgentAvatar-CWn_73qY.js +0 -1
  101. package/dist/assets/AssistantView-BiOO5An0.js +0 -1
  102. package/dist/assets/ContextPanel-BbNBZipF.js +0 -288
  103. package/dist/assets/DiagramView-AMPWVMV0.js +0 -1
  104. package/dist/assets/MobileSurface-DmMgH78x.js +0 -2
  105. package/dist/assets/SettingsView-BDaMrSki.js +0 -25
  106. package/dist/assets/appThemeRegistry-D6qZmouf.js +0 -2
  107. package/dist/assets/dialog-BbHDXPt6.js +0 -1
  108. package/dist/assets/es-BkIvCvwj.js +0 -15
  109. package/dist/assets/fileQueries-_VUJM8AE.js +0 -1
  110. package/dist/assets/fr-tCwQ3odX.js +0 -13
  111. package/dist/assets/insertionBoundaries-M7AXIWfg.js +0 -1
  112. package/dist/assets/ja-C0rD6dwp.js +0 -15
  113. package/dist/assets/ko-CAbtZ462.js +0 -15
  114. package/dist/assets/main-QjOL36Zh.js +0 -29
  115. package/dist/assets/miniChat-C45kGqqH.js +0 -2
  116. package/dist/assets/mobile-DH8nxp1K.js +0 -2
  117. package/dist/assets/multirun-BkKb0iZv.js +0 -4
  118. package/dist/assets/pl-Bb190ba6.js +0 -15
  119. package/dist/assets/platform-DxNH_0Fj.js +0 -2
  120. package/dist/assets/projectMeta-BHtBPaxp.js +0 -3
  121. package/dist/assets/pt-BR-B6p_t3IB.js +0 -15
  122. package/dist/assets/radio-BacnmuU0.js +0 -1
  123. package/dist/assets/renderElectronMiniChatApp-Cn7umSw9.js +0 -2
  124. package/dist/assets/renderMobileApp-DZBR2t3l.js +0 -23
  125. package/dist/assets/runtimeConfig-CSAVS8gd.js +0 -2
  126. package/dist/assets/runtimeEndpointReset-BZUX3z0A.js +0 -4
  127. package/dist/assets/src-DziW4HUU.css +0 -2
  128. package/dist/assets/store-1FOY4G3z.js +0 -15
  129. package/dist/assets/uk-Dy8-6ZEj.js +0 -15
  130. package/dist/assets/useEffectiveDirectory-D3u_oih8.js +0 -1
  131. package/dist/assets/useMobileNavigationStore-CCPTHVO9.js +0 -475
  132. package/dist/assets/zh-CN-gRTE0npc.js +0 -15
  133. package/dist/assets/zh-TW-ClD4BCfW.js +0 -15
@@ -2,8 +2,9 @@
2
2
  //
3
3
  // Device tokens are persisted per UI session (mirrors push-runtime.js). Delivery has two
4
4
  // modes, chosen at send time:
5
- // - Relay (default): POST tokens + generic text to the central Cloudflare relay, which
6
- // holds the single project APNs key and signs+sends so users configure nothing.
5
+ // - Relay (default): POST tokens + generic text to the same-origin Push service
6
+ // (`/v1/push/*` on the effective Relay host). The isolated Push process holds the
7
+ // project APNs key and signs+sends — so users configure nothing.
7
8
  // - Direct (fallback): sign an ES256 JWT with Node crypto and send over HTTP/2 ourselves,
8
9
  // for self-hosters who set OPENCHAMBER_APNS_* and OPENCHAMBER_PUSH_RELAY_DISABLED=true.
9
10
  // Wired into the same trigger fanout as web push (see runtime.js); the relay carries only
@@ -13,18 +14,57 @@ import {
13
14
  getOrCreateRelaySigningKeypair,
14
15
  signRelayMessage as signRelayMessageShared,
15
16
  } from '../relay/signing-key.js';
17
+ import { DEFAULT_RELAY_URL, derivePushSendUrlFromRelayUrl } from '../relay/service.js';
18
+ import {
19
+ APNS_DEFAULT_LOCALE,
20
+ localizeApnsPayload,
21
+ normalizeApnsLocale,
22
+ } from './apns-titles.js';
16
23
 
17
- const APNS_TOKENS_VERSION = 1;
24
+ const APNS_TOKENS_VERSION = 2;
25
+ const APNS_TOKENS_SUPPORTED_VERSIONS = new Set([1, APNS_TOKENS_VERSION]);
18
26
  const APNS_HOST_PRODUCTION = 'https://api.push.apple.com';
19
27
  const APNS_HOST_SANDBOX = 'https://api.sandbox.push.apple.com';
20
28
  // APNs rejects auth tokens older than 1h; refresh well inside that window.
21
29
  const JWT_TTL_MS = 50 * 60 * 1000;
22
- const DEFAULT_BUNDLE_ID = 'com.openchamber.app';
23
- const DEFAULT_RELAY_URL = 'https://api.openchamber.dev/v1/push/send';
30
+ const DEFAULT_BUNDLE_ID = 'com.yee94.openchamber';
24
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;
39
+ const MAX_RELAY_SEND_TOKENS = 100;
40
+ const RELAY_REGISTER_CONCURRENCY = 16;
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']);
25
45
  // APNs reasons that mean the token is permanently invalid → drop it.
26
46
  const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic']);
27
47
 
48
+ const mapWithBoundedConcurrency = async (items, concurrency, mapper) => {
49
+ const results = new Array(items.length);
50
+ const limit = Math.max(1, concurrency);
51
+ for (let start = 0; start < items.length; start += limit) {
52
+ const end = Math.min(start + limit, items.length);
53
+ await Promise.all(
54
+ items.slice(start, end).map(async (item, offset) => {
55
+ results[start + offset] = await mapper(item, start + offset);
56
+ }),
57
+ );
58
+ }
59
+ return results;
60
+ };
61
+
62
+ const apnsCollapseId = (tag) => {
63
+ if (typeof tag !== 'string' || tag.length === 0) return undefined;
64
+ if (Buffer.byteLength(tag, 'utf8') > APNS_COLLAPSE_ID_MAX_BYTES) return undefined;
65
+ return tag;
66
+ };
67
+
28
68
  const trimmedEnv = (name) => {
29
69
  const value = process.env[name];
30
70
  return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
@@ -44,12 +84,17 @@ export const createApnsRuntime = (deps) => {
44
84
  writeSettingsToDisk,
45
85
  // Strict settings reader gating identity regeneration (see signing-key.js).
46
86
  readSettingsStrict,
87
+ resolveEffectiveRelayUrl,
47
88
  } = deps;
48
89
 
49
90
  let persistLock = Promise.resolve();
50
91
  let cachedJwt = null; // { token, issuedAtMs, keyId }
51
92
  let cachedRelayKey = null; // { privateKey, publicJwk }
52
93
  let warnedUnconfigured = false;
94
+ let registrationSuccessCache = new Map();
95
+ let registrationCacheRegisterUrl = null;
96
+ let liveActivityRegistrationSuccessCache = new Map();
97
+ let liveActivityRegistrationCacheRegisterUrl = null;
53
98
 
54
99
  // ---------------------------------------------------------------------------
55
100
  // Per-server relay signing identity (ECDSA P-256). Auto-generated + persisted in settings
@@ -76,9 +121,43 @@ export const createApnsRuntime = (deps) => {
76
121
  y: publicJwk.y,
77
122
  });
78
123
 
79
- const registerTokenWithRelay = async (token, platform = 'ios') => {
80
- const relay = resolveRelayConfig();
81
- if (!relay) return; // direct mode — no relay binding needed
124
+ const registrationCacheKey = (registerUrl, token, platform) =>
125
+ `${registerUrl}\0${token}\0${platform}`;
126
+
127
+ const rememberRelayRegisterUrl = (registerUrl) => {
128
+ if (registrationCacheRegisterUrl === registerUrl) return;
129
+ registrationSuccessCache = new Map();
130
+ registrationCacheRegisterUrl = registerUrl;
131
+ };
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
+
145
+ const boundRegistrationCache = (registerUrl, entries) => {
146
+ rememberRelayRegisterUrl(registerUrl);
147
+ const allowed = new Set(
148
+ entries.map((entry) => registrationCacheKey(registerUrl, entry.deviceToken, entry.platform)),
149
+ );
150
+ for (const key of registrationSuccessCache.keys()) {
151
+ if (!allowed.has(key)) registrationSuccessCache.delete(key);
152
+ }
153
+ };
154
+
155
+ const registerTokenWithRelay = async (token, platform = 'ios', relayOverride) => {
156
+ const relay = relayOverride ?? (await resolveRelayConfig());
157
+ if (!relay) return true; // direct mode — no relay binding needed
158
+ rememberRelayRegisterUrl(relay.registerUrl);
159
+ const cacheKey = registrationCacheKey(relay.registerUrl, token, platform);
160
+ if (registrationSuccessCache.has(cacheKey)) return true;
82
161
  try {
83
162
  const { privateKey, publicJwk } = await getOrCreateRelayKeypair();
84
163
  const ts = Date.now();
@@ -89,9 +168,86 @@ export const createApnsRuntime = (deps) => {
89
168
  headers: { 'content-type': 'application/json' },
90
169
  body: JSON.stringify({ token, platform, publicKeyJwk: relayPublicJwk(publicJwk), ts, sig }),
91
170
  });
92
- if (!res.ok) console.warn(`[Push relay] register-token failed status=${res.status}`);
171
+ if (!res.ok) {
172
+ console.warn(`[Push relay] register-token failed status=${res.status}`);
173
+ return false;
174
+ }
175
+ registrationSuccessCache.set(cacheKey, true);
176
+ return true;
93
177
  } catch (error) {
94
178
  console.warn('[Push relay] register-token request failed:', error?.message ?? error);
179
+ return false;
180
+ }
181
+ };
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);
95
251
  }
96
252
  };
97
253
 
@@ -99,18 +255,36 @@ export const createApnsRuntime = (deps) => {
99
255
  // Token persistence (same shape + write-lock pattern as push-runtime.js)
100
256
  // ---------------------------------------------------------------------------
101
257
 
102
- const emptyStore = () => ({ version: APNS_TOKENS_VERSION, tokensBySession: {} });
258
+ const emptyStore = () => ({
259
+ version: APNS_TOKENS_VERSION,
260
+ tokensBySession: {},
261
+ liveActivityTokensBySession: {},
262
+ liveActivityEventVersions: {},
263
+ });
103
264
 
104
265
  const readTokensFromDisk = async () => {
105
266
  try {
106
267
  const raw = await fsPromises.readFile(APNS_TOKENS_FILE_PATH, 'utf8');
107
268
  const parsed = JSON.parse(raw);
108
- if (!parsed || typeof parsed !== 'object' || parsed.version !== APNS_TOKENS_VERSION) {
269
+ if (!parsed || typeof parsed !== 'object' || !APNS_TOKENS_SUPPORTED_VERSIONS.has(parsed.version)) {
109
270
  return emptyStore();
110
271
  }
111
272
  const tokensBySession =
112
273
  parsed.tokensBySession && typeof parsed.tokensBySession === 'object' ? parsed.tokensBySession : {};
113
- 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
+ };
114
288
  } catch (error) {
115
289
  if (error && typeof error === 'object' && error.code === 'ENOENT') {
116
290
  return emptyStore();
@@ -128,9 +302,21 @@ export const createApnsRuntime = (deps) => {
128
302
  const persistTokenUpdate = async (mutate) => {
129
303
  persistLock = persistLock.then(async () => {
130
304
  const current = await readTokensFromDisk();
131
- const next = mutate({ version: APNS_TOKENS_VERSION, tokensBySession: current.tokensBySession || {} });
132
- await writeTokensToDisk(next);
133
- 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;
134
320
  });
135
321
  return persistLock;
136
322
  };
@@ -149,31 +335,96 @@ export const createApnsRuntime = (deps) => {
149
335
  userAgent: typeof entry.userAgent === 'string' ? entry.userAgent : undefined,
150
336
  // 'ios' (APNs) or 'android' (FCM). Older entries without one are APNs by default.
151
337
  platform: entry.platform === 'android' ? 'android' : 'ios',
338
+ // App UI locale at last registration; missing → English titles (legacy tokens).
339
+ locale: normalizeApnsLocale(entry.locale),
152
340
  };
153
341
  })
154
342
  .filter(Boolean);
155
343
  };
156
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
+
157
383
  // Normalize an incoming platform hint to the two we support; default to APNs/iOS since that
158
384
  // was the only registrant before Android/FCM existed.
159
385
  const normalizePlatform = (platform) => (platform === 'android' ? 'android' : 'ios');
160
386
 
161
- const addOrUpdateApnsToken = async (uiSessionToken, deviceToken, userAgent, platform) => {
387
+ const listUniqueTokenEntries = (store) => {
388
+ const unique = [];
389
+ const seen = new Map();
390
+ for (const record of Object.values(store.tokensBySession || {})) {
391
+ for (const entry of normalizeTokens(record)) {
392
+ const key = `${entry.deviceToken}\0${entry.platform}`;
393
+ const priorIndex = seen.get(key);
394
+ if (priorIndex == null) {
395
+ seen.set(key, unique.length);
396
+ unique.push(entry);
397
+ continue;
398
+ }
399
+ // Same device may register under multiple UI sessions; keep the freshest locale.
400
+ const prior = unique[priorIndex];
401
+ const priorSeen = typeof prior.lastSeenAt === 'number' ? prior.lastSeenAt : 0;
402
+ const nextSeen = typeof entry.lastSeenAt === 'number' ? entry.lastSeenAt : 0;
403
+ if (nextSeen >= priorSeen) unique[priorIndex] = entry;
404
+ }
405
+ }
406
+ return unique;
407
+ };
408
+
409
+ const addOrUpdateApnsToken = async (uiSessionToken, deviceToken, userAgent, platform, locale) => {
162
410
  if (!uiSessionToken || typeof deviceToken !== 'string' || deviceToken.trim().length === 0) return;
163
411
  const token = deviceToken.trim();
164
412
  const tokenPlatform = normalizePlatform(platform);
413
+ const tokenLocale = normalizeApnsLocale(locale);
165
414
  const now = Date.now();
166
415
 
167
416
  await persistTokenUpdate((current) => {
168
417
  const tokensBySession = { ...(current.tokensBySession || {}) };
169
418
  const existing = normalizeTokens(tokensBySession[uiSessionToken]);
419
+ const prior = existing.find((entry) => entry.deviceToken === token);
170
420
  const filtered = existing.filter((entry) => entry.deviceToken !== token);
171
421
  filtered.unshift({
172
422
  deviceToken: token,
173
- createdAt: now,
423
+ createdAt: typeof prior?.createdAt === 'number' ? prior.createdAt : now,
174
424
  lastSeenAt: now,
175
425
  userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined,
176
426
  platform: tokenPlatform,
427
+ locale: tokenLocale,
177
428
  });
178
429
  tokensBySession[uiSessionToken] = filtered.slice(0, MAX_TOKENS_PER_SESSION);
179
430
  return { version: APNS_TOKENS_VERSION, tokensBySession };
@@ -213,6 +464,89 @@ export const createApnsRuntime = (deps) => {
213
464
  });
214
465
  };
215
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
+
216
550
  // ---------------------------------------------------------------------------
217
551
  // Config (env first, then settings.apnsConfig) — mirrors resolveVapidSubject
218
552
  // ---------------------------------------------------------------------------
@@ -316,8 +650,9 @@ export const createApnsRuntime = (deps) => {
316
650
  'apns-push-type': 'alert',
317
651
  'apns-priority': '10',
318
652
  };
319
- // collapse-id dedups like web-push tags; APNs caps it at 64 bytes.
320
- const collapseId = typeof config.tag === 'string' ? config.tag.slice(0, 64) : undefined;
653
+ // collapse-id dedups like web-push tags; APNs caps it at 64 UTF-8 bytes.
654
+ // Keep legal values; omit anything over the limit (do not truncate mid-codepoint).
655
+ const collapseId = apnsCollapseId(config.tag);
321
656
  if (collapseId) headers['apns-collapse-id'] = collapseId;
322
657
 
323
658
  let req;
@@ -363,61 +698,86 @@ export const createApnsRuntime = (deps) => {
363
698
  req.end(body);
364
699
  });
365
700
 
366
- // Relay mode (default): the single APNs key lives in the central Cloudflare relay, not on
367
- // each user's server — so users configure nothing. The server just POSTs device tokens +
368
- // generic text; the relay signs + sends and reports which tokens to drop. Direct mode (below)
369
- // is the fallback for self-hosters who set OPENCHAMBER_APNS_* and disable the relay.
370
- const resolveRelayConfig = () => {
701
+ // Relay mode (default): the single APNs key lives in the isolated Push process on the same
702
+ // Relay origin (`/v1/push/*`), not on each user's server — so users configure nothing. The
703
+ // server just POSTs device tokens + generic text; Push signs + sends and reports which tokens
704
+ // to drop. Direct mode (below) is the fallback for self-hosters who set OPENCHAMBER_APNS_*
705
+ // and disable the relay.
706
+ const buildRelayConfig = (url) => ({
707
+ url,
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'),
712
+ environment:
713
+ (trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || 'sandbox').toLowerCase() === 'production'
714
+ ? 'production'
715
+ : 'sandbox',
716
+ });
717
+
718
+ const resolveRelayConfig = async () => {
371
719
  if (trimmedEnv('OPENCHAMBER_PUSH_RELAY_DISABLED') === 'true') return null;
372
- const url = trimmedEnv('OPENCHAMBER_PUSH_RELAY_URL') || DEFAULT_RELAY_URL;
373
- return {
374
- url,
375
- registerUrl: url.replace(/\/send$/, '/register-token'),
376
- environment:
377
- (trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || 'sandbox').toLowerCase() === 'production'
378
- ? 'production'
379
- : 'sandbox',
380
- };
720
+ const explicit = trimmedEnv('OPENCHAMBER_PUSH_RELAY_URL');
721
+ if (explicit) return buildRelayConfig(explicit);
722
+
723
+ let relayWsUrl = DEFAULT_RELAY_URL;
724
+ if (typeof resolveEffectiveRelayUrl === 'function') {
725
+ try {
726
+ const resolved = await resolveEffectiveRelayUrl();
727
+ if (typeof resolved === 'string' && resolved.trim()) {
728
+ if (derivePushSendUrlFromRelayUrl(resolved)) relayWsUrl = resolved.trim();
729
+ else console.warn('[Push relay] resolver returned a non-WebSocket URL; falling back to default');
730
+ }
731
+ } catch {
732
+ console.warn('[Push relay] resolver failed; falling back to default');
733
+ }
734
+ }
735
+ const url = derivePushSendUrlFromRelayUrl(relayWsUrl) || derivePushSendUrlFromRelayUrl(DEFAULT_RELAY_URL);
736
+ if (!url) return null;
737
+ return buildRelayConfig(url);
381
738
  };
382
739
 
383
740
  const sendViaRelay = async (deviceTokens, payload, relay) => {
384
- const tokens = deviceTokens.slice(0, 100);
741
+ if (!Array.isArray(deviceTokens) || deviceTokens.length === 0) return;
385
742
  const title = typeof payload?.title === 'string' && payload.title.length > 0 ? payload.title : 'OpenChamber';
386
743
  const { privateKey, publicJwk } = await getOrCreateRelayKeypair();
387
- const ts = Date.now();
388
- // Sign over the same canonical form the relay verifies: ts.sortedTokens.title.
389
- const sig = signRelayMessage(privateKey, `${ts}.${[...tokens].sort().join(',')}.${title}`);
390
- const requestBody = JSON.stringify({
391
- tokens,
392
- title,
393
- body: typeof payload?.body === 'string' ? payload.body : '',
394
- badge: Number.isFinite(payload?.badge) && payload.badge >= 0 ? Math.trunc(payload.badge) : undefined,
395
- collapseId: typeof payload?.tag === 'string' ? payload.tag.slice(0, 64) : undefined,
396
- env: relay.environment,
397
- data: payload?.data && typeof payload.data === 'object' ? payload.data : undefined,
398
- publicKeyJwk: relayPublicJwk(publicJwk),
399
- ts,
400
- sig,
401
- });
402
- try {
403
- const res = await fetch(relay.url, {
404
- method: 'POST',
405
- headers: { 'content-type': 'application/json' },
406
- body: requestBody,
744
+ for (let start = 0; start < deviceTokens.length; start += MAX_RELAY_SEND_TOKENS) {
745
+ const tokens = deviceTokens.slice(start, start + MAX_RELAY_SEND_TOKENS);
746
+ const ts = Date.now();
747
+ // Sign over the same canonical form the relay verifies: ts.sortedTokens.title.
748
+ const sig = signRelayMessage(privateKey, `${ts}.${[...tokens].sort().join(',')}.${title}`);
749
+ const requestBody = JSON.stringify({
750
+ tokens,
751
+ title,
752
+ body: typeof payload?.body === 'string' ? payload.body : '',
753
+ badge: Number.isFinite(payload?.badge) && payload.badge >= 0 ? Math.trunc(payload.badge) : undefined,
754
+ collapseId: apnsCollapseId(payload?.tag),
755
+ env: relay.environment,
756
+ data: payload?.data && typeof payload.data === 'object' ? payload.data : undefined,
757
+ publicKeyJwk: relayPublicJwk(publicJwk),
758
+ ts,
759
+ sig,
407
760
  });
408
- if (!res.ok) {
409
- console.warn(`[APNs relay] send failed status=${res.status}`);
410
- return;
411
- }
412
- const data = await res.json().catch(() => null);
413
- const results = Array.isArray(data?.results) ? data.results : [];
414
- for (const result of results) {
415
- if (result && result.drop === true && typeof result.token === 'string') {
416
- await removeApnsTokenFromAllSessions(result.token);
761
+ try {
762
+ const res = await fetch(relay.url, {
763
+ method: 'POST',
764
+ headers: { 'content-type': 'application/json' },
765
+ body: requestBody,
766
+ });
767
+ if (!res.ok) {
768
+ console.warn(`[APNs relay] send failed status=${res.status}`);
769
+ continue;
417
770
  }
771
+ const data = await res.json().catch(() => null);
772
+ const results = Array.isArray(data?.results) ? data.results : [];
773
+ for (const result of results) {
774
+ if (result && result.drop === true && typeof result.token === 'string') {
775
+ await removeApnsTokenFromAllSessions(result.token);
776
+ }
777
+ }
778
+ } catch (error) {
779
+ console.warn('[APNs relay] request failed:', error?.message ?? error);
418
780
  }
419
- } catch (error) {
420
- console.warn('[APNs relay] request failed:', error?.message ?? error);
421
781
  }
422
782
  };
423
783
 
@@ -468,38 +828,393 @@ export const createApnsRuntime = (deps) => {
468
828
  });
469
829
  };
470
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
+
471
1129
  // NOT gated on UI visibility (unlike web push). A backgrounded WKWebView can't reliably
472
1130
  // report "hidden" before iOS suspends it, so a visibility gate wrongly suppressed
473
1131
  // background push for short responses. Instead we always send, and rely on iOS to NOT
474
1132
  // display the alert while the app is foreground (presentationOptions: [] in
475
1133
  // capacitor.config) — so there is no notification when the app is active, with no race.
476
- const sendApnsToAllUiSessions = async (payload, _options = {}) => {
1134
+ const reRegisterAllTokens = async () => {
1135
+ const relay = await resolveRelayConfig();
1136
+ if (!relay) {
1137
+ return { attempted: 0, succeeded: 0, failed: 0 };
1138
+ }
477
1139
  const store = await readTokensFromDisk();
478
- const deviceTokens = [];
479
- const seen = new Set();
480
- for (const record of Object.values(store.tokensBySession || {})) {
481
- for (const entry of normalizeTokens(record)) {
482
- if (!seen.has(entry.deviceToken)) {
483
- seen.add(entry.deviceToken);
484
- deviceTokens.push(entry.deviceToken);
485
- }
1140
+ const entries = listUniqueTokenEntries(store);
1141
+ boundRegistrationCache(relay.registerUrl, entries);
1142
+ const outcomes = await mapWithBoundedConcurrency(
1143
+ entries,
1144
+ RELAY_REGISTER_CONCURRENCY,
1145
+ (entry) => registerTokenWithRelay(entry.deviceToken, entry.platform, relay),
1146
+ );
1147
+ let succeeded = 0;
1148
+ let failed = 0;
1149
+ for (const ok of outcomes) {
1150
+ if (ok) succeeded += 1;
1151
+ else failed += 1;
1152
+ }
1153
+ return { attempted: entries.length, succeeded, failed };
1154
+ };
1155
+
1156
+ // Group ready device tokens by app locale so each batch can carry a localized title.
1157
+ // Relay signatures cover title, so mixed-locale tokens must not share one send body.
1158
+ const groupTokensByLocale = (entries) => {
1159
+ const groups = new Map();
1160
+ for (const entry of entries) {
1161
+ const locale = entry?.locale ? normalizeApnsLocale(entry.locale) : APNS_DEFAULT_LOCALE;
1162
+ const token = entry?.deviceToken;
1163
+ if (typeof token !== 'string' || token.length === 0) continue;
1164
+ let list = groups.get(locale);
1165
+ if (!list) {
1166
+ list = [];
1167
+ groups.set(locale, list);
486
1168
  }
1169
+ list.push(token);
487
1170
  }
488
- if (deviceTokens.length === 0) return;
1171
+ return groups;
1172
+ };
489
1173
 
490
- const relay = resolveRelayConfig();
1174
+ const sendApnsToAllUiSessions = async (payload, _options = {}) => {
1175
+ const store = await readTokensFromDisk();
1176
+ const uniqueEntries = listUniqueTokenEntries(store);
1177
+ if (uniqueEntries.length === 0) return;
1178
+
1179
+ const relay = await resolveRelayConfig();
491
1180
  if (relay) {
492
- await sendViaRelay(deviceTokens, payload, relay);
1181
+ boundRegistrationCache(relay.registerUrl, uniqueEntries);
1182
+ const outcomes = await mapWithBoundedConcurrency(
1183
+ uniqueEntries,
1184
+ RELAY_REGISTER_CONCURRENCY,
1185
+ async (entry) => {
1186
+ const registered = await registerTokenWithRelay(entry.deviceToken, entry.platform, relay);
1187
+ return registered ? entry : null;
1188
+ },
1189
+ );
1190
+ const readyEntries = [];
1191
+ const readySeen = new Set();
1192
+ for (const entry of outcomes) {
1193
+ if (!entry?.deviceToken || readySeen.has(entry.deviceToken)) continue;
1194
+ readySeen.add(entry.deviceToken);
1195
+ readyEntries.push(entry);
1196
+ }
1197
+ if (readyEntries.length === 0) return;
1198
+ for (const [locale, tokens] of groupTokensByLocale(readyEntries)) {
1199
+ await sendViaRelay(tokens, localizeApnsPayload(payload, locale), relay);
1200
+ }
493
1201
  return;
494
1202
  }
495
- await sendViaDirectApns(deviceTokens, payload);
1203
+ const byLocale = groupTokensByLocale(uniqueEntries);
1204
+ for (const [locale, deviceTokens] of byLocale) {
1205
+ await sendViaDirectApns(deviceTokens, localizeApnsPayload(payload, locale));
1206
+ }
496
1207
  };
497
1208
 
498
1209
  return {
499
1210
  addOrUpdateApnsToken,
500
1211
  removeApnsToken,
501
1212
  removeApnsTokenFromAllSessions,
1213
+ addOrUpdateLiveActivityToken,
1214
+ removeLiveActivityToken,
1215
+ sendLiveActivityEnd,
502
1216
  sendApnsToAllUiSessions,
1217
+ reRegisterAllTokens,
503
1218
  resolveApnsConfig,
504
1219
  // exposed for tests
505
1220
  signApnsJwt,