@openchambery/web 1.19.0-beta.37 → 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 (50) hide show
  1. package/dist/assets/{AssistantView-D5pDX9ES.js → AssistantView-BGHsUIcD.js} +1 -1
  2. package/dist/assets/{ChatContainer-DBuXfy4g.js → ChatContainer-lPNgvmdy.js} +4 -4
  3. package/dist/assets/{ContextPanel-RniXr46u.js → ContextPanel-DuDbecdJ.js} +3 -3
  4. package/dist/assets/{DiagramView-BwNKhbsZ.js → DiagramView-41vCtWQO.js} +1 -1
  5. package/dist/assets/{MarkdownRendererImpl-wJgiOdis.js → MarkdownRendererImpl-4wf_NyPi.js} +1 -1
  6. package/dist/assets/{MobileDetailNavigation-Dl6KVNYY.js → MobileDetailNavigation-BZ5-chaJ.js} +1 -1
  7. package/dist/assets/{MobileShareBridge-BxR0pE_m.js → MobileShareBridge-DZ4_UDPx.js} +1 -1
  8. package/dist/assets/{MobileSurface-LhR2YNCl.js → MobileSurface-DO07H7VN.js} +1 -1
  9. package/dist/assets/{MultiRunWindow-Cgg9u8VX.js → MultiRunWindow-CwyGNg6E.js} +1 -1
  10. package/dist/assets/{SettingsView-CBtDTnbu.js → SettingsView-eTWUs3mr.js} +1 -1
  11. package/dist/assets/{SettingsWindow-kBA9auJC.js → SettingsWindow-CWTIj5qT.js} +1 -1
  12. package/dist/assets/{TerminalView-DhVu4pCB.js → TerminalView-CRihEF7B.js} +1 -1
  13. package/dist/assets/{ToolOutputDialog-cDDD9uCL.js → ToolOutputDialog-BPXKhRBa.js} +1 -1
  14. package/dist/assets/{appThemeRegistry-staJMB2J.js → appThemeRegistry-BiAR1i_S.js} +2 -2
  15. package/dist/assets/{gitApi-CYDWoaIF.js → gitApi-BWk11cqj.js} +1 -1
  16. package/dist/assets/{insertionBoundaries-DbiBrF9p.js → insertionBoundaries-CNPI5E9-.js} +1 -1
  17. package/dist/assets/{main-cvGY60fq.js → main-8PESnWMg.js} +2 -2
  18. package/dist/assets/{main-Dkf8xb0G.js → main-CxMNCA6O.js} +4 -4
  19. package/dist/assets/{miniChat-Cz1ftAm8.js → miniChat-Buff55v8.js} +2 -2
  20. package/dist/assets/{mobile-C6Di4aP0.js → mobile-DdwSIsqR.js} +2 -2
  21. package/dist/assets/{multirun-Dp0YasTz.js → multirun-DicMqzmS.js} +1 -1
  22. package/dist/assets/{projectMeta-CVORtKSv.js → projectMeta-YlBX0ZsF.js} +1 -1
  23. package/dist/assets/{radio-BwGc_8vf.js → radio-BGL1w8gp.js} +1 -1
  24. package/dist/assets/{renderElectronMiniChatApp-DovEohIn.js → renderElectronMiniChatApp-CKpo0WpH.js} +2 -2
  25. package/dist/assets/renderMobileApp-BhjxKg9G.js +23 -0
  26. package/dist/assets/{runtimeConfig-ClXahK8y.js → runtimeConfig-tjqLOQnJ.js} +1 -1
  27. package/dist/assets/{runtimeEndpointReset-Wd0jJx7c.js → runtimeEndpointReset-DCb37IOY.js} +1 -1
  28. package/dist/assets/{shikiHighlight-rNaL87ld.js → shikiHighlight-CWn7NoHc.js} +1 -1
  29. package/dist/assets/{src-Dc5_NNo2.css → src-DeaNVOre.css} +1 -1
  30. package/dist/assets/{useAppFontEffects-BgJOiL9O.js → useAppFontEffects-C1okpzsV.js} +2 -2
  31. package/dist/assets/{useDesktopWindowControlsLayout-BUMOckKt.js → useDesktopWindowControlsLayout-BNh1BzFG.js} +1 -1
  32. package/dist/assets/{useEffectiveDirectory-DmcTREeQ.js → useEffectiveDirectory-BwY1B5mw.js} +1 -1
  33. package/dist/assets/{useMobileNavigationStore-B5TOw3Mw.js → useMobileNavigationStore-DjEE6WNI.js} +1 -1
  34. package/dist/assets/{useSessionAutoCleanup-CZdJsOtU.js → useSessionAutoCleanup-3tZ8BVvw.js} +1 -1
  35. package/dist/assets/{useWorkerHighlightedLines-DaZyFcgv.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/lib/notifications/APNS.md +53 -38
  41. package/server/lib/notifications/DOCUMENTATION.md +5 -4
  42. package/server/lib/notifications/apns-runtime.js +139 -69
  43. package/server/lib/notifications/apns-runtime.test.js +288 -0
  44. package/server/lib/notifications/apns-titles.js +212 -0
  45. package/server/lib/notifications/apns-titles.test.js +55 -0
  46. package/server/lib/notifications/routes.js +2 -1
  47. package/server/lib/notifications/runtime.js +5 -13
  48. package/server/lib/relay/DOCUMENTATION.md +2 -2
  49. package/server/lib/relay/signing-key.js +1 -1
  50. package/dist/assets/renderMobileApp-DKt9BrQO.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
@@ -14,17 +15,45 @@ import {
14
15
  signRelayMessage as signRelayMessageShared,
15
16
  } from '../relay/signing-key.js';
16
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';
17
23
 
18
24
  const APNS_TOKENS_VERSION = 1;
19
25
  const APNS_HOST_PRODUCTION = 'https://api.push.apple.com';
20
26
  const APNS_HOST_SANDBOX = 'https://api.sandbox.push.apple.com';
21
27
  // APNs rejects auth tokens older than 1h; refresh well inside that window.
22
28
  const JWT_TTL_MS = 50 * 60 * 1000;
23
- const DEFAULT_BUNDLE_ID = 'com.openchamber.app';
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;
@@ -180,6 +209,8 @@ export const createApnsRuntime = (deps) => {
180
209
  userAgent: typeof entry.userAgent === 'string' ? entry.userAgent : undefined,
181
210
  // 'ios' (APNs) or 'android' (FCM). Older entries without one are APNs by default.
182
211
  platform: entry.platform === 'android' ? 'android' : 'ios',
212
+ // App UI locale at last registration; missing → English titles (legacy tokens).
213
+ locale: normalizeApnsLocale(entry.locale),
183
214
  };
184
215
  })
