ruby_everywhere 0.3.0 → 0.5.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +339 -0
  3. data/bridge/README.md +70 -1
  4. data/bridge/everywhere/bridge.js +211 -3
  5. data/bridge/everywhere/native.css +61 -0
  6. data/lib/everywhere/android_resources.rb +217 -0
  7. data/lib/everywhere/android_sdk.rb +330 -0
  8. data/lib/everywhere/asset_catalog.rb +185 -0
  9. data/lib/everywhere/auth_handoff.rb +404 -0
  10. data/lib/everywhere/auth_token.rb +118 -0
  11. data/lib/everywhere/builders/android.rb +844 -0
  12. data/lib/everywhere/builders/desktop.rb +326 -0
  13. data/lib/everywhere/builders/ios.rb +40 -3
  14. data/lib/everywhere/child_processes.rb +74 -0
  15. data/lib/everywhere/commands/build.rb +95 -14
  16. data/lib/everywhere/commands/clean.rb +10 -5
  17. data/lib/everywhere/commands/dev.rb +462 -57
  18. data/lib/everywhere/commands/doctor.rb +89 -3
  19. data/lib/everywhere/commands/install.rb +21 -1
  20. data/lib/everywhere/commands/logs.rb +117 -8
  21. data/lib/everywhere/commands/release.rb +2 -2
  22. data/lib/everywhere/config.rb +703 -13
  23. data/lib/everywhere/console.rb +117 -0
  24. data/lib/everywhere/desktop_assets.rb +150 -0
  25. data/lib/everywhere/dock/footer.rb +150 -0
  26. data/lib/everywhere/dock/screen.rb +114 -0
  27. data/lib/everywhere/dock/state.rb +59 -0
  28. data/lib/everywhere/dock.rb +238 -0
  29. data/lib/everywhere/emulator.rb +405 -0
  30. data/lib/everywhere/engine.rb +12 -0
  31. data/lib/everywhere/fatal.rb +20 -0
  32. data/lib/everywhere/icon.rb +196 -0
  33. data/lib/everywhere/line_pump.rb +89 -0
  34. data/lib/everywhere/log_filter.rb +111 -0
  35. data/lib/everywhere/native_helper.rb +39 -9
  36. data/lib/everywhere/omniauth.rb +88 -0
  37. data/lib/everywhere/paths.rb +109 -11
  38. data/lib/everywhere/relay.rb +77 -0
  39. data/lib/everywhere/shellout.rb +90 -15
  40. data/lib/everywhere/task_pool.rb +123 -0
  41. data/lib/everywhere/ui.rb +54 -11
  42. data/lib/everywhere/version.rb +1 -1
  43. data/support/desktop/README.md +121 -0
  44. data/support/{shell → desktop}/src-tauri/Cargo.lock +23 -0
  45. data/support/{shell → desktop}/src-tauri/Cargo.toml +19 -1
  46. data/support/{shell → desktop}/src-tauri/capabilities/default.json +7 -0
  47. data/support/desktop/src-tauri/gen/schemas/capabilities.json +1 -0
  48. data/support/desktop/src-tauri/src/extension_host.rs +53 -0
  49. data/support/desktop/src-tauri/src/extensions/mod.rs +39 -0
  50. data/support/{shell → desktop}/src-tauri/src/main.rs +355 -10
  51. data/support/{shell → desktop}/src-tauri/tauri.conf.json +2 -2
  52. data/support/mobile/android/README.md +217 -0
  53. data/support/mobile/android/app/build.gradle.kts +156 -0
  54. data/support/mobile/android/app/everywhere.properties +8 -0
  55. data/support/mobile/android/app/native-packages.gradle.kts +16 -0
  56. data/support/mobile/android/app/proguard-rules.pro +21 -0
  57. data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
  58. data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
  59. data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
  60. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
  61. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
  62. data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
  63. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
  64. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
  65. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
  66. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
  67. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
  68. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
  69. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
  70. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
  71. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
  72. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
  73. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
  74. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
  75. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
  76. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
  77. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
  78. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
  79. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
  80. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
  81. data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
  82. data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
  83. data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
  84. data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
  85. data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
  86. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
  87. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
  88. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
  89. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
  90. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
  91. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  92. data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
  93. data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
  94. data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
  95. data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
  96. data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
  97. data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
  98. data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
  99. data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
  100. data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
  101. data/support/mobile/android/build.gradle.kts +8 -0
  102. data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  103. data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
  104. data/support/mobile/android/gradle.properties +14 -0
  105. data/support/mobile/android/gradlew +251 -0
  106. data/support/mobile/android/gradlew.bat +94 -0
  107. data/support/mobile/android/settings.gradle.kts +28 -0
  108. data/support/mobile/ios/App/AppDelegate.swift +5 -0
  109. data/support/mobile/ios/App/AuthFlow.swift +101 -0
  110. data/support/mobile/ios/App/EverywhereConfig.swift +84 -0
  111. data/support/mobile/ios/App/SceneDelegate.swift +103 -0
  112. data/support/mobile/ios/App.xcodeproj/project.pbxproj +4 -0
  113. data/support/mobile/ios/README.md +4 -1
  114. data/support/{macos → release/macos}/notarize.sh +2 -2
  115. metadata +97 -17
  116. data/support/github/build.yml +0 -85
  117. data/support/shell/src-tauri/gen/schemas/capabilities.json +0 -1
  118. /data/support/{shell → desktop}/splash/index.html +0 -0
  119. /data/support/{shell → desktop}/src-tauri/build.rs +0 -0
  120. /data/support/{shell → desktop}/src-tauri/gen/schemas/acl-manifests.json +0 -0
  121. /data/support/{shell → desktop}/src-tauri/gen/schemas/desktop-schema.json +0 -0
  122. /data/support/{shell → desktop}/src-tauri/gen/schemas/macOS-schema.json +0 -0
  123. /data/support/{shell → desktop}/src-tauri/icons/icon.png +0 -0
  124. /data/support/{shell → desktop}/src-tauri/src/updater.rs +0 -0
  125. /data/support/{macos → release/macos}/entitlements.plist +0 -0
@@ -38,23 +38,99 @@ module Everywhere
38
38
  # to the shell's control channel directly (no bridge/importmap dependency);
