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
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "dry/cli"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "json"
|
|
4
6
|
require_relative "../shellout"
|
|
7
|
+
require_relative "../host"
|
|
5
8
|
require_relative "../log_filter"
|
|
6
9
|
require_relative "../receipt"
|
|
7
10
|
require_relative "../paths"
|
|
@@ -17,75 +20,181 @@ module Everywhere
|
|
|
17
20
|
# surface (local, CI, Platform) must produce. See
|
|
18
21
|
# platform/docs/build-engine.md §4, §7, §9.
|
|
19
22
|
#
|
|
20
|
-
#
|
|
21
|
-
# ios/android/windows/linux slot in as builders later.
|
|
23
|
+
# macos/ios/android are live; windows/linux slot in as builders later.
|
|
22
24
|
class Release < Dry::CLI::Command
|
|
23
25
|
NOTARIZE = File.expand_path("../../../support/release/macos/notarize.sh", __dir__)
|
|
24
26
|
|
|
27
|
+
# What counts as "the distributable" per os, newest first when several
|
|
28
|
+
# match. Android is listed aab-then-apk so a `--format aab` run picks the
|
|
29
|
+
# bundle even when an older apk is still sitting in dist/.
|
|
30
|
+
ARTIFACTS = { "macos" => %w[*.zip], "ios" => %w[*.ipa], "android" => %w[*.aab *.apk] }.freeze
|
|
31
|
+
|
|
25
32
|
desc "Sign, notarize, staple, and emit release.json for a distributable build"
|
|
26
33
|
|
|
27
34
|
option :root, default: ".", desc: "App root directory"
|
|
28
|
-
option :target, default:
|
|
35
|
+
option :target, default: Build::DEFAULT_TARGET, desc: "Build target (os-arch)"
|
|
29
36
|
option :channel, default: "direct", desc: "Distribution channel (direct | mac_app_store | ...)"
|
|
30
37
|
option :ruby, default: nil, desc: "Ruby version to package (default: build.ruby or 4.0.6)"
|
|
31
38
|
option :entry, default: "native_boot.rb", desc: "Entry point script relative to root"
|
|
32
39
|
option :app_name, default: nil, desc: "Bundle name (default from config)"
|
|
33
40
|
option :shell_dir, default: nil, desc: "Path to the shell's src-tauri directory"
|
|
34
41
|
option :env_file, default: nil, desc: "dotenv file with APPLE_* signing secrets (default: .env.release)"
|
|
35
|
-
option :
|
|
42
|
+
option :format, default: "apk", desc: "Android artifact format (apk | aab)"
|
|
43
|
+
option :skip_build, type: :boolean, default: false, desc: "Sign the existing build instead of rebuilding"
|
|
36
44
|
|
|
37
|
-
def call(root: ".", target:
|
|
38
|
-
app_name: nil, shell_dir: nil, env_file: nil, skip_build: false, **)
|
|
45
|
+
def call(root: ".", target: Build::DEFAULT_TARGET, channel: "direct", ruby: nil, entry: "native_boot.rb",
|
|
46
|
+
app_name: nil, shell_dir: nil, env_file: nil, format: "apk", skip_build: false, **)
|
|
39
47
|
root_path = File.expand_path(root)
|
|
40
48
|
config = Everywhere::Config.load(root_path)
|
|
41
49
|
app_name ||= config.name(target: target)
|
|
42
50
|
ruby ||= config.build_ruby || "4.0.6"
|
|
51
|
+
os = Everywhere::Config.os_of(target)
|
|
43
52
|
|
|
44
|
-
preflight!(target)
|
|
53
|
+
preflight!(target, os)
|
|
45
54
|
|
|
46
55
|
framework = config.remote? ? nil : Framework.detect(root_path)
|
|
47
56
|
dist_dir = File.expand_path("dist", root_path)
|
|
48
57
|
|
|
49
|
-
# 1. Produce (or reuse) the signed
|
|
50
|
-
# notarize step
|
|
58
|
+
# 1. Produce (or reuse) the build. macOS gets a dev-signed .app that the
|
|
59
|
+
# notarize step re-signs inside-out with the Developer ID; the mobile
|
|
60
|
+
# toolchains sign as they build, so their artifact is already final.
|
|
51
61
|
unless skip_build
|
|
52
62
|
UI.phase("Building #{app_name} for #{UI.target_label(target)}")
|
|
53
|
-
Build.new.call(root: root, ruby: ruby, entry: entry, app_name: app_name, target: target,
|
|
63
|
+
Build.new.call(root: root, ruby: ruby, entry: entry, app_name: app_name, target: target,
|
|
64
|
+
shell_dir: shell_dir, **, **build_options(os, format, channel))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# 2. Sign/notarize (macOS) or locate what the mobile builder signed, then
|
|
68
|
+
# 3. record the signing facts and emit the receipt.
|
|
69
|
+
artifact, signing = case os
|
|
70
|
+
when "macos" then notarize!(dist_dir, root_path, env_file)
|
|
71
|
+
when "ios" then [find_artifact(dist_dir, os), ios_signing(dist_dir)]
|
|
72
|
+
else [find_artifact(dist_dir, os), android_signing]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
receipt = Receipt.new(root: root_path, config: config, framework: framework, ruby: ruby,
|
|
76
|
+
target: target, channel: channel, shell_dir: resolve_shell_dir(shell_dir))
|
|
77
|
+
out = File.join(dist_dir, "release.json")
|
|
78
|
+
receipt.write(out, artifact: artifact, signing: signing)
|
|
79
|
+
|
|
80
|
+
summarize(out, artifact, signing, root_path)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def preflight!(target, os)
|
|
86
|
+
case os
|
|
87
|
+
when "macos"
|
|
88
|
+
UI.die!("`every release --target #{target}` runs on macOS only") unless Host.darwin?
|
|
89
|
+
UI.die!("signing engine missing at #{NOTARIZE}") unless File.exist?(NOTARIZE)
|
|
90
|
+
when "ios"
|
|
91
|
+
UI.die!("iOS releases need Xcode — `every release --target #{target}` runs on macOS only") unless Host.darwin?
|
|
92
|
+
when "android"
|
|
93
|
+
nil # Gradle signs and builds anywhere the Android SDK does — Linux included.
|
|
94
|
+
else
|
|
95
|
+
UI.die!("no release builder for #{target} — macos-*, ios-* and android-* are supported")
|
|
54
96
|
end
|
|
97
|
+
end
|
|
98
|
+
|
|
55
99
|
|
|
100
|
+
# Only the flag that belongs to this os: `every build` rejects the other.
|
|
101
|
+
# iOS also needs the channel: a testflight/app_store release uploads to App
|
|
102
|
+
# Store Connect from the build host, which only the builder can do.
|
|
103
|
+
def build_options(os, format, channel)
|
|
104
|
+
case os
|
|
105
|
+
when "ios" then { device: true, channel: channel } # a release is always a device .ipa, never a simulator .app
|
|
106
|
+
when "android" then { format: format }
|
|
107
|
+
else {}
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# macOS: sign (Developer ID + hardened runtime) → notarize → staple → verify,
|
|
112
|
+
# then read the signing facts back off the bundle.
|
|
113
|
+
def notarize!(dist_dir, root_path, env_file)
|
|
56
114
|
app_dir = Dir[File.join(dist_dir, "*.app")].max_by { |d| File.mtime(d) }
|
|
57
115
|
UI.die!("no .app found in #{dist_dir} — run without --skip-build first") unless app_dir
|
|
58
116
|
|
|
59
|
-
# 2. Sign (Developer ID + hardened runtime) → notarize → staple → verify.
|
|
60
117
|
env_file ||= discover_env_file(root_path)
|
|
61
118
|
env = env_file ? { "ENV_FILE" => env_file } : {}
|
|
62
119
|
UI.phase("Signing & notarizing #{File.basename(app_dir)}")
|
|
63
|
-
UI.step("preparing #{File.basename(app_dir)}
|
|
120
|
+
UI.step("preparing #{File.basename(app_dir)}")
|
|
121
|
+
# Named in full: these are the Apple credentials the notarization runs
|
|
122
|
+
# with, and "which file did that come from" is the first thing you ask
|
|
123
|
+
# when a build signs as the wrong team.
|
|
124
|
+
UI.detail("signing env: #{UI.short_path(File.expand_path(env_file))}") if env_file
|
|
64
125
|
UI.warn("no signing env found — set APPLE_* or pass --env-file (notarize.sh will fail without it)") unless env_file || ENV["APPLE_SIGNING_IDENTITY"]
|
|
65
126
|
log = "#{app_dir}.notarize.log"
|
|
66
127
|
Shellout.run_logged!(env, ["/bin/bash", NOTARIZE, app_dir], log: log, filter: LogFilter.for(:notarize))
|
|
67
128
|
|
|
68
|
-
# 3. Gather signing facts from the bundle itself, then emit the receipt.
|
|
69
129
|
artifact = "#{app_dir.sub(/\.app\z/, "")}.zip" # notarize.sh re-zips the stapled app
|
|
70
130
|
UI.die!("expected notarized zip #{artifact} not found") unless File.exist?(artifact)
|
|
71
|
-
artifact = dashify(artifact)
|
|
72
131
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
target: target, channel: channel, shell_dir: resolve_shell_dir(shell_dir))
|
|
76
|
-
out = File.join(dist_dir, "release.json")
|
|
77
|
-
receipt.write(out, artifact: artifact, signing: signing)
|
|
132
|
+
[dashify(artifact), gather_signing(app_dir, log)]
|
|
133
|
+
end
|
|
78
134
|
|
|
79
|
-
|
|
135
|
+
# The mobile artifact ships as-is — no zip, no rename: an .ipa/.apk/.aab is
|
|
136
|
+
# already the installable container the stores and devices expect.
|
|
137
|
+
def find_artifact(dist_dir, os)
|
|
138
|
+
patterns = ARTIFACTS.fetch(os)
|
|
139
|
+
found = patterns.filter_map { |p| Dir[File.join(dist_dir, p)].max_by { |f| File.mtime(f) } }.first
|
|
140
|
+
return found if found
|
|
141
|
+
|
|
142
|
+
UI.die!("no #{patterns.map { |p| p.delete("*") }.join(" or ")} found in #{dist_dir} — " \
|
|
143
|
+
"run without --skip-build first")
|
|
80
144
|
end
|
|
81
145
|
|
|
82
|
-
|
|
146
|
+
# Reaching this line IS the verification for iOS: xcodebuild -exportArchive
|
|
147
|
+
# refuses to emit an .ipa it couldn't sign with the requested profile, so
|
|
148
|
+
# there's nothing left to probe (and no spctl/stapler for a mobile bundle).
|
|
149
|
+
def ios_signing(dist_dir)
|
|
150
|
+
{
|
|
151
|
+
"signed" => true,
|
|
152
|
+
"signed_by" => ENV["EVERY_IOS_TEAM_ID"],
|
|
153
|
+
"verified" => true,
|
|
154
|
+
"platform" => {
|
|
155
|
+
"provisioning_profile" => ENV["EVERY_IOS_PROVISIONING_PROFILE"],
|
|
156
|
+
"distribution_method" => ENV["EVERY_IOS_EXPORT_METHOD"] || "app-store-connect",
|
|
157
|
+
"upload" => upload_bag(dist_dir)
|
|
158
|
+
}.compact
|
|
159
|
+
}
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# dist/ios-upload.json is written by Builders::Ios only after the App Store
|
|
163
|
+
# Connect upload pass succeeded — so it's absent (and the key compacted
|
|
164
|
+
# away) for every non-store channel. This is the contract the Platform's
|
|
165
|
+
# store distributors read back out of the receipt.
|
|
166
|
+
def upload_bag(dist_dir)
|
|
167
|
+
path = File.join(dist_dir, "ios-upload.json")
|
|
168
|
+
return nil unless File.exist?(path)
|
|
83
169
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
170
|
+
JSON.parse(File.read(path))
|
|
171
|
+
rescue JSON::ParserError
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Gradle only signs when a keystore was handed to it; without one it still
|
|
176
|
+
# produces a (debug/unsigned) artifact, so the receipt has to say which.
|
|
177
|
+
def android_signing
|
|
178
|
+
keystore = ENV["EVERY_ANDROID_KEYSTORE"]
|
|
179
|
+
fingerprint = keystore_fingerprint(keystore)
|
|
180
|
+
{
|
|
181
|
+
"signed" => !fingerprint.nil?,
|
|
182
|
+
"signed_by" => fingerprint,
|
|
183
|
+
"verified" => !fingerprint.nil?,
|
|
184
|
+
"platform" => {
|
|
185
|
+
"signing_scheme" => fingerprint ? "v2" : "unsigned",
|
|
186
|
+
"keystore_fingerprint" => fingerprint
|
|
187
|
+
}.compact
|
|
188
|
+
}
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# The file digest, not keytool's certificate fingerprint: it identifies the
|
|
192
|
+
# keystore that signed this build without shelling out to the JDK (which the
|
|
193
|
+
# runner may not have on PATH) or unlocking it with the store password.
|
|
194
|
+
def keystore_fingerprint(keystore)
|
|
195
|
+
return nil if keystore.nil? || keystore.empty? || !File.exist?(keystore)
|
|
196
|
+
|
|
197
|
+
"sha256:#{Digest::SHA256.file(keystore).hexdigest}"
|
|
89
198
|
end
|
|
90
199
|
|
|
91
200
|
# The distributable filename must be URL/CI-safe, so spaces become dashes
|
|
@@ -97,10 +206,11 @@ module Everywhere
|
|
|
97
206
|
dashed
|
|
98
207
|
end
|
|
99
208
|
|
|
100
|
-
# A .env.release next to the app
|
|
209
|
+
# A .env.release next to the app — and nowhere else. The parent directory
|
|
210
|
+
# used to be searched too, which meant a release could silently pick up
|
|
211
|
+
# Apple credentials from outside the project it was building.
|
|
101
212
|
def discover_env_file(root_path)
|
|
102
|
-
[ENV["ENV_FILE"], File.join(root_path, ".env.release")
|
|
103
|
-
.compact.find { |p| File.exist?(p) }
|
|
213
|
+
[ENV["ENV_FILE"], File.join(root_path, ".env.release")].compact.find { |p| File.exist?(p) }
|
|
104
214
|
end
|
|
105
215
|
|
|
106
216
|
def resolve_shell_dir(given)
|
|
@@ -138,15 +248,25 @@ module Everywhere
|
|
|
138
248
|
|
|
139
249
|
def summarize(out, artifact, signing, root_path)
|
|
140
250
|
UI.phase("Release ready 🎉")
|
|
141
|
-
UI.success("#{
|
|
251
|
+
UI.success("#{UI.rel_path(artifact, root_path)} #{UI.dim("(#{(File.size(artifact) / 1024.0 / 1024.0).round}MB — send this to your friends)")}")
|
|
142
252
|
UI.substep("signed by #{UI.bold(signing["signed_by"] || "?")}")
|
|
143
|
-
UI.substep(
|
|
144
|
-
|
|
145
|
-
"gatekeeper #{UI.yn(signing["verified"])}")
|
|
146
|
-
UI.substep("receipt #{UI.dim(rel(out, root_path))}")
|
|
253
|
+
UI.substep(signing_line(signing["platform"] || {}, signing))
|
|
254
|
+
UI.substep("receipt #{UI.dim(UI.rel_path(out, root_path))}")
|
|
147
255
|
end
|
|
148
256
|
|
|
149
|
-
def
|
|
257
|
+
def signing_line(platform, signing)
|
|
258
|
+
if platform.key?("notarized")
|
|
259
|
+
"notarized #{UI.yn(platform["notarized"])} · stapled #{UI.yn(platform["stapled"])} · " \
|
|
260
|
+
"gatekeeper #{UI.yn(signing["verified"])}"
|
|
261
|
+
elsif platform.key?("distribution_method")
|
|
262
|
+
upload = platform["upload"]
|
|
263
|
+
"profile #{UI.bold(platform["provisioning_profile"] || "?")} · " \
|
|
264
|
+
"method #{platform["distribution_method"]}" \
|
|
265
|
+
"#{" · uploaded to App Store Connect (build #{upload["build_number"] || "?"})" if upload}"
|
|
266
|
+
else
|
|
267
|
+
"signing scheme #{UI.bold(platform["signing_scheme"] || "?")}"
|
|
268
|
+
end
|
|
269
|
+
end
|
|
150
270
|
end
|
|
151
271
|
end
|
|
152
272
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "dry/cli"
|
|
4
|
+
require_relative "../config"
|
|
5
|
+
require_relative "../paths"
|
|
4
6
|
|
|
5
7
|
module Everywhere
|
|
6
8
|
module Commands
|
|
@@ -12,9 +14,9 @@ module Everywhere
|
|
|
12
14
|
class ShellDir < Dry::CLI::Command
|
|
13
15
|
desc "Print the path of the bundled shell (Tauri src-tauri, or the iOS template)"
|
|
14
16
|
|
|
15
|
-
option :target, default: "macos
|
|
17
|
+
option :target, default: "macos", desc: "Build target (os or os-arch) selecting which shell"
|
|
16
18
|
|
|
17
|
-
def call(target: "macos
|
|
19
|
+
def call(target: "macos", **)
|
|
18
20
|
case Everywhere::Config.os_of(target)
|
|
19
21
|
when "ios" then puts Everywhere::Paths.ios_dir!
|
|
20
22
|
else puts Everywhere::Paths.shell_dir!
|
|
@@ -31,14 +31,22 @@ module Everywhere
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
UI.phase("Generating update signing key for #{bundle_id}")
|
|
34
|
-
|
|
34
|
+
secret = resolve_passphrase(passphrase)
|
|
35
|
+
pair = Minisign.keygen(passphrase: secret)
|
|
35
36
|
|
|
36
37
|
require "fileutils"
|
|
37
38
|
FileUtils.mkdir_p(keys_dir, mode: 0o700)
|
|
38
39
|
File.write(sk_path, pair.secret_key, perm: 0o600)
|
|
39
40
|
File.write(pub_path, pair.public_key, perm: 0o644)
|
|
41
|
+
# perm: only applies when the file is created — an overwrite (--force,
|
|
42
|
+
# or a key from an older version) keeps whatever mode it had.
|
|
43
|
+
File.chmod(0o600, sk_path)
|
|
40
44
|
|
|
41
45
|
UI.ok("secret key #{UI.short_path(sk_path)} #{UI.dim("(key id #{pair.key_id_hex})")}")
|
|
46
|
+
if secret.empty?
|
|
47
|
+
UI.warn("this secret key is stored UNENCRYPTED — anyone who can read " \
|
|
48
|
+
"#{UI.short_path(sk_path)} can sign updates your users will install")
|
|
49
|
+
end
|
|
42
50
|
UI.ok("public key #{UI.short_path(pub_path)}")
|
|
43
51
|
|
|
44
52
|
UI.phase("Add the public key to config/everywhere.yml")
|
|
@@ -54,6 +62,22 @@ module Everywhere
|
|
|
54
62
|
|
|
55
63
|
private
|
|
56
64
|
|
|
65
|
+
# --passphrase insists on one. Without the flag we still ask whenever
|
|
66
|
+
# there's a terminal to ask on — an unencrypted signing key is a real
|
|
67
|
+
# choice (CI has to have one), but it shouldn't be what you get by
|
|
68
|
+
# forgetting a flag. Empty answer = unencrypted, and it's warned about.
|
|
69
|
+
def resolve_passphrase(flag)
|
|
70
|
+
return prompt_passphrase if flag
|
|
71
|
+
return "" unless $stdin.tty?
|
|
72
|
+
|
|
73
|
+
require "io/console"
|
|
74
|
+
pass = $stdin.getpass("Passphrase (empty for an unencrypted key): ")
|
|
75
|
+
return "" if pass.empty?
|
|
76
|
+
|
|
77
|
+
UI.die!("passphrases didn't match") unless pass == $stdin.getpass("Passphrase (again): ")
|
|
78
|
+
pass
|
|
79
|
+
end
|
|
80
|
+
|
|
57
81
|
def prompt_passphrase
|
|
58
82
|
require "io/console"
|
|
59
83
|
UI.die!("--passphrase needs an interactive terminal") unless $stdin.tty?
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Everywhere
|
|
4
|
+
class Config
|
|
5
|
+
module App
|
|
6
|
+
def name(target: nil)
|
|
7
|
+
resolved(target)["name"] || File.basename(File.expand_path(root)).split(/[-_]/).map(&:capitalize).join(" ")
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Reverse-DNS identifier: macOS bundle id, and the name of the per-user
|
|
11
|
+
# app-data directory on every platform. Set it explicitly and never change
|
|
12
|
+
# it — renaming moves users' data. A platform may override it (e.g. the App
|
|
13
|
+
# Store often wants `com.example.app.ios`).
|
|
14
|
+
def bundle_id(target: nil)
|
|
15
|
+
resolved(target)["bundle_id"] ||
|
|
16
|
+
"com.rubyeverywhere.#{name(target: target).downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# A bundle id isn't only an identifier: it's a path segment (Paths.*_work_dir,
|
|
20
|
+
# which the builders rm_rf, and the update-signing key on disk). The derived
|
|
21
|
+
# default is safe by construction; an explicit one is whatever the app repo
|
|
22
|
+
# says, so it's held to reverse-DNS characters and nothing else.
|
|
23
|
+
BUNDLE_ID = /\A[A-Za-z0-9][A-Za-z0-9.-]*\z/
|
|
24
|
+
|
|
25
|
+
def identity_errors
|
|
26
|
+
sections = [["app", app]] + platforms.map { |os, override| ["platforms.#{os}", override] }
|
|
27
|
+
sections.filter_map do |at, section|
|
|
28
|
+
next unless section.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
value = section["bundle_id"].to_s
|
|
31
|
+
next if value.empty? || (value.match?(BUNDLE_ID) && !value.include?(".."))
|
|
32
|
+
|
|
33
|
+
"#{at}.bundle_id #{value.inspect} is not a bundle id — reverse-DNS, " \
|
|
34
|
+
"letters/digits/./- only (like com.example.app)"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# App icon source: a PNG (ideally square, 1024px+). Explicit `app.icon`
|
|
39
|
+
# path relative to the app root, or icon.png at the root by convention.
|
|
40
|
+
def icon
|
|
41
|
+
if (explicit = app["icon"])
|
|
42
|
+
File.expand_path(explicit, root)
|
|
43
|
+
else
|
|
44
|
+
default = File.join(root, "icon.png")
|
|
45
|
+
File.exist?(default) ? default : nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Optional custom splash page (HTML file, path relative to the app root).
|
|
50
|
+
# Shown while the packaged server boots; the shell injects
|
|
51
|
+
# window.__EVERYWHERE_CONFIG__ so it can use the app's name and colors.
|
|
52
|
+
def splash
|
|
53
|
+
path = app["splash"]
|
|
54
|
+
File.expand_path(path, root) if path
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def entry_path
|
|
58
|
+
path = app["entry_path"] || "/"
|
|
59
|
+
path.start_with?("/") ? path : "/#{path}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Marketing/display version of the app (CFBundleShortVersionString, and the
|
|
63
|
+
# `version` recorded in the build receipt). One shared `app.version` is the
|
|
64
|
+
# default for every target; a platform may override it when a store forces a
|
|
65
|
+
# different number. Defaults conservatively.
|
|
66
|
+
def version(target: nil)
|
|
67
|
+
resolved(target)["version"] || "0.1.0"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The `build:` section — durable build knobs that were once `every build`
|
|
71
|
+
# flags (ruby, targets, capabilities). CLI flags still override per-run.
|
|
72
|
+
# See platform/docs/build-engine.md §2.
|
|
73
|
+
def build = @data.fetch("build", nil) || {}
|
|
74
|
+
|
|
75
|
+
# Ruby version to package. nil here means "let the CLI decide its default".
|
|
76
|
+
def build_ruby = build["ruby"]
|
|
77
|
+
|
|
78
|
+
# Desktop OS-integration capabilities the app declares (`build.capabilities`),
|
|
79
|
+
# as a plain list. Deliberately not called permissions: the top-level
|
|
80
|
+
# `permissions:` below is the mobile prompt declaration — a different shape
|
|
81
|
+
# for a different consumer — and while both were spelled "permissions" this
|
|
82
|
+
# accessor was silently redefined by that one and could never be read.
|
|
83
|
+
def build_capabilities = Array(build["capabilities"])
|
|
84
|
+
|
|
85
|
+
# Build targets as BARE os-arch strings — any ":<channel>" suffix is
|
|
86
|
+
# stripped. Callers treat these as filesystem/URL-safe identifiers
|
|
87
|
+
# (`every publish` derives S3 update-manifest key paths from the first one),
|
|
88
|
+
# so a suffix leaking through here would poison those paths. Use
|
|
89
|
+
# #build_target_specs when the channel matters.
|
|
90
|
+
def targets = build_target_specs.map { |t| t.to_s.split(":", 2).first }
|
|
91
|
+
|
|
92
|
+
# The raw `build.targets` entries, suffixes intact ("ios-arm64:testflight").
|
|
93
|
+
def build_target_specs = Array(build["targets"])
|
|
94
|
+
|
|
95
|
+
# "local" — the app is tebako-pressed and runs on-device (default)
|
|
96
|
+
# "remote" — thin shell around an already-deployed app: no press, no sidecar
|
|
97
|
+
def mode
|
|
98
|
+
app["mode"] || (remote_url ? "remote" : "local")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def remote? = mode == "remote"
|
|
102
|
+
|
|
103
|
+
def remote_url
|
|
104
|
+
@data.dig("remote", "url")&.chomp("/")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Multi-instance apps (`remote.instances: true`): the mobile shell boots
|
|
108
|
+
# into remote.url — a hosted instance-picker page — and lets that page
|
|
109
|
+
# re-root the app onto a chosen instance via Everywhere.instance.set,
|
|
110
|
+
# persisted across launches until Everywhere.instance.clear. Opt-in
|
|
111
|
+
# because it lets page JS repoint the whole app: apps that aren't
|
|
112
|
+
# multi-instance shouldn't carry that surface.
|
|
113
|
+
def remote_instances?
|
|
114
|
+
@data.dig("remote", "instances") == true
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def tint_color
|
|
118
|
+
normalize_color(appearance["tint_color"])
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def background_color
|
|
122
|
+
normalize_color(appearance["background_color"])
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -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
|