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
@@ -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
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ # Turns the raw byte stream off a pty master into whole lines.
5
+ #
6
+ # `every dev` relays the dev server and the desktop shell itself so it can tag
7
+ # each line with its source. That relay has to be line-oriented — a half-line
8
+ # written to the terminal would leave the cursor mid-row and the dock repaints
9
+ # from column 1 — but a pty hands over arbitrary chunks, so this buffers.
10
+ #
11
+ # Two pty-specific quirks it exists to absorb:
12
+ #
13
+ # * ONLCR. The slave's line discipline turns every LF into CRLF, so lines
14
+ # arrive as "text\r\n". The trailing CR is an artifact, not content.
15
+ # * In-place rewrites. cargo (and anything with a progress bar) redraws a
16
+ # row by writing CR and the new text without a newline. What the terminal
17
+ # would have shown is whatever followed the LAST CR, so that is what gets
18
+ # emitted — one settled line instead of thirty flickering ones.
19
+ #
20
+ # Pure: feed it strings, get lines back. No IO, no threads, no clock beyond
21
+ # the idle deadline the caller passes in.
22
+ class LinePump
23
+ # How long a partial line may sit unterminated before it is emitted anyway.
24
+ # Without this a progress bar that never writes a newline — or a prompt like
25
+ # "Overwrite? [Yn]" — would be invisible for as long as it mattered.
26
+ IDLE_FLUSH = 0.2
27
+
28
+ def initialize(idle_flush: IDLE_FLUSH, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
29
+ # Bytes in, text out. A pty hands over BINARY, and a chunk boundary can
30
+ # land in the middle of a multi-byte character, so buffering and splitting
31
+ # happen on bytes — UTF-8 is self-synchronizing, so a newline is never
32
+ # inside a character — and each settled line is transcoded on the way out.
33
+ @buffer = String.new(encoding: Encoding::BINARY)
34
+ @idle_flush = idle_flush
35
+ @clock = clock
36
+ @touched = nil
37
+ end
38
+
39
+ # Feed a chunk; returns the complete lines it produced (without newlines).
40
+ def <<(chunk)
41
+ @buffer << chunk.b
42
+ @touched = @clock.call
43
+ lines = []
44
+ while (index = @buffer.index("\n"))
45
+ raw = @buffer.slice!(0, index + 1)
46
+ line = settle(raw.chomp("\n"))
47
+ lines << line if line
48
+ end
49
+ lines
50
+ end
51
+
52
+ # Emit a still-unterminated partial line once it has gone quiet. Call this
53
+ # from the relay loop whenever a read times out.
54
+ def flush_idle
55
+ return [] if @buffer.empty?
56
+ return [] if @touched && (@clock.call - @touched) < @idle_flush
57
+
58
+ lines = drain
59
+ @touched = @clock.call
60
+ lines
61
+ end
62
+
63
+ # Emit whatever is left, terminated or not. For end-of-stream.
64
+ def drain
65
+ return [] if @buffer.empty?
66
+
67
+ line = settle(@buffer)
68
+ @buffer = +""
69
+ line ? [line] : []
70
+ end
71
+
72
+ def pending? = !@buffer.empty?
73
+
74
+ private
75
+
76
+ # chomp the ONLCR artifact first, THEN collapse an in-place rewrite —
77
+ # order matters. Doing it the other way round reads "bar\r" as a rewrite to
78
+ # the empty string and swallows the line.
79
+ #
80
+ # scrub, not just force_encoding: a build tool can emit a stray byte that is
81
+ # not valid UTF-8, and a String that claims an encoding it doesn't satisfy
82
+ # raises the moment anything tries to concatenate a tag onto it.
83
+ def settle(raw)
84
+ line = raw.chomp("\r")
85
+ line = line[(line.rindex("\r") + 1)..] if line.include?("\r")
86
+ line.dup.force_encoding(Encoding::UTF_8).scrub
87
+ end
88
+ end
89
+ end
@@ -30,6 +30,8 @@ module Everywhere
30
30
  when :notarize then notarize(line)
31
31
  when :tebako then tebako(line)
32
32
  when :xcodebuild then xcodebuild(line)
33
+ when :gradle then gradle(line)
34
+ when :cargo then cargo(line)
33
35
  end
34
36
  end
35
37
 
@@ -153,6 +155,115 @@ module Everywhere
153
155
  end
154
156
  end
155
157
 
158
+ # --- gradle (Android shell) -----------------------------------------------
159
+ #
160
+ # Gradle is the quiet one: a cold build is ~120 lines, ~83 of them
161
+ # "> Task :app:…". So this filter isn't fighting volume like the two above —
162
+ # it exists because of two specific noises that make a healthy build look
163
+ # broken. Gradle 9 ends every build with a four-line "incompatible with
164
+ # Gradle 10" deprecation banner earned by the Android Gradle Plugin, not by
165
+ # anything the user wrote; and hotwire-native-android 1.3.0 compiled with
166
+ # Kotlin 2.3.0 emits exactly 7 `w:` warnings from navigation-fragments
167
+ # (deprecated overrides, getParcelableArrayList, KT-73255) that no app can
168
+ # act on. Both are suppressed here and both stay in the on-disk log.
169
+ #
170
+ # The line that decides a warning's fate is its path: app-authored Kotlin is
171
+ # stamped into extensions/, and that's the only code the user can fix.
172
+ MILESTONES = {
173
+ /:process\w*Manifest\b/ => "merging manifests",
174
+ /:merge\w*Resources\b/ => "merging resources",
175
+ /:merge\w*Assets\b/ => "bundling assets",
176
+ /:compile\w*Kotlin\b/ => "compiling Kotlin",
177
+ /:compile\w*JavaWithJavac\b/ => "compiling Java",
178
+ /:package\w+\b/ => "packaging APK"
179
+ }.freeze
180
+
181
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
182
+ def gradle(line)
183
+ short = UI.short_path(line)
184
+ case line
185
+ # Kotlin/javac/AAPT failures, and Gradle's own failure block.
186
+ when /\Ae: /, /\berror:/i, /\AFAILURE: /, /\A\* What went wrong:/, /\ABUILD FAILED/
187
+ " #{UI.red(short)}"
188
+ when /\Aw: /
189
+ return " #{UI.yellow(short)}" if line.include?("/extensions/")
190
+
191
+ once(:shell_warning) { UI.note_line("Kotlin warnings from the shell template — see the full log") }
192
+ when /\ABUILD SUCCESSFUL(?: in (.+))?/
193
+ UI.note_line("build succeeded#{Regexp.last_match(1) && " (#{Regexp.last_match(1)})"}",
194
+ marker: "✓", color: :green)
195
+ when /\A\d+ actionable tasks?:/
196
+ UI.note_line(line)
197
+
198
+ # First-run downloads are the whole reason a cold build takes minutes.
199
+ when /\ADownloading https:\/\/services\.gradle\.org/
200
+ once(:gradle_dist) { UI.note_line("downloading Gradle (first run only)") }
201
+ when /\AStarting a Gradle Daemon/
202
+ once(:daemon) { UI.note_line("starting the Gradle daemon") }
203
+
204
+ # ~83 task lines. Milestones once each; the rest, including everything
205
+ # already up to date, is background.
206
+ when /\A> Task (\S+)/
207
+ task = Regexp.last_match(1)
208
+ return :drop if line.match?(/(UP-TO-DATE|FROM-CACHE|NO-SOURCE|SKIPPED)\s*\z/)
209
+
210
+ label = MILESTONES.find { |pattern, _| task.match?(pattern) }&.last or return :drop
211
+
212
+ once("task-#{label}") { UI.note_line(label) }
213
+
214
+ # Gradle 9's deprecation banner and its follow-on lines, the incubating
215
+ # problems report, the daemon's welcome, and any progress redraw that
216
+ # survives --console=plain.
217
+ when /Deprecated Gradle features were used in this build/,
218
+ /--warning-mode all/,
219
+ /\AFor more information[, ].*docs\.gradle\.org/,
220
+ /\A\[Incubating\] Problems report/,
221
+ /\AWelcome to Gradle /,
222
+ /\A<[=\-]+>/,
223
+ /\A> (Configure|Starting|Loading|Calculating|IDLE)/
224
+ :drop
225
+ else
226
+ nil # unrecognized -> caller shows it dimmed
227
+ end
228
+ end
229
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
230
+
231
+ # --- `every dev`'s desktop shell (cargo) ----------------------------------
232
+
233
+ # A cold `cargo run` compiles ~300 crates, one "Compiling foo v0.1.0" line
234
+ # each, plus a progress bar it redraws several times a second. None of that
235
+ # is actionable while you're waiting on your own app — but warnings, errors
236
+ # and the moment the shell actually starts very much are.
237
+ #
238
+ # Unlike xcodebuild and Gradle (which we run with --console=plain), cargo is
239
+ # talking to a pty here and colors everything, so its status lines start
240
+ # with SGR codes and carry OSC-8 hyperlinks. Match on the stripped text;
241
+ # print the original, since that color is cargo's and worth keeping.
242
+ #
243
+ # The fall-through returns the line verbatim rather than nil, so it is NOT
244
+ # dimmed as background detail: once the shell is up, everything arriving on
245
+ # this stream is the app's own output, and the body of a Rust compile error
246
+ # is exactly what you need to read in full.
247
+ def cargo(line)
248
+ plain = UI.strip_ansi(line)
249
+ case plain
250
+ when /\A\s*error(\[E\d+\])?[:\[]/
251
+ " #{UI.red(UI.short_path(plain))}"
252
+ when /\A\s*warning: /
253
+ " #{UI.yellow(UI.short_path(plain))}"
254
+ when /\A\s*Compiling /
255
+ once(:compiling) { UI.note_line("compiling the desktop shell (first run is slow)") }
256
+ when /\A\s*(Downloaded|Downloading|Updating|Building \[|Blocking|Locking)/
257
+ :drop
258
+ when /\A\s*Finished\b/
259
+ UI.note_line("desktop shell compiled", marker: "✓", color: :green)
260
+ when /\A\s*Running\b/
261
+ UI.note_line("desktop shell running", marker: "✓", color: :green)
262
+ else
263
+ line
264
+ end
265
+ end
266
+
156
267
  # Show a line only the first time its key is seen; :drop the repeats. Keeps a
157
268
  # long notarization from scrolling a wall of identical "waiting" lines (and
158
269
  # notarytool's thrice-echoed submission id from showing three times).
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "erb"
4
4
  require "json"
5
+ require_relative "config"
5
6
 
6
7
  module Everywhere
7
8
  # Server-side view helpers mirroring the JS bridge's platform detection, so
@@ -9,27 +10,50 @@ module Everywhere
9
10
  # does on the client:
10
11
  #
11
12
  # <%= link_to "Install the app", … unless native_app? %>
12
- # <div class="<%= "native-inset" if native_app? %>">
13
+ # <div class="<%= "native-inset" if mobile_app? %>">
13
14
  # <% if native_version && native_version >= Gem::Version.new("1.2") %>
14
15
  #
15
16
  # Detection is by User-Agent: the RubyEverywhere shells prepend
16
17
  # "RubyEverywhere/<version> (<os>)" to Hotwire Native's own
17
18
  # "Hotwire Native iOS/Android" marker. Included into all Rails views by
18
19
  # Everywhere::Engine.
20
+ #
21
+ # Pick the narrowest one that's true of what you're branching on. `native_app?`
22
+ # means "not a browser tab" and covers the desktop shell; `mobile_app?` means
23
+ # a phone, with a tab bar and safe-area insets; `desktop_app?` means a window,
24
+ # with a title bar and a menu bar. Safe-area padding is mobile_app?; hiding a
25
+ # web nav in favour of native chrome is usually mobile_app? too — the desktop
26
+ # shell has no native navigation to replace it with.
19
27
  module NativeHelper
20
- # True inside any RubyEverywhere native shell (iOS or Android).
28
+ # True inside any RubyEverywhere native shell iOS, Android or desktop.
29
+ # Mirrors the bridge's `Everywhere.native`.
21
30
  def native_app?
22
31
  !native_platform.nil?
23
32
  end
24
33
 
25
- # :ios, :android, or nil in a plain browser. Reads Hotwire Native's UA
34
+ # :ios, :android, :desktop, or nil in a plain browser. Reads the shell's UA
26
35
  # marker, so it's correct even before the JS bridge has booted.
27
36
  def native_platform
28
- ua = _everywhere_user_agent or return nil
29
- return :ios if ua.include?("Hotwire Native iOS")
30
- return :android if ua.include?("Hotwire Native Android")
37
+ Everywhere.native_platform_of(_everywhere_user_agent)
38
+ end
31
39
 
32
- nil
40
+ # True in the Tauri desktop shell. Use it for window chrome — reserving room
41
+ # for an overlay title bar, say — and for anything that assumes a pointer
42
+ # and a resizable window.
43
+ def desktop_app?
44
+ native_platform == :desktop
45
+ end
46
+
47
+ # True in the iOS or Android shell, and the one to reach for when the
48
+ # affordance is a phone's: safe-area insets, the native tab bar, biometrics,
49
+ # the OAuth handoff. Mirrors the bridge's `Everywhere.platform === "mobile"`.
50
+ def mobile_app?
51
+ !mobile_platform.nil?
52
+ end
53
+
54
+ # :ios, :android, or nil (including in the desktop shell).
55
+ def mobile_platform
56
+ Everywhere.mobile_platform_of(_everywhere_user_agent)
33
57
  end
34
58
 
35
59
  # The shell's version as a Gem::Version (from the "RubyEverywhere/<ver>"
@@ -52,8 +76,11 @@ module Everywhere
52
76
  # redirect_to everywhere_auth_redirect(after_authentication_url)
53
77
  #
54
78
  # `to` may be a full URL or a path; only the same-origin path is forwarded.
79
+ # mobile_app?, not native_app?: the reset page exists to rebuild a phone
80
+ # shell's web views and re-fetch its tab bar. The desktop shell has neither,
81
+ # so a plain redirect is both correct and what it already did.
55
82
  def everywhere_auth_redirect(to)
56
- return to unless native_app? && respond_to?(:everywhere_reset_path)
83
+ return to unless mobile_app? && respond_to?(:everywhere_reset_path)
57
84
 
58
85
  path = to.to_s.sub(%r{\Ahttps?://[^/]+}, "")
59
86
  path = "/" if path.empty? || !path.start_with?("/")
@@ -107,7 +134,10 @@ module Everywhere
107
134
  wrapper_class: nil, locked_class: nil,
108
135
  message_class: nil, unlock_class: nil, &block)
109
136
  content = respond_to?(:capture) ? capture(&block) : block.call
110
- return content unless native_app?
137
+ # mobile_app?: there's no Face ID on the desktop shell, so emitting the
138
+ # locked wrapper there would hide the content behind an unlock nothing can
139
+ # satisfy. Browsers and the desktop shell render it plainly.
140
+ return content unless mobile_app?
111
141
 
112
142
  attrs = [%(data-everywhere-biometric-lock="#{_everywhere_attr(name)}")]
113
143
  attrs << %(data-everywhere-biometric-reason="#{_everywhere_attr(reason)}") if reason
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "auth_handoff"
4
+
5
+ module Everywhere
6
+ # OmniAuth 2 and the native sign-in flow, introduced to each other.
7
+ #
8
+ # Since CVE-2015-9284, OmniAuth only starts a provider flow from a POST
9
+ # carrying a CSRF token (omniauth-rails_csrf_protection). Right for browsers —
10
+ # and impossible for the native flow, which enters the provider path through
11
+ # /everywhere/auth/start's redirect: a GET, in a browser session that has no
12
+ # CSRF token to give. Without help, tapping "Sign in with GitHub" in the app
13
+ # ends on OmniAuth's failure page.
14
+ #
15
+ # # config/initializers/omniauth.rb, after your provider setup
16
+ # require "everywhere/omniauth"
17
+ # Everywhere::OmniAuth.protect!
18
+ #
19
+ # Browsers keep exactly the validation they had — whatever
20
+ # request_validation_phase was set to keeps running. The one extra shape
21
+ # allowed through is the native flow's entry: a top-level, not-cross-site GET
22
+ # carrying a marker cookie that /everywhere/auth/start sealed seconds ago.
23
+ # Nobody else can mint that marker (AES-GCM under the app's secret), start
24
+ # refuses cross-site initiators outright, and the freshness window means a
25
+ # marker lingering in Safari's jar after a real sign-in stops vouching for
26
+ # anything almost immediately.
27
+ module OmniAuth
28
+ # How long after start seals the marker its GET still reads as the native
29
+ # flow arriving. The real gap is one redirect — milliseconds — but the
30
+ # sheet can sit on our page briefly on a slow network; anything past this
31
+ # is a browser wandering in.
32
+ ENTRY_WINDOW = 90
33
+
34
+ class << self
35
+ # Wire OmniAuth's global config: allow GET request phases (they're
36
+ # refused per-request by the validator unless they prove themselves),
37
+ # and wrap the configured validation phase so everything that isn't the
38
+ # native entry is judged exactly as before. Safe to call more than once —
39
+ # re-running replaces the wrapper, never stacks it.
40
+ def protect!(entry_window: ENTRY_WINDOW)
41
+ unless defined?(::OmniAuth) && ::OmniAuth.respond_to?(:config)
42
+ raise "Everywhere::OmniAuth.protect! needs OmniAuth loaded first — " \
43
+ "call it below your OmniAuth/Devise setup (config/initializers/omniauth.rb)"
44
+ end
45
+
46
+ config = ::OmniAuth.config
47
+ config.allowed_request_methods = Array(config.allowed_request_methods) | [:get]
48
+ config.silence_get_warning = true if config.respond_to?(:silence_get_warning=)
49
+
50
+ fallback = config.request_validation_phase
51
+ fallback = fallback.fallback if fallback.is_a?(RequestValidator)
52
+ config.request_validation_phase = RequestValidator.new(fallback: fallback, entry_window: entry_window)
53
+ end
54
+ end
55
+
56
+ # OmniAuth request_validation_phase: passes the native flow's entry,
57
+ # delegates everything else. A nil fallback means the app had no request
58
+ # validation configured, and it still has none — this never tightens.
59
+ class RequestValidator
60
+ attr_reader :fallback
61
+
62
+ def initialize(fallback: nil, entry_window: ENTRY_WINDOW)
63
+ @fallback = fallback
64
+ @entry_window = entry_window
65
+ end
66
+
67
+ def call(env)
68
+ return if native_entry?(env)
69
+
70
+ @fallback&.call(env)
71
+ end
72
+
73
+ private
74
+
75
+ # The one shape the native flow produces: a GET, not from a cross-site
76
+ # initiator, carrying a marker minted moments ago. POSTs never qualify —
77
+ # they come from rendered pages that have a CSRF token to present.
78
+ def native_entry?(env)
79
+ return false unless env["REQUEST_METHOD"] == "GET"
80
+ return false if env["HTTP_SEC_FETCH_SITE"] == "cross-site"
81
+
82
+ marker = AuthHandoff.marker(env)
83
+ issued = marker && marker["iat"]
84
+ !!issued && Time.now.to_i - issued.to_i <= @entry_window
85
+ end
86
+ end
87
+ end
88
+ end