39
39
  # in a plain browser it just forwards to the target. `to` is constrained to
40
40
  # a same-origin path.
41
+ #
42
+ # Both shells are addressed inline, and differently, for the same reason
43
+ # the bridge normalizes them: WKWebView's message handler takes an object,
44
+ # while Android's WebMessageListener channel takes a string. Reaching for
45
+ # the bridge here instead would trade that one line for an importmap
46
+ # dependency on a page whose whole job is to work before anything loads.
41
47
  def mobile_reset_html(to)
42
48
  require "json"
43
49
  target = to.to_s
44
50
  target = "/" unless target.start_with?("/") && !target.start_with?("//")
45
- encoded = target.to_json
51
+ # script_safe, not to_json: this lands inside <script>, and outside Rails
52
+ # nothing escapes "</script>" in a plain JSON string.
53
+ encoded = JSON.generate(target, script_safe: true)
46
54
 
47
55
  <<~HTML
48
56
  <!DOCTYPE html><html><head><meta charset="utf-8"><title>One moment…</title>
49
57
  <meta name="viewport" content="width=device-width,initial-scale=1"></head>
50
58
  <body><script>
51
- (function(){var to=#{encoded};
52
- var ch=window.webkit&&window.webkit.messageHandlers&&window.webkit.messageHandlers.everywhereControl;
53
- if(ch){ch.postMessage({action:"reset",to:to});}else{window.location.replace(to);}})();
59
+ (function(){var to=#{encoded};var msg={action:"reset",to:to};
60
+ var ios=window.webkit&&window.webkit.messageHandlers&&window.webkit.messageHandlers.everywhereControl;
61
+ var android=window.everywhereControl;
62
+ if(ios){ios.postMessage(msg);}
63
+ else if(android&&android.postMessage){android.postMessage(JSON.stringify(msg));}
64
+ else{window.location.replace(to);}})();
54
65
  </script></body></html>
55
66
  HTML
56
67
  end
57
68
 
69
+ # The page served at /everywhere/auth/native. The shell normally diverts a
70
+ # provider path natively, before the request is ever made; this covers the
71
+ # visits it can't see — a `data-turbo="false"` link, or the POST OmniAuth 2
72
+ # requires — by asking the shell, from the page, to open the auth session.
73
+ # In a browser it just continues to the provider.
74
+ def mobile_auth_html(to)
75
+ require "json"
76
+ target = to.to_s
77
+ target = "/" unless target.start_with?("/") && !target.start_with?("//")
78
+ # script_safe, as in mobile_reset_html: `to` is request input.
79
+ encoded = JSON.generate(target, script_safe: true)
80
+
81
+ <<~HTML
82
+ <!DOCTYPE html><html><head><meta charset="utf-8"><title>Signing in…</title>
83
+ <meta name="viewport" content="width=device-width,initial-scale=1"></head>
84
+ <body><p>Opening secure sign-in…</p><script>
85
+ (function(){var to=#{encoded};var msg={action:"authFlow",to:to};
86
+ var ios=window.webkit&&window.webkit.messageHandlers&&window.webkit.messageHandlers.everywhereControl;
87
+ var android=window.everywhereControl;
88
+ if(ios){ios.postMessage(msg);}
89
+ else if(android&&android.postMessage){android.postMessage(JSON.stringify(msg));}
90
+ else{window.location.replace(to);}})();
91
+ </script></body></html>
92
+ HTML
93
+ end
94
+
95
+ # The desktop shell's marker. WKWebView APPENDS
96
+ # `applicationNameForUserAgent` to the real system UA, so this arrives
97
+ # alongside a normal Safari UA rather than replacing it — hence a pattern
98
+ # rather than a prefix check. The version is absent when everywhere.yml
99
+ # declares none.
100
+ #
101
+ # macOS only for now: the marker is set in macos_webview_configuration, and
102
+ # Windows/Linux have no append-only equivalent (their user_agent() REPLACES
103
+ # the string, and fabricating a whole UA is worse than not marking it).
104
+ # Desktop only ships macOS today; revisit when it doesn't.
105
+ DESKTOP_UA = %r{\bRubyEverywhere(?:/[\w.\-]+)? \((?:macos|windows|linux)\)}
106
+
107
+ MOBILE_PLATFORMS = %i[ios android].freeze
108
+
109
+ # :ios, :android, :desktop or nil, from a User-Agent string. Present from
110
+ # the very first request, before any JS has run. Shared by the view helpers
111
+ # and the auth middleware.
112
+ #
113
+ # Mobile is checked FIRST and the order matters: the mobile shells prepend
114
+ # the same "RubyEverywhere/<version> (<os>)" marker to Hotwire Native's own,
115
+ # so a mobile UA matches DESKTOP_UA too.
116
+ def native_platform_of(user_agent)
117
+ ua = user_agent.to_s
118
+ return :ios if ua.include?("Hotwire Native iOS")
119
+ return :android if ua.include?("Hotwire Native Android")
120
+ return :desktop if ua.match?(DESKTOP_UA)
121
+
122
+ nil
123
+ end
124
+
125
+ # Just the phone shells. Anything that leans on a mobile-only affordance —
126
+ # the OAuth handoff to ASWebAuthenticationSession / Custom Tabs, biometric
127
+ # gating — has to ask for this rather than "is this a native shell", because
128
+ # the desktop shell answers yes to that and has neither.
129
+ def mobile_platform_of(user_agent)
130
+ platform = native_platform_of(user_agent)
131
+ platform if MOBILE_PLATFORMS.include?(platform)
132
+ end
133
+
58
134
  # Test/reset hook.
59
135
  attr_writer :tabs_filter
60
136
  end
@@ -139,16 +215,19 @@ module Everywhere
139
215
  end
140
216
 
141
217
  # The `build:` section — durable build knobs that were once `every build`
142
- # flags (ruby, targets, permissions). CLI flags still override per-run.
218
+ # flags (ruby, targets, capabilities). CLI flags still override per-run.
143
219
  # See platform/docs/build-engine.md §2.
144
220
  def build = @data.fetch("build", nil) || {}
145
221
 
