Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion Documentation/xtool.docc/Control.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,54 @@ bundleID: com.example.Hello
EOF
```

## Select the target platform

`xtool dev build` accepts a target triple for iOS, tvOS, macOS, or Mac Catalyst:

| Platform | Example target triple | Swift package deployment platform |
| --- | --- | --- |
| iOS | `arm64-apple-ios` | `.iOS("17.0")` |
| tvOS | `arm64-apple-tvos` | `.tvOS("17.0")` |
| macOS | `arm64-apple-macosx` | `.macOS("14.0")` |
| Mac Catalyst | `arm64-apple-ios-macabi` | `.macCatalyst("17.0")` and `.macOS("14.0")` |

For example:

```bash
xtool sdk status --triple arm64-apple-tvos
xtool dev build --configuration release --triple arm64-apple-tvos
```

The status command fails if the installed Darwin SDK lacks that target or its SDK directory.
Add `--build-metadata` to print the recorded SDK, platform, and Xcode build values as JSON.
Rebuild an older Darwin SDK to add the new target definitions. tvOS targets are included only when
the supplied Xcode installation contains the corresponding Apple TV SDKs. Mac Catalyst uses the
MacOSX SDK and its `System/iOSSupport` framework, header, and library paths. The SDK artifact retains
Xcode's `Contents/version.plist` as `xcode-version.plist` for consumers that need build provenance.
Linux builds stamp SDK, platform, and Xcode `DT*` values from those retained SDK metadata files into
the app and its extensions. Missing required provenance causes an error instead of fabricated build values.
Its `.macCatalyst` deployment version controls compilation; `.macOS` supplies the separate
`LSMinimumSystemVersion` value in the bundle's `Info.plist`.

`xtool dev build --print-supported-platforms` prints the platform names without building or requiring
an installed SDK. This reports xtool's packaging capabilities; use the SDK status command to check
the SDKs actually available on a machine.

Linux builds explicitly pass the recorded SDK version to the linker so the executable's
`LC_BUILD_VERSION` reports the SDK used to build it, independently of the minimum deployment version.
Mac Catalyst on Linux requires LLD 23 or newer to handle arm64e macOS libraries reexported by the
Apple SDK. Set `XTOOL_LD64_LLD` to an absolute path to an updated `ld64.lld` executable to override
the installed toolset's linker. The SDK status command checks this prerequisite for Catalyst targets.

macOS and Mac Catalyst produce a `YourApp.app/Contents` bundle with `MacOS`, `Resources`, `Frameworks`,
and `PlugIns` directories as needed. Their `Info.plist` is in `Contents`. These apps are left unsigned;
apply entitlements and sign them with a macOS-capable signing tool. `--ipa` and automatic development
signing are unavailable for these bundles. Automatic development signing remains limited to iOS.

These target and bundle rules do not add an Xcode project importer, an asset-catalog compiler, or an
App Store distribution workflow. Target-specific frameworks, compiled resources, signing, and store
acceptance still require validation with the actual Apple SDK and destination platform.

## Change your bundle ID

You can update your app's bundle ID by editing the `bundleID` field in `xtool.yml`.
Expand Down Expand Up @@ -94,7 +142,8 @@ resources:
- Resources/GoogleServices-Info.plist
```

The file will be copied to `Hello.app/GoogleServices-Info.plist`.
The file will be copied to `Hello.app/GoogleServices-Info.plist`, or to
`Hello.app/Contents/Resources/GoogleServices-Info.plist` for macOS and Mac Catalyst.

## Add an app icon

Expand Down
95 changes: 95 additions & 0 deletions Sources/PackLib/ApplePlatform.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import Foundation

