@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
|
@@ -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
package/templates/CraftApp.swift
CHANGED
|
@@ -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
|
-
|
|
386
|
-
|
|
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
|
-
//
|
|
1555
|
-
//
|
|
1556
|
-
private var
|
|
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:
|
|
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:
|
|
1649
|
+
dispatchDeepLink(url, initial: initial)
|
|
1602
1650
|
} else {
|
|
1603
|
-
pendingURLs.append((url,
|
|
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:
|
|
1659
|
+
dispatchDeepLink(url, initial: initial)
|
|
1612
1660
|
} else {
|
|
1613
|
-
pendingURLs.append((url,
|
|
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,
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
928
|
-
let url = URL(string:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|