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
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ui"
|
|
4
|
+
require_relative "console"
|
|
5
|
+
require_relative "dock/state"
|
|
6
|
+
require_relative "dock/footer"
|
|
7
|
+
require_relative "dock/screen"
|
|
8
|
+
|
|
9
|
+
module Everywhere
|
|
10
|
+
# The pinned status bar at the bottom of `every dev`.
|
|
11
|
+
#
|
|
12
|
+
# The old key menu was printed once, before the loop, and was gone from the
|
|
13
|
+
# screen within seconds of the Rails server starting to log. The dock keeps
|
|
14
|
+
# the keymap — and what each target is actually doing — permanently visible,
|
|
15
|
+
# while the logs scroll normally above it.
|
|
16
|
+
#
|
|
17
|
+
# It installs itself as Console's sink, so every write in the process (CLI
|
|
18
|
+
# chrome, relayed server output, filtered build output) is framed by an erase
|
|
19
|
+
# and a repaint. See Dock::Screen for why it repaints rather than reserving
|
|
20
|
+
# rows with a scroll region.
|
|
21
|
+
class Dock
|
|
22
|
+
TICK = 0.2
|
|
23
|
+
|
|
24
|
+
KEYS = [
|
|
25
|
+
["d", "desktop"],
|
|
26
|
+
["i", "iOS"],
|
|
27
|
+
["a", "Android"],
|
|
28
|
+
["b", "browser"],
|
|
29
|
+
["l", "logs"],
|
|
30
|
+
["r", "restart"],
|
|
31
|
+
["?", "help"],
|
|
32
|
+
["q", "quit"]
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
HELP = [
|
|
36
|
+
["d", "relaunch the desktop shell (opens it if it isn't running)"],
|
|
37
|
+
["i", "rebuild, reinstall and relaunch on the iOS Simulator"],
|
|
38
|
+
["a", "rebuild, reinstall and relaunch on the Android emulator"],
|
|
39
|
+
["b", "open the app in your default browser"],
|
|
40
|
+
["l", "start/stop streaming iOS Simulator logs"],
|
|
41
|
+
["r", "restart the dev server only"],
|
|
42
|
+
["?", "hide this help"],
|
|
43
|
+
["q", "quit (Ctrl-C works too)"]
|
|
44
|
+
].freeze
|
|
45
|
+
|
|
46
|
+
# A dock needs room to be worth having, and a terminal that understands
|
|
47
|
+
# cursor movement. NO_COLOR is deliberately NOT a disqualifier: it governs
|
|
48
|
+
# SGR, not cursor control, and the footer's glyphs carry the meaning on
|
|
49
|
+
# their own.
|
|
50
|
+
MIN_ROWS = 12
|
|
51
|
+
MIN_COLS = 40
|
|
52
|
+
|
|
53
|
+
def self.open(targets, io: $stdout, keys: true, size: nil)
|
|
54
|
+
return Null.new(targets, keys: keys) unless drawable?(io, size)
|
|
55
|
+
|
|
56
|
+
new(targets, io: io, keys: keys, size: size).tap(&:install)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.drawable?(io, size)
|
|
60
|
+
return false if ENV["EVERY_NO_DOCK"]
|
|
61
|
+
return false unless io.respond_to?(:tty?) && io.tty?
|
|
62
|
+
return false if ENV["TERM"].to_s.empty? || ENV["TERM"] == "dumb"
|
|
63
|
+
|
|
64
|
+
rows, cols = size || Screen.new(io: io).size
|
|
65
|
+
rows >= MIN_ROWS && cols >= MIN_COLS
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def initialize(targets, io: $stdout, keys: true, size: nil)
|
|
69
|
+
@state = State.new(targets)
|
|
70
|
+
@screen = Screen.new(io: io, size: size)
|
|
71
|
+
@keys = keys
|
|
72
|
+
@help = false
|
|
73
|
+
@frame = 0
|
|
74
|
+
@dirty = true
|
|
75
|
+
@open = false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
attr_reader :state
|
|
79
|
+
|
|
80
|
+
def install
|
|
81
|
+
@open = true
|
|
82
|
+
@screen.open
|
|
83
|
+
Console.dock = self
|
|
84
|
+
trap_resize
|
|
85
|
+
start_ticker
|
|
86
|
+
self
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# --- state --------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def set(key, state, detail: nil)
|
|
92
|
+
@state.set(key, state, detail: detail)
|
|
93
|
+
@dirty = true
|
|
94
|
+
refresh
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def detail(key, text)
|
|
98
|
+
@state.detail(key, text)
|
|
99
|
+
@dirty = true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def toggle_help
|
|
103
|
+
@help = !@help
|
|
104
|
+
@dirty = true
|
|
105
|
+
refresh
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The dock draws the keymap permanently, so there is nothing to announce.
|
|
109
|
+
# Null overrides this to print the one-shot hint the old command used.
|
|
110
|
+
def announce_keys = nil
|
|
111
|
+
|
|
112
|
+
# --- drawing ------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
# Called by Console, which already holds the lock. Content must end at
|
|
115
|
+
# column 1, or the erase math for the next frame is off by a row.
|
|
116
|
+
def emit(text)
|
|
117
|
+
body = text.end_with?("\n") ? text : "#{text}\n"
|
|
118
|
+
@screen.frame(body, footer_rows)
|
|
119
|
+
@dirty = false
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def refresh
|
|
123
|
+
Console.synchronize do
|
|
124
|
+
next unless @open
|
|
125
|
+
|
|
126
|
+
@screen.frame("", footer_rows)
|
|
127
|
+
@dirty = false
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Wipe the footer and leave it wiped — for stderr, or an interactive child
|
|
132
|
+
# that needs the screen to itself.
|
|
133
|
+
def clear = @screen.clear
|
|
134
|
+
|
|
135
|
+
def close
|
|
136
|
+
return unless @open
|
|
137
|
+
|
|
138
|
+
# Order matters: drop the flag so the ticker stops drawing, let it finish
|
|
139
|
+
# the frame it may be mid-way through, and only then unhook and restore.
|
|
140
|
+
@open = false
|
|
141
|
+
@ticker&.join(TICK * 2)
|
|
142
|
+
@ticker&.kill
|
|
143
|
+
Console.dock = nil
|
|
144
|
+
Console.synchronize { @screen.close }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
# One column short of the terminal on purpose. A row filled to the last
|
|
150
|
+
# column leaves the cursor in a pending-wrap state whose handling varies
|
|
151
|
+
# between terminals, and a footer row that wraps costs the erase math a row.
|
|
152
|
+
def footer_rows
|
|
153
|
+
lines = Footer.render(@state.targets,
|
|
154
|
+
cols: [@screen.cols - 1, 1].max,
|
|
155
|
+
keys: (@keys ? KEYS : nil),
|
|
156
|
+
frame: @frame,
|
|
157
|
+
elapsed: ->(target) { @state.elapsed(target) },
|
|
158
|
+
rule: @screen.rows >= 24)
|
|
159
|
+
@help ? lines + help_rows : lines
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def help_rows
|
|
163
|
+
width = HELP.map { |key, _| key.length }.max
|
|
164
|
+
HELP.map { |key, desc| " #{UI.bold(key.ljust(width))} #{UI.dim(desc)}" }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# An idle dev session should emit zero bytes per second, so only redraw when
|
|
168
|
+
# something changed or a spinner is actually turning.
|
|
169
|
+
def start_ticker
|
|
170
|
+
@ticker = Thread.new do
|
|
171
|
+
Thread.current.name = "dock"
|
|
172
|
+
Thread.current.report_on_exception = false
|
|
173
|
+
while @open
|
|
174
|
+
sleep TICK
|
|
175
|
+
next unless @open
|
|
176
|
+
|
|
177
|
+
# Screen#size re-reads the ioctl every frame, so a resize needs
|
|
178
|
+
# nothing more than a redraw.
|
|
179
|
+
if @resized
|
|
180
|
+
@resized = false
|
|
181
|
+
@dirty = true
|
|
182
|
+
end
|
|
183
|
+
busy = @state.busy?
|
|
184
|
+
@frame += 1 if busy
|
|
185
|
+
refresh if busy || @dirty
|
|
186
|
+
end
|
|
187
|
+
rescue StandardError
|
|
188
|
+
# A dock that can't draw must never be the reason a dev session dies.
|
|
189
|
+
@open = false
|
|
190
|
+
Console.dock = nil
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Trap handlers must not take a lock (Ruby raises ThreadError when the lock
|
|
195
|
+
# is held in trap context), so this only sets a flag; the ticker draws.
|
|
196
|
+
def trap_resize
|
|
197
|
+
Signal.trap("WINCH") { @resized = true }
|
|
198
|
+
rescue ArgumentError, StandardError
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The headless stand-in: same API, no escape sequences ever. Used for CI,
|
|
203
|
+
# pipes, `| tee`, dumb terminals and windows too small to spare the rows.
|
|
204
|
+
# It narrates transitions as ordinary lines so a piped log still reads.
|
|
205
|
+
#
|
|
206
|
+
# Note it never installs itself as Console's sink — output keeps going
|
|
207
|
+
# straight to $stdout, exactly as it did before the dock existed.
|
|
208
|
+
class Null
|
|
209
|
+
def initialize(targets, keys: true)
|
|
210
|
+
@state = State.new(targets)
|
|
211
|
+
@keys = keys
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
attr_reader :state
|
|
215
|
+
|
|
216
|
+
def install = self
|
|
217
|
+
|
|
218
|
+
def set(key, state, detail: nil)
|
|
219
|
+
target = @state.set(key, state, detail: detail) or return
|
|
220
|
+
UI.step("#{target.label} → #{[state, detail].compact.join(" ")}")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def detail(_key, _text) = nil
|
|
224
|
+
def toggle_help = nil
|
|
225
|
+
def emit(text) = nil
|
|
226
|
+
def refresh = nil
|
|
227
|
+
def clear = nil
|
|
228
|
+
def close = nil
|
|
229
|
+
|
|
230
|
+
# The one-shot keymap the old command printed, kept for headless runs.
|
|
231
|
+
def announce_keys
|
|
232
|
+
return unless @keys
|
|
233
|
+
|
|
234
|
+
UI.note(KEYS.map { |key, desc| "#{UI.bold(key)} #{desc}" }.join(" · "), marker: "⌨", color: :cyan)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "android_sdk"
|
|
5
|
+
require_relative "shellout"
|
|
6
|
+
require_relative "ui"
|
|
7
|
+
|
|
8
|
+
module Everywhere
|
|
9
|
+
# Thin wrappers around `adb` and `emulator` — just enough to boot a virtual
|
|
10
|
+
# device, put the stamped APK on it and launch it. The Android counterpart of
|
|
11
|
+
# Simulator, method for method, so `every dev` reads the same either way.
|
|
12
|
+
#
|
|
13
|
+
# Every binary comes from AndroidSdk, never from PATH: Android Studio
|
|
14
|
+
# provisions adb and the emulator and exports neither, so a machine that can
|
|
15
|
+
# build Android apps all day has no `adb` on PATH (see AndroidSdk's preamble).
|
|
16
|
+
module Emulator
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Phase 0 measured a headless cold boot of Medium_Phone_API_35 at 16s to
|
|
20
|
+
# sys.boot_completed, and a windowed cold boot — which is what we default
|
|
21
|
+
# to, see #boot_argv — at roughly two minutes. Four minutes is generous
|
|
22
|
+
# enough to cover a slower machine or an AVD whose Quick Boot snapshot is
|
|
23
|
+
# being rebuilt, without pretending a genuinely wedged emulator will
|
|
24
|
+
# eventually come good.
|
|
25
|
+
BOOT_TIMEOUT = 240
|
|
26
|
+
|
|
27
|
+
POLL_INTERVAL = 1
|
|
28
|
+
|
|
29
|
+
# The emulator's alias for the host's own loopback interface: inside the
|
|
30
|
+
# guest, 127.0.0.1 is the *guest*, and 10.0.2.2 is the machine running it.
|
|
31
|
+
# Rung 2 of the dev-URL ladder (plan §2.3).
|
|
32
|
+
LOOPBACK_ALIAS = "10.0.2.2"
|
|
33
|
+
|
|
34
|
+
# Hosts that mean "this machine" in a dev URL and therefore mean the wrong
|
|
35
|
+
# machine once the URL is loaded inside the guest. 0.0.0.0 is here because
|
|
36
|
+
# a server bound to every interface is often *printed* as http://0.0.0.0:3000.
|
|
37
|
+
LOOPBACK_HOSTS = ["127.0.0.1", "localhost", "::1", "0.0.0.0"].freeze
|
|
38
|
+
|
|
39
|
+
# The shell template's launcher activity, FULLY QUALIFIED on purpose.
|
|
40
|
+
#
|
|
41
|
+
# `am start -n <applicationId>/<activity>` resolves a leading dot against the
|
|
42
|
+
# applicationId — but the template's manifest says `android:name=".MainActivity"`,
|
|
43
|
+
# which Gradle resolves against the *namespace*, and the namespace is frozen
|
|
44
|
+
# at com.rubyeverywhere.shell precisely so an app can rename its bundle id
|
|
45
|
+
# freely. The two are different packages for every real app, so any relative
|
|
46
|
+
# spelling here is wrong the moment applicationId != namespace.
|
|
47
|
+
# (The Hotwire demo's `.main.MainActivity` is a fact about the demo, whose
|
|
48
|
+
# applicationId happens to equal its namespace. It does not transfer.)
|
|
49
|
+
DEFAULT_ACTIVITY = "com.rubyeverywhere.shell.MainActivity"
|
|
50
|
+
|
|
51
|
+
# --- devices --------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
# Serial of a device we can actually talk to, or nil.
|
|
54
|
+
#
|
|
55
|
+
# Emulators come first because they are what `every dev --android` boots and
|
|
56
|
+
# what the dev-URL ladder can rewrite for; a plugged-in phone is still
|
|
57
|
+
# accepted after them, since someone with a device attached and no emulator
|
|
58
|
+
# running clearly meant that device.
|
|
59
|
+
def booted_serial
|
|
60
|
+
ready = devices.select { |d| d[:state] == "device" }
|
|
61
|
+
(ready.find { |d| emulator?(d[:serial]) } || ready.first)&.fetch(:serial)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Parsed `adb devices -l`. Lines look like:
|
|
65
|
+
#
|
|
66
|
+
# List of devices attached
|
|
67
|
+
# emulator-5554 device product:sdk_gphone64_arm64 model:… transport_id:1
|
|
68
|
+
# emulator-5556 offline
|
|
69
|
+
# 3A2B1C0D4E unauthorized usb:1-1
|
|
70
|
+
#
|
|
71
|
+
# `device` is the only state that can take a command. `offline` is a device
|
|
72
|
+
# the daemon has seen but can't talk to — an emulator still coming up, or one
|
|
73
|
+
# that wedged — and `unauthorized` is a phone whose owner hasn't tapped
|
|
74
|
+
# "Allow USB debugging" yet. Both are kept in the list (so callers can say
|
|
75
|
+
# *why* nothing is usable) but neither is ever picked by #booted_serial:
|
|
76
|
+
# installing against them fails with a message that reads like our bug.
|
|
77
|
+
def devices
|
|
78
|
+
exe = AndroidSdk.adb or return []
|
|
79
|
+
|
|
80
|
+
out, status = Shellout.capture(exe, "devices", "-l")
|
|
81
|
+
return [] unless status&.success?
|
|
82
|
+
|
|
83
|
+
out.lines.filter_map do |line|
|
|
84
|
+
# Skip the header and adb's own "* daemon started successfully" notes.
|
|
85
|
+
next if line.start_with?("List of devices", "*")
|
|
86
|
+
|
|
87
|
+
serial, state, = line.split
|
|
88
|
+
next unless serial && state&.match?(/\A[a-z]+\z/)
|
|
89
|
+
|
|
90
|
+
{ serial: serial, state: state }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# adb names emulators after their console port (emulator-5554); physical
|
|
95
|
+
# devices carry a hardware serial. That prefix is the standard test and it's
|
|
96
|
+
# what decides whether the 10.0.2.2 rewrite is even meaningful.
|
|
97
|
+
def emulator?(serial) = serial.to_s.start_with?("emulator-")
|
|
98
|
+
|
|
99
|
+
# --- booting --------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
# Reuse a running device, else boot an AVD and wait for it to be usable.
|
|
102
|
+
# Returns the serial.
|
|
103
|
+
def boot_default!(avd: nil, headless: false, timeout: BOOT_TIMEOUT)
|
|
104
|
+
if (serial = booted_serial)
|
|
105
|
+
UI.step("reusing #{UI.bold(serial)}")
|
|
106
|
+
return serial
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
name = avd || AndroidSdk.avds!.last
|
|
110
|
+
UI.step("booting emulator #{UI.bold(name)}#{headless ? UI.dim(" (headless)") : ""}")
|
|
111
|
+
spawn_emulator(boot_argv(name, headless: headless))
|
|
112
|
+
|
|
113
|
+
deadline = Time.now + timeout
|
|
114
|
+
bounded_wait(adb_argv(nil, "wait-for-device"), timeout: timeout)
|
|
115
|
+
serial = booted_serial or
|
|
116
|
+
UI.die!("emulator #{name} never registered with adb — try booting it from Android " \
|
|
117
|
+
"Studio's Device Manager to see what it says")
|
|
118
|
+
wait_until_booted(serial, timeout: [deadline - Time.now, POLL_INTERVAL].max)
|
|
119
|
+
serial
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# WINDOWED BY DEFAULT, deliberately, even though headless boots roughly
|
|
123
|
+
# eight times faster (16s vs ~2m in the Phase 0 measurements).
|
|
124
|
+
#
|
|
125
|
+
# The emulator window *is* the feedback surface of this dev loop — you press
|
|
126
|
+
# `a` to look at your app. A headless boot installs and launches a shell you
|
|
127
|
+
# cannot see, which is indistinguishable from a build that silently failed;
|
|
128
|
+
# that is not hypothetical, it is what happened the first time this was run
|
|
129
|
+
# by hand. The slow boot is a one-time cost per session, paid while other
|
|
130
|
+
# work (the Gradle build) is happening anyway, and Quick Boot amortises it
|
|
131
|
+
# away on every subsequent run. Invisibility is a cost paid *every* time,
|
|
132
|
+
# and it is paid in "is this broken?".
|
|
133
|
+
#
|
|
134
|
+
# `headless: true` stays available for CI and for anyone who really does
|
|
135
|
+
# only want logs.
|
|
136
|
+
#
|
|
137
|
+
# Notably absent: `-no-snapshot`. The spike passed it to force honest cold
|
|
138
|
+
# boots for measurement; for a dev loop the opposite is wanted — Quick Boot
|
|
139
|
+
# restores a saved state in seconds instead of running Android's whole
|
|
140
|
+
# startup again. `-no-audio` stays regardless: the emulator grabbing the
|
|
141
|
+
# host's audio device is a well-known source of hangs on macOS, and a web
|
|
142
|
+
# shell has nothing to play.
|
|
143
|
+
def boot_argv(avd, headless: false)
|
|
144
|
+
argv = [AndroidSdk.emulator!, "-avd", avd, "-no-boot-anim", "-no-audio"]
|
|
145
|
+
argv << "-no-window" if headless
|
|
146
|
+
argv
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# `adb wait-for-device` returns as soon as the daemon can talk to the
|
|
150
|
+
# device, which Phase 0 clocked at 8s — a full 8s before the system is
|
|
151
|
+
# actually usable. So it's the *start* of the wait, not the end: what says
|
|
152
|
+
# "ready" is sys.boot_completed flipping to 1.
|
|
153
|
+
def wait_until_booted(serial, timeout: BOOT_TIMEOUT)
|
|
154
|
+
deadline = Time.now + timeout
|
|
155
|
+
bounded_wait(adb_argv(serial, "wait-for-device"), timeout: timeout)
|
|
156
|
+
|
|
157
|
+
until Time.now > deadline
|
|
158
|
+
return serial if boot_completed?(serial)
|
|
159
|
+
|
|
160
|
+
sleep POLL_INTERVAL
|
|
161
|
+
end
|
|
162
|
+
UI.die!("emulator #{serial} never finished booting " \
|
|
163
|
+
"(sys.boot_completed still unset after #{UI.elapsed(timeout)})")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def boot_completed?(serial)
|
|
167
|
+
out, status = Shellout.capture(*adb_argv(serial, "shell", "getprop", "sys.boot_completed"))
|
|
168
|
+
status&.success? && out.strip == "1"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# --- app lifecycle --------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
# `-r` reinstalls over an existing copy, keeping its data — the fast path,
|
|
174
|
+
# and the one `every dev --android` takes on every press of `a`.
|
|
175
|
+
#
|
|
176
|
+
# It has one failure that no amount of retrying fixes: an APK signed by a
|
|
177
|
+
# different key than the copy already on the device is rejected with
|
|
178
|
+
# INSTALL_FAILED_UPDATE_INCOMPATIBLE, which is routine the first time a
|
|
179
|
+
# machine builds an app someone else's debug keystore installed. The only
|
|
180
|
+
# cure is removing the old package, so we do that once, out loud, rather
|
|
181
|
+
# than making the user decode the error.
|
|
182
|
+
#
|
|
183
|
+
# Old adb builds printed `Failure [...]` and still exited 0, so the output
|
|
184
|
+
# is checked as well as the status.
|
|
185
|
+
def install(serial, apk, application_id: nil)
|
|
186
|
+
out, status = capture_install(serial, apk)
|
|
187
|
+
return if status&.success? && !out.include?("Failure [")
|
|
188
|
+
|
|
189
|
+
if application_id && out.include?("INSTALL_FAILED_UPDATE_INCOMPATIBLE")
|
|
190
|
+
UI.warn "#{application_id} is installed with a different signing key — reinstalling " \
|
|
191
|
+
"from scratch #{UI.dim("(its app data is dropped)")}"
|
|
192
|
+
uninstall(serial, application_id)
|
|
193
|
+
out, status = capture_install(serial, apk)
|
|
194
|
+
return if status&.success? && !out.include?("Failure [")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
UI.die!("adb install failed: #{out.strip}")
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def capture_install(serial, apk)
|
|
201
|
+
Shellout.capture(*adb_argv(serial, "install", "-r", apk))
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Not fatal: "not installed for user 0" is the normal answer on a clean
|
|
205
|
+
# device, and every caller only wants the package gone.
|
|
206
|
+
def uninstall(serial, application_id)
|
|
207
|
+
_out, status = Shellout.capture(*adb_argv(serial, "uninstall", application_id))
|
|
208
|
+
!!status&.success?
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# `am start` reports an unknown component as `Error: Activity class {…} does
|
|
212
|
+
# not exist.` on stdout *and still exits 0*, so the output is what decides.
|
|
213
|
+
def launch(serial, application_id, activity: DEFAULT_ACTIVITY)
|
|
214
|
+
component = "#{application_id}/#{activity}"
|
|
215
|
+
out, status = Shellout.capture(*adb_argv(serial, "shell", "am", "start", "-n", component))
|
|
216
|
+
return if started?(out, status)
|
|
217
|
+
|
|
218
|
+
# The APK on the device is the authority on its own launcher activity, so
|
|
219
|
+
# rather than dying on a stale constant, ask it. This costs one round trip
|
|
220
|
+
# only on the failure path, and it means moving MainActivity in the
|
|
221
|
+
# template can never silently break the dev loop again — which is exactly
|
|
222
|
+
# how this broke the first time.
|
|
223
|
+
resolved = launcher_activity(serial, application_id)
|
|
224
|
+
if resolved && resolved != activity
|
|
225
|
+
retry_component = "#{application_id}/#{resolved}"
|
|
226
|
+
retry_out, retry_status = Shellout.capture(
|
|
227
|
+
*adb_argv(serial, "shell", "am", "start", "-n", retry_component)
|
|
228
|
+
)
|
|
229
|
+
if started?(retry_out, retry_status)
|
|
230
|
+
UI.warn "launched #{resolved} — #{activity} is stale, update Emulator::DEFAULT_ACTIVITY"
|
|
231
|
+
return
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
UI.die!("couldn't launch #{component}: #{out.strip}")
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# `am start` reports an unknown component on stdout and STILL exits 0, so a
|
|
239
|
+
# bare exit status is not an answer.
|
|
240
|
+
def started?(out, status)
|
|
241
|
+
!!status&.success? && !out.to_s.match?(/^Error:/)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# The launcher activity the installed APK actually declares. `cmd package
|
|
245
|
+
# resolve-activity --brief` prints `<package>/<activity>` on its last line;
|
|
246
|
+
# anything else (old adb, no match) answers nil so the caller can fail with
|
|
247
|
+
# the original, more useful error.
|
|
248
|
+
def launcher_activity(serial, application_id)
|
|
249
|
+
out, status = Shellout.capture(*adb_argv(serial, "shell", "cmd", "package", "resolve-activity",
|
|
250
|
+
"--brief", application_id))
|
|
251
|
+
return unless status&.success?
|
|
252
|
+
|
|
253
|
+
line = out.to_s.lines.map(&:strip).reject(&:empty?).last
|
|
254
|
+
return unless line&.include?("/")
|
|
255
|
+
|
|
256
|
+
package, activity = line.split("/", 2)
|
|
257
|
+
activity if package == application_id && !activity.to_s.empty?
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The app's process id on the device, or nil when it isn't running. `pidof`
|
|
261
|
+
# exits non-zero and prints nothing in that case, which is exactly the
|
|
262
|
+
# distinction `every logs --android` needs to make.
|
|
263
|
+
def pid_of(serial, application_id)
|
|
264
|
+
out, status = Shellout.capture(*adb_argv(serial, "shell", "pidof", application_id))
|
|
265
|
+
pid = out.to_s.split.first
|
|
266
|
+
pid if status&.success? && pid&.match?(/\A\d+\z/)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Forward the device's <port> back to the host's <port>. Returns whether it
|
|
270
|
+
# took, because that answer *is* rung 1 of the dev-URL ladder below.
|
|
271
|
+
#
|
|
272
|
+
# It succeeds with nothing listening on the host port (verified in Phase 0),
|
|
273
|
+
# so it can run before the dev server is up. On success adb prints the port
|
|
274
|
+
# number to stdout — a wrapper that read non-empty output as failure would
|
|
275
|
+
# get this exactly backwards.
|
|
276
|
+
def reverse(serial, port)
|
|
277
|
+
_out, status = Shellout.capture(*adb_argv(serial, "reverse", "tcp:#{port}", "tcp:#{port}"))
|
|
278
|
+
!!status&.success?
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# --- the dev-URL ladder (plan §2.3) --------------------------------------
|
|
282
|
+
|
|
283
|
+
# Resolve the URL the Android shell should be pointed at, given the URL the
|
|
284
|
+
# dev server is on *from the host's point of view*. Returns a URL string;
|
|
285
|
+
# `every dev --android` stamps whatever comes back into the debug build.
|
|
286
|
+
#
|
|
287
|
+
# Three rungs, so a failure at any one degrades instead of dead-ending:
|
|
288
|
+
#
|
|
289
|
+
# 0. `override` (--dev-url / --host) short-circuits everything. Someone who
|
|
290
|
+
# typed an address knows something we don't — typically that the app is
|
|
291
|
+
# being opened on a physical phone over the LAN.
|
|
292
|
+
# 1. `adb reverse tcp:<port> tcp:<port>`. Preferred, because it leaves the
|
|
293
|
+
# URL *literally correct*: 127.0.0.1:3000 on the device is 127.0.0.1:3000
|
|
294
|
+
# on the host, so cookies, redirects, OAuth callbacks and anything the
|
|
295
|
+
# app prints all keep matching. Works on emulators and physical devices
|
|
296
|
+
# alike.
|
|
297
|
+
# 2. The 10.0.2.2 rewrite, if reverse failed and the target is an emulator.
|
|
298
|
+
# Automatic and announced — the user is told the URL changed and why,
|
|
299
|
+
# because a silently different origin is a genuinely confusing thing to
|
|
300
|
+
# debug an hour later.
|
|
301
|
+
#
|
|
302
|
+
# If none apply (a physical device, reverse failed, no override) the URL is
|
|
303
|
+
# returned unchanged with a warning naming the fix, rather than aborting a
|
|
304
|
+
# dev loop that is otherwise fine.
|
|
305
|
+
#
|
|
306
|
+
# Emulator.dev_url("http://127.0.0.1:3000/", serial: "emulator-5554")
|
|
307
|
+
# #=> "http://127.0.0.1:3000/" (rung 1) or "http://10.0.2.2:3000/" (rung 2)
|
|
308
|
+
def dev_url(url, serial:, port: nil, override: nil)
|
|
309
|
+
unless override.to_s.empty?
|
|
310
|
+
UI.step("dev URL #{UI.cyan(override)} #{UI.dim("(explicit override)")}")
|
|
311
|
+
return override
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# A URL that doesn't point at this machine needs no help: a LAN address or
|
|
315
|
+
# a deployed https:// origin is already reachable from the device.
|
|
316
|
+
return url unless loopback?(url)
|
|
317
|
+
|
|
318
|
+
port ||= port_of(url)
|
|
319
|
+
return url if port && reverse(serial, port)
|
|
320
|
+
|
|
321
|
+
rewritten = rewrite_loopback(url)
|
|
322
|
+
if emulator?(serial) && rewritten != url
|
|
323
|
+
UI.warn "adb reverse failed — pointing the shell at #{UI.cyan(rewritten)} instead " \
|
|
324
|
+
"#{UI.dim("(#{LOOPBACK_ALIAS} is the emulator's alias for this machine)")}"
|
|
325
|
+
return rewritten
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
UI.warn "adb reverse failed and #{serial || "this target"} isn't an emulator — " \
|
|
329
|
+
"#{UI.cyan(url)} will resolve to the device itself; pass a LAN address " \
|
|
330
|
+
"(--dev-url http://<this machine's IP>:#{port}) with the dev server bound to 0.0.0.0"
|
|
331
|
+
url
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def loopback?(url) = LOOPBACK_HOSTS.include?(host_of(url))
|
|
335
|
+
|
|
336
|
+
def rewrite_loopback(url)
|
|
337
|
+
uri = URI.parse(url.to_s)
|
|
338
|
+
return url unless LOOPBACK_HOSTS.include?(uri.host)
|
|
339
|
+
|
|
340
|
+
uri.host = LOOPBACK_ALIAS
|
|
341
|
+
uri.to_s
|
|
342
|
+
rescue URI::InvalidURIError
|
|
343
|
+
url
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def host_of(url)
|
|
347
|
+
URI.parse(url.to_s).host
|
|
348
|
+
rescue URI::InvalidURIError
|
|
349
|
+
nil
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def port_of(url)
|
|
353
|
+
URI.parse(url.to_s).port
|
|
354
|
+
rescue URI::InvalidURIError
|
|
355
|
+
nil
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# --- plumbing -------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
# Every adb invocation goes through here so the `-s <serial>` targeting is
|
|
361
|
+
# never forgotten — an unqualified adb command with two devices attached
|
|
362
|
+
# fails with "more than one device", and with one emulator plus a phone it
|
|
363
|
+
# silently does the right thing on the wrong one. A nil serial means "any
|
|
364
|
+
# device", which is only ever wanted while waiting for the first one.
|
|
365
|
+
def adb_argv(serial, *args)
|
|
366
|
+
[AndroidSdk.adb!, *(serial ? ["-s", serial.to_s] : []), *args]
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# The emulator has to outlive `every dev` the way the iOS Simulator app
|
|
370
|
+
# does — quitting the CLI should not tear down a device you were using — so
|
|
371
|
+
# it leads its own process group and is detached. Its INFO/WARNING banners
|
|
372
|
+
# go to /dev/null: they would otherwise land in the middle of the dev
|
|
373
|
+
# server's log, and nothing here reads them.
|
|
374
|
+
def spawn_emulator(argv)
|
|
375
|
+
pid = Process.spawn(Shellout.child_env, *argv, pgroup: true, out: File::NULL, err: File::NULL)
|
|
376
|
+
Process.detach(pid)
|
|
377
|
+
pid
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Run a command with a deadline, and report whether it finished in time.
|
|
381
|
+
#
|
|
382
|
+
# This exists for `adb wait-for-device`, which blocks forever by design: an
|
|
383
|
+
# AVD that fails to start would hang the dev loop with no output at all.
|
|
384
|
+
# Waiting on the child ourselves is the only way to put a ceiling on it, so
|
|
385
|
+
# the "never came up" path reaches a message a human can act on.
|
|
386
|
+
def bounded_wait(argv, timeout: BOOT_TIMEOUT)
|
|
387
|
+
pid = Process.spawn(Shellout.child_env, *argv, out: File::NULL, err: File::NULL)
|
|
388
|
+
deadline = Time.now + timeout
|
|
389
|
+
loop do
|
|
390
|
+
return true if Process.waitpid(pid, Process::WNOHANG)
|
|
391
|
+
break if Time.now > deadline
|
|
392
|
+
|
|
393
|
+
sleep POLL_INTERVAL
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
begin
|
|
397
|
+
Process.kill("TERM", pid)
|
|
398
|
+
Process.waitpid(pid)
|
|
399
|
+
rescue StandardError
|
|
400
|
+
nil
|
|
401
|
+
end
|
|
402
|
+
false
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
end
|
data/lib/everywhere/engine.rb
CHANGED
|
@@ -39,6 +39,18 @@ module Everywhere
|
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# Third-party sign-in for the native shells (everywhere.yml `auth:`). Middleware
|
|
43
|
+
# rather than routes: two thirds of the job is watching the app's *own* auth
|
|
44
|
+
# responses go by — see Everywhere::AuthHandoff. At the TOP of the stack,
|
|
45
|
+
# deliberately: a provider path from the web view must be diverted before any
|
|
46
|
+
# OmniAuth-family middleware (Devise's included) can answer it, and appended
|
|
47
|
+
# middleware sits below those or not depending on Gemfile order — which no
|
|
48
|
+
# app should have to know. Does nothing at all until the app declares `auth:`.
|
|
49
|
+
initializer "everywhere.auth" do |app|
|
|
50
|
+
require_relative "auth_handoff"
|
|
51
|
+
app.middleware.insert_before 0, Everywhere::AuthHandoff, root: app.root.to_s
|
|
52
|
+
end
|
|
53
|
+
|
|
42
54
|
# /everywhere/ios_v1.json — the mobile shells' remote path configuration
|
|
43
55
|
# (rules + settings.tabs), generated from config/everywhere.yml at request
|
|
44
56
|
# time so tab changes ship with a normal deploy. A real controller (not
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Everywhere
|
|
4
|
+
# A failure that ends the current unit of work — not necessarily the process.
|
|
5
|
+
#
|
|
6
|
+
# It subclasses SystemExit deliberately. At the top level Ruby exits with
|
|
7
|
+
# #status and prints nothing (UI.die! has already written the ✗ line), so an
|
|
8
|
+
# uncaught Fatal behaves exactly like the Kernel#abort it replaces. Inside
|
|
9
|
+
# `every dev` a worker thread catches it instead and returns to the key menu,
|
|
10
|
+
# which is the whole point: a Swift typo must not take the Rails server down
|
|
11
|
+
# with it.
|
|
12
|
+
#
|
|
13
|
+
# CAUTION for anyone catching this in a thread: MRI enqueues a SystemExit that
|
|
14
|
+
# escapes a non-main thread onto the MAIN thread, which re-raises it wherever
|
|
15
|
+
# that thread happens to be. A worker must therefore `rescue Exception`, not
|
|
16
|
+
# `rescue Everywhere::Fatal` — see TaskPool.
|
|
17
|
+
class Fatal < SystemExit
|
|
18
|
+
def initialize(status = 1, message = nil) = super
|
|
19
|
+
end
|
|
20
|
+
end
|