ruby_everywhere 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/exe/every +2 -13
  3. data/exe/rbe +2 -13
  4. data/lib/everywhere/boot.rb +9 -2
  5. data/lib/everywhere/builders/android.rb +16 -42
  6. data/lib/everywhere/builders/base.rb +53 -0
  7. data/lib/everywhere/builders/desktop.rb +7 -23
  8. data/lib/everywhere/builders/ios.rb +49 -52
  9. data/lib/everywhere/builders/native_sources.rb +38 -0
  10. data/lib/everywhere/child_processes.rb +4 -4
  11. data/lib/everywhere/child_supervision.rb +172 -0
  12. data/lib/everywhere/clock.rb +12 -0
  13. data/lib/everywhere/commands/build.rb +20 -9
  14. data/lib/everywhere/commands/dev.rb +59 -284
  15. data/lib/everywhere/commands/doctor.rb +6 -6
  16. data/lib/everywhere/commands/install.rb +1 -0
  17. data/lib/everywhere/commands/platform/auth_status.rb +1 -0
  18. data/lib/everywhere/commands/platform/build.rb +5 -6
  19. data/lib/everywhere/commands/platform/login.rb +4 -4
  20. data/lib/everywhere/commands/platform/logout.rb +1 -0
  21. data/lib/everywhere/commands/platform/runner.rb +62 -7
  22. data/lib/everywhere/commands/preview.rb +24 -97
  23. data/lib/everywhere/commands/publish.rb +2 -0
  24. data/lib/everywhere/commands/release.rb +14 -11
  25. data/lib/everywhere/commands/shell_dir.rb +2 -0
  26. data/lib/everywhere/commands/updates_keygen.rb +25 -1
  27. data/lib/everywhere/config/app.rb +126 -0
  28. data/lib/everywhere/config/auth.rb +108 -0
  29. data/lib/everywhere/config/data.rb +50 -0
  30. data/lib/everywhere/config/deep_linking.rb +107 -0
  31. data/lib/everywhere/config/desktop_ui.rb +153 -0
  32. data/lib/everywhere/config/mobile.rb +211 -0
  33. data/lib/everywhere/config/native_desktop.rb +168 -0
  34. data/lib/everywhere/config/native_mobile.rb +337 -0
  35. data/lib/everywhere/config/shell.rb +57 -0
  36. data/lib/everywhere/config/updates.rb +63 -0
  37. data/lib/everywhere/config.rb +30 -1423
  38. data/lib/everywhere/desktop_dev_app.rb +138 -0
  39. data/lib/everywhere/dock/state.rb +3 -1
  40. data/lib/everywhere/entrypoint.rb +24 -0
  41. data/lib/everywhere/error.rb +8 -0
  42. data/lib/everywhere/framework.rb +2 -2
  43. data/lib/everywhere/host.rb +11 -0
  44. data/lib/everywhere/ignore.rb +11 -5
  45. data/lib/everywhere/line_pump.rb +3 -1
  46. data/lib/everywhere/minisign.rb +1 -0
  47. data/lib/everywhere/native_platform.rb +44 -0
  48. data/lib/everywhere/paths.rb +23 -7
  49. data/lib/everywhere/platform/client.rb +19 -1
  50. data/lib/everywhere/platform/snapshot.rb +6 -4
  51. data/lib/everywhere/plist.rb +17 -0
  52. data/lib/everywhere/png.rb +1 -0
  53. data/lib/everywhere/raw_tty.rb +51 -0
  54. data/lib/everywhere/s3.rb +1 -0
  55. data/lib/everywhere/shell_pages.rb +109 -0
  56. data/lib/everywhere/tab_filter.rb +36 -0
  57. data/lib/everywhere/task_pool.rb +3 -4
  58. data/lib/everywhere/ui.rb +6 -1
  59. data/lib/everywhere/version.rb +6 -4
  60. data/lib/everywhere.rb +1 -2
  61. data/support/mobile/ios/App.xcodeproj/project.pbxproj +0 -2
  62. data/support/release/macos/notarize.sh +3 -0
  63. metadata +24 -1
