ruby_everywhere 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 97a5e572306841f37118d70c1b5702efc0fc1d48d9377e8ad325e551b7b720b2
4
- data.tar.gz: 9ffbcd6323f81374f94b1237cdf06a13c50dc2c3466479c8fbb0cfdedfa6ddbd
3
+ metadata.gz: effe761f70b4c3220e02788d26a8fc49278a52f3e3876c331e3f9f484867592f
4
+ data.tar.gz: 9e589059bea3245ad9540696a24edfb9261736e0d1f30467698aad051c78541e
5
5
  SHA512:
6
- metadata.gz: 66071e14c65bec691891622e1133253e77437cf5ce1d1c43d6785e3b70568ad12c9bcd7155d932a2bf27b313779b562082bdf9fb9f38a1c7468ccff1006c04e2
7
- data.tar.gz: '068d02e32db302d5b48e1325e9f789e460ede9e18a15e30b20fee7d2ac22a100858577f7ba2fd138d0a2655eaa5e50532058527921b8a29abd3dc727aeb02339'
6
+ metadata.gz: ca496e477bd712bc8a8687916f8f9c17e1a3736d944f245bd633812a479d7771e1457107dd3e7135a2536657e70c4a5aecebe6a5c3df5ee5d12428fe70b423ba
7
+ data.tar.gz: 28e6a3a1c4a6c6c90a455ad545354a1d6812ea3bd1c3ef57a6a1d252c7e7ddf30d5edf7375a7b99e53dd15e6e059c70b11c55ef7070f2df90906b9ca2b13f36b
@@ -1,18 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "dry/cli"
4
+ require "fileutils"
4
5
  require "socket"
5
6
  require "securerandom"
6
7
  require "shellwords"
7
8
  require "timeout"
8
9
  require "uri"
10
+ require "yaml"
9
11
  require_relative "../shellout"
10
12
  require_relative "../console"
11
13
  require_relative "../child_processes"
12
14
  require_relative "../child_supervision"
15
+ require_relative "../dock/info"
13
16
  require_relative "../framework"
14
17
  require_relative "../config"
15
18
  require_relative "../jump"
19
+ require_relative "../paths"
20
+ require_relative "../raw_tty"
16
21
 
17
22
  module Everywhere
18
23
  module Commands
@@ -70,12 +75,18 @@ module Everywhere
70
75
 
71
76
  start_server(port)
72
77
  warn_if_loopback_only if lan
78
+ # Everything below this line scrolls: the dev server's log starts
79
+ # flowing the moment a device connects, and the invitation was gone off
80
+ # the top of the screen with it. The dock pins it instead.
81
+ @dock = Dock::Info.open(keys: $stdin.tty?)
73
82
  announce
74
83
  wait
75
84
  rescue Interrupt
76
85
  nil
77
86
  ensure
78
- teardown_children
87
+ # The dock has to let go of the terminal before anything else prints —
88
+ # teardown_children's own warnings included.
89
+ teardown_children(before: [-> { @dock&.close }])
79
90
  end
80
91
 
81
92
  private
@@ -115,6 +126,7 @@ module Everywhere
115
126
  def start_tunnel
116
127
  if @tunnel_name
