@npaw/forge-react-native 9.7.4 → 10.0.0

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 (27) hide show
  1. package/android/build.gradle +1 -1
  2. package/android/src/main/java/com/npaw/forge/reactnative/ForgeDtoMapper.java +3 -0
  3. package/android/src/main/java/com/npaw/forge/reactnative/NPAWForgeModule.java +10 -0
  4. package/ios/ForgeApple/ForgeKernelFFI.xcframework/Info.plist +8 -8
  5. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/forge_kernelFFI.h +11 -0
  6. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/libForgeKernelFFI.a +0 -0
  7. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/forge_kernelFFI.h +11 -0
  8. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/libForgeKernelFFI.a +0 -0
  9. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/forge_kernelFFI.h +11 -0
  10. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/libForgeKernelFFI.a +0 -0
  11. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/forge_kernelFFI.h +11 -0
  12. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/libForgeKernelFFI.a +0 -0
  13. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernel.swift +201 -12
  14. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernelFFI.h +11 -0
  15. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeApple.swift +48 -8
  16. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeRuntime.swift +22 -2
  17. package/ios/ForgeApple/Sources/NPAWForgeCore/NPAWForge.swift +34 -2
  18. package/ios/ForgeApple/Sources/NPAWForgeCore/NativeUniFFIRuntime.swift +42 -7
  19. package/ios/ForgeApple/Sources/NPAWForgeCore/ObjectiveCWrappers.swift +14 -0
  20. package/ios/ForgeApple/Sources/NPAWForgeCore/VideoAdapter.swift +5 -5
  21. package/ios/ForgeApple/Sources/NPAWForgeCore/WireValue.swift +207 -0
  22. package/ios/ForgeModuleImpl.swift +33 -0
  23. package/ios/NPAWForgeTurboModule.mm +10 -0
  24. package/package.json +1 -1
  25. package/src/NPAWForge.ts +22 -0
  26. package/src/specs/NativeNPAWForge.ts +3 -0
  27. package/src/types.ts +8 -0
