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 answersundeclaredfor 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 withtrue.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
-
#android_manifest_permissions ⇒ Object
The manifest permissions an Android build declares, in declaration order.
-
#android_screen_properties(properties) ⇒ Object
Hotwire Native iOS picks a native screen with
view_controller: <id>; Android picks one withuri: hotwire://fragment/<id>, matched against the Fragment's own @HotwireDestinationDeepLink annotation. -
#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:. -
#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).
- #path_configuration_json(os, tabs: nil) ⇒ Object
-
#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.
- #permissions ⇒ Object
-
#tabs ⇒ Object
Mobile tab bar, platform-neutral.
-
#tabs_for(os) ⇒ Object
Tabs with the icon resolved for one platform: icons.
, then the shared icon, then a safe default.
Instance Method Details
#android_manifest_permissions ⇒ Object
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 .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 (os) .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 |
#permissions ⇒ Object
99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
# File 'lib/everywhere/config/mobile.rb', line 99 def 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 |
#tabs ⇒ Object
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/
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.
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 |