ruby_everywhere 0.2.0 → 0.4.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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +255 -0
  3. data/bridge/README.md +48 -0
  4. data/bridge/everywhere/bridge.js +388 -3
  5. data/bridge/everywhere/native.css +203 -0
  6. data/bridge/package.json +6 -3
  7. data/lib/everywhere/android_resources.rb +217 -0
  8. data/lib/everywhere/android_sdk.rb +330 -0
  9. data/lib/everywhere/asset_catalog.rb +185 -0
  10. data/lib/everywhere/auth_handoff.rb +398 -0
  11. data/lib/everywhere/auth_token.rb +118 -0
  12. data/lib/everywhere/builders/android.rb +844 -0
  13. data/lib/everywhere/builders/ios.rb +72 -3
  14. data/lib/everywhere/commands/build.rb +51 -9
  15. data/lib/everywhere/commands/clean.rb +10 -5
  16. data/lib/everywhere/commands/dev.rb +46 -8
  17. data/lib/everywhere/commands/doctor.rb +89 -3
  18. data/lib/everywhere/commands/install.rb +1 -1
  19. data/lib/everywhere/commands/logs.rb +117 -8
  20. data/lib/everywhere/config.rb +513 -14
  21. data/lib/everywhere/emulator.rb +405 -0
  22. data/lib/everywhere/engine.rb +21 -0
  23. data/lib/everywhere/icon.rb +196 -0
  24. data/lib/everywhere/log_filter.rb +74 -0
  25. data/lib/everywhere/mobile_config_endpoint.rb +13 -0
  26. data/lib/everywhere/mobile_configs_controller.rb +18 -0
  27. data/lib/everywhere/native_helper.rb +198 -5
  28. data/lib/everywhere/omniauth.rb +88 -0
  29. data/lib/everywhere/paths.rb +47 -0
  30. data/lib/everywhere/version.rb +1 -1
  31. data/support/mobile/android/README.md +217 -0
  32. data/support/mobile/android/app/build.gradle.kts +156 -0
  33. data/support/mobile/android/app/everywhere.properties +8 -0
  34. data/support/mobile/android/app/native-packages.gradle.kts +16 -0
  35. data/support/mobile/android/app/proguard-rules.pro +21 -0
  36. data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
  37. data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
  38. data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
  39. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
  40. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
  41. data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
  42. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
  43. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
  44. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
  45. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
  46. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
  47. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
  48. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
  49. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
  50. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
  51. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
  52. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
  53. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
  54. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
  55. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
  56. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
  57. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
  58. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
  59. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
  60. data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
  61. data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
  62. data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
  63. data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
  64. data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
  65. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
  66. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
  67. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
  68. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
  69. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
  70. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  71. data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
  72. data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
  73. data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
  74. data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
  75. data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
  76. data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
  77. data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
  78. data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
  79. data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
  80. data/support/mobile/android/build.gradle.kts +8 -0
  81. data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  82. data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
  83. data/support/mobile/android/gradle.properties +14 -0
  84. data/support/mobile/android/gradlew +251 -0
  85. data/support/mobile/android/gradlew.bat +94 -0
  86. data/support/mobile/android/settings.gradle.kts +28 -0
  87. data/support/mobile/ios/App/AppDelegate.swift +7 -1
  88. data/support/mobile/ios/App/AuthFlow.swift +101 -0
  89. data/support/mobile/ios/App/Bridge/MenuComponent.swift +192 -0
  90. data/support/mobile/ios/App/EverywhereConfig.swift +105 -0
  91. data/support/mobile/ios/App/SceneDelegate.swift +144 -0
  92. data/support/mobile/ios/App.xcodeproj/project.pbxproj +8 -0
  93. data/support/mobile/ios/README.md +10 -0
  94. metadata +69 -1
