craft-native 0.0.451 → 0.0.467

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 (35) 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 +23 -5
  9. package/dist/android/templates/CraftNativeShell.kt.template +201 -50
  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 +293 -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 +105 -14
  20. package/dist/ios/templates/CraftNativeFiles.swift +230 -18
  21. package/dist/ios/templates/CraftNativeMediaOwnership.swift +92 -0
  22. package/dist/ios/templates/CraftNativeScreen.swift +136 -6
  23. package/dist/ios/templates/CraftNotificationService.swift +199 -0
  24. package/dist/ios/templates/CraftVoiceRecording.swift +29 -1
  25. package/dist/ios/templates/testing/DriverTests.swift.template +44 -5
  26. package/dist/mobile-testing.js +12 -3
  27. package/dist/mobile.d.ts +2 -0
  28. package/dist/mobile.js +120 -0
  29. package/dist/native-notifications.d.ts +17 -0
  30. package/dist/native-routing.d.ts +26 -0
  31. package/dist/testing/media-cache/media-cache.kt +74 -5
  32. package/dist/testing/media-cache/media-cache.swift.fixture +60 -4
  33. package/dist/typescript/src/native-notifications.d.ts +17 -0
  34. package/dist/typescript/src/native-routing.d.ts +26 -0
  35. package/package.json +1 -1
@@ -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() {}); },
@@ -9203,7 +9301,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9203
9301
  field.textColor = color(style["color"]) ?? .label
9204
9302
  field.font = textFont(style, default: .systemFont(ofSize: 16))
9205
9303
  field.textAlignment = textAlignment(style["textAlign"])
9206
- field.defaultTextAttributes = inputTextAttributes(style, font: field.font ?? .systemFont(ofSize: 16), alignment: field.textAlignment)
9304
+ field.defaultTextAttributes = inputTextAttributes(style, font: field.font ?? .systemFont(ofSize: 16), alignment: field.textAlignment, singleLine: true)
9207
9305
  let desiredKeyboardType = keyboardType(props["keyboardType"])
9208
9306
  if field.keyboardType != desiredKeyboardType { field.keyboardType = desiredKeyboardType }
9209
9307
  let desiredReturnKeyType = returnKeyType(props["returnKeyType"])
@@ -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
  }
