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
@@ -3,12 +3,22 @@
3
3
  require "dry/cli"
4
4
  require "socket"
5
5
  require "shellwords"
6
+ require "fileutils"
6
7
  require_relative "../shellout"
7
8
  require_relative "../paths"
9
+ require_relative "../icon"
10
+ require_relative "../desktop_assets"
8
11
  require_relative "../builders/ios"
9
12
  require_relative "../builders/android"
13
+ require_relative "../builders/desktop"
10
14
  require_relative "../simulator"
11
15
  require_relative "../emulator"
16
+ require_relative "../console"
17
+ require_relative "../child_processes"
18
+ require_relative "../task_pool"
19
+ require_relative "../relay"
20
+ require_relative "../dock"
21
+ require_relative "../log_filter"
12
22
  require_relative "logs"
13
23
 
14
24
  module Everywhere
@@ -20,31 +30,49 @@ module Everywhere
20
30
  #
21
31
  # Target flags are additive (--desktop --ios --browser opens all three);
22
32
  # with no flags the server boots and a key menu launches shells on demand.
33
+ #
34
+ # Everything runs concurrently and nothing a shell does can end the session:
35
+ # each build owns a TaskPool thread that catches its own failures, each
36
+ # child's output is relayed through Console tagged with its source, and a
37
+ # Dock keeps the keymap and every target's state pinned to the bottom of the
38
+ # terminal while the logs scroll above it.
23
39
  class Dev < Dry::CLI::Command
24
40
  desc "Run the dev server with native shells on demand (live reload, no packaging)"
25
41
 
26
42
  IOS_TARGET = "ios-arm64"
27
43
  ANDROID_TARGET = "android-arm64"
28
44
 
45
+ # Key → the target it acts on, for the "press i to try again" hints.
46
+ KEY_FOR = { ios: "i", android: "a", desktop: "d", web: "r" }.freeze
47
+
29
48
  option :port, default: "3000", desc: "Dev server port"
30
49
  option :shell_dir, default: nil, desc: "Path to the shell's src-tauri directory"
31
50
  option :target, default: nil, desc: "Single shell to open: macos (desktop), ios (Simulator) or android (emulator)"
32
51
  option :desktop, type: :boolean, default: false, desc: "Open the desktop shell"
33
52
  option :ios, type: :boolean, default: false, desc: "Open the iOS Simulator shell"
34
53
  option :android, type: :boolean, default: false, desc: "Open the Android emulator shell"
54
+ option :mobile, type: :boolean, default: false, desc: "Open both mobile shells (same as --ios --android)"
35
55
  option :browser, type: :boolean, default: false, desc: "Open the app in the default browser"
36
56
  option :dev_url, default: nil, desc: "Explicit URL for the device to load (e.g. a LAN address for a physical Android device)"
37
57
 
38
58
  def call(port: "3000", shell_dir: nil, target: nil, desktop: false, ios: false, android: false,
39
- browser: false, dev_url: nil, **)
59
+ mobile: false, browser: false, dev_url: nil, **)
40
60
  @shell_dir = shell_dir
41
61
  @port = port
42
62
  @dev_url_override = dev_url
43
- targets = requested_targets(target, desktop: desktop, ios: ios, android: android, browser: browser)
44
- framework, @config = detect_app
63
+ # Children are spawned from pool threads as well as the main one.
64
+ @pids = {}
65
+ @relays = {}
66
+ @children = Mutex.new
67
+ targets = requested_targets(target, desktop: desktop, ios: ios, android: android,
68
+ mobile: mobile, browser: browser)
69
+ @framework, @config = detect_app
70
+
71
+ @pool = TaskPool.new(on_error: method(:report_failure))
72
+ @dock = Dock.open(dock_targets(targets), keys: $stdin.tty?)
45
73
 
46
- start_server(framework, port)
47
- @dev_url = if framework
74
+ start_server(@framework, port)
75
+ @dev_url = if @framework
48
76
  "http://127.0.0.1:#{port}#{@config.entry_path}"
49
77
  else
50
78
  "#{@config.remote_url}#{@config.entry_path}"
@@ -55,7 +83,7 @@ module Everywhere
55
83
  targets << "desktop" if targets.empty? && !$stdin.tty?
