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
@@ -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
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "ui"
4
+ require_relative "console"
5
+ require_relative "child_processes"
4
6
 
5
7
  module Everywhere
6
8
  # All external commands (tebako, cargo, the app's own bin/dev) run outside
@@ -8,27 +10,57 @@ module Everywhere
8
10
  module Shellout
9
11
  module_function
10
12
 
11
- def unbundled(&block)
12
- defined?(Bundler) ? Bundler.with_unbundled_env(&block) : yield
13
+ # The environment a child should get: this gem's bundle stripped back out,
14
+ # plus the caller's overrides.
15
+ #
16
+ # This is handed to spawn as its env argument rather than swapped into ENV,
17
+ # which is what Bundler.with_unbundled_env does (ENV.replace, twice, around
18
+ # the block). That is process-global mutable state, and `every dev` now runs
19
+ # the iOS and Android builds concurrently: two overlapping swaps race so that
20
+ # one child inherits the CLI's bundle after all — the exact thing unbundling
21
+ # exists to prevent — and ENV is left wrong for the rest of the session.
22
+ #
23
+ # A nil value tells spawn to REMOVE that key, which is most of what
24
+ # unbundling is. The resulting child environment is identical to what
25
+ # with_unbundled_env produced, since Bundler.unbundled_env is derived from
26
+ # the snapshot taken before Bundler was activated either way.
27
+ def child_env(overrides = {})
28
+ base = unbundled_base
29
+ delta = {}
30
+ ENV.each_key { |key| delta[key] = nil unless base.key?(key) }
31
+ base.each { |key, value| delta[key] = value unless ENV[key] == value }
32
+ delta.merge(overrides.transform_keys(&:to_s))
33
+ end
34
+
35
+ # Bundler.unbundled_env derives from `original_env`, a snapshot frozen at
36
+ # load time, so this is genuinely constant for the life of the process.
37
+ def unbundled_base
38
+ @unbundled_base ||= defined?(Bundler) ? Bundler.unbundled_env : ENV.to_h
13
39
  end
14
40
 
15
41
  def run!(env, *cmd, chdir: Dir.pwd)
16
- success = unbundled { system(env, *cmd, chdir: chdir) }
42
+ success = system(child_env(env), *cmd, chdir: chdir)
17
43
  UI.die!("command failed: #{cmd.join(" ")}") unless success
18
44
  end
19
45
 
46
+ # quiet also detaches stdin. A quiet command is by definition one that has
47
+ # nothing to say to the terminal, and `every dev` reads single keystrokes
48
+ # from that same terminal — a child left holding stdin silently eats the
49
+ # keypresses the menu is waiting on.
20
50
  def run?(*cmd, chdir: Dir.pwd, quiet: false)
21
51
  opts = { chdir: chdir }
22
- opts.update(out: File::NULL, err: File::NULL) if quiet
23
- unbundled { system(*cmd, **opts) }
52
+ opts.update(in: File::NULL, out: File::NULL, err: File::NULL) if quiet
53
+ system(child_env, *cmd, **opts)
24
54
  end
25
55
 
26
56
  # Run a command and capture its combined stdout+stderr. Returns
27
57
  # [output_string, Process::Status]. For probing tool output (codesign,
28
- # stapler, notarytool) rather than driving a build.
58
+ # stapler, notarytool) rather than driving a build. A leading Hash is env
59
+ # overrides, matching Open3's own convention.
29
60
  def capture(*cmd)
30
61
  require "open3"
31
- unbundled { Open3.capture2e(*cmd) }
62
+ overrides = cmd.first.is_a?(Hash) ? cmd.shift : {}
63
+ Open3.capture2e(child_env(overrides), *cmd)
32
64
  rescue Errno::ENOENT
33
65
  ["", nil]
34
66
  end
@@ -40,18 +72,32 @@ module Everywhere
40
72
  # prettify what a human sees; the on-disk log always gets the raw stream, so
41
73
  # nothing is lost. Unrecognized lines (filter returns nil) are shown dimmed
42
74
  # and indented so they read as background detail under the current step.
75
+ #
76
+ # The signature is deliberately unchanged: the builder tests stub this with
77
+ # fixed-signature lambdas, and cancellation is wired up out of band through
78
+ # ChildProcesses instead.
43
79
  def run_logged!(env, cmd, log:, filter: nil)
44
80
  require "open3"
45
81
  success = false
