craft-native 0.0.449 → 0.0.464

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/android/src/index.d.ts +8 -0
  2. package/dist/android/src/index.js +227 -30
  3. package/dist/android/src/testing.d.ts +1 -1
  4. package/dist/android/templates/CraftNativeBackground.kt.template +65 -25
  5. package/dist/android/templates/CraftNativeCapabilities.kt.template +3 -1
  6. package/dist/android/templates/CraftNativeMessagingService.kt.template +8 -28
  7. package/dist/android/templates/CraftNativeNotificationReceiver.kt.template +213 -15
  8. package/dist/android/templates/CraftNativeServices.kt.template +6 -4
  9. package/dist/android/templates/CraftNativeShell.kt.template +72 -21
  10. package/dist/android/templates/MainActivityNative.kt.template +227 -12
  11. package/dist/cli.js +1 -1
  12. package/dist/components/index.d.ts +18 -0
  13. package/dist/index.cjs +1 -1
  14. package/dist/index.js +1 -1
  15. package/dist/ios/src/index.d.ts +13 -14
  16. package/dist/ios/src/index.js +292 -81
  17. package/dist/ios/templates/CraftApp.swift +118 -16
  18. package/dist/ios/templates/CraftHybrid.swift +75 -5
  19. package/dist/ios/templates/CraftNativeActions.swift +84 -13
  20. package/dist/ios/templates/CraftNativeFiles.swift +83 -1
  21. package/dist/ios/templates/CraftNativeScreen.swift +130 -4
  22. package/dist/ios/templates/CraftNotificationService.swift +199 -0
  23. package/dist/ios/templates/CraftVoiceRecording.swift +29 -1
  24. package/dist/ios/templates/testing/DriverTests.swift.template +61 -6
  25. package/dist/mobile.d.ts +2 -0
  26. package/dist/mobile.js +120 -0
  27. package/dist/native-notifications.d.ts +17 -0
  28. package/dist/native-routing.d.ts +26 -0
  29. package/dist/testing/media-cache/media-cache.kt +28 -2
  30. package/dist/testing/media-cache/media-cache.swift.fixture +18 -4
  31. package/dist/typescript/src/native-notifications.d.ts +17 -0
  32. package/dist/typescript/src/native-routing.d.ts +26 -0
  33. package/package.json +1 -1
@@ -6,6 +6,72 @@ import Network
6
6
  import PhotosUI
7
7
  import UIKit
8
8
  import UniformTypeIdentifiers
