@openchambery/web 1.19.0-beta.4 → 1.19.0-beta.41
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-Bt6ACcoy.js +1 -0
- package/dist/assets/AssistantView-DSVnoF6D.js +1 -0
- package/dist/assets/{ChatContainer-BTOShmJo.js → ChatContainer-BR8bX7eo.js} +39 -41
- package/dist/assets/ContextPanel-08KY9_yh.js +288 -0
- package/dist/assets/{DiagramView-CkAyvW7S.js → DiagramView-CcwntnET.js} +1 -1
- package/dist/assets/{FadeInOnReveal-By2F9vcI.js → FadeInOnReveal-BpIMrqet.js} +1 -1
- package/dist/assets/{Icon-DpjYnXxn.js → Icon-Bs6GJIoO.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-B0eBCtyD.js → MarkdownRendererImpl-DvCzwWEz.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-DL4SVma_.js → MobileDetailNavigation-Bevs3Mx_.js} +1 -1
- package/dist/assets/{MobileShareBridge-FZ2V3gRM.js → MobileShareBridge-DK5v0Rir.js} +2 -2
- package/dist/assets/MobileSurface-D2ms2C0d.js +2 -0
- package/dist/assets/{MultiRunWindow-D-kO7Hwi.js → MultiRunWindow-B73TNXR9.js} +1 -1
- package/dist/assets/{OnboardingScreen-BKmuT7AL.js → OnboardingScreen-DuB2zBXh.js} +1 -1
- package/dist/assets/OverlayScrollbar-VZKKu-cu.js +2 -0
- package/dist/assets/SettingsView-BK7_Oz8z.js +25 -0
- package/dist/assets/{SettingsWindow-L92_gMCL.js → SettingsWindow-LNjg25i3.js} +1 -1
- package/dist/assets/{TerminalView-D4W3gUrF.js → TerminalView-B8mRchYI.js} +1 -1
- package/dist/assets/{ToolOutputDialog-CpMU9Jmc.js → ToolOutputDialog-CjIqC0IE.js} +4 -4
- package/dist/assets/appThemeRegistry-BZw1SpcA.js +2 -0
- package/dist/assets/{button-C7OXr4ae.js → button-BkTMa7Qj.js} +1 -1
- package/dist/assets/{clipboard-DWuULPzo.js → clipboard-mffeUZRt.js} +1 -1
- package/dist/assets/{collapsible-DaEya6b6.js → collapsible-CVL-5iEB.js} +1 -1
- package/dist/assets/{desktopHosts-Txp4xOVQ.js → desktopHosts-Dzx3McaD.js} +1 -1
- package/dist/assets/{desktopRelayRestore-C_FSEHKM.js → desktopRelayRestore-B9S0Rp5s.js} +1 -1
- package/dist/assets/dialog-D8720PGH.js +1 -0
- package/dist/assets/{es-BX0Vm3Ac.js → es-DlnJ_LoQ.js} +2 -2
- package/dist/assets/{fileQueries-B_fTkH6s.js → fileQueries-_VUJM8AE.js} +1 -1
- package/dist/assets/fr-qNdP8tDC.js +13 -0
- package/dist/assets/{gitApi-BIqXEOV4.js → gitApi-0_4_-WcX.js} +3 -3
- package/dist/assets/{input-BzYA603C.js → input-Ca2tUhgN.js} +1 -1
- package/dist/assets/insertionBoundaries-Bl_qiyTR.js +4 -0
- package/dist/assets/{ja-DGtzjrFO.js → ja-DPKgF5Ex.js} +3 -3
- package/dist/assets/{ko-DZ9Di0je.js → ko-BFmhVYY-.js} +3 -3
- package/dist/assets/main-B27rFGjh.js +29 -0
- package/dist/assets/{main-BtWdJ1o-.js → main-DljC1FuL.js} +2 -2
- package/dist/assets/{markdown-worker-W06-MhF5.js → markdown-worker-9Sn2jsDp.js} +1 -1
- package/dist/assets/{markdownTheme-B89dbj3N.js → markdownTheme-DidS-jC5.js} +1 -1
- package/dist/assets/{miniChat-CJzPIIkQ.js → miniChat-DWhKSy9J.js} +2 -2
- package/dist/assets/mobile-B5z9BrGL.js +2 -0
- package/dist/assets/multirun-B2_-qAoC.js +4 -0
- package/dist/assets/{persistence-6sclr7B6.js → persistence-aCOlo6ew.js} +2 -2
- package/dist/assets/pl-DtrJUxrk.js +15 -0
- package/dist/assets/platform-ulFAVx1H.js +2 -0
- package/dist/assets/projectMeta-BueyKfXu.js +3 -0
- package/dist/assets/{pt-BR-Brf4RMsw.js → pt-BR-BQVvvMJt.js} +2 -2
- package/dist/assets/radio-_bcJkk2w.js +1 -0
- package/dist/assets/renderElectronMiniChatApp-Bb6K99m4.js +2 -0
- package/dist/assets/renderMobileApp-ByiWWEfa.js +23 -0
- package/dist/assets/{runtimeConfig-DMpRvT8y.js → runtimeConfig-CPxbwfqk.js} +1 -1
- package/dist/assets/runtimeEndpointReset-CQIhCLle.js +4 -0
- package/dist/assets/{shikiHighlight-CGuRQJIr.js → shikiHighlight-DaveQmV5.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-nLsPC06f.js → sortable-tabs-strip-D2D5nqti.js} +1 -1
- package/dist/assets/src-BwToVDxi.css +2 -0
- package/dist/assets/{store-DQY8Fkb0.js → store-DcNAsu-r.js} +4 -4
- package/dist/assets/{terminalApi-BpPeXWIu.js → terminalApi-HsIEks5U.js} +1 -1
- package/dist/assets/{tooltip-D_orJMYd.js → tooltip-GC3dg3gO.js} +1 -1
- package/dist/assets/{uk-C-wmaPTi.js → uk-BhJpEyza.js} +2 -2
- package/dist/assets/{url-BsaBG5-1.js → url-BQHM4bC4.js} +1 -1
- package/dist/assets/{useAppFontEffects-Dlfg51WY.js → useAppFontEffects-Bo8Gg2wk.js} +5 -5
- package/dist/assets/{useAssistantUIStore-BHsHXCKE.js → useAssistantUIStore-DxZsjSPQ.js} +1 -1
- package/dist/assets/{useDesktopWindowControlsLayout-B6l3XRlk.js → useDesktopWindowControlsLayout-CcJfst4E.js} +1 -1
- package/dist/assets/useEffectiveDirectory-CrmbaDn6.js +1 -0
- package/dist/assets/{useFontPreferences-ckzpXQaJ.js → useFontPreferences-DaFspdDd.js} +1 -1
- package/dist/assets/useMobileNavigationStore-Ce5rebqo.js +475 -0
- package/dist/assets/useSessionAutoCleanup-ggCjN2VE.js +1 -0
- package/dist/assets/{useUIStore-DzwCfRqr.js → useUIStore-Wyu09VUF.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-DXDqlnez.js → useWorkerHighlightedLines-CbqnYoLv.js} +1 -1
- package/dist/assets/{utils-B1EK5zKa.js → utils-CJdWZupK.js} +1 -1
- package/dist/assets/{zh-CN-DueRrIql.js → zh-CN-C5ctnTME.js} +2 -2
- package/dist/assets/zh-TW-B97f1ndw.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 +1 -1
- package/server/index.js +12 -15
- package/server/lib/config-sync/DOCUMENTATION.md +6 -7
- package/server/lib/config-sync/engine.js +0 -8
- package/server/lib/config-sync/index.js +0 -7
- package/server/lib/config-sync/receiver.js +0 -35
- package/server/lib/config-sync/routes.js +8 -41
- package/server/lib/config-sync/routes.test.js +1 -38
- 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/opencode/openchamber-routes.js +1 -7
- package/server/lib/opencode/settings-helpers.js +0 -3
- package/server/lib/opencode/shutdown-runtime.js +0 -2
- 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/session-goal/DOCUMENTATION.md +3 -3
- package/server/lib/session-goal/runtime.js +3 -3
- package/server/lib/session-title/DOCUMENTATION.md +1 -2
- package/dist/assets/AgentAvatar-DDcjamcJ.js +0 -1
- package/dist/assets/AssistantView-BmrllM8T.js +0 -1
- package/dist/assets/ContextPanel-DrOpxE_Z.js +0 -288
- package/dist/assets/MobileSurface-DeRexDW2.js +0 -2
- package/dist/assets/OverlayScrollbar-Ch3J6ASR.js +0 -2
- package/dist/assets/SettingsView-yNqH1aC2.js +0 -25
- package/dist/assets/appThemeRegistry-DohSaUlr.js +0 -2
- package/dist/assets/dialog-DPS0W9eS.js +0 -1
- package/dist/assets/fr-CuPzXTYx.js +0 -13
- package/dist/assets/insertionBoundaries-9AGImjk0.js +0 -1
- package/dist/assets/main-CnioFiw7.js +0 -29
- package/dist/assets/mobile-CvkyrtMY.js +0 -2
- package/dist/assets/multirun-BjeA-Baw.js +0 -4
- package/dist/assets/pl-BuCO3qb1.js +0 -15
- package/dist/assets/platform-CbeLpKPk.js +0 -2
- package/dist/assets/projectMeta-BP0IYkad.js +0 -3
- package/dist/assets/radio-dL5rRE7d.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-Dzfy8-St.js +0 -2
- package/dist/assets/renderMobileApp-Bi1Sm_lT.js +0 -23
- package/dist/assets/runtimeEndpointReset-C6_MkS3Z.js +0 -4
- package/dist/assets/src-RGaBtbGz.css +0 -2
- package/dist/assets/useEffectiveDirectory-DrRShKin.js +0 -1
- package/dist/assets/useMobileNavigationStore-CncTmwzO.js +0 -475
- package/dist/assets/useSessionAutoCleanup-BkMesfd-.js +0 -1
- package/dist/assets/zh-TW-6875X80V.js +0 -15
- package/server/lib/config-sync/credential-auth.js +0 -39
- package/server/lib/session-assist/DOCUMENTATION.md +0 -62
- package/server/lib/session-assist/runtime.js +0 -376
- package/server/lib/session-assist/runtime.test.js +0 -114
|
@@ -13,17 +13,20 @@ registered them — so a leaked device token alone can't be used to push.
|
|
|
13
13
|
`POST /v1/push/register-token`, signed with its auto-generated ECDSA P-256 key
|
|
14
14
|
(`getOrCreateRelayKeypair`, persisted in settings like the VAPID keys). The relay records
|
|
15
15
|
`token → serverId` where `serverId = SHA-256(publicKey)`.
|
|
16
|
-
3. On a trigger (ready/error/question/permission), the server composes **generic,
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
16
|
+
3. On a trigger (ready/error/question/permission/goal_*), the server composes **generic,
|
|
17
|
+
content-free** text — a **locale-specific** scenario title (from `apns-titles.js`, keyed off
|
|
18
|
+
the locale stored with each device token at `POST /api/push/apns-token`) + the **session name**
|
|
19
|
+
as the body, no model/project/message content — plus a **`badge`** count (see below). Tokens
|
|
20
|
+
are grouped by locale (relay signatures cover `title`, so mixed locales cannot share one
|
|
21
|
+
batch) and each group POSTs `{ tokens, title, body, badge, env, data:{sessionId},
|
|
22
|
+
publicKeyJwk, ts, sig }` to `POST /v1/push/send` (`apns-runtime.js` → `sendViaRelay`). It does
|
|
23
|
+
**not** gate on UI visibility (see below). Legacy tokens without a locale fall back to English.
|
|
24
|
+
4. The **Push Relay** (`packages/relay-server/src/push/`, process `openchamber-push-relay`)
|
|
25
|
+
verifies the signature + `ts` freshness, derives `serverId`, and only delivers to tokens bound
|
|
26
|
+
to that server. It holds the project APNs `.p8` key, signs an ES256 JWT with Node crypto, and
|
|
27
|
+
sends each token to APNs over HTTP/2, returning per-token results; the server drops tokens
|
|
28
|
+
flagged `drop` (410 / BadDeviceToken). The Push database stores `token → serverId` bindings,
|
|
29
|
+
not application plaintext. Layer 1 (`openchamber-relay`) never sees these secrets.
|
|
27
30
|
5. Tapping a push deep-links to its session via the forwarded `sessionId`.
|
|
28
31
|
|
|
29
32
|
## Foreground suppression
|
|
@@ -34,8 +37,8 @@ responses. Instead the server always sends, and **iOS** suppresses the foregroun
|
|
|
34
37
|
(`PushNotifications.presentationOptions: []` in `capacitor.config`) — so there is no notification
|
|
35
38
|
while the app is active, with no race. APNs is the native app's **only** channel; local
|
|
36
39
|
notifications were removed (a WKWebView can't tell foreground from background — `document.hasFocus()`
|
|
37
|
-
is unreliable — so they leaked while the app was open).
|
|
38
|
-
app with notifications on has a registered token and a trigger fires.
|
|
40
|
+
is unreliable — so they leaked while the app was open). The Push Relay is contacted only when a
|
|
41
|
+
native app with notifications on has a registered token and a trigger fires.
|
|
39
42
|
|
|
40
43
|
## App-icon badge
|
|
41
44
|
|
|
@@ -61,36 +64,45 @@ mirrors the device zeroing its icon badge on `sceneDidBecomeActive` (`AppDelegat
|
|
|
61
64
|
server and device in sync.
|
|
62
65
|
|
|
63
66
|
The value flows `runtime.js` (`toApnsGenericPayload`) → `apns-runtime.js` (`sendViaRelay` body /
|
|
64
|
-
direct-mode `aps.badge`) →
|
|
65
|
-
`body`/`data`); the relay still only delivers to bound tokens. The set
|
|
66
|
-
device token of a server sees the same badge.
|
|
67
|
+
direct-mode `aps.badge`) → Push Relay (`packages/relay-server/src/push/schema.js` → `aps.badge`).
|
|
68
|
+
It is **not** signed (like `body`/`data`); the relay still only delivers to bound tokens. The set
|
|
69
|
+
is server-global, so every device token of a server sees the same badge.
|
|
67
70
|
|
|
68
71
|
## Modes
|
|
69
72
|
|
|
70
|
-
- **Relay (default):**
|
|
71
|
-
`
|
|
73
|
+
- **Relay (default):** Host has no Apple key. Unless `OPENCHAMBER_PUSH_RELAY_URL` is set, the
|
|
74
|
+
Host maps the effective Relay `wss://`/`ws://` URL to the same host as `https://`/`http://`
|
|
75
|
+
`/v1/push/send` (register URL is `/v1/push/register-token`). A Relay switch re-registers
|
|
76
|
+
persisted tokens and binds them again before the first send.
|
|
72
77
|
- **Direct (fallback):** set `OPENCHAMBER_PUSH_RELAY_DISABLED=true` + `OPENCHAMBER_APNS_KEY_ID/
|
|
73
78
|
TEAM_ID/P8` to sign+send from the server itself (HTTP/2 + ES256 JWT); no relay binding needed.
|
|
74
79
|
|
|
75
80
|
## Config
|
|
76
81
|
|
|
77
|
-
|
|
78
|
-
- `OPENCHAMBER_PUSH_RELAY_URL` (
|
|
79
|
-
(`sandbox` default / `production`). The signing keypair is
|
|
82
|
+
Host (`apns-runtime.js`):
|
|
83
|
+
- `OPENCHAMBER_PUSH_RELAY_URL` (explicit send-URL override), `OPENCHAMBER_APNS_ENVIRONMENT`
|
|
84
|
+
(`sandbox` default / `production` for TestFlight and App Store). The signing keypair is
|
|
85
|
+
auto-generated — nothing to set.
|
|
80
86
|
- Direct fallback: `OPENCHAMBER_APNS_KEY_ID`, `OPENCHAMBER_APNS_TEAM_ID`, `OPENCHAMBER_APNS_P8`
|
|
81
87
|
(or `_P8_PATH`), `OPENCHAMBER_APNS_BUNDLE_ID`, `OPENCHAMBER_PUSH_RELAY_DISABLED=true`.
|
|
82
88
|
|
|
83
|
-
|
|
84
|
-
`
|
|
85
|
-
|
|
89
|
+
Push process (`packages/relay-server/src/push/`, env prefix `OPENCHAMBER_PUSH_RELAY_`):
|
|
90
|
+
`HOST` (default `127.0.0.1`), `PORT` (default `8788`), `TRUST_PROXY`, `DATABASE_PATH`,
|
|
91
|
+
timestamp/replay/rate-limit/token/`MAX_IN_FLIGHT` limits, plus APNs `APNS_KEY_ID`,
|
|
92
|
+
`APNS_TEAM_ID`, `APNS_BUNDLE_ID`, and `APNS_P8` or `APNS_P8_PATH`. See
|
|
93
|
+
`packages/relay-server/README.md`. SQLite is single-instance. Do not log `.p8` material,
|
|
94
|
+
device tokens, or signatures.
|
|
86
95
|
|
|
87
96
|
## Apple setup (one-time)
|
|
88
97
|
|
|
89
98
|
1. Apple **Keys** (not Certificates) → create an **APNs Auth Key** (`.p8`) → Key ID + Team ID;
|
|
90
|
-
enable **Push Notifications** on App ID
|
|
91
|
-
|
|
92
|
-
|
|
99
|
+
enable **Push Notifications** on the App ID. TestFlight / App Store builds of this product
|
|
100
|
+
use bundle ID `com.yee94.openchamber`.
|
|
101
|
+
2. Give those values only to the Push process (environment or Docker secret). Do not put the
|
|
102
|
+
`.p8` on Layer 1 or on each OpenChamber Host when using Relay mode.
|
|
93
103
|
3. Xcode: confirm the Push Notifications capability; Clean Build Folder; run on device.
|
|
104
|
+
Development builds use APNs `sandbox`. TestFlight and App Store Hosts set
|
|
105
|
+
`OPENCHAMBER_APNS_ENVIRONMENT=production`.
|
|
94
106
|
|
|
95
107
|
## Security posture
|
|
96
108
|
|
|
@@ -98,10 +110,12 @@ binding table is created by `migrations/0002_push_tokens.sql` (applied on deploy
|
|
|
98
110
|
is signed by the server's private key, and the relay only delivers to a token from its bound
|
|
99
111
|
`serverId`. A leaked token alone is useless — an attacker has neither the private key nor a
|
|
100
112
|
matching binding.
|
|
101
|
-
- `serverId` self-certifies (`SHA-256(publicKey)`)
|
|
102
|
-
|
|
113
|
+
- `serverId` self-certifies (`SHA-256(publicKey)`). A SQLite leak exposes `token → serverId`
|
|
114
|
+
bindings, not Host private keys or application plaintext. The signed `ts` (±5 min window)
|
|
115
|
+
blocks replay. Signature verification lives in `packages/relay-server/src/push/crypto.js`
|
|
116
|
+
and must stay aligned with `packages/web/server/lib/relay/signing-key.js`.
|
|
103
117
|
- Residual: trust-on-first-bind (whoever registers a token first owns it) — acceptable, since
|
|
104
|
-
registering already requires possessing the token.
|
|
118
|
+
registering already requires possessing the token. Process rate limits are defence-in-depth.
|
|
105
119
|
|
|
106
120
|
## Data confidentiality (what the relay / Apple can see)
|
|
107
121
|
|
|
@@ -113,17 +127,18 @@ to APNs). The request **signature is authentication, not encryption** — the re
|
|
|
113
127
|
Who can read the alert text:
|
|
114
128
|
|
|
115
129
|
- **Network hops:** nothing (TLS).
|
|
116
|
-
- **The
|
|
117
|
-
`sessionId`. It stores
|
|
130
|
+
- **The Push Relay process:** the generic title + body (session name), the device token, and
|
|
131
|
+
`sessionId`. It stores `token → serverId` bindings (no alert text, no payload).
|
|
118
132
|
- **Apple APNs:** the alert text too — APNs always reads the alert payload of an `alert` push.
|
|
119
133
|
- **The device:** displays it.
|
|
120
134
|
|
|
121
|
-
This is acceptable **because the text is deliberately content-free**: a fixed scenario title
|
|
122
|
-
the session name only — no model, project, or message content
|
|
123
|
-
`toApnsGenericPayload`). The session name is the single
|
|
124
|
-
relay/Apple. To hide even that from Apple would require an
|
|
125
|
-
(`mutable-content` + a Notification Service Extension that
|
|
126
|
-
sent to the relay) — not implemented, and unnecessary for
|
|
135
|
+
This is acceptable **because the text is deliberately content-free**: a fixed scenario title
|
|
136
|
+
(localized per device) + the session name only — no model, project, or message content
|
|
137
|
+
(`runtime.js` → `toApnsGenericPayload` → `localizeApnsPayload`). The session name is the single
|
|
138
|
+
semi-personal field that crosses the relay/Apple. To hide even that from Apple would require an
|
|
139
|
+
end-to-end **encrypted payload** (`mutable-content` + a Notification Service Extension that
|
|
140
|
+
decrypts on-device with a key never sent to the relay) — not implemented, and unnecessary for
|
|
141
|
+
generic text.
|
|
127
142
|
|
|
128
143
|
## Android (FCM) note
|
|
129
144
|
|
|
@@ -7,7 +7,7 @@ This module provides notification message preparation utilities for the web serv
|
|
|
7
7
|
- `packages/web/server/lib/notifications/index.js`: public entrypoint imported by `packages/web/server/index.js`.
|
|
8
8
|
- `packages/web/server/lib/notifications/routes.js`: route registration for push, visibility, and session status/attention endpoints.
|
|
9
9
|
- `packages/web/server/lib/notifications/push-runtime.js`: push subscription persistence, VAPID initialization, and UI visibility runtime.
|
|
10
|
-
- `packages/web/server/lib/notifications/apns-runtime.js`: native iOS APNs device-token persistence + delivery. Two modes: **relay** (default — sign + POST tokens + generic text to the
|
|
10
|
+
- `packages/web/server/lib/notifications/apns-runtime.js`: native iOS APNs device-token persistence + delivery. Two modes: **relay** (default — sign + POST tokens + generic text to the isolated Push Relay in `packages/relay-server/src/push/`, which holds the project APNs key) and **direct** (fallback — sign ES256 JWT with Node crypto + HTTP/2, when `OPENCHAMBER_PUSH_RELAY_DISABLED=true`). Unless `OPENCHAMBER_PUSH_RELAY_URL` is set, the Host maps the effective Relay `wss`/`ws` URL to the same host as `https`/`http` `/v1/push/send`. Before each relay send, the runtime checks an in-memory registration success cache (keyed by current register URL + token + platform) and only POSTs `/v1/push/register-token` for cache misses; only successfully bound tokens are sent. Switching Relay/Push URL clears that cache so persisted tokens re-bind to the new origin on the next send or `reRegisterAllTokens()`. Each server has an auto-generated ECDSA P-256 keypair (`getOrCreateRelayKeypair`, persisted in settings); it binds tokens on the relay (`/v1/push/register-token`) and signs every relay request, so the relay only delivers to tokens bound to that server. APNs is the native app's sole notification channel (no local notifications) and is NOT gated on UI visibility — iOS suppresses the foreground banner instead. Mobile push carries only generic text (scenario title + session name) — see `APNS.md`.
|
|
11
11
|
- `packages/web/server/lib/notifications/emitter-runtime.js`: desktop/stdout + UI SSE notification emission runtime.
|
|
12
12
|
- `packages/web/server/lib/notifications/runtime.js`: trigger runtime for OpenCode event-driven notification fanout.
|
|
13
13
|
- `packages/web/server/lib/notifications/template-runtime.js`: notification template variables and session text/title enrichment runtime. Zen-model helpers are retained as compatibility stubs only.
|
|
@@ -67,12 +67,13 @@ This module provides notification message preparation utilities for the web serv
|
|
|
67
67
|
### APNs runtime API (apns-runtime.js)
|
|
68
68
|
- `createApnsRuntime(dependencies)`: creates runtime for native iOS APNs push and device-token state. Dependencies: `fsPromises`, `path`, `crypto`, `http2`, `APNS_TOKENS_FILE_PATH`, `readSettingsFromDiskMigrated`, `writeSettingsToDisk` (persists the auto-generated relay signing keypair).
|
|
69
69
|
- Returned API:
|
|
70
|
-
- `addOrUpdateApnsToken(uiSessionToken, deviceToken, userAgent)` — also binds a newly-seen token on the relay (signed `/v1/push/register-token`).
|
|
70
|
+
- `addOrUpdateApnsToken(uiSessionToken, deviceToken, userAgent, platform?, locale?)` — also binds a newly-seen token on the relay (signed `/v1/push/register-token`). Persists app UI `locale` for localized push titles.
|
|
71
71
|
- `removeApnsToken(uiSessionToken, deviceToken)`
|
|
72
72
|
- `removeApnsTokenFromAllSessions(deviceToken)`
|
|
73
|
-
- `sendApnsToAllUiSessions(payload)` —
|
|
73
|
+
- `sendApnsToAllUiSessions(payload)` — localizes scenario titles per stored token locale (`apns-titles.js`), groups tokens by locale, then signs + sends (no UI-visibility gate; iOS suppresses the foreground banner). Before a relay send, each token is checked against the in-memory registration success cache for the current register URL; cache hits skip `/v1/push/register-token`, misses are registered with bounded concurrency, and only successfully bound tokens are sent. Switching Relay/Push URL clears that cache so the next send re-binds persisted tokens to the new origin. No-ops with a single warning when APNs is unconfigured. Drops tokens on `410` / `BadDeviceToken` / `Unregistered`.
|
|
74
|
+
- `reRegisterAllTokens()` — binds every persisted device token to the current Push origin (same success cache and URL-switch invalidation as send).
|
|
74
75
|
- `resolveApnsConfig()`
|
|
75
|
-
- Configuration (env first, then `settings.apnsConfig`): `OPENCHAMBER_APNS_KEY_ID`, `OPENCHAMBER_APNS_TEAM_ID`, `OPENCHAMBER_APNS_P8` (PEM contents; literal `\n` accepted) or `OPENCHAMBER_APNS_P8_PATH`, `OPENCHAMBER_APNS_BUNDLE_ID` (default `com.openchamber
|
|
76
|
+
- Configuration (env first, then `settings.apnsConfig`): `OPENCHAMBER_PUSH_RELAY_URL` (optional send-URL override), `OPENCHAMBER_PUSH_RELAY_DISABLED`, `OPENCHAMBER_APNS_KEY_ID`, `OPENCHAMBER_APNS_TEAM_ID`, `OPENCHAMBER_APNS_P8` (PEM contents; literal `\n` accepted) or `OPENCHAMBER_APNS_P8_PATH`, `OPENCHAMBER_APNS_BUNDLE_ID` (default `com.yee94.openchamber`), `OPENCHAMBER_APNS_ENVIRONMENT` (`sandbox` default, or `production` for TestFlight and App Store).
|
|
76
77
|
|
|
77
78
|
### Emitter runtime API (emitter-runtime.js)
|
|
78
79
|
- `createNotificationEmitterRuntime(dependencies)`: creates runtime for unified notification emission channels.
|
|
@@ -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,
|