craft-native 0.0.122 → 0.0.124

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