146
222
  # Ruby version to package. nil here means "let the CLI decide its default".
147
223
  def build_ruby = build["ruby"]
148
224
 
149
- # OS-integration permissions the app declares. Recorded in the receipt and
150
- # (later) used to generate the shell's capabilities.
151
- def permissions = Array(build["permissions"])
225
+ # Desktop OS-integration capabilities the app declares (`build.capabilities`),
226
+ # as a plain list. Deliberately not called permissions: the top-level
227
+ # `permissions:` below is the mobile prompt declaration — a different shape
228
+ # for a different consumer — and while both were spelled "permissions" this
229
+ # accessor was silently redefined by that one and could never be read.
230
+ def build_capabilities = Array(build["capabilities"])
152
231
 
153
232
  # Build targets (os-arch strings) — the CI matrix, expressed once.
154
233
  def targets = Array(build["targets"])
@@ -293,6 +372,25 @@ module Everywhere
293
372
  "biometrics" => "NSFaceIDUsageDescription"
294
373
  }.freeze
295
374
 
375
+ # Manifest <uses-permission> names per permission, stamped into
376
+ # src/stamped/AndroidManifest.xml. Deliberately *not* the mirror of
377
+ # IOS_USAGE_KEYS: presence here mandates nothing, because Android has no
378
+ # Info.plist-style contract — a runtime request without a rationale string
379
+ # prompts normally instead of terminating the process, and the rationale is
380
+ # a dialog the app draws itself, not a manifest value. So the map only
381
+ # answers "which manifest entry does this permission need", and every
382
+ # declared name has one.
383
+ #
384
+ # ACCESS_FINE_LOCATION implies ACCESS_COARSE_LOCATION on API 31+ only when
385
+ # both are declared, but the shell asks for precise location, so the fine
386
+ # permission alone is the honest declaration.
387
+ ANDROID_PERMISSIONS = {
388
+ "notifications" => "android.permission.POST_NOTIFICATIONS",
389
+ "camera" => "android.permission.CAMERA",
390
+ "location" => "android.permission.ACCESS_FINE_LOCATION",
391
+ "biometrics" => "android.permission.USE_BIOMETRIC"
392
+ }.freeze
393
+
296
394
  def permissions
297
395
  entries = @data["permissions"]
298
396
  return {} unless entries.is_a?(Hash)
@@ -309,9 +407,21 @@ module Everywhere
309
407
  end
310
408
  end
311
409
 
410
+ # The manifest permissions an Android build declares, in declaration order.
411
+ # Only known names map to anything; permission_errors("android") has
412
+ # already failed the build on the rest by the time the builder asks.
413
+ def android_manifest_permissions
414
+ permissions.keys.filter_map { |name| ANDROID_PERMISSIONS[name] }.uniq
415
+ end
416
+
312
417
  # Problems with the permissions declaration for one platform, as
313
418
  # human-readable strings — unknown names, and camera/location missing the
314
419
  # mandatory usage string. Empty means buildable.
420
+ #
421
+ # Only the unknown-name half applies to Android: the usage string exists to
422
+ # satisfy iOS, which kills the app when a prompt has no Info.plist entry.
423
+ # Android just prompts, so requiring the sentence there would fail builds
424
+ # over a value nothing reads.
315
425
  def permission_errors(os)
316
426
  permissions.flat_map do |name, usage|
317
427
  unless MOBILE_PERMISSIONS.include?(name)
@@ -339,17 +449,42 @@ module Everywhere
339
449
  # - patterns: ["/live/"]
340
450
  # properties:
341
451
  # pull_to_refresh_enabled: false
342
- def mobile_rules
452
+ # Native screens are selected by a different property on each platform, so
453
+ # `os` decides how a rule's `view_controller:` is read (see
454
+ # #android_screen_properties).
455
+ def mobile_rules(os = nil)
343
456
  entries = @data["rules"]
344
457
  return [] unless entries.is_a?(Array)
345
458
 
346
459
  entries.filter_map do |rule|
347
460
  next unless rule.is_a?(Hash) && rule["patterns"].is_a?(Array) && rule["properties"].is_a?(Hash)
348
461
 
349
- { "patterns" => rule["patterns"].map(&:to_s), "properties" => rule["properties"] }
462
+ properties = rule["properties"]
463
+ properties = android_screen_properties(properties) if android_target?(os)
464
+ { "patterns" => rule["patterns"].map(&:to_s), "properties" => properties }
350
465
  end
351
466
  end
352
467
 
468
+ # Hotwire Native iOS picks a native screen with `view_controller: <id>`;
469
+ # Android picks one with `uri: hotwire://fragment/<id>`, matched against the
470
+ # Fragment's own @HotwireDestinationDeepLink annotation. Same rule, same
471
+ # identifier, different property name — so the Android document derives the
472
+ # `uri` rather than making apps write the route twice and keep the two in
473
+ # sync by hand.
474
+ #
475
+ # Derived only for ids declared under native.android.screens: an id that
476
+ # names an iOS-only screen must fall through to the web fragment, because a
477
+ # `uri` pointing at a Fragment this build doesn't contain resolves to
478
+ # nothing and the visit dead-ends. An explicit `uri:` always wins — that's
479
+ # the escape hatch for a screen whose annotation says something else.
480
+ def android_screen_properties(properties)
481
+ id = properties["view_controller"]
482
+ return properties if id.nil? || properties.key?("uri")
483
+ return properties unless native_android_screens.key?(id.to_s)
484
+
485
+ properties.merge("uri" => "hotwire://fragment/#{id}")
486
+ end
487
+
353
488
  # The full path-configuration document for one mobile platform — rules plus
354
489
  # our settings (tabs live in settings, per Hotwire Native convention).
355
490
  # Pass `tabs:` to override the resolved list (the mobile config endpoint
@@ -359,7 +494,7 @@ module Everywhere
359
494
  settings = {}
360
495
  platform_tabs = tabs || tabs_for(os)
361
496
  settings["tabs"] = platform_tabs unless platform_tabs.empty?
362
- { "settings" => settings, "rules" => MOBILE_PATH_RULES + mobile_rules }
497
+ { "settings" => settings, "rules" => MOBILE_PATH_RULES + mobile_rules(os) }
363
498
  end