56
84
  targets.each { |t| launch(t) }
57
85
 
58
- $stdin.tty? ? interact : wait_for_children
86
+ $stdin.tty? ? interact : run_headless
59
87
  rescue Interrupt
60
88
  nil
61
89
  ensure
@@ -67,16 +95,25 @@ module Everywhere
67
95
  # --target names ONE shell; the boolean flags are additive. Desktop is the
68
96
  # fallback for any --target that isn't a mobile os, which keeps
69
97
  # `--target macos-arm64` (and a bare `--target`) meaning what it always did.
70
- def requested_targets(target, desktop:, ios:, android:, browser:)
98
+ # --mobile is exactly --ios --android.
99
+ def requested_targets(target, desktop:, ios:, android:, mobile:, browser:)
71
100
  target_os = target && Everywhere::Config.os_of(target)
72
101
  targets = []
73
102
  targets << "desktop" if desktop || (target && !%w[ios android].include?(target_os))
74
- targets << "ios" if ios || target_os == "ios"
75
- targets << "android" if android || target_os == "android"
103
+ targets << "ios" if ios || mobile || target_os == "ios"
104
+ targets << "android" if android || mobile || target_os == "android"
76
105
  targets << "browser" if browser
77
106
  targets
78
107
  end
79
108
 
109
+ # What the dock lists. On a terminal every shell is one keypress away, so
110
+ # show them all and let the idle ones sit dim; headless, only show what was
111
+ # actually asked for.
112
+ def dock_targets(requested)
113
+ shells = $stdin.tty? ? %i[desktop ios android] : requested.map(&:to_sym)
114
+ ([@framework ? :web : nil] + shells).compact.uniq
115
+ end
116
+
80
117
  # The framework (nil for remote-only roots) and config. A root with
81
118
  # neither a framework nor remote.url is a genuine error.
82
119
  def detect_app
@@ -93,6 +130,8 @@ module Everywhere
93
130
  [framework, config]
94
131
  end
95
132
 
133
+ # --- the dev server -------------------------------------------------------
134
+
96
135
  def start_server(framework, port)
97
136
  unless framework
98
137
  # Remote-mode app with no local code (just everywhere.yml): nothing
@@ -104,34 +143,220 @@ module Everywhere
104
143
  if port_open?(port)
105
144
  UI.warn "something is ALREADY RUNNING on port #{port} — attaching to it instead of starting"
106
145
  UI.warn "#{UI.dim("your Procfile.dev/watchers are NOT running; stop that server or use --port if this isn't yours")}"
146
+ @dock.set(:web, :running, detail: "port #{port} (attached)")
107
147
  else
108
148
  UI.step("starting #{UI.bold(framework.name)} dev server #{UI.dim("(" + framework.dev_command + ")")}")
109
- @server_pid = Shellout.spawn({ "PORT" => port.to_s }, framework.dev_command, chdir: framework.root)
110
- @server_thread = Process.detach(@server_pid)
149
+ @dock.set(:web, :booting)
150
+ supervise(:web, { "PORT" => port.to_s }, framework.dev_command, chdir: framework.root)
111
151
  wait_for_port(port)
152
+ @dock.set(:web, :running, detail: ":#{port}")
112
153
  end
113
154
  end
114
155
 
156
+ # Kill and respawn just the dev server — for a Gemfile or initializer
157
+ # change, where a full restart of the session would mean rebuilding the
158
+ # native shells for nothing.
159
+ def restart_server
160
+ return UI.warn("nothing to restart — this app runs in remote mode") unless @framework
161
+
162
+ @dock.set(:web, :booting, detail: "restarting")
163
+ UI.step("restarting the dev server")
164
+ stop_child(:web)
165
+ start_server(@framework, @port)
166
+ end
167
+
168
+ # --- shells ---------------------------------------------------------------
169
+
115
170
  def launch(target)
116
171
  case target
117
- when "ios" then launch_ios
118
- when "android" then launch_android
172
+ when "ios" then background(:ios) { launch_ios }
173
+ when "android" then background(:android) { launch_android }
119
174
  when "browser" then launch_browser
120
- else launch_desktop
175
+ else background(:desktop) { launch_desktop }
121
176
  end
