craft-native 0.0.123 → 0.0.125

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