ruby_everywhere 0.8.0 → 0.9.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/lib/everywhere/builders/android.rb +39 -0
- data/lib/everywhere/config/mobile.rb +13 -1
- data/lib/everywhere/version.rb +1 -1
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +65 -4
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +21 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +75 -17
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MissingExtensionFragment.kt +105 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +31 -4
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +6 -10
- data/support/mobile/android/app/src/main/res/layout/everywhere_error.xml +60 -0
- data/support/mobile/android/app/src/main/res/values/strings.xml +19 -0
- data/support/mobile/ios/App/ErrorViewController.swift +21 -3
- data/support/mobile/ios/App/SceneDelegate.swift +12 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 97a5e572306841f37118d70c1b5702efc0fc1d48d9377e8ad325e551b7b720b2
|
|
4
|
+
data.tar.gz: 9ffbcd6323f81374f94b1237cdf06a13c50dc2c3466479c8fbb0cfdedfa6ddbd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 66071e14c65bec691891622e1133253e77437cf5ce1d1c43d6785e3b70568ad12c9bcd7155d932a2bf27b313779b562082bdf9fb9f38a1c7468ccff1006c04e2
|
|
7
|
+
data.tar.gz: '068d02e32db302d5b48e1325e9f789e460ede9e18a15e30b20fee7d2ac22a100858577f7ba2fd138d0a2655eaa5e50532058527921b8a29abd3dc727aeb02339'
|
|
@@ -287,10 +287,49 @@ module Everywhere
|
|
|
287
287
|
write_drawables(work)
|
|
288
288
|
write_icon_font(work)
|
|
289
289
|
write_manifest(work)
|
|
290
|
+
write_network_security(work)
|
|
290
291
|
write_extensions(work)
|
|
291
292
|
write_native_packages(work)
|
|
292
293
|
end
|
|
293
294
|
|
|
295
|
+
# The template's baseline forbids cleartext everywhere (debug overlays
|
|
296
|
+
# loopback + .local only — see the two res/xml/network_security_config.xml
|
|
297
|
+
# files and their comments). An instance app (`remote.instances: true`)
|
|
298
|
+
# exists to load OTHER apps, and `every preview --lan` serves those as
|
|
299
|
+
# plain http on whatever LAN address the laptop has that day — an address
|
|
300
|
+
# no config written at build time can enumerate, and Android's
|
|
301
|
+
# network-security-config has no CIDR syntax to scope a private-range
|
|
302
|
+
# exception the way iOS's NSAllowsLocalNetworking does. So instance apps
|
|
303
|
+
# get cleartext permitted wholesale, in both build types (the debug
|
|
304
|
+
# overlay REPLACES main's resource, so leaving it standing would make
|
|
305
|
+
# debug builds stricter than release). The shell's own traffic to
|
|
306
|
+
# remote.url stays https; the alternative is a picker that cannot open
|
|
307
|
+
# what it picked.
|
|
308
|
+
#
|
|
309
|
+
# Written only when instances is on: stage() re-copies the template over
|
|
310
|
+
# the work dir every build, so switching it off restores the strict
|
|
311
|
+
# default without a writer for that side of the toggle.
|
|
312
|
+
def write_network_security(work)
|
|
313
|
+
return unless @config.remote_instances?
|
|
314
|
+
|
|
315
|
+
UI.step("allowing cleartext http #{UI.dim("(remote.instances: previewed apps live on LAN addresses)")}")
|
|
316
|
+
xml = <<~XML
|
|
317
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
318
|
+
<!-- Written by `every build` because config/everywhere.yml sets
|
|
319
|
+
remote.instances: true — previewed apps arrive as plain http on
|
|
320
|
+
LAN addresses that cannot be enumerated at build time. Do not
|
|
321
|
+
edit; the template's strict default returns if instances is
|
|
322
|
+
turned off. -->
|
|
323
|
+
<network-security-config>
|
|
324
|
+
<base-config cleartextTrafficPermitted="true" />
|
|
325
|
+
</network-security-config>
|
|
326
|
+
XML
|
|
327
|
+
[File.join(work, "app", "src", "main", "res", "xml", "network_security_config.xml"),
|
|
328
|
+
File.join(work, "app", "src", "debug", "res", "xml", "network_security_config.xml")].each do |path|
|
|
329
|
+
File.write(path, xml)
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
294
333
|
# --- identity -------------------------------------------------------------
|
|
295
334
|
|
|
296
335
|
# app/build.gradle.kts reads these four values, so identity is stamped
|
|
@@ -182,12 +182,24 @@ module Everywhere
|
|
|
182
182
|
# `uri` pointing at a Fragment this build doesn't contain resolves to
|
|
183
183
|
# nothing and the visit dead-ends. An explicit `uri:` always wins — that's
|
|
184
184
|
# the escape hatch for a screen whose annotation says something else.
|
|
185
|
+
#
|
|
186
|
+
# `fallback_uri` rides along for the shell that ISN'T this app's own
|
|
187
|
+
# build: a Jump-style instance shell fetches this same document live, and
|
|
188
|
+
# the derived uri names a Fragment compiled into the app's build, not the
|
|
189
|
+
# picker's. Hotwire falls back per rule when the primary uri resolves to
|
|
190
|
+
# no registered destination, and every gem-built shell registers the
|
|
191
|
+
# missing-screen placeholder at this address — so the previewing shell
|
|
192
|
+
# says "this screen is native" instead of spinning forever, while the
|
|
193
|
+
# app's own build never consults the fallback at all.
|
|
194
|
+
MISSING_SCREEN_URI = "hotwire://fragment/everywhere-missing"
|
|
195
|
+
|
|
185
196
|
def android_screen_properties(properties)
|
|
186
197
|
id = properties["view_controller"]
|
|
187
198
|
return properties if id.nil? || properties.key?("uri")
|
|
188
199
|
return properties unless native_android_screens.key?(id.to_s)
|
|
189
200
|
|
|
190
|
-
properties.
|
|
201
|
+
fallback = properties.key?("fallback_uri") ? {} : { "fallback_uri" => MISSING_SCREEN_URI }
|
|
202
|
+
properties.merge("uri" => "hotwire://fragment/#{id}", **fallback)
|
|
191
203
|
end
|
|
192
204
|
|
|
193
205
|
# The full path-configuration document for one mobile platform — rules plus
|
data/lib/everywhere/version.rb
CHANGED
data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt
CHANGED
|
@@ -2,6 +2,8 @@ package com.rubyeverywhere.shell
|
|
|
2
2
|
|
|
3
3
|
import android.app.Application
|
|
4
4
|
import android.webkit.CookieManager
|
|
5
|
+
import android.webkit.WebSettings
|
|
6
|
+
import android.widget.Toast
|
|
5
7
|
import androidx.core.net.toUri
|
|
6
8
|
import com.rubyeverywhere.shell.bridge.BiometricsComponent
|
|
7
9
|
import com.rubyeverywhere.shell.bridge.HapticsComponent
|
|
@@ -20,6 +22,7 @@ import dev.hotwire.core.turbo.visit.VisitProposal
|
|
|
20
22
|
import dev.hotwire.core.turbo.webview.HotwireWebView
|
|
21
23
|
import dev.hotwire.navigation.activities.HotwireActivity
|
|
22
24
|
import dev.hotwire.navigation.config.defaultFragmentDestination
|
|
25
|
+
import dev.hotwire.navigation.destinations.HotwireDestinationDeepLink
|
|
23
26
|
import dev.hotwire.navigation.config.registerBridgeComponents
|
|
24
27
|
import dev.hotwire.navigation.config.registerFragmentDestinations
|
|
25
28
|
import dev.hotwire.navigation.config.registerRouteDecisionHandlers
|
|
@@ -55,12 +58,22 @@ class EverywhereApplication : Application() {
|
|
|
55
58
|
|
|
56
59
|
Hotwire.defaultFragmentDestination = WebFragment::class
|
|
57
60
|
|
|
58
|
-
|
|
61
|
+
val fragmentDestinations = listOf(
|
|
59
62
|
WebFragment::class,
|
|
60
63
|
WebBottomSheetFragment::class,
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
// Where a rule's fallback_uri lands when its primary uri names a
|
|
65
|
+
// Fragment this build doesn't contain (previewing another app).
|
|
66
|
+
MissingExtensionFragment::class
|
|
67
|
+
) + EverywhereExtensions.fragmentDestinations // everywhere.yml native.android.screens
|
|
68
|
+
Hotwire.registerFragmentDestinations(*fragmentDestinations.toTypedArray())
|
|
69
|
+
|
|
70
|
+
// Every uri those destinations answer to, read from the same
|
|
71
|
+
// annotation Hotwire itself matches against — handed to the
|
|
72
|
+
// missing-screen guard below so it can tell a resolvable rule from
|
|
73
|
+
// one that would go nowhere.
|
|
74
|
+
val registeredUris = fragmentDestinations
|
|
75
|
+
.mapNotNull { it.java.getAnnotation(HotwireDestinationDeepLink::class.java)?.uri }
|
|
76
|
+
.toSet()
|
|
64
77
|
|
|
65
78
|
Hotwire.registerBridgeComponents(
|
|
66
79
|
BridgeComponentFactory("everywhere--notification", ::NotificationComponent),
|
|
@@ -78,6 +91,7 @@ class EverywhereApplication : Application() {
|
|
|
78
91
|
Hotwire.registerRouteDecisionHandlers(
|
|
79
92
|
ResetRouteDecisionHandler(),
|
|
80
93
|
AuthFlowRouteDecisionHandler(),
|
|
94
|
+
MissingScreenRouteDecisionHandler(registeredUris),
|
|
81
95
|
AppNavigationRouteDecisionHandler(),
|
|
82
96
|
BrowserTabRouteDecisionHandler(),
|
|
83
97
|
SystemNavigationRouteDecisionHandler()
|
|
@@ -99,6 +113,16 @@ class EverywhereApplication : Application() {
|
|
|
99
113
|
Hotwire.config.makeCustomWebView = { context ->
|
|
100
114
|
HotwireWebView(context, null).also { webView ->
|
|
101
115
|
WebControlChannel.install(webView, config.webConfigJson)
|
|
116
|
+
// Instance apps pair with LAN-hosted previews: the picker page
|
|
117
|
+
// is https but the app it probes is plain http, which WebView
|
|
118
|
+
// blocks as mixed content — and then the picker reports a live
|
|
119
|
+
// preview as "not answering". Scoped to remote.instances so
|
|
120
|
+
// ordinary apps keep the strict default. The main-frame http
|
|
121
|
+
// load itself is governed by network_security_config.xml,
|
|
122
|
+
// which `every build` opens up under the same flag.
|
|
123
|
+
if (config.remoteInstances) {
|
|
124
|
+
webView.settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
|
125
|
+
}
|
|
102
126
|
}
|
|
103
127
|
}
|
|
104
128
|
}
|
|
@@ -154,6 +178,43 @@ private class ResetRouteDecisionHandler : Router.RouteDecisionHandler {
|
|
|
154
178
|
}
|
|
155
179
|
}
|
|
156
180
|
|
|
181
|
+
/**
|
|
182
|
+
* The last line of defence for a native-screen rule this build can't serve.
|
|
183
|
+
*
|
|
184
|
+
* A previewed app on a current gem serves `fallback_uri` pointing at
|
|
185
|
+
* [MissingExtensionFragment], and the visit lands there — this handler never
|
|
186
|
+
* matches. An app on an older gem serves only `uri: hotwire://fragment/<id>`,
|
|
187
|
+
* and Hotwire's answer to a uri no registered destination claims is to do
|
|
188
|
+
* nothing at all — which reads as a spinner that never ends. Refuse the visit
|
|
189
|
+
* loudly instead: a toast naming the screen, and the user stays on the page
|
|
190
|
+
* they tapped from.
|
|
191
|
+
*/
|
|
192
|
+
private class MissingScreenRouteDecisionHandler(
|
|
193
|
+
private val registeredUris: Set<String>
|
|
194
|
+
) : Router.RouteDecisionHandler {
|
|
195
|
+
override val name = "everywhere-missing-screen"
|
|
196
|
+
|
|
197
|
+
override fun matches(proposal: VisitProposal, configuration: NavigatorConfiguration): Boolean {
|
|
198
|
+
val uri = proposal.properties["uri"]?.toString() ?: return false
|
|
199
|
+
val fallback = proposal.properties["fallback_uri"]?.toString()
|
|
200
|
+
return uri !in registeredUris && (fallback == null || fallback !in registeredUris)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
override fun handle(
|
|
204
|
+
proposal: VisitProposal,
|
|
205
|
+
configuration: NavigatorConfiguration,
|
|
206
|
+
activity: HotwireActivity
|
|
207
|
+
): Router.Decision {
|
|
208
|
+
val identifier = proposal.properties["view_controller"]?.toString() ?: "native"
|
|
209
|
+
Toast.makeText(
|
|
210
|
+
activity,
|
|
211
|
+
activity.getString(R.string.everywhere_missing_screen_toast, identifier),
|
|
212
|
+
Toast.LENGTH_LONG
|
|
213
|
+
).show()
|
|
214
|
+
return Router.Decision.CANCEL
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
157
218
|
/**
|
|
158
219
|
* A provider sign-in: hand it to a Custom Tab instead of loading it in the web
|
|
159
220
|
* view. Cancelling leaves the user on the page they tapped from, which is where
|
|
@@ -189,6 +189,27 @@ class EverywhereConfig private constructor(
|
|
|
189
189
|
return if (path.isNullOrEmpty() || path == "/") rootUrl else url(forPath = path)
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Every origin the shell's own pages can be served from: the stamped
|
|
194
|
+
* root, the dev override, and a picked instance. The control channel is
|
|
195
|
+
* offered to all of them, not just the current [rootUrl] — while rooted
|
|
196
|
+
* on an instance, the picker's pages still come from the stamped origin,
|
|
197
|
+
* and without the channel there they could switch in but never switch
|
|
198
|
+
* again (`Everywhere.instance.*` would fall back to a plain navigation).
|
|
199
|
+
*/
|
|
200
|
+
val trustedRootOrigins: Set<String>
|
|
201
|
+
get() = listOfNotNull(stampedRemoteUrl, devUrl, instanceUrl)
|
|
202
|
+
.mapNotNull(::originOf)
|
|
203
|
+
.toSet()
|
|
204
|
+
|
|
205
|
+
private fun originOf(url: String): String? {
|
|
206
|
+
val uri = url.toUri()
|
|
207
|
+
val scheme = uri.scheme ?: return null
|
|
208
|
+
val host = uri.host ?: return null
|
|
209
|
+
val port = if (uri.port > 0) ":${uri.port}" else ""
|
|
210
|
+
return "$scheme://$host$port"
|
|
211
|
+
}
|
|
212
|
+
|
|
192
213
|
/**
|
|
193
214
|
* The server half of the path configuration (rules + settings.tabs),
|
|
194
215
|
* generated from everywhere.yml by the gem's MobileConfigEndpoint — so tab
|
|
@@ -140,7 +140,16 @@ class MainActivity : HotwireActivity() {
|
|
|
140
140
|
// Swap to the real theme before the content view inflates.
|
|
141
141
|
setTheme(R.style.Theme_Everywhere)
|
|
142
142
|
enableEdgeToEdge()
|
|
143
|
-
|
|
143
|
+
|
|
144
|
+
// A rebuild ([rebuildAndRoute]: reset, sign-in/out, instance switch)
|
|
145
|
+
// arrives with EXTRA_ROUTE on the Intent and must start from scratch:
|
|
146
|
+
// handing the saved state to super restores every navigator's back
|
|
147
|
+
// stack, climbing the page the user just left back on top of the new
|
|
148
|
+
// root. Dropped here, the recreate behaves like a fresh launch — while
|
|
149
|
+
// a real config change or process death (no EXTRA_ROUTE) restores
|
|
150
|
+
// normally.
|
|
151
|
+
val state = if (intent.hasExtra(EXTRA_ROUTE)) null else savedInstanceState
|
|
152
|
+
super.onCreate(state)
|
|
144
153
|
|
|
145
154
|
setContentView(R.layout.activity_main)
|
|
146
155
|
findViewById<View>(R.id.root).applyDefaultImeWindowInsets()
|
|
@@ -148,7 +157,7 @@ class MainActivity : HotwireActivity() {
|
|
|
148
157
|
splash = findViewById(R.id.splash)
|
|
149
158
|
showSplash()
|
|
150
159
|
|
|
151
|
-
initializeTabs(
|
|
160
|
+
initializeTabs(state?.getInt(STATE_SELECTED_TAB) ?: 0)
|
|
152
161
|
|
|
153
162
|
observeEvents()
|
|
154
163
|
observePathConfiguration()
|
|
@@ -220,7 +229,14 @@ class MainActivity : HotwireActivity() {
|
|
|
220
229
|
|
|
221
230
|
pendingRoute = null
|
|
222
231
|
pendingRouteHostId = null
|
|
223
|
-
|
|
232
|
+
// Post, don't call: this callback fires from onAttachFragment while
|
|
233
|
+
// the host's start fragment is still mid-transaction — no view, no
|
|
234
|
+
// web delegate — and routing into it synchronously crashes Hotwire
|
|
235
|
+
// ("lateinit property webDelegate has not been initialized"). One
|
|
236
|
+
// trip through the main queue lets the transaction finish first.
|
|
237
|
+
window.decorView.post {
|
|
238
|
+
if (!isFinishing && !isDestroyed) navigator.route(route)
|
|
239
|
+
}
|
|
224
240
|
}
|
|
225
241
|
|
|
226
242
|
// ------------------------------------------------------------------------
|
|
@@ -380,19 +396,32 @@ class MainActivity : HotwireActivity() {
|
|
|
380
396
|
* handling selection would make More a one-shot.
|
|
381
397
|
*/
|
|
382
398
|
private fun installTabListeners(arrangement: MainTabs.Arrangement) {
|
|
383
|
-
|
|
399
|
+
val hasLeaveSlot = arrangement.slots.any { isLeaveTab(it.entry?.path) }
|
|
400
|
+
if (!arrangement.hasMore && !hasLeaveSlot) return
|
|
384
401
|
|
|
385
402
|
bottomNav.setOnItemSelectedListener { item ->
|
|
386
403
|
val index = item.itemId
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
404
|
+
when {
|
|
405
|
+
// The Jump leave tab is a native action, exactly as on iOS
|
|
406
|
+
// (EverywhereTabBarController): the page behind it is a
|
|
407
|
+
// gem-served Turbo-less fallback, so selecting its host could
|
|
408
|
+
// only end at the error view. The tap clears the picked
|
|
409
|
+
// instance directly and never becomes a selection.
|
|
410
|
+
isLeaveTab(arrangement.entryAt(index)?.path) -> {
|
|
411
|
+
leaveInstance()
|
|
412
|
+
false
|
|
413
|
+
}
|
|
414
|
+
// selectingMoreForRoute is the one case where selecting More IS
|
|
415
|
+
// the intent: something routed to an overflow path and the host
|
|
416
|
+
// has to come forward to receive it.
|
|
417
|
+
arrangement.isMoreIndex(index) && !selectingMoreForRoute -> {
|
|
418
|
+
showMoreSheet(arrangement)
|
|
419
|
+
false
|
|
420
|
+
}
|
|
421
|
+
else -> {
|
|
422
|
+
switchToSlot(index)
|
|
423
|
+
true
|
|
424
|
+
}
|
|
396
425
|
}
|
|
397
426
|
}
|
|
398
427
|
|
|
@@ -408,6 +437,19 @@ class MainActivity : HotwireActivity() {
|
|
|
408
437
|
}
|
|
409
438
|
}
|
|
410
439
|
|
|
440
|
+
/**
|
|
441
|
+
* The injected "leave preview" tab (a Jump instance's way home), matched
|
|
442
|
+
* by its gem-reserved path. Only meaningful for instance apps — for
|
|
443
|
+
* everyone else no such tab is ever injected, and the guard keeps an
|
|
444
|
+
* unlucky app path from being swallowed.
|
|
445
|
+
*/
|
|
446
|
+
private fun isLeaveTab(path: String?): Boolean =
|
|
447
|
+
config.remoteInstances && path == JUMP_LEAVE_PATH
|
|
448
|
+
|
|
449
|
+
private fun leaveInstance() {
|
|
450
|
+
EverywhereEvents.emit(EverywhereEvents.Event.ClearInstance(null))
|
|
451
|
+
}
|
|
452
|
+
|
|
411
453
|
/**
|
|
412
454
|
* Bring a slot's host forward — upstream `HotwireBottomNavigationController`'s
|
|
413
455
|
* private `switchTab`, which taking over the item-selected listener costs us.
|
|
@@ -466,7 +508,11 @@ class MainActivity : HotwireActivity() {
|
|
|
466
508
|
|
|
467
509
|
row.setOnClickListener {
|
|
468
510
|
dialog.dismiss()
|
|
469
|
-
|
|
511
|
+
// The leave row is the same native action as a leave SLOT —
|
|
512
|
+
// see installTabListeners; routing it would load the
|
|
513
|
+
// Turbo-less fallback page and end at the error view.
|
|
514
|
+
if (isLeaveTab(entry.path)) leaveInstance()
|
|
515
|
+
else route(config.url(forPath = entry.path))
|
|
470
516
|
}
|
|
471
517
|
list.addView(row)
|
|
472
518
|
}
|
|
@@ -777,9 +823,17 @@ class MainActivity : HotwireActivity() {
|
|
|
777
823
|
}
|
|
778
824
|
|
|
779
825
|
/**
|
|
780
|
-
* `recreate()`
|
|
781
|
-
*
|
|
782
|
-
*
|
|
826
|
+
* `recreate()` for an in-place rebuild — a CLEAR_TASK relaunch tears the
|
|
827
|
+
* task down first, which plays as the app closing and cold-starting on
|
|
828
|
+
* every reset (sign-in, sign-out, instance switch). recreate() keeps the
|
|
829
|
+
* window; the landing page rides along on the Intent, which recreate()
|
|
830
|
+
* preserves while fields are lost.
|
|
831
|
+
*
|
|
832
|
+
* The saved instance state, however, must NOT come back: it carries every
|
|
833
|
+
* navigator's back stack, and restoring it climbs the page the user just
|
|
834
|
+
* left (Jump's connect screen, a pre-sign-in page) right back on top of
|
|
835
|
+
* the new root. `onCreate` treats the presence of [EXTRA_ROUTE] as the
|
|
836
|
+
* fresh-start flag and drops the state — see the top of [onCreate].
|
|
783
837
|
*/
|
|
784
838
|
private fun rebuildAndRoute(target: String) {
|
|
785
839
|
AuthFlow.cancel()
|
|
@@ -956,6 +1010,10 @@ class MainActivity : HotwireActivity() {
|
|
|
956
1010
|
const val EXTRA_ROUTE = "everywhere.route"
|
|
957
1011
|
const val EXTRA_LAST_REBUILD = "everywhere.lastRebuild"
|
|
958
1012
|
|
|
1013
|
+
/** The injected leave tab's path — same constant iOS keeps on
|
|
1014
|
+
* EverywhereTabBarController.jumpLeavePath. */
|
|
1015
|
+
const val JUMP_LEAVE_PATH = "/everywhere/jump/leave"
|
|
1016
|
+
|
|
959
1017
|
/**
|
|
960
1018
|
* How close two path-change rebuilds have to be before the second is
|
|
961
1019
|
* treated as a loop rather than a legitimate config change. Generous
|
data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MissingExtensionFragment.kt
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
package com.rubyeverywhere.shell
|
|
2
|
+
|
|
3
|
+
import android.graphics.drawable.Drawable
|
|
4
|
+
import android.os.Bundle
|
|
5
|
+
import android.util.TypedValue
|
|
6
|
+
import android.view.Gravity
|
|
7
|
+
import android.view.LayoutInflater
|
|
8
|
+
import android.view.View
|
|
9
|
+
import android.view.ViewGroup
|
|
10
|
+
import android.widget.FrameLayout
|
|
11
|
+
import android.widget.ImageView
|
|
12
|
+
import android.widget.LinearLayout
|
|
13
|
+
import android.widget.TextView
|
|
14
|
+
import androidx.appcompat.widget.Toolbar
|
|
15
|
+
import androidx.core.graphics.drawable.DrawableCompat
|
|
16
|
+
import com.google.android.material.appbar.AppBarLayout
|
|
17
|
+
import dev.hotwire.navigation.destinations.HotwireDestinationDeepLink
|
|
18
|
+
import dev.hotwire.navigation.fragments.HotwireFragment
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The per-rule fallback destination for native screens this build doesn't
|
|
22
|
+
* contain — the Android half of iOS's `MissingExtensionScreen`.
|
|
23
|
+
*
|
|
24
|
+
* The gem writes `fallback_uri: hotwire://fragment/everywhere-missing` next to
|
|
25
|
+
* every derived screen `uri` (see the gem's Config::Mobile), so when a
|
|
26
|
+
* Jump-style shell previews an app whose rules name Fragments compiled into
|
|
27
|
+
* THAT app's build, the visit lands here and says so — instead of Hotwire
|
|
28
|
+
* finding no destination and leaving the user on a spinner forever. An app's
|
|
29
|
+
* own build resolves the primary uri and never consults the fallback.
|
|
30
|
+
*/
|
|
31
|
+
@HotwireDestinationDeepLink(uri = "hotwire://fragment/everywhere-missing")
|
|
32
|
+
class MissingExtensionFragment : HotwireFragment() {
|
|
33
|
+
private lateinit var toolbar: Toolbar
|
|
34
|
+
|
|
35
|
+
override fun onCreateView(
|
|
36
|
+
inflater: LayoutInflater,
|
|
37
|
+
container: ViewGroup?,
|
|
38
|
+
savedInstanceState: Bundle?
|
|
39
|
+
): View {
|
|
40
|
+
val context = requireContext()
|
|
41
|
+
// The rule that routed here still carries the screen's identifier.
|
|
42
|
+
val identifier = pathProperties["view_controller"]?.toString() ?: "native"
|
|
43
|
+
|
|
44
|
+
toolbar = Toolbar(context).apply {
|
|
45
|
+
title = getString(R.string.everywhere_missing_screen_nav_title)
|
|
46
|
+
}
|
|
47
|
+
val appBar = AppBarLayout(context).apply {
|
|
48
|
+
fitsSystemWindows = true
|
|
49
|
+
addView(toolbar, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
val secondary = TypedValue().let {
|
|
53
|
+
context.theme.resolveAttribute(android.R.attr.textColorSecondary, it, true)
|
|
54
|
+
requireNotNull(context.getColorStateList(it.resourceId)).defaultColor
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
val icon = ImageView(context).apply {
|
|
58
|
+
setImageDrawable(tinted(IconFont.drawable(context, "extension", dp(44)), secondary))
|
|
59
|
+
}
|
|
60
|
+
val title = TextView(context).apply {
|
|
61
|
+
text = getString(R.string.everywhere_missing_screen_title)
|
|
62
|
+
textSize = 20f
|
|
63
|
+
setTypeface(typeface, android.graphics.Typeface.BOLD)
|
|
64
|
+
gravity = Gravity.CENTER
|
|
65
|
+
}
|
|
66
|
+
val body = TextView(context).apply {
|
|
67
|
+
text = getString(R.string.everywhere_missing_screen_body, identifier)
|
|
68
|
+
textSize = 15f
|
|
69
|
+
setTextColor(secondary)
|
|
70
|
+
gravity = Gravity.CENTER
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
val column = LinearLayout(context).apply {
|
|
74
|
+
orientation = LinearLayout.VERTICAL
|
|
75
|
+
gravity = Gravity.CENTER_HORIZONTAL
|
|
76
|
+
setPadding(dp(32), 0, dp(32), 0)
|
|
77
|
+
addView(icon)
|
|
78
|
+
addView(title, spaced(dp(14)))
|
|
79
|
+
addView(body, spaced(dp(14)))
|
|
80
|
+
}
|
|
81
|
+
val content = FrameLayout(context).apply {
|
|
82
|
+
addView(column, FrameLayout.LayoutParams(
|
|
83
|
+
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT,
|
|
84
|
+
Gravity.CENTER
|
|
85
|
+
))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return LinearLayout(context).apply {
|
|
89
|
+
orientation = LinearLayout.VERTICAL
|
|
90
|
+
addView(appBar, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
|
91
|
+
addView(content, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f))
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
override fun toolbarForNavigation(): Toolbar = toolbar
|
|
96
|
+
|
|
97
|
+
private fun tinted(drawable: Drawable, color: Int): Drawable =
|
|
98
|
+
DrawableCompat.wrap(drawable).mutate().also { DrawableCompat.setTint(it, color) }
|
|
99
|
+
|
|
100
|
+
private fun spaced(marginTop: Int) = LinearLayout.LayoutParams(
|
|
101
|
+
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
|
102
|
+
).apply { topMargin = marginTop }
|
|
103
|
+
|
|
104
|
+
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
|
|
105
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
package com.rubyeverywhere.shell
|
|
2
2
|
|
|
3
|
+
import android.view.View
|
|
4
|
+
import android.widget.TextView
|
|
5
|
+
import androidx.core.view.isVisible
|
|
3
6
|
import dev.hotwire.core.turbo.errors.VisitError
|
|
4
7
|
import dev.hotwire.navigation.destinations.HotwireDestinationDeepLink
|
|
5
8
|
import dev.hotwire.navigation.fragments.HotwireWebFragment
|
|
@@ -8,10 +11,11 @@ import dev.hotwire.navigation.fragments.HotwireWebFragment
|
|
|
8
11
|
* The default destination for every web visit.
|
|
9
12
|
*
|
|
10
13
|
* Deliberately thin — the framework's own fragment already does the work. Its
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
14
|
+
* jobs are telling [MainActivity] when there is content on screen (how the
|
|
15
|
+
* splash comes down — Android has no `NavigatorDelegate.requestDidFinish` the
|
|
16
|
+
* way iOS does; the visit callbacks on the fragment are where that information
|
|
17
|
+
* surfaces) and swapping the library's button-less error view for one the user
|
|
18
|
+
* can act on.
|
|
15
19
|
*/
|
|
16
20
|
@HotwireDestinationDeepLink(uri = "hotwire://fragment/web")
|
|
17
21
|
open class WebFragment : HotwireWebFragment() {
|
|
@@ -30,6 +34,29 @@ open class WebFragment : HotwireWebFragment() {
|
|
|
30
34
|
settled()
|
|
31
35
|
}
|
|
32
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The library's error view is a message with no controls — its only retry
|
|
39
|
+
* is pull-to-refresh, invisible exactly when the user is staring at a dead
|
|
40
|
+
* page. Ours adds an explicit retry, and — inside a picked instance, where
|
|
41
|
+
* retrying can only knock on the same dead host — the way home: clear the
|
|
42
|
+
* instance and re-root onto the app's own start page, exactly what the
|
|
43
|
+
* page's own `Everywhere.instance.clear()` would do if it could load.
|
|
44
|
+
*/
|
|
45
|
+
override fun createErrorView(error: VisitError): View {
|
|
46
|
+
val view = layoutInflater.inflate(R.layout.everywhere_error, null)
|
|
47
|
+
view.findViewById<TextView>(R.id.everywhere_error_description).text = error.description()
|
|
48
|
+
view.findViewById<View>(R.id.everywhere_error_retry).setOnClickListener {
|
|
49
|
+
refresh(displayProgress = true)
|
|
50
|
+
}
|
|
51
|
+
view.findViewById<View>(R.id.everywhere_error_leave).apply {
|
|
52
|
+
isVisible = EverywhereConfig.shared.instanceUrl != null
|
|
53
|
+
setOnClickListener {
|
|
54
|
+
EverywhereEvents.emit(EverywhereEvents.Event.ClearInstance(null))
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return view
|
|
58
|
+
}
|
|
59
|
+
|
|
33
60
|
private fun settled() {
|
|
34
61
|
(activity as? MainActivity)?.onWebContentSettled()
|
|
35
62
|
}
|
data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt
CHANGED
|
@@ -189,16 +189,12 @@ class WebControlChannel : WebViewCompat.WebMessageListener {
|
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
/**
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
192
|
+
* Every origin the shell's own pages come from — stamped root, dev
|
|
193
|
+
* override, picked instance (EverywhereConfig.trustedRootOrigins).
|
|
194
|
+
* All of them, not just the effective root: while the shell is rooted
|
|
195
|
+
* on an instance, the picker's own pages are still served from the
|
|
196
|
+
* stamped origin, and they need the channel to switch or leave.
|
|
195
197
|
*/
|
|
196
|
-
private fun originRules(): Set<String>
|
|
197
|
-
val uri = EverywhereConfig.shared.rootUrl.toUri()
|
|
198
|
-
val scheme = uri.scheme ?: return emptySet()
|
|
199
|
-
val host = uri.host ?: return emptySet()
|
|
200
|
-
val port = if (uri.port > 0) ":${uri.port}" else ""
|
|
201
|
-
return setOf("$scheme://$host$port")
|
|
202
|
-
}
|
|
198
|
+
private fun originRules(): Set<String> = EverywhereConfig.shared.trustedRootOrigins
|
|
203
199
|
}
|
|
204
200
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
+
<!--
|
|
3
|
+
FROZEN. The failed-visit screen WebFragment.createErrorView inflates in
|
|
4
|
+
place of the library's hotwire_error.xml, which has no controls at all —
|
|
5
|
+
its only retry is pull-to-refresh, invisible exactly when the user is
|
|
6
|
+
staring at a dead page. Same typography as the library's version, plus an
|
|
7
|
+
explicit retry button and an escape hatch for picked instances (shown by
|
|
8
|
+
WebFragment only when an instance override is active).
|
|
9
|
+
-->
|
|
10
|
+
<FrameLayout
|
|
11
|
+
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
12
|
+
android:layout_width="match_parent"
|
|
13
|
+
android:layout_height="match_parent"
|
|
14
|
+
android:background="?android:colorBackground">
|
|
15
|
+
|
|
16
|
+
<LinearLayout
|
|
17
|
+
android:layout_width="match_parent"
|
|
18
|
+
android:layout_height="wrap_content"
|
|
19
|
+
android:layout_gravity="center"
|
|
20
|
+
android:orientation="vertical"
|
|
21
|
+
android:gravity="center_horizontal"
|
|
22
|
+
android:paddingStart="24dp"
|
|
23
|
+
android:paddingEnd="24dp">
|
|
24
|
+
|
|
25
|
+
<com.google.android.material.textview.MaterialTextView
|
|
26
|
+
style="@style/TextAppearance.MaterialComponents.Headline6"
|
|
27
|
+
android:id="@+id/everywhere_error_title"
|
|
28
|
+
android:layout_width="match_parent"
|
|
29
|
+
android:layout_height="wrap_content"
|
|
30
|
+
android:gravity="center_horizontal"
|
|
31
|
+
android:text="@string/everywhere_error_title" />
|
|
32
|
+
|
|
33
|
+
<com.google.android.material.textview.MaterialTextView
|
|
34
|
+
style="@style/TextAppearance.MaterialComponents.Caption"
|
|
35
|
+
android:id="@+id/everywhere_error_description"
|
|
36
|
+
android:layout_width="match_parent"
|
|
37
|
+
android:layout_height="wrap_content"
|
|
38
|
+
android:layout_marginTop="8dp"
|
|
39
|
+
android:gravity="center_horizontal"
|
|
40
|
+
android:textSize="14sp" />
|
|
41
|
+
|
|
42
|
+
<com.google.android.material.button.MaterialButton
|
|
43
|
+
android:id="@+id/everywhere_error_retry"
|
|
44
|
+
android:layout_width="wrap_content"
|
|
45
|
+
android:layout_height="wrap_content"
|
|
46
|
+
android:layout_marginTop="24dp"
|
|
47
|
+
android:text="@string/everywhere_error_retry" />
|
|
48
|
+
|
|
49
|
+
<com.google.android.material.button.MaterialButton
|
|
50
|
+
style="@style/Widget.MaterialComponents.Button.TextButton"
|
|
51
|
+
android:id="@+id/everywhere_error_leave"
|
|
52
|
+
android:layout_width="wrap_content"
|
|
53
|
+
android:layout_height="wrap_content"
|
|
54
|
+
android:layout_marginTop="8dp"
|
|
55
|
+
android:text="@string/everywhere_error_leave"
|
|
56
|
+
android:visibility="gone" />
|
|
57
|
+
|
|
58
|
+
</LinearLayout>
|
|
59
|
+
|
|
60
|
+
</FrameLayout>
|
|
@@ -15,4 +15,23 @@
|
|
|
15
15
|
names its own overflow tab on iOS.
|
|
16
16
|
-->
|
|
17
17
|
<string name="everywhere_more_tab">More</string>
|
|
18
|
+
<!--
|
|
19
|
+
The failed-visit screen (WebFragment.createErrorView). "Back to start"
|
|
20
|
+
shows only inside a picked instance, where retrying the same dead host
|
|
21
|
+
is the only other move — it clears the instance and re-roots.
|
|
22
|
+
-->
|
|
23
|
+
<string name="everywhere_error_title">Error loading page</string>
|
|
24
|
+
<string name="everywhere_error_retry">Try again</string>
|
|
25
|
+
<string name="everywhere_error_leave">Back to start</string>
|
|
26
|
+
<!--
|
|
27
|
+
MissingExtensionFragment — the fallback destination when a previewed
|
|
28
|
+
app's rule names a native screen compiled into ITS build, not this
|
|
29
|
+
shell. Copy mirrors iOS's MissingExtensionScreen.
|
|
30
|
+
-->
|
|
31
|
+
<string name="everywhere_missing_screen_nav_title">Preview</string>
|
|
32
|
+
<string name="everywhere_missing_screen_title">This screen is native</string>
|
|
33
|
+
<string name="everywhere_missing_screen_body">In the full build, “%1$s” is a native screen compiled into the app. Jump can\'t run another app\'s native code — everything else in this preview works.</string>
|
|
34
|
+
<!-- Older previewed apps serve no fallback_uri; their native-screen visits
|
|
35
|
+
are refused with this toast instead of a spinner that never ends. -->
|
|
36
|
+
<string name="everywhere_missing_screen_toast">“%1$s” is a native screen in the full build — Jump can\'t run another app\'s native code.</string>
|
|
18
37
|
</resources>
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import UIKit
|
|
2
2
|
|
|
3
3
|
/// A full-screen error page with a retry button, presented when a visit fails
|
|
4
|
-
/// (e.g. the server is unreachable).
|
|
4
|
+
/// (e.g. the server is unreachable). When the failure happened inside a picked
|
|
5
|
+
/// instance — where retrying can only knock on the same dead host — a second
|
|
6
|
+
/// button offers the way home (SceneDelegate passes `leaveHandler` exactly
|
|
7
|
+
/// then, and it clears the instance and re-roots).
|
|
5
8
|
final class ErrorViewController: UIViewController {
|
|
6
9
|
private let error: Error
|
|
7
10
|
private let retryHandler: (() -> Void)?
|
|
11
|
+
private let leaveHandler: (() -> Void)?
|
|
8
12
|
|
|
9
|
-
init(error: Error, retryHandler: (() -> Void)?) {
|
|
13
|
+
init(error: Error, retryHandler: (() -> Void)?, leaveHandler: (() -> Void)? = nil) {
|
|
10
14
|
self.error = error
|
|
11
15
|
self.retryHandler = retryHandler
|
|
16
|
+
self.leaveHandler = leaveHandler
|
|
12
17
|
super.init(nibName: nil, bundle: nil)
|
|
13
18
|
}
|
|
14
19
|
|
|
@@ -41,7 +46,15 @@ final class ErrorViewController: UIViewController {
|
|
|
41
46
|
})
|
|
42
47
|
retryButton.isHidden = retryHandler == nil
|
|
43
48
|
|
|
44
|
-
|
|
49
|
+
var leaveConfiguration = UIButton.Configuration.plain()
|
|
50
|
+
leaveConfiguration.title = "Back to start"
|
|
51
|
+
leaveConfiguration.buttonSize = .large
|
|
52
|
+
let leaveButton = UIButton(configuration: leaveConfiguration, primaryAction: UIAction { [weak self] _ in
|
|
53
|
+
self?.leave()
|
|
54
|
+
})
|
|
55
|
+
leaveButton.isHidden = leaveHandler == nil
|
|
56
|
+
|
|
57
|
+
let stackView = UIStackView(arrangedSubviews: [imageView, messageLabel, retryButton, leaveButton])
|
|
45
58
|
stackView.axis = .vertical
|
|
46
59
|
stackView.alignment = .center
|
|
47
60
|
stackView.spacing = 16
|
|
@@ -61,4 +74,9 @@ final class ErrorViewController: UIViewController {
|
|
|
61
74
|
retryHandler?()
|
|
62
75
|
dismiss(animated: true)
|
|
63
76
|
}
|
|
77
|
+
|
|
78
|
+
private func leave() {
|
|
79
|
+
dismiss(animated: true)
|
|
80
|
+
leaveHandler?()
|
|
81
|
+
}
|
|
64
82
|
}
|
|
@@ -599,7 +599,18 @@ extension SceneDelegate: NavigatorDelegate {
|
|
|
599
599
|
}
|
|
600
600
|
|
|
601
601
|
private func presentError(_ error: HotwireNativeError, retryHandler: RetryBlock?) {
|
|
602
|
-
|
|
602
|
+
// Inside a picked instance whose server has gone away, Retry can only
|
|
603
|
+
// knock on the same dead host — offer the way home too: clear the
|
|
604
|
+
// instance and re-root, exactly what the leave tab does when it can load.
|
|
605
|
+
var leaveHandler: (() -> Void)?
|
|
606
|
+
if config.remoteInstances == true, EverywhereConfig.instanceURL != nil {
|
|
607
|
+
leaveHandler = { [weak self] in
|
|
608
|
+
guard let self else { return }
|
|
609
|
+
EverywhereConfig.setInstanceURL(nil)
|
|
610
|
+
self.resetApp(to: self.config.startURL)
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
let errorViewController = ErrorViewController(error: error, retryHandler: retryHandler, leaveHandler: leaveHandler)
|
|
603
614
|
errorViewController.modalPresentationStyle = .fullScreen
|
|
604
615
|
activeNavigator.activeNavigationController.present(errorViewController, animated: true)
|
|
605
616
|
}
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby_everywhere
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrea Fomera
|
|
@@ -240,6 +240,7 @@ files:
|
|
|
240
240
|
- support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt
|
|
241
241
|
- support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt
|
|
242
242
|
- support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt
|
|
243
|
+
- support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MissingExtensionFragment.kt
|
|
243
244
|
- support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt
|
|
244
245
|
- support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt
|
|
245
246
|
- support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt
|
|
@@ -257,6 +258,7 @@ files:
|
|
|
257
258
|
- support/mobile/android/app/src/main/res/layout/activity_main.xml
|
|
258
259
|
- support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml
|
|
259
260
|
- support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml
|
|
261
|
+
- support/mobile/android/app/src/main/res/layout/everywhere_error.xml
|
|
260
262
|
- support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml
|
|
261
263
|
- support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml
|
|
262
264
|
- support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
|