@@ -8,7 +8,10 @@ require "base64"
8
8
  require "json"
9
9
  require "open3"
10
10
  require "securerandom"
11
+ require "rubygems/package"
12
+ require "zlib"
11
13
  require_relative "../../ui"
14
+ require_relative "../../host"
12
15
  require_relative "../../paths"
13
16
  require_relative "../../platform/client"
14
17
 
@@ -47,6 +50,8 @@ module Everywhere
47
50
  "~/Library/Developer/Xcode/UserData/Provisioning Profiles"
48
51
  ].freeze
49
52
 
53
+ PROFILE_UUID = /\A[0-9a-f-]+\z/i
54
+
50
55
  desc "Run a Platform build on this machine (used by the hosted runner)"
51
56
 
52
57
  option :artifact, required: true, desc: "Artifact public id (art_…)"
@@ -103,16 +108,48 @@ module Everywhere
103
108
  @client.download(job["snapshot_url"], tarball)
104
109
  src = File.join(work, "src")
105
110
  FileUtils.mkdir_p(src)
106
- UI.die!("failed to extract snapshot") unless system("tar", "-xzf", tarball, "-C", src)
111
+ extract_snapshot(tarball, src)
107
112
  src
108
113
  end
109
114
 
115
+ # Read with the same pure-Ruby tar the snapshot was WRITTEN with
116
+ # (Platform::Snapshot uses Gem::Package::TarWriter) instead of shelling
117
+ # out: `tar -xzf` follows "../" members and symlink members straight out
118
+ # of the extraction dir, and this archive arrives over the network.
119
+ def extract_snapshot(tarball, dest)
120
+ Zlib::GzipReader.open(tarball) do |gz|
121
+ Gem::Package::TarReader.new(gz) do |tar|
122
+ tar.each { |entry| extract_entry(entry, dest) }
123
+ end
124
+ end
125
+ rescue Zlib::Error, Gem::Package::TarInvalidError => e
126
+ UI.die!("failed to extract snapshot (#{e.class.name.split("::").last})")
127
+ end
128
+
129
+ def extract_entry(entry, dest)
130
+ name = entry.full_name
131
+ UI.die!("the snapshot contains an unsafe path (#{name})") if unsafe_entry?(name)
132
+ # Snapshot only ever writes regular files; anything else in the archive
133
+ # didn't come from us.
134
+ return unless entry.file? && !entry.symlink?
135
+
136
+ path = File.join(dest, name)
137
+ FileUtils.mkdir_p(File.dirname(path))
138
+ File.open(path, "wb") { |out| out.write(entry.read(1 << 20)) until entry.eof? }
139
+ mode = entry.header.mode & 0o777
140
+ File.chmod(mode.zero? ? 0o644 : mode, path)
141
+ end
142
+
143
+ def unsafe_entry?(name)
144
+ name.empty? || name.start_with?("/") || name.split("/").include?("..")
145
+ end
146
+
110
147
  def pull_credentials(work)
111
148
  remote_log("pulling signing credentials")
112
149
  creds = get!("/runner/artifacts/#{@artifact}/credentials")["credentials"] || []
113
150
  env = {}
114
151
  creds.each do |cred|
115
- path = File.join(work, cred["filename"])
152
+ path = credential_path(work, cred["filename"])
116
153
  File.binwrite(path, Base64.strict_decode64(cred["data_base64"]))
117
154
  File.chmod(0o600, path)
118
155
  meta = cred["metadata"] || {}
@@ -122,7 +159,7 @@ module Everywhere
122
159
  # An android job runs on Linux, where there's no keychain to import
123
160
  # into. A stray apple credential there is the Platform's mistake, not
124
161
  # a reason to lose the build.
125
- if darwin?
162
+ if Host.darwin?
126
163
  env["APPLE_SIGNING_IDENTITY"] = install_certificate(path, meta, work)
127
164
  else
128
165
  remote_log("skipping #{cred["kind"]} — keychain import needs macOS")
@@ -146,6 +183,16 @@ module Everywhere
146
183
  env.compact
147
184
  end
148
185
 
