ruby_everywhere 0.3.0 → 0.4.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 +255 -0
- data/bridge/everywhere/bridge.js +60 -3
- 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 +398 -0
- data/lib/everywhere/auth_token.rb +118 -0
- data/lib/everywhere/builders/android.rb +844 -0
- data/lib/everywhere/builders/ios.rb +40 -3
- data/lib/everywhere/commands/build.rb +51 -9
- data/lib/everywhere/commands/clean.rb +10 -5
- data/lib/everywhere/commands/dev.rb +46 -8
- data/lib/everywhere/commands/doctor.rb +89 -3
- data/lib/everywhere/commands/install.rb +1 -1
- data/lib/everywhere/commands/logs.rb +117 -8
- data/lib/everywhere/config.rb +409 -13
- data/lib/everywhere/emulator.rb +405 -0
- data/lib/everywhere/engine.rb +12 -0
- data/lib/everywhere/icon.rb +196 -0
- data/lib/everywhere/log_filter.rb +74 -0
- data/lib/everywhere/native_helper.rb +2 -5
- data/lib/everywhere/omniauth.rb +88 -0
- data/lib/everywhere/paths.rb +47 -0
- data/lib/everywhere/version.rb +1 -1
- 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
- metadata +67 -1
data/lib/everywhere/config.rb
CHANGED
|
@@ -38,23 +38,72 @@ 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);}})();
|
|
65
|
+
</script></body></html>
|
|
66
|
+
HTML
|
|
67
|
+
end
|
|
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);}})();
|
|
54
91
|
</script></body></html>
|
|
55
92
|
HTML
|
|
56
93
|
end
|
|
57
94
|
|
|
95
|
+
# :ios, :android or nil, from a User-Agent string. The shells prepend
|
|
96
|
+
# "RubyEverywhere/<version> (<os>)" to Hotwire Native's own marker; this
|
|
97
|
+
# reads the marker, which is present from the very first request. Shared by
|
|
98
|
+
# the view helpers and the auth middleware.
|
|
99
|
+
def native_platform_of(user_agent)
|
|
100
|
+
ua = user_agent.to_s
|
|
101
|
+
return :ios if ua.include?("Hotwire Native iOS")
|
|
102
|
+
return :android if ua.include?("Hotwire Native Android")
|
|
103
|
+
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
58
107
|
# Test/reset hook.
|
|
59
108
|
attr_writer :tabs_filter
|
|
60
109
|
end
|
|
@@ -139,16 +188,19 @@ module Everywhere
|
|
|
139
188
|
end
|
|
140
189
|
|
|
141
190
|
# The `build:` section — durable build knobs that were once `every build`
|
|
142
|
-
# flags (ruby, targets,
|
|
191
|
+
# flags (ruby, targets, capabilities). CLI flags still override per-run.
|
|
143
192
|
# See platform/docs/build-engine.md §2.
|
|
144
193
|
def build = @data.fetch("build", nil) || {}
|
|
145
194
|
|
|
146
195
|
# Ruby version to package. nil here means "let the CLI decide its default".
|
|
147
196
|
def build_ruby = build["ruby"]
|
|
148
197
|
|
|
149
|
-
# OS-integration
|
|
150
|
-
#
|
|
151
|
-
|
|
198
|
+
# Desktop OS-integration capabilities the app declares (`build.capabilities`),
|
|
199
|
+
# as a plain list. Deliberately not called permissions: the top-level
|
|
200
|
+
# `permissions:` below is the mobile prompt declaration — a different shape
|
|
201
|
+
# for a different consumer — and while both were spelled "permissions" this
|
|
202
|
+
# accessor was silently redefined by that one and could never be read.
|
|
203
|
+
def build_capabilities = Array(build["capabilities"])
|
|
152
204
|
|
|
153
205
|
# Build targets (os-arch strings) — the CI matrix, expressed once.
|
|
154
206
|
def targets = Array(build["targets"])
|
|
@@ -293,6 +345,25 @@ module Everywhere
|
|
|
293
345
|
"biometrics" => "NSFaceIDUsageDescription"
|
|
294
346
|
}.freeze
|
|
295
347
|
|
|
348
|
+
# Manifest <uses-permission> names per permission, stamped into
|
|
349
|
+
# src/stamped/AndroidManifest.xml. Deliberately *not* the mirror of
|
|
350
|
+
# IOS_USAGE_KEYS: presence here mandates nothing, because Android has no
|
|
351
|
+
# Info.plist-style contract — a runtime request without a rationale string
|
|
352
|
+
# prompts normally instead of terminating the process, and the rationale is
|
|
353
|
+
# a dialog the app draws itself, not a manifest value. So the map only
|
|
354
|
+
# answers "which manifest entry does this permission need", and every
|
|
355
|
+
# declared name has one.
|
|
356
|
+
#
|
|
357
|
+
# ACCESS_FINE_LOCATION implies ACCESS_COARSE_LOCATION on API 31+ only when
|
|
358
|
+
# both are declared, but the shell asks for precise location, so the fine
|
|
359
|
+
# permission alone is the honest declaration.
|
|
360
|
+
ANDROID_PERMISSIONS = {
|
|
361
|
+
"notifications" => "android.permission.POST_NOTIFICATIONS",
|
|
362
|
+
"camera" => "android.permission.CAMERA",
|
|
363
|
+
"location" => "android.permission.ACCESS_FINE_LOCATION",
|
|
364
|
+
"biometrics" => "android.permission.USE_BIOMETRIC"
|
|
365
|
+
}.freeze
|
|
366
|
+
|
|
296
367
|
def permissions
|
|
297
368
|
entries = @data["permissions"]
|
|
298
369
|
return {} unless entries.is_a?(Hash)
|
|
@@ -309,9 +380,21 @@ module Everywhere
|
|
|
309
380
|
end
|
|
310
381
|
end
|
|
311
382
|
|
|
383
|
+
# The manifest permissions an Android build declares, in declaration order.
|
|
384
|
+
# Only known names map to anything; permission_errors("android") has
|
|
385
|
+
# already failed the build on the rest by the time the builder asks.
|
|
386
|
+
def android_manifest_permissions
|
|
387
|
+
permissions.keys.filter_map { |name| ANDROID_PERMISSIONS[name] }.uniq
|
|
388
|
+
end
|
|
389
|
+
|
|
312
390
|
# Problems with the permissions declaration for one platform, as
|
|
313
391
|
# human-readable strings — unknown names, and camera/location missing the
|
|
314
392
|
# mandatory usage string. Empty means buildable.
|
|
393
|
+
#
|
|
394
|
+
# Only the unknown-name half applies to Android: the usage string exists to
|
|
395
|
+
# satisfy iOS, which kills the app when a prompt has no Info.plist entry.
|
|
396
|
+
# Android just prompts, so requiring the sentence there would fail builds
|
|
397
|
+
# over a value nothing reads.
|
|
315
398
|
def permission_errors(os)
|
|
316
399
|
permissions.flat_map do |name, usage|
|
|
317
400
|
unless MOBILE_PERMISSIONS.include?(name)
|
|
@@ -339,17 +422,42 @@ module Everywhere
|
|
|
339
422
|
# - patterns: ["/live/"]
|
|
340
423
|
# properties:
|
|
341
424
|
# pull_to_refresh_enabled: false
|
|
342
|
-
|
|
425
|
+
# Native screens are selected by a different property on each platform, so
|
|
426
|
+
# `os` decides how a rule's `view_controller:` is read (see
|
|
427
|
+
# #android_screen_properties).
|
|
428
|
+
def mobile_rules(os = nil)
|
|
343
429
|
entries = @data["rules"]
|
|
344
430
|
return [] unless entries.is_a?(Array)
|
|
345
431
|
|
|
346
432
|
entries.filter_map do |rule|
|
|
347
433
|
next unless rule.is_a?(Hash) && rule["patterns"].is_a?(Array) && rule["properties"].is_a?(Hash)
|
|
348
434
|
|
|
349
|
-
|
|
435
|
+
properties = rule["properties"]
|
|
436
|
+
properties = android_screen_properties(properties) if android_target?(os)
|
|
437
|
+
{ "patterns" => rule["patterns"].map(&:to_s), "properties" => properties }
|
|
350
438
|
end
|
|
351
439
|
end
|
|
352
440
|
|
|
441
|
+
# Hotwire Native iOS picks a native screen with `view_controller: <id>`;
|
|
442
|
+
# Android picks one with `uri: hotwire://fragment/<id>`, matched against the
|
|
443
|
+
# Fragment's own @HotwireDestinationDeepLink annotation. Same rule, same
|
|
444
|
+
# identifier, different property name — so the Android document derives the
|
|
445
|
+
# `uri` rather than making apps write the route twice and keep the two in
|
|
446
|
+
# sync by hand.
|
|
447
|
+
#
|
|
448
|
+
# Derived only for ids declared under native.android.screens: an id that
|
|
449
|
+
# names an iOS-only screen must fall through to the web fragment, because a
|
|
450
|
+
# `uri` pointing at a Fragment this build doesn't contain resolves to
|
|
451
|
+
# nothing and the visit dead-ends. An explicit `uri:` always wins — that's
|
|
452
|
+
# the escape hatch for a screen whose annotation says something else.
|
|
453
|
+
def android_screen_properties(properties)
|
|
454
|
+
id = properties["view_controller"]
|
|
455
|
+
return properties if id.nil? || properties.key?("uri")
|
|
456
|
+
return properties unless native_android_screens.key?(id.to_s)
|
|
457
|
+
|
|
458
|
+
properties.merge("uri" => "hotwire://fragment/#{id}")
|
|
459
|
+
end
|
|
460
|
+
|
|
353
461
|
# The full path-configuration document for one mobile platform — rules plus
|
|
354
462
|
# our settings (tabs live in settings, per Hotwire Native convention).
|
|
355
463
|
# Pass `tabs:` to override the resolved list (the mobile config endpoint
|
|
@@ -359,7 +467,7 @@ module Everywhere
|
|
|
359
467
|
settings = {}
|
|
360
468
|
platform_tabs = tabs || tabs_for(os)
|
|
361
469
|
settings["tabs"] = platform_tabs unless platform_tabs.empty?
|
|
362
|
-
{ "settings" => settings, "rules" => MOBILE_PATH_RULES + mobile_rules }
|
|
470
|
+
{ "settings" => settings, "rules" => MOBILE_PATH_RULES + mobile_rules(os) }
|
|
363
471
|
end
|
|
364
472
|
|
|
365
473
|
def path_configuration_json(os, tabs: nil)
|
|
@@ -514,6 +622,274 @@ module Everywhere
|
|
|
514
622
|
end
|
|
515
623
|
end
|
|
516
624
|
|
|
625
|
+
# The Android half of "supernative": Kotlin the app repo carries in
|
|
626
|
+
# native/android/ that `every build --android` compiles into the shell,
|
|
627
|
+
# declared the same way iOS declares Swift. Same build-time-only rule —
|
|
628
|
+
# Play forbids downloading executable code — and the same shape, so an app
|
|
629
|
+
# that already knows `native.ios:` knows this section too.
|
|
630
|
+
#
|
|
631
|
+
# native:
|
|
632
|
+
# android:
|
|
633
|
+
# components: [ChartComponent] # BridgeComponent subclasses to register
|
|
634
|
+
# screens: # path rules with view_controller: <id>
|
|
635
|
+
# map: MapFragment # Fragment with an (url) argument
|
|
636
|
+
# splash: LaunchSplash # Fragment/Activity shown while booting
|
|
637
|
+
# splash_min_seconds: 1.0 # how long that splash stays up at minimum
|
|
638
|
+
# lazy_load_tabs: true # defer each tab's first visit until selected
|
|
639
|
+
# icon_font: symbols # which Material icon font resolves icon names
|
|
640
|
+
# packages: # Maven coordinates, not SPM entries
|
|
641
|
+
# - "com.airbnb.android:lottie:6.4.0"
|
|
642
|
+
def native_android
|
|
643
|
+
section = @data.dig("native", "android")
|
|
644
|
+
section.is_a?(Hash) ? section : {}
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def native_android_components = Array(native_android["components"]).map(&:to_s)
|
|
648
|
+
|
|
649
|
+
def native_android_screens
|
|
650
|
+
entries = native_android["screens"]
|
|
651
|
+
return {} unless entries.is_a?(Hash)
|
|
652
|
+
|
|
653
|
+
entries.to_h { |id, type| [id.to_s, type.to_s] }
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
def native_android_splash = native_android["splash"]&.to_s
|
|
657
|
+
|
|
658
|
+
# Same contract as the iOS splash minimum, and the same clamp: the shell
|
|
659
|
+
# gives up waiting at 8s either way, so a larger number here would only
|
|
660
|
+
# promise something the shell won't honor.
|
|
661
|
+
def native_android_splash_min_seconds
|
|
662
|
+
value = native_android["splash_min_seconds"]
|
|
663
|
+
value.to_f.clamp(0.0, 8.0) if value.is_a?(Numeric)
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
# Tri-state like native_ios_lazy_load_tabs: true defers each non-selected
|
|
667
|
+
# tab's first visit, false loads them all up front, absent leaves the shell
|
|
668
|
+
# on its default. Android's bottom-nav hosts are created eagerly at
|
|
669
|
+
# onCreate, so this decides whether they *visit*, not whether they exist.
|
|
670
|
+
def native_android_lazy_load_tabs
|
|
671
|
+
value = native_android["lazy_load_tabs"]
|
|
672
|
+
value if value == true || value == false
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
# Third-party dependencies for native/android code, as Gradle sees them:
|
|
676
|
+
# plain Maven coordinates. There is no SPM-style url + requirement split —
|
|
677
|
+
# a coordinate already carries group, artifact and version — so this
|
|
678
|
+
# accessor returns strings, one per `implementation(…)` line the builder
|
|
679
|
+
# writes into native-packages.gradle.kts. Assumes the config validated
|
|
680
|
+
# clean (see native_android_package_errors).
|
|
681
|
+
def native_android_packages
|
|
682
|
+
Array(native_android["packages"]).filter_map do |entry|
|
|
683
|
+
next unless entry.is_a?(String)
|
|
684
|
+
|
|
685
|
+
coordinate = entry.strip
|
|
686
|
+
coordinate unless coordinate.empty?
|
|
687
|
+
end
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
def native_android_packages? = !native_android_packages.empty?
|
|
691
|
+
|
|
692
|
+
def native_android? = !(native_android_components.empty? && native_android_screens.empty? &&
|
|
693
|
+
native_android_splash.nil?)
|
|
694
|
+
|
|
695
|
+
# Kotlin's identifier rules match Swift's across everything we generate
|
|
696
|
+
# (no backticked names, no unicode escapes), so one pattern validates both.
|
|
697
|
+
# Aliased rather than reused by name so the Android messages can say
|
|
698
|
+
# "Kotlin" and the two paths can diverge later without touching iOS.
|
|
699
|
+
KOTLIN_TYPE_NAME = SWIFT_TYPE_NAME
|
|
700
|
+
|
|
701
|
+
def native_android_errors
|
|
702
|
+
types = native_android_components + native_android_screens.values + [native_android_splash].compact
|
|
703
|
+
errors = types.reject { |t| t.match?(KOTLIN_TYPE_NAME) }.map do |t|
|
|
704
|
+
"native.android: #{t.inspect} is not a Kotlin type name (letters, digits, _)"
|
|
705
|
+
end
|
|
706
|
+
errors += native_android_screens.keys.reject { |id| id.match?(SCREEN_IDENTIFIER) }.map do |id|
|
|
707
|
+
"native.android.screens: identifier #{id.inspect} must match #{SCREEN_IDENTIFIER.inspect}"
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
raw = native_android["splash_min_seconds"]
|
|
711
|
+
errors << "native.android.splash_min_seconds must be a number (seconds)" if raw && !raw.is_a?(Numeric)
|
|
712
|
+
errors + native_android_icon_font_errors + native_android_package_errors
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
# group:artifact:version — Gradle's shorthand form, and the only one we
|
|
716
|
+
# accept. The charset is narrower than Maven strictly allows on purpose:
|
|
717
|
+
# every coordinate is interpolated into a generated Kotlin DSL file inside
|
|
718
|
+
# a double-quoted literal, so a quote, a backslash, a `$` (Kotlin string
|
|
719
|
+
# templates), a newline or a space would rewrite the build script rather
|
|
720
|
+
# than name a dependency. Version ranges ("[1.0,2.0)") and BOM-style
|
|
721
|
+
# two-part coordinates are rejected for the same reason and because Gradle
|
|
722
|
+
# resolves anything subtler at build time anyway.
|
|
723
|
+
MAVEN_SEGMENT = /[A-Za-z0-9_][A-Za-z0-9_.-]*/
|
|
724
|
+
MAVEN_COORDINATE = /\A#{MAVEN_SEGMENT}:#{MAVEN_SEGMENT}:[A-Za-z0-9_][A-Za-z0-9_.+-]*\z/
|
|
725
|
+
|
|
726
|
+
def native_android_package_errors
|
|
727
|
+
raw = native_android["packages"]
|
|
728
|
+
return [] if raw.nil?
|
|
729
|
+
return ["native.android.packages must be a list"] unless raw.is_a?(Array)
|
|
730
|
+
|
|
731
|
+
raw.each_with_index.filter_map do |entry, i|
|
|
732
|
+
at = "native.android.packages[#{i}]"
|
|
733
|
+
next "#{at} must be a Maven coordinate string like \"com.airbnb.android:lottie:6.4.0\"" unless entry.is_a?(String)
|
|
734
|
+
|
|
735
|
+
coordinate = entry.strip
|
|
736
|
+
next if coordinate.match?(MAVEN_COORDINATE)
|
|
737
|
+
|
|
738
|
+
"#{at} #{coordinate.inspect} is not a Maven coordinate — " \
|
|
739
|
+
"\"group:artifact:version\" (letters, digits, . _ -)"
|
|
740
|
+
end
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
# Which Material icon font `icons.android` names — and the per-platform
|
|
744
|
+
# icon names nav buttons and menu items carry in page HTML — resolve
|
|
745
|
+
# against. Android has no UIImage(systemName:), so the shell draws the
|
|
746
|
+
# icon's codepoint from a font; the choice is which font ships.
|
|
747
|
+
#
|
|
748
|
+
# `bundled` is the whole reason this is a map and not a list: only the
|
|
749
|
+
# classic set (357 KB) rides inside the gem, because `ruby_everywhere` is
|
|
750
|
+
# 229 KB today and Symbols is ~10.6 MB — a 48x install cost paid by every
|
|
751
|
+
# user, including the desktop-only ones. The Symbols variants are fetched
|
|
752
|
+
# once on first Android build and cached under ~/.rubyeverywhere, which is
|
|
753
|
+
# noise next to the Gradle and AGP downloads the same build already makes.
|
|
754
|
+
#
|
|
755
|
+
# `codepoints` is the name → codepoint map Ruby validates every declared
|
|
756
|
+
# icon name against at build time, so a typo fails the build naming the tab
|
|
757
|
+
# it came from instead of rendering a blank icon on device.
|
|
758
|
+
ANDROID_ICON_FONTS = {
|
|
759
|
+
"symbols" => { "file" => "MaterialSymbolsOutlined.ttf", "bundled" => false },
|
|
760
|
+
"symbols-rounded" => { "file" => "MaterialSymbolsRounded.ttf", "bundled" => false },
|
|
761
|
+
"symbols-sharp" => { "file" => "MaterialSymbolsSharp.ttf", "bundled" => false },
|
|
762
|
+
"classic" => { "file" => "MaterialIcons-Regular.ttf", "bundled" => true },
|
|
763
|
+
# No font at all: the app brings its own drawables in native/android/res/.
|
|
764
|
+
"none" => { "file" => nil, "bundled" => true }
|
|
765
|
+
}.freeze
|
|
766
|
+
|
|
767
|
+
DEFAULT_ANDROID_ICON_FONT = "symbols"
|
|
768
|
+
|
|
769
|
+
def native_android_icon_font
|
|
770
|
+
value = native_android["icon_font"].to_s.strip
|
|
771
|
+
value.empty? ? DEFAULT_ANDROID_ICON_FONT : value
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
# The font's asset filename (assets/fonts/<file>), or nil for "none".
|
|
775
|
+
def native_android_icon_font_file = ANDROID_ICON_FONTS.dig(native_android_icon_font, "file")
|
|
776
|
+
|
|
777
|
+
# The sidecar map the builder validates names against, alongside the font.
|
|
778
|
+
def native_android_icon_font_codepoints
|
|
779
|
+
native_android_icon_font_file&.sub(/\.ttf\z/, ".codepoints")
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
# True when the selected font has to be downloaded before the first build
|
|
783
|
+
# can stamp it — the only case that can fail offline, so the builder warns
|
|
784
|
+
# about it (and points at `classic`) rather than dying mid-Gradle.
|
|
785
|
+
def native_android_icon_font_fetched?
|
|
786
|
+
entry = ANDROID_ICON_FONTS[native_android_icon_font]
|
|
787
|
+
!entry.nil? && entry["bundled"] == false
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
def native_android_icons? = !native_android_icon_font_file.nil?
|
|
791
|
+
|
|
792
|
+
def native_android_icon_font_errors
|
|
793
|
+
return [] if ANDROID_ICON_FONTS.key?(native_android_icon_font)
|
|
794
|
+
|
|
795
|
+
["native.android.icon_font #{native_android_icon_font.inspect} is not a known font — " \
|
|
796
|
+
"one of: #{ANDROID_ICON_FONTS.keys.join(", ")}"]
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
# Third-party sign-in (Sign in with Apple / Google / GitHub / anything
|
|
800
|
+
# OmniAuth speaks). Providers refuse to run — or run badly — inside an app's
|
|
801
|
+
# web view, so the mobile shell hands these paths to an
|
|
802
|
+
# ASWebAuthenticationSession instead, and the gem bridges the resulting
|
|
803
|
+
# session back into the app (see AuthHandoff).
|
|
804
|
+
#
|
|
805
|
+
# auth:
|
|
806
|
+
# oauth_paths: # paths that begin a provider flow
|
|
807
|
+
# - ^/auth/ # unanchored regexes, like `rules:`
|
|
808
|
+
# scheme: com.example.app # callback scheme (default: the iOS bundle id)
|
|
809
|
+
# cookies: # which cookies cross back into the app
|
|
810
|
+
# except: ["_ga"] # (default: all of them)
|
|
811
|
+
#
|
|
812
|
+
# The app keeps its own auth: `auth:` declares nothing about providers,
|
|
813
|
+
# only which paths the shell must not open in its web view.
|
|
814
|
+
def auth = @data.fetch("auth", nil) || {}
|
|
815
|
+
|
|
816
|
+
# Paths that begin a third-party auth flow, as regex source strings matched
|
|
817
|
+
# against the request path. Declaring `auth:` at all opts in, defaulting to
|
|
818
|
+
# OmniAuth's `/auth/…` convention.
|
|
819
|
+
DEFAULT_OAUTH_PATHS = ["^/auth/"].freeze
|
|
820
|
+
|
|
821
|
+
def oauth_paths
|
|
822
|
+
return [] unless @data.key?("auth")
|
|
823
|
+
|
|
824
|
+
paths = Array(auth["oauth_paths"]).map { |p| p.to_s.strip }.reject(&:empty?)
|
|
825
|
+
paths.empty? ? DEFAULT_OAUTH_PATHS.dup : paths
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
def oauth? = !oauth_paths.empty?
|
|
829
|
+
|
|
830
|
+
# Whether a request path starts a provider flow. Both halves of the system
|
|
831
|
+
# ask this — the middleware per request, the shell per visit proposal — so
|
|
832
|
+
# the patterns are compiled the same way on both sides (unanchored regex).
|
|
833
|
+
def oauth_path?(path)
|
|
834
|
+
oauth_paths.any? { |pattern| Regexp.new(pattern).match?(path.to_s) }
|
|
835
|
+
rescue RegexpError
|
|
836
|
+
false
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
# The custom URL scheme ASWebAuthenticationSession returns through. Defaults
|
|
840
|
+
# to the iOS bundle id — the convention, and already unique per app. Only
|
|
841
|
+
# the characters RFC 3986 allows in a scheme.
|
|
842
|
+
URL_SCHEME = /\A[a-zA-Z][a-zA-Z0-9+.-]*\z/
|
|
843
|
+
|
|
844
|
+
def auth_scheme
|
|
845
|
+
explicit = auth["scheme"].to_s.strip
|
|
846
|
+
explicit.empty? ? bundle_id(target: "ios") : explicit
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
# Cookie names to carry from the auth browser back into the app's web view,
|
|
850
|
+
# filtered by an optional allow/deny list. Everything the flow set is
|
|
851
|
+
# carried by default: the gem can't know which cookie an app's auth library
|
|
852
|
+
# signs its session with.
|
|
853
|
+
def auth_cookie?(name)
|
|
854
|
+
cookies = auth["cookies"]
|
|
855
|
+
return true unless cookies.is_a?(Hash)
|
|
856
|
+
|
|
857
|
+
only = Array(cookies["only"]).map(&:to_s)
|
|
858
|
+
return only.include?(name.to_s) unless only.empty?
|
|
859
|
+
|
|
860
|
+
!Array(cookies["except"]).map(&:to_s).include?(name.to_s)
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
# How long a minted handoff token stays valid. It travels device-locally
|
|
864
|
+
# (browser sheet → shell → web view), so seconds are plenty.
|
|
865
|
+
def auth_token_ttl = [(auth["token_ttl"] || 60).to_i, 5].max
|
|
866
|
+
|
|
867
|
+
def auth_errors
|
|
868
|
+
return [] unless @data.key?("auth")
|
|
869
|
+
|
|
870
|
+
errors = oauth_paths.filter_map do |pattern|
|
|
871
|
+
Regexp.new(pattern)
|
|
872
|
+
nil
|
|
873
|
+
rescue RegexpError => e
|
|
874
|
+
"auth.oauth_paths: #{pattern.inspect} is not a valid pattern (#{e.message})"
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
unless auth_scheme.match?(URL_SCHEME)
|
|
878
|
+
errors << "auth.scheme #{auth_scheme.inspect} is not a URL scheme (letters, digits, +, -, .)"
|
|
879
|
+
end
|
|
880
|
+
errors
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
# The auth subset the shell needs: which visits to divert into the auth
|
|
884
|
+
# session, and the scheme to bring the answer back through. nil (absent from
|
|
885
|
+
# everywhere.json) when the app declares no `auth:` — a shell that can't
|
|
886
|
+
# divert anything is a shell with no new surface.
|
|
887
|
+
def auth_shell_hash
|
|
888
|
+
return nil unless oauth?
|
|
889
|
+
|
|
890
|
+
{ "oauth_paths" => oauth_paths, "scheme" => auth_scheme }
|
|
891
|
+
end
|
|
892
|
+
|
|
517
893
|
# Custom native menu items (macOS app menu). Each entry: label + path
|
|
518
894
|
# (+ optional accelerator), or "separator". Clicks arrive in the page as
|
|
519
895
|
# "everywhere:menu" events and navigate via Turbo.
|
|
@@ -655,6 +1031,23 @@ module Everywhere
|
|
|
655
1031
|
JSON.generate(doc)
|
|
656
1032
|
end
|
|
657
1033
|
|
|
1034
|
+
# The two `native:` knobs that ship inside everywhere.json rather than
|
|
1035
|
+
# being compiled in, resolved for the platform being stamped. They read the
|
|
1036
|
+
# target's own section: everywhere.json is written once per target, so an
|
|
1037
|
+
# Android build that inherited native.ios.lazy_load_tabs would silently
|
|
1038
|
+
# apply a decision the app made about a different shell — and the two
|
|
1039
|
+
# platforms load tabs differently enough that the answer legitimately
|
|
1040
|
+
# differs. Without a target (the desktop dev loop, and every caller written
|
|
1041
|
+
# before Android existed) the iOS answer stands, which is what those
|
|
1042
|
+
# callers have always gotten.
|
|
1043
|
+
def native_lazy_load_tabs(target: nil)
|
|
1044
|
+
android_target?(target) ? native_android_lazy_load_tabs : native_ios_lazy_load_tabs
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1047
|
+
def native_splash_min_seconds(target: nil)
|
|
1048
|
+
android_target?(target) ? native_android_splash_min_seconds : native_ios_splash_min_seconds
|
|
1049
|
+
end
|
|
1050
|
+
|
|
658
1051
|
# The subset the shell needs, shipped as JSON (env var in dev,
|
|
659
1052
|
# Resources/everywhere.json inside a bundled .app). Pass `target:` so the
|
|
660
1053
|
# packaged config reflects the platform being built (its bundle id / name).
|
|
@@ -671,10 +1064,11 @@ module Everywhere
|
|
|
671
1064
|
"background_color" => background_color,
|
|
672
1065
|
"menu" => (menu unless menu.empty?),
|
|
673
1066
|
"tray" => (tray unless tray.empty?),
|
|
674
|
-
"lazy_load_tabs" =>
|
|
675
|
-
"splash_min_seconds" =>
|
|
1067
|
+
"lazy_load_tabs" => native_lazy_load_tabs(target: target),
|
|
1068
|
+
"splash_min_seconds" => native_splash_min_seconds(target: target),
|
|
676
1069
|
"updates" => updates_shell_hash,
|
|
677
1070
|
"permissions" => (permissions.keys unless permissions.empty?),
|
|
1071
|
+
"auth" => auth_shell_hash,
|
|
678
1072
|
# Hosts the shell treats as its own for incoming universal links, so a
|
|
679
1073
|
# tapped link to any associated domain opens in-app rather than Safari.
|
|
680
1074
|
"universal_link_hosts" => (deep_linking_domains if deep_linking? && !deep_linking_domains.empty?)
|
|
@@ -691,6 +1085,8 @@ module Everywhere
|
|
|
691
1085
|
def app = @data.fetch("app", nil) || {}
|
|
692
1086
|
def appearance = @data.fetch("appearance", nil) || {}
|
|
693
1087
|
|
|
1088
|
+
def android_target?(target) = self.class.os_of(target) == "android"
|
|
1089
|
+
|
|
694
1090
|
# Per-platform overrides, keyed by os (macos / ios / android / windows /
|
|
695
1091
|
# linux). Each value overrides the matching `app:` keys for that platform.
|
|
696
1092
|
def platforms = @data.fetch("platforms", nil) || {}
|