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.
- checksums.yaml +4 -4
- data/exe/every +2 -2
- data/exe/rbe +2 -2
- data/lib/everywhere/agents_guide.rb +3 -1
- data/lib/everywhere/blake2b.rb +17 -1
- data/lib/everywhere/boot.rb +30 -6
- data/lib/everywhere/builders/android.rb +152 -64
- data/lib/everywhere/builders/base.rb +53 -0
- data/lib/everywhere/builders/desktop.rb +32 -40
- data/lib/everywhere/builders/ios.rb +268 -36
- data/lib/everywhere/builders/native_sources.rb +38 -0
- data/lib/everywhere/child_processes.rb +4 -4
- data/lib/everywhere/child_supervision.rb +172 -0
- data/lib/everywhere/cli.rb +2 -0
- data/lib/everywhere/clock.rb +12 -0
- data/lib/everywhere/commands/build.rb +132 -66
- data/lib/everywhere/commands/clean.rb +8 -3
- data/lib/everywhere/commands/dev.rb +91 -242
- data/lib/everywhere/commands/doctor.rb +138 -21
- data/lib/everywhere/commands/install.rb +49 -8
- data/lib/everywhere/commands/platform/auth_status.rb +1 -0
- data/lib/everywhere/commands/platform/build.rb +141 -33
- data/lib/everywhere/commands/platform/login.rb +20 -6
- data/lib/everywhere/commands/platform/logout.rb +1 -0
- data/lib/everywhere/commands/platform/runner.rb +170 -25
- data/lib/everywhere/commands/preview.rb +286 -0
- data/lib/everywhere/commands/publish.rb +22 -2
- data/lib/everywhere/commands/release.rb +155 -35
- data/lib/everywhere/commands/shell_dir.rb +4 -2
- data/lib/everywhere/commands/updates_keygen.rb +25 -1
- data/lib/everywhere/config/app.rb +126 -0
- data/lib/everywhere/config/auth.rb +108 -0
- data/lib/everywhere/config/data.rb +50 -0
- data/lib/everywhere/config/deep_linking.rb +107 -0
- data/lib/everywhere/config/desktop_ui.rb +153 -0
- data/lib/everywhere/config/mobile.rb +211 -0
- data/lib/everywhere/config/native_desktop.rb +168 -0
- data/lib/everywhere/config/native_mobile.rb +337 -0
- data/lib/everywhere/config/shell.rb +57 -0
- data/lib/everywhere/config/updates.rb +63 -0
- data/lib/everywhere/config.rb +59 -1367
- data/lib/everywhere/console.rb +2 -1
- data/lib/everywhere/desktop_dev_app.rb +138 -0
- data/lib/everywhere/dock/state.rb +3 -1
- data/lib/everywhere/engine.rb +24 -0
- data/lib/everywhere/entrypoint.rb +24 -0
- data/lib/everywhere/error.rb +8 -0
- data/lib/everywhere/framework.rb +23 -6
- data/lib/everywhere/host.rb +11 -0
- data/lib/everywhere/ignore.rb +13 -5
- data/lib/everywhere/jump.rb +121 -0
- data/lib/everywhere/line_pump.rb +3 -1
- data/lib/everywhere/minisign.rb +1 -0
- data/lib/everywhere/mobile_config_endpoint.rb +47 -0
- data/lib/everywhere/mobile_configs_controller.rb +46 -0
- data/lib/everywhere/native_platform.rb +44 -0
- data/lib/everywhere/paths.rb +36 -13
- data/lib/everywhere/platform/client.rb +46 -7
- data/lib/everywhere/platform/credentials.rb +18 -8
- data/lib/everywhere/platform/snapshot.rb +12 -0
- data/lib/everywhere/plist.rb +17 -0
- data/lib/everywhere/png.rb +1 -0
- data/lib/everywhere/raw_tty.rb +51 -0
- data/lib/everywhere/receipt.rb +55 -10
- data/lib/everywhere/s3.rb +1 -0
- data/lib/everywhere/shell_pages.rb +109 -0
- data/lib/everywhere/shellout.rb +19 -0
- data/lib/everywhere/simulator.rb +12 -1
- data/lib/everywhere/tab_filter.rb +36 -0
- data/lib/everywhere/task_pool.rb +3 -4
- data/lib/everywhere/ui.rb +6 -1
- data/lib/everywhere/version.rb +6 -4
- data/lib/everywhere.rb +1 -2
- data/support/mobile/android/app/build.gradle.kts +29 -1
- data/support/mobile/ios/App/EverywhereConfig.swift +17 -5
- data/support/mobile/ios/App/SceneDelegate.swift +75 -8
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +2 -4
- data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +0 -1
- data/support/mobile/ios/README.md +13 -6
- data/support/release/macos/notarize.sh +3 -0
- 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
|
-
#
|
|
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
|
-
#
|
|
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
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
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
|
-
|
|
63
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
#
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
136
|
-
|
|
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 =
|
|
42
|
-
sleep(0.1) while pids.any? { |pid| alive?(pid) } &&
|
|
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, [])
|