@openchambery/web 1.19.0-beta.36 → 1.19.0-beta.39

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 (53) hide show
  1. package/dist/assets/{AssistantView-DKkGbIaN.js → AssistantView-BGHsUIcD.js} +1 -1
  2. package/dist/assets/{ChatContainer-BV1_DKRK.js → ChatContainer-lPNgvmdy.js} +31 -31
  3. package/dist/assets/{ContextPanel-y2-b_Z2K.js → ContextPanel-DuDbecdJ.js} +3 -3
  4. package/dist/assets/{DiagramView-BQynaQK8.js → DiagramView-41vCtWQO.js} +1 -1
  5. package/dist/assets/{MarkdownRendererImpl-BLuEiJfe.js → MarkdownRendererImpl-4wf_NyPi.js} +1 -1
  6. package/dist/assets/{MobileDetailNavigation-DvQ7TyBM.js → MobileDetailNavigation-BZ5-chaJ.js} +1 -1
  7. package/dist/assets/{MobileShareBridge-CuVKbp4w.js → MobileShareBridge-DZ4_UDPx.js} +1 -1
  8. package/dist/assets/{MobileSurface-Ckqf46Ht.js → MobileSurface-DO07H7VN.js} +1 -1
  9. package/dist/assets/{MultiRunWindow-CryWGQm3.js → MultiRunWindow-CwyGNg6E.js} +1 -1
  10. package/dist/assets/{SettingsView-4Qx32CM5.js → SettingsView-eTWUs3mr.js} +1 -1
  11. package/dist/assets/{SettingsWindow-5o4fZ7Mm.js → SettingsWindow-CWTIj5qT.js} +1 -1
  12. package/dist/assets/{TerminalView-BcACDBI2.js → TerminalView-CRihEF7B.js} +1 -1
  13. package/dist/assets/{ToolOutputDialog-DPz8gn8_.js → ToolOutputDialog-BPXKhRBa.js} +1 -1
  14. package/dist/assets/{appThemeRegistry-5J_p1u_I.js → appThemeRegistry-BiAR1i_S.js} +2 -2
  15. package/dist/assets/{gitApi-iEiu2XFG.js → gitApi-BWk11cqj.js} +1 -1
  16. package/dist/assets/{insertionBoundaries-0eRxJVW6.js → insertionBoundaries-CNPI5E9-.js} +1 -1
  17. package/dist/assets/{main-BLhg2XWt.js → main-8PESnWMg.js} +2 -2
  18. package/dist/assets/{main-eSewAXup.js → main-CxMNCA6O.js} +4 -4
  19. package/dist/assets/{miniChat-D1-a2j3X.js → miniChat-Buff55v8.js} +2 -2
  20. package/dist/assets/{mobile-Ci7TpzO5.js → mobile-DdwSIsqR.js} +2 -2
  21. package/dist/assets/{multirun-DSpk4Zg7.js → multirun-DicMqzmS.js} +1 -1
  22. package/dist/assets/{projectMeta-BiPtYEsc.js → projectMeta-YlBX0ZsF.js} +1 -1
  23. package/dist/assets/{radio-DHDmOqeF.js → radio-BGL1w8gp.js} +1 -1
  24. package/dist/assets/{renderElectronMiniChatApp-C_TDVAS5.js → renderElectronMiniChatApp-CKpo0WpH.js} +2 -2
  25. package/dist/assets/renderMobileApp-BhjxKg9G.js +23 -0
  26. package/dist/assets/{runtimeConfig-D40WiKD4.js → runtimeConfig-tjqLOQnJ.js} +1 -1
  27. package/dist/assets/{runtimeEndpointReset-N20BnvKg.js → runtimeEndpointReset-DCb37IOY.js} +1 -1
  28. package/dist/assets/{shikiHighlight-DSolaZz5.js → shikiHighlight-CWn7NoHc.js} +1 -1
  29. package/dist/assets/{src-ILRsQd_W.css → src-DeaNVOre.css} +1 -1
  30. package/dist/assets/{useAppFontEffects-DovmEWUn.js → useAppFontEffects-C1okpzsV.js} +2 -2
  31. package/dist/assets/{useDesktopWindowControlsLayout-uYmYg3ee.js → useDesktopWindowControlsLayout-BNh1BzFG.js} +1 -1
  32. package/dist/assets/{useEffectiveDirectory-lReAbPFb.js → useEffectiveDirectory-BwY1B5mw.js} +1 -1
  33. package/dist/assets/{useMobileNavigationStore-BKVdzZVI.js → useMobileNavigationStore-DjEE6WNI.js} +1 -1
  34. package/dist/assets/{useSessionAutoCleanup-BkX_0XVu.js → useSessionAutoCleanup-3tZ8BVvw.js} +1 -1
  35. package/dist/assets/{useWorkerHighlightedLines-DFOYREuh.js → useWorkerHighlightedLines-CJqDVMqE.js} +1 -1
  36. package/dist/index.html +3 -3
  37. package/dist/mini-chat.html +3 -3
  38. package/dist/mobile.html +3 -3
  39. package/package.json +1 -1
  40. package/server/index.js +8 -1
  41. package/server/lib/notifications/APNS.md +53 -38
  42. package/server/lib/notifications/DOCUMENTATION.md +5 -4
  43. package/server/lib/notifications/apns-runtime.js +235 -71
  44. package/server/lib/notifications/apns-runtime.test.js +447 -0
  45. package/server/lib/notifications/apns-titles.js +212 -0
  46. package/server/lib/notifications/apns-titles.test.js +55 -0
  47. package/server/lib/notifications/routes.js +2 -1
  48. package/server/lib/notifications/runtime.js +5 -13
  49. package/server/lib/relay/DOCUMENTATION.md +2 -2
  50. package/server/lib/relay/service.js +45 -3
  51. package/server/lib/relay/service.test.js +117 -0
  52. package/server/lib/relay/signing-key.js +1 -1
  53. package/dist/assets/renderMobileApp-CZkBh4ph.js +0 -23
