ruby_everywhere 0.8.0 → 0.9.1

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: d36d2151f58701a6eab470774617b2c81dd99b78caa9829a152017e9840652e9
4
+ data.tar.gz: 1315f5032b754d7a45418604850cd96f9168ddeb25a7faf24df91608df7ede8c
5
5
  SHA512:
6
- metadata.gz: 4de42927fa8a53c136e0f9a3015ee1f930199642a26504d198673d0fe3e8cc24e32e9db219d34a170023809db8a9fb20715a6577e13cc6bb030fc9e3a8600b45
7
- data.tar.gz: 58da2af357a422bd9dd87badf31175ba71767a5c3364d52f875690fd217a0be15cf1b12296bb195332ebec015ede65b7f486e3c1bf65ff66d5d0cb6831a20e3f
6
+ metadata.gz: a2a6201f5ecae1781fecca6750a8952250d78e22e8b45c224c696fab8717a4ff2bb173b591d1e8724d69c695633358a8fb5320e74e2e849fcab738d5f2870b68
7
+ data.tar.gz: acaed9ef6364a807dcdf85378e33bac32f99e5309be5e9fcc404c257f6c2dc348f5f61b5e2482582a4bfca56495677da97dcf9de51f308ef9522b8919854185f
@@ -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
@@ -114,6 +114,18 @@ module Everywhere
114
114
  @data.dig("remote", "instances") == true
115
115
  end
116
116
 
117
+ # Whether the shell offers its built-in escape hatch while re-rooted on
118
+ # an instance: a "back to start" toolbar action on screens where the tab
119
+ # bar (and the injected leave tab it carries) is hidden — every
120
+ # modal-context screen, and an instance serving no tabs at all. On by
121
+ # default because stranding is inherent to the picker pattern (a
122
+ # previewed app's whole signed-out surface can be modal); an instance
123
+ # app that wants no shell chrome opts out with
124
+ # `remote: { instances_escape: false }`.
125
+ def remote_instances_escape?
126
+ remote_instances? && @data.dig("remote", "instances_escape") != false
127
+ end
128
+
117
129
  def tint_color
118
130
  normalize_color(appearance["tint_color"])
119
131
  end
@@ -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
@@ -31,6 +31,7 @@ module Everywhere
31
31
  "mode" => mode,
32
32
  "remote_url" => remote_url,
33
33
  "remote_instances" => (true if remote_instances?),
34
+ "instances_escape" => (true if remote_instances_escape?),
34
35
  "entry_path" => entry_path,
35
36
  "tint_color" => tint_color,
36
37
  "background_color" => background_color,
@@ -3,7 +3,7 @@
3
3
  require "json"
4
4
 
5
5
  module Everywhere
6
- VERSION = "0.8.0"
6
+ VERSION = "0.9.1"
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
  }
@@ -145,7 +169,18 @@ private class ResetRouteDecisionHandler : Router.RouteDecisionHandler {
145
169
  activity: HotwireActivity
146
170
  ): Router.Decision {
147
171
  val to = proposal.location.toUri().getQueryParameter("to")
148
- EverywhereEvents.emit(EverywhereEvents.Event.ResetApp(to))
172
+ // Directly, not through EverywhereEvents: the event collector is a
173
+ // coroutine that can lose the race to the tab-path watcher when a
174
+ // sign-in changes the auth-gated tabs, and the watcher's plain
175
+ // recreate() restores the half-dismissed pre-auth back stack — a
176
+ // framework crash. Calling in synchronously puts the reset (and its
177
+ // pendingResetTarget) in place before the config refresh can land.
178
+ val main = activity as? MainActivity
179
+ if (main != null) {
180
+ main.resetAppNow(to)
181
+ } else {
182
+ EverywhereEvents.emit(EverywhereEvents.Event.ResetApp(to))
183
+ }
149
184
  return Router.Decision.CANCEL
150
185
  }
151
186
 
@@ -154,6 +189,43 @@ private class ResetRouteDecisionHandler : Router.RouteDecisionHandler {
154
189
  }
155
190
  }
156
191
 
