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.
- checksums.yaml +4 -4
- data/README.md +255 -0
- data/bridge/everywhere/bridge.js +60 -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 +40 -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 +409 -13
- data/lib/everywhere/emulator.rb +405 -0
- data/lib/everywhere/engine.rb +12 -0
- data/lib/everywhere/icon.rb +196 -0
- data/lib/everywhere/log_filter.rb +74 -0
- data/lib/everywhere/native_helper.rb +2 -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 +5 -0
- data/support/mobile/ios/App/AuthFlow.swift +101 -0
- data/support/mobile/ios/App/EverywhereConfig.swift +84 -0
- data/support/mobile/ios/App/SceneDelegate.swift +103 -0
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +4 -0
- data/support/mobile/ios/README.md +4 -1
- metadata +67 -1
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
package com.rubyeverywhere.shell
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.SharedPreferences
|
|
5
|
+
import android.content.res.Configuration
|
|
6
|
+
import android.graphics.Color
|
|
7
|
+
import androidx.annotation.ColorInt
|
|
8
|
+
import androidx.core.content.edit
|
|
9
|
+
import androidx.core.net.toUri
|
|
10
|
+
import kotlinx.serialization.json.Json
|
|
11
|
+
import kotlinx.serialization.json.JsonElement
|
|
12
|
+
import kotlinx.serialization.json.JsonObject
|
|
13
|
+
import kotlinx.serialization.json.JsonPrimitive
|
|
14
|
+
import kotlinx.serialization.json.booleanOrNull
|
|
15
|
+
import kotlinx.serialization.json.buildJsonObject
|
|
16
|
+
import kotlinx.serialization.json.contentOrNull
|
|
17
|
+
import kotlinx.serialization.json.doubleOrNull
|
|
18
|
+
import kotlinx.serialization.json.jsonArray
|
|
19
|
+
import kotlinx.serialization.json.jsonObject
|
|
20
|
+
import kotlinx.serialization.json.jsonPrimitive
|
|
21
|
+
import kotlinx.serialization.json.put
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A colour that adapts to light/dark mode. Decodes either a single hex string
|
|
25
|
+
* (used for both appearances) or a `{"light": "#RRGGBB", "dark": "#RRGGBB"}`
|
|
26
|
+
* object, matching iOS's ThemedColor exactly — the same everywhere.json is
|
|
27
|
+
* stamped for both shells.
|
|
28
|
+
*
|
|
29
|
+
* Note the shell rarely needs these: the theme reads the same two colours from
|
|
30
|
+
* res/values/colors.xml, which the builder stamps at the same time. They exist
|
|
31
|
+
* for the places a theme attribute can't reach, such as a Custom Tab's toolbar.
|
|
32
|
+
*/
|
|
33
|
+
class ThemedColor(private val light: String, private val dark: String?) {
|
|
34
|
+
@ColorInt
|
|
35
|
+
fun colorInt(context: Context): Int? {
|
|
36
|
+
val night = context.resources.configuration.uiMode and
|
|
37
|
+
Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
|
|
38
|
+
val hex = if (night) dark ?: light else light
|
|
39
|
+
return parseHex(hex)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
companion object {
|
|
43
|
+
internal fun from(element: JsonElement?): ThemedColor? = when (element) {
|
|
44
|
+
null -> null
|
|
45
|
+
is JsonPrimitive -> element.contentOrNull?.let { ThemedColor(it, null) }
|
|
46
|
+
is JsonObject -> {
|
|
47
|
+
val light = element["light"]?.jsonPrimitive?.contentOrNull
|
|
48
|
+
val dark = element["dark"]?.jsonPrimitive?.contentOrNull
|
|
49
|
+
(light ?: dark)?.let { ThemedColor(it, dark ?: light) }
|
|
50
|
+
}
|
|
51
|
+
else -> null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Accepts `#RRGGBB` and `#RRGGBBAA` (leading `#` optional). */
|
|
55
|
+
@ColorInt
|
|
56
|
+
private fun parseHex(value: String): Int? {
|
|
57
|
+
val raw = value.trim().removePrefix("#")
|
|
58
|
+
if (raw.length != 6 && raw.length != 8) return null
|
|
59
|
+
val bits = raw.toLongOrNull(16) ?: return null
|
|
60
|
+
return if (raw.length == 8) {
|
|
61
|
+
// #RRGGBBAA on the wire, 0xAARRGGBB in Android.
|
|
62
|
+
val alpha = (bits and 0xFF).toInt()
|
|
63
|
+
Color.argb(alpha, ((bits shr 24) and 0xFF).toInt(), ((bits shr 16) and 0xFF).toInt(), ((bits shr 8) and 0xFF).toInt())
|
|
64
|
+
} else {
|
|
65
|
+
Color.rgb(((bits shr 16) and 0xFF).toInt(), ((bits shr 8) and 0xFF).toInt(), (bits and 0xFF).toInt())
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Third-party sign-in (everywhere.yml `auth:`): which paths hand off to a
|
|
73
|
+
* Custom Tab instead of loading in the web view, and the custom scheme the
|
|
74
|
+
* callback comes back through. Absent when the app declares no `auth:` — a
|
|
75
|
+
* shell that diverts nothing.
|
|
76
|
+
*/
|
|
77
|
+
class AuthConfig(val oauthPaths: List<String>, val scheme: String) {
|
|
78
|
+
/**
|
|
79
|
+
* The patterns compiled once. They're regex sources, matched unanchored,
|
|
80
|
+
* exactly like the path-configuration `rules:` patterns they sit beside in
|
|
81
|
+
* everywhere.yml — and exactly like the gem matches them server-side, so
|
|
82
|
+
* both halves of the flow agree on what a provider path is.
|
|
83
|
+
*/
|
|
84
|
+
private val expressions: List<Regex> = oauthPaths.mapNotNull {
|
|
85
|
+
runCatching { Regex(it) }.getOrNull()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Half a declaration is worse than none: without a scheme there's nothing
|
|
90
|
+
* for the callback to return through, and diverting a visit we can't
|
|
91
|
+
* finish would turn the sign-in button into a button that does nothing.
|
|
92
|
+
*/
|
|
93
|
+
val isUsable: Boolean get() = scheme.isNotEmpty() && expressions.isNotEmpty()
|
|
94
|
+
|
|
95
|
+
fun matches(path: String): Boolean = expressions.any { it.containsMatchIn(path) }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The app's configuration, stamped into `assets/everywhere.json` by
|
|
100
|
+
* `every build`. The raw template ships a placeholder pointing at
|
|
101
|
+
* rubyeverywhere.com.
|
|
102
|
+
*
|
|
103
|
+
* Parsed from [JsonObject] rather than through `@Serializable` data classes on
|
|
104
|
+
* purpose: `tint_color` is either a string or an object, `permissions` may be
|
|
105
|
+
* absent entirely, and the gem adds keys (desktop `menu:`, `tray:`) that this
|
|
106
|
+
* shell has no business knowing about. Hand-reading tolerates all three
|
|
107
|
+
* without a custom serializer per field.
|
|
108
|
+
*/
|
|
109
|
+
class EverywhereConfig private constructor(
|
|
110
|
+
private val appContext: Context,
|
|
111
|
+
private val json: JsonObject
|
|
112
|
+
) {
|
|
113
|
+
val name: String = json.string("name") ?: "RubyEverywhere"
|
|
114
|
+
val version: String? = json.string("version")
|
|
115
|
+
val entryPath: String? = json.string("entry_path")
|
|
116
|
+
val remoteInstances: Boolean = json["remote_instances"]?.jsonPrimitive?.booleanOrNull == true
|
|
117
|
+
val permissions: List<String> =
|
|
118
|
+
json["permissions"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
|
|
119
|
+
val universalLinkHosts: List<String> =
|
|
120
|
+
json["universal_link_hosts"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
|
|
121
|
+
|
|
122
|
+
val tintColor: ThemedColor? = ThemedColor.from(json["tint_color"])
|
|
123
|
+
val backgroundColor: ThemedColor? = ThemedColor.from(json["background_color"])
|
|
124
|
+
|
|
125
|
+
private val stampedRemoteUrl: String? = json.string("remote_url")
|
|
126
|
+
|
|
127
|
+
private val auth: AuthConfig? = json["auth"]?.jsonObject?.let { obj ->
|
|
128
|
+
AuthConfig(
|
|
129
|
+
oauthPaths = obj["oauth_paths"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(),
|
|
130
|
+
scheme = obj.string("scheme") ?: ""
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Whether each non-selected tab defers its initial visit until first
|
|
136
|
+
* selected (everywhere.yml `lazy_load_tabs`).
|
|
137
|
+
*
|
|
138
|
+
* Unlike iOS this defaults to `true` when absent, and the shell passes
|
|
139
|
+
* `true` to the controller regardless — see [MainActivity]. Five pooled
|
|
140
|
+
* NavigatorHosts loading eagerly means five cold-boot WebViews and five
|
|
141
|
+
* start-location fetches at launch, including the hidden auth-gated ones.
|
|
142
|
+
* The value is still read so `lazy_load_tabs: false` is at least visible
|
|
143
|
+
* in the config, but Android cannot honour it.
|
|
144
|
+
*/
|
|
145
|
+
val lazyLoadTabs: Boolean = json["lazy_load_tabs"]?.jsonPrimitive?.booleanOrNull ?: true
|
|
146
|
+
|
|
147
|
+
/** Minimum on-screen time for an app-provided splash, in milliseconds. */
|
|
148
|
+
val splashMinimumDisplay: Long =
|
|
149
|
+
((json["splash_min_seconds"]?.jsonPrimitive?.doubleOrNull ?: 1.0) * 1000).toLong()
|
|
150
|
+
|
|
151
|
+
val userAgentPrefix: String =
|
|
152
|
+
version?.let { "RubyEverywhere/$it (android)" } ?: "RubyEverywhere (android)"
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* User-Agent for native HTTP requests ([everywhereFetch]): the shell prefix
|
|
156
|
+
* plus the Hotwire Native marker, so the gem's native_app?/native_platform/
|
|
157
|
+
* native_version helpers see native-screen requests exactly like web-view
|
|
158
|
+
* requests. The framework appends the marker itself for the WebView; a
|
|
159
|
+
* hand-built request has to say it.
|
|
160
|
+
*/
|
|
161
|
+
val nativeUserAgent: String
|
|
162
|
+
get() = "$userAgentPrefix Hotwire Native Android"
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Whether everywhere.yml declares this permission. Undeclared permissions
|
|
166
|
+
* must never reach the system APIs: Android only merges a
|
|
167
|
+
* `<uses-permission>` for declared ones, and requesting a permission the
|
|
168
|
+
* manifest doesn't hold fails silently — a denial the user never saw and
|
|
169
|
+
* can never undo.
|
|
170
|
+
*/
|
|
171
|
+
fun declaresPermission(name: String): Boolean = permissions.contains(name)
|
|
172
|
+
|
|
173
|
+
// MARK: Roots
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The root URL of the deployed app. A picked instance outranks the dev
|
|
177
|
+
* override on purpose: it's the more recent, in-app expression of intent,
|
|
178
|
+
* and dev-testing the picker flow only works if picking takes effect.
|
|
179
|
+
* `Everywhere.instance.clear()` falls back to the dev/stamped root.
|
|
180
|
+
*/
|
|
181
|
+
val rootUrl: String
|
|
182
|
+
get() = instanceUrl ?: devUrl ?: stampedRemoteUrl
|
|
183
|
+
?: error("remote.url not set in everywhere.yml — the Android shell is remote-mode only")
|
|
184
|
+
|
|
185
|
+
/** The first location the navigator visits: rootUrl joined with entry_path. */
|
|
186
|
+
val startUrl: String
|
|
187
|
+
get() {
|
|
188
|
+
val path = entryPath
|
|
189
|
+
return if (path.isNullOrEmpty() || path == "/") rootUrl else url(forPath = path)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The server half of the path configuration (rules + settings.tabs),
|
|
194
|
+
* generated from everywhere.yml by the gem's MobileConfigEndpoint — so tab
|
|
195
|
+
* changes deploy with the web app, no Play Store release.
|
|
196
|
+
*/
|
|
197
|
+
val pathConfigurationUrl: String
|
|
198
|
+
get() = url(forPath = "/everywhere/android_v1.json")
|
|
199
|
+
|
|
200
|
+
/** rootUrl joined with an absolute-style path (leading `/` optional). */
|
|
201
|
+
fun url(forPath: String): String {
|
|
202
|
+
val base = rootUrl.trimEnd('/')
|
|
203
|
+
val suffix = if (forPath.startsWith("/")) forPath else "/$forPath"
|
|
204
|
+
return base + suffix
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The path (plus query) of an absolute URL, for re-routing a deep link
|
|
209
|
+
* through the app's own effective root (dev override / picked instance).
|
|
210
|
+
*/
|
|
211
|
+
fun pathAndQuery(url: String): String {
|
|
212
|
+
val uri = url.toUri()
|
|
213
|
+
val path = uri.path?.takeIf { it.isNotEmpty() } ?: "/"
|
|
214
|
+
val query = uri.query
|
|
215
|
+
return if (query.isNullOrEmpty()) path else "$path?$query"
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// MARK: Dev-server override
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The dev-server URL, in debug builds only.
|
|
222
|
+
*
|
|
223
|
+
* Android has no `SIMCTL_CHILD_*`, so there is nothing to read from the
|
|
224
|
+
* launching environment: `every dev --android` stamps
|
|
225
|
+
* `src/debug/assets/dev-url.txt` and rebuilds, which it has to do anyway.
|
|
226
|
+
* Baked into the APK means it also survives a relaunch from the launcher,
|
|
227
|
+
* which is the case iOS needs UserDefaults to paper over. Release builds
|
|
228
|
+
* never look for the file, and the debug source set never ships in one.
|
|
229
|
+
*/
|
|
230
|
+
private val devUrl: String? by lazy {
|
|
231
|
+
if (!BuildConfig.DEBUG) return@lazy null
|
|
232
|
+
runCatching {
|
|
233
|
+
appContext.assets.open(DEV_URL_ASSET).bufferedReader().use { it.readText() }.trim()
|
|
234
|
+
}.getOrNull()?.takeIf { it.isNotEmpty() }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// MARK: Instance override (multi-instance apps)
|
|
238
|
+
|
|
239
|
+
private val prefs: SharedPreferences
|
|
240
|
+
get() = appContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The persisted instance override. Read per-access, never cached:
|
|
244
|
+
* setInstance changes it mid-run and everything derived from rootUrl (start
|
|
245
|
+
* URL, path-configuration URL) must follow immediately.
|
|
246
|
+
*/
|
|
247
|
+
val instanceUrl: String?
|
|
248
|
+
get() = if (!remoteInstances) null else prefs.getString(INSTANCE_KEY, null)
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Persist (null clears) the instance override. Only absolute http(s) roots
|
|
252
|
+
* are accepted — the page posts arbitrary strings at the control channel,
|
|
253
|
+
* and re-rooting the whole app is the one place that must not take them on
|
|
254
|
+
* faith. (http stays allowed for LAN-hosted instances.)
|
|
255
|
+
*/
|
|
256
|
+
fun setInstanceUrl(url: String?): Boolean {
|
|
257
|
+
if (url == null) {
|
|
258
|
+
prefs.edit { remove(INSTANCE_KEY) }
|
|
259
|
+
return true
|
|
260
|
+
}
|
|
261
|
+
val uri = url.toUri()
|
|
262
|
+
val scheme = uri.scheme?.lowercase()
|
|
263
|
+
if (scheme != "https" && scheme != "http") return false
|
|
264
|
+
if (uri.host.isNullOrEmpty()) return false
|
|
265
|
+
|
|
266
|
+
prefs.edit { putString(INSTANCE_KEY, url) }
|
|
267
|
+
return true
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// MARK: Third-party sign-in
|
|
271
|
+
|
|
272
|
+
val authScheme: String? get() = auth?.takeIf { it.isUsable }?.scheme
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The path a visit should hand to the auth flow, or null to let it load in
|
|
276
|
+
* the web view. Two shapes: a provider path the app linked to directly, and
|
|
277
|
+
* the gem's /everywhere/auth/native page, which is where the server sends
|
|
278
|
+
* visits the shell couldn't intercept (a `data-turbo="false"` link, or the
|
|
279
|
+
* POST OmniAuth 2 requires) — its `to` names the real provider path.
|
|
280
|
+
*/
|
|
281
|
+
fun authTarget(url: String): String? {
|
|
282
|
+
val config = auth?.takeIf { it.isUsable } ?: return null
|
|
283
|
+
val uri = url.toUri()
|
|
284
|
+
val path = uri.path ?: return null
|
|
285
|
+
|
|
286
|
+
if (path == "/everywhere/auth/native") {
|
|
287
|
+
val to = uri.getQueryParameter("to")
|
|
288
|
+
return to?.takeIf { it.startsWith("/") && !it.startsWith("//") }
|
|
289
|
+
}
|
|
290
|
+
if (!config.matches(path)) return null
|
|
291
|
+
|
|
292
|
+
return pathAndQuery(url)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The first page of the auth flow: the gem's start endpoint, which marks
|
|
297
|
+
* the browser's cookie jar as ours and continues into `to`. `returnTo` is
|
|
298
|
+
* where the user was, used only if the app's own sign-in doesn't say where
|
|
299
|
+
* to land.
|
|
300
|
+
*/
|
|
301
|
+
fun authStartUrl(to: String, returnTo: String?): String {
|
|
302
|
+
val builder = url(forPath = "/everywhere/auth/start").toUri().buildUpon()
|
|
303
|
+
builder.appendQueryParameter("to", to)
|
|
304
|
+
returnTo?.let { builder.appendQueryParameter("return_to", it) }
|
|
305
|
+
return builder.build().toString()
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Where the token gets spent. Visited in the web view — that's the whole
|
|
310
|
+
* point, the response's cookies have to land in the web view's jar — and it
|
|
311
|
+
* redirects on to wherever the app's sign-in wanted the user.
|
|
312
|
+
*/
|
|
313
|
+
fun authHandoffUrl(token: String): String =
|
|
314
|
+
url(forPath = "/everywhere/auth/handoff").toUri().buildUpon()
|
|
315
|
+
.appendQueryParameter("token", token)
|
|
316
|
+
.build()
|
|
317
|
+
.toString()
|
|
318
|
+
|
|
319
|
+
// MARK: Deep links
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Whether an incoming link belongs to this app: https, and its host is the
|
|
323
|
+
* app's own root host or one of the verified domains (everywhere.yml
|
|
324
|
+
* `deep_linking`, injected as `universal_link_hosts`). A link to some other
|
|
325
|
+
* site is left to the browser.
|
|
326
|
+
*
|
|
327
|
+
* The manifest's `autoVerify` intent filter has already narrowed this on
|
|
328
|
+
* the way in, but the filter is stamped from the same list and an
|
|
329
|
+
* Intent can be sent by any app on the device, so the check is repeated
|
|
330
|
+
* here rather than trusted.
|
|
331
|
+
*/
|
|
332
|
+
fun handlesUniversalLink(url: String): Boolean {
|
|
333
|
+
val uri = url.toUri()
|
|
334
|
+
if (uri.scheme?.lowercase() != "https") return false
|
|
335
|
+
val host = uri.host?.lowercase() ?: return false
|
|
336
|
+
if (rootUrl.toUri().host?.lowercase() == host) return true
|
|
337
|
+
return universalLinkHosts.any { it.lowercase() == host }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// MARK: Page-visible config
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* A compact JSON object injected into every page as
|
|
344
|
+
* `window.__EVERYWHERE_CONFIG__`. Built per web view (not once at launch):
|
|
345
|
+
* `instance` must reflect the override as it stands when the post-reset web
|
|
346
|
+
* views are created.
|
|
347
|
+
*/
|
|
348
|
+
val webConfigJson: String
|
|
349
|
+
get() = buildJsonObject {
|
|
350
|
+
put("name", name)
|
|
351
|
+
put("os", "android")
|
|
352
|
+
version?.let { put("version", it) }
|
|
353
|
+
if (remoteInstances) {
|
|
354
|
+
put("instances", true)
|
|
355
|
+
instanceUrl?.let { put("instance", it) }
|
|
356
|
+
}
|
|
357
|
+
}.toString()
|
|
358
|
+
|
|
359
|
+
companion object {
|
|
360
|
+
private const val ASSET = "everywhere.json"
|
|
361
|
+
private const val DEV_URL_ASSET = "dev-url.txt"
|
|
362
|
+
private const val PREFS = "everywhere"
|
|
363
|
+
private const val INSTANCE_KEY = "instance_url"
|
|
364
|
+
|
|
365
|
+
private val parser = Json { ignoreUnknownKeys = true; isLenient = true }
|
|
366
|
+
|
|
367
|
+
@Volatile
|
|
368
|
+
private var instance: EverywhereConfig? = null
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Parsed once, from [EverywhereApplication.onCreate], before anything
|
|
372
|
+
* can ask for [shared].
|
|
373
|
+
*/
|
|
374
|
+
fun initialize(context: Context) {
|
|
375
|
+
instance = load(context.applicationContext)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
val shared: EverywhereConfig
|
|
379
|
+
get() = instance ?: error(
|
|
380
|
+
"EverywhereConfig was read before EverywhereApplication.onCreate ran."
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
private fun load(appContext: Context): EverywhereConfig {
|
|
384
|
+
val text = try {
|
|
385
|
+
appContext.assets.open(ASSET).bufferedReader().use { it.readText() }
|
|
386
|
+
} catch (e: Exception) {
|
|
387
|
+
// Deliberately fatal, like iOS. A shell with no config has no
|
|
388
|
+
// URL to load; failing at launch with the reason beats a blank
|
|
389
|
+
// web view and a support ticket.
|
|
390
|
+
throw IllegalStateException(
|
|
391
|
+
"$ASSET is missing from the app's assets — stamp this shell with `every build` before running it.",
|
|
392
|
+
e
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
val json = try {
|
|
396
|
+
parser.parseToJsonElement(text).jsonObject
|
|
397
|
+
} catch (e: Exception) {
|
|
398
|
+
throw IllegalStateException(
|
|
399
|
+
"$ASSET in the app's assets is invalid — re-stamp this shell with `every build`.",
|
|
400
|
+
e
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
return EverywhereConfig(appContext, json)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private fun JsonObject.string(key: String): String? =
|
|
407
|
+
this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotEmpty() }
|
|
408
|
+
}
|
|
409
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
package com.rubyeverywhere.shell
|
|
2
|
+
|
|
3
|
+
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Page → shell control messages, in one place. The Android answer to iOS's
|
|
7
|
+
* NotificationCenter names (`.everywhereReloadConfig` and friends).
|
|
8
|
+
*
|
|
9
|
+
* The producer is [com.rubyeverywhere.shell.bridge.WebControlChannel], which
|
|
10
|
+
* receives `window.everywhereControl.postMessage({action: …})` from the page;
|
|
11
|
+
* the consumer is [MainActivity], which collects on the main dispatcher and is
|
|
12
|
+
* the only thing allowed to act on these. Keeping the channel decoupled from
|
|
13
|
+
* the Activity matters for the same reason it does on iOS: the web view (and
|
|
14
|
+
* the channel bound to it) is created by the Application before any Activity
|
|
15
|
+
* exists, and survives the Activity being recreated for a tab-path change.
|
|
16
|
+
*
|
|
17
|
+
* Buffered and non-replaying, deliberately:
|
|
18
|
+
* * `extraBufferCapacity` with DROP_OLDEST semantics via [tryEmit] means the
|
|
19
|
+
* channel is never suspended by a slow collector — it is called from the
|
|
20
|
+
* WebView's message thread, which must not block.
|
|
21
|
+
* * `replay = 0` because every event is an imperative command. Replaying
|
|
22
|
+
* "reset the app" to a collector that starts later would re-run it, and
|
|
23
|
+
* MainActivity re-collects every time it is recreated.
|
|
24
|
+
*/
|
|
25
|
+
object EverywhereEvents {
|
|
26
|
+
|
|
27
|
+
sealed interface Event {
|
|
28
|
+
/** `{action: "reloadConfig"}` — refetch the auth-aware path configuration. */
|
|
29
|
+
data object ReloadConfig : Event
|
|
30
|
+
|
|
31
|
+
/** `{action: "reset", to: "/path"}` — full app reset, then land on `to`. */
|
|
32
|
+
data class ResetApp(val to: String?) : Event
|
|
33
|
+
|
|
34
|
+
/** `{action: "setTabBadge", path: "/x", count: 3}` — 0 clears. */
|
|
35
|
+
data class SetTabBadge(val path: String, val count: Int) : Event
|
|
36
|
+
|
|
37
|
+
/** `{action: "setBadge", count: 3}` — the launcher icon badge; 0 clears. */
|
|
38
|
+
data class SetBadge(val count: Int) : Event
|
|
39
|
+
|
|
40
|
+
/** `{action: "setInstance", url: "https://…", to: "/path"?}` */
|
|
41
|
+
data class SetInstance(val url: String, val to: String?) : Event
|
|
42
|
+
|
|
43
|
+
/** `{action: "clearInstance", to: "/path"?}` */
|
|
44
|
+
data class ClearInstance(val to: String?) : Event
|
|
45
|
+
|
|
46
|
+
/** `everywhereVisit(path)` from native extension code. */
|
|
47
|
+
data class NativeVisit(val path: String) : Event
|
|
48
|
+
|
|
49
|
+
/** `{action: "authFlow", to: "/auth/github"}` */
|
|
50
|
+
data class AuthFlow(val to: String) : Event
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Mutable on purpose, and public. The control channel emits from a
|
|
55
|
+
* coroutine so it can await a slow collector rather than drop; the route
|
|
56
|
+
* decision handlers, which run on a framework callback that cannot suspend,
|
|
57
|
+
* use [emit] instead. Both need to write, so there is nothing to hide
|
|
58
|
+
* behind an `asSharedFlow()`.
|
|
59
|
+
*/
|
|
60
|
+
val events = MutableSharedFlow<Event>(replay = 0, extraBufferCapacity = 32)
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Non-suspending, callable from any thread. Returns false only if the
|
|
64
|
+
* buffer is full — 32 pending control messages means nothing is collecting,
|
|
65
|
+
* in which case dropping is the right answer, not blocking the caller.
|
|
66
|
+
*/
|
|
67
|
+
fun emit(event: Event): Boolean = events.tryEmit(event)
|
|
68
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
package com.rubyeverywhere.shell
|
|
2
|
+
|
|
3
|
+
import android.webkit.CookieManager
|
|
4
|
+
import kotlinx.coroutines.Dispatchers
|
|
5
|
+
import kotlinx.coroutines.withContext
|
|
6
|
+
import java.net.HttpURLConnection
|
|
7
|
+
import java.net.URL
|
|
8
|
+
|
|
9
|
+
// Note for whoever edits the comments in this file: Kotlin block comments NEST,
|
|
10
|
+
// unlike Java's, so a "slash star" sequence inside a KDoc block — which is what
|
|
11
|
+
// a glob like the one naming this app's Kotlin sources would be — opens a nested
|
|
12
|
+
// comment and swallows the rest of the file. It fails as "Unclosed comment" at
|
|
13
|
+
// the last line, naming nothing. Globs belong in line comments only.
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The two things app-authored native code in the app repo's native/android
|
|
17
|
+
* directory needs from the shell, and the Android half of iOS's
|
|
18
|
+
* EverywhereHost.swift. Deliberately top-level functions with the same names as
|
|
19
|
+
* the Swift ones, so the two platforms' extension code reads the same.
|
|
20
|
+
*
|
|
21
|
+
* There is no `everywhereHost` counterpart: iOS needs one to lift a SwiftUI
|
|
22
|
+
* view into a UIViewController, while a Hotwire Android destination is already
|
|
23
|
+
* a Fragment.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Route the app to a path (or absolute URL) from native extension code — the
|
|
28
|
+
* native counterpart of `Everywhere.visit()`. Relative paths resolve against
|
|
29
|
+
* the effective root (dev override / picked instance included); web routes load
|
|
30
|
+
* in the web view, native-screen routes push their screen.
|
|
31
|
+
*
|
|
32
|
+
* Non-suspending and safe from any thread: it hands the path to
|
|
33
|
+
* [EverywhereEvents], which [MainActivity] collects on the main dispatcher.
|
|
34
|
+
*/
|
|
35
|
+
fun everywhereVisit(path: String) {
|
|
36
|
+
EverywhereEvents.emit(EverywhereEvents.Event.NativeVisit(path))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* GET a path (or absolute URL) from the app's own server and return the body —
|
|
41
|
+
* for native screens that render the app's data instead of its HTML. The
|
|
42
|
+
* counterpart of iOS's `everywhereURLSession`, narrowed to the one call that
|
|
43
|
+
* needs shell knowledge to get right:
|
|
44
|
+
*
|
|
45
|
+
* * Relative paths resolve against the effective root, so a native screen
|
|
46
|
+
* follows a dev override or a picked instance like every web visit does.
|
|
47
|
+
* * The session cookie is copied from the WebView's store, so the request
|
|
48
|
+
* rides the signed-in session. Android's CookieManager is one process-wide
|
|
49
|
+
* store, so this is a synchronous read (on iOS it is a whole round trip).
|
|
50
|
+
* * The User-Agent carries the Hotwire Native marker, so the gem's
|
|
51
|
+
* `native_app?` / `native_platform` / `native_version` helpers answer for
|
|
52
|
+
* native-screen requests exactly as they do for web-view requests.
|
|
53
|
+
*
|
|
54
|
+
* Suspending, and switched to Dispatchers.IO: a blocking call on the main
|
|
55
|
+
* thread would raise NetworkOnMainThreadException. Throws on a transport
|
|
56
|
+
* failure or a non-2xx status — a native screen has to render *something* for a
|
|
57
|
+
* signed-out or erroring server, and swallowing that here would leave it
|
|
58
|
+
* rendering an empty list with no way to tell why.
|
|
59
|
+
*/
|
|
60
|
+
suspend fun everywhereFetch(path: String, accept: String = "application/json"): String =
|
|
61
|
+
withContext(Dispatchers.IO) {
|
|
62
|
+
val config = EverywhereConfig.shared
|
|
63
|
+
val location = if (path.startsWith("http://") || path.startsWith("https://")) {
|
|
64
|
+
path
|
|
65
|
+
} else {
|
|
66
|
+
config.url(forPath = path)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
val connection = (URL(location).openConnection() as HttpURLConnection).apply {
|
|
70
|
+
requestMethod = "GET"
|
|
71
|
+
setRequestProperty("Accept", accept)
|
|
72
|
+
setRequestProperty("User-Agent", config.nativeUserAgent)
|
|
73
|
+
runCatching { CookieManager.getInstance().getCookie(location) }.getOrNull()
|
|
74
|
+
?.takeIf { it.isNotEmpty() }
|
|
75
|
+
?.let { setRequestProperty("Cookie", it) }
|
|
76
|
+
connectTimeout = TIMEOUT_MS
|
|
77
|
+
readTimeout = TIMEOUT_MS
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
val status = connection.responseCode
|
|
82
|
+
if (status !in 200..299) {
|
|
83
|
+
// errorStream, not inputStream: reading the latter on a 4xx/5xx
|
|
84
|
+
// throws, which would hide the status the caller needs to report.
|
|
85
|
+
val detail = connection.errorStream?.bufferedReader()?.use { it.readText() }.orEmpty()
|
|
86
|
+
throw EverywhereFetchError(status, location, detail)
|
|
87
|
+
}
|
|
88
|
+
connection.inputStream.bufferedReader().use { it.readText() }
|
|
89
|
+
} finally {
|
|
90
|
+
connection.disconnect()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A non-2xx response from [everywhereFetch]. */
|
|
95
|
+
class EverywhereFetchError(
|
|
96
|
+
val status: Int,
|
|
97
|
+
val location: String,
|
|
98
|
+
val body: String
|
|
99
|
+
) : Exception("HTTP $status for $location")
|
|
100
|
+
|
|
101
|
+
private const val TIMEOUT_MS = 15_000
|