ruby_everywhere 0.6.0 → 0.8.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/exe/every +2 -2
  3. data/exe/rbe +2 -2
  4. data/lib/everywhere/agents_guide.rb +3 -1
  5. data/lib/everywhere/blake2b.rb +17 -1
  6. data/lib/everywhere/boot.rb +30 -6
  7. data/lib/everywhere/builders/android.rb +152 -64
  8. data/lib/everywhere/builders/base.rb +53 -0
  9. data/lib/everywhere/builders/desktop.rb +32 -40
  10. data/lib/everywhere/builders/ios.rb +268 -36
  11. data/lib/everywhere/builders/native_sources.rb +38 -0
  12. data/lib/everywhere/child_processes.rb +4 -4
  13. data/lib/everywhere/child_supervision.rb +172 -0
  14. data/lib/everywhere/cli.rb +2 -0
  15. data/lib/everywhere/clock.rb +12 -0
  16. data/lib/everywhere/commands/build.rb +132 -66
  17. data/lib/everywhere/commands/clean.rb +8 -3
  18. data/lib/everywhere/commands/dev.rb +91 -242
  19. data/lib/everywhere/commands/doctor.rb +138 -21
  20. data/lib/everywhere/commands/install.rb +49 -8
  21. data/lib/everywhere/commands/platform/auth_status.rb +1 -0
  22. data/lib/everywhere/commands/platform/build.rb +141 -33
  23. data/lib/everywhere/commands/platform/login.rb +20 -6
  24. data/lib/everywhere/commands/platform/logout.rb +1 -0
  25. data/lib/everywhere/commands/platform/runner.rb +170 -25
  26. data/lib/everywhere/commands/preview.rb +286 -0
  27. data/lib/everywhere/commands/publish.rb +22 -2
  28. data/lib/everywhere/commands/release.rb +155 -35
  29. data/lib/everywhere/commands/shell_dir.rb +4 -2
  30. data/lib/everywhere/commands/updates_keygen.rb +25 -1
  31. data/lib/everywhere/config/app.rb +126 -0
  32. data/lib/everywhere/config/auth.rb +108 -0
  33. data/lib/everywhere/config/data.rb +50 -0
  34. data/lib/everywhere/config/deep_linking.rb +107 -0
  35. data/lib/everywhere/config/desktop_ui.rb +153 -0
  36. data/lib/everywhere/config/mobile.rb +211 -0
  37. data/lib/everywhere/config/native_desktop.rb +168 -0
  38. data/lib/everywhere/config/native_mobile.rb +337 -0
  39. data/lib/everywhere/config/shell.rb +57 -0
  40. data/lib/everywhere/config/updates.rb +63 -0
  41. data/lib/everywhere/config.rb +59 -1367
  42. data/lib/everywhere/console.rb +2 -1
  43. data/lib/everywhere/desktop_dev_app.rb +138 -0
  44. data/lib/everywhere/dock/state.rb +3 -1
  45. data/lib/everywhere/engine.rb +24 -0
  46. data/lib/everywhere/entrypoint.rb +24 -0
  47. data/lib/everywhere/error.rb +8 -0
  48. data/lib/everywhere/framework.rb +23 -6
  49. data/lib/everywhere/host.rb +11 -0
  50. data/lib/everywhere/ignore.rb +13 -5
  51. data/lib/everywhere/jump.rb +121 -0
  52. data/lib/everywhere/line_pump.rb +3 -1
  53. data/lib/everywhere/minisign.rb +1 -0
  54. data/lib/everywhere/mobile_config_endpoint.rb +47 -0
  55. data/lib/everywhere/mobile_configs_controller.rb +46 -0
  56. data/lib/everywhere/native_platform.rb +44 -0
  57. data/lib/everywhere/paths.rb +36 -13
  58. data/lib/everywhere/platform/client.rb +46 -7
  59. data/lib/everywhere/platform/credentials.rb +18 -8
  60. data/lib/everywhere/platform/snapshot.rb +12 -0
  61. data/lib/everywhere/plist.rb +17 -0
  62. data/lib/everywhere/png.rb +1 -0
  63. data/lib/everywhere/raw_tty.rb +51 -0
  64. data/lib/everywhere/receipt.rb +55 -10
  65. data/lib/everywhere/s3.rb +1 -0
  66. data/lib/everywhere/shell_pages.rb +109 -0
  67. data/lib/everywhere/shellout.rb +19 -0
  68. data/lib/everywhere/simulator.rb +12 -1
  69. data/lib/everywhere/tab_filter.rb +36 -0
  70. data/lib/everywhere/task_pool.rb +3 -4
  71. data/lib/everywhere/ui.rb +6 -1
  72. data/lib/everywhere/version.rb +6 -4
  73. data/lib/everywhere.rb +1 -2
  74. data/support/mobile/android/app/build.gradle.kts +29 -1
  75. data/support/mobile/ios/App/EverywhereConfig.swift +17 -5
  76. data/support/mobile/ios/App/SceneDelegate.swift +75 -8
  77. data/support/mobile/ios/App.xcodeproj/project.pbxproj +2 -4
  78. data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +0 -1
  79. data/support/mobile/ios/README.md +13 -6
  80. data/support/release/macos/notarize.sh +3 -0
  81. metadata +40 -1
