@openchambery/web 1.18.7-beta.2 → 1.18.7-beta.3
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/AgentAvatar-CVTgW_rx.js +1 -0
- package/dist/assets/AssistantView-CvQ91w2s.js +1 -0
- package/dist/assets/{ChatContainer-BHDNtLuy.js → ChatContainer-CERrtaB-.js} +41 -43
- package/dist/assets/ContextPanel-CLz4r1G-.js +285 -0
- package/dist/assets/DiagramView-DhV4Zthj.js +1 -0
- package/dist/assets/{FadeInOnReveal-BpIMrqet.js → FadeInOnReveal-Cy3sf2M5.js} +1 -1
- package/dist/assets/{Icon-C0G6afNV.js → Icon-BLWVqBee.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-B-SOb7Za.js → MarkdownRendererImpl-B4qwUu4q.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-C7OjF65p.js → MobileDetailNavigation-CuWgEThi.js} +1 -1
- package/dist/assets/{MobileShareBridge-CRRrlX0l.js → MobileShareBridge-C3nay5Sq.js} +2 -2
- package/dist/assets/MobileSurface-D7v_ZkYO.js +2 -0
- package/dist/assets/{MultiRunWindow-Dx4tkVFN.js → MultiRunWindow-Cq8yyiqm.js} +1 -1
- package/dist/assets/{OnboardingScreen-BpxD5W4Z.js → OnboardingScreen-CTNi2zuA.js} +1 -1
- package/dist/assets/{OverlayScrollbar-Bo7MF_jE.js → OverlayScrollbar-GQgJusKL.js} +1 -1
- package/dist/assets/SettingsView-CViKCsxJ.js +25 -0
- package/dist/assets/{SettingsWindow-BlKxv7c3.js → SettingsWindow-C6vwy4hH.js} +1 -1
- package/dist/assets/{TerminalView-xtUbWmdn.js → TerminalView-C1aX22ka.js} +1 -1
- package/dist/assets/{ToolOutputDialog-DUYcTYYW.js → ToolOutputDialog-BrOqj1sB.js} +4 -4
- package/dist/assets/appThemeRegistry-BbqImxSo.js +2 -0
- package/dist/assets/{button-DfF088a8.js → button-CeCkEpvn.js} +1 -1
- package/dist/assets/{clipboard-RoOQVnSl.js → clipboard-BXC1vZwE.js} +1 -1
- package/dist/assets/{collapsible-CVL-5iEB.js → collapsible-B-aagADh.js} +1 -1
- package/dist/assets/{desktopHosts-Dzx3McaD.js → desktopHosts-BrrYPqKq.js} +1 -1
- package/dist/assets/{desktopRelayRestore-B9S0Rp5s.js → desktopRelayRestore-DR60DATT.js} +1 -1
- package/dist/assets/diagram-Cxma_IKI.js +1 -0
- package/dist/assets/dialog-wraJHZQ2.js +1 -0
- package/dist/assets/es-DQqFAaWT.js +15 -0
- package/dist/assets/fileQueries-BJKLdYos.js +1 -0
- package/dist/assets/fr-CNfGRpc1.js +13 -0
- package/dist/assets/{gitApi-DsdwefuR.js → gitApi-B4z4eSZV.js} +1 -1
- package/dist/assets/{input-Ca2tUhgN.js → input-CyG05w-V.js} +1 -1
- package/dist/assets/insertionBoundaries-DCYAK5W1.js +4 -0
- package/dist/assets/ja-CQqehnVn.js +15 -0
- package/dist/assets/ko-kmwi8UJD.js +15 -0
- package/dist/assets/main-A8oe9r34.js +29 -0
- package/dist/assets/{main-CKb-YXE6.js → main-BJBKpmw3.js} +2 -2
- package/dist/assets/{markdown-worker-ZaHiIw5I.js → markdown-worker-DDCUZkz4.js} +1 -1
- package/dist/assets/{markdownTheme-DidS-jC5.js → markdownTheme-H7_7lbuh.js} +1 -1
- package/dist/assets/miniChat-BObOyFSV.js +2 -0
- package/dist/assets/mobile-Ba390Cmc.js +2 -0
- package/dist/assets/multirun-CMDYOrmD.js +4 -0
- package/dist/assets/{persistence-aCOlo6ew.js → persistence-DE9RnVcA.js} +2 -2
- package/dist/assets/pl-Blv5o2QA.js +15 -0
- package/dist/assets/platform-MjT8iqKB.js +2 -0
- package/dist/assets/projectMeta-uWBWFG5A.js +3 -0
- package/dist/assets/pt-BR-BK74t-Bd.js +15 -0
- package/dist/assets/radio-CEXUd4RC.js +1 -0
- package/dist/assets/renderElectronMiniChatApp-Bpgb2E78.js +2 -0
- package/dist/assets/renderMobileApp-BMwRozEC.js +23 -0
- package/dist/assets/runtimeConfig-hHuyauKZ.js +2 -0
- package/dist/assets/runtimeEndpointReset-CmMI7Xp1.js +4 -0
- package/dist/assets/{shikiHighlight-eMLXb99c.js → shikiHighlight-C-kTQvqd.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-CDHNwlp9.js → sortable-tabs-strip-BdbKDDAP.js} +1 -1
- package/dist/assets/src-CgFGBE_A.css +2 -0
- package/dist/assets/store-UweBeN2y.js +15 -0
- package/dist/assets/{terminalApi-B36I-z14.js → terminalApi-Bnx0KvaV.js} +1 -1
- package/dist/assets/{tooltip-GC3dg3gO.js → tooltip-B__x-HDJ.js} +1 -1
- package/dist/assets/uk-6I-KXy3R.js +15 -0
- package/dist/assets/{url-BQHM4bC4.js → url-CTuw7Rjf.js} +1 -1
- package/dist/assets/{useAppFontEffects-B9Ozsfua.js → useAppFontEffects-8iAE0Yrj.js} +4 -4
- package/dist/assets/{useAssistantUIStore-DxZsjSPQ.js → useAssistantUIStore-D2K7ksGj.js} +1 -1
- package/dist/assets/{useDesktopWindowControlsLayout-QxNEOYzY.js → useDesktopWindowControlsLayout--oz1VAPt.js} +1 -1
- package/dist/assets/useEffectiveDirectory-DmoqXaa4.js +1 -0
- package/dist/assets/{useFontPreferences-D9h4M7x4.js → useFontPreferences-CWZnNRjz.js} +1 -1
- package/dist/assets/useMobileNavigationStore-BxbEX-es.js +412 -0
- package/dist/assets/{useSessionAutoCleanup-CXqhEUbp.js → useSessionAutoCleanup-BwlJlhY7.js} +1 -1
- package/dist/assets/{useUIStore-Wyu09VUF.js → useUIStore-DOxLVeGb.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-2KOHSbaa.js → useWorkerHighlightedLines-B21D9MOD.js} +1 -1
- package/dist/assets/{utils-CJdWZupK.js → utils-C2R0YoC5.js} +1 -1
- package/dist/assets/vendor-legendapp-list-WE32lwNk.js +1 -0
- package/dist/assets/zh-CN-DwlzS4yK.js +15 -0
- package/dist/assets/zh-TW-p0VriyfT.js +15 -0
- package/dist/index.html +9 -9
- package/dist/mini-chat.html +9 -9
- package/dist/mobile.html +9 -9
- package/package.json +3 -2
- package/server/index.js +19 -6
- package/server/lib/notifications/APNS.md +88 -38
- package/server/lib/notifications/DOCUMENTATION.md +11 -4
- package/server/lib/notifications/apns-runtime.js +793 -78
- package/server/lib/notifications/apns-runtime.test.js +922 -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 +57 -1
- package/server/lib/notifications/runtime.js +20 -17
- package/server/lib/notifications/runtime.test.js +108 -0
- package/server/lib/opencode/DOCUMENTATION.md +1 -1
- package/server/lib/opencode/bootstrap-runtime.js +6 -0
- package/server/lib/opencode/bootstrap-runtime.test.js +21 -0
- package/server/lib/opencode/routes.js +24 -5
- package/server/lib/opencode/routes.test.js +78 -1
- 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/server/lib/scheduled-tasks/DOCUMENTATION.md +27 -2
- package/server/lib/scheduled-tasks/runtime.js +271 -3
- package/server/lib/scheduled-tasks/runtime.test.js +308 -0
- package/server/lib/session-goal/runtime.js +2 -2
- package/dist/assets/AgentAvatar-CWn_73qY.js +0 -1
- package/dist/assets/AssistantView-BiOO5An0.js +0 -1
- package/dist/assets/ContextPanel-BbNBZipF.js +0 -288
- package/dist/assets/DiagramView-AMPWVMV0.js +0 -1
- package/dist/assets/MobileSurface-DmMgH78x.js +0 -2
- package/dist/assets/SettingsView-BDaMrSki.js +0 -25
- package/dist/assets/appThemeRegistry-D6qZmouf.js +0 -2
- package/dist/assets/dialog-BbHDXPt6.js +0 -1
- package/dist/assets/es-BkIvCvwj.js +0 -15
- package/dist/assets/fileQueries-_VUJM8AE.js +0 -1
- package/dist/assets/fr-tCwQ3odX.js +0 -13
- package/dist/assets/insertionBoundaries-M7AXIWfg.js +0 -1
- package/dist/assets/ja-C0rD6dwp.js +0 -15
- package/dist/assets/ko-CAbtZ462.js +0 -15
- package/dist/assets/main-QjOL36Zh.js +0 -29
- package/dist/assets/miniChat-C45kGqqH.js +0 -2
- package/dist/assets/mobile-DH8nxp1K.js +0 -2
- package/dist/assets/multirun-BkKb0iZv.js +0 -4
- package/dist/assets/pl-Bb190ba6.js +0 -15
- package/dist/assets/platform-DxNH_0Fj.js +0 -2
- package/dist/assets/projectMeta-BHtBPaxp.js +0 -3
- package/dist/assets/pt-BR-B6p_t3IB.js +0 -15
- package/dist/assets/radio-BacnmuU0.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-Cn7umSw9.js +0 -2
- package/dist/assets/renderMobileApp-DZBR2t3l.js +0 -23
- package/dist/assets/runtimeConfig-CSAVS8gd.js +0 -2
- package/dist/assets/runtimeEndpointReset-BZUX3z0A.js +0 -4
- package/dist/assets/src-DziW4HUU.css +0 -2
- package/dist/assets/store-1FOY4G3z.js +0 -15
- package/dist/assets/uk-Dy8-6ZEj.js +0 -15
- package/dist/assets/useEffectiveDirectory-D3u_oih8.js +0 -1
- package/dist/assets/useMobileNavigationStore-CCPTHVO9.js +0 -475
- package/dist/assets/zh-CN-gRTE0npc.js +0 -15
- package/dist/assets/zh-TW-ClD4BCfW.js +0 -15
|
@@ -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,57 @@ 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
|
-
const APNS_TOKENS_VERSION =
|
|
24
|
+
const APNS_TOKENS_VERSION = 2;
|
|
25
|
+
const APNS_TOKENS_SUPPORTED_VERSIONS = new Set([1, APNS_TOKENS_VERSION]);
|
|
18
26
|
const APNS_HOST_PRODUCTION = 'https://api.push.apple.com';
|
|
19
27
|
const APNS_HOST_SANDBOX = 'https://api.sandbox.push.apple.com';
|
|
20
28
|
// APNs rejects auth tokens older than 1h; refresh well inside that window.
|
|
21
29
|
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';
|
|
30
|
+
const DEFAULT_BUNDLE_ID = 'com.yee94.openchamber';
|
|
24
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;
|
|
39
|
+
const MAX_RELAY_SEND_TOKENS = 100;
|
|
40
|
+
const RELAY_REGISTER_CONCURRENCY = 16;
|
|
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']);
|
|
25
45
|
// APNs reasons that mean the token is permanently invalid → drop it.
|
|
26
46
|
const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic']);
|
|
27
47
|
|
|
48
|
+
const mapWithBoundedConcurrency = async (items, concurrency, mapper) => {
|
|
49
|
+
const results = new Array(items.length);
|
|
50
|
+
const limit = Math.max(1, concurrency);
|
|
51
|
+
for (let start = 0; start < items.length; start += limit) {
|
|
52
|
+
const end = Math.min(start + limit, items.length);
|
|
53
|
+
await Promise.all(
|
|
54
|
+
items.slice(start, end).map(async (item, offset) => {
|
|
55
|
+
results[start + offset] = await mapper(item, start + offset);
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return results;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const apnsCollapseId = (tag) => {
|
|
63
|
+
if (typeof tag !== 'string' || tag.length === 0) return undefined;
|
|
64
|
+
if (Buffer.byteLength(tag, 'utf8') > APNS_COLLAPSE_ID_MAX_BYTES) return undefined;
|
|
65
|
+
return tag;
|
|
66
|
+
};
|
|
67
|
+
|
|
28
68
|
const trimmedEnv = (name) => {
|
|
29
69
|
const value = process.env[name];
|
|
30
70
|
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
@@ -44,12 +84,17 @@ export const createApnsRuntime = (deps) => {
|
|
|
44
84
|
writeSettingsToDisk,
|
|
45
85
|
// Strict settings reader gating identity regeneration (see signing-key.js).
|
|
46
86
|
readSettingsStrict,
|
|
87
|
+
resolveEffectiveRelayUrl,
|
|
47
88
|
} = deps;
|
|
48
89
|
|
|
49
90
|
let persistLock = Promise.resolve();
|
|
50
91
|
let cachedJwt = null; // { token, issuedAtMs, keyId }
|
|
51
92
|
let cachedRelayKey = null; // { privateKey, publicJwk }
|
|
52
93
|
let warnedUnconfigured = false;
|
|
94
|
+
let registrationSuccessCache = new Map();
|
|
95
|
+
let registrationCacheRegisterUrl = null;
|
|
96
|
+
let liveActivityRegistrationSuccessCache = new Map();
|
|
97
|
+
let liveActivityRegistrationCacheRegisterUrl = null;
|
|
53
98
|
|
|
54
99
|
// ---------------------------------------------------------------------------
|
|
55
100
|
// Per-server relay signing identity (ECDSA P-256). Auto-generated + persisted in settings
|
|
@@ -76,9 +121,43 @@ export const createApnsRuntime = (deps) => {
|
|
|
76
121
|
y: publicJwk.y,
|
|
77
122
|
});
|
|
78
123
|
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
124
|
+
const registrationCacheKey = (registerUrl, token, platform) =>
|
|
125
|
+
`${registerUrl}\0${token}\0${platform}`;
|
|
126
|
+
|
|
127
|
+
const rememberRelayRegisterUrl = (registerUrl) => {
|
|
128
|
+
if (registrationCacheRegisterUrl === registerUrl) return;
|
|
129
|
+
registrationSuccessCache = new Map();
|
|
130
|
+
registrationCacheRegisterUrl = registerUrl;
|
|
131
|
+
};
|
|
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
|
+
|
|
145
|
+
const boundRegistrationCache = (registerUrl, entries) => {
|
|
146
|
+
rememberRelayRegisterUrl(registerUrl);
|
|
147
|
+
const allowed = new Set(
|
|
148
|
+
entries.map((entry) => registrationCacheKey(registerUrl, entry.deviceToken, entry.platform)),
|
|
149
|
+
);
|
|
150
|
+
for (const key of registrationSuccessCache.keys()) {
|
|
151
|
+
if (!allowed.has(key)) registrationSuccessCache.delete(key);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const registerTokenWithRelay = async (token, platform = 'ios', relayOverride) => {
|
|
156
|
+
const relay = relayOverride ?? (await resolveRelayConfig());
|
|
157
|
+
if (!relay) return true; // direct mode — no relay binding needed
|
|
158
|
+
rememberRelayRegisterUrl(relay.registerUrl);
|
|
159
|
+
const cacheKey = registrationCacheKey(relay.registerUrl, token, platform);
|
|
160
|
+
if (registrationSuccessCache.has(cacheKey)) return true;
|
|
82
161
|
try {
|
|
83
162
|
const { privateKey, publicJwk } = await getOrCreateRelayKeypair();
|
|
84
163
|
const ts = Date.now();
|
|
@@ -89,9 +168,86 @@ export const createApnsRuntime = (deps) => {
|
|
|
89
168
|
headers: { 'content-type': 'application/json' },
|
|
90
169
|
body: JSON.stringify({ token, platform, publicKeyJwk: relayPublicJwk(publicJwk), ts, sig }),
|
|
91
170
|
});
|
|
92
|
-
if (!res.ok)
|
|
171
|
+
if (!res.ok) {
|
|
172
|
+
console.warn(`[Push relay] register-token failed status=${res.status}`);
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
registrationSuccessCache.set(cacheKey, true);
|
|
176
|
+
return true;
|
|
93
177
|
} catch (error) {
|
|
94
178
|
console.warn('[Push relay] register-token request failed:', error?.message ?? error);
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
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);
|
|
95
251
|
}
|
|
96
252
|
};
|
|
97
253
|
|
|
@@ -99,18 +255,36 @@ export const createApnsRuntime = (deps) => {
|
|
|
99
255
|
// Token persistence (same shape + write-lock pattern as push-runtime.js)
|
|
100
256
|
// ---------------------------------------------------------------------------
|
|
101
257
|
|
|
102
|
-
const emptyStore = () => ({
|
|
258
|
+
const emptyStore = () => ({
|
|
259
|
+
version: APNS_TOKENS_VERSION,
|
|
260
|
+
tokensBySession: {},
|
|
261
|
+
liveActivityTokensBySession: {},
|
|
262
|
+
liveActivityEventVersions: {},
|
|
263
|
+
});
|
|
103
264
|
|
|
104
265
|
const readTokensFromDisk = async () => {
|
|
105
266
|
try {
|
|
106
267
|
const raw = await fsPromises.readFile(APNS_TOKENS_FILE_PATH, 'utf8');
|
|
107
268
|
const parsed = JSON.parse(raw);
|
|
108
|
-
if (!parsed || typeof parsed !== 'object' || parsed.version
|
|
269
|
+
if (!parsed || typeof parsed !== 'object' || !APNS_TOKENS_SUPPORTED_VERSIONS.has(parsed.version)) {
|
|
109
270
|
return emptyStore();
|
|
110
271
|
}
|
|
111
272
|
const tokensBySession =
|
|
112
273
|
parsed.tokensBySession && typeof parsed.tokensBySession === 'object' ? parsed.tokensBySession : {};
|
|
113
|
-
|
|
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
|
+
};
|
|
114
288
|
} catch (error) {
|
|
115
289
|
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
|
116
290
|
return emptyStore();
|
|
@@ -128,9 +302,21 @@ export const createApnsRuntime = (deps) => {
|
|
|
128
302
|
const persistTokenUpdate = async (mutate) => {
|
|
129
303
|
persistLock = persistLock.then(async () => {
|
|
130
304
|
const current = await readTokensFromDisk();
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
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;
|
|
134
320
|
});
|
|
135
321
|
return persistLock;
|
|
136
322
|
};
|
|
@@ -149,31 +335,96 @@ export const createApnsRuntime = (deps) => {
|
|
|
149
335
|
userAgent: typeof entry.userAgent === 'string' ? entry.userAgent : undefined,
|
|
150
336
|
// 'ios' (APNs) or 'android' (FCM). Older entries without one are APNs by default.
|
|
151
337
|
platform: entry.platform === 'android' ? 'android' : 'ios',
|
|
338
|
+
// App UI locale at last registration; missing → English titles (legacy tokens).
|
|
339
|
+
locale: normalizeApnsLocale(entry.locale),
|
|
152
340
|
};
|
|
153
341
|
})
|
|
154
342
|
.filter(Boolean);
|
|
155
343
|
};
|
|
156
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
|
+
|
|
157
383
|
// Normalize an incoming platform hint to the two we support; default to APNs/iOS since that
|
|
158
384
|
// was the only registrant before Android/FCM existed.
|
|
159
385
|
const normalizePlatform = (platform) => (platform === 'android' ? 'android' : 'ios');
|
|
160
386
|
|
|
161
|
-
const
|
|
387
|
+
const listUniqueTokenEntries = (store) => {
|
|
388
|
+
const unique = [];
|
|
389
|
+
const seen = new Map();
|
|
390
|
+
for (const record of Object.values(store.tokensBySession || {})) {
|
|
391
|
+
for (const entry of normalizeTokens(record)) {
|
|
392
|
+
const key = `${entry.deviceToken}\0${entry.platform}`;
|
|
393
|
+
const priorIndex = seen.get(key);
|
|
394
|
+
if (priorIndex == null) {
|
|
395
|
+
seen.set(key, unique.length);
|
|
396
|
+
unique.push(entry);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
// Same device may register under multiple UI sessions; keep the freshest locale.
|
|
400
|
+
const prior = unique[priorIndex];
|
|
401
|
+
const priorSeen = typeof prior.lastSeenAt === 'number' ? prior.lastSeenAt : 0;
|
|
402
|
+
const nextSeen = typeof entry.lastSeenAt === 'number' ? entry.lastSeenAt : 0;
|
|
403
|
+
if (nextSeen >= priorSeen) unique[priorIndex] = entry;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return unique;
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const addOrUpdateApnsToken = async (uiSessionToken, deviceToken, userAgent, platform, locale) => {
|
|
162
410
|
if (!uiSessionToken || typeof deviceToken !== 'string' || deviceToken.trim().length === 0) return;
|
|
163
411
|
const token = deviceToken.trim();
|
|
164
412
|
const tokenPlatform = normalizePlatform(platform);
|
|
413
|
+
const tokenLocale = normalizeApnsLocale(locale);
|
|
165
414
|
const now = Date.now();
|
|
166
415
|
|
|
167
416
|
await persistTokenUpdate((current) => {
|
|
168
417
|
const tokensBySession = { ...(current.tokensBySession || {}) };
|
|
169
418
|
const existing = normalizeTokens(tokensBySession[uiSessionToken]);
|
|
419
|
+
const prior = existing.find((entry) => entry.deviceToken === token);
|
|
170
420
|
const filtered = existing.filter((entry) => entry.deviceToken !== token);
|
|
171
421
|
filtered.unshift({
|
|
172
422
|
deviceToken: token,
|
|
173
|
-
createdAt: now,
|
|
423
|
+
createdAt: typeof prior?.createdAt === 'number' ? prior.createdAt : now,
|
|
174
424
|
lastSeenAt: now,
|
|
175
425
|
userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined,
|
|
176
426
|
platform: tokenPlatform,
|
|
427
|
+
locale: tokenLocale,
|
|
177
428
|
});
|
|
178
429
|
tokensBySession[uiSessionToken] = filtered.slice(0, MAX_TOKENS_PER_SESSION);
|
|
179
430
|
return { version: APNS_TOKENS_VERSION, tokensBySession };
|
|
@@ -213,6 +464,89 @@ export const createApnsRuntime = (deps) => {
|
|
|
213
464
|
});
|
|
214
465
|
};
|
|
215
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
|
+
|
|
216
550
|
// ---------------------------------------------------------------------------
|
|
217
551
|
// Config (env first, then settings.apnsConfig) — mirrors resolveVapidSubject
|
|
218
552
|
// ---------------------------------------------------------------------------
|
|
@@ -316,8 +650,9 @@ export const createApnsRuntime = (deps) => {
|
|
|
316
650
|
'apns-push-type': 'alert',
|
|
317
651
|
'apns-priority': '10',
|
|
318
652
|
};
|
|
319
|
-
// collapse-id dedups like web-push tags; APNs caps it at 64 bytes.
|
|
320
|
-
|
|
653
|
+
// collapse-id dedups like web-push tags; APNs caps it at 64 UTF-8 bytes.
|
|
654
|
+
// Keep legal values; omit anything over the limit (do not truncate mid-codepoint).
|
|
655
|
+
const collapseId = apnsCollapseId(config.tag);
|
|
321
656
|
if (collapseId) headers['apns-collapse-id'] = collapseId;
|
|
322
657
|
|
|
323
658
|
let req;
|
|
@@ -363,61 +698,86 @@ export const createApnsRuntime = (deps) => {
|
|
|
363
698
|
req.end(body);
|
|
364
699
|
});
|
|
365
700
|
|
|
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
|
-
|
|
701
|
+
// Relay mode (default): the single APNs key lives in the isolated Push process on the same
|
|
702
|
+
// Relay origin (`/v1/push/*`), not on each user's server — so users configure nothing. The
|
|
703
|
+
// server just POSTs device tokens + generic text; Push signs + sends and reports which tokens
|
|
704
|
+
// to drop. Direct mode (below) is the fallback for self-hosters who set OPENCHAMBER_APNS_*
|
|
705
|
+
// and disable the relay.
|
|
706
|
+
const buildRelayConfig = (url) => ({
|
|
707
|
+
url,
|
|
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'),
|
|
712
|
+
environment:
|
|
713
|
+
(trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || 'sandbox').toLowerCase() === 'production'
|
|
714
|
+
? 'production'
|
|
715
|
+
: 'sandbox',
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
const resolveRelayConfig = async () => {
|
|
371
719
|
if (trimmedEnv('OPENCHAMBER_PUSH_RELAY_DISABLED') === 'true') return null;
|
|
372
|
-
const
|
|
373
|
-
return
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
720
|
+
const explicit = trimmedEnv('OPENCHAMBER_PUSH_RELAY_URL');
|
|
721
|
+
if (explicit) return buildRelayConfig(explicit);
|
|
722
|
+
|
|
723
|
+
let relayWsUrl = DEFAULT_RELAY_URL;
|
|
724
|
+
if (typeof resolveEffectiveRelayUrl === 'function') {
|
|
725
|
+
try {
|
|
726
|
+
const resolved = await resolveEffectiveRelayUrl();
|
|
727
|
+
if (typeof resolved === 'string' && resolved.trim()) {
|
|
728
|
+
if (derivePushSendUrlFromRelayUrl(resolved)) relayWsUrl = resolved.trim();
|
|
729
|
+
else console.warn('[Push relay] resolver returned a non-WebSocket URL; falling back to default');
|
|
730
|
+
}
|
|
731
|
+
} catch {
|
|
732
|
+
console.warn('[Push relay] resolver failed; falling back to default');
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
const url = derivePushSendUrlFromRelayUrl(relayWsUrl) || derivePushSendUrlFromRelayUrl(DEFAULT_RELAY_URL);
|
|
736
|
+
if (!url) return null;
|
|
737
|
+
return buildRelayConfig(url);
|
|
381
738
|
};
|
|
382
739
|
|
|
383
740
|
const sendViaRelay = async (deviceTokens, payload, relay) => {
|
|
384
|
-
|
|
741
|
+
if (!Array.isArray(deviceTokens) || deviceTokens.length === 0) return;
|
|
385
742
|
const title = typeof payload?.title === 'string' && payload.title.length > 0 ? payload.title : 'OpenChamber';
|
|
386
743
|
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,
|
|
744
|
+
for (let start = 0; start < deviceTokens.length; start += MAX_RELAY_SEND_TOKENS) {
|
|
745
|
+
const tokens = deviceTokens.slice(start, start + MAX_RELAY_SEND_TOKENS);
|
|
746
|
+
const ts = Date.now();
|
|
747
|
+
// Sign over the same canonical form the relay verifies: ts.sortedTokens.title.
|
|
748
|
+
const sig = signRelayMessage(privateKey, `${ts}.${[...tokens].sort().join(',')}.${title}`);
|
|
749
|
+
const requestBody = JSON.stringify({
|
|
750
|
+
tokens,
|
|
751
|
+
title,
|
|
752
|
+
body: typeof payload?.body === 'string' ? payload.body : '',
|
|
753
|
+
badge: Number.isFinite(payload?.badge) && payload.badge >= 0 ? Math.trunc(payload.badge) : undefined,
|
|
754
|
+
collapseId: apnsCollapseId(payload?.tag),
|
|
755
|
+
env: relay.environment,
|
|
756
|
+
data: payload?.data && typeof payload.data === 'object' ? payload.data : undefined,
|
|
757
|
+
publicKeyJwk: relayPublicJwk(publicJwk),
|
|
758
|
+
ts,
|
|
759
|
+
sig,
|
|
407
760
|
});
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
761
|
+
try {
|
|
762
|
+
const res = await fetch(relay.url, {
|
|
763
|
+
method: 'POST',
|
|
764
|
+
headers: { 'content-type': 'application/json' },
|
|
765
|
+
body: requestBody,
|
|
766
|
+
});
|
|
767
|
+
if (!res.ok) {
|
|
768
|
+
console.warn(`[APNs relay] send failed status=${res.status}`);
|
|
769
|
+
continue;
|
|
417
770
|
}
|
|
771
|
+
const data = await res.json().catch(() => null);
|
|
772
|
+
const results = Array.isArray(data?.results) ? data.results : [];
|
|
773
|
+
for (const result of results) {
|
|
774
|
+
if (result && result.drop === true && typeof result.token === 'string') {
|
|
775
|
+
await removeApnsTokenFromAllSessions(result.token);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
} catch (error) {
|
|
779
|
+
console.warn('[APNs relay] request failed:', error?.message ?? error);
|
|
418
780
|
}
|
|
419
|
-
} catch (error) {
|
|
420
|
-
console.warn('[APNs relay] request failed:', error?.message ?? error);
|
|
421
781
|
}
|
|
422
782
|
};
|
|
423
783
|
|
|
@@ -468,38 +828,393 @@ export const createApnsRuntime = (deps) => {
|
|
|
468
828
|
});
|
|
469
829
|
};
|
|
470
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
|
+
|
|
471
1129
|
// NOT gated on UI visibility (unlike web push). A backgrounded WKWebView can't reliably
|
|
472
1130
|
// report "hidden" before iOS suspends it, so a visibility gate wrongly suppressed
|
|
473
1131
|
// background push for short responses. Instead we always send, and rely on iOS to NOT
|
|
474
1132
|
// display the alert while the app is foreground (presentationOptions: [] in
|
|
475
1133
|
// capacitor.config) — so there is no notification when the app is active, with no race.
|
|
476
|
-
const
|
|
1134
|
+
const reRegisterAllTokens = async () => {
|
|
1135
|
+
const relay = await resolveRelayConfig();
|
|
1136
|
+
if (!relay) {
|
|
1137
|
+
return { attempted: 0, succeeded: 0, failed: 0 };
|
|
1138
|
+
}
|
|
477
1139
|
const store = await readTokensFromDisk();
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
1140
|
+
const entries = listUniqueTokenEntries(store);
|
|
1141
|
+
boundRegistrationCache(relay.registerUrl, entries);
|
|
1142
|
+
const outcomes = await mapWithBoundedConcurrency(
|
|
1143
|
+
entries,
|
|
1144
|
+
RELAY_REGISTER_CONCURRENCY,
|
|
1145
|
+
(entry) => registerTokenWithRelay(entry.deviceToken, entry.platform, relay),
|
|
1146
|
+
);
|
|
1147
|
+
let succeeded = 0;
|
|
1148
|
+
let failed = 0;
|
|
1149
|
+
for (const ok of outcomes) {
|
|
1150
|
+
if (ok) succeeded += 1;
|
|
1151
|
+
else failed += 1;
|
|
1152
|
+
}
|
|
1153
|
+
return { attempted: entries.length, succeeded, failed };
|
|
1154
|
+
};
|
|
1155
|
+
|
|
1156
|
+
// Group ready device tokens by app locale so each batch can carry a localized title.
|
|
1157
|
+
// Relay signatures cover title, so mixed-locale tokens must not share one send body.
|
|
1158
|
+
const groupTokensByLocale = (entries) => {
|
|
1159
|
+
const groups = new Map();
|
|
1160
|
+
for (const entry of entries) {
|
|
1161
|
+
const locale = entry?.locale ? normalizeApnsLocale(entry.locale) : APNS_DEFAULT_LOCALE;
|
|
1162
|
+
const token = entry?.deviceToken;
|
|
1163
|
+
if (typeof token !== 'string' || token.length === 0) continue;
|
|
1164
|
+
let list = groups.get(locale);
|
|
1165
|
+
if (!list) {
|
|
1166
|
+
list = [];
|
|
1167
|
+
groups.set(locale, list);
|
|
486
1168
|
}
|
|
1169
|
+
list.push(token);
|
|
487
1170
|
}
|
|
488
|
-
|
|
1171
|
+
return groups;
|
|
1172
|
+
};
|
|
489
1173
|
|
|
490
|
-
|
|
1174
|
+
const sendApnsToAllUiSessions = async (payload, _options = {}) => {
|
|
1175
|
+
const store = await readTokensFromDisk();
|
|
1176
|
+
const uniqueEntries = listUniqueTokenEntries(store);
|
|
1177
|
+
if (uniqueEntries.length === 0) return;
|
|
1178
|
+
|
|
1179
|
+
const relay = await resolveRelayConfig();
|
|
491
1180
|
if (relay) {
|
|
492
|
-
|
|
1181
|
+
boundRegistrationCache(relay.registerUrl, uniqueEntries);
|
|
1182
|
+
const outcomes = await mapWithBoundedConcurrency(
|
|
1183
|
+
uniqueEntries,
|
|
1184
|
+
RELAY_REGISTER_CONCURRENCY,
|
|
1185
|
+
async (entry) => {
|
|
1186
|
+
const registered = await registerTokenWithRelay(entry.deviceToken, entry.platform, relay);
|
|
1187
|
+
return registered ? entry : null;
|
|
1188
|
+
},
|
|
1189
|
+
);
|
|
1190
|
+
const readyEntries = [];
|
|
1191
|
+
const readySeen = new Set();
|
|
1192
|
+
for (const entry of outcomes) {
|
|
1193
|
+
if (!entry?.deviceToken || readySeen.has(entry.deviceToken)) continue;
|
|
1194
|
+
readySeen.add(entry.deviceToken);
|
|
1195
|
+
readyEntries.push(entry);
|
|
1196
|
+
}
|
|
1197
|
+
if (readyEntries.length === 0) return;
|
|
1198
|
+
for (const [locale, tokens] of groupTokensByLocale(readyEntries)) {
|
|
1199
|
+
await sendViaRelay(tokens, localizeApnsPayload(payload, locale), relay);
|
|
1200
|
+
}
|
|
493
1201
|
return;
|
|
494
1202
|
}
|
|
495
|
-
|
|
1203
|
+
const byLocale = groupTokensByLocale(uniqueEntries);
|
|
1204
|
+
for (const [locale, deviceTokens] of byLocale) {
|
|
1205
|
+
await sendViaDirectApns(deviceTokens, localizeApnsPayload(payload, locale));
|
|
1206
|
+
}
|
|
496
1207
|
};
|
|
497
1208
|
|
|
498
1209
|
return {
|
|
499
1210
|
addOrUpdateApnsToken,
|
|
500
1211
|
removeApnsToken,
|
|
501
1212
|
removeApnsTokenFromAllSessions,
|
|
1213
|
+
addOrUpdateLiveActivityToken,
|
|
1214
|
+
removeLiveActivityToken,
|
|
1215
|
+
sendLiveActivityEnd,
|
|
502
1216
|
sendApnsToAllUiSessions,
|
|
1217
|
+
reRegisterAllTokens,
|
|
503
1218
|
resolveApnsConfig,
|
|
504
1219
|
// exposed for tests
|
|
505
1220
|
signApnsJwt,
|