@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,596 @@
|
|
|
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: Internal Apple facade that owns binding-local runtime wiring and event sanitization.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import Foundation
|
|
16
|
+
|
|
17
|
+
public struct ForgeAppleConfig: Equatable {
|
|
18
|
+
public var accountCode: String
|
|
19
|
+
public var pluginVersion: String
|
|
20
|
+
public var appName: String
|
|
21
|
+
public var buildVariant: String
|
|
22
|
+
public var appReleaseVersion: String?
|
|
23
|
+
public var lmaEndpoint: String
|
|
24
|
+
public var analyticsUserAgent: String?
|
|
25
|
+
public var allowLocalHttp: Bool
|
|
26
|
+
public var startSessionOnInitialize: Bool
|
|
27
|
+
public var processEventsBeforeSessionStart: Bool
|
|
28
|
+
public var preSessionEventQueueLimitEvents: Int
|
|
29
|
+
public var preSessionEventQueueMaxAgeMs: Int64
|
|
30
|
+
public var logLevel: ForgeLogLevel
|
|
31
|
+
|
|
32
|
+
public init(
|
|
33
|
+
accountCode: String,
|
|
34
|
+
pluginVersion: String,
|
|
35
|
+
appName: String,
|
|
36
|
+
buildVariant: String,
|
|
37
|
+
appReleaseVersion: String? = nil,
|
|
38
|
+
lmaEndpoint: String = "https://lma.npaw.com",
|
|
39
|
+
analyticsUserAgent: String? = nil,
|
|
40
|
+
allowLocalHttp: Bool = false,
|
|
41
|
+
startSessionOnInitialize: Bool = false,
|
|
42
|
+
processEventsBeforeSessionStart: Bool = false,
|
|
43
|
+
preSessionEventQueueLimitEvents: Int = 100,
|
|
44
|
+
preSessionEventQueueMaxAgeMs: Int64 = 30_000,
|
|
45
|
+
logLevel: ForgeLogLevel = .warning
|
|
46
|
+
) {
|
|
47
|
+
self.accountCode = accountCode
|
|
48
|
+
self.pluginVersion = pluginVersion
|
|
49
|
+
self.appName = appName
|
|
50
|
+
self.buildVariant = buildVariant
|
|
51
|
+
self.appReleaseVersion = appReleaseVersion
|
|
52
|
+
self.lmaEndpoint = lmaEndpoint
|
|
53
|
+
self.analyticsUserAgent = analyticsUserAgent
|
|
54
|
+
self.allowLocalHttp = allowLocalHttp
|
|
55
|
+
self.startSessionOnInitialize = startSessionOnInitialize
|
|
56
|
+
self.processEventsBeforeSessionStart = processEventsBeforeSessionStart
|
|
57
|
+
self.preSessionEventQueueLimitEvents = preSessionEventQueueLimitEvents
|
|
58
|
+
self.preSessionEventQueueMaxAgeMs = preSessionEventQueueMaxAgeMs
|
|
59
|
+
self.logLevel = logLevel
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
public struct ForgeAppleServices {
|
|
64
|
+
public var runtimeFactory: ForgeRuntimeFactory
|
|
65
|
+
public var storage: ForgeStorage
|
|
66
|
+
public var logSink: ForgeLogSink?
|
|
67
|
+
|
|
68
|
+
public init(
|
|
69
|
+
runtimeFactory: ForgeRuntimeFactory = NativeForgeRuntimeFactory(),
|
|
70
|
+
storage: ForgeStorage = MemoryStorage(),
|
|
71
|
+
logSink: ForgeLogSink? = OSLogForgeLogSink()
|
|
72
|
+
) {
|
|
73
|
+
self.runtimeFactory = runtimeFactory
|
|
74
|
+
self.storage = storage
|
|
75
|
+
self.logSink = logSink
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// Typed options for `trackSearchResultClick`, mirroring the web binding's
|
|
80
|
+
/// `ForgeSearchResultClickOptions` shape.
|
|
81
|
+
public struct ForgeSearchResultClickOptions: Equatable {
|
|
82
|
+
/// Identifier of the content the user selected from the results.
|
|
83
|
+
public var contentId: String?
|
|
84
|
+
/// One-based position of the selected result within the list.
|
|
85
|
+
public var position: Int?
|
|
86
|
+
|
|
87
|
+
public init(contentId: String? = nil, position: Int? = nil) {
|
|
88
|
+
self.contentId = contentId
|
|
89
|
+
self.position = position
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/// Typed campaign parameters for the explicit `trackAttribution` variant,
|
|
94
|
+
/// mirroring the web binding's `ForgeAttributionParams` shape.
|
|
95
|
+
public struct ForgeAttributionOptions: Equatable {
|
|
96
|
+
public var utmSource: String?
|
|
97
|
+
public var utmMedium: String?
|
|
98
|
+
public var utmCampaign: String?
|
|
99
|
+
public var utmTerm: String?
|
|
100
|
+
public var utmContent: String?
|
|
101
|
+
public var utmUrl: String?
|
|
102
|
+
|
|
103
|
+
public init(
|
|
104
|
+
utmSource: String? = nil,
|
|
105
|
+
utmMedium: String? = nil,
|
|
106
|
+
utmCampaign: String? = nil,
|
|
107
|
+
utmTerm: String? = nil,
|
|
108
|
+
utmContent: String? = nil,
|
|
109
|
+
utmUrl: String? = nil
|
|
110
|
+
) {
|
|
111
|
+
self.utmSource = utmSource
|
|
112
|
+
self.utmMedium = utmMedium
|
|
113
|
+
self.utmCampaign = utmCampaign
|
|
114
|
+
self.utmTerm = utmTerm
|
|
115
|
+
self.utmContent = utmContent
|
|
116
|
+
self.utmUrl = utmUrl
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
public final class ForgeApple {
|
|
121
|
+
/// Upper bound for the Swift-side recorded-event mirror. Matches the Rust-side
|
|
122
|
+
/// `EVENT_SNAPSHOT_LIMIT` bounded snapshot semantics (oldest entries dropped).
|
|
123
|
+
static let eventSnapshotLimit = 512
|
|
124
|
+
|
|
125
|
+
public let config: ForgeAppleConfig
|
|
126
|
+
public let services: ForgeAppleServices
|
|
127
|
+
private let runtime: ForgeRuntime
|
|
128
|
+
private let logger: ForgeLogger
|
|
129
|
+
private var destroyed = false
|
|
130
|
+
private var appSessionActive = false
|
|
131
|
+
private let lock = NSRecursiveLock()
|
|
132
|
+
private var recordedEvents: [ForgeRecordedEvent] = []
|
|
133
|
+
private var eventObservers: [UUID: (ForgeRecordedEvent, ForgeOutcome) -> Void] = [:]
|
|
134
|
+
private var onSendRequestListeners: [UUID: (String, inout [String: String]) -> Void] = [:]
|
|
135
|
+
private var capturePlanObservers: [UUID: (ForgeCapturePlan) -> Void] = [:]
|
|
136
|
+
private var latestCapturePlan: ForgeCapturePlan?
|
|
137
|
+
|
|
138
|
+
public init(config: ForgeAppleConfig, services: ForgeAppleServices) {
|
|
139
|
+
let safeConfig = config.withPluginVersion(NPAWForge.defaultPluginVersion)
|
|
140
|
+
self.config = safeConfig
|
|
141
|
+
self.services = services
|
|
142
|
+
self.logger = ForgeLogger(level: safeConfig.logLevel, sink: services.logSink)
|
|
143
|
+
self.runtime = services.runtimeFactory.create(config: safeConfig, services: services)
|
|
144
|
+
_ = runtime.setCapturePlanListener { [weak self] plan in
|
|
145
|
+
self?.dispatchCapturePlan(plan)
|
|
146
|
+
}
|
|
147
|
+
_ = runtime.start()
|
|
148
|
+
if safeConfig.startSessionOnInitialize {
|
|
149
|
+
_ = startSession()
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
public func recordEvent(
|
|
154
|
+
eventName: String,
|
|
155
|
+
payload: [String: String] = [:],
|
|
156
|
+
timestampMs: Int64 = 0,
|
|
157
|
+
urgent: Bool = false,
|
|
158
|
+
viewKey: String? = nil
|
|
159
|
+
) -> ForgeOutcome {
|
|
160
|
+
lock.lock()
|
|
161
|
+
defer { lock.unlock() }
|
|
162
|
+
guard !destroyed else { return .ignoredInvalidState }
|
|
163
|
+
if eventName == "sessionstart" {
|
|
164
|
+
return startSession(metadata: payload, timestampMs: timestampMs)
|
|
165
|
+
}
|
|
166
|
+
if eventName == "sessionstop" {
|
|
167
|
+
return endSession(metadata: payload, timestampMs: timestampMs)
|
|
168
|
+
}
|
|
169
|
+
let event = ForgeRecordedEvent(
|
|
170
|
+
eventName: eventName,
|
|
171
|
+
timestampMs: timestampMs,
|
|
172
|
+
payload: PrivacySanitizer.sanitize(appSessionPayload(eventName: eventName, payload: payload), maxValueLength: maxPayloadValueLength(eventName)),
|
|
173
|
+
urgent: urgent,
|
|
174
|
+
viewKey: viewKey
|
|
175
|
+
)
|
|
176
|
+
let outcome = runtime.recordEvent(event)
|
|
177
|
+
recordRuntimeEvent(event, outcome: outcome)
|
|
178
|
+
return outcome
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
public func startSession(metadata: [String: String] = [:], timestampMs: Int64 = 0) -> ForgeOutcome {
|
|
182
|
+
lock.lock()
|
|
183
|
+
defer { lock.unlock() }
|
|
184
|
+
guard !destroyed else { return .ignoredInvalidState }
|
|
185
|
+
guard !appSessionActive else { return .ignoredDuplicate }
|
|
186
|
+
appSessionActive = true
|
|
187
|
+
let event = ForgeRecordedEvent(
|
|
188
|
+
eventName: "sessionstart",
|
|
189
|
+
timestampMs: timestampMs,
|
|
190
|
+
payload: PrivacySanitizer.sanitize(appSessionPayload(eventName: "sessionstart", payload: metadata)),
|
|
191
|
+
urgent: true
|
|
192
|
+
)
|
|
193
|
+
let outcome = runtime.recordEvent(event)
|
|
194
|
+
if outcome.code == .failedWithDiagnostic || outcome.code == .unsupportedCapability {
|
|
195
|
+
appSessionActive = false
|
|
196
|
+
}
|
|
197
|
+
recordRuntimeEvent(event, outcome: outcome)
|
|
198
|
+
return outcome
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
public func endSession(metadata: [String: String] = [:], timestampMs: Int64 = 0) -> ForgeOutcome {
|
|
202
|
+
lock.lock()
|
|
203
|
+
defer { lock.unlock() }
|
|
204
|
+
guard !destroyed else { return .ignoredInvalidState }
|
|
205
|
+
guard appSessionActive else { return .ignoredInvalidState }
|
|
206
|
+
appSessionActive = false
|
|
207
|
+
let event = ForgeRecordedEvent(
|
|
208
|
+
eventName: "sessionstop",
|
|
209
|
+
timestampMs: timestampMs,
|
|
210
|
+
payload: PrivacySanitizer.sanitize(appSessionPayload(eventName: "sessionstop", payload: metadata)),
|
|
211
|
+
urgent: true
|
|
212
|
+
)
|
|
213
|
+
let outcome = runtime.recordEvent(event)
|
|
214
|
+
recordRuntimeEvent(event, outcome: outcome)
|
|
215
|
+
return outcome
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
public func trackSessionNavigation(_ metadata: [String: String]) -> ForgeOutcome {
|
|
219
|
+
recordEvent(eventName: "sessionnav", payload: metadata)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
public func trackSessionEvent(name: String, metadata: [String: String] = [:]) -> ForgeOutcome {
|
|
223
|
+
recordEvent(eventName: "sessionevent", payload: metadata.merging(["name": name]) { _, new in new })
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
public func trackSessionEventStop(name: String, metadata: [String: String] = [:]) -> ForgeOutcome {
|
|
227
|
+
recordEvent(eventName: "sessioneventstop", payload: metadata.merging(["name": name]) { _, new in new })
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
public func trackSessionError(errorCode: String, errorMessage: String, metadata: [String: String] = [:]) -> ForgeOutcome {
|
|
231
|
+
recordEvent(
|
|
232
|
+
eventName: "sessionerror",
|
|
233
|
+
payload: metadata.merging(["errorCode": errorCode, "errorMsg": errorMessage]) { _, new in new },
|
|
234
|
+
urgent: true
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/// Emits a raw event: the name gains the `raw_` prefix when missing and
|
|
239
|
+
/// travels on the session-event path (`sessionevent` with the normalized
|
|
240
|
+
/// name), matching the web binding's `trackRawEvent` wire shape.
|
|
241
|
+
public func trackRawEvent(name: String, params: [String: String] = [:]) -> ForgeOutcome {
|
|
242
|
+
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
243
|
+
let normalized = trimmed.hasPrefix("raw_") ? trimmed : "raw_\(trimmed)"
|
|
244
|
+
return trackSessionEvent(name: normalized, metadata: params)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/// Emits a `raw_search` event. The query text is subject to the kernel-side
|
|
248
|
+
/// remote visible-text privacy policy, which can strip it before any
|
|
249
|
+
/// payload leaves the device.
|
|
250
|
+
public func trackSearch(query: String, resultCount: Int? = nil) -> ForgeOutcome {
|
|
251
|
+
var params = ["searchQuery": query]
|
|
252
|
+
if let resultCount {
|
|
253
|
+
params["resultCount"] = String(resultCount)
|
|
254
|
+
}
|
|
255
|
+
return trackRawEvent(name: "raw_search", params: params)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/// Emits a `raw_search_result_click` event for a result chosen from the
|
|
259
|
+
/// search results produced by the query last reported via `trackSearch`.
|
|
260
|
+
public func trackSearchResultClick(query: String, options: ForgeSearchResultClickOptions = ForgeSearchResultClickOptions()) -> ForgeOutcome {
|
|
261
|
+
var params = ["searchQuery": query]
|
|
262
|
+
if let contentId = options.contentId {
|
|
263
|
+
params["contentId"] = contentId
|
|
264
|
+
}
|
|
265
|
+
if let position = options.position {
|
|
266
|
+
params["position"] = String(position)
|
|
267
|
+
}
|
|
268
|
+
return trackRawEvent(name: "raw_search_result_click", params: params)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/// Emits a typed business raw event: the name gains the `raw_` prefix when
|
|
272
|
+
/// missing and every attribute travels under the `target.attr.*`
|
|
273
|
+
/// convention, so the kernel target-attribute allowlist and PPE
|
|
274
|
+
/// `target.attr.*` facts apply unchanged.
|
|
275
|
+
public func trackBusinessEvent(name: String, attributes: [String: String] = [:]) -> ForgeOutcome {
|
|
276
|
+
var params: [String: String] = [:]
|
|
277
|
+
params.reserveCapacity(attributes.count)
|
|
278
|
+
for (key, value) in attributes {
|
|
279
|
+
params["target.attr.\(key)"] = value
|
|
280
|
+
}
|
|
281
|
+
return trackRawEvent(name: name, params: params)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/// Emits a `raw_attribution` event for apps that resolve their own
|
|
285
|
+
/// campaign parameters (the explicit variant; automatic UTM capture is a
|
|
286
|
+
/// separate capture family).
|
|
287
|
+
public func trackAttribution(options: ForgeAttributionOptions = ForgeAttributionOptions()) -> ForgeOutcome {
|
|
288
|
+
var params: [String: String] = [:]
|
|
289
|
+
if let utmSource = options.utmSource { params["utmSource"] = utmSource }
|
|
290
|
+
if let utmMedium = options.utmMedium { params["utmMedium"] = utmMedium }
|
|
291
|
+
if let utmCampaign = options.utmCampaign { params["utmCampaign"] = utmCampaign }
|
|
292
|
+
if let utmTerm = options.utmTerm { params["utmTerm"] = utmTerm }
|
|
293
|
+
if let utmContent = options.utmContent { params["utmContent"] = utmContent }
|
|
294
|
+
if let utmUrl = options.utmUrl { params["utmUrl"] = utmUrl }
|
|
295
|
+
return trackRawEvent(name: "raw_attribution", params: params)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/// Reports a successful login. The kernel stamps the session-level
|
|
299
|
+
/// identity on subsequent session traffic, emits `login
|
|
300
|
+
/// {authStatus:"success"}` on the CURRENT session, and atomically restarts
|
|
301
|
+
/// the app session when the effective identity changed.
|
|
302
|
+
public func loginSuccess(userId: String, options: [String: String] = [:]) -> ForgeOutcome {
|
|
303
|
+
recordIdentityEvent(kind: "loginSuccess", fields: options.merging(["userId": userId]) { _, new in new })
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/// Reports a failed login attempt: `login {authStatus:"failure"}`.
|
|
307
|
+
public func loginFailed(options: [String: String] = [:]) -> ForgeOutcome {
|
|
308
|
+
recordIdentityEvent(kind: "loginFailed", fields: options)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/// Reports a logout. The `logout` event still carries the outgoing user;
|
|
312
|
+
/// the kernel then clears the identity to Anonymous and atomically
|
|
313
|
+
/// restarts the app session.
|
|
314
|
+
public func logout(options: [String: String] = [:]) -> ForgeOutcome {
|
|
315
|
+
recordIdentityEvent(kind: "logout", fields: options)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/// Reports a profile creation: `profile_created {profileId, profileType?}`.
|
|
319
|
+
public func profileCreated(profileId: String, options: [String: String] = [:]) -> ForgeOutcome {
|
|
320
|
+
recordIdentityEvent(kind: "profileCreated", fields: options.merging(["profileId": profileId]) { _, new in new })
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/// Reports a profile selection. Stamps `profileId` on the session identity
|
|
324
|
+
/// and atomically restarts the app session when the profile changed.
|
|
325
|
+
public func profileSelected(profileId: String, options: [String: String] = [:]) -> ForgeOutcome {
|
|
326
|
+
recordIdentityEvent(kind: "profileSelected", fields: options.merging(["profileId": profileId]) { _, new in new })
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/// Reports a profile deletion: `profile_deleted {profileId}`.
|
|
330
|
+
public func profileDeleted(profileId: String) -> ForgeOutcome {
|
|
331
|
+
recordIdentityEvent(kind: "profileDeleted", fields: ["profileId": profileId])
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/// Forwards a typed identity transition to the runtime. Recognized fields
|
|
335
|
+
/// (`userId`, `profileId`, `profileType`, `method`, `errorCode`, `reason`,
|
|
336
|
+
/// `userType`) are consumed by the kernel; remaining entries ride as extra
|
|
337
|
+
/// session-event params. Validation failures (e.g. a missing `userId`)
|
|
338
|
+
/// surface as `failedWithDiagnostic` outcomes from the kernel.
|
|
339
|
+
public func recordIdentityEvent(kind: String, fields: [String: String] = [:], timestampMs: Int64 = 0) -> ForgeOutcome {
|
|
340
|
+
lock.lock()
|
|
341
|
+
defer { lock.unlock() }
|
|
342
|
+
guard !destroyed else { return .ignoredInvalidState }
|
|
343
|
+
return runtime.recordIdentityEvent(
|
|
344
|
+
kind: kind,
|
|
345
|
+
timemarkMs: timestampMs,
|
|
346
|
+
fields: PrivacySanitizer.sanitize(fields)
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
public func trackNetworkRequest(_ record: NetworkRequestRecord) -> ForgeOutcome {
|
|
351
|
+
lock.lock()
|
|
352
|
+
defer { lock.unlock() }
|
|
353
|
+
guard !destroyed else { return .ignoredInvalidState }
|
|
354
|
+
return runtime.recordNetworkEvent(record)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
public func recordApplicationForeground(_ foreground: Bool, timestampMs: Int64 = 0) -> ForgeOutcome {
|
|
358
|
+
lock.lock()
|
|
359
|
+
defer { lock.unlock() }
|
|
360
|
+
guard !destroyed else { return .ignoredInvalidState }
|
|
361
|
+
return runtime.recordApplicationForeground(foreground, timestampMs: timestampMs)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
public func setLogLevel(_ level: ForgeLogLevel) -> ForgeOutcome {
|
|
365
|
+
logger.setLevel(level)
|
|
366
|
+
return runtime.setLogLevel(level)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
public func effectiveLogLevel() -> ForgeLogLevel {
|
|
370
|
+
logger.effectiveLevel()
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
public func createVideoView(timestampMs: Int64, options: VideoOptions) -> RuntimeVideoView {
|
|
374
|
+
runtime.createVideoView(timestampMs: timestampMs, options: options)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
public func recordPlayerEvent(
|
|
378
|
+
view: RuntimeVideoView,
|
|
379
|
+
event: PlayerEvent,
|
|
380
|
+
timestampMs: Int64,
|
|
381
|
+
metadata: PlayerMetadataSnapshot,
|
|
382
|
+
options: VideoOptions
|
|
383
|
+
) -> ForgeOutcome {
|
|
384
|
+
runtime.recordPlayerEvent(
|
|
385
|
+
view: view,
|
|
386
|
+
event: event,
|
|
387
|
+
timemarkMs: timestampMs,
|
|
388
|
+
elapsedRealtimeMs: elapsedRealtimeFor(timestampMs),
|
|
389
|
+
options: options,
|
|
390
|
+
metadata: metadata
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
public func recordAdEvent(
|
|
395
|
+
view: RuntimeVideoView,
|
|
396
|
+
event: AdEvent,
|
|
397
|
+
timestampMs: Int64,
|
|
398
|
+
metadata: AdMetadataSnapshot,
|
|
399
|
+
options: VideoOptions,
|
|
400
|
+
adapterVersion: String?
|
|
401
|
+
) -> ForgeOutcome {
|
|
402
|
+
runtime.recordAdEvent(
|
|
403
|
+
view: view,
|
|
404
|
+
event: event,
|
|
405
|
+
timemarkMs: timestampMs,
|
|
406
|
+
elapsedRealtimeMs: elapsedRealtimeFor(timestampMs),
|
|
407
|
+
options: options,
|
|
408
|
+
metadata: metadata,
|
|
409
|
+
adapterVersion: adapterVersion
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
public func registerContentMetadataProvider(view: RuntimeVideoView, provider: RuntimeContentMetadataProvider?) -> ForgeOutcome {
|
|
414
|
+
runtime.registerContentMetadataProvider(view: view, provider: provider)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
public func registerVideoPreSendHook(view: RuntimeVideoView, hook: RuntimeVideoPreSendHook?) -> ForgeOutcome {
|
|
418
|
+
runtime.registerVideoPreSendHook(view: view, hook: hook)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
public func registerSessionPreSendHook(_ hook: RuntimeSessionPreSendHook?) -> ForgeOutcome {
|
|
422
|
+
runtime.registerSessionPreSendHook(hook)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
public func addOnSendRequestListener(_ listener: @escaping (String, inout [String: String]) -> Void) -> ForgeEventSubscription {
|
|
426
|
+
let id = UUID()
|
|
427
|
+
lock.lock()
|
|
428
|
+
onSendRequestListeners[id] = listener
|
|
429
|
+
lock.unlock()
|
|
430
|
+
refreshSessionPreSendHook()
|
|
431
|
+
return ForgeEventSubscription { [weak self] in
|
|
432
|
+
self?.lock.lock()
|
|
433
|
+
self?.onSendRequestListeners.removeValue(forKey: id)
|
|
434
|
+
self?.lock.unlock()
|
|
435
|
+
self?.refreshSessionPreSendHook()
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
public func subscribeEvents(_ observer: @escaping (ForgeRecordedEvent, ForgeOutcome) -> Void) -> ForgeEventSubscription {
|
|
440
|
+
let id = UUID()
|
|
441
|
+
lock.lock()
|
|
442
|
+
eventObservers[id] = observer
|
|
443
|
+
lock.unlock()
|
|
444
|
+
return ForgeEventSubscription { [weak self] in
|
|
445
|
+
self?.lock.lock()
|
|
446
|
+
self?.eventObservers.removeValue(forKey: id)
|
|
447
|
+
self?.lock.unlock()
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/// Observes kernel-derived capture plans. The latest plan (if any) is
|
|
452
|
+
/// replayed synchronously so listeners registered after the first
|
|
453
|
+
/// emission still reconcile their observers immediately.
|
|
454
|
+
public func addCapturePlanListener(_ listener: @escaping (ForgeCapturePlan) -> Void) -> ForgeEventSubscription {
|
|
455
|
+
let id = UUID()
|
|
456
|
+
lock.lock()
|
|
457
|
+
capturePlanObservers[id] = listener
|
|
458
|
+
let replay = latestCapturePlan
|
|
459
|
+
lock.unlock()
|
|
460
|
+
if let replay {
|
|
461
|
+
listener(replay)
|
|
462
|
+
}
|
|
463
|
+
return ForgeEventSubscription { [weak self] in
|
|
464
|
+
self?.lock.lock()
|
|
465
|
+
self?.capturePlanObservers.removeValue(forKey: id)
|
|
466
|
+
self?.lock.unlock()
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/// Latest kernel-emitted capture plan, or `nil` before the first
|
|
471
|
+
/// emission. Adapters read `capturePlan()?.behavior` at view start for
|
|
472
|
+
/// the remote behavior hints.
|
|
473
|
+
public func capturePlan() -> ForgeCapturePlan? {
|
|
474
|
+
lock.lock()
|
|
475
|
+
let latest = latestCapturePlan
|
|
476
|
+
lock.unlock()
|
|
477
|
+
return latest ?? runtime.capturePlan()
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private func dispatchCapturePlan(_ plan: ForgeCapturePlan) {
|
|
481
|
+
lock.lock()
|
|
482
|
+
guard !destroyed else {
|
|
483
|
+
lock.unlock()
|
|
484
|
+
return
|
|
485
|
+
}
|
|
486
|
+
latestCapturePlan = plan
|
|
487
|
+
let observers = Array(capturePlanObservers.values)
|
|
488
|
+
lock.unlock()
|
|
489
|
+
observers.forEach { $0(plan) }
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
public func destroyVideoView(_ view: RuntimeVideoView) -> ForgeOutcome {
|
|
493
|
+
runtime.destroyVideoView(view)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
public func snapshotEvents() -> [ForgeRecordedEvent] {
|
|
497
|
+
let snapshot = runtime.snapshotEvents()
|
|
498
|
+
if !snapshot.isEmpty { return snapshot }
|
|
499
|
+
lock.lock()
|
|
500
|
+
defer { lock.unlock() }
|
|
501
|
+
return recordedEvents
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
public func destroy() -> ForgeOutcome {
|
|
505
|
+
lock.lock()
|
|
506
|
+
defer { lock.unlock() }
|
|
507
|
+
guard !destroyed else { return .ignoredDuplicate }
|
|
508
|
+
destroyed = true
|
|
509
|
+
eventObservers.removeAll()
|
|
510
|
+
onSendRequestListeners.removeAll()
|
|
511
|
+
capturePlanObservers.removeAll()
|
|
512
|
+
recordedEvents.removeAll()
|
|
513
|
+
_ = runtime.registerSessionPreSendHook(nil)
|
|
514
|
+
_ = runtime.setCapturePlanListener(nil)
|
|
515
|
+
return runtime.destroy()
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private func appSessionPayload(eventName: String, payload: [String: String]) -> [String: String] {
|
|
519
|
+
if eventName.hasPrefix("raw_") { return payload }
|
|
520
|
+
return ["appName": config.appName]
|
|
521
|
+
.merging(config.appReleaseVersion.map { ["appReleaseVersion": $0] } ?? [:]) { _, new in new }
|
|
522
|
+
.merging(payload) { _, new in new }
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private func maxPayloadValueLength(_ eventName: String) -> Int {
|
|
526
|
+
eventName == "app_crash" ? 16_384 : 512
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private func elapsedRealtimeFor(_ timestampMs: Int64) -> Int64 {
|
|
530
|
+
timestampMs <= 0 ? 0 : ForgeClock.elapsedRealtimeMs(anchorTimestampMs: timestampMs)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
private func recordRuntimeEvent(_ event: ForgeRecordedEvent, outcome: ForgeOutcome) {
|
|
534
|
+
if recordedEvents.contains(event) { return }
|
|
535
|
+
recordedEvents.append(event)
|
|
536
|
+
if recordedEvents.count > Self.eventSnapshotLimit {
|
|
537
|
+
recordedEvents.removeFirst(recordedEvents.count - Self.eventSnapshotLimit)
|
|
538
|
+
}
|
|
539
|
+
eventObservers.values.forEach { observer in
|
|
540
|
+
observer(event, outcome)
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
private func refreshSessionPreSendHook() {
|
|
545
|
+
if onSendRequestListeners.isEmpty {
|
|
546
|
+
_ = runtime.registerSessionPreSendHook(nil)
|
|
547
|
+
return
|
|
548
|
+
}
|
|
549
|
+
let hook = SessionPreSendHook { [weak self] serviceName, params in
|
|
550
|
+
guard let self else { return }
|
|
551
|
+
self.lock.lock()
|
|
552
|
+
let listeners = Array(self.onSendRequestListeners.values)
|
|
553
|
+
self.lock.unlock()
|
|
554
|
+
listeners.forEach { $0(serviceName, ¶ms) }
|
|
555
|
+
}
|
|
556
|
+
_ = runtime.registerSessionPreSendHook(hook)
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private extension ForgeAppleConfig {
|
|
561
|
+
func withPluginVersion(_ pluginVersion: String) -> ForgeAppleConfig {
|
|
562
|
+
var copy = self
|
|
563
|
+
copy.pluginVersion = pluginVersion
|
|
564
|
+
return copy
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
public final class ForgeEventSubscription {
|
|
569
|
+
private let cancelBlock: () -> Void
|
|
570
|
+
private var cancelled = false
|
|
571
|
+
private let lock = NSLock()
|
|
572
|
+
|
|
573
|
+
public init(_ cancelBlock: @escaping () -> Void) {
|
|
574
|
+
self.cancelBlock = cancelBlock
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
public func cancel() {
|
|
578
|
+
lock.lock()
|
|
579
|
+
defer { lock.unlock() }
|
|
580
|
+
guard !cancelled else { return }
|
|
581
|
+
cancelled = true
|
|
582
|
+
cancelBlock()
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
private final class SessionPreSendHook: RuntimeSessionPreSendHook {
|
|
587
|
+
private let callback: (String, inout [String: String]) -> Void
|
|
588
|
+
|
|
589
|
+
init(callback: @escaping (String, inout [String: String]) -> Void) {
|
|
590
|
+
self.callback = callback
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
func willSendRequest(serviceName: String, params: inout [String: String]) {
|
|
594
|
+
callback(serviceName, ¶ms)
|
|
595
|
+
}
|
|
596
|
+
}
|