46
- unbundled do
47
- File.open(log, "w") do |logf|
48
- Open3.popen2e(env, *cmd) do |stdin, out, wait|
49
- stdin.close
82
+ File.open(log, "w") do |logf|
83
+ # Inside an `every dev` worker the build leads its own process group,
84
+ # for two reasons: a Ctrl-C at the terminal no longer reaches
85
+ # xcodebuild/gradlew behind the CLI's back (the CLI decides when a build
86
+ # dies), and teardown can TERM the whole compile tree with one signal.
87
+ # Outside dev this stays off, so Ctrl-C during `every build` still stops
88
+ # the compiler as it always has.
89
+ opts = ChildProcesses.isolated? ? { pgroup: true } : {}
90
+ Open3.popen2e(child_env(env), *cmd, **opts) do |stdin, out, wait|
91
+ stdin.close
92
+ ChildProcesses.track(wait.pid)
93
+ begin
50
94
  out.each_line do |raw|
51
95
  logf.write(raw)
52
96
  emit(raw, filter)
53
97
  end
54
98
  success = wait.value.success?
99
+ ensure
100
+ ChildProcesses.untrack(wait.pid)
55
101
  end
56
102
  end
57
103
  end
@@ -61,13 +107,13 @@ module Everywhere
61
107
  # Route one raw line to the console: verbatim when there's no filter, else
62
108
  # the filter's rendition (:drop swallows it, nil falls back to dim detail).
63
109
  def emit(raw, filter)
64
- return $stdout.print(raw) unless filter
110
+ return Console.print(raw) unless filter
65
111
 
66
112
  shown = filter.call(raw)
67
113
  case shown
68
114
  when :drop, false then nil
69
- when nil then $stdout.puts(" #{UI.gray(UI.short_path(raw.rstrip))}")
70
- else $stdout.puts(shown)
115
+ when nil then Console.puts(" #{UI.gray(UI.short_path(raw.rstrip))}")
116
+ else Console.puts(shown)
71
117
  end
72
118
  end
73
119
  private_class_method :emit
@@ -75,7 +121,36 @@ module Everywhere
75
121
  # pgroup: the child leads its own process group so teardown can TERM the
76
122
  # whole tree (foreman + watchers) without signalling the CLI itself.
77
123
  def spawn(env, cmd, chdir:)
78
- unbundled { Process.spawn(env, cmd, chdir: chdir, pgroup: true) }
124
+ Process.spawn(child_env(env), cmd, chdir: chdir, pgroup: true)
125
+ end
126
+
127
+ # Same, but on a pseudo-terminal, returning [master_io, pid].
128
+ #
129
+ # `every dev` relays the dev server's and desktop shell's output itself so
130
+ # it can tag each line with its source and keep the dock pinned at the
131
+ # bottom. A plain pipe would work, but the children would see a non-TTY and
132
+ # foreman, cargo and friends would drop their colors and progress output; on
133
+ # a pty they behave exactly as they do when you run them by hand.
134
+ #
135
+ # PTY.spawn calls setsid, so the child leads its own session: a terminal
136
+ # SIGINT can't reach it, and Process.kill("TERM", -pid) still takes down the
137
+ # whole tree.
138
+ def spawn_pty(env, cmd, chdir:)
139
+ require "pty"
140
+ # PTY.spawn hands back the read and write ends of the pty MASTER. We never
141
+ # forward stdin, so the write end is closed immediately — the child keeps
142
+ # running because the read end still holds the master open.
143
+ reader, writer, pid = PTY.spawn(child_env(env), cmd, chdir: chdir)
144
+ writer.close
145
+ [reader, pid]
146
+ end
147
+
148
+ # PTY is POSIX-only. Callers fall back to spawn + inherited fds.
149
+ def pty?
150
+ require "pty"
151
+ true
152
+ rescue LoadError
153
+ false
79
154
  end
80
155
  end
81
156
  end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "console"
