Module: Everywhere::Config::Mobile

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

Constant Summary collapse

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

Instance Method Summary collapse

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.



118
119
120
# File 'lib/everywhere/config/mobile.rb', line 118

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.



185
186
187
188
189
190
191
# File 'lib/everywhere/config/mobile.rb', line 185

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

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



160
161
162
163
164
165
166
167
168
169
170
171
# File 'lib/everywhere/config/mobile.rb', line 160

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

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



198
199
200
201
202
203
# File 'lib/everywhere/config/mobile.rb', line 198

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



205
206
207
208
# File 'lib/everywhere/config/mobile.rb', line 205

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.



130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/everywhere/config/mobile.rb', line 130

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



99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/everywhere/config/mobile.rb', line 99

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

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



20
21
22
23
24
25
26
27
28
29
30
31
# File 'lib/everywhere/config/mobile.rb', line 20

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.



35
36
37
38
39
40
# File 'lib/everywhere/config/mobile.rb', line 35

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