ruby_everywhere 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/exe/every +2 -2
  3. data/exe/rbe +2 -2
  4. data/lib/everywhere/agents_guide.rb +3 -1
  5. data/lib/everywhere/blake2b.rb +17 -1
  6. data/lib/everywhere/boot.rb +30 -6
  7. data/lib/everywhere/builders/android.rb +152 -64
  8. data/lib/everywhere/builders/base.rb +53 -0
  9. data/lib/everywhere/builders/desktop.rb +32 -40
  10. data/lib/everywhere/builders/ios.rb +268 -36
  11. data/lib/everywhere/builders/native_sources.rb +38 -0
  12. data/lib/everywhere/child_processes.rb +4 -4
  13. data/lib/everywhere/child_supervision.rb +172 -0
  14. data/lib/everywhere/cli.rb +2 -0
  15. data/lib/everywhere/clock.rb +12 -0
  16. data/lib/everywhere/commands/build.rb +132 -66
  17. data/lib/everywhere/commands/clean.rb +8 -3
  18. data/lib/everywhere/commands/dev.rb +91 -242
  19. data/lib/everywhere/commands/doctor.rb +138 -21
  20. data/lib/everywhere/commands/install.rb +49 -8
  21. data/lib/everywhere/commands/platform/auth_status.rb +1 -0
  22. data/lib/everywhere/commands/platform/build.rb +141 -33
  23. data/lib/everywhere/commands/platform/login.rb +20 -6
  24. data/lib/everywhere/commands/platform/logout.rb +1 -0
  25. data/lib/everywhere/commands/platform/runner.rb +170 -25
  26. data/lib/everywhere/commands/preview.rb +286 -0
  27. data/lib/everywhere/commands/publish.rb +22 -2
  28. data/lib/everywhere/commands/release.rb +155 -35
  29. data/lib/everywhere/commands/shell_dir.rb +4 -2
  30. data/lib/everywhere/commands/updates_keygen.rb +25 -1
  31. data/lib/everywhere/config/app.rb +126 -0
  32. data/lib/everywhere/config/auth.rb +108 -0
  33. data/lib/everywhere/config/data.rb +50 -0
  34. data/lib/everywhere/config/deep_linking.rb +107 -0
  35. data/lib/everywhere/config/desktop_ui.rb +153 -0
  36. data/lib/everywhere/config/mobile.rb +211 -0
  37. data/lib/everywhere/config/native_desktop.rb +168 -0
  38. data/lib/everywhere/config/native_mobile.rb +337 -0
  39. data/lib/everywhere/config/shell.rb +57 -0
  40. data/lib/everywhere/config/updates.rb +63 -0
  41. data/lib/everywhere/config.rb +59 -1367
  42. data/lib/everywhere/console.rb +2 -1
  43. data/lib/everywhere/desktop_dev_app.rb +138 -0
  44. data/lib/everywhere/dock/state.rb +3 -1
  45. data/lib/everywhere/engine.rb +24 -0
  46. data/lib/everywhere/entrypoint.rb +24 -0
  47. data/lib/everywhere/error.rb +8 -0
  48. data/lib/everywhere/framework.rb +23 -6
  49. data/lib/everywhere/host.rb +11 -0
  50. data/lib/everywhere/ignore.rb +13 -5
  51. data/lib/everywhere/jump.rb +121 -0
  52. data/lib/everywhere/line_pump.rb +3 -1
  53. data/lib/everywhere/minisign.rb +1 -0
  54. data/lib/everywhere/mobile_config_endpoint.rb +47 -0
  55. data/lib/everywhere/mobile_configs_controller.rb +46 -0
  56. data/lib/everywhere/native_platform.rb +44 -0
  57. data/lib/everywhere/paths.rb +36 -13
  58. data/lib/everywhere/platform/client.rb +46 -7
  59. data/lib/everywhere/platform/credentials.rb +18 -8
  60. data/lib/everywhere/platform/snapshot.rb +12 -0
  61. data/lib/everywhere/plist.rb +17 -0
  62. data/lib/everywhere/png.rb +1 -0
  63. data/lib/everywhere/raw_tty.rb +51 -0
  64. data/lib/everywhere/receipt.rb +55 -10
  65. data/lib/everywhere/s3.rb +1 -0
  66. data/lib/everywhere/shell_pages.rb +109 -0
  67. data/lib/everywhere/shellout.rb +19 -0
  68. data/lib/everywhere/simulator.rb +12 -1
  69. data/lib/everywhere/tab_filter.rb +36 -0
  70. data/lib/everywhere/task_pool.rb +3 -4
  71. data/lib/everywhere/ui.rb +6 -1
  72. data/lib/everywhere/version.rb +6 -4
  73. data/lib/everywhere.rb +1 -2
  74. data/support/mobile/android/app/build.gradle.kts +29 -1
  75. data/support/mobile/ios/App/EverywhereConfig.swift +17 -5
  76. data/support/mobile/ios/App/SceneDelegate.swift +75 -8
  77. data/support/mobile/ios/App.xcodeproj/project.pbxproj +2 -4
  78. data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +0 -1
  79. data/support/mobile/ios/README.md +13 -6
  80. data/support/release/macos/notarize.sh +3 -0
  81. metadata +40 -1
