@openchambery/web 1.19.3-beta.4 → 1.19.3-beta.6
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-C5rdZZo5.js → AssistantView-CPF6Nfb6.js} +1 -1
- package/dist/assets/{ChatContainer-BAnhC8Xu.js → ChatContainer-CldTRJmB.js} +5 -5
- package/dist/assets/{ContextPanel-DmmmVTxg.js → ContextPanel-BJnRefSW.js} +3 -3
- package/dist/assets/{DiagramView-fKtYRmMy.js → DiagramView-Co5-JMSr.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-DBwTTIyb.js → MarkdownRendererImpl-Cx10d-dT.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-Ci8iQd1U.js → MobileDetailNavigation-ZJtJMDT3.js} +1 -1
- package/dist/assets/{MobileShareBridge-COlraBE5.js → MobileShareBridge-3NBuOdWs.js} +1 -1
- package/dist/assets/{MobileSurface-6AySCP_w.js → MobileSurface-BZQDdrqA.js} +1 -1
- package/dist/assets/{MultiRunWindow-CtzIc5H5.js → MultiRunWindow-BY2_dGd_.js} +1 -1
- package/dist/assets/{SettingsView-DplP6pCo.js → SettingsView-BsnYPviY.js} +1 -1
- package/dist/assets/{SettingsWindow-BstGL5r4.js → SettingsWindow-DT92dg19.js} +1 -1
- package/dist/assets/{TerminalView-Dzbpn3Mh.js → TerminalView-uqPJOhzP.js} +1 -1
- package/dist/assets/{ToolOutputDialog-iNhoUbun.js → ToolOutputDialog-_9sExuXj.js} +1 -1
- package/dist/assets/{appThemeRegistry-ChLdmqHp.js → appThemeRegistry-DL8zx2Tf.js} +2 -2
- package/dist/assets/{gitApi-16lIt8-p.js → gitApi-C5a0FHg7.js} +1 -1
- package/dist/assets/{insertionBoundaries-DxxTBrpg.js → insertionBoundaries-CN8Y824W.js} +1 -1
- package/dist/assets/{main-CblkZ_8-.js → main-BOEazbCD.js} +3 -3
- package/dist/assets/{main-0dycOVhR.js → main-ej4kLP0y.js} +2 -2
- package/dist/assets/{miniChat-CWYbXUX6.js → miniChat-h4TCod13.js} +2 -2
- package/dist/assets/{mobile-DVpq6s-q.js → mobile-D3ev3kF7.js} +2 -2
- package/dist/assets/{multirun-DzCqDyyy.js → multirun-Bb6ZUBgz.js} +1 -1
- package/dist/assets/{projectMeta-vDIfgkrT.js → projectMeta-DAnaHeES.js} +1 -1
- package/dist/assets/{radio-Cyoobx07.js → radio-iuo8w3_4.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-BERJygjo.js → renderElectronMiniChatApp-aIjl55lT.js} +2 -2
- package/dist/assets/{renderMobileApp-DVGzx3FV.js → renderMobileApp-MTX_4lox.js} +5 -5
- package/dist/assets/runtimeConfig-DY2RRqNN.js +2 -0
- package/dist/assets/{runtimeEndpointReset-DQJpeX9Y.js → runtimeEndpointReset-CM9B-gNb.js} +1 -1
- package/dist/assets/{shikiHighlight-CBJY8Gag.js → shikiHighlight-jCavhi4H.js} +1 -1
- package/dist/assets/{useAppFontEffects-J5k1Vhy8.js → useAppFontEffects-I4H3yYVm.js} +2 -2
- package/dist/assets/{useDesktopWindowControlsLayout-xXnfxmE2.js → useDesktopWindowControlsLayout-DGrq4UFu.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-DQC0Tb3_.js → useEffectiveDirectory-Cf6WHNi9.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-DLXqazM8.js → useMobileNavigationStore-9NzHuLJl.js} +1 -1
- package/dist/assets/{useSessionAutoCleanup-C5gzJHC4.js → useSessionAutoCleanup-QOfL2Fdn.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-DM2pcpju.js → useWorkerHighlightedLines-NWmW5faW.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/mini-chat.html +2 -2
- package/dist/mobile.html +2 -2
- package/package.json +1 -1
- package/server/index.js +7 -0
- package/server/lib/notifications/APNS.md +35 -0
- package/server/lib/notifications/DOCUMENTATION.md +8 -2
- package/server/lib/notifications/apns-runtime.js +558 -7
- package/server/lib/notifications/apns-runtime.test.js +475 -0
- package/server/lib/notifications/routes.js +55 -0
- package/server/lib/notifications/runtime.js +15 -4
- package/server/lib/notifications/runtime.test.js +108 -0
- package/server/lib/opencode/bootstrap-runtime.js +6 -0
- package/server/lib/opencode/bootstrap-runtime.test.js +21 -0
- package/dist/assets/runtimeConfig-wlHzE-5Y.js +0 -2
|
@@ -21,16 +21,27 @@ import {
|
|
|
21
21
|
normalizeApnsLocale,
|
|
22
22
|
} from './apns-titles.js';
|
|
23
23
|
|
|
24
|
-
const APNS_TOKENS_VERSION =
|
|
24
|
+
const APNS_TOKENS_VERSION = 2;
|
|
25
|
+
const APNS_TOKENS_SUPPORTED_VERSIONS = new Set([1, APNS_TOKENS_VERSION]);
|
|
25
26
|
const APNS_HOST_PRODUCTION = 'https://api.push.apple.com';
|
|
26
27
|
const APNS_HOST_SANDBOX = 'https://api.sandbox.push.apple.com';
|
|
27
28
|
// APNs rejects auth tokens older than 1h; refresh well inside that window.
|
|
28
29
|
const JWT_TTL_MS = 50 * 60 * 1000;
|
|
29
30
|
const DEFAULT_BUNDLE_ID = 'com.yee94.openchamber';
|
|
30
31
|
const MAX_TOKENS_PER_SESSION = 10;
|
|
32
|
+
const MAX_LIVE_ACTIVITY_TOKENS_PER_SESSION = 8;
|
|
33
|
+
const MAX_LIVE_ACTIVITY_TOKEN_CHARS = 512;
|
|
34
|
+
const MAX_LIVE_ACTIVITY_ID_CHARS = 128;
|
|
35
|
+
const LIVE_ACTIVITY_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
|
|
36
|
+
const LIVE_ACTIVITY_COMPLETE_DISMISSAL_SECONDS = 15 * 60;
|
|
37
|
+
const LIVE_ACTIVITY_ERROR_DISMISSAL_SECONDS = 60 * 60;
|
|
38
|
+
const LIVE_ACTIVITY_STALE_SECONDS = 20 * 60;
|
|
31
39
|
const MAX_RELAY_SEND_TOKENS = 100;
|
|
32
40
|
const RELAY_REGISTER_CONCURRENCY = 16;
|
|
33
41
|
const APNS_COLLAPSE_ID_MAX_BYTES = 64;
|
|
42
|
+
const LIVE_ACTIVITY_KIND = 'liveactivity';
|
|
43
|
+
const LIVE_ACTIVITY_PLATFORM = 'ios';
|
|
44
|
+
const LIVE_ACTIVITY_TERMINAL_STATUSES = new Set(['complete', 'error']);
|
|
34
45
|
// APNs reasons that mean the token is permanently invalid → drop it.
|
|
35
46
|
const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic']);
|
|
36
47
|
|
|
@@ -82,6 +93,8 @@ export const createApnsRuntime = (deps) => {
|
|
|
82
93
|
let warnedUnconfigured = false;
|
|
83
94
|
let registrationSuccessCache = new Map();
|
|
84
95
|
let registrationCacheRegisterUrl = null;
|
|
96
|
+
let liveActivityRegistrationSuccessCache = new Map();
|
|
97
|
+
let liveActivityRegistrationCacheRegisterUrl = null;
|
|
85
98
|
|
|
86
99
|
// ---------------------------------------------------------------------------
|
|
87
100
|
// Per-server relay signing identity (ECDSA P-256). Auto-generated + persisted in settings
|
|
@@ -117,6 +130,18 @@ export const createApnsRuntime = (deps) => {
|
|
|
117
130
|
registrationCacheRegisterUrl = registerUrl;
|
|
118
131
|
};
|
|
119
132
|
|
|
133
|
+
const rememberLiveActivityRelayRegisterUrl = (registerUrl) => {
|
|
134
|
+
if (liveActivityRegistrationCacheRegisterUrl === registerUrl) return;
|
|
135
|
+
liveActivityRegistrationSuccessCache = new Map();
|
|
136
|
+
liveActivityRegistrationCacheRegisterUrl = registerUrl;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const liveActivityRegistrationCacheKey = (registerUrl, token) =>
|
|
140
|
+
`${registerUrl}\0${token}\0${LIVE_ACTIVITY_PLATFORM}\0${LIVE_ACTIVITY_KIND}`;
|
|
141
|
+
|
|
142
|
+
const isLimitedId = (value, maxChars) =>
|
|
143
|
+
typeof value === 'string' && value.length > 0 && value.length <= maxChars;
|
|
144
|
+
|
|
120
145
|
const boundRegistrationCache = (registerUrl, entries) => {
|
|
121
146
|
rememberRelayRegisterUrl(registerUrl);
|
|
122
147
|
const allowed = new Set(
|
|
@@ -155,22 +180,111 @@ export const createApnsRuntime = (deps) => {
|
|
|
155
180
|
}
|
|
156
181
|
};
|
|
157
182
|
|
|
183
|
+
const registerLiveActivityTokenWithRelay = async (token, relayOverride) => {
|
|
184
|
+
const relay = relayOverride ?? (await resolveRelayConfig());
|
|
185
|
+
if (!relay) return true;
|
|
186
|
+
rememberLiveActivityRelayRegisterUrl(relay.liveActivityRegisterUrl);
|
|
187
|
+
const cacheKey = liveActivityRegistrationCacheKey(relay.liveActivityRegisterUrl, token);
|
|
188
|
+
if (liveActivityRegistrationSuccessCache.has(cacheKey)) return true;
|
|
189
|
+
try {
|
|
190
|
+
const { privateKey, publicJwk } = await getOrCreateRelayKeypair();
|
|
191
|
+
const ts = Date.now();
|
|
192
|
+
const sig = signRelayMessage(
|
|
193
|
+
privateKey,
|
|
194
|
+
`${ts}.${token}.${LIVE_ACTIVITY_PLATFORM}.${LIVE_ACTIVITY_KIND}`,
|
|
195
|
+
);
|
|
196
|
+
const res = await fetch(relay.liveActivityRegisterUrl, {
|
|
197
|
+
method: 'POST',
|
|
198
|
+
headers: { 'content-type': 'application/json' },
|
|
199
|
+
body: JSON.stringify({
|
|
200
|
+
token,
|
|
201
|
+
platform: LIVE_ACTIVITY_PLATFORM,
|
|
202
|
+
kind: LIVE_ACTIVITY_KIND,
|
|
203
|
+
publicKeyJwk: relayPublicJwk(publicJwk),
|
|
204
|
+
ts,
|
|
205
|
+
sig,
|
|
206
|
+
}),
|
|
207
|
+
});
|
|
208
|
+
if (!res.ok) {
|
|
209
|
+
console.warn(`[Push relay] register-live-activity-token failed status=${res.status}`);
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
liveActivityRegistrationSuccessCache.set(cacheKey, true);
|
|
213
|
+
return true;
|
|
214
|
+
} catch (error) {
|
|
215
|
+
console.warn('[Push relay] register-live-activity-token request failed:', error?.message ?? error);
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const unregisterLiveActivityTokenWithRelay = async (token) => {
|
|
221
|
+
const relay = await resolveRelayConfig();
|
|
222
|
+
if (!relay || !isLimitedId(token, MAX_LIVE_ACTIVITY_TOKEN_CHARS)) return;
|
|
223
|
+
rememberLiveActivityRelayRegisterUrl(relay.liveActivityRegisterUrl);
|
|
224
|
+
liveActivityRegistrationSuccessCache.delete(
|
|
225
|
+
liveActivityRegistrationCacheKey(relay.liveActivityRegisterUrl, token),
|
|
226
|
+
);
|
|
227
|
+
try {
|
|
228
|
+
const { privateKey, publicJwk } = await getOrCreateRelayKeypair();
|
|
229
|
+
const ts = Date.now();
|
|
230
|
+
const sig = signRelayMessage(
|
|
231
|
+
privateKey,
|
|
232
|
+
`${ts}.${token}.${LIVE_ACTIVITY_PLATFORM}.${LIVE_ACTIVITY_KIND}`,
|
|
233
|
+
);
|
|
234
|
+
const res = await fetch(relay.liveActivityUnregisterUrl, {
|
|
235
|
+
method: 'POST',
|
|
236
|
+
headers: { 'content-type': 'application/json' },
|
|
237
|
+
body: JSON.stringify({
|
|
238
|
+
token,
|
|
239
|
+
platform: LIVE_ACTIVITY_PLATFORM,
|
|
240
|
+
kind: LIVE_ACTIVITY_KIND,
|
|
241
|
+
publicKeyJwk: relayPublicJwk(publicJwk),
|
|
242
|
+
ts,
|
|
243
|
+
sig,
|
|
244
|
+
}),
|
|
245
|
+
});
|
|
246
|
+
if (!res.ok) {
|
|
247
|
+
console.warn(`[Push relay] unregister-live-activity-token failed status=${res.status}`);
|
|
248
|
+
}
|
|
249
|
+
} catch (error) {
|
|
250
|
+
console.warn('[Push relay] unregister-live-activity-token request failed:', error?.message ?? error);
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
158
254
|
// ---------------------------------------------------------------------------
|
|
159
255
|
// Token persistence (same shape + write-lock pattern as push-runtime.js)
|
|
160
256
|
// ---------------------------------------------------------------------------
|
|
161
257
|
|
|
162
|
-
const emptyStore = () => ({
|
|
258
|
+
const emptyStore = () => ({
|
|
259
|
+
version: APNS_TOKENS_VERSION,
|
|
260
|
+
tokensBySession: {},
|
|
261
|
+
liveActivityTokensBySession: {},
|
|
262
|
+
liveActivityEventVersions: {},
|
|
263
|
+
});
|
|
163
264
|
|
|
164
265
|
const readTokensFromDisk = async () => {
|
|
165
266
|
try {
|
|
166
267
|
const raw = await fsPromises.readFile(APNS_TOKENS_FILE_PATH, 'utf8');
|
|
167
268
|
const parsed = JSON.parse(raw);
|
|
168
|
-
if (!parsed || typeof parsed !== 'object' || parsed.version
|
|
269
|
+
if (!parsed || typeof parsed !== 'object' || !APNS_TOKENS_SUPPORTED_VERSIONS.has(parsed.version)) {
|
|
169
270
|
return emptyStore();
|
|
170
271
|
}
|
|
171
272
|
const tokensBySession =
|
|
172
273
|
parsed.tokensBySession && typeof parsed.tokensBySession === 'object' ? parsed.tokensBySession : {};
|
|
173
|
-
|
|
274
|
+
const liveActivityTokensBySession =
|
|
275
|
+
parsed.liveActivityTokensBySession && typeof parsed.liveActivityTokensBySession === 'object'
|
|
276
|
+
? parsed.liveActivityTokensBySession
|
|
277
|
+
: {};
|
|
278
|
+
const liveActivityEventVersions =
|
|
279
|
+
parsed.liveActivityEventVersions && typeof parsed.liveActivityEventVersions === 'object'
|
|
280
|
+
? parsed.liveActivityEventVersions
|
|
281
|
+
: {};
|
|
282
|
+
return {
|
|
283
|
+
version: APNS_TOKENS_VERSION,
|
|
284
|
+
tokensBySession,
|
|
285
|
+
liveActivityTokensBySession,
|
|
286
|
+
liveActivityEventVersions,
|
|
287
|
+
};
|
|
174
288
|
} catch (error) {
|
|
175
289
|
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
|
176
290
|
return emptyStore();
|
|
@@ -188,9 +302,21 @@ export const createApnsRuntime = (deps) => {
|
|
|
188
302
|
const persistTokenUpdate = async (mutate) => {
|
|
189
303
|
persistLock = persistLock.then(async () => {
|
|
190
304
|
const current = await readTokensFromDisk();
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
305
|
+
const base = {
|
|
306
|
+
version: APNS_TOKENS_VERSION,
|
|
307
|
+
tokensBySession: current.tokensBySession || {},
|
|
308
|
+
liveActivityTokensBySession: current.liveActivityTokensBySession || {},
|
|
309
|
+
liveActivityEventVersions: current.liveActivityEventVersions || {},
|
|
310
|
+
};
|
|
311
|
+
const next = mutate(base);
|
|
312
|
+
const written = {
|
|
313
|
+
version: APNS_TOKENS_VERSION,
|
|
314
|
+
tokensBySession: next.tokensBySession || {},
|
|
315
|
+
liveActivityTokensBySession: next.liveActivityTokensBySession ?? base.liveActivityTokensBySession,
|
|
316
|
+
liveActivityEventVersions: next.liveActivityEventVersions ?? base.liveActivityEventVersions,
|
|
317
|
+
};
|
|
318
|
+
await writeTokensToDisk(written);
|
|
319
|
+
return written;
|
|
194
320
|
});
|
|
195
321
|
return persistLock;
|
|
196
322
|
};
|
|
@@ -216,6 +342,44 @@ export const createApnsRuntime = (deps) => {
|
|
|
216
342
|
.filter(Boolean);
|
|
217
343
|
};
|
|
218
344
|
|
|
345
|
+
const normalizeLiveActivityTokens = (record, now = Date.now()) => {
|
|
346
|
+
if (!Array.isArray(record)) return [];
|
|
347
|
+
return record
|
|
348
|
+
.map((entry) => {
|
|
349
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
350
|
+
const token = typeof entry.token === 'string' ? entry.token.trim() : '';
|
|
351
|
+
const activityId = typeof entry.activityId === 'string' ? entry.activityId.trim() : '';
|
|
352
|
+
const sessionId = typeof entry.sessionId === 'string' ? entry.sessionId.trim() : '';
|
|
353
|
+
if (!isLimitedId(token, MAX_LIVE_ACTIVITY_TOKEN_CHARS)) return null;
|
|
354
|
+
if (!isLimitedId(activityId, MAX_LIVE_ACTIVITY_ID_CHARS)) return null;
|
|
355
|
+
if (!isLimitedId(sessionId, MAX_LIVE_ACTIVITY_ID_CHARS)) return null;
|
|
356
|
+
const createdAt = typeof entry.createdAt === 'number' ? entry.createdAt : null;
|
|
357
|
+
const lastSeenAt = typeof entry.lastSeenAt === 'number' ? entry.lastSeenAt : createdAt;
|
|
358
|
+
if (typeof lastSeenAt === 'number' && now - lastSeenAt > LIVE_ACTIVITY_TOKEN_TTL_MS) return null;
|
|
359
|
+
return { token, activityId, sessionId, createdAt, lastSeenAt };
|
|
360
|
+
})
|
|
361
|
+
.filter(Boolean);
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const pruneLiveActivityTokensBySession = (liveActivityTokensBySession, now = Date.now()) => {
|
|
365
|
+
const next = {};
|
|
366
|
+
for (const [uiSessionToken, record] of Object.entries(liveActivityTokensBySession || {})) {
|
|
367
|
+
const entries = normalizeLiveActivityTokens(record, now);
|
|
368
|
+
if (entries.length > 0) next[uiSessionToken] = entries;
|
|
369
|
+
}
|
|
370
|
+
return next;
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const countLiveActivityTokenCopies = (liveActivityTokensBySession, token) => {
|
|
374
|
+
let count = 0;
|
|
375
|
+
for (const record of Object.values(liveActivityTokensBySession || {})) {
|
|
376
|
+
for (const entry of record) {
|
|
377
|
+
if (entry?.token === token) count += 1;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return count;
|
|
381
|
+
};
|
|
382
|
+
|
|
219
383
|
// Normalize an incoming platform hint to the two we support; default to APNs/iOS since that
|
|
220
384
|
// was the only registrant before Android/FCM existed.
|
|
221
385
|
const normalizePlatform = (platform) => (platform === 'android' ? 'android' : 'ios');
|
|
@@ -300,6 +464,89 @@ export const createApnsRuntime = (deps) => {
|
|
|
300
464
|
});
|
|
301
465
|
};
|
|
302
466
|
|
|
467
|
+
const addOrUpdateLiveActivityToken = async (uiSessionToken, token, activityId, sessionId) => {
|
|
468
|
+
if (!uiSessionToken) return;
|
|
469
|
+
const trimmedToken = typeof token === 'string' ? token.trim() : '';
|
|
470
|
+
const trimmedActivityId = typeof activityId === 'string' ? activityId.trim() : '';
|
|
471
|
+
const trimmedSessionId = typeof sessionId === 'string' ? sessionId.trim() : '';
|
|
472
|
+
if (!isLimitedId(trimmedToken, MAX_LIVE_ACTIVITY_TOKEN_CHARS)) return;
|
|
473
|
+
if (!isLimitedId(trimmedActivityId, MAX_LIVE_ACTIVITY_ID_CHARS)) return;
|
|
474
|
+
if (!isLimitedId(trimmedSessionId, MAX_LIVE_ACTIVITY_ID_CHARS)) return;
|
|
475
|
+
const now = Date.now();
|
|
476
|
+
let replacedToken = null;
|
|
477
|
+
|
|
478
|
+
await persistTokenUpdate((current) => {
|
|
479
|
+
const liveActivityTokensBySession = pruneLiveActivityTokensBySession(
|
|
480
|
+
current.liveActivityTokensBySession,
|
|
481
|
+
now,
|
|
482
|
+
);
|
|
483
|
+
const existing = liveActivityTokensBySession[uiSessionToken] || [];
|
|
484
|
+
const replaced = existing.find((entry) => entry.activityId === trimmedActivityId);
|
|
485
|
+
if (replaced && replaced.token !== trimmedToken) replacedToken = replaced.token;
|
|
486
|
+
const filtered = existing.filter(
|
|
487
|
+
(entry) => entry.activityId !== trimmedActivityId && entry.token !== trimmedToken,
|
|
488
|
+
);
|
|
489
|
+
filtered.unshift({
|
|
490
|
+
token: trimmedToken,
|
|
491
|
+
activityId: trimmedActivityId,
|
|
492
|
+
sessionId: trimmedSessionId,
|
|
493
|
+
createdAt: typeof replaced?.createdAt === 'number' ? replaced.createdAt : now,
|
|
494
|
+
lastSeenAt: now,
|
|
495
|
+
});
|
|
496
|
+
liveActivityTokensBySession[uiSessionToken] = filtered.slice(0, MAX_LIVE_ACTIVITY_TOKENS_PER_SESSION);
|
|
497
|
+
return { ...current, liveActivityTokensBySession };
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
await registerLiveActivityTokenWithRelay(trimmedToken);
|
|
501
|
+
if (replacedToken) {
|
|
502
|
+
const store = await readTokensFromDisk();
|
|
503
|
+
if (countLiveActivityTokenCopies(store.liveActivityTokensBySession, replacedToken) === 0) {
|
|
504
|
+
await unregisterLiveActivityTokenWithRelay(replacedToken);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const removeLiveActivityToken = async (uiSessionToken, token) => {
|
|
510
|
+
if (!uiSessionToken || typeof token !== 'string' || token.length === 0) return;
|
|
511
|
+
let removed = false;
|
|
512
|
+
await persistTokenUpdate((current) => {
|
|
513
|
+
const liveActivityTokensBySession = pruneLiveActivityTokensBySession(
|
|
514
|
+
current.liveActivityTokensBySession,
|
|
515
|
+
);
|
|
516
|
+
const existing = liveActivityTokensBySession[uiSessionToken] || [];
|
|
517
|
+
const filtered = existing.filter((entry) => {
|
|
518
|
+
if (entry.token !== token) return true;
|
|
519
|
+
removed = true;
|
|
520
|
+
return false;
|
|
521
|
+
});
|
|
522
|
+
if (filtered.length === 0) delete liveActivityTokensBySession[uiSessionToken];
|
|
523
|
+
else liveActivityTokensBySession[uiSessionToken] = filtered;
|
|
524
|
+
return { ...current, liveActivityTokensBySession };
|
|
525
|
+
});
|
|
526
|
+
if (!removed) return;
|
|
527
|
+
const store = await readTokensFromDisk();
|
|
528
|
+
if (countLiveActivityTokenCopies(store.liveActivityTokensBySession, token) === 0) {
|
|
529
|
+
await unregisterLiveActivityTokenWithRelay(token);
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
const removeLiveActivityTokens = async (tokensToRemove) => {
|
|
534
|
+
const unique = [...new Set((tokensToRemove || []).filter((token) => typeof token === 'string' && token.length > 0))];
|
|
535
|
+
if (unique.length === 0) return;
|
|
536
|
+
const drop = new Set(unique);
|
|
537
|
+
await persistTokenUpdate((current) => {
|
|
538
|
+
const liveActivityTokensBySession = pruneLiveActivityTokensBySession(
|
|
539
|
+
current.liveActivityTokensBySession,
|
|
540
|
+
);
|
|
541
|
+
for (const [uiSessionToken, entries] of Object.entries(liveActivityTokensBySession)) {
|
|
542
|
+
const filtered = entries.filter((entry) => !drop.has(entry.token));
|
|
543
|
+
if (filtered.length === 0) delete liveActivityTokensBySession[uiSessionToken];
|
|
544
|
+
else liveActivityTokensBySession[uiSessionToken] = filtered;
|
|
545
|
+
}
|
|
546
|
+
return { ...current, liveActivityTokensBySession };
|
|
547
|
+
});
|
|
548
|
+
};
|
|
549
|
+
|
|
303
550
|
// ---------------------------------------------------------------------------
|
|
304
551
|
// Config (env first, then settings.apnsConfig) — mirrors resolveVapidSubject
|
|
305
552
|
// ---------------------------------------------------------------------------
|
|
@@ -459,6 +706,9 @@ export const createApnsRuntime = (deps) => {
|
|
|
459
706
|
const buildRelayConfig = (url) => ({
|
|
460
707
|
url,
|
|
461
708
|
registerUrl: url.replace(/\/send$/, '/register-token'),
|
|
709
|
+
liveActivityRegisterUrl: url.replace(/\/send$/, '/register-live-activity-token'),
|
|
710
|
+
liveActivityUnregisterUrl: url.replace(/\/send$/, '/unregister-live-activity-token'),
|
|
711
|
+
liveActivitySendUrl: url.replace(/\/send$/, '/live-activity'),
|
|
462
712
|
environment:
|
|
463
713
|
(trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || 'sandbox').toLowerCase() === 'production'
|
|
464
714
|
? 'production'
|
|
@@ -578,6 +828,304 @@ export const createApnsRuntime = (deps) => {
|
|
|
578
828
|
});
|
|
579
829
|
};
|
|
580
830
|
|
|
831
|
+
const toLiveActivityUnixSeconds = (value, fallbackMs) => {
|
|
832
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
833
|
+
return value > 1e12 ? value / 1000 : value;
|
|
834
|
+
}
|
|
835
|
+
return fallbackMs / 1000;
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
const nextLiveActivityEventVersion = (nowMs, previous, provided) => {
|
|
839
|
+
const prior = typeof previous === 'number' && Number.isFinite(previous) ? previous : 0;
|
|
840
|
+
let next = Math.max(Math.floor(nowMs), prior + 1);
|
|
841
|
+
if (typeof provided === 'number' && Number.isFinite(provided) && provided > 0) {
|
|
842
|
+
next = Math.max(next, Math.floor(provided));
|
|
843
|
+
}
|
|
844
|
+
return next;
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
const liveActivityDismissalDate = (status, nowMs) => {
|
|
848
|
+
const extra = status === 'error'
|
|
849
|
+
? LIVE_ACTIVITY_ERROR_DISMISSAL_SECONDS
|
|
850
|
+
: LIVE_ACTIVITY_COMPLETE_DISMISSAL_SECONDS;
|
|
851
|
+
return Math.floor(nowMs / 1000) + extra;
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
// End carries dismissalDate only. Update (not sent here) would set staleDate = updatedAt+20min.
|
|
855
|
+
const liveActivityPushDates = (event, status, updatedAtSeconds, nowMs) => {
|
|
856
|
+
if (event === 'update') return { staleDate: updatedAtSeconds + LIVE_ACTIVITY_STALE_SECONDS };
|
|
857
|
+
return { dismissalDate: liveActivityDismissalDate(status, nowMs) };
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
const liveActivityRelaySignMessage = ({ ts, tokens, event, contentState, dismissalDate, staleDate }) => (
|
|
861
|
+
`${ts}.${[...tokens].sort().join(',')}.${event}.${contentState.status}.${contentState.eventVersion}.${contentState.updatedAt}.${contentState.endedAt ?? ''}.${dismissalDate ?? ''}.${staleDate ?? ''}`
|
|
862
|
+
);
|
|
863
|
+
|
|
864
|
+
const sendLiveActivityViaRelay = async (deviceTokens, payload, relay) => {
|
|
865
|
+
if (!Array.isArray(deviceTokens) || deviceTokens.length === 0) return [];
|
|
866
|
+
const accepted = [];
|
|
867
|
+
const { privateKey, publicJwk } = await getOrCreateRelayKeypair();
|
|
868
|
+
for (let start = 0; start < deviceTokens.length; start += MAX_RELAY_SEND_TOKENS) {
|
|
869
|
+
const tokens = deviceTokens.slice(start, start + MAX_RELAY_SEND_TOKENS);
|
|
870
|
+
const ts = Date.now();
|
|
871
|
+
const sig = signRelayMessage(
|
|
872
|
+
privateKey,
|
|
873
|
+
liveActivityRelaySignMessage({
|
|
874
|
+
ts,
|
|
875
|
+
tokens,
|
|
876
|
+
event: payload.event,
|
|
877
|
+
contentState: payload.contentState,
|
|
878
|
+
dismissalDate: payload.dismissalDate,
|
|
879
|
+
staleDate: payload.staleDate,
|
|
880
|
+
}),
|
|
881
|
+
);
|
|
882
|
+
const requestBody = {
|
|
883
|
+
tokens,
|
|
884
|
+
event: payload.event,
|
|
885
|
+
contentState: payload.contentState,
|
|
886
|
+
env: relay.environment,
|
|
887
|
+
publicKeyJwk: relayPublicJwk(publicJwk),
|
|
888
|
+
ts,
|
|
889
|
+
sig,
|
|
890
|
+
};
|
|
891
|
+
if (payload.dismissalDate != null) requestBody.dismissalDate = payload.dismissalDate;
|
|
892
|
+
if (payload.staleDate != null) requestBody.staleDate = payload.staleDate;
|
|
893
|
+
try {
|
|
894
|
+
const res = await fetch(relay.liveActivitySendUrl, {
|
|
895
|
+
method: 'POST',
|
|
896
|
+
headers: { 'content-type': 'application/json' },
|
|
897
|
+
body: JSON.stringify(requestBody),
|
|
898
|
+
});
|
|
899
|
+
if (!res.ok) {
|
|
900
|
+
console.warn(`[APNs relay] live-activity send failed status=${res.status}`);
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
accepted.push(...tokens);
|
|
904
|
+
const data = await res.json().catch(() => null);
|
|
905
|
+
const results = Array.isArray(data?.results) ? data.results : [];
|
|
906
|
+
for (const result of results) {
|
|
907
|
+
if (result && result.drop === true && typeof result.token === 'string') {
|
|
908
|
+
await removeLiveActivityTokens([result.token]);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
} catch (error) {
|
|
912
|
+
console.warn('[APNs relay] live-activity request failed:', error?.message ?? error);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
return accepted;
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
const sendLiveActivityOne = (client, deviceToken, body, jwt, config) =>
|
|
919
|
+
new Promise((resolve) => {
|
|
920
|
+
const headers = {
|
|
921
|
+
':method': 'POST',
|
|
922
|
+
':path': `/3/device/${deviceToken}`,
|
|
923
|
+
authorization: `bearer ${jwt}`,
|
|
924
|
+
'apns-topic': `${config.bundleId}.push-type.liveactivity`,
|
|
925
|
+
'apns-push-type': 'liveactivity',
|
|
926
|
+
'apns-priority': '10',
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
let req;
|
|
930
|
+
try {
|
|
931
|
+
req = client.request(headers);
|
|
932
|
+
} catch (error) {
|
|
933
|
+
console.warn('[APNs] live-activity request open failed:', error?.message ?? error);
|
|
934
|
+
resolve({ token: deviceToken, accepted: false });
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
let status = 0;
|
|
939
|
+
let responseBody = '';
|
|
940
|
+
req.on('response', (resHeaders) => {
|
|
941
|
+
status = Number(resHeaders[':status']) || 0;
|
|
942
|
+
});
|
|
943
|
+
req.setEncoding('utf8');
|
|
944
|
+
req.on('data', (chunk) => {
|
|
945
|
+
responseBody += chunk;
|
|
946
|
+
});
|
|
947
|
+
req.on('end', async () => {
|
|
948
|
+
if (status === 200) {
|
|
949
|
+
resolve({ token: deviceToken, accepted: true });
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
let reason = '';
|
|
953
|
+
try {
|
|
954
|
+
reason = JSON.parse(responseBody)?.reason || '';
|
|
955
|
+
} catch {
|
|
956
|
+
// non-JSON error body
|
|
957
|
+
}
|
|
958
|
+
if (status === 410 || DEAD_TOKEN_REASONS.has(reason)) {
|
|
959
|
+
await removeLiveActivityTokens([deviceToken]);
|
|
960
|
+
} else {
|
|
961
|
+
console.warn(`[APNs] live-activity push failed status=${status} reason=${reason || 'unknown'}`);
|
|
962
|
+
}
|
|
963
|
+
resolve({ token: deviceToken, accepted: false });
|
|
964
|
+
});
|
|
965
|
+
req.on('error', (error) => {
|
|
966
|
+
console.warn('[APNs] live-activity request error:', error?.message ?? error);
|
|
967
|
+
resolve({ token: deviceToken, accepted: false });
|
|
968
|
+
});
|
|
969
|
+
req.end(body);
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
const buildLiveActivityDirectBody = (payload) => {
|
|
973
|
+
const aps = {
|
|
974
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
975
|
+
event: payload.event,
|
|
976
|
+
'content-state': {
|
|
977
|
+
status: payload.contentState.status,
|
|
978
|
+
eventVersion: payload.contentState.eventVersion,
|
|
979
|
+
updatedAt: payload.contentState.updatedAt,
|
|
980
|
+
endedAt: payload.contentState.endedAt,
|
|
981
|
+
},
|
|
982
|
+
};
|
|
983
|
+
if (payload.dismissalDate != null) aps['dismissal-date'] = payload.dismissalDate;
|
|
984
|
+
if (payload.staleDate != null) aps['stale-date'] = payload.staleDate;
|
|
985
|
+
return JSON.stringify({ aps });
|
|
986
|
+
};
|
|
987
|
+
|
|
988
|
+
const sendLiveActivityViaDirectApns = async (deviceTokens, payload) => {
|
|
989
|
+
if (!Array.isArray(deviceTokens) || deviceTokens.length === 0) return [];
|
|
990
|
+
const config = await resolveApnsConfig();
|
|
991
|
+
if (!config) {
|
|
992
|
+
if (!warnedUnconfigured) {
|
|
993
|
+
warnedUnconfigured = true;
|
|
994
|
+
console.warn(
|
|
995
|
+
'[APNs] Relay disabled and no direct config; set OPENCHAMBER_APNS_KEY_ID / OPENCHAMBER_APNS_TEAM_ID / OPENCHAMBER_APNS_P8 for direct send.',
|
|
996
|
+
);
|
|
997
|
+
}
|
|
998
|
+
return [];
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const host = config.environment === 'production' ? APNS_HOST_PRODUCTION : APNS_HOST_SANDBOX;
|
|
1002
|
+
const jwt = getJwt(config);
|
|
1003
|
+
const body = buildLiveActivityDirectBody(payload);
|
|
1004
|
+
|
|
1005
|
+
let client;
|
|
1006
|
+
try {
|
|
1007
|
+
client = http2.connect(host);
|
|
1008
|
+
} catch (error) {
|
|
1009
|
+
console.warn('[APNs] live-activity connect failed:', error?.message ?? error);
|
|
1010
|
+
return [];
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
const outcomes = await new Promise((resolve) => {
|
|
1014
|
+
let settled = false;
|
|
1015
|
+
const finish = (value) => {
|
|
1016
|
+
if (settled) return;
|
|
1017
|
+
settled = true;
|
|
1018
|
+
try {
|
|
1019
|
+
client.close();
|
|
1020
|
+
} catch {
|
|
1021
|
+
// ignore close errors
|
|
1022
|
+
}
|
|
1023
|
+
resolve(value);
|
|
1024
|
+
};
|
|
1025
|
+
client.on('error', (error) => {
|
|
1026
|
+
console.warn('[APNs] live-activity session error:', error?.message ?? error);
|
|
1027
|
+
finish([]);
|
|
1028
|
+
});
|
|
1029
|
+
Promise.all(
|
|
1030
|
+
deviceTokens.map((token) => sendLiveActivityOne(client, token, body, jwt, config)),
|
|
1031
|
+
).then((results) => finish(results), () => finish([]));
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
return (outcomes || []).filter((result) => result?.accepted).map((result) => result.token);
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
const collectLiveActivityEntriesForSession = (store, sessionId, now = Date.now()) => {
|
|
1038
|
+
const entries = [];
|
|
1039
|
+
const seen = new Set();
|
|
1040
|
+
for (const record of Object.values(pruneLiveActivityTokensBySession(store.liveActivityTokensBySession, now))) {
|
|
1041
|
+
for (const entry of record) {
|
|
1042
|
+
if (entry.sessionId !== sessionId || seen.has(entry.token)) continue;
|
|
1043
|
+
seen.add(entry.token);
|
|
1044
|
+
entries.push(entry);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
return entries;
|
|
1048
|
+
};
|
|
1049
|
+
|
|
1050
|
+
const sendLiveActivityEnd = async ({ sessionId, status, eventVersion, endedAt } = {}) => {
|
|
1051
|
+
const trimmedSessionId = typeof sessionId === 'string' ? sessionId.trim() : '';
|
|
1052
|
+
if (!isLimitedId(trimmedSessionId, MAX_LIVE_ACTIVITY_ID_CHARS)) return;
|
|
1053
|
+
if (!LIVE_ACTIVITY_TERMINAL_STATUSES.has(status)) return;
|
|
1054
|
+
|
|
1055
|
+
const now = Date.now();
|
|
1056
|
+
let pendingEntries = [];
|
|
1057
|
+
let nextVersion = 0;
|
|
1058
|
+
await persistTokenUpdate((current) => {
|
|
1059
|
+
const liveActivityTokensBySession = pruneLiveActivityTokensBySession(
|
|
1060
|
+
current.liveActivityTokensBySession,
|
|
1061
|
+
now,
|
|
1062
|
+
);
|
|
1063
|
+
pendingEntries = collectLiveActivityEntriesForSession(
|
|
1064
|
+
{ liveActivityTokensBySession },
|
|
1065
|
+
trimmedSessionId,
|
|
1066
|
+
now,
|
|
1067
|
+
);
|
|
1068
|
+
if (pendingEntries.length === 0) {
|
|
1069
|
+
return { ...current, liveActivityTokensBySession };
|
|
1070
|
+
}
|
|
1071
|
+
const previous = current.liveActivityEventVersions?.[trimmedSessionId];
|
|
1072
|
+
nextVersion = nextLiveActivityEventVersion(now, previous, eventVersion);
|
|
1073
|
+
return {
|
|
1074
|
+
...current,
|
|
1075
|
+
liveActivityTokensBySession,
|
|
1076
|
+
liveActivityEventVersions: {
|
|
1077
|
+
...(current.liveActivityEventVersions || {}),
|
|
1078
|
+
[trimmedSessionId]: nextVersion,
|
|
1079
|
+
},
|
|
1080
|
+
};
|
|
1081
|
+
});
|
|
1082
|
+
if (pendingEntries.length === 0) return;
|
|
1083
|
+
|
|
1084
|
+
const updatedAt = now / 1000;
|
|
1085
|
+
const endedAtSeconds = toLiveActivityUnixSeconds(endedAt, now);
|
|
1086
|
+
const contentState = {
|
|
1087
|
+
status,
|
|
1088
|
+
eventVersion: nextVersion,
|
|
1089
|
+
updatedAt,
|
|
1090
|
+
endedAt: endedAtSeconds,
|
|
1091
|
+
};
|
|
1092
|
+
const payload = {
|
|
1093
|
+
event: 'end',
|
|
1094
|
+
contentState,
|
|
1095
|
+
...liveActivityPushDates('end', status, updatedAt, now),
|
|
1096
|
+
};
|
|
1097
|
+
|
|
1098
|
+
const tokens = pendingEntries.map((entry) => entry.token);
|
|
1099
|
+
let accepted = [];
|
|
1100
|
+
try {
|
|
1101
|
+
const relay = await resolveRelayConfig();
|
|
1102
|
+
if (relay) {
|
|
1103
|
+
rememberLiveActivityRelayRegisterUrl(relay.liveActivityRegisterUrl);
|
|
1104
|
+
const outcomes = await mapWithBoundedConcurrency(
|
|
1105
|
+
tokens,
|
|
1106
|
+
RELAY_REGISTER_CONCURRENCY,
|
|
1107
|
+
async (token) => {
|
|
1108
|
+
const registered = await registerLiveActivityTokenWithRelay(token, relay);
|
|
1109
|
+
return registered ? token : null;
|
|
1110
|
+
},
|
|
1111
|
+
);
|
|
1112
|
+
const readyTokens = outcomes.filter((token) => typeof token === 'string');
|
|
1113
|
+
if (readyTokens.length > 0) {
|
|
1114
|
+
accepted = await sendLiveActivityViaRelay(readyTokens, payload, relay);
|
|
1115
|
+
}
|
|
1116
|
+
} else {
|
|
1117
|
+
accepted = await sendLiveActivityViaDirectApns(tokens, payload);
|
|
1118
|
+
}
|
|
1119
|
+
} catch (error) {
|
|
1120
|
+
console.warn('[Live Activity] end failed:', error?.message ?? error);
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
if (accepted.length > 0) {
|
|
1125
|
+
await removeLiveActivityTokens(accepted);
|
|
1126
|
+
}
|
|
1127
|
+
};
|
|
1128
|
+
|
|
581
1129
|
// NOT gated on UI visibility (unlike web push). A backgrounded WKWebView can't reliably
|
|
582
1130
|
// report "hidden" before iOS suspends it, so a visibility gate wrongly suppressed
|
|
583
1131
|
// background push for short responses. Instead we always send, and rely on iOS to NOT
|
|
@@ -662,6 +1210,9 @@ export const createApnsRuntime = (deps) => {
|
|
|
662
1210
|
addOrUpdateApnsToken,
|
|
663
1211
|
removeApnsToken,
|
|
664
1212
|
removeApnsTokenFromAllSessions,
|
|
1213
|
+
addOrUpdateLiveActivityToken,
|
|
1214
|
+
removeLiveActivityToken,
|
|
1215
|
+
sendLiveActivityEnd,
|
|
665
1216
|
sendApnsToAllUiSessions,
|
|
666
1217
|
reRegisterAllTokens,
|
|
667
1218
|
resolveApnsConfig,
|