186
+ # Credential filenames come from the Platform API, so they're untrusted
187
+ # input: a "../../.ssh/authorized_keys" would write outside the job's
188
+ # tmpdir. Only ever a basename, inside work.
189
+ def credential_path(work, filename)
190
+ name = File.basename(filename.to_s)
191
+ UI.die!("the Platform delivered a credential with no usable filename") if name.empty? || [".", ".."].include?(name)
192
+
193
+ File.join(work, name)
194
+ end
195
+
149
196
  # A .mobileprovision is a CMS-signed DER blob wrapped around a plain XML
150
197
  # plist. Scanning for the plist reads it identically on Linux, where
151
198
  # `security cms -D` doesn't exist.
@@ -157,9 +204,12 @@ module Everywhere
157
204
  name = plist[%r{<key>Name</key>\s*<string>([^<]+)</string>}m, 1]
158
205
  # TeamIdentifier is an <array> of one — the first <string> after the key.
159
206
  team = plist[%r{<key>TeamIdentifier</key>.*?<string>([^<]+)</string>}m, 1]
160
- UI.die!("the delivered provisioning profile has no UUID") unless uuid
207
+ # The UUID becomes a filename in the user's Provisioning Profiles dirs,
208
+ # and it's read out of a blob we didn't author — a "../" one would land
209
+ # anywhere. Shape-check it before it's ever a path segment.
210
+ UI.die!("the delivered provisioning profile has no usable UUID") unless uuid&.match?(PROFILE_UUID)
161
211
 
162
- if darwin?
212
+ if Host.darwin?
163
213
  profile_dirs.each do |dir|
164
214
  FileUtils.mkdir_p(dir)
165
215
  FileUtils.cp(path, File.join(dir, "#{uuid}.mobileprovision"))
@@ -171,10 +221,15 @@ module Everywhere
171
221
 
172
222
  def profile_dirs = PROFILE_DIRS.map { |dir| File.expand_path(dir) }
173
223
 
174
- def darwin? = RUBY_PLATFORM.include?("darwin")
175
224
 
176
225
  # Import the .p12 into a throwaway keychain and add it to the search list so
177
226
  # codesign finds the identity. Returns the identity name for notarize.sh.
227
+ #
228
+ # ACCEPTED RISK: `security` takes its passwords on argv, where `ps` can see
229
+ # them — it has no stdin form (a bare unlock-keychain prompts on the tty,
230
+ # which is no use in a headless job). What's exposed is this keychain's
231
+ # random single-use password and the .p12's own, on an ephemeral
232
+ # single-tenant runner that is destroyed after the build.
178
233
  def install_certificate(p12, metadata, work)
179
234
  @keychain = File.join(work, "runner.keychain-db")
180
235
  password = SecureRandom.hex(12)
@@ -329,7 +384,7 @@ module Everywhere
329
384
  end
330
385
 
331
386
  def restore_keychains
332
- return unless darwin?
387
+ return unless Host.darwin?
333
388
 
334
389
  run_security("list-keychains", "-d", "user", "-s", *@saved_keychains) if @saved_keychains
335
390
  run_security("delete-keychain", @keychain) if @keychain && File.exist?(@keychain)
@@ -9,7 +9,7 @@ require "uri"
9
9
  require_relative "../shellout"
10
10
  require_relative "../console"
11
11
  require_relative "../child_processes"
12
- require_relative "../relay"
12
+ require_relative "../child_supervision"
13
13
  require_relative "../framework"
14
14
  require_relative "../config"
15
15
  require_relative "../jump"
@@ -30,6 +30,8 @@ module Everywhere
30
30
  # that hostname is — cloudflared doesn't know its own DNS)
31
31
  # --lan no tunnel: the LAN address, for offline use
32
32
  class Preview < Dry::CLI::Command
33
+ include ChildSupervision
34
+
33
35
  desc "Run the dev server behind a shareable tunnel for the Jump app"
34
36
 
35
37
  option :port, default: "3000", desc: "Dev server port"
@@ -41,10 +43,11 @@ module Everywhere
41
43
  TUNNEL_BOOT_TIMEOUT = 30
42
44
 
43
45
  def call(port: "3000", lan: false, tunnel_name: nil, tunnel_host: nil, **)
