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:
Bohdan Triapitsyn
2026-07-25 01:18:41 +03:00
parent fe0ef0d1da
commit 9f1bd0dfa0
9 changed files with 204 additions and 54 deletions
@@ -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) => {
+5
View File
@@ -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 {