ruby_everywhere 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/exe/every +2 -13
- data/exe/rbe +2 -13
- data/lib/everywhere/boot.rb +9 -2
- data/lib/everywhere/builders/android.rb +16 -42
- data/lib/everywhere/builders/base.rb +53 -0
- data/lib/everywhere/builders/desktop.rb +7 -23
- data/lib/everywhere/builders/ios.rb +49 -52
- data/lib/everywhere/builders/native_sources.rb +38 -0
- data/lib/everywhere/child_processes.rb +4 -4
- data/lib/everywhere/child_supervision.rb +172 -0
- data/lib/everywhere/clock.rb +12 -0
- data/lib/everywhere/commands/build.rb +20 -9
- data/lib/everywhere/commands/dev.rb +59 -284
- data/lib/everywhere/commands/doctor.rb +6 -6
- data/lib/everywhere/commands/install.rb +1 -0
- data/lib/everywhere/commands/platform/auth_status.rb +1 -0
- data/lib/everywhere/commands/platform/build.rb +5 -6
- data/lib/everywhere/commands/platform/login.rb +4 -4
- data/lib/everywhere/commands/platform/logout.rb +1 -0
- data/lib/everywhere/commands/platform/runner.rb +62 -7
- data/lib/everywhere/commands/preview.rb +24 -97
- data/lib/everywhere/commands/publish.rb +2 -0
- data/lib/everywhere/commands/release.rb +14 -11
- data/lib/everywhere/commands/shell_dir.rb +2 -0
- data/lib/everywhere/commands/updates_keygen.rb +25 -1
- data/lib/everywhere/config/app.rb +126 -0
- data/lib/everywhere/config/auth.rb +108 -0
- data/lib/everywhere/config/data.rb +50 -0
- data/lib/everywhere/config/deep_linking.rb +107 -0
- data/lib/everywhere/config/desktop_ui.rb +153 -0
- data/lib/everywhere/config/mobile.rb +211 -0
- data/lib/everywhere/config/native_desktop.rb +168 -0
- data/lib/everywhere/config/native_mobile.rb +337 -0
- data/lib/everywhere/config/shell.rb +57 -0
- data/lib/everywhere/config/updates.rb +63 -0
- data/lib/everywhere/config.rb +30 -1423
- data/lib/everywhere/desktop_dev_app.rb +138 -0
- data/lib/everywhere/dock/state.rb +3 -1
- data/lib/everywhere/entrypoint.rb +24 -0
- data/lib/everywhere/error.rb +8 -0
- data/lib/everywhere/framework.rb +2 -2
- data/lib/everywhere/host.rb +11 -0
- data/lib/everywhere/ignore.rb +11 -5
- data/lib/everywhere/line_pump.rb +3 -1
- data/lib/everywhere/minisign.rb +1 -0
- data/lib/everywhere/native_platform.rb +44 -0
- data/lib/everywhere/paths.rb +23 -7
- data/lib/everywhere/platform/client.rb +19 -1
- data/lib/everywhere/platform/snapshot.rb +6 -4
- data/lib/everywhere/plist.rb +17 -0
- data/lib/everywhere/png.rb +1 -0
- data/lib/everywhere/raw_tty.rb +51 -0
- data/lib/everywhere/s3.rb +1 -0
- data/lib/everywhere/shell_pages.rb +109 -0
- data/lib/everywhere/tab_filter.rb +36 -0
- data/lib/everywhere/task_pool.rb +3 -4
- data/lib/everywhere/ui.rb +6 -1
- data/lib/everywhere/version.rb +6 -4
- data/lib/everywhere.rb +1 -2
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +0 -2
- data/support/release/macos/notarize.sh +3 -0
- metadata +24 -1
|
@@ -8,7 +8,10 @@ require "base64"
|
|
|
8
8
|
require "json"
|
|
9
9
|
require "open3"
|
|
10
10
|
require "securerandom"
|
|
11
|
+
require "rubygems/package"
|
|
12
|
+
require "zlib"
|
|
11
13
|
require_relative "../../ui"
|
|
14
|
+
require_relative "../../host"
|
|
12
15
|
require_relative "../../paths"
|
|
13
16
|
require_relative "../../platform/client"
|
|
14
17
|
|
|
@@ -47,6 +50,8 @@ module Everywhere
|
|
|
47
50
|
"~/Library/Developer/Xcode/UserData/Provisioning Profiles"
|
|
48
51
|
].freeze
|
|
49
52
|
|
|
53
|
+
PROFILE_UUID = /\A[0-9a-f-]+\z/i
|
|
54
|
+
|
|
50
55
|
desc "Run a Platform build on this machine (used by the hosted runner)"
|
|
51
56
|
|
|
52
57
|
option :artifact, required: true, desc: "Artifact public id (art_…)"
|
|
@@ -103,16 +108,48 @@ module Everywhere
|
|
|
103
108
|
@client.download(job["snapshot_url"], tarball)
|
|
104
109
|
src = File.join(work, "src")
|
|
105
110
|
FileUtils.mkdir_p(src)
|
|
106
|
-
|
|
111
|
+
extract_snapshot(tarball, src)
|
|
107
112
|
src
|
|
108
113
|
end
|
|
109
114
|
|
|
115
|
+
# Read with the same pure-Ruby tar the snapshot was WRITTEN with
|
|
116
|
+
# (Platform::Snapshot uses Gem::Package::TarWriter) instead of shelling
|
|
117
|
+
# out: `tar -xzf` follows "../" members and symlink members straight out
|
|
118
|
+
# of the extraction dir, and this archive arrives over the network.
|
|
119
|
+
def extract_snapshot(tarball, dest)
|
|
120
|
+
Zlib::GzipReader.open(tarball) do |gz|
|
|
121
|
+
Gem::Package::TarReader.new(gz) do |tar|
|
|
122
|
+
tar.each { |entry| extract_entry(entry, dest) }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
rescue Zlib::Error, Gem::Package::TarInvalidError => e
|
|
126
|
+
UI.die!("failed to extract snapshot (#{e.class.name.split("::").last})")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def extract_entry(entry, dest)
|
|
130
|
+
name = entry.full_name
|
|
131
|
+
UI.die!("the snapshot contains an unsafe path (#{name})") if unsafe_entry?(name)
|
|
132
|
+
# Snapshot only ever writes regular files; anything else in the archive
|
|
133
|
+
# didn't come from us.
|
|
134
|
+
return unless entry.file? && !entry.symlink?
|
|
135
|
+
|
|
136
|
+
path = File.join(dest, name)
|
|
137
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
138
|
+
File.open(path, "wb") { |out| out.write(entry.read(1 << 20)) until entry.eof? }
|
|
139
|
+
mode = entry.header.mode & 0o777
|
|
140
|
+
File.chmod(mode.zero? ? 0o644 : mode, path)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def unsafe_entry?(name)
|
|
144
|
+
name.empty? || name.start_with?("/") || name.split("/").include?("..")
|
|
145
|
+
end
|
|
146
|
+
|
|
110
147
|
def pull_credentials(work)
|
|
111
148
|
remote_log("pulling signing credentials")
|
|
112
149
|
creds = get!("/runner/artifacts/#{@artifact}/credentials")["credentials"] || []
|
|
113
150
|
env = {}
|
|
114
151
|
creds.each do |cred|
|
|
115
|
-
path =
|
|
152
|
+
path = credential_path(work, cred["filename"])
|
|
116
153
|
File.binwrite(path, Base64.strict_decode64(cred["data_base64"]))
|
|
117
154
|
File.chmod(0o600, path)
|
|
118
155
|
meta = cred["metadata"] || {}
|
|
@@ -122,7 +159,7 @@ module Everywhere
|
|
|
122
159
|
# An android job runs on Linux, where there's no keychain to import
|
|
123
160
|
# into. A stray apple credential there is the Platform's mistake, not
|
|
124
161
|
# a reason to lose the build.
|
|
125
|
-
if darwin?
|
|
162
|
+
if Host.darwin?
|
|
126
163
|
env["APPLE_SIGNING_IDENTITY"] = install_certificate(path, meta, work)
|
|
127
164
|
else
|
|
128
165
|
remote_log("skipping #{cred["kind"]} — keychain import needs macOS")
|
|
@@ -146,6 +183,16 @@ module Everywhere
|
|
|
146
183
|
env.compact
|
|
147
184
|
end
|
|
148
185
|
|
|
186
|
+
# Credential filenames come from the Platform API, so they're untrusted
|
|
187
|
+
# input: a "../../.ssh/authorized_keys" would write outside the job's
|
|
188
|
+
# tmpdir. Only ever a basename, inside work.
|
|
189
|
+
def credential_path(work, filename)
|
|
190
|
+
name = File.basename(filename.to_s)
|
|
191
|
+
UI.die!("the Platform delivered a credential with no usable filename") if name.empty? || [".", ".."].include?(name)
|
|
192
|
+
|
|
193
|
+
File.join(work, name)
|
|
194
|
+
end
|
|
195
|
+
|
|
149
196
|
# A .mobileprovision is a CMS-signed DER blob wrapped around a plain XML
|
|
150
197
|
# plist. Scanning for the plist reads it identically on Linux, where
|
|
151
198
|
# `security cms -D` doesn't exist.
|
|
@@ -157,9 +204,12 @@ module Everywhere
|
|
|
157
204
|
name = plist[%r{<key>Name</key>\s*<string>([^<]+)</string>}m, 1]
|
|
158
205
|
# TeamIdentifier is an <array> of one — the first <string> after the key.
|
|
159
206
|
team = plist[%r{<key>TeamIdentifier</key>.*?<string>([^<]+)</string>}m, 1]
|
|
160
|
-
|
|
207
|
+
# The UUID becomes a filename in the user's Provisioning Profiles dirs,
|
|
208
|
+
# and it's read out of a blob we didn't author — a "../" one would land
|
|
209
|
+
# anywhere. Shape-check it before it's ever a path segment.
|
|
210
|
+
UI.die!("the delivered provisioning profile has no usable UUID") unless uuid&.match?(PROFILE_UUID)
|
|
161
211
|
|
|
162
|
-
if darwin?
|
|
212
|
+
if Host.darwin?
|
|
163
213
|
profile_dirs.each do |dir|
|
|
164
214
|
FileUtils.mkdir_p(dir)
|
|
165
215
|
FileUtils.cp(path, File.join(dir, "#{uuid}.mobileprovision"))
|
|
@@ -171,10 +221,15 @@ module Everywhere
|
|
|
171
221
|
|
|
172
222
|
def profile_dirs = PROFILE_DIRS.map { |dir| File.expand_path(dir) }
|
|
173
223
|
|
|
174
|
-
def darwin? = RUBY_PLATFORM.include?("darwin")
|
|
175
224
|
|
|
176
225
|
# Import the .p12 into a throwaway keychain and add it to the search list so
|
|
177
226
|
# codesign finds the identity. Returns the identity name for notarize.sh.
|
|
227
|
+
#
|
|
228
|
+
# ACCEPTED RISK: `security` takes its passwords on argv, where `ps` can see
|
|
229
|
+
# them — it has no stdin form (a bare unlock-keychain prompts on the tty,
|
|
230
|
+
# which is no use in a headless job). What's exposed is this keychain's
|
|
231
|
+
# random single-use password and the .p12's own, on an ephemeral
|
|
232
|
+
# single-tenant runner that is destroyed after the build.
|
|
178
233
|
def install_certificate(p12, metadata, work)
|
|
179
234
|
@keychain = File.join(work, "runner.keychain-db")
|
|
180
235
|
password = SecureRandom.hex(12)
|
|
@@ -329,7 +384,7 @@ module Everywhere
|
|
|
329
384
|
end
|
|
330
385
|
|
|
331
386
|
def restore_keychains
|
|
332
|
-
return unless darwin?
|
|
387
|
+
return unless Host.darwin?
|
|
333
388
|
|
|
334
389
|
run_security("list-keychains", "-d", "user", "-s", *@saved_keychains) if @saved_keychains
|
|
335
390
|
run_security("delete-keychain", @keychain) if @keychain && File.exist?(@keychain)
|
|
@@ -9,7 +9,7 @@ require "uri"
|
|
|
9
9
|
require_relative "../shellout"
|
|
10
10
|
require_relative "../console"
|
|
11
11
|
require_relative "../child_processes"
|
|
12
|
-
require_relative "../
|
|
12
|
+
require_relative "../child_supervision"
|
|
13
13
|
require_relative "../framework"
|
|
14
14
|
require_relative "../config"
|
|
15
15
|
require_relative "../jump"
|
|
@@ -30,6 +30,8 @@ module Everywhere
|
|
|
30
30
|
# that hostname is — cloudflared doesn't know its own DNS)
|
|
31
31
|
# --lan no tunnel: the LAN address, for offline use
|
|
32
32
|
class Preview < Dry::CLI::Command
|
|
33
|
+
include ChildSupervision
|
|
34
|
+
|
|
33
35
|
desc "Run the dev server behind a shareable tunnel for the Jump app"
|
|
34
36
|
|
|
35
37
|
option :port, default: "3000", desc: "Dev server port"
|
|
@@ -41,10 +43,11 @@ module Everywhere
|
|
|
41
43
|
TUNNEL_BOOT_TIMEOUT = 30
|
|
42
44
|
|
|
43
45
|
def call(port: "3000", lan: false, tunnel_name: nil, tunnel_host: nil, **)
|
|
44
|
-
port =
|
|
45
|
-
@
|
|
46
|
-
|
|
47
|
-
@
|
|
46
|
+
port = validate_port!(port)
|
|
47
|
+
# An Integer, because @port is interpolated into the cloudflared command
|
|
48
|
+
# strings — nothing that reaches a shell should be a free-form String.
|
|
49
|
+
@port = Integer(port, 10)
|
|
50
|
+
init_children!
|
|
48
51
|
@framework = detect_local_framework
|
|
49
52
|
@config = Everywhere::Config.load(@framework.root)
|
|
50
53
|
# Multi-use on purpose: a forwarded link must work for a second
|
|
@@ -72,7 +75,7 @@ module Everywhere
|
|
|
72
75
|
rescue Interrupt
|
|
73
76
|
nil
|
|
74
77
|
ensure
|
|
75
|
-
|
|
78
|
+
teardown_children
|
|
76
79
|
end
|
|
77
80
|
|
|
78
81
|
private
|
|
@@ -136,8 +139,11 @@ module Everywhere
|
|
|
136
139
|
end
|
|
137
140
|
end
|
|
138
141
|
|
|
142
|
+
# quiet drops the child's routine chatter but always lets errors
|
|
143
|
+
# through — a quick tunnel that can't connect must say why.
|
|
139
144
|
def spawn_tunnel(cmd, on_line: nil, quiet: false)
|
|
140
|
-
|
|
145
|
+
filter = quiet ? ->(line) { line.match?(/\bERR\b|error/i) ? line : :drop } : nil
|
|
146
|
+
supervise(:tunnel, {}, cmd, chdir: Dir.pwd, filter: filter, on_line: on_line)
|
|
141
147
|
end
|
|
142
148
|
|
|
143
149
|
# Rails' puma template binds 0.0.0.0, but a rackup-style bin/dev often
|
|
@@ -263,96 +269,17 @@ module Everywhere
|
|
|
263
269
|
|
|
264
270
|
# --- child supervision (dev.rb's pattern, without the dock) ---------------
|
|
265
271
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
writer.close
|
|
278
|
-
end
|
|
279
|
-
thread = Thread.new do
|
|
280
|
-
Thread.current.name = "relay-#{key}"
|
|
281
|
-
Thread.current.report_on_exception = false
|
|
282
|
-
begin
|
|
283
|
-
Relay.new(io, source: key, filter: filter, on_line: on_line).run
|
|
284
|
-
rescue StandardError => e
|
|
285
|
-
Kernel.warn("#{key} output relay stopped: #{e.class}: #{e.message}")
|
|
286
|
-
end
|
|
287
|
-
end
|
|
288
|
-
Process.detach(pid)
|
|
289
|
-
@children.synchronize do
|
|
290
|
-
@pids[key] = pid
|
|
291
|
-
@relays[key] = thread
|
|
292
|
-
end
|
|
293
|
-
pid
|
|
294
|
-
end
|
|
295
|
-
|
|
296
|
-
def stop_child(key, grace: 5)
|
|
297
|
-
pid, thread = @children.synchronize { [@pids.delete(key), @relays.delete(key)] }
|
|
298
|
-
return unless pid
|
|
299
|
-
|
|
300
|
-
reap([pid], grace: grace)
|
|
301
|
-
thread&.join(1)
|
|
302
|
-
end
|
|
303
|
-
|
|
304
|
-
def reap(pids, grace: 5)
|
|
305
|
-
live = pids.compact.select { |pid| ChildProcesses.alive?(pid) }
|
|
306
|
-
return if live.empty?
|
|
307
|
-
|
|
308
|
-
live.each { |pid| ChildProcesses.signal(pid, "TERM") }
|
|
309
|
-
deadline = monotonic + grace
|
|
310
|
-
sleep(0.1) while live.any? { |pid| ChildProcesses.alive?(pid) } && monotonic < deadline
|
|
311
|
-
live.select { |pid| ChildProcesses.alive?(pid) }.each { |pid| ChildProcesses.signal(pid, "KILL") }
|
|
312
|
-
end
|
|
313
|
-
|
|
314
|
-
def teardown
|
|
315
|
-
pids, relays = @children ? @children.synchronize { [@pids.values, @relays.values] } : [[], []]
|
|
316
|
-
[
|
|
317
|
-
-> { reap(pids, grace: 2) },
|
|
318
|
-
-> { relays.each { |thread| thread.join(1) } }
|
|
319
|
-
].each do |step|
|
|
320
|
-
step.call
|
|
321
|
-
rescue StandardError => e
|
|
322
|
-
Kernel.warn("teardown: #{e.class}: #{e.message}")
|
|
323
|
-
end
|
|
324
|
-
end
|
|
325
|
-
|
|
326
|
-
# --- helpers --------------------------------------------------------------
|
|
327
|
-
|
|
328
|
-
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
329
|
-
|
|
330
|
-
def validate_port!(port)
|
|
331
|
-
number = begin
|
|
332
|
-
Integer(port.to_s, 10)
|
|
333
|
-
rescue ArgumentError, TypeError
|
|
334
|
-
UI.die!("--port must be a number (got #{port.inspect})")
|
|
335
|
-
end
|
|
336
|
-
UI.die!("--port must be between 1 and 65535 (got #{number})") unless (1..65_535).cover?(number)
|
|
337
|
-
number.to_s
|
|
338
|
-
end
|
|
339
|
-
|
|
340
|
-
def port_open?(port)
|
|
341
|
-
TCPSocket.new("127.0.0.1", Integer(port)).close
|
|
342
|
-
true
|
|
343
|
-
rescue SystemCallError
|
|
344
|
-
false
|
|
345
|
-
end
|
|
346
|
-
|
|
347
|
-
def wait_for_port(port, pid: nil, timeout: 60)
|
|
348
|
-
deadline = monotonic + timeout
|
|
349
|
-
until port_open?(port)
|
|
350
|
-
if pid && !ChildProcesses.alive?(pid)
|
|
351
|
-
UI.die!("the dev server exited before it finished booting — scroll up for its output")
|
|
352
|
-
end
|
|
353
|
-
UI.die!("dev server never came up on port #{port} after #{timeout}s") if monotonic > deadline
|
|
354
|
-
sleep 0.25
|
|
355
|
-
end
|
|
272
|
+
def spawn_child(env, cmd, chdir:)
|
|
273
|
+
return Shellout.spawn_pty(env, cmd, chdir: chdir) if Shellout.pty?
|
|
274
|
+
|
|
275
|
+
# No pty (Windows): a plain pipe loses colors but keeps the output —
|
|
276
|
+
# and the quick tunnel's URL parsing, which reads it. child_env for
|
|
277
|
+
# the same reason spawn_pty uses it: the CLI's own BUNDLE_* must not
|
|
278
|
+
# follow the app's server into its child.
|
|
279
|
+
io, writer = IO.pipe
|
|
280
|
+
pid = Process.spawn(Shellout.child_env(env), cmd, chdir: chdir, %i[out err] => writer)
|
|
281
|
+
writer.close
|
|
282
|
+
[io, pid]
|
|
356
283
|
end
|
|
357
284
|
end
|
|
358
285
|
end
|
|
@@ -173,6 +173,8 @@ module Everywhere
|
|
|
173
173
|
def preflight!
|
|
174
174
|
UI.die!("no updates.url in everywhere.yml — add an updates: section (see `every updates keygen`)") unless @config.updates_url
|
|
175
175
|
UI.die!("no updates.public_key in everywhere.yml — run `every updates keygen`") unless @config.updates_public_key
|
|
176
|
+
updates_errors = @config.updates_errors
|
|
177
|
+
UI.die!("updates: in config/everywhere.yml:\n#{updates_errors.join("\n")}") unless updates_errors.empty?
|
|
176
178
|
UI.die!("no updates.s3.bucket in everywhere.yml") if s3_config["bucket"].to_s.empty?
|
|
177
179
|
UI.die!("missing bucket credentials — set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY") if
|
|
178
180
|
ENV["AWS_ACCESS_KEY_ID"].to_s.empty? || ENV["AWS_SECRET_ACCESS_KEY"].to_s.empty?
|
|
@@ -4,6 +4,7 @@ require "dry/cli"
|
|
|
4
4
|
require "digest"
|
|
5
5
|
require "json"
|
|
6
6
|
require_relative "../shellout"
|
|
7
|
+
require_relative "../host"
|
|
7
8
|
require_relative "../log_filter"
|
|
8
9
|
require_relative "../receipt"
|
|
9
10
|
require_relative "../paths"
|
|
@@ -84,10 +85,10 @@ module Everywhere
|
|
|
84
85
|
def preflight!(target, os)
|
|
85
86
|
case os
|
|
86
87
|
when "macos"
|
|
87
|
-
UI.die!("`every release --target #{target}` runs on macOS only") unless darwin?
|
|
88
|
+
UI.die!("`every release --target #{target}` runs on macOS only") unless Host.darwin?
|
|
88
89
|
UI.die!("signing engine missing at #{NOTARIZE}") unless File.exist?(NOTARIZE)
|
|
89
90
|
when "ios"
|
|
90
|
-
UI.die!("iOS releases need Xcode — `every release --target #{target}` runs on macOS only") unless darwin?
|
|
91
|
+
UI.die!("iOS releases need Xcode — `every release --target #{target}` runs on macOS only") unless Host.darwin?
|
|
91
92
|
when "android"
|
|
92
93
|
nil # Gradle signs and builds anywhere the Android SDK does — Linux included.
|
|
93
94
|
else
|
|
@@ -95,7 +96,6 @@ module Everywhere
|
|
|
95
96
|
end
|
|
96
97
|
end
|
|
97
98
|
|
|
98
|
-
def darwin? = RUBY_PLATFORM.include?("darwin")
|
|
99
99
|
|
|
100
100
|
# Only the flag that belongs to this os: `every build` rejects the other.
|
|
101
101
|
# iOS also needs the channel: a testflight/app_store release uploads to App
|
|
@@ -117,7 +117,11 @@ module Everywhere
|
|
|
117
117
|
env_file ||= discover_env_file(root_path)
|
|
118
118
|
env = env_file ? { "ENV_FILE" => env_file } : {}
|
|
119
119
|
UI.phase("Signing & notarizing #{File.basename(app_dir)}")
|
|
120
|
-
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
|
|
121
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"]
|
|
122
126
|
log = "#{app_dir}.notarize.log"
|
|
123
127
|
Shellout.run_logged!(env, ["/bin/bash", NOTARIZE, app_dir], log: log, filter: LogFilter.for(:notarize))
|
|
@@ -202,10 +206,11 @@ module Everywhere
|
|
|
202
206
|
dashed
|
|
203
207
|
end
|
|
204
208
|
|
|
205
|
-
# 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.
|
|
206
212
|
def discover_env_file(root_path)
|
|
207
|
-
[ENV["ENV_FILE"], File.join(root_path, ".env.release")
|
|
208
|
-
.compact.find { |p| File.exist?(p) }
|
|
213
|
+
[ENV["ENV_FILE"], File.join(root_path, ".env.release")].compact.find { |p| File.exist?(p) }
|
|
209
214
|
end
|
|
210
215
|
|
|
211
216
|
def resolve_shell_dir(given)
|
|
@@ -243,10 +248,10 @@ module Everywhere
|
|
|
243
248
|
|
|
244
249
|
def summarize(out, artifact, signing, root_path)
|
|
245
250
|
UI.phase("Release ready 🎉")
|
|
246
|
-
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)")}")
|
|
247
252
|
UI.substep("signed by #{UI.bold(signing["signed_by"] || "?")}")
|
|
248
253
|
UI.substep(signing_line(signing["platform"] || {}, signing))
|
|
249
|
-
UI.substep("receipt #{UI.dim(
|
|
254
|
+
UI.substep("receipt #{UI.dim(UI.rel_path(out, root_path))}")
|
|
250
255
|
end
|
|
251
256
|
|
|
252
257
|
def signing_line(platform, signing)
|
|
@@ -262,8 +267,6 @@ module Everywhere
|
|
|
262
267
|
"signing scheme #{UI.bold(platform["signing_scheme"] || "?")}"
|
|
263
268
|
end
|
|
264
269
|
end
|
|
265
|
-
|
|
266
|
-
def rel(path, root_path) = UI.short_path(path.sub("#{root_path}/", "").sub("#{Dir.pwd}/", ""))
|
|
267
270
|
end
|
|
268
271
|
end
|
|
269
272
|
end
|
|
@@ -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
|