364
499
 
365
500
  def path_configuration_json(os, tabs: nil)
@@ -514,6 +649,383 @@ module Everywhere
514
649
  end
515
650
  end
516
651
 
652
+ # The Android half of "supernative": Kotlin the app repo carries in
653
+ # native/android/ that `every build --android` compiles into the shell,
654
+ # declared the same way iOS declares Swift. Same build-time-only rule —
655
+ # Play forbids downloading executable code — and the same shape, so an app
656
+ # that already knows `native.ios:` knows this section too.
657
+ #
658
+ # native:
659
+ # android:
660
+ # components: [ChartComponent] # BridgeComponent subclasses to register
661
+ # screens: # path rules with view_controller: <id>
662
+ # map: MapFragment # Fragment with an (url) argument
663
+ # splash: LaunchSplash # Fragment/Activity shown while booting
664
+ # splash_min_seconds: 1.0 # how long that splash stays up at minimum
665
+ # lazy_load_tabs: true # defer each tab's first visit until selected
666
+ # icon_font: symbols # which Material icon font resolves icon names
667
+ # packages: # Maven coordinates, not SPM entries
668
+ # - "com.airbnb.android:lottie:6.4.0"
669
+ def native_android
670
+ section = @data.dig("native", "android")
671
+ section.is_a?(Hash) ? section : {}
672
+ end
673
+
674
+ def native_android_components = Array(native_android["components"]).map(&:to_s)
675
+
676
+ def native_android_screens
677
+ entries = native_android["screens"]
678
+ return {} unless entries.is_a?(Hash)
679
+
680
+ entries.to_h { |id, type| [id.to_s, type.to_s] }
681
+ end
682
+
683
+ def native_android_splash = native_android["splash"]&.to_s
684
+
685
+ # Same contract as the iOS splash minimum, and the same clamp: the shell
686
+ # gives up waiting at 8s either way, so a larger number here would only
687
+ # promise something the shell won't honor.
688
+ def native_android_splash_min_seconds
689
+ value = native_android["splash_min_seconds"]
690
+ value.to_f.clamp(0.0, 8.0) if value.is_a?(Numeric)
691
+ end
692
+
693
+ # Tri-state like native_ios_lazy_load_tabs: true defers each non-selected
694
+ # tab's first visit, false loads them all up front, absent leaves the shell
695
+ # on its default. Android's bottom-nav hosts are created eagerly at
696
+ # onCreate, so this decides whether they *visit*, not whether they exist.
697
+ def native_android_lazy_load_tabs
698
+ value = native_android["lazy_load_tabs"]
699
+ value if value == true || value == false
700
+ end
701
+
702
+ # Third-party dependencies for native/android code, as Gradle sees them:
703
+ # plain Maven coordinates. There is no SPM-style url + requirement split —
704
+ # a coordinate already carries group, artifact and version — so this
705
+ # accessor returns strings, one per `implementation(…)` line the builder
706
+ # writes into native-packages.gradle.kts. Assumes the config validated
707
+ # clean (see native_android_package_errors).
708
+ def native_android_packages
709
+ Array(native_android["packages"]).filter_map do |entry|
710
+ next unless entry.is_a?(String)
711
+
712
+ coordinate = entry.strip
713
+ coordinate unless coordinate.empty?
714
+ end
715
+ end
716
+
717
+ def native_android_packages? = !native_android_packages.empty?
718
+
719
+ def native_android? = !(native_android_components.empty? && native_android_screens.empty? &&
720
+ native_android_splash.nil?)
721
+
722
+ # Kotlin's identifier rules match Swift's across everything we generate
723
+ # (no backticked names, no unicode escapes), so one pattern validates both.
724
+ # Aliased rather than reused by name so the Android messages can say
725
+ # "Kotlin" and the two paths can diverge later without touching iOS.
726
+ KOTLIN_TYPE_NAME = SWIFT_TYPE_NAME
727
+
728
+ def native_android_errors
729
+ types = native_android_components + native_android_screens.values + [native_android_splash].compact
730
+ errors = types.reject { |t| t.match?(KOTLIN_TYPE_NAME) }.map do |t|
731
+ "native.android: #{t.inspect} is not a Kotlin type name (letters, digits, _)"
732
+ end
733
+ errors += native_android_screens.keys.reject { |id| id.match?(SCREEN_IDENTIFIER) }.map do |id|
734
+ "native.android.screens: identifier #{id.inspect} must match #{SCREEN_IDENTIFIER.inspect}"
735
+ end
736
+
737
+ raw = native_android["splash_min_seconds"]
738
+ errors << "native.android.splash_min_seconds must be a number (seconds)" if raw && !raw.is_a?(Numeric)
739
+ errors + native_android_icon_font_errors + native_android_package_errors
740
+ end
741
+
742
+ # group:artifact:version — Gradle's shorthand form, and the only one we
743
+ # accept. The charset is narrower than Maven strictly allows on purpose:
744
+ # every coordinate is interpolated into a generated Kotlin DSL file inside
745
+ # a double-quoted literal, so a quote, a backslash, a `$` (Kotlin string
746
+ # templates), a newline or a space would rewrite the build script rather
747
+ # than name a dependency. Version ranges ("[1.0,2.0)") and BOM-style
748
+ # two-part coordinates are rejected for the same reason and because Gradle
749
+ # resolves anything subtler at build time anyway.
750
+ MAVEN_SEGMENT = /[A-Za-z0-9_][A-Za-z0-9_.-]*/
751
+ MAVEN_COORDINATE = /\A#{MAVEN_SEGMENT}:#{MAVEN_SEGMENT}:[A-Za-z0-9_][A-Za-z0-9_.+-]*\z/
752
+
753
+ def native_android_package_errors
754
+ raw = native_android["packages"]
755
+ return [] if raw.nil?
756
+ return ["native.android.packages must be a list"] unless raw.is_a?(Array)
757
+
758
+ raw.each_with_index.filter_map do |entry, i|
759
+ at = "native.android.packages[#{i}]"
760
+ next "#{at} must be a Maven coordinate string like \"com.airbnb.android:lottie:6.4.0\"" unless entry.is_a?(String)
761
+
762
+ coordinate = entry.strip
763
+ next if coordinate.match?(MAVEN_COORDINATE)
764
+
765
+ "#{at} #{coordinate.inspect} is not a Maven coordinate — " \
766
+ "\"group:artifact:version\" (letters, digits, . _ -)"
767
+ end
768
+ end
769
+
770
+ # Which Material icon font `icons.android` names — and the per-platform
771
+ # icon names nav buttons and menu items carry in page HTML — resolve
772
+ # against. Android has no UIImage(systemName:), so the shell draws the
773
+ # icon's codepoint from a font; the choice is which font ships.
774
+ #
775
+ # `bundled` is the whole reason this is a map and not a list: only the
776
+ # classic set (357 KB) rides inside the gem, because `ruby_everywhere` is
777
+ # 229 KB today and Symbols is ~10.6 MB — a 48x install cost paid by every
778
+ # user, including the desktop-only ones. The Symbols variants are fetched
779
+ # once on first Android build and cached under ~/.rubyeverywhere, which is
780
+ # noise next to the Gradle and AGP downloads the same build already makes.
781
+ #
782
+ # `codepoints` is the name → codepoint map Ruby validates every declared
783
+ # icon name against at build time, so a typo fails the build naming the tab
784
+ # it came from instead of rendering a blank icon on device.
785
+ ANDROID_ICON_FONTS = {
786
+ "symbols" => { "file" => "MaterialSymbolsOutlined.ttf", "bundled" => false },
787
+ "symbols-rounded" => { "file" => "MaterialSymbolsRounded.ttf", "bundled" => false },
788
+ "symbols-sharp" => { "file" => "MaterialSymbolsSharp.ttf", "bundled" => false },
789
+ "classic" => { "file" => "MaterialIcons-Regular.ttf", "bundled" => true },
790
+ # No font at all: the app brings its own drawables in native/android/res/.
791
+ "none" => { "file" => nil, "bundled" => true }
792
+ }.freeze
793
+
794
+ DEFAULT_ANDROID_ICON_FONT = "symbols"
795
+
796
+ def native_android_icon_font
797
+ value = native_android["icon_font"].to_s.strip
798
+ value.empty? ? DEFAULT_ANDROID_ICON_FONT : value
799
+ end
800
+
801
+ # The font's asset filename (assets/fonts/<file>), or nil for "none".
802
+ def native_android_icon_font_file = ANDROID_ICON_FONTS.dig(native_android_icon_font, "file")
803
+
804
+ # The sidecar map the builder validates names against, alongside the font.
805
+ def native_android_icon_font_codepoints
806
+ native_android_icon_font_file&.sub(/\.ttf\z/, ".codepoints")
807
+ end
808
+
809
+ # True when the selected font has to be downloaded before the first build
810
+ # can stamp it — the only case that can fail offline, so the builder warns
811
+ # about it (and points at `classic`) rather than dying mid-Gradle.
812
+ def native_android_icon_font_fetched?
813
+ entry = ANDROID_ICON_FONTS[native_android_icon_font]
814
+ !entry.nil? && entry["bundled"] == false
815
+ end
816
+
817
+ def native_android_icons? = !native_android_icon_font_file.nil?
818
+
819
+ def native_android_icon_font_errors
820
+ return [] if ANDROID_ICON_FONTS.key?(native_android_icon_font)
821
+
822
+ ["native.android.icon_font #{native_android_icon_font.inspect} is not a known font — " \
823
+ "one of: #{ANDROID_ICON_FONTS.keys.join(", ")}"]
824
+ end
825
+
826
+ # The desktop half of "supernative": Rust the app repo carries in
827
+ # native/desktop/ that `every build` compiles into the Tauri shell.
828
+ #
829
+ # The shape differs from ios/android because desktop differs: the whole UI
830
+ # is a webview, so there are no native screens or splash views to register.
831
+ # What an app actually wants down here is the machine — a serial port, a USB
832
+ # device, an FFI library, a Tauri plugin — reached from the page.
833
+ #
834
+ # native:
835
+ # desktop:
836
+ # commands: [scan_ports] # fns reachable from the page
837
+ # setup: true # run native/desktop/setup.rs at boot
838
+ # crates:
839
+ # serialport: "4.3"
840
+ # reqwest: { version: "0.12", features: [json] }
841
+ #
842
+ # A command is a plain function, NOT a #[tauri::command] — Tauri's typed
843
+ # commands only arrive through invoke, and invoke is unavailable to our
844
+ # pages (they're served over http; see extension_host.rs in the shell). The
845
+ # signature is uniform:
846
+ #
847
+ # pub fn scan_ports(app: &tauri::AppHandle, payload: serde_json::Value)
848
+ # -> Result<serde_json::Value, String>
849
+ #
850
+ # Declaring anything here has a real cost the mobile sections don't have:
851
+ # the shell stops being one prebuilt crate shared by every app and gets
852
+ # stamped and compiled per app (see Builders::Desktop). Apps that declare
853
+ # nothing keep the fast path.
854
+ def native_desktop
855
+ section = @data.dig("native", "desktop")
856
+ section.is_a?(Hash) ? section : {}
857
+ end
858
+
859
+ def native_desktop_commands = Array(native_desktop["commands"]).map { |name| name.to_s.strip }
860
+
861
+ def native_desktop_setup? = native_desktop["setup"] == true
862
+
863
+ # Cargo dependencies, normalized to { "name" => {...table} } so the
864
+ # generator has one shape to write. The short form (a bare version string)
865
+ # becomes { "version" => "4.3" }.
866
+ def native_desktop_crates
867
+ entries = native_desktop["crates"]
868
+ return {} unless entries.is_a?(Hash)
869
+
870
+ entries.to_h do |name, spec|
871
+ table = spec.is_a?(Hash) ? spec : { "version" => spec.to_s }
872
+ [name.to_s.strip, table]
873
+ end
874
+ end
875
+
876
+ def native_desktop? = !(native_desktop_commands.empty? && native_desktop_crates.empty?) ||
877
+ native_desktop_setup?
878
+
879
+ # Everything here is interpolated into generated Rust and TOML, so names are
880
+ # validated hard — same reasoning as MAVEN_COORDINATE above. A command name
881
+ # becomes a path segment in a generate_handler! list; a crate name and its
882
+ # features become bare TOML keys and quoted strings.
883
+ RUST_FN_NAME = /\A[a-z_][a-z0-9_]*\z/
884
+ CRATE_NAME = /\A[a-zA-Z0-9_][a-zA-Z0-9_-]*\z/
885
+ # Cargo's requirement syntax, loosely: ^1.2, ~1.2, >=1.0, 1.*, 1.2.3-beta.1.
886
+ # Deliberately no spaces and no commas — a multi-requirement string would
887
+ # need quoting rules we don't want to own, and cargo reports anything
888
+ # subtler at build time.
889
+ CRATE_VERSION = /\A[\^~<>=]{0,2}\d+(\.\d+){0,2}(\.\*)?(-[0-9A-Za-z.-]+)?\z/
890
+ CRATE_FEATURE = /\A[A-Za-z0-9_][A-Za-z0-9_+-]*\z/
891
+ # Where a git dependency may point. Same rule the SPM packages use.
892
+ CRATE_GIT_URL = PACKAGE_URL
893
+
894
+ def native_desktop_errors
895
+ raw = @data.dig("native", "desktop")
896
+ return [] if raw.nil?
897
+ return ["native.desktop must be a mapping"] unless raw.is_a?(Hash)
898
+
899
+ errors = []
900
+ commands = raw["commands"]
901
+ if !commands.nil? && !commands.is_a?(Array)
902
+ errors << "native.desktop.commands must be a list of function names"
903
+ else
904
+ errors += native_desktop_commands.reject { |name| name.match?(RUST_FN_NAME) }.map do |name|
905
+ "native.desktop.commands: #{name.inspect} is not a Rust function name — " \
906
+ "snake_case (letters, digits, _), matching the fn in native/desktop/"
907
+ end
908
+ duplicates = native_desktop_commands.tally.select { |_name, count| count > 1 }.keys
909
+ errors += duplicates.map do |name|
910
+ "native.desktop.commands: #{name.inspect} is listed twice"
911
+ end
912
+ end
913
+
914
+ setup = raw["setup"]
915
+ errors << "native.desktop.setup must be true or false" unless setup.nil? || boolean_or_nil(setup) == setup
916
+
917
+ errors + native_desktop_crate_errors
918
+ end
919
+
920
+ def native_desktop_crate_errors
921
+ raw = native_desktop["crates"]
922
+ return [] if raw.nil?
923
+ return ["native.desktop.crates must be a mapping of crate name to version"] unless raw.is_a?(Hash)
924
+
925
+ raw.flat_map do |name, spec|
926
+ at = "native.desktop.crates.#{name}"
927
+ errors = []
928
+ unless name.to_s.strip.match?(CRATE_NAME)
929
+ errors << "#{at}: #{name.to_s.inspect} is not a crate name (letters, digits, _, -)"
930
+ end
931
+ errors + crate_spec_errors(at, spec)
932
+ end
933
+ end
934
+
935
+ # Third-party sign-in (Sign in with Apple / Google / GitHub / anything
936
+ # OmniAuth speaks). Providers refuse to run — or run badly — inside an app's
937
+ # web view, so the mobile shell hands these paths to an
938
+ # ASWebAuthenticationSession instead, and the gem bridges the resulting
939
+ # session back into the app (see AuthHandoff).
940
+ #
941
+ # auth:
942
+ # oauth_paths: # paths that begin a provider flow
943
+ # - ^/auth/ # unanchored regexes, like `rules:`
944
+ # scheme: com.example.app # callback scheme (default: the iOS bundle id)
945
+ # cookies: # which cookies cross back into the app
946
+ # except: ["_ga"] # (default: all of them)
947
+ #
948
+ # The app keeps its own auth: `auth:` declares nothing about providers,
949
+ # only which paths the shell must not open in its web view.
950
+ def auth = @data.fetch("auth", nil) || {}
951
+
952
+ # Paths that begin a third-party auth flow, as regex source strings matched
953
+ # against the request path. Declaring `auth:` at all opts in, defaulting to
954
+ # OmniAuth's `/auth/…` convention.
955
+ DEFAULT_OAUTH_PATHS = ["^/auth/"].freeze
956
+
957
+ def oauth_paths
958
+ return [] unless @data.key?("auth")
959
+
960
+ paths = Array(auth["oauth_paths"]).map { |p| p.to_s.strip }.reject(&:empty?)
961
+ paths.empty? ? DEFAULT_OAUTH_PATHS.dup : paths
962
+ end
963
+
964
+ def oauth? = !oauth_paths.empty?
965
+
966
+ # Whether a request path starts a provider flow. Both halves of the system
967
+ # ask this — the middleware per request, the shell per visit proposal — so
968
+ # the patterns are compiled the same way on both sides (unanchored regex).
969
+ def oauth_path?(path)
970
+ oauth_paths.any? { |pattern| Regexp.new(pattern).match?(path.to_s) }
971
+ rescue RegexpError
972
+ false
973
+ end
974
+
975
+ # The custom URL scheme ASWebAuthenticationSession returns through. Defaults
976
+ # to the iOS bundle id — the convention, and already unique per app. Only
977
+ # the characters RFC 3986 allows in a scheme.
978
+ URL_SCHEME = /\A[a-zA-Z][a-zA-Z0-9+.-]*\z/
979
+
980
+ def auth_scheme
981
+ explicit = auth["scheme"].to_s.strip
982
+ explicit.empty? ? bundle_id(target: "ios") : explicit
983
+ end
984
+
985
+ # Cookie names to carry from the auth browser back into the app's web view,
986
+ # filtered by an optional allow/deny list. Everything the flow set is
987
+ # carried by default: the gem can't know which cookie an app's auth library
988
+ # signs its session with.
989
+ def auth_cookie?(name)
990
+ cookies = auth["cookies"]
991
+ return true unless cookies.is_a?(Hash)
992
+
993
+ only = Array(cookies["only"]).map(&:to_s)
994
+ return only.include?(name.to_s) unless only.empty?
995
+
996
+ !Array(cookies["except"]).map(&:to_s).include?(name.to_s)
997
+ end
998
+
999
+ # How long a minted handoff token stays valid. It travels device-locally
1000
+ # (browser sheet → shell → web view), so seconds are plenty.
1001
+ def auth_token_ttl = [(auth["token_ttl"] || 60).to_i, 5].max
1002
+
1003
+ def auth_errors
1004
+ return [] unless @data.key?("auth")
1005
+
1006
+ errors = oauth_paths.filter_map do |pattern|
1007
+ Regexp.new(pattern)
1008
+ nil
1009
+ rescue RegexpError => e
1010
+ "auth.oauth_paths: #{pattern.inspect} is not a valid pattern (#{e.message})"
1011
+ end
1012
+
1013
+ unless auth_scheme.match?(URL_SCHEME)
1014
+ errors << "auth.scheme #{auth_scheme.inspect} is not a URL scheme (letters, digits, +, -, .)"
1015
+ end
1016
+ errors
1017
+ end
1018
+
1019
+ # The auth subset the shell needs: which visits to divert into the auth
1020
+ # session, and the scheme to bring the answer back through. nil (absent from
1021
+ # everywhere.json) when the app declares no `auth:` — a shell that can't
1022
+ # divert anything is a shell with no new surface.
1023
+ def auth_shell_hash
1024
+ return nil unless oauth?
1025
+
1026
+ { "oauth_paths" => oauth_paths, "scheme" => auth_scheme }
1027
+ end
1028
+
517
1029
  # Custom native menu items (macOS app menu). Each entry: label + path
