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,25 +4,32 @@ require "fileutils"
4
4
  require_relative "../paths"
5
5
  require_relative "../ui"
6
6
  require_relative "../version"
7
+ require_relative "base"
8
+ require_relative "native_sources"
7
9
 
8
10
  module Everywhere
9
11
  module Builders
10
12
  # Resolves where cargo should run for the desktop shell, and stamps a
11
13
  # per-app copy of it when the app declares `native.desktop`.
12
14
  #
13
- # Two paths, deliberately:
15
+ # EVERY app gets its own staged copy of the shell under
16
+ # ~/.rubyeverywhere/desktop/<id>; cargo never runs in the template. Cargo
17
+ # writes Cargo.lock into the package dir and `every build` swaps the app's
18
+ # icon into icons/icon.png, so compiling the template in place means writing
19
+ # into the installed gem — Errno::EACCES on a system-wide install, and one
20
+ # app's branding in the next app's build when two builds share it.
14
21
  #
15
- # no native.desktop the gem's own support/desktop/src-tauri, compiled
16
- # against the SHARED ~/.rubyeverywhere/shell-target.
17
- # Identical source for every app, so one warm target
18
- # dir serves all of them. This is what every app did
19
- # before extensions existed, and it stays free.
22
+ # What the two paths differ in is the stamp and the target dir:
20
23
  #
21
- # native.desktop a stamped copy under ~/.rubyeverywhere/desktop/<id>
22
- # with its own target dir, exactly like the iOS and
23
- # Android work dirs. Costs one cold Tauri compile
24
- # (minutes) and a couple of GB, which is why it is not
25
- # the default.
24
+ # no native.desktop the template verbatim, compiled against the SHARED
25
+ # ~/.rubyeverywhere/shell-target. The source really is
26
+ # identical for every such app, so one warm target dir
27
+ # serves all of them.
28
+ #
29
+ # native.desktop the app's Rust stamped in, and its own target dir,
30
+ # exactly like the iOS and Android work dirs. Costs one
31
+ # cold Tauri compile (minutes) and a couple of GB,
32
+ # which is why it is not the default.
26
33
  #
27
34
  # The stamped list, and nothing else:
28
35
  #
@@ -35,11 +42,10 @@ module Everywhere
35
42
  # Everything else in the work dir is the frozen template, copied as-is. In
36
43
  # particular src/main.rs, src/extension_host.rs and capabilities/ are never
37
44
  # rewritten — see support/desktop/README.md.
38
- class Desktop
45
+ class Desktop < Base
39
46
  # The generated module file, at every level of src/extensions. Reserved:
40
47
  # an app shipping its own would be silently overwritten, so we refuse.
41
48
  MODULE_FILE = "mod.rs"
42
- STAGE_MARKER = ".everywhere-template"
43
49
  CRATES_MARKER = "# __EVERYWHERE_APP_CRATES__"
44
50
 
45
51
  # Where the app keeps its Rust, relative to the app root.
@@ -49,30 +55,22 @@ module Everywhere
49
55
  def stamped? = stamped
50
56
  end
51
57
 
52
- def initialize(config:, root:, template_dir: nil)
53
- @config = config
54
- @root = root
55
- @template_dir = template_dir
56
- end
57
-
58
58
  # Returns where to run cargo and what to set CARGO_TARGET_DIR to. Callers
59
59
  # (`every dev`, `every build`) don't need to care which path they got.
60
60
  def prepare!
61
61
  template = @template_dir || Paths.shell_dir!
62
- unless @config.native_desktop?
63
- return Prepared.new(dir: template, target_dir: Paths.cargo_target_dir, stamped: false)
64
- end
62
+ stamped = @config.native_desktop?
63
+ preflight! if stamped
65
64
 
66
- preflight!
67
65
  src_tauri = stage(template)
