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")
PERMITTED_YAML_CLASSES =

Dates, times and symbols are permitted rather than fatal: an unquoted version: 2026-07-25 or mode: :remote is a typo of a string, and every value here is read as one anyway — dying on it would be theatre.

[Date, Time, Symbol].freeze
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"
RUST_FN_NAME =

Everything here is interpolated into generated Rust and TOML, so names are validated hard — same reasoning as MAVEN_COORDINATE above. A command name becomes a path segment in a generate_handler! list; a crate name and its features become bare TOML keys and quoted strings.

/\A[a-z_][a-z0-9_]*\z/
CRATE_NAME =
/\A[a-zA-Z0-9_][a-zA-Z0-9_-]*\z/
CRATE_VERSION =

Cargo's requirement syntax, loosely: ^1.2, ~1.2, >=1.0, 1.*, 1.2.3-beta.1. Deliberately no spaces and no commas — a multi-requirement string would need quoting rules we don't want to own, and cargo reports anything subtler at build time.

/\A[\^~<>=]{0,2}\d+(\.\d+){0,2}(\.\*)?(-[0-9A-Za-z.-]+)?\z/
CRATE_FEATURE =
/\A[A-Za-z0-9_][A-Za-z0-9_+-]*\z/
CRATE_GIT_URL =

Where a git dependency may point. Same rule the SPM packages use.

PACKAGE_URL
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/
WINDOW_TITLE_BARS =

Desktop window chrome. Desktop-only: on mobile the OS owns the frame, so mobile builds ignore this section entirely.

window:
title_bar: overlay   # decorated (default) | overlay | frameless
title: false         # draw the title text (default true)
size: [1100, 750]    # initial inner size
min_size: [600, 400]
resizable: true
drag_height: 28      # top strip that drags the window; 0 turns it off

Every reader returns nil when the key is absent so to_shell_hash drops it and the shell keeps its own default — there is exactly one place each default lives, and it's the Rust side.

overlay is the macOS "traffic lights floating over your content" look (TitleBarStyle::Overlay + hidden title). Windows and Linux have no such style, so the shell falls back to frameless there and the page draws its own controls via Everywhere.window.

%w[decorated overlay frameless].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(data, root) ⇒ Config

Returns a new instance of Config.



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

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

Instance Attribute Details

#rootObject (readonly)

Returns the value of attribute root.



230
231
232
# File 'lib/everywhere/config.rb', line 230

def root
  @root
end

Class Method Details

.channel_of(target) ⇒ Object

A target may carry a ":" suffix ("ios-arm64:testflight") that selects the distribution channel for THAT target. nil when it doesn't.



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

def self.channel_of(target)
  suffix = target.to_s.split(":", 2)[1]
  suffix unless suffix.nil? || suffix.empty?
end

.load(root = Dir.pwd) ⇒ Object



207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
# File 'lib/everywhere/config.rb', line 207

def self.load(root = Dir.pwd)
  path = File.join(root, FILE)
  return new({}, root) unless File.exist?(path)

  data = YAML.safe_load_file(path, aliases: true, permitted_classes: PERMITTED_YAML_CLASSES)
  data = {} if data.nil? || data == false
  unless data.is_a?(Hash)
    # Raise, never die!: this also runs inside the packaged app's request
    # middleware, where a SystemExit would take the whole server down —
    # runtime callers rescue and keep the last good config, and the CLI's
    # top-level handler renders an Error just as cleanly.
    raise Everywhere::Error,
          "#{UI.short_path(path)} must be a mapping of sections (app:, build:, …), not a #{data.class}"
  end

  new(data, root)
