ruby_everywhere 0.9.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 +4 -4
- data/lib/everywhere/config/app.rb +12 -0
- data/lib/everywhere/config/shell.rb +1 -0
- data/lib/everywhere/version.rb +1 -1
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +12 -1
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +4 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +118 -22
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +57 -0
- data/support/mobile/ios/App/Bridge/MenuComponent.swift +4 -0
- data/support/mobile/ios/App/EverywhereConfig.swift +5 -0
- data/support/mobile/ios/App/SceneDelegate.swift +61 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d36d2151f58701a6eab470774617b2c81dd99b78caa9829a152017e9840652e9
|
|
4
|
+
data.tar.gz: 1315f5032b754d7a45418604850cd96f9168ddeb25a7faf24df91608df7ede8c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a2a6201f5ecae1781fecca6750a8952250d78e22e8b45c224c696fab8717a4ff2bb173b591d1e8724d69c695633358a8fb5320e74e2e849fcab738d5f2870b68
|
|
7
|
+
data.tar.gz: acaed9ef6364a807dcdf85378e33bac32f99e5309be5e9fcc404c257f6c2dc348f5f61b5e2482582a4bfca56495677da97dcf9de51f308ef9522b8919854185f
|
|
@@ -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
|
|
@@ -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,
|
data/lib/everywhere/version.rb
CHANGED
data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt
CHANGED
|
@@ -169,7 +169,18 @@ private class ResetRouteDecisionHandler : Router.RouteDecisionHandler {
|
|
|
169
169
|
activity: HotwireActivity
|
|
170
170
|
): Router.Decision {
|
|
171
171
|
val to = proposal.location.toUri().getQueryParameter("to")
|
|
172
|
-
EverywhereEvents
|
|
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
|
+
}
|
|
173
184
|
return Router.Decision.CANCEL
|
|
174
185
|
}
|
|
175
186
|
|
|
@@ -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> =
|
|
@@ -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
|
// ------------------------------------------------------------------------
|
|
@@ -162,6 +178,13 @@ class MainActivity : HotwireActivity() {
|
|
|
162
178
|
observeEvents()
|
|
163
179
|
observePathConfiguration()
|
|
164
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
|
+
|
|
165
188
|
// Survives `recreate()` — the Intent does, fields don't. See
|
|
166
189
|
// [rebuildAndRoute].
|
|
167
190
|
intent.getStringExtra(EXTRA_ROUTE)?.let {
|
|
@@ -212,6 +235,13 @@ class MainActivity : HotwireActivity() {
|
|
|
212
235
|
|
|
213
236
|
override fun navigatorConfigurations(): List<NavigatorConfiguration> = pooledConfigurations
|
|
214
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
|
+
|
|
215
245
|
/**
|
|
216
246
|
* A host finished building its start destination. Used to flush a route
|
|
217
247
|
* captured before any navigator existed — at `onCreate` there is nothing to
|
|
@@ -580,16 +610,27 @@ class MainActivity : HotwireActivity() {
|
|
|
580
610
|
lifecycleScope.launch {
|
|
581
611
|
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
|
582
612
|
Hotwire.config.pathConfiguration.loadState.collect { state ->
|
|
583
|
-
if (state is PathConfigurationLoadState.Loaded)
|
|
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
|
+
}
|
|
584
622
|
}
|
|
585
623
|
}
|
|
586
624
|
}
|
|
587
625
|
}
|
|
588
626
|
|
|
589
|
-
private fun applyPathConfiguration() {
|
|
627
|
+
private fun applyPathConfiguration(fresh: Boolean = false) {
|
|
590
628
|
val entries = MainTabs.entries()
|
|
591
629
|
val fingerprint = MainTabs.fingerprint(entries)
|
|
592
|
-
if (fingerprint == loadedFingerprint)
|
|
630
|
+
if (fingerprint == loadedFingerprint) {
|
|
631
|
+
if (fresh) settleRebuildChain()
|
|
632
|
+
return
|
|
633
|
+
}
|
|
593
634
|
|
|
594
635
|
val arrangement = MainTabs.arrange(entries, moreTitle)
|
|
595
636
|
|
|
@@ -623,14 +664,38 @@ class MainActivity : HotwireActivity() {
|
|
|
623
664
|
return
|
|
624
665
|
}
|
|
625
666
|
|
|
626
|
-
|
|
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")
|
|
627
680
|
intent.putExtra(EXTRA_LAST_REBUILD, now)
|
|
628
|
-
|
|
681
|
+
rebuildAndRoute(target)
|
|
629
682
|
return
|
|
630
683
|
}
|
|
631
684
|
|
|
632
685
|
loadedFingerprint = fingerprint
|
|
633
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)
|
|
634
699
|
}
|
|
635
700
|
|
|
636
701
|
/**
|
|
@@ -773,28 +838,29 @@ class MainActivity : HotwireActivity() {
|
|
|
773
838
|
|
|
774
839
|
/**
|
|
775
840
|
* Full app reset for an auth change. Deterministic and self-contained — it
|
|
776
|
-
* does NOT depend on the reset page's JavaScript running:
|
|
777
|
-
*
|
|
778
|
-
*
|
|
779
|
-
*
|
|
780
|
-
*
|
|
781
|
-
* 3. reset every live navigator — new Session, new WebView, backstack
|
|
782
|
-
* cleared — and route the current one at `target`,
|
|
783
|
-
* 4. refetch the auth-aware config, which shows or hides the auth-gated
|
|
784
|
-
* 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.
|
|
785
846
|
*/
|
|
786
847
|
private fun resetApp(target: String) {
|
|
787
|
-
AuthFlow.cancel()
|
|
788
848
|
awaitingAuthHandoff = false
|
|
789
|
-
clearWebContentCache()
|
|
790
|
-
|
|
791
|
-
delegate.resetNavigators()
|
|
792
|
-
|
|
793
|
-
// resetNavigators rebuilds each host's graph; routing has to wait for
|
|
794
|
-
// that to land or the visit is swallowed by the rebuild.
|
|
795
|
-
bottomNav.post { route(target) }
|
|
796
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.
|
|
797
862
|
reloadPathConfiguration()
|
|
863
|
+
rebuildAndRoute(target)
|
|
798
864
|
}
|
|
799
865
|
|
|
800
866
|
/**
|
|
@@ -838,10 +904,39 @@ class MainActivity : HotwireActivity() {
|
|
|
838
904
|
private fun rebuildAndRoute(target: String) {
|
|
839
905
|
AuthFlow.cancel()
|
|
840
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
|
|
841
919
|
intent.putExtra(EXTRA_ROUTE, target)
|
|
920
|
+
intent.putExtra(EXTRA_RESET_TARGET, target)
|
|
921
|
+
if (rebuildScheduled) return
|
|
922
|
+
rebuildScheduled = true
|
|
842
923
|
recreate()
|
|
843
924
|
}
|
|
844
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
|
+
|
|
845
940
|
/**
|
|
846
941
|
* Drop web storage so a page isn't served from its pre-auth state. Cookies
|
|
847
942
|
* are left intact — the server has already invalidated the session on
|
|
@@ -1008,6 +1103,7 @@ class MainActivity : HotwireActivity() {
|
|
|
1008
1103
|
const val TAG = "Everywhere"
|
|
1009
1104
|
const val STATE_SELECTED_TAB = "everywhere.selectedTab"
|
|
1010
1105
|
const val EXTRA_ROUTE = "everywhere.route"
|
|
1106
|
+
const val EXTRA_RESET_TARGET = "everywhere.resetTarget"
|
|
1011
1107
|
const val EXTRA_LAST_REBUILD = "everywhere.lastRebuild"
|
|
1012
1108
|
|
|
1013
1109
|
/** The injected leave tab's path — same constant iOS keeps on
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
package com.rubyeverywhere.shell
|
|
2
2
|
|
|
3
|
+
import android.os.Bundle
|
|
4
|
+
import android.view.Menu
|
|
5
|
+
import android.view.MenuItem
|
|
3
6
|
import android.view.View
|
|
4
7
|
import android.widget.TextView
|
|
8
|
+
import androidx.core.graphics.drawable.DrawableCompat
|
|
5
9
|
import androidx.core.view.isVisible
|
|
6
10
|
import dev.hotwire.core.turbo.errors.VisitError
|
|
7
11
|
import dev.hotwire.navigation.destinations.HotwireDestinationDeepLink
|
|
@@ -20,6 +24,54 @@ import dev.hotwire.navigation.fragments.HotwireWebFragment
|
|
|
20
24
|
@HotwireDestinationDeepLink(uri = "hotwire://fragment/web")
|
|
21
25
|
open class WebFragment : HotwireWebFragment() {
|
|
22
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
|
+
|
|
23
75
|
override fun onVisitRequestFinished(location: String) {
|
|
24
76
|
super.onVisitRequestFinished(location)
|
|
25
77
|
settled()
|
|
@@ -60,4 +112,9 @@ open class WebFragment : HotwireWebFragment() {
|
|
|
60
112
|
private fun settled() {
|
|
61
113
|
(activity as? MainActivity)?.onWebContentSettled()
|
|
62
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
|
+
}
|
|
63
120
|
}
|
|
@@ -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 {
|
|
@@ -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
|
-
|
|
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) {
|
|
@@ -663,3 +666,60 @@ struct MissingExtensionScreen: View {
|
|
|
663
666
|
.navigationTitle("Preview")
|
|
664
667
|
}
|
|
665
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
|
+
}
|