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.
- checksums.yaml +4 -4
- data/README.md +339 -0
- data/bridge/README.md +70 -1
- data/bridge/everywhere/bridge.js +211 -3
- data/bridge/everywhere/native.css +61 -0
- data/lib/everywhere/android_resources.rb +217 -0
- data/lib/everywhere/android_sdk.rb +330 -0
- data/lib/everywhere/asset_catalog.rb +185 -0
- data/lib/everywhere/auth_handoff.rb +404 -0
- data/lib/everywhere/auth_token.rb +118 -0
- data/lib/everywhere/builders/android.rb +844 -0
- data/lib/everywhere/builders/desktop.rb +326 -0
- data/lib/everywhere/builders/ios.rb +40 -3
- data/lib/everywhere/child_processes.rb +74 -0
- data/lib/everywhere/commands/build.rb +95 -14
- data/lib/everywhere/commands/clean.rb +10 -5
- data/lib/everywhere/commands/dev.rb +462 -57
- data/lib/everywhere/commands/doctor.rb +89 -3
- data/lib/everywhere/commands/install.rb +21 -1
- data/lib/everywhere/commands/logs.rb +117 -8
- data/lib/everywhere/commands/release.rb +2 -2
- data/lib/everywhere/config.rb +703 -13
- data/lib/everywhere/console.rb +117 -0
- data/lib/everywhere/desktop_assets.rb +150 -0
- data/lib/everywhere/dock/footer.rb +150 -0
- data/lib/everywhere/dock/screen.rb +114 -0
- data/lib/everywhere/dock/state.rb +59 -0
- data/lib/everywhere/dock.rb +238 -0
- data/lib/everywhere/emulator.rb +405 -0
- data/lib/everywhere/engine.rb +12 -0
- data/lib/everywhere/fatal.rb +20 -0
- data/lib/everywhere/icon.rb +196 -0
- data/lib/everywhere/line_pump.rb +89 -0
- data/lib/everywhere/log_filter.rb +111 -0
- data/lib/everywhere/native_helper.rb +39 -9
- data/lib/everywhere/omniauth.rb +88 -0
- data/lib/everywhere/paths.rb +109 -11
- data/lib/everywhere/relay.rb +77 -0
- data/lib/everywhere/shellout.rb +90 -15
- data/lib/everywhere/task_pool.rb +123 -0
- data/lib/everywhere/ui.rb +54 -11
- data/lib/everywhere/version.rb +1 -1
- data/support/desktop/README.md +121 -0
- data/support/{shell → desktop}/src-tauri/Cargo.lock +23 -0
- data/support/{shell → desktop}/src-tauri/Cargo.toml +19 -1
- data/support/{shell → desktop}/src-tauri/capabilities/default.json +7 -0
- data/support/desktop/src-tauri/gen/schemas/capabilities.json +1 -0
- data/support/desktop/src-tauri/src/extension_host.rs +53 -0
- data/support/desktop/src-tauri/src/extensions/mod.rs +39 -0
- data/support/{shell → desktop}/src-tauri/src/main.rs +355 -10
- data/support/{shell → desktop}/src-tauri/tauri.conf.json +2 -2
- data/support/mobile/android/README.md +217 -0
- data/support/mobile/android/app/build.gradle.kts +156 -0
- data/support/mobile/android/app/everywhere.properties +8 -0
- data/support/mobile/android/app/native-packages.gradle.kts +16 -0
- data/support/mobile/android/app/proguard-rules.pro +21 -0
- data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
- data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
- data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
- data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
- data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
- data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
- data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
- data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
- data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
- data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
- data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
- data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
- data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
- data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
- data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
- data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
- data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
- data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
- data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
- data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
- data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
- data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
- data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
- data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
- data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
- data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
- data/support/mobile/android/build.gradle.kts +8 -0
- data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
- data/support/mobile/android/gradle.properties +14 -0
- data/support/mobile/android/gradlew +251 -0
- data/support/mobile/android/gradlew.bat +94 -0
- data/support/mobile/android/settings.gradle.kts +28 -0
- data/support/mobile/ios/App/AppDelegate.swift +5 -0
- data/support/mobile/ios/App/AuthFlow.swift +101 -0
- data/support/mobile/ios/App/EverywhereConfig.swift +84 -0
- data/support/mobile/ios/App/SceneDelegate.swift +103 -0
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +4 -0
- data/support/mobile/ios/README.md +4 -1
- data/support/{macos → release/macos}/notarize.sh +2 -2
- metadata +97 -17
- data/support/github/build.yml +0 -85
- data/support/shell/src-tauri/gen/schemas/capabilities.json +0 -1
- /data/support/{shell → desktop}/splash/index.html +0 -0
- /data/support/{shell → desktop}/src-tauri/build.rs +0 -0
- /data/support/{shell → desktop}/src-tauri/gen/schemas/acl-manifests.json +0 -0
- /data/support/{shell → desktop}/src-tauri/gen/schemas/desktop-schema.json +0 -0
- /data/support/{shell → desktop}/src-tauri/gen/schemas/macOS-schema.json +0 -0
- /data/support/{shell → desktop}/src-tauri/icons/icon.png +0 -0
- /data/support/{shell → desktop}/src-tauri/src/updater.rs +0 -0
- /data/support/{macos → release/macos}/entitlements.plist +0 -0
data/lib/everywhere/config.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
53
|
-
|
|
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,
|
|
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
|
|
150
|
-
#
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
675
|
-
"
|
|
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) || {}
|