Class: Everywhere::Config

Inherits:
Object
  • Object
show all
Defined in:
lib/everywhere/config.rb

Overview

Loads config/everywhere.yml:

app:
name: My Really Awesome App
entry_path: /dashboard        # initial url on app launch
appearance:
tint_color: "#CC342D"         # a hex string...
background_color:             # ...or split by system theme
  light: "#FAF9F7"
  dark: "#1C1B1A"

Colors always normalize to { "light" => ..., "dark" => ... }.

Constant Summary collapse

FILE =
File.join("config", "everywhere.yml")
DEFAULT_TAB_ICONS =
{ "ios" => "circle", "android" => "circle" }.freeze
MOBILE_PATH_RULES =

The Hotwire Native path-configuration rules every RubyEverywhere mobile shell starts from. Single source of truth: the builder bakes this into the app bundle and MobileConfigEndpoint serves it live.

[
  { "patterns" => [".*"],
    "properties" => { "context" => "default", "pull_to_refresh_enabled" => true } },
  { "patterns" => ["/new$", "/edit$"],
    "properties" => { "context" => "modal", "pull_to_refresh_enabled" => false } }
].freeze
MOBILE_PERMISSIONS =

Native permissions the app declares (top-level permissions:). Only declared permissions can be requested at runtime — the shell answers undeclared for everything else, so an app that doesn't need a permission can never prompt for it. For camera and location the value is the user-facing usage string iOS shows in the permission prompt (the "why"); it's mandatory there because requesting without one crashes the app. Notifications need no string — declare with true.

permissions:
notifications: true
camera: "Scan QR codes to pair devices."
location:
  ios: "Find build agents near you."
biometrics: "Unlock your account with Face ID."

Returns { name => { "ios" => usage-or-nil, ... } }.

%w[notifications camera location biometrics].freeze
IOS_USAGE_KEYS =

Info.plist usage-description keys per permission. Presence here makes the usage string mandatory on iOS.

{
  "camera" => "NSCameraUsageDescription",
  "location" => "NSLocationWhenInUseUsageDescription",
  "biometrics" => "NSFaceIDUsageDescription"
}.freeze
ANDROID_PERMISSIONS =

Manifest names per permission, stamped into src/stamped/AndroidManifest.xml. Deliberately not the mirror of IOS_USAGE_KEYS: presence here mandates nothing, because Android has no Info.plist-style contract — a runtime request without a rationale string prompts normally instead of terminating the process, and the rationale is a dialog the app draws itself, not a manifest value. So the map only answers "which manifest entry does this permission need", and every declared name has one.

ACCESS_FINE_LOCATION implies ACCESS_COARSE_LOCATION on API 31+ only when both are declared, but the shell asks for precise location, so the fine permission alone is the honest declaration.

{
  "notifications" => "android.permission.POST_NOTIFICATIONS",
  "camera" => "android.permission.CAMERA",
  "location" => "android.permission.ACCESS_FINE_LOCATION",
  "biometrics" => "android.permission.USE_BIOMETRIC"
}.freeze
PACKAGE_REQUIREMENT_KEYS =

Third-party Swift packages this app pins for its native/ios code. Each entry is one SPM dependency; every build writes them into the shell's local NativeExtensions package and re-exports every product, so native/ios code reaches them with a single import NativeExtensions.

packages:
- url: https://github.com/simibac/ConfettiSwiftUI
  from: "1.1.0"                 # or exact: / branch: / revision:
  products: [ConfettiSwiftUI]   # optional; defaults to the repo name

Returns normalized entries: { "url", "requirement" => { "kind", "value" }, "products" => [...] }. Assumes the config validated clean (see native_ios_package_errors) — the requirement is always present here.

%w[from exact branch revision].freeze
SWIFT_TYPE_NAME =

Everything declared here is interpolated into generated Swift, so names are validated hard: type names must be plain Swift identifiers, screen identifiers must be safe inside a Swift string literal.

/\A[A-Za-z_][A-Za-z0-9_]*\z/
SCREEN_IDENTIFIER =
/\A[A-Za-z0-9_-]+\z/
PACKAGE_URL =

A URL SPM accepts (https for the common case, git@ for SSH remotes) and a loose semver for from:/exact: — enough to catch typos without reimplementing SPM's resolver, which reports anything subtler at build time.

