ruby_everywhere 0.3.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +255 -0
  3. data/bridge/everywhere/bridge.js +60 -3
  4. data/lib/everywhere/android_resources.rb +217 -0
  5. data/lib/everywhere/android_sdk.rb +330 -0
  6. data/lib/everywhere/asset_catalog.rb +185 -0
  7. data/lib/everywhere/auth_handoff.rb +398 -0
  8. data/lib/everywhere/auth_token.rb +118 -0
  9. data/lib/everywhere/builders/android.rb +844 -0
  10. data/lib/everywhere/builders/ios.rb +40 -3
  11. data/lib/everywhere/commands/build.rb +51 -9
  12. data/lib/everywhere/commands/clean.rb +10 -5
  13. data/lib/everywhere/commands/dev.rb +46 -8
  14. data/lib/everywhere/commands/doctor.rb +89 -3
  15. data/lib/everywhere/commands/install.rb +1 -1
  16. data/lib/everywhere/commands/logs.rb +117 -8
  17. data/lib/everywhere/config.rb +409 -13
  18. data/lib/everywhere/emulator.rb +405 -0
  19. data/lib/everywhere/engine.rb +12 -0
  20. data/lib/everywhere/icon.rb +196 -0
  21. data/lib/everywhere/log_filter.rb +74 -0
  22. data/lib/everywhere/native_helper.rb +2 -5
  23. data/lib/everywhere/omniauth.rb +88 -0
  24. data/lib/everywhere/paths.rb +47 -0
  25. data/lib/everywhere/version.rb +1 -1
  26. data/support/mobile/android/README.md +217 -0
  27. data/support/mobile/android/app/build.gradle.kts +156 -0
  28. data/support/mobile/android/app/everywhere.properties +8 -0
  29. data/support/mobile/android/app/native-packages.gradle.kts +16 -0
  30. data/support/mobile/android/app/proguard-rules.pro +21 -0
  31. data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
  32. data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
  33. data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
  34. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
  35. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
  36. data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
  37. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
  38. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
  39. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
  40. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
  41. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
  42. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
  43. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
  44. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
  45. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
  46. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
  47. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
  48. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
  49. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
  50. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
  51. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
  52. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
  53. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
  54. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
  55. data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
  56. data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
  57. data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
  58. data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
  59. data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
  60. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
  61. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
  62. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
  63. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
  64. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
  65. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  66. data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
  67. data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
  68. data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
  69. data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
  70. data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
  71. data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
  72. data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
  73. data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
  74. data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
  75. data/support/mobile/android/build.gradle.kts +8 -0
  76. data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  77. data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
  78. data/support/mobile/android/gradle.properties +14 -0
  79. data/support/mobile/android/gradlew +251 -0
  80. data/support/mobile/android/gradlew.bat +94 -0
  81. data/support/mobile/android/settings.gradle.kts +28 -0
  82. data/support/mobile/ios/App/AppDelegate.swift +5 -0
  83. data/support/mobile/ios/App/AuthFlow.swift +101 -0
  84. data/support/mobile/ios/App/EverywhereConfig.swift +84 -0
  85. data/support/mobile/ios/App/SceneDelegate.swift +103 -0
  86. data/support/mobile/ios/App.xcodeproj/project.pbxproj +4 -0
  87. data/support/mobile/ios/README.md +4 -1
  88. metadata +67 -1
