ruby_everywhere 0.7.0 → 0.8.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/exe/every +2 -13
- data/exe/rbe +2 -13
- data/lib/everywhere/boot.rb +9 -2
- data/lib/everywhere/builders/android.rb +16 -42
- data/lib/everywhere/builders/base.rb +53 -0
- data/lib/everywhere/builders/desktop.rb +7 -23
- data/lib/everywhere/builders/ios.rb +49 -52
- data/lib/everywhere/builders/native_sources.rb +38 -0
- data/lib/everywhere/child_processes.rb +4 -4
- data/lib/everywhere/child_supervision.rb +172 -0
- data/lib/everywhere/clock.rb +12 -0
- data/lib/everywhere/commands/build.rb +20 -9
- data/lib/everywhere/commands/dev.rb +59 -284
- data/lib/everywhere/commands/doctor.rb +6 -6
- data/lib/everywhere/commands/install.rb +1 -0
- data/lib/everywhere/commands/platform/auth_status.rb +1 -0
- data/lib/everywhere/commands/platform/build.rb +5 -6
- data/lib/everywhere/commands/platform/login.rb +4 -4
- data/lib/everywhere/commands/platform/logout.rb +1 -0
- data/lib/everywhere/commands/platform/runner.rb +62 -7
- data/lib/everywhere/commands/preview.rb +24 -97
- data/lib/everywhere/commands/publish.rb +2 -0
- data/lib/everywhere/commands/release.rb +14 -11
- data/lib/everywhere/commands/shell_dir.rb +2 -0
- data/lib/everywhere/commands/updates_keygen.rb +25 -1
- data/lib/everywhere/config/app.rb +126 -0
- data/lib/everywhere/config/auth.rb +108 -0
- data/lib/everywhere/config/data.rb +50 -0
- data/lib/everywhere/config/deep_linking.rb +107 -0
- data/lib/everywhere/config/desktop_ui.rb +153 -0
- data/lib/everywhere/config/mobile.rb +211 -0
- data/lib/everywhere/config/native_desktop.rb +168 -0
- data/lib/everywhere/config/native_mobile.rb +337 -0
- data/lib/everywhere/config/shell.rb +57 -0
- data/lib/everywhere/config/updates.rb +63 -0
- data/lib/everywhere/config.rb +30 -1423
- data/lib/everywhere/desktop_dev_app.rb +138 -0
- data/lib/everywhere/dock/state.rb +3 -1
- data/lib/everywhere/entrypoint.rb +24 -0
- data/lib/everywhere/error.rb +8 -0
- data/lib/everywhere/framework.rb +2 -2
- data/lib/everywhere/host.rb +11 -0
- data/lib/everywhere/ignore.rb +11 -5
- data/lib/everywhere/line_pump.rb +3 -1
- data/lib/everywhere/minisign.rb +1 -0
- data/lib/everywhere/native_platform.rb +44 -0
- data/lib/everywhere/paths.rb +23 -7
- data/lib/everywhere/platform/client.rb +19 -1
- data/lib/everywhere/platform/snapshot.rb +6 -4
- data/lib/everywhere/plist.rb +17 -0
- data/lib/everywhere/png.rb +1 -0
- data/lib/everywhere/raw_tty.rb +51 -0
- data/lib/everywhere/s3.rb +1 -0
- data/lib/everywhere/shell_pages.rb +109 -0
- data/lib/everywhere/tab_filter.rb +36 -0
- data/lib/everywhere/task_pool.rb +3 -4
- data/lib/everywhere/ui.rb +6 -1
- data/lib/everywhere/version.rb +6 -4
- data/lib/everywhere.rb +1 -2
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +0 -2
- data/support/release/macos/notarize.sh +3 -0
- metadata +24 -1
data/lib/everywhere/config.rb
CHANGED
|
@@ -1,189 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "yaml"
|
|
4
|
+
require_relative "error"
|
|
4
5
|
require_relative "ui"
|
|
6
|
+
require_relative "native_platform"
|
|
7
|
+
require_relative "shell_pages"
|
|
8
|
+
require_relative "tab_filter"
|
|
9
|
+
require_relative "config/data"
|
|
10
|
+
require_relative "config/app"
|
|
11
|
+
require_relative "config/updates"
|
|
12
|
+
require_relative "config/desktop_ui"
|
|
13
|
+
require_relative "config/auth"
|
|
14
|
+
require_relative "config/mobile"
|
|
15
|
+
require_relative "config/native_mobile"
|
|
16
|
+
require_relative "config/native_desktop"
|
|
17
|
+
require_relative "config/deep_linking"
|
|
18
|
+
require_relative "config/shell"
|
|
5
19
|
|
|
6
20
|
module Everywhere
|
|
7
|
-
# Also defined by everywhere.rb and framework.rb; repeated here so config.rb
|
|
8
|
-
# stays loadable on its own (the packaged runtime requires it piecemeal).
|
|
9
|
-
class Error < StandardError; end
|
|
10
|
-
|
|
11
|
-
class << self
|
|
12
|
-
# Register a per-request filter for the mobile tab bar. The block receives
|
|
13
|
-
# the resolved tab list (`[{ "title" =>, "path" =>, "icon" => }, …]`) and
|
|
14
|
-
# the current request, and returns the subset to show — return `[]` to hide
|
|
15
|
-
# the tab bar entirely (the shell falls back to single-screen navigation).
|
|
16
|
-
#
|
|
17
|
-
# It runs inside the mobile config endpoint, which shares the app's session
|
|
18
|
-
# and cookies, so it can branch on auth or any request state:
|
|
19
|
-
#
|
|
20
|
-
# # config/initializers/everywhere.rb
|
|
21
|
-
# Everywhere.filter_tabs do |tabs, request|
|
|
22
|
-
# request.session[:user_id] ? tabs : []
|
|
23
|
-
# end
|
|
24
|
-
#
|
|
25
|
-
# Live like the rest of the config: the shell re-reads it on launch and on
|
|
26
|
-
# every foreground, so a sign-in shows the tabs on next foreground — no
|
|
27
|
-
# rebuild, no app-store release.
|
|
28
|
-
def filter_tabs(&block)
|
|
29
|
-
@tabs_filter = block
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# Apply the registered filter (identity when none is set). Always returns
|
|
33
|
-
# an Array so a stray nil/scalar from a block can't break serialization.
|
|
34
|
-
def resolve_tabs(tabs, request)
|
|
35
|
-
return tabs unless @tabs_filter
|
|
36
|
-
|
|
37
|
-
Array(@tabs_filter.call(tabs, request))
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# The tiny page served at /everywhere/reset. Auth flows redirect the native
|
|
41
|
-
# app here so it resets cleanly (fresh web views, re-fetched tabs) before
|
|
42
|
-
# continuing — the standard Hotwire Native "reset the app" pattern. It talks
|
|
43
|
-
# to the shell's control channel directly (no bridge/importmap dependency);
|
|
44
|
-
# in a plain browser it just forwards to the target. `to` is constrained to
|
|
45
|
-
# a same-origin path.
|
|
46
|
-
#
|
|
47
|
-
# Both shells are addressed inline, and differently, for the same reason
|
|
48
|
-
# the bridge normalizes them: WKWebView's message handler takes an object,
|
|
49
|
-
# while Android's WebMessageListener channel takes a string. Reaching for
|
|
50
|
-
# the bridge here instead would trade that one line for an importmap
|
|
51
|
-
# dependency on a page whose whole job is to work before anything loads.
|
|
52
|
-
def mobile_reset_html(to)
|
|
53
|
-
require "json"
|
|
54
|
-
target = to.to_s
|
|
55
|
-
target = "/" unless target.start_with?("/") && !target.start_with?("//")
|
|
56
|
-
# script_safe, not to_json: this lands inside <script>, and outside Rails
|
|
57
|
-
# nothing escapes "</script>" in a plain JSON string.
|
|
58
|
-
encoded = JSON.generate(target, script_safe: true)
|
|
59
|
-
|
|
60
|
-
<<~HTML
|
|
61
|
-
<!DOCTYPE html><html><head><meta charset="utf-8"><title>One moment…</title>
|
|
62
|
-
<meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
|
63
|
-
<body><script>
|
|
64
|
-
(function(){var to=#{encoded};var msg={action:"reset",to:to};
|
|
65
|
-
var ios=window.webkit&&window.webkit.messageHandlers&&window.webkit.messageHandlers.everywhereControl;
|
|
66
|
-
var android=window.everywhereControl;
|
|
67
|
-
if(ios){ios.postMessage(msg);}
|
|
68
|
-
else if(android&&android.postMessage){android.postMessage(JSON.stringify(msg));}
|
|
69
|
-
else{window.location.replace(to);}})();
|
|
70
|
-
</script></body></html>
|
|
71
|
-
HTML
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# The page served at /everywhere/jump/leave — the way OUT of a Jump
|
|
75
|
-
# preview. Once Jump re-roots onto a previewed app (instance.set), every
|
|
76
|
-
# Jump surface — launcher, scanner, tabs — resolves against the previewed
|
|
77
|
-
# app, so the escape hatch has to be served by the previewed app itself:
|
|
78
|
-
# this gem is the one thing guaranteed to be there. Posts clearInstance on
|
|
79
|
-
# the shell's control channel; shells that aren't multi-instance ignore
|
|
80
|
-
# it, and a plain browser just goes home.
|
|
81
|
-
# Fires clearInstance only once VISIBLE. This page rides in the previewed
|
|
82
|
-
# app's tab bar; shells preload tabs eagerly, but a preloaded webview is
|
|
83
|
-
# offscreen and reports visibilityState "hidden" — firing on load would
|
|
84
|
-
# bounce every preview the instant it connects, and a tap on the tab
|
|
85
|
-
# never re-proposes a visit, so visibility flipping is the ONLY signal a
|
|
86
|
-
# tab tap gives the page. The button is a belt for browsers and any
|
|
87
|
-
# webview that lies about visibility.
|
|
88
|
-
def mobile_jump_leave_html
|
|
89
|
-
<<~HTML
|
|
90
|
-
<!DOCTYPE html><html><head><meta charset="utf-8"><title>Jump</title>
|
|
91
|
-
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
92
|
-
<style>
|
|
93
|
-
body{font-family:-apple-system,system-ui,sans-serif;margin:0;min-height:100vh;
|
|
94
|
-
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
|
95
|
-
gap:12px;padding:24px;text-align:center;background:#fff;color:#171717}
|
|
96
|
-
@media(prefers-color-scheme:dark){body{background:#171717;color:#fafafa}}
|
|
97
|
-
p{margin:0;opacity:.6}
|
|
98
|
-
button{font:inherit;font-weight:600;font-size:18px;color:#fff;background:#dc2626;
|
|
99
|
-
border:0;border-radius:12px;padding:16px 32px}
|
|
100
|
-
</style></head>
|
|
101
|
-
<body>
|
|
102
|
-
<p>Returning to Jump…</p>
|
|
103
|
-
<button onclick="everywhereJumpLeave()">Return to Jump</button>
|
|
104
|
-
<script>
|
|
105
|
-
function everywhereJumpLeave(){var msg={action:"clearInstance"};
|
|
106
|
-
var ios=window.webkit&&window.webkit.messageHandlers&&window.webkit.messageHandlers.everywhereControl;
|
|
107
|
-
var android=window.everywhereControl;
|
|
108
|
-
if(ios){ios.postMessage(msg);}
|
|
109
|
-
else if(android&&android.postMessage){android.postMessage(JSON.stringify(msg));}
|
|
110
|
-
else{window.location.replace("/");}}
|
|
111
|
-
if(document.visibilityState==="visible"){everywhereJumpLeave();}
|
|
112
|
-
else{document.addEventListener("visibilitychange",function(){
|
|
113
|
-
if(document.visibilityState==="visible"){everywhereJumpLeave();}});}
|
|
114
|
-
</script></body></html>
|
|
115
|
-
HTML
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
# The page served at /everywhere/auth/native. The shell normally diverts a
|
|
119
|
-
# provider path natively, before the request is ever made; this covers the
|
|
120
|
-
# visits it can't see — a `data-turbo="false"` link, or the POST OmniAuth 2
|
|
121
|
-
# requires — by asking the shell, from the page, to open the auth session.
|
|
122
|
-
# In a browser it just continues to the provider.
|
|
123
|
-
def mobile_auth_html(to)
|
|
124
|
-
require "json"
|
|
125
|
-
target = to.to_s
|
|
126
|
-
target = "/" unless target.start_with?("/") && !target.start_with?("//")
|
|
127
|
-
# script_safe, as in mobile_reset_html: `to` is request input.
|
|
128
|
-
encoded = JSON.generate(target, script_safe: true)
|
|
129
|
-
|
|
130
|
-
<<~HTML
|
|
131
|
-
<!DOCTYPE html><html><head><meta charset="utf-8"><title>Signing in…</title>
|
|
132
|
-
<meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
|
133
|
-
<body><p>Opening secure sign-in…</p><script>
|
|
134
|
-
(function(){var to=#{encoded};var msg={action:"authFlow",to:to};
|
|
135
|
-
var ios=window.webkit&&window.webkit.messageHandlers&&window.webkit.messageHandlers.everywhereControl;
|
|
136
|
-
var android=window.everywhereControl;
|
|
137
|
-
if(ios){ios.postMessage(msg);}
|
|
138
|
-
else if(android&&android.postMessage){android.postMessage(JSON.stringify(msg));}
|
|
139
|
-
else{window.location.replace(to);}})();
|
|
140
|
-
</script></body></html>
|
|
141
|
-
HTML
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
# The desktop shell's marker. WKWebView APPENDS
|
|
145
|
-
# `applicationNameForUserAgent` to the real system UA, so this arrives
|
|
146
|
-
# alongside a normal Safari UA rather than replacing it — hence a pattern
|
|
147
|
-
# rather than a prefix check. The version is absent when everywhere.yml
|
|
148
|
-
# declares none.
|
|
149
|
-
#
|
|
150
|
-
# macOS only for now: the marker is set in macos_webview_configuration, and
|
|
151
|
-
# Windows/Linux have no append-only equivalent (their user_agent() REPLACES
|
|
152
|
-
# the string, and fabricating a whole UA is worse than not marking it).
|
|
153
|
-
# Desktop only ships macOS today; revisit when it doesn't.
|
|
154
|
-
DESKTOP_UA = %r{\bRubyEverywhere(?:/[\w.\-]+)? \((?:macos|windows|linux)\)}
|
|
155
|
-
|
|
156
|
-
MOBILE_PLATFORMS = %i[ios android].freeze
|
|
157
|
-
|
|
158
|
-
# :ios, :android, :desktop or nil, from a User-Agent string. Present from
|
|
159
|
-
# the very first request, before any JS has run. Shared by the view helpers
|
|
160
|
-
# and the auth middleware.
|
|
161
|
-
#
|
|
162
|
-
# Mobile is checked FIRST and the order matters: the mobile shells prepend
|
|
163
|
-
# the same "RubyEverywhere/<version> (<os>)" marker to Hotwire Native's own,
|
|
164
|
-
# so a mobile UA matches DESKTOP_UA too.
|
|
165
|
-
def native_platform_of(user_agent)
|
|
166
|
-
ua = user_agent.to_s
|
|
167
|
-
return :ios if ua.include?("Hotwire Native iOS")
|
|
168
|
-
return :android if ua.include?("Hotwire Native Android")
|
|
169
|
-
return :desktop if ua.match?(DESKTOP_UA)
|
|
170
|
-
|
|
171
|
-
nil
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
# Just the phone shells. Anything that leans on a mobile-only affordance —
|
|
175
|
-
# the OAuth handoff to ASWebAuthenticationSession / Custom Tabs, biometric
|
|
176
|
-
# gating — has to ask for this rather than "is this a native shell", because
|
|
177
|
-
# the desktop shell answers yes to that and has neither.
|
|
178
|
-
def mobile_platform_of(user_agent)
|
|
179
|
-
platform = native_platform_of(user_agent)
|
|
180
|
-
platform if MOBILE_PLATFORMS.include?(platform)
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
# Test/reset hook.
|
|
184
|
-
attr_writer :tabs_filter
|
|
185
|
-
end
|
|
186
|
-
|
|
187
21
|
# Loads config/everywhere.yml:
|
|
188
22
|
#
|
|
189
23
|
# app:
|
|
@@ -197,6 +31,22 @@ module Everywhere
|
|
|
197
31
|
#
|
|
198
32
|
# Colors always normalize to { "light" => ..., "dark" => ... }.
|
|
199
33
|
class Config
|
|
34
|
+
# One module per section of everywhere.yml — the sections are what the file
|
|
35
|
+
# is made of, and each is long enough (readers, constants, its own
|
|
36
|
+
# validations) to be worth reading on its own. Data comes first: it holds
|
|
37
|
+
# the private plumbing (`app`, `resolved`, `normalize_color`) the rest of
|
|
38
|
+
# them read through.
|
|
39
|
+
include Data
|
|
40
|
+
include App
|
|
41
|
+
include Updates
|
|
42
|
+
include DesktopUi
|
|
43
|
+
include Auth
|
|
44
|
+
include Mobile
|
|
45
|
+
include NativeMobile
|
|
46
|
+
include NativeDesktop
|
|
47
|
+
include DeepLinking
|
|
48
|
+
include Shell
|
|
49
|
+
|
|
200
50
|
FILE = File.join("config", "everywhere.yml")
|
|
201
51
|
|
|
202
52
|
# Dates, times and symbols are permitted rather than fatal: an unquoted
|
|
@@ -246,1248 +96,5 @@ module Everywhere
|
|
|
246
96
|
suffix = target.to_s.split(":", 2)[1]
|
|
247
97
|
suffix unless suffix.nil? || suffix.empty?
|
|
248
98
|
end
|
|
249
|
-
|
|
250
|
-
def name(target: nil)
|
|
251
|
-
resolved(target)["name"] || File.basename(File.expand_path(root)).split(/[-_]/).map(&:capitalize).join(" ")
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
# Reverse-DNS identifier: macOS bundle id, and the name of the per-user
|
|
255
|
-
# app-data directory on every platform. Set it explicitly and never change
|
|
256
|
-
# it — renaming moves users' data. A platform may override it (e.g. the App
|
|
257
|
-
# Store often wants `com.example.app.ios`).
|
|
258
|
-
def bundle_id(target: nil)
|
|
259
|
-
resolved(target)["bundle_id"] ||
|
|
260
|
-
"com.rubyeverywhere.#{name(target: target).downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")}"
|
|
261
|
-
end
|
|
262
|
-
|
|
263
|
-
# App icon source: a PNG (ideally square, 1024px+). Explicit `app.icon`
|
|
264
|
-
# path relative to the app root, or icon.png at the root by convention.
|
|
265
|
-
def icon
|
|
266
|
-
if (explicit = app["icon"])
|
|
267
|
-
File.expand_path(explicit, root)
|
|
268
|
-
else
|
|
269
|
-
default = File.join(root, "icon.png")
|
|
270
|
-
File.exist?(default) ? default : nil
|
|
271
|
-
end
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
# Optional custom splash page (HTML file, path relative to the app root).
|
|
275
|
-
# Shown while the packaged server boots; the shell injects
|
|
276
|
-
# window.__EVERYWHERE_CONFIG__ so it can use the app's name and colors.
|
|
277
|
-
def splash
|
|
278
|
-
path = app["splash"]
|
|
279
|
-
File.expand_path(path, root) if path
|
|
280
|
-
end
|
|
281
|
-
|
|
282
|
-
def entry_path
|
|
283
|
-
path = app["entry_path"] || "/"
|
|
284
|
-
path.start_with?("/") ? path : "/#{path}"
|
|
285
|
-
end
|
|
286
|
-
|
|
287
|
-
# Marketing/display version of the app (CFBundleShortVersionString, and the
|
|
288
|
-
# `version` recorded in the build receipt). One shared `app.version` is the
|
|
289
|
-
# default for every target; a platform may override it when a store forces a
|
|
290
|
-
# different number. Defaults conservatively.
|
|
291
|
-
def version(target: nil)
|
|
292
|
-
resolved(target)["version"] || "0.1.0"
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
# The `build:` section — durable build knobs that were once `every build`
|
|
296
|
-
# flags (ruby, targets, capabilities). CLI flags still override per-run.
|
|
297
|
-
# See platform/docs/build-engine.md §2.
|
|
298
|
-
def build = @data.fetch("build", nil) || {}
|
|
299
|
-
|
|
300
|
-
# Ruby version to package. nil here means "let the CLI decide its default".
|
|
301
|
-
def build_ruby = build["ruby"]
|
|
302
|
-
|
|
303
|
-
# Desktop OS-integration capabilities the app declares (`build.capabilities`),
|
|
304
|
-
# as a plain list. Deliberately not called permissions: the top-level
|
|
305
|
-
# `permissions:` below is the mobile prompt declaration — a different shape
|
|
306
|
-
# for a different consumer — and while both were spelled "permissions" this
|
|
307
|
-
# accessor was silently redefined by that one and could never be read.
|
|
308
|
-
def build_capabilities = Array(build["capabilities"])
|
|
309
|
-
|
|
310
|
-
# Build targets as BARE os-arch strings — any ":<channel>" suffix is
|
|
311
|
-
# stripped. Callers treat these as filesystem/URL-safe identifiers
|
|
312
|
-
# (`every publish` derives S3 update-manifest key paths from the first one),
|
|
313
|
-
# so a suffix leaking through here would poison those paths. Use
|
|
314
|
-
# #build_target_specs when the channel matters.
|
|
315
|
-
def targets = build_target_specs.map { |t| t.to_s.split(":", 2).first }
|
|
316
|
-
|
|
317
|
-
# The raw `build.targets` entries, suffixes intact ("ios-arm64:testflight").
|
|
318
|
-
def build_target_specs = Array(build["targets"])
|
|
319
|
-
|
|
320
|
-
# "local" — the app is tebako-pressed and runs on-device (default)
|
|
321
|
-
# "remote" — thin shell around an already-deployed app: no press, no sidecar
|
|
322
|
-
def mode
|
|
323
|
-
app["mode"] || (remote_url ? "remote" : "local")
|
|
324
|
-
end
|
|
325
|
-
|
|
326
|
-
def remote? = mode == "remote"
|
|
327
|
-
|
|
328
|
-
def remote_url
|
|
329
|
-
@data.dig("remote", "url")&.chomp("/")
|
|
330
|
-
end
|
|
331
|
-
|
|
332
|
-
# Multi-instance apps (`remote.instances: true`): the mobile shell boots
|
|
333
|
-
# into remote.url — a hosted instance-picker page — and lets that page
|
|
334
|
-
# re-root the app onto a chosen instance via Everywhere.instance.set,
|
|
335
|
-
# persisted across launches until Everywhere.instance.clear. Opt-in
|
|
336
|
-
# because it lets page JS repoint the whole app: apps that aren't
|
|
337
|
-
# multi-instance shouldn't carry that surface.
|
|
338
|
-
def remote_instances?
|
|
339
|
-
@data.dig("remote", "instances") == true
|
|
340
|
-
end
|
|
341
|
-
|
|
342
|
-
# The `updates:` section — self-hosted auto-updates. Two halves:
|
|
343
|
-
# a client half (url/channel/public_key/auto/interval) that ships to the
|
|
344
|
-
# shell, and a publish-side `s3:` half that NEVER leaves the dev machine.
|
|
345
|
-
# `channel` here is a *release channel* (stable/beta) — not the receipt's
|
|
346
|
-
# `distribution_channel` (direct vs app stores).
|
|
347
|
-
def updates = @data.fetch("updates", nil) || {}
|
|
348
|
-
|
|
349
|
-
def updates_s3 = updates.fetch("s3", nil) || {}
|
|
350
|
-
|
|
351
|
-
def updates_channel = updates["channel"] || "stable"
|
|
352
|
-
|
|
353
|
-
def updates_url = updates["url"]&.chomp("/")
|
|
354
|
-
|
|
355
|
-
def updates_public_key = updates["public_key"]
|
|
356
|
-
|
|
357
|
-
# off — never check; check — check + notify (default); download — also
|
|
358
|
-
# fetch/verify in the background; install — silent auto-update.
|
|
359
|
-
def updates_auto = updates["auto"] || "check"
|
|
360
|
-
|
|
361
|
-
def updates_interval = [(updates["interval"] || 21_600).to_i, 300].max
|
|
362
|
-
|
|
363
|
-
# The client subset for the shell; nil (and thus absent from
|
|
364
|
-
# everywhere.json) until both url and public_key are configured — an
|
|
365
|
-
# unsigned update feed is not a thing.
|
|
366
|
-
def updates_shell_hash
|
|
367
|
-
return nil unless updates_url && updates_public_key
|
|
368
|
-
|
|
369
|
-
{
|
|
370
|
-
"url" => updates_url,
|
|
371
|
-
"channel" => updates_channel,
|
|
372
|
-
"public_key" => updates_public_key,
|
|
373
|
-
"auto" => updates_auto,
|
|
374
|
-
"interval" => updates_interval
|
|
375
|
-
}
|
|
376
|
-
end
|
|
377
|
-
|
|
378
|
-
def tint_color
|
|
379
|
-
normalize_color(appearance["tint_color"])
|
|
380
|
-
end
|
|
381
|
-
|
|
382
|
-
def background_color
|
|
383
|
-
normalize_color(appearance["background_color"])
|
|
384
|
-
end
|
|
385
|
-
|
|
386
|
-
# Mobile tab bar, platform-neutral. Icons are per-platform (ios = SF
|
|
387
|
-
# Symbol, android = Material icon later), with a shared `icon:` fallback:
|
|
388
|
-
#
|
|
389
|
-
# tabs:
|
|
390
|
-
# - name: Builds
|
|
391
|
-
# path: /builds
|
|
392
|
-
# icons:
|
|
393
|
-
# ios: hammer
|
|
394
|
-
# android: build
|
|
395
|
-
#
|
|
396
|
-
# Tabs ship two ways from the same source: baked into the app's bundled
|
|
397
|
-
# path-configuration.json at build time (offline/first-launch), and served
|
|
398
|
-
# live from /everywhere/<platform>_v1.json (MobileConfigEndpoint) so tab
|
|
399
|
-
# changes deploy with the web app — no app-store release.
|
|
400
|
-
def tabs
|
|
401
|
-
entries = @data["tabs"]
|
|
402
|
-
return [] unless entries.is_a?(Array)
|
|
403
|
-
|
|
404
|
-
entries.filter_map do |tab|
|
|
405
|
-
next unless tab.is_a?(Hash) && tab["name"] && tab["path"]
|
|
406
|
-
|
|
407
|
-
path = tab["path"].start_with?("/") ? tab["path"] : "/#{tab["path"]}"
|
|
408
|
-
{ "name" => tab["name"], "path" => path,
|
|
409
|
-
"icon" => tab["icon"], "icons" => (tab["icons"] if tab["icons"].is_a?(Hash)) }.compact
|
|
410
|
-
end
|
|
411
|
-
end
|
|
412
|
-
|
|
413
|
-
# Tabs with the icon resolved for one platform: icons.<os>, then the
|
|
414
|
-
# shared icon, then a safe default.
|
|
415
|
-
def tabs_for(os)
|
|
416
|
-
tabs.map do |tab|
|
|
417
|
-
{ "title" => tab["name"], "path" => tab["path"],
|
|
418
|
-
"icon" => tab.dig("icons", os.to_s) || tab["icon"] || DEFAULT_TAB_ICONS[os.to_s] }.compact
|
|
419
|
-
end
|
|
420
|
-
end
|
|
421
|
-
|
|
422
|
-
DEFAULT_TAB_ICONS = { "ios" => "circle", "android" => "circle" }.freeze
|
|
423
|
-
|
|
424
|
-
# The Hotwire Native path-configuration rules every RubyEverywhere mobile
|
|
425
|
-
# shell starts from. Single source of truth: the builder bakes this into
|
|
426
|
-
# the app bundle and MobileConfigEndpoint serves it live.
|
|
427
|
-
MOBILE_PATH_RULES = [
|
|
428
|
-
{ "patterns" => [".*"],
|
|
429
|
-
"properties" => { "context" => "default", "pull_to_refresh_enabled" => true } },
|
|
430
|
-
{ "patterns" => ["/new$", "/edit$"],
|
|
431
|
-
"properties" => { "context" => "modal", "pull_to_refresh_enabled" => false } }
|
|
432
|
-
].freeze
|
|
433
|
-
|
|
434
|
-
# Native permissions the app declares (top-level `permissions:`). Only
|
|
435
|
-
# declared permissions can be requested at runtime — the shell answers
|
|
436
|
-
# `undeclared` for everything else, so an app that doesn't need a
|
|
437
|
-
# permission can never prompt for it. For camera and location the value is
|
|
438
|
-
# the user-facing usage string iOS shows in the permission prompt (the
|
|
439
|
-
# "why"); it's mandatory there because requesting without one crashes the
|
|
440
|
-
# app. Notifications need no string — declare with `true`.
|
|
441
|
-
#
|
|
442
|
-
# permissions:
|
|
443
|
-
# notifications: true
|
|
444
|
-
# camera: "Scan QR codes to pair devices."
|
|
445
|
-
# location:
|
|
446
|
-
# ios: "Find build agents near you."
|
|
447
|
-
# biometrics: "Unlock your account with Face ID."
|
|
448
|
-
#
|
|
449
|
-
# Returns { name => { "ios" => usage-or-nil, ... } }.
|
|
450
|
-
MOBILE_PERMISSIONS = %w[notifications camera location biometrics].freeze
|
|
451
|
-
|
|
452
|
-
# Info.plist usage-description keys per permission. Presence here makes
|
|
453
|
-
# the usage string mandatory on iOS.
|
|
454
|
-
IOS_USAGE_KEYS = {
|
|
455
|
-
"camera" => "NSCameraUsageDescription",
|
|
456
|
-
"location" => "NSLocationWhenInUseUsageDescription",
|
|
457
|
-
"biometrics" => "NSFaceIDUsageDescription"
|
|
458
|
-
}.freeze
|
|
459
|
-
|
|
460
|
-
# Manifest <uses-permission> names per permission, stamped into
|
|
461
|
-
# src/stamped/AndroidManifest.xml. Deliberately *not* the mirror of
|
|
462
|
-
# IOS_USAGE_KEYS: presence here mandates nothing, because Android has no
|
|
463
|
-
# Info.plist-style contract — a runtime request without a rationale string
|
|
464
|
-
# prompts normally instead of terminating the process, and the rationale is
|
|
465
|
-
# a dialog the app draws itself, not a manifest value. So the map only
|
|
466
|
-
# answers "which manifest entry does this permission need", and every
|
|
467
|
-
# declared name has one.
|
|
468
|
-
#
|
|
469
|
-
# ACCESS_FINE_LOCATION implies ACCESS_COARSE_LOCATION on API 31+ only when
|
|
470
|
-
# both are declared, but the shell asks for precise location, so the fine
|
|
471
|
-
# permission alone is the honest declaration.
|
|
472
|
-
ANDROID_PERMISSIONS = {
|
|
473
|
-
"notifications" => "android.permission.POST_NOTIFICATIONS",
|
|
474
|
-
"camera" => "android.permission.CAMERA",
|
|
475
|
-
"location" => "android.permission.ACCESS_FINE_LOCATION",
|
|
476
|
-
"biometrics" => "android.permission.USE_BIOMETRIC"
|
|
477
|
-
}.freeze
|
|
478
|
-
|
|
479
|
-
def permissions
|
|
480
|
-
entries = @data["permissions"]
|
|
481
|
-
return {} unless entries.is_a?(Hash)
|
|
482
|
-
|
|
483
|
-
entries.each_with_object({}) do |(name, value), acc|
|
|
484
|
-
next if value.nil? || value == false
|
|
485
|
-
|
|
486
|
-
acc[name.to_s] =
|
|
487
|
-
case value
|
|
488
|
-
when String then { "ios" => value }
|
|
489
|
-
when Hash then value.transform_keys(&:to_s).transform_values(&:to_s)
|
|
490
|
-
else {}
|
|
491
|
-
end
|
|
492
|
-
end
|
|
493
|
-
end
|
|
494
|
-
|
|
495
|
-
# The manifest permissions an Android build declares, in declaration order.
|
|
496
|
-
# Only known names map to anything; permission_errors("android") has
|
|
497
|
-
# already failed the build on the rest by the time the builder asks.
|
|
498
|
-
def android_manifest_permissions
|
|
499
|
-
permissions.keys.filter_map { |name| ANDROID_PERMISSIONS[name] }.uniq
|
|
500
|
-
end
|
|
501
|
-
|
|
502
|
-
# Problems with the permissions declaration for one platform, as
|
|
503
|
-
# human-readable strings — unknown names, and camera/location missing the
|
|
504
|
-
# mandatory usage string. Empty means buildable.
|
|
505
|
-
#
|
|
506
|
-
# Only the unknown-name half applies to Android: the usage string exists to
|
|
507
|
-
# satisfy iOS, which kills the app when a prompt has no Info.plist entry.
|
|
508
|
-
# Android just prompts, so requiring the sentence there would fail builds
|
|
509
|
-
# over a value nothing reads.
|
|
510
|
-
def permission_errors(os)
|
|
511
|
-
permissions.flat_map do |name, usage|
|
|
512
|
-
unless MOBILE_PERMISSIONS.include?(name)
|
|
513
|
-
next ["unknown permission #{name.inspect} — supported: #{MOBILE_PERMISSIONS.join(", ")}"]
|
|
514
|
-
end
|
|
515
|
-
|
|
516
|
-
if os == "ios" && IOS_USAGE_KEYS.key?(name) && usage["ios"].to_s.strip.empty?
|
|
517
|
-
next ["#{name} needs a usage string — the sentence iOS shows when asking. " \
|
|
518
|
-
"In config/everywhere.yml:\n permissions:\n #{name}: \"Why the app needs #{name}.\""]
|
|
519
|
-
end
|
|
520
|
-
|
|
521
|
-
[]
|
|
522
|
-
end
|
|
523
|
-
end
|
|
524
|
-
|
|
525
|
-
# App-declared Hotwire Native path-configuration rules (everywhere.yml
|
|
526
|
-
# top-level `rules:`), appended after MOBILE_PATH_RULES — later rules win
|
|
527
|
-
# per property, so apps can make routes modal, disable pull-to-refresh,
|
|
528
|
-
# or set any other Hotwire path property without touching native code:
|
|
529
|
-
#
|
|
530
|
-
# rules:
|
|
531
|
-
# - patterns: ["/preferences$"]
|
|
532
|
-
# properties:
|
|
533
|
-
# context: default # not a modal, unlike other /edit routes
|
|
534
|
-
# - patterns: ["/live/"]
|
|
535
|
-
# properties:
|
|
536
|
-
# pull_to_refresh_enabled: false
|
|
537
|
-
# Native screens are selected by a different property on each platform, so
|
|
538
|
-
# `os` decides how a rule's `view_controller:` is read (see
|
|
539
|
-
# #android_screen_properties).
|
|
540
|
-
def mobile_rules(os = nil)
|
|
541
|
-
entries = @data["rules"]
|
|
542
|
-
return [] unless entries.is_a?(Array)
|
|
543
|
-
|
|
544
|
-
entries.filter_map do |rule|
|
|
545
|
-
next unless rule.is_a?(Hash) && rule["patterns"].is_a?(Array) && rule["properties"].is_a?(Hash)
|
|
546
|
-
|
|
547
|
-
properties = rule["properties"]
|
|
548
|
-
properties = android_screen_properties(properties) if android_target?(os)
|
|
549
|
-
{ "patterns" => rule["patterns"].map(&:to_s), "properties" => properties }
|
|
550
|
-
end
|
|
551
|
-
end
|
|
552
|
-
|
|
553
|
-
# Hotwire Native iOS picks a native screen with `view_controller: <id>`;
|
|
554
|
-
# Android picks one with `uri: hotwire://fragment/<id>`, matched against the
|
|
555
|
-
# Fragment's own @HotwireDestinationDeepLink annotation. Same rule, same
|
|
556
|
-
# identifier, different property name — so the Android document derives the
|
|
557
|
-
# `uri` rather than making apps write the route twice and keep the two in
|
|
558
|
-
# sync by hand.
|
|
559
|
-
#
|
|
560
|
-
# Derived only for ids declared under native.android.screens: an id that
|
|
561
|
-
# names an iOS-only screen must fall through to the web fragment, because a
|
|
562
|
-
# `uri` pointing at a Fragment this build doesn't contain resolves to
|
|
563
|
-
# nothing and the visit dead-ends. An explicit `uri:` always wins — that's
|
|
564
|
-
# the escape hatch for a screen whose annotation says something else.
|
|
565
|
-
def android_screen_properties(properties)
|
|
566
|
-
id = properties["view_controller"]
|
|
567
|
-
return properties if id.nil? || properties.key?("uri")
|
|
568
|
-
return properties unless native_android_screens.key?(id.to_s)
|
|
569
|
-
|
|
570
|
-
properties.merge("uri" => "hotwire://fragment/#{id}")
|
|
571
|
-
end
|
|
572
|
-
|
|
573
|
-
# The full path-configuration document for one mobile platform — rules plus
|
|
574
|
-
# our settings (tabs live in settings, per Hotwire Native convention).
|
|
575
|
-
# Pass `tabs:` to override the resolved list (the mobile config endpoint
|
|
576
|
-
# passes a per-request-filtered list; build-time stamping omits it and
|
|
577
|
-
# bakes them all).
|
|
578
|
-
def path_configuration_hash(os, tabs: nil)
|
|
579
|
-
settings = {}
|
|
580
|
-
platform_tabs = tabs || tabs_for(os)
|
|
581
|
-
settings["tabs"] = platform_tabs unless platform_tabs.empty?
|
|
582
|
-
{ "settings" => settings, "rules" => MOBILE_PATH_RULES + mobile_rules(os) }
|
|
583
|
-
end
|
|
584
|
-
|
|
585
|
-
def path_configuration_json(os, tabs: nil)
|
|
586
|
-
require "json"
|
|
587
|
-
JSON.generate(path_configuration_hash(os, tabs: tabs))
|
|
588
|
-
end
|
|
589
|
-
|
|
590
|
-
# Native code extensions ("supernative"): Swift the app repo carries in
|
|
591
|
-
# native/ios/ that `every build` compiles into the shell, plus this
|
|
592
|
-
# declaration of what to hook up. Build-time only — stores forbid
|
|
593
|
-
# downloading native code, so extensions ship with the app binary.
|
|
594
|
-
#
|
|
595
|
-
# native:
|
|
596
|
-
# ios:
|
|
597
|
-
# components: [ChartComponent] # BridgeComponent subclasses to register
|
|
598
|
-
# screens: # path rules with view_controller: <id>
|
|
599
|
-
# map: MapScreen # SwiftUI View or UIViewController, init(url:)
|
|
600
|
-
# splash: LaunchSplash # SwiftUI View or UIViewController, init()
|
|
601
|
-
# splash_min_seconds: 1.0 # how long the custom splash stays up at minimum
|
|
602
|
-
# lazy_load_tabs: true # defer each tab's first visit until it's selected
|
|
603
|
-
def native_ios
|
|
604
|
-
section = @data.dig("native", "ios")
|
|
605
|
-
section.is_a?(Hash) ? section : {}
|
|
606
|
-
end
|
|
607
|
-
|
|
608
|
-
def native_ios_components = Array(native_ios["components"]).map(&:to_s)
|
|
609
|
-
|
|
610
|
-
def native_ios_screens
|
|
611
|
-
entries = native_ios["screens"]
|
|
612
|
-
return {} unless entries.is_a?(Hash)
|
|
613
|
-
|
|
614
|
-
entries.to_h { |id, type| [id.to_s, type.to_s] }
|
|
615
|
-
end
|
|
616
|
-
|
|
617
|
-
def native_ios_splash = native_ios["splash"]&.to_s
|
|
618
|
-
|
|
619
|
-
# Minimum seconds a custom splash stays on screen (default 1.0 shell-side;
|
|
620
|
-
# a fast server would otherwise dismiss a branded splash in a ~50ms flash).
|
|
621
|
-
# Clamped to the shell's 8s give-up timeout.
|
|
622
|
-
def native_ios_splash_min_seconds
|
|
623
|
-
value = native_ios["splash_min_seconds"]
|
|
624
|
-
value.to_f.clamp(0.0, 8.0) if value.is_a?(Numeric)
|
|
625
|
-
end
|
|
626
|
-
|
|
627
|
-
# Whether the native tab bar defers each non-selected tab's initial visit
|
|
628
|
-
# until the tab is first selected (Hotwire Native `lazyLoadTabs`). Tri-state
|
|
629
|
-
# so apps can opt in or opt out explicitly: `true` lazy-loads, `false` loads
|
|
630
|
-
# every tab up front, and an absent key leaves the shell on its default
|
|
631
|
-
# (eager).
|
|
632
|
-
def native_ios_lazy_load_tabs
|
|
633
|
-
value = native_ios["lazy_load_tabs"]
|
|
634
|
-
value if value == true || value == false
|
|
635
|
-
end
|
|
636
|
-
|
|
637
|
-
# Third-party Swift packages this app pins for its native/ios code. Each
|
|
638
|
-
# entry is one SPM dependency; `every build` writes them into the shell's
|
|
639
|
-
# local NativeExtensions package and re-exports every product, so native/ios
|
|
640
|
-
# code reaches them with a single `import NativeExtensions`.
|
|
641
|
-
#
|
|
642
|
-
# packages:
|
|
643
|
-
# - url: https://github.com/simibac/ConfettiSwiftUI
|
|
644
|
-
# from: "1.1.0" # or exact: / branch: / revision:
|
|
645
|
-
# products: [ConfettiSwiftUI] # optional; defaults to the repo name
|
|
646
|
-
#
|
|
647
|
-
# Returns normalized entries: { "url", "requirement" => { "kind", "value" },
|
|
648
|
-
# "products" => [...] }. Assumes the config validated clean (see
|
|
649
|
-
# native_ios_package_errors) — the requirement is always present here.
|
|
650
|
-
PACKAGE_REQUIREMENT_KEYS = %w[from exact branch revision].freeze
|
|
651
|
-
|
|
652
|
-
def native_ios_packages
|
|
653
|
-
Array(native_ios["packages"]).filter_map do |entry|
|
|
654
|
-
next unless entry.is_a?(Hash)
|
|
655
|
-
|
|
656
|
-
url = entry["url"].to_s.strip
|
|
657
|
-
kind = PACKAGE_REQUIREMENT_KEYS.find { |k| entry.key?(k) && !entry[k].to_s.strip.empty? }
|
|
658
|
-
products = Array(entry["products"]).map { |p| p.to_s.strip }.reject(&:empty?)
|
|
659
|
-
products = [package_identity(url)] if products.empty?
|
|
660
|
-
|
|
661
|
-
{ "url" => url,
|
|
662
|
-
"requirement" => (kind && { "kind" => kind, "value" => entry[kind].to_s.strip }),
|
|
663
|
-
"products" => products }
|
|
664
|
-
end
|
|
665
|
-
end
|
|
666
|
-
|
|
667
|
-
def native_ios_packages? = !native_ios_packages.empty?
|
|
668
|
-
|
|
669
|
-
# SPM's package identity: the URL's last path segment without a .git suffix
|
|
670
|
-
# (github.com/simibac/ConfettiSwiftUI → "ConfettiSwiftUI"). Used as the
|
|
671
|
-
# default product name and as the `package:` key in .product(name:package:).
|
|
672
|
-
def package_identity(url) = File.basename(url.to_s.sub(%r{/+\z}, "")).sub(/\.git\z/, "")
|
|
673
|
-
|
|
674
|
-
def native_ios? = !(native_ios_components.empty? && native_ios_screens.empty? && native_ios_splash.nil?)
|
|
675
|
-
|
|
676
|
-
# Everything declared here is interpolated into generated Swift, so names
|
|
677
|
-
# are validated hard: type names must be plain Swift identifiers, screen
|
|
678
|
-
# identifiers must be safe inside a Swift string literal.
|
|
679
|
-
SWIFT_TYPE_NAME = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
680
|
-
SCREEN_IDENTIFIER = /\A[A-Za-z0-9_-]+\z/
|
|
681
|
-
|
|
682
|
-
def native_ios_errors
|
|
683
|
-
types = native_ios_components + native_ios_screens.values + [native_ios_splash].compact
|
|
684
|
-
errors = types.reject { |t| t.match?(SWIFT_TYPE_NAME) }.map do |t|
|
|
685
|
-
"native.ios: #{t.inspect} is not a Swift type name (letters, digits, _)"
|
|
686
|
-
end
|
|
687
|
-
errors += native_ios_screens.keys.reject { |id| id.match?(SCREEN_IDENTIFIER) }.map do |id|
|
|
688
|
-
"native.ios.screens: identifier #{id.inspect} must match #{SCREEN_IDENTIFIER.inspect}"
|
|
689
|
-
end
|
|
690
|
-
|
|
691
|
-
raw = native_ios["splash_min_seconds"]
|
|
692
|
-
errors << "native.ios.splash_min_seconds must be a number (seconds)" if raw && !raw.is_a?(Numeric)
|
|
693
|
-
errors + native_ios_package_errors
|
|
694
|
-
end
|
|
695
|
-
|
|
696
|
-
# A URL SPM accepts (https for the common case, git@ for SSH remotes) and a
|
|
697
|
-
# loose semver for from:/exact: — enough to catch typos without reimplementing
|
|
698
|
-
# SPM's resolver, which reports anything subtler at build time.
|
|
699
|
-
PACKAGE_URL = %r{\A(https?://|git@).+}
|
|
700
|
-
PACKAGE_VERSION = /\A\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?\z/
|
|
701
|
-
|
|
702
|
-
def native_ios_package_errors
|
|
703
|
-
raw = native_ios["packages"]
|
|
704
|
-
return [] if raw.nil?
|
|
705
|
-
return ["native.ios.packages must be a list"] unless raw.is_a?(Array)
|
|
706
|
-
|
|
707
|
-
raw.each_with_index.flat_map do |entry, i|
|
|
708
|
-
at = "native.ios.packages[#{i}]"
|
|
709
|
-
next ["#{at} must be a mapping with a url:"] unless entry.is_a?(Hash)
|
|
710
|
-
|
|
711
|
-
errors = []
|
|
712
|
-
url = entry["url"].to_s.strip
|
|
713
|
-
if url.empty?
|
|
714
|
-
errors << "#{at}.url is required"
|
|
715
|
-
elsif !url.match?(PACKAGE_URL)
|
|
716
|
-
errors << "#{at}.url #{url.inspect} must be an https:// or git@ URL"
|
|
717
|
-
end
|
|
718
|
-
|
|
719
|
-
present = PACKAGE_REQUIREMENT_KEYS.select { |k| entry.key?(k) && !entry[k].to_s.strip.empty? }
|
|
720
|
-
if present.empty?
|
|
721
|
-
errors << "#{at} needs one version requirement (#{PACKAGE_REQUIREMENT_KEYS.join(" / ")})"
|
|
722
|
-
elsif present.length > 1
|
|
723
|
-
errors << "#{at} sets conflicting requirements (#{present.join(", ")}) — pick one"
|
|
724
|
-
elsif %w[from exact].include?(present.first) && !entry[present.first].to_s.strip.match?(PACKAGE_VERSION)
|
|
725
|
-
errors << "#{at}.#{present.first} #{entry[present.first].to_s.inspect} must be a version like \"1.2.0\""
|
|
726
|
-
end
|
|
727
|
-
|
|
728
|
-
Array(entry["products"]).each do |product|
|
|
729
|
-
next if product.to_s.match?(SWIFT_TYPE_NAME)
|
|
730
|
-
|
|
731
|
-
errors << "#{at}.products: #{product.to_s.inspect} is not a module name (letters, digits, _)"
|
|
732
|
-
end
|
|
733
|
-
errors
|
|
734
|
-
end
|
|
735
|
-
end
|
|
736
|
-
|
|
737
|
-
# The Android half of "supernative": Kotlin the app repo carries in
|
|
738
|
-
# native/android/ that `every build --android` compiles into the shell,
|
|
739
|
-
# declared the same way iOS declares Swift. Same build-time-only rule —
|
|
740
|
-
# Play forbids downloading executable code — and the same shape, so an app
|
|
741
|
-
# that already knows `native.ios:` knows this section too.
|
|
742
|
-
#
|
|
743
|
-
# native:
|
|
744
|
-
# android:
|
|
745
|
-
# components: [ChartComponent] # BridgeComponent subclasses to register
|
|
746
|
-
# screens: # path rules with view_controller: <id>
|
|
747
|
-
# map: MapFragment # Fragment with an (url) argument
|
|
748
|
-
# splash: LaunchSplash # Fragment/Activity shown while booting
|
|
749
|
-
# splash_min_seconds: 1.0 # how long that splash stays up at minimum
|
|
750
|
-
# lazy_load_tabs: true # defer each tab's first visit until selected
|
|
751
|
-
# icon_font: symbols # which Material icon font resolves icon names
|
|
752
|
-
# packages: # Maven coordinates, not SPM entries
|
|
753
|
-
# - "com.airbnb.android:lottie:6.4.0"
|
|
754
|
-
def native_android
|
|
755
|
-
section = @data.dig("native", "android")
|
|
756
|
-
section.is_a?(Hash) ? section : {}
|
|
757
|
-
end
|
|
758
|
-
|
|
759
|
-
def native_android_components = Array(native_android["components"]).map(&:to_s)
|
|
760
|
-
|
|
761
|
-
def native_android_screens
|
|
762
|
-
entries = native_android["screens"]
|
|
763
|
-
return {} unless entries.is_a?(Hash)
|
|
764
|
-
|
|
765
|
-
entries.to_h { |id, type| [id.to_s, type.to_s] }
|
|
766
|
-
end
|
|
767
|
-
|
|
768
|
-
def native_android_splash = native_android["splash"]&.to_s
|
|
769
|
-
|
|
770
|
-
# Same contract as the iOS splash minimum, and the same clamp: the shell
|
|
771
|
-
# gives up waiting at 8s either way, so a larger number here would only
|
|
772
|
-
# promise something the shell won't honor.
|
|
773
|
-
def native_android_splash_min_seconds
|
|
774
|
-
value = native_android["splash_min_seconds"]
|
|
775
|
-
value.to_f.clamp(0.0, 8.0) if value.is_a?(Numeric)
|
|
776
|
-
end
|
|
777
|
-
|
|
778
|
-
# Tri-state like native_ios_lazy_load_tabs: true defers each non-selected
|
|
779
|
-
# tab's first visit, false loads them all up front, absent leaves the shell
|
|
780
|
-
# on its default. Android's bottom-nav hosts are created eagerly at
|
|
781
|
-
# onCreate, so this decides whether they *visit*, not whether they exist.
|
|
782
|
-
def native_android_lazy_load_tabs
|
|
783
|
-
value = native_android["lazy_load_tabs"]
|
|
784
|
-
value if value == true || value == false
|
|
785
|
-
end
|
|
786
|
-
|
|
787
|
-
# Third-party dependencies for native/android code, as Gradle sees them:
|
|
788
|
-
# plain Maven coordinates. There is no SPM-style url + requirement split —
|
|
789
|
-
# a coordinate already carries group, artifact and version — so this
|
|
790
|
-
# accessor returns strings, one per `implementation(…)` line the builder
|
|
791
|
-
# writes into native-packages.gradle.kts. Assumes the config validated
|
|
792
|
-
# clean (see native_android_package_errors).
|
|
793
|
-
def native_android_packages
|
|
794
|
-
Array(native_android["packages"]).filter_map do |entry|
|
|
795
|
-
next unless entry.is_a?(String)
|
|
796
|
-
|
|
797
|
-
coordinate = entry.strip
|
|
798
|
-
coordinate unless coordinate.empty?
|
|
799
|
-
end
|
|
800
|
-
end
|
|
801
|
-
|
|
802
|
-
def native_android_packages? = !native_android_packages.empty?
|
|
803
|
-
|
|
804
|
-
def native_android? = !(native_android_components.empty? && native_android_screens.empty? &&
|
|
805
|
-
native_android_splash.nil?)
|
|
806
|
-
|
|
807
|
-
# Kotlin's identifier rules match Swift's across everything we generate
|
|
808
|
-
# (no backticked names, no unicode escapes), so one pattern validates both.
|
|
809
|
-
# Aliased rather than reused by name so the Android messages can say
|
|
810
|
-
# "Kotlin" and the two paths can diverge later without touching iOS.
|
|
811
|
-
KOTLIN_TYPE_NAME = SWIFT_TYPE_NAME
|
|
812
|
-
|
|
813
|
-
def native_android_errors
|
|
814
|
-
types = native_android_components + native_android_screens.values + [native_android_splash].compact
|
|
815
|
-
errors = types.reject { |t| t.match?(KOTLIN_TYPE_NAME) }.map do |t|
|
|
816
|
-
"native.android: #{t.inspect} is not a Kotlin type name (letters, digits, _)"
|
|
817
|
-
end
|
|
818
|
-
errors += native_android_screens.keys.reject { |id| id.match?(SCREEN_IDENTIFIER) }.map do |id|
|
|
819
|
-
"native.android.screens: identifier #{id.inspect} must match #{SCREEN_IDENTIFIER.inspect}"
|
|
820
|
-
end
|
|
821
|
-
|
|
822
|
-
raw = native_android["splash_min_seconds"]
|
|
823
|
-
errors << "native.android.splash_min_seconds must be a number (seconds)" if raw && !raw.is_a?(Numeric)
|
|
824
|
-
errors + native_android_icon_font_errors + native_android_package_errors
|
|
825
|
-
end
|
|
826
|
-
|
|
827
|
-
# group:artifact:version — Gradle's shorthand form, and the only one we
|
|
828
|
-
# accept. The charset is narrower than Maven strictly allows on purpose:
|
|
829
|
-
# every coordinate is interpolated into a generated Kotlin DSL file inside
|
|
830
|
-
# a double-quoted literal, so a quote, a backslash, a `$` (Kotlin string
|
|
831
|
-
# templates), a newline or a space would rewrite the build script rather
|
|
832
|
-
# than name a dependency. Version ranges ("[1.0,2.0)") and BOM-style
|
|
833
|
-
# two-part coordinates are rejected for the same reason and because Gradle
|
|
834
|
-
# resolves anything subtler at build time anyway.
|
|
835
|
-
MAVEN_SEGMENT = /[A-Za-z0-9_][A-Za-z0-9_.-]*/
|
|
836
|
-
MAVEN_COORDINATE = /\A#{MAVEN_SEGMENT}:#{MAVEN_SEGMENT}:[A-Za-z0-9_][A-Za-z0-9_.+-]*\z/
|
|
837
|
-
|
|
838
|
-
def native_android_package_errors
|
|
839
|
-
raw = native_android["packages"]
|
|
840
|
-
return [] if raw.nil?
|
|
841
|
-
return ["native.android.packages must be a list"] unless raw.is_a?(Array)
|
|
842
|
-
|
|
843
|
-
raw.each_with_index.filter_map do |entry, i|
|
|
844
|
-
at = "native.android.packages[#{i}]"
|
|
845
|
-
next "#{at} must be a Maven coordinate string like \"com.airbnb.android:lottie:6.4.0\"" unless entry.is_a?(String)
|
|
846
|
-
|
|
847
|
-
coordinate = entry.strip
|
|
848
|
-
next if coordinate.match?(MAVEN_COORDINATE)
|
|
849
|
-
|
|
850
|
-
"#{at} #{coordinate.inspect} is not a Maven coordinate — " \
|
|
851
|
-
"\"group:artifact:version\" (letters, digits, . _ -)"
|
|
852
|
-
end
|
|
853
|
-
end
|
|
854
|
-
|
|
855
|
-
# Which Material icon font `icons.android` names — and the per-platform
|
|
856
|
-
# icon names nav buttons and menu items carry in page HTML — resolve
|
|
857
|
-
# against. Android has no UIImage(systemName:), so the shell draws the
|
|
858
|
-
# icon's codepoint from a font; the choice is which font ships.
|
|
859
|
-
#
|
|
860
|
-
# `bundled` is the whole reason this is a map and not a list: only the
|
|
861
|
-
# classic set (357 KB) rides inside the gem, because `ruby_everywhere` is
|
|
862
|
-
# 229 KB today and Symbols is ~10.6 MB — a 48x install cost paid by every
|
|
863
|
-
# user, including the desktop-only ones. The Symbols variants are fetched
|
|
864
|
-
# once on first Android build and cached under ~/.rubyeverywhere, which is
|
|
865
|
-
# noise next to the Gradle and AGP downloads the same build already makes.
|
|
866
|
-
#
|
|
867
|
-
# `codepoints` is the name → codepoint map Ruby validates every declared
|
|
868
|
-
# icon name against at build time, so a typo fails the build naming the tab
|
|
869
|
-
# it came from instead of rendering a blank icon on device.
|
|
870
|
-
ANDROID_ICON_FONTS = {
|
|
871
|
-
"symbols" => { "file" => "MaterialSymbolsOutlined.ttf", "bundled" => false },
|
|
872
|
-
"symbols-rounded" => { "file" => "MaterialSymbolsRounded.ttf", "bundled" => false },
|
|
873
|
-
"symbols-sharp" => { "file" => "MaterialSymbolsSharp.ttf", "bundled" => false },
|
|
874
|
-
"classic" => { "file" => "MaterialIcons-Regular.ttf", "bundled" => true },
|
|
875
|
-
# No font at all: the app brings its own drawables in native/android/res/.
|
|
876
|
-
"none" => { "file" => nil, "bundled" => true }
|
|
877
|
-
}.freeze
|
|
878
|
-
|
|
879
|
-
DEFAULT_ANDROID_ICON_FONT = "symbols"
|
|
880
|
-
|
|
881
|
-
def native_android_icon_font
|
|
882
|
-
value = native_android["icon_font"].to_s.strip
|
|
883
|
-
value.empty? ? DEFAULT_ANDROID_ICON_FONT : value
|
|
884
|
-
end
|
|
885
|
-
|
|
886
|
-
# The font's asset filename (assets/fonts/<file>), or nil for "none".
|
|
887
|
-
def native_android_icon_font_file = ANDROID_ICON_FONTS.dig(native_android_icon_font, "file")
|
|
888
|
-
|
|
889
|
-
# The sidecar map the builder validates names against, alongside the font.
|
|
890
|
-
def native_android_icon_font_codepoints
|
|
891
|
-
native_android_icon_font_file&.sub(/\.ttf\z/, ".codepoints")
|
|
892
|
-
end
|
|
893
|
-
|
|
894
|
-
# True when the selected font has to be downloaded before the first build
|
|
895
|
-
# can stamp it — the only case that can fail offline, so the builder warns
|
|
896
|
-
# about it (and points at `classic`) rather than dying mid-Gradle.
|
|
897
|
-
def native_android_icon_font_fetched?
|
|
898
|
-
entry = ANDROID_ICON_FONTS[native_android_icon_font]
|
|
899
|
-
!entry.nil? && entry["bundled"] == false
|
|
900
|
-
end
|
|
901
|
-
|
|
902
|
-
def native_android_icons? = !native_android_icon_font_file.nil?
|
|
903
|
-
|
|
904
|
-
def native_android_icon_font_errors
|
|
905
|
-
return [] if ANDROID_ICON_FONTS.key?(native_android_icon_font)
|
|
906
|
-
|
|
907
|
-
["native.android.icon_font #{native_android_icon_font.inspect} is not a known font — " \
|
|
908
|
-
"one of: #{ANDROID_ICON_FONTS.keys.join(", ")}"]
|
|
909
|
-
end
|
|
910
|
-
|
|
911
|
-
# The desktop half of "supernative": Rust the app repo carries in
|
|
912
|
-
# native/desktop/ that `every build` compiles into the Tauri shell.
|
|
913
|
-
#
|
|
914
|
-
# The shape differs from ios/android because desktop differs: the whole UI
|
|
915
|
-
# is a webview, so there are no native screens or splash views to register.
|
|
916
|
-
# What an app actually wants down here is the machine — a serial port, a USB
|
|
917
|
-
# device, an FFI library, a Tauri plugin — reached from the page.
|
|
918
|
-
#
|
|
919
|
-
# native:
|
|
920
|
-
# desktop:
|
|
921
|
-
# commands: [scan_ports] # fns reachable from the page
|
|
922
|
-
# setup: true # run native/desktop/setup.rs at boot
|
|
923
|
-
# crates:
|
|
924
|
-
# serialport: "4.3"
|
|
925
|
-
# reqwest: { version: "0.12", features: [json] }
|
|
926
|
-
#
|
|
927
|
-
# A command is a plain function, NOT a #[tauri::command] — Tauri's typed
|
|
928
|
-
# commands only arrive through invoke, and invoke is unavailable to our
|
|
929
|
-
# pages (they're served over http; see extension_host.rs in the shell). The
|
|
930
|
-
# signature is uniform:
|
|
931
|
-
#
|
|
932
|
-
# pub fn scan_ports(app: &tauri::AppHandle, payload: serde_json::Value)
|
|
933
|
-
# -> Result<serde_json::Value, String>
|
|
934
|
-
#
|
|
935
|
-
# Declaring anything here has a real cost the mobile sections don't have:
|
|
936
|
-
# the shell stops being one prebuilt crate shared by every app and gets
|
|
937
|
-
# stamped and compiled per app (see Builders::Desktop). Apps that declare
|
|
938
|
-
# nothing keep the fast path.
|
|
939
|
-
def native_desktop
|
|
940
|
-
section = @data.dig("native", "desktop")
|
|
941
|
-
section.is_a?(Hash) ? section : {}
|
|
942
|
-
end
|
|
943
|
-
|
|
944
|
-
def native_desktop_commands = Array(native_desktop["commands"]).map { |name| name.to_s.strip }
|
|
945
|
-
|
|
946
|
-
def native_desktop_setup? = native_desktop["setup"] == true
|
|
947
|
-
|
|
948
|
-
# Cargo dependencies, normalized to { "name" => {...table} } so the
|
|
949
|
-
# generator has one shape to write. The short form (a bare version string)
|
|
950
|
-
# becomes { "version" => "4.3" }.
|
|
951
|
-
def native_desktop_crates
|
|
952
|
-
entries = native_desktop["crates"]
|
|
953
|
-
return {} unless entries.is_a?(Hash)
|
|
954
|
-
|
|
955
|
-
entries.to_h do |name, spec|
|
|
956
|
-
table = spec.is_a?(Hash) ? spec : { "version" => spec.to_s }
|
|
957
|
-
[name.to_s.strip, table]
|
|
958
|
-
end
|
|
959
|
-
end
|
|
960
|
-
|
|
961
|
-
def native_desktop? = !(native_desktop_commands.empty? && native_desktop_crates.empty?) ||
|
|
962
|
-
native_desktop_setup?
|
|
963
|
-
|
|
964
|
-
# Everything here is interpolated into generated Rust and TOML, so names are
|
|
965
|
-
# validated hard — same reasoning as MAVEN_COORDINATE above. A command name
|
|
966
|
-
# becomes a path segment in a generate_handler! list; a crate name and its
|
|
967
|
-
# features become bare TOML keys and quoted strings.
|
|
968
|
-
RUST_FN_NAME = /\A[a-z_][a-z0-9_]*\z/
|
|
969
|
-
CRATE_NAME = /\A[a-zA-Z0-9_][a-zA-Z0-9_-]*\z/
|
|
970
|
-
# Cargo's requirement syntax, loosely: ^1.2, ~1.2, >=1.0, 1.*, 1.2.3-beta.1.
|
|
971
|
-
# Deliberately no spaces and no commas — a multi-requirement string would
|
|
972
|
-
# need quoting rules we don't want to own, and cargo reports anything
|
|
973
|
-
# subtler at build time.
|
|
974
|
-
CRATE_VERSION = /\A[\^~<>=]{0,2}\d+(\.\d+){0,2}(\.\*)?(-[0-9A-Za-z.-]+)?\z/
|
|
975
|
-
CRATE_FEATURE = /\A[A-Za-z0-9_][A-Za-z0-9_+-]*\z/
|
|
976
|
-
# Where a git dependency may point. Same rule the SPM packages use.
|
|
977
|
-
CRATE_GIT_URL = PACKAGE_URL
|
|
978
|
-
|
|
979
|
-
def native_desktop_errors
|
|
980
|
-
raw = @data.dig("native", "desktop")
|
|
981
|
-
return [] if raw.nil?
|
|
982
|
-
return ["native.desktop must be a mapping"] unless raw.is_a?(Hash)
|
|
983
|
-
|
|
984
|
-
errors = []
|
|
985
|
-
commands = raw["commands"]
|
|
986
|
-
if !commands.nil? && !commands.is_a?(Array)
|
|
987
|
-
errors << "native.desktop.commands must be a list of function names"
|
|
988
|
-
else
|
|
989
|
-
errors += native_desktop_commands.reject { |name| name.match?(RUST_FN_NAME) }.map do |name|
|
|
990
|
-
"native.desktop.commands: #{name.inspect} is not a Rust function name — " \
|
|
991
|
-
"snake_case (letters, digits, _), matching the fn in native/desktop/"
|
|
992
|
-
end
|
|
993
|
-
duplicates = native_desktop_commands.tally.select { |_name, count| count > 1 }.keys
|
|
994
|
-
errors += duplicates.map do |name|
|
|
995
|
-
"native.desktop.commands: #{name.inspect} is listed twice"
|
|
996
|
-
end
|
|
997
|
-
end
|
|
998
|
-
|
|
999
|
-
setup = raw["setup"]
|
|
1000
|
-
errors << "native.desktop.setup must be true or false" unless setup.nil? || boolean_or_nil(setup) == setup
|
|
1001
|
-
|
|
1002
|
-
errors + native_desktop_crate_errors
|
|
1003
|
-
end
|
|
1004
|
-
|
|
1005
|
-
def native_desktop_crate_errors
|
|
1006
|
-
raw = native_desktop["crates"]
|
|
1007
|
-
return [] if raw.nil?
|
|
1008
|
-
return ["native.desktop.crates must be a mapping of crate name to version"] unless raw.is_a?(Hash)
|
|
1009
|
-
|
|
1010
|
-
raw.flat_map do |name, spec|
|
|
1011
|
-
at = "native.desktop.crates.#{name}"
|
|
1012
|
-
errors = []
|
|
1013
|
-
unless name.to_s.strip.match?(CRATE_NAME)
|
|
1014
|
-
errors << "#{at}: #{name.to_s.inspect} is not a crate name (letters, digits, _, -)"
|
|
1015
|
-
end
|
|
1016
|
-
errors + crate_spec_errors(at, spec)
|
|
1017
|
-
end
|
|
1018
|
-
end
|
|
1019
|
-
|
|
1020
|
-
# Third-party sign-in (Sign in with Apple / Google / GitHub / anything
|
|
1021
|
-
# OmniAuth speaks). Providers refuse to run — or run badly — inside an app's
|
|
1022
|
-
# web view, so the mobile shell hands these paths to an
|
|
1023
|
-
# ASWebAuthenticationSession instead, and the gem bridges the resulting
|
|
1024
|
-
# session back into the app (see AuthHandoff).
|
|
1025
|
-
#
|
|
1026
|
-
# auth:
|
|
1027
|
-
# oauth_paths: # paths that begin a provider flow
|
|
1028
|
-
# - ^/auth/ # unanchored regexes, like `rules:`
|
|
1029
|
-
# scheme: com.example.app # callback scheme (default: the iOS bundle id)
|
|
1030
|
-
# cookies: # which cookies cross back into the app
|
|
1031
|
-
# except: ["_ga"] # (default: all of them)
|
|
1032
|
-
#
|
|
1033
|
-
# The app keeps its own auth: `auth:` declares nothing about providers,
|
|
1034
|
-
# only which paths the shell must not open in its web view.
|
|
1035
|
-
def auth = @data.fetch("auth", nil) || {}
|
|
1036
|
-
|
|
1037
|
-
# Paths that begin a third-party auth flow, as regex source strings matched
|
|
1038
|
-
# against the request path. Declaring `auth:` at all opts in, defaulting to
|
|
1039
|
-
# OmniAuth's `/auth/…` convention.
|
|
1040
|
-
DEFAULT_OAUTH_PATHS = ["^/auth/"].freeze
|
|
1041
|
-
|
|
1042
|
-
def oauth_paths
|
|
1043
|
-
return [] unless @data.key?("auth")
|
|
1044
|
-
|
|
1045
|
-
paths = Array(auth["oauth_paths"]).map { |p| p.to_s.strip }.reject(&:empty?)
|
|
1046
|
-
paths.empty? ? DEFAULT_OAUTH_PATHS.dup : paths
|
|
1047
|
-
end
|
|
1048
|
-
|
|
1049
|
-
def oauth? = !oauth_paths.empty?
|
|
1050
|
-
|
|
1051
|
-
# Whether a request path starts a provider flow. Both halves of the system
|
|
1052
|
-
# ask this — the middleware per request, the shell per visit proposal — so
|
|
1053
|
-
# the patterns are compiled the same way on both sides (unanchored regex).
|
|
1054
|
-
def oauth_path?(path)
|
|
1055
|
-
oauth_paths.any? { |pattern| Regexp.new(pattern).match?(path.to_s) }
|
|
1056
|
-
rescue RegexpError
|
|
1057
|
-
false
|
|
1058
|
-
end
|
|
1059
|
-
|
|
1060
|
-
# The custom URL scheme ASWebAuthenticationSession returns through. Defaults
|
|
1061
|
-
# to the iOS bundle id — the convention, and already unique per app. Only
|
|
1062
|
-
# the characters RFC 3986 allows in a scheme.
|
|
1063
|
-
URL_SCHEME = /\A[a-zA-Z][a-zA-Z0-9+.-]*\z/
|
|
1064
|
-
|
|
1065
|
-
def auth_scheme
|
|
1066
|
-
explicit = auth["scheme"].to_s.strip
|
|
1067
|
-
explicit.empty? ? bundle_id(target: "ios") : explicit
|
|
1068
|
-
end
|
|
1069
|
-
|
|
1070
|
-
# Cookie names to carry from the auth browser back into the app's web view,
|
|
1071
|
-
# filtered by an optional allow/deny list. Everything the flow set is
|
|
1072
|
-
# carried by default: the gem can't know which cookie an app's auth library
|
|
1073
|
-
# signs its session with.
|
|
1074
|
-
def auth_cookie?(name)
|
|
1075
|
-
cookies = auth["cookies"]
|
|
1076
|
-
return true unless cookies.is_a?(Hash)
|
|
1077
|
-
|
|
1078
|
-
only = Array(cookies["only"]).map(&:to_s)
|
|
1079
|
-
return only.include?(name.to_s) unless only.empty?
|
|
1080
|
-
|
|
1081
|
-
!Array(cookies["except"]).map(&:to_s).include?(name.to_s)
|
|
1082
|
-
end
|
|
1083
|
-
|
|
1084
|
-
# How long a minted handoff token stays valid. It travels device-locally
|
|
1085
|
-
# (browser sheet → shell → web view), so seconds are plenty.
|
|
1086
|
-
def auth_token_ttl = [(auth["token_ttl"] || 60).to_i, 5].max
|
|
1087
|
-
|
|
1088
|
-
def auth_errors
|
|
1089
|
-
return [] unless @data.key?("auth")
|
|
1090
|
-
|
|
1091
|
-
errors = oauth_paths.filter_map do |pattern|
|
|
1092
|
-
Regexp.new(pattern)
|
|
1093
|
-
nil
|
|
1094
|
-
rescue RegexpError => e
|
|
1095
|
-
"auth.oauth_paths: #{pattern.inspect} is not a valid pattern (#{e.message})"
|
|
1096
|
-
end
|
|
1097
|
-
|
|
1098
|
-
unless auth_scheme.match?(URL_SCHEME)
|
|
1099
|
-
errors << "auth.scheme #{auth_scheme.inspect} is not a URL scheme (letters, digits, +, -, .)"
|
|
1100
|
-
end
|
|
1101
|
-
errors
|
|
1102
|
-
end
|
|
1103
|
-
|
|
1104
|
-
# The auth subset the shell needs: which visits to divert into the auth
|
|
1105
|
-
# session, and the scheme to bring the answer back through. nil (absent from
|
|
1106
|
-
# everywhere.json) when the app declares no `auth:` — a shell that can't
|
|
1107
|
-
# divert anything is a shell with no new surface.
|
|
1108
|
-
def auth_shell_hash
|
|
1109
|
-
return nil unless oauth?
|
|
1110
|
-
|
|
1111
|
-
{ "oauth_paths" => oauth_paths, "scheme" => auth_scheme }
|
|
1112
|
-
end
|
|
1113
|
-
|
|
1114
|
-
# Custom native menu items (macOS app menu). Each entry: label + path
|
|
1115
|
-
# (+ optional accelerator), or "separator". Clicks arrive in the page as
|
|
1116
|
-
# "everywhere:menu" events and navigate via Turbo.
|
|
1117
|
-
def menu
|
|
1118
|
-
entries = @data["menu"]
|
|
1119
|
-
return [] unless entries.is_a?(Array)
|
|
1120
|
-
|
|
1121
|
-
entries.filter_map do |item|
|
|
1122
|
-
if item == "separator" || (item.is_a?(Hash) && item["separator"])
|
|
1123
|
-
{ "separator" => true }
|
|
1124
|
-
elsif item.is_a?(Hash) && item["label"] && item["path"]
|
|
1125
|
-
{ "label" => item["label"], "path" => item["path"],
|
|
1126
|
-
"accelerator" => item["accelerator"] }.compact
|
|
1127
|
-
end
|
|
1128
|
-
end
|
|
1129
|
-
end
|
|
1130
|
-
|
|
1131
|
-
# System tray: same entry shape as `menu:` plus `action: quit | show`.
|
|
1132
|
-
#
|
|
1133
|
-
# tray:
|
|
1134
|
-
# - label: "Open My App"
|
|
1135
|
-
# action: show
|
|
1136
|
-
# - label: "New Note"
|
|
1137
|
-
# path: /notes/new
|
|
1138
|
-
# - separator
|
|
1139
|
-
# - label: Quit
|
|
1140
|
-
# action: quit
|
|
1141
|
-
def tray
|
|
1142
|
-
entries = @data["tray"]
|
|
1143
|
-
return [] unless entries.is_a?(Array)
|
|
1144
|
-
|
|
1145
|
-
entries.filter_map do |item|
|
|
1146
|
-
if item == "separator" || (item.is_a?(Hash) && item["separator"])
|
|
1147
|
-
{ "separator" => true }
|
|
1148
|
-
elsif item.is_a?(Hash) && item["label"] && (item["path"] || item["action"])
|
|
1149
|
-
{ "label" => item["label"], "path" => item["path"],
|
|
1150
|
-
"action" => item["action"] }.compact
|
|
1151
|
-
end
|
|
1152
|
-
end
|
|
1153
|
-
end
|
|
1154
|
-
|
|
1155
|
-
# Desktop window chrome. Desktop-only: on mobile the OS owns the frame, so
|
|
1156
|
-
# mobile builds ignore this section entirely.
|
|
1157
|
-
#
|
|
1158
|
-
# window:
|
|
1159
|
-
# title_bar: overlay # decorated (default) | overlay | frameless
|
|
1160
|
-
# title: false # draw the title text (default true)
|
|
1161
|
-
# size: [1100, 750] # initial inner size
|
|
1162
|
-
# min_size: [600, 400]
|
|
1163
|
-
# resizable: true
|
|
1164
|
-
# drag_height: 28 # top strip that drags the window; 0 turns it off
|
|
1165
|
-
#
|
|
1166
|
-
# Every reader returns nil when the key is absent so `to_shell_hash` drops it
|
|
1167
|
-
# and the shell keeps its own default — there is exactly one place each
|
|
1168
|
-
# default lives, and it's the Rust side.
|
|
1169
|
-
#
|
|
1170
|
-
# `overlay` is the macOS "traffic lights floating over your content" look
|
|
1171
|
-
# (TitleBarStyle::Overlay + hidden title). Windows and Linux have no such
|
|
1172
|
-
# style, so the shell falls back to frameless there and the page draws its
|
|
1173
|
-
# own controls via Everywhere.window.
|
|
1174
|
-
WINDOW_TITLE_BARS = %w[decorated overlay frameless].freeze
|
|
1175
|
-
|
|
1176
|
-
def window
|
|
1177
|
-
section = @data["window"]
|
|
1178
|
-
section.is_a?(Hash) ? section : {}
|
|
1179
|
-
end
|
|
1180
|
-
|
|
1181
|
-
def window_title_bar
|
|
1182
|
-
value = window["title_bar"].to_s
|
|
1183
|
-
value if WINDOW_TITLE_BARS.include?(value)
|
|
1184
|
-
end
|
|
1185
|
-
|
|
1186
|
-
# Tri-state, like native_ios_lazy_load_tabs: an absent key leaves the shell
|
|
1187
|
-
# on its default rather than forcing one.
|
|
1188
|
-
def window_title = boolean_or_nil(window["title"])
|
|
1189
|
-
def window_resizable = boolean_or_nil(window["resizable"])
|
|
1190
|
-
|
|
1191
|
-
def window_size = window_dimensions("size")
|
|
1192
|
-
def window_min_size = window_dimensions("min_size")
|
|
1193
|
-
|
|
1194
|
-
# How much of the top of the page drags the window, in CSS pixels. Only
|
|
1195
|
-
# meaningful for overlay/frameless, where there's no system title bar left
|
|
1196
|
-
# to grab. nil leaves the shell's 28px default (the height of the macOS
|
|
1197
|
-
# traffic-light band); 0 turns dragging off for an app that wants to place
|
|
1198
|
-
# its own drag regions.
|
|
1199
|
-
def window_drag_height
|
|
1200
|
-
value = window["drag_height"]
|
|
1201
|
-
value.to_f if value.is_a?(Numeric) && !value.negative?
|
|
1202
|
-
end
|
|
1203
|
-
|
|
1204
|
-
# The window subset the shell needs. nil (absent from everywhere.json) when
|
|
1205
|
-
# the app declares nothing, so the shell's own defaults stand untouched.
|
|
1206
|
-
def window_shell_hash
|
|
1207
|
-
hash = {
|
|
1208
|
-
"title_bar" => window_title_bar,
|
|
1209
|
-
"title" => window_title,
|
|
1210
|
-
"resizable" => window_resizable,
|
|
1211
|
-
"size" => window_size,
|
|
1212
|
-
"min_size" => window_min_size,
|
|
1213
|
-
"drag_height" => window_drag_height
|
|
1214
|
-
}.compact
|
|
1215
|
-
hash unless hash.empty?
|
|
1216
|
-
end
|
|
1217
|
-
|
|
1218
|
-
def window_errors
|
|
1219
|
-
raw = @data["window"]
|
|
1220
|
-
return [] if raw.nil?
|
|
1221
|
-
return ["window: must be a mapping"] unless raw.is_a?(Hash)
|
|
1222
|
-
|
|
1223
|
-
errors = []
|
|
1224
|
-
bar = raw["title_bar"]
|
|
1225
|
-
if bar && !WINDOW_TITLE_BARS.include?(bar.to_s)
|
|
1226
|
-
errors << "window.title_bar #{bar.to_s.inspect} must be one of " \
|
|
1227
|
-
"#{WINDOW_TITLE_BARS.join(" / ")}"
|
|
1228
|
-
end
|
|
1229
|
-
|
|
1230
|
-
errors += %w[size min_size].filter_map do |key|
|
|
1231
|
-
next if raw[key].nil? || window_dimensions(key)
|
|
1232
|
-
|
|
1233
|
-
"window.#{key} must be two positive numbers, like [1100, 750]"
|
|
1234
|
-
end
|
|
1235
|
-
|
|
1236
|
-
drag = raw["drag_height"]
|
|
1237
|
-
unless drag.nil? || (drag.is_a?(Numeric) && !drag.negative?)
|
|
1238
|
-
errors << "window.drag_height must be a number of pixels (0 turns dragging off)"
|
|
1239
|
-
end
|
|
1240
|
-
|
|
1241
|
-
errors + %w[title resizable].filter_map do |key|
|
|
1242
|
-
next if raw[key].nil? || boolean_or_nil(raw[key]) == raw[key]
|
|
1243
|
-
|
|
1244
|
-
"window.#{key} must be true or false"
|
|
1245
|
-
end
|
|
1246
|
-
end
|
|
1247
|
-
|
|
1248
|
-
# Deep linking / universal links. Declares the app's association with its
|
|
1249
|
-
# web domains so the OS can hand matching URLs to the app instead of the
|
|
1250
|
-
# browser. The gem serves the two well-known association files this needs —
|
|
1251
|
-
# /.well-known/apple-app-site-association and /.well-known/assetlinks.json —
|
|
1252
|
-
# generated from this config (Everywhere::Engine in Rails; MobileConfigEndpoint
|
|
1253
|
-
# for Sinatra/Hanami). The iOS builder stamps the matching Associated Domains
|
|
1254
|
-
# entitlement, and the shell routes an incoming link to its path.
|
|
1255
|
-
#
|
|
1256
|
-
# deep_linking:
|
|
1257
|
-
# team_id: ABCDE12345 # derives the iOS app id from bundle_id
|
|
1258
|
-
# # or set app ids explicitly:
|
|
1259
|
-
# # apple_app_id: ABCDE12345.com.example.app
|
|
1260
|
-
# # apple_app_ids: ["ABCDE12345.com.example.app"]
|
|
1261
|
-
# paths: ["/*"] # which paths open the app (default: all)
|
|
1262
|
-
# domains: ["www.example.com"] # extra applinks: domains (remote host is implicit)
|
|
1263
|
-
# android:
|
|
1264
|
-
# package: com.example.app
|
|
1265
|
-
# sha256_cert_fingerprints: ["AB:CD:EF:..."]
|
|
1266
|
-
def deep_linking = @data.fetch("deep_linking", nil) || {}
|
|
1267
|
-
|
|
1268
|
-
# The iOS/tvOS/... app identifiers (TeamID.bundleID) the association file
|
|
1269
|
-
# advertises. Explicit ids win; otherwise team_id + the app's iOS bundle id.
|
|
1270
|
-
def deep_linking_apple_app_ids
|
|
1271
|
-
explicit = Array(deep_linking["apple_app_ids"]) + [deep_linking["apple_app_id"]].compact
|
|
1272
|
-
return explicit.map(&:to_s).uniq unless explicit.empty?
|
|
1273
|
-
|
|
1274
|
-
team = deep_linking["team_id"].to_s.strip
|
|
1275
|
-
team.empty? ? [] : ["#{team}.#{bundle_id(target: "ios")}"]
|
|
1276
|
-
end
|
|
1277
|
-
|
|
1278
|
-
# URL path patterns that open the app. Modern (iOS 14+) component form;
|
|
1279
|
-
# defaults to every path.
|
|
1280
|
-
def deep_linking_paths
|
|
1281
|
-
paths = Array(deep_linking["paths"]).map(&:to_s).reject(&:empty?)
|
|
1282
|
-
paths.empty? ? ["*"] : paths
|
|
1283
|
-
end
|
|
1284
|
-
|
|
1285
|
-
def deep_linking_android = deep_linking["android"].is_a?(Hash) ? deep_linking["android"] : {}
|
|
1286
|
-
def deep_linking_android_package = deep_linking_android["package"]&.to_s
|
|
1287
|
-
|
|
1288
|
-
def deep_linking_android_fingerprints
|
|
1289
|
-
Array(deep_linking_android["sha256_cert_fingerprints"]).map { |f| f.to_s.strip }.reject(&:empty?)
|
|
1290
|
-
end
|
|
1291
|
-
|
|
1292
|
-
def deep_linking_android? = !deep_linking_android_package.to_s.empty? && !deep_linking_android_fingerprints.empty?
|
|
1293
|
-
|
|
1294
|
-
def deep_linking_apple? = !deep_linking_apple_app_ids.empty?
|
|
1295
|
-
|
|
1296
|
-
def deep_linking? = deep_linking_apple? || deep_linking_android?
|
|
1297
|
-
|
|
1298
|
-
# Domains for the iOS Associated Domains entitlement (applinks:<host>) and
|
|
1299
|
-
# the shell's universal-link host allowlist: the remote host plus any extra
|
|
1300
|
-
# `domains:`. Bare hostnames (no scheme, no path).
|
|
1301
|
-
def deep_linking_domains
|
|
1302
|
-
hosts = []
|
|
1303
|
-
if remote_url
|
|
1304
|
-
require "uri"
|
|
1305
|
-
hosts << URI(remote_url).host
|
|
1306
|
-
end
|
|
1307
|
-
hosts += Array(deep_linking["domains"]).map { |d| d.to_s.sub(%r{\Ahttps?://}, "").sub(%r{/.*\z}, "") }
|
|
1308
|
-
hosts.compact.reject(&:empty?).uniq
|
|
1309
|
-
rescue URI::InvalidURIError
|
|
1310
|
-
Array(deep_linking["domains"]).map(&:to_s).reject(&:empty?).uniq
|
|
1311
|
-
end
|
|
1312
|
-
|
|
1313
|
-
# The apple-app-site-association document (a Hash), or nil when no Apple app
|
|
1314
|
-
# ids are configured. `applinks` for universal links; `webcredentials` so
|
|
1315
|
-
# associated-domains password autofill / Face ID credentials work too.
|
|
1316
|
-
def apple_app_site_association
|
|
1317
|
-
ids = deep_linking_apple_app_ids
|
|
1318
|
-
return nil if ids.empty?
|
|
1319
|
-
|
|
1320
|
-
{ "applinks" => {
|
|
1321
|
-
"details" => [{ "appIDs" => ids, "components" => deep_linking_paths.map { |p| { "/" => p } } }]
|
|
1322
|
-
},
|
|
1323
|
-
"webcredentials" => { "apps" => ids } }
|
|
1324
|
-
end
|
|
1325
|
-
|
|
1326
|
-
def apple_app_site_association_json
|
|
1327
|
-
require "json"
|
|
1328
|
-
doc = apple_app_site_association or return nil
|
|
1329
|
-
JSON.generate(doc)
|
|
1330
|
-
end
|
|
1331
|
-
|
|
1332
|
-
# The Digital Asset Links document (an Array), or nil without an Android app.
|
|
1333
|
-
def asset_links
|
|
1334
|
-
return nil unless deep_linking_android?
|
|
1335
|
-
|
|
1336
|
-
[{ "relation" => ["delegate_permission/common.handle_all_urls"],
|
|
1337
|
-
"target" => { "namespace" => "android_app",
|
|
1338
|
-
"package_name" => deep_linking_android_package,
|
|
1339
|
-
"sha256_cert_fingerprints" => deep_linking_android_fingerprints } }]
|
|
1340
|
-
end
|
|
1341
|
-
|
|
1342
|
-
def asset_links_json
|
|
1343
|
-
require "json"
|
|
1344
|
-
doc = asset_links or return nil
|
|
1345
|
-
JSON.generate(doc)
|
|
1346
|
-
end
|
|
1347
|
-
|
|
1348
|
-
# The two `native:` knobs that ship inside everywhere.json rather than
|
|
1349
|
-
# being compiled in, resolved for the platform being stamped. They read the
|
|
1350
|
-
# target's own section: everywhere.json is written once per target, so an
|
|
1351
|
-
# Android build that inherited native.ios.lazy_load_tabs would silently
|
|
1352
|
-
# apply a decision the app made about a different shell — and the two
|
|
1353
|
-
# platforms load tabs differently enough that the answer legitimately
|
|
1354
|
-
# differs. Without a target (the desktop dev loop, and every caller written
|
|
1355
|
-
# before Android existed) the iOS answer stands, which is what those
|
|
1356
|
-
# callers have always gotten.
|
|
1357
|
-
def native_lazy_load_tabs(target: nil)
|
|
1358
|
-
android_target?(target) ? native_android_lazy_load_tabs : native_ios_lazy_load_tabs
|
|
1359
|
-
end
|
|
1360
|
-
|
|
1361
|
-
def native_splash_min_seconds(target: nil)
|
|
1362
|
-
android_target?(target) ? native_android_splash_min_seconds : native_ios_splash_min_seconds
|
|
1363
|
-
end
|
|
1364
|
-
|
|
1365
|
-
# The subset the shell needs, shipped as JSON (env var in dev,
|
|
1366
|
-
# Resources/everywhere.json inside a bundled .app). Pass `target:` so the
|
|
1367
|
-
# packaged config reflects the platform being built (its bundle id / name).
|
|
1368
|
-
def to_shell_hash(target: nil)
|
|
1369
|
-
{
|
|
1370
|
-
"name" => name(target: target),
|
|
1371
|
-
"bundle_id" => bundle_id(target: target),
|
|
1372
|
-
"version" => version(target: target),
|
|
1373
|
-
"mode" => mode,
|
|
1374
|
-
"remote_url" => remote_url,
|
|
1375
|
-
"remote_instances" => (true if remote_instances?),
|
|
1376
|
-
"entry_path" => entry_path,
|
|
1377
|
-
"tint_color" => tint_color,
|
|
1378
|
-
"background_color" => background_color,
|
|
1379
|
-
"menu" => (menu unless menu.empty?),
|
|
1380
|
-
"tray" => (tray unless tray.empty?),
|
|
1381
|
-
"window" => window_shell_hash,
|
|
1382
|
-
"lazy_load_tabs" => native_lazy_load_tabs(target: target),
|
|
1383
|
-
"splash_min_seconds" => native_splash_min_seconds(target: target),
|
|
1384
|
-
"updates" => updates_shell_hash,
|
|
1385
|
-
"permissions" => (permissions.keys unless permissions.empty?),
|
|
1386
|
-
"auth" => auth_shell_hash,
|
|
1387
|
-
# Hosts the shell treats as its own for incoming universal links, so a
|
|
1388
|
-
# tapped link to any associated domain opens in-app rather than Safari.
|
|
1389
|
-
"universal_link_hosts" => (deep_linking_domains if deep_linking? && !deep_linking_domains.empty?)
|
|
1390
|
-
}.compact
|
|
1391
|
-
end
|
|
1392
|
-
|
|
1393
|
-
def to_shell_json(target: nil)
|
|
1394
|
-
require "json"
|
|
1395
|
-
JSON.generate(to_shell_hash(target: target))
|
|
1396
|
-
end
|
|
1397
|
-
|
|
1398
|
-
private
|
|
1399
|
-
|
|
1400
|
-
def app = @data.fetch("app", nil) || {}
|
|
1401
|
-
def appearance = @data.fetch("appearance", nil) || {}
|
|
1402
|
-
|
|
1403
|
-
# One crate's dependency spec: either a bare version string or a table.
|
|
1404
|
-
def crate_spec_errors(at, spec)
|
|
1405
|
-
return version_errors(at, spec) unless spec.is_a?(Hash)
|
|
1406
|
-
|
|
1407
|
-
errors = []
|
|
1408
|
-
# A crate needs somewhere to come from. version: is the common case; git:
|
|
1409
|
-
# and path: are the two escape hatches cargo itself offers.
|
|
1410
|
-
unless %w[version git path].any? { |key| !spec[key].to_s.strip.empty? }
|
|
1411
|
-
errors << "#{at} needs a version: (or git: / path:)"
|
|
1412
|
-
end
|
|
1413
|
-
errors += version_errors("#{at}.version", spec["version"]) if spec.key?("version")
|
|
1414
|
-
|
|
1415
|
-
git = spec["git"].to_s.strip
|
|
1416
|
-
unless git.empty? || git.match?(CRATE_GIT_URL)
|
|
1417
|
-
errors << "#{at}.git #{git.inspect} must be an https:// or git@ URL"
|
|
1418
|
-
end
|
|
1419
|
-
|
|
1420
|
-
# branch/tag/rev/path have no useful shape to check, but they DO end up
|
|
1421
|
-
# inside quoted TOML strings — so the one thing that matters is that they
|
|
1422
|
-
# can't close the quote or start a new line.
|
|
1423
|
-
errors += %w[branch tag rev path].filter_map do |key|
|
|
1424
|
-
value = spec[key].to_s
|
|
1425
|
-
next if value.empty? || !value.match?(/["\\\n\r]/)
|
|
1426
|
-
|
|
1427
|
-
"#{at}.#{key} can't contain quotes, backslashes or newlines"
|
|
1428
|
-
end
|
|
1429
|
-
|
|
1430
|
-
features = spec["features"]
|
|
1431
|
-
if !features.nil? && !features.is_a?(Array)
|
|
1432
|
-
errors << "#{at}.features must be a list"
|
|
1433
|
-
else
|
|
1434
|
-
errors += Array(features).reject { |f| f.to_s.match?(CRATE_FEATURE) }.map do |feature|
|
|
1435
|
-
"#{at}.features: #{feature.to_s.inspect} is not a feature name (letters, digits, _, +, -)"
|
|
1436
|
-
end
|
|
1437
|
-
end
|
|
1438
|
-
errors
|
|
1439
|
-
end
|
|
1440
|
-
|
|
1441
|
-
def version_errors(at, value)
|
|
1442
|
-
version = value.to_s.strip
|
|
1443
|
-
return [] if version.empty?
|
|
1444
|
-
return [] if version.match?(CRATE_VERSION)
|
|
1445
|
-
|
|
1446
|
-
["#{at} #{version.inspect} is not a cargo version requirement — like \"1.2\", \"^1.2.3\" or \"~0.4\""]
|
|
1447
|
-
end
|
|
1448
|
-
|
|
1449
|
-
# YAML's `yes`/`on` already parse as true, so anything that reaches here
|
|
1450
|
-
# still not a boolean is a genuine mistake rather than a spelling of one.
|
|
1451
|
-
# Deliberately not an endless def: `def f(v) = v if cond` parses as
|
|
1452
|
-
# `(def f(v) = v) if cond`, which would define nothing when cond is false.
|
|
1453
|
-
def boolean_or_nil(value)
|
|
1454
|
-
value if value == true || value == false
|
|
1455
|
-
end
|
|
1456
|
-
|
|
1457
|
-
# A [width, height] pair. Rejects the near-misses (one number, three, a
|
|
1458
|
-
# negative) rather than silently building a 0-pixel window.
|
|
1459
|
-
def window_dimensions(key)
|
|
1460
|
-
value = window[key]
|
|
1461
|
-
return unless value.is_a?(Array) && value.length == 2
|
|
1462
|
-
return unless value.all? { |n| n.is_a?(Numeric) && n.positive? }
|
|
1463
|
-
|
|
1464
|
-
value.map(&:to_f)
|
|
1465
|
-
end
|
|
1466
|
-
|
|
1467
|
-
def android_target?(target) = self.class.os_of(target) == "android"
|
|
1468
|
-
|
|
1469
|
-
# Per-platform overrides, keyed by os (macos / ios / android / windows /
|
|
1470
|
-
# linux). Each value overrides the matching `app:` keys for that platform.
|
|
1471
|
-
def platforms = @data.fetch("platforms", nil) || {}
|
|
1472
|
-
|
|
1473
|
-
# The app-level keys with the target's platform override merged on top.
|
|
1474
|
-
# Blank override values are dropped (`.compact`) so they don't erase a
|
|
1475
|
-
# default. Without a target — or without an override for that os — this is
|
|
1476
|
-
# just the plain `app:` hash.
|
|
1477
|
-
def resolved(target)
|
|
1478
|
-
override = target && platforms[self.class.os_of(target)]
|
|
1479
|
-
override.is_a?(Hash) ? app.merge(override.compact) : app
|
|
1480
|
-
end
|
|
1481
|
-
|
|
1482
|
-
def normalize_color(value)
|
|
1483
|
-
case value
|
|
1484
|
-
when String
|
|
1485
|
-
{ "light" => value, "dark" => value }
|
|
1486
|
-
when Hash
|
|
1487
|
-
light = value["light"] || value["dark"]
|
|
1488
|
-
dark = value["dark"] || value["light"]
|
|
1489
|
-
{ "light" => light, "dark" => dark } if light
|
|
1490
|
-
end
|
|
1491
|
-
end
|
|
1492
99
|
end
|
|
1493
100
|
end
|