192
+ /**
193
+ * The last line of defence for a native-screen rule this build can't serve.
194
+ *
195
+ * A previewed app on a current gem serves `fallback_uri` pointing at
196
+ * [MissingExtensionFragment], and the visit lands there — this handler never
197
+ * matches. An app on an older gem serves only `uri: hotwire://fragment/<id>`,
198
+ * and Hotwire's answer to a uri no registered destination claims is to do
199
+ * nothing at all — which reads as a spinner that never ends. Refuse the visit
200
+ * loudly instead: a toast naming the screen, and the user stays on the page
201
+ * they tapped from.
202
+ */
203
+ private class MissingScreenRouteDecisionHandler(
204
+ private val registeredUris: Set<String>
205
+ ) : Router.RouteDecisionHandler {
206
+ override val name = "everywhere-missing-screen"
207
+
208
+ override fun matches(proposal: VisitProposal, configuration: NavigatorConfiguration): Boolean {
209
+ val uri = proposal.properties["uri"]?.toString() ?: return false
210
+ val fallback = proposal.properties["fallback_uri"]?.toString()
211
+ return uri !in registeredUris && (fallback == null || fallback !in registeredUris)
212
+ }
213
+
214
+ override fun handle(
215
+ proposal: VisitProposal,
216
+ configuration: NavigatorConfiguration,
217
+ activity: HotwireActivity
218
+ ): Router.Decision {
219
+ val identifier = proposal.properties["view_controller"]?.toString() ?: "native"
220
+ Toast.makeText(
221
+ activity,
222
+ activity.getString(R.string.everywhere_missing_screen_toast, identifier),
223
+ Toast.LENGTH_LONG
224
+ ).show()
225
+ return Router.Decision.CANCEL
226
+ }
227
+ }
228
+
157
229
  /**
158
230
  * A provider sign-in: hand it to a Custom Tab instead of loading it in the web
159
231
  * view. Cancelling leaves the user on the page they tapped from, which is where
@@ -114,6 +114,10 @@ class EverywhereConfig private constructor(
114
114
  val version: String? = json.string("version")
115
115
  val entryPath: String? = json.string("entry_path")
116
116
  val remoteInstances: Boolean = json["remote_instances"]?.jsonPrimitive?.booleanOrNull == true
117
+
118
+ /** `remote.instances_escape` — the built-in "back to start" toolbar action
119
+ * offered while re-rooted, on screens whose tab bar is hidden. */
120
+ val instancesEscape: Boolean = json["instances_escape"]?.jsonPrimitive?.booleanOrNull == true
117
121
  val permissions: List<String> =