117
128
  UI.step("starting named tunnel #{UI.bold(@tunnel_name)} → #{UI.cyan("https://#{@tunnel_host}")}")
129
+ warn_if_ingress_mismatch
118
130
  spawn_tunnel("cloudflared tunnel run --url http://127.0.0.1:#{@port} #{@tunnel_name.shellescape}")
119
131
  "https://#{@tunnel_host}"
120
132
  else
@@ -128,7 +140,7 @@ module Everywhere
128
140
  # and the on_line hook fishes out the URL.
129
141
  def quick_tunnel_url
130
142
  found = Queue.new
131
- spawn_tunnel("cloudflared tunnel --url http://127.0.0.1:#{@port}",
143
+ spawn_tunnel("cloudflared --config #{blank_config.shellescape} tunnel --url http://127.0.0.1:#{@port}",
132
144
  on_line: ->(line) { (url = line[TRYCLOUDFLARE_URL]) && found << url },
133
145
  quiet: true)
134
146
  begin
@@ -139,6 +151,62 @@ module Everywhere
139
151
  end
140
152
  end
141
153
 
154
+ # Where cloudflared looks for a local config, in its own search order.
155
+ CLOUDFLARED_CONFIGS = ["~/.cloudflared/config.yml", "~/.cloudflared/config.yaml",
156
+ "/etc/cloudflared/config.yml", "/usr/local/etc/cloudflared/config.yml"].freeze
157
+
158
+ # A named tunnel keeps the user's cloudflared config on purpose — its
159
+ # credentials live there — but when that config declares ingress rules
160
+ # they beat our --url: cloudflared routes by Host header. A host with no
161
+ # matching rule falls to the catch-all (conventionally http_status:404);
162
+ # a rule aimed at another port reaches the wrong server. Both look like
163
+ # a dead preview with no hint of why, so say so before it happens.
164
+ # Best-effort: a config we can't read just means cloudflared honors --url.
165
+ def warn_if_ingress_mismatch
166
+ rules = cloudflared_ingress
167
+ return if rules.nil? || rules.empty?
168
+
169
+ rule = rules.find { |r| r["hostname"].nil? || host_matches?(r["hostname"], @tunnel_host) }
170
+ service = rule&.fetch("service", nil).to_s
171
+ if rule.nil? || service.start_with?("http_status:")
172
+ UI.warn("your cloudflared config's ingress has no rule for #{@tunnel_host}, so requests will get " \
173
+ "the catch-all#{" (#{service})" unless service.empty?} — add " \
174
+ "{hostname: #{@tunnel_host}, service: http://localhost:#{@port}} above it")
175
+ elsif (svc_port = service[/:(\d+)\z/, 1]) && Integer(svc_port) != @port
176
+ UI.warn("your cloudflared config's ingress routes #{@tunnel_host} to #{service}, but this preview " \
177
+ "runs on port #{@port} — point that rule at the preview, or match it with --port #{svc_port}")
178
+ end
179
+ end
180
+
181
+ def cloudflared_ingress(paths: CLOUDFLARED_CONFIGS)
182
+ path = paths.map { |p| File.expand_path(p) }.find { |p| File.file?(p) }
183
+ return nil unless path
184
+
185
+ config = YAML.safe_load(File.read(path))
186
+ rules = config.is_a?(Hash) ? config["ingress"] : nil
187
+ rules.is_a?(Array) ? rules.grep(Hash) : nil
188
+ rescue StandardError
189
+ nil
190
+ end
191
+
192
+ # cloudflared ingress hostnames may hold a * wildcard; fnmatch covers it.
193
+ def host_matches?(pattern, host)
194
+ File.fnmatch?(pattern.to_s, host.to_s, File::FNM_CASEFOLD)
195
+ end
196
+
197
+ # cloudflared reads ~/.cloudflared/config.yml even in quick-tunnel mode,
198
+ # and config ingress rules win over --url: a leftover named-tunnel config
199
+ # sees a *.trycloudflare.com Host, matches nothing, and its catch-all
200
+ # (conventionally http_status:404) answers every request. A blank config
201
+ # keeps the quick tunnel self-contained. Named tunnels (--tunnel-name)
202
+ # still read the user's config on purpose — their credentials live there.
203
+ def blank_config
204
+ path = File.join(Paths.cache_dir, "cloudflared-blank.yml")
205
+ FileUtils.mkdir_p(File.dirname(path))
206
+ File.write(path, "")
207
+ path
208
+ end
209
+
142
210
  # quiet drops the child's routine chatter but always lets errors
143
211
  # through — a quick tunnel that can't connect must say why.
144
212
  def spawn_tunnel(cmd, on_line: nil, quiet: false)
@@ -205,13 +273,26 @@ module Everywhere
205
273
  end
206
274
 
207
275
  def announce
208
- link = share_link || connect_url
276
+ @link = share_link || connect_url
209
277
  UI.step("preview of #{UI.bold(@config.name)} is live → #{UI.cyan(@url)}")
210
- Console.print(qr_ansi(link)) if $stdout.tty?
211
- Console.print(" Scan with your phone's camera, or send:\n\n #{UI.cyan(link)}\n\n")
278
+ show_qr
212
279
  Console.print(" #{UI.dim("Manual entry in Jump:")} #{@url} #{UI.dim("token:")} #{@token}\n")
213
280
  Console.print(UI.dim(" The link and QR die with this session — Ctrl-C to stop.\n"))
214
281
  UI.note("waiting for devices…", marker: "⌁", color: :cyan)
282
+ @dock.show(footer_facts)
283
+ end
284
+
285
+ # What stays on screen. The QR is ~17 rows — far too tall to pin — so the
286
+ # footer carries what a person can act on without it: the link to forward,
287
+ # and the URL and token for Jump's manual entry form.
288
+ def footer_facts = [["link", @link], ["url", @url], ["token", @token]]
289
+
290
+ # Printed at announce time and again on `s`, once the logs have scrolled
291
+ # the code away. A non-tty stdout gets the link alone — an ANSI QR down a
292
+ # pipe is noise.
293
+ def show_qr
294
+ Console.print(qr_ansi(@link)) if $stdout.tty?
295
+ Console.print(" Scan with your phone's camera, or send:\n\n #{UI.cyan(@link)}\n\n")
215
296
  end
216
297
 
217
298
  # Half-block rendering: ▀ painted fg-for-top-module, bg-for-bottom packs
@@ -246,14 +327,23 @@ module Everywhere
246
327
 
247
328
  # --- the wait loop --------------------------------------------------------
248
329
 
330
+ # RawTty only when the dock is offering keys: it puts the terminal in
331
+ # -icanon -echo for the whole process, which a piped or CI run has no use
332
+ # for — and its ensure is what puts the terminal back, so the watch loop
333
+ # has to run inside it rather than beside it.
334
+ def wait
335
+ @dock.keys? ? RawTty.with { watch } : watch
336
+ end
337
+
249
338
  # The server's death ends the session; the tunnel's doesn't. Quick
250
339
  # tunnels are explicitly best-effort — Cloudflare recycles them — so a
251
340
  # dead tunnel is restarted and the fresh URL re-announced. (The token
252
341
  # survives, but the origin changed, so recents/short links need the new
253
- # invitation.)
254
- def wait
342
+ # invitation — including the copy pinned in the footer.)
343
+ def watch
255
344
  loop do
256
- sleep 1
345
+ break if pause == :quit
346
+
257
347
  web = @children.synchronize { @pids[:web] }
258
348
  UI.die!("the dev server exited — scroll up for its output") unless ChildProcesses.alive?(web)
259
349
 
@@ -267,7 +357,45 @@ module Everywhere
267
357
  end
268
358
  end
269
359
 
270
- # --- child supervision (dev.rb's pattern, without the dock) ---------------
360
+ # A second between checks, spent waiting on a keystroke when there is a
361
+ # terminal to take one from — so a key is acted on the moment it's pressed
362
+ # rather than up to a second later, and the loop keeps its one pacing
363
+ # point either way.
364
+ def pause
365
+ return sleep(1) unless @dock.keys?
366
+ return unless IO.select([$stdin], nil, nil, 1)
367
+
368
+ case $stdin.getc
369
+ when "s" then show_qr
370
+ when "r" then restart_server
371
+ when "q", "\u0004", nil then :quit # q, Ctrl-D, EOF (Ctrl-C stays a real SIGINT)
372
+ end
373
+ end
374
+
375
+ # Replace just the dev server, keeping the tunnel and the pairing token —
376
+ # how a gem update or an everywhere.yml change gets picked up without
377
+ # invalidating the invitation everyone already scanned. start_server
378
+ # rebuilds the env, so the same @token rides back in; connected devices
379
+ # see a blip, not a disconnect. The config is reloaded so a renamed app
380
+ # announces under its new name if the tunnel later restarts.
381
+ def restart_server
382
+ UI.step("restarting the dev server #{UI.dim("(the tunnel, link and token stay)")}")
383
+ stop_child(:web)
384
+ wait_for_port_close
385
+ @config = Everywhere::Config.load(@framework.root)
386
+ start_server(@port.to_s)
387
+ UI.note("server is back — same URL, same token", marker: "⌁", color: :cyan)
388
+ end
389
+
390
+ # stop_child has reaped the process, but the kernel can hold its listener
391
+ # a beat longer; start_server's already-running guard would read that as
392
+ # a stranger's server and kill the whole session.
393
+ def wait_for_port_close(timeout: 10)
394
+ deadline = Clock.monotonic + timeout
395
+ sleep 0.1 while port_open?(@port) && Clock.monotonic < deadline
396
+ end
397
+
398
+ # --- child supervision (dev.rb's pattern, its own no-pty fallback) --------
271
399
 
272
400
  def spawn_child(env, cmd, chdir:)
273
401
  return Shellout.spawn_pty(env, cmd, chdir: chdir) if Shellout.pty?
@@ -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,
@@ -95,6 +95,49 @@ module Everywhere
95
95
 
96
96
  def plain_width(segments) = segments.sum { |s| plain(s).length } + (GAP.length * [segments.size - 1, 0].max)
97
97
 
98
+ # --- info rows -----------------------------------------------------------
99
+
100
+ # `every preview`'s footer: labelled facts (the share link, the tunnel
101
+ # URL, the pairing token) instead of target states. items: [[label,
102
+ # value], …].
103
+ #
104
+ # Packed across as many rows as it takes rather than truncated to one,
105
+ # which is the one place this parts ways with status_row: these values
106
+ # exist to be scanned, read aloud and typed in by hand, and half a URL or
107
+ # a clipped token is worse than useless.
108
+ def info(items, cols:, keys: nil, rule: true)
109
+ cols = [cols.to_i, 1].max
110
+ lines = []
111
+ lines << UI.gray("─" * cols) if rule
112
+ lines.concat(pack(items.map { |label, value| info_pair(label, value) }, cols))
113
+ lines << keys_row(keys, cols) if keys
114
+ lines
115
+ end
116
+
117
+ # Greedy: keep adding facts to the current row while they fit, start a new
118
+ # row when they don't. A single fact wider than the terminal is the only
119
+ # thing that ever gets cut (by fit).
120
+ def pack(pairs, cols)
121
+ rows = []
122
+ row = []
123
+ pairs.each do |pair|
124
+ if row.any? && row_width(row + [pair]) > cols
125
+ rows << fit(row, cols)
126
+ row = [pair]
127
+ else
128
+ row << pair
129
+ end
130
+ end
131
+ rows << fit(row, cols) if row.any?
132
+ rows
133
+ end
134
+
135
+ def info_pair(label, value)
136
+ ["#{label} #{value}", "#{UI.dim(label)} #{UI.cyan(value)}"]
137
+ end
138
+
139
+ def row_width(pairs) = pairs.sum { |plain, _| plain.length } + (GAP.length * [pairs.size - 1, 0].max)
140
+
98
141
  # --- keys row ------------------------------------------------------------
99
142
 
100
143
  # Named keys with dot separators, then named keys packed tighter, then
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../dock"
4
+
5
+ module Everywhere
6
+ class Dock
7
+ # The pinned footer for `every preview`.
8
+ #
9
+ # Same machinery as the dev dock — it owns the terminal through Console and
10
+ # repaints around every write (see Dock::Screen) — but what it pins is a
11
+ # handful of facts rather than a set of running targets: the share link, the
12
+ # URL to type in by hand and the pairing token. Preview prints those once
13
+ # and then relays the dev server's log, which used to bury them within
14
+ # seconds; the whole point of the command is that someone can still see how
15
+ # to connect five minutes in.
16
+ #
17
+ # It carries no state of its own to spin, so the base class's ticker only
18
+ # ever redraws it on a resize.
19
+ class Info < Dock
20
+ KEYS = [
21
+ ["s", "show the QR"],
22
+ ["r", "restart server"],
23
+ ["q", "quit"]
24
+ ].freeze
25
+
26
+ def self.open(items = [], io: $stdout, keys: true, size: nil)
27
+ return Null.new unless drawable?(io, size)
28
+
29
+ new(items, io: io, keys: keys, size: size).tap(&:install)
30
+ end
31
+
32
+ def initialize(items = [], io: $stdout, keys: true, size: nil)
33
+ super([], io: io, keys: keys, size: size)
34
+ @items = items
35
+ end
36
+
37
+ # Whether the keymap in the footer is a promise we can keep — the caller
38
+ # owns the key loop, so it has to know whether to read stdin at all.
39
+ def keys? = @keys
40
+
41
+ # Replace the facts and repaint now. A quick tunnel that dropped comes
42
+ # back on a different hostname, so the footer must not outlive its URL.
43
+ def show(items)
44
+ @items = items
45
+ @dirty = true
46
+ refresh
47
+ end
48
+
49
+ private
50
+
51
+ def footer_rows
52
+ Footer.info(@items,
53
+ cols: [@screen.cols - 1, 1].max,
54
+ keys: (@keys ? KEYS : nil),
55
+ rule: @screen.rows >= 24)
56
+ end
57
+
58
+ # The headless stand-in, as with Dock::Null: no escape sequences, and
59
+ # never installed as Console's sink, so a piped or CI run prints exactly
60
+ # what it printed before the footer existed.
61
+ class Null
62
+ def install = self
63
+ def keys? = false
64
+ def show(_items) = nil
65
+ def emit(_text) = nil
66
+ def refresh = nil
67
+ def clear = nil
68
+ def close = nil
69
+ end
70
+ end
71
+ end
72
+ end
@@ -3,7 +3,7 @@
3
3
  require "json"
4
4
 
5
5
  module Everywhere
6
- VERSION = "0.9.0"
6
+ VERSION = "0.10.0"
7
7
 
8
8
  # Version of the @rubyeverywhere/bridge JS this gem ships. bridge/ in the
9
9
  # gem IS the npm package (served to Rails apps by Everywhere::Engine,
@@ -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.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrea Fomera
@@ -156,6 +156,7 @@ files:
156
156
  - lib/everywhere/desktop_dev_app.rb
157
157
  - lib/everywhere/dock.rb
158
158
  - lib/everywhere/dock/footer.rb
159
+ - lib/everywhere/dock/info.rb
159
160
  - lib/everywhere/dock/screen.rb
160
161
  - lib/everywhere/dock/state.rb
161
162
  - lib/everywhere/emulator.rb