ruby_everywhere 0.3.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +339 -0
  3. data/bridge/README.md +70 -1
  4. data/bridge/everywhere/bridge.js +211 -3
  5. data/bridge/everywhere/native.css +61 -0
  6. data/lib/everywhere/android_resources.rb +217 -0
  7. data/lib/everywhere/android_sdk.rb +330 -0
  8. data/lib/everywhere/asset_catalog.rb +185 -0
  9. data/lib/everywhere/auth_handoff.rb +404 -0
  10. data/lib/everywhere/auth_token.rb +118 -0
  11. data/lib/everywhere/builders/android.rb +844 -0
  12. data/lib/everywhere/builders/desktop.rb +326 -0
  13. data/lib/everywhere/builders/ios.rb +40 -3
  14. data/lib/everywhere/child_processes.rb +74 -0
  15. data/lib/everywhere/commands/build.rb +95 -14
  16. data/lib/everywhere/commands/clean.rb +10 -5
  17. data/lib/everywhere/commands/dev.rb +462 -57
  18. data/lib/everywhere/commands/doctor.rb +89 -3
  19. data/lib/everywhere/commands/install.rb +21 -1
  20. data/lib/everywhere/commands/logs.rb +117 -8
  21. data/lib/everywhere/commands/release.rb +2 -2
  22. data/lib/everywhere/config.rb +703 -13
  23. data/lib/everywhere/console.rb +117 -0
  24. data/lib/everywhere/desktop_assets.rb +150 -0
  25. data/lib/everywhere/dock/footer.rb +150 -0
  26. data/lib/everywhere/dock/screen.rb +114 -0
  27. data/lib/everywhere/dock/state.rb +59 -0
  28. data/lib/everywhere/dock.rb +238 -0
  29. data/lib/everywhere/emulator.rb +405 -0
  30. data/lib/everywhere/engine.rb +12 -0
  31. data/lib/everywhere/fatal.rb +20 -0
  32. data/lib/everywhere/icon.rb +196 -0
  33. data/lib/everywhere/line_pump.rb +89 -0
  34. data/lib/everywhere/log_filter.rb +111 -0
  35. data/lib/everywhere/native_helper.rb +39 -9
  36. data/lib/everywhere/omniauth.rb +88 -0
  37. data/lib/everywhere/paths.rb +109 -11
  38. data/lib/everywhere/relay.rb +77 -0
  39. data/lib/everywhere/shellout.rb +90 -15
  40. data/lib/everywhere/task_pool.rb +123 -0
  41. data/lib/everywhere/ui.rb +54 -11
  42. data/lib/everywhere/version.rb +1 -1
  43. data/support/desktop/README.md +121 -0
  44. data/support/{shell → desktop}/src-tauri/Cargo.lock +23 -0
  45. data/support/{shell → desktop}/src-tauri/Cargo.toml +19 -1
  46. data/support/{shell → desktop}/src-tauri/capabilities/default.json +7 -0
  47. data/support/desktop/src-tauri/gen/schemas/capabilities.json +1 -0
  48. data/support/desktop/src-tauri/src/extension_host.rs +53 -0
  49. data/support/desktop/src-tauri/src/extensions/mod.rs +39 -0
  50. data/support/{shell → desktop}/src-tauri/src/main.rs +355 -10
  51. data/support/{shell → desktop}/src-tauri/tauri.conf.json +2 -2
  52. data/support/mobile/android/README.md +217 -0
  53. data/support/mobile/android/app/build.gradle.kts +156 -0
  54. data/support/mobile/android/app/everywhere.properties +8 -0
  55. data/support/mobile/android/app/native-packages.gradle.kts +16 -0
  56. data/support/mobile/android/app/proguard-rules.pro +21 -0
  57. data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
  58. data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
  59. data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
  60. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
  61. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
  62. data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
  63. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
  64. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
  65. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
  66. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
  67. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
  68. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
  69. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
  70. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
  71. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
  72. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
  73. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
  74. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
  75. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
  76. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
  77. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
  78. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
  79. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
  80. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
  81. data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
  82. data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
  83. data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
  84. data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
  85. data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
  86. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
  87. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
  88. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
  89. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
  90. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
  91. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  92. data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
  93. data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
  94. data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
  95. data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
  96. data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
  97. data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
  98. data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
  99. data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
  100. data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
  101. data/support/mobile/android/build.gradle.kts +8 -0
  102. data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  103. data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
  104. data/support/mobile/android/gradle.properties +14 -0
  105. data/support/mobile/android/gradlew +251 -0
  106. data/support/mobile/android/gradlew.bat +94 -0
  107. data/support/mobile/android/settings.gradle.kts +28 -0
  108. data/support/mobile/ios/App/AppDelegate.swift +5 -0
  109. data/support/mobile/ios/App/AuthFlow.swift +101 -0
  110. data/support/mobile/ios/App/EverywhereConfig.swift +84 -0
  111. data/support/mobile/ios/App/SceneDelegate.swift +103 -0
  112. data/support/mobile/ios/App.xcodeproj/project.pbxproj +4 -0
  113. data/support/mobile/ios/README.md +4 -1
  114. data/support/{macos → release/macos}/notarize.sh +2 -2
  115. metadata +97 -17
  116. data/support/github/build.yml +0 -85
  117. data/support/shell/src-tauri/gen/schemas/capabilities.json +0 -1
  118. /data/support/{shell → desktop}/splash/index.html +0 -0
  119. /data/support/{shell → desktop}/src-tauri/build.rs +0 -0
  120. /data/support/{shell → desktop}/src-tauri/gen/schemas/acl-manifests.json +0 -0
  121. /data/support/{shell → desktop}/src-tauri/gen/schemas/desktop-schema.json +0 -0
  122. /data/support/{shell → desktop}/src-tauri/gen/schemas/macOS-schema.json +0 -0
  123. /data/support/{shell → desktop}/src-tauri/icons/icon.png +0 -0
  124. /data/support/{shell → desktop}/src-tauri/src/updater.rs +0 -0
  125. /data/support/{macos → release/macos}/entitlements.plist +0 -0
