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.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +255 -0
  3. data/bridge/everywhere/bridge.js +60 -3
  4. data/lib/everywhere/android_resources.rb +217 -0
  5. data/lib/everywhere/android_sdk.rb +330 -0
  6. data/lib/everywhere/asset_catalog.rb +185 -0
  7. data/lib/everywhere/auth_handoff.rb +398 -0
  8. data/lib/everywhere/auth_token.rb +118 -0
  9. data/lib/everywhere/builders/android.rb +844 -0
  10. data/lib/everywhere/builders/ios.rb +40 -3
  11. data/lib/everywhere/commands/build.rb +51 -9
  12. data/lib/everywhere/commands/clean.rb +10 -5
  13. data/lib/everywhere/commands/dev.rb +46 -8
  14. data/lib/everywhere/commands/doctor.rb +89 -3
  15. data/lib/everywhere/commands/install.rb +1 -1
  16. data/lib/everywhere/commands/logs.rb +117 -8
  17. data/lib/everywhere/config.rb +409 -13
  18. data/lib/everywhere/emulator.rb +405 -0
  19. data/lib/everywhere/engine.rb +12 -0
  20. data/lib/everywhere/icon.rb +196 -0
  21. data/lib/everywhere/log_filter.rb +74 -0
  22. data/lib/everywhere/native_helper.rb +2 -5
  23. data/lib/everywhere/omniauth.rb +88 -0
  24. data/lib/everywhere/paths.rb +47 -0
  25. data/lib/everywhere/version.rb +1 -1
  26. data/support/mobile/android/README.md +217 -0
  27. data/support/mobile/android/app/build.gradle.kts +156 -0
  28. data/support/mobile/android/app/everywhere.properties +8 -0
  29. data/support/mobile/android/app/native-packages.gradle.kts +16 -0
  30. data/support/mobile/android/app/proguard-rules.pro +21 -0
  31. data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
  32. data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
  33. data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
  34. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
  35. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
  36. data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
  37. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
  38. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
  39. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
  40. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
  41. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
  42. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
  43. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
  44. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
  45. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
  46. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
  47. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
  48. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
  49. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
  50. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
  51. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
  52. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
  53. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
  54. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
  55. data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
  56. data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
  57. data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
  58. data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
  59. data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
  60. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
  61. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
  62. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
  63. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
  64. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
  65. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  66. data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
  67. data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
  68. data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
  69. data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
  70. data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
  71. data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
  72. data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
  73. data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
  74. data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
  75. data/support/mobile/android/build.gradle.kts +8 -0
  76. data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  77. data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
  78. data/support/mobile/android/gradle.properties +14 -0
  79. data/support/mobile/android/gradlew +251 -0
  80. data/support/mobile/android/gradlew.bat +94 -0
  81. data/support/mobile/android/settings.gradle.kts +28 -0
  82. data/support/mobile/ios/App/AppDelegate.swift +5 -0
  83. data/support/mobile/ios/App/AuthFlow.swift +101 -0
  84. data/support/mobile/ios/App/EverywhereConfig.swift +84 -0
  85. data/support/mobile/ios/App/SceneDelegate.swift +103 -0
  86. data/support/mobile/ios/App.xcodeproj/project.pbxproj +4 -0
  87. data/support/mobile/ios/README.md +4 -1
  88. metadata +67 -1
@@ -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
- 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);}})();
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, permissions). CLI flags still override per-run.
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 permissions the app declares. Recorded in the receipt and
150
- # (later) used to generate the shell's capabilities.
151
- def permissions = Array(build["permissions"])
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
- def mobile_rules
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
- { "patterns" => rule["patterns"].map(&:to_s), "properties" => rule["properties"] }
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" => native_ios_lazy_load_tabs,
675
- "splash_min_seconds" => native_ios_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) || {}