@@ -0,0 +1,19 @@
1
+ {
2
+ "settings": {},
3
+ "rules": [
4
+ {
5
+ "patterns": [".*"],
6
+ "properties": {
7
+ "context": "default",
8
+ "pull_to_refresh_enabled": true
9
+ }
10
+ },
11
+ {
12
+ "patterns": ["/new$", "/edit$"],
13
+ "properties": {
14
+ "context": "modal",
15
+ "pull_to_refresh_enabled": false
16
+ }
17
+ }
18
+ ]
19
+ }
@@ -0,0 +1,268 @@
1
+ package com.rubyeverywhere.shell
2
+
3
+ import android.content.ActivityNotFoundException
4
+ import android.content.Intent
5
+ import android.content.pm.ApplicationInfo
6
+ import android.net.Uri
7
+ import android.util.Log
8
+ import androidx.activity.ComponentActivity
9
+ import androidx.browser.customtabs.CustomTabsIntent
10
+ import androidx.core.net.toUri
11
+ import androidx.lifecycle.DefaultLifecycleObserver
12
+ import androidx.lifecycle.Lifecycle
13
+ import androidx.lifecycle.LifecycleOwner
14
+
15
+ /**
16
+ * Third-party sign-in (Sign in with Apple / Google / GitHub / anything the app
17
+ * routes through `auth.oauth_paths`), run in a Chrome Custom Tab rather than the
18
+ * app's own web view.
19
+ *
20
+ * Not a stylistic choice: Google rejects OAuth from an embedded WebView outright
21
+ * (`disallowed_useragent`), and the others degrade badly there — no password
22
+ * manager, none of the user's existing provider session, 2FA fighting a keyboard
23
+ * the page doesn't control. A Custom Tab is the user's real browser, sharing its
24
+ * cookie jar, so a signed-in user usually taps once. It is the Android analogue
25
+ * of iOS's `ASWebAuthenticationSession` (support/mobile/ios/App/AuthFlow.swift),
26
+ * and the gem side of the flow is untouched — nothing below knows a provider
27
+ * from a hole in the ground:
28
+ *
29
+ * 1. open /everywhere/auth/start?to=…&return_to=… (in the browser)
30
+ * 2. the app's own OAuth runs and lands somewhere signed in
31
+ * 3. Everywhere::AuthHandoff seals that cookie jar into a one-time token and
32
+ * redirects to <scheme>://auth?token=…
33
+ * 4. that deep link arrives here; MainActivity spends the token at
34
+ * /everywhere/auth/handoff in the web view, which replays the jar there
35
+ *
36
+ * A singleton rather than a per-Activity object because the flow outlives the
37
+ * Activity that started it: the callback is a deep link, and Android is free to
38
+ * destroy and recreate MainActivity while the browser is up.
39
+ *
40
+ * ### Two honest differences from iOS
41
+ *
42
+ * **The callback is weaker.** iOS delivers the redirect *to the session that
43
+ * started it*, so no other app can claim it. A custom scheme on Android is
44
+ * first-come-first-served, and a malicious app registering the same one could
45
+ * receive the token. That is the standard Android OAuth exposure, and the
46
+ * mitigations are the ones the gem already ships: the handoff token is
47
+ * single-use (`AuthToken.decrypt(once: true)`) and short-lived, so a stolen one
48
+ * is a race rather than a durable credential. App Links (`autoVerify`) would
49
+ * close it properly; they can't, because the callback URL shape is shared with
50
+ * iOS.
51
+ *
52
+ * **Cancellation is inferred, not reported.** A Custom Tab hands back no result.
53
+ * Backing out of it simply resumes the Activity, so a resume while a flow is
54
+ * still pending *is* the cancellation — the AppAuth pattern. Same outcome as
55
+ * iOS: the user stays exactly where they started and nothing is shown.
56
+ */
57
+ object AuthFlow {
58
+
59
+ /** Mirrors iOS's `Result<String, AuthFlow.Failure>`. */
60
+ sealed interface Result {
61
+ data class Success(val token: String) : Result
62
+
63
+ /** The user dismissed the browser. Not an error worth showing them. */
64
+ data object Canceled : Result
65
+
66
+ /** No browser, or a callback that carried nothing to spend. */
67
+ data class Failed(val message: String) : Result
68
+ }
69
+
70
+ private const val TAG = "EverywhereAuth"
71
+
72
+ private var completion: ((Result) -> Unit)? = null
73
+ private var observer: Watcher? = null
74
+
75
+ /**
76
+ * Set by [cancel] and cleared by the next callback or [start]. Without it a
77
+ * flow the app deliberately abandoned — an instance switch, a sign-out from
78
+ * another surface — could still sign the user back in through the orphan
79
+ * path below when its redirect eventually lands.
80
+ */
81
+ private var abandoned = false
82
+
83
+ val isRunning: Boolean
84
+ get() = completion != null
85
+
86
+ /**
87
+ * Open the provider sign-in in the browser. Nothing is loaded in the web
88
+ * view: the whole point is that this URL never touches it.
89
+ *
90
+ * @param startUrl the gem's /everywhere/auth/start, already carrying `to`
91
+ * and `return_to` (EverywhereConfig.authStartUrl)
92
+ * @param scheme the app's callback scheme, from everywhere.yml `auth.scheme`
93
+ */
94
+ fun start(
95
+ activity: ComponentActivity,
96
+ startUrl: String,
97
+ scheme: String,
98
+ completion: (Result) -> Unit
99
+ ) {
100
+ // A second tap while the browser is up must not restart the flow: the
101
+ // first one is still going to come back through our scheme.
102
+ if (isRunning) return
103
+
104
+ if (scheme.isEmpty()) {
105
+ completion(Result.Failed("no auth scheme is declared in everywhere.yml"))
106
+ return
107
+ }
108
+
109
+ if (activity.isDebuggable) Log.d(TAG, "authFlow: $startUrl")
110
+
111
+ abandoned = false
112
+ this.completion = completion
113
+ watch(activity)
114
+
115
+ val intent = CustomTabsIntent.Builder()
116
+ .setShowTitle(true)
117
+ .setShareState(CustomTabsIntent.SHARE_STATE_OFF)
118
+ .build()
119
+
120
+ // The browser tab must not outlive the flow. Without this the callback
121
+ // deep link brings the app forward but leaves the sign-in page sitting
122
+ // in the task, so the first Back press drops the freshly signed-in user
123
+ // back onto the provider's "you're already signed in" screen.
124
+ intent.intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
125
+
126
+ try {
127
+ intent.launchUrl(activity, startUrl.toUri())
128
+ } catch (e: ActivityNotFoundException) {
129
+ // CustomTabsIntent degrades to a plain ACTION_VIEW when no browser
130
+ // supports Custom Tabs, so reaching here means there is nothing on
131
+ // the device that can open an http URL at all.
132
+ Log.e(TAG, "authFlow: no browser available", e)
133
+ settle(Result.Failed("no browser is available to sign in with"))
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Feed every incoming deep link through here. Returns true when the URI was
139
+ * our sign-in callback, so the caller knows not to treat it as a link into
140
+ * app content.
141
+ */
142
+ fun handleCallback(uri: Uri): Boolean {
143
+ val scheme = EverywhereConfig.shared.authScheme
144
+ if (scheme.isNullOrEmpty() || !uri.scheme.equals(scheme, ignoreCase = true)) return false
145
+
146
+ // Claim it before the resume that follows can read the pending flow as
147
+ // a cancellation.
148
+ val pending = completion
149
+ stopWatching()
150
+ completion = null
151
+
152
+ // The host is `auth` today (AuthHandoff#close), but iOS accepts any host
153
+ // its scheme returns through, and so do we — the token is the contract.
154
+ val token = runCatching { uri.getQueryParameter("token") }.getOrNull()
155
+ if (token.isNullOrBlank()) {
156
+ Log.w(TAG, "authFlow: the callback carried no token")
157
+ abandoned = false
158
+ pending?.invoke(Result.Failed("the sign-in callback carried no token"))
159
+ return true
160
+ }
161
+
162
+ if (pending != null) {
163
+ abandoned = false
164
+ pending(Result.Success(token))
165
+ return true
166
+ }
167
+
168
+ // Orphaned: the Activity that started the flow was destroyed (a rotation
169
+ // while the browser was up), or the callback cold-launched the process.
170
+ // The token is still perfectly good and the user still expects to end up
171
+ // signed in, so spend it through the normal visit path. What is lost
172
+ // versus the live path is only the immediate config refetch — the
173
+ // auth-gated tabs catch up on the next foreground.
174
+ if (abandoned) {
175
+ abandoned = false
176
+ Log.i(TAG, "authFlow: ignoring a callback for an abandoned flow")
177
+ return true
178
+ }
179
+
180
+ Log.i(TAG, "authFlow: completing an orphaned callback through a native visit")
181
+ EverywhereEvents.emit(
182
+ EverywhereEvents.Event.NativeVisit(EverywhereConfig.shared.authHandoffUrl(token))
183
+ )
184
+ return true
185
+ }
186
+
187
+ /**
188
+ * Abandon a flow that's still out — used when the app resets underneath it
189
+ * (an instance switch, a sign-out from another surface), where whatever it
190
+ * returns would be answering a question the app no longer has.
191
+ *
192
+ * Unlike iOS this cannot close the browser: a Custom Tab belongs to the
193
+ * browser, not to us. It stops us acting on the return, and stops the
194
+ * resume that follows from being reported as a cancellation.
195
+ */
196
+ fun cancel() {
197
+ if (!isRunning) return
198
+ abandoned = true
199
+ stopWatching()
200
+ completion = null
201
+ }
202
+
203
+ // ------------------------------------------------------------------------
204
+ // Cancellation detection
205
+ // ------------------------------------------------------------------------
206
+
207
+ private fun watch(activity: ComponentActivity) {
208
+ stopWatching()
209
+ Watcher(activity).also {
210
+ observer = it
211
+ activity.lifecycle.addObserver(it)
212
+ }
213
+ }
214
+
215
+ private fun stopWatching() {
216
+ observer?.detach()
217
+ observer = null
218
+ }
219
+
220
+ private fun settle(result: Result) {
221
+ val pending = completion ?: return
222
+ stopWatching()
223
+ completion = null
224
+ pending(result)
225
+ }
226
+
227
+ /**
228
+ * Watches the Activity that launched the browser. `leftForBrowser` is what
229
+ * makes this reliable: [start] runs while the Activity is resumed, so
230
+ * without it the very next resume — the one that follows our own launch —
231
+ * would read as a cancellation.
232
+ */
233
+ private class Watcher(activity: ComponentActivity) : DefaultLifecycleObserver {
234
+ private var lifecycle: Lifecycle? = activity.lifecycle
235
+ private var leftForBrowser = false
236
+
237
+ fun detach() {
238
+ lifecycle?.removeObserver(this)
239
+ lifecycle = null
240
+ }
241
+
242
+ override fun onPause(owner: LifecycleOwner) {
243
+ leftForBrowser = true
244
+ }
245
+
246
+ override fun onResume(owner: LifecycleOwner) {
247
+ if (!leftForBrowser) return
248
+ AuthFlow.settle(Result.Canceled)
249
+ }
250
+
251
+ /**
252
+ * The Activity is gone (a rotation while the browser was up), so its
253
+ * completion closes over a destroyed screen and must not be called.
254
+ * The flow itself isn't over — the redirect is still coming — and
255
+ * [handleCallback]'s orphan path is what finishes it.
256
+ */
257
+ override fun onDestroy(owner: LifecycleOwner) {
258
+ detach()
259
+ if (AuthFlow.observer === this) {
260
+ AuthFlow.observer = null
261
+ AuthFlow.completion = null
262
+ }
263
+ }
264
+ }
265
+
266
+ private val ComponentActivity.isDebuggable: Boolean
267
+ get() = applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
268
+ }
@@ -0,0 +1,178 @@
1
+ package com.rubyeverywhere.shell
2
+
3
+ import android.app.Application
4
+ import android.webkit.CookieManager
5
+ import androidx.core.net.toUri
6
+ import com.rubyeverywhere.shell.bridge.BiometricsComponent
7
+ import com.rubyeverywhere.shell.bridge.HapticsComponent
8
+ import com.rubyeverywhere.shell.bridge.MenuComponent
9
+ import com.rubyeverywhere.shell.bridge.NotificationComponent
10
+ import com.rubyeverywhere.shell.bridge.PermissionsComponent
11
+ import com.rubyeverywhere.shell.bridge.StorageComponent
12
+ import com.rubyeverywhere.shell.bridge.WebControlChannel
13
+ import com.rubyeverywhere.shell.extensions.EverywhereExtensions
14
+ import dev.hotwire.core.bridge.BridgeComponentFactory
15
+ import dev.hotwire.core.bridge.KotlinXJsonConverter
16
+ import dev.hotwire.core.config.Hotwire
17
+ import dev.hotwire.core.logging.HotwireLogLevel
18
+ import dev.hotwire.core.turbo.config.PathConfiguration
19
+ import dev.hotwire.core.turbo.visit.VisitProposal
20
+ import dev.hotwire.core.turbo.webview.HotwireWebView
21
+ import dev.hotwire.navigation.activities.HotwireActivity
22
+ import dev.hotwire.navigation.config.defaultFragmentDestination
23
+ import dev.hotwire.navigation.config.registerBridgeComponents
24
+ import dev.hotwire.navigation.config.registerFragmentDestinations
25
+ import dev.hotwire.navigation.config.registerRouteDecisionHandlers
26
+ import dev.hotwire.navigation.navigator.NavigatorConfiguration
27
+ import dev.hotwire.navigation.routing.AppNavigationRouteDecisionHandler
28
+ import dev.hotwire.navigation.routing.BrowserTabRouteDecisionHandler
29
+ import dev.hotwire.navigation.routing.Router
30
+ import dev.hotwire.navigation.routing.SystemNavigationRouteDecisionHandler
31
+
32
+ /**
33
+ * Everything that must be true before the first Activity exists: the parsed
34
+ * config, the framework's global configuration, the bridge, and the first read
35
+ * of the path configuration.
36
+ *
37
+ * The Android counterpart of iOS's AppDelegate.configureHotwire, with one
38
+ * difference worth stating: on Android the path configuration is loaded here
39
+ * rather than from the Activity, because `MainActivity.navigatorConfigurations()`
40
+ * is read during its `super.onCreate` and needs the tabs to already exist. The
41
+ * bundled copy loads synchronously, so they do.
42
+ */
43
+ class EverywhereApplication : Application() {
44
+
45
+ override fun onCreate() {
46
+ super.onCreate()
47
+
48
+ EverywhereConfig.initialize(this)
49
+ configureHotwire()
50
+ loadPathConfiguration()
51
+ }
52
+
53
+ private fun configureHotwire() {
54
+ val config = EverywhereConfig.shared
55
+
56
+ Hotwire.defaultFragmentDestination = WebFragment::class
57
+
58
+ Hotwire.registerFragmentDestinations(
59
+ WebFragment::class,
60
+ WebBottomSheetFragment::class,
61
+ // App-provided native screens (everywhere.yml native.android.screens).
62
+ *EverywhereExtensions.fragmentDestinations.toTypedArray()
63
+ )
64
+
65
+ Hotwire.registerBridgeComponents(
66
+ BridgeComponentFactory("everywhere--notification", ::NotificationComponent),
67
+ BridgeComponentFactory("everywhere--haptics", ::HapticsComponent),
68
+ BridgeComponentFactory("everywhere--permissions", ::PermissionsComponent),
69
+ BridgeComponentFactory("everywhere--biometrics", ::BiometricsComponent),
70
+ BridgeComponentFactory("everywhere--storage", ::StorageComponent),
71
+ BridgeComponentFactory("everywhere--menu", ::MenuComponent),
72
+ *EverywhereExtensions.bridgeComponents.toTypedArray()
73
+ )
74
+
75
+ // Order matters: ours run before the framework's, and the framework's
76
+ // first handler claims every same-host URL. A reset or a provider
77
+ // sign-in has to be recognised before that happens.
78
+ Hotwire.registerRouteDecisionHandlers(
79
+ ResetRouteDecisionHandler(),
80
+ AuthFlowRouteDecisionHandler(),
81
+ AppNavigationRouteDecisionHandler(),
82
+ BrowserTabRouteDecisionHandler(),
83
+ SystemNavigationRouteDecisionHandler()
84
+ )
85
+
86
+ Hotwire.config.jsonConverter = KotlinXJsonConverter()
87
+ Hotwire.config.applicationUserAgentPrefix = config.userAgentPrefix
88
+ Hotwire.config.webViewDebuggingEnabled = BuildConfig.DEBUG
89
+ Hotwire.config.logger.logLevel =
90
+ if (BuildConfig.DEBUG) HotwireLogLevel.DEBUG else HotwireLogLevel.NONE
91
+
92
+ // Android WebView has no user-script API, so the two things iOS does
93
+ // with WKUserContentController — expose the control channel, and inject
94
+ // window.__EVERYWHERE_CONFIG__ at document start — are both done inside
95
+ // WebControlChannel.install, through WebViewCompat, per web view.
96
+ //
97
+ // Per web view, not once at launch, because webConfigJson carries the
98
+ // instance override, and that changes when a picker re-roots the app.
99
+ Hotwire.config.makeCustomWebView = { context ->
100
+ HotwireWebView(context, null).also { webView ->
101
+ WebControlChannel.install(webView, config.webConfigJson)
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Both sources at launch: the bundled copy loads synchronously (instant
108
+ * tabs, works offline) and the server copy refines it. The server copy is
109
+ * the auth-gated one, so it is fetched with the WebView's cookies — see
110
+ * [MainActivity.cookieHeaders] for why that is not automatic.
111
+ */
112
+ private fun loadPathConfiguration() {
113
+ val config = EverywhereConfig.shared
114
+ val remoteUrl = config.pathConfigurationUrl
115
+ val cookie = runCatching { CookieManager.getInstance().getCookie(remoteUrl) }.getOrNull()
116
+
117
+ Hotwire.loadPathConfiguration(
118
+ context = this,
119
+ location = PathConfiguration.Location(
120
+ assetFilePath = "json/path-configuration.json",
121
+ remoteFileUrl = remoteUrl
122
+ ),
123
+ options = PathConfiguration.LoaderOptions(
124
+ httpHeaders = if (cookie.isNullOrEmpty()) emptyMap() else mapOf("Cookie" to cookie)
125
+ )
126
+ )
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Intercepts `/everywhere/reset` — the URL the gem redirects to after a sign-in
132
+ * or sign-out — natively, so resetting never depends on the reset page's
133
+ * JavaScript running. Cancelling the visit leaves the reset to [MainActivity],
134
+ * which does the whole sequence deterministically.
135
+ */
136
+ private class ResetRouteDecisionHandler : Router.RouteDecisionHandler {
137
+ override val name = "everywhere-reset"
138
+
139
+ override fun matches(proposal: VisitProposal, configuration: NavigatorConfiguration): Boolean =
140
+ proposal.location.toUri().path == RESET_PATH
141
+
142
+ override fun handle(
143
+ proposal: VisitProposal,
144
+ configuration: NavigatorConfiguration,
145
+ activity: HotwireActivity
146
+ ): Router.Decision {
147
+ val to = proposal.location.toUri().getQueryParameter("to")
148
+ EverywhereEvents.emit(EverywhereEvents.Event.ResetApp(to))
149
+ return Router.Decision.CANCEL
150
+ }
151
+
152
+ private companion object {
153
+ const val RESET_PATH = "/everywhere/reset"
154
+ }
155
+ }
156
+
157
+ /**
158
+ * A provider sign-in: hand it to a Custom Tab instead of loading it in the web
159
+ * view. Cancelling leaves the user on the page they tapped from, which is where
160
+ * they belong if they close the tab.
161
+ */
162
+ private class AuthFlowRouteDecisionHandler : Router.RouteDecisionHandler {
163
+ override val name = "everywhere-auth"
164
+
165
+ override fun matches(proposal: VisitProposal, configuration: NavigatorConfiguration): Boolean =
166
+ EverywhereConfig.shared.authTarget(proposal.location) != null
167
+
168
+ override fun handle(
169
+ proposal: VisitProposal,
170
+ configuration: NavigatorConfiguration,
171
+ activity: HotwireActivity
172
+ ): Router.Decision {
173
+ EverywhereConfig.shared.authTarget(proposal.location)?.let {
174
+ EverywhereEvents.emit(EverywhereEvents.Event.AuthFlow(it))
175
+ }
176
+ return Router.Decision.CANCEL
177
+ }
178
+ }