@@ -1,13 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "dry/cli"
4
- require "socket"
5
4
  require "shellwords"
6
5
  require "fileutils"
7
6
  require_relative "../shellout"
8
- require_relative "../paths"
9
- require_relative "../icon"
10
- require_relative "../desktop_assets"
7
+ require_relative "../host"
8
+ require_relative "../desktop_dev_app"
9
+ require_relative "../raw_tty"
11
10
  require_relative "../builders/ios"
12
11
  require_relative "../builders/android"
13
12
  require_relative "../builders/desktop"
@@ -15,8 +14,8 @@ require_relative "../simulator"
15
14
  require_relative "../emulator"
16
15
  require_relative "../console"
17
16
  require_relative "../child_processes"
17
+ require_relative "../child_supervision"
18
18
  require_relative "../task_pool"
19
- require_relative "../relay"
20
19
  require_relative "../dock"
21
20
  require_relative "../log_filter"
22
21
  require_relative "logs"
@@ -37,6 +36,8 @@ module Everywhere
37
36
  # Dock keeps the keymap and every target's state pinned to the bottom of the
38
37
  # terminal while the logs scroll above it.
39
38
  class Dev < Dry::CLI::Command
39
+ include ChildSupervision
40
+
40
41
  desc "Run the dev server with native shells on demand (live reload, no packaging)"
41
42
 
42
43
  IOS_TARGET = "ios-arm64"
@@ -58,12 +59,9 @@ module Everywhere
58
59
  def call(port: "3000", shell_dir: nil, target: nil, desktop: false, ios: false, android: false,
59
60
  mobile: false, browser: false, dev_url: nil, **)
60
61
  @shell_dir = shell_dir
61
- @port = port
62
+ port = @port = validate_port!(port)
62
63
  @dev_url_override = dev_url
63
- # Children are spawned from pool threads as well as the main one.
64
- @pids = {}
65
- @relays = {}
66
- @children = Mutex.new
64
+ init_children!
67
65
  targets = requested_targets(target, desktop: desktop, ios: ios, android: android,
68
66
  mobile: mobile, browser: browser)
69
67
  @framework, @config = detect_app
@@ -127,6 +125,12 @@ module Everywhere
127
125
  UI.die!("no supported framework here (Rails, Hanami, Sinatra) and no remote.url in " \
128
126
  "config/everywhere.yml — nothing to run")
129
127
  end
128
+
129
+ # The bundle id keys every per-app dir under ~/.rubyeverywhere that the
130
+ # session stages into, so it's checked before anything is written.
131
+ errors = config.identity_errors
132
+ UI.die!("app: in config/everywhere.yml:\n#{errors.join("\n")}") unless errors.empty?
133
+
130
134
  [framework, config]
131
135
  end