518
1030
  # (+ optional accelerator), or "separator". Clicks arrive in the page as
519
1031
  # "everywhere:menu" events and navigate via Turbo.
@@ -555,6 +1067,99 @@ module Everywhere
555
1067
  end
556
1068
  end
557
1069
 
1070
+ # Desktop window chrome. Desktop-only: on mobile the OS owns the frame, so
1071
+ # mobile builds ignore this section entirely.
1072
+ #
1073
+ # window:
1074
+ # title_bar: overlay # decorated (default) | overlay | frameless
1075
+ # title: false # draw the title text (default true)
1076
+ # size: [1100, 750] # initial inner size
1077
+ # min_size: [600, 400]
1078
+ # resizable: true
1079
+ # drag_height: 28 # top strip that drags the window; 0 turns it off
1080
+ #
1081
+ # Every reader returns nil when the key is absent so `to_shell_hash` drops it
1082
+ # and the shell keeps its own default — there is exactly one place each
1083
+ # default lives, and it's the Rust side.
1084
+ #
1085
+ # `overlay` is the macOS "traffic lights floating over your content" look
1086
+ # (TitleBarStyle::Overlay + hidden title). Windows and Linux have no such
1087
+ # style, so the shell falls back to frameless there and the page draws its
1088
+ # own controls via Everywhere.window.
1089
+ WINDOW_TITLE_BARS = %w[decorated overlay frameless].freeze
1090
+
1091
+ def window
1092
+ section = @data["window"]
1093
+ section.is_a?(Hash) ? section : {}
1094
+ end
1095
+
1096
+ def window_title_bar
1097
+ value = window["title_bar"].to_s
1098
+ value if WINDOW_TITLE_BARS.include?(value)
1099
+ end
1100
+
1101
+ # Tri-state, like native_ios_lazy_load_tabs: an absent key leaves the shell
1102
+ # on its default rather than forcing one.
1103
+ def window_title = boolean_or_nil(window["title"])
1104
+ def window_resizable = boolean_or_nil(window["resizable"])
1105
+
1106
+ def window_size = window_dimensions("size")
1107
+ def window_min_size = window_dimensions("min_size")
1108
+
1109
+ # How much of the top of the page drags the window, in CSS pixels. Only
1110
+ # meaningful for overlay/frameless, where there's no system title bar left
1111
+ # to grab. nil leaves the shell's 28px default (the height of the macOS
1112
+ # traffic-light band); 0 turns dragging off for an app that wants to place
1113
+ # its own drag regions.
1114
+ def window_drag_height
1115
+ value = window["drag_height"]
1116
+ value.to_f if value.is_a?(Numeric) && !value.negative?
1117
+ end
1118
+
1119
+ # The window subset the shell needs. nil (absent from everywhere.json) when
1120
+ # the app declares nothing, so the shell's own defaults stand untouched.
1121
+ def window_shell_hash
1122
+ hash = {
1123
+ "title_bar" => window_title_bar,
1124
+ "title" => window_title,
1125
+ "resizable" => window_resizable,
1126
+ "size" => window_size,
1127
+ "min_size" => window_min_size,
1128
+ "drag_height" => window_drag_height
1129
+ }.compact
1130
+ hash unless hash.empty?
1131
+ end
1132
+
1133
+ def window_errors
1134
+ raw = @data["window"]
1135
+ return [] if raw.nil?
1136
+ return ["window: must be a mapping"] unless raw.is_a?(Hash)
1137
+
1138
+ errors = []
1139
+ bar = raw["title_bar"]
1140
+ if bar && !WINDOW_TITLE_BARS.include?(bar.to_s)
1141
+ errors << "window.title_bar #{bar.to_s.inspect} must be one of " \
1142
+ "#{WINDOW_TITLE_BARS.join(" / ")}"
1143
+ end
1144
+
1145
+ errors += %w[size min_size].filter_map do |key|
1146
+ next if raw[key].nil? || window_dimensions(key)
1147
+
1148
+ "window.#{key} must be two positive numbers, like [1100, 750]"
1149
+ end
1150
+
1151
+ drag = raw["drag_height"]
1152
+ unless drag.nil? || (drag.is_a?(Numeric) && !drag.negative?)
1153
+ errors << "window.drag_height must be a number of pixels (0 turns dragging off)"
1154
+ end
1155
+
1156
+ errors + %w[title resizable].filter_map do |key|
1157
+ next if raw[key].nil? || boolean_or_nil(raw[key]) == raw[key]
1158
+
1159
+ "window.#{key} must be true or false"
1160
+ end
1161
+ end
1162
+
558
1163
  # Deep linking / universal links. Declares the app's association with its
