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.
- checksums.yaml +4 -4
- data/README.md +339 -0
- data/bridge/README.md +70 -1
- data/bridge/everywhere/bridge.js +211 -3
- data/bridge/everywhere/native.css +61 -0
- data/lib/everywhere/android_resources.rb +217 -0
- data/lib/everywhere/android_sdk.rb +330 -0
- data/lib/everywhere/asset_catalog.rb +185 -0
- data/lib/everywhere/auth_handoff.rb +404 -0
- data/lib/everywhere/auth_token.rb +118 -0
- data/lib/everywhere/builders/android.rb +844 -0
- data/lib/everywhere/builders/desktop.rb +326 -0
- data/lib/everywhere/builders/ios.rb +40 -3
- data/lib/everywhere/child_processes.rb +74 -0
- data/lib/everywhere/commands/build.rb +95 -14
- data/lib/everywhere/commands/clean.rb +10 -5
- data/lib/everywhere/commands/dev.rb +462 -57
- data/lib/everywhere/commands/doctor.rb +89 -3
- data/lib/everywhere/commands/install.rb +21 -1
- data/lib/everywhere/commands/logs.rb +117 -8
- data/lib/everywhere/commands/release.rb +2 -2
- data/lib/everywhere/config.rb +703 -13
- 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 +405 -0
- data/lib/everywhere/engine.rb +12 -0
- data/lib/everywhere/fatal.rb +20 -0
- data/lib/everywhere/icon.rb +196 -0
- data/lib/everywhere/line_pump.rb +89 -0
- data/lib/everywhere/log_filter.rb +111 -0
- data/lib/everywhere/native_helper.rb +39 -9
- data/lib/everywhere/omniauth.rb +88 -0
- data/lib/everywhere/paths.rb +109 -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/mobile/android/README.md +217 -0
- data/support/mobile/android/app/build.gradle.kts +156 -0
- data/support/mobile/android/app/everywhere.properties +8 -0
- data/support/mobile/android/app/native-packages.gradle.kts +16 -0
- data/support/mobile/android/app/proguard-rules.pro +21 -0
- data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
- data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
- data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
- data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
- data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
- data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
- data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
- data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
- data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
- data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
- data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
- data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
- data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
- data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
- data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
- data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
- data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
- data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
- data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
- data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
- data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
- data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
- data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
- data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
- data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
- data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
- data/support/mobile/android/build.gradle.kts +8 -0
- data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
- data/support/mobile/android/gradle.properties +14 -0
- data/support/mobile/android/gradlew +251 -0
- data/support/mobile/android/gradlew.bat +94 -0
- data/support/mobile/android/settings.gradle.kts +28 -0
- data/support/mobile/ios/App/AppDelegate.swift +5 -0
- data/support/mobile/ios/App/AuthFlow.swift +101 -0
- data/support/mobile/ios/App/EverywhereConfig.swift +84 -0
- data/support/mobile/ios/App/SceneDelegate.swift +103 -0
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +4 -0
- data/support/mobile/ios/README.md +4 -1
- data/support/{macos → release/macos}/notarize.sh +2 -2
- metadata +97 -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
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
|
|
|
@@ -54,6 +58,30 @@ module Everywhere
|
|
|
54
58
|
"reinstall ruby_everywhere or pass --template-dir")
|
|
55
59
|
end
|
|
56
60
|
|
|
61
|
+
# The Hotwire Native Android shell template bundled inside the gem:
|
|
62
|
+
# support/mobile/android/ holds a frozen Gradle project (wrapper committed)
|
|
63
|
+
# the CLI stamps per-app — everywhere.properties + assets + res, never a
|
|
64
|
+
# build script. Same freeze contract the iOS template has.
|
|
65
|
+
def bundled_android_dir
|
|
66
|
+
File.join(gem_root, "support", "mobile", "android")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Absolute path to the Android template, or nil when it isn't there. Unlike
|
|
70
|
+
# #ios_dir, nil is the NORMAL state until the template lands — callers must
|
|
71
|
+
# read it as "Android isn't buildable in this release yet", not as a corrupt
|
|
72
|
+
# install. settings.gradle.kts is the marker because it's the one file every
|
|
73
|
+
# Gradle project must have at its root.
|
|
74
|
+
def android_dir
|
|
75
|
+
bundled_android_dir if File.exist?(File.join(bundled_android_dir, "settings.gradle.kts"))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Like #android_dir but aborts with a helpful message when the template is
|
|
79
|
+
# missing. The --template-dir hint covers dev on a modified template.
|
|
80
|
+
def android_dir!
|
|
81
|
+
android_dir or UI.die!("couldn't find the bundled Android template at #{bundled_android_dir}; " \
|
|
82
|
+
"reinstall ruby_everywhere or pass --template-dir")
|
|
83
|
+
end
|
|
84
|
+
|
|
57
85
|
# Per-user cache dir. Keeps heavy build state out of the gem and the app.
|
|
58
86
|
def cache_dir
|
|
59
87
|
File.expand_path(ENV["RUBYEVERYWHERE_HOME"] || "~/.rubyeverywhere")
|
|
@@ -79,10 +107,80 @@ module Everywhere
|
|
|
79
107
|
File.join(cache_dir, "ios-packages")
|
|
80
108
|
end
|
|
81
109
|
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
110
|
+
# The stamped copy of the Android template for one app, keyed by
|
|
111
|
+
# applicationId. Persistent for the same reasons ios_work_dir is: Gradle's
|
|
112
|
+
# incremental state is path-sensitive, and the stamped project opens in
|
|
113
|
+
# Android Studio unmodified.
|
|
114
|
+
def android_work_dir(application_id)
|
|
115
|
+
File.join(cache_dir, "android", application_id)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# GRADLE_USER_HOME for shell builds. Redirected here so the multi-GB Gradle
|
|
119
|
+
# distribution, the Android Gradle Plugin and the Hotwire artifacts are
|
|
120
|
+
# downloaded once and stay warm across every app — and never land inside the
|
|
121
|
+
# installed gem. Same trick as cargo_target_dir.
|
|
122
|
+
def android_gradle_home
|
|
123
|
+
File.join(cache_dir, "android-gradle")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Where the Material Symbols icon font is cached after its one-time fetch.
|
|
127
|
+
# The font is ~10 MB, so it's fetched on first Android build instead of
|
|
128
|
+
# riding along in a 229 KB gem that most users never build Android with.
|
|
129
|
+
def android_fonts_dir
|
|
130
|
+
File.join(cache_dir, "android-fonts")
|
|
131
|
+
end
|
|
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.
|
|
86
184
|
def cargo_target_dir
|
|
87
185
|
File.join(cache_dir, "shell-target")
|
|
88
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
|
data/lib/everywhere/ui.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "fatal"
|
|
4
|
+
require_relative "console"
|
|
5
|
+
|
|
3
6
|
module Everywhere
|
|
4
7
|
# Terminal output helpers. Colors follow the informal standard:
|
|
5
8
|
# on when stdout is a TTY or CLICOLOR_FORCE=1, always off when NO_COLOR is set.
|
|
@@ -69,14 +72,18 @@ module Everywhere
|
|
|
69
72
|
def warn_line(msg) = "#{yellow("!")} #{msg}"
|
|
70
73
|
def success_line(msg) = "#{green("✓")} #{bold(msg)}"
|
|
71
74
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def
|
|
77
|
-
def
|
|
78
|
-
def
|
|
79
|
-
def
|
|
75
|
+
# Everything prints through Console, which serializes concurrent writers and
|
|
76
|
+
# gives the `every dev` dock a chance to repaint around each line. With no
|
|
77
|
+
# dock installed it is a plain $stdout.write, resolved per call so
|
|
78
|
+
# capture_io still works.
|
|
79
|
+
def phase(msg) = Console.puts(phase_line(msg))
|
|
80
|
+
def step(msg) = Console.puts(step_line(msg))
|
|
81
|
+
def substep(msg) = Console.puts(substep_line(msg))
|
|
82
|
+
def detail(msg) = Console.puts(detail_line(msg))
|
|
83
|
+
def ok(msg) = Console.puts(ok_line(msg))
|
|
84
|
+
def bad(msg) = Console.puts(bad_line(msg))
|
|
85
|
+
def warn(msg) = Console.puts(warn_line(msg))
|
|
86
|
+
def success(msg) = Console.puts(success_line(msg))
|
|
80
87
|
|
|
81
88
|
# An indented, dim outcome under a substep — e.g. "accepted", "valid on disk".
|
|
82
89
|
def note_line(msg, marker: "·", color: :gray)
|
|
@@ -84,15 +91,28 @@ module Everywhere
|
|
|
84
91
|
" #{tint.call(marker)} #{tint.call(msg)}"
|
|
85
92
|
end
|
|
86
93
|
|
|
87
|
-
def note(msg, marker: "·", color: :gray) = puts(note_line(msg, marker: marker, color: color))
|
|
94
|
+
def note(msg, marker: "·", color: :gray) = Console.puts(note_line(msg, marker: marker, color: color))
|
|
88
95
|
|
|
96
|
+
# Ends the current unit of work. At the top level Ruby exits 1 and prints
|
|
97
|
+
# nothing extra, so this behaves exactly like the Kernel#abort it replaced —
|
|
98
|
+
# but inside `every dev` the worker thread running the build catches the
|
|
99
|
+
# Fatal, marks that target failed, and hands you back the key menu with the
|
|
100
|
+
# dev server still serving. The message is the same string abort received,
|
|
101
|
+
# so every assert_raises(SystemExit) reading err.message is unaffected.
|
|
89
102
|
def die!(msg)
|
|
90
|
-
|
|
103
|
+
line = "#{red("✗")} #{red(msg)}"
|
|
104
|
+
Console.error(line)
|
|
105
|
+
raise Everywhere::Fatal.new(1, line)
|
|
91
106
|
end
|
|
92
107
|
|
|
93
108
|
# A yes/no fact, colored by truthiness (for signing receipts).
|
|
94
109
|
def yn(bool) = bool ? green("yes") : red("no")
|
|
95
110
|
|
|
111
|
+
# Braille spinner frames, shared by the transient Status line and the
|
|
112
|
+
# `every dev` dock. Ten frames at ~200ms is one revolution every two
|
|
113
|
+
# seconds — legible as motion without being distracting.
|
|
114
|
+
FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
115
|
+
|
|
96
116
|
# --- transient status line -------------------------------------------------
|
|
97
117
|
|
|
98
118
|
# A single self-rewriting line for waits that produce no output — chiefly
|
|
@@ -104,8 +124,12 @@ module Everywhere
|
|
|
104
124
|
# line every HEADLESS_INTERVAL seconds. Callers MUST `clear` before writing
|
|
105
125
|
# anything else — `update` tracks whether a line is currently on screen so
|
|
106
126
|
# `clear` is cheap and idempotent.
|
|
127
|
+
#
|
|
128
|
+
# NOTE: this writes to its `io:` directly, NOT through Console, so it is not
|
|
129
|
+
# serialized against other writers. That's fine for its one caller
|
|
130
|
+
# (`every platform build`, single-threaded) — do not reuse it concurrently.
|
|
107
131
|
class Status
|
|
108
|
-
FRAMES =
|
|
132
|
+
FRAMES = UI::FRAMES
|
|
109
133
|
HEADLESS_INTERVAL = 30 # seconds between plain lines when not a TTY
|
|
110
134
|
|
|
111
135
|
def initialize(io: $stdout)
|
|
@@ -157,6 +181,25 @@ module Everywhere
|
|
|
157
181
|
"#{secs / 3600}h #{(secs % 3600) / 60}m"
|
|
158
182
|
end
|
|
159
183
|
|
|
184
|
+
# --- ANSI ------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
# Escape sequences a tool writes when it thinks it's on a terminal — which,
|
|
187
|
+
# since `every dev` relays its children over a pty, they all do. Two forms
|
|
188
|
+
# matter in practice: SGR color (CSI), and OSC-8 hyperlinks, which cargo
|
|
189
|
+
# wraps around the profile name in its own status lines.
|
|
190
|
+
ANSI = /
|
|
191
|
+
\e\][^\a\e]*(?:\a|\e\\) # OSC ... terminated by BEL or ST
|
|
192
|
+
|
|
|
193
|
+
\e\[[0-9;?]*[ -\/]*[@-~] # CSI ... final byte
|
|
194
|
+
|
|
|
195
|
+
\e[@-Z\\-_] # two-character escapes
|
|
196
|
+
/x
|
|
197
|
+
|
|
198
|
+
# For MATCHING, never for display: a filter that pattern-matches colored
|
|
199
|
+
# output has to look past the codes, but the colors are still what the
|
|
200
|
+
# developer wants to read.
|
|
201
|
+
def strip_ansi(str) = str.to_s.gsub(ANSI, "")
|
|
202
|
+
|
|
160
203
|
# --- path shortening ------------------------------------------------------
|
|
161
204
|
|
|
162
205
|
# Collapse the noisy machine paths that external tools echo (codesign,
|
data/lib/everywhere/version.rb
CHANGED