@@ -0,0 +1,192 @@
1
+ import Foundation
2
+ import HotwireNative
3
+ import UIKit
4
+ import WebKit
5
+
6
+ /// Bridge component backing `everywhere--menu`: the native chrome the page
7
+ /// declares with `data-everywhere-nav-*` / `data-everywhere-menu` (the
8
+ /// everywhere_nav_button / everywhere_nav_menu / everywhere_menu Rails helpers).
9
+ ///
10
+ /// Two events, both replied to by echoing back the tapped item's id so the web
11
+ /// half can `.click()` the element it mirrors — behavior stays defined once, in
12
+ /// the page:
13
+ /// * `navItems` — install left/right `UIBarButtonItem`s on THIS screen's
14
+ /// navigation bar (plain buttons, and pull-down `UIMenu`s for overflow
15
+ /// menus). Native re-replies to the same message on every tap.
16
+ /// * `menu` — present a `UIAlertController` action sheet, replying once with
17
+ /// the selection (or a cancel).
18
+ ///
19
+ /// Installs onto `delegate?.destination` — the visitable view controller this
20
+ /// component instance belongs to — so buttons land on the right screen and go
21
+ /// away with it, exactly like the framework's own per-page bridge components.
22
+ final class MenuComponent: BridgeComponent {
23
+ override nonisolated class var name: String { "everywhere--menu" }
24
+
25
+ override func onReceive(message: Message) {
26
+ switch message.event {
27
+ case "navItems":
28
+ handleNavItems(message: message)
29
+ case "menu":
30
+ handleMenu(message: message)
31
+ default:
32
+ break
33
+ }
34
+ }
35
+
36
+ // MARK: Private
37
+
38
+ private var viewController: UIViewController? {
39
+ delegate?.destination as? UIViewController
40
+ }
41
+
42
+ // MARK: Nav bar items
43
+
44
+ private func handleNavItems(message: Message) {
45
+ guard let viewController, let data: NavItemsData = message.data() else { return }
46
+
47
+ var left: [UIBarButtonItem] = []
48
+ var right: [UIBarButtonItem] = []
49
+ for item in data.items {
50
+ let barButton = barButtonItem(for: item)
51
+ if item.side == "left" { left.append(barButton) } else { right.append(barButton) }
52
+ }
53
+
54
+ // Right-side items read right-to-left in a nav bar; reverse so the page
55
+ // order ("Edit", "Share") lands left-to-right as authored.
56
+ viewController.navigationItem.leftBarButtonItems = left.isEmpty ? nil : left
57
+ viewController.navigationItem.rightBarButtonItems = right.isEmpty ? nil : right.reversed()
58
+ }
59
+
60
+ private func barButtonItem(for item: NavItem) -> UIBarButtonItem {
61
+ let image = item.image.flatMap { UIImage(systemName: $0) }
62
+
63
+ // A pull-down / overflow menu: children become a native UIMenu, each
64
+ // echoing its own id back on selection.
65
+ if let children = item.children, !children.isEmpty {
66
+ let actions = children.map { child in
67
+ UIAction(
68
+ title: child.title,
69
+ image: child.image.flatMap { UIImage(systemName: $0) },
70
+ attributes: child.style == "destructive" ? .destructive : []
71
+ ) { [weak self] _ in
72
+ self?.replyTap(id: child.id)
73
+ }
74
+ }
75
+ let menu = UIMenu(title: item.title.isEmpty ? "" : item.title, children: actions)
76
+ let barButton = UIBarButtonItem(
77
+ title: item.title.isEmpty ? nil : item.title,
78
+ image: image ?? UIImage(systemName: "ellipsis.circle"),
79
+ menu: menu
80
+ )
81
+ return barButton
82
+ }
83
+
84
+ // A plain button: echo its id back so the web half clicks the element.
85
+ let action = UIAction(title: item.title, image: image) { [weak self] _ in
86
+ self?.replyTap(id: item.id)
87
+ }
88
+ let barButton: UIBarButtonItem
89
+ if let image {
90
+ barButton = UIBarButtonItem(image: image, primaryAction: action)
91
+ } else {
92
+ barButton = UIBarButtonItem(title: item.title, primaryAction: action)
93
+ }
94
+ barButton.style = item.style == "done" ? .done : .plain
95
+ barButton.isEnabled = !(item.disabled ?? false)
96
+ return barButton
97
+ }
98
+
99
+ private func replyTap(id: String) {
100
+ reply(to: "navItems", with: NavReply(action: "tap", id: id))
101
+ }
102
+
103
+ // MARK: Action sheet
104
+
105
+ private func handleMenu(message: Message) {
106
+ guard let viewController, let data: MenuData = message.data() else { return }
107
+
108
+ let sheet = UIAlertController(title: data.title, message: data.message, preferredStyle: .actionSheet)
109
+ for item in data.items {
110
+ let style: UIAlertAction.Style = item.style == "destructive" ? .destructive : .default
111
+ let action = UIAlertAction(title: item.title, style: style) { [weak self] _ in
112
+ self?.reply(to: "menu", with: MenuReply(action: "select", id: item.id))
113
+ }
114
+ action.isEnabled = !(item.disabled ?? false)
115
+ sheet.addAction(action)
116
+ }
117
+ sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in
118
+ self?.reply(to: "menu", with: MenuReply(action: "cancel", id: nil))
119
+ })
120
+
121
+ // iPad / iOS 26 present action sheets as popovers, which need an anchor.
122
+ // The web sends the trigger's rect in page coordinates; offset by the
123
+ // web view's top content inset (the nav bar) to reach view coordinates.
124
+ if let popover = sheet.popoverPresentationController {
125
+ popover.sourceView = viewController.view
126
+ if let source = data.source {
127
+ var y = source.y
128
+ if let visitable = viewController as? Visitable,
129
+ let webView = visitable.visitableView.webView {
130
+ y += Double(webView.scrollView.adjustedContentInset.top)
131
+ }
132
+ popover.sourceRect = CGRect(x: source.x, y: y, width: source.width, height: source.height)
133
+ } else {
134
+ popover.sourceRect = CGRect(x: viewController.view.bounds.midX,
135
+ y: viewController.view.bounds.midY, width: 0, height: 0)
136
+ popover.permittedArrowDirections = []
137
+ }
138
+ }
139
+
140
+ viewController.present(sheet, animated: true)
141
+ }
142
+ }
143
+
144
+ // MARK: - Message data
145
+
146
+ private extension MenuComponent {
147
+ struct NavItemsData: Decodable {
148
+ let items: [NavItem]
149
+ }
150
+
151
+ struct NavItem: Decodable {
152
+ let id: String
153
+ let title: String
154
+ let image: String?
155
+ let side: String?
156
+ let style: String?
157
+ let disabled: Bool?
158
+ let children: [MenuItem]?
159
+ }
160
+
161
+ struct MenuData: Decodable {
162
+ let title: String?
163
+ let message: String?
164
+ let items: [MenuItem]
165
+ let source: Source?
166
+ }
167
+
168
+ struct MenuItem: Decodable {
169
+ let id: String
170
+ let title: String
171
+ let image: String?
172
+ let style: String?
173
+ let disabled: Bool?
174
+ }
175
+
176
+ struct Source: Decodable {
177
+ let x: Double
178
+ let y: Double
179
+ let width: Double
180
+ let height: Double
181
+ }
182
+
183
+ struct NavReply: Encodable {
184
+ let action: String
185
+ let id: String
186
+ }
187
+
188
+ struct MenuReply: Encodable {
189
+ let action: String
190
+ let id: String?
191
+ }
192
+ }
@@ -32,6 +32,43 @@ struct ThemedColor: Decodable {
32
32
  }