122
177
  end
123
178
 
179
+ # A second press while a build is still running is refused, not queued:
180
+ # Builders::Ios rm_rf's and re-copies a shared work dir on every run, so
181
+ # two overlapping builds would corrupt each other.
182
+ def background(name, &block)
183
+ case @pool.start(name, &block)
184
+ when :busy
185
+ UI.warn "#{Dock::State::LABELS[name]} is already working — #{UI.dim("give it a moment")}"
186
+ end
187
+ end
188
+
189
+ # `d` relaunches, the same way `i` and `a` do — quit the running window and
190
+ # open a fresh one. Refusing with "already running" made the desktop the
191
+ # odd one out, and left no way to restart a shell that had wedged or was
192
+ # holding stale NATIVE_CONFIG.
193
+ #
194
+ # The rebuild is free either way: a warm `cargo run` re-checks the crate
195
+ # graph in well under a second before handing off.
196
+ # native/desktop/assets/** → the staged flat folder the running shell
197
+ # reads. Runs the same compiler `every build` does, so an asset that
198
+ # resolves in dev resolves in the packaged app too. Returns
199
+ # [dir, errors]; the dir is returned even when the app ships no assets,
200
+ # because the shell only has to cope with it not existing.
201
+ def stage_desktop_assets
202
+ dest = Everywhere::Paths.desktop_assets_dir(@config.bundle_id)
203
+ source = File.join(@config.root, "native", "desktop", "assets")
204
+ _names, errors = Everywhere::DesktopAssets.write!(source, dest)
205
+ [dest, errors]
206
+ end
207
+
208
+ # A minimal .app around the dev binary, so macOS has a CFBundleName to
209
+ # read. Without it every menu that names the app says "example-shell" —
210
+ # the crate name — because an unbundled process has nothing else to go on.
211
+ # (Setting NSProcessInfo's processName at runtime does NOT fix this: the
212
+ # menu bar and the predefined About/Hide/Quit items are built from the
213
+ # bundle, and macOS has already decided by the time any Rust runs.)
214
+ #
215
+ # Only the plist matters here. No icon is stamped: the shell sets the Dock
216
+ # icon at runtime from NATIVE_ICON_PATH, because Tauri applies its own
217
+ # compiled-in icon after launch and would win over a bundle icns anyway.
218
+ # `every build` writes the real, complete plist — this is the dev subset.
219
+ def stage_dev_bundle
220
+ app = File.join(Everywhere::Paths.desktop_dev_app_dir(@config.bundle_id),
221
+ "#{@config.name}.app")
222
+ macos = File.join(app, "Contents", "MacOS")
223
+ FileUtils.mkdir_p(macos)
224
+ File.write(File.join(app, "Contents", "Info.plist"), dev_info_plist)
225
+ [app, File.join(macos, dev_exe_name)]
226
+ end
227
+
228
+ # Spaces are legal in CFBundleExecutable but a nuisance in every shell
229
+ # command that names it, so the executable follows the packaged app's
230
+ # convention and drops them.
231
+ def dev_exe_name = @config.name.gsub(/\s+/, "")
232
+
233
+ def dev_info_plist
234
+ <<~PLIST
235
+ <?xml version="1.0" encoding="UTF-8"?>
236
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
237
+ <plist version="1.0">
238
+ <dict>
239
+ <key>CFBundleName</key><string>#{@config.name}</string>
240
+ <key>CFBundleDisplayName</key><string>#{@config.name}</string>
241
+ <key>CFBundleExecutable</key><string>#{dev_exe_name}</string>
242
+ <key>CFBundleIdentifier</key><string>#{@config.bundle_id}</string>
243
+ <key>CFBundlePackageType</key><string>APPL</string>
244
+ <key>CFBundleShortVersionString</key><string>#{@config.version}</string>
245
+ <key>NSHighResolutionCapable</key><true/>
246
+ </dict>
247
+ </plist>
248
+ PLIST
249
+ end
250
+
251
+ # The Dock icon for the running shell. `cargo run` produces a bare binary
252
+ # with no .app around it, so macOS has nothing to read an icon from and
253
+ # Tauri's compiled-in placeholder wins — which is how you end up staring
254
+ # at the gem's icon while testing your own app.
255
+ #
256
+ # Shaped with the same Big Sur inset + squircle the packaged .icns gets
257
+ # (Icon.macos_master), so the Dock looks identical in dev and in a release
258
+ # build rather than subtly rounder in one of them. Returns "" when the app
259
+ # has no icon — the shell reads that as "leave it alone".
260
+ def stage_desktop_icon
261
+ source = @config.icon
262
+ return "" unless source && File.exist?(source)
263
+
264
+ dest = File.join(Everywhere::Paths.desktop_icon_dir(@config.bundle_id), "icon.png")
265
+ FileUtils.mkdir_p(File.dirname(dest))
266
+ FileUtils.cp(source, dest) unless Everywhere::Icon.macos_master(source, dest)
267
+ dest
268
+ rescue StandardError => e
269
+ # A Dock icon is never worth failing a dev session over.
270
+ UI.warn("couldn't prepare the Dock icon (#{e.class}) — using the default")
271
+ ""
272
+ end
273
+
124
274
  def launch_desktop
