diff --git a/Sources/PackLib/DarwinSDK.swift b/Sources/PackLib/DarwinSDK.swift index cadd72dd..cb01abce 100644 --- a/Sources/PackLib/DarwinSDK.swift +++ b/Sources/PackLib/DarwinSDK.swift @@ -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" }) + .map { sdks.appendingPathComponent($0, isDirectory: true) } + } + static func swiftPMDirectory( environment: [String: String] = ProcessInfo.processInfo.environment, homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser diff --git a/Sources/PackLib/Packer.swift b/Sources/PackLib/Packer.swift index bd2057b7..9edecb9d 100644 --- a/Sources/PackLib/Packer.swift +++ b/Sources/PackLib/Packer.swift @@ -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 `. 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 @@ -38,7 +64,7 @@ public struct Packer: Sendable { dependencies: [ .product(name: "\($0.product)", package: "RootPackage"), ], - linkerSettings: \($0.linkerSettings) + linkerSettings: \($0.linkerSettings(extraFlags: sysrootFlags)) ) """ } @@ -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"), @@ -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: "\\\"") + } +} diff --git a/Tests/XToolTests/DarwinSDKTests.swift b/Tests/XToolTests/DarwinSDKTests.swift index ed5ecb57..1a6b8cd1 100644 --- a/Tests/XToolTests/DarwinSDKTests.swift +++ b/Tests/XToolTests/DarwinSDKTests.swift @@ -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) +} diff --git a/Tests/XToolTests/PackerTests.swift b/Tests/XToolTests/PackerTests.swift index 5aabda83..c0208526 100644 --- a/Tests/XToolTests/PackerTests.swift +++ b/Tests/XToolTests/PackerTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import PackLib @@ -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") +}