ruby_everywhere 0.1.15 → 0.3.0

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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/bridge/README.md +105 -2
  3. data/bridge/everywhere/bridge.js +1131 -9
  4. data/bridge/everywhere/native.css +203 -0
  5. data/bridge/package.json +6 -3
  6. data/lib/everywhere/builders/ios.rb +396 -0
  7. data/lib/everywhere/cli.rb +2 -0
  8. data/lib/everywhere/commands/build.rb +17 -1
  9. data/lib/everywhere/commands/clean.rb +19 -10
  10. data/lib/everywhere/commands/dev.rb +182 -19
  11. data/lib/everywhere/commands/doctor.rb +45 -3
  12. data/lib/everywhere/commands/icon.rb +14 -0
  13. data/lib/everywhere/commands/install.rb +37 -6
  14. data/lib/everywhere/commands/logs.rb +56 -0
  15. data/lib/everywhere/commands/platform/build.rb +3 -3
  16. data/lib/everywhere/commands/platform/runner.rb +1 -1
  17. data/lib/everywhere/commands/release.rb +1 -1
  18. data/lib/everywhere/commands/shell_dir.rb +11 -4
  19. data/lib/everywhere/config.rb +469 -1
  20. data/lib/everywhere/engine.rb +42 -1
  21. data/lib/everywhere/icon.rb +50 -0
  22. data/lib/everywhere/log_filter.rb +28 -2
  23. data/lib/everywhere/mobile_config_endpoint.rb +88 -0
  24. data/lib/everywhere/mobile_configs_controller.rb +64 -0
  25. data/lib/everywhere/native_helper.rb +352 -0
  26. data/lib/everywhere/paths.rb +41 -0
  27. data/lib/everywhere/raster.rb +17 -0
  28. data/lib/everywhere/shellout.rb +3 -1
  29. data/lib/everywhere/simulator.rb +74 -0
  30. data/lib/everywhere/ui.rb +18 -0
  31. data/lib/everywhere/version.rb +1 -1
  32. data/support/mobile/ios/App/App.xcconfig +6 -0
  33. data/support/mobile/ios/App/AppDelegate.swift +164 -0
  34. data/support/mobile/ios/App/Assets.xcassets/AccentColor.colorset/Contents.json +20 -0
  35. data/support/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
  36. data/support/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
  37. data/support/mobile/ios/App/Assets.xcassets/Contents.json +6 -0
  38. data/support/mobile/ios/App/Assets.xcassets/LaunchBackground.colorset/Contents.json +38 -0
  39. data/support/mobile/ios/App/Base.lproj/LaunchScreen.storyboard +32 -0
  40. data/support/mobile/ios/App/Bridge/BiometricsComponent.swift +276 -0
  41. data/support/mobile/ios/App/Bridge/HapticsComponent.swift +47 -0
  42. data/support/mobile/ios/App/Bridge/MenuComponent.swift +192 -0
  43. data/support/mobile/ios/App/Bridge/NotificationComponent.swift +56 -0
  44. data/support/mobile/ios/App/Bridge/PermissionsComponent.swift +142 -0
  45. data/support/mobile/ios/App/Bridge/StorageComponent.swift +63 -0
  46. data/support/mobile/ios/App/ErrorViewController.swift +64 -0
  47. data/support/mobile/ios/App/EverywhereConfig.swift +289 -0
  48. data/support/mobile/ios/App/EverywhereHost.swift +34 -0
  49. data/support/mobile/ios/App/Extensions/EverywhereExtensions.swift +32 -0
  50. data/support/mobile/ios/App/Info.plist +28 -0
  51. data/support/mobile/ios/App/Resources/everywhere.json +8 -0
  52. data/support/mobile/ios/App/Resources/path-configuration.json +19 -0
  53. data/support/mobile/ios/App/SceneDelegate.swift +484 -0
  54. data/support/mobile/ios/App.xcodeproj/project.pbxproj +458 -0
  55. data/support/mobile/ios/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  56. data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +14 -0
  57. data/support/mobile/ios/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme +77 -0
  58. data/support/mobile/ios/NativeExtensions/Package.swift +26 -0
  59. data/support/mobile/ios/NativeExtensions/Sources/NativeExtensions/Exports.swift +5 -0
  60. data/support/mobile/ios/README.md +73 -0
  61. metadata +37 -1
