@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.
- package/dist/assets/{AssistantView-DKkGbIaN.js → AssistantView-BGHsUIcD.js} +1 -1
- package/dist/assets/{ChatContainer-BV1_DKRK.js → ChatContainer-lPNgvmdy.js} +31 -31
- package/dist/assets/{ContextPanel-y2-b_Z2K.js → ContextPanel-DuDbecdJ.js} +3 -3
- package/dist/assets/{DiagramView-BQynaQK8.js → DiagramView-41vCtWQO.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-BLuEiJfe.js → MarkdownRendererImpl-4wf_NyPi.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-DvQ7TyBM.js → MobileDetailNavigation-BZ5-chaJ.js} +1 -1
- package/dist/assets/{MobileShareBridge-CuVKbp4w.js → MobileShareBridge-DZ4_UDPx.js} +1 -1
- package/dist/assets/{MobileSurface-Ckqf46Ht.js → MobileSurface-DO07H7VN.js} +1 -1
- package/dist/assets/{MultiRunWindow-CryWGQm3.js → MultiRunWindow-CwyGNg6E.js} +1 -1
- package/dist/assets/{SettingsView-4Qx32CM5.js → SettingsView-eTWUs3mr.js} +1 -1
- package/dist/assets/{SettingsWindow-5o4fZ7Mm.js → SettingsWindow-CWTIj5qT.js} +1 -1
- package/dist/assets/{TerminalView-BcACDBI2.js → TerminalView-CRihEF7B.js} +1 -1
- package/dist/assets/{ToolOutputDialog-DPz8gn8_.js → ToolOutputDialog-BPXKhRBa.js} +1 -1
- package/dist/assets/{appThemeRegistry-5J_p1u_I.js → appThemeRegistry-BiAR1i_S.js} +2 -2
- package/dist/assets/{gitApi-iEiu2XFG.js → gitApi-BWk11cqj.js} +1 -1
- package/dist/assets/{insertionBoundaries-0eRxJVW6.js → insertionBoundaries-CNPI5E9-.js} +1 -1
- package/dist/assets/{main-BLhg2XWt.js → main-8PESnWMg.js} +2 -2
- package/dist/assets/{main-eSewAXup.js → main-CxMNCA6O.js} +4 -4
- package/dist/assets/{miniChat-D1-a2j3X.js → miniChat-Buff55v8.js} +2 -2
- package/dist/assets/{mobile-Ci7TpzO5.js → mobile-DdwSIsqR.js} +2 -2
- package/dist/assets/{multirun-DSpk4Zg7.js → multirun-DicMqzmS.js} +1 -1
- package/dist/assets/{projectMeta-BiPtYEsc.js → projectMeta-YlBX0ZsF.js} +1 -1
- package/dist/assets/{radio-DHDmOqeF.js → radio-BGL1w8gp.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-C_TDVAS5.js → renderElectronMiniChatApp-CKpo0WpH.js} +2 -2
- package/dist/assets/renderMobileApp-BhjxKg9G.js +23 -0
- package/dist/assets/{runtimeConfig-D40WiKD4.js → runtimeConfig-tjqLOQnJ.js} +1 -1
- package/dist/assets/{runtimeEndpointReset-N20BnvKg.js → runtimeEndpointReset-DCb37IOY.js} +1 -1
- package/dist/assets/{shikiHighlight-DSolaZz5.js → shikiHighlight-CWn7NoHc.js} +1 -1
- package/dist/assets/{src-ILRsQd_W.css → src-DeaNVOre.css} +1 -1
- package/dist/assets/{useAppFontEffects-DovmEWUn.js → useAppFontEffects-C1okpzsV.js} +2 -2
- package/dist/assets/{useDesktopWindowControlsLayout-uYmYg3ee.js → useDesktopWindowControlsLayout-BNh1BzFG.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-lReAbPFb.js → useEffectiveDirectory-BwY1B5mw.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-BKVdzZVI.js → useMobileNavigationStore-DjEE6WNI.js} +1 -1
- package/dist/assets/{useSessionAutoCleanup-BkX_0XVu.js → useSessionAutoCleanup-3tZ8BVvw.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-DFOYREuh.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/index.js +8 -1
- package/server/lib/notifications/APNS.md +53 -38
- package/server/lib/notifications/DOCUMENTATION.md +5 -4
- package/server/lib/notifications/apns-runtime.js +235 -71
- package/server/lib/notifications/apns-runtime.test.js +447 -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/service.js +45 -3
- package/server/lib/relay/service.test.js +117 -0
- package/server/lib/relay/signing-key.js +1 -1
- 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
|
|
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
|
|
@@ -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
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
|
367
|
-
// each user's server — so users configure nothing. The
|
|
368
|
-
// generic text;
|
|
369
|
-
// is the fallback for self-hosters who set OPENCHAMBER_APNS_*
|
|
370
|
-
|
|
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
|
|
373
|
-
return
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
tokens,
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
|
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
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
623
|
+
return groups;
|
|
624
|
+
};
|
|
489
625
|
|
|
490
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|