ruby_everywhere 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/exe/every +2 -2
  3. data/exe/rbe +2 -2
  4. data/lib/everywhere/agents_guide.rb +3 -1
  5. data/lib/everywhere/blake2b.rb +17 -1
  6. data/lib/everywhere/boot.rb +30 -6
  7. data/lib/everywhere/builders/android.rb +152 -64
  8. data/lib/everywhere/builders/base.rb +53 -0
  9. data/lib/everywhere/builders/desktop.rb +32 -40
  10. data/lib/everywhere/builders/ios.rb +268 -36
  11. data/lib/everywhere/builders/native_sources.rb +38 -0
  12. data/lib/everywhere/child_processes.rb +4 -4
  13. data/lib/everywhere/child_supervision.rb +172 -0
  14. data/lib/everywhere/cli.rb +2 -0
  15. data/lib/everywhere/clock.rb +12 -0
  16. data/lib/everywhere/commands/build.rb +132 -66
  17. data/lib/everywhere/commands/clean.rb +8 -3
  18. data/lib/everywhere/commands/dev.rb +91 -242
  19. data/lib/everywhere/commands/doctor.rb +138 -21
  20. data/lib/everywhere/commands/install.rb +49 -8
  21. data/lib/everywhere/commands/platform/auth_status.rb +1 -0
  22. data/lib/everywhere/commands/platform/build.rb +141 -33
  23. data/lib/everywhere/commands/platform/login.rb +20 -6
  24. data/lib/everywhere/commands/platform/logout.rb +1 -0
  25. data/lib/everywhere/commands/platform/runner.rb +170 -25
  26. data/lib/everywhere/commands/preview.rb +286 -0
  27. data/lib/everywhere/commands/publish.rb +22 -2
  28. data/lib/everywhere/commands/release.rb +155 -35
  29. data/lib/everywhere/commands/shell_dir.rb +4 -2
  30. data/lib/everywhere/commands/updates_keygen.rb +25 -1
  31. data/lib/everywhere/config/app.rb +126 -0
  32. data/lib/everywhere/config/auth.rb +108 -0
  33. data/lib/everywhere/config/data.rb +50 -0
  34. data/lib/everywhere/config/deep_linking.rb +107 -0
  35. data/lib/everywhere/config/desktop_ui.rb +153 -0
  36. data/lib/everywhere/config/mobile.rb +211 -0
  37. data/lib/everywhere/config/native_desktop.rb +168 -0
  38. data/lib/everywhere/config/native_mobile.rb +337 -0
  39. data/lib/everywhere/config/shell.rb +57 -0
  40. data/lib/everywhere/config/updates.rb +63 -0
  41. data/lib/everywhere/config.rb +59 -1367
  42. data/lib/everywhere/console.rb +2 -1
  43. data/lib/everywhere/desktop_dev_app.rb +138 -0
  44. data/lib/everywhere/dock/state.rb +3 -1
  45. data/lib/everywhere/engine.rb +24 -0
  46. data/lib/everywhere/entrypoint.rb +24 -0
  47. data/lib/everywhere/error.rb +8 -0
  48. data/lib/everywhere/framework.rb +23 -6
  49. data/lib/everywhere/host.rb +11 -0
  50. data/lib/everywhere/ignore.rb +13 -5
  51. data/lib/everywhere/jump.rb +121 -0
  52. data/lib/everywhere/line_pump.rb +3 -1
  53. data/lib/everywhere/minisign.rb +1 -0
  54. data/lib/everywhere/mobile_config_endpoint.rb +47 -0
  55. data/lib/everywhere/mobile_configs_controller.rb +46 -0
  56. data/lib/everywhere/native_platform.rb +44 -0
  57. data/lib/everywhere/paths.rb +36 -13
  58. data/lib/everywhere/platform/client.rb +46 -7
  59. data/lib/everywhere/platform/credentials.rb +18 -8
  60. data/lib/everywhere/platform/snapshot.rb +12 -0
  61. data/lib/everywhere/plist.rb +17 -0
  62. data/lib/everywhere/png.rb +1 -0
  63. data/lib/everywhere/raw_tty.rb +51 -0
  64. data/lib/everywhere/receipt.rb +55 -10
  65. data/lib/everywhere/s3.rb +1 -0
  66. data/lib/everywhere/shell_pages.rb +109 -0
  67. data/lib/everywhere/shellout.rb +19 -0
  68. data/lib/everywhere/simulator.rb +12 -1
  69. data/lib/everywhere/tab_filter.rb +36 -0
  70. data/lib/everywhere/task_pool.rb +3 -4
  71. data/lib/everywhere/ui.rb +6 -1
  72. data/lib/everywhere/version.rb +6 -4
  73. data/lib/everywhere.rb +1 -2
  74. data/support/mobile/android/app/build.gradle.kts +29 -1
  75. data/support/mobile/ios/App/EverywhereConfig.swift +17 -5
  76. data/support/mobile/ios/App/SceneDelegate.swift +75 -8
  77. data/support/mobile/ios/App.xcodeproj/project.pbxproj +2 -4
  78. data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +0 -1
  79. data/support/mobile/ios/README.md +13 -6
  80. data/support/release/macos/notarize.sh +3 -0
  81. metadata +40 -1