@@ -7,15 +7,19 @@ require "shellwords"
7
7
  require "base64"
8
8
  require "json"
9
9
  require "open3"
10
+ require "securerandom"
11
+ require "rubygems/package"
12
+ require "zlib"
10
13
  require_relative "../../ui"
14
+ require_relative "../../host"
11
15
  require_relative "../../paths"
12
16
  require_relative "../../platform/client"
13
17
 
14
18
  module Everywhere
15
19
  module Commands
16
- # `every platform runner` — runs ON a build runner (a GitHub Actions macOS
17
- # machine, or any Mac). It's the glue between the Runner API and the
18
- # `every release` signing engine:
20
+ # `every platform runner` — runs ON a build runner: a macOS machine for the
21
+ # macos/ios targets, macOS or Linux for android. It's the glue between the
22
+ # Runner API and the `every release` signing engine:
19
23
  #
20
24
  # job spec → mark running → download+extract snapshot → pull JIT creds into
21
25
  # a throwaway keychain (+ .p8) → every release (streaming logs) → upload the
@@ -24,6 +28,30 @@ module Everywhere
24
28
  # Authenticated with the per-artifact runner token, so it can only touch the
25
29
  # one artifact it was dispatched for.
26
30
  class PlatformRunner < Dry::CLI::Command
31
+ # What `every release` leaves in dist/ per os, newest first when several
32
+ # match, and how the blob store should label it.
33
+ ARTIFACTS = { "macos" => %w[*.zip], "ios" => %w[*.ipa], "android" => %w[*.aab *.apk] }.freeze
34
+ CONTENT_TYPES = {
35
+ ".zip" => "application/zip",
36
+ ".ipa" => "application/octet-stream",
37
+ ".apk" => "application/vnd.android.package-archive",
38
+ ".aab" => "application/octet-stream"
39
+ }.freeze
40
+ RELEASE_NARRATION = {
41
+ "macos" => "every release — press → sign → notarize → staple",
42
+ "ios" => "every release — build → archive → export .ipa",
43
+ "android" => "every release — assemble → sign → bundle"
44
+ }.freeze
45
+
46
+ # A .mobileprovision has to be in both: Xcode reads the UserData copy,
47
+ # xcodebuild's own resolution still reads the MobileDevice one.
48
+ PROFILE_DIRS = [
49
+ "~/Library/MobileDevice/Provisioning Profiles",
50
+ "~/Library/Developer/Xcode/UserData/Provisioning Profiles"
51
+ ].freeze
52
+
53
+ PROFILE_UUID = /\A[0-9a-f-]+\z/i
54
+
27
55
  desc "Run a Platform build on this machine (used by the hosted runner)"
28
56
 