559
1164
  # web domains so the OS can hand matching URLs to the app instead of the
560
1165
  # browser. The gem serves the two well-known association files this needs —
@@ -655,6 +1260,23 @@ module Everywhere
655
1260
  JSON.generate(doc)
656
1261
  end
657
1262
 
1263
+ # The two `native:` knobs that ship inside everywhere.json rather than
1264
+ # being compiled in, resolved for the platform being stamped. They read the
1265
+ # target's own section: everywhere.json is written once per target, so an
1266
+ # Android build that inherited native.ios.lazy_load_tabs would silently
1267
+ # apply a decision the app made about a different shell — and the two
1268
+ # platforms load tabs differently enough that the answer legitimately
1269
+ # differs. Without a target (the desktop dev loop, and every caller written
1270
+ # before Android existed) the iOS answer stands, which is what those
1271
+ # callers have always gotten.
1272
+ def native_lazy_load_tabs(target: nil)
1273
+ android_target?(target) ? native_android_lazy_load_tabs : native_ios_lazy_load_tabs
1274
+ end
1275
+
1276
+ def native_splash_min_seconds(target: nil)
1277
+ android_target?(target) ? native_android_splash_min_seconds : native_ios_splash_min_seconds
1278
+ end
1279
+
658
1280
  # The subset the shell needs, shipped as JSON (env var in dev,