@@ -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,46 @@ 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
24
  const APNS_TOKENS_VERSION = 1;
18
25
  const APNS_HOST_PRODUCTION = 'https://api.push.apple.com';
19
26
  const APNS_HOST_SANDBOX = 'https://api.sandbox.push.apple.com';
20
27
  // APNs rejects auth tokens older than 1h; refresh well inside that window.
21
28
  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';
29
+ const DEFAULT_BUNDLE_ID = 'com.yee94.openchamber';
24
30
  const MAX_TOKENS_PER_SESSION = 10;
31
+ const MAX_RELAY_SEND_TOKENS = 100;
32
+ const RELAY_REGISTER_CONCURRENCY = 16;
33
+ const APNS_COLLAPSE_ID_MAX_BYTES = 64;
25
34
  // APNs reasons that mean the token is permanently invalid → drop it.
26
35
  const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic']);
27
36
 
37
+ const mapWithBoundedConcurrency = async (items, concurrency, mapper) => {
38
+ const results = new Array(items.length);
39
+ const limit = Math.max(1, concurrency);
40
+ for (let start = 0; start < items.length; start += limit) {
41
+ const end = Math.min(start + limit, items.length);
42
+ await Promise.all(
43
+ items.slice(start, end).map(async (item, offset) => {
44
+ results[start + offset] = await mapper(item, start + offset);
45
+ }),
46
+ );
47
+ }
48
+ return results;
49
+ };
50
+
51
+ const apnsCollapseId = (tag) => {
52
+ if (typeof tag !== 'string' || tag.length === 0) return undefined;
53
+ if (Buffer.byteLength(tag, 'utf8') > APNS_COLLAPSE_ID_MAX_BYTES) return undefined;
54
+ return tag;
55
+ };
56
+
28
57
  const trimmedEnv = (name) => {
29
58
  const value = process.env[name];
30
59
  return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
@@ -44,12 +73,15 @@ export const createApnsRuntime = (deps) => {
44
73
  writeSettingsToDisk,
45
74
  // Strict settings reader gating identity regeneration (see signing-key.js).
46
75
  readSettingsStrict,
76
+ resolveEffectiveRelayUrl,
47
77
  } = deps;
48
78
 
49
79
  let persistLock = Promise.resolve();
50
80
  let cachedJwt = null; // { token, issuedAtMs, keyId }
51
81
  let cachedRelayKey = null; // { privateKey, publicJwk }
52
82
  let warnedUnconfigured = false;
83
+ let registrationSuccessCache = new Map();
84
+ let registrationCacheRegisterUrl = null;
53
85
 
54
86
  // ---------------------------------------------------------------------------
55
87
  // Per-server relay signing identity (ECDSA P-256). Auto-generated + persisted in settings
@@ -76,9 +108,31 @@ export const createApnsRuntime = (deps) => {
76
108
  y: publicJwk.y,
77
109
  });