29
57
  option :artifact, required: true, desc: "Artifact public id (art_…)"
@@ -49,10 +77,10 @@ module Everywhere
49
77
  src = fetch_snapshot(work, job)
50
78
  signing_env = pull_credentials(work)
51
79
 
52
- status, release_json, zip = run_release(src, job, signing_env, shell_dir)
80
+ status, release_json, built = run_release(src, job, signing_env, shell_dir)
53
81
 
54
- if status == "succeeded" && zip
55
- complete_success(zip, release_json)
82
+ if status == "succeeded" && built
83
+ complete_success(built, release_json)
56
84
  else
57
85
  complete_failure("build failed — see log")
58
86
  end
@@ -62,6 +90,12 @@ module Everywhere
62
90
  rescue Everywhere::Error => e
63
91
  complete_failure(e.message)
64
92
  UI.die!(e.message)
93
+ rescue Everywhere::Fatal => e
94
+ # UI.die! raises Fatal (a SystemExit), which the rescue above can't see —
95
+ # without this an upload/signing failure would leave the artifact stuck
96
+ # "running" on the Platform forever.
97
+ complete_failure(e.message.to_s.empty? ? "build failed" : e.message)
98
+ raise
65
99
  end
66
100
 
67
101
  private
@@ -74,32 +108,128 @@ module Everywhere
74
108
  @client.download(job["snapshot_url"], tarball)
75
109
  src = File.join(work, "src")
76
110
  FileUtils.mkdir_p(src)
77
- UI.die!("failed to extract snapshot") unless system("tar", "-xzf", tarball, "-C", src)
111
+ extract_snapshot(tarball, src)
78
112
  src
79
113
  end
80
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
+
81
147
  def pull_credentials(work)
82
148
  remote_log("pulling signing credentials")
83
149
  creds = get!("/runner/artifacts/#{@artifact}/credentials")["credentials"] || []
84
150
  env = {}
85
151
  creds.each do |cred|
86
- path = File.join(work, cred["filename"])
152
+ path = credential_path(work, cred["filename"])
87
153
  File.binwrite(path, Base64.strict_decode64(cred["data_base64"]))
88
154
  File.chmod(0o600, path)
155
+ meta = cred["metadata"] || {}
89
156
  case cred["kind"]
90
157
  when "apple_developer_id", "apple_distribution"
91
- env["APPLE_SIGNING_IDENTITY"] = install_certificate(path, cred["metadata"] || {}, work)
158
+ env["EVERY_IOS_TEAM_ID"] ||= meta["team_id"]
159
+ # An android job runs on Linux, where there's no keychain to import
160
+ # into. A stray apple credential there is the Platform's mistake, not
161
+ # a reason to lose the build.
162
+ if Host.darwin?
163
+ env["APPLE_SIGNING_IDENTITY"] = install_certificate(path, meta, work)
164
+ else
165
+ remote_log("skipping #{cred["kind"]} — keychain import needs macOS")
166
+ end
92
167
  when "apple_asc_api_key"
93
168
  env["NOTARY_KEY"] = path
94
- env["NOTARY_KEY_ID"] = cred.dig("metadata", "key_id")
95
- env["NOTARY_ISSUER"] = cred.dig("metadata", "issuer_id")
169
+ env["NOTARY_KEY_ID"] = meta["key_id"]
170
+ env["NOTARY_ISSUER"] = meta["issuer_id"]
171
+ when "apple_provisioning_profile"
172
+ profile = install_provisioning_profile(path)
173
+ env["EVERY_IOS_PROVISIONING_PROFILE"] = profile[:name]
174
+ env["EVERY_IOS_TEAM_ID"] ||= profile[:team]
175
+ when "android_keystore"
176
+ env["EVERY_ANDROID_KEYSTORE"] = path
177
+ env["EVERY_ANDROID_KEYSTORE_PASSWORD"] = meta["store_password"]
178
+ env["EVERY_ANDROID_KEY_ALIAS"] = meta["key_alias"]
179
+ # One-key keystores are usually created with a single password.
180
+ env["EVERY_ANDROID_KEY_PASSWORD"] = meta["key_password"] || meta["store_password"]
96
181
  end