@@ -3,10 +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"
12
+ require_relative "../builders/android"
13
+ require_relative "../builders/desktop"
9
14
  require_relative "../simulator"
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"
10
22
  require_relative "logs"
11
23
 
12
24
  module Everywhere
@@ -18,23 +30,49 @@ module Everywhere
18
30
  #
19
31
  # Target flags are additive (--desktop --ios --browser opens all three);
20
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.
21
39
  class Dev < Dry::CLI::Command
22
40
  desc "Run the dev server with native shells on demand (live reload, no packaging)"
23
41
 
42
+ IOS_TARGET = "ios-arm64"
43
+ ANDROID_TARGET = "android-arm64"
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
+
24
48
  option :port, default: "3000", desc: "Dev server port"
25
49
  option :shell_dir, default: nil, desc: "Path to the shell's src-tauri directory"
26
- option :target, default: nil, desc: "Single shell to open: macos (desktop) or ios (Simulator)"
50
+ option :target, default: nil, desc: "Single shell to open: macos (desktop), ios (Simulator) or android (emulator)"
27
51
  option :desktop, type: :boolean, default: false, desc: "Open the desktop shell"
28
52
  option :ios, type: :boolean, default: false, desc: "Open the iOS Simulator shell"
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)"
29
55
  option :browser, type: :boolean, default: false, desc: "Open the app in the default browser"
56
+ option :dev_url, default: nil, desc: "Explicit URL for the device to load (e.g. a LAN address for a physical Android device)"
30
57
 
31
- def call(port: "3000", shell_dir: nil, target: nil, desktop: false, ios: false, browser: false, **)
58
+ def call(port: "3000", shell_dir: nil, target: nil, desktop: false, ios: false, android: false,
59
+ mobile: false, browser: false, dev_url: nil, **)
32
60
  @shell_dir = shell_dir
33
- targets = requested_targets(target, desktop: desktop, ios: ios, browser: browser)
34
- framework, @config = detect_app
61
+ @port = port
62
+ @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
67
+ targets = requested_targets(target, desktop: desktop, ios: ios, android: android,
68
+ mobile: mobile, browser: browser)
69
+ @framework, @config = detect_app
35
70
 
36
- start_server(framework, port)
37
- @dev_url = if framework
71
+ @pool = TaskPool.new(on_error: method(:report_failure))
72
+ @dock = Dock.open(dock_targets(targets), keys: $stdin.tty?)
73
+
74
+ start_server(@framework, port)
75
+ @dev_url = if @framework
38
76
  "http://127.0.0.1:#{port}#{@config.entry_path}"
39
77
  else
40
78
  "#{@config.remote_url}#{@config.entry_path}"
@@ -45,7 +83,7 @@ module Everywhere
45
83
  targets << "desktop" if targets.empty? && !$stdin.tty?
46
84
  targets.each { |t| launch(t) }
47
85
 
48
- $stdin.tty? ? interact : wait_for_children
86
+ $stdin.tty? ? interact : run_headless
49
87
  rescue Interrupt
50
88
  nil
