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
|
@@ -31,6 +31,7 @@ module Everywhere
|
|
|
31
31
|
when :tebako then tebako(line)
|
|
32
32
|
when :xcodebuild then xcodebuild(line)
|
|
33
33
|
when :gradle then gradle(line)
|
|
34
|
+
when :cargo then cargo(line)
|
|
34
35
|
end
|
|
35
36
|
end
|
|
36
37
|
|
|
@@ -227,6 +228,42 @@ module Everywhere
|
|
|
227
228
|
end
|
|
228
229
|
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
229
230
|
|
|
231
|
+
# --- `every dev`'s desktop shell (cargo) ----------------------------------
|
|
232
|
+
|
|
233
|
+
# A cold `cargo run` compiles ~300 crates, one "Compiling foo v0.1.0" line
|
|
234
|
+
# each, plus a progress bar it redraws several times a second. None of that
|
|
235
|
+
# is actionable while you're waiting on your own app — but warnings, errors
|
|
236
|
+
# and the moment the shell actually starts very much are.
|
|
237
|
+
#
|
|
238
|
+
# Unlike xcodebuild and Gradle (which we run with --console=plain), cargo is
|
|
239
|
+
# talking to a pty here and colors everything, so its status lines start
|
|
240
|
+
# with SGR codes and carry OSC-8 hyperlinks. Match on the stripped text;
|
|
241
|
+
# print the original, since that color is cargo's and worth keeping.
|
|
242
|
+
#
|
|
243
|
+
# The fall-through returns the line verbatim rather than nil, so it is NOT
|
|
244
|
+
# dimmed as background detail: once the shell is up, everything arriving on
|
|
245
|
+
# this stream is the app's own output, and the body of a Rust compile error
|
|
246
|
+
# is exactly what you need to read in full.
|
|
247
|
+
def cargo(line)
|
|
248
|
+
plain = UI.strip_ansi(line)
|
|
249
|
+
case plain
|
|
250
|
+
when /\A\s*error(\[E\d+\])?[:\[]/
|
|
251
|
+
" #{UI.red(UI.short_path(plain))}"
|
|
252
|
+
when /\A\s*warning: /
|
|
253
|
+
" #{UI.yellow(UI.short_path(plain))}"
|
|
254
|
+
when /\A\s*Compiling /
|
|
255
|
+
once(:compiling) { UI.note_line("compiling the desktop shell (first run is slow)") }
|
|
256
|
+
when /\A\s*(Downloaded|Downloading|Updating|Building \[|Blocking|Locking)/
|
|
257
|
+
:drop
|
|
258
|
+
when /\A\s*Finished\b/
|
|
259
|
+
UI.note_line("desktop shell compiled", marker: "✓", color: :green)
|
|
260
|
+
when /\A\s*Running\b/
|
|
261
|
+
UI.note_line("desktop shell running", marker: "✓", color: :green)
|
|
262
|
+
else
|
|
263
|
+
line
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
230
267
|
# Show a line only the first time its key is seen; :drop the repeats. Keeps a
|
|
231
268
|
# long notarization from scrolling a wall of identical "waiting" lines (and
|
|
232
269
|
# notarytool's thrice-echoed submission id from showing three times).
|
|
@@ -10,25 +10,52 @@ module Everywhere
|
|
|
10
10
|
# does on the client:
|
|
11
11
|
#
|
|
12
12
|
# <%= link_to "Install the app", … unless native_app? %>
|
|
13
|
-
# <div class="<%= "native-inset" if
|
|
13
|
+
# <div class="<%= "native-inset" if mobile_app? %>">
|
|
14
14
|
# <% if native_version && native_version >= Gem::Version.new("1.2") %>
|
|
15
15
|
#
|
|
16
16
|
# Detection is by User-Agent: the RubyEverywhere shells prepend
|
|
17
17
|
# "RubyEverywhere/<version> (<os>)" to Hotwire Native's own
|
|
18
18
|
# "Hotwire Native iOS/Android" marker. Included into all Rails views by
|
|
19
19
|
# Everywhere::Engine.
|
|
20
|
+
#
|
|
21
|
+
# Pick the narrowest one that's true of what you're branching on. `native_app?`
|
|
22
|
+
# means "not a browser tab" and covers the desktop shell; `mobile_app?` means
|
|
23
|
+
# a phone, with a tab bar and safe-area insets; `desktop_app?` means a window,
|
|
24
|
+
# with a title bar and a menu bar. Safe-area padding is mobile_app?; hiding a
|
|
25
|
+
# web nav in favour of native chrome is usually mobile_app? too — the desktop
|
|
26
|
+
# shell has no native navigation to replace it with.
|
|
20
27
|
module NativeHelper
|
|
21
|
-
# True inside any RubyEverywhere native shell
|
|
28
|
+
# True inside any RubyEverywhere native shell — iOS, Android or desktop.
|
|
29
|
+
# Mirrors the bridge's `Everywhere.native`.
|
|
22
30
|
def native_app?
|
|
23
31
|
!native_platform.nil?
|
|
24
32
|
end
|
|
25
33
|
|
|
26
|
-
# :ios, :android, or nil in a plain browser. Reads
|
|
34
|
+
# :ios, :android, :desktop, or nil in a plain browser. Reads the shell's UA
|
|
27
35
|
# marker, so it's correct even before the JS bridge has booted.
|
|
28
36
|
def native_platform
|
|
29
37
|
Everywhere.native_platform_of(_everywhere_user_agent)
|
|
30
38
|
end
|
|
31
39
|
|
|
40
|
+
# True in the Tauri desktop shell. Use it for window chrome — reserving room
|
|
41
|
+
# for an overlay title bar, say — and for anything that assumes a pointer
|
|
42
|
+
# and a resizable window.
|
|
43
|
+
def desktop_app?
|
|
44
|
+
native_platform == :desktop
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# True in the iOS or Android shell, and the one to reach for when the
|
|
48
|
+
# affordance is a phone's: safe-area insets, the native tab bar, biometrics,
|
|
49
|
+
# the OAuth handoff. Mirrors the bridge's `Everywhere.platform === "mobile"`.
|
|
50
|
+
def mobile_app?
|
|
51
|
+
!mobile_platform.nil?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# :ios, :android, or nil (including in the desktop shell).
|
|
55
|
+
def mobile_platform
|
|
56
|
+
Everywhere.mobile_platform_of(_everywhere_user_agent)
|
|
57
|
+
end
|
|
58
|
+
|
|
32
59
|
# The shell's version as a Gem::Version (from the "RubyEverywhere/<ver>"
|
|
33
60
|
# UA prefix), or nil outside the shell / when unparseable. Use it to gate
|
|
34
61
|
# features that need a newer shell than some users have installed.
|
|
@@ -49,8 +76,11 @@ module Everywhere
|
|
|
49
76
|
# redirect_to everywhere_auth_redirect(after_authentication_url)
|
|
50
77
|
#
|
|
51
78
|
# `to` may be a full URL or a path; only the same-origin path is forwarded.
|
|
79
|
+
# mobile_app?, not native_app?: the reset page exists to rebuild a phone
|
|
80
|
+
# shell's web views and re-fetch its tab bar. The desktop shell has neither,
|
|
81
|
+
# so a plain redirect is both correct and what it already did.
|
|
52
82
|
def everywhere_auth_redirect(to)
|
|
53
|
-
return to unless
|
|
83
|
+
return to unless mobile_app? && respond_to?(:everywhere_reset_path)
|
|
54
84
|
|
|
55
85
|
path = to.to_s.sub(%r{\Ahttps?://[^/]+}, "")
|
|
56
86
|
path = "/" if path.empty? || !path.start_with?("/")
|
|
@@ -104,7 +134,10 @@ module Everywhere
|
|
|
104
134
|
wrapper_class: nil, locked_class: nil,
|
|
105
135
|
message_class: nil, unlock_class: nil, &block)
|
|
106
136
|
content = respond_to?(:capture) ? capture(&block) : block.call
|
|
107
|
-
|
|
137
|
+
# mobile_app?: there's no Face ID on the desktop shell, so emitting the
|
|
138
|
+
# locked wrapper there would hide the content behind an unlock nothing can
|
|
139
|
+
# satisfy. Browsers and the desktop shell render it plainly.
|
|
140
|
+
return content unless mobile_app?
|
|
108
141
|
|
|
109
142
|
attrs = [%(data-everywhere-biometric-lock="#{_everywhere_attr(name)}")]
|
|
110
143
|
attrs << %(data-everywhere-biometric-reason="#{_everywhere_attr(reason)}") if reason
|
data/lib/everywhere/paths.rb
CHANGED
|
@@ -12,24 +12,28 @@ module Everywhere
|
|
|
12
12
|
File.expand_path("../..", __dir__)
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
# The generic Tauri shell bundled inside the gem: support/
|
|
16
|
-
# src-tauri app and the splash/ dir it serves
|
|
17
|
-
#
|
|
15
|
+
# The generic Tauri shell bundled inside the gem: support/desktop/ holds the
|
|
16
|
+
# src-tauri app and the splash/ dir it serves, alongside support/mobile/ios
|
|
17
|
+
# and support/mobile/android. This is the CANONICAL copy of the shell —
|
|
18
|
+
# external consumers (e.g. the build-runner repo) resolve it via
|
|
18
19
|
# `every shell-dir`. The CLI uses it unless the caller passes --shell-dir.
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
#
|
|
21
|
+
# The directory is named for the platform; the reader keeps the "shell"
|
|
22
|
+
# wording because that's what the flag, the command and every caller call it.
|
|
23
|
+
def bundled_desktop_dir
|
|
24
|
+
File.join(gem_root, "support", "desktop", "src-tauri")
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
# Absolute path to the shell's src-tauri directory, or nil if the bundled
|
|
24
28
|
# copy is missing (a corrupt/partial install).
|
|
25
29
|
def shell_dir
|
|
26
|
-
|
|
30
|
+
bundled_desktop_dir if File.exist?(File.join(bundled_desktop_dir, "tauri.conf.json"))
|
|
27
31
|
end
|
|
28
32
|
|
|
29
33
|
# Like #shell_dir but aborts with a helpful message when the shell is
|
|
30
34
|
# missing. The --shell-dir hint covers dev on a modified shell checkout.
|
|
31
35
|
def shell_dir!
|
|
32
|
-
shell_dir or UI.die!("couldn't find the bundled shell at #{
|
|
36
|
+
shell_dir or UI.die!("couldn't find the bundled shell at #{bundled_desktop_dir}; " \
|
|
33
37
|
"reinstall ruby_everywhere or pass --shell-dir")
|
|
34
38
|
end
|
|
35
39
|
|
|
@@ -126,10 +130,57 @@ module Everywhere
|
|
|
126
130
|
File.join(cache_dir, "android-fonts")
|
|
127
131
|
end
|
|
128
132
|
|
|
129
|
-
#
|
|
130
|
-
#
|
|
131
|
-
# the
|
|
132
|
-
#
|
|
133
|
+
# The stamped copy of the Tauri shell for one app, keyed by bundle id.
|
|
134
|
+
# Only apps that declare `native.desktop` get one — everyone else compiles
|
|
135
|
+
# the gem's own copy, which is identical for them and stays warm across
|
|
136
|
+
# projects (see cargo_target_dir). Persistent for the same reason the mobile
|
|
137
|
+
# work dirs are: cargo's incremental state lives inside it, and a cold Tauri
|
|
138
|
+
# build is minutes where a warm one is seconds.
|
|
139
|
+
def desktop_work_dir(bundle_id)
|
|
140
|
+
File.join(cache_dir, "desktop", bundle_id)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# CARGO_TARGET_DIR for a stamped shell. Per-app rather than shared, because
|
|
144
|
+
# the whole point of stamping is that this app's crate graph differs from
|
|
145
|
+
# every other app's — pointing them at one target dir would make cargo
|
|
146
|
+
# rebuild the world on every alternating build.
|
|
147
|
+
def desktop_target_dir(bundle_id)
|
|
148
|
+
File.join(cache_dir, "desktop-target", bundle_id)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Where `every dev` keeps the throwaway .app wrapper it runs the shell
|
|
152
|
+
# inside. macOS reads the application name from CFBundleName, so a bare
|
|
153
|
+
# `cargo run` binary shows up as the crate name — "example-shell" — in the
|
|
154
|
+
# menu bar, the About/Hide/Quit items and the Dock. The wrapper exists only
|
|
155
|
+
# to carry an Info.plist; the binary inside it is a hard link to cargo's.
|
|
156
|
+
def desktop_dev_app_dir(bundle_id)
|
|
157
|
+
File.join(cache_dir, "desktop-app", bundle_id)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Where `every dev` stages the app's Dock icon for the running shell. The
|
|
161
|
+
# shaped master is derived from app.icon, so it's a build product, not the
|
|
162
|
+
# app's own file — it belongs in the cache next to the rest of them.
|
|
163
|
+
def desktop_icon_dir(bundle_id)
|
|
164
|
+
File.join(cache_dir, "desktop-icon", bundle_id)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Where `every dev` stages native/desktop/assets for the running shell.
|
|
168
|
+
# A staged copy rather than the source tree on purpose: the packaged app
|
|
169
|
+
# reads ONE flat Resources/assets folder, and pointing dev at the nested
|
|
170
|
+
# source would quietly resolve assets/branding/logo.png in a build and not
|
|
171
|
+
# in dev. Staging runs the same DesktopAssets compiler both ways, so the two
|
|
172
|
+
# can't drift. Keyed by bundle id like the mobile work dirs.
|
|
173
|
+
def desktop_assets_dir(bundle_id)
|
|
174
|
+
File.join(cache_dir, "desktop-assets", bundle_id)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Where cargo writes the shell's build output (CARGO_TARGET_DIR) for apps on
|
|
178
|
+
# the fast path — the ones that declare no `native.desktop`. Redirected here
|
|
179
|
+
# so cargo never dumps a multi-GB target/ into the installed gem, and so the
|
|
180
|
+
# shell compiles warm across projects: with no app Rust in it the shell
|
|
181
|
+
# source really is identical for everyone, so one shared target dir is safe
|
|
182
|
+
# to reuse. An app that declares extensions gets desktop_target_dir instead,
|
|
183
|
+
# because that premise stops holding the moment its crate graph differs.
|
|
133
184
|
def cargo_target_dir
|
|
134
185
|
File.join(cache_dir, "shell-target")
|
|
135
186
|
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "console"
|
|
4
|
+
require_relative "line_pump"
|
|
5
|
+
require_relative "ui"
|
|
6
|
+
|
|
7
|
+
module Everywhere
|
|
8
|
+
# Reads one child's output off a pty and republishes it, one whole line at a
|
|
9
|
+
# time, tagged with its source.
|
|
10
|
+
#
|
|
11
|
+
# `every dev` used to hand the dev server and desktop shell its own stdout and
|
|
12
|
+
# let them write straight to the terminal. That made two things impossible:
|
|
13
|
+
# telling which of four concurrent things printed a line, and keeping anything
|
|
14
|
+
# pinned to the bottom of the screen. Relaying costs a thread per child and
|
|
15
|
+
# buys both.
|
|
16
|
+
#
|
|
17
|
+
# The full raw stream is also tee'd to a log file, matching what the builders
|
|
18
|
+
# already do with dist/ios-build.log — so nothing a filter hides is lost.
|
|
19
|
+
class Relay
|
|
20
|
+
CHUNK = 4096
|
|
21
|
+
POLL = 0.1
|
|
22
|
+
|
|
23
|
+
# on_line: an optional callback given every settled line before filtering.
|
|
24
|
+
# `every dev` uses it to notice the moment cargo actually starts the desktop
|
|
25
|
+
# shell — there is no other signal that the build turned into a running app.
|
|
26
|
+
def initialize(io, source:, log: nil, filter: nil, on_line: nil)
|
|
27
|
+
@io = io
|
|
28
|
+
@source = source
|
|
29
|
+
@filter = filter
|
|
30
|
+
@on_line = on_line
|
|
31
|
+
@log = log && File.open(log, "w")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Blocking; run it in a thread. Returns when the child closes the pty.
|
|
35
|
+
def run
|
|
36
|
+
Console.tag = @source
|
|
37
|
+
pump = LinePump.new
|
|
38
|
+
loop do
|
|
39
|
+
if @io.wait_readable(POLL)
|
|
40
|
+
chunk = @io.read_nonblock(CHUNK, exception: false)
|
|
41
|
+
break if chunk.nil? # EOF
|
|
42
|
+
|
|
43
|
+
publish(pump << chunk) unless chunk == :wait_readable
|
|
44
|
+
else
|
|
45
|
+
publish(pump.flush_idle)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
publish(pump.drain)
|
|
49
|
+
rescue Errno::EIO, IOError, Errno::EBADF
|
|
50
|
+
# A pty master raises EIO (not EOF) once the last slave closes, which is
|
|
51
|
+
# simply how a child exiting looks from this side.
|
|
52
|
+
publish(pump.drain) if pump
|
|
53
|
+
ensure
|
|
54
|
+
@log&.close
|
|
55
|
+
begin
|
|
56
|
+
@io.close
|
|
57
|
+
rescue StandardError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def publish(lines)
|
|
65
|
+
lines.each do |line|
|
|
66
|
+
@log&.puts(line)
|
|
67
|
+
@on_line&.call(line)
|
|
68
|
+
shown = @filter ? @filter.call(line) : line
|
|
69
|
+
case shown
|
|
70
|
+
when :drop, false then nil
|
|
71
|
+
when nil then Console.puts(" #{UI.gray(UI.short_path(line))}")
|
|
72
|
+
else Console.puts(shown)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/everywhere/shellout.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "ui"
|
|
4
|
+
require_relative "console"
|
|
5
|
+
require_relative "child_processes"
|
|
4
6
|
|
|
5
7
|
module Everywhere
|
|
6
8
|
# All external commands (tebako, cargo, the app's own bin/dev) run outside
|
|
@@ -8,27 +10,57 @@ module Everywhere
|
|
|
8
10
|
module Shellout
|
|
9
11
|
module_function
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
# The environment a child should get: this gem's bundle stripped back out,
|
|
14
|
+
# plus the caller's overrides.
|
|
15
|
+
#
|
|
16
|
+
# This is handed to spawn as its env argument rather than swapped into ENV,
|
|
17
|
+
# which is what Bundler.with_unbundled_env does (ENV.replace, twice, around
|
|
18
|
+
# the block). That is process-global mutable state, and `every dev` now runs
|
|
19
|
+
# the iOS and Android builds concurrently: two overlapping swaps race so that
|
|
20
|
+
# one child inherits the CLI's bundle after all — the exact thing unbundling
|
|
21
|
+
# exists to prevent — and ENV is left wrong for the rest of the session.
|
|
22
|
+
#
|
|
23
|
+
# A nil value tells spawn to REMOVE that key, which is most of what
|
|
24
|
+
# unbundling is. The resulting child environment is identical to what
|
|
25
|
+
# with_unbundled_env produced, since Bundler.unbundled_env is derived from
|
|
26
|
+
# the snapshot taken before Bundler was activated either way.
|
|
27
|
+
def child_env(overrides = {})
|
|
28
|
+
base = unbundled_base
|
|
29
|
+
delta = {}
|
|
30
|
+
ENV.each_key { |key| delta[key] = nil unless base.key?(key) }
|
|
31
|
+
base.each { |key, value| delta[key] = value unless ENV[key] == value }
|
|
32
|
+
delta.merge(overrides.transform_keys(&:to_s))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Bundler.unbundled_env derives from `original_env`, a snapshot frozen at
|
|
36
|
+
# load time, so this is genuinely constant for the life of the process.
|
|
37
|
+
def unbundled_base
|
|
38
|
+
@unbundled_base ||= defined?(Bundler) ? Bundler.unbundled_env : ENV.to_h
|
|
13
39
|
end
|
|
14
40
|
|
|
15
41
|
def run!(env, *cmd, chdir: Dir.pwd)
|
|
16
|
-
success =
|
|
42
|
+
success = system(child_env(env), *cmd, chdir: chdir)
|
|
17
43
|
UI.die!("command failed: #{cmd.join(" ")}") unless success
|
|
18
44
|
end
|
|
19
45
|
|
|
46
|
+
# quiet also detaches stdin. A quiet command is by definition one that has
|
|
47
|
+
# nothing to say to the terminal, and `every dev` reads single keystrokes
|
|
48
|
+
# from that same terminal — a child left holding stdin silently eats the
|
|
49
|
+
# keypresses the menu is waiting on.
|
|
20
50
|
def run?(*cmd, chdir: Dir.pwd, quiet: false)
|
|
21
51
|
opts = { chdir: chdir }
|
|
22
|
-
opts.update(out: File::NULL, err: File::NULL) if quiet
|
|
23
|
-
|
|
52
|
+
opts.update(in: File::NULL, out: File::NULL, err: File::NULL) if quiet
|
|
53
|
+
system(child_env, *cmd, **opts)
|
|
24
54
|
end
|
|
25
55
|
|
|
26
56
|
# Run a command and capture its combined stdout+stderr. Returns
|
|
27
57
|
# [output_string, Process::Status]. For probing tool output (codesign,
|
|
28
|
-
# stapler, notarytool) rather than driving a build.
|
|
58
|
+
# stapler, notarytool) rather than driving a build. A leading Hash is env
|
|
59
|
+
# overrides, matching Open3's own convention.
|
|
29
60
|
def capture(*cmd)
|
|
30
61
|
require "open3"
|
|
31
|
-
|
|
62
|
+
overrides = cmd.first.is_a?(Hash) ? cmd.shift : {}
|
|
63
|
+
Open3.capture2e(child_env(overrides), *cmd)
|
|
32
64
|
rescue Errno::ENOENT
|
|
33
65
|
["", nil]
|
|
34
66
|
end
|
|
@@ -40,18 +72,32 @@ module Everywhere
|
|
|
40
72
|
# prettify what a human sees; the on-disk log always gets the raw stream, so
|
|
41
73
|
# nothing is lost. Unrecognized lines (filter returns nil) are shown dimmed
|
|
42
74
|
# and indented so they read as background detail under the current step.
|
|
75
|
+
#
|
|
76
|
+
# The signature is deliberately unchanged: the builder tests stub this with
|
|
77
|
+
# fixed-signature lambdas, and cancellation is wired up out of band through
|
|
78
|
+
# ChildProcesses instead.
|
|
43
79
|
def run_logged!(env, cmd, log:, filter: nil)
|
|
44
80
|
require "open3"
|
|
45
81
|
success = false
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
82
|
+
File.open(log, "w") do |logf|
|
|
83
|
+
# Inside an `every dev` worker the build leads its own process group,
|
|
84
|
+
# for two reasons: a Ctrl-C at the terminal no longer reaches
|
|
85
|
+
# xcodebuild/gradlew behind the CLI's back (the CLI decides when a build
|
|
86
|
+
# dies), and teardown can TERM the whole compile tree with one signal.
|
|
87
|
+
# Outside dev this stays off, so Ctrl-C during `every build` still stops
|
|
88
|
+
# the compiler as it always has.
|
|
89
|
+
opts = ChildProcesses.isolated? ? { pgroup: true } : {}
|
|
90
|
+
Open3.popen2e(child_env(env), *cmd, **opts) do |stdin, out, wait|
|
|
91
|
+
stdin.close
|
|
92
|
+
ChildProcesses.track(wait.pid)
|
|
93
|
+
begin
|
|
50
94
|
out.each_line do |raw|
|
|
51
95
|
logf.write(raw)
|
|
52
96
|
emit(raw, filter)
|
|
53
97
|
end
|
|
54
98
|
success = wait.value.success?
|
|
99
|
+
ensure
|
|
100
|
+
ChildProcesses.untrack(wait.pid)
|
|
55
101
|
end
|
|
56
102
|
end
|
|
57
103
|
end
|
|
@@ -61,13 +107,13 @@ module Everywhere
|
|
|
61
107
|
# Route one raw line to the console: verbatim when there's no filter, else
|
|
62
108
|
# the filter's rendition (:drop swallows it, nil falls back to dim detail).
|
|
63
109
|
def emit(raw, filter)
|
|
64
|
-
return
|
|
110
|
+
return Console.print(raw) unless filter
|
|
65
111
|
|
|
66
112
|
shown = filter.call(raw)
|
|
67
113
|
case shown
|
|
68
114
|
when :drop, false then nil
|
|
69
|
-
when nil then
|
|
70
|
-
else
|
|
115
|
+
when nil then Console.puts(" #{UI.gray(UI.short_path(raw.rstrip))}")
|
|
116
|
+
else Console.puts(shown)
|
|
71
117
|
end
|
|
72
118
|
end
|
|
73
119
|
private_class_method :emit
|
|
@@ -75,7 +121,36 @@ module Everywhere
|
|
|
75
121
|
# pgroup: the child leads its own process group so teardown can TERM the
|
|
76
122
|
# whole tree (foreman + watchers) without signalling the CLI itself.
|
|
77
123
|
def spawn(env, cmd, chdir:)
|
|
78
|
-
|
|
124
|
+
Process.spawn(child_env(env), cmd, chdir: chdir, pgroup: true)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Same, but on a pseudo-terminal, returning [master_io, pid].
|
|
128
|
+
#
|
|
129
|
+
# `every dev` relays the dev server's and desktop shell's output itself so
|
|
130
|
+
# it can tag each line with its source and keep the dock pinned at the
|
|
131
|
+
# bottom. A plain pipe would work, but the children would see a non-TTY and
|
|
132
|
+
# foreman, cargo and friends would drop their colors and progress output; on
|
|
133
|
+
# a pty they behave exactly as they do when you run them by hand.
|
|
134
|
+
#
|
|
135
|
+
# PTY.spawn calls setsid, so the child leads its own session: a terminal
|
|
136
|
+
# SIGINT can't reach it, and Process.kill("TERM", -pid) still takes down the
|
|
137
|
+
# whole tree.
|
|
138
|
+
def spawn_pty(env, cmd, chdir:)
|
|
139
|
+
require "pty"
|
|
140
|
+
# PTY.spawn hands back the read and write ends of the pty MASTER. We never
|
|
141
|
+
# forward stdin, so the write end is closed immediately — the child keeps
|
|
142
|
+
# running because the read end still holds the master open.
|
|
143
|
+
reader, writer, pid = PTY.spawn(child_env(env), cmd, chdir: chdir)
|
|
144
|
+
writer.close
|
|
145
|
+
[reader, pid]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# PTY is POSIX-only. Callers fall back to spawn + inherited fds.
|
|
149
|
+
def pty?
|
|
150
|
+
require "pty"
|
|
151
|
+
true
|
|
152
|
+
rescue LoadError
|
|
153
|
+
false
|
|
79
154
|
end
|
|
80
155
|
end
|
|
81
156
|
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "console"
|
|
4
|
+
require_relative "child_processes"
|
|
5
|
+
|
|
6
|
+
module Everywhere
|
|
7
|
+
# Named background tasks for the `every dev` key loop.
|
|
8
|
+
#
|
|
9
|
+
# Three guarantees, each of which fixes a specific way the old synchronous
|
|
10
|
+
# command misbehaved:
|
|
11
|
+
#
|
|
12
|
+
# * At most one live task per name. Pressing `i` twice must not run two
|
|
13
|
+
# iOS builds over the same staged work dir, which Builders::Ios rm_rf's
|
|
14
|
+
# and re-copies on every run.
|
|
15
|
+
# * No task failure can reach the main thread. A Swift compile error used to
|
|
16
|
+
# unwind through the key loop into Dev#call's `ensure teardown` and take
|
|
17
|
+
# the Rails server down with it.
|
|
18
|
+
# * Every task is cancellable. Its children lead their own process groups
|
|
19
|
+
# and are TERMed (then KILLed) at shutdown.
|
|
20
|
+
class TaskPool
|
|
21
|
+
# A task's failure is data, not control flow — hence `rescue Exception`, one
|
|
22
|
+
# of the few places it is the correct choice. MRI enqueues a SystemExit that
|
|
23
|
+
# escapes a non-main thread onto the MAIN thread and re-raises it there,
|
|
24
|
+
# wherever that thread happens to be. Since UI.die! raises Everywhere::Fatal
|
|
25
|
+
# (a SystemExit), `rescue StandardError` here would leave the original bug
|
|
26
|
+
# fully intact, just relocated. Verified:
|
|
27
|
+
#
|
|
28
|
+
# ruby -e 'Thread.new { raise SystemExit.new(2) }; sleep 1; puts "alive"'
|
|
29
|
+
# # => exits 2, never prints
|
|
30
|
+
GRACE = 5
|
|
31
|
+
|
|
32
|
+
def initialize(on_error: nil)
|
|
33
|
+
@lock = Mutex.new
|
|
34
|
+
@threads = {}
|
|
35
|
+
@failed = []
|
|
36
|
+
@stopping = false
|
|
37
|
+
@on_error = on_error
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# :started, :busy or :stopping.
|
|
41
|
+
def start(name, &block)
|
|
42
|
+
@lock.synchronize do
|
|
43
|
+
return :stopping if @stopping
|
|
44
|
+
return :busy if @threads[name]&.alive?
|
|
45
|
+
|
|
46
|
+
@threads[name] = spawn_worker(name, &block)
|
|
47
|
+
retag
|
|
48
|
+
:started
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def busy?(name) = @lock.synchronize { !!@threads[name]&.alive? }
|
|
53
|
+
|
|
54
|
+
def failed = @lock.synchronize { @failed.dup }
|
|
55
|
+
|
|
56
|
+
def any_running? = @lock.synchronize { @threads.each_value.any?(&:alive?) }
|
|
57
|
+
|
|
58
|
+
# Join every live task. Used by the headless path, where there is no key
|
|
59
|
+
# loop to keep the process alive and a failed build still has to be news.
|
|
60
|
+
def wait_all
|
|
61
|
+
loop do
|
|
62
|
+
live = @lock.synchronize { @threads.each_value.select(&:alive?) }
|
|
63
|
+
break if live.empty?
|
|
64
|
+
|
|
65
|
+
live.each(&:join)
|
|
66
|
+
end
|
|
67
|
+
Console.multiplexed = false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Kill the tasks' children first, then the threads. Ordering matters: a
|
|
71
|
+
# worker blocked reading a build's output only unwinds once that build is
|
|
72
|
+
# gone, so signalling the children is what actually makes the joins return.
|
|
73
|
+
def shutdown(grace: GRACE)
|
|
74
|
+
live = @lock.synchronize do
|
|
75
|
+
@stopping = true
|
|
76
|
+
@threads.each_value.select(&:alive?)
|
|
77
|
+
end
|
|
78
|
+
return if live.empty?
|
|
79
|
+
|
|
80
|
+
live.each { |thread| ChildProcesses.terminate(thread, grace: grace) }
|
|
81
|
+
deadline = now + grace
|
|
82
|
+
live.each { |thread| thread.join([deadline - now, 0].max) }
|
|
83
|
+
live.select(&:alive?).each(&:kill)
|
|
84
|
+
Console.multiplexed = false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# Called while holding @lock; the new thread also takes @lock in its ensure.
|
|
90
|
+
# That is not a deadlock — the worker simply blocks until start returns —
|
|
91
|
+
# but it is why these two must not be collapsed into one synchronize block.
|
|
92
|
+
def spawn_worker(name, &block)
|
|
93
|
+
Thread.new do
|
|
94
|
+
# Set from inside the thread, as its first statement: assigning it after
|
|
95
|
+
# Thread.new returns races a task that fails immediately.
|
|
96
|
+
Thread.current.name = name.to_s
|
|
97
|
+
Thread.current.report_on_exception = false
|
|
98
|
+
Console.tag = name
|
|
99
|
+
ChildProcesses.isolate!
|
|
100
|
+
|
|
101
|
+
begin
|
|
102
|
+
block.call
|
|
103
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
104
|
+
stopping = @lock.synchronize { @failed << name; @stopping }
|
|
105
|
+
@on_error&.call(name, e, stopping)
|
|
106
|
+
ensure
|
|
107
|
+
@lock.synchronize { retag }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Tag output by source only while two or more tasks are live: with one build
|
|
113
|
+
# running, the output is exactly what it has always been. Thread.current is
|
|
114
|
+
# excluded because a worker calling this from its own ensure is still
|
|
115
|
+
# #alive? and would keep tagging on for the task that just finished.
|
|
116
|
+
def retag
|
|
117
|
+
live = @threads.each_value.count { |t| t.alive? && t != Thread.current }
|
|
118
|
+
Console.multiplexed = live > 1
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
122
|
+
end
|
|
123
|
+
end
|