659
1281
  # Resources/everywhere.json inside a bundled .app). Pass `target:` so the
660
1282
  # packaged config reflects the platform being built (its bundle id / name).
@@ -671,10 +1293,12 @@ module Everywhere
671
1293
  "background_color" => background_color,
672
1294
  "menu" => (menu unless menu.empty?),
673
1295
  "tray" => (tray unless tray.empty?),
674
- "lazy_load_tabs" => native_ios_lazy_load_tabs,
675
- "splash_min_seconds" => native_ios_splash_min_seconds,
1296
+ "window" => window_shell_hash,
1297
+ "lazy_load_tabs" => native_lazy_load_tabs(target: target),
1298
+ "splash_min_seconds" => native_splash_min_seconds(target: target),
676
1299
  "updates" => updates_shell_hash,
677
1300
  "permissions" => (permissions.keys unless permissions.empty?),
1301
+ "auth" => auth_shell_hash,
678
1302
  # Hosts the shell treats as its own for incoming universal links, so a
679
1303
  # tapped link to any associated domain opens in-app rather than Safari.
680
1304
  "universal_link_hosts" => (deep_linking_domains if deep_linking? && !deep_linking_domains.empty?)
@@ -691,6 +1315,72 @@ module Everywhere
691
1315
  def app = @data.fetch("app", nil) || {}
