ruby_everywhere 0.4.0 → 0.5.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +86 -2
  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/auth_handoff.rb +8 -2
  7. data/lib/everywhere/builders/desktop.rb +326 -0
  8. data/lib/everywhere/child_processes.rb +74 -0
  9. data/lib/everywhere/commands/build.rb +44 -5
  10. data/lib/everywhere/commands/dev.rb +426 -59
  11. data/lib/everywhere/commands/install.rb +20 -0
  12. data/lib/everywhere/commands/release.rb +2 -2
  13. data/lib/everywhere/config.rb +298 -4
  14. data/lib/everywhere/console.rb +117 -0
  15. data/lib/everywhere/desktop_assets.rb +150 -0
  16. data/lib/everywhere/dock/footer.rb +150 -0
  17. data/lib/everywhere/dock/screen.rb +114 -0
  18. data/lib/everywhere/dock/state.rb +59 -0
  19. data/lib/everywhere/dock.rb +238 -0
  20. data/lib/everywhere/emulator.rb +2 -2
  21. data/lib/everywhere/fatal.rb +20 -0
  22. data/lib/everywhere/line_pump.rb +89 -0
  23. data/lib/everywhere/log_filter.rb +37 -0
  24. data/lib/everywhere/native_helper.rb +38 -5
  25. data/lib/everywhere/paths.rb +62 -11
  26. data/lib/everywhere/relay.rb +77 -0
  27. data/lib/everywhere/shellout.rb +90 -15
  28. data/lib/everywhere/task_pool.rb +123 -0
  29. data/lib/everywhere/ui.rb +54 -11
  30. data/lib/everywhere/version.rb +1 -1
  31. data/support/desktop/README.md +121 -0
  32. data/support/{shell → desktop}/src-tauri/Cargo.lock +23 -0
  33. data/support/{shell → desktop}/src-tauri/Cargo.toml +19 -1
  34. data/support/{shell → desktop}/src-tauri/capabilities/default.json +7 -0
  35. data/support/desktop/src-tauri/gen/schemas/capabilities.json +1 -0
  36. data/support/desktop/src-tauri/src/extension_host.rs +53 -0
  37. data/support/desktop/src-tauri/src/extensions/mod.rs +39 -0
  38. data/support/{shell → desktop}/src-tauri/src/main.rs +355 -10
  39. data/support/{shell → desktop}/src-tauri/tauri.conf.json +2 -2
  40. data/support/{macos → release/macos}/notarize.sh +2 -2
  41. metadata +31 -17
  42. data/support/github/build.yml +0 -85
  43. data/support/shell/src-tauri/gen/schemas/capabilities.json +0 -1
  44. /data/support/{shell → desktop}/splash/index.html +0 -0
  45. /data/support/{shell → desktop}/src-tauri/build.rs +0 -0
  46. /data/support/{shell → desktop}/src-tauri/gen/schemas/acl-manifests.json +0 -0
  47. /data/support/{shell → desktop}/src-tauri/gen/schemas/desktop-schema.json +0 -0
  48. /data/support/{shell → desktop}/src-tauri/gen/schemas/macOS-schema.json +0 -0
  49. /data/support/{shell → desktop}/src-tauri/icons/icon.png +0 -0
  50. /data/support/{shell → desktop}/src-tauri/src/updater.rs +0 -0
  51. /data/support/{macos → release/macos}/entitlements.plist +0 -0
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ui"
4
+
5
+ module Everywhere
6
+ class Dock
7
+ # Renders the pinned footer. PURE: state in, array of strings out. No IO, no
8
+ # clock, no terminal — every layout decision here is testable with plain
9
+ # string assertions.
10
+ #
11
+ # Composition rule: build every segment as PLAIN text, measure it, truncate,
12
+ # and only then paint. Measuring or cutting a string that already contains
13
+ # SGR codes is how you end up slicing through "\e[32" and corrupting the
14
+ # terminal, and it makes the width math quietly wrong under NO_COLOR.
15
+ module Footer
16
+ # Every state gets its own glyph, so the footer stays fully legible with
17
+ # NO_COLOR — color is redundant reinforcement, never the only signal.
18
+ # A nil glyph means "use the spinner frame".
19
+ GLYPHS = {
20
+ idle: ["○", :gray],
21
+ booting: [nil, :cyan],
22
+ building: [nil, :cyan],
23
+ installing: [nil, :cyan],
24
+ running: ["●", :green],
25
+ failed: ["✗", :red],
26
+ stopped: ["!", :yellow]
27
+ }.freeze
28
+
29
+ GAP = " "
30
+ KEY_GAP = " · "
31
+ ELLIPSIS = "…"
32
+
33
+ module_function
34
+
35
+ # targets: Array<State::Target>. elapsed: ->(target) { seconds }.
36
+ # keys: Array<[key, description]>, or nil to omit the keymap row.
37
+ def render(targets, cols:, keys: nil, frame: 0, elapsed: nil, rule: true)
38
+ cols = [cols.to_i, 1].max
39
+ lines = []
40
+ lines << UI.gray("─" * cols) if rule
41
+ lines << status_row(targets, cols, frame, elapsed)
42
+ lines << keys_row(keys, cols) if keys
43
+ lines
44
+ end
45
+
46
+ # --- status row ----------------------------------------------------------
47
+
48
+ # Three levels of degradation, tried in order until one fits: everything;
49
+ # details only on what's actually working; then drop idle targets. A hard
50
+ # truncation is the last resort.
51
+ def status_row(targets, cols, frame, elapsed)
52
+ segments = targets.map { |t| segment(t, frame, elapsed) }
53
+
54
+ candidates = [
55
+ segments,
56
+ segments.map { |s| s[:busy] ? s : s.merge(detail: nil) },
57
+ segments.select { |s| s[:state] != :idle }
58
+ ]
59
+ chosen = candidates.find { |set| plain_width(set) <= cols } || candidates.last
60
+ fit(chosen.map { |s| [plain(s), painted(s)] }, cols)
61
+ end
62
+
63
+ def segment(target, frame, elapsed)
64
+ glyph, color = GLYPHS.fetch(target.state, GLYPHS[:idle])
65
+ { glyph: glyph || UI::FRAMES[frame % UI::FRAMES.size],
66
+ color: color,
67
+ label: target.label,
68
+ detail: detail_for(target, elapsed),
69
+ state: target.state,
70
+ busy: target.busy? }
71
+ end
72
+
73
+ # Busy targets carry a running clock — the whole reason the dock exists is
74
+ # that a cold Gradle build looks identical to a hung one without it.
75
+ def detail_for(target, elapsed)
76
+ if target.busy?
77
+ seconds = elapsed&.call(target)
78
+ [target.state == :building ? nil : target.state.to_s,
79
+ (UI.elapsed(seconds) if seconds)].compact.join(" ")
80
+ else
81
+ target.detail
82
+ end
83
+ end
84
+
85
+ def plain(seg)
86
+ [seg[:glyph], seg[:label], presence(seg[:detail])].compact.join(" ")
87
+ end
88
+
89
+ def painted(seg)
90
+ tint = UI.method(seg[:color])
91
+ parts = [tint.call(seg[:glyph]), seg[:state] == :idle ? UI.gray(seg[:label]) : seg[:label]]
92
+ parts << UI.dim(seg[:detail]) if presence(seg[:detail])
93
+ parts.join(" ")
94
+ end
95
+
96
+ def plain_width(segments) = segments.sum { |s| plain(s).length } + (GAP.length * [segments.size - 1, 0].max)
97
+
98
+ # --- keys row ------------------------------------------------------------
99
+
100
+ # Named keys with dot separators, then named keys packed tighter, then
101
+ # bare letters. Measured on the plain text and painted only once a tier
102
+ # fits, so the width math can't be thrown off by SGR codes.
103
+ def keys_row(keys, cols)
104
+ tiers = [
105
+ [:named, KEY_GAP],
106
+ [:named, " "],
107
+ [:bare, " "]
108
+ ]
109
+ tiers.each do |style, gap|
110
+ plain = "⌨ #{keys.map { |key, desc| plain_key(key, desc, style) }.join(gap)}"
111
+ next unless plain.length <= cols
112
+
113
+ painted = keys.map { |key, desc| paint_key(key, desc, style) }.join(gap == KEY_GAP ? UI.gray(gap) : gap)
114
+ return "#{UI.cyan("⌨")} #{painted}"
115
+ end
116
+ ""
117
+ end
118
+
119
+ def plain_key(key, desc, style) = style == :bare ? key : "#{key} #{desc}"
120
+
121
+ def paint_key(key, desc, style)
122
+ style == :bare ? UI.bold(key) : "#{UI.bold(key)} #{UI.dim(desc)}"
123
+ end
124
+
125
+ # --- fitting -------------------------------------------------------------
126
+
127
+ # pairs: [[plain, painted], …]. Emit painted segments while they fit,
128
+ # measuring only the plain text; cut the first one that doesn't.
129
+ def fit(pairs, cols)
130
+ out = +""
131
+ width = 0
132
+ pairs.each_with_index do |(text, colored), index|
133
+ gap = index.zero? ? "" : GAP
134
+ if width + gap.length + text.length <= cols
135
+ out << gap << colored
136
+ width += gap.length + text.length
137
+ next
138
+ end
139
+
140
+ room = cols - width - gap.length - ELLIPSIS.length
141
+ out << gap << text[0, room] << ELLIPSIS if room.positive?
142
+ break
143
+ end
144
+ out
145
+ end
146
+
147
+ def presence(str) = str.nil? || str.empty? ? nil : str
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ class Dock
5
+ # Emits the escape sequences that keep the footer pinned to the bottom.
6
+ #
7
+ # THE MECHANIC, and why it isn't the obvious one:
8
+ #
9
+ # The tempting approach is DECSTBM — set a scroll region (ESC[1;Nr) that
10
+ # excludes the last few rows and let the terminal itself keep output off
11
+ # them. It works, and it even confines child processes writing straight to
12
+ # the fd. It was rejected for two reasons:
13
+ #
14
+ # 1. It destroys scrollback. tmux only copies scrolled-out rows into its
15
+ # history when the scroll region is the full screen
16
+ # (grid_view_scroll_region_up checks rupper == 0 && rlower == sy - 1);
17
+ # screen and, as far as we can tell, iTerm2 behave the same way. Losing
18
+ # the ability to scroll up and read a Rails backtrace is a far worse
19
+ # regression than the one the dock is fixing.
20
+ # 2. Its failure mode is terrible. If the CLI is SIGKILLed before it can
21
+ # emit ESC[r, the user's terminal keeps a dead band at the bottom until
22
+ # they run `reset`.
23
+ #
24
+ # So instead we own every byte (the dev server and shells are relayed
25
+ # through us, not inherited) and repaint: erase the footer rows, write the
26
+ # new content — which scrolls normally, preserving scrollback everywhere —
27
+ # then draw the footer again. This is what indicatif, Ink, docker compose
28
+ # and cargo all do. Worst-case failure is one stale line on screen.
29
+ #
30
+ # Everything is emitted as a SINGLE write per frame. Byte interleaving
31
+ # between processes happens between write(2) calls, not inside one.
32
+ class Screen
33
+ ERASE_ROW = "\r\e[2K" # clear this row, cursor back to column 1
34
+ UP_ROW = "\e[1A\e[2K" # up one row and clear it
35
+ HIDE = "\e[?25l"
36
+ SHOW = "\e[?25h"
37
+
38
+ DEFAULT_SIZE = [24, 80].freeze
39
+
40
+ def initialize(io:, size: nil)
41
+ @io = io
42
+ @size = size
43
+ @drawn = 0
44
+ @open = false
45
+ end
46
+
47
+ def open
48
+ return if @open
49
+
50
+ @open = true
51
+ @sync = @io.sync
52
+ @io.sync = true
53
+ @io.write(HIDE)
54
+ end
55
+
56
+ # content: text to land above the footer (must end in a newline, or be
57
+ # empty). footer: the rows to pin.
58
+ def frame(content, footer)
59
+ return unless @open
60
+
61
+ out = +""
62
+ out << erase
63
+ out << content
64
+ out << footer.join("\n")
65
+ @drawn = footer.length
66
+ @io.write(out)
67
+ end
68
+
69
+ # Wipe the footer without redrawing it — for anything that has to write
70
+ # outside our frame discipline (stderr, an interactive child).
71
+ def clear
72
+ return unless @open && @drawn.positive?
73
+
74
+ @io.write(erase)
75
+ @drawn = 0
76
+ end
77
+
78
+ def close
79
+ return unless @open
80
+
81
+ @io.write("#{erase}#{SHOW}")
82
+ @drawn = 0
83
+ @open = false
84
+ @io.sync = @sync
85
+ end
86
+
87
+ # Rows and columns, re-read on every resize. IO#winsize is a plain
88
+ # TIOCGWINSZ ioctl — it does not touch termios, so it is safe alongside
89
+ # the `stty -icanon -echo` the key loop sets.
90
+ def size
91
+ return @size if @size
92
+
93
+ require "io/console"
94
+ @io.winsize
95
+ rescue StandardError
96
+ env = [ENV["LINES"].to_i, ENV["COLUMNS"].to_i]
97
+ env.all?(&:positive?) ? env : DEFAULT_SIZE
98
+ end
99
+
100
+ def rows = size[0]
101
+ def cols = size[1]
102
+
103
+ private
104
+
105
+ # Cursor sits at the end of the last footer row; walk back up to column 1
106
+ # of the first, clearing as we go.
107
+ def erase
108
+ return "" if @drawn.zero?
109
+
110
+ ERASE_ROW + (UP_ROW * (@drawn - 1))
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ class Dock
5
+ # What the dock knows about each thing `every dev` is running. Pure data
6
+ # with an injected clock, so elapsed times are deterministic under test.
7
+ class State
8
+ # Ordered so the dock reads left-to-right the way the session starts up.
9
+ ORDER = %i[web desktop ios android].freeze
10
+
11
+ LABELS = {
12
+ web: "web",
13
+ desktop: "desktop",
14
+ ios: "iOS",
15
+ android: "Android"
16
+ }.freeze
17
+
18
+ # States that are "in flight" — they get a spinner and a running clock.
19
+ BUSY = %i[booting building installing].freeze
20
+
21
+ Target = Struct.new(:key, :label, :state, :detail, :since, keyword_init: true) do
22
+ def busy? = BUSY.include?(state)
23
+ end
24
+
25
+ def initialize(keys, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
26
+ @clock = clock
27
+ @lock = Mutex.new
28
+ ordered = ORDER & keys
29
+ @targets = ordered.to_h do |key|
30
+ [key, Target.new(key: key, label: LABELS.fetch(key, key.to_s), state: :idle, since: clock.call)]
31
+ end
32
+ end
33
+
34
+ def set(key, state, detail: nil)
35
+ @lock.synchronize do
36
+ target = @targets[key] or return nil
37
+ # Keep `since` when nothing actually changed, so a repeated
38
+ # set(:ios, :building) from a retry doesn't reset the elapsed clock.
39
+ target.since = @clock.call unless target.state == state
40
+ target.state = state
41
+ target.detail = detail
42
+ target
43
+ end
44
+ end
45
+
46
+ def detail(key, text)
47
+ @lock.synchronize { @targets[key]&.detail = text }
48
+ end
49
+
50
+ def [](key) = @lock.synchronize { @targets[key] }
51
+
52
+ def targets = @lock.synchronize { @targets.values.map(&:dup) }
53
+
54
+ def busy? = @lock.synchronize { @targets.each_value.any?(&:busy?) }
55
+
56
+ def elapsed(target) = @clock.call - target.since
57
+ end
58
+ end
59
+ end
@@ -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