craft-native 0.0.340 → 0.0.344

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.
@@ -37,6 +37,7 @@ import java.net.URLConnection
37
37
  import java.util.UUID
38
38
  import java.util.concurrent.ConcurrentHashMap
39
39
  import java.util.concurrent.Executors
40
+ import java.util.concurrent.atomic.AtomicBoolean
40
41
  import kotlin.math.max
41
42
  import kotlin.math.roundToInt
42
43
 
@@ -86,6 +87,7 @@ internal class CraftNativeCapabilities(
86
87
  private val databaseExecutor = Executors.newSingleThreadExecutor()
87
88
  private val networkExecutor = Executors.newCachedThreadPool()
88
89
  private val cancelled = ConcurrentHashMap.newKeySet<String>()
90
+ private val closed = AtomicBoolean(false)
89
91
  private val fetchConnections = ConcurrentHashMap<String, HttpURLConnection>()
90
92
  private val fileRoot by lazy { File(context.cacheDir, "craft-files").apply { mkdirs() } }
91
93
  private var pendingFilePick: PendingFilePick? = null
@@ -135,6 +137,10 @@ internal class CraftNativeCapabilities(
135
137
  args: JSONArray,
136
138
  completion: (CraftNativeCapabilityResult) -> Unit,
137
139
  ) {
140
+ if (closed.get()) {
141
+ mainHandler.post { completion(failure("HOST_CLOSED", "The native capability host is closed")) }
142
+ return
143
+ }
138
144
  cancelled.remove(requestToken)
139
145
  if (version != CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION) {
140
146
  complete(requestToken, failure("UNSUPPORTED_VERSION", "Unsupported native capability protocol version"), completion)
@@ -150,6 +156,7 @@ internal class CraftNativeCapabilities(
150
156
  else bluetooth.perform(method, args) { complete(requestToken, it, completion) }
151
157
  }
152
158
  "Storage" -> mainHandler.post {
159
+ if (cancelled.remove(requestToken) || closed.get()) return@post
153
160
  val answer = runCatching { storage(method, args) }
154
161
  .getOrElse { failure("STORAGE_ERROR", it.message ?: "Storage operation failed") }
155
162
  complete(requestToken, answer, completion)
@@ -199,6 +206,8 @@ internal class CraftNativeCapabilities(
199
206
  }
200
207
 
201
208
  fun close() {
209
+ if (!closed.compareAndSet(false, true)) return
210
+ cancelled.clear()
202
211
  if (config.optBoolean("enableBluetooth")) bluetooth.disconnect()
203
212
  databaseExecutor.shutdownNow()
204
213
  networkExecutor.shutdownNow()
@@ -653,9 +662,9 @@ internal class CraftNativeCapabilities(
653
662
  completion: (CraftNativeCapabilityResult) -> Unit,
654
663
  ) {
655
664
  if (android.os.Looper.myLooper() == android.os.Looper.getMainLooper()) {
656
- if (!cancelled.remove(requestToken)) completion(result)
665
+ if (!cancelled.remove(requestToken) && !closed.get()) completion(result)
657
666
  } else mainHandler.post {
658
- if (!cancelled.remove(requestToken)) completion(result)
667
+ if (!cancelled.remove(requestToken) && !closed.get()) completion(result)
659
668
  }
660
669
  }
661
670
 
package/dist/cli.js CHANGED
@@ -3022,7 +3022,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3022
3022
  `);
3023
3023
  }
3024
3024
  // package.json
3025
- var version = "0.0.340";
3025
+ var version = "0.0.344";
3026
3026
 
3027
3027
  // bin/cli.ts
3028
3028
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -375,7 +375,7 @@ function craftBinaryNotFoundMessage(triedPath) {
375
375
  `);
376
376
  }
377
377
  // package.json
378
- var version = "0.0.340";
378
+ var version = "0.0.344";
379
379
 
380
380
  // src/sdk-version.ts
381
381
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
117
117
  `);
118
118
  }
119
119
  // package.json
120
- var version = "0.0.340";
120
+ var version = "0.0.344";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -1796,12 +1796,18 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1796
1796
  hybrid.openOutside(raw)
1797
1797
  return
1798
1798
  }
1799
- open(CraftHybridRoutes.appPath(of: url))
1799
+ open(CraftHybridRoutes.appPath(of: url), props: props)
1800
1800
  return
1801
1801
  }
1802
1802
  guard let path = CraftHybridRoutes.normalize(raw) else { return }
1803
1803
  if let tab = hybrid.tabIds.first(where: { CraftHybridRoutes.normalize($0) == path }) {
1804
- selectTabAsIfTapped(tab)
1804
+ if hybrid.routes.match(raw) != nil, URLComponents(string: raw)?.query != nil || !props.isEmpty {
1805
+ // A checkout/auth return targets the retained tab with new
1806
+ // parameters. Selecting alone would discard that handoff.
1807
+ showNative(path: raw, replace: false, props: props)
1808
+ } else {
1809
+ selectTabAsIfTapped(tab)
1810
+ }
1805
1811
  return
1806
1812
  }
1807
1813
  if hybrid.routes.match(raw) != nil {
@@ -203,7 +203,13 @@ enum CraftNativeActions {
203
203
  case ("Clipboard", "read"):
204
204
  sync = perform(action: "clipboardRead", body: [:], config: config)
205
205
  case ("Storage", _):
206
- DispatchQueue.main.async { complete(requestToken, storage(method: method, args: args), completion) }
206
+ DispatchQueue.main.async {
207
+ cancellationLock.lock()
208
+ let cancelled = cancelledRequests.remove(requestToken) != nil
209
+ cancellationLock.unlock()
210
+ guard !cancelled else { return }
211
+ complete(requestToken, storage(method: method, args: args), completion)
212
+ }
207
213
  return
208
214
  case ("SecureStorage", _):
209
215
  guard config.enableSecureStorage else {
@@ -15,6 +15,45 @@ import UIKit
15
15
 
16
16
  private let craftNativeCapabilityTimeoutMilliseconds = 30_000
17
17
 
18
+ /// A retained JavaScript context must lose synchronous storage access when
19
+ /// its screen closes. Only the actual synchronous unmount callback may finish
20
+ /// its cleanup; queued script work runs after that allowance ends.
21
+ private final class CraftNativeStorageLifetime {
22
+ private let lock = NSLock()
23
+ private var closed = false
24
+ private var unmounting = false
25
+
26
+ func close() {
27
+ lock.lock()
28
+ closed = true
29
+ lock.unlock()
30
+ }
31
+
32
+ func unmount(_ work: () -> Void) {
33
+ lock.lock()
34
+ unmounting = true
35
+ lock.unlock()
36
+ defer {
37
+ lock.lock()
38
+ unmounting = false
39
+ lock.unlock()
40
+ }
41
+ work()
42
+ }
43
+
44
+ func access<T>(closedValue: T, _ work: () -> T) -> T {
45
+ lock.lock()
46
+ defer { lock.unlock() }
47
+ guard !closed || unmounting else {
48
+ if let context = JSContext.current() {
49
+ context.exception = JSValue(newErrorFromMessage: "SCREEN_CLOSED: The native screen is closed", in: context)
50
+ }
51
+ return closedValue
52
+ }
53
+ return work()
54
+ }
55
+ }
56
+
18
57
  /// Foundation's `NSNumber(0/1) is Bool` is true even for JSON numbers.
19
58
  /// Check the Core Foundation identity so real booleans stay invalid numeric
20
59
  /// props while zero lengths, flex factors and opacity 1 remain numbers.
@@ -5751,6 +5790,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5751
5790
  weak var hybridEvents: CraftHybridScreenEvents?
5752
5791
  private var hybridRendered = false
5753
5792
  private var runtimeDisposed = false
5793
+ private let storageLifetime = CraftNativeStorageLifetime()
5754
5794
  /// The app script (`nativeApp`, CraftHybrid.swift): a bundle route with
5755
5795
  /// no screen, loaded once and never shown, that runs for the app's life.
5756
5796
  /// It hears every `APP_STATE` and the app's events (`CRAFT_EVENT`), no
@@ -5998,6 +6038,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5998
6038
  private func disposeRuntime() {
5999
6039
  guard !runtimeDisposed else { return }
6000
6040
  runtimeDisposed = true
6041
+ storageLifetime.close()
6042
+ pendingCapabilityDeadlines.values.forEach { $0.cancel() }
6043
+ pendingCapabilityDeadlines.removeAll()
6044
+ pendingCapabilityRequests.forEach { CraftNativeActions.cancel(requestToken: $0) }
6045
+ pendingCapabilityRequests.removeAll()
6001
6046
  if let owner = voiceRecordingOwner {
6002
6047
  voiceRecordingOwner = nil
6003
6048
  CraftVoiceRecording.shared.perform(method: "cancel", args: [owner]) { _ in }
@@ -6007,7 +6052,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6007
6052
  disposeRenderedTree()
6008
6053
  let context = jsContext
6009
6054
  let route = routeName ?? "screen"
6010
- CraftNativeScriptThread.run(label: "unmount \(route)") { context.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: []) }
6055
+ let storageLifetime = self.storageLifetime
6056
+ CraftNativeScriptThread.run(label: "unmount \(route)") {
6057
+ storageLifetime.unmount { context.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: []) }
6058
+ }
6011
6059
  audio.close()
6012
6060
  sounds.close()
6013
6061
  streams.close()
@@ -6469,17 +6517,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6469
6517
  let log: @convention(block) (String, String, String) -> Void = { level, category, message in
6470
6518
  CraftNativeConsole.write(level, category: category, message: message)
6471
6519
  }
6520
+ let storageLifetime = self.storageLifetime
6472
6521
  let storageGet: @convention(block) (String) -> String? = { key in
6473
- do { return try CraftNativeActions.storageJSON(forKey: key) }
6474
- catch {
6475
- if let context = JSContext.current() {
6476
- context.exception = JSValue(newErrorFromMessage: "STORAGE_ERROR: Unable to read stored value", in: context)
6522
+ storageLifetime.access(closedValue: nil) {
6523
+ do { return try CraftNativeActions.storageJSON(forKey: key) }
6524
+ catch {
6525
+ if let context = JSContext.current() {
6526
+ context.exception = JSValue(newErrorFromMessage: "STORAGE_ERROR: Unable to read stored value", in: context)
6527
+ }
6528
+ return nil
6477
6529
  }
6478
- return nil
6479
6530
  }
6480
6531
  }
6481
6532
  let storageSet: @convention(block) (String, JSValue) -> Bool = { key, value in
6482
- CraftNativeActions.setStorageJSON(value.isNull || value.isUndefined ? nil : value.toString(), forKey: key)
6533
+ storageLifetime.access(closedValue: false) {
6534
+ CraftNativeActions.setStorageJSON(value.isNull || value.isUndefined ? nil : value.toString(), forKey: key)
6535
+ }
6483
6536
  }
6484
6537
  let snapshotGet: @convention(block) (String) -> String? = { name in
6485
6538
  CraftSnapshots.read(name)
@@ -7613,6 +7666,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7613
7666
  navigationController?.popToRootViewController(animated: true)
7614
7667
  }
7615
7668
  case "API_REQUEST":
7669
+ guard !runtimeDisposed else { return }
7616
7670
  guard let id = message["id"] as? String else { return }
7617
7671
  let args = payload["args"] as? [Any] ?? []
7618
7672
  let requestToken = "\(capabilityScope)/\(id)"
@@ -7682,7 +7736,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7682
7736
  case "API_CANCEL":
7683
7737
  guard let id = payload["requestId"] as? String else { return }
7684
7738
  let requestToken = "\(capabilityScope)/\(id)"
7685
- pendingCapabilityRequests.remove(requestToken)
7739
+ guard pendingCapabilityRequests.remove(requestToken) != nil else { return }
7686
7740
  pendingCapabilityDeadlines.removeValue(forKey: requestToken)?.cancel()
7687
7741
  screenRequestCancellers.removeValue(forKey: requestToken)?()
7688
7742
  CraftNativeActions.cancel(requestToken: requestToken)
@@ -2,26 +2,80 @@ import AVFoundation
2
2
  import Foundation
3
3
  import UIKit
4
4
 
5
- /// One owner at a time, including the asynchronous permission prompt. A late
6
- /// permission answer cannot start recording after its screen/account left.
5
+ protocol CraftVoiceRecorder: AnyObject {
6
+ func record(forDuration duration: TimeInterval) -> Bool
7
+ func stop()
8
+ }
9
+ extension AVAudioRecorder: CraftVoiceRecorder {}
10
+
11
+ /// The main thread owns requests; audio and encoder work belong to one serial
12
+ /// queue. Cancellation invalidates a request immediately, even while AVFoundation
13
+ /// is starting, so a late permission/encoder answer cannot resurrect it.
7
14
  final class CraftVoiceRecording {
8
15
  static let shared = CraftVoiceRecording()
9
- private var owner: String?
10
- private var recorder: AVAudioRecorder?
11
- private var file: URL?
16
+ private final class Session {
17
+ let key: String
18
+ var file: URL?
19
+ var recorder: CraftVoiceRecorder? // audio queue only
20
+ var completion: ((Result<Any, CraftNativeActionError>) -> Void)? // main only
21
+ var deadline: DispatchWorkItem?
22
+ private let lock = NSLock()
23
+ private var cancelled = false
24
+ init(key: String, completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
25
+ self.key = key
26
+ self.completion = completion
27
+ }
28
+ var isCancelled: Bool { lock.withLock { cancelled } }
29
+ func invalidate() { lock.withLock { cancelled = true } }
30
+ func finish(_ result: Result<Any, CraftNativeActionError>) {
31
+ deadline?.cancel()
32
+ deadline = nil
33
+ let done = completion
34
+ completion = nil
35
+ done?(result)
36
+ }
37
+ }
38
+ private var current: Session?
39
+ private let audioQueue: DispatchQueue
40
+ private let permission: (@escaping (Bool) -> Void) -> Void
41
+ private let makeRecorder: (URL) throws -> CraftVoiceRecorder
42
+ private let deactivate: () -> Void
43
+ private let startupTimeout: TimeInterval
12
44
  private var backgroundObserver: NSObjectProtocol?
13
45
 
14
- private init() {
46
+ init(audioQueue: DispatchQueue = DispatchQueue(label: "craft.voice-recording", qos: .userInitiated),
47
+ startupTimeout: TimeInterval = 15,
48
+ permission: @escaping (@escaping (Bool) -> Void) -> Void = { AVAudioSession.sharedInstance().requestRecordPermission($0) },
49
+ makeRecorder: @escaping (URL) throws -> CraftVoiceRecorder = { file in
50
+ let session = AVAudioSession.sharedInstance()
51
+ try session.setCategory(.record, mode: .measurement)
52
+ try session.setActive(true)
53
+ return try AVAudioRecorder(url: file, settings: [AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 16000, AVNumberOfChannelsKey: 1, AVEncoderBitRateKey: 32000])
54
+ },
55
+ deactivate: @escaping () -> Void = { try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) }) {
56
+ self.audioQueue = audioQueue
57
+ self.startupTimeout = startupTimeout
58
+ self.permission = permission
59
+ self.makeRecorder = makeRecorder
60
+ self.deactivate = deactivate
15
61
  backgroundObserver = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { [weak self] _ in self?.clear() }
16
62
  }
17
63
 
18
- private func clear() {
19
- owner = nil
20
- recorder?.stop()
21
- recorder = nil
22
- if let file { CraftNativeFiles.remove(file.path) }
23
- file = nil
24
- try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
64
+ deinit { if let backgroundObserver { NotificationCenter.default.removeObserver(backgroundObserver) } }
65
+
66
+ private func erase(_ session: Session) {
67
+ session.recorder?.stop()
68
+ session.recorder = nil
69
+ if let file = session.file { CraftNativeFiles.remove(file.path) }
70
+ deactivate()
71
+ }
72
+
73
+ private func clear(_ error: CraftNativeActionError = CraftNativeActionError(code: "RECORDING_CANCELLED", message: "Recording was cancelled")) {
74
+ guard let session = current else { return }
75
+ current = nil
76
+ session.invalidate()
77
+ session.finish(.failure(error))
78
+ audioQueue.async { self.erase(session) }
25
79
  }
26
80
 
27
81
  func perform(method: String, args: [Any], isActive: @escaping () -> Bool = { true }, completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
@@ -32,43 +86,84 @@ final class CraftVoiceRecording {
32
86
  func fail(_ code: String, _ message: String) { completion(.failure(CraftNativeActionError(code: code, message: message))) }
33
87
  guard let key = args.first as? String, UUID(uuidString: key) != nil else { fail("INVALID_ARGUMENT", "A recording owner UUID is required"); return }
34
88
  if method == "cancel" {
35
- if owner == key { clear() }
89
+ if current?.key == key { clear() }
36
90
  completion(.success(true))
37
91
  return
38
92
  }
39
93
  if method == "stop" {
40
- guard owner == key, let file else { fail("RECORDING_NOT_FOUND", "This recording is no longer available"); return }
41
- recorder?.stop()
42
- recorder = nil
43
- try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
44
- guard let audio = try? AVAudioFile(forReading: file), audio.length > 0 else { clear(); fail("EMPTY_RECORDING", "The recording was empty"); return }
45
- let duration = Double(audio.length) / audio.processingFormat.sampleRate
46
- completion(.success(CraftNativeFiles.describe(file, type: "audio/mp4", extra: ["durationSeconds": duration])))
94
+ guard let session = current, session.key == key, let file = session.file else { fail("RECORDING_NOT_FOUND", "This recording is no longer available"); return }
95
+ audioQueue.async {
96
+ guard !session.isCancelled else { return DispatchQueue.main.async { fail("RECORDING_CANCELLED", "Recording was cancelled") } }
97
+ session.recorder?.stop()
98
+ session.recorder = nil
99
+ self.deactivate()
100
+ let result: Result<Any, CraftNativeActionError>
101
+ if let audio = try? AVAudioFile(forReading: file), audio.length > 0 {
102
+ let duration = Double(audio.length) / audio.processingFormat.sampleRate
103
+ result = .success(CraftNativeFiles.describe(file, type: "audio/mp4", extra: ["durationSeconds": duration]))
104
+ } else {
105
+ result = .failure(CraftNativeActionError(code: "EMPTY_RECORDING", message: "The recording was empty"))
106
+ }
107
+ DispatchQueue.main.async {
108
+ guard self.current === session, isActive(), !session.isCancelled else {
109
+ if self.current === session { self.clear() }
110
+ fail("RECORDING_CANCELLED", "Recording was cancelled")
111
+ return
112
+ }
113
+ if case .failure = result { self.clear() }
114
+ completion(result)
115
+ }
116
+ }
47
117
  return
48
118
  }
49
119
  guard method == "start" else { fail("UNKNOWN_ACTION", "Unknown voice recording action"); return }
50
120
  guard isActive() else { fail("RECORDING_CANCELLED", "The requesting screen has closed"); return }
51
- guard owner == nil else { fail("RECORDING_BUSY", "Another microphone session is still active"); return }
121
+ guard current == nil else { fail("RECORDING_BUSY", "Another microphone session is still active"); return }
52
122
  let options = args.count > 1 ? args[1] as? [String: Any] ?? [:] : [:]
53
123
  let seconds = (options["maxSeconds"] as? NSNumber)?.doubleValue ?? 300
54
124
  guard seconds.isFinite, seconds >= 1, seconds <= 300 else { fail("INVALID_ARGUMENT", "Recordings are limited to five minutes"); return }
55
- owner = key
56
- AVAudioSession.sharedInstance().requestRecordPermission { allowed in
125
+ let session = Session(key: key, completion: completion)
126
+ current = session
127
+ permission { allowed in
57
128
  DispatchQueue.main.async {
58
- guard self.owner == key, isActive() else { if self.owner == key { self.clear() }; fail("RECORDING_CANCELLED", "Recording was cancelled"); return }
59
- guard allowed else { self.clear(); fail("PERMISSION_DENIED", "Microphone permission was refused"); return }
60
- do {
61
- let session = AVAudioSession.sharedInstance()
62
- try session.setCategory(.record, mode: .measurement)
63
- try session.setActive(true)
64
- let file = try CraftNativeFiles.newFile(named: "voice-feedback.m4a")
65
- self.file = file
66
- let recorder = try AVAudioRecorder(url: file, settings: [AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 16000, AVNumberOfChannelsKey: 1, AVEncoderBitRateKey: 32000])
67
- self.recorder = recorder
68
- guard recorder.record(forDuration: max(0.5, seconds - 0.25)) else { throw CraftNativeActionError(code: "RECORDING_FAILED", message: "The microphone could not start") }
69
- completion(.success(true))
129
+ guard self.current === session, isActive() else {
130
+ if self.current === session { self.clear() }
131
+ session.finish(.failure(CraftNativeActionError(code: "RECORDING_CANCELLED", message: "Recording was cancelled")))
132
+ return
133
+ }
134
+ guard allowed else { self.clear(CraftNativeActionError(code: "PERMISSION_DENIED", message: "Microphone permission was refused")); return }
135
+ do { session.file = try CraftNativeFiles.newFile(named: "voice-feedback.m4a") }
136
+ catch { self.clear(CraftNativeActionError(code: "RECORDING_FAILED", message: "The recording file could not be created")); return }
137
+ guard let file = session.file else { return }
138
+ let deadline = DispatchWorkItem { [weak host = self] in
139
+ guard let host, host.current === session else { return }
140
+ host.clear(CraftNativeActionError(code: "RECORDING_TIMEOUT", message: "The microphone took too long to start. Please try again."))
141
+ }
142
+ session.deadline = deadline
143
+ DispatchQueue.main.asyncAfter(deadline: .now() + self.startupTimeout, execute: deadline)
144
+ self.audioQueue.async {
145
+ do {
146
+ guard !session.isCancelled else { return }
147
+ session.recorder = try self.makeRecorder(file)
148
+ guard !session.isCancelled else { self.erase(session); return }
149
+ guard session.recorder?.record(forDuration: max(0.5, seconds - 0.25)) == true else {
150
+ throw CraftNativeActionError(code: "RECORDING_FAILED", message: "The microphone could not start")
151
+ }
152
+ guard !session.isCancelled else { self.erase(session); return }
153
+ DispatchQueue.main.async {
154
+ guard self.current === session, isActive() else {
155
+ if self.current === session { self.clear() }
156
+ return
157
+ }
158
+ session.finish(.success(true))
159
+ }
160
+ }
161
+ catch {
162
+ DispatchQueue.main.async {
163
+ if self.current === session { self.clear(CraftNativeActionError(code: "RECORDING_FAILED", message: "The microphone could not start")) }
164
+ }
165
+ }
70
166
  }
71
- catch { self.clear(); fail("RECORDING_FAILED", "The microphone could not start") }
72
167
  }
73
168
  }
74
169
  }
@@ -64,7 +64,10 @@ final class DriverTests: XCTestCase {
64
64
  func get(_ path: String, timeout: Double = 15) -> Data? {
65
65
  var result: Data?
66
66
  let done = expectation(description: path)
67
- URLSession.shared.dataTask(with: URL(string: control + path)!) { data, _, _ in result = data; done.fulfill() }.resume()
67
+ URLSession.shared.dataTask(with: URL(string: control + path)!) { data, response, error in
68
+ if error == nil, let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode) { result = data }
69
+ done.fulfill()
70
+ }.resume()
68
71
  wait(for: [done], timeout: timeout)
69
72
  return result
70
73
  }
@@ -332,7 +335,10 @@ final class DriverTests: XCTestCase {
332
335
  case "net": _ = get("/\(arg)"); note("net \(arg)")
333
336
  case "delay": _ = get("/delay?ms=\(arg)"); note("delay \(arg)ms")
334
337
  case "exec":
335
- let out = get("/exec?name=\(arg)", timeout: 180).flatMap { String(data: $0, encoding: .utf8) } ?? "no answer"
338
+ guard let data = get("/exec?name=\(arg)", timeout: 180), let out = String(data: data, encoding: .utf8) else {
339
+ check(false, "exec \(arg): hook failed or returned no successful response")
340
+ return
341
+ }
336
342
  note("exec \(arg): \(out.trimmingCharacters(in: .whitespacesAndNewlines))")
337
343
  case "openurl":
338
344
  if let url = URL(string: arg) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.340",
3
+ "version": "0.0.344",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",