132
136
 
@@ -147,8 +151,8 @@ module Everywhere
147
151
  else
148
152
  UI.step("starting #{UI.bold(framework.name)} dev server #{UI.dim("(" + framework.dev_command + ")")}")
149
153
  @dock.set(:web, :booting)
150
- supervise(:web, { "PORT" => port.to_s }, framework.dev_command, chdir: framework.root)
151
- wait_for_port(port)
154
+ pid = supervise(:web, { "PORT" => port.to_s }, framework.dev_command, chdir: framework.root)
155
+ wait_for_port(port, pid: pid)
152
156
  @dock.set(:web, :running, detail: ":#{port}")
153
157
  end
154
158
  end
@@ -186,6 +190,15 @@ module Everywhere
186
190
  end
187
191
  end
188
192
 
193
+ # Everything the running desktop shell is staged out of: the assets, the
194
+ # Dock icon, the throwaway .app it runs from. Memoized because a restart
195
+ # restages all of it against the same config.
196
+ def desktop_dev_app = @desktop_dev_app ||= DesktopDevApp.new(config: @config)
197
+
198
+ def dev_bundle_name = desktop_dev_app.dev_bundle_name
199
+
200
+ def dev_info_plist = desktop_dev_app.dev_info_plist
201
+
189
202
  # `d` relaunches, the same way `i` and `a` do — quit the running window and
190
203
  # open a fresh one. Refusing with "already running" made the desktop the
191
204
  # odd one out, and left no way to restart a shell that had wedged or was
@@ -193,85 +206,13 @@ module Everywhere
193
206
  #
194
207
  # The rebuild is free either way: a warm `cargo run` re-checks the crate
195
208
  # 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
-
274
209
  def launch_desktop
210
+ # Checked before anything is staged: without cargo the build dies deep
211
+ # in a relayed `sh: cargo: command not found`, after a dock entry and a
212
+ # half-written dev bundle say a shell is on its way.
213
+ Shellout.ensure_tool!("cargo", "the desktop shell is built with Rust — install it from " \
214
+ "https://rustup.rs, then press d again")
215
+
275
216
  # A bad `window:` or a broken assets/ tree marks the desktop red and
276
217
  # leaves the dev server and the mobile shells alone — the same deal
277
218
  # every other target gets when its build fails. Dying here would take
@@ -284,7 +225,7 @@ module Everywhere
284
225
  return
285
226
  end
286
227
 
287
- assets_dir, asset_errors = stage_desktop_assets
228
+ assets_dir, asset_errors = desktop_dev_app.stage_desktop_assets
288
229
  unless asset_errors.empty?
289
230
  asset_errors.each { |error| UI.warn(error) }
290
231
  @dock.set(:desktop, :failed, detail: "native/desktop/assets")
@@ -320,10 +261,10 @@ module Everywhere
320
261
  supervise(:desktop,
321
262
  { "NATIVE_DEV_URL" => @dev_url, "NATIVE_CONFIG" => @config.to_shell_json,
322
263
  "NATIVE_ASSETS_DIR" => assets_dir,
323
- "NATIVE_ICON_PATH" => stage_desktop_icon,
264
+ "NATIVE_ICON_PATH" => desktop_dev_app.stage_desktop_icon,
324
265
  "CARGO_TARGET_DIR" => prepared.target_dir,
325
266
  "CARGO_TERM_COLOR" => "always" },
326
- desktop_command(prepared), chdir: prepared.dir,
267
+ desktop_dev_app.desktop_command(prepared), chdir: prepared.dir,
327
268
  filter: LogFilter.for(:cargo), log: dist_log("desktop-dev.log"),
328
269
  # cargo colors its status lines, so the marker has to be
329
270
  # matched past the escape codes. "Finished" is cargo's last
@@ -334,37 +275,21 @@ module Everywhere
334
275
  })
335
276
  end
336
277
 
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(" && ")
360
- end
361
-
362
278
  # Build the shell (Debug, warm after the first run), put it on a booted