%r{\A(https?://|git@).+}
PACKAGE_VERSION =
/\A\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?\z/
KOTLIN_TYPE_NAME =

Kotlin's identifier rules match Swift's across everything we generate (no backticked names, no unicode escapes), so one pattern validates both. Aliased rather than reused by name so the Android messages can say "Kotlin" and the two paths can diverge later without touching iOS.

SWIFT_TYPE_NAME
MAVEN_SEGMENT =

group:artifact:version — Gradle's shorthand form, and the only one we accept. The charset is narrower than Maven strictly allows on purpose: every coordinate is interpolated into a generated Kotlin DSL file inside a double-quoted literal, so a quote, a backslash, a $ (Kotlin string templates), a newline or a space would rewrite the build script rather than name a dependency. Version ranges ("[1.0,2.0)") and BOM-style two-part coordinates are rejected for the same reason and because Gradle resolves anything subtler at build time anyway.

/[A-Za-z0-9_][A-Za-z0-9_.-]*/
MAVEN_COORDINATE =
/\A#{MAVEN_SEGMENT}:#{MAVEN_SEGMENT}:[A-Za-z0-9_][A-Za-z0-9_.+-]*\z/
ANDROID_ICON_FONTS =

Which Material icon font icons.android names — and the per-platform icon names nav buttons and menu items carry in page HTML — resolve against. Android has no UIImage(systemName:), so the shell draws the icon's codepoint from a font; the choice is which font ships.

bundled is the whole reason this is a map and not a list: only the classic set (357 KB) rides inside the gem, because ruby_everywhere is 229 KB today and Symbols is ~10.6 MB — a 48x install cost paid by every user, including the desktop-only ones. The Symbols variants are fetched once on first Android build and cached under ~/.rubyeverywhere, which is noise next to the Gradle and AGP downloads the same build already makes.

codepoints is the name → codepoint map Ruby validates every declared icon name against at build time, so a typo fails the build naming the tab it came from instead of rendering a blank icon on device.

{
  "symbols" => { "file" => "MaterialSymbolsOutlined.ttf", "bundled" => false },
  "symbols-rounded" => { "file" => "MaterialSymbolsRounded.ttf", "bundled" => false },
  "symbols-sharp" => { "file" => "MaterialSymbolsSharp.ttf", "bundled" => false },
  "classic" => { "file" => "MaterialIcons-Regular.ttf", "bundled" => true },
  # No font at all: the app brings its own drawables in native/android/res/.
  "none" => { "file" => nil, "bundled" => true }
}.freeze
DEFAULT_ANDROID_ICON_FONT =
"symbols"
DEFAULT_OAUTH_PATHS =

Paths that begin a third-party auth flow, as regex source strings matched against the request path. Declaring auth: at all opts in, defaulting to OmniAuth's /auth/… convention.

["^/auth/"].freeze
URL_SCHEME =

The custom URL scheme ASWebAuthenticationSession returns through. Defaults to the iOS bundle id — the convention, and already unique per app. Only the characters RFC 3986 allows in a scheme.

/\A[a-zA-Z][a-zA-Z0-9+.-]*\z/

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(data, root) ⇒ Config

Returns a new instance of Config.



134
135
136
137
# File 'lib/everywhere/config.rb', line 134

def initialize(data, root)
  @data = data
  @root = root
end

Instance Attribute Details

#rootObject (readonly)

Returns the value of attribute root.



132
133
134
# File 'lib/everywhere/config.rb', line 132

def root
  @root
end

Class Method Details

.load(root = Dir.pwd) ⇒ Object



126
127
128
129
130
# File 'lib/everywhere/config.rb', line 126

def self.load(root = Dir.pwd)
  path = File.join(root, FILE)
  data = File.exist?(path) ? YAML.safe_load_file(path, aliases: true) || {} : {}
  new(data, root)
end

.os_of(target) ⇒ Object

Every target (os-arch string) resolves to a single platform (its os), and a platform may override the app-level identity/version keys — see platforms: below. Pass target: to any of these accessors to apply the matching override; omit it for the shared app-level value.



143
# File 'lib/everywhere/config.rb', line 143

def self.os_of(target) = target.to_s.split("-", 2).first

Instance Method Details

#android_manifest_permissionsObject

The manifest permissions an Android build declares, in declaration order. Only known names map to anything; permission_errors("android") has already failed the build on the rest by the time the builder asks.



386
387
388
# File 'lib/everywhere/config.rb', line 386

def android_manifest_permissions
  permissions.keys.filter_map { |name| ANDROID_PERMISSIONS[name] }.uniq
end

#android_screen_properties(properties) ⇒ Object

Hotwire Native iOS picks a native screen with view_controller: <id>; Android picks one with uri: hotwire://fragment/<id>, matched against the Fragment's own @HotwireDestinationDeepLink annotation. Same rule, same identifier, different property name — so the Android document derives the uri rather than making apps write the route twice and keep the two in sync by hand.

Derived only for ids declared under native.android.screens: an id that names an iOS-only screen must fall through to the web fragment, because a uri pointing at a Fragment this build doesn't contain resolves to nothing and the visit dead-ends. An explicit uri: always wins — that's the escape hatch for a screen whose annotation says something else.



453
454
455
456
457
458
459
# File 'lib/everywhere/config.rb', line 453

def android_screen_properties(properties)
  id = properties["view_controller"]
  return properties if id.nil? || properties.key?("uri")
  return properties unless native_android_screens.key?(id.to_s)

  properties.merge("uri" => "hotwire://fragment/#{id}")
end

#apple_app_site_associationObject

The apple-app-site-association document (a Hash), or nil when no Apple app ids are configured. applinks for universal links; webcredentials so associated-domains password autofill / Face ID credentials work too.



1002
1003
1004
1005
1006
1007
1008
1009
1010
# File 'lib/everywhere/config.rb', line 1002

def apple_app_site_association
  ids = deep_linking_apple_app_ids
  return nil if ids.empty?

  { "applinks" => {
      "details" => [{ "appIDs" => ids, "components" => deep_linking_paths.map { |p| { "/" => p } } }]
    },
    "webcredentials" => { "apps" => ids } }
end

#apple_app_site_association_jsonObject



1012
1013
1014
1015
1016
# File 'lib/everywhere/config.rb', line 1012

def apple_app_site_association_json
  require "json"
  doc = apple_app_site_association or return nil
  JSON.generate(doc)
end

The Digital Asset Links document (an Array), or nil without an Android app.



1019
1020
1021
1022
1023
1024
1025
1026
# File 'lib/everywhere/config.rb', line 1019

def asset_links
  return nil unless deep_linking_android?

  [{ "relation" => ["delegate_permission/common.handle_all_urls"],
     "target" => { "namespace" => "android_app",
                   "package_name" => deep_linking_android_package,
                   "sha256_cert_fingerprints" => deep_linking_android_fingerprints } }]
end


1028
1029
1030
1031
1032
# File 'lib/everywhere/config.rb', line 1028

def asset_links_json
  require "json"
  doc = asset_links or return nil
  JSON.generate(doc)
end

#authObject

Third-party sign-in (Sign in with Apple / Google / GitHub / anything OmniAuth speaks). Providers refuse to run — or run badly — inside an app's web view, so the mobile shell hands these paths to an ASWebAuthenticationSession instead, and the gem bridges the resulting session back into the app (see AuthHandoff).

auth:
oauth_paths:                # paths that begin a provider flow
  - ^/auth/                 # unanchored regexes, like `rules:`
scheme: com.example.app     # callback scheme (default: the iOS bundle id)
cookies:                    # which cookies cross back into the app
  except: ["_ga"]           #   (default: all of them)

The app keeps its own auth: auth: declares nothing about providers, only which paths the shell must not open in its web view.



814
# File 'lib/everywhere/config.rb', line 814

def auth = @data.fetch("auth", nil) || {}

#auth_cookie?(name) ⇒ Boolean

Cookie names to carry from the auth browser back into the app's web view, filtered by an optional allow/deny list. Everything the flow set is carried by default: the gem can't know which cookie an app's auth library signs its session with.

Returns:

  • (Boolean)


853
854
855
856
857
858
859
860
861
# File 'lib/everywhere/config.rb', line 853

def auth_cookie?(name)
  cookies = auth["cookies"]
  return true unless cookies.is_a?(Hash)

  only = Array(cookies["only"]).map(&:to_s)
  return only.include?(name.to_s) unless only.empty?

  !Array(cookies["except"]).map(&:to_s).include?(name.to_s)
end

#auth_errorsObject



867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
# File 'lib/everywhere/config.rb', line 867

def auth_errors
  return [] unless @data.key?("auth")

  errors = oauth_paths.filter_map do |pattern|
    Regexp.new(pattern)
    nil
  rescue RegexpError => e
    "auth.oauth_paths: #{pattern.inspect} is not a valid pattern (#{e.message})"
  end

  unless auth_scheme.match?(URL_SCHEME)
    errors << "auth.scheme #{auth_scheme.inspect} is not a URL scheme (letters, digits, +, -, .)"
  end
  errors
end

#auth_schemeObject



844
845
846
847
# File 'lib/everywhere/config.rb', line 844

def auth_scheme
  explicit = auth["scheme"].to_s.strip
  explicit.empty? ? bundle_id(target: "ios") : explicit
end

#auth_shell_hashObject

The auth subset the shell needs: which visits to divert into the auth session, and the scheme to bring the answer back through. nil (absent from everywhere.json) when the app declares no auth: — a shell that can't divert anything is a shell with no new surface.



887
888
889
890
891
# File 'lib/everywhere/config.rb', line 887

def auth_shell_hash
  return nil unless oauth?

  { "oauth_paths" => oauth_paths, "scheme" => auth_scheme }
end

#auth_token_ttlObject

How long a minted handoff token stays valid. It travels device-locally (browser sheet → shell → web view), so seconds are plenty.



865
# File 'lib/everywhere/config.rb', line 865

def auth_token_ttl = [(auth["token_ttl"] || 60).to_i, 5].max

#background_colorObject



270
271
272
# File 'lib/everywhere/config.rb', line 270

def background_color
  normalize_color(appearance["background_color"])
end

#buildObject

The build: section — durable build knobs that were once every build flags (ruby, targets, capabilities). CLI flags still override per-run. See platform/docs/build-engine.md §2.



193
# File 'lib/everywhere/config.rb', line 193

def build = @data.fetch("build", nil) || {}

#build_capabilitiesObject

Desktop OS-integration capabilities the app declares (build.capabilities), as a plain list. Deliberately not called permissions: the top-level permissions: below is the mobile prompt declaration — a different shape for a different consumer — and while both were spelled "permissions" this accessor was silently redefined by that one and could never be read.



203
# File 'lib/everywhere/config.rb', line 203

def build_capabilities = Array(build["capabilities"])

#build_rubyObject

Ruby version to package. nil here means "let the CLI decide its default".



196
# File 'lib/everywhere/config.rb', line 196

def build_ruby = build["ruby"]

#bundle_id(target: nil) ⇒ Object

Reverse-DNS identifier: macOS bundle id, and the name of the per-user app-data directory on every platform. Set it explicitly and never change it — renaming moves users' data. A platform may override it (e.g. the App Store often wants com.example.app.ios).



153
154
155
156
# File 'lib/everywhere/config.rb', line 153

def bundle_id(target: nil)
  resolved(target)["bundle_id"] ||
    "com.rubyeverywhere.#{name(target: target).downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")}"
