From cc65359645b057a4c3d8921b10ae32305772646a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 1 Sep 2026 17:32:56 -0400 Subject: [PATCH 1/7] feat(push): hold a notification tap that arrives before setup() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cold launch from a notification tap delivers the response ~150ms in, before a Flutter or React Native host can reach setup() from its own runtime, so the swizzles were not yet installed and the open was lost. Adds prewarmPushNotificationOpenCapture() to install the notification delegate swizzles early and hold one response until the integration subscribes, and warns when no UNUserNotificationCenter delegate exists at all — the case where taps reach nobody and nothing is capturable. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd --- .changeset/prewarm-push-open-capture.md | 5 + .../warn-missing-notification-delegate.md | 5 + PostHog/PostHogConfig.swift | 13 +- PostHog/PostHogSDK.swift | 33 ++++ ...stHogPushNotificationOpenIntegration.swift | 67 +++++-- .../PushNotificationPublisher.swift | 143 ++++++++++++++- ...PostHogPushNotificationSwizzlingTest.swift | 172 ++++++++++++++++++ api/posthog-ios.public-api.txt | 1 + 8 files changed, 418 insertions(+), 21 deletions(-) create mode 100644 .changeset/prewarm-push-open-capture.md create mode 100644 .changeset/warn-missing-notification-delegate.md diff --git a/.changeset/prewarm-push-open-capture.md b/.changeset/prewarm-push-open-capture.md new file mode 100644 index 000000000..d260aa72c --- /dev/null +++ b/.changeset/prewarm-push-open-capture.md @@ -0,0 +1,5 @@ +--- +'posthog-ios': minor +--- + +Add `PostHogSDK.prewarmPushNotificationOpenCapture()` so a notification tap delivered before `setup()` is still captured as `$push_notification_opened`. diff --git a/.changeset/warn-missing-notification-delegate.md b/.changeset/warn-missing-notification-delegate.md new file mode 100644 index 000000000..860b55a04 --- /dev/null +++ b/.changeset/warn-missing-notification-delegate.md @@ -0,0 +1,5 @@ +--- +'posthog-ios': patch +--- + +Log a warning when `capturePushNotificationOpened` is enabled and no `UNUserNotificationCenter` delegate is set. diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index 1d39e45bc..d8d1cdcdd 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -559,7 +559,7 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? integrations.append(PostHogPushNotificationSubscriptionIntegration()) } #endif - if capturePushNotificationOpened { + if installsPushNotificationOpenIntegration { integrations.append(PostHogPushNotificationOpenIntegration()) } } @@ -568,6 +568,17 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? return integrations } + #if os(iOS) || os(macOS) + /// Whether `PostHogPushNotificationOpenIntegration` will be installed by this config. + /// + /// `setup()`'s prewarm-discard gate is the negation of this, and the discard is the only thing + /// that releases a prewarm the config did not want. Both read this property so a new reason + /// not to install cannot be added on one side only. + var installsPushNotificationOpenIntegration: Bool { + capturePushNotificationOpened && enableSwizzling && !optOut + } + #endif + var _surveys: Bool = true // swiftlint:disable:this identifier_name private func setSurveys(_ value: Bool) { // protection against objc API availability warning instead of error diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 2b3b7681d..16d8bc8a9 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -275,6 +275,18 @@ let maxRetryDelay = 30.0 notifyExceptionStepsDidChange() } + #if os(iOS) || os(macOS) + // A host may prewarm push-open capture before it knows the config (see + // `prewarmPushNotificationOpenCapture`). Release the swizzles when this setup turns + // out not to want them — including while opted out, where the integrations above + // were never installed and so could never release them. + if #available(iOS 14.0, macOS 11.0, *) { + if !config.installsPushNotificationOpenIntegration { + DI.main.pushNotificationPublisher.discardPrewarmedNotificationResponseCapture() + } + } + #endif + // Next-launch retry for a persisted, not-yet-delivered push subscription // (no-ops while opted out, offline, or when the record was already delivered). pushSubscriptionHandler?.retryIfNeeded() @@ -3139,6 +3151,27 @@ let maxRetryDelay = 30.0 #endif #if os(iOS) || os(macOS) + /// Installs the notification-open swizzles before `setup()` is called. + /// + /// A cold launch from a notification tap delivers the response to the app within a few hundred + /// milliseconds — sooner than a cross-platform host (Flutter, React Native) can reach its own + /// `setup()` call from the Dart/JS runtime, so the swizzles are not yet in place and the open is + /// lost. Call this from `application(_:didFinishLaunchingWithOptions:)`, or from a plugin + /// registration that runs inside it, and the response is held until `setup()` installs the + /// integration, which then captures it. + /// + /// Holds at most one response, and only replays it when `setup()` follows within 30 seconds. + /// Native iOS apps that call `setup()` from `didFinishLaunchingWithOptions` do not need this. + /// + /// The swizzles are installed immediately and released again when the last subscriber detaches + /// (`close()`), or at `setup()` when the config disables push-open capture or the app is + /// opted out. If `setup()` is never called they stay for the process lifetime. The per-class + /// delegate wrapper, as elsewhere in this SDK, stays for the process lifetime regardless. + @available(iOS 14.0, macOS 11.0, *) + @objc public static func prewarmPushNotificationOpenCapture() { + DI.main.pushNotificationPublisher.prewarmNotificationResponseCapture() + } + /// Manually captures a `$push_notification_opened` event for a notification the user tapped. /// /// Use this when you're not relying on the automatic swizzling installed by diff --git a/PostHog/PushNotifications/PostHogPushNotificationOpenIntegration.swift b/PostHog/PushNotifications/PostHogPushNotificationOpenIntegration.swift index 1b97ad764..7cf83e921 100644 --- a/PostHog/PushNotifications/PostHogPushNotificationOpenIntegration.swift +++ b/PostHog/PushNotifications/PostHogPushNotificationOpenIntegration.swift @@ -13,6 +13,11 @@ private static let integrationInstallState = PostHogIntegrationInstallState() + /// No lifecycle hook says "the app is finished installing its notification delegate", so the + /// absence of one can only be concluded from a deadline. Long enough for an app that installs + /// it during launch, short enough that the warning lands in the same debugging session. + private static let missingDelegateGracePeriod: TimeInterval = 5 + private weak var postHog: PostHogSDK? private var token: RegistrationToken? @@ -38,18 +43,56 @@ return } token = DI.main.pushNotificationPublisher.onNotificationResponse.subscribe { [weak self] response in - // Auto-capture remote pushes only. A local - // notification (calendar/interval/location trigger, or none) is the app's own, not a - // delivered push; users who want to capture those can call - // `capturePushNotificationOpened(response:)` themselves — it stays unfiltered. - // Exclude dismiss actions: a `.customDismissAction` category invokes this same delegate - // callback on swipe-to-dismiss, which is not the user "opening" the notification. - guard response.notification.request.trigger is UNPushNotificationTrigger, - response.actionIdentifier != UNNotificationDismissActionIdentifier - else { - return - } - self?.postHog?.capturePushNotificationOpened(response: response) + self?.capture(response) + } + + // A response delivered before setup() — a cold launch from a notification tap in a + // Flutter/React Native app, where setup() runs once the JS/Dart isolate is up — is buffered + // by a prewarmed publisher and replayed here. Draining after subscribing means a response + // racing this call is never dropped. + if let pending = DI.main.pushNotificationPublisher.consumePendingNotificationResponse() { + capture(pending) + } + + warnIfNoNotificationDelegate(while: token) + } + + private func capture(_ response: UNNotificationResponse) { + // Auto-capture remote pushes only. A local + // notification (calendar/interval/location trigger, or none) is the app's own, not a + // delivered push; users who want to capture those can call + // `capturePushNotificationOpened(response:)` themselves — it stays unfiltered. + // Exclude dismiss actions: a `.customDismissAction` category invokes this same delegate + // callback on swipe-to-dismiss, which is not the user "opening" the notification. + guard response.notification.request.trigger is UNPushNotificationTrigger, + response.actionIdentifier != UNNotificationDismissActionIdentifier + else { + return + } + postHog?.capturePushNotificationOpened(response: response) + } + + /// Without a `UNUserNotificationCenter` delegate the system never reports a tap to anyone, so + /// there is nothing to swizzle and no open is ever captured — the integration installs cleanly + /// and then stays silent forever, which is near-impossible to diagnose from the outside. + /// + /// The check is delayed because an app may install its delegate after setup(), which the + /// swizzled setter handles; only a delegate still missing well after launch is a real problem. + private func warnIfNoNotificationDelegate(while token: RegistrationToken?) { + // An app extension is never the notification-center delegate, so the advice below would be + // wrong there. + guard Bundle.main.bundleURL.pathExtension == "app" else { return } + + DispatchQueue.main.asyncAfter(deadline: .now() + Self.missingDelegateGracePeriod) { [weak token] in + // A weak token reads "still subscribed" without racing `stop()` on another thread. + guard token != nil, UNUserNotificationCenter.current().delegate == nil else { return } + hedgeLog(""" + Push notification opened integration: no UNUserNotificationCenter delegate is set, so \ + notification taps are never reported to the app and `$push_notification_opened` cannot \ + be captured. Set `UNUserNotificationCenter.current().delegate` in your application \ + delegate, or capture opens yourself with \ + `PostHogSDK.shared.capturePushNotificationOpened(response:)`. + """) } } diff --git a/PostHog/PushNotifications/PushNotificationPublisher.swift b/PostHog/PushNotifications/PushNotificationPublisher.swift index 0c799924c..464d83fbd 100644 --- a/PostHog/PushNotifications/PushNotificationPublisher.swift +++ b/PostHog/PushNotifications/PushNotificationPublisher.swift @@ -15,6 +15,18 @@ var onNotificationResponse: PostHogMulticastCallback { get } /// Fires when APNs delivers a device token (already converted to a lowercase-hex string). var onDeviceToken: PostHogMulticastCallback { get } + /// Installs the notification-delegate swizzles before the SDK is configured, so a response + /// delivered during launch is held until an integration subscribes. A no-op once a subscriber + /// exists — setup() has already run, so there is nothing to hold for it. + func prewarmNotificationResponseCapture() + /// Publishes a response to subscribers. With no subscriber it is held only while prewarmed, + /// and otherwise dropped. + func deliver(notificationResponse: UNNotificationResponse) + /// Returns and clears a response buffered before any subscriber attached. + func consumePendingNotificationResponse() -> UNNotificationResponse? + /// Undoes a prewarm that setup() turned out not to want, so an app that disabled push-open + /// capture is not left permanently swizzled. + func discardPrewarmedNotificationResponseCapture() } // MARK: - Publisher @@ -44,18 +56,30 @@ private let delegateClassesLock = NSLock() private var swizzledDelegateClasses = Set() + /// Guards the setter-swizzle install state and the pre-subscriber response buffer. + private let stateLock = NSLock() + private var isDelegateSetterSwizzled = false + private var isPrewarmed = false + private var pendingResponse: (response: UNNotificationResponse, capturedAt: Date)? + + /// A buffered response stands for the launch that is happening now. Anything older than this + /// belongs to a launch the app never configured the SDK for, and would be reported with a + /// misleading timestamp. + private static let pendingResponseTTL: TimeInterval = 30 + private init() { // weakSelf avoids capturing self in the subscriber-count closures before init completes. weak var weakSelf: PushNotificationPublisher? onNotificationResponse = PostHogMulticastCallback(onSubscriberCountChanged: { count in guard let self = weakSelf else { return } if count == 1 { - self.swizzleNotificationCenterDelegateSetter() - if let existing = UNUserNotificationCenter.current().delegate { - self.swizzleNotificationDelegateMethods(on: type(of: existing)) - } + // The prewarm window ends at the first subscriber: from here the publisher tears + // down normally on the way out, and a response arriving with no subscriber is + // dropped rather than buffered for a later setup(). + self.stateLock.withLock { self.isPrewarmed = false } + self.installNotificationDelegateSwizzles() } else if count == 0 { - self.unswizzleNotificationCenterDelegateSetter() + self.uninstallNotificationDelegateSwizzles() } }) onDeviceToken = PostHogMulticastCallback(onSubscriberCountChanged: { count in @@ -77,11 +101,35 @@ UNUserNotificationCenterDelegate.userNotificationCenter(_:didReceive:withCompletionHandler:) ) - private func swizzleNotificationCenterDelegateSetter() { + /// Installs the setter swizzle and covers a delegate that is already set. + /// + /// `isDelegateSetterSwizzled` is what makes this idempotent, and it is load-bearing: the + /// swizzle is a method exchange, so an unguarded second call would reverse the first. + private func installNotificationDelegateSwizzles() { + // Reachable from public API, so it can run outside an app. + guard Self.isRunningInAppContext else { return } + + let shouldInstall = stateLock.withLock { + guard !isDelegateSetterSwizzled else { return false } + isDelegateSetterSwizzled = true + return true + } + guard shouldInstall else { return } + swizzle(forClass: UNUserNotificationCenter.self, original: Self.delegateSetterOriginal, new: Self.delegateSetterSwizzled) + if let existing = UNUserNotificationCenter.current().delegate { + swizzleNotificationDelegateMethods(on: type(of: existing)) + } } - private func unswizzleNotificationCenterDelegateSetter() { + private func uninstallNotificationDelegateSwizzles() { + let shouldUninstall = stateLock.withLock { + guard isDelegateSetterSwizzled else { return false } + isDelegateSetterSwizzled = false + return true + } + guard shouldUninstall else { return } + // Calling swizzle() again reverses the setter exchange. `swizzledDelegateClasses` is // deliberately NOT cleared: the per-class `didReceive` replacements stay in place for the // process lifetime (invoking an empty multicast is a no-op), and clearing the set would wrap @@ -89,6 +137,73 @@ swizzle(forClass: UNUserNotificationCenter.self, original: Self.delegateSetterOriginal, new: Self.delegateSetterSwizzled) } + func prewarmNotificationResponseCapture() { + // A live subscriber means setup() already ran, so there is nothing to hold for it. + // Read outside `stateLock` — `subscriberCount` takes the multicast's own lock. A prewarm + // racing the very first subscribe can still set the flag; the TTL bounds that. + guard onNotificationResponse.subscriberCount == 0 else { return } + + let alreadyPrewarmed = stateLock.withLock { + let wasPrewarmed = isPrewarmed + isPrewarmed = true + return wasPrewarmed + } + guard !alreadyPrewarmed else { return } + installNotificationDelegateSwizzles() + } + + func deliver(notificationResponse response: UNNotificationResponse) { + if onNotificationResponse.subscriberCount == 0 { + let buffered = stateLock.withLock { () -> Bool in + guard isPrewarmed else { return false } + pendingResponse = (response, Date()) + return true + } + if buffered { + // A subscriber that arrived while this ran already drained an empty buffer, so + // hand the response over here instead of stranding it. `consume` clears under + // the lock, so it cannot be delivered twice. + if onNotificationResponse.subscriberCount > 0, + let pending = consumePendingNotificationResponse() + { + onNotificationResponse.invoke(pending) + } + return + } + // The count read above can already be stale, and invoking an empty multicast is a + // no-op, so falling through cannot drop a response that a subscriber arriving + // mid-call should have seen. + } + onNotificationResponse.invoke(response) + } + + func consumePendingNotificationResponse() -> UNNotificationResponse? { + stateLock.withLock { + guard let pending = pendingResponse else { return nil } + pendingResponse = nil + guard Date().timeIntervalSince(pending.capturedAt) <= Self.pendingResponseTTL else { + return nil + } + return pending.response + } + } + + func discardPrewarmedNotificationResponseCapture() { + stateLock.withLock { + pendingResponse = nil + isPrewarmed = false + } + // A live subscriber means another PostHogSDK instance still needs these swizzles. + guard onNotificationResponse.subscriberCount == 0 else { return } + uninstallNotificationDelegateSwizzles() + } + + /// `UNUserNotificationCenter` needs a real app bundle; it traps in test runners and CLI tools. + private static var isRunningInAppContext: Bool { + let bundleExtension = Bundle.main.bundleURL.pathExtension + return bundleExtension == "app" || bundleExtension == "appex" + } + func swizzleNotificationDelegateMethods(on delegateClass: AnyClass) { delegateClassesLock.withLock { let classId = ObjectIdentifier(delegateClass) @@ -145,7 +260,7 @@ let implementationHolder = OriginalDelegateIMP(originalImplementation) let replacement: ReplacementBlock = { delegate, center, response, completionHandler in - DI.main.pushNotificationPublisher.onNotificationResponse.invoke(response) + DI.main.pushNotificationPublisher.deliver(notificationResponse: response) guard let originalImplementation = implementationHolder.lock.withLock({ implementationHolder.value }) else { completionHandler() return @@ -354,6 +469,18 @@ shared.delegateClassesLock.withLock { shared.swizzledDelegateClasses.removeAll() } + let wasSwizzled = shared.stateLock.withLock { + let wasSwizzled = shared.isDelegateSetterSwizzled + shared.isDelegateSetterSwizzled = false + shared.isPrewarmed = false + shared.pendingResponse = nil + return wasSwizzled + } + // Clearing the flag without reversing the exchange would leave the next install + // un-swizzling a setter it never swizzled. + if wasSwizzled { + swizzle(forClass: UNUserNotificationCenter.self, original: delegateSetterOriginal, new: delegateSetterSwizzled) + } } } #endif diff --git a/PostHogTests/PostHogPushNotificationSwizzlingTest.swift b/PostHogTests/PostHogPushNotificationSwizzlingTest.swift index f83a8cfb2..4f0a5b25f 100644 --- a/PostHogTests/PostHogPushNotificationSwizzlingTest.swift +++ b/PostHogTests/PostHogPushNotificationSwizzlingTest.swift @@ -13,6 +13,34 @@ private final class TestPushNotificationPublisher: PushNotificationPublishing { let onNotificationResponse = PostHogMulticastCallback() let onDeviceToken = PostHogMulticastCallback() + private(set) var prewarmCount = 0 + private(set) var discardCount = 0 + private var isPrewarmed = false + private var pendingResponse: UNNotificationResponse? + + func prewarmNotificationResponseCapture() { + prewarmCount += 1 + isPrewarmed = true + } + + func deliver(notificationResponse response: UNNotificationResponse) { + guard onNotificationResponse.subscriberCount > 0 else { + if isPrewarmed { pendingResponse = response } + return + } + onNotificationResponse.invoke(response) + } + + func consumePendingNotificationResponse() -> UNNotificationResponse? { + defer { pendingResponse = nil } + return pendingResponse + } + + func discardPrewarmedNotificationResponseCapture() { + discardCount += 1 + isPrewarmed = false + pendingResponse = nil + } } #if os(iOS) @@ -53,9 +81,12 @@ let container = DI.Container() container.pushNotificationPublisher = publisher DI.main = container + // `PushNotificationPublisher.shared` is process-wide, so prewarm state leaks between tests. + PushNotificationPublisher.reset() } deinit { + PushNotificationPublisher.reset() DI.main = previousContainer } @@ -86,6 +117,147 @@ } #endif + /// The placeholder stands in for a `UNNotificationResponse`, which has no public initializer. + /// It is only ever compared by identity here — never dereferenced. + private func withPlaceholderResponse(_ body: (UNNotificationResponse) -> Void) { + let placeholder = NSObject() + body(unsafeBitCast(placeholder, to: UNNotificationResponse.self)) + withExtendedLifetime(placeholder) {} + } + + @Test("buffers a response delivered after prewarm but before any subscriber, and drains it once") + func buffersResponseDeliveredBeforeSubscriber() { + let publisher = PushNotificationPublisher.shared + publisher.prewarmNotificationResponseCapture() + + withPlaceholderResponse { response in + publisher.deliver(notificationResponse: response) + + #expect(publisher.consumePendingNotificationResponse() === response) + #expect(publisher.consumePendingNotificationResponse() == nil) + } + } + + @Test("drops a response delivered with no subscriber when not prewarmed") + func dropsResponseWhenNotPrewarmed() { + let publisher = PushNotificationPublisher.shared + + withPlaceholderResponse { response in + publisher.deliver(notificationResponse: response) + #expect(publisher.consumePendingNotificationResponse() == nil) + } + } + + @Test("prewarming twice leaves the buffer window open exactly once") + func prewarmIsIdempotent() { + let publisher = PushNotificationPublisher.shared + publisher.prewarmNotificationResponseCapture() + publisher.prewarmNotificationResponseCapture() + + // Still buffering means the second call did not tear the prewarm window down. + withPlaceholderResponse { response in + publisher.deliver(notificationResponse: response) + #expect(publisher.consumePendingNotificationResponse() === response) + } + } + + @Test("discarding a prewarm clears the buffer and ends the prewarm window") + func discardEndsPrewarmWindow() { + let publisher = PushNotificationPublisher.shared + publisher.prewarmNotificationResponseCapture() + + withPlaceholderResponse { response in + publisher.deliver(notificationResponse: response) + publisher.discardPrewarmedNotificationResponseCapture() + #expect(publisher.consumePendingNotificationResponse() == nil) + + publisher.deliver(notificationResponse: response) + #expect(publisher.consumePendingNotificationResponse() == nil) + } + } + + @Test("prewarming while a subscriber is attached does not re-open the buffer window") + func prewarmWithLiveSubscriberIsIgnored() { + let publisher = PushNotificationPublisher.shared + var token: RegistrationToken? = publisher.onNotificationResponse.subscribe { _ in } + + publisher.prewarmNotificationResponseCapture() + token = nil + + withPlaceholderResponse { response in + publisher.deliver(notificationResponse: response) + #expect(publisher.consumePendingNotificationResponse() == nil) + } + } + + @Test("the public prewarm API reaches the publisher") + @available(iOS 14.0, macOS 11.0, *) + func publicPrewarmApiReachesPublisher() { + #expect(publisher.prewarmCount == 0) + PostHogSDK.prewarmPushNotificationOpenCapture() + #expect(publisher.prewarmCount == 1) + } + + /// Mirrors `PostHogIntegrationInstallationTest.getSut`, trimmed to what the discard gate reads. + @available(iOS 14.0, macOS 11.0, *) + private func makeSut( + optOut: Bool = false, + capturePushNotificationOpened: Bool = true, + enableSwizzling: Bool = true + ) -> PostHogSDK { + let config = PostHogConfig(projectToken: "test_project_token", host: "http://localhost:9001") + config.disableRemoteConfigForTesting = true + config.disableFlushOnBackgroundForTesting = true + config.disableReachabilityForTesting = true + config.captureApplicationLifecycleEvents = false + config.captureScreenViews = false + config.errorTrackingConfig.autoCapture = false + #if os(iOS) + config.sessionReplay = false + #endif + config.optOut = optOut + config.enableSwizzling = enableSwizzling + config.capturePushNotificationOpened = capturePushNotificationOpened + config.capturePushNotificationSubscriptions = false + + let storage = PostHogStorage(config) + storage.reset() + + PostHogPushNotificationOpenIntegration.clearInstalls() + return PostHogSDK.with(config) + } + + @Test("setup() releases a prewarm when push-open capture is disabled") + @available(iOS 14.0, macOS 11.0, *) + func setupDiscardsPrewarmWhenCaptureDisabled() { + let sut = makeSut(capturePushNotificationOpened: false) + defer { sut.close() } + + #expect(publisher.discardCount == 1) + } + + /// `setup()` skips `installIntegrations()` entirely while opted out, so the discard cannot + /// live there without leaving an opted-out app swizzled for the process lifetime. + @Test("setup() releases a prewarm while opted out, even with push-open capture enabled") + @available(iOS 14.0, macOS 11.0, *) + func setupDiscardsPrewarmWhileOptedOut() { + let sut = makeSut(optOut: true, capturePushNotificationOpened: true) + defer { sut.close() } + + #expect(publisher.discardCount == 1) + } + + @Test("setup() with push-open capture enabled does not discard the prewarm") + @available(iOS 14.0, macOS 11.0, *) + func setupKeepsPrewarmWhenCaptureEnabled() { + publisher.prewarmNotificationResponseCapture() + + let sut = makeSut(capturePushNotificationOpened: true) + defer { sut.close() } + + #expect(publisher.discardCount == 0) + } + @Test("captures and calls an Objective-C delegate with the original selector") func callsObjectiveCDelegate() { let delegate = PHDirectNotificationDelegateTestFixture() diff --git a/api/posthog-ios.public-api.txt b/api/posthog-ios.public-api.txt index 19e4d45a3..f304d3e74 100644 --- a/api/posthog-ios.public-api.txt +++ b/api/posthog-ios.public-api.txt @@ -320,6 +320,7 @@ PostHog | PostHogSDK.isSessionReplayActive() | method | @objc func isSessionRepl PostHog | PostHogSDK.logger | property | @objc var logger: PostHogLogger? { get } | c:@M@PostHog@objc(cs)PostHogSDK(py)logger PostHog | PostHogSDK.optIn() | method | @objc func optIn() | c:@M@PostHog@objc(cs)PostHogSDK(im)optIn PostHog | PostHogSDK.optOut() | method | @objc func optOut() | c:@M@PostHog@objc(cs)PostHogSDK(im)optOut +PostHog | PostHogSDK.prewarmPushNotificationOpenCapture() | type.method | @objc static func prewarmPushNotificationOpenCapture() | c:@M@PostHog@objc(cs)PostHogSDK(cm)prewarmPushNotificationOpenCapture PostHog | PostHogSDK.register(_:) | method | @objc(registerProperties:) func register(_ properties: [String : Any]) | c:@M@PostHog@objc(cs)PostHogSDK(im)registerProperties: PostHog | PostHogSDK.registerPushNotificationToken(_:) | method | @objc func registerPushNotificationToken(_ deviceToken: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)registerPushNotificationToken: PostHog | PostHogSDK.registerPushNotificationToken(_:appId:) | method | @objc func registerPushNotificationToken(_ deviceToken: String, appId: String?) | c:@M@PostHog@objc(cs)PostHogSDK(im)registerPushNotificationToken:appId: From c69c0e93d19663b91614dec3b56c8a1bc86c3c05 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 1 Sep 2026 21:14:47 -0400 Subject: [PATCH 2/7] docs(push): note the missing-delegate warning is debug-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hedgeLogEnabled defaults to false, so the warning only prints when config.debug is on — a release build sees nothing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd --- .changeset/warn-missing-notification-delegate.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/warn-missing-notification-delegate.md b/.changeset/warn-missing-notification-delegate.md index 860b55a04..da196c509 100644 --- a/.changeset/warn-missing-notification-delegate.md +++ b/.changeset/warn-missing-notification-delegate.md @@ -2,4 +2,4 @@ 'posthog-ios': patch --- -Log a warning when `capturePushNotificationOpened` is enabled and no `UNUserNotificationCenter` delegate is set. +Log a debug warning when `capturePushNotificationOpened` is enabled and no `UNUserNotificationCenter` delegate is set, which is the case where no notification tap can ever be captured. From d7b54ae34f4b08e08d901822ed6032754416f05e Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 2 Sep 2026 12:01:20 -0400 Subject: [PATCH 3/7] docs(push): trim an over-explained comment Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd --- .../PostHogPushNotificationOpenIntegration.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/PostHog/PushNotifications/PostHogPushNotificationOpenIntegration.swift b/PostHog/PushNotifications/PostHogPushNotificationOpenIntegration.swift index 7cf83e921..855d93872 100644 --- a/PostHog/PushNotifications/PostHogPushNotificationOpenIntegration.swift +++ b/PostHog/PushNotifications/PostHogPushNotificationOpenIntegration.swift @@ -46,10 +46,7 @@ self?.capture(response) } - // A response delivered before setup() — a cold launch from a notification tap in a - // Flutter/React Native app, where setup() runs once the JS/Dart isolate is up — is buffered - // by a prewarmed publisher and replayed here. Draining after subscribing means a response - // racing this call is never dropped. + // Draining after subscribing, so a response racing this call is never dropped. if let pending = DI.main.pushNotificationPublisher.consumePendingNotificationResponse() { capture(pending) } From 75e65ab905b4cf71f1f62a72a3212bcf23b27678 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 7 Sep 2026 21:48:40 -0400 Subject: [PATCH 4/7] docs(push): cut comments that restate the code, name the delegate prerequisite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit capturePushNotificationOpened's doc never mentioned that iOS reports a tap only through UNUserNotificationCenter.current().delegate — the one surface a native developer reads, and the last one still silent about it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd --- PostHog/PostHogConfig.swift | 5 +++-- PostHog/PostHogSDK.swift | 6 ++---- PostHog/PushNotifications/PushNotificationPublisher.swift | 7 +------ PostHogTests/PostHogPushNotificationSwizzlingTest.swift | 1 - 4 files changed, 6 insertions(+), 13 deletions(-) diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index d8d1cdcdd..c32745a8a 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -189,6 +189,9 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// `userNotificationCenter(_:didReceive:withCompletionHandler:)` implementation. /// /// Default: true. Set to `false` to opt out. + /// + /// Requires your app to set `UNUserNotificationCenter.current().delegate`. Without one, iOS + /// reports the tap to nobody and no open can be captured, in any app state. @objc public var capturePushNotificationOpened: Bool = true #endif @@ -569,8 +572,6 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? } #if os(iOS) || os(macOS) - /// Whether `PostHogPushNotificationOpenIntegration` will be installed by this config. - /// /// `setup()`'s prewarm-discard gate is the negation of this, and the discard is the only thing /// that releases a prewarm the config did not want. Both read this property so a new reason /// not to install cannot be added on one side only. diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 16d8bc8a9..c91af0057 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -276,10 +276,8 @@ let maxRetryDelay = 30.0 } #if os(iOS) || os(macOS) - // A host may prewarm push-open capture before it knows the config (see - // `prewarmPushNotificationOpenCapture`). Release the swizzles when this setup turns - // out not to want them — including while opted out, where the integrations above - // were never installed and so could never release them. + // Releases a prewarm this setup turns out not to want — including while opted out, + // where the integrations above were never installed and so could never release it. if #available(iOS 14.0, macOS 11.0, *) { if !config.installsPushNotificationOpenIntegration { DI.main.pushNotificationPublisher.discardPrewarmedNotificationResponseCapture() diff --git a/PostHog/PushNotifications/PushNotificationPublisher.swift b/PostHog/PushNotifications/PushNotificationPublisher.swift index 464d83fbd..9679771cf 100644 --- a/PostHog/PushNotifications/PushNotificationPublisher.swift +++ b/PostHog/PushNotifications/PushNotificationPublisher.swift @@ -22,7 +22,6 @@ /// Publishes a response to subscribers. With no subscriber it is held only while prewarmed, /// and otherwise dropped. func deliver(notificationResponse: UNNotificationResponse) - /// Returns and clears a response buffered before any subscriber attached. func consumePendingNotificationResponse() -> UNNotificationResponse? /// Undoes a prewarm that setup() turned out not to want, so an app that disabled push-open /// capture is not left permanently swizzled. @@ -73,8 +72,7 @@ onNotificationResponse = PostHogMulticastCallback(onSubscriberCountChanged: { count in guard let self = weakSelf else { return } if count == 1 { - // The prewarm window ends at the first subscriber: from here the publisher tears - // down normally on the way out, and a response arriving with no subscriber is + // From the first subscriber on, a response arriving with no subscriber is // dropped rather than buffered for a later setup(). self.stateLock.withLock { self.isPrewarmed = false } self.installNotificationDelegateSwizzles() @@ -101,8 +99,6 @@ UNUserNotificationCenterDelegate.userNotificationCenter(_:didReceive:withCompletionHandler:) ) - /// Installs the setter swizzle and covers a delegate that is already set. - /// /// `isDelegateSetterSwizzled` is what makes this idempotent, and it is load-bearing: the /// swizzle is a method exchange, so an unguarded second call would reverse the first. private func installNotificationDelegateSwizzles() { @@ -138,7 +134,6 @@ } func prewarmNotificationResponseCapture() { - // A live subscriber means setup() already ran, so there is nothing to hold for it. // Read outside `stateLock` — `subscriberCount` takes the multicast's own lock. A prewarm // racing the very first subscribe can still set the flag; the TTL bounds that. guard onNotificationResponse.subscriberCount == 0 else { return } diff --git a/PostHogTests/PostHogPushNotificationSwizzlingTest.swift b/PostHogTests/PostHogPushNotificationSwizzlingTest.swift index 4f0a5b25f..771003b1d 100644 --- a/PostHogTests/PostHogPushNotificationSwizzlingTest.swift +++ b/PostHogTests/PostHogPushNotificationSwizzlingTest.swift @@ -154,7 +154,6 @@ publisher.prewarmNotificationResponseCapture() publisher.prewarmNotificationResponseCapture() - // Still buffering means the second call did not tear the prewarm window down. withPlaceholderResponse { response in publisher.deliver(notificationResponse: response) #expect(publisher.consumePendingNotificationResponse() === response) From ea2815a028de6544d89406bc03663063d4365a04 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 8 Sep 2026 10:28:26 -0400 Subject: [PATCH 5/7] fix(push): close the prewarm window when the last subscriber detaches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subscriber-count read in prewarmNotificationResponseCapture() sits outside stateLock — it has to, since the count is behind the multicast's own lock and nesting them the other way inverts the ordering the subscriber-count callback already uses. A subscribe landing between that read and the isPrewarmed write therefore left the flag set with a live subscriber, and nothing cleared it when that subscriber went away, so a later response could be buffered with no live prewarm and replayed into the next setup() inside the TTL. Clearing the flag alongside the un-swizzle converges both racers on "not prewarmed" without nesting the locks. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd --- .../PushNotificationPublisher.swift | 4 ++++ .../PostHogPushNotificationSwizzlingTest.swift | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/PostHog/PushNotifications/PushNotificationPublisher.swift b/PostHog/PushNotifications/PushNotificationPublisher.swift index 9679771cf..3d1efcf1b 100644 --- a/PostHog/PushNotifications/PushNotificationPublisher.swift +++ b/PostHog/PushNotifications/PushNotificationPublisher.swift @@ -77,6 +77,10 @@ self.stateLock.withLock { self.isPrewarmed = false } self.installNotificationDelegateSwizzles() } else if count == 0 { + // Also clears the flag: a prewarm racing the first subscribe can re-set it after + // the branch above cleared it, and a live prewarm with no subscriber is exactly + // the state that buffers a response into the next setup(). + self.stateLock.withLock { self.isPrewarmed = false } self.uninstallNotificationDelegateSwizzles() } }) diff --git a/PostHogTests/PostHogPushNotificationSwizzlingTest.swift b/PostHogTests/PostHogPushNotificationSwizzlingTest.swift index 771003b1d..73f037636 100644 --- a/PostHogTests/PostHogPushNotificationSwizzlingTest.swift +++ b/PostHogTests/PostHogPushNotificationSwizzlingTest.swift @@ -189,6 +189,20 @@ } } + @Test("a prewarm after the last subscriber detaches still opens the buffer window") + func prewarmAfterLastSubscriberDetachesBuffers() { + let publisher = PushNotificationPublisher.shared + var token: RegistrationToken? = publisher.onNotificationResponse.subscribe { _ in } + token = nil + + publisher.prewarmNotificationResponseCapture() + + withPlaceholderResponse { response in + publisher.deliver(notificationResponse: response) + #expect(publisher.consumePendingNotificationResponse() != nil) + } + } + @Test("the public prewarm API reaches the publisher") @available(iOS 14.0, macOS 11.0, *) func publicPrewarmApiReachesPublisher() { From efde9535595d84f8148ec550bc78ff608fc842aa Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 8 Sep 2026 15:48:46 -0400 Subject: [PATCH 6/7] fix(push): re-arm interception when a discard is overtaken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit discardPrewarmedNotificationResponseCapture() reads the subscriber count and clears the prewarm flag, then tears the swizzles down — but prewarmPushNotificationOpenCapture() is a public static that does not run under setupLock, so it can set the flag and install between those steps and have the teardown undo it. That leaves a live prewarm with an un-swizzled delegate setter, so a delegate assigned afterwards is never wrapped. Re-checking both signals after the teardown converges on "installed" whichever way the two interleave. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd --- .../PushNotifications/PushNotificationPublisher.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/PostHog/PushNotifications/PushNotificationPublisher.swift b/PostHog/PushNotifications/PushNotificationPublisher.swift index 3d1efcf1b..edb76c818 100644 --- a/PostHog/PushNotifications/PushNotificationPublisher.swift +++ b/PostHog/PushNotifications/PushNotificationPublisher.swift @@ -192,9 +192,18 @@ pendingResponse = nil isPrewarmed = false } - // A live subscriber means another PostHogSDK instance still needs these swizzles. + // A live subscriber means an integration still needs these swizzles. guard onNotificationResponse.subscriberCount == 0 else { return } uninstallNotificationDelegateSwizzles() + + // The count read above, and the prewarm flag cleared at the top, can both be overtaken + // while this tears down — prewarmNotificationResponseCapture() runs outside setupLock. + // Re-arming on either signal converges on "installed" whichever way they interleave, + // instead of leaving a live prewarm with an un-swizzled delegate setter. + let stillNeeded = stateLock.withLock { isPrewarmed } || onNotificationResponse.subscriberCount > 0 + if stillNeeded { + installNotificationDelegateSwizzles() + } } /// `UNUserNotificationCenter` needs a real app bundle; it traps in test runners and CLI tools. From af022f6ad0094845be416958a0d987b8790da210 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 8 Sep 2026 21:04:51 -0400 Subject: [PATCH 7/7] test(push): drive the prewarm interleavings deterministically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both guards exist for races whose only route into the bad state is the race itself, so neither had a regression test. Adds a TESTING-gated hook at the two points where a concurrent caller can overtake — after the subscriber-count read in prewarm and in discard — plus counters for the swizzle transitions, which stay observable where the swizzle state does not (a test runner is never an app, so the bundle guard no-ops it). Each test fails only with its own fix reverted: the prewarm one buffers a response that should have been dropped, the discard one stops at one install where it should re-arm. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd --- .../PushNotificationPublisher.swift | 34 ++++++++++++++ ...PostHogPushNotificationSwizzlingTest.swift | 45 +++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/PostHog/PushNotifications/PushNotificationPublisher.swift b/PostHog/PushNotifications/PushNotificationPublisher.swift index edb76c818..aa187e182 100644 --- a/PostHog/PushNotifications/PushNotificationPublisher.swift +++ b/PostHog/PushNotifications/PushNotificationPublisher.swift @@ -66,6 +66,25 @@ /// misleading timestamp. private static let pendingResponseTTL: TimeInterval = 30 + #if TESTING + /// The points where a concurrent caller can overtake this one. Exposed so the + /// interleavings the guards exist for can be driven in a fixed order instead of raced — + /// every route into those states is the race itself, so there is no other way to reach + /// them deterministically. + enum RaceWindow { + case prewarmAfterSubscriberCheck + case discardAfterSubscriberCheck + } + + var onRaceWindow: ((RaceWindow) -> Void)? + + /// Counts entries to the swizzle transitions, including the ones the bundle guard turns + /// into no-ops — a test runner is never an app, so the swizzle state itself is not + /// observable here, but the decision to re-arm is. + private(set) var swizzleInstallAttempts = 0 + private(set) var swizzleUninstallAttempts = 0 + #endif + private init() { // weakSelf avoids capturing self in the subscriber-count closures before init completes. weak var weakSelf: PushNotificationPublisher? @@ -106,6 +125,9 @@ /// `isDelegateSetterSwizzled` is what makes this idempotent, and it is load-bearing: the /// swizzle is a method exchange, so an unguarded second call would reverse the first. private func installNotificationDelegateSwizzles() { + #if TESTING + stateLock.withLock { swizzleInstallAttempts += 1 } + #endif // Reachable from public API, so it can run outside an app. guard Self.isRunningInAppContext else { return } @@ -123,6 +145,9 @@ } private func uninstallNotificationDelegateSwizzles() { + #if TESTING + stateLock.withLock { swizzleUninstallAttempts += 1 } + #endif let shouldUninstall = stateLock.withLock { guard isDelegateSetterSwizzled else { return false } isDelegateSetterSwizzled = false @@ -141,6 +166,9 @@ // Read outside `stateLock` — `subscriberCount` takes the multicast's own lock. A prewarm // racing the very first subscribe can still set the flag; the TTL bounds that. guard onNotificationResponse.subscriberCount == 0 else { return } + #if TESTING + onRaceWindow?(.prewarmAfterSubscriberCheck) + #endif let alreadyPrewarmed = stateLock.withLock { let wasPrewarmed = isPrewarmed @@ -194,6 +222,9 @@ } // A live subscriber means an integration still needs these swizzles. guard onNotificationResponse.subscriberCount == 0 else { return } + #if TESTING + onRaceWindow?(.discardAfterSubscriberCheck) + #endif uninstallNotificationDelegateSwizzles() // The count read above, and the prewarm flag cleared at the top, can both be overtaken @@ -482,6 +513,9 @@ shared.isDelegateSetterSwizzled = false shared.isPrewarmed = false shared.pendingResponse = nil + shared.onRaceWindow = nil + shared.swizzleInstallAttempts = 0 + shared.swizzleUninstallAttempts = 0 return wasSwizzled } // Clearing the flag without reversing the exchange would leave the next install diff --git a/PostHogTests/PostHogPushNotificationSwizzlingTest.swift b/PostHogTests/PostHogPushNotificationSwizzlingTest.swift index 73f037636..8dce0ec54 100644 --- a/PostHogTests/PostHogPushNotificationSwizzlingTest.swift +++ b/PostHogTests/PostHogPushNotificationSwizzlingTest.swift @@ -189,6 +189,51 @@ } } + @Test("a prewarm overtaken by the first subscriber does not outlive it") + func prewarmOvertakenByFirstSubscriberDoesNotOutliveIt() { + let publisher = PushNotificationPublisher.shared + var token: RegistrationToken? + + // Drive the interleaving the subscriber-count read cannot be made atomic against: + // subscribe in the window between that read and the flag being set. + publisher.onRaceWindow = { window in + guard window == .prewarmAfterSubscriberCheck else { return } + token = publisher.onNotificationResponse.subscribe { _ in } + } + publisher.prewarmNotificationResponseCapture() + publisher.onRaceWindow = nil + + // That subscriber goes away, so nothing is left that wants a response held for it. + token = nil + + withPlaceholderResponse { response in + publisher.deliver(notificationResponse: response) + #expect(publisher.consumePendingNotificationResponse() == nil) + } + } + + @Test("a discard overtaken by a prewarm re-arms interception") + func discardOvertakenByPrewarmReArmsInterception() { + let publisher = PushNotificationPublisher.shared + + publisher.onRaceWindow = { window in + guard window == .discardAfterSubscriberCheck else { return } + // One-shot: the nested prewarm must not re-enter this hook. + publisher.onRaceWindow = nil + publisher.prewarmNotificationResponseCapture() + } + + let installsBefore = publisher.swizzleInstallAttempts + let uninstallsBefore = publisher.swizzleUninstallAttempts + publisher.discardPrewarmedNotificationResponseCapture() + publisher.onRaceWindow = nil + + // The prewarm that landed mid-teardown installs once; the discard then tears down and + // must re-arm, so the transition count is install, uninstall, install. + #expect(publisher.swizzleUninstallAttempts == uninstallsBefore + 1) + #expect(publisher.swizzleInstallAttempts == installsBefore + 2) + } + @Test("a prewarm after the last subscriber detaches still opens the buffer window") func prewarmAfterLastSubscriberDetachesBuffers() { let publisher = PushNotificationPublisher.shared