33
33
  }
34
34
 
35
+ /// Third-party sign-in (everywhere.yml `auth:`): which paths hand off to the
36
+ /// system browser instead of loading in the web view, and the custom scheme
37
+ /// ASWebAuthenticationSession returns through. Absent when the app declares no
38
+ /// `auth:` — a shell that diverts nothing.
39
+ struct AuthConfig: Decodable {
40
+ let oauthPaths: [String]
41
+ let scheme: String
42
+
43
+ private enum CodingKeys: String, CodingKey {
44
+ case oauthPaths = "oauth_paths"
45
+ case scheme
46
+ }
47
+
48
+ /// The patterns compiled once. They're regex sources, matched unanchored,
49
+ /// exactly like the path-configuration `rules:` patterns they sit beside in
50
+ /// everywhere.yml — and exactly like the gem matches them server-side, so
51
+ /// both halves of the flow agree on what a provider path is.
52
+ let expressions: [NSRegularExpression]
53
+
54
+ init(from decoder: Decoder) throws {
55
+ let container = try decoder.container(keyedBy: CodingKeys.self)
56
+ oauthPaths = try container.decodeIfPresent([String].self, forKey: .oauthPaths) ?? []
57
+ scheme = try container.decodeIfPresent(String.self, forKey: .scheme) ?? ""
58
+ expressions = oauthPaths.compactMap { try? NSRegularExpression(pattern: $0) }
59
+ }
60
+
61
+ /// Half a declaration is worse than none: without a scheme there's nothing
62
+ /// for the auth session to return through, and diverting a visit we can't
63
+ /// finish would turn the sign-in button into a button that does nothing.
64
+ var isUsable: Bool { !scheme.isEmpty && !expressions.isEmpty }
65
+
66
+ func matches(path: String) -> Bool {
67
+ let range = NSRange(path.startIndex..., in: path)
68
+ return expressions.contains { $0.firstMatch(in: path, range: range) != nil }
69
+ }
70
+ }
71
+
35
72
  /// The app's configuration, stamped into the bundle as `everywhere.json` by