@@ -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
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "native_mobile"
4
+
5
+ module Everywhere
6
+ class Config
7
+ module NativeDesktop
8
+ # The desktop half of "supernative": Rust the app repo carries in
9
+ # native/desktop/ that `every build` compiles into the Tauri shell.
10
+ #
11
+ # The shape differs from ios/android because desktop differs: the whole UI
12
+ # is a webview, so there are no native screens or splash views to register.
13
+ # What an app actually wants down here is the machine — a serial port, a USB
14
+ # device, an FFI library, a Tauri plugin — reached from the page.
15
+ #
16
+ # native:
17
+ # desktop:
18
+ # commands: [scan_ports] # fns reachable from the page
19
+ # setup: true # run native/desktop/setup.rs at boot
20
+ # crates:
21
+ # serialport: "4.3"
22
+ # reqwest: { version: "0.12", features: [json] }
23
+ #
24
+ # A command is a plain function, NOT a #[tauri::command] — Tauri's typed
25
+ # commands only arrive through invoke, and invoke is unavailable to our
26
+ # pages (they're served over http; see extension_host.rs in the shell). The
27
+ # signature is uniform:
28
+ #
29
+ # pub fn scan_ports(app: &tauri::AppHandle, payload: serde_json::Value)
30
+ # -> Result<serde_json::Value, String>
31
+ #
32
+ # Declaring anything here has a real cost the mobile sections don't have:
33
+ # the shell stops being one prebuilt crate shared by every app and gets
34
+ # stamped and compiled per app (see Builders::Desktop). Apps that declare
35
+ # nothing keep the fast path.
36
+ def native_desktop
37
+ section = @data.dig("native", "desktop")
38
+ section.is_a?(Hash) ? section : {}
39
+ end
40
+
41
+ def native_desktop_commands = Array(native_desktop["commands"]).map { |name| name.to_s.strip }
42
+
43
+ def native_desktop_setup? = native_desktop["setup"] == true
44
+
45
+ # Cargo dependencies, normalized to { "name" => {...table} } so the
46
+ # generator has one shape to write. The short form (a bare version string)
47
+ # becomes { "version" => "4.3" }.
48
+ def native_desktop_crates
49
+ entries = native_desktop["crates"]
50
+ return {} unless entries.is_a?(Hash)
51
+
52
+ entries.to_h do |name, spec|
53
+ table = spec.is_a?(Hash) ? spec : { "version" => spec.to_s }
54
+ [name.to_s.strip, table]
55
+ end
56
+ end
57
+
58
+ def native_desktop? = !(native_desktop_commands.empty? && native_desktop_crates.empty?) ||
59
+ native_desktop_setup?
60
+
61
+ # Everything here is interpolated into generated Rust and TOML, so names are
62
+ # validated hard — same reasoning as MAVEN_COORDINATE above. A command name
63
+ # becomes a path segment in a generate_handler! list; a crate name and its
64
+ # features become bare TOML keys and quoted strings.
65
+ RUST_FN_NAME = /\A[a-z_][a-z0-9_]*\z/
66
+ CRATE_NAME = /\A[a-zA-Z0-9_][a-zA-Z0-9_-]*\z/
67
+ # Cargo's requirement syntax, loosely: ^1.2, ~1.2, >=1.0, 1.*, 1.2.3-beta.1.
68
+ # Deliberately no spaces and no commas — a multi-requirement string would
69
+ # need quoting rules we don't want to own, and cargo reports anything
70
+ # subtler at build time.
71
+ CRATE_VERSION = /\A[\^~<>=]{0,2}\d+(\.\d+){0,2}(\.\*)?(-[0-9A-Za-z.-]+)?\z/
72
+ CRATE_FEATURE = /\A[A-Za-z0-9_][A-Za-z0-9_+-]*\z/
73
+ # Where a git dependency may point. Same rule the SPM packages use —
74
+ # named through NativeMobile because a sibling section module's constants
75
+ # aren't in scope here the way they were when both lived in Config.
76
+ CRATE_GIT_URL = NativeMobile::PACKAGE_URL
77
+
78
+ def native_desktop_errors
79
+ raw = @data.dig("native", "desktop")
80
+ return [] if raw.nil?
81
+ return ["native.desktop must be a mapping"] unless raw.is_a?(Hash)
82
+
83
+ errors = []
84
+ commands = raw["commands"]
85
+ if !commands.nil? && !commands.is_a?(Array)
86
+ errors << "native.desktop.commands must be a list of function names"
87
+ else
88
+ errors += native_desktop_commands.reject { |name| name.match?(RUST_FN_NAME) }.map do |name|
89
+ "native.desktop.commands: #{name.inspect} is not a Rust function name — " \
90
+ "snake_case (letters, digits, _), matching the fn in native/desktop/"
91
+ end
92
+ duplicates = native_desktop_commands.tally.select { |_name, count| count > 1 }.keys
93
+ errors += duplicates.map do |name|
94
+ "native.desktop.commands: #{name.inspect} is listed twice"
95
+ end
96
+ end
97
+
98
+ setup = raw["setup"]
99
+ errors << "native.desktop.setup must be true or false" unless setup.nil? || boolean_or_nil(setup) == setup
100
+
101
+ errors + native_desktop_crate_errors
102
+ end
103
+
104
+ def native_desktop_crate_errors
105
+ raw = native_desktop["crates"]
106
+ return [] if raw.nil?
107
+ return ["native.desktop.crates must be a mapping of crate name to version"] unless raw.is_a?(Hash)
108
+
109
+ raw.flat_map do |name, spec|
110
+ at = "native.desktop.crates.#{name}"
111
+ errors = []
112
+ unless name.to_s.strip.match?(CRATE_NAME)
113
+ errors << "#{at}: #{name.to_s.inspect} is not a crate name (letters, digits, _, -)"
114
+ end
115
+ errors + crate_spec_errors(at, spec)
116
+ end
117
+ end
118
+
119
+ private
120
+
121
+ # One crate's dependency spec: either a bare version string or a table.
122
+ def crate_spec_errors(at, spec)
123
+ return version_errors(at, spec) unless spec.is_a?(Hash)
124
+
125
+ errors = []
126
+ # A crate needs somewhere to come from. version: is the common case; git:
127
+ # and path: are the two escape hatches cargo itself offers.
128
+ unless %w[version git path].any? { |key| !spec[key].to_s.strip.empty? }
129
+ errors << "#{at} needs a version: (or git: / path:)"
130
+ end
131
+ errors += version_errors("#{at}.version", spec["version"]) if spec.key?("version")
132
+
133
+ git = spec["git"].to_s.strip
134
+ unless git.empty? || git.match?(CRATE_GIT_URL)
135
+ errors << "#{at}.git #{git.inspect} must be an https:// or git@ URL"
136
+ end
137
+
138
+ # branch/tag/rev/path have no useful shape to check, but they DO end up
139
+ # inside quoted TOML strings — so the one thing that matters is that they
140
+ # can't close the quote or start a new line.
141
+ errors += %w[branch tag rev path].filter_map do |key|
142
+ value = spec[key].to_s
143
+ next if value.empty? || !value.match?(/["\\\n\r]/)
144
+
145
+ "#{at}.#{key} can't contain quotes, backslashes or newlines"
146
+ end
147
+
148
+ features = spec["features"]
149
+ if !features.nil? && !features.is_a?(Array)
150
+ errors << "#{at}.features must be a list"
151
+ else
152
+ errors += Array(features).reject { |f| f.to_s.match?(CRATE_FEATURE) }.map do |feature|
153
+ "#{at}.features: #{feature.to_s.inspect} is not a feature name (letters, digits, _, +, -)"
154
+ end
155
+ end
156
+ errors
157
+ end
158
+
159
+ def version_errors(at, value)
160
+ version = value.to_s.strip
161
+ return [] if version.empty?
162
+ return [] if version.match?(CRATE_VERSION)
163
+
164
+ ["#{at} #{version.inspect} is not a cargo version requirement — like \"1.2\", \"^1.2.3\" or \"~0.4\""]
165
+ end
166
+ end
167
+ end
168
+ end