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,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
@@ -0,0 +1,337 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ class Config
5
+ module NativeMobile
6
+ # Native code extensions ("supernative"): Swift the app repo carries in
7
+ # native/ios/ that `every build` compiles into the shell, plus this
8
+ # declaration of what to hook up. Build-time only — stores forbid
9
+ # downloading native code, so extensions ship with the app binary.
10
+ #
11
+ # native:
12
+ # ios:
13
+ # components: [ChartComponent] # BridgeComponent subclasses to register
14
+ # screens: # path rules with view_controller: <id>
15
+ # map: MapScreen # SwiftUI View or UIViewController, init(url:)
16
+ # splash: LaunchSplash # SwiftUI View or UIViewController, init()
17
+ # splash_min_seconds: 1.0 # how long the custom splash stays up at minimum
18
+ # lazy_load_tabs: true # defer each tab's first visit until it's selected
19
+ def native_ios = native_section("ios")
20
+
21
+ def native_ios_components = native_components("ios")
22
+
23
+ def native_ios_screens = native_screens("ios")
24
+
25
+ def native_ios_splash = native_splash("ios")
26
+
27
+ # Minimum seconds a custom splash stays on screen (default 1.0 shell-side;
28
+ # a fast server would otherwise dismiss a branded splash in a ~50ms flash).
29
+ # Clamped to the shell's 8s give-up timeout.
30
+ def native_ios_splash_min_seconds = native_splash_seconds("ios")
31
+
32
+ # Whether the native tab bar defers each non-selected tab's initial visit
33
+ # until the tab is first selected (Hotwire Native `lazyLoadTabs`). Tri-state
34
+ # so apps can opt in or opt out explicitly: `true` lazy-loads, `false` loads
35
+ # every tab up front, and an absent key leaves the shell on its default
36
+ # (eager).
37
+ def native_ios_lazy_load_tabs = native_lazy_load("ios")
38
+
39
+ # Third-party Swift packages this app pins for its native/ios code. Each
40
+ # entry is one SPM dependency; `every build` writes them into the shell's
41
+ # local NativeExtensions package and re-exports every product, so native/ios
42
+ # code reaches them with a single `import NativeExtensions`.
43
+ #
44
+ # packages:
45
+ # - url: https://github.com/simibac/ConfettiSwiftUI
46
+ # from: "1.1.0" # or exact: / branch: / revision:
47
+ # products: [ConfettiSwiftUI] # optional; defaults to the repo name
48
+ #
49
+ # Returns normalized entries: { "url", "requirement" => { "kind", "value" },
50
+ # "products" => [...] }. Assumes the config validated clean (see
51
+ # native_ios_package_errors) — the requirement is always present here.
52
+ PACKAGE_REQUIREMENT_KEYS = %w[from exact branch revision].freeze
53
+
54
+ def native_ios_packages
55
+ Array(native_ios["packages"]).filter_map do |entry|
56
+ next unless entry.is_a?(Hash)
57
+
58
+ url = entry["url"].to_s.strip
59
+ kind = PACKAGE_REQUIREMENT_KEYS.find { |k| entry.key?(k) && !entry[k].to_s.strip.empty? }
60
+ products = Array(entry["products"]).map { |p| p.to_s.strip }.reject(&:empty?)
61
+ products = [package_identity(url)] if products.empty?
62
+
63
+ { "url" => url,
64
+ "requirement" => (kind && { "kind" => kind, "value" => entry[kind].to_s.strip }),
65
+ "products" => products }
66
+ end
67
+ end
68
+
69
+ def native_ios_packages? = !native_ios_packages.empty?
70
+
71
+ # SPM's package identity: the URL's last path segment without a .git suffix
72
+ # (github.com/simibac/ConfettiSwiftUI → "ConfettiSwiftUI"). Used as the
73
+ # default product name and as the `package:` key in .product(name:package:).
74
+ def package_identity(url) = File.basename(url.to_s.sub(%r{/+\z}, "")).sub(/\.git\z/, "")
75
+
76
+ def native_ios? = native_declared?("ios")
77
+
78
+ # Everything declared here is interpolated into generated Swift, so names
79
+ # are validated hard: type names must be plain Swift identifiers, screen
80
+ # identifiers must be safe inside a Swift string literal.
81
+ SWIFT_TYPE_NAME = /\A[A-Za-z_][A-Za-z0-9_]*\z/
82
+ SCREEN_IDENTIFIER = /\A[A-Za-z0-9_-]+\z/
83
+
84
+ def native_ios_errors = native_type_errors("ios") + native_ios_package_errors
85
+
86
+ # A URL SPM accepts (https for the common case, git@ for SSH remotes) and a
87
+ # loose semver for from:/exact: — enough to catch typos without reimplementing
88
+ # SPM's resolver, which reports anything subtler at build time.
89
+ PACKAGE_URL = %r{\A(https?://|git@).+}
90
+ PACKAGE_VERSION = /\A\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?\z/
91
+
92
+ def native_ios_package_errors
93
+ raw = native_ios["packages"]
94
+ return [] if raw.nil?
95
+ return ["native.ios.packages must be a list"] unless raw.is_a?(Array)
96
+
97
+ raw.each_with_index.flat_map do |entry, i|
98
+ at = "native.ios.packages[#{i}]"
99
+ next ["#{at} must be a mapping with a url:"] unless entry.is_a?(Hash)
100
+
101
+ errors = []
102
+ url = entry["url"].to_s.strip
103
+ if url.empty?
104
+ errors << "#{at}.url is required"
105
+ elsif !url.match?(PACKAGE_URL)
106
+ errors << "#{at}.url #{url.inspect} must be an https:// or git@ URL"
107
+ end
108
+
109
+ present = PACKAGE_REQUIREMENT_KEYS.select { |k| entry.key?(k) && !entry[k].to_s.strip.empty? }
110
+ if present.empty?
111
+ errors << "#{at} needs one version requirement (#{PACKAGE_REQUIREMENT_KEYS.join(" / ")})"
112
+ elsif present.length > 1
113
+ errors << "#{at} sets conflicting requirements (#{present.join(", ")}) — pick one"
114
+ elsif %w[from exact].include?(present.first) && !entry[present.first].to_s.strip.match?(PACKAGE_VERSION)
115
+ errors << "#{at}.#{present.first} #{entry[present.first].to_s.inspect} must be a version like \"1.2.0\""
116
+ end
117
+
118
+ Array(entry["products"]).each do |product|
119
+ next if product.to_s.match?(SWIFT_TYPE_NAME)
120
+
121
+ errors << "#{at}.products: #{product.to_s.inspect} is not a module name (letters, digits, _)"
122
+ end
123
+ errors
124
+ end
125
+ end
126
+
127
+ # The Android half of "supernative": Kotlin the app repo carries in
128
+ # native/android/ that `every build --android` compiles into the shell,
129
+ # declared the same way iOS declares Swift. Same build-time-only rule —
130
+ # Play forbids downloading executable code — and the same shape, so an app
131
+ # that already knows `native.ios:` knows this section too.
132
+ #
133
+ # native:
134
+ # android:
135
+ # components: [ChartComponent] # BridgeComponent subclasses to register
136
+ # screens: # path rules with view_controller: <id>
137
+ # map: MapFragment # Fragment with an (url) argument
138
+ # splash: LaunchSplash # Fragment/Activity shown while booting
139
+ # splash_min_seconds: 1.0 # how long that splash stays up at minimum
140
+ # lazy_load_tabs: true # defer each tab's first visit until selected
141
+ # icon_font: symbols # which Material icon font resolves icon names
142
+ # packages: # Maven coordinates, not SPM entries
143
+ # - "com.airbnb.android:lottie:6.4.0"
144
+ def native_android = native_section("android")
145
+
146
+ def native_android_components = native_components("android")
147
+
148
+ def native_android_screens = native_screens("android")
149
+
150
+ def native_android_splash = native_splash("android")
151
+
152
+ # Same contract as the iOS splash minimum, and the same clamp: the shell
153
+ # gives up waiting at 8s either way, so a larger number here would only
154
+ # promise something the shell won't honor.
155
+ def native_android_splash_min_seconds = native_splash_seconds("android")
156
+
157
+ # Tri-state like native_ios_lazy_load_tabs: true defers each non-selected
158
+ # tab's first visit, false loads them all up front, absent leaves the shell
159
+ # on its default. Android's bottom-nav hosts are created eagerly at
160
+ # onCreate, so this decides whether they *visit*, not whether they exist.
161
+ def native_android_lazy_load_tabs = native_lazy_load("android")
162
+
163
+ # Third-party dependencies for native/android code, as Gradle sees them:
164
+ # plain Maven coordinates. There is no SPM-style url + requirement split —
165
+ # a coordinate already carries group, artifact and version — so this
166
+ # accessor returns strings, one per `implementation(…)` line the builder
167
+ # writes into native-packages.gradle.kts. Assumes the config validated
168
+ # clean (see native_android_package_errors).
169
+ def native_android_packages
170
+ Array(native_android["packages"]).filter_map do |entry|
171
+ next unless entry.is_a?(String)
172
+
173
+ coordinate = entry.strip
174
+ coordinate unless coordinate.empty?
175
+ end
176
+ end
177
+
178
+ def native_android_packages? = !native_android_packages.empty?
179
+
180
+ def native_android? = native_declared?("android")
181
+
182
+ # Kotlin's identifier rules match Swift's across everything we generate
183
+ # (no backticked names, no unicode escapes), so one pattern validates both.
184
+ # Aliased rather than reused by name so the Android messages can say
185
+ # "Kotlin" and the two paths can diverge later without touching iOS.
186
+ KOTLIN_TYPE_NAME = SWIFT_TYPE_NAME
187
+
188
+ def native_android_errors
189
+ native_type_errors("android") + native_android_icon_font_errors + native_android_package_errors
190
+ end
191
+
192
+ # group:artifact:version — Gradle's shorthand form, and the only one we
193
+ # accept. The charset is narrower than Maven strictly allows on purpose:
194
+ # every coordinate is interpolated into a generated Kotlin DSL file inside
195
+ # a double-quoted literal, so a quote, a backslash, a `$` (Kotlin string
196
+ # templates), a newline or a space would rewrite the build script rather
197
+ # than name a dependency. Version ranges ("[1.0,2.0)") and BOM-style
198
+ # two-part coordinates are rejected for the same reason and because Gradle
199
+ # resolves anything subtler at build time anyway.
200
+ MAVEN_SEGMENT = /[A-Za-z0-9_][A-Za-z0-9_.-]*/
201
+ MAVEN_COORDINATE = /\A#{MAVEN_SEGMENT}:#{MAVEN_SEGMENT}:[A-Za-z0-9_][A-Za-z0-9_.+-]*\z/
202
+
203
+ def native_android_package_errors
204
+ raw = native_android["packages"]
205
+ return [] if raw.nil?
206
+ return ["native.android.packages must be a list"] unless raw.is_a?(Array)
207
+
208
+ raw.each_with_index.filter_map do |entry, i|
209
+ at = "native.android.packages[#{i}]"
210
+ next "#{at} must be a Maven coordinate string like \"com.airbnb.android:lottie:6.4.0\"" unless entry.is_a?(String)
211
+
212
+ coordinate = entry.strip
213
+ next if coordinate.match?(MAVEN_COORDINATE)
214
+
215
+ "#{at} #{coordinate.inspect} is not a Maven coordinate — " \
216
+ "\"group:artifact:version\" (letters, digits, . _ -)"
217
+ end
218
+ end
219
+
220
+ # Which Material icon font `icons.android` names — and the per-platform
221
+ # icon names nav buttons and menu items carry in page HTML — resolve
222
+ # against. Android has no UIImage(systemName:), so the shell draws the
223
+ # icon's codepoint from a font; the choice is which font ships.
224
+ #
225
+ # `bundled` is the whole reason this is a map and not a list: only the
226
+ # classic set (357 KB) rides inside the gem, because `ruby_everywhere` is
227
+ # 229 KB today and Symbols is ~10.6 MB — a 48x install cost paid by every
228
+ # user, including the desktop-only ones. The Symbols variants are fetched
229
+ # once on first Android build and cached under ~/.rubyeverywhere, which is
230
+ # noise next to the Gradle and AGP downloads the same build already makes.
231
+ #
232
+ # `codepoints` is the name → codepoint map Ruby validates every declared
233
+ # icon name against at build time, so a typo fails the build naming the tab
234
+ # it came from instead of rendering a blank icon on device.
235
+ ANDROID_ICON_FONTS = {
236
+ "symbols" => { "file" => "MaterialSymbolsOutlined.ttf", "bundled" => false },
237
+ "symbols-rounded" => { "file" => "MaterialSymbolsRounded.ttf", "bundled" => false },
238
+ "symbols-sharp" => { "file" => "MaterialSymbolsSharp.ttf", "bundled" => false },
239
+ "classic" => { "file" => "MaterialIcons-Regular.ttf", "bundled" => true },
240
+ # No font at all: the app brings its own drawables in native/android/res/.
241
+ "none" => { "file" => nil, "bundled" => true }
242
+ }.freeze
243
+
244
+ DEFAULT_ANDROID_ICON_FONT = "symbols"
245
+
246
+ def native_android_icon_font
247
+ value = native_android["icon_font"].to_s.strip
248
+ value.empty? ? DEFAULT_ANDROID_ICON_FONT : value
249
+ end
250
+
251
+ # The font's asset filename (assets/fonts/<file>), or nil for "none".
252
+ def native_android_icon_font_file = ANDROID_ICON_FONTS.dig(native_android_icon_font, "file")
253
+
254
+ # The sidecar map the builder validates names against, alongside the font.
255
+ def native_android_icon_font_codepoints
256
+ native_android_icon_font_file&.sub(/\.ttf\z/, ".codepoints")
257
+ end
258
+
259
+ # True when the selected font has to be downloaded before the first build
260
+ # can stamp it — the only case that can fail offline, so the builder warns
261
+ # about it (and points at `classic`) rather than dying mid-Gradle.
262
+ def native_android_icon_font_fetched?
263
+ entry = ANDROID_ICON_FONTS[native_android_icon_font]
264
+ !entry.nil? && entry["bundled"] == false
265
+ end
266
+
267
+ def native_android_icons? = !native_android_icon_font_file.nil?
268
+
269
+ def native_android_icon_font_errors
270
+ return [] if ANDROID_ICON_FONTS.key?(native_android_icon_font)
271
+
272
+ ["native.android.icon_font #{native_android_icon_font.inspect} is not a known font — " \
273
+ "one of: #{ANDROID_ICON_FONTS.keys.join(", ")}"]
274
+ end
275
+
276
+ # The language each shell's extensions are written in, named in the
277
+ # messages because that's what the app author is looking at, and the
278
+ # pattern their type names must match — one entry per platform so the two
279
+ # can still diverge without either one's half changing.
280
+ NATIVE_LANGUAGES = { "ios" => "Swift", "android" => "Kotlin" }.freeze
281
+ NATIVE_TYPE_NAMES = { "ios" => SWIFT_TYPE_NAME, "android" => KOTLIN_TYPE_NAME }.freeze
282
+
283
+ private
284
+
285
+ # The two `native:` halves are declared the same way and read the same
286
+ # way, so the readers above are one line each over these. What genuinely
287
+ # differs — SPM packages vs Maven coordinates, the Android icon font —
288
+ # stays spelled out per platform.
289
+ def native_section(os)
290
+ section = @data.dig("native", os)
291
+ section.is_a?(Hash) ? section : {}
292
+ end
293
+
294
+ def native_components(os) = Array(native_section(os)["components"]).map(&:to_s)
295
+
296
+ def native_screens(os)
297
+ entries = native_section(os)["screens"]
298
+ return {} unless entries.is_a?(Hash)
299
+
300
+ entries.to_h { |id, type| [id.to_s, type.to_s] }
301
+ end
302
+
303
+ def native_splash(os) = native_section(os)["splash"]&.to_s
304
+
305
+ def native_splash_seconds(os)
306
+ value = native_section(os)["splash_min_seconds"]
307
+ value.to_f.clamp(0.0, 8.0) if value.is_a?(Numeric)
308
+ end
309
+
310
+ def native_lazy_load(os)
311
+ value = native_section(os)["lazy_load_tabs"]
312
+ value if value == true || value == false
313
+ end
314
+
315
+ def native_declared?(os)
316
+ !(native_components(os).empty? && native_screens(os).empty? && native_splash(os).nil?)
317
+ end
318
+
319
+ # The type/identifier half of one platform's errors — everything both
320
+ # shells validate identically. Each platform's public *_errors appends
321
+ # what only it has (packages, and Android's icon font).
322
+ def native_type_errors(os)
323
+ types = native_components(os) + native_screens(os).values + [native_splash(os)].compact
324
+ errors = types.reject { |t| t.match?(NATIVE_TYPE_NAMES[os]) }.map do |t|
325
+ "native.#{os}: #{t.inspect} is not a #{NATIVE_LANGUAGES[os]} type name (letters, digits, _)"
326
+ end
327
+ errors += native_screens(os).keys.reject { |id| id.match?(SCREEN_IDENTIFIER) }.map do |id|
328
+ "native.#{os}.screens: identifier #{id.inspect} must match #{SCREEN_IDENTIFIER.inspect}"
329
+ end
330
+
331
+ raw = native_section(os)["splash_min_seconds"]
332
+ errors << "native.#{os}.splash_min_seconds must be a number (seconds)" if raw && !raw.is_a?(Numeric)
333
+ errors
334
+ end
335
+ end
336
+ end
337
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ class Config
5
+ module Shell
6
+ # The two `native:` knobs that ship inside everywhere.json rather than
7
+ # being compiled in, resolved for the platform being stamped. They read the
8
+ # target's own section: everywhere.json is written once per target, so an
9
+ # Android build that inherited native.ios.lazy_load_tabs would silently
10
+ # apply a decision the app made about a different shell — and the two
11
+ # platforms load tabs differently enough that the answer legitimately
12
+ # differs. Without a target (the desktop dev loop, and every caller written
13
+ # before Android existed) the iOS answer stands, which is what those
14
+ # callers have always gotten.
15
+ def native_lazy_load_tabs(target: nil)
16
+ android_target?(target) ? native_android_lazy_load_tabs : native_ios_lazy_load_tabs
17
+ end
18
+
19
+ def native_splash_min_seconds(target: nil)
20
+ android_target?(target) ? native_android_splash_min_seconds : native_ios_splash_min_seconds
21
+ end
22
+
23
+ # The subset the shell needs, shipped as JSON (env var in dev,
24
+ # Resources/everywhere.json inside a bundled .app). Pass `target:` so the
25
+ # packaged config reflects the platform being built (its bundle id / name).
26
+ def to_shell_hash(target: nil)
27
+ {
28
+ "name" => name(target: target),
29
+ "bundle_id" => bundle_id(target: target),
30
+ "version" => version(target: target),
31
+ "mode" => mode,
32
+ "remote_url" => remote_url,
33
+ "remote_instances" => (true if remote_instances?),
34
+ "entry_path" => entry_path,
35
+ "tint_color" => tint_color,
36
+ "background_color" => background_color,
37
+ "menu" => (menu unless menu.empty?),
38
+ "tray" => (tray unless tray.empty?),
39
+ "window" => window_shell_hash,
40
+ "lazy_load_tabs" => native_lazy_load_tabs(target: target),
41
+ "splash_min_seconds" => native_splash_min_seconds(target: target),
42
+ "updates" => updates_shell_hash,
43
+ "permissions" => (permissions.keys unless permissions.empty?),
44
+ "auth" => auth_shell_hash,
45
+ # Hosts the shell treats as its own for incoming universal links, so a
46
+ # tapped link to any associated domain opens in-app rather than Safari.
47
+ "universal_link_hosts" => (deep_linking_domains if deep_linking? && !deep_linking_domains.empty?)
48
+ }.compact
49
+ end
50
+
51
+ def to_shell_json(target: nil)
52
+ require "json"
53
+ JSON.generate(to_shell_hash(target: target))
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ class Config
5
+ module Updates
6
+ # The `updates:` section — self-hosted auto-updates. Two halves:
7
+ # a client half (url/channel/public_key/auto/interval) that ships to the
8
+ # shell, and a publish-side `s3:` half that NEVER leaves the dev machine.
9
+ # `channel` here is a *release channel* (stable/beta) — not the receipt's
10
+ # `distribution_channel` (direct vs app stores).
11
+ def updates = @data.fetch("updates", nil) || {}
12
+
13
+ def updates_s3 = updates.fetch("s3", nil) || {}
14
+
15
+ def updates_channel = updates["channel"] || "stable"
16
+
17
+ def updates_url = updates["url"]&.chomp("/")
18
+
19
+ def updates_public_key = updates["public_key"]
20
+
21
+ # off — never check; check — check + notify (default); download — also
22
+ # fetch/verify in the background; install — silent auto-update.
23
+ def updates_auto = updates["auto"] || "check"
24
+
25
+ def updates_interval = [(updates["interval"] || 21_600).to_i, 300].max
26
+
27
+ # A dev machine serving a feed to itself is the one case http is fine.
28
+ UPDATES_LOOPBACK = /\A(localhost|127(\.\d+){3}|::1)\z/
29
+
30
+ # An http updates.url is not a smaller version of a working updater: the
31
+ # shell refuses to fetch it and silently stops checking, so the app ships
32
+ # looking fine and never updates again. Say so at build/publish time.
33
+ def updates_errors
34
+ url = updates_url.to_s
35
+ return [] if url.empty?
36
+
37
+ require "uri"
38
+ uri = URI.parse(url)
39
+ return [] if uri.scheme == "https"
40
+ return [] if uri.scheme == "http" && uri.host.to_s.delete("[]").match?(UPDATES_LOOPBACK)
41
+
42
+ ["updates.url #{url.inspect} must be https — the shell disables the updater otherwise"]
43
+ rescue URI::Error => e
44
+ ["updates.url #{url.inspect} is not a URL (#{e.message})"]
45
+ end
46
+
47
+ # The client subset for the shell; nil (and thus absent from
48
+ # everywhere.json) until both url and public_key are configured — an
49
+ # unsigned update feed is not a thing.
50
+ def updates_shell_hash
51
+ return nil unless updates_url && updates_public_key
52
+
53
+ {
54
+ "url" => updates_url,
55
+ "channel" => updates_channel,
56
+ "public_key" => updates_public_key,
57
+ "auto" => updates_auto,
58
+ "interval" => updates_interval
59
+ }
60
+ end
61
+ end
62
+ end
63
+ end