From df68ba5a5b290c57146b970534ce55dc5b9a7240 Mon Sep 17 00:00:00 2001 From: bvolpato Date: Fri, 16 Oct 2026 17:06:56 -0400 Subject: [PATCH] build: support tvOS and Mac Catalyst targets --- Documentation/xtool.docc/Control.md | 51 ++++++- Sources/PackLib/ApplePlatform.swift | 95 +++++++++++++ Sources/PackLib/BuildSettings.swift | 51 ++++++- Sources/PackLib/DarwinSDK.swift | 21 +++ Sources/PackLib/LinkerConfiguration.swift | 72 ++++++++++ Sources/PackLib/Packer.swift | 113 +++++++++------ Sources/PackLib/Planner.swift | 75 +++++++--- Sources/PackLib/SDKBuildMetadata.swift | 120 ++++++++++++++++ Sources/XToolSupport/DevCommand.swift | 21 ++- Sources/XToolSupport/SDKBuilder.swift | 130 ++++++++++-------- Sources/XToolSupport/SDKCommand.swift | 30 ++++ Tests/XToolTests/ApplePlatformTests.swift | 61 ++++++++ Tests/XToolTests/DarwinSDKTests.swift | 17 +++ .../XToolTests/LinkerConfigurationTests.swift | 37 +++++ Tests/XToolTests/PackerTests.swift | 61 ++++++++ Tests/XToolTests/PlannerTests.swift | 55 ++++++++ Tests/XToolTests/SDKBuildMetadataTests.swift | 79 +++++++++++ Tests/XToolTests/SDKBuilderTests.swift | 40 ++++++ 18 files changed, 1010 insertions(+), 119 deletions(-) create mode 100644 Sources/PackLib/ApplePlatform.swift create mode 100644 Sources/PackLib/LinkerConfiguration.swift create mode 100644 Sources/PackLib/SDKBuildMetadata.swift create mode 100644 Tests/XToolTests/ApplePlatformTests.swift create mode 100644 Tests/XToolTests/LinkerConfigurationTests.swift create mode 100644 Tests/XToolTests/PlannerTests.swift create mode 100644 Tests/XToolTests/SDKBuildMetadataTests.swift create mode 100644 Tests/XToolTests/SDKBuilderTests.swift diff --git a/Documentation/xtool.docc/Control.md b/Documentation/xtool.docc/Control.md index babd0c2e..d7b45d1d 100644 --- a/Documentation/xtool.docc/Control.md +++ b/Documentation/xtool.docc/Control.md @@ -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`. @@ -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 diff --git a/Sources/PackLib/ApplePlatform.swift b/Sources/PackLib/ApplePlatform.swift new file mode 100644 index 00000000..7d33954e --- /dev/null +++ b/Sources/PackLib/ApplePlatform.swift @@ -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)", + ] + } +} diff --git a/Sources/PackLib/BuildSettings.swift b/Sources/PackLib/BuildSettings.swift index 9d5a041c..a6ccad7a 100644 --- a/Sources/PackLib/BuildSettings.swift +++ b/Sources/PackLib/BuildSettings.swift @@ -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?] @@ -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", @@ -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) diff --git a/Sources/PackLib/DarwinSDK.swift b/Sources/PackLib/DarwinSDK.swift index cadd72dd..422a1340 100644 --- a/Sources/PackLib/DarwinSDK.swift +++ b/Sources/PackLib/DarwinSDK.swift @@ -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) } diff --git a/Sources/PackLib/LinkerConfiguration.swift b/Sources/PackLib/LinkerConfiguration.swift new file mode 100644 index 00000000..a49132ab --- /dev/null +++ b/Sources/PackLib/LinkerConfiguration.swift @@ -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) } + } +} diff --git a/Sources/PackLib/Packer.swift b/Sources/PackLib/Packer.swift index bd2057b7..be41fcae 100644 --- a/Sources/PackLib/Packer.swift +++ b/Sources/PackLib/Packer.swift @@ -12,6 +12,12 @@ public struct Packer: Sendable { } private func build() async throws { + let sdkVersion: String? + #if os(macOS) + sdkVersion = nil + #else + sdkVersion = try DarwinSDK.current()?.sdkVersion(for: buildSettings.sdkTriple) + #endif let xtoolDir = URL(fileURLWithPath: "xtool") let packageDir = xtoolDir.appendingPathComponent(".xtool-tmp") try? FileManager.default.removeItem(at: packageDir) @@ -24,7 +30,7 @@ public struct Packer: Sendable { let package = Package( name: "\(plan.app.product)-Builder", platforms: [ - .iOS("\(plan.app.deploymentTarget)"), + .\(buildSettings.platform.rawValue)("\(plan.app.deploymentTarget)"), ], dependencies: [ .package(name: "RootPackage", path: "../.."), @@ -38,7 +44,11 @@ public struct Packer: Sendable { dependencies: [ .product(name: "\($0.product)", package: "RootPackage"), ], - linkerSettings: \($0.linkerSettings) + linkerSettings: \($0.linkerSettings( + platform: buildSettings.platform, + triple: buildSettings.triple, + sdkVersion: sdkVersion + )) ) """ } @@ -90,20 +100,37 @@ public struct Packer: Sendable { case .swiftPM: binPath = "\(buildSettings.triple)/\(buildSettings.configuration.rawValue)" case .swiftBuild: - let platformName = buildSettings.triple.contains("simulator") ? "iphonesimulator" : "iphoneos" - binPath = "out/Products/\(buildSettings.configuration.swiftBuildValue)-\(platformName)" + let platformName = buildSettings.platform.buildProductsPlatform(triple: buildSettings.triple) + let suffix = buildSettings.platform == .macOS ? "" : "-\(platformName)" + binPath = "out/Products/\(buildSettings.configuration.swiftBuildValue)\(suffix)" } let binDir = URL( fileURLWithPath: ".build/\(binPath)", isDirectory: true ) + let metadata: [String: String] + #if os(macOS) + metadata = [:] + #else + metadata = try DarwinSDK.current()?.buildMetadata(for: buildSettings.sdkTriple) ?? [:] + #endif + try await packProducts(binDir: binDir, outputURL: outputURL, buildMetadata: metadata) + + let dest = URL(fileURLWithPath: "xtool").appendingPathComponent(outputURL.lastPathComponent) + try? FileManager.default.removeItem(at: dest) + try output.persist(at: dest) + return dest + } + + func packProducts(binDir: URL, outputURL: URL, buildMetadata: [String: String] = [:]) async throws { try await withThrowingTaskGroup(of: Void.self) { group in for product in plan.allProducts { try pack( product: product, binDir: binDir, - outputURL: product.directory(inApp: outputURL), + outputURL: product.directory(inApp: outputURL, platform: buildSettings.platform), + buildMetadata: buildMetadata, &group ) } @@ -119,22 +146,22 @@ public struct Packer: Sendable { } } } - - let dest = URL(fileURLWithPath: "xtool").appendingPathComponent(outputURL.lastPathComponent) - try? FileManager.default.removeItem(at: dest) - try output.persist(at: dest) - return dest } @Sendable private func pack( product: Plan.Product, binDir: URL, outputURL: URL, + buildMetadata: [String: String], _ group: inout ThrowingTaskGroup ) throws { + let platform = buildSettings.platform + let contentsURL = platform.contentsDirectory(in: outputURL) + let resourcesURL = platform.resourcesDirectory(in: outputURL) + @Sendable func packFileToRoot(srcName: String) async throws { let srcURL = URL(fileURLWithPath: srcName) - let destURL = outputURL.appendingPathComponent(srcURL.lastPathComponent) + let destURL = resourcesURL.appendingPathComponent(srcURL.lastPathComponent) try FileManager.default.copyItem(at: srcURL, to: destURL) try Task.checkCancellation() @@ -142,7 +169,7 @@ public struct Packer: Sendable { @Sendable func packFile(srcName: String, dstName: String? = nil, sign: Bool = false) async throws { let srcURL = URL(fileURLWithPath: srcName, relativeTo: binDir) - let dstURL = URL(fileURLWithPath: dstName ?? srcURL.lastPathComponent, relativeTo: outputURL) + let dstURL = URL(fileURLWithPath: dstName ?? srcURL.lastPathComponent, relativeTo: contentsURL) try? FileManager.default.createDirectory(at: dstURL.deletingLastPathComponent(), withIntermediateDirectories: true) try FileManager.default.copyItem(at: srcURL, to: dstURL) @@ -150,13 +177,15 @@ public struct Packer: Sendable { } // Ensure output directory is available - try? FileManager.default.createDirectory(at: outputURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: resourcesURL, withIntermediateDirectories: true) for command in product.resources { group.addTask { switch command { case .bundle(let package, let target): - try await packFile(srcName: "\(package)_\(target).bundle") + let name = "\(package)_\(target).bundle" + let destination = platform.usesContentsDirectory ? "Resources/\(name)" : name + try await packFile(srcName: name, dstName: destination) case .binaryTarget(let name): let src = URL(fileURLWithPath: "\(name).framework/\(name)", relativeTo: binDir) let magic = Data("!\n".utf8) @@ -185,23 +214,29 @@ public struct Packer: Sendable { } } group.addTask { - try await packFile(srcName: product.targetName, dstName: product.product) + let destination = platform.usesContentsDirectory ? "MacOS/\(product.product)" : product.product + try await packFile(srcName: product.targetName, dstName: destination) } group.addTask { - var info = product.infoPlist + var info = product.infoPlist.merging(buildMetadata.mapValues { $0 as any Sendable }) { _, recorded in recorded } if product.type == .application { - info["UIRequiredDeviceCapabilities"] = ["arm64"] - info["LSRequiresIPhoneOS"] = true - info["CFBundleSupportedPlatforms"] = Packer.cfBundleSupportedPlatforms(for: buildSettings.triple) + if !platform.usesContentsDirectory { + // Simulator builds may target x86_64 and do not require device hardware capabilities. + if !buildSettings.triple.split(separator: "-").contains("simulator") { + info["UIRequiredDeviceCapabilities"] = ["arm64"] + } + info["LSRequiresIPhoneOS"] = true + } } + info["CFBundleSupportedPlatforms"] = Packer.cfBundleSupportedPlatforms(for: buildSettings.triple) if let iconPath = product.iconPath { let iconName = URL(fileURLWithPath: iconPath).deletingPathExtension().lastPathComponent info["CFBundleIconFile"] = iconName } - let infoPath = outputURL.appendingPathComponent("Info.plist") + let infoPath = contentsURL.appendingPathComponent("Info.plist") let encodedPlist = try PropertyListSerialization.data( fromPropertyList: info, format: .xml, @@ -212,33 +247,26 @@ public struct Packer: Sendable { } static func cfBundleSupportedPlatforms(for triple: String) -> [String] { - let components = triple.split(separator: "-").map(String.init) - guard let appleIndex = components.firstIndex(of: "apple"), - components.indices.contains(appleIndex + 1) else { - return ["iPhoneOS"] - } - - let os = components[appleIndex + 1].prefix { !$0.isNumber } - switch os { - case "ios" where components.contains("simulator"): - return ["iPhoneSimulator"] - case "ios": - return ["iPhoneOS"] - case "macos", "macosx": - return ["MacOSX"] - default: - return ["iPhoneOS"] - } + [ApplePlatform(triple: triple)?.supportedPlatform(triple: triple) ?? "iPhoneOS"] } } extension Plan.Product { - fileprivate var linkerSettings: String { - switch self.type { + fileprivate func linkerSettings(platform: ApplePlatform, triple: String, sdkVersion: String?) -> String { + let frameworks = platform.usesContentsDirectory ? "../Frameworks" : "Frameworks" + let appFrameworks = platform.usesContentsDirectory ? "../../../../Frameworks" : "../../Frameworks" + let sdkFlags = if let sdkVersion { + """ + "-Xlinker", "-platform_version", "-Xlinker", "\(platform.linkerPlatform(triple: triple))", + "-Xlinker", "\(deploymentTarget)", "-Xlinker", "\(sdkVersion)", + """ + } else { "" } + return switch self.type { case .application: """ [ .unsafeFlags([ - "-Xlinker", "-rpath", "-Xlinker", "@executable_path/Frameworks", + "-Xlinker", "-rpath", "-Xlinker", "@executable_path/\(frameworks)", + \(sdkFlags) ]), ] """ @@ -250,9 +278,10 @@ extension Plan.Product { // Set the entry point to Foundation`_NSExtensionMain "-Xlinker", "-e", "-Xlinker", "_NSExtensionMain", // Include frameworks that the host app may use - "-Xlinker", "-rpath", "-Xlinker", "@executable_path/../../Frameworks", + "-Xlinker", "-rpath", "-Xlinker", "@executable_path/\(appFrameworks)", // ...as well as our own - "-Xlinker", "-rpath", "-Xlinker", "@executable_path/Frameworks", + "-Xlinker", "-rpath", "-Xlinker", "@executable_path/\(frameworks)", + \(sdkFlags) ]), ] """ diff --git a/Sources/PackLib/Planner.swift b/Sources/PackLib/Planner.swift index df2f0125..9cc65534 100644 --- a/Sources/PackLib/Planner.swift +++ b/Sources/PackLib/Planner.swift @@ -20,10 +20,6 @@ public struct Planner: Sendable { return decoder }() - // anything older than this requires bundling the stdlib which - // is doable but probably not worth the effort - private static let minSupportedIOSVersion = "13.0" - private func buildGraph() async throws -> PackageGraph { let dependencyRoot = try await dumpDependencies() @@ -164,33 +160,47 @@ public struct Planner: Sendable { } let bundleID = idSpecifier.formBundleID(product: library.name) - let deploymentTarget = graph.root.platforms?.first { $0.name == "ios" }?.version - ?? Self.minSupportedIOSVersion + let platform = buildSettings.platform + let (deploymentTarget, macOSBundleMinimum) = try await deploymentTargets(for: graph.root) var infoPlist: [String: Sendable] = [ "CFBundleInfoDictionaryVersion": "6.0", "CFBundleDevelopmentRegion": "en", "CFBundleVersion": "1", "CFBundleShortVersionString": "1.0.0", - "MinimumOSVersion": deploymentTarget, "CFBundleIdentifier": bundleID, "CFBundleName": library.name, "CFBundleExecutable": library.name, "CFBundleDisplayName": library.name, "CFBundlePackageType": type.fourCharCode, ] + if platform.usesContentsDirectory { + // Catalyst's deployment version is an iOS version. The macOS minimum is a separate value. + infoPlist["LSMinimumSystemVersion"] = macOSBundleMinimum + } else { + infoPlist["MinimumOSVersion"] = deploymentTarget + } switch type { case .application: - infoPlist["UIDeviceFamily"] = [1, 2] - infoPlist["UISupportedInterfaceOrientations"] = ["UIInterfaceOrientationPortrait"] - infoPlist["UISupportedInterfaceOrientations~ipad"] = [ - "UIInterfaceOrientationPortrait", - "UIInterfaceOrientationPortraitUpsideDown", - "UIInterfaceOrientationLandscapeLeft", - "UIInterfaceOrientationLandscapeRight", - ] - infoPlist["UILaunchScreen"] = [:] as [String: Sendable] + switch platform { + case .iOS: + infoPlist["UIDeviceFamily"] = [1, 2] + infoPlist["UISupportedInterfaceOrientations"] = ["UIInterfaceOrientationPortrait"] + infoPlist["UISupportedInterfaceOrientations~ipad"] = [ + "UIInterfaceOrientationPortrait", + "UIInterfaceOrientationPortraitUpsideDown", + "UIInterfaceOrientationLandscapeLeft", + "UIInterfaceOrientationLandscapeRight", + ] + infoPlist["UILaunchScreen"] = [:] as [String: Sendable] + case .tvOS: + infoPlist["UIDeviceFamily"] = [3] + case .macCatalyst: + infoPlist["UIDeviceFamily"] = [6] + case .macOS: + break + } case .appExtension: // Should set default parameters? infoPlist["NSExtension"] = [:] as [String: Sendable] @@ -205,6 +215,10 @@ public struct Planner: Sendable { throw StringError("Info.plist has invalid format: expected a dictionary.") } } + let minimumKey = platform.usesContentsDirectory ? "LSMinimumSystemVersion" : "MinimumOSVersion" + let bundleMinimum = platform.usesContentsDirectory ? macOSBundleMinimum : deploymentTarget + infoPlist[minimumKey] = [infoPlist[minimumKey] as? String, bundleMinimum] + .compactMap { $0 }.max { $0.compare($1, options: .numeric) == .orderedAscending } return Plan.Product( type: type, @@ -218,6 +232,29 @@ public struct Planner: Sendable { ) } + private func deploymentTargets(for package: PackageDump) async throws -> (product: String, macOS: String) { + let platform = buildSettings.platform + let platforms = Dictionary((package.platforms ?? []).map { ($0.name, $0.version) }, uniquingKeysWith: { _, last in last }) + let swiftVersion = try await SwiftVersion.current + let sdkRoot = try await buildSettings.sdkRootURL() + let sdkMinimum = try sdkRoot.flatMap { + try DarwinSDK.minimumDeploymentTarget(for: buildSettings.sdkTriple, sdkRoot: $0) + } + let deploymentTarget = platform.effectiveDeploymentTarget( + platforms: platforms, swiftVersion: swiftVersion, sdkMinimum: sdkMinimum, + tripleMinimum: buildSettings.tripleDeploymentTarget + ) + let macOSMinimum = ApplePlatform.macOS.effectiveDeploymentTarget( + platforms: platforms, swiftVersion: swiftVersion, sdkMinimum: nil + ) + let catalystMacOSMinimum = platform == .macCatalyst ? try sdkRoot.flatMap { + try DarwinSDK.macOSMinimum(forCatalyst: deploymentTarget, sdkRoot: $0) + } : nil + let macOSBundleMinimum = [macOSMinimum, catalystMacOSMinimum, platform == .macOS ? deploymentTarget : nil] + .compactMap { $0 }.max { $0.compare($1, options: .numeric) == .orderedAscending } ?? macOSMinimum + return (deploymentTarget, macOSBundleMinimum) + } + private func dumpDependencies() async throws -> PackageDependency { let tempDir = try TemporaryDirectory(name: "xtool-dump") let tempFileURL = tempDir.url.appendingPathComponent("dump.json") @@ -325,12 +362,16 @@ public struct Plan: Sendable { } public func directory(inApp baseDir: URL) -> URL { + directory(inApp: baseDir, platform: .iOS) + } + + package func directory(inApp baseDir: URL, platform: ApplePlatform) -> URL { switch type { case .application: baseDir .appendingPathComponent(".", isDirectory: true) case .appExtension: - baseDir + platform.contentsDirectory(in: baseDir) .appendingPathComponent("PlugIns", isDirectory: true) .appendingPathComponent(product, isDirectory: true) .appendingPathExtension("appex") diff --git a/Sources/PackLib/SDKBuildMetadata.swift b/Sources/PackLib/SDKBuildMetadata.swift new file mode 100644 index 00000000..dac42cec --- /dev/null +++ b/Sources/PackLib/SDKBuildMetadata.swift @@ -0,0 +1,120 @@ +import Foundation + +extension DarwinSDK { + public func buildMetadata(for triple: String) throws -> [String: String] { + let root = try sdkRoot(for: triple) + let settings = try Self.sdkSettings(root) + let platform = root.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let platformInfo = try Self.metadataPlist(platform.appendingPathComponent("Info.plist")) + let additional = platformInfo["AdditionalInfo"] as? [String: Any] ?? [:] + let defaults = settings["DefaultProperties"] as? [String: Any] ?? [:] + let platformDefaults = platformInfo["DefaultProperties"] as? [String: Any] ?? [:] + let systemVersionURL = root.appendingPathComponent("System/Library/CoreServices/SystemVersion.plist") + let systemVersion = FileManager.default.fileExists(atPath: systemVersionURL.path) + ? try Self.metadataPlist(systemVersionURL) : [:] + let xcode = try Self.metadataPlist(bundle.appendingPathComponent("xcode-version.plist")) + + // Platform Info.plist contains Xcode build-setting templates, not necessarily recorded values. + func recorded(_ value: Any?) -> String? { + guard let string = value as? String, !string.contains("$(") else { return nil } + return string + } + + func required(_ value: Any?, key: String) throws -> String { + guard let value = recorded(value), !value.isEmpty || key == "DTPlatformBuild" else { + throw StringError("The Darwin SDK is missing build metadata '\(key)' for '\(triple)'. Rebuild the SDK.") + } + return value + } + + let sdkBuild = recorded(settings["ProductBuildVersion"]) ?? recorded(systemVersion["ProductBuildVersion"]) + ?? recorded(additional["DTSDKBuild"]) + var metadata = try [ + "DTSDKName": required(recorded(settings["CanonicalName"]) ?? recorded(additional["DTSDKName"]), key: "DTSDKName"), + "DTSDKBuild": required(sdkBuild, key: "DTSDKBuild"), + "DTPlatformName": required(recorded(additional["DTPlatformName"]) ?? defaults["PLATFORM_NAME"], key: "DTPlatformName"), + "DTPlatformVersion": required(recorded(settings["Version"]) ?? additional["DTPlatformVersion"], key: "DTPlatformVersion"), + "DTPlatformBuild": required(recorded(additional["DTPlatformBuild"]) ?? sdkBuild, key: "DTPlatformBuild"), + "DTXcodeBuild": required(recorded(xcode["ProductBuildVersion"]) ?? additional["DTXcodeBuild"], key: "DTXcodeBuild"), + ] + if let version = xcode["CFBundleShortVersionString"] as? String { + metadata["DTXcode"] = try Self.xcodeBuildVersion(version) + } else { + metadata["DTXcode"] = try required(additional["DTXcode"], key: "DTXcode") + } + if let compiler = recorded(additional["DTCompiler"]) ?? recorded(defaults["DEFAULT_COMPILER"]) + ?? recorded(platformDefaults["DEFAULT_COMPILER"]) { + metadata["DTCompiler"] = compiler + } + return metadata + } + + public func sdkVersion(for triple: String) throws -> String { + let settings = try Self.sdkSettings(sdkRoot(for: triple)) + guard let version = settings["Version"] as? String else { + throw StringError("The Darwin SDK is missing its version for '\(triple)'.") + } + if ApplePlatform(triple: triple) == .macCatalyst, + let maps = settings["VersionMap"] as? [String: [String: String]], + let catalystVersion = maps["macOS_iOSMac"]?[version] { + return catalystVersion + } + return version + } + + package func minimumDeploymentTarget(for triple: String) throws -> String? { + try Self.minimumDeploymentTarget(for: triple, sdkRoot: sdkRoot(for: triple)) + } + + package static func minimumDeploymentTarget(for triple: String, sdkRoot: URL) throws -> String? { + let settings = try sdkSettings(sdkRoot) + let platform = ApplePlatform(triple: triple) ?? .iOS + let target = platform == .macCatalyst ? "iosmac" : platform.supportedPlatform(triple: triple).lowercased() + let targets = settings["SupportedTargets"] as? [String: [String: Any]] + return targets?[target]?["MinimumDeploymentTarget"] as? String + } + + package func macOSMinimum(forCatalyst deployment: String, triple: String) throws -> String? { + try Self.macOSMinimum(forCatalyst: deployment, sdkRoot: sdkRoot(for: triple)) + } + + package static func macOSMinimum(forCatalyst deployment: String, sdkRoot: URL) throws -> String? { + let settings = try sdkSettings(sdkRoot) + let maps = settings["VersionMap"] as? [String: [String: String]] + let mapping = maps?["iOSMac_macOS"] ?? [:] + let available = mapping.keys.filter { $0.compare(deployment, options: .numeric) != .orderedDescending } + let key = available.max { $0.compare($1, options: .numeric) == .orderedAscending } + return key.flatMap { mapping[$0] } + } + + private static func sdkSettings(_ root: URL) throws -> [String: Any] { + let json = root.appendingPathComponent("SDKSettings.json") + if FileManager.default.fileExists(atPath: json.path) { + let data = try Data(contentsOf: json) + guard let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw StringError("Invalid SDK settings at '\(json.path)'") + } + return decoded + } else { + return try Self.metadataPlist(root.appendingPathComponent("SDKSettings.plist")) + } + } + + private static func metadataPlist(_ url: URL) throws -> [String: Any] { + let data = try Data(contentsOf: url) + guard let plist = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { + throw StringError("Invalid SDK build metadata at '\(url.path)'") + } + return plist + } + + static func xcodeBuildVersion(_ version: String) throws -> String { + let pieces = version.split(separator: ".").map(String.init) + guard (2...3).contains(pieces.count), let major = Int(pieces[0]), let minor = Int(pieces[1]), + let patch = pieces.count == 3 ? Int(pieces[2]) : 0, + major >= 0, minor >= 0, minor < 10, patch >= 0, patch < 10 else { + throw StringError("Unsupported Xcode version in SDK metadata: '\(version)'") + } + return String(format: "%04d", major * 100 + minor * 10 + patch) + } +} diff --git a/Sources/XToolSupport/DevCommand.swift b/Sources/XToolSupport/DevCommand.swift index 369bfa8d..bdb97390 100644 --- a/Sources/XToolSupport/DevCommand.swift +++ b/Sources/XToolSupport/DevCommand.swift @@ -79,14 +79,15 @@ struct PackOperation { let productsWithEntitlements = plan .allProducts .compactMap { p in p.entitlementsPath.map { (p, $0) } } - if !productsWithEntitlements.isEmpty { + // Zupersign handles mobile bundles. macOS bundles are left unsigned for a platform-specific signer. + if !productsWithEntitlements.isEmpty && !buildSettings.platform.usesContentsDirectory { 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 (product.directory(inApp: bundle, platform: buildSettings.platform), entitlements) } } return try await group.reduce(into: [:]) { $0[$1.0] = $1.1 } @@ -140,11 +141,25 @@ struct DevBuildCommand: AsyncParsableCommand { @Option( help: ArgumentHelp( "Custom target triple to build for", - discussion: "Defaults to '\(PackOperation.defaultTriple)'" + discussion: "Defaults to '\(PackOperation.defaultTriple)'. Supports iOS, tvOS, macOS, and Mac Catalyst." ) ) var triple: String? + @Flag(help: "Print supported platforms without building or requiring an SDK") + var printSupportedPlatforms = false + func run() async throws { + if printSupportedPlatforms { + print(ApplePlatform.allCases.map(\.rawValue).joined(separator: "\n")) + return + } + let platform = ApplePlatform(triple: triple ?? PackOperation.defaultTriple) ?? .iOS + if ipa && platform.usesContentsDirectory { + throw Console.Error("macOS and Mac Catalyst apps cannot be packaged as an IPA. Use the .app output.") + } + if sign && platform != .iOS { + throw Console.Error("Automatic development signing currently supports iOS only. Sign this platform's .app separately.") + } let signingAuthToken: AuthToken? if sign { guard let token = try AuthToken.savedIfPresent() else { diff --git a/Sources/XToolSupport/SDKBuilder.swift b/Sources/XToolSupport/SDKBuilder.swift index 09af2dd7..cce57d3a 100644 --- a/Sources/XToolSupport/SDKBuilder.swift +++ b/Sources/XToolSupport/SDKBuilder.swift @@ -76,7 +76,42 @@ struct SDKBuilder { let mode: Mode // bump this when the sdk builder logic changes - static let sdkEpoch = 3 + static let sdkEpoch = 4 + + static let platforms = ["iPhoneOS", "MacOSX", "iPhoneSimulator", "AppleTVOS", "AppleTVSimulator"] + + static func sdkDefinition(sdks: [String: String]) -> SDKDefinition { + func triple(platform: String, sdk: String, catalyst: Bool) -> SDKDefinition.Triple { + let root = "Developer/Platforms/\(platform).platform/Developer/SDKs/\(sdk)" + let platformLibrary = "Developer/Platforms/\(platform).platform/Developer/usr/lib" + return SDKDefinition.Triple( + sdkRootPath: root, + includeSearchPaths: [platformLibrary] + (catalyst ? ["\(root)/System/iOSSupport/usr/include"] : []), + librarySearchPaths: [platformLibrary] + (catalyst ? ["\(root)/System/iOSSupport/usr/lib"] : []), + swiftResourcesPath: "Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift", + swiftStaticResourcesPath: "Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift_static", + toolsetPaths: ["toolset.json"] + ) + } + + let triplesByPlatform = [ + "iPhoneOS": ["arm64-apple-ios"], + "iPhoneSimulator": ["arm64-apple-ios-simulator", "x86_64-apple-ios-simulator"], + "MacOSX": [ + "arm64-apple-macosx", "x86_64-apple-macosx", + "arm64-apple-ios-macabi", "x86_64-apple-ios-macabi", + ], + "AppleTVOS": ["arm64-apple-tvos"], + "AppleTVSimulator": ["arm64-apple-tvos-simulator", "x86_64-apple-tvos-simulator"], + ] + var targetTriples: [String: SDKDefinition.Triple] = [:] + for (platform, sdk) in sdks { + for target in triplesByPlatform[platform] ?? [] { + targetTriples[target] = triple(platform: platform, sdk: sdk, catalyst: target.hasSuffix("-macabi")) + } + } + return SDKDefinition(schemaVersion: "4.0", targetTriples: targetTriples) + } // tag from https://github.com/xtool-org/darwin-tools-linux-llvm static let darwinToolsVersion = "1.1.0" @@ -95,7 +130,6 @@ struct SDKBuilder { """ } - // swiftlint:disable:next function_body_length func buildSDK() async throws { let sdkVersion = Self.currentSDKVersion @@ -126,27 +160,16 @@ struct SDKBuilder { return name } - func triple(platform: String, sdk: String) -> SDKDefinition.Triple { - SDKDefinition.Triple( - sdkRootPath: "Developer/Platforms/\(platform).platform/Developer/SDKs/\(sdk)", - includeSearchPaths: ["Developer/Platforms/\(platform).platform/Developer/usr/lib"], - librarySearchPaths: ["Developer/Platforms/\(platform).platform/Developer/usr/lib"], - swiftResourcesPath: "Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift", - swiftStaticResourcesPath: "Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift_static", - toolsetPaths: ["toolset.json"] - ) + var sdks: [String: String] = [:] + for platform in Self.platforms { + let sdkDirectory = dev.appendingPathComponent("Platforms/\(platform).platform/Developer/SDKs") + // Xcode installations may omit optional tvOS SDKs. Do not advertise unavailable triples. + if platform.hasPrefix("AppleTV") && !sdkDirectory.dirExists { continue } + let name = try sdk(platform: platform, prefix: platform) + sdks[platform] = name + print("- \(name)") } - let iPhoneOSSDK = try sdk(platform: "iPhoneOS", prefix: "iPhoneOS") - let iPhoneSimSDK = try sdk(platform: "iPhoneSimulator", prefix: "iPhoneSimulator") - let macOSSDK = try sdk(platform: "MacOSX", prefix: "MacOSX") - - print(""" - - \(iPhoneOSSDK) - - \(iPhoneSimSDK) - - \(macOSSDK) - """) - print("[Writing metadata]") try """ { @@ -213,16 +236,7 @@ struct SDKBuilder { encoding: .utf8 ) - let sdkDefinition = SDKDefinition( - schemaVersion: "4.0", - targetTriples: [ - "arm64-apple-ios": triple(platform: "iPhoneOS", sdk: iPhoneOSSDK), - "arm64-apple-ios-simulator": triple(platform: "iPhoneSimulator", sdk: iPhoneSimSDK), - "x86_64-apple-ios-simulator": triple(platform: "iPhoneSimulator", sdk: iPhoneSimSDK), - "arm64-apple-macosx": triple(platform: "MacOSX", sdk: macOSSDK), - "x86_64-apple-macosx": triple(platform: "MacOSX", sdk: macOSSDK), - ] - ) + let sdkDefinition = Self.sdkDefinition(sdks: sdks) let encoder = JSONEncoder() try encoder @@ -495,6 +509,10 @@ struct SDKBuilder { } try Task.checkCancellation() + try FileManager.default.copyItem( + at: appDir.appendingPathComponent("Contents/version.plist"), + to: output.appendingPathComponent("xcode-version.plist") + ) try FileManager.default.createDirectory(at: dev, withIntermediateDirectories: false) var toDoDirs: [String] = ["Contents/Developer"] @@ -552,9 +570,10 @@ struct SDKBuilder { print("[Finalizing SDKs]") - for platform in ["iPhoneOS", "MacOSX", "iPhoneSimulator"] { + for platform in Self.platforms { let lib = "../../../../../Library" let platformDir = dev.appending(path: "Platforms/\(platform).platform") + guard platformDir.appending(path: "Developer/SDKs").dirExists else { continue } try patchPlatformManifest(path: platformDir.appending(path: "Info.plist")) @@ -606,14 +625,14 @@ struct SDKBuilder { let hostDir = platformDir.appendingPathComponent("Developer/usr/lib/swift/host") let pluginsDir = hostDir.appendingPathComponent("plugins") - if platform == "iPhoneSimulator" { - // The iPhoneSimulator platform doesn't contain plugins; Xcode seems to be hardcoded - // to look at the ones from iPhoneOS.platform. We can symlink the iPhoneOS ones too. + if platform.hasSuffix("Simulator") { + let devicePlatform = platform == "AppleTVSimulator" ? "AppleTVOS" : "iPhoneOS" + // Simulator platforms use the corresponding device platform's macro plugins. try? FileManager.default.createDirectory(at: hostDir, withIntermediateDirectories: true) try? FileManager.default.removeItem(at: pluginsDir) try FileManager.default.createSymbolicLink( atPath: pluginsDir.path, - withDestinationPath: "../../../../../../iPhoneOS.platform/Developer/usr/lib/swift/host/plugins" + withDestinationPath: "../../../../../../\(devicePlatform).platform/Developer/usr/lib/swift/host/plugins" ) } else { try? FileManager.default.removeItem(at: pluginsDir) @@ -827,24 +846,27 @@ struct SDKEntry { return parts.dropFirst().reduce(SDKEntry(parts.first!, values)) { SDKEntry($1, [$0]) } } - static let wanted = E("Contents/Developer", [ - E("Toolchains/XcodeDefault.xctoolchain/usr/lib", [ - E("swift"), - E("swift_static"), - E("clang"), - ]), - E("Platforms", ["iPhoneOS", "MacOSX", "iPhoneSimulator"].map { - E("\($0).platform", [ - E("Info.plist"), - E("Developer", [ - E("SDKs"), - E("Library", [ - E("Frameworks"), - E("PrivateFrameworks"), - ]), - E("usr/lib"), + static let wanted = E("Contents", [ + E("version.plist"), + E("Developer", [ + E("Toolchains/XcodeDefault.xctoolchain/usr/lib", [ + E("swift"), + E("swift_static"), + E("clang"), + ]), + E("Platforms", SDKBuilder.platforms.map { + E("\($0).platform", [ + E("Info.plist"), + E("Developer", [ + E("SDKs"), + E("Library", [ + E("Frameworks"), + E("PrivateFrameworks"), + ]), + E("usr/lib"), + ]) ]) - ]) - }), + }), + ]), ]) } diff --git a/Sources/XToolSupport/SDKCommand.swift b/Sources/XToolSupport/SDKCommand.swift index 39aea7a6..ff927f14 100644 --- a/Sources/XToolSupport/SDKCommand.swift +++ b/Sources/XToolSupport/SDKCommand.swift @@ -132,13 +132,43 @@ struct DevSDKStatusCommand: AsyncParsableCommand { abstract: "Get the status of the Darwin Swift SDK" ) + @Option(help: "Require an installed SDK for this exact target triple") + var triple: String? + + @Flag(help: "Print recorded build metadata as JSON (requires --triple)") + var buildMetadata = false + func run() async throws { + if buildMetadata && triple == nil { + throw Console.Error("--build-metadata requires --triple") + } if let sdk = try DarwinSDK.current() { + if triple != nil && !sdk.isUpToDate() { + throw Console.Error("The installed Darwin SDK is out of date. Update it before building this target.") + } + let root = try triple.map { try sdk.sdkRoot(for: $0) } + #if !os(macOS) + if let triple, let platform = ApplePlatform(triple: triple) { + try await LinkerConfiguration.validate(platform: platform, sdk: sdk) + } + #endif + if buildMetadata, let triple { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(sdk.buildMetadata(for: triple)) + print(String(decoding: data, as: UTF8.self)) + return + } print("Darwin SDK is installed") print(" Path: \(sdk.bundle.path)") print(" Flavor: \(sdk.flavor)") print(" Version: \(sdk.version)") + if let triple, let root { + print(" Target: \(triple)") + print(" SDK root: \(root.path)") + } } else { + if triple != nil { throw Console.Error("No Darwin SDK configured. Please run `xtool setup`.") } print("Not installed") } } diff --git a/Tests/XToolTests/ApplePlatformTests.swift b/Tests/XToolTests/ApplePlatformTests.swift new file mode 100644 index 00000000..ccaf1465 --- /dev/null +++ b/Tests/XToolTests/ApplePlatformTests.swift @@ -0,0 +1,61 @@ +import Foundation +import Subprocess +import Testing +@testable import PackLib + +@Test func effectiveDeploymentMinimumMatchesSwiftPMAndSDK() { + #expect(ApplePlatform.macCatalyst.effectiveDeploymentTarget( + platforms: ["ios": "17.0"], swiftVersion: .init(6, 4), sdkMinimum: "13.1" + ) == "17.0") + #expect(ApplePlatform.iOS.effectiveDeploymentTarget( + platforms: ["ios": "13.0"], swiftVersion: .init(6, 4), sdkMinimum: "12.0" + ) == "15.0") + #expect(ApplePlatform.iOS.effectiveDeploymentTarget( + platforms: ["ios": "17.0"], swiftVersion: .init(6, 4), sdkMinimum: "18.0" + ) == "18.0") + #expect(ApplePlatform.iOS.effectiveDeploymentTarget( + platforms: ["ios": "17.0"], swiftVersion: .init(6, 4), sdkMinimum: "15.0", tripleMinimum: "18.0" + ) == "18.0") +} + +@Test func catalystCanImportCompatibilityFrameworks() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("CatalystTests-\(UUID())", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let sdk = root.appendingPathComponent("MacOSX.sdk", isDirectory: true) + let support = sdk.appendingPathComponent("System/iOSSupport", isDirectory: true) + let framework = support.appendingPathComponent("System/Library/Frameworks/UIKit.framework", isDirectory: true) + for path in ["Headers", "Modules"] { + try FileManager.default.createDirectory(at: framework.appendingPathComponent(path), withIntermediateDirectories: true) + } + let include = support.appendingPathComponent("usr/include", isDirectory: true) + try FileManager.default.createDirectory(at: include, withIntermediateDirectories: true) + try Data("void UIKitFixture(void);\n".utf8).write(to: include.appendingPathComponent("UIKitFixture.h")) + try Data("#include \n".utf8).write(to: framework.appendingPathComponent("Headers/UIKit.h")) + try Data("framework module UIKit { umbrella header \"UIKit.h\" export * }\n".utf8) + .write(to: framework.appendingPathComponent("Modules/module.modulemap")) + let source = root.appendingPathComponent("Example.swift") + try Data("import UIKit\n".utf8).write(to: source) + + let baseArguments = [ + "-typecheck", "-parse-stdlib", "-nostdimport", "-target", "arm64-apple-ios17.0-macabi", + "-sdk", sdk.path, source.path, + ] + let compiler = try await BuildSettings.swiftcURL() + let withoutSupport = try await Subprocess.run( + .path(compiler), arguments: .init(baseArguments), output: .discarded, error: .discarded + ) + #expect(!withoutSupport.terminationStatus.isSuccess) + + let options = ApplePlatform.macCatalyst.additionalSDKOptions(sdkRoot: sdk) + let compilerOptions = stride(from: 0, to: options.count, by: 2).flatMap { index -> [String] in + switch options[index] { + case "-Xswiftc": return [options[index + 1]] + case "-Xcc": return ["-Xcc", options[index + 1]] + default: return [] + } + } + let withSupport = try await Subprocess.run( + .path(compiler), arguments: .init(baseArguments + compilerOptions), output: .discarded, error: .string(limit: .max) + ) + #expect(withSupport.terminationStatus.isSuccess, "\(withSupport.standardError)") +} diff --git a/Tests/XToolTests/DarwinSDKTests.swift b/Tests/XToolTests/DarwinSDKTests.swift index ed5ecb57..140f349e 100644 --- a/Tests/XToolTests/DarwinSDKTests.swift +++ b/Tests/XToolTests/DarwinSDKTests.swift @@ -53,3 +53,20 @@ import Testing #expect(!FileManager.default.fileExists(atPath: temporaryURL.path)) #expect(FileManager.default.fileExists(atPath: installedBundle.path)) } + +@Test func sdkRootRequiresInstalledTargetAndDirectory() throws { + let bundle = FileManager.default.temporaryDirectory + .appendingPathComponent("DarwinSDKTests-\(UUID())", isDirectory: true) + defer { try? FileManager.default.removeItem(at: bundle) } + try FileManager.default.createDirectory(at: bundle, withIntermediateDirectories: true) + try Data("test".utf8).write(to: bundle.appendingPathComponent("darwin-sdk-version.txt")) + try Data(""" + {"targetTriples":{"arm64-apple-tvos":{"sdkRootPath":"SDKs/AppleTVOS.sdk"}}} + """.utf8).write(to: bundle.appendingPathComponent("swift-sdk.json")) + let sdk = try #require(DarwinSDK(bundle: bundle)) + #expect(throws: StringError.self) { try sdk.sdkRoot(for: "arm64-apple-ios") } + #expect(throws: StringError.self) { try sdk.sdkRoot(for: "arm64-apple-tvos") } + let root = bundle.appendingPathComponent("SDKs/AppleTVOS.sdk", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + #expect(try sdk.sdkRoot(for: "arm64-apple-tvos") == root) +} diff --git a/Tests/XToolTests/LinkerConfigurationTests.swift b/Tests/XToolTests/LinkerConfigurationTests.swift new file mode 100644 index 00000000..b94b81b9 --- /dev/null +++ b/Tests/XToolTests/LinkerConfigurationTests.swift @@ -0,0 +1,37 @@ +import Testing +import Foundation +import Subprocess +@testable import PackLib + +@Test func parsesLLDMajorVersion() { + #expect(LinkerConfiguration.majorVersion("LLD 23.1.3\n") == 23) + #expect(LinkerConfiguration.majorVersion("LLD 21.1.6 (compatible with GNU linkers)\n") == 21) + #expect(LinkerConfiguration.majorVersion("GNU ld 2.42") == nil) + #expect(LinkerConfiguration.majorVersion("LLD .") == nil) + #expect(LinkerConfiguration.majorVersion("") == nil) +} + +@Test func overrideToolsetPreservesRelocatableLinkerHandling() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("LinkerTests-\(UUID()) ' space") + defer { try? FileManager.default.removeItem(at: root) } + let original = root.appendingPathComponent("sdk/toolset/bin/ld64.lld") + let replacement = root.appendingPathComponent("replacement/ld64.lld") + for (url, text) in [(original, "original"), (replacement, "replacement")] { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("#!/bin/sh\nprintf '%s' '\(text)'\n".utf8).write(to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + } + try Data("test".utf8).write(to: root.appendingPathComponent("sdk/darwin-sdk-version.txt")) + let sdk = try #require(DarwinSDK(bundle: root.appendingPathComponent("sdk"))) + let toolset = try LinkerConfiguration.writeToolset( + sdk: sdk, linker: replacement.path, directory: root.appendingPathComponent("cache") + ) + let definition = try JSONSerialization.jsonObject(with: Data(contentsOf: toolset)) as? [String: Any] + let selected = try #require((definition?["linker"] as? [String: String])?["path"]) + let finalLink = try await Subprocess.run(.path(.init(selected)), arguments: ["--version"], output: .string(limit: .max)) + let partialLink = try await Subprocess.run(.path(.init(selected)), arguments: ["-r"], output: .string(limit: .max)) + #expect(finalLink.terminationStatus.isSuccess) + #expect(finalLink.standardOutput == "replacement") + #expect(partialLink.terminationStatus.isSuccess) + #expect(partialLink.standardOutput == "original") +} diff --git a/Tests/XToolTests/PackerTests.swift b/Tests/XToolTests/PackerTests.swift index 5aabda83..a5c7821c 100644 --- a/Tests/XToolTests/PackerTests.swift +++ b/Tests/XToolTests/PackerTests.swift @@ -1,4 +1,5 @@ import Testing +import Foundation @testable import PackLib @Test func cfBundleSupportedPlatformsFollowsBuildTriple() { @@ -10,6 +11,66 @@ import Testing #expect(Packer.cfBundleSupportedPlatforms(for: "x86_64-apple-ios-simulator") == ["iPhoneSimulator"]) #expect(Packer.cfBundleSupportedPlatforms(for: "arm64-apple-macosx") == ["MacOSX"]) #expect(Packer.cfBundleSupportedPlatforms(for: "x86_64-apple-macosx") == ["MacOSX"]) + #expect(Packer.cfBundleSupportedPlatforms(for: "arm64-apple-ios17.0-macabi") == ["MacOSX"]) + #expect(Packer.cfBundleSupportedPlatforms(for: "x86_64-apple-ios-macabi") == ["MacOSX"]) + #expect(Packer.cfBundleSupportedPlatforms(for: "arm64-apple-tvos") == ["AppleTVOS"]) + #expect(Packer.cfBundleSupportedPlatforms(for: "arm64-apple-tvos17.0-simulator") == ["AppleTVSimulator"]) #expect(Packer.cfBundleSupportedPlatforms(for: "unknown-triple") == ["iPhoneOS"]) } + +@Test(arguments: ["arm64-apple-tvos", "arm64-apple-ios-macabi"]) +func packsPlatformBundleAndExtension(triple: String) async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("PackerTests-\(UUID())") + defer { try? FileManager.default.removeItem(at: root) } + let binDir = root.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + let settings = try await BuildSettings(configuration: .release, triple: triple, buildSystem: .swiftPM) + let platform = settings.platform + let app = Plan.Product( + type: .application, product: "Example", deploymentTarget: "17.0", bundleID: "com.example.app", + infoPlist: ["CFBundleIdentifier": "com.example.app", "DTSDKBuild": "stale-build"], + resources: [.bundle(package: "Example", target: "Content"), .library(name: "Shared")], + iconPath: nil, entitlementsPath: nil + ) + var ext = app + ext.type = .appExtension + ext.product = "Extension" + ext.resources = [] + let executable = Data([0, 1, 2, 3]) + for product in [app, ext] { + try executable.write(to: binDir.appendingPathComponent(product.targetName)) + } + let resources = binDir.appendingPathComponent("Example_Content.bundle", isDirectory: true) + try FileManager.default.createDirectory(at: resources, withIntermediateDirectories: false) + try Data("resource".utf8).write(to: resources.appendingPathComponent("content.txt")) + try executable.write(to: binDir.appendingPathComponent("libShared.dylib")) + + let output = root.appendingPathComponent("Example.app", isDirectory: true) + try await Packer(buildSettings: settings, plan: Plan(app: app, extensions: [ext])) + .packProducts(binDir: binDir, outputURL: output, buildMetadata: ["DTSDKBuild": "recorded-sdk-build"]) + + let prefix = platform.usesContentsDirectory ? "Contents/" : "" + let executablePrefix = platform.usesContentsDirectory ? "Contents/MacOS/" : "" + let resourcePrefix = platform.usesContentsDirectory ? "Contents/Resources/" : "" + #expect(try Data(contentsOf: output.appendingPathComponent("\(executablePrefix)Example")) == executable) + #expect(try Data(contentsOf: output.appendingPathComponent("\(resourcePrefix)Example_Content.bundle/content.txt")) + == Data("resource".utf8)) + #expect(try Data(contentsOf: output.appendingPathComponent("\(prefix)Frameworks/libShared.dylib")) == executable) + #expect(try Data(contentsOf: output.appendingPathComponent("\(prefix)PlugIns/Extension.appex/\(executablePrefix)Extension")) + == executable) + + let plist = try PropertyListSerialization.propertyList( + from: Data(contentsOf: output.appendingPathComponent("\(prefix)Info.plist")), format: nil + ) as? [String: Any] + #expect(plist?["CFBundleSupportedPlatforms"] as? [String] == [platform.supportedPlatform(triple: triple)]) + #expect(plist?["DTSDKBuild"] as? String == "recorded-sdk-build") + let extensionInfo = try PropertyListSerialization.propertyList( + from: Data(contentsOf: output.appendingPathComponent("\(prefix)PlugIns/Extension.appex/\(prefix)Info.plist")), format: nil + ) as? [String: Any] + #expect(extensionInfo?["DTSDKBuild"] as? String == "recorded-sdk-build") + if platform.usesContentsDirectory { + #expect(plist?["LSRequiresIPhoneOS"] == nil) + #expect(plist?["UIRequiredDeviceCapabilities"] == nil) + } +} diff --git a/Tests/XToolTests/PlannerTests.swift b/Tests/XToolTests/PlannerTests.swift new file mode 100644 index 00000000..4f4b1075 --- /dev/null +++ b/Tests/XToolTests/PlannerTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing +@testable import PackLib + +@Test(arguments: ["arm64-apple-ios", "arm64-apple-tvos", "arm64-apple-ios-macabi"], [nil, "13.0", "19.0"] as [String?]) +func plannerUsesTargetDeploymentAndDeviceFamily(triple: String, customMinimum: String?) async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("PlannerTests-\(UUID())", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let sources = root.appendingPathComponent("Sources/Example", isDirectory: true) + try FileManager.default.createDirectory(at: sources, withIntermediateDirectories: true) + try Data("public enum Example {}\n".utf8).write(to: sources.appendingPathComponent("Example.swift")) + try Data(""" + // swift-tools-version: 6.0 + import PackageDescription + let package = Package( + name: "Example", + platforms: [.iOS("17.0"), .tvOS("18.0"), .macCatalyst("17.0"), .macOS("14.0")], + products: [.library(name: "Example", targets: ["Example"])], + targets: [.target(name: "Example")] + ) + """.utf8).write(to: root.appendingPathComponent("Package.swift")) + var settings = try await BuildSettings( + configuration: .release, triple: triple, buildSystem: .swiftPM, packagePath: root.path + ) + // Describing a package does not compile it or require a cross-compilation SDK. + settings.sdkOptions = [] + var schema = PackSchema.default + if let customMinimum { + let infoPath = root.appendingPathComponent("Info.plist") + try PropertyListSerialization.data(fromPropertyList: ["MinimumOSVersion": customMinimum], format: .xml, options: 0) + .write(to: infoPath) + var base = schema.base + base.infoPath = infoPath.path + schema = try PackSchema(validating: base) + } + let plan = try await Planner(buildSettings: settings, schema: schema).createPlan() + switch settings.platform { + case .iOS: + #expect(plan.app.deploymentTarget == "17.0") + #expect(plan.app.infoPlist["MinimumOSVersion"] as? String == (customMinimum == "19.0" ? "19.0" : "17.0")) + #expect(plan.app.infoPlist["UIDeviceFamily"] as? [Int] == [1, 2]) + case .tvOS: + #expect(plan.app.deploymentTarget == "18.0") + #expect(plan.app.infoPlist["MinimumOSVersion"] as? String == (customMinimum == "19.0" ? "19.0" : "18.0")) + #expect(plan.app.infoPlist["UIDeviceFamily"] as? [Int] == [3]) + #expect(plan.app.infoPlist["UISupportedInterfaceOrientations"] == nil) + case .macCatalyst: + #expect(plan.app.deploymentTarget == "17.0") + #expect(plan.app.infoPlist["LSMinimumSystemVersion"] as? String == "14.0") + #expect(plan.app.infoPlist["MinimumOSVersion"] as? String == customMinimum) + #expect(plan.app.infoPlist["UIDeviceFamily"] as? [Int] == [6]) + case .macOS: + Issue.record("Unexpected test platform") + } +} diff --git a/Tests/XToolTests/SDKBuildMetadataTests.swift b/Tests/XToolTests/SDKBuildMetadataTests.swift new file mode 100644 index 00000000..7208bc2f --- /dev/null +++ b/Tests/XToolTests/SDKBuildMetadataTests.swift @@ -0,0 +1,79 @@ +import Foundation +import Testing +@testable import PackLib + +@Test(arguments: ["json", "plist"]) +func buildMetadataUsesRecordedSDKAndXcodeVersions(format: String) throws { + let bundle = FileManager.default.temporaryDirectory.appendingPathComponent("MetadataTests-\(UUID())", isDirectory: true) + defer { try? FileManager.default.removeItem(at: bundle) } + let platformPath = "Developer/Platforms/iPhoneOS.platform" + let sdkPath = "\(platformPath)/Developer/SDKs/iPhoneOS27.1.sdk" + let sdk = bundle.appendingPathComponent(sdkPath, isDirectory: true) + try FileManager.default.createDirectory(at: sdk, withIntermediateDirectories: true) + try Data("test".utf8).write(to: bundle.appendingPathComponent("darwin-sdk-version.txt")) + try JSONSerialization.data(withJSONObject: [ + "targetTriples": ["arm64-apple-ios": ["sdkRootPath": sdkPath]], + ]).write(to: bundle.appendingPathComponent("swift-sdk.json")) + let settings: [String: Any] = [ + "CanonicalName": "iphoneos27.1", "Version": "27.1", + "DefaultProperties": ["DEFAULT_COMPILER": "Compiler-Actual-ID"], + "SupportedTargets": ["iphoneos": ["MinimumDeploymentTarget": "15.0"]], + ] + let settingsData = try format == "json" + ? JSONSerialization.data(withJSONObject: settings) + : PropertyListSerialization.data(fromPropertyList: settings, format: .binary, options: 0) + try settingsData.write(to: sdk.appendingPathComponent("SDKSettings.\(format)")) + let system = sdk.appendingPathComponent("System/Library/CoreServices", isDirectory: true) + try FileManager.default.createDirectory(at: system, withIntermediateDirectories: true) + try PropertyListSerialization.data(fromPropertyList: ["ProductBuildVersion": "SDK-Actual-Build"], format: .binary, options: 0) + .write(to: system.appendingPathComponent("SystemVersion.plist")) + let info = ["AdditionalInfo": [ + "DTPlatformName": "iphoneos", "DTPlatformBuild": "$(PLATFORM_PRODUCT_BUILD_VERSION)", + "DTCompiler": "$(GCC_VERSION)", "DTSDKBuild": "$(SDK_PRODUCT_BUILD_VERSION)", + ]] + try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) + .write(to: bundle.appendingPathComponent("\(platformPath)/Info.plist")) + try PropertyListSerialization.data(fromPropertyList: [ + "CFBundleShortVersionString": "27.1.2", "ProductBuildVersion": "Xcode-Actual-Build", + ], format: .xml, options: 0).write(to: bundle.appendingPathComponent("xcode-version.plist")) + + let darwinSDK = try #require(DarwinSDK(bundle: bundle)) + let metadata = try darwinSDK.buildMetadata(for: "arm64-apple-ios") + #expect(try darwinSDK.sdkVersion(for: "arm64-apple-ios") == "27.1") + #expect(try darwinSDK.minimumDeploymentTarget(for: "arm64-apple-ios") == "15.0") + #expect(metadata == [ + "DTSDKName": "iphoneos27.1", "DTSDKBuild": "SDK-Actual-Build", + "DTPlatformName": "iphoneos", "DTPlatformVersion": "27.1", "DTPlatformBuild": "SDK-Actual-Build", + "DTXcode": "2712", "DTXcodeBuild": "Xcode-Actual-Build", "DTCompiler": "Compiler-Actual-ID", + ]) + try FileManager.default.removeItem(at: bundle.appendingPathComponent("xcode-version.plist")) + #expect(throws: (any Error).self) { try darwinSDK.buildMetadata(for: "arm64-apple-ios") } +} + +@Test func sdkVersionUsesCatalystVersionMap() throws { + let bundle = FileManager.default.temporaryDirectory.appendingPathComponent("SDKVersionTests-\(UUID())", isDirectory: true) + defer { try? FileManager.default.removeItem(at: bundle) } + let root = bundle.appendingPathComponent("SDK", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try Data("test".utf8).write(to: bundle.appendingPathComponent("darwin-sdk-version.txt")) + try JSONSerialization.data(withJSONObject: [ + "targetTriples": ["arm64-apple-ios-macabi": ["sdkRootPath": "SDK"]], + ]).write(to: bundle.appendingPathComponent("swift-sdk.json")) + try JSONSerialization.data(withJSONObject: [ + "Version": "15.0", "VersionMap": ["macOS_iOSMac": ["15.0": "18.0"], "iOSMac_macOS": ["18.0": "15.0"]], + "SupportedTargets": ["iosmac": ["MinimumDeploymentTarget": "15.0"]], + ]).write(to: root.appendingPathComponent("SDKSettings.json")) + let sdk = try #require(DarwinSDK(bundle: bundle)) + #expect(try sdk.sdkVersion(for: "arm64-apple-ios-macabi") == "18.0") + #expect(try sdk.macOSMinimum(forCatalyst: "18.0", triple: "arm64-apple-ios-macabi") == "15.0") + // Native macOS reads the selected Xcode SDK without installing a Darwin artifact bundle. + try FileManager.default.removeItem(at: bundle.appendingPathComponent("swift-sdk.json")) + #expect(try DarwinSDK.macOSMinimum(forCatalyst: "18.0", sdkRoot: root) == "15.0") + #expect(try DarwinSDK.minimumDeploymentTarget(for: "arm64-apple-ios-macabi", sdkRoot: root) == "15.0") +} + +@Test func xcodeBuildVersionConvertsRecordedVersion() throws { + #expect(try DarwinSDK.xcodeBuildVersion("16.4") == "1640") + #expect(try DarwinSDK.xcodeBuildVersion("27.1.2") == "2712") + #expect(throws: StringError.self) { try DarwinSDK.xcodeBuildVersion("unknown") } +} diff --git a/Tests/XToolTests/SDKBuilderTests.swift b/Tests/XToolTests/SDKBuilderTests.swift new file mode 100644 index 00000000..f584dff0 --- /dev/null +++ b/Tests/XToolTests/SDKBuilderTests.swift @@ -0,0 +1,40 @@ +import Testing +@testable import XToolSupport + +@Test func sdkDefinitionUsesPlatformSDKs() throws { + let definition = SDKBuilder.sdkDefinition(sdks: [ + "iPhoneOS": "iPhoneOS26.0.sdk", + "MacOSX": "MacOSX26.0.sdk", + "AppleTVOS": "AppleTVOS26.0.sdk", + "AppleTVSimulator": "AppleTVSimulator26.0.sdk", + ]) + let catalyst = try #require(definition.targetTriples["arm64-apple-ios-macabi"]) + #expect(catalyst.sdkRootPath == "Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX26.0.sdk") + #expect(catalyst.includeSearchPaths.contains("\(catalyst.sdkRootPath)/System/iOSSupport/usr/include")) + #expect(catalyst.librarySearchPaths.contains("\(catalyst.sdkRootPath)/System/iOSSupport/usr/lib")) + let tvOS = try #require(definition.targetTriples["arm64-apple-tvos"]) + #expect(tvOS.sdkRootPath == "Developer/Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS26.0.sdk") + let tvSimulator = try #require(definition.targetTriples["x86_64-apple-tvos-simulator"]) + #expect(tvSimulator.sdkRootPath + == "Developer/Platforms/AppleTVSimulator.platform/Developer/SDKs/AppleTVSimulator26.0.sdk") + #expect(tvOS.librarySearchPaths == ["Developer/Platforms/AppleTVOS.platform/Developer/usr/lib"]) + #expect(definition.targetTriples["arm64-apple-ios-simulator"] == nil) +} + +@Test func sdkDefinitionOmitsUnavailableTVSDKs() { + let definition = SDKBuilder.sdkDefinition(sdks: ["iPhoneOS": "iPhoneOS26.0.sdk"]) + #expect(definition.targetTriples["arm64-apple-ios"] != nil) + #expect(definition.targetTriples["arm64-apple-tvos"] == nil) + #expect(definition.targetTriples["arm64-apple-tvos-simulator"] == nil) + #expect(definition.targetTriples["arm64-apple-ios-macabi"] == nil) +} + +@Test func sdkExtractionRetainsTVSDKs() { + let device = "Contents/Developer/Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS26.0.sdk/usr/lib/libc.tbd" + let simulator = "Contents/Developer/Platforms/AppleTVSimulator.platform/Developer/usr/lib/swift/libswiftTesting.dylib" + let watch = "Contents/Developer/Platforms/WatchOS.platform/Developer/SDKs/WatchOS26.0.sdk/usr/lib/libc.tbd" + #expect(SDKEntry.wanted.matches(device.split(separator: "/")[...])) + #expect(SDKEntry.wanted.matches(simulator.split(separator: "/")[...])) + #expect(!SDKEntry.wanted.matches(watch.split(separator: "/")[...])) + #expect(SDKEntry.wanted.matches("Contents/version.plist".split(separator: "/")[...])) +}