36
73
  /// `every build`. The raw template ships a placeholder pointing at rubyeverywhere.com.
37
74
  struct EverywhereConfig: Decodable {
@@ -46,6 +83,8 @@ struct EverywhereConfig: Decodable {
46
83
  let permissions: [String]?
47
84
  let splashMinSeconds: Double?
48
85
  let lazyLoadTabs: Bool?
86
+ let universalLinkHosts: [String]?
87
+ let auth: AuthConfig?
49
88
 
50
89
  private enum CodingKeys: String, CodingKey {
51
90
  case name
@@ -59,6 +98,8 @@ struct EverywhereConfig: Decodable {
59
98
  case permissions
60
99
  case splashMinSeconds = "splash_min_seconds"
61
100
  case lazyLoadTabs = "lazy_load_tabs"
101
+ case universalLinkHosts = "universal_link_hosts"
102
+ case auth
62
103
  }
63
104
 
64
105
  /// Whether the tab bar defers each non-selected tab's initial visit until
@@ -197,6 +238,70 @@ struct EverywhereConfig: Decodable {
197
238
  ]
198
239
  }
199
240
 
241
+ // MARK: Third-party sign-in
242
+
243
+ /// The path a visit should hand to the auth session, or nil to let it load
244
+ /// in the web view. Two shapes: a provider path the app linked to directly,
245
+ /// and the gem's /everywhere/auth/native page, which is where the server
246
+ /// sends visits the shell couldn't intercept (a `data-turbo="false"` link,
247
+ /// or the POST OmniAuth 2 requires) — its `to` names the real provider path.
248
+ func authTarget(for url: URL) -> String? {
249
+ guard let auth, auth.isUsable else { return nil }
250
+
251
+ if url.path == "/everywhere/auth/native" {
252
+ let to = URLComponents(url: url, resolvingAgainstBaseURL: false)?
253
+ .queryItems?.first { $0.name == "to" }?.value
254
+ return to.flatMap { $0.hasPrefix("/") && !$0.hasPrefix("//") ? $0 : nil }
255
+ }
256
+ guard auth.matches(path: url.path) else { return nil }
257
+
258
+ return pathAndQuery(of: url)
259
+ }
260
+
261
+ var authScheme: String? { auth.flatMap { $0.isUsable ? $0.scheme : nil } }
262
+
263
+ /// The first page of the auth session: the gem's start endpoint, which
264
+ /// marks the browser's jar as ours and continues into `to`. `returnTo` is
265
+ /// where the user was, used only if the app's own sign-in doesn't say where
266
+ /// to land.
267
+ func authStartURL(to: String, returnTo: String?) -> URL? {
268
+ var components = URLComponents(url: url(forPath: "/everywhere/auth/start"),
269
+ resolvingAgainstBaseURL: false)
270
+ var items = [URLQueryItem(name: "to", value: to)]
271
+ if let returnTo { items.append(URLQueryItem(name: "return_to", value: returnTo)) }
272
+ components?.queryItems = items
273
+ return components?.url
274
+ }
275
+
276
+ /// Where the token gets spent. Visited in the web view — that's the whole
277
+ /// point, the response's cookies have to land in the web view's jar — and
278
+ /// it redirects on to wherever the app's sign-in wanted the user.
279
+ func authHandoffURL(token: String) -> URL? {
280
+ var components = URLComponents(url: url(forPath: "/everywhere/auth/handoff"),
281
+ resolvingAgainstBaseURL: false)
282
+ components?.queryItems = [URLQueryItem(name: "token", value: token)]
283
+ return components?.url
284
+ }
285
+
286
+ /// Whether an incoming universal link belongs to this app: https, and its
287
+ /// host is the app's own root host or one of the associated domains
288
+ /// (everywhere.yml deep_linking, injected as `universal_link_hosts`). A link
289
+ /// to some other site is left to Safari.
290
+ func handlesUniversalLink(_ url: URL) -> Bool {
291
+ guard url.scheme?.lowercased() == "https", let host = url.host?.lowercased() else { return false }
292
+ if rootURL.host?.lowercased() == host { return true }
293
+ return (universalLinkHosts ?? []).contains { $0.lowercased() == host }
294
+ }
295
+
296
+ /// The path (plus query) of an absolute URL, for re-routing a universal link
297
+ /// through the app's own effective root (dev override / picked instance).
298
+ func pathAndQuery(of url: URL) -> String {
299
+ guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return "/" }
300
+ let path = components.path.isEmpty ? "/" : components.path
301
+ if let query = components.query, !query.isEmpty { return "\(path)?\(query)" }
302
+ return path
303
+ }
304
+
200
305
  /// rootURL joined with an absolute-style path (leading `/` optional).
201
306
  func url(forPath path: String) -> URL {
202
307
  let root = rootURL
@@ -1,3 +1,4 @@
1
+ import AuthenticationServices
1
2
  import HotwireNative
2
3
  import UIKit
3
4
  import WebKit
@@ -39,6 +40,22 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
39
40
  /// The path the shell treats as the "reset the app" signal.
40
41
  private static let resetPath = "/everywhere/reset"
41
42
 
43
+ /// A universal link that arrived before the root existed (cold launch), held
44
+ /// until the first navigator/tab bar is built, then routed.
45
+ private var pendingUniversalLinkURL: URL?
46
+
47
+ /// Third-party sign-in, run in the system browser (everywhere.yml `auth:`).
48
+ /// Held for the scene's lifetime — the session inside it must outlive the
49
+ /// call that started it.
50
+ private lazy var authFlow = AuthFlow { [weak self] in
51
+ self?.window ?? ASPresentationAnchor()
52
+ }
53
+
54
+ /// Set while the web view is spending a handoff token. The visit that
55
+ /// follows carries the Set-Cookie headers that sign the app in, so the
56
+ /// auth-gated tab bar can only be fetched once it has finished.
57
+ private var awaitingAuthHandoff = false
58
+
42
59
  func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
43
60
  guard let windowScene = scene as? UIWindowScene else { return }
44
61
 
@@ -71,6 +88,9 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
71
88
  NotificationCenter.default.addObserver(
72
89
  self, selector: #selector(handleNativeVisit(_:)),
73
90
  name: .everywhereNativeVisit, object: nil)
91
+ NotificationCenter.default.addObserver(
92
+ self, selector: #selector(handleAuthFlow(_:)),
93
+ name: .everywhereAuthFlow, object: nil)
74
94
 
75
95
  // Cookie hydration BEFORE the first visit: after a force-kill relaunch
76
96
  // (iOS kills the app when a Settings permission changes) the first
@@ -79,13 +99,50 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
79
99
  // hydration — the splash covers the wait — and the same pass mirrors
80
100
  // cookies for the auth-aware config fetch, which adds the tab bar for
81
101
  // a signed-in user.
102
+ // A universal link that launched the app (cold start): capture it now,
103
+ // route it once the root exists (below).
104
+ captureUniversalLink(from: connectionOptions.userActivities)
105
+
82
106
  syncWebViewCookiesToSharedStore { [weak self] in
83
107
  guard let self else { return }
84
108
  self.applyPathConfiguration()
109
+ self.routePendingUniversalLink()
85
110
  Hotwire.loadPathConfiguration(from: EverywhereConfig.pathConfigurationSources(serverOnly: true))
86
111
  }
87
112
  }
88
113
 
114
+ // MARK: Universal links (deep linking)
115
+
116
+ /// A universal link tapped while the app is running: route it straight to
117
+ /// its in-app path. Links to other sites fall through to the system.
118
+ func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
119
+ guard let url = webpageURL(from: userActivity) else { return }
120
+ activeNavigator.route(config.url(forPath: config.pathAndQuery(of: url)))
121
+ }
122
+
123
+ private func captureUniversalLink(from activities: Set<NSUserActivity>) {
124
+ guard let activity = activities.first(where: { $0.activityType == NSUserActivityTypeBrowsingWeb }),
125
+ let url = webpageURL(from: activity)
126
+ else { return }
127
+ pendingUniversalLinkURL = config.url(forPath: config.pathAndQuery(of: url))
128
+ }
129
+
130
+ /// The webpage URL of a browsing-web activity, but only if this app claims
131
+ /// its host (the associated domains) — never route someone else's link.
132
+ private func webpageURL(from userActivity: NSUserActivity) -> URL? {
133
+ guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
134
+ let url = userActivity.webpageURL,
135
+ config.handlesUniversalLink(url)
136
+ else { return nil }
137
+ return url
138
+ }
139
+
140
+ private func routePendingUniversalLink() {
141
+ guard let url = pendingUniversalLinkURL else { return }
142
+ pendingUniversalLinkURL = nil
143
+ activeNavigator.route(url)
144
+ }
145
+
89
146
  /// Refetch the server path configuration on every foreground — so a config
