craft-native 0.0.123 → 0.0.124

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 {
@@ -228,6 +465,7 @@ struct CraftApp: App {
228
465
  // The page's own chrome, drawn natively over it: the tab
229
466
  // bar the page asks for, and the splash until it is ready.
230
467
  ZStack(alignment: .bottom) {
468
+ CraftColorSchemeObserver()
231
469
  CraftWebView(config: appState.config)
232
470
  .ignoresSafeArea()
233
471
  CraftTabBarView()
@@ -279,10 +517,19 @@ final class CraftChrome: ObservableObject {
279
517
  /// What the tab bar covers at the bottom of the screen, in points.
280
518
  private var occupied: CGFloat = 0
281
519
  private var pendingHide: DispatchWorkItem?
520
+ private var splashDeadline: DispatchWorkItem?
521
+
522
+ private init() {}
282
523
 
283
- private init() {
284
- // However the page behaves, the splash never outstays ten seconds.
285
- DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self] in self?.hideSplash() }
524
+ /// However the page behaves, the splash is gone after `seconds`
525
+ /// (`splashMaxSeconds`, three by default). It used to be ten: a page that
526
+ /// never painted held a logo on screen for ten seconds, which reads as a
527
+ /// hang rather than a launch.
528
+ func holdSplash(atMost seconds: Double) {
529
+ splashDeadline?.cancel()
530
+ let deadline = DispatchWorkItem { [weak self] in self?.hideSplash() }
531
+ splashDeadline = deadline
532
+ DispatchQueue.main.asyncAfter(deadline: .now() + max(0.5, seconds), execute: deadline)
286
533
  }
287
534
 
288
535
  func handle(_ body: [String: Any]) {
@@ -300,11 +547,11 @@ final class CraftChrome: ObservableObject {
300
547
  let dark = (body["tintDark"] as? String).flatMap { UIColor(hex: $0) }
301
548
  tint = light.map { light in dark.map { dark in UIColor { $0.userInterfaceStyle == .dark ? dark : light } } ?? light }
302
549
  }
303
- withAnimation(.easeOut(duration: 0.2)) { tabBarVisible = !tabs.isEmpty }
550
+ withAnimation(Self.motion(.easeOut(duration: 0.2))) { tabBarVisible = !tabs.isEmpty }
304
551
  publishLayout()
305
552
  case "selectTab":
306
553
  if let id = body["id"] as? String, id != selected {
307
- withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { selected = id }
554
+ withAnimation(Self.motion(.spring(response: 0.32, dampingFraction: 0.86))) { selected = id }
308
555
  }
309
556
  case "hideTabBar":
310
557
  // Later, and only if no screen asks for the bar meanwhile: moving
@@ -312,7 +559,7 @@ final class CraftChrome: ObservableObject {
312
559
  // mounts the next, and the bar must not blink in between.
313
560
  pendingHide?.cancel()
314
561
  let hide = DispatchWorkItem { [weak self] in
315
- withAnimation(.easeIn(duration: 0.18)) { self?.tabBarVisible = false }
562
+ withAnimation(Self.motion(.easeIn(duration: 0.18))) { self?.tabBarVisible = false }
316
563
  self?.occupied = 0
317
564
  self?.publishLayout()
318
565
  }
@@ -320,25 +567,70 @@ final class CraftChrome: ObservableObject {
320
567
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: hide)
321
568
  case "ready":
322
569
  hideSplash()
570
+ case "painted":
571
+ // The page's first contentful paint, two frames on: something of
572
+ // the page is on screen, so the launch screen has done its job
573
+ // even if the page never says `ready`.
574
+ hideSplash()
323
575
  default:
324
576
  break
325
577
  }
326
578
  }
327
579
 
328
580
  /// A tap on a tab: the page navigates, and says which tab is current.
581
+ ///
582
+ /// No haptic. UITabBar plays none, and a tab bar that ticks on every tap is
583
+ /// one of the small things that gives a web shell away.
329
584
  func tap(_ tab: Tab) {
330
- UISelectionFeedbackGenerator().selectionChanged()
331
585
  if tab.id != selected {
332
- withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { selected = tab.id }
586
+ withAnimation(Self.motion(.spring(response: 0.32, dampingFraction: 0.86))) { selected = tab.id }
333
587
  }
334
588
  emit("craftTabSelect", ["id": tab.id])
335
589
  }
336
590
 
591
+ /// The animation, or none with Reduce Motion on: the selection jumps to
592
+ /// its tab rather than springing there, as UIKit's own controls do.
593
+ static func motion(_ animation: Animation) -> Animation? {
594
+ UIAccessibility.isReduceMotionEnabled ? nil : animation
595
+ }
596
+
597
+ /// The splash again, over a page that is being rebuilt (the web content
598
+ /// process died), rather than the white flash of a reload.
599
+ func showSplash(atMost seconds: Double) {
600
+ withAnimation(nil) { splashVisible = true }
601
+ holdSplash(atMost: seconds)
602
+ }
603
+
337
604
  func hideSplash() {
605
+ splashDeadline?.cancel()
606
+ splashDeadline = nil
338
607
  guard splashVisible else { return }
339
608
  withAnimation(.easeOut(duration: 0.28)) { splashVisible = false }
340
609
  }
341
610
 
611
+ /// The paint half of "until the page is ready or has painted": tells the
612
+ /// shell once the document's first contentful paint is on screen.
613
+ static let paintScript = """
614
+ (function() {
615
+ try {
616
+ var handler = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craftChrome;
617
+ if (!handler || typeof PerformanceObserver !== 'function') return;
618
+ var sent = false;
619
+ var send = function() {
620
+ if (sent) return;
621
+ sent = true;
622
+ requestAnimationFrame(function() { requestAnimationFrame(function() { handler.postMessage({type: 'painted'}); }); });
623
+ };
624
+ var observer = new PerformanceObserver(function(list) {
625
+ list.getEntries().forEach(function(entry) {
626
+ if (entry.name === 'first-contentful-paint') { send(); observer.disconnect(); }
627
+ });
628
+ });
629
+ observer.observe({type: 'paint', buffered: true});
630
+ } catch (e) {}
631
+ })();
632
+ """
633
+
342
634
  /// A page that loaded and never said it was ready still gets shown.
343
635
  func pageFinished() {
344
636
  DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in self?.hideSplash() }
@@ -386,6 +678,13 @@ final class CraftChromeRelay: NSObject, WKScriptMessageHandler {
386
678
  struct CraftTabBarView: View {
387
679
  @ObservedObject private var chrome = CraftChrome.shared
388
680
  @Namespace private var selection
681
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
682
+ // Dynamic Type, relative to the styles UIKit's own bar uses. The bar is
683
+ // capped at the largest standard size (below), as UITabBar is; past that
684
+ // a long press shows the large content viewer instead.
685
+ @ScaledMetric(relativeTo: .title3) private var iconSize: CGFloat = 20
686
+ @ScaledMetric(relativeTo: .caption2) private var labelSize: CGFloat = 11
687
+ @ScaledMetric(relativeTo: .caption2) private var badgeSize: CGFloat = 10
389
688
 
390
689
  var body: some View {
391
690
  if chrome.tabBarVisible && !chrome.tabs.isEmpty {
@@ -397,8 +696,9 @@ struct CraftTabBarView: View {
397
696
  .onAppear { report(proxy) }
398
697
  .onChange(of: proxy.frame(in: .global)) { _ in report(proxy) }
399
698
  })
400
- .transition(.move(edge: .bottom).combined(with: .opacity))
699
+ .transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity))
401
700
  .ignoresSafeArea(.keyboard)
701
+ .dynamicTypeSize(...DynamicTypeSize.xxxLarge)
402
702
  }
403
703
  }
404
704
 
@@ -408,6 +708,7 @@ struct CraftTabBarView: View {
408
708
  }
409
709
 
410
710
  @ViewBuilder private var bar: some View {
711
+ #if compiler(>=6.2)
411
712
  if #available(iOS 26.0, *) {
412
713
  GlassEffectContainer {
413
714
  items
@@ -421,6 +722,13 @@ struct CraftTabBarView: View {
421
722
  .overlay(Capsule().strokeBorder(Color.white.opacity(0.35), lineWidth: 0.5))
422
723
  .shadow(color: .black.opacity(0.12), radius: 18, y: 6)
423
724
  }
725
+ #else
726
+ items
727
+ .padding(4)
728
+ .background(.ultraThinMaterial, in: Capsule())
729
+ .overlay(Capsule().strokeBorder(Color.white.opacity(0.35), lineWidth: 0.5))
730
+ .shadow(color: .black.opacity(0.12), radius: 18, y: 6)
731
+ #endif
424
732
  }
425
733
 
426
734
  private var items: some View {
@@ -431,11 +739,11 @@ struct CraftTabBarView: View {
431
739
  VStack(spacing: 3) {
432
740
  ZStack(alignment: .topTrailing) {
433
741
  Image(systemName: symbol(tab.symbol, selected: isSelected))
434
- .font(.system(size: 20, weight: isSelected ? .semibold : .regular))
435
- .frame(height: 26)
742
+ .font(.system(size: iconSize, weight: isSelected ? .semibold : .regular))
743
+ .frame(height: iconSize + 6)
436
744
  if let badge = tab.badge {
437
745
  Text(badge)
438
- .font(.system(size: 10, weight: .bold))
746
+ .font(.system(size: badgeSize, weight: .bold))
439
747
  .foregroundStyle(.white)
440
748
  .padding(.horizontal, 4)
441
749
  .frame(minWidth: 16, minHeight: 16)
@@ -444,7 +752,7 @@ struct CraftTabBarView: View {
444
752
  }
445
753
  }
446
754
  Text(tab.title)
447
- .font(.system(size: 11, weight: isSelected ? .semibold : .medium))
755
+ .font(.system(size: labelSize, weight: isSelected ? .semibold : .medium))
448
756
  .lineLimit(1)
449
757
  .minimumScaleFactor(0.8)
450
758
  }
@@ -460,6 +768,10 @@ struct CraftTabBarView: View {
460
768
  .contentShape(Capsule())
461
769
  }
462
770
  .buttonStyle(.plain)
771
+ .accessibilityShowsLargeContentViewer {
772
+ Image(systemName: symbol(tab.symbol, selected: isSelected))
773
+ Text(tab.title)
774
+ }
463
775
  .accessibilityLabel(tab.badge.map { "\(tab.title), \($0)" } ?? tab.title)
464
776
  .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
465
777
  }
@@ -633,6 +945,14 @@ class AppState: ObservableObject {
633
945
  }
634
946
 
635
947
  extension CraftConfig {
948
+ /// craft.config.json from the app bundle, for code that runs before the
949
+ /// app's state exists (the app delegate at launch).
950
+ static func bundled() -> CraftConfig {
951
+ guard let url = Bundle.main.url(forResource: "craft.config", withExtension: "json"),
952
+ let data = try? Data(contentsOf: url) else { return CraftConfig() }
953
+ return CraftConfig.load(from: data)
954
+ }
955
+
636
956
  /// The bundled config laid over the defaults, key by key.
637
957
  ///
638
958
  /// Decoded whole, one missing or mistyped key failed the entire file and
@@ -711,6 +1031,31 @@ struct CraftConfig: Codable {
711
1031
  var enableMLKit: Bool = false
712
1032
  var devServerURL: String? = nil
713
1033
  var trustedOrigins: [String] = []
1034
+ /// The longest the launch splash stays up when the page neither says it is
1035
+ /// ready nor paints.
1036
+ var splashMaxSeconds: Double = 3
1037
+ /// How long the app's own page may take to answer before the load counts
1038
+ /// as unreachable and the bundled copy stands in.
1039
+ var requestTimeoutSeconds: Double = 10
1040
+ /// Press-and-hold on a link shows its preview, as in Safari. Off unless
1041
+ /// asked for: no native app previews its own buttons.
1042
+ var allowsLinkPreview: Bool? = nil
1043
+ /// WebKit's bar above the keyboard (previous, next, Done). Hidden unless
1044
+ /// asked for; `craft.chrome.setKeyboardAccessory` changes it at runtime.
1045
+ var keyboardAccessory: Bool? = nil
1046
+ /// Pinch and double-tap zoom. Off unless set to false: an app's screens
1047
+ /// are laid out for the phone, and zooming one is a web page's tell.
1048
+ var disableZoom: Bool? = nil
1049
+ /// iOS's scheduled app refresh, which fires `craftBackgroundRefresh`.
1050
+ var backgroundRefresh: BackgroundRefresh? = nil
1051
+
1052
+ struct BackgroundRefresh: Codable {
1053
+ var enabled: Bool = false
1054
+ /// The BGTaskScheduler identifier; `<bundleId>.refresh` if unset.
1055
+ var identifier: String? = nil
1056
+ /// The soonest iOS may run it again, in minutes (15 if unset).
1057
+ var minimumIntervalMinutes: Double? = nil
1058
+ }
714
1059
  }
715
1060
 
716
1061
  extension CraftConfig {
@@ -724,6 +1069,49 @@ extension CraftConfig {
724
1069
  }
725
1070
  }
726
1071
 
1072
+ /// What the web view adds to its user agent: WebKit's own token, then
1073
+ /// `Craft/<version>` and `<AppName>/<version>`, so the app's server can
1074
+ /// tell the app from Safari, and which build is asking.
1075
+ func applicationNameForUserAgent(after base: String?) -> String {
1076
+ let name = String(appName.unicodeScalars.filter { $0.isASCII && (CharacterSet.alphanumerics.contains($0) || "._-".unicodeScalars.contains($0)) })
1077
+ let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
1078
+ return [base ?? "", "Craft/{{CRAFT_VERSION}}", "\(name.isEmpty ? "App" : name)/\(version)"]
1079
+ .filter { !$0.isEmpty }
1080
+ .joined(separator: " ")
1081
+ }
1082
+
1083
+ /// Pins the page's viewport at its own width: no pinch, no double-tap
1084
+ /// zoom. Installed at document start, so its DOMContentLoaded listener is
1085
+ /// the first: the tag is rewritten once the page's own is parsed, before
1086
+ /// any of the page's handlers read it.
1087
+ static let viewportScript = """
1088
+ (function() {
1089
+ function pin() {
1090
+ var meta = document.querySelector('meta[name="viewport"]');
1091
+ var content = meta ? (meta.getAttribute('content') || '') : 'width=device-width, initial-scale=1';
1092
+ content = content.split(',').map(function(part) { return part.trim(); }).filter(function(part) {
1093
+ return part && !/^(maximum-scale|minimum-scale|user-scalable)\\s*=/i.test(part);
1094
+ }).concat(['minimum-scale=1', 'maximum-scale=1', 'user-scalable=no']).join(', ');
1095
+ if (!meta) {
1096
+ meta = document.createElement('meta');
1097
+ meta.setAttribute('name', 'viewport');
1098
+ (document.head || document.documentElement).appendChild(meta);
1099
+ }
1100
+ meta.setAttribute('content', content);
1101
+ }
1102
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', pin);
1103
+ else pin();
1104
+ })();
1105
+ """
1106
+
1107
+ /// A request for the app's own page, given up on after
1108
+ /// `requestTimeoutSeconds`. URLRequest's default is sixty seconds, and a
1109
+ /// first launch on a dead connection sat on the splash and then a blank
1110
+ /// page for that long before the offline page could show.
1111
+ func request(for url: URL) -> URLRequest {
1112
+ URLRequest(url: url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: max(1, requestTimeoutSeconds))
1113
+ }
1114
+
727
1115
  /// The webview's background, resolved per trait so a Dark Mode switch
728
1116
  /// repaints it without a reload.
729
1117
  var resolvedBackgroundColor: UIColor {
@@ -742,6 +1130,13 @@ final class BundledAssetSchemeHandler: NSObject, WKURLSchemeHandler {
742
1130
  super.init()
743
1131
  }
744
1132
 
1133
+ /// The bundled `index.html` as text, for a web view that cannot navigate
1134
+ /// to `craft://app` (see `loadBundledPage`).
1135
+ static func bundledIndexHTML() -> String? {
1136
+ guard let root = findBundledRoot() else { return nil }
1137
+ return try? String(contentsOf: root.appendingPathComponent("index.html"), encoding: .utf8)
1138
+ }
1139
+
745
1140
  private static func findBundledRoot() -> URL? {
746
1141
  let bundle = Bundle.main
747
1142
  let directCandidates = [
@@ -876,7 +1271,9 @@ struct CraftWebView: UIViewRepresentable {
876
1271
  let webConfig = WKWebViewConfiguration()
877
1272
  webConfig.defaultWebpagePreferences.allowsContentJavaScript = true
878
1273
  webConfig.allowsInlineMediaPlayback = true
1274
+ webConfig.allowsPictureInPictureMediaPlayback = true
879
1275
  webConfig.mediaTypesRequiringUserActionForPlayback = []
1276
+ webConfig.applicationNameForUserAgent = config.applicationNameForUserAgent(after: webConfig.applicationNameForUserAgent)
880
1277
  webConfig.setURLSchemeHandler(BundledAssetSchemeHandler(), forURLScheme: "craft")
881
1278
  // The app's own domains (WKAppBoundDomains in Info.plist) get what an
882
1279
  // iOS web view reserves for them, service workers among them, so a
@@ -890,26 +1287,42 @@ struct CraftWebView: UIViewRepresentable {
890
1287
  let contentController = WKUserContentController()
891
1288
  contentController.add(context.coordinator, name: "craft")
892
1289
  // The tab bar and the splash, outside the bridge's action dispatch:
893
- // they are the screen's, not a device API, and the page needs them
894
- // before the bridge is installed.
1290
+ // they are the screen's, not a device API.
895
1291
  let coordinator = context.coordinator
896
1292
  contentController.add(CraftChromeRelay(trusts: { [weak coordinator] origin in coordinator?.trusts(origin) ?? false }), name: "craftChrome")
897
- // From the first byte, so the page's CSS knows before it paints that
898
- // the shell draws the tab bar, and does not draw its own for a frame.
899
- contentController.addUserScript(WKUserScript(source: "document.documentElement.setAttribute('data-craft-chrome','ios')", injectionTime: .atDocumentStart, forMainFrameOnly: true))
900
- webConfig.userContentController = contentController
901
1293
  #if DEBUG
902
1294
  // The page's errors and console, in the device log, so a debug build
903
1295
  // can be diagnosed from `log stream` or `simctl spawn … log show`
904
1296
  // without attaching Safari. A separate handler, outside the bridge's
905
1297
  // action dispatch. Never in a release build.
906
1298
  contentController.add(PageConsoleRelay(), name: "craftLog")
907
- contentController.addUserScript(WKUserScript(source: PageConsoleRelay.script, injectionTime: .atDocumentStart, forMainFrameOnly: true))
908
1299
  #endif
1300
+ // The bridge and everything else the page gets from the first byte.
1301
+ coordinator.installUserScripts(into: contentController)
1302
+ webConfig.userContentController = contentController
909
1303
 
910
1304
  let webView = WKWebView(frame: .zero, configuration: webConfig)
911
1305
  webView.navigationDelegate = context.coordinator
1306
+ // alert(), confirm() and prompt() as the system's own alerts, and
1307
+ // target=_blank and window.open somewhere they can actually open.
1308
+ // Without a UI delegate WebKit drops all of them silently.
1309
+ webView.uiDelegate = context.coordinator
1310
+ // Now, not when the first page finishes loading. Everything the
1311
+ // coordinator sends the page goes through this reference, and a
1312
+ // network change, a push token or a location fix that arrived before
1313
+ // the first didFinish used to be dropped on a nil.
1314
+ coordinator.attach(webView)
912
1315
  webView.isOpaque = false
1316
+ // The feel of an app rather than a page: no link previews on
1317
+ // press-and-hold, the keyboard follows a drag down as it does in
1318
+ // Messages, and the page stays at its own scale.
1319
+ webView.allowsLinkPreview = config.allowsLinkPreview ?? false
1320
+ webView.scrollView.keyboardDismissMode = .interactive
1321
+ if config.disableZoom != false {
1322
+ webView.scrollView.minimumZoomScale = 1
1323
+ webView.scrollView.maximumZoomScale = 1
1324
+ webView.scrollView.pinchGestureRecognizer?.isEnabled = false
1325
+ }
913
1326
  // An edge swipe goes back (and forward) through the page's history,
914
1327
  // pushState entries included, the way every iOS app's stack does.
915
1328
  webView.allowsBackForwardNavigationGestures = config.swipeNavigation ?? false
@@ -928,17 +1341,22 @@ struct CraftWebView: UIViewRepresentable {
928
1341
  DeepLinkManager.shared.setWebView(webView)
929
1342
  CraftEventManager.shared.setWebView(webView)
930
1343
  CraftChrome.shared.webView = webView
1344
+ CraftChrome.shared.holdSplash(atMost: config.splashMaxSeconds)
931
1345
 
932
1346
  // Parse background color
933
1347
  let bgColor = config.resolvedBackgroundColor
934
1348
  webView.backgroundColor = bgColor
935
1349
  webView.scrollView.backgroundColor = bgColor
936
1350
 
937
- // Load content
938
- if 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 {
939
1357
  // Development mode - connect to server
940
1358
  if let url = URL(string: devURL) {
941
- webView.load(URLRequest(url: url))
1359
+ webView.load(config.request(for: url))
942
1360
  }
943
1361
  } else if let bundledURL = URL(string: "craft://app/index.html") {
944
1362
  // A route-aware local origin keeps root-relative STX assets and
@@ -978,6 +1396,25 @@ struct CraftWebView: UIViewRepresentable {
978
1396
  private var pendingCallbackId: String?
979
1397
  private var pendingPushCallbackId: String?
980
1398
  private var loadedBundledFallback = false
1399
+ /// Whether the current document's bridge has announced itself.
1400
+ private var documentReady = false
1401
+ /// Reloads after the content process dies, budgeted per burst.
1402
+ private var recoveryBudget = CraftRecoveryBudget()
1403
+ /// The content process died in the background; bring it back when the
1404
+ /// app is on screen again rather than spending the work unseen.
1405
+ private var recoverOnForeground = false
1406
+ /// The last page the app's own origin finished, for a recovery that
1407
+ /// finds the web view with no URL.
1408
+ private var lastPageURL: URL?
1409
+ /// When the app last went to the background, for `craftResume`.
1410
+ private var backgroundedAt: Date?
1411
+ /// Whether WebKit's bar above the keyboard shows (`keyboardAccessory`,
1412
+ /// then `craft.chrome.setKeyboardAccessory`).
1413
+ private lazy var keyboardAccessoryVisible = config.keyboardAccessory ?? false
1414
+ /// The native pull-to-refresh, when the page has asked for it.
1415
+ private lazy var refreshControl = CraftRefreshControl { [weak self] in
1416
+ self?.sendToWeb("craftRefresh", data: [:])
1417
+ }
981
1418
 
982
1419
  /// The highest `cb_<n>` this process has seen the page hand out.
983
1420
  ///
@@ -1102,6 +1539,11 @@ struct CraftWebView: UIViewRepresentable {
1102
1539
  NotificationCenter.default.addObserver(self, selector: #selector(receivePushToken(_:)), name: .craftPushToken, object: nil)
1103
1540
  NotificationCenter.default.addObserver(self, selector: #selector(receivePushRegistrationError(_:)), name: .craftPushRegistrationError, object: nil)
1104
1541
  NotificationCenter.default.addObserver(self, selector: #selector(appWillEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
1542
+ NotificationCenter.default.addObserver(self, selector: #selector(appDidEnterBackground(_:)), name: UIApplication.didEnterBackgroundNotification, object: nil)
1543
+ NotificationCenter.default.addObserver(self, selector: #selector(appDidReceiveMemoryWarning(_:)), name: UIApplication.didReceiveMemoryWarningNotification, object: nil)
1544
+ for name in [UIContentSizeCategory.didChangeNotification, UIAccessibility.reduceMotionStatusDidChangeNotification, UIAccessibility.reduceTransparencyStatusDidChangeNotification, .craftColorSchemeChanged] {
1545
+ NotificationCenter.default.addObserver(self, selector: #selector(appearanceChanged(_:)), name: name, object: nil)
1546
+ }
1105
1547
  setupNetworkMonitoring()
1106
1548
  }
1107
1549
 
@@ -1155,6 +1597,7 @@ struct CraftWebView: UIViewRepresentable {
1155
1597
  networkMonitor?.start(queue: DispatchQueue.global())
1156
1598
  }
1157
1599
 
1600
+ // CRAFT_IOS_PAGE_BRIDGE_DISPATCHER
1158
1601
  func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
1159
1602
  let securityOrigin = message.frameInfo.securityOrigin
1160
1603
  guard isTrustedOrigin(scheme: securityOrigin.protocol, host: securityOrigin.host, port: securityOrigin.port) else {
@@ -1164,6 +1607,13 @@ struct CraftWebView: UIViewRepresentable {
1164
1607
  guard let body = message.body as? [String: Any],
1165
1608
  let action = body["action"] as? String else { return }
1166
1609
 
1610
+ // The page's own signal that the bridge has announced itself, not
1611
+ // a device API: it is never offered to Zig and nothing answers it.
1612
+ if action == "__craftReady" {
1613
+ pageBecameReady()
1614
+ return
1615
+ }
1616
+
1167
1617
  let callbackId = body["callbackId"] as? String
1168
1618
  // Before Zig is offered the call, because the seed has to cover
1169
1619
  // every id the page drew, not only the ones Swift went on to serve.
@@ -1310,6 +1760,62 @@ struct CraftWebView: UIViewRepresentable {
1310
1760
  } else {
1311
1761
  rejectCallback(callbackId, error: "Settings URL is unavailable")
1312
1762
  }
1763
+ // MARK: Native UI: dialogs, menus, the in-app browser, chrome.
1764
+ // None of these is gated: they are the system's own UI, and need
1765
+ // no permission or entitlement.
1766
+ case "dialogAlert":
1767
+ CraftDialogs.alert(body, from: webView) { [weak self] in self?.resolveCallback(callbackId, result: NSNull()) }
1768
+ case "dialogConfirm":
1769
+ CraftDialogs.confirm(body, from: webView) { [weak self] confirmed in self?.resolveCallback(callbackId, result: confirmed) }
1770
+ case "dialogActionSheet":
1771
+ CraftDialogs.actionSheet(body, from: webView) { [weak self] id in self?.resolveCallback(callbackId, result: id ?? NSNull()) }
1772
+ case "contextMenuShow":
1773
+ guard let webView else { resolveCallback(callbackId, result: NSNull()); return }
1774
+ CraftContextMenu.show(body, in: webView) { [weak self] id in self?.resolveCallback(callbackId, result: id ?? NSNull()) }
1775
+ case "browserOpen":
1776
+ guard let address = body["url"] as? String, let url = URL(string: address) else {
1777
+ rejectCallback(callbackId, error: "browser.open needs a URL it can parse", code: "INVALID_ARGUMENT")
1778
+ return
1779
+ }
1780
+ CraftBrowser.shared.open(url, mode: body["mode"] as? String ?? "safari", callbackScheme: body["callbackScheme"] as? String, from: webView) { [weak self] answer in
1781
+ self?.resolveCallback(callbackId, result: answer)
1782
+ }
1783
+ case "symbolImage":
1784
+ resolveCallback(callbackId, result: CraftSymbols.image(named: body["name"] as? String ?? "", options: body) ?? NSNull())
1785
+ case "statusBarSetStyle":
1786
+ resolveCallback(callbackId, result: CraftStatusBar.setStyle(body["style"] as? String ?? "default"))
1787
+ case "chromeSetUnderPageColor":
1788
+ // What shows past the page's edges when it rubber-bands.
1789
+ if #available(iOS 15.0, *), let webView, let css = body["color"] as? String, let color = UIColor(css: css) {
1790
+ webView.underPageBackgroundColor = color
1791
+ resolveCallback(callbackId, result: true)
1792
+ } else {
1793
+ resolveCallback(callbackId, result: false)
1794
+ }
1795
+ case "chromeSetKeyboardAccessory":
1796
+ let visible = body["visible"] as? Bool ?? true
1797
+ keyboardAccessoryVisible = visible
1798
+ resolveCallback(callbackId, result: webView.map { CraftKeyboardAccessory.setVisible(visible, in: $0) } ?? false)
1799
+ case "refreshEnable":
1800
+ guard let webView else { resolveCallback(callbackId, result: false); return }
1801
+ refreshControl.enable(on: webView.scrollView, tint: (body["tintColor"] as? String).flatMap { UIColor(css: $0) })
1802
+ resolveCallback(callbackId, result: true)
1803
+ case "refreshDisable":
1804
+ refreshControl.disable()
1805
+ resolveCallback(callbackId, result: true)
1806
+ case "refreshEnd":
1807
+ refreshControl.end()
1808
+ resolveCallback(callbackId, result: true)
1809
+ // The page has finished the work a craftSilentPush or
1810
+ // craftBackgroundRefresh asked for.
1811
+ case "backgroundComplete":
1812
+ CraftBackgroundWork.shared.complete(id: body["id"] as? String, ok: body["ok"] as? Bool ?? true)
1813
+ resolveCallback(callbackId, result: true)
1814
+ // The offline page's Retry button, and anything else that wants
1815
+ // the app's own page back after the bundled copy stood in.
1816
+ case "retryRemote":
1817
+ retryRemote()
1818
+ resolveCallback(callbackId, result: true)
1313
1819
  case "log":
1314
1820
  if let msg = body["message"] as? String {
1315
1821
  print("[Craft Web] \(msg)")
@@ -1999,12 +2505,30 @@ struct CraftWebView: UIViewRepresentable {
1999
2505
  DeepLinkManager.shared.setLoading()
2000
2506
  }
2001
2507
 
2508
+ func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
2509
+ // A new document: the old one's ready no longer counts, and the
2510
+ // pull-to-refresh it asked for goes with it, or a pull would spin
2511
+ // waiting on a page that does not know it has one.
2512
+ documentReady = false
2513
+ refreshControl.disable()
2514
+ // Kept across documents and after the content process restarts.
2515
+ if !keyboardAccessoryVisible { CraftKeyboardAccessory.setVisible(false, in: webView) }
2516
+ if config.disableZoom != false { webView.scrollView.pinchGestureRecognizer?.isEnabled = false }
2517
+ }
2518
+
2002
2519
  func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
2003
- self.webView = webView
2004
2520
  CraftChrome.shared.pageFinished()
2005
2521
  CraftChrome.shared.publishLayout()
2006
2522
  guard isTrustedURL(webView.url) else { return }
2007
- 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
+ }
2008
2532
  }
2009
2533
 
2010
2534
  func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
@@ -2012,7 +2536,20 @@ struct CraftWebView: UIViewRepresentable {
2012
2536
  decisionHandler(.cancel)
2013
2537
  return
2014
2538
  }
2539
+ // A link that opens a new window (target=_blank) has no frame to
2540
+ // load into. It is allowed on, so WebKit asks the UI delegate's
2541
+ // `createWebViewWith`, which decides where it goes.
2542
+ if navigationAction.targetFrame == nil {
2543
+ decisionHandler(.allow)
2544
+ return
2545
+ }
2015
2546
  if isTrustedURL(url) {
2547
+ // A new main-frame document gets the bridge seeded from the
2548
+ // ids handed out so far (#226), so the user scripts are
2549
+ // rebuilt before it exists rather than once per web view.
2550
+ if navigationAction.targetFrame?.isMainFrame == true {
2551
+ installUserScripts(into: webView.configuration.userContentController)
2552
+ }
2016
2553
  decisionHandler(.allow)
2017
2554
  return
2018
2555
  }
@@ -2042,11 +2579,150 @@ struct CraftWebView: UIViewRepresentable {
2042
2579
  }
2043
2580
  }
2044
2581
 
2582
+ /// WebKit's content process died: out of memory, usually, often while
2583
+ /// the app was in the background. The view does not error; it goes
2584
+ /// white and stays white. It used to be reloaded on the spot, every
2585
+ /// time, into whatever crash killed it.
2586
+ ///
2587
+ /// Now the reload waits until the app is on screen, shows the splash
2588
+ /// rather than a white page while the page rebuilds, comes back on
2589
+ /// the page that was showing, and is budgeted: past three crashes a
2590
+ /// minute (`CraftRecoveryBudget`) the offline page shows instead,
2591
+ /// whose Retry starts afresh.
2045
2592
  func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
2046
- 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
2047
2716
  }
2048
2717
 
2049
2718
  func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
2719
+ // The navigation never replaced the page, so the page that was
2720
+ // ready still is. Without this, events stayed queued for the rest
2721
+ // of its life after one cancelled tap.
2722
+ if documentReady {
2723
+ DeepLinkManager.shared.setReady()
2724
+ CraftEventManager.shared.setReady()
2725
+ }
2050
2726
  fallBackIfUnreachable(webView, after: error)
2051
2727
  }
2052
2728
 
@@ -2062,14 +2738,9 @@ struct CraftWebView: UIViewRepresentable {
2062
2738
  }
2063
2739
 
2064
2740
  private func loadBundledFallback(in webView: WKWebView) {
2065
- guard !loadedBundledFallback,
2066
- config.devServerURL != nil,
2067
- let bundledURL = URL(string: "craft://app/index.html") else { return }
2741
+ guard !loadedBundledFallback, config.devServerURL != nil else { return }
2068
2742
  loadedBundledFallback = true
2069
- // Kept for the way back: `returnFromBundledFallback` has no view of
2070
- // its own to reload.
2071
- self.webView = webView
2072
- webView.load(URLRequest(url: bundledURL))
2743
+ loadBundledPage(in: webView)
2073
2744
  }
2074
2745
 
2075
2746
  /// Leave the bundled copy for the remote origin again (#252).
@@ -2088,14 +2759,40 @@ struct CraftWebView: UIViewRepresentable {
2088
2759
  let remote = config.devServerURL.flatMap(URL.init(string:)) else { return }
2089
2760
  loadedBundledFallback = false
2090
2761
  print("Craft: \(reason); loading \(remote) again instead of the bundled copy")
2091
- webView.load(URLRequest(url: remote))
2762
+ webView.load(config.request(for: remote))
2092
2763
  }
2093
2764
 
2094
2765
  @objc private func appWillEnterForeground(_ notification: Notification) {
2766
+ if recoverOnForeground, let webView {
2767
+ // The page is about to be rebuilt; there is no one to resume.
2768
+ backgroundedAt = nil
2769
+ recoverFromTerminatedContent(webView)
2770
+ return
2771
+ }
2772
+ if let backgroundedAt {
2773
+ self.backgroundedAt = nil
2774
+ sendToWeb("craftResume", data: ["backgroundedMs": Int(Date().timeIntervalSince(backgroundedAt) * 1000)])
2775
+ }
2095
2776
  returnFromBundledFallback(because: "the app returned to the foreground")
2096
2777
  }
2097
2778
 
2098
- 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 {
2099
2796
  guard let url = url else { return false }
2100
2797
  return isTrustedOrigin(scheme: url.scheme ?? "", host: url.host ?? "", port: url.port ?? 0)
2101
2798
  }
@@ -2151,7 +2848,91 @@ struct CraftWebView: UIViewRepresentable {
2151
2848
  if drawn > highestCallbackId { highestCallbackId = drawn }
2152
2849
  }
2153
2850
 
2154
- 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 {
2155
2936
  let laContext = LAContext()
2156
2937
  var biometricAvailable = false
2157
2938
  if laContext.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) {
@@ -3698,7 +4479,12 @@ struct CraftWebView: UIViewRepresentable {
3698
4479
  var personFacing = {
3699
4480
  requestHealthAuthorization: true,
3700
4481
  requestPermission: true,
3701
- signInWithApple: true
4482
+ signInWithApple: true,
4483
+ dialogAlert: true,
4484
+ dialogConfirm: true,
4485
+ dialogActionSheet: true,
4486
+ contextMenuShow: true,
4487
+ browserOpen: true
3702
4488
  };
3703
4489
  craft._invoke = function(action, payload) {
3704
4490
  var self = craft;
@@ -3733,12 +4519,19 @@ struct CraftWebView: UIViewRepresentable {
3733
4519
  throw error;
3734
4520
  });
3735
4521
  }
4522
+ // Each kind reaches the generator UIKit has for it: a
4523
+ // notification plays the success, warning or error pattern
4524
+ // and a selection the picker's detent. Both used to be
4525
+ // impacts of some weight, which is not what either feels like.
3736
4526
  craft.haptics = {
3737
4527
  impact: function(style) { return hapticFeedback(craft.haptic(style || 'medium')); },
3738
4528
  notification: function(type) {
3739
- return hapticFeedback(craft.haptic(type === 'error' ? 'heavy' : type === 'warning' ? 'medium' : 'light'));
4529
+ return hapticFeedback(craft.haptic(type === 'error' || type === 'warning' ? type : 'success'));
3740
4530
  },
3741
- 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})); },
3742
4535
  vibrate: function(pattern) { return hapticFeedback(craft.vibrate(pattern || [])); }
3743
4536
  };
3744
4537
  craft.permissions = {
@@ -3910,6 +4703,146 @@ struct CraftWebView: UIViewRepresentable {
3910
4703
  }
3911
4704
  })(window.craft);
3912
4705
 
4706
+ // The system's own UI, driven by the page: dialogs, a menu at a
4707
+ // point, the in-app browser and web sign-in, SF Symbols, and the
4708
+ // chrome around the page (status bar, under-page colour, keyboard
4709
+ // bar, pull to refresh).
4710
+ //
4711
+ // Every call settles, and none throws: a failure answers with
4712
+ // what "nothing happened" means for that call (null, false, a
4713
+ // cancelled browser), the way the same calls answer in a browser.
4714
+ (function installNativeUI(craft) {
4715
+ function settle(answer, fallback) {
4716
+ return answer.then(function(value) {
4717
+ return value === undefined || value === null ? fallback : value;
4718
+ }, function(error) {
4719
+ if (craft._debug) console.warn('[Craft]', error && error.message);
4720
+ return fallback;
4721
+ });
4722
+ }
4723
+ function options(value) {
4724
+ return typeof value === 'string' ? {title: value} : Object.assign({}, value || {});
4725
+ }
4726
+ // A rect, or an element (or anything with
4727
+ // getBoundingClientRect), in the viewport's CSS pixels.
4728
+ function rect(anchor) {
4729
+ if (!anchor) return null;
4730
+ if (typeof anchor.getBoundingClientRect === 'function') anchor = anchor.getBoundingClientRect();
4731
+ var x = Number(anchor.x != null ? anchor.x : anchor.left);
4732
+ var y = Number(anchor.y != null ? anchor.y : anchor.top);
4733
+ if (!isFinite(x) || !isFinite(y)) return null;
4734
+ return {x: x, y: y, width: Number(anchor.width) || 0, height: Number(anchor.height) || 0};
4735
+ }
4736
+ craft.dialog = {
4737
+ alert: function(value) {
4738
+ return settle(craft._invoke('dialogAlert', options(value)), null).then(function() {});
4739
+ },
4740
+ confirm: function(value) {
4741
+ return settle(craft._invoke('dialogConfirm', options(value)), false).then(function(confirmed) { return confirmed === true; });
4742
+ },
4743
+ actionSheet: function(value) {
4744
+ var payload = options(value);
4745
+ payload.anchor = rect(payload.anchor);
4746
+ payload.actions = (payload.actions || []).map(function(action) {
4747
+ return {id: String(action.id), title: String(action.title), style: action.style || 'default'};
4748
+ });
4749
+ return settle(craft._invoke('dialogActionSheet', payload), null);
4750
+ }
4751
+ };
4752
+ craft.contextMenu = {
4753
+ show: function(value) {
4754
+ var payload = Object.assign({}, value || {});
4755
+ payload.anchor = rect(payload.anchor);
4756
+ payload.items = (payload.items || []).map(function(item) {
4757
+ return {id: String(item.id), title: String(item.title), symbol: item.symbol || null, destructive: item.destructive === true, disabled: item.disabled === true};
4758
+ });
4759
+ return settle(craft._invoke('contextMenuShow', payload), null);
4760
+ }
4761
+ };
4762
+ craft.browser = {
4763
+ open: function(url, value) {
4764
+ value = value || {};
4765
+ return settle(craft._invoke('browserOpen', {
4766
+ url: String(url),
4767
+ mode: value.mode === 'auth' ? 'auth' : 'safari',
4768
+ callbackScheme: value.callbackScheme || null
4769
+ }), {cancelled: true});
4770
+ }
4771
+ };
4772
+ // The same symbol at the same size is drawn once per page.
4773
+ var symbolCache = {};
4774
+ craft.symbols = {
4775
+ image: function(name, value) {
4776
+ value = value || {};
4777
+ var payload = {name: String(name), pointSize: value.pointSize, weight: value.weight, color: value.color, scale: value.scale};
4778
+ var key = JSON.stringify(payload);
4779
+ if (!symbolCache[key]) {
4780
+ symbolCache[key] = settle(craft._invoke('symbolImage', payload), null).then(function(image) {
4781
+ if (image === null) delete symbolCache[key];
4782
+ return image;
4783
+ });
4784
+ }
4785
+ return symbolCache[key];
4786
+ }
4787
+ };
4788
+ craft.statusBar = {
4789
+ setStyle: function(style) {
4790
+ return settle(craft._invoke('statusBarSetStyle', {style: style === 'light' || style === 'dark' ? style : 'default'}), false);
4791
+ }
4792
+ };
4793
+ craft.chrome = {
4794
+ setUnderPageColor: function(color) {
4795
+ return settle(craft._invoke('chromeSetUnderPageColor', {color: String(color)}), false);
4796
+ },
4797
+ setKeyboardAccessory: function(visible) {
4798
+ return settle(craft._invoke('chromeSetKeyboardAccessory', {visible: visible !== false}), false);
4799
+ }
4800
+ };
4801
+ // Text size, Reduce Motion, Reduce Transparency and Light or
4802
+ // Dark, as the shell last reported them.
4803
+ Object.defineProperty(craft, 'appearance', {
4804
+ configurable: true,
4805
+ enumerable: true,
4806
+ get: function() { return window.__craftAppearance || null; }
4807
+ });
4808
+ // Background time: answer a craftSilentPush or a
4809
+ // craftBackgroundRefresh once its work is done, with the
4810
+ // event's id, or with none for everything pending.
4811
+ craft.background = {
4812
+ complete: function(ok, id) {
4813
+ return settle(craft._invoke('backgroundComplete', {ok: ok !== false, id: id || null}), false);
4814
+ },
4815
+ onRefresh: function(callback) {
4816
+ var listener = function(e) { callback(e.detail || {}); };
4817
+ window.addEventListener('craftBackgroundRefresh', listener);
4818
+ return function() { window.removeEventListener('craftBackgroundRefresh', listener); };
4819
+ },
4820
+ onSilentPush: function(callback) {
4821
+ var listener = function(e) { callback(e.detail || {}); };
4822
+ window.addEventListener('craftSilentPush', listener);
4823
+ return function() { window.removeEventListener('craftSilentPush', listener); };
4824
+ }
4825
+ };
4826
+ craft.onAppearanceChange = function(callback) {
4827
+ var listener = function(e) { callback(e.detail); };
4828
+ window.addEventListener('craftAppearance', listener);
4829
+ return function() { window.removeEventListener('craftAppearance', listener); };
4830
+ };
4831
+ craft.refresh = {
4832
+ enable: function(value) {
4833
+ return settle(craft._invoke('refreshEnable', {tintColor: (value && value.tintColor) || null}), false);
4834
+ },
4835
+ disable: function() { return settle(craft._invoke('refreshDisable'), false); },
4836
+ end: function() { return settle(craft._invoke('refreshEnd'), false); },
4837
+ // A pull of the native control; returns the unsubscribe.
4838
+ onRefresh: function(callback) {
4839
+ var listener = function() { callback(); };
4840
+ window.addEventListener('craftRefresh', listener);
4841
+ return function() { window.removeEventListener('craftRefresh', listener); };
4842
+ }
4843
+ };
4844
+ })(window.craft);
4845
+
3913
4846
  // The reply route for actions the Zig runtime serves.
3914
4847
  //
3915
4848
  // Zig owns one wire format and calls these two functions by name;
@@ -3931,15 +4864,39 @@ struct CraftWebView: UIViewRepresentable {
3931
4864
  window.craft._rejectCallback('cb_' + ctx.id, ctx.message, ctx.code);
3932
4865
  };
3933
4866
 
3934
- // Dispatch ready event
3935
- window.dispatchEvent(new CustomEvent('craftReady', {detail: window.craft}));
3936
- 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
+ }
3937
4899
  """
3938
- webView?.evaluateJavaScript(script)
3939
-
3940
- // Mark DeepLinkManager as ready
3941
- DeepLinkManager.shared.setReady()
3942
- CraftEventManager.shared.setReady()
3943
4900
  }
3944
4901
 
3945
4902
  // MARK: - Callback Helpers
@@ -4071,8 +5028,7 @@ struct CraftWebView: UIViewRepresentable {
4071
5028
 
4072
5029
  // MARK: - Share
4073
5030
  private func share(options: [String: Any], callbackId: String?) {
4074
- guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
4075
- let rootVC = windowScene.windows.first?.rootViewController else {
5031
+ guard CraftPresenter.topViewController() != nil else {
4076
5032
  rejectCallback(callbackId, error: "Unable to present the share sheet")
4077
5033
  return
4078
5034
  }
@@ -4098,7 +5054,11 @@ struct CraftWebView: UIViewRepresentable {
4098
5054
  self.resolveCallback(callbackId, result: completed)
4099
5055
  }
4100
5056
  }
4101
- 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"]))
4102
5062
  }
4103
5063
 
4104
5064
  // MARK: - Camera & Photo Library
@@ -4108,8 +5068,7 @@ struct CraftWebView: UIViewRepresentable {
4108
5068
  self.rejectCallback(self.pendingCallbackId, error: "Camera not available")
4109
5069
  return
4110
5070
  }
4111
- guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
4112
- let rootVC = windowScene.windows.first?.rootViewController else { return }
5071
+ guard let rootVC = CraftPresenter.topViewController() else { return }
4113
5072
 
4114
5073
  let picker = UIImagePickerController()
4115
5074
  picker.sourceType = .camera
@@ -4120,8 +5079,7 @@ struct CraftWebView: UIViewRepresentable {
4120
5079
 
4121
5080
  private func pickImage() {
4122
5081
  DispatchQueue.main.async {
4123
- guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
4124
- let rootVC = windowScene.windows.first?.rootViewController else { return }
5082
+ guard let rootVC = CraftPresenter.topViewController() else { return }
4125
5083
 
4126
5084
  let picker = UIImagePickerController()
4127
5085
  picker.sourceType = .photoLibrary
@@ -5342,8 +6300,7 @@ struct CraftWebView: UIViewRepresentable {
5342
6300
  // MARK: - QR/Barcode Scanner
5343
6301
  private func scanQRCode(callbackId: String?) {
5344
6302
  DispatchQueue.main.async {
5345
- guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
5346
- let rootVC = windowScene.windows.first?.rootViewController else { return }
6303
+ guard let rootVC = CraftPresenter.topViewController() else { return }
5347
6304
 
5348
6305
  if #available(iOS 16.0, *) {
5349
6306
  let scannerVC = DataScannerViewController(
@@ -5365,8 +6322,7 @@ struct CraftWebView: UIViewRepresentable {
5365
6322
  // MARK: - File Picker
5366
6323
  private func pickFile(types: [String]?, callbackId: String?) {
5367
6324
  DispatchQueue.main.async {
5368
- guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
5369
- let rootVC = windowScene.windows.first?.rootViewController else { return }
6325
+ guard let rootVC = CraftPresenter.topViewController() else { return }
5370
6326
 
5371
6327
  var allowedTypes: [UTType] = [.item]
5372
6328
  if let types = types {
@@ -5510,8 +6466,7 @@ struct CraftWebView: UIViewRepresentable {
5510
6466
  return
5511
6467
  }
5512
6468
 
5513
- guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
5514
- let rootVC = windowScene.windows.first?.rootViewController else { return }
6469
+ guard let rootVC = CraftPresenter.topViewController() else { return }
5515
6470
 
5516
6471
  let picker = UIImagePickerController()
5517
6472
  picker.sourceType = .camera
@@ -6270,8 +7225,7 @@ struct CraftWebView: UIViewRepresentable {
6270
7225
 
6271
7226
  private func openPDF(source: String, page: Int, callbackId: String?) {
6272
7227
  DispatchQueue.main.async {
6273
- guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
6274
- let rootVC = windowScene.windows.first?.rootViewController else {
7228
+ guard let rootVC = CraftPresenter.topViewController() else {
6275
7229
  self.rejectCallback(callbackId, error: "No root view controller")
6276
7230
  return
6277
7231
  }
@@ -6348,8 +7302,7 @@ struct CraftWebView: UIViewRepresentable {
6348
7302
  // MARK: - Contacts Picker
6349
7303
  private func pickContact(multiple: Bool, callbackId: String?) {
6350
7304
  DispatchQueue.main.async {
6351
- guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
6352
- let rootVC = windowScene.windows.first?.rootViewController else {
7305
+ guard let rootVC = CraftPresenter.topViewController() else {
6353
7306
  self.rejectCallback(callbackId, error: "No root view controller")
6354
7307
  return
6355
7308
  }
@@ -7218,6 +8171,72 @@ extension URL {
7218
8171
  //
7219
8172
  // The end of an utterance is what `craft.speech.speak()` waits for. Hopped to
7220
8173
  // main because every piece of speech state is touched there and nowhere else.
8174
+ // MARK: - The page's own windows and dialogs
8175
+ //
8176
+ // WebKit hands these to the UI delegate and, with none set, drops them: an
8177
+ // alert() that never showed, a confirm() that answered false without asking,
8178
+ // a target=_blank link that did nothing when tapped.
8179
+ extension CraftWebView.Coordinator: WKUIDelegate {
8180
+ /// A dialog's title: none for the app's own page, which speaks as the app,
8181
+ /// and the origin's host for anything embedded in it, so a frame cannot
8182
+ /// pass its alert off as the app's.
8183
+ private func dialogTitle(for frame: WKFrameInfo) -> String? {
8184
+ trusts(frame.securityOrigin) ? nil : frame.securityOrigin.host
8185
+ }
8186
+
8187
+ func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
8188
+ let alert = UIAlertController(title: dialogTitle(for: frame), message: message, preferredStyle: .alert)
8189
+ alert.addAction(UIAlertAction(title: CraftPresenter.systemString("OK"), style: .default) { _ in completionHandler() })
8190
+ if !CraftPresenter.present(alert, from: webView) { completionHandler() }
8191
+ }
8192
+
8193
+ func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
8194
+ let alert = UIAlertController(title: dialogTitle(for: frame), message: message, preferredStyle: .alert)
8195
+ alert.addAction(UIAlertAction(title: CraftPresenter.systemString("Cancel"), style: .cancel) { _ in completionHandler(false) })
8196
+ alert.addAction(UIAlertAction(title: CraftPresenter.systemString("OK"), style: .default) { _ in completionHandler(true) })
8197
+ if !CraftPresenter.present(alert, from: webView) { completionHandler(false) }
8198
+ }
8199
+
8200
+ func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) {
8201
+ let alert = UIAlertController(title: dialogTitle(for: frame), message: prompt, preferredStyle: .alert)
8202
+ alert.addTextField { field in field.text = defaultText }
8203
+ alert.addAction(UIAlertAction(title: CraftPresenter.systemString("Cancel"), style: .cancel) { _ in completionHandler(nil) })
8204
+ alert.addAction(UIAlertAction(title: CraftPresenter.systemString("OK"), style: .default) { [weak alert] _ in
8205
+ completionHandler(alert?.textFields?.first?.text ?? "")
8206
+ })
8207
+ if !CraftPresenter.present(alert, from: webView) { completionHandler(nil) }
8208
+ }
8209
+
8210
+ /// target=_blank and window.open. The app's own pages open in this web
8211
+ /// view, the way a native app pushes a screen rather than opening a
8212
+ /// second app. Anywhere else opens in Safari's in-app view, over the app
8213
+ /// and dismissed back to it, and a non-web link goes to whatever app
8214
+ /// handles it. A second web view is never made: there would be nothing to
8215
+ /// show it in.
8216
+ func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
8217
+ guard let url = navigationAction.request.url else { return nil }
8218
+ if isTrustedURL(url) {
8219
+ webView.load(navigationAction.request)
8220
+ } else if url.scheme == "https" || url.scheme == "http" {
8221
+ let safari = SFSafariViewController(url: url)
8222
+ safari.dismissButtonStyle = .close
8223
+ if !CraftPresenter.present(safari, from: webView) { UIApplication.shared.open(url) }
8224
+ } else {
8225
+ UIApplication.shared.open(url)
8226
+ }
8227
+ return nil
8228
+ }
8229
+
8230
+ /// getUserMedia from the app's own page is granted without WebKit's own
8231
+ /// per-page prompt: the app already holds (or will ask for) the camera
8232
+ /// and microphone permission iOS itself shows, and asking twice for one
8233
+ /// thing reads as a web page. Anything embedded still gets the prompt.
8234
+ @available(iOS 15.0, *)
8235
+ func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType, decisionHandler: @escaping (WKPermissionDecision) -> Void) {
8236
+ decisionHandler(trusts(origin) && trusts(frame.securityOrigin) ? .grant : .prompt)
8237
+ }
8238
+ }
8239
+
7221
8240
  extension CraftWebView.Coordinator: AVSpeechSynthesizerDelegate {
7222
8241
  func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
7223
8242
  DispatchQueue.main.async { self.speechDidEnd(utterance, spoken: true) }