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.
- checksums.yaml +4 -4
- data/README.md +86 -2
- data/bridge/README.md +70 -1
- data/bridge/everywhere/bridge.js +151 -0
- data/bridge/everywhere/native.css +61 -0
- data/lib/everywhere/auth_handoff.rb +8 -2
- data/lib/everywhere/builders/desktop.rb +326 -0
- data/lib/everywhere/child_processes.rb +74 -0
- data/lib/everywhere/commands/build.rb +44 -5
- data/lib/everywhere/commands/dev.rb +426 -59
- data/lib/everywhere/commands/install.rb +20 -0
- data/lib/everywhere/commands/release.rb +2 -2
- data/lib/everywhere/config.rb +298 -4
- data/lib/everywhere/console.rb +117 -0
- data/lib/everywhere/desktop_assets.rb +150 -0
- data/lib/everywhere/dock/footer.rb +150 -0
- data/lib/everywhere/dock/screen.rb +114 -0
- data/lib/everywhere/dock/state.rb +59 -0
- data/lib/everywhere/dock.rb +238 -0
- data/lib/everywhere/emulator.rb +2 -2
- data/lib/everywhere/fatal.rb +20 -0
- data/lib/everywhere/line_pump.rb +89 -0
- data/lib/everywhere/log_filter.rb +37 -0
- data/lib/everywhere/native_helper.rb +38 -5
- data/lib/everywhere/paths.rb +62 -11
- data/lib/everywhere/relay.rb +77 -0
- data/lib/everywhere/shellout.rb +90 -15
- data/lib/everywhere/task_pool.rb +123 -0
- data/lib/everywhere/ui.rb +54 -11
- data/lib/everywhere/version.rb +1 -1
- data/support/desktop/README.md +121 -0
- data/support/{shell → desktop}/src-tauri/Cargo.lock +23 -0
- data/support/{shell → desktop}/src-tauri/Cargo.toml +19 -1
- data/support/{shell → desktop}/src-tauri/capabilities/default.json +7 -0
- data/support/desktop/src-tauri/gen/schemas/capabilities.json +1 -0
- data/support/desktop/src-tauri/src/extension_host.rs +53 -0
- data/support/desktop/src-tauri/src/extensions/mod.rs +39 -0
- data/support/{shell → desktop}/src-tauri/src/main.rs +355 -10
- data/support/{shell → desktop}/src-tauri/tauri.conf.json +2 -2
- data/support/{macos → release/macos}/notarize.sh +2 -2
- metadata +31 -17
- data/support/github/build.yml +0 -85
- data/support/shell/src-tauri/gen/schemas/capabilities.json +0 -1
- /data/support/{shell → desktop}/splash/index.html +0 -0
- /data/support/{shell → desktop}/src-tauri/build.rs +0 -0
- /data/support/{shell → desktop}/src-tauri/gen/schemas/acl-manifests.json +0 -0
- /data/support/{shell → desktop}/src-tauri/gen/schemas/desktop-schema.json +0 -0
- /data/support/{shell → desktop}/src-tauri/gen/schemas/macOS-schema.json +0 -0
- /data/support/{shell → desktop}/src-tauri/icons/icon.png +0 -0
- /data/support/{shell → desktop}/src-tauri/src/updater.rs +0 -0
- /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
|
-
|
|
44
|
-
|
|
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 :
|
|
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
|
-
|
|
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
|
-
@
|
|
110
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
199
|
-
#
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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"
|
|
211
|
-
when "i"
|
|
212
|
-
when "a"
|
|
213
|
-
when "b"
|
|
214
|
-
when "l"
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|