end

#deep_linkingObject

Deep linking / universal links. Declares the app's association with its web domains so the OS can hand matching URLs to the app instead of the browser. The gem serves the two well-known association files this needs — /.well-known/apple-app-site-association and /.well-known/assetlinks.json — generated from this config (Everywhere::Engine in Rails; MobileConfigEndpoint for Sinatra/Hanami). The iOS builder stamps the matching Associated Domains entitlement, and the shell routes an incoming link to its path.

deep_linking:
team_id: ABCDE12345          # derives the iOS app id from bundle_id
# or set app ids explicitly:
# apple_app_id: ABCDE12345.com.example.app
# apple_app_ids: ["ABCDE12345.com.example.app"]
paths: ["/*"]                # which paths open the app (default: all)
domains: ["www.example.com"] # extra applinks: domains (remote host is implicit)
android:
  package: com.example.app
  sha256_cert_fingerprints: ["AB:CD:EF:..."]


952
# File 'lib/everywhere/config.rb', line 952

def deep_linking = @data.fetch("deep_linking", nil) || {}

#deep_linking?Boolean

Returns:

  • (Boolean)


982
# File 'lib/everywhere/config.rb', line 982

def deep_linking? = deep_linking_apple? || deep_linking_android?

#deep_linking_androidObject



971
# File 'lib/everywhere/config.rb', line 971

def deep_linking_android = deep_linking["android"].is_a?(Hash) ? deep_linking["android"] : {}

#deep_linking_android?Boolean

Returns:

  • (Boolean)


978
# File 'lib/everywhere/config.rb', line 978

def deep_linking_android? = !deep_linking_android_package.to_s.empty? && !deep_linking_android_fingerprints.empty?

#deep_linking_android_fingerprintsObject



974
975
976
# File 'lib/everywhere/config.rb', line 974

def deep_linking_android_fingerprints
  Array(deep_linking_android["sha256_cert_fingerprints"]).map { |f| f.to_s.strip }.reject(&:empty?)
end

#deep_linking_android_packageObject



972
# File 'lib/everywhere/config.rb', line 972

def deep_linking_android_package = deep_linking_android["package"]&.to_s

#deep_linking_apple?Boolean

Returns:

  • (Boolean)


980
# File 'lib/everywhere/config.rb', line 980

def deep_linking_apple? = !deep_linking_apple_app_ids.empty?

#deep_linking_apple_app_idsObject

