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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/exe/every +12 -1
  3. data/exe/rbe +12 -1
  4. data/lib/everywhere/agents_guide.rb +3 -1
  5. data/lib/everywhere/blake2b.rb +17 -1
  6. data/lib/everywhere/boot.rb +21 -4
  7. data/lib/everywhere/builders/android.rb +144 -30
  8. data/lib/everywhere/builders/desktop.rb +25 -17
  9. data/lib/everywhere/builders/ios.rb +244 -9
  10. data/lib/everywhere/cli.rb +2 -0
  11. data/lib/everywhere/commands/build.rb +113 -58
  12. data/lib/everywhere/commands/clean.rb +8 -3
  13. data/lib/everywhere/commands/dev.rb +85 -11
  14. data/lib/everywhere/commands/doctor.rb +138 -21
  15. data/lib/everywhere/commands/install.rb +48 -8
  16. data/lib/everywhere/commands/platform/build.rb +136 -27
  17. data/lib/everywhere/commands/platform/login.rb +16 -2
  18. data/lib/everywhere/commands/platform/runner.rb +113 -23
  19. data/lib/everywhere/commands/preview.rb +359 -0
  20. data/lib/everywhere/commands/publish.rb +20 -2
  21. data/lib/everywhere/commands/release.rb +145 -28
  22. data/lib/everywhere/commands/shell_dir.rb +2 -2
  23. data/lib/everywhere/config.rb +88 -3
  24. data/lib/everywhere/console.rb +2 -1
  25. data/lib/everywhere/engine.rb +24 -0
  26. data/lib/everywhere/framework.rb +21 -4
  27. data/lib/everywhere/ignore.rb +3 -1
  28. data/lib/everywhere/jump.rb +121 -0
  29. data/lib/everywhere/mobile_config_endpoint.rb +47 -0
  30. data/lib/everywhere/mobile_configs_controller.rb +46 -0
  31. data/lib/everywhere/paths.rb +13 -6
  32. data/lib/everywhere/platform/client.rb +27 -6
  33. data/lib/everywhere/platform/credentials.rb +18 -8
  34. data/lib/everywhere/platform/snapshot.rb +10 -0
  35. data/lib/everywhere/receipt.rb +55 -10
  36. data/lib/everywhere/shellout.rb +19 -0
  37. data/lib/everywhere/simulator.rb +12 -1
  38. data/lib/everywhere/version.rb +1 -1
  39. data/support/mobile/android/app/build.gradle.kts +29 -1
  40. data/support/mobile/ios/App/EverywhereConfig.swift +17 -5
  41. data/support/mobile/ios/App/SceneDelegate.swift +75 -8
  42. data/support/mobile/ios/App.xcodeproj/project.pbxproj +2 -2
  43. data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +0 -1
  44. data/support/mobile/ios/README.md +13 -6
  45. 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 || "macos-arm64"
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
- filename.end_with?(".zip") ? "application/zip" : "application/octet-stream"
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
- # Today only the macos builder is live; the target is parameterized so
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: "macos-arm64", desc: "Build target (os-arch)"
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 :skip_build, type: :boolean, default: false, desc: "Notarize the existing .app instead of rebuilding"
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: "macos-arm64", channel: "direct", ruby: nil, entry: "native_boot.rb",
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-for-dev .app via the build path. The
50
- # notarize step below re-signs it inside-out with the Developer ID.
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, shell_dir: shell_dir, **)
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
- signing = gather_signing(app_dir, log)
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)
128
+ [dashify(artifact), gather_signing(app_dir, log)]
129
+ end
78
130
 
79
- summarize(out, artifact, signing, root_path)
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
- private
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
- def preflight!(target)
85
- os = target.split("-", 2).first
86
- UI.die!("only the macos target is implemented (got #{target}); other builders are stubs") unless os == "macos"
87
- UI.die!("`every release` runs on macOS only") unless RUBY_PLATFORM.include?("darwin")
88
- UI.die!("signing engine missing at #{NOTARIZE}") unless File.exist?(NOTARIZE)
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("notarized #{UI.yn(signing.dig("platform", "notarized"))} · " \
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-arm64", desc: "Build target (os or os-arch) selecting which shell"
15
+ option :target, default: "macos", desc: "Build target (os or os-arch) selecting which shell"
16
16
 
17
- def call(target: "macos-arm64", **)
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!