125
- if @desktop_pid && alive?(@desktop_pid)
126
- UI.warn "desktop shell already running"
275
+ # A bad `window:` or a broken assets/ tree marks the desktop red and
276
+ # leaves the dev server and the mobile shells alone — the same deal
277
+ # every other target gets when its build fails. Dying here would take
278
+ # the whole session down over a misspelled title bar, and you'd fix it
279
+ # and have to start over.
280
+ errors = @config.window_errors
281
+ unless errors.empty?
282
+ errors.each { |error| UI.warn(error) }
283
+ @dock.set(:desktop, :failed, detail: "window: in config/everywhere.yml")
127
284
  return
128
285
  end
129
- dir = @shell_dir || Everywhere::Paths.shell_dir!
130
- UI.step("opening desktop shell → #{UI.cyan(@dev_url)}")
131
- @desktop_pid = Shellout.spawn({ "NATIVE_DEV_URL" => @dev_url, "NATIVE_CONFIG" => @config.to_shell_json,
132
- "CARGO_TARGET_DIR" => Everywhere::Paths.cargo_target_dir },
133
- "cargo run", chdir: dir)
134
- @desktop_thread = Process.detach(@desktop_pid)
286
+
287
+ assets_dir, asset_errors = stage_desktop_assets
288
+ unless asset_errors.empty?
289
+ asset_errors.each { |error| UI.warn(error) }
290
+ @dock.set(:desktop, :failed, detail: "native/desktop/assets")
291
+ return
292
+ end
293
+
294
+ # Stamps the per-app shell when the app declares native.desktop, and
295
+ # hands back the gem's copy when it doesn't. Done before the restart
296
+ # dance so a config error doesn't first kill a working window.
297
+ prepared = Builders::Desktop.new(config: @config, root: @config.root,
298
+ template_dir: @shell_dir).prepare!
299
+
300
+ pid = child_pid(:desktop)
301
+ restarting = pid && ChildProcesses.alive?(pid)
302
+ if restarting
303
+ @dock.set(:desktop, :booting, detail: "restarting")
304
+ UI.step("quitting the running desktop shell")
305
+ stop_child(:desktop, grace: 3)
306
+ end
307
+
308
+ UI.step("#{restarting ? "reopening" : "opening"} desktop shell → #{UI.cyan(@dev_url)}")
309
+ # A cold cargo build is minutes long, so the dock says "building" until
310
+ # cargo itself reports it has handed off to the app. There is no other
311
+ # signal that the compile turned into a running window.
312
+ @dock.set(:desktop, :building)
313
+ # CARGO_TERM_COLOR: cargo drops color when its stdout isn't a terminal.
314
+ # It IS one here (we relay over a pty), but the variable costs nothing
315
+ # and keeps the output right if the pty path ever falls back to a pipe.
316
+ # NATIVE_ASSETS_DIR: `cargo run` has no .app around it, so there's no
317
+ # Resources/assets for the shell to find. It gets the staged copy
318
+ # instead, which is re-stamped just above on every launch — so editing a
319
+ # PNG and pressing `d` is enough to see it.
320
+ supervise(:desktop,
321
+ { "NATIVE_DEV_URL" => @dev_url, "NATIVE_CONFIG" => @config.to_shell_json,
322
+ "NATIVE_ASSETS_DIR" => assets_dir,
323
+ "NATIVE_ICON_PATH" => stage_desktop_icon,
324
+ "CARGO_TARGET_DIR" => prepared.target_dir,
325
+ "CARGO_TERM_COLOR" => "always" },
326
+ desktop_command(prepared), chdir: prepared.dir,
327
+ filter: LogFilter.for(:cargo), log: dist_log("desktop-dev.log"),
328
+ # cargo colors its status lines, so the marker has to be
329
+ # matched past the escape codes. "Finished" is cargo's last
330
+ # word before we hand off to the app; on the `cargo run` path
331
+ # it was "Running".
332
+ on_line: lambda { |line|
333
+ @dock.set(:desktop, :running) if UI.strip_ansi(line).match?(/\A\s*(Running|Finished)\b/)
334
+ })
335
+ end
336
+
337
+ # macOS runs the shell from inside a throwaway .app so it has a name;
338
+ # everywhere else `cargo run` is still the whole story.
339
+ #
340
+ # Three steps in one command rather than three Ruby calls, because the
341
+ # link has to happen between the compile and the launch and we want ONE
342
+ # supervised child: cargo's progress relays into the dock, and exec then
343
+ # replaces it with the app, so the pid we're tracking is the window.
344
+ #
345
+ # A hard link, not a copy: same volume (both under ~/.rubyeverywhere), so
346
+ # it's free and instant, where copying a debug Tauri binary is ~100MB on
347
+ # every launch. cargo replaces the file on rebuild rather than writing
348
+ # through it, so the link is remade each time — hence `ln -f`.
349
+ def desktop_command(prepared)
350
+ return "cargo run" unless RUBY_PLATFORM.include?("darwin")
351
+
352
+ _app, exe = stage_dev_bundle
353
+ built = File.join(prepared.target_dir, "debug", "example-shell")
354
+ [
355
+ "cargo build",
356
+ "{ ln -f #{built.shellescape} #{exe.shellescape} 2>/dev/null || " \
357
+ "cp -f #{built.shellescape} #{exe.shellescape}; }",
358
+ "exec #{exe.shellescape}"
359
+ ].join(" && ")
135
360
  end