@@ -10806,7 +10930,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10806
10930
  private func inputTextAttributes(
10807
10931
  _ style: [String: Any],
10808
10932
  font: UIFont,
10809
- alignment: NSTextAlignment
10933
+ alignment: NSTextAlignment,
10934
+ singleLine: Bool = false
10810
10935
  ) -> [NSAttributedString.Key: Any] {
10811
10936
  var attributes: [NSAttributedString.Key: Any] = [
10812
10937
  .foregroundColor: color(style["color"]) ?? .label,
@@ -10816,6 +10941,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10816
10941
  // UIKit otherwise resets the field's alignment to natural when these attributes are applied.
10817
10942
  let paragraph = NSMutableParagraphStyle()
10818
10943
  paragraph.alignment = alignment
10944
+ // The default paragraph wraps even inside a UITextField. A long URL
10945
+ // then paints a second line outside the field instead of scrolling.
10946
+ if singleLine { paragraph.lineBreakMode = .byClipping }
10819
10947
  if let lineHeight = number(style["lineHeight"]) {
10820
10948
  paragraph.minimumLineHeight = lineHeight
10821
10949
  paragraph.maximumLineHeight = lineHeight
@@ -11230,6 +11358,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11230
11358
  view.accessibilityLabel = (props["accessibilityLabel"] as? String)
11231
11359
  ?? ((type == "Image" || type == "ImageBackground") ? props["alt"] as? String : nil)
11232
11360
  view.accessibilityHint = props["accessibilityHint"] as? String
11361
+ let language = (props["accessibilityLanguage"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
11362
+ view.accessibilityLanguage = language?.isEmpty == false ? language : nil
11233
11363
  if let value = props["accessibilityValue"] {
11234
11364
  view.accessibilityValue = (value as? String) ?? (value as? NSNumber)?.stringValue
11235
11365
  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
  }
@@ -61,6 +61,8 @@ final class DriverTests: XCTestCase {
61
61
  let tabs: [String] = {{TABS}}
62
62
  let tabIdentifiers: [String: String] = {{TAB_IDENTIFIERS}}
63
63
  private var bundledJourney = false
64
+ private var lastTappedInput: XCUIElement?
65
+ private var lastBooleanTap: [String: Any]?
64
66
 
65
67
  func get(_ path: String, timeout: Double = 15) -> Data? {
66
68
  var result: Data?
@@ -293,8 +295,6 @@ final class DriverTests: XCTestCase {
293
295
  return (parts[0], parts.count > 1 ? Double(parts[1]) ?? 10 : 10)
294
296
  }
295
297
 
296
- /// A switch reports "0" or "1". Any other value is not a switch state to
297
- /// wait on, so the tap stands as it would for any other control.
298
298
  /// The switch answers `/exec` with 200 whatever the hook did, its first
299
299
  /// word the hook's exit code: only `exit 0` is a hook that worked.
300
300
  func hookSucceeded(_ answer: String) -> Bool {
@@ -308,16 +308,26 @@ final class DriverTests: XCTestCase {
308
308
  return fields.count >= 2 && fields[1] == "0"
309
309
  }
310
310
 
311
+ /// UIKit and Craft's solid toggle both publish "0" or "1", regardless
312
+ /// of the accessibility element type that XCTest assigns to them.
311
313
  func switchFlipped(from before: String?, to after: String?) -> Bool {
312
314
  let states: Set<String> = ["0", "1"]
313
- guard let before, let after, states.contains(before), states.contains(after) else { return true }
315
+ // A lost accessibility value is missing evidence, not a successful tap.
316
+ guard let before, let after, states.contains(before), states.contains(after) else { return false }
314
317
  return before != after
315
318
  }
316
319
 
317
320
  /// Deliver one gesture; the command's observation determines success.
318
321
  func press(_ element: XCUIElement) {
322
+ // Resolve this before activation: a menu item can disappear as soon
323
+ // as its gesture completes (for example Select All).
324
+ let input = [.textField, .secureTextField, .textView, .searchField].contains(element.elementType)
325
+ ? element : nil
319
326
  if element.isHittable { element.tap() }
320
327
  else { element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() }
328
+ // A tap on a read-only text view need not open the keyboard. Only a
329
+ // following type command observes whether this target took focus.
330
+ lastTappedInput = input
321
331
  }
322
332
 
323
333
  func answerOpenPrompt() {
@@ -353,6 +363,11 @@ final class DriverTests: XCTestCase {
353
363
  let parts = line.split(separator: " ", maxSplits: 1).map(String.init)
354
364
  let command = parts[0]
355
365
  let arg = parts.count > 1 ? parts[1] : ""
366
+ // Context changes can move focus without going through press().
367
+ if ["launch", "activate", "terminate", "home", "background", "openurl", "universallink",
368
+ "back", "return", "tab", "tapxy", "edge", "notifications", "systemtap", "longpressid", "exec"].contains(command) {
369
+ lastTappedInput = nil
370
+ }
356
371
  if bundledJourney && ["net", "delay", "exec"].contains(command) {
357
372
  check(false, "\(command) requires a control-server journey")
358
373
  return
@@ -470,12 +485,15 @@ final class DriverTests: XCTestCase {
470
485
  if let target = poll(10, { exact(arg) }) { press(target); note("tapexact \(arg): ok") }
471
486
  else { XCTFail("Element not found"); note("FAIL tapexact \(arg): NOT FOUND") }
472
487
  case "tapid":
488
+ lastBooleanTap = nil
473
489
  let target = identified(arg)
474
490
  if !target.waitForExistence(timeout: 10) { XCTFail("Element not found"); note("FAIL tapid \(arg): NOT FOUND") }
475
- else if target.elementType == .switch {
491
+ else if target.elementType == .switch || ["0", "1"].contains(target.value as? String ?? "") {
476
492
  // Observe one physical gesture. Retapping could conceal a
477
493
  // renderer or hit-testing failure (HQ agreement job 7570).
478
494
  let before = target.value as? String
495
+ let enabledBefore = target.isEnabled
496
+ let frameBefore = target.frame
479
497
  press(target)
480
498
  let settle = Date().addingTimeInterval(1.5)
481
499
  var flipped = false
@@ -483,6 +501,15 @@ final class DriverTests: XCTestCase {
483
501
  flipped = switchFlipped(from: before, to: target.value as? String)
484
502
  if !flipped { Thread.sleep(forTimeInterval: 0.25) }
485
503
  } while !flipped && Date() < settle
504
+ let observation: [String: Any] = [
505
+ "id": arg, "gestures": 1, "flipped": flipped,
506
+ "enabledBefore": enabledBefore, "enabledAfter": target.isEnabled,
507
+ "valueBefore": before ?? "unknown", "valueAfter": target.value as? String ?? "unknown",
508
+ "frameBefore": [frameBefore.origin.x, frameBefore.origin.y, frameBefore.width, frameBefore.height],
509
+ ]
510
+ lastBooleanTap = observation
511
+ if let data = try? JSONSerialization.data(withJSONObject: observation, options: [.sortedKeys]),
512
+ let json = String(data: data, encoding: .utf8) { note("BOOLEAN_TAP \(json)") }
486
513
  check(flipped, "tapid \(arg) flips the switch")
487
514
  }
488
515
  else { press(target); note("tapid \(arg): ok") }
@@ -596,9 +623,20 @@ final class DriverTests: XCTestCase {
596
623
  start.press(forDuration: 0.05, thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5)))
597
624
  note("back: edge swipe")
598
625
  }
599
- case "type": app.typeText(arg)
626
+ case "type":
627
+ let target = lastTappedInput ?? app
628
+ lastTappedInput = nil
629
+ // A returned gesture is not proof of keyboard focus. Observe the
630
+ // one gesture before typing, including WebKit/cold-keyboard focus
631
+ // delivery. Never retap and conceal a dropped input event.
632
+ guard poll(15, { self.hasKeyboardFocus(target) ? target : nil }) != nil else {
633
+ check(false, "type into \(target.label) takes keyboard focus")
634
+ return
635
+ }
636
+ app.typeText(arg)
600
637
  case "return": app.typeText(XCUIKeyboardKey.return.rawValue); note("return")
601
638
  case "typeid":
639
+ lastTappedInput = nil
602
640
  let idParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
603
641
  let target = identified(idParts[0])
604
642
  if target.waitForExistence(timeout: 10) {
@@ -627,6 +665,7 @@ final class DriverTests: XCTestCase {
627
665
  let target = XCUIApplication(bundleIdentifier: bundle)
628
666
  check(target.wait(for: .runningForeground, timeout: seconds), "expectapp \(bundle)")
629
667
  case "field":
668
+ lastTappedInput = nil
630
669
  let fieldParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
631
670
  let index = Int(fieldParts[0]) ?? 0
632
671
  let fields = app.textFields.allElementsBoundByIndex + app.secureTextFields.allElementsBoundByIndex