@npaw/forge-react-native 9.7.1 → 9.7.3

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 (38) hide show
  1. package/android/build.gradle +1 -1
  2. package/android/src/main/java/com/npaw/forge/reactnative/ForgeDtoMapper.java +10 -0
  3. package/android/src/main/java/com/npaw/forge/reactnative/ForgeInstanceRegistry.java +59 -1
  4. package/android/src/main/java/com/npaw/forge/reactnative/NPAWForgeModule.java +33 -0
  5. package/android/src/test/java/com/npaw/forge/reactnative/ForgeInstanceRegistryTest.java +65 -0
  6. package/app.plugin.js +17 -0
  7. package/ios/ForgeApple/ForgeKernelFFI.xcframework/Info.plist +8 -8
  8. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/forge_kernelFFI.h +71 -0
  9. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/libForgeKernelFFI.a +0 -0
  10. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/forge_kernelFFI.h +71 -0
  11. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/libForgeKernelFFI.a +0 -0
  12. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/forge_kernelFFI.h +71 -0
  13. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/libForgeKernelFFI.a +0 -0
  14. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/forge_kernelFFI.h +71 -0
  15. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/libForgeKernelFFI.a +0 -0
  16. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernel.swift +198 -1
  17. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernelFFI.h +71 -0
  18. package/ios/ForgeApple/Sources/NPAWForgeAVPlayerAdapter/AVPlayerAdapter.swift +12 -0
  19. package/ios/ForgeApple/Sources/NPAWForgeCore/ConnectionType.swift +125 -0
  20. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeApple.swift +67 -1
  21. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeCapturePlan.swift +4 -0
  22. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeLogging.swift +80 -3
  23. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeRuntime.swift +44 -0
  24. package/ios/ForgeApple/Sources/NPAWForgeCore/LocationObserver.swift +183 -0
  25. package/ios/ForgeApple/Sources/NPAWForgeCore/NPAWForge.swift +123 -2
  26. package/ios/ForgeApple/Sources/NPAWForgeCore/NativeUniFFIRuntime.swift +59 -0
  27. package/ios/ForgeApple/Sources/NPAWForgeCore/PlayerAdapter.swift +17 -1
  28. package/ios/ForgeInstanceRegistry.swift +69 -0
  29. package/ios/ForgeModuleImpl.swift +53 -6
  30. package/ios/NPAWForgeTurboModule.mm +6 -1
  31. package/package.json +3 -1
  32. package/plugin/index.js +82 -0
  33. package/plugin/mutations.js +140 -0
  34. package/src/NPAWForge.ts +12 -1
  35. package/src/events.ts +21 -1
  36. package/src/index.ts +11 -1
  37. package/src/sections.ts +292 -0
  38. package/src/types.ts +23 -2
@@ -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.1"
43
+ public static let defaultPluginVersion = "9.7.3"
44
44
  public static let defaultAppName = "apple"
45
45
  public static let defaultBuildVariant = "release"
46
46
  public static let defaultLmaEndpoint = "https://lma.npaw.com"
