ruby_everywhere 0.2.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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +255 -0
  3. data/bridge/README.md +48 -0
  4. data/bridge/everywhere/bridge.js +388 -3
  5. data/bridge/everywhere/native.css +203 -0
  6. data/bridge/package.json +6 -3
  7. data/lib/everywhere/android_resources.rb +217 -0
  8. data/lib/everywhere/android_sdk.rb +330 -0
  9. data/lib/everywhere/asset_catalog.rb +185 -0
  10. data/lib/everywhere/auth_handoff.rb +398 -0
  11. data/lib/everywhere/auth_token.rb +118 -0
  12. data/lib/everywhere/builders/android.rb +844 -0
  13. data/lib/everywhere/builders/ios.rb +72 -3
  14. data/lib/everywhere/commands/build.rb +51 -9
  15. data/lib/everywhere/commands/clean.rb +10 -5
  16. data/lib/everywhere/commands/dev.rb +46 -8
  17. data/lib/everywhere/commands/doctor.rb +89 -3
  18. data/lib/everywhere/commands/install.rb +1 -1
  19. data/lib/everywhere/commands/logs.rb +117 -8
  20. data/lib/everywhere/config.rb +513 -14
  21. data/lib/everywhere/emulator.rb +405 -0
  22. data/lib/everywhere/engine.rb +21 -0
  23. data/lib/everywhere/icon.rb +196 -0
  24. data/lib/everywhere/log_filter.rb +74 -0
  25. data/lib/everywhere/mobile_config_endpoint.rb +13 -0
  26. data/lib/everywhere/mobile_configs_controller.rb +18 -0
  27. data/lib/everywhere/native_helper.rb +198 -5
  28. data/lib/everywhere/omniauth.rb +88 -0
  29. data/lib/everywhere/paths.rb +47 -0
  30. data/lib/everywhere/version.rb +1 -1
  31. data/support/mobile/android/README.md +217 -0
  32. data/support/mobile/android/app/build.gradle.kts +156 -0
  33. data/support/mobile/android/app/everywhere.properties +8 -0
  34. data/support/mobile/android/app/native-packages.gradle.kts +16 -0
  35. data/support/mobile/android/app/proguard-rules.pro +21 -0
  36. data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
  37. data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
  38. data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
  39. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
  40. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
  41. data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
  42. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
  43. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
  44. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
  45. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
  46. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
  47. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
  48. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
  49. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
  50. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
  51. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
  52. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
  53. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
  54. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
  55. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
  56. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
  57. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
  58. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
  59. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
  60. data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
  61. data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
  62. data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
  63. data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
  64. data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
  65. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
  66. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
  67. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
  68. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
  69. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
  70. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  71. data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
  72. data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
  73. data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
  74. data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
  75. data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
  76. data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
  77. data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
  78. data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
  79. data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
  80. data/support/mobile/android/build.gradle.kts +8 -0
  81. data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  82. data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
  83. data/support/mobile/android/gradle.properties +14 -0
  84. data/support/mobile/android/gradlew +251 -0
  85. data/support/mobile/android/gradlew.bat +94 -0
  86. data/support/mobile/android/settings.gradle.kts +28 -0
  87. data/support/mobile/ios/App/AppDelegate.swift +7 -1
  88. data/support/mobile/ios/App/AuthFlow.swift +101 -0
  89. data/support/mobile/ios/App/Bridge/MenuComponent.swift +192 -0
  90. data/support/mobile/ios/App/EverywhereConfig.swift +105 -0
  91. data/support/mobile/ios/App/SceneDelegate.swift +144 -0
  92. data/support/mobile/ios/App.xcodeproj/project.pbxproj +8 -0
  93. data/support/mobile/ios/README.md +10 -0
  94. metadata +69 -1
@@ -30,6 +30,7 @@ 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)
33
34
  end
34
35
  end
35
36
 
@@ -153,6 +154,79 @@ module Everywhere
153
154
  end
154
155
  end
155
156
 
