craft-native 0.0.377 → 0.0.380

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/README.md CHANGED
@@ -658,7 +658,15 @@ This lets browser simulations checkpoint on backgrounding with the same contract
658
658
  as a native shell. Storage failures must still be handled by the caller.
659
659
 
660
660
  Await `platform.call('Storage', 'set', [key, value], signal)` before reporting
661
- a saved checkpoint. `craft.storage.setSync` updates the immediate UI cache and
661
+ a saved checkpoint. The browser captures the key and JSON value when the call
662
+ starts, before waiting for a Web Lock. Later mutations of your simulation state
663
+ or argument array cannot retarget or change that pending save. `setIfAbsent`
664
+ captures its value too; `compareAndSet` captures both the expected and next JSON
665
+ values (including their `toJSON` representation). Invalid JSON input rejects
666
+ without changing the previous checkpoint. Cancellation and account-scope checks
667
+ still run before committing the captured value.
668
+
669
+ `craft.storage.setSync` updates the immediate UI cache and
662
670
  queues its write when Web Locks are available; it cannot acknowledge durability
663
671
  or propagate a later quota failure. Background events are an opportunity to
664
672
  checkpoint, but browsers can terminate a page before queued work finishes, so
package/dist/browser.js CHANGED
@@ -242,8 +242,18 @@ async function createBrowserStorage(options) {
242
242
  signal?.throwIfAborted();
243
243
  const captured = capture();
244
244
  const wasAvailable = available();
245
- await waitForAccount(accountChanged(), signal);
246
245
  const name = String(args[0] ?? "");
246
+ let json;
247
+ let expected;
248
+ if (wasAvailable && module === "Storage") {
249
+ if (method === "set" || method === "setIfAbsent" && lock)
250
+ json = encode(args[1]);
251
+ if (method === "compareAndSet" && lock) {
252
+ expected = parse(encode(args[1]));
253
+ json = encode(args[2]);
254
+ }
255
+ }
256
+ await waitForAccount(accountChanged(), signal);
247
257
  if (module === "Storage") {
248
258
  if (!wasAvailable)
249
259
  throw scopeChanged();
@@ -253,7 +263,7 @@ async function createBrowserStorage(options) {
253
263
  if (method === "get")
254
264
  return parse(storage.getItem(address));
255
265
  if (method === "set") {
256
- storage.setItem(address, encode(args[1]));
266
+ storage.setItem(address, json);
257
267
  return true;
258
268
  }
259
269
  if (method === "remove") {
@@ -274,12 +284,12 @@ async function createBrowserStorage(options) {
274
284
  if (method === "setIfAbsent") {
275
285
  if (raw !== null)
276
286
  return false;
277
- storage.setItem(address, encode(args[1]));
287
+ storage.setItem(address, json);
278
288
  return true;
279
289
  }
280
- if (raw === null || !jsonEqual(parse(raw), args[1]))
290
+ if (raw === null || !jsonEqual(parse(raw), expected))
281
291
  return false;
282
- storage.setItem(address, encode(args[2]));
292
+ storage.setItem(address, json);
283
293
  return true;
284
294
  }
285
295
  throw Object.assign(new Error(`Unsupported browser storage operation ${method}`), { code: "UNSUPPORTED" });
package/dist/cli.js CHANGED
@@ -3047,7 +3047,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3047
3047
  `);
3048
3048
  }
3049
3049
  // package.json
3050
- var version = "0.0.377";
3050
+ var version = "0.0.380";
3051
3051
 
3052
3052
  // bin/cli.ts
3053
3053
  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.377";
378
+ var version = "0.0.380";
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.377";
120
+ var version = "0.0.380";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -44,6 +44,8 @@ export interface CraftConfig {
44
44
  /** Runs from the generated project root after `craft ios build` writes it. */
45
45
  postGenerate?: CraftPostGenerateHook;
46
46
  backgroundColor?: string;
47
+ /** Default tint for native controls and selected tabs, as #RRGGBB. */
48
+ tintColor?: string;
47
49
  /** The launch and webview background while the phone is in Dark Mode. */
48
50
  backgroundColorDark?: string;
49
51
  /** Edge-swipe back and forward through the page's history, pushState entries included. */
@@ -1421,6 +1421,8 @@ async function init(options) {
1421
1421
  if (config.renderer !== "web" && config.renderer !== "native") {
1422
1422
  throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`);
1423
1423
  }
1424
+ if (config.tintColor !== undefined && !/^#[0-9a-f]{6}$/i.test(config.tintColor))
1425
+ throw new Error("iOS tintColor must be a #RRGGBB color");
1424
1426
  const selection = config.tabBar?.selection;
1425
1427
  if (selection?.cornerRadius !== undefined && (!Number.isFinite(selection.cornerRadius) || selection.cornerRadius < 0))
1426
1428
  throw new Error("iOS tabBar.selection.cornerRadius must be a finite non-negative number");
@@ -700,6 +700,7 @@ struct CraftApp: App {
700
700
  .ignoresSafeArea(.keyboard, edges: .bottom)
701
701
  }
702
702
  }
703
+ .tint(appState.config.tintColor.flatMap { UIColor(hex: $0) }.map { Color($0) })
703
704
  .preferredColorScheme(appState.appearanceScheme)
704
705
  .environmentObject(appState)
705
706
  .onOpenURL { url in
@@ -1788,6 +1789,8 @@ struct CraftConfig: Codable {
1788
1789
  /// which keep what `darkMode` pinned.
1789
1790
  var appearance: String? = nil
1790
1791
  var backgroundColor: String = "#1a1a2e"
1792
+ /// Default native control and selected-tab tint, independent of page chrome.
1793
+ var tintColor: String? = nil
1791
1794
  /// The background while the phone is in Dark Mode; the light one if unset.
1792
1795
  var backgroundColorDark: String? = nil
1793
1796
  /// Edge-swipe back and forward through the page's history.
@@ -1192,6 +1192,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1192
1192
  }
1193
1193
  }
1194
1194
  view.backgroundColor = config.resolvedBackgroundColor
1195
+ if let tint = config.tintColor.flatMap({ UIColor(hex: $0) }) { view.tintColor = tint }
1195
1196
  parking.frame = view.bounds
1196
1197
  parking.autoresizingMask = [.flexibleWidth, .flexibleHeight]
1197
1198
  view.insertSubview(parking, at: 0)
@@ -1265,7 +1266,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1265
1266
  view.layoutIfNeeded()
1266
1267
  }
1267
1268
  }