51
89
  ensure
@@ -54,14 +92,28 @@ module Everywhere
54
92
 
55
93
  private
56
94
 
57
- def requested_targets(target, desktop:, ios:, browser:)
95
+ # --target names ONE shell; the boolean flags are additive. Desktop is the
96
+ # fallback for any --target that isn't a mobile os, which keeps
97
+ # `--target macos-arm64` (and a bare `--target`) meaning what it always did.
98
+ # --mobile is exactly --ios --android.
99
+ def requested_targets(target, desktop:, ios:, android:, mobile:, browser:)
100
+ target_os = target && Everywhere::Config.os_of(target)
58
101
  targets = []
59
- targets << "desktop" if desktop || (target && Everywhere::Config.os_of(target) != "ios")
60
- targets << "ios" if ios || (target && Everywhere::Config.os_of(target) == "ios")
102
+ targets << "desktop" if desktop || (target && !%w[ios android].include?(target_os))
103
+ targets << "ios" if ios || mobile || target_os == "ios"
104
+ targets << "android" if android || mobile || target_os == "android"
61
105
  targets << "browser" if browser
62
106
  targets
63
107
  end
64
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
+
65
117
  # The framework (nil for remote-only roots) and config. A root with
66
118
  # neither a framework nor remote.url is a genuine error.
67
119
  def detect_app
@@ -78,6 +130,8 @@ module Everywhere
78
130
  [framework, config]
79
131
  end
80
132
 
133
+ # --- the dev server -------------------------------------------------------
134
+
81
135
  def start_server(framework, port)
82
136
  unless framework
83
137
  # Remote-mode app with no local code (just everywhere.yml): nothing
@@ -89,33 +143,220 @@ module Everywhere
89
143
  if port_open?(port)
90
144
  UI.warn "something is ALREADY RUNNING on port #{port} — attaching to it instead of starting"
91
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)")
92
147
  else
93
148
  UI.step("starting #{UI.bold(framework.name)} dev server #{UI.dim("(" + framework.dev_command + ")")}")
94
- @server_pid = Shellout.spawn({ "PORT" => port.to_s }, framework.dev_command, chdir: framework.root)
95
- @server_thread = Process.detach(@server_pid)
149
+ @dock.set(:web, :booting)
150
+ supervise(:web, { "PORT" => port.to_s }, framework.dev_command, chdir: framework.root)
96
151
  wait_for_port(port)
152
+ @dock.set(:web, :running, detail: ":#{port}")
97
153
  end
98
154
  end
99
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
+
100
170
  def launch(target)
101
171
  case target
102
- when "ios" then launch_ios
172
+ when "ios" then background(:ios) { launch_ios }
173
+ when "android" then background(:android) { launch_android }
103
174
  when "browser" then launch_browser
104
- else launch_desktop
175
+ else background(:desktop) { launch_desktop }
105
176
  end
106
177
  end
107
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
+
108
274
  def launch_desktop
109
- if @desktop_pid && alive?(@desktop_pid)
110
- 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")
111
284
  return
112
285
  end
113
- dir = @shell_dir || Everywhere::Paths.shell_dir!
114
- UI.step("opening desktop shell → #{UI.cyan(@dev_url)}")
115
- @desktop_pid = Shellout.spawn({ "NATIVE_DEV_URL" => @dev_url, "NATIVE_CONFIG" => @config.to_shell_json,
116
- "CARGO_TARGET_DIR" => Everywhere::Paths.cargo_target_dir },
117
- "cargo run", chdir: dir)
118
- @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(" && ")
119
360
  end
120
361
 
121
362
  # Build the shell (Debug, warm after the first run), put it on a booted
@@ -124,14 +365,43 @@ module Everywhere
124
365
  # covers plain http for local networking. Pressing i again rebuilds and
125
366
  # relaunches, so it doubles as a native-shell reload.
126
367
  def launch_ios
127
- 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)
128
370
  .build!(dist_dir: File.expand_path("dist", @config.root), dev: true)
129
371
 
372
+ @dock.set(:ios, :booting, detail: "Simulator")
130
373
  udid = Simulator.boot_default!
374
+ @dock.set(:ios, :installing)
131
375
  UI.step("installing #{UI.bold(File.basename(app))}")
132
376
  Simulator.install(udid, app)
133
377
  UI.step("launching → #{UI.cyan(@dev_url)}")