185
216
  .filter(Boolean);
@@ -191,34 +222,45 @@ export const createApnsRuntime = (deps) => {
191
222
 
192
223
  const listUniqueTokenEntries = (store) => {
193
224
  const unique = [];
194
- const seen = new Set();
225
+ const seen = new Map();
195
226
  for (const record of Object.values(store.tokensBySession || {})) {
196
227
  for (const entry of normalizeTokens(record)) {
197
228
  const key = `${entry.deviceToken}\0${entry.platform}`;
198
- if (seen.has(key)) continue;
199
- seen.add(key);
200
- unique.push(entry);
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;
201
240
  }
202
241
  }
203
242
  return unique;
204
243
  };
205
244
 
206
- const addOrUpdateApnsToken = async (uiSessionToken, deviceToken, userAgent, platform) => {
245
+ const addOrUpdateApnsToken = async (uiSessionToken, deviceToken, userAgent, platform, locale) => {
207
246
  if (!uiSessionToken || typeof deviceToken !== 'string' || deviceToken.trim().length === 0) return;
208
247
  const token = deviceToken.trim();
209
248
  const tokenPlatform = normalizePlatform(platform);
249
+ const tokenLocale = normalizeApnsLocale(locale);
210
250
  const now = Date.now();
211
251
 
212
252
  await persistTokenUpdate((current) => {
213
253
  const tokensBySession = { ...(current.tokensBySession || {}) };
214
254
  const existing = normalizeTokens(tokensBySession[uiSessionToken]);
255
+ const prior = existing.find((entry) => entry.deviceToken === token);
215
256
  const filtered = existing.filter((entry) => entry.deviceToken !== token);
216
257
  filtered.unshift({
217
258
  deviceToken: token,
218
- createdAt: now,
259
+ createdAt: typeof prior?.createdAt === 'number' ? prior.createdAt : now,
219
260
  lastSeenAt: now,
220
261
  userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined,
221
262
  platform: tokenPlatform,
263
+ locale: tokenLocale,
222
264
  });
223
265
  tokensBySession[uiSessionToken] = filtered.slice(0, MAX_TOKENS_PER_SESSION);
224
266
  return { version: APNS_TOKENS_VERSION, tokensBySession };
@@ -361,8 +403,9 @@ export const createApnsRuntime = (deps) => {
361
403
  'apns-push-type': 'alert',
362
404
  'apns-priority': '10',
363
405
  };
364
- // collapse-id dedups like web-push tags; APNs caps it at 64 bytes.
365
- 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);
366
409
  if (collapseId) headers['apns-collapse-id'] = collapseId;
367
410
 
368
411
  let req;
@@ -408,10 +451,11 @@ export const createApnsRuntime = (deps) => {
408
451
  req.end(body);
409
452
  });
410
453
 
411
- // Relay mode (default): the single APNs key lives in the central Cloudflare relay, not on
412
- // each user's server — so users configure nothing. The server just POSTs device tokens +
413
- // generic text; the relay signs + sends and reports which tokens to drop. Direct mode (below)
414
- // is the fallback for self-hosters who set OPENCHAMBER_APNS_* and disable the relay.
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.
415
459
  const buildRelayConfig = (url) => ({
416
460
  url,
417
461
  registerUrl: url.replace(/\/send$/, '/register-token'),
@@ -430,9 +474,12 @@ export const createApnsRuntime = (deps) => {
430
474
  if (typeof resolveEffectiveRelayUrl === 'function') {
431
475
  try {
432
476
  const resolved = await resolveEffectiveRelayUrl();
433
- if (typeof resolved === 'string' && resolved.trim()) relayWsUrl = resolved;
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
+ }
434
481
  } catch {
435
- relayWsUrl = DEFAULT_RELAY_URL;
482
+ console.warn('[Push relay] resolver failed; falling back to default');
436
483
  }
437
484
  }
438
485
  const url = derivePushSendUrlFromRelayUrl(relayWsUrl) || derivePushSendUrlFromRelayUrl(DEFAULT_RELAY_URL);
@@ -441,43 +488,46 @@ export const createApnsRuntime = (deps) => {
441
488
  };
442
489
 
443
490
  const sendViaRelay = async (deviceTokens, payload, relay) => {
444
- const tokens = deviceTokens.slice(0, 100);
491
+ if (!Array.isArray(deviceTokens) || deviceTokens.length === 0) return;
445
492
  const title = typeof payload?.title === 'string' && payload.title.length > 0 ? payload.title : 'OpenChamber';
446
493
  const { privateKey, publicJwk } = await getOrCreateRelayKeypair();
447
- const ts = Date.now();
448
- // Sign over the same canonical form the relay verifies: ts.sortedTokens.title.
449
- const sig = signRelayMessage(privateKey, `${ts}.${[...tokens].sort().join(',')}.${title}`);
450
- const requestBody = JSON.stringify({
451
- tokens,
452
- title,
453
- body: typeof payload?.body === 'string' ? payload.body : '',
454
- badge: Number.isFinite(payload?.badge) && payload.badge >= 0 ? Math.trunc(payload.badge) : undefined,
455
- collapseId: typeof payload?.tag === 'string' ? payload.tag.slice(0, 64) : undefined,
456
- env: relay.environment,
457
- data: payload?.data && typeof payload.data === 'object' ? payload.data : undefined,
458
- publicKeyJwk: relayPublicJwk(publicJwk),
459
- ts,
460
- sig,
461
- });
462
- try {
463
- const res = await fetch(relay.url, {
464
- method: 'POST',
465
- headers: { 'content-type': 'application/json' },
466
- 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,
467
510
  });
468
- if (!res.ok) {
469
- console.warn(`[APNs relay] send failed status=${res.status}`);
470
- return;
471
- }
472
- const data = await res.json().catch(() => null);
473
- const results = Array.isArray(data?.results) ? data.results : [];
474
- for (const result of results) {
475
- if (result && result.drop === true && typeof result.token === 'string') {
476
- 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;
477
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);
478
530
  }
479
- } catch (error) {
480
- console.warn('[APNs relay] request failed:', error?.message ?? error);
481
531
  }
482
532
  };
483
533
 
@@ -541,8 +591,10 @@ export const createApnsRuntime = (deps) => {
541
591
  const store = await readTokensFromDisk();
542
592
  const entries = listUniqueTokenEntries(store);
543
593
  boundRegistrationCache(relay.registerUrl, entries);
544
- const outcomes = await Promise.all(
545
- entries.map((entry) => registerTokenWithRelay(entry.deviceToken, entry.platform, relay)),
594
+ const outcomes = await mapWithBoundedConcurrency(
595
+ entries,
596
+ RELAY_REGISTER_CONCURRENCY,
597
+ (entry) => registerTokenWithRelay(entry.deviceToken, entry.platform, relay),
546
598
  );
547
599
  let succeeded = 0;
548
600
  let failed = 0;
@@ -553,6 +605,24 @@ export const createApnsRuntime = (deps) => {
553
605
  return { attempted: entries.length, succeeded, failed };
554
606
  };
555
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);
620
+ }
621
+ list.push(token);
622
+ }
623
+ return groups;
624
+ };
625
+
556
626
  const sendApnsToAllUiSessions = async (payload, _options = {}) => {
557
627
  const store = await readTokensFromDisk();
558
628
  const uniqueEntries = listUniqueTokenEntries(store);
@@ -561,31 +631,31 @@ export const createApnsRuntime = (deps) => {
561
631
  const relay = await resolveRelayConfig();
562
632
  if (relay) {
563
633
  boundRegistrationCache(relay.registerUrl, uniqueEntries);
564
- const outcomes = await Promise.all(
565
- uniqueEntries.map(async (entry) => {
634
+ const outcomes = await mapWithBoundedConcurrency(
635
+ uniqueEntries,
636
+ RELAY_REGISTER_CONCURRENCY,
637
+ async (entry) => {
566
638
  const registered = await registerTokenWithRelay(entry.deviceToken, entry.platform, relay);
567
- return registered ? entry.deviceToken : null;
568
- }),
639
+ return registered ? entry : null;
640
+ },
569
641
  );
570
- const readyTokens = [];
642
+ const readyEntries = [];
571
643
  const readySeen = new Set();
572
- for (const token of outcomes) {
573
- if (!token || readySeen.has(token)) continue;
574
- readySeen.add(token);
575
- readyTokens.push(token);
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);
576
652
  }
577
- if (readyTokens.length === 0) return;
578
- await sendViaRelay(readyTokens, payload, relay);
579
653
  return;
580
654
  }
581
- const deviceTokens = [];
582
- const seen = new Set();
583
- for (const entry of uniqueEntries) {
584
- if (seen.has(entry.deviceToken)) continue;
585
- seen.add(entry.deviceToken);
586
- deviceTokens.push(entry.deviceToken);
655
+ const byLocale = groupTokensByLocale(uniqueEntries);
656
+ for (const [locale, deviceTokens] of byLocale) {
657
+ await sendViaDirectApns(deviceTokens, localizeApnsPayload(payload, locale));
587
658
  }
588
- await sendViaDirectApns(deviceTokens, payload);
589
659
  };
590
660
 
591
661
  return {
@@ -72,6 +72,43 @@ afterEach(() => {
72
72
  delete process.env.OPENCHAMBER_PUSH_RELAY_URL;
73
73
  delete process.env.OPENCHAMBER_PUSH_RELAY_DISABLED;
74
74
  delete process.env.OPENCHAMBER_RELAY_URL;
75
+ delete process.env.OPENCHAMBER_APNS_KEY_ID;
76
+ delete process.env.OPENCHAMBER_APNS_TEAM_ID;
77
+ delete process.env.OPENCHAMBER_APNS_P8;
78
+ delete process.env.OPENCHAMBER_APNS_BUNDLE_ID;
79
+ });
80
+
81
+ describe('apns runtime bundle ID', () => {
82
+ it('defaults to the current product bundle ID when env and settings omit it', async () => {
83
+ process.env.OPENCHAMBER_APNS_KEY_ID = 'KEY123';
84
+ process.env.OPENCHAMBER_APNS_TEAM_ID = 'TEAM123';
85
+ process.env.OPENCHAMBER_APNS_P8 = P8;
86
+ const runtime = createApnsRuntime(makeDeps());
87
+ await expect(runtime.resolveApnsConfig()).resolves.toMatchObject({ bundleId: 'com.yee94.openchamber' });
88
+ });
89
+
90
+ it('prefers OPENCHAMBER_APNS_BUNDLE_ID over the default and settings', async () => {
91
+ process.env.OPENCHAMBER_APNS_BUNDLE_ID = 'com.example.app';
92
+ const runtime = createApnsRuntime(
93
+ makeDeps({
94
+ readSettingsFromDiskMigrated: vi.fn(async () => ({
95
+ apnsConfig: { ...APNS_CONFIG, bundleId: 'com.example.settings' },
96
+ })),
97
+ }),
98
+ );
99
+ await expect(runtime.resolveApnsConfig()).resolves.toMatchObject({ bundleId: 'com.example.app' });
100
+ });
101
+
102
+ it('prefers settings.apnsConfig.bundleId over the default', async () => {
103
+ const runtime = createApnsRuntime(
104
+ makeDeps({
105
+ readSettingsFromDiskMigrated: vi.fn(async () => ({
106
+ apnsConfig: { ...APNS_CONFIG, bundleId: 'com.example.settings' },
107
+ })),
108
+ }),
109
+ );
110
+ await expect(runtime.resolveApnsConfig()).resolves.toMatchObject({ bundleId: 'com.example.settings' });
111
+ });
75
112
  });
76
113
 
77
114
  describe('apns runtime relay mode (default)', () => {
@@ -154,6 +191,36 @@ describe('apns runtime relay mode (default)', () => {
154
191
  await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
155
192
  expect(fetchMock).not.toHaveBeenCalled();
156
193
  });
194
+
195
+ it('omits relay collapseId when CJK/emoji tags exceed 64 UTF-8 bytes', async () => {
196
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
197
+ vi.stubGlobal('fetch', fetchMock);
198
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
199
+ const runtime = createApnsRuntime(makeDeps());
200
+ await runtime.addOrUpdateApnsToken('s1', 'tokenA');
201
+
202
+ const sendTag = async (tag) => {
203
+ fetchMock.mockClear();
204
+ await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b', tag });
205
+ const sent = JSON.parse(fetchMock.mock.calls.find(isSend)[1].body);
206
+ return Object.prototype.hasOwnProperty.call(sent, 'collapseId') ? sent.collapseId : undefined;
207
+ };
208
+
209
+ const cjkKeep = '会话'.repeat(10); // 60 bytes
210
+ const cjkOmit = '会话'.repeat(11); // 66 bytes
211
+ const emojiKeep = '🎉'.repeat(16); // 64 bytes
212
+ const emojiOmit = '🎉'.repeat(17); // 68 bytes
213
+ expect(Buffer.byteLength(cjkKeep, 'utf8')).toBeLessThanOrEqual(64);
214
+ expect(Buffer.byteLength(cjkOmit, 'utf8')).toBeGreaterThan(64);
215
+ expect(Buffer.byteLength(emojiKeep, 'utf8')).toBe(64);
216
+ expect(Buffer.byteLength(emojiOmit, 'utf8')).toBeGreaterThan(64);
217
+
218
+ expect(await sendTag('ready-x')).toBe('ready-x');
219
+ expect(await sendTag(cjkKeep)).toBe(cjkKeep);
220
+ expect(await sendTag(cjkOmit)).toBeUndefined();
221
+ expect(await sendTag(emojiKeep)).toBe(emojiKeep);
222
+ expect(await sendTag(emojiOmit)).toBeUndefined();
223
+ });
157
224
  });
158
225
 
159
226
  describe('apns runtime direct fallback (relay disabled)', () => {
@@ -189,6 +256,57 @@ describe('apns runtime direct fallback (relay disabled)', () => {
189
256
  expect(targeted).toEqual(['tokenDirect']);
190
257
  });
191
258
 
259
+ it('keeps direct APNs collapse-id within 64 UTF-8 bytes and omits CJK/emoji over the limit', async () => {
260
+ process.env.OPENCHAMBER_PUSH_RELAY_DISABLED = 'true';
261
+ const collapseIds = [];
262
+ const http2 = {
263
+ connect: () => ({
264
+ on: () => {},
265
+ close: () => {},
266
+ request: (headers) => {
267
+ collapseIds.push(headers['apns-collapse-id']);
268
+ const listeners = {};
269
+ const req = {
270
+ on: (event, cb) => { listeners[event] = cb; return req; },
271
+ setEncoding: () => req,
272
+ end: () => {
273
+ queueMicrotask(() => {
274
+ listeners.response?.({ ':status': '200' });
275
+ listeners.end?.();
276
+ });
277
+ },
278
+ };
279
+ return req;
280
+ },
281
+ }),
282
+ };
283
+ const runtime = createApnsRuntime(
284
+ makeDeps({ http2, readSettingsFromDiskMigrated: vi.fn(async () => ({ apnsConfig: APNS_CONFIG })) }),
285
+ );
286
+ await runtime.addOrUpdateApnsToken('s', 'tokenDirect');
287
+
288
+ const sendTag = async (tag) => {
289
+ collapseIds.length = 0;
290
+ await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b', tag });
291
+ return collapseIds[0];
292
+ };
293
+
294
+ const cjkKeep = '会话'.repeat(10); // 60 bytes
295
+ const cjkOmit = '会话'.repeat(11); // 66 bytes
296
+ const emojiKeep = '🎉'.repeat(16); // 64 bytes
297
+ const emojiOmit = '🎉'.repeat(17); // 68 bytes
298
+ expect(Buffer.byteLength(cjkKeep, 'utf8')).toBeLessThanOrEqual(64);
299
+ expect(Buffer.byteLength(cjkOmit, 'utf8')).toBeGreaterThan(64);
300
+ expect(Buffer.byteLength(emojiKeep, 'utf8')).toBe(64);
301
+ expect(Buffer.byteLength(emojiOmit, 'utf8')).toBeGreaterThan(64);
302
+
303
+ expect(await sendTag('ready-x')).toBe('ready-x');
304
+ expect(await sendTag(cjkKeep)).toBe(cjkKeep);
305
+ expect(await sendTag(cjkOmit)).toBeUndefined();
306
+ expect(await sendTag(emojiKeep)).toBe(emojiKeep);
307
+ expect(await sendTag(emojiOmit)).toBeUndefined();
308
+ });
309
+
192
310
  it('signApnsJwt produces a 3-part ES256 token with the expected header/claims', () => {
193
311
  const runtime = createApnsRuntime(makeDeps());
194
312
  const parts = runtime.signApnsJwt(APNS_CONFIG).split('.');
@@ -352,4 +470,174 @@ describe('apns runtime push relay derivation and re-register', () => {
352
470
  expect(retryRegister).toEqual(['tokenB']);
353
471
  expect(JSON.parse(fetchMock.mock.calls.find(([url]) => String(url).endsWith('/v1/push/send'))[1].body).tokens).toEqual(['tokenA']);
354
472
  });
473
+
474
+ it('warns without leaking the URL when the resolver returns a non-WebSocket URL, then falls back to default', async () => {
475
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
476
+ vi.stubGlobal('fetch', fetchMock);
477
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
478
+ const leaked = 'https://evil.example/v1/push/send?token=secret';
479
+ const runtime = createApnsRuntime(makeDeps({
480
+ resolveEffectiveRelayUrl: async () => leaked,
481
+ }));
482
+
483
+ try {
484
+ await runtime.addOrUpdateApnsToken('s1', 'tokenA');
485
+ expect(registerUrls(fetchMock)).toEqual(['https://relay.openchamber.dev/v1/push/register-token']);
486
+ expect(warn).toHaveBeenCalled();
487
+ const messages = warn.mock.calls.map((args) => args.map(String).join(' ')).join('\n');
488
+ expect(messages).toContain('falling back to default');
489
+ expect(messages).not.toContain(leaked);
490
+ expect(messages).not.toContain('evil.example');
491
+ expect(messages).not.toContain('secret');
492
+ expect(messages).not.toContain('token=');
493
+ } finally {
494
+ warn.mockRestore();
495
+ }
496
+ });
497
+
498
+ it('warns without leaking the error when the resolver throws, then falls back to default', async () => {
499
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
500
+ vi.stubGlobal('fetch', fetchMock);
501
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
502
+ const leaked = 'wss://evil.example/ws?token=secret';
503
+ const runtime = createApnsRuntime(makeDeps({
504
+ resolveEffectiveRelayUrl: async () => {
505
+ throw new Error(leaked);
506
+ },
507
+ }));
508
+
509
+ try {
510
+ await runtime.addOrUpdateApnsToken('s1', 'tokenA');
511
+ expect(registerUrls(fetchMock)).toEqual(['https://relay.openchamber.dev/v1/push/register-token']);
512
+ expect(warn).toHaveBeenCalled();
513
+ const messages = warn.mock.calls.map((args) => args.map(String).join(' ')).join('\n');
514
+ expect(messages).toContain('falling back to default');
515
+ expect(messages).not.toContain(leaked);
516
+ expect(messages).not.toContain('evil.example');
517
+ expect(messages).not.toContain('secret');
518
+ expect(messages).not.toContain('token=');
519
+ } finally {
520
+ warn.mockRestore();
521
+ }
522
+ });
523
+
524
+ it('sends more than 100 tokens in batches of at most 100, each token exactly once', async () => {
525
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
526
+ const fetchMock = vi.fn(async (url) =>
527
+ String(url).endsWith('/register-token')
528
+ ? jsonResponse({ ok: true })
529
+ : jsonResponse({ results: [] }),
530
+ );
531
+ vi.stubGlobal('fetch', fetchMock);
532
+ const runtime = createApnsRuntime(makeDeps());
533
+ const tokens = Array.from({ length: 101 }, (_, i) => `token${String(i).padStart(3, '0')}`);
534
+ for (const [i, token] of tokens.entries()) {
535
+ await runtime.addOrUpdateApnsToken(`s${i}`, token);
536
+ }
537
+
538
+ fetchMock.mockClear();
539
+ await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
540
+
541
+ const sendCalls = fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/v1/push/send'));
542
+ expect(sendCalls).toHaveLength(2);
543
+ const batches = sendCalls.map(([, init]) => JSON.parse(init.body).tokens);
544
+ expect(batches.every((batch) => Array.isArray(batch) && batch.length > 0 && batch.length <= 100)).toBe(true);
545
+ const sent = batches.flat();
546
+ expect(sent).toHaveLength(101);
547
+ expect(new Set(sent).size).toBe(101);
548
+ expect(new Set(sent)).toEqual(new Set(tokens));
549
+ for (const [, init] of sendCalls) {
550
+ const sentBody = JSON.parse(init.body);
551
+ const sendMessage = `${sentBody.ts}.${[...sentBody.tokens].sort().join(',')}.${sentBody.title}`;
552
+ expect(await verifyRelaySignature(sentBody.publicKeyJwk, sendMessage, sentBody.sig)).toBe(true);
553
+ }
554
+ });
555
+
556
+ it('continues remaining send batches when one batch fails', async () => {
557
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
558
+ let sendCount = 0;
559
+ const fetchMock = vi.fn(async (url) => {
560
+ if (String(url).endsWith('/register-token')) return jsonResponse({ ok: true });
561
+ sendCount += 1;
562
+ if (sendCount === 1) return jsonResponse({ error: true }, 500);
563
+ return jsonResponse({ results: [{ token: 'token100', ok: false, drop: true }] });
564
+ });
565
+ vi.stubGlobal('fetch', fetchMock);
566
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
567
+ const runtime = createApnsRuntime(makeDeps());
568
+ const tokens = Array.from({ length: 101 }, (_, i) => `token${String(i).padStart(3, '0')}`);
569
+ for (const [i, token] of tokens.entries()) {
570
+ await runtime.addOrUpdateApnsToken(`s${i}`, token);
571
+ }
572
+
573
+ try {
574
+ fetchMock.mockClear();
575
+ await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
576
+ const sendCalls = fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/v1/push/send'));
577
+ expect(sendCalls).toHaveLength(2);
578
+ expect(sendCalls.map(([, init]) => JSON.parse(init.body).tokens).flat()).toHaveLength(101);
579
+
580
+ fetchMock.mockClear();
581
+ await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
582
+ const retryTokens = fetchMock.mock.calls
583
+ .filter(([url]) => String(url).endsWith('/v1/push/send'))
584
+ .flatMap(([, init]) => JSON.parse(init.body).tokens);
585
+ expect(retryTokens).not.toContain('token100');
586
+ expect(retryTokens).toHaveLength(100);
587
+ } finally {
588
+ warn.mockRestore();
589
+ }
590
+ });
591
+
592
+ it('sends localized scenario titles grouped by stored token locale', async () => {
593
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
594
+ vi.stubGlobal('fetch', fetchMock);
595
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
596
+
597
+ const runtime = createApnsRuntime(makeDeps());
598
+ await runtime.addOrUpdateApnsToken('s1', 'tokenEn', undefined, 'ios', 'en');
599
+ await runtime.addOrUpdateApnsToken('s2', 'tokenZh', undefined, 'ios', 'zh-CN');
600
+ await runtime.addOrUpdateApnsToken('s3', 'tokenJa', undefined, 'android', 'ja');
601
+
602
+ fetchMock.mockClear();
603
+ await runtime.sendApnsToAllUiSessions({
604
+ type: 'ready',
605
+ sessionName: 'My session',
606
+ badge: 2,
607
+ tag: 'ready-x',
608
+ data: { sessionId: 'sess1' },
609
+ });
610
+
611
+ const sendBodies = fetchMock.mock.calls
612
+ .filter(isSend)
613
+ .map(([, init]) => JSON.parse(init.body));
614
+ expect(sendBodies).toHaveLength(3);
615
+
616
+ const byTitle = Object.fromEntries(sendBodies.map((body) => [body.title, body]));
617
+ expect(byTitle['Agent response is ready']?.tokens).toEqual(['tokenEn']);
618
+ expect(byTitle['智能体回复已就绪']?.tokens).toEqual(['tokenZh']);
619
+ expect(byTitle['エージェントの応答が準備できました']?.tokens).toEqual(['tokenJa']);
620
+ for (const body of sendBodies) {
621
+ expect(body.body).toBe('My session');
622
+ expect(body.badge).toBe(2);
623
+ const sendMessage = `${body.ts}.${[...body.tokens].sort().join(',')}.${body.title}`;
624
+ expect(await verifyRelaySignature(body.publicKeyJwk, sendMessage, body.sig)).toBe(true);
625
+ }
626
+ });
627
+
628
+ it('defaults missing token locale to English titles', async () => {
629
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
630
+ vi.stubGlobal('fetch', fetchMock);
631
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
632
+
633
+ const runtime = createApnsRuntime(makeDeps());
634
+ await runtime.addOrUpdateApnsToken('s1', 'tokenLegacy');
635
+
636
+ fetchMock.mockClear();
637
+ await runtime.sendApnsToAllUiSessions({ type: 'permission', sessionName: 'S' });
638
+
639
+ const sent = JSON.parse(fetchMock.mock.calls.find(isSend)[1].body);
640
+ expect(sent.title).toBe('Agent needs permission');
641
+ expect(sent.body).toBe('S');
642
+ });
355
643
  });