Skip to content
Open
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
84 changes: 84 additions & 0 deletions Sources/PackLib/DarwinSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,90 @@ public struct DarwinSDK {
public let version: String
public let flavor: Flavor

/// The SDK directory (e.g. `iPhoneOS27.1.sdk`) that builds for `triple` link against, if installed.
///
/// This is the SDK `swift-sdk.json` names for the triple, i.e. the one SwiftPM compiles against, so
/// the link uses the same SDK as the compilation. Bundles from before that file listed it fall back
/// to the newest versioned SDK on disk. When the file is there but does not lead to an SDK on disk,
/// the fallback may differ from what SwiftPM compiles against, so `warn` is told about it.
package func sdkRoot(
forTriple triple: String,
warn: (String) -> Void = { stderrPrint($0) }
) -> URL? {
let metadata = sdkRootFromMetadata(forTriple: triple)
if case .found(let url) = metadata { return url }
let fallback = newestSDKRoot(forTriple: triple)
let file = bundle.appendingPathComponent("swift-sdk.json").path
let reason: String
switch metadata {
case .found, .absent:
return fallback
case .unreadable:
reason = "'\(file)' could not be read"
case .noEntry:
reason = "'\(file)' has no entry for \(triple)"
case .missing(let url):
reason = "'\(file)' names an SDK for \(triple) that is not installed at '\(url.path)'"
}
let used = fallback.map { "'\($0.path)'" } ?? "none found"
warn("warning: \(reason); linking against a different SDK than SwiftPM compiles against (\(used))")
return fallback
}

package enum MetadataSDKRoot: Equatable {
/// No `swift-sdk.json`, as in bundles from before it listed the SDK paths.
case absent
case unreadable
case noEntry
case missing(URL)
case found(URL)
}

package func sdkRootFromMetadata(forTriple triple: String) -> MetadataSDKRoot {
struct Definition: Decodable {
struct Target: Decodable {
let sdkRootPath: String
}
let targetTriples: [String: Target]
}
let file = bundle.appendingPathComponent("swift-sdk.json")
guard FileManager.default.fileExists(atPath: file.path) else { return .absent }
guard let data = try? Data(contentsOf: file) else { return .unreadable }
guard let definition = try? JSONDecoder().decode(Definition.self, from: data) else {
return .unreadable
}

// `triple` may carry an OS version (arm64-apple-ios17.0-simulator) that the keys don't
let wanted = Self.versionless(triple)
guard let target = definition.targetTriples.first(where: { Self.versionless($0.key) == wanted })?.value
else { return .noEntry }
let url = bundle.appendingPathComponent(target.sdkRootPath, isDirectory: true)
return url.dirExists ? .found(url) : .missing(url)
}

/// `arm64-apple-ios17.0-simulator` -> `arm64-apple-ios-simulator`
private static func versionless(_ triple: String) -> String {
triple.split(separator: "-", omittingEmptySubsequences: false)
.map { $0.prefix { !$0.isNumber } }
.joined(separator: "-")
}

private func newestSDKRoot(forTriple triple: String) -> URL? {
let platform: String
switch Packer.cfBundleSupportedPlatforms(for: triple) {
case ["iPhoneSimulator"]: platform = "iPhoneSimulator"
case ["MacOSX"]: platform = "MacOSX"
default: platform = "iPhoneOS"
}
let sdks = bundle.appendingPathComponent("Developer/Platforms/\(platform).platform/Developer/SDKs")
let names = (try? FileManager.default.contentsOfDirectory(atPath: sdks.path)) ?? []
let versioned = names
.filter { $0.hasPrefix(platform) && $0.hasSuffix(".sdk") && $0 != "\(platform).sdk" }
.sorted { $0.compare($1, options: .numeric) == .orderedAscending }
return (versioned.last ?? names.first { $0 == "\(platform).sdk" })
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.map { sdks.appendingPathComponent($0, isDirectory: true) }
}

static func swiftPMDirectory(
environment: [String: String] = ProcessInfo.processInfo.environment,
homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser
Expand Down
44 changes: 40 additions & 4 deletions Sources/PackLib/Packer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,38 @@ public struct Packer: Sendable {
self.buildSettings = buildSettings
}

/// Linker flags that make clang read the SDK's `SDKSettings.json`.
///
/// swiftc links through clang but hands it `--sysroot <SDK>`. Clang's Darwin driver only derives the
/// SDK version from `-isysroot`, so without this the `-platform_version` it gives the linker has
/// the deployment target in the SDK slot, and `LC_BUILD_VERSION.sdk` ends up equal to `minos`
/// (e.g. 17.0 instead of 27.1). App Store Connect checks that field against its minimum SDK.
///
/// This lives here and not in the Swift SDK's toolset because toolset options cannot be relative
/// to the SDK, whereas the installed SDK's location is known when we write the package, and it
/// also fixes SDKs installed before this was added.
package static func sysrootLinkerFlags(sdkRoot: URL?) -> [String] {
guard let sdkRoot else { return [] }
// -Xclang-linker is a Swift driver flag: this only works because targets are linked through swiftc.
return ["-Xclang-linker", "-isysroot", "-Xclang-linker", sdkRoot.path]
}

private func sdkRootForLinking() -> URL? {
#if os(macOS)
// Xcode's toolchain already passes -isysroot.
return nil
#else
return (try? DarwinSDK.current())?.sdkRoot(forTriple: buildSettings.triple)
#endif
}

private func build() async throws {
let xtoolDir = URL(fileURLWithPath: "xtool")
let packageDir = xtoolDir.appendingPathComponent(".xtool-tmp")
try? FileManager.default.removeItem(at: packageDir)
try FileManager.default.createDirectory(at: packageDir, withIntermediateDirectories: true)

let sysrootFlags = Packer.sysrootLinkerFlags(sdkRoot: sdkRootForLinking())
let packageSwift = packageDir.appendingPathComponent("Package.swift")
let contents = """
// swift-tools-version: 6.0
Expand All @@ -38,7 +64,7 @@ public struct Packer: Sendable {
dependencies: [
.product(name: "\($0.product)", package: "RootPackage"),
],
linkerSettings: \($0.linkerSettings)
linkerSettings: \($0.linkerSettings(extraFlags: sysrootFlags))
)
"""
}
Expand Down Expand Up @@ -233,16 +259,19 @@ public struct Packer: Sendable {
}

extension Plan.Product {
fileprivate var linkerSettings: String {
/// Swift source for the `linkerSettings:` argument of the generated target.
package func linkerSettings(extraFlags: [String]) -> String {
let extra = extraFlags.map { "\"\($0.swiftStringEscaped)\"," }.joined(separator: "\n")
switch self.type {
case .application: """
case .application: return """
[
.unsafeFlags([
"-Xlinker", "-rpath", "-Xlinker", "@executable_path/Frameworks",
\(extra)
]),
]
"""
case .appExtension: """
case .appExtension: return """
[
// Link to Foundation framework which implements the _NSExtensionMain entrypoint
.linkedFramework("Foundation"),
Expand All @@ -253,9 +282,16 @@ extension Plan.Product {
"-Xlinker", "-rpath", "-Xlinker", "@executable_path/../../Frameworks",
// ...as well as our own
"-Xlinker", "-rpath", "-Xlinker", "@executable_path/Frameworks",
\(extra)
]),
]
"""
}
}
}

private extension String {
var swiftStringEscaped: String {
replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"")
}
}
88 changes: 88 additions & 0 deletions Tests/XToolTests/DarwinSDKTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,91 @@ import Testing
#expect(!FileManager.default.fileExists(atPath: temporaryURL.path))
#expect(FileManager.default.fileExists(atPath: installedBundle.path))
}

private func makeSDKBundle(swiftSDKJSON: String?, sdks: [String]) throws -> (URL, DarwinSDK) {
let bundle = FileManager.default.temporaryDirectory
.appendingPathComponent("DarwinSDKTests-\(UUID().uuidString)", isDirectory: true)
.appendingPathComponent("darwin.artifactbundle", isDirectory: true)
try FileManager.default.createDirectory(at: bundle, withIntermediateDirectories: true)
if let swiftSDKJSON {
try Data(swiftSDKJSON.utf8).write(to: bundle.appendingPathComponent("swift-sdk.json"))
}
for sdk in sdks {
try FileManager.default.createDirectory(
at: bundle.appendingPathComponent("Developer/Platforms/iPhoneOS.platform/Developer/SDKs/\(sdk)"),
withIntermediateDirectories: true
)
}
return (bundle.deletingLastPathComponent(), try #require(DarwinSDK(bundle: bundle)))
}

private let sdkJSON = """
{"targetTriples": {"arm64-apple-ios": {"sdkRootPath": "Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS27.0.sdk"}}}
"""

@Test func sdkRootPrefersSwiftSDKMetadataWithoutWarning() throws {
let (root, sdk) = try makeSDKBundle(swiftSDKJSON: sdkJSON, sdks: ["iPhoneOS27.0.sdk", "iPhoneOS27.1.sdk"])
defer { try? FileManager.default.removeItem(at: root) }

var warnings: [String] = []
let result = sdk.sdkRoot(forTriple: "arm64-apple-ios17.0") { warnings.append($0) }

#expect(result?.lastPathComponent == "iPhoneOS27.0.sdk")
#expect(warnings.isEmpty)
}

@Test func sdkRootWarnsWhenMetadataSDKIsNotOnDisk() throws {
let (root, sdk) = try makeSDKBundle(swiftSDKJSON: sdkJSON, sdks: ["iPhoneOS26.0.sdk", "iPhoneOS27.1.sdk"])
defer { try? FileManager.default.removeItem(at: root) }

var warnings: [String] = []
let result = sdk.sdkRoot(forTriple: "arm64-apple-ios17.0") { warnings.append($0) }

#expect(result?.lastPathComponent == "iPhoneOS27.1.sdk")
#expect(warnings.count == 1)
#expect(warnings.first?.contains("swift-sdk.json") == true)
#expect(warnings.first?.contains("iPhoneOS27.0.sdk") == true)
#expect(warnings.first?.contains("iPhoneOS27.1.sdk") == true)
}

@Test func sdkRootWarnsWhenMetadataHasNoEntryForTriple() throws {
let (root, sdk) = try makeSDKBundle(swiftSDKJSON: sdkJSON, sdks: ["iPhoneOS27.1.sdk"])
defer { try? FileManager.default.removeItem(at: root) }

var warnings: [String] = []
let result = sdk.sdkRoot(forTriple: "arm64-apple-ios17.0-simulator") { warnings.append($0) }

// only an iPhoneOS SDK is installed, so the simulator fallback finds nothing either
#expect(result == nil)
#expect(warnings.count == 1)
#expect(warnings.first?.contains("no entry") == true)
#expect(warnings.first?.contains("none found") == true)
}

@Test func sdkRootFallsBackSilentlyWithoutMetadata() throws {
let (root, sdk) = try makeSDKBundle(swiftSDKJSON: nil, sdks: ["iPhoneOS27.0.sdk", "iPhoneOS27.1.sdk"])
defer { try? FileManager.default.removeItem(at: root) }

var warnings: [String] = []
let result = sdk.sdkRoot(forTriple: "arm64-apple-ios17.0") { warnings.append($0) }

#expect(result?.lastPathComponent == "iPhoneOS27.1.sdk")
#expect(warnings.isEmpty)
}

@Test func sdkRootWarnsWhenMetadataCannotBeRead() throws {
let (root, sdk) = try makeSDKBundle(swiftSDKJSON: nil, sdks: ["iPhoneOS27.1.sdk"])
defer { try? FileManager.default.removeItem(at: root) }
// present but not readable as a file
try FileManager.default.createDirectory(
at: sdk.bundle.appendingPathComponent("swift-sdk.json"),
withIntermediateDirectories: false
)

var warnings: [String] = []
let result = sdk.sdkRoot(forTriple: "arm64-apple-ios17.0") { warnings.append($0) }

#expect(result?.lastPathComponent == "iPhoneOS27.1.sdk")
#expect(warnings.count == 1)
#expect(warnings.first?.contains("could not be read") == true)
}
89 changes: 89 additions & 0 deletions Tests/XToolTests/PackerTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Foundation
import Testing
@testable import PackLib

Expand All @@ -13,3 +14,91 @@ import Testing

#expect(Packer.cfBundleSupportedPlatforms(for: "unknown-triple") == ["iPhoneOS"])
}

private func makeProduct(type: Plan.ProductType) -> Plan.Product {
Plan.Product(
type: type,
product: "Demo",
deploymentTarget: "17.0",
bundleID: "com.example.demo",
infoPlist: [:],
resources: []
)
}

@Test func sysrootLinkerFlagsPassIsysrootToClang() {
let sdk = URL(fileURLWithPath: "/sdks/iPhoneOS27.1.sdk")
#expect(Packer.sysrootLinkerFlags(sdkRoot: sdk) == [
"-Xclang-linker", "-isysroot", "-Xclang-linker", "/sdks/iPhoneOS27.1.sdk",
])
#expect(Packer.sysrootLinkerFlags(sdkRoot: nil).isEmpty)
}

