Files
openchamber/packages/ui/src/apps/useNativePushRegistration.ts
T
Bohdan Triapitsyn 9f1bd0dfa0 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.
2026-07-25 01:18:41 +03:00

119 lines
5.1 KiB
TypeScript

import React from 'react';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { getClientPlatform } from '@/lib/platform';
import { useUIStore } from '@/stores/useUIStore';
/**
* Registers the native iOS APNs device token with the connected server so the app can
* receive remote push even when suspended/closed. Delivery goes through the central relay
* (server posts generic text → relay signs+sends) — see
* `packages/web/server/lib/notifications/APNS.md`.
*
* Lazy-imports `@capacitor/push-notifications` (only present in the Capacitor shell),
* mirroring the other `@capacitor/*` integrations in MobileApp. On `registration` the
* device token is sent to the server via `apis.push.registerApnsToken`; tapping a push
* deep-links to its session. Pass `enabled = isNativeMobileApp && isConnected`; the hook
* additionally gates on the `nativeNotificationsEnabled` setting and re-registers when
* the connection (and thus the active server endpoint) changes.
*/
// Native push: iOS uses APNs, Android uses FCM. Both are set up natively (google-services.json +
// 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;
const platform = capacitor?.getPlatform?.();
return platform === 'ios' || platform === 'android';
};
export const useNativePushRegistration = (options: { enabled: boolean }): void => {
const { enabled } = options;
const nativeNotificationsEnabled = useUIStore((state) => state.nativeNotificationsEnabled);
const lastTokenRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (!enabled || !nativeNotificationsEnabled || !isNativePushPlatform()) {
return;
}
let disposed = false;
const cleanup: Array<() => void> = [];
void import('@capacitor/push-notifications')
.then(async ({ PushNotifications }) => {
if (disposed) return;
let permission = await PushNotifications.checkPermissions().catch(() => null);
if (permission?.receive !== 'granted') {
permission = await PushNotifications.requestPermissions().catch(() => null);
}
if (permission?.receive !== 'granted') {
return;
}
const registrationHandle = await PushNotifications.addListener('registration', (token) => {
lastTokenRef.current = token.value;
const apis = getRegisteredRuntimeAPIs();
void apis?.push?.registerApnsToken?.({
token: token.value,
platform: getClientPlatform(),
environment: getApnsEnvironment(),
});
});
const registrationErrorHandle = await PushNotifications.addListener('registrationError', (error) => {
console.warn('[Push] APNs registration error:', error);
});
// Note: notification-tap handling lives in the deep-link layer (`useDeepLinkSource`
// in deepLinkNavigation), registered unconditionally so cold-launch taps aren't lost
// while disconnected.
await PushNotifications.register().catch(() => undefined);
if (disposed) {
void registrationHandle.remove();
void registrationErrorHandle.remove();
return;
}
cleanup.push(
() => void registrationHandle.remove(),
() => void registrationErrorHandle.remove(),
);
})
.catch(() => undefined);
return () => {
disposed = true;
cleanup.forEach((remove) => remove());
};
}, [enabled, nativeNotificationsEnabled]);
// When notifications are turned off, drop the token from the server so it stops
// pushing to this device. (Separate from the register effect so a transient
// disconnect doesn't unregister.)
React.useEffect(() => {
if (nativeNotificationsEnabled) return;
const token = lastTokenRef.current;
if (!token) return;
lastTokenRef.current = null;
const apis = getRegisteredRuntimeAPIs();
void apis?.push?.unregisterApnsToken?.({ token });
}, [nativeNotificationsEnabled]);
};