363
279
  # Simulator and hand it the dev URL. The Simulator shares the Mac's
364
280
  # loopback, so 127.0.0.1 works as-is; the template's ATS exception
365
281
  # covers plain http for local networking. Pressing i again rebuilds and
366
282
  # relaunches, so it doubles as a native-shell reload.
367
283
  def launch_ios
284
+ # xcodebuild and simctl are macOS-only, so `i` elsewhere can only ever
285
+ # produce a long build that fails at the first tool. Android stays
286
+ # available everywhere — its toolchain genuinely is cross-platform.
287
+ unless Host.darwin?
288
+ UI.warn("iOS builds need macOS and Xcode — #{UI.dim("nothing to do on this platform")}")
289
+ @dock.set(:ios, :idle, detail: "needs macOS")
290
+ return
291
+ end
292
+
368
293
  @dock.set(:ios, :building)
369
294
  app = Builders::Ios.new(config: @config, root: @config.root, target: IOS_TARGET)
370
295
  .build!(dist_dir: File.expand_path("dist", @config.root), dev: true)
@@ -406,7 +331,22 @@ module Everywhere
406
331
 
407
332
  def launch_browser
408
333
  UI.step("opening browser → #{UI.cyan(@dev_url)}")
409
- opener = RUBY_PLATFORM.include?("darwin") ? "open" : "xdg-open"
334
+ # `start` is a cmd builtin, not an executable, so it can't be probed or
335
+ # spawned directly; the empty "" is the window title argument, without
336
+ # which cmd reads the URL as one.
337
+ if Gem.win_platform?
338
+ system("cmd", "/c", "start", "", @dev_url)
339
+ return
340
+ end
341
+
342
+ opener = Host.darwin? ? "open" : "xdg-open"
343
+ # A headless Linux box (or WSL) has no xdg-open, and run? would just
344
+ # return nil — the key would look broken. Print the URL instead.
345
+ unless Shellout.tool?(opener)
346
+ UI.warn("couldn't find #{opener} — open #{UI.cyan(@dev_url)} yourself")
347
+ return
348
+ end
349
+
410
350
  Shellout.run?(opener, @dev_url, quiet: true)
411
351
  end
412
352
 
@@ -431,109 +371,51 @@ module Everywhere
431
371
 
432
372
  # --- child supervision ----------------------------------------------------
433
373
 
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
374
+ def spawn_child(env, cmd, chdir:)
375
+ return Shellout.spawn_pty(env, cmd, chdir: chdir) if Shellout.pty?
455
376
 
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
377
+ # Windows, or any platform without a pty: fall back to inherited fds.
378
+ # No tags and no dock framing, but everything else works.
379
+ [nil, Shellout.spawn(env, cmd, chdir: chdir)]
473
380
  end
474
381
 
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)
382
+ # The extra advice an `every dev` failure carries: this is the app's own
383
+ # bin/dev, run from the app's own checkout, so the two usual explanations
384
+ # are worth naming.
385
+ def wait_for_port(port, pid: nil, timeout: 60,
386
+ dead_hint: " (did `bundle install` run? does bin/dev's foreman exist?)",
387
+ slow_hint: " — it may just be slow to boot; check its output above")
388
+ super
484
389
  end
485
390
 
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") }
391
+ # Only report a death we didn't ask for. stop_child removes the pid
392
+ # BEFORE it signals, so a deliberate restart can never match here —
393
+ # what does match is a shell that quit or crashed on its own, which is
394
+ # exactly what the dock should show.
395
+ def on_child_exit(key, pid)
396
+ @dock.set(key, :stopped) if !@stopping && @children.synchronize { @pids[key] } == pid
496
397
  end
497
398
 
498
- def child_pid(key) = @children.synchronize { @pids[key] }
499
-
500
399
  # --- the key menu ---------------------------------------------------------
501
400
 
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
401
+ # Builds no longer run on this thread, so the menu stays responsive
506
402
  # while one is going — including `q`.
507
403
  def interact
508
404
  @dock.announce_keys