rescue Psych::Exception => e
  # Psych prefixes syntax errors with the absolute path; the line/column
  # tail is the useful half, and we've already named the file.
  detail = e.message.sub(/\A\(#{Regexp.escape(path)}\):\s*/, "")
  raise Everywhere::Error, "#{UI.short_path(path)} isn't valid YAML: #{detail}"
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.



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

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.



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

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.



565
566
567
568
569
570
571
# File 'lib/everywhere/config.rb', line 565

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.



1316
1317
1318
1319
1320
1321
1322
1323
1324
# File 'lib/everywhere/config.rb', line 1316

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



1326
1327
1328
1329
1330
# File 'lib/everywhere/config.rb', line 1326

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.



1333
1334
1335
1336
1337
1338
1339
1340
# File 'lib/everywhere/config.rb', line 1333

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


1342
1343
1344
1345
1346
# File 'lib/everywhere/config.rb', line 1342

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.



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

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)


1074
1075
1076
1077
1078
1079
1080
1081
1082
# File 'lib/everywhere/config.rb', line 1074

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



1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
# File 'lib/everywhere/config.rb', line 1088

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



1065
1066
1067
1068
# File 'lib/everywhere/config.rb', line 1065

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.



1108
1109
1110
1111
1112
# File 'lib/everywhere/config.rb', line 1108

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.



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

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

#background_colorObject



382
383
384
# File 'lib/everywhere/config.rb', line 382

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.



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

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.



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

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

#build_rubyObject

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



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

def build_ruby = build["ruby"]

#build_target_specsObject

The raw build.targets entries, suffixes intact ("ios-arm64:testflight").



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

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

#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).



258
259
260
261
# File 'lib/everywhere/config.rb', line 258

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:..."]


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

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

#deep_linking?Boolean

Returns:

  • (Boolean)


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

def deep_linking? = deep_linking_apple? || deep_linking_android?

#deep_linking_androidObject



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

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

#deep_linking_android?Boolean

Returns:

  • (Boolean)


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

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

#deep_linking_android_fingerprintsObject



1288
1289
1290
# File 'lib/everywhere/config.rb', line 1288

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



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

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

#deep_linking_apple?Boolean

Returns:

  • (Boolean)


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

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.



1270
1271
1272
1273
1274
1275
1276
# File 'lib/everywhere/config.rb', line 1270

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).



1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
# File 'lib/everywhere/config.rb', line 1301

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.



1280
1281
1282
1283
# File 'lib/everywhere/config.rb', line 1280

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

#entry_pathObject



282
283
284
285
# File 'lib/everywhere/config.rb', line 282

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.



265
266
267
268
269
270
271
272
# File 'lib/everywhere/config.rb', line 265

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.



1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
# File 'lib/everywhere/config.rb', line 1117

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).



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

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



322
323
324
# File 'lib/everywhere/config.rb', line 322

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

#name(target: nil) ⇒ Object



250
251
252
# File 'lib/everywhere/config.rb', line 250

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"


754
755
756
757
# File 'lib/everywhere/config.rb', line 754

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

#native_android?Boolean

Returns:

  • (Boolean)


804
805
# File 'lib/everywhere/config.rb', line 804

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

#native_android_componentsObject



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

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

#native_android_errorsObject



813
814
815
816
817
818
819
820
821
822
823
824
825
# File 'lib/everywhere/config.rb', line 813

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



881
882
883
884
# File 'lib/everywhere/config.rb', line 881

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.



890
891
892
# File 'lib/everywhere/config.rb', line 890

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

#native_android_icon_font_errorsObject



904
905
906
907
908
909
# File 'lib/everywhere/config.rb', line 904

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)


897
898
899
900
# File 'lib/everywhere/config.rb', line 897

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".



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

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

#native_android_icons?Boolean

Returns:

  • (Boolean)


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

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.



782
783
784
785
# File 'lib/everywhere/config.rb', line 782

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

#native_android_package_errorsObject



838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
# File 'lib/everywhere/config.rb', line 838

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).



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

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)


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

def native_android_packages? = !native_android_packages.empty?

#native_android_screensObject



761
762
763
764
765
766
# File 'lib/everywhere/config.rb', line 761

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



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

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.



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

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_desktopObject

The desktop half of "supernative": Rust the app repo carries in native/desktop/ that every build compiles into the Tauri shell.

The shape differs from ios/android because desktop differs: the whole UI is a webview, so there are no native screens or splash views to register. What an app actually wants down here is the machine — a serial port, a USB device, an FFI library, a Tauri plugin — reached from the page.

native:
desktop:
  commands: [scan_ports]        # fns reachable from the page
  setup: true                   # run native/desktop/setup.rs at boot
  crates:
    serialport: "4.3"
    reqwest: { version: "0.12", features: [json] }

A command is a plain function, NOT a #[tauri::command] — Tauri's typed commands only arrive through invoke, and invoke is unavailable to our pages (they're served over http; see extension_host.rs in the shell). The signature is uniform:

pub fn scan_ports(app: &tauri::AppHandle, payload: serde_json::Value)
  -> Result<serde_json::Value, String>

Declaring anything here has a real cost the mobile sections don't have: the shell stops being one prebuilt crate shared by every app and gets stamped and compiled per app (see Builders::Desktop). Apps that declare nothing keep the fast path.