134
- 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")
381
+ end
382
+
383
+ # The Android counterpart of launch_ios: build Debug (warm after the first
384
+ # run), put it on a booted emulator and launch it. Unlike the Simulator,
385
+ # which shares the Mac's loopback, an Android device reaches the host
386
+ # through `adb reverse` — so the URL is resolved per-device rather than
387
+ # reused from @dev_url (see Emulator.dev_url for the fallback ladder).
388
+ # Pressing a again rebuilds and relaunches, so it doubles as a reload.
389
+ def launch_android
390
+ @dock.set(:android, :booting, detail: "emulator")
391
+ serial = Emulator.boot_default!
392
+ url = Emulator.dev_url(@dev_url, serial: serial, port: @port, override: @dev_url_override)
393
+
394
+ @dock.set(:android, :building)
395
+ app = Builders::Android.new(config: @config, root: @config.root, target: ANDROID_TARGET)
396
+ .build!(dist_dir: File.expand_path("dist", @config.root), dev: true, dev_url: url)
397
+
398
+ application_id = @config.bundle_id(target: ANDROID_TARGET)
399
+ @dock.set(:android, :installing)
400
+ UI.step("installing #{UI.bold(File.basename(app))}")
401
+ Emulator.install(serial, app, application_id: application_id)
402
+ UI.step("launching → #{UI.cyan(url)}")
403
+ Emulator.launch(serial, application_id)
404
+ @dock.set(:android, :running, detail: serial)
135
405
  end
136
406
 
137
407
  def launch_browser
@@ -140,12 +410,12 @@ module Everywhere
140
410
  Shellout.run?(opener, @dev_url, quiet: true)
141
411
  end
142
412
 
143
- # Toggle the `every logs --ios` stream, interleaved with the server
144
- # output on this same terminal.
413
+ # Toggle the `every logs --ios` stream, tagged and interleaved with the
414
+ # server output on this same terminal.
145
415
  def toggle_logs
146
- if @logs_pid && alive?(@logs_pid)
147
- stop(@logs_pid)
148
- @logs_pid = nil
416
+ pid = child_pid(:logs)
417
+ if pid && ChildProcesses.alive?(pid)
418
+ stop_child(:logs)
149
419
  UI.step("stopped iOS Simulator logs")
150
420
  return
151
421
  end
@@ -156,24 +426,98 @@ module Everywhere
156
426
  return
157
427
  end
158
428
  UI.step("watching iOS Simulator logs #{UI.dim("(press l again to stop)")}")
159
- @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
160
454
  end
161
455
 
162
- # Key menu. io/console's getch holds full raw mode while blocked, which
163
- # clears OPOST and stair-steps every log line the server prints; stty
164
- # -icanon -echo disables only line buffering and echo, leaving output
165
- # processing (and Ctrl-C as SIGINT) intact.
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)
484
+ end
485
+
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`.
166
507
  def interact
167
- UI.note("press #{UI.bold("d")} desktop · #{UI.bold("i")} iOS Simulator · #{UI.bold("b")} browser · " \
168
- "#{UI.bold("l")} iOS logs · #{UI.bold("q")} quit", marker: "⌨", color: :cyan)
508
+ @dock.announce_keys
169
509
  saved_tty = `stty -g`.chomp
170
510
  system("stty", "-icanon", "-echo")
511
+ trap_exit_signals
171
512
  loop do
172
513
  case $stdin.getc
173
- when "d" then launch("desktop")
174
- when "i" then launch("ios")
175
- when "b" then launch("browser")
176
- 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
177
521
  when "q", "\u0004", nil then break # q, Ctrl-D, EOF (Ctrl-C stays a real SIGINT)
178
522
  end
179
523
  end
@@ -181,33 +525,94 @@ module Everywhere
181
525
  system("stty", saved_tty) unless saved_tty.to_s.empty?
182
526
  end
183
527
 
184
- def wait_for_children
185
- (@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
186
537
  end
187
538
 
188
- def teardown
189
- [@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)
190
554
  end
191
555
 
192
- def stop(pid)
193
- return unless alive?(pid)
556
+ # --- failure --------------------------------------------------------------
194
557
 
195
- begin
196
- Process.kill("TERM", -Process.getpgid(pid))
197
- rescue StandardError
198
- Process.kill("TERM", pid) rescue nil
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)
571
+ end
572
+
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 -------------------------------------------------------------
581
+
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}")
199
601
  end
200
602
  end
201
603
 
202
- def alive?(pid)
203
- Process.kill(0, pid)
204
- true
205
- rescue Errno::ESRCH
206
- false
207
- rescue Errno::EPERM
208
- 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
209
612
  end
210
613
 
614
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
615
+
211
616
  def port_open?(port)
212
617
  TCPSocket.new("127.0.0.1", Integer(port)).close
213
618
  true