@npaw/forge-react-native 9.7.3 → 9.7.4

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 (20) hide show
  1. package/android/build.gradle +1 -1
  2. package/ios/ForgeApple/ForgeKernelFFI.xcframework/Info.plist +8 -8
  3. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/forge_kernelFFI.h +60 -0
  4. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/libForgeKernelFFI.a +0 -0
  5. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/forge_kernelFFI.h +60 -0
  6. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/libForgeKernelFFI.a +0 -0
  7. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/forge_kernelFFI.h +60 -0
  8. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/libForgeKernelFFI.a +0 -0
  9. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/forge_kernelFFI.h +60 -0
  10. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/libForgeKernelFFI.a +0 -0
  11. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernel.swift +415 -1
  12. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernelFFI.h +60 -0
  13. package/ios/ForgeApple/Sources/NPAWForgeAVPlayerAdapter/AVPlayerAdapter.swift +22 -1
  14. package/ios/ForgeApple/Sources/NPAWForgeCore/DeviceInsightsCollector.swift +494 -0
  15. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeApple.swift +51 -0
  16. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeCapturePlan.swift +14 -0
  17. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeRuntime.swift +48 -0
  18. package/ios/ForgeApple/Sources/NPAWForgeCore/NPAWForge.swift +49 -1
  19. package/ios/ForgeApple/Sources/NPAWForgeCore/NativeUniFFIRuntime.swift +77 -0
  20. package/package.json +1 -1
