ruby_everywhere 0.3.0 → 0.4.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 +255 -0
- data/bridge/everywhere/bridge.js +60 -3
- 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 +398 -0
- data/lib/everywhere/auth_token.rb +118 -0
- data/lib/everywhere/builders/android.rb +844 -0
- data/lib/everywhere/builders/ios.rb +40 -3
- data/lib/everywhere/commands/build.rb +51 -9
- data/lib/everywhere/commands/clean.rb +10 -5
- data/lib/everywhere/commands/dev.rb +46 -8
- data/lib/everywhere/commands/doctor.rb +89 -3
- data/lib/everywhere/commands/install.rb +1 -1
- data/lib/everywhere/commands/logs.rb +117 -8
- data/lib/everywhere/config.rb +409 -13
- data/lib/everywhere/emulator.rb +405 -0
- data/lib/everywhere/engine.rb +12 -0
- data/lib/everywhere/icon.rb +196 -0
- data/lib/everywhere/log_filter.rb +74 -0
- data/lib/everywhere/native_helper.rb +2 -5
- data/lib/everywhere/omniauth.rb +88 -0
- data/lib/everywhere/paths.rb +47 -0
- data/lib/everywhere/version.rb +1 -1
- 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
- metadata +67 -1
|
@@ -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 = Shellout.unbundled { Process.spawn(*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 = Shellout.unbundled { Process.spawn(*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
|
data/lib/everywhere/icon.rb
CHANGED
|
@@ -37,6 +37,83 @@ module Everywhere
|
|
|
37
37
|
ICO_SIZES = [16, 32, 48, 64, 128, 256].freeze
|
|
38
38
|
LINUX_SIZES = [16, 32, 48, 64, 128, 256, 512].freeze
|
|
39
39
|
|
|
40
|
+
# The adaptive icon's foreground layer, one entry per density bucket (mdpi
|
|
41
|
+
# is 1x, the rest step 1.5/2/3/4x) of the 108dp layer. No legacy
|
|
42
|
+
# mipmap-<dpi>/ic_launcher.png companions: the shell's minSdk is 28, so
|
|
43
|
+
# every device that can install the app resolves @mipmap/ic_launcher to the
|
|
44
|
+
# anydpi-v26 adaptive icon and could never load them.
|
|
45
|
+
ANDROID_ADAPTIVE_SIZES = {
|
|
46
|
+
"mdpi" => 108, "hdpi" => 162, "xhdpi" => 216, "xxhdpi" => 324, "xxxhdpi" => 432
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
# Adaptive-icon safe-zone geometry, as a fraction of the 108dp layer.
|
|
50
|
+
#
|
|
51
|
+
# The launcher masks both layers to a shape *it* chooses and can parallax
|
|
52
|
+
# them, so only the centre 72dp is ever guaranteed visible — the outer 18dp
|
|
53
|
+
# on each side is headroom. The most aggressive stock mask is the circle
|
|
54
|
+
# inscribed in that 72dp viewport, and Google's keyline pulls the safe zone
|
|
55
|
+
# one step further in, to a 66dp circle. So a logo goes *inside* that
|
|
56
|
+
# circle, bounding box and all: side = 66/sqrt(2) = 46.7dp, i.e. 0.432 of
|
|
57
|
+
# the layer. Scaling a logo to the 72dp viewport instead is the usual
|
|
58
|
+
# mistake — it looks correct in a square preview and loses every corner on
|
|
59
|
+
# the round-mask launchers most devices ship with.
|
|
60
|
+
ADAPTIVE_ART = (66.0 / Math.sqrt(2)) / 108.0
|
|
61
|
+
|
|
62
|
+
# Alpha at or below this is background, not artwork — it survives PNG
|
|
63
|
+
# export slop and antialiased edges without counting them as content.
|
|
64
|
+
CONTENT_ALPHA = 8
|
|
65
|
+
|
|
66
|
+
# Opaque fraction of the source above which it is treated as full-bleed art
|
|
67
|
+
# rather than a logo. `config.icon` is one shared icon.png feeding macOS,
|
|
68
|
+
# iOS *and* Android, and the iOS path wants it opaque corner to corner, so
|
|
69
|
+
# in practice it usually is. Insetting that into the safe zone would render
|
|
70
|
+
# the source's own baked background as a shrunken tile floating inside the
|
|
71
|
+
# launcher's background colour — two nested backgrounds and a tiny logo.
|
|
72
|
+
#
|
|
73
|
+
# 0.95 sits in an empty gap. Shaped-but-full-bleed sources stay above it: a
|
|
74
|
+
# Big Sur squircle keeps ~99% of its bounding square, a 22%-rounded rect
|
|
75
|
+
# ~97%. Anything with a deliberate margin falls far below — even a mere 5%
|
|
76
|
+
# border leaves 81%. Nothing real lands near the line.
|
|
77
|
+
FULL_BLEED_COVERAGE = 0.95
|
|
78
|
+
|
|
79
|
+
# Both launcher aliases resolve to the same layers; ic_launcher_round exists
|
|
80
|
+
# only because the manifest's android:roundIcon asks for it by name. The
|
|
81
|
+
# layers are @mipmap, not the template placeholder's @drawable, because
|
|
82
|
+
# what gets stamped is a raster per density rather than one vector.
|
|
83
|
+
ANDROID_ADAPTIVE_ICON_XML = <<~XML
|
|
84
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
85
|
+
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
86
|
+
<background android:drawable="@color/ic_launcher_background" />
|
|
87
|
+
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
|
88
|
+
</adaptive-icon>
|
|
89
|
+
XML
|
|
90
|
+
|
|
91
|
+
# Android 13+ themed ("Material You") icons tint the monochrome layer's
|
|
92
|
+
# *alpha* and throw its colour away, so the layer is only worth emitting
|
|
93
|
+
# when the alpha is already a silhouette — i.e. the inset branch, where the
|
|
94
|
+
# source arrived as a logo on transparency. Full-bleed art would tint as a
|
|
95
|
+
# solid filled square, which is worse than the launcher's own fallback, so
|
|
96
|
+
# that branch ships the icon without a monochrome layer and lets the
|
|
97
|
+
# fallback happen.
|
|
98
|
+
ANDROID_THEMED_ADAPTIVE_ICON_XML = <<~XML
|
|
99
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
100
|
+
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
101
|
+
<background android:drawable="@color/ic_launcher_background" />
|
|
102
|
+
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
|
103
|
+
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
|
104
|
+
</adaptive-icon>
|
|
105
|
+
XML
|
|
106
|
+
|
|
107
|
+
# The background layer is a colour, not a bitmap: it has to survive being
|
|
108
|
+
# scaled and shifted by the launcher's parallax, which a PNG can't do
|
|
109
|
+
# without banding or seams at the edges.
|
|
110
|
+
ANDROID_BACKGROUND_XML = <<~XML
|
|
111
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
112
|
+
<resources>
|
|
113
|
+
<color name="ic_launcher_background">%s</color>
|
|
114
|
+
</resources>
|
|
115
|
+
XML
|
|
116
|
+
|
|
40
117
|
# Modern (Xcode 14+) single-size asset catalog entry: one 1024px universal
|
|
41
118
|
# image, iOS derives every size itself.
|
|
42
119
|
IOS_APPICON_CONTENTS = <<~JSON
|
|
@@ -112,6 +189,48 @@ module Everywhere
|
|
|
112
189
|
true
|
|
113
190
|
end
|
|
114
191
|
|
|
192
|
+
# Write the Android launcher icon under `res_dir`: an adaptive icon's
|
|
193
|
+
# foreground layer per density, the solid-colour background resource, and
|
|
194
|
+
# the anydpi-v26 <adaptive-icon> pair that binds them (ic_launcher and the
|
|
195
|
+
# ic_launcher_round alias the manifest names). How large the artwork lands,
|
|
196
|
+
# and whether it can carry a themed-icon layer, both follow from what the
|
|
197
|
+
# source turns out to be — see android_art. `background` is an [r, g, b]
|
|
198
|
+
# triple as Icon.hex_rgb returns, default white. Returns true, or false if
|
|
199
|
+
# the source PNG can't be read — same contract as write_ios_appiconset, so
|
|
200
|
+
# the builder can warn and ship the placeholder.
|
|
201
|
+
def write_android_mipmaps(source, res_dir, background: nil)
|
|
202
|
+
raster = Raster.decode_png(source) or return false
|
|
203
|
+
|
|
204
|
+
rgb = Array(background || [255, 255, 255])[0, 3]
|
|
205
|
+
art, full_bleed = android_art(raster)
|
|
206
|
+
|
|
207
|
+
ANDROID_ADAPTIVE_SIZES.each do |density, layer|
|
|
208
|
+
dir = File.join(res_dir, "mipmap-#{density}")
|
|
209
|
+
FileUtils.mkdir_p(dir)
|
|
210
|
+
|
|
211
|
+
# The foreground keeps its alpha — the launcher composites it over the
|
|
212
|
+
# background layer, and its mask needs the surround empty to cut into.
|
|
213
|
+
foreground = if full_bleed
|
|
214
|
+
filling(art, layer)
|
|
215
|
+
else
|
|
216
|
+
centred(art, layer, (layer * ADAPTIVE_ART).round)
|
|
217
|
+
end
|
|
218
|
+
foreground.write_png(File.join(dir, "ic_launcher_foreground.png"))
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
xml = full_bleed ? ANDROID_ADAPTIVE_ICON_XML : ANDROID_THEMED_ADAPTIVE_ICON_XML
|
|
222
|
+
anydpi = File.join(res_dir, "mipmap-anydpi-v26")
|
|
223
|
+
FileUtils.mkdir_p(anydpi)
|
|
224
|
+
File.write(File.join(anydpi, "ic_launcher.xml"), xml)
|
|
225
|
+
File.write(File.join(anydpi, "ic_launcher_round.xml"), xml)
|
|
226
|
+
|
|
227
|
+
values = File.join(res_dir, "values")
|
|
228
|
+
FileUtils.mkdir_p(values)
|
|
229
|
+
File.write(File.join(values, "ic_launcher_background.xml"),
|
|
230
|
+
format(ANDROID_BACKGROUND_XML, format("#%02X%02X%02X", *rgb)))
|
|
231
|
+
true
|
|
232
|
+
end
|
|
233
|
+
|
|
115
234
|
# Build a macOS .icns from an already-shaped master PNG (ideally 1024px).
|
|
116
235
|
# Returns true on success.
|
|
117
236
|
def write_icns(master_png, dest)
|
|
@@ -167,6 +286,83 @@ module Everywhere
|
|
|
167
286
|
m.captures.map { |c| c.to_i(16) }
|
|
168
287
|
end
|
|
169
288
|
|
|
289
|
+
# How this source wants to be scaled onto a launcher layer. Returns
|
|
290
|
+
# [art, full_bleed] — either the raster untouched, to be scaled edge to
|
|
291
|
+
# edge because it is art designed to be cropped by the mask, or the source
|
|
292
|
+
# cropped to its opaque bounding box, to be inset into the safe zone.
|
|
293
|
+
#
|
|
294
|
+
# Cropping matters for the inset branch: scaling the whole canvas would let
|
|
295
|
+
# the source's own transparent padding eat into the 66dp circle and land
|
|
296
|
+
# the logo smaller than the safe zone actually allows. A source with no
|
|
297
|
+
# content at all is nothing to measure, so it takes the full-bleed path.
|
|
298
|
+
def android_art(raster)
|
|
299
|
+
bounds, coverage = content_bounds(raster)
|
|
300
|
+
return [raster, true] if bounds.nil? || coverage >= FULL_BLEED_COVERAGE
|
|
301
|
+
|
|
302
|
+
[crop(raster, *bounds), false]
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# [[x0, y0, x1, y1], coverage] for the pixels above CONTENT_ALPHA — the
|
|
306
|
+
# half-open bounding box of the artwork and the fraction of the canvas it
|
|
307
|
+
# actually paints. [nil, 0.0] when the raster is entirely transparent.
|
|
308
|
+
def content_bounds(raster)
|
|
309
|
+
x0 = raster.width
|
|
310
|
+
y0 = raster.height
|
|
311
|
+
x1 = 0
|
|
312
|
+
y1 = 0
|
|
313
|
+
opaque = 0
|
|
314
|
+
|
|
315
|
+
raster.height.times do |y|
|
|
316
|
+
base = y * raster.width
|
|
317
|
+
raster.width.times do |x|
|
|
318
|
+
next if raster.pixels.getbyte(((base + x) * 4) + 3) <= CONTENT_ALPHA
|
|
319
|
+
|
|
320
|
+
opaque += 1
|
|
321
|
+
x0 = x if x < x0
|
|
322
|
+
x1 = x + 1 if x >= x1
|
|
323
|
+
y0 = y if y < y0
|
|
324
|
+
y1 = y + 1 if y >= y1
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
return [nil, 0.0] if opaque.zero?
|
|
329
|
+
|
|
330
|
+
[[x0, y0, x1, y1], opaque.to_f / (raster.width * raster.height)]
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# The [x0, y0, x1, y1) region of `raster` as a new raster.
|
|
334
|
+
def crop(raster, x0, y0, x1, y1)
|
|
335
|
+
w = x1 - x0
|
|
336
|
+
h = y1 - y0
|
|
337
|
+
out = String.new(capacity: w * h * 4)
|
|
338
|
+
h.times { |y| out << raster.pixels.byteslice(((((y0 + y) * raster.width) + x0) * 4), w * 4) }
|
|
339
|
+
Raster.new(w, h, out)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# `raster` scaled to cover a `canvas`x`canvas` square and centre-cropped to
|
|
343
|
+
# it — no transparent border anywhere. Full-bleed art is meant to be
|
|
344
|
+
# cropped, so a non-square source loses its overhang rather than exposing
|
|
345
|
+
# bands of background the launcher's mask would read as a mistake.
|
|
346
|
+
def filling(raster, canvas)
|
|
347
|
+
fill = [canvas.to_f / raster.width, canvas.to_f / raster.height].max
|
|
348
|
+
sw = [(raster.width * fill).ceil, canvas].max
|
|
349
|
+
sh = [(raster.height * fill).ceil, canvas].max
|
|
350
|
+
crop(raster.resample(sw, sh), (sw - canvas) / 2, (sh - canvas) / 2,
|
|
351
|
+
((sw - canvas) / 2) + canvas, ((sh - canvas) / 2) + canvas)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# `raster` scaled to fit a `box`-sized square (aspect preserved, never
|
|
355
|
+
# stretched) and centred on a transparent `canvas`x`canvas` raster.
|
|
356
|
+
def centred(raster, canvas, box)
|
|
357
|
+
fit = [box.to_f / raster.width, box.to_f / raster.height].min
|
|
358
|
+
sw = (raster.width * fit).round.clamp(1, box)
|
|
359
|
+
sh = (raster.height * fit).round.clamp(1, box)
|
|
360
|
+
|
|
361
|
+
out = Raster.transparent(canvas, canvas)
|
|
362
|
+
out.paste!(raster.resample(sw, sh), (canvas - sw) / 2, (canvas - sh) / 2)
|
|
363
|
+
out
|
|
364
|
+
end
|
|
365
|
+
|
|
170
366
|
# PNG bytes of `master` at `size`x`size` (no-op resample when already sized).
|
|
171
367
|
def sized_png(master, size)
|
|
172
368
|
(size == master.width && size == master.height ? master : master.resample(size, size)).encode_png
|