@craft-native/ios 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.
- package/README.md +66 -1
- package/dist/cli.js +290 -81
- package/dist/index.d.ts +1 -685
- package/dist/index.js +292 -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 +84 -13
- package/templates/CraftNativeFiles.swift +83 -1
- package/templates/CraftNativeScreen.swift +130 -4
- package/templates/CraftNotificationService.swift +199 -0
- package/templates/CraftVoiceRecording.swift +29 -1
- package/templates/testing/DriverTests.swift.template +61 -6
- /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
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
}
|