@npaw/forge-react-native 10.1.0 → 10.2.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 (28) hide show
  1. package/android/build.gradle +1 -1
  2. package/android/src/main/java/com/npaw/forge/reactnative/ForgeDtoMapper.java +14 -0
  3. package/android/src/main/java/com/npaw/forge/reactnative/NPAWForgeModule.java +82 -0
  4. package/ios/ForgeApple/ForgeKernelFFI.xcframework/Info.plist +13 -13
  5. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/forge_kernelFFI.h +71 -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 +71 -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 +71 -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 +71 -0
  12. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/libForgeKernelFFI.a +0 -0
  13. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernel.swift +250 -0
  14. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernelFFI.h +71 -0
  15. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeApple.swift +147 -6
  16. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeCommands.swift +113 -0
  17. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeRuntime.swift +72 -2
  18. package/ios/ForgeApple/Sources/NPAWForgeCore/NPAWForge.swift +46 -2
  19. package/ios/ForgeApple/Sources/NPAWForgeCore/NativeUniFFIRuntime.swift +53 -0
  20. package/ios/ForgeApple/Sources/NPAWForgeCore/ObjectiveCWrappers.swift +14 -0
  21. package/ios/ForgeModuleImpl.swift +66 -0
  22. package/ios/NPAWForgeTurboModule.mm +16 -1
  23. package/package.json +1 -1
  24. package/src/NPAWForge.ts +131 -1
  25. package/src/events.ts +17 -0
  26. package/src/index.ts +6 -2
  27. package/src/specs/NativeNPAWForge.ts +9 -0
  28. package/src/types.ts +21 -0