939
940
941
942
# File 'lib/everywhere/config.rb', line 939

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

#native_desktop?Boolean

Returns:

  • (Boolean)


961
962
# File 'lib/everywhere/config.rb', line 961

def native_desktop? = !(native_desktop_commands.empty? && native_desktop_crates.empty?) ||
native_desktop_setup?

#native_desktop_commandsObject



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

def native_desktop_commands = Array(native_desktop["commands"]).map { |name| name.to_s.strip }

#native_desktop_crate_errorsObject



1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
# File 'lib/everywhere/config.rb', line 1005

def native_desktop_crate_errors
  raw = native_desktop["crates"]
  return [] if raw.nil?
  return ["native.desktop.crates must be a mapping of crate name to version"] unless raw.is_a?(Hash)

  raw.flat_map do |name, spec|
    at = "native.desktop.crates.#{name}"
    errors = []
    unless name.to_s.strip.match?(CRATE_NAME)
      errors << "#{at}: #{name.to_s.inspect} is not a crate name (letters, digits, _, -)"
    end
    errors + crate_spec_errors(at, spec)
  end
end

#native_desktop_cratesObject

Cargo dependencies, normalized to { "name" => Everywhere::Config......table } so the generator has one shape to write. The short form (a bare version string) becomes { "version" => "4.3" }.



951
952
953
954
955
956
957
958
959
# File 'lib/everywhere/config.rb', line 951

def native_desktop_crates
  entries = native_desktop["crates"]
  return {} unless entries.is_a?(Hash)

  entries.to_h do |name, spec|
    table = spec.is_a?(Hash) ? spec : { "version" => spec.to_s }
    [name.to_s.strip, table]
  end
end

#native_desktop_errorsObject



979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
# File 'lib/everywhere/config.rb', line 979

def native_desktop_errors
  raw = @data.dig("native", "desktop")
  return [] if raw.nil?
  return ["native.desktop must be a mapping"] unless raw.is_a?(Hash)

  errors = []
  commands = raw["commands"]
  if !commands.nil? && !commands.is_a?(Array)
    errors << "native.desktop.commands must be a list of function names"
  else
    errors += native_desktop_commands.reject { |name| name.match?(RUST_FN_NAME) }.map do |name|
      "native.desktop.commands: #{name.inspect} is not a Rust function name — " \
        "snake_case (letters, digits, _), matching the fn in native/desktop/"
    end
    duplicates = native_desktop_commands.tally.select { |_name, count| count > 1 }.keys
    errors += duplicates.map do |name|
      "native.desktop.commands: #{name.inspect} is listed twice"
    end
  end

  setup = raw["setup"]
  errors << "native.desktop.setup must be true or false" unless setup.nil? || boolean_or_nil(setup) == setup

  errors + native_desktop_crate_errors
end

#native_desktop_setup?Boolean

Returns:

  • (Boolean)


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

def native_desktop_setup? = native_desktop["setup"] == true

#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


603
604
605
606
# File 'lib/everywhere/config.rb', line 603

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

#native_ios?Boolean

Returns:

  • (Boolean)


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

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

#native_ios_componentsObject



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

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

#native_ios_errorsObject



682
683
684
685
686
687
688
689
690
691
692
693
694
# File 'lib/everywhere/config.rb', line 682

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).



632
633
634
635
# File 'lib/everywhere/config.rb', line 632

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

#native_ios_package_errorsObject



702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
# File 'lib/everywhere/config.rb', line 702

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



652
653
654
655
656
657
658
659
660
661
662
663
664
665
# File 'lib/everywhere/config.rb', line 652

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)


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

def native_ios_packages? = !native_ios_packages.empty?

#native_ios_screensObject



610
611
612
613
614
615
# File 'lib/everywhere/config.rb', line 610

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



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

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.



622
623
624
625
# File 'lib/everywhere/config.rb', line 622

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.



1357
1358
1359
# File 'lib/everywhere/config.rb', line 1357

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



1361
1362
1363
# File 'lib/everywhere/config.rb', line 1361

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)


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

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)


1054
1055
1056
1057
1058
# File 'lib/everywhere/config.rb', line 1054

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

#oauth_pathsObject



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

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:).



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

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).



578
579
580
581
582
583
# File 'lib/everywhere/config.rb', line 578

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



585
586
587
588
# File 'lib/everywhere/config.rb', line 585

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.



510
511
512
513
514
515
516
517
518
519
520
521
522
523
# File 'lib/everywhere/config.rb', line 510

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