4
+ require_relative "child_processes"
5
+
6
+ module Everywhere
7
+ # Named background tasks for the `every dev` key loop.
8
+ #
9
+ # Three guarantees, each of which fixes a specific way the old synchronous
10
+ # command misbehaved:
11
+ #
12
+ # * At most one live task per name. Pressing `i` twice must not run two
13
+ # iOS builds over the same staged work dir, which Builders::Ios rm_rf's
14
+ # and re-copies on every run.
15
+ # * No task failure can reach the main thread. A Swift compile error used to
16
+ # unwind through the key loop into Dev#call's `ensure teardown` and take
17
+ # the Rails server down with it.
18
+ # * Every task is cancellable. Its children lead their own process groups
19
+ # and are TERMed (then KILLed) at shutdown.
20
+ class TaskPool
21
+ # A task's failure is data, not control flow — hence `rescue Exception`, one
22
+ # of the few places it is the correct choice. MRI enqueues a SystemExit that
23
+ # escapes a non-main thread onto the MAIN thread and re-raises it there,
24
+ # wherever that thread happens to be. Since UI.die! raises Everywhere::Fatal
25
+ # (a SystemExit), `rescue StandardError` here would leave the original bug
26
+ # fully intact, just relocated. Verified:
27
+ #
28
+ # ruby -e 'Thread.new { raise SystemExit.new(2) }; sleep 1; puts "alive"'
29
+ # # => exits 2, never prints
30
+ GRACE = 5
31
+
32
+ def initialize(on_error: nil)
33
+ @lock = Mutex.new
34
+ @threads = {}
35
+ @failed = []
36
+ @stopping = false
37
+ @on_error = on_error
38
+ end
39
+
40
+ # :started, :busy or :stopping.
41
+ def start(name, &block)
42
+ @lock.synchronize do
43
+ return :stopping if @stopping
44
+ return :busy if @threads[name]&.alive?
45
+
46
+ @threads[name] = spawn_worker(name, &block)
47
+ retag
48
+ :started
49
+ end
50
+ end
51
+
52
+ def busy?(name) = @lock.synchronize { !!@threads[name]&.alive? }
53
+
54
+ def failed = @lock.synchronize { @failed.dup }
55
+
56
+ def any_running? = @lock.synchronize { @threads.each_value.any?(&:alive?) }
57
+
58
+ # Join every live task. Used by the headless path, where there is no key
59
+ # loop to keep the process alive and a failed build still has to be news.
60
+ def wait_all
61
+ loop do
62
+ live = @lock.synchronize { @threads.each_value.select(&:alive?) }
63
+ break if live.empty?
64
+
65
+ live.each(&:join)
66
+ end
67
+ Console.multiplexed = false
68
+ end
69
+
70
+ # Kill the tasks' children first, then the threads. Ordering matters: a
71
+ # worker blocked reading a build's output only unwinds once that build is
72
+ # gone, so signalling the children is what actually makes the joins return.
73
+ def shutdown(grace: GRACE)
74
+ live = @lock.synchronize do
75
+ @stopping = true
76
+ @threads.each_value.select(&:alive?)
77
+ end
78
+ return if live.empty?
79
+
80
+ live.each { |thread| ChildProcesses.terminate(thread, grace: grace) }
81
+ deadline = now + grace
82
+ live.each { |thread| thread.join([deadline - now, 0].max) }
83
+ live.select(&:alive?).each(&:kill)
84
+ Console.multiplexed = false
85
+ end
86
+
87
+ private
88
+
89
+ # Called while holding @lock; the new thread also takes @lock in its ensure.
90
+ # That is not a deadlock — the worker simply blocks until start returns —
91
+ # but it is why these two must not be collapsed into one synchronize block.
92
+ def spawn_worker(name, &block)
93
+ Thread.new do
94
+ # Set from inside the thread, as its first statement: assigning it after
95
+ # Thread.new returns races a task that fails immediately.
96
+ Thread.current.name = name.to_s
97
+ Thread.current.report_on_exception = false
98
+ Console.tag = name
99
+ ChildProcesses.isolate!
100
+
101
+ begin
102
+ block.call
103
+ rescue Exception => e # rubocop:disable Lint/RescueException
104
+ stopping = @lock.synchronize { @failed << name; @stopping }
105
+ @on_error&.call(name, e, stopping)
106
+ ensure
107
+ @lock.synchronize { retag }
108
+ end
109
+ end
110
+ end
111
+
112
+ # Tag output by source only while two or more tasks are live: with one build
113
+ # running, the output is exactly what it has always been. Thread.current is
114
+ # excluded because a worker calling this from its own ensure is still
115
+ # #alive? and would keep tagging on for the task that just finished.
116
+ def retag
117
+ live = @threads.each_value.count { |t| t.alive? && t != Thread.current }
118
+ Console.multiplexed = live > 1
119
+ end
120
+
121
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
122
+ end
123
+ end