diff --git a/packages/mobile/ios/App/App/AppDelegate.swift b/packages/mobile/ios/App/App/AppDelegate.swift index 91ac5b2d..b46cb7cf 100644 --- a/packages/mobile/ios/App/App/AppDelegate.swift +++ b/packages/mobile/ios/App/App/AppDelegate.swift @@ -1,6 +1,7 @@ import UIKit import Capacitor import UserNotifications +import WebKit import WidgetKit @UIApplicationMain @@ -60,6 +61,42 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } +/// APNs environment of this build: "development" for Xcode/dev-signed installs, +/// "production" for TestFlight/App Store. Read from the embedded provisioning profile's +/// aps-environment entitlement; App Store builds carry no embedded profile and are +/// production. Exposed to the web layer so the server can deliver each device token to +/// the APNs endpoint that actually knows it (sandbox vs production). +let apnsEnvironment: String = { + guard let path = Bundle.main.path(forResource: "embedded", ofType: "mobileprovision"), + let data = FileManager.default.contents(atPath: path), + // isoLatin1, not ascii/utf8: the profile is a binary CMS envelope around the XML + // plist, and only Latin-1 decodes arbitrary bytes without returning nil. + let profile = String(data: data, encoding: .isoLatin1) else { + return "production" + } + let pattern = "aps-environment\\s*development" + return profile.range(of: pattern, options: .regularExpression) != nil ? "development" : "production" +}() + +/// Bridge subclass (referenced from Main.storyboard) whose only job is to expose the APNs +/// environment as a document-start user script. This runs before any page JS, so token +/// registration (useNativePushRegistration) always sees it — injecting later from the scene +/// lifecycle raced the registration call and lost on first launch. +/// +/// The script must be added in capacitorDidLoad(), NOT webViewConfiguration(for:): Capacitor's +/// prepareWebView replaces the configuration's userContentController with its own right after +/// calling webViewConfiguration(for:), which silently discards any user script added there. +/// capacitorDidLoad() runs after that swap but before loadWebView() starts the initial page load. +class BridgeViewController: CAPBridgeViewController { + override func capacitorDidLoad() { + super.capacitorDidLoad() + let source = "window.__OPENCHAMBER_APNS_ENV__ = '\(apnsEnvironment)';" + webView?.configuration.userContentController.addUserScript( + WKUserScript(source: source, injectionTime: .atDocumentStart, forMainFrameOnly: true) + ) + } +} + // iOS 26 (TN3187) requires apps built with the latest SDK to adopt the UIScene // lifecycle. Capacitor 7's template still uses the legacy window setup, so we host a // minimal scene delegate here that loads the Main storyboard (CAPBridgeViewController) diff --git a/packages/mobile/ios/App/App/Base.lproj/Main.storyboard b/packages/mobile/ios/App/App/Base.lproj/Main.storyboard index b44df7be..fa2b5cce 100644 --- a/packages/mobile/ios/App/App/Base.lproj/Main.storyboard +++ b/packages/mobile/ios/App/App/Base.lproj/Main.storyboard @@ -11,7 +11,7 @@ - + diff --git a/packages/ui/src/apps/useNativePushRegistration.ts b/packages/ui/src/apps/useNativePushRegistration.ts index 2c1c7fdf..31745856 100644 --- a/packages/ui/src/apps/useNativePushRegistration.ts +++ b/packages/ui/src/apps/useNativePushRegistration.ts @@ -21,6 +21,19 @@ import { useUIStore } from '@/stores/useUIStore'; // the Google Services Gradle plugin on Android), so @capacitor/push-notifications' register() // returns the right token per platform. The token is sent to the server tagged with its platform // so the relay routes it to APNs vs FCM. +// APNs environment of this build. Xcode/dev-signed installs get sandbox device tokens, +// TestFlight/App Store installs get production ones; the native iOS shell reports which via +// a global injected in SceneDelegate (see packages/mobile/ios/App/App/AppDelegate.swift). +// Undefined when the global is absent (Android, or a shell predating the injection) — the +// server then defaults to production, matching released builds. +const getApnsEnvironment = (): 'sandbox' | 'production' | undefined => { + if (typeof window === 'undefined') return undefined; + const env = (window as typeof window & { __OPENCHAMBER_APNS_ENV__?: string }).__OPENCHAMBER_APNS_ENV__; + if (env === 'development') return 'sandbox'; + if (env === 'production') return 'production'; + return undefined; +}; + const isNativePushPlatform = (): boolean => { if (typeof window === 'undefined') return false; const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor; @@ -56,7 +69,11 @@ export const useNativePushRegistration = (options: { enabled: boolean }): void = const registrationHandle = await PushNotifications.addListener('registration', (token) => { lastTokenRef.current = token.value; const apis = getRegisteredRuntimeAPIs(); - void apis?.push?.registerApnsToken?.({ token: token.value, platform: getClientPlatform() }); + void apis?.push?.registerApnsToken?.({ + token: token.value, + platform: getClientPlatform(), + environment: getApnsEnvironment(), + }); }); const registrationErrorHandle = await PushNotifications.addListener('registrationError', (error) => { diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 261aeb30..04aed49b 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -778,6 +778,11 @@ export interface ApnsTokenPayload { token: string; /** 'ios' (APNs) or 'android' (FCM) — lets the relay route the token to the right service. */ platform?: string; + /** + * APNs environment the token belongs to: 'sandbox' for Xcode/dev-signed installs, + * 'production' for TestFlight/App Store. Omitted when unknown (server defaults to production). + */ + environment?: 'sandbox' | 'production'; } export interface PushAPI { diff --git a/packages/web/server/lib/notifications/APNS.md b/packages/web/server/lib/notifications/APNS.md index efa615db..d6b4368b 100644 --- a/packages/web/server/lib/notifications/APNS.md +++ b/packages/web/server/lib/notifications/APNS.md @@ -76,7 +76,11 @@ device token of a server sees the same badge. Server (`apns-runtime.js`): - `OPENCHAMBER_PUSH_RELAY_URL` (default the public relay), `OPENCHAMBER_APNS_ENVIRONMENT` - (`production` default / `sandbox` for development builds). The signing keypair is auto-generated — nothing to set. + (optional override forcing every send to `sandbox` or `production`; normally unset — each + token is delivered to the environment it registered with: the iOS shell reads the + `aps-environment` entitlement from the embedded provisioning profile and reports it at + registration, so Xcode dev builds go to sandbox and TestFlight/App Store to production). + The signing keypair is auto-generated — nothing to set. - Direct fallback: `OPENCHAMBER_APNS_KEY_ID`, `OPENCHAMBER_APNS_TEAM_ID`, `OPENCHAMBER_APNS_P8` (or `_P8_PATH`), `OPENCHAMBER_APNS_BUNDLE_ID`, `OPENCHAMBER_PUSH_RELAY_DISABLED=true`. diff --git a/packages/web/server/lib/notifications/DOCUMENTATION.md b/packages/web/server/lib/notifications/DOCUMENTATION.md index 8dd53532..52bf1033 100644 --- a/packages/web/server/lib/notifications/DOCUMENTATION.md +++ b/packages/web/server/lib/notifications/DOCUMENTATION.md @@ -67,12 +67,12 @@ This module provides notification message preparation utilities for the web serv ### APNs runtime API (apns-runtime.js) - `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). - Returned API: - - `addOrUpdateApnsToken(uiSessionToken, deviceToken, userAgent)` — also binds a newly-seen token on the relay (signed `/v1/push/register-token`). + - `addOrUpdateApnsToken(uiSessionToken, deviceToken, userAgent, platform, environment)` — also binds a newly-seen token on the relay (signed `/v1/push/register-token`). `environment` is the APNs environment the token was minted for (`sandbox` for Xcode/dev-signed installs, `production` otherwise — reported by the client at registration); delivery groups tokens by it. - `removeApnsToken(uiSessionToken, deviceToken)` - `removeApnsTokenFromAllSessions(deviceToken)` - `sendApnsToAllUiSessions(payload)` — signs + sends to all registered tokens (no UI-visibility gate; iOS suppresses the foreground banner). No-ops with a single warning when APNs is unconfigured. Drops tokens on `410` / `BadDeviceToken` / `Unregistered`. - `resolveApnsConfig()` -- 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.app`), `OPENCHAMBER_APNS_ENVIRONMENT` (`production` default, or `sandbox` for development builds). +- 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.app`), `OPENCHAMBER_APNS_ENVIRONMENT` (optional override forcing every send to `sandbox` or `production`; when unset, each token is delivered to the environment it registered with, defaulting to `production` for tokens without one). ### Emitter runtime API (emitter-runtime.js) - `createNotificationEmitterRuntime(dependencies)`: creates runtime for unified notification emission channels. diff --git a/packages/web/server/lib/notifications/apns-runtime.js b/packages/web/server/lib/notifications/apns-runtime.js index 0c0056d3..759c4c59 100644 --- a/packages/web/server/lib/notifications/apns-runtime.js +++ b/packages/web/server/lib/notifications/apns-runtime.js @@ -149,6 +149,11 @@ export const createApnsRuntime = (deps) => { userAgent: typeof entry.userAgent === 'string' ? entry.userAgent : undefined, // 'ios' (APNs) or 'android' (FCM). Older entries without one are APNs by default. platform: entry.platform === 'android' ? 'android' : 'ios', + // APNs delivery environment for this token. Xcode/dev-signed installs produce + // sandbox tokens, TestFlight/App Store produce production ones; the client reports + // which at registration. Older entries without one default to production (matches + // released builds). + environment: entry.environment === 'sandbox' ? 'sandbox' : 'production', }; }) .filter(Boolean); @@ -158,10 +163,13 @@ export const createApnsRuntime = (deps) => { // was the only registrant before Android/FCM existed. const normalizePlatform = (platform) => (platform === 'android' ? 'android' : 'ios'); - const addOrUpdateApnsToken = async (uiSessionToken, deviceToken, userAgent, platform) => { + const normalizeEnvironment = (environment) => (environment === 'sandbox' ? 'sandbox' : 'production'); + + const addOrUpdateApnsToken = async (uiSessionToken, deviceToken, userAgent, platform, environment) => { if (!uiSessionToken || typeof deviceToken !== 'string' || deviceToken.trim().length === 0) return; const token = deviceToken.trim(); const tokenPlatform = normalizePlatform(platform); + const tokenEnvironment = normalizeEnvironment(environment); const now = Date.now(); await persistTokenUpdate((current) => { @@ -174,6 +182,7 @@ export const createApnsRuntime = (deps) => { lastSeenAt: now, userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined, platform: tokenPlatform, + environment: tokenEnvironment, }); tokensBySession[uiSessionToken] = filtered.slice(0, MAX_TOKENS_PER_SESSION); return { version: APNS_TOKENS_VERSION, tokensBySession }; @@ -256,7 +265,9 @@ export const createApnsRuntime = (deps) => { teamId, p8, bundleId: bundleId || DEFAULT_BUNDLE_ID, - environment: environment === 'sandbox' ? 'sandbox' : 'production', + // Explicit env/settings value forces every send to that environment; when unset (null), + // each token is delivered to the environment it registered with. + environment: environment === 'sandbox' ? 'sandbox' : environment === 'production' ? 'production' : null, }; }; @@ -370,17 +381,17 @@ export const createApnsRuntime = (deps) => { const resolveRelayConfig = () => { if (trimmedEnv('OPENCHAMBER_PUSH_RELAY_DISABLED') === 'true') return null; const url = trimmedEnv('OPENCHAMBER_PUSH_RELAY_URL') || DEFAULT_RELAY_URL; + const override = (trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || '').toLowerCase(); return { url, registerUrl: url.replace(/\/send$/, '/register-token'), - environment: - (trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || 'production').toLowerCase() === 'sandbox' - ? 'sandbox' - : 'production', + // Explicit OPENCHAMBER_APNS_ENVIRONMENT forces every send to that environment; when + // unset (null), each token is delivered to the environment it registered with. + environment: override === 'sandbox' ? 'sandbox' : override === 'production' ? 'production' : null, }; }; - const sendViaRelay = async (deviceTokens, payload, relay) => { + const sendViaRelay = async (deviceTokens, payload, relay, environment) => { const tokens = deviceTokens.slice(0, 100); const title = typeof payload?.title === 'string' && payload.title.length > 0 ? payload.title : 'OpenChamber'; const { privateKey, publicJwk } = await getOrCreateRelayKeypair(); @@ -393,7 +404,7 @@ export const createApnsRuntime = (deps) => { body: typeof payload?.body === 'string' ? payload.body : '', badge: Number.isFinite(payload?.badge) && payload.badge >= 0 ? Math.trunc(payload.badge) : undefined, collapseId: typeof payload?.tag === 'string' ? payload.tag.slice(0, 64) : undefined, - env: relay.environment, + env: environment, data: payload?.data && typeof payload.data === 'object' ? payload.data : undefined, publicKeyJwk: relayPublicJwk(publicJwk), ts, @@ -421,7 +432,7 @@ export const createApnsRuntime = (deps) => { } }; - const sendViaDirectApns = async (deviceTokens, payload) => { + const sendViaDirectApns = async (tokenGroups, payload) => { const config = await resolveApnsConfig(); if (!config) { if (!warnedUnconfigured) { @@ -433,39 +444,45 @@ export const createApnsRuntime = (deps) => { return; } - const host = config.environment === 'production' ? APNS_HOST_PRODUCTION : APNS_HOST_SANDBOX; const jwt = getJwt(config); const body = buildBody(payload); const sendConfig = { ...config, tag: typeof payload?.tag === 'string' ? payload.tag : undefined }; - let client; - try { - client = http2.connect(host); - } catch (error) { - console.warn('[APNs] connect failed:', error?.message ?? error); - return; - } + // One HTTP/2 session per APNs environment; a sandbox token sent to the production host + // (or vice versa) gets BadDeviceToken and would be wrongly dropped as dead. + for (const [environment, deviceTokens] of tokenGroups) { + const effectiveEnvironment = config.environment ?? environment; + const host = effectiveEnvironment === 'sandbox' ? APNS_HOST_SANDBOX : APNS_HOST_PRODUCTION; - await new Promise((resolve) => { - let settled = false; - const finish = () => { - if (settled) return; - settled = true; - try { - client.close(); - } catch { - // ignore close errors - } - resolve(); - }; - client.on('error', (error) => { - console.warn('[APNs] session error:', error?.message ?? error); - finish(); + let client; + try { + client = http2.connect(host); + } catch (error) { + console.warn('[APNs] connect failed:', error?.message ?? error); + continue; + } + + await new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + try { + client.close(); + } catch { + // ignore close errors + } + resolve(); + }; + client.on('error', (error) => { + console.warn('[APNs] session error:', error?.message ?? error); + finish(); + }); + Promise.all( + deviceTokens.map((token) => sendOne(client, token, body, jwt, sendConfig)), + ).finally(finish); }); - Promise.all( - deviceTokens.map((token) => sendOne(client, token, body, jwt, sendConfig)), - ).finally(finish); - }); + } }; // NOT gated on UI visibility (unlike web push). A backgrounded WKWebView can't reliably @@ -475,24 +492,30 @@ export const createApnsRuntime = (deps) => { // capacitor.config) — so there is no notification when the app is active, with no race. const sendApnsToAllUiSessions = async (payload, _options = {}) => { const store = await readTokensFromDisk(); - const deviceTokens = []; + // Tokens are grouped by their registered APNs environment so each batch goes to the + // endpoint that actually knows the token (Xcode builds → sandbox, TestFlight/App Store + // → production). Mixing them gets BadDeviceToken and the token wrongly dropped as dead. + const tokensByEnvironment = new Map(); const seen = new Set(); for (const record of Object.values(store.tokensBySession || {})) { for (const entry of normalizeTokens(record)) { - if (!seen.has(entry.deviceToken)) { - seen.add(entry.deviceToken); - deviceTokens.push(entry.deviceToken); - } + if (seen.has(entry.deviceToken)) continue; + seen.add(entry.deviceToken); + const group = tokensByEnvironment.get(entry.environment) || []; + group.push(entry.deviceToken); + tokensByEnvironment.set(entry.environment, group); } } - if (deviceTokens.length === 0) return; + if (seen.size === 0) return; const relay = resolveRelayConfig(); if (relay) { - await sendViaRelay(deviceTokens, payload, relay); + for (const [environment, deviceTokens] of tokensByEnvironment) { + await sendViaRelay(deviceTokens, payload, relay, relay.environment ?? environment); + } return; } - await sendViaDirectApns(deviceTokens, payload); + await sendViaDirectApns(tokensByEnvironment, payload); }; return { diff --git a/packages/web/server/lib/notifications/apns-runtime.test.js b/packages/web/server/lib/notifications/apns-runtime.test.js index 5c15d95b..df02e1ff 100644 --- a/packages/web/server/lib/notifications/apns-runtime.test.js +++ b/packages/web/server/lib/notifications/apns-runtime.test.js @@ -146,20 +146,40 @@ describe('apns runtime relay mode (default)', () => { expect(deps.writeSettingsToDisk).toHaveBeenCalledTimes(1); }); - it('honors an explicit sandbox environment', async () => { + it('honors an explicit sandbox environment override for every token', async () => { const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] })); vi.stubGlobal('fetch', fetchMock); process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send'; process.env.OPENCHAMBER_APNS_ENVIRONMENT = 'sandbox'; const runtime = createApnsRuntime(makeDeps()); - await runtime.addOrUpdateApnsToken('s1', 'tokenA'); + await runtime.addOrUpdateApnsToken('s1', 'tokenA', undefined, 'ios', 'production'); await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' }); const sent = JSON.parse(fetchMock.mock.calls.find(isSend)[1].body); expect(sent.env).toBe('sandbox'); }); + it('routes each token to its registered environment (dev build sandbox, release production)', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] })); + vi.stubGlobal('fetch', fetchMock); + process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send'; + + const runtime = createApnsRuntime(makeDeps()); + await runtime.addOrUpdateApnsToken('s1', 'tokenXcode', undefined, 'ios', 'sandbox'); + await runtime.addOrUpdateApnsToken('s2', 'tokenStore', undefined, 'ios', 'production'); + await runtime.addOrUpdateApnsToken('s3', 'tokenLegacy'); // no environment → production + + fetchMock.mockClear(); + await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' }); + + const sends = fetchMock.mock.calls.filter(isSend).map(([, init]) => JSON.parse(init.body)); + expect(sends).toHaveLength(2); + const byEnv = Object.fromEntries(sends.map((s) => [s.env, new Set(s.tokens)])); + expect(byEnv.sandbox).toEqual(new Set(['tokenXcode'])); + expect(byEnv.production).toEqual(new Set(['tokenStore', 'tokenLegacy'])); + }); + it('no-ops (no relay call) when no tokens are registered', async () => { const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); @@ -170,13 +190,54 @@ describe('apns runtime relay mode (default)', () => { }); describe('apns runtime direct fallback (relay disabled)', () => { - it('defaults direct APNs configuration to production', async () => { + it('leaves direct APNs environment unset without an explicit override (per-token routing)', async () => { const { environment: _environment, ...configWithoutEnvironment } = APNS_CONFIG; const runtime = createApnsRuntime( makeDeps({ readSettingsFromDiskMigrated: vi.fn(async () => ({ apnsConfig: configWithoutEnvironment })) }), ); - await expect(runtime.resolveApnsConfig()).resolves.toMatchObject({ environment: 'production' }); + await expect(runtime.resolveApnsConfig()).resolves.toMatchObject({ environment: null }); + }); + + it('sends each token to the APNs host of its registered environment', async () => { + process.env.OPENCHAMBER_PUSH_RELAY_DISABLED = 'true'; + const { environment: _environment, ...configWithoutEnvironment } = APNS_CONFIG; + const hosts = []; + const http2 = { + connect: (host) => { + const targeted = []; + hosts.push({ host, targeted }); + return { + on: () => {}, + close: () => {}, + request: (headers) => { + targeted.push(String(headers[':path']).replace('/3/device/', '')); + const listeners = {}; + const req = { + on: (event, cb) => { listeners[event] = cb; return req; }, + setEncoding: () => req, + end: () => { + queueMicrotask(() => { + listeners.response?.({ ':status': '200' }); + listeners.end?.(); + }); + }, + }; + return req; + }, + }; + }, + }; + const runtime = createApnsRuntime( + makeDeps({ http2, readSettingsFromDiskMigrated: vi.fn(async () => ({ apnsConfig: configWithoutEnvironment })) }), + ); + await runtime.addOrUpdateApnsToken('s1', 'tokenXcode', undefined, 'ios', 'sandbox'); + await runtime.addOrUpdateApnsToken('s2', 'tokenStore', undefined, 'ios', 'production'); + await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b', tag: 'ready-x' }); + + const byHost = Object.fromEntries(hosts.map(({ host, targeted }) => [host, targeted])); + expect(byHost['https://api.sandbox.push.apple.com']).toEqual(['tokenXcode']); + expect(byHost['https://api.push.apple.com']).toEqual(['tokenStore']); }); it('signs an ES256 JWT and sends over http2 when relay is disabled', async () => { diff --git a/packages/web/server/lib/notifications/routes.js b/packages/web/server/lib/notifications/routes.js index 4f291a28..27507e0b 100644 --- a/packages/web/server/lib/notifications/routes.js +++ b/packages/web/server/lib/notifications/routes.js @@ -162,8 +162,11 @@ export const registerNotificationRoutes = (app, dependencies) => { } const platform = req.body?.platform === 'android' ? 'android' : 'ios'; + // APNs environment the token belongs to: Xcode/dev-signed installs report 'sandbox', + // TestFlight/App Store report 'production'. Absent (older clients, Android) → production. + const environment = req.body?.environment === 'sandbox' ? 'sandbox' : 'production'; if (typeof addOrUpdateApnsToken === 'function') { - await addOrUpdateApnsToken(uiToken, deviceToken, req.headers['user-agent'], platform); + await addOrUpdateApnsToken(uiToken, deviceToken, req.headers['user-agent'], platform, environment); } return res.json({ ok: true }); });