68
- stamp!(src_tauri)
69
- Prepared.new(dir: src_tauri, target_dir: Paths.desktop_target_dir(bundle_id), stamped: true)
66
+ stamp!(src_tauri) if stamped
67
+ Prepared.new(dir: src_tauri,
68
+ target_dir: stamped ? Paths.desktop_target_dir(bundle_id) : Paths.cargo_target_dir,
69
+ stamped: stamped)
70
70
  end
71
71
 
72
72
  private
73
73
 
74
- def bundle_id = @config.bundle_id
75
-
76
74
  # Config errors first: they're free to check, and everything below either
77
75
  # interpolates these values into generated Rust or spends minutes on cargo.
78
76
  def preflight!
@@ -91,6 +89,9 @@ module Everywhere
91
89
  #
92
90
  # A gem upgrade wipes it: the template's own sources changed, and merging
93
91
  # new files over old ones would leave whatever the new template deleted.
92
+ # Re-copying the template every time is also what un-brands the shell: the
93
+ # build swaps the app's icon into the staged icons/icon.png, and the
94
+ # placeholder has to come back on its own when app.icon goes away.
94
95
  # `template` points at src-tauri (that's what --shell-dir means), but the
95
96
  # whole shell root has to come along: tauri.conf.json's frontendDist is
96
97
  # "../splash", a sibling of src-tauri, and generate_context! panics at
@@ -101,15 +102,10 @@ module Everywhere
101
102
  FileUtils.rm_rf(work) unless staged_version(work) == Everywhere::VERSION
102
103
  FileUtils.mkdir_p(work)
103
104
  FileUtils.cp_r(File.join(File.dirname(template), "."), work)
104
- File.write(File.join(work, STAGE_MARKER), "#{Everywhere::VERSION}\n")
105
+ mark_staged!(work)
105
106
  File.join(work, File.basename(template))
106
107
  end
107
108
 
108
- def staged_version(work)
109
- marker = File.join(work, STAGE_MARKER)
110
- File.read(marker).strip if File.file?(marker)
111
- end
112
-
113
109
  def stamp!(work)
114
110
  write_cargo_toml(work)
115
111
  write_extensions(work)
@@ -186,17 +182,13 @@ module Everywhere
186
182
  FileUtils.mkdir_p(dest)
187
183
 
188
184
  source = File.join(@root, SOURCE_DIR)
189
- sources = File.directory?(source) ? Dir.glob(File.join(source, "**", "*.rs")).sort : []
190
- sources.each do |path|
191
- relative = path.delete_prefix("#{source}/")
185
+ # mod.rs is reserved here rather than by NativeSources: the fix isn't to
186
+ # declare the file in everywhere.yml, it's to delete it.
187
+ sources = NativeSources.copy!(from: source, into: dest, ext: "rs") do |_path, relative|
192
188
  if File.basename(relative) == MODULE_FILE
193
189
  UI.die!("#{File.join(SOURCE_DIR, relative)} is generated by `every build` — " \
194
190
  "remove it and let the CLI write the module declarations")
195
191
  end
196
-
197
- target = File.join(dest, relative)
198
- FileUtils.mkdir_p(File.dirname(target))
199
- FileUtils.cp(path, target)
200
192
  end
201
193
 
202
194
  modules = module_tree(sources.map { |path| path.delete_prefix("#{source}/") })
@@ -1,12 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require_relative "../host"
4
5
  require_relative "../paths"
5
6
  require_relative "../shellout"
6
7
  require_relative "../log_filter"
7
8
  require_relative "../icon"
8
9
  require_relative "../asset_catalog"
10
+ require_relative "../plist"
9
11
  require_relative "../ui"
12
+ require_relative "base"
13
+ require_relative "native_sources"
10
14
 
11
15
  module Everywhere
12
16
  module Builders
@@ -15,30 +19,59 @@ module Everywhere
15
19
  # Xcode template, stamp it (xcconfig + everywhere.json + AppIcon; never