136
361
 
137
362
  # Build the shell (Debug, warm after the first run), put it on a booted
@@ -140,14 +365,19 @@ module Everywhere
140
365
  # covers plain http for local networking. Pressing i again rebuilds and
141
366
  # relaunches, so it doubles as a native-shell reload.
142
367
  def launch_ios
143
- app = Builders::Ios.new(config: @config, root: @config.root, target: "ios-arm64")
368
+ @dock.set(:ios, :building)
369
+ app = Builders::Ios.new(config: @config, root: @config.root, target: IOS_TARGET)
144
370
  .build!(dist_dir: File.expand_path("dist", @config.root), dev: true)
145
371
 
372
+ @dock.set(:ios, :booting, detail: "Simulator")
146
373
  udid = Simulator.boot_default!
374
+ @dock.set(:ios, :installing)
147
375
  UI.step("installing #{UI.bold(File.basename(app))}")
148
376
  Simulator.install(udid, app)
149
377
  UI.step("launching → #{UI.cyan(@dev_url)}")
150
- Simulator.launch(udid, @config.bundle_id(target: "ios-arm64"), env: { "EVERYWHERE_DEV_URL" => @dev_url })
378
+ Simulator.launch(udid, @config.bundle_id(target: IOS_TARGET),
379
+ env: { "EVERYWHERE_DEV_URL" => @dev_url })
380
+ @dock.set(:ios, :running, detail: "Simulator")
151
381
  end
152
382
 
153
383
  # The Android counterpart of launch_ios: build Debug (warm after the first
@@ -157,17 +387,21 @@ module Everywhere
157
387
  # reused from @dev_url (see Emulator.dev_url for the fallback ladder).
158
388
  # Pressing a again rebuilds and relaunches, so it doubles as a reload.
159
389
  def launch_android
