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.
- checksums.yaml +4 -4
- data/README.md +255 -0
- data/bridge/README.md +48 -0
- data/bridge/everywhere/bridge.js +388 -3
- data/bridge/everywhere/native.css +203 -0
- data/bridge/package.json +6 -3
- data/lib/everywhere/android_resources.rb +217 -0
- data/lib/everywhere/android_sdk.rb +330 -0
- data/lib/everywhere/asset_catalog.rb +185 -0
- data/lib/everywhere/auth_handoff.rb +398 -0
- data/lib/everywhere/auth_token.rb +118 -0
- data/lib/everywhere/builders/android.rb +844 -0
- data/lib/everywhere/builders/ios.rb +72 -3
- data/lib/everywhere/commands/build.rb +51 -9
- data/lib/everywhere/commands/clean.rb +10 -5
- data/lib/everywhere/commands/dev.rb +46 -8
- data/lib/everywhere/commands/doctor.rb +89 -3
- data/lib/everywhere/commands/install.rb +1 -1
- data/lib/everywhere/commands/logs.rb +117 -8
- data/lib/everywhere/config.rb +513 -14
- data/lib/everywhere/emulator.rb +405 -0
- data/lib/everywhere/engine.rb +21 -0
- data/lib/everywhere/icon.rb +196 -0
- data/lib/everywhere/log_filter.rb +74 -0
- data/lib/everywhere/mobile_config_endpoint.rb +13 -0
- data/lib/everywhere/mobile_configs_controller.rb +18 -0
- data/lib/everywhere/native_helper.rb +198 -5
- data/lib/everywhere/omniauth.rb +88 -0
- data/lib/everywhere/paths.rb +47 -0
- data/lib/everywhere/version.rb +1 -1
- data/support/mobile/android/README.md +217 -0
- data/support/mobile/android/app/build.gradle.kts +156 -0
- data/support/mobile/android/app/everywhere.properties +8 -0
- data/support/mobile/android/app/native-packages.gradle.kts +16 -0
- data/support/mobile/android/app/proguard-rules.pro +21 -0
- data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
- data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
- data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
- data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
- data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
- data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
- data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
- data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
- data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
- data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
- data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
- data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
- data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
- data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
- data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
- data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
- data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
- data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
- data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
- data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
- data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
- data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
- data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
- data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
- data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
- data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
- data/support/mobile/android/build.gradle.kts +8 -0
- data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
- data/support/mobile/android/gradle.properties +14 -0
- data/support/mobile/android/gradlew +251 -0
- data/support/mobile/android/gradlew.bat +94 -0
- data/support/mobile/android/settings.gradle.kts +28 -0
- data/support/mobile/ios/App/AppDelegate.swift +7 -1
- data/support/mobile/ios/App/AuthFlow.swift +101 -0
- data/support/mobile/ios/App/Bridge/MenuComponent.swift +192 -0
- data/support/mobile/ios/App/EverywhereConfig.swift +105 -0
- data/support/mobile/ios/App/SceneDelegate.swift +144 -0
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +8 -0
- data/support/mobile/ios/README.md +10 -0
- 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
|