@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.
- package/dist/assets/{AssistantView-D5pDX9ES.js → AssistantView-BGHsUIcD.js} +1 -1
- package/dist/assets/{ChatContainer-DBuXfy4g.js → ChatContainer-lPNgvmdy.js} +4 -4
- package/dist/assets/{ContextPanel-RniXr46u.js → ContextPanel-DuDbecdJ.js} +3 -3
- package/dist/assets/{DiagramView-BwNKhbsZ.js → DiagramView-41vCtWQO.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-wJgiOdis.js → MarkdownRendererImpl-4wf_NyPi.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-Dl6KVNYY.js → MobileDetailNavigation-BZ5-chaJ.js} +1 -1
- package/dist/assets/{MobileShareBridge-BxR0pE_m.js → MobileShareBridge-DZ4_UDPx.js} +1 -1
- package/dist/assets/{MobileSurface-LhR2YNCl.js → MobileSurface-DO07H7VN.js} +1 -1
- package/dist/assets/{MultiRunWindow-Cgg9u8VX.js → MultiRunWindow-CwyGNg6E.js} +1 -1
- package/dist/assets/{SettingsView-CBtDTnbu.js → SettingsView-eTWUs3mr.js} +1 -1
- package/dist/assets/{SettingsWindow-kBA9auJC.js → SettingsWindow-CWTIj5qT.js} +1 -1
- package/dist/assets/{TerminalView-DhVu4pCB.js → TerminalView-CRihEF7B.js} +1 -1
- package/dist/assets/{ToolOutputDialog-cDDD9uCL.js → ToolOutputDialog-BPXKhRBa.js} +1 -1
- package/dist/assets/{appThemeRegistry-staJMB2J.js → appThemeRegistry-BiAR1i_S.js} +2 -2
- package/dist/assets/{gitApi-CYDWoaIF.js → gitApi-BWk11cqj.js} +1 -1
- package/dist/assets/{insertionBoundaries-DbiBrF9p.js → insertionBoundaries-CNPI5E9-.js} +1 -1
- package/dist/assets/{main-cvGY60fq.js → main-8PESnWMg.js} +2 -2
- package/dist/assets/{main-Dkf8xb0G.js → main-CxMNCA6O.js} +4 -4
- package/dist/assets/{miniChat-Cz1ftAm8.js → miniChat-Buff55v8.js} +2 -2
- package/dist/assets/{mobile-C6Di4aP0.js → mobile-DdwSIsqR.js} +2 -2
- package/dist/assets/{multirun-Dp0YasTz.js → multirun-DicMqzmS.js} +1 -1
- package/dist/assets/{projectMeta-CVORtKSv.js → projectMeta-YlBX0ZsF.js} +1 -1
- package/dist/assets/{radio-BwGc_8vf.js → radio-BGL1w8gp.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-DovEohIn.js → renderElectronMiniChatApp-CKpo0WpH.js} +2 -2
- package/dist/assets/renderMobileApp-BhjxKg9G.js +23 -0
- package/dist/assets/{runtimeConfig-ClXahK8y.js → runtimeConfig-tjqLOQnJ.js} +1 -1
- package/dist/assets/{runtimeEndpointReset-Wd0jJx7c.js → runtimeEndpointReset-DCb37IOY.js} +1 -1
- package/dist/assets/{shikiHighlight-rNaL87ld.js → shikiHighlight-CWn7NoHc.js} +1 -1
- package/dist/assets/{src-Dc5_NNo2.css → src-DeaNVOre.css} +1 -1
- package/dist/assets/{useAppFontEffects-BgJOiL9O.js → useAppFontEffects-C1okpzsV.js} +2 -2
- package/dist/assets/{useDesktopWindowControlsLayout-BUMOckKt.js → useDesktopWindowControlsLayout-BNh1BzFG.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-DmcTREeQ.js → useEffectiveDirectory-BwY1B5mw.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-B5TOw3Mw.js → useMobileNavigationStore-DjEE6WNI.js} +1 -1
- package/dist/assets/{useSessionAutoCleanup-CZdJsOtU.js → useSessionAutoCleanup-3tZ8BVvw.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-DaZyFcgv.js → useWorkerHighlightedLines-CJqDVMqE.js} +1 -1
- package/dist/index.html +3 -3
- package/dist/mini-chat.html +3 -3
- package/dist/mobile.html +3 -3
- package/package.json +1 -1
- package/server/lib/notifications/APNS.md +53 -38
- package/server/lib/notifications/DOCUMENTATION.md +5 -4
- package/server/lib/notifications/apns-runtime.js +139 -69
- package/server/lib/notifications/apns-runtime.test.js +288 -0
- package/server/lib/notifications/apns-titles.js +212 -0
- package/server/lib/notifications/apns-titles.test.js +55 -0
- package/server/lib/notifications/routes.js +2 -1
- package/server/lib/notifications/runtime.js +5 -13
- package/server/lib/relay/DOCUMENTATION.md +2 -2
- package/server/lib/relay/signing-key.js +1 -1
- 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
|
|
6
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
|
412
|
-
// each user's server — so users configure nothing. The
|
|
413
|
-
// generic text;
|
|
414
|
-
// is the fallback for self-hosters who set OPENCHAMBER_APNS_*
|
|
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())
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
tokens,
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
|
545
|
-
entries
|
|
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
|
|
565
|
-
uniqueEntries
|
|
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
|
|
568
|
-
}
|
|
639
|
+
return registered ? entry : null;
|
|
640
|
+
},
|
|
569
641
|
);
|
|
570
|
-
const
|
|
642
|
+
const readyEntries = [];
|
|
571
643
|
const readySeen = new Set();
|
|
572
|
-
for (const
|
|
573
|
-
if (!
|
|
574
|
-
readySeen.add(
|
|
575
|
-
|
|
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
|
|
582
|
-
const
|
|
583
|
-
|
|
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
|
});
|