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
@@ -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 = "<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
// 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)
@@ -11,7 +11,7 @@
<!--Bridge View Controller-->
<scene sceneID="tne-QT-ifu">
<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"/>
</objects>
</scene>