97
182
  end
98
- env
183
+ env.compact
99
184
  end
100
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
+
196
+ # A .mobileprovision is a CMS-signed DER blob wrapped around a plain XML
197
+ # plist. Scanning for the plist reads it identically on Linux, where
198
+ # `security cms -D` doesn't exist.
199
+ def install_provisioning_profile(path)
200
+ plist = File.binread(path)[/<\?xml.*<\/plist>/m]
201
+ UI.die!("the delivered provisioning profile has no embedded plist") unless plist
202
+
203
+ uuid = plist[%r{<key>UUID</key>\s*<string>([^<]+)</string>}m, 1]
204
+ name = plist[%r{<key>Name</key>\s*<string>([^<]+)</string>}m, 1]
205
+ # TeamIdentifier is an <array> of one — the first <string> after the key.
206
+ team = plist[%r{<key>TeamIdentifier</key>.*?<string>([^<]+)</string>}m, 1]
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)
211
+
212
+ if Host.darwin?
213
+ profile_dirs.each do |dir|
214
+ FileUtils.mkdir_p(dir)
215
+ FileUtils.cp(path, File.join(dir, "#{uuid}.mobileprovision"))
216
+ end
217
+ end
218
+ remote_log("provisioning profile #{UI.bold(name || uuid)}#{team ? " #{UI.dim("(team #{team})")}" : ""}")
219
+ { name: name, team: team, uuid: uuid }
220
+ end
221
+
222
+ def profile_dirs = PROFILE_DIRS.map { |dir| File.expand_path(dir) }
223
+
224
+
101
225
  # Import the .p12 into a throwaway keychain and add it to the search list so
102
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.
103
233
  def install_certificate(p12, metadata, work)
104
234
  @keychain = File.join(work, "runner.keychain-db")
105
235
  password = SecureRandom.hex(12)
@@ -124,11 +254,16 @@ module Everywhere
124
254
  end
125
255
 
126
256
  def run_release(src, job, signing_env, shell_dir)
127
- remote_log("every release — press → sign → notarize → staple")
257
+ os = job.dig("target", "os").to_s
258
+ channel = job.dig("target", "distribution_channel").to_s
259
+ remote_log(RELEASE_NARRATION.fetch(os, "every release"))
128
260
  cmd = [RbConfig.ruby, "-I", lib_dir, every_exe, "release",
129
261
  "--root", src,
130
- "--target", "#{job.dig("target", "os")}-#{job.dig("target", "arch")}",
131
- "--channel", job.dig("target", "distribution_channel").to_s]
262
+ "--target", "#{os}-#{job.dig("target", "arch")}",
263
+ "--channel", channel]
264
+ # Play wants an App Bundle; every other Android channel (direct download,
265
+ # sideload, Amazon) wants the installable apk `every release` defaults to.
266
+ cmd += ["--format", "aab"] if os == "android" && channel == "play"
132
267
  cmd += ["--shell-dir", shell_dir] if shell_dir
133
268
 
134
269
  # The web UI renders ANSI, but `every release` runs here as a pipe (not a
@@ -144,37 +279,45 @@ module Everywhere
144
279
 
145
280
  dist = File.join(src, "dist")
146
281
  release_json = JSON.parse(File.read(File.join(dist, "release.json"))) rescue nil
147
- zip = Dir[File.join(dist, "*.zip")].max_by { |f| File.mtime(f) }
148
- [success ? "succeeded" : "failed", release_json, zip]
282
+ built = ARTIFACTS.fetch(os, %w[*.zip])
283
+ .filter_map { |p| Dir[File.join(dist, p)].max_by { |f| File.mtime(f) } }.first
284
+ [success ? "succeeded" : "failed", release_json, built]
149
285
  end
150
286
 
151
- def complete_success(zip, release_json)
287
+ def complete_success(built, release_json)
152
288
  report_phase("uploading")
