ruby_everywhere 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/exe/every +2 -13
- data/exe/rbe +2 -13
- data/lib/everywhere/boot.rb +9 -2
- data/lib/everywhere/builders/android.rb +16 -42
- data/lib/everywhere/builders/base.rb +53 -0
- data/lib/everywhere/builders/desktop.rb +7 -23
- data/lib/everywhere/builders/ios.rb +49 -52
- data/lib/everywhere/builders/native_sources.rb +38 -0
- data/lib/everywhere/child_processes.rb +4 -4
- data/lib/everywhere/child_supervision.rb +172 -0
- data/lib/everywhere/clock.rb +12 -0
- data/lib/everywhere/commands/build.rb +20 -9
- data/lib/everywhere/commands/dev.rb +59 -284
- data/lib/everywhere/commands/doctor.rb +6 -6
- data/lib/everywhere/commands/install.rb +1 -0
- data/lib/everywhere/commands/platform/auth_status.rb +1 -0
- data/lib/everywhere/commands/platform/build.rb +5 -6
- data/lib/everywhere/commands/platform/login.rb +4 -4
- data/lib/everywhere/commands/platform/logout.rb +1 -0
- data/lib/everywhere/commands/platform/runner.rb +62 -7
- data/lib/everywhere/commands/preview.rb +24 -97
- data/lib/everywhere/commands/publish.rb +2 -0
- data/lib/everywhere/commands/release.rb +14 -11
- data/lib/everywhere/commands/shell_dir.rb +2 -0
- data/lib/everywhere/commands/updates_keygen.rb +25 -1
- data/lib/everywhere/config/app.rb +126 -0
- data/lib/everywhere/config/auth.rb +108 -0
- data/lib/everywhere/config/data.rb +50 -0
- data/lib/everywhere/config/deep_linking.rb +107 -0
- data/lib/everywhere/config/desktop_ui.rb +153 -0
- data/lib/everywhere/config/mobile.rb +211 -0
- data/lib/everywhere/config/native_desktop.rb +168 -0
- data/lib/everywhere/config/native_mobile.rb +337 -0
- data/lib/everywhere/config/shell.rb +57 -0
- data/lib/everywhere/config/updates.rb +63 -0
- data/lib/everywhere/config.rb +30 -1423
- data/lib/everywhere/desktop_dev_app.rb +138 -0
- data/lib/everywhere/dock/state.rb +3 -1
- data/lib/everywhere/entrypoint.rb +24 -0
- data/lib/everywhere/error.rb +8 -0
- data/lib/everywhere/framework.rb +2 -2
- data/lib/everywhere/host.rb +11 -0
- data/lib/everywhere/ignore.rb +11 -5
- data/lib/everywhere/line_pump.rb +3 -1
- data/lib/everywhere/minisign.rb +1 -0
- data/lib/everywhere/native_platform.rb +44 -0
- data/lib/everywhere/paths.rb +23 -7
- data/lib/everywhere/platform/client.rb +19 -1
- data/lib/everywhere/platform/snapshot.rb +6 -4
- data/lib/everywhere/plist.rb +17 -0
- data/lib/everywhere/png.rb +1 -0
- data/lib/everywhere/raw_tty.rb +51 -0
- data/lib/everywhere/s3.rb +1 -0
- data/lib/everywhere/shell_pages.rb +109 -0
- data/lib/everywhere/tab_filter.rb +36 -0
- data/lib/everywhere/task_pool.rb +3 -4
- data/lib/everywhere/ui.rb +6 -1
- data/lib/everywhere/version.rb +6 -4
- data/lib/everywhere.rb +1 -2
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +0 -2
- data/support/release/macos/notarize.sh +3 -0
- metadata +24 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Everywhere
|
|
4
|
+
class Config
|
|
5
|
+
module Auth
|
|
6
|
+
# Third-party sign-in (Sign in with Apple / Google / GitHub / anything
|
|
7
|
+
# OmniAuth speaks). Providers refuse to run — or run badly — inside an app's
|
|
8
|
+
# web view, so the mobile shell hands these paths to an
|
|
9
|
+
# ASWebAuthenticationSession instead, and the gem bridges the resulting
|
|
10
|
+
# session back into the app (see AuthHandoff).
|
|
11
|
+
#
|
|
12
|
+
# auth:
|
|
13
|
+
# oauth_paths: # paths that begin a provider flow
|
|
14
|
+
# - ^/auth/ # unanchored regexes, like `rules:`
|
|
15
|
+
# scheme: com.example.app # callback scheme (default: the iOS bundle id)
|
|
16
|
+
# cookies: # which cookies cross back into the app
|
|
17
|
+
# except: ["_ga"] # (default: all of them)
|
|
18
|
+
#
|
|
19
|
+
# The app keeps its own auth: `auth:` declares nothing about providers,
|
|
20
|
+
# only which paths the shell must not open in its web view.
|
|
21
|
+
def auth = @data.fetch("auth", nil) || {}
|
|
22
|
+
|
|
23
|
+
# Paths that begin a third-party auth flow, as regex source strings matched
|
|
24
|
+
# against the request path. Declaring `auth:` at all opts in, defaulting to
|
|
25
|
+
# OmniAuth's `/auth/…` convention.
|
|
26
|
+
DEFAULT_OAUTH_PATHS = ["^/auth/"].freeze
|
|
27
|
+
|
|
28
|
+
def oauth_paths
|
|
29
|
+
return [] unless @data.key?("auth")
|
|
30
|
+
|
|
31
|
+
paths = Array(auth["oauth_paths"]).map { |p| p.to_s.strip }.reject(&:empty?)
|
|
32
|
+
paths.empty? ? DEFAULT_OAUTH_PATHS.dup : paths
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def oauth? = !oauth_paths.empty?
|
|
36
|
+
|
|
37
|
+
# Whether a request path starts a provider flow. Both halves of the system
|
|
38
|
+
# ask this — the middleware per request, the shell per visit proposal — so
|
|
39
|
+
# the patterns are compiled the same way on both sides (unanchored regex).
|
|
40
|
+
def oauth_path?(path)
|
|
41
|
+
# Compiled once per Config, not once per request: the middleware calls
|
|
42
|
+
# this on every navigation, and the instance it holds lives until
|
|
43
|
+
# everywhere.yml changes on disk. An unparseable pattern matches nothing
|
|
44
|
+
# (auth_errors is what reports it).
|
|
45
|
+
@oauth_regexps ||= oauth_paths.filter_map do |pattern|
|
|
46
|
+
Regexp.new(pattern)
|
|
47
|
+
rescue RegexpError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
@oauth_regexps.any? { |regexp| regexp.match?(path.to_s) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The custom URL scheme ASWebAuthenticationSession returns through. Defaults
|
|
54
|
+
# to the iOS bundle id — the convention, and already unique per app. Only
|
|
55
|
+
# the characters RFC 3986 allows in a scheme.
|
|
56
|
+
URL_SCHEME = /\A[a-zA-Z][a-zA-Z0-9+.-]*\z/
|
|
57
|
+
|
|
58
|
+
def auth_scheme
|
|
59
|
+
explicit = auth["scheme"].to_s.strip
|
|
60
|
+
explicit.empty? ? bundle_id(target: "ios") : explicit
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Cookie names to carry from the auth browser back into the app's web view,
|
|
64
|
+
# filtered by an optional allow/deny list. Everything the flow set is
|
|
65
|
+
# carried by default: the gem can't know which cookie an app's auth library
|
|
66
|
+
# signs its session with.
|
|
67
|
+
def auth_cookie?(name)
|
|
68
|
+
cookies = auth["cookies"]
|
|
69
|
+
return true unless cookies.is_a?(Hash)
|
|
70
|
+
|
|
71
|
+
only = Array(cookies["only"]).map(&:to_s)
|
|
72
|
+
return only.include?(name.to_s) unless only.empty?
|
|
73
|
+
|
|
74
|
+
!Array(cookies["except"]).map(&:to_s).include?(name.to_s)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# How long a minted handoff token stays valid. It travels device-locally
|
|
78
|
+
# (browser sheet → shell → web view), so seconds are plenty.
|
|
79
|
+
def auth_token_ttl = [(auth["token_ttl"] || 60).to_i, 5].max
|
|
80
|
+
|
|
81
|
+
def auth_errors
|
|
82
|
+
return [] unless @data.key?("auth")
|
|
83
|
+
|
|
84
|
+
errors = oauth_paths.filter_map do |pattern|
|
|
85
|
+
Regexp.new(pattern)
|
|
86
|
+
nil
|
|
87
|
+
rescue RegexpError => e
|
|
88
|
+
"auth.oauth_paths: #{pattern.inspect} is not a valid pattern (#{e.message})"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
unless auth_scheme.match?(URL_SCHEME)
|
|
92
|
+
errors << "auth.scheme #{auth_scheme.inspect} is not a URL scheme (letters, digits, +, -, .)"
|
|
93
|
+
end
|
|
94
|
+
errors
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The auth subset the shell needs: which visits to divert into the auth
|
|
98
|
+
# session, and the scheme to bring the answer back through. nil (absent from
|
|
99
|
+
# everywhere.json) when the app declares no `auth:` — a shell that can't
|
|
100
|
+
# divert anything is a shell with no new surface.
|
|
101
|
+
def auth_shell_hash
|
|
102
|
+
return nil unless oauth?
|
|
103
|
+
|
|
104
|
+
{ "oauth_paths" => oauth_paths, "scheme" => auth_scheme }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Everywhere
|
|
4
|
+
class Config
|
|
5
|
+
# The plumbing every other section reads through: the raw `app:` and
|
|
6
|
+
# `appearance:` hashes, the per-platform overrides that sit on top of them,
|
|
7
|
+
# and the two value coercions shared across sections. Included first, so
|
|
8
|
+
# everything below it can call these.
|
|
9
|
+
module Data
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def app = @data.fetch("app", nil) || {}
|
|
13
|
+
def appearance = @data.fetch("appearance", nil) || {}
|
|
14
|
+
|
|
15
|
+
# YAML's `yes`/`on` already parse as true, so anything that reaches here
|
|
16
|
+
# still not a boolean is a genuine mistake rather than a spelling of one.
|
|
17
|
+
# Deliberately not an endless def: `def f(v) = v if cond` parses as
|
|
18
|
+
# `(def f(v) = v) if cond`, which would define nothing when cond is false.
|
|
19
|
+
def boolean_or_nil(value)
|
|
20
|
+
value if value == true || value == false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def android_target?(target) = self.class.os_of(target) == "android"
|
|
24
|
+
|
|
25
|
+
# Per-platform overrides, keyed by os (macos / ios / android / windows /
|
|
26
|
+
# linux). Each value overrides the matching `app:` keys for that platform.
|
|
27
|
+
def platforms = @data.fetch("platforms", nil) || {}
|
|
28
|
+
|
|
29
|
+
# The app-level keys with the target's platform override merged on top.
|
|
30
|
+
# Blank override values are dropped (`.compact`) so they don't erase a
|
|
31
|
+
# default. Without a target — or without an override for that os — this is
|
|
32
|
+
# just the plain `app:` hash.
|
|
33
|
+
def resolved(target)
|
|
34
|
+
override = target && platforms[self.class.os_of(target)]
|
|
35
|
+
override.is_a?(Hash) ? app.merge(override.compact) : app
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def normalize_color(value)
|
|
39
|
+
case value
|
|
40
|
+
when String
|
|
41
|
+
{ "light" => value, "dark" => value }
|
|
42
|
+
when Hash
|
|
43
|
+
light = value["light"] || value["dark"]
|
|
44
|
+
dark = value["dark"] || value["light"]
|
|
45
|
+
{ "light" => light, "dark" => dark } if light
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Everywhere
|
|
4
|
+
class Config
|
|
5
|
+
module DeepLinking
|
|
6
|
+
# Deep linking / universal links. Declares the app's association with its
|
|
7
|
+
# web domains so the OS can hand matching URLs to the app instead of the
|
|
8
|
+
# browser. The gem serves the two well-known association files this needs —
|
|
9
|
+
# /.well-known/apple-app-site-association and /.well-known/assetlinks.json —
|
|
10
|
+
# generated from this config (Everywhere::Engine in Rails; MobileConfigEndpoint
|
|
11
|
+
# for Sinatra/Hanami). The iOS builder stamps the matching Associated Domains
|
|
12
|
+
# entitlement, and the shell routes an incoming link to its path.
|
|
13
|
+
#
|
|
14
|
+
# deep_linking:
|
|
15
|
+
# team_id: ABCDE12345 # derives the iOS app id from bundle_id
|
|
16
|
+
# # or set app ids explicitly:
|
|
17
|
+
# # apple_app_id: ABCDE12345.com.example.app
|
|
18
|
+
# # apple_app_ids: ["ABCDE12345.com.example.app"]
|
|
19
|
+
# paths: ["/*"] # which paths open the app (default: all)
|
|
20
|
+
# domains: ["www.example.com"] # extra applinks: domains (remote host is implicit)
|
|
21
|
+
# android:
|
|
22
|
+
# package: com.example.app
|
|
23
|
+
# sha256_cert_fingerprints: ["AB:CD:EF:..."]
|
|
24
|
+
def deep_linking = @data.fetch("deep_linking", nil) || {}
|
|
25
|
+
|
|
26
|
+
# The iOS/tvOS/... app identifiers (TeamID.bundleID) the association file
|
|
27
|
+
# advertises. Explicit ids win; otherwise team_id + the app's iOS bundle id.
|
|
28
|
+
def deep_linking_apple_app_ids
|
|
29
|
+
explicit = Array(deep_linking["apple_app_ids"]) + [deep_linking["apple_app_id"]].compact
|
|
30
|
+
return explicit.map(&:to_s).uniq unless explicit.empty?
|
|
31
|
+
|
|
32
|
+
team = deep_linking["team_id"].to_s.strip
|
|
33
|
+
team.empty? ? [] : ["#{team}.#{bundle_id(target: "ios")}"]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# URL path patterns that open the app. Modern (iOS 14+) component form;
|
|
37
|
+
# defaults to every path.
|
|
38
|
+
def deep_linking_paths
|
|
39
|
+
paths = Array(deep_linking["paths"]).map(&:to_s).reject(&:empty?)
|
|
40
|
+
paths.empty? ? ["*"] : paths
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def deep_linking_android = deep_linking["android"].is_a?(Hash) ? deep_linking["android"] : {}
|
|
44
|
+
def deep_linking_android_package = deep_linking_android["package"]&.to_s
|
|
45
|
+
|
|
46
|
+
def deep_linking_android_fingerprints
|
|
47
|
+
Array(deep_linking_android["sha256_cert_fingerprints"]).map { |f| f.to_s.strip }.reject(&:empty?)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def deep_linking_android? = !deep_linking_android_package.to_s.empty? && !deep_linking_android_fingerprints.empty?
|
|
51
|
+
|
|
52
|
+
def deep_linking_apple? = !deep_linking_apple_app_ids.empty?
|
|
53
|
+
|
|
54
|
+
def deep_linking? = deep_linking_apple? || deep_linking_android?
|
|
55
|
+
|
|
56
|
+
# Domains for the iOS Associated Domains entitlement (applinks:<host>) and
|
|
57
|
+
# the shell's universal-link host allowlist: the remote host plus any extra
|
|
58
|
+
# `domains:`. Bare hostnames (no scheme, no path).
|
|
59
|
+
def deep_linking_domains
|
|
60
|
+
hosts = []
|
|
61
|
+
if remote_url
|
|
62
|
+
require "uri"
|
|
63
|
+
hosts << URI(remote_url).host
|
|
64
|
+
end
|
|
65
|
+
hosts += Array(deep_linking["domains"]).map { |d| d.to_s.sub(%r{\Ahttps?://}, "").sub(%r{/.*\z}, "") }
|
|
66
|
+
hosts.compact.reject(&:empty?).uniq
|
|
67
|
+
rescue URI::InvalidURIError
|
|
68
|
+
Array(deep_linking["domains"]).map(&:to_s).reject(&:empty?).uniq
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The apple-app-site-association document (a Hash), or nil when no Apple app
|
|
72
|
+
# ids are configured. `applinks` for universal links; `webcredentials` so
|
|
73
|
+
# associated-domains password autofill / Face ID credentials work too.
|
|
74
|
+
def apple_app_site_association
|
|
75
|
+
ids = deep_linking_apple_app_ids
|
|
76
|
+
return nil if ids.empty?
|
|
77
|
+
|
|
78
|
+
{ "applinks" => {
|
|
79
|
+
"details" => [{ "appIDs" => ids, "components" => deep_linking_paths.map { |p| { "/" => p } } }]
|
|
80
|
+
},
|
|
81
|
+
"webcredentials" => { "apps" => ids } }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def apple_app_site_association_json
|
|
85
|
+
require "json"
|
|
86
|
+
doc = apple_app_site_association or return nil
|
|
87
|
+
JSON.generate(doc)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The Digital Asset Links document (an Array), or nil without an Android app.
|
|
91
|
+
def asset_links
|
|
92
|
+
return nil unless deep_linking_android?
|
|
93
|
+
|
|
94
|
+
[{ "relation" => ["delegate_permission/common.handle_all_urls"],
|
|
95
|
+
"target" => { "namespace" => "android_app",
|
|
96
|
+
"package_name" => deep_linking_android_package,
|
|
97
|
+
"sha256_cert_fingerprints" => deep_linking_android_fingerprints } }]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def asset_links_json
|
|
101
|
+
require "json"
|
|
102
|
+
doc = asset_links or return nil
|
|
103
|
+
JSON.generate(doc)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Everywhere
|
|
4
|
+
class Config
|
|
5
|
+
module DesktopUi
|
|
6
|
+
# Custom native menu items (macOS app menu). Each entry: label + path
|
|
7
|
+
# (+ optional accelerator), or "separator". Clicks arrive in the page as
|
|
8
|
+
# "everywhere:menu" events and navigate via Turbo.
|
|
9
|
+
def menu
|
|
10
|
+
entries = @data["menu"]
|
|
11
|
+
return [] unless entries.is_a?(Array)
|
|
12
|
+
|
|
13
|
+
entries.filter_map do |item|
|
|
14
|
+
if item == "separator" || (item.is_a?(Hash) && item["separator"])
|
|
15
|
+
{ "separator" => true }
|
|
16
|
+
elsif item.is_a?(Hash) && item["label"] && item["path"]
|
|
17
|
+
{ "label" => item["label"], "path" => item["path"],
|
|
18
|
+
"accelerator" => item["accelerator"] }.compact
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# System tray: same entry shape as `menu:` plus `action: quit | show`.
|
|
24
|
+
#
|
|
25
|
+
# tray:
|
|
26
|
+
# - label: "Open My App"
|
|
27
|
+
# action: show
|
|
28
|
+
# - label: "New Note"
|
|
29
|
+
# path: /notes/new
|
|
30
|
+
# - separator
|
|
31
|
+
# - label: Quit
|
|
32
|
+
# action: quit
|
|
33
|
+
def tray
|
|
34
|
+
entries = @data["tray"]
|
|
35
|
+
return [] unless entries.is_a?(Array)
|
|
36
|
+
|
|
37
|
+
entries.filter_map do |item|
|
|
38
|
+
if item == "separator" || (item.is_a?(Hash) && item["separator"])
|
|
39
|
+
{ "separator" => true }
|
|
40
|
+
elsif item.is_a?(Hash) && item["label"] && (item["path"] || item["action"])
|
|
41
|
+
{ "label" => item["label"], "path" => item["path"],
|
|
42
|
+
"action" => item["action"] }.compact
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Desktop window chrome. Desktop-only: on mobile the OS owns the frame, so
|
|
48
|
+
# mobile builds ignore this section entirely.
|
|
49
|
+
#
|
|
50
|
+
# window:
|
|
51
|
+
# title_bar: overlay # decorated (default) | overlay | frameless
|
|
52
|
+
# title: false # draw the title text (default true)
|
|
53
|
+
# size: [1100, 750] # initial inner size
|
|
54
|
+
# min_size: [600, 400]
|
|
55
|
+
# resizable: true
|
|
56
|
+
# drag_height: 28 # top strip that drags the window; 0 turns it off
|
|
57
|
+
#
|
|
58
|
+
# Every reader returns nil when the key is absent so `to_shell_hash` drops it
|
|
59
|
+
# and the shell keeps its own default — there is exactly one place each
|
|
60
|
+
# default lives, and it's the Rust side.
|
|
61
|
+
#
|
|
62
|
+
# `overlay` is the macOS "traffic lights floating over your content" look
|
|
63
|
+
# (TitleBarStyle::Overlay + hidden title). Windows and Linux have no such
|
|
64
|
+
# style, so the shell falls back to frameless there and the page draws its
|
|
65
|
+
# own controls via Everywhere.window.
|
|
66
|
+
WINDOW_TITLE_BARS = %w[decorated overlay frameless].freeze
|
|
67
|
+
|
|
68
|
+
def window
|
|
69
|
+
section = @data["window"]
|
|
70
|
+
section.is_a?(Hash) ? section : {}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def window_title_bar
|
|
74
|
+
value = window["title_bar"].to_s
|
|
75
|
+
value if WINDOW_TITLE_BARS.include?(value)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Tri-state, like native_ios_lazy_load_tabs: an absent key leaves the shell
|
|
79
|
+
# on its default rather than forcing one.
|
|
80
|
+
def window_title = boolean_or_nil(window["title"])
|
|
81
|
+
def window_resizable = boolean_or_nil(window["resizable"])
|
|
82
|
+
|
|
83
|
+
def window_size = window_dimensions("size")
|
|
84
|
+
def window_min_size = window_dimensions("min_size")
|
|
85
|
+
|
|
86
|
+
# How much of the top of the page drags the window, in CSS pixels. Only
|
|
87
|
+
# meaningful for overlay/frameless, where there's no system title bar left
|
|
88
|
+
# to grab. nil leaves the shell's 28px default (the height of the macOS
|
|
89
|
+
# traffic-light band); 0 turns dragging off for an app that wants to place
|
|
90
|
+
# its own drag regions.
|
|
91
|
+
def window_drag_height
|
|
92
|
+
value = window["drag_height"]
|
|
93
|
+
value.to_f if value.is_a?(Numeric) && !value.negative?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The window subset the shell needs. nil (absent from everywhere.json) when
|
|
97
|
+
# the app declares nothing, so the shell's own defaults stand untouched.
|
|
98
|
+
def window_shell_hash
|
|
99
|
+
hash = {
|
|
100
|
+
"title_bar" => window_title_bar,
|
|
101
|
+
"title" => window_title,
|
|
102
|
+
"resizable" => window_resizable,
|
|
103
|
+
"size" => window_size,
|
|
104
|
+
"min_size" => window_min_size,
|
|
105
|
+
"drag_height" => window_drag_height
|
|
106
|
+
}.compact
|
|
107
|
+
hash unless hash.empty?
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def window_errors
|
|
111
|
+
raw = @data["window"]
|
|
112
|
+
return [] if raw.nil?
|
|
113
|
+
return ["window: must be a mapping"] unless raw.is_a?(Hash)
|
|
114
|
+
|
|
115
|
+
errors = []
|
|
116
|
+
bar = raw["title_bar"]
|
|
117
|
+
if bar && !WINDOW_TITLE_BARS.include?(bar.to_s)
|
|
118
|
+
errors << "window.title_bar #{bar.to_s.inspect} must be one of " \
|
|
119
|
+
"#{WINDOW_TITLE_BARS.join(" / ")}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
errors += %w[size min_size].filter_map do |key|
|
|
123
|
+
next if raw[key].nil? || window_dimensions(key)
|
|
124
|
+
|
|
125
|
+
"window.#{key} must be two positive numbers, like [1100, 750]"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
drag = raw["drag_height"]
|
|
129
|
+
unless drag.nil? || (drag.is_a?(Numeric) && !drag.negative?)
|
|
130
|
+
errors << "window.drag_height must be a number of pixels (0 turns dragging off)"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
errors + %w[title resizable].filter_map do |key|
|
|
134
|
+
next if raw[key].nil? || boolean_or_nil(raw[key]) == raw[key]
|
|
135
|
+
|
|
136
|
+
"window.#{key} must be true or false"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
# A [width, height] pair. Rejects the near-misses (one number, three, a
|
|
143
|
+
# negative) rather than silently building a 0-pixel window.
|
|
144
|
+
def window_dimensions(key)
|
|
145
|
+
value = window[key]
|
|
146
|
+
return unless value.is_a?(Array) && value.length == 2
|
|
147
|
+
return unless value.all? { |n| n.is_a?(Numeric) && n.positive? }
|
|
148
|
+
|
|
149
|
+
value.map(&:to_f)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Everywhere
|
|
4
|
+
class Config
|
|
5
|
+
module Mobile
|
|
6
|
+
# Mobile tab bar, platform-neutral. Icons are per-platform (ios = SF
|
|
7
|
+
# Symbol, android = Material icon later), with a shared `icon:` fallback:
|
|
8
|
+
#
|
|
9
|
+
# tabs:
|
|
10
|
+
# - name: Builds
|
|
11
|
+
# path: /builds
|
|
12
|
+
# icons:
|
|
13
|
+
# ios: hammer
|
|
14
|
+
# android: build
|
|
15
|
+
#
|
|
16
|
+
# Tabs ship two ways from the same source: baked into the app's bundled
|
|
17
|
+
# path-configuration.json at build time (offline/first-launch), and served
|
|
18
|
+
# live from /everywhere/<platform>_v1.json (MobileConfigEndpoint) so tab
|
|
19
|
+
# changes deploy with the web app — no app-store release.
|
|
20
|
+
def tabs
|
|
21
|
+
entries = @data["tabs"]
|
|
22
|
+
return [] unless entries.is_a?(Array)
|
|
23
|
+
|
|
24
|
+
entries.filter_map do |tab|
|
|
25
|
+
next unless tab.is_a?(Hash) && tab["name"] && tab["path"]
|
|
26
|
+
|
|
27
|
+
path = tab["path"].start_with?("/") ? tab["path"] : "/#{tab["path"]}"
|
|
28
|
+
{ "name" => tab["name"], "path" => path,
|
|
29
|
+
"icon" => tab["icon"], "icons" => (tab["icons"] if tab["icons"].is_a?(Hash)) }.compact
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Tabs with the icon resolved for one platform: icons.<os>, then the
|
|
34
|
+
# shared icon, then a safe default.
|
|
35
|
+
def tabs_for(os)
|
|
36
|
+
tabs.map do |tab|
|
|
37
|
+
{ "title" => tab["name"], "path" => tab["path"],
|
|
38
|
+
"icon" => tab.dig("icons", os.to_s) || tab["icon"] || DEFAULT_TAB_ICONS[os.to_s] }.compact
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
DEFAULT_TAB_ICONS = { "ios" => "circle", "android" => "circle" }.freeze
|
|
43
|
+
|
|
44
|
+
# The Hotwire Native path-configuration rules every RubyEverywhere mobile
|
|
45
|
+
# shell starts from. Single source of truth: the builder bakes this into
|
|
46
|
+
# the app bundle and MobileConfigEndpoint serves it live.
|
|
47
|
+
MOBILE_PATH_RULES = [
|
|
48
|
+
{ "patterns" => [".*"],
|
|
49
|
+
"properties" => { "context" => "default", "pull_to_refresh_enabled" => true } },
|
|
50
|
+
{ "patterns" => ["/new$", "/edit$"],
|
|
51
|
+
"properties" => { "context" => "modal", "pull_to_refresh_enabled" => false } }
|
|
52
|
+
].freeze
|
|
53
|
+
|
|
54
|
+
# Native permissions the app declares (top-level `permissions:`). Only
|
|
55
|
+
# declared permissions can be requested at runtime — the shell answers
|
|
56
|
+
# `undeclared` for everything else, so an app that doesn't need a
|
|
57
|
+
# permission can never prompt for it. For camera and location the value is
|
|
58
|
+
# the user-facing usage string iOS shows in the permission prompt (the
|
|
59
|
+
# "why"); it's mandatory there because requesting without one crashes the
|
|
60
|
+
# app. Notifications need no string — declare with `true`.
|
|
61
|
+
#
|
|
62
|
+
# permissions:
|
|
63
|
+
# notifications: true
|
|
64
|
+
# camera: "Scan QR codes to pair devices."
|
|
65
|
+
# location:
|
|
66
|
+
# ios: "Find build agents near you."
|
|
67
|
+
# biometrics: "Unlock your account with Face ID."
|
|
68
|
+
#
|
|
69
|
+
# Returns { name => { "ios" => usage-or-nil, ... } }.
|
|
70
|
+
MOBILE_PERMISSIONS = %w[notifications camera location biometrics].freeze
|
|
71
|
+
|
|
72
|
+
# Info.plist usage-description keys per permission. Presence here makes
|
|
73
|
+
# the usage string mandatory on iOS.
|
|
74
|
+
IOS_USAGE_KEYS = {
|
|
75
|
+
"camera" => "NSCameraUsageDescription",
|
|
76
|
+
"location" => "NSLocationWhenInUseUsageDescription",
|
|
77
|
+
"biometrics" => "NSFaceIDUsageDescription"
|
|
78
|
+
}.freeze
|
|
79
|
+
|
|
80
|
+
# Manifest <uses-permission> names per permission, stamped into
|
|
81
|
+
# src/stamped/AndroidManifest.xml. Deliberately *not* the mirror of
|
|
82
|
+
# IOS_USAGE_KEYS: presence here mandates nothing, because Android has no
|
|
83
|
+
# Info.plist-style contract — a runtime request without a rationale string
|
|
84
|
+
# prompts normally instead of terminating the process, and the rationale is
|
|
85
|
+
# a dialog the app draws itself, not a manifest value. So the map only
|
|
86
|
+
# answers "which manifest entry does this permission need", and every
|
|
87
|
+
# declared name has one.
|
|
88
|
+
#
|
|
89
|
+
# ACCESS_FINE_LOCATION implies ACCESS_COARSE_LOCATION on API 31+ only when
|
|
90
|
+
# both are declared, but the shell asks for precise location, so the fine
|
|
91
|
+
# permission alone is the honest declaration.
|
|
92
|
+
ANDROID_PERMISSIONS = {
|
|
93
|
+
"notifications" => "android.permission.POST_NOTIFICATIONS",
|
|
94
|
+
"camera" => "android.permission.CAMERA",
|
|
95
|
+
"location" => "android.permission.ACCESS_FINE_LOCATION",
|
|
96
|
+
"biometrics" => "android.permission.USE_BIOMETRIC"
|
|
97
|
+
}.freeze
|
|
98
|
+
|
|
99
|
+
def permissions
|
|
100
|
+
entries = @data["permissions"]
|
|
101
|
+
return {} unless entries.is_a?(Hash)
|
|
102
|
+
|
|
103
|
+
entries.each_with_object({}) do |(name, value), acc|
|
|
104
|
+
next if value.nil? || value == false
|
|
105
|
+
|
|
106
|
+
acc[name.to_s] =
|
|
107
|
+
case value
|
|
108
|
+
when String then { "ios" => value }
|
|
109
|
+
when Hash then value.transform_keys(&:to_s).transform_values(&:to_s)
|
|
110
|
+
else {}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The manifest permissions an Android build declares, in declaration order.
|
|
116
|
+
# Only known names map to anything; permission_errors("android") has
|
|
117
|
+
# already failed the build on the rest by the time the builder asks.
|
|
118
|
+
def android_manifest_permissions
|
|
119
|
+
permissions.keys.filter_map { |name| ANDROID_PERMISSIONS[name] }.uniq
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Problems with the permissions declaration for one platform, as
|
|
123
|
+
# human-readable strings — unknown names, and camera/location missing the
|
|
124
|
+
# mandatory usage string. Empty means buildable.
|
|
125
|
+
#
|
|
126
|
+
# Only the unknown-name half applies to Android: the usage string exists to
|
|
127
|
+
# satisfy iOS, which kills the app when a prompt has no Info.plist entry.
|
|
128
|
+
# Android just prompts, so requiring the sentence there would fail builds
|
|
129
|
+
# over a value nothing reads.
|
|
130
|
+
def permission_errors(os)
|
|
131
|
+
permissions.flat_map do |name, usage|
|
|
132
|
+
unless MOBILE_PERMISSIONS.include?(name)
|
|
133
|
+
next ["unknown permission #{name.inspect} — supported: #{MOBILE_PERMISSIONS.join(", ")}"]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
if os == "ios" && IOS_USAGE_KEYS.key?(name) && usage["ios"].to_s.strip.empty?
|
|
137
|
+
next ["#{name} needs a usage string — the sentence iOS shows when asking. " \
|
|
138
|
+
"In config/everywhere.yml:\n permissions:\n #{name}: \"Why the app needs #{name}.\""]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
[]
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# App-declared Hotwire Native path-configuration rules (everywhere.yml
|
|
146
|
+
# top-level `rules:`), appended after MOBILE_PATH_RULES — later rules win
|
|
147
|
+
# per property, so apps can make routes modal, disable pull-to-refresh,
|
|
148
|
+
# or set any other Hotwire path property without touching native code:
|
|
149
|
+
#
|
|
150
|
+
# rules:
|
|
151
|
+
# - patterns: ["/preferences$"]
|
|
152
|
+
# properties:
|
|
153
|
+
# context: default # not a modal, unlike other /edit routes
|
|
154
|
+
# - patterns: ["/live/"]
|
|
155
|
+
# properties:
|
|
156
|
+
# pull_to_refresh_enabled: false
|
|
157
|
+
# Native screens are selected by a different property on each platform, so
|
|
158
|
+
# `os` decides how a rule's `view_controller:` is read (see
|
|
159
|
+
# #android_screen_properties).
|
|
160
|
+
def mobile_rules(os = nil)
|
|
161
|
+
entries = @data["rules"]
|
|
162
|
+
return [] unless entries.is_a?(Array)
|
|
163
|
+
|
|
164
|
+
entries.filter_map do |rule|
|
|
165
|
+
next unless rule.is_a?(Hash) && rule["patterns"].is_a?(Array) && rule["properties"].is_a?(Hash)
|
|
166
|
+
|
|
167
|
+
properties = rule["properties"]
|
|
168
|
+
properties = android_screen_properties(properties) if android_target?(os)
|
|
169
|
+
{ "patterns" => rule["patterns"].map(&:to_s), "properties" => properties }
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Hotwire Native iOS picks a native screen with `view_controller: <id>`;
|
|
174
|
+
# Android picks one with `uri: hotwire://fragment/<id>`, matched against the
|
|
175
|
+
# Fragment's own @HotwireDestinationDeepLink annotation. Same rule, same
|
|
176
|
+
# identifier, different property name — so the Android document derives the
|
|
177
|
+
# `uri` rather than making apps write the route twice and keep the two in
|
|
178
|
+
# sync by hand.
|
|
179
|
+
#
|
|
180
|
+
# Derived only for ids declared under native.android.screens: an id that
|
|
181
|
+
# names an iOS-only screen must fall through to the web fragment, because a
|
|
182
|
+
# `uri` pointing at a Fragment this build doesn't contain resolves to
|
|
183
|
+
# nothing and the visit dead-ends. An explicit `uri:` always wins — that's
|
|
184
|
+
# the escape hatch for a screen whose annotation says something else.
|
|
185
|
+
def android_screen_properties(properties)
|
|
186
|
+
id = properties["view_controller"]
|
|
187
|
+
return properties if id.nil? || properties.key?("uri")
|
|
188
|
+
return properties unless native_android_screens.key?(id.to_s)
|
|
189
|
+
|
|
190
|
+
properties.merge("uri" => "hotwire://fragment/#{id}")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The full path-configuration document for one mobile platform — rules plus
|
|
194
|
+
# our settings (tabs live in settings, per Hotwire Native convention).
|
|
195
|
+
# Pass `tabs:` to override the resolved list (the mobile config endpoint
|
|
196
|
+
# passes a per-request-filtered list; build-time stamping omits it and
|
|
197
|
+
# bakes them all).
|
|
198
|
+
def path_configuration_hash(os, tabs: nil)
|
|
199
|
+
settings = {}
|
|
200
|
+
platform_tabs = tabs || tabs_for(os)
|
|
201
|
+
settings["tabs"] = platform_tabs unless platform_tabs.empty?
|
|
202
|
+
{ "settings" => settings, "rules" => MOBILE_PATH_RULES + mobile_rules(os) }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def path_configuration_json(os, tabs: nil)
|
|
206
|
+
require "json"
|
|
207
|
+
JSON.generate(path_configuration_hash(os, tabs: tabs))
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|