@@ -159,12 +159,29 @@ public final class ForgeApple {
159
159
  private var latestCapturePlan: ForgeCapturePlan?
160
160
  private var logObservers: [UUID: (ForgeRenderedLog) -> Void] = [:]
161
161
  /// Guarded by its own lock, not `lock`: requests arrive on the runtime's
162
- /// worker thread, and `destroy()` holds `lock` while the runtime joins
163
- /// that worker - a request landing in that window must not need `lock`.
162
+ /// worker thread, which must never wait on the facade lock - a host call
163
+ /// holding `lock` would stall the kernel for as long as it runs.
164
164
  private let deviceSampleRequestLock = NSLock()
165
165
  private var deviceSampleRequestObservers: [UUID: ([String]) -> Void] = [:]
166
+ /// Remote command listeners keyed by command name, guarded by their own
167
+ /// lock for the same reason as the device-sample observers: commands
168
+ /// arrive on the runtime worker thread.
169
+ private let commandLock = NSLock()
170
+ private var commandListeners: [String: [(id: UUID, listener: ForgeCommandListener)]] = [:]
166
171
 
167
- public init(config: ForgeAppleConfig, services: ForgeAppleServices) {
172
+ public convenience init(config: ForgeAppleConfig, services: ForgeAppleServices) {
173
+ self.init(config: config, services: services, initialCommandListeners: [])
174
+ }
175
+
176
+ /// `initialCommandListeners` are subscribed before the runtime starts, so
177
+ /// a command riding the very first response already finds them. They are
178
+ /// registered in the order given (the Builder keeps registration order),
179
+ /// so the subscription sets the runtime sees are deterministic.
180
+ public init(
181
+ config: ForgeAppleConfig,
182
+ services: ForgeAppleServices,
183
+ initialCommandListeners: [(name: String, listener: ForgeCommandListener)]
184
+ ) {
168
185
  let safeConfig = config.withPluginVersion(NPAWForge.defaultPluginVersion)
169
186
  self.config = safeConfig
170
187
  self.services = services
@@ -183,6 +200,14 @@ public final class ForgeApple {
183
200
  _ = runtime.setDeviceSampleListener { [weak self] families in
184
201
  self?.dispatchDeviceSampleRequest(families)
185
202
  }
203
+ // Also before start(): a command nobody subscribed to is answered
204
+ // `handled=false` by the kernel and never re-delivered.
205
+ _ = runtime.setCommandListener { [weak self] command in
206
+ self?.dispatchCommandRequest(command)
207
+ }
208
+ for registration in initialCommandListeners {
209
+ _ = addCommandListener(registration.name, registration.listener)
210
+ }
186
211
  _ = runtime.start()
187
212
  if safeConfig.startSessionOnInitialize {
188
213
  _ = startSession()
@@ -476,6 +501,107 @@ public final class ForgeApple {
476
501
  }
477
502
  }
478
503
 
504
+ /// Listens for a remote command by name. Adding or removing a listener
505
+ /// re-sends the full subscription set to the kernel, which auto-answers
506
+ /// `handled=false` any command whose name nobody subscribed to and never
507
+ /// re-delivers it. Blank or malformed names and the 65th distinct name
508
+ /// are refused with a diagnostic. Listeners run on the runtime worker
509
+ /// thread: return quickly; the answer may be sent later from any thread.
510
+ public func addCommandListener(_ name: String, _ listener: @escaping ForgeCommandListener) -> ForgeEventSubscription {
511
+ guard let normalized = ForgeCommandNames.normalize(name) else {
512
+ recordDiagnostic(
513
+ code: "forge_command_name_invalid",
514
+ message: "command names are 1-64 characters of [A-Za-z0-9_.-]",
515
+ attributes: ["name": String(name.prefix(64))]
516
+ )
517
+ return ForgeEventSubscription {}
518
+ }
519
+ let id = UUID()
520
+ commandLock.lock()
521
+ if commandListeners[normalized] == nil, commandListeners.count >= ForgeCommandNames.maxSubscriptions {
522
+ commandLock.unlock()
523
+ recordDiagnostic(
524
+ code: "forge_command_subscriptions_limit",
525
+ message: "at most \(ForgeCommandNames.maxSubscriptions) distinct command names can be subscribed",
526
+ attributes: ["name": normalized]
527
+ )
528
+ return ForgeEventSubscription {}
529
+ }
530
+ commandListeners[normalized, default: []].append((id: id, listener: listener))
531
+ commandLock.unlock()
532
+ syncCommandSubscriptions()
533
+ return ForgeEventSubscription { [weak self] in
534
+ guard let self else { return }
535
+ self.commandLock.lock()
536
+ if var listeners = self.commandListeners[normalized] {
537
+ listeners.removeAll { $0.id == id }
538
+ if listeners.isEmpty {
539
+ self.commandListeners.removeValue(forKey: normalized)
540
+ } else {
541
+ self.commandListeners[normalized] = listeners
542
+ }
543
+ }
544
+ self.commandLock.unlock()
545
+ self.syncCommandSubscriptions()
546
+ }
547
+ }
548
+
549
+ /// The remote command names currently subscribed, sorted.
550
+ public func commandSubscriptions() -> [String] {
551
+ commandLock.lock()
552
+ defer { commandLock.unlock() }
553
+ return commandListeners.keys.sorted()
554
+ }
555
+
556
+ /// Answers a delivered remote command by id. Normally reached through the
557
+ /// ``ForgeCommandAnswer`` handed to a listener; exposed for hosts that
558
+ /// correlate answers themselves (React Native). The kernel keeps the first
559
+ /// answer per command id.
560
+ @discardableResult
561
+ public func answerCommand(id: String, handled: Bool, details: [String: String] = [:]) -> ForgeOutcome {
562
+ // Same shape as `syncCommandSubscriptions`: `destroyed` is read under
563
+ // `lock`, which is released before the runtime call. Answers may come
564
+ // from a listener running on the runtime worker thread, and that
565
+ // thread must never wait on `lock` for longer than a state read.
566
+ lock.lock()
567
+ let isDestroyed = destroyed
568
+ lock.unlock()
569
+ guard !isDestroyed else { return .ignoredInvalidState }
570
+ return runtime.answerCommand(id: id, handled: handled, details: details)
571
+ }
572
+
573
+ private func syncCommandSubscriptions() {
574
+ lock.lock()
575
+ let isDestroyed = destroyed
576
+ lock.unlock()
577
+ guard !isDestroyed else { return }
578
+ _ = runtime.setCommandSubscriptions(commandSubscriptions())
579
+ }
580
+
581
+ /// Runs on the runtime's worker thread. Every listener of the name gets
582
+ /// its own once-guarded answer; a name without listeners (a race with an
583
+ /// unsubscribe) is left unanswered - the kernel auto-answers only names
584
+ /// nobody subscribed to.
585
+ private func dispatchCommandRequest(_ command: ForgeCommand) {
586
+ commandLock.lock()
587
+ let listeners = commandListeners[command.name]?.map { $0.listener } ?? []
588
+ commandLock.unlock()
589
+ for listener in listeners {
590
+ let answer = ForgeCommandAnswer { [weak self] handled, details in
591
+ self?.answerCommand(id: command.id, handled: handled, details: details) ?? .ignoredInvalidState
592
+ }
593
+ listener(command, answer)
594
+ }
595
+ }
596
+
597
+ /// The Apple services carry no diagnostics sink (listener closures cannot
598
+ /// throw here), so refused registrations surface as facade warnings.
599
+ private func recordDiagnostic(code: String, message: String, attributes: [String: String]) {
600
+ var details = attributes
601
+ details["code"] = code
602
+ logger.warning(family: .diagnostics, action: message, details: details)
603
+ }
604
+
479
605
  public func effectiveLogLevel() -> ForgeLogLevel {
480
606
  logger.effectiveLevel()
481
607
  }
@@ -658,23 +784,38 @@ public final class ForgeApple {
658
784
  return recordedEvents
659
785
  }
660
786
 
787
+ /// `lock` covers only the state flip and the observer registries; the
788
+ /// runtime calls run unlocked on purpose. The native runtime's `destroy()`
789
+ /// joins its worker thread, and a listener still running on that worker
790
+ /// (answering a command, forwarding a log or a plan) takes `lock` on its
791
+ /// way back in - holding it across the join would make both threads wait
792
+ /// on each other forever. Every entry point re-checks `destroyed` under
793
+ /// `lock`, so nothing new reaches the runtime in the meantime, and a
794
+ /// second `destroy()` still reports `.ignoredDuplicate` at once.
661
795
  public func destroy() -> ForgeOutcome {
662
796
  lock.lock()
663
- defer { lock.unlock() }
664
- guard !destroyed else { return .ignoredDuplicate }
797
+ guard !destroyed else {
798
+ lock.unlock()
799
+ return .ignoredDuplicate
800
+ }
665
801
  destroyed = true
666
802
  eventObservers.removeAll()
667
803
  onSendRequestListeners.removeAll()
668
804
  capturePlanObservers.removeAll()
669
805
  logObservers.removeAll()
806
+ recordedEvents.removeAll()
807
+ lock.unlock()
670
808
  deviceSampleRequestLock.lock()
671
809
  deviceSampleRequestObservers.removeAll()
672
810
  deviceSampleRequestLock.unlock()
673
- recordedEvents.removeAll()
811
+ commandLock.lock()
812
+ commandListeners.removeAll()
813
+ commandLock.unlock()
674
814
  _ = runtime.registerSessionPreSendHook(nil)
675
815
  _ = runtime.setCapturePlanListener(nil)
676
816
  _ = runtime.setLogListener(nil)
677
817
  _ = runtime.setDeviceSampleListener(nil)
818
+ _ = runtime.setCommandListener(nil)
678
819
  return runtime.destroy()
679
820
  }
680
821
 
@@ -0,0 +1,113 @@
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. This file is confidential
6
+ * and only available to authorized individuals with the permission of the
7
+ * copyright holders. If you encounter this file and do not have permission,
8
+ * please contact the copyright holders and delete this file.
9
+ *
10
+ *
11
+ * @author: Victor Soto <victor.soto@nicepeopleatwork.com>
12
+ * @description: Public remote-command types for Apple (forge-commands): the command the backend addressed to this client, the once-guarded answer handle whose first call becomes the kernel's `command-answer` event, the Objective-C mirrors, and the name validation shared with the kernel.
13
+ */
14
+ import Foundation
15
+
16
+ /// A remote command the backend addressed to this client, delivered in the
17
+ /// `npaw-command` header of an NQS response. `args` are positional strings in
18
+ /// header order; `id` is opaque and echoed as `commandId` on the answer.
19
+ public struct ForgeCommand: Equatable {
20
+ public let id: String
21
+ public let name: String
22
+ public let args: [String]
23
+
24
+ public init(id: String, name: String, args: [String]) {
25
+ self.id = id
26
+ self.name = name
27
+ self.args = args
28
+ }
29
+ }
30
+
31
+ /// Answers one delivered command. The first `send` forwards `handled` plus the
32
+ /// optional flat string `details` to the kernel, which reports them as a
33
+ /// `command-answer` analytics event carrying the command id and name; later
34
+ /// calls return `.ignoredDuplicate` locally. Across listeners the kernel keeps
35
+ /// the first answer per command id. Safe to call from any thread, later than
36
+ /// the listener invocation.
37
+ @objc(NPAWForgeCommandAnswer)
38
+ public final class ForgeCommandAnswer: NSObject {
39
+ private let forward: (Bool, [String: String]) -> ForgeOutcome
40
+ private let lock = NSLock()
41
+ private var answered = false
42
+
43
+ init(forward: @escaping (Bool, [String: String]) -> ForgeOutcome) {
44
+ self.forward = forward
45
+ }
46
+
47
+ @objc
48
+ @discardableResult
49
+ public func send(handled: Bool, details: [String: String] = [:]) -> ForgeOutcome {
50
+ lock.lock()
51
+ if answered {
52
+ lock.unlock()
53
+ return .ignoredDuplicate
54
+ }
55
+ answered = true
56
+ lock.unlock()
57
+ return forward(handled, details)
58
+ }
59
+
60
+ /// True once `send` ran.
61
+ @objc public var isAnswered: Bool {
62
+ lock.lock()
63
+ defer { lock.unlock() }
64
+ return answered
65
+ }
66
+ }
67
+
68
+ /// Receives remote commands of the name it was registered for. Every listener
69
+ /// of a name runs, on the native runtime thread: return quickly and do slow
70
+ /// work on a queue of your own; the answer may be sent later.
71
+ public typealias ForgeCommandListener = (ForgeCommand, ForgeCommandAnswer) -> Void
72
+
73
+ /// Objective-C mirror of ``ForgeCommand``.
74
+ @objc(NPAWForgeCommand)
75
+ public final class NPAWForgeCommandObjC: NSObject {
76
+ @objc public let id: String
77
+ @objc public let name: String
78
+ @objc public let args: [String]
79
+
80
+ public init(_ command: ForgeCommand) {
81
+ self.id = command.id
82
+ self.name = command.name
83
+ self.args = command.args
84
+ }
85
+ }
86
+
87
+ /// Objective-C handle for cancelling a subscription made through the wrapper.
88
+ @objc(NPAWForgeSubscription)
89
+ public final class NPAWForgeSubscriptionObjC: NSObject {
90
+ private let subscription: ForgeEventSubscription
91
+
92
+ public init(_ subscription: ForgeEventSubscription) {
93
+ self.subscription = subscription
94
+ }
95
+
96
+ @objc public func cancel() {
97
+ subscription.cancel()
98
+ }
99
+ }
100
+
101
+ enum ForgeCommandNames {
102
+ /// Distinct command names one host may subscribe to (the kernel cap).
103
+ static let maxSubscriptions = 64
104
+
105
+ /// Trims and validates a command name; nil when the kernel would refuse it.
106
+ static func normalize(_ name: String) -> String? {
107
+ let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
108
+ guard !trimmed.isEmpty, trimmed.count <= 64 else { return nil }
109
+ let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_.-"))
110
+ guard trimmed.unicodeScalars.allSatisfy({ $0.isASCII && allowed.contains($0) }) else { return nil }
111
+ return trimmed
112
+ }
113
+ }
@@ -89,6 +89,17 @@ public protocol ForgeRuntime: AnyObject {
89
89
  /// request is not replayed to a late listener - the kernel asks again on
90
90
  /// the next tick.
91
91
  func setDeviceSampleListener(_ listener: (([String]) -> Void)?) -> ForgeOutcome
92
+ /// Replaces the full set of remote command names this host listens to.
93
+ /// Commands with any other name are auto-answered `handled=false` by the
94
+ /// kernel. Accepted before `start()`.
95
+ func setCommandSubscriptions(_ names: [String]) -> ForgeOutcome
96
+ /// Answers a command delivered through ``setCommandListener(_:)``; the
97
+ /// kernel emits the first answer per command id as a `command-answer`
98
+ /// session event.
99
+ func answerCommand(id: String, handled: Bool, details: [String: String]) -> ForgeOutcome
100
+ /// Registers (or clears with `nil`) the receiver of subscribed remote
101
+ /// commands. A command is delivered once and never replayed.
102
+ func setCommandListener(_ listener: ((ForgeCommand) -> Void)?) -> ForgeOutcome
92
103
  func createVideoView(timestampMs: Int64, options: VideoOptions) -> RuntimeVideoView
93
104
  func recordEvent(_ event: ForgeRecordedEvent) -> ForgeOutcome
94
105
  /// Records a typed identity transition (`loginSuccess`, `loginFailed`,
@@ -162,6 +173,11 @@ public extension ForgeRuntime {
162
173
  /// Additive default: no kernel means no cadence, so registration succeeds
163
174
  /// and simply never delivers a request.
164
175
  func setDeviceSampleListener(_ listener: (([String]) -> Void)?) -> ForgeOutcome { .accepted }
176
+ /// Additive defaults: a runtime with no kernel behind it receives no
177
+ /// commands, so subscriptions and answers are accepted and forgotten.
178
+ func setCommandSubscriptions(_ names: [String]) -> ForgeOutcome { .accepted }
179
+ func answerCommand(id: String, handled: Bool, details: [String: String]) -> ForgeOutcome { .accepted }
180
+ func setCommandListener(_ listener: ((ForgeCommand) -> Void)?) -> ForgeOutcome { .accepted }
165
181
  }
166
182
 
167
183
  public protocol ForgeRuntimeFactory: AnyObject {
@@ -181,6 +197,11 @@ public final class UnavailableForgeRuntime: ForgeRuntime {
181
197
  public func recordLocationFix(_ fix: ForgeLocationFix) -> ForgeOutcome { .accepted }
182
198
  public func recordDeviceSample(_ sample: ForgeDeviceSample) -> ForgeOutcome { .accepted }
183
199
  public func setDeviceSampleListener(_ listener: (([String]) -> Void)?) -> ForgeOutcome { .accepted }
200
+ public func setCommandSubscriptions(_ names: [String]) -> ForgeOutcome { .accepted }
201
+ /// An answer is an event the missing kernel cannot emit: it fails like
202
+ /// `recordEvent`, so the host is not told a `command-answer` went out.
203
+ public func answerCommand(id: String, handled: Bool, details: [String: String]) -> ForgeOutcome { failed() }
204
+ public func setCommandListener(_ listener: ((ForgeCommand) -> Void)?) -> ForgeOutcome { .accepted }
184
205
  public func createVideoView(timestampMs: Int64, options: VideoOptions) -> RuntimeVideoView { RuntimeVideoView(id: -1) }
185
206
  public func recordEvent(_ event: ForgeRecordedEvent) -> ForgeOutcome { failed() }
186
207
  public func recordIdentityEvent(kind: String, timemarkMs: Int64, fields: [String: String]) -> ForgeOutcome { failed() }
@@ -255,6 +276,21 @@ public final class RecordingForgeRuntime: ForgeRuntime {
255
276
  /// The device sample request listener currently registered, `nil` once
256
277
  /// cleared.
257
278
  public private(set) var deviceSampleListener: (([String]) -> Void)?
279
+ /// Every remote-command subscription set forwarded, in order.
280
+ public private(set) var commandSubscriptions: [[String]] = []
281
+ /// Every remote-command answer forwarded, in order.
282
+ public private(set) var commandAnswers: [(id: String, handled: Bool, details: [String: String])] = []
283
+ /// The remote command listener currently registered, `nil` once cleared.
284
+ public private(set) var commandListener: ((ForgeCommand) -> Void)?
285
+ /// Runtime calls in order (`start`, `subscriptions:a,b`,
286
+ /// `commandListener:set`, `commandListener:clear`), so a test can assert
287
+ /// that Builder listeners and the command listener are in place before
288
+ /// `start()`.
289
+ public private(set) var calls: [String] = []
290
+ /// Runs inside `destroy()`, at the point where the native runtime joins
291
+ /// its worker thread. A test models a delivery still running on that
292
+ /// worker by blocking here until it returns.
293
+ public var destroyHook: (() -> Void)?
258
294
  /// Device samples arrive from the collector's utility queue while a test
259
295
  /// reads them from the main thread; only those two members are guarded.
260
296
  private let recordingLock = NSLock()
@@ -262,12 +298,43 @@ public final class RecordingForgeRuntime: ForgeRuntime {
262
298
 
263
299
  public init() {}
264
300
 
265
- public func start() -> ForgeOutcome { .accepted }
301
+ public func start() -> ForgeOutcome {
302
+ calls.append("start")
303
+ return .accepted
304
+ }
266
305
  public func setLogLevel(_ level: ForgeLogLevel) -> ForgeOutcome { .accepted }
267
306
  public func setAutoConnectionType(_ connectionType: String?) -> ForgeOutcome {
268
307
  publishedConnectionTypes.append(connectionType)
269
308
  return .accepted
270
309
  }
310
+ public func setCommandSubscriptions(_ names: [String]) -> ForgeOutcome {
311
+ recordingLock.lock()
312
+ defer { recordingLock.unlock() }
313
+ commandSubscriptions.append(names)
314
+ calls.append("subscriptions:" + names.joined(separator: ","))
315
+ return .accepted
316
+ }
317
+ public func answerCommand(id: String, handled: Bool, details: [String: String]) -> ForgeOutcome {
318
+ recordingLock.lock()
319
+ defer { recordingLock.unlock() }
320
+ commandAnswers.append((id: id, handled: handled, details: details))
321
+ return .accepted
322
+ }
323
+ public func setCommandListener(_ listener: ((ForgeCommand) -> Void)?) -> ForgeOutcome {
324
+ recordingLock.lock()
325
+ defer { recordingLock.unlock() }
326
+ commandListener = listener
327
+ calls.append(listener == nil ? "commandListener:clear" : "commandListener:set")
328
+ return .accepted
329
+ }
330
+ /// Test hook mirroring the kernel's command delivery: hands the command to
331
+ /// the registered listener, if any. Nothing is replayed.
332
+ public func emitCommandRequest(_ command: ForgeCommand) {
333
+ recordingLock.lock()
334
+ let listener = commandListener
335
+ recordingLock.unlock()
336
+ listener?(command)
337
+ }
271
338
  public func recordLocationFix(_ fix: ForgeLocationFix) -> ForgeOutcome {
272
339
  recordedLocationFixes.append(fix)
273
340
  return .accepted
@@ -374,7 +441,10 @@ public final class RecordingForgeRuntime: ForgeRuntime {
374
441
  return .accepted
375
442
  }
376
443
  public func snapshotEvents() -> [ForgeRecordedEvent] { events }
377
- public func destroy() -> ForgeOutcome { .accepted }
444
+ public func destroy() -> ForgeOutcome {
445
+ destroyHook?()
446
+ return .accepted
447
+ }
378
448
  public func setCapturePlanListener(_ listener: ((ForgeCapturePlan) -> Void)?) -> ForgeOutcome {
379
449
  capturePlanListener = listener
380
450
  if let listener, let latest = latestCapturePlan {
@@ -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 = "10.1.0"
43
+ public static let defaultPluginVersion = "10.2.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"
@@ -139,6 +139,32 @@ public final class NPAWForge: NSObject {
139
139
  kernel.addOnSendRequestListener(listener)
140
140
  }
141
141
 
142
+ /// Listens for a remote command by name. The backend delivers commands in
143
+ /// the responses of Forge's own analytics requests, so they arrive only
144
+ /// while events flow; a command nobody subscribed to is answered
145
+ /// `handled=false` by the kernel and never re-delivered - register early
146
+ /// (or through `Builder.addCommandListener`, which runs before the runtime
147
+ /// starts). Every listener of the name runs; call
148
+ /// ``ForgeCommandAnswer/send(handled:details:)`` at most once per listener,
149
+ /// and the first answer per command id is reported as a `command-answer`
150
+ /// event. Listeners run on an internal runtime thread: return quickly.
151
+ public func onCommand(_ name: String, _ listener: @escaping ForgeCommandListener) -> ForgeEventSubscription {
152
+ kernel.addCommandListener(name, listener)
153
+ }
154
+
155
+ /// Answers a delivered remote command by id, for hosts that correlate
156
+ /// answers themselves. Prefer the ``ForgeCommandAnswer`` handed to the
157
+ /// listener. The kernel keeps the first answer per command id.
158
+ @discardableResult
159
+ public func answerCommand(id: String, handled: Bool, details: [String: String] = [:]) -> ForgeOutcome {
160
+ kernel.answerCommand(id: id, handled: handled, details: details)
161
+ }
162
+
163
+ /// The remote command names currently subscribed, sorted.
164
+ public func commandSubscriptions() -> [String] {
165
+ kernel.commandSubscriptions()
166
+ }
167
+
142
168
  public func snapshotEvents() -> [ForgeRecordedEvent] {
143
169
  kernel.snapshotEvents()
144
170
  }
@@ -420,6 +446,11 @@ public final class NPAWForge: NSObject {
420
446
  private var deviceInfo: ForgeDeviceInfo?
421
447
  private var framework: String?
422
448
  private var services: ForgeAppleServices?
449
+ /// Registration-ordered: the facade subscribes these one by one before
450
+ /// the runtime starts, so the order decides the subscription sets the
451
+ /// runtime sees. A dictionary would make that order hash-seed
452
+ /// dependent.
453
+ private var commandListeners: [(name: String, listener: ForgeCommandListener)] = []
423
454
 
424
455
  public init(accountCode: String) {
425
456
  self.accountCode = accountCode
@@ -570,6 +601,15 @@ public final class NPAWForge: NSObject {
570
601
  return self
571
602
  }
572
603
 
604
+ /// Registers a remote command listener before the runtime starts, so
605
+ /// a command riding the very first response already finds it. Same
606
+ /// semantics as ``NPAWForge/onCommand(_:_:)``.
607
+ @discardableResult
608
+ public func addCommandListener(_ name: String, _ listener: @escaping ForgeCommandListener) -> Builder {
609
+ commandListeners.append((name: name, listener: listener))
610
+ return self
611
+ }
612
+
573
613
  public func build() -> NPAWForge {
574
614
  // Services first: resolving the device UUID needs the storage the
575
615
  // integrator supplied, so it cannot wait until after the config.
@@ -599,7 +639,11 @@ public final class NPAWForge: NSObject {
599
639
  deviceInfo: resolvedDeviceInfo?.isEmpty == true ? nil : resolvedDeviceInfo,
600
640
  framework: framework
601
641
  )
602
- let forge = NPAWForge(kernel: ForgeApple(config: config, services: resolvedServices))
642
+ let forge = NPAWForge(kernel: ForgeApple(
643
+ config: config,
644
+ services: resolvedServices,
645
+ initialCommandListeners: commandListeners
646
+ ))
603
647
  if autoLocationEnabled {
604
648
  // Created here but inert: it reads nothing until a capture plan
605
649
  // carries the location bit. The plan registry replays the
@@ -28,6 +28,9 @@ final class UniFFIForgeRuntime: ForgeRuntime {
28
28
  private var capturePlanListener: UniFFICapturePlanListener?
29
29
  private var logListener: UniFFILogListener?
30
30
  private var deviceSampleListener: UniFFIDeviceSampleListener?
31
+ /// Readable by the bridge tests, which check the wrapper is released once
32
+ /// the listener is cleared.
33
+ private(set) var commandListener: UniFFICommandListener?
31
34
 
32
35
  init(config: ForgeAppleConfig) {
33
36
  bridge = ForgeRuntimeBridge(config: AppleRuntimeConfig(
@@ -104,6 +107,39 @@ final class UniFFIForgeRuntime: ForgeRuntime {
104
107
  return result
105
108
  }
106
109
 
110
+ func setCommandSubscriptions(_ names: [String]) -> ForgeOutcome {
111
+ outcome(bridge.setCommandSubscriptions(names: names))
112
+ }
113
+
114
+ func answerCommand(id: String, handled: Bool, details: [String: String]) -> ForgeOutcome {
115
+ outcome(bridge.answerCommand(commandId: id, handled: handled, details: details))
116
+ }
117
+
118
+ func setCommandListener(_ listener: ((ForgeCommand) -> Void)?) -> ForgeOutcome {
119
+ if let listener {
120
+ let callback = UniFFICommandListener(callback: listener)
121
+ // Retained before registering, like the other listeners.
122
+ callbackLock.lock()
123
+ commandListener = callback
124
+ callbackLock.unlock()
125
+ let result = outcome(bridge.setCommandListener(listener: callback))
126
+ if !result.isAccepted {
127
+ callbackLock.lock()
128
+ commandListener = nil
129
+ callbackLock.unlock()
130
+ }
131
+ return result
132
+ }
133
+
134
+ let result = outcome(bridge.clearCommandListener())
135
+ if result.isAccepted {
136
+ callbackLock.lock()
137
+ commandListener = nil
138
+ callbackLock.unlock()
139
+ }
140
+ return result
141
+ }
142
+
107
143
  func createVideoView(timestampMs: Int64, options: VideoOptions) -> RuntimeVideoView {
108
144
  RuntimeVideoView(id: Int64(clamping: bridge.createVideoView(
109
145
  nowMs: u64(timestampMs),
@@ -355,6 +391,7 @@ final class UniFFIForgeRuntime: ForgeRuntime {
355
391
  _ = bridge.clearCapturePlanListener()
356
392
  _ = bridge.clearLogListener()
357
393
  _ = bridge.clearDeviceSampleListener()
394
+ _ = bridge.clearCommandListener()
358
395
  callbackLock.lock()
359
396
  contentMetadataProviders.removeAll()
360
397
  videoPreSendHooks.removeAll()
@@ -362,6 +399,7 @@ final class UniFFIForgeRuntime: ForgeRuntime {
362
399
  capturePlanListener = nil
363
400
  logListener = nil
364
401
  deviceSampleListener = nil
402
+ commandListener = nil
365
403
  callbackLock.unlock()
366
404
  return result
367
405
  }
@@ -769,4 +807,19 @@ private final class UniFFIDeviceSampleListener: AppleDeviceSampleListener, @unch
769
807
  callback(families)
770
808
  }
771
809
  }
810
+
811
+ /// Receives subscribed remote commands on the runtime worker thread and hands
812
+ /// them to the Swift facade, which fans out to the `onCommand` listeners.
813
+ /// Module-internal (not private) so the bridge tests can pin the mapping.
814
+ final class UniFFICommandListener: AppleCommandListener, @unchecked Sendable {
815
+ private let callback: (ForgeCommand) -> Void
816
+
817
+ init(callback: @escaping (ForgeCommand) -> Void) {
818
+ self.callback = callback
819
+ }
820
+
821
+ func onCommand(command: AppleCommandRequest) {
822
+ callback(ForgeCommand(id: command.id, name: command.name, args: command.args))
823
+ }
824
+ }
772
825
  #endif
@@ -93,6 +93,20 @@ public final class NPAWForgeObjC: NSObject {
93
93
  swiftForge.trackSessionEvent(name: name, metadata: metadata.toStringAnyMap())
94
94
  }
95
95
 
96
+ /// Listens for a remote command by name; see `NPAWForge.onCommand`.
97
+ @objc(onCommandWithName:listener:)
98
+ public func onCommand(name: String, listener: @escaping (NPAWForgeCommandObjC, ForgeCommandAnswer) -> Void) -> NPAWForgeSubscriptionObjC {
99
+ NPAWForgeSubscriptionObjC(swiftForge.onCommand(name) { command, answer in
100
+ listener(NPAWForgeCommandObjC(command), answer)
101
+ })
102
+ }
103
+
104
+ /// Answers a delivered remote command by id; see `NPAWForge.answerCommand`.
105
+ @objc(answerCommandWithId:handled:details:)
106
+ public func answerCommand(id: String, handled: Bool, details: NSDictionary?) -> ForgeOutcome {
107
+ swiftForge.answerCommand(id: id, handled: handled, details: details.toStringStringMap())
108
+ }
109
+
96
110
  @objc(trackSessionErrorWithCode:message:metadata:)
97
111
  public func trackSessionError(code: String, message: String, metadata: NSDictionary?) -> ForgeOutcome {
98
112
  swiftForge.trackSessionError(errorCode: code, errorMessage: message, metadata: metadata.toStringAnyMap())