@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.
@@ -0,0 +1,17 @@
1
+ /** Registered before notification delivery; payloads reference category ids. */
2
+ export interface NativeNotificationCategory {
3
+ id: string;
4
+ actions: {
5
+ id: string;
6
+ title: string;
7
+ destructive?: boolean;
8
+ /** Open the app. Otherwise nativeApp handles the action in the background. */
9
+ foreground?: boolean;
10
+ authenticationRequired?: boolean;
11
+ textInput?: {
12
+ buttonTitle: string;
13
+ placeholder?: string;
14
+ };
15
+ }[];
16
+ }
17
+ export declare function nativeNotificationProblems(categories: unknown, nativeApp?: string, renderer?: string): string[];
@@ -0,0 +1,26 @@
1
+ /** Constraints on decoded named path parameters; query items cannot override them. */
2
+ export interface NativeParameterConstraint {
3
+ /** A case-sensitive regular expression for the complete normalized value. */
4
+ pattern?: string;
5
+ exclude?: readonly string[];
6
+ /** Normalize decoded values and exclusions before case-sensitive grammar validation. */
7
+ normalize?: 'lowercase';
8
+ }
9
+ export type NativeRouteConstraints = Record<string, Record<string, NativeParameterConstraint>>;
10
+ /** Validate and normalize the decoded parameter map before query binding. */
11
+ export declare function applyNativeParameterConstraints(params: Record<string, string>, rules?: Record<string, NativeParameterConstraint>): boolean;
12
+ export declare function nativeRouteConstraintProblems(screens?: Record<string, string>, constraints?: NativeRouteConstraints): string[];
13
+ /** `/a//b/?q#h` → `/a/b`, the way the shell and the page compare paths. Null for a relative path. */
14
+ export declare function normalizeNativePath(raw: string): string | null;
15
+ export interface NativePathMatch {
16
+ path: string;
17
+ screen: string;
18
+ params: Record<string, string>;
19
+ }
20
+ export declare function routeSegments(path: string): string[];
21
+ /**
22
+ * The native screen for a path, by the rules the shell (`CraftHybridRoutes`)
23
+ * and the page script apply: static segments win over `:params`, which win
24
+ * over a trailing `*`; query items become params under the path's own.
25
+ */
26
+ export declare function matchNativePath(nativeScreens: Record<string, string> | undefined, raw: string, constraints?: NativeRouteConstraints): NativePathMatch | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.451",
3
+ "version": "0.0.467",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -284,6 +284,18 @@ struct CraftRecoveryBudget {
284
284
  }
285
285
  }
286
286
 
287
+ /// Capture the scene's launch intent before activation and before SwiftUI's
288
+ /// onOpenURL callback. Activation alone cannot say that a cold launch had no URL.
289
+ final class CraftSceneDelegate: NSObject, UIWindowSceneDelegate {
290
+ func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
291
+ let urls = options.urlContexts.map { $0.url }
292
+ + options.userActivities.filter { $0.activityType == NSUserActivityTypeBrowsingWeb }.compactMap { $0.webpageURL }
293
+ if let url = urls.sorted(by: { $0.absoluteString < $1.absoluteString }).first {
294
+ DeepLinkManager.shared.captureLaunchURL(url)
295
+ }
296
+ }
297
+ }
298
+
287
299
  final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
288
300
  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
289
301
  let config = CraftConfig.bundled()
@@ -292,6 +304,7 @@ final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificatio
292
304
  // First, so a crash anywhere in the rest of launch is reported too.
293
305
  CraftCrashReporter.shared.start(config)
294
306
  UNUserNotificationCenter.current().delegate = self
307
+ UNUserNotificationCenter.current().setNotificationCategories(CraftNotificationCategory.registered(config.notificationCategories ?? []))
295
308
  // Before launch finishes, which is the only time iOS accepts it.
296
309
  CraftBackgroundWork.shared.registerRefresh(config)
297
310
  // The shell, the app script and an all-native app's services now,
@@ -323,6 +336,13 @@ final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificatio
323
336
  return true
324
337
  }
325
338
 
339
+ func application(_ application: UIApplication, configurationForConnecting session: UISceneSession,
340
+ options: UIScene.ConnectionOptions) -> UISceneConfiguration {
341
+ let configuration = UISceneConfiguration(name: nil, sessionRole: session.role)
342
+ if session.role == .windowApplication { configuration.delegateClass = CraftSceneDelegate.self }
343
+ return configuration
344
+ }
345
+
326
346
  func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
327
347
  CraftEventManager.shared.handleShortcut(shortcutItem)
328
348
  completionHandler(true)
@@ -382,8 +402,19 @@ final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificatio
382
402
  // rather than where the notification pointed. The manager exists from
