@npaw/forge-react-native 10.0.4 → 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.
- package/android/build.gradle +1 -1
- package/android/src/main/java/com/npaw/forge/reactnative/ForgeDtoMapper.java +14 -0
- package/android/src/main/java/com/npaw/forge/reactnative/NPAWForgeModule.java +82 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/Info.plist +9 -9
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/forge_kernelFFI.h +71 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/libForgeKernelFFI.a +0 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/forge_kernelFFI.h +71 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/libForgeKernelFFI.a +0 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/forge_kernelFFI.h +71 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/libForgeKernelFFI.a +0 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/forge_kernelFFI.h +71 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/libForgeKernelFFI.a +0 -0
- package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernel.swift +250 -0
- package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernelFFI.h +71 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeApple.swift +147 -6
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeCommands.swift +113 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeRuntime.swift +72 -2
- package/ios/ForgeApple/Sources/NPAWForgeCore/NPAWForge.swift +46 -2
- package/ios/ForgeApple/Sources/NPAWForgeCore/NativeUniFFIRuntime.swift +53 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ObjectiveCWrappers.swift +14 -0
- package/ios/ForgeModuleImpl.swift +66 -0
- package/ios/NPAWForgeTurboModule.mm +16 -1
- package/package.json +1 -1
- package/src/NPAWForge.ts +131 -1
- package/src/events.ts +17 -0
- package/src/index.ts +6 -2
- package/src/specs/NativeNPAWForge.ts +9 -0
- 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,
|
|
163
|
-
///
|
|
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
|
-
|
|
664
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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.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(
|
|
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())
|