479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
# File 'lib/everywhere/config.rb', line 479

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)


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

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)


338
339
340
# File 'lib/everywhere/config.rb', line 338

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

#remote_urlObject



328
329
330
# File 'lib/everywhere/config.rb', line 328

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.



277
278
279
280
# File 'lib/everywhere/config.rb', line 277

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.



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

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.



415
416
417
418
419
420
# File 'lib/everywhere/config.rb', line 415

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 as BARE os-arch strings — any ":" suffix is stripped. Callers treat these as filesystem/URL-safe identifiers (every publish derives S3 update-manifest key paths from the first one), so a suffix leaking through here would poison those paths. Use #build_target_specs when the channel matters.



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

def targets = build_target_specs.map { |t| t.to_s.split(":", 2).first }

#tint_colorObject



378
379
380
# File 'lib/everywhere/config.rb', line 378

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).



1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
# File 'lib/everywhere/config.rb', line 1368

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?),
    "window" => window_shell_hash,
    "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



1393
1394
1395
1396
# File 'lib/everywhere/config.rb', line 1393

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


1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
# File 'lib/everywhere/config.rb', line 1141

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).



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

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.



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

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

#updates_channelObject



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

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

#updates_intervalObject



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

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

#updates_public_keyObject



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

def updates_public_key = updates["public_key"]

#updates_s3Object



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

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.



366
367
368
369
370
371
372
373
374
375
376
# File 'lib/everywhere/config.rb', line 366

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



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

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.



291
292
293
# File 'lib/everywhere/config.rb', line 291

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

#windowObject



1176
1177
1178
1179
# File 'lib/everywhere/config.rb', line 1176

def window
  section = @data["window"]
  section.is_a?(Hash) ? section : {}
end

#window_drag_heightObject

How much of the top of the page drags the window, in CSS pixels. Only meaningful for overlay/frameless, where there's no system title bar left to grab. nil leaves the shell's 28px default (the height of the macOS traffic-light band); 0 turns dragging off for an app that wants to place its own drag regions.



1199
1200
1201
1202
# File 'lib/everywhere/config.rb', line 1199

def window_drag_height
  value = window["drag_height"]
  value.to_f if value.is_a?(Numeric) && !value.negative?
end

#window_errorsObject



1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
# File 'lib/everywhere/config.rb', line 1218

def window_errors
  raw = @data["window"]
  return [] if raw.nil?
  return ["window: must be a mapping"] unless raw.is_a?(Hash)

  errors = []
  bar = raw["title_bar"]
  if bar && !WINDOW_TITLE_BARS.include?(bar.to_s)
    errors << "window.title_bar #{bar.to_s.inspect} must be one of " \
              "#{WINDOW_TITLE_BARS.join(" / ")}"
  end

  errors += %w[size min_size].filter_map do |key|
    next if raw[key].nil? || window_dimensions(key)

    "window.#{key} must be two positive numbers, like [1100, 750]"
  end

  drag = raw["drag_height"]
  unless drag.nil? || (drag.is_a?(Numeric) && !drag.negative?)
    errors << "window.drag_height must be a number of pixels (0 turns dragging off)"
  end

  errors + %w[title resizable].filter_map do |key|
    next if raw[key].nil? || boolean_or_nil(raw[key]) == raw[key]

    "window.#{key} must be true or false"
  end
end

#window_min_sizeObject



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

def window_min_size = window_dimensions("min_size")

#window_resizableObject



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

def window_resizable = boolean_or_nil(window["resizable"])

#window_shell_hashObject

The window subset the shell needs. nil (absent from everywhere.json) when the app declares nothing, so the shell's own defaults stand untouched.



1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
# File 'lib/everywhere/config.rb', line 1206

def window_shell_hash
  hash = {
    "title_bar" => window_title_bar,
    "title" => window_title,
    "resizable" => window_resizable,
    "size" => window_size,
    "min_size" => window_min_size,
    "drag_height" => window_drag_height
  }.compact
  hash unless hash.empty?
end

#window_sizeObject



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

def window_size = window_dimensions("size")

#window_titleObject

Tri-state, like native_ios_lazy_load_tabs: an absent key leaves the shell on its default rather than forcing one.



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

def window_title = boolean_or_nil(window["title"])

#window_title_barObject



1181
1182
1183
1184
# File 'lib/everywhere/config.rb', line 1181

def window_title_bar
  value = window["title_bar"].to_s
  value if WINDOW_TITLE_BARS.include?(value)
end