@@ -89,6 +89,12 @@ public final class NPAWForge: NSObject {
89
89
  private var destroyed = false
90
90
  private let lock = NSRecursiveLock()
91
91
  private var videoAdapters: [VideoAdapter] = []
92
+ /// Set when automatic connection type collection is on. Held so `destroy()`
93
+ /// can cancel the path monitor that belongs to this instance.
94
+ private var connectionTypeObserver: ForgeConnectionTypeObserver?
95
+ /// Set when the integrator allowed location observation. It reads nothing
96
+ /// until a capture plan carries the location bit.
97
+ private var locationObserver: ForgeLocationObserver?
92
98
 
93
99
  public init(kernel: ForgeApple) {
94
100
  self.kernel = kernel
@@ -102,6 +108,14 @@ public final class NPAWForge: NSObject {
102
108
  kernel.subscribeEvents(observer)
103
109
  }
104
110
 
111
+ /// Observes the kernel's rendered decision log - the same lines
112
+ /// `logLevel: trace` writes to the platform console. Registering is not
113
+ /// required for console output; it is for hosts that forward the stream
114
+ /// somewhere else, as the React Native binding does.
115
+ public func addLogListener(_ listener: @escaping (ForgeRenderedLog) -> Void) -> ForgeEventSubscription {
116
+ kernel.addLogListener(listener)
117
+ }
118
+
105
119
  /// Observes kernel-derived capture plans (bitmask + focus/limit knobs +
106
120
  /// behavior hints). The latest plan is replayed to listeners added after
107
121
  /// the first emission. The kernel intake gates remain the semantic
@@ -232,12 +246,55 @@ public final class NPAWForge: NSObject {
232
246
  defer { lock.unlock() }
233
247
  guard !destroyed else { return .ignoredDuplicate }
234
248
  destroyed = true
249
+ connectionTypeObserver?.stop()
250
+ connectionTypeObserver = nil
251
+ locationObserver?.stop()
252
+ locationObserver = nil
235
253
  let adapters = videoAdapters
236
254
  videoAdapters.removeAll()
237
255
  adapters.forEach { _ = $0.destroy() }
238
256
  return kernel.destroy()
239
257
  }
240
258
 
259
+ /// Reports the device's current connection class to the kernel. Called by
260
+ /// the automatic observer; a `connectionType` the integrator set through
261
+ /// content metadata keeps precedence over it.
262
+ @discardableResult
263
+ public func setAutoConnectionType(_ connectionType: String?) -> ForgeOutcome {
264
+ kernel.setAutoConnectionType(connectionType)
265
+ }
266
+
267
+ /// Forwards a location fix to the kernel, which owns every threshold.
268
+ @discardableResult
269
+ public func recordLocationFix(_ fix: ForgeLocationFix) -> ForgeOutcome {
270
+ kernel.recordLocationFix(fix)
271
+ }
272
+
273
+ /// Starts or stops location observation to match the capture plan. Nothing
274
+ /// reads location - not even the authorization status - until an operator
275
+ /// enables one of the two motion switches.
276
+ func applyLocationPlan(_ plan: ForgeCapturePlan) {
277
+ guard let observer = locationObserver else { return }
278
+ if plan.bits & ForgeCapturePlan.Bits.location != 0 {
279
+ observer.start()
280
+ } else {
281
+ observer.stop()
282
+ }
283
+ }
284
+
285
+ func installLocationObserver(_ observer: ForgeLocationObserver) {
286
+ lock.lock()
287
+ locationObserver = observer
288
+ lock.unlock()
289
+ }
290
+
291
+ func installConnectionTypeObserver(_ observer: ForgeConnectionTypeObserver) {
292
+ lock.lock()
293
+ connectionTypeObserver = observer
294
+ lock.unlock()
295
+ observer.start()
296
+ }
297
+
241
298
  func buildVideoAdapter(
242
299
  identifier: String,
243
300
  options: VideoOptions,
@@ -299,8 +356,11 @@ public final class NPAWForge: NSObject {
299
356
  private var preSessionEventQueueMaxAgeMs: Int64 = 30_000
300
357
  private var logLevel: ForgeLogLevel = .warning
301
358
  private var autoDeviceIdentityEnabled = true
359
+ private var autoConnectionTypeEnabled = true
360
+ private var autoLocationEnabled = true
302
361
  private var deviceUuid: String?
303
362
  private var deviceInfo: ForgeDeviceInfo?
363
+ private var framework: String?
304
364
  private var services: ForgeAppleServices?
305
365
 
306
366
  public init(accountCode: String) {
@@ -391,6 +451,42 @@ public final class NPAWForge: NSObject {
391
451
  return self
392
452
  }
393
453
 
454
+ /// Disables automatic collection of the device connection type
455
+ /// (`connectionType`: `wifi`, `cellular`, `ethernet`, `other` or
456
+ /// `unknown`).
457
+ ///
458
+ /// Collection is on by default and uses `NWPathMonitor`, which needs no
459
+ /// entitlement and no user prompt. A `connectionType` supplied through
460
+ /// content metadata always wins, whether or not this is enabled.
461
+ @discardableResult
462
+ public func setAutoConnectionTypeEnabled(_ enabled: Bool) -> Builder {
463
+ autoConnectionTypeEnabled = enabled
464
+ return self
465
+ }
466
+
467
+ /// Disables location observation entirely, whatever an operator
468
+ /// configures remotely.
469
+ ///
470
+ /// Forge never requests location authorization and reads location only
471
+ /// when the app already holds one AND an operator enabled a motion
472
+ /// switch. This is the local override for integrators who would rather
473
+ /// Forge never touch location at all.
474
+ @discardableResult
475
+ public func setAutoLocationEnabled(_ enabled: Bool) -> Builder {
476
+ autoLocationEnabled = enabled
477
+ return self
478
+ }
479
+
480
+ /// Declares the framework layered on top of this facade. First-party
481
+ /// bindings only: React Native sets `"react-native"` so its traffic is
482
+ /// distinguishable in `pluginInfo`. Unknown values are dropped by the
483
+ /// kernel, and the reported platform stays `apple` either way.
484
+ @discardableResult
485
+ public func setFramework(_ value: String?) -> Builder {
486
+ framework = value
487
+ return self
488
+ }
489
+
394
490
  /// Overrides the automatically collected device identifier.
395
491
  @discardableResult
396
492
  public func setDeviceUuid(_ value: String?) -> Builder {
@@ -430,9 +526,34 @@ public final class NPAWForge: NSObject {
430
526
  preSessionEventQueueMaxAgeMs: preSessionEventQueueMaxAgeMs,
431
527
  logLevel: logLevel,
432
528
  deviceUuid: resolvedDeviceUuid,
433
- deviceInfo: resolvedDeviceInfo?.isEmpty == true ? nil : resolvedDeviceInfo
529
+ deviceInfo: resolvedDeviceInfo?.isEmpty == true ? nil : resolvedDeviceInfo,
530
+ framework: framework
434
531
  )
435
532
  let forge = NPAWForge(kernel: ForgeApple(config: config, services: resolvedServices))
533
+ if autoLocationEnabled {
534
+ // Created here but inert: it reads nothing until a capture plan
535
+ // carries the location bit. The plan registry replays the
536
+ // latest plan to a late subscriber, so this covers a plan that
537
+ // already arrived as well as later ones.
538
+ forge.installLocationObserver(
539
+ ForgeLocationObserver { [weak forge] fix in
540
+ forge?.recordLocationFix(fix)
541
+ }
542
+ )
543
+ _ = forge.addCapturePlanListener { [weak forge] plan in
544
+ forge?.applyLocationPlan(plan)
545
+ }
546
+ }
547
+ if autoConnectionTypeEnabled {
548
+ // Started once here, for the life of the instance, and
549
+ // cancelled in `destroy()`. A monitor per view is what would
550
+ // leak one per playback.
551
+ forge.installConnectionTypeObserver(
552
+ ForgeConnectionTypeObserver { [weak forge] connectionType in
553
+ forge?.setAutoConnectionType(connectionType)
554
+ }
555
+ )
556
+ }
436
557
  ForgeAutomaticCaptureRegistry.installIfAvailable(forge)
437
558
  return forge
438
559
  }
@@ -26,6 +26,7 @@ final class UniFFIForgeRuntime: ForgeRuntime {
26
26
  private var videoPreSendHooks: [Int64: UniFFIVideoPreSendHook] = [:]
27
27
  private var sessionPreSendHook: UniFFISessionPreSendHook?
28
28
  private var capturePlanListener: UniFFICapturePlanListener?
29
+ private var logListener: UniFFILogListener?
29
30
 
30
31
  init(config: ForgeAppleConfig) {
31
32
  bridge = ForgeRuntimeBridge(config: AppleRuntimeConfig(
@@ -40,6 +41,7 @@ final class UniFFIForgeRuntime: ForgeRuntime {
40
41
  logLevel: config.logLevel.wireName,
41
42
  deviceUuid: config.deviceUuid,
42
43
  deviceInfoJson: config.deviceInfo?.toRuntimeJson(),
44
+ framework: config.framework,
43
45
  preSessionEventPolicy: ApplePreSessionEventPolicy(
44
46
  processEventsBeforeSessionStart: config.processEventsBeforeSessionStart,
45
47
  queueLimitEvents: UInt64(max(0, config.preSessionEventQueueLimitEvents)),
@@ -56,6 +58,19 @@ final class UniFFIForgeRuntime: ForgeRuntime {
56
58
  outcome(bridge.setLogLevel(logLevel: level.wireName))
57
59
  }
58
60
 
61
+ func setAutoConnectionType(_ connectionType: String?) -> ForgeOutcome {
62
+ outcome(bridge.setAutoConnectionType(connectionType: connectionType))
63
+ }
64
+
65
+ func recordLocationFix(_ fix: ForgeLocationFix) -> ForgeOutcome {
66
+ outcome(bridge.recordLocationFix(
67
+ latitude: fix.latitude,
68
+ longitude: fix.longitude,
69
+ accuracyM: fix.accuracyMetres,
70
+ timestampMs: u64(fix.timestampMs)
71
+ ))
72
+ }
73
+
59
74
  func createVideoView(timestampMs: Int64, options: VideoOptions) -> RuntimeVideoView {
60
75
  RuntimeVideoView(id: Int64(clamping: bridge.createVideoView(
61
76
  nowMs: u64(timestampMs),
@@ -245,6 +260,33 @@ final class UniFFIForgeRuntime: ForgeRuntime {
245
260
  return result
246
261
  }
247
262
 
263
+ func setLogListener(_ listener: ((ForgeRenderedLog) -> Void)?) -> ForgeOutcome {
264
+ if let listener {
265
+ let callback = UniFFILogListener(callback: listener)
266
+ // Retain the wrapper before registering: the bridge replays the
267
+ // logs rendered before this point synchronously, and they must
268
+ // land in a wrapper the runtime keeps alive.
269
+ callbackLock.lock()
270
+ logListener = callback
271
+ callbackLock.unlock()
272
+ let result = outcome(bridge.setLogListener(listener: callback))
273
+ if !result.isAccepted {
274
+ callbackLock.lock()
275
+ logListener = nil
276
+ callbackLock.unlock()
277
+ }
278
+ return result
279
+ }
280
+
281
+ let result = outcome(bridge.clearLogListener())
282
+ if result.isAccepted {
283
+ callbackLock.lock()
284
+ logListener = nil
285
+ callbackLock.unlock()
286
+ }
287
+ return result
288
+ }
289
+
248
290
  func capturePlan() -> ForgeCapturePlan? {
249
291
  bridge.capturePlan().map(forgeCapturePlan)
250
292
  }
@@ -269,11 +311,13 @@ final class UniFFIForgeRuntime: ForgeRuntime {
269
311
  func destroy() -> ForgeOutcome {
270
312
  let result = outcome(bridge.destroy())
271
313
  _ = bridge.clearCapturePlanListener()
314
+ _ = bridge.clearLogListener()
272
315
  callbackLock.lock()
273
316
  contentMetadataProviders.removeAll()
274
317
  videoPreSendHooks.removeAll()
275
318
  sessionPreSendHook = nil
276
319
  capturePlanListener = nil
320
+ logListener = nil
277
321
  callbackLock.unlock()
278
322
  return result
279
323
  }
@@ -593,4 +637,19 @@ private final class UniFFICapturePlanListener: AppleCapturePlanListener, @unchec
593
637
  callback(forgeCapturePlan(plan))
594
638
  }
595
639
  }
640
+
641
+ private final class UniFFILogListener: AppleLogListener, @unchecked Sendable {
642
+ private let callback: (ForgeRenderedLog) -> Void
643
+
644
+ init(callback: @escaping (ForgeRenderedLog) -> Void) {
645
+ self.callback = callback
646
+ }
647
+
648
+ func onLog(log: AppleRenderedLog) {
649
+ // An unrecognized level from the bridge is dropped rather than
650
+ // relabelled, matching ForgeLogger.emitRendered.
651
+ guard let level = ForgeLogLevel.parse(log.level) else { return }
652
+ callback(ForgeRenderedLog(level: level, tag: log.tag, message: log.message))
653
+ }
654
+ }
596
655
  #endif
@@ -65,10 +65,26 @@ open class PlayerAdapter: NSObject {
65
65
  if playerObject != nil { registerListeners() }
66
66
  }
67
67
 
68
+ /// Detaches the adapter from its view.
69
+ ///
70
+ /// The unbind happens **before** the listeners are unregistered, and that
71
+ /// order is the whole point. Unregistering is not instantaneous from the
72
+ /// player's perspective: a KVO delivery already in flight on a player
73
+ /// thread, or a periodic-time-observer block already enqueued on the main
74
+ /// queue (which `removeTimeObserver` does not cancel), can still run while
75
+ /// it happens. With `videoAdapter` cleared first, `record` fails closed and
76
+ /// those late callbacks go nowhere.
77
+ ///
78
+ /// Clearing it afterwards is what produced the tvOS phantom view: a player
79
+ /// being deallocated reported a transport change into a view the kernel had
80
+ /// just stopped, and the kernel is contracted to treat a post-stop start as
81
+ /// a replay, so it opened an empty replacement view for every playback
82
+ /// (forge-feedback `native/2026-08-13_rn-video-teardown-reinits-view`).
68
83
  func unbindFromVideoAdapter() {
69
- if isBound { unregisterListeners() }
84
+ let wasBound = isBound
70
85
  isBound = false
71
86
  videoAdapter = nil
87
+ if wasBound { unregisterListeners() }
72
88
  }
73
89
 
74
90
  open func registerListeners() {}
@@ -0,0 +1,69 @@
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: Public lookup from a React Native instance id to the live Forge facade, so application code can attach a native adapter to a player the shipped packages do not cover.
13
+ */
14
+
15
+ import Foundation
16
+ #if canImport(NPAWForgeCore)
17
+ import NPAWForgeCore
18
+ #elseif canImport(NPAWForgeApple)
19
+ import NPAWForgeApple
20
+ #endif
21
+
22
+ /// Resolves the native Forge facade behind a React Native instance id.
23
+ ///
24
+ /// **This is public API.** `resolve` and `resolveVideoAdapter` carry the usual
25
+ /// stability commitment: they will not be renamed or removed without a major
26
+ /// version and a release note.
27
+ ///
28
+ /// It exists because the React Native packages bind two players — Media3 on
29
+ /// Android and AVPlayer on Apple, both through `react-native-video` — while
30
+ /// Forge ships native adapters for many more. Rather than wait for a
31
+ /// first-party package per player, application code can host its own native
32
+ /// view, resolve the facade from the instance id it already has in JS, and
33
+ /// attach any Forge adapter to it.
34
+ ///
35
+ /// The React Native design deliberately keeps native player instances off the
36
+ /// bridge, and this does not weaken that: only the opaque instance id crosses
37
+ /// it. Resolution and attachment happen entirely in native code.
38
+ ///
39
+ /// ```swift
40
+ /// guard let forge = ForgeInstanceRegistry.resolve(instanceId),
41
+ /// let video = forge.getVideoAdapter() else { return }
42
+ /// video.setPlayerAdapter(MyPlayerAdapter(player: myPlayer))
43
+ /// ```
44
+ ///
45
+ /// Detach symmetrically when the view goes away — the registry holds the
46
+ /// facade, not your player, and it will not release the adapter for you.
47
+ ///
48
+ /// The Android counterpart is `com.npaw.forge.reactnative.ForgeInstanceRegistry`
49
+ /// and carries the same two members with the same meaning.
50
+ public enum ForgeInstanceRegistry {
51
+
52
+ /// The live facade for `instanceId`, or `nil` if no such instance exists
53
+ /// or it has already been destroyed.
54
+ public static func resolve(_ instanceId: String) -> NPAWForge? {
55
+ ForgeModuleImpl.shared.resolveInstance(instanceId)
56
+ }
57
+
58
+ /// The video adapter registered under `identifier` on the instance's
59
+ /// facade, or `nil` if either the instance or the adapter is absent.
60
+ ///
61
+ /// Pass `nil` for `identifier` to get the default adapter, matching
62
+ /// `NPAWForge.getVideoAdapter(identifier:)`.
63
+ public static func resolveVideoAdapter(
64
+ _ instanceId: String,
65
+ identifier: String? = nil
66
+ ) -> VideoAdapter? {
67
+ resolve(instanceId)?.getVideoAdapter(identifier: identifier)
68
+ }
69
+ }
@@ -33,15 +33,23 @@ public final class ForgeModuleImpl: NSObject {
33
33
 
34
34
  /// Set by the TurboModule shell; receives the channel body for every kernel event.
35
35
  @objc public var eventSink: (([String: Any]) -> Void)?
36
+ /// Set by the TurboModule shell; receives the channel body for every line
37
+ /// of the kernel's rendered decision log.
38
+ @objc public var logSink: (([String: Any]) -> Void)?
36
39
 
37
40
  private let lock = NSLock()
38
41
  private var instances: [String: NPAWForge] = [:]
39
42
  private var subscriptions: [String: ForgeEventSubscription] = [:]
43
+ private var logSubscriptions: [String: ForgeEventSubscription] = [:]
40
44
  private var adReporters: [Int: ManualAdReporter] = [:]
41
45
  private var nextInstanceId = 1
42
46
  private var nextReporterId = 1
43
47
  private var listenerCount = 0
44
48
 
49
+ /// Declared to the native facade so React Native traffic is
50
+ /// distinguishable in `pluginInfo`; the reported platform stays `apple`.
51
+ static let frameworkMarker = "react-native"
52
+
45
53
  private static let errUnknownInstance = "E_FORGE_UNKNOWN_INSTANCE"
46
54
  private static let errUnknownReporter = "E_FORGE_UNKNOWN_REPORTER"
47
55
  private static let errBadArgument = "E_FORGE_BAD_ARGUMENT"
@@ -71,6 +79,8 @@ public final class ForgeModuleImpl: NSObject {
71
79
  return
72
80
  }
73
81
  let builder = NPAWForge.Builder(accountCode: accountCode)
82
+ // The binding knows what it is; this is not a host-settable config key.
83
+ builder.setFramework(ForgeModuleImpl.frameworkMarker)
74
84
  applyConfig(builder, config: config)
75
85
  let forge = builder.build()
76
86
 
@@ -95,6 +105,7 @@ public final class ForgeModuleImpl: NSObject {
95
105
  lock.lock()
96
106
  let forge = instances.removeValue(forKey: instanceId)
97
107
  let subscription = subscriptions.removeValue(forKey: instanceId)
108
+ let logSubscription = logSubscriptions.removeValue(forKey: instanceId)
98
109
  lock.unlock()
99
110
 
100
111
  guard let forge else {
@@ -102,6 +113,7 @@ public final class ForgeModuleImpl: NSObject {
102
113
  return
103
114
  }
104
115
  subscription?.cancel()
116
+ logSubscription?.cancel()
105
117
  resolve(Self.outcomeToDictionary(forge.destroy()))
106
118
  }
107
119
 
@@ -901,8 +913,13 @@ public final class ForgeModuleImpl: NSObject {
901
913
  lock.lock()
902
914
  listenerCount = max(0, listenerCount - count)
903
915
  let shouldCancel = listenerCount == 0
904
- let cancelled = shouldCancel ? Array(subscriptions.values) : []
905
- if shouldCancel { subscriptions.removeAll() }
916
+ let cancelled = shouldCancel
917
+ ? Array(subscriptions.values) + Array(logSubscriptions.values)
918
+ : []
919
+ if shouldCancel {
920
+ subscriptions.removeAll()
921
+ logSubscriptions.removeAll()
922
+ }
906
923
  lock.unlock()
907
924
 
908
925
  cancelled.forEach { $0.cancel() }
@@ -910,9 +927,10 @@ public final class ForgeModuleImpl: NSObject {
910
927
 
911
928
  @objc public func invalidate() {
912
929
  lock.lock()
913
- let cancelled = Array(subscriptions.values)
930
+ let cancelled = Array(subscriptions.values) + Array(logSubscriptions.values)
914
931
  let live = Array(instances.values)
915
932
  subscriptions.removeAll()
933
+ logSubscriptions.removeAll()
916
934
  instances.removeAll()
917
935
  adReporters.removeAll()
918
936
  listenerCount = 0
@@ -991,8 +1009,15 @@ public final class ForgeModuleImpl: NSObject {
991
1009
  guard let self, let sink = self.eventSink else { return }
992
1010
  sink(Self.recordedEventToDictionary(instanceId: instanceId, event: event))
993
1011
  }
1012
+ // The kernel's decision log rides its own channel: it is not an event,
1013
+ // and hosts subscribe to it separately.
1014
+ let logSubscription = forge.addLogListener { [weak self] log in
1015
+ guard let self, let sink = self.logSink else { return }
1016
+ sink(Self.renderedLogToDictionary(instanceId: instanceId, log: log))
1017
+ }
994
1018
  lock.lock()
995
1019
  subscriptions[instanceId] = subscription
1020
+ logSubscriptions[instanceId] = logSubscription
996
1021
  lock.unlock()
997
1022
  }
998
1023
 
@@ -1022,9 +1047,19 @@ public final class ForgeModuleImpl: NSObject {
1022
1047
  builder.setPreSessionEventQueueMaxAgeMs((value as? NSNumber)?.int64Value ?? 0)
1023
1048
  case "allowLocalHttpForTesting":
1024
1049
  builder.setLocalPlaintextTransportForTesting(value as? Bool ?? false)
1025
- case "deviceUuid", "autoDeviceIdentityEnabled":
1026
- // Android-only today: the Swift Builder lacks these setters.
1027
- NSLog("[NPAWForgeRN] ForgeConfig key '%@' is Android-only; ignored on iOS", key)
1050
+ case "deviceUuid":
1051
+ // A non-string value is ignored rather than used to clear the
1052
+ // override: passing nil here would disable an identifier the
1053
+ // app did configure.
1054
+ if let deviceUuid = value as? String {
1055
+ builder.setDeviceUuid(deviceUuid)
1056
+ }
1057
+ case "autoDeviceIdentityEnabled":
1058
+ // Matches the Android mapper: a real Bool wins, anything else
1059
+ // is parsed leniently and falls back to false.
1060
+ builder.setAutoDeviceIdentityEnabled(
1061
+ value as? Bool ?? (String(describing: value).lowercased() == "true")
1062
+ )
1028
1063
  default:
1029
1064
  NSLog("[NPAWForgeRN] Unknown ForgeConfig key '%@' ignored (forward compatibility)", key)
1030
1065
  }
@@ -1128,4 +1163,16 @@ public final class ForgeModuleImpl: NSObject {
1128
1163
  }
1129
1164
  return result
1130
1165
  }
1166
+
1167
+ static func renderedLogToDictionary(
1168
+ instanceId: String,
1169
+ log: ForgeRenderedLog
1170
+ ) -> [String: Any] {
1171
+ [
1172
+ "instanceId": instanceId,
1173
+ "level": log.level.wireName,
1174
+ "tag": log.tag,
1175
+ "message": log.message,
1176
+ ]
1177
+ }
1131
1178
  }
@@ -25,6 +25,7 @@
25
25
  #endif
26
26
 
27
27
  static NSString *const kForgeEventChannel = @"NPAWForgeEvent";
28
+ static NSString *const kForgeLogChannel = @"NPAWForgeLog";
28
29
 
29
30
  @interface NPAWForgeReactNative : RCTEventEmitter
30
31
  #ifdef RCT_NEW_ARCH_ENABLED
@@ -48,13 +49,16 @@ RCT_EXPORT_MODULE(NPAWForgeRN)
48
49
  ForgeModuleImpl.shared.eventSink = ^(NSDictionary<NSString *, id> *body) {
49
50
  [weakSelf sendEventWithName:kForgeEventChannel body:body];
50
51
  };
52
+ ForgeModuleImpl.shared.logSink = ^(NSDictionary<NSString *, id> *body) {
53
+ [weakSelf sendEventWithName:kForgeLogChannel body:body];
54
+ };
51
55
  }
52
56
  return self;
53
57
  }
54
58
 
55
59
  - (NSArray<NSString *> *)supportedEvents
56
60
  {
57
- return @[ kForgeEventChannel ];
61
+ return @[ kForgeEventChannel, kForgeLogChannel ];
58
62
  }
59
63
 
60
64
  - (void)startObserving
@@ -70,6 +74,7 @@ RCT_EXPORT_MODULE(NPAWForgeRN)
70
74
  - (void)invalidate
71
75
  {
72
76
  ForgeModuleImpl.shared.eventSink = nil;
77
+ ForgeModuleImpl.shared.logSink = nil;
73
78
  [ForgeModuleImpl.shared invalidate];
74
79
  [super invalidate];
75
80
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@npaw/forge-react-native",
3
- "version": "9.7.1",
3
+ "version": "9.7.3",
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>",
@@ -11,7 +11,9 @@
11
11
  "NPAWForgeReactNative.podspec",
12
12
  "README.md",
13
13
  "android",
14
+ "app.plugin.js",
14
15
  "ios",
16
+ "plugin",
15
17
  "src"
16
18
  ],
17
19
  "scripts": {
@@ -0,0 +1,82 @@
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: Expo config plugin wiring the Forge host-project mutations into expo prebuild; all mutation logic lives in ./mutations.js so it is testable without Expo installed.
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const { addForgeMavenRepository, addKotlinMetadataFlag } = require('./mutations');
18
+
19
+ /**
20
+ * @typedef {object} ForgePluginOptions
21
+ * @property {boolean} [artifactoryRepository=true] Add the NPAW Artifactory Maven repository.
22
+ * @property {boolean} [kotlinMetadataFlag=true] Add `-Xskip-metadata-version-check` to the app module.
23
+ */
24
+
25
+ /**
26
+ * Applies the native host-project changes the Forge React Native packages need.
27
+ *
28
+ * Expo Go cannot load native modules, so this is for development builds and
29
+ * `expo prebuild`; it does not make Forge work in Expo Go. Every mutation is
30
+ * idempotent because prebuild runs repeatedly.
31
+ *
32
+ * ```json
33
+ * { "expo": { "plugins": ["@npaw/forge-react-native"] } }
34
+ * ```
35
+ *
36
+ * This does NOT disable bridgeless. That is a source-level change in
37
+ * MainApplication and AppDelegate — see ./mutations.js for why automating it
38
+ * would do more harm than good — and it stays a documented manual step for
39
+ * applications using the video adapter package.
40
+ */
41
+ function withForge(config, options = {}) {
42
+ const { withProjectBuildGradle, withAppBuildGradle } = require('@expo/config-plugins');
43
+
44
+ const settings = {
45
+ artifactoryRepository: true,
46
+ kotlinMetadataFlag: true,
47
+ ...options,
48
+ };
49
+
50
+ let next = config;
51
+
52
+ if (settings.artifactoryRepository) {
53
+ next = withProjectBuildGradle(next, (mod) => {
54
+ if (mod.modResults.language !== 'groovy') {
55
+ throw new Error(
56
+ 'Forge config plugin: the project build.gradle is not Groovy; apply the Maven repository manually',
57
+ );
58
+ }
59
+ mod.modResults.contents = addForgeMavenRepository(mod.modResults.contents);
60
+ return mod;
61
+ });
62
+ }
63
+
64
+ if (settings.kotlinMetadataFlag) {
65
+ next = withAppBuildGradle(next, (mod) => {
66
+ if (mod.modResults.language !== 'groovy') {
67
+ throw new Error(
68
+ 'Forge config plugin: the app build.gradle is not Groovy; apply the Kotlin flag manually',
69
+ );
70
+ }
71
+ mod.modResults.contents = addKotlinMetadataFlag(mod.modResults.contents);
72
+ return mod;
73
+ });
74
+ }
75
+
76
+ return next;
77
+ }
78
+
79
+ module.exports = withForge;
80
+ module.exports.default = withForge;
81
+ // Re-exported so host projects and tests can reuse the raw mutations.
82
+ module.exports.mutations = { addForgeMavenRepository, addKotlinMetadataFlag };