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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 97a5e572306841f37118d70c1b5702efc0fc1d48d9377e8ad325e551b7b720b2
4
- data.tar.gz: 9ffbcd6323f81374f94b1237cdf06a13c50dc2c3466479c8fbb0cfdedfa6ddbd
3
+ metadata.gz: d36d2151f58701a6eab470774617b2c81dd99b78caa9829a152017e9840652e9
4
+ data.tar.gz: 1315f5032b754d7a45418604850cd96f9168ddeb25a7faf24df91608df7ede8c
5
5
  SHA512:
6
- metadata.gz: 66071e14c65bec691891622e1133253e77437cf5ce1d1c43d6785e3b70568ad12c9bcd7155d932a2bf27b313779b562082bdf9fb9f38a1c7468ccff1006c04e2
7
- data.tar.gz: '068d02e32db302d5b48e1325e9f789e460ede9e18a15e30b20fee7d2ac22a100858577f7ba2fd138d0a2655eaa5e50532058527921b8a29abd3dc727aeb02339'
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,
@@ -3,7 +3,7 @@
3
3
  require "json"
4
4
 
5
5
  module Everywhere
6
- VERSION = "0.9.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,
@@ -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.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
+ }
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) 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
+ }
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) return
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
- 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")
627
680
  intent.putExtra(EXTRA_LAST_REBUILD, now)
628
- recreate()
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
- * 1. abandon any sign-in still in flight; it is answering a question the
778
- * app no longer has,
779
- * 2. clear cached web content, so no page is served from its pre-auth
780
- * state,
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
- 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) {
@@ -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
+ }
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.9.0
4
+ version: 0.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrea Fomera