@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,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, &params) }
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, &params)
595
+ }
596
+ }