78
110
 
79
- const registerTokenWithRelay = async (token, platform = 'ios') => {
80
- const relay = resolveRelayConfig();
81
- if (!relay) return; // direct mode — no relay binding needed
111
+ const registrationCacheKey = (registerUrl, token, platform) =>
112
+ `${registerUrl}\0${token}\0${platform}`;
113
+
114
+ const rememberRelayRegisterUrl = (registerUrl) => {
115
+ if (registrationCacheRegisterUrl === registerUrl) return;
116
+ registrationSuccessCache = new Map();
117
+ registrationCacheRegisterUrl = registerUrl;
118
+ };
119
+
120
+ const boundRegistrationCache = (registerUrl, entries) => {
121
+ rememberRelayRegisterUrl(registerUrl);
122
+ const allowed = new Set(
123
+ entries.map((entry) => registrationCacheKey(registerUrl, entry.deviceToken, entry.platform)),
124
+ );
125
+ for (const key of registrationSuccessCache.keys()) {
126
+ if (!allowed.has(key)) registrationSuccessCache.delete(key);
127
+ }
128
+ };
129
+
130
+ const registerTokenWithRelay = async (token, platform = 'ios', relayOverride) => {
131
+ const relay = relayOverride ?? (await resolveRelayConfig());
132
+ if (!relay) return true; // direct mode — no relay binding needed
133
+ rememberRelayRegisterUrl(relay.registerUrl);
134
+ const cacheKey = registrationCacheKey(relay.registerUrl, token, platform);
135
+ if (registrationSuccessCache.has(cacheKey)) return true;
82
136
  try {
83
137
  const { privateKey, publicJwk } = await getOrCreateRelayKeypair();
84
138
  const ts = Date.now();
@@ -89,9 +143,15 @@ export const createApnsRuntime = (deps) => {
89
143
  headers: { 'content-type': 'application/json' },
90
144
  body: JSON.stringify({ token, platform, publicKeyJwk: relayPublicJwk(publicJwk), ts, sig }),
91
145
  });
92
- if (!res.ok) console.warn(`[Push relay] register-token failed status=${res.status}`);
146
+ if (!res.ok) {
147
+ console.warn(`[Push relay] register-token failed status=${res.status}`);
148
+ return false;
149
+ }
150
+ registrationSuccessCache.set(cacheKey, true);
151
+ return true;
93
152
  } catch (error) {
94
153
  console.warn('[Push relay] register-token request failed:', error?.message ?? error);
154
+ return false;
95
155
  }
96
156
  };
97
157
 
@@ -149,6 +209,8 @@ export const createApnsRuntime = (deps) => {
149
209
  userAgent: typeof entry.userAgent === 'string' ? entry.userAgent : undefined,
150
210
  // 'ios' (APNs) or 'android' (FCM). Older entries without one are APNs by default.
151
211
  platform: entry.platform === 'android' ? 'android' : 'ios',
212
+ // App UI locale at last registration; missing → English titles (legacy tokens).
213
+ locale: normalizeApnsLocale(entry.locale),
152
214
  };
153
215
  })
154
216
  .filter(Boolean);
