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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/bridge/README.md +57 -2
  3. data/bridge/everywhere/bridge.js +803 -9
  4. data/bridge/package.json +1 -1
  5. data/lib/everywhere/builders/ios.rb +364 -0
  6. data/lib/everywhere/cli.rb +2 -0
  7. data/lib/everywhere/commands/build.rb +17 -1
  8. data/lib/everywhere/commands/clean.rb +19 -10
  9. data/lib/everywhere/commands/dev.rb +182 -19
  10. data/lib/everywhere/commands/doctor.rb +45 -3
  11. data/lib/everywhere/commands/icon.rb +14 -0
  12. data/lib/everywhere/commands/install.rb +37 -6
  13. data/lib/everywhere/commands/logs.rb +56 -0
  14. data/lib/everywhere/commands/platform/build.rb +3 -3
  15. data/lib/everywhere/commands/platform/runner.rb +1 -1
  16. data/lib/everywhere/commands/release.rb +1 -1
  17. data/lib/everywhere/commands/shell_dir.rb +11 -4
  18. data/lib/everywhere/config.rb +366 -1
  19. data/lib/everywhere/engine.rb +33 -1
  20. data/lib/everywhere/icon.rb +50 -0
  21. data/lib/everywhere/log_filter.rb +28 -2
  22. data/lib/everywhere/mobile_config_endpoint.rb +75 -0
  23. data/lib/everywhere/mobile_configs_controller.rb +46 -0
  24. data/lib/everywhere/native_helper.rb +156 -0
  25. data/lib/everywhere/paths.rb +41 -0
  26. data/lib/everywhere/raster.rb +17 -0
  27. data/lib/everywhere/shellout.rb +3 -1
  28. data/lib/everywhere/simulator.rb +74 -0
  29. data/lib/everywhere/ui.rb +18 -0
  30. data/lib/everywhere/version.rb +1 -1
  31. data/support/mobile/ios/App/App.xcconfig +6 -0
  32. data/support/mobile/ios/App/AppDelegate.swift +163 -0
  33. data/support/mobile/ios/App/Assets.xcassets/AccentColor.colorset/Contents.json +20 -0
  34. data/support/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
  35. data/support/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
  36. data/support/mobile/ios/App/Assets.xcassets/Contents.json +6 -0
  37. data/support/mobile/ios/App/Assets.xcassets/LaunchBackground.colorset/Contents.json +38 -0
  38. data/support/mobile/ios/App/Base.lproj/LaunchScreen.storyboard +32 -0
  39. data/support/mobile/ios/App/Bridge/BiometricsComponent.swift +276 -0
  40. data/support/mobile/ios/App/Bridge/HapticsComponent.swift +47 -0
  41. data/support/mobile/ios/App/Bridge/NotificationComponent.swift +56 -0
  42. data/support/mobile/ios/App/Bridge/PermissionsComponent.swift +142 -0
  43. data/support/mobile/ios/App/Bridge/StorageComponent.swift +63 -0
  44. data/support/mobile/ios/App/ErrorViewController.swift +64 -0
  45. data/support/mobile/ios/App/EverywhereConfig.swift +268 -0
  46. data/support/mobile/ios/App/EverywhereHost.swift +34 -0
  47. data/support/mobile/ios/App/Extensions/EverywhereExtensions.swift +32 -0
  48. data/support/mobile/ios/App/Info.plist +28 -0
  49. data/support/mobile/ios/App/Resources/everywhere.json +8 -0
  50. data/support/mobile/ios/App/Resources/path-configuration.json +19 -0
  51. data/support/mobile/ios/App/SceneDelegate.swift +443 -0
  52. data/support/mobile/ios/App.xcodeproj/project.pbxproj +454 -0
  53. data/support/mobile/ios/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  54. data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +14 -0
  55. data/support/mobile/ios/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme +77 -0
  56. data/support/mobile/ios/NativeExtensions/Package.swift +26 -0
  57. data/support/mobile/ios/NativeExtensions/Sources/NativeExtensions/Exports.swift +5 -0
  58. data/support/mobile/ios/README.md +66 -0
  59. 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
+ }