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
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ class Config
5
+ module Auth
6
+ # Third-party sign-in (Sign in with Apple / Google / GitHub / anything
7
+ # OmniAuth speaks). Providers refuse to run — or run badly — inside an app's
8
+ # web view, so the mobile shell hands these paths to an
9
+ # ASWebAuthenticationSession instead, and the gem bridges the resulting
10
+ # session back into the app (see AuthHandoff).
11
+ #
12
+ # auth:
13
+ # oauth_paths: # paths that begin a provider flow
14
+ # - ^/auth/ # unanchored regexes, like `rules:`
15
+ # scheme: com.example.app # callback scheme (default: the iOS bundle id)
16
+ # cookies: # which cookies cross back into the app
17
+ # except: ["_ga"] # (default: all of them)
18
+ #
19
+ # The app keeps its own auth: `auth:` declares nothing about providers,
20
+ # only which paths the shell must not open in its web view.
21
+ def auth = @data.fetch("auth", nil) || {}
22
+
23
+ # Paths that begin a third-party auth flow, as regex source strings matched
24
+ # against the request path. Declaring `auth:` at all opts in, defaulting to
25
+ # OmniAuth's `/auth/…` convention.
26
+ DEFAULT_OAUTH_PATHS = ["^/auth/"].freeze
27
+
28
+ def oauth_paths
29
+ return [] unless @data.key?("auth")
30
+
31
+ paths = Array(auth["oauth_paths"]).map { |p| p.to_s.strip }.reject(&:empty?)
32
+ paths.empty? ? DEFAULT_OAUTH_PATHS.dup : paths
33
+ end
34
+
35
+ def oauth? = !oauth_paths.empty?
36
+
37
+ # Whether a request path starts a provider flow. Both halves of the system
38
+ # ask this — the middleware per request, the shell per visit proposal — so
39
+ # the patterns are compiled the same way on both sides (unanchored regex).
40
+ def oauth_path?(path)
41
+ # Compiled once per Config, not once per request: the middleware calls
42
+ # this on every navigation, and the instance it holds lives until
43
+ # everywhere.yml changes on disk. An unparseable pattern matches nothing
44
+ # (auth_errors is what reports it).
45
+ @oauth_regexps ||= oauth_paths.filter_map do |pattern|
46
+ Regexp.new(pattern)
47
+ rescue RegexpError
48
+ nil
49
+ end
50
+ @oauth_regexps.any? { |regexp| regexp.match?(path.to_s) }
51
+ end
52
+
53
+ # The custom URL scheme ASWebAuthenticationSession returns through. Defaults
54
+ # to the iOS bundle id — the convention, and already unique per app. Only
55
+ # the characters RFC 3986 allows in a scheme.
56
+ URL_SCHEME = /\A[a-zA-Z][a-zA-Z0-9+.-]*\z/
57
+
58
+ def auth_scheme
59
+ explicit = auth["scheme"].to_s.strip
60
+ explicit.empty? ? bundle_id(target: "ios") : explicit
61
+ end
62
+
63
+ # Cookie names to carry from the auth browser back into the app's web view,
64
+ # filtered by an optional allow/deny list. Everything the flow set is
65
+ # carried by default: the gem can't know which cookie an app's auth library
66
+ # signs its session with.
67
+ def auth_cookie?(name)
68
+ cookies = auth["cookies"]
69
+ return true unless cookies.is_a?(Hash)
70
+
71
+ only = Array(cookies["only"]).map(&:to_s)
72
+ return only.include?(name.to_s) unless only.empty?
73
+
74
+ !Array(cookies["except"]).map(&:to_s).include?(name.to_s)
75
+ end
76
+
77
+ # How long a minted handoff token stays valid. It travels device-locally
78
+ # (browser sheet → shell → web view), so seconds are plenty.
79
+ def auth_token_ttl = [(auth["token_ttl"] || 60).to_i, 5].max
80
+
81
+ def auth_errors
82
+ return [] unless @data.key?("auth")
83
+
84
+ errors = oauth_paths.filter_map do |pattern|
85
+ Regexp.new(pattern)
86
+ nil
87
+ rescue RegexpError => e
88
+ "auth.oauth_paths: #{pattern.inspect} is not a valid pattern (#{e.message})"
89
+ end
90
+
91
+ unless auth_scheme.match?(URL_SCHEME)
92
+ errors << "auth.scheme #{auth_scheme.inspect} is not a URL scheme (letters, digits, +, -, .)"
93
+ end
94
+ errors
95
+ end
96
+
97
+ # The auth subset the shell needs: which visits to divert into the auth
98
+ # session, and the scheme to bring the answer back through. nil (absent from
99
+ # everywhere.json) when the app declares no `auth:` — a shell that can't
100
+ # divert anything is a shell with no new surface.
101
+ def auth_shell_hash
102
+ return nil unless oauth?
103
+
104
+ { "oauth_paths" => oauth_paths, "scheme" => auth_scheme }
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ class Config
5
+ # The plumbing every other section reads through: the raw `app:` and
6
+ # `appearance:` hashes, the per-platform overrides that sit on top of them,
7
+ # and the two value coercions shared across sections. Included first, so
8
+ # everything below it can call these.
9
+ module Data
10
+ private
11
+
12
+ def app = @data.fetch("app", nil) || {}
13
+ def appearance = @data.fetch("appearance", nil) || {}
14
+
15
+ # YAML's `yes`/`on` already parse as true, so anything that reaches here
16
+ # still not a boolean is a genuine mistake rather than a spelling of one.
17
+ # Deliberately not an endless def: `def f(v) = v if cond` parses as
18
+ # `(def f(v) = v) if cond`, which would define nothing when cond is false.
19
+ def boolean_or_nil(value)
20
+ value if value == true || value == false
21
+ end
22
+
23
+ def android_target?(target) = self.class.os_of(target) == "android"
24
+
25
+ # Per-platform overrides, keyed by os (macos / ios / android / windows /
26
+ # linux). Each value overrides the matching `app:` keys for that platform.
27
+ def platforms = @data.fetch("platforms", nil) || {}
28
+
29
+ # The app-level keys with the target's platform override merged on top.
30
+ # Blank override values are dropped (`.compact`) so they don't erase a
31
+ # default. Without a target — or without an override for that os — this is
32
+ # just the plain `app:` hash.
33
+ def resolved(target)
34
+ override = target && platforms[self.class.os_of(target)]
35
+ override.is_a?(Hash) ? app.merge(override.compact) : app
36
+ end
37
+
38
+ def normalize_color(value)
39
+ case value
40
+ when String
41
+ { "light" => value, "dark" => value }
42
+ when Hash
43
+ light = value["light"] || value["dark"]
44
+ dark = value["dark"] || value["light"]
45
+ { "light" => light, "dark" => dark } if light
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ class Config
5
+ module DeepLinking
6
+ # Deep linking / universal links. Declares the app's association with its
7
+ # web domains so the OS can hand matching URLs to the app instead of the
8
+ # browser. The gem serves the two well-known association files this needs —
9
+ # /.well-known/apple-app-site-association and /.well-known/assetlinks.json —
10
+ # generated from this config (Everywhere::Engine in Rails; MobileConfigEndpoint
11
+ # for Sinatra/Hanami). The iOS builder stamps the matching Associated Domains
12
+ # entitlement, and the shell routes an incoming link to its path.
13
+ #
14
+ # deep_linking:
15
+ # team_id: ABCDE12345 # derives the iOS app id from bundle_id
16
+ # # or set app ids explicitly:
17
+ # # apple_app_id: ABCDE12345.com.example.app
18
+ # # apple_app_ids: ["ABCDE12345.com.example.app"]
19
+ # paths: ["/*"] # which paths open the app (default: all)
20
+ # domains: ["www.example.com"] # extra applinks: domains (remote host is implicit)
21
+ # android:
22
+ # package: com.example.app
23
+ # sha256_cert_fingerprints: ["AB:CD:EF:..."]
24
+ def deep_linking = @data.fetch("deep_linking", nil) || {}
25
+
26
+ # The iOS/tvOS/... app identifiers (TeamID.bundleID) the association file
27
+ # advertises. Explicit ids win; otherwise team_id + the app's iOS bundle id.
28
+ def deep_linking_apple_app_ids
29
+ explicit = Array(deep_linking["apple_app_ids"]) + [deep_linking["apple_app_id"]].compact
30
+ return explicit.map(&:to_s).uniq unless explicit.empty?
31
+
32
+ team = deep_linking["team_id"].to_s.strip
33
+ team.empty? ? [] : ["#{team}.#{bundle_id(target: "ios")}"]
34
+ end
35
+
36
+ # URL path patterns that open the app. Modern (iOS 14+) component form;
37
+ # defaults to every path.
38
+ def deep_linking_paths
39
+ paths = Array(deep_linking["paths"]).map(&:to_s).reject(&:empty?)
40
+ paths.empty? ? ["*"] : paths
41
+ end
42
+
43
+ def deep_linking_android = deep_linking["android"].is_a?(Hash) ? deep_linking["android"] : {}
44
+ def deep_linking_android_package = deep_linking_android["package"]&.to_s
45
+
46
+ def deep_linking_android_fingerprints
47
+ Array(deep_linking_android["sha256_cert_fingerprints"]).map { |f| f.to_s.strip }.reject(&:empty?)
48
+ end
49
+
50
+ def deep_linking_android? = !deep_linking_android_package.to_s.empty? && !deep_linking_android_fingerprints.empty?
51
+
52
+ def deep_linking_apple? = !deep_linking_apple_app_ids.empty?
53
+
54
+ def deep_linking? = deep_linking_apple? || deep_linking_android?
55
+
56
+ # Domains for the iOS Associated Domains entitlement (applinks:<host>) and
57
+ # the shell's universal-link host allowlist: the remote host plus any extra
58
+ # `domains:`. Bare hostnames (no scheme, no path).
59
+ def deep_linking_domains
60
+ hosts = []
61
+ if remote_url
62
+ require "uri"
63
+ hosts << URI(remote_url).host
64
+ end
65
+ hosts += Array(deep_linking["domains"]).map { |d| d.to_s.sub(%r{\Ahttps?://}, "").sub(%r{/.*\z}, "") }
66
+ hosts.compact.reject(&:empty?).uniq
67
+ rescue URI::InvalidURIError
68
+ Array(deep_linking["domains"]).map(&:to_s).reject(&:empty?).uniq
69
+ end
70
+
71
+ # The apple-app-site-association document (a Hash), or nil when no Apple app
72
+ # ids are configured. `applinks` for universal links; `webcredentials` so
73
+ # associated-domains password autofill / Face ID credentials work too.
74
+ def apple_app_site_association
75
+ ids = deep_linking_apple_app_ids
76
+ return nil if ids.empty?
77
+
78
+ { "applinks" => {
79
+ "details" => [{ "appIDs" => ids, "components" => deep_linking_paths.map { |p| { "/" => p } } }]
80
+ },
81
+ "webcredentials" => { "apps" => ids } }
82
+ end
83
+
84
+ def apple_app_site_association_json
85
+ require "json"
86
+ doc = apple_app_site_association or return nil
87
+ JSON.generate(doc)
88
+ end
89
+
90
+ # The Digital Asset Links document (an Array), or nil without an Android app.
91
+ def asset_links
92
+ return nil unless deep_linking_android?
93
+
94
+ [{ "relation" => ["delegate_permission/common.handle_all_urls"],
95
+ "target" => { "namespace" => "android_app",
96
+ "package_name" => deep_linking_android_package,
97
+ "sha256_cert_fingerprints" => deep_linking_android_fingerprints } }]
98
+ end
99
+
100
+ def asset_links_json
101
+ require "json"
102
+ doc = asset_links or return nil
103
+ JSON.generate(doc)
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ class Config
5
+ module DesktopUi
6
+ # Custom native menu items (macOS app menu). Each entry: label + path
7
+ # (+ optional accelerator), or "separator". Clicks arrive in the page as
8
+ # "everywhere:menu" events and navigate via Turbo.
9
+ def menu
10
+ entries = @data["menu"]
11
+ return [] unless entries.is_a?(Array)
12
+
13
+ entries.filter_map do |item|
14
+ if item == "separator" || (item.is_a?(Hash) && item["separator"])
15
+ { "separator" => true }
16
+ elsif item.is_a?(Hash) && item["label"] && item["path"]
17
+ { "label" => item["label"], "path" => item["path"],
18
+ "accelerator" => item["accelerator"] }.compact
19
+ end
20
+ end
21
+ end
22
+
23
+ # System tray: same entry shape as `menu:` plus `action: quit | show`.
24
+ #
25
+ # tray:
26
+ # - label: "Open My App"
27
+ # action: show
28
+ # - label: "New Note"
29
+ # path: /notes/new
30
+ # - separator
31
+ # - label: Quit
32
+ # action: quit
33
+ def tray
34
+ entries = @data["tray"]
35
+ return [] unless entries.is_a?(Array)
36
+
37
+ entries.filter_map do |item|
38
+ if item == "separator" || (item.is_a?(Hash) && item["separator"])
39
+ { "separator" => true }
40
+ elsif item.is_a?(Hash) && item["label"] && (item["path"] || item["action"])
41
+ { "label" => item["label"], "path" => item["path"],
42
+ "action" => item["action"] }.compact
43
+ end
44
+ end
45
+ end
46
+
47
+ # Desktop window chrome. Desktop-only: on mobile the OS owns the frame, so
48
+ # mobile builds ignore this section entirely.
49
+ #
50
+ # window:
51
+ # title_bar: overlay # decorated (default) | overlay | frameless
52
+ # title: false # draw the title text (default true)
53
+ # size: [1100, 750] # initial inner size
54
+ # min_size: [600, 400]
55
+ # resizable: true
56
+ # drag_height: 28 # top strip that drags the window; 0 turns it off
57
+ #
58
+ # Every reader returns nil when the key is absent so `to_shell_hash` drops it
59
+ # and the shell keeps its own default — there is exactly one place each
60
+ # default lives, and it's the Rust side.
61
+ #
62
+ # `overlay` is the macOS "traffic lights floating over your content" look
63
+ # (TitleBarStyle::Overlay + hidden title). Windows and Linux have no such
64
+ # style, so the shell falls back to frameless there and the page draws its
65
+ # own controls via Everywhere.window.
66
+ WINDOW_TITLE_BARS = %w[decorated overlay frameless].freeze
67
+
68
+ def window
69
+ section = @data["window"]
70
+ section.is_a?(Hash) ? section : {}
71
+ end
72
+
73
+ def window_title_bar
74
+ value = window["title_bar"].to_s
75
+ value if WINDOW_TITLE_BARS.include?(value)
76
+ end
77
+
78
+ # Tri-state, like native_ios_lazy_load_tabs: an absent key leaves the shell
79
+ # on its default rather than forcing one.
80
+ def window_title = boolean_or_nil(window["title"])
81
+ def window_resizable = boolean_or_nil(window["resizable"])
82
+
83
+ def window_size = window_dimensions("size")
84
+ def window_min_size = window_dimensions("min_size")
85
+
86
+ # How much of the top of the page drags the window, in CSS pixels. Only
87
+ # meaningful for overlay/frameless, where there's no system title bar left
88
+ # to grab. nil leaves the shell's 28px default (the height of the macOS
89
+ # traffic-light band); 0 turns dragging off for an app that wants to place
90
+ # its own drag regions.
91
+ def window_drag_height
92
+ value = window["drag_height"]
93
+ value.to_f if value.is_a?(Numeric) && !value.negative?
94
+ end
95
+
96
+ # The window subset the shell needs. nil (absent from everywhere.json) when
97
+ # the app declares nothing, so the shell's own defaults stand untouched.
98
+ def window_shell_hash
99
+ hash = {
100
+ "title_bar" => window_title_bar,
101
+ "title" => window_title,
102
+ "resizable" => window_resizable,
103
+ "size" => window_size,
104
+ "min_size" => window_min_size,
105
+ "drag_height" => window_drag_height
106
+ }.compact
107
+ hash unless hash.empty?
108
+ end
109
+
110
+ def window_errors
111
+ raw = @data["window"]
112
+ return [] if raw.nil?
113
+ return ["window: must be a mapping"] unless raw.is_a?(Hash)
114
+
115
+ errors = []
116
+ bar = raw["title_bar"]
117
+ if bar && !WINDOW_TITLE_BARS.include?(bar.to_s)
118
+ errors << "window.title_bar #{bar.to_s.inspect} must be one of " \
119
+ "#{WINDOW_TITLE_BARS.join(" / ")}"
120
+ end
121
+
122
+ errors += %w[size min_size].filter_map do |key|
123
+ next if raw[key].nil? || window_dimensions(key)
124
+
125
+ "window.#{key} must be two positive numbers, like [1100, 750]"
126
+ end
127
+
128
+ drag = raw["drag_height"]
129
+ unless drag.nil? || (drag.is_a?(Numeric) && !drag.negative?)
130
+ errors << "window.drag_height must be a number of pixels (0 turns dragging off)"
131
+ end
132
+
133
+ errors + %w[title resizable].filter_map do |key|
134
+ next if raw[key].nil? || boolean_or_nil(raw[key]) == raw[key]
135
+
136
+ "window.#{key} must be true or false"
137
+ end
138
+ end
139
+
140
+ private
141
+
142
+ # A [width, height] pair. Rejects the near-misses (one number, three, a
143
+ # negative) rather than silently building a 0-pixel window.
144
+ def window_dimensions(key)
145
+ value = window[key]
146
+ return unless value.is_a?(Array) && value.length == 2
147
+ return unless value.all? { |n| n.is_a?(Numeric) && n.positive? }
148
+
149
+ value.map(&:to_f)
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ class Config
5
+ module Mobile
6
+ # Mobile tab bar, platform-neutral. Icons are per-platform (ios = SF
7
+ # Symbol, android = Material icon later), with a shared `icon:` fallback:
8
+ #
9
+ # tabs:
10
+ # - name: Builds
11
+ # path: /builds
12
+ # icons:
13
+ # ios: hammer
14
+ # android: build
15
+ #
16
+ # Tabs ship two ways from the same source: baked into the app's bundled
17
+ # path-configuration.json at build time (offline/first-launch), and served
18
+ # live from /everywhere/<platform>_v1.json (MobileConfigEndpoint) so tab
19
+ # changes deploy with the web app — no app-store release.
20
+ def tabs
21
+ entries = @data["tabs"]
22
+ return [] unless entries.is_a?(Array)
23
+
24
+ entries.filter_map do |tab|
25
+ next unless tab.is_a?(Hash) && tab["name"] && tab["path"]
26
+
27
+ path = tab["path"].start_with?("/") ? tab["path"] : "/#{tab["path"]}"
28
+ { "name" => tab["name"], "path" => path,
29
+ "icon" => tab["icon"], "icons" => (tab["icons"] if tab["icons"].is_a?(Hash)) }.compact
30
+ end
31
+ end
32
+
33
+ # Tabs with the icon resolved for one platform: icons.<os>, then the
34
+ # shared icon, then a safe default.
35
+ def tabs_for(os)
36
+ tabs.map do |tab|
37
+ { "title" => tab["name"], "path" => tab["path"],
38
+ "icon" => tab.dig("icons", os.to_s) || tab["icon"] || DEFAULT_TAB_ICONS[os.to_s] }.compact
39
+ end
40
+ end
41
+
42
+ DEFAULT_TAB_ICONS = { "ios" => "circle", "android" => "circle" }.freeze
43
+
44
+ # The Hotwire Native path-configuration rules every RubyEverywhere mobile
45
+ # shell starts from. Single source of truth: the builder bakes this into
46
+ # the app bundle and MobileConfigEndpoint serves it live.
47
+ MOBILE_PATH_RULES = [
48
+ { "patterns" => [".*"],
49
+ "properties" => { "context" => "default", "pull_to_refresh_enabled" => true } },
50
+ { "patterns" => ["/new$", "/edit$"],
51
+ "properties" => { "context" => "modal", "pull_to_refresh_enabled" => false } }
52
+ ].freeze
53
+
54
+ # Native permissions the app declares (top-level `permissions:`). Only
55
+ # declared permissions can be requested at runtime — the shell answers
56
+ # `undeclared` for everything else, so an app that doesn't need a
57
+ # permission can never prompt for it. For camera and location the value is
58
+ # the user-facing usage string iOS shows in the permission prompt (the
59
+ # "why"); it's mandatory there because requesting without one crashes the
60
+ # app. Notifications need no string — declare with `true`.
61
+ #
62
+ # permissions:
63
+ # notifications: true
64
+ # camera: "Scan QR codes to pair devices."
65
+ # location:
66
+ # ios: "Find build agents near you."
67
+ # biometrics: "Unlock your account with Face ID."
68
+ #
69
+ # Returns { name => { "ios" => usage-or-nil, ... } }.
70
+ MOBILE_PERMISSIONS = %w[notifications camera location biometrics].freeze
71
+
72
+ # Info.plist usage-description keys per permission. Presence here makes
73
+ # the usage string mandatory on iOS.
74
+ IOS_USAGE_KEYS = {
75
+ "camera" => "NSCameraUsageDescription",
76
+ "location" => "NSLocationWhenInUseUsageDescription",
77
+ "biometrics" => "NSFaceIDUsageDescription"
78
+ }.freeze
79
+
80
+ # Manifest <uses-permission> names per permission, stamped into
81
+ # src/stamped/AndroidManifest.xml. Deliberately *not* the mirror of
82
+ # IOS_USAGE_KEYS: presence here mandates nothing, because Android has no
83
+ # Info.plist-style contract — a runtime request without a rationale string
84
+ # prompts normally instead of terminating the process, and the rationale is
85
+ # a dialog the app draws itself, not a manifest value. So the map only
86
+ # answers "which manifest entry does this permission need", and every
87
+ # declared name has one.
88
+ #
89
+ # ACCESS_FINE_LOCATION implies ACCESS_COARSE_LOCATION on API 31+ only when
90
+ # both are declared, but the shell asks for precise location, so the fine
91
+ # permission alone is the honest declaration.
92
+ ANDROID_PERMISSIONS = {
93
+ "notifications" => "android.permission.POST_NOTIFICATIONS",
94
+ "camera" => "android.permission.CAMERA",
95
+ "location" => "android.permission.ACCESS_FINE_LOCATION",
96
+ "biometrics" => "android.permission.USE_BIOMETRIC"
97
+ }.freeze
98
+
99
+ def permissions
100
+ entries = @data["permissions"]
101
+ return {} unless entries.is_a?(Hash)
102
+
103
+ entries.each_with_object({}) do |(name, value), acc|
104
+ next if value.nil? || value == false
105
+
106
+ acc[name.to_s] =
107
+ case value
108
+ when String then { "ios" => value }
109
+ when Hash then value.transform_keys(&:to_s).transform_values(&:to_s)
110
+ else {}
111
+ end
112
+ end
113
+ end
114
+
115
+ # The manifest permissions an Android build declares, in declaration order.
116
+ # Only known names map to anything; permission_errors("android") has
117
+ # already failed the build on the rest by the time the builder asks.
118
+ def android_manifest_permissions
119
+ permissions.keys.filter_map { |name| ANDROID_PERMISSIONS[name] }.uniq
120
+ end
121
+
122
+ # Problems with the permissions declaration for one platform, as
123
+ # human-readable strings — unknown names, and camera/location missing the
124
+ # mandatory usage string. Empty means buildable.
125
+ #
126
+ # Only the unknown-name half applies to Android: the usage string exists to
127
+ # satisfy iOS, which kills the app when a prompt has no Info.plist entry.
128
+ # Android just prompts, so requiring the sentence there would fail builds
129
+ # over a value nothing reads.
130
+ def permission_errors(os)
131
+ permissions.flat_map do |name, usage|
132
+ unless MOBILE_PERMISSIONS.include?(name)
133
+ next ["unknown permission #{name.inspect} — supported: #{MOBILE_PERMISSIONS.join(", ")}"]
134
+ end
135
+
136
+ if os == "ios" && IOS_USAGE_KEYS.key?(name) && usage["ios"].to_s.strip.empty?
137
+ next ["#{name} needs a usage string — the sentence iOS shows when asking. " \
138
+ "In config/everywhere.yml:\n permissions:\n #{name}: \"Why the app needs #{name}.\""]
139
+ end
140
+
141
+ []
142
+ end
143
+ end
144
+
145
+ # App-declared Hotwire Native path-configuration rules (everywhere.yml
146
+ # top-level `rules:`), appended after MOBILE_PATH_RULES — later rules win
147
+ # per property, so apps can make routes modal, disable pull-to-refresh,
148
+ # or set any other Hotwire path property without touching native code:
149
+ #
150
+ # rules:
151
+ # - patterns: ["/preferences$"]
152
+ # properties:
153
+ # context: default # not a modal, unlike other /edit routes
154
+ # - patterns: ["/live/"]
155
+ # properties:
156
+ # pull_to_refresh_enabled: false
157
+ # Native screens are selected by a different property on each platform, so
158
+ # `os` decides how a rule's `view_controller:` is read (see
159
+ # #android_screen_properties).
160
+ def mobile_rules(os = nil)
161
+ entries = @data["rules"]
162
+ return [] unless entries.is_a?(Array)
163
+
164
+ entries.filter_map do |rule|
165
+ next unless rule.is_a?(Hash) && rule["patterns"].is_a?(Array) && rule["properties"].is_a?(Hash)
166
+
167
+ properties = rule["properties"]
168
+ properties = android_screen_properties(properties) if android_target?(os)
169
+ { "patterns" => rule["patterns"].map(&:to_s), "properties" => properties }
170
+ end
171
+ end
172
+
173
+ # Hotwire Native iOS picks a native screen with `view_controller: <id>`;
174
+ # Android picks one with `uri: hotwire://fragment/<id>`, matched against the
175
+ # Fragment's own @HotwireDestinationDeepLink annotation. Same rule, same
176
+ # identifier, different property name — so the Android document derives the
177
+ # `uri` rather than making apps write the route twice and keep the two in
178
+ # sync by hand.
179
+ #
180
+ # Derived only for ids declared under native.android.screens: an id that
181
+ # names an iOS-only screen must fall through to the web fragment, because a
182
+ # `uri` pointing at a Fragment this build doesn't contain resolves to
183
+ # nothing and the visit dead-ends. An explicit `uri:` always wins — that's
184
+ # the escape hatch for a screen whose annotation says something else.
185
+ def android_screen_properties(properties)
186
+ id = properties["view_controller"]
187
+ return properties if id.nil? || properties.key?("uri")
188
+ return properties unless native_android_screens.key?(id.to_s)
189
+
190
+ properties.merge("uri" => "hotwire://fragment/#{id}")
191
+ end
192
+
193
+ # The full path-configuration document for one mobile platform — rules plus
194
+ # our settings (tabs live in settings, per Hotwire Native convention).
195
+ # Pass `tabs:` to override the resolved list (the mobile config endpoint
196
+ # passes a per-request-filtered list; build-time stamping omits it and
197
+ # bakes them all).
198
+ def path_configuration_hash(os, tabs: nil)
199
+ settings = {}
200
+ platform_tabs = tabs || tabs_for(os)
201
+ settings["tabs"] = platform_tabs unless platform_tabs.empty?
202
+ { "settings" => settings, "rules" => MOBILE_PATH_RULES + mobile_rules(os) }
203
+ end
204
+
205
+ def path_configuration_json(os, tabs: nil)
206
+ require "json"
207
+ JSON.generate(path_configuration_hash(os, tabs: tabs))
208
+ end
209
+ end
210
+ end
211
+ end