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.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +255 -0
  3. data/bridge/everywhere/bridge.js +60 -3
  4. data/lib/everywhere/android_resources.rb +217 -0
  5. data/lib/everywhere/android_sdk.rb +330 -0
  6. data/lib/everywhere/asset_catalog.rb +185 -0
  7. data/lib/everywhere/auth_handoff.rb +398 -0
  8. data/lib/everywhere/auth_token.rb +118 -0
  9. data/lib/everywhere/builders/android.rb +844 -0
  10. data/lib/everywhere/builders/ios.rb +40 -3
  11. data/lib/everywhere/commands/build.rb +51 -9
  12. data/lib/everywhere/commands/clean.rb +10 -5
  13. data/lib/everywhere/commands/dev.rb +46 -8
  14. data/lib/everywhere/commands/doctor.rb +89 -3
  15. data/lib/everywhere/commands/install.rb +1 -1
  16. data/lib/everywhere/commands/logs.rb +117 -8
  17. data/lib/everywhere/config.rb +409 -13
  18. data/lib/everywhere/emulator.rb +405 -0
  19. data/lib/everywhere/engine.rb +12 -0
  20. data/lib/everywhere/icon.rb +196 -0
  21. data/lib/everywhere/log_filter.rb +74 -0
  22. data/lib/everywhere/native_helper.rb +2 -5
  23. data/lib/everywhere/omniauth.rb +88 -0
  24. data/lib/everywhere/paths.rb +47 -0
  25. data/lib/everywhere/version.rb +1 -1
  26. data/support/mobile/android/README.md +217 -0
  27. data/support/mobile/android/app/build.gradle.kts +156 -0
  28. data/support/mobile/android/app/everywhere.properties +8 -0
  29. data/support/mobile/android/app/native-packages.gradle.kts +16 -0
  30. data/support/mobile/android/app/proguard-rules.pro +21 -0
  31. data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
  32. data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
  33. data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
  34. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
  35. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
  36. data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
  37. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
  38. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
  39. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
  40. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
  41. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
  42. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
  43. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
  44. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
  45. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
  46. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
  47. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
  48. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
  49. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
  50. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
  51. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
  52. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
  53. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
  54. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
  55. data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
  56. data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
  57. data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
  58. data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
  59. data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
  60. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
  61. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
  62. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
  63. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
  64. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
  65. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  66. data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
  67. data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
  68. data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
  69. data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
  70. data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
  71. data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
  72. data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
  73. data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
  74. data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
  75. data/support/mobile/android/build.gradle.kts +8 -0
  76. data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  77. data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
  78. data/support/mobile/android/gradle.properties +14 -0
  79. data/support/mobile/android/gradlew +251 -0
  80. data/support/mobile/android/gradlew.bat +94 -0
  81. data/support/mobile/android/settings.gradle.kts +28 -0
  82. data/support/mobile/ios/App/AppDelegate.swift +5 -0
  83. data/support/mobile/ios/App/AuthFlow.swift +101 -0
  84. data/support/mobile/ios/App/EverywhereConfig.swift +84 -0
  85. data/support/mobile/ios/App/SceneDelegate.swift +103 -0
  86. data/support/mobile/ios/App.xcodeproj/project.pbxproj +4 -0
  87. data/support/mobile/ios/README.md +4 -1
  88. 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
@@ -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
@@ -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