90
147
  /// change lands without a relaunch (the dev endpoint is uncached). Uses the
91
148
  /// server-only sources: the auth-aware source of truth, no baked flash.
@@ -141,6 +198,10 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
141
198
  /// 3. refresh the auth-aware server config, which rebuilds the tab bar
142
199
  /// back in if (and only if) the user is now signed in.
143
200
  private func resetApp(to target: URL) {
201
+ // A sign-in sheet still up is answering a question the app no longer
202
+ // has — the reset is re-rooting or re-authing underneath it.
203
+ authFlow.cancel()
204
+ awaitingAuthHandoff = false
144
205
  clearWebContentCache()
145
206
  rebuildRoot(entries: [], routeTo: target)
146
207
  loadedTabsFingerprint = "" // matches the empty tab set we just built
@@ -162,6 +223,75 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
162
223
  activeNavigator.route(url)
163
224
  }
164
225
 
226
+ // MARK: Third-party sign-in
227
+
228
+ /// `{action: "authFlow", to: "/auth/github"}` from the gem's
229
+ /// /everywhere/auth/native page — the fallback for visits the proposal
230
+ /// handler never sees (a `data-turbo="false"` link, or OmniAuth 2's POST).
231
+ @objc private func handleAuthFlow(_ note: Notification) {
232
+ guard let to = note.userInfo?["to"] as? String, to.hasPrefix("/"), !to.hasPrefix("//") else { return }
233
+ startAuthFlow(to: to)
234
+ }
235
+
236
+ /// Open a provider sign-in in the system browser. Nothing is loaded in the
237
+ /// web view: the whole point is that this URL never touches it.
238
+ private func startAuthFlow(to: String) {
239
+ guard let scheme = config.authScheme,
240
+ let start = config.authStartURL(to: to, returnTo: currentPath)
241
+ else { return }
242
+
243
+ #if DEBUG
244
+ NSLog("authFlow: %@", start.absoluteString)
245
+ #endif
246
+
247
+ authFlow.start(url: start, scheme: scheme) { [weak self] result in
248
+ guard let self else { return }
249
+ switch result {
250
+ case .success(let token):
251
+ self.finishAuthFlow(token: token)
252
+ case .failure(.canceled):
253
+ break // they closed the sheet; they're still on the page they started from
254
+ case .failure(let error):
255
+ self.presentAuthFailure(error)
256
+ }
257
+ }
258
+ }
259
+
260
+ /// Spend the handoff token in the web view and rebuild around whatever it
261
+ /// signs the app in as. Deliberately the reset sequence, in order:
262
+ /// 1. drop cached web content — the pre-auth pages are all stale now,
263
+ /// 2. drop to a single navigator aimed at the handoff, which sets the
264
+ /// session cookie in the WEB VIEW's jar (the browser's jar, where the
265
+ /// flow actually happened, is unreachable from here) and redirects on
266
+ /// to the app's post-sign-in page,
267
+ /// 3. once that request has finished — and only then, or the fetch races
268
+ /// the cookie — re-read the auth-gated config, which brings the tab
269
+ /// bar back for the user who just signed in.
270
+ private func finishAuthFlow(token: String) {
271
+ guard let handoff = config.authHandoffURL(token: token) else { return }
272
+
273
+ clearWebContentCache()
274
+ rebuildRoot(entries: [], routeTo: handoff)
275
+ loadedTabsFingerprint = "" // matches the empty tab set we just built
276
+ awaitingAuthHandoff = true
277
+ }
278
+
279
+ private func presentAuthFailure(_ error: AuthFlow.Failure) {
280
+ NSLog("authFlow failed: %@", String(describing: error))
281
+
282
+ let alert = UIAlertController(title: "Sign-in didn't finish",
283
+ message: "Something went wrong on the way back to the app. Please try again.",
284
+ preferredStyle: .alert)
285
+ alert.addAction(UIAlertAction(title: "OK", style: .default))
286
+ activeNavigator.activeNavigationController.present(alert, animated: true)
287
+ }
288
+
289
+ /// The path the user is currently on, for the flow to come back to when the
290
+ /// app's own sign-in doesn't name a destination.
291
+ private var currentPath: String? {
292
+ activeNavigator.currentVisitableURL.map { config.pathAndQuery(of: $0) }
293
+ }
294
+
165
295
  // MARK: Instance switching (multi-instance apps)
