ruby_everywhere 0.6.0 → 0.7.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 +12 -1
- data/exe/rbe +12 -1
- data/lib/everywhere/agents_guide.rb +3 -1
- data/lib/everywhere/blake2b.rb +17 -1
- data/lib/everywhere/boot.rb +21 -4
- data/lib/everywhere/builders/android.rb +144 -30
- data/lib/everywhere/builders/desktop.rb +25 -17
- data/lib/everywhere/builders/ios.rb +244 -9
- data/lib/everywhere/cli.rb +2 -0
- data/lib/everywhere/commands/build.rb +113 -58
- data/lib/everywhere/commands/clean.rb +8 -3
- data/lib/everywhere/commands/dev.rb +85 -11
- data/lib/everywhere/commands/doctor.rb +138 -21
- data/lib/everywhere/commands/install.rb +48 -8
- data/lib/everywhere/commands/platform/build.rb +136 -27
- data/lib/everywhere/commands/platform/login.rb +16 -2
- data/lib/everywhere/commands/platform/runner.rb +113 -23
- data/lib/everywhere/commands/preview.rb +359 -0
- data/lib/everywhere/commands/publish.rb +20 -2
- data/lib/everywhere/commands/release.rb +145 -28
- data/lib/everywhere/commands/shell_dir.rb +2 -2
- data/lib/everywhere/config.rb +88 -3
- data/lib/everywhere/console.rb +2 -1
- data/lib/everywhere/engine.rb +24 -0
- data/lib/everywhere/framework.rb +21 -4
- data/lib/everywhere/ignore.rb +3 -1
- data/lib/everywhere/jump.rb +121 -0
- data/lib/everywhere/mobile_config_endpoint.rb +47 -0
- data/lib/everywhere/mobile_configs_controller.rb +46 -0
- data/lib/everywhere/paths.rb +13 -6
- data/lib/everywhere/platform/client.rb +27 -6
- data/lib/everywhere/platform/credentials.rb +18 -8
- data/lib/everywhere/platform/snapshot.rb +10 -0
- data/lib/everywhere/receipt.rb +55 -10
- data/lib/everywhere/shellout.rb +19 -0
- data/lib/everywhere/simulator.rb +12 -1
- data/lib/everywhere/version.rb +1 -1
- 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 -2
- data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +0 -1
- data/support/mobile/ios/README.md +13 -6
- metadata +17 -1
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/cli"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "shellwords"
|
|
7
|
+
require "timeout"
|
|
8
|
+
require "uri"
|
|
9
|
+
require_relative "../shellout"
|
|
10
|
+
require_relative "../console"
|
|
11
|
+
require_relative "../child_processes"
|
|
12
|
+
require_relative "../relay"
|
|
13
|
+
require_relative "../framework"
|
|
14
|
+
require_relative "../config"
|
|
15
|
+
require_relative "../jump"
|
|
16
|
+
|
|
17
|
+
module Everywhere
|
|
18
|
+
module Commands
|
|
19
|
+
# Put the running dev server in someone's hand: boot the app, open a
|
|
20
|
+
# Cloudflare tunnel to it, and print a QR code that opens the Jump app
|
|
21
|
+
# (spec: platform/docs/jump.md). The server gets EVERYWHERE_JUMP_TOKEN, so
|
|
22
|
+
# the Jump endpoints in MobileConfigEndpoint / MobileConfigsController are
|
|
23
|
+
# live for exactly as long as this command runs.
|
|
24
|
+
#
|
|
25
|
+
# Three transports, one seam (tunnel_url):
|
|
26
|
+
# default cloudflared quick tunnel — free, no account, HTTPS,
|
|
27
|
+
# best-effort (restarted with a fresh QR if it drops)
|
|
28
|
+
# --tunnel-name a named tunnel from the developer's own Cloudflare
|
|
29
|
+
# account, for a stable hostname (--tunnel-host says what
|
|
30
|
+
# that hostname is — cloudflared doesn't know its own DNS)
|
|
31
|
+
# --lan no tunnel: the LAN address, for offline use
|
|
32
|
+
class Preview < Dry::CLI::Command
|
|
33
|
+
desc "Run the dev server behind a shareable tunnel for the Jump app"
|
|
34
|
+
|
|
35
|
+
option :port, default: "3000", desc: "Dev server port"
|
|
36
|
+
option :lan, type: :boolean, default: false, desc: "Skip the tunnel and share the LAN address"
|
|
37
|
+
option :tunnel_name, default: nil, desc: "Run a named cloudflared tunnel instead of a quick one"
|
|
38
|
+
option :tunnel_host, default: nil, desc: "Public hostname the named tunnel is routed to"
|
|
39
|
+
|
|
40
|
+
TRYCLOUDFLARE_URL = %r{https://[a-z0-9-]+\.trycloudflare\.com}
|
|
41
|
+
TUNNEL_BOOT_TIMEOUT = 30
|
|
42
|
+
|
|
43
|
+
def call(port: "3000", lan: false, tunnel_name: nil, tunnel_host: nil, **)
|
|
44
|
+
port = @port = validate_port!(port)
|
|
45
|
+
@pids = {}
|
|
46
|
+
@relays = {}
|
|
47
|
+
@children = Mutex.new
|
|
48
|
+
@framework = detect_local_framework
|
|
49
|
+
@config = Everywhere::Config.load(@framework.root)
|
|
50
|
+
# Multi-use on purpose: a forwarded link must work for a second
|
|
51
|
+
# coworker's device. Dies with this process — bearers are HMACs over
|
|
52
|
+
# it, so they die too.
|
|
53
|
+
@token = SecureRandom.urlsafe_base64(24)
|
|
54
|
+
|
|
55
|
+
@lan = lan
|
|
56
|
+
if lan
|
|
57
|
+
@url = lan_url(port)
|
|
58
|
+
else
|
|
59
|
+
Shellout.ensure_tool!("cloudflared",
|
|
60
|
+
"preview shares your app through a Cloudflare tunnel — " \
|
|
61
|
+
"install it with `brew install cloudflared` (or use --lan)")
|
|
62
|
+
validate_tunnel_options!(tunnel_name, tunnel_host)
|
|
63
|
+
@tunnel_name = tunnel_name
|
|
64
|
+
@tunnel_host = tunnel_host
|
|
65
|
+
@url = start_tunnel
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
start_server(port)
|
|
69
|
+
warn_if_loopback_only if lan
|
|
70
|
+
announce
|
|
71
|
+
wait
|
|
72
|
+
rescue Interrupt
|
|
73
|
+
nil
|
|
74
|
+
ensure
|
|
75
|
+
teardown
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# Preview exists to share a *local* dev server. A remote-mode root has
|
|
81
|
+
# no local server — the deployed URL is already shareable.
|
|
82
|
+
def detect_local_framework
|
|
83
|
+
Framework.detect
|
|
84
|
+
rescue Everywhere::Error
|
|
85
|
+
config = Everywhere::Config.load(Dir.pwd)
|
|
86
|
+
if config.remote?
|
|
87
|
+
UI.die!("this app runs in remote mode — #{config.remote_url} is already online, share that " \
|
|
88
|
+
"(preview tunnels a local dev server)")
|
|
89
|
+
end
|
|
90
|
+
UI.die!("no supported framework here (Rails, Hanami, Sinatra) — nothing to preview")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def validate_tunnel_options!(name, host)
|
|
94
|
+
return if name.nil? && host.nil?
|
|
95
|
+
return if name && host
|
|
96
|
+
|
|
97
|
+
UI.die!("--tunnel-name and --tunnel-host go together: cloudflared runs the named tunnel, " \
|
|
98
|
+
"but only your Cloudflare DNS knows its public hostname")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# --- transports -----------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def lan_url(port)
|
|
104
|
+
"http://#{lan_ip}:#{port}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def lan_ip
|
|
108
|
+
Socket.ip_address_list.find { |a| a.ipv4_private? }&.ip_address ||
|
|
109
|
+
UI.die!("couldn't find a LAN address — is this machine on a network? (drop --lan to tunnel instead)")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def start_tunnel
|
|
113
|
+
if @tunnel_name
|
|
114
|
+
UI.step("starting named tunnel #{UI.bold(@tunnel_name)} → #{UI.cyan("https://#{@tunnel_host}")}")
|
|
115
|
+
spawn_tunnel("cloudflared tunnel run --url http://127.0.0.1:#{@port} #{@tunnel_name.shellescape}")
|
|
116
|
+
"https://#{@tunnel_host}"
|
|
117
|
+
else
|
|
118
|
+
UI.step("starting Cloudflare quick tunnel")
|
|
119
|
+
quick_tunnel_url
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Quick tunnels choose their hostname at startup and print it once;
|
|
124
|
+
# everything else cloudflared logs is noise here, so the relay drops it
|
|
125
|
+
# and the on_line hook fishes out the URL.
|
|
126
|
+
def quick_tunnel_url
|
|
127
|
+
found = Queue.new
|
|
128
|
+
spawn_tunnel("cloudflared tunnel --url http://127.0.0.1:#{@port}",
|
|
129
|
+
on_line: ->(line) { (url = line[TRYCLOUDFLARE_URL]) && found << url },
|
|
130
|
+
quiet: true)
|
|
131
|
+
begin
|
|
132
|
+
Timeout.timeout(TUNNEL_BOOT_TIMEOUT) { found.pop }
|
|
133
|
+
rescue Timeout::Error
|
|
134
|
+
UI.die!("cloudflared never reported a tunnel URL after #{TUNNEL_BOOT_TIMEOUT}s — " \
|
|
135
|
+
"check your network, or share on the local network with --lan")
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def spawn_tunnel(cmd, on_line: nil, quiet: false)
|
|
140
|
+
supervise(:tunnel, {}, cmd, chdir: Dir.pwd, on_line: on_line, quiet: quiet)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Rails' puma template binds 0.0.0.0, but a rackup-style bin/dev often
|
|
144
|
+
# pins --host 127.0.0.1 — then the LAN URL on the QR is a promise the
|
|
145
|
+
# server can't keep. Say so, instead of letting the phone time out.
|
|
146
|
+
def warn_if_loopback_only
|
|
147
|
+
TCPSocket.new(URI.parse(@url).host, Integer(@port)).close
|
|
148
|
+
rescue SystemCallError
|
|
149
|
+
UI.warn("the dev server only listens on 127.0.0.1, so phones can't reach #{@url} — " \
|
|
150
|
+
"make bin/dev bind 0.0.0.0 (rackup --host 0.0.0.0), or drop --lan and tunnel instead")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# --- the dev server -------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
def start_server(port)
|
|
156
|
+
# Attaching to an already-running server (dev's behavior) can't work
|
|
157
|
+
# here: that process has no EVERYWHERE_JUMP_TOKEN, so Jump could never
|
|
158
|
+
# pair with it.
|
|
159
|
+
if port_open?(port)
|
|
160
|
+
UI.die!("something is already running on port #{port} — preview must start the server itself " \
|
|
161
|
+
"(the Jump pairing token rides in on its environment); stop that server or use --port")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
UI.step("starting #{UI.bold(@framework.name)} dev server #{UI.dim("(" + @framework.dev_command + ")")}")
|
|
165
|
+
env = { "PORT" => port.to_s,
|
|
166
|
+
"EVERYWHERE_JUMP_TOKEN" => @token,
|
|
167
|
+
"EVERYWHERE_PREVIEW_HOST" => URI.parse(@url).host }
|
|
168
|
+
# `rails server` binds localhost by default, which would make the LAN
|
|
169
|
+
# address on the QR a lie. BINDING is Rails' own env override; rackup
|
|
170
|
+
# bin/devs that pin --host still get warn_if_loopback_only.
|
|
171
|
+
env["BINDING"] = "0.0.0.0" if @lan
|
|
172
|
+
pid = supervise(:web, env, @framework.dev_command, chdir: @framework.root)
|
|
173
|
+
wait_for_port(port, pid: pid)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# --- the invitation -------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def jump_base = ENV.fetch("EVERY_JUMP_URL", "https://jump.rubyeverywhere.com").chomp("/")
|
|
179
|
+
|
|
180
|
+
def connect_url
|
|
181
|
+
"#{jump_base}/connect?#{URI.encode_www_form(url: @url, token: @token)}"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# POST /j on the Jump site trades the long connect URL for a short one —
|
|
185
|
+
# a nicer link to read out and a much smaller QR. Best-effort: preview
|
|
186
|
+
# works offline from the Jump site's point of view.
|
|
187
|
+
def share_link
|
|
188
|
+
require "net/http"
|
|
189
|
+
require "json"
|
|
190
|
+
uri = URI.parse("#{jump_base}/j")
|
|
191
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
192
|
+
open_timeout: 5, read_timeout: 5) do |http|
|
|
193
|
+
http.post(uri.path, JSON.generate(url: @url, token: @token),
|
|
194
|
+
"content-type" => "application/json")
|
|
195
|
+
end
|
|
196
|
+
response.code == "201" ? JSON.parse(response.body)["url"] : nil
|
|
197
|
+
rescue StandardError
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def announce
|
|
202
|
+
link = share_link || connect_url
|
|
203
|
+
UI.step("preview of #{UI.bold(@config.name)} is live → #{UI.cyan(@url)}")
|
|
204
|
+
Console.print(qr_ansi(link)) if $stdout.tty?
|
|
205
|
+
Console.print(" Scan with your phone's camera, or send:\n\n #{UI.cyan(link)}\n\n")
|
|
206
|
+
Console.print(" #{UI.dim("Manual entry in Jump:")} #{@url} #{UI.dim("token:")} #{@token}\n")
|
|
207
|
+
Console.print(UI.dim(" The link and QR die with this session — Ctrl-C to stop.\n"))
|
|
208
|
+
UI.note("waiting for devices…", marker: "⌁", color: :cyan)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Half-block rendering: ▀ painted fg-for-top-module, bg-for-bottom packs
|
|
212
|
+
# two QR rows into one terminal line and one module into one column —
|
|
213
|
+
# a quarter of the area of rqrcode's own as_ansi, which burns two full
|
|
214
|
+
# cells per module. Explicit black/white (not theme colors), because a
|
|
215
|
+
# camera needs dark-on-light no matter what the terminal theme thinks.
|
|
216
|
+
#
|
|
217
|
+
# rqrcode is pure Ruby. The rescue is portability hardening: a missing
|
|
218
|
+
# gem downgrades the session to link-only instead of killing it.
|
|
219
|
+
def qr_ansi(link)
|
|
220
|
+
require "rqrcode"
|
|
221
|
+
modules = RQRCode::QRCode.new(link, level: :l).modules
|
|
222
|
+
quiet = 2
|
|
223
|
+
size = modules.length + quiet * 2
|
|
224
|
+
dark = lambda do |x, y|
|
|
225
|
+
gx = x - quiet
|
|
226
|
+
gy = y - quiet
|
|
227
|
+
gy.between?(0, modules.length - 1) && gx.between?(0, modules.length - 1) && modules[gy][gx]
|
|
228
|
+
end
|
|
229
|
+
(0...size).step(2).map do |y|
|
|
230
|
+
" " + (0...size).map do |x|
|
|
231
|
+
top = dark.call(x, y)
|
|
232
|
+
bottom = (y + 1 < size) && dark.call(x, y + 1)
|
|
233
|
+
"\e[#{top ? 30 : 97};#{bottom ? 40 : 107}m▀"
|
|
234
|
+
end.join + "\e[0m"
|
|
235
|
+
end.join("\n") << "\e[0m\n"
|
|
236
|
+
rescue LoadError, StandardError => e
|
|
237
|
+
UI.warn("couldn't render the QR (#{e.class}) — use the link below")
|
|
238
|
+
""
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# --- the wait loop --------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
# The server's death ends the session; the tunnel's doesn't. Quick
|
|
244
|
+
# tunnels are explicitly best-effort — Cloudflare recycles them — so a
|
|
245
|
+
# dead tunnel is restarted and the fresh URL re-announced. (The token
|
|
246
|
+
# survives, but the origin changed, so recents/short links need the new
|
|
247
|
+
# invitation.)
|
|
248
|
+
def wait
|
|
249
|
+
loop do
|
|
250
|
+
sleep 1
|
|
251
|
+
web = @children.synchronize { @pids[:web] }
|
|
252
|
+
UI.die!("the dev server exited — scroll up for its output") unless ChildProcesses.alive?(web)
|
|
253
|
+
|
|
254
|
+
tunnel = @children.synchronize { @pids[:tunnel] }
|
|
255
|
+
next if tunnel.nil? || ChildProcesses.alive?(tunnel)
|
|
256
|
+
|
|
257
|
+
UI.warn("the tunnel dropped — starting a fresh one")
|
|
258
|
+
stop_child(:tunnel)
|
|
259
|
+
@url = start_tunnel
|
|
260
|
+
announce
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# --- child supervision (dev.rb's pattern, without the dock) ---------------
|
|
265
|
+
|
|
266
|
+
# quiet drops the child's routine chatter but always lets errors
|
|
267
|
+
# through — a quick tunnel that can't connect must say why.
|
|
268
|
+
def supervise(key, env, cmd, chdir:, on_line: nil, quiet: false)
|
|
269
|
+
filter = quiet ? ->(line) { line.match?(/\bERR\b|error/i) ? line : :drop } : nil
|
|
270
|
+
if Shellout.pty?
|
|
271
|
+
io, pid = Shellout.spawn_pty(env, cmd, chdir: chdir)
|
|
272
|
+
else
|
|
273
|
+
# No pty (Windows): a plain pipe loses colors but keeps the output —
|
|
274
|
+
# and the quick tunnel's URL parsing, which reads it.
|
|
275
|
+
io, writer = IO.pipe
|
|
276
|
+
pid = Process.spawn(env, cmd, chdir: chdir, %i[out err] => writer)
|
|
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
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|
|
@@ -8,6 +8,7 @@ require_relative "../s3"
|
|
|
8
8
|
require_relative "../update_manifest"
|
|
9
9
|
require_relative "../paths"
|
|
10
10
|
require_relative "../ui"
|
|
11
|
+
require_relative "build"
|
|
11
12
|
|
|
12
13
|
module Everywhere
|
|
13
14
|
module Commands
|
|
@@ -21,6 +22,15 @@ module Everywhere
|
|
|
21
22
|
# from everywhere.yml's updates.s3 section. See UpdateManifest for the
|
|
22
23
|
# layout/JSON contract.
|
|
23
24
|
class Publish < Dry::CLI::Command
|
|
25
|
+
# Only the .apk needs a specific type: Android refuses to install a
|
|
26
|
+
# download the server labelled application/octet-stream.
|
|
27
|
+
CONTENT_TYPES = {
|
|
28
|
+
".zip" => "application/zip",
|
|
29
|
+
".apk" => "application/vnd.android.package-archive",
|
|
30
|
+
".aab" => "application/octet-stream",
|
|
31
|
+
".ipa" => "application/octet-stream"
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
24
34
|
desc "Publish a released build to your update bucket (self-hosted auto-updates)"
|
|
25
35
|
|
|
26
36
|
option :root, default: ".", desc: "App root directory"
|
|
@@ -46,6 +56,12 @@ module Everywhere
|
|
|
46
56
|
publish!(receipt_path(receipt), notes: read_notes(notes, notes_file),
|
|
47
57
|
key: key, latest_alias: latest_alias, force: force)
|
|
48
58
|
end
|
|
59
|
+
rescue Everywhere::S3::Error => e
|
|
60
|
+
# RequestTimeExpired means the bucket rejected our SigV4 timestamp; the
|
|
61
|
+
# only realistic local cause is a wrong clock, and the raw message
|
|
62
|
+
# doesn't say so.
|
|
63
|
+
hint = e.message.include?("RequestTimeExpired") ? " — your system clock may be off by more than 15 minutes" : ""
|
|
64
|
+
UI.die!("#{e.message}#{hint}")
|
|
49
65
|
end
|
|
50
66
|
|
|
51
67
|
private
|
|
@@ -131,7 +147,7 @@ module Everywhere
|
|
|
131
147
|
def rollback!(version, latest_alias:)
|
|
132
148
|
# os/arch come from the receipt on publish; on rollback there may be no
|
|
133
149
|
# dist/, so take the target from build.targets (or the default).
|
|
134
|
-
target = Array(@config.targets).first ||
|
|
150
|
+
target = Array(@config.targets).first || Build::DEFAULT_TARGET
|
|
135
151
|
os, arch = target.split("-", 2)
|
|
136
152
|
loc = { channel: @channel, os: os, arch: arch, prefix: prefix }
|
|
137
153
|
|
|
@@ -207,6 +223,8 @@ module Everywhere
|
|
|
207
223
|
signature
|
|
208
224
|
rescue Minisign::PassphraseError
|
|
209
225
|
UI.die!("wrong or missing key passphrase — set EVERYWHERE_SIGNING_KEY_PASSPHRASE")
|
|
226
|
+
rescue Minisign::Error => e
|
|
227
|
+
UI.die!("couldn't sign with that key — #{e.message}")
|
|
210
228
|
end
|
|
211
229
|
|
|
212
230
|
def signing_key(key_option)
|
|
@@ -244,7 +262,7 @@ module Everywhere
|
|
|
244
262
|
end
|
|
245
263
|
|
|
246
264
|
def content_type(filename)
|
|
247
|
-
|
|
265
|
+
CONTENT_TYPES.fetch(File.extname(filename), "application/octet-stream")
|
|
248
266
|
end
|
|
249
267
|
|
|
250
268
|
def read_notes(notes, notes_file)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "dry/cli"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "json"
|
|
4
6
|
require_relative "../shellout"
|
|
5
7
|
require_relative "../log_filter"
|
|
6
8
|
require_relative "../receipt"
|
|
@@ -17,46 +19,101 @@ module Everywhere
|
|
|
17
19
|
# surface (local, CI, Platform) must produce. See
|
|
18
20
|
# platform/docs/build-engine.md §4, §7, §9.
|
|
19
21
|
#
|
|
20
|
-
#
|
|
21
|
-
# ios/android/windows/linux slot in as builders later.
|
|
22
|
+
# macos/ios/android are live; windows/linux slot in as builders later.
|
|
22
23
|
class Release < Dry::CLI::Command
|
|
23
24
|
NOTARIZE = File.expand_path("../../../support/release/macos/notarize.sh", __dir__)
|
|
24
25
|
|
|
26
|
+
# What counts as "the distributable" per os, newest first when several
|
|
27
|
+
# match. Android is listed aab-then-apk so a `--format aab` run picks the
|
|
28
|
+
# bundle even when an older apk is still sitting in dist/.
|
|
29
|
+
ARTIFACTS = { "macos" => %w[*.zip], "ios" => %w[*.ipa], "android" => %w[*.aab *.apk] }.freeze
|
|
30
|
+
|
|
25
31
|
desc "Sign, notarize, staple, and emit release.json for a distributable build"
|
|
26
32
|
|
|
27
33
|
option :root, default: ".", desc: "App root directory"
|
|
28
|
-
option :target, default:
|
|
34
|
+
option :target, default: Build::DEFAULT_TARGET, desc: "Build target (os-arch)"
|
|
29
35
|
option :channel, default: "direct", desc: "Distribution channel (direct | mac_app_store | ...)"
|
|
30
36
|
option :ruby, default: nil, desc: "Ruby version to package (default: build.ruby or 4.0.6)"
|
|
31
37
|
option :entry, default: "native_boot.rb", desc: "Entry point script relative to root"
|
|
32
38
|
option :app_name, default: nil, desc: "Bundle name (default from config)"
|
|
33
39
|
option :shell_dir, default: nil, desc: "Path to the shell's src-tauri directory"
|
|
34
40
|
option :env_file, default: nil, desc: "dotenv file with APPLE_* signing secrets (default: .env.release)"
|
|
35
|
-
option :
|
|
41
|
+
option :format, default: "apk", desc: "Android artifact format (apk | aab)"
|
|
42
|
+
option :skip_build, type: :boolean, default: false, desc: "Sign the existing build instead of rebuilding"
|
|
36
43
|
|
|
37
|
-
def call(root: ".", target:
|
|
38
|
-
app_name: nil, shell_dir: nil, env_file: nil, skip_build: false, **)
|
|
44
|
+
def call(root: ".", target: Build::DEFAULT_TARGET, channel: "direct", ruby: nil, entry: "native_boot.rb",
|
|
45
|
+
app_name: nil, shell_dir: nil, env_file: nil, format: "apk", skip_build: false, **)
|
|
39
46
|
root_path = File.expand_path(root)
|
|
40
47
|
config = Everywhere::Config.load(root_path)
|
|
41
48
|
app_name ||= config.name(target: target)
|
|
42
49
|
ruby ||= config.build_ruby || "4.0.6"
|
|
50
|
+
os = Everywhere::Config.os_of(target)
|
|
43
51
|
|
|
44
|
-
preflight!(target)
|
|
52
|
+
preflight!(target, os)
|
|
45
53
|
|
|
46
54
|
framework = config.remote? ? nil : Framework.detect(root_path)
|
|
47
55
|
dist_dir = File.expand_path("dist", root_path)
|
|
48
56
|
|
|
49
|
-
# 1. Produce (or reuse) the signed
|
|
50
|
-
# notarize step
|
|
57
|
+
# 1. Produce (or reuse) the build. macOS gets a dev-signed .app that the
|
|
58
|
+
# notarize step re-signs inside-out with the Developer ID; the mobile
|
|
59
|
+
# toolchains sign as they build, so their artifact is already final.
|
|
51
60
|
unless skip_build
|
|
52
61
|
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,
|
|
62
|
+
Build.new.call(root: root, ruby: ruby, entry: entry, app_name: app_name, target: target,
|
|
63
|
+
shell_dir: shell_dir, **, **build_options(os, format, channel))
|
|
54
64
|
end
|
|
55
65
|
|
|
66
|
+
# 2. Sign/notarize (macOS) or locate what the mobile builder signed, then
|
|
67
|
+
# 3. record the signing facts and emit the receipt.
|
|
68
|
+
artifact, signing = case os
|
|
69
|
+
when "macos" then notarize!(dist_dir, root_path, env_file)
|
|
70
|
+
when "ios" then [find_artifact(dist_dir, os), ios_signing(dist_dir)]
|
|
71
|
+
else [find_artifact(dist_dir, os), android_signing]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
receipt = Receipt.new(root: root_path, config: config, framework: framework, ruby: ruby,
|
|
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)
|
|
78
|
+
|
|
79
|
+
summarize(out, artifact, signing, root_path)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def preflight!(target, os)
|
|
85
|
+
case os
|
|
86
|
+
when "macos"
|
|
87
|
+
UI.die!("`every release --target #{target}` runs on macOS only") unless darwin?
|
|
88
|
+
UI.die!("signing engine missing at #{NOTARIZE}") unless File.exist?(NOTARIZE)
|
|
89
|
+
when "ios"
|
|
90
|
+
UI.die!("iOS releases need Xcode — `every release --target #{target}` runs on macOS only") unless darwin?
|
|
91
|
+
when "android"
|
|
92
|
+
nil # Gradle signs and builds anywhere the Android SDK does — Linux included.
|
|
93
|
+
else
|
|
94
|
+
UI.die!("no release builder for #{target} — macos-*, ios-* and android-* are supported")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def darwin? = RUBY_PLATFORM.include?("darwin")
|
|
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)}")
|
|
@@ -65,27 +122,75 @@ module Everywhere
|
|
|
65
122
|
log = "#{app_dir}.notarize.log"
|
|
66
123
|
Shellout.run_logged!(env, ["/bin/bash", NOTARIZE, app_dir], log: log, filter: LogFilter.for(:notarize))
|
|
67
124
|
|
|
68
|
-
# 3. Gather signing facts from the bundle itself, then emit the receipt.
|
|
69
125
|
artifact = "#{app_dir.sub(/\.app\z/, "")}.zip" # notarize.sh re-zips the stapled app
|
|
70
126
|
UI.die!("expected notarized zip #{artifact} not found") unless File.exist?(artifact)
|
|
71
|
-
artifact = dashify(artifact)
|
|
72
127
|
|
|
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)
|
|
128
|
+
[dashify(artifact), gather_signing(app_dir, log)]
|
|
129
|
+
end
|
|
78
130
|
|
|
79
|
-
|
|
131
|
+
# The mobile artifact ships as-is — no zip, no rename: an .ipa/.apk/.aab is
|
|
132
|
+
# already the installable container the stores and devices expect.
|
|
133
|
+
def find_artifact(dist_dir, os)
|
|
134
|
+
patterns = ARTIFACTS.fetch(os)
|
|
135
|
+
found = patterns.filter_map { |p| Dir[File.join(dist_dir, p)].max_by { |f| File.mtime(f) } }.first
|
|
136
|
+
return found if found
|
|
137
|
+
|
|
138
|
+
UI.die!("no #{patterns.map { |p| p.delete("*") }.join(" or ")} found in #{dist_dir} — " \
|
|
139
|
+
"run without --skip-build first")
|
|
80
140
|
end
|
|
81
141
|
|
|
82
|
-
|
|
142
|
+
# Reaching this line IS the verification for iOS: xcodebuild -exportArchive
|
|
143
|
+
# refuses to emit an .ipa it couldn't sign with the requested profile, so
|
|
144
|
+
# there's nothing left to probe (and no spctl/stapler for a mobile bundle).
|
|
145
|
+
def ios_signing(dist_dir)
|
|
146
|
+
{
|
|
147
|
+
"signed" => true,
|
|
148
|
+
"signed_by" => ENV["EVERY_IOS_TEAM_ID"],
|
|
149
|
+
"verified" => true,
|
|
150
|
+
"platform" => {
|
|
151
|
+
"provisioning_profile" => ENV["EVERY_IOS_PROVISIONING_PROFILE"],
|
|
152
|
+
"distribution_method" => ENV["EVERY_IOS_EXPORT_METHOD"] || "app-store-connect",
|
|
153
|
+
"upload" => upload_bag(dist_dir)
|
|
154
|
+
}.compact
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# dist/ios-upload.json is written by Builders::Ios only after the App Store
|
|
159
|
+
# Connect upload pass succeeded — so it's absent (and the key compacted
|
|
160
|
+
# away) for every non-store channel. This is the contract the Platform's
|
|
161
|
+
# store distributors read back out of the receipt.
|
|
162
|
+
def upload_bag(dist_dir)
|
|
163
|
+
path = File.join(dist_dir, "ios-upload.json")
|
|
164
|
+
return nil unless File.exist?(path)
|
|
165
|
+
|
|
166
|
+
JSON.parse(File.read(path))
|
|
167
|
+
rescue JSON::ParserError
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
83
170
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
171
|
+
# Gradle only signs when a keystore was handed to it; without one it still
|
|
172
|
+
# produces a (debug/unsigned) artifact, so the receipt has to say which.
|
|
173
|
+
def android_signing
|
|
174
|
+
keystore = ENV["EVERY_ANDROID_KEYSTORE"]
|
|
175
|
+
fingerprint = keystore_fingerprint(keystore)
|
|
176
|
+
{
|
|
177
|
+
"signed" => !fingerprint.nil?,
|
|
178
|
+
"signed_by" => fingerprint,
|
|
179
|
+
"verified" => !fingerprint.nil?,
|
|
180
|
+
"platform" => {
|
|
181
|
+
"signing_scheme" => fingerprint ? "v2" : "unsigned",
|
|
182
|
+
"keystore_fingerprint" => fingerprint
|
|
183
|
+
}.compact
|
|
184
|
+
}
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The file digest, not keytool's certificate fingerprint: it identifies the
|
|
188
|
+
# keystore that signed this build without shelling out to the JDK (which the
|
|
189
|
+
# runner may not have on PATH) or unlocking it with the store password.
|
|
190
|
+
def keystore_fingerprint(keystore)
|
|
191
|
+
return nil if keystore.nil? || keystore.empty? || !File.exist?(keystore)
|
|
192
|
+
|
|
193
|
+
"sha256:#{Digest::SHA256.file(keystore).hexdigest}"
|
|
89
194
|
end
|
|
90
195
|
|
|
91
196
|
# The distributable filename must be URL/CI-safe, so spaces become dashes
|
|
@@ -140,12 +245,24 @@ module Everywhere
|
|
|
140
245
|
UI.phase("Release ready 🎉")
|
|
141
246
|
UI.success("#{rel(artifact, root_path)} #{UI.dim("(#{(File.size(artifact) / 1024.0 / 1024.0).round}MB — send this to your friends)")}")
|
|
142
247
|
UI.substep("signed by #{UI.bold(signing["signed_by"] || "?")}")
|
|
143
|
-
UI.substep(
|
|
144
|
-
"stapled #{UI.yn(signing.dig("platform", "stapled"))} · " \
|
|
145
|
-
"gatekeeper #{UI.yn(signing["verified"])}")
|
|
248
|
+
UI.substep(signing_line(signing["platform"] || {}, signing))
|
|
146
249
|
UI.substep("receipt #{UI.dim(rel(out, root_path))}")
|
|
147
250
|
end
|
|
148
251
|
|
|
252
|
+
def signing_line(platform, signing)
|
|
253
|
+
if platform.key?("notarized")
|
|
254
|
+
"notarized #{UI.yn(platform["notarized"])} · stapled #{UI.yn(platform["stapled"])} · " \
|
|
255
|
+
"gatekeeper #{UI.yn(signing["verified"])}"
|
|
256
|
+
elsif platform.key?("distribution_method")
|
|
257
|
+
upload = platform["upload"]
|
|
258
|
+
"profile #{UI.bold(platform["provisioning_profile"] || "?")} · " \
|
|
259
|
+
"method #{platform["distribution_method"]}" \
|
|
260
|
+
"#{" · uploaded to App Store Connect (build #{upload["build_number"] || "?"})" if upload}"
|
|
261
|
+
else
|
|
262
|
+
"signing scheme #{UI.bold(platform["signing_scheme"] || "?")}"
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
149
266
|
def rel(path, root_path) = UI.short_path(path.sub("#{root_path}/", "").sub("#{Dir.pwd}/", ""))
|
|
150
267
|
end
|
|
151
268
|
end
|
|
@@ -12,9 +12,9 @@ module Everywhere
|
|
|
12
12
|
class ShellDir < Dry::CLI::Command
|
|
13
13
|
desc "Print the path of the bundled shell (Tauri src-tauri, or the iOS template)"
|
|
14
14
|
|
|
15
|
-
option :target, default: "macos
|
|
15
|
+
option :target, default: "macos", desc: "Build target (os or os-arch) selecting which shell"
|
|
16
16
|
|
|
17
|
-
def call(target: "macos
|
|
17
|
+
def call(target: "macos", **)
|
|
18
18
|
case Everywhere::Config.os_of(target)
|
|
19
19
|
when "ios" then puts Everywhere::Paths.ios_dir!
|
|
20
20
|
else puts Everywhere::Paths.shell_dir!
|