509
- saved_tty = `stty -g`.chomp
510
- system("stty", "-icanon", "-echo")
511
- trap_exit_signals
512
- loop do
513
- case $stdin.getc
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
521
- when "q", "\u0004", nil then break # q, Ctrl-D, EOF (Ctrl-C stays a real SIGINT)
405
+ RawTty.with do
406
+ loop do
407
+ case $stdin.getc
408
+ when "d" then background(:desktop) { launch_desktop }
409
+ when "i" then background(:ios) { launch_ios }
410
+ when "a" then background(:android) { launch_android }
411
+ when "b" then launch_browser
412
+ when "l" then toggle_logs
413
+ when "r" then background(:web) { restart_server }
414
+ when "?" then @dock.toggle_help
415
+ when "q", "\u0004", nil then break # q, Ctrl-D, EOF (Ctrl-C stays a real SIGINT)
416
+ end
522
417
  end
523
418
  end
524
- ensure
525
- system("stty", saved_tty) unless saved_tty.to_s.empty?
526
- end
527
-
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
537
419
  end
538
420
 
539
421
  # --- headless -------------------------------------------------------------
@@ -579,26 +461,8 @@ module Everywhere
579
461
 
580
462
  # --- teardown -------------------------------------------------------------
581
463
 
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
464
  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}")
601
- end
465
+ teardown_children(before: [-> { @dock&.close }, -> { @pool&.shutdown }])
602
466
  end
603
467
 
604
468
  # --- helpers --------------------------------------------------------------
@@ -610,21 +474,6 @@ module Everywhere
610
474
  rescue StandardError
611
475
  nil
612
476
  end
613
-
614
- def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
615
-
616
- def port_open?(port)
617
- TCPSocket.new("127.0.0.1", Integer(port)).close
618
- true
619
- rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT
620
- false
621
- end
622
-
623
- def wait_for_port(port, timeout: 60)
624
- deadline = Time.now + timeout
625
- sleep 0.5 until port_open?(port) || Time.now > deadline
626
- UI.die!("dev server never came up on port #{port}") unless port_open?(port)
627
- end
628
477
  end
629
478
  end
630
479
  end
@@ -2,49 +2,167 @@
2
2
 
3
3
  require "dry/cli"
4
4
  require_relative "../shellout"
5
+ require_relative "../host"
5
6
  require_relative "../paths"
7
+ require_relative "../config"
6
8
  require_relative "../android_sdk"
7
9
 
8
10
  module Everywhere
9
11
  module Commands
10
- # Check that this machine can build native apps. Desktop checks always
11
- # run; the iOS and Android sections join in when the app's build.targets
12
- # include such a target, or explicitly via --target ios-* / android-*.
12
+ # Check that this machine can build native apps. Every section always
13
+ # shows, so one run answers "what can this machine build?" but only the
14
+ # sections the app's build.targets declare (or --target names) gate the
15
+ # exit status: failing doctor over a toolchain the app doesn't use would
16
+ # be a lie, the same reasoning as the cmdline-tools warning below.
13
17
  class Doctor < Dry::CLI::Command
14
18
  desc "Check the toolchain (rbe-tebako, rust, brew deps; Xcode for iOS, SDK/JDK for Android)"
15
19
 
16
- option :root, default: ".", desc: "App root (its build.targets pick the sections)"
17
- option :target, default: nil, desc: "Also check a specific target's toolchain (e.g. ios-arm64)"
20
+ option :root, default: ".", desc: "App root (its build.targets pick which sections are required)"
21
+ option :target, default: nil, desc: "Require a specific target's toolchain (e.g. ios-arm64)"
18
22
 
19
23
  def call(root: ".", target: nil, **)
20
- ok = true
21
- ok &= check("rbe-tebako installed") { Shellout.run?("rbe-tebako help > /dev/null 2>&1") }
22
- ok &= check("cargo installed") { Shellout.run?("cargo --version > /dev/null 2>&1") }
23
- ok &= check("homebrew bison") { !`brew --prefix bison 2>/dev/null`.strip.empty? }
24
+ ok = config_check(root)
24
25
 
