@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.
- package/LICENSE +10 -0
- package/NPAWForgeReactNative.podspec +44 -0
- package/README.md +51 -0
- package/android/build.gradle +77 -0
- package/android/src/main/AndroidManifest.xml +14 -0
- package/android/src/main/java/com/npaw/forge/reactnative/ForgeDtoMapper.java +295 -0
- package/android/src/main/java/com/npaw/forge/reactnative/ForgeInstanceRegistry.java +61 -0
- package/android/src/main/java/com/npaw/forge/reactnative/NPAWForgeModule.java +735 -0
- package/android/src/main/java/com/npaw/forge/reactnative/NPAWForgePackage.java +53 -0
- package/android/src/test/java/com/npaw/forge/reactnative/ForgeDtoMapperTest.java +133 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/Info.plist +82 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/ForgeKernelFFI.h +17 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/forge_kernelFFI.h +873 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/module.modulemap +7 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/libForgeKernelFFI.a +0 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/ForgeKernelFFI.h +17 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/forge_kernelFFI.h +873 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/module.modulemap +7 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/libForgeKernelFFI.a +0 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/ForgeKernelFFI.h +17 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/forge_kernelFFI.h +873 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/module.modulemap +7 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/libForgeKernelFFI.a +0 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/ForgeKernelFFI.h +17 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/forge_kernelFFI.h +873 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/module.modulemap +7 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/libForgeKernelFFI.a +0 -0
- package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernel.swift +3931 -0
- package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernelFFI.h +873 -0
- package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernelFFI.modulemap +7 -0
- package/ios/ForgeApple/Sources/NPAWForgeAVPlayerAdapter/AVPlayerAccessLogFacts.swift +152 -0
- package/ios/ForgeApple/Sources/NPAWForgeAVPlayerAdapter/AVPlayerAdapter.swift +877 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/AttributionCapture.swift +158 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/CapturePlanGate.swift +189 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/CrashCapture.swift +120 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/ForgeAutomaticCapture.swift +639 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsCapture.swift +1176 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsEngine.swift +674 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsMarkers.swift +105 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsSwiftUI.swift +284 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/SwiftUICapture.swift +44 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/AdAdapter.swift +199 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/AdSemantics.swift +136 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeApple.swift +596 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeCapturePlan.swift +297 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeClock.swift +29 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeLogging.swift +109 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeOutcome.swift +89 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeRuntime.swift +291 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeStorage.swift +70 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ManualAdReporter.swift +180 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/NPAWForge.swift +453 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/NativeUniFFIRuntime.swift +594 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/NetworkRequestRecord.swift +191 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ObjectiveCWrappers.swift +191 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/PlayerAdapter.swift +299 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/PlayerSemantics.swift +266 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/PrivacySanitizer.swift +59 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/VideoAdapter.swift +478 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/VideoOptions.swift +431 -0
- package/ios/ForgeApple/Sources/NPAWForgeNetworkCapture/ForgeNetworkCapture.swift +116 -0
- package/ios/ForgeApple/Sources/NPAWForgeNetworkCaptureObjC/NPAWForgeNetworkSwizzler.m +293 -0
- package/ios/ForgeApple/Sources/NPAWForgeNetworkCaptureObjC/include/NPAWForgeNetworkSwizzler.h +33 -0
- package/ios/ForgeModuleImpl.swift +1131 -0
- package/ios/NPAWForgeTurboModule.mm +399 -0
- package/package.json +33 -0
- package/src/ForgeAdReporter.ts +101 -0
- package/src/ForgeVideo.ts +169 -0
- package/src/NPAWForge.ts +177 -0
- package/src/capture.ts +192 -0
- package/src/events.ts +40 -0
- package/src/index.ts +37 -0
- package/src/specs/NativeNPAWForge.ts +168 -0
- 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
|
+
}
|