383
403
  // process start and holds the tap until the page is ready, the way it
384
404
  // already does for a home-screen shortcut.
385
- CraftEventManager.shared.handleNotificationResponse(response.notification.request.content.userInfo)
386
- completionHandler()
405
+ DispatchQueue.main.async {
406
+ if response.actionIdentifier == UNNotificationDefaultActionIdentifier {
407
+ CraftEventManager.shared.handleNotificationResponse(response.notification.request.content.userInfo)
408
+ completionHandler()
409
+ } else if response.actionIdentifier == UNNotificationDismissActionIdentifier {
410
+ completionHandler()
411
+ } else {
412
+ CraftEventManager.shared.handleNotificationAction(
413
+ request: response.notification.request, action: response.actionIdentifier,
414
+ userText: (response as? UNTextInputNotificationResponse)?.userText,
415
+ completion: completionHandler)
416
+ }
417
+ }
387
418
  }
388
419
 
389
420
  func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
@@ -481,6 +512,18 @@ class CraftEventManager {
481
512
  sendToWeb("craftNotificationResponse", data: Self.pageData(userInfo))
482
513
  }
483
514
 
515
+ /// Keep the response alive while the app script handles a background action.
516
+ /// The existing readiness queue also covers a cold launch; app code answers
517
+ /// craft.background.complete(ok, detail.id) and the normal deadline bounds it.
518
+ func handleNotificationAction(request: UNNotificationRequest, action: String, userText: String?, completion: @escaping () -> Void) {
519
+ let id = CraftBackgroundWork.shared.hold { _ in completion() }
520
+ var detail: [String: Any] = ["id": id, "actionId": action,
521
+ "notificationId": request.identifier, "categoryId": request.content.categoryIdentifier,
522
+ "data": Self.pageData(request.content.userInfo)]
523
+ if let userText { detail["userText"] = userText }
524
+ sendToWeb("craftNotificationAction", data: detail)
525
+ }
526
+
484
527
  /// A notification that arrived while the app was in front, in the same
485
528
  /// shape as a tap so one handler can read both.
