@npaw/forge-react-native 9.6.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.
Files changed (74) hide show
  1. package/LICENSE +10 -0
  2. package/NPAWForgeReactNative.podspec +44 -0
  3. package/README.md +51 -0
  4. package/android/build.gradle +77 -0
  5. package/android/src/main/AndroidManifest.xml +14 -0
  6. package/android/src/main/java/com/npaw/forge/reactnative/ForgeDtoMapper.java +295 -0
  7. package/android/src/main/java/com/npaw/forge/reactnative/ForgeInstanceRegistry.java +61 -0
  8. package/android/src/main/java/com/npaw/forge/reactnative/NPAWForgeModule.java +735 -0
  9. package/android/src/main/java/com/npaw/forge/reactnative/NPAWForgePackage.java +53 -0
  10. package/android/src/test/java/com/npaw/forge/reactnative/ForgeDtoMapperTest.java +133 -0
  11. package/ios/ForgeApple/ForgeKernelFFI.xcframework/Info.plist +82 -0
  12. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/ForgeKernelFFI.h +17 -0
  13. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/forge_kernelFFI.h +873 -0
  14. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/module.modulemap +7 -0
  15. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/libForgeKernelFFI.a +0 -0
  16. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/ForgeKernelFFI.h +17 -0
  17. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/forge_kernelFFI.h +873 -0
  18. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/module.modulemap +7 -0
  19. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/libForgeKernelFFI.a +0 -0
  20. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/ForgeKernelFFI.h +17 -0
  21. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/forge_kernelFFI.h +873 -0
  22. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/module.modulemap +7 -0
  23. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/libForgeKernelFFI.a +0 -0
  24. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/ForgeKernelFFI.h +17 -0
  25. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/forge_kernelFFI.h +873 -0
  26. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/module.modulemap +7 -0
  27. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/libForgeKernelFFI.a +0 -0
  28. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernel.swift +3931 -0
  29. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernelFFI.h +873 -0
  30. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernelFFI.modulemap +7 -0
  31. package/ios/ForgeApple/Sources/NPAWForgeAVPlayerAdapter/AVPlayerAccessLogFacts.swift +152 -0
  32. package/ios/ForgeApple/Sources/NPAWForgeAVPlayerAdapter/AVPlayerAdapter.swift +877 -0
  33. package/ios/ForgeApple/Sources/NPAWForgeCapture/AttributionCapture.swift +158 -0
  34. package/ios/ForgeApple/Sources/NPAWForgeCapture/CapturePlanGate.swift +189 -0
  35. package/ios/ForgeApple/Sources/NPAWForgeCapture/CrashCapture.swift +120 -0
  36. package/ios/ForgeApple/Sources/NPAWForgeCapture/ForgeAutomaticCapture.swift +639 -0
  37. package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsCapture.swift +1176 -0
  38. package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsEngine.swift +674 -0
  39. package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsMarkers.swift +105 -0
  40. package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsSwiftUI.swift +284 -0
  41. package/ios/ForgeApple/Sources/NPAWForgeCapture/SwiftUICapture.swift +44 -0
  42. package/ios/ForgeApple/Sources/NPAWForgeCore/AdAdapter.swift +199 -0
  43. package/ios/ForgeApple/Sources/NPAWForgeCore/AdSemantics.swift +136 -0
  44. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeApple.swift +596 -0
  45. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeCapturePlan.swift +297 -0
  46. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeClock.swift +29 -0
  47. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeLogging.swift +109 -0
  48. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeOutcome.swift +89 -0
  49. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeRuntime.swift +291 -0
  50. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeStorage.swift +70 -0
  51. package/ios/ForgeApple/Sources/NPAWForgeCore/ManualAdReporter.swift +180 -0
  52. package/ios/ForgeApple/Sources/NPAWForgeCore/NPAWForge.swift +453 -0
  53. package/ios/ForgeApple/Sources/NPAWForgeCore/NativeUniFFIRuntime.swift +594 -0
  54. package/ios/ForgeApple/Sources/NPAWForgeCore/NetworkRequestRecord.swift +191 -0
  55. package/ios/ForgeApple/Sources/NPAWForgeCore/ObjectiveCWrappers.swift +191 -0
  56. package/ios/ForgeApple/Sources/NPAWForgeCore/PlayerAdapter.swift +299 -0
  57. package/ios/ForgeApple/Sources/NPAWForgeCore/PlayerSemantics.swift +266 -0
  58. package/ios/ForgeApple/Sources/NPAWForgeCore/PrivacySanitizer.swift +59 -0
  59. package/ios/ForgeApple/Sources/NPAWForgeCore/VideoAdapter.swift +478 -0
  60. package/ios/ForgeApple/Sources/NPAWForgeCore/VideoOptions.swift +431 -0
  61. package/ios/ForgeApple/Sources/NPAWForgeNetworkCapture/ForgeNetworkCapture.swift +116 -0
  62. package/ios/ForgeApple/Sources/NPAWForgeNetworkCaptureObjC/NPAWForgeNetworkSwizzler.m +293 -0
  63. package/ios/ForgeApple/Sources/NPAWForgeNetworkCaptureObjC/include/NPAWForgeNetworkSwizzler.h +33 -0
  64. package/ios/ForgeModuleImpl.swift +1131 -0
  65. package/ios/NPAWForgeTurboModule.mm +399 -0
  66. package/package.json +33 -0
  67. package/src/ForgeAdReporter.ts +101 -0
  68. package/src/ForgeVideo.ts +169 -0
  69. package/src/NPAWForge.ts +177 -0
  70. package/src/capture.ts +192 -0
  71. package/src/events.ts +40 -0
  72. package/src/index.ts +37 -0
  73. package/src/specs/NativeNPAWForge.ts +168 -0
  74. package/src/types.ts +114 -0