166
296
 
167
297
  /// `Everywhere.instance.set(url)`: persist the picked instance as the
@@ -403,6 +533,13 @@ extension SceneDelegate: NavigatorDelegate {
403
533
  resetApp(to: resetTarget(from: proposal.url))
404
534
  return .reject
405
535
  }
536
+ // A provider sign-in: hand it to the system browser instead of loading
537
+ // it here. Rejecting leaves the user on the page they tapped from, which
538
+ // is where they belong if they close the sheet.
539
+ if let target = config.authTarget(for: proposal.url) {
540
+ startAuthFlow(to: target)
541
+ return .reject
542
+ }
406
543
  // App-provided native screens: a path rule's `view_controller`
407
544
  // identifier resolves through the extension registry (everywhere.yml
408
545
  // native.ios.screens). Unknown identifiers fall through to the web.
@@ -419,6 +556,13 @@ extension SceneDelegate: NavigatorDelegate {
419
556
 
420
557
  func requestDidFinish(at url: URL) {
421
558
  dismissSplash()
559
+
560
+ // The handoff has landed, so its cookies are in the web view's jar:
561
+ // now the tab bar can be fetched as the signed-in user.
562
+ if awaitingAuthHandoff {
563
+ awaitingAuthHandoff = false
564
+ loadPathConfiguration(serverOnly: true)
565
+ }
422
566
  }
423
567
 
424
568
  func visitableDidFailRequest(_ visitable: any Visitable, error: HotwireNativeError, retryHandler: RetryBlock?) {
@@ -16,7 +16,9 @@
16
16
  EBE000000000000000000046 /* PermissionsComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBE000000000000000000045 /* PermissionsComponent.swift */; };
17
17
  EBE000000000000000000058 /* BiometricsComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBE000000000000000000057 /* BiometricsComponent.swift */; };
