craft-native 0.0.123 → 0.0.125
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 +22 -3
- package/dist/android/templates/AndroidManifest.xml.template +1 -1
- package/dist/android/templates/CraftNativeFlatList.kt.template +151 -7
- package/dist/android/templates/CraftNativeRefreshableFlatList.kt.template +29 -0
- package/dist/android/templates/MainActivity.kt.template +17 -0
- package/dist/android/templates/MainActivityNative.kt.template +1085 -84
- package/dist/android/templates/build.gradle.kts.app.template +1 -0
- package/dist/api/mobile.d.ts +250 -2
- package/dist/cli.js +3 -3
- package/dist/index.cjs +22 -1
- package/dist/index.js +22 -1
- package/dist/ios/src/index.d.ts +125 -3
- package/dist/ios/src/index.js +284 -47
- package/dist/ios/templates/CraftApp.swift +1186 -69
- package/dist/ios/templates/CraftHybrid.swift +1196 -0
- package/dist/ios/templates/CraftNativeActions.swift +310 -10
- package/dist/ios/templates/CraftNativeFlatList.swift +226 -17
- package/dist/ios/templates/CraftNativeScreen.swift +2217 -220
- package/dist/ios/templates/CraftNativeUI.swift +562 -0
- package/dist/ios/templates/Info.plist.template +2 -1
- package/dist/mobile.js +204 -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,29 @@ 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
|
+
let id = CraftBackgroundWork.shared.hold { ok in
|
|
231
|
+
switch ok {
|
|
232
|
+
case true?: completionHandler(.newData)
|
|
233
|
+
case false?: completionHandler(.failed)
|
|
234
|
+
case nil: completionHandler(.noData)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
CraftEventManager.shared.sendToWeb("craftSilentPush", data: ["id": id, "payload": CraftEventManager.pageData(userInfo)])
|
|
94
238
|
}
|
|
95
239
|
|
|
96
240
|
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
|
@@ -170,7 +314,7 @@ class CraftEventManager {
|
|
|
170
314
|
sendToWeb("craftNotificationReceived", data: Self.pageData(userInfo))
|
|
171
315
|
}
|
|
172
316
|
|
|
173
|
-
|
|
317
|
+
static func pageData(_ userInfo: [AnyHashable: Any]) -> [String: Any] {
|
|
174
318
|
var data: [String: Any] = [:]
|
|
175
319
|
for (key, value) in userInfo {
|
|
176
320
|
guard let key = key as? String else { continue }
|
|
@@ -194,7 +338,8 @@ class CraftEventManager {
|
|
|
194
338
|
return true
|
|
195
339
|
}
|
|
196
340
|
|
|
197
|
-
|
|
341
|
+
/// Dispatch now if the page can hear it, or hold it until it can.
|
|
342
|
+
func sendToWeb(_ event: String, data: [String: Any]) {
|
|
198
343
|
guard isReady, webView != nil else {
|
|
199
344
|
pendingEvents.append((event, data))
|
|
200
345
|
return
|
|
@@ -213,6 +358,100 @@ class CraftEventManager {
|
|
|
213
358
|
}
|
|
214
359
|
}
|
|
215
360
|
|
|
361
|
+
// MARK: - Background work
|
|
362
|
+
|
|
363
|
+
/// The page's share of background time: a silent push (`craftSilentPush`) and
|
|
364
|
+
/// iOS's scheduled app refresh (`craftBackgroundRefresh`). Each holds iOS's
|
|
365
|
+
/// completion until the page calls `craft.background.complete(ok)` (with the
|
|
366
|
+
/// event's `id`, or with none for everything pending), or for 25 seconds.
|
|
367
|
+
///
|
|
368
|
+
/// The page has to be alive to do the work: when iOS wakes an app it had
|
|
369
|
+
/// suspended, it is; when it launches one it had ended, the event waits for
|
|
370
|
+
/// the page like any other and the deadline answers iOS.
|
|
371
|
+
final class CraftBackgroundWork {
|
|
372
|
+
static let shared = CraftBackgroundWork()
|
|
373
|
+
static let deadline: TimeInterval = 25
|
|
374
|
+
|
|
375
|
+
private var pending: [String: (Bool?) -> Void] = [:]
|
|
376
|
+
private var refreshIdentifier: String?
|
|
377
|
+
private var refreshInterval: TimeInterval = 15 * 60
|
|
378
|
+
|
|
379
|
+
private init() {}
|
|
380
|
+
|
|
381
|
+
/// Hold `finish` until the page answers the event that carries the
|
|
382
|
+
/// returned id: true or false from the page, nil when the deadline passed
|
|
383
|
+
/// first. The caller sends that event, with the id in its detail, after
|
|
384
|
+
/// this returns. It names the event itself so every event the page can
|
|
385
|
+
/// hear is spelled at a `sendToWeb` call, where the conformance test reads
|
|
386
|
+
/// the vocabulary from.
|
|
387
|
+
func hold(_ finish: @escaping (Bool?) -> Void) -> String {
|
|
388
|
+
let id = UUID().uuidString
|
|
389
|
+
pending[id] = finish
|
|
390
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + Self.deadline) { [weak self] in self?.end(id, ok: nil) }
|
|
391
|
+
return id
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/// `craft.background.complete(ok, id?)`.
|
|
395
|
+
func complete(id: String?, ok: Bool) {
|
|
396
|
+
if let id {
|
|
397
|
+
end(id, ok: ok)
|
|
398
|
+
} else {
|
|
399
|
+
for id in Array(pending.keys) { end(id, ok: ok) }
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private func end(_ id: String, ok: Bool?) {
|
|
404
|
+
guard let finish = pending.removeValue(forKey: id) else { return }
|
|
405
|
+
finish(ok)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/// BGAppRefreshTask, when `backgroundRefresh.enabled`: registered at
|
|
409
|
+
/// launch, asked for again each time the app goes to the background and
|
|
410
|
+
/// after each run, no sooner than `minimumIntervalMinutes` (15 by
|
|
411
|
+
/// default). iOS decides when it actually runs.
|
|
412
|
+
func registerRefresh(_ config: CraftConfig) {
|
|
413
|
+
guard let refresh = config.backgroundRefresh, refresh.enabled else { return }
|
|
414
|
+
let identifier = refresh.identifier.flatMap { $0.isEmpty ? nil : $0 }
|
|
415
|
+
?? "\(Bundle.main.bundleIdentifier ?? config.bundleId).refresh"
|
|
416
|
+
refreshIdentifier = identifier
|
|
417
|
+
refreshInterval = max(1, refresh.minimumIntervalMinutes ?? 15) * 60
|
|
418
|
+
let registered = BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier, using: nil) { task in
|
|
419
|
+
DispatchQueue.main.async { CraftBackgroundWork.shared.run(task) }
|
|
420
|
+
}
|
|
421
|
+
guard registered else {
|
|
422
|
+
print("Craft: background refresh \(identifier) could not be registered; is it in BGTaskSchedulerPermittedIdentifiers?")
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { _ in
|
|
426
|
+
CraftBackgroundWork.shared.scheduleRefresh()
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
func scheduleRefresh() {
|
|
431
|
+
guard let refreshIdentifier else { return }
|
|
432
|
+
let request = BGAppRefreshTaskRequest(identifier: refreshIdentifier)
|
|
433
|
+
request.earliestBeginDate = Date(timeIntervalSinceNow: refreshInterval)
|
|
434
|
+
do {
|
|
435
|
+
try BGTaskScheduler.shared.submit(request)
|
|
436
|
+
} catch {
|
|
437
|
+
// The simulator refuses every request; a device refuses one
|
|
438
|
+
// with Background App Refresh off.
|
|
439
|
+
print("Craft: background refresh not scheduled: \(error)")
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private func run(_ task: BGTask) {
|
|
444
|
+
scheduleRefresh()
|
|
445
|
+
let id = hold { ok in
|
|
446
|
+
task.setTaskCompleted(success: ok ?? false)
|
|
447
|
+
}
|
|
448
|
+
task.expirationHandler = {
|
|
449
|
+
DispatchQueue.main.async { CraftBackgroundWork.shared.end(id, ok: false) }
|
|
450
|
+
}
|
|
451
|
+
CraftEventManager.shared.sendToWeb("craftBackgroundRefresh", data: ["id": id])
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
216
455
|
// MARK: - App Entry Point
|
|
217
456
|
@main
|
|
218
457
|
struct CraftApp: App {
|
|
@@ -224,10 +463,21 @@ struct CraftApp: App {
|
|
|
224
463
|
Group {
|
|
225
464
|
if appState.config.renderer == "native" {
|
|
226
465
|
CraftNativeScreen(config: appState.config)
|
|
466
|
+
} else if CraftHybrid.shared.isEnabled {
|
|
467
|
+
// Web plus native screens (CraftHybrid.swift): the native
|
|
468
|
+
// screen for the start path at once, the page warm behind it.
|
|
469
|
+
ZStack(alignment: .bottom) {
|
|
470
|
+
CraftColorSchemeObserver()
|
|
471
|
+
CraftHybridRoot(config: appState.config)
|
|
472
|
+
.ignoresSafeArea()
|
|
473
|
+
CraftTabBarView()
|
|
474
|
+
CraftSplashView(background: appState.config.resolvedBackgroundColor)
|
|
475
|
+
}
|
|
227
476
|
} else {
|
|
228
477
|
// The page's own chrome, drawn natively over it: the tab
|
|
229
478
|
// bar the page asks for, and the splash until it is ready.
|
|
230
479
|
ZStack(alignment: .bottom) {
|
|
480
|
+
CraftColorSchemeObserver()
|
|
231
481
|
CraftWebView(config: appState.config)
|
|
232
482
|
.ignoresSafeArea()
|
|
233
483
|
CraftTabBarView()
|
|
@@ -279,10 +529,30 @@ final class CraftChrome: ObservableObject {
|
|
|
279
529
|
/// What the tab bar covers at the bottom of the screen, in points.
|
|
280
530
|
private var occupied: CGFloat = 0
|
|
281
531
|
private var pendingHide: DispatchWorkItem?
|
|
532
|
+
private var splashDeadline: DispatchWorkItem?
|
|
533
|
+
|
|
534
|
+
private init() {}
|
|
282
535
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
536
|
+
/// The tab bar from `tabs` in craft.config.json, there before the page
|
|
537
|
+
/// loads; the page's own `tabBar` message replaces it later. `splash`
|
|
538
|
+
/// false when the first screen is native and draws itself.
|
|
539
|
+
func configure(tabs configured: [CraftConfig.Tab], selected: String?, splash: Bool) {
|
|
540
|
+
splashVisible = splash
|
|
541
|
+
guard !configured.isEmpty else { return }
|
|
542
|
+
tabs = configured.map { Tab(id: $0.id, title: $0.title, symbol: $0.symbol ?? "circle", badge: nil) }
|
|
543
|
+
self.selected = selected ?? configured.first?.id
|
|
544
|
+
tabBarVisible = true
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/// However the page behaves, the splash is gone after `seconds`
|
|
548
|
+
/// (`splashMaxSeconds`, three by default). It used to be ten: a page that
|
|
549
|
+
/// never painted held a logo on screen for ten seconds, which reads as a
|
|
550
|
+
/// hang rather than a launch.
|
|
551
|
+
func holdSplash(atMost seconds: Double) {
|
|
552
|
+
splashDeadline?.cancel()
|
|
553
|
+
let deadline = DispatchWorkItem { [weak self] in self?.hideSplash() }
|
|
554
|
+
splashDeadline = deadline
|
|
555
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + max(0.5, seconds), execute: deadline)
|
|
286
556
|
}
|
|
287
557
|
|
|
288
558
|
func handle(_ body: [String: Any]) {
|
|
@@ -294,25 +564,29 @@ final class CraftChrome: ObservableObject {
|
|
|
294
564
|
let badge = (item["badge"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
|
295
565
|
return Tab(id: id, title: title, symbol: item["symbol"] as? String ?? "circle", badge: badge)
|
|
296
566
|
}
|
|
297
|
-
if let selected = body["selected"] as? String {
|
|
567
|
+
if let selected = body["selected"] as? String {
|
|
568
|
+
self.selected = selected
|
|
569
|
+
CraftHybrid.shared.pageSelectedTab(selected)
|
|
570
|
+
}
|
|
298
571
|
if let hex = body["tint"] as? String {
|
|
299
572
|
let light = UIColor(hex: hex)
|
|
300
573
|
let dark = (body["tintDark"] as? String).flatMap { UIColor(hex: $0) }
|
|
301
574
|
tint = light.map { light in dark.map { dark in UIColor { $0.userInterfaceStyle == .dark ? dark : light } } ?? light }
|
|
302
575
|
}
|
|
303
|
-
withAnimation(.easeOut(duration: 0.2)) { tabBarVisible = !tabs.isEmpty }
|
|
576
|
+
withAnimation(Self.motion(.easeOut(duration: 0.2))) { tabBarVisible = !tabs.isEmpty }
|
|
304
577
|
publishLayout()
|
|
305
578
|
case "selectTab":
|
|
306
579
|
if let id = body["id"] as? String, id != selected {
|
|
307
|
-
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { selected = id }
|
|
580
|
+
withAnimation(Self.motion(.spring(response: 0.32, dampingFraction: 0.86))) { selected = id }
|
|
308
581
|
}
|
|
582
|
+
if let id = body["id"] as? String { CraftHybrid.shared.pageSelectedTab(id) }
|
|
309
583
|
case "hideTabBar":
|
|
310
584
|
// Later, and only if no screen asks for the bar meanwhile: moving
|
|
311
585
|
// between two screens that both have it unmounts one bar and
|
|
312
586
|
// mounts the next, and the bar must not blink in between.
|
|
313
587
|
pendingHide?.cancel()
|
|
314
588
|
let hide = DispatchWorkItem { [weak self] in
|
|
315
|
-
withAnimation(.easeIn(duration: 0.18)) { self?.tabBarVisible = false }
|
|
589
|
+
withAnimation(Self.motion(.easeIn(duration: 0.18))) { self?.tabBarVisible = false }
|
|
316
590
|
self?.occupied = 0
|
|
317
591
|
self?.publishLayout()
|
|
318
592
|
}
|
|
@@ -320,25 +594,77 @@ final class CraftChrome: ObservableObject {
|
|
|
320
594
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: hide)
|
|
321
595
|
case "ready":
|
|
322
596
|
hideSplash()
|
|
597
|
+
case "painted":
|
|
598
|
+
// The page's first contentful paint, two frames on: something of
|
|
599
|
+
// the page is on screen, so the launch screen has done its job
|
|
600
|
+
// even if the page never says `ready`.
|
|
601
|
+
hideSplash()
|
|
323
602
|
default:
|
|
324
603
|
break
|
|
325
604
|
}
|
|
326
605
|
}
|
|
327
606
|
|
|
328
607
|
/// A tap on a tab: the page navigates, and says which tab is current.
|
|
608
|
+
///
|
|
609
|
+
/// No haptic. UITabBar plays none, and a tab bar that ticks on every tap is
|
|
610
|
+
/// one of the small things that gives a web shell away.
|
|
329
611
|
func tap(_ tab: Tab) {
|
|
330
|
-
|
|
331
|
-
if
|
|
332
|
-
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { selected = tab.id }
|
|
612
|
+
let reselect = tab.id == selected
|
|
613
|
+
if !reselect {
|
|
614
|
+
withAnimation(Self.motion(.spring(response: 0.32, dampingFraction: 0.86))) { selected = tab.id }
|
|
333
615
|
}
|
|
616
|
+
// A hybrid app shows the tab's own stack first; the page hears the
|
|
617
|
+
// tap unless the shell handled all of it (a native tab's reselect).
|
|
618
|
+
guard CraftHybrid.shared.tabTapped(tab.id, reselect: reselect) else { return }
|
|
334
619
|
emit("craftTabSelect", ["id": tab.id])
|
|
335
620
|
}
|
|
336
621
|
|
|
622
|
+
/// The animation, or none with Reduce Motion on: the selection jumps to
|
|
623
|
+
/// its tab rather than springing there, as UIKit's own controls do.
|
|
624
|
+
static func motion(_ animation: Animation) -> Animation? {
|
|
625
|
+
UIAccessibility.isReduceMotionEnabled ? nil : animation
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/// The splash again, over a page that is being rebuilt (the web content
|
|
629
|
+
/// process died), rather than the white flash of a reload.
|
|
630
|
+
func showSplash(atMost seconds: Double) {
|
|
631
|
+
withAnimation(nil) { splashVisible = true }
|
|
632
|
+
holdSplash(atMost: seconds)
|
|
633
|
+
}
|
|
634
|
+
|
|
337
635
|
func hideSplash() {
|
|
636
|
+
splashDeadline?.cancel()
|
|
637
|
+
splashDeadline = nil
|
|
338
638
|
guard splashVisible else { return }
|
|
639
|
+
#if DEBUG
|
|
640
|
+
NSLog("[craft] splash hidden")
|
|
641
|
+
#endif
|
|
339
642
|
withAnimation(.easeOut(duration: 0.28)) { splashVisible = false }
|
|
340
643
|
}
|
|
341
644
|
|
|
645
|
+
/// The paint half of "until the page is ready or has painted": tells the
|
|
646
|
+
/// shell once the document's first contentful paint is on screen.
|
|
647
|
+
static let paintScript = """
|
|
648
|
+
(function() {
|
|
649
|
+
try {
|
|
650
|
+
var handler = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craftChrome;
|
|
651
|
+
if (!handler || typeof PerformanceObserver !== 'function') return;
|
|
652
|
+
var sent = false;
|
|
653
|
+
var send = function() {
|
|
654
|
+
if (sent) return;
|
|
655
|
+
sent = true;
|
|
656
|
+
requestAnimationFrame(function() { requestAnimationFrame(function() { handler.postMessage({type: 'painted'}); }); });
|
|
657
|
+
};
|
|
658
|
+
var observer = new PerformanceObserver(function(list) {
|
|
659
|
+
list.getEntries().forEach(function(entry) {
|
|
660
|
+
if (entry.name === 'first-contentful-paint') { send(); observer.disconnect(); }
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
observer.observe({type: 'paint', buffered: true});
|
|
664
|
+
} catch (e) {}
|
|
665
|
+
})();
|
|
666
|
+
"""
|
|
667
|
+
|
|
342
668
|
/// A page that loaded and never said it was ready still gets shown.
|
|
343
669
|
func pageFinished() {
|
|
344
670
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in self?.hideSplash() }
|
|
@@ -355,6 +681,7 @@ final class CraftChrome: ObservableObject {
|
|
|
355
681
|
/// for it, and as an event for anything that measures.
|
|
356
682
|
func publishLayout() {
|
|
357
683
|
let height = tabBarVisible ? Int(occupied) : 0
|
|
684
|
+
CraftHybrid.shared.tabBarCovers(CGFloat(height))
|
|
358
685
|
let script = "document.documentElement.style.setProperty('--craft-tab-bar-height','\(height)px');window.dispatchEvent(new CustomEvent('craftTabBarLayout',{detail:{height:\(height)}}));"
|
|
359
686
|
DispatchQueue.main.async { [weak self] in self?.webView?.evaluateJavaScript(script, completionHandler: nil) }
|
|
360
687
|
}
|
|
@@ -386,6 +713,13 @@ final class CraftChromeRelay: NSObject, WKScriptMessageHandler {
|
|
|
386
713
|
struct CraftTabBarView: View {
|
|
387
714
|
@ObservedObject private var chrome = CraftChrome.shared
|
|
388
715
|
@Namespace private var selection
|
|
716
|
+
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
717
|
+
// Dynamic Type, relative to the styles UIKit's own bar uses. The bar is
|
|
718
|
+
// capped at the largest standard size (below), as UITabBar is; past that
|
|
719
|
+
// a long press shows the large content viewer instead.
|
|
720
|
+
@ScaledMetric(relativeTo: .title3) private var iconSize: CGFloat = 20
|
|
721
|
+
@ScaledMetric(relativeTo: .caption2) private var labelSize: CGFloat = 11
|
|
722
|
+
@ScaledMetric(relativeTo: .caption2) private var badgeSize: CGFloat = 10
|
|
389
723
|
|
|
390
724
|
var body: some View {
|
|
391
725
|
if chrome.tabBarVisible && !chrome.tabs.isEmpty {
|
|
@@ -397,8 +731,9 @@ struct CraftTabBarView: View {
|
|
|
397
731
|
.onAppear { report(proxy) }
|
|
398
732
|
.onChange(of: proxy.frame(in: .global)) { _ in report(proxy) }
|
|
399
733
|
})
|
|
400
|
-
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
734
|
+
.transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity))
|
|
401
735
|
.ignoresSafeArea(.keyboard)
|
|
736
|
+
.dynamicTypeSize(...DynamicTypeSize.xxxLarge)
|
|
402
737
|
}
|
|
403
738
|
}
|
|
404
739
|
|
|
@@ -408,6 +743,7 @@ struct CraftTabBarView: View {
|
|
|
408
743
|
}
|
|
409
744
|
|
|
410
745
|
@ViewBuilder private var bar: some View {
|
|
746
|
+
#if compiler(>=6.2)
|
|
411
747
|
if #available(iOS 26.0, *) {
|
|
412
748
|
GlassEffectContainer {
|
|
413
749
|
items
|
|
@@ -421,6 +757,13 @@ struct CraftTabBarView: View {
|
|
|
421
757
|
.overlay(Capsule().strokeBorder(Color.white.opacity(0.35), lineWidth: 0.5))
|
|
422
758
|
.shadow(color: .black.opacity(0.12), radius: 18, y: 6)
|
|
423
759
|
}
|
|
760
|
+
#else
|
|
761
|
+
items
|
|
762
|
+
.padding(4)
|
|
763
|
+
.background(.ultraThinMaterial, in: Capsule())
|
|
764
|
+
.overlay(Capsule().strokeBorder(Color.white.opacity(0.35), lineWidth: 0.5))
|
|
765
|
+
.shadow(color: .black.opacity(0.12), radius: 18, y: 6)
|
|
766
|
+
#endif
|
|
424
767
|
}
|
|
425
768
|
|
|
426
769
|
private var items: some View {
|
|
@@ -431,11 +774,11 @@ struct CraftTabBarView: View {
|
|
|
431
774
|
VStack(spacing: 3) {
|
|
432
775
|
ZStack(alignment: .topTrailing) {
|
|
433
776
|
Image(systemName: symbol(tab.symbol, selected: isSelected))
|
|
434
|
-
.font(.system(size:
|
|
435
|
-
.frame(height:
|
|
777
|
+
.font(.system(size: iconSize, weight: isSelected ? .semibold : .regular))
|
|
778
|
+
.frame(height: iconSize + 6)
|
|
436
779
|
if let badge = tab.badge {
|
|
437
780
|
Text(badge)
|
|
438
|
-
.font(.system(size:
|
|
781
|
+
.font(.system(size: badgeSize, weight: .bold))
|
|
439
782
|
.foregroundStyle(.white)
|
|
440
783
|
.padding(.horizontal, 4)
|
|
441
784
|
.frame(minWidth: 16, minHeight: 16)
|
|
@@ -444,7 +787,7 @@ struct CraftTabBarView: View {
|
|
|
444
787
|
}
|
|
445
788
|
}
|
|
446
789
|
Text(tab.title)
|
|
447
|
-
.font(.system(size:
|
|
790
|
+
.font(.system(size: labelSize, weight: isSelected ? .semibold : .medium))
|
|
448
791
|
.lineLimit(1)
|
|
449
792
|
.minimumScaleFactor(0.8)
|
|
450
793
|
}
|
|
@@ -460,6 +803,10 @@ struct CraftTabBarView: View {
|
|
|
460
803
|
.contentShape(Capsule())
|
|
461
804
|
}
|
|
462
805
|
.buttonStyle(.plain)
|
|
806
|
+
.accessibilityShowsLargeContentViewer {
|
|
807
|
+
Image(systemName: symbol(tab.symbol, selected: isSelected))
|
|
808
|
+
Text(tab.title)
|
|
809
|
+
}
|
|
463
810
|
.accessibilityLabel(tab.badge.map { "\(tab.title), \($0)" } ?? tab.title)
|
|
464
811
|
.accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
|
|
465
812
|
}
|
|
@@ -545,6 +892,19 @@ class DeepLinkManager {
|
|
|
545
892
|
initialURL = url
|
|
546
893
|
}
|
|
547
894
|
|
|
895
|
+
// A hybrid app opens a native path's screen itself, and tells the
|
|
896
|
+
// native screens that listen; every other link is the page's.
|
|
897
|
+
if CraftHybrid.shared.isEnabled {
|
|
898
|
+
if CraftHybrid.shared.handleDeepLink(url) {
|
|
899
|
+
dispatchNative(url, initial: false)
|
|
900
|
+
} else if isReady && webView != nil {
|
|
901
|
+
dispatchDeepLink(url, initial: false)
|
|
902
|
+
} else {
|
|
903
|
+
pendingURLs.append(url)
|
|
904
|
+
}
|
|
905
|
+
return
|
|
906
|
+
}
|
|
907
|
+
|
|
548
908
|
if !nativeListeners.isEmpty {
|
|
549
909
|
dispatchNative(url, initial: false)
|
|
550
910
|
} else if isReady && webView != nil {
|
|
@@ -562,6 +922,9 @@ class DeepLinkManager {
|
|
|
562
922
|
func addNativeListener(_ listener: @escaping (URL, Bool) -> Void) -> UUID {
|
|
563
923
|
let token = UUID()
|
|
564
924
|
nativeListeners[token] = listener
|
|
925
|
+
// In a hybrid app the page is the one waiting for the links that
|
|
926
|
+
// arrived early; a native screen hears only native ones, as they come.
|
|
927
|
+
if CraftHybrid.shared.isEnabled { return token }
|
|
565
928
|
let firstNativeScreen = !hasBeenReady
|
|
566
929
|
hasBeenReady = true
|
|
567
930
|
let urls = pendingURLs
|
|
@@ -629,10 +992,21 @@ class AppState: ObservableObject {
|
|
|
629
992
|
} else {
|
|
630
993
|
self.config = CraftConfig()
|
|
631
994
|
}
|
|
995
|
+
// Before the first frame: the hybrid start screen, and the tab bar
|
|
996
|
+
// from the config, are decided here.
|
|
997
|
+
CraftHybrid.shared.configure(config)
|
|
632
998
|
}
|
|
633
999
|
}
|
|
634
1000
|
|
|
635
1001
|
extension CraftConfig {
|
|
1002
|
+
/// craft.config.json from the app bundle, for code that runs before the
|
|
1003
|
+
/// app's state exists (the app delegate at launch).
|
|
1004
|
+
static func bundled() -> CraftConfig {
|
|
1005
|
+
guard let url = Bundle.main.url(forResource: "craft.config", withExtension: "json"),
|
|
1006
|
+
let data = try? Data(contentsOf: url) else { return CraftConfig() }
|
|
1007
|
+
return CraftConfig.load(from: data)
|
|
1008
|
+
}
|
|
1009
|
+
|
|
636
1010
|
/// The bundled config laid over the defaults, key by key.
|
|
637
1011
|
///
|
|
638
1012
|
/// Decoded whole, one missing or mistyped key failed the entire file and
|
|
@@ -711,6 +1085,45 @@ struct CraftConfig: Codable {
|
|
|
711
1085
|
var enableMLKit: Bool = false
|
|
712
1086
|
var devServerURL: String? = nil
|
|
713
1087
|
var trustedOrigins: [String] = []
|
|
1088
|
+
/// The longest the launch splash stays up when the page neither says it is
|
|
1089
|
+
/// ready nor paints.
|
|
1090
|
+
var splashMaxSeconds: Double = 3
|
|
1091
|
+
/// How long the app's own page may take to answer before the load counts
|
|
1092
|
+
/// as unreachable and the bundled copy stands in.
|
|
1093
|
+
var requestTimeoutSeconds: Double = 10
|
|
1094
|
+
/// Press-and-hold on a link shows its preview, as in Safari. Off unless
|
|
1095
|
+
/// asked for: no native app previews its own buttons.
|
|
1096
|
+
var allowsLinkPreview: Bool? = nil
|
|
1097
|
+
/// WebKit's bar above the keyboard (previous, next, Done). Hidden unless
|
|
1098
|
+
/// asked for; `craft.chrome.setKeyboardAccessory` changes it at runtime.
|
|
1099
|
+
var keyboardAccessory: Bool? = nil
|
|
1100
|
+
/// Pinch and double-tap zoom. Off unless set to false: an app's screens
|
|
1101
|
+
/// are laid out for the phone, and zooming one is a web page's tell.
|
|
1102
|
+
var disableZoom: Bool? = nil
|
|
1103
|
+
/// iOS's scheduled app refresh, which fires `craftBackgroundRefresh`.
|
|
1104
|
+
var backgroundRefresh: BackgroundRefresh? = nil
|
|
1105
|
+
/// Hybrid: app paths drawn natively, to the screen in `dist/native-screen.js`
|
|
1106
|
+
/// that draws each (`{"/m": "today"}`). Everything else is the web page.
|
|
1107
|
+
var nativeScreens: [String: String]? = nil
|
|
1108
|
+
/// The tab bar to show before the page describes its own.
|
|
1109
|
+
var tabs: [Tab]? = nil
|
|
1110
|
+
/// Hybrid: web-storage keys the page's own code writes, mirrored into the
|
|
1111
|
+
/// Keychain under another name for native screens (`{"auth_token": "auth.token"}`).
|
|
1112
|
+
var shareStorage: [String: String]? = nil
|
|
1113
|
+
|
|
1114
|
+
struct Tab: Codable {
|
|
1115
|
+
var id: String
|
|
1116
|
+
var title: String
|
|
1117
|
+
var symbol: String? = nil
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
struct BackgroundRefresh: Codable {
|
|
1121
|
+
var enabled: Bool = false
|
|
1122
|
+
/// The BGTaskScheduler identifier; `<bundleId>.refresh` if unset.
|
|
1123
|
+
var identifier: String? = nil
|
|
1124
|
+
/// The soonest iOS may run it again, in minutes (15 if unset).
|
|
1125
|
+
var minimumIntervalMinutes: Double? = nil
|
|
1126
|
+
}
|
|
714
1127
|
}
|
|
715
1128
|
|
|
716
1129
|
extension CraftConfig {
|
|
@@ -724,6 +1137,49 @@ extension CraftConfig {
|
|
|
724
1137
|
}
|
|
725
1138
|
}
|
|
726
1139
|
|
|
1140
|
+
/// What the web view adds to its user agent: WebKit's own token, then
|
|
1141
|
+
/// `Craft/<version>` and `<AppName>/<version>`, so the app's server can
|
|
1142
|
+
/// tell the app from Safari, and which build is asking.
|
|
1143
|
+
func applicationNameForUserAgent(after base: String?) -> String {
|
|
1144
|
+
let name = String(appName.unicodeScalars.filter { $0.isASCII && (CharacterSet.alphanumerics.contains($0) || "._-".unicodeScalars.contains($0)) })
|
|
1145
|
+
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
|
|
1146
|
+
return [base ?? "", "Craft/{{CRAFT_VERSION}}", "\(name.isEmpty ? "App" : name)/\(version)"]
|
|
1147
|
+
.filter { !$0.isEmpty }
|
|
1148
|
+
.joined(separator: " ")
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/// Pins the page's viewport at its own width: no pinch, no double-tap
|
|
1152
|
+
/// zoom. Installed at document start, so its DOMContentLoaded listener is
|
|
1153
|
+
/// the first: the tag is rewritten once the page's own is parsed, before
|
|
1154
|
+
/// any of the page's handlers read it.
|
|
1155
|
+
static let viewportScript = """
|
|
1156
|
+
(function() {
|
|
1157
|
+
function pin() {
|
|
1158
|
+
var meta = document.querySelector('meta[name="viewport"]');
|
|
1159
|
+
var content = meta ? (meta.getAttribute('content') || '') : 'width=device-width, initial-scale=1';
|
|
1160
|
+
content = content.split(',').map(function(part) { return part.trim(); }).filter(function(part) {
|
|
1161
|
+
return part && !/^(maximum-scale|minimum-scale|user-scalable)\\s*=/i.test(part);
|
|
1162
|
+
}).concat(['minimum-scale=1', 'maximum-scale=1', 'user-scalable=no']).join(', ');
|
|
1163
|
+
if (!meta) {
|
|
1164
|
+
meta = document.createElement('meta');
|
|
1165
|
+
meta.setAttribute('name', 'viewport');
|
|
1166
|
+
(document.head || document.documentElement).appendChild(meta);
|
|
1167
|
+
}
|
|
1168
|
+
meta.setAttribute('content', content);
|
|
1169
|
+
}
|
|
1170
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', pin);
|
|
1171
|
+
else pin();
|
|
1172
|
+
})();
|
|
1173
|
+
"""
|
|
1174
|
+
|
|
1175
|
+
/// A request for the app's own page, given up on after
|
|
1176
|
+
/// `requestTimeoutSeconds`. URLRequest's default is sixty seconds, and a
|
|
1177
|
+
/// first launch on a dead connection sat on the splash and then a blank
|
|
1178
|
+
/// page for that long before the offline page could show.
|
|
1179
|
+
func request(for url: URL) -> URLRequest {
|
|
1180
|
+
URLRequest(url: url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: max(1, requestTimeoutSeconds))
|
|
1181
|
+
}
|
|
1182
|
+
|
|
727
1183
|
/// The webview's background, resolved per trait so a Dark Mode switch
|
|
728
1184
|
/// repaints it without a reload.
|
|
729
1185
|
var resolvedBackgroundColor: UIColor {
|
|
@@ -742,6 +1198,13 @@ final class BundledAssetSchemeHandler: NSObject, WKURLSchemeHandler {
|
|
|
742
1198
|
super.init()
|
|
743
1199
|
}
|
|
744
1200
|
|
|
1201
|
+
/// The bundled `index.html` as text, for a web view that cannot navigate
|
|
1202
|
+
/// to `craft://app` (see `loadBundledPage`).
|
|
1203
|
+
static func bundledIndexHTML() -> String? {
|
|
1204
|
+
guard let root = findBundledRoot() else { return nil }
|
|
1205
|
+
return try? String(contentsOf: root.appendingPathComponent("index.html"), encoding: .utf8)
|
|
1206
|
+
}
|
|
1207
|
+
|
|
745
1208
|
private static func findBundledRoot() -> URL? {
|
|
746
1209
|
let bundle = Bundle.main
|
|
747
1210
|
let directCandidates = [
|
|
@@ -876,7 +1339,9 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
876
1339
|
let webConfig = WKWebViewConfiguration()
|
|
877
1340
|
webConfig.defaultWebpagePreferences.allowsContentJavaScript = true
|
|
878
1341
|
webConfig.allowsInlineMediaPlayback = true
|
|
1342
|
+
webConfig.allowsPictureInPictureMediaPlayback = true
|
|
879
1343
|
webConfig.mediaTypesRequiringUserActionForPlayback = []
|
|
1344
|
+
webConfig.applicationNameForUserAgent = config.applicationNameForUserAgent(after: webConfig.applicationNameForUserAgent)
|
|
880
1345
|
webConfig.setURLSchemeHandler(BundledAssetSchemeHandler(), forURLScheme: "craft")
|
|
881
1346
|
// The app's own domains (WKAppBoundDomains in Info.plist) get what an
|
|
882
1347
|
// iOS web view reserves for them, service workers among them, so a
|
|
@@ -890,26 +1355,47 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
890
1355
|
let contentController = WKUserContentController()
|
|
891
1356
|
contentController.add(context.coordinator, name: "craft")
|
|
892
1357
|
// 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.
|
|
1358
|
+
// they are the screen's, not a device API.
|
|
895
1359
|
let coordinator = context.coordinator
|
|
896
1360
|
contentController.add(CraftChromeRelay(trusts: { [weak coordinator] origin in coordinator?.trusts(origin) ?? false }), name: "craftChrome")
|
|
897
|
-
//
|
|
898
|
-
//
|
|
899
|
-
|
|
900
|
-
|
|
1361
|
+
// Hybrid apps: navigation to native screens, snapshots and shared
|
|
1362
|
+
// storage, outside the bridge's action dispatch like the chrome.
|
|
1363
|
+
if CraftHybrid.shared.isEnabled {
|
|
1364
|
+
contentController.add(CraftHybridRelay(trusts: { [weak coordinator] origin in coordinator?.trusts(origin) ?? false }), name: "craftHybrid")
|
|
1365
|
+
}
|
|
901
1366
|
#if DEBUG
|
|
902
1367
|
// The page's errors and console, in the device log, so a debug build
|
|
903
1368
|
// can be diagnosed from `log stream` or `simctl spawn … log show`
|
|
904
1369
|
// without attaching Safari. A separate handler, outside the bridge's
|
|
905
1370
|
// action dispatch. Never in a release build.
|
|
906
1371
|
contentController.add(PageConsoleRelay(), name: "craftLog")
|
|
907
|
-
contentController.addUserScript(WKUserScript(source: PageConsoleRelay.script, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
|
908
1372
|
#endif
|
|
1373
|
+
// The bridge and everything else the page gets from the first byte.
|
|
1374
|
+
coordinator.installUserScripts(into: contentController)
|
|
1375
|
+
webConfig.userContentController = contentController
|
|
909
1376
|
|
|
910
1377
|
let webView = WKWebView(frame: .zero, configuration: webConfig)
|
|
911
1378
|
webView.navigationDelegate = context.coordinator
|
|
1379
|
+
// alert(), confirm() and prompt() as the system's own alerts, and
|
|
1380
|
+
// target=_blank and window.open somewhere they can actually open.
|
|
1381
|
+
// Without a UI delegate WebKit drops all of them silently.
|
|
1382
|
+
webView.uiDelegate = context.coordinator
|
|
1383
|
+
// Now, not when the first page finishes loading. Everything the
|
|
1384
|
+
// coordinator sends the page goes through this reference, and a
|
|
1385
|
+
// network change, a push token or a location fix that arrived before
|
|
1386
|
+
// the first didFinish used to be dropped on a nil.
|
|
1387
|
+
coordinator.attach(webView)
|
|
912
1388
|
webView.isOpaque = false
|
|
1389
|
+
// The feel of an app rather than a page: no link previews on
|
|
1390
|
+
// press-and-hold, the keyboard follows a drag down as it does in
|
|
1391
|
+
// Messages, and the page stays at its own scale.
|
|
1392
|
+
webView.allowsLinkPreview = config.allowsLinkPreview ?? false
|
|
1393
|
+
webView.scrollView.keyboardDismissMode = .interactive
|
|
1394
|
+
if config.disableZoom != false {
|
|
1395
|
+
webView.scrollView.minimumZoomScale = 1
|
|
1396
|
+
webView.scrollView.maximumZoomScale = 1
|
|
1397
|
+
webView.scrollView.pinchGestureRecognizer?.isEnabled = false
|
|
1398
|
+
}
|
|
913
1399
|
// An edge swipe goes back (and forward) through the page's history,
|
|
914
1400
|
// pushState entries included, the way every iOS app's stack does.
|
|
915
1401
|
webView.allowsBackForwardNavigationGestures = config.swipeNavigation ?? false
|
|
@@ -928,17 +1414,23 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
928
1414
|
DeepLinkManager.shared.setWebView(webView)
|
|
929
1415
|
CraftEventManager.shared.setWebView(webView)
|
|
930
1416
|
CraftChrome.shared.webView = webView
|
|
1417
|
+
CraftHybrid.shared.webView = webView
|
|
1418
|
+
CraftChrome.shared.holdSplash(atMost: config.splashMaxSeconds)
|
|
931
1419
|
|
|
932
1420
|
// Parse background color
|
|
933
1421
|
let bgColor = config.resolvedBackgroundColor
|
|
934
1422
|
webView.backgroundColor = bgColor
|
|
935
1423
|
webView.scrollView.backgroundColor = bgColor
|
|
936
1424
|
|
|
937
|
-
// Load content
|
|
938
|
-
if
|
|
1425
|
+
// Load content: where the person was when the app was last put away,
|
|
1426
|
+
// if iOS has since ended it, or the app's start page.
|
|
1427
|
+
if coordinator.restoreInteractionState(into: webView) {
|
|
1428
|
+
// Restored: WebKit loads the page that was on screen, with its
|
|
1429
|
+
// history behind it for the back gesture.
|
|
1430
|
+
} else if let devURL = config.devServerURL, !devURL.isEmpty {
|
|
939
1431
|
// Development mode - connect to server
|
|
940
1432
|
if let url = URL(string: devURL) {
|
|
941
|
-
webView.load(
|
|
1433
|
+
webView.load(config.request(for: url))
|
|
942
1434
|
}
|
|
943
1435
|
} else if let bundledURL = URL(string: "craft://app/index.html") {
|
|
944
1436
|
// A route-aware local origin keeps root-relative STX assets and
|
|
@@ -978,6 +1470,25 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
978
1470
|
private var pendingCallbackId: String?
|
|
979
1471
|
private var pendingPushCallbackId: String?
|
|
980
1472
|
private var loadedBundledFallback = false
|
|
1473
|
+
/// Whether the current document's bridge has announced itself.
|
|
1474
|
+
private var documentReady = false
|
|
1475
|
+
/// Reloads after the content process dies, budgeted per burst.
|
|
1476
|
+
private var recoveryBudget = CraftRecoveryBudget()
|
|
1477
|
+
/// The content process died in the background; bring it back when the
|
|
1478
|
+
/// app is on screen again rather than spending the work unseen.
|
|
1479
|
+
private var recoverOnForeground = false
|
|
1480
|
+
/// The last page the app's own origin finished, for a recovery that
|
|
1481
|
+
/// finds the web view with no URL.
|
|
1482
|
+
private var lastPageURL: URL?
|
|
1483
|
+
/// When the app last went to the background, for `craftResume`.
|
|
1484
|
+
private var backgroundedAt: Date?
|
|
1485
|
+
/// Whether WebKit's bar above the keyboard shows (`keyboardAccessory`,
|
|
1486
|
+
/// then `craft.chrome.setKeyboardAccessory`).
|
|
1487
|
+
private lazy var keyboardAccessoryVisible = config.keyboardAccessory ?? false
|
|
1488
|
+
/// The native pull-to-refresh, when the page has asked for it.
|
|
1489
|
+
private lazy var refreshControl = CraftRefreshControl { [weak self] in
|
|
1490
|
+
self?.sendToWeb("craftRefresh", data: [:])
|
|
1491
|
+
}
|
|
981
1492
|
|
|
982
1493
|
/// The highest `cb_<n>` this process has seen the page hand out.
|
|
983
1494
|
///
|
|
@@ -1102,6 +1613,11 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1102
1613
|
NotificationCenter.default.addObserver(self, selector: #selector(receivePushToken(_:)), name: .craftPushToken, object: nil)
|
|
1103
1614
|
NotificationCenter.default.addObserver(self, selector: #selector(receivePushRegistrationError(_:)), name: .craftPushRegistrationError, object: nil)
|
|
1104
1615
|
NotificationCenter.default.addObserver(self, selector: #selector(appWillEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
|
|
1616
|
+
NotificationCenter.default.addObserver(self, selector: #selector(appDidEnterBackground(_:)), name: UIApplication.didEnterBackgroundNotification, object: nil)
|
|
1617
|
+
NotificationCenter.default.addObserver(self, selector: #selector(appDidReceiveMemoryWarning(_:)), name: UIApplication.didReceiveMemoryWarningNotification, object: nil)
|
|
1618
|
+
for name in [UIContentSizeCategory.didChangeNotification, UIAccessibility.reduceMotionStatusDidChangeNotification, UIAccessibility.reduceTransparencyStatusDidChangeNotification, .craftColorSchemeChanged] {
|
|
1619
|
+
NotificationCenter.default.addObserver(self, selector: #selector(appearanceChanged(_:)), name: name, object: nil)
|
|
1620
|
+
}
|
|
1105
1621
|
setupNetworkMonitoring()
|
|
1106
1622
|
}
|
|
1107
1623
|
|
|
@@ -1155,6 +1671,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1155
1671
|
networkMonitor?.start(queue: DispatchQueue.global())
|
|
1156
1672
|
}
|
|
1157
1673
|
|
|
1674
|
+
// CRAFT_IOS_PAGE_BRIDGE_DISPATCHER
|
|
1158
1675
|
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
1159
1676
|
let securityOrigin = message.frameInfo.securityOrigin
|
|
1160
1677
|
guard isTrustedOrigin(scheme: securityOrigin.protocol, host: securityOrigin.host, port: securityOrigin.port) else {
|
|
@@ -1164,6 +1681,13 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1164
1681
|
guard let body = message.body as? [String: Any],
|
|
1165
1682
|
let action = body["action"] as? String else { return }
|
|
1166
1683
|
|
|
1684
|
+
// The page's own signal that the bridge has announced itself, not
|
|
1685
|
+
// a device API: it is never offered to Zig and nothing answers it.
|
|
1686
|
+
if action == "__craftReady" {
|
|
1687
|
+
pageBecameReady()
|
|
1688
|
+
return
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1167
1691
|
let callbackId = body["callbackId"] as? String
|
|
1168
1692
|
// Before Zig is offered the call, because the seed has to cover
|
|
1169
1693
|
// every id the page drew, not only the ones Swift went on to serve.
|
|
@@ -1310,6 +1834,62 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1310
1834
|
} else {
|
|
1311
1835
|
rejectCallback(callbackId, error: "Settings URL is unavailable")
|
|
1312
1836
|
}
|
|
1837
|
+
// MARK: Native UI: dialogs, menus, the in-app browser, chrome.
|
|
1838
|
+
// None of these is gated: they are the system's own UI, and need
|
|
1839
|
+
// no permission or entitlement.
|
|
1840
|
+
case "dialogAlert":
|
|
1841
|
+
CraftDialogs.alert(body, from: webView) { [weak self] in self?.resolveCallback(callbackId, result: NSNull()) }
|
|
1842
|
+
case "dialogConfirm":
|
|
1843
|
+
CraftDialogs.confirm(body, from: webView) { [weak self] confirmed in self?.resolveCallback(callbackId, result: confirmed) }
|
|
1844
|
+
case "dialogActionSheet":
|
|
1845
|
+
CraftDialogs.actionSheet(body, from: webView) { [weak self] id in self?.resolveCallback(callbackId, result: id ?? NSNull()) }
|
|
1846
|
+
case "contextMenuShow":
|
|
1847
|
+
guard let webView else { resolveCallback(callbackId, result: NSNull()); return }
|
|
1848
|
+
CraftContextMenu.show(body, in: webView) { [weak self] id in self?.resolveCallback(callbackId, result: id ?? NSNull()) }
|
|
1849
|
+
case "browserOpen":
|
|
1850
|
+
guard let address = body["url"] as? String, let url = URL(string: address) else {
|
|
1851
|
+
rejectCallback(callbackId, error: "browser.open needs a URL it can parse", code: "INVALID_ARGUMENT")
|
|
1852
|
+
return
|
|
1853
|
+
}
|
|
1854
|
+
CraftBrowser.shared.open(url, mode: body["mode"] as? String ?? "safari", callbackScheme: body["callbackScheme"] as? String, from: webView) { [weak self] answer in
|
|
1855
|
+
self?.resolveCallback(callbackId, result: answer)
|
|
1856
|
+
}
|
|
1857
|
+
case "symbolImage":
|
|
1858
|
+
resolveCallback(callbackId, result: CraftSymbols.image(named: body["name"] as? String ?? "", options: body) ?? NSNull())
|
|
1859
|
+
case "statusBarSetStyle":
|
|
1860
|
+
resolveCallback(callbackId, result: CraftStatusBar.setStyle(body["style"] as? String ?? "default"))
|
|
1861
|
+
case "chromeSetUnderPageColor":
|
|
1862
|
+
// What shows past the page's edges when it rubber-bands.
|
|
1863
|
+
if #available(iOS 15.0, *), let webView, let css = body["color"] as? String, let color = UIColor(css: css) {
|
|
1864
|
+
webView.underPageBackgroundColor = color
|
|
1865
|
+
resolveCallback(callbackId, result: true)
|
|
1866
|
+
} else {
|
|
1867
|
+
resolveCallback(callbackId, result: false)
|
|
1868
|
+
}
|
|
1869
|
+
case "chromeSetKeyboardAccessory":
|
|
1870
|
+
let visible = body["visible"] as? Bool ?? true
|
|
1871
|
+
keyboardAccessoryVisible = visible
|
|
1872
|
+
resolveCallback(callbackId, result: webView.map { CraftKeyboardAccessory.setVisible(visible, in: $0) } ?? false)
|
|
1873
|
+
case "refreshEnable":
|
|
1874
|
+
guard let webView else { resolveCallback(callbackId, result: false); return }
|
|
1875
|
+
refreshControl.enable(on: webView.scrollView, tint: (body["tintColor"] as? String).flatMap { UIColor(css: $0) })
|
|
1876
|
+
resolveCallback(callbackId, result: true)
|
|
1877
|
+
case "refreshDisable":
|
|
1878
|
+
refreshControl.disable()
|
|
1879
|
+
resolveCallback(callbackId, result: true)
|
|
1880
|
+
case "refreshEnd":
|
|
1881
|
+
refreshControl.end()
|
|
1882
|
+
resolveCallback(callbackId, result: true)
|
|
1883
|
+
// The page has finished the work a craftSilentPush or
|
|
1884
|
+
// craftBackgroundRefresh asked for.
|
|
1885
|
+
case "backgroundComplete":
|
|
1886
|
+
CraftBackgroundWork.shared.complete(id: body["id"] as? String, ok: body["ok"] as? Bool ?? true)
|
|
1887
|
+
resolveCallback(callbackId, result: true)
|
|
1888
|
+
// The offline page's Retry button, and anything else that wants
|
|
1889
|
+
// the app's own page back after the bundled copy stood in.
|
|
1890
|
+
case "retryRemote":
|
|
1891
|
+
retryRemote()
|
|
1892
|
+
resolveCallback(callbackId, result: true)
|
|
1313
1893
|
case "log":
|
|
1314
1894
|
if let msg = body["message"] as? String {
|
|
1315
1895
|
print("[Craft Web] \(msg)")
|
|
@@ -1999,12 +2579,30 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1999
2579
|
DeepLinkManager.shared.setLoading()
|
|
2000
2580
|
}
|
|
2001
2581
|
|
|
2582
|
+
func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
|
|
2583
|
+
// A new document: the old one's ready no longer counts, and the
|
|
2584
|
+
// pull-to-refresh it asked for goes with it, or a pull would spin
|
|
2585
|
+
// waiting on a page that does not know it has one.
|
|
2586
|
+
documentReady = false
|
|
2587
|
+
refreshControl.disable()
|
|
2588
|
+
// Kept across documents and after the content process restarts.
|
|
2589
|
+
if !keyboardAccessoryVisible { CraftKeyboardAccessory.setVisible(false, in: webView) }
|
|
2590
|
+
if config.disableZoom != false { webView.scrollView.pinchGestureRecognizer?.isEnabled = false }
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2002
2593
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
2003
|
-
self.webView = webView
|
|
2004
2594
|
CraftChrome.shared.pageFinished()
|
|
2005
2595
|
CraftChrome.shared.publishLayout()
|
|
2006
2596
|
guard isTrustedURL(webView.url) else { return }
|
|
2007
|
-
|
|
2597
|
+
if !loadedBundledFallback { lastPageURL = webView.url }
|
|
2598
|
+
// The bridge is a document-start user script, so it is normally
|
|
2599
|
+
// here long before this. A document that came without it is given
|
|
2600
|
+
// it now, the way every page used to be, so no page is ever worse
|
|
2601
|
+
// off than before the user script.
|
|
2602
|
+
webView.evaluateJavaScript("!!(window.craft && window.craft._callbacks && window.craft.platform === 'ios')") { [weak self, weak webView] installed, _ in
|
|
2603
|
+
guard let self, let webView, (installed as? Bool) != true else { return }
|
|
2604
|
+
webView.evaluateJavaScript(self.bridgeScript(), completionHandler: nil)
|
|
2605
|
+
}
|
|
2008
2606
|
}
|
|
2009
2607
|
|
|
2010
2608
|
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
|
@@ -2012,7 +2610,20 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2012
2610
|
decisionHandler(.cancel)
|
|
2013
2611
|
return
|
|
2014
2612
|
}
|
|
2613
|
+
// A link that opens a new window (target=_blank) has no frame to
|
|
2614
|
+
// load into. It is allowed on, so WebKit asks the UI delegate's
|
|
2615
|
+
// `createWebViewWith`, which decides where it goes.
|
|
2616
|
+
if navigationAction.targetFrame == nil {
|
|
2617
|
+
decisionHandler(.allow)
|
|
2618
|
+
return
|
|
2619
|
+
}
|
|
2015
2620
|
if isTrustedURL(url) {
|
|
2621
|
+
// A new main-frame document gets the bridge seeded from the
|
|
2622
|
+
// ids handed out so far (#226), so the user scripts are
|
|
2623
|
+
// rebuilt before it exists rather than once per web view.
|
|
2624
|
+
if navigationAction.targetFrame?.isMainFrame == true {
|
|
2625
|
+
installUserScripts(into: webView.configuration.userContentController)
|
|
2626
|
+
}
|
|
2016
2627
|
decisionHandler(.allow)
|
|
2017
2628
|
return
|
|
2018
2629
|
}
|
|
@@ -2042,11 +2653,158 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2042
2653
|
}
|
|
2043
2654
|
}
|
|
2044
2655
|
|
|
2656
|
+
/// WebKit's content process died: out of memory, usually, often while
|
|
2657
|
+
/// the app was in the background. The view does not error; it goes
|
|
2658
|
+
/// white and stays white. It used to be reloaded on the spot, every
|
|
2659
|
+
/// time, into whatever crash killed it.
|
|
2660
|
+
///
|
|
2661
|
+
/// Now the reload waits until the app is on screen, shows the splash
|
|
2662
|
+
/// rather than a white page while the page rebuilds, comes back on
|
|
2663
|
+
/// the page that was showing, and is budgeted: past three crashes a
|
|
2664
|
+
/// minute (`CraftRecoveryBudget`) the offline page shows instead,
|
|
2665
|
+
/// whose Retry starts afresh.
|
|
2045
2666
|
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
|
2046
|
-
|
|
2667
|
+
documentReady = false
|
|
2668
|
+
CraftEventManager.shared.setLoading()
|
|
2669
|
+
DeepLinkManager.shared.setLoading()
|
|
2670
|
+
guard UIApplication.shared.applicationState != .background else {
|
|
2671
|
+
recoverOnForeground = true
|
|
2672
|
+
return
|
|
2673
|
+
}
|
|
2674
|
+
recoverFromTerminatedContent(webView)
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
private func recoverFromTerminatedContent(_ webView: WKWebView) {
|
|
2678
|
+
recoverOnForeground = false
|
|
2679
|
+
switch recoveryBudget.onCrash(at: ProcessInfo.processInfo.systemUptime) {
|
|
2680
|
+
case .reload:
|
|
2681
|
+
// Not over a native screen in a hybrid app: the page rebuilds
|
|
2682
|
+
// out of sight there.
|
|
2683
|
+
if CraftHybrid.shared.webIsVisible {
|
|
2684
|
+
CraftChrome.shared.showSplash(atMost: config.splashMaxSeconds)
|
|
2685
|
+
}
|
|
2686
|
+
installUserScripts(into: webView.configuration.userContentController)
|
|
2687
|
+
if webView.url == nil, let lastURL = lastPageURL {
|
|
2688
|
+
webView.load(config.request(for: lastURL))
|
|
2689
|
+
} else {
|
|
2690
|
+
// The current history item, pushState entries included,
|
|
2691
|
+
// with the back stack still behind it.
|
|
2692
|
+
webView.reload()
|
|
2693
|
+
}
|
|
2694
|
+
case .giveUp:
|
|
2695
|
+
print("Craft: the page's content process died \(recoveryBudget.attempts) times in a minute; showing the offline page")
|
|
2696
|
+
if CraftHybrid.shared.webIsVisible { CraftChrome.shared.hideSplash() }
|
|
2697
|
+
showOfflinePage(in: webView)
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
/// The bundled page (`dist/index.html`), which for an app whose page
|
|
2702
|
+
/// is remote is the offline page with its Retry.
|
|
2703
|
+
private func showOfflinePage(in webView: WKWebView) {
|
|
2704
|
+
if config.devServerURL != nil { loadedBundledFallback = true }
|
|
2705
|
+
loadBundledPage(in: webView)
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
/// Whether Info.plist declares WKAppBoundDomains.
|
|
2709
|
+
private static let declaresAppBoundDomains: Bool = {
|
|
2710
|
+
(Bundle.main.object(forInfoDictionaryKey: "WKAppBoundDomains") as? [String])?.isEmpty == false
|
|
2711
|
+
}()
|
|
2712
|
+
|
|
2713
|
+
/// The bundled page, in place of the app's remote one.
|
|
2714
|
+
///
|
|
2715
|
+
/// An app that declares app-bound domains cannot navigate from one of
|
|
2716
|
+
/// them to `craft://app`: WebKit ignores the load ("attempting to
|
|
2717
|
+
/// navigate away from an app-bound domain or navigate after using
|
|
2718
|
+
/// restricted APIs"), whether or not navigations are limited to them,
|
|
2719
|
+
/// because the bridge's scripts and message handlers count as those
|
|
2720
|
+
/// APIs. The fallback then left a blank page where the offline page
|
|
2721
|
+
/// should have been. So such an app gets the bundled index as a page
|
|
2722
|
+
/// of the remote origin itself (`loadHTMLString` with the remote as
|
|
2723
|
+
/// its base URL): an app-bound navigation, with the bridge, and a
|
|
2724
|
+
/// Retry the message handlers trust. It must be self-contained, as the
|
|
2725
|
+
/// generated offline page is; an app that bundles a whole site as its
|
|
2726
|
+
/// fallback and declares app-bound domains gets only its index.
|
|
2727
|
+
private func loadBundledPage(in webView: WKWebView) {
|
|
2728
|
+
if Self.declaresAppBoundDomains,
|
|
2729
|
+
let remote = config.devServerURL.flatMap(URL.init(string:)),
|
|
2730
|
+
let html = BundledAssetSchemeHandler.bundledIndexHTML() {
|
|
2731
|
+
webView.loadHTMLString(html, baseURL: remote)
|
|
2732
|
+
return
|
|
2733
|
+
}
|
|
2734
|
+
guard let bundledURL = URL(string: "craft://app/index.html") else { return }
|
|
2735
|
+
webView.load(URLRequest(url: bundledURL))
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
// MARK: - Lifecycle
|
|
2739
|
+
|
|
2740
|
+
@objc private func appDidEnterBackground(_ notification: Notification) {
|
|
2741
|
+
backgroundedAt = Date()
|
|
2742
|
+
saveInteractionState()
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
@objc private func appDidReceiveMemoryWarning(_ notification: Notification) {
|
|
2746
|
+
sendToWeb("craftMemoryWarning", data: [:])
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
/// Where the person was, kept so a launch after iOS ended the app in
|
|
2750
|
+
/// the background comes back to the same screen, as native apps do.
|
|
2751
|
+
/// WKWebView's `interactionState` (iOS 15) is its back-forward list
|
|
2752
|
+
/// with the current page and scroll position, as opaque data.
|
|
2753
|
+
///
|
|
2754
|
+
/// Not saved for the offline page or anything outside the app's own
|
|
2755
|
+
/// origins, and only restored into the same build: a new build may
|
|
2756
|
+
/// have moved the screen it pointed at.
|
|
2757
|
+
private static var interactionStateFile: URL? {
|
|
2758
|
+
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
|
|
2759
|
+
.appendingPathComponent("craft-interaction-state.data")
|
|
2760
|
+
}
|
|
2761
|
+
private static let interactionStateBuildKey = "craft.interactionState.build"
|
|
2762
|
+
|
|
2763
|
+
private static var currentBuild: String {
|
|
2764
|
+
let info = Bundle.main.infoDictionary
|
|
2765
|
+
return "\(info?["CFBundleShortVersionString"] as? String ?? "")+\(info?["CFBundleVersion"] as? String ?? "")"
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
private func saveInteractionState() {
|
|
2769
|
+
guard #available(iOS 15.0, *), let webView, let file = Self.interactionStateFile else { return }
|
|
2770
|
+
guard isTrustedURL(webView.url), !loadedBundledFallback,
|
|
2771
|
+
let state = webView.interactionState as? Data else {
|
|
2772
|
+
try? FileManager.default.removeItem(at: file)
|
|
2773
|
+
return
|
|
2774
|
+
}
|
|
2775
|
+
do {
|
|
2776
|
+
try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
2777
|
+
try state.write(to: file, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
|
|
2778
|
+
UserDefaults.standard.set(Self.currentBuild, forKey: Self.interactionStateBuildKey)
|
|
2779
|
+
} catch {
|
|
2780
|
+
print("Craft: could not keep the page's place: \(error)")
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
/// True when a saved place was handed to the web view to load.
|
|
2785
|
+
func restoreInteractionState(into webView: WKWebView) -> Bool {
|
|
2786
|
+
// A hybrid app that opens on a native screen loads the page at
|
|
2787
|
+
// that screen's path, never where the page was left: the native
|
|
2788
|
+
// screen is the start.
|
|
2789
|
+
guard !CraftHybrid.shared.startsNative else { return false }
|
|
2790
|
+
guard #available(iOS 15.0, *), let file = Self.interactionStateFile,
|
|
2791
|
+
UserDefaults.standard.string(forKey: Self.interactionStateBuildKey) == Self.currentBuild,
|
|
2792
|
+
let state = try? Data(contentsOf: file) else { return false }
|
|
2793
|
+
// Used once: if the restored page is what brings the app down,
|
|
2794
|
+
// the next launch starts clean rather than looping on it.
|
|
2795
|
+
try? FileManager.default.removeItem(at: file)
|
|
2796
|
+
webView.interactionState = state
|
|
2797
|
+
return true
|
|
2047
2798
|
}
|
|
2048
2799
|
|
|
2049
2800
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
|
2801
|
+
// The navigation never replaced the page, so the page that was
|
|
2802
|
+
// ready still is. Without this, events stayed queued for the rest
|
|
2803
|
+
// of its life after one cancelled tap.
|
|
2804
|
+
if documentReady {
|
|
2805
|
+
DeepLinkManager.shared.setReady()
|
|
2806
|
+
CraftEventManager.shared.setReady()
|
|
2807
|
+
}
|
|
2050
2808
|
fallBackIfUnreachable(webView, after: error)
|
|
2051
2809
|
}
|
|
2052
2810
|
|
|
@@ -2062,14 +2820,9 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2062
2820
|
}
|
|
2063
2821
|
|
|
2064
2822
|
private func loadBundledFallback(in webView: WKWebView) {
|
|
2065
|
-
guard !loadedBundledFallback,
|
|
2066
|
-
config.devServerURL != nil,
|
|
2067
|
-
let bundledURL = URL(string: "craft://app/index.html") else { return }
|
|
2823
|
+
guard !loadedBundledFallback, config.devServerURL != nil else { return }
|
|
2068
2824
|
loadedBundledFallback = true
|
|
2069
|
-
|
|
2070
|
-
// its own to reload.
|
|
2071
|
-
self.webView = webView
|
|
2072
|
-
webView.load(URLRequest(url: bundledURL))
|
|
2825
|
+
loadBundledPage(in: webView)
|
|
2073
2826
|
}
|
|
2074
2827
|
|
|
2075
2828
|
/// Leave the bundled copy for the remote origin again (#252).
|
|
@@ -2088,14 +2841,53 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2088
2841
|
let remote = config.devServerURL.flatMap(URL.init(string:)) else { return }
|
|
2089
2842
|
loadedBundledFallback = false
|
|
2090
2843
|
print("Craft: \(reason); loading \(remote) again instead of the bundled copy")
|
|
2091
|
-
webView.load(
|
|
2844
|
+
webView.load(config.request(for: remote))
|
|
2092
2845
|
}
|
|
2093
2846
|
|
|
2094
2847
|
@objc private func appWillEnterForeground(_ notification: Notification) {
|
|
2848
|
+
if recoverOnForeground, let webView {
|
|
2849
|
+
// The page is about to be rebuilt; there is no one to resume.
|
|
2850
|
+
backgroundedAt = nil
|
|
2851
|
+
recoverFromTerminatedContent(webView)
|
|
2852
|
+
return
|
|
2853
|
+
}
|
|
2854
|
+
if let backgroundedAt {
|
|
2855
|
+
self.backgroundedAt = nil
|
|
2856
|
+
sendToWeb("craftResume", data: ["backgroundedMs": Int(Date().timeIntervalSince(backgroundedAt) * 1000)])
|
|
2857
|
+
}
|
|
2095
2858
|
returnFromBundledFallback(because: "the app returned to the foreground")
|
|
2096
2859
|
}
|
|
2097
2860
|
|
|
2098
|
-
|
|
2861
|
+
/// Load the app's own page again, now, because someone asked.
|
|
2862
|
+
///
|
|
2863
|
+
/// Unlike `returnFromBundledFallback` it does not wait for a trigger
|
|
2864
|
+
/// and works from any page. If the remote is still out of reach the
|
|
2865
|
+
/// load fails as unreachable and the bundled copy comes back.
|
|
2866
|
+
private func retryRemote() {
|
|
2867
|
+
guard let webView else { return }
|
|
2868
|
+
recoveryBudget.reset()
|
|
2869
|
+
guard let remote = config.devServerURL.flatMap(URL.init(string:)) else {
|
|
2870
|
+
webView.reload()
|
|
2871
|
+
return
|
|
2872
|
+
}
|
|
2873
|
+
loadedBundledFallback = false
|
|
2874
|
+
webView.load(config.request(for: remote))
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
/// Load `path` on the app's own origin afresh: for a hybrid app whose
|
|
2878
|
+
/// page cannot navigate itself (still loading, or the offline page).
|
|
2879
|
+
func loadAppPath(_ path: String) {
|
|
2880
|
+
guard let webView else { return }
|
|
2881
|
+
guard let remote = config.devServerURL.flatMap(URL.init(string:)),
|
|
2882
|
+
let url = URL(string: path, relativeTo: remote)?.absoluteURL else {
|
|
2883
|
+
if let bundled = URL(string: "craft://app\(path)") { webView.load(URLRequest(url: bundled)) }
|
|
2884
|
+
return
|
|
2885
|
+
}
|
|
2886
|
+
loadedBundledFallback = false
|
|
2887
|
+
webView.load(config.request(for: url))
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
fileprivate func isTrustedURL(_ url: URL?) -> Bool {
|
|
2099
2891
|
guard let url = url else { return false }
|
|
2100
2892
|
return isTrustedOrigin(scheme: url.scheme ?? "", host: url.host ?? "", port: url.port ?? 0)
|
|
2101
2893
|
}
|
|
@@ -2151,7 +2943,94 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2151
2943
|
if drawn > highestCallbackId { highestCallbackId = drawn }
|
|
2152
2944
|
}
|
|
2153
2945
|
|
|
2154
|
-
|
|
2946
|
+
/// Hand the coordinator the web view it serves, as soon as it exists.
|
|
2947
|
+
func attach(_ webView: WKWebView) {
|
|
2948
|
+
self.webView = webView
|
|
2949
|
+
if !keyboardAccessoryVisible { CraftKeyboardAccessory.setVisible(false, in: webView) }
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
/// Every script the page gets before its own first byte runs, in order.
|
|
2953
|
+
///
|
|
2954
|
+
/// The bridge used to be evaluated in `didFinish`: after every image,
|
|
2955
|
+
/// font and script of the page had loaded, on every navigation, as
|
|
2956
|
+
/// some 1800 lines of fresh source each time. A page could not reach
|
|
2957
|
+
/// `window.craft` while it was starting, which is when it most wants
|
|
2958
|
+
/// to, and everything native tried to tell it before then was lost.
|
|
2959
|
+
/// As document-start user scripts it exists before any of the page's
|
|
2960
|
+
/// own code, and WebKit installs it for each document itself.
|
|
2961
|
+
///
|
|
2962
|
+
/// Called at creation and again before each main-frame navigation,
|
|
2963
|
+
/// because the bridge carries the callback seed (#226) and a seed
|
|
2964
|
+
/// fixed at creation would repeat ids across reloads.
|
|
2965
|
+
func installUserScripts(into controller: WKUserContentController) {
|
|
2966
|
+
controller.removeAllUserScripts()
|
|
2967
|
+
// From the first byte, so the page's CSS knows before it paints
|
|
2968
|
+
// that the shell draws the tab bar, and does not draw its own for
|
|
2969
|
+
// a frame.
|
|
2970
|
+
controller.addUserScript(WKUserScript(source: "document.documentElement.setAttribute('data-craft-chrome','ios')", injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
|
2971
|
+
#if DEBUG
|
|
2972
|
+
controller.addUserScript(WKUserScript(source: PageConsoleRelay.script, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
|
2973
|
+
#endif
|
|
2974
|
+
controller.addUserScript(WKUserScript(source: CraftChrome.paintScript, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
|
2975
|
+
controller.addUserScript(WKUserScript(source: CraftPageAppearance.script(appearanceSnapshot(), announce: false), injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
|
2976
|
+
if config.disableZoom != false {
|
|
2977
|
+
controller.addUserScript(WKUserScript(source: CraftConfig.viewportScript, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
|
2978
|
+
}
|
|
2979
|
+
controller.addUserScript(WKUserScript(source: bridgeScript(), injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
|
2980
|
+
if CraftHybrid.shared.isEnabled {
|
|
2981
|
+
controller.addUserScript(WKUserScript(source: CraftHybrid.shared.pageScript(), injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
private func appearanceSnapshot() -> [String: Any] {
|
|
2986
|
+
CraftPageAppearance.snapshot(style: webView?.traitCollection.userInterfaceStyle ?? UITraitCollection.current.userInterfaceStyle)
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
/// Text size, Reduce Motion, Reduce Transparency or Light/Dark changed:
|
|
2990
|
+
/// the page gets the new values and a `craftAppearance` event.
|
|
2991
|
+
@objc private func appearanceChanged(_ notification: Notification) {
|
|
2992
|
+
DispatchQueue.main.async { [weak self] in
|
|
2993
|
+
guard let self, let webView = self.webView else { return }
|
|
2994
|
+
webView.evaluateJavaScript(CraftPageAppearance.script(self.appearanceSnapshot(), announce: true), completionHandler: nil)
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
/// The page announced the bridge (`craftReady`): from here it can be
|
|
2999
|
+
/// told things. Whatever arrived while it could not be is delivered now.
|
|
3000
|
+
private func pageBecameReady() {
|
|
3001
|
+
documentReady = true
|
|
3002
|
+
DeepLinkManager.shared.setReady()
|
|
3003
|
+
CraftEventManager.shared.setReady()
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
/// The origins the bridge script installs itself in, as the page
|
|
3007
|
+
/// spells them (`location.protocol + '//' + location.host`). The same
|
|
3008
|
+
/// set `isTrustedOrigin` answers for; the message handlers still check
|
|
3009
|
+
/// every message, so this only keeps the bridge out of pages that
|
|
3010
|
+
/// could not use it.
|
|
3011
|
+
private func trustedPageOrigins() -> [String] {
|
|
3012
|
+
var origins = config.trustedOrigins
|
|
3013
|
+
if let devServerURL = config.devServerURL,
|
|
3014
|
+
let components = URLComponents(string: devServerURL),
|
|
3015
|
+
let host = components.host,
|
|
3016
|
+
let scheme = components.scheme {
|
|
3017
|
+
let defaultPort = scheme == "https" ? 443 : 80
|
|
3018
|
+
let port = components.port ?? defaultPort
|
|
3019
|
+
origins.append("\(scheme)://\(host)\(port == defaultPort ? "" : ":\(port)")")
|
|
3020
|
+
}
|
|
3021
|
+
return origins.compactMap { origin in
|
|
3022
|
+
guard let url = URL(string: origin), let scheme = url.scheme, let host = url.host else { return nil }
|
|
3023
|
+
guard scheme == "https" || (scheme == "http" && ["localhost", "127.0.0.1", "::1"].contains(host)) else { return nil }
|
|
3024
|
+
let defaultPort = scheme == "https" ? 443 : 80
|
|
3025
|
+
let port = url.port ?? defaultPort
|
|
3026
|
+
let shownHost = host.contains(":") ? "[\(host)]" : host
|
|
3027
|
+
return "\(scheme)://\(shownHost)\(port == defaultPort ? "" : ":\(port)")"
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
3030
|
+
|
|
3031
|
+
/// The bridge, as the source the page runs. Only in a trusted page:
|
|
3032
|
+
/// the check is the first thing it does.
|
|
3033
|
+
func bridgeScript() -> String {
|
|
2155
3034
|
let laContext = LAContext()
|
|
2156
3035
|
var biometricAvailable = false
|
|
2157
3036
|
if laContext.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) {
|
|
@@ -3698,7 +4577,12 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3698
4577
|
var personFacing = {
|
|
3699
4578
|
requestHealthAuthorization: true,
|
|
3700
4579
|
requestPermission: true,
|
|
3701
|
-
signInWithApple: true
|
|
4580
|
+
signInWithApple: true,
|
|
4581
|
+
dialogAlert: true,
|
|
4582
|
+
dialogConfirm: true,
|
|
4583
|
+
dialogActionSheet: true,
|
|
4584
|
+
contextMenuShow: true,
|
|
4585
|
+
browserOpen: true
|
|
3702
4586
|
};
|
|
3703
4587
|
craft._invoke = function(action, payload) {
|
|
3704
4588
|
var self = craft;
|
|
@@ -3733,12 +4617,19 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3733
4617
|
throw error;
|
|
3734
4618
|
});
|
|
3735
4619
|
}
|
|
4620
|
+
// Each kind reaches the generator UIKit has for it: a
|
|
4621
|
+
// notification plays the success, warning or error pattern
|
|
4622
|
+
// and a selection the picker's detent. Both used to be
|
|
4623
|
+
// impacts of some weight, which is not what either feels like.
|
|
3736
4624
|
craft.haptics = {
|
|
3737
4625
|
impact: function(style) { return hapticFeedback(craft.haptic(style || 'medium')); },
|
|
3738
4626
|
notification: function(type) {
|
|
3739
|
-
return hapticFeedback(craft.haptic(type === 'error'
|
|
4627
|
+
return hapticFeedback(craft.haptic(type === 'error' || type === 'warning' ? type : 'success'));
|
|
3740
4628
|
},
|
|
3741
|
-
selection: function() { return hapticFeedback(craft.haptic('
|
|
4629
|
+
selection: function() { return hapticFeedback(craft.haptic('selection')); },
|
|
4630
|
+
// Wakes the engine ahead of a haptic the page knows is
|
|
4631
|
+
// coming, so it plays on the frame it is asked for.
|
|
4632
|
+
prepare: function(kind) { return hapticFeedback(craft._invoke('hapticPrepare', {kind: kind || null})); },
|
|
3742
4633
|
vibrate: function(pattern) { return hapticFeedback(craft.vibrate(pattern || [])); }
|
|
3743
4634
|
};
|
|
3744
4635
|
craft.permissions = {
|
|
@@ -3910,6 +4801,146 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3910
4801
|
}
|
|
3911
4802
|
})(window.craft);
|
|
3912
4803
|
|
|
4804
|
+
// The system's own UI, driven by the page: dialogs, a menu at a
|
|
4805
|
+
// point, the in-app browser and web sign-in, SF Symbols, and the
|
|
4806
|
+
// chrome around the page (status bar, under-page colour, keyboard
|
|
4807
|
+
// bar, pull to refresh).
|
|
4808
|
+
//
|
|
4809
|
+
// Every call settles, and none throws: a failure answers with
|
|
4810
|
+
// what "nothing happened" means for that call (null, false, a
|
|
4811
|
+
// cancelled browser), the way the same calls answer in a browser.
|
|
4812
|
+
(function installNativeUI(craft) {
|
|
4813
|
+
function settle(answer, fallback) {
|
|
4814
|
+
return answer.then(function(value) {
|
|
4815
|
+
return value === undefined || value === null ? fallback : value;
|
|
4816
|
+
}, function(error) {
|
|
4817
|
+
if (craft._debug) console.warn('[Craft]', error && error.message);
|
|
4818
|
+
return fallback;
|
|
4819
|
+
});
|
|
4820
|
+
}
|
|
4821
|
+
function options(value) {
|
|
4822
|
+
return typeof value === 'string' ? {title: value} : Object.assign({}, value || {});
|
|
4823
|
+
}
|
|
4824
|
+
// A rect, or an element (or anything with
|
|
4825
|
+
// getBoundingClientRect), in the viewport's CSS pixels.
|
|
4826
|
+
function rect(anchor) {
|
|
4827
|
+
if (!anchor) return null;
|
|
4828
|
+
if (typeof anchor.getBoundingClientRect === 'function') anchor = anchor.getBoundingClientRect();
|
|
4829
|
+
var x = Number(anchor.x != null ? anchor.x : anchor.left);
|
|
4830
|
+
var y = Number(anchor.y != null ? anchor.y : anchor.top);
|
|
4831
|
+
if (!isFinite(x) || !isFinite(y)) return null;
|
|
4832
|
+
return {x: x, y: y, width: Number(anchor.width) || 0, height: Number(anchor.height) || 0};
|
|
4833
|
+
}
|
|
4834
|
+
craft.dialog = {
|
|
4835
|
+
alert: function(value) {
|
|
4836
|
+
return settle(craft._invoke('dialogAlert', options(value)), null).then(function() {});
|
|
4837
|
+
},
|
|
4838
|
+
confirm: function(value) {
|
|
4839
|
+
return settle(craft._invoke('dialogConfirm', options(value)), false).then(function(confirmed) { return confirmed === true; });
|
|
4840
|
+
},
|
|
4841
|
+
actionSheet: function(value) {
|
|
4842
|
+
var payload = options(value);
|
|
4843
|
+
payload.anchor = rect(payload.anchor);
|
|
4844
|
+
payload.actions = (payload.actions || []).map(function(action) {
|
|
4845
|
+
return {id: String(action.id), title: String(action.title), style: action.style || 'default'};
|
|
4846
|
+
});
|
|
4847
|
+
return settle(craft._invoke('dialogActionSheet', payload), null);
|
|
4848
|
+
}
|
|
4849
|
+
};
|
|
4850
|
+
craft.contextMenu = {
|
|
4851
|
+
show: function(value) {
|
|
4852
|
+
var payload = Object.assign({}, value || {});
|
|
4853
|
+
payload.anchor = rect(payload.anchor);
|
|
4854
|
+
payload.items = (payload.items || []).map(function(item) {
|
|
4855
|
+
return {id: String(item.id), title: String(item.title), symbol: item.symbol || null, destructive: item.destructive === true, disabled: item.disabled === true};
|
|
4856
|
+
});
|
|
4857
|
+
return settle(craft._invoke('contextMenuShow', payload), null);
|
|
4858
|
+
}
|
|
4859
|
+
};
|
|
4860
|
+
craft.browser = {
|
|
4861
|
+
open: function(url, value) {
|
|
4862
|
+
value = value || {};
|
|
4863
|
+
return settle(craft._invoke('browserOpen', {
|
|
4864
|
+
url: String(url),
|
|
4865
|
+
mode: value.mode === 'auth' ? 'auth' : 'safari',
|
|
4866
|
+
callbackScheme: value.callbackScheme || null
|
|
4867
|
+
}), {cancelled: true});
|
|
4868
|
+
}
|
|
4869
|
+
};
|
|
4870
|
+
// The same symbol at the same size is drawn once per page.
|
|
4871
|
+
var symbolCache = {};
|
|
4872
|
+
craft.symbols = {
|
|
4873
|
+
image: function(name, value) {
|
|
4874
|
+
value = value || {};
|
|
4875
|
+
var payload = {name: String(name), pointSize: value.pointSize, weight: value.weight, color: value.color, scale: value.scale};
|
|
4876
|
+
var key = JSON.stringify(payload);
|
|
4877
|
+
if (!symbolCache[key]) {
|
|
4878
|
+
symbolCache[key] = settle(craft._invoke('symbolImage', payload), null).then(function(image) {
|
|
4879
|
+
if (image === null) delete symbolCache[key];
|
|
4880
|
+
return image;
|
|
4881
|
+
});
|
|
4882
|
+
}
|
|
4883
|
+
return symbolCache[key];
|
|
4884
|
+
}
|
|
4885
|
+
};
|
|
4886
|
+
craft.statusBar = {
|
|
4887
|
+
setStyle: function(style) {
|
|
4888
|
+
return settle(craft._invoke('statusBarSetStyle', {style: style === 'light' || style === 'dark' ? style : 'default'}), false);
|
|
4889
|
+
}
|
|
4890
|
+
};
|
|
4891
|
+
craft.chrome = {
|
|
4892
|
+
setUnderPageColor: function(color) {
|
|
4893
|
+
return settle(craft._invoke('chromeSetUnderPageColor', {color: String(color)}), false);
|
|
4894
|
+
},
|
|
4895
|
+
setKeyboardAccessory: function(visible) {
|
|
4896
|
+
return settle(craft._invoke('chromeSetKeyboardAccessory', {visible: visible !== false}), false);
|
|
4897
|
+
}
|
|
4898
|
+
};
|
|
4899
|
+
// Text size, Reduce Motion, Reduce Transparency and Light or
|
|
4900
|
+
// Dark, as the shell last reported them.
|
|
4901
|
+
Object.defineProperty(craft, 'appearance', {
|
|
4902
|
+
configurable: true,
|
|
4903
|
+
enumerable: true,
|
|
4904
|
+
get: function() { return window.__craftAppearance || null; }
|
|
4905
|
+
});
|
|
4906
|
+
// Background time: answer a craftSilentPush or a
|
|
4907
|
+
// craftBackgroundRefresh once its work is done, with the
|
|
4908
|
+
// event's id, or with none for everything pending.
|
|
4909
|
+
craft.background = {
|
|
4910
|
+
complete: function(ok, id) {
|
|
4911
|
+
return settle(craft._invoke('backgroundComplete', {ok: ok !== false, id: id || null}), false);
|
|
4912
|
+
},
|
|
4913
|
+
onRefresh: function(callback) {
|
|
4914
|
+
var listener = function(e) { callback(e.detail || {}); };
|
|
4915
|
+
window.addEventListener('craftBackgroundRefresh', listener);
|
|
4916
|
+
return function() { window.removeEventListener('craftBackgroundRefresh', listener); };
|
|
4917
|
+
},
|
|
4918
|
+
onSilentPush: function(callback) {
|
|
4919
|
+
var listener = function(e) { callback(e.detail || {}); };
|
|
4920
|
+
window.addEventListener('craftSilentPush', listener);
|
|
4921
|
+
return function() { window.removeEventListener('craftSilentPush', listener); };
|
|
4922
|
+
}
|
|
4923
|
+
};
|
|
4924
|
+
craft.onAppearanceChange = function(callback) {
|
|
4925
|
+
var listener = function(e) { callback(e.detail); };
|
|
4926
|
+
window.addEventListener('craftAppearance', listener);
|
|
4927
|
+
return function() { window.removeEventListener('craftAppearance', listener); };
|
|
4928
|
+
};
|
|
4929
|
+
craft.refresh = {
|
|
4930
|
+
enable: function(value) {
|
|
4931
|
+
return settle(craft._invoke('refreshEnable', {tintColor: (value && value.tintColor) || null}), false);
|
|
4932
|
+
},
|
|
4933
|
+
disable: function() { return settle(craft._invoke('refreshDisable'), false); },
|
|
4934
|
+
end: function() { return settle(craft._invoke('refreshEnd'), false); },
|
|
4935
|
+
// A pull of the native control; returns the unsubscribe.
|
|
4936
|
+
onRefresh: function(callback) {
|
|
4937
|
+
var listener = function() { callback(); };
|
|
4938
|
+
window.addEventListener('craftRefresh', listener);
|
|
4939
|
+
return function() { window.removeEventListener('craftRefresh', listener); };
|
|
4940
|
+
}
|
|
4941
|
+
};
|
|
4942
|
+
})(window.craft);
|
|
4943
|
+
|
|
3913
4944
|
// The reply route for actions the Zig runtime serves.
|
|
3914
4945
|
//
|
|
3915
4946
|
// Zig owns one wire format and calls these two functions by name;
|
|
@@ -3931,15 +4962,39 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3931
4962
|
window.craft._rejectCallback('cb_' + ctx.id, ctx.message, ctx.code);
|
|
3932
4963
|
};
|
|
3933
4964
|
|
|
3934
|
-
//
|
|
3935
|
-
|
|
3936
|
-
|
|
4965
|
+
// craftReady, once per document, at the first moment a page can
|
|
4966
|
+
// be listening for it.
|
|
4967
|
+
//
|
|
4968
|
+
// The bridge itself exists from the document's first byte, before
|
|
4969
|
+
// any of the page's scripts, so a page that checks window.craft
|
|
4970
|
+
// finds it at once. An event fired then would reach nobody, and
|
|
4971
|
+
// pages written for the old injection only listen. So it fires
|
|
4972
|
+
// when the document has been parsed: every parser-inserted
|
|
4973
|
+
// script, module and deferred script has run by then and could
|
|
4974
|
+
// have subscribed, and it is still well before the load event
|
|
4975
|
+
// (images and all) it used to wait for. Native hears of it here
|
|
4976
|
+
// too, and only then delivers what it was holding.
|
|
4977
|
+
(function announceReady(craft) {
|
|
4978
|
+
function ready() {
|
|
4979
|
+
if (craft.ready) return;
|
|
4980
|
+
craft.ready = true;
|
|
4981
|
+
window.dispatchEvent(new CustomEvent('craftReady', {detail: craft}));
|
|
4982
|
+
// The appearance the document started with, for a page
|
|
4983
|
+
// that subscribes rather than reading craft.appearance.
|
|
4984
|
+
if (window.__craftAppearance) window.dispatchEvent(new CustomEvent('craftAppearance', {detail: window.__craftAppearance}));
|
|
4985
|
+
try { window.webkit.messageHandlers.craft.postMessage({action: '__craftReady'}); } catch (e) {}
|
|
4986
|
+
}
|
|
4987
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ready);
|
|
4988
|
+
else ready();
|
|
4989
|
+
})(window.craft);
|
|
4990
|
+
"""
|
|
4991
|
+
let trusted = (try? JSONSerialization.data(withJSONObject: trustedPageOrigins()))
|
|
4992
|
+
.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
|
|
4993
|
+
return """
|
|
4994
|
+
if ((location.protocol === 'craft:' && location.host === 'app') || location.protocol === 'file:' || \(trusted).indexOf(location.protocol + '//' + location.host) !== -1) {
|
|
4995
|
+
\(script)
|
|
4996
|
+
}
|
|
3937
4997
|
"""
|
|
3938
|
-
webView?.evaluateJavaScript(script)
|
|
3939
|
-
|
|
3940
|
-
// Mark DeepLinkManager as ready
|
|
3941
|
-
DeepLinkManager.shared.setReady()
|
|
3942
|
-
CraftEventManager.shared.setReady()
|
|
3943
4998
|
}
|
|
3944
4999
|
|
|
3945
5000
|
// MARK: - Callback Helpers
|
|
@@ -4071,8 +5126,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
4071
5126
|
|
|
4072
5127
|
// MARK: - Share
|
|
4073
5128
|
private func share(options: [String: Any], callbackId: String?) {
|
|
4074
|
-
guard
|
|
4075
|
-
let rootVC = windowScene.windows.first?.rootViewController else {
|
|
5129
|
+
guard CraftPresenter.topViewController() != nil else {
|
|
4076
5130
|
rejectCallback(callbackId, error: "Unable to present the share sheet")
|
|
4077
5131
|
return
|
|
4078
5132
|
}
|
|
@@ -4098,7 +5152,11 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
4098
5152
|
self.resolveCallback(callbackId, result: completed)
|
|
4099
5153
|
}
|
|
4100
5154
|
}
|
|
4101
|
-
|
|
5155
|
+
// On iPad the sheet is a popover, and one with no anchor is an
|
|
5156
|
+
// exception rather than a sheet: it points at `anchor` when the
|
|
5157
|
+
// page gives one (the button's rect), the middle of the screen
|
|
5158
|
+
// when it does not.
|
|
5159
|
+
CraftPresenter.present(activityVC, from: webView, anchor: CraftPresenter.rect(options["anchor"]))
|
|
4102
5160
|
}
|
|
4103
5161
|
|
|
4104
5162
|
// MARK: - Camera & Photo Library
|
|
@@ -4108,8 +5166,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
4108
5166
|
self.rejectCallback(self.pendingCallbackId, error: "Camera not available")
|
|
4109
5167
|
return
|
|
4110
5168
|
}
|
|
4111
|
-
guard let
|
|
4112
|
-
let rootVC = windowScene.windows.first?.rootViewController else { return }
|
|
5169
|
+
guard let rootVC = CraftPresenter.topViewController() else { return }
|
|
4113
5170
|
|
|
4114
5171
|
let picker = UIImagePickerController()
|
|
4115
5172
|
picker.sourceType = .camera
|
|
@@ -4120,8 +5177,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
4120
5177
|
|
|
4121
5178
|
private func pickImage() {
|
|
4122
5179
|
DispatchQueue.main.async {
|
|
4123
|
-
guard let
|
|
4124
|
-
let rootVC = windowScene.windows.first?.rootViewController else { return }
|
|
5180
|
+
guard let rootVC = CraftPresenter.topViewController() else { return }
|
|
4125
5181
|
|
|
4126
5182
|
let picker = UIImagePickerController()
|
|
4127
5183
|
picker.sourceType = .photoLibrary
|
|
@@ -5342,8 +6398,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5342
6398
|
// MARK: - QR/Barcode Scanner
|
|
5343
6399
|
private func scanQRCode(callbackId: String?) {
|
|
5344
6400
|
DispatchQueue.main.async {
|
|
5345
|
-
guard let
|
|
5346
|
-
let rootVC = windowScene.windows.first?.rootViewController else { return }
|
|
6401
|
+
guard let rootVC = CraftPresenter.topViewController() else { return }
|
|
5347
6402
|
|
|
5348
6403
|
if #available(iOS 16.0, *) {
|
|
5349
6404
|
let scannerVC = DataScannerViewController(
|
|
@@ -5365,8 +6420,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5365
6420
|
// MARK: - File Picker
|
|
5366
6421
|
private func pickFile(types: [String]?, callbackId: String?) {
|
|
5367
6422
|
DispatchQueue.main.async {
|
|
5368
|
-
guard let
|
|
5369
|
-
let rootVC = windowScene.windows.first?.rootViewController else { return }
|
|
6423
|
+
guard let rootVC = CraftPresenter.topViewController() else { return }
|
|
5370
6424
|
|
|
5371
6425
|
var allowedTypes: [UTType] = [.item]
|
|
5372
6426
|
if let types = types {
|
|
@@ -5510,8 +6564,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5510
6564
|
return
|
|
5511
6565
|
}
|
|
5512
6566
|
|
|
5513
|
-
guard let
|
|
5514
|
-
let rootVC = windowScene.windows.first?.rootViewController else { return }
|
|
6567
|
+
guard let rootVC = CraftPresenter.topViewController() else { return }
|
|
5515
6568
|
|
|
5516
6569
|
let picker = UIImagePickerController()
|
|
5517
6570
|
picker.sourceType = .camera
|
|
@@ -6270,8 +7323,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6270
7323
|
|
|
6271
7324
|
private func openPDF(source: String, page: Int, callbackId: String?) {
|
|
6272
7325
|
DispatchQueue.main.async {
|
|
6273
|
-
guard let
|
|
6274
|
-
let rootVC = windowScene.windows.first?.rootViewController else {
|
|
7326
|
+
guard let rootVC = CraftPresenter.topViewController() else {
|
|
6275
7327
|
self.rejectCallback(callbackId, error: "No root view controller")
|
|
6276
7328
|
return
|
|
6277
7329
|
}
|
|
@@ -6348,8 +7400,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6348
7400
|
// MARK: - Contacts Picker
|
|
6349
7401
|
private func pickContact(multiple: Bool, callbackId: String?) {
|
|
6350
7402
|
DispatchQueue.main.async {
|
|
6351
|
-
guard let
|
|
6352
|
-
let rootVC = windowScene.windows.first?.rootViewController else {
|
|
7403
|
+
guard let rootVC = CraftPresenter.topViewController() else {
|
|
6353
7404
|
self.rejectCallback(callbackId, error: "No root view controller")
|
|
6354
7405
|
return
|
|
6355
7406
|
}
|
|
@@ -7218,6 +8269,72 @@ extension URL {
|
|
|
7218
8269
|
//
|
|
7219
8270
|
// The end of an utterance is what `craft.speech.speak()` waits for. Hopped to
|
|
7220
8271
|
// main because every piece of speech state is touched there and nowhere else.
|
|
8272
|
+
// MARK: - The page's own windows and dialogs
|
|
8273
|
+
//
|
|
8274
|
+
// WebKit hands these to the UI delegate and, with none set, drops them: an
|
|
8275
|
+
// alert() that never showed, a confirm() that answered false without asking,
|
|
8276
|
+
// a target=_blank link that did nothing when tapped.
|
|
8277
|
+
extension CraftWebView.Coordinator: WKUIDelegate {
|
|
8278
|
+
/// A dialog's title: none for the app's own page, which speaks as the app,
|
|
8279
|
+
/// and the origin's host for anything embedded in it, so a frame cannot
|
|
8280
|
+
/// pass its alert off as the app's.
|
|
8281
|
+
private func dialogTitle(for frame: WKFrameInfo) -> String? {
|
|
8282
|
+
trusts(frame.securityOrigin) ? nil : frame.securityOrigin.host
|
|
8283
|
+
}
|
|
8284
|
+
|
|
8285
|
+
func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
|
|
8286
|
+
let alert = UIAlertController(title: dialogTitle(for: frame), message: message, preferredStyle: .alert)
|
|
8287
|
+
alert.addAction(UIAlertAction(title: CraftPresenter.systemString("OK"), style: .default) { _ in completionHandler() })
|
|
8288
|
+
if !CraftPresenter.present(alert, from: webView) { completionHandler() }
|
|
8289
|
+
}
|
|
8290
|
+
|
|
8291
|
+
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
|
|
8292
|
+
let alert = UIAlertController(title: dialogTitle(for: frame), message: message, preferredStyle: .alert)
|
|
8293
|
+
alert.addAction(UIAlertAction(title: CraftPresenter.systemString("Cancel"), style: .cancel) { _ in completionHandler(false) })
|
|
8294
|
+
alert.addAction(UIAlertAction(title: CraftPresenter.systemString("OK"), style: .default) { _ in completionHandler(true) })
|
|
8295
|
+
if !CraftPresenter.present(alert, from: webView) { completionHandler(false) }
|
|
8296
|
+
}
|
|
8297
|
+
|
|
8298
|
+
func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) {
|
|
8299
|
+
let alert = UIAlertController(title: dialogTitle(for: frame), message: prompt, preferredStyle: .alert)
|
|
8300
|
+
alert.addTextField { field in field.text = defaultText }
|
|
8301
|
+
alert.addAction(UIAlertAction(title: CraftPresenter.systemString("Cancel"), style: .cancel) { _ in completionHandler(nil) })
|
|
8302
|
+
alert.addAction(UIAlertAction(title: CraftPresenter.systemString("OK"), style: .default) { [weak alert] _ in
|
|
8303
|
+
completionHandler(alert?.textFields?.first?.text ?? "")
|
|
8304
|
+
})
|
|
8305
|
+
if !CraftPresenter.present(alert, from: webView) { completionHandler(nil) }
|
|
8306
|
+
}
|
|
8307
|
+
|
|
8308
|
+
/// target=_blank and window.open. The app's own pages open in this web
|
|
8309
|
+
/// view, the way a native app pushes a screen rather than opening a
|
|
8310
|
+
/// second app. Anywhere else opens in Safari's in-app view, over the app
|
|
8311
|
+
/// and dismissed back to it, and a non-web link goes to whatever app
|
|
8312
|
+
/// handles it. A second web view is never made: there would be nothing to
|
|
8313
|
+
/// show it in.
|
|
8314
|
+
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
|
|
8315
|
+
guard let url = navigationAction.request.url else { return nil }
|
|
8316
|
+
if isTrustedURL(url) {
|
|
8317
|
+
webView.load(navigationAction.request)
|
|
8318
|
+
} else if url.scheme == "https" || url.scheme == "http" {
|
|
8319
|
+
let safari = SFSafariViewController(url: url)
|
|
8320
|
+
safari.dismissButtonStyle = .close
|
|
8321
|
+
if !CraftPresenter.present(safari, from: webView) { UIApplication.shared.open(url) }
|
|
8322
|
+
} else {
|
|
8323
|
+
UIApplication.shared.open(url)
|
|
8324
|
+
}
|
|
8325
|
+
return nil
|
|
8326
|
+
}
|
|
8327
|
+
|
|
8328
|
+
/// getUserMedia from the app's own page is granted without WebKit's own
|
|
8329
|
+
/// per-page prompt: the app already holds (or will ask for) the camera
|
|
8330
|
+
/// and microphone permission iOS itself shows, and asking twice for one
|
|
8331
|
+
/// thing reads as a web page. Anything embedded still gets the prompt.
|
|
8332
|
+
@available(iOS 15.0, *)
|
|
8333
|
+
func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType, decisionHandler: @escaping (WKPermissionDecision) -> Void) {
|
|
8334
|
+
decisionHandler(trusts(origin) && trusts(frame.securityOrigin) ? .grant : .prompt)
|
|
8335
|
+
}
|
|
8336
|
+
}
|
|
8337
|
+
|
|
7221
8338
|
extension CraftWebView.Coordinator: AVSpeechSynthesizerDelegate {
|
|
7222
8339
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
|
7223
8340
|
DispatchQueue.main.async { self.speechDidEnd(utterance, spoken: true) }
|