157
+ # --- gradle (Android shell) -----------------------------------------------
158
+ #
159
+ # Gradle is the quiet one: a cold build is ~120 lines, ~83 of them
160
+ # "> Task :app:…". So this filter isn't fighting volume like the two above —
161
+ # it exists because of two specific noises that make a healthy build look
162
+ # broken. Gradle 9 ends every build with a four-line "incompatible with
163
+ # Gradle 10" deprecation banner earned by the Android Gradle Plugin, not by
164
+ # anything the user wrote; and hotwire-native-android 1.3.0 compiled with
165
+ # Kotlin 2.3.0 emits exactly 7 `w:` warnings from navigation-fragments
166
+ # (deprecated overrides, getParcelableArrayList, KT-73255) that no app can
167
+ # act on. Both are suppressed here and both stay in the on-disk log.
168
+ #
169
+ # The line that decides a warning's fate is its path: app-authored Kotlin is
170
+ # stamped into extensions/, and that's the only code the user can fix.
171
+ MILESTONES = {
172
+ /:process\w*Manifest\b/ => "merging manifests",
173
+ /:merge\w*Resources\b/ => "merging resources",
174
+ /:merge\w*Assets\b/ => "bundling assets",
175
+ /:compile\w*Kotlin\b/ => "compiling Kotlin",
176
+ /:compile\w*JavaWithJavac\b/ => "compiling Java",
177
+ /:package\w+\b/ => "packaging APK"
178
+ }.freeze
179
+
180
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
181
+ def gradle(line)
182
+ short = UI.short_path(line)
183
+ case line
184
+ # Kotlin/javac/AAPT failures, and Gradle's own failure block.
185
+ when /\Ae: /, /\berror:/i, /\AFAILURE: /, /\A\* What went wrong:/, /\ABUILD FAILED/
186
+ " #{UI.red(short)}"
187
+ when /\Aw: /
188
+ return " #{UI.yellow(short)}" if line.include?("/extensions/")
189
+
190
+ once(:shell_warning) { UI.note_line("Kotlin warnings from the shell template — see the full log") }
191
+ when /\ABUILD SUCCESSFUL(?: in (.+))?/
192
+ UI.note_line("build succeeded#{Regexp.last_match(1) && " (#{Regexp.last_match(1)})"}",
193
+ marker: "✓", color: :green)
194
+ when /\A\d+ actionable tasks?:/
195
+ UI.note_line(line)
196
+
197
+ # First-run downloads are the whole reason a cold build takes minutes.
198
+ when /\ADownloading https:\/\/services\.gradle\.org/
199
+ once(:gradle_dist) { UI.note_line("downloading Gradle (first run only)") }
200
+ when /\AStarting a Gradle Daemon/
201
+ once(:daemon) { UI.note_line("starting the Gradle daemon") }
202
+
203
+ # ~83 task lines. Milestones once each; the rest, including everything
204
+ # already up to date, is background.
205
+ when /\A> Task (\S+)/
206
+ task = Regexp.last_match(1)
207
+ return :drop if line.match?(/(UP-TO-DATE|FROM-CACHE|NO-SOURCE|SKIPPED)\s*\z/)
208
+
209
+ label = MILESTONES.find { |pattern, _| task.match?(pattern) }&.last or return :drop
210
+
211
+ once("task-#{label}") { UI.note_line(label) }
212
+
213
+ # Gradle 9's deprecation banner and its follow-on lines, the incubating
214
+ # problems report, the daemon's welcome, and any progress redraw that
215
+ # survives --console=plain.
216
+ when /Deprecated Gradle features were used in this build/,
217
+ /--warning-mode all/,
218
+ /\AFor more information[, ].*docs\.gradle\.org/,
219
+ /\A\[Incubating\] Problems report/,
220
+ /\AWelcome to Gradle /,
221
+ /\A<[=\-]+>/,
222
+ /\A> (Configure|Starting|Loading|Calculating|IDLE)/
223
+ :drop
224
+ else
225
+ nil # unrecognized -> caller shows it dimmed
226
+ end
227
+ end
228
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
229
+
156
230
  # Show a line only the first time its key is seen; :drop the repeats. Keeps a
157
231
  # long notarization from scrolling a wall of identical "waiting" lines (and
158
232
  # notarytool's thrice-echoed submission id from showing three times).
@@ -23,6 +23,8 @@ module Everywhere
23
23
  class MobileConfigEndpoint
24
24
  PATH = %r{\A/everywhere/(ios|android)_v1\.json\z}
25
25
  RESET = "/everywhere/reset"
26
+ AASA = "/.well-known/apple-app-site-association"
27
+ ASSETLINKS = "/.well-known/assetlinks.json"
26
28
 
27
29
  def initialize(app, root: Dir.pwd)
28
30
  @app = app
@@ -38,6 +40,17 @@ module Everywhere
38
40
  return html(Everywhere.mobile_reset_html(to))
39
41
  end
40
42
 
43
+ # Deep-linking association files (generated from everywhere.yml). Pass
44
+ # through when not configured so the host app can serve its own.
45
+ if env["PATH_INFO"] == AASA
46
+ body = Config.load(@root).apple_app_site_association_json
47
+ return body ? json(body) : @app.call(env)
48
+ end
49
+ if env["PATH_INFO"] == ASSETLINKS
50
+ body = Config.load(@root).asset_links_json
51
+ return body ? json(body) : @app.call(env)
52
+ end
53
+
41
54
  match = PATH.match(env["PATH_INFO"]) or return @app.call(env)
42
55
 
43
56
  os = match[1]
@@ -34,6 +34,24 @@ module Everywhere
34
34
  render json: config.path_configuration_hash(os, tabs: tabs)
35
35
  end
36
36
 
37
+ # GET /.well-known/apple-app-site-association — the iOS universal-links
38
+ # association file, generated from everywhere.yml. Extension-less and
39
+ # application/json, per Apple. 404 when deep linking isn't configured.
40
+ def apple_app_site_association
41
+ json = Config.load(::Rails.root.to_s).apple_app_site_association_json
42
+ return head(:not_found) unless json
43
+
44
+ render json: json
45
+ end
46
+
47
+ # GET /.well-known/assetlinks.json — the Android Digital Asset Links file.
48
+ def asset_links
49
+ json = Config.load(::Rails.root.to_s).asset_links_json
50
+ return head(:not_found) unless json
51
+
52
+ render json: json
53
+ end
54
+
37
55
  # GET /everywhere/reset?to=/path — the native "reset the app" page. Auth
38
56
  # flows redirect the shell here so it rebuilds cleanly before landing on
39
57
  # `to`. Never cached (it must run every time).
@@ -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
@@ -25,11 +26,7 @@ module Everywhere
25
26
  # :ios, :android, or nil in a plain browser. Reads Hotwire Native's UA
26
27
  # marker, so it's correct even before the JS bridge has booted.
27
28
  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")
31
-
32
- nil
29
+ Everywhere.native_platform_of(_everywhere_user_agent)
33
30
  end
34
31
 
35
32
  # The shell's version as a Gem::Version (from the "RubyEverywhere/<ver>"
@@ -140,8 +137,204 @@ module Everywhere
140
137
  html.respond_to?(:html_safe) ? html.html_safe : html
141
138
  end
142
139
 
140
+ # A navigation-bar button. Renders a normal link (or a submit button with
141
+ # type: :submit) that the mobile shell lifts into the top navigation bar —
142
+ # tapping the native button just clicks this element, so a link navigates
143
+ # and a submit submits, defined once. In a browser it's the plain link it
144
+ # already is (the shell hides the in-page copy). Icons are per-platform like
145
+ # tabs: icon: is the shared fallback, icons: {ios:, android:} the specifics
146
+ # (SF Symbol / Material name).
147
+ #
148
+ # <%= everywhere_nav_button "New", new_note_path, icons: { ios: "plus", android: "add" } %>
149
+ # <%= everywhere_nav_button "Save", type: :submit, style: "done" %>
150
+ def everywhere_nav_button(title, href = nil, icon: nil, icons: nil, side: "right",
151
+ style: "plain", type: nil, form: nil, disabled: false,
152
+ method: nil, css_class: nil, id: nil)
153
+ nav = _everywhere_nav_attrs("data-everywhere-nav-button", title: title, icon: icon,
154
+ icons: icons, side: side, style: style, disabled: disabled)
155
+ _everywhere_clickable(title, href, type: type, form: form, method: method,
156
+ disabled: disabled, css_class: css_class, id: id, extra: nav)
157
+ end
158
+
159
+ # A form's submit control mirrored into the navigation bar (right side,
160
+ # "done" style by default). Place it inside the <form>, or aim it at one
161
+ # with form: "<form-id>".
162
+ #
163
+ # <%= form_with model: @note do |f| %>
164
+ # <%= everywhere_submit_button "Save" %>
165
+ # ...
166
+ # <% end %>
167
+ def everywhere_submit_button(title = "Save", side: "right", style: "done", form: nil,
168
+ icon: nil, icons: nil, css_class: nil, id: nil)
169
+ everywhere_nav_button(title, type: :submit, side: side, style: style, form: form,
170
+ icon: icon, icons: icons, css_class: css_class, id: id)
171
+ end
172
+
173
+ # A navigation-bar pull-down / overflow menu (defaults to the ⋯ ellipsis
174
+ # icon). Yields the items — build them with everywhere_menu_item; in the
175
+ # shell they become a native UIMenu, in a browser a plain list of links.
176
+ #
177
+ # <%= everywhere_nav_menu do %>
178
+ # <%= everywhere_menu_item "Share", share_path, icons: { ios: "square.and.arrow.up" } %>
179
+ # <%= everywhere_menu_item "Delete", note_path(@note), method: :delete, style: "destructive" %>
180
+ # <% end %>
181
+ def everywhere_nav_menu(title = nil, icon: nil, icons: nil, side: "right",
182
+ css_class: nil, id: nil, &block)
183
+ content = _everywhere_capture(&block)
184
+ attrs = _everywhere_nav_attrs("data-everywhere-nav-menu", title: title, icon: icon,
185
+ icons: icons, side: side)
186
+ common = +""
187
+ common << %( id="#{_everywhere_attr(id)}") if id
188
+ common << %( class="#{_everywhere_attr(css_class)}") if css_class
189
+ _everywhere_safe(%(<div#{common} #{attrs}>#{content}</div>))
190
+ end
191
+
192
+ # One item inside an everywhere_nav_menu or everywhere_menu. A link by
193
+ # default; type: :submit (with form:) submits a form, and method: (:delete,
194
+ # …) makes a Turbo method link. style: "destructive" tints it in the native
195
+ # menu / action sheet.
196
+ def everywhere_menu_item(title, href = nil, icon: nil, icons: nil, style: nil,
197
+ type: nil, form: nil, method: nil, css_class: nil, id: nil)
198
+ extra = +"data-everywhere-menu-item"
199
+ extra << %( data-everywhere-menu-title="#{_everywhere_attr(title)}")
200
+ extra << _everywhere_icon_attrs("data-everywhere-menu-icon", icon, icons)
201
+ extra << %( data-everywhere-menu-style="#{_everywhere_attr(style)}") if style
202
+ _everywhere_clickable(title, href, type: type, form: form, method: method,
203
+ css_class: css_class, id: id, extra: extra)
204
+ end
205
+
206
+ # An in-content action sheet: a trigger and the items it opens. In the
207
+ # mobile shell the trigger presents a native action sheet; in a browser it
208
+ # toggles the items as an inline menu (styled by everywhere/native.css).
209
+ # Choosing an item clicks it, so behavior lives in the items themselves.
210
+ #
211
+ # <%= everywhere_menu "Options" do %>
212
+ # <%= everywhere_menu_item "Edit", edit_post_path(@post) %>
213
+ # <%= everywhere_menu_item "Delete", post_path(@post), method: :delete, style: "destructive" %>
214
+ # <% end %>
215
+ def everywhere_menu(trigger = "Options", title: nil, trigger_class: nil,
216
+ items_class: nil, css_class: nil, id: nil, &block)
217
+ content = _everywhere_capture(&block)
218
+ wrap = +""
219
+ wrap << %( id="#{_everywhere_attr(id)}") if id
220
+ wrap << %( class="#{_everywhere_attr(css_class)}") if css_class
221
+ wrap << %( data-everywhere-menu-title="#{_everywhere_attr(title)}") if title
222
+
223
+ trig = %(<button type="button" data-everywhere-menu-trigger#{%( class="#{_everywhere_attr(trigger_class)}") if trigger_class}>#{_everywhere_attr(trigger)}</button>)
224
+ items = %(<div data-everywhere-menu-items#{%( class="#{_everywhere_attr(items_class)}") if items_class}>#{content}</div>)
225
+ _everywhere_safe(%(<div data-everywhere-menu#{wrap}>#{trig}#{items}</div>))
226
+ end
227
+
228
+ # A floating action button: a fixed, safe-area-aware circular control (see
229
+ # everywhere/native.css). A real link (or button) that shows in a browser
230
+ # too and gains a tap haptic in the shell. Pass a block for custom content,
231
+ # or icon: for a built-in line glyph; label: sets the accessible name and,
232
+ # with extended: true, a visible label pill.
233
+ #
234
+ # <%= everywhere_fab new_note_path, icon: :plus, label: "New note" %>
235
+ # <%= everywhere_fab compose_path, icon: :pencil, label: "Compose", extended: true %>
236
+ def everywhere_fab(href = nil, icon: :plus, label: nil, haptic: "light", side: "right",
237
+ extended: false, type: nil, form: nil, method: nil,
238
+ css_class: nil, id: nil, &block)
239
+ classes = ["everywhere-fab"]
240
+ classes << "everywhere-fab-left" if side.to_s == "left"
241
+ classes << "everywhere-fab-extended" if extended
242
+ classes << css_class if css_class
243
+
244
+ inner = block ? _everywhere_capture(&block) : _everywhere_fab_icon(icon)
245
+ inner = "#{inner}<span class=\"everywhere-fab-label\">#{_everywhere_attr(label)}</span>" if extended && label
246
+
247
+ extra = %(class="#{classes.join(" ")}")
248
+ extra << %( aria-label="#{_everywhere_attr(label)}") if label
249
+ extra << %( data-everywhere-haptic="#{_everywhere_attr(haptic)}") if haptic
250
+
251
+ _everywhere_clickable(inner, href, type: type, form: form, method: method,
252
+ id: id, extra: extra, raw_content: true)
253
+ end
254
+
143
255
  private
144
256
 
257
+ # Build a link or button carrying `extra` attributes. `raw_content: true`
258
+ # trusts pre-built inner HTML (icon SVG); otherwise the text is escaped.
259
+ def _everywhere_clickable(content, href, type: nil, form: nil, method: nil,
260
+ disabled: false, css_class: nil, id: nil, extra: "",
261
+ raw_content: false)
262
+ common = +""
263
+ common << %( id="#{_everywhere_attr(id)}") if id
264
+ common << %( class="#{_everywhere_attr(css_class)}") if css_class
265
+ inner = raw_content ? content.to_s : _everywhere_attr(content)
266
+
267
+ html =
268
+ if type.to_s == "submit"
269
+ %(<button type="submit"#{%( form="#{_everywhere_attr(form)}") if form}#{" disabled" if disabled}#{common} #{extra}>#{inner}</button>)
270
+ elsif href.nil? && type
271
+ %(<button type="#{_everywhere_attr(type)}"#{" disabled" if disabled}#{common} #{extra}>#{inner}</button>)
272
+ else
273
+ meth = method ? %( data-turbo-method="#{_everywhere_attr(method)}") : ""
274
+ %(<a href="#{_everywhere_attr(href || "#")}"#{meth}#{common} #{extra}>#{inner}</a>)
275
+ end
276
+ _everywhere_safe(html)
277
+ end
278
+
279
+ # The data-* attribute run shared by nav buttons and nav menus.
280
+ def _everywhere_nav_attrs(marker, title:, icon: nil, icons: nil, side: "right",
281
+ style: nil, disabled: false)
282
+ out = +marker.to_s
283
+ out << %( data-everywhere-nav-title="#{_everywhere_attr(title)}") if title
284
+ out << _everywhere_icon_attrs("data-everywhere-nav-icon", icon, icons)
285
+ out << %( data-everywhere-nav-side="left") if side.to_s == "left"
286
+ out << %( data-everywhere-nav-style="#{_everywhere_attr(style)}") if style && style.to_s != "plain"
287
+ out << " data-everywhere-nav-disabled" if disabled
288
+ out
289
+ end
290
+
291
+ # Per-platform icon attributes: the shared `<base>` plus `<base>-ios` /
292
+ # `<base>-android` from an icons: {ios:, android:} hash (string or symbol keys).
293
+ def _everywhere_icon_attrs(base, icon, icons)
294
+ out = +""
295
+ out << %( #{base}="#{_everywhere_attr(icon)}") if icon
296
+ if icons.is_a?(Hash)
297
+ %i[ios android].each do |os|
298
+ value = icons[os] || icons[os.to_s]
299
+ out << %( #{base}-#{os}="#{_everywhere_attr(value)}") if value
300
+ end
301
+ end
302
+ out
303
+ end
304
+
305
+ def _everywhere_capture(&block)
306
+ return "" unless block
307
+
308
+ respond_to?(:capture) ? capture(&block) : block.call
309
+ end
310
+
311
+ def _everywhere_safe(html)
312
+ html.respond_to?(:html_safe) ? html.html_safe : html
313
+ end
314
+
315
+ # Built-in line glyphs for everywhere_fab (24×24, currentColor stroke). SF
316
+ # Symbol / Material names don't render on the web, so the FAB carries its own.
317
+ FAB_ICONS = {
318
+ plus: "M12 5v14M5 12h14",
319
+ pencil: "M12 20h9M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z",
320
+ camera: "M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2zM12 17a4 4 0 1 0 0-8 4 4 0 0 0 0 8z",
321
+ search: "M21 21l-4.35-4.35M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z",
322
+ share: "M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v14",
323
+ check: "M20 6L9 17l-5-5",
324
+ close: "M18 6L6 18M6 6l12 12",
325
+ trash: "M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6",
326
+ arrow_up: "M12 19V5M5 12l7-7 7 7",
327
+ chevron_up: "M18 15l-6-6-6 6"
328
+ }.freeze
329
+
330
+ def _everywhere_fab_icon(icon)
331
+ return "" if icon.nil?
332
+
333
+ path = FAB_ICONS[icon.to_sym] || FAB_ICONS[:plus]
334
+ %(<svg class="everywhere-fab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" ) +
335
+ %(stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="#{path}"/></svg>)
336
+ end
337
+
145
338
  def _everywhere_attr(value)
146
339
  ERB::Util.html_escape(value.to_s)
147
340
  end
@@ -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
@@ -54,6 +54,30 @@ module Everywhere
54
54
  "reinstall ruby_everywhere or pass --template-dir")
55
55
  end
56
56
 
57
+ # The Hotwire Native Android shell template bundled inside the gem:
58
+ # support/mobile/android/ holds a frozen Gradle project (wrapper committed)
59
+ # the CLI stamps per-app — everywhere.properties + assets + res, never a
60
+ # build script. Same freeze contract the iOS template has.
61
+ def bundled_android_dir
62
+ File.join(gem_root, "support", "mobile", "android")
63
+ end
64
+
65
+ # Absolute path to the Android template, or nil when it isn't there. Unlike
66
+ # #ios_dir, nil is the NORMAL state until the template lands — callers must
67
+ # read it as "Android isn't buildable in this release yet", not as a corrupt
68
+ # install. settings.gradle.kts is the marker because it's the one file every
69
+ # Gradle project must have at its root.
70
+ def android_dir
71
+ bundled_android_dir if File.exist?(File.join(bundled_android_dir, "settings.gradle.kts"))
72
+ end
73
+
74
+ # Like #android_dir but aborts with a helpful message when the template is
75
+ # missing. The --template-dir hint covers dev on a modified template.
76
+ def android_dir!
77
+ android_dir or UI.die!("couldn't find the bundled Android template at #{bundled_android_dir}; " \
78
+ "reinstall ruby_everywhere or pass --template-dir")
79
+ end
80
+
57
81
  # Per-user cache dir. Keeps heavy build state out of the gem and the app.
58
82
  def cache_dir
59
83
  File.expand_path(ENV["RUBYEVERYWHERE_HOME"] || "~/.rubyeverywhere")
@@ -79,6 +103,29 @@ module Everywhere
79
103
  File.join(cache_dir, "ios-packages")
80
104
  end
81
105
 
106
+ # The stamped copy of the Android template for one app, keyed by
107
+ # applicationId. Persistent for the same reasons ios_work_dir is: Gradle's
108
+ # incremental state is path-sensitive, and the stamped project opens in
109
+ # Android Studio unmodified.
110
+ def android_work_dir(application_id)
111
+ File.join(cache_dir, "android", application_id)
112
+ end
113
+
114
+ # GRADLE_USER_HOME for shell builds. Redirected here so the multi-GB Gradle
115
+ # distribution, the Android Gradle Plugin and the Hotwire artifacts are
116
+ # downloaded once and stay warm across every app — and never land inside the
117
+ # installed gem. Same trick as cargo_target_dir.
118
+ def android_gradle_home
119
+ File.join(cache_dir, "android-gradle")
120
+ end
121
+
122
+ # Where the Material Symbols icon font is cached after its one-time fetch.
123
+ # The font is ~10 MB, so it's fetched on first Android build instead of
124
+ # riding along in a 229 KB gem that most users never build Android with.
125
+ def android_fonts_dir
126
+ File.join(cache_dir, "android-fonts")
127
+ end
128
+
82
129
  # Where cargo writes the shell's build output (CARGO_TARGET_DIR). Redirected
83
130
  # here so cargo never dumps a multi-GB target/ into the installed gem, and so
84
131
  # the shell compiles warm across projects — the shell source is identical for
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Everywhere
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
 
6
6
  # Version of the @rubyeverywhere/bridge JS this gem ships. bridge/ in the
7
7
  # gem IS the npm package (served to Rails apps by Everywhere::Engine,