@@ -71,6 +71,18 @@ public protocol ForgeRuntime: AnyObject {
71
71
  /// Forwards a location fix. Fixes only - the kernel owns every threshold,
72
72
  /// so the three platforms cannot drift into different definitions of moving.
73
73
  func recordLocationFix(_ fix: ForgeLocationFix) -> ForgeOutcome
74
+ /// Forwards a device runtime health sample. Every field is optional; the
75
+ /// kernel validates field-wise, derives `dev_cpu` between samples and
76
+ /// attaches the set to the next session beat and video ping. Refused
77
+ /// (`ignoredInvalidState`) while device insights are off remotely.
78
+ func recordDeviceSample(_ sample: ForgeDeviceSample) -> ForgeOutcome
79
+ /// Registers (or clears with `nil`) the receiver of the kernel's device
80
+ /// sample requests. The kernel owns the cadence: it asks for one sample per
81
+ /// tick with the operator's family allowlist (`cpu`, `mem`, `bat`, `thm`,
82
+ /// `disk`), and the host answers through ``recordDeviceSample(_:)``. A
83
+ /// request is not replayed to a late listener - the kernel asks again on
84
+ /// the next tick.
85
+ func setDeviceSampleListener(_ listener: (([String]) -> Void)?) -> ForgeOutcome
74
86
  func createVideoView(timestampMs: Int64, options: VideoOptions) -> RuntimeVideoView
75
87
  func recordEvent(_ event: ForgeRecordedEvent) -> ForgeOutcome
76
88
  /// Records a typed identity transition (`loginSuccess`, `loginFailed`,
@@ -131,6 +143,12 @@ public extension ForgeRuntime {
131
143
  func setAutoConnectionType(_ connectionType: String?) -> ForgeOutcome { .accepted }
132
144
  /// Additive default: no kernel behind it means nothing to derive motion with.
133
145
  func recordLocationFix(_ fix: ForgeLocationFix) -> ForgeOutcome { .accepted }
146
+ /// Additive default: a runtime with no kernel behind it has no sampler to
147
+ /// feed, so it accepts and forgets.
148
+ func recordDeviceSample(_ sample: ForgeDeviceSample) -> ForgeOutcome { .accepted }
149
+ /// Additive default: no kernel means no cadence, so registration succeeds
150
+ /// and simply never delivers a request.
151
+ func setDeviceSampleListener(_ listener: (([String]) -> Void)?) -> ForgeOutcome { .accepted }
134
152
  }
135
153
 
136
154
  public protocol ForgeRuntimeFactory: AnyObject {
@@ -148,6 +166,8 @@ public final class UnavailableForgeRuntime: ForgeRuntime {
148
166
  public func setLogLevel(_ level: ForgeLogLevel) -> ForgeOutcome { .accepted }
149
167
  public func setAutoConnectionType(_ connectionType: String?) -> ForgeOutcome { .accepted }
150
168
  public func recordLocationFix(_ fix: ForgeLocationFix) -> ForgeOutcome { .accepted }
169
+ public func recordDeviceSample(_ sample: ForgeDeviceSample) -> ForgeOutcome { .accepted }
170
+ public func setDeviceSampleListener(_ listener: (([String]) -> Void)?) -> ForgeOutcome { .accepted }
151
171
  public func createVideoView(timestampMs: Int64, options: VideoOptions) -> RuntimeVideoView { RuntimeVideoView(id: -1) }
152
172
  public func recordEvent(_ event: ForgeRecordedEvent) -> ForgeOutcome { failed() }
153
173
  public func recordIdentityEvent(kind: String, timemarkMs: Int64, fields: [String: String]) -> ForgeOutcome { failed() }
@@ -215,6 +235,14 @@ public final class RecordingForgeRuntime: ForgeRuntime {
215
235
  public private(set) var publishedConnectionTypes: [String?] = []
216
236
  /// Every location fix forwarded, in order.
217
237
  public private(set) var recordedLocationFixes: [ForgeLocationFix] = []
238
+ /// Every device sample forwarded, in order.
239
+ public private(set) var recordedDeviceSamples: [ForgeDeviceSample] = []
240
+ /// The device sample request listener currently registered, `nil` once
241
+ /// cleared.
242
+ public private(set) var deviceSampleListener: (([String]) -> Void)?
243
+ /// Device samples arrive from the collector's utility queue while a test
244
+ /// reads them from the main thread; only those two members are guarded.
245
+ private let recordingLock = NSLock()
218
246
  private var nextViewId: Int64 = 1
219
247
 
220
248
  public init() {}
@@ -229,6 +257,26 @@ public final class RecordingForgeRuntime: ForgeRuntime {
229
257
  recordedLocationFixes.append(fix)
230
258
  return .accepted
231
259
  }
260
+ public func recordDeviceSample(_ sample: ForgeDeviceSample) -> ForgeOutcome {
261
+ recordingLock.lock()
262
+ defer { recordingLock.unlock() }
263
+ recordedDeviceSamples.append(sample)
264
+ return .accepted
265
+ }
266
+ public func setDeviceSampleListener(_ listener: (([String]) -> Void)?) -> ForgeOutcome {
267
+ recordingLock.lock()
268
+ defer { recordingLock.unlock() }
269
+ deviceSampleListener = listener
270
+ return .accepted
271
+ }
272
+ /// Test hook mirroring the kernel's request push path: hands the family
273
+ /// allowlist to the registered listener, if any. Nothing is replayed.
274
+ public func emitDeviceSampleRequest(_ families: [String]) {
275
+ recordingLock.lock()
276
+ let listener = deviceSampleListener
277
+ recordingLock.unlock()
278
+ listener?(families)
279
+ }
232
280
  public func createVideoView(timestampMs: Int64, options: VideoOptions) -> RuntimeVideoView {
233
281
  createdVideoTimestamps.append(timestampMs)
234
282
  defer { nextViewId += 1 }
@@ -40,7 +40,7 @@ public final class NPAWForge: NSObject {
40
40
  /// `scripts/apple/publish-local-shipped.sh` rewrites this literal to the
41
41
  /// release version when staging the shipped package, and fails loudly if
42
42
  /// it cannot find it. Keep it a plain stored literal for that reason.
43
- public static let defaultPluginVersion = "9.7.3"
43
+ public static let defaultPluginVersion = "9.7.4"
44
44
  public static let defaultAppName = "apple"
45
45
  public static let defaultBuildVariant = "release"
46
46
  public static let defaultLmaEndpoint = "https://lma.npaw.com"
@@ -95,6 +95,11 @@ public final class NPAWForge: NSObject {
95
95
  /// Set when the integrator allowed location observation. It reads nothing
96
96
  /// until a capture plan carries the location bit.
97
97
  private var locationObserver: ForgeLocationObserver?
98
+ /// Answers the kernel's device sample requests. Inert until an operator
99
+ /// enables device insights remotely; held so `destroy()` can stop it and
100
+ /// return battery monitoring to what it was.
101
+ private var deviceInsightsCollector: ForgeDeviceInsightsCollector?
102
+ private var deviceInsightsSubscriptions: [ForgeEventSubscription] = []
98
103
 
99
104
  public init(kernel: ForgeApple) {
100
105
  self.kernel = kernel
@@ -250,6 +255,10 @@ public final class NPAWForge: NSObject {
250
255
  connectionTypeObserver = nil
251
256
  locationObserver?.stop()
252
257
  locationObserver = nil
258
+ deviceInsightsSubscriptions.forEach { $0.cancel() }
259
+ deviceInsightsSubscriptions.removeAll()
260
+ deviceInsightsCollector?.stop()
261
+ deviceInsightsCollector = nil
253
262
  let adapters = videoAdapters
254
263
  videoAdapters.removeAll()
255
264
  adapters.forEach { _ = $0.destroy() }
@@ -270,6 +279,17 @@ public final class NPAWForge: NSObject {
270
279
  kernel.recordLocationFix(fix)
271
280
  }
272
281
 
282
+ /// Forwards a device runtime health sample to the kernel. Forge answers the
283
+ /// kernel's requests itself (process CPU time, memory, battery on iOS,
284
+ /// thermal state); use this only to supply a value Forge does not read,
285
+ /// such as `diskFreeBytes` from an app that declares the required-reason
286
+ /// API in its own privacy manifest. Every field is optional. Ignored while
287
+ /// device insights are off remotely.
288
+ @discardableResult
289
+ public func recordDeviceSample(_ sample: ForgeDeviceSample) -> ForgeOutcome {
290
+ kernel.recordDeviceSample(sample)
291
+ }
292
+
273
293
  /// Starts or stops location observation to match the capture plan. Nothing
274
294
  /// reads location - not even the authorization status - until an operator
275
295
  /// enables one of the two motion switches.
@@ -295,6 +315,24 @@ public final class NPAWForge: NSObject {
295
315
  observer.start()
296
316
  }
297
317
 
318
+ /// Wires the collector to the kernel: capture plans pre-arm the observers a
319
+ /// family needs (battery monitoring for `bat`), sample requests trigger the
320
+ /// reads. Both subscriptions are cancelled in `destroy()`.
321
+ func installDeviceInsightsCollector(_ collector: ForgeDeviceInsightsCollector) {
322
+ lock.lock()
323
+ deviceInsightsCollector = collector
324
+ lock.unlock()
325
+ let planSubscription = kernel.addCapturePlanListener { [weak collector] plan in
326
+ collector?.applyCapturePlan(plan)
327
+ }
328
+ let requestSubscription = kernel.addDeviceSampleRequestListener { [weak collector] families in
329
+ collector?.handleSampleRequest(families)
330
+ }
331
+ lock.lock()
332
+ deviceInsightsSubscriptions.append(contentsOf: [planSubscription, requestSubscription])
333
+ lock.unlock()
334
+ }
335
+
298
336
  func buildVideoAdapter(
299
337
  identifier: String,
300
338
  options: VideoOptions,
@@ -554,6 +592,16 @@ public final class NPAWForge: NSObject {
554
592
  }
555
593
  )
556
594
  }
595
+ // Always installed and inert by construction: it reads nothing
596
+ // until the kernel asks, and the kernel asks only once an operator
597
+ // enabled device insights remotely (that enablement is the whole
598
+ // authorisation - device health is non-personal and needs no OS
599
+ // permission on any platform).
600
+ forge.installDeviceInsightsCollector(
601
+ ForgeDeviceInsightsCollector { [weak forge] sample in
602
+ forge?.recordDeviceSample(sample)
603
+ }
604
+ )
557
605
  ForgeAutomaticCaptureRegistry.installIfAvailable(forge)
558
606
  return forge
559
607
  }
@@ -27,6 +27,7 @@ final class UniFFIForgeRuntime: ForgeRuntime {
27
27
  private var sessionPreSendHook: UniFFISessionPreSendHook?
28
28
  private var capturePlanListener: UniFFICapturePlanListener?
29
29
  private var logListener: UniFFILogListener?
30
+ private var deviceSampleListener: UniFFIDeviceSampleListener?
30
31
 
31
32
  init(config: ForgeAppleConfig) {
32
33
  bridge = ForgeRuntimeBridge(config: AppleRuntimeConfig(
@@ -71,6 +72,37 @@ final class UniFFIForgeRuntime: ForgeRuntime {
71
72
  ))
72
73
  }
73
74
 
75
+ func recordDeviceSample(_ sample: ForgeDeviceSample) -> ForgeOutcome {
76
+ outcome(bridge.recordDeviceSample(sample: appleDeviceSample(sample)))
77
+ }
78
+
79
+ func setDeviceSampleListener(_ listener: (([String]) -> Void)?) -> ForgeOutcome {
80
+ if let listener {
81
+ let callback = UniFFIDeviceSampleListener(callback: listener)
82
+ // Retained before registering, like the plan and log listeners, so
83
+ // a request that fires as soon as the bridge has the wrapper lands
84
+ // in one the runtime keeps alive.
85
+ callbackLock.lock()
86
+ deviceSampleListener = callback
87
+ callbackLock.unlock()
88
+ let result = outcome(bridge.setDeviceSampleListener(listener: callback))
89
+ if !result.isAccepted {
90
+ callbackLock.lock()
91
+ deviceSampleListener = nil
92
+ callbackLock.unlock()
93
+ }
94
+ return result
95
+ }
96
+
97
+ let result = outcome(bridge.clearDeviceSampleListener())
98
+ if result.isAccepted {
99
+ callbackLock.lock()
100
+ deviceSampleListener = nil
101
+ callbackLock.unlock()
102
+ }
103
+ return result
104
+ }
105
+
74
106
  func createVideoView(timestampMs: Int64, options: VideoOptions) -> RuntimeVideoView {
75
107
  RuntimeVideoView(id: Int64(clamping: bridge.createVideoView(
76
108
  nowMs: u64(timestampMs),
@@ -312,12 +344,14 @@ final class UniFFIForgeRuntime: ForgeRuntime {
312
344
  let result = outcome(bridge.destroy())
313
345
  _ = bridge.clearCapturePlanListener()
314
346
  _ = bridge.clearLogListener()
347
+ _ = bridge.clearDeviceSampleListener()
315
348
  callbackLock.lock()
316
349
  contentMetadataProviders.removeAll()
317
350
  videoPreSendHooks.removeAll()
318
351
  sessionPreSendHook = nil
319
352
  capturePlanListener = nil
320
353
  logListener = nil
354
+ deviceSampleListener = nil
321
355
  callbackLock.unlock()
322
356
  return result
323
357
  }
@@ -508,6 +542,7 @@ private func forgeCapturePlan(_ plan: AppleCapturePlan) -> ForgeCapturePlan {
508
542
  attributionIncludeLandingUrl: plan.attributionIncludeLandingUrl,
509
543
  attributionDeriveFromDeepLinks: plan.attributionDeriveFromDeepLinks,
510
544
  attributionInstallReferrerEnabled: plan.attributionInstallReferrerEnabled,
545
+ deviceInsightFamilies: plan.deviceInsightFamilies,
511
546
  hash: plan.hash
512
547
  )
513
548
  }
@@ -548,6 +583,33 @@ private func appleAdInsertionType(_ insertionType: AdInsertionType) -> AppleAdIn
548
583
  }
549
584
  }
550
585
 
586
+ /// Maps the typed Swift sample onto the UniFFI dictionary one-to-one. `nil`
587
+ /// stays `nil` on every field: the kernel's "absent" is the UniFFI `None`,
588
+ /// never `0`. A non-positive timestamp becomes `0`, which the bridge stamps.
589
+ private func appleDeviceSample(_ sample: ForgeDeviceSample) -> AppleDeviceSample {
590
+ AppleDeviceSample(
591
+ sampledAtMs: u64(sample.sampledAtMs),
592
+ cpuTimeMs: sample.cpuTimeMs,
593
+ cpuCores: sample.cpuCores,
594
+ memUsedBytes: sample.memUsedBytes,
595
+ memAvailBytes: sample.memAvailBytes,
596
+ batteryLevelPct: sample.batteryLevelPct,
597
+ batteryCharging: sample.batteryCharging,
598
+ thermal: sample.thermal.map(appleThermalState),
599
+ diskFreeBytes: sample.diskFreeBytes
600
+ )
601
+ }
602
+
603
+ private func appleThermalState(_ state: ForgeThermalState) -> AppleThermalState {
604
+ switch state {
605
+ case .nominal: return .nominal
606
+ case .fair: return .fair
607
+ case .serious: return .serious
608
+ case .critical: return .critical
609
+ case .emergency: return .emergency
610
+ }
611
+ }
612
+
551
613
  private func customEventJson(_ payload: CustomVideoEventPayload) -> String? {
552
614
  var object: [String: Any] = [
553
615
  "name": payload.name,
@@ -652,4 +714,19 @@ private final class UniFFILogListener: AppleLogListener, @unchecked Sendable {
652
714
  callback(ForgeRenderedLog(level: level, tag: log.tag, message: log.message))
653
715
  }
654
716
  }
717
+
718
+ /// Receives the kernel's device sample requests on the runtime worker thread
719
+ /// and hands the family allowlist to the Swift listener, which must not block
720
+ /// (the collector hops to its own queue).
721
+ private final class UniFFIDeviceSampleListener: AppleDeviceSampleListener, @unchecked Sendable {
722
+ private let callback: ([String]) -> Void
723
+
724
+ init(callback: @escaping ([String]) -> Void) {
725
+ self.callback = callback
726
+ }
727
+
728
+ func onSampleRequest(families: [String]) {
729
+ callback(families)
730
+ }
731
+ }
655
732
  #endif
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@npaw/forge-react-native",
3
- "version": "9.7.3",
3
+ "version": "9.7.4",
4
4
  "description": "NPAW Forge analytics for React Native: New Architecture TurboModule facade over the native Forge Android/Apple SDKs.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Victor Soto <victor.soto@nicepeopleatwork.com>",