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.
@@ -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
 
@@ -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
 
@@ -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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Everywhere
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.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,
@@ -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
+ }