@Test(arguments: [Plan.ProductType.application, .appExtension])
func generatedLinkerSettingsIncludeSysroot(type: Plan.ProductType) {
let flags = Packer.sysrootLinkerFlags(sdkRoot: URL(fileURLWithPath: "/sdks/with \"quote\"/iPhoneOS27.1.sdk"))
let settings = makeProduct(type: type).linkerSettings(extraFlags: flags)
#expect(settings.contains("\"-Xclang-linker\","))
#expect(settings.contains("\"-isysroot\","))
#expect(settings.contains(#""/sdks/with \"quote\"/iPhoneOS27.1.sdk","#))
#expect(settings.contains("@executable_path/Frameworks"))

#expect(!makeProduct(type: type).linkerSettings(extraFlags: []).contains("isysroot"))
}

@Test func sdkRootFollowsTheTripleAndPicksVersionedSDK() throws {
let bundle = FileManager.default.temporaryDirectory
.appendingPathComponent("SysrootTests-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: bundle) }
let sdks = [
"iPhoneOS.platform/Developer/SDKs/iPhoneOS27.1.sdk",
"iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator27.1.sdk",
]
for sdk in sdks {
try FileManager.default.createDirectory(
at: bundle.appendingPathComponent("Developer/Platforms/\(sdk)"),
withIntermediateDirectories: true
)
}
try Data("epoch=4\n".utf8).write(to: bundle.appendingPathComponent("darwin-sdk-version.txt"))
let sdk = try #require(DarwinSDK(bundle: bundle))

#expect(sdk.sdkRoot(forTriple: "arm64-apple-ios")?.lastPathComponent == "iPhoneOS27.1.sdk")
#expect(sdk.sdkRoot(forTriple: "arm64-apple-ios17.0-simulator")?.lastPathComponent == "iPhoneSimulator27.1.sdk")
#expect(sdk.sdkRoot(forTriple: "arm64-apple-macosx") == nil)
}

@Test func sdkRootFollowsSwiftSDKMetadata() throws {
let bundle = FileManager.default.temporaryDirectory
.appendingPathComponent("SysrootMetadataTests-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: bundle) }
for sdk in [
"iPhoneOS.platform/Developer/SDKs/iPhoneOS26.5.sdk",
"iPhoneOS.platform/Developer/SDKs/iPhoneOS27.1.sdk",
"iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator26.5.sdk",
"iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator27.1.sdk",
] {
try FileManager.default.createDirectory(
at: bundle.appendingPathComponent("Developer/Platforms/\(sdk)"),
withIntermediateDirectories: true
)
}
try Data("epoch=4\n".utf8).write(to: bundle.appendingPathComponent("darwin-sdk-version.txt"))
// SDKBuilder records the first matching SDK, which is not the newest one here
let metadata = """
{"schemaVersion": "4.0", "targetTriples": {
"arm64-apple-ios": {"sdkRootPath": "Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.5.sdk"},
"arm64-apple-ios-simulator": {
"sdkRootPath": "Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator26.5.sdk"
}
}}
"""
try Data(metadata.utf8).write(to: bundle.appendingPathComponent("swift-sdk.json"))
let sdk = try #require(DarwinSDK(bundle: bundle))

#expect(sdk.sdkRoot(forTriple: "arm64-apple-ios")?.lastPathComponent == "iPhoneOS26.5.sdk")
#expect(sdk.sdkRoot(forTriple: "arm64-apple-ios17.0")?.lastPathComponent == "iPhoneOS26.5.sdk")
#expect(sdk.sdkRoot(forTriple: "arm64-apple-ios17.0-simulator")?.lastPathComponent == "iPhoneSimulator26.5.sdk")
// a triple the metadata doesn't list falls back to the newest SDK on disk
#expect(sdk.sdkRoot(forTriple: "x86_64-apple-ios-simulator")?.lastPathComponent == "iPhoneSimulator27.1.sdk")
}