ruby_everywhere 0.4.0 → 0.6.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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +99 -3
  3. data/bridge/README.md +70 -1
  4. data/bridge/everywhere/bridge.js +151 -0
  5. data/bridge/everywhere/native.css +61 -0
  6. data/lib/everywhere/agents_guide.rb +57 -0
  7. data/lib/everywhere/auth_handoff.rb +8 -2
  8. data/lib/everywhere/builders/desktop.rb +326 -0
  9. data/lib/everywhere/child_processes.rb +74 -0
  10. data/lib/everywhere/commands/build.rb +44 -5
  11. data/lib/everywhere/commands/dev.rb +426 -59
  12. data/lib/everywhere/commands/install.rb +93 -1
  13. data/lib/everywhere/commands/release.rb +2 -2
  14. data/lib/everywhere/config.rb +298 -4
  15. data/lib/everywhere/console.rb +117 -0
  16. data/lib/everywhere/desktop_assets.rb +150 -0
  17. data/lib/everywhere/dock/footer.rb +150 -0
  18. data/lib/everywhere/dock/screen.rb +114 -0
  19. data/lib/everywhere/dock/state.rb +59 -0
  20. data/lib/everywhere/dock.rb +238 -0
  21. data/lib/everywhere/emulator.rb +2 -2
  22. data/lib/everywhere/fatal.rb +20 -0
  23. data/lib/everywhere/line_pump.rb +89 -0
  24. data/lib/everywhere/log_filter.rb +37 -0
  25. data/lib/everywhere/native_helper.rb +38 -5
  26. data/lib/everywhere/paths.rb +62 -11
  27. data/lib/everywhere/relay.rb +77 -0
  28. data/lib/everywhere/shellout.rb +90 -15
  29. data/lib/everywhere/task_pool.rb +123 -0
  30. data/lib/everywhere/ui.rb +54 -11
  31. data/lib/everywhere/version.rb +1 -1
  32. data/support/agents/AGENTS.md +90 -0
  33. data/support/agents/frameworks/hanami-views.md +26 -0
  34. data/support/agents/frameworks/hanami.md +10 -0
  35. data/support/agents/frameworks/rails-views.md +98 -0
  36. data/support/agents/frameworks/rails.md +16 -0
  37. data/support/agents/frameworks/sinatra-views.md +24 -0
  38. data/support/agents/frameworks/sinatra.md +9 -0
  39. data/support/agents/modes/local.md +12 -0
  40. data/support/agents/modes/remote.md +14 -0
  41. data/support/desktop/README.md +121 -0
  42. data/support/{shell → desktop}/src-tauri/Cargo.lock +23 -0
  43. data/support/{shell → desktop}/src-tauri/Cargo.toml +19 -1
  44. data/support/{shell → desktop}/src-tauri/capabilities/default.json +7 -0
  45. data/support/desktop/src-tauri/gen/schemas/capabilities.json +1 -0
  46. data/support/desktop/src-tauri/src/extension_host.rs +53 -0
  47. data/support/desktop/src-tauri/src/extensions/mod.rs +39 -0
  48. data/support/{shell → desktop}/src-tauri/src/main.rs +355 -10
  49. data/support/{shell → desktop}/src-tauri/tauri.conf.json +2 -2
  50. data/support/{macos → release/macos}/notarize.sh +2 -2
  51. metadata +55 -22
  52. data/support/github/build.yml +0 -85
  53. data/support/shell/src-tauri/gen/schemas/capabilities.json +0 -1
  54. /data/support/{shell → desktop}/splash/index.html +0 -0
  55. /data/support/{shell → desktop}/src-tauri/build.rs +0 -0
  56. /data/support/{shell → desktop}/src-tauri/gen/schemas/acl-manifests.json +0 -0
  57. /data/support/{shell → desktop}/src-tauri/gen/schemas/desktop-schema.json +0 -0
  58. /data/support/{shell → desktop}/src-tauri/gen/schemas/macOS-schema.json +0 -0
  59. /data/support/{shell → desktop}/src-tauri/icons/icon.png +0 -0
  60. /data/support/{shell → desktop}/src-tauri/src/updater.rs +0 -0
  61. /data/support/{macos → release/macos}/entitlements.plist +0 -0
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ui"
4
+ require_relative "console"
5
+ require_relative "dock/state"
6
+ require_relative "dock/footer"
7
+ require_relative "dock/screen"
8
+
9
+ module Everywhere
10
+ # The pinned status bar at the bottom of `every dev`.
11
+ #
12
+ # The old key menu was printed once, before the loop, and was gone from the
13
+ # screen within seconds of the Rails server starting to log. The dock keeps
14
+ # the keymap — and what each target is actually doing — permanently visible,
15
+ # while the logs scroll normally above it.
16
+ #
17
+ # It installs itself as Console's sink, so every write in the process (CLI
18
+ # chrome, relayed server output, filtered build output) is framed by an erase
19
+ # and a repaint. See Dock::Screen for why it repaints rather than reserving
20
+ # rows with a scroll region.
21
+ class Dock
22
+ TICK = 0.2
23
+
24
+ KEYS = [
25
+ ["d", "desktop"],
26
+ ["i", "iOS"],
27
+ ["a", "Android"],
28
+ ["b", "browser"],
29
+ ["l", "logs"],
30
+ ["r", "restart"],
31
+ ["?", "help"],
32
+ ["q", "quit"]
33
+ ].freeze
34
+
35
+ HELP = [
36
+ ["d", "relaunch the desktop shell (opens it if it isn't running)"],
37
+ ["i", "rebuild, reinstall and relaunch on the iOS Simulator"],
38
+ ["a", "rebuild, reinstall and relaunch on the Android emulator"],
39
+ ["b", "open the app in your default browser"],
40
+ ["l", "start/stop streaming iOS Simulator logs"],
41
+ ["r", "restart the dev server only"],
42
+ ["?", "hide this help"],
43
+ ["q", "quit (Ctrl-C works too)"]
44
+ ].freeze
45
+
46
+ # A dock needs room to be worth having, and a terminal that understands
47
+ # cursor movement. NO_COLOR is deliberately NOT a disqualifier: it governs
48
+ # SGR, not cursor control, and the footer's glyphs carry the meaning on
49
+ # their own.
50
+ MIN_ROWS = 12
51
+ MIN_COLS = 40
52
+
53
+ def self.open(targets, io: $stdout, keys: true, size: nil)
54
+ return Null.new(targets, keys: keys) unless drawable?(io, size)
55
+
56
+ new(targets, io: io, keys: keys, size: size).tap(&:install)
57
+ end
58
+
59
+ def self.drawable?(io, size)
60
+ return false if ENV["EVERY_NO_DOCK"]
61
+ return false unless io.respond_to?(:tty?) && io.tty?
62
+ return false if ENV["TERM"].to_s.empty? || ENV["TERM"] == "dumb"
63
+
64
+ rows, cols = size || Screen.new(io: io).size
65
+ rows >= MIN_ROWS && cols >= MIN_COLS
66
+ end
67
+
68
+ def initialize(targets, io: $stdout, keys: true, size: nil)
69
+ @state = State.new(targets)
70
+ @screen = Screen.new(io: io, size: size)
71
+ @keys = keys
72
+ @help = false
73
+ @frame = 0
74
+ @dirty = true
75
+ @open = false
76
+ end
77
+
78
+ attr_reader :state
79
+
80
+ def install
81
+ @open = true
82
+ @screen.open
83
+ Console.dock = self
84
+ trap_resize
85
+ start_ticker
86
+ self
87
+ end
88
+
89
+ # --- state --------------------------------------------------------------
90
+
91
+ def set(key, state, detail: nil)
92
+ @state.set(key, state, detail: detail)
93
+ @dirty = true
94
+ refresh
95
+ end
96
+
97
+ def detail(key, text)
98
+ @state.detail(key, text)
99
+ @dirty = true
100
+ end
101
+
102
+ def toggle_help
103
+ @help = !@help
104
+ @dirty = true
105
+ refresh
106
+ end
107
+
108
+ # The dock draws the keymap permanently, so there is nothing to announce.
109
+ # Null overrides this to print the one-shot hint the old command used.
110
+ def announce_keys = nil
111
+
112
+ # --- drawing ------------------------------------------------------------
113
+
114
+ # Called by Console, which already holds the lock. Content must end at
115
+ # column 1, or the erase math for the next frame is off by a row.
116
+ def emit(text)
117
+ body = text.end_with?("\n") ? text : "#{text}\n"
118
+ @screen.frame(body, footer_rows)
119
+ @dirty = false
120
+ end
121
+
122
+ def refresh
123
+ Console.synchronize do
124
+ next unless @open
125
+
126
+ @screen.frame("", footer_rows)
127
+ @dirty = false
128
+ end
129
+ end
130
+
131
+ # Wipe the footer and leave it wiped — for stderr, or an interactive child
132
+ # that needs the screen to itself.
133
+ def clear = @screen.clear
134
+
135
+ def close
136
+ return unless @open
137
+
138
+ # Order matters: drop the flag so the ticker stops drawing, let it finish
139
+ # the frame it may be mid-way through, and only then unhook and restore.
140
+ @open = false
141
+ @ticker&.join(TICK * 2)
142
+ @ticker&.kill
143
+ Console.dock = nil
144
+ Console.synchronize { @screen.close }
145
+ end
146
+
147
+ private
148
+
149
+ # One column short of the terminal on purpose. A row filled to the last
150
+ # column leaves the cursor in a pending-wrap state whose handling varies
151
+ # between terminals, and a footer row that wraps costs the erase math a row.
152
+ def footer_rows
153
+ lines = Footer.render(@state.targets,
154
+ cols: [@screen.cols - 1, 1].max,
155
+ keys: (@keys ? KEYS : nil),
156
+ frame: @frame,
157
+ elapsed: ->(target) { @state.elapsed(target) },
158
+ rule: @screen.rows >= 24)
159
+ @help ? lines + help_rows : lines
160
+ end
161
+
162
+ def help_rows
163
+ width = HELP.map { |key, _| key.length }.max
164
+ HELP.map { |key, desc| " #{UI.bold(key.ljust(width))} #{UI.dim(desc)}" }
165
+ end
166
+
167
+ # An idle dev session should emit zero bytes per second, so only redraw when
168
+ # something changed or a spinner is actually turning.
169
+ def start_ticker
170
+ @ticker = Thread.new do
171
+ Thread.current.name = "dock"
172
+ Thread.current.report_on_exception = false
173
+ while @open
174
+ sleep TICK
175
+ next unless @open
176
+
177
+ # Screen#size re-reads the ioctl every frame, so a resize needs
178
+ # nothing more than a redraw.
179
+ if @resized
180
+ @resized = false
181
+ @dirty = true
182
+ end
183
+ busy = @state.busy?
184
+ @frame += 1 if busy
185
+ refresh if busy || @dirty
186
+ end
187
+ rescue StandardError
188
+ # A dock that can't draw must never be the reason a dev session dies.
189
+ @open = false
190
+ Console.dock = nil
191
+ end
192
+ end
193
+
194
+ # Trap handlers must not take a lock (Ruby raises ThreadError when the lock
195
+ # is held in trap context), so this only sets a flag; the ticker draws.
196
+ def trap_resize
197
+ Signal.trap("WINCH") { @resized = true }
198
+ rescue ArgumentError, StandardError
199
+ nil
200
+ end
201
+
202
+ # The headless stand-in: same API, no escape sequences ever. Used for CI,
203
+ # pipes, `| tee`, dumb terminals and windows too small to spare the rows.
204
+ # It narrates transitions as ordinary lines so a piped log still reads.
205
+ #
206
+ # Note it never installs itself as Console's sink — output keeps going
207
+ # straight to $stdout, exactly as it did before the dock existed.
208
+ class Null
209
+ def initialize(targets, keys: true)
210
+ @state = State.new(targets)
211
+ @keys = keys
212
+ end
213
+
214
+ attr_reader :state
215
+
216
+ def install = self
217
+
218
+ def set(key, state, detail: nil)
219
+ target = @state.set(key, state, detail: detail) or return
220
+ UI.step("#{target.label} → #{[state, detail].compact.join(" ")}")
221
+ end
222
+
223
+ def detail(_key, _text) = nil
224
+ def toggle_help = nil
225
+ def emit(text) = nil
226
+ def refresh = nil
227
+ def clear = nil
228
+ def close = nil
229
+
230
+ # The one-shot keymap the old command printed, kept for headless runs.
231
+ def announce_keys
232
+ return unless @keys
233
+
234
+ UI.note(KEYS.map { |key, desc| "#{UI.bold(key)} #{desc}" }.join(" · "), marker: "⌨", color: :cyan)
235
+ end
236
+ end
237
+ end
238
+ end
@@ -372,7 +372,7 @@ module Everywhere
372
372
  # go to /dev/null: they would otherwise land in the middle of the dev
