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