ruby_everywhere 0.2.0 → 0.3.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/bridge/README.md +48 -0
- data/bridge/everywhere/bridge.js +328 -0
- data/bridge/everywhere/native.css +203 -0
- data/bridge/package.json +6 -3
- data/lib/everywhere/builders/ios.rb +32 -0
- data/lib/everywhere/config.rb +104 -1
- data/lib/everywhere/engine.rb +9 -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 +196 -0
- data/lib/everywhere/version.rb +1 -1
- data/support/mobile/ios/App/AppDelegate.swift +2 -1
- data/support/mobile/ios/App/Bridge/MenuComponent.swift +192 -0
- data/support/mobile/ios/App/EverywhereConfig.swift +21 -0
- data/support/mobile/ios/App/SceneDelegate.swift +41 -0
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +4 -0
- data/support/mobile/ios/README.md +7 -0
- metadata +3 -1
|
@@ -86,11 +86,39 @@ module Everywhere
|
|
|
86
86
|
@config.path_configuration_json("ios"))
|
|
87
87
|
write_launch_background(work)
|
|
88
88
|
write_usage_strings(work)
|
|
89
|
+
write_entitlements(work)
|
|
89
90
|
write_app_icon(work)
|
|
90
91
|
write_extensions(work)
|
|
91
92
|
write_native_packages(work)
|
|
92
93
|
end
|
|
93
94
|
|
|
95
|
+
# Associated Domains entitlement for deep linking (everywhere.yml
|
|
96
|
+
# deep_linking:). Written only when configured — the default build stays
|
|
97
|
+
# entitlement-free, so the frozen template still compiles unchanged. The
|
|
98
|
+
# matching CODE_SIGN_ENTITLEMENTS is set in the xcconfig (write_xcconfig).
|
|
99
|
+
# applinks: for universal links, webcredentials: to match the association
|
|
100
|
+
# file's webcredentials section (password autofill / associated credentials).
|
|
101
|
+
def write_entitlements(work)
|
|
102
|
+
domains = @config.deep_linking_domains
|
|
103
|
+
return unless @config.deep_linking? && domains.any?
|
|
104
|
+
|
|
105
|
+
values = domains.flat_map { |host| ["applinks:#{host}", "webcredentials:#{host}"] }
|
|
106
|
+
entries = values.map { |v| "\t\t<string>#{v}</string>" }.join("\n")
|
|
107
|
+
UI.step("stamping associated domains #{UI.dim("(#{domains.join(", ")})")}")
|
|
108
|
+
File.write(File.join(work, "App", "App.entitlements"), <<~PLIST)
|
|
109
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
110
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
111
|
+
<plist version="1.0">
|
|
112
|
+
<dict>
|
|
113
|
+
\t<key>com.apple.developer.associated-domains</key>
|
|
114
|
+
\t<array>
|
|
115
|
+
#{entries}
|
|
116
|
+
\t</array>
|
|
117
|
+
</dict>
|
|
118
|
+
</plist>
|
|
119
|
+
PLIST
|
|
120
|
+
end
|
|
121
|
+
|
|
94
122
|
# Native extensions: the app repo's native/ios/*.swift copied into the
|
|
95
123
|
# template's Extensions/ folder, plus a generated registry naming what
|
|
96
124
|
# everywhere.yml declares (native.ios). Extensions/ is a filesystem-
|
|
@@ -300,6 +328,9 @@ module Everywhere
|
|
|
300
328
|
end
|
|
301
329
|
|
|
302
330
|
def write_xcconfig(work)
|
|
331
|
+
# Only point at an entitlements file when deep linking wrote one — an
|
|
332
|
+
# empty CODE_SIGN_ENTITLEMENTS would break the signing step.
|
|
333
|
+
entitlements = "CODE_SIGN_ENTITLEMENTS = App/App.entitlements\n" if @config.deep_linking? && @config.deep_linking_domains.any?
|
|
303
334
|
File.write(File.join(work, "App", "App.xcconfig"), <<~XCCONFIG)
|
|
304
335
|
// Written by `every build --target #{@target}` — do not edit; changes
|
|
305
336
|
// belong in config/everywhere.yml.
|
|
@@ -308,6 +339,7 @@ module Everywhere
|
|
|
308
339
|
CURRENT_PROJECT_VERSION = 1
|
|
309
340
|
INFOPLIST_KEY_CFBundleDisplayName = #{name}
|
|
310
341
|
DEVELOPMENT_TEAM =
|
|
342
|
+
#{entitlements}
|
|
311
343
|
XCCONFIG
|
|
312
344
|
end
|
|
313
345
|
|
data/lib/everywhere/config.rb
CHANGED
|
@@ -555,6 +555,106 @@ module Everywhere
|
|
|
555
555
|
end
|
|
556
556
|
end
|
|
557
557
|
|
|
558
|
+
# Deep linking / universal links. Declares the app's association with its
|
|
559
|
+
# web domains so the OS can hand matching URLs to the app instead of the
|
|
560
|
+
# browser. The gem serves the two well-known association files this needs —
|
|
561
|
+
# /.well-known/apple-app-site-association and /.well-known/assetlinks.json —
|
|
562
|
+
# generated from this config (Everywhere::Engine in Rails; MobileConfigEndpoint
|
|
563
|
+
# for Sinatra/Hanami). The iOS builder stamps the matching Associated Domains
|
|
564
|
+
# entitlement, and the shell routes an incoming link to its path.
|
|
565
|
+
#
|
|
566
|
+
# deep_linking:
|
|
567
|
+
# team_id: ABCDE12345 # derives the iOS app id from bundle_id
|
|
568
|
+
# # or set app ids explicitly:
|
|
569
|
+
# # apple_app_id: ABCDE12345.com.example.app
|
|
570
|
+
# # apple_app_ids: ["ABCDE12345.com.example.app"]
|
|
571
|
+
# paths: ["/*"] # which paths open the app (default: all)
|
|
572
|
+
# domains: ["www.example.com"] # extra applinks: domains (remote host is implicit)
|
|
573
|
+
# android:
|
|
574
|
+
# package: com.example.app
|
|
575
|
+
# sha256_cert_fingerprints: ["AB:CD:EF:..."]
|
|
576
|
+
def deep_linking = @data.fetch("deep_linking", nil) || {}
|
|
577
|
+
|
|
578
|
+
# The iOS/tvOS/... app identifiers (TeamID.bundleID) the association file
|
|
579
|
+
# advertises. Explicit ids win; otherwise team_id + the app's iOS bundle id.
|
|
580
|
+
def deep_linking_apple_app_ids
|
|
581
|
+
explicit = Array(deep_linking["apple_app_ids"]) + [deep_linking["apple_app_id"]].compact
|
|
582
|
+
return explicit.map(&:to_s).uniq unless explicit.empty?
|
|
583
|
+
|
|
584
|
+
team = deep_linking["team_id"].to_s.strip
|
|
585
|
+
team.empty? ? [] : ["#{team}.#{bundle_id(target: "ios")}"]
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# URL path patterns that open the app. Modern (iOS 14+) component form;
|
|
589
|
+
# defaults to every path.
|
|
590
|
+
def deep_linking_paths
|
|
591
|
+
paths = Array(deep_linking["paths"]).map(&:to_s).reject(&:empty?)
|
|
592
|
+
paths.empty? ? ["*"] : paths
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def deep_linking_android = deep_linking["android"].is_a?(Hash) ? deep_linking["android"] : {}
|
|
596
|
+
def deep_linking_android_package = deep_linking_android["package"]&.to_s
|
|
597
|
+
|
|
598
|
+
def deep_linking_android_fingerprints
|
|
599
|
+
Array(deep_linking_android["sha256_cert_fingerprints"]).map { |f| f.to_s.strip }.reject(&:empty?)
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def deep_linking_android? = !deep_linking_android_package.to_s.empty? && !deep_linking_android_fingerprints.empty?
|
|
603
|
+
|
|
604
|
+
def deep_linking_apple? = !deep_linking_apple_app_ids.empty?
|
|
605
|
+
|
|
606
|
+
def deep_linking? = deep_linking_apple? || deep_linking_android?
|
|
607
|
+
|
|
608
|
+
# Domains for the iOS Associated Domains entitlement (applinks:<host>) and
|
|
609
|
+
# the shell's universal-link host allowlist: the remote host plus any extra
|
|
610
|
+
# `domains:`. Bare hostnames (no scheme, no path).
|
|
611
|
+
def deep_linking_domains
|
|
612
|
+
hosts = []
|
|
613
|
+
if remote_url
|
|
614
|
+
require "uri"
|
|
615
|
+
hosts << URI(remote_url).host
|
|
616
|
+
end
|
|
617
|
+
hosts += Array(deep_linking["domains"]).map { |d| d.to_s.sub(%r{\Ahttps?://}, "").sub(%r{/.*\z}, "") }
|
|
618
|
+
hosts.compact.reject(&:empty?).uniq
|
|
619
|
+
rescue URI::InvalidURIError
|
|
620
|
+
Array(deep_linking["domains"]).map(&:to_s).reject(&:empty?).uniq
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
# The apple-app-site-association document (a Hash), or nil when no Apple app
|
|
624
|
+
# ids are configured. `applinks` for universal links; `webcredentials` so
|
|
625
|
+
# associated-domains password autofill / Face ID credentials work too.
|
|
626
|
+
def apple_app_site_association
|
|
627
|
+
ids = deep_linking_apple_app_ids
|
|
628
|
+
return nil if ids.empty?
|
|
629
|
+
|
|
630
|
+
{ "applinks" => {
|
|
631
|
+
"details" => [{ "appIDs" => ids, "components" => deep_linking_paths.map { |p| { "/" => p } } }]
|
|
632
|
+
},
|
|
633
|
+
"webcredentials" => { "apps" => ids } }
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
def apple_app_site_association_json
|
|
637
|
+
require "json"
|
|
638
|
+
doc = apple_app_site_association or return nil
|
|
639
|
+
JSON.generate(doc)
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
# The Digital Asset Links document (an Array), or nil without an Android app.
|
|
643
|
+
def asset_links
|
|
644
|
+
return nil unless deep_linking_android?
|
|
645
|
+
|
|
646
|
+
[{ "relation" => ["delegate_permission/common.handle_all_urls"],
|
|
647
|
+
"target" => { "namespace" => "android_app",
|
|
648
|
+
"package_name" => deep_linking_android_package,
|
|
649
|
+
"sha256_cert_fingerprints" => deep_linking_android_fingerprints } }]
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
def asset_links_json
|
|
653
|
+
require "json"
|
|
654
|
+
doc = asset_links or return nil
|
|
655
|
+
JSON.generate(doc)
|
|
656
|
+
end
|
|
657
|
+
|
|
558
658
|
# The subset the shell needs, shipped as JSON (env var in dev,
|
|
559
659
|
# Resources/everywhere.json inside a bundled .app). Pass `target:` so the
|
|
560
660
|
# packaged config reflects the platform being built (its bundle id / name).
|
|
@@ -574,7 +674,10 @@ module Everywhere
|
|
|
574
674
|
"lazy_load_tabs" => native_ios_lazy_load_tabs,
|
|
575
675
|
"splash_min_seconds" => native_ios_splash_min_seconds,
|
|
576
676
|
"updates" => updates_shell_hash,
|
|
577
|
-
"permissions" => (permissions.keys unless permissions.empty?)
|
|
677
|
+
"permissions" => (permissions.keys unless permissions.empty?),
|
|
678
|
+
# Hosts the shell treats as its own for incoming universal links, so a
|
|
679
|
+
# tapped link to any associated domain opens in-app rather than Safari.
|
|
680
|
+
"universal_link_hosts" => (deep_linking_domains if deep_linking? && !deep_linking_domains.empty?)
|
|
578
681
|
}.compact
|
|
579
682
|
end
|
|
580
683
|
|
data/lib/everywhere/engine.rb
CHANGED
|
@@ -56,6 +56,15 @@ module Everywhere
|
|
|
56
56
|
constraints: { platform_v1: /(?:ios|android)_v1/ },
|
|
57
57
|
defaults: { format: "json" },
|
|
58
58
|
as: :everywhere_mobile_config
|
|
59
|
+
|
|
60
|
+
# Deep-linking association files, generated from everywhere.yml's
|
|
61
|
+
# deep_linking: section. Served only when configured (404 otherwise, so
|
|
62
|
+
# an app can draw its own). Apple's file is intentionally extension-less
|
|
63
|
+
# and must be application/json.
|
|
64
|
+
get "/.well-known/apple-app-site-association",
|
|
65
|
+
to: Everywhere::MobileConfigsController.action(:apple_app_site_association)
|
|
66
|
+
get "/.well-known/assetlinks.json",
|
|
67
|
+
to: Everywhere::MobileConfigsController.action(:asset_links)
|
|
59
68
|
end
|
|
60
69
|
end
|
|
61
70
|
end
|
|
@@ -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).
|
|
@@ -140,8 +140,204 @@ module Everywhere
|
|
|
140
140
|
html.respond_to?(:html_safe) ? html.html_safe : html
|
|
141
141
|
end
|
|
142
142
|
|
|
143
|
+
# A navigation-bar button. Renders a normal link (or a submit button with
|
|
144
|
+
# type: :submit) that the mobile shell lifts into the top navigation bar —
|
|
145
|
+
# tapping the native button just clicks this element, so a link navigates
|
|
146
|
+
# and a submit submits, defined once. In a browser it's the plain link it
|
|
147
|
+
# already is (the shell hides the in-page copy). Icons are per-platform like
|
|
148
|
+
# tabs: icon: is the shared fallback, icons: {ios:, android:} the specifics
|
|
149
|
+
# (SF Symbol / Material name).
|
|
150
|
+
#
|
|
151
|
+
# <%= everywhere_nav_button "New", new_note_path, icons: { ios: "plus", android: "add" } %>
|
|
152
|
+
# <%= everywhere_nav_button "Save", type: :submit, style: "done" %>
|
|
153
|
+
def everywhere_nav_button(title, href = nil, icon: nil, icons: nil, side: "right",
|
|
154
|
+
style: "plain", type: nil, form: nil, disabled: false,
|
|
155
|
+
method: nil, css_class: nil, id: nil)
|
|
156
|
+
nav = _everywhere_nav_attrs("data-everywhere-nav-button", title: title, icon: icon,
|
|
157
|
+
icons: icons, side: side, style: style, disabled: disabled)
|
|
158
|
+
_everywhere_clickable(title, href, type: type, form: form, method: method,
|
|
159
|
+
disabled: disabled, css_class: css_class, id: id, extra: nav)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# A form's submit control mirrored into the navigation bar (right side,
|
|
163
|
+
# "done" style by default). Place it inside the <form>, or aim it at one
|
|
164
|
+
# with form: "<form-id>".
|
|
165
|
+
#
|
|
166
|
+
# <%= form_with model: @note do |f| %>
|
|
167
|
+
# <%= everywhere_submit_button "Save" %>
|
|
168
|
+
# ...
|
|
169
|
+
# <% end %>
|
|
170
|
+
def everywhere_submit_button(title = "Save", side: "right", style: "done", form: nil,
|
|
171
|
+
icon: nil, icons: nil, css_class: nil, id: nil)
|
|
172
|
+
everywhere_nav_button(title, type: :submit, side: side, style: style, form: form,
|
|
173
|
+
icon: icon, icons: icons, css_class: css_class, id: id)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# A navigation-bar pull-down / overflow menu (defaults to the ⋯ ellipsis
|
|
177
|
+
# icon). Yields the items — build them with everywhere_menu_item; in the
|
|
178
|
+
# shell they become a native UIMenu, in a browser a plain list of links.
|
|
179
|
+
#
|
|
180
|
+
# <%= everywhere_nav_menu do %>
|
|
181
|
+
# <%= everywhere_menu_item "Share", share_path, icons: { ios: "square.and.arrow.up" } %>
|
|
182
|
+
# <%= everywhere_menu_item "Delete", note_path(@note), method: :delete, style: "destructive" %>
|
|
183
|
+
# <% end %>
|
|
184
|
+
def everywhere_nav_menu(title = nil, icon: nil, icons: nil, side: "right",
|
|
185
|
+
css_class: nil, id: nil, &block)
|
|
186
|
+
content = _everywhere_capture(&block)
|
|
187
|
+
attrs = _everywhere_nav_attrs("data-everywhere-nav-menu", title: title, icon: icon,
|
|
188
|
+
icons: icons, side: side)
|
|
189
|
+
common = +""
|
|
190
|
+
common << %( id="#{_everywhere_attr(id)}") if id
|
|
191
|
+
common << %( class="#{_everywhere_attr(css_class)}") if css_class
|
|
192
|
+
_everywhere_safe(%(<div#{common} #{attrs}>#{content}</div>))
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# One item inside an everywhere_nav_menu or everywhere_menu. A link by
|
|
196
|
+
# default; type: :submit (with form:) submits a form, and method: (:delete,
|
|
197
|
+
# …) makes a Turbo method link. style: "destructive" tints it in the native
|
|
198
|
+
# menu / action sheet.
|
|
199
|
+
def everywhere_menu_item(title, href = nil, icon: nil, icons: nil, style: nil,
|
|
200
|
+
type: nil, form: nil, method: nil, css_class: nil, id: nil)
|
|
201
|
+
extra = +"data-everywhere-menu-item"
|
|
202
|
+
extra << %( data-everywhere-menu-title="#{_everywhere_attr(title)}")
|
|
203
|
+
extra << _everywhere_icon_attrs("data-everywhere-menu-icon", icon, icons)
|
|
204
|
+
extra << %( data-everywhere-menu-style="#{_everywhere_attr(style)}") if style
|
|
205
|
+
_everywhere_clickable(title, href, type: type, form: form, method: method,
|
|
206
|
+
css_class: css_class, id: id, extra: extra)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# An in-content action sheet: a trigger and the items it opens. In the
|
|
210
|
+
# mobile shell the trigger presents a native action sheet; in a browser it
|
|
211
|
+
# toggles the items as an inline menu (styled by everywhere/native.css).
|
|
212
|
+
# Choosing an item clicks it, so behavior lives in the items themselves.
|
|
213
|
+
#
|
|
214
|
+
# <%= everywhere_menu "Options" do %>
|
|
215
|
+
# <%= everywhere_menu_item "Edit", edit_post_path(@post) %>
|
|
216
|
+
# <%= everywhere_menu_item "Delete", post_path(@post), method: :delete, style: "destructive" %>
|
|
217
|
+
# <% end %>
|
|
218
|
+
def everywhere_menu(trigger = "Options", title: nil, trigger_class: nil,
|
|
219
|
+
items_class: nil, css_class: nil, id: nil, &block)
|
|
220
|
+
content = _everywhere_capture(&block)
|
|
221
|
+
wrap = +""
|
|
222
|
+
wrap << %( id="#{_everywhere_attr(id)}") if id
|
|
223
|
+
wrap << %( class="#{_everywhere_attr(css_class)}") if css_class
|
|
224
|
+
wrap << %( data-everywhere-menu-title="#{_everywhere_attr(title)}") if title
|
|
225
|
+
|
|
226
|
+
trig = %(<button type="button" data-everywhere-menu-trigger#{%( class="#{_everywhere_attr(trigger_class)}") if trigger_class}>#{_everywhere_attr(trigger)}</button>)
|
|
227
|
+
items = %(<div data-everywhere-menu-items#{%( class="#{_everywhere_attr(items_class)}") if items_class}>#{content}</div>)
|
|
228
|
+
_everywhere_safe(%(<div data-everywhere-menu#{wrap}>#{trig}#{items}</div>))
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# A floating action button: a fixed, safe-area-aware circular control (see
|
|
232
|
+
# everywhere/native.css). A real link (or button) that shows in a browser
|
|
233
|
+
# too and gains a tap haptic in the shell. Pass a block for custom content,
|
|
234
|
+
# or icon: for a built-in line glyph; label: sets the accessible name and,
|
|
235
|
+
# with extended: true, a visible label pill.
|
|
236
|
+
#
|
|
237
|
+
# <%= everywhere_fab new_note_path, icon: :plus, label: "New note" %>
|
|
238
|
+
# <%= everywhere_fab compose_path, icon: :pencil, label: "Compose", extended: true %>
|
|
239
|
+
def everywhere_fab(href = nil, icon: :plus, label: nil, haptic: "light", side: "right",
|
|
240
|
+
extended: false, type: nil, form: nil, method: nil,
|
|
241
|
+
css_class: nil, id: nil, &block)
|
|
242
|
+
classes = ["everywhere-fab"]
|
|
243
|
+
classes << "everywhere-fab-left" if side.to_s == "left"
|
|
244
|
+
classes << "everywhere-fab-extended" if extended
|
|
245
|
+
classes << css_class if css_class
|
|
246
|
+
|
|
247
|
+
inner = block ? _everywhere_capture(&block) : _everywhere_fab_icon(icon)
|
|
248
|
+
inner = "#{inner}<span class=\"everywhere-fab-label\">#{_everywhere_attr(label)}</span>" if extended && label
|
|
249
|
+
|
|
250
|
+
extra = %(class="#{classes.join(" ")}")
|
|
251
|
+
extra << %( aria-label="#{_everywhere_attr(label)}") if label
|
|
252
|
+
extra << %( data-everywhere-haptic="#{_everywhere_attr(haptic)}") if haptic
|
|
253
|
+
|
|
254
|
+
_everywhere_clickable(inner, href, type: type, form: form, method: method,
|
|
255
|
+
id: id, extra: extra, raw_content: true)
|
|
256
|
+
end
|
|
257
|
+
|
|
143
258
|
private
|
|
144
259
|
|
|
260
|
+
# Build a link or button carrying `extra` attributes. `raw_content: true`
|
|
261
|
+
# trusts pre-built inner HTML (icon SVG); otherwise the text is escaped.
|
|
262
|
+
def _everywhere_clickable(content, href, type: nil, form: nil, method: nil,
|
|
263
|
+
disabled: false, css_class: nil, id: nil, extra: "",
|
|
264
|
+
raw_content: false)
|
|
265
|
+
common = +""
|
|
266
|
+
common << %( id="#{_everywhere_attr(id)}") if id
|
|
267
|
+
common << %( class="#{_everywhere_attr(css_class)}") if css_class
|
|
268
|
+
inner = raw_content ? content.to_s : _everywhere_attr(content)
|
|
269
|
+
|
|
270
|
+
html =
|
|
271
|
+
if type.to_s == "submit"
|
|
272
|
+
%(<button type="submit"#{%( form="#{_everywhere_attr(form)}") if form}#{" disabled" if disabled}#{common} #{extra}>#{inner}</button>)
|
|
273
|
+
elsif href.nil? && type
|
|
274
|
+
%(<button type="#{_everywhere_attr(type)}"#{" disabled" if disabled}#{common} #{extra}>#{inner}</button>)
|
|
275
|
+
else
|
|
276
|
+
meth = method ? %( data-turbo-method="#{_everywhere_attr(method)}") : ""
|
|
277
|
+
%(<a href="#{_everywhere_attr(href || "#")}"#{meth}#{common} #{extra}>#{inner}</a>)
|
|
278
|
+
end
|
|
279
|
+
_everywhere_safe(html)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# The data-* attribute run shared by nav buttons and nav menus.
|
|
283
|
+
def _everywhere_nav_attrs(marker, title:, icon: nil, icons: nil, side: "right",
|
|
284
|
+
style: nil, disabled: false)
|
|
285
|
+
out = +marker.to_s
|
|
286
|
+
out << %( data-everywhere-nav-title="#{_everywhere_attr(title)}") if title
|
|
287
|
+
out << _everywhere_icon_attrs("data-everywhere-nav-icon", icon, icons)
|
|
288
|
+
out << %( data-everywhere-nav-side="left") if side.to_s == "left"
|
|
289
|
+
out << %( data-everywhere-nav-style="#{_everywhere_attr(style)}") if style && style.to_s != "plain"
|
|
290
|
+
out << " data-everywhere-nav-disabled" if disabled
|
|
291
|
+
out
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Per-platform icon attributes: the shared `<base>` plus `<base>-ios` /
|
|
295
|
+
# `<base>-android` from an icons: {ios:, android:} hash (string or symbol keys).
|
|
296
|
+
def _everywhere_icon_attrs(base, icon, icons)
|
|
297
|
+
out = +""
|
|
298
|
+
out << %( #{base}="#{_everywhere_attr(icon)}") if icon
|
|
299
|
+
if icons.is_a?(Hash)
|
|
300
|
+
%i[ios android].each do |os|
|
|
301
|
+
value = icons[os] || icons[os.to_s]
|
|
302
|
+
out << %( #{base}-#{os}="#{_everywhere_attr(value)}") if value
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
out
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def _everywhere_capture(&block)
|
|
309
|
+
return "" unless block
|
|
310
|
+
|
|
311
|
+
respond_to?(:capture) ? capture(&block) : block.call
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def _everywhere_safe(html)
|
|
315
|
+
html.respond_to?(:html_safe) ? html.html_safe : html
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Built-in line glyphs for everywhere_fab (24×24, currentColor stroke). SF
|
|
319
|
+
# Symbol / Material names don't render on the web, so the FAB carries its own.
|
|
320
|
+
FAB_ICONS = {
|
|
321
|
+
plus: "M12 5v14M5 12h14",
|
|
322
|
+
pencil: "M12 20h9M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z",
|
|
323
|
+
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",
|
|
324
|
+
search: "M21 21l-4.35-4.35M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z",
|
|
325
|
+
share: "M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v14",
|
|
326
|
+
check: "M20 6L9 17l-5-5",
|
|
327
|
+
close: "M18 6L6 18M6 6l12 12",
|
|
328
|
+
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",
|
|
329
|
+
arrow_up: "M12 19V5M5 12l7-7 7 7",
|
|
330
|
+
chevron_up: "M18 15l-6-6-6 6"
|
|
331
|
+
}.freeze
|
|
332
|
+
|
|
333
|
+
def _everywhere_fab_icon(icon)
|
|
334
|
+
return "" if icon.nil?
|
|
335
|
+
|
|
336
|
+
path = FAB_ICONS[icon.to_sym] || FAB_ICONS[:plus]
|
|
337
|
+
%(<svg class="everywhere-fab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" ) +
|
|
338
|
+
%(stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="#{path}"/></svg>)
|
|
339
|
+
end
|
|
340
|
+
|
|
145
341
|
def _everywhere_attr(value)
|
|
146
342
|
ERB::Util.html_escape(value.to_s)
|
|
147
343
|
end
|
data/lib/everywhere/version.rb
CHANGED
|
@@ -45,7 +45,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|
|
45
45
|
HapticsComponent.self,
|
|
46
46
|
PermissionsComponent.self,
|
|
47
47
|
BiometricsComponent.self,
|
|
48
|
-
StorageComponent.self
|
|
48
|
+
StorageComponent.self,
|
|
49
|
+
MenuComponent.self
|
|
49
50
|
] + EverywhereExtensions.components)
|
|
50
51
|
|
|
51
52
|
// Handles Everywhere.reloadTabs() from the page (a dedicated message
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import HotwireNative
|
|
3
|
+
import UIKit
|
|
4
|
+
import WebKit
|
|
5
|
+
|
|
6
|
+
/// Bridge component backing `everywhere--menu`: the native chrome the page
|
|
7
|
+
/// declares with `data-everywhere-nav-*` / `data-everywhere-menu` (the
|
|
8
|
+
/// everywhere_nav_button / everywhere_nav_menu / everywhere_menu Rails helpers).
|
|
9
|
+
///
|
|
10
|
+
/// Two events, both replied to by echoing back the tapped item's id so the web
|
|
11
|
+
/// half can `.click()` the element it mirrors — behavior stays defined once, in
|
|
12
|
+
/// the page:
|
|
13
|
+
/// * `navItems` — install left/right `UIBarButtonItem`s on THIS screen's
|
|
14
|
+
/// navigation bar (plain buttons, and pull-down `UIMenu`s for overflow
|
|
15
|
+
/// menus). Native re-replies to the same message on every tap.
|
|
16
|
+
/// * `menu` — present a `UIAlertController` action sheet, replying once with
|
|
17
|
+
/// the selection (or a cancel).
|
|
18
|
+
///
|
|
19
|
+
/// Installs onto `delegate?.destination` — the visitable view controller this
|
|
20
|
+
/// component instance belongs to — so buttons land on the right screen and go
|
|
21
|
+
/// away with it, exactly like the framework's own per-page bridge components.
|
|
22
|
+
final class MenuComponent: BridgeComponent {
|
|
23
|
+
override nonisolated class var name: String { "everywhere--menu" }
|
|
24
|
+
|
|
25
|
+
override func onReceive(message: Message) {
|
|
26
|
+
switch message.event {
|
|
27
|
+
case "navItems":
|
|
28
|
+
handleNavItems(message: message)
|
|
29
|
+
case "menu":
|
|
30
|
+
handleMenu(message: message)
|
|
31
|
+
default:
|
|
32
|
+
break
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// MARK: Private
|
|
37
|
+
|
|
38
|
+
private var viewController: UIViewController? {
|
|
39
|
+
delegate?.destination as? UIViewController
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// MARK: Nav bar items
|
|
43
|
+
|
|
44
|
+
private func handleNavItems(message: Message) {
|
|
45
|
+
guard let viewController, let data: NavItemsData = message.data() else { return }
|
|
46
|
+
|
|
47
|
+
var left: [UIBarButtonItem] = []
|
|
48
|
+
var right: [UIBarButtonItem] = []
|
|
49
|
+
for item in data.items {
|
|
50
|
+
let barButton = barButtonItem(for: item)
|
|
51
|
+
if item.side == "left" { left.append(barButton) } else { right.append(barButton) }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Right-side items read right-to-left in a nav bar; reverse so the page
|
|
55
|
+
// order ("Edit", "Share") lands left-to-right as authored.
|
|
56
|
+
viewController.navigationItem.leftBarButtonItems = left.isEmpty ? nil : left
|
|
57
|
+
viewController.navigationItem.rightBarButtonItems = right.isEmpty ? nil : right.reversed()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private func barButtonItem(for item: NavItem) -> UIBarButtonItem {
|
|
61
|
+
let image = item.image.flatMap { UIImage(systemName: $0) }
|
|
62
|
+
|
|
63
|
+
// A pull-down / overflow menu: children become a native UIMenu, each
|
|
64
|
+
// echoing its own id back on selection.
|
|
65
|
+
if let children = item.children, !children.isEmpty {
|
|
66
|
+
let actions = children.map { child in
|
|
67
|
+
UIAction(
|
|
68
|
+
title: child.title,
|
|
69
|
+
image: child.image.flatMap { UIImage(systemName: $0) },
|
|
70
|
+
attributes: child.style == "destructive" ? .destructive : []
|
|
71
|
+
) { [weak self] _ in
|
|
72
|
+
self?.replyTap(id: child.id)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
let menu = UIMenu(title: item.title.isEmpty ? "" : item.title, children: actions)
|
|
76
|
+
let barButton = UIBarButtonItem(
|
|
77
|
+
title: item.title.isEmpty ? nil : item.title,
|
|
78
|
+
image: image ?? UIImage(systemName: "ellipsis.circle"),
|
|
79
|
+
menu: menu
|
|
80
|
+
)
|
|
81
|
+
return barButton
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A plain button: echo its id back so the web half clicks the element.
|
|
85
|
+
let action = UIAction(title: item.title, image: image) { [weak self] _ in
|
|
86
|
+
self?.replyTap(id: item.id)
|
|
87
|
+
}
|
|
88
|
+
let barButton: UIBarButtonItem
|
|
89
|
+
if let image {
|
|
90
|
+
barButton = UIBarButtonItem(image: image, primaryAction: action)
|
|
91
|
+
} else {
|
|
92
|
+
barButton = UIBarButtonItem(title: item.title, primaryAction: action)
|
|
93
|
+
}
|
|
94
|
+
barButton.style = item.style == "done" ? .done : .plain
|
|
95
|
+
barButton.isEnabled = !(item.disabled ?? false)
|
|
96
|
+
return barButton
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private func replyTap(id: String) {
|
|
100
|
+
reply(to: "navItems", with: NavReply(action: "tap", id: id))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// MARK: Action sheet
|
|
104
|
+
|
|
105
|
+
private func handleMenu(message: Message) {
|
|
106
|
+
guard let viewController, let data: MenuData = message.data() else { return }
|
|
107
|
+
|
|
108
|
+
let sheet = UIAlertController(title: data.title, message: data.message, preferredStyle: .actionSheet)
|
|
109
|
+
for item in data.items {
|
|
110
|
+
let style: UIAlertAction.Style = item.style == "destructive" ? .destructive : .default
|
|
111
|
+
let action = UIAlertAction(title: item.title, style: style) { [weak self] _ in
|
|
112
|
+
self?.reply(to: "menu", with: MenuReply(action: "select", id: item.id))
|
|
113
|
+
}
|
|
114
|
+
action.isEnabled = !(item.disabled ?? false)
|
|
115
|
+
sheet.addAction(action)
|
|
116
|
+
}
|
|
117
|
+
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in
|
|
118
|
+
self?.reply(to: "menu", with: MenuReply(action: "cancel", id: nil))
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// iPad / iOS 26 present action sheets as popovers, which need an anchor.
|
|
122
|
+
// The web sends the trigger's rect in page coordinates; offset by the
|
|
123
|
+
// web view's top content inset (the nav bar) to reach view coordinates.
|
|
124
|
+
if let popover = sheet.popoverPresentationController {
|
|
125
|
+
popover.sourceView = viewController.view
|
|
126
|
+
if let source = data.source {
|
|
127
|
+
var y = source.y
|
|
128
|
+
if let visitable = viewController as? Visitable,
|
|
129
|
+
let webView = visitable.visitableView.webView {
|
|
130
|
+
y += Double(webView.scrollView.adjustedContentInset.top)
|
|
131
|
+
}
|
|
132
|
+
popover.sourceRect = CGRect(x: source.x, y: y, width: source.width, height: source.height)
|
|
133
|
+
} else {
|
|
134
|
+
popover.sourceRect = CGRect(x: viewController.view.bounds.midX,
|
|
135
|
+
y: viewController.view.bounds.midY, width: 0, height: 0)
|
|
136
|
+
popover.permittedArrowDirections = []
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
viewController.present(sheet, animated: true)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// MARK: - Message data
|
|
145
|
+
|
|
146
|
+
private extension MenuComponent {
|
|
147
|
+
struct NavItemsData: Decodable {
|
|
148
|
+
let items: [NavItem]
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
struct NavItem: Decodable {
|
|
152
|
+
let id: String
|
|
153
|
+
let title: String
|
|
154
|
+
let image: String?
|
|
155
|
+
let side: String?
|
|
156
|
+
let style: String?
|
|
157
|
+
let disabled: Bool?
|
|
158
|
+
let children: [MenuItem]?
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
struct MenuData: Decodable {
|
|
162
|
+
let title: String?
|
|
163
|
+
let message: String?
|
|
164
|
+
let items: [MenuItem]
|
|
165
|
+
let source: Source?
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
struct MenuItem: Decodable {
|
|
169
|
+
let id: String
|
|
170
|
+
let title: String
|
|
171
|
+
let image: String?
|
|
172
|
+
let style: String?
|
|
173
|
+
let disabled: Bool?
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
struct Source: Decodable {
|
|
177
|
+
let x: Double
|
|
178
|
+
let y: Double
|
|
179
|
+
let width: Double
|
|
180
|
+
let height: Double
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
struct NavReply: Encodable {
|
|
184
|
+
let action: String
|
|
185
|
+
let id: String
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
struct MenuReply: Encodable {
|
|
189
|
+
let action: String
|
|
190
|
+
let id: String?
|
|
191
|
+
}
|
|
192
|
+
}
|