@craft-native/ios 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.
- package/README.md +63 -0
- package/dist/cli.js +291 -81
- package/dist/index.d.ts +1 -685
- package/dist/index.js +293 -81
- package/dist/internal/ios/src/index.d.ts +684 -0
- package/dist/internal/typescript/src/native-notifications.d.ts +17 -0
- package/dist/internal/typescript/src/native-routing.d.ts +26 -0
- package/package.json +1 -1
- package/templates/CraftApp.swift +118 -16
- package/templates/CraftHybrid.swift +75 -5
- package/templates/CraftNativeActions.swift +105 -14
- package/templates/CraftNativeFiles.swift +230 -18
- package/templates/CraftNativeMediaOwnership.swift +92 -0
- package/templates/CraftNativeScreen.swift +136 -6
- package/templates/CraftNotificationService.swift +199 -0
- package/templates/CraftVoiceRecording.swift +29 -1
- package/templates/testing/DriverTests.swift.template +44 -5
- /package/dist/{app-shortcuts.d.ts → internal/ios/src/app-shortcuts.d.ts} +0 -0
- /package/dist/{runtime-profile.d.ts → internal/ios/src/runtime-profile.d.ts} +0 -0
- /package/dist/{source-audit.d.ts → internal/ios/src/source-audit.d.ts} +0 -0
- /package/dist/{testing.d.ts → internal/ios/src/testing.d.ts} +0 -0
|
@@ -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 {
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|