fix: route APNs delivery per-token by registered environment
Issue: after defaulting APNs delivery to production (#2381), development builds installed from Xcode stopped receiving notifications entirely: their sandbox device tokens were sent to the production APNs endpoint, rejected as BadDeviceToken, and dropped as dead. Fix: the iOS shell reads the aps-environment entitlement from the embedded provisioning profile and exposes it to the web layer as a document-start user script (added in capacitorDidLoad, since Capacitor replaces the userContentController after webViewConfiguration(for:)). Token registration reports the environment to the server, which stores it per token and groups delivery by environment for both relay and direct APNs sends. OPENCHAMBER_APNS_ENVIRONMENT remains as an explicit override forcing every send to one environment. TestFlight/App Store builds and older clients without the field default to production, preserving released behavior; the relay already accepts env per send request.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import Capacitor
|
import Capacitor
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
import WebKit
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
|
||||||
@UIApplicationMain
|
@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 = "<key>aps-environment</key>\\s*<string>development</string>"
|
||||||
|
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
|
// 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
|
// 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)
|
// minimal scene delegate here that loads the Main storyboard (CAPBridgeViewController)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<!--Bridge View Controller-->
|
<!--Bridge View Controller-->
|
||||||
<scene sceneID="tne-QT-ifu">
|
<scene sceneID="tne-QT-ifu">
|
||||||
<objects>
|
<objects>
|
||||||
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
|
<viewController id="BYZ-38-t0r" customClass="BridgeViewController" customModule="App" customModuleProvider="target" sceneMemberID="viewController"/>
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||||
</objects>
|
</objects>
|
||||||
</scene>
|
</scene>
|
||||||
|
|||||||
@@ -21,6 +21,19 @@ import { useUIStore } from '@/stores/useUIStore';
|
|||||||
// the Google Services Gradle plugin on Android), so @capacitor/push-notifications' register()
|
// 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
|
// 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.
|
// 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 => {
|
const isNativePushPlatform = (): boolean => {
|
||||||
if (typeof window === 'undefined') return false;
|
if (typeof window === 'undefined') return false;
|
||||||
const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor;
|
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) => {
|
const registrationHandle = await PushNotifications.addListener('registration', (token) => {
|
||||||
lastTokenRef.current = token.value;
|
lastTokenRef.current = token.value;
|
||||||
const apis = getRegisteredRuntimeAPIs();
|
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) => {
|
const registrationErrorHandle = await PushNotifications.addListener('registrationError', (error) => {
|
||||||
|
|||||||
@@ -778,6 +778,11 @@ export interface ApnsTokenPayload {
|
|||||||
token: string;
|
token: string;
|
||||||
/** 'ios' (APNs) or 'android' (FCM) — lets the relay route the token to the right service. */
|
/** 'ios' (APNs) or 'android' (FCM) — lets the relay route the token to the right service. */
|
||||||
platform?: string;
|
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 {
|
export interface PushAPI {
|
||||||
|
|||||||
@@ -76,7 +76,11 @@ device token of a server sees the same badge.
|
|||||||
|
|
||||||
Server (`apns-runtime.js`):
|
Server (`apns-runtime.js`):
|
||||||
- `OPENCHAMBER_PUSH_RELAY_URL` (default the public relay), `OPENCHAMBER_APNS_ENVIRONMENT`
|
- `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`
|
- 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`.
|
(or `_P8_PATH`), `OPENCHAMBER_APNS_BUNDLE_ID`, `OPENCHAMBER_PUSH_RELAY_DISABLED=true`.
|
||||||
|
|
||||||
|
|||||||
@@ -67,12 +67,12 @@ This module provides notification message preparation utilities for the web serv
|
|||||||
### APNs runtime API (apns-runtime.js)
|
### 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).
|
- `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:
|
- 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)`
|
- `removeApnsToken(uiSessionToken, deviceToken)`
|
||||||
- `removeApnsTokenFromAllSessions(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`.
|
- `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()`
|
- `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)
|
### Emitter runtime API (emitter-runtime.js)
|
||||||
- `createNotificationEmitterRuntime(dependencies)`: creates runtime for unified notification emission channels.
|
- `createNotificationEmitterRuntime(dependencies)`: creates runtime for unified notification emission channels.
|
||||||
|
|||||||
@@ -149,6 +149,11 @@ export const createApnsRuntime = (deps) => {
|
|||||||
userAgent: typeof entry.userAgent === 'string' ? entry.userAgent : undefined,
|
userAgent: typeof entry.userAgent === 'string' ? entry.userAgent : undefined,
|
||||||
// 'ios' (APNs) or 'android' (FCM). Older entries without one are APNs by default.
|
// 'ios' (APNs) or 'android' (FCM). Older entries without one are APNs by default.
|
||||||
platform: entry.platform === 'android' ? 'android' : 'ios',
|
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);
|
.filter(Boolean);
|
||||||
@@ -158,10 +163,13 @@ export const createApnsRuntime = (deps) => {
|
|||||||
// was the only registrant before Android/FCM existed.
|
// was the only registrant before Android/FCM existed.
|
||||||
const normalizePlatform = (platform) => (platform === 'android' ? 'android' : 'ios');
|
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;
|
if (!uiSessionToken || typeof deviceToken !== 'string' || deviceToken.trim().length === 0) return;
|
||||||
const token = deviceToken.trim();
|
const token = deviceToken.trim();
|
||||||
const tokenPlatform = normalizePlatform(platform);
|
const tokenPlatform = normalizePlatform(platform);
|
||||||
|
const tokenEnvironment = normalizeEnvironment(environment);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
await persistTokenUpdate((current) => {
|
await persistTokenUpdate((current) => {
|
||||||
@@ -174,6 +182,7 @@ export const createApnsRuntime = (deps) => {
|
|||||||
lastSeenAt: now,
|
lastSeenAt: now,
|
||||||
userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined,
|
userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined,
|
||||||
platform: tokenPlatform,
|
platform: tokenPlatform,
|
||||||
|
environment: tokenEnvironment,
|
||||||
});
|
});
|
||||||
tokensBySession[uiSessionToken] = filtered.slice(0, MAX_TOKENS_PER_SESSION);
|
tokensBySession[uiSessionToken] = filtered.slice(0, MAX_TOKENS_PER_SESSION);
|
||||||
return { version: APNS_TOKENS_VERSION, tokensBySession };
|
return { version: APNS_TOKENS_VERSION, tokensBySession };
|
||||||
@@ -256,7 +265,9 @@ export const createApnsRuntime = (deps) => {
|
|||||||
teamId,
|
teamId,
|
||||||
p8,
|
p8,
|
||||||
bundleId: bundleId || DEFAULT_BUNDLE_ID,
|
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 = () => {
|
const resolveRelayConfig = () => {
|
||||||
if (trimmedEnv('OPENCHAMBER_PUSH_RELAY_DISABLED') === 'true') return null;
|
if (trimmedEnv('OPENCHAMBER_PUSH_RELAY_DISABLED') === 'true') return null;
|
||||||
const url = trimmedEnv('OPENCHAMBER_PUSH_RELAY_URL') || DEFAULT_RELAY_URL;
|
const url = trimmedEnv('OPENCHAMBER_PUSH_RELAY_URL') || DEFAULT_RELAY_URL;
|
||||||
|
const override = (trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || '').toLowerCase();
|
||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
registerUrl: url.replace(/\/send$/, '/register-token'),
|
registerUrl: url.replace(/\/send$/, '/register-token'),
|
||||||
environment:
|
// Explicit OPENCHAMBER_APNS_ENVIRONMENT forces every send to that environment; when
|
||||||
(trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || 'production').toLowerCase() === 'sandbox'
|
// unset (null), each token is delivered to the environment it registered with.
|
||||||
? 'sandbox'
|
environment: override === 'sandbox' ? 'sandbox' : override === 'production' ? 'production' : null,
|
||||||
: 'production',
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendViaRelay = async (deviceTokens, payload, relay) => {
|
const sendViaRelay = async (deviceTokens, payload, relay, environment) => {
|
||||||
const tokens = deviceTokens.slice(0, 100);
|
const tokens = deviceTokens.slice(0, 100);
|
||||||
const title = typeof payload?.title === 'string' && payload.title.length > 0 ? payload.title : 'OpenChamber';
|
const title = typeof payload?.title === 'string' && payload.title.length > 0 ? payload.title : 'OpenChamber';
|
||||||
const { privateKey, publicJwk } = await getOrCreateRelayKeypair();
|
const { privateKey, publicJwk } = await getOrCreateRelayKeypair();
|
||||||
@@ -393,7 +404,7 @@ export const createApnsRuntime = (deps) => {
|
|||||||
body: typeof payload?.body === 'string' ? payload.body : '',
|
body: typeof payload?.body === 'string' ? payload.body : '',
|
||||||
badge: Number.isFinite(payload?.badge) && payload.badge >= 0 ? Math.trunc(payload.badge) : undefined,
|
badge: Number.isFinite(payload?.badge) && payload.badge >= 0 ? Math.trunc(payload.badge) : undefined,
|
||||||
collapseId: typeof payload?.tag === 'string' ? payload.tag.slice(0, 64) : 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,
|
data: payload?.data && typeof payload.data === 'object' ? payload.data : undefined,
|
||||||
publicKeyJwk: relayPublicJwk(publicJwk),
|
publicKeyJwk: relayPublicJwk(publicJwk),
|
||||||
ts,
|
ts,
|
||||||
@@ -421,7 +432,7 @@ export const createApnsRuntime = (deps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendViaDirectApns = async (deviceTokens, payload) => {
|
const sendViaDirectApns = async (tokenGroups, payload) => {
|
||||||
const config = await resolveApnsConfig();
|
const config = await resolveApnsConfig();
|
||||||
if (!config) {
|
if (!config) {
|
||||||
if (!warnedUnconfigured) {
|
if (!warnedUnconfigured) {
|
||||||
@@ -433,39 +444,45 @@ export const createApnsRuntime = (deps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = config.environment === 'production' ? APNS_HOST_PRODUCTION : APNS_HOST_SANDBOX;
|
|
||||||
const jwt = getJwt(config);
|
const jwt = getJwt(config);
|
||||||
const body = buildBody(payload);
|
const body = buildBody(payload);
|
||||||
const sendConfig = { ...config, tag: typeof payload?.tag === 'string' ? payload.tag : undefined };
|
const sendConfig = { ...config, tag: typeof payload?.tag === 'string' ? payload.tag : undefined };
|
||||||
|
|
||||||
let client;
|
// One HTTP/2 session per APNs environment; a sandbox token sent to the production host
|
||||||
try {
|
// (or vice versa) gets BadDeviceToken and would be wrongly dropped as dead.
|
||||||
client = http2.connect(host);
|
for (const [environment, deviceTokens] of tokenGroups) {
|
||||||
} catch (error) {
|
const effectiveEnvironment = config.environment ?? environment;
|
||||||
console.warn('[APNs] connect failed:', error?.message ?? error);
|
const host = effectiveEnvironment === 'sandbox' ? APNS_HOST_SANDBOX : APNS_HOST_PRODUCTION;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise((resolve) => {
|
let client;
|
||||||
let settled = false;
|
try {
|
||||||
const finish = () => {
|
client = http2.connect(host);
|
||||||
if (settled) return;
|
} catch (error) {
|
||||||
settled = true;
|
console.warn('[APNs] connect failed:', error?.message ?? error);
|
||||||
try {
|
continue;
|
||||||
client.close();
|
}
|
||||||
} catch {
|
|
||||||
// ignore close errors
|
await new Promise((resolve) => {
|
||||||
}
|
let settled = false;
|
||||||
resolve();
|
const finish = () => {
|
||||||
};
|
if (settled) return;
|
||||||
client.on('error', (error) => {
|
settled = true;
|
||||||
console.warn('[APNs] session error:', error?.message ?? error);
|
try {
|
||||||
finish();
|
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
|
// 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.
|
// capacitor.config) — so there is no notification when the app is active, with no race.
|
||||||
const sendApnsToAllUiSessions = async (payload, _options = {}) => {
|
const sendApnsToAllUiSessions = async (payload, _options = {}) => {
|
||||||
const store = await readTokensFromDisk();
|
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();
|
const seen = new Set();
|
||||||
for (const record of Object.values(store.tokensBySession || {})) {
|
for (const record of Object.values(store.tokensBySession || {})) {
|
||||||
for (const entry of normalizeTokens(record)) {
|
for (const entry of normalizeTokens(record)) {
|
||||||
if (!seen.has(entry.deviceToken)) {
|
if (seen.has(entry.deviceToken)) continue;
|
||||||
seen.add(entry.deviceToken);
|
seen.add(entry.deviceToken);
|
||||||
deviceTokens.push(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();
|
const relay = resolveRelayConfig();
|
||||||
if (relay) {
|
if (relay) {
|
||||||
await sendViaRelay(deviceTokens, payload, relay);
|
for (const [environment, deviceTokens] of tokensByEnvironment) {
|
||||||
|
await sendViaRelay(deviceTokens, payload, relay, relay.environment ?? environment);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await sendViaDirectApns(deviceTokens, payload);
|
await sendViaDirectApns(tokensByEnvironment, payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -146,20 +146,40 @@ describe('apns runtime relay mode (default)', () => {
|
|||||||
expect(deps.writeSettingsToDisk).toHaveBeenCalledTimes(1);
|
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: [] }));
|
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
||||||
process.env.OPENCHAMBER_APNS_ENVIRONMENT = 'sandbox';
|
process.env.OPENCHAMBER_APNS_ENVIRONMENT = 'sandbox';
|
||||||
|
|
||||||
const runtime = createApnsRuntime(makeDeps());
|
const runtime = createApnsRuntime(makeDeps());
|
||||||
await runtime.addOrUpdateApnsToken('s1', 'tokenA');
|
await runtime.addOrUpdateApnsToken('s1', 'tokenA', undefined, 'ios', 'production');
|
||||||
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
|
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
|
||||||
|
|
||||||
const sent = JSON.parse(fetchMock.mock.calls.find(isSend)[1].body);
|
const sent = JSON.parse(fetchMock.mock.calls.find(isSend)[1].body);
|
||||||
expect(sent.env).toBe('sandbox');
|
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 () => {
|
it('no-ops (no relay call) when no tokens are registered', async () => {
|
||||||
const fetchMock = vi.fn();
|
const fetchMock = vi.fn();
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
@@ -170,13 +190,54 @@ describe('apns runtime relay mode (default)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('apns runtime direct fallback (relay disabled)', () => {
|
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 { environment: _environment, ...configWithoutEnvironment } = APNS_CONFIG;
|
||||||
const runtime = createApnsRuntime(
|
const runtime = createApnsRuntime(
|
||||||
makeDeps({ readSettingsFromDiskMigrated: vi.fn(async () => ({ apnsConfig: configWithoutEnvironment })) }),
|
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 () => {
|
it('signs an ES256 JWT and sends over http2 when relay is disabled', async () => {
|
||||||
|
|||||||
@@ -162,8 +162,11 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const platform = req.body?.platform === 'android' ? 'android' : 'ios';
|
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') {
|
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 });
|
return res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user