package enum ApplePlatform: String, CaseIterable, Sendable {
case iOS
case tvOS
case macOS
case macCatalyst

package init?(triple: String) {
let components = triple.split(separator: "-")
guard components.count >= 3, components[1] == "apple" else { return nil }
let os = components[2].prefix { !$0.isNumber }
switch os {
case "ios": self = components.contains("macabi") ? .macCatalyst : .iOS
case "tvos": self = .tvOS
case "macos", "macosx": self = .macOS
default: return nil
}
}

package var usesContentsDirectory: Bool {
self == .macOS || self == .macCatalyst
}

package var packagePlatformName: String {
rawValue.lowercased()
}

package var minimumDeploymentTarget: String {
self == .macOS ? "10.15" : "13.0"
}

package func effectiveDeploymentTarget(
platforms: [String: String], swiftVersion: SwiftVersion, sdkMinimum: String?, tripleMinimum: String? = nil
) -> String {
let declared = platforms[packagePlatformName] ?? (self == .macCatalyst ? platforms["ios"] : nil)
// Match PackageModel.Platform.oldestSupportedVersion in SwiftPM 6.4.
let toolchainMinimum = swiftVersion >= SwiftVersion(6, 4)
? (self == .macOS ? "12.0" : "15.0") : minimumDeploymentTarget
return [declared, toolchainMinimum, sdkMinimum, tripleMinimum].compactMap { $0 }.max {
$0.compare($1, options: .numeric) == .orderedAscending
} ?? toolchainMinimum
}

package func supportedPlatform(triple: String) -> String {
let simulator = triple.split(separator: "-").contains("simulator")
switch self {
case .iOS: return simulator ? "iPhoneSimulator" : "iPhoneOS"
case .tvOS: return simulator ? "AppleTVSimulator" : "AppleTVOS"
case .macOS, .macCatalyst: return "MacOSX"
}
}

package func buildProductsPlatform(triple: String) -> String {
if self == .macCatalyst { return "maccatalyst" }
return supportedPlatform(triple: triple).lowercased()
}

package func linkerPlatform(triple: String) -> String {
let simulator = triple.split(separator: "-").contains("simulator")
switch self {
case .iOS: return simulator ? "ios-sim" : "ios"
case .tvOS: return simulator ? "tvos-sim" : "tvos"
case .macOS: return "macos"
case .macCatalyst: return "mac-catalyst"
}
}

package func contentsDirectory(in bundle: URL) -> URL {
usesContentsDirectory ? bundle.appendingPathComponent("Contents", isDirectory: true) : bundle
}

package func resourcesDirectory(in bundle: URL) -> URL {
let contents = contentsDirectory(in: bundle)
return usesContentsDirectory ? contents.appendingPathComponent("Resources", isDirectory: true) : contents
}

package func executableDirectory(in bundle: URL) -> URL {
let contents = contentsDirectory(in: bundle)
return usesContentsDirectory ? contents.appendingPathComponent("MacOS", isDirectory: true) : contents
}

package func additionalSDKOptions(sdkRoot: URL) -> [String] {
guard self == .macCatalyst else { return [] }
let support = sdkRoot.appendingPathComponent("System/iOSSupport")
let frameworks = support.appendingPathComponent("System/Library/Frameworks").path
return [
"-Xswiftc", "-Fsystem", "-Xswiftc", frameworks,
"-Xcc", "-isystem", "-Xcc", support.appendingPathComponent("usr/include").path,
"-Xcc", "-iframework", "-Xcc", frameworks,
"-Xlinker", "-F\(frameworks)",
"-Xlinker", "-L\(support.appendingPathComponent("usr/lib").path)",
]
}
}
51 changes: 49 additions & 2 deletions Sources/PackLib/BuildSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,24 @@ public struct BuildSettings: Sendable {
public let buildSystem: BuildSystem
public let customOptions: [String]

package var platform: ApplePlatform {
ApplePlatform(triple: triple) ?? .iOS
}

package var sdkTriple: String {
var components = triple.split(separator: "-").map(String.init)
guard components.count >= 3 else { return triple }
components[2] = String(components[2].prefix { !$0.isNumber })
return components.joined(separator: "-")
}

package var tripleDeploymentTarget: String? {
let components = triple.split(separator: "-")
guard components.count >= 3 else { return nil }
let version = components[2].drop { !$0.isNumber }
return version.isEmpty ? nil : String(version)
}

public var sdkOptions: [String]
public var sdkEnvironment: [Environment.Key: String?]

Expand Down Expand Up @@ -56,12 +74,18 @@ public struct BuildSettings: Sendable {
// https://github.com/swiftlang/swift-package-manager/pull/6828
self.sdkOptions = ["--swift-sdk", triple]
case .swiftBuild:
self.sdkOptions = ["--triple", triple]
self.sdkOptions = [
"--triple", triple,
// Match the native toolset: SwiftUI integration APIs such as PhotosPicker live in cross-import overlays.
"-Xswiftc", "-Xfrontend", "-Xswiftc", "-enable-cross-import-overlays",
]
#if !os(macOS)
let darwinSDK = try DarwinSDK.current()
.orThrow(StringError("No Darwin SDK configured. Please run `xtool setup`."))
let toolset = try LinkerConfiguration.toolsetOverride(sdk: darwinSDK)
?? darwinSDK.bundle.appendingPathComponent("toolset-swb.json")
self.sdkOptions += [
"--toolset", "\(darwinSDK.bundle.path)/toolset-swb.json",
"--toolset", toolset.path,
]
self.sdkEnvironment.merge([
"XCODE_EXTRA_PLATFORM_FOLDERS": "\(darwinSDK.bundle.path)/Developer/Platforms",
Expand All @@ -70,6 +94,29 @@ public struct BuildSettings: Sendable {
]) { $1 }
#endif
}

if platform == .macCatalyst {
if let sdkRoot = try await sdkRootURL() {
self.sdkOptions += platform.additionalSDKOptions(sdkRoot: sdkRoot)
}
}
#if !os(macOS)
if let darwinSDK = try DarwinSDK.current() {
try await LinkerConfiguration.validate(platform: platform, sdk: darwinSDK)
if self.buildSystem == .swiftPM, let toolset = try LinkerConfiguration.toolsetOverride(sdk: darwinSDK) {
self.sdkOptions += ["--toolset", toolset.path]
}
}
#endif
}

package func sdkRootURL() async throws -> URL? {
#if os(macOS)
let sdkName = platform.supportedPlatform(triple: triple).lowercased()
return URL(fileURLWithPath: try await Self.xcrun(["--sdk", sdkName, "--show-sdk-path"]))
#else
return try DarwinSDK.current()?.sdkRoot(for: sdkTriple)
#endif
}

#if os(macOS)
Expand Down
21 changes: 21 additions & 0 deletions Sources/PackLib/DarwinSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,27 @@ public struct DarwinSDK {
return DarwinSDK(bundle: bundle)
}

public func sdkRoot(for triple: String) throws -> URL {
struct Definition: Decodable {
struct Target: Decodable {
let sdkRootPath: String
}
let targetTriples: [String: Target]
}
let data = try Data(contentsOf: bundle.appendingPathComponent("swift-sdk.json"))
let definition = try JSONDecoder().decode(Definition.self, from: data)
guard let target = definition.targetTriples[triple] else {
throw StringError("""
The installed Darwin SDK does not contain target '\(triple)'. Rebuild it with the required platform SDK.
""")
}
let root = bundle.appendingPathComponent(target.sdkRootPath, isDirectory: true)
guard root.dirExists else {
throw StringError("The installed Darwin SDK is missing the SDK root for '\(triple)': '\(root.path)'")
}
return root
}

public func remove() throws {
try FileManager.default.removeItem(at: bundle)
}
Expand Down
72 changes: 72 additions & 0 deletions Sources/PackLib/LinkerConfiguration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Foundation
import Subprocess
import XUtils

package enum LinkerConfiguration {
package static func validate(platform: ApplePlatform, sdk: DarwinSDK) async throws {
let override = ProcessInfo.processInfo.environment["XTOOL_LD64_LLD"]
let executable: URL
if let override {
guard (override as NSString).isAbsolutePath, FileManager.default.isExecutableFile(atPath: override) else {
throw StringError("XTOOL_LD64_LLD must name an executable at an absolute path: '\(override)'")
}
executable = URL(fileURLWithPath: override)
} else {
executable = sdk.bundle.appendingPathComponent("toolset/bin/ld64.lld")
}
if platform == .macCatalyst {
let result = try await Subprocess.run(
.path(FilePath(executable.path)), arguments: ["--version"], output: .string(limit: .max)
).checkSuccess()
let version = result.standardOutput.trimmingCharacters(in: .whitespacesAndNewlines)
guard let major = majorVersion(version), major >= 23 else {
throw StringError("""
Mac Catalyst requires LLD 23 or newer on Linux for arm64e SDK reexports. \
Set XTOOL_LD64_LLD to an updated ld64.lld executable. Found: \(version)
""")
}
}
}

package static func toolsetOverride(sdk: DarwinSDK) throws -> URL? {
guard let override = ProcessInfo.processInfo.environment["XTOOL_LD64_LLD"] else { return nil }
let key = (sdk.bundle.path + "\n" + override).utf8.reduce(UInt64(14695981039346656037)) {
($0 ^ UInt64($1)) &* 1099511628211
}
let directory = try DarwinSDK.swiftPMDirectory().appendingPathComponent("xtool-toolsets/\(String(key, radix: 16))")
return try writeToolset(sdk: sdk, linker: override, directory: directory)
}

static func writeToolset(sdk: DarwinSDK, linker: String, directory: URL) throws -> URL {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let originalBin = sdk.bundle.appendingPathComponent("toolset/bin")
let wrapper = directory.appendingPathComponent("ld64.lld")
func shellQuote(_ value: String) -> String { "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" }
let script = """
#!/bin/sh
set -eu
for argument do
if [ "$argument" = "-r" ]; then
exec \(shellQuote(originalBin.appendingPathComponent("ld64.lld").path)) "$@"
fi
done
exec \(shellQuote(linker)) "$@"
"""
try Data(script.utf8).write(to: wrapper, options: .atomic)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: wrapper.path)
let toolset: [String: Any] = [
"schemaVersion": "1.0", "rootPath": originalBin.path,
"linker": ["path": wrapper.path],
"librarian": ["path": originalBin.appendingPathComponent("llvm-lib").path],
]
let definition = directory.appendingPathComponent("toolset.json")
try JSONSerialization.data(withJSONObject: toolset, options: .sortedKeys).write(to: definition, options: .atomic)
return definition
}

static func majorVersion(_ output: String) -> Int? {
let words = output.split(whereSeparator: \.isWhitespace)
guard words.count >= 2, words[0] == "LLD" else { return nil }
return words[1].split(separator: ".").first.flatMap { Int($0) }
}
}
Loading