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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/exe/every +2 -13
  3. data/exe/rbe +2 -13
  4. data/lib/everywhere/boot.rb +9 -2
  5. data/lib/everywhere/builders/android.rb +16 -42
  6. data/lib/everywhere/builders/base.rb +53 -0
  7. data/lib/everywhere/builders/desktop.rb +7 -23
  8. data/lib/everywhere/builders/ios.rb +49 -52
  9. data/lib/everywhere/builders/native_sources.rb +38 -0
  10. data/lib/everywhere/child_processes.rb +4 -4
  11. data/lib/everywhere/child_supervision.rb +172 -0
  12. data/lib/everywhere/clock.rb +12 -0
  13. data/lib/everywhere/commands/build.rb +20 -9
  14. data/lib/everywhere/commands/dev.rb +59 -284
  15. data/lib/everywhere/commands/doctor.rb +6 -6
  16. data/lib/everywhere/commands/install.rb +1 -0
  17. data/lib/everywhere/commands/platform/auth_status.rb +1 -0
  18. data/lib/everywhere/commands/platform/build.rb +5 -6
  19. data/lib/everywhere/commands/platform/login.rb +4 -4
  20. data/lib/everywhere/commands/platform/logout.rb +1 -0
  21. data/lib/everywhere/commands/platform/runner.rb +62 -7
  22. data/lib/everywhere/commands/preview.rb +24 -97
  23. data/lib/everywhere/commands/publish.rb +2 -0
  24. data/lib/everywhere/commands/release.rb +14 -11
  25. data/lib/everywhere/commands/shell_dir.rb +2 -0
  26. data/lib/everywhere/commands/updates_keygen.rb +25 -1
  27. data/lib/everywhere/config/app.rb +126 -0
  28. data/lib/everywhere/config/auth.rb +108 -0
  29. data/lib/everywhere/config/data.rb +50 -0
  30. data/lib/everywhere/config/deep_linking.rb +107 -0
  31. data/lib/everywhere/config/desktop_ui.rb +153 -0
  32. data/lib/everywhere/config/mobile.rb +211 -0
  33. data/lib/everywhere/config/native_desktop.rb +168 -0
  34. data/lib/everywhere/config/native_mobile.rb +337 -0
  35. data/lib/everywhere/config/shell.rb +57 -0
  36. data/lib/everywhere/config/updates.rb +63 -0
  37. data/lib/everywhere/config.rb +30 -1423
  38. data/lib/everywhere/desktop_dev_app.rb +138 -0
  39. data/lib/everywhere/dock/state.rb +3 -1
  40. data/lib/everywhere/entrypoint.rb +24 -0
  41. data/lib/everywhere/error.rb +8 -0
  42. data/lib/everywhere/framework.rb +2 -2
  43. data/lib/everywhere/host.rb +11 -0
  44. data/lib/everywhere/ignore.rb +11 -5
  45. data/lib/everywhere/line_pump.rb +3 -1
  46. data/lib/everywhere/minisign.rb +1 -0
  47. data/lib/everywhere/native_platform.rb +44 -0
  48. data/lib/everywhere/paths.rb +23 -7
  49. data/lib/everywhere/platform/client.rb +19 -1
  50. data/lib/everywhere/platform/snapshot.rb +6 -4
  51. data/lib/everywhere/plist.rb +17 -0
  52. data/lib/everywhere/png.rb +1 -0
  53. data/lib/everywhere/raw_tty.rb +51 -0
  54. data/lib/everywhere/s3.rb +1 -0
  55. data/lib/everywhere/shell_pages.rb +109 -0
  56. data/lib/everywhere/tab_filter.rb +36 -0
  57. data/lib/everywhere/task_pool.rb +3 -4
  58. data/lib/everywhere/ui.rb +6 -1
  59. data/lib/everywhere/version.rb +6 -4
  60. data/lib/everywhere.rb +1 -2
  61. data/support/mobile/ios/App.xcodeproj/project.pbxproj +0 -2
  62. data/support/release/macos/notarize.sh +3 -0
  63. metadata +24 -1
@@ -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