44
- port = @port = validate_port!(port)
45
- @pids = {}
46
- @relays = {}
47
- @children = Mutex.new
46
+ port = validate_port!(port)
47
+ # An Integer, because @port is interpolated into the cloudflared command
48
+ # strings — nothing that reaches a shell should be a free-form String.
49
+ @port = Integer(port, 10)
50
+ init_children!
48
51
  @framework = detect_local_framework
49
52
  @config = Everywhere::Config.load(@framework.root)
50
53
  # Multi-use on purpose: a forwarded link must work for a second
@@ -72,7 +75,7 @@ module Everywhere
72
75
  rescue Interrupt
73
76
  nil
74
77
  ensure
75
- teardown
78
+ teardown_children
76
79
  end
77
80
 
78
81
  private
@@ -136,8 +139,11 @@ module Everywhere
136
139
  end
137
140
  end
138
141
 
142
+ # quiet drops the child's routine chatter but always lets errors
143
+ # through — a quick tunnel that can't connect must say why.
139
144
  def spawn_tunnel(cmd, on_line: nil, quiet: false)
140
- supervise(:tunnel, {}, cmd, chdir: Dir.pwd, on_line: on_line, quiet: quiet)
145
+ filter = quiet ? ->(line) { line.match?(/\bERR\b|error/i) ? line : :drop } : nil
146
+ supervise(:tunnel, {}, cmd, chdir: Dir.pwd, filter: filter, on_line: on_line)
141
147
  end
142
148
 
143
149
  # Rails' puma template binds 0.0.0.0, but a rackup-style bin/dev often
@@ -263,96 +269,17 @@ module Everywhere
263
269
 
264
270
  # --- child supervision (dev.rb's pattern, without the dock) ---------------
265
271
 
266
- # 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
272
+ def spawn_child(env, cmd, chdir:)
273
+ return Shellout.spawn_pty(env, cmd, chdir: chdir) if Shellout.pty?
274
+
275
+ # No pty (Windows): a plain pipe loses colors but keeps the output —
276
+ # and the quick tunnel's URL parsing, which reads it. child_env for
277
+ # the same reason spawn_pty uses it: the CLI's own BUNDLE_* must not
278
+ # follow the app's server into its child.
279
+ io, writer = IO.pipe
280
+ pid = Process.spawn(Shellout.child_env(env), cmd, chdir: chdir, %i[out err] => writer)
281
+ writer.close
282
+ [io, pid]
356
283
  end
357
284
  end
358
285
  end
@@ -173,6 +173,8 @@ module Everywhere
173
173
  def preflight!
174
174
  UI.die!("no updates.url in everywhere.yml — add an updates: section (see `every updates keygen`)") unless @config.updates_url
175
175
  UI.die!("no updates.public_key in everywhere.yml — run `every updates keygen`") unless @config.updates_public_key
176
+ updates_errors = @config.updates_errors
177
+ UI.die!("updates: in config/everywhere.yml:\n#{updates_errors.join("\n")}") unless updates_errors.empty?
176
178
  UI.die!("no updates.s3.bucket in everywhere.yml") if s3_config["bucket"].to_s.empty?
177
179
  UI.die!("missing bucket credentials — set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY") if
178
180
  ENV["AWS_ACCESS_KEY_ID"].to_s.empty? || ENV["AWS_SECRET_ACCESS_KEY"].to_s.empty?
@@ -4,6 +4,7 @@ require "dry/cli"
4
4
  require "digest"
5
5
  require "json"
6
6
  require_relative "../shellout"
7
+ require_relative "../host"
7
8
  require_relative "../log_filter"
8
9
  require_relative "../receipt"
9
10
  require_relative "../paths"
@@ -84,10 +85,10 @@ module Everywhere
84
85
  def preflight!(target, os)
85
86
  case os
86
87
  when "macos"
87
- UI.die!("`every release --target #{target}` runs on macOS only") unless darwin?
88
+ UI.die!("`every release --target #{target}` runs on macOS only") unless Host.darwin?
88
89
  UI.die!("signing engine missing at #{NOTARIZE}") unless File.exist?(NOTARIZE)