16
20
  # the pbxproj), and hand it to xcodebuild.
17
21
  #
18
- # MVP scope is the Simulator (ad-hoc signed, no certs). Device signing,
19
- # archive/.ipa export and store distribution hang off this same seam
20
- # later — see platform/docs/build-engine.md §5.
21
- class Ios
22
+ # Two products come out of the same stamped project: the Simulator .app
23
+ # (ad-hoc signed, no certs) and, with device: true, a signed .ipa via
24
+ # xcodebuild archive + -exportArchive — see platform/docs/build-engine.md §5.
25
+ class Ios < Base
22
26
  # Fixed by the template contract: the target/product is always "App"
23
27
  # (App.app), so build paths never depend on per-app values.
24
28
  SCHEME = "App"
25
29
 
26
- def initialize(config:, root:, target:, template_dir: nil)
27
- @config = config
28
- @root = root
29
- @target = target
30
- @template_dir = template_dir
30
+ # Device signing arrives through the environment, never everywhere.yml:
31
+ # the platform runner pulls the certificate and profile just-in-time and
32
+ # nothing lands in the app repo.
33
+ REQUIRED_SIGNING_ENV = {
34
+ "EVERY_IOS_TEAM_ID" => "the 10-character Apple Developer team id",
35
+ "EVERY_IOS_PROVISIONING_PROFILE" => "the provisioning profile NAME (not a path or UUID)"
36
+ }.freeze
37
+
38
+ EXPORT_METHODS = %w[app-store-connect app-store ad-hoc development release-testing enterprise].freeze
39
+ DEFAULT_EXPORT_METHOD = "app-store-connect"
40
+
41
+ # Distribution channels whose artifact has to reach App Store Connect: the
42
+ # upload happens here, on the build host, because only this machine has the
43
+ # archive and Xcode.
44
+ STORE_CHANNELS = %w[testflight app_store].freeze
45
+
46
+ # The App Store Connect API key, under the names the platform runner
47
+ # already exports for notarytool, plus explicit aliases for local runs.
48
+ ASC_ENV = {
49
+ key_path: %w[NOTARY_KEY EVERY_ASC_KEY],
50
+ key_id: %w[NOTARY_KEY_ID EVERY_ASC_KEY_ID],
51
+ issuer_id: %w[NOTARY_ISSUER EVERY_ASC_ISSUER_ID]
52
+ }.freeze
53
+
54
+ def initialize(config:, root:, target:, template_dir: nil, channel: "direct")
55
+ super(config: config, root: root, target: target, template_dir: template_dir)
56
+ @channel = channel.to_s
31
57
  end
32
58
 
33
59
  # Build the Simulator .app and copy it into dist_dir. `dev` builds Debug
34
60
  # and relaxes the remote-mode requirement (the dev URL arrives at launch
35
61
  # via SIMCTL_CHILD_EVERYWHERE_DEV_URL, not baked into the bundle).
36
- # Returns the path of the built .app.
37
- def build!(dist_dir:, dev: false)
38
- preflight!(dev: dev)
62
+ # `device` archives and exports a signed dist/<Name>.ipa instead.
63
+ # Returns the path of the built product.
64
+ def build!(dist_dir:, dev: false, device: false)
65
+ preflight!(dev: dev, device: device)
66
+ @device = device
39
67
  template = @template_dir ? File.expand_path(@template_dir) : Paths.ios_dir!
40
68
  work = stage(template)
41
69
  stamp!(work)
70
+ if device
71
+ archive!(work, dist_dir)
72
+ return export!(work, dist_dir)
73
+ end
74
+
42
75
  configuration = dev ? "Debug" : "Release"
43
76
  xcodebuild!(work, configuration, dist_dir)
44
77
  collect(configuration, dist_dir)
@@ -46,21 +79,65 @@ module Everywhere
46
79
 
47
80
  private
48
81
 