373
373
  # server's log, and nothing here reads them.
374
374
  def spawn_emulator(argv)
375
- pid = Shellout.unbundled { Process.spawn(*argv, pgroup: true, out: File::NULL, err: File::NULL) }
375
+ pid = Process.spawn(Shellout.child_env, *argv, pgroup: true, out: File::NULL, err: File::NULL)
376
376
  Process.detach(pid)
377
377
  pid
378
378
  end
@@ -384,7 +384,7 @@ module Everywhere
384
384
  # Waiting on the child ourselves is the only way to put a ceiling on it, so
385
385
  # the "never came up" path reaches a message a human can act on.
386
386
  def bounded_wait(argv, timeout: BOOT_TIMEOUT)
387
- pid = Shellout.unbundled { Process.spawn(*argv, out: File::NULL, err: File::NULL) }
387
+ pid = Process.spawn(Shellout.child_env, *argv, out: File::NULL, err: File::NULL)
388
388
  deadline = Time.now + timeout
389
389
  loop do
390
390
  return true if Process.waitpid(pid, Process::WNOHANG)
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ # A failure that ends the current unit of work — not necessarily the process.
5
+ #
6
+ # It subclasses SystemExit deliberately. At the top level Ruby exits with
7
+ # #status and prints nothing (UI.die! has already written the ✗ line), so an
8
+ # uncaught Fatal behaves exactly like the Kernel#abort it replaces. Inside
9
+ # `every dev` a worker thread catches it instead and returns to the key menu,
10
+ # which is the whole point: a Swift typo must not take the Rails server down
11
+ # with it.
12
+ #
13
+ # CAUTION for anyone catching this in a thread: MRI enqueues a SystemExit that
14
+ # escapes a non-main thread onto the MAIN thread, which re-raises it wherever
15
+ # that thread happens to be. A worker must therefore `rescue Exception`, not
16
+ # `rescue Everywhere::Fatal` — see TaskPool.
17
+ class Fatal < SystemExit
18
+ def initialize(status = 1, message = nil) = super
19
+ end
20
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ # Turns the raw byte stream off a pty master into whole lines.
5
+ #
6
+ # `every dev` relays the dev server and the desktop shell itself so it can tag
7
+ # each line with its source. That relay has to be line-oriented — a half-line
8
+ # written to the terminal would leave the cursor mid-row and the dock repaints
9
+ # from column 1 — but a pty hands over arbitrary chunks, so this buffers.
10
+ #
11
+ # Two pty-specific quirks it exists to absorb:
12
+ #
13
+ # * ONLCR. The slave's line discipline turns every LF into CRLF, so lines
14
+ # arrive as "text\r\n". The trailing CR is an artifact, not content.
15
+ # * In-place rewrites. cargo (and anything with a progress bar) redraws a
16
+ # row by writing CR and the new text without a newline. What the terminal
17
+ # would have shown is whatever followed the LAST CR, so that is what gets
18
+ # emitted — one settled line instead of thirty flickering ones.
19
+ #
20
+ # Pure: feed it strings, get lines back. No IO, no threads, no clock beyond
21
+ # the idle deadline the caller passes in.
22
+ class LinePump
23
+ # How long a partial line may sit unterminated before it is emitted anyway.
24
+ # Without this a progress bar that never writes a newline — or a prompt like
25
+ # "Overwrite? [Yn]" — would be invisible for as long as it mattered.
26
+ IDLE_FLUSH = 0.2
27
+
28
+ def initialize(idle_flush: IDLE_FLUSH, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
29
+ # Bytes in, text out. A pty hands over BINARY, and a chunk boundary can
30
+ # land in the middle of a multi-byte character, so buffering and splitting
31
+ # happen on bytes — UTF-8 is self-synchronizing, so a newline is never
32
+ # inside a character — and each settled line is transcoded on the way out.
33
+ @buffer = String.new(encoding: Encoding::BINARY)
34
+ @idle_flush = idle_flush
35
+ @clock = clock
36
+ @touched = nil
37
+ end
38
+
39
+ # Feed a chunk; returns the complete lines it produced (without newlines).
40
+ def <<(chunk)
41
+ @buffer << chunk.b
42
+ @touched = @clock.call
43
+ lines = []
44
+ while (index = @buffer.index("\n"))
45
+ raw = @buffer.slice!(0, index + 1)
46
+ line = settle(raw.chomp("\n"))
47
+ lines << line if line
48
+ end
49
+ lines
50
+ end
51
+
52
+ # Emit a still-unterminated partial line once it has gone quiet. Call this
53
+ # from the relay loop whenever a read times out.
54
+ def flush_idle
55
+ return [] if @buffer.empty?
56
+ return [] if @touched && (@clock.call - @touched) < @idle_flush
57
+
58
+ lines = drain
59
+ @touched = @clock.call
60
+ lines
61
+ end
62
+
63
+ # Emit whatever is left, terminated or not. For end-of-stream.
64
+ def drain
65
+ return [] if @buffer.empty?
66
+
67
+ line = settle(@buffer)
68
+ @buffer = +""
69
+ line ? [line] : []
70
+ end
71
+
72
+ def pending? = !@buffer.empty?
73
+
74
+ private
75
+
76
+ # chomp the ONLCR artifact first, THEN collapse an in-place rewrite —
77
+ # order matters. Doing it the other way round reads "bar\r" as a rewrite to
78
+ # the empty string and swallows the line.
79
+ #
80
+ # scrub, not just force_encoding: a build tool can emit a stray byte that is
81
+ # not valid UTF-8, and a String that claims an encoding it doesn't satisfy
82
+ # raises the moment anything tries to concatenate a tag onto it.
83
+ def settle(raw)
84
+ line = raw.chomp("\r")
85
+ line = line[(line.rindex("\r") + 1)..] if line.include?("\r")
86
+ line.dup.force_encoding(Encoding::UTF_8).scrub
87
+ end
88
+ end
89
+ end
@@ -31,6 +31,7 @@ module Everywhere
31
31
  when :tebako then tebako(line)
32
32
  when :xcodebuild then xcodebuild(line)
33
33
  when :gradle then gradle(line)
34
+ when :cargo then cargo(line)
34
35
  end
35
36
  end
36
37
 
@@ -227,6 +228,42 @@ module Everywhere
227
228
  end
228
229
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
229
230
 
231
+ # --- `every dev`'s desktop shell (cargo) ----------------------------------
232
+
233
+ # A cold `cargo run` compiles ~300 crates, one "Compiling foo v0.1.0" line
234
+ # each, plus a progress bar it redraws several times a second. None of that
235
+ # is actionable while you're waiting on your own app — but warnings, errors
236
+ # and the moment the shell actually starts very much are.
237
+ #
238
+ # Unlike xcodebuild and Gradle (which we run with --console=plain), cargo is
239
+ # talking to a pty here and colors everything, so its status lines start
240
+ # with SGR codes and carry OSC-8 hyperlinks. Match on the stripped text;
241
+ # print the original, since that color is cargo's and worth keeping.
242
+ #
243
+ # The fall-through returns the line verbatim rather than nil, so it is NOT
244
+ # dimmed as background detail: once the shell is up, everything arriving on
245
+ # this stream is the app's own output, and the body of a Rust compile error
246
+ # is exactly what you need to read in full.
247
+ def cargo(line)
248
+ plain = UI.strip_ansi(line)
249
+ case plain
250
+ when /\A\s*error(\[E\d+\])?[:\[]/
251
+ " #{UI.red(UI.short_path(plain))}"
252
+ when /\A\s*warning: /
253
+ " #{UI.yellow(UI.short_path(plain))}"
254
+ when /\A\s*Compiling /
255
+ once(:compiling) { UI.note_line("compiling the desktop shell (first run is slow)") }
256
+ when /\A\s*(Downloaded|Downloading|Updating|Building \[|Blocking|Locking)/
257
+ :drop
258
+ when /\A\s*Finished\b/
259
+ UI.note_line("desktop shell compiled", marker: "✓", color: :green)
260
+ when /\A\s*Running\b/
261
+ UI.note_line("desktop shell running", marker: "✓", color: :green)
262
+ else
263
+ line
264
+ end
265
+ end
266
+
230
267
  # Show a line only the first time its key is seen; :drop the repeats. Keeps a
231
268
  # long notarization from scrolling a wall of identical "waiting" lines (and
232
269
  # notarytool's thrice-echoed submission id from showing three times).
@@ -10,25 +10,52 @@ module Everywhere
10
10
  # does on the client:
11
11
  #
12
12
  # <%= link_to "Install the app", … unless native_app? %>
13
- # <div class="<%= "native-inset" if native_app? %>">
13
+ # <div class="<%= "native-inset" if mobile_app? %>">
14
14
  # <% if native_version && native_version >= Gem::Version.new("1.2") %>
15
15
  #
16
16
  # Detection is by User-Agent: the RubyEverywhere shells prepend
17
17
  # "RubyEverywhere/<version> (<os>)" to Hotwire Native's own
18
18
  # "Hotwire Native iOS/Android" marker. Included into all Rails views by
19
19
  # Everywhere::Engine.
20
+ #
21
+ # Pick the narrowest one that's true of what you're branching on. `native_app?`
22
+ # means "not a browser tab" and covers the desktop shell; `mobile_app?` means
23
+ # a phone, with a tab bar and safe-area insets; `desktop_app?` means a window,
24
+ # with a title bar and a menu bar. Safe-area padding is mobile_app?; hiding a
25
+ # web nav in favour of native chrome is usually mobile_app? too — the desktop
26
+ # shell has no native navigation to replace it with.
20
27
  module NativeHelper
21
- # True inside any RubyEverywhere native shell (iOS or Android).
28
+ # True inside any RubyEverywhere native shell iOS, Android or desktop.
29
+ # Mirrors the bridge's `Everywhere.native`.
22
30
  def native_app?
23
31
  !native_platform.nil?
24
32
  end
25
33
 
26
- # :ios, :android, or nil in a plain browser. Reads Hotwire Native's UA
34
+ # :ios, :android, :desktop, or nil in a plain browser. Reads the shell's UA
27
35
  # marker, so it's correct even before the JS bridge has booted.
28
36
  def native_platform
29
37
  Everywhere.native_platform_of(_everywhere_user_agent)
30
38
  end
31
39
 
40
+ # True in the Tauri desktop shell. Use it for window chrome — reserving room
41
+ # for an overlay title bar, say — and for anything that assumes a pointer
42
+ # and a resizable window.
43
+ def desktop_app?
44
+ native_platform == :desktop
45
+ end
46
+
47
+ # True in the iOS or Android shell, and the one to reach for when the
48
+ # affordance is a phone's: safe-area insets, the native tab bar, biometrics,
49
+ # the OAuth handoff. Mirrors the bridge's `Everywhere.platform === "mobile"`.
50
+ def mobile_app?
51
+ !mobile_platform.nil?
52
+ end
53
+
54
+ # :ios, :android, or nil (including in the desktop shell).
55
+ def mobile_platform
56
+ Everywhere.mobile_platform_of(_everywhere_user_agent)
57
+ end
58
+
32
59
  # The shell's version as a Gem::Version (from the "RubyEverywhere/<ver>"
33
60
  # UA prefix), or nil outside the shell / when unparseable. Use it to gate
34
61
  # features that need a newer shell than some users have installed.
@@ -49,8 +76,11 @@ module Everywhere
49
76
  # redirect_to everywhere_auth_redirect(after_authentication_url)
50
77
  #
51
78
  # `to` may be a full URL or a path; only the same-origin path is forwarded.
79
+ # mobile_app?, not native_app?: the reset page exists to rebuild a phone
80
+ # shell's web views and re-fetch its tab bar. The desktop shell has neither,
81
+ # so a plain redirect is both correct and what it already did.
52
82
  def everywhere_auth_redirect(to)
53
- return to unless native_app? && respond_to?(:everywhere_reset_path)
83
+ return to unless mobile_app? && respond_to?(:everywhere_reset_path)
54
84
 
55
85
  path = to.to_s.sub(%r{\Ahttps?://[^/]+}, "")
56
86
  path = "/" if path.empty? || !path.start_with?("/")
@@ -104,7 +134,10 @@ module Everywhere
104
134
  wrapper_class: nil, locked_class: nil,
105
135
  message_class: nil, unlock_class: nil, &block)
106
136
  content = respond_to?(:capture) ? capture(&block) : block.call
107
- return content unless native_app?
137
+ # mobile_app?: there's no Face ID on the desktop shell, so emitting the
138
+ # locked wrapper there would hide the content behind an unlock nothing can
139
+ # satisfy. Browsers and the desktop shell render it plainly.
140
+ return content unless mobile_app?
108
141
 
109
142
  attrs = [%(data-everywhere-biometric-lock="#{_everywhere_attr(name)}")]
110
143
  attrs << %(data-everywhere-biometric-reason="#{_everywhere_attr(reason)}") if reason
@@ -12,24 +12,28 @@ module Everywhere
12
12
  File.expand_path("../..", __dir__)
13
13
  end
14
14
 
15
- # The generic Tauri shell bundled inside the gem: support/shell/ holds the
16
- # src-tauri app and the splash/ dir it serves. This is the CANONICAL copy of
17
- # the shell external consumers (e.g. the build-runner repo) resolve it via
15
+ # The generic Tauri shell bundled inside the gem: support/desktop/ holds the
16
+ # src-tauri app and the splash/ dir it serves, alongside support/mobile/ios
17
+ # and support/mobile/android. This is the CANONICAL copy of the shell
18
+ # external consumers (e.g. the build-runner repo) resolve it via
18
19
  # `every shell-dir`. The CLI uses it unless the caller passes --shell-dir.
19
- def bundled_shell_dir
20
- File.join(gem_root, "support", "shell", "src-tauri")
20
+ #
21
+ # The directory is named for the platform; the reader keeps the "shell"
22
+ # wording because that's what the flag, the command and every caller call it.
23
+ def bundled_desktop_dir
24
+ File.join(gem_root, "support", "desktop", "src-tauri")
21
25
  end
22
26
 
23
27
  # Absolute path to the shell's src-tauri directory, or nil if the bundled
24
28
  # copy is missing (a corrupt/partial install).
25
29
  def shell_dir
26
- bundled_shell_dir if File.exist?(File.join(bundled_shell_dir, "tauri.conf.json"))
30
+ bundled_desktop_dir if File.exist?(File.join(bundled_desktop_dir, "tauri.conf.json"))
27
31
  end
28
32
 
29
33
  # Like #shell_dir but aborts with a helpful message when the shell is
30
34
  # missing. The --shell-dir hint covers dev on a modified shell checkout.
31
35
  def shell_dir!
32
- shell_dir or UI.die!("couldn't find the bundled shell at #{bundled_shell_dir}; " \
36
+ shell_dir or UI.die!("couldn't find the bundled shell at #{bundled_desktop_dir}; " \
33
37
  "reinstall ruby_everywhere or pass --shell-dir")
34
38
  end
35
39
 
@@ -126,10 +130,57 @@ module Everywhere
126
130
  File.join(cache_dir, "android-fonts")
127
131
  end
128
132
 
129
- # Where cargo writes the shell's build output (CARGO_TARGET_DIR). Redirected
130
- # here so cargo never dumps a multi-GB target/ into the installed gem, and so
131
- # the shell compiles warm across projects the shell source is identical for
132
- # everyone, so one shared target dir is safe to reuse.
133
+ # The stamped copy of the Tauri shell for one app, keyed by bundle id.
134
+ # Only apps that declare `native.desktop` get one everyone else compiles
135
+ # the gem's own copy, which is identical for them and stays warm across
136
+ # projects (see cargo_target_dir). Persistent for the same reason the mobile
137
+ # work dirs are: cargo's incremental state lives inside it, and a cold Tauri
138
+ # build is minutes where a warm one is seconds.
139
+ def desktop_work_dir(bundle_id)
140
+ File.join(cache_dir, "desktop", bundle_id)
141
+ end
142
+
143
+ # CARGO_TARGET_DIR for a stamped shell. Per-app rather than shared, because
144
+ # the whole point of stamping is that this app's crate graph differs from
145
+ # every other app's — pointing them at one target dir would make cargo
146
+ # rebuild the world on every alternating build.
147
+ def desktop_target_dir(bundle_id)
148
+ File.join(cache_dir, "desktop-target", bundle_id)
149
+ end
150
+
151
+ # Where `every dev` keeps the throwaway .app wrapper it runs the shell
152
+ # inside. macOS reads the application name from CFBundleName, so a bare
153
+ # `cargo run` binary shows up as the crate name — "example-shell" — in the
154
+ # menu bar, the About/Hide/Quit items and the Dock. The wrapper exists only
155
+ # to carry an Info.plist; the binary inside it is a hard link to cargo's.
156
+ def desktop_dev_app_dir(bundle_id)
157
+ File.join(cache_dir, "desktop-app", bundle_id)
158
+ end
159
+
160
+ # Where `every dev` stages the app's Dock icon for the running shell. The
161
+ # shaped master is derived from app.icon, so it's a build product, not the
162
+ # app's own file — it belongs in the cache next to the rest of them.
163
+ def desktop_icon_dir(bundle_id)
164
+ File.join(cache_dir, "desktop-icon", bundle_id)
165
+ end
166
+
167
+ # Where `every dev` stages native/desktop/assets for the running shell.
168
+ # A staged copy rather than the source tree on purpose: the packaged app
169
+ # reads ONE flat Resources/assets folder, and pointing dev at the nested
170
+ # source would quietly resolve assets/branding/logo.png in a build and not
171
+ # in dev. Staging runs the same DesktopAssets compiler both ways, so the two
172
+ # can't drift. Keyed by bundle id like the mobile work dirs.
173
+ def desktop_assets_dir(bundle_id)
174
+ File.join(cache_dir, "desktop-assets", bundle_id)
175
+ end
176
+
177
+ # Where cargo writes the shell's build output (CARGO_TARGET_DIR) for apps on
178
+ # the fast path — the ones that declare no `native.desktop`. Redirected here
179
+ # so cargo never dumps a multi-GB target/ into the installed gem, and so the
180
+ # shell compiles warm across projects: with no app Rust in it the shell
181
+ # source really is identical for everyone, so one shared target dir is safe
182
+ # to reuse. An app that declares extensions gets desktop_target_dir instead,
183
+ # because that premise stops holding the moment its crate graph differs.
133
184
  def cargo_target_dir
134
185
  File.join(cache_dir, "shell-target")
135
186
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "console"
4
+ require_relative "line_pump"
5
+ require_relative "ui"
6
+
7
+ module Everywhere
8
+ # Reads one child's output off a pty and republishes it, one whole line at a
9
+ # time, tagged with its source.
10
+ #
11
+ # `every dev` used to hand the dev server and desktop shell its own stdout and
12
+ # let them write straight to the terminal. That made two things impossible:
13
+ # telling which of four concurrent things printed a line, and keeping anything
14
+ # pinned to the bottom of the screen. Relaying costs a thread per child and
15
+ # buys both.
16
+ #
17
+ # The full raw stream is also tee'd to a log file, matching what the builders
18
+ # already do with dist/ios-build.log — so nothing a filter hides is lost.
19
+ class Relay
20
+ CHUNK = 4096
21
+ POLL = 0.1
22
+
23
+ # on_line: an optional callback given every settled line before filtering.
24
+ # `every dev` uses it to notice the moment cargo actually starts the desktop
25
+ # shell — there is no other signal that the build turned into a running app.
26
+ def initialize(io, source:, log: nil, filter: nil, on_line: nil)
27
+ @io = io
28
+ @source = source
29
+ @filter = filter
30
+ @on_line = on_line
31
+ @log = log && File.open(log, "w")
32
+ end
33
+
34
+ # Blocking; run it in a thread. Returns when the child closes the pty.
35
+ def run
36
+ Console.tag = @source
37
+ pump = LinePump.new
38
+ loop do
39
+ if @io.wait_readable(POLL)
40
+ chunk = @io.read_nonblock(CHUNK, exception: false)
41
+ break if chunk.nil? # EOF
42
+
43
+ publish(pump << chunk) unless chunk == :wait_readable
44
+ else
45
+ publish(pump.flush_idle)
46
+ end
47
+ end
48
+ publish(pump.drain)
49
+ rescue Errno::EIO, IOError, Errno::EBADF
50
+ # A pty master raises EIO (not EOF) once the last slave closes, which is
51
+ # simply how a child exiting looks from this side.
52
+ publish(pump.drain) if pump
53
+ ensure
54
+ @log&.close
55
+ begin
56
+ @io.close
57
+ rescue StandardError
58
+ nil
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def publish(lines)
65
+ lines.each do |line|
66
+ @log&.puts(line)
67
+ @on_line&.call(line)
68
+ shown = @filter ? @filter.call(line) : line
69
+ case shown
70
+ when :drop, false then nil
71
+ when nil then Console.puts(" #{UI.gray(UI.short_path(line))}")
72
+ else Console.puts(shown)
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end