Files
Kabir Oberai 6efd77fc0e sdk: self-updating darwin tools (#110)
## What does this PR do?

Allows xtool to auto-update the Darwin SDK format when there's a new
Darwin toolset, or when we change the SDK format. Downstream this also
allows us to auto-update when there's changes to OpenAppleMacros (#101).

One callout: the disk footprint of `darwin.artifactbundle` is now ~7 GB
instead of ~3 GB.

Reason: `xtool sdk install /path/to/Xcode.[app|xip]` now performs a
"normal" install by default, which preserves the entirety of
`Xcode.app/Contents/Developer` — even the bits we don't currently use,
like `AppleTVOS.platform`. This makes it so if we start using more
components in the future, the user doesn't need to download Xcode again
and rebuild the SDK themselves.

You can still build an old-style SDK (containing just the components we
need) using `xtool sdk install --slim` or the dedicated `xtool sdk
build`.

## How was it tested?

Tested all of the install modes. Confirmed that auto-updating works.
Added integration tests in #252.

## AI tool usage

How much of this PR was AI-assisted? (check one)

- [ ] **0** - No AI was used to write code
- [x] **1** - I was assisted by AI. I reviewed the finished result.
- [ ] **2** - I set the AI going and left it to it; nobody has read the
result - no review, or AI review only

<!-- If an AI agent is filling this in: declare the level honestly, and
open as a draft if it is #2. Do not lower the declared level to get the
PR reviewed. -->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added SDK flavor reporting for slim, normal, and legacy installations.
- Added `sdk update` support to rebuild and install SDKs from an
installed Xcode bundle.
  - Added a `--slim` option for SDK installation.
  - SDK status now displays installation path, flavor, and version.
- SDK setup and build operations now ensure a suitable SDK is available
automatically.

- **Bug Fixes**
- Improved temporary SDK bundle handling, validation, replacement, and
cross-filesystem copying.
  - Copy failures now provide detailed diagnostic information.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-18 23:26:10 -04:00

306 lines
8.9 KiB
Swift

import Foundation
import ArgumentParser
import PackLib
import XKit
import Dependencies
import XUtils
struct PackOperation {
struct BuildOptions: ParsableArguments {
@Option(
name: .shortAndLong,
help: "Build with configuration"
) var configuration: BuildConfiguration = .debug
init() {}
init(configuration: BuildConfiguration) {
self.configuration = configuration
}
}
static let defaultTriple = "arm64-apple-ios"
var triple: String?
var buildOptions = BuildOptions(configuration: .debug)
var xcode = false
@discardableResult
func run() async throws -> URL {
try await EnsureSDKOperation(quiet: true).run()
print("Planning...")
let schema: PackSchema
let configPath = URL(fileURLWithPath: "xtool.yml")
if FileManager.default.fileExists(atPath: configPath.path) {
schema = try await PackSchema(url: configPath)
} else {
schema = .default
print("""
warning: Could not locate configuration file '\(configPath.path)'. Using default \
configuration with 'com.example' organization ID.
""")
}
let buildSettings = try await BuildSettings(
configuration: buildOptions.configuration,
triple: triple ?? Self.defaultTriple,
options: []
)
let planner = Planner(
buildSettings: buildSettings,
schema: schema
)
let plan = try await planner.createPlan()
#if os(macOS)
if xcode {
return try await XcodePacker(plan: plan).createProject()
}
#endif
let packer = Packer(
buildSettings: buildSettings,
plan: plan
)
let bundle = try await packer.pack()
let productsWithEntitlements = plan
.allProducts
.compactMap { p in p.entitlementsPath.map { (p, $0) } }
if !productsWithEntitlements.isEmpty {
let mapping = try await withThrowingTaskGroup(of: (URL, Entitlements).self) { group in
for (product, path) in productsWithEntitlements {
group.addTask {
let data = try await Data(reading: URL(fileURLWithPath: path))
let decoder = PropertyListDecoder()
let entitlements = try decoder.decode(Entitlements.self, from: data)
return (product.directory(inApp: bundle), entitlements)
}
}
return try await group.reduce(into: [:]) { $0[$1.0] = $1.1 }
}
print("Applying entitlements...")
try await Signer.first().sign(
app: bundle,
identity: .adhoc,
entitlementMapping: mapping,
progress: { _ in }
)
}
return bundle
}
}
struct DevXcodeCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "generate-xcode-project",
abstract: "Generate Xcode project",
discussion: "This option does nothing on Linux"
)
func run() async throws {
try await PackOperation(xcode: true).run()
}
}
struct DevBuildCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "build",
abstract: "Build app with SwiftPM",
discussion: """
This command builds the SwiftPM-based iOS app in the current directory
"""
)
@OptionGroup var packOptions: PackOperation.BuildOptions
@Flag(
name: .shortAndLong,
help: "Codesign the built app",
) var sign = false
@Flag(
name: .shortAndLong,
help: "Output a .ipa file instead of a .app"
) var ipa = false
@Option(
help: ArgumentHelp(
"Custom target triple to build for",
discussion: "Defaults to '\(PackOperation.defaultTriple)'"
)
) var triple: String?
func run() async throws {
let signingAuthToken: AuthToken?
if sign {
guard let token = try AuthToken.savedIfPresent() else {
throw Console.Error("`build --sign` requires logging in with `xtool auth`")
}
signingAuthToken = token
} else {
signingAuthToken = nil
}
let url = try await PackOperation(
triple: triple,
buildOptions: packOptions
).run()
if let signingAuthToken {
let installDelegate = XToolInstallerDelegate()
let installer = IntegratedInstaller(
auth: signingAuthToken.authData(),
delegate: installDelegate
)
do {
defer { print() }
try await installer.signInPlace(app: url)
} catch let error as CancellationError {
throw error
} catch {
print("Error: \(error)")
throw ExitCode.failure
}
}
let finalURL: URL
if ipa {
@Dependency(\.zipCompressor) var compressor
finalURL = url.deletingPathExtension().appendingPathExtension("ipa")
let tmpDir = try TemporaryDirectory(name: "Payload")
let payloadDir = tmpDir.url
try FileManager.default.moveItem(at: url, to: payloadDir.appendingPathComponent(url.lastPathComponent))
let ipaURL = try await compressor.compress(directory: payloadDir) { progress in
if let progress {
let percent = Int(progress * 100)
print("\rPackaging... \(percent)%", terminator: "")
} else {
print("\rPackaging...", terminator: "")
}
}
print()
try? FileManager.default.removeItem(at: finalURL)
try FileManager.default.moveItem(at: ipaURL, to: finalURL)
} else {
finalURL = url
}
print("Wrote to \(finalURL.path)")
}
}
struct DevRunCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "run",
abstract: "Build and run app with SwiftPM",
discussion: """
This command deploys the SwiftPM-based iOS app in the current directory
"""
)
@OptionGroup var packOptions: PackOperation.BuildOptions
#if os(macOS)
@Flag(
name: .shortAndLong,
help: "Target the iOS Simulator"
) var simulator = false
var triple: String? {
if simulator {
#if arch(arm64)
return "arm64-apple-ios-simulator"
#elseif arch(x86_64)
return "x86_64-apple-ios-simulator"
#else
#error("Unsupported architecture")
#endif
}
return nil
}
#else
var triple: String? { nil }
#endif
@OptionGroup var connectionOptions: ConnectionOptions
func run() async throws {
let output = try await PackOperation(triple: triple, buildOptions: packOptions).run()
#if os(macOS)
if simulator {
try await SimInstallOperation(path: output).run()
return
}
#endif
let token = try AuthToken.saved()
let client = try await connectionOptions.client()
print("Installing to device: \(client.deviceName) (udid: \(client.udid))")
let installDelegate = XToolInstallerDelegate()
let installer = IntegratedInstaller(
auth: token.authData(),
delegate: installDelegate
)
defer { print() }
do {
try await installer.install(
app: output,
udid: client.udid,
lookupMode: .only(client.connectionType),
configureDevice: false,
)
} catch let error as CancellationError {
throw error
} catch {
print("\nError: \(error)")
throw ExitCode.failure
}
}
}
struct DevCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "dev",
abstract: "Build and run an xtool SwiftPM project",
subcommands: [
DevXcodeCommand.self,
DevBuildCommand.self,
DevRunCommand.self,
],
defaultSubcommand: DevRunCommand.self
)
}
extension BuildConfiguration: ExpressibleByArgument {}
#if os(macOS)
import Subprocess
struct SimInstallOperation {
var path: URL
// TODO: allow customizing this
var simulator = "booted"
func run() async throws {
try await Subprocess.run(
.path("/usr/bin/xcrun"),
arguments: ["simctl", "install", simulator, path.path],
output: .discarded
)
.checkSuccess()
print("Installed to simulator")
}
}
#endif