89
90
  when "ios"
90
- UI.die!("iOS releases need Xcode — `every release --target #{target}` runs on macOS only") unless darwin?
91
+ UI.die!("iOS releases need Xcode — `every release --target #{target}` runs on macOS only") unless Host.darwin?
91
92
  when "android"
92
93
  nil # Gradle signs and builds anywhere the Android SDK does — Linux included.
93
94
  else
@@ -95,7 +96,6 @@ module Everywhere
95
96
  end
96
97
  end
97
98
 
98
- def darwin? = RUBY_PLATFORM.include?("darwin")
99
99
 
100
100
  # Only the flag that belongs to this os: `every build` rejects the other.
101
101
  # iOS also needs the channel: a testflight/app_store release uploads to App
@@ -117,7 +117,11 @@ module Everywhere
117
117
  env_file ||= discover_env_file(root_path)
118
118
  env = env_file ? { "ENV_FILE" => env_file } : {}
119
119
  UI.phase("Signing & notarizing #{File.basename(app_dir)}")
120
- UI.step("preparing #{File.basename(app_dir)}#{env_file ? " #{UI.dim("(env: #{rel(env_file, root_path)})")}" : ""}")
120
+ UI.step("preparing #{File.basename(app_dir)}")
121
+ # Named in full: these are the Apple credentials the notarization runs
122
+ # with, and "which file did that come from" is the first thing you ask
123
+ # when a build signs as the wrong team.
124
+ UI.detail("signing env: #{UI.short_path(File.expand_path(env_file))}") if env_file
121
125
  UI.warn("no signing env found — set APPLE_* or pass --env-file (notarize.sh will fail without it)") unless env_file || ENV["APPLE_SIGNING_IDENTITY"]
122
126
  log = "#{app_dir}.notarize.log"
123
127
  Shellout.run_logged!(env, ["/bin/bash", NOTARIZE, app_dir], log: log, filter: LogFilter.for(:notarize))
@@ -202,10 +206,11 @@ module Everywhere
202
206
  dashed
203
207
  end
204
208
 
205
- # A .env.release next to the app, then at the repo root above it.
209
+ # A .env.release next to the app and nowhere else. The parent directory
210
+ # used to be searched too, which meant a release could silently pick up
211
+ # Apple credentials from outside the project it was building.
206
212
  def discover_env_file(root_path)
207
- [ENV["ENV_FILE"], File.join(root_path, ".env.release"), File.join(root_path, "..", ".env.release")]
208
- .compact.find { |p| File.exist?(p) }
213
+ [ENV["ENV_FILE"], File.join(root_path, ".env.release")].compact.find { |p| File.exist?(p) }
209
214
  end
210
215
 
211
216
  def resolve_shell_dir(given)
@@ -243,10 +248,10 @@ module Everywhere
243
248
 
244
249
  def summarize(out, artifact, signing, root_path)
245
250
  UI.phase("Release ready 🎉")
246
- UI.success("#{rel(artifact, root_path)} #{UI.dim("(#{(File.size(artifact) / 1024.0 / 1024.0).round}MB — send this to your friends)")}")
251
+ UI.success("#{UI.rel_path(artifact, root_path)} #{UI.dim("(#{(File.size(artifact) / 1024.0 / 1024.0).round}MB — send this to your friends)")}")
247
252
  UI.substep("signed by #{UI.bold(signing["signed_by"] || "?")}")
248
253
  UI.substep(signing_line(signing["platform"] || {}, signing))
249
- UI.substep("receipt #{UI.dim(rel(out, root_path))}")
254
+ UI.substep("receipt #{UI.dim(UI.rel_path(out, root_path))}")
250
255
  end
251
256
 
252
257
  def signing_line(platform, signing)
@@ -262,8 +267,6 @@ module Everywhere
262
267
  "signing scheme #{UI.bold(platform["signing_scheme"] || "?")}"
263
268
  end
264
269
  end
265
-
266
- def rel(path, root_path) = UI.short_path(path.sub("#{root_path}/", "").sub("#{Dir.pwd}/", ""))
267
270
  end
268
271
  end
269
272
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "dry/cli"
4
+ require_relative "../config"
5
+ require_relative "../paths"
4
6
 
5
7
  module Everywhere
6
8
  module Commands
@@ -31,14 +31,22 @@ module Everywhere
31
31
  end
32
32
 
33
33
  UI.phase("Generating update signing key for #{bundle_id}")
34
- pair = Minisign.keygen(passphrase: passphrase ? prompt_passphrase : "")
34
+ secret = resolve_passphrase(passphrase)
35
+ pair = Minisign.keygen(passphrase: secret)
35
36
 
36
37
  require "fileutils"
37
38
  FileUtils.mkdir_p(keys_dir, mode: 0o700)
38
39
  File.write(sk_path, pair.secret_key, perm: 0o600)
39
40
  File.write(pub_path, pair.public_key, perm: 0o644)
41
+ # perm: only applies when the file is created — an overwrite (--force,
42
+ # or a key from an older version) keeps whatever mode it had.
43
+ File.chmod(0o600, sk_path)
40
44
 
41
45
  UI.ok("secret key #{UI.short_path(sk_path)} #{UI.dim("(key id #{pair.key_id_hex})")}")
46
+ if secret.empty?
47
+ UI.warn("this secret key is stored UNENCRYPTED — anyone who can read " \
48
+ "#{UI.short_path(sk_path)} can sign updates your users will install")
49
+ end
42
50
  UI.ok("public key #{UI.short_path(pub_path)}")
43
51
 
44
52
  UI.phase("Add the public key to config/everywhere.yml")
@@ -54,6 +62,22 @@ module Everywhere
54
62
 
55
63
  private
56
64
 
65
+ # --passphrase insists on one. Without the flag we still ask whenever
66
+ # there's a terminal to ask on — an unencrypted signing key is a real
67
+ # choice (CI has to have one), but it shouldn't be what you get by
68
+ # forgetting a flag. Empty answer = unencrypted, and it's warned about.
69
+ def resolve_passphrase(flag)
70
+ return prompt_passphrase if flag
71
+ return "" unless $stdin.tty?
72
+
73
+ require "io/console"
74
+ pass = $stdin.getpass("Passphrase (empty for an unencrypted key): ")
75
+ return "" if pass.empty?
76
+
77
+ UI.die!("passphrases didn't match") unless pass == $stdin.getpass("Passphrase (again): ")
78
+ pass
79
+ end
80
+
57
81
  def prompt_passphrase
58
82
  require "io/console"
59
83
  UI.die!("--passphrase needs an interactive terminal") unless $stdin.tty?
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ class Config
5
+ module App
6
+ def name(target: nil)
7
+ resolved(target)["name"] || File.basename(File.expand_path(root)).split(/[-_]/).map(&:capitalize).join(" ")
8
+ end
9
+
10
+ # Reverse-DNS identifier: macOS bundle id, and the name of the per-user
11
+ # app-data directory on every platform. Set it explicitly and never change
12
+ # it — renaming moves users' data. A platform may override it (e.g. the App
13
+ # Store often wants `com.example.app.ios`).
14
+ def bundle_id(target: nil)
15
+ resolved(target)["bundle_id"] ||
16
+ "com.rubyeverywhere.#{name(target: target).downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")}"
17
+ end
18
+
19
+ # A bundle id isn't only an identifier: it's a path segment (Paths.*_work_dir,
20
+ # which the builders rm_rf, and the update-signing key on disk). The derived
21
+ # default is safe by construction; an explicit one is whatever the app repo
22
+ # says, so it's held to reverse-DNS characters and nothing else.
23
+ BUNDLE_ID = /\A[A-Za-z0-9][A-Za-z0-9.-]*\z/
24
+
25
+ def identity_errors
26
+ sections = [["app", app]] + platforms.map { |os, override| ["platforms.#{os}", override] }
27
+ sections.filter_map do |at, section|
28
+ next unless section.is_a?(Hash)
29
+
30
+ value = section["bundle_id"].to_s
31
+ next if value.empty? || (value.match?(BUNDLE_ID) && !value.include?(".."))
32
+
33
+ "#{at}.bundle_id #{value.inspect} is not a bundle id — reverse-DNS, " \
34
+ "letters/digits/./- only (like com.example.app)"
35
+ end
36
+ end
37
+
38
+ # App icon source: a PNG (ideally square, 1024px+). Explicit `app.icon`
39
+ # path relative to the app root, or icon.png at the root by convention.
40
+ def icon
41
+ if (explicit = app["icon"])
42
+ File.expand_path(explicit, root)
43
+ else
44
+ default = File.join(root, "icon.png")
45
+ File.exist?(default) ? default : nil
46
+ end
47
+ end
48
+
49
+ # Optional custom splash page (HTML file, path relative to the app root).
50
+ # Shown while the packaged server boots; the shell injects
51
+ # window.__EVERYWHERE_CONFIG__ so it can use the app's name and colors.
52
+ def splash
53
+ path = app["splash"]
54
+ File.expand_path(path, root) if path
55
+ end
56
+
57
+ def entry_path
58
+ path = app["entry_path"] || "/"
59
+ path.start_with?("/") ? path : "/#{path}"
60
+ end
61
+
62
+ # Marketing/display version of the app (CFBundleShortVersionString, and the
63
+ # `version` recorded in the build receipt). One shared `app.version` is the
64
+ # default for every target; a platform may override it when a store forces a
65
+ # different number. Defaults conservatively.
66
+ def version(target: nil)
67
+ resolved(target)["version"] || "0.1.0"
68
+ end
69
+
70
+ # The `build:` section — durable build knobs that were once `every build`
71
+ # flags (ruby, targets, capabilities). CLI flags still override per-run.
72
+ # See platform/docs/build-engine.md §2.
73
+ def build = @data.fetch("build", nil) || {}
74
+
75
+ # Ruby version to package. nil here means "let the CLI decide its default".
76
+ def build_ruby = build["ruby"]
77
+
78
+ # Desktop OS-integration capabilities the app declares (`build.capabilities`),
79
+ # as a plain list. Deliberately not called permissions: the top-level
80
+ # `permissions:` below is the mobile prompt declaration — a different shape
81
+ # for a different consumer — and while both were spelled "permissions" this
82
+ # accessor was silently redefined by that one and could never be read.
83
+ def build_capabilities = Array(build["capabilities"])
84
+
85
+ # Build targets as BARE os-arch strings — any ":<channel>" suffix is
86
+ # stripped. Callers treat these as filesystem/URL-safe identifiers
87
+ # (`every publish` derives S3 update-manifest key paths from the first one),
88
+ # so a suffix leaking through here would poison those paths. Use
89
+ # #build_target_specs when the channel matters.
90
+ def targets = build_target_specs.map { |t| t.to_s.split(":", 2).first }
91
+
92
+ # The raw `build.targets` entries, suffixes intact ("ios-arm64:testflight").
93
+ def build_target_specs = Array(build["targets"])
94
+
95
+ # "local" — the app is tebako-pressed and runs on-device (default)
96
+ # "remote" — thin shell around an already-deployed app: no press, no sidecar
97
+ def mode
98
+ app["mode"] || (remote_url ? "remote" : "local")
99
+ end
100
+
101
+ def remote? = mode == "remote"
102
+
103
+ def remote_url
104
+ @data.dig("remote", "url")&.chomp("/")
105
+ end
106
+
107
+ # Multi-instance apps (`remote.instances: true`): the mobile shell boots
108
+ # into remote.url — a hosted instance-picker page — and lets that page
109
+ # re-root the app onto a chosen instance via Everywhere.instance.set,
110
+ # persisted across launches until Everywhere.instance.clear. Opt-in
111
+ # because it lets page JS repoint the whole app: apps that aren't
112
+ # multi-instance shouldn't carry that surface.
113
+ def remote_instances?
114
+ @data.dig("remote", "instances") == true
115
+ end
116
+
117
+ def tint_color
118
+ normalize_color(appearance["tint_color"])
119
+ end
120
+
121
+ def background_color
122
+ normalize_color(appearance["background_color"])
123
+ end
124
+ end
125
+ end
126
+ end