@@ -0,0 +1,639 @@
1
+ /**
2
+ * Copyright (C) NPAW - All Rights Reserved
3
+ *
4
+ * This source code is protected under international copyright law. All rights
5
+ * reserved and protected by the copyright holders.
6
+ * This file is confidential and only available to authorized individuals with the
7
+ * permission of the copyright holders. If you encounter this file and do not have
8
+ * permission, please contact the copyright holders and delete this file.
9
+ *
10
+ *
11
+ * @author: Victor Soto <victor.soto@nicepeopleatwork.com>
12
+ * @description: Install-gated Apple automatic capture for lifecycle, screens, interactions, and crash retry.
13
+ */
14
+
15
+ import Foundation
16
+ #if canImport(NPAWForgeCore)
17
+ import NPAWForgeCore
18
+ #endif
19
+ #if canImport(UIKit)
20
+ import UIKit
21
+ #endif
22
+
23
+ public struct CaptureConfiguration: Equatable {
24
+ public var enabled: Bool
25
+ public var navigationEnabled: Bool
26
+ public var clickEnabled: Bool
27
+ public var lifecycleEnabled: Bool
28
+ public var crashEnabled: Bool
29
+ public var consentGranted: Bool
30
+ public var maxStringLength: Int
31
+ public var maxPayloadFields: Int
32
+
33
+ public init(
34
+ enabled: Bool = true,
35
+ navigationEnabled: Bool = true,
36
+ clickEnabled: Bool = true,
37
+ lifecycleEnabled: Bool = true,
38
+ crashEnabled: Bool = true,
39
+ consentGranted: Bool = true,
40
+ maxStringLength: Int = 512,
41
+ maxPayloadFields: Int = 64
42
+ ) {
43
+ self.enabled = enabled
44
+ self.navigationEnabled = navigationEnabled
45
+ self.clickEnabled = clickEnabled
46
+ self.lifecycleEnabled = lifecycleEnabled
47
+ self.crashEnabled = crashEnabled
48
+ self.consentGranted = consentGranted
49
+ self.maxStringLength = max(1, maxStringLength)
50
+ self.maxPayloadFields = max(1, maxPayloadFields)
51
+ }
52
+ }
53
+
54
+ public enum RawEventName {
55
+ public static let screenView = "raw_screen_view"
56
+ public static let fragmentView = "raw_fragment_view"
57
+ public static let composeView = "raw_compose_view"
58
+ public static let pageView = "raw_page_view"
59
+ public static let deepLinkReceived = "raw_deep_link_received"
60
+ public static let tap = "raw_tap"
61
+ public static let click = "raw_click"
62
+ public static let scroll = "raw_scroll"
63
+ public static let swipe = "raw_swipe"
64
+ public static let focusChange = "raw_focus_change"
65
+ public static let focusDwell = "raw_focus_dwell"
66
+ public static let remoteKey = "raw_remote_key"
67
+ public static let select = "raw_select"
68
+ public static let sectionVisible = "raw_section_visible"
69
+ public static let sectionHidden = "raw_section_hidden"
70
+ public static let contentImpression = "raw_content_impression"
71
+ public static let contentDwell = "raw_content_dwell"
72
+ public static let attribution = "raw_attribution"
73
+ public static let applicationForeground = "raw_application_foreground"
74
+ public static let applicationBackground = "raw_application_background"
75
+
76
+ public static func normalize(_ value: String) -> String {
77
+ switch value {
78
+ case "screen", "screen_view", "screenView": return screenView
79
+ case "fragment", "fragment_view", "fragmentView": return fragmentView
80
+ case "compose", "compose_view", "composeView": return composeView
81
+ case "deep_link", "deepLink": return deepLinkReceived
82
+ case "tap": return tap
83
+ case "click", "button_click": return click
84
+ case "scroll": return scroll
85
+ case "swipe": return swipe
86
+ case "foreground": return applicationForeground
87
+ case "background": return applicationBackground
88
+ default: return value.hasPrefix("raw_") ? value : "raw_\(value)"
89
+ }
90
+ }
91
+ }
92
+
93
+ public struct ScreenContext: Equatable {
94
+ public var current: String?
95
+ public var previous: String?
96
+
97
+ public init(current: String?, previous: String? = nil) {
98
+ self.current = current
99
+ self.previous = previous
100
+ }
101
+ }
102
+
103
+ public enum ForegroundState: String {
104
+ case foreground
105
+ case background
106
+ case unknown
107
+ }
108
+
109
+ public final class RawEventCapture {
110
+ private let kernel: ForgeApple
111
+ private let configuration: CaptureConfiguration
112
+ private let clientId: String
113
+ /// Single family chokepoint combining the local configuration flags with
114
+ /// the kernel capture-plan bits; local-flags-only before the first plan.
115
+ private let gate: CapturePlanGate
116
+ private var recentKeys: [String] = []
117
+ private let maxRecentKeys = 128
118
+ private var currentScreen: ScreenContext?
119
+ private let lock = NSRecursiveLock()
120
+
121
+ public init(kernel: ForgeApple, configuration: CaptureConfiguration, clientId: String) {
122
+ self.kernel = kernel
123
+ self.configuration = configuration
124
+ self.clientId = clientId
125
+ self.gate = CapturePlanGate(configuration: configuration)
126
+ }
127
+
128
+ /// Applies a kernel capture plan to the family gate. Safe to call from
129
+ /// the runtime callback thread; the gate flips immediately while observer
130
+ /// reconciliation hops to the main thread separately.
131
+ public func applyPlan(_ plan: ForgeCapturePlan) {
132
+ gate.apply(plan)
133
+ }
134
+
135
+ /// Current effective feature snapshot (local flags AND plan bits).
136
+ public var effectiveFeatures: EffectiveCaptureFeatures {
137
+ gate.features
138
+ }
139
+
140
+ /// Latest applied kernel capture plan, or `nil` before the first plan.
141
+ public var capturePlan: ForgeCapturePlan? {
142
+ gate.plan
143
+ }
144
+
145
+ @discardableResult
146
+ public func capture(
147
+ eventName: String,
148
+ data: [String: String] = [:],
149
+ screen: ScreenContext? = nil,
150
+ foregroundState: ForegroundState = .unknown,
151
+ timestampMs: Int64 = ForgeClock.nowMs(),
152
+ urgent: Bool = false
153
+ ) -> ForgeOutcome {
154
+ lock.lock()
155
+ defer { lock.unlock() }
156
+ guard configuration.enabled, configuration.consentGranted else { return .ignoredInvalidState }
157
+ let normalized = RawEventName.normalize(eventName)
158
+ guard isFamilyEnabled(normalized) else { return .ignoredInvalidState }
159
+ let resolvedScreen = screen ?? currentScreen
160
+ if let current = screen?.current, !current.isEmpty {
161
+ currentScreen = screen
162
+ }
163
+ let key = dedupeKey(eventName: normalized, screen: resolvedScreen, data: data, timestampMs: timestampMs)
164
+ guard !recentKeys.contains(key) else { return .ignoredDuplicate }
165
+ recentKeys.append(key)
166
+ if recentKeys.count > maxRecentKeys {
167
+ recentKeys.removeFirst(recentKeys.count - maxRecentKeys)
168
+ }
169
+
170
+ let bounded = PrivacySanitizer.sanitize(data, maxValueLength: configuration.maxStringLength)
171
+ .prefix(configuration.maxPayloadFields)
172
+ .reduce(into: [String: String]()) { output, element in output[element.key] = element.value }
173
+
174
+ if normalized == RawEventName.applicationForeground {
175
+ _ = kernel.recordApplicationForeground(true, timestampMs: timestampMs)
176
+ } else if normalized == RawEventName.applicationBackground {
177
+ _ = kernel.recordApplicationForeground(false, timestampMs: timestampMs)
178
+ }
179
+
180
+ return kernel.recordEvent(
181
+ eventName: normalized,
182
+ payload: flatten(
183
+ eventName: normalized,
184
+ data: bounded,
185
+ screen: resolvedScreen,
186
+ foregroundState: foregroundState
187
+ ),
188
+ timestampMs: timestampMs,
189
+ urgent: urgent
190
+ )
191
+ }
192
+
193
+ public func setCurrentScreen(_ screen: ScreenContext?) {
194
+ lock.lock()
195
+ currentScreen = screen
196
+ lock.unlock()
197
+ }
198
+
199
+ /// Name of the screen currently attached to captured events, if any.
200
+ public var currentScreenName: String? {
201
+ lock.lock()
202
+ defer { lock.unlock() }
203
+ return currentScreen?.current
204
+ }
205
+
206
+ private func isFamilyEnabled(_ eventName: String) -> Bool {
207
+ gate.isEventFamilyEnabled(eventName)
208
+ }
209
+
210
+ private func dedupeKey(eventName: String, screen: ScreenContext?, data: [String: String], timestampMs: Int64) -> String {
211
+ let interaction = data["interactionKey"] ?? data["elementId"].map { "\(data["elementType"] ?? "view"):\($0)" }
212
+ return "\(eventName):\(screen?.current ?? ""):\(interaction ?? String(data.hashValue)):\(timestampMs / 500)"
213
+ }
214
+
215
+ private func flatten(
216
+ eventName: String,
217
+ data: [String: String],
218
+ screen: ScreenContext?,
219
+ foregroundState: ForegroundState
220
+ ) -> [String: String] {
221
+ var output: [String: String] = [
222
+ "eventId": UUID().uuidString,
223
+ "clientId": clientId,
224
+ "foregroundState": foregroundState.rawValue,
225
+ ]
226
+ if let current = screen?.current { output["screenName"] = current }
227
+ if let previous = screen?.previous { output["previousScreenName"] = previous }
228
+ data.forEach { output["data.\($0.key)"] = $0.value }
229
+ return output
230
+ }
231
+ }
232
+
233
+ public enum ForgeAutomaticCapture {
234
+ private static let lock = NSLock()
235
+ /// Weak-keyed registry (mirrors Android's `WeakHashMap<NPAWForge, AutomaticCaptureHandle>`):
236
+ /// entries disappear with their forge, so rebuilt SDK instances never alias a
237
+ /// stale handle and dead kernels are not retained forever.
238
+ private static let handles = NSMapTable<NPAWForge, AutomaticCaptureHandle>.weakToStrongObjects()
239
+
240
+ public static func install(
241
+ forge: NPAWForge,
242
+ configuration: CaptureConfiguration = CaptureConfiguration()
243
+ ) -> AutomaticCaptureHandle {
244
+ lock.lock()
245
+ defer { lock.unlock() }
246
+ if let existing = handles.object(forKey: forge) { return existing }
247
+ let handle = AutomaticCaptureHandle(forge: forge, configuration: configuration)
248
+ handles.setObject(handle, forKey: forge)
249
+ handle.install()
250
+ return handle
251
+ }
252
+
253
+ /// Uninstalls and removes the handle registered for `forge`, if any.
254
+ public static func uninstall(forge: NPAWForge) {
255
+ lock.lock()
256
+ let handle = handles.object(forKey: forge)
257
+ handles.removeObject(forKey: forge)
258
+ lock.unlock()
259
+ handle?.uninstall()
260
+ }
261
+
262
+ static func removeHandle(forge: NPAWForge) {
263
+ lock.lock()
264
+ handles.removeObject(forKey: forge)
265
+ lock.unlock()
266
+ }
267
+
268
+ public static func registerAutoInstall() {
269
+ ForgeAutomaticCaptureRegistry.register { forge in
270
+ _ = install(forge: forge)
271
+ }
272
+ }
273
+ }
274
+
275
+ public final class AutomaticCaptureHandle {
276
+ public let capture: RawEventCapture
277
+ /// F3 sections engine owner: installed/uninstalled by plan reconciliation,
278
+ /// flushed on screen changes, and consulted for interaction enrichment.
279
+ /// Emits through the regular raw-event capture chokepoint.
280
+ let sectionsCoordinator: ForgeSectionsCoordinator
281
+ private weak var forge: NPAWForge?
282
+ private let configuration: CaptureConfiguration
283
+ /// Guards `observers` and `lifecycleObserversInstalled`: install/uninstall
284
+ /// run on the integrator's thread while plan reconciliation runs on main.
285
+ private let observerStateLock = NSRecursiveLock()
286
+ private var observers: [NSObjectProtocol] = []
287
+ private var lifecycleObserversInstalled = false
288
+ private var crashStore: CrashRecordStore?
289
+ private var crashSubscription: ForgeEventSubscription?
290
+ private var planSubscription: ForgeEventSubscription?
291
+ /// Guards the single deep-link retention slot: `deepLink` can arrive on
292
+ /// any thread while plan reconciliation replays on main.
293
+ private let retainedDeepLinkLock = NSLock()
294
+ /// Single retention slot (Android `DeepLinkCapture` parity): the most
295
+ /// recent deep link observed while attribution derivation was ineffective
296
+ /// (fail-closed pre-plan, or the plan keeps the family/knob off) — the
297
+ /// iOS cold-start campaign landing case. Plan reconciliation replays
298
+ /// exactly this URL once derivation turns effective; the per-URL dedupe
299
+ /// keeps the replay idempotent because it only records accepted
300
+ /// derivations.
301
+ private var retainedDeepLinkWhileIneffective: URL?
302
+
303
+ init(forge: NPAWForge, configuration: CaptureConfiguration) {
304
+ self.forge = forge
305
+ self.configuration = configuration
306
+ let capture = RawEventCapture(
307
+ kernel: forge.kernel,
308
+ configuration: configuration,
309
+ clientId: "apple-\(Bundle.main.bundleIdentifier ?? "unknown")"
310
+ )
311
+ self.capture = capture
312
+ // The coordinator only holds the capture chokepoint (never the
313
+ // handle), so the emit closure cannot create a retain cycle.
314
+ self.sectionsCoordinator = ForgeSectionsCoordinator(emit: { kind, params in
315
+ _ = capture.capture(eventName: kind, data: params, foregroundState: .foreground)
316
+ })
317
+ }
318
+
319
+ public func install() {
320
+ // Pre-first-plan behavior is identical to the plan-less pipeline:
321
+ // lifecycle observers install unconditionally and family gating
322
+ // happens at capture time from the local flags.
323
+ installLifecycleCapture()
324
+ if configuration.crashEnabled {
325
+ installCrashCapture()
326
+ }
327
+ // Closure capture is weak, so the kernel listener list never pins the
328
+ // handle (mirrors the Android WeakReference subscription contract).
329
+ planSubscription = forge?.addCapturePlanListener { [weak self] plan in
330
+ self?.applyPlan(plan)
331
+ }
332
+ }
333
+
334
+ public func uninstall() {
335
+ planSubscription?.cancel()
336
+ planSubscription = nil
337
+ removeLifecycleObservers()
338
+ // Sections teardown removes KVO tokens, the safety timer, and
339
+ // notification observers; the sampler is main-thread affine.
340
+ let coordinator = sectionsCoordinator
341
+ runOnMain { coordinator.shutdown() }
342
+ crashSubscription?.cancel()
343
+ crashSubscription = nil
344
+ if let forge {
345
+ ForgeAutomaticCapture.removeHandle(forge: forge)
346
+ }
347
+ }
348
+
349
+ deinit {
350
+ planSubscription?.cancel()
351
+ observers.forEach(NotificationCenter.default.removeObserver)
352
+ crashSubscription?.cancel()
353
+ }
354
+
355
+ /// Applies a kernel capture plan: the family gate flips immediately on
356
+ /// the calling (runtime callback) thread so newly disabled families stop
357
+ /// at the capture chokepoint at once, while UIKit observer add/remove
358
+ /// hops to the main thread. Reconciliation is idempotent: repeated plans
359
+ /// with the same semantics leave the installed observers untouched.
360
+ public func applyPlan(_ plan: ForgeCapturePlan) {
361
+ capture.applyPlan(plan)
362
+ runOnMain { [weak self] in
363
+ self?.reconcileObservers()
364
+ }
365
+ }
366
+
367
+ /// Main-thread observer reconciliation against the latest effective
368
+ /// features (reads the gate at execution time so rapid plan bursts
369
+ /// converge on the newest plan).
370
+ private func reconcileObservers() {
371
+ let features = capture.effectiveFeatures
372
+ // Sections: bit on installs the engine/sampler, knob changes
373
+ // hot-apply, bit off uninstalls and resets engine state.
374
+ sectionsCoordinator.reconcile(features: features)
375
+ // Attribution: replay the deep link retained while derivation was
376
+ // ineffective once the plan turns derivation on (B4 cold start).
377
+ reconcileRetainedDeepLink(features: features)
378
+ let lifecycle = features.lifecycle
379
+ observerStateLock.lock()
380
+ defer { observerStateLock.unlock() }
381
+ if lifecycle, !lifecycleObserversInstalled {
382
+ installLifecycleCapture()
383
+ } else if !lifecycle, lifecycleObserversInstalled {
384
+ removeLifecycleObservers()
385
+ }
386
+ }
387
+
388
+ private func removeLifecycleObservers() {
389
+ observerStateLock.lock()
390
+ let removed = observers
391
+ observers.removeAll()
392
+ lifecycleObserversInstalled = false
393
+ observerStateLock.unlock()
394
+ removed.forEach(NotificationCenter.default.removeObserver)
395
+ }
396
+
397
+ private func runOnMain(_ block: @escaping () -> Void) {
398
+ if Thread.isMainThread {
399
+ block()
400
+ } else {
401
+ DispatchQueue.main.async(execute: block)
402
+ }
403
+ }
404
+
405
+ public func screenView(_ name: String, previous: String? = nil) -> ForgeOutcome {
406
+ // F3 parity rule: on a real screen change, flush raw_section_hidden
407
+ // for still-visible sections FIRST (under the old screen context) and
408
+ // reset per-screen dedupe, before the new screen context lands.
409
+ let screenChanged = capture.currentScreenName != name
410
+ if screenChanged {
411
+ sectionsCoordinator.onScreenWillChange()
412
+ }
413
+ let screen = ScreenContext(current: name, previous: previous)
414
+ capture.setCurrentScreen(screen)
415
+ let outcome = capture.capture(eventName: RawEventName.screenView, screen: screen, foregroundState: .foreground)
416
+ if screenChanged {
417
+ sectionsCoordinator.onScreenDidChange()
418
+ }
419
+ return outcome
420
+ }
421
+
422
+ /// Reports an incoming deep link. Emits `raw_deep_link_received` with the
423
+ /// Android-parity component fields (scheme/host/path plus the url*
424
+ /// duplicates and the always-present `action`), then — independently of
425
+ /// the deep-link family outcome — derives `raw_attribution` when the plan
426
+ /// enables the ATTRIBUTION family with `deriveFromDeepLinks` (B4). While
427
+ /// derivation is ineffective (fail-closed pre-plan) the most recent URL
428
+ /// is retained and replayed once by plan reconciliation. The returned
429
+ /// outcome stays the deep-link event's outcome.
430
+ public func deepLink(_ url: URL, action: String? = nil) -> ForgeOutcome {
431
+ let outcome = capture.capture(
432
+ eventName: RawEventName.deepLinkReceived,
433
+ data: [
434
+ "scheme": url.scheme ?? "unknown",
435
+ "host": url.host ?? "unknown",
436
+ "path": url.path,
437
+ "action": action ?? "",
438
+ "urlScheme": url.scheme ?? "unknown",
439
+ "urlHost": url.host ?? "unknown",
440
+ "urlPath": url.path,
441
+ "url": PrivacySanitizer.redactUrl(url.absoluteString),
442
+ ],
443
+ foregroundState: .foreground
444
+ )
445
+ deriveAttribution(from: url)
446
+ return outcome
447
+ }
448
+
449
+ /// B4 deep-link attribution: gated by the plan family bit AND the
450
+ /// `deriveFromDeepLinks` knob (closed before the first plan), parsed by
451
+ /// the pure parser, and deduped once per exact URL per process AFTER all
452
+ /// other gates so a disabled sighting never consumes the URL's slot.
453
+ /// While derivation is ineffective the most recent URL is retained in a
454
+ /// single slot for the plan-reconcile replay (Android parity).
455
+ private func deriveAttribution(from url: URL) {
456
+ // Mirror the chokepoint's outer guard BEFORE the dedupe so a
457
+ // disabled/pre-consent sighting never burns the URL's only slot (and
458
+ // is never retained either while consent is missing).
459
+ guard configuration.enabled, configuration.consentGranted else { return }
460
+ let features = capture.effectiveFeatures
461
+ guard features.attribution,
462
+ let knobs = features.attributionPlan,
463
+ knobs.deriveFromDeepLinks else {
464
+ // Fail-closed today, maybe effective tomorrow: keep the most
465
+ // recent URL so the plan reconcile hook can replay it (the iOS
466
+ // cold-start campaign landing arrives before the first plan).
467
+ retainedDeepLinkLock.lock()
468
+ retainedDeepLinkWhileIneffective = url
469
+ retainedDeepLinkLock.unlock()
470
+ return
471
+ }
472
+ guard let params = AttributionDeepLinkParser.parse(
473
+ url: url,
474
+ extraQueryKeys: knobs.extraQueryKeys,
475
+ includeLandingUrl: knobs.includeLandingUrl
476
+ ),
477
+ !AttributionDeepLinkDedupe.hasSeen(url: url.absoluteString) else {
478
+ return
479
+ }
480
+ let outcome = capture.capture(
481
+ eventName: RawEventName.attribution,
482
+ data: params,
483
+ foregroundState: .foreground
484
+ )
485
+ // The chokepoint can still reject after the gates above (plan flip
486
+ // window, kernel rejection): consume the URL's single slot only on an
487
+ // accepted emission (mirrors the Android install-referrer settle).
488
+ if outcome.isAccepted {
489
+ AttributionDeepLinkDedupe.markSeen(url: url.absoluteString)
490
+ }
491
+ }
492
+
493
+ /// Plan reconcile hook (Android `DeepLinkCapture.reconcile` parity): when
494
+ /// the effective plan enables deep-link derivation and a deep link was
495
+ /// retained while derivation was ineffective, replays the derivation for
496
+ /// exactly that URL. The slot clears once a derivation attempt runs under
497
+ /// an effective plan; ineffective plans keep the slot for a later flip.
498
+ private func reconcileRetainedDeepLink(features: EffectiveCaptureFeatures) {
499
+ guard features.attribution, features.attributionPlan?.deriveFromDeepLinks == true else { return }
500
+ retainedDeepLinkLock.lock()
501
+ let retained = retainedDeepLinkWhileIneffective
502
+ retainedDeepLinkWhileIneffective = nil
503
+ retainedDeepLinkLock.unlock()
504
+ guard let retained else { return }
505
+ deriveAttribution(from: retained)
506
+ }
507
+
508
+ public func interaction(eventName: String, data: [String: String]) -> ForgeOutcome {
509
+ var enriched = data
510
+ let normalized = RawEventName.normalize(eventName)
511
+ // tvOS remote-key/select interactions enrich from the armed dwell
512
+ // slot when present; caller-provided keys always win.
513
+ if normalized == RawEventName.remoteKey || normalized == RawEventName.select {
514
+ sectionsCoordinator.enrichFromDwellSlot(into: &enriched)
515
+ }
516
+ return capture.capture(eventName: eventName, data: enriched, foregroundState: .foreground)
517
+ }
518
+
519
+ #if canImport(UIKit)
520
+ /// Manual-interaction chokepoint overload accepting the interacted view:
521
+ /// resolves {section, sectionOrder, contentId, position} through the
522
+ /// sections registry (explicit registrations, markers, native ids) and
523
+ /// merges the fields without overwriting caller-provided keys.
524
+ public func interaction(eventName: String, data: [String: String] = [:], view: UIView) -> ForgeOutcome {
525
+ var enriched = data
526
+ sectionsCoordinator.enrich(view: view, into: &enriched)
527
+ return capture.capture(eventName: eventName, data: enriched, foregroundState: .foreground)
528
+ }
529
+
530
+ /// F3 sections registration facade: `track(view:name:order:)`,
531
+ /// `track(collectionView:...)`, `track(tableView:...)`, `untrack(view:)`.
532
+ public var sections: ForgeSectionsTracker {
533
+ sectionsCoordinator.tracker
534
+ }
535
+ #endif
536
+
537
+ private func installLifecycleCapture() {
538
+ observerStateLock.lock()
539
+ defer { observerStateLock.unlock() }
540
+ guard !lifecycleObserversInstalled else { return }
541
+ lifecycleObserversInstalled = true
542
+ #if canImport(UIKit)
543
+ observers.append(NotificationCenter.default.addObserver(
544
+ forName: UIApplication.didBecomeActiveNotification,
545
+ object: nil,
546
+ queue: nil
547
+ ) { [weak self] _ in
548
+ _ = self?.capture.capture(
549
+ eventName: RawEventName.applicationForeground,
550
+ foregroundState: .foreground,
551
+ urgent: true
552
+ )
553
+ })
554
+ observers.append(NotificationCenter.default.addObserver(
555
+ forName: UIApplication.didEnterBackgroundNotification,
556
+ object: nil,
557
+ queue: nil
558
+ ) { [weak self] _ in
559
+ _ = self?.capture.capture(
560
+ eventName: RawEventName.applicationBackground,
561
+ foregroundState: .background,
562
+ urgent: true
563
+ )
564
+ })
565
+ #endif
566
+ }
567
+
568
+ private func installCrashCapture() {
569
+ let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
570
+ .appendingPathComponent("forge-crashes", isDirectory: true)
571
+ let store = CrashRecordStore(directory: directory)
572
+ crashStore = store
573
+ retryPendingCrashes()
574
+ crashSubscription = forge?.subscribeEvents { [weak self] event, outcome in
575
+ if event.eventName == "sessionstart", outcome.isAccepted {
576
+ self?.retryPendingCrashes()
577
+ }
578
+ }
579
+ ForgeCrashExceptionHandler.install()
580
+ }
581
+
582
+ private func retryPendingCrashes() {
583
+ guard let forge, let crashStore else { return }
584
+ for record in crashStore.loadPending() {
585
+ let outcome = forge.kernel.recordEvent(
586
+ eventName: "app_crash",
587
+ payload: record.payload(deliveryMode: "next_launch_retry"),
588
+ timestampMs: ForgeClock.nowMs(),
589
+ urgent: true
590
+ )
591
+ if outcome.isAccepted {
592
+ crashStore.markSent(record.id)
593
+ }
594
+ }
595
+ }
596
+ }
597
+
598
+ /// Process-wide NSException crash hook that chains to any previously installed
599
+ /// handler (mirrors the Android `UncaughtExceptionCapture` chaining contract):
600
+ /// Forge stores its crash record first, and the displaced handler is always
601
+ /// invoked afterwards so other crash reporters keep receiving NSException crashes.
602
+ enum ForgeCrashExceptionHandler {
603
+ private static let lock = NSLock()
604
+ private static var installed = false
605
+ private(set) static var previousHandler: (@convention(c) (NSException) -> Void)?
606
+
607
+ static func install() {
608
+ lock.lock()
609
+ defer { lock.unlock() }
610
+ guard !installed else { return }
611
+ installed = true
612
+ previousHandler = NSGetUncaughtExceptionHandler()
613
+ NSSetUncaughtExceptionHandler(forgeUncaughtNSExceptionHandler)
614
+ }
615
+
616
+ /// Forge-owned crash work. All persistence is best-effort (`try?` inside the
617
+ /// store), so a failure here never prevents the chained previous handler from
618
+ /// running in `forgeUncaughtNSExceptionHandler`.
619
+ static func storeCrashRecord(_ exception: NSException) {
620
+ CrashRecordStore.defaultStore.store(CrashRecord(exception: exception))
621
+ }
622
+
623
+ /// Test hook: clears the installed flag and the captured previous handler so
624
+ /// regression tests can exercise installation chaining deterministically.
625
+ static func resetForTesting() {
626
+ lock.lock()
627
+ installed = false
628
+ previousHandler = nil
629
+ lock.unlock()
630
+ }
631
+ }
632
+
633
+ /// Top-level handler so it can be expressed as a C function pointer. The previous
634
+ /// handler invocation is unconditional: it does not depend on the outcome of
635
+ /// Forge's own crash work.
636
+ private func forgeUncaughtNSExceptionHandler(_ exception: NSException) {
637
+ ForgeCrashExceptionHandler.storeCrashRecord(exception)
638
+ ForgeCrashExceptionHandler.previousHandler?(exception)
639
+ }