486
529
  func handleNotificationReceived(_ userInfo: [AnyHashable: Any]) {
@@ -1551,9 +1594,9 @@ class DeepLinkManager {
1551
1594
  private var replayingNativeLinks = false
1552
1595
  private weak var webView: WKWebView?
1553
1596
  private var isReady = false
1554
- // Replay bookkeeping only. A native screen can become ready before UIKit
1555
- // delivers the launch URL; listener readiness cannot classify that URL.
1556
- private var hasBeenReady = false
1597
+ // Startup classification is independent of listener readiness. The same
1598
+ // URL opened again later is a warm link, even if it matches the launch URL.
1599
+ private var initialURLDispatched = false
1557
1600
  private var nativeListeners: [UUID: (canReceive: () -> Bool, receive: (URL, Bool) -> Void)] = [:]
1558
1601
 
1559
1602
  private init() {
@@ -1578,7 +1621,6 @@ class DeepLinkManager {
1578
1621
 
1579
1622
  func setReady() {
1580
1623
  isReady = true
1581
- hasBeenReady = true
1582
1624
  let urls = pendingURLs
1583
1625
  pendingURLs.removeAll()
1584
1626
  for link in urls {
@@ -1586,31 +1628,37 @@ class DeepLinkManager {
1586
1628
  }
1587
1629
  }
1588
1630
 
1631
+ func captureLaunchURL(_ url: URL) {
1632
+ launch.receive(url)
1633
+ }
1634
+
1589
1635
  func handleURL(_ url: URL) {
1590
1636
  launch.receive(url)
1637
+ let initial = !initialURLDispatched && url == initialURL
1638
+ if initial { initialURLDispatched = true }
1591
1639
 
1592
1640
  // A hybrid app opens a native path's screen itself, and tells the
1593
1641
  // native screens that listen; every other link is the page's. An
1594
1642
  // all-native app has no page: its native screens hear every link.
1595
1643
  if CraftHybrid.shared.isEnabled {
1596
1644
  if CraftHybrid.shared.handleDeepLink(url) {
1597
- dispatchNative(url, initial: false)
1645
+ dispatchNative(url, initial: initial)
1598
1646
  } else if !CraftHybrid.shared.hasWeb {
1599
- enqueueNative(url)
1647
+ enqueueNative(url, initial: initial)
1600
1648
  } else if isReady && webView != nil {
1601
- dispatchDeepLink(url, initial: false)
1649
+ dispatchDeepLink(url, initial: initial)
1602
1650
  } else {
1603
- pendingURLs.append((url, !hasBeenReady && url == initialURL))
1651
+ pendingURLs.append((url, initial))
1604
1652
  }
1605
1653
  return
1606
1654
  }
1607
1655
 
1608
1656
  if !nativeListeners.isEmpty {
1609
- enqueueNative(url)
1657
+ enqueueNative(url, initial: initial)
1610
1658
  } else if isReady && webView != nil {
1611
- dispatchDeepLink(url, initial: false)
1659
+ dispatchDeepLink(url, initial: initial)
1612
1660
  } else {
1613
- pendingURLs.append((url, !hasBeenReady && url == initialURL))
1661
+ pendingURLs.append((url, initial))
1614
1662
  }
1615
1663
  }
1616
1664
 
@@ -1645,8 +1693,8 @@ class DeepLinkManager {
1645
1693
  nativeListeners.removeValue(forKey: token)
1646
1694
  }
1647
1695
 
1648
- private func enqueueNative(_ url: URL) {
1649
- pendingURLs.append((url, !hasBeenReady && url == initialURL))
1696
+ private func enqueueNative(_ url: URL, initial: Bool) {
1697
+ pendingURLs.append((url, initial))
1650
1698
  replayNativeLinks()
1651
1699
  }
1652
1700
 
@@ -1660,7 +1708,6 @@ class DeepLinkManager {
1660
1708
  let receivers = nativeListeners.values.filter { $0.canReceive() }
1661
1709
  guard !receivers.isEmpty else { return }
1662
1710
  pendingURLs.removeFirst()
1663
- hasBeenReady = true
1664
1711
  for receiver in receivers { receiver.receive(link.url, link.initial) }
1665
1712
  }
1666
1713
  }
@@ -1788,7 +1835,59 @@ struct CraftConfiguredShortcut: Codable {
1788
1835
  var phrases: [String]
1789
1836
  }
1790
1837
 
1838
+ struct CraftNotificationCategory: Codable {
1839
+ struct Action: Codable {
1840
+ struct TextInput: Codable {
1841
+ var buttonTitle: String
1842
+ var placeholder: String?
1843
+ }
1844
+ var id: String
1845
+ var title: String
1846
+ var destructive: Bool?
1847
+ var foreground: Bool?
1848
+ var authenticationRequired: Bool?
1849
+ var textInput: TextInput?
1850
+
1851
+ func native() -> UNNotificationAction {
1852
+ var options: UNNotificationActionOptions = []
1853
+ if destructive == true { options.insert(.destructive) }
1854
+ if foreground == true { options.insert(.foreground) }
1855
+ if authenticationRequired == true { options.insert(.authenticationRequired) }
1856
+ if let textInput {
1857
+ return UNTextInputNotificationAction(identifier: id, title: title, options: options,
1858
+ textInputButtonTitle: textInput.buttonTitle, textInputPlaceholder: textInput.placeholder ?? "")
1859
+ }
1860
+ return UNNotificationAction(identifier: id, title: title, options: options)
1861
+ }
1862
+ }
1863
+ var id: String
1864
+ var actions: [Action]
1865
+
1866
+ static func registered(_ categories: [Self]) -> Set<UNNotificationCategory> {
1867
+ Set(categories.map { UNNotificationCategory(identifier: $0.id, actions: $0.actions.map { $0.native() }, intentIdentifiers: [], options: []) })
1868
+ }
1869
+
1870
+ static func apply(_ value: [String: Any], to content: UNMutableNotificationContent) throws {
1871
+ for key in ["categoryId", "threadIdentifier"] {
1872
+ if let raw = value[key], !(raw is String) {
1873
+ throw NSError(domain: "CraftNotification", code: 1, userInfo: [NSLocalizedDescriptionKey: "\(key) must be a string"])
1874
+ }
1875
+ }
1876
+ content.categoryIdentifier = value["categoryId"] as? String ?? ""
1877
+ content.threadIdentifier = value["threadIdentifier"] as? String ?? ""
1878
+ if let raw = value["interruptionLevel"] {
1879
+ switch raw as? String {
1880
+ case "passive": content.interruptionLevel = .passive
1881
+ case "active": content.interruptionLevel = .active
1882
+ case "timeSensitive": content.interruptionLevel = .timeSensitive
1883
+ default: throw NSError(domain: "CraftNotification", code: 1, userInfo: [NSLocalizedDescriptionKey: "interruptionLevel must be passive, active or timeSensitive"])
1884
+ }
1885
+ }
1886
+ }
1887
+ }
1888
+
1791
1889
  struct CraftConfig: Codable {
1890
+ var notificationCategories: [CraftNotificationCategory]? = nil
1792
1891
  var appShortcuts: [CraftConfiguredShortcut]? = nil
1793
1892
  var appName: String = "Craft App"
1794
1893
  var bundleId: String = "com.craft.app"
@@ -1895,6 +1994,7 @@ struct CraftConfig: Codable {
1895
1994
  /// Hybrid: app paths drawn natively, to the screen in `dist/native-screen.js`
1896
1995
  /// that draws each (`{"/m": "today"}`). Everything else is the web page.
1897
1996
  var nativeScreens: [String: String]? = nil
1997
+ var nativeScreenConstraints: [String: [String: CraftHybridRoutes.ParameterConstraint]]? = nil
1898
1998
  /// The tab bar to show before the page describes its own.
1899
1999
  var tabs: [Tab]? = nil
1900
2000
  /// Static styling for the selected tab indicator; the page may still
@@ -7942,6 +8042,8 @@ struct CraftWebView: UIViewRepresentable {
7942
8042
  }
7943
8043
 
7944
8044
  let content = UNMutableNotificationContent()
8045
+ do { try CraftNotificationCategory.apply(data, to: content) }
8046
+ catch { self?.rejectCallback(callbackId, error: error.localizedDescription); return }
7945
8047
  content.title = data["title"] as? String ?? ""
7946
8048
  content.body = data["body"] as? String ?? ""
7947
8049
  if let subtitle = data["subtitle"] as? String { content.subtitle = subtitle }
@@ -45,6 +45,48 @@ import Combine
45
45
  /// page script and the typescript API (`matchNativePath`) implement the same
46
46
  /// rules, and the tests hold all three to one set of cases.
47
47
  struct CraftHybridRoutes {
48
+ struct ParameterConstraint: Codable {
49
+ var pattern: String? = nil
50
+ var exclude: [String]? = nil
51
+ var normalize: String? = nil
52
+
53
+ private enum CodingKeys: String, CodingKey { case pattern, exclude, normalize }
54
+ private struct Field: CodingKey {
55
+ let stringValue: String
56
+ let intValue: Int? = nil
57
+ init(stringValue: String) { self.stringValue = stringValue }
58
+ init?(intValue: Int) { return nil }
59
+ }
60
+
61
+ init(from decoder: Decoder) throws {
62
+ let fields = try decoder.container(keyedBy: Field.self)
63
+ guard fields.allKeys.allSatisfy({ ["pattern", "exclude", "normalize"].contains($0.stringValue) }) else {
64
+ throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Unknown native path parameter constraint"))
65
+ }
66
+ let values = try decoder.container(keyedBy: CodingKeys.self)
67
+ pattern = values.contains(.pattern) ? try values.decode(String.self, forKey: .pattern) : nil
68
+ exclude = values.contains(.exclude) ? try values.decode([String].self, forKey: .exclude) : nil
69
+ normalize = values.contains(.normalize) ? try values.decode(String.self, forKey: .normalize) : nil
70
+ guard normalize == nil || normalize == "lowercase" else {
71
+ throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Unknown native path parameter normalization"))
72
+ }
73
+ }
74
+
75
+ func normalized(_ value: String) -> String { normalize == "lowercase" ? value.lowercased() : value }
76
+
77
+ func accepts(_ raw: String) -> Bool {
78
+ guard normalize == nil || normalize == "lowercase" else { return false }
79
+ let value = normalized(raw)
80
+ if (exclude ?? []).contains(where: { normalized($0) == value }) { return false }
81
+ if let pattern {
82
+ let range = NSRange(value.startIndex..<value.endIndex, in: value)
83
+ guard let expression = try? NSRegularExpression(pattern: "^(?:\(pattern))$"),
84
+ expression.firstMatch(in: value, range: range)?.range == range else { return false }
85
+ }
86
+ return true
87
+ }
88
+ }
89
+
48
90
  struct Match: Equatable {
49
91
  let path: String
50
92
  let screen: String
@@ -55,14 +97,15 @@ struct CraftHybridRoutes {
55
97
  let pattern: String
56
98
  let segments: [String]
57
99
  let screen: String
100
+ let constraints: [String: ParameterConstraint]
58
101
  }
59
102
 
60
103
  private let routes: [Route]
61
104
 
62
- init(_ table: [String: String]) {
105
+ init(_ table: [String: String], constraints: [String: [String: ParameterConstraint]] = [:]) {
63
106
  routes = table.compactMap { pattern, screen -> Route? in
64
107
  guard let normalized = CraftHybridRoutes.normalize(pattern), !screen.isEmpty else { return nil }
65
- return Route(pattern: normalized, segments: CraftHybridRoutes.segments(normalized), screen: screen)
108
+ return Route(pattern: normalized, segments: CraftHybridRoutes.segments(normalized), screen: screen, constraints: constraints[pattern] ?? [:])
66
109
  }.sorted(by: CraftHybridRoutes.precedes)
67
110
  }
68
111
 
@@ -100,6 +143,8 @@ struct CraftHybridRoutes {
100
143
  let parts = Self.segments(path)
101
144
  for route in routes {
102
145
  guard var params = Self.bind(route.segments, to: parts) else { continue }
146
+ guard route.constraints.allSatisfy({ name, rule in params[name].map(rule.accepts) ?? false }) else { continue }
147
+ for (name, rule) in route.constraints { params[name] = params[name].map(rule.normalized) }
103
148
  if let query = raw.firstIndex(of: "?") {
104
149
  let rest = raw[raw.index(after: query)...].split(separator: "#", maxSplits: 1).first.map(String.init) ?? ""
105
150
  for item in rest.split(separator: "&") {
@@ -370,7 +415,7 @@ final class CraftHybrid: NSObject {
370
415
  siteURL = config.devServerURL.flatMap(URL.init(string:)).flatMap { url in
371
416
  ["http", "https"].contains((url.scheme ?? "").lowercased()) ? url : nil
372
417
  }
373
- routes = CraftHybridRoutes(config.nativeScreens ?? [:])
418
+ routes = CraftHybridRoutes(config.nativeScreens ?? [:], constraints: config.nativeScreenConstraints ?? [:])
374
419
  startPath = config.devServerURL.flatMap(URL.init(string:)).map(CraftHybridRoutes.appPath(of:)) ?? "/"
375
420
  if startPath.isEmpty { startPath = "/" }
376
421
  let wanted = !routes.isEmpty
@@ -498,7 +543,11 @@ final class CraftHybrid: NSObject {
498
543
  /// The document-start script the hybrid page runs, with the route table
499
544
  /// and the shared storage keys filled in.
500
545
  func pageScript() -> String {
501
- let routeList = (config.nativeScreens ?? [:]).map { ["pattern": $0.key, "screen": $0.value] }
546
+ let routeList: [[String: Any]] = (config.nativeScreens ?? [:]).map { pattern, screen in
547
+ let encoded = try? JSONEncoder().encode(config.nativeScreenConstraints?[pattern] ?? [:])
548
+ let constraints = encoded.flatMap { try? JSONSerialization.jsonObject(with: $0) } ?? [:]
549
+ return ["pattern": pattern, "screen": screen, "constraints": constraints]
550
+ }
502
551
  let share = Array((config.shareStorage ?? [:]).keys).sorted()
503
552
  // The page's key for the sign-in token, when it shares one.
504
553
  let tokenKey = CraftSession.tokenKey(config)
@@ -537,7 +586,7 @@ final class CraftHybrid: NSObject {
537
586
  var table = [];
538
587
  for (var i = 0; i < ROUTES.length; i++) {
539
588
  var pattern = normalize(ROUTES[i].pattern);
540
- if (pattern && ROUTES[i].screen) table.push({ pattern: pattern, segments: segments(pattern), screen: ROUTES[i].screen });
589
+ if (pattern && ROUTES[i].screen) table.push({ pattern: pattern, segments: segments(pattern), screen: ROUTES[i].screen, constraints: ROUTES[i].constraints === undefined ? {} : ROUTES[i].constraints });
541
590
  }
542
591
  function wild(r) { return r.segments[r.segments.length - 1] === '*' ? 1 : 0; }
543
592
  function fixed(r) { return r.segments.filter(function (s) { return s !== '*' && s.charAt(0) !== ':'; }).length; }
@@ -562,6 +611,27 @@ final class CraftHybrid: NSObject {
562
611
  for (var i = 0; i < table.length; i++) {
563
612
  var params = bind(table[i].segments, parts);
564
613
  if (!params) continue;
614
+ var rules = table[i].constraints;
615
+ if (!rules || typeof rules !== 'object' || Array.isArray(rules)) continue;
616
+ var accepted = Object.keys(rules).every(function (name) {
617
+ var rule = table[i].constraints[name], value = params[name];
618
+ if (!rule || typeof rule !== 'object' || Array.isArray(rule) || typeof value !== 'string') return false;
619
+ if (Object.keys(rule).some(function (key) { return ['pattern', 'exclude', 'normalize'].indexOf(key) < 0; })) return false;
620
+ if (rule.normalize !== undefined && rule.normalize !== 'lowercase') return false;
621
+ if (rule.normalize === 'lowercase') value = value.toLowerCase();
622
+ if (rule.exclude !== undefined) {
623
+ if (!Array.isArray(rule.exclude) || rule.exclude.some(function (item) { return typeof item !== 'string'; })) return false;
624
+ if (rule.exclude.some(function (item) { return (rule.normalize === 'lowercase' ? item.toLowerCase() : item) === value; })) return false;
625
+ }
626
+ if (rule.pattern !== undefined) {
627
+ if (typeof rule.pattern !== 'string') return false;
628
+ try { var found = new RegExp('^(?:' + rule.pattern + ')$').exec(value); if (!found || found[0] !== value) return false; }
629
+ catch (e) { return false; }
630
+ }
631
+ params[name] = value;
632
+ return true;
633
+ });
634
+ if (!accepted) continue;
565
635
  var query = raw.indexOf('?');
566
636
  if (query >= 0) {
567
637
  raw.slice(query + 1).split('#')[0].split('&').forEach(function (item) {
@@ -57,6 +57,7 @@ enum CraftNativeActions {
57
57
  values.append("viewScroll")
58
58
  values.append("crypto")
59
59
  values.append("shortcuts")
60
+ values.append("accessibility")
60
61
  if config.enableBiometric { values.append("biometric") }
61
62
  if config.enableSecureStorage { values.append("secureStorage") }
62
63
  if config.enableLocalDatabase { values.append("database") }
@@ -91,7 +92,7 @@ enum CraftNativeActions {
91
92
  if config.enableAppleMusic { values.append("music") }
92
93
  // `craft.background.complete(ok, id)`: the answer to a silent push or
93
94
  // a background refresh, which the app script (`nativeApp`) hears.
94
- if config.backgroundRefresh?.enabled == true || config.enablePushNotifications { values.append("background") }
95
+ if config.backgroundRefresh?.enabled == true || config.enablePushNotifications || !(config.notificationCategories ?? []).isEmpty { values.append("background") }
95
96
  // A heart-rate strap is the app's own service (CraftHeartRate.swift),
96
97
  // so it needs no web view.
97
98
  if config.enableHeartRate { values.append(contentsOf: ["heartRate", "heartRateRecordingOwnership", "heartRateRecordingIntentIdentity"]) }
@@ -107,6 +108,8 @@ enum CraftNativeActions {
107
108
  // Video and audio kept on the phone for offline (CraftNativeMedia),
108
109
  // which the native Video element plays in place of the address.
109
110
  values.append("media")
111
+ values.append("media-cache-key")
112
+ values.append("media-remove")
110
113
  if config.enableCamera { values.append("camera") }
111
114
  if config.enableAudioRecording { values.append("audioRecording") }
112
115
  // craft.crashReporter: the app's crash and error reporter (bughq).
@@ -189,6 +192,23 @@ enum CraftNativeActions {
189
192
 
190
193
  let sync: Result<Any, CraftNativeActionError>?
191
194
  switch (module, method) {
195
+ case ("Accessibility", "announce"):
196
+ guard let text = args.first as? String, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
197
+ sync = failure("INVALID_ARGUMENT", "Accessibility.announce needs nonempty text")
198
+ break
199
+ }
200
+ let options = args.count > 1 ? args[1] : NSNull()
201
+ guard options is NSNull || options is [String: Any] else {
202
+ sync = failure("INVALID_ARGUMENT", "Accessibility.announce options must be an object")
203
+ break
204
+ }
205
+ let polite = (options as? [String: Any])?["polite"]
206
+ guard polite == nil || (polite as? NSNumber).map({ CFGetTypeID($0) == CFBooleanGetTypeID() }) == true else {
207
+ sync = failure("INVALID_ARGUMENT", "Accessibility.announce polite must be a Boolean")
208
+ break
209
+ }
210
+ announce(text, polite: polite as? Bool ?? true)
211
+ sync = .success(true)
192
212
  case ("Crypto", "randomUUID"):
193
213
  sync = .success(UUID().uuidString.lowercased())
194
214
  case ("Device", "getInfo"):
@@ -313,9 +333,31 @@ enum CraftNativeActions {
313
333
  keepMedia(requestToken: requestToken, args: args, completion: completion)
314
334
  return
315
335
  case ("Media", "path"):
316
- sync = .success(CraftNativeMedia.local(args.first as? String ?? "")?.path ?? NSNull())
336
+ sync = .success(CraftNativeMedia.local(CraftNativeMedia.identity(args.first as? String ?? "", options: args.count > 1 ? args[1] as? [String: Any] ?? [:] : [:]))?.path ?? NSNull())
337
+ case ("Media", "remove"):
338
+ let options = args.count > 1 ? args[1] as? [String: Any] ?? [:] : [:]
339
+ if let value = options["cacheKey"], !(value is NSNull) {
340
+ guard let key = value as? String, !key.isEmpty, key.utf8.count <= 1024 else {
341
+ sync = failure("INVALID_ARGUMENT", "media.remove cacheKey must be a nonempty string of at most 1024 bytes")
342
+ break
343
+ }
344
+ }
345
+ let address = CraftNativeMedia.identity(args.first as? String ?? "", options: options)
346
+ guard !address.isEmpty else {
347
+ sync = failure("INVALID_ARGUMENT", "media.remove needs a source or cache key")
348
+ break
349
+ }
350
+ DispatchQueue.global(qos: .utility).async {
351
+ do { complete(requestToken, .success(try CraftNativeMedia.remove(address)), completion) }
352
+ catch { complete(requestToken, failure("IO_ERROR", "The media copy could not be removed"), completion) }
353
+ }
354
+ return
317
355
  case ("Media", "prune"):
318
- let keep = (args.first as? [Any] ?? []).compactMap { $0 as? String }
356
+ let keep = (args.first as? [Any] ?? []).compactMap { value -> String? in
357
+ if let url = value as? String { return url }
358
+ guard let source = value as? [String: Any], let url = source["url"] as? String else { return nil }
359
+ return CraftNativeMedia.identity(url, options: source)
360
+ }
319
361
  DispatchQueue.global(qos: .utility).async {
320
362
  complete(requestToken, .success(CraftNativeMedia.prune(keep: keep)), completion)
321
363
  }
@@ -661,7 +703,7 @@ enum CraftNativeActions {
661
703
  // The app script's answer to `craftSilentPush` or
662
704
  // `craftBackgroundRefresh`: args `[ok, id?]`, with no id for
663
705
  // everything still pending, as a page's `craft.background.complete`.
664
- guard config.backgroundRefresh?.enabled == true || config.enablePushNotifications else {
706
+ guard config.backgroundRefresh?.enabled == true || config.enablePushNotifications || !(config.notificationCategories ?? []).isEmpty else {
665
707
  complete(requestToken, failure("CAPABILITY_DISABLED", "Background work is disabled"), completion)
666
708
  return
667
709
  }
@@ -690,7 +732,15 @@ enum CraftNativeActions {
690
732
  complete(requestToken, failure("CAPABILITY_DISABLED", "Local notifications are disabled"), completion)
691
733
  return
692
734
  }
693
- notifications(requestToken: requestToken, method: method, args: args, completion: completion)
735
+ // Staging notification media can copy large files; keep it off the
736
+ // renderer/JavaScript thread. UserNotifications is thread-safe.
737
+ if method == "schedule" {
738
+ DispatchQueue.global(qos: .utility).async {
739
+ notifications(requestToken: requestToken, method: method, args: args, completion: completion)
740
+ }
741
+ } else {
742
+ notifications(requestToken: requestToken, method: method, args: args, completion: completion)
743
+ }
694
744
  return
695
745
  default:
696
746
  sync = failure("UNKNOWN_ACTION", "Unsupported native API")
@@ -924,18 +974,19 @@ enum CraftNativeActions {
924
974
  args: [Any],
925
975
  completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
926
976
  ) {
927
- guard let address = args.first as? String,
928
- let url = URL(string: address),
977
+ guard let source = args.first as? String,
978
+ let url = URL(string: source),
929
979
  let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http",
930
980
  url.host?.isEmpty == false else {
931
981
  complete(requestToken, failure("INVALID_ARGUMENT", "media.keep needs an absolute http or https URL"), completion)
932
982
  return
933
983
  }
984
+ let options = args.count > 1 ? args[1] as? [String: Any] ?? [:] : [:]
985
+ let address = CraftNativeMedia.identity(source, options: options)
934
986
  if let kept = CraftNativeMedia.local(address) {
935
987
  complete(requestToken, .success(CraftNativeMedia.describe(address, kept)), completion)
936
988
  return
937
989
  }
938
- let options = args.count > 1 ? args[1] as? [String: Any] ?? [:] : [:]
939
990
  var request = URLRequest(url: url, timeoutInterval: craftNativeFetchTimeoutSeconds)
940
991
  request.httpMethod = "GET"
941
992
  if let headers = options["headers"] as? [String: Any] {
@@ -969,7 +1020,8 @@ enum CraftNativeActions {
969
1020
  // On the app's background session (CraftNativeMediaTransfers): it
970
1021
  // carries on while the app is suspended, and a keep the phone cannot
971
1022
  // fetch now is answered at once while its transfer waits with iOS.
972
- let task = CraftNativeMediaTransfers.shared.start(request, address: address, flight: flight, wifiOnly: wifiOnly)
1023
+ guard let ownership = CraftNativeMedia.ownershipStamp(address, flight: flight) else { return }
1024
+ let task = CraftNativeMediaTransfers.shared.start(request, address: address, flight: flight, ownership: ownership, wifiOnly: wifiOnly)
973
1025
  cancellationLock.lock()
974
1026
  // Unless it answered already (a queued transfer, or an instant failure).
975
1027
  if CraftNativeMedia.isWaiting(address, flight: flight) { fetchTasks[requestToken] = task }
@@ -1305,11 +1357,17 @@ enum CraftNativeActions {
1305
1357
  return failure("DATABASE_ERROR", message)
1306
1358
  }
1307
1359
 
1308
- private static func notifications(
1360
+ static func notifications(
1309
1361
  requestToken: String,
1310
1362
  method: String,
1311
1363
  args: [Any],
1312
- completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
1364
+ completion: @escaping (Result<Any, CraftNativeActionError>) -> Void,
1365
+ authorize: (@escaping (Bool, Error?) -> Void) -> Void = {
1366
+ UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge], completionHandler: $0)
1367
+ },
1368
+ submit: @escaping (UNNotificationRequest, @escaping (Error?) -> Void) -> Void = {
1369
+ UNUserNotificationCenter.current().add($0, withCompletionHandler: $1)
1370
+ }
1313
1371
  ) {
1314
1372
  let center = UNUserNotificationCenter.current()
1315
1373
  switch method {
@@ -1330,16 +1388,35 @@ enum CraftNativeActions {
1330
1388
  }), completion)
1331
1389
  }
1332
1390
  case "schedule":
1391
+ guard !isCancelled(requestToken) else {
1392
+ complete(requestToken, failure("CANCELLED", "Notification request cancelled"), completion)
1393
+ return
1394
+ }
1333
1395
  guard let value = args.first as? [String: Any], let title = value["title"] as? String, !title.isEmpty else {
1334
1396
  complete(requestToken, failure("INVALID_ARGUMENT", "Notifications.schedule needs a title"), completion)
1335
1397
  return
1336
1398
  }
1337
- center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, authorizationError in
1399
+ let content = UNMutableNotificationContent()
1400
+ let files: CraftNotificationAttachments
1401
+ do {
1402
+ try CraftNotificationCategory.apply(value, to: content)
1403
+ files = try CraftNotificationAttachments(value["attachments"])
1404
+ content.attachments = files.attachments
1405
+ } catch {
1406
+ complete(requestToken, failure("INVALID_ARGUMENT", error.localizedDescription), completion)
1407
+ return
1408
+ }
1409
+ authorize { granted, authorizationError in
1410
+ guard !isCancelled(requestToken) else {
1411
+ files.cleanup()
1412
+ complete(requestToken, failure("CANCELLED", "Notification request cancelled"), completion)
1413
+ return
1414
+ }
1338
1415
  guard authorizationError == nil, granted else {
1416
+ files.cleanup()
1339
1417
  complete(requestToken, failure("PERMISSION_DENIED", authorizationError?.localizedDescription ?? "Notification permission denied"), completion)
1340
1418
  return
1341
1419
  }
1342
- let content = UNMutableNotificationContent()
1343
1420
  content.title = title
1344
1421
  content.body = value["body"] as? String ?? ""
1345
1422
  content.sound = .default
@@ -1353,7 +1430,7 @@ enum CraftNativeActions {
1353
1430
  let id = (value["id"] as? String).flatMap { validKey($0) } ?? UUID().uuidString
1354
1431
  let delay = craftNativeNumber(value["delay"]).map { $0.doubleValue }.flatMap { $0.isFinite ? $0 : nil }
1355
1432
  let trigger = delay.map { UNTimeIntervalNotificationTrigger(timeInterval: max($0 / 1_000, 1), repeats: false) }
1356
- center.add(UNNotificationRequest(identifier: id, content: content, trigger: trigger)) { addError in
1433
+ files.schedule(UNNotificationRequest(identifier: id, content: content, trigger: trigger), submit: submit) { addError in
1357
1434
  if let addError = addError {
1358
1435
  complete(requestToken, failure("NOTIFICATION_ERROR", addError.localizedDescription), completion)
1359
1436
  } else {
@@ -1424,6 +1501,20 @@ enum CraftNativeActions {
1424
1501
  CraftNativeActionError(code: code, message: message)
1425
1502
  }
1426
1503
 
1504
+ /// Announcements report a status without moving VoiceOver's current focus.
1505
+ static func announce(_ text: String, polite: Bool,
1506
+ post: (UIAccessibility.Notification, Any?) -> Void = { UIAccessibility.post(notification: $0, argument: $1) }) {
1507
+ let spoken: NSAttributedString
1508
+ if #available(iOS 17.0, *) {
1509
+ spoken = NSAttributedString(string: text, attributes: [
1510
+ .accessibilitySpeechAnnouncementPriority: polite ? UIAccessibilityPriority.low : UIAccessibilityPriority.default,
1511
+ ])
1512
+ } else {
1513
+ spoken = NSAttributedString(string: text, attributes: [.accessibilitySpeechQueueAnnouncement: polite])
1514
+ }
1515
+ post(.announcement, spoken)
1516
+ }
1517
+
1427
1518
  private static func failure(_ code: String, _ message: String) -> Result<Any, CraftNativeActionError> {
1428
1519
  .failure(error(code, message))
1429
1520
  }