18
18
  EBE000000000000000000060 /* StorageComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBE000000000000000000059 /* StorageComponent.swift */; };
19
+ EBE000000000000000000068 /* MenuComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBE000000000000000000067 /* MenuComponent.swift */; };
19
20
  EBE000000000000000000063 /* EverywhereHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBE000000000000000000062 /* EverywhereHost.swift */; };
21
+ EBE000000000000000000070 /* AuthFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBE000000000000000000069 /* AuthFlow.swift */; };
20
22
  EBE000000000000000000036 /* everywhere.json in Resources */ = {isa = PBXBuildFile; fileRef = EBE000000000000000000018 /* everywhere.json */; };
21
23
  EBE000000000000000000037 /* path-configuration.json in Resources */ = {isa = PBXBuildFile; fileRef = EBE000000000000000000019 /* path-configuration.json */; };
22
24
  EBE000000000000000000038 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EBE000000000000000000020 /* Assets.xcassets */; };
@@ -38,11 +40,13 @@
38
40
  EBE000000000000000000045 /* PermissionsComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsComponent.swift; sourceTree = "<group>"; };
39
41
  EBE000000000000000000057 /* BiometricsComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BiometricsComponent.swift; sourceTree = "<group>"; };
40
42
  EBE000000000000000000059 /* StorageComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageComponent.swift; sourceTree = "<group>"; };
43
+ EBE000000000000000000067 /* MenuComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuComponent.swift; sourceTree = "<group>"; };
41
44
  EBE000000000000000000018 /* everywhere.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = everywhere.json; sourceTree = "<group>"; };
42
45
  EBE000000000000000000019 /* path-configuration.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "path-configuration.json"; sourceTree = "<group>"; };
43
46
  EBE000000000000000000020 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
44
47
  EBE000000000000000000021 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
45
48
  EBE000000000000000000062 /* EverywhereHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EverywhereHost.swift; sourceTree = "<group>"; };
49
+ EBE000000000000000000069 /* AuthFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthFlow.swift; sourceTree = "<group>"; };
46
50
  /* End PBXFileReference section */