@@ -27,6 +27,12 @@ public struct ForgeAppleConfig: Equatable {
27
27
  public var processEventsBeforeSessionStart: Bool
28
28
  public var preSessionEventQueueLimitEvents: Int
29
29
  public var preSessionEventQueueMaxAgeMs: Int64
30
+ /// Logout guard window in milliseconds (session-update mode only): after
31
+ /// `logout()` closes the session, the kernel holds the replacement
32
+ /// session back for this long so a follow-up `loginSuccess` attaches its
33
+ /// own session directly; events raised inside the window are buffered
34
+ /// with their original timestamps. `0` disables the guard.
35
+ public var logoutSessionGuardMs: Int64
30
36
  public var logLevel: ForgeLogLevel
31
37
  public var deviceUuid: String?
32
38
  public var deviceInfo: ForgeDeviceInfo?
@@ -48,6 +54,7 @@ public struct ForgeAppleConfig: Equatable {
48
54
  processEventsBeforeSessionStart: Bool = false,
49
55
  preSessionEventQueueLimitEvents: Int = 100,
50
56
  preSessionEventQueueMaxAgeMs: Int64 = 30_000,
57
+ logoutSessionGuardMs: Int64 = 30_000,
51
58
  logLevel: ForgeLogLevel = .warning,
52
59
  deviceUuid: String? = nil,
53
60
  deviceInfo: ForgeDeviceInfo? = nil,
@@ -65,6 +72,7 @@ public struct ForgeAppleConfig: Equatable {
65
72
  self.processEventsBeforeSessionStart = processEventsBeforeSessionStart
66
73
  self.preSessionEventQueueLimitEvents = preSessionEventQueueLimitEvents
67
74
  self.preSessionEventQueueMaxAgeMs = preSessionEventQueueMaxAgeMs
75
+ self.logoutSessionGuardMs = logoutSessionGuardMs
68
76
  self.logLevel = logLevel
69
77
  self.deviceUuid = deviceUuid
70
78
  self.deviceInfo = deviceInfo
@@ -143,7 +151,10 @@ public final class ForgeApple {
143
151
  private let lock = NSRecursiveLock()
144
152
  private var recordedEvents: [ForgeRecordedEvent] = []
145
153
  private var eventObservers: [UUID: (ForgeRecordedEvent, ForgeOutcome) -> Void] = [:]
146
- private var onSendRequestListeners: [UUID: (String, inout [String: String]) -> Void] = [:]
154
+ /// Registration-ordered, matching Android's CopyOnWriteArrayList: when two
155
+ /// listeners edit the same param, the later registration wins
156
+ /// deterministically on both platforms.
157
+ private var onSendRequestListeners: [(id: UUID, listener: (String, inout [String: WireValue]) -> Void)] = []
147
158
  private var capturePlanObservers: [UUID: (ForgeCapturePlan) -> Void] = [:]
148
159
  private var latestCapturePlan: ForgeCapturePlan?
149
160
  private var logObservers: [UUID: (ForgeRenderedLog) -> Void] = [:]
@@ -359,6 +370,35 @@ public final class ForgeApple {
359
370
  recordIdentityEvent(kind: "profileDeleted", fields: ["profileId": profileId])
360
371
  }
361
372
 
373
+ /// Hot-updates session-level dimensions without a session boundary:
374
+ /// `param1`..`param20` extraparams, `screenName`, or any dimension the
375
+ /// NQS parameter registry marks updatable - new backend dimensions work
376
+ /// without an SDK release. Identity keys are rejected by the kernel (use
377
+ /// the typed identity API); blank values are dropped because NQS never
378
+ /// erases a session value. Requires the account's remote config to
379
+ /// enable session updates.
380
+ public func updateSession(_ metadata: [String: String], timestampMs: Int64 = 0) -> ForgeOutcome {
381
+ lock.lock()
382
+ defer { lock.unlock() }
383
+ guard !destroyed else { return .ignoredInvalidState }
384
+ return runtime.recordSessionUpdate(
385
+ timemarkMs: timestampMs,
386
+ params: PrivacySanitizer.sanitize(metadata),
387
+ metrics: [:]
388
+ )
389
+ }
390
+
391
+ /// Reports numeric session metrics (account-registered custom metrics).
392
+ /// Values aggregate server-side per the account's registered operation
393
+ /// and settle on the session close row; re-sending an unchanged value is
394
+ /// a no-op. Same remote-config gating as `updateSession`.
395
+ public func updateSessionMetrics(_ metrics: [String: Double], timestampMs: Int64 = 0) -> ForgeOutcome {
396
+ lock.lock()
397
+ defer { lock.unlock() }
398
+ guard !destroyed else { return .ignoredInvalidState }
399
+ return runtime.recordSessionUpdate(timemarkMs: timestampMs, params: [:], metrics: metrics)
400
+ }
401
+
362
402
  /// Forwards a typed identity transition to the runtime. Recognized fields
363
403
  /// (`userId`, `profileId`, `profileType`, `method`, `errorCode`, `reason`,
364
404
  /// `userType`) are consumed by the kernel; remaining entries ride as extra
@@ -492,15 +532,15 @@ public final class ForgeApple {
492
532
  runtime.registerSessionPreSendHook(hook)
493
533
  }
494
534
 
495
- public func addOnSendRequestListener(_ listener: @escaping (String, inout [String: String]) -> Void) -> ForgeEventSubscription {
535
+ public func addOnSendRequestListener(_ listener: @escaping (String, inout [String: WireValue]) -> Void) -> ForgeEventSubscription {
496
536
  let id = UUID()
497
537
  lock.lock()
498
- onSendRequestListeners[id] = listener
538
+ onSendRequestListeners.append((id: id, listener: listener))
499
539
  lock.unlock()
500
540
  refreshSessionPreSendHook()
501
541
  return ForgeEventSubscription { [weak self] in
502
542
  self?.lock.lock()
503
- self?.onSendRequestListeners.removeValue(forKey: id)
543
+ self?.onSendRequestListeners.removeAll { $0.id == id }
504
544
  self?.lock.unlock()
505
545
  self?.refreshSessionPreSendHook()
506
546
  }
@@ -672,7 +712,7 @@ public final class ForgeApple {
672
712
  let hook = SessionPreSendHook { [weak self] serviceName, params in
673
713
  guard let self else { return }
674
714
  self.lock.lock()
675
- let listeners = Array(self.onSendRequestListeners.values)
715
+ let listeners = self.onSendRequestListeners.map(\.listener)
676
716
  self.lock.unlock()
677
717
  listeners.forEach { $0(serviceName, &params) }
678
718
  }
@@ -707,13 +747,13 @@ public final class ForgeEventSubscription {
707
747
  }
708
748
 
709
749
  private final class SessionPreSendHook: RuntimeSessionPreSendHook {
710
- private let callback: (String, inout [String: String]) -> Void
750
+ private let callback: (String, inout [String: WireValue]) -> Void
711
751
 
712
- init(callback: @escaping (String, inout [String: String]) -> Void) {
752
+ init(callback: @escaping (String, inout [String: WireValue]) -> Void) {
713
753
  self.callback = callback
714
754
  }
715
755
 
716
- func willSendRequest(serviceName: String, params: inout [String: String]) {
756
+ func willSendRequest(serviceName: String, params: inout [String: WireValue]) {
717
757
  callback(serviceName, &params)
718
758
  }
719
759
  }
@@ -48,17 +48,23 @@ public protocol RuntimeContentMetadataProvider: AnyObject {
48
48
  func metadataSnapshot(timemarkMs: Int64, elapsedRealtimeMs: Int64) -> MetadataSnapshot?
49
49
  }
50
50
 
51
+ /// Per-view pre-send hook. `params` is the request payload as the typed
52
+ /// values the wire will carry — see ``WireValue`` for the encoding and the
53
+ /// two deliberate escape hatches (string re-typing, byte-identical
54
+ /// untouched values).
51
55
  public protocol RuntimeVideoPreSendHook: AnyObject {
52
56
  func willSendRequest(
53
57
  eventName: String,
54
58
  view: RuntimeVideoView,
55
59
  serviceName: String,
56
- params: inout [String: String]
60
+ params: inout [String: WireValue]
57
61
  )
58
62
  }
59
63
 
64
+ /// Session-scope counterpart of ``RuntimeVideoPreSendHook``; same typed
65
+ /// payload contract.
60
66
  public protocol RuntimeSessionPreSendHook: AnyObject {
61
- func willSendRequest(serviceName: String, params: inout [String: String])
67
+ func willSendRequest(serviceName: String, params: inout [String: WireValue])
62
68
  }
63
69
 
64
70
  public protocol ForgeRuntime: AnyObject {
@@ -91,6 +97,10 @@ public protocol ForgeRuntime: AnyObject {
91
97
  /// `profileType`, `method`, `errorCode`, `reason`, `userType`), forwards
92
98
  /// the rest as extra session-event params, and emits the taxonomy event.
93
99
  func recordIdentityEvent(kind: String, timemarkMs: Int64, fields: [String: String]) -> ForgeOutcome
100
+ /// Hot session update (session-update mode): flat dimension params plus
101
+ /// numeric session metrics staged by the kernel and carried on the next
102
+ /// session event or an update beat.
103
+ func recordSessionUpdate(timemarkMs: Int64, params: [String: String], metrics: [String: Double]) -> ForgeOutcome
94
104
  func recordApplicationForeground(_ foreground: Bool, timestampMs: Int64) -> ForgeOutcome
95
105
  func recordNetworkEvent(_ record: NetworkRequestRecord) -> ForgeOutcome
96
106
  func recordPlayerEvent(
@@ -138,6 +148,9 @@ public extension ForgeRuntime {
138
148
  /// Additive default: runtimes without kernel identity support report the
139
149
  /// capability gap instead of silently accepting the transition.
140
150
  func recordIdentityEvent(kind: String, timemarkMs: Int64, fields: [String: String]) -> ForgeOutcome { .unsupportedCapability }
151
+ /// Additive default: runtimes without kernel session-update support report
152
+ /// the capability gap instead of silently accepting the update.
153
+ func recordSessionUpdate(timemarkMs: Int64, params: [String: String], metrics: [String: Double]) -> ForgeOutcome { .unsupportedCapability }
141
154
  /// Additive default: a runtime with no kernel behind it has nowhere to keep
142
155
  /// the observed connection class, so it accepts and forgets.
143
156
  func setAutoConnectionType(_ connectionType: String?) -> ForgeOutcome { .accepted }
@@ -171,6 +184,7 @@ public final class UnavailableForgeRuntime: ForgeRuntime {
171
184
  public func createVideoView(timestampMs: Int64, options: VideoOptions) -> RuntimeVideoView { RuntimeVideoView(id: -1) }
172
185
  public func recordEvent(_ event: ForgeRecordedEvent) -> ForgeOutcome { failed() }
173
186
  public func recordIdentityEvent(kind: String, timemarkMs: Int64, fields: [String: String]) -> ForgeOutcome { failed() }
187
+ public func recordSessionUpdate(timemarkMs: Int64, params: [String: String], metrics: [String: Double]) -> ForgeOutcome { failed() }
174
188
  public func recordApplicationForeground(_ foreground: Bool, timestampMs: Int64) -> ForgeOutcome { failed() }
175
189
  public func recordNetworkEvent(_ record: NetworkRequestRecord) -> ForgeOutcome { failed() }
176
190
  public func recordPlayerEvent(
@@ -217,6 +231,7 @@ public final class NativeForgeRuntimeFactory: ForgeRuntimeFactory {
217
231
  public final class RecordingForgeRuntime: ForgeRuntime {
218
232
  public private(set) var events: [ForgeRecordedEvent] = []
219
233
  public private(set) var identityEvents: [(kind: String, timemarkMs: Int64, fields: [String: String])] = []
234
+ public private(set) var sessionUpdates: [(timemarkMs: Int64, params: [String: String], metrics: [String: Double])] = []
220
235
  public private(set) var playerEvents: [(RuntimeVideoView, PlayerEvent, PlayerMetadataSnapshot)] = []
221
236
  public private(set) var playerEventTimestamps: [(timemarkMs: Int64, elapsedRealtimeMs: Int64)] = []
222
237
  public private(set) var playerEventOptions: [VideoOptions] = []
@@ -290,6 +305,11 @@ public final class RecordingForgeRuntime: ForgeRuntime {
290
305
  identityEvents.append((kind, timemarkMs, fields))
291
306
  return .accepted
292
307
  }
308
+
309
+ public func recordSessionUpdate(timemarkMs: Int64, params: [String: String], metrics: [String: Double]) -> ForgeOutcome {
310
+ sessionUpdates.append((timemarkMs, params, metrics))
311
+ return .accepted
312
+ }
293
313
  public func recordApplicationForeground(_ foreground: Bool, timestampMs: Int64) -> ForgeOutcome {
294
314
  events.append(
295
315
  ForgeRecordedEvent(
@@ -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.4"
43
+ public static let defaultPluginVersion = "10.0.0"
44
44
  public static let defaultAppName = "apple"
45
45
  public static let defaultBuildVariant = "release"
46
46
  public static let defaultLmaEndpoint = "https://lma.npaw.com"
@@ -135,7 +135,7 @@ public final class NPAWForge: NSObject {
135
135
  kernel.capturePlan()
136
136
  }
137
137
 
138
- public func addOnSendRequestListener(_ listener: @escaping (String, inout [String: String]) -> Void) -> ForgeEventSubscription {
138
+ public func addOnSendRequestListener(_ listener: @escaping (String, inout [String: WireValue]) -> Void) -> ForgeEventSubscription {
139
139
  kernel.addOnSendRequestListener(listener)
140
140
  }
141
141
 
@@ -200,6 +200,25 @@ public final class NPAWForge: NSObject {
200
200
  kernel.trackAttribution(options: options)
201
201
  }
202
202
 
203
+ /// Hot-updates session-level dimensions without a session boundary:
204
+ /// `param1`..`param20` extraparams, `screenName`, or any dimension the
205
+ /// NQS parameter registry marks updatable - new backend dimensions work
206
+ /// without an SDK release. The change reaches NQS on the next session
207
+ /// event within one second, or on an immediate beat after that window.
208
+ /// Identity keys are rejected - use the typed identity API. Requires the
209
+ /// account's remote config to enable session updates.
210
+ @objc public func updateSession(_ metadata: [String: Any]) -> ForgeOutcome {
211
+ kernel.updateSession(metadata.toStringMap())
212
+ }
213
+
214
+ /// Reports numeric session metrics (account-registered custom metrics).
215
+ /// Values aggregate server-side per the account's registered operation
216
+ /// and settle on the session close row; re-sending an unchanged value is
217
+ /// a no-op. Same remote-config gating as `updateSession(_:)`.
218
+ @objc public func updateSessionMetrics(_ metrics: [String: Double]) -> ForgeOutcome {
219
+ kernel.updateSessionMetrics(metrics)
220
+ }
221
+
203
222
  /// Reports a successful login; the kernel emits `login
204
223
  /// {authStatus:"success"}` and atomically restarts the app session when
205
224
  /// the effective identity changed.
@@ -392,6 +411,7 @@ public final class NPAWForge: NSObject {
392
411
  private var processEventsBeforeSessionStart = false
393
412
  private var preSessionEventQueueLimitEvents = 100
394
413
  private var preSessionEventQueueMaxAgeMs: Int64 = 30_000
414
+ private var logoutSessionGuardMs: Int64 = 30_000
395
415
  private var logLevel: ForgeLogLevel = .warning
396
416
  private var autoDeviceIdentityEnabled = true
397
417
  private var autoConnectionTypeEnabled = true
@@ -464,6 +484,17 @@ public final class NPAWForge: NSObject {
464
484
  return self
465
485
  }
466
486
 
487
+ /// Logout guard window in milliseconds (session-update mode only):
488
+ /// after `logout()` closes the session, the kernel holds the
489
+ /// replacement session back for this long so a follow-up
490
+ /// `loginSuccess` attaches its own session directly; events raised
491
+ /// inside the window are buffered with their original timestamps.
492
+ /// `0` disables the guard. Default 30 000.
493
+ @discardableResult public func setLogoutSessionGuardMs(_ guardMs: Int64) -> Builder {
494
+ logoutSessionGuardMs = max(0, guardMs)
495
+ return self
496
+ }
497
+
467
498
  @discardableResult public func setLogLevel(_ value: Any?) -> Builder {
468
499
  logLevel = ForgeLogLevel.from(value)
469
500
  return self
@@ -562,6 +593,7 @@ public final class NPAWForge: NSObject {
562
593
  processEventsBeforeSessionStart: processEventsBeforeSessionStart,
563
594
  preSessionEventQueueLimitEvents: preSessionEventQueueLimitEvents,
564
595
  preSessionEventQueueMaxAgeMs: preSessionEventQueueMaxAgeMs,
596
+ logoutSessionGuardMs: logoutSessionGuardMs,
565
597
  logLevel: logLevel,
566
598
  deviceUuid: resolvedDeviceUuid,
567
599
  deviceInfo: resolvedDeviceInfo?.isEmpty == true ? nil : resolvedDeviceInfo,
@@ -47,7 +47,8 @@ final class UniFFIForgeRuntime: ForgeRuntime {
47
47
  processEventsBeforeSessionStart: config.processEventsBeforeSessionStart,
48
48
  queueLimitEvents: UInt64(max(0, config.preSessionEventQueueLimitEvents)),
49
49
  maxAgeMs: u64(config.preSessionEventQueueMaxAgeMs)
50
- )
50
+ ),
51
+ logoutSessionGuardMs: u64(config.logoutSessionGuardMs)
51
52
  ))
52
53
  }
53
54
 
@@ -132,6 +133,14 @@ final class UniFFIForgeRuntime: ForgeRuntime {
132
133
  ))
133
134
  }
134
135
 
136
+ func recordSessionUpdate(timemarkMs: Int64, params: [String: String], metrics: [String: Double]) -> ForgeOutcome {
137
+ outcome(bridge.recordSessionUpdate(
138
+ timemarkMs: u64(timemarkMs),
139
+ params: params,
140
+ metrics: metrics
141
+ ))
142
+ }
143
+
135
144
  func recordApplicationForeground(_ foreground: Bool, timestampMs: Int64) -> ForgeOutcome {
136
145
  outcome(bridge.recordApplicationForeground(foreground: foreground, timemarkMs: u64(timestampMs)))
137
146
  }
@@ -653,6 +662,32 @@ private final class UniFFIContentMetadataProvider: AppleContentMetadataProvider,
653
662
  }
654
663
  }
655
664
 
665
+ // MARK: - WireValue boundary conversions
666
+
667
+ extension WireValue {
668
+ init(bridged: AppleWireValue) {
669
+ switch bridged {
670
+ case let .str(value): self = .string(value)
671
+ case let .int(value): self = .int(value)
672
+ case let .num(value): self = .number(value)
673
+ case let .bool(value): self = .bool(value)
674
+ case let .object(value): self = .object(value.mapValues(WireValue.init(bridged:)))
675
+ case let .array(value): self = .array(value.map(WireValue.init(bridged:)))
676
+ }
677
+ }
678
+
679
+ var bridged: AppleWireValue {
680
+ switch self {
681
+ case let .string(value): return .str(value: value)
682
+ case let .int(value): return .int(value: value)
683
+ case let .number(value): return .num(value: value)
684
+ case let .bool(value): return .bool(value: value)
685
+ case let .object(value): return .object(value: value.mapValues(\.bridged))
686
+ case let .array(value): return .array(value: value.map(\.bridged))
687
+ }
688
+ }
689
+ }
690
+
656
691
  private final class UniFFIVideoPreSendHook: AppleVideoPreSendHook, @unchecked Sendable {
657
692
  private let view: RuntimeVideoView
658
693
  private let hook: RuntimeVideoPreSendHook
@@ -662,15 +697,15 @@ private final class UniFFIVideoPreSendHook: AppleVideoPreSendHook, @unchecked Se
662
697
  self.hook = hook
663
698
  }
664
699
 
665
- func willSend(eventName: String, viewId: UInt64, serviceName: String, params: [String: String]) -> [String: String] {
666
- var mutableParams = params
700
+ func willSend(eventName: String, viewId: UInt64, serviceName: String, params: [String: AppleWireValue]) -> [String: AppleWireValue] {
701
+ var mutableParams = params.mapValues(WireValue.init(bridged:))
667
702
  hook.willSendRequest(
668
703
  eventName: eventName,
669
704
  view: view.id == i64(viewId) ? view : RuntimeVideoView(id: i64(viewId)),
670
705
  serviceName: serviceName,
671
706
  params: &mutableParams
672
707
  )
673
- return mutableParams
708
+ return mutableParams.mapValues(\.bridged)
674
709
  }
675
710
  }
676
711
 
@@ -681,10 +716,10 @@ private final class UniFFISessionPreSendHook: AppleSessionPreSendHook, @unchecke
681
716
  self.hook = hook
682
717
  }
683
718
 
684
- func willSend(serviceName: String, params: [String: String]) -> [String: String] {
685
- var mutableParams = params
719
+ func willSend(serviceName: String, params: [String: AppleWireValue]) -> [String: AppleWireValue] {
720
+ var mutableParams = params.mapValues(WireValue.init(bridged:))
686
721
  hook.willSendRequest(serviceName: serviceName, params: &mutableParams)
687
- return mutableParams
722
+ return mutableParams.mapValues(\.bridged)
688
723
  }
689
724
  }
690
725
 
@@ -134,6 +134,20 @@ public final class NPAWForgeObjC: NSObject {
134
134
  ))
135
135
  }
136
136
 
137
+ @objc(updateSessionWithMetadata:)
138
+ public func updateSession(metadata: NSDictionary?) -> ForgeOutcome {
139
+ swiftForge.updateSession(metadata as? [String: Any] ?? [:])
140
+ }
141
+
142
+ @objc(updateSessionMetricsWithMetrics:)
143
+ public func updateSessionMetrics(metrics: NSDictionary?) -> ForgeOutcome {
144
+ var converted: [String: Double] = [:]
145
+ for case let (key as String, value as NSNumber) in metrics ?? [:] {
146
+ converted[key] = value.doubleValue
147
+ }
148
+ return swiftForge.updateSessionMetrics(converted)
149
+ }
150
+
137
151
  @objc(loginSuccessWithUserId:options:)
138
152
  public func loginSuccess(userId: String, options: NSDictionary?) -> ForgeOutcome {
139
153
  swiftForge.loginSuccess(userId: userId, options: options.toStringStringMap())
@@ -70,7 +70,7 @@ public final class VideoAdapter: RuntimeContentMetadataProvider {
70
70
  private var effectiveOptions: VideoOptions
71
71
  private let runtimeView: RuntimeVideoView
72
72
  private var destroyed = false
73
- private var willSendRequestListener: ((String, String, VideoAdapter, inout [String: String]) -> Void)?
73
+ private var willSendRequestListener: ((String, String, VideoAdapter, inout [String: WireValue]) -> Void)?
74
74
 
75
75
  public private(set) var playerAdapter: PlayerAdapter?
76
76
  public private(set) var adAdapter: AdAdapter?
@@ -113,7 +113,7 @@ public final class VideoAdapter: RuntimeContentMetadataProvider {
113
113
  /// addressed to `error` and `stop`, both carrying the same `eventName` — so
114
114
  /// without the service name that correct pair is indistinguishable from a
115
115
  /// duplicate.
116
- public func setWillSendRequestListener(_ listener: ((String, String, VideoAdapter, inout [String: String]) -> Void)?) -> ForgeOutcome {
116
+ public func setWillSendRequestListener(_ listener: ((String, String, VideoAdapter, inout [String: WireValue]) -> Void)?) -> ForgeOutcome {
117
117
  guard !destroyed else { return .ignoredInvalidState }
118
118
  willSendRequestListener = listener
119
119
  let hook = listener.map { callback in
@@ -475,13 +475,13 @@ private extension AdInsertionType {
475
475
  }
476
476
 
477
477
  private final class VideoPreSendHook: RuntimeVideoPreSendHook {
478
- private let callback: (String, RuntimeVideoView, String, inout [String: String]) -> Void
478
+ private let callback: (String, RuntimeVideoView, String, inout [String: WireValue]) -> Void
479
479
 
480
- init(callback: @escaping (String, RuntimeVideoView, String, inout [String: String]) -> Void) {
480
+ init(callback: @escaping (String, RuntimeVideoView, String, inout [String: WireValue]) -> Void) {
481
481
  self.callback = callback
482
482
  }
483
483
 
484
- func willSendRequest(eventName: String, view: RuntimeVideoView, serviceName: String, params: inout [String: String]) {
484
+ func willSendRequest(eventName: String, view: RuntimeVideoView, serviceName: String, params: inout [String: WireValue]) {
485
485
  callback(eventName, view, serviceName, &params)
486
486
  }
487
487
  }
@@ -0,0 +1,207 @@
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: Typed wire value the pre-send request hooks see and edit.
13
+ */
14
+
15
+ import Foundation
16
+
17
+ /// One request-body param as the wire carries it.
18
+ ///
19
+ /// The NQS POST body is typed JSON: quantities are numbers, flags are
20
+ /// booleans, and nested documents (`entities`, `metrics`, `dimensions`,
21
+ /// `deviceInfo`, …) are nested objects. Pre-send hooks see and edit exactly
22
+ /// that encoding — what you read is what the wire says, and what you write is
23
+ /// what the wire will say.
24
+ ///
25
+ /// Literal conformances keep hook code natural:
26
+ ///
27
+ /// ```swift
28
+ /// params["contentId"] = "movie-42" // .string
29
+ /// params["bitrate"] = 4_316_400 // .int
30
+ /// params["playhead"] = 645.75 // .number
31
+ /// params["live"] = true // .bool
32
+ /// params["dimensions"] = ["genre": "sports"]
33
+ /// ```
34
+ ///
35
+ /// Two escape hatches are deliberate:
36
+ /// - A string written where a numeric or boolean param is declared (for
37
+ /// example `params["bitrate"] = "4316400"`, the all-strings habit) is
38
+ /// re-typed by the kernel before sending, so ported hook code keeps
39
+ /// producing the same wire.
40
+ /// - A param the hook leaves content-equal is sent byte-identical to its
41
+ /// pre-hook form — key order of untouched nested documents included — so
42
+ /// registering a hook never changes traffic the hook did not edit.
43
+ public enum WireValue: Equatable, Hashable {
44
+ case string(String)
45
+ /// An integral number. Serializes without a decimal point (`28899`).
46
+ case int(Int64)
47
+ /// A floating-point number. Keeps its spelling (`1.0` stays `1.0`).
48
+ case number(Double)
49
+ case bool(Bool)
50
+ case object([String: WireValue])
51
+ case array([WireValue])
52
+ }
53
+
54
+ extension WireValue: ExpressibleByStringLiteral {
55
+ public init(stringLiteral value: String) { self = .string(value) }
56
+ }
57
+
58
+ extension WireValue: ExpressibleByIntegerLiteral {
59
+ public init(integerLiteral value: Int64) { self = .int(value) }
60
+ }
61
+
62
+ extension WireValue: ExpressibleByFloatLiteral {
63
+ public init(floatLiteral value: Double) { self = .number(value) }
64
+ }
65
+
66
+ extension WireValue: ExpressibleByBooleanLiteral {
67
+ public init(booleanLiteral value: Bool) { self = .bool(value) }
68
+ }
69
+
70
+ extension WireValue: ExpressibleByDictionaryLiteral {
71
+ public init(dictionaryLiteral elements: (String, WireValue)...) {
72
+ self = .object(Dictionary(uniqueKeysWithValues: elements))
73
+ }
74
+ }
75
+
76
+ extension WireValue: ExpressibleByArrayLiteral {
77
+ public init(arrayLiteral elements: WireValue...) { self = .array(elements) }
78
+ }
79
+
80
+ public extension WireValue {
81
+ /// The string payload, when this value is a string.
82
+ var stringValue: String? {
83
+ if case let .string(value) = self { return value }
84
+ return nil
85
+ }
86
+
87
+ /// The integral payload. An integral `.number` (`4.0`) converts; a
88
+ /// fractional one does not.
89
+ var intValue: Int64? {
90
+ switch self {
91
+ case let .int(value): return value
92
+ case let .number(value)
93
+ where value.truncatingRemainder(dividingBy: 1) == 0
94
+ && value >= -9_007_199_254_740_992 && value <= 9_007_199_254_740_992:
95
+ return Int64(value)
96
+ default: return nil
97
+ }
98
+ }
99
+
100
+ /// The numeric payload, integral or floating.
101
+ var doubleValue: Double? {
102
+ switch self {
103
+ case let .int(value): return Double(value)
104
+ case let .number(value): return value
105
+ default: return nil
106
+ }
107
+ }
108
+
109
+ /// The boolean payload, when this value is a boolean.
110
+ var boolValue: Bool? {
111
+ if case let .bool(value) = self { return value }
112
+ return nil
113
+ }
114
+
115
+ /// The nested object payload, when this value is an object.
116
+ var objectValue: [String: WireValue]? {
117
+ if case let .object(value) = self { return value }
118
+ return nil
119
+ }
120
+
121
+ /// The nested array payload, when this value is an array.
122
+ var arrayValue: [WireValue]? {
123
+ if case let .array(value) = self { return value }
124
+ return nil
125
+ }
126
+
127
+ /// The text this value renders to where a string is needed: the payload
128
+ /// itself for a string, the canonical JSON text for everything else.
129
+ ///
130
+ /// Scalars render exactly as the wire does. One divergence is inherent to
131
+ /// Swift: `.object` renders with its keys **sorted**, because a Swift
132
+ /// dictionary has no insertion order to preserve — the wire itself keeps
133
+ /// authored order (untouched values byte-repaired by the kernel), so use
134
+ /// this for reading and logging, not as a byte-level mirror of a nested
135
+ /// document on the wire.
136
+ var textValue: String {
137
+ switch self {
138
+ case let .string(value): return value
139
+ case let .int(value): return String(value)
140
+ case let .number(value): return jsonNumberText(value)
141
+ case let .bool(value): return value ? "true" : "false"
142
+ case .object, .array:
143
+ return jsonText
144
+ }
145
+ }
146
+ }
147
+
148
+ extension WireValue: CustomStringConvertible {
149
+ public var description: String { textValue }
150
+ }
151
+
152
+ public extension WireValue {
153
+ /// Canonical JSON text of this value. Object keys render sorted — see
154
+ /// ``textValue`` for why.
155
+ var jsonText: String {
156
+ switch self {
157
+ case let .string(value):
158
+ return escapedJSONString(value)
159
+ case let .int(value):
160
+ return String(value)
161
+ case let .number(value):
162
+ return jsonNumberText(value)
163
+ case let .bool(value):
164
+ return value ? "true" : "false"
165
+ case let .object(value):
166
+ let fields = value
167
+ .sorted { $0.key < $1.key }
168
+ .map { "\(escapedJSONString($0.key)):\($0.value.jsonText)" }
169
+ return "{\(fields.joined(separator: ","))}"
170
+ case let .array(value):
171
+ return "[\(value.map(\.jsonText).joined(separator: ","))]"
172
+ }
173
+ }
174
+ }
175
+
176
+ /// Renders a double the way the wire does: integral values keep one decimal
177
+ /// (`1.0`), fractional values keep their precision.
178
+ private func jsonNumberText(_ value: Double) -> String {
179
+ guard value.isFinite else { return String(describing: value) }
180
+ if value.truncatingRemainder(dividingBy: 1) == 0, abs(value) < 1e15 {
181
+ return String(format: "%.1f", value)
182
+ }
183
+ var text = String(value)
184
+ // Swift renders some doubles in exponent form ("1e+16"); JSON accepts it.
185
+ if text.hasSuffix(".0") { text.removeLast(2); text += ".0" }
186
+ return text
187
+ }
188
+
189
+ private func escapedJSONString(_ value: String) -> String {
190
+ var escaped = "\""
191
+ for scalar in value.unicodeScalars {
192
+ switch scalar {
193
+ case "\"": escaped += "\\\""
194
+ case "\\": escaped += "\\\\"
195
+ case "\n": escaped += "\\n"
196
+ case "\r": escaped += "\\r"
197
+ case "\t": escaped += "\\t"
198
+ default:
199
+ if scalar.value < 0x20 {
200
+ escaped += String(format: "\\u%04x", scalar.value)
201
+ } else {
202
+ escaped.unicodeScalars.append(scalar)
203
+ }
204
+ }
205
+ }
206
+ return escaped + "\""
207
+ }
@@ -194,6 +194,37 @@ public final class ForgeModuleImpl: NSObject {
194
194
  }
195
195
  }
196
196
 
197
+ // MARK: - Session updates
198
+
199
+ @objc public func updateSession(
200
+ _ instanceId: String,
201
+ metadata: [String: Any]?,
202
+ resolve: @escaping ForgeResolveBlock,
203
+ reject: @escaping ForgeRejectBlock
204
+ ) {
205
+ withInstance(instanceId, resolve: resolve, reject: reject) {
206
+ $0.updateSession(metadata ?? [:])
207
+ }
208
+ }
209
+
210
+ @objc public func updateSessionMetrics(
211
+ _ instanceId: String,
212
+ metrics: [String: Any]?,
213
+ resolve: @escaping ForgeResolveBlock,
214
+ reject: @escaping ForgeRejectBlock
215
+ ) {
216
+ var converted: [String: Double] = [:]
217
+ for (key, value) in metrics ?? [:] {
218
+ if let number = value as? NSNumber {
219
+ converted[key] = number.doubleValue
220
+ }
221
+ }
222
+ let numeric = converted
223
+ withInstance(instanceId, resolve: resolve, reject: reject) {
224
+ $0.updateSessionMetrics(numeric)
225
+ }
226
+ }
227
+
197
228
  // MARK: - Identity
198
229
 
199
230
  @objc public func loginSuccess(
@@ -1045,6 +1076,8 @@ public final class ForgeModuleImpl: NSObject {
1045
1076
  builder.setPreSessionEventQueueLimitEvents((value as? NSNumber)?.intValue ?? 100)
1046
1077
  case "preSessionEventQueueMaxAgeMs":
1047
1078
  builder.setPreSessionEventQueueMaxAgeMs((value as? NSNumber)?.int64Value ?? 0)
1079
+ case "logoutSessionGuardMs":
1080
+ builder.setLogoutSessionGuardMs((value as? NSNumber)?.int64Value ?? 30_000)
1048
1081
  case "allowLocalHttpForTesting":
1049
1082
  builder.setLocalPlaintextTransportForTesting(value as? Bool ?? false)
1050
1083
  case "deviceUuid":