Files
xtool-mirror/Sources/XToolSupport/DevCommand.swift
Kabir Oberai 0108899e04 sdk: SwiftBuild support (#229)
## What does this PR do?

Adds support for SwiftBuild on Swift 6.4.

We now point SwiftBuild to iPhoneOS.platform and rely on the real
[SWBApplePlatform](https://github.com/swiftlang/swift-build/tree/b30ee3c00abf34857084c26bb873c3da06348134/Sources/SWBApplePlatform).
We also do a little futzing to convince SWB to use our Darwin-compatible
toolset (lld, librarian, resource dirs). We explicitly _don't_ pass in a
Swift SDK because that would downgrade SWB to using one of the generic
platform plugins instead of ApplePlatform.

This also gets us a lot closer to parity with Xcode's behavior when
building for Apple platforms; we get the same build flags and
everything. We can probably achieve identical bundling too, if we take
control of the PIF.

One other change: since we need to pass custom env vars and such, we
can't rely on sourcekit-lsp's built-in SwiftPM integration anymore. We
thus create a custom BSP server that trampolines to SwiftPM's build
server with the right flags set. This effectively makes SwiftPM an
implementation detail, so we can start messing with the PIF.

## How was it tested?

- [x] Build with Swift 6.3 on macOS
- [x] Build with Swift 6.4 on macOS
- [x] Build with Swift 6.3 on Linux
- [x] Build with Swift 6.4 on Linux
- [x] BSP/LSP file creation works
- [x] LSP support works in VSCode

## 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 a `dev build-server` command for Build Server Protocol
integration.
- Projects automatically select the appropriate Swift build system when
supported.
- Packaging supports output layouts for Swift Package Manager and Swift
Build.
  - Added options to skip language-server configuration when needed.

- **Documentation**
- Updated project templates and tutorials to use `.bsp/xtool.json` for
IDE features such as syntax highlighting and documentation lookup.

- **Improvements**
- Enhanced SDK generation and compatibility across supported toolchains
and platforms.
- Improved automatic language-server configuration while preserving
existing configuration files.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-20 02:47:12 -04:00

318 lines
9.3 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 {
// TODO: support running inside a child directory
let contents = try FileManager.default.contentsOfDirectory(atPath: ".")
guard contents.contains("Package.swift")
|| contents.contains(where: { $0.hasPrefix("Package@") && $0.hasSuffix(".swift") })
else {
throw Console.Error("Could not find Package.swift in this directory.")
}
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.
""")
}
await LSPConfig.tryEnsure(using: schema)
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,
DevBSPCommand.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