47
51
 
48
52
  /* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -87,6 +91,7 @@
87
91
  EBE000000000000000000015 /* EverywhereConfig.swift */,
88
92
  EBE000000000000000000016 /* ErrorViewController.swift */,
89
93
  EBE000000000000000000062 /* EverywhereHost.swift */,
94
+ EBE000000000000000000069 /* AuthFlow.swift */,
90
95
  EBE000000000000000000005 /* Bridge */,
91
96
  EBE000000000000000000061 /* Extensions */,
92
97
  EBE000000000000000000023 /* Resources */,
@@ -105,6 +110,7 @@
105
110
  EBE000000000000000000045 /* PermissionsComponent.swift */,
106
111
  EBE000000000000000000057 /* BiometricsComponent.swift */,
107
112
  EBE000000000000000000059 /* StorageComponent.swift */,
113
+ EBE000000000000000000067 /* MenuComponent.swift */,
108
114
  );
109
115
  path = Bridge;
110
116
  sourceTree = "<group>";
@@ -210,7 +216,9 @@
210
216
  EBE000000000000000000046 /* PermissionsComponent.swift in Sources */,
211
217
  EBE000000000000000000058 /* BiometricsComponent.swift in Sources */,
212
218
  EBE000000000000000000060 /* StorageComponent.swift in Sources */,
219
+ EBE000000000000000000068 /* MenuComponent.swift in Sources */,
213
220
  EBE000000000000000000063 /* EverywhereHost.swift in Sources */,
221
+ EBE000000000000000000070 /* AuthFlow.swift in Sources */,
214
222
  );
215
223
  runOnlyForDeploymentPostprocessing = 0;
216
224
  };
@@ -19,6 +19,16 @@ this template, it overwrites **exactly** these files and nothing else:
19
19
  4. `App/Assets.xcassets/AppIcon.appiconset/AppIcon.png` — the app icon (1024×1024 opaque PNG)
20
20
  5. *(optionally)* `App/Assets.xcassets/AccentColor.colorset/Contents.json` and
21
21
  `App/Assets.xcassets/LaunchBackground.colorset/Contents.json` — brand colors
22
+ 6. *(optionally)* `App/Info.plist`'s `CFBundleURLTypes` — the sign-in callback
23
+ scheme, written **only** when `auth:` is set in `everywhere.yml` (alongside
24
+ the permission usage strings, which are stamped the same way)
25
+ 7. *(optionally)* `App/App.entitlements` — the Associated Domains entitlement,
26
+ written **only** when `deep_linking:` is set in `everywhere.yml`. The matching
27
+ `CODE_SIGN_ENTITLEMENTS` is added to the xcconfig in the same case, so the
28
+ default build stays entitlement-free and the frozen template still compiles
29
+ unchanged. The gem serves the `/.well-known/apple-app-site-association` file
30
+ these domains point at; `SceneDelegate` routes an incoming universal link to
31
+ its in-app path.
22
32
 
23
33
  Everything the CLI needs to vary per app flows through those files. The product
24
34
  is always `App.app` (`PRODUCT_NAME = App` is hardcoded); identity comes from the