9
+ import UserNotifications
10
+
11
+ /// UserNotifications takes ownership of attachment files. Never give it a
12
+ /// picker/download original: stage copies and retain them until `add` answers.
13
+ final class CraftNotificationAttachments {
14
+ let attachments: [UNNotificationAttachment]
15
+ let directory: URL?
16
+
17
+ init(_ raw: Any?) throws {
18
+ guard let raw else { attachments = []; directory = nil; return }
19
+ func invalid(_ message: String) -> NSError {
20
+ NSError(domain: "CraftNotification", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
21
+ }
22
+ guard let entries = raw as? [[String: Any]], entries.count <= 10 else {
23
+ throw invalid("attachments must be an array of at most ten { id, path } entries")
24
+ }
25
+ var identifiers = Set<String>()
26
+ let sources: [(String, URL)] = try entries.map { entry in
27
+ guard let id = entry["id"] as? String, !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
28
+ identifiers.insert(id).inserted,
29
+ let path = entry["path"] as? String, let url = CraftNativeFiles.owned(path) else {
30
+ throw invalid("Each attachment needs a unique nonempty id and an existing craft.files path")
31
+ }
32
+ let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
33
+ guard attributes[.type] as? FileAttributeType == .typeRegular,
34
+ let size = attributes[.size] as? NSNumber, size.int64Value > 0, size.int64Value <= 50 * 1024 * 1024 else {
35
+ throw invalid("An attachment must be a nonempty regular file no larger than 50 MiB")
36
+ }
37
+ return (id, url)
38
+ }
39
+ guard !sources.isEmpty else { attachments = []; directory = nil; return }
40
+ let folder = FileManager.default.temporaryDirectory
41
+ .appendingPathComponent("craft-notification-\(UUID().uuidString)", isDirectory: true)
42
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
43
+ do {
44
+ attachments = try sources.map { id, source in
45
+ // Use generated names, keeping the extension for iOS type detection.
46
+ let copy = folder.appendingPathComponent(UUID().uuidString).appendingPathExtension(source.pathExtension)
47
+ try FileManager.default.copyItem(at: source, to: copy)
48
+ // iOS validates supported media, including the lower image/audio
49
+ // limits. A failure rejects the entire notification, never a subset.
50
+ return try UNNotificationAttachment(identifier: id, url: copy, options: nil)
51
+ }
52
+ directory = folder
53
+ } catch {
54
+ try? FileManager.default.removeItem(at: folder)
55
+ throw error
56
+ }
57
+ }
58
+
59
+ func cleanup() {
60
+ // Only our staging folder, never attachment.url (which iOS can move).
61
+ if let directory { try? FileManager.default.removeItem(at: directory) }
62
+ }
63
+
64
+ func schedule(_ request: UNNotificationRequest,
65
+ submit: (UNNotificationRequest, @escaping (Error?) -> Void) -> Void = { UNUserNotificationCenter.current().add($0, withCompletionHandler: $1) },
66
+ completion: @escaping (Error?) -> Void) {
67
+ submit(request) { error in
68
+ self.cleanup()
69
+ completion(error)
70
+ }
71
+ }
72
+
73
+ deinit { cleanup() }
74
+ }
9
75
 
10
76
  // `craft.files` on a native screen: a photo picked from the library (or taken
11
77
  // with the camera), a file downloaded for the share sheet, and those files
@@ -847,6 +913,12 @@ enum CraftNativeFiles {
847
913
  // JavaScript. The screen decides what to keep (`keep`) and what to drop
848
914
  // (`prune`); nothing is evicted behind its back.
849
915
  enum CraftNativeMedia {
916
+ /// A caller-owned account + resource + version identity. Never used as a network URL.
917
+ static func identity(_ source: String, options: [String: Any]) -> String {
918
+ guard let key = options["cacheKey"] as? String, !key.isEmpty, key.utf8.count <= 1024 else { return source }
919
+ return "craft-media-key:" + key
920
+ }
921
+
850
922
  /// Foundation often wraps ENOSPC inside a URL or Cocoa file error.
851
923
  static func errorCode(_ error: Error, fallback: String) -> String {
852
924
  var cause: NSError? = error as NSError
@@ -1086,7 +1158,17 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
1086
1158
  lock.lock()
1087
1159
  let task: URLSessionDownloadTask
1088
1160
  if let earlier = restored.removeValue(forKey: address), earlier.state == .running || earlier.state == .suspended {
1089
- task = earlier
1161
+ if earlier.originalRequest?.url == request.url && earlier.originalRequest?.allHTTPHeaderFields == request.allHTTPHeaderFields {
1162
+ task = earlier
1163
+ }
1164
+ else {
1165
+ // A queued capability can expire while the app is suspended.
1166
+ // Retain its cache identity, replace only its authorized source.
1167
+ earlier.taskDescription = nil
1168
+ earlier.cancel()
1169
+ task = session.downloadTask(with: request)
1170
+ task.taskDescription = address
1171
+ }
1090
1172
  }
1091
1173
  else {
1092
1174
  task = session.downloadTask(with: request)
@@ -13,6 +13,92 @@ import Security
13
13
  import SwiftUI
14
14
  import UIKit
15
15
 
16
+ /// A menu belongs to its native view, not to coordinates in a web page.
17
+ final class CraftNativeContextMenu: NSObject, UIContextMenuInteractionDelegate {
18
+ private(set) var items: [[String: Any]] = []
19
+ private var dispatch: ((String) -> Void)?
20
+ private weak var view: UIView?
21
+ private(set) lazy var interaction = UIContextMenuInteraction(delegate: self)
22
+ private var revision = 0
23
+ private var handlerIdentity: String?
24
+
25
+ init(view: UIView) {
26
+ self.view = view
27
+ super.init()
28
+ view.addInteraction(interaction)
29
+ }
30
+
31
+ static func entries(_ raw: Any?) -> [[String: Any]] {
32
+ var seen = Set<String>()
33
+ return (raw as? [[String: Any]] ?? []).filter {
34
+ guard let id = $0["id"] as? String, !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
35
+ let title = $0["title"] as? String, !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false }
36
+ return seen.insert(id).inserted
37
+ }
38
+ }
39
+
40
+ func update(_ entries: [[String: Any]], handler: String? = nil, dispatch: @escaping (String) -> Void) {
41
+ if let handler, handler == handlerIdentity, NSArray(array: items).isEqual(to: entries) {
42
+ self.dispatch = dispatch
43
+ return
44
+ }
45
+ handlerIdentity = handler
46
+ revision += 1
47
+ interaction.dismissMenu()
48
+ items = entries
49
+ self.dispatch = dispatch
50
+ }
51
+
52
+ func detach() {
53
+ revision += 1
54
+ dispatch = nil
55
+ items = []
56
+ interaction.dismissMenu()
57
+ view?.removeInteraction(interaction)
58
+ }
59
+
60
+ /// Captured actions from an open menu cannot target a reconciled row.
61
+ func selection(_ id: String) -> () -> Void {
62
+ let expected = revision
63
+ return { [weak self] in
64
+ guard let self, self.revision == expected,
65
+ let item = self.items.first(where: { $0["id"] as? String == id }),
66
+ item["disabled"] as? Bool != true else { return }
67
+ self.dispatch?(id)
68
+ }
69
+ }
70
+
71
+ func menu() -> UIMenu {
72
+ UIMenu(children: items.map { item in
73
+ var attributes: UIMenuElement.Attributes = []
74
+ if item["disabled"] as? Bool == true { attributes.insert(.disabled) }
75
+ if item["destructive"] as? Bool == true { attributes.insert(.destructive) }
76
+ let chosen = selection(item["id"] as! String)
77
+ return UIAction(title: item["title"] as! String,
78
+ image: (item["symbol"] as? String).flatMap { UIImage(systemName: $0) },
79
+ attributes: attributes) { _ in chosen() }
80
+ })
81
+ }
82
+
83
+ func menuProvider() -> UIContextMenuActionProvider {
84
+ // UIKit asks for contents when presentation begins, after configuration
85
+ // was requested. A reconciled node must not supply a different menu to
86
+ // an interaction that started against its previous items or handler.
87
+ let expected = revision
88
+ return { [weak self] _ in
89
+ guard let self, self.revision == expected,
90
+ self.dispatch != nil, !self.items.isEmpty else { return nil }
91
+ return self.menu()
92
+ }
93
+ }
94
+
95
+ func contextMenuInteraction(_ interaction: UIContextMenuInteraction,
96
+ configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
97
+ guard dispatch != nil, !items.isEmpty else { return nil }
98
+ return UIContextMenuConfiguration(identifier: nil, previewProvider: nil, actionProvider: menuProvider())
99
+ }
100
+ }
101
+
16
102
  private let craftNativeCapabilityTimeoutMilliseconds = 30_000
17
103
 
18
104
  /// A retained JavaScript context must lose storage and snapshot access when
@@ -4376,6 +4462,7 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate, U
4376
4462
 
4377
4463
  private func configureSheet(_ controller: CraftNativeSheetController) {
4378
4464
  controller.isModalInPresentation = onRequestClose == nil
4465
+ controller.view.accessibilityViewIsModal = true
4379
4466
  guard let sheet = controller.sheetPresentationController else { return }
4380
4467
  let choices = sheetDetents.filter { $0 == "medium" || $0 == "large" }
4381
4468
  let values = choices.isEmpty ? ["large"] : Array(Set(choices)).sorted { $0 == "medium" && $1 == "large" }
@@ -4432,6 +4519,7 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate, U
4432
4519
  guard let controller = presentationController.presentedViewController as? CraftNativeSheetController else { return }
4433
4520
  restoreSheetContent(controller)
4434
4521
  renderedVisible = false
4522
+ accessibilityViewIsModal = false
4435
4523
  onRequestClose?("swipe")
4436
4524
  }
4437
4525
 
@@ -4443,7 +4531,10 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate, U
4443
4531
  /// screen-level overlay but lets hit testing fall through outside its
4444
4532
  /// interactive content.
4445
4533
  var blocksBackground = true {
4446
- didSet { blocker.isUserInteractionEnabled = blocksBackground }
4534
+ didSet {
4535
+ blocker.isUserInteractionEnabled = blocksBackground
4536
+ accessibilityViewIsModal = blocksBackground && renderedVisible
4537
+ }
4447
4538
  }
4448
4539
  /// An opt-in non-modal dismissal policy. The default popup remains
4449
4540
  /// pass-through; when enabled, an outside tap is consumed and routed to
@@ -4471,6 +4562,7 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate, U
4471
4562
  /// press bookkeeping once turned that off for a view with no handler of
4472
4563
  /// its own, and a shown modal let every tap through to the screen behind.
4473
4564
  func setVisible(_ visible: Bool, opacity: CGFloat, animationType: String?) {
4565
+ accessibilityViewIsModal = blocksBackground && visible
4474
4566
  if blocksBackground && (presentationStyle == "pageSheet" || presentationStyle == "formSheet") {
4475
4567
  setSheetVisible(visible, animated: animationType != "none")
4476
4568
  return
@@ -4528,6 +4620,7 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate, U
4528
4620
 
4529
4621
  func resetForTeardown() {
4530
4622
  renderedVisible = false
4623
+ accessibilityViewIsModal = false
4531
4624
  sheetPresenter = nil
4532
4625
  if let controller = sheetController {
4533
4626
  controller.dismiss(animated: false)
@@ -6404,7 +6497,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6404
6497
  private var inputIdentities: [ObjectIdentifier: String] = [:]
6405
6498
  private var inputDrafts: [String: String] = [:]
6406
6499
  private var longPressHandlers: [ObjectIdentifier: String] = [:]
6500
+ private var contextMenus: [ObjectIdentifier: CraftNativeContextMenu] = [:]
6407
6501
  private var accessibilityActionHandlers: [ObjectIdentifier: String] = [:]
6502
+ private var accessibilityActionRevisions: [ObjectIdentifier: UUID] = [:]
6408
6503
  private var textMaxLengths: [ObjectIdentifier: Int] = [:]
6409
6504
  private var scrollHandlers: [ObjectIdentifier: String] = [:]
6410
6505
  private var scrollBeginHandlers: [ObjectIdentifier: String] = [:]
@@ -7420,6 +7515,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7420
7515
  // DOM fallback even though this screen can present UIKit.
7421
7516
  function installNativeDialog() {
7422
7517
  var craft = globalThis.craft = globalThis.craft || {};
7518
+ craft.accessibility = {
7519
+ announce: function(text, options) { return request('Accessibility', 'announce', [text, options == null ? {} : options]).then(function() {}); }
7520
+ };
7423
7521
  if (Array.isArray(bridge.capabilities) && bridge.capabilities.indexOf('dialog') >= 0) {
7424
7522
  craft.dialog = {
7425
7523
  alert: function(options) { return request('Dialog', 'alert', [options || {}], true).then(function() {}); },
@@ -9626,7 +9724,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9626
9724
  configureStack(stack, style: style)
9627
9725
  reconcileChildren(children, in: stack, parent: current, path: path, style: style)
9628
9726
  }
9629
- updateLongPressHandler(props["disabled"] as? Bool == true ? nil : events["onLongPress"], for: result)
9727
+ let menuDisabled = props["disabled"] as? Bool == true || (props["accessibilityState"] as? [String: Any])?["disabled"] as? Bool == true
9728
+ // STX's HTML parser lowercases unknown bound props and maps
9729
+ // @contextmenu to onLongPress. Keep that authored path usable too.
9730
+ let menuEntries = menuDisabled ? [] : CraftNativeContextMenu.entries(props["contextMenu"] ?? props["contextmenu"])
9731
+ let menuHandler = nonEmptyHandler(events["onContextMenuAction"]) ?? nonEmptyHandler(events["onLongPress"])
9732
+ let hasMenu = !menuEntries.isEmpty && menuHandler != nil
9733
+ // A menu owns the hold when both are authored; never open an action
9734
+ // sheet from onLongPress underneath UIKit's menu.
9735
+ updateLongPressHandler(menuDisabled || hasMenu ? nil : events["onLongPress"], for: result)
9630
9736
  if type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "SegmentedControl" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "StatusBar" && type != "RefreshControl" && type != "Map" {
9631
9737
  let disabled = props["disabled"] as? Bool == true
9632
9738
  updatePressHandler(disabled ? nil : nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: result)
@@ -9646,7 +9752,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9646
9752
  applyAccessibility(props, type: type, to: result)
9647
9753
  // After applyAccessibility: it settles whether the view is an element
9648
9754
  // at all, and an action on a non-element is unreachable.
9649
- updateAccessibilityActions(props["accessibilityActions"], handler: props["disabled"] as? Bool == true ? nil : events["onAccessibilityAction"], for: result)
9755
+ updateAccessibilityActions(props["accessibilityActions"], handler: menuDisabled ? nil : events["onAccessibilityAction"], for: result)
9756
+ let menuID = ObjectIdentifier(result)
9757
+ if hasMenu, let menuHandler {
9758
+ let menu = contextMenus[menuID] ?? CraftNativeContextMenu(view: result)
9759
+ contextMenus[menuID] = menu
9760
+ menu.update(menuEntries, handler: menuHandler) { [weak self] itemID in
9761
+ self?.send(type: "EVENT", payload: ["handlerName": menuHandler, "nativeEvent": ["id": itemID]])
9762
+ }
9763
+ result.isUserInteractionEnabled = true
9764
+ } else {
9765
+ contextMenus.removeValue(forKey: menuID)?.detach()
9766
+ }
9650
9767
  (result as? CraftNativeFlowView)?.configureCelebration(props["celebration"])
9651
9768
  return current
9652
9769
  }
@@ -10129,7 +10246,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10129
10246
  inputEventCounts.removeValue(forKey: id)
10130
10247
  if node.type == "TextInput" && !preservingInputDrafts { inputDrafts.removeValue(forKey: node.identity) }
10131
10248
  longPressHandlers.removeValue(forKey: id)
10249
+ contextMenus.removeValue(forKey: id)?.detach()
10132
10250
  accessibilityActionHandlers.removeValue(forKey: id)
10251
+ accessibilityActionRevisions.removeValue(forKey: id)
10252
+ node.view.accessibilityCustomActions = nil
10133
10253
  textMaxLengths.removeValue(forKey: id)
10134
10254
  scrollHandlers.removeValue(forKey: id)
10135
10255
  scrollBeginHandlers.removeValue(forKey: id)
@@ -10367,15 +10487,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10367
10487
  }
10368
10488
  guard let handler, !handler.isEmpty, !entries.isEmpty else {
10369
10489
  accessibilityActionHandlers.removeValue(forKey: id)
10490
+ accessibilityActionRevisions.removeValue(forKey: id)
10370
10491
  view.accessibilityCustomActions = nil
10371
10492
  return
10372
10493
  }
10373
10494
  accessibilityActionHandlers[id] = handler
10495
+ let revision = UUID()
10496
+ accessibilityActionRevisions[id] = revision
10374
10497
  view.accessibilityCustomActions = entries.map { entry in
10375
10498
  UIAccessibilityCustomAction(name: entry.label) { [weak self, weak view] _ in
10376
10499
  // Read the handler at firing time: a reconcile can rename it
10377
10500
  // while the rotor sits on the element.
10378
- guard let self, let view, let current = self.accessibilityActionHandlers[ObjectIdentifier(view)] else { return false }
10501
+ guard let self, let view, self.accessibilityActionRevisions[ObjectIdentifier(view)] == revision,
10502
+ let current = self.accessibilityActionHandlers[ObjectIdentifier(view)] else { return false }
10379
10503
  self.send(type: "EVENT", payload: ["handlerName": current, "nativeEvent": ["actionName": entry.name]])
10380
10504
  return true
10381
10505
  }
@@ -11230,6 +11354,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11230
11354
  view.accessibilityLabel = (props["accessibilityLabel"] as? String)
11231
11355
  ?? ((type == "Image" || type == "ImageBackground") ? props["alt"] as? String : nil)
11232
11356
  view.accessibilityHint = props["accessibilityHint"] as? String
11357
+ let language = (props["accessibilityLanguage"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
11358
+ view.accessibilityLanguage = language?.isEmpty == false ? language : nil
11233
11359
  if let value = props["accessibilityValue"] {
11234
11360
  view.accessibilityValue = (value as? String) ?? (value as? NSNumber)?.stringValue
11235
11361
  authoredAccessibilityValues.insert(id)
@@ -0,0 +1,199 @@
1
+ import Foundation
2
+ import ImageIO
3
+ import UserNotifications
4
+
5
+ /// A separate extension process: no app runtime, shared container or JS required.
6
+ final class CraftNotificationService: UNNotificationServiceExtension {
7
+ private let lock = NSLock()
8
+ private var delivery: CraftRemoteNotificationDelivery?
9
+
10
+ override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
11
+ let next = CraftRemoteNotificationDelivery()
12
+ lock.lock()
13
+ let previous = delivery
14
+ delivery = next
15
+ lock.unlock()
16
+ previous?.expire()
17
+ next.start(request.content, completion: contentHandler)
18
+ }
19
+
20
+ override func serviceExtensionTimeWillExpire() {
21
+ lock.lock()
22
+ let current = delivery
23
+ lock.unlock()
24
+ current?.expire()
25
+ }
26
+ }
27
+
28
+ /// Serial streaming downloads bound both disk use and the extension's lifetime.
29
+ /// Any failure returns the original alert, without partially attached media.
30
+ final class CraftRemoteNotificationDelivery: NSObject, URLSessionDataDelegate {
31
+ static let maximumBytes = 10 * 1024 * 1024
32
+ private let queue = DispatchQueue(label: "craft.notification-service")
33
+ private let configuration: URLSessionConfiguration
34
+ private let root: URL
35
+ private let timeout: TimeInterval
36
+ private var session: URLSession?
37
+ private var task: URLSessionDataTask?
38
+ private var deadline: DispatchWorkItem?
39
+ private var original: UNNotificationContent?
40
+ private var handler: ((UNNotificationContent) -> Void)?
41
+ private var expired = false
42
+ private var entries: [(String, URL)] = []
43
+ private var attachments: [UNNotificationAttachment] = []
44
+ private var folder: URL?
45
+ private var file: URL?
46
+ private var stream: FileHandle?
47
+ private var received = 0
48
+
49
+ init(configuration: URLSessionConfiguration = .ephemeral,
50
+ root: URL = FileManager.default.temporaryDirectory.appendingPathComponent("craft-remote-notifications", isDirectory: true),
51
+ timeout: TimeInterval = 25) {
52
+ self.configuration = configuration
53
+ self.root = root
54
+ self.timeout = timeout
55
+ super.init()
56
+ }
57
+
58
+ static func permitted(_ url: URL) -> Bool {
59
+ url.scheme?.lowercased() == "https" && url.host?.isEmpty == false && url.user == nil && url.password == nil
60
+ }
61
+
62
+ func start(_ content: UNNotificationContent, completion: @escaping (UNNotificationContent) -> Void) {
63
+ queue.async {
64
+ self.original = content
65
+ self.handler = completion
66
+ guard !self.expired, let raw = content.userInfo["craftAttachments"] as? [[String: Any]],
67
+ !raw.isEmpty, raw.count <= 10 else { self.finish(false); return }
68
+ var ids = Set<String>()
69
+ for item in raw {
70
+ guard let id = item["id"] as? String, !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
71
+ id.utf8.count <= 128, ids.insert(id).inserted,
72
+ let string = item["url"] as? String, let url = URL(string: string), Self.permitted(url) else {
73
+ self.finish(false); return
74
+ }
75
+ self.entries.append((id, url))
76
+ }
77
+ do {
78
+ try FileManager.default.createDirectory(at: self.root, withIntermediateDirectories: true)
79
+ // Successful handoffs must outlive the handler: iOS validates and
80
+ // moves their files AFTER the extension finishes. Sweep only old
81
+ // UUID folders, never another delivery still in flight.
82
+ for url in try FileManager.default.contentsOfDirectory(at: self.root, includingPropertiesForKeys: [.creationDateKey]) {
83
+ if UUID(uuidString: url.lastPathComponent) != nil,
84
+ let created = try url.resourceValues(forKeys: [.creationDateKey]).creationDate,
85
+ created < Date().addingTimeInterval(-86_400) {
86
+ try? FileManager.default.removeItem(at: url)
87
+ }
88
+ }
89
+ let folder = self.root.appendingPathComponent(UUID().uuidString, isDirectory: true)
90
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
91
+ self.folder = folder
92
+ } catch { self.finish(false); return }
93
+ self.configuration.httpCookieStorage = nil
94
+ self.configuration.httpShouldSetCookies = false
95
+ self.configuration.urlCredentialStorage = nil
96
+ self.configuration.urlCache = nil
97
+ self.configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
98
+ self.configuration.timeoutIntervalForRequest = self.timeout
99
+ self.configuration.timeoutIntervalForResource = self.timeout
100
+ self.session = URLSession(configuration: self.configuration, delegate: self, delegateQueue: nil)
101
+ let deadline = DispatchWorkItem { [weak self] in self?.finish(false) }
102
+ self.deadline = deadline
103
+ self.queue.asyncAfter(deadline: .now() + self.timeout, execute: deadline)
104
+ self.downloadNext()
105
+ }
106
+ }
107
+
108
+ func expire() {
109
+ queue.async { self.expired = true; self.finish(false) }
110
+ }
111
+
112
+ private func downloadNext() {
113
+ guard attachments.count < entries.count else { finish(true); return }
114
+ file = nil
115
+ task = session?.dataTask(with: entries[attachments.count].1)
116
+ task?.resume()
117
+ }
118
+
119
+ func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
120
+ newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
121
+ queue.async {
122
+ guard self.handler != nil, let url = request.url, Self.permitted(url) else {
123
+ completionHandler(nil); self.finish(false); return
124
+ }
125
+ completionHandler(request)
126
+ }
127
+ }
128
+
129
+ func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse,
130
+ completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
131
+ queue.async {
132
+ let formats = ["image/jpeg": "jpg", "image/png": "png", "image/gif": "gif",
133
+ "audio/mpeg": "mp3", "audio/mp4": "m4a", "audio/wav": "wav", "audio/x-wav": "wav",
134
+ "audio/aiff": "aiff", "audio/x-aiff": "aiff", "video/mp4": "mp4", "video/mpeg": "mpeg", "video/x-msvideo": "avi"]
135
+ guard self.handler != nil, let http = response as? HTTPURLResponse, http.statusCode == 200,
136
+ let url = response.url, Self.permitted(url),
137
+ let suffix = formats[response.mimeType?.lowercased() ?? ""], let folder = self.folder,
138
+ response.expectedContentLength <= Int64(Self.maximumBytes - self.received) else {
139
+ completionHandler(.cancel); self.finish(false); return
140
+ }
141
+ let file = folder.appendingPathComponent(UUID().uuidString).appendingPathExtension(suffix)
142
+ do {
143
+ guard FileManager.default.createFile(atPath: file.path, contents: nil) else { throw CocoaError(.fileWriteUnknown) }
144
+ self.file = file
145
+ self.stream = try FileHandle(forWritingTo: file)
146
+ completionHandler(.allow)
147
+ } catch { completionHandler(.cancel); self.finish(false) }
148
+ }
149
+ }
150
+
151
+ func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
152
+ // Backpressure the delegate queue: do not retain an unbounded queue of
153
+ // Data chunks from a fast/chunked server while disk writes catch up.
154
+ queue.sync {
155
+ guard self.handler != nil else { return }
156
+ guard data.count <= Self.maximumBytes - self.received, let stream = self.stream else { self.finish(false); return }
157
+ do { try stream.write(contentsOf: data); self.received += data.count }
158
+ catch { self.finish(false) }
159
+ }
160
+ }
161
+
162
+ func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
163
+ queue.async {
164
+ guard self.handler != nil else { return }
165
+ do {
166
+ try self.stream?.close()
167
+ self.stream = nil
168
+ guard error == nil, let file = self.file else { self.finish(false); return }
169
+ if ["jpg", "png", "gif"].contains(file.pathExtension) {
170
+ guard let image = CGImageSourceCreateWithURL(file as CFURL, nil), CGImageSourceGetCount(image) > 0,
171
+ CGImageSourceGetStatus(image) == .statusComplete else { self.finish(false); return }
172
+ }
173
+ let attachment = try UNNotificationAttachment(identifier: self.entries[self.attachments.count].0, url: file, options: nil)
174
+ self.attachments.append(attachment)
175
+ self.downloadNext()
176
+ } catch { self.finish(false) }
177
+ }
178
+ }
179
+
180
+ private func finish(_ success: Bool) {
181
+ guard let handler, let original else { return }
182
+ self.handler = nil
183
+ deadline?.cancel()
184
+ deadline = nil
185
+ task?.cancel()
186
+ session?.invalidateAndCancel()
187
+ session = nil
188
+ try? stream?.close()
189
+ stream = nil
190
+ if success, let content = original.mutableCopy() as? UNMutableNotificationContent {
191
+ content.attachments = original.attachments + attachments
192
+ handler(content)
193
+ // No acknowledgement exists here. Leave successful files for iOS.
194
+ } else {
195
+ if let folder { try? FileManager.default.removeItem(at: folder) }
196
+ handler(original)
197
+ }
198
+ }
199
+ }
@@ -105,11 +105,21 @@ final class CraftVoiceRecording {
105
105
  private var generation = 0
106
106
  private let startupTimeout: TimeInterval
107
107
  private let stopTimeout: TimeInterval
108
+ struct FileRemovalReceipt: Encodable {
109
+ let queueMilliseconds: Double
110
+ let removalMilliseconds: Double
111
+ let ownedBefore: Bool
112
+ let removed: Bool
113
+ let existsAfter: Bool
114
+ }
115
+ // Internal observation of the real deletion, not a replacement filesystem.
116
+ private let fileRemovalObserved: ((FileRemovalReceipt) -> Void)?
108
117
  private var backgroundObserver: NSObjectProtocol?
109
118
 
110
119
  init(audioQueue: DispatchQueue = DispatchQueue(label: "craft.voice-recording", qos: .userInitiated),
111
120
  startupTimeout: TimeInterval = 15,
112
121
  stopTimeout: TimeInterval = 10,
122
+ fileRemovalObserved: ((FileRemovalReceipt) -> Void)? = nil,
113
123
  permission: @escaping (@escaping (Bool) -> Void) -> Void = { answer in
114
124
  #if CRAFT_PERMISSION_MICROPHONE
115
125
  AVAudioSession.sharedInstance().requestRecordPermission(answer)
@@ -132,6 +142,7 @@ final class CraftVoiceRecording {
132
142
  self.audioQueue = audioQueue
133
143
  self.startupTimeout = startupTimeout
134
144
  self.stopTimeout = stopTimeout
145
+ self.fileRemovalObserved = fileRemovalObserved
135
146
  self.permission = permission
136
147
  self.makeRecorder = makeRecorder
137
148
  self.deactivate = deactivate
@@ -164,7 +175,24 @@ final class CraftVoiceRecording {
164
175
  session.finish(.failure(error))
165
176
  if let file = session.file {
166
177
  // A stalled encoder must not hold deletion behind its audio queue.
167
- Self.fileQueue.async { CraftNativeFiles.remove(file.path) }
178
+ let queued = ProcessInfo.processInfo.systemUptime
179
+ let observed = fileRemovalObserved
180
+ Self.trace("cancel \(session.key.prefix(8)): file removal queued")
181
+ Self.fileQueue.async {
182
+ let began = ProcessInfo.processInfo.systemUptime
183
+ let owned = CraftNativeFiles.owned(file.path) != nil
184
+ let removed = CraftNativeFiles.remove(file.path)
185
+ let receipt = FileRemovalReceipt(
186
+ queueMilliseconds: (began - queued) * 1000,
187
+ removalMilliseconds: (ProcessInfo.processInfo.systemUptime - began) * 1000,
188
+ ownedBefore: owned, removed: removed,
189
+ existsAfter: FileManager.default.fileExists(atPath: file.path))
190
+ // No path or recording content is included in diagnostics.
191
+ if let json = try? JSONEncoder().encode(receipt), let text = String(data: json, encoding: .utf8) {
192
+ Self.trace("cancel \(session.key.prefix(8)): file removal \(text)")
193
+ }
194
+ observed?(receipt)
195
+ }
168
196
  }
169
197
  audioQueue.async { self.erase(session) }
170
198
  }