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