@@ -0,0 +1,484 @@
1
+ import HotwireNative
2
+ import UIKit
3
+ import WebKit
4
+
5
+ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
6
+ var window: UIWindow?
7
+
8
+ private let config = EverywhereConfig.shared
9
+
10
+ /// Fingerprint (title|path|icon per tab) of the tabs currently on screen,
11
+ /// so a path-config refresh only rebuilds when the tabs actually change.
12
+ /// Empty string = single-navigator mode.
13
+ private var loadedTabsFingerprint: String?
14
+
15
+ /// The current root — exactly one is non-nil, matching the fingerprint.
16
+ private var navigator: Navigator?
17
+ private var tabBarController: HotwireTabBarController?
18
+
19
+ /// Cold-launch splash, removed when the first request finishes. A whole
20
+ /// window (not a subview of the main one): launch swaps the main
21
+ /// window's rootViewController (single nav → tab bar), and window
22
+ /// subviews don't survive that swap — an overlay subview is gone before
23
+ /// the first frame ever reaches the screen.
24
+ private var splashWindow: UIWindow?
25
+ private var splashIsCustom = false
26
+
27
+ /// Custom splashes stay up at least config.splashMinimumDisplay. Against
28
+ /// a fast (local) server the first request finishes before the first
29
+ /// frame is even presented, and a branded splash that flashes for 50ms
30
+ /// reads as a glitch — while the default spinner vanishes instantly.
31
+ private var splashShownAt: Date = .distantPast
32
+ private var splashDismissScheduled = false
33
+
34
+ /// Last badge per tab path. Pages usually set badges from the first page
35
+ /// load — often before the auth-gated tab bar exists — so badges are
36
+ /// stored here and (re)applied whenever the tab bar is (re)built.
37
+ private var tabBadges: [String: String] = [:]
38
+
39
+ /// The path the shell treats as the "reset the app" signal.
40
+ private static let resetPath = "/everywhere/reset"
41
+
42
+ /// A universal link that arrived before the root existed (cold launch), held
43
+ /// until the first navigator/tab bar is built, then routed.
44
+ private var pendingUniversalLinkURL: URL?
45
+
46
+ func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
47
+ guard let windowScene = scene as? UIWindowScene else { return }
48
+
49
+ let window = UIWindow(windowScene: windowScene)
50
+ if let tint = config.tint {
51
+ window.tintColor = tint
52
+ }
53
+ self.window = window
54
+
55
+ Hotwire.config.pathConfiguration.delegate = self
56
+
57
+ window.makeKeyAndVisible()
58
+ showSplash(in: windowScene)
59
+
60
+ NotificationCenter.default.addObserver(
61
+ self, selector: #selector(reloadPathConfiguration),
62
+ name: .everywhereReloadConfig, object: nil)
63
+ NotificationCenter.default.addObserver(
64
+ self, selector: #selector(handleReset(_:)),
65
+ name: .everywhereResetApp, object: nil)
66
+ NotificationCenter.default.addObserver(
67
+ self, selector: #selector(handleTabBadge(_:)),
68
+ name: .everywhereSetTabBadge, object: nil)
69
+ NotificationCenter.default.addObserver(
70
+ self, selector: #selector(handleSetInstance(_:)),
71
+ name: .everywhereSetInstance, object: nil)
72
+ NotificationCenter.default.addObserver(
73
+ self, selector: #selector(handleClearInstance(_:)),
74
+ name: .everywhereClearInstance, object: nil)
75
+ NotificationCenter.default.addObserver(
76
+ self, selector: #selector(handleNativeVisit(_:)),
77
+ name: .everywhereNativeVisit, object: nil)
78
+
79
+ // Cookie hydration BEFORE the first visit: after a force-kill relaunch
80
+ // (iOS kills the app when a Settings permission changes) the first
81
+ // request can beat the web view's cookie store loading from disk and
82
+ // render a signed-in user the login page. getAllCookies forces
83
+ // hydration — the splash covers the wait — and the same pass mirrors
84
+ // cookies for the auth-aware config fetch, which adds the tab bar for
85
+ // a signed-in user.
86
+ // A universal link that launched the app (cold start): capture it now,
87
+ // route it once the root exists (below).
88
+ captureUniversalLink(from: connectionOptions.userActivities)
89
+
90
+ syncWebViewCookiesToSharedStore { [weak self] in
91
+ guard let self else { return }
92
+ self.applyPathConfiguration()
93
+ self.routePendingUniversalLink()
94
+ Hotwire.loadPathConfiguration(from: EverywhereConfig.pathConfigurationSources(serverOnly: true))
95
+ }
96
+ }
97
+
98
+ // MARK: Universal links (deep linking)
99
+
100
+ /// A universal link tapped while the app is running: route it straight to
101
+ /// its in-app path. Links to other sites fall through to the system.
102
+ func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
103
+ guard let url = webpageURL(from: userActivity) else { return }
104
+ activeNavigator.route(config.url(forPath: config.pathAndQuery(of: url)))
105
+ }
106
+
107
+ private func captureUniversalLink(from activities: Set<NSUserActivity>) {
108
+ guard let activity = activities.first(where: { $0.activityType == NSUserActivityTypeBrowsingWeb }),
109
+ let url = webpageURL(from: activity)
110
+ else { return }
111
+ pendingUniversalLinkURL = config.url(forPath: config.pathAndQuery(of: url))
112
+ }
113
+
114
+ /// The webpage URL of a browsing-web activity, but only if this app claims
115
+ /// its host (the associated domains) — never route someone else's link.
116
+ private func webpageURL(from userActivity: NSUserActivity) -> URL? {
117
+ guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
118
+ let url = userActivity.webpageURL,
119
+ config.handlesUniversalLink(url)
120
+ else { return nil }
121
+ return url
122
+ }
123
+
124
+ private func routePendingUniversalLink() {
125
+ guard let url = pendingUniversalLinkURL else { return }
126
+ pendingUniversalLinkURL = nil
127
+ activeNavigator.route(url)
128
+ }
129
+
130
+ /// Refetch the server path configuration on every foreground — so a config
131
+ /// change lands without a relaunch (the dev endpoint is uncached). Uses the
132
+ /// server-only sources: the auth-aware source of truth, no baked flash.
133
+ func sceneWillEnterForeground(_ scene: UIScene) {
134
+ reloadPathConfiguration()
135
+ }
136
+
137
+ @objc private func reloadPathConfiguration() {
138
+ loadPathConfiguration(serverOnly: true)
139
+ }
140
+
141
+ /// Load the path configuration, first mirroring the web view's cookies into
142
+ /// the shared store. The config's tabs are auth-gated server-side, and the
143
+ /// framework fetches them with `URLSession.shared` (backed by
144
+ /// `HTTPCookieStorage.shared`) — a DIFFERENT cookie jar from the one the
145
+ /// web view uses for the session cookie. Without this mirror the config
146
+ /// request is unauthenticated, so a just-signed-in user gets no tabs.
147
+ private func loadPathConfiguration(serverOnly: Bool) {
148
+ syncWebViewCookiesToSharedStore {
149
+ Hotwire.loadPathConfiguration(from: EverywhereConfig.pathConfigurationSources(serverOnly: serverOnly))
150
+ }
151
+ }
152
+
153
+ private func syncWebViewCookiesToSharedStore(_ then: @escaping () -> Void) {
154
+ WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
155
+ // True sync, not additive: a session cookie the web view no longer
156
+ // has (e.g. lost when iOS killed the app for a Settings permission
157
+ // change before the web view persisted it) must not linger here and
158
+ // keep authenticating the config fetch — that's how the app ends up
159
+ // signed out with a signed-in tab bar.
160
+ HTTPCookieStorage.shared.removeCookies(since: .distantPast)
161
+ for cookie in cookies {
162
+ HTTPCookieStorage.shared.setCookie(cookie)
163
+ }
164
+ then() // getAllCookies delivers its completion on the main queue
165
+ }
166
+ }
167
+
168
+ // MARK: Reset (auth changes)
169
+
170
+ /// Reset triggered by `Everywhere.resetApp()` from the page (the reset
171
+ /// page's fallback, or app code) — same effect as intercepting the URL.
172
+ @objc private func handleReset(_ note: Notification) {
173
+ let target = (note.userInfo?["to"] as? String).map { config.url(forPath: $0) } ?? config.startURL
174
+ resetApp(to: target)
175
+ }
176
+
177
+ /// Full app reset for an auth change. Deterministic and self-contained —
178
+ /// it does NOT depend on the reset page's JavaScript running:
179
+ /// 1. clear cached web content (no stale authed pages),
180
+ /// 2. drop to a single navigator on `target` immediately (a correct
181
+ /// baseline for any auth state — no tab bar),
182
+ /// 3. refresh the auth-aware server config, which rebuilds the tab bar
183
+ /// back in if (and only if) the user is now signed in.
184
+ private func resetApp(to target: URL) {
185
+ clearWebContentCache()
186
+ rebuildRoot(entries: [], routeTo: target)
187
+ loadedTabsFingerprint = "" // matches the empty tab set we just built
188
+ loadPathConfiguration(serverOnly: true)
189
+ }
190
+
191
+ // MARK: Native-code navigation
192
+
193
+ /// `everywhereVisit(path)` from extension code: route through the active
194
+ /// navigator, so the visit gets the full path-configuration treatment
195
+ /// (modals, native screens, reset interception) like any web-driven visit.
196
+ @objc private func handleNativeVisit(_ note: Notification) {
197
+ guard let path = note.userInfo?["path"] as? String else { return }
198
+ let url = path.hasPrefix("/") ? config.url(forPath: path) : URL(string: path)
199
+ guard let url else { return }
200
+ #if DEBUG
201
+ NSLog("everywhereVisit: %@", url.absoluteString)
202
+ #endif
203
+ activeNavigator.route(url)
204
+ }
205
+
206
+ // MARK: Instance switching (multi-instance apps)
207
+
208
+ /// `Everywhere.instance.set(url)`: persist the picked instance as the
209
+ /// app's root, then full-reset onto it. Ignored unless everywhere.yml
210
+ /// opts in (`remote.instances: true`) — for every other app, a page that
211
+ /// posts setInstance must not be able to re-root the shell. Persisting
212
+ /// happens BEFORE the reset target resolves: `config.url(forPath:)` must
213
+ /// build against the new root.
214
+ @objc private func handleSetInstance(_ note: Notification) {
215
+ guard config.remoteInstances == true,
216
+ let raw = note.userInfo?["url"] as? String,
217
+ let url = URL(string: raw),
218
+ EverywhereConfig.setInstanceURL(url)
219
+ else { return }
220
+
221
+ resetApp(to: resetTarget(fromPath: note.userInfo?["to"] as? String))
222
+ }
223
+
224
+ /// `Everywhere.instance.clear()`: drop the override and reset back into
225
+ /// the stamped root — the instance picker.
226
+ @objc private func handleClearInstance(_ note: Notification) {
227
+ guard config.remoteInstances == true else { return }
228
+ EverywhereConfig.setInstanceURL(nil)
229
+ resetApp(to: resetTarget(fromPath: note.userInfo?["to"] as? String))
230
+ }
231
+
232
+ private func resetTarget(fromPath path: String?) -> URL {
233
+ path.map { config.url(forPath: $0) } ?? config.startURL
234
+ }
235
+
236
+ /// The `to` path carried on a /everywhere/reset URL, resolved against the
237
+ /// app's root (falls back to the configured start URL).
238
+ private func resetTarget(from url: URL) -> URL {
239
+ let to = URLComponents(url: url, resolvingAgainstBaseURL: false)?
240
+ .queryItems?.first { $0.name == "to" }?.value
241
+ return to.map { config.url(forPath: $0) } ?? config.startURL
242
+ }
243
+
244
+ /// Drop cached responses and WKWebView content caches so a page isn't
245
+ /// served from its pre-auth state. Cookies are left intact — the server
246
+ /// has already invalidated the session on sign-out, and we must keep the
247
+ /// fresh cookie on sign-in.
248
+ private func clearWebContentCache() {
249
+ URLCache.shared.removeAllCachedResponses()
250
+ let types: Set<String> = [
251
+ WKWebsiteDataTypeDiskCache,
252
+ WKWebsiteDataTypeMemoryCache,
253
+ WKWebsiteDataTypeOfflineWebApplicationCache
254
+ ]
255
+ WKWebsiteDataStore.default().removeData(ofTypes: types, modifiedSince: .distantPast) {}
256
+ }
257
+
258
+ // MARK: Tabs / root
259
+
260
+ private func applyPathConfiguration() {
261
+ let entries = tabEntries
262
+ let fingerprint = entries
263
+ .map { "\($0.title)|\($0.path)|\($0.icon)" }
264
+ .joined(separator: "\n")
265
+
266
+ guard loadedTabsFingerprint != fingerprint else { return }
267
+ loadedTabsFingerprint = fingerprint
268
+ rebuildRoot(entries: entries, routeTo: nil)
269
+ }
270
+
271
+ private func rebuildRoot(entries: [TabEntry], routeTo: URL?) {
272
+ if entries.isEmpty {
273
+ let nav = makeNavigator()
274
+ navigator = nav
275
+ tabBarController = nil
276
+ window?.rootViewController = nav.rootViewController
277
+ if let routeTo {
278
+ nav.route(routeTo)
279
+ } else {
280
+ nav.start()
281
+ }
282
+ } else {
283
+ let controller = HotwireTabBarController(navigatorDelegate: self, lazyLoadTabs: config.lazyLoadsTabs)
284
+ tabBarController = controller
285
+ navigator = nil
286
+ window?.rootViewController = controller
287
+ controller.load(entries.map(hotwireTab(for:)))
288
+ if let routeTo {
289
+ controller.route(routeTo)
290
+ }
291
+ tabBadges.forEach { applyTabBadge(path: $0.key, value: $0.value) }
292
+ }
293
+ }
294
+
295
+ // MARK: Splash
296
+
297
+ /// Continues the static launch screen (same background) until the first
298
+ /// request finishes — no white flash while the first page loads over the
299
+ /// network. An app-provided splash (everywhere.yml native.ios.splash)
300
+ /// replaces the default spinner wholesale.
301
+ private func showSplash(in scene: UIWindowScene) {
302
+ let custom = EverywhereExtensions.splashViewController()
303
+ splashIsCustom = custom != nil
304
+
305
+ let overlay = UIWindow(windowScene: scene)
306
+ overlay.windowLevel = .normal + 1
307
+ overlay.rootViewController = custom ?? defaultSplashController()
308
+ overlay.isHidden = false
309
+ splashWindow = overlay
310
+ splashShownAt = Date()
311
+
312
+ // Safety net: never trap the user behind the splash.
313
+ DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in
314
+ self?.dismissSplash()
315
+ }
316
+ }
317
+
318
+ private func defaultSplashController() -> UIViewController {
319
+ let controller = UIViewController()
320
+ controller.view.backgroundColor = config.background ?? .systemBackground
321
+
322
+ let spinner = UIActivityIndicatorView(style: .medium)
323
+ spinner.translatesAutoresizingMaskIntoConstraints = false
324
+ spinner.startAnimating()
325
+ controller.view.addSubview(spinner)
326
+ NSLayoutConstraint.activate([
327
+ spinner.centerXAnchor.constraint(equalTo: controller.view.centerXAnchor),
328
+ spinner.centerYAnchor.constraint(equalTo: controller.view.centerYAnchor)
329
+ ])
330
+ return controller
331
+ }
332
+
333
+ private func dismissSplash() {
334
+ guard let overlay = splashWindow else { return }
335
+
336
+ if splashIsCustom {
337
+ let remaining = config.splashMinimumDisplay - Date().timeIntervalSince(splashShownAt)
338
+ if remaining > 0 {
339
+ guard !splashDismissScheduled else { return }
340
+ splashDismissScheduled = true
341
+ DispatchQueue.main.asyncAfter(deadline: .now() + remaining) { [weak self] in
342
+ self?.splashDismissScheduled = false
343
+ self?.dismissSplash()
344
+ }
345
+ return
346
+ }
347
+ }
348
+
349
+ splashWindow = nil
350
+ UIView.animate(withDuration: 0.25, animations: { overlay.alpha = 0 }) { _ in
351
+ overlay.isHidden = true
352
+ }
353
+ }
354
+
355
+ // MARK: Badges
356
+
357
+ @objc private func handleTabBadge(_ note: Notification) {
358
+ guard let path = note.userInfo?["path"] as? String,
359
+ let count = note.userInfo?["count"] as? Int
360
+ else { return }
361
+
362
+ if count > 0 {
363
+ tabBadges[path] = String(count)
364
+ } else {
365
+ tabBadges.removeValue(forKey: path)
366
+ }
367
+ applyTabBadge(path: path, value: count > 0 ? String(count) : nil)
368
+ }
369
+
370
+ private func applyTabBadge(path: String, value: String?) {
371
+ if #available(iOS 18.0, *), let tabBarController {
372
+ tabBarController.tabs.first { $0.identifier == path }?.badgeValue = value
373
+ } else if let viewControllers = tabBarController?.viewControllers,
374
+ let index = tabEntries.firstIndex(where: { $0.path == path }),
375
+ viewControllers.indices.contains(index) {
376
+ viewControllers[index].tabBarItem.badgeValue = value
377
+ }
378
+ }
379
+
380
+ private func makeNavigator() -> Navigator {
381
+ Navigator(
382
+ configuration: Navigator.Configuration(name: "main", startLocation: config.startURL),
383
+ delegate: self
384
+ )
385
+ }
386
+
387
+ private struct TabEntry {
388
+ let title: String
389
+ let path: String
390
+ let icon: String
391
+ }
392
+
393
+ /// `settings.tabs` from the merged path configuration. Icon names are SF
394
+ /// Symbols (everywhere.yml `tabs[].icons.ios`).
395
+ private var tabEntries: [TabEntry] {
396
+ guard let entries = Hotwire.config.pathConfiguration.settings["tabs"] as? [[String: AnyHashable]] else {
397
+ return []
398
+ }
399
+
400
+ return entries.compactMap { entry -> TabEntry? in
401
+ guard let title = entry["title"] as? String,
402
+ let path = entry["path"] as? String
403
+ else { return nil }
404
+
405
+ return TabEntry(title: title, path: path, icon: entry["icon"] as? String ?? "circle")
406
+ }
407
+ }
408
+
409
+ private func hotwireTab(for entry: TabEntry) -> HotwireTab {
410
+ HotwireTab(
411
+ id: entry.path,
412
+ title: entry.title,
413
+ image: UIImage(systemName: entry.icon) ?? UIImage(systemName: "circle"),
414
+ url: config.url(forPath: entry.path)
415
+ )
416
+ }
417
+
418
+ /// The navigator currently on screen.
419
+ private var activeNavigator: Navigator {
420
+ if let tabBarController { return tabBarController.activeNavigator }
421
+ return navigator ?? makeNavigator()
422
+ }
423
+ }
424
+
425
+ // MARK: - PathConfigurationDelegate
426
+
427
+ extension SceneDelegate: PathConfigurationDelegate {
428
+ func pathConfigurationDidUpdate() {
429
+ if Thread.isMainThread {
430
+ applyPathConfiguration()
431
+ } else {
432
+ DispatchQueue.main.async { self.applyPathConfiguration() }
433
+ }
434
+ }
435
+ }
436
+
437
+ // MARK: - NavigatorDelegate
438
+
439
+ extension SceneDelegate: NavigatorDelegate {
440
+ func handle(proposal: VisitProposal, from navigator: Navigator) -> ProposalResult {
441
+ // Intercept the reset URL natively — the redirect after sign-in/out —
442
+ // so resetting never depends on the reset page's JavaScript running.
443
+ if proposal.url.path == Self.resetPath {
444
+ resetApp(to: resetTarget(from: proposal.url))
445
+ return .reject
446
+ }
447
+ // App-provided native screens: a path rule's `view_controller`
448
+ // identifier resolves through the extension registry (everywhere.yml
449
+ // native.ios.screens). Unknown identifiers fall through to the web.
450
+ if let identifier = proposal.properties["view_controller"] as? String,
451
+ let screen = EverywhereExtensions.screen(for: identifier, url: proposal.url) {
452
+ // A native screen IS first content: without this, an app whose
453
+ // entry path is native never finishes a request and the splash
454
+ // sits until its safety timeout.
455
+ dismissSplash()
456
+ return .acceptCustom(screen)
457
+ }
458
+ return .accept
459
+ }
460
+
461
+ func requestDidFinish(at url: URL) {
462
+ dismissSplash()
463
+ }
464
+
465
+ func visitableDidFailRequest(_ visitable: any Visitable, error: HotwireNativeError, retryHandler: RetryBlock?) {
466
+ dismissSplash()
467
+ switch error {
468
+ case .http(.client(.unauthorized)):
469
+ promptForAuthentication()
470
+ default:
471
+ presentError(error, retryHandler: retryHandler)
472
+ }
473
+ }
474
+
475
+ private func promptForAuthentication() {
476
+ activeNavigator.route(config.rootURL.appendingPathComponent("/session/new"))
477
+ }
478
+
479
+ private func presentError(_ error: HotwireNativeError, retryHandler: RetryBlock?) {
480
+ let errorViewController = ErrorViewController(error: error, retryHandler: retryHandler)
481
+ errorViewController.modalPresentationStyle = .fullScreen
482
+ activeNavigator.activeNavigationController.present(errorViewController, animated: true)
483
+ }
484
+ }