390
+ @dock.set(:android, :booting, detail: "emulator")
160
391
  serial = Emulator.boot_default!
161
392
  url = Emulator.dev_url(@dev_url, serial: serial, port: @port, override: @dev_url_override)
162
393
 
394
+ @dock.set(:android, :building)
163
395
  app = Builders::Android.new(config: @config, root: @config.root, target: ANDROID_TARGET)
164
396
  .build!(dist_dir: File.expand_path("dist", @config.root), dev: true, dev_url: url)
165
397
 
166
398
  application_id = @config.bundle_id(target: ANDROID_TARGET)
399
+ @dock.set(:android, :installing)
167
400
  UI.step("installing #{UI.bold(File.basename(app))}")
168
401
  Emulator.install(serial, app, application_id: application_id)
169
402
  UI.step("launching → #{UI.cyan(url)}")
170
403
  Emulator.launch(serial, application_id)
404
+ @dock.set(:android, :running, detail: serial)
171
405
  end
172
406
 
173
407
  def launch_browser
@@ -176,12 +410,12 @@ module Everywhere
176
410
  Shellout.run?(opener, @dev_url, quiet: true)
177
411
  end
178
412
 
179
- # Toggle the `every logs --ios` stream, interleaved with the server
180
- # output on this same terminal.
413
+ # Toggle the `every logs --ios` stream, tagged and interleaved with the
414
+ # server output on this same terminal.
181
415
  def toggle_logs
182
- if @logs_pid && alive?(@logs_pid)
183
- stop(@logs_pid)
184
- @logs_pid = nil
416
+ pid = child_pid(:logs)
417
+ if pid && ChildProcesses.alive?(pid)
418
+ stop_child(:logs)
185
419
  UI.step("stopped iOS Simulator logs")
186
420
  return
187
421
  end
@@ -192,26 +426,98 @@ module Everywhere
192
426
  return
193
427
  end
194
428
  UI.step("watching iOS Simulator logs #{UI.dim("(press l again to stop)")}")
195
- @logs_pid = Shellout.spawn({}, Logs.new.argv(udid).shelljoin, chdir: Dir.pwd)
429
+ supervise(:logs, {}, Logs.new.argv(udid).shelljoin, chdir: Dir.pwd)
430
+ end
431
+
432
+ # --- child supervision ----------------------------------------------------
433
+
434
+ # Spawn a long-lived child on a pty and relay its output, one tagged line
435
+ # at a time. A pty rather than a pipe so foreman, cargo and friends still
436
+ # believe they're on a terminal and keep their colors; PTY.spawn also
437
+ # setsids, so a Ctrl-C at our terminal can't reach them and teardown
438
+ # decides when they die.
439
+ def supervise(key, env, cmd, chdir:, filter: nil, log: nil, on_line: nil)
440
+ if Shellout.pty?
441
+ io, pid = Shellout.spawn_pty(env, cmd, chdir: chdir)
442
+ thread = relay_thread(key, io, pid, filter: filter, log: log, on_line: on_line)
443
+ else
444
+ # Windows, or any platform without a pty: fall back to inherited fds.
445
+ # No tags and no dock framing, but everything else works.
446
+ pid = Shellout.spawn(env, cmd, chdir: chdir)
447
+ end
448
+ Process.detach(pid)
449
+ @children.synchronize do
450
+ @pids[key] = pid
451
+ @relays[key] = thread if thread
452
+ end
453
+ pid
454
+ end
455
+
456
+ def relay_thread(key, io, pid, filter:, log:, on_line:)
457
+ Thread.new do
458
+ Thread.current.name = "relay-#{key}"
459
+ Thread.current.report_on_exception = false
460
+ begin
461
+ Relay.new(io, source: key, log: log, filter: filter, on_line: on_line).run
462
+ rescue StandardError => e
463
+ # Losing a relay costs us that child's output, nothing more. It must
464
+ # not surface later out of the join in teardown.
465
+ Kernel.warn("#{key} output relay stopped: #{e.class}: #{e.message}")
466
+ end
467
+ # Only report a death we didn't ask for. stop_child removes the pid
468
+ # BEFORE it signals, so a deliberate restart can never match here —
469
+ # what does match is a shell that quit or crashed on its own, which is
470
+ # exactly what the dock should show.
471
+ @dock.set(key, :stopped) if !@stopping && @children.synchronize { @pids[key] } == pid
472
+ end
473
+ end
474
+
475
+ # TERM the child's whole process group, escalate to KILL, and wait for the
476
+ # relay to drain so its last lines aren't lost. The pid is dropped from
477
+ # the registry FIRST, so the relay knows this death was asked for.
478
+ def stop_child(key, grace: 5)
479
+ pid, thread = @children.synchronize { [@pids.delete(key), @relays.delete(key)] }
480
+ return unless pid
481
+
482
+ reap([pid], grace: grace)
483
+ thread&.join(1)
196
484
  end