1268
- tabBar.tintColor = chrome.tint ?? view.tintColor
1269
+ tabBar.tintColor = chrome.tint ?? config.tintColor.flatMap { UIColor(hex: $0) } ?? view.tintColor
1269
1270
  let hidden = !chrome.tabBarVisible || chrome.tabs.isEmpty
1270
1271
  if #available(iOS 18.0, *) { setTabBarHidden(hidden, animated: false) }
1271
1272
  else { tabBar.isHidden = hidden }
@@ -1411,7 +1412,10 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1411
1412
  accessoryGlass.frame = accessoryButton.frame
1412
1413
  let badgeWidth = max(18, accessoryBadge.intrinsicContentSize.width + 8)
1413
1414
  accessoryBadge.frame = CGRect(x: accessoryButton.frame.maxX - badgeWidth + 4, y: accessoryButton.frame.minY - 3, width: badgeWidth, height: 18)
1414
- CraftChrome.shared.layoutChanged(tabBar.isHidden ? 0 : max(0, view.bounds.maxY - tabBar.frame.minY) + (accessoryModel == nil ? 0 : (iconOnly ? 56 : 44)))
1415
+ // A compact action floats over the page, not across its whole width.
1416
+ // Only the older-system full-width shelf reserves space in web layout.
1417
+ let accessoryInset: CGFloat = accessoryModel != nil && !iconOnly ? 44 : 0
1418
+ CraftChrome.shared.layoutChanged(tabBar.isHidden ? 0 : max(0, view.bounds.maxY - tabBar.frame.minY) + accessoryInset)
1415
1419
  }
1416
1420
 
1417
1421
  private var webAttached = false
@@ -2273,10 +2277,11 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
2273
2277
  }
2274
2278
 
