craft-native 0.0.123 → 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 +207 -2
- 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 +76 -2
- package/dist/ios/src/index.js +134 -43
- package/dist/ios/templates/CraftApp.swift +1086 -67
- 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 +2 -1
- package/dist/mobile.js +138 -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 {
|
|
@@ -228,6 +465,7 @@ struct CraftApp: App {
|
|
|
228
465
|
// The page's own chrome, drawn natively over it: the tab
|
|
229
466
|
// bar the page asks for, and the splash until it is ready.
|
|
230
467
|
ZStack(alignment: .bottom) {
|
|
468
|
+
CraftColorSchemeObserver()
|
|
231
469
|
CraftWebView(config: appState.config)
|
|
232
470
|
.ignoresSafeArea()
|
|
233
471
|
CraftTabBarView()
|
|
@@ -279,10 +517,19 @@ final class CraftChrome: ObservableObject {
|
|
|
279
517
|
/// What the tab bar covers at the bottom of the screen, in points.
|
|
280
518
|
private var occupied: CGFloat = 0
|
|
281
519
|
private var pendingHide: DispatchWorkItem?
|
|
520
|
+
private var splashDeadline: DispatchWorkItem?
|
|
521
|
+
|
|
522
|
+
private init() {}
|
|
282
523
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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)
|
|
286
533
|
}
|
|
287
534
|
|
|
288
535
|
func handle(_ body: [String: Any]) {
|
|
@@ -300,11 +547,11 @@ final class CraftChrome: ObservableObject {
|
|
|
300
547
|
let dark = (body["tintDark"] as? String).flatMap { UIColor(hex: $0) }
|
|
301
548
|
tint = light.map { light in dark.map { dark in UIColor { $0.userInterfaceStyle == .dark ? dark : light } } ?? light }
|
|
302
549
|
}
|
|
303
|
-
withAnimation(.easeOut(duration: 0.2)) { tabBarVisible = !tabs.isEmpty }
|
|
550
|
+
withAnimation(Self.motion(.easeOut(duration: 0.2))) { tabBarVisible = !tabs.isEmpty }
|
|
304
551
|
publishLayout()
|
|
305
552
|
case "selectTab":
|
|
306
553
|
if let id = body["id"] as? String, id != selected {
|
|
307
|
-
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { selected = id }
|
|
554
|
+
withAnimation(Self.motion(.spring(response: 0.32, dampingFraction: 0.86))) { selected = id }
|
|
308
555
|
}
|
|
309
556
|
case "hideTabBar":
|
|
310
557
|
// Later, and only if no screen asks for the bar meanwhile: moving
|
|
@@ -312,7 +559,7 @@ final class CraftChrome: ObservableObject {
|
|
|
312
559
|
// mounts the next, and the bar must not blink in between.
|
|
313
560
|
pendingHide?.cancel()
|
|
314
561
|
let hide = DispatchWorkItem { [weak self] in
|
|
315
|
-
withAnimation(.easeIn(duration: 0.18)) { self?.tabBarVisible = false }
|
|
562
|
+
withAnimation(Self.motion(.easeIn(duration: 0.18))) { self?.tabBarVisible = false }
|
|
316
563
|
self?.occupied = 0
|
|
317
564
|
self?.publishLayout()
|
|
318
565
|
}
|
|
@@ -320,25 +567,70 @@ final class CraftChrome: ObservableObject {
|
|
|
320
567
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: hide)
|
|
321
568
|
case "ready":
|
|
322
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()
|
|
323
575
|
default:
|
|
324
576
|
break
|
|
325
577
|
}
|
|
326
578
|
}
|
|
327
579
|
|
|
328
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.
|
|
329
584
|
func tap(_ tab: Tab) {
|
|
330
|
-
UISelectionFeedbackGenerator().selectionChanged()
|
|
331
585
|
if tab.id != selected {
|
|
332
|
-
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { selected = tab.id }
|
|
586
|
+
withAnimation(Self.motion(.spring(response: 0.32, dampingFraction: 0.86))) { selected = tab.id }
|
|
333
587
|
}
|
|
334
588
|
emit("craftTabSelect", ["id": tab.id])
|
|
335
589
|
}
|
|
336
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
|
+
|
|
337
604
|
func hideSplash() {
|
|
605
|
+
splashDeadline?.cancel()
|
|
606
|
+
splashDeadline = nil
|
|
338
607
|
guard splashVisible else { return }
|
|
339
608
|
withAnimation(.easeOut(duration: 0.28)) { splashVisible = false }
|
|
340
609
|
}
|
|
341
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
|
+
|
|
342
634
|
/// A page that loaded and never said it was ready still gets shown.
|
|
343
635
|
func pageFinished() {
|
|
344
636
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in self?.hideSplash() }
|
|
@@ -386,6 +678,13 @@ final class CraftChromeRelay: NSObject, WKScriptMessageHandler {
|
|
|
386
678
|
struct CraftTabBarView: View {
|
|
387
679
|
@ObservedObject private var chrome = CraftChrome.shared
|
|
388
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
|
|
389
688
|
|
|
390
689
|
var body: some View {
|
|
391
690
|
if chrome.tabBarVisible && !chrome.tabs.isEmpty {
|
|
@@ -397,8 +696,9 @@ struct CraftTabBarView: View {
|
|
|
397
696
|
.onAppear { report(proxy) }
|
|
398
697
|
.onChange(of: proxy.frame(in: .global)) { _ in report(proxy) }
|
|
399
698
|
})
|
|
400
|
-
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
699
|
+
.transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity))
|
|
401
700
|
.ignoresSafeArea(.keyboard)
|
|
701
|
+
.dynamicTypeSize(...DynamicTypeSize.xxxLarge)
|
|
402
702
|
}
|
|
403
703
|
}
|
|
404
704
|
|
|
@@ -408,6 +708,7 @@ struct CraftTabBarView: View {
|
|
|
408
708
|
}
|
|
409
709
|
|
|
410
710
|
@ViewBuilder private var bar: some View {
|
|
711
|
+
#if compiler(>=6.2)
|
|
411
712
|
if #available(iOS 26.0, *) {
|
|
412
713
|
GlassEffectContainer {
|
|
413
714
|
items
|
|
@@ -421,6 +722,13 @@ struct CraftTabBarView: View {
|
|
|
421
722
|
.overlay(Capsule().strokeBorder(Color.white.opacity(0.35), lineWidth: 0.5))
|
|
422
723
|
.shadow(color: .black.opacity(0.12), radius: 18, y: 6)
|
|
423
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
|
|
424
732
|
}
|
|
425
733
|
|
|
426
734
|
private var items: some View {
|
|
@@ -431,11 +739,11 @@ struct CraftTabBarView: View {
|
|
|
431
739
|
VStack(spacing: 3) {
|
|
432
740
|
ZStack(alignment: .topTrailing) {
|
|
433
741
|
Image(systemName: symbol(tab.symbol, selected: isSelected))
|
|
434
|
-
.font(.system(size:
|
|
435
|
-
.frame(height:
|
|
742
|
+
.font(.system(size: iconSize, weight: isSelected ? .semibold : .regular))
|
|
743
|
+
.frame(height: iconSize + 6)
|
|
436
744
|
if let badge = tab.badge {
|
|
437
745
|
Text(badge)
|
|
438
|
-
.font(.system(size:
|
|
746
|
+
.font(.system(size: badgeSize, weight: .bold))
|
|
439
747
|
.foregroundStyle(.white)
|
|
440
748
|
.padding(.horizontal, 4)
|
|
441
749
|
.frame(minWidth: 16, minHeight: 16)
|
|
@@ -444,7 +752,7 @@ struct CraftTabBarView: View {
|
|
|
444
752
|
}
|
|
445
753
|
}
|
|
446
754
|
Text(tab.title)
|
|
447
|
-
.font(.system(size:
|
|
755
|
+
.font(.system(size: labelSize, weight: isSelected ? .semibold : .medium))
|
|
448
756
|
.lineLimit(1)
|
|
449
757
|
.minimumScaleFactor(0.8)
|
|
450
758
|
}
|
|
@@ -460,6 +768,10 @@ struct CraftTabBarView: View {
|
|
|
460
768
|
.contentShape(Capsule())
|
|
461
769
|
}
|
|
462
770
|
.buttonStyle(.plain)
|
|
771
|
+
.accessibilityShowsLargeContentViewer {
|
|
772
|
+
Image(systemName: symbol(tab.symbol, selected: isSelected))
|
|
773
|
+
Text(tab.title)
|
|
774
|
+
}
|
|
463
775
|
.accessibilityLabel(tab.badge.map { "\(tab.title), \($0)" } ?? tab.title)
|
|
464
776
|
.accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
|
|
465
777
|
}
|
|
@@ -633,6 +945,14 @@ class AppState: ObservableObject {
|
|
|
633
945
|
}
|
|
634
946
|
|
|
635
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
|
+
|
|
636
956
|
/// The bundled config laid over the defaults, key by key.
|
|
637
957
|
///
|
|
638
958
|
/// Decoded whole, one missing or mistyped key failed the entire file and
|
|
@@ -711,6 +1031,31 @@ struct CraftConfig: Codable {
|
|
|
711
1031
|
var enableMLKit: Bool = false
|
|
712
1032
|
var devServerURL: String? = nil
|
|
713
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
|
+
}
|
|
714
1059
|
}
|
|
715
1060
|
|
|
716
1061
|
extension CraftConfig {
|
|
@@ -724,6 +1069,49 @@ extension CraftConfig {
|
|
|
724
1069
|
}
|
|
725
1070
|
}
|
|
726
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
|
+
|
|
727
1115
|
/// The webview's background, resolved per trait so a Dark Mode switch
|
|
728
1116
|
/// repaints it without a reload.
|
|
729
1117
|
var resolvedBackgroundColor: UIColor {
|
|
@@ -742,6 +1130,13 @@ final class BundledAssetSchemeHandler: NSObject, WKURLSchemeHandler {
|
|
|
742
1130
|
super.init()
|
|
743
1131
|
}
|
|
744
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
|
+
|
|
745
1140
|
private static func findBundledRoot() -> URL? {
|
|
746
1141
|
let bundle = Bundle.main
|
|
747
1142
|
let directCandidates = [
|
|
@@ -876,7 +1271,9 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
876
1271
|
let webConfig = WKWebViewConfiguration()
|
|
877
1272
|
webConfig.defaultWebpagePreferences.allowsContentJavaScript = true
|
|
878
1273
|
webConfig.allowsInlineMediaPlayback = true
|
|
1274
|
+
webConfig.allowsPictureInPictureMediaPlayback = true
|
|
879
1275
|
webConfig.mediaTypesRequiringUserActionForPlayback = []
|
|
1276
|
+
webConfig.applicationNameForUserAgent = config.applicationNameForUserAgent(after: webConfig.applicationNameForUserAgent)
|
|
880
1277
|
webConfig.setURLSchemeHandler(BundledAssetSchemeHandler(), forURLScheme: "craft")
|
|
881
1278
|
// The app's own domains (WKAppBoundDomains in Info.plist) get what an
|
|
882
1279
|
// iOS web view reserves for them, service workers among them, so a
|
|
@@ -890,26 +1287,42 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
890
1287
|
let contentController = WKUserContentController()
|
|
891
1288
|
contentController.add(context.coordinator, name: "craft")
|
|
892
1289
|
// The tab bar and the splash, outside the bridge's action dispatch:
|
|
893
|
-
// they are the screen's, not a device API
|
|
894
|
-
// before the bridge is installed.
|
|
1290
|
+
// they are the screen's, not a device API.
|
|
895
1291
|
let coordinator = context.coordinator
|
|
896
1292
|
contentController.add(CraftChromeRelay(trusts: { [weak coordinator] origin in coordinator?.trusts(origin) ?? false }), name: "craftChrome")
|
|
897
|
-
// From the first byte, so the page's CSS knows before it paints that
|
|
898
|
-
// the shell draws the tab bar, and does not draw its own for a frame.
|
|
899
|
-
contentController.addUserScript(WKUserScript(source: "document.documentElement.setAttribute('data-craft-chrome','ios')", injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
|
900
|
-
webConfig.userContentController = contentController
|
|
901
1293
|
#if DEBUG
|
|
902
1294
|
// The page's errors and console, in the device log, so a debug build
|
|
903
1295
|
// can be diagnosed from `log stream` or `simctl spawn … log show`
|
|
904
1296
|
// without attaching Safari. A separate handler, outside the bridge's
|
|
905
1297
|
// action dispatch. Never in a release build.
|
|
906
1298
|
contentController.add(PageConsoleRelay(), name: "craftLog")
|
|
907
|
-
contentController.addUserScript(WKUserScript(source: PageConsoleRelay.script, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
|
908
1299
|
#endif
|
|
1300
|
+
// The bridge and everything else the page gets from the first byte.
|
|
1301
|
+
coordinator.installUserScripts(into: contentController)
|
|
1302
|
+
webConfig.userContentController = contentController
|
|
909
1303
|
|
|
910
1304
|
let webView = WKWebView(frame: .zero, configuration: webConfig)
|
|
911
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)
|
|
912
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
|
+
}
|
|
913
1326
|
// An edge swipe goes back (and forward) through the page's history,
|
|
914
1327
|
// pushState entries included, the way every iOS app's stack does.
|
|
915
1328
|
webView.allowsBackForwardNavigationGestures = config.swipeNavigation ?? false
|
|
@@ -928,17 +1341,22 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
928
1341
|
DeepLinkManager.shared.setWebView(webView)
|
|
929
1342
|
CraftEventManager.shared.setWebView(webView)
|
|
930
1343
|
CraftChrome.shared.webView = webView
|
|
1344
|
+
CraftChrome.shared.holdSplash(atMost: config.splashMaxSeconds)
|
|
931
1345
|
|
|
932
1346
|
// Parse background color
|
|
933
1347
|
let bgColor = config.resolvedBackgroundColor
|
|
934
1348
|
webView.backgroundColor = bgColor
|
|
935
1349
|
webView.scrollView.backgroundColor = bgColor
|
|
936
1350
|
|
|
937
|
-
// Load content
|
|
938
|
-
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 {
|
|
939
1357
|
// Development mode - connect to server
|
|
940
1358
|
if let url = URL(string: devURL) {
|
|
941
|
-
webView.load(
|
|
1359
|
+
webView.load(config.request(for: url))
|
|
942
1360
|
}
|
|
943
1361
|
} else if let bundledURL = URL(string: "craft://app/index.html") {
|
|
944
1362
|
// A route-aware local origin keeps root-relative STX assets and
|
|
@@ -978,6 +1396,25 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
978
1396
|
private var pendingCallbackId: String?
|
|
979
1397
|
private var pendingPushCallbackId: String?
|
|
980
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
|
+
}
|
|
981
1418
|
|
|
982
1419
|
/// The highest `cb_<n>` this process has seen the page hand out.
|
|
983
1420
|
///
|
|
@@ -1102,6 +1539,11 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1102
1539
|
NotificationCenter.default.addObserver(self, selector: #selector(receivePushToken(_:)), name: .craftPushToken, object: nil)
|
|
1103
1540
|
NotificationCenter.default.addObserver(self, selector: #selector(receivePushRegistrationError(_:)), name: .craftPushRegistrationError, object: nil)
|
|
1104
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
|
+
}
|
|
1105
1547
|
setupNetworkMonitoring()
|
|
1106
1548
|
}
|
|
1107
1549
|
|
|
@@ -1155,6 +1597,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1155
1597
|
networkMonitor?.start(queue: DispatchQueue.global())
|
|
1156
1598
|
}
|
|
1157
1599
|
|
|
1600
|
+
// CRAFT_IOS_PAGE_BRIDGE_DISPATCHER
|
|
1158
1601
|
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
1159
1602
|
let securityOrigin = message.frameInfo.securityOrigin
|
|
1160
1603
|
guard isTrustedOrigin(scheme: securityOrigin.protocol, host: securityOrigin.host, port: securityOrigin.port) else {
|
|
@@ -1164,6 +1607,13 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1164
1607
|
guard let body = message.body as? [String: Any],
|
|
1165
1608
|
let action = body["action"] as? String else { return }
|
|
1166
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
|
+
|
|
1167
1617
|
let callbackId = body["callbackId"] as? String
|
|
1168
1618
|
// Before Zig is offered the call, because the seed has to cover
|
|
1169
1619
|
// every id the page drew, not only the ones Swift went on to serve.
|
|
@@ -1310,6 +1760,62 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1310
1760
|
} else {
|
|
1311
1761
|
rejectCallback(callbackId, error: "Settings URL is unavailable")
|
|
1312
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)
|
|
1313
1819
|
case "log":
|
|
1314
1820
|
if let msg = body["message"] as? String {
|
|
1315
1821
|
print("[Craft Web] \(msg)")
|
|
@@ -1999,12 +2505,30 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1999
2505
|
DeepLinkManager.shared.setLoading()
|
|
2000
2506
|
}
|
|
2001
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
|
+
|
|
2002
2519
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
2003
|
-
self.webView = webView
|
|
2004
2520
|
CraftChrome.shared.pageFinished()
|
|
2005
2521
|
CraftChrome.shared.publishLayout()
|
|
2006
2522
|
guard isTrustedURL(webView.url) else { return }
|
|
2007
|
-
|
|
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
|
+
}
|
|
2008
2532
|
}
|
|
2009
2533
|
|
|
2010
2534
|
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
|
@@ -2012,7 +2536,20 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2012
2536
|
decisionHandler(.cancel)
|
|
2013
2537
|
return
|
|
2014
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
|
+
}
|
|
2015
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
|
+
}
|
|
2016
2553
|
decisionHandler(.allow)
|
|
2017
2554
|
return
|
|
2018
2555
|
}
|
|
@@ -2042,11 +2579,150 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2042
2579
|
}
|
|
2043
2580
|
}
|
|
2044
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.
|
|
2045
2592
|
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
|
2046
|
-
|
|
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
|
|
2047
2716
|
}
|
|
2048
2717
|
|
|
2049
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
|
+
}
|
|
2050
2726
|
fallBackIfUnreachable(webView, after: error)
|
|
2051
2727
|
}
|
|
2052
2728
|
|
|
@@ -2062,14 +2738,9 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2062
2738
|
}
|
|
2063
2739
|
|
|
2064
2740
|
private func loadBundledFallback(in webView: WKWebView) {
|
|
2065
|
-
guard !loadedBundledFallback,
|
|
2066
|
-
config.devServerURL != nil,
|
|
2067
|
-
let bundledURL = URL(string: "craft://app/index.html") else { return }
|
|
2741
|
+
guard !loadedBundledFallback, config.devServerURL != nil else { return }
|
|
2068
2742
|
loadedBundledFallback = true
|
|
2069
|
-
|
|
2070
|
-
// its own to reload.
|
|
2071
|
-
self.webView = webView
|
|
2072
|
-
webView.load(URLRequest(url: bundledURL))
|
|
2743
|
+
loadBundledPage(in: webView)
|
|
2073
2744
|
}
|
|
2074
2745
|
|
|
2075
2746
|
/// Leave the bundled copy for the remote origin again (#252).
|
|
@@ -2088,14 +2759,40 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2088
2759
|
let remote = config.devServerURL.flatMap(URL.init(string:)) else { return }
|
|
2089
2760
|
loadedBundledFallback = false
|
|
2090
2761
|
print("Craft: \(reason); loading \(remote) again instead of the bundled copy")
|
|
2091
|
-
webView.load(
|
|
2762
|
+
webView.load(config.request(for: remote))
|
|
2092
2763
|
}
|
|
2093
2764
|
|
|
2094
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
|
+
}
|
|
2095
2776
|
returnFromBundledFallback(because: "the app returned to the foreground")
|
|
2096
2777
|
}
|
|
2097
2778
|
|
|
2098
|
-
|
|
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 {
|
|
2099
2796
|
guard let url = url else { return false }
|
|
2100
2797
|
return isTrustedOrigin(scheme: url.scheme ?? "", host: url.host ?? "", port: url.port ?? 0)
|
|
2101
2798
|
}
|
|
@@ -2151,7 +2848,91 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2151
2848
|
if drawn > highestCallbackId { highestCallbackId = drawn }
|
|
2152
2849
|
}
|
|
2153
2850
|
|
|
2154
|
-
|
|
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 {
|
|
2155
2936
|
let laContext = LAContext()
|
|
2156
2937
|
var biometricAvailable = false
|
|
2157
2938
|
if laContext.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) {
|
|
@@ -3698,7 +4479,12 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3698
4479
|
var personFacing = {
|
|
3699
4480
|
requestHealthAuthorization: true,
|
|
3700
4481
|
requestPermission: true,
|
|
3701
|
-
signInWithApple: true
|
|
4482
|
+
signInWithApple: true,
|
|
4483
|
+
dialogAlert: true,
|
|
4484
|
+
dialogConfirm: true,
|
|
4485
|
+
dialogActionSheet: true,
|
|
4486
|
+
contextMenuShow: true,
|
|
4487
|
+
browserOpen: true
|
|
3702
4488
|
};
|
|
3703
4489
|
craft._invoke = function(action, payload) {
|
|
3704
4490
|
var self = craft;
|
|
@@ -3733,12 +4519,19 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3733
4519
|
throw error;
|
|
3734
4520
|
});
|
|
3735
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.
|
|
3736
4526
|
craft.haptics = {
|
|
3737
4527
|
impact: function(style) { return hapticFeedback(craft.haptic(style || 'medium')); },
|
|
3738
4528
|
notification: function(type) {
|
|
3739
|
-
return hapticFeedback(craft.haptic(type === 'error'
|
|
4529
|
+
return hapticFeedback(craft.haptic(type === 'error' || type === 'warning' ? type : 'success'));
|
|
3740
4530
|
},
|
|
3741
|
-
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})); },
|
|
3742
4535
|
vibrate: function(pattern) { return hapticFeedback(craft.vibrate(pattern || [])); }
|
|
3743
4536
|
};
|
|
3744
4537
|
craft.permissions = {
|
|
@@ -3910,6 +4703,146 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3910
4703
|
}
|
|
3911
4704
|
})(window.craft);
|
|
3912
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
|
+
|
|
3913
4846
|
// The reply route for actions the Zig runtime serves.
|
|
3914
4847
|
//
|
|
3915
4848
|
// Zig owns one wire format and calls these two functions by name;
|
|
@@ -3931,15 +4864,39 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3931
4864
|
window.craft._rejectCallback('cb_' + ctx.id, ctx.message, ctx.code);
|
|
3932
4865
|
};
|
|
3933
4866
|
|
|
3934
|
-
//
|
|
3935
|
-
|
|
3936
|
-
|
|
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
|
+
}
|
|
3937
4899
|
"""
|
|
3938
|
-
webView?.evaluateJavaScript(script)
|
|
3939
|
-
|
|
3940
|
-
// Mark DeepLinkManager as ready
|
|
3941
|
-
DeepLinkManager.shared.setReady()
|
|
3942
|
-
CraftEventManager.shared.setReady()
|
|
3943
4900
|
}
|
|
3944
4901
|
|
|
3945
4902
|
// MARK: - Callback Helpers
|
|
@@ -4071,8 +5028,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
4071
5028
|
|
|
4072
5029
|
// MARK: - Share
|
|
4073
5030
|
private func share(options: [String: Any], callbackId: String?) {
|
|
4074
|
-
guard
|
|
4075
|
-
let rootVC = windowScene.windows.first?.rootViewController else {
|
|
5031
|
+
guard CraftPresenter.topViewController() != nil else {
|
|
4076
5032
|
rejectCallback(callbackId, error: "Unable to present the share sheet")
|
|
4077
5033
|
return
|
|
4078
5034
|
}
|
|
@@ -4098,7 +5054,11 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
4098
5054
|
self.resolveCallback(callbackId, result: completed)
|
|
4099
5055
|
}
|
|
4100
5056
|
}
|
|
4101
|
-
|
|
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"]))
|
|
4102
5062
|
}
|
|
4103
5063
|
|
|
4104
5064
|
// MARK: - Camera & Photo Library
|
|
@@ -4108,8 +5068,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
4108
5068
|
self.rejectCallback(self.pendingCallbackId, error: "Camera not available")
|
|
4109
5069
|
return
|
|
4110
5070
|
}
|
|
4111
|
-
guard let
|
|
4112
|
-
let rootVC = windowScene.windows.first?.rootViewController else { return }
|
|
5071
|
+
guard let rootVC = CraftPresenter.topViewController() else { return }
|
|
4113
5072
|
|
|
4114
5073
|
let picker = UIImagePickerController()
|
|
4115
5074
|
picker.sourceType = .camera
|
|
@@ -4120,8 +5079,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
4120
5079
|
|
|
4121
5080
|
private func pickImage() {
|
|
4122
5081
|
DispatchQueue.main.async {
|
|
4123
|
-
guard let
|
|
4124
|
-
let rootVC = windowScene.windows.first?.rootViewController else { return }
|
|
5082
|
+
guard let rootVC = CraftPresenter.topViewController() else { return }
|
|
4125
5083
|
|
|
4126
5084
|
let picker = UIImagePickerController()
|
|
4127
5085
|
picker.sourceType = .photoLibrary
|
|
@@ -5342,8 +6300,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5342
6300
|
// MARK: - QR/Barcode Scanner
|
|
5343
6301
|
private func scanQRCode(callbackId: String?) {
|
|
5344
6302
|
DispatchQueue.main.async {
|
|
5345
|
-
guard let
|
|
5346
|
-
let rootVC = windowScene.windows.first?.rootViewController else { return }
|
|
6303
|
+
guard let rootVC = CraftPresenter.topViewController() else { return }
|
|
5347
6304
|
|
|
5348
6305
|
if #available(iOS 16.0, *) {
|
|
5349
6306
|
let scannerVC = DataScannerViewController(
|
|
@@ -5365,8 +6322,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5365
6322
|
// MARK: - File Picker
|
|
5366
6323
|
private func pickFile(types: [String]?, callbackId: String?) {
|
|
5367
6324
|
DispatchQueue.main.async {
|
|
5368
|
-
guard let
|
|
5369
|
-
let rootVC = windowScene.windows.first?.rootViewController else { return }
|
|
6325
|
+
guard let rootVC = CraftPresenter.topViewController() else { return }
|
|
5370
6326
|
|
|
5371
6327
|
var allowedTypes: [UTType] = [.item]
|
|
5372
6328
|
if let types = types {
|
|
@@ -5510,8 +6466,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5510
6466
|
return
|
|
5511
6467
|
}
|
|
5512
6468
|
|
|
5513
|
-
guard let
|
|
5514
|
-
let rootVC = windowScene.windows.first?.rootViewController else { return }
|
|
6469
|
+
guard let rootVC = CraftPresenter.topViewController() else { return }
|
|
5515
6470
|
|
|
5516
6471
|
let picker = UIImagePickerController()
|
|
5517
6472
|
picker.sourceType = .camera
|
|
@@ -6270,8 +7225,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6270
7225
|
|
|
6271
7226
|
private func openPDF(source: String, page: Int, callbackId: String?) {
|
|
6272
7227
|
DispatchQueue.main.async {
|
|
6273
|
-
guard let
|
|
6274
|
-
let rootVC = windowScene.windows.first?.rootViewController else {
|
|
7228
|
+
guard let rootVC = CraftPresenter.topViewController() else {
|
|
6275
7229
|
self.rejectCallback(callbackId, error: "No root view controller")
|
|
6276
7230
|
return
|
|
6277
7231
|
}
|
|
@@ -6348,8 +7302,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6348
7302
|
// MARK: - Contacts Picker
|
|
6349
7303
|
private func pickContact(multiple: Bool, callbackId: String?) {
|
|
6350
7304
|
DispatchQueue.main.async {
|
|
6351
|
-
guard let
|
|
6352
|
-
let rootVC = windowScene.windows.first?.rootViewController else {
|
|
7305
|
+
guard let rootVC = CraftPresenter.topViewController() else {
|
|
6353
7306
|
self.rejectCallback(callbackId, error: "No root view controller")
|
|
6354
7307
|
return
|
|
6355
7308
|
}
|
|
@@ -7218,6 +8171,72 @@ extension URL {
|
|
|
7218
8171
|
//
|
|
7219
8172
|
// The end of an utterance is what `craft.speech.speak()` waits for. Hopped to
|
|
7220
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
|
+
|
|
7221
8240
|
extension CraftWebView.Coordinator: AVSpeechSynthesizerDelegate {
|
|
7222
8241
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
|
7223
8242
|
DispatchQueue.main.async { self.speechDidEnd(utterance, spoken: true) }
|