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.
- checksums.yaml +4 -4
- data/README.md +255 -0
- data/bridge/README.md +48 -0
- data/bridge/everywhere/bridge.js +388 -3
- data/bridge/everywhere/native.css +203 -0
- data/bridge/package.json +6 -3
- data/lib/everywhere/android_resources.rb +217 -0
- data/lib/everywhere/android_sdk.rb +330 -0
- data/lib/everywhere/asset_catalog.rb +185 -0
- data/lib/everywhere/auth_handoff.rb +398 -0
- data/lib/everywhere/auth_token.rb +118 -0
- data/lib/everywhere/builders/android.rb +844 -0
- data/lib/everywhere/builders/ios.rb +72 -3
- data/lib/everywhere/commands/build.rb +51 -9
- data/lib/everywhere/commands/clean.rb +10 -5
- data/lib/everywhere/commands/dev.rb +46 -8
- data/lib/everywhere/commands/doctor.rb +89 -3
- data/lib/everywhere/commands/install.rb +1 -1
- data/lib/everywhere/commands/logs.rb +117 -8
- data/lib/everywhere/config.rb +513 -14
- data/lib/everywhere/emulator.rb +405 -0
- data/lib/everywhere/engine.rb +21 -0
- data/lib/everywhere/icon.rb +196 -0
- data/lib/everywhere/log_filter.rb +74 -0
- data/lib/everywhere/mobile_config_endpoint.rb +13 -0
- data/lib/everywhere/mobile_configs_controller.rb +18 -0
- data/lib/everywhere/native_helper.rb +198 -5
- data/lib/everywhere/omniauth.rb +88 -0
- data/lib/everywhere/paths.rb +47 -0
- data/lib/everywhere/version.rb +1 -1
- data/support/mobile/android/README.md +217 -0
- data/support/mobile/android/app/build.gradle.kts +156 -0
- data/support/mobile/android/app/everywhere.properties +8 -0
- data/support/mobile/android/app/native-packages.gradle.kts +16 -0
- data/support/mobile/android/app/proguard-rules.pro +21 -0
- data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
- data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
- data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
- data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
- data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
- data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
- data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
- data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
- data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
- data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
- data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
- data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
- data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
- data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
- data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
- data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
- data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
- data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
- data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
- data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
- data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
- data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
- data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
- data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
- data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
- data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
- data/support/mobile/android/build.gradle.kts +8 -0
- data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
- data/support/mobile/android/gradle.properties +14 -0
- data/support/mobile/android/gradlew +251 -0
- data/support/mobile/android/gradlew.bat +94 -0
- data/support/mobile/android/settings.gradle.kts +28 -0
- data/support/mobile/ios/App/AppDelegate.swift +7 -1
- data/support/mobile/ios/App/AuthFlow.swift +101 -0
- data/support/mobile/ios/App/Bridge/MenuComponent.swift +192 -0
- data/support/mobile/ios/App/EverywhereConfig.swift +105 -0
- data/support/mobile/ios/App/SceneDelegate.swift +144 -0
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +8 -0
- data/support/mobile/ios/README.md +10 -0
- 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
|
-
|
|
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
|
data/lib/everywhere/paths.rb
CHANGED
|
@@ -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
|
data/lib/everywhere/version.rb
CHANGED