692
1316
  def appearance = @data.fetch("appearance", nil) || {}
693
1317
 
1318
+ # One crate's dependency spec: either a bare version string or a table.
1319
+ def crate_spec_errors(at, spec)
1320
+ return version_errors(at, spec) unless spec.is_a?(Hash)
1321
+
1322
+ errors = []
1323
+ # A crate needs somewhere to come from. version: is the common case; git:
1324
+ # and path: are the two escape hatches cargo itself offers.
1325
+ unless %w[version git path].any? { |key| !spec[key].to_s.strip.empty? }
1326
+ errors << "#{at} needs a version: (or git: / path:)"
1327
+ end
1328
+ errors += version_errors("#{at}.version", spec["version"]) if spec.key?("version")
1329
+
1330
+ git = spec["git"].to_s.strip
1331
+ unless git.empty? || git.match?(CRATE_GIT_URL)
1332
+ errors << "#{at}.git #{git.inspect} must be an https:// or git@ URL"
1333
+ end
1334
+
1335
+ # branch/tag/rev/path have no useful shape to check, but they DO end up
1336
+ # inside quoted TOML strings — so the one thing that matters is that they
1337
+ # can't close the quote or start a new line.
1338
+ errors += %w[branch tag rev path].filter_map do |key|
1339
+ value = spec[key].to_s
1340
+ next if value.empty? || !value.match?(/["\\\n\r]/)
1341
+
1342
+ "#{at}.#{key} can't contain quotes, backslashes or newlines"
1343
+ end
1344
+
1345
+ features = spec["features"]
1346
+ if !features.nil? && !features.is_a?(Array)
1347
+ errors << "#{at}.features must be a list"
1348
+ else
1349
+ errors += Array(features).reject { |f| f.to_s.match?(CRATE_FEATURE) }.map do |feature|
1350
+ "#{at}.features: #{feature.to_s.inspect} is not a feature name (letters, digits, _, +, -)"
1351
+ end
1352
+ end
1353
+ errors
1354
+ end
1355
+
1356
+ def version_errors(at, value)
1357
+ version = value.to_s.strip
1358
+ return [] if version.empty?
1359
+ return [] if version.match?(CRATE_VERSION)
1360
+
1361
+ ["#{at} #{version.inspect} is not a cargo version requirement — like \"1.2\", \"^1.2.3\" or \"~0.4\""]
1362
+ end
1363
+
1364
+ # YAML's `yes`/`on` already parse as true, so anything that reaches here
1365
+ # still not a boolean is a genuine mistake rather than a spelling of one.
1366
+ # Deliberately not an endless def: `def f(v) = v if cond` parses as
1367
+ # `(def f(v) = v) if cond`, which would define nothing when cond is false.
1368
+ def boolean_or_nil(value)
1369
+ value if value == true || value == false
1370
+ end
1371
+
1372
+ # A [width, height] pair. Rejects the near-misses (one number, three, a
1373
+ # negative) rather than silently building a 0-pixel window.
1374
+ def window_dimensions(key)
1375
+ value = window[key]
1376
+ return unless value.is_a?(Array) && value.length == 2
1377
+ return unless value.all? { |n| n.is_a?(Numeric) && n.positive? }
1378
+
1379
+ value.map(&:to_f)
1380
+ end
1381
+
1382
+ def android_target?(target) = self.class.os_of(target) == "android"
1383
+
694
1384
  # Per-platform overrides, keyed by os (macos / ios / android / windows /
695
1385
  # linux). Each value overrides the matching `app:` keys for that platform.
696
1386
  def platforms = @data.fetch("platforms", nil) || {}