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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a1644d6c9be14fdb8ef738eaee6f33225e5916ea401945f7c6c494e8963a2c16
4
- data.tar.gz: 03621b7846f16b5621a352e7d4fc42ac38c8859095fc1be1098eef48b081fb0f
3
+ metadata.gz: 97a5e572306841f37118d70c1b5702efc0fc1d48d9377e8ad325e551b7b720b2
4
+ data.tar.gz: 9ffbcd6323f81374f94b1237cdf06a13c50dc2c3466479c8fbb0cfdedfa6ddbd
5
5
  SHA512:
6
- metadata.gz: 4de42927fa8a53c136e0f9a3015ee1f930199642a26504d198673d0fe3e8cc24e32e9db219d34a170023809db8a9fb20715a6577e13cc6bb030fc9e3a8600b45
7
- data.tar.gz: 58da2af357a422bd9dd87badf31175ba71767a5c3364d52f875690fd217a0be15cf1b12296bb195332ebec015ede65b7f486e3c1bf65ff66d5d0cb6831a20e3f
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.merge("uri" => "hotwire://fragment/#{id}")
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
@@ -3,7 +3,7 @@
3
3
  require "json"
4
4
 
5
5
  module Everywhere
6
- VERSION = "0.8.0"
6
+ VERSION = "0.9.0"
7
7
 
8
8
  # Version of the @rubyeverywhere/bridge JS this gem ships. bridge/ in the
9
9
  # gem IS the npm package (served to Rails apps by Everywhere::Engine,
@@ -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
- Hotwire.registerFragmentDestinations(
61
+ val fragmentDestinations = listOf(
59
62
  WebFragment::class,
60
63
  WebBottomSheetFragment::class,
61
- // App-provided native screens (everywhere.yml native.android.screens).
62
- *EverywhereExtensions.fragmentDestinations.toTypedArray()
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
- super.onCreate(savedInstanceState)
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(savedInstanceState?.getInt(STATE_SELECTED_TAB) ?: 0)
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
- navigator.route(route)
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
- if (!arrangement.hasMore) return
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
- // selectingMoreForRoute is the one case where selecting More IS the
388
- // intent: something routed to an overflow path and the host has to
389
- // come forward to receive it.
390
- if (arrangement.isMoreIndex(index) && !selectingMoreForRoute) {
391
- showMoreSheet(arrangement)
392
- false
393
- } else {
394
- switchToSlot(index)
395
- true
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
- route(config.url(forPath = entry.path))
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()` loses every field, so the landing page rides along on the
781
- * Intent which `recreate()` does preserve and is picked up by the next
782
- * `onCreate`.
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
@@ -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
- * one job is telling [MainActivity] when there is content on screen, which is
12
- * how the splash comes down. Android has no `NavigatorDelegate.requestDidFinish`
13
- * the way iOS does; the visit callbacks on the fragment are where that
14
- * information surfaces.
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
  }
@@ -189,16 +189,12 @@ class WebControlChannel : WebViewCompat.WebMessageListener {
189
189
  }
190
190
 
191
191
  /**
192
- * The app's own origin, derived from the *effective* root — so a dev
193
- * build against 127.0.0.1 and a picked instance both work without a
194
- * second list to keep in sync.
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
- let stackView = UIStackView(arrangedSubviews: [imageView, messageLabel, retryButton])
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
- let errorViewController = ErrorViewController(error: error, retryHandler: retryHandler)
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.8.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