49
- def name = @config.name(target: @target)
50
- def bundle_id = @config.bundle_id(target: @target)
51
-
52
- def preflight!(dev:)
82
+ def preflight!(dev:, device:)
53
83
  unless dev || @config.remote?
54
84
  UI.die!("iOS apps are remote mode only for now — set remote.url in config/everywhere.yml " \
55
85
  "(the shell wraps your deployed app; nothing is packaged locally)")
56
86
  end
57
- UI.die!("iOS builds need macOS with Xcode installed") unless RUBY_PLATFORM.include?("darwin")
87
+ UI.die!("iOS builds need macOS with Xcode installed") unless Host.darwin?
88
+ return device_preflight! if device
58
89
  return if Shellout.run?("xcrun", "--sdk", "iphonesimulator", "--show-sdk-path", quiet: true)
59
90
 
60
91
  UI.die!("no iOS Simulator SDK — install full Xcode (not just the Command Line Tools) " \
61
92
  "and run: xcode-select --switch /Applications/Xcode.app")
62
93
  end
63
94
 
95
+ # Signing is checked before anything is staged: xcodebuild only notices a
96
+ # missing team or profile minutes in, at the codesign step, and says so in
97
+ # its own vocabulary.
98
+ def device_preflight!
99
+ unless Shellout.run?("xcrun", "--sdk", "iphoneos", "--show-sdk-path", quiet: true)
100
+ UI.die!("no iOS device SDK — install full Xcode (not just the Command Line Tools) " \
101
+ "and run: xcode-select --switch /Applications/Xcode.app")
102
+ end
103
+
104
+ missing = REQUIRED_SIGNING_ENV.reject { |name, _| env_value(name) }
105
+ unless missing.empty?
106
+ UI.die!("device builds need signing credentials in the environment:\n" \
107
+ "#{missing.map { |name, hint| " #{name} — #{hint}" }.join("\n")}")
108
+ end
109
+ unless EXPORT_METHODS.include?(export_method)
110
+ UI.die!("EVERY_IOS_EXPORT_METHOD=#{export_method} isn't an Xcode export method — " \
111
+ "use one of: #{EXPORT_METHODS.join(", ")}")
112
+ end
113
+ asc_preflight! if store_channel?
114
+ end
115
+
116
+ # A store channel that can't reach App Store Connect has to fail now: the
117
+ # archive it would otherwise produce takes minutes and is unusable.
118
+ def asc_preflight!
119
+ missing = ASC_ENV.reject { |field, _names| asc_env(field) }
120
+ .map { |_field, names| names.first }
121
+ unless missing.empty?
122
+ UI.die!("--channel #{@channel} uploads to App Store Connect, which needs an API key " \
123
+ "in the environment — missing: #{missing.join(", ")}")
124
+ end
125
+ return if export_method == DEFAULT_EXPORT_METHOD
126
+
127
+ UI.die!("--channel #{@channel} uploads to App Store Connect, which only accepts " \
128
+ "#{DEFAULT_EXPORT_METHOD} builds — unset EVERY_IOS_EXPORT_METHOD=#{export_method}")
129
+ end
130
+
131
+ def asc_env(field) = ASC_ENV.fetch(field).filter_map { |name| env_value(name) }.first
132
+
133
+ def store_channel? = STORE_CHANNELS.include?(@channel)
134
+ def upload_to_asc? = store_channel? && ASC_ENV.each_key.all? { |field| asc_env(field) }
135
+
136
+ def team_id = env_value("EVERY_IOS_TEAM_ID")
137
+ def provisioning_profile = env_value("EVERY_IOS_PROVISIONING_PROFILE")
138
+ def signing_identity = env_value("APPLE_SIGNING_IDENTITY")
139
+ def export_method = env_value("EVERY_IOS_EXPORT_METHOD") || DEFAULT_EXPORT_METHOD
140
+
64
141
  # Copy the template into the app's persistent work dir. Persistent (not
65
142
  # a tmpdir) so the project path is stable — Xcode's incremental state
66
143
  # stays valid and users can open the stamped project directly.
@@ -106,7 +183,7 @@ module Everywhere
106
183
  return unless @config.deep_linking? && domains.any?
107
184
 
108
185
  values = domains.flat_map { |host| ["applinks:#{host}", "webcredentials:#{host}"] }
109
- entries = values.map { |v| "\t\t<string>#{v}</string>" }.join("\n")
186
+ entries = values.map { |v| "\t\t<string>#{xml_escape(v)}</string>" }.join("\n")
110
187
  UI.step("stamping associated domains #{UI.dim("(#{domains.join(", ")})")}")
111
188
  File.write(File.join(work, "App", "App.entitlements"), <<~PLIST)
112
189
  <?xml version="1.0" encoding="UTF-8"?>
@@ -132,19 +209,8 @@ module Everywhere
132
209
  UI.die!("native.ios in config/everywhere.yml:\n#{errors.join("\n")}") unless errors.empty?
133
210
 
134
211
  dest = File.join(work, "App", "Extensions")
135
- source = File.join(@root, "native", "ios")
136
- sources = File.directory?(source) ? Dir.glob(File.join(source, "**", "*.swift")).sort : []
137
-
138
- sources.each do |file|
139
- relative = file.delete_prefix("#{source}/")
140
- if File.basename(file) == "EverywhereExtensions.swift"
141
- UI.die!("native/ios/#{relative}: EverywhereExtensions.swift is generated by " \
142
- "`every build` — declare your types under native.ios in config/everywhere.yml instead")
143
- end
144
- target_file = File.join(dest, relative)
145
- FileUtils.mkdir_p(File.dirname(target_file))
146
- FileUtils.cp(file, target_file)
147
- end
212
+ sources = NativeSources.copy!(from: File.join(@root, "native", "ios"), into: dest, ext: "swift",
213
+ reserved: "EverywhereExtensions.swift", label: "native/ios")
148
214
 
149
215
  if @config.native_ios?
150
216
  UI.step("bundling native extensions #{UI.dim("(#{sources.length} Swift files from native/ios)")}")
@@ -356,15 +422,38 @@ module Everywhere
356
422
  File.write(File.join(work, "App", "App.xcconfig"), <<~XCCONFIG)
357
423
  // Written by `every build --target #{@target}` — do not edit; changes
358
424
  // belong in config/everywhere.yml.
359
- PRODUCT_BUNDLE_IDENTIFIER = #{bundle_id}
360
- MARKETING_VERSION = #{@config.version(target: @target)}
425
+ PRODUCT_BUNDLE_IDENTIFIER = #{xcconfig_value(bundle_id)}
426
+ MARKETING_VERSION = #{xcconfig_value(@config.version(target: @target))}
361
427
  CURRENT_PROJECT_VERSION = 1
362
- INFOPLIST_KEY_CFBundleDisplayName = #{name}
363
- DEVELOPMENT_TEAM =
364
- #{entitlements}
428
+ INFOPLIST_KEY_CFBundleDisplayName = #{xcconfig_value(name)}
429
+ DEVELOPMENT_TEAM =#{" #{xcconfig_value(team_id)}" if team_id}
430
+ #{entitlements}#{device_signing_xcconfig}
365
431
  XCCONFIG
366
432
  end
367
433
 
434
+ # Signing lives here, not on the xcodebuild command line: command-line
435
+ # build settings apply to every target in the build — including SPM
436
+ # resource-bundle targets (HotwireNative_HotwireNative), which reject
437
+ # PROVISIONING_PROFILE_SPECIFIER outright. The xcconfig is scoped to the
438
+ # App target.
439
+ def device_signing_xcconfig
440
+ return unless @device
441
+
442
+ identity = signing_identity ||
443
+ (export_method == "development" ? "Apple Development" : "Apple Distribution")
444
+ <<~XCCONFIG
445
+ CODE_SIGN_STYLE = Manual
446
+ PROVISIONING_PROFILE_SPECIFIER = #{xcconfig_value(provisioning_profile)}
447
+ CODE_SIGN_IDENTITY = #{xcconfig_value(identity)}
448
+ XCCONFIG
449
+ end
450
+
451
+ # An xcconfig is line-oriented: a value carrying a newline doesn't produce
452
+ # a broken setting, it produces an ADDITIONAL one of the author's
453
+ # choosing (OTHER_CODE_SIGN_FLAGS, a run-script path, …). Strip rather
454
+ # than die — the same values feed non-xcconfig writers that don't care.
455
+ def xcconfig_value(value) = value.to_s.gsub(/[\r\n]+/, " ").strip
456
+
368
457
  def write_app_icon(work)
369
458
  source = @config.icon
370
459
  unless source && File.exist?(source)
@@ -415,6 +504,149 @@ module Everywhere
415
504
  log: log, filter: LogFilter.for(:xcodebuild))
416
505
  end
417
506
 
507
+ # Archive for a real device. Signing arrives via the stamped xcconfig
508
+ # (device_signing_xcconfig), never as command-line build settings.
509
+ def archive!(work, dist_dir)
510
+ UI.step("archiving iOS app #{UI.dim("(xcodebuild archive, team #{team_id})")}")
511
+ FileUtils.mkdir_p(dist_dir)
512
+ FileUtils.rm_rf(archive_path(work))
513
+ Shellout.run_logged!({},
514
+ ["xcodebuild", "archive",
515
+ "-project", File.join(work, "App.xcodeproj"),
516
+ "-scheme", SCHEME,
517
+ "-configuration", "Release",
518
+ "-destination", "generic/platform=iOS",
519
+ "-archivePath", archive_path(work),
520
+ "-derivedDataPath", Paths.ios_derived_data_dir,
521
+ "-clonedSourcePackagesDirPath", Paths.ios_packages_dir],
522
+ log: File.join(dist_dir, "ios-archive.log"), filter: LogFilter.for(:xcodebuild))
523
+ end
524
+
525
+ # Two passes over the same archive: the local .ipa (the artifact everything
526
+ # downstream expects) and, for a store channel, the upload to App Store
527
+ # Connect. Xcode won't do both from one -exportArchive — `destination` is
528
+ # either export or upload — and the .ipa must exist either way.
529
+ def export!(work, dist_dir)
530
+ FileUtils.mkdir_p(dist_dir)
531
+ # A receipt left by a previous run would otherwise read as proof that
532
+ # THIS build reached App Store Connect.
533
+ FileUtils.rm_f(upload_receipt_path(dist_dir))
534
+ ipa = export_local!(work, dist_dir)
535
+ upload_to_asc!(work, dist_dir) if upload_to_asc?
536
+ ipa
537
+ end
538
+
539
+ def export_local!(work, dist_dir)
540
+ export = File.join(work, "build", "export")
541
+ FileUtils.rm_rf(export)
542
+ UI.step("exporting .ipa #{UI.dim("(#{export_method}, profile #{provisioning_profile})")}")
543
+ Shellout.run_logged!({},
544
+ ["xcodebuild", "-exportArchive",
545
+ "-archivePath", archive_path(work),
546
+ "-exportOptionsPlist", write_export_options(work),
547
+ "-exportPath", export],
548
+ log: File.join(dist_dir, "ios-export.log"), filter: LogFilter.for(:xcodebuild))
549
+ collect_ipa(export, dist_dir)
550
+ end
551
+
552
+ # -allowProvisioningUpdates is what lets the auth-key trio stand in for a
553
+ # signed-in Xcode account; `destination: upload` in the plist is what makes
554
+ # this an upload rather than a second export.
555
+ def upload_to_asc!(work, dist_dir)
556
+ upload = File.join(work, "build", "upload")
557
+ FileUtils.rm_rf(upload)
558
+ UI.step("uploading to App Store Connect #{UI.dim("(#{@channel}, key #{asc_env(:key_id)})")}")
559
+ Shellout.run_logged!({},
560
+ ["xcodebuild", "-exportArchive",
561
+ "-archivePath", archive_path(work),
562
+ "-exportOptionsPlist", write_export_options(work, destination: "upload"),
563
+ "-exportPath", upload,
564
+ "-allowProvisioningUpdates",
565
+ "-authenticationKeyPath", asc_env(:key_path),
566
+ "-authenticationKeyID", asc_env(:key_id),
567
+ "-authenticationKeyIssuerID", asc_env(:issuer_id)],
568
+ log: File.join(dist_dir, "ios-upload.log"), filter: LogFilter.for(:xcodebuild))
569
+ write_upload_receipt(work, dist_dir)
570
+ end
571
+
572
+ def upload_receipt_path(dist_dir) = File.join(dist_dir, "ios-upload.json")
573
+
574
+ # The contract `every release` folds into the receipt's signing bag. Written
575
+ # only after the upload pass returned — run_logged! dies on failure — so the
576
+ # file existing IS the proof that App Store Connect accepted the build.
577
+ def write_upload_receipt(work, dist_dir)
578
+ require "json"
579
+ require "time"
580
+ File.write(upload_receipt_path(dist_dir), "#{JSON.pretty_generate(
581
+ "uploaded" => true,
582
+ "destination" => "app-store-connect",
583
+ "method" => export_method,
584
+ "channel" => @channel,
585
+ "build_number" => archive_build_number(work),
586
+ "uploaded_at" => Time.now.utc.iso8601
587
+ )}\n")
588
+ end
589
+
590
+ # What ASC will show as the build: the archive's own CFBundleVersion, which
591
+ # the upload pass is pinned to by manageAppVersionAndBuildNumber = false.
592
+ def archive_build_number(work)
593
+ out, status = Shellout.capture("plutil", "-extract", "ApplicationProperties.CFBundleVersion",
594
+ "raw", "-o", "-", File.join(archive_path(work), "Info.plist"))
595
+ status&.success? ? out.strip : nil
596
+ end
597
+
598
+ def archive_path(work) = File.join(work, "build", "App.xcarchive")
599
+
600
+ # `destination` picks the pass: "export" writes an .ipa, "upload" hands the
601
+ # archive to App Store Connect. manageAppVersionAndBuildNumber is
602
+ # LOAD-BEARING on the upload pass — left at its default Xcode silently bumps
603
+ # CFBundleVersion, and the uploaded build stops matching the stored .ipa.
604
+ # testFlightInternalTestingOnly is deliberately never emitted: it would
605
+ # permanently bar the build from external TestFlight and the App Store.
606
+ def write_export_options(work, destination: "export")
607
+ upload = destination == "upload"
608
+ path = File.join(work, "build", upload ? "ExportOptions-upload.plist" : "ExportOptions.plist")
609
+ managed = "\t<key>manageAppVersionAndBuildNumber</key>\n\t<false/>\n" if upload
610
+ FileUtils.mkdir_p(File.dirname(path))
611
+ File.write(path, <<~PLIST)
612
+ <?xml version="1.0" encoding="UTF-8"?>
613
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
614
+ <plist version="1.0">
615
+ <dict>
616
+ \t<key>method</key>
617
+ \t<string>#{export_method}</string>
618
+ \t<key>teamID</key>
619
+ \t<string>#{team_id}</string>
620
+ \t<key>signingStyle</key>
621
+ \t<string>manual</string>
622
+ \t<key>provisioningProfiles</key>
623
+ \t<dict>
624
+ \t\t<key>#{bundle_id}</key>
625
+ \t\t<string>#{xml_escape(provisioning_profile)}</string>
626
+ \t</dict>
627
+ \t<key>destination</key>
628
+ \t<string>#{destination}</string>
629
+ #{managed}\t<key>uploadSymbols</key>
630
+ \t<true/>
631
+ </dict>
632
+ </plist>
633
+ PLIST
634
+ path
635
+ end
636
+
637
+ def xml_escape(value) = Everywhere::Plist.escape(value)
638
+
639
+ def collect_ipa(export, dist_dir)
640
+ built = File.join(export, "App.ipa")
641
+ built = Dir.glob(File.join(export, "*.ipa")).min unless File.file?(built)
642
+ UI.die!("xcodebuild reported success but no .ipa in #{UI.short_path(export)}") unless built && File.file?(built)
643
+
644
+ dest = File.join(dist_dir, "#{name}.ipa")
645
+ FileUtils.rm_f(dest)
646
+ FileUtils.cp(built, dest)
647
+ dest
648
+ end
649
+
418
650
  # Copy the product out of DerivedData. The ".simulator.app" suffix keeps
419
651
  # it visibly distinct from the desktop dist/<Name>.app (and out of
420
652
  # release.rb's dist/*.app glob).
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "../ui"
5
+
6
+ module Everywhere
7
+ module Builders
8
+ # The half of the native-extension seam that is the same on all three
9
+ # platforms: copy the app repo's native/<platform>/**/*.<ext> into the
10
+ # staged template, preserving relative paths. What the copied files then
11
+ # have to satisfy (a Kotlin package line) and what gets generated alongside
12
+ # them (a registry, mod.rs) is per-platform and stays in the builders.
13
+ module NativeSources
14
+ # `reserved` is the generated file's own name: an app shipping one would
15
+ # be overwritten by the generator, so we refuse instead. `label` is the
16
+ # source dir as the app sees it ("native/ios"), which doubles as the
17
+ # everywhere.yml key it maps to (native.ios). The block, when given, sees
18
+ # each file before it is copied. Returns the source paths, sorted.
19
+ def self.copy!(from:, into:, ext:, reserved: nil, label: nil)
20
+ sources = File.directory?(from) ? Dir.glob(File.join(from, "**", "*.#{ext}")).sort : []
21
+ sources.each do |file|
22
+ relative = file.delete_prefix("#{from}/")
23
+ if reserved && File.basename(file) == reserved
24
+ UI.die!("#{label}/#{relative}: #{reserved} is generated by " \
25
+ "`every build` — declare your types under #{label.tr("/", ".")} " \
26
+ "in config/everywhere.yml instead")
27
+ end
28
+ yield(file, relative) if block_given?
29
+
30
+ target = File.join(into, relative)
31
+ FileUtils.mkdir_p(File.dirname(target))
32
+ FileUtils.cp(file, target)
33
+ end
34
+ sources
35
+ end
36
+ end
37
+ end
38
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "clock"
4
+
3
5
  module Everywhere
4
6
  # A thread-scoped registry of the long-running children a task spawned, so the
5
7
  # task can be cancelled without hunting pids by hand.
@@ -38,8 +40,8 @@ module Everywhere
38
40
  return [] if pids.empty?
39
41
 
40
42
  pids.each { |pid| signal(pid, "TERM") }
41
- deadline = now + grace
42
- sleep(0.1) while pids.any? { |pid| alive?(pid) } && now < deadline
43
+ deadline = Clock.monotonic + grace
44
+ sleep(0.1) while pids.any? { |pid| alive?(pid) } && Clock.monotonic < deadline
43
45
  pids.select { |pid| alive?(pid) }.each { |pid| signal(pid, "KILL") }
44
46
  end
45
47
 
@@ -64,8 +66,6 @@ module Everywhere
64
66
  true
65
67
  end
66
68
 
67
- def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
68
-
69
69
  def list
70
70
  Thread.current.thread_variable_get(PIDS) ||
71
71
  Thread.current.thread_variable_set(PIDS, [])