153
- remote_log("uploading artifact #{UI.dim("(#{(File.size(zip) / 1024.0 / 1024.0).round(1)}MB)")}")
154
- signed_id = upload_artifact(zip)
289
+ remote_log("uploading artifact #{UI.dim("(#{(File.size(built) / 1024.0 / 1024.0).round(1)}MB)")}")
290
+ signed_id = upload_artifact(built)
155
291
  post!("/runner/artifacts/#{@artifact}/complete",
156
292
  status: "succeeded", file_signed_id: signed_id, release_json: release_json)
157
- UI.success("build succeeded — #{File.basename(zip)}")
293
+ UI.success("build succeeded — #{File.basename(built)}")
158
294
  end
159
295
 
296
+ # Best-effort: if the thing that failed WAS connectivity, reporting the
297
+ # failure fails too — raising here would replace the real error and abort
298
+ # cleanup, so log locally and move on.
160
299
  def complete_failure(message)
161
300
  return if @completed
162
301
 
163
302
  @client.post("/runner/artifacts/#{@artifact}/complete", status: "failed", error: message)
164
303
  @completed = true
304
+ rescue Everywhere::Error => e
305
+ UI.warn("couldn't report the failure to the Platform (#{e.message})")
165
306
  end
166
307
 
167
- def upload_artifact(zip)
308
+ def upload_artifact(built)
168
309
  require_relative "../../platform/snapshot"
169
310
  reservation = post!("/runner/artifacts/#{@artifact}/upload",
170
- filename: File.basename(zip), byte_size: File.size(zip),
171
- checksum: Everywhere::Platform::Snapshot.md5_base64(zip), content_type: "application/zip")
311
+ filename: File.basename(built), byte_size: File.size(built),
312
+ checksum: Everywhere::Platform::Snapshot.md5_base64(built), content_type: content_type_for(built))
172
313
  du = reservation["direct_upload"] || {}
173
- res = @client.put_file(du["url"], zip, headers: du["headers"] || {})
314
+ res = @client.put_file(du["url"], built, headers: du["headers"] || {})
174
315
  UI.die!("artifact upload failed (HTTP #{res.code})") unless res.code.to_i.between?(200, 299)
175
316
  reservation["signed_id"]
176
317
  end
177
318
 
319
+ def content_type_for(path) = CONTENT_TYPES.fetch(File.extname(path), "application/octet-stream")
320
+
178
321
  # ---- log streaming -------------------------------------------------------
179
322
 
180
323
  # Run cmd, echoing output to this process's stdout AND forwarding it to the
@@ -241,6 +384,8 @@ module Everywhere
241
384
  end
242
385
 
243
386
  def restore_keychains
387
+ return unless Host.darwin?
388
+
244
389
  run_security("list-keychains", "-d", "user", "-s", *@saved_keychains) if @saved_keychains
245
390
  run_security("delete-keychain", @keychain) if @keychain && File.exist?(@keychain)
246
391
  end
@@ -0,0 +1,286 @@
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 "../child_supervision"
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
+ include ChildSupervision
34
+
35
+ desc "Run the dev server behind a shareable tunnel for the Jump app"
36
+
37
+ option :port, default: "3000", desc: "Dev server port"
38
+ option :lan, type: :boolean, default: false, desc: "Skip the tunnel and share the LAN address"
39
+ option :tunnel_name, default: nil, desc: "Run a named cloudflared tunnel instead of a quick one"
40
+ option :tunnel_host, default: nil, desc: "Public hostname the named tunnel is routed to"
41
+
42
+ TRYCLOUDFLARE_URL = %r{https://[a-z0-9-]+\.trycloudflare\.com}
43
+ TUNNEL_BOOT_TIMEOUT = 30
44
+
45
+ def call(port: "3000", lan: false, tunnel_name: nil, tunnel_host: nil, **)
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!
51
+ @framework = detect_local_framework
52
+ @config = Everywhere::Config.load(@framework.root)
53
+ # Multi-use on purpose: a forwarded link must work for a second
54
+ # coworker's device. Dies with this process — bearers are HMACs over
55
+ # it, so they die too.
56
+ @token = SecureRandom.urlsafe_base64(24)
57
+
58
+ @lan = lan
59
+ if lan
60
+ @url = lan_url(port)
61
+ else
62
+ Shellout.ensure_tool!("cloudflared",
63
+ "preview shares your app through a Cloudflare tunnel — " \
64
+ "install it with `brew install cloudflared` (or use --lan)")
65
+ validate_tunnel_options!(tunnel_name, tunnel_host)
66
+ @tunnel_name = tunnel_name
67
+ @tunnel_host = tunnel_host
68
+ @url = start_tunnel
69
+ end
70
+
71
+ start_server(port)
72
+ warn_if_loopback_only if lan
73
+ announce
74
+ wait
75
+ rescue Interrupt
76
+ nil
77
+ ensure
78
+ teardown_children
79
+ end
80
+
81
+ private
82
+
83
+ # Preview exists to share a *local* dev server. A remote-mode root has
84
+ # no local server — the deployed URL is already shareable.
85
+ def detect_local_framework
86
+ Framework.detect
87
+ rescue Everywhere::Error
88
+ config = Everywhere::Config.load(Dir.pwd)
89
+ if config.remote?
90
+ UI.die!("this app runs in remote mode — #{config.remote_url} is already online, share that " \
91
+ "(preview tunnels a local dev server)")
92
+ end
93
+ UI.die!("no supported framework here (Rails, Hanami, Sinatra) — nothing to preview")
94
+ end
95
+
96
+ def validate_tunnel_options!(name, host)
97
+ return if name.nil? && host.nil?
98
+ return if name && host
99
+
100
+ UI.die!("--tunnel-name and --tunnel-host go together: cloudflared runs the named tunnel, " \
101
+ "but only your Cloudflare DNS knows its public hostname")
102
+ end
103
+
104
+ # --- transports -----------------------------------------------------------
105
+
106
+ def lan_url(port)
107
+ "http://#{lan_ip}:#{port}"
108
+ end
109
+
110
+ def lan_ip
111
+ Socket.ip_address_list.find { |a| a.ipv4_private? }&.ip_address ||
112
+ UI.die!("couldn't find a LAN address — is this machine on a network? (drop --lan to tunnel instead)")
113
+ end
114
+
115
+ def start_tunnel
116
+ if @tunnel_name
117
+ UI.step("starting named tunnel #{UI.bold(@tunnel_name)} → #{UI.cyan("https://#{@tunnel_host}")}")
118
+ spawn_tunnel("cloudflared tunnel run --url http://127.0.0.1:#{@port} #{@tunnel_name.shellescape}")
119
+ "https://#{@tunnel_host}"
120
+ else
121
+ UI.step("starting Cloudflare quick tunnel")
122
+ quick_tunnel_url
123
+ end
124
+ end
125
+
126
+ # Quick tunnels choose their hostname at startup and print it once;
127
+ # everything else cloudflared logs is noise here, so the relay drops it
128
+ # and the on_line hook fishes out the URL.
129
+ def quick_tunnel_url
130
+ found = Queue.new
131
+ spawn_tunnel("cloudflared tunnel --url http://127.0.0.1:#{@port}",
132
+ on_line: ->(line) { (url = line[TRYCLOUDFLARE_URL]) && found << url },
133
+ quiet: true)
134
+ begin
135
+ Timeout.timeout(TUNNEL_BOOT_TIMEOUT) { found.pop }
136
+ rescue Timeout::Error
137
+ UI.die!("cloudflared never reported a tunnel URL after #{TUNNEL_BOOT_TIMEOUT}s — " \
138
+ "check your network, or share on the local network with --lan")
139
+ end
140
+ end
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.
144
+ def spawn_tunnel(cmd, on_line: nil, quiet: false)
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)
147
+ end
148
+
149
+ # Rails' puma template binds 0.0.0.0, but a rackup-style bin/dev often
150
+ # pins --host 127.0.0.1 — then the LAN URL on the QR is a promise the
151
+ # server can't keep. Say so, instead of letting the phone time out.
152
+ def warn_if_loopback_only
153
+ TCPSocket.new(URI.parse(@url).host, Integer(@port)).close
154
+ rescue SystemCallError
155
+ UI.warn("the dev server only listens on 127.0.0.1, so phones can't reach #{@url} — " \
156
+ "make bin/dev bind 0.0.0.0 (rackup --host 0.0.0.0), or drop --lan and tunnel instead")
157
+ end
158
+
159
+ # --- the dev server -------------------------------------------------------
160
+
161
+ def start_server(port)
162
+ # Attaching to an already-running server (dev's behavior) can't work
163
+ # here: that process has no EVERYWHERE_JUMP_TOKEN, so Jump could never
164
+ # pair with it.
165
+ if port_open?(port)
166
+ UI.die!("something is already running on port #{port} — preview must start the server itself " \
167
+ "(the Jump pairing token rides in on its environment); stop that server or use --port")
168
+ end
169
+
170
+ UI.step("starting #{UI.bold(@framework.name)} dev server #{UI.dim("(" + @framework.dev_command + ")")}")
171
+ env = { "PORT" => port.to_s,
172
+ "EVERYWHERE_JUMP_TOKEN" => @token,
173
+ "EVERYWHERE_PREVIEW_HOST" => URI.parse(@url).host }
174
+ # `rails server` binds localhost by default, which would make the LAN
175
+ # address on the QR a lie. BINDING is Rails' own env override; rackup
176
+ # bin/devs that pin --host still get warn_if_loopback_only.
177
+ env["BINDING"] = "0.0.0.0" if @lan
178
+ pid = supervise(:web, env, @framework.dev_command, chdir: @framework.root)
179
+ wait_for_port(port, pid: pid)
180
+ end
181
+
182
+ # --- the invitation -------------------------------------------------------
183
+
184
+ def jump_base = ENV.fetch("EVERY_JUMP_URL", "https://jump.rubyeverywhere.com").chomp("/")
185
+
186
+ def connect_url
187
+ "#{jump_base}/connect?#{URI.encode_www_form(url: @url, token: @token)}"
188
+ end
189
+
190
+ # POST /j on the Jump site trades the long connect URL for a short one —
191
+ # a nicer link to read out and a much smaller QR. Best-effort: preview
192
+ # works offline from the Jump site's point of view.
193
+ def share_link
194
+ require "net/http"
195
+ require "json"
196
+ uri = URI.parse("#{jump_base}/j")
197
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
198
+ open_timeout: 5, read_timeout: 5) do |http|
199
+ http.post(uri.path, JSON.generate(url: @url, token: @token),
200
+ "content-type" => "application/json")
201
+ end
202
+ response.code == "201" ? JSON.parse(response.body)["url"] : nil
203
+ rescue StandardError
204
+ nil
205
+ end
206
+
207
+ def announce
208
+ link = share_link || connect_url
209
+ UI.step("preview of #{UI.bold(@config.name)} is live → #{UI.cyan(@url)}")
210
+ Console.print(qr_ansi(link)) if $stdout.tty?
211
+ Console.print(" Scan with your phone's camera, or send:\n\n #{UI.cyan(link)}\n\n")
212
+ Console.print(" #{UI.dim("Manual entry in Jump:")} #{@url} #{UI.dim("token:")} #{@token}\n")
213
+ Console.print(UI.dim(" The link and QR die with this session — Ctrl-C to stop.\n"))
214
+ UI.note("waiting for devices…", marker: "⌁", color: :cyan)
215
+ end
216
+
217
+ # Half-block rendering: ▀ painted fg-for-top-module, bg-for-bottom packs
218
+ # two QR rows into one terminal line and one module into one column —
219
+ # a quarter of the area of rqrcode's own as_ansi, which burns two full
220
+ # cells per module. Explicit black/white (not theme colors), because a
221
+ # camera needs dark-on-light no matter what the terminal theme thinks.
222
+ #
223
+ # rqrcode is pure Ruby. The rescue is portability hardening: a missing
224
+ # gem downgrades the session to link-only instead of killing it.
225
+ def qr_ansi(link)
226
+ require "rqrcode"
227
+ modules = RQRCode::QRCode.new(link, level: :l).modules
228
+ quiet = 2
229
+ size = modules.length + quiet * 2
230
+ dark = lambda do |x, y|
231
+ gx = x - quiet
232
+ gy = y - quiet
233
+ gy.between?(0, modules.length - 1) && gx.between?(0, modules.length - 1) && modules[gy][gx]
234
+ end
235
+ (0...size).step(2).map do |y|
236
+ " " + (0...size).map do |x|
237
+ top = dark.call(x, y)
238
+ bottom = (y + 1 < size) && dark.call(x, y + 1)
239
+ "\e[#{top ? 30 : 97};#{bottom ? 40 : 107}m▀"
240
+ end.join + "\e[0m"
241
+ end.join("\n") << "\e[0m\n"
242
+ rescue LoadError, StandardError => e
243
+ UI.warn("couldn't render the QR (#{e.class}) — use the link below")
244
+ ""
245
+ end
246
+
247
+ # --- the wait loop --------------------------------------------------------
248
+
249
+ # The server's death ends the session; the tunnel's doesn't. Quick
250
+ # tunnels are explicitly best-effort — Cloudflare recycles them — so a
251
+ # dead tunnel is restarted and the fresh URL re-announced. (The token
252
+ # survives, but the origin changed, so recents/short links need the new
253
+ # invitation.)
254
+ def wait
255
+ loop do
256
+ sleep 1
257
+ web = @children.synchronize { @pids[:web] }
258
+ UI.die!("the dev server exited — scroll up for its output") unless ChildProcesses.alive?(web)
259
+
260
+ tunnel = @children.synchronize { @pids[:tunnel] }
261
+ next if tunnel.nil? || ChildProcesses.alive?(tunnel)
262
+
263
+ UI.warn("the tunnel dropped — starting a fresh one")
264
+ stop_child(:tunnel)
265
+ @url = start_tunnel
266
+ announce
267
+ end
268
+ end
269
+
270
+ # --- child supervision (dev.rb's pattern, without the dock) ---------------
271
+
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]
283
+ end
284
+ end
285
+ end
286
+ 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
 
@@ -157,6 +173,8 @@ module Everywhere
157
173
  def preflight!
158
174
  UI.die!("no updates.url in everywhere.yml — add an updates: section (see `every updates keygen`)") unless @config.updates_url
159
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?
160
178
  UI.die!("no updates.s3.bucket in everywhere.yml") if s3_config["bucket"].to_s.empty?
161
179
  UI.die!("missing bucket credentials — set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY") if
162
180
  ENV["AWS_ACCESS_KEY_ID"].to_s.empty? || ENV["AWS_SECRET_ACCESS_KEY"].to_s.empty?
@@ -207,6 +225,8 @@ module Everywhere
207
225
  signature
208
226
  rescue Minisign::PassphraseError
209
227
  UI.die!("wrong or missing key passphrase — set EVERYWHERE_SIGNING_KEY_PASSPHRASE")
228
+ rescue Minisign::Error => e
229
+ UI.die!("couldn't sign with that key — #{e.message}")
210
230
  end
211
231
 
212
232
  def signing_key(key_option)
@@ -244,7 +264,7 @@ module Everywhere
244
264
  end
245
265
 
246
266
  def content_type(filename)
247
- filename.end_with?(".zip") ? "application/zip" : "application/octet-stream"
267
+ CONTENT_TYPES.fetch(File.extname(filename), "application/octet-stream")
248
268
  end
249
269
 
250
270
  def read_notes(notes, notes_file)