118
122
  json["permissions"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
119
123
  val universalLinkHosts: List<String> =
@@ -189,6 +193,27 @@ class EverywhereConfig private constructor(
189
193
  return if (path.isNullOrEmpty() || path == "/") rootUrl else url(forPath = path)
190
194
  }
191
195
 
196
+ /**
197
+ * Every origin the shell's own pages can be served from: the stamped
198
+ * root, the dev override, and a picked instance. The control channel is
199
+ * offered to all of them, not just the current [rootUrl] — while rooted
200
+ * on an instance, the picker's pages still come from the stamped origin,
201
+ * and without the channel there they could switch in but never switch
202
+ * again (`Everywhere.instance.*` would fall back to a plain navigation).
203
+ */
204
+ val trustedRootOrigins: Set<String>
205
+ get() = listOfNotNull(stampedRemoteUrl, devUrl, instanceUrl)
206
+ .mapNotNull(::originOf)
207
+ .toSet()
208
+
209
+ private fun originOf(url: String): String? {
210
+ val uri = url.toUri()
211
+ val scheme = uri.scheme ?: return null
212
+ val host = uri.host ?: return null
213
+ val port = if (uri.port > 0) ":${uri.port}" else ""
214
+ return "$scheme://$host$port"
215
+ }
216
+
192
217
  /**
193
218
  * The server half of the path configuration (rules + settings.tabs),
194
219
  * generated from everywhere.yml by the gem's MobileConfigEndpoint — so tab
@@ -130,6 +130,22 @@ class MainActivity : HotwireActivity() {
130
130
  /** The open More sheet, so a second tap doesn't stack a second one. */
131
131
  private var moreSheet: BottomSheetDialog? = null
132
132
 
133
+ /**
134
+ * Single-flight guard for `recreate()`: a reset and the tab-path watcher
135
+ * can both want a rebuild within the same beat (a sign-in changes the
136
+ * auth-gated tabs moments after the reset route fires), and two stacked
137
+ * relaunches with navigation mid-teardown is a framework crash. Dies with
138
+ * the Activity, which is exactly the right lifetime.
139
+ */
140
+ private var rebuildScheduled = false
141
+
142
+ /**
143
+ * Where the in-flight reset is headed, so a tab-path change caused BY that
144
+ * reset rebuilds fresh (EXTRA_ROUTE, no state restoration) instead of
145
+ * restoring the pre-auth back stack. Null when no reset is in flight.
146
+ */
147
+ private var pendingResetTarget: String? = null
148
+
133
149
  // ------------------------------------------------------------------------
134
150
  // Lifecycle
135
151
  // ------------------------------------------------------------------------
@@ -140,7 +156,16 @@ class MainActivity : HotwireActivity() {
140
156
  // Swap to the real theme before the content view inflates.
141
157
  setTheme(R.style.Theme_Everywhere)
142
158
  enableEdgeToEdge()
143
- super.onCreate(savedInstanceState)
159
+
160
+ // A rebuild ([rebuildAndRoute]: reset, sign-in/out, instance switch)
161
+ // arrives with EXTRA_ROUTE on the Intent and must start from scratch:
162
+ // handing the saved state to super restores every navigator's back
163
+ // stack, climbing the page the user just left back on top of the new
164
+ // root. Dropped here, the recreate behaves like a fresh launch — while
165
+ // a real config change or process death (no EXTRA_ROUTE) restores
166
+ // normally.
167
+ val state = if (intent.hasExtra(EXTRA_ROUTE)) null else savedInstanceState
168
+ super.onCreate(state)
144
169
 
145
170
  setContentView(R.layout.activity_main)
146
171
  findViewById<View>(R.id.root).applyDefaultImeWindowInsets()
@@ -148,11 +173,18 @@ class MainActivity : HotwireActivity() {
148
173
  splash = findViewById(R.id.splash)
149
174
  showSplash()
150
175
 
151
- initializeTabs(savedInstanceState?.getInt(STATE_SELECTED_TAB) ?: 0)
176
+ initializeTabs(state?.getInt(STATE_SELECTED_TAB) ?: 0)
152
177
 
153
178
  observeEvents()
154
179
  observePathConfiguration()
155
180
 
181
+ // A rebuild chain in progress (see rebuildAndRoute): the config that
182
+ // motivated it may land after this Activity built its tabs, and the
183
+ // paths watcher needs to know to rebuild fresh again, aimed at the
184
+ // same landing page. Cleared by applyPathConfiguration once a config
185
+ // pass finds the tabs already correct.
186
+ pendingResetTarget = intent.getStringExtra(EXTRA_RESET_TARGET)
187
+
156
188
  // Survives `recreate()` — the Intent does, fields don't. See
157
189
  // [rebuildAndRoute].
158
190
  intent.getStringExtra(EXTRA_ROUTE)?.let {
@@ -203,6 +235,13 @@ class MainActivity : HotwireActivity() {
203
235
 
204
236
  override fun navigatorConfigurations(): List<NavigatorConfiguration> = pooledConfigurations
205
237
 
238
+ /**
239
+ * Whether a tab bar is on screen at all — [WebFragment] uses this to
240
+ * decide if the instance-escape toolbar action is needed. Modal screens
241
+ * hide the bar regardless; the fragment checks its own context for that.
242
+ */
243
+ val hasTabBar: Boolean get() = currentArrangement.slots.isNotEmpty()
244
+
206
245
  /**
207
246
  * A host finished building its start destination. Used to flush a route
208
247
  * captured before any navigator existed — at `onCreate` there is nothing to
@@ -220,7 +259,14 @@ class MainActivity : HotwireActivity() {
220
259
 
221
260
  pendingRoute = null
222
261
  pendingRouteHostId = null
223
- navigator.route(route)
262
+ // Post, don't call: this callback fires from onAttachFragment while
263
+ // the host's start fragment is still mid-transaction — no view, no
264
+ // web delegate — and routing into it synchronously crashes Hotwire
265
+ // ("lateinit property webDelegate has not been initialized"). One
266
+ // trip through the main queue lets the transaction finish first.
267
+ window.decorView.post {
268
+ if (!isFinishing && !isDestroyed) navigator.route(route)
269
+ }
224
270
  }
225
271
 
226
272
  // ------------------------------------------------------------------------
@@ -380,19 +426,32 @@ class MainActivity : HotwireActivity() {
380
426
  * handling selection would make More a one-shot.
381
427
  */
382
428
  private fun installTabListeners(arrangement: MainTabs.Arrangement) {
383
- if (!arrangement.hasMore) return
429
+ val hasLeaveSlot = arrangement.slots.any { isLeaveTab(it.entry?.path) }
430
+ if (!arrangement.hasMore && !hasLeaveSlot) return
384
431
 
385
432
  bottomNav.setOnItemSelectedListener { item ->
386
433
  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
434
+ when {
435
+ // The Jump leave tab is a native action, exactly as on iOS
436
+ // (EverywhereTabBarController): the page behind it is a
437
+ // gem-served Turbo-less fallback, so selecting its host could
438
+ // only end at the error view. The tap clears the picked
439
+ // instance directly and never becomes a selection.
440
+ isLeaveTab(arrangement.entryAt(index)?.path) -> {
441
+ leaveInstance()
442
+ false
443
+ }
444
+ // selectingMoreForRoute is the one case where selecting More IS
445
+ // the intent: something routed to an overflow path and the host
446
+ // has to come forward to receive it.
447
+ arrangement.isMoreIndex(index) && !selectingMoreForRoute -> {
448
+ showMoreSheet(arrangement)
449
+ false
450
+ }
451
+ else -> {
452
+ switchToSlot(index)
453
+ true
454
+ }
396
455
  }
397
456
  }
398
457
 
@@ -408,6 +467,19 @@ class MainActivity : HotwireActivity() {
408
467
  }
409
468
  }
410
469
 
470
+ /**
471
+ * The injected "leave preview" tab (a Jump instance's way home), matched
472
+ * by its gem-reserved path. Only meaningful for instance apps — for
473
+ * everyone else no such tab is ever injected, and the guard keeps an
474
+ * unlucky app path from being swallowed.
475
+ */
476
+ private fun isLeaveTab(path: String?): Boolean =
477
+ config.remoteInstances && path == JUMP_LEAVE_PATH
478
+
479
+ private fun leaveInstance() {
480
+ EverywhereEvents.emit(EverywhereEvents.Event.ClearInstance(null))
481
+ }
482
+
411
483
  /**
412
484
  * Bring a slot's host forward — upstream `HotwireBottomNavigationController`'s
413
485
  * private `switchTab`, which taking over the item-selected listener costs us.
@@ -466,7 +538,11 @@ class MainActivity : HotwireActivity() {
466
538
 
467
539
  row.setOnClickListener {
468
540
  dialog.dismiss()
469
- route(config.url(forPath = entry.path))
541
+ // The leave row is the same native action as a leave SLOT —
542
+ // see installTabListeners; routing it would load the
543
+ // Turbo-less fallback page and end at the error view.
544
+ if (isLeaveTab(entry.path)) leaveInstance()
545
+ else route(config.url(forPath = entry.path))
470
546
  }
471
547
  list.addView(row)
472
548
  }
@@ -534,16 +610,27 @@ class MainActivity : HotwireActivity() {
534
610
  lifecycleScope.launch {
535
611
  repeatOnLifecycle(Lifecycle.State.STARTED) {
536
612
  Hotwire.config.pathConfiguration.loadState.collect { state ->
537
- if (state is PathConfigurationLoadState.Loaded) applyPathConfiguration()
613
+ if (state is PathConfigurationLoadState.Loaded) {
614
+ // Only a fresh server answer can settle a rebuild
615
+ // chain: bundled/cached passes reflect the OLD root
616
+ // and match trivially right after a re-root, before
617
+ // the config that motivated the rebuild has landed.
618
+ applyPathConfiguration(
619
+ fresh = state is PathConfigurationLoadState.Loaded.RemoteLoaded
620
+ )
621
+ }
538
622
  }
539
623
  }
540
624
  }
541
625
  }
542
626
 
543
- private fun applyPathConfiguration() {
627
+ private fun applyPathConfiguration(fresh: Boolean = false) {
544
628
  val entries = MainTabs.entries()
545
629
  val fingerprint = MainTabs.fingerprint(entries)
546
- if (fingerprint == loadedFingerprint) return
630
+ if (fingerprint == loadedFingerprint) {
631
+ if (fresh) settleRebuildChain()
632
+ return
633
+ }
547
634
 
548
635
  val arrangement = MainTabs.arrange(entries, moreTitle)
549
636
 
@@ -577,14 +664,38 @@ class MainActivity : HotwireActivity() {
577
664
  return
578
665
  }
579
666
 
580
- Log.d(TAG, "tab paths changed rebuilding the activity")
667
+ // ALWAYS the fresh-start rebuild, never a state-restoring
668
+ // recreate(): the restored back stacks belong to hosts whose
669
+ // start locations just changed, and restoring them mid-flight —
670
+ // during a reset's teardown, an instance switch's cold boot —
671
+ // is every framework crash this file has collected ("No
672
+ // configuration found for NavigatorHost", empty-URL proposals,
673
+ // clearAll pop-loops). iOS rebuilds its navigators fresh on a
674
+ // tab change too. Land where the in-flight reset was headed, or
675
+ // where the user is standing, or at the start.
676
+ val target = pendingResetTarget
677
+ ?: delegate.currentNavigator?.location
678
+ ?: config.startUrl
679
+ Log.d(TAG, "tab paths changed — fresh rebuild to $target")
581
680
  intent.putExtra(EXTRA_LAST_REBUILD, now)
582
- recreate()
681
+ rebuildAndRoute(target)
583
682
  return
584
683
  }
585
684
 
586
685
  loadedFingerprint = fingerprint
587
686
  applyTabs(arrangement, safeSelectedIndex(arrangement))
687
+ if (fresh) settleRebuildChain()
688
+ }
689
+
690
+ /**
691
+ * The tabs now match the loaded config with no rebuild needed — whatever
692
+ * rebuild chain was in flight (a reset, an instance switch) has landed.
693
+ * Clearing the mark stops a LATER unrelated tab-path change from being
694
+ * misread as part of it and redirected to a stale landing page.
695
+ */
696
+ private fun settleRebuildChain() {
697
+ pendingResetTarget = null
698
+ intent.removeExtra(EXTRA_RESET_TARGET)
588
699
  }
589
700
 
590
701
  /**
@@ -727,28 +838,29 @@ class MainActivity : HotwireActivity() {
727
838
 
728
839
  /**
729
840
  * Full app reset for an auth change. Deterministic and self-contained — it
730
- * does NOT depend on the reset page's JavaScript running:
731
- * 1. abandon any sign-in still in flight; it is answering a question the
732
- * app no longer has,
733
- * 2. clear cached web content, so no page is served from its pre-auth
734
- * state,
735
- * 3. reset every live navigator — new Session, new WebView, backstack
736
- * cleared — and route the current one at `target`,
737
- * 4. refetch the auth-aware config, which shows or hides the auth-gated
738
- * tabs to match whoever the user is now.
841
+ * does NOT depend on the reset page's JavaScript running: refetch the
842
+ * auth-aware config (shows or hides the auth-gated tabs to match whoever
843
+ * the user is now) and rebuild the Activity fresh, landing on `target`.
844
+ * rebuildAndRoute abandons any in-flight sign-in and clears cached web
845
+ * content on the way.
739
846
  */
740
847
  private fun resetApp(target: String) {
741
- AuthFlow.cancel()
742
848
  awaitingAuthHandoff = false
743
- clearWebContentCache()
744
-
745
- delegate.resetNavigators()
746
-
747
- // resetNavigators rebuilds each host's graph; routing has to wait for
748
- // that to land or the visit is swallowed by the rebuild.
749
- bottomNav.post { route(target) }
750
849
 
850
+ // NOT delegate.resetNavigators() + route: the in-place reset detaches
851
+ // every WebView asynchronously, and each detach continuation ends in
852
+ // Hotwire's clearAll pop-loop — which, once the recreate that follows
853
+ // has made the FragmentManager save its state, spins forever on
854
+ // ignored popBackStack() calls and ANRs the app. A fresh Activity IS
855
+ // the reset (new sessions, new WebViews, empty back stacks), with no
856
+ // async teardown left behind to race it.
857
+ //
858
+ // The refetch starts first so the auth-aware tab set has a head start;
859
+ // if it still lands after the new Activity built its tabs, the
860
+ // EXTRA_RESET_TARGET chain (see rebuildAndRoute) finishes the job with
861
+ // a second fresh rebuild.
751
862
  reloadPathConfiguration()
863
+ rebuildAndRoute(target)
752
864
  }
753
865
 
754
866
  /**
@@ -777,17 +889,54 @@ class MainActivity : HotwireActivity() {
777
889
  }
778
890
 
779
891
  /**
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`.
892
+ * `recreate()` for an in-place rebuild a CLEAR_TASK relaunch tears the
893
+ * task down first, which plays as the app closing and cold-starting on
894
+ * every reset (sign-in, sign-out, instance switch). recreate() keeps the
895
+ * window; the landing page rides along on the Intent, which recreate()
896
+ * preserves while fields are lost.
897
+ *
898
+ * The saved instance state, however, must NOT come back: it carries every
899
+ * navigator's back stack, and restoring it climbs the page the user just
900
+ * left (Jump's connect screen, a pre-sign-in page) right back on top of
901
+ * the new root. `onCreate` treats the presence of [EXTRA_ROUTE] as the
902
+ * fresh-start flag and drops the state — see the top of [onCreate].
783
903
  */
784
904
  private fun rebuildAndRoute(target: String) {
785
905
  AuthFlow.cancel()
786
906
  clearWebContentCache()
907
+ // The extras go on unconditionally: if a relaunch is already scheduled
908
+ // (the tab-path watcher got there first), it reuses this same Intent,
909
+ // and EXTRA_ROUTE upgrades it from a state-restoring recreate to the
910
+ // fresh-start rebuild a reset needs.
911
+ //
912
+ // EXTRA_RESET_TARGET marks the whole chain: the next Activity boots
913
+ // its tabs from whatever config is loaded NOW, and the refreshed
914
+ // config (an instance's tabs, the post-auth tab set) usually lands
915
+ // moments later. When that changes the tab paths, the watcher must
916
+ // rebuild fresh again, aimed here — never restore. The mark is
917
+ // cleared when a config pass finds nothing left to rebuild.
918
+ pendingResetTarget = target
787
919
  intent.putExtra(EXTRA_ROUTE, target)
920
+ intent.putExtra(EXTRA_RESET_TARGET, target)
921
+ if (rebuildScheduled) return
922
+ rebuildScheduled = true
788
923
  recreate()
789
924
  }
790
925
 
926
+ /**
927
+ * The reset route (`/everywhere/reset` after a sign-in or sign-out) calls
928
+ * this DIRECTLY from its route decision handler rather than through
929
+ * [EverywhereEvents]: a sign-in changes the auth-gated tab paths moments
930
+ * later, and if the event collector loses the race to the tab-path
931
+ * watcher's `recreate()`, the app restores the half-dismissed pre-sign-in
932
+ * back stack — mid-flight WebView teardown included — and crashes inside
933
+ * the framework. Scheduling the fresh-start relaunch synchronously, before
934
+ * the config refresh can land, makes the ordering deterministic.
935
+ */
936
+ fun resetAppNow(to: String?) {
937
+ resetApp(resetTarget(to))
938
+ }
939
+
791
940
  /**
792
941
  * Drop web storage so a page isn't served from its pre-auth state. Cookies
793
942
  * are left intact — the server has already invalidated the session on
@@ -954,8 +1103,13 @@ class MainActivity : HotwireActivity() {
954
1103
  const val TAG = "Everywhere"
955
1104
  const val STATE_SELECTED_TAB = "everywhere.selectedTab"
956
1105
  const val EXTRA_ROUTE = "everywhere.route"
1106
+ const val EXTRA_RESET_TARGET = "everywhere.resetTarget"
957
1107
  const val EXTRA_LAST_REBUILD = "everywhere.lastRebuild"
958
1108
 
1109
+ /** The injected leave tab's path — same constant iOS keeps on
1110
+ * EverywhereTabBarController.jumpLeavePath. */
1111
+ const val JUMP_LEAVE_PATH = "/everywhere/jump/leave"
1112
+
959
1113
  /**
960
1114
  * How close two path-change rebuilds have to be before the second is
961
1115
  * 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,12 @@
1
1
  package com.rubyeverywhere.shell
2
2
 
3
+ import android.os.Bundle
4
+ import android.view.Menu
5
+ import android.view.MenuItem
6
+ import android.view.View
7
+ import android.widget.TextView
8
+ import androidx.core.graphics.drawable.DrawableCompat
9
+ import androidx.core.view.isVisible
3
10
  import dev.hotwire.core.turbo.errors.VisitError
4
11
  import dev.hotwire.navigation.destinations.HotwireDestinationDeepLink
5
12
  import dev.hotwire.navigation.fragments.HotwireWebFragment
@@ -8,14 +15,63 @@ import dev.hotwire.navigation.fragments.HotwireWebFragment
8
15
  * The default destination for every web visit.
9
16
  *
10
17
  * 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.
18
+ * jobs are telling [MainActivity] when there is content on screen (how the
19
+ * splash comes down Android has no `NavigatorDelegate.requestDidFinish` the
20
+ * way iOS does; the visit callbacks on the fragment are where that information
21
+ * surfaces) and swapping the library's button-less error view for one the user
22
+ * can act on.
15
23
  */
16
24
  @HotwireDestinationDeepLink(uri = "hotwire://fragment/web")
17
25
  open class WebFragment : HotwireWebFragment() {
18
26
 
27
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
28
+ super.onViewCreated(view, savedInstanceState)
29
+ addLeavePreviewAction()
30
+ }
31
+
32
+ /**
33
+ * While a picked instance is active, screens whose TAB BAR is hidden get
34
+ * the way home in their toolbar instead. The injected leave TAB alone is
35
+ * not enough: Hotwire hides the tab bar on modal-context screens, and a
36
+ * previewed app's whole signed-out surface can be modal (`/session/new`
37
+ * under a `/new$` rule) — leaving no escape at all once a sign-out lands
38
+ * there. Where the bar IS showing, its Jump tab already covers this, so
39
+ * the toolbar stays the app's own. `remote.instances_escape: false`
40
+ * turns the whole affordance off.
41
+ */
42
+ private fun addLeavePreviewAction() {
43
+ val config = EverywhereConfig.shared
44
+ if (!config.instancesEscape || config.instanceUrl == null) return
45
+
46
+ val modal = pathProperties["context"]?.toString() == "modal"
47
+ val barAvailable = (activity as? MainActivity)?.hasTabBar == true
48
+ if (!modal && barAvailable) return
49
+
50
+ val toolbar = toolbarForNavigation() ?: return
51
+ if (toolbar.menu.findItem(MENU_LEAVE_PREVIEW) != null) return
52
+
53
+ val sizePx = (22 * resources.displayMetrics.density).toInt()
54
+ val tint = requireContext().let { context ->
55
+ val value = android.util.TypedValue()
56
+ context.theme.resolveAttribute(
57
+ com.google.android.material.R.attr.colorOnSurface, value, true
58
+ )
59
+ context.getColor(value.resourceId)
60
+ }
61
+ toolbar.menu.add(
62
+ Menu.NONE, MENU_LEAVE_PREVIEW, Menu.NONE, R.string.everywhere_error_leave
63
+ ).apply {
64
+ icon = DrawableCompat.wrap(
65
+ IconFont.drawable(requireContext(), "u_turn_left", sizePx)
66
+ ).mutate().also { DrawableCompat.setTint(it, tint) }
67
+ setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
68
+ setOnMenuItemClickListener {
69
+ EverywhereEvents.emit(EverywhereEvents.Event.ClearInstance(null))
70
+ true
71
+ }
72
+ }
73
+ }
74
+
19
75
  override fun onVisitRequestFinished(location: String) {
20
76
  super.onVisitRequestFinished(location)
21
77
  settled()
@@ -30,7 +86,35 @@ open class WebFragment : HotwireWebFragment() {
30
86
  settled()
31
87
  }
32
88
 
89
+ /**
90
+ * The library's error view is a message with no controls — its only retry
91
+ * is pull-to-refresh, invisible exactly when the user is staring at a dead
92
+ * page. Ours adds an explicit retry, and — inside a picked instance, where
93
+ * retrying can only knock on the same dead host — the way home: clear the
94
+ * instance and re-root onto the app's own start page, exactly what the
95
+ * page's own `Everywhere.instance.clear()` would do if it could load.
96
+ */
97
+ override fun createErrorView(error: VisitError): View {
98
+ val view = layoutInflater.inflate(R.layout.everywhere_error, null)
99
+ view.findViewById<TextView>(R.id.everywhere_error_description).text = error.description()
100
+ view.findViewById<View>(R.id.everywhere_error_retry).setOnClickListener {
101
+ refresh(displayProgress = true)
102
+ }
103
+ view.findViewById<View>(R.id.everywhere_error_leave).apply {
104
+ isVisible = EverywhereConfig.shared.instanceUrl != null
105
+ setOnClickListener {
106
+ EverywhereEvents.emit(EverywhereEvents.Event.ClearInstance(null))
107
+ }
108
+ }
109
+ return view
110
+ }
111
+
33
112
  private fun settled() {
34
113
  (activity as? MainActivity)?.onWebContentSettled()
35
114
  }
115
+
116
+ private companion object {
117
+ /** Stable menu id so re-created toolbars don't stack duplicates. */
118
+ const val MENU_LEAVE_PREVIEW = 0x00EE01
119
+ }
36
120
  }
@@ -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>
@@ -55,6 +55,10 @@ final class MenuComponent: BridgeComponent {
55
55
  // order ("Edit", "Share") lands left-to-right as authored.
56
56
  viewController.navigationItem.leftBarButtonItems = left.isEmpty ? nil : left
57
57
  viewController.navigationItem.rightBarButtonItems = right.isEmpty ? nil : right.reversed()
58
+
59
+ // The wholesale replacement above just dropped the shell's
60
+ // instance-escape action, if this screen carries one — put it back.
61
+ (viewController as? EverywhereWebViewController)?.appendLeaveItemIfMissing()
58
62
  }
59
63
 
60
64
  private func barButtonItem(for item: NavItem) -> UIBarButtonItem {
@@ -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
  }
@@ -77,6 +77,10 @@ struct EverywhereConfig: Decodable {
77
77
  let mode: String?
78
78
  let remoteUrl: String?
79
79
  let remoteInstances: Bool?
80
+ /// `remote.instances_escape` — the built-in "back to start" nav-bar action
81
+ /// offered while re-rooted, on screens the tab bar (and its injected
82
+ /// leave tab) can't reach: modals, and instances serving no tabs.
83
+ let instancesEscape: Bool?
80
84
  let entryPath: String?
81
85
  let tintColor: ThemedColor?
82
86
  let backgroundColor: ThemedColor?
@@ -92,6 +96,7 @@ struct EverywhereConfig: Decodable {
92
96
  case mode
93
97
  case remoteUrl = "remote_url"
94
98
  case remoteInstances = "remote_instances"
99
+ case instancesEscape = "instances_escape"
95
100
  case entryPath = "entry_path"
96
101
  case tintColor = "tint_color"
97
102
  case backgroundColor = "background_color"
@@ -570,7 +570,10 @@ extension SceneDelegate: NavigatorDelegate {
570
570
  return .acceptCustom(everywhereHost(MissingExtensionScreen(identifier: identifier)))
571
571
  }
572
572
  }
573
- return .accept
573
+ // The shell's own web controller rather than the framework default —
574
+ // it carries the instance-escape nav action on screens the tab bar
575
+ // can't reach (see EverywhereWebViewController).
576
+ return .acceptCustom(EverywhereWebViewController(url: proposal.url))
574
577
  }
575
578
 
576
579
  func requestDidFinish(at url: URL) {
@@ -599,7 +602,18 @@ extension SceneDelegate: NavigatorDelegate {
599
602
  }
600
603
 
601
604
  private func presentError(_ error: HotwireNativeError, retryHandler: RetryBlock?) {
602
- let errorViewController = ErrorViewController(error: error, retryHandler: retryHandler)
605
+ // Inside a picked instance whose server has gone away, Retry can only
606
+ // knock on the same dead host — offer the way home too: clear the
607
+ // instance and re-root, exactly what the leave tab does when it can load.
608
+ var leaveHandler: (() -> Void)?
609
+ if config.remoteInstances == true, EverywhereConfig.instanceURL != nil {
610
+ leaveHandler = { [weak self] in
611
+ guard let self else { return }
612
+ EverywhereConfig.setInstanceURL(nil)
613
+ self.resetApp(to: self.config.startURL)
614
+ }
615
+ }
616
+ let errorViewController = ErrorViewController(error: error, retryHandler: retryHandler, leaveHandler: leaveHandler)
603
617
  errorViewController.modalPresentationStyle = .fullScreen
604
618
  activeNavigator.activeNavigationController.present(errorViewController, animated: true)
605
619
  }
@@ -652,3 +666,60 @@ struct MissingExtensionScreen: View {
652
666
  .navigationTitle("Preview")
653
667
  }
654
668
  }
669
+
670
+ /// The shell's web view controller — `handle(proposal:)` returns it for every
671
+ /// accepted web visit instead of the framework default.
672
+ ///
673
+ /// Its one addition: while a picked instance is active
674
+ /// (`remote.instances: true` + `Everywhere.instance.set`), screens the tab bar
675
+ /// can't reach carry the way home as a nav-bar action. The injected leave TAB
676
+ /// alone is not enough — modals present over the tab bar, and a previewed
677
+ /// app's whole signed-out surface can be modal (`/session/new` under a `/new$`
678
+ /// rule), leaving no escape once a sign-out lands there. Where the tab bar IS
679
+ /// visible its Jump tab already covers this, so the nav bar stays the app's
680
+ /// own. `remote: { instances_escape: false }` turns the affordance off.
681
+ final class EverywhereWebViewController: HotwireWebViewController {
682
+ /// Exposed so MenuComponent can re-append it after a page's own
683
+ /// `everywhere_nav_button`s replace `rightBarButtonItems` wholesale.
684
+ private(set) var leaveBarButtonItem: UIBarButtonItem?
685
+
686
+ override func viewWillAppear(_ animated: Bool) {
687
+ super.viewWillAppear(animated)
688
+ addLeavePreviewActionIfNeeded()
689
+ }
690
+
691
+ private func addLeavePreviewActionIfNeeded() {
692
+ let config = EverywhereConfig.shared
693
+ guard config.remoteInstances == true,
694
+ config.instancesEscape == true,
695
+ EverywhereConfig.instanceURL != nil,
696
+ // A reachable tab bar means the leave tab is available — stand
697
+ // down. Modals (their own nav stack) and bar-less roots land here.
698
+ tabBarController == nil
699
+ else { return }
700
+
701
+ if leaveBarButtonItem == nil {
702
+ let item = UIBarButtonItem(
703
+ image: UIImage(systemName: "arrow.uturn.backward"),
704
+ primaryAction: UIAction { _ in
705
+ NotificationCenter.default.post(name: .everywhereClearInstance, object: nil)
706
+ }
707
+ )
708
+ item.accessibilityLabel = "Back to start"
709
+ leaveBarButtonItem = item
710
+ }
711
+
712
+ appendLeaveItemIfMissing()
713
+ }
714
+
715
+ /// Idempotent: the page's own nav buttons may have replaced the items —
716
+ /// MenuComponent calls this again after it rebuilds them.
717
+ func appendLeaveItemIfMissing() {
718
+ guard let item = leaveBarButtonItem else { return }
719
+ let items = navigationItem.rightBarButtonItems ?? []
720
+ guard !items.contains(item) else { return }
721
+ // First in the array is the rightmost slot — the escape stays at the
722
+ // far edge, clear of the page's own actions.
723
+ navigationItem.rightBarButtonItems = [item] + items
724
+ }
725
+ }
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.1
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