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
@@ -4,6 +4,7 @@ require "dry/cli"
4
4
  require "fileutils"
5
5
  require_relative "../shellout"
6
6
  require_relative "../config"
7
+ require_relative "../framework"
7
8
  require_relative "../agents_guide"
8
9
 
9
10
  module Everywhere
@@ -56,6 +57,7 @@ module Everywhere
56
57
  end
57
58
 
58
59
  def install_sinatra
60
+ add_puma_gem
59
61
  vendor_bridge_to_public
60
62
  guide_rack_manual_steps(
61
63
  db: "point your production SQLite path at ENV.fetch(\"NATIVE_STORAGE_DIR\", \"storage\")",
@@ -65,6 +67,7 @@ module Everywhere
65
67
  end
66
68
 
67
69
  def install_hanami
70
+ add_puma_gem
68
71
  vendor_bridge_to_public
69
72
  guide_rack_manual_steps(
70
73
  db: "override DATABASE_URL from ENV[\"NATIVE_STORAGE_DIR\"] in config.ru when NATIVE_PACKAGED",
@@ -100,14 +103,35 @@ module Everywhere
100
103
  made ? UI.ok(label) : UI.step("#{label} #{UI.dim("(already done, skipped)")}")
101
104
  end
102
105
 
106
+ # A step whose file this app doesn't have. Every one of these is a shape a
107
+ # real app legitimately ships in (API-only, DATABASE_URL, no
108
+ # config/puma.rb), so say what was skipped — never raise on it.
109
+ def skip(label, reason)
110
+ UI.step("#{label} #{UI.dim("(skipped — #{reason})")}")
111
+ end
112
+
103
113
  def add_gem
114
+ UI.die!("no Gemfile in #{@root} — run `every install` inside your app") unless File.exist?(app_file("Gemfile"))
115
+
116
+ add_gemfile_gem("ruby_everywhere",
117
+ comment: "# Desktop apps from this #{@framework.name.capitalize} app — https://rubyeverywhere.com")
118
+ end
119
+
120
+ # Packaged Rack apps serve through Puma at runtime (Framework#serve_rack
121
+ # requires it), so a missing gem surfaces as a LoadError on the end user's
122
+ # machine at first launch rather than here.
123
+ def add_puma_gem
124
+ add_gemfile_gem("puma", comment: "# Packaged RubyEverywhere apps serve through puma.")
125
+ end
126
+
127
+ def add_gemfile_gem(name, comment:)
104
128
  gemfile = app_file("Gemfile")
105
129
  contents = File.read(gemfile)
106
- return change("add ruby_everywhere to Gemfile", false) if contents.match?(/gem ["']ruby_everywhere["']/)
130
+ return change("add #{name} to Gemfile", false) if contents.match?(/gem ["']#{name}["']/)
107
131
 
108
- File.write(gemfile, "#{contents.chomp}\n\n# Desktop apps from this #{@framework.name.capitalize} app — https://rubyeverywhere.com\ngem \"ruby_everywhere\"\n")
132
+ File.write(gemfile, "#{contents.chomp}\n\n#{comment}\ngem \"#{name}\"\n")
109
133
  Shellout.run!({}, "bundle", "install", "--quiet", chdir: @root)
110
- change("add ruby_everywhere to Gemfile", true)
134
+ change("add #{name} to Gemfile", true)
111
135
  end
112
136
 
113
137
  def write_config
@@ -214,9 +238,10 @@ module Everywhere
214
238
  # targets: # the platforms you ship — the CI matrix, expressed once
215
239
  # - macos-arm64
216
240
  # # - ios-arm64 # Hotwire Native shell; remote mode only. Local builds
217
- # # # (`every build` / `every dev --ios`) work today;
218
- # # # hosted platform builds for iOS aren't live yet.
219
- # # - android-arm64 # future mobile is remote mode only
241
+ # # # (`every build` / `every dev --ios`) and hosted
242
+ # # # platform builds are both live.
243
+ # # - ios-arm64:testflight # optional per-target distribution channel
244
+ # # - android-arm64 # Hotwire Native shell; remote mode only, hosted builds live
220
245
  # capabilities: [notifications, filesystem:read, clipboard] # desktop OS integration
221
246
 
222
247
  # Per-platform overrides. Keys are platform names (macos / ios / android /
@@ -321,6 +346,8 @@ module Everywhere
321
346
 
322
347
  def guard_bootsnap
323
348
  boot = app_file("config", "boot.rb")
349
+ return skip("guard bootsnap for packaged runs", "no config/boot.rb") unless File.exist?(boot)
350
+
324
351
  contents = File.read(boot)
325
352
  return change("guard bootsnap for packaged runs", false) if contents.include?("NATIVE_PACKAGED")
326
353
 
@@ -328,12 +355,16 @@ module Everywhere
328
355
  "# Bootsnap writes its cache to tmp/, which is read-only inside the packaged filesystem.\n" \
329
356
  "require \"bootsnap/setup\" unless ENV[\"NATIVE_PACKAGED\"]"
330
357
  end
331
- File.write(boot, contents) if made
332
- change("guard bootsnap for packaged runs", !!made)
358
+ return skip("guard bootsnap for packaged runs", "this app doesn't load bootsnap") unless made
359
+
360
+ File.write(boot, contents)
361
+ change("guard bootsnap for packaged runs", true)
333
362
  end
334
363
 
335
364
  def disable_force_ssl
336
365
  production = app_file("config", "environments", "production.rb")
366
+ return skip("disable force_ssl for localhost", "no config/environments/production.rb") unless File.exist?(production)
367
+
337
368
  contents = File.read(production)
338
369
  made = false
339
370
  made |= !!contents.gsub!(/^(\s*config\.assume_ssl\s*=\s*)true/) { "#{Regexp.last_match(1)}false # desktop: webview talks plain HTTP to 127.0.0.1" }
@@ -416,6 +447,11 @@ module Everywhere
416
447
  # connection was lost" (-1005). Single local user: keep-alives off.
417
448
  def disable_puma_keep_alives
418
449
  puma = app_file("config", "puma.rb")
450
+ unless File.exist?(puma)
451
+ return skip("disable puma keep-alives when packaged",
452
+ "no config/puma.rb — add `enable_keep_alives false if ENV[\"NATIVE_PACKAGED\"]` to yours if you add one")
453
+ end
454
+
419
455
  contents = File.read(puma)
420
456
  return change("disable puma keep-alives when packaged", false) if contents.include?("enable_keep_alives")
421
457
 
@@ -431,6 +467,11 @@ module Everywhere
431
467
 
432
468
  def point_database_at_app_data
433
469
  db = app_file("config", "database.yml")
470
+ unless File.exist?(db)
471
+ return skip("point production SQLite at app-data dir",
472
+ "no config/database.yml — point the packaged database at ENV[\"NATIVE_STORAGE_DIR\"] yourself")
473
+ end
474
+
434
475
  contents = File.read(db)
435
476
  return change("point production SQLite at app-data dir", false) if contents.include?("NATIVE_STORAGE_DIR")
436
477
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "dry/cli"
4
+ require_relative "../../error"
4
5
  require_relative "../../ui"
5
6
  require_relative "../../platform/client"
6
7
  require_relative "../../platform/credentials"
@@ -3,6 +3,7 @@
3
3
  require "dry/cli"
4
4
  require "tmpdir"
5
5
  require "fileutils"
6
+ require_relative "../../clock"
6
7
  require_relative "../../ui"
7
8
  require_relative "../../config"
8
9
  require_relative "../../framework"
@@ -21,8 +22,8 @@ module Everywhere
21
22
 
22
23
  option :root, default: ".", desc: "App root directory"
23
24
  option :url, default: nil, desc: "Platform base URL"
24
- option :target, default: nil, desc: "Targets (comma-separated os-arch); overrides everywhere.yml"
25
- option :channel, default: "direct", desc: "Distribution channel"
25
+ option :target, default: nil, desc: "Targets (comma-separated os-arch[:channel]); overrides everywhere.yml"
26
+ option :channel, default: "direct", desc: "Default distribution channel for targets without a :channel suffix"
26
27
  option :ruby, default: nil, desc: "Ruby version (default: build.ruby or 4.0.6)"
27
28
  option :output, default: nil, desc: "Where to save artifacts (default: <root>/dist)"
28
29
  option :wait, type: :boolean, default: true, desc: "Wait for the build and download results"
@@ -38,29 +39,26 @@ module Everywhere
38
39
 
39
40
  client = Everywhere::Platform::Client.new(base, token: creds["token"])
40
41
  framework = config.remote? ? nil : Everywhere::Framework.detect(root_path)
41
- targets = resolve_targets(target, config)
42
+ specs = resolve_targets(target, config, channel)
42
43
  ruby ||= config.build_ruby || "4.0.6"
43
44
 
44
45
  Dir.mktmpdir("every-snapshot") do |tmp|
45
46
  signed_id = snapshot_and_upload(client, root_path, tmp)
46
- manifest = Everywhere::Receipt.new(
47
- root: root_path, config: config, framework: framework, ruby: ruby,
48
- target: targets.first, channel: channel
49
- ).manifest
47
+ manifests = build_manifests(root_path, config, framework, ruby, specs)
50
48
 
51
- build = submit(client, targets, channel, signed_id, manifest)
52
- UI.success("submitted #{UI.bold(build["id"])} for #{targets.map { |t| UI.target_label(t) }.join(", ")}")
49
+ build = submit(client, specs, channel, signed_id, manifests)
50
+ UI.success("submitted #{UI.bold(build["id"])} for #{specs.map(&:display_label).join(", ")}")
53
51
  if (build_url = build["url"] || "#{client.base_url}/builds/#{build["id"]}")
54
52
  UI.substep("watch it live → #{UI.cyan(build_url)}")
55
53
  end
56
54
 
57
55
  return unless wait
58
56
 
59
- # Set expectations before the quiet part: a hosted macOS runner is not
57
+ # Set expectations before the quiet part: a hosted runner is not
60
58
  # instant, and knowing that up front is the difference between "slow"
61
59
  # and "broken".
62
- UI.substep(UI.dim("hosted macOS runners usually start within a few minutes"))
63
- final = poll_and_tail(client, build["id"])
60
+ UI.substep(UI.dim("#{runner_wait_subject(specs)} usually start within a few minutes"))
61
+ final = poll_and_tail(client, build["id"], build_url)
64
62
  report(client, final, output || File.join(root_path, "dist"), download)
65
63
  end
66
64
  rescue Everywhere::Error => e
@@ -69,6 +67,30 @@ module Everywhere
69
67
 
70
68
  private
71
69
 
70
+ # Which hosted runner OS each target builds on — not the target's own OS
71
+ # (iOS builds on macOS, Android on Linux).
72
+ RUNNER_OS_LABELS = {
73
+ "macos" => "macOS", "ios" => "macOS",
74
+ "android" => "Linux", "linux" => "Linux", "windows" => "Windows"
75
+ }.freeze
76
+
77
+ # One requested target. `token` is the literal string the user wrote
78
+ # ("ios-arm64:testflight") and is what keys the per-target manifests on the
79
+ # wire; `os_arch` is the bare form everything else (receipts, labels,
80
+ # filenames) is built from.
81
+ TargetSpec = Struct.new(:token, :os, :arch, :channel) do
82
+ def os_arch = "#{os}-#{arch}"
83
+ def label = UI.target_label(os_arch)
84
+ def display_label = channel == "direct" ? label : "#{label} (#{channel})"
85
+ end
86
+
87
+ def runner_wait_subject(specs)
88
+ kinds = specs.filter_map { |s| RUNNER_OS_LABELS[s.os] }.uniq
89
+ return "hosted build runners" if kinds.empty?
90
+
91
+ "hosted #{kinds.join("/")} runners"
92
+ end
93
+
72
94
  def snapshot_and_upload(client, root_path, tmp)
73
95
  path = File.join(tmp, "snapshot.tar.gz")
74
96
  UI.step("snapshotting source #{UI.dim("(honoring .everywhereignore)")}")
@@ -79,6 +101,7 @@ module Everywhere
79
101
  reservation = client.post("/cli/direct_uploads",
80
102
  filename: "snapshot.tar.gz", byte_size: size,
81
103
  checksum: Everywhere::Platform::Snapshot.md5_base64(path), content_type: "application/gzip")
104
+ die_unauthorized! if reservation.unauthorized?
82
105
  UI.die!("couldn't reserve upload (HTTP #{reservation.status})") unless reservation.ok?
83
106
 
84
107
  upload = reservation.body["direct_upload"] || {}
@@ -89,21 +112,49 @@ module Everywhere
89
112
  reservation.body["signed_id"]
90
113
  end
91
114
 
92
- def submit(client, targets, channel, signed_id, manifest)
115
+ # `manifests` is keyed by the literal target token; `manifest` (singular)
116
+ # stays = the first target's, which is all an older Platform reads.
117
+ def submit(client, specs, channel, signed_id, manifests)
93
118
  res = client.post("/cli/builds",
94
- targets: targets.join(","), channel: channel, snapshot_signed_id: signed_id, manifest: manifest)
119
+ targets: specs.map(&:token).join(","), channel: channel, snapshot_signed_id: signed_id,
120
+ manifest: manifests.values.first, manifests: manifests)
95
121
  return res.body if res.ok?
96
122
 
97
- if res.status == 422 && res.body["error"] == "missing_credentials"
123
+ die_unauthorized! if res.unauthorized?
124
+ render_submit_error!(res)
125
+ end
126
+
127
+ def render_submit_error!(res)
128
+ body = res.body.is_a?(Hash) ? res.body : {}
129
+
130
+ case body["error"]
131
+ when "missing_credentials"
98
132
  UI.bad("can't build yet — missing signing credentials:")
99
- Array(res.body["targets"]).each do |t|
100
- tgt = t["target"] || {}
101
- UI.step("#{UI.os_label(tgt["os"])}-#{tgt["arch"]}/#{tgt["distribution_channel"]}: add #{Array(t["missing"]).join(", ")}")
133
+ Array(body["targets"]).each do |t|
134
+ UI.step("#{server_target_label(t["target"])}: add #{Array(t["missing"]).join(", ")}")
135
+ end
136
+ UI.step("set them up → #{UI.cyan(body["manage_url"])}")
137
+ exit 1
138
+ when "invalid_targets"
139
+ UI.bad(body["message"] || "some targets can't be built as configured:")
140
+ Array(body["targets"]).each do |t|
141
+ UI.step("#{server_target_label(t["target"])}: #{Array(t["errors"]).join("; ")}")
102
142
  end
103
- UI.step("set them up → #{UI.cyan(res.body["manage_url"])}")
104
143
  exit 1
144
+ when "unsupported_targets"
145
+ UI.step("supported: #{Array(body["supported"]).join(", ")}") if body["supported"]
105
146
  end
106
- UI.die!("build submission failed (HTTP #{res.status}) #{res.body["error"]}")
147
+
148
+ UI.die!("build submission failed (HTTP #{res.status}) #{body["message"] || body["error"]}")
149
+ end
150
+
151
+ def server_target_label(target)
152
+ target ||= {}
153
+ "#{UI.os_label(target["os"])}-#{target["arch"]}/#{target["distribution_channel"]}"
154
+ end
155
+
156
+ def die_unauthorized!
157
+ UI.die!("token expired or revoked — run `every platform login`")
107
158
  end
108
159
 
109
160
  # How often we ask the Platform for new logs. A build spends most of its
@@ -114,14 +165,30 @@ module Everywhere
114
165
  ACTIVE_POLL = 2
115
166
  SPINNER_TICK = 0.2
116
167
 
117
- def poll_and_tail(client, build_id)
168
+ # The build keeps running on the Platform whether or not we can reach it,
169
+ # so a blip mid-tail must not end the wait. Give up only after this many
170
+ # polls in a row have failed.
171
+ MAX_POLL_ERRORS = 5
172
+
173
+ def poll_and_tail(client, build_id, build_url = nil)
118
174
  printed = Hash.new("")
119
175
  last = {}
120
- started = now
176
+ started = Clock.monotonic
121
177
  status = UI::Status.new
178
+ errors = 0
122
179
 
123
180
  loop do
124
- res = client.get("/cli/builds/#{build_id}/logs")
181
+ begin
182
+ res = client.get("/cli/builds/#{build_id}/logs")
183
+ errors = 0
184
+ rescue Everywhere::Error => e
185
+ errors += 1
186
+ status.clear
187
+ detach!(build_id, build_url, e) if errors >= MAX_POLL_ERRORS
188
+ UI.warn("lost contact with the Platform (#{e.message}) — retrying #{errors}/#{MAX_POLL_ERRORS}")
189
+ spin(status, [], started, IDLE_POLL)
190
+ next
191
+ end
125
192
  artifacts = res.ok? ? (res.body["artifacts"] || []) : []
126
193
 
127
194
  status.clear
@@ -140,14 +207,24 @@ module Everywhere
140
207
  status&.clear
141
208
  end
142
209
 
210
+ # We stopped watching; the build didn't stop. Leave the user everything
211
+ # they need to re-attach or grab the artifacts later.
212
+ def detach!(build_id, build_url, error)
213
+ UI.bad("gave up tailing #{UI.bold(build_id)} after #{MAX_POLL_ERRORS} failed polls — #{error.message}")
214
+ UI.step("the build is still running on the Platform")
215
+ UI.step("watch it → #{UI.cyan(build_url)}") if build_url
216
+ UI.die!("re-attach with `every platform build --wait` once you're back online, " \
217
+ "or download the artifacts from the build page")
218
+ end
219
+
143
220
  # Hold the status line alive for `interval` seconds. The spinner ticks far
144
221
  # faster than we poll so the line reads as "alive" during the multi-minute
145
222
  # pre-runner wait rather than freezing between requests.
146
223
  def spin(status, artifacts, started, interval)
147
- deadline = now + interval
224
+ deadline = Clock.monotonic + interval
148
225
  loop do
149
- status.update("#{waiting_summary(artifacts)} · #{UI.elapsed(now - started)} elapsed")
150
- remaining = deadline - now
226
+ status.update("#{waiting_summary(artifacts)} · #{UI.elapsed(Clock.monotonic - started)} elapsed")
227
+ remaining = deadline - Clock.monotonic
151
228
  break if remaining <= 0
152
229
 
153
230
  sleep([remaining, SPINNER_TICK].min)
@@ -172,8 +249,6 @@ module Everywhere
172
249
  label.empty? ? artifact["status"].to_s : label
173
250
  end
174
251
 
175
- def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
176
-
177
252
  def tail_log(artifact, printed)
178
253
  full = artifact["log"].to_s
179
254
  prev = printed[artifact["id"]]
@@ -224,11 +299,44 @@ module Everywhere
224
299
  end
225
300
  end
226
301
 
227
- # everywhere.yml build.targets, or --target, or the macOS default.
228
- def resolve_targets(override, config)
229
- list = override ? override.split(",") : config.targets
230
- list = ["macos-arm64"] if list.nil? || list.empty?
231
- list.map(&:strip).reject(&:empty?)
302
+ # everywhere.yml build.targets, or --target, or the macOS default. Each
303
+ # entry is "os-arch" with an optional ":channel" suffix; --channel is the
304
+ # default for the ones that leave it off. No channel allowlist here — the
305
+ # Platform owns which channels exist and says so in its 422.
306
+ def resolve_targets(override, config, default_channel)
307
+ list = override ? override.split(",") : config.build_target_specs
308
+ list = Array(list).map { |t| t.to_s.strip }.reject(&:empty?)
309
+ list = ["macos-arm64"] if list.empty?
310
+ list.map { |token| parse_target_spec(token, default_channel) }
311
+ end
312
+
313
+ def parse_target_spec(token, default_channel)
314
+ os_arch, channel, extra = token.split(":", -1)
315
+ if !extra.nil? || os_arch.to_s.empty? || (!channel.nil? && channel.empty?)
316
+ UI.die!("invalid target #{token.inspect} — expected os-arch[:channel], e.g. ios-arm64:testflight")
317
+ end
318
+
319
+ os, arch = os_arch.split("-", 2)
320
+ TargetSpec.new(token, os, arch, channel || default_channel)
321
+ end
322
+
323
+ # One manifest per target — they differ in `target`, and may differ in
324
+ # bundle_id/name/version via `platforms:` overrides. The source digest is
325
+ # the same for all of them, so hash the tree once and thread it through.
326
+ def build_manifests(root_path, config, framework, ruby, specs)
327
+ source = nil
328
+ lockfile = nil
329
+
330
+ specs.each_with_object({}) do |spec, out|
331
+ manifest = Everywhere::Receipt.new(
332
+ root: root_path, config: config, framework: framework, ruby: ruby,
333
+ target: spec.os_arch, channel: spec.channel,
334
+ source_checksum: source, lockfile_checksum: lockfile
335
+ ).manifest
336
+ source ||= manifest.dig("source", "checksum")
337
+ lockfile ||= manifest.dig("source", "lockfile_checksum")
338
+ out[spec.token] = manifest
339
+ end
232
340
  end
233
341
 
234
342
  def label(target)
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "dry/cli"
4
4
  require "socket"
5
+ require_relative "../../clock"
6
+ require_relative "../../error"
5
7
  require_relative "../../ui"
6
8
  require_relative "../../platform/client"
7
9
  require_relative "../../platform/credentials"
@@ -23,6 +25,14 @@ module Everywhere
23
25
  start = client.post("/cli/device_authorizations", client_name: client_name)
24
26
  UI.die!("couldn't start login — HTTP #{start.status}") unless start.ok?
25
27
 
28
+ # A captive portal or a misconfigured proxy answers 200 with HTML, which
29
+ # parses to {} — without this we'd print an empty code and poll nothing
30
+ # for ten minutes.
31
+ unless start.body["user_code"] && start.body["device_code"]
32
+ UI.die!("#{base} answered without a device code — check EVERYWHERE_PLATFORM_URL " \
33
+ "(a captive portal or proxy may be intercepting the request)")
34
+ end
35
+
26
36
  verify_url = start.body["verification_uri_complete"] || start.body["verification_uri"]
27
37
  UI.step("confirmation code: #{UI.bold(start.body["user_code"])}")
28
38
  UI.step("opening #{UI.cyan(verify_url)}")
@@ -43,14 +53,20 @@ module Everywhere
43
53
  private
44
54
 
45
55
  def poll(client, device_code, interval, expires_in)
46
- deadline = monotonic + (expires_in.positive? ? expires_in : 600)
56
+ deadline = Clock.monotonic + (expires_in.positive? ? expires_in : 600)
47
57
  loop do
48
58
  sleep(interval)
49
- UI.die!("login timed out — run `every platform login` again") if monotonic > deadline
59
+ UI.die!("login timed out — run `every platform login` again") if Clock.monotonic > deadline
50
60
 
51
- res = client.post("/cli/device_authorizations/token", device_code: device_code)
61
+ # The user is mid-approval in a browser; a dropped connection here must
62
+ # not kill the login. Keep polling until the deadline instead.
63
+ res = begin
64
+ client.post("/cli/device_authorizations/token", device_code: device_code)
65
+ rescue Everywhere::Error
66
+ next
67
+ end
52
68
  UI.die!("login failed — #{res.body["error"] || "invalid request"}") if res.status == 400
53
- next unless res.ok? # transient 5xx/network: keep polling until the deadline
69
+ next unless res.ok? # transient 5xx: keep polling until the deadline
54
70
 
55
71
  case res.body["status"]
56
72
  when "approved" then return res.body
@@ -61,8 +77,6 @@ module Everywhere
61
77
  end
62
78
  end
63
79
 
64
- def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
65
-
66
80
  # Shown on the approval page + used as the token's name, so a user can tell
67
81
  # their machines apart when reviewing/revoking tokens.
68
82
  def client_name
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "dry/cli"
4
+ require_relative "../../error"
4
5
  require_relative "../../ui"
5
6
  require_relative "../../platform/client"
6
7
  require_relative "../../platform/credentials"