ruby_everywhere 0.6.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 -2
- data/exe/rbe +2 -2
- data/lib/everywhere/agents_guide.rb +3 -1
- data/lib/everywhere/blake2b.rb +17 -1
- data/lib/everywhere/boot.rb +30 -6
- data/lib/everywhere/builders/android.rb +152 -64
- data/lib/everywhere/builders/base.rb +53 -0
- data/lib/everywhere/builders/desktop.rb +32 -40
- data/lib/everywhere/builders/ios.rb +268 -36
- 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/cli.rb +2 -0
- data/lib/everywhere/clock.rb +12 -0
- data/lib/everywhere/commands/build.rb +132 -66
- data/lib/everywhere/commands/clean.rb +8 -3
- data/lib/everywhere/commands/dev.rb +91 -242
- data/lib/everywhere/commands/doctor.rb +138 -21
- data/lib/everywhere/commands/install.rb +49 -8
- data/lib/everywhere/commands/platform/auth_status.rb +1 -0
- data/lib/everywhere/commands/platform/build.rb +141 -33
- data/lib/everywhere/commands/platform/login.rb +20 -6
- data/lib/everywhere/commands/platform/logout.rb +1 -0
- data/lib/everywhere/commands/platform/runner.rb +170 -25
- data/lib/everywhere/commands/preview.rb +286 -0
- data/lib/everywhere/commands/publish.rb +22 -2
- data/lib/everywhere/commands/release.rb +155 -35
- data/lib/everywhere/commands/shell_dir.rb +4 -2
- 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 +59 -1367
- data/lib/everywhere/console.rb +2 -1
- data/lib/everywhere/desktop_dev_app.rb +138 -0
- data/lib/everywhere/dock/state.rb +3 -1
- data/lib/everywhere/engine.rb +24 -0
- data/lib/everywhere/entrypoint.rb +24 -0
- data/lib/everywhere/error.rb +8 -0
- data/lib/everywhere/framework.rb +23 -6
- data/lib/everywhere/host.rb +11 -0
- data/lib/everywhere/ignore.rb +13 -5
- data/lib/everywhere/jump.rb +121 -0
- data/lib/everywhere/line_pump.rb +3 -1
- data/lib/everywhere/minisign.rb +1 -0
- data/lib/everywhere/mobile_config_endpoint.rb +47 -0
- data/lib/everywhere/mobile_configs_controller.rb +46 -0
- data/lib/everywhere/native_platform.rb +44 -0
- data/lib/everywhere/paths.rb +36 -13
- data/lib/everywhere/platform/client.rb +46 -7
- data/lib/everywhere/platform/credentials.rb +18 -8
- data/lib/everywhere/platform/snapshot.rb +12 -0
- 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/receipt.rb +55 -10
- data/lib/everywhere/s3.rb +1 -0
- data/lib/everywhere/shell_pages.rb +109 -0
- data/lib/everywhere/shellout.rb +19 -0
- data/lib/everywhere/simulator.rb +12 -1
- 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/android/app/build.gradle.kts +29 -1
- data/support/mobile/ios/App/EverywhereConfig.swift +17 -5
- data/support/mobile/ios/App/SceneDelegate.swift +75 -8
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +2 -4
- data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +0 -1
- data/support/mobile/ios/README.md +13 -6
- data/support/release/macos/notarize.sh +3 -0
- metadata +40 -1
|
@@ -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
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "native_mobile"
|
|
4
|
+
|
|
5
|
+
module Everywhere
|
|
6
|
+
class Config
|
|
7
|
+
module NativeDesktop
|
|
8
|
+
# The desktop half of "supernative": Rust the app repo carries in
|
|
9
|
+
# native/desktop/ that `every build` compiles into the Tauri shell.
|
|
10
|
+
#
|
|
11
|
+
# The shape differs from ios/android because desktop differs: the whole UI
|
|
12
|
+
# is a webview, so there are no native screens or splash views to register.
|
|
13
|
+
# What an app actually wants down here is the machine — a serial port, a USB
|
|
14
|
+
# device, an FFI library, a Tauri plugin — reached from the page.
|
|
15
|
+
#
|
|
16
|
+
# native:
|
|
17
|
+
# desktop:
|
|
18
|
+
# commands: [scan_ports] # fns reachable from the page
|
|
19
|
+
# setup: true # run native/desktop/setup.rs at boot
|
|
20
|
+
# crates:
|
|
21
|
+
# serialport: "4.3"
|
|
22
|
+
# reqwest: { version: "0.12", features: [json] }
|
|
23
|
+
#
|
|
24
|
+
# A command is a plain function, NOT a #[tauri::command] — Tauri's typed
|
|
25
|
+
# commands only arrive through invoke, and invoke is unavailable to our
|
|
26
|
+
# pages (they're served over http; see extension_host.rs in the shell). The
|
|
27
|
+
# signature is uniform:
|
|
28
|
+
#
|
|
29
|
+
# pub fn scan_ports(app: &tauri::AppHandle, payload: serde_json::Value)
|
|
30
|
+
# -> Result<serde_json::Value, String>
|
|
31
|
+
#
|
|
32
|
+
# Declaring anything here has a real cost the mobile sections don't have:
|
|
33
|
+
# the shell stops being one prebuilt crate shared by every app and gets
|
|
34
|
+
# stamped and compiled per app (see Builders::Desktop). Apps that declare
|
|
35
|
+
# nothing keep the fast path.
|
|
36
|
+
def native_desktop
|
|
37
|
+
section = @data.dig("native", "desktop")
|
|
38
|
+
section.is_a?(Hash) ? section : {}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def native_desktop_commands = Array(native_desktop["commands"]).map { |name| name.to_s.strip }
|
|
42
|
+
|
|
43
|
+
def native_desktop_setup? = native_desktop["setup"] == true
|
|
44
|
+
|
|
45
|
+
# Cargo dependencies, normalized to { "name" => {...table} } so the
|
|
46
|
+
# generator has one shape to write. The short form (a bare version string)
|
|
47
|
+
# becomes { "version" => "4.3" }.
|
|
48
|
+
def native_desktop_crates
|
|
49
|
+
entries = native_desktop["crates"]
|
|
50
|
+
return {} unless entries.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
entries.to_h do |name, spec|
|
|
53
|
+
table = spec.is_a?(Hash) ? spec : { "version" => spec.to_s }
|
|
54
|
+
[name.to_s.strip, table]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def native_desktop? = !(native_desktop_commands.empty? && native_desktop_crates.empty?) ||
|
|
59
|
+
native_desktop_setup?
|
|
60
|
+
|
|
61
|
+
# Everything here is interpolated into generated Rust and TOML, so names are
|
|
62
|
+
# validated hard — same reasoning as MAVEN_COORDINATE above. A command name
|
|
63
|
+
# becomes a path segment in a generate_handler! list; a crate name and its
|
|
64
|
+
# features become bare TOML keys and quoted strings.
|
|
65
|
+
RUST_FN_NAME = /\A[a-z_][a-z0-9_]*\z/
|
|
66
|
+
CRATE_NAME = /\A[a-zA-Z0-9_][a-zA-Z0-9_-]*\z/
|
|
67
|
+
# Cargo's requirement syntax, loosely: ^1.2, ~1.2, >=1.0, 1.*, 1.2.3-beta.1.
|
|
68
|
+
# Deliberately no spaces and no commas — a multi-requirement string would
|
|
69
|
+
# need quoting rules we don't want to own, and cargo reports anything
|
|
70
|
+
# subtler at build time.
|
|
71
|
+
CRATE_VERSION = /\A[\^~<>=]{0,2}\d+(\.\d+){0,2}(\.\*)?(-[0-9A-Za-z.-]+)?\z/
|
|
72
|
+
CRATE_FEATURE = /\A[A-Za-z0-9_][A-Za-z0-9_+-]*\z/
|
|
73
|
+
# Where a git dependency may point. Same rule the SPM packages use —
|
|
74
|
+
# named through NativeMobile because a sibling section module's constants
|
|
75
|
+
# aren't in scope here the way they were when both lived in Config.
|
|
76
|
+
CRATE_GIT_URL = NativeMobile::PACKAGE_URL
|
|
77
|
+
|
|
78
|
+
def native_desktop_errors
|
|
79
|
+
raw = @data.dig("native", "desktop")
|
|
80
|
+
return [] if raw.nil?
|
|
81
|
+
return ["native.desktop must be a mapping"] unless raw.is_a?(Hash)
|
|
82
|
+
|
|
83
|
+
errors = []
|
|
84
|
+
commands = raw["commands"]
|
|
85
|
+
if !commands.nil? && !commands.is_a?(Array)
|
|
86
|
+
errors << "native.desktop.commands must be a list of function names"
|
|
87
|
+
else
|
|
88
|
+
errors += native_desktop_commands.reject { |name| name.match?(RUST_FN_NAME) }.map do |name|
|
|
89
|
+
"native.desktop.commands: #{name.inspect} is not a Rust function name — " \
|
|
90
|
+
"snake_case (letters, digits, _), matching the fn in native/desktop/"
|
|
91
|
+
end
|
|
92
|
+
duplicates = native_desktop_commands.tally.select { |_name, count| count > 1 }.keys
|
|
93
|
+
errors += duplicates.map do |name|
|
|
94
|
+
"native.desktop.commands: #{name.inspect} is listed twice"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
setup = raw["setup"]
|
|
99
|
+
errors << "native.desktop.setup must be true or false" unless setup.nil? || boolean_or_nil(setup) == setup
|
|
100
|
+
|
|
101
|
+
errors + native_desktop_crate_errors
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def native_desktop_crate_errors
|
|
105
|
+
raw = native_desktop["crates"]
|
|
106
|
+
return [] if raw.nil?
|
|
107
|
+
return ["native.desktop.crates must be a mapping of crate name to version"] unless raw.is_a?(Hash)
|
|
108
|
+
|
|
109
|
+
raw.flat_map do |name, spec|
|
|
110
|
+
at = "native.desktop.crates.#{name}"
|
|
111
|
+
errors = []
|
|
112
|
+
unless name.to_s.strip.match?(CRATE_NAME)
|
|
113
|
+
errors << "#{at}: #{name.to_s.inspect} is not a crate name (letters, digits, _, -)"
|
|
114
|
+
end
|
|
115
|
+
errors + crate_spec_errors(at, spec)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# One crate's dependency spec: either a bare version string or a table.
|
|
122
|
+
def crate_spec_errors(at, spec)
|
|
123
|
+
return version_errors(at, spec) unless spec.is_a?(Hash)
|
|
124
|
+
|
|
125
|
+
errors = []
|
|
126
|
+
# A crate needs somewhere to come from. version: is the common case; git:
|
|
127
|
+
# and path: are the two escape hatches cargo itself offers.
|
|
128
|
+
unless %w[version git path].any? { |key| !spec[key].to_s.strip.empty? }
|
|
129
|
+
errors << "#{at} needs a version: (or git: / path:)"
|
|
130
|
+
end
|
|
131
|
+
errors += version_errors("#{at}.version", spec["version"]) if spec.key?("version")
|
|
132
|
+
|
|
133
|
+
git = spec["git"].to_s.strip
|
|
134
|
+
unless git.empty? || git.match?(CRATE_GIT_URL)
|
|
135
|
+
errors << "#{at}.git #{git.inspect} must be an https:// or git@ URL"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# branch/tag/rev/path have no useful shape to check, but they DO end up
|
|
139
|
+
# inside quoted TOML strings — so the one thing that matters is that they
|
|
140
|
+
# can't close the quote or start a new line.
|
|
141
|
+
errors += %w[branch tag rev path].filter_map do |key|
|
|
142
|
+
value = spec[key].to_s
|
|
143
|
+
next if value.empty? || !value.match?(/["\\\n\r]/)
|
|
144
|
+
|
|
145
|
+
"#{at}.#{key} can't contain quotes, backslashes or newlines"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
features = spec["features"]
|
|
149
|
+
if !features.nil? && !features.is_a?(Array)
|
|
150
|
+
errors << "#{at}.features must be a list"
|
|
151
|
+
else
|
|
152
|
+
errors += Array(features).reject { |f| f.to_s.match?(CRATE_FEATURE) }.map do |feature|
|
|
153
|
+
"#{at}.features: #{feature.to_s.inspect} is not a feature name (letters, digits, _, +, -)"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
errors
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def version_errors(at, value)
|
|
160
|
+
version = value.to_s.strip
|
|
161
|
+
return [] if version.empty?
|
|
162
|
+
return [] if version.match?(CRATE_VERSION)
|
|
163
|
+
|
|
164
|
+
["#{at} #{version.inspect} is not a cargo version requirement — like \"1.2\", \"^1.2.3\" or \"~0.4\""]
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|