197
485
 
198
- # Key menu. io/console's getch holds full raw mode while blocked, which
199
- # clears OPOST and stair-steps every log line the server prints; stty
200
- # -icanon -echo disables only line buffering and echo, leaving output
201
- # processing (and Ctrl-C as SIGINT) intact.
486
+ # TERM each process group, wait for them together rather than serially,
487
+ # then KILL the stragglers.
488
+ def reap(pids, grace: 5)
489
+ live = pids.compact.select { |pid| ChildProcesses.alive?(pid) }
490
+ return if live.empty?
491
+
492
+ live.each { |pid| ChildProcesses.signal(pid, "TERM") }
493
+ deadline = monotonic + grace
494
+ sleep(0.1) while live.any? { |pid| ChildProcesses.alive?(pid) } && monotonic < deadline
495
+ live.select { |pid| ChildProcesses.alive?(pid) }.each { |pid| ChildProcesses.signal(pid, "KILL") }
496
+ end
497
+
498
+ def child_pid(key) = @children.synchronize { @pids[key] }
499
+
500
+ # --- the key menu ---------------------------------------------------------
501
+
502
+ # io/console's getch holds full raw mode while blocked, which clears OPOST
503
+ # and stair-steps every log line we relay; stty -icanon -echo disables only
504
+ # line buffering and echo, leaving output processing (and Ctrl-C as SIGINT)
505
+ # intact. Builds no longer run on this thread, so the menu stays responsive
506
+ # while one is going — including `q`.
202
507
  def interact
203
- UI.note("press #{UI.bold("d")} desktop · #{UI.bold("i")} iOS Simulator · #{UI.bold("a")} Android · " \
204
- "#{UI.bold("b")} browser · #{UI.bold("l")} iOS logs · #{UI.bold("q")} quit",
205
- marker: "⌨", color: :cyan)
508
+ @dock.announce_keys
206
509
  saved_tty = `stty -g`.chomp
207
510
  system("stty", "-icanon", "-echo")
511
+ trap_exit_signals
208
512
  loop do
209
513
  case $stdin.getc
210
- when "d" then launch("desktop")
211
- when "i" then launch("ios")
212
- when "a" then launch("android")
213
- when "b" then launch("browser")
214
- when "l" then toggle_logs
514
+ when "d" then background(:desktop) { launch_desktop }
515
+ when "i" then background(:ios) { launch_ios }
516
+ when "a" then background(:android) { launch_android }
517
+ when "b" then launch_browser
518
+ when "l" then toggle_logs
519
+ when "r" then background(:web) { restart_server }
520
+ when "?" then @dock.toggle_help
215
521
  when "q", "\u0004", nil then break # q, Ctrl-D, EOF (Ctrl-C stays a real SIGINT)
216
522
  end
217
523
  end
@@ -219,33 +525,94 @@ module Everywhere
219
525
  system("stty", saved_tty) unless saved_tty.to_s.empty?
220
526
  end
221
527
 
222
- def wait_for_children
223
- (@server_thread || @desktop_thread)&.join
528
+ # The dock hides the cursor and the key loop puts the tty in -icanon
529
+ # -echo; a SIGTERM that skipped our ensure blocks would leave the user's
530
+ # shell in that state. exit(1) from a trap unwinds normally, which is all
531
+ # this needs to do — a handler must stay this small, because anything that
532
+ # takes a lock (Console, the dock) raises ThreadError in trap context.
533
+ def trap_exit_signals
534
+ %w[TERM HUP].each { |sig| Signal.trap(sig) { exit(1) } }
535
+ rescue ArgumentError
536
+ nil
224
537
  end
