Module: Everywhere::Config::NativeMobile

Included in:
Everywhere::Config
Defined in:
lib/everywhere/config/native_mobile.rb

Constant Summary collapse

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"
NATIVE_LANGUAGES =

The language each shell's extensions are written in, named in the messages because that's what the app author is looking at, and the pattern their type names must match — one entry per platform so the two can still diverge without either one's half changing.

{ "ios" => "Swift", "android" => "Kotlin" }.freeze
NATIVE_TYPE_NAMES =
{ "ios" => SWIFT_TYPE_NAME, "android" => KOTLIN_TYPE_NAME }.freeze

Instance Method Summary collapse

Instance Method Details

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


144
# File 'lib/everywhere/config/native_mobile.rb', line 144

def native_android = native_section("android")

#native_android?Boolean

Returns:

  • (Boolean)


180
# File 'lib/everywhere/config/native_mobile.rb', line 180

def native_android? = native_declared?("android")

#native_android_componentsObject



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

def native_android_components = native_components("android")

#native_android_errorsObject



188
189
190
# File 'lib/everywhere/config/native_mobile.rb', line 188

def native_android_errors
  native_type_errors("android") + native_android_icon_font_errors + native_android_package_errors
end

#native_android_icon_fontObject



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

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.



255
256
257
# File 'lib/everywhere/config/native_mobile.rb', line 255

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

#native_android_icon_font_errorsObject



269
270
271
272
273
274
# File 'lib/everywhere/config/native_mobile.rb', line 269

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)


262
263
264
265
# File 'lib/everywhere/config/native_mobile.rb', line 262

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



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

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

#native_android_icons?Boolean

Returns:

  • (Boolean)


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

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.



161
# File 'lib/everywhere/config/native_mobile.rb', line 161

def native_android_lazy_load_tabs = native_lazy_load("android")

#native_android_package_errorsObject



203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
# File 'lib/everywhere/config/native_mobile.rb', line 203

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



169
170
171
172
173
174
175
176
# File 'lib/everywhere/config/native_mobile.rb', line 169

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)


178
# File 'lib/everywhere/config/native_mobile.rb', line 178

def native_android_packages? = !native_android_packages.empty?

#native_android_screensObject



148
# File 'lib/everywhere/config/native_mobile.rb', line 148

def native_android_screens = native_screens("android")

#native_android_splashObject



150
# File 'lib/everywhere/config/native_mobile.rb', line 150

def native_android_splash = native_splash("android")

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



155
# File 'lib/everywhere/config/native_mobile.rb', line 155

def native_android_splash_min_seconds = native_splash_seconds("android")

#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


19
# File 'lib/everywhere/config/native_mobile.rb', line 19

def native_ios = native_section("ios")

#native_ios?Boolean

Returns:

  • (Boolean)


76
# File 'lib/everywhere/config/native_mobile.rb', line 76

def native_ios? = native_declared?("ios")

#native_ios_componentsObject



21
# File 'lib/everywhere/config/native_mobile.rb', line 21

def native_ios_components = native_components("ios")

#native_ios_errorsObject



84
# File 'lib/everywhere/config/native_mobile.rb', line 84

def native_ios_errors = native_type_errors("ios") + native_ios_package_errors

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



37
# File 'lib/everywhere/config/native_mobile.rb', line 37

def native_ios_lazy_load_tabs = native_lazy_load("ios")

#native_ios_package_errorsObject



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/everywhere/config/native_mobile.rb', line 92

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



54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/everywhere/config/native_mobile.rb', line 54

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)


69
# File 'lib/everywhere/config/native_mobile.rb', line 69

def native_ios_packages? = !native_ios_packages.empty?

#native_ios_screensObject



23
# File 'lib/everywhere/config/native_mobile.rb', line 23

def native_ios_screens = native_screens("ios")

#native_ios_splashObject



25
# File 'lib/everywhere/config/native_mobile.rb', line 25

def native_ios_splash = native_splash("ios")

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



30
# File 'lib/everywhere/config/native_mobile.rb', line 30

def native_ios_splash_min_seconds = native_splash_seconds("ios")

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



74
# File 'lib/everywhere/config/native_mobile.rb', line 74

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