The iOS/tvOS/... app identifiers (TeamID.bundleID) the association file advertises. Explicit ids win; otherwise team_id + the app's iOS bundle id.



956
957
958
959
960
961
962
# File 'lib/everywhere/config.rb', line 956

def deep_linking_apple_app_ids
  explicit = Array(deep_linking["apple_app_ids"]) + [deep_linking["apple_app_id"]].compact
  return explicit.map(&:to_s).uniq unless explicit.empty?

  team = deep_linking["team_id"].to_s.strip
  team.empty? ? [] : ["#{team}.#{bundle_id(target: "ios")}"]
end

#deep_linking_domainsObject

Domains for the iOS Associated Domains entitlement (applinks:) and the shell's universal-link host allowlist: the remote host plus any extra domains:. Bare hostnames (no scheme, no path).



987
988
989
990
991
992
993
994
995
996
997
# File 'lib/everywhere/config.rb', line 987

def deep_linking_domains
  hosts = []
  if remote_url
    require "uri"
    hosts << URI(remote_url).host
  end
  hosts += Array(deep_linking["domains"]).map { |d| d.to_s.sub(%r{\Ahttps?://}, "").sub(%r{/.*\z}, "") }
  hosts.compact.reject(&:empty?).uniq
rescue URI::InvalidURIError
  Array(deep_linking["domains"]).map(&:to_s).reject(&:empty?).uniq
end

#deep_linking_pathsObject

URL path patterns that open the app. Modern (iOS 14+) component form; defaults to every path.



966
967
968
969
# File 'lib/everywhere/config.rb', line 966

def deep_linking_paths
  paths = Array(deep_linking["paths"]).map(&:to_s).reject(&:empty?)
  paths.empty? ? ["*"] : paths
end

#entry_pathObject



177
178
179
180
# File 'lib/everywhere/config.rb', line 177

def entry_path
  path = app["entry_path"] || "/"
  path.start_with?("/") ? path : "/#{path}"
end

#iconObject

App icon source: a PNG (ideally square, 1024px+). Explicit app.icon path relative to the app root, or icon.png at the root by convention.



160
161
162
163
164
165
166
167
# File 'lib/everywhere/config.rb', line 160

def icon
  if (explicit = app["icon"])
    File.expand_path(explicit, root)
  else
    default = File.join(root, "icon.png")
    File.exist?(default) ? default : nil
  end
end

Custom native menu items (macOS app menu). Each entry: label + path (+ optional accelerator), or "separator". Clicks arrive in the page as "everywhere:menu" events and navigate via Turbo.



896
897
898
899
900
901
902
903
904
905
906
907
908
# File 'lib/everywhere/config.rb', line 896

def menu
  entries = @data["menu"]
  return [] unless entries.is_a?(Array)

  entries.filter_map do |item|
    if item == "separator" || (item.is_a?(Hash) && item["separator"])
      { "separator" => true }
    elsif item.is_a?(Hash) && item["label"] && item["path"]
      { "label" => item["label"], "path" => item["path"],
        "accelerator" => item["accelerator"] }.compact
    end
  end
end

#mobile_rules(os = nil) ⇒ Object

App-declared Hotwire Native path-configuration rules (everywhere.yml top-level rules:), appended after MOBILE_PATH_RULES — later rules win per property, so apps can make routes modal, disable pull-to-refresh, or set any other Hotwire path property without touching native code:

rules:
- patterns: ["/preferences$"]
  properties:
    context: default        # not a modal, unlike other /edit routes
- patterns: ["/live/"]
  properties:
    pull_to_refresh_enabled: false

Native screens are selected by a different property on each platform, so os decides how a rule's view_controller: is read (see #android_screen_properties).



428
429
430
431
432
433
434
435
436
437
438
439
# File 'lib/everywhere/config.rb', line 428

def mobile_rules(os = nil)
  entries = @data["rules"]
  return [] unless entries.is_a?(Array)

  entries.filter_map do |rule|
    next unless rule.is_a?(Hash) && rule["patterns"].is_a?(Array) && rule["properties"].is_a?(Hash)

    properties = rule["properties"]
    properties = android_screen_properties(properties) if android_target?(os)
    { "patterns" => rule["patterns"].map(&:to_s), "properties" => properties }
  end
end

#modeObject

"local" — the app is tebako-pressed and runs on-device (default) "remote" — thin shell around an already-deployed app: no press, no sidecar



210
211
212
# File 'lib/everywhere/config.rb', line 210

def mode
  app["mode"] || (remote_url ? "remote" : "local")
end

#name(target: nil) ⇒ Object



145
146
147
# File 'lib/everywhere/config.rb', line 145

def name(target: nil)
  resolved(target)["name"] || File.basename(File.expand_path(root)).split(/[-_]/).map(&:capitalize).join(" ")
end

#native_androidObject

The Android half of "supernative": Kotlin the app repo carries in native/android/ that every build --android compiles into the shell, declared the same way iOS declares Swift. Same build-time-only rule — Play forbids downloading executable code — and the same shape, so an app that already knows native.ios: knows this section too.

native:
android:
  components: [ChartComponent]   # BridgeComponent subclasses to register
  screens:                       # path rules with view_controller: <id>
    map: MapFragment             #   Fragment with an (url) argument
  splash: LaunchSplash           # Fragment/Activity shown while booting
  splash_min_seconds: 1.0        # how long that splash stays up at minimum
  lazy_load_tabs: true           # defer each tab's first visit until selected
  icon_font: symbols             # which Material icon font resolves icon names
  packages:                      # Maven coordinates, not SPM entries
    - "com.airbnb.android:lottie:6.4.0"


642
643
644
645
# File 'lib/everywhere/config.rb', line 642

def native_android
  section = @data.dig("native", "android")
  section.is_a?(Hash) ? section : {}
end

#native_android?Boolean

Returns:

  • (Boolean)


692
693
# File 'lib/everywhere/config.rb', line 692

def native_android? = !(native_android_components.empty? && native_android_screens.empty? &&
native_android_splash.nil?)

#native_android_componentsObject



647
# File 'lib/everywhere/config.rb', line 647

def native_android_components = Array(native_android["components"]).map(&:to_s)

#native_android_errorsObject



701
702
703
704
705
706
707
708
709
710
711
712
713
# File 'lib/everywhere/config.rb', line 701

def native_android_errors
  types = native_android_components + native_android_screens.values + [native_android_splash].compact
  errors = types.reject { |t| t.match?(KOTLIN_TYPE_NAME) }.map do |t|
    "native.android: #{t.inspect} is not a Kotlin type name (letters, digits, _)"
  end
  errors += native_android_screens.keys.reject { |id| id.match?(SCREEN_IDENTIFIER) }.map do |id|
    "native.android.screens: identifier #{id.inspect} must match #{SCREEN_IDENTIFIER.inspect}"
  end

  raw = native_android["splash_min_seconds"]
  errors << "native.android.splash_min_seconds must be a number (seconds)" if raw && !raw.is_a?(Numeric)
  errors + native_android_icon_font_errors + native_android_package_errors
end

#native_android_icon_fontObject



769
770
771
772
# File 'lib/everywhere/config.rb', line 769

def native_android_icon_font
  value = native_android["icon_font"].to_s.strip
  value.empty? ? DEFAULT_ANDROID_ICON_FONT : value
end

#native_android_icon_font_codepointsObject

The sidecar map the builder validates names against, alongside the font.



778
779
780
# File 'lib/everywhere/config.rb', line 778

def native_android_icon_font_codepoints
  native_android_icon_font_file&.sub(/\.ttf\z/, ".codepoints")
end

#native_android_icon_font_errorsObject



792
793
794
795
796
797
# File 'lib/everywhere/config.rb', line 792

def native_android_icon_font_errors
  return [] if ANDROID_ICON_FONTS.key?(native_android_icon_font)

  ["native.android.icon_font #{native_android_icon_font.inspect} is not a known font — " \
   "one of: #{ANDROID_ICON_FONTS.keys.join(", ")}"]
end

#native_android_icon_font_fetched?Boolean

True when the selected font has to be downloaded before the first build can stamp it — the only case that can fail offline, so the builder warns about it (and points at classic) rather than dying mid-Gradle.

Returns:

  • (Boolean)


785
786
787
788
# File 'lib/everywhere/config.rb', line 785

def native_android_icon_font_fetched?
  entry = ANDROID_ICON_FONTS[native_android_icon_font]
  !entry.nil? && entry["bundled"] == false
end

#native_android_icon_font_fileObject

The font's asset filename (assets/fonts/), or nil for "none".



775
# File 'lib/everywhere/config.rb', line 775

def native_android_icon_font_file = ANDROID_ICON_FONTS.dig(native_android_icon_font, "file")

#native_android_icons?Boolean

Returns:

  • (Boolean)


790
# File 'lib/everywhere/config.rb', line 790

def native_android_icons? = !native_android_icon_font_file.nil?

#native_android_lazy_load_tabsObject

Tri-state like native_ios_lazy_load_tabs: true defers each non-selected tab's first visit, false loads them all up front, absent leaves the shell on its default. Android's bottom-nav hosts are created eagerly at onCreate, so this decides whether they visit, not whether they exist.



670
671
672
673
# File 'lib/everywhere/config.rb', line 670

def native_android_lazy_load_tabs
  value = native_android["lazy_load_tabs"]
  value if value == true || value == false
end

#native_android_package_errorsObject



726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
# File 'lib/everywhere/config.rb', line 726

def native_android_package_errors
  raw = native_android["packages"]
  return [] if raw.nil?
  return ["native.android.packages must be a list"] unless raw.is_a?(Array)

  raw.each_with_index.filter_map do |entry, i|
    at = "native.android.packages[#{i}]"
    next "#{at} must be a Maven coordinate string like \"com.airbnb.android:lottie:6.4.0\"" unless entry.is_a?(String)

    coordinate = entry.strip
    next if coordinate.match?(MAVEN_COORDINATE)

    "#{at} #{coordinate.inspect} is not a Maven coordinate — " \
      "\"group:artifact:version\" (letters, digits, . _ -)"
  end
end

#native_android_packagesObject

Third-party dependencies for native/android code, as Gradle sees them: plain Maven coordinates. There is no SPM-style url + requirement split — a coordinate already carries group, artifact and version — so this accessor returns strings, one per implementation(…) line the builder writes into native-packages.gradle.kts. Assumes the config validated clean (see native_android_package_errors).



681
682
683
684
685
686
687
688
# File 'lib/everywhere/config.rb', line 681

def native_android_packages
  Array(native_android["packages"]).filter_map do |entry|
    next unless entry.is_a?(String)

    coordinate = entry.strip
    coordinate unless coordinate.empty?
  end
end

#native_android_packages?Boolean

Returns:

  • (Boolean)


690
# File 'lib/everywhere/config.rb', line 690

def native_android_packages? = !native_android_packages.empty?

#native_android_screensObject



649
650
651
652
653
654
# File 'lib/everywhere/config.rb', line 649

def native_android_screens
  entries = native_android["screens"]
  return {} unless entries.is_a?(Hash)

  entries.to_h { |id, type| [id.to_s, type.to_s] }
end

#native_android_splashObject



656
# File 'lib/everywhere/config.rb', line 656

def native_android_splash = native_android["splash"]&.to_s

#native_android_splash_min_secondsObject

Same contract as the iOS splash minimum, and the same clamp: the shell gives up waiting at 8s either way, so a larger number here would only promise something the shell won't honor.



661
662
663
664
# File 'lib/everywhere/config.rb', line 661

def native_android_splash_min_seconds
  value = native_android["splash_min_seconds"]
  value.to_f.clamp(0.0, 8.0) if value.is_a?(Numeric)
end

#native_iosObject

Native code extensions ("supernative"): Swift the app repo carries in native/ios/ that every build compiles into the shell, plus this declaration of what to hook up. Build-time only — stores forbid downloading native code, so extensions ship with the app binary.

native:
ios:
  components: [ChartComponent]   # BridgeComponent subclasses to register
  screens:                       # path rules with view_controller: <id>
    map: MapScreen               #   SwiftUI View or UIViewController, init(url:)
  splash: LaunchSplash           # SwiftUI View or UIViewController, init()
  splash_min_seconds: 1.0        # how long the custom splash stays up at minimum
  lazy_load_tabs: true           # defer each tab's first visit until it's selected


491
492
493
494
# File 'lib/everywhere/config.rb', line 491

def native_ios
  section = @data.dig("native", "ios")
  section.is_a?(Hash) ? section : {}
end

#native_ios?Boolean

Returns:

  • (Boolean)


562
# File 'lib/everywhere/config.rb', line 562

def native_ios? = !(native_ios_components.empty? && native_ios_screens.empty? && native_ios_splash.nil?)

#native_ios_componentsObject



496
# File 'lib/everywhere/config.rb', line 496

def native_ios_components = Array(native_ios["components"]).map(&:to_s)

#native_ios_errorsObject



570
571
572
573
574
575
576
577
578
579
580
581
582
# File 'lib/everywhere/config.rb', line 570

def native_ios_errors
  types = native_ios_components + native_ios_screens.values + [native_ios_splash].compact
  errors = types.reject { |t| t.match?(SWIFT_TYPE_NAME) }.map do |t|
    "native.ios: #{t.inspect} is not a Swift type name (letters, digits, _)"
  end
  errors += native_ios_screens.keys.reject { |id| id.match?(SCREEN_IDENTIFIER) }.map do |id|
    "native.ios.screens: identifier #{id.inspect} must match #{SCREEN_IDENTIFIER.inspect}"
  end

  raw = native_ios["splash_min_seconds"]
  errors << "native.ios.splash_min_seconds must be a number (seconds)" if raw && !raw.is_a?(Numeric)
  errors + native_ios_package_errors
end

#native_ios_lazy_load_tabsObject

Whether the native tab bar defers each non-selected tab's initial visit until the tab is first selected (Hotwire Native lazyLoadTabs). Tri-state so apps can opt in or opt out explicitly: true lazy-loads, false loads every tab up front, and an absent key leaves the shell on its default (eager).



520
521
522
523
# File 'lib/everywhere/config.rb', line 520

def native_ios_lazy_load_tabs
  value = native_ios["lazy_load_tabs"]
  value if value == true || value == false
end

#native_ios_package_errorsObject



590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
# File 'lib/everywhere/config.rb', line 590

def native_ios_package_errors
  raw = native_ios["packages"]
  return [] if raw.nil?
  return ["native.ios.packages must be a list"] unless raw.is_a?(Array)

  raw.each_with_index.flat_map do |entry, i|
    at = "native.ios.packages[#{i}]"
    next ["#{at} must be a mapping with a url:"] unless entry.is_a?(Hash)

    errors = []
    url = entry["url"].to_s.strip
    if url.empty?
      errors << "#{at}.url is required"
    elsif !url.match?(PACKAGE_URL)
      errors << "#{at}.url #{url.inspect} must be an https:// or git@ URL"
    end

    present = PACKAGE_REQUIREMENT_KEYS.select { |k| entry.key?(k) && !entry[k].to_s.strip.empty? }
    if present.empty?
      errors << "#{at} needs one version requirement (#{PACKAGE_REQUIREMENT_KEYS.join(" / ")})"
    elsif present.length > 1
      errors << "#{at} sets conflicting requirements (#{present.join(", ")}) — pick one"
    elsif %w[from exact].include?(present.first) && !entry[present.first].to_s.strip.match?(PACKAGE_VERSION)
      errors << "#{at}.#{present.first} #{entry[present.first].to_s.inspect} must be a version like \"1.2.0\""
    end

    Array(entry["products"]).each do |product|
      next if product.to_s.match?(SWIFT_TYPE_NAME)

      errors << "#{at}.products: #{product.to_s.inspect} is not a module name (letters, digits, _)"
    end
    errors
  end
end

#native_ios_packagesObject



540
541
542
543
544
545
546
547
548
549
550
551
552
553
# File 'lib/everywhere/config.rb', line 540

def native_ios_packages
  Array(native_ios["packages"]).filter_map do |entry|
    next unless entry.is_a?(Hash)

    url = entry["url"].to_s.strip
    kind = PACKAGE_REQUIREMENT_KEYS.find { |k| entry.key?(k) && !entry[k].to_s.strip.empty? }
    products = Array(entry["products"]).map { |p| p.to_s.strip }.reject(&:empty?)
    products = [package_identity(url)] if products.empty?

    { "url" => url,
      "requirement" => (kind && { "kind" => kind, "value" => entry[kind].to_s.strip }),
      "products" => products }
  end
end

#native_ios_packages?Boolean

Returns:

  • (Boolean)


555
# File 'lib/everywhere/config.rb', line 555

def native_ios_packages? = !native_ios_packages.empty?

#native_ios_screensObject



498
499
500
501
502
503
# File 'lib/everywhere/config.rb', line 498

def native_ios_screens
  entries = native_ios["screens"]
  return {} unless entries.is_a?(Hash)

  entries.to_h { |id, type| [id.to_s, type.to_s] }
end

#native_ios_splashObject



505
# File 'lib/everywhere/config.rb', line 505

def native_ios_splash = native_ios["splash"]&.to_s

#native_ios_splash_min_secondsObject

Minimum seconds a custom splash stays on screen (default 1.0 shell-side; a fast server would otherwise dismiss a branded splash in a ~50ms flash). Clamped to the shell's 8s give-up timeout.



510
511
512
513
# File 'lib/everywhere/config.rb', line 510

def native_ios_splash_min_seconds
  value = native_ios["splash_min_seconds"]
  value.to_f.clamp(0.0, 8.0) if value.is_a?(Numeric)
end

#native_lazy_load_tabs(target: nil) ⇒ Object

The two native: knobs that ship inside everywhere.json rather than being compiled in, resolved for the platform being stamped. They read the target's own section: everywhere.json is written once per target, so an Android build that inherited native.ios.lazy_load_tabs would silently apply a decision the app made about a different shell — and the two platforms load tabs differently enough that the answer legitimately differs. Without a target (the desktop dev loop, and every caller written before Android existed) the iOS answer stands, which is what those callers have always gotten.



1043
1044
1045
# File 'lib/everywhere/config.rb', line 1043

def native_lazy_load_tabs(target: nil)
  android_target?(target) ? native_android_lazy_load_tabs : native_ios_lazy_load_tabs
end

#native_splash_min_seconds(target: nil) ⇒ Object



1047
1048
1049
# File 'lib/everywhere/config.rb', line 1047

def native_splash_min_seconds(target: nil)
  android_target?(target) ? native_android_splash_min_seconds : native_ios_splash_min_seconds
end

#oauth?Boolean

Returns:

  • (Boolean)


828
# File 'lib/everywhere/config.rb', line 828

def oauth?  = !oauth_paths.empty?

#oauth_path?(path) ⇒ Boolean

Whether a request path starts a provider flow. Both halves of the system ask this — the middleware per request, the shell per visit proposal — so the patterns are compiled the same way on both sides (unanchored regex).

Returns:

  • (Boolean)


833
834
835
836
837
# File 'lib/everywhere/config.rb', line 833

def oauth_path?(path)
  oauth_paths.any? { |pattern| Regexp.new(pattern).match?(path.to_s) }
rescue RegexpError
  false
end

#oauth_pathsObject



821
822
823
824
825
826
# File 'lib/everywhere/config.rb', line 821

def oauth_paths
  return [] unless @data.key?("auth")

  paths = Array(auth["oauth_paths"]).map { |p| p.to_s.strip }.reject(&:empty?)
  paths.empty? ? DEFAULT_OAUTH_PATHS.dup : paths
end

#package_identity(url) ⇒ Object

SPM's package identity: the URL's last path segment without a .git suffix (github.com/simibac/ConfettiSwiftUI → "ConfettiSwiftUI"). Used as the default product name and as the package: key in .product(name:package:).



560
# File 'lib/everywhere/config.rb', line 560

def package_identity(url) = File.basename(url.to_s.sub(%r{/+\z}, "")).sub(/\.git\z/, "")

#path_configuration_hash(os, tabs: nil) ⇒ Object

The full path-configuration document for one mobile platform — rules plus our settings (tabs live in settings, per Hotwire Native convention). Pass tabs: to override the resolved list (the mobile config endpoint passes a per-request-filtered list; build-time stamping omits it and bakes them all).



466
467
468
469
470
471
# File 'lib/everywhere/config.rb', line 466

def path_configuration_hash(os, tabs: nil)
  settings = {}
  platform_tabs = tabs || tabs_for(os)
  settings["tabs"] = platform_tabs unless platform_tabs.empty?
  { "settings" => settings, "rules" => MOBILE_PATH_RULES + mobile_rules(os) }
end

#path_configuration_json(os, tabs: nil) ⇒ Object



473
474
475
476
# File 'lib/everywhere/config.rb', line 473

def path_configuration_json(os, tabs: nil)
  require "json"
  JSON.generate(path_configuration_hash(os, tabs: tabs))
end

#permission_errors(os) ⇒ Object

Problems with the permissions declaration for one platform, as human-readable strings — unknown names, and camera/location missing the mandatory usage string. Empty means buildable.

Only the unknown-name half applies to Android: the usage string exists to satisfy iOS, which kills the app when a prompt has no Info.plist entry. Android just prompts, so requiring the sentence there would fail builds over a value nothing reads.



398
399
400
401
402
403
404
405
406
407
408
409
410
411
# File 'lib/everywhere/config.rb', line 398

def permission_errors(os)
  permissions.flat_map do |name, usage|
    unless MOBILE_PERMISSIONS.include?(name)
      next ["unknown permission #{name.inspect} — supported: #{MOBILE_PERMISSIONS.join(", ")}"]
    end

    if os == "ios" && IOS_USAGE_KEYS.key?(name) && usage["ios"].to_s.strip.empty?
      next ["#{name} needs a usage string — the sentence iOS shows when asking. " \
            "In config/everywhere.yml:\n  permissions:\n    #{name}: \"Why the app needs #{name}.\""]
    end

    []
  end
end

#permissionsObject



367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
# File 'lib/everywhere/config.rb', line 367

def permissions
  entries = @data["permissions"]
  return {} unless entries.is_a?(Hash)

  entries.each_with_object({}) do |(name, value), acc|
    next if value.nil? || value == false

    acc[name.to_s] =
      case value
      when String then { "ios" => value }
      when Hash then value.transform_keys(&:to_s).transform_values(&:to_s)
      else {}
      end
  end
end

#remote?Boolean

Returns:

  • (Boolean)


214
# File 'lib/everywhere/config.rb', line 214

def remote? = mode == "remote"

#remote_instances?Boolean

Multi-instance apps (remote.instances: true): the mobile shell boots into remote.url — a hosted instance-picker page — and lets that page re-root the app onto a chosen instance via Everywhere.instance.set, persisted across launches until Everywhere.instance.clear. Opt-in because it lets page JS repoint the whole app: apps that aren't multi-instance shouldn't carry that surface.

Returns:

  • (Boolean)


226
227
228
# File 'lib/everywhere/config.rb', line 226

def remote_instances?
  @data.dig("remote", "instances") == true
end

#remote_urlObject



216
217
218
# File 'lib/everywhere/config.rb', line 216

def remote_url
  @data.dig("remote", "url")&.chomp("/")
end

#splashObject

Optional custom splash page (HTML file, path relative to the app root). Shown while the packaged server boots; the shell injects window.EVERYWHERE_CONFIG so it can use the app's name and colors.



172
173
174
175
# File 'lib/everywhere/config.rb', line 172

def splash
  path = app["splash"]
  File.expand_path(path, root) if path
end

#tabsObject

Mobile tab bar, platform-neutral. Icons are per-platform (ios = SF Symbol, android = Material icon later), with a shared icon: fallback:

tabs:
- name: Builds
  path: /builds
  icons:
    ios: hammer
    android: build

Tabs ship two ways from the same source: baked into the app's bundled path-configuration.json at build time (offline/first-launch), and served live from /everywhere/_v1.json (MobileConfigEndpoint) so tab changes deploy with the web app — no app-store release.



288
289
290
291
292
293
294
295
296
297
298
299
# File 'lib/everywhere/config.rb', line 288

def tabs
  entries = @data["tabs"]
  return [] unless entries.is_a?(Array)

  entries.filter_map do |tab|
    next unless tab.is_a?(Hash) && tab["name"] && tab["path"]

    path = tab["path"].start_with?("/") ? tab["path"] : "/#{tab["path"]}"
    { "name" => tab["name"], "path" => path,
      "icon" => tab["icon"], "icons" => (tab["icons"] if tab["icons"].is_a?(Hash)) }.compact
  end
end

#tabs_for(os) ⇒ Object

Tabs with the icon resolved for one platform: icons., then the shared icon, then a safe default.



303
304
305
306
307
308
# File 'lib/everywhere/config.rb', line 303

def tabs_for(os)
  tabs.map do |tab|
    { "title" => tab["name"], "path" => tab["path"],
      "icon" => tab.dig("icons", os.to_s) || tab["icon"] || DEFAULT_TAB_ICONS[os.to_s] }.compact
  end
end

#targetsObject

Build targets (os-arch strings) — the CI matrix, expressed once.



206
# File 'lib/everywhere/config.rb', line 206

def targets = Array(build["targets"])

#tint_colorObject



266
267
268
# File 'lib/everywhere/config.rb', line 266

def tint_color
  normalize_color(appearance["tint_color"])
end

#to_shell_hash(target: nil) ⇒ Object

The subset the shell needs, shipped as JSON (env var in dev, Resources/everywhere.json inside a bundled .app). Pass target: so the packaged config reflects the platform being built (its bundle id / name).



1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
# File 'lib/everywhere/config.rb', line 1054

def to_shell_hash(target: nil)
  {
    "name" => name(target: target),
    "bundle_id" => bundle_id(target: target),
    "version" => version(target: target),
    "mode" => mode,
    "remote_url" => remote_url,
    "remote_instances" => (true if remote_instances?),
    "entry_path" => entry_path,
    "tint_color" => tint_color,
    "background_color" => background_color,
    "menu" => (menu unless menu.empty?),
    "tray" => (tray unless tray.empty?),
    "lazy_load_tabs" => native_lazy_load_tabs(target: target),
    "splash_min_seconds" => native_splash_min_seconds(target: target),
    "updates" => updates_shell_hash,
    "permissions" => (permissions.keys unless permissions.empty?),
    "auth" => auth_shell_hash,
    # Hosts the shell treats as its own for incoming universal links, so a
    # tapped link to any associated domain opens in-app rather than Safari.
    "universal_link_hosts" => (deep_linking_domains if deep_linking? && !deep_linking_domains.empty?)
  }.compact
end

#to_shell_json(target: nil) ⇒ Object



1078
1079
1080
1081
# File 'lib/everywhere/config.rb', line 1078

def to_shell_json(target: nil)
  require "json"
  JSON.generate(to_shell_hash(target: target))
end

#trayObject

System tray: same entry shape as menu: plus action: quit | show.

tray:
- label: "Open My App"
  action: show
- label: "New Note"
  path: /notes/new
- separator
- label: Quit
  action: quit


920
921
922
923
924
925
926
927
928
929
930
931
932
# File 'lib/everywhere/config.rb', line 920

def tray
  entries = @data["tray"]
  return [] unless entries.is_a?(Array)

  entries.filter_map do |item|
    if item == "separator" || (item.is_a?(Hash) && item["separator"])
      { "separator" => true }
    elsif item.is_a?(Hash) && item["label"] && (item["path"] || item["action"])
      { "label" => item["label"], "path" => item["path"],
        "action" => item["action"] }.compact
    end
  end
end

#updatesObject

The updates: section — self-hosted auto-updates. Two halves: a client half (url/channel/public_key/auto/interval) that ships to the shell, and a publish-side s3: half that NEVER leaves the dev machine. channel here is a release channel (stable/beta) — not the receipt's distribution_channel (direct vs app stores).



235
# File 'lib/everywhere/config.rb', line 235

def updates = @data.fetch("updates", nil) || {}

#updates_autoObject

off — never check; check — check + notify (default); download — also fetch/verify in the background; install — silent auto-update.



247
# File 'lib/everywhere/config.rb', line 247

def updates_auto = updates["auto"] || "check"

#updates_channelObject



239
# File 'lib/everywhere/config.rb', line 239

def updates_channel = updates["channel"] || "stable"

#updates_intervalObject



249
# File 'lib/everywhere/config.rb', line 249

def updates_interval = [(updates["interval"] || 21_600).to_i, 300].max

#updates_public_keyObject



243
# File 'lib/everywhere/config.rb', line 243

def updates_public_key = updates["public_key"]

#updates_s3Object



237
# File 'lib/everywhere/config.rb', line 237

def updates_s3 = updates.fetch("s3", nil) || {}

#updates_shell_hashObject

The client subset for the shell; nil (and thus absent from everywhere.json) until both url and public_key are configured — an unsigned update feed is not a thing.



254
255
256
257
258
259
260
261
262
263
264
# File 'lib/everywhere/config.rb', line 254

def updates_shell_hash
  return nil unless updates_url && updates_public_key

  {
    "url" => updates_url,
    "channel" => updates_channel,
    "public_key" => updates_public_key,
    "auto" => updates_auto,
    "interval" => updates_interval
  }
end

#updates_urlObject



241
# File 'lib/everywhere/config.rb', line 241

def updates_url = updates["url"]&.chomp("/")

#version(target: nil) ⇒ Object

Marketing/display version of the app (CFBundleShortVersionString, and the version recorded in the build receipt). One shared app.version is the default for every target; a platform may override it when a store forces a different number. Defaults conservatively.



186
187
188
# File 'lib/everywhere/config.rb', line 186

def version(target: nil)
  resolved(target)["version"] || "0.1.0"
end