225
538
 
226
- def teardown
227
- [@server_pid, @desktop_pid, @logs_pid].compact.each { |pid| stop(pid) }
539
+ # --- headless -------------------------------------------------------------
540
+
541
+ # No terminal to take keystrokes from, so there's no menu to return to:
542
+ # wait for the builds, and make a failure an actual non-zero exit.
543
+ def run_headless
544
+ @pool.wait_all
545
+ failed = @pool.failed
546
+ UI.die!("#{failed.join(", ")} failed — see the log above") if failed.any?
547
+
548
+ watcher = @children.synchronize { @relays[:web] || @relays[:desktop] }
549
+ return watcher.join if watcher
550
+
551
+ # No pty (or no relay): fall back to waiting on the process itself.
552
+ pid = child_pid(:web) || child_pid(:desktop)
553
+ sleep(1) while pid && ChildProcesses.alive?(pid)
554
+ end
555
+
556
+ # --- failure --------------------------------------------------------------
557
+
558
+ # A build failing is news, not the end of the session. UI.die! has already
559
+ # printed the red ✗ at the point of failure, correctly interleaved with the
560
+ # build's own output, so a Fatal needs no second message — just the dock
561
+ # state and a reminder that everything else is still up.
562
+ def report_failure(name, error, stopping)
563
+ return if stopping
564
+
565
+ @dock.set(name, :failed, detail: failure_detail(name))
566
+ UI.bad("#{Dock::State::LABELS[name]} failed: #{error.message}") unless error.is_a?(Everywhere::Fatal)
567
+ return unless (key = KEY_FOR[name])
568
+
569
+ UI.note("everything else is still running — press #{UI.bold(key)} to try again",
570
+ marker: "⌨", color: :cyan)
228
571
  end
229
572
 
230
- def stop(pid)
231
- return unless alive?(pid)
573
+ # nil for targets with no build log of their own — the dock's ✗ already
574
+ # says "failed", so repeating the word adds nothing.
575
+ def failure_detail(name)
576
+ log = { ios: "dist/ios-build.log", android: "dist/android-build.log" }[name]
577
+ "see #{log}" if log
578
+ end
579
+
580
+ # --- teardown -------------------------------------------------------------
232
581
 
233
- begin
234
- Process.kill("TERM", -Process.getpgid(pid))
235
- rescue StandardError
236
- Process.kill("TERM", pid) rescue nil
582
+ # Teardown must never raise — it runs from an ensure, where an exception
583
+ # would mask whatever actually ended the session — and one failed step
584
+ # must not skip the rest, or a child survives the CLI that spawned it.
585
+ def teardown
586
+ @stopping = true
587
+ pids, relays = @children ? @children.synchronize { [@pids.values, @relays.values] } : [[], []]
588
+ [
589
+ -> { @dock&.close },
590
+ -> { @pool&.shutdown },
591
+ # TERM everything, then give it a moment and KILL whatever is left.
592
+ # The CLI exits the instant this returns, so anything still alive is
593
+ # orphaned — and a windowed shell is exactly the kind of child that
594
+ # doesn't drop dead on the first signal.
595
+ -> { reap(pids, grace: 2) },
596
+ -> { relays.each { |thread| thread.join(1) } }
597
+ ].each do |step|
598
+ step.call
599
+ rescue StandardError => e
600
+ Kernel.warn("teardown: #{e.class}: #{e.message}")
237
601
  end
238
602
  end
239
603
 
240
- def alive?(pid)
241
- Process.kill(0, pid)
242
- true
243
- rescue Errno::ESRCH
244
- false
245
- rescue Errno::EPERM
246
- true
604
+ # --- helpers --------------------------------------------------------------
605
+
606
+ def dist_log(name)
607
+ dir = File.expand_path("dist", @config.root)
608
+ FileUtils.mkdir_p(dir)
609
+ File.join(dir, name)
610
+ rescue StandardError
611
+ nil
247
612
  end
248
613
 
614
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
615
+
249
616
  def port_open?(port)
250
617
  TCPSocket.new("127.0.0.1", Integer(port)).close
251
618
  true