2275
2279
  private func safeAreaForTabBar() -> CGFloat {
2276
- // UIKit supplies the bar (and on iOS 26, its accessory) inset. Only
2277
- // the older-system accessory above that bar needs an extra inset.
2280
+ // UIKit supplies the bar (and on iOS 26, its standard accessory) inset.
2281
+ // Compact actions overlay content; only the older full-width shelf
2282
+ // needs an extra inset.
2278
2283
  let chrome = CraftChrome.shared
2279
- if chrome.tabBarVisible && !chrome.tabs.isEmpty && chrome.tabAccessory?.iconOnly == true { return 56 }
2284
+ if chrome.tabAccessory?.iconOnly == true { return 0 }
2280
2285
  #if compiler(>=6.2)
2281
2286
  if #available(iOS 26.0, *) { return 0 }
2282
2287
  #endif
@@ -278,13 +278,13 @@ enum CraftNativeActions {
278
278
  case ("Files", "pickVideo"):
279
279
  let options = args.first as? [String: Any] ?? [:]
280
280
  DispatchQueue.main.async {
281
- CraftNativeFiles.pickVideo(options) { completeFile(requestToken, $0, completion) }
281
+ CraftNativeFiles.pickVideo(options, isCancelled: { isCancelled(requestToken) }) { completeFile(requestToken, $0, completion) }
282
282
  }
283
283
  return
284
284
  case ("Files", "pickFile"):
285
285
  let options = args.first as? [String: Any] ?? [:]
286
286
  DispatchQueue.main.async {
287
- CraftNativeFiles.pickFile(options) { completeFile(requestToken, $0, completion) }
287
+ CraftNativeFiles.pickFile(options, isCancelled: { isCancelled(requestToken) }) { completeFile(requestToken, $0, completion) }
288
288
  }
289
289
  return
290
290
  case ("Files", "download"):
@@ -700,6 +700,13 @@ enum CraftNativeActions {
700
700
  }
701
701
  }
702
702
 
703
+ /// Read without consuming the token: final delivery owns its removal.
704
+ static func isCancelled(_ requestToken: String) -> Bool {
705
+ cancellationLock.lock()
706
+ defer { cancellationLock.unlock() }
707
+ return cancelledRequests.contains(requestToken)
708
+ }
709
+
703
710
  static func cancel(requestToken: String) {
704
711
  cancellationLock.lock()
705
712
  cancelledRequests.insert(requestToken)
@@ -1836,7 +1843,9 @@ enum CraftSnapshots {
1836
1843
  revisions[name] = mine
1837
1844
  pending[name] = .some(json)
1838
1845
  cache.removeObject(forKey: name as NSString)
1839
- lock.unlock()
1846
+ // Admission and queue submission are one ordering boundary. Unlocking
1847
+ // before enqueue lets an older caller overtake a newer revision on disk
1848
+ // while the cache still retains the newer value.
1840
1849
  queue.async {
1841
1850
  var ok = true
1842
1851
  do {
@@ -1863,6 +1872,7 @@ enum CraftSnapshots {
1863
1872
  completion?(ok)
1864
1873
  }
1865
1874
  }
1875
+ lock.unlock()
1866
1876
  }
1867
1877
 
1868
1878
  /// Every snapshot there is: on disk, and written but not on disk yet.
@@ -1,4 +1,5 @@
1
1
  import CryptoKit
2
+ import Darwin
2
3
  import Foundation
3
4
  import ImageIO
4
5
  import Network
@@ -74,11 +75,21 @@ enum CraftNativeFiles {
74
75
  return info
75
76
  }
76
77
 
77
- /// Forget a file made here. True when there was one.
78
+ /// Forget only the requested owned file. True only when it was removed;
79
+ /// pruning its now-empty directory is best effort, not recursive deletion.
78
80
  @discardableResult
79
81
  static func remove(_ path: String) -> Bool {
80
82
  guard let url = owned(path) else { return false }
81
- try? FileManager.default.removeItem(at: url.deletingLastPathComponent())
83
+ // unlink cannot recursively remove a directory if the entry changes
84
+ // after ownership validation. A denied or already-gone file is false.
85
+ guard url.path.withCString({ unlink($0) }) == 0 else { return false }
86
+ let parent = url.deletingLastPathComponent()
87
+ let base = root.standardizedFileURL.resolvingSymlinksInPath()
88
+ if parent.path != base.path {
89
+ // rmdir refuses nonempty directories, even if a sibling appears
90
+ // concurrently. Never recursively delete an imported file's peers.
91
+ _ = parent.path.withCString { rmdir($0) }
92
+ }
82
93
  return true
83
94
  }
84
95
 
@@ -298,7 +309,8 @@ enum CraftNativeFiles {
298
309
 
299
310
  /// System pickers grant access to just the selected video. Copy it while
300
311
  /// the provider's URL is valid; no movie bytes cross JavaScriptCore.
301
- static func pickVideo(_ options: [String: Any], done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
312
+ static func pickVideo(_ options: [String: Any], isCancelled: @escaping () -> Bool = { false }, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
313
+ guard !isCancelled() else { done(.failure(importCancelled())); return }
302
314
  guard activePicker == nil else {
303
315
  done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
304
316
  return
@@ -313,7 +325,7 @@ enum CraftNativeFiles {
313
325
  DispatchQueue.main.async { activePicker = nil; done(result) }
314
326
  }
315
327
  if options["source"] as? String == "files" {
316
- let delegate = VideoDocumentDelegate(limit: limit, done: finish)
328
+ let delegate = VideoDocumentDelegate(limit: limit, isCancelled: isCancelled, done: finish)
317
329
  var types: [UTType] = [.movie]
318
330
  if let webm = UTType(filenameExtension: "webm") { types.append(webm) }
319
331
  let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
@@ -329,7 +341,7 @@ enum CraftNativeFiles {
329
341
  configuration.filter = .videos
330
342
  configuration.selectionLimit = 1
331
343
  configuration.preferredAssetRepresentationMode = .current
332
- let delegate = VideoLibraryDelegate(limit: limit, done: finish)
344
+ let delegate = VideoLibraryDelegate(limit: limit, isCancelled: isCancelled, done: finish)
333
345
  let picker = PHPickerViewController(configuration: configuration)
334
346
  picker.delegate = delegate
335
347
  activePicker = delegate
@@ -345,7 +357,8 @@ enum CraftNativeFiles {
345
357
  /// Pick one or more documents and copy them into app-owned storage while
346
358
  /// the security-scoped URLs are valid. `contentTypes` accepts MIME types,
347
359
  /// UTIs, or filename extensions; an empty list means any document.
348
- static func pickFile(_ options: [String: Any], done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
360
+ static func pickFile(_ options: [String: Any], isCancelled: @escaping () -> Bool = { false }, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
361
+ guard !isCancelled() else { done(.failure(importCancelled())); return }
349
362
  guard activePicker == nil else {
350
363
  done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
351
364
  return
@@ -372,7 +385,7 @@ enum CraftNativeFiles {
372
385
  let finish: (Result<Any, CraftNativeActionError>) -> Void = { result in
373
386
  DispatchQueue.main.async { activePicker = nil; done(result) }
374
387
  }
375
- let delegate = FileDocumentDelegate(limit: limit, done: finish)
388
+ let delegate = FileDocumentDelegate(limit: limit, isCancelled: isCancelled, done: finish)
376
389
  let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
377
390
  picker.allowsMultipleSelection = selectionLimit > 1
378
391
  picker.delegate = delegate
@@ -409,28 +422,98 @@ enum CraftNativeFiles {
409
422
 
410
423
  /// Copy an arbitrary selected document into Craft's private file area and
411
424
  /// return the same descriptor used by downloads, writes, and uploads.
412
- static func copyFile(_ source: URL, limit: Int) -> Result<Any, CraftNativeActionError> {
425
+ static func copyFile(_ source: URL, limit: Int, isCancelled: () -> Bool = { false }) -> Result<Any, CraftNativeActionError> {
426
+ let contentType = (try? source.resourceValues(forKeys: [.contentTypeKey]))?.contentType
427
+ return copyImport(source, limit: limit, type: mimeType(for: source, contentType: contentType), isCancelled: isCancelled)
428
+ }
429
+
430
+ private static func importCancelled() -> CraftNativeActionError {
431
+ CraftNativeActionError(code: "CANCELLED", message: "The file import was cancelled")
432
+ }
433
+
434
+ /// Validate the opened file, not just its path metadata. Nonblocking open
435
+ /// avoids hanging on a FIFO; no-follow refuses symlinks. Metadata is only
436
+ /// an early rejection: enforce the limit on bytes actually read as well.
437
+ private static func copyImport(_ source: URL, limit: Int, type: String, isCancelled: () -> Bool) -> Result<Any, CraftNativeActionError> {
438
+ var destination: URL?
439
+ var succeeded = false
440
+ defer {
441
+ if !succeeded, let destination {
442
+ _ = destination.path.withCString { unlink($0) }
443
+ _ = destination.deletingLastPathComponent().path.withCString { rmdir($0) }
444
+ }
445
+ }
413
446
  do {
414
- let values = try source.resourceValues(forKeys: [.fileSizeKey, .contentTypeKey])
415
- let size: Int
416
- if let fileSize = values.fileSize {
417
- size = fileSize
418
- } else {
419
- size = ((try? FileManager.default.attributesOfItem(atPath: source.path))?[.size] as? NSNumber)?.intValue ?? 0
420
- }
421
- guard size > 0, size <= limit else {
422
- return .failure(CraftNativeActionError(code: "FILE_TOO_LARGE", message: "Choose a nonempty file under \(limit / 1024 / 1024)MB."))
423
- }
424
- let destination = try newFile(named: source.lastPathComponent)
425
- do { try FileManager.default.copyItem(at: source, to: destination) }
426
- catch { try? FileManager.default.removeItem(at: destination.deletingLastPathComponent()); throw error }
427
- let type = mimeType(for: source, contentType: values.contentType)
428
- return .success(describe(destination, type: type))
447
+ guard !isCancelled() else { throw importCancelled() }
448
+ guard source.isFileURL else {
449
+ throw CraftNativeActionError(code: "INVALID_FILE", message: "Choose a regular local file")
450
+ }
451
+ let descriptor = source.path.withCString { open($0, O_RDONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC) }
452
+ guard descriptor >= 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) }
453
+ let input = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true)
454
+ defer { try? input.close() }
455
+ var metadata = stat()
456
+ guard fstat(descriptor, &metadata) == 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) }
457
+ guard metadata.st_mode & S_IFMT == S_IFREG else {
458
+ throw CraftNativeActionError(code: "INVALID_FILE", message: "Choose a regular file, not a directory or special file")
459
+ }
460
+ let oversized = CraftNativeActionError(code: "FILE_TOO_LARGE", message: "Choose a nonempty file no larger than \(limit) bytes.")
461
+ guard limit > 0, metadata.st_size > 0, metadata.st_size <= limit else { throw oversized }
462
+ let target = try newFile(named: source.lastPathComponent)
463
+ destination = target
464
+ guard FileManager.default.createFile(atPath: target.path, contents: nil) else {
465
+ throw CraftNativeActionError(code: "WRITE_ERROR", message: "The imported file could not be created")
466
+ }
467
+ let output = try FileHandle(forWritingTo: target)
468
+ defer { try? output.close() }
469
+ var total = 0
470
+ while true {
471
+ guard !isCancelled() else { throw importCancelled() }
472
+ // At the limit, probe one more byte without writing it. This
473
+ // also avoids overflowing limit + 1 for an Int.max caller.
474
+ let chunk = try input.read(upToCount: max(1, min(64 * 1024, limit - total))) ?? Data()
475
+ guard !isCancelled() else { throw importCancelled() }
476
+ if chunk.isEmpty { break }
477
+ guard chunk.count <= limit - total else { throw oversized }
478
+ try output.write(contentsOf: chunk)
479
+ total += chunk.count
480
+ }
481
+ guard total > 0 else { throw oversized }
482
+ try output.close()
483
+ guard !isCancelled() else { throw importCancelled() }
484
+ succeeded = true
485
+ return .success(describe(target, type: type))
429
486
  } catch {
430
- return .failure(CraftNativeActionError(code: "READ_ERROR", message: error.localizedDescription))
487
+ return .failure((error as? CraftNativeActionError) ?? CraftNativeActionError(code: "READ_ERROR", message: error.localizedDescription))
431
488
  }
432
489
  }
433
490
 
491
+ /// All-or-nothing ownership for a multi-document selection. Security scope
492
+ /// lasts through each copy; cancellation also rolls back earlier results.
493
+ static func copyFiles(_ sources: [URL], limit: Int, isCancelled: () -> Bool = { false }) -> Result<Any, CraftNativeActionError> {
494
+ var results: [[String: Any]] = []
495
+ var succeeded = false
496
+ defer {
497
+ if !succeeded { results.compactMap { $0["path"] as? String }.forEach { remove($0) } }
498
+ }
499
+ for source in sources {
500
+ guard !isCancelled() else { return .failure(importCancelled()) }
501
+ let granted = source.startAccessingSecurityScopedResource()
502
+ defer { if granted { source.stopAccessingSecurityScopedResource() } }
503
+ switch copyFile(source, limit: limit, isCancelled: isCancelled) {
504
+ case .success(let value):
505
+ guard let info = value as? [String: Any] else {
506
+ return .failure(CraftNativeActionError(code: "READ_ERROR", message: "The selected file could not be described"))
507
+ }
508
+ results.append(info)
509
+ case .failure(let error): return .failure(error)
510
+ }
511
+ }
512
+ guard !isCancelled() else { return .failure(importCancelled()) }
513
+ succeeded = true
514
+ return .success(results)
515
+ }
516
+
434
517
  private static func mimeType(for source: URL, contentType: UTType?) -> String {
435
518
  let generic = "application/octet-stream"
436
519
  if let preferred = contentType?.preferredMIMEType, preferred != generic { return preferred }
@@ -450,82 +533,60 @@ enum CraftNativeFiles {
450
533
 
451
534
  private final class FileDocumentDelegate: NSObject, UIDocumentPickerDelegate {
452
535
  let limit: Int
536
+ let isCancelled: () -> Bool
453
537
  let done: (Result<Any, CraftNativeActionError>) -> Void
454
- init(limit: Int, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.done = done }
538
+ init(limit: Int, isCancelled: @escaping () -> Bool, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.isCancelled = isCancelled; self.done = done }
455
539
  func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { done(.success([])) }
456
540
  func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
457
541
  guard !urls.isEmpty else { done(.success([])); return }
458
- let (limit, done) = (self.limit, self.done)
542
+ let (limit, isCancelled, done) = (self.limit, self.isCancelled, self.done)
459
543
  DispatchQueue.global(qos: .userInitiated).async {
460
- var results: [[String: Any]] = []
461
- for url in urls {
462
- let granted = url.startAccessingSecurityScopedResource()
463
- defer { if granted { url.stopAccessingSecurityScopedResource() } }
464
- switch CraftNativeFiles.copyFile(url, limit: limit) {
465
- case .success(let value):
466
- guard let info = value as? [String: Any] else { done(.failure(CraftNativeActionError(code: "READ_ERROR", message: "The selected file could not be described"))); return }
467
- results.append(info)
468
- case .failure(let error):
469
- results.compactMap { $0["path"] as? String }.forEach { CraftNativeFiles.remove($0) }
470
- done(.failure(error))
471
- return
472
- }
473
- }
474
- done(.success(results))
544
+ done(CraftNativeFiles.copyFiles(urls, limit: limit, isCancelled: isCancelled))
475
545
  }
476
546
  }
477
547
  }
478
548
 
479
- static func copyVideo(_ source: URL, limit: Int) -> Result<Any, CraftNativeActionError> {
549
+ static func copyVideo(_ source: URL, limit: Int, isCancelled: () -> Bool = { false }) -> Result<Any, CraftNativeActionError> {
480
550
  let ext = source.pathExtension.lowercased()
481
551
  let types = ["mp4": "video/mp4", "m4v": "video/x-m4v", "mov": "video/quicktime", "webm": "video/webm"]
482
552
  guard let type = types[ext] else {
483
553
  return .failure(CraftNativeActionError(code: "INVALID_VIDEO", message: "Choose an MP4, MOV or WebM video."))
484
554
  }
485
- do {
486
- let size = ((try FileManager.default.attributesOfItem(atPath: source.path))[.size] as? NSNumber)?.intValue ?? 0
487
- guard size > 0, size <= limit else {
488
- return .failure(CraftNativeActionError(code: "FILE_TOO_LARGE", message: "Choose a nonempty video under \(limit / 1024 / 1024)MB."))
489
- }
490
- let destination = try newFile(named: source.lastPathComponent)
491
- do { try FileManager.default.copyItem(at: source, to: destination) }
492
- catch { try? FileManager.default.removeItem(at: destination.deletingLastPathComponent()); throw error }
493
- return .success(describe(destination, type: type))
494
- } catch {
495
- return .failure(CraftNativeActionError(code: "READ_ERROR", message: error.localizedDescription))
496
- }
555
+ return copyImport(source, limit: limit, type: type, isCancelled: isCancelled)
497
556
  }
498
557
 
499
558
  private final class VideoLibraryDelegate: NSObject, PHPickerViewControllerDelegate {
500
559
  let limit: Int
560
+ let isCancelled: () -> Bool
501
561
  let done: (Result<Any, CraftNativeActionError>) -> Void
502
- init(limit: Int, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.done = done }
562
+ init(limit: Int, isCancelled: @escaping () -> Bool, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.isCancelled = isCancelled; self.done = done }
503
563
  func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
504
564
  picker.dismiss(animated: true)
505
565
  guard let provider = results.first?.itemProvider else { done(.success(NSNull())); return }
506
- let (limit, done) = (self.limit, self.done)
566
+ let (limit, isCancelled, done) = (self.limit, self.isCancelled, self.done)
507
567
  provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
508
568
  guard let url else {
509
569
  done(.failure(CraftNativeActionError(code: "READ_ERROR", message: error?.localizedDescription ?? "The video could not be read")))
510
570
  return
511
571
  }
512
- done(CraftNativeFiles.copyVideo(url, limit: limit))
572
+ done(CraftNativeFiles.copyVideo(url, limit: limit, isCancelled: isCancelled))
513
573
  }
514
574
  }
515
575
  }
516
576
 
517
577
  private final class VideoDocumentDelegate: NSObject, UIDocumentPickerDelegate {
518
578
  let limit: Int
579
+ let isCancelled: () -> Bool
519
580
  let done: (Result<Any, CraftNativeActionError>) -> Void
520
- init(limit: Int, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.done = done }
581
+ init(limit: Int, isCancelled: @escaping () -> Bool, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.isCancelled = isCancelled; self.done = done }
521
582
  func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { done(.success(NSNull())) }
522
583
  func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
523
584
  guard let url = urls.first else { done(.success(NSNull())); return }
524
- let (limit, done) = (self.limit, self.done)
585
+ let (limit, isCancelled, done) = (self.limit, self.isCancelled, self.done)
525
586
  DispatchQueue.global(qos: .userInitiated).async {
526
587
  let granted = url.startAccessingSecurityScopedResource()
527
588
  defer { if granted { url.stopAccessingSecurityScopedResource() } }
528
- done(CraftNativeFiles.copyVideo(url, limit: limit))
589
+ done(CraftNativeFiles.copyVideo(url, limit: limit, isCancelled: isCancelled))
529
590
  }
530
591
  }
531
592
  }
@@ -15,7 +15,7 @@ import UIKit
15
15
 
16
16
  private let craftNativeCapabilityTimeoutMilliseconds = 30_000
17
17
 
18
- /// A retained JavaScript context must lose synchronous storage access when
18
+ /// A retained JavaScript context must lose storage and snapshot access when
19
19
  /// its screen closes. Only the actual synchronous unmount callback may finish
20
20
  /// its cleanup; queued script work runs after that allowance ends.
21
21
  private final class CraftNativeStorageLifetime {
@@ -4929,7 +4929,7 @@ final class CraftNativeSvgView: UIView {
4929
4929
  private func updateFrames() {
4930
4930
  if isAnimating {
4931
4931
  if animationStart == nil { animationStart = CACurrentMediaTime() }
4932
- CraftNativeFrameClock.shared.request(self) { [weak self] in self?.nextFrame() }
4932
+ CraftNativeFrameClock.shared.request(self) { [weak self] _ in self?.nextFrame() }
4933
4933
  } else {
4934
4934
  animationStart = nil
4935
4935
  CraftNativeFrameClock.shared.cancel(self)
@@ -4941,7 +4941,7 @@ final class CraftNativeSvgView: UIView {
4941
4941
  // Drawn only while some of it is on screen; a row scrolled away keeps
4942
4942
  // its time and is right when it comes back.
4943
4943
  if let window, window.bounds.intersects(convert(bounds, to: window)) { setNeedsDisplay() }
4944
- CraftNativeFrameClock.shared.request(self) { [weak self] in self?.nextFrame() }
4944
+ CraftNativeFrameClock.shared.request(self) { [weak self] _ in self?.nextFrame() }
4945
4945
  }
4946
4946
 
4947
4947
  private static func hasAnimation(_ children: [Any]) -> Bool {
@@ -5713,8 +5713,10 @@ extension CAAnimation {
5713
5713
  final class CraftNativeFrameClock: NSObject {
5714
5714
  static let shared = CraftNativeFrameClock()
5715
5715
 
5716
- private var waiting: [ObjectIdentifier: () -> Void] = [:]
5716
+ private var waiting: [ObjectIdentifier: (CFTimeInterval) -> Void] = [:]
5717
5717
  private var link: CADisplayLink?
5718
+ /// The display's monotonic timestamp, captured before handing off scripts.
5719
+ private var lastTimestamp: CFTimeInterval = 0
5718
5720
 
5719
5721
  /// How many screens wait for the next frame.
5720
5722
  var waitingCount: Int { waiting.count }
@@ -5722,7 +5724,7 @@ final class CraftNativeFrameClock: NSObject {
5722
5724
  var isRunning: Bool { link.map { !$0.isPaused } ?? false }
5723
5725
 
5724
5726
  /// `deliver` on the next frame, once. Asking again before it comes keeps one.
5725
- func request(_ owner: AnyObject, _ deliver: @escaping () -> Void) {
5727
+ func request(_ owner: AnyObject, _ deliver: @escaping (CFTimeInterval) -> Void) {
5726
5728
  waiting[ObjectIdentifier(owner)] = deliver
5727
5729
  if link == nil {
5728
5730
  let link = CADisplayLink(target: self, selector: #selector(frame(_:)))
@@ -5742,10 +5744,15 @@ final class CraftNativeFrameClock: NSObject {
5742
5744
  }
5743
5745
 
5744
5746
  @objc private func frame(_ link: CADisplayLink) {
5747
+ let timestamp = link.timestamp
5748
+ // Keep waiting for a new vsync rather than delivering a duplicate or
5749
+ // stale display timestamp after the link is paused and requested again.
5750
+ guard timestamp.isFinite, timestamp > lastTimestamp else { return }
5751
+ lastTimestamp = timestamp
5745
5752
  let due = waiting
5746
5753
  waiting.removeAll()
5747
5754
  if waiting.isEmpty { link.isPaused = true }
5748
- due.values.forEach { $0() }
5755
+ due.values.forEach { $0(timestamp) }
5749
5756
  }
5750
5757
  }
5751
5758
 
@@ -6015,6 +6022,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6015
6022
  /// The script asked for an animation frame it has not had yet
6016
6023
  /// (`requestAnimationFrame`, CraftNativeFrameClock). Main thread only.
6017
6024
  private var frameWanted = false
6025
+ private let frameTimeOrigin = CACurrentMediaTime()
6026
+ private var lastFrameTimestamp = -Double.infinity
6027
+ /// Vsync is delivered on main but may wait on the script queue. Closing
6028
+ /// on main invalidates queued delivery without retaining the controller
6029
+ /// or holding a lock while JavaScript calls synchronous UIKit APIs.
6030
+ private final class FrameDelivery {
6031
+ private let lock = NSLock()
6032
+ private var open = true
6033
+ var isOpen: Bool { lock.withLock { open } }
6034
+ func close() { lock.withLock { open = false } }
6035
+ }
6036
+ private let frameDelivery = FrameDelivery()
6018
6037
  private var lifecycleObservers: [NSObjectProtocol] = []
6019
6038
  private var deepLinkListener: UUID?
6020
6039
  private var keyboardSafeAreaInset: CGFloat = 0
@@ -6070,6 +6089,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6070
6089
  CraftInterfaceStyleController.apply(to: self, config: config)
6071
6090
  navigationItem.title = routeName ?? config.appName
6072
6091
  view.backgroundColor = config.resolvedBackgroundColor
6092
+ if let tint = config.tintColor.flatMap({ UIColor(hex: $0) }) { view.tintColor = tint }
6073
6093
  rootStack.axis = .vertical
6074
6094
  rootStack.alignItems = "stretch"
6075
6095
  rootStack.translatesAutoresizingMaskIntoConstraints = false
@@ -6214,15 +6234,34 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6214
6234
  guard !runtimeDisposed, !frameWanted else { return }
6215
6235
  frameWanted = true
6216
6236
  guard isAppScript || reportedVisible != false else { return }
6217
- CraftNativeFrameClock.shared.request(self) { [weak self] in self?.fireFrame() }
6237
+ CraftNativeFrameClock.shared.request(self) { [weak self] timestamp in self?.fireFrame(timestamp: timestamp) }
6218
6238
  }
6219
6239
 
6220
- private func fireFrame() {
6240
+ // Native display-delivery boundary; internal so the native fixture can
6241
+ // exercise stale startup ticks without depending on simulator timing.
6242
+ func fireFrame(timestamp displayTimestamp: CFTimeInterval) {
6221
6243
  guard frameWanted, !runtimeDisposed else { return }
6244
+ // Another screen's callback can hide this screen after the clock has
6245
+ // captured its batch. Keep the request pending until it is shown.
6246
+ guard isAppScript || reportedVisible != false else { return }
6247
+ let timestamp = (displayTimestamp - frameTimeOrigin) * 1000
6248
+ // A display tick may predate a newly created controller. Clamping
6249
+ // those ticks to zero delivers multiple frames at the same time.
6250
+ // Wait for a valid tick instead, keeping the JS batch cancellable.
6251
+ guard displayTimestamp.isFinite, timestamp.isFinite, timestamp >= 0,
6252
+ timestamp > lastFrameTimestamp else {
6253
+ CraftNativeFrameClock.shared.request(self) { [weak self] timestamp in self?.fireFrame(timestamp: timestamp) }
6254
+ return
6255
+ }
6256
+ lastFrameTimestamp = timestamp
6222
6257
  frameWanted = false
6223
6258
  let context = jsContext
6224
6259
  let route = routeName ?? "screen"
6225
- CraftNativeScriptThread.run(label: "frame \(route)") { context.objectForKeyedSubscript("__craftNativeFireFrame")?.call(withArguments: []) }
6260
+ let delivery = frameDelivery
6261
+ CraftNativeScriptThread.run(label: "frame \(route)") {
6262
+ guard delivery.isOpen else { return }
6263
+ context.objectForKeyedSubscript("__craftNativeFireFrame")?.call(withArguments: [timestamp])
6264
+ }
6226
6265
  }
6227
6266
 
6228
6267
  /// The screen came on screen or went off it: a screen pushed over it was
@@ -6247,7 +6286,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6247
6286
  self.hasAppeared = true
6248
6287
  }
6249
6288
  }
6250
- if visible, frameWanted { CraftNativeFrameClock.shared.request(self) { [weak self] in self?.fireFrame() } }
6289
+ if visible, frameWanted { CraftNativeFrameClock.shared.request(self) { [weak self] timestamp in self?.fireFrame(timestamp: timestamp) } }
6251
6290
  if !visible { CraftNativeFrameClock.shared.cancel(self) }
6252
6291
  guard visible != reportedVisible else { return }
6253
6292
  let first = reportedVisible == nil
@@ -6276,6 +6315,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6276
6315
  private func disposeRuntime() {
6277
6316
  guard !runtimeDisposed else { return }
6278
6317
  runtimeDisposed = true
6318
+ frameDelivery.close()
6279
6319
  storageLifetime.close()
6280
6320
  pendingCapabilityDeadlines.values.forEach { $0.cancel() }
6281
6321
  pendingCapabilityDeadlines.removeAll()
@@ -6343,6 +6383,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6343
6383
 
6344
6384
  override func viewDidLayoutSubviews() {
6345
6385
  super.viewDidLayoutSubviews()
6386
+ // Containment, bar visibility and accessory insets can change after
6387
+ // rendering. Recheck once the scroll viewport has its actual frame.
6388
+ trackContentScrollView()
6346
6389
  positionModals()
6347
6390
  emitLayoutEvents()
6348
6391
  sendViewportIfChanged()
@@ -6588,28 +6631,28 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6588
6631
  if (typeof globalThis.requestAnimationFrame !== 'function') {
6589
6632
  (function() {
6590
6633
  var frames = [];
6591
- var cancelled = new Set();
6634
+ var callbacks = new Map();
6592
6635
  var nextFrame = 0;
6593
6636
  globalThis.requestAnimationFrame = function(callback) {
6594
6637
  if (typeof callback !== 'function') return 0;
6595
6638
  var id = ++nextFrame;
6596
- frames.push({ id: id, callback: callback });
6639
+ callbacks.set(id, callback);
6640
+ frames.push(id);
6597
6641
  if (frames.length === 1) craftNativeRequestFrame();
6598
6642
  return id;
6599
6643
  };
6600
6644
  globalThis.cancelAnimationFrame = function(id) {
6601
- if (frames.some(function(frame) { return frame.id === id; })) cancelled.add(id);
6645
+ callbacks.delete(id);
6602
6646
  };
6603
- globalThis.__craftNativeFireFrame = function() {
6647
+ globalThis.__craftNativeFireFrame = function(now) {
6604
6648
  var due = frames;
6605
- var skip = cancelled;
6606
6649
  frames = [];
6607
- cancelled = new Set();
6608
- var now = globalThis.performance && typeof globalThis.performance.now === 'function' ? globalThis.performance.now() : Date.now();
6609
6650
  var failures = [];
6610
6651
  for (var i = 0; i < due.length; i++) {
6611
- if (skip.has(due[i].id)) continue;
6612
- try { due[i].callback(now); } catch (error) { failures.push(error); }
6652
+ var callback = callbacks.get(due[i]);
6653
+ callbacks.delete(due[i]);
6654
+ if (!callback) continue;
6655
+ try { callback(now); } catch (error) { failures.push(error); }
6613
6656
  }
6614
6657
  failures.forEach(function(error) { globalThis.setTimeout(function() { throw error; }, 0); });
6615
6658
  };
@@ -6773,26 +6816,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6773
6816
  }
6774
6817
  }
6775
6818
  let snapshotGet: @convention(block) (String) -> String? = { name in
6776
- CraftSnapshots.read(name)
6819
+ storageLifetime.access(closedValue: nil) { CraftSnapshots.read(name) }
6777
6820
  }
6778
6821
  // JavaScriptCore retains these outer host blocks. A weak capture only
6779
6822
  // on the nested completion still makes the host block retain self,
6780
6823
  // forming controller -> JSContext -> host block -> controller.
6781
6824
  let snapshotSet: @convention(block) (String, JSValue, JSValue) -> Void = { [weak self] name, value, done in
6782
- CraftSnapshots.write(name, json: value.isNull || value.isUndefined ? nil : value.toString()) { [weak self] ok in
6783
- CraftNativeScriptThread.run {
6784
- // A writer can finish after UIKit has disposed this
6785
- // screen. Resolve the pending promise as a cancellation,
6786
- // never as a successful write into a dead runtime.
6787
- done.call(withArguments: [self?.runtimeDisposed != false ? false : ok])
6825
+ let json = value.isNull || value.isUndefined ? nil : value.toString()
6826
+ // Refuse before queueing disk work. Rejecting only its completion
6827
+ // still lets a closed screen overwrite another screen's checkpoint.
6828
+ storageLifetime.access(closedValue: ()) {
6829
+ CraftSnapshots.write(name, json: json) { [weak self] ok in
6830
+ CraftNativeScriptThread.run {
6831
+ // A writer can finish after UIKit has disposed this
6832
+ // screen. Resolve the pending promise as a cancellation,
6833
+ // never as a successful write into a dead runtime.
6834
+ done.call(withArguments: [self?.runtimeDisposed != false ? false : ok])
6835
+ }
6788
6836
  }
6789
6837
  }
6790
6838
  }
6791
6839
  let snapshotClear: @convention(block) (JSValue, JSValue) -> Void = { [weak self] keep, done in
6792
6840
  let names = (keep.isUndefined || keep.isNull ? [] : (keep.toArray() ?? [])).compactMap { $0 as? String }
6793
- CraftSnapshots.clear(keeping: Set(names)) { [weak self] ok in
6794
- CraftNativeScriptThread.run {
6795
- done.call(withArguments: [self?.runtimeDisposed != false ? false : ok])
6841
+ storageLifetime.access(closedValue: ()) {
6842
+ CraftSnapshots.clear(keeping: Set(names)) { [weak self] ok in
6843
+ CraftNativeScriptThread.run {
6844
+ done.call(withArguments: [self?.runtimeDisposed != false ? false : ok])
6845
+ }
6796
6846
  }
6797
6847
  }
6798
6848
  }
@@ -7576,10 +7626,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7576
7626
  let edgeToEdge = scroll != nil && (prefersLargeTitle == true || scroll === leadingScroll?.view)
7577
7627
  let fullEdgeToEdge = rootIsSafeAreaView
7578
7628
  let topEdgeToEdge = fullEdgeToEdge || edgeToEdge
7629
+ // Only a leading vertical scroller that reaches the root's bottom
7630
+ // can paint beneath a visible tab bar. A bounded list, fixed footer,
7631
+ // plain screen or presented sheet must retain its safe-area bottom.
7632
+ // Use UIKit's automatic inset for tab/accessory clearance, rather
7633
+ // than adding a second explicit inset (also owned by keyboard code).
7634
+ var tabbedScroll = false
7635
+ if let scroll, scroll === leadingScroll?.view, scroll.bounds.height > 0,
7636
+ let tabBar = tabBarController?.tabBar, !tabBar.isHidden, tabBar.alpha > 0,
7637
+ let window = view.window, tabBar.window === window,
7638
+ tabBar.convert(tabBar.bounds, to: view).intersects(view.bounds) {
7639
+ let viewport = scroll.convert(scroll.bounds, to: rootStack)
7640
+ tabbedScroll = abs(viewport.maxY - rootStack.bounds.maxY) < 0.5
7641
+ }
7642
+ let bottomEdgeToEdge = fullEdgeToEdge || tabbedScroll
7579
7643
  rootTopToSafeArea?.isActive = !topEdgeToEdge
7580
7644
  rootTopToView?.isActive = topEdgeToEdge
7581
- rootBottomToSafeArea?.isActive = !fullEdgeToEdge
7582
- rootBottomToView?.isActive = fullEdgeToEdge
7645
+ if bottomEdgeToEdge {
7646
+ rootBottomToSafeArea?.isActive = false
7647
+ rootBottomToView?.isActive = true
7648
+ } else {
7649
+ rootBottomToView?.isActive = false
7650
+ rootBottomToSafeArea?.isActive = true
7651
+ }
7583
7652
  rootLeadingToSafeArea?.isActive = !fullEdgeToEdge
7584
7653
  rootLeadingToView?.isActive = fullEdgeToEdge
7585
7654
  rootTrailingToSafeArea?.isActive = !fullEdgeToEdge
@@ -7769,6 +7838,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7769
7838
  if let keyboard = keyboardScreenFrame, let window = scroll.window {
7770
7839
  let frame = scroll.convert(scroll.bounds, to: window)
7771
7840
  overlap = max(0, frame.maxY - window.convert(keyboard, from: nil).minY)
7841
+ // An edge-to-edge scroller already receives the keyboard's
7842
+ // safe-area share through UIKit. Add only uncovered overlap.
7843
+ let automaticBottom = max(0, scroll.adjustedContentInset.bottom - scroll.contentInset.bottom)
7844
+ overlap = max(0, overlap - automaticBottom)
7772
7845
  }
7773
7846
  if abs(scroll.contentInset.bottom - overlap) > 0.5 { scroll.contentInset.bottom = overlap }
7774
7847
  if abs(scroll.verticalScrollIndicatorInsets.bottom - overlap) > 0.5 { scroll.verticalScrollIndicatorInsets.bottom = overlap }
@@ -314,30 +314,44 @@ final class CraftNativeSounds {
314
314
  private static var category: AVAudioSession.Category = .ambient
315
315
 
316
316
  private var sounds: [String: Sound] = [:]
317
- private var interruptionObserver: NSObjectProtocol?
317
+ /// AVAudioSession belongs to the app, not to a screen. Observe for the
318
+ /// process lifetime once sounds are used, including gaps between screens.
319
+ /// Weak membership must not keep a disposed screen's service alive.
320
+ private static let services = NSHashTable<CraftNativeSounds>.weakObjects()
321
+ private static var sessionInterrupted = false
322
+ private static let interruptionObserver = NotificationCenter.default.addObserver(
323
+ forName: AVAudioSession.interruptionNotification,
324
+ object: nil,
325
+ queue: .main,
326
+ ) { note in CraftNativeSounds.handleInterruption(note) }
318
327
  /// `{ id, event: "ended" | "error", message? }` to the screen's script.
319
328
  var emit: (([String: Any]) -> Void)?
320
329
 
321
330
  init() {
322
- interruptionObserver = NotificationCenter.default.addObserver(
323
- forName: AVAudioSession.interruptionNotification,
324
- object: nil,
325
- queue: .main,
326
- ) { [weak self] note in
327
- guard let self else { return }
328
- let type = (note.userInfo?[AVAudioSessionInterruptionTypeKey] as? NSNumber)?.uintValue
329
- if type == AVAudioSession.InterruptionType.began.rawValue {
330
- self.sounds.values.forEach { sound in
331
+ _ = Self.interruptionObserver
332
+ Self.services.add(self)
333
+ }
334
+
335
+ /// Sound services and their playback controls are used on main.
336
+ private static func handleInterruption(_ note: Notification) {
337
+ let type = (note.userInfo?[AVAudioSessionInterruptionTypeKey] as? NSNumber)?.uintValue
338
+ if type == AVAudioSession.InterruptionType.began.rawValue {
339
+ sessionInterrupted = true
340
+ services.allObjects.forEach { service in
341
+ service.sounds.values.forEach { sound in
331
342
  guard !sound.paused else { return }
332
343
  sound.interrupted = true
333
344
  sound.player.pause()
334
345
  }
335
- return
336
346
  }
337
- guard type == AVAudioSession.InterruptionType.ended.rawValue else { return }
338
- let options = (note.userInfo?[AVAudioSessionInterruptionOptionKey] as? NSNumber)?.uintValue ?? 0
339
- let shouldResume = options & AVAudioSession.InterruptionOptions.shouldResume.rawValue != 0
340
- self.sounds.values.forEach { sound in
347
+ return
348
+ }
349
+ guard type == AVAudioSession.InterruptionType.ended.rawValue else { return }
350
+ sessionInterrupted = false
351
+ let options = (note.userInfo?[AVAudioSessionInterruptionOptionKey] as? NSNumber)?.uintValue ?? 0
352
+ let shouldResume = options & AVAudioSession.InterruptionOptions.shouldResume.rawValue != 0
353
+ services.allObjects.forEach { service in
354
+ service.sounds.values.forEach { sound in
341
355
  guard sound.interrupted else { return }
342
356
  sound.interrupted = false
343
357
  // Without permission to auto-resume, keep a resumable paused
@@ -348,10 +362,6 @@ final class CraftNativeSounds {
348
362
  }
349
363
  }
350
364
 
351
- deinit {
352
- if let interruptionObserver { NotificationCenter.default.removeObserver(interruptionObserver) }
353
- }
354
-
355
365
  /// Starts `url`. `answer` hears once it plays (or could not be loaded).
356
366
  func play(id: String, url: URL, options: [String: Any], answer: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
357
367
  stop(id: id, fadeOut: 0)
@@ -364,6 +374,7 @@ final class CraftNativeSounds {
364
374
  let item = AVPlayerItem(url: url)
365
375
  let player = AVQueuePlayer()
366
376
  let sound = Sound(player: player, volume: volume)
377
+ sound.interrupted = Self.sessionInterrupted
367
378
  sound.pending = answer
368
379
  sounds[id] = sound
369
380
  let failed: (String) -> Void = { [weak self, weak sound] message in
@@ -397,7 +408,7 @@ final class CraftNativeSounds {
397
408
  }
398
409
  }
399
410
  player.volume = fadeIn > 0 ? 0 : volume
400
- player.play()
411
+ if !Self.sessionInterrupted { player.play() }
401
412
  if fadeIn > 0 { ramp(sound, to: volume, over: fadeIn) }
402
413
  }
403
414
 
@@ -413,7 +424,7 @@ final class CraftNativeSounds {
413
424
 
414
425
  @discardableResult
415
426
  func resume(id: String) -> Bool {
416
- guard let sound = sounds[id], sound.paused else { return false }
427
+ guard !Self.sessionInterrupted, let sound = sounds[id], sound.paused else { return false }
417
428
  sound.paused = false
418
429
  sound.interrupted = false
419
430
  sound.player.volume = sound.volume
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.377",
3
+ "version": "0.0.380",
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>",