25
- ok &= ios_checks if ios_wanted?(root, target)
26
- ok &= android_checks if android_wanted?(root, target)
26
+ @optional_gaps = []
27
+ sections = [
28
+ ["Desktop", desktop_wanted?(root, target), method(:desktop_checks)],
29
+ ["iOS", ios_wanted?(root, target), method(:ios_checks)],
30
+ ["Android", android_wanted?(root, target), method(:android_checks)]
31
+ ]
32
+ # The sections that gate the run are the ones the user came to fix, so
33
+ # they come first; the informational ones read as an appendix.
34
+ # partition, not sort_by — Ruby's sort isn't stable.
35
+ required_sections, info_sections = sections.partition { |_, required, _| required }
36
+ (required_sections + info_sections).each do |name, required, checks|
37
+ ok &= section(name, required: required) { |req| checks.call(req) }
38
+ end
27
39
 
28
40
  puts
29
- ok ? UI.success("ready to build") : UI.bad("fix the items above (see https://rubyeverywhere.com/docs/diy/requirements)")
41
+ if ok
42
+ note = @optional_gaps.any? ? " #{UI.dim("(#{@optional_gaps.join(" and ")} would need setup — see above)")}" : ""
43
+ UI.success("ready to build#{note}")
44
+ else
45
+ UI.bad("fix the items above (see https://rubyeverywhere.com/docs/diy/requirements)")
46
+ end
30
47
  exit(1) unless ok
31
48
  end
32
49
 
33
50
  private
34
51
 
52
+ # A section always renders; only a required one can fail the run. An
53
+ # optional section's gaps are remembered for the summary line instead.
54
+ def section(name, required:)
55
+ passed = yield(required)
56
+ @optional_gaps << name if !passed && !required
57
+ required ? passed : true
58
+ end
59
+
60
+ def section_label(name, required)
61
+ required ? name : "#{name} #{UI.dim("(not in build.targets — informational)")}"
62
+ end
63
+
64
+
65
+ # Desktop is the implicit default: an app with no build.targets presses
66
+ # for the host. Any non-mobile target (or --target) counts as desktop,
67
+ # mirroring how build/dev route targets.
68
+ def desktop_wanted?(root, target)
69
+ return true if target && !%w[ios android].include?(Everywhere::Config.os_of(target))
70
+
71
+ targets = config(root).targets
72
+ targets.empty? || targets.any? { |t| !%w[ios android].include?(Everywhere::Config.os_of(t)) }
73
+ rescue StandardError
74
+ true
75
+ end
76
+
77
+ def desktop_checks(required = true)
78
+ puts
79
+ UI.step(section_label("Desktop target", required))
80
+ # Mirrors the gate in `every build`: pressing is macOS-only today, so
81
+ # probing for its toolchain elsewhere would only mislead.
82
+ unless Host.darwin?
83
+ return fix_check("macOS", "desktop builds are macOS-only for now") { false } if required
84
+
85
+ UI.warn("desktop builds are macOS-only for now — skipped")
86
+ return false
87
+ end
88
+
89
+ ok = fix_check("rbe-tebako installed",
90
+ "it ships as a dependency of ruby_everywhere: `gem install rbe-tebako` " \
91
+ "(or reinstall the gem)") { Shellout.run?("rbe-tebako help > /dev/null 2>&1") }
92
+ ok &= fix_check("cargo installed", "install Rust: https://rustup.rs") do
93
+ Shellout.run?("cargo --version > /dev/null 2>&1")
94
+ end
95
+ ok &= fix_check(bison_label, bison_hint) { bison? }
96
+ ok
97
+ end
98
+
99
+ # macOS ships a bison far too old for tebako's build, so there the brew
100
+ # one is the only one that counts — and asking brew is the only way to see
101
+ # it, since it stays off PATH (keg-only). Everywhere else the package
102
+ # manager installs a current bison normally.
103
+ def bison?
104
+ return Shellout.tool?("bison") unless Host.darwin?
105
+
106
+ out, status = Shellout.capture("brew", "--prefix", "bison")
107
+ !status.nil? && status.success? && !out.strip.empty?
108
+ end
109
+
110
+ def bison_label = Host.darwin? ? "homebrew bison" : "bison"
111
+ def bison_hint = Host.darwin? ? "brew install bison" : "install bison with your package manager"
112
+
113
+ # Config.load raises on an unparseable everywhere.yml; doctor reports it
114
+ # as its first check and carries on with an empty config, so the machine
115
+ # that most needs the rest of the report still gets it. The load happens
116
+ # once, here, and every later reader gets what it produced.
117
+ def config_check(root)
118
+ return true unless File.exist?(File.join(File.expand_path(root), Everywhere::Config::FILE))
119
+
120
+ config(root)
121
+ if @config_error
122
+ UI.bad(@config_error)
123
+ return false
124
+ end
125
+
126
+ UI.ok(Everywhere::Config::FILE)
127
+ true
128
+ end
129
+
130
+ def config(root)
131
+ return @config if defined?(@config)
132
+
133
+ @config_error = nil
134
+ @config = begin
135
+ Everywhere::Config.load(File.expand_path(root))
136
+ rescue StandardError, SystemExit => e
137
+ @config_error = e.message
138
+ Everywhere::Config.new({}, File.expand_path(root))
139
+ end
140
+ end
141
+
35
142
  def ios_wanted?(root, target)
36
143
  return true if target && Everywhere::Config.os_of(target) == "ios"
37
144
 
38
- Everywhere::Config.load(File.expand_path(root)).targets
39
- .any? { |t| Everywhere::Config.os_of(t) == "ios" }
145
+ config(root).targets.any? { |t| Everywhere::Config.os_of(t) == "ios" }
40
146
  rescue StandardError
41
147
  false
42
148
  end
43
149
 
44
- def ios_checks
150
+ def ios_checks(required = true)
45
151
  puts
46
- UI.step("iOS target")
47
- ok = check("full Xcode selected") { Shellout.run?("xcodebuild -version > /dev/null 2>&1") }
152
+ UI.step(section_label("iOS target", required))
153
+ # Off macOS every tool below is guaranteed absent one honest line
154
+ # beats four unfixable ✗s.
155
+ unless Host.darwin?
156
+ return fix_check("macOS with Xcode", "iOS apps can only be built on a Mac") { false } if required
157
+
158
+ UI.warn("needs macOS and Xcode — skipped")
159
+ return false
160
+ end
161
+
162
+ ok = fix_check("full Xcode selected",
163
+ "install Xcode and run `sudo xcode-select -s /Applications/Xcode.app`") do
164
+ Shellout.run?("xcodebuild -version > /dev/null 2>&1")
165
+ end
48
166
  ok &= check("iOS Simulator SDK") do
49
167
  Shellout.run?("xcrun", "--sdk", "iphonesimulator", "--show-sdk-path", quiet: true)
50
168
  end
@@ -69,8 +187,7 @@ module Everywhere
69
187
  def android_wanted?(root, target)
70
188
  return true if target && Everywhere::Config.os_of(target) == "android"
71
189
 
72
- Everywhere::Config.load(File.expand_path(root)).targets
73
- .any? { |t| Everywhere::Config.os_of(t) == "android" }
190
+ config(root).targets.any? { |t| Everywhere::Config.os_of(t) == "android" }
74
191
  rescue StandardError
75
192
  false
76
193
  end
@@ -80,9 +197,9 @@ module Everywhere
80
197
  # without exporting any of them, so a `which adb` would report a broken
81
198
  # machine that in fact builds fine. Nothing auto-installs — SDK components
82
199
  # are gigabytes — so each failure carries the exact command to run.
83
- def android_checks
200
+ def android_checks(required = true)
84
201
  puts
85
- UI.step("Android target")
202
+ UI.step(section_label("Android target", required))
86
203
  sdk = Everywhere::AndroidSdk
87
204
  root = sdk.sdk_root
88
205