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.
Files changed (125) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +339 -0
  3. data/bridge/README.md +70 -1
  4. data/bridge/everywhere/bridge.js +211 -3
  5. data/bridge/everywhere/native.css +61 -0
  6. data/lib/everywhere/android_resources.rb +217 -0
  7. data/lib/everywhere/android_sdk.rb +330 -0
  8. data/lib/everywhere/asset_catalog.rb +185 -0
  9. data/lib/everywhere/auth_handoff.rb +404 -0
  10. data/lib/everywhere/auth_token.rb +118 -0
  11. data/lib/everywhere/builders/android.rb +844 -0
  12. data/lib/everywhere/builders/desktop.rb +326 -0
  13. data/lib/everywhere/builders/ios.rb +40 -3
  14. data/lib/everywhere/child_processes.rb +74 -0
  15. data/lib/everywhere/commands/build.rb +95 -14
  16. data/lib/everywhere/commands/clean.rb +10 -5
  17. data/lib/everywhere/commands/dev.rb +462 -57
  18. data/lib/everywhere/commands/doctor.rb +89 -3
  19. data/lib/everywhere/commands/install.rb +21 -1
  20. data/lib/everywhere/commands/logs.rb +117 -8
  21. data/lib/everywhere/commands/release.rb +2 -2
  22. data/lib/everywhere/config.rb +703 -13
  23. data/lib/everywhere/console.rb +117 -0
  24. data/lib/everywhere/desktop_assets.rb +150 -0
  25. data/lib/everywhere/dock/footer.rb +150 -0
  26. data/lib/everywhere/dock/screen.rb +114 -0
  27. data/lib/everywhere/dock/state.rb +59 -0
  28. data/lib/everywhere/dock.rb +238 -0
  29. data/lib/everywhere/emulator.rb +405 -0
  30. data/lib/everywhere/engine.rb +12 -0
  31. data/lib/everywhere/fatal.rb +20 -0
  32. data/lib/everywhere/icon.rb +196 -0
  33. data/lib/everywhere/line_pump.rb +89 -0
  34. data/lib/everywhere/log_filter.rb +111 -0
  35. data/lib/everywhere/native_helper.rb +39 -9
  36. data/lib/everywhere/omniauth.rb +88 -0
  37. data/lib/everywhere/paths.rb +109 -11
  38. data/lib/everywhere/relay.rb +77 -0
  39. data/lib/everywhere/shellout.rb +90 -15
  40. data/lib/everywhere/task_pool.rb +123 -0
  41. data/lib/everywhere/ui.rb +54 -11
  42. data/lib/everywhere/version.rb +1 -1
  43. data/support/desktop/README.md +121 -0
  44. data/support/{shell → desktop}/src-tauri/Cargo.lock +23 -0
  45. data/support/{shell → desktop}/src-tauri/Cargo.toml +19 -1
  46. data/support/{shell → desktop}/src-tauri/capabilities/default.json +7 -0
  47. data/support/desktop/src-tauri/gen/schemas/capabilities.json +1 -0
  48. data/support/desktop/src-tauri/src/extension_host.rs +53 -0
  49. data/support/desktop/src-tauri/src/extensions/mod.rs +39 -0
  50. data/support/{shell → desktop}/src-tauri/src/main.rs +355 -10
  51. data/support/{shell → desktop}/src-tauri/tauri.conf.json +2 -2
  52. data/support/mobile/android/README.md +217 -0
  53. data/support/mobile/android/app/build.gradle.kts +156 -0
  54. data/support/mobile/android/app/everywhere.properties +8 -0
  55. data/support/mobile/android/app/native-packages.gradle.kts +16 -0
  56. data/support/mobile/android/app/proguard-rules.pro +21 -0
  57. data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
  58. data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
  59. data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
  60. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
  61. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
  62. data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
  63. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
  64. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
  65. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
  66. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
  67. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
  68. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
  69. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
  70. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
  71. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
  72. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
  73. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
  74. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
  75. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
  76. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
  77. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
  78. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
  79. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
  80. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
  81. data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
  82. data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
  83. data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
  84. data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
  85. data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
  86. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
  87. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
  88. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
  89. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
  90. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
  91. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  92. data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
  93. data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
  94. data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
  95. data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
  96. data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
  97. data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
  98. data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
  99. data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
  100. data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
  101. data/support/mobile/android/build.gradle.kts +8 -0
  102. data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  103. data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
  104. data/support/mobile/android/gradle.properties +14 -0
  105. data/support/mobile/android/gradlew +251 -0
  106. data/support/mobile/android/gradlew.bat +94 -0
  107. data/support/mobile/android/settings.gradle.kts +28 -0
  108. data/support/mobile/ios/App/AppDelegate.swift +5 -0
  109. data/support/mobile/ios/App/AuthFlow.swift +101 -0
  110. data/support/mobile/ios/App/EverywhereConfig.swift +84 -0
  111. data/support/mobile/ios/App/SceneDelegate.swift +103 -0
  112. data/support/mobile/ios/App.xcodeproj/project.pbxproj +4 -0
  113. data/support/mobile/ios/README.md +4 -1
  114. data/support/{macos → release/macos}/notarize.sh +2 -2
  115. metadata +97 -17
  116. data/support/github/build.yml +0 -85
  117. data/support/shell/src-tauri/gen/schemas/capabilities.json +0 -1
  118. /data/support/{shell → desktop}/splash/index.html +0 -0
  119. /data/support/{shell → desktop}/src-tauri/build.rs +0 -0
  120. /data/support/{shell → desktop}/src-tauri/gen/schemas/acl-manifests.json +0 -0
  121. /data/support/{shell → desktop}/src-tauri/gen/schemas/desktop-schema.json +0 -0
  122. /data/support/{shell → desktop}/src-tauri/gen/schemas/macOS-schema.json +0 -0
  123. /data/support/{shell → desktop}/src-tauri/icons/icon.png +0 -0
  124. /data/support/{shell → desktop}/src-tauri/src/updater.rs +0 -0
  125. /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
@@ -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