ruby_everywhere 0.0.1 → 0.1.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 +6 -0
- data/exe/rbe +6 -0
- data/lib/everywhere/boot.rb +138 -0
- data/lib/everywhere/cli.rb +44 -0
- data/lib/everywhere/commands/build.rb +272 -0
- data/lib/everywhere/commands/dev.rb +66 -0
- data/lib/everywhere/commands/doctor.rb +54 -0
- data/lib/everywhere/commands/install.rb +264 -0
- data/lib/everywhere/commands/platform/auth_status.rb +42 -0
- data/lib/everywhere/commands/platform/build.rb +181 -0
- data/lib/everywhere/commands/platform/login.rb +81 -0
- data/lib/everywhere/commands/platform/logout.rb +34 -0
- data/lib/everywhere/commands/platform/runner.rb +224 -0
- data/lib/everywhere/commands/release.rb +143 -0
- data/lib/everywhere/config.rb +188 -0
- data/lib/everywhere/database.rb +89 -0
- data/lib/everywhere/framework.rb +197 -0
- data/lib/everywhere/ignore.rb +85 -0
- data/lib/everywhere/javascript/bridge.js +174 -0
- data/lib/everywhere/platform/client.rb +125 -0
- data/lib/everywhere/platform/credentials.rb +71 -0
- data/lib/everywhere/platform/snapshot.rb +48 -0
- data/lib/everywhere/png.rb +110 -0
- data/lib/everywhere/receipt.rb +185 -0
- data/lib/everywhere/shellout.rb +46 -0
- data/lib/everywhere/ui.rb +37 -0
- data/lib/everywhere/version.rb +11 -0
- data/lib/everywhere.rb +50 -0
- data/lib/ruby_everywhere.rb +2 -3
- data/support/github/build.yml +85 -0
- data/support/macos/entitlements.plist +23 -0
- data/support/macos/notarize.sh +123 -0
- metadata +67 -7
- data/README.md +0 -11
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/cli"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "shellwords"
|
|
7
|
+
require "base64"
|
|
8
|
+
require "json"
|
|
9
|
+
require "open3"
|
|
10
|
+
require_relative "../../ui"
|
|
11
|
+
require_relative "../../platform/client"
|
|
12
|
+
|
|
13
|
+
module Everywhere
|
|
14
|
+
module Commands
|
|
15
|
+
# `every platform runner` — runs ON a build runner (a GitHub Actions macOS
|
|
16
|
+
# machine, or any Mac). It's the glue between the Runner API and the
|
|
17
|
+
# `every release` signing engine:
|
|
18
|
+
#
|
|
19
|
+
# job spec → mark running → download+extract snapshot → pull JIT creds into
|
|
20
|
+
# a throwaway keychain (+ .p8) → every release (streaming logs) → upload the
|
|
21
|
+
# artifact + release.json → complete.
|
|
22
|
+
#
|
|
23
|
+
# Authenticated with the per-artifact runner token, so it can only touch the
|
|
24
|
+
# one artifact it was dispatched for.
|
|
25
|
+
class PlatformRunner < Dry::CLI::Command
|
|
26
|
+
desc "Run a Platform build on this machine (used by the hosted runner)"
|
|
27
|
+
|
|
28
|
+
option :artifact, required: true, desc: "Artifact public id (art_…)"
|
|
29
|
+
option :token, required: true, desc: "Per-artifact runner token"
|
|
30
|
+
option :url, default: nil, desc: "Platform base URL"
|
|
31
|
+
option :shell_dir, default: nil, desc: "Path to the generic Tauri shell (src-tauri)"
|
|
32
|
+
|
|
33
|
+
def call(artifact:, token:, url: nil, shell_dir: nil, **)
|
|
34
|
+
base = Everywhere::Platform::Client.base_url(url)
|
|
35
|
+
@client = Everywhere::Platform::Client.new(base, token: token)
|
|
36
|
+
@artifact = artifact
|
|
37
|
+
|
|
38
|
+
job = get!("/runner/artifacts/#{artifact}")
|
|
39
|
+
patch!("/runner/artifacts/#{artifact}", status: "running")
|
|
40
|
+
remote_log("runner: picked up #{job.dig("target", "os")}-#{job.dig("target", "arch")} for #{job.dig("app", "name")}")
|
|
41
|
+
|
|
42
|
+
Dir.mktmpdir("every-runner") do |work|
|
|
43
|
+
src = fetch_snapshot(work, job)
|
|
44
|
+
signing_env = pull_credentials(work)
|
|
45
|
+
|
|
46
|
+
status, release_json, zip = run_release(src, job, signing_env, shell_dir)
|
|
47
|
+
|
|
48
|
+
if status == "succeeded" && zip
|
|
49
|
+
complete_success(zip, release_json)
|
|
50
|
+
else
|
|
51
|
+
complete_failure("build failed — see log")
|
|
52
|
+
end
|
|
53
|
+
ensure
|
|
54
|
+
restore_keychains
|
|
55
|
+
end
|
|
56
|
+
rescue Everywhere::Error => e
|
|
57
|
+
complete_failure(e.message)
|
|
58
|
+
UI.die!(e.message)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# ---- pipeline steps ------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def fetch_snapshot(work, job)
|
|
66
|
+
remote_log("runner: downloading source snapshot")
|
|
67
|
+
tarball = File.join(work, "snapshot.tar.gz")
|
|
68
|
+
@client.download(job["snapshot_url"], tarball)
|
|
69
|
+
src = File.join(work, "src")
|
|
70
|
+
FileUtils.mkdir_p(src)
|
|
71
|
+
UI.die!("failed to extract snapshot") unless system("tar", "-xzf", tarball, "-C", src)
|
|
72
|
+
src
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def pull_credentials(work)
|
|
76
|
+
remote_log("runner: pulling signing credentials")
|
|
77
|
+
creds = get!("/runner/artifacts/#{@artifact}/credentials")["credentials"] || []
|
|
78
|
+
env = {}
|
|
79
|
+
creds.each do |cred|
|
|
80
|
+
path = File.join(work, cred["filename"])
|
|
81
|
+
File.binwrite(path, Base64.strict_decode64(cred["data_base64"]))
|
|
82
|
+
File.chmod(0o600, path)
|
|
83
|
+
case cred["kind"]
|
|
84
|
+
when "apple_developer_id", "apple_distribution"
|
|
85
|
+
env["APPLE_SIGNING_IDENTITY"] = install_certificate(path, cred["metadata"] || {}, work)
|
|
86
|
+
when "apple_asc_api_key"
|
|
87
|
+
env["NOTARY_KEY"] = path
|
|
88
|
+
env["NOTARY_KEY_ID"] = cred.dig("metadata", "key_id")
|
|
89
|
+
env["NOTARY_ISSUER"] = cred.dig("metadata", "issuer_id")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
env
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Import the .p12 into a throwaway keychain and add it to the search list so
|
|
96
|
+
# codesign finds the identity. Returns the identity name for notarize.sh.
|
|
97
|
+
def install_certificate(p12, metadata, work)
|
|
98
|
+
@keychain = File.join(work, "runner.keychain-db")
|
|
99
|
+
password = SecureRandom.hex(12)
|
|
100
|
+
@saved_keychains = `security list-keychains -d user`.scan(/"([^"]+)"/).flatten
|
|
101
|
+
|
|
102
|
+
run_security("create-keychain", "-p", password, @keychain)
|
|
103
|
+
run_security("set-keychain-settings", "-lut", "7200", @keychain)
|
|
104
|
+
run_security("unlock-keychain", "-p", password, @keychain)
|
|
105
|
+
run_security("import", p12, "-k", @keychain, "-P", metadata["password"].to_s, "-T", "/usr/bin/codesign")
|
|
106
|
+
run_security("list-keychains", "-d", "user", "-s", @keychain, *@saved_keychains)
|
|
107
|
+
run_security("set-key-partition-list", "-S", "apple-tool:,apple:,codesign:", "-s", "-k", password, @keychain)
|
|
108
|
+
|
|
109
|
+
# Sign by SHA-1, not name: the name can collide with other identities on
|
|
110
|
+
# the machine, and `-v` (valid-only) would hide ours when the Apple
|
|
111
|
+
# intermediate isn't in THIS keychain — codesign completes the chain from
|
|
112
|
+
# the full search list.
|
|
113
|
+
listing = `security find-identity -p codesigning #{@keychain.shellescape} 2>/dev/null`
|
|
114
|
+
sha = listing[/\b([0-9A-F]{40})\b/, 1]
|
|
115
|
+
UI.die!("no codesigning identity in the delivered .p12") unless sha
|
|
116
|
+
remote_log("runner: signing identity #{listing[/"(.+)"/, 1]} (#{sha[0, 10]}…)")
|
|
117
|
+
sha
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def run_release(src, job, signing_env, shell_dir)
|
|
121
|
+
remote_log("runner: every release (press → sign → notarize → staple)")
|
|
122
|
+
cmd = [RbConfig.ruby, "-I", lib_dir, every_exe, "release",
|
|
123
|
+
"--root", src,
|
|
124
|
+
"--target", "#{job.dig("target", "os")}-#{job.dig("target", "arch")}",
|
|
125
|
+
"--channel", job.dig("target", "distribution_channel").to_s]
|
|
126
|
+
cmd += ["--shell-dir", shell_dir] if shell_dir
|
|
127
|
+
|
|
128
|
+
success = stream(signing_env, cmd)
|
|
129
|
+
|
|
130
|
+
dist = File.join(src, "dist")
|
|
131
|
+
release_json = JSON.parse(File.read(File.join(dist, "release.json"))) rescue nil
|
|
132
|
+
zip = Dir[File.join(dist, "*.zip")].max_by { |f| File.mtime(f) }
|
|
133
|
+
[success ? "succeeded" : "failed", release_json, zip]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def complete_success(zip, release_json)
|
|
137
|
+
remote_log("runner: uploading artifact (#{(File.size(zip) / 1024.0 / 1024.0).round(1)}MB)")
|
|
138
|
+
signed_id = upload_artifact(zip)
|
|
139
|
+
post!("/runner/artifacts/#{@artifact}/complete",
|
|
140
|
+
status: "succeeded", file_signed_id: signed_id, release_json: release_json)
|
|
141
|
+
UI.success("build succeeded — #{File.basename(zip)}")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def complete_failure(message)
|
|
145
|
+
return if @completed
|
|
146
|
+
|
|
147
|
+
@client.post("/runner/artifacts/#{@artifact}/complete", status: "failed", error: message)
|
|
148
|
+
@completed = true
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def upload_artifact(zip)
|
|
152
|
+
require_relative "../../platform/snapshot"
|
|
153
|
+
reservation = post!("/runner/artifacts/#{@artifact}/upload",
|
|
154
|
+
filename: File.basename(zip), byte_size: File.size(zip),
|
|
155
|
+
checksum: Everywhere::Platform::Snapshot.md5_base64(zip), content_type: "application/zip")
|
|
156
|
+
du = reservation["direct_upload"] || {}
|
|
157
|
+
res = @client.put_file(du["url"], zip, headers: du["headers"] || {})
|
|
158
|
+
UI.die!("artifact upload failed (HTTP #{res.code})") unless res.code.to_i.between?(200, 299)
|
|
159
|
+
reservation["signed_id"]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# ---- log streaming -------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
# Run cmd, echoing output to this process's stdout AND forwarding it to the
|
|
165
|
+
# Platform log in ~400-byte batches so the web UI tails it live.
|
|
166
|
+
def stream(env, cmd)
|
|
167
|
+
buffer = +""
|
|
168
|
+
status = nil
|
|
169
|
+
Open3.popen2e(env, *cmd) do |stdin, out, wait|
|
|
170
|
+
stdin.close
|
|
171
|
+
out.each_line do |line|
|
|
172
|
+
$stdout.print(line)
|
|
173
|
+
buffer << line
|
|
174
|
+
(flush_log(buffer); buffer = +"") if buffer.bytesize >= 400
|
|
175
|
+
end
|
|
176
|
+
flush_log(buffer) unless buffer.empty?
|
|
177
|
+
status = wait.value
|
|
178
|
+
end
|
|
179
|
+
status&.success?
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def flush_log(text)
|
|
183
|
+
@client.post("/runner/artifacts/#{@artifact}/logs", text: text)
|
|
184
|
+
rescue Everywhere::Error
|
|
185
|
+
nil # never let a log hiccup kill the build
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def remote_log(message)
|
|
189
|
+
UI.step(message)
|
|
190
|
+
flush_log("#{message}\n")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# ---- helpers -------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
def get!(path)
|
|
196
|
+
res = @client.get(path)
|
|
197
|
+
UI.die!("runner API #{path} failed (HTTP #{res.status})") unless res.ok?
|
|
198
|
+
res.body
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def post!(path, body)
|
|
202
|
+
res = @client.post(path, body)
|
|
203
|
+
UI.die!("runner API #{path} failed (HTTP #{res.status})") unless res.ok?
|
|
204
|
+
res.body
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def patch!(path, body)
|
|
208
|
+
@client.patch(path, body)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def run_security(*args)
|
|
212
|
+
system("security", *args, out: File::NULL, err: File::NULL)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def restore_keychains
|
|
216
|
+
run_security("list-keychains", "-d", "user", "-s", *@saved_keychains) if @saved_keychains
|
|
217
|
+
run_security("delete-keychain", @keychain) if @keychain && File.exist?(@keychain)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def lib_dir = File.expand_path("../../..", __dir__)
|
|
221
|
+
def every_exe = File.expand_path("../../../../exe/every", __dir__)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/cli"
|
|
4
|
+
require_relative "../shellout"
|
|
5
|
+
require_relative "../receipt"
|
|
6
|
+
require_relative "build"
|
|
7
|
+
|
|
8
|
+
module Everywhere
|
|
9
|
+
module Commands
|
|
10
|
+
# Produce a signed, notarized, stapled, distributable artifact + release.json.
|
|
11
|
+
#
|
|
12
|
+
# `every release` wraps the existing signing engine
|
|
13
|
+
# (cli/support/macos/notarize.sh) rather than reimplementing
|
|
14
|
+
# codesign/notarytool, and emits the machine-readable receipt every build
|
|
15
|
+
# surface (local, CI, Platform) must produce. See
|
|
16
|
+
# platform/docs/build-engine.md §4, §7, §9.
|
|
17
|
+
#
|
|
18
|
+
# Today only the macos builder is live; the target is parameterized so
|
|
19
|
+
# ios/android/windows/linux slot in as builders later.
|
|
20
|
+
class Release < Dry::CLI::Command
|
|
21
|
+
NOTARIZE = File.expand_path("../../../support/macos/notarize.sh", __dir__)
|
|
22
|
+
|
|
23
|
+
desc "Sign, notarize, staple, and emit release.json for a distributable build"
|
|
24
|
+
|
|
25
|
+
option :root, default: ".", desc: "App root directory"
|
|
26
|
+
option :target, default: "macos-arm64", desc: "Build target (os-arch)"
|
|
27
|
+
option :channel, default: "direct", desc: "Distribution channel (direct | mac_app_store | ...)"
|
|
28
|
+
option :ruby, default: nil, desc: "Ruby version to package (default: build.ruby or 4.0.6)"
|
|
29
|
+
option :entry, default: "native_boot.rb", desc: "Entry point script relative to root"
|
|
30
|
+
option :app_name, default: nil, desc: "Bundle name (default from config)"
|
|
31
|
+
option :shell_dir, default: nil, desc: "Path to the shell's src-tauri directory"
|
|
32
|
+
option :env_file, default: nil, desc: "dotenv file with APPLE_* signing secrets (default: .env.release)"
|
|
33
|
+
option :skip_build, type: :boolean, default: false, desc: "Notarize the existing .app instead of rebuilding"
|
|
34
|
+
|
|
35
|
+
def call(root: ".", target: "macos-arm64", channel: "direct", ruby: nil, entry: "native_boot.rb",
|
|
36
|
+
app_name: nil, shell_dir: nil, env_file: nil, skip_build: false, **)
|
|
37
|
+
root_path = File.expand_path(root)
|
|
38
|
+
config = Everywhere::Config.load(root_path)
|
|
39
|
+
app_name ||= config.name
|
|
40
|
+
ruby ||= config.build_ruby || "4.0.6"
|
|
41
|
+
|
|
42
|
+
preflight!(target)
|
|
43
|
+
|
|
44
|
+
framework = config.remote? ? nil : Framework.detect(root_path)
|
|
45
|
+
dist_dir = File.expand_path("dist", root_path)
|
|
46
|
+
|
|
47
|
+
# 1. Produce (or reuse) the signed-for-dev .app via the build path. The
|
|
48
|
+
# notarize step below re-signs it inside-out with the Developer ID.
|
|
49
|
+
unless skip_build
|
|
50
|
+
UI.step("building #{UI.bold(app_name)} for #{UI.cyan(target)}")
|
|
51
|
+
Build.new.call(root: root, ruby: ruby, entry: entry, app_name: app_name, shell_dir: shell_dir, **)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
app_dir = Dir[File.join(dist_dir, "*.app")].max_by { |d| File.mtime(d) }
|
|
55
|
+
UI.die!("no .app found in #{dist_dir} — run without --skip-build first") unless app_dir
|
|
56
|
+
|
|
57
|
+
# 2. Sign (Developer ID + hardened runtime) → notarize → staple → verify.
|
|
58
|
+
env_file ||= discover_env_file(root_path)
|
|
59
|
+
env = env_file ? { "ENV_FILE" => env_file } : {}
|
|
60
|
+
UI.step("signing + notarizing #{File.basename(app_dir)}#{env_file ? " #{UI.dim("(#{rel(env_file, root_path)})")}" : ""}")
|
|
61
|
+
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"]
|
|
62
|
+
log = "#{app_dir}.notarize.log"
|
|
63
|
+
Shellout.run_logged!(env, ["/bin/bash", NOTARIZE, app_dir], log: log)
|
|
64
|
+
|
|
65
|
+
# 3. Gather signing facts from the bundle itself, then emit the receipt.
|
|
66
|
+
artifact = "#{app_dir.sub(/\.app\z/, "")}.zip" # notarize.sh re-zips the stapled app
|
|
67
|
+
UI.die!("expected notarized zip #{artifact} not found") unless File.exist?(artifact)
|
|
68
|
+
|
|
69
|
+
signing = gather_signing(app_dir, log)
|
|
70
|
+
receipt = Receipt.new(root: root_path, config: config, framework: framework, ruby: ruby,
|
|
71
|
+
target: target, channel: channel, shell_dir: resolve_shell_dir(shell_dir))
|
|
72
|
+
out = File.join(dist_dir, "release.json")
|
|
73
|
+
receipt.write(out, artifact: artifact, signing: signing)
|
|
74
|
+
|
|
75
|
+
summarize(out, artifact, signing, root_path)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def preflight!(target)
|
|
81
|
+
os = target.split("-", 2).first
|
|
82
|
+
UI.die!("only the macos target is implemented (got #{target}); other builders are stubs") unless os == "macos"
|
|
83
|
+
UI.die!("`every release` runs on macOS only") unless RUBY_PLATFORM.include?("darwin")
|
|
84
|
+
UI.die!("signing engine missing at #{NOTARIZE}") unless File.exist?(NOTARIZE)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# A .env.release next to the app, then at the repo root above it.
|
|
88
|
+
def discover_env_file(root_path)
|
|
89
|
+
[ENV["ENV_FILE"], File.join(root_path, ".env.release"), File.join(root_path, "..", ".env.release")]
|
|
90
|
+
.compact.find { |p| File.exist?(p) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def resolve_shell_dir(given)
|
|
94
|
+
return File.expand_path(given) if given
|
|
95
|
+
|
|
96
|
+
["shell/src-tauri", "../shell/src-tauri", "src-tauri"]
|
|
97
|
+
.map { |c| File.expand_path(c) }
|
|
98
|
+
.find { |c| File.exist?(File.join(c, "tauri.conf.json")) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Read the truth back off the bundle rather than trusting the script's log:
|
|
102
|
+
# a valid staple proves notarization; codesign/spctl prove signed/accepted.
|
|
103
|
+
def gather_signing(app_dir, log)
|
|
104
|
+
stapled = probe("xcrun", "stapler", "validate", app_dir)
|
|
105
|
+
{
|
|
106
|
+
"signed" => probe("codesign", "--verify", "--deep", "--strict", app_dir),
|
|
107
|
+
"signed_by" => team_id(app_dir),
|
|
108
|
+
"verified" => probe("spctl", "--assess", "--type", "execute", app_dir),
|
|
109
|
+
"platform" => {
|
|
110
|
+
"notarized" => stapled,
|
|
111
|
+
"stapled" => stapled,
|
|
112
|
+
"notarization_id" => notarization_id(log)
|
|
113
|
+
}.compact
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def probe(*cmd) = Shellout.capture(*cmd).last&.success? || false
|
|
118
|
+
|
|
119
|
+
def team_id(app_dir)
|
|
120
|
+
out, status = Shellout.capture("codesign", "-dvv", app_dir)
|
|
121
|
+
(status&.success? && out[/TeamIdentifier=(\w+)/, 1]) || ENV["APPLE_TEAM_ID"]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def notarization_id(log)
|
|
125
|
+
File.exist?(log) ? File.read(log)[/\bid:\s*([0-9a-fA-F-]{36})/, 1] : nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def summarize(out, artifact, signing, root_path)
|
|
129
|
+
UI.success("release ready")
|
|
130
|
+
UI.step("artifact: #{rel(artifact, root_path)} (#{(File.size(artifact) / 1024.0 / 1024.0).round}MB)")
|
|
131
|
+
UI.step("signed by #{signing["signed_by"] || "?"} · " \
|
|
132
|
+
"notarized #{yn(signing.dig("platform", "notarized"))} · " \
|
|
133
|
+
"stapled #{yn(signing.dig("platform", "stapled"))} · " \
|
|
134
|
+
"gatekeeper #{yn(signing["verified"])}")
|
|
135
|
+
UI.step("receipt: #{rel(out, root_path)}")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def yn(bool) = bool ? UI.green("yes") : UI.red("no")
|
|
139
|
+
|
|
140
|
+
def rel(path, root_path) = path.sub("#{root_path}/", "").sub("#{Dir.pwd}/", "")
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Everywhere
|
|
6
|
+
# Loads config/everywhere.yml:
|
|
7
|
+
#
|
|
8
|
+
# app:
|
|
9
|
+
# name: My Really Awesome App
|
|
10
|
+
# entry_path: /dashboard # initial url on app launch
|
|
11
|
+
# appearance:
|
|
12
|
+
# tint_color: "#CC342D" # a hex string...
|
|
13
|
+
# background_color: # ...or split by system theme
|
|
14
|
+
# light: "#FAF9F7"
|
|
15
|
+
# dark: "#1C1B1A"
|
|
16
|
+
#
|
|
17
|
+
# Colors always normalize to { "light" => ..., "dark" => ... }.
|
|
18
|
+
class Config
|
|
19
|
+
FILE = File.join("config", "everywhere.yml")
|
|
20
|
+
|
|
21
|
+
def self.load(root = Dir.pwd)
|
|
22
|
+
path = File.join(root, FILE)
|
|
23
|
+
data = File.exist?(path) ? YAML.safe_load_file(path, aliases: true) || {} : {}
|
|
24
|
+
new(data, root)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
attr_reader :root
|
|
28
|
+
|
|
29
|
+
def initialize(data, root)
|
|
30
|
+
@data = data
|
|
31
|
+
@root = root
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def name
|
|
35
|
+
app["name"] || File.basename(File.expand_path(root)).split(/[-_]/).map(&:capitalize).join(" ")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Reverse-DNS identifier: macOS bundle id, and the name of the per-user
|
|
39
|
+
# app-data directory on every platform. Set it explicitly and never change
|
|
40
|
+
# it — renaming moves users' data.
|
|
41
|
+
def bundle_id
|
|
42
|
+
app["bundle_id"] || "com.rubyeverywhere.#{name.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# App icon source: a PNG (ideally square, 1024px+). Explicit `app.icon`
|
|
46
|
+
# path relative to the app root, or icon.png at the root by convention.
|
|
47
|
+
def icon
|
|
48
|
+
if (explicit = app["icon"])
|
|
49
|
+
File.expand_path(explicit, root)
|
|
50
|
+
else
|
|
51
|
+
default = File.join(root, "icon.png")
|
|
52
|
+
File.exist?(default) ? default : nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Optional custom splash page (HTML file, path relative to the app root).
|
|
57
|
+
# Shown while the packaged server boots; the shell injects
|
|
58
|
+
# window.__EVERYWHERE_CONFIG__ so it can use the app's name and colors.
|
|
59
|
+
def splash
|
|
60
|
+
path = app["splash"]
|
|
61
|
+
File.expand_path(path, root) if path
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def entry_path
|
|
65
|
+
path = app["entry_path"] || "/"
|
|
66
|
+
path.start_with?("/") ? path : "/#{path}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Marketing/display version of the app (CFBundleShortVersionString, and the
|
|
70
|
+
# `version` recorded in the build receipt). Defaults conservatively.
|
|
71
|
+
def version
|
|
72
|
+
app["version"] || "0.1.0"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The `build:` section — durable build knobs that were once `every build`
|
|
76
|
+
# flags (ruby, targets, permissions). CLI flags still override per-run.
|
|
77
|
+
# See platform/docs/build-engine.md §2.
|
|
78
|
+
def build = @data.fetch("build", nil) || {}
|
|
79
|
+
|
|
80
|
+
# Ruby version to package. nil here means "let the CLI decide its default".
|
|
81
|
+
def build_ruby = build["ruby"]
|
|
82
|
+
|
|
83
|
+
# OS-integration permissions the app declares. Recorded in the receipt and
|
|
84
|
+
# (later) used to generate the shell's capabilities.
|
|
85
|
+
def permissions = Array(build["permissions"])
|
|
86
|
+
|
|
87
|
+
# Build targets (os-arch strings) — the CI matrix, expressed once.
|
|
88
|
+
def targets = Array(build["targets"])
|
|
89
|
+
|
|
90
|
+
# "local" — the app is tebako-pressed and runs on-device (default)
|
|
91
|
+
# "remote" — thin shell around an already-deployed app: no press, no sidecar
|
|
92
|
+
def mode
|
|
93
|
+
app["mode"] || (remote_url ? "remote" : "local")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def remote? = mode == "remote"
|
|
97
|
+
|
|
98
|
+
def remote_url
|
|
99
|
+
@data.dig("remote", "url")&.chomp("/")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def tint_color
|
|
103
|
+
normalize_color(appearance["tint_color"])
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def background_color
|
|
107
|
+
normalize_color(appearance["background_color"])
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Custom native menu items (macOS app menu). Each entry: label + path
|
|
111
|
+
# (+ optional accelerator), or "separator". Clicks arrive in the page as
|
|
112
|
+
# "everywhere:menu" events and navigate via Turbo.
|
|
113
|
+
def menu
|
|
114
|
+
entries = @data["menu"]
|
|
115
|
+
return [] unless entries.is_a?(Array)
|
|
116
|
+
|
|
117
|
+
entries.filter_map do |item|
|
|
118
|
+
if item == "separator" || (item.is_a?(Hash) && item["separator"])
|
|
119
|
+
{ "separator" => true }
|
|
120
|
+
elsif item.is_a?(Hash) && item["label"] && item["path"]
|
|
121
|
+
{ "label" => item["label"], "path" => item["path"],
|
|
122
|
+
"accelerator" => item["accelerator"] }.compact
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# System tray: same entry shape as `menu:` plus `action: quit | show`.
|
|
128
|
+
#
|
|
129
|
+
# tray:
|
|
130
|
+
# - label: "Open My App"
|
|
131
|
+
# action: show
|
|
132
|
+
# - label: "New Note"
|
|
133
|
+
# path: /notes/new
|
|
134
|
+
# - separator
|
|
135
|
+
# - label: Quit
|
|
136
|
+
# action: quit
|
|
137
|
+
def tray
|
|
138
|
+
entries = @data["tray"]
|
|
139
|
+
return [] unless entries.is_a?(Array)
|
|
140
|
+
|
|
141
|
+
entries.filter_map do |item|
|
|
142
|
+
if item == "separator" || (item.is_a?(Hash) && item["separator"])
|
|
143
|
+
{ "separator" => true }
|
|
144
|
+
elsif item.is_a?(Hash) && item["label"] && (item["path"] || item["action"])
|
|
145
|
+
{ "label" => item["label"], "path" => item["path"],
|
|
146
|
+
"action" => item["action"] }.compact
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The subset the shell needs, shipped as JSON (env var in dev,
|
|
152
|
+
# Resources/everywhere.json inside a bundled .app).
|
|
153
|
+
def to_shell_hash
|
|
154
|
+
{
|
|
155
|
+
"name" => name,
|
|
156
|
+
"bundle_id" => bundle_id,
|
|
157
|
+
"mode" => mode,
|
|
158
|
+
"remote_url" => remote_url,
|
|
159
|
+
"entry_path" => entry_path,
|
|
160
|
+
"tint_color" => tint_color,
|
|
161
|
+
"background_color" => background_color,
|
|
162
|
+
"menu" => (menu unless menu.empty?),
|
|
163
|
+
"tray" => (tray unless tray.empty?)
|
|
164
|
+
}.compact
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def to_shell_json
|
|
168
|
+
require "json"
|
|
169
|
+
JSON.generate(to_shell_hash)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
def app = @data.fetch("app", nil) || {}
|
|
175
|
+
def appearance = @data.fetch("appearance", nil) || {}
|
|
176
|
+
|
|
177
|
+
def normalize_color(value)
|
|
178
|
+
case value
|
|
179
|
+
when String
|
|
180
|
+
{ "light" => value, "dark" => value }
|
|
181
|
+
when Hash
|
|
182
|
+
light = value["light"] || value["dark"]
|
|
183
|
+
dark = value["dark"] || value["light"]
|
|
184
|
+
{ "light" => light, "dark" => dark } if light
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Everywhere
|
|
4
|
+
# Framework-neutral database preparation for the packaged runtime: create the
|
|
5
|
+
# database if missing and bring it up to the latest migration, so a freshly
|
|
6
|
+
# installed desktop app has a working schema on first launch.
|
|
7
|
+
#
|
|
8
|
+
# Rather than hardcode one stack, prepare! adapts to whatever ORM the app has
|
|
9
|
+
# already loaded (it runs *after* the app boots):
|
|
10
|
+
#
|
|
11
|
+
# * ActiveRecord — Rails uses the multi-db-aware prepare_all; a plain
|
|
12
|
+
# ActiveRecord app (e.g. Sinatra + sinatra-activerecord) runs its
|
|
13
|
+
# migrations in-process.
|
|
14
|
+
# * Sequel — runs Sequel migrations against the app's database handle.
|
|
15
|
+
#
|
|
16
|
+
# Apps that need something else register a hook and own the whole step:
|
|
17
|
+
#
|
|
18
|
+
# Everywhere.configure { |c| c.database { MyMigrator.run! } }
|
|
19
|
+
module Database
|
|
20
|
+
MIGRATION_DIRS = %w[db/migrate db/migrations config/db/migrate].freeze
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def prepare!(root)
|
|
25
|
+
if (hook = Everywhere.config.database_hook)
|
|
26
|
+
return hook.call
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
if defined?(::ActiveRecord::Base)
|
|
30
|
+
prepare_active_record(root)
|
|
31
|
+
elsif defined?(::Sequel)
|
|
32
|
+
prepare_sequel(root)
|
|
33
|
+
else
|
|
34
|
+
log "no ActiveRecord/Sequel detected — skipping database prepare"
|
|
35
|
+
end
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
warn "[everywhere] database prepare failed: #{e.class}: #{e.message}"
|
|
38
|
+
raise
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Rails: prepare_all handles the primary/cache/queue/cable databases.
|
|
42
|
+
# Plain ActiveRecord: connect (sqlite creates the file on demand) and run
|
|
43
|
+
# any pending migrations found on disk.
|
|
44
|
+
def prepare_active_record(root)
|
|
45
|
+
if rails_app?
|
|
46
|
+
::ActiveRecord::Tasks::DatabaseTasks.prepare_all
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
paths = migration_paths(root)
|
|
51
|
+
return log("no migrations found — skipping") if paths.empty?
|
|
52
|
+
|
|
53
|
+
# Opening the connection creates the sqlite file if it doesn't exist yet.
|
|
54
|
+
::ActiveRecord::Base.connection
|
|
55
|
+
context = ::ActiveRecord::MigrationContext.new(paths)
|
|
56
|
+
unless context.needs_migration?
|
|
57
|
+
return log("database up to date")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
log "migrating database"
|
|
61
|
+
context.migrate
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def prepare_sequel(root)
|
|
65
|
+
db = Everywhere.config.sequel_db
|
|
66
|
+
db ||= ::Object.const_get(:DB) if ::Object.const_defined?(:DB)
|
|
67
|
+
return log("no Sequel database handle (set Everywhere.config.sequel_db)") unless db
|
|
68
|
+
|
|
69
|
+
paths = migration_paths(root)
|
|
70
|
+
return log("no migrations found — skipping") if paths.empty?
|
|
71
|
+
|
|
72
|
+
::Sequel.extension :migration
|
|
73
|
+
paths.each { |dir| ::Sequel::Migrator.run(db, dir) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def migration_paths(root)
|
|
77
|
+
MIGRATION_DIRS.map { |d| File.join(root, d) }.select { |p| Dir.exist?(p) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def rails_app?
|
|
81
|
+
defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application &&
|
|
82
|
+
defined?(::ActiveRecord::Tasks::DatabaseTasks)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def log(message)
|
|
86
|
+
puts "[everywhere] #{message}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|