@txlemetry/react-native-plugin 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,70 @@
1
+ // swift-tools-version: 6.0
2
+
3
+ import PackageDescription
4
+
5
+ // React Native's SwiftPM autolinker references this package through
6
+ // build/generated/autolinking/libs/ReactNativePlugin. These paths are relative
7
+ // to that stable alias, not to node_modules.
8
+ let reactNativeDependencies: [Package.Dependency] = [
9
+ .package(name: "ReactNative", path: "../../../../xcframeworks"),
10
+ .package(name: "React-GeneratedCode", path: "../../../ios"),
11
+ ]
12
+
13
+ let reactHeaderDependencies: [Target.Dependency] = [
14
+ .product(name: "ReactHeaders", package: "ReactNative"),
15
+ .product(name: "ReactNativeHeaders", package: "ReactNative"),
16
+ .product(name: "ReactNativeDependenciesHeaders", package: "ReactNative"),
17
+ .product(name: "ReactAppHeaders", package: "React-GeneratedCode"),
18
+ ]
19
+
20
+ let package = Package(
21
+ // This name must match React Native's Swift-name derivation for
22
+ // @posthog/react-native-plugin.
23
+ name: "ReactNativePlugin",
24
+ platforms: [.iOS(.v15)],
25
+ products: [
26
+ .library(
27
+ name: "ReactNativePlugin",
28
+ targets: ["PosthogReactNativePlugin", "PosthogReactNativePluginBridge"]
29
+ ),
30
+ ],
31
+ dependencies: reactNativeDependencies + [
32
+ .package(
33
+ url: "https://github.com/PostHog/posthog-ios.git",
34
+ .upToNextMinor(from: "3.69.10")
35
+ ),
36
+ ],
37
+ targets: [
38
+ .target(
39
+ name: "PosthogReactNativePlugin",
40
+ dependencies: reactHeaderDependencies + [
41
+ .product(name: "PostHog", package: "posthog-ios"),
42
+ ],
43
+ path: ".",
44
+ exclude: [
45
+ "Package.swift",
46
+ "PosthogReactNativePlugin-Bridging-Header.h",
47
+ "PosthogReactNativePlugin.mm",
48
+ ],
49
+ sources: ["PosthogReactNativePlugin.swift"]
50
+ ),
51
+ // SwiftPM cannot compile Swift and Objective-C++ in one target. The
52
+ // RCT_EXTERN_MODULE registration bridge is independent and is linked
53
+ // into the same library product as the Swift implementation.
54
+ .target(
55
+ name: "PosthogReactNativePluginBridge",
56
+ dependencies: reactHeaderDependencies,
57
+ path: ".",
58
+ exclude: [
59
+ "Package.swift",
60
+ "PosthogReactNativePlugin.swift",
61
+ ],
62
+ sources: ["PosthogReactNativePlugin.mm"],
63
+ publicHeadersPath: "."
64
+ ),
65
+ ],
66
+ // Match the CocoaPods integration's Swift 5 language mode. The plugin has
67
+ // main-thread-confined Objective-C bridge state that is not yet annotated
68
+ // for Swift 6's strict concurrency checks.
69
+ swiftLanguageModes: [.v5]
70
+ )
@@ -0,0 +1,3 @@
1
+ #import <React/RCTBridgeModule.h>
2
+ #import <React/RCTViewManager.h>
3
+ #import <React/RCTEventEmitter.h>
@@ -0,0 +1,77 @@
1
+ #import <React/RCTBridgeModule.h>
2
+ #import <React/RCTEventEmitter.h>
3
+
4
+ @interface RCT_EXTERN_MODULE(PosthogReactNativePlugin, RCTEventEmitter)
5
+
6
+ RCT_EXTERN_METHOD(setup:(NSString)sessionId
7
+ withSdkOptions:(NSDictionary)sdkOptions
8
+ withPluginConfig:(NSDictionary)pluginConfig
9
+ withResolver:(RCTPromiseResolveBlock)resolve
10
+ withRejecter:(RCTPromiseRejectBlock)reject)
11
+
12
+ RCT_EXTERN_METHOD(start:(NSString)sessionId
13
+ withSdkOptions:(NSDictionary)sdkOptions
14
+ withSdkReplayConfig:(NSDictionary)sdkReplayConfig
15
+ withDecideReplayConfig:(NSDictionary)decideReplayConfig
16
+ withResolver:(RCTPromiseResolveBlock)resolve
17
+ withRejecter:(RCTPromiseRejectBlock)reject)
18
+
19
+ RCT_EXTERN_METHOD(startSession:(NSString)sessionId
20
+ withResolver:(RCTPromiseResolveBlock)resolve
21
+ withRejecter:(RCTPromiseRejectBlock)reject)
22
+
23
+ RCT_EXTERN_METHOD(isEnabled:(RCTPromiseResolveBlock)resolve
24
+ withRejecter:(RCTPromiseRejectBlock)reject)
25
+
26
+ RCT_EXTERN_METHOD(endSession:(RCTPromiseResolveBlock)resolve
27
+ withRejecter:(RCTPromiseRejectBlock)reject)
28
+
29
+ RCT_EXTERN_METHOD(identify:(NSString)distinctId
30
+ withAnonymousId:(NSString)anonymousId
31
+ withResolver:(RCTPromiseResolveBlock)resolve
32
+ withRejecter:(RCTPromiseRejectBlock)reject)
33
+
34
+ RCT_EXTERN_METHOD(startRecording:(BOOL)resumeCurrent
35
+ withResolver:(RCTPromiseResolveBlock)resolve
36
+ withRejecter:(RCTPromiseRejectBlock)reject)
37
+
38
+ RCT_EXTERN_METHOD(stopRecording:(RCTPromiseResolveBlock)resolve
39
+ withRejecter:(RCTPromiseRejectBlock)reject)
40
+
41
+ RCT_EXTERN_METHOD(addExceptionStep:(NSString)message
42
+ withProperties:(NSDictionary)properties
43
+ withResolver:(RCTPromiseResolveBlock)resolve
44
+ withRejecter:(RCTPromiseRejectBlock)reject)
45
+
46
+ RCT_EXTERN_METHOD(reset:(NSString)distinctId
47
+ withAnonymousId:(NSString)anonymousId
48
+ withResolver:(RCTPromiseResolveBlock)resolve
49
+ withRejecter:(RCTPromiseRejectBlock)reject)
50
+
51
+ RCT_EXTERN_METHOD(registerPushNotificationToken:(NSString)deviceToken
52
+ withAppId:(NSString)appId
53
+ withResolver:(RCTPromiseResolveBlock)resolve
54
+ withRejecter:(RCTPromiseRejectBlock)reject)
55
+
56
+ RCT_EXTERN_METHOD(unregisterPushNotificationToken:(RCTPromiseResolveBlock)resolve
57
+ withRejecter:(RCTPromiseRejectBlock)reject)
58
+
59
+ RCT_EXTERN_METHOD(setOptOut:(BOOL)optOut
60
+ withResolver:(RCTPromiseResolveBlock)resolve
61
+ withRejecter:(RCTPromiseRejectBlock)reject)
62
+
63
+ RCT_EXTERN_METHOD(capturePushNotificationOpened:(NSDictionary)properties
64
+ withResolver:(RCTPromiseResolveBlock)resolve
65
+ withRejecter:(RCTPromiseRejectBlock)reject)
66
+
67
+ RCT_EXTERN_METHOD(providePushIdentityToken:(NSString)requestId
68
+ withToken:(NSString)token
69
+ withResolver:(RCTPromiseResolveBlock)resolve
70
+ withRejecter:(RCTPromiseRejectBlock)reject)
71
+
72
+ + (BOOL)requiresMainQueueSetup
73
+ {
74
+ return NO;
75
+ }
76
+
77
+ @end
@@ -0,0 +1,474 @@
1
+ import PostHog
2
+ import React
3
+
4
+ /// Meant for internally logging PostHog related things
5
+ private func hedgeLog(_ message: String) {
6
+ print("[PostHog] \(message)")
7
+ }
8
+
9
+ #if !os(iOS)
10
+ // Session replay is part of posthog-ios's iOS-only surface, so recording is a no-op on macOS.
11
+ // Log once so a caller isn't left wondering why recording "started" but nothing arrives.
12
+ private var didLogSessionReplayUnsupported = false
13
+ private func logSessionReplayUnsupportedOnMacOS() {
14
+ guard !didLogSessionReplayUnsupported else { return }
15
+ didLogSessionReplayUnsupported = true
16
+ hedgeLog("Session replay is not supported on macOS")
17
+ }
18
+ #endif
19
+
20
+ /// Deduplication works on Android (both architectures), iOS (old architecture only), and macOS.
21
+ /// On the iOS new architecture, fatal JS exception events surface as a generic SIGABRT
22
+ /// crash event with no JS-error text in any field, so they currently cannot be filtered.
23
+ private let fatalJsErrorMarkers = ["Unhandled JS Exception", "ExceptionsManager.reportException", "facebook::jsi::JSError"]
24
+
25
+ private func containsFatalJsErrorMarker(_ text: String?) -> Bool {
26
+ guard let text else { return false }
27
+ return fatalJsErrorMarkers.contains { text.contains($0) }
28
+ }
29
+
30
+ private func isReactNativeFatalJsError(_ event: PostHogEvent) -> Bool {
31
+ guard event.event == "$exception",
32
+ let exceptionList = event.properties["$exception_list"] as? [[String: Any]]
33
+ else { return false }
34
+ return exceptionList.contains { exception in
35
+ if containsFatalJsErrorMarker(exception["type"] as? String) {
36
+ return true
37
+ }
38
+ if containsFatalJsErrorMarker(exception["value"] as? String) {
39
+ return true
40
+ }
41
+ // New-architecture RN rethrows fatal JS errors as a C++ jsi::JSError (SIGABRT);
42
+ // the JS-error text only survives in the signal's crash-info message.
43
+ let mechanism = exception["mechanism"] as? [String: Any]
44
+ let meta = mechanism?["meta"] as? [String: Any]
45
+ let signal = meta?["signal"] as? [String: Any]
46
+ return containsFatalJsErrorMarker(signal?["crash_info_message"] as? String)
47
+ }
48
+ }
49
+
50
+ // A nil identity token sends the request unauthenticated, which a project requiring
51
+ // identity verification rejects server-side. Log the reason so that failure is greppable
52
+ // and distinct from a host that deliberately returned nil.
53
+ private func declinePushIdentity(_ completion: (String?) -> Void, _ reason: String) {
54
+ hedgeLog("Push subscription will be sent unauthenticated: \(reason)")
55
+ completion(nil)
56
+ }
57
+
58
+ @objc(PosthogReactNativePlugin)
59
+ public class PosthogReactNativePlugin: RCTEventEmitter {
60
+ private var config: PostHogConfig?
61
+
62
+ private static let pushIdentityEvent = "PostHogPushIdentityRequest"
63
+
64
+ // This module dies on every bridge reload, so the provider closure resolves the live
65
+ // module through this static weak reference at call time, not a captured setup-time one.
66
+ private static weak var pushInstance: PosthogReactNativePlugin?
67
+
68
+ // Main-thread confined, like the rest of the identity-request bookkeeping below.
69
+ private var hasPushListeners = false
70
+ private var pushIdentityCompletions: [String: (String?) -> Void] = [:]
71
+
72
+ public override func supportedEvents() -> [String]! {
73
+ [PosthogReactNativePlugin.pushIdentityEvent]
74
+ }
75
+
76
+ public override func startObserving() {
77
+ DispatchQueue.main.async { self.hasPushListeners = true }
78
+ }
79
+
80
+ public override func stopObserving() {
81
+ DispatchQueue.main.async { self.hasPushListeners = false }
82
+ }
83
+
84
+ @objc(setup:withSdkOptions:withPluginConfig:withResolver:withRejecter:)
85
+ func setup(
86
+ sessionId: String, sdkOptions: [String: Any], pluginConfig: [String: Any],
87
+ resolve: RCTPromiseResolveBlock, reject _: RCTPromiseRejectBlock
88
+ ) {
89
+ let sessionReplayConfig = pluginConfig["sessionReplay"] as? [String: Any] ?? [:]
90
+ let errorTrackingConfig = pluginConfig["errorTracking"] as? [String: Any] ?? [:]
91
+ let exceptionStepsConfig = errorTrackingConfig["exceptionSteps"] as? [String: Any] ?? [:]
92
+
93
+ setupNativeSdk(
94
+ method: "setup",
95
+ sessionId: sessionId,
96
+ sdkOptions: sdkOptions,
97
+ sessionReplayEnabled: sessionReplayConfig["enabled"] as? Bool ?? false,
98
+ sdkReplayConfig: sessionReplayConfig["sdkReplayConfig"] as? [String: Any] ?? [:],
99
+ decideReplayConfig: sessionReplayConfig["decideReplayConfig"] as? [String: Any] ?? [:],
100
+ nativeErrorTrackingAutocapture: errorTrackingConfig["nativeAutocapture"] as? Bool ?? false,
101
+ exceptionStepsConfig: exceptionStepsConfig,
102
+ pushConfig: pluginConfig["push"] as? [String: Any] ?? [:],
103
+ resolve: resolve
104
+ )
105
+ }
106
+
107
+ @objc(start:withSdkOptions:withSdkReplayConfig:withDecideReplayConfig:withResolver:withRejecter:)
108
+ func start(
109
+ sessionId: String, sdkOptions: [String: Any], sdkReplayConfig: [String: Any],
110
+ decideReplayConfig: [String: Any], resolve: RCTPromiseResolveBlock,
111
+ reject _: RCTPromiseRejectBlock
112
+ ) {
113
+ setupNativeSdk(
114
+ method: "start",
115
+ sessionId: sessionId,
116
+ sdkOptions: sdkOptions,
117
+ sessionReplayEnabled: true,
118
+ sdkReplayConfig: sdkReplayConfig,
119
+ decideReplayConfig: decideReplayConfig,
120
+ nativeErrorTrackingAutocapture: false,
121
+ exceptionStepsConfig: [:],
122
+ pushConfig: [:],
123
+ resolve: resolve
124
+ )
125
+ }
126
+
127
+ private func setupNativeSdk(
128
+ method _: String,
129
+ sessionId: String,
130
+ sdkOptions: [String: Any],
131
+ sessionReplayEnabled: Bool,
132
+ sdkReplayConfig: [String: Any],
133
+ decideReplayConfig: [String: Any],
134
+ nativeErrorTrackingAutocapture: Bool,
135
+ exceptionStepsConfig: [String: Any],
136
+ pushConfig: [String: Any],
137
+ resolve: RCTPromiseResolveBlock
138
+ ) {
139
+ if sessionId.isEmpty {
140
+ hedgeLog("Invalid empty sessionId provided.")
141
+ resolve(nil)
142
+ return
143
+ }
144
+
145
+ let projectToken =
146
+ (sdkOptions["projectToken"] as? String)
147
+ ?? (sdkOptions["apiKey"] as? String)
148
+ ?? ""
149
+ let host = sdkOptions["host"] as? String ?? PostHogConfig.defaultHost
150
+ let debug = sdkOptions["debug"] as? Bool ?? false
151
+
152
+ PostHogSessionManager.shared.setSessionId(sessionId)
153
+
154
+ let config = PostHogConfig(projectToken: projectToken, host: host)
155
+ config.captureApplicationLifecycleEvents = false
156
+ config.captureScreenViews = false
157
+ config.debug = debug
158
+ config.errorTrackingConfig.autoCapture = nativeErrorTrackingAutocapture
159
+
160
+ // Keep the native exception-steps buffer aligned with the JS layer (one logical buffer).
161
+ if let enabled = exceptionStepsConfig["enabled"] as? Bool {
162
+ config.errorTrackingConfig.exceptionSteps.enabled = enabled
163
+ }
164
+ if let maxBytes = exceptionStepsConfig["maxBytes"] as? Int {
165
+ config.errorTrackingConfig.exceptionSteps.maxBytes = maxBytes
166
+ }
167
+
168
+ // React Native rethrows fatal JS errors natively (RCTFatalException / ExceptionsManager).
169
+ // The JS layer already captured them, so drop the native duplicate.
170
+ config.setBeforeSend { event in
171
+ isReactNativeFatalJsError(event) ? nil : event
172
+ }
173
+
174
+ // Surveys and session replay are iOS-only in posthog-ios, so the APIs below
175
+ // don't exist on macOS. macOS gets error tracking only.
176
+ #if os(iOS)
177
+ if #available(iOS 15.0, *) {
178
+ config.surveys = false
179
+ }
180
+
181
+ // Always apply the session replay configuration so that recording started later
182
+ // (e.g. startRecording or a linked feature flag) uses the right mode and masking;
183
+ // sessionReplayEnabled only controls whether recording starts at setup.
184
+ config.sessionReplay = sessionReplayEnabled
185
+ config.sessionReplayConfig.screenshotMode = true
186
+
187
+ let maskAllTextInputs = sdkReplayConfig["maskAllTextInputs"] as? Bool ?? true
188
+ config.sessionReplayConfig.maskAllTextInputs = maskAllTextInputs
189
+
190
+ let maskAllImages = sdkReplayConfig["maskAllImages"] as? Bool ?? true
191
+ config.sessionReplayConfig.maskAllImages = maskAllImages
192
+
193
+ let maskAllSandboxedViews = sdkReplayConfig["maskAllSandboxedViews"] as? Bool ?? true
194
+ config.sessionReplayConfig.maskAllSandboxedViews = maskAllSandboxedViews
195
+
196
+ // read throttleDelayMs and use iOSdebouncerDelayMs as a fallback for back compatibility
197
+ let throttleDelayMs =
198
+ (sdkReplayConfig["throttleDelayMs"] as? Int)
199
+ ?? (sdkReplayConfig["iOSdebouncerDelayMs"] as? Int)
200
+ ?? 1000
201
+
202
+ let timeInterval: TimeInterval = Double(throttleDelayMs) / 1000.0
203
+ config.sessionReplayConfig.throttleDelay = timeInterval
204
+
205
+ let captureNetworkTelemetry = sdkReplayConfig["captureNetworkTelemetry"] as? Bool ?? true
206
+ config.sessionReplayConfig.captureNetworkTelemetry = captureNetworkTelemetry
207
+
208
+ let captureLog = sdkReplayConfig["captureLog"] as? Bool ?? true
209
+ config.sessionReplayConfig.captureLogs = captureLog
210
+
211
+ config.sessionReplayConfig.sampleRate = sdkReplayConfig["sampleRate"] as? NSNumber
212
+
213
+ let screenshotModeBackgroundCapture = sdkReplayConfig["screenshotModeBackgroundCapture"] as? Bool ?? false
214
+ config.sessionReplayConfig.screenshotModeBackgroundCapture = screenshotModeBackgroundCapture
215
+
216
+ let endpoint = decideReplayConfig["endpoint"] as? String ?? ""
217
+ if !endpoint.isEmpty {
218
+ config.snapshotEndpoint = endpoint
219
+ }
220
+ #endif
221
+
222
+ let distinctId = sdkOptions["distinctId"] as? String ?? ""
223
+ let anonymousId = sdkOptions["anonymousId"] as? String ?? ""
224
+
225
+ let sdkVersion = sdkOptions["sdkVersion"] as? String ?? ""
226
+
227
+ let flushAt = sdkOptions["flushAt"] as? Int ?? 20
228
+ config.flushAt = flushAt
229
+
230
+ config.optOut = sdkOptions["optOut"] as? Bool ?? false
231
+ // JS owns flags; it tells us when the native preload would be a duplicate. posthog-ios
232
+ // has no remoteConfig switch to mirror — it deprecated the option and always loads.
233
+ config.preloadFeatureFlags = sdkOptions["preloadFeatureFlags"] as? Bool ?? true
234
+
235
+ // Forward custom headers (e.g. Authorization for a reverse proxy) so the native SDK
236
+ // attaches them to the requests it sends directly (session replay, crash uploads).
237
+ // Keep only string values so a stray non-string doesn't drop every header (matches Android).
238
+ if let rawHeaders = sdkOptions["requestHeaders"] as? [String: Any] {
239
+ config.requestHeaders = rawHeaders.compactMapValues { $0 as? String }
240
+ }
241
+
242
+ if !sdkVersion.isEmpty {
243
+ postHogSdkName = "posthog-react-native"
244
+ postHogVersion = sdkVersion
245
+ }
246
+
247
+ // Only set when present: the legacy start() path predates push, and there the
248
+ // native defaults (both true) must win, matching posthog-ios on its own.
249
+ if let capturePushSubscriptions = pushConfig["capturePushNotificationSubscriptions"] as? Bool {
250
+ config.capturePushNotificationSubscriptions = capturePushSubscriptions
251
+ }
252
+ if let capturePushOpened = pushConfig["capturePushNotificationOpened"] as? Bool {
253
+ config.capturePushNotificationOpened = capturePushOpened
254
+ }
255
+
256
+ // Installed only when JS asked for it: an uninvited bridging provider would change
257
+ // how the native SDK handles a 401 on the subscription call.
258
+ if pushConfig["pushIdentityProviderEnabled"] as? Bool == true {
259
+ PosthogReactNativePlugin.pushInstance = self
260
+ config.pushIdentityProvider = { distinctId, appId, completion in
261
+ DispatchQueue.main.async {
262
+ guard let instance = PosthogReactNativePlugin.pushInstance, instance.hasPushListeners else {
263
+ declinePushIdentity(completion, "no JS listener attached")
264
+ return
265
+ }
266
+ let requestId = UUID().uuidString
267
+ instance.pushIdentityCompletions[requestId] = completion
268
+ instance.sendEvent(
269
+ withName: PosthogReactNativePlugin.pushIdentityEvent,
270
+ body: ["requestId": requestId, "distinctId": distinctId, "appId": appId]
271
+ )
272
+ // The native SDK's own 10s mint watchdog handles the fallback; this only
273
+ // drops the entry so a late JS reply is ignored and the closure doesn't leak.
274
+ DispatchQueue.main.asyncAfter(deadline: .now() + 15) { [weak instance] in
275
+ instance?.pushIdentityCompletions.removeValue(forKey: requestId)
276
+ }
277
+ }
278
+ }
279
+ }
280
+
281
+ PostHogSDK.shared.setup(config)
282
+
283
+ self.config = config
284
+
285
+ guard let storageManager = self.config?.storageManager else {
286
+ hedgeLog("Storage manager is not available in the config.")
287
+ resolve(nil)
288
+ return
289
+ }
290
+
291
+ setIdentify(storageManager, distinctId: distinctId, anonymousId: anonymousId)
292
+
293
+ resolve(nil)
294
+ }
295
+
296
+ @objc(startSession:withResolver:withRejecter:)
297
+ func startSession(
298
+ sessionId: String, resolve: RCTPromiseResolveBlock, reject _: RCTPromiseRejectBlock
299
+ ) {
300
+ if sessionId.isEmpty {
301
+ hedgeLog("Invalid empty sessionId provided.")
302
+ resolve(nil)
303
+ return
304
+ }
305
+ PostHogSessionManager.shared.setSessionId(sessionId)
306
+ PostHogSDK.shared.startSession()
307
+ resolve(nil)
308
+ }
309
+
310
+ @objc(isEnabled:withRejecter:)
311
+ func isEnabled(resolve: RCTPromiseResolveBlock, reject _: RCTPromiseRejectBlock) {
312
+ #if os(iOS)
313
+ resolve(PostHogSDK.shared.isSessionReplayActive())
314
+ #else
315
+ // Session replay is unsupported on macOS.
316
+ resolve(false)
317
+ #endif
318
+ }
319
+
320
+ @objc(endSession:withRejecter:)
321
+ func endSession(resolve: RCTPromiseResolveBlock, reject _: RCTPromiseRejectBlock) {
322
+ PostHogSDK.shared.endSession()
323
+ resolve(nil)
324
+ }
325
+
326
+ @objc(identify:withAnonymousId:withResolver:withRejecter:)
327
+ func identify(
328
+ distinctId: String, anonymousId: String, resolve: RCTPromiseResolveBlock,
329
+ reject _: RCTPromiseRejectBlock
330
+ ) {
331
+ guard let storageManager = config?.storageManager else {
332
+ hedgeLog("Storage manager is not available in the config.")
333
+ resolve(nil)
334
+ return
335
+ }
336
+ setIdentify(storageManager, distinctId: distinctId, anonymousId: anonymousId)
337
+
338
+ resolve(nil)
339
+ }
340
+
341
+ // Calls the native SDK rather than writing storage like identify() does: reset() is what
342
+ // unregisters the logged-out user's push subscription and re-registers under the new identity.
343
+ @objc(reset:withAnonymousId:withResolver:withRejecter:)
344
+ func reset(
345
+ distinctId: String, anonymousId: String, resolve: RCTPromiseResolveBlock,
346
+ reject _: RCTPromiseRejectBlock
347
+ ) {
348
+ PostHogSDK.shared.reset()
349
+ // Native reset() mints its own anonymous id; overwrite it with the JS one so the two SDKs
350
+ // stay on the same identity. Must run after reset(), which needs the pre-reset distinctId
351
+ // to know which subscription to unregister.
352
+ if let storageManager = config?.storageManager {
353
+ setIdentify(storageManager, distinctId: distinctId, anonymousId: anonymousId)
354
+ }
355
+ resolve(nil)
356
+ }
357
+
358
+ private func setIdentify(
359
+ _ storageManager: PostHogStorageManager, distinctId: String, anonymousId: String
360
+ ) {
361
+ if !anonymousId.isEmpty {
362
+ storageManager.setAnonymousId(anonymousId)
363
+ }
364
+ if !distinctId.isEmpty {
365
+ storageManager.setDistinctId(distinctId)
366
+ }
367
+ }
368
+
369
+ // Runtime consent changes must reach native: it persists its own opt-out flag and only
370
+ // reads the JS value at setup(), so a refreshed APNs token could otherwise auto-register
371
+ // after the user opted out. optIn() also reinstalls the integrations opt-out removed.
372
+ @objc(setOptOut:withResolver:withRejecter:)
373
+ func setOptOut(
374
+ optOut: Bool, resolve: RCTPromiseResolveBlock, reject _: RCTPromiseRejectBlock
375
+ ) {
376
+ if optOut {
377
+ PostHogSDK.shared.optOut()
378
+ } else {
379
+ PostHogSDK.shared.optIn()
380
+ }
381
+ resolve(nil)
382
+ }
383
+
384
+ @objc(startRecording:withResolver:withRejecter:)
385
+ func startRecording(
386
+ resumeCurrent: Bool, resolve: RCTPromiseResolveBlock, reject _: RCTPromiseRejectBlock
387
+ ) {
388
+ #if os(iOS)
389
+ PostHogSDK.shared.startSessionRecording(resumeCurrent: resumeCurrent)
390
+ #else
391
+ logSessionReplayUnsupportedOnMacOS()
392
+ #endif
393
+ resolve(nil)
394
+ }
395
+
396
+ @objc(stopRecording:withRejecter:)
397
+ func stopRecording(resolve: RCTPromiseResolveBlock, reject _: RCTPromiseRejectBlock) {
398
+ #if os(iOS)
399
+ PostHogSDK.shared.stopSessionRecording()
400
+ #else
401
+ logSessionReplayUnsupportedOnMacOS()
402
+ #endif
403
+ resolve(nil)
404
+ }
405
+
406
+ @objc(addExceptionStep:withProperties:withResolver:withRejecter:)
407
+ func addExceptionStep(
408
+ message: String, properties: [String: Any]?, resolve: RCTPromiseResolveBlock,
409
+ reject _: RCTPromiseRejectBlock
410
+ ) {
411
+ PostHogSDK.shared.addExceptionStep(message, properties: properties)
412
+ resolve(nil)
413
+ }
414
+
415
+ @objc(registerPushNotificationToken:withAppId:withResolver:withRejecter:)
416
+ func registerPushNotificationToken(
417
+ deviceToken: String, appId: String?, resolve: RCTPromiseResolveBlock,
418
+ reject: RCTPromiseRejectBlock
419
+ ) {
420
+ #if os(iOS)
421
+ // A blank token is dropped silently by the native SDK, so surface it here
422
+ // instead of reporting false success.
423
+ if deviceToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
424
+ reject("PosthogReactNativePluginError", "registerPushNotificationToken: deviceToken is blank; token not registered.", nil)
425
+ return
426
+ }
427
+ PostHogSDK.shared.registerPushNotificationToken(deviceToken, appId: appId)
428
+ resolve(nil)
429
+ #else
430
+ // posthog-ios push registration is iOS-only (the backend rejects the macos platform).
431
+ _ = reject
432
+ hedgeLog("registerPushNotificationToken is not supported on macOS; token not registered.")
433
+ resolve(nil)
434
+ #endif
435
+ }
436
+
437
+ @objc(unregisterPushNotificationToken:withRejecter:)
438
+ func unregisterPushNotificationToken(resolve: RCTPromiseResolveBlock, reject _: RCTPromiseRejectBlock) {
439
+ #if os(iOS)
440
+ PostHogSDK.shared.unregisterPushNotificationToken()
441
+ #else
442
+ hedgeLog("unregisterPushNotificationToken is not supported on macOS; nothing to unregister.")
443
+ #endif
444
+ resolve(nil)
445
+ }
446
+
447
+ @objc(capturePushNotificationOpened:withResolver:withRejecter:)
448
+ func capturePushNotificationOpened(
449
+ properties: [String: Any], resolve: RCTPromiseResolveBlock, reject _: RCTPromiseRejectBlock
450
+ ) {
451
+ PostHogSDK.shared.capturePushNotificationOpened(
452
+ title: properties["title"] as? String,
453
+ subtitle: properties["subtitle"] as? String,
454
+ body: properties["body"] as? String,
455
+ payload: properties["payload"] as? [String: Any],
456
+ action: properties["action"] as? String
457
+ )
458
+ resolve(nil)
459
+ }
460
+
461
+ @objc(providePushIdentityToken:withToken:withResolver:withRejecter:)
462
+ func providePushIdentityToken(
463
+ requestId: String, token: String?, resolve: RCTPromiseResolveBlock,
464
+ reject _: RCTPromiseRejectBlock
465
+ ) {
466
+ DispatchQueue.main.async { [weak self] in
467
+ guard let completion = self?.pushIdentityCompletions.removeValue(forKey: requestId) else {
468
+ return
469
+ }
470
+ completion(token)
471
+ }
472
+ resolve(nil)
473
+ }
474
+ }