ruby_everywhere 0.1.15 → 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 +105 -2
- data/bridge/everywhere/bridge.js +1131 -9
- data/bridge/everywhere/native.css +203 -0
- data/bridge/package.json +6 -3
- data/lib/everywhere/builders/ios.rb +396 -0
- data/lib/everywhere/cli.rb +2 -0
- data/lib/everywhere/commands/build.rb +17 -1
- data/lib/everywhere/commands/clean.rb +19 -10
- data/lib/everywhere/commands/dev.rb +182 -19
- data/lib/everywhere/commands/doctor.rb +45 -3
- data/lib/everywhere/commands/icon.rb +14 -0
- data/lib/everywhere/commands/install.rb +37 -6
- data/lib/everywhere/commands/logs.rb +56 -0
- data/lib/everywhere/commands/platform/build.rb +3 -3
- data/lib/everywhere/commands/platform/runner.rb +1 -1
- data/lib/everywhere/commands/release.rb +1 -1
- data/lib/everywhere/commands/shell_dir.rb +11 -4
- data/lib/everywhere/config.rb +469 -1
- data/lib/everywhere/engine.rb +42 -1
- data/lib/everywhere/icon.rb +50 -0
- data/lib/everywhere/log_filter.rb +28 -2
- data/lib/everywhere/mobile_config_endpoint.rb +88 -0
- data/lib/everywhere/mobile_configs_controller.rb +64 -0
- data/lib/everywhere/native_helper.rb +352 -0
- data/lib/everywhere/paths.rb +41 -0
- data/lib/everywhere/raster.rb +17 -0
- data/lib/everywhere/shellout.rb +3 -1
- data/lib/everywhere/simulator.rb +74 -0
- data/lib/everywhere/ui.rb +18 -0
- data/lib/everywhere/version.rb +1 -1
- data/support/mobile/ios/App/App.xcconfig +6 -0
- data/support/mobile/ios/App/AppDelegate.swift +164 -0
- data/support/mobile/ios/App/Assets.xcassets/AccentColor.colorset/Contents.json +20 -0
- data/support/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
- data/support/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
- data/support/mobile/ios/App/Assets.xcassets/Contents.json +6 -0
- data/support/mobile/ios/App/Assets.xcassets/LaunchBackground.colorset/Contents.json +38 -0
- data/support/mobile/ios/App/Base.lproj/LaunchScreen.storyboard +32 -0
- data/support/mobile/ios/App/Bridge/BiometricsComponent.swift +276 -0
- data/support/mobile/ios/App/Bridge/HapticsComponent.swift +47 -0
- data/support/mobile/ios/App/Bridge/MenuComponent.swift +192 -0
- data/support/mobile/ios/App/Bridge/NotificationComponent.swift +56 -0
- data/support/mobile/ios/App/Bridge/PermissionsComponent.swift +142 -0
- data/support/mobile/ios/App/Bridge/StorageComponent.swift +63 -0
- data/support/mobile/ios/App/ErrorViewController.swift +64 -0
- data/support/mobile/ios/App/EverywhereConfig.swift +289 -0
- data/support/mobile/ios/App/EverywhereHost.swift +34 -0
- data/support/mobile/ios/App/Extensions/EverywhereExtensions.swift +32 -0
- data/support/mobile/ios/App/Info.plist +28 -0
- data/support/mobile/ios/App/Resources/everywhere.json +8 -0
- data/support/mobile/ios/App/Resources/path-configuration.json +19 -0
- data/support/mobile/ios/App/SceneDelegate.swift +484 -0
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +458 -0
- data/support/mobile/ios/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +14 -0
- data/support/mobile/ios/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme +77 -0
- data/support/mobile/ios/NativeExtensions/Package.swift +26 -0
- data/support/mobile/ios/NativeExtensions/Sources/NativeExtensions/Exports.swift +5 -0
- data/support/mobile/ios/README.md +73 -0
- metadata +37 -1
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
|
|
5
|
+
module Everywhere
|
|
6
|
+
# Rack middleware serving the mobile shells' remote path configuration:
|
|
7
|
+
#
|
|
8
|
+
# GET /everywhere/ios_v1.json (android_v1.json when that shell lands)
|
|
9
|
+
#
|
|
10
|
+
# The JSON is built from config/everywhere.yml on each request (it's a tiny
|
|
11
|
+
# file, and re-reading means tab changes deploy with the app — the whole
|
|
12
|
+
# point: no app-store release to change tabs). This middleware is the
|
|
13
|
+
# SINATRA/HANAMI strategy — add it to config.ru:
|
|
14
|
+
#
|
|
15
|
+
# use Everywhere::MobileConfigEndpoint
|
|
16
|
+
#
|
|
17
|
+
# Rails apps don't need it: Everywhere::Engine appends a real route to
|
|
18
|
+
# MobileConfigsController instead (logging, instrumentation, overridable).
|
|
19
|
+
#
|
|
20
|
+
# The native shells load it as a Hotwire Native path-configuration source
|
|
21
|
+
# (after their bundled copy), so both rules and settings.tabs can be
|
|
22
|
+
# overridden server-side.
|
|
23
|
+
class MobileConfigEndpoint
|
|
24
|
+
PATH = %r{\A/everywhere/(ios|android)_v1\.json\z}
|
|
25
|
+
RESET = "/everywhere/reset"
|
|
26
|
+
AASA = "/.well-known/apple-app-site-association"
|
|
27
|
+
ASSETLINKS = "/.well-known/assetlinks.json"
|
|
28
|
+
|
|
29
|
+
def initialize(app, root: Dir.pwd)
|
|
30
|
+
@app = app
|
|
31
|
+
@root = root
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call(env)
|
|
35
|
+
return @app.call(env) unless env["REQUEST_METHOD"] == "GET"
|
|
36
|
+
|
|
37
|
+
if env["PATH_INFO"] == RESET
|
|
38
|
+
require "rack/utils"
|
|
39
|
+
to = Rack::Utils.parse_query(env["QUERY_STRING"])["to"]
|
|
40
|
+
return html(Everywhere.mobile_reset_html(to))
|
|
41
|
+
end
|
|
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
|
+
|
|
54
|
+
match = PATH.match(env["PATH_INFO"]) or return @app.call(env)
|
|
55
|
+
|
|
56
|
+
os = match[1]
|
|
57
|
+
config = Config.load(@root)
|
|
58
|
+
tabs = Everywhere.resolve_tabs(config.tabs_for(os), request_for(env))
|
|
59
|
+
json(config.path_configuration_json(os, tabs: tabs))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def json(body)
|
|
65
|
+
[200, { "content-type" => "application/json", "cache-control" => cache_control }, [body]]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def html(body)
|
|
69
|
+
[200, { "content-type" => "text/html; charset=utf-8", "cache-control" => "no-store" }, [body]]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A Rack::Request the tab filter can read (session/cookies) when the host
|
|
73
|
+
# app has it; otherwise the raw env is enough for header-based checks.
|
|
74
|
+
def request_for(env)
|
|
75
|
+
require "rack/request"
|
|
76
|
+
Rack::Request.new(env)
|
|
77
|
+
rescue LoadError
|
|
78
|
+
env
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Never cache: the response depends on the session (filter_tabs), so a
|
|
82
|
+
# public cache could leak one user's tabs to another and a client cache
|
|
83
|
+
# would serve stale tabs across sign-in/out. It's a couple of ms anyway.
|
|
84
|
+
def cache_control
|
|
85
|
+
"no-store"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
|
|
5
|
+
module Everywhere
|
|
6
|
+
# Serves the mobile shells' remote path configuration in Rails apps:
|
|
7
|
+
#
|
|
8
|
+
# GET /everywhere/ios_v1.json -> MobileConfigsController#show
|
|
9
|
+
#
|
|
10
|
+
# The route is appended by Everywhere::Engine, so it shows up in
|
|
11
|
+
# `rails routes` and the app can override it by drawing its own. Inherits
|
|
12
|
+
# ActionController::API on purpose:
|
|
13
|
+
# * never the app's ApplicationController — its before_actions
|
|
14
|
+
# (authentication!) would lock the shells out, and
|
|
15
|
+
# * not ActionController::Base — its `helper :all` resolution reaches
|
|
16
|
+
# into the host app's helpers, which aren't loadable when the gem is
|
|
17
|
+
# required during boot. A JSON endpoint needs none of that.
|
|
18
|
+
#
|
|
19
|
+
# everywhere.yml is re-read on every request: tab changes go live with a
|
|
20
|
+
# deploy — or a plain file save in development, where responses are
|
|
21
|
+
# explicitly uncached so the dev shell's reload polling sees them instantly.
|
|
22
|
+
class MobileConfigsController < ActionController::API
|
|
23
|
+
def show
|
|
24
|
+
config = Config.load(::Rails.root.to_s)
|
|
25
|
+
|
|
26
|
+
# Never cache: the response depends on the session (filter_tabs can hide
|
|
27
|
+
# tabs when signed out), so a shared/public cache could serve one user's
|
|
28
|
+
# tabs to another, and a client cache would serve stale tabs across
|
|
29
|
+
# sign-in/out. The endpoint is a couple of milliseconds — no cache needed.
|
|
30
|
+
response.headers["cache-control"] = "no-store"
|
|
31
|
+
|
|
32
|
+
os = params[:platform_v1].delete_suffix("_v1")
|
|
33
|
+
tabs = Everywhere.resolve_tabs(config.tabs_for(os), request)
|
|
34
|
+
render json: config.path_configuration_hash(os, tabs: tabs)
|
|
35
|
+
end
|
|
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
|
+
|
|
55
|
+
# GET /everywhere/reset?to=/path — the native "reset the app" page. Auth
|
|
56
|
+
# flows redirect the shell here so it rebuilds cleanly before landing on
|
|
57
|
+
# `to`. Never cached (it must run every time).
|
|
58
|
+
def reset
|
|
59
|
+
response.headers["cache-control"] = "no-store"
|
|
60
|
+
render html: Everywhere.mobile_reset_html(params[:to]).html_safe,
|
|
61
|
+
layout: false, content_type: "text/html"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Everywhere
|
|
7
|
+
# Server-side view helpers mirroring the JS bridge's platform detection, so
|
|
8
|
+
# ERB can branch on the native shell the same way `Everywhere.platform`
|
|
9
|
+
# does on the client:
|
|
10
|
+
#
|
|
11
|
+
# <%= link_to "Install the app", … unless native_app? %>
|
|
12
|
+
# <div class="<%= "native-inset" if native_app? %>">
|
|
13
|
+
# <% if native_version && native_version >= Gem::Version.new("1.2") %>
|
|
14
|
+
#
|
|
15
|
+
# Detection is by User-Agent: the RubyEverywhere shells prepend
|
|
16
|
+
# "RubyEverywhere/<version> (<os>)" to Hotwire Native's own
|
|
17
|
+
# "Hotwire Native iOS/Android" marker. Included into all Rails views by
|
|
18
|
+
# Everywhere::Engine.
|
|
19
|
+
module NativeHelper
|
|
20
|
+
# True inside any RubyEverywhere native shell (iOS or Android).
|
|
21
|
+
def native_app?
|
|
22
|
+
!native_platform.nil?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# :ios, :android, or nil in a plain browser. Reads Hotwire Native's UA
|
|
26
|
+
# marker, so it's correct even before the JS bridge has booted.
|
|
27
|
+
def native_platform
|
|
28
|
+
ua = _everywhere_user_agent or return nil
|
|
29
|
+
return :ios if ua.include?("Hotwire Native iOS")
|
|
30
|
+
return :android if ua.include?("Hotwire Native Android")
|
|
31
|
+
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The shell's version as a Gem::Version (from the "RubyEverywhere/<ver>"
|
|
36
|
+
# UA prefix), or nil outside the shell / when unparseable. Use it to gate
|
|
37
|
+
# features that need a newer shell than some users have installed.
|
|
38
|
+
def native_version
|
|
39
|
+
ua = _everywhere_user_agent or return nil
|
|
40
|
+
match = ua.match(%r{RubyEverywhere/([\w.\-]+)}) or return nil
|
|
41
|
+
|
|
42
|
+
Gem::Version.new(match[1])
|
|
43
|
+
rescue ArgumentError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Where to send the user after an auth change. In a native shell, route
|
|
48
|
+
# through the reset page (/everywhere/reset) so the app rebuilds cleanly —
|
|
49
|
+
# fresh web views, re-fetched tabs — then lands on `to`. In a browser it's
|
|
50
|
+
# just `to`. Use it in controllers:
|
|
51
|
+
#
|
|
52
|
+
# redirect_to everywhere_auth_redirect(after_authentication_url)
|
|
53
|
+
#
|
|
54
|
+
# `to` may be a full URL or a path; only the same-origin path is forwarded.
|
|
55
|
+
def everywhere_auth_redirect(to)
|
|
56
|
+
return to unless native_app? && respond_to?(:everywhere_reset_path)
|
|
57
|
+
|
|
58
|
+
path = to.to_s.sub(%r{\Ahttps?://[^/]+}, "")
|
|
59
|
+
path = "/" if path.empty? || !path.start_with?("/")
|
|
60
|
+
everywhere_reset_path(to: path)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# App icon badge count, server-rendered. Emits a meta tag the JS bridge
|
|
64
|
+
# applies on every Turbo visit — CSP-safe (no inline script) and correct
|
|
65
|
+
# on the first paint. 0 clears the badge. Put it in the layout <head>:
|
|
66
|
+
#
|
|
67
|
+
# <%= everywhere_badge Current.user.unread_count %>
|
|
68
|
+
#
|
|
69
|
+
# In the mobile shell this badges the app icon; in an installed PWA the
|
|
70
|
+
# Badging API; elsewhere it renders inert metadata.
|
|
71
|
+
def everywhere_badge(count)
|
|
72
|
+
tag.meta(name: "everywhere:badge", content: count.to_i)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Native tab bar badge for the tab whose everywhere.yml path is `path`.
|
|
76
|
+
# Same delivery as everywhere_badge; 0 clears. Mobile shell only.
|
|
77
|
+
#
|
|
78
|
+
# <%= everywhere_tab_badge "/inbox", Current.user.unread_count %>
|
|
79
|
+
def everywhere_tab_badge(path, count)
|
|
80
|
+
tag.meta(name: "everywhere:tab-badge",
|
|
81
|
+
content: JSON.generate({ path: path, count: count.to_i }))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Biometric gate: wraps sensitive markup so the mobile shell keeps it
|
|
85
|
+
# hidden until Face ID / Touch ID passes — but only when the user has the
|
|
86
|
+
# device-local lock turned on (everywhere_biometric_toggle, or
|
|
87
|
+
# Everywhere.biometrics.setLockEnabled). Browsers and the desktop shell
|
|
88
|
+
# render the content plainly. Requires `biometrics` in everywhere.yml's
|
|
89
|
+
# permissions.
|
|
90
|
+
#
|
|
91
|
+
# <%= everywhere_biometric_lock reason: "Unlock account settings" do %>
|
|
92
|
+
# ...profile, password, sessions...
|
|
93
|
+
# <% end %>
|
|
94
|
+
#
|
|
95
|
+
# `name` keys the once-per-session unlock (defaults to the page path).
|
|
96
|
+
# For a fully custom overlay, hand-write the contract this emits:
|
|
97
|
+
#
|
|
98
|
+
# <div data-everywhere-biometric-lock="<name>"
|
|
99
|
+
# data-everywhere-biometric-reason="…" data-everywhere-biometric-passcode>
|
|
100
|
+
# <div data-everywhere-biometric-content hidden>…</div>
|
|
101
|
+
# <div data-everywhere-biometric-locked hidden>
|
|
102
|
+
# <button data-everywhere-biometric-unlock>Unlock</button>
|
|
103
|
+
# </div>
|
|
104
|
+
# </div>
|
|
105
|
+
def everywhere_biometric_lock(reason: nil, name: nil, allow_passcode: true,
|
|
106
|
+
unlock_label: "Unlock", locked_message: nil,
|
|
107
|
+
wrapper_class: nil, locked_class: nil,
|
|
108
|
+
message_class: nil, unlock_class: nil, &block)
|
|
109
|
+
content = respond_to?(:capture) ? capture(&block) : block.call
|
|
110
|
+
return content unless native_app?
|
|
111
|
+
|
|
112
|
+
attrs = [%(data-everywhere-biometric-lock="#{_everywhere_attr(name)}")]
|
|
113
|
+
attrs << %(data-everywhere-biometric-reason="#{_everywhere_attr(reason)}") if reason
|
|
114
|
+
attrs << "data-everywhere-biometric-passcode" if allow_passcode
|
|
115
|
+
attrs << %(class="#{_everywhere_attr(wrapper_class)}") if wrapper_class
|
|
116
|
+
|
|
117
|
+
html = +"<div #{attrs.join(" ")}>"
|
|
118
|
+
html << %(<div data-everywhere-biometric-content hidden>#{content}</div>)
|
|
119
|
+
html << %(<div data-everywhere-biometric-locked hidden#{%( class="#{_everywhere_attr(locked_class)}") if locked_class}>)
|
|
120
|
+
html << %(<p#{%( class="#{_everywhere_attr(message_class)}") if message_class}>#{_everywhere_attr(locked_message)}</p>) if locked_message
|
|
121
|
+
html << %(<button type="button" data-everywhere-biometric-unlock#{%( class="#{_everywhere_attr(unlock_class)}") if unlock_class}>#{_everywhere_attr(unlock_label)}</button>)
|
|
122
|
+
html << "</div></div>"
|
|
123
|
+
html.respond_to?(:html_safe) ? html.html_safe : html
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The settings switch for the device-local biometric lock. Renders hidden
|
|
127
|
+
# and disabled; the bridge reveals and enables it only in the mobile shell
|
|
128
|
+
# with working biometrics (toggling runs the biometric check first). Put
|
|
129
|
+
# `data-everywhere-biometric-toggle-row` + `hidden` on the surrounding row
|
|
130
|
+
# to reveal the whole thing together:
|
|
131
|
+
#
|
|
132
|
+
# <div data-everywhere-biometric-toggle-row hidden>
|
|
133
|
+
# <label>Require Face ID <%= everywhere_biometric_toggle %></label>
|
|
134
|
+
# </div>
|
|
135
|
+
def everywhere_biometric_toggle(css_class: nil, id: nil)
|
|
136
|
+
attrs = +""
|
|
137
|
+
attrs << %( id="#{_everywhere_attr(id)}") if id
|
|
138
|
+
attrs << %( class="#{_everywhere_attr(css_class)}") if css_class
|
|
139
|
+
html = %(<input type="checkbox" hidden disabled data-everywhere-biometric-toggle#{attrs}>)
|
|
140
|
+
html.respond_to?(:html_safe) ? html.html_safe : html
|
|
141
|
+
end
|
|
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
|
+
|
|
258
|
+
private
|
|
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
|
+
|
|
341
|
+
def _everywhere_attr(value)
|
|
342
|
+
ERB::Util.html_escape(value.to_s)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def _everywhere_user_agent
|
|
346
|
+
return unless respond_to?(:request) && request
|
|
347
|
+
|
|
348
|
+
ua = request.user_agent
|
|
349
|
+
ua unless ua.nil? || ua.empty?
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
data/lib/everywhere/paths.rb
CHANGED
|
@@ -33,11 +33,52 @@ module Everywhere
|
|
|
33
33
|
"reinstall ruby_everywhere or pass --shell-dir")
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# The Hotwire Native iOS shell template bundled inside the gem:
|
|
37
|
+
# support/mobile/ios/ holds a frozen Xcode project the CLI stamps per-app
|
|
38
|
+
# (xcconfig + everywhere.json + AppIcon — never the pbxproj). The mobile/
|
|
39
|
+
# parent leaves room for support/mobile/android later.
|
|
40
|
+
def bundled_ios_dir
|
|
41
|
+
File.join(gem_root, "support", "mobile", "ios")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Absolute path to the iOS template, or nil if the bundled copy is missing
|
|
45
|
+
# (a corrupt/partial install).
|
|
46
|
+
def ios_dir
|
|
47
|
+
bundled_ios_dir if File.exist?(File.join(bundled_ios_dir, "App.xcodeproj", "project.pbxproj"))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Like #ios_dir but aborts with a helpful message when the template is
|
|
51
|
+
# missing. The --template-dir hint covers dev on a modified template.
|
|
52
|
+
def ios_dir!
|
|
53
|
+
ios_dir or UI.die!("couldn't find the bundled iOS template at #{bundled_ios_dir}; " \
|
|
54
|
+
"reinstall ruby_everywhere or pass --template-dir")
|
|
55
|
+
end
|
|
56
|
+
|
|
36
57
|
# Per-user cache dir. Keeps heavy build state out of the gem and the app.
|
|
37
58
|
def cache_dir
|
|
38
59
|
File.expand_path(ENV["RUBYEVERYWHERE_HOME"] || "~/.rubyeverywhere")
|
|
39
60
|
end
|
|
40
61
|
|
|
62
|
+
# The stamped copy of the iOS template for one app. Persistent (not a
|
|
63
|
+
# tmpdir) on purpose: a stable project path keeps Xcode's incremental state
|
|
64
|
+
# valid across builds, and users can open the stamped project in Xcode.
|
|
65
|
+
def ios_work_dir(bundle_id)
|
|
66
|
+
File.join(cache_dir, "ios", bundle_id)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Where xcodebuild writes DerivedData for iOS shell builds. Shared across
|
|
70
|
+
# apps like cargo_target_dir — safe because the product is always App.app;
|
|
71
|
+
# worst case a mismatch is a cache miss, never a wrong artifact.
|
|
72
|
+
def ios_derived_data_dir
|
|
73
|
+
File.join(cache_dir, "ios-derived-data")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Where SPM clones the shell's package dependencies (hotwire-native-ios),
|
|
77
|
+
# so resolution is warm/offline after the first build.
|
|
78
|
+
def ios_packages_dir
|
|
79
|
+
File.join(cache_dir, "ios-packages")
|
|
80
|
+
end
|
|
81
|
+
|
|
41
82
|
# Where cargo writes the shell's build output (CARGO_TARGET_DIR). Redirected
|
|
42
83
|
# here so cargo never dumps a multi-GB target/ into the installed gem, and so
|
|
43
84
|
# the shell compiles warm across projects — the shell source is identical for
|
data/lib/everywhere/raster.rb
CHANGED
|
@@ -25,6 +25,23 @@ module Everywhere
|
|
|
25
25
|
new(width, height)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# A copy composited over an opaque background color ([r, g, b] bytes) —
|
|
29
|
+
# straight alpha blend, result fully opaque. Flattens icons for stores
|
|
30
|
+
# that reject alpha channels (the iOS App Store).
|
|
31
|
+
def flatten_onto(rgb)
|
|
32
|
+
out = "\x00".b * (width * height * 4)
|
|
33
|
+
(width * height).times do |p|
|
|
34
|
+
i = p * 4
|
|
35
|
+
a = pixels.getbyte(i + 3)
|
|
36
|
+
3.times do |c|
|
|
37
|
+
out.setbyte(i + c, a == 255 ? pixels.getbyte(i + c)
|
|
38
|
+
: ((pixels.getbyte(i + c) * a + rgb[c] * (255 - a)) / 255.0).round)
|
|
39
|
+
end
|
|
40
|
+
out.setbyte(i + 3, 255)
|
|
41
|
+
end
|
|
42
|
+
self.class.new(width, height, out)
|
|
43
|
+
end
|
|
44
|
+
|
|
28
45
|
# Decode a PNG file into a Raster, or nil if it's a flavor PNG.decode_rgba
|
|
29
46
|
# can't read (palette, 16-bit, interlaced, grayscale).
|
|
30
47
|
def self.decode_png(path)
|
data/lib/everywhere/shellout.rb
CHANGED
|
@@ -72,8 +72,10 @@ module Everywhere
|
|
|
72
72
|
end
|
|
73
73
|
private_class_method :emit
|
|
74
74
|
|
|
75
|
+
# pgroup: the child leads its own process group so teardown can TERM the
|
|
76
|
+
# whole tree (foreman + watchers) without signalling the CLI itself.
|
|
75
77
|
def spawn(env, cmd, chdir:)
|
|
76
|
-
unbundled { Process.spawn(env, cmd, chdir: chdir) }
|
|
78
|
+
unbundled { Process.spawn(env, cmd, chdir: chdir, pgroup: true) }
|
|
77
79
|
end
|
|
78
80
|
end
|
|
79
81
|
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "shellout"
|
|
5
|
+
require_relative "ui"
|
|
6
|
+
|
|
7
|
+
module Everywhere
|
|
8
|
+
# Thin wrappers around `xcrun simctl` — just enough to boot a Simulator,
|
|
9
|
+
# install the stamped shell and launch it. Anything fancier (choosing
|
|
10
|
+
# devices, multiple simulators) belongs in the user's own Xcode workflow.
|
|
11
|
+
module Simulator
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# UDID of a booted iPhone Simulator, or nil.
|
|
15
|
+
def booted_udid
|
|
16
|
+
devices.find { |d| d["state"] == "Booted" }&.fetch("udid", nil)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Boot the newest available iPhone (or reuse a booted one) and bring the
|
|
20
|
+
# Simulator app to the foreground. Returns the UDID.
|
|
21
|
+
def boot_default!
|
|
22
|
+
if (udid = booted_udid)
|
|
23
|
+
Shellout.run?("open", "-a", "Simulator", quiet: true)
|
|
24
|
+
return udid
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
device = devices.reverse.find { |d| d["name"].to_s.match?(/\AiPhone/) } || devices.last
|
|
28
|
+
UI.die!("no iOS Simulator devices — open Xcode once to install a Simulator runtime") unless device
|
|
29
|
+
|
|
30
|
+
UI.step("booting simulator #{UI.bold(device["name"])}")
|
|
31
|
+
Shellout.run?("xcrun", "simctl", "boot", device["udid"], quiet: true) # races `open` starting it; harmless
|
|
32
|
+
Shellout.run?("open", "-a", "Simulator", quiet: true)
|
|
33
|
+
wait_until_booted(device["udid"])
|
|
34
|
+
device["udid"]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def install(udid, app_path)
|
|
38
|
+
out, status = Shellout.capture("xcrun", "simctl", "install", udid, app_path)
|
|
39
|
+
UI.die!("simctl install failed: #{out.strip}") unless status&.success?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Launch (relaunching if already running). Env vars are forwarded into the
|
|
43
|
+
# app's process via simctl's SIMCTL_CHILD_ prefix — how the dev URL
|
|
44
|
+
# reaches the shell without restamping the bundle.
|
|
45
|
+
def launch(udid, bundle_id, env: {})
|
|
46
|
+
child_env = env.transform_keys { |k| "SIMCTL_CHILD_#{k}" }
|
|
47
|
+
out, status = Shellout.capture(child_env,
|
|
48
|
+
"xcrun", "simctl", "launch",
|
|
49
|
+
"--terminate-running-process", udid, bundle_id)
|
|
50
|
+
UI.die!("simctl launch failed: #{out.strip}") unless status&.success?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Available devices of the current runtime set, oldest runtime first —
|
|
54
|
+
# `.last` / `reverse.find` therefore prefer the newest.
|
|
55
|
+
def devices
|
|
56
|
+
out, status = Shellout.capture("xcrun", "simctl", "list", "devices", "available", "-j")
|
|
57
|
+
return [] unless status&.success?
|
|
58
|
+
|
|
59
|
+
JSON.parse(out).fetch("devices", {}).sort.flat_map { |_runtime, list| list }
|
|
60
|
+
rescue JSON::ParserError
|
|
61
|
+
[]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def wait_until_booted(udid, timeout: 60)
|
|
65
|
+
deadline = Time.now + timeout
|
|
66
|
+
until Time.now > deadline
|
|
67
|
+
return if devices.any? { |d| d["udid"] == udid && d["state"] == "Booted" }
|
|
68
|
+
|
|
69
|
+
sleep 1
|
|
70
|
+
end
|
|
71
|
+
UI.die!("simulator never finished booting")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|