craft-native 0.0.122 → 0.0.124
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 +3 -0
- package/dist/android/src/index.d.ts +3 -0
- package/dist/android/src/index.js +17 -0
- package/dist/android/templates/AndroidManifest.xml.template +1 -1
- package/dist/android/templates/CraftNativeFlatList.kt.template +113 -6
- package/dist/android/templates/MainActivity.kt.template +17 -0
- package/dist/android/templates/MainActivityNative.kt.template +826 -68
- package/dist/api/mobile.d.ts +251 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +22 -1
- package/dist/index.js +22 -1
- package/dist/ios/src/index.d.ts +90 -2
- package/dist/ios/src/index.js +177 -44
- package/dist/ios/templates/CraftApp.swift +1341 -51
- package/dist/ios/templates/CraftNativeActions.swift +97 -9
- package/dist/ios/templates/CraftNativeFlatList.swift +92 -12
- package/dist/ios/templates/CraftNativeScreen.swift +876 -64
- package/dist/ios/templates/CraftNativeUI.swift +562 -0
- package/dist/ios/templates/Info.plist.template +3 -3
- package/dist/mobile.js +187 -0
- package/package.json +1 -1
|
@@ -30,10 +30,92 @@ import WidgetKit
|
|
|
30
30
|
import Intents
|
|
31
31
|
import WatchConnectivity
|
|
32
32
|
import ActivityKit
|
|
33
|
+
import SafariServices
|
|
33
34
|
|
|
34
35
|
extension Notification.Name {
|
|
35
36
|
static let craftPushToken = Notification.Name("craftPushToken")
|
|
36
37
|
static let craftPushRegistrationError = Notification.Name("craftPushRegistrationError")
|
|
38
|
+
/// The screen's Light or Dark appearance changed (SwiftUI sees it first).
|
|
39
|
+
static let craftColorSchemeChanged = Notification.Name("craftColorSchemeChanged")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// The reading and motion settings the page should match: text size, Reduce
|
|
43
|
+
/// Motion, Reduce Transparency, Light or Dark.
|
|
44
|
+
///
|
|
45
|
+
/// A web page sees Dark Mode through `prefers-color-scheme` and Reduce Motion
|
|
46
|
+
/// through `prefers-reduced-motion`, but not the Dynamic Type size a native
|
|
47
|
+
/// app's text follows, so a page could not grow its text with everything else
|
|
48
|
+
/// on the phone. The shell tells it, from the first byte of every document
|
|
49
|
+
/// (`--craft-font-scale` and `data-craft-*` on the root element) and with a
|
|
50
|
+
/// `craftAppearance` event when any of it changes.
|
|
51
|
+
enum CraftPageAppearance {
|
|
52
|
+
static func snapshot(style: UIUserInterfaceStyle) -> [String: Any] {
|
|
53
|
+
let category = UIApplication.shared.preferredContentSizeCategory
|
|
54
|
+
let traits = UITraitCollection(preferredContentSizeCategory: category)
|
|
55
|
+
// How much body text has grown or shrunk from its default 17 points.
|
|
56
|
+
let scale = UIFontMetrics(forTextStyle: .body).scaledValue(for: 17, compatibleWith: traits) / 17
|
|
57
|
+
return [
|
|
58
|
+
"contentSizeCategory": name(category),
|
|
59
|
+
"fontScale": (Double(scale) * 1000).rounded() / 1000,
|
|
60
|
+
"reduceMotion": UIAccessibility.isReduceMotionEnabled,
|
|
61
|
+
"reduceTransparency": UIAccessibility.isReduceTransparencyEnabled,
|
|
62
|
+
"colorScheme": style == .dark ? "dark" : "light",
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// The script that applies a snapshot to the page, announcing it as
|
|
67
|
+
/// `craftAppearance` when `announce` is true.
|
|
68
|
+
static func script(_ snapshot: [String: Any], announce: Bool) -> String {
|
|
69
|
+
guard let data = try? JSONSerialization.data(withJSONObject: snapshot),
|
|
70
|
+
let json = String(data: data, encoding: .utf8) else { return "" }
|
|
71
|
+
return """
|
|
72
|
+
(function(a) {
|
|
73
|
+
var root = document.documentElement;
|
|
74
|
+
if (root) {
|
|
75
|
+
root.style.setProperty('--craft-font-scale', String(a.fontScale));
|
|
76
|
+
root.setAttribute('data-craft-reduce-motion', a.reduceMotion ? 'true' : 'false');
|
|
77
|
+
root.setAttribute('data-craft-reduce-transparency', a.reduceTransparency ? 'true' : 'false');
|
|
78
|
+
root.setAttribute('data-craft-content-size', a.contentSizeCategory);
|
|
79
|
+
}
|
|
80
|
+
window.__craftAppearance = a;
|
|
81
|
+
\(announce ? "window.dispatchEvent(new CustomEvent('craftAppearance', {detail: a}));" : "")
|
|
82
|
+
})(\(json));
|
|
83
|
+
"""
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private static func name(_ category: UIContentSizeCategory) -> String {
|
|
87
|
+
switch category {
|
|
88
|
+
case .extraSmall: return "extraSmall"
|
|
89
|
+
case .small: return "small"
|
|
90
|
+
case .medium: return "medium"
|
|
91
|
+
case .large: return "large"
|
|
92
|
+
case .extraLarge: return "extraLarge"
|
|
93
|
+
case .extraExtraLarge: return "extraExtraLarge"
|
|
94
|
+
case .extraExtraExtraLarge: return "extraExtraExtraLarge"
|
|
95
|
+
case .accessibilityMedium: return "accessibilityMedium"
|
|
96
|
+
case .accessibilityLarge: return "accessibilityLarge"
|
|
97
|
+
case .accessibilityExtraLarge: return "accessibilityExtraLarge"
|
|
98
|
+
case .accessibilityExtraExtraLarge: return "accessibilityExtraExtraLarge"
|
|
99
|
+
case .accessibilityExtraExtraExtraLarge: return "accessibilityExtraExtraExtraLarge"
|
|
100
|
+
default: return "large"
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// Tells the shell when SwiftUI's colour scheme changes: the one appearance
|
|
106
|
+
/// setting with no notification of its own.
|
|
107
|
+
struct CraftColorSchemeObserver: View {
|
|
108
|
+
@Environment(\.colorScheme) private var colorScheme
|
|
109
|
+
|
|
110
|
+
var body: some View {
|
|
111
|
+
Color.clear
|
|
112
|
+
.frame(width: 0, height: 0)
|
|
113
|
+
.allowsHitTesting(false)
|
|
114
|
+
.accessibilityHidden(true)
|
|
115
|
+
.onChange(of: colorScheme) { _ in
|
|
116
|
+
NotificationCenter.default.post(name: .craftColorSchemeChanged, object: nil)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
37
119
|
}
|
|
38
120
|
|
|
39
121
|
/// Which failed page loads mean the remote origin is out of reach (#252).
|
|
@@ -72,9 +154,49 @@ enum CraftLoadFailure {
|
|
|
72
154
|
}
|
|
73
155
|
}
|
|
74
156
|
|
|
157
|
+
/// How many times a dead web content process is brought back before the
|
|
158
|
+
/// shell stops and shows the offline page instead.
|
|
159
|
+
///
|
|
160
|
+
/// The port of `webview_recovery.zig`'s budget. Reloading on every crash is
|
|
161
|
+
/// the whole fix for a white screen, and reloading unconditionally is the
|
|
162
|
+
/// whole problem: a page that reliably kills its renderer (out of memory, a
|
|
163
|
+
/// bad WebGL context) would be reloaded into the same crash forever. The
|
|
164
|
+
/// budget is a rate, not a lifetime count: an app that loses its renderer
|
|
165
|
+
/// once a day is healthy, so a crash more than `resetAfter` after the last
|
|
166
|
+
/// one starts a new burst with its budget back.
|
|
167
|
+
///
|
|
168
|
+
/// Foundation only, and given the clock, so `scripts/recovery-budget.ts`
|
|
169
|
+
/// can compile and run it on its own.
|
|
170
|
+
struct CraftRecoveryBudget {
|
|
171
|
+
enum Action: Equatable {
|
|
172
|
+
case reload
|
|
173
|
+
case giveUp
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
var maxAttempts = 3
|
|
177
|
+
var resetAfter: TimeInterval = 60
|
|
178
|
+
private(set) var attempts = 0
|
|
179
|
+
private(set) var lastCrash: TimeInterval?
|
|
180
|
+
|
|
181
|
+
mutating func onCrash(at now: TimeInterval) -> Action {
|
|
182
|
+
let sameBurst = lastCrash.map { now - $0 < resetAfter } ?? false
|
|
183
|
+
attempts = sameBurst ? attempts + 1 : 1
|
|
184
|
+
lastCrash = now
|
|
185
|
+
return attempts <= maxAttempts ? .reload : .giveUp
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/// Someone asked for the page again (Retry): a fresh budget.
|
|
189
|
+
mutating func reset() {
|
|
190
|
+
attempts = 0
|
|
191
|
+
lastCrash = nil
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
75
195
|
final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
|
76
196
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
|
77
197
|
UNUserNotificationCenter.current().delegate = self
|
|
198
|
+
// Before launch finishes, which is the only time iOS accepts it.
|
|
199
|
+
CraftBackgroundWork.shared.registerRefresh(CraftConfig.bundled())
|
|
78
200
|
if let shortcut = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem {
|
|
79
201
|
CraftEventManager.shared.handleShortcut(shortcut)
|
|
80
202
|
// Returning false tells UIKit the launch-time item was handled and
|
|
@@ -90,7 +212,28 @@ final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificatio
|
|
|
90
212
|
}
|
|
91
213
|
|
|
92
214
|
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
|
|
93
|
-
|
|
215
|
+
// A universal link takes the same road as a custom-scheme link. The
|
|
216
|
+
// scene's onContinueUserActivity is where they normally arrive; this
|
|
217
|
+
// is the road for a launch that does not go through a scene.
|
|
218
|
+
if userActivity.activityType == NSUserActivityTypeBrowsingWeb, let url = userActivity.webpageURL {
|
|
219
|
+
DeepLinkManager.shared.handleURL(url)
|
|
220
|
+
return true
|
|
221
|
+
}
|
|
222
|
+
return CraftEventManager.shared.handleSiriActivity(userActivity)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/// A push with `content-available`: the page gets `craftSilentPush` with
|
|
226
|
+
/// the payload, and iOS gets its answer when the page calls
|
|
227
|
+
/// `craft.background.complete(ok)`, or after 25 seconds, inside the 30 iOS
|
|
228
|
+
/// allows.
|
|
229
|
+
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
|
230
|
+
CraftBackgroundWork.shared.begin("craftSilentPush", detail: ["payload": CraftEventManager.pageData(userInfo)]) { ok in
|
|
231
|
+
switch ok {
|
|
232
|
+
case true?: completionHandler(.newData)
|
|
233
|
+
case false?: completionHandler(.failed)
|
|
234
|
+
case nil: completionHandler(.noData)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
94
237
|
}
|
|
95
238
|
|
|
96
239
|
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
|
@@ -170,7 +313,7 @@ class CraftEventManager {
|
|
|
170
313
|
sendToWeb("craftNotificationReceived", data: Self.pageData(userInfo))
|
|
171
314
|
}
|
|
172
315
|
|
|
173
|
-
|
|
316
|
+
static func pageData(_ userInfo: [AnyHashable: Any]) -> [String: Any] {
|
|
174
317
|
var data: [String: Any] = [:]
|
|
175
318
|
for (key, value) in userInfo {
|
|
176
319
|
guard let key = key as? String else { continue }
|
|
@@ -194,7 +337,8 @@ class CraftEventManager {
|
|
|
194
337
|
return true
|
|
195
338
|
}
|
|
196
339
|
|
|
197
|
-
|
|
340
|
+
/// Dispatch now if the page can hear it, or hold it until it can.
|
|
341
|
+
func sendToWeb(_ event: String, data: [String: Any]) {
|
|
198
342
|
guard isReady, webView != nil else {
|
|
199
343
|
pendingEvents.append((event, data))
|
|
200
344
|
return
|
|
@@ -213,6 +357,99 @@ class CraftEventManager {
|
|
|
213
357
|
}
|
|
214
358
|
}
|
|
215
359
|
|
|
360
|
+
// MARK: - Background work
|
|
361
|
+
|
|
362
|
+
/// The page's share of background time: a silent push (`craftSilentPush`) and
|
|
363
|
+
/// iOS's scheduled app refresh (`craftBackgroundRefresh`). Each holds iOS's
|
|
364
|
+
/// completion until the page calls `craft.background.complete(ok)` (with the
|
|
365
|
+
/// event's `id`, or with none for everything pending), or for 25 seconds.
|
|
366
|
+
///
|
|
367
|
+
/// The page has to be alive to do the work: when iOS wakes an app it had
|
|
368
|
+
/// suspended, it is; when it launches one it had ended, the event waits for
|
|
369
|
+
/// the page like any other and the deadline answers iOS.
|
|
370
|
+
final class CraftBackgroundWork {
|
|
371
|
+
static let shared = CraftBackgroundWork()
|
|
372
|
+
static let deadline: TimeInterval = 25
|
|
373
|
+
|
|
374
|
+
private var pending: [String: (Bool?) -> Void] = [:]
|
|
375
|
+
private var refreshIdentifier: String?
|
|
376
|
+
private var refreshInterval: TimeInterval = 15 * 60
|
|
377
|
+
|
|
378
|
+
private init() {}
|
|
379
|
+
|
|
380
|
+
/// Tell the page, and hold `finish` until it answers: true or false from
|
|
381
|
+
/// the page, nil when the deadline passed first.
|
|
382
|
+
@discardableResult
|
|
383
|
+
func begin(_ event: String, detail: [String: Any], finish: @escaping (Bool?) -> Void) -> String {
|
|
384
|
+
let id = UUID().uuidString
|
|
385
|
+
pending[id] = finish
|
|
386
|
+
var detail = detail
|
|
387
|
+
detail["id"] = id
|
|
388
|
+
CraftEventManager.shared.sendToWeb(event, data: detail)
|
|
389
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + Self.deadline) { [weak self] in self?.end(id, ok: nil) }
|
|
390
|
+
return id
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/// `craft.background.complete(ok, id?)`.
|
|
394
|
+
func complete(id: String?, ok: Bool) {
|
|
395
|
+
if let id {
|
|
396
|
+
end(id, ok: ok)
|
|
397
|
+
} else {
|
|
398
|
+
for id in Array(pending.keys) { end(id, ok: ok) }
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private func end(_ id: String, ok: Bool?) {
|
|
403
|
+
guard let finish = pending.removeValue(forKey: id) else { return }
|
|
404
|
+
finish(ok)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/// BGAppRefreshTask, when `backgroundRefresh.enabled`: registered at
|
|
408
|
+
/// launch, asked for again each time the app goes to the background and
|
|
409
|
+
/// after each run, no sooner than `minimumIntervalMinutes` (15 by
|
|
410
|
+
/// default). iOS decides when it actually runs.
|
|
411
|
+
func registerRefresh(_ config: CraftConfig) {
|
|
412
|
+
guard let refresh = config.backgroundRefresh, refresh.enabled else { return }
|
|
413
|
+
let identifier = refresh.identifier.flatMap { $0.isEmpty ? nil : $0 }
|
|
414
|
+
?? "\(Bundle.main.bundleIdentifier ?? config.bundleId).refresh"
|
|
415
|
+
refreshIdentifier = identifier
|
|
416
|
+
refreshInterval = max(1, refresh.minimumIntervalMinutes ?? 15) * 60
|
|
417
|
+
let registered = BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier, using: nil) { task in
|
|
418
|
+
DispatchQueue.main.async { CraftBackgroundWork.shared.run(task) }
|
|
419
|
+
}
|
|
420
|
+
guard registered else {
|
|
421
|
+
print("Craft: background refresh \(identifier) could not be registered; is it in BGTaskSchedulerPermittedIdentifiers?")
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { _ in
|
|
425
|
+
CraftBackgroundWork.shared.scheduleRefresh()
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
func scheduleRefresh() {
|
|
430
|
+
guard let refreshIdentifier else { return }
|
|
431
|
+
let request = BGAppRefreshTaskRequest(identifier: refreshIdentifier)
|
|
432
|
+
request.earliestBeginDate = Date(timeIntervalSinceNow: refreshInterval)
|
|
433
|
+
do {
|
|
434
|
+
try BGTaskScheduler.shared.submit(request)
|
|
435
|
+
} catch {
|
|
436
|
+
// The simulator refuses every request; a device refuses one
|
|
437
|
+
// with Background App Refresh off.
|
|
438
|
+
print("Craft: background refresh not scheduled: \(error)")
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private func run(_ task: BGTask) {
|
|
443
|
+
scheduleRefresh()
|
|
444
|
+
let id = begin("craftBackgroundRefresh", detail: [:]) { ok in
|
|
445
|
+
task.setTaskCompleted(success: ok ?? false)
|
|
446
|
+
}
|
|
447
|
+
task.expirationHandler = {
|
|
448
|
+
DispatchQueue.main.async { CraftBackgroundWork.shared.end(id, ok: false) }
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
216
453
|
// MARK: - App Entry Point
|
|
217
454
|
@main
|
|
218
455
|
struct CraftApp: App {
|
|
@@ -225,8 +462,15 @@ struct CraftApp: App {
|
|
|
225
462
|
if appState.config.renderer == "native" {
|
|
226
463
|
CraftNativeScreen(config: appState.config)
|
|
227
464
|
} else {
|
|
228
|
-
|
|
229
|
-
|
|
465
|
+
// The page's own chrome, drawn natively over it: the tab
|
|
466
|
+
// bar the page asks for, and the splash until it is ready.
|
|
467
|
+
ZStack(alignment: .bottom) {
|
|
468
|
+
CraftColorSchemeObserver()
|
|
469
|
+
CraftWebView(config: appState.config)
|
|
470
|
+
.ignoresSafeArea()
|
|
471
|
+
CraftTabBarView()
|
|
472
|
+
CraftSplashView(background: appState.config.resolvedBackgroundColor)
|
|
473
|
+
}
|
|
230
474
|
}
|
|
231
475
|
}
|
|
232
476
|
.preferredColorScheme(appState.config.colorScheme)
|
|
@@ -244,6 +488,329 @@ struct CraftApp: App {
|
|
|
244
488
|
}
|
|
245
489
|
}
|
|
246
490
|
|
|
491
|
+
// MARK: - Native chrome: the tab bar and the launch splash
|
|
492
|
+
//
|
|
493
|
+
// Both belong to the screen rather than to the page, so they are drawn here in
|
|
494
|
+
// SwiftUI and the page only describes them. The page reaches them through the
|
|
495
|
+
// `craftChrome` message handler, which exists from the first byte of the
|
|
496
|
+
// document; `window.craft` arrives only once the page has finished loading,
|
|
497
|
+
// images and all, and a tab bar or a splash that waited for it would be late
|
|
498
|
+
// on every launch.
|
|
499
|
+
|
|
500
|
+
final class CraftChrome: ObservableObject {
|
|
501
|
+
static let shared = CraftChrome()
|
|
502
|
+
|
|
503
|
+
struct Tab: Identifiable, Equatable {
|
|
504
|
+
let id: String
|
|
505
|
+
let title: String
|
|
506
|
+
let symbol: String
|
|
507
|
+
let badge: String?
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
@Published var tabs: [Tab] = []
|
|
511
|
+
@Published var selected: String?
|
|
512
|
+
@Published var tabBarVisible = false
|
|
513
|
+
@Published var splashVisible = true
|
|
514
|
+
@Published var tint: UIColor?
|
|
515
|
+
|
|
516
|
+
weak var webView: WKWebView?
|
|
517
|
+
/// What the tab bar covers at the bottom of the screen, in points.
|
|
518
|
+
private var occupied: CGFloat = 0
|
|
519
|
+
private var pendingHide: DispatchWorkItem?
|
|
520
|
+
private var splashDeadline: DispatchWorkItem?
|
|
521
|
+
|
|
522
|
+
private init() {}
|
|
523
|
+
|
|
524
|
+
/// However the page behaves, the splash is gone after `seconds`
|
|
525
|
+
/// (`splashMaxSeconds`, three by default). It used to be ten: a page that
|
|
526
|
+
/// never painted held a logo on screen for ten seconds, which reads as a
|
|
527
|
+
/// hang rather than a launch.
|
|
528
|
+
func holdSplash(atMost seconds: Double) {
|
|
529
|
+
splashDeadline?.cancel()
|
|
530
|
+
let deadline = DispatchWorkItem { [weak self] in self?.hideSplash() }
|
|
531
|
+
splashDeadline = deadline
|
|
532
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + max(0.5, seconds), execute: deadline)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
func handle(_ body: [String: Any]) {
|
|
536
|
+
switch body["type"] as? String {
|
|
537
|
+
case "tabBar":
|
|
538
|
+
pendingHide?.cancel()
|
|
539
|
+
tabs = (body["tabs"] as? [[String: Any]] ?? []).compactMap { item in
|
|
540
|
+
guard let id = item["id"] as? String, let title = item["title"] as? String else { return nil }
|
|
541
|
+
let badge = (item["badge"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
|
542
|
+
return Tab(id: id, title: title, symbol: item["symbol"] as? String ?? "circle", badge: badge)
|
|
543
|
+
}
|
|
544
|
+
if let selected = body["selected"] as? String { self.selected = selected }
|
|
545
|
+
if let hex = body["tint"] as? String {
|
|
546
|
+
let light = UIColor(hex: hex)
|
|
547
|
+
let dark = (body["tintDark"] as? String).flatMap { UIColor(hex: $0) }
|
|
548
|
+
tint = light.map { light in dark.map { dark in UIColor { $0.userInterfaceStyle == .dark ? dark : light } } ?? light }
|
|
549
|
+
}
|
|
550
|
+
withAnimation(Self.motion(.easeOut(duration: 0.2))) { tabBarVisible = !tabs.isEmpty }
|
|
551
|
+
publishLayout()
|
|
552
|
+
case "selectTab":
|
|
553
|
+
if let id = body["id"] as? String, id != selected {
|
|
554
|
+
withAnimation(Self.motion(.spring(response: 0.32, dampingFraction: 0.86))) { selected = id }
|
|
555
|
+
}
|
|
556
|
+
case "hideTabBar":
|
|
557
|
+
// Later, and only if no screen asks for the bar meanwhile: moving
|
|
558
|
+
// between two screens that both have it unmounts one bar and
|
|
559
|
+
// mounts the next, and the bar must not blink in between.
|
|
560
|
+
pendingHide?.cancel()
|
|
561
|
+
let hide = DispatchWorkItem { [weak self] in
|
|
562
|
+
withAnimation(Self.motion(.easeIn(duration: 0.18))) { self?.tabBarVisible = false }
|
|
563
|
+
self?.occupied = 0
|
|
564
|
+
self?.publishLayout()
|
|
565
|
+
}
|
|
566
|
+
pendingHide = hide
|
|
567
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: hide)
|
|
568
|
+
case "ready":
|
|
569
|
+
hideSplash()
|
|
570
|
+
case "painted":
|
|
571
|
+
// The page's first contentful paint, two frames on: something of
|
|
572
|
+
// the page is on screen, so the launch screen has done its job
|
|
573
|
+
// even if the page never says `ready`.
|
|
574
|
+
hideSplash()
|
|
575
|
+
default:
|
|
576
|
+
break
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/// A tap on a tab: the page navigates, and says which tab is current.
|
|
581
|
+
///
|
|
582
|
+
/// No haptic. UITabBar plays none, and a tab bar that ticks on every tap is
|
|
583
|
+
/// one of the small things that gives a web shell away.
|
|
584
|
+
func tap(_ tab: Tab) {
|
|
585
|
+
if tab.id != selected {
|
|
586
|
+
withAnimation(Self.motion(.spring(response: 0.32, dampingFraction: 0.86))) { selected = tab.id }
|
|
587
|
+
}
|
|
588
|
+
emit("craftTabSelect", ["id": tab.id])
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/// The animation, or none with Reduce Motion on: the selection jumps to
|
|
592
|
+
/// its tab rather than springing there, as UIKit's own controls do.
|
|
593
|
+
static func motion(_ animation: Animation) -> Animation? {
|
|
594
|
+
UIAccessibility.isReduceMotionEnabled ? nil : animation
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/// The splash again, over a page that is being rebuilt (the web content
|
|
598
|
+
/// process died), rather than the white flash of a reload.
|
|
599
|
+
func showSplash(atMost seconds: Double) {
|
|
600
|
+
withAnimation(nil) { splashVisible = true }
|
|
601
|
+
holdSplash(atMost: seconds)
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
func hideSplash() {
|
|
605
|
+
splashDeadline?.cancel()
|
|
606
|
+
splashDeadline = nil
|
|
607
|
+
guard splashVisible else { return }
|
|
608
|
+
withAnimation(.easeOut(duration: 0.28)) { splashVisible = false }
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/// The paint half of "until the page is ready or has painted": tells the
|
|
612
|
+
/// shell once the document's first contentful paint is on screen.
|
|
613
|
+
static let paintScript = """
|
|
614
|
+
(function() {
|
|
615
|
+
try {
|
|
616
|
+
var handler = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craftChrome;
|
|
617
|
+
if (!handler || typeof PerformanceObserver !== 'function') return;
|
|
618
|
+
var sent = false;
|
|
619
|
+
var send = function() {
|
|
620
|
+
if (sent) return;
|
|
621
|
+
sent = true;
|
|
622
|
+
requestAnimationFrame(function() { requestAnimationFrame(function() { handler.postMessage({type: 'painted'}); }); });
|
|
623
|
+
};
|
|
624
|
+
var observer = new PerformanceObserver(function(list) {
|
|
625
|
+
list.getEntries().forEach(function(entry) {
|
|
626
|
+
if (entry.name === 'first-contentful-paint') { send(); observer.disconnect(); }
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
observer.observe({type: 'paint', buffered: true});
|
|
630
|
+
} catch (e) {}
|
|
631
|
+
})();
|
|
632
|
+
"""
|
|
633
|
+
|
|
634
|
+
/// A page that loaded and never said it was ready still gets shown.
|
|
635
|
+
func pageFinished() {
|
|
636
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in self?.hideSplash() }
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
func layoutChanged(_ height: CGFloat) {
|
|
640
|
+
let rounded = height.rounded()
|
|
641
|
+
guard rounded != occupied else { return }
|
|
642
|
+
occupied = rounded
|
|
643
|
+
publishLayout()
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/// The bar's height as `--craft-tab-bar-height`, so the page leaves room
|
|
647
|
+
/// for it, and as an event for anything that measures.
|
|
648
|
+
func publishLayout() {
|
|
649
|
+
let height = tabBarVisible ? Int(occupied) : 0
|
|
650
|
+
let script = "document.documentElement.style.setProperty('--craft-tab-bar-height','\(height)px');window.dispatchEvent(new CustomEvent('craftTabBarLayout',{detail:{height:\(height)}}));"
|
|
651
|
+
DispatchQueue.main.async { [weak self] in self?.webView?.evaluateJavaScript(script, completionHandler: nil) }
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
private func emit(_ event: String, _ detail: [String: Any]) {
|
|
655
|
+
guard let data = try? JSONSerialization.data(withJSONObject: detail),
|
|
656
|
+
let json = String(data: data, encoding: .utf8) else { return }
|
|
657
|
+
let script = "window.dispatchEvent(new CustomEvent('\(event)',{detail:\(json)}));"
|
|
658
|
+
DispatchQueue.main.async { [weak self] in self?.webView?.evaluateJavaScript(script, completionHandler: nil) }
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/// Receives the page's chrome messages. Only from a trusted origin, like the bridge.
|
|
663
|
+
final class CraftChromeRelay: NSObject, WKScriptMessageHandler {
|
|
664
|
+
private let trusts: (WKSecurityOrigin) -> Bool
|
|
665
|
+
|
|
666
|
+
init(trusts: @escaping (WKSecurityOrigin) -> Bool) {
|
|
667
|
+
self.trusts = trusts
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
671
|
+
guard message.frameInfo.isMainFrame, trusts(message.frameInfo.securityOrigin),
|
|
672
|
+
let body = message.body as? [String: Any] else { return }
|
|
673
|
+
DispatchQueue.main.async { CraftChrome.shared.handle(body) }
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/// The floating tab bar: Liquid Glass on iOS 26 and later, a material capsule before.
|
|
678
|
+
struct CraftTabBarView: View {
|
|
679
|
+
@ObservedObject private var chrome = CraftChrome.shared
|
|
680
|
+
@Namespace private var selection
|
|
681
|
+
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
682
|
+
// Dynamic Type, relative to the styles UIKit's own bar uses. The bar is
|
|
683
|
+
// capped at the largest standard size (below), as UITabBar is; past that
|
|
684
|
+
// a long press shows the large content viewer instead.
|
|
685
|
+
@ScaledMetric(relativeTo: .title3) private var iconSize: CGFloat = 20
|
|
686
|
+
@ScaledMetric(relativeTo: .caption2) private var labelSize: CGFloat = 11
|
|
687
|
+
@ScaledMetric(relativeTo: .caption2) private var badgeSize: CGFloat = 10
|
|
688
|
+
|
|
689
|
+
var body: some View {
|
|
690
|
+
if chrome.tabBarVisible && !chrome.tabs.isEmpty {
|
|
691
|
+
bar
|
|
692
|
+
.padding(.horizontal, 16)
|
|
693
|
+
.padding(.bottom, 4)
|
|
694
|
+
.background(GeometryReader { proxy in
|
|
695
|
+
Color.clear
|
|
696
|
+
.onAppear { report(proxy) }
|
|
697
|
+
.onChange(of: proxy.frame(in: .global)) { _ in report(proxy) }
|
|
698
|
+
})
|
|
699
|
+
.transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity))
|
|
700
|
+
.ignoresSafeArea(.keyboard)
|
|
701
|
+
.dynamicTypeSize(...DynamicTypeSize.xxxLarge)
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
private func report(_ proxy: GeometryProxy) {
|
|
706
|
+
let screen = UIScreen.main.bounds.height
|
|
707
|
+
chrome.layoutChanged(max(0, screen - proxy.frame(in: .global).minY))
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
@ViewBuilder private var bar: some View {
|
|
711
|
+
#if compiler(>=6.2)
|
|
712
|
+
if #available(iOS 26.0, *) {
|
|
713
|
+
GlassEffectContainer {
|
|
714
|
+
items
|
|
715
|
+
.padding(4)
|
|
716
|
+
.glassEffect(.regular.interactive(), in: .capsule)
|
|
717
|
+
}
|
|
718
|
+
} else {
|
|
719
|
+
items
|
|
720
|
+
.padding(4)
|
|
721
|
+
.background(.ultraThinMaterial, in: Capsule())
|
|
722
|
+
.overlay(Capsule().strokeBorder(Color.white.opacity(0.35), lineWidth: 0.5))
|
|
723
|
+
.shadow(color: .black.opacity(0.12), radius: 18, y: 6)
|
|
724
|
+
}
|
|
725
|
+
#else
|
|
726
|
+
items
|
|
727
|
+
.padding(4)
|
|
728
|
+
.background(.ultraThinMaterial, in: Capsule())
|
|
729
|
+
.overlay(Capsule().strokeBorder(Color.white.opacity(0.35), lineWidth: 0.5))
|
|
730
|
+
.shadow(color: .black.opacity(0.12), radius: 18, y: 6)
|
|
731
|
+
#endif
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
private var items: some View {
|
|
735
|
+
HStack(spacing: 0) {
|
|
736
|
+
ForEach(chrome.tabs) { tab in
|
|
737
|
+
let isSelected = tab.id == chrome.selected
|
|
738
|
+
Button { chrome.tap(tab) } label: {
|
|
739
|
+
VStack(spacing: 3) {
|
|
740
|
+
ZStack(alignment: .topTrailing) {
|
|
741
|
+
Image(systemName: symbol(tab.symbol, selected: isSelected))
|
|
742
|
+
.font(.system(size: iconSize, weight: isSelected ? .semibold : .regular))
|
|
743
|
+
.frame(height: iconSize + 6)
|
|
744
|
+
if let badge = tab.badge {
|
|
745
|
+
Text(badge)
|
|
746
|
+
.font(.system(size: badgeSize, weight: .bold))
|
|
747
|
+
.foregroundStyle(.white)
|
|
748
|
+
.padding(.horizontal, 4)
|
|
749
|
+
.frame(minWidth: 16, minHeight: 16)
|
|
750
|
+
.background(Capsule().fill(Color.red))
|
|
751
|
+
.offset(x: 10, y: -4)
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
Text(tab.title)
|
|
755
|
+
.font(.system(size: labelSize, weight: isSelected ? .semibold : .medium))
|
|
756
|
+
.lineLimit(1)
|
|
757
|
+
.minimumScaleFactor(0.8)
|
|
758
|
+
}
|
|
759
|
+
.foregroundStyle(isSelected ? selectedColor : Color.primary)
|
|
760
|
+
.frame(maxWidth: .infinity, minHeight: 54)
|
|
761
|
+
.background {
|
|
762
|
+
if isSelected {
|
|
763
|
+
Capsule()
|
|
764
|
+
.fill(Color.primary.opacity(0.09))
|
|
765
|
+
.matchedGeometryEffect(id: "selection", in: selection)
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
.contentShape(Capsule())
|
|
769
|
+
}
|
|
770
|
+
.buttonStyle(.plain)
|
|
771
|
+
.accessibilityShowsLargeContentViewer {
|
|
772
|
+
Image(systemName: symbol(tab.symbol, selected: isSelected))
|
|
773
|
+
Text(tab.title)
|
|
774
|
+
}
|
|
775
|
+
.accessibilityLabel(tab.badge.map { "\(tab.title), \($0)" } ?? tab.title)
|
|
776
|
+
.accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
private var selectedColor: Color {
|
|
782
|
+
chrome.tint.map { Color($0) } ?? Color.primary
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/// The filled variant for the current tab, when the symbol has one.
|
|
786
|
+
private func symbol(_ name: String, selected: Bool) -> String {
|
|
787
|
+
guard selected, !name.hasSuffix(".fill"), UIImage(systemName: "\(name).fill") != nil else { return name }
|
|
788
|
+
return "\(name).fill"
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/// The launch screen, held until the page is ready: the same colour and the
|
|
793
|
+
/// same logo at the same place, so the hand-over from the system's launch
|
|
794
|
+
/// screen cannot be seen.
|
|
795
|
+
struct CraftSplashView: View {
|
|
796
|
+
@ObservedObject private var chrome = CraftChrome.shared
|
|
797
|
+
let background: UIColor
|
|
798
|
+
|
|
799
|
+
var body: some View {
|
|
800
|
+
if chrome.splashVisible {
|
|
801
|
+
ZStack {
|
|
802
|
+
Color(background)
|
|
803
|
+
if let logo = UIImage(named: "LaunchLogo") {
|
|
804
|
+
Image(uiImage: logo)
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
.ignoresSafeArea()
|
|
808
|
+
.allowsHitTesting(true)
|
|
809
|
+
.transition(.opacity)
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
247
814
|
// MARK: - Deep Link Manager
|
|
248
815
|
class DeepLinkManager {
|
|
249
816
|
static let shared = DeepLinkManager()
|
|
@@ -378,6 +945,14 @@ class AppState: ObservableObject {
|
|
|
378
945
|
}
|
|
379
946
|
|
|
380
947
|
extension CraftConfig {
|
|
948
|
+
/// craft.config.json from the app bundle, for code that runs before the
|
|
949
|
+
/// app's state exists (the app delegate at launch).
|
|
950
|
+
static func bundled() -> CraftConfig {
|
|
951
|
+
guard let url = Bundle.main.url(forResource: "craft.config", withExtension: "json"),
|
|
952
|
+
let data = try? Data(contentsOf: url) else { return CraftConfig() }
|
|
953
|
+
return CraftConfig.load(from: data)
|
|
954
|
+
}
|
|
955
|
+
|
|
381
956
|
/// The bundled config laid over the defaults, key by key.
|
|
382
957
|
///
|
|
383
958
|
/// Decoded whole, one missing or mistyped key failed the entire file and
|
|
@@ -456,6 +1031,31 @@ struct CraftConfig: Codable {
|
|
|
456
1031
|
var enableMLKit: Bool = false
|
|
457
1032
|
var devServerURL: String? = nil
|
|
458
1033
|
var trustedOrigins: [String] = []
|
|
1034
|
+
/// The longest the launch splash stays up when the page neither says it is
|
|
1035
|
+
/// ready nor paints.
|
|
1036
|
+
var splashMaxSeconds: Double = 3
|
|
1037
|
+
/// How long the app's own page may take to answer before the load counts
|
|
1038
|
+
/// as unreachable and the bundled copy stands in.
|
|
1039
|
+
var requestTimeoutSeconds: Double = 10
|
|
1040
|
+
/// Press-and-hold on a link shows its preview, as in Safari. Off unless
|
|
1041
|
+
/// asked for: no native app previews its own buttons.
|
|
1042
|
+
var allowsLinkPreview: Bool? = nil
|
|
1043
|
+
/// WebKit's bar above the keyboard (previous, next, Done). Hidden unless
|
|
1044
|
+
/// asked for; `craft.chrome.setKeyboardAccessory` changes it at runtime.
|
|
1045
|
+
var keyboardAccessory: Bool? = nil
|
|
1046
|
+
/// Pinch and double-tap zoom. Off unless set to false: an app's screens
|
|
1047
|
+
/// are laid out for the phone, and zooming one is a web page's tell.
|
|
1048
|
+
var disableZoom: Bool? = nil
|
|
1049
|
+
/// iOS's scheduled app refresh, which fires `craftBackgroundRefresh`.
|
|
1050
|
+
var backgroundRefresh: BackgroundRefresh? = nil
|
|
1051
|
+
|
|
1052
|
+
struct BackgroundRefresh: Codable {
|
|
1053
|
+
var enabled: Bool = false
|
|
1054
|
+
/// The BGTaskScheduler identifier; `<bundleId>.refresh` if unset.
|
|
1055
|
+
var identifier: String? = nil
|
|
1056
|
+
/// The soonest iOS may run it again, in minutes (15 if unset).
|
|
1057
|
+
var minimumIntervalMinutes: Double? = nil
|
|
1058
|
+
}
|
|
459
1059
|
}
|
|
460
1060
|
|
|
461
1061
|
extension CraftConfig {
|
|
@@ -469,6 +1069,49 @@ extension CraftConfig {
|
|
|
469
1069
|
}
|
|
470
1070
|
}
|
|
471
1071
|
|
|
1072
|
+
/// What the web view adds to its user agent: WebKit's own token, then
|
|
1073
|
+
/// `Craft/<version>` and `<AppName>/<version>`, so the app's server can
|
|
1074
|
+
/// tell the app from Safari, and which build is asking.
|
|
1075
|
+
func applicationNameForUserAgent(after base: String?) -> String {
|
|
1076
|
+
let name = String(appName.unicodeScalars.filter { $0.isASCII && (CharacterSet.alphanumerics.contains($0) || "._-".unicodeScalars.contains($0)) })
|
|
1077
|
+
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
|
|
1078
|
+
return [base ?? "", "Craft/{{CRAFT_VERSION}}", "\(name.isEmpty ? "App" : name)/\(version)"]
|
|
1079
|
+
.filter { !$0.isEmpty }
|
|
1080
|
+
.joined(separator: " ")
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/// Pins the page's viewport at its own width: no pinch, no double-tap
|
|
1084
|
+
/// zoom. Installed at document start, so its DOMContentLoaded listener is
|
|
1085
|
+
/// the first: the tag is rewritten once the page's own is parsed, before
|
|
1086
|
+
/// any of the page's handlers read it.
|
|
1087
|
+
static let viewportScript = """
|
|
1088
|
+
(function() {
|
|
1089
|
+
function pin() {
|
|
1090
|
+
var meta = document.querySelector('meta[name="viewport"]');
|
|
1091
|
+
var content = meta ? (meta.getAttribute('content') || '') : 'width=device-width, initial-scale=1';
|
|
1092
|
+
content = content.split(',').map(function(part) { return part.trim(); }).filter(function(part) {
|
|
1093
|
+
return part && !/^(maximum-scale|minimum-scale|user-scalable)\\s*=/i.test(part);
|
|
1094
|
+
}).concat(['minimum-scale=1', 'maximum-scale=1', 'user-scalable=no']).join(', ');
|
|
1095
|
+
if (!meta) {
|
|
1096
|
+
meta = document.createElement('meta');
|
|
1097
|
+
meta.setAttribute('name', 'viewport');
|
|
1098
|
+
(document.head || document.documentElement).appendChild(meta);
|
|
1099
|
+
}
|
|
1100
|
+
meta.setAttribute('content', content);
|
|
1101
|
+
}
|
|
1102
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', pin);
|
|
1103
|
+
else pin();
|
|
1104
|
+
})();
|
|
1105
|
+
"""
|
|
1106
|
+
|
|
1107
|
+
/// A request for the app's own page, given up on after
|
|
1108
|
+
/// `requestTimeoutSeconds`. URLRequest's default is sixty seconds, and a
|
|
1109
|
+
/// first launch on a dead connection sat on the splash and then a blank
|
|
1110
|
+
/// page for that long before the offline page could show.
|
|
1111
|
+
func request(for url: URL) -> URLRequest {
|
|
1112
|
+
URLRequest(url: url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: max(1, requestTimeoutSeconds))
|
|
1113
|
+
}
|
|
1114
|
+
|
|
472
1115
|
/// The webview's background, resolved per trait so a Dark Mode switch
|
|
473
1116
|
/// repaints it without a reload.
|
|
474
1117
|
var resolvedBackgroundColor: UIColor {
|
|
@@ -487,6 +1130,13 @@ final class BundledAssetSchemeHandler: NSObject, WKURLSchemeHandler {
|
|
|
487
1130
|
super.init()
|
|
488
1131
|
}
|
|
489
1132
|
|
|
1133
|
+
/// The bundled `index.html` as text, for a web view that cannot navigate
|
|
1134
|
+
/// to `craft://app` (see `loadBundledPage`).
|
|
1135
|
+
static func bundledIndexHTML() -> String? {
|
|
1136
|
+
guard let root = findBundledRoot() else { return nil }
|
|
1137
|
+
return try? String(contentsOf: root.appendingPathComponent("index.html"), encoding: .utf8)
|
|
1138
|
+
}
|
|
1139
|
+
|
|
490
1140
|
private static func findBundledRoot() -> URL? {
|
|
491
1141
|
let bundle = Bundle.main
|
|
492
1142
|
let directCandidates = [
|
|
@@ -621,7 +1271,9 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
621
1271
|
let webConfig = WKWebViewConfiguration()
|
|
622
1272
|
webConfig.defaultWebpagePreferences.allowsContentJavaScript = true
|
|
623
1273
|
webConfig.allowsInlineMediaPlayback = true
|
|
1274
|
+
webConfig.allowsPictureInPictureMediaPlayback = true
|
|
624
1275
|
webConfig.mediaTypesRequiringUserActionForPlayback = []
|
|
1276
|
+
webConfig.applicationNameForUserAgent = config.applicationNameForUserAgent(after: webConfig.applicationNameForUserAgent)
|
|
625
1277
|
webConfig.setURLSchemeHandler(BundledAssetSchemeHandler(), forURLScheme: "craft")
|
|
626
1278
|
// The app's own domains (WKAppBoundDomains in Info.plist) get what an
|
|
627
1279
|
// iOS web view reserves for them, service workers among them, so a
|
|
@@ -634,19 +1286,43 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
634
1286
|
// Add native bridge
|
|
635
1287
|
let contentController = WKUserContentController()
|
|
636
1288
|
contentController.add(context.coordinator, name: "craft")
|
|
637
|
-
|
|
1289
|
+
// The tab bar and the splash, outside the bridge's action dispatch:
|
|
1290
|
+
// they are the screen's, not a device API.
|
|
1291
|
+
let coordinator = context.coordinator
|
|
1292
|
+
contentController.add(CraftChromeRelay(trusts: { [weak coordinator] origin in coordinator?.trusts(origin) ?? false }), name: "craftChrome")
|
|
638
1293
|
#if DEBUG
|
|
639
1294
|
// The page's errors and console, in the device log, so a debug build
|
|
640
1295
|
// can be diagnosed from `log stream` or `simctl spawn … log show`
|
|
641
1296
|
// without attaching Safari. A separate handler, outside the bridge's
|
|
642
1297
|
// action dispatch. Never in a release build.
|
|
643
1298
|
contentController.add(PageConsoleRelay(), name: "craftLog")
|
|
644
|
-
contentController.addUserScript(WKUserScript(source: PageConsoleRelay.script, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
|
645
1299
|
#endif
|
|
1300
|
+
// The bridge and everything else the page gets from the first byte.
|
|
1301
|
+
coordinator.installUserScripts(into: contentController)
|
|
1302
|
+
webConfig.userContentController = contentController
|
|
646
1303
|
|
|
647
1304
|
let webView = WKWebView(frame: .zero, configuration: webConfig)
|
|
648
1305
|
webView.navigationDelegate = context.coordinator
|
|
1306
|
+
// alert(), confirm() and prompt() as the system's own alerts, and
|
|
1307
|
+
// target=_blank and window.open somewhere they can actually open.
|
|
1308
|
+
// Without a UI delegate WebKit drops all of them silently.
|
|
1309
|
+
webView.uiDelegate = context.coordinator
|
|
1310
|
+
// Now, not when the first page finishes loading. Everything the
|
|
1311
|
+
// coordinator sends the page goes through this reference, and a
|
|
1312
|
+
// network change, a push token or a location fix that arrived before
|
|
1313
|
+
// the first didFinish used to be dropped on a nil.
|
|
1314
|
+
coordinator.attach(webView)
|
|
649
1315
|
webView.isOpaque = false
|
|
1316
|
+
// The feel of an app rather than a page: no link previews on
|
|
1317
|
+
// press-and-hold, the keyboard follows a drag down as it does in
|
|
1318
|
+
// Messages, and the page stays at its own scale.
|
|
1319
|
+
webView.allowsLinkPreview = config.allowsLinkPreview ?? false
|
|
1320
|
+
webView.scrollView.keyboardDismissMode = .interactive
|
|
1321
|
+
if config.disableZoom != false {
|
|
1322
|
+
webView.scrollView.minimumZoomScale = 1
|
|
1323
|
+
webView.scrollView.maximumZoomScale = 1
|
|
1324
|
+
webView.scrollView.pinchGestureRecognizer?.isEnabled = false
|
|
1325
|
+
}
|
|
650
1326
|
// An edge swipe goes back (and forward) through the page's history,
|
|
651
1327
|
// pushState entries included, the way every iOS app's stack does.
|
|
652
1328
|
webView.allowsBackForwardNavigationGestures = config.swipeNavigation ?? false
|
|
@@ -664,17 +1340,23 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
664
1340
|
// Register with DeepLinkManager
|
|
665
1341
|
DeepLinkManager.shared.setWebView(webView)
|
|
666
1342
|
CraftEventManager.shared.setWebView(webView)
|
|
1343
|
+
CraftChrome.shared.webView = webView
|
|
1344
|
+
CraftChrome.shared.holdSplash(atMost: config.splashMaxSeconds)
|
|
667
1345
|
|
|
668
1346
|
// Parse background color
|
|
669
1347
|
let bgColor = config.resolvedBackgroundColor
|
|
670
1348
|
webView.backgroundColor = bgColor
|
|
671
1349
|
webView.scrollView.backgroundColor = bgColor
|
|
672
1350
|
|
|
673
|
-
// Load content
|
|
674
|
-
if
|
|
1351
|
+
// Load content: where the person was when the app was last put away,
|
|
1352
|
+
// if iOS has since ended it, or the app's start page.
|
|
1353
|
+
if coordinator.restoreInteractionState(into: webView) {
|
|
1354
|
+
// Restored: WebKit loads the page that was on screen, with its
|
|
1355
|
+
// history behind it for the back gesture.
|
|
1356
|
+
} else if let devURL = config.devServerURL, !devURL.isEmpty {
|
|
675
1357
|
// Development mode - connect to server
|
|
676
1358
|
if let url = URL(string: devURL) {
|
|
677
|
-
webView.load(
|
|
1359
|
+
webView.load(config.request(for: url))
|
|
678
1360
|
}
|
|
679
1361
|
} else if let bundledURL = URL(string: "craft://app/index.html") {
|
|
680
1362
|
// A route-aware local origin keeps root-relative STX assets and
|
|
@@ -714,6 +1396,25 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
714
1396
|
private var pendingCallbackId: String?
|
|
715
1397
|
private var pendingPushCallbackId: String?
|
|
716
1398
|
private var loadedBundledFallback = false
|
|
1399
|
+
/// Whether the current document's bridge has announced itself.
|
|
1400
|
+
private var documentReady = false
|
|
1401
|
+
/// Reloads after the content process dies, budgeted per burst.
|
|
1402
|
+
private var recoveryBudget = CraftRecoveryBudget()
|
|
1403
|
+
/// The content process died in the background; bring it back when the
|
|
1404
|
+
/// app is on screen again rather than spending the work unseen.
|
|
1405
|
+
private var recoverOnForeground = false
|
|
1406
|
+
/// The last page the app's own origin finished, for a recovery that
|
|
1407
|
+
/// finds the web view with no URL.
|
|
1408
|
+
private var lastPageURL: URL?
|
|
1409
|
+
/// When the app last went to the background, for `craftResume`.
|
|
1410
|
+
private var backgroundedAt: Date?
|
|
1411
|
+
/// Whether WebKit's bar above the keyboard shows (`keyboardAccessory`,
|
|
1412
|
+
/// then `craft.chrome.setKeyboardAccessory`).
|
|
1413
|
+
private lazy var keyboardAccessoryVisible = config.keyboardAccessory ?? false
|
|
1414
|
+
/// The native pull-to-refresh, when the page has asked for it.
|
|
1415
|
+
private lazy var refreshControl = CraftRefreshControl { [weak self] in
|
|
1416
|
+
self?.sendToWeb("craftRefresh", data: [:])
|
|
1417
|
+
}
|
|
717
1418
|
|
|
718
1419
|
/// The highest `cb_<n>` this process has seen the page hand out.
|
|
719
1420
|
///
|
|
@@ -838,6 +1539,11 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
838
1539
|
NotificationCenter.default.addObserver(self, selector: #selector(receivePushToken(_:)), name: .craftPushToken, object: nil)
|
|
839
1540
|
NotificationCenter.default.addObserver(self, selector: #selector(receivePushRegistrationError(_:)), name: .craftPushRegistrationError, object: nil)
|
|
840
1541
|
NotificationCenter.default.addObserver(self, selector: #selector(appWillEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
|
|
1542
|
+
NotificationCenter.default.addObserver(self, selector: #selector(appDidEnterBackground(_:)), name: UIApplication.didEnterBackgroundNotification, object: nil)
|
|
1543
|
+
NotificationCenter.default.addObserver(self, selector: #selector(appDidReceiveMemoryWarning(_:)), name: UIApplication.didReceiveMemoryWarningNotification, object: nil)
|
|
1544
|
+
for name in [UIContentSizeCategory.didChangeNotification, UIAccessibility.reduceMotionStatusDidChangeNotification, UIAccessibility.reduceTransparencyStatusDidChangeNotification, .craftColorSchemeChanged] {
|
|
1545
|
+
NotificationCenter.default.addObserver(self, selector: #selector(appearanceChanged(_:)), name: name, object: nil)
|
|
1546
|
+
}
|
|
841
1547
|
setupNetworkMonitoring()
|
|
842
1548
|
}
|
|
843
1549
|
|
|
@@ -891,6 +1597,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
891
1597
|
networkMonitor?.start(queue: DispatchQueue.global())
|
|
892
1598
|
}
|
|
893
1599
|
|
|
1600
|
+
// CRAFT_IOS_PAGE_BRIDGE_DISPATCHER
|
|
894
1601
|
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
895
1602
|
let securityOrigin = message.frameInfo.securityOrigin
|
|
896
1603
|
guard isTrustedOrigin(scheme: securityOrigin.protocol, host: securityOrigin.host, port: securityOrigin.port) else {
|
|
@@ -900,6 +1607,13 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
900
1607
|
guard let body = message.body as? [String: Any],
|
|
901
1608
|
let action = body["action"] as? String else { return }
|
|
902
1609
|
|
|
1610
|
+
// The page's own signal that the bridge has announced itself, not
|
|
1611
|
+
// a device API: it is never offered to Zig and nothing answers it.
|
|
1612
|
+
if action == "__craftReady" {
|
|
1613
|
+
pageBecameReady()
|
|
1614
|
+
return
|
|
1615
|
+
}
|
|
1616
|
+
|
|
903
1617
|
let callbackId = body["callbackId"] as? String
|
|
904
1618
|
// Before Zig is offered the call, because the seed has to cover
|
|
905
1619
|
// every id the page drew, not only the ones Swift went on to serve.
|
|
@@ -1046,6 +1760,62 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1046
1760
|
} else {
|
|
1047
1761
|
rejectCallback(callbackId, error: "Settings URL is unavailable")
|
|
1048
1762
|
}
|
|
1763
|
+
// MARK: Native UI: dialogs, menus, the in-app browser, chrome.
|
|
1764
|
+
// None of these is gated: they are the system's own UI, and need
|
|
1765
|
+
// no permission or entitlement.
|
|
1766
|
+
case "dialogAlert":
|
|
1767
|
+
CraftDialogs.alert(body, from: webView) { [weak self] in self?.resolveCallback(callbackId, result: NSNull()) }
|
|
1768
|
+
case "dialogConfirm":
|
|
1769
|
+
CraftDialogs.confirm(body, from: webView) { [weak self] confirmed in self?.resolveCallback(callbackId, result: confirmed) }
|
|
1770
|
+
case "dialogActionSheet":
|
|
1771
|
+
CraftDialogs.actionSheet(body, from: webView) { [weak self] id in self?.resolveCallback(callbackId, result: id ?? NSNull()) }
|
|
1772
|
+
case "contextMenuShow":
|
|
1773
|
+
guard let webView else { resolveCallback(callbackId, result: NSNull()); return }
|
|
1774
|
+
CraftContextMenu.show(body, in: webView) { [weak self] id in self?.resolveCallback(callbackId, result: id ?? NSNull()) }
|
|
1775
|
+
case "browserOpen":
|
|
1776
|
+
guard let address = body["url"] as? String, let url = URL(string: address) else {
|
|
1777
|
+
rejectCallback(callbackId, error: "browser.open needs a URL it can parse", code: "INVALID_ARGUMENT")
|
|
1778
|
+
return
|
|
1779
|
+
}
|
|
1780
|
+
CraftBrowser.shared.open(url, mode: body["mode"] as? String ?? "safari", callbackScheme: body["callbackScheme"] as? String, from: webView) { [weak self] answer in
|
|
1781
|
+
self?.resolveCallback(callbackId, result: answer)
|
|
1782
|
+
}
|
|
1783
|
+
case "symbolImage":
|
|
1784
|
+
resolveCallback(callbackId, result: CraftSymbols.image(named: body["name"] as? String ?? "", options: body) ?? NSNull())
|
|
1785
|
+
case "statusBarSetStyle":
|
|
1786
|
+
resolveCallback(callbackId, result: CraftStatusBar.setStyle(body["style"] as? String ?? "default"))
|
|
1787
|
+
case "chromeSetUnderPageColor":
|
|
1788
|
+
// What shows past the page's edges when it rubber-bands.
|
|
1789
|
+
if #available(iOS 15.0, *), let webView, let css = body["color"] as? String, let color = UIColor(css: css) {
|
|
1790
|
+
webView.underPageBackgroundColor = color
|
|
1791
|
+
resolveCallback(callbackId, result: true)
|
|
1792
|
+
} else {
|
|
1793
|
+
resolveCallback(callbackId, result: false)
|
|
1794
|
+
}
|
|
1795
|
+
case "chromeSetKeyboardAccessory":
|
|
1796
|
+
let visible = body["visible"] as? Bool ?? true
|
|
1797
|
+
keyboardAccessoryVisible = visible
|
|
1798
|
+
resolveCallback(callbackId, result: webView.map { CraftKeyboardAccessory.setVisible(visible, in: $0) } ?? false)
|
|
1799
|
+
case "refreshEnable":
|
|
1800
|
+
guard let webView else { resolveCallback(callbackId, result: false); return }
|
|
1801
|
+
refreshControl.enable(on: webView.scrollView, tint: (body["tintColor"] as? String).flatMap { UIColor(css: $0) })
|
|
1802
|
+
resolveCallback(callbackId, result: true)
|
|
1803
|
+
case "refreshDisable":
|
|
1804
|
+
refreshControl.disable()
|
|
1805
|
+
resolveCallback(callbackId, result: true)
|
|
1806
|
+
case "refreshEnd":
|
|
1807
|
+
refreshControl.end()
|
|
1808
|
+
resolveCallback(callbackId, result: true)
|
|
1809
|
+
// The page has finished the work a craftSilentPush or
|
|
1810
|
+
// craftBackgroundRefresh asked for.
|
|
1811
|
+
case "backgroundComplete":
|
|
1812
|
+
CraftBackgroundWork.shared.complete(id: body["id"] as? String, ok: body["ok"] as? Bool ?? true)
|
|
1813
|
+
resolveCallback(callbackId, result: true)
|
|
1814
|
+
// The offline page's Retry button, and anything else that wants
|
|
1815
|
+
// the app's own page back after the bundled copy stood in.
|
|
1816
|
+
case "retryRemote":
|
|
1817
|
+
retryRemote()
|
|
1818
|
+
resolveCallback(callbackId, result: true)
|
|
1049
1819
|
case "log":
|
|
1050
1820
|
if let msg = body["message"] as? String {
|
|
1051
1821
|
print("[Craft Web] \(msg)")
|
|
@@ -1735,10 +2505,30 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1735
2505
|
DeepLinkManager.shared.setLoading()
|
|
1736
2506
|
}
|
|
1737
2507
|
|
|
2508
|
+
func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
|
|
2509
|
+
// A new document: the old one's ready no longer counts, and the
|
|
2510
|
+
// pull-to-refresh it asked for goes with it, or a pull would spin
|
|
2511
|
+
// waiting on a page that does not know it has one.
|
|
2512
|
+
documentReady = false
|
|
2513
|
+
refreshControl.disable()
|
|
2514
|
+
// Kept across documents and after the content process restarts.
|
|
2515
|
+
if !keyboardAccessoryVisible { CraftKeyboardAccessory.setVisible(false, in: webView) }
|
|
2516
|
+
if config.disableZoom != false { webView.scrollView.pinchGestureRecognizer?.isEnabled = false }
|
|
2517
|
+
}
|
|
2518
|
+
|
|
1738
2519
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
1739
|
-
|
|
2520
|
+
CraftChrome.shared.pageFinished()
|
|
2521
|
+
CraftChrome.shared.publishLayout()
|
|
1740
2522
|
guard isTrustedURL(webView.url) else { return }
|
|
1741
|
-
|
|
2523
|
+
if !loadedBundledFallback { lastPageURL = webView.url }
|
|
2524
|
+
// The bridge is a document-start user script, so it is normally
|
|
2525
|
+
// here long before this. A document that came without it is given
|
|
2526
|
+
// it now, the way every page used to be, so no page is ever worse
|
|
2527
|
+
// off than before the user script.
|
|
2528
|
+
webView.evaluateJavaScript("!!(window.craft && window.craft._callbacks && window.craft.platform === 'ios')") { [weak self, weak webView] installed, _ in
|
|
2529
|
+
guard let self, let webView, (installed as? Bool) != true else { return }
|
|
2530
|
+
webView.evaluateJavaScript(self.bridgeScript(), completionHandler: nil)
|
|
2531
|
+
}
|
|
1742
2532
|
}
|
|
1743
2533
|
|
|
1744
2534
|
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
|
@@ -1746,7 +2536,20 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1746
2536
|
decisionHandler(.cancel)
|
|
1747
2537
|
return
|
|
1748
2538
|
}
|
|
2539
|
+
// A link that opens a new window (target=_blank) has no frame to
|
|
2540
|
+
// load into. It is allowed on, so WebKit asks the UI delegate's
|
|
2541
|
+
// `createWebViewWith`, which decides where it goes.
|
|
2542
|
+
if navigationAction.targetFrame == nil {
|
|
2543
|
+
decisionHandler(.allow)
|
|
2544
|
+
return
|
|
2545
|
+
}
|
|
1749
2546
|
if isTrustedURL(url) {
|
|
2547
|
+
// A new main-frame document gets the bridge seeded from the
|
|
2548
|
+
// ids handed out so far (#226), so the user scripts are
|
|
2549
|
+
// rebuilt before it exists rather than once per web view.
|
|
2550
|
+
if navigationAction.targetFrame?.isMainFrame == true {
|
|
2551
|
+
installUserScripts(into: webView.configuration.userContentController)
|
|
2552
|
+
}
|
|
1750
2553
|
decisionHandler(.allow)
|
|
1751
2554
|
return
|
|
1752
2555
|
}
|
|
@@ -1776,11 +2579,150 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1776
2579
|
}
|
|
1777
2580
|
}
|
|
1778
2581
|
|
|
2582
|
+
/// WebKit's content process died: out of memory, usually, often while
|
|
2583
|
+
/// the app was in the background. The view does not error; it goes
|
|
2584
|
+
/// white and stays white. It used to be reloaded on the spot, every
|
|
2585
|
+
/// time, into whatever crash killed it.
|
|
2586
|
+
///
|
|
2587
|
+
/// Now the reload waits until the app is on screen, shows the splash
|
|
2588
|
+
/// rather than a white page while the page rebuilds, comes back on
|
|
2589
|
+
/// the page that was showing, and is budgeted: past three crashes a
|
|
2590
|
+
/// minute (`CraftRecoveryBudget`) the offline page shows instead,
|
|
2591
|
+
/// whose Retry starts afresh.
|
|
1779
2592
|
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
|
1780
|
-
|
|
2593
|
+
documentReady = false
|
|
2594
|
+
CraftEventManager.shared.setLoading()
|
|
2595
|
+
DeepLinkManager.shared.setLoading()
|
|
2596
|
+
guard UIApplication.shared.applicationState != .background else {
|
|
2597
|
+
recoverOnForeground = true
|
|
2598
|
+
return
|
|
2599
|
+
}
|
|
2600
|
+
recoverFromTerminatedContent(webView)
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
private func recoverFromTerminatedContent(_ webView: WKWebView) {
|
|
2604
|
+
recoverOnForeground = false
|
|
2605
|
+
switch recoveryBudget.onCrash(at: ProcessInfo.processInfo.systemUptime) {
|
|
2606
|
+
case .reload:
|
|
2607
|
+
CraftChrome.shared.showSplash(atMost: config.splashMaxSeconds)
|
|
2608
|
+
installUserScripts(into: webView.configuration.userContentController)
|
|
2609
|
+
if webView.url == nil, let lastURL = lastPageURL {
|
|
2610
|
+
webView.load(config.request(for: lastURL))
|
|
2611
|
+
} else {
|
|
2612
|
+
// The current history item, pushState entries included,
|
|
2613
|
+
// with the back stack still behind it.
|
|
2614
|
+
webView.reload()
|
|
2615
|
+
}
|
|
2616
|
+
case .giveUp:
|
|
2617
|
+
print("Craft: the page's content process died \(recoveryBudget.attempts) times in a minute; showing the offline page")
|
|
2618
|
+
CraftChrome.shared.hideSplash()
|
|
2619
|
+
showOfflinePage(in: webView)
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
/// The bundled page (`dist/index.html`), which for an app whose page
|
|
2624
|
+
/// is remote is the offline page with its Retry.
|
|
2625
|
+
private func showOfflinePage(in webView: WKWebView) {
|
|
2626
|
+
if config.devServerURL != nil { loadedBundledFallback = true }
|
|
2627
|
+
loadBundledPage(in: webView)
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
/// Whether Info.plist declares WKAppBoundDomains.
|
|
2631
|
+
private static let declaresAppBoundDomains: Bool = {
|
|
2632
|
+
(Bundle.main.object(forInfoDictionaryKey: "WKAppBoundDomains") as? [String])?.isEmpty == false
|
|
2633
|
+
}()
|
|
2634
|
+
|
|
2635
|
+
/// The bundled page, in place of the app's remote one.
|
|
2636
|
+
///
|
|
2637
|
+
/// An app that declares app-bound domains cannot navigate from one of
|
|
2638
|
+
/// them to `craft://app`: WebKit ignores the load ("attempting to
|
|
2639
|
+
/// navigate away from an app-bound domain or navigate after using
|
|
2640
|
+
/// restricted APIs"), whether or not navigations are limited to them,
|
|
2641
|
+
/// because the bridge's scripts and message handlers count as those
|
|
2642
|
+
/// APIs. The fallback then left a blank page where the offline page
|
|
2643
|
+
/// should have been. So such an app gets the bundled index as a page
|
|
2644
|
+
/// of the remote origin itself (`loadHTMLString` with the remote as
|
|
2645
|
+
/// its base URL): an app-bound navigation, with the bridge, and a
|
|
2646
|
+
/// Retry the message handlers trust. It must be self-contained, as the
|
|
2647
|
+
/// generated offline page is; an app that bundles a whole site as its
|
|
2648
|
+
/// fallback and declares app-bound domains gets only its index.
|
|
2649
|
+
private func loadBundledPage(in webView: WKWebView) {
|
|
2650
|
+
if Self.declaresAppBoundDomains,
|
|
2651
|
+
let remote = config.devServerURL.flatMap(URL.init(string:)),
|
|
2652
|
+
let html = BundledAssetSchemeHandler.bundledIndexHTML() {
|
|
2653
|
+
webView.loadHTMLString(html, baseURL: remote)
|
|
2654
|
+
return
|
|
2655
|
+
}
|
|
2656
|
+
guard let bundledURL = URL(string: "craft://app/index.html") else { return }
|
|
2657
|
+
webView.load(URLRequest(url: bundledURL))
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
// MARK: - Lifecycle
|
|
2661
|
+
|
|
2662
|
+
@objc private func appDidEnterBackground(_ notification: Notification) {
|
|
2663
|
+
backgroundedAt = Date()
|
|
2664
|
+
saveInteractionState()
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
@objc private func appDidReceiveMemoryWarning(_ notification: Notification) {
|
|
2668
|
+
sendToWeb("craftMemoryWarning", data: [:])
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
/// Where the person was, kept so a launch after iOS ended the app in
|
|
2672
|
+
/// the background comes back to the same screen, as native apps do.
|
|
2673
|
+
/// WKWebView's `interactionState` (iOS 15) is its back-forward list
|
|
2674
|
+
/// with the current page and scroll position, as opaque data.
|
|
2675
|
+
///
|
|
2676
|
+
/// Not saved for the offline page or anything outside the app's own
|
|
2677
|
+
/// origins, and only restored into the same build: a new build may
|
|
2678
|
+
/// have moved the screen it pointed at.
|
|
2679
|
+
private static var interactionStateFile: URL? {
|
|
2680
|
+
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
|
|
2681
|
+
.appendingPathComponent("craft-interaction-state.data")
|
|
2682
|
+
}
|
|
2683
|
+
private static let interactionStateBuildKey = "craft.interactionState.build"
|
|
2684
|
+
|
|
2685
|
+
private static var currentBuild: String {
|
|
2686
|
+
let info = Bundle.main.infoDictionary
|
|
2687
|
+
return "\(info?["CFBundleShortVersionString"] as? String ?? "")+\(info?["CFBundleVersion"] as? String ?? "")"
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
private func saveInteractionState() {
|
|
2691
|
+
guard #available(iOS 15.0, *), let webView, let file = Self.interactionStateFile else { return }
|
|
2692
|
+
guard isTrustedURL(webView.url), !loadedBundledFallback,
|
|
2693
|
+
let state = webView.interactionState as? Data else {
|
|
2694
|
+
try? FileManager.default.removeItem(at: file)
|
|
2695
|
+
return
|
|
2696
|
+
}
|
|
2697
|
+
do {
|
|
2698
|
+
try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
2699
|
+
try state.write(to: file, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
|
|
2700
|
+
UserDefaults.standard.set(Self.currentBuild, forKey: Self.interactionStateBuildKey)
|
|
2701
|
+
} catch {
|
|
2702
|
+
print("Craft: could not keep the page's place: \(error)")
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
/// True when a saved place was handed to the web view to load.
|
|
2707
|
+
func restoreInteractionState(into webView: WKWebView) -> Bool {
|
|
2708
|
+
guard #available(iOS 15.0, *), let file = Self.interactionStateFile,
|
|
2709
|
+
UserDefaults.standard.string(forKey: Self.interactionStateBuildKey) == Self.currentBuild,
|
|
2710
|
+
let state = try? Data(contentsOf: file) else { return false }
|
|
2711
|
+
// Used once: if the restored page is what brings the app down,
|
|
2712
|
+
// the next launch starts clean rather than looping on it.
|
|
2713
|
+
try? FileManager.default.removeItem(at: file)
|
|
2714
|
+
webView.interactionState = state
|
|
2715
|
+
return true
|
|
1781
2716
|
}
|
|
1782
2717
|
|
|
1783
2718
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
|
2719
|
+
// The navigation never replaced the page, so the page that was
|
|
2720
|
+
// ready still is. Without this, events stayed queued for the rest
|
|
2721
|
+
// of its life after one cancelled tap.
|
|
2722
|
+
if documentReady {
|
|
2723
|
+
DeepLinkManager.shared.setReady()
|
|
2724
|
+
CraftEventManager.shared.setReady()
|
|
2725
|
+
}
|
|
1784
2726
|
fallBackIfUnreachable(webView, after: error)
|
|
1785
2727
|
}
|
|
1786
2728
|
|
|
@@ -1796,14 +2738,9 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1796
2738
|
}
|
|
1797
2739
|
|
|
1798
2740
|
private func loadBundledFallback(in webView: WKWebView) {
|
|
1799
|
-
guard !loadedBundledFallback,
|
|
1800
|
-
config.devServerURL != nil,
|
|
1801
|
-
let bundledURL = URL(string: "craft://app/index.html") else { return }
|
|
2741
|
+
guard !loadedBundledFallback, config.devServerURL != nil else { return }
|
|
1802
2742
|
loadedBundledFallback = true
|
|
1803
|
-
|
|
1804
|
-
// its own to reload.
|
|
1805
|
-
self.webView = webView
|
|
1806
|
-
webView.load(URLRequest(url: bundledURL))
|
|
2743
|
+
loadBundledPage(in: webView)
|
|
1807
2744
|
}
|
|
1808
2745
|
|
|
1809
2746
|
/// Leave the bundled copy for the remote origin again (#252).
|
|
@@ -1822,14 +2759,40 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1822
2759
|
let remote = config.devServerURL.flatMap(URL.init(string:)) else { return }
|
|
1823
2760
|
loadedBundledFallback = false
|
|
1824
2761
|
print("Craft: \(reason); loading \(remote) again instead of the bundled copy")
|
|
1825
|
-
webView.load(
|
|
2762
|
+
webView.load(config.request(for: remote))
|
|
1826
2763
|
}
|
|
1827
2764
|
|
|
1828
2765
|
@objc private func appWillEnterForeground(_ notification: Notification) {
|
|
2766
|
+
if recoverOnForeground, let webView {
|
|
2767
|
+
// The page is about to be rebuilt; there is no one to resume.
|
|
2768
|
+
backgroundedAt = nil
|
|
2769
|
+
recoverFromTerminatedContent(webView)
|
|
2770
|
+
return
|
|
2771
|
+
}
|
|
2772
|
+
if let backgroundedAt {
|
|
2773
|
+
self.backgroundedAt = nil
|
|
2774
|
+
sendToWeb("craftResume", data: ["backgroundedMs": Int(Date().timeIntervalSince(backgroundedAt) * 1000)])
|
|
2775
|
+
}
|
|
1829
2776
|
returnFromBundledFallback(because: "the app returned to the foreground")
|
|
1830
2777
|
}
|
|
1831
2778
|
|
|
1832
|
-
|
|
2779
|
+
/// Load the app's own page again, now, because someone asked.
|
|
2780
|
+
///
|
|
2781
|
+
/// Unlike `returnFromBundledFallback` it does not wait for a trigger
|
|
2782
|
+
/// and works from any page. If the remote is still out of reach the
|
|
2783
|
+
/// load fails as unreachable and the bundled copy comes back.
|
|
2784
|
+
private func retryRemote() {
|
|
2785
|
+
guard let webView else { return }
|
|
2786
|
+
recoveryBudget.reset()
|
|
2787
|
+
guard let remote = config.devServerURL.flatMap(URL.init(string:)) else {
|
|
2788
|
+
webView.reload()
|
|
2789
|
+
return
|
|
2790
|
+
}
|
|
2791
|
+
loadedBundledFallback = false
|
|
2792
|
+
webView.load(config.request(for: remote))
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
fileprivate func isTrustedURL(_ url: URL?) -> Bool {
|
|
1833
2796
|
guard let url = url else { return false }
|
|
1834
2797
|
return isTrustedOrigin(scheme: url.scheme ?? "", host: url.host ?? "", port: url.port ?? 0)
|
|
1835
2798
|
}
|
|
@@ -1852,6 +2815,11 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1852
2815
|
/// now, and `scripts/mobile-e2e.ts` runs a real round trip on a booted
|
|
1853
2816
|
/// simulator so a future hardening pass cannot quietly take the bridge
|
|
1854
2817
|
/// away again.
|
|
2818
|
+
/// Whether a message from this origin may drive the app's chrome.
|
|
2819
|
+
func trusts(_ origin: WKSecurityOrigin) -> Bool {
|
|
2820
|
+
isTrustedOrigin(scheme: origin.protocol, host: origin.host, port: origin.port)
|
|
2821
|
+
}
|
|
2822
|
+
|
|
1855
2823
|
private func isTrustedOrigin(scheme: String, host: String, port: Int) -> Bool {
|
|
1856
2824
|
if scheme == "craft" && host == "app" { return true }
|
|
1857
2825
|
if scheme == "file" { return true }
|
|
@@ -1880,7 +2848,91 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1880
2848
|
if drawn > highestCallbackId { highestCallbackId = drawn }
|
|
1881
2849
|
}
|
|
1882
2850
|
|
|
1883
|
-
|
|
2851
|
+
/// Hand the coordinator the web view it serves, as soon as it exists.
|
|
2852
|
+
func attach(_ webView: WKWebView) {
|
|
2853
|
+
self.webView = webView
|
|
2854
|
+
if !keyboardAccessoryVisible { CraftKeyboardAccessory.setVisible(false, in: webView) }
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
/// Every script the page gets before its own first byte runs, in order.
|
|
2858
|
+
///
|
|
2859
|
+
/// The bridge used to be evaluated in `didFinish`: after every image,
|
|
2860
|
+
/// font and script of the page had loaded, on every navigation, as
|
|
2861
|
+
/// some 1800 lines of fresh source each time. A page could not reach
|
|
2862
|
+
/// `window.craft` while it was starting, which is when it most wants
|
|
2863
|
+
/// to, and everything native tried to tell it before then was lost.
|
|
2864
|
+
/// As document-start user scripts it exists before any of the page's
|
|
2865
|
+
/// own code, and WebKit installs it for each document itself.
|
|
2866
|
+
///
|
|
2867
|
+
/// Called at creation and again before each main-frame navigation,
|
|
2868
|
+
/// because the bridge carries the callback seed (#226) and a seed
|
|
2869
|
+
/// fixed at creation would repeat ids across reloads.
|
|
2870
|
+
func installUserScripts(into controller: WKUserContentController) {
|
|
2871
|
+
controller.removeAllUserScripts()
|
|
2872
|
+
// From the first byte, so the page's CSS knows before it paints
|
|
2873
|
+
// that the shell draws the tab bar, and does not draw its own for
|
|
2874
|
+
// a frame.
|
|
2875
|
+
controller.addUserScript(WKUserScript(source: "document.documentElement.setAttribute('data-craft-chrome','ios')", injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
|
2876
|
+
#if DEBUG
|
|
2877
|
+
controller.addUserScript(WKUserScript(source: PageConsoleRelay.script, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
|
2878
|
+
#endif
|
|
2879
|
+
controller.addUserScript(WKUserScript(source: CraftChrome.paintScript, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
|
2880
|
+
controller.addUserScript(WKUserScript(source: CraftPageAppearance.script(appearanceSnapshot(), announce: false), injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
|
2881
|
+
if config.disableZoom != false {
|
|
2882
|
+
controller.addUserScript(WKUserScript(source: CraftConfig.viewportScript, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
|
2883
|
+
}
|
|
2884
|
+
controller.addUserScript(WKUserScript(source: bridgeScript(), injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
private func appearanceSnapshot() -> [String: Any] {
|
|
2888
|
+
CraftPageAppearance.snapshot(style: webView?.traitCollection.userInterfaceStyle ?? UITraitCollection.current.userInterfaceStyle)
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
/// Text size, Reduce Motion, Reduce Transparency or Light/Dark changed:
|
|
2892
|
+
/// the page gets the new values and a `craftAppearance` event.
|
|
2893
|
+
@objc private func appearanceChanged(_ notification: Notification) {
|
|
2894
|
+
DispatchQueue.main.async { [weak self] in
|
|
2895
|
+
guard let self, let webView = self.webView else { return }
|
|
2896
|
+
webView.evaluateJavaScript(CraftPageAppearance.script(self.appearanceSnapshot(), announce: true), completionHandler: nil)
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
/// The page announced the bridge (`craftReady`): from here it can be
|
|
2901
|
+
/// told things. Whatever arrived while it could not be is delivered now.
|
|
2902
|
+
private func pageBecameReady() {
|
|
2903
|
+
documentReady = true
|
|
2904
|
+
DeepLinkManager.shared.setReady()
|
|
2905
|
+
CraftEventManager.shared.setReady()
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
/// The origins the bridge script installs itself in, as the page
|
|
2909
|
+
/// spells them (`location.protocol + '//' + location.host`). The same
|
|
2910
|
+
/// set `isTrustedOrigin` answers for; the message handlers still check
|
|
2911
|
+
/// every message, so this only keeps the bridge out of pages that
|
|
2912
|
+
/// could not use it.
|
|
2913
|
+
private func trustedPageOrigins() -> [String] {
|
|
2914
|
+
var origins = config.trustedOrigins
|
|
2915
|
+
if let devServerURL = config.devServerURL,
|
|
2916
|
+
let components = URLComponents(string: devServerURL),
|
|
2917
|
+
let host = components.host,
|
|
2918
|
+
let scheme = components.scheme {
|
|
2919
|
+
let defaultPort = scheme == "https" ? 443 : 80
|
|
2920
|
+
let port = components.port ?? defaultPort
|
|
2921
|
+
origins.append("\(scheme)://\(host)\(port == defaultPort ? "" : ":\(port)")")
|
|
2922
|
+
}
|
|
2923
|
+
return origins.compactMap { origin in
|
|
2924
|
+
guard let url = URL(string: origin), let scheme = url.scheme, let host = url.host else { return nil }
|
|
2925
|
+
guard scheme == "https" || (scheme == "http" && ["localhost", "127.0.0.1", "::1"].contains(host)) else { return nil }
|
|
2926
|
+
let defaultPort = scheme == "https" ? 443 : 80
|
|
2927
|
+
let port = url.port ?? defaultPort
|
|
2928
|
+
let shownHost = host.contains(":") ? "[\(host)]" : host
|
|
2929
|
+
return "\(scheme)://\(shownHost)\(port == defaultPort ? "" : ":\(port)")"
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
/// The bridge, as the source the page runs. Only in a trusted page:
|
|
2934
|
+
/// the check is the first thing it does.
|
|
2935
|
+
func bridgeScript() -> String {
|
|
1884
2936
|
let laContext = LAContext()
|
|
1885
2937
|
var biometricAvailable = false
|
|
1886
2938
|
if laContext.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) {
|
|
@@ -3427,7 +4479,12 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3427
4479
|
var personFacing = {
|
|
3428
4480
|
requestHealthAuthorization: true,
|
|
3429
4481
|
requestPermission: true,
|
|
3430
|
-
signInWithApple: true
|
|
4482
|
+
signInWithApple: true,
|
|
4483
|
+
dialogAlert: true,
|
|
4484
|
+
dialogConfirm: true,
|
|
4485
|
+
dialogActionSheet: true,
|
|
4486
|
+
contextMenuShow: true,
|
|
4487
|
+
browserOpen: true
|
|
3431
4488
|
};
|
|
3432
4489
|
craft._invoke = function(action, payload) {
|
|
3433
4490
|
var self = craft;
|
|
@@ -3462,12 +4519,19 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3462
4519
|
throw error;
|
|
3463
4520
|
});
|
|
3464
4521
|
}
|
|
4522
|
+
// Each kind reaches the generator UIKit has for it: a
|
|
4523
|
+
// notification plays the success, warning or error pattern
|
|
4524
|
+
// and a selection the picker's detent. Both used to be
|
|
4525
|
+
// impacts of some weight, which is not what either feels like.
|
|
3465
4526
|
craft.haptics = {
|
|
3466
4527
|
impact: function(style) { return hapticFeedback(craft.haptic(style || 'medium')); },
|
|
3467
4528
|
notification: function(type) {
|
|
3468
|
-
return hapticFeedback(craft.haptic(type === 'error'
|
|
4529
|
+
return hapticFeedback(craft.haptic(type === 'error' || type === 'warning' ? type : 'success'));
|
|
3469
4530
|
},
|
|
3470
|
-
selection: function() { return hapticFeedback(craft.haptic('
|
|
4531
|
+
selection: function() { return hapticFeedback(craft.haptic('selection')); },
|
|
4532
|
+
// Wakes the engine ahead of a haptic the page knows is
|
|
4533
|
+
// coming, so it plays on the frame it is asked for.
|
|
4534
|
+
prepare: function(kind) { return hapticFeedback(craft._invoke('hapticPrepare', {kind: kind || null})); },
|
|
3471
4535
|
vibrate: function(pattern) { return hapticFeedback(craft.vibrate(pattern || [])); }
|
|
3472
4536
|
};
|
|
3473
4537
|
craft.permissions = {
|
|
@@ -3639,6 +4703,146 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3639
4703
|
}
|
|
3640
4704
|
})(window.craft);
|
|
3641
4705
|
|
|
4706
|
+
// The system's own UI, driven by the page: dialogs, a menu at a
|
|
4707
|
+
// point, the in-app browser and web sign-in, SF Symbols, and the
|
|
4708
|
+
// chrome around the page (status bar, under-page colour, keyboard
|
|
4709
|
+
// bar, pull to refresh).
|
|
4710
|
+
//
|
|
4711
|
+
// Every call settles, and none throws: a failure answers with
|
|
4712
|
+
// what "nothing happened" means for that call (null, false, a
|
|
4713
|
+
// cancelled browser), the way the same calls answer in a browser.
|
|
4714
|
+
(function installNativeUI(craft) {
|
|
4715
|
+
function settle(answer, fallback) {
|
|
4716
|
+
return answer.then(function(value) {
|
|
4717
|
+
return value === undefined || value === null ? fallback : value;
|
|
4718
|
+
}, function(error) {
|
|
4719
|
+
if (craft._debug) console.warn('[Craft]', error && error.message);
|
|
4720
|
+
return fallback;
|
|
4721
|
+
});
|
|
4722
|
+
}
|
|
4723
|
+
function options(value) {
|
|
4724
|
+
return typeof value === 'string' ? {title: value} : Object.assign({}, value || {});
|
|
4725
|
+
}
|
|
4726
|
+
// A rect, or an element (or anything with
|
|
4727
|
+
// getBoundingClientRect), in the viewport's CSS pixels.
|
|
4728
|
+
function rect(anchor) {
|
|
4729
|
+
if (!anchor) return null;
|
|
4730
|
+
if (typeof anchor.getBoundingClientRect === 'function') anchor = anchor.getBoundingClientRect();
|
|
4731
|
+
var x = Number(anchor.x != null ? anchor.x : anchor.left);
|
|
4732
|
+
var y = Number(anchor.y != null ? anchor.y : anchor.top);
|
|
4733
|
+
if (!isFinite(x) || !isFinite(y)) return null;
|
|
4734
|
+
return {x: x, y: y, width: Number(anchor.width) || 0, height: Number(anchor.height) || 0};
|
|
4735
|
+
}
|
|
4736
|
+
craft.dialog = {
|
|
4737
|
+
alert: function(value) {
|
|
4738
|
+
return settle(craft._invoke('dialogAlert', options(value)), null).then(function() {});
|
|
4739
|
+
},
|
|
4740
|
+
confirm: function(value) {
|
|
4741
|
+
return settle(craft._invoke('dialogConfirm', options(value)), false).then(function(confirmed) { return confirmed === true; });
|
|
4742
|
+
},
|
|
4743
|
+
actionSheet: function(value) {
|
|
4744
|
+
var payload = options(value);
|
|
4745
|
+
payload.anchor = rect(payload.anchor);
|
|
4746
|
+
payload.actions = (payload.actions || []).map(function(action) {
|
|
4747
|
+
return {id: String(action.id), title: String(action.title), style: action.style || 'default'};
|
|
4748
|
+
});
|
|
4749
|
+
return settle(craft._invoke('dialogActionSheet', payload), null);
|
|
4750
|
+
}
|
|
4751
|
+
};
|
|
4752
|
+
craft.contextMenu = {
|
|
4753
|
+
show: function(value) {
|
|
4754
|
+
var payload = Object.assign({}, value || {});
|
|
4755
|
+
payload.anchor = rect(payload.anchor);
|
|
4756
|
+
payload.items = (payload.items || []).map(function(item) {
|
|
4757
|
+
return {id: String(item.id), title: String(item.title), symbol: item.symbol || null, destructive: item.destructive === true, disabled: item.disabled === true};
|
|
4758
|
+
});
|
|
4759
|
+
return settle(craft._invoke('contextMenuShow', payload), null);
|
|
4760
|
+
}
|
|
4761
|
+
};
|
|
4762
|
+
craft.browser = {
|
|
4763
|
+
open: function(url, value) {
|
|
4764
|
+
value = value || {};
|
|
4765
|
+
return settle(craft._invoke('browserOpen', {
|
|
4766
|
+
url: String(url),
|
|
4767
|
+
mode: value.mode === 'auth' ? 'auth' : 'safari',
|
|
4768
|
+
callbackScheme: value.callbackScheme || null
|
|
4769
|
+
}), {cancelled: true});
|
|
4770
|
+
}
|
|
4771
|
+
};
|
|
4772
|
+
// The same symbol at the same size is drawn once per page.
|
|
4773
|
+
var symbolCache = {};
|
|
4774
|
+
craft.symbols = {
|
|
4775
|
+
image: function(name, value) {
|
|
4776
|
+
value = value || {};
|
|
4777
|
+
var payload = {name: String(name), pointSize: value.pointSize, weight: value.weight, color: value.color, scale: value.scale};
|
|
4778
|
+
var key = JSON.stringify(payload);
|
|
4779
|
+
if (!symbolCache[key]) {
|
|
4780
|
+
symbolCache[key] = settle(craft._invoke('symbolImage', payload), null).then(function(image) {
|
|
4781
|
+
if (image === null) delete symbolCache[key];
|
|
4782
|
+
return image;
|
|
4783
|
+
});
|
|
4784
|
+
}
|
|
4785
|
+
return symbolCache[key];
|
|
4786
|
+
}
|
|
4787
|
+
};
|
|
4788
|
+
craft.statusBar = {
|
|
4789
|
+
setStyle: function(style) {
|
|
4790
|
+
return settle(craft._invoke('statusBarSetStyle', {style: style === 'light' || style === 'dark' ? style : 'default'}), false);
|
|
4791
|
+
}
|
|
4792
|
+
};
|
|
4793
|
+
craft.chrome = {
|
|
4794
|
+
setUnderPageColor: function(color) {
|
|
4795
|
+
return settle(craft._invoke('chromeSetUnderPageColor', {color: String(color)}), false);
|
|
4796
|
+
},
|
|
4797
|
+
setKeyboardAccessory: function(visible) {
|
|
4798
|
+
return settle(craft._invoke('chromeSetKeyboardAccessory', {visible: visible !== false}), false);
|
|
4799
|
+
}
|
|
4800
|
+
};
|
|
4801
|
+
// Text size, Reduce Motion, Reduce Transparency and Light or
|
|
4802
|
+
// Dark, as the shell last reported them.
|
|
4803
|
+
Object.defineProperty(craft, 'appearance', {
|
|
4804
|
+
configurable: true,
|
|
4805
|
+
enumerable: true,
|
|
4806
|
+
get: function() { return window.__craftAppearance || null; }
|
|
4807
|
+
});
|
|
4808
|
+
// Background time: answer a craftSilentPush or a
|
|
4809
|
+
// craftBackgroundRefresh once its work is done, with the
|
|
4810
|
+
// event's id, or with none for everything pending.
|
|
4811
|
+
craft.background = {
|
|
4812
|
+
complete: function(ok, id) {
|
|
4813
|
+
return settle(craft._invoke('backgroundComplete', {ok: ok !== false, id: id || null}), false);
|
|
4814
|
+
},
|
|
4815
|
+
onRefresh: function(callback) {
|
|
4816
|
+
var listener = function(e) { callback(e.detail || {}); };
|
|
4817
|
+
window.addEventListener('craftBackgroundRefresh', listener);
|
|
4818
|
+
return function() { window.removeEventListener('craftBackgroundRefresh', listener); };
|
|
4819
|
+
},
|
|
4820
|
+
onSilentPush: function(callback) {
|
|
4821
|
+
var listener = function(e) { callback(e.detail || {}); };
|
|
4822
|
+
window.addEventListener('craftSilentPush', listener);
|
|
4823
|
+
return function() { window.removeEventListener('craftSilentPush', listener); };
|
|
4824
|
+
}
|
|
4825
|
+
};
|
|
4826
|
+
craft.onAppearanceChange = function(callback) {
|
|
4827
|
+
var listener = function(e) { callback(e.detail); };
|
|
4828
|
+
window.addEventListener('craftAppearance', listener);
|
|
4829
|
+
return function() { window.removeEventListener('craftAppearance', listener); };
|
|
4830
|
+
};
|
|
4831
|
+
craft.refresh = {
|
|
4832
|
+
enable: function(value) {
|
|
4833
|
+
return settle(craft._invoke('refreshEnable', {tintColor: (value && value.tintColor) || null}), false);
|
|
4834
|
+
},
|
|
4835
|
+
disable: function() { return settle(craft._invoke('refreshDisable'), false); },
|
|
4836
|
+
end: function() { return settle(craft._invoke('refreshEnd'), false); },
|
|
4837
|
+
// A pull of the native control; returns the unsubscribe.
|
|
4838
|
+
onRefresh: function(callback) {
|
|
4839
|
+
var listener = function() { callback(); };
|
|
4840
|
+
window.addEventListener('craftRefresh', listener);
|
|
4841
|
+
return function() { window.removeEventListener('craftRefresh', listener); };
|
|
4842
|
+
}
|
|
4843
|
+
};
|
|
4844
|
+
})(window.craft);
|
|
4845
|
+
|
|
3642
4846
|
// The reply route for actions the Zig runtime serves.
|
|
3643
4847
|
//
|
|
3644
4848
|
// Zig owns one wire format and calls these two functions by name;
|
|
@@ -3660,15 +4864,39 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3660
4864
|
window.craft._rejectCallback('cb_' + ctx.id, ctx.message, ctx.code);
|
|
3661
4865
|
};
|
|
3662
4866
|
|
|
3663
|
-
//
|
|
3664
|
-
|
|
3665
|
-
|
|
4867
|
+
// craftReady, once per document, at the first moment a page can
|
|
4868
|
+
// be listening for it.
|
|
4869
|
+
//
|
|
4870
|
+
// The bridge itself exists from the document's first byte, before
|
|
4871
|
+
// any of the page's scripts, so a page that checks window.craft
|
|
4872
|
+
// finds it at once. An event fired then would reach nobody, and
|
|
4873
|
+
// pages written for the old injection only listen. So it fires
|
|
4874
|
+
// when the document has been parsed: every parser-inserted
|
|
4875
|
+
// script, module and deferred script has run by then and could
|
|
4876
|
+
// have subscribed, and it is still well before the load event
|
|
4877
|
+
// (images and all) it used to wait for. Native hears of it here
|
|
4878
|
+
// too, and only then delivers what it was holding.
|
|
4879
|
+
(function announceReady(craft) {
|
|
4880
|
+
function ready() {
|
|
4881
|
+
if (craft.ready) return;
|
|
4882
|
+
craft.ready = true;
|
|
4883
|
+
window.dispatchEvent(new CustomEvent('craftReady', {detail: craft}));
|
|
4884
|
+
// The appearance the document started with, for a page
|
|
4885
|
+
// that subscribes rather than reading craft.appearance.
|
|
4886
|
+
if (window.__craftAppearance) window.dispatchEvent(new CustomEvent('craftAppearance', {detail: window.__craftAppearance}));
|
|
4887
|
+
try { window.webkit.messageHandlers.craft.postMessage({action: '__craftReady'}); } catch (e) {}
|
|
4888
|
+
}
|
|
4889
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ready);
|
|
4890
|
+
else ready();
|
|
4891
|
+
})(window.craft);
|
|
4892
|
+
"""
|
|
4893
|
+
let trusted = (try? JSONSerialization.data(withJSONObject: trustedPageOrigins()))
|
|
4894
|
+
.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
|
|
4895
|
+
return """
|
|
4896
|
+
if ((location.protocol === 'craft:' && location.host === 'app') || location.protocol === 'file:' || \(trusted).indexOf(location.protocol + '//' + location.host) !== -1) {
|
|
4897
|
+
\(script)
|
|
4898
|
+
}
|
|
3666
4899
|
"""
|
|
3667
|
-
webView?.evaluateJavaScript(script)
|
|
3668
|
-
|
|
3669
|
-
// Mark DeepLinkManager as ready
|
|
3670
|
-
DeepLinkManager.shared.setReady()
|
|
3671
|
-
CraftEventManager.shared.setReady()
|
|
3672
4900
|
}
|
|
3673
4901
|
|
|
3674
4902
|
// MARK: - Callback Helpers
|
|
@@ -3800,8 +5028,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3800
5028
|
|
|
3801
5029
|
// MARK: - Share
|
|
3802
5030
|
private func share(options: [String: Any], callbackId: String?) {
|
|
3803
|
-
guard
|
|
3804
|
-
let rootVC = windowScene.windows.first?.rootViewController else {
|
|
5031
|
+
guard CraftPresenter.topViewController() != nil else {
|
|
3805
5032
|
rejectCallback(callbackId, error: "Unable to present the share sheet")
|
|
3806
5033
|
return
|
|
3807
5034
|
}
|
|
@@ -3827,7 +5054,11 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3827
5054
|
self.resolveCallback(callbackId, result: completed)
|
|
3828
5055
|
}
|
|
3829
5056
|
}
|
|
3830
|
-
|
|
5057
|
+
// On iPad the sheet is a popover, and one with no anchor is an
|
|
5058
|
+
// exception rather than a sheet: it points at `anchor` when the
|
|
5059
|
+
// page gives one (the button's rect), the middle of the screen
|
|
5060
|
+
// when it does not.
|
|
5061
|
+
CraftPresenter.present(activityVC, from: webView, anchor: CraftPresenter.rect(options["anchor"]))
|
|
3831
5062
|
}
|
|
3832
5063
|
|
|
3833
5064
|
// MARK: - Camera & Photo Library
|
|
@@ -3837,8 +5068,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3837
5068
|
self.rejectCallback(self.pendingCallbackId, error: "Camera not available")
|
|
3838
5069
|
return
|
|
3839
5070
|
}
|
|
3840
|
-
guard let
|
|
3841
|
-
let rootVC = windowScene.windows.first?.rootViewController else { return }
|
|
5071
|
+
guard let rootVC = CraftPresenter.topViewController() else { return }
|
|
3842
5072
|
|
|
3843
5073
|
let picker = UIImagePickerController()
|
|
3844
5074
|
picker.sourceType = .camera
|
|
@@ -3849,8 +5079,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3849
5079
|
|
|
3850
5080
|
private func pickImage() {
|
|
3851
5081
|
DispatchQueue.main.async {
|
|
3852
|
-
guard let
|
|
3853
|
-
let rootVC = windowScene.windows.first?.rootViewController else { return }
|
|
5082
|
+
guard let rootVC = CraftPresenter.topViewController() else { return }
|
|
3854
5083
|
|
|
3855
5084
|
let picker = UIImagePickerController()
|
|
3856
5085
|
picker.sourceType = .photoLibrary
|
|
@@ -5071,8 +6300,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5071
6300
|
// MARK: - QR/Barcode Scanner
|
|
5072
6301
|
private func scanQRCode(callbackId: String?) {
|
|
5073
6302
|
DispatchQueue.main.async {
|
|
5074
|
-
guard let
|
|
5075
|
-
let rootVC = windowScene.windows.first?.rootViewController else { return }
|
|
6303
|
+
guard let rootVC = CraftPresenter.topViewController() else { return }
|
|
5076
6304
|
|
|
5077
6305
|
if #available(iOS 16.0, *) {
|
|
5078
6306
|
let scannerVC = DataScannerViewController(
|
|
@@ -5094,8 +6322,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5094
6322
|
// MARK: - File Picker
|
|
5095
6323
|
private func pickFile(types: [String]?, callbackId: String?) {
|
|
5096
6324
|
DispatchQueue.main.async {
|
|
5097
|
-
guard let
|
|
5098
|
-
let rootVC = windowScene.windows.first?.rootViewController else { return }
|
|
6325
|
+
guard let rootVC = CraftPresenter.topViewController() else { return }
|
|
5099
6326
|
|
|
5100
6327
|
var allowedTypes: [UTType] = [.item]
|
|
5101
6328
|
if let types = types {
|
|
@@ -5239,8 +6466,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5239
6466
|
return
|
|
5240
6467
|
}
|
|
5241
6468
|
|
|
5242
|
-
guard let
|
|
5243
|
-
let rootVC = windowScene.windows.first?.rootViewController else { return }
|
|
6469
|
+
guard let rootVC = CraftPresenter.topViewController() else { return }
|
|
5244
6470
|
|
|
5245
6471
|
let picker = UIImagePickerController()
|
|
5246
6472
|
picker.sourceType = .camera
|
|
@@ -5999,8 +7225,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5999
7225
|
|
|
6000
7226
|
private func openPDF(source: String, page: Int, callbackId: String?) {
|
|
6001
7227
|
DispatchQueue.main.async {
|
|
6002
|
-
guard let
|
|
6003
|
-
let rootVC = windowScene.windows.first?.rootViewController else {
|
|
7228
|
+
guard let rootVC = CraftPresenter.topViewController() else {
|
|
6004
7229
|
self.rejectCallback(callbackId, error: "No root view controller")
|
|
6005
7230
|
return
|
|
6006
7231
|
}
|
|
@@ -6077,8 +7302,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6077
7302
|
// MARK: - Contacts Picker
|
|
6078
7303
|
private func pickContact(multiple: Bool, callbackId: String?) {
|
|
6079
7304
|
DispatchQueue.main.async {
|
|
6080
|
-
guard let
|
|
6081
|
-
let rootVC = windowScene.windows.first?.rootViewController else {
|
|
7305
|
+
guard let rootVC = CraftPresenter.topViewController() else {
|
|
6082
7306
|
self.rejectCallback(callbackId, error: "No root view controller")
|
|
6083
7307
|
return
|
|
6084
7308
|
}
|
|
@@ -6947,6 +8171,72 @@ extension URL {
|
|
|
6947
8171
|
//
|
|
6948
8172
|
// The end of an utterance is what `craft.speech.speak()` waits for. Hopped to
|
|
6949
8173
|
// main because every piece of speech state is touched there and nowhere else.
|
|
8174
|
+
// MARK: - The page's own windows and dialogs
|
|
8175
|
+
//
|
|
8176
|
+
// WebKit hands these to the UI delegate and, with none set, drops them: an
|
|
8177
|
+
// alert() that never showed, a confirm() that answered false without asking,
|
|
8178
|
+
// a target=_blank link that did nothing when tapped.
|
|
8179
|
+
extension CraftWebView.Coordinator: WKUIDelegate {
|
|
8180
|
+
/// A dialog's title: none for the app's own page, which speaks as the app,
|
|
8181
|
+
/// and the origin's host for anything embedded in it, so a frame cannot
|
|
8182
|
+
/// pass its alert off as the app's.
|
|
8183
|
+
private func dialogTitle(for frame: WKFrameInfo) -> String? {
|
|
8184
|
+
trusts(frame.securityOrigin) ? nil : frame.securityOrigin.host
|
|
8185
|
+
}
|
|
8186
|
+
|
|
8187
|
+
func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
|
|
8188
|
+
let alert = UIAlertController(title: dialogTitle(for: frame), message: message, preferredStyle: .alert)
|
|
8189
|
+
alert.addAction(UIAlertAction(title: CraftPresenter.systemString("OK"), style: .default) { _ in completionHandler() })
|
|
8190
|
+
if !CraftPresenter.present(alert, from: webView) { completionHandler() }
|
|
8191
|
+
}
|
|
8192
|
+
|
|
8193
|
+
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
|
|
8194
|
+
let alert = UIAlertController(title: dialogTitle(for: frame), message: message, preferredStyle: .alert)
|
|
8195
|
+
alert.addAction(UIAlertAction(title: CraftPresenter.systemString("Cancel"), style: .cancel) { _ in completionHandler(false) })
|
|
8196
|
+
alert.addAction(UIAlertAction(title: CraftPresenter.systemString("OK"), style: .default) { _ in completionHandler(true) })
|
|
8197
|
+
if !CraftPresenter.present(alert, from: webView) { completionHandler(false) }
|
|
8198
|
+
}
|
|
8199
|
+
|
|
8200
|
+
func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) {
|
|
8201
|
+
let alert = UIAlertController(title: dialogTitle(for: frame), message: prompt, preferredStyle: .alert)
|
|
8202
|
+
alert.addTextField { field in field.text = defaultText }
|
|
8203
|
+
alert.addAction(UIAlertAction(title: CraftPresenter.systemString("Cancel"), style: .cancel) { _ in completionHandler(nil) })
|
|
8204
|
+
alert.addAction(UIAlertAction(title: CraftPresenter.systemString("OK"), style: .default) { [weak alert] _ in
|
|
8205
|
+
completionHandler(alert?.textFields?.first?.text ?? "")
|
|
8206
|
+
})
|
|
8207
|
+
if !CraftPresenter.present(alert, from: webView) { completionHandler(nil) }
|
|
8208
|
+
}
|
|
8209
|
+
|
|
8210
|
+
/// target=_blank and window.open. The app's own pages open in this web
|
|
8211
|
+
/// view, the way a native app pushes a screen rather than opening a
|
|
8212
|
+
/// second app. Anywhere else opens in Safari's in-app view, over the app
|
|
8213
|
+
/// and dismissed back to it, and a non-web link goes to whatever app
|
|
8214
|
+
/// handles it. A second web view is never made: there would be nothing to
|
|
8215
|
+
/// show it in.
|
|
8216
|
+
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
|
|
8217
|
+
guard let url = navigationAction.request.url else { return nil }
|
|
8218
|
+
if isTrustedURL(url) {
|
|
8219
|
+
webView.load(navigationAction.request)
|
|
8220
|
+
} else if url.scheme == "https" || url.scheme == "http" {
|
|
8221
|
+
let safari = SFSafariViewController(url: url)
|
|
8222
|
+
safari.dismissButtonStyle = .close
|
|
8223
|
+
if !CraftPresenter.present(safari, from: webView) { UIApplication.shared.open(url) }
|
|
8224
|
+
} else {
|
|
8225
|
+
UIApplication.shared.open(url)
|
|
8226
|
+
}
|
|
8227
|
+
return nil
|
|
8228
|
+
}
|
|
8229
|
+
|
|
8230
|
+
/// getUserMedia from the app's own page is granted without WebKit's own
|
|
8231
|
+
/// per-page prompt: the app already holds (or will ask for) the camera
|
|
8232
|
+
/// and microphone permission iOS itself shows, and asking twice for one
|
|
8233
|
+
/// thing reads as a web page. Anything embedded still gets the prompt.
|
|
8234
|
+
@available(iOS 15.0, *)
|
|
8235
|
+
func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType, decisionHandler: @escaping (WKPermissionDecision) -> Void) {
|
|
8236
|
+
decisionHandler(trusts(origin) && trusts(frame.securityOrigin) ? .grant : .prompt)
|
|
8237
|
+
}
|
|
8238
|
+
}
|
|
8239
|
+
|
|
6950
8240
|
extension CraftWebView.Coordinator: AVSpeechSynthesizerDelegate {
|
|
6951
8241
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
|
6952
8242
|
DispatchQueue.main.async { self.speechDidEnd(utterance, spoken: true) }
|