@@ -158,22 +220,47 @@ export const createApnsRuntime = (deps) => {
158
220
  // was the only registrant before Android/FCM existed.
159
221
  const normalizePlatform = (platform) => (platform === 'android' ? 'android' : 'ios');
160
222
 
161
- const addOrUpdateApnsToken = async (uiSessionToken, deviceToken, userAgent, platform) => {
223
+ const listUniqueTokenEntries = (store) => {
224
+ const unique = [];
225
+ const seen = new Map();
226
+ for (const record of Object.values(store.tokensBySession || {})) {
227
+ for (const entry of normalizeTokens(record)) {
228
+ const key = `${entry.deviceToken}\0${entry.platform}`;
229
+ const priorIndex = seen.get(key);
230
+ if (priorIndex == null) {
231
+ seen.set(key, unique.length);
232
+ unique.push(entry);
233
+ continue;
234
+ }
235
+ // Same device may register under multiple UI sessions; keep the freshest locale.
236
+ const prior = unique[priorIndex];
237
+ const priorSeen = typeof prior.lastSeenAt === 'number' ? prior.lastSeenAt : 0;
238
+ const nextSeen = typeof entry.lastSeenAt === 'number' ? entry.lastSeenAt : 0;
239
+ if (nextSeen >= priorSeen) unique[priorIndex] = entry;
240
+ }
241
+ }
242
+ return unique;
243
+ };
244
+
245
+ const addOrUpdateApnsToken = async (uiSessionToken, deviceToken, userAgent, platform, locale) => {
162
246
  if (!uiSessionToken || typeof deviceToken !== 'string' || deviceToken.trim().length === 0) return;
163
247
  const token = deviceToken.trim();
164
248
  const tokenPlatform = normalizePlatform(platform);
249
+ const tokenLocale = normalizeApnsLocale(locale);
165
250
  const now = Date.now();
166
251
 
167
252
  await persistTokenUpdate((current) => {
168
253
  const tokensBySession = { ...(current.tokensBySession || {}) };
169
254
  const existing = normalizeTokens(tokensBySession[uiSessionToken]);
255
+ const prior = existing.find((entry) => entry.deviceToken === token);
170
256
  const filtered = existing.filter((entry) => entry.deviceToken !== token);
171
257
  filtered.unshift({
172
258
  deviceToken: token,
173
- createdAt: now,
259
+ createdAt: typeof prior?.createdAt === 'number' ? prior.createdAt : now,
174
260
  lastSeenAt: now,
175
261
  userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined,
176
262
  platform: tokenPlatform,
263
+ locale: tokenLocale,
177
264
  });
178
265
  tokensBySession[uiSessionToken] = filtered.slice(0, MAX_TOKENS_PER_SESSION);
179
266
  return { version: APNS_TOKENS_VERSION, tokensBySession };
@@ -316,8 +403,9 @@ export const createApnsRuntime = (deps) => {
316
403
  'apns-push-type': 'alert',
317
404
  'apns-priority': '10',
318
405
  };
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;
406
+ // collapse-id dedups like web-push tags; APNs caps it at 64 UTF-8 bytes.
407
+ // Keep legal values; omit anything over the limit (do not truncate mid-codepoint).
408
+ const collapseId = apnsCollapseId(config.tag);
321
409
  if (collapseId) headers['apns-collapse-id'] = collapseId;
322
410
 
323
411
  let req;
@@ -363,61 +451,83 @@ export const createApnsRuntime = (deps) => {
363
451
  req.end(body);
364
452
  });
365
453
 
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 = () => {
454
+ // Relay mode (default): the single APNs key lives in the isolated Push process on the same
455
+ // Relay origin (`/v1/push/*`), not on each user's server — so users configure nothing. The
456
+ // server just POSTs device tokens + generic text; Push signs + sends and reports which tokens
457
+ // to drop. Direct mode (below) is the fallback for self-hosters who set OPENCHAMBER_APNS_*
458
+ // and disable the relay.
459
+ const buildRelayConfig = (url) => ({
460
+ url,
461
+ registerUrl: url.replace(/\/send$/, '/register-token'),
462
+ environment:
463
+ (trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || 'sandbox').toLowerCase() === 'production'
464
+ ? 'production'
465
+ : 'sandbox',
466
+ });
467
+
468
+ const resolveRelayConfig = async () => {
371
469
  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
- };
470
+ const explicit = trimmedEnv('OPENCHAMBER_PUSH_RELAY_URL');
471
+ if (explicit) return buildRelayConfig(explicit);
472
+
473
+ let relayWsUrl = DEFAULT_RELAY_URL;
474
+ if (typeof resolveEffectiveRelayUrl === 'function') {
475
+ try {
476
+ const resolved = await resolveEffectiveRelayUrl();
477
+ if (typeof resolved === 'string' && resolved.trim()) {
478
+ if (derivePushSendUrlFromRelayUrl(resolved)) relayWsUrl = resolved.trim();
479
+ else console.warn('[Push relay] resolver returned a non-WebSocket URL; falling back to default');
480
+ }
481
+ } catch {
482
+ console.warn('[Push relay] resolver failed; falling back to default');
483
+ }
484
+ }
485
+ const url = derivePushSendUrlFromRelayUrl(relayWsUrl) || derivePushSendUrlFromRelayUrl(DEFAULT_RELAY_URL);
486
+ if (!url) return null;
487
+ return buildRelayConfig(url);
381
488
  };
382
489
 
383
490
  const sendViaRelay = async (deviceTokens, payload, relay) => {
384
- const tokens = deviceTokens.slice(0, 100);
491
+ if (!Array.isArray(deviceTokens) || deviceTokens.length === 0) return;
385
492
  const title = typeof payload?.title === 'string' && payload.title.length > 0 ? payload.title : 'OpenChamber';
386
493
  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,
494
+ for (let start = 0; start < deviceTokens.length; start += MAX_RELAY_SEND_TOKENS) {
495
+ const tokens = deviceTokens.slice(start, start + MAX_RELAY_SEND_TOKENS);
496
+ const ts = Date.now();
497
+ // Sign over the same canonical form the relay verifies: ts.sortedTokens.title.
498
+ const sig = signRelayMessage(privateKey, `${ts}.${[...tokens].sort().join(',')}.${title}`);
499
+ const requestBody = JSON.stringify({
500
+ tokens,
501
+ title,
502
+ body: typeof payload?.body === 'string' ? payload.body : '',
503
+ badge: Number.isFinite(payload?.badge) && payload.badge >= 0 ? Math.trunc(payload.badge) : undefined,
504
+ collapseId: apnsCollapseId(payload?.tag),
505
+ env: relay.environment,
506
+ data: payload?.data && typeof payload.data === 'object' ? payload.data : undefined,
507
+ publicKeyJwk: relayPublicJwk(publicJwk),
508
+ ts,
509
+ sig,
407
510
  });
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);
511
+ try {
512
+ const res = await fetch(relay.url, {
513
+ method: 'POST',
514
+ headers: { 'content-type': 'application/json' },
515
+ body: requestBody,
516
+ });
517
+ if (!res.ok) {
518
+ console.warn(`[APNs relay] send failed status=${res.status}`);
519
+ continue;
417
520
  }
521
+ const data = await res.json().catch(() => null);
522
+ const results = Array.isArray(data?.results) ? data.results : [];
523
+ for (const result of results) {
524
+ if (result && result.drop === true && typeof result.token === 'string') {
525
+ await removeApnsTokenFromAllSessions(result.token);
526
+ }
527
+ }
528
+ } catch (error) {
529
+ console.warn('[APNs relay] request failed:', error?.message ?? error);
418
530
  }
419
- } catch (error) {
420
- console.warn('[APNs relay] request failed:', error?.message ?? error);
421
531
  }
422
532
  };
423
533
 
@@ -473,26 +583,79 @@ export const createApnsRuntime = (deps) => {
473
583
  // background push for short responses. Instead we always send, and rely on iOS to NOT
474
584
  // display the alert while the app is foreground (presentationOptions: [] in
475
585
  // capacitor.config) — so there is no notification when the app is active, with no race.
476
- const sendApnsToAllUiSessions = async (payload, _options = {}) => {
586
+ const reRegisterAllTokens = async () => {
587
+ const relay = await resolveRelayConfig();
588
+ if (!relay) {
589
+ return { attempted: 0, succeeded: 0, failed: 0 };
590
+ }
477
591
  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
- }
592
+ const entries = listUniqueTokenEntries(store);
593
+ boundRegistrationCache(relay.registerUrl, entries);
594
+ const outcomes = await mapWithBoundedConcurrency(
595
+ entries,
596
+ RELAY_REGISTER_CONCURRENCY,
597
+ (entry) => registerTokenWithRelay(entry.deviceToken, entry.platform, relay),
598
+ );
599
+ let succeeded = 0;
600
+ let failed = 0;
601
+ for (const ok of outcomes) {
602
+ if (ok) succeeded += 1;
603
+ else failed += 1;
604
+ }
605
+ return { attempted: entries.length, succeeded, failed };
606
+ };
607
+
608
+ // Group ready device tokens by app locale so each batch can carry a localized title.
609
+ // Relay signatures cover title, so mixed-locale tokens must not share one send body.
610
+ const groupTokensByLocale = (entries) => {
611
+ const groups = new Map();
612
+ for (const entry of entries) {
613
+ const locale = entry?.locale ? normalizeApnsLocale(entry.locale) : APNS_DEFAULT_LOCALE;
614
+ const token = entry?.deviceToken;
615
+ if (typeof token !== 'string' || token.length === 0) continue;
616
+ let list = groups.get(locale);
617
+ if (!list) {
618
+ list = [];
619
+ groups.set(locale, list);
486
620
  }
621
+ list.push(token);
487
622
  }
488
- if (deviceTokens.length === 0) return;
623
+ return groups;
624
+ };
489
625
 
490
- const relay = resolveRelayConfig();
626
+ const sendApnsToAllUiSessions = async (payload, _options = {}) => {
627
+ const store = await readTokensFromDisk();
628
+ const uniqueEntries = listUniqueTokenEntries(store);
629
+ if (uniqueEntries.length === 0) return;
630
+
631
+ const relay = await resolveRelayConfig();
491
632
  if (relay) {
492
- await sendViaRelay(deviceTokens, payload, relay);
633
+ boundRegistrationCache(relay.registerUrl, uniqueEntries);
634
+ const outcomes = await mapWithBoundedConcurrency(
635
+ uniqueEntries,
636
+ RELAY_REGISTER_CONCURRENCY,
637
+ async (entry) => {
638
+ const registered = await registerTokenWithRelay(entry.deviceToken, entry.platform, relay);
639
+ return registered ? entry : null;
640
+ },
641
+ );
642
+ const readyEntries = [];
643
+ const readySeen = new Set();
644
+ for (const entry of outcomes) {
645
+ if (!entry?.deviceToken || readySeen.has(entry.deviceToken)) continue;
646
+ readySeen.add(entry.deviceToken);
647
+ readyEntries.push(entry);
648
+ }
649
+ if (readyEntries.length === 0) return;
650
+ for (const [locale, tokens] of groupTokensByLocale(readyEntries)) {
651
+ await sendViaRelay(tokens, localizeApnsPayload(payload, locale), relay);
652
+ }
493
653
  return;
494
654
  }
495
- await sendViaDirectApns(deviceTokens, payload);
655
+ const byLocale = groupTokensByLocale(uniqueEntries);
656
+ for (const [locale, deviceTokens] of byLocale) {
657
+ await sendViaDirectApns(deviceTokens, localizeApnsPayload(payload, locale));
658
+ }
496
659
  };
497
660
 
498
661
  return {
@@ -500,6 +663,7 @@ export const createApnsRuntime = (deps) => {
500
663
  removeApnsToken,
501
664
  removeApnsTokenFromAllSessions,
502
665
  sendApnsToAllUiSessions,
666
+ reRegisterAllTokens,
503
667
  resolveApnsConfig,
504
668
  // exposed for tests
505
669
  signApnsJwt,