From 5729c07b932d11db6461212c5038e8bc2fae7c5e Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Wed, 14 Oct 2026 03:29:43 +0200 Subject: [PATCH 01/59] build: link swift-dependencies into PackLib PackLib is about to expose its product builder as a swift-dependencies client so the dev commands and tests can substitute it. The package is already part of the graph through XToolSupport; this only adds the Dependencies product to the PackLib target. Co-Authored-By: Claude Opus 5.5 (1M context) --- Package.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Package.swift b/Package.swift index 199a142c..3e281563 100644 --- a/Package.swift +++ b/Package.swift @@ -196,6 +196,7 @@ let package = Package( name: "PackLib", dependencies: [ "XUtils", + .product(name: "Dependencies", package: "swift-dependencies"), "XcodeProjects", .product(name: "Superutils", package: "xtool-core"), .product(name: "Yams", package: "Yams"), From a1032fb589337d84bcd076a8e255314dd7d9bde6 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 13 Oct 2026 03:10:19 +0200 Subject: [PATCH 02/59] packer: define macOS build contracts Introduce the types the macOS route builds on, without enabling it: - TargetTriple and ResolvedTarget parse the selected triple and resolve the deployment version from the triple, the schema or the package's declared platforms, rejecting versions below the platform minimum. - BundleLayout describes where executables, frameworks, resources and nested products live in iOS and macOS bundles. - xtool.yml version 2 adds target, deploymentTarget, product sources, resourceCopies and nestedProducts, and accepts ICNS icons. Version 1 configurations reject the new keys. - DeferredSigningManifest records what a downstream signer needs. - iOS compilation moves out of Packer into ProductBuilder, exposed as the ProductBuilderClient dependency. macOS targets, version 2 product sources and resource destinations still fail with "not implemented yet" errors. --- Sources/PackLib/BuildPlan.swift | 85 ++++++++++ Sources/PackLib/BundleLayout.swift | 133 ++++++++++++++++ Sources/PackLib/PackSchema.swift | 92 ++++++++++- Sources/PackLib/Packer.swift | 153 +++--------------- Sources/PackLib/Planner.swift | 101 ++++-------- Sources/PackLib/ProductBuilder.swift | 173 +++++++++++++++++++++ Sources/PackLib/ProductSource.swift | 56 +++++++ Sources/PackLib/TargetPlatform.swift | 123 +++++++++++++++ Sources/PackLib/XcodePacker.swift | 21 +++ Tests/XToolTests/PackContractTests.swift | 189 +++++++++++++++++++++++ Tests/XToolTests/PackerTests.swift | 15 -- 11 files changed, 920 insertions(+), 221 deletions(-) create mode 100644 Sources/PackLib/BuildPlan.swift create mode 100644 Sources/PackLib/BundleLayout.swift create mode 100644 Sources/PackLib/ProductBuilder.swift create mode 100644 Sources/PackLib/ProductSource.swift create mode 100644 Sources/PackLib/TargetPlatform.swift create mode 100644 Tests/XToolTests/PackContractTests.swift delete mode 100644 Tests/XToolTests/PackerTests.swift diff --git a/Sources/PackLib/BuildPlan.swift b/Sources/PackLib/BuildPlan.swift new file mode 100644 index 00000000..08568c8b --- /dev/null +++ b/Sources/PackLib/BuildPlan.swift @@ -0,0 +1,85 @@ +import Foundation +import XUtils + +public struct Plan: Sendable { + public let target: ResolvedTarget + public var app: Product + public var extensions: [Product] + public var nestedProducts: [Product] + + public init(target: ResolvedTarget, app: Product, extensions: [Product] = [], nestedProducts: [Product] = []) throws { + let products = [app] + extensions + nestedProducts + guard products.allSatisfy({ $0.target == target }), app.type == .application, + extensions.allSatisfy({ $0.type == .appExtension }), + nestedProducts.allSatisfy({ $0.type != .application }), + Set(products.map(\.product)).count == products.count else { + throw StringError("Build plan has inconsistent targets, product kinds, or duplicate names") + } + self.target = target + self.app = app + self.extensions = extensions + self.nestedProducts = nestedProducts + } + + public var allProducts: [Product] { + [app] + extensions + nestedProducts + } + + public enum Resource: Codable, Sendable, Hashable { + case bundle(package: String, target: String) + case binaryTarget(name: String) + case library(name: String) + case root(source: String) + case copy(ResourceCopy) + } + + public struct Product: Sendable { + public let target: ResolvedTarget + public var platform: TargetPlatform { target.platform } + public var source: ProductSource? + public var type: ProductType + public var product: String + public var deploymentTarget: String { target.deploymentTarget } + public var bundleID: String + public var infoPlist: [String: any Sendable] + public var resources: [Resource] + public var iconPath: String? + public var entitlementsPath: String? + + public var resolvedSource: ProductSource { source ?? .library(product: product) } + + public var layout: BundleLayout { .init(platform: platform, kind: type, name: product) } + + public var targetName: String { + "\(self.product)-\(self.type.targetSuffix)" + } + + public func directory(inApp baseDir: URL) -> URL { + baseDir.appendingPathComponent(layout.bundlePath, isDirectory: type != .helperExecutable) + } + } + + public enum ProductType: String, Codable, Sendable, Equatable { + case application + case appExtension + case xpcService + case helperExecutable + + var targetSuffix: String { + switch self { + case .application: "App" + case .appExtension: "Extension" + case .xpcService: "XPCService" + case .helperExecutable: "Helper" + } + } + + var fourCharCode: String { + switch self { + case .application: "APPL" + case .appExtension, .xpcService: "XPC!" + case .helperExecutable: "" + } + } + } +} diff --git a/Sources/PackLib/BundleLayout.swift b/Sources/PackLib/BundleLayout.swift new file mode 100644 index 00000000..c769da60 --- /dev/null +++ b/Sources/PackLib/BundleLayout.swift @@ -0,0 +1,133 @@ +import Foundation +import XUtils + +/// Paths are relative to the outer application bundle. The builder never writes here. +public struct BundleLayout: Sendable, Equatable { + public let platform: TargetPlatform + public let kind: Plan.ProductType + public let name: String + + public init(platform: TargetPlatform, kind: Plan.ProductType, name: String) { + self.platform = platform + self.kind = kind + self.name = name + } + + public var bundlePath: String { + switch kind { + case .application: "" + case .appExtension: "\(platform == .macOS ? "Contents/" : "")PlugIns/\(name).appex" + case .xpcService: "Contents/XPCServices/\(name).xpc" + case .helperExecutable: "Contents/Helpers/\(name)" + } + } + + public var contentsPath: String { + if kind == .helperExecutable { return "Contents/Helpers" } + return joined(bundlePath, platform == .macOS ? "Contents" : "") + } + + public var executablePath: String { + if kind == .helperExecutable { return bundlePath } + return joined(contentsPath, platform == .macOS ? "MacOS/\(name)" : name) + } + + public var infoPlistPath: String? { + kind == .helperExecutable ? nil : joined(contentsPath, "Info.plist") + } + + public var resourcesPath: String { + joined(contentsPath, platform == .macOS ? "Resources" : "") + } + + public var frameworksPath: String { joined(contentsPath, "Frameworks") } + + public func path(for destination: ResourceDestination) throws -> String { + try destination.validate() + let base = switch destination.location { + case .resources: resourcesPath + case .bundle: kind == .helperExecutable ? contentsPath : bundlePath + case .contents: contentsPath + } + return joined(base, destination.path) + } + + private func joined(_ base: String, _ path: String) -> String { + [base, path].filter { !$0.isEmpty }.joined(separator: "/") + } +} + +/// Explicit destinations are relative to the selected product, never the host filesystem. +public struct ResourceDestination: Codable, Sendable, Hashable { + public enum Location: String, Codable, Sendable { + case resources + case bundle + case contents + } + + public var location: Location + public var path: String + + public init(location: Location = .resources, path: String) { + self.location = location + self.path = path + } + + public func validate() throws { + try Self.validateRelativePath(path) + } + + static func validateRelativePath(_ path: String) throws { + let components = path.split(separator: "/", omittingEmptySubsequences: false) + guard !path.isEmpty, !path.contains("\\"), !path.contains("\0"), + components.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." }) else { + throw StringError("Bundle destination must be a safe relative path: '\(path)'") + } + } +} + +public struct ResourceCopy: Codable, Sendable, Hashable { + public var source: String + public var destination: ResourceDestination + + public init(source: String, destination: ResourceDestination) { + self.source = source + self.destination = destination + } +} + +/// Handoff to a downstream Mac signer. This records intent, not a claim that Mach-O +/// files lack linker-generated ad-hoc signatures. No provisioning or signing occurs here. +public struct DeferredSigningManifest: Codable, Sendable { + public enum Status: String, Codable, Sendable { case deferred } + + public var version: Int = 1 + public var status: Status = .deferred + public var target: ResolvedTarget + public var products: [Product] + + public struct Product: Codable, Sendable { + public var kind: Plan.ProductType + public var bundleID: String + /// Empty for the outer app; all other paths are relative to the app root. + public var bundlePath: String + public var executablePath: String + /// Caller-supplied input, external to the bundle. The handoff writer must + /// preserve this file and rebase its path relative to the manifest location. + public var entitlementsPath: String? + } + + public init(plan: Plan) { + target = plan.target + products = plan.allProducts.map { product in + let layout = product.layout + return Product( + kind: product.type, + bundleID: product.bundleID, + bundlePath: layout.bundlePath, + executablePath: layout.executablePath, + entitlementsPath: product.entitlementsPath + ) + } + } +} diff --git a/Sources/PackLib/PackSchema.swift b/Sources/PackLib/PackSchema.swift index 297c4241..ad76a4a4 100644 --- a/Sources/PackLib/PackSchema.swift +++ b/Sources/PackLib/PackSchema.swift @@ -5,6 +5,7 @@ import XUtils public struct PackSchemaBase: Codable, Sendable { public enum Version: Int, Codable, Sendable { case v1 = 1 + case v2 = 2 } public var version: Version @@ -14,6 +15,13 @@ public struct PackSchemaBase: Codable, Sendable { public var product: String? + /// Version 2 only. Omission retains the legacy arm64 iOS target. + public var target: String? + public var deploymentTarget: String? + public var source: ProductSource? + public var resourceCopies: [ResourceCopy]? + public var nestedProducts: [NestedProduct]? + public var infoPath: String? public var entitlementsPath: String? @@ -31,6 +39,18 @@ public struct PackSchemaBase: Codable, Sendable { public var infoPath: String public var resources: [String]? public var entitlementsPath: String? + public var source: ProductSource? + public var resourceCopies: [ResourceCopy]? + } + + public struct NestedProduct: Codable, Sendable { + public var product: String + public var kind: Plan.ProductType + public var source: ProductSource + public var bundleID: String? + public var infoPath: String? + public var entitlementsPath: String? + public var resourceCopies: [ResourceCopy]? } } @@ -56,9 +76,7 @@ public struct PackSchema: Sendable { public init(validating base: PackSchemaBase) throws { self.base = base - if base.version != .v1 { - throw StringError("xtool.yml: Unsupported schema version: \(base.version.rawValue)") - } + try Self.validateConfiguration(base) switch (base.bundleID, base.orgID) { case (let bundleID?, _): @@ -71,8 +89,72 @@ public struct PackSchema: Sendable { if let iconPath = base.iconPath { let ext = URL(fileURLWithPath: iconPath).pathExtension - guard ext == "png" else { - throw StringError("xtool.yml: iconPath should have a 'png' path extension. Got '\(ext)'.") + let allowed = base.version == .v1 ? ["png"] : ["png", "icns"] + guard allowed.contains(ext) else { + throw StringError("xtool.yml: iconPath must be a prepared \(allowed.joined(separator: " or ")) file. Got '\(ext)'.") + } + } + } + + private static func validateConfiguration(_ base: PackSchemaBase) throws { + if base.version == .v1 { + guard base.target == nil, base.deploymentTarget == nil, base.source == nil, + base.resourceCopies == nil, base.nestedProducts == nil, + !(base.extensions ?? []).contains(where: { $0.source != nil || $0.resourceCopies != nil }) else { + throw StringError("xtool.yml: target, deploymentTarget, source, resourceCopies and nestedProducts require version: 2") + } + } + if let target = base.target { _ = try TargetTriple(target) } + if let deployment = base.deploymentTarget { try TargetTriple.validateVersion(deployment) } + try base.source?.validate() + try validateProducts(base) + } + + private static func validateProducts(_ base: PackSchemaBase) throws { + for product in base.nestedProducts ?? [] { + try Self.validateName(product.product) + guard product.kind != .application else { + throw StringError("xtool.yml: nestedProducts cannot contain an application") + } + try product.source.validate() + if product.kind == .appExtension || product.kind == .xpcService { + guard product.infoPath != nil else { + throw StringError("xtool.yml: nested extensions and XPC services require infoPath") + } + } + } + let copies = (base.resourceCopies ?? []) + (base.extensions ?? []).flatMap { $0.resourceCopies ?? [] } + + (base.nestedProducts ?? []).flatMap { $0.resourceCopies ?? [] } + for copy in copies { + guard !copy.source.isEmpty else { throw StringError("xtool.yml: resource source cannot be empty") } + try copy.destination.validate() + } + if let product = base.product { try Self.validateName(product) } + for ext in base.extensions ?? [] { + try Self.validateName(ext.product) + try ext.source?.validate() + } + let names = (base.extensions ?? []).map(\.product) + (base.nestedProducts ?? []).map(\.product) + guard Set(names).count == names.count else { + throw StringError("xtool.yml: nested product names must be unique") + } + } + + private static func validateName(_ name: String) throws { + try ResourceDestination.validateRelativePath(name) + guard !name.contains("/") else { + throw StringError("xtool.yml: product name must be a single path component") + } + } + + /// Validate against the effective CLI/schema target, after precedence is applied. + public func validate(for platform: TargetPlatform) throws { + if platform != .macOS { + guard !(base.nestedProducts ?? []).contains(where: { $0.kind == .xpcService || $0.kind == .helperExecutable }) else { + throw StringError("xtool.yml: XPC services and helper executables require a macOS target") + } + if let iconPath = base.iconPath, URL(fileURLWithPath: iconPath).pathExtension == "icns" { + throw StringError("xtool.yml: icns icons require a macOS target") } } } diff --git a/Sources/PackLib/Packer.swift b/Sources/PackLib/Packer.swift index bd2057b7..fdbedef5 100644 --- a/Sources/PackLib/Packer.swift +++ b/Sources/PackLib/Packer.swift @@ -1,108 +1,43 @@ import Foundation import XUtils -import Subprocess +import Dependencies public struct Packer: Sendable { public let buildSettings: BuildSettings public let plan: Plan + @Dependency(\.productBuilder) private var productBuilder + public init(buildSettings: BuildSettings, plan: Plan) { self.plan = plan self.buildSettings = buildSettings } - 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 packageSwift = packageDir.appendingPathComponent("Package.swift") - let contents = """ - // swift-tools-version: 6.0 - import PackageDescription - let package = Package( - name: "\(plan.app.product)-Builder", - platforms: [ - .iOS("\(plan.app.deploymentTarget)"), - ], - dependencies: [ - .package(name: "RootPackage", path: "../.."), - ], - targets: [ - \( - plan.allProducts.map { - """ - .executableTarget( - name: "\($0.targetName)", - dependencies: [ - .product(name: "\($0.product)", package: "RootPackage"), - ], - linkerSettings: \($0.linkerSettings) - ) - """ - } - .joined(separator: ",\n") - ) - ] - )\n - """ - try Data(contents.utf8).write(to: packageSwift) - - for product in plan.allProducts { - let sources: URL = packageDir.appendingPathComponent("Sources/\(product.targetName)", isDirectory: true) - try FileManager.default.createDirectory(at: sources, withIntermediateDirectories: true) - try Data().write(to: sources.appendingPathComponent("stub.c", isDirectory: false)) + public func pack() async throws -> URL { + guard plan.target.platform != .macOS else { + throw StringError("macOS bundle assembly is not implemented yet") } - - let buildConfig = try await buildSettings - .withPackagePath(packageDir.path) - .swiftPMInvocation( - forTool: "build", - arguments: [ - "--scratch-path", ".build", - // resolving can cause SwiftPM to overwrite the root package deps - // with just the deps needed for the builder package (which is to - // say, any "dev dependencies" of the root package may be removed.) - // fortunately we've already resolved the root package by this point - // in order to dump the plan, so we can skip resolution here to skirt - // the issue. - "--disable-automatic-resolution", - ], - ) - try await Subprocess.run( - buildConfig, - output: .currentStandardOutput, - error: .currentStandardError, - ) - .checkSuccess() + let artifacts = try await productBuilder.build(buildSettings, plan) + return try await assemble(artifacts: artifacts) } - public func pack() async throws -> URL { - try await build() + /// Assembly consumes completed build artifacts and never compiles or signs. + public func assemble(artifacts: BuildArtifacts) async throws -> URL { + guard plan.target.platform != .macOS else { + throw StringError("macOS bundle assembly is not implemented yet") + } + let binDir = artifacts.directory let output = try TemporaryDirectory(name: "\(plan.app.product).app") let outputURL = output.url - let binPath: String - switch buildSettings.buildSystem { - 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 binDir = URL( - fileURLWithPath: ".build/\(binPath)", - isDirectory: true - ) - try await withThrowingTaskGroup(of: Void.self) { group in for product in plan.allProducts { try pack( product: product, binDir: binDir, + executable: artifacts.executables[product.product], outputURL: product.directory(inApp: outputURL), &group ) @@ -129,6 +64,7 @@ public struct Packer: Sendable { @Sendable private func pack( product: Plan.Product, binDir: URL, + executable: URL?, outputURL: URL, _ group: inout ThrowingTaskGroup ) throws { @@ -174,6 +110,8 @@ public struct Packer: Sendable { } case .library(let name): try await packFile(srcName: "lib\(name).dylib", dstName: "Frameworks/lib\(name).dylib", sign: true) + case .copy: + throw StringError("Explicit resource destinations are not implemented yet") case .root(let source): try await packFileToRoot(srcName: source) } @@ -185,7 +123,10 @@ public struct Packer: Sendable { } } group.addTask { - try await packFile(srcName: product.targetName, dstName: product.product) + guard let executable else { + throw StringError("No built executable for product '\(product.product)'") + } + try await packFile(srcName: executable.path, dstName: product.product) } group.addTask { var info = product.infoPlist @@ -193,7 +134,7 @@ public struct Packer: Sendable { if product.type == .application { info["UIRequiredDeviceCapabilities"] = ["arm64"] info["LSRequiresIPhoneOS"] = true - info["CFBundleSupportedPlatforms"] = Packer.cfBundleSupportedPlatforms(for: buildSettings.triple) + info["CFBundleSupportedPlatforms"] = [plan.target.platform.supportedPlatform] } if let iconPath = product.iconPath { @@ -210,52 +151,4 @@ public struct Packer: Sendable { try encodedPlist.write(to: infoPath) } } - - 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"] - } - } -} - -extension Plan.Product { - fileprivate var linkerSettings: String { - switch self.type { - case .application: """ - [ - .unsafeFlags([ - "-Xlinker", "-rpath", "-Xlinker", "@executable_path/Frameworks", - ]), - ] - """ - case .appExtension: """ - [ - // Link to Foundation framework which implements the _NSExtensionMain entrypoint - .linkedFramework("Foundation"), - .unsafeFlags([ - // 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", - // ...as well as our own - "-Xlinker", "-rpath", "-Xlinker", "@executable_path/Frameworks", - ]), - ] - """ - } - } } diff --git a/Sources/PackLib/Planner.swift b/Sources/PackLib/Planner.swift index df2f0125..cdbeff07 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() @@ -75,10 +71,33 @@ public struct Planner: Sendable { public func createPlan() async throws -> Plan { // TODO: cache plan using (Package.swift+Package.resolved) as the key? + // Validate the selected platform before invoking SwiftPM. The CLI supplies + // schema/override precedence through BuildSettings in the command integration. + let selectedTriple = try TargetTriple(buildSettings.triple) + try schema.validate(for: selectedTriple.platform) + guard selectedTriple.platform != .macOS else { + throw StringError("macOS product planning is not implemented yet") + } + guard schema.target == nil else { + throw StringError("Schema target selection is not integrated with the build command yet; use --triple") + } + guard schema.source == nil, schema.resourceCopies == nil, schema.nestedProducts == nil, + !(schema.extensions ?? []).contains(where: { $0.source != nil || $0.resourceCopies != nil }) else { + throw StringError("Version 2 product sources and resource destinations are not implemented yet") + } let graph = try await buildGraph() + let target = try ResolvedTarget.resolve( + cliTarget: buildSettings.triple, + schema: schema, + packageDeploymentTargets: Dictionary( + (graph.root.platforms ?? []).map { ($0.name, $0.version) }, + uniquingKeysWith: { first, _ in first } + ) + ) let app = try await product( from: graph, + target: target, matching: schema.product, type: .application, plist: schema.infoPath, @@ -95,6 +114,7 @@ public struct Planner: Sendable { group.addTask { try await product( from: graph, + target: target, matching: ext.product, type: .appExtension, plist: ext.infoPath, @@ -111,12 +131,13 @@ public struct Planner: Sendable { extensionProducts = [] } - return Plan(app: app, extensions: extensionProducts) + return try Plan(target: target, app: app, extensions: extensionProducts) } // swiftlint:disable cyclomatic_complexity function_parameter_count private func product( from graph: PackageGraph, + target: ResolvedTarget, matching name: String?, type: Plan.ProductType, plist: String?, @@ -164,8 +185,7 @@ public struct Planner: Sendable { } let bundleID = idSpecifier.formBundleID(product: library.name) - let deploymentTarget = graph.root.platforms?.first { $0.name == "ios" }?.version - ?? Self.minSupportedIOSVersion + let deploymentTarget = target.deploymentTarget var infoPlist: [String: Sendable] = [ "CFBundleInfoDictionaryVersion": "6.0", @@ -191,6 +211,8 @@ public struct Planner: Sendable { "UIInterfaceOrientationLandscapeRight", ] infoPlist["UILaunchScreen"] = [:] as [String: Sendable] + case .xpcService, .helperExecutable: + throw StringError("macOS nested product planning is not implemented yet") case .appExtension: // Should set default parameters? infoPlist["NSExtension"] = [:] as [String: Sendable] @@ -207,9 +229,9 @@ public struct Planner: Sendable { } return Plan.Product( + target: target, type: type, product: library.name, - deploymentTarget: deploymentTarget, bundleID: bundleID, infoPlist: infoPlist, resources: resources, @@ -295,69 +317,6 @@ public struct Planner: Sendable { } } -public struct Plan: Sendable { - public var app: Product - public var extensions: [Product] - - public var allProducts: [Product] { - [app] + extensions - } - - public enum Resource: Codable, Sendable, Hashable { - case bundle(package: String, target: String) - case binaryTarget(name: String) - case library(name: String) - case root(source: String) - } - - public struct Product: Sendable { - public var type: ProductType - public var product: String - public var deploymentTarget: String - public var bundleID: String - public var infoPlist: [String: any Sendable] - public var resources: [Resource] - public var iconPath: String? - public var entitlementsPath: String? - - public var targetName: String { - "\(self.product)-\(self.type.targetSuffix)" - } - - public func directory(inApp baseDir: URL) -> URL { - switch type { - case .application: - baseDir - .appendingPathComponent(".", isDirectory: true) - case .appExtension: - baseDir - .appendingPathComponent("PlugIns", isDirectory: true) - .appendingPathComponent(product, isDirectory: true) - .appendingPathExtension("appex") - } - } - } - - public enum ProductType: Sendable { - case application - case appExtension - - fileprivate var targetSuffix: String { - switch self { - case .application: "App" - case .appExtension: "Extension" - } - } - - fileprivate var fourCharCode: String { - switch self { - case .application: "APPL" - case .appExtension: "XPC!" - } - } - } -} - private struct PackageDependency: Decodable { let identity: String let name: String diff --git a/Sources/PackLib/ProductBuilder.swift b/Sources/PackLib/ProductBuilder.swift new file mode 100644 index 00000000..5adc8819 --- /dev/null +++ b/Sources/PackLib/ProductBuilder.swift @@ -0,0 +1,173 @@ +import Foundation +import Dependencies +import XUtils +import Subprocess + +/// Compilation owns the generated wrapper package and build tree only. +/// Bundle staging and publication belong exclusively to Packer/BundleAssembler. +public struct ProductBuilder: Sendable { + public let buildSettings: BuildSettings + public let plan: Plan + + public init(buildSettings: BuildSettings, plan: Plan) { + self.buildSettings = buildSettings + self.plan = plan + } + + public func build() async throws -> BuildArtifacts { + guard plan.target.triple == buildSettings.triple else { + throw StringError("Build settings target differs from the resolved plan") + } + guard plan.target.platform != .macOS, + plan.allProducts.allSatisfy({ + if case .library(let name) = $0.resolvedSource { return name == $0.product } + return false + }) else { + throw StringError("Only iOS library-wrapper compilation is implemented") + } + 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 packageSwift = packageDir.appendingPathComponent("Package.swift") + let contents = """ + // swift-tools-version: 6.0 + import PackageDescription + let package = Package( + name: "\(plan.app.product)-Builder", + platforms: [ + .iOS("\(plan.app.deploymentTarget)"), + ], + dependencies: [ + .package(name: "RootPackage", path: "../.."), + ], + targets: [ + \( + plan.allProducts.map { + """ + .executableTarget( + name: "\($0.targetName)", + dependencies: [ + .product(name: "\($0.product)", package: "RootPackage"), + ], + linkerSettings: \($0.linkerSettings) + ) + """ + } + .joined(separator: ",\n") + ) + ] + )\n + """ + try Data(contents.utf8).write(to: packageSwift) + + for product in plan.allProducts { + let sources: URL = packageDir.appendingPathComponent("Sources/\(product.targetName)", isDirectory: true) + try FileManager.default.createDirectory(at: sources, withIntermediateDirectories: true) + try Data().write(to: sources.appendingPathComponent("stub.c", isDirectory: false)) + } + + let buildConfig = try await buildSettings + .withPackagePath(packageDir.path) + .swiftPMInvocation( + forTool: "build", + arguments: [ + "--scratch-path", ".build", + // resolving can cause SwiftPM to overwrite the root package deps + // with just the deps needed for the builder package (which is to + // say, any "dev dependencies" of the root package may be removed.) + // fortunately we've already resolved the root package by this point + // in order to dump the plan, so we can skip resolution here to skirt + // the issue. + "--disable-automatic-resolution", + ], + ) + try await Subprocess.run( + buildConfig, + output: .currentStandardOutput, + error: .currentStandardError, + ) + .checkSuccess() + let binPath: String + switch buildSettings.buildSystem { + 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 binDir = URL( + fileURLWithPath: ".build/\(binPath)", + isDirectory: true + ) + + return BuildArtifacts( + directory: binDir, + executables: Dictionary(uniqueKeysWithValues: plan.allProducts.map { + ($0.product, binDir.appendingPathComponent($0.targetName)) + }) + ) + } +} + +extension Plan.Product { + fileprivate var linkerSettings: String { + switch self.type { + case .application: """ + [ + .unsafeFlags([ + "-Xlinker", "-rpath", "-Xlinker", "@executable_path/Frameworks", + ]), + ] + """ + case .xpcService, .helperExecutable: "[]" + case .appExtension: """ + [ + // Link to Foundation framework which implements the _NSExtensionMain entrypoint + .linkedFramework("Foundation"), + .unsafeFlags([ + // 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", + // ...as well as our own + "-Xlinker", "-rpath", "-Xlinker", "@executable_path/Frameworks", + ]), + ] + """ + } + } +} + +/// Build outputs are inputs to assembly; the assembler must not invoke SwiftPM. +public struct BuildArtifacts: Sendable { + public var directory: URL + public var executables: [String: URL] + + public init(directory: URL, executables: [String: URL]) { + self.directory = directory + self.executables = executables + } +} + +public struct ProductBuilderClient: Sendable { + public var build: @Sendable (BuildSettings, Plan) async throws -> BuildArtifacts + + public init(build: @escaping @Sendable (BuildSettings, Plan) async throws -> BuildArtifacts) { + self.build = build + } +} + +extension ProductBuilderClient: DependencyKey { + public static let liveValue = Self { settings, plan in + try await ProductBuilder(buildSettings: settings, plan: plan).build() + } +} + +extension DependencyValues { + public var productBuilder: ProductBuilderClient { + get { self[ProductBuilderClient.self] } + set { self[ProductBuilderClient.self] = newValue } + } +} diff --git a/Sources/PackLib/ProductSource.swift b/Sources/PackLib/ProductSource.swift new file mode 100644 index 00000000..921ff083 --- /dev/null +++ b/Sources/PackLib/ProductSource.swift @@ -0,0 +1,56 @@ +import Foundation +import XUtils + +/// Library products get an executable wrapper; executable products build directly; +/// prebuilt inputs are copied only after target compatibility validation. +public enum ProductSource: Sendable, Equatable, Codable { + case library(product: String) + case executable(product: String) + case prebuilt(path: String) + + private enum Kind: String, Codable { case library, executable, prebuilt } + private enum CodingKeys: String, CodingKey { case kind, product, path } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + let kind = try values.decode(Kind.self, forKey: .kind) + switch kind { + case .library, .executable: + guard !values.contains(.path) else { + throw StringError("SwiftPM product sources cannot specify a prebuilt path") + } + let name = try values.decode(String.self, forKey: .product) + self = kind == .library ? .library(product: name) : .executable(product: name) + case .prebuilt: + guard !values.contains(.product) else { + throw StringError("Prebuilt sources cannot specify a SwiftPM product") + } + self = .prebuilt(path: try values.decode(String.self, forKey: .path)) + } + try validate() + } + + public func encode(to encoder: Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .library(let product): + try values.encode(Kind.library, forKey: .kind) + try values.encode(product, forKey: .product) + case .executable(let product): + try values.encode(Kind.executable, forKey: .kind) + try values.encode(product, forKey: .product) + case .prebuilt(let path): + try values.encode(Kind.prebuilt, forKey: .kind) + try values.encode(path, forKey: .path) + } + } + + public func validate() throws { + switch self { + case .library(let value), .executable(let value), .prebuilt(let value): + guard !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw StringError("Product source must specify a nonempty product or path") + } + } + } +} diff --git a/Sources/PackLib/TargetPlatform.swift b/Sources/PackLib/TargetPlatform.swift new file mode 100644 index 00000000..3a80e2c7 --- /dev/null +++ b/Sources/PackLib/TargetPlatform.swift @@ -0,0 +1,123 @@ +import Foundation +import XUtils + +/// Target identity is independent of the host and of the build backend. +public enum TargetPlatform: String, Codable, Sendable, CaseIterable { + case iOS = "ios" + case iOSSimulator = "ios-simulator" + case macOS = "macos" + + public var minimumDeploymentTarget: String { + switch self { + case .iOS, .iOSSimulator: "13.0" + case .macOS: "15.0" + } + } + + public var packagePlatformName: String { + self == .macOS ? "macos" : "ios" + } + + public var supportedPlatform: String { + switch self { + case .iOS: "iPhoneOS" + case .iOSSimulator: "iPhoneSimulator" + case .macOS: "MacOSX" + } + } +} + +/// Parse only supported Apple targets; never infer the target from the host. +public struct TargetTriple: Sendable, Equatable { + public let architecture: String + public let platform: TargetPlatform + public let deploymentTarget: String? + + public init(_ triple: String) throws { + let parts = triple.split(separator: "-", omittingEmptySubsequences: false).map(String.init) + guard (3...4).contains(parts.count), parts[1] == "apple" else { + throw StringError("Unsupported target triple: \(triple)") + } + let os = String(parts[2].prefix { !$0.isNumber }) + let version = String(parts[2].dropFirst(os.count)) + switch (os, parts.count == 4 ? parts[3] : "") { + case ("ios", ""): platform = .iOS + case ("ios", "simulator"): platform = .iOSSimulator + case ("macos", ""), ("macosx", ""): platform = .macOS + default: throw StringError("Unsupported target triple: \(triple)") + } + architecture = parts[0] + let supported = platform == .iOS ? ["arm64"] : ["arm64", "x86_64"] + guard supported.contains(architecture) else { + throw StringError("Unsupported architecture '\(architecture)' for \(platform.rawValue)") + } + if !version.isEmpty { try Self.validateVersion(version) } + deploymentTarget = version.isEmpty ? nil : version + } + + static func validateVersion(_ value: String) throws { + let parts = value.split(separator: ".", omittingEmptySubsequences: false) + guard (1...3).contains(parts.count), parts.allSatisfy({ !$0.isEmpty && $0.allSatisfy({ $0.isASCII && $0.isNumber }) }) else { + throw StringError("Invalid deployment target: \(value)") + } + } +} + +/// Resolve once, then share this value with planning, compilation, assembly, and signing. +public struct ResolvedTarget: Codable, Sendable, Equatable { + public static let defaultTriple = "arm64-apple-ios" + + public let triple: String + public let platform: TargetPlatform + public let architecture: String + public let deploymentTarget: String + + /// Triple precedence: CLI > schema > iOS default. + /// Deployment precedence: selected triple's version > schema > package > platform minimum. + /// A selected triple cannot lower an explicit schema/package minimum. + public static func resolve( + cliTarget: String? = nil, + schema: PackSchema = .default, + packageDeploymentTargets: [String: String] = [:] + ) throws -> Self { + let triple = cliTarget ?? schema.target ?? defaultTriple + let parsed = try TargetTriple(triple) + let packageMinimum = packageDeploymentTargets[parsed.platform.packagePlatformName] + let deployment = parsed.deploymentTarget ?? schema.deploymentTarget + ?? packageMinimum ?? parsed.platform.minimumDeploymentTarget + try TargetTriple.validateVersion(deployment) + for minimum in [schema.deploymentTarget, packageMinimum, parsed.platform.minimumDeploymentTarget].compactMap({ $0 }) { + try TargetTriple.validateVersion(minimum) + guard !version(deployment, isBelow: minimum) else { + throw StringError("Deployment target \(deployment) is below required minimum \(minimum)") + } + } + try schema.validate(for: parsed.platform) + return Self( + triple: triple, + platform: parsed.platform, + architecture: parsed.architecture, + deploymentTarget: deployment + ) + } + + private static func version(_ value: String, isBelow minimum: String) -> Bool { + let lhs = value.split(separator: ".").map { Int($0) ?? Int.max } + let rhs = minimum.split(separator: ".").map { Int($0) ?? Int.max } + for index in 0.. URL { + try validatePlan() let xtoolDir = root.appendingPathComponent("xtool", isDirectory: true) let projectDir = xtoolDir.appendingPathComponent(".xtool-tmp", isDirectory: true) try? FileManager.default.removeItem(at: projectDir) @@ -233,6 +235,25 @@ public struct XcodePacker { return workspaceURL } + private func validatePlan() throws { + guard plan.target.platform != .macOS else { + throw StringError("Xcode project generation for macOS is not supported") + } + for product in plan.allProducts { + switch product.type { + case .application, .appExtension: break + case .xpcService, .helperExecutable: + throw StringError("Xcode project generation does not support XPC services or helpers") + } + guard case .library = product.resolvedSource else { + throw StringError("Xcode project generation requires library product sources") + } + if product.resources.contains(where: { if case .copy = $0 { return true }; return false }) { + throw StringError("Xcode project generation does not support explicit resource destinations") + } + } + } + private func resourceReferences(for product: Plan.Product, at root: URL) throws -> [XCSchema.Reference] { var paths = product.resources.compactMap { resource -> String? in guard case .root(let source) = resource else { return nil } diff --git a/Tests/XToolTests/PackContractTests.swift b/Tests/XToolTests/PackContractTests.swift new file mode 100644 index 00000000..3f0a3942 --- /dev/null +++ b/Tests/XToolTests/PackContractTests.swift @@ -0,0 +1,189 @@ +import Dependencies +import Foundation +import Testing +import Yams +@testable import PackLib + +struct PackContractTests { + private func schema(_ yaml: String) throws -> PackSchema { + try PackSchema(validating: YAMLDecoder().decode(PackSchemaBase.self, from: yaml)) + } + + @Test func legacyConfigurationsRemainValid() throws { + let examples = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("Examples") + let enumerator = try #require(FileManager.default.enumerator(at: examples, includingPropertiesForKeys: nil)) + var count = 0 + for case let url as URL in enumerator where url.lastPathComponent == "xtool.yml" { + let config = try schema(String(contentsOf: url, encoding: .utf8)) + #expect(config.version == .v1) + #expect(try ResolvedTarget.resolve(schema: config).platform == .iOS) + count += 1 + } + #expect(count > 0) + let target = try ResolvedTarget.resolve() + #expect(target.triple == "arm64-apple-ios") + #expect(target.deploymentTarget == "13.0") + } + + @Test func schemaVersionControlsNewFields() throws { + #expect(throws: (any Error).self) { + try schema("version: 1\norgID: test\ntarget: arm64-apple-macosx") + } + #expect(throws: (any Error).self) { + try schema("version: 3\norgID: test") + } + #expect(throws: (any Error).self) { + try schema("version: 2\norgID: test\nsource: {kind: prebuilt, path: helper, product: Wrong}") + } + } + + @Test func effectiveTargetAndDeploymentPrecedence() throws { + let config = try schema("version: 2\norgID: test\ntarget: arm64-apple-macosx\ndeploymentTarget: '15.0'") + let fromSchema = try ResolvedTarget.resolve(schema: config, packageDeploymentTargets: ["macos": "14.0"]) + #expect(fromSchema.platform == .macOS) + #expect(fromSchema.compilerTriple == "arm64-apple-macosx15.0") + let fromCLI = try ResolvedTarget.resolve(cliTarget: "x86_64-apple-ios17.0-simulator", schema: config) + #expect(fromCLI.platform == .iOSSimulator) + #expect(fromCLI.architecture == "x86_64") + #expect(fromCLI.deploymentTarget == "17.0") + let fromPackage = try ResolvedTarget.resolve(packageDeploymentTargets: ["ios": "16.0"]) + #expect(fromPackage.deploymentTarget == "16.0") + #expect(throws: (any Error).self) { + try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx14.0", schema: config) + } + #expect(throws: (any Error).self) { + try ResolvedTarget.resolve(schema: config, packageDeploymentTargets: ["macos": "16.0"]) + } + } + + @Test(arguments: ["unknown-triple", "arm64-apple-ios-macabi", "arm64-apple-tvos", + "x86_64-apple-ios", "arm64-apple-macosx-simulator", "arm64-apple-ios17..0", + "arm64-apple-macosx15.0-extra", "arm64--ios"]) + func unsupportedTriplesReject(triple: String) { + #expect(throws: (any Error).self) { try TargetTriple(triple) } + } + + @Test func versionTwoModelsExternalInputs() throws { + let config = try schema(""" + version: 2 + orgID: test + target: arm64-apple-macosx + source: {kind: executable, product: App} + entitlementsPath: ../signing/App.entitlements + resourceCopies: + - source: agent.plist + destination: {location: contents, path: Library/LaunchAgents/test.agent.plist} + nestedProducts: + - product: Service + kind: xpcService + source: {kind: library, product: ServiceLibrary} + infoPath: Service.plist + entitlementsPath: /external/Service.entitlements + - product: Helper + kind: helperExecutable + source: {kind: prebuilt, path: /external/helper} + """) + #expect(config.source == .executable(product: "App")) + #expect(config.nestedProducts?.count == 2) + #expect(config.nestedProducts?.last?.source == .prebuilt(path: "/external/helper")) + #expect(config.entitlementsPath == "../signing/App.entitlements") + #expect(try ResolvedTarget.resolve(schema: config).platform == .macOS) + #expect(throws: (any Error).self) { + try ResolvedTarget.resolve(cliTarget: "arm64-apple-ios", schema: config) + } + } + + @Test func layoutsMatchPlatformAndProductKind() throws { + let app = BundleLayout(platform: .macOS, kind: .application, name: "App") + #expect(app.executablePath == "Contents/MacOS/App") + #expect(app.infoPlistPath == "Contents/Info.plist") + #expect(app.resourcesPath == "Contents/Resources") + #expect(app.frameworksPath == "Contents/Frameworks") + #expect(try app.path(for: .init(location: .contents, path: "Library/LaunchAgents/agent.plist")) + == "Contents/Library/LaunchAgents/agent.plist") + let ext = BundleLayout(platform: .macOS, kind: .appExtension, name: "Preview") + #expect(ext.executablePath == "Contents/PlugIns/Preview.appex/Contents/MacOS/Preview") + let service = BundleLayout(platform: .macOS, kind: .xpcService, name: "Service") + #expect(service.infoPlistPath == "Contents/XPCServices/Service.xpc/Contents/Info.plist") + let helper = BundleLayout(platform: .macOS, kind: .helperExecutable, name: "Helper") + #expect(helper.executablePath == "Contents/Helpers/Helper") + #expect(helper.infoPlistPath == nil) + let ios = BundleLayout(platform: .iOS, kind: .appExtension, name: "Extension") + #expect(ios.executablePath == "PlugIns/Extension.appex/Extension") + #expect(ios.resourcesPath == "PlugIns/Extension.appex") + } + + @Test(arguments: ["", "/absolute", "../escape", "a/../../escape", "a//b", "a/./b", "a\\b", "a\0b"]) + func unsafeResourceDestinationsReject(path: String) { + #expect(throws: (any Error).self) { try ResourceDestination(path: path).validate() } + } + + private func product(_ name: String, kind: Plan.ProductType, target: ResolvedTarget) -> Plan.Product { + Plan.Product( + target: target, + type: kind, + product: name, + bundleID: "test.\(name)", + infoPlist: [:], + resources: [], + entitlementsPath: "/external/\(name).entitlements" + ) + } + + @Test func signingManifestKeepsProductAssociations() throws { + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx") + let plan = try Plan( + target: target, + app: product("App", kind: .application, target: target), + extensions: [product("Preview", kind: .appExtension, target: target)], + nestedProducts: [product("Service", kind: .xpcService, target: target), + product("Helper", kind: .helperExecutable, target: target)] + ) + let data = try JSONEncoder().encode(DeferredSigningManifest(plan: plan)) + let manifest = try JSONDecoder().decode(DeferredSigningManifest.self, from: data) + #expect(manifest.version == 1) + #expect(manifest.status == .deferred) + #expect(manifest.target == target) + #expect(manifest.products.count == 4) + #expect(manifest.products[2].bundlePath == "Contents/XPCServices/Service.xpc") + #expect(manifest.products[2].entitlementsPath == "/external/Service.entitlements") + #expect(manifest.products[3].executablePath == "Contents/Helpers/Helper") + } + + @Test func inconsistentPlansReject() throws { + let ios = try ResolvedTarget.resolve() + let mac = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx") + #expect(throws: (any Error).self) { + try Plan(target: ios, app: product("App", kind: .application, target: mac)) + } + #expect(throws: (any Error).self) { + try Plan(target: ios, app: product("App", kind: .application, target: ios), + extensions: [product("App", kind: .appExtension, target: ios)]) + } + } + + @Test func packerUsesInjectedBuilder() async throws { + struct BuildStopped: Error {} + let target = try ResolvedTarget.resolve() + let plan = try Plan(target: target, app: product("App", kind: .application, target: target)) + let settings = try await BuildSettings(configuration: .debug, triple: target.triple, buildSystem: .swiftPM) + await #expect(throws: BuildStopped.self) { + try await withDependencies { + $0.productBuilder = ProductBuilderClient { _, _ in throw BuildStopped() } + } operation: { + try await Packer(buildSettings: settings, plan: plan).pack() + } + } + } + + @Test func macOSXcodeGenerationRejectsBeforeWriting() async throws { + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx") + let plan = try Plan(target: target, app: product("App", kind: .application, target: target)) + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + await #expect(throws: (any Error).self) { + try await XcodePacker(plan: plan).createProject(at: root) + } + #expect(!FileManager.default.fileExists(atPath: root.path)) + } +} diff --git a/Tests/XToolTests/PackerTests.swift b/Tests/XToolTests/PackerTests.swift deleted file mode 100644 index 5aabda83..00000000 --- a/Tests/XToolTests/PackerTests.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Testing -@testable import PackLib - -@Test func cfBundleSupportedPlatformsFollowsBuildTriple() { - #expect(Packer.cfBundleSupportedPlatforms(for: "arm64-apple-ios") == ["iPhoneOS"]) - #expect(Packer.cfBundleSupportedPlatforms(for: "arm64-apple-ios-simulator") == ["iPhoneSimulator"]) - - #expect(Packer.cfBundleSupportedPlatforms(for: "arm64-apple-ios17.0") == ["iPhoneOS"]) - #expect(Packer.cfBundleSupportedPlatforms(for: "arm64-apple-ios17.0-simulator") == ["iPhoneSimulator"]) - #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: "unknown-triple") == ["iPhoneOS"]) -} From b88a200c1575839fcdc2ed48cb773201bf2fe21f Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 13 Oct 2026 09:04:09 +0200 Subject: [PATCH 03/59] packer: build selected products for macOS targets Teach ProductBuilder to compile the products a plan selects for any supported triple: - Each product is built with its own `swift build --product` call and located through `--show-bin-path`; builds pass the versioned target triple, including for iOS. - Products with a direct `.executable` source are built as-is; other products still go through the generated wrapper package, whose manifest now quotes paths as Swift string literals. - BuildSettings rejects triples it does not recognise, and rejects Debug macOS builds on Linux hosts. Planner and Packer still refuse macOS targets, so `xtool dev` cannot reach this path yet. --- Sources/PackLib/BuildSettings.swift | 72 ++++- Sources/PackLib/ProductBuilder.swift | 289 +++++++++++++-------- Sources/PackLib/TargetPlatform.swift | 2 +- Tests/XToolTests/ProductBuilderTests.swift | 230 ++++++++++++++++ 4 files changed, 487 insertions(+), 106 deletions(-) create mode 100644 Tests/XToolTests/ProductBuilderTests.swift diff --git a/Sources/PackLib/BuildSettings.swift b/Sources/PackLib/BuildSettings.swift index 9d5a041c..4bd4d4ee 100644 --- a/Sources/PackLib/BuildSettings.swift +++ b/Sources/PackLib/BuildSettings.swift @@ -40,7 +40,16 @@ public struct BuildSettings: Sendable { self.configuration = configuration self.customOptions = options self.triple = triple - self.buildSystem = if let buildSystem { buildSystem } else { try await .default() } + let target = try TargetTriple(triple) + // SwiftBuild has not been validated for macOS cross-compilation. Keep the + // established iOS default, and use the proven SwiftPM backend for macOS. + self.buildSystem = if let buildSystem { + buildSystem + } else if target.platform == .macOS { + .swiftPM + } else { + try await .default() + } self.sdkEnvironment = [ // xcrun passes an SDKROOT that messes with our sdk configuration @@ -54,8 +63,14 @@ public struct BuildSettings: Sendable { // i.e. we can pass `--swift-sdk arm64-apple-ios` and it // just works. See: // https://github.com/swiftlang/swift-package-manager/pull/6828 - self.sdkOptions = ["--swift-sdk", triple] + self.sdkOptions = ["--swift-sdk", Self.sdkIdentity(for: target)] + if target.deploymentTarget != nil { + self.sdkOptions += ["--triple", Self.compilerTriple(for: target)] + } case .swiftBuild: + guard target.platform != .macOS else { + throw StringError("macOS builds currently require the SwiftPM backend; SwiftBuild is not validated") + } self.sdkOptions = ["--triple", triple] #if !os(macOS) let darwinSDK = try DarwinSDK.current() @@ -113,6 +128,59 @@ public struct BuildSettings: Sendable { return copy } + /// An SDK's unversioned identity selects its tools and libraries; the + /// compiler triple separately carries the plan's effective minimum OS. + public func withTarget(_ target: ResolvedTarget) throws -> Self { + guard target.triple == triple else { + throw StringError("Build settings target differs from the resolved plan") + } + var copy = self + switch buildSystem { + case .swiftPM: + copy.sdkOptions = ["--swift-sdk", Self.sdkIdentity(for: try TargetTriple(triple)), + "--triple", target.compilerTriple] + case .swiftBuild: + // Retain the backend-specific toolset selected by the initializer. + copy.sdkOptions = ["--triple", target.compilerTriple] + sdkOptions.dropFirst(2) + } + return copy + } + + private static func sdkIdentity(for target: TargetTriple) -> String { + switch target.platform { + case .iOS: "\(target.architecture)-apple-ios" + case .iOSSimulator: "\(target.architecture)-apple-ios-simulator" + case .macOS: "\(target.architecture)-apple-macosx" + } + } + + private static func compilerTriple(for target: TargetTriple) -> String { + let version = target.deploymentTarget ?? target.platform.minimumDeploymentTarget + return switch target.platform { + case .iOS: "\(target.architecture)-apple-ios\(version)" + case .iOSSimulator: "\(target.architecture)-apple-ios\(version)-simulator" + case .macOS: "\(target.architecture)-apple-macosx\(version)" + } + } + + /// Reject combinations whose failure is already known, before a builder + /// creates its wrapper or starts resolving/compiling a package. + func validateProductBuild(for target: ResolvedTarget, hostIsMacOS: Bool) throws { + // `init` already rejects SwiftBuild for macOS, and `withTarget` a different triple. + guard target.platform != .macOS || hostIsMacOS || configuration == .release else { + throw StringError("macOS Debug builds on Linux need unavailable codesign support; use --configuration release") + } + // Build options may tune compilation, but cannot silently replace the + // already-resolved target, product, backend, or package. + let reserved = ["--triple", "--swift-sdk", "--sdk", "--arch", "--destination", "--configuration", "-c", + "--build-system", "--package-path", "--product", "--target", "--show-bin-path", "--build-tests"] + for option in customOptions { + if reserved.contains(where: { option == $0 || option.hasPrefix("\($0)=") }) { + throw StringError("Build option '\(option)' conflicts with the resolved product build settings") + } + } + } + public func swiftPMInvocation( forTool tool: String, arguments: [String], diff --git a/Sources/PackLib/ProductBuilder.swift b/Sources/PackLib/ProductBuilder.swift index 5adc8819..0e9aa6a7 100644 --- a/Sources/PackLib/ProductBuilder.swift +++ b/Sources/PackLib/ProductBuilder.swift @@ -14,129 +14,212 @@ public struct ProductBuilder: Sendable { self.plan = plan } + @Dependency(\.productBuildRunner) private var runner + public func build() async throws -> BuildArtifacts { - guard plan.target.triple == buildSettings.triple else { - throw StringError("Build settings target differs from the resolved plan") + #if os(macOS) + let hostIsMacOS = true + #else + let hostIsMacOS = false + #endif + try buildSettings.validateProductBuild(for: plan.target, hostIsMacOS: hostIsMacOS) + try validateProducts() + let settings = try buildSettings.withTarget(plan.target) + let root = URL(fileURLWithPath: settings.packagePath, isDirectory: true).standardizedFileURL + let wrapper = root.appendingPathComponent("xtool/.xtool-tmp", isDirectory: true) + let libraries = plan.allProducts.filter { + if case .library = $0.resolvedSource { return true } + return false } - guard plan.target.platform != .macOS, - plan.allProducts.allSatisfy({ - if case .library(let name) = $0.resolvedSource { return name == $0.product } - return false - }) else { - throw StringError("Only iOS library-wrapper compilation is implemented") + if !libraries.isEmpty { try createWrapper(at: wrapper, root: root, products: libraries) } + + // Share one scratch tree so resources and dependency libraries remain + // beside all products, including mixed direct and wrapped products. + let scratchOptions = settings.customOptions.contains { + $0 == "--scratch-path" || $0.hasPrefix("--scratch-path=") + } ? [] : ["--scratch-path", root.appendingPathComponent(".build").path] + var directory: URL? + var executables: [String: URL] = [:] + for product in plan.allProducts { + let package: URL + let name: String + let options: [String] + switch product.resolvedSource { + case .library: + package = wrapper + name = product.targetName + // Planning already resolved root dependencies; avoid dropping + // its development-only dependencies from Package.resolved. + options = ["--disable-automatic-resolution"] + case .executable(let sourceName): + package = root + name = sourceName + options = try directProductOptions(product, in: wrapper) + case .prebuilt: + // validateProducts rejects these before any side effects. + throw StringError("Prebuilt product compatibility validation is not implemented") + } + let productSettings = settings.withPackagePath(package.path) + let arguments = scratchOptions + ["--product", name] + options + try await runner.build(productSettings, arguments) + let path = try await runner.binaryPath(productSettings, arguments) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty, !path.contains("\n"), path.hasPrefix("/") else { + throw StringError("Build backend returned an invalid binary output path: '\(path)'") + } + let binDir = URL(fileURLWithPath: path, isDirectory: true).standardizedFileURL + if let directory, directory != binDir { + throw StringError("Build backend produced products in different resource directories") + } + directory = binDir + let executable = binDir.appendingPathComponent(name) + guard FileManager.default.isExecutableFile(atPath: executable.path) else { + throw StringError("Build backend did not produce executable '\(name)' at \(executable.path)") + } + executables[product.product] = executable } - 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) + guard let directory else { throw StringError("Build plan has no products") } + return BuildArtifacts(directory: directory, executables: executables) + } + + private func directProductOptions(_ product: Plan.Product, in directory: URL) throws -> [String] { + // SwiftPM forwards CLI -Xlinker options to host macro executables too. + // SDK toolset options belong only to the target toolchain instead. + let toolsets = directory.appendingPathComponent("toolsets", isDirectory: true) + try FileManager.default.createDirectory(at: toolsets, withIntermediateDirectories: true) + let path = toolsets.appendingPathComponent("\(product.targetName).json") + let data = try JSONSerialization.data(withJSONObject: [ + "schemaVersion": "1.0", + "swiftCompiler": ["extraCLIOptions": product.linkerArguments], + ], options: [.sortedKeys]) + try data.write(to: path) + return ["--toolset", path.path] + } - let packageSwift = packageDir.appendingPathComponent("Package.swift") + private func validateProducts() throws { + let names = plan.allProducts.map { product -> String in + switch product.resolvedSource { + case .library: product.targetName + case .executable(let name): name + case .prebuilt: "" + } + } + guard Set(names).count == names.count else { + throw StringError("Product build output names collide") + } + for product in plan.allProducts { + try product.resolvedSource.validate() + try ResourceDestination.validateRelativePath(product.product) + guard !product.product.contains("/") else { + throw StringError("Product names must not contain path separators") + } + switch product.resolvedSource { + case .prebuilt: + throw StringError("Prebuilt product compatibility validation is not implemented; use a SwiftPM product") + case .executable where buildSettings.buildSystem != .swiftPM: + throw StringError("Direct executable products currently require the SwiftPM backend") + case .library(let name), .executable(let name): + try ResourceDestination.validateRelativePath(name) + guard !name.contains("/") else { + throw StringError("SwiftPM product names must not contain path separators") + } + } + } + } + + private func createWrapper(at directory: URL, root: URL, products: [Plan.Product]) throws { + try? FileManager.default.removeItem(at: directory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let targets = products.map { product in + let sourceName: String + if case .library(let name) = product.resolvedSource { sourceName = name } else { sourceName = product.product } + return """ + .executableTarget( + name: \(product.targetName.swiftLiteral), + dependencies: [.product(name: \(sourceName.swiftLiteral), package: "RootPackage")], + linkerSettings: \(product.linkerSettings) + ) + """ + }.joined(separator: ",\n") + let platform = plan.target.platform == .macOS ? "macOS" : "iOS" let contents = """ // swift-tools-version: 6.0 import PackageDescription let package = Package( - name: "\(plan.app.product)-Builder", - platforms: [ - .iOS("\(plan.app.deploymentTarget)"), - ], - dependencies: [ - .package(name: "RootPackage", path: "../.."), - ], + name: \((plan.app.product + "-Builder").swiftLiteral), + platforms: [.\(platform)(\(plan.target.deploymentTarget.swiftLiteral))], + dependencies: [.package(name: "RootPackage", path: \(root.path.swiftLiteral))], targets: [ - \( - plan.allProducts.map { - """ - .executableTarget( - name: "\($0.targetName)", - dependencies: [ - .product(name: "\($0.product)", package: "RootPackage"), - ], - linkerSettings: \($0.linkerSettings) - ) - """ - } - .joined(separator: ",\n") - ) + \(targets) ] - )\n - """ - try Data(contents.utf8).write(to: packageSwift) + ) - for product in plan.allProducts { - let sources: URL = packageDir.appendingPathComponent("Sources/\(product.targetName)", isDirectory: true) + """ + try Data(contents.utf8).write(to: directory.appendingPathComponent("Package.swift")) + for product in products { + let sources = directory.appendingPathComponent("Sources/\(product.targetName)", isDirectory: true) try FileManager.default.createDirectory(at: sources, withIntermediateDirectories: true) - try Data().write(to: sources.appendingPathComponent("stub.c", isDirectory: false)) + try Data().write(to: sources.appendingPathComponent("stub.c")) } + } +} - let buildConfig = try await buildSettings - .withPackagePath(packageDir.path) - .swiftPMInvocation( - forTool: "build", - arguments: [ - "--scratch-path", ".build", - // resolving can cause SwiftPM to overwrite the root package deps - // with just the deps needed for the builder package (which is to - // say, any "dev dependencies" of the root package may be removed.) - // fortunately we've already resolved the root package by this point - // in order to dump the plan, so we can skip resolution here to skirt - // the issue. - "--disable-automatic-resolution", - ], - ) - try await Subprocess.run( - buildConfig, - output: .currentStandardOutput, - error: .currentStandardError, - ) - .checkSuccess() - let binPath: String - switch buildSettings.buildSystem { - 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)" +extension String { + /// Encode untrusted package/product names as a literal, never manifest code. + fileprivate var swiftLiteral: String { String(reflecting: self) } +} + +extension Plan.Product { + var linkerArguments: [String] { + var arguments: [String] = [] + if type == .appExtension { + arguments += ["-Xlinker", "-framework", "-Xlinker", "Foundation", + "-Xlinker", "-e", "-Xlinker", "_NSExtensionMain"] } - let binDir = URL( - fileURLWithPath: ".build/\(binPath)", - isDirectory: true - ) + // Paths follow the executable's actual position in the selected layout. + let own = platform == .macOS ? "@executable_path/../Frameworks" : "@executable_path/Frameworks" + let outer: String? = switch type { + case .application: nil + case .appExtension, .xpcService: + platform == .macOS ? "@executable_path/../../../../Frameworks" : "@executable_path/../../Frameworks" + case .helperExecutable: "@executable_path/../Frameworks" + } + if let outer { arguments += ["-Xlinker", "-rpath", "-Xlinker", outer] } + if outer != own { arguments += ["-Xlinker", "-rpath", "-Xlinker", own] } + return arguments + } - return BuildArtifacts( - directory: binDir, - executables: Dictionary(uniqueKeysWithValues: plan.allProducts.map { - ($0.product, binDir.appendingPathComponent($0.targetName)) - }) - ) + fileprivate var linkerSettings: String { + "[.unsafeFlags([\(linkerArguments.map(\.swiftLiteral).joined(separator: ", "))])]" } } -extension Plan.Product { - fileprivate var linkerSettings: String { - switch self.type { - case .application: """ - [ - .unsafeFlags([ - "-Xlinker", "-rpath", "-Xlinker", "@executable_path/Frameworks", - ]), - ] - """ - case .xpcService, .helperExecutable: "[]" - case .appExtension: """ - [ - // Link to Foundation framework which implements the _NSExtensionMain entrypoint - .linkedFramework("Foundation"), - .unsafeFlags([ - // 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", - // ...as well as our own - "-Xlinker", "-rpath", "-Xlinker", "@executable_path/Frameworks", - ]), - ] - """ +/// The build backend owns output discovery. Injection lets focused tests assert +/// target/host separation without starting a compiler or provisioning a device. +struct ProductBuildRunner: Sendable { + var build: @Sendable (BuildSettings, [String]) async throws -> Void + var binaryPath: @Sendable (BuildSettings, [String]) async throws -> String +} + +extension ProductBuildRunner: DependencyKey { + static let liveValue = Self( + build: { settings, arguments in + let invocation = try await settings.swiftPMInvocation(forTool: "build", arguments: arguments) + try await Subprocess.run(invocation, output: .currentStandardOutput, error: .currentStandardError).checkSuccess() + }, + binaryPath: { settings, arguments in + let invocation = try await settings.swiftPMInvocation(forTool: "build", arguments: arguments + ["--show-bin-path"]) + let result = try await Subprocess.run(invocation, output: .string(limit: .max), error: .currentStandardError) + .checkSuccess() + return result.standardOutput } + ) +} + +extension DependencyValues { + var productBuildRunner: ProductBuildRunner { + get { self[ProductBuildRunner.self] } + set { self[ProductBuildRunner.self] = newValue } } } diff --git a/Sources/PackLib/TargetPlatform.swift b/Sources/PackLib/TargetPlatform.swift index 3a80e2c7..3db99ad8 100644 --- a/Sources/PackLib/TargetPlatform.swift +++ b/Sources/PackLib/TargetPlatform.swift @@ -112,7 +112,7 @@ public struct ResolvedTarget: Codable, Sendable, Equatable { return false } - /// SDK identity remains `triple`; compiler invocations use this versioned target. + /// Canonical versioned target for compiler invocations. The SDK identity is selected separately. public var compilerTriple: String { switch platform { case .iOS: "\(architecture)-apple-ios\(deploymentTarget)" diff --git a/Tests/XToolTests/ProductBuilderTests.swift b/Tests/XToolTests/ProductBuilderTests.swift new file mode 100644 index 00000000..7ea9e073 --- /dev/null +++ b/Tests/XToolTests/ProductBuilderTests.swift @@ -0,0 +1,230 @@ +import Dependencies +import Foundation +import Testing +@testable import PackLib + +struct ProductBuilderTests { + private func product( + _ name: String, source: ProductSource, kind: Plan.ProductType = .application, target: ResolvedTarget + ) -> Plan.Product { + Plan.Product(target: target, source: source, type: kind, product: name, + bundleID: "test.\(name)", infoPlist: [:], resources: []) + } + + private func temporaryRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private actor Recorder { + var builds: [(BuildSettings, [String])] = [] + var queries: [(BuildSettings, [String])] = [] + let output: URL + + init(output: URL) { self.output = output } + + func build(_ settings: BuildSettings, arguments: [String]) throws { + builds.append((settings, arguments)) + let index = try #require(arguments.firstIndex(of: "--product")) + try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + let executable = output.appendingPathComponent(arguments[index + 1]) + try Data("fixture".utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + } + + func binaryPath(_ settings: BuildSettings, arguments: [String]) -> String { + queries.append((settings, arguments)) + return output.path + "\n" + } + + nonisolated var runner: ProductBuildRunner { + ProductBuildRunner(build: { try await self.build($0, arguments: $1) }, + binaryPath: { await self.binaryPath($0, arguments: $1) }) + } + } + + @Test(arguments: ["arm64-apple-macosx15.0", "arm64-apple-macos15.0", "arm64-apple-macos", "arm64-apple-macosx"]) + func sdkIdentityAndDeploymentAreSeparate(triple: String) async throws { + let target = try ResolvedTarget.resolve(cliTarget: triple, packageDeploymentTargets: ["macos": "15.0"]) + let settings = try await BuildSettings(configuration: .release, triple: triple) + let resolved = try settings.withTarget(target) + #expect(resolved.buildSystem == .swiftPM) + #expect(resolved.sdkOptions == ["--swift-sdk", "arm64-apple-macosx", "--triple", "arm64-apple-macosx15.0"]) + #expect(resolved.sdkEnvironment["SDKROOT"] != nil) + } + + @Test func packageMinimumAndUserOptionsSurvive() async throws { + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-ios", packageDeploymentTargets: ["ios": "17.2"]) + let settings = try await BuildSettings(configuration: .debug, triple: target.triple, buildSystem: .swiftPM, + options: ["-Xswiftc", "-DUSER_OPTION", "--jobs", "2"]) + let resolved = try settings.withTarget(target) + #expect(resolved.sdkOptions == ["--swift-sdk", "arm64-apple-ios", "--triple", "arm64-apple-ios17.2"]) + #expect(resolved.customOptions == settings.customOptions) + #expect(resolved.configuration == .debug) + } + + @Test(arguments: [BuildConfiguration.debug, .release]) + func directExecutablesUseBackendPaths(configuration: BuildConfiguration) async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-ios17.0") + let plan = try Plan(target: target, app: product("App", source: .executable(product: "ActualExecutable"), target: target)) + let settings = try await BuildSettings(configuration: configuration, triple: target.triple, buildSystem: .swiftPM, + packagePath: root.path, options: ["--jobs", "2"]) + let output = root.appendingPathComponent("backend-selected/\(configuration.rawValue)") + let recorder = Recorder(output: output) + let artifacts = try await withDependencies { + $0.productBuildRunner = recorder.runner + } operation: { + try await ProductBuilder(buildSettings: settings, plan: plan).build() + } + #expect(artifacts.directory.path == output.path) + #expect(artifacts.executables["App"] == output.appendingPathComponent("ActualExecutable")) + #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent("xtool/.xtool-tmp/Package.swift").path)) + let builds = await recorder.builds + let queries = await recorder.queries + #expect(builds.count == 1) + #expect(builds[0].0.packagePath == root.path) + #expect(builds[0].0.customOptions == ["--jobs", "2"]) + #expect(builds[0].0.sdkOptions == ["--swift-sdk", "arm64-apple-ios", "--triple", target.compilerTriple]) + #expect(builds[0].1.contains("ActualExecutable")) + #expect(!builds[0].1.contains("-Xlinker")) + let toolsetIndex = try #require(builds[0].1.firstIndex(of: "--toolset")) + let toolsetData = try Data(contentsOf: URL(fileURLWithPath: builds[0].1[toolsetIndex + 1])) + let toolset = try #require(JSONSerialization.jsonObject(with: toolsetData) as? [String: Any]) + #expect(toolset["schemaVersion"] as? String == "1.0") + #expect(queries[0].1 == builds[0].1) + #expect(queries[0].0.configuration == configuration) + } + + @Test(arguments: ["arm64-apple-ios17.0", "arm64-apple-macosx15.0"]) + func wrappersLinkSelectedLibraryAndExtension(triple: String) async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let target = try ResolvedTarget.resolve(cliTarget: triple) + let plan = try Plan(target: target, app: product("App", source: .library(product: "AppLibrary"), target: target), + extensions: [product("Preview", source: .library(product: "PreviewLibrary"), + kind: .appExtension, target: target)]) + let settings = try await BuildSettings(configuration: .release, triple: triple, buildSystem: .swiftPM, + packagePath: root.path) + let recorder = Recorder(output: root.appendingPathComponent("target-output")) + let artifacts = try await withDependencies { + $0.productBuildRunner = recorder.runner + } operation: { + try await ProductBuilder(buildSettings: settings, plan: plan).build() + } + let manifest = try String(contentsOf: root.appendingPathComponent("xtool/.xtool-tmp/Package.swift"), encoding: .utf8) + #expect(manifest.contains(".product(name: \"AppLibrary\"")) + #expect(manifest.contains(".product(name: \"PreviewLibrary\"")) + #expect(manifest.contains("_NSExtensionMain")) + #expect(manifest.contains("Foundation")) + #expect(manifest.contains(target.platform == .macOS ? ".macOS(\"15.0\")" : ".iOS(\"17.0\")")) + #expect(manifest.contains(target.platform == .macOS + ? "@executable_path/../../../../Frameworks" : "@executable_path/../../Frameworks")) + #expect(artifacts.executables["App"]?.lastPathComponent == "App-App") + #expect(artifacts.executables["Preview"]?.lastPathComponent == "Preview-Extension") + #expect(await recorder.builds.allSatisfy { !$0.1.contains("-Xlinker") }) + } + + @Test func unsupportedCombinationsReject() async throws { + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + let debug = try await BuildSettings(configuration: .debug, triple: target.triple, buildSystem: .swiftPM) + #expect(throws: (any Error).self) { try debug.validateProductBuild(for: target, hostIsMacOS: false) } + try debug.validateProductBuild(for: target, hostIsMacOS: true) + let release = try await BuildSettings(configuration: .release, triple: target.triple, buildSystem: .swiftPM) + try release.validateProductBuild(for: target, hostIsMacOS: false) + await #expect(throws: (any Error).self) { + try await BuildSettings(configuration: .release, triple: target.triple, buildSystem: .swiftBuild) + } + } + + @Test(arguments: [ProductSource.prebuilt(path: "/external/helper"), .executable(product: "../escape")]) + func invalidSourcesRejectBeforeWriting(source: ProductSource) async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx") + let plan = try Plan(target: target, app: product("App", source: source, target: target)) + let settings = try await BuildSettings(configuration: .release, triple: target.triple, buildSystem: .swiftPM, + packagePath: root.path) + await #expect(throws: (any Error).self) { + try await withDependencies { + $0.productBuildRunner = ProductBuildRunner(build: { _, _ in Issue.record("Unexpected build") }, + binaryPath: { _, _ in Issue.record("Unexpected query"); return "" }) + } operation: { + try await ProductBuilder(buildSettings: settings, plan: plan).build() + } + } + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path).isEmpty) + } + + @Test(arguments: ["--triple=arm64-apple-ios", "--arch", "--configuration=debug", "--product", "--show-bin-path"]) + func conflictingOptionsReject(option: String) async throws { + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx") + let settings = try await BuildSettings(configuration: .release, triple: target.triple, buildSystem: .swiftPM, + options: [option]) + #expect(throws: (any Error).self) { try settings.validateProductBuild(for: target, hostIsMacOS: false) } + } + + @Test func missingBackendExecutableRejects() async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-ios") + let plan = try Plan(target: target, app: product("App", source: .executable(product: "Missing"), target: target)) + let settings = try await BuildSettings(configuration: .release, triple: target.triple, buildSystem: .swiftPM, + packagePath: root.path) + await #expect(throws: (any Error).self) { + try await withDependencies { + $0.productBuildRunner = ProductBuildRunner(build: { _, _ in }, binaryPath: { _, _ in root.path }) + } operation: { + try await ProductBuilder(buildSettings: settings, plan: plan).build() + } + } + } + #if os(macOS) + @Test(arguments: [BuildConfiguration.debug, .release]) + func nativeExecutableAndLibraryFixture(configuration: BuildConfiguration) async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let manifest = """ + // swift-tools-version: 6.0 + import PackageDescription + let package = Package( + name: "BuilderFixture", + platforms: [.macOS("15.0")], + products: [.executable(name: "DirectExecutable", targets: ["Direct"]), + .library(name: "HelperLibrary", targets: ["Helper"])], + targets: [.executableTarget(name: "Direct", resources: [.copy("message.txt")]), + .target(name: "Helper")] + ) + """ + try Data(manifest.utf8).write(to: root.appendingPathComponent("Package.swift")) + let direct = root.appendingPathComponent("Sources/Direct") + let helper = root.appendingPathComponent("Sources/Helper") + try FileManager.default.createDirectory(at: direct, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: helper.appendingPathComponent("include"), withIntermediateDirectories: true) + try Data("print(\"builder fixture\")".utf8).write(to: direct.appendingPathComponent("main.swift")) + try Data("resource fixture".utf8).write(to: direct.appendingPathComponent("message.txt")) + try Data("int main(void) { return 0; }".utf8).write(to: helper.appendingPathComponent("Entry.c")) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macos15.0") + let plan = try Plan(target: target, + app: product("App", source: .executable(product: "DirectExecutable"), target: target), + nestedProducts: [product("Helper", source: .library(product: "HelperLibrary"), + kind: .helperExecutable, target: target)]) + let settings = try await BuildSettings(configuration: configuration, triple: target.triple, buildSystem: .swiftPM, + packagePath: root.path) + let artifacts = try await withDependencies { + $0.productBuildRunner = .liveValue + } operation: { + try await ProductBuilder(buildSettings: settings, plan: plan).build() + } + #expect(artifacts.executables.count == 2) + #expect(artifacts.directory.lastPathComponent == configuration.rawValue) + #expect(FileManager.default.isExecutableFile(atPath: try #require(artifacts.executables["App"]).path)) + #expect(FileManager.default.isExecutableFile(atPath: try #require(artifacts.executables["Helper"]).path)) + let resource = artifacts.directory.appendingPathComponent("BuilderFixture_Direct.bundle/message.txt") + #expect(try String(contentsOf: resource, encoding: .utf8) == "resource fixture") + } + #endif + +} From 99a3080ceca4cbbbf82eca2d600b8479b82d5df2 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 13 Oct 2026 09:26:23 +0200 Subject: [PATCH 04/59] planner: handle platform-specific products and resources Plan macOS and version 2 configurations instead of rejecting them: - Package inspection moves behind the PackageInspectionClient dependency and also reads `swift package dump-package`, so dependency platform conditions decide which libraries and resource bundles a product pulls in for the selected platform. - Products may name an explicit or prebuilt source, may be qualified by package, and may declare nested products (XPC services, helpers) and resourceCopies. Plugin targets are skipped and duplicate resource bundles are planned once. - When an Info.plist overrides CFBundleIdentifier, the plan's bundle ID follows it. - Extensions are planned sequentially. Selecting the target through xtool.yml's `target` key is still rejected here; the dev command learns to honour it in a later commit. --- Sources/PackLib/Planner.swift | 634 +++++++++++++++------------- Tests/XToolTests/PlannerTests.swift | 328 ++++++++++++++ 2 files changed, 667 insertions(+), 295 deletions(-) create mode 100644 Tests/XToolTests/PlannerTests.swift diff --git a/Sources/PackLib/Planner.swift b/Sources/PackLib/Planner.swift index cdbeff07..f68b1321 100644 --- a/Sources/PackLib/Planner.swift +++ b/Sources/PackLib/Planner.swift @@ -1,3 +1,4 @@ +import Dependencies import Foundation import XUtils import Subprocess @@ -6,10 +7,9 @@ public struct Planner: Sendable { public var buildSettings: BuildSettings public var schema: PackSchema - public init( - buildSettings: BuildSettings, - schema: PackSchema - ) { + @Dependency(\.packageInspection) private var inspection + + public init(buildSettings: BuildSettings, schema: PackSchema) { self.buildSettings = buildSettings self.schema = schema } @@ -21,340 +21,277 @@ public struct Planner: Sendable { }() private func buildGraph() async throws -> PackageGraph { - let dependencyRoot = try await dumpDependencies() - + let dependencyRoot = try Self.decoder.decode( + PackageDependency.self, from: try await inspection.dependencies(buildSettings) + ) let packages = try await withThrowingTaskGroup( - of: (PackageDependency, PackageDump).self, - returning: [String: PackageDump].self + of: (String, InspectedPackage).self, returning: [String: InspectedPackage].self ) { group in var visited: Set = [] - var dependencies: [PackageDependency] = [dependencyRoot] - while let dependencyNode = dependencies.popLast() { - guard visited.insert(dependencyNode.identity).inserted else { continue } - dependencies.append(contentsOf: dependencyNode.dependencies) - group.addTask { (dependencyNode, try await dumpPackage(at: dependencyNode.path)) } - } - - var packages: [String: PackageDump] = [:] - while let result = await group.nextResult() { - switch result { - case .success((let dependency, let dump)): - packages[dependency.identity] = dump - case .failure(_ as CancellationError): - // continue loop - break - case .failure(let error): - group.cancelAll() - throw error + var dependencies = [dependencyRoot] + while let node = dependencies.popLast() { + guard visited.insert(node.identity).inserted else { continue } + dependencies.append(contentsOf: node.dependencies) + group.addTask { + let settings = buildSettings.withPackagePath(node.path) + async let description = inspection.describe(settings) + async let manifest = inspection.manifest(settings) + return try await (node.identity, InspectedPackage( + description: Self.decoder.decode(PackageDump.self, from: jsonContents(description)), + manifest: Self.decoder.decode(PackageManifest.self, from: jsonContents(manifest)) + )) } } - + var packages: [String: InspectedPackage] = [:] + for try await (identity, package) in group { packages[identity] = package } return packages } - - var packagesByProductName: [String: String] = [:] - for (packageID, package) in packages { - for product in package.products ?? [] { - packagesByProductName[product.name] = packageID - } + guard packages[dependencyRoot.identity] != nil else { + throw StringError("Could not inspect root package '\(dependencyRoot.identity)'") } - - let rootPackage = packages[dependencyRoot.identity]! - - return PackageGraph( - root: rootPackage, - packages: packages, - packagesByProductName: packagesByProductName - ) + return PackageGraph(rootIdentity: dependencyRoot.identity, packages: packages) } public func createPlan() async throws -> Plan { - // TODO: cache plan using (Package.swift+Package.resolved) as the key? - - // Validate the selected platform before invoking SwiftPM. The CLI supplies - // schema/override precedence through BuildSettings in the command integration. - let selectedTriple = try TargetTriple(buildSettings.triple) - try schema.validate(for: selectedTriple.platform) - guard selectedTriple.platform != .macOS else { - throw StringError("macOS product planning is not implemented yet") - } + let platform = try TargetTriple(buildSettings.triple).platform + try schema.validate(for: platform) guard schema.target == nil else { throw StringError("Schema target selection is not integrated with the build command yet; use --triple") } - guard schema.source == nil, schema.resourceCopies == nil, schema.nestedProducts == nil, - !(schema.extensions ?? []).contains(where: { $0.source != nil || $0.resourceCopies != nil }) else { - throw StringError("Version 2 product sources and resource destinations are not implemented yet") - } let graph = try await buildGraph() let target = try ResolvedTarget.resolve( - cliTarget: buildSettings.triple, - schema: schema, + cliTarget: buildSettings.triple, schema: schema, packageDeploymentTargets: Dictionary( - (graph.root.platforms ?? []).map { ($0.name, $0.version) }, + (graph.root.description.platforms ?? []).map { ($0.name, $0.version) }, uniquingKeysWith: { first, _ in first } ) ) - - let app = try await product( - from: graph, - target: target, - matching: schema.product, - type: .application, - plist: schema.infoPath, - idSpecifier: schema.idSpecifier, - iconPath: schema.iconPath, - rootResources: schema.resources, - entitlementsPath: schema.entitlementsPath - ) - - let extensionProducts: [Plan.Product] - if let extensions = schema.extensions, !extensions.isEmpty { - extensionProducts = try await withThrowingTaskGroup(of: Plan.Product.self) { group in - for ext in extensions { - group.addTask { - try await product( - from: graph, - target: target, - matching: ext.product, - type: .appExtension, - plist: ext.infoPath, - idSpecifier: ext.bundleID.flatMap(PackSchema.IDSpecifier.bundleID) ?? .orgID(app.bundleID), - iconPath: nil, - rootResources: ext.resources, - entitlementsPath: ext.entitlementsPath - ) - } - } - return try await group.reduce(into: []) { $0.append($1) } - } - } else { - extensionProducts = [] + let app = try await product(from: graph, target: target, request: ProductRequest( + name: schema.product, source: schema.source, type: .application, + plist: schema.infoPath, idSpecifier: schema.idSpecifier, iconPath: schema.iconPath, + rootResources: schema.resources, resourceCopies: schema.resourceCopies, entitlementsPath: schema.entitlementsPath + )) + var extensions: [Plan.Product] = [] + for ext in schema.extensions ?? [] { + extensions.append(try await product(from: graph, target: target, request: ProductRequest( + name: ext.product, source: ext.source, type: .appExtension, + plist: ext.infoPath, idSpecifier: ext.bundleID.map(PackSchema.IDSpecifier.bundleID) ?? .orgID(app.bundleID), + rootResources: ext.resources, resourceCopies: ext.resourceCopies, entitlementsPath: ext.entitlementsPath + ))) + } + var nested: [Plan.Product] = [] + for item in schema.nestedProducts ?? [] { + nested.append(try await product(from: graph, target: target, request: ProductRequest( + name: item.product, source: item.source, type: item.kind, + plist: item.infoPath, idSpecifier: item.bundleID.map(PackSchema.IDSpecifier.bundleID) ?? .orgID(app.bundleID), + resourceCopies: item.resourceCopies, entitlementsPath: item.entitlementsPath + ))) } + return try Plan(target: target, app: app, extensions: extensions, nestedProducts: nested) + } - return try Plan(target: target, app: app, extensions: extensionProducts) + private struct ProductRequest { + var name: String? + var source: ProductSource? + var type: Plan.ProductType + var plist: String? + var idSpecifier: PackSchema.IDSpecifier + var iconPath: String? + var rootResources: [String]? + var resourceCopies: [ResourceCopy]? + var entitlementsPath: String? } - // swiftlint:disable cyclomatic_complexity function_parameter_count private func product( - from graph: PackageGraph, - target: ResolvedTarget, - matching name: String?, - type: Plan.ProductType, - plist: String?, - idSpecifier: PackSchema.IDSpecifier, - iconPath: String?, - rootResources: [String]?, - entitlementsPath: String? + from graph: PackageGraph, target: ResolvedTarget, request: ProductRequest ) async throws -> Plan.Product { - let library = try selectLibrary( - from: graph.root.products?.filter { $0.type == .autoLibrary } ?? [], - matching: name - ) - var resources: [Plan.Resource] = [] - var visited: Set = [] - var targets = library.targets.map { (graph.root, $0) } - while let (targetPackage, targetName) = targets.popLast() { - guard let target = targetPackage.targets?.first(where: { $0.name == targetName }) else { - throw StringError("Could not find target '\(targetName)' in package '\(targetPackage.name)'") - } - guard visited.insert(targetName).inserted else { continue } - // Macro targets are compiler plugins that run on the host, not code - // linked into the iOS app. Skip them to avoid resolving their - // host-only dependencies (e.g. SwiftSyntaxMacros). - if target.type == "macro" { continue } - if target.moduleType == "BinaryTarget" { - resources.append(.binaryTarget(name: targetName)) - } - if target.resources?.isEmpty == false { - resources.append(.bundle(package: targetPackage.name, target: targetName)) - } - for targetName in (target.targetDependencies ?? []) { - targets.append((targetPackage, targetName)) - } - for productName in (target.productDependencies ?? []) { - let (package, product) = try graph.product(name: productName) - if product.type == .dynamicLibrary { - resources.append(.library(name: productName)) - } - targets.append(contentsOf: product.targets.map { (package, $0) }) + let selection = try selectProduct(from: graph.root.description.products ?? [], request: request) + let name = request.name ?? selection.name + try validateProductName(name) + var resources = try dependencyResources(from: graph, target: target, product: selection.product) + resources += (request.rootResources ?? []).map { .root(source: $0) } + resources += (request.resourceCopies ?? []).map { .copy($0) } + var info = defaultInfo(name: name, type: request.type, target: target, idSpecifier: request.idSpecifier) + if let plist = request.plist { + let data = try await Data(reading: URL(fileURLWithPath: plist)) + guard let overrides = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Sendable] else { + throw StringError("Info.plist has invalid format: expected a dictionary.") } + info.merge(overrides, uniquingKeysWith: { $1 }) } - - if let rootResources { - resources += rootResources.map { .root(source: $0) } + guard let bundleID = info["CFBundleIdentifier"] as? String, !bundleID.isEmpty else { + throw StringError("Info.plist CFBundleIdentifier must be a nonempty string") } + return Plan.Product( + target: target, source: selection.source, type: request.type, product: name, bundleID: bundleID, + infoPlist: info, resources: resources, iconPath: request.iconPath, entitlementsPath: request.entitlementsPath + ) + } - let bundleID = idSpecifier.formBundleID(product: library.name) - let deploymentTarget = target.deploymentTarget - - var infoPlist: [String: Sendable] = [ + private func defaultInfo( + name: String, type: Plan.ProductType, target: ResolvedTarget, idSpecifier: PackSchema.IDSpecifier + ) -> [String: Sendable] { + var info: [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, + "CFBundleIdentifier": idSpecifier.formBundleID(product: name), + "CFBundleName": name, + "CFBundleExecutable": name, + "CFBundleDisplayName": name, "CFBundlePackageType": type.fourCharCode, ] - - switch type { - case .application: - infoPlist["UIDeviceFamily"] = [1, 2] - infoPlist["UISupportedInterfaceOrientations"] = ["UIInterfaceOrientationPortrait"] - infoPlist["UISupportedInterfaceOrientations~ipad"] = [ - "UIInterfaceOrientationPortrait", - "UIInterfaceOrientationPortraitUpsideDown", - "UIInterfaceOrientationLandscapeLeft", - "UIInterfaceOrientationLandscapeRight", + let deploymentKey = target.platform == .macOS ? "LSMinimumSystemVersion" : "MinimumOSVersion" + info[deploymentKey] = target.deploymentTarget + if type == .appExtension { info["NSExtension"] = [:] as [String: Sendable] } + if type == .application && target.platform != .macOS { + info["UIDeviceFamily"] = [1, 2] + info["UISupportedInterfaceOrientations"] = ["UIInterfaceOrientationPortrait"] + info["UISupportedInterfaceOrientations~ipad"] = [ + "UIInterfaceOrientationPortrait", "UIInterfaceOrientationPortraitUpsideDown", + "UIInterfaceOrientationLandscapeLeft", "UIInterfaceOrientationLandscapeRight", ] - infoPlist["UILaunchScreen"] = [:] as [String: Sendable] - case .xpcService, .helperExecutable: - throw StringError("macOS nested product planning is not implemented yet") - case .appExtension: - // Should set default parameters? - infoPlist["NSExtension"] = [:] as [String: Sendable] + info["UILaunchScreen"] = [:] as [String: Sendable] } + return info + } - if let plist { - let data = try await Data(reading: URL(fileURLWithPath: plist)) - let info = try PropertyListSerialization.propertyList(from: data, format: nil) - if let info = info as? [String: Sendable] { - infoPlist.merge(info, uniquingKeysWith: { $1 }) - } else { - throw StringError("Info.plist has invalid format: expected a dictionary.") + private struct Selection { + let name: String + let source: ProductSource + let product: PackageDump.Product? + } + + private func selectProduct(from products: [PackageDump.Product], request: ProductRequest) throws -> Selection { + let selectedName: String? + switch request.source { + case .prebuilt(let path): + return try selectPrebuilt(path: path, name: request.name) + case .library(let name), .executable(let name): + try validateProductName(name) + selectedName = name + case nil: selectedName = request.name + } + let product: PackageDump.Product + if let selectedName { + guard let match = products.first(where: { $0.name == selectedName }) else { + throw StringError("No product named '\(selectedName)' was found. Found: \(products.map(\.name).sorted())") } + product = match + } else { + // Version 1's implicit selection remains limited to automatic libraries. + let libraries = products.filter { $0.type == .autoLibrary } + guard libraries.count == 1 else { + throw StringError("Expected one automatic library product; specify 'product' or 'source' explicitly. " + + "Found: \(libraries.map(\.name).sorted())") + } + product = libraries[0] } - - return Plan.Product( - target: target, - type: type, - product: library.name, - bundleID: bundleID, - infoPlist: infoPlist, - resources: resources, - iconPath: iconPath, - entitlementsPath: entitlementsPath - ) + try validateProductName(product.name) + let source: ProductSource + switch product.type { + case .executable: source = .executable(product: product.name) + case .autoLibrary, .staticLibrary, .dynamicLibrary: source = .library(product: product.name) + case .other: throw StringError("Unsupported product type for '\(product.name)'") + } + if let requested = request.source, requested != source { + throw StringError("Product '\(product.name)' does not match the requested source kind") + } + guard !product.targets.isEmpty else { throw StringError("Product '\(product.name)' has no targets") } + return Selection(name: product.name, source: source, product: product) } - private func dumpDependencies() async throws -> PackageDependency { - let tempDir = try TemporaryDirectory(name: "xtool-dump") - let tempFileURL = tempDir.url.appendingPathComponent("dump.json") - - // SwiftPM sometimes prints extraneous data to stdout, so ask - // it to write the JSON to a temp file instead. See: - // https://github.com/xtool-org/xtool/pull/97#discussion_r2203618825 - _ = try await _dumpAction( - arguments: ["-q", "show-dependencies", "--format", "json", "-o", tempFileURL.path], - path: buildSettings.packagePath - ) - - return try Self.decoder.decode( - PackageDependency.self, - from: Data(contentsOf: tempFileURL) - ) + private func selectPrebuilt(path: String, name: String?) throws -> Selection { + try ProductSource.prebuilt(path: path).validate() + guard !path.contains("\0"), !path.hasSuffix("/") else { + throw StringError("Prebuilt source must identify a file") + } + let name = name ?? URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent + try validateProductName(name) + return Selection(name: name, source: .prebuilt(path: path), product: nil) } - private func dumpPackage(at path: String) async throws -> PackageDump { - let data = try await _dumpAction(arguments: ["-q", "describe", "--type", "json"], path: path) - try Task.checkCancellation() - - // As in dumpDependencies, we may end up with extraneous data, but `describe` - // doesn't have a `-o` flag for a clean workaround. Resort to a heuristic, - // looking for the opening brace. - let fromBrace = data.drop(while: { $0 != Character("{").asciiValue }) - return try Self.decoder.decode(PackageDump.self, from: fromBrace) + private func validateProductName(_ name: String) throws { + try ResourceDestination.validateRelativePath(name) + guard !name.contains("/") else { throw StringError("Product name must be a single path component") } } - private func _dumpAction(arguments: [String], path: String) async throws -> Data { - let dumpConfig = try await buildSettings.withPackagePath(path).swiftPMInvocation( - forTool: "package", - arguments: arguments, - ) - return try await Subprocess.run( - dumpConfig, - output: .data(limit: .max), - error: .currentStandardError, - ) - .checkSuccess() - .standardOutput + private struct TargetIdentity: Hashable { + let package: String + let name: String } - private func selectLibrary( - from products: [PackageDump.Product], - matching name: String? - ) throws -> PackageDump.Product { - switch products.count { - case 0: - throw StringError("No library products were found in the package") - case 1: - let product = products[0] - if let name, product.name != name { - throw StringError(""" - Product name ('\(product.name)') does not match the 'product' value in the schema ('\(name)') - """) + private func dependencyResources( + from graph: PackageGraph, target: ResolvedTarget, product: PackageDump.Product? + ) throws -> [Plan.Resource] { + guard let product else { return [] } + var pending = product.targets.map { TargetIdentity(package: graph.rootIdentity, name: $0) } + var visited: Set = [] + var resources: [Plan.Resource] = product.type == .dynamicLibrary ? [.library(name: product.name)] : [] + var seenResources = Set(resources) + func append(_ resource: Plan.Resource) { + if seenResources.insert(resource).inserted { resources.append(resource) } + } + while let identity = pending.popLast() { + guard visited.insert(identity).inserted else { continue } + guard let package = graph.packages[identity.package], + let described = package.description.targets?.first(where: { $0.name == identity.name }), + let manifest = package.manifest.targets.first(where: { $0.name == identity.name }) else { + throw StringError("Could not find target '\(identity.name)' in package '\(identity.package)'") } - return product - default: - guard let name else { - throw StringError(""" - Multiple library products were found (\(products.map(\.name))). Please either: - 1) Expose exactly one library product, or - 2) Specify the product you want via the 'product' key in xtool.yml. - """) + // Compiler plugins and their dependencies run on the host, never in the bundle. + if described.type == "macro" || ["macro", "plugin"].contains(manifest.type) { continue } + if described.moduleType == "BinaryTarget" { append(.binaryTarget(name: identity.name)) } + if described.resources?.isEmpty == false { + append(.bundle(package: package.description.name, target: identity.name)) } - guard let product = products.first(where: { $0.name == name }) else { - throw StringError(""" - Schema declares a 'product' name of '\(name)' but no matching product was found. - Found: \(products.map(\.name)). - """) + for dependency in manifest.dependencies where dependency.matches(target.platform) { + switch dependency.kind { + case .target: + pending.append(TargetIdentity(package: identity.package, name: dependency.name)) + case .byName where package.description.targets?.contains(where: { $0.name == dependency.name }) == true: + pending.append(TargetIdentity(package: identity.package, name: dependency.name)) + case .byName, .product: + let (packageID, dependencyProduct) = try graph.product( + name: dependency.name, package: dependency.package, from: identity.package + ) + if dependencyProduct.type == .dynamicLibrary { append(.library(name: dependencyProduct.name)) } + pending += dependencyProduct.targets.map { TargetIdentity(package: packageID, name: $0) } + } } - return product } + return resources } } +private func jsonContents(_ data: Data) -> Data { + // SwiftPM can print status messages before its JSON output. + Data(data.drop(while: { $0 != Character("{").asciiValue })) +} + private struct PackageDependency: Decodable { let identity: String - let name: String - let path: String // on disk + let path: String let dependencies: [PackageDependency] } -private struct PackageDump: Decodable { - enum ProductType: Decodable { - case executable - case dynamicLibrary - case staticLibrary - case autoLibrary - case other - - private enum CodingKeys: String, CodingKey { - case executable - case library - } +private struct PackageDump: Decodable, Sendable { + enum ProductType: Decodable, Sendable { + case executable, dynamicLibrary, staticLibrary, autoLibrary, other + + private enum CodingKeys: String, CodingKey { case executable, library } init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - if container.contains(.executable) { + let values = try decoder.container(keyedBy: CodingKeys.self) + if values.contains(.executable) { self = .executable - } else if let library = try container.decodeIfPresent([String].self, forKey: .library) { - if library.count == 1 { - switch library[0] { - case "dynamic": - self = .dynamicLibrary - case "static": - self = .staticLibrary - case "automatic": - self = .autoLibrary - default: - self = .other - } - } else { - self = .other + } else if let library = try values.decodeIfPresent([String].self, forKey: .library), library.count == 1 { + switch library[0] { + case "dynamic": self = .dynamicLibrary + case "static": self = .staticLibrary + case "automatic": self = .autoLibrary + default: self = .other } } else { self = .other @@ -362,26 +299,21 @@ private struct PackageDump: Decodable { } } - struct Product: Decodable { + struct Product: Decodable, Sendable { let name: String let targets: [String] let type: ProductType } - struct Target: Decodable { + struct Target: Decodable, Sendable { let name: String let moduleType: String let type: String? - let productDependencies: [String]? - let targetDependencies: [String]? let resources: [Resource]? } - struct Resource: Decodable { - let path: String - } - - struct Platform: Decodable { + struct Resource: Decodable, Sendable { let path: String } + struct Platform: Decodable, Sendable { let name: String let version: String } @@ -392,21 +324,133 @@ private struct PackageDump: Decodable { let platforms: [Platform]? } -private struct PackageGraph { - let root: PackageDump - let packages: [String: PackageDump] - let packagesByProductName: [String: String] +/// `describe` supplies resource bundle names, but only `dump-package` preserves +/// dependency conditions and package qualifiers. Both describe the same package. +private struct PackageManifest: Decodable, Sendable { + struct Target: Decodable, Sendable { + let name: String + let type: String + let dependencies: [Dependency] + } + + struct Dependency: Decodable, Sendable { + enum Kind: String, CodingKey, Sendable { case target, product, byName } + struct Condition: Decodable, Sendable { let platformNames: [String]? } + let kind: Kind + let name: String + let package: String? + let condition: Condition? + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: Kind.self) + guard let kind = values.allKeys.first, values.allKeys.count == 1 else { + throw StringError("Unsupported SwiftPM target dependency") + } + self.kind = kind + var fields = try values.nestedUnkeyedContainer(forKey: kind) + name = try fields.decode(String.self) + if kind == .product { + package = try fields.decodeIfPresent(String.self) + // Module aliases affect Swift import names, not product/resource identity. + _ = try fields.decodeIfPresent([String: String].self) + } else { + package = nil + } + condition = try fields.decodeIfPresent(Condition.self) + } - func product(name productName: String) throws -> (PackageDump, PackageDump.Product) { - guard let packageID = packagesByProductName[productName] else { - throw StringError("Could not find package containing product '\(productName)'") + func matches(_ platform: TargetPlatform) -> Bool { + guard let names = condition?.platformNames, !names.isEmpty else { return true } + return names.contains(platform.packagePlatformName) } - guard let package = packages[packageID] else { - throw StringError("Could not find package by id '\(packageID)'") + } + + struct PackageReference: Decodable, Sendable { + struct Reference: Decodable, Sendable { + let identity: String + let nameForTargetDependencyResolutionOnly: String? + } + let reference: Reference + + private enum CodingKeys: String, CodingKey { case sourceControl, fileSystem, registry } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + guard let kind = values.allKeys.first, + let reference = try values.decode([Reference].self, forKey: kind).first else { + throw StringError("Unsupported SwiftPM package dependency") + } + self.reference = reference + } + } + + let targets: [Target] + let dependencies: [PackageReference] +} + +private struct InspectedPackage: Sendable { + let description: PackageDump + let manifest: PackageManifest +} + +private struct PackageGraph { + let rootIdentity: String + let packages: [String: InspectedPackage] + var root: InspectedPackage { packages[rootIdentity]! } + + func product(name: String, package qualifier: String?, from identity: String) throws -> (String, PackageDump.Product) { + guard let requester = packages[identity] else { throw StringError("Missing package '\(identity)'") } + let references = requester.manifest.dependencies.map(\.reference) + let candidates: [String] + if let qualifier { + candidates = references.filter { + ($0.nameForTargetDependencyResolutionOnly ?? $0.identity).lowercased() == qualifier.lowercased() + }.map(\.identity) + } else { + candidates = [identity] + references.map(\.identity) + } + let matches = candidates.compactMap { identity -> (String, PackageDump.Product)? in + guard let product = packages[identity]?.description.products?.first(where: { $0.name == name }) else { return nil } + return (identity, product) } - guard let product = package.products?.first(where: { $0.name == productName }) else { - throw StringError("Could not find product '\(productName)' in package '\(packageID)'") + guard matches.count == 1, let match = matches.first else { + throw StringError("Could not uniquely resolve product '\(name)' in package '\(identity)' " + + "(qualifier: \(qualifier ?? "none"))") } - return (package, product) + return match + } +} + +/// Inject package inspection without bypassing decoding, graph resolution or planning. +struct PackageInspectionClient: Sendable { + var dependencies: @Sendable (BuildSettings) async throws -> Data + var describe: @Sendable (BuildSettings) async throws -> Data + var manifest: @Sendable (BuildSettings) async throws -> Data + + private static func run(_ settings: BuildSettings, arguments: [String]) async throws -> Data { + let invocation = try await settings.swiftPMInvocation(forTool: "package", arguments: arguments) + return try await Subprocess.run(invocation, output: .data(limit: .max), error: .currentStandardError) + .checkSuccess().standardOutput + } +} + +extension PackageInspectionClient: DependencyKey { + static let liveValue = Self( + dependencies: { settings in + let directory = try TemporaryDirectory(name: "xtool-dump") + let output = directory.url.appendingPathComponent("dump.json") + // Unlike describe/dump-package, show-dependencies can write clean JSON directly. + _ = try await run(settings, arguments: ["-q", "show-dependencies", "--format", "json", "-o", output.path]) + return try Data(contentsOf: output) + }, + describe: { try await run($0, arguments: ["-q", "describe", "--type", "json"]) }, + manifest: { try await run($0, arguments: ["-q", "dump-package"]) } + ) +} + +extension DependencyValues { + var packageInspection: PackageInspectionClient { + get { self[PackageInspectionClient.self] } + set { self[PackageInspectionClient.self] = newValue } } } diff --git a/Tests/XToolTests/PlannerTests.swift b/Tests/XToolTests/PlannerTests.swift new file mode 100644 index 00000000..922be5bd --- /dev/null +++ b/Tests/XToolTests/PlannerTests.swift @@ -0,0 +1,328 @@ +import Dependencies +import Foundation +import Testing +import Yams +@testable import PackLib + +struct PlannerTests { + private struct Package: Sendable { + let description: Data + let manifest: Data + } + + private func json(_ value: Any) throws -> Data { try JSONSerialization.data(withJSONObject: value) } + + private func package( + _ name: String = "Root", products: [[String: Any]]? = nil, targets: [[String: Any]]? = nil, + dependencies: [[String: Any]] = [], platforms: [[String: String]] = [] + ) throws -> Package { + let targets = targets ?? [target("App")] + return try Package( + description: json([ + "name": name, "products": products ?? [product("App")], + "platforms": platforms, "targets": targets.map { target -> [String: Any] in + var described = target + described.removeValue(forKey: "dependencies") + return described + }, + ]), + manifest: json(["targets": targets, "dependencies": dependencies]) + ) + } + + private func product(_ name: String, kind: String = "automatic", target: String? = nil) -> [String: Any] { + ["name": name, "targets": [target ?? name], + "type": kind == "executable" ? ["executable": NSNull()] : ["library": [kind]]] + } + + private func target( + _ name: String, kind: String = "regular", resources: Bool = false, dependencies: [[String: Any]] = [] + ) -> [String: Any] { + ["name": name, "type": kind, "module_type": kind == "binary" ? "BinaryTarget" : "SwiftTarget", + "resources": resources ? [["path": "message.txt"]] : [], "dependencies": dependencies] + } + + private func reference(_ identity: String, alias: String? = nil) -> [String: Any] { + var value = ["identity": identity] + value["nameForTargetDependencyResolutionOnly"] = alias + return ["fileSystem": [value]] + } + + private func dependency(_ name: String, package: String? = nil, platforms: [String]? = nil) -> [String: Any] { + let condition: Any = platforms.map { ["platformNames": $0] } ?? NSNull() + return ["product": [name, package as Any? ?? NSNull(), NSNull(), condition]] + } + + private func configuration(_ text: String = "") throws -> PackSchema { + try PackSchema(validating: YAMLDecoder().decode(PackSchemaBase.self, from: "version: 2\norgID: test\n" + text)) + } + + private func plan( + _ packages: [String: Package], schema: PackSchema = .default, triple: String = "arm64-apple-ios", + clientOverride: PackageInspectionClient? = nil + ) async throws -> Plan { + let dependencyTree = try json([ + "identity": "root", "path": "/root", "dependencies": packages.keys.filter { $0 != "root" }.map { + ["identity": $0, "path": "/\($0)", "dependencies": []] as [String: Any] + }, + ]) + let client = PackageInspectionClient( + dependencies: { _ in dependencyTree }, + describe: { settings in try #require(packages[URL(fileURLWithPath: settings.packagePath).lastPathComponent]).description }, + manifest: { settings in try #require(packages[URL(fileURLWithPath: settings.packagePath).lastPathComponent]).manifest } + ) + let settings = try await BuildSettings(configuration: .release, triple: triple, buildSystem: .swiftPM, packagePath: "/root") + return try await withDependencies { + $0.packageInspection = clientOverride ?? client + } operation: { + try await Planner(buildSettings: settings, schema: schema).createPlan() + } + } + + @Test func sameNamedTargetsInDifferentPackagesBothContributeResources() async throws { + let root = try package(targets: [target("App", dependencies: [ + dependency("First", package: "one"), dependency("Second", package: "two"), + ])], dependencies: [reference("one"), reference("two")]) + let one = try package("FirstPackage", products: [product("First", target: "Shared")], + targets: [target("Shared", resources: true)]) + let two = try package("SecondPackage", products: [product("Second", target: "Shared")], + targets: [target("Shared", resources: true)]) + let app = try await plan(["root": root, "one": one, "two": two]).app + #expect(Set(app.resources) == [.bundle(package: "FirstPackage", target: "Shared"), + .bundle(package: "SecondPackage", target: "Shared")]) + } + + @Test(arguments: ["arm64-apple-macosx", "arm64-apple-ios", "x86_64-apple-ios-simulator"]) + func conditionsUseTargetRatherThanHost(triple: String) async throws { + let root = try package(targets: [target("App", dependencies: [ + dependency("MacLibrary", package: "mac", platforms: ["macos"]), + dependency("IOSLibrary", package: "ios", platforms: ["ios"]), + dependency("MissingHostLibrary", package: "absent", platforms: ["linux"]), + ["target": ["MacLocal", ["platformNames": ["macos"]]]], + ["byName": ["IOSLocal", ["platformNames": ["ios"]]]], + ]), target("MacLocal", resources: true), target("IOSLocal", resources: true)], + dependencies: [reference("mac"), reference("ios")]) + let mac = try package("Mac", products: [product("MacLibrary", kind: "dynamic")], + targets: [target("MacLibrary", resources: true)]) + let ios = try package("IOS", products: [product("IOSLibrary", kind: "static")], + targets: [target("IOSLibrary", resources: true)]) + let app = try await plan(["root": root, "mac": mac, "ios": ios], triple: triple).app + if app.platform == .macOS { + #expect(Set(app.resources) == [.bundle(package: "Root", target: "MacLocal"), + .bundle(package: "Mac", target: "MacLibrary"), .library(name: "MacLibrary")]) + } else { + #expect(Set(app.resources) == [.bundle(package: "Root", target: "IOSLocal"), + .bundle(package: "IOS", target: "IOSLibrary")]) + } + } + + @Test func macroDependenciesNeverContributeResourcesOrResolveHostProducts() async throws { + let root = try package(targets: [target("App", dependencies: [["target": ["Macro", NSNull()]]]), + target("Macro", kind: "macro", resources: true, dependencies: [ + dependency("SwiftSyntaxMacros", package: "host-only"), + ])]) + #expect(try await plan(["root": root]).app.resources.isEmpty) + } + + @Test(arguments: ["automatic", "static", "dynamic", "executable"]) + func explicitProductSelectionResolvesSourceKinds(kind: String) async throws { + let root = try package(products: [product("Selected", kind: kind), product("Other")], + targets: [target("Selected"), target("Other")]) + let app = try await plan(["root": root], schema: configuration("product: Selected")).app + #expect(app.product == "Selected") + #expect(app.resources == (kind == "dynamic" ? [.library(name: "Selected")] : [])) + #expect(app.resolvedSource == (kind == "executable" ? .executable(product: "Selected") : .library(product: "Selected"))) + } + + @Test func explicitSourceAllowsDistinctBundleName() async throws { + let root = try package(products: [product("CLI", kind: "executable")], targets: [target("CLI")]) + let app = try await plan(["root": root], schema: configuration(""" + product: BundleName + source: {kind: executable, product: CLI} + """)).app + #expect(app.product == "BundleName") + #expect(app.resolvedSource == .executable(product: "CLI")) + #expect(app.infoPlist["CFBundleExecutable"] as? String == "BundleName") + } + + @Test func macOSDefaultsUseSelectedPackageDeploymentAndExcludeIOSKeys() async throws { + let root = try package(platforms: [["name": "ios", "version": "19.0"], ["name": "macos", "version": "16.2"]]) + let app = try await plan(["root": root], triple: "arm64-apple-macosx").app + #expect(app.deploymentTarget == "16.2") + #expect(app.infoPlist["LSMinimumSystemVersion"] as? String == "16.2") + for key in ["MinimumOSVersion", "UIDeviceFamily", "UISupportedInterfaceOrientations", "UILaunchScreen"] { + #expect(app.infoPlist[key] == nil) + } + } + + private func plist(_ dictionary: Any, in root: URL, name: String) throws -> String { + let url = root.appendingPathComponent(name + ".plist") + try PropertyListSerialization.data(fromPropertyList: dictionary, format: .xml, options: 0).write(to: url) + return url.path + } + + private func temporaryRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + @Test func legacyIOSLibraryAndPlistOverridesRemainSupported() async throws { + let directory = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: directory) } + let path = try plist(["CFBundleIdentifier": "custom.identity", "CFBundleDisplayName": "Custom Name", + "UIDeviceFamily": [2], "MinimumOSVersion": "17.0", "NSExtension": ["Custom": true]], + in: directory, name: "App") + let schema = try PackSchema(validating: .init(version: .v1, orgID: "test", infoPath: path)) + let app = try await plan(["root": package(platforms: [["name": "ios", "version": "16.0"]])], schema: schema).app + #expect(app.resolvedSource == .library(product: "App")) + #expect(app.bundleID == "custom.identity") + #expect(app.infoPlist["CFBundleDisplayName"] as? String == "Custom Name") + #expect(app.infoPlist["MinimumOSVersion"] as? String == "17.0") + #expect(app.infoPlist["UIDeviceFamily"] as? [Int] == [2]) + #expect(app.infoPlist["NSExtension"] as? [String: Bool] == ["Custom": true]) + #expect(app.infoPlist["UILaunchScreen"] != nil) + } + + @Test func nestedProductsRetainCallerDictionariesAndSources() async throws { + let directory = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: directory) } + let appPlist = try plist(["CFBundleIdentifier": "caller.App"], in: directory, name: "App") + let extPlist = try plist( + ["CFBundleIdentifier": "caller.Extension", "NSExtension": ["NSExtensionPointIdentifier": "test"]], + in: directory, name: "Extension" + ) + let servicePlist = try plist( + ["XPCService": ["ServiceType": "Application", "RunLoopType": "dispatch_main"]], + in: directory, name: "Service" + ) + let schema = try configuration(""" + product: App + infoPath: \(appPlist) + extensions: + - product: Extension + source: {kind: library, product: ExtensionLibrary} + infoPath: \(extPlist) + nestedProducts: + - product: Service + kind: xpcService + source: {kind: executable, product: ServiceExecutable} + infoPath: \(servicePlist) + - product: Helper + kind: helperExecutable + source: {kind: prebuilt, path: /external/helper} + entitlementsPath: /external/helper.entitlements + """) + let root = try package(products: [product("App"), product("ExtensionLibrary"), + product("ServiceExecutable", kind: "executable")], + targets: [target("App"), target("ExtensionLibrary"), target("ServiceExecutable")]) + let plan = try await plan(["root": root], schema: schema, triple: "arm64-apple-macosx") + #expect(plan.extensions[0].bundleID == "caller.Extension") + #expect(plan.extensions[0].infoPlist["NSExtension"] as? [String: String] == ["NSExtensionPointIdentifier": "test"]) + #expect(plan.nestedProducts[0].bundleID == "caller.App.Service") + #expect(plan.nestedProducts[0].infoPlist["XPCService"] as? [String: String] + == ["ServiceType": "Application", "RunLoopType": "dispatch_main"]) + #expect(plan.nestedProducts[1].resolvedSource == .prebuilt(path: "/external/helper")) + #expect(plan.nestedProducts[1].entitlementsPath == "/external/helper.entitlements") + #expect(plan.allProducts.allSatisfy { $0.target == plan.target }) + } + + @Test func resourcesAreDeduplicatedAndExplicitDestinationsSurvive() async throws { + let root = try package(targets: [target("App", resources: true, dependencies: [ + ["target": ["Local", NSNull()]], ["byName": ["Local", NSNull()]], ["target": ["Binary", NSNull()]], + ]), target("Local", resources: true), target("Binary", kind: "binary")]) + let schema = try configuration(""" + resources: [Assets] + resourceCopies: + - source: agent.plist + destination: {location: contents, path: Library/LaunchAgents/agent.plist} + """) + let app = try await plan(["root": root], schema: schema, triple: "arm64-apple-macosx").app + #expect(app.resources.count == 5) + #expect(Set(app.resources) == [.bundle(package: "Root", target: "App"), .bundle(package: "Root", target: "Local"), + .binaryTarget(name: "Binary"), .root(source: "Assets"), + .copy(.init(source: "agent.plist", destination: .init( + location: .contents, path: "Library/LaunchAgents/agent.plist")))]) + #expect(app.resources.contains(.binaryTarget(name: "Binary"))) + } + + @Test func packageAliasesDisambiguateSameNamedProducts() async throws { + let root = try package(targets: [target("App", dependencies: [dependency("Shared", package: "Alias")])], + dependencies: [reference("one", alias: "Alias"), reference("two")]) + let one = try package("One", products: [product("Shared")], targets: [target("Shared", resources: true)]) + let two = try package("Two", products: [product("Shared")], targets: [target("Shared", resources: true)]) + #expect(try await plan(["root": root, "one": one, "two": two]).app.resources == [.bundle(package: "One", target: "Shared")]) + } + + @Test(arguments: ["product: Missing", "source: {kind: executable, product: App}", + "source: {kind: library, product: Missing}", "source: {kind: executable, product: ../escape}"]) + func invalidProductSelectionsReject(text: String) async throws { + await #expect(throws: (any Error).self) { + try await plan(["root": package()], schema: configuration(text)) + } + } + + @Test func ambiguousLibrariesMissingTargetsAndWrongPlistTypesReject() async throws { + let ambiguous = try package(products: [product("App"), product("Other")], targets: [target("App"), target("Other")]) + await #expect(throws: (any Error).self) { try await plan(["root": ambiguous]) } + let missing = try package(targets: []) + await #expect(throws: (any Error).self) { try await plan(["root": missing]) } + let directory = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: directory) } + let path = try plist(["not a dictionary"], in: directory, name: "Invalid") + await #expect(throws: (any Error).self) { + try await plan(["root": package()], schema: configuration("infoPath: \(path)")) + } + } + + #if os(macOS) + @Test func liveSwiftPMInspectionPreservesConditionsAndAliases() async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + func write(_ contents: String, path: String) throws { + let url = root.appendingPathComponent(path) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(contents.utf8).write(to: url) + } + try write(""" + // swift-tools-version: 6.0 + import PackageDescription + let package = Package( + name: "LivePlanner", platforms: [.macOS("16.0"), .iOS("19.0")], + products: [.executable(name: "CLI", targets: ["Entry"])], + dependencies: [.package(name: "SupportAlias", path: "Dependency")], + targets: [ + .executableTarget(name: "Entry", dependencies: [ + .target(name: "IOSOnly", condition: .when(platforms: [.iOS])), + .product(name: "SupportLibrary", package: "SupportAlias", condition: .when(platforms: [.macOS])), + ]), + .target(name: "IOSOnly", resources: [.copy("message.txt")]), + ] + ) + """, path: "Package.swift") + try write("print(\"fixture\")", path: "Sources/Entry/main.swift") + try write("public struct IOSOnly {}", path: "Sources/IOSOnly/IOSOnly.swift") + try write("ios resource", path: "Sources/IOSOnly/message.txt") + try write(""" + // swift-tools-version: 6.0 + import PackageDescription + let package = Package(name: "Support", products: [.library(name: "SupportLibrary", targets: ["Support"])], + targets: [.target(name: "Support", resources: [.copy("message.txt")])]) + """, path: "Dependency/Package.swift") + try write("public struct Support {}", path: "Dependency/Sources/Support/Support.swift") + try write("mac resource", path: "Dependency/Sources/Support/message.txt") + let settings = try await BuildSettings(configuration: .release, triple: "arm64-apple-macosx", buildSystem: .swiftPM, + packagePath: root.path) + let schema = try configuration("product: CLI") + let plan = try await withDependencies { + $0.packageInspection = .liveValue + } operation: { + try await Planner(buildSettings: settings, schema: schema).createPlan() + } + #expect(plan.app.resolvedSource == .executable(product: "CLI")) + #expect(plan.app.deploymentTarget == "16.0") + #expect(plan.app.resources == [.bundle(package: "Support", target: "Support")]) + } + #endif + +} From 9219de7d21e18a46baf259585662d94d7a6d9b66 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 13 Oct 2026 10:29:17 +0200 Subject: [PATCH 05/59] packer: assemble macOS product bundles Replace Packer's concurrent iOS copy step with BundleAssembler, which assembles iOS and macOS bundles, including nested app extensions, XPC services and helpers, from the plan and BundleLayout: - Every destination is computed and checked for collisions before anything is written. - Raw asset catalogs, XIBs and storyboards are rejected; supply compiled or prepared resources instead. - An existing CFBundleIconFile in the product's Info.plist is kept. - Resource and icon paths resolve against the package path, and output goes to `/xtool`. - SwiftPM resource bundles are linked next to the executable so the generated `Bundle.module` accessor finds them. Packer no longer rejects macOS targets. --- Sources/PackLib/BundleAssembler.swift | 232 +++++++++++++++ Sources/PackLib/Packer.swift | 142 +-------- Tests/XToolTests/BundleAssemblerTests.swift | 307 ++++++++++++++++++++ 3 files changed, 552 insertions(+), 129 deletions(-) create mode 100644 Sources/PackLib/BundleAssembler.swift create mode 100644 Tests/XToolTests/BundleAssemblerTests.swift diff --git a/Sources/PackLib/BundleAssembler.swift b/Sources/PackLib/BundleAssembler.swift new file mode 100644 index 00000000..ab9de600 --- /dev/null +++ b/Sources/PackLib/BundleAssembler.swift @@ -0,0 +1,232 @@ +import Foundation +import XUtils + +/// Stages completed artifacts without compiling, provisioning, signing, or publishing. +public struct BundleAssembler: Sendable { + public let plan: Plan + public let sourceDirectory: URL + + public init(plan: Plan, sourceDirectory: URL) { + self.plan = plan + self.sourceDirectory = sourceDirectory + } + + /// The destination must be empty. All inputs and destinations are checked before copying. + public func assemble(artifacts: BuildArtifacts, at outputURL: URL) throws { + let assembly = try operations(artifacts: artifacts) + try validate(assembly, outputURL: outputURL) + let manager = FileManager.default + try manager.createDirectory(at: outputURL, withIntermediateDirectories: true) + for path in assembly.directories.sorted() { + try manager.createDirectory(at: outputURL.appendingPathComponent(path), withIntermediateDirectories: true) + } + for operation in assembly.operations { + try Task.checkCancellation() + let destination = outputURL.appendingPathComponent(operation.path) + try manager.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) + switch operation.payload { + case .copy(let source): try manager.copyItem(at: source, to: destination) + case .data(let data): try data.write(to: destination, options: .withoutOverwriting) + case .symlink(let target): try manager.createSymbolicLink(atPath: destination.path, withDestinationPath: target) + } + } + } + + private enum Payload { + case copy(URL) + case data(Data) + case symlink(String) + } + + private struct Operation { + var path: String + var payload: Payload + } + + private struct Assembly { + var directories: Set = [] + var operations: [Operation] = [] + } + + private func operations(artifacts: BuildArtifacts) throws -> Assembly { + var assembly = Assembly() + for product in plan.allProducts { + try validateName(product.product) + let layout = product.layout + for path in [layout.bundlePath, layout.contentsPath, layout.resourcesPath, layout.frameworksPath, + (layout.executablePath as NSString).deletingLastPathComponent] where !path.isEmpty { + assembly.directories.insert(path) + } + // A helper's bundlePath is its executable, not a directory. + if product.type == .helperExecutable { assembly.directories.remove(layout.bundlePath) } + guard let executable = artifacts.executables[product.product], + FileManager.default.isExecutableFile(atPath: executable.path) else { + throw StringError("Missing built executable for product '\(product.product)'") + } + assembly.operations.append(.init(path: layout.executablePath, payload: .copy(executable))) + if let path = layout.infoPlistPath { + assembly.operations.append(.init(path: path, payload: .data(try plist(for: product)))) + } + for resource in product.resources { + try append(resource, product: product, artifacts: artifacts, to: &assembly) + } + if let icon = product.iconPath { + let source = sourceURL(icon) + let path = joined(layout.resourcesPath, source.lastPathComponent) + assembly.operations.append(.init(path: path, payload: .copy(source))) + } + } + return assembly + } + + private func append( + _ resource: Plan.Resource, product: Plan.Product, artifacts: BuildArtifacts, to assembly: inout Assembly + ) throws { + let layout = product.layout + switch resource { + case .bundle(let package, let target): + try validateName(package) + try validateName(target) + let name = "\(package)_\(target).bundle" + let path = joined(layout.resourcesPath, name) + assembly.operations.append(.init(path: path, payload: .copy(artifacts.directory.appendingPathComponent(name)))) + if product.platform == .macOS { + // SwiftPM's generated Bundle.module accessor searches beside Bundle.main.bundleURL. + // Keep one payload in Resources and expose that path through a relocatable relative link. + let aliasBase = product.type == .helperExecutable ? layout.contentsPath : layout.bundlePath + assembly.operations.append(.init(path: joined(aliasBase, name), + payload: .symlink(product.type == .helperExecutable + ? "Resources/\(name)" : "Contents/Resources/\(name)"))) + } + case .binaryTarget(let name): + try validateName(name) + let source = artifacts.directory.appendingPathComponent("\(name).framework") + let binary = source.appendingPathComponent(name) + // iOS SwiftPM omits some linked static binary targets from the output tree. + // Retain that legacy omission; macOS declared inputs must be present and classified. + if product.platform != .macOS && !FileManager.default.fileExists(atPath: binary.path) { return } + try validateInput(source) + guard FileManager.default.fileExists(atPath: binary.path) else { + throw StringError("Framework '\(name)' has no readable binary at \(binary.path)") + } + if try !isArchive(binary) { + assembly.operations.append(.init(path: joined(layout.frameworksPath, source.lastPathComponent), + payload: .copy(source))) + } + case .library(let name): + try validateName(name) + let source = artifacts.directory.appendingPathComponent("lib\(name).dylib") + try validateInput(source) + if try !isArchive(source) { + assembly.operations.append(.init(path: joined(layout.frameworksPath, source.lastPathComponent), + payload: .copy(source))) + } + case .root(let path): + let source = sourceURL(path) + // Legacy root resources retain their bundle-root destination on every platform. + let base = product.type == .helperExecutable ? layout.contentsPath : layout.bundlePath + assembly.operations.append(.init(path: joined(base, source.lastPathComponent), payload: .copy(source))) + case .copy(let copy): + assembly.operations.append(.init(path: try layout.path(for: copy.destination), payload: .copy(sourceURL(copy.source)))) + } + } + + private func plist(for product: Plan.Product) throws -> Data { + var info = product.infoPlist + if product.type == .application && product.platform != .macOS { + info["UIRequiredDeviceCapabilities"] = ["arm64"] + info["LSRequiresIPhoneOS"] = true + info["CFBundleSupportedPlatforms"] = [product.platform.supportedPlatform] + } + if let icon = product.iconPath, info["CFBundleIconFile"] == nil { + info["CFBundleIconFile"] = URL(fileURLWithPath: icon).deletingPathExtension().lastPathComponent + } + return try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) + } + + private func validate(_ assembly: Assembly, outputURL: URL) throws { + let manager = FileManager.default + if manager.fileExists(atPath: outputURL.path) { + guard try manager.contentsOfDirectory(atPath: outputURL.path).isEmpty else { + throw StringError("Bundle staging destination must be empty: \(outputURL.path)") + } + } + let directories = assembly.directories.map(canonicalPath) + var destinations: [String] = [] + for operation in assembly.operations { + try ResourceDestination.validateRelativePath(operation.path) + let path = canonicalPath(operation.path) + guard !directories.contains(where: { $0 == path || $0.hasPrefix(path + "/") }), + !destinations.contains(where: { $0 == path || $0.hasPrefix(path + "/") || path.hasPrefix($0 + "/") }) else { + throw StringError("Bundle destination collision at '\(operation.path)'") + } + destinations.append(path) + if case .copy(let source) = operation.payload { + try validateInput(source) + let sourcePath = source.resolvingSymlinksInPath().path + let outputPath = outputURL.resolvingSymlinksInPath().path + guard outputPath != sourcePath, !outputPath.hasPrefix(sourcePath + "/") else { + throw StringError("Bundle staging destination is inside input '\(source.path)'") + } + } + } + } + + private func validateInput(_ source: URL) throws { + let manager = FileManager.default + guard manager.fileExists(atPath: source.path) else { + throw StringError("Missing bundle input: \(source.path)") + } + // Top-level links to a build-tree payload would break after relocation; require an owned payload. + if try source.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink == true { + throw StringError("Bundle input must be a payload rather than a top-level symbolic link: \(source.path)") + } + try validateEntry(source, root: source) + if try source.resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true { + guard let enumerator = manager.enumerator(at: source, includingPropertiesForKeys: [.isSymbolicLinkKey]) else { + throw StringError("Cannot enumerate bundle input: \(source.path)") + } + while let entry = enumerator.nextObject() as? URL { try validateEntry(entry, root: source) } + } + } + + private func validateEntry(_ entry: URL, root: URL) throws { + let rawAssets = ["xcassets", "iconset", "storyboard", "xib"] + if rawAssets.contains(entry.pathExtension.lowercased()) { + throw StringError("Raw asset '\(entry.path)' requires Apple asset compilation; supply prepared " + + "Assets.car, .icns, or compiled resources using resource-copies instead") + } + if try entry.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink == true { + let link = try FileManager.default.destinationOfSymbolicLink(atPath: entry.path) + let resolved = entry.resolvingSymlinksInPath().standardizedFileURL.path + let rootPath = root.standardizedFileURL.path + guard !link.hasPrefix("/"), resolved.hasPrefix(rootPath + "/"), + FileManager.default.fileExists(atPath: resolved) else { + throw StringError("Broken or escaping symbolic link in bundle input: \(entry.path) -> \(link)") + } + } + } + + private func isArchive(_ source: URL) throws -> Bool { + let handle = try FileHandle(forReadingFrom: source) + defer { try? handle.close() } + let bytes = try handle.read(upToCount: 8) + return bytes == Data("!\n".utf8) || bytes == Data("!\n".utf8) + } + + private func sourceURL(_ path: String) -> URL { + if path.hasPrefix("/") { return URL(fileURLWithPath: path).standardizedFileURL } + return sourceDirectory.appendingPathComponent(path).standardizedFileURL + } + + private func validateName(_ name: String) throws { + try ResourceDestination.validateRelativePath(name) + guard !name.contains("/") else { throw StringError("Bundle input names must not contain path separators: '\(name)'") } + } + + private func canonicalPath(_ path: String) -> String { path.precomposedStringWithCanonicalMapping.lowercased() } + + private func joined(_ base: String, _ path: String) -> String { + [base, path].filter { !$0.isEmpty }.joined(separator: "/") + } +} diff --git a/Sources/PackLib/Packer.swift b/Sources/PackLib/Packer.swift index fdbedef5..2e9de549 100644 --- a/Sources/PackLib/Packer.swift +++ b/Sources/PackLib/Packer.swift @@ -14,141 +14,25 @@ public struct Packer: Sendable { } public func pack() async throws -> URL { - guard plan.target.platform != .macOS else { - throw StringError("macOS bundle assembly is not implemented yet") - } let artifacts = try await productBuilder.build(buildSettings, plan) return try await assemble(artifacts: artifacts) } /// Assembly consumes completed build artifacts and never compiles or signs. public func assemble(artifacts: BuildArtifacts) async throws -> URL { - guard plan.target.platform != .macOS else { - throw StringError("macOS bundle assembly is not implemented yet") - } - let binDir = artifacts.directory - + try ResourceDestination.validateRelativePath(plan.app.product) + guard !plan.app.product.contains("/") else { throw StringError("Application name must not contain path separators") } let output = try TemporaryDirectory(name: "\(plan.app.product).app") - - let outputURL = output.url - - try await withThrowingTaskGroup(of: Void.self) { group in - for product in plan.allProducts { - try pack( - product: product, - binDir: binDir, - executable: artifacts.executables[product.product], - outputURL: product.directory(inApp: outputURL), - &group - ) - } - - while !group.isEmpty { - do { - try await group.next() - } catch is CancellationError { - // continue - } catch { - group.cancelAll() - throw error - } - } - } - - 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, - executable: URL?, - outputURL: URL, - _ group: inout ThrowingTaskGroup - ) throws { - @Sendable func packFileToRoot(srcName: String) async throws { - let srcURL = URL(fileURLWithPath: srcName) - let destURL = outputURL.appendingPathComponent(srcURL.lastPathComponent) - try FileManager.default.copyItem(at: srcURL, to: destURL) - - try Task.checkCancellation() - } - - @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) - try? FileManager.default.createDirectory(at: dstURL.deletingLastPathComponent(), withIntermediateDirectories: true) - try FileManager.default.copyItem(at: srcURL, to: dstURL) - - try Task.checkCancellation() - } - - // Ensure output directory is available - try? FileManager.default.createDirectory(at: outputURL, withIntermediateDirectories: true) - - for command in product.resources { - group.addTask { - switch command { - case .bundle(let package, let target): - try await packFile(srcName: "\(package)_\(target).bundle") - case .binaryTarget(let name): - let src = URL(fileURLWithPath: "\(name).framework/\(name)", relativeTo: binDir) - let magic = Data("!\n".utf8) - let thinMagic = Data("!\n".utf8) - guard let bytes = try? FileHandle(forReadingFrom: src).read(upToCount: magic.count) else { - // if we can't find the binary, it might be a static framework that SwiftPM - // did not copy into the .build directory. we don't need to pack it anyway. - break - } - // if the magic matches one of these it's a static archive; don't embed it. - // https://github.com/apple/llvm-project/blob/e716ff14c46490d2da6b240806c04e2beef01f40/llvm/include/llvm/Object/Archive.h#L33 - // swiftlint:disable:previous line_length - if bytes != magic && bytes != thinMagic { - try await packFile(srcName: "\(name).framework", dstName: "Frameworks/\(name).framework", sign: true) - } - case .library(let name): - try await packFile(srcName: "lib\(name).dylib", dstName: "Frameworks/lib\(name).dylib", sign: true) - case .copy: - throw StringError("Explicit resource destinations are not implemented yet") - case .root(let source): - try await packFileToRoot(srcName: source) - } - } - } - if let iconPath = product.iconPath { - group.addTask { - try await packFileToRoot(srcName: iconPath) - } - } - group.addTask { - guard let executable else { - throw StringError("No built executable for product '\(product.product)'") - } - try await packFile(srcName: executable.path, dstName: product.product) - } - group.addTask { - var info = product.infoPlist - - if product.type == .application { - info["UIRequiredDeviceCapabilities"] = ["arm64"] - info["LSRequiresIPhoneOS"] = true - info["CFBundleSupportedPlatforms"] = [plan.target.platform.supportedPlatform] - } - - if let iconPath = product.iconPath { - let iconName = URL(fileURLWithPath: iconPath).deletingPathExtension().lastPathComponent - info["CFBundleIconFile"] = iconName - } - - let infoPath = outputURL.appendingPathComponent("Info.plist") - let encodedPlist = try PropertyListSerialization.data( - fromPropertyList: info, - format: .xml, - options: 0 - ) - try encodedPlist.write(to: infoPath) - } + let root = URL(fileURLWithPath: buildSettings.packagePath, isDirectory: true).standardizedFileURL + let assembler = BundleAssembler(plan: plan, sourceDirectory: root) + try assembler.assemble(artifacts: artifacts, at: output.url) + let directory = root.appendingPathComponent("xtool", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let destination = directory.appendingPathComponent(output.url.lastPathComponent) + if FileManager.default.fileExists(atPath: destination.path) { + try FileManager.default.removeItem(at: destination) + } + try output.persist(at: destination) + return destination } } diff --git a/Tests/XToolTests/BundleAssemblerTests.swift b/Tests/XToolTests/BundleAssemblerTests.swift new file mode 100644 index 00000000..d4482bc6 --- /dev/null +++ b/Tests/XToolTests/BundleAssemblerTests.swift @@ -0,0 +1,307 @@ +import Dependencies +import Foundation +import Testing +@testable import PackLib + +struct BundleAssemblerTests { + private func root() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func write(_ text: String = "fixture", at url: URL, executable: Bool = false) throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(text.utf8).write(to: url) + if executable { try FileManager.default.setAttributes([.posixPermissions: 0o751], ofItemAtPath: url.path) } + } + + private func product( + _ name: String = "App", kind: Plan.ProductType = .application, resources: [Plan.Resource] = [] + ) throws -> Plan.Product { + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + return Plan.Product(target: target, type: kind, product: name, bundleID: "test.\(name)", + infoPlist: ["CFBundleExecutable": name, "CFBundleIdentifier": "test.\(name)", + "CallerKey": "preserved"], resources: resources) + } + + private func artifacts(_ plan: Plan, in root: URL) throws -> BuildArtifacts { + let directory = root.appendingPathComponent("build") + var executables: [String: URL] = [:] + for product in plan.allProducts { + let executable = directory.appendingPathComponent(product.targetName) + try write(at: executable, executable: true) + executables[product.product] = executable + } + return BuildArtifacts(directory: directory, executables: executables) + } + + @Test func nestedProductsAndMetadataPreserveInputs() throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let agent = root.appendingPathComponent("agent.plist") + try write("caller launch agent metadata", at: agent) + let destination = ResourceDestination(location: .contents, path: "Library/LaunchAgents/test.plist") + let app = try product(resources: [.copy(.init(source: "agent.plist", destination: destination))]) + let ext = try product("Preview", kind: .appExtension) + let xpc = try product("Service", kind: .xpcService) + let helper = try product("Helper", kind: .helperExecutable) + let plan = try Plan(target: app.target, app: app, extensions: [ext], nestedProducts: [xpc, helper]) + let artifacts = try artifacts(plan, in: root) + let output = root.appendingPathComponent("App.app") + try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: artifacts, at: output) + for product in plan.allProducts { + let executable = output.appendingPathComponent(product.layout.executablePath) + #expect(FileManager.default.isExecutableFile(atPath: executable.path)) + let permissions = try FileManager.default.attributesOfItem(atPath: executable.path)[.posixPermissions] as? NSNumber + #expect(permissions?.intValue == 0o751) + if let path = product.layout.infoPlistPath { + let data = try Data(contentsOf: output.appendingPathComponent(path)) + let info = try #require(PropertyListSerialization.propertyList(from: data, format: nil) as? [String: String]) + #expect(info["CallerKey"] == "preserved") + #expect(info["CFBundleExecutable"] == product.product) + #expect(info["LSRequiresIPhoneOS"] == nil) + } + } + let extPath = "Contents/PlugIns/Preview.appex/Contents/MacOS/Preview" + #expect(FileManager.default.fileExists(atPath: output.appendingPathComponent(extPath).path)) + let xpcPath = "Contents/XPCServices/Service.xpc/Contents/MacOS/Service" + #expect(FileManager.default.fileExists(atPath: output.appendingPathComponent(xpcPath).path)) + #expect(FileManager.default.fileExists(atPath: output.appendingPathComponent("Contents/Helpers/Helper").path)) + let agentCopy = output.appendingPathComponent("Contents/Library/LaunchAgents/test.plist") + #expect(try Data(contentsOf: agentCopy) == Data(contentsOf: agent)) + #expect(!FileManager.default.fileExists(atPath: output.appendingPathComponent("Contents/Helpers/Info.plist").path)) + } + + private func framework(_ name: String, in directory: URL, binary: String = "dynamic fixture") throws -> URL { + let framework = directory.appendingPathComponent("\(name).framework") + try write(binary, at: framework.appendingPathComponent("Versions/A/\(name)"), executable: true) + try write("framework metadata", at: framework.appendingPathComponent("Versions/A/Resources/Info.plist")) + for (path, target) in [("Versions/Current", "A"), (name, "Versions/Current/\(name)"), + ("Resources", "Versions/Current/Resources")] { + try FileManager.default.createSymbolicLink(atPath: framework.appendingPathComponent(path).path, + withDestinationPath: target) + } + return framework + } + + @Test func frameworksStayIntactAndArchivesAreExcluded() throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let app = try product(resources: [.binaryTarget(name: "Dynamic"), .binaryTarget(name: "Static"), + .binaryTarget(name: "Thin"), .library(name: "Shared")]) + let plan = try Plan(target: app.target, app: app) + let artifacts = try artifacts(plan, in: root) + _ = try framework("Dynamic", in: artifacts.directory) + _ = try framework("Static", in: artifacts.directory, binary: "!\narchive fixture") + _ = try framework("Thin", in: artifacts.directory, binary: "!\narchive fixture") + try write("dynamic library", at: artifacts.directory.appendingPathComponent("libShared.dylib")) + let output = root.appendingPathComponent("App.app") + try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: artifacts, at: output) + let copied = output.appendingPathComponent("Contents/Frameworks/Dynamic.framework") + let current = copied.appendingPathComponent("Versions/Current") + #expect(try FileManager.default.destinationOfSymbolicLink(atPath: current.path) == "A") + let binary = copied.appendingPathComponent("Dynamic") + #expect(try FileManager.default.destinationOfSymbolicLink(atPath: binary.path) == "Versions/Current/Dynamic") + #expect(try String(contentsOf: copied.appendingPathComponent("Resources/Info.plist"), encoding: .utf8) == "framework metadata") + #expect(FileManager.default.isExecutableFile(atPath: copied.appendingPathComponent("Dynamic").path)) + #expect(!FileManager.default.fileExists(atPath: output.appendingPathComponent("Contents/Frameworks/Static.framework").path)) + #expect(!FileManager.default.fileExists(atPath: output.appendingPathComponent("Contents/Frameworks/Thin.framework").path)) + #expect(FileManager.default.fileExists(atPath: output.appendingPathComponent("Contents/Frameworks/libShared.dylib").path)) + } + + @Test func preparedAssetsAndIconAreCopied() throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + try write("prepared icon", at: root.appendingPathComponent("Icon.icns")) + try write("prepared assets", at: root.appendingPathComponent("Assets.car")) + var app = try product(resources: [.copy(.init(source: "Assets.car", destination: .init(path: "Assets.car")))]) + app.iconPath = "Icon.icns" + app.infoPlist["CFBundleIconFile"] = "CallerIconName" + let plan = try Plan(target: app.target, app: app) + let artifacts = try artifacts(plan, in: root) + let output = root.appendingPathComponent("App.app") + try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: artifacts, at: output) + let iconCopy = output.appendingPathComponent("Contents/Resources/Icon.icns") + #expect(try String(contentsOf: iconCopy, encoding: .utf8) == "prepared icon") + let assetsCopy = output.appendingPathComponent("Contents/Resources/Assets.car") + #expect(try String(contentsOf: assetsCopy, encoding: .utf8) == "prepared assets") + let info = try #require(PropertyListSerialization.propertyList( + from: Data(contentsOf: output.appendingPathComponent("Contents/Info.plist")), format: nil) as? [String: String]) + #expect(info["CFBundleIconFile"] == "CallerIconName") + } + + @Test(arguments: ["Assets.xcassets", "Icon.iconset", "Window.storyboard", "Window.xib"]) + func rawAssetsRejectWithActionableMessage(name: String) throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + try write(at: root.appendingPathComponent(name)) + let app = try product(resources: [.root(source: name)]) + let plan = try Plan(target: app.target, app: app) + let artifacts = try artifacts(plan, in: root) + do { + try BundleAssembler(plan: plan, sourceDirectory: root) + .assemble(artifacts: artifacts, at: root.appendingPathComponent("App.app")) + Issue.record("Expected raw asset rejection") + } catch { + #expect(String(describing: error).contains("supply prepared Assets.car, .icns")) + } + } + + @Test(arguments: ["../escape", "/absolute", "Contents/../escape", "a//b", "a\\b"]) + func traversalRejectsBeforeWriting(path: String) throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let app = try product(resources: [.copy(.init(source: "input", destination: .init(path: path)))]) + let plan = try Plan(target: app.target, app: app) + let artifacts = try artifacts(plan, in: root) + let output = root.appendingPathComponent("App.app") + #expect(throws: (any Error).self) { + try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: artifacts, at: output) + } + #expect(!FileManager.default.fileExists(atPath: output.path)) + } + + @Test(arguments: ["same", "SAME", "same/child"]) + func duplicateAndAncestorDestinationsReject(path: String) throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + try write(at: root.appendingPathComponent("input")) + let app = try product(resources: ["same", path].map { + .copy(.init(source: "input", destination: .init(path: $0))) + }) + let plan = try Plan(target: app.target, app: app) + let artifacts = try artifacts(plan, in: root) + #expect(throws: (any Error).self) { + try BundleAssembler(plan: plan, sourceDirectory: root) + .assemble(artifacts: artifacts, at: root.appendingPathComponent("App.app")) + } + } + + @Test func resourcesCannotReplaceBundleStructure() throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + try write(at: root.appendingPathComponent("input")) + let app = try product(resources: [.copy(.init(source: "input", destination: .init(location: .bundle, path: "Contents")))]) + let plan = try Plan(target: app.target, app: app) + let artifacts = try artifacts(plan, in: root) + #expect(throws: (any Error).self) { + try BundleAssembler(plan: plan, sourceDirectory: root) + .assemble(artifacts: artifacts, at: root.appendingPathComponent("App.app")) + } + } + + @Test(arguments: [Plan.Resource.root(source: "absent"), .bundle(package: "Missing", target: "Resources"), + .binaryTarget(name: "Missing"), .library(name: "Missing")]) + func missingInputsReject(resource: Plan.Resource) throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let app = try product(resources: [resource]) + let plan = try Plan(target: app.target, app: app) + let artifacts = try artifacts(plan, in: root) + #expect(throws: (any Error).self) { + try BundleAssembler(plan: plan, sourceDirectory: root) + .assemble(artifacts: artifacts, at: root.appendingPathComponent("App.app")) + } + } + + @Test(arguments: ["Missing", "../../../outside", "/tmp/outside"]) + func brokenAndEscapingFrameworkSymlinksReject(target: String) throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let app = try product(resources: [.binaryTarget(name: "Dynamic")]) + let plan = try Plan(target: app.target, app: app) + let artifacts = try artifacts(plan, in: root) + let framework = try framework("Dynamic", in: artifacts.directory) + try FileManager.default.createSymbolicLink(atPath: framework.appendingPathComponent("BadLink").path, + withDestinationPath: target) + #expect(throws: (any Error).self) { + try BundleAssembler(plan: plan, sourceDirectory: root) + .assemble(artifacts: artifacts, at: root.appendingPathComponent("App.app")) + } + } + + @Test func missingExecutableAndUnsafeProductNamesReject() throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + for name in ["App", "../escape"] { + let app = try product(name) + let plan = try Plan(target: app.target, app: app) + #expect(throws: (any Error).self) { + try BundleAssembler(plan: plan, sourceDirectory: root) + .assemble(artifacts: .init(directory: root, executables: [:]), at: root.appendingPathComponent("App.app")) + } + } + } + + @Test func iosLayoutAndLegacyRootResourcesRemainFlat() throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-ios17.0") + let app = Plan.Product(target: target, type: .application, product: "App", bundleID: "test.App", infoPlist: [:], + resources: [.root(source: "legacy.txt"), .bundle(package: "Package", target: "Target")]) + let plan = try Plan(target: target, app: app) + let artifacts = try artifacts(plan, in: root) + try write(at: root.appendingPathComponent("legacy.txt")) + try write(at: artifacts.directory.appendingPathComponent("Package_Target.bundle/resource.txt")) + let output = root.appendingPathComponent("App.app") + try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: artifacts, at: output) + #expect(FileManager.default.fileExists(atPath: output.appendingPathComponent("App").path)) + #expect(FileManager.default.fileExists(atPath: output.appendingPathComponent("legacy.txt").path)) + #expect(FileManager.default.fileExists(atPath: output.appendingPathComponent("Package_Target.bundle/resource.txt").path)) + let info = try #require(PropertyListSerialization.propertyList( + from: Data(contentsOf: output.appendingPathComponent("Info.plist")), format: nil) as? [String: Any]) + #expect(info["LSRequiresIPhoneOS"] as? Bool == true) + #expect(info["CFBundleSupportedPlatforms"] as? [String] == ["iPhoneOS"]) + } + + #if os(macOS) + @Test func generatedSwiftPMAccessorFindsRelocatedResources() async throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let package = root.appendingPathComponent("package") + let manifest = """ + // swift-tools-version: 6.0 + import PackageDescription + let package = Package(name: "RelocationFixture", platforms: [.macOS("15.0")], + products: [.executable(name: "Reader", targets: ["Reader"])], + targets: [.executableTarget(name: "Reader", resources: [.copy("message.txt")])]) + """ + try write(manifest, at: package.appendingPathComponent("Package.swift")) + try write("relocated resource", at: package.appendingPathComponent("Sources/Reader/message.txt")) + try write(""" + import Foundation + let file = Bundle.module.url(forResource: "message", withExtension: "txt")! + print(try String(contentsOf: file, encoding: .utf8)) + """, at: package.appendingPathComponent("Sources/Reader/main.swift")) + var app = try product(resources: [.bundle(package: "RelocationFixture", target: "Reader")]) + app.source = .executable(product: "Reader") + let plan = try Plan(target: app.target, app: app) + let settings = try await BuildSettings(configuration: .debug, triple: app.target.triple, buildSystem: .swiftPM, + packagePath: package.path) + let artifacts = try await withDependencies { + $0.productBuildRunner = .liveValue + } operation: { + try await ProductBuilder(buildSettings: settings, plan: plan).build() + } + let output = root.appendingPathComponent("App.app") + try BundleAssembler(plan: plan, sourceDirectory: package).assemble(artifacts: artifacts, at: output) + let relocated = root.appendingPathComponent("elsewhere/App.app") + try FileManager.default.createDirectory(at: relocated.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.moveItem(at: output, to: relocated) + try FileManager.default.removeItem(at: package) + let link = relocated.appendingPathComponent("RelocationFixture_Reader.bundle") + let resourcePath = "Contents/Resources/RelocationFixture_Reader.bundle" + #expect(try FileManager.default.destinationOfSymbolicLink(atPath: link.path) == resourcePath) + #expect(try String(contentsOf: link.appendingPathComponent("message.txt"), encoding: .utf8) == "relocated resource") + let process = Process() + process.executableURL = relocated.appendingPathComponent("Contents/MacOS/App") + let pipe = Pipe() + process.standardOutput = pipe + try process.run() + process.waitUntilExit() + #expect(process.terminationStatus == 0) + #expect(String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) == "relocated resource\n") + } + #endif +} From 9aff50071367bf7c66c85640063f4a040a7f942f Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 13 Oct 2026 09:46:57 +0200 Subject: [PATCH 06/59] dev: expose deferred macOS signing handoff Route `xtool dev build` for macOS targets: - `--defer-signing` builds and assembles the app, then writes `.signing.json` and a `.signing-inputs/` directory with each product's entitlements for a downstream Mac signer. macOS builds without it are rejected, as are `--sign`, IPA output, `dev run` and Xcode project generation for macOS. - The route is validated before authentication, SDK setup or packing. - The selected target now comes from `--triple`, then xtool.yml's `target`, then the iOS default, for build, run and generate-xcode-project; the planner stops rejecting a configured target. - Command effects (schema loading, SDK setup, packing, Xcode generation, running) move behind the DevCommandClient dependency. --- Sources/PackLib/Planner.swift | 3 - Sources/XToolSupport/DevCommand.swift | 222 ++++++++++++- Tests/XToolTests/DevCommandTests.swift | 410 +++++++++++++++++++++++++ 3 files changed, 620 insertions(+), 15 deletions(-) create mode 100644 Tests/XToolTests/DevCommandTests.swift diff --git a/Sources/PackLib/Planner.swift b/Sources/PackLib/Planner.swift index f68b1321..80df658a 100644 --- a/Sources/PackLib/Planner.swift +++ b/Sources/PackLib/Planner.swift @@ -55,9 +55,6 @@ public struct Planner: Sendable { public func createPlan() async throws -> Plan { let platform = try TargetTriple(buildSettings.triple).platform try schema.validate(for: platform) - guard schema.target == nil else { - throw StringError("Schema target selection is not integrated with the build command yet; use --triple") - } let graph = try await buildGraph() let target = try ResolvedTarget.resolve( cliTarget: buildSettings.triple, schema: schema, diff --git a/Sources/XToolSupport/DevCommand.swift b/Sources/XToolSupport/DevCommand.swift index 369bfa8d..a98c34f1 100644 --- a/Sources/XToolSupport/DevCommand.swift +++ b/Sources/XToolSupport/DevCommand.swift @@ -111,7 +111,10 @@ struct DevXcodeCommand: AsyncParsableCommand { ) func run() async throws { - let result = try await PackOperation(xcode: true).run() + let request = try await DevBuildRequest.load() + try request.validate(route: .xcode) + @Dependency(\.devCommandClient) var client + let result = try await client.legacyPack(request.triple, .debug, true) print("Wrote to \(result.path)") } } @@ -121,7 +124,10 @@ struct DevBuildCommand: AsyncParsableCommand { commandName: "build", abstract: "Build app with SwiftPM", discussion: """ - This command builds the SwiftPM-based iOS app in the current directory + Build the SwiftPM-based app in the current directory. macOS targets require + --defer-signing and produce an app plus a signing manifest and preserved + entitlement inputs for signing later on a Mac. This command does not issue + Developer ID certificates or notarize apps. """ ) @@ -137,14 +143,27 @@ struct DevBuildCommand: AsyncParsableCommand { help: "Output a .ipa file instead of a .app" ) var ipa = false + @Flag(help: "Defer macOS signing and preserve inputs for a downstream Mac signer") + var deferSigning = false + @Option( help: ArgumentHelp( "Custom target triple to build for", - discussion: "Defaults to '\(PackOperation.defaultTriple)'" + discussion: "Overrides xtool.yml target; otherwise defaults to '\(PackOperation.defaultTriple)'" ) ) var triple: String? func run() async throws { + let request = try await DevBuildRequest.load(cliTriple: triple) + try request.validate(route: .build(sign: sign, ipa: ipa, deferSigning: deferSigning)) + + if request.platform == .macOS { + let output = try await DeferredMacBuildOperation(request: request, configuration: packOptions.configuration).run() + print("Wrote to \(output.bundle.path)") + print("Signing inputs: \(output.manifest.path)") + return + } + let signingAuthToken: AuthToken? if sign { guard let token = try AuthToken.savedIfPresent() else { @@ -155,10 +174,8 @@ struct DevBuildCommand: AsyncParsableCommand { signingAuthToken = nil } - let url = try await PackOperation( - triple: triple, - buildOptions: packOptions - ).run() + @Dependency(\.devCommandClient) var client + let url = try await client.legacyPack(request.triple, packOptions.configuration, false) if let signingAuthToken { let installDelegate = XToolInstallerDelegate() @@ -239,7 +256,11 @@ struct DevRunCommand: AsyncParsableCommand { @OptionGroup var connectionOptions: ConnectionOptions func run() async throws { - let output = try await PackOperation(triple: triple, buildOptions: packOptions).run() + // Validate the configured target before the simulator override can hide it. + let request = try await DevBuildRequest.load() + try request.validate(route: .run) + @Dependency(\.devCommandClient) var client + let output = try await client.legacyPack(triple ?? request.triple, packOptions.configuration, false) #if os(macOS) if simulator { @@ -250,8 +271,8 @@ struct DevRunCommand: AsyncParsableCommand { let token = try AuthToken.saved() - let client = try await connectionOptions.client() - print("Installing to device: \(client.deviceName) (udid: \(client.udid))") + let device = try await connectionOptions.client() + print("Installing to device: \(device.deviceName) (udid: \(device.udid))") let installDelegate = XToolInstallerDelegate() let installer = IntegratedInstaller( @@ -264,8 +285,8 @@ struct DevRunCommand: AsyncParsableCommand { do { try await installer.install( app: output, - udid: client.udid, - lookupMode: .only(client.connectionType), + udid: device.udid, + lookupMode: .only(device.connectionType), configureDevice: false, ) } catch let error as CancellationError { @@ -277,6 +298,183 @@ struct DevRunCommand: AsyncParsableCommand { } } +/// Select the effective target before auth, SDK setup, LSP writes, or packing. +/// Planner resolves the deployment minimum later, with package metadata available. +struct DevBuildRequest: Sendable { + enum Route { + case build(sign: Bool, ipa: Bool, deferSigning: Bool) + case run + case xcode + } + + let schema: PackSchema + let triple: String + let platform: TargetPlatform + let directory: URL + + init(schema: PackSchema, cliTriple: String? = nil, directory: URL) throws { + self.schema = schema + self.triple = cliTriple ?? schema.target ?? ResolvedTarget.defaultTriple + self.platform = try TargetTriple(triple).platform + self.directory = directory + try schema.validate(for: platform) + } + + static func load(cliTriple: String? = nil) async throws -> Self { + @Dependency(\.devCommandClient) var client + let directory = client.currentDirectory() + let schema = try await client.loadSchema(directory) + return try Self(schema: schema, cliTriple: cliTriple, directory: directory) + } + + func validate(route: Route) throws { + switch route { + case .build(let sign, let ipa, let deferSigning): + if platform == .macOS { + guard !sign, !ipa else { + throw Console.Error(""" + macOS builds do not support --sign, IPA packaging, or device provisioning; use --defer-signing + """) + } + guard deferSigning else { + throw Console.Error("macOS builds require --defer-signing; sign the resulting app later on a Mac") + } + } else if deferSigning { + throw Console.Error("--defer-signing requires a macOS target") + } + case .run, .xcode: + guard platform != .macOS else { + throw Console.Error(""" + macOS targets support only `xtool dev build --defer-signing`; run and Xcode project generation are unsupported + """) + } + } + } +} + +struct DeferredMacBuildOperation { + let request: DevBuildRequest + let configuration: BuildConfiguration + + struct Output { + let bundle: URL + let manifest: URL + } + + func run() async throws -> Output { + guard request.platform == .macOS else { + throw Console.Error("Deferred macOS packing requires a macOS target") + } + #if !os(macOS) + guard configuration == .release else { + throw Console.Error("macOS Debug builds on Linux need unavailable codesign support; use --configuration release") + } + #endif + let contents = try FileManager.default.contentsOfDirectory(atPath: request.directory.path) + guard contents.contains("Package.swift") + || contents.contains(where: { $0.hasPrefix("Package@") && $0.hasSuffix(".swift") }) else { + throw Console.Error("Could not find Package.swift in this directory.") + } + @Dependency(\.devCommandClient) var client + try await client.ensureSDK() + print("Planning...") + let settings = try await BuildSettings( + configuration: configuration, triple: request.triple, + buildSystem: .swiftPM, packagePath: request.directory.path + ) + let plan = try await Planner(buildSettings: settings, schema: request.schema).createPlan() + // This deliberately bypasses PackOperation's automatic entitlement signing. + let bundle = try await client.deferredPack(settings, plan) + let manifest = try DeferredSigningHandoff.write(plan: plan, bundle: bundle, sourceDirectory: request.directory) + return Output(bundle: bundle, manifest: manifest) + } +} + +enum DeferredSigningHandoff { + /// Keep signing inputs outside the bundle, so they cannot alter bundle resources. + static func write(plan: Plan, bundle: URL, sourceDirectory: URL) throws -> URL { + guard plan.target.platform == .macOS else { + throw Console.Error("Deferred signing handoffs require a macOS plan") + } + var manifest = DeferredSigningManifest(plan: plan) + let name = bundle.deletingPathExtension().lastPathComponent + let parent = bundle.deletingLastPathComponent() + let inputs = parent.appendingPathComponent("\(name).signing-inputs", isDirectory: true) + let manifestURL = parent.appendingPathComponent("\(name).signing.json") + var files: [(String, Data)] = [] + for index in manifest.products.indices { + guard let path = manifest.products[index].entitlementsPath else { continue } + let source = URL(fileURLWithPath: path, relativeTo: sourceDirectory) + let data = try Data(contentsOf: source) + guard try PropertyListSerialization.propertyList(from: data, options: [], format: nil) is [String: Any] else { + throw Console.Error("Entitlements must be a property-list dictionary: \(path)") + } + let filename = "\(index).entitlements.plist" + files.append((filename, data)) + manifest.products[index].entitlementsPath = "\(inputs.lastPathComponent)/\(filename)" + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(manifest) + if !files.isEmpty { + try FileManager.default.createDirectory(at: inputs, withIntermediateDirectories: true) + for (filename, contents) in files { + try contents.write(to: inputs.appendingPathComponent(filename), options: .atomic) + } + } + try data.write(to: manifestURL, options: .atomic) + return manifestURL + } +} + +/// External effects are replaceable through swift-dependencies, including packing. +struct DevCommandClient: Sendable { + var currentDirectory: @Sendable () -> URL + var loadSchema: @Sendable (URL) async throws -> PackSchema + var ensureSDK: @Sendable () async throws -> Void + var legacyPack: @Sendable (String, BuildConfiguration, Bool) async throws -> URL + var deferredPack: @Sendable (BuildSettings, Plan) async throws -> URL +} + +extension DevCommandClient: DependencyKey { + static let liveValue = Self( + currentDirectory: { URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true) }, + loadSchema: { directory in + let path = directory.appendingPathComponent("xtool.yml") + if FileManager.default.fileExists(atPath: path.path) { + return try await PackSchema(url: path) + } + print(""" + warning: Could not locate configuration file '\(path.path)'. Using default \ + configuration with 'com.example' organization ID. + """) + return .default + }, + ensureSDK: { try await EnsureSDKOperation(quiet: true).run() }, + legacyPack: { triple, configuration, xcode in + try await PackOperation(triple: triple, buildOptions: .init(configuration: configuration), xcode: xcode).run() + }, + deferredPack: { settings, plan in + try await Packer(buildSettings: settings, plan: plan).pack() + } + ) + + static let testValue = Self( + currentDirectory: { URL(fileURLWithPath: "/unexpected-command-directory") }, + loadSchema: { _ in throw StringError("Unexpected schema load") }, + ensureSDK: { throw StringError("Unexpected SDK setup") }, + legacyPack: { _, _, _ in throw StringError("Unexpected iOS packing") }, + deferredPack: { _, _ in throw StringError("Unexpected deferred packing") } + ) +} + +extension DependencyValues { + var devCommandClient: DevCommandClient { + get { self[DevCommandClient.self] } + set { self[DevCommandClient.self] = newValue } + } +} + struct DevCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "dev", diff --git a/Tests/XToolTests/DevCommandTests.swift b/Tests/XToolTests/DevCommandTests.swift new file mode 100644 index 00000000..03dea1ba --- /dev/null +++ b/Tests/XToolTests/DevCommandTests.swift @@ -0,0 +1,410 @@ +import ArgumentParser +import Dependencies +import Foundation +import Testing +import XKit +import XUtils +@testable import PackLib +@testable import XToolSupport + +struct DevCommandTests { + private struct ForbiddenAuthStorage: KeyValueStorage { + func data(forKey key: String) throws -> Data? { + Issue.record("Unexpected authentication read: \(key)") + throw StringError("Unexpected authentication") + } + + func setData(_ data: Data?, forKey key: String) throws { + Issue.record("Unexpected authentication write: \(key)") + throw StringError("Unexpected authentication") + } + } + + private actor Recorder { + var events: [String] = [] + func record(_ event: String) { events.append(event) } + } + + private func root() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func schema(target: String? = nil, entitlements: String? = nil) throws -> PackSchema { + try PackSchema(validating: .init( + version: .v2, orgID: "test", product: "App", target: target, + source: .executable(product: "Executable"), entitlementsPath: entitlements + )) + } + + private func client(schema: PackSchema, root: URL) -> DevCommandClient { + var client = DevCommandClient.testValue + client.currentDirectory = { root } + client.loadSchema = { _ in schema } + client.ensureSDK = { Issue.record("Unexpected SDK setup"); throw StringError("Unexpected SDK setup") } + client.legacyPack = { _, _, _ in Issue.record("Unexpected legacy packing"); throw StringError("Unexpected legacy packing") } + client.deferredPack = { _, _ in Issue.record("Unexpected deferred packing"); throw StringError("Unexpected deferred packing") } + return client + } + + @Test func buildParserKeepsIOSDefaults() throws { + let command = try DevBuildCommand.parse([]) + #expect(command.triple == nil) + #expect(command.packOptions.configuration == .debug) + #expect(!command.sign) + #expect(!command.ipa) + #expect(!command.deferSigning) + let request = try DevBuildRequest(schema: .default, directory: URL(fileURLWithPath: "/project")) + #expect(request.triple == "arm64-apple-ios") + #expect(request.platform == .iOS) + try request.validate(route: .build(sign: false, ipa: false, deferSigning: false)) + try request.validate(route: .build(sign: true, ipa: true, deferSigning: false)) + try request.validate(route: .run) + try request.validate(route: .xcode) + } + + @Test func schemaTargetAndCLIOverrideSelectRoute() throws { + let directory = URL(fileURLWithPath: "/project") + let macSchema = try schema(target: "arm64-apple-macosx15.0") + let configured = try DevBuildRequest(schema: macSchema, directory: directory) + #expect(configured.triple == "arm64-apple-macosx15.0") + #expect(configured.platform == .macOS) + let overridden = try DevBuildRequest(schema: macSchema, cliTriple: "arm64-apple-ios17.0", directory: directory) + #expect(overridden.triple == "arm64-apple-ios17.0") + #expect(overridden.platform == .iOS) + let macOverride = try DevBuildRequest(schema: .default, cliTriple: "x86_64-apple-macosx15.0", directory: directory) + #expect(macOverride.platform == .macOS) + try macOverride.validate(route: .build(sign: false, ipa: false, deferSigning: true)) + } + + @Test func unsupportedRunAndXcodeTripleOptionsRejectDuringParsing() { + #expect(throws: (any Error).self) { + try DevRunCommand.parse(["--triple", "arm64-apple-macosx"]) + } + #expect(throws: (any Error).self) { + try DevXcodeCommand.parse(["--triple", "arm64-apple-macosx"]) + } + } + + @Test(arguments: [[], ["--sign"], ["--ipa"], ["--sign", "--defer-signing"], ["--ipa", "--defer-signing"]]) + func macOSInvalidBuildRoutesFailBeforeEffects(arguments: [String]) async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let command = try DevBuildCommand.parse(arguments) + let effects = client(schema: try schema(target: "arm64-apple-macosx"), root: directory) + await #expect(throws: (any Error).self) { + try await withDependencies { + $0.devCommandClient = effects + $0.keyValueStorage = ForbiddenAuthStorage() + } operation: { + try await command.run() + } + } + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty) + } + + @Test func macOSRunAndXcodeRejectBeforeEffects() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let effects = client(schema: try schema(target: "arm64-apple-macosx"), root: directory) + await #expect(throws: (any Error).self) { + try await withDependencies { + $0.devCommandClient = effects + $0.keyValueStorage = ForbiddenAuthStorage() + } operation: { + try await DevRunCommand.parse([]).run() + } + } + #if os(macOS) + await #expect(throws: (any Error).self) { + try await withDependencies { + $0.devCommandClient = effects + $0.keyValueStorage = ForbiddenAuthStorage() + } operation: { + try await DevRunCommand.parse(["--simulator"]).run() + } + } + #endif + await #expect(throws: (any Error).self) { + try await withDependencies { + $0.devCommandClient = effects + $0.keyValueStorage = ForbiddenAuthStorage() + } operation: { + try await DevXcodeCommand.parse([]).run() + } + } + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty) + } + + @Test(arguments: ["arm64-unknown-linux", "arm64-apple-macosx-invalid"]) + func invalidCLITripleRejectsBeforeEffects(triple: String) async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let effects = client(schema: .default, root: directory) + await #expect(throws: (any Error).self) { + try await withDependencies { + $0.devCommandClient = effects + $0.keyValueStorage = ForbiddenAuthStorage() + } operation: { + try await DevBuildCommand.parse(["--triple", triple, "--sign"]).run() + } + } + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty) + } + + @Test func macOSCLIOverrideRejectsSigningBeforeAuthentication() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let effects = client(schema: .default, root: directory) + await #expect(throws: (any Error).self) { + try await withDependencies { + $0.devCommandClient = effects + $0.keyValueStorage = ForbiddenAuthStorage() + } operation: { + try await DevBuildCommand.parse(["--triple", "arm64-apple-macosx", "--sign"]).run() + } + } + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty) + } + + @Test func deferredSigningRejectsIOSBeforeEffects() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let effects = client(schema: .default, root: directory) + await #expect(throws: (any Error).self) { + try await withDependencies { + $0.devCommandClient = effects + $0.keyValueStorage = ForbiddenAuthStorage() + } operation: { + try await DevBuildCommand.parse(["--defer-signing"]).run() + } + } + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty) + } + + private func packageInspection(root: URL) throws -> PackageInspectionClient { + let dependencies = try JSONSerialization.data(withJSONObject: [ + "identity": "root", "path": root.path, "dependencies": [], + ]) + let description = try JSONSerialization.data(withJSONObject: [ + "name": "Fixture", + "platforms": [["name": "macos", "version": "15.0"]], + "products": [["name": "Executable", "targets": ["Main"], "type": ["executable": NSNull()]]], + "targets": [["name": "Main", "type": "executable", "module_type": "SwiftTarget", "resources": []]], + ]) + let manifest = try JSONSerialization.data(withJSONObject: [ + "targets": [["name": "Main", "type": "executable", "dependencies": []]], "dependencies": [], + ]) + return PackageInspectionClient( + dependencies: { _ in dependencies }, describe: { _ in description }, manifest: { _ in manifest } + ) + } + + @Test func deferredBuildUsesSchemaAndPreservesSigningInputsWithoutAuth() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + try Data("fixture".utf8).write(to: directory.appendingPathComponent("Package.swift")) + let entitlementData = try PropertyListSerialization.data( + fromPropertyList: ["com.apple.security.app-sandbox": true], format: .xml, options: 0 + ) + try entitlementData.write(to: directory.appendingPathComponent("App.entitlements")) + let recorder = Recorder() + let configured = try schema(target: "arm64-apple-macosx", entitlements: "App.entitlements") + var effects = client(schema: configured, root: directory) + effects.ensureSDK = { await recorder.record("sdk") } + effects.loadSchema = { _ in await recorder.record("schema"); return configured } + effects.deferredPack = { settings, plan in + #expect(settings.triple == "arm64-apple-macosx") + #expect(settings.configuration == .release) + #expect(settings.packagePath == directory.path) + #expect(settings.buildSystem == .swiftPM) + #expect(plan.target.platform == .macOS) + #expect(plan.target.deploymentTarget == "15.0") + #expect(plan.app.resolvedSource == .executable(product: "Executable")) + await recorder.record("deferred-pack") + // Assembly is stubbed; this test covers routing and the signing handoff. + let bundle = directory.appendingPathComponent("App.app", isDirectory: true) + try FileManager.default.createDirectory(at: bundle, withIntermediateDirectories: true) + return bundle + } + let inspection = try packageInspection(root: directory) + try await withDependencies { + $0.devCommandClient = effects + $0.packageInspection = inspection + $0.keyValueStorage = ForbiddenAuthStorage() + } operation: { + try await DevBuildCommand.parse(["--defer-signing", "--configuration", "release"]).run() + } + #expect(await recorder.events == ["schema", "sdk", "deferred-pack"]) + let manifestURL = directory.appendingPathComponent("App.signing.json") + let handoff = try JSONDecoder().decode(DeferredSigningManifest.self, from: Data(contentsOf: manifestURL)) + #expect(handoff.status == .deferred) + #expect(handoff.products[0].bundlePath == "") + #expect(handoff.products[0].executablePath == "Contents/MacOS/App") + let relative = try #require(handoff.products[0].entitlementsPath) + let copied = manifestURL.deletingLastPathComponent().appendingPathComponent(relative) + #expect(try Data(contentsOf: copied) == entitlementData) + #expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent(".bsp").path)) + #expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent(".sourcekit-lsp").path)) + } + + private func product(_ name: String, kind: Plan.ProductType, target: ResolvedTarget, entitlements: String?) -> Plan.Product { + Plan.Product(target: target, type: kind, product: name, bundleID: "test.\(name)", + infoPlist: [:], resources: [], entitlementsPath: entitlements) + } + + @Test func handoffKeepsNestedAssociationsAndMovesWithArtifact() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appendingPathComponent("source", isDirectory: true) + let output = directory.appendingPathComponent("output", isDirectory: true) + try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + let entitlementData = try PropertyListSerialization.data(fromPropertyList: ["test.flag": true], format: .binary, options: 0) + try entitlementData.write(to: source.appendingPathComponent("shared.plist")) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + let plan = try Plan( + target: target, app: product("App", kind: .application, target: target, entitlements: "shared.plist"), + extensions: [product("Preview", kind: .appExtension, target: target, entitlements: "shared.plist")], + nestedProducts: [product("Service", kind: .xpcService, target: target, entitlements: "shared.plist"), + product("Helper", kind: .helperExecutable, target: target, entitlements: nil)] + ) + let manifestURL = try DeferredSigningHandoff.write( + plan: plan, bundle: output.appendingPathComponent("App.app"), sourceDirectory: source + ) + let moved = directory.appendingPathComponent("moved", isDirectory: true) + try FileManager.default.moveItem(at: output, to: moved) + try FileManager.default.removeItem(at: source) + let manifest = try JSONDecoder().decode( + DeferredSigningManifest.self, from: Data(contentsOf: moved.appendingPathComponent(manifestURL.lastPathComponent)) + ) + #expect(manifest.products.map(\.bundleID) == ["test.App", "test.Preview", "test.Service", "test.Helper"]) + #expect(manifest.products.map(\.bundlePath) == ["", "Contents/PlugIns/Preview.appex", "Contents/XPCServices/Service.xpc", + "Contents/Helpers/Helper"]) + #expect(manifest.products[3].entitlementsPath == nil) + for product in manifest.products.prefix(3) { + let path = try #require(product.entitlementsPath) + #expect(!path.hasPrefix("/")) + #expect(try Data(contentsOf: moved.appendingPathComponent(path)) == entitlementData) + } + } + + @Test func malformedEntitlementsRejectWithoutWritingHandoff() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + try Data("not a plist".utf8).write(to: directory.appendingPathComponent("bad.plist")) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + let plan = try Plan(target: target, app: product("App", kind: .application, target: target, entitlements: "bad.plist")) + #expect(throws: (any Error).self) { + try DeferredSigningHandoff.write(plan: plan, bundle: directory.appendingPathComponent("App.app"), + sourceDirectory: directory) + } + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path) == ["bad.plist"]) + } + + @Test(arguments: [nil, "arm64-apple-ios17.0"]) + func iosBuildForwardsEffectiveTripleWithoutAuthentication(override: String?) async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let configured = try schema(target: "arm64-apple-ios16.0") + let recorder = Recorder() + var effects = client(schema: configured, root: directory) + effects.legacyPack = { triple, configuration, xcode in + #expect(triple == (override ?? "arm64-apple-ios16.0")) + #expect(configuration == .debug) + #expect(!xcode) + await recorder.record("ios-pack") + return directory.appendingPathComponent("App.app") + } + let arguments = override.map { ["--triple", $0] } ?? [] + try await withDependencies { + $0.devCommandClient = effects + $0.keyValueStorage = ForbiddenAuthStorage() + } operation: { + try await DevBuildCommand.parse(arguments).run() + } + #expect(await recorder.events == ["ios-pack"]) + } + + @Test func iosIPAPackagingStillUsesCompressor() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let recorder = Recorder() + let app = directory.appendingPathComponent("App.app", isDirectory: true) + try FileManager.default.createDirectory(at: app, withIntermediateDirectories: true) + try Data("app executable".utf8).write(to: app.appendingPathComponent("App")) + var effects = client(schema: .default, root: directory) + effects.legacyPack = { triple, _, xcode in + #expect(triple == "arm64-apple-ios") + #expect(!xcode) + return app + } + let compressor = ZIPCompressor( + compress: { payload, _ in + #expect(payload.lastPathComponent == "Payload") + #expect(FileManager.default.fileExists(atPath: payload.appendingPathComponent("App.app/App").path)) + await recorder.record("ipa") + let compressed = directory.appendingPathComponent("compressed.zip") + try Data("compressed app".utf8).write(to: compressed) + return compressed + }, + decompress: { _, _, _ in Issue.record("Unexpected decompression") } + ) + try await withDependencies { + $0.devCommandClient = effects + $0.keyValueStorage = ForbiddenAuthStorage() + $0.zipCompressor = compressor + } operation: { + try await DevBuildCommand.parse(["--ipa"]).run() + } + #expect(await recorder.events == ["ipa"]) + #expect(try Data(contentsOf: directory.appendingPathComponent("App.ipa")) == Data("compressed app".utf8)) + } + + @Test func iosRunAndXcodeRetainLegacyPacking() async throws { + struct BuildStopped: Error {} + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let recorder = Recorder() + var effects = client(schema: .default, root: directory) + effects.legacyPack = { triple, configuration, xcode in + #expect(triple == "arm64-apple-ios") + #expect(configuration == .debug) + await recorder.record(xcode ? "xcode" : "run") + throw BuildStopped() + } + await #expect(throws: BuildStopped.self) { + try await withDependencies { + $0.devCommandClient = effects + $0.keyValueStorage = ForbiddenAuthStorage() + } operation: { + try await DevRunCommand.parse([]).run() + } + } + await #expect(throws: BuildStopped.self) { + try await withDependencies { + $0.devCommandClient = effects + $0.keyValueStorage = ForbiddenAuthStorage() + } operation: { + try await DevXcodeCommand.parse([]).run() + } + } + #expect(await recorder.events == ["run", "xcode"]) + } + + @Test func iosSignStillRequiresAuthenticationBeforePacking() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let effects = client(schema: .default, root: directory) + await #expect(throws: (any Error).self) { + try await withDependencies { + $0.devCommandClient = effects + $0.keyValueStorage = MemoryKeyValueStorage() + } operation: { + try await DevBuildCommand.parse(["--sign"]).run() + } + } + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty) + } +} From 56fb1952fc15a8ca8b72ef4cb53921a244d680de Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 13 Oct 2026 11:07:34 +0200 Subject: [PATCH 07/59] packer: validate macOS link metadata and runtime paths Check what the linker produced before a bundle is assembled: - Built products, prebuilt products and binary targets are parsed as Mach-O and rejected when their file type, architecture or platform does not match the plan. - Each product gets the runpath search paths its bundle location needs. Helpers search both the app's Contents/Frameworks and their own Frameworks directory, so libraries assembled next to a helper load. - `.prebuilt` product sources are accepted. - macOS links pass `-platform_version macos ` from the SDK's SDKSettings.json, because LLD on Linux otherwise records the deployment version as the SDK version. --- Sources/PackLib/BuildSettings.swift | 56 ++++ Sources/PackLib/BundleAssembler.swift | 10 + Sources/PackLib/ProductBuilder.swift | 294 ++++++++++++++++++-- Tests/XToolTests/BinaryLinkTests.swift | 210 ++++++++++++++ Tests/XToolTests/BundleAssemblerTests.swift | 25 ++ Tests/XToolTests/ProductBuilderTests.swift | 27 +- 6 files changed, 591 insertions(+), 31 deletions(-) create mode 100644 Tests/XToolTests/BinaryLinkTests.swift diff --git a/Sources/PackLib/BuildSettings.swift b/Sources/PackLib/BuildSettings.swift index 4bd4d4ee..324ba20a 100644 --- a/Sources/PackLib/BuildSettings.swift +++ b/Sources/PackLib/BuildSettings.swift @@ -163,6 +163,62 @@ public struct BuildSettings: Sendable { } } + /// LLD otherwise records the deployment version as the SDK version on Linux. + /// Keep this target-only: SwiftPM CLI linker flags also affect host macros. + func macOSLinkerArguments() async throws -> [String] { + let sdkRoot: URL + if let custom = swiftSDKsPath { + sdkRoot = try Self.macOSSDKRoot(in: URL(fileURLWithPath: custom), architecture: try TargetTriple(triple).architecture) + } else { + #if os(macOS) + sdkRoot = URL(fileURLWithPath: try await Self.xcrun(["--sdk", "macosx", "--show-sdk-path"])) + #else + guard let sdk = try DarwinSDK.current() else { + throw StringError("No Darwin SDK configured. Please run `xtool setup`.") + } + sdkRoot = try Self.macOSSDKRoot(in: sdk.bundle.deletingLastPathComponent(), + architecture: try TargetTriple(triple).architecture) + #endif + } + let data = try Data(contentsOf: sdkRoot.appendingPathComponent("SDKSettings.json")) + guard let settings = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let version = settings["Version"] as? String else { + throw StringError("Missing SDK version in \(sdkRoot.path)/SDKSettings.json") + } + try TargetTriple.validateVersion(version) + let tripleIndex = sdkOptions.firstIndex(of: "--triple") + let target = try TargetTriple(tripleIndex.map { sdkOptions[$0 + 1] } ?? triple) + return ["-Xlinker", "-platform_version", "-Xlinker", "macos", "-Xlinker", + target.deploymentTarget ?? target.platform.minimumDeploymentTarget, "-Xlinker", version] + } + + private var swiftSDKsPath: String? { + for (index, option) in customOptions.enumerated() { + if option.hasPrefix("--swift-sdks-path=") { return String(option.dropFirst("--swift-sdks-path=".count)) } + if option == "--swift-sdks-path", index + 1 < customOptions.count { return customOptions[index + 1] } + } + return nil + } + + private static func macOSSDKRoot(in directory: URL, architecture: String) throws -> URL { + let bundles = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "artifactbundle" } + var roots: [URL] = [] + for bundle in bundles { + let metadata = bundle.appendingPathComponent("swift-sdk.json") + guard FileManager.default.fileExists(atPath: metadata.path) else { continue } + let data = try Data(contentsOf: metadata) + guard let settings = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let triples = settings["targetTriples"] as? [String: [String: Any]], + let path = triples["\(architecture)-apple-macosx"]?["sdkRootPath"] as? String else { continue } + roots.append(URL(fileURLWithPath: path, relativeTo: bundle).standardizedFileURL) + } + guard roots.count == 1, let root = roots.first else { + throw StringError("Expected exactly one macOS Swift SDK in \(directory.path); found \(roots.count)") + } + return root + } + /// Reject combinations whose failure is already known, before a builder /// creates its wrapper or starts resolving/compiling a package. func validateProductBuild(for target: ResolvedTarget, hostIsMacOS: Bool) throws { diff --git a/Sources/PackLib/BundleAssembler.swift b/Sources/PackLib/BundleAssembler.swift index ab9de600..caf4bf52 100644 --- a/Sources/PackLib/BundleAssembler.swift +++ b/Sources/PackLib/BundleAssembler.swift @@ -102,6 +102,16 @@ public struct BundleAssembler: Sendable { try validateName(name) let source = artifacts.directory.appendingPathComponent("\(name).framework") let binary = source.appendingPathComponent(name) + if artifacts.staticBinaryTargets.contains(name) { + let looseArchive = artifacts.directory.appendingPathComponent("lib\(name).a") + guard FileManager.default.fileExists(atPath: binary.path) + || FileManager.default.fileExists(atPath: looseArchive.path) else { + throw StringError("Missing validated static binary target '\(name)' in \(artifacts.directory.path)") + } + // ProductBuilder inspected the archive and confirmed that SwiftPM + // linked it into the executable. Static code is not embedded. + return + } // iOS SwiftPM omits some linked static binary targets from the output tree. // Retain that legacy omission; macOS declared inputs must be present and classified. if product.platform != .macOS && !FileManager.default.fileExists(atPath: binary.path) { return } diff --git a/Sources/PackLib/ProductBuilder.swift b/Sources/PackLib/ProductBuilder.swift index 0e9aa6a7..9a675d98 100644 --- a/Sources/PackLib/ProductBuilder.swift +++ b/Sources/PackLib/ProductBuilder.swift @@ -25,13 +25,16 @@ public struct ProductBuilder: Sendable { try buildSettings.validateProductBuild(for: plan.target, hostIsMacOS: hostIsMacOS) try validateProducts() let settings = try buildSettings.withTarget(plan.target) + let prebuilt = try validatedPrebuiltProducts() + let sdkArguments = plan.target.platform == .macOS && prebuilt.count != plan.allProducts.count + ? try await runner.sdkLinkerArguments(settings) : [] let root = URL(fileURLWithPath: settings.packagePath, isDirectory: true).standardizedFileURL let wrapper = root.appendingPathComponent("xtool/.xtool-tmp", isDirectory: true) let libraries = plan.allProducts.filter { if case .library = $0.resolvedSource { return true } return false } - if !libraries.isEmpty { try createWrapper(at: wrapper, root: root, products: libraries) } + if !libraries.isEmpty { try createWrapper(at: wrapper, root: root, products: libraries, sdkArguments: sdkArguments) } // Share one scratch tree so resources and dependency libraries remain // beside all products, including mixed direct and wrapped products. @@ -39,8 +42,9 @@ public struct ProductBuilder: Sendable { $0 == "--scratch-path" || $0.hasPrefix("--scratch-path=") } ? [] : ["--scratch-path", root.appendingPathComponent(".build").path] var directory: URL? - var executables: [String: URL] = [:] + var executables = prebuilt for product in plan.allProducts { + if prebuilt[product.product] != nil { continue } let package: URL let name: String let options: [String] @@ -54,10 +58,9 @@ public struct ProductBuilder: Sendable { case .executable(let sourceName): package = root name = sourceName - options = try directProductOptions(product, in: wrapper) + options = try directProductOptions(product, in: wrapper, sdkArguments: sdkArguments) case .prebuilt: - // validateProducts rejects these before any side effects. - throw StringError("Prebuilt product compatibility validation is not implemented") + continue } let productSettings = settings.withPackagePath(package.path) let arguments = scratchOptions + ["--product", name] + options @@ -78,11 +81,57 @@ public struct ProductBuilder: Sendable { } executables[product.product] = executable } - guard let directory else { throw StringError("Build plan has no products") } - return BuildArtifacts(directory: directory, executables: executables) + let output = directory ?? root + let staticTargets = try validateBinaryTargets(in: output) + return BuildArtifacts(directory: output, executables: executables, staticBinaryTargets: staticTargets) } - private func directProductOptions(_ product: Plan.Product, in directory: URL) throws -> [String] { + private func validatedPrebuiltProducts() throws -> [String: URL] { + var result: [String: URL] = [:] + for product in plan.allProducts { + guard case .prebuilt(let path) = product.resolvedSource else { continue } + let root = URL(fileURLWithPath: buildSettings.packagePath, isDirectory: true) + let url = URL(fileURLWithPath: path, relativeTo: root).standardizedFileURL + guard FileManager.default.isExecutableFile(atPath: url.path) else { + throw StringError("Prebuilt executable is missing or not executable: \(url.path)") + } + try MachOBinary.validate(url, target: plan.target, executable: true) + result[product.product] = url + } + return result + } + + private func validateBinaryTargets(in directory: URL) throws -> Set { + guard plan.target.platform == .macOS else { return [] } + var staticTargets: Set = [] + for resource in Set(plan.allProducts.flatMap(\.resources)) { + switch resource { + case .binaryTarget(let name): + try ResourceDestination.validateRelativePath(name) + let framework = directory.appendingPathComponent("\(name).framework/\(name)") + let archive = directory.appendingPathComponent("lib\(name).a") + let url = FileManager.default.fileExists(atPath: framework.path) ? framework : archive + guard FileManager.default.fileExists(atPath: url.path) else { + throw StringError("Missing binary target '\(name)': expected \(framework.path) or \(archive.path)") + } + if try MachOBinary.validate(url, target: plan.target, executable: false) { + staticTargets.insert(name) + } else if url == archive { + throw StringError("Binary target '\(name)' is not a static archive: \(url.path)") + } + case .library(let name): + try ResourceDestination.validateRelativePath(name) + try MachOBinary.validate(directory.appendingPathComponent("lib\(name).dylib"), + target: plan.target, executable: false) + default: break + } + } + return staticTargets + } + + private func directProductOptions( + _ product: Plan.Product, in directory: URL, sdkArguments: [String] + ) throws -> [String] { // SwiftPM forwards CLI -Xlinker options to host macro executables too. // SDK toolset options belong only to the target toolchain instead. let toolsets = directory.appendingPathComponent("toolsets", isDirectory: true) @@ -90,18 +139,18 @@ public struct ProductBuilder: Sendable { let path = toolsets.appendingPathComponent("\(product.targetName).json") let data = try JSONSerialization.data(withJSONObject: [ "schemaVersion": "1.0", - "swiftCompiler": ["extraCLIOptions": product.linkerArguments], + "swiftCompiler": ["extraCLIOptions": product.linkerArguments + sdkArguments], ], options: [.sortedKeys]) try data.write(to: path) return ["--toolset", path.path] } private func validateProducts() throws { - let names = plan.allProducts.map { product -> String in + let names = plan.allProducts.compactMap { product -> String? in switch product.resolvedSource { case .library: product.targetName case .executable(let name): name - case .prebuilt: "" + case .prebuilt: nil } } guard Set(names).count == names.count else { @@ -115,7 +164,7 @@ public struct ProductBuilder: Sendable { } switch product.resolvedSource { case .prebuilt: - throw StringError("Prebuilt product compatibility validation is not implemented; use a SwiftPM product") + break case .executable where buildSettings.buildSystem != .swiftPM: throw StringError("Direct executable products currently require the SwiftPM backend") case .library(let name), .executable(let name): @@ -127,7 +176,9 @@ public struct ProductBuilder: Sendable { } } - private func createWrapper(at directory: URL, root: URL, products: [Plan.Product]) throws { + private func createWrapper( + at directory: URL, root: URL, products: [Plan.Product], sdkArguments: [String] + ) throws { try? FileManager.default.removeItem(at: directory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let targets = products.map { product in @@ -137,7 +188,7 @@ public struct ProductBuilder: Sendable { .executableTarget( name: \(product.targetName.swiftLiteral), dependencies: [.product(name: \(sourceName.swiftLiteral), package: "RootPackage")], - linkerSettings: \(product.linkerSettings) + linkerSettings: \(product.linkerSettings(sdkArguments: sdkArguments)) ) """ }.joined(separator: ",\n") @@ -176,21 +227,30 @@ extension Plan.Product { arguments += ["-Xlinker", "-framework", "-Xlinker", "Foundation", "-Xlinker", "-e", "-Xlinker", "_NSExtensionMain"] } - // Paths follow the executable's actual position in the selected layout. - let own = platform == .macOS ? "@executable_path/../Frameworks" : "@executable_path/Frameworks" - let outer: String? = switch type { - case .application: nil - case .appExtension, .xpcService: - platform == .macOS ? "@executable_path/../../../../Frameworks" : "@executable_path/../../Frameworks" - case .helperExecutable: "@executable_path/../Frameworks" + for path in runtimeSearchPaths { + arguments += ["-Xlinker", "-rpath", "-Xlinker", path] } - if let outer { arguments += ["-Xlinker", "-rpath", "-Xlinker", outer] } - if outer != own { arguments += ["-Xlinker", "-rpath", "-Xlinker", own] } return arguments } - fileprivate var linkerSettings: String { - "[.unsafeFlags([\(linkerArguments.map(\.swiftLiteral).joined(separator: ", "))])]" + /// Compute paths from the same frozen layout consumed by assembly. + var runtimeSearchPaths: [String] { + let executableDirectory = layout.executablePath.split(separator: "/").dropLast().map(String.init) + let outer = BundleLayout(platform: platform, kind: .application, name: product).frameworksPath + let destinations = [outer, layout.frameworksPath] + var paths: [String] = [] + for destination in destinations { + let components = destination.split(separator: "/").map(String.init) + let shared = zip(executableDirectory, components).prefix { $0 == $1 }.count + let relative = Array(repeating: "..", count: executableDirectory.count - shared) + components.dropFirst(shared) + let path = (["@executable_path"] + relative).joined(separator: "/") + if !paths.contains(path) { paths.append(path) } + } + return paths + } + + fileprivate func linkerSettings(sdkArguments: [String]) -> String { + "[.unsafeFlags([\((linkerArguments + sdkArguments).map(\.swiftLiteral).joined(separator: ", "))])]" } } @@ -199,6 +259,7 @@ extension Plan.Product { struct ProductBuildRunner: Sendable { var build: @Sendable (BuildSettings, [String]) async throws -> Void var binaryPath: @Sendable (BuildSettings, [String]) async throws -> String + var sdkLinkerArguments: @Sendable (BuildSettings) async throws -> [String] = { try await $0.macOSLinkerArguments() } } extension ProductBuildRunner: DependencyKey { @@ -227,10 +288,13 @@ extension DependencyValues { public struct BuildArtifacts: Sendable { public var directory: URL public var executables: [String: URL] + /// Only targets with a present, validated static archive may omit embedding. + public var staticBinaryTargets: Set - public init(directory: URL, executables: [String: URL]) { + public init(directory: URL, executables: [String: URL], staticBinaryTargets: Set = []) { self.directory = directory self.executables = executables + self.staticBinaryTargets = staticBinaryTargets } } @@ -254,3 +318,181 @@ extension DependencyValues { set { self[ProductBuilderClient.self] = newValue } } } + +/// A bounded, host-independent reader: Linux must diagnose Apple inputs without +/// relying on the host's `file`, `otool`, or codesign tools. +struct MachOBinary { + struct Slice { + var cpu: UInt32 + var fileType: UInt32 + var platform: UInt32? + var minimumOS: UInt32 = 0 + var sdk: UInt32 = 0 + var installName: String? + var rpaths: [String] = [] + var hasCodeSignature = false + } + + @discardableResult + static func validate(_ url: URL, target: ResolvedTarget, executable: Bool) throws -> Bool { + guard FileManager.default.isReadableFile(atPath: url.path) else { + throw StringError("Missing binary artifact: \(url.path)") + } + do { + let bytes = [UInt8](try Data(contentsOf: url)) + let archive = bytes.starts(with: Array("!\n".utf8)) + let slices = try inspect(bytes) + let cpu = try cpuType(for: target.architecture) + let matching = slices.filter { $0.cpu == cpu } + guard !matching.isEmpty else { throw StringError("Wrong architecture; expected \(target.architecture)") } + let platform: UInt32 = switch target.platform { + case .macOS: 1 + case .iOS: 2 + case .iOSSimulator: 7 + } + for slice in matching { + guard slice.platform == platform else { + throw StringError("Wrong or missing Mach-O platform; expected \(target.platform.rawValue)") + } + guard slice.minimumOS <= packedVersion(target.deploymentTarget) else { + throw StringError("Binary minimum OS exceeds deployment target \(target.deploymentTarget)") + } + let expectedType: UInt32 = executable ? 2 : (archive ? 1 : 6) + guard slice.fileType == expectedType else { throw StringError("Wrong Mach-O file type; expected \(expectedType)") } + if expectedType == 6 { + try validateInstallName(slice.installName, binary: url) + } + } + return archive + } catch { + throw StringError("Incompatible binary '\(url.path)': \(error)") + } + } + + private static func cpuType(for architecture: String) throws -> UInt32 { + switch architecture { + case "arm64": return 0x0100000c + case "x86_64": return 0x01000007 + default: throw StringError("Unsupported architecture: \(architecture)") + } + } + + private static func validateInstallName(_ name: String?, binary: URL) throws { + guard let name, name.hasPrefix("@rpath/") else { + throw StringError("Dynamic binary must use an @rpath install name for relocation") + } + let relative = String(name.dropFirst("@rpath/".count)) + try ResourceDestination.validateRelativePath(relative) + var base = binary.deletingLastPathComponent() + if let index = binary.pathComponents.firstIndex(where: { $0.hasSuffix(".framework") }) { + base = URL(fileURLWithPath: NSString.path(withComponents: Array(binary.pathComponents.prefix(index))), + isDirectory: true) + } + guard base.appendingPathComponent(relative).resolvingSymlinksInPath() == binary.resolvingSymlinksInPath() else { + throw StringError("Install name '\(name)' does not resolve to the embedded binary '\(binary.lastPathComponent)'") + } + } + + static func packedVersion(_ version: String) -> UInt32 { + let parts: [UInt32] = version.split(separator: ".").map { UInt32($0) ?? UInt32.max } + [0, 0] + return (min(parts[0], 65535) << 16) | (min(parts[1], 255) << 8) | min(parts[2], 255) + } + + static func inspect(_ bytes: [UInt8]) throws -> [Slice] { + if bytes.starts(with: Array("!\n".utf8)) { return try archiveSlices(bytes) } + let magic = try word(bytes, 0) + if magic == 0xbebafeca || magic == 0xcafebabe { + let bigEndian = magic == 0xbebafeca + let count = Int(try word(bytes, 4, bigEndian: bigEndian)) + guard count <= (bytes.count - 8) / 20 else { throw StringError("Truncated universal binary") } + return try (0..= 8 + count * 20, offset <= bytes.count, size <= bytes.count - offset else { + throw StringError("Invalid universal binary slice") + } + let slices = try inspect(Array(bytes[offset..<(offset + size)])) + let cpu = try word(bytes, entry, bigEndian: bigEndian) + guard slices.allSatisfy({ $0.cpu == cpu }) else { throw StringError("Universal binary CPU mismatch") } + return slices + } + } + guard magic == 0xfeedfacf else { throw StringError("Expected a 64-bit Mach-O binary") } + return [try thinSlice(bytes)] + } + + private static func archiveSlices(_ bytes: [UInt8]) throws -> [Slice] { + var offset = 8 + var slices: [Slice] = [] + while offset < bytes.count { + guard bytes.count - offset >= 60, bytes[offset + 58] == 0x60, bytes[offset + 59] == 0x0a else { + throw StringError("Invalid archive member") + } + let name = String(decoding: bytes[offset..<(offset + 16)], as: UTF8.self).trimmingCharacters(in: .whitespaces) + let sizeText = String(decoding: bytes[(offset + 48)..<(offset + 58)], as: UTF8.self) + guard let size = Int(sizeText.trimmingCharacters(in: .whitespaces)), size >= 0, + size <= bytes.count - offset - 60 else { throw StringError("Invalid archive member size") } + let nameSize = name.hasPrefix("#1/") ? Int(name.dropFirst(3)) : 0 + guard let nameSize, nameSize >= 0, nameSize <= size else { throw StringError("Invalid archive member name") } + let actualName = nameSize > 0 + ? String(decoding: bytes[(offset + 60)..<(offset + 60 + nameSize)], as: UTF8.self) : name + if !actualName.hasPrefix("__.SYMDEF") && actualName != "/" && actualName != "//" { + slices += try inspect(Array(bytes[(offset + 60 + nameSize)..<(offset + 60 + size)])) + } + offset += 60 + size + size % 2 + } + guard !slices.isEmpty else { throw StringError("Archive has no Mach-O object members") } + return slices + } + + private static func thinSlice(_ bytes: [UInt8]) throws -> Slice { + var slice = Slice(cpu: try word(bytes, 4), fileType: try word(bytes, 12)) + let count = Int(try word(bytes, 16)) + let size = Int(try word(bytes, 20)) + guard bytes.count >= 32, size <= bytes.count - 32, count <= size / 8 else { + throw StringError("Truncated Mach-O load commands") + } + var offset = 32 + for _ in 0..= 8, length <= 32 + size - offset else { throw StringError("Invalid Mach-O load command") } + let load = Array(bytes[offset..<(offset + length)]) + switch command { + case 0x32: // LC_BUILD_VERSION + guard slice.platform == nil else { throw StringError("Duplicate Mach-O platform metadata") } + slice.platform = try word(load, 8) + slice.minimumOS = try word(load, 12) + slice.sdk = try word(load, 16) + case 0x24, 0x25: // legacy LC_VERSION_MIN_MACOSX / IPHONEOS + guard slice.platform == nil else { throw StringError("Duplicate Mach-O platform metadata") } + slice.platform = command == 0x24 ? 1 : 2 + slice.minimumOS = try word(load, 8) + slice.sdk = try word(load, 12) + case 0xd: slice.installName = try loadString(load) + case 0x8000001c: slice.rpaths.append(try loadString(load)) + case 0x1d: slice.hasCodeSignature = true + default: break + } + offset += length + } + guard offset == 32 + size else { throw StringError("Inconsistent Mach-O command size") } + return slice + } + + private static func loadString(_ load: [UInt8]) throws -> String { + let offset = Int(try word(load, 8)) + guard offset >= 12, offset < load.count, let end = load[offset...].firstIndex(of: 0) else { + throw StringError("Invalid Mach-O load-command string") + } + return String(decoding: load[offset.. UInt32 { + guard offset >= 0, offset <= bytes.count, bytes.count - offset >= 4 else { throw StringError("Truncated Mach-O data") } + return (0..<4).reduce(UInt32(0)) { value, index in + value | (UInt32(bytes[offset + index]) << ((bigEndian ? 3 - index : index) * 8)) + } + } +} diff --git a/Tests/XToolTests/BinaryLinkTests.swift b/Tests/XToolTests/BinaryLinkTests.swift new file mode 100644 index 00000000..e6c4c29e --- /dev/null +++ b/Tests/XToolTests/BinaryLinkTests.swift @@ -0,0 +1,210 @@ +import Dependencies +import Foundation +import Testing +@testable import PackLib + +struct BinaryLinkTests { + private func temporaryRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private func product(_ name: String, kind: Plan.ProductType, target: ResolvedTarget) -> Plan.Product { + Plan.Product(target: target, source: .prebuilt(path: name), type: kind, product: name, + bundleID: "test.\(name)", infoPlist: [:], resources: []) + } + + private func binary(cpu: UInt32 = 0x0100000c, platform: UInt32 = 1, + minimum: UInt32 = 0x0f0000, type: UInt32 = 2) -> Data { + let words: [UInt32] = [0xfeedfacf, cpu, 0, type, 1, 24, 0, 0, 0x32, 24, platform, minimum, 0x1a0200, 0] + return Data(words.flatMap { value in (0..<4).map { UInt8(truncatingIfNeeded: value >> ($0 * 8)) } }) + } + + private func write(_ data: Data, to url: URL) throws { + try data.write(to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + } + + @Test(arguments: [Plan.ProductType.application, .appExtension, .xpcService, .helperExecutable]) + func rpathsResolveAgainstFrozenLayout(kind: Plan.ProductType) throws { + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + let product = product("Item", kind: kind, target: target) + let root = URL(fileURLWithPath: "/Relocated/App.app") + let executable = root.appendingPathComponent(product.layout.executablePath).deletingLastPathComponent() + let paths = product.runtimeSearchPaths.map { + executable.appendingPathComponent(String($0.dropFirst("@executable_path/".count))).standardizedFileURL.path + } + #expect(paths.first == root.appendingPathComponent("Contents/Frameworks").path) + #expect(paths.contains(root.appendingPathComponent(product.layout.frameworksPath).path)) + #expect(Set(paths).count == paths.count) + } + + @Test func helperOnlyDynamicDependencyResolvesFromEmittedRpath() async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + try write(binary(), to: root.appendingPathComponent("App")) + try write(binary(), to: root.appendingPathComponent("Helper")) + let installName = "@rpath/libHelperRuntime.dylib" + var library = binary(type: 6) + let string = Array(installName.utf8) + [0] + let size = (24 + string.count + 7) / 8 * 8 + let command: [UInt32] = [0xd, UInt32(size), 24, 0, 0, 0] + library[16] = 2 + library[20] = UInt8(24 + size) + library += Data(command.flatMap { value in (0..<4).map { UInt8(truncatingIfNeeded: value >> ($0 * 8)) } }) + library += Data(string + Array(repeating: 0, count: size - 24 - string.count)) + try library.write(to: root.appendingPathComponent("libHelperRuntime.dylib")) + let helper = Plan.Product(target: target, source: .executable(product: "Helper"), + type: .helperExecutable, product: "Helper", bundleID: "test.Helper", + infoPlist: [:], resources: [.library(name: "HelperRuntime")]) + let plan = try Plan(target: target, app: product("App", kind: .application, target: target), + nestedProducts: [helper]) + let settings = try await BuildSettings(configuration: .release, triple: target.triple, packagePath: root.path) + let artifacts = try await withDependencies { + $0.productBuildRunner = .init(build: { _, _ in }, binaryPath: { _, _ in root.path }, + sdkLinkerArguments: { _ in [] }) + } operation: { + try await ProductBuilder(buildSettings: settings, plan: plan).build() + } + let toolsetURL = root.appendingPathComponent("xtool/.xtool-tmp/toolsets/\(helper.targetName).json") + let toolset = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: toolsetURL)) as? [String: Any]) + let compiler = try #require(toolset["swiftCompiler"] as? [String: Any]) + let arguments = try #require(compiler["extraCLIOptions"] as? [String]) + let rpaths = arguments.indices.filter { arguments[$0] == "-rpath" }.map { arguments[$0 + 2] } + #expect(rpaths == ["@executable_path/../Frameworks", "@executable_path/Frameworks"]) + + let output = root.appendingPathComponent("Relocated/App.app") + try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: artifacts, at: output) + let dependency = output.appendingPathComponent("Contents/Helpers/Frameworks/libHelperRuntime.dylib") + #expect(try Data(contentsOf: dependency) == library) + let outerDependency = output.appendingPathComponent("Contents/Frameworks/libHelperRuntime.dylib") + #expect(!FileManager.default.fileExists(atPath: outerDependency.path)) + let executableDirectory = output.appendingPathComponent(helper.layout.executablePath).deletingLastPathComponent() + let relativeLibrary = String(installName.dropFirst("@rpath/".count)) + let candidates = rpaths.map { + executableDirectory.appendingPathComponent(String($0.dropFirst("@executable_path/".count))) + .appendingPathComponent(relativeLibrary).standardizedFileURL + } + #expect(candidates.filter { FileManager.default.fileExists(atPath: $0.path) } == [dependency]) + } + + @Test func prebuiltOnlyBuildPreservesPathsWithoutCompiler() async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + try write(binary(), to: root.appendingPathComponent("App")) + try write(binary(), to: root.appendingPathComponent("Helper")) + let plan = try Plan(target: target, app: product("App", kind: .application, target: target), + nestedProducts: [product("Helper", kind: .helperExecutable, target: target)]) + let settings = try await BuildSettings(configuration: .release, triple: target.triple, packagePath: root.path) + let artifacts = try await withDependencies { + $0.productBuildRunner = .init(build: { _, _ in Issue.record("Unexpected build") }, + binaryPath: { _, _ in Issue.record("Unexpected output query"); return "" }, + sdkLinkerArguments: { _ in Issue.record("Unexpected SDK query"); return [] }) + } operation: { + try await ProductBuilder(buildSettings: settings, plan: plan).build() + } + #expect(artifacts.executables["App"] == root.appendingPathComponent("App")) + #expect(artifacts.executables["Helper"] == root.appendingPathComponent("Helper")) + #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent("xtool").path)) + } + + @Test func incompatiblePrebuiltDiagnostics() throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + let url = root.appendingPathComponent("Input") + let cases: [(Data, String)] = [ + (binary(cpu: 0x01000007), "Wrong architecture"), + (binary(platform: 2), "platform"), + (binary(minimum: 0x100000), "minimum OS"), + (binary(type: 6), "file type"), + (Data("#!/bin/sh\n".utf8), "Mach-O"), + (Data([0xcf, 0xfa, 0xed, 0xfe]), "Truncated"), + ] + for (data, diagnostic) in cases { + try write(data, to: url) + do { + try MachOBinary.validate(url, target: target, executable: true) + Issue.record("Accepted invalid binary: \(diagnostic)") + } catch { + #expect(String(describing: error).contains(diagnostic)) + #expect(String(describing: error).contains(url.path)) + } + } + } + + @Test func universalBinarySelectsMatchingSliceAndRejectsTruncation() throws { + let slices = [binary(cpu: 0x01000007), binary()] + var data = Data() + let header: [UInt32] = [0xcafebabe, 2, 0x01000007, 0, 48, 56, 0, 0x0100000c, 0, 104, 56, 0] + for word in header { data += Data((0..<4).map { UInt8(truncatingIfNeeded: word >> ((3 - $0) * 8)) }) } + data += slices[0] + slices[1] + #expect(try MachOBinary.inspect(Array(data)).count == 2) + #expect(throws: (any Error).self) { try MachOBinary.inspect(Array(data.dropLast())) } + } + + @Test func staticBinaryTargetsRequireRealArchive() async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + try write(binary(), to: root.appendingPathComponent("App")) + var app = product("App", kind: .application, target: target) + app.resources = [.binaryTarget(name: "Static")] + let plan = try Plan(target: target, app: app) + let settings = try await BuildSettings(configuration: .release, triple: target.triple, packagePath: root.path) + await #expect(throws: (any Error).self) { try await ProductBuilder(buildSettings: settings, plan: plan).build() } + let object = binary(type: 1) + let size = String(object.count).padding(toLength: 10, withPad: " ", startingAt: 0) + let header = "file.o/ 0 0 0 100644 \(size)`\n" + let archive = Data("!\n\(header)".utf8) + object + try archive.write(to: root.appendingPathComponent("libStatic.a")) + let artifacts = try await ProductBuilder(buildSettings: settings, plan: plan).build() + #expect(artifacts.staticBinaryTargets == ["Static"]) + try Data("!\n".utf8).write(to: root.appendingPathComponent("libStatic.a")) + await #expect(throws: (any Error).self) { try await ProductBuilder(buildSettings: settings, plan: plan).build() } + } + + @Test func dynamicInstallNameMustResolveInsideEmbeddedFramework() throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let framework = root.appendingPathComponent("Runtime.framework") + try FileManager.default.createDirectory(at: framework, withIntermediateDirectories: true) + let url = framework.appendingPathComponent("Runtime") + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + for name in ["@rpath/Runtime.framework/Runtime", "/tmp/Runtime", "@rpath/Other.framework/Runtime", "@rpath/../Runtime"] { + var data = binary(type: 6) + let string = Array(name.utf8) + [0] + let size = (24 + string.count + 7) / 8 * 8 + let command: [UInt32] = [0xd, UInt32(size), 24, 0, 0, 0] + data[16] = 2 + data[20] = UInt8(24 + size) + data += Data(command.flatMap { value in (0..<4).map { UInt8(truncatingIfNeeded: value >> ($0 * 8)) } }) + data += Data(string + Array(repeating: 0, count: size - 24 - string.count)) + try data.write(to: url) + if name == "@rpath/Runtime.framework/Runtime" { + #expect(try !MachOBinary.validate(url, target: target, executable: false)) + } else { + #expect(throws: (any Error).self) { try MachOBinary.validate(url, target: target, executable: false) } + } + } + } + + @Test func sdkVersionUsesSDKMetadataAndEffectiveDeployment() async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let bundle = root.appendingPathComponent("darwin.artifactbundle") + let sdk = bundle.appendingPathComponent("SDK") + try FileManager.default.createDirectory(at: sdk, withIntermediateDirectories: true) + try Data(#"{"targetTriples":{"arm64-apple-macosx":{"sdkRootPath":"SDK"}}}"#.utf8) + .write(to: bundle.appendingPathComponent("swift-sdk.json")) + try Data(#"{"Version":"26.2"}"#.utf8).write(to: sdk.appendingPathComponent("SDKSettings.json")) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx", packageDeploymentTargets: ["macos": "16.0"]) + let settings = try await BuildSettings(configuration: .release, triple: target.triple, + options: ["--swift-sdks-path", root.path]).withTarget(target) + #expect(try await settings.macOSLinkerArguments() == ["-Xlinker", "-platform_version", "-Xlinker", "macos", + "-Xlinker", "16.0", "-Xlinker", "26.2"]) + } +} diff --git a/Tests/XToolTests/BundleAssemblerTests.swift b/Tests/XToolTests/BundleAssemblerTests.swift index d4482bc6..1652fca2 100644 --- a/Tests/XToolTests/BundleAssemblerTests.swift +++ b/Tests/XToolTests/BundleAssemblerTests.swift @@ -110,6 +110,31 @@ struct BundleAssemblerTests { #expect(FileManager.default.fileExists(atPath: output.appendingPathComponent("Contents/Frameworks/libShared.dylib").path)) } + @Test func validatedLooseStaticTargetIsNotEmbeddedAndMissingArchiveFails() throws { + let packageRoot = try root() + defer { try? FileManager.default.removeItem(at: packageRoot) } + let app = try product(resources: [.binaryTarget(name: "StaticProbe")]) + let plan = try Plan(target: app.target, app: app) + let built = try artifacts(plan, in: packageRoot) + try write("!\nstatic archive fixture", at: built.directory.appendingPathComponent("libStaticProbe.a")) + let marked = BuildArtifacts(directory: built.directory, executables: built.executables, + staticBinaryTargets: ["StaticProbe"]) + let output = packageRoot.appendingPathComponent("App.app") + try BundleAssembler(plan: plan, sourceDirectory: packageRoot).assemble(artifacts: marked, at: output) + let embeddedStaticFramework = output.appendingPathComponent("Contents/Frameworks/StaticProbe.framework") + #expect(!FileManager.default.fileExists(atPath: embeddedStaticFramework.path)) + + let missingRoot = try self.root() + defer { try? FileManager.default.removeItem(at: missingRoot) } + let missingBuilt = try artifacts(plan, in: missingRoot) + let missing = BuildArtifacts(directory: missingBuilt.directory, executables: missingBuilt.executables, + staticBinaryTargets: ["StaticProbe"]) + #expect(throws: (any Error).self) { + try BundleAssembler(plan: plan, sourceDirectory: missingRoot) + .assemble(artifacts: missing, at: missingRoot.appendingPathComponent("Missing.app")) + } + } + @Test func preparedAssetsAndIconAreCopied() throws { let root = try root() defer { try? FileManager.default.removeItem(at: root) } diff --git a/Tests/XToolTests/ProductBuilderTests.swift b/Tests/XToolTests/ProductBuilderTests.swift index 7ea9e073..db14284b 100644 --- a/Tests/XToolTests/ProductBuilderTests.swift +++ b/Tests/XToolTests/ProductBuilderTests.swift @@ -40,7 +40,8 @@ struct ProductBuilderTests { nonisolated var runner: ProductBuildRunner { ProductBuildRunner(build: { try await self.build($0, arguments: $1) }, - binaryPath: { await self.binaryPath($0, arguments: $1) }) + binaryPath: { await self.binaryPath($0, arguments: $1) }, + sdkLinkerArguments: { _ in [] }) } } @@ -203,14 +204,19 @@ struct ProductBuilderTests { let helper = root.appendingPathComponent("Sources/Helper") try FileManager.default.createDirectory(at: direct, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: helper.appendingPathComponent("include"), withIntermediateDirectories: true) - try Data("print(\"builder fixture\")".utf8).write(to: direct.appendingPathComponent("main.swift")) + try Data("import AppKit\nprint(NSApplication.self)".utf8).write(to: direct.appendingPathComponent("main.swift")) try Data("resource fixture".utf8).write(to: direct.appendingPathComponent("message.txt")) - try Data("int main(void) { return 0; }".utf8).write(to: helper.appendingPathComponent("Entry.c")) + try Data("#include \nint main() { std::vector values(3, 1); return values.at(1); }".utf8) + .write(to: helper.appendingPathComponent("Entry.cpp")) let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macos15.0") let plan = try Plan(target: target, app: product("App", source: .executable(product: "DirectExecutable"), target: target), + extensions: [product("Preview", source: .library(product: "HelperLibrary"), + kind: .appExtension, target: target)], nestedProducts: [product("Helper", source: .library(product: "HelperLibrary"), - kind: .helperExecutable, target: target)]) + kind: .helperExecutable, target: target), + product("Service", source: .library(product: "HelperLibrary"), + kind: .xpcService, target: target)]) let settings = try await BuildSettings(configuration: configuration, triple: target.triple, buildSystem: .swiftPM, packagePath: root.path) let artifacts = try await withDependencies { @@ -218,10 +224,21 @@ struct ProductBuilderTests { } operation: { try await ProductBuilder(buildSettings: settings, plan: plan).build() } - #expect(artifacts.executables.count == 2) + #expect(artifacts.executables.count == 4) #expect(artifacts.directory.lastPathComponent == configuration.rawValue) #expect(FileManager.default.isExecutableFile(atPath: try #require(artifacts.executables["App"]).path)) #expect(FileManager.default.isExecutableFile(atPath: try #require(artifacts.executables["Helper"]).path)) + let expectedSDK = try #require(try await settings.withTarget(target).macOSLinkerArguments().last) + for product in plan.allProducts { + let executable = try #require(artifacts.executables[product.product]) + let slice = try #require(MachOBinary.inspect(Array(Data(contentsOf: executable))).first) + #expect(slice.platform == 1) + #expect(slice.minimumOS == MachOBinary.packedVersion("15.0")) + #expect(slice.sdk == MachOBinary.packedVersion(expectedSDK)) + #expect(Set(product.runtimeSearchPaths).isSubset(of: Set(slice.rpaths))) + // An ARM64 linker signature is not Developer ID or release signing. + #expect(slice.hasCodeSignature) + } let resource = artifacts.directory.appendingPathComponent("BuilderFixture_Direct.bundle/message.txt") #expect(try String(contentsOf: resource, encoding: .utf8) == "resource fixture") } From cf1c7eb51e3ed33fab07661620b9ea04af9f85b6 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 13 Oct 2026 11:41:25 +0200 Subject: [PATCH 08/59] dev: configure editor tooling for the selected macOS target Make editor configuration follow the same target resolution as builds: - `xtool dev build-server` resolves the target from `--triple`, xtool.yml and the package's platforms through the planner. - With Swift 6.4 and later, `.bsp/xtool.json` launches the build server with the selected `--triple`. Earlier toolchains get a `.sourcekit-lsp/config.json` with the Swift SDK and the versioned compiler triple. - Generated files are refreshed or migrated only when they match xtool's complete generated content; customised files are kept. - Document target selection in Control.md. The new tests build iOS BuildSettings, which on Linux need an installed Darwin SDK, so the integration script now installs the SDK before running `swift test`. --- Documentation/xtool.docc/Control.md | 17 ++ Sources/XToolSupport/DevBSPCommand.swift | 8 +- Sources/XToolSupport/DevCommand.swift | 4 +- Sources/XToolSupport/LSPConfig.swift | 116 +++++++++--- Tests/XToolTests/DevCommandTests.swift | 17 +- Tests/XToolTests/EditorConfigTests.swift | 213 +++++++++++++++++++++++ integration-tests/run.sh | 2 +- 7 files changed, 343 insertions(+), 34 deletions(-) create mode 100644 Tests/XToolTests/EditorConfigTests.swift diff --git a/Documentation/xtool.docc/Control.md b/Documentation/xtool.docc/Control.md index babd0c2e..98810854 100644 --- a/Documentation/xtool.docc/Control.md +++ b/Documentation/xtool.docc/Control.md @@ -15,6 +15,23 @@ bundleID: com.example.Hello EOF ``` +## Select the target platform + +Version 1 configurations keep the `arm64-apple-ios` default. Version 2 adds explicit target and deployment settings: + +```yaml +version: 2 +bundleID: com.example.Hello +target: arm64-apple-macosx +deploymentTarget: "15.0" +``` + +`xtool dev build --triple` and `xtool dev build-server --triple` override `target` in `xtool.yml`. When neither is set, both use `arm64-apple-ios`. macOS targets support `arm64` and `x86_64`; `macos` is accepted as an alias for `macosx`. Build macOS apps with `xtool dev build --defer-signing` and sign the resulting app later on a Mac. On Linux, use `--configuration release`; macOS Debug builds require signing support available on macOS. + +The selected triple's OS version takes precedence over `deploymentTarget`, then the package's matching platform minimum, then xtool's platform minimum (iOS 13.0 or macOS 15.0). An explicit version cannot lower the schema, package, or platform minimum. For example, `arm64-apple-macosx16.0` selects macOS 16.0 while the SDK identity remains `arm64-apple-macosx`. + +Editor support uses the same target resolution and package minimum as builds. Swift 6.4 and later use `.bsp/xtool.json`, whose build-server command selects the effective target. Earlier toolchains use `.sourcekit-lsp/config.json` with separate Swift SDK and compiler triple settings. Restart the editor after changing these files. xtool refreshes or migrates only configurations matching its complete generated content; customized files are preserved. Set `skipLSP: true` to disable automatic editor configuration. + ## Change your bundle ID You can update your app's bundle ID by editing the `bundleID` field in `xtool.yml`. diff --git a/Sources/XToolSupport/DevBSPCommand.swift b/Sources/XToolSupport/DevBSPCommand.swift index 4d3b9f5f..34a22d0a 100644 --- a/Sources/XToolSupport/DevBSPCommand.swift +++ b/Sources/XToolSupport/DevBSPCommand.swift @@ -10,17 +10,15 @@ struct DevBSPCommand: AsyncParsableCommand { abstract: "Run build server", ) - @Option + @Option(help: "Target triple; overrides xtool.yml target, otherwise defaults to arm64-apple-ios") var triple: String? func run() async throws { guard try await SwiftVersion.current.supportsSwiftBuild else { throw Console.Error("`xtool dev build-server` requires Swift 6.4 or later") } - let settings = try await BuildSettings( - configuration: .debug, - triple: triple ?? PackOperation.defaultTriple - ) + let request = try await DevBuildRequest.load(cliTriple: triple) + let (settings, _) = try await EditorTarget.buildSettings(request: request) try await Subprocess.run( settings.buildServerInvocation(), input: .currentStandardInput, diff --git a/Sources/XToolSupport/DevCommand.swift b/Sources/XToolSupport/DevCommand.swift index a98c34f1..300ac5f8 100644 --- a/Sources/XToolSupport/DevCommand.swift +++ b/Sources/XToolSupport/DevCommand.swift @@ -52,8 +52,6 @@ struct PackOperation { """) } - await LSPConfig.tryEnsure(using: schema) - let buildSettings = try await BuildSettings( configuration: buildOptions.configuration, triple: triple ?? Self.defaultTriple, @@ -65,6 +63,7 @@ struct PackOperation { schema: schema ) let plan = try await planner.createPlan() + await LSPConfig.tryEnsure(plan: plan, settings: buildSettings, schema: schema) if xcode { return try await XcodePacker(plan: plan).createProject() @@ -383,6 +382,7 @@ struct DeferredMacBuildOperation { buildSystem: .swiftPM, packagePath: request.directory.path ) let plan = try await Planner(buildSettings: settings, schema: request.schema).createPlan() + await LSPConfig.tryEnsure(plan: plan, settings: settings, schema: request.schema, currentDirectory: request.directory) // This deliberately bypasses PackOperation's automatic entitlement signing. let bundle = try await client.deferredPack(settings, plan) let manifest = try DeferredSigningHandoff.write(plan: plan, bundle: bundle, sourceDirectory: request.directory) diff --git a/Sources/XToolSupport/LSPConfig.swift b/Sources/XToolSupport/LSPConfig.swift index b7482b56..cc484194 100644 --- a/Sources/XToolSupport/LSPConfig.swift +++ b/Sources/XToolSupport/LSPConfig.swift @@ -28,11 +28,12 @@ enum LSPConfig { nonisolated(nonsending) static func tryEnsure( using schema: PackSchema, + cliTriple: String? = nil, quiet: Bool = false, currentDirectory directory: URL = URL(filePath: FileManager.default.currentDirectoryPath) ) async { do { - try await ensure(using: schema, quiet: quiet, currentDirectory: directory) + try await ensure(using: schema, cliTriple: cliTriple, quiet: quiet, currentDirectory: directory) } catch { print("warning: failed to validate LSP configuration: \(error)") } @@ -40,6 +41,7 @@ enum LSPConfig { nonisolated(nonsending) static func ensure( using schema: PackSchema, + cliTriple: String? = nil, quiet: Bool = false, currentDirectory directory: URL = URL(filePath: FileManager.default.currentDirectoryPath) ) async throws { @@ -47,44 +49,110 @@ enum LSPConfig { return } - if try await SwiftVersion.current.supportsSwiftBuild { - try await ensure(primary: bsp, secondary: legacyLSP, quiet: quiet, currentDirectory: directory) + let request = try DevBuildRequest(schema: schema, cliTriple: cliTriple, directory: directory) + let (settings, target) = try await EditorTarget.buildSettings(request: request) + try ensure(target: target, sdkOptions: settings.sdkOptions, + supportsBSP: try await SwiftVersion.current.supportsSwiftBuild, + quiet: quiet, currentDirectory: directory) + } + + /// Builds already planned the package; reuse that plan instead of inspecting the package again. + nonisolated(nonsending) static func tryEnsure( + plan: Plan, + settings: BuildSettings, + schema: PackSchema, + currentDirectory directory: URL = URL(filePath: FileManager.default.currentDirectoryPath) + ) async { + guard schema.skipLSP != true else { return } + do { + try ensure(target: plan.target, sdkOptions: settings.withTarget(plan.target).sdkOptions, + supportsBSP: try await SwiftVersion.current.supportsSwiftBuild, currentDirectory: directory) + } catch { + print("warning: failed to validate LSP configuration: \(error)") + } + } + + static func configuration(target: ResolvedTarget, sdkOptions: [String], bsp: Bool) throws -> (String, String) { + let object: [String: Any] + let path: String + if bsp { + path = Self.bsp.0 + object = [ + "name": "xtool", "version": "1.0", "bspVersion": "2.2.0", + "languages": ["c", "cpp", "objective-c", "objective-cpp", "swift"], + "argv": ["/usr/bin/env", "xtool", "dev", "build-server", "--triple", target.triple], + ] } else { - try await ensure(primary: legacyLSP, secondary: bsp, quiet: quiet, currentDirectory: directory) + path = legacyLSP.0 + let sdkIndex = sdkOptions.firstIndex(of: "--swift-sdk") + // Legacy SourceKit uses SwiftPM even when builds default to SwiftBuild. + let sdkPlatform = switch target.platform { + case .iOS: "ios" + case .iOSSimulator: "ios-simulator" + case .macOS: "macosx" + } + let sdk = sdkIndex.map { sdkOptions[$0 + 1] } ?? "\(target.architecture)-apple-\(sdkPlatform)" + object = ["swiftPM": ["swiftSDK": sdk, "triple": target.compilerTriple]] } + let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) + return (path, String(decoding: data, as: UTF8.self)) } - // creates the primary LSP-support file if it doesn't exist, and cleans up old files - private nonisolated(nonsending) static func ensure( - primary: (String, String), - secondary: (String, String), - quiet: Bool, - currentDirectory directory: URL - ) async throws { + static func ensure( + target: ResolvedTarget, sdkOptions: [String], supportsBSP: Bool, quiet: Bool = false, currentDirectory directory: URL + ) throws { + let primary = try configuration(target: target, sdkOptions: sdkOptions, bsp: supportsBSP) + let secondary = try configuration(target: target, sdkOptions: sdkOptions, bsp: !supportsBSP) let primaryURL = directory.appendingPathComponent(primary.0) if FileManager.default.fileExists(atPath: primaryURL.path) { - return + let data = try Data(contentsOf: primaryURL) + guard isGenerated(data, bsp: supportsBSP) else { return } } - - try? FileManager.default.createDirectory( - at: primaryURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - try Data((primary.1 + "\n").utf8).write(to: primaryURL) + try FileManager.default.createDirectory(at: primaryURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data((primary.1 + "\n").utf8).write(to: primaryURL, options: .atomic) let secondaryURL = directory.appendingPathComponent(secondary.0) - if let secondaryData = try? await Data(reading: secondaryURL), - let secondaryString = String(data: secondaryData, encoding: .utf8), - secondaryString.trimmingCharacters(in: .whitespacesAndNewlines) == secondary.1 { - try? FileManager.default.removeItem(at: secondaryURL) + if FileManager.default.fileExists(atPath: secondaryURL.path), + isGenerated(try Data(contentsOf: secondaryURL), bsp: !supportsBSP) { + try FileManager.default.removeItem(at: secondaryURL) } - if !quiet { print(""" - [LSP] Created \(primary.0) for enhanced IDE support. You may + [LSP] Configured \(primary.0) for enhanced IDE support. You may need to restart your editor for the change to take effect. You can disable this with `skipLSP: true` in xtool.yml. """) } } + + /// Recognize complete generated shapes; any extra key or changed value is user-owned. + private static func isGenerated(_ data: Data, bsp: Bool) -> Bool { + guard let object = try? JSONSerialization.jsonObject(with: data) as? NSDictionary else { return false } + let original = bsp ? Self.bsp.1 : legacyLSP.1 + if let old = try? JSONSerialization.jsonObject(with: Data(original.utf8)) as? NSDictionary, object == old { + return true + } + let triple: String? + if bsp { + let argv = object["argv"] as? [String] + triple = argv?.count == 6 ? argv?.last : nil + } else { + triple = (object["swiftPM"] as? [String: String])?["triple"] + } + guard let triple, let target = try? ResolvedTarget.resolve(cliTarget: triple), + let expected = try? configuration(target: target, sdkOptions: [], bsp: bsp), + let generated = try? JSONSerialization.jsonObject(with: Data(expected.1.utf8)) as? NSDictionary else { + return false + } + return object == generated + } +} + +/// Use the same package deployment minima and target contract as app builds. +enum EditorTarget { + nonisolated(nonsending) static func buildSettings(request: DevBuildRequest) async throws -> (BuildSettings, ResolvedTarget) { + let settings = try await BuildSettings(configuration: .debug, triple: request.triple, packagePath: request.directory.path) + let plan = try await Planner(buildSettings: settings, schema: request.schema).createPlan() + return (try settings.withTarget(plan.target), plan.target) + } } diff --git a/Tests/XToolTests/DevCommandTests.swift b/Tests/XToolTests/DevCommandTests.swift index 03dea1ba..3892fb47 100644 --- a/Tests/XToolTests/DevCommandTests.swift +++ b/Tests/XToolTests/DevCommandTests.swift @@ -245,8 +245,21 @@ struct DevCommandTests { let relative = try #require(handoff.products[0].entitlementsPath) let copied = manifestURL.deletingLastPathComponent().appendingPathComponent(relative) #expect(try Data(contentsOf: copied) == entitlementData) - #expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent(".bsp").path)) - #expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent(".sourcekit-lsp").path)) + let supportsBSP = try await SwiftVersion.current.supportsSwiftBuild + let editorConfigPath = supportsBSP ? LSPConfig.bsp.0 : LSPConfig.legacyLSP.0 + let alternateConfigPath = supportsBSP ? LSPConfig.legacyLSP.0 : LSPConfig.bsp.0 + #expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent(alternateConfigPath).path)) + let editorConfigURL = directory.appendingPathComponent(editorConfigPath) + let editorConfig = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: editorConfigURL)) as? [String: Any]) + if supportsBSP { + #expect(editorConfig["argv"] as? [String] == [ + "/usr/bin/env", "xtool", "dev", "build-server", "--triple", "arm64-apple-macosx", + ]) + } else { + #expect(editorConfig["swiftPM"] as? [String: String] == [ + "swiftSDK": "arm64-apple-macosx", "triple": "arm64-apple-macosx15.0", + ]) + } } private func product(_ name: String, kind: Plan.ProductType, target: ResolvedTarget, entitlements: String?) -> Plan.Product { diff --git a/Tests/XToolTests/EditorConfigTests.swift b/Tests/XToolTests/EditorConfigTests.swift new file mode 100644 index 00000000..93c0a7fa --- /dev/null +++ b/Tests/XToolTests/EditorConfigTests.swift @@ -0,0 +1,213 @@ +import ArgumentParser +import Dependencies +import Foundation +import Testing +@testable import PackLib +@testable import XToolSupport + +struct EditorConfigTests { + private func root() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func write(_ config: (String, String), to root: URL) throws { + let url = root.appendingPathComponent(config.0) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(config.1.utf8).write(to: url) + } + + private func read(_ path: String, in root: URL) throws -> Data { + try Data(contentsOf: root.appendingPathComponent(path)) + } + + @Test(arguments: [true, false]) + func newConfigurationsKeepIOSDefaultAndSelectMacOS(bsp: Bool) throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let ios = try ResolvedTarget.resolve() + try LSPConfig.ensure(target: ios, sdkOptions: [], supportsBSP: bsp, quiet: true, currentDirectory: directory) + let path = bsp ? LSPConfig.bsp.0 : LSPConfig.legacyLSP.0 + let iosText = String(decoding: try read(path, in: directory), as: UTF8.self) + #expect(iosText.contains("arm64-apple-ios")) + let mac = try ResolvedTarget.resolve(cliTarget: "x86_64-apple-macos15.0") + try LSPConfig.ensure(target: mac, sdkOptions: [], supportsBSP: bsp, quiet: true, currentDirectory: directory) + let object = try #require(JSONSerialization.jsonObject(with: read(path, in: directory)) as? [String: Any]) + if bsp { + #expect(object["argv"] as? [String] == ["/usr/bin/env", "xtool", "dev", "build-server", "--triple", mac.triple]) + } else { + #expect(object["swiftPM"] as? [String: String] == [ + "swiftSDK": "x86_64-apple-macosx", "triple": "x86_64-apple-macosx15.0", + ]) + } + } + + @Test(arguments: [true, false]) + func oldGeneratedConfigsMigrateInBothDirections(bsp: Bool) throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + try write(LSPConfig.bsp, to: directory) + try write(LSPConfig.legacyLSP, to: directory) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx16.0") + try LSPConfig.ensure(target: target, sdkOptions: [], supportsBSP: bsp, quiet: true, currentDirectory: directory) + let primary = bsp ? LSPConfig.bsp.0 : LSPConfig.legacyLSP.0 + let secondary = bsp ? LSPConfig.legacyLSP.0 : LSPConfig.bsp.0 + #expect(String(decoding: try read(primary, in: directory), as: UTF8.self).contains("arm64-apple-macosx16.0")) + #expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent(secondary).path)) + // Reversing direction recognizes the newly generated content too. + try LSPConfig.ensure(target: target, sdkOptions: [], supportsBSP: !bsp, quiet: true, currentDirectory: directory) + #expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent(primary).path)) + #expect(FileManager.default.fileExists(atPath: directory.appendingPathComponent(secondary).path)) + } + + @Test(arguments: [true, false]) + func userPrimaryPreservesBothFiles(bsp: Bool) throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let primary = bsp ? LSPConfig.bsp.0 : LSPConfig.legacyLSP.0 + let secondary = bsp ? LSPConfig.legacyLSP : LSPConfig.bsp + let userContent = "{\"custom\": true}" + try write((primary, userContent), to: directory) + try write(secondary, to: directory) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx") + try LSPConfig.ensure(target: target, sdkOptions: [], supportsBSP: bsp, quiet: true, currentDirectory: directory) + #expect(try read(primary, in: directory) == Data(userContent.utf8)) + #expect(try read(secondary.0, in: directory) == Data(secondary.1.utf8)) + } + + @Test(arguments: [true, false]) + func userSecondaryIsNeverDeleted(bsp: Bool) throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx") + let secondary = try LSPConfig.configuration(target: target, sdkOptions: [], bsp: !bsp) + var object = try #require(JSONSerialization.jsonObject(with: Data(secondary.1.utf8)) as? [String: Any]) + object["custom"] = true + let contents = try JSONSerialization.data(withJSONObject: object) + try write((secondary.0, String(decoding: contents, as: UTF8.self)), to: directory) + try LSPConfig.ensure(target: target, sdkOptions: [], supportsBSP: bsp, quiet: true, currentDirectory: directory) + #expect(try read(secondary.0, in: directory) == contents) + } + + @Test(arguments: [true, false]) + func changedGeneratedValuesAreUserOwned(bsp: Bool) throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let target = try ResolvedTarget.resolve() + let generated = try LSPConfig.configuration(target: target, sdkOptions: [], bsp: bsp) + let customized = bsp ? generated.1.replacingOccurrences(of: "xtool", with: "custom-tool") + : generated.1.replacingOccurrences(of: "arm64-apple-ios13.0", with: "arm64-apple-ios12.0") + try write((generated.0, customized), to: directory) + let mac = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx") + try LSPConfig.ensure(target: mac, sdkOptions: [], supportsBSP: bsp, quiet: true, currentDirectory: directory) + #expect(try read(generated.0, in: directory) == Data(customized.utf8)) + } + + @Test func malformedPrimaryIsPreserved() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + try write((LSPConfig.legacyLSP.0, "not JSON"), to: directory) + let target = try ResolvedTarget.resolve() + try LSPConfig.ensure(target: target, sdkOptions: [], supportsBSP: false, quiet: true, currentDirectory: directory) + #expect(try read(LSPConfig.legacyLSP.0, in: directory) == Data("not JSON".utf8)) + } + + @Test func skipLSPAvoidsInspectionAndWrites() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let schema = try PackSchema(validating: .init(version: .v1, orgID: "test", skipLSP: true)) + try await LSPConfig.ensure(using: schema, quiet: true, currentDirectory: directory) + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty) + } + + private func inspection(root: URL, platform: String, minimum: String) throws -> PackageInspectionClient { + let dependencies = try JSONSerialization.data(withJSONObject: [ + "identity": "root", "path": root.path, "dependencies": [], + ]) + let description = try JSONSerialization.data(withJSONObject: [ + "name": "Fixture", "platforms": [["name": platform, "version": minimum]], + "products": [["name": "App", "targets": ["Main"], "type": ["executable": NSNull()]]], + "targets": [["name": "Main", "type": "executable", "module_type": "SwiftTarget", "resources": []]], + ]) + let manifest = try JSONSerialization.data(withJSONObject: [ + "targets": [["name": "Main", "type": "executable", "dependencies": []]], "dependencies": [], + ]) + return PackageInspectionClient( + dependencies: { _ in dependencies }, describe: { _ in description }, manifest: { _ in manifest } + ) + } + + @Test(arguments: [nil, "x86_64-apple-macosx17.0"]) + func buildServerMatchesPlannerTargetAndDeployment(override: String?) async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let schema = try PackSchema(validating: .init( + version: .v2, orgID: "test", product: "App", target: "arm64-apple-macosx", deploymentTarget: "16.0" + )) + let command = try DevBSPCommand.parse(override.map { ["--triple", $0] } ?? []) + let request = try DevBuildRequest(schema: schema, cliTriple: command.triple, directory: directory) + let inspector = try inspection(root: directory, platform: "macos", minimum: "15.0") + try await withDependencies { + $0.packageInspection = inspector + } operation: { + let (settings, target) = try await EditorTarget.buildSettings(request: request) + #expect(settings.buildSystem == .swiftPM) + #expect(settings.sdkOptions == [ + "--swift-sdk", override == nil ? "arm64-apple-macosx" : "x86_64-apple-macosx", + "--triple", override ?? "arm64-apple-macosx16.0", + ]) + let plan = try await Planner(buildSettings: settings, schema: schema).createPlan() + #expect(plan.target == target) + #expect(settings.buildServerArguments.contains(target.compilerTriple)) + let config = try LSPConfig.configuration(target: target, sdkOptions: settings.sdkOptions, bsp: false) + #expect(config.1.contains(target.compilerTriple)) + } + } + + @Test(arguments: [true, false]) + func buildServerKeepsIOSDefaultAndPackageMinimum(bsp: Bool) async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let schema = try PackSchema(validating: .init(version: .v1, orgID: "test", product: "App")) + let inspector = try inspection(root: directory, platform: "ios", minimum: "14.0") + try await withDependencies { + $0.packageInspection = inspector + } operation: { + let request = try DevBuildRequest(schema: schema, directory: directory) + let (settings, target) = try await EditorTarget.buildSettings(request: request) + #expect(request.triple == ResolvedTarget.defaultTriple) + #expect(target.compilerTriple == "arm64-apple-ios14.0") + #expect(settings.buildServerArguments.contains("arm64-apple-ios14.0")) + let config = try LSPConfig.configuration(target: target, sdkOptions: settings.sdkOptions, bsp: bsp) + let object = try #require(JSONSerialization.jsonObject(with: Data(config.1.utf8)) as? [String: Any]) + if bsp { + #expect(config.0 == LSPConfig.bsp.0) + #expect(object["argv"] as? [String] == [ + "/usr/bin/env", "xtool", "dev", "build-server", "--triple", "arm64-apple-ios", + ]) + } else { + #expect(config.0 == LSPConfig.legacyLSP.0) + #expect(object["swiftPM"] as? [String: String] == [ + "swiftSDK": "arm64-apple-ios", "triple": "arm64-apple-ios14.0", + ]) + } + } + } + + @Test func packageMinimumControlsEditorAndRejectsLowerOverrides() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let schema = try PackSchema(validating: .init(version: .v2, orgID: "test", product: "App", target: "arm64-apple-macosx")) + let inspector = try inspection(root: directory, platform: "macos", minimum: "16.0") + try await withDependencies { + $0.packageInspection = inspector + } operation: { + let request = try DevBuildRequest(schema: schema, directory: directory) + let (_, target) = try await EditorTarget.buildSettings(request: request) + #expect(target.compilerTriple == "arm64-apple-macosx16.0") + let lower = try DevBuildRequest(schema: schema, cliTriple: "arm64-apple-macosx15.0", directory: directory) + await #expect(throws: (any Error).self) { try await EditorTarget.buildSettings(request: lower) } + } + } +} diff --git a/integration-tests/run.sh b/integration-tests/run.sh index eb70d5d7..19712e78 100755 --- a/integration-tests/run.sh +++ b/integration-tests/run.sh @@ -34,7 +34,6 @@ cd "$(dirname "$0")/.." project_dir="$PWD" swift build --product xtool -swift test ln -fs $PWD/.build/debug/xtool /usr/local/bin/xtool hash -r @@ -46,4 +45,5 @@ echo "Installing SDK" xtool sdk install --slim "$XTL_TEST_XCODE_APP" echo "Running tests..." +swift test --package-path "$project_dir" bats "$project_dir/integration-tests/suite" From 3292856ff02bcb0465b0a54fdb553b9f25b6d7d6 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 13 Oct 2026 12:03:23 +0200 Subject: [PATCH 09/59] packer: coalesce shared helper dependencies Helpers share the Contents/Helpers location, so two helpers that depend on the same library or resource bundle generated identical copy operations and failed with a destination collision. Merge identical generated operations across helpers. A dependency repeated within one helper, or an explicit copy that collides with a generated one, is still rejected. --- Sources/PackLib/BundleAssembler.swift | 33 ++++++--- Tests/XToolTests/BundleAssemblerTests.swift | 74 +++++++++++++++++++++ 2 files changed, 97 insertions(+), 10 deletions(-) diff --git a/Sources/PackLib/BundleAssembler.swift b/Sources/PackLib/BundleAssembler.swift index caf4bf52..d77dff3e 100644 --- a/Sources/PackLib/BundleAssembler.swift +++ b/Sources/PackLib/BundleAssembler.swift @@ -32,13 +32,13 @@ public struct BundleAssembler: Sendable { } } - private enum Payload { + private enum Payload: Hashable { case copy(URL) case data(Data) case symlink(String) } - private struct Operation { + private struct Operation: Hashable { var path: String var payload: Payload } @@ -46,6 +46,7 @@ public struct BundleAssembler: Sendable { private struct Assembly { var directories: Set = [] var operations: [Operation] = [] + var helperDependencies: [Operation: Set] = [:] } private func operations(artifacts: BuildArtifacts) throws -> Assembly { @@ -89,14 +90,16 @@ public struct BundleAssembler: Sendable { try validateName(target) let name = "\(package)_\(target).bundle" let path = joined(layout.resourcesPath, name) - assembly.operations.append(.init(path: path, payload: .copy(artifacts.directory.appendingPathComponent(name)))) + appendDependency(.init(path: path, payload: .copy(artifacts.directory.appendingPathComponent(name))), + product: product, to: &assembly) if product.platform == .macOS { // SwiftPM's generated Bundle.module accessor searches beside Bundle.main.bundleURL. // Keep one payload in Resources and expose that path through a relocatable relative link. let aliasBase = product.type == .helperExecutable ? layout.contentsPath : layout.bundlePath - assembly.operations.append(.init(path: joined(aliasBase, name), - payload: .symlink(product.type == .helperExecutable - ? "Resources/\(name)" : "Contents/Resources/\(name)"))) + appendDependency(.init(path: joined(aliasBase, name), + payload: .symlink(product.type == .helperExecutable + ? "Resources/\(name)" : "Contents/Resources/\(name)")), + product: product, to: &assembly) } case .binaryTarget(let name): try validateName(name) @@ -120,16 +123,16 @@ public struct BundleAssembler: Sendable { throw StringError("Framework '\(name)' has no readable binary at \(binary.path)") } if try !isArchive(binary) { - assembly.operations.append(.init(path: joined(layout.frameworksPath, source.lastPathComponent), - payload: .copy(source))) + appendDependency(.init(path: joined(layout.frameworksPath, source.lastPathComponent), payload: .copy(source)), + product: product, to: &assembly) } case .library(let name): try validateName(name) let source = artifacts.directory.appendingPathComponent("lib\(name).dylib") try validateInput(source) if try !isArchive(source) { - assembly.operations.append(.init(path: joined(layout.frameworksPath, source.lastPathComponent), - payload: .copy(source))) + appendDependency(.init(path: joined(layout.frameworksPath, source.lastPathComponent), payload: .copy(source)), + product: product, to: &assembly) } case .root(let path): let source = sourceURL(path) @@ -141,6 +144,16 @@ public struct BundleAssembler: Sendable { } } + private func appendDependency(_ operation: Operation, product: Plan.Product, to assembly: inout Assembly) { + if product.type == .helperExecutable { + // Helpers share dependency directories. Coalesce exact generated operations across helpers, + // but retain repeated declarations within one helper so collision validation still rejects them. + let inserted = assembly.helperDependencies[operation, default: []].insert(product.product).inserted + if inserted && assembly.helperDependencies[operation]?.count != 1 { return } + } + assembly.operations.append(operation) + } + private func plist(for product: Plan.Product) throws -> Data { var info = product.infoPlist if product.type == .application && product.platform != .macOS { diff --git a/Tests/XToolTests/BundleAssemblerTests.swift b/Tests/XToolTests/BundleAssemblerTests.swift index 1652fca2..49f0a1da 100644 --- a/Tests/XToolTests/BundleAssemblerTests.swift +++ b/Tests/XToolTests/BundleAssemblerTests.swift @@ -110,6 +110,80 @@ struct BundleAssemblerTests { #expect(FileManager.default.fileExists(atPath: output.appendingPathComponent("Contents/Frameworks/libShared.dylib").path)) } + @Test func helpersShareGeneratedDependenciesAfterRelocation() throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let resources: [Plan.Resource] = [.binaryTarget(name: "Dynamic"), .library(name: "Shared"), + .bundle(package: "Shared", target: "Resources")] + let app = try product() + let helpers = try ["FirstHelper", "SecondHelper"].map { + try product($0, kind: .helperExecutable, resources: resources) + } + let plan = try Plan(target: app.target, app: app, nestedProducts: helpers) + let artifacts = try artifacts(plan, in: root) + _ = try framework("Dynamic", in: artifacts.directory) + try write("dynamic library", at: artifacts.directory.appendingPathComponent("libShared.dylib")) + try write("shared resource", at: artifacts.directory.appendingPathComponent("Shared_Resources.bundle/message.txt")) + let output = root.appendingPathComponent("App.app") + try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: artifacts, at: output) + + let relocated = root.appendingPathComponent("Relocated.app") + try FileManager.default.moveItem(at: output, to: relocated) + try FileManager.default.removeItem(at: artifacts.directory) + for helper in helpers { + #expect(FileManager.default.isExecutableFile(atPath: relocated.appendingPathComponent(helper.layout.executablePath).path)) + } + let framework = relocated.appendingPathComponent("Contents/Helpers/Frameworks/Dynamic.framework") + #expect(try String(contentsOf: framework.appendingPathComponent("Dynamic"), encoding: .utf8) == "dynamic fixture") + let current = framework.appendingPathComponent("Versions/Current") + #expect(try FileManager.default.destinationOfSymbolicLink(atPath: current.path) == "A") + let library = relocated.appendingPathComponent("Contents/Helpers/Frameworks/libShared.dylib") + #expect(try String(contentsOf: library, encoding: .utf8) == "dynamic library") + let alias = relocated.appendingPathComponent("Contents/Helpers/Shared_Resources.bundle") + #expect(try FileManager.default.destinationOfSymbolicLink(atPath: alias.path) == "Resources/Shared_Resources.bundle") + #expect(try String(contentsOf: alias.appendingPathComponent("message.txt"), encoding: .utf8) == "shared resource") + } + + @Test(arguments: [Plan.Resource.bundle(package: "Shared", target: "Resources"), + .binaryTarget(name: "Dynamic"), .library(name: "Shared")]) + func duplicateGeneratedDependenciesWithinHelperReject(resource: Plan.Resource) throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let app = try product() + let helper = try product("Helper", kind: .helperExecutable, resources: [resource, resource]) + let plan = try Plan(target: app.target, app: app, nestedProducts: [helper]) + let artifacts = try artifacts(plan, in: root) + _ = try framework("Dynamic", in: artifacts.directory) + try write(at: artifacts.directory.appendingPathComponent("libShared.dylib")) + try write(at: artifacts.directory.appendingPathComponent("Shared_Resources.bundle/message.txt")) + let output = root.appendingPathComponent("App.app") + #expect(throws: (any Error).self) { + try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: artifacts, at: output) + } + #expect(!FileManager.default.fileExists(atPath: output.path)) + } + + @Test(arguments: ["build/Shared_Resources.bundle", "alternate/Shared_Resources.bundle"], + ["Shared_Resources.bundle", "shared_resources.bundle", "Shared_Resources.bundle/child"]) + func explicitHelperCopiesCollidingWithGeneratedDependenciesReject(source: String, path: String) throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let app = try product() + let first = try product("FirstHelper", kind: .helperExecutable, + resources: [.bundle(package: "Shared", target: "Resources")]) + let second = try product("SecondHelper", kind: .helperExecutable, + resources: [.copy(.init(source: source, destination: .init(path: path)))]) + let plan = try Plan(target: app.target, app: app, nestedProducts: [first, second]) + let artifacts = try artifacts(plan, in: root) + try write(at: artifacts.directory.appendingPathComponent("Shared_Resources.bundle/message.txt")) + try write(at: root.appendingPathComponent("alternate/Shared_Resources.bundle/message.txt")) + let output = root.appendingPathComponent("App.app") + #expect(throws: (any Error).self) { + try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: artifacts, at: output) + } + #expect(!FileManager.default.fileExists(atPath: output.path)) + } + @Test func validatedLooseStaticTargetIsNotEmbeddedAndMissingArchiveFails() throws { let packageRoot = try root() defer { try? FileManager.default.removeItem(at: packageRoot) } From 9ad216d8206188a229b986634b87d3d9e9eb13e8 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 13 Oct 2026 12:59:26 +0200 Subject: [PATCH 10/59] packer: make macOS resource bundles signable codesign rejects anything outside Contents/ in a macOS bundle, so the resource-bundle links at the bundle root made every app with a SwiftPM resource target unsignable. Keep one copy of each resource bundle in Contents/Resources instead: - For macOS products with resource bundles, ProductBuilder rebuilds the product with a compiler VFS overlay that adapts SwiftPM's generated `Bundle.module` accessor to look in `Bundle.main.resourceURL`, falling back to the directory-based lookup. Package sources are not edited. Only Swift resource targets are supported. - Helpers place their resources in the app's Contents/Resources and keep a relative link in Contents/Helpers for the executable-directory lookup. - Identical generated operations are now merged across all products, not only helpers. - Prebuilt macOS products that need SwiftPM resource bundles are rejected, since their accessors cannot be adapted. --- Sources/PackLib/BundleAssembler.swift | 24 ++-- Sources/PackLib/BundleLayout.swift | 4 +- Sources/PackLib/ProductBuilder.swift | 11 +- Sources/PackLib/SwiftPMResourceAccessor.swift | 71 ++++++++++++ Tests/XToolTests/BundleAssemblerTests.swift | 101 +++++++++++++---- Tests/XToolTests/PackContractTests.swift | 1 + .../SwiftPMResourceAccessorTests.swift | 106 ++++++++++++++++++ 7 files changed, 282 insertions(+), 36 deletions(-) create mode 100644 Sources/PackLib/SwiftPMResourceAccessor.swift create mode 100644 Tests/XToolTests/SwiftPMResourceAccessorTests.swift diff --git a/Sources/PackLib/BundleAssembler.swift b/Sources/PackLib/BundleAssembler.swift index d77dff3e..be59d91c 100644 --- a/Sources/PackLib/BundleAssembler.swift +++ b/Sources/PackLib/BundleAssembler.swift @@ -46,7 +46,7 @@ public struct BundleAssembler: Sendable { private struct Assembly { var directories: Set = [] var operations: [Operation] = [] - var helperDependencies: [Operation: Set] = [:] + var dependencyProducts: [Operation: Set] = [:] } private func operations(artifacts: BuildArtifacts) throws -> Assembly { @@ -92,13 +92,11 @@ public struct BundleAssembler: Sendable { let path = joined(layout.resourcesPath, name) appendDependency(.init(path: path, payload: .copy(artifacts.directory.appendingPathComponent(name))), product: product, to: &assembly) - if product.platform == .macOS { - // SwiftPM's generated Bundle.module accessor searches beside Bundle.main.bundleURL. - // Keep one payload in Resources and expose that path through a relocatable relative link. - let aliasBase = product.type == .helperExecutable ? layout.contentsPath : layout.bundlePath - appendDependency(.init(path: joined(aliasBase, name), - payload: .symlink(product.type == .helperExecutable - ? "Resources/\(name)" : "Contents/Resources/\(name)")), + if product.platform == .macOS && product.type == .helperExecutable { + // Bare helpers retain SwiftPM's executable-directory lookup. Real bundles use + // the resourceURL accessor compiled by ProductBuilder; root aliases cannot be signed. + appendDependency(.init(path: joined(layout.contentsPath, name), + payload: .symlink("../Resources/\(name)")), product: product, to: &assembly) } case .binaryTarget(let name): @@ -145,12 +143,10 @@ public struct BundleAssembler: Sendable { } private func appendDependency(_ operation: Operation, product: Plan.Product, to assembly: inout Assembly) { - if product.type == .helperExecutable { - // Helpers share dependency directories. Coalesce exact generated operations across helpers, - // but retain repeated declarations within one helper so collision validation still rejects them. - let inserted = assembly.helperDependencies[operation, default: []].insert(product.product).inserted - if inserted && assembly.helperDependencies[operation]?.count != 1 { return } - } + // Helpers share resources with the app and dependencies with other helpers. Coalesce exact + // generated operations across products, but reject repeated declarations within one product. + let inserted = assembly.dependencyProducts[operation, default: []].insert(product.product).inserted + if inserted && assembly.dependencyProducts[operation]?.count != 1 { return } assembly.operations.append(operation) } diff --git a/Sources/PackLib/BundleLayout.swift b/Sources/PackLib/BundleLayout.swift index c769da60..c66478c8 100644 --- a/Sources/PackLib/BundleLayout.swift +++ b/Sources/PackLib/BundleLayout.swift @@ -37,7 +37,9 @@ public struct BundleLayout: Sendable, Equatable { } public var resourcesPath: String { - joined(contentsPath, platform == .macOS ? "Resources" : "") + // Helpers is a nested-code location; codeless payloads belong in the outer resource seal. + if platform == .macOS && kind == .helperExecutable { return "Contents/Resources" } + return joined(contentsPath, platform == .macOS ? "Resources" : "") } public var frameworksPath: String { joined(contentsPath, "Frameworks") } diff --git a/Sources/PackLib/ProductBuilder.swift b/Sources/PackLib/ProductBuilder.swift index 9a675d98..33f90057 100644 --- a/Sources/PackLib/ProductBuilder.swift +++ b/Sources/PackLib/ProductBuilder.swift @@ -75,6 +75,11 @@ public struct ProductBuilder: Sendable { throw StringError("Build backend produced products in different resource directories") } directory = binDir + if let overlay = try SwiftPMResourceAccessor.overlay(for: product, in: binDir, output: wrapper) { + // SwiftPM regenerates accessors during planning. A compiler overlay adapts only + // generated target sources, without editing dependencies or racing that regeneration. + try await runner.build(productSettings, arguments + ["-Xswiftc", "-vfsoverlay", "-Xswiftc", overlay.path]) + } let executable = binDir.appendingPathComponent(name) guard FileManager.default.isExecutableFile(atPath: executable.path) else { throw StringError("Build backend did not produce executable '\(name)' at \(executable.path)") @@ -164,7 +169,11 @@ public struct ProductBuilder: Sendable { } switch product.resolvedSource { case .prebuilt: - break + if product.platform == .macOS && product.type != .helperExecutable + && product.resources.contains(where: { if case .bundle = $0 { return true }; return false }) { + throw StringError("Prebuilt macOS product '\(product.product)' cannot adapt SwiftPM resource accessors; " + + "build it from source or supply resources with an explicit copy and its own lookup") + } case .executable where buildSettings.buildSystem != .swiftPM: throw StringError("Direct executable products currently require the SwiftPM backend") case .library(let name), .executable(let name): diff --git a/Sources/PackLib/SwiftPMResourceAccessor.swift b/Sources/PackLib/SwiftPMResourceAccessor.swift new file mode 100644 index 00000000..d5086c57 --- /dev/null +++ b/Sources/PackLib/SwiftPMResourceAccessor.swift @@ -0,0 +1,71 @@ +import Foundation +import XUtils + +/// Native SwiftPM generates bundle-root lookup, which requires unsigned root aliases on macOS. +/// Compile an adapted view of those generated sources; keep package sources and build fallbacks intact. +enum SwiftPMResourceAccessor { + static func overlay(for product: Plan.Product, in directory: URL, output: URL) throws -> URL? { + guard product.platform == .macOS else { return nil } + let names = Set(product.resources.compactMap { resource -> String? in + guard case .bundle(let package, let target) = resource else { return nil } + return "\(package)_\(target).bundle" + }) + guard !names.isEmpty else { return nil } + let manager = FileManager.default + // Keep the backend's spelling (not Foundation's canonicalized /private/var spelling): + // VFS names must match the compiler's source arguments exactly. + let candidates = try manager.contentsOfDirectory(atPath: directory.path) + .filter { $0.hasSuffix(".build") && !$0.hasSuffix("-tool.build") } + .map { directory.appendingPathComponent($0) } + .map { $0.appendingPathComponent("DerivedSources/resource_bundle_accessor.swift") } + .filter { manager.fileExists(atPath: $0.path) } + .sorted { $0.path < $1.path } + var matched: Set = [] + var files: [(URL, String)] = [] + for candidate in candidates { + let source = try String(contentsOf: candidate, encoding: .utf8) + for name in names where source.contains("appendingPathComponent(\(String(reflecting: name)))") { + guard matched.insert(name).inserted else { + throw StringError("Ambiguous generated SwiftPM accessor for '\(name)'") + } + files.append((candidate, try adapted(source, bundleName: name))) + } + } + guard matched == names else { + throw StringError("Missing supported generated SwiftPM resource accessor for: " + + names.subtracting(matched).sorted().joined(separator: ", ") + + "; macOS bundles currently require Swift resource targets") + } + let destination = output.appendingPathComponent("resource-accessors/\(product.targetName)") + try manager.createDirectory(at: destination, withIntermediateDirectories: true) + var roots: [[String: String]] = [] + for (index, file) in files.enumerated() { + let copy = destination.appendingPathComponent("\(index).swift") + try Data(file.1.utf8).write(to: copy, options: .atomic) + roots.append(["type": "file", "name": file.0.path, "external-contents": copy.path]) + } + let overlay = destination.appendingPathComponent("overlay.json") + let data = try JSONSerialization.data(withJSONObject: [ + "version": 0, "use-external-names": false, "roots": roots, + ], options: [.sortedKeys]) + try data.write(to: overlay, options: .atomic) + return overlay + } + + static func adapted(_ source: String, bundleName: String) throws -> String { + let lookup = "let mainPath = Bundle.main.bundleURL.appendingPathComponent(\(String(reflecting: bundleName))).path" + guard source.contains("extension Foundation.Bundle {"), + source.components(separatedBy: lookup).count == 2, + source.components(separatedBy: "Bundle(path: mainPath)").count == 2 else { + throw StringError("Unsupported generated SwiftPM resource accessor for '\(bundleName)'") + } + let replacement = lookup.replacingOccurrences(of: "Bundle.main.bundleURL", + with: "(Bundle.main.resourceURL ?? Bundle.main.bundleURL)") + // Keep directory-based helper lookup too. All products need the same accessor because a + // resource target can live in a dylib shared by app, services, and helpers in one build tree. + let fallback = "Bundle(path: mainPath) ?? Bundle(url: Bundle.main.bundleURL.appendingPathComponent(" + + String(reflecting: bundleName) + "))" + return source.replacingOccurrences(of: lookup, with: replacement) + .replacingOccurrences(of: "Bundle(path: mainPath)", with: fallback) + } +} diff --git a/Tests/XToolTests/BundleAssemblerTests.swift b/Tests/XToolTests/BundleAssemblerTests.swift index 49f0a1da..e5de4abf 100644 --- a/Tests/XToolTests/BundleAssemblerTests.swift +++ b/Tests/XToolTests/BundleAssemblerTests.swift @@ -45,7 +45,8 @@ struct BundleAssemblerTests { let app = try product(resources: [.copy(.init(source: "agent.plist", destination: destination))]) let ext = try product("Preview", kind: .appExtension) let xpc = try product("Service", kind: .xpcService) - let helper = try product("Helper", kind: .helperExecutable) + let helper = try product("Helper", kind: .helperExecutable, + resources: [.copy(.init(source: "agent.plist", destination: .init(path: "Helper.plist")))]) let plan = try Plan(target: app.target, app: app, extensions: [ext], nestedProducts: [xpc, helper]) let artifacts = try artifacts(plan, in: root) let output = root.appendingPathComponent("App.app") @@ -70,9 +71,30 @@ struct BundleAssemblerTests { #expect(FileManager.default.fileExists(atPath: output.appendingPathComponent("Contents/Helpers/Helper").path)) let agentCopy = output.appendingPathComponent("Contents/Library/LaunchAgents/test.plist") #expect(try Data(contentsOf: agentCopy) == Data(contentsOf: agent)) + #expect(try Data(contentsOf: output.appendingPathComponent("Contents/Resources/Helper.plist")) == Data(contentsOf: agent)) #expect(!FileManager.default.fileExists(atPath: output.appendingPathComponent("Contents/Helpers/Info.plist").path)) } + @Test func macOSResourceBundlesNeverCreateBundleRootAliases() throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let resources: [Plan.Resource] = [.bundle(package: "Package", target: "Resources")] + let app = try product(resources: resources) + let ext = try product("Preview", kind: .appExtension, resources: resources) + let xpc = try product("Service", kind: .xpcService, resources: resources) + let plan = try Plan(target: app.target, app: app, extensions: [ext], nestedProducts: [xpc]) + let artifacts = try artifacts(plan, in: root) + try write(at: artifacts.directory.appendingPathComponent("Package_Resources.bundle/message.txt")) + let output = root.appendingPathComponent("App.app") + try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: artifacts, at: output) + for product in plan.allProducts { + let bundle = output.appendingPathComponent(product.layout.bundlePath) + #expect(try FileManager.default.contentsOfDirectory(atPath: bundle.path) == ["Contents"]) + let resource = output.appendingPathComponent(product.layout.resourcesPath + "/Package_Resources.bundle/message.txt") + #expect(try String(contentsOf: resource, encoding: .utf8) == "fixture") + } + } + private func framework(_ name: String, in directory: URL, binary: String = "dynamic fixture") throws -> URL { let framework = directory.appendingPathComponent("\(name).framework") try write(binary, at: framework.appendingPathComponent("Versions/A/\(name)"), executable: true) @@ -140,8 +162,9 @@ struct BundleAssemblerTests { let library = relocated.appendingPathComponent("Contents/Helpers/Frameworks/libShared.dylib") #expect(try String(contentsOf: library, encoding: .utf8) == "dynamic library") let alias = relocated.appendingPathComponent("Contents/Helpers/Shared_Resources.bundle") - #expect(try FileManager.default.destinationOfSymbolicLink(atPath: alias.path) == "Resources/Shared_Resources.bundle") + #expect(try FileManager.default.destinationOfSymbolicLink(atPath: alias.path) == "../Resources/Shared_Resources.bundle") #expect(try String(contentsOf: alias.appendingPathComponent("message.txt"), encoding: .utf8) == "shared resource") + #expect(!FileManager.default.fileExists(atPath: relocated.appendingPathComponent("Contents/Helpers/Resources").path)) } @Test(arguments: [Plan.Resource.bundle(package: "Shared", target: "Resources"), @@ -355,28 +378,49 @@ struct BundleAssemblerTests { } #if os(macOS) - @Test func generatedSwiftPMAccessorFindsRelocatedResources() async throws { + @Test(arguments: [BuildConfiguration.debug, .release]) + func generatedSwiftPMAccessorFindsRelocatedResources(configuration: BuildConfiguration) async throws { let root = try root() defer { try? FileManager.default.removeItem(at: root) } let package = root.appendingPathComponent("package") - let manifest = """ + try write(""" // swift-tools-version: 6.0 import PackageDescription let package = Package(name: "RelocationFixture", platforms: [.macOS("15.0")], - products: [.executable(name: "Reader", targets: ["Reader"])], - targets: [.executableTarget(name: "Reader", resources: [.copy("message.txt")])]) + products: [.library(name: "ResourceLibrary", type: .dynamic, targets: ["Resources"])], + targets: [.target(name: "Resources", resources: [.copy("message.txt")])]) + """, at: package.appendingPathComponent("Dependency/Package.swift")) + let manifest = """ + // swift-tools-version: 6.0 + import PackageDescription + let dependency: Target.Dependency = .product(name: "ResourceLibrary", package: "Dependency") + let package = Package(name: "Readers", platforms: [.macOS("15.0")], dependencies: [.package(path: "Dependency")], + targets: [.executableTarget(name: "Reader", dependencies: [dependency]), + .executableTarget(name: "Service", dependencies: [dependency]), + .executableTarget(name: "Helper", dependencies: [dependency])]) """ try write(manifest, at: package.appendingPathComponent("Package.swift")) - try write("relocated resource", at: package.appendingPathComponent("Sources/Reader/message.txt")) + try write("relocated resource", at: package.appendingPathComponent("Dependency/Sources/Resources/message.txt")) try write(""" import Foundation - let file = Bundle.module.url(forResource: "message", withExtension: "txt")! - print(try String(contentsOf: file, encoding: .utf8)) - """, at: package.appendingPathComponent("Sources/Reader/main.swift")) - var app = try product(resources: [.bundle(package: "RelocationFixture", target: "Reader")]) + public func readResource() throws -> String { + let file = Bundle.module.url(forResource: "message", withExtension: "txt")! + return try String(contentsOf: file, encoding: .utf8) + } + """, at: package.appendingPathComponent("Dependency/Sources/Resources/Resources.swift")) + for name in ["Reader", "Service", "Helper"] { + try write("import Resources\nprint(try readResource())", at: package.appendingPathComponent("Sources/\(name)/main.swift")) + } + let resources: [Plan.Resource] = [.bundle(package: "RelocationFixture", target: "Resources"), + .library(name: "ResourceLibrary")] + var app = try product(resources: resources) app.source = .executable(product: "Reader") - let plan = try Plan(target: app.target, app: app) - let settings = try await BuildSettings(configuration: .debug, triple: app.target.triple, buildSystem: .swiftPM, + var service = try product("Service", kind: .xpcService, resources: resources) + service.source = .executable(product: "Service") + var helper = try product("Helper", kind: .helperExecutable, resources: resources) + helper.source = .executable(product: "Helper") + let plan = try Plan(target: app.target, app: app, nestedProducts: [service, helper]) + let settings = try await BuildSettings(configuration: configuration, triple: app.target.triple, buildSystem: .swiftPM, packagePath: package.path) let artifacts = try await withDependencies { $0.productBuildRunner = .liveValue @@ -389,18 +433,35 @@ struct BundleAssemblerTests { try FileManager.default.createDirectory(at: relocated.deletingLastPathComponent(), withIntermediateDirectories: true) try FileManager.default.moveItem(at: output, to: relocated) try FileManager.default.removeItem(at: package) - let link = relocated.appendingPathComponent("RelocationFixture_Reader.bundle") - let resourcePath = "Contents/Resources/RelocationFixture_Reader.bundle" - #expect(try FileManager.default.destinationOfSymbolicLink(atPath: link.path) == resourcePath) - #expect(try String(contentsOf: link.appendingPathComponent("message.txt"), encoding: .utf8) == "relocated resource") + #expect(try FileManager.default.contentsOfDirectory(atPath: relocated.path) == ["Contents"]) + let alias = relocated.appendingPathComponent("Contents/Helpers/RelocationFixture_Resources.bundle") + #expect(try FileManager.default.destinationOfSymbolicLink(atPath: alias.path) + == "../Resources/RelocationFixture_Resources.bundle") + #expect(!FileManager.default.fileExists(atPath: relocated.appendingPathComponent("Contents/Helpers/Resources").path)) + for product in [helper, service, app] { + // Disposable local fixture only. Sign inside out, then verify the complete resource seal. + let library = relocated.appendingPathComponent(product.layout.frameworksPath + "/libResourceLibrary.dylib") + _ = try run(URL(fileURLWithPath: "/usr/bin/codesign"), arguments: ["--force", "--sign", "-", library.path]) + let signedPath = relocated.appendingPathComponent(product.layout.bundlePath).path + _ = try run(URL(fileURLWithPath: "/usr/bin/codesign"), arguments: ["--force", "--sign", "-", signedPath]) + } + _ = try run(URL(fileURLWithPath: "/usr/bin/codesign"), arguments: ["--verify", "--deep", "--strict", relocated.path]) + for product in plan.allProducts { + let executable = relocated.appendingPathComponent(product.layout.executablePath) + #expect(try run(executable) == "relocated resource\n") + } + } + + private func run(_ executable: URL, arguments: [String] = []) throws -> String { let process = Process() - process.executableURL = relocated.appendingPathComponent("Contents/MacOS/App") + process.executableURL = executable + process.arguments = arguments let pipe = Pipe() process.standardOutput = pipe try process.run() process.waitUntilExit() - #expect(process.terminationStatus == 0) - #expect(String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) == "relocated resource\n") + try #require(process.terminationStatus == 0) + return try #require(String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)) } #endif } diff --git a/Tests/XToolTests/PackContractTests.swift b/Tests/XToolTests/PackContractTests.swift index 3f0a3942..2b64286d 100644 --- a/Tests/XToolTests/PackContractTests.swift +++ b/Tests/XToolTests/PackContractTests.swift @@ -109,6 +109,7 @@ struct PackContractTests { let helper = BundleLayout(platform: .macOS, kind: .helperExecutable, name: "Helper") #expect(helper.executablePath == "Contents/Helpers/Helper") #expect(helper.infoPlistPath == nil) + #expect(helper.resourcesPath == "Contents/Resources") let ios = BundleLayout(platform: .iOS, kind: .appExtension, name: "Extension") #expect(ios.executablePath == "PlugIns/Extension.appex/Extension") #expect(ios.resourcesPath == "PlugIns/Extension.appex") diff --git a/Tests/XToolTests/SwiftPMResourceAccessorTests.swift b/Tests/XToolTests/SwiftPMResourceAccessorTests.swift new file mode 100644 index 00000000..ea98038c --- /dev/null +++ b/Tests/XToolTests/SwiftPMResourceAccessorTests.swift @@ -0,0 +1,106 @@ +import Dependencies +import Foundation +import Testing +@testable import PackLib + +struct SwiftPMResourceAccessorTests { + private let bundleName = "Package_Resources.bundle" + + private var generatedSource: String { + """ + import Foundation + extension Foundation.Bundle { + static nonisolated let module: Bundle = { + let mainPath = Bundle.main.bundleURL.appendingPathComponent("Package_Resources.bundle").path + let buildPath = "/build/Package_Resources.bundle" + return Bundle(path: mainPath) ?? Bundle(path: buildPath)! + }() + } + """ + } + + private func product(platform: String = "macosx", kind: Plan.ProductType = .application) throws -> Plan.Product { + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-\(platform)15.0") + return Plan.Product(target: target, type: kind, product: "App", bundleID: "test.App", infoPlist: [:], + resources: [.bundle(package: "Package", target: "Resources")]) + } + + @Test func adaptsOnlyGeneratedMainLookupAndRetainsBuildFallback() throws { + let result = try SwiftPMResourceAccessor.adapted(generatedSource, bundleName: bundleName) + #expect(result.contains("(Bundle.main.resourceURL ?? Bundle.main.bundleURL).appendingPathComponent")) + #expect(result.contains("let buildPath = \"/build/Package_Resources.bundle\"")) + #expect(result.contains("static nonisolated let module: Bundle")) + #expect(result.contains("Bundle(url: Bundle.main.bundleURL.appendingPathComponent(\"Package_Resources.bundle\"))")) + #expect(result.contains("?? Bundle(path: buildPath)!")) + } + + @Test(arguments: ["", "extension Foundation.Bundle {}", "let mainPath = Bundle.main.bundleURL"]) + func unknownGeneratedSourceRejects(source: String) { + #expect(throws: (any Error).self) { + try SwiftPMResourceAccessor.adapted(source, bundleName: bundleName) + } + } + + @Test func overlayOwnsCopiesAndLeavesGeneratedAndHostSourcesUntouched() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let manager = FileManager.default + let sources = ["Resources.build", "Resources-tool.build"].map { + root.appendingPathComponent("\($0)/DerivedSources/resource_bundle_accessor.swift") + } + for source in sources { + try manager.createDirectory(at: source.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(generatedSource.utf8).write(to: source) + } + let prepared = try SwiftPMResourceAccessor.overlay(for: product(), in: root, output: root.appendingPathComponent("owned")) + let overlay = try #require(prepared) + let json = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: overlay)) as? [String: Any]) + #expect(json["use-external-names"] as? Bool == false) + let roots = try #require(json["roots"] as? [[String: String]]) + #expect(roots.count == 1) + #expect(roots.first?["name"] == sources[0].path) + let copy = URL(fileURLWithPath: try #require(roots.first?["external-contents"])) + #expect(try String(contentsOf: copy, encoding: .utf8).contains("Bundle.main.resourceURL")) + for source in sources { + #expect(try String(contentsOf: source, encoding: .utf8) == generatedSource) + } + // Repeated preparation stays deterministic and supports incremental/retry builds. + let first = try Data(contentsOf: overlay) + _ = try SwiftPMResourceAccessor.overlay(for: product(), in: root, output: root.appendingPathComponent("owned")) + #expect(try Data(contentsOf: overlay) == first) + } + + @Test func iosKeepsNativeAccessors() throws { + let missing = URL(fileURLWithPath: "/does-not-exist") + #expect(try SwiftPMResourceAccessor.overlay(for: product(platform: "ios"), in: missing, output: missing) == nil) + } + + @Test func missingResourceAccessorRejectsBeforeWriting() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + #expect(throws: (any Error).self) { + try SwiftPMResourceAccessor.overlay(for: product(), in: root, output: root.appendingPathComponent("owned")) + } + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path).isEmpty) + } + + @Test func prebuiltMacOSSwiftPMResourcesRejectBeforeBuilding() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + var app = try product() + app.source = .prebuilt(path: "App") + let plan = try Plan(target: app.target, app: app) + let settings = try await BuildSettings(configuration: .release, triple: app.target.triple, buildSystem: .swiftPM, + packagePath: root.path) + await #expect(throws: (any Error).self) { + try await withDependencies { + $0.productBuildRunner = ProductBuildRunner(build: { _, _ in Issue.record("Unexpected build") }, + binaryPath: { _, _ in Issue.record("Unexpected query"); return "" }) + } operation: { + try await ProductBuilder(buildSettings: settings, plan: plan).build() + } + } + #expect(!FileManager.default.fileExists(atPath: root.path)) + } +} From cb0aad3207e45459b87a7e77d62a7a4f23b965b1 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 13 Oct 2026 13:50:17 +0200 Subject: [PATCH 11/59] docs: document macOS build contract Add MacOS.md describing the macOS route (target selection, bundle assembly, the deferred signing handoff, tests and boundaries) and extend Configuration.md with the version 2 keys, helper and resource destinations, and how resource relocation is verified. --- Documentation/xtool.docc/Configuration.md | 127 ++++++++++++++++++++++ Documentation/xtool.docc/MacOS.md | 56 ++++++++++ Documentation/xtool.docc/xtool.md | 4 + 3 files changed, 187 insertions(+) create mode 100644 Documentation/xtool.docc/Configuration.md create mode 100644 Documentation/xtool.docc/MacOS.md diff --git a/Documentation/xtool.docc/Configuration.md b/Documentation/xtool.docc/Configuration.md new file mode 100644 index 00000000..41aa121b --- /dev/null +++ b/Documentation/xtool.docc/Configuration.md @@ -0,0 +1,127 @@ +# Configure packaging + +Describe a SwiftPM application's target, products and prepared inputs in `xtool.yml`. + +## Version 2 + +Version 1 retains the existing iOS configuration. Version 2 adds `target`, +`deploymentTarget`, `source`, `resourceCopies` and `nestedProducts`; using these +fields in version 1 is rejected. See for target precedence, supported +backends and build commands. + +The example below packages an app with an extension, XPC service and helper. +Supply the referenced plist, entitlement and icon files alongside your package. + +```yaml +version: 2 +bundleID: com.example.app +product: MyApp +target: arm64-apple-macosx15.0 +deploymentTarget: '15.0' +source: {kind: executable, product: MyApp} +infoPath: Support/App-Info.plist +entitlementsPath: Support/App.entitlements +iconPath: Support/App.icns +nestedProducts: + - product: MyExtension + kind: appExtension + bundleID: com.example.extension + source: {kind: library, product: MyExtension} + infoPath: Support/Extension-Info.plist + entitlementsPath: Support/Extension.entitlements + - product: MyService + kind: xpcService + bundleID: com.example.xpc + source: {kind: executable, product: MyService} + infoPath: Support/XPC-Info.plist + entitlementsPath: Support/XPC.entitlements + - product: MyHelper + kind: helperExecutable + bundleID: com.example.helper + source: {kind: executable, product: MyHelper} +``` + +## Fields and product sources + +| Field | Contract | +| --- | --- | +| `version` | Required schema version, `1` or `2`. | +| `bundleID` / `orgID` | At least one is required. `bundleID` takes precedence; `orgID` derives `.`. | +| `product` | Output/application product name. Use an explicit name when the package has multiple candidates. | +| `target` | Apple target triple, independent of host OS; CLI `--triple` takes precedence. | +| `deploymentTarget` | Numeric OS version; cannot lower the package or platform minimum. | +| `source` | Tagged product source described below; explicit in this example. | +| `infoPath` | Caller plist dictionary merged over generated defaults; caller identifiers and extension/XPC metadata are preserved. | +| `entitlementsPath` | Caller plist dictionary associated with this product for the later signer. | +| `iconPath` | Prepared `.png` or `.icns` in version 2; ICNS requires macOS. | +| `resources` | Legacy list of files/directories copied to the product bundle root. | +| `resourceCopies` | Explicit copies with `source` and `destination: {location, path}`. | +| `nestedProducts` | Products with required `product`, `kind` and `source`; optional `bundleID`, `infoPath`, `entitlementsPath`, `resourceCopies`. | +| `extensions` | Existing extension list with required `product`/`infoPath`; version 2 also accepts `source`/`resourceCopies`. | +| `skipLSP` | Skip automatic creation of a missing LSP support file. | + +Paths to input files resolve from the package directory. Product names must be +safe single path components; nested names must be unique. Extension and XPC +products require `infoPath`; helpers are bare executables without their own +Info.plist. `xpcService` and `helperExecutable` require a macOS target. + +| `source.kind` | Required value | Build behavior | +| --- | --- | --- | +| `executable` | `product` | Build that SwiftPM executable directly; do not create a target depending on an executable product. | +| `library` | `product` | Create an executable wrapper linking that SwiftPM library; extensions use the extension entry point. | +| `prebuilt` | `path` | Validate and copy an existing target executable; do not compile it. | + +SwiftPM sources cannot carry `path`; prebuilt sources cannot carry `product`. +The requested kind must match the selected package product. Omitted source +retains legacy product selection; explicit sources make multi-product packages +unambiguous. For prebuilt XPC services/helpers use `source.kind: prebuilt` with +a `path` to a compatible macOS Mach-O executable, while retaining the nested +product's `kind` and metadata. This form is covered by planner/builder tests; +the example above builds its XPC service/helper from source. +Prebuilt products with automatically discovered SwiftPM resources are rejected +because xtool cannot rewrite their compiled accessors. Supply explicit copies +and resource lookup implemented by that binary instead. + +## macOS bundle and resource destinations + +| Product kind | Executable relative to the outer app | +| --- | --- | +| `application` | `Contents/MacOS/` | +| `appExtension` | `Contents/PlugIns/.appex/Contents/MacOS/` | +| `xpcService` | `Contents/XPCServices/.xpc/Contents/MacOS/` | +| `helperExecutable` | `Contents/Helpers/` | + +The outer application is selected at the schema root; `nestedProducts` cannot +contain `application`. App, extension and XPC plists live in their respective +`Contents/Info.plist`. Helpers have no plist. Dependency traversal follows +platform-conditional SwiftPM dependencies, collecting resource bundles, dynamic +libraries and compatible binary targets. Frameworks/dylibs are placed in each +owner's `Contents/Frameworks`; helper-owned frameworks use +`Contents/Helpers/Frameworks`, with a matching helper rpath. + +`destination.location` is required in YAML and accepts: + +| Location | Base for app/extension/XPC | Base for helper | +| --- | --- | --- | +| `resources` | The owner's `Contents/Resources` | Outer app's `Contents/Resources` | +| `contents` | The owner's `Contents` | Outer app's `Contents/Helpers` | +| `bundle` | The owner's bundle root | Outer app's `Contents/Helpers` | + +`destination.path` is a nonempty relative path appended to that base. Absolute +paths, empty components, `.`, `..`, backslashes and NUL are rejected. Assembly +also rejects overlapping destinations, including case/Unicode-equivalent +collisions, and unsafe symlinks. Legacy `resources` retains bundle-root copying; +prefer `resourceCopies` for prepared macOS resources. Explicit extension resources +belong in the extension's Resources directory. + +Source-built macOS SwiftPM resource accessors are adapted for relocated bundles; +separate relocation tests exercise the adapted `Bundle.module` accessor after +original build inputs are removed. +Prepared assets are copied without invoking `actool` or `ibtool`. Raw +`.xcassets`, `.iconset`, `.storyboard` and `.xib` inputs require preparation elsewhere. + +Caller plist values override generated defaults, including identifiers. Ensure +`CFBundleExecutable`, bundle kind and minimum OS agree with the actual product; +xtool does not translate an Xcode project's build settings or extension policy. +Deferred signing preserves entitlement dictionaries and their product associations +without approving or applying them. diff --git a/Documentation/xtool.docc/MacOS.md b/Documentation/xtool.docc/MacOS.md new file mode 100644 index 00000000..cb3b7997 --- /dev/null +++ b/Documentation/xtool.docc/MacOS.md @@ -0,0 +1,56 @@ +# Build macOS applications + +Cross-compile a SwiftPM application on Linux and hand its assembled bundle to a Mac for signing. + +## Supported build contract + +macOS builds use the SwiftPM backend and require explicit deferred signing. + +The CLI requires xtool, a matching Swift toolchain and an installed Darwin Swift +SDK on Linux, or a selected Xcode installation on macOS. + +```sh +# Run in the application's directory beside Package.swift and xtool.yml. +xtool dev build --configuration release --defer-signing +``` + +Set `version: 2` and a macOS `target` in `xtool.yml`; see . +The optional CLI `--triple arm64-apple-macosx15.0` overrides the schema target. +Omitting both retains the existing `arm64-apple-ios` default, even on a Mac. +The deployment version comes from the selected triple, then the schema's +`deploymentTarget`, then the package's platform minimum, then the platform +minimum (macOS 15.0). A selected version below any explicit schema/package +minimum or the platform minimum is rejected. + +The parser accepts arm64 and x86_64 macOS triples. Linux macOS Debug builds are rejected because SwiftPM +requires unavailable `codesign` support. Use Release. SwiftBuild is rejected +for macOS; its existing iOS support does not establish macOS support. + +The output is `xtool/.app`, accompanied by `.signing.json` +and, when supplied, `.signing-inputs/`. The CLI preserves copies of +caller entitlement dictionaries outside the app and rebases their paths in +the manifest. Transfer all three together. The manifest has `version: 1`, +`status: deferred`, a resolved target, and product bundle/executable paths with +their entitlement associations. It records signing intent, not release approval. + +This route performs no Apple account provisioning or identity signing. Linker +ad-hoc signatures may already be present. `--sign`, IPA output, `xtool dev run`, +and Xcode project generation are unavailable for macOS targets. A downstream +Mac signer must sign nested code before the outer app and validate the result. +The generic handoff does not select certificates or publish releases. + +## Tests + +The Swift Testing suite in `Tests/XToolTests` covers product selection, target +validation, nested bundle assembly, resource relocation and signing handoffs. +On macOS it compiles and executes disposable packages to test generated +`Bundle.module` lookup after removing the original build directory. + +## Boundaries + +This support excludes native execution of macOS applications on Linux, an asset +compiler replacement, release publishing, notarization, universal binary +production and full Xcode project ingestion. Supply prepared ICNS/PNG, +`Assets.car`, storyboards or nibs where needed; raw asset catalogs, iconsets, storyboards +and xibs are rejected by assembly. Signing with Developer ID, Gatekeeper +acceptance and application-specific entitlements require separate Mac work. diff --git a/Documentation/xtool.docc/xtool.md b/Documentation/xtool.docc/xtool.md index fea43522..e266bbaa 100644 --- a/Documentation/xtool.docc/xtool.md +++ b/Documentation/xtool.docc/xtool.md @@ -14,6 +14,8 @@ xtool is a cross-platform (Linux/WSL/macOS) tool that replicates Xcode functiona ✅ Build a SwiftPM package into an iOS app +✅ Build and assemble a macOS SwiftPM app with deferred signing + ✅ Sign and install iOS apps ✅ Interact with Apple Developer Services programmatically @@ -32,3 +34,5 @@ xtool is a cross-platform (Linux/WSL/macOS) tool that replicates Xcode functiona - - +- +- From 1c358556faca96ab74ad4039febb4d487f8cd202 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 13 Oct 2026 19:13:46 +0200 Subject: [PATCH 12/59] packer: resolve static XCFramework archives by library path SwiftPM copies a static XCFramework slice into the bin directory under the slice's LibraryPath, which need not be `lib.a`. Read the extracted XCFramework's Info.plist, pick the library for the planned platform, variant and architecture, and use its LibraryPath when validating and assembling binary targets. Framework slices and targets without an extracted XCFramework keep the previous lookup. --- Sources/PackLib/BundleAssembler.swift | 3 +- Sources/PackLib/ProductBuilder.swift | 42 +++++++++++++++++++++++++- Tests/XToolTests/BinaryLinkTests.swift | 30 ++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/Sources/PackLib/BundleAssembler.swift b/Sources/PackLib/BundleAssembler.swift index be59d91c..844ec0a8 100644 --- a/Sources/PackLib/BundleAssembler.swift +++ b/Sources/PackLib/BundleAssembler.swift @@ -104,7 +104,8 @@ public struct BundleAssembler: Sendable { let source = artifacts.directory.appendingPathComponent("\(name).framework") let binary = source.appendingPathComponent(name) if artifacts.staticBinaryTargets.contains(name) { - let looseArchive = artifacts.directory.appendingPathComponent("lib\(name).a") + let looseArchive = ProductBuilder.staticArchive(forBinaryTarget: name, in: artifacts.directory, + target: plan.target) guard FileManager.default.fileExists(atPath: binary.path) || FileManager.default.fileExists(atPath: looseArchive.path) else { throw StringError("Missing validated static binary target '\(name)' in \(artifacts.directory.path)") diff --git a/Sources/PackLib/ProductBuilder.swift b/Sources/PackLib/ProductBuilder.swift index 33f90057..90d33b5e 100644 --- a/Sources/PackLib/ProductBuilder.swift +++ b/Sources/PackLib/ProductBuilder.swift @@ -114,7 +114,7 @@ public struct ProductBuilder: Sendable { case .binaryTarget(let name): try ResourceDestination.validateRelativePath(name) let framework = directory.appendingPathComponent("\(name).framework/\(name)") - let archive = directory.appendingPathComponent("lib\(name).a") + let archive = Self.staticArchive(forBinaryTarget: name, in: directory, target: plan.target) let url = FileManager.default.fileExists(atPath: framework.path) ? framework : archive guard FileManager.default.fileExists(atPath: url.path) else { throw StringError("Missing binary target '\(name)': expected \(framework.path) or \(archive.path)") @@ -134,6 +134,46 @@ public struct ProductBuilder: Sendable { return staticTargets } + private struct XCFrameworkInfo: Decodable { + struct Library: Decodable { + let LibraryPath: String + let SupportedPlatform: String + let SupportedPlatformVariant: String? + let SupportedArchitectures: [String] + } + + let AvailableLibraries: [Library] + } + + /// SwiftPM copies a static xcframework slice into the bin directory under the slice's own + /// `LibraryPath`, which need not match the target name. Remote and archived binary targets are + /// extracted to `/artifacts///`; otherwise expect `lib.a`. + static func staticArchive(forBinaryTarget name: String, in directory: URL, target: ResolvedTarget) -> URL { + let manager = FileManager.default + let artifacts = directory.deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("artifacts", isDirectory: true) + let variant: String? = target.platform == .iOSSimulator ? "simulator" : nil + let packages = (try? manager.contentsOfDirectory(atPath: artifacts.path).sorted()) ?? [] + for package in packages { + let targetDirectory = artifacts.appendingPathComponent(package).appendingPathComponent(name) + let entries = (try? manager.contentsOfDirectory(atPath: targetDirectory.path).sorted()) ?? [] + for entry in entries where entry.hasSuffix(".xcframework") { + let plist = targetDirectory.appendingPathComponent(entry).appendingPathComponent("Info.plist") + guard let data = try? Data(contentsOf: plist), + let info = try? PropertyListDecoder().decode(XCFrameworkInfo.self, from: data), + let library = info.AvailableLibraries.first(where: { + $0.SupportedPlatform == target.platform.packagePlatformName + && $0.SupportedPlatformVariant == variant + && $0.SupportedArchitectures.contains(target.architecture) + }), + library.LibraryPath.hasSuffix(".a"), !library.LibraryPath.contains("/"), + (try? ResourceDestination.validateRelativePath(library.LibraryPath)) != nil else { continue } + return directory.appendingPathComponent(library.LibraryPath) + } + } + return directory.appendingPathComponent("lib\(name).a") + } + private func directProductOptions( _ product: Plan.Product, in directory: URL, sdkArguments: [String] ) throws -> [String] { diff --git a/Tests/XToolTests/BinaryLinkTests.swift b/Tests/XToolTests/BinaryLinkTests.swift index e6c4c29e..6ffc376f 100644 --- a/Tests/XToolTests/BinaryLinkTests.swift +++ b/Tests/XToolTests/BinaryLinkTests.swift @@ -167,6 +167,36 @@ struct BinaryLinkTests { await #expect(throws: (any Error).self) { try await ProductBuilder(buildSettings: settings, plan: plan).build() } } + @Test func staticBinaryTargetArchiveUsesXCFrameworkLibraryPath() async throws { + let scratch = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: scratch) } + let root = scratch.appendingPathComponent("arm64-apple-macosx/release") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let xcframework = scratch.appendingPathComponent("artifacts/package/Static/Static.xcframework") + try FileManager.default.createDirectory(at: xcframework, withIntermediateDirectories: true) + let libraries: [[String: Any]] = [ + ["LibraryPath": "libstatic-ios.a", "SupportedPlatform": "ios", "SupportedArchitectures": ["arm64"]], + ["LibraryPath": "libstatic-fat.a", "SupportedPlatform": "macos", "SupportedArchitectures": ["arm64", "x86_64"]], + ] + try PropertyListSerialization.data(fromPropertyList: ["AvailableLibraries": libraries], format: .xml, options: 0) + .write(to: xcframework.appendingPathComponent("Info.plist")) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + try write(binary(), to: root.appendingPathComponent("App")) + var app = product("App", kind: .application, target: target) + app.resources = [.binaryTarget(name: "Static")] + let plan = try Plan(target: target, app: app) + let object = binary(type: 1) + let size = String(object.count).padding(toLength: 10, withPad: " ", startingAt: 0) + let header = "file.o/ 0 0 0 100644 \(size)`\n" + try (Data("!\n\(header)".utf8) + object).write(to: root.appendingPathComponent("libstatic-fat.a")) + let settings = try await BuildSettings(configuration: .release, triple: target.triple, packagePath: root.path) + let artifacts = try await ProductBuilder(buildSettings: settings, plan: plan).build() + #expect(artifacts.staticBinaryTargets == ["Static"]) + let output = scratch.appendingPathComponent("App.app") + try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: artifacts, at: output) + #expect(!FileManager.default.fileExists(atPath: output.appendingPathComponent("Contents/Frameworks/Static.framework").path)) + } + @Test func dynamicInstallNameMustResolveInsideEmbeddedFramework() throws { let root = try temporaryRoot() defer { try? FileManager.default.removeItem(at: root) } From a8d9cf6fb6143227a3c9c31b2df73f93fae81854 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Wed, 14 Oct 2026 03:34:40 +0200 Subject: [PATCH 13/59] build: link swift-crypto into PackLib PackLib starts hashing build artifacts for the verified macOS build handoff. swift-crypto is already a package dependency used by XKit; this adds its Crypto product to the PackLib target. Co-Authored-By: Claude Opus 5.5 (1M context) --- Package.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Package.swift b/Package.swift index 3e281563..3e68dbcf 100644 --- a/Package.swift +++ b/Package.swift @@ -196,6 +196,7 @@ let package = Package( name: "PackLib", dependencies: [ "XUtils", + .product(name: "Crypto", package: "swift-crypto"), .product(name: "Dependencies", package: "swift-dependencies"), "XcodeProjects", .product(name: "Superutils", package: "xtool-core"), From 9efa985e3b087ed373dbcb28fda2fe2a16b0e7da Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Wed, 14 Oct 2026 03:34:40 +0200 Subject: [PATCH 14/59] build: reference SwiftyMobileDevice by package product XToolSupport and the xtool executable named SwiftyMobileDevice by bare product name, while XKit already spells out the package. Use the explicit `.product(name:package:)` form everywhere. No dependency or version changes. Co-Authored-By: Claude Opus 5.5 (1M context) --- Package.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 3e68dbcf..7388c59a 100644 --- a/Package.swift +++ b/Package.swift @@ -174,7 +174,7 @@ let package = Package( .target( name: "XToolSupport", dependencies: [ - "SwiftyMobileDevice", + .product(name: "SwiftyMobileDevice", package: "SwiftyMobileDevice"), "XKit", "PackLib", .product(name: "ArgumentParser", package: "swift-argument-parser"), @@ -208,7 +208,7 @@ let package = Package( .executableTarget( name: "xtool", dependencies: [ - "SwiftyMobileDevice", + .product(name: "SwiftyMobileDevice", package: "SwiftyMobileDevice"), "XKit", "XToolSupport", ], From 838c9f76a94f57eadf7e014fda5f62f44e0909bc Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 13 Oct 2026 19:51:19 +0200 Subject: [PATCH 15/59] dev: add macOS app execution and verified build handoffs - `xtool dev run` builds and launches a macOS app, forwarding arguments after `--` and exiting with the app's status (128+n when it is killed by signal n). `--app ` launches an existing .app without building, and `--triple` overrides the configured target. - `xtool dev build --defer-signing` also writes `.artifact.json`, a SHA-256 inventory of the bundle and its signing inputs, and removes a stale `.signing-inputs/` before writing new ones. `xtool dev verify` checks a handoff against that inventory. - MacBundleAudit walks the bundle's Mach-O load commands and rejects dependencies that resolve neither inside the bundle nor to a system library. - `--swift-sdks-path` and `--scratch-path` select a prepared Swift SDK directory and a separate SwiftPM scratch directory for macOS builds. - Add a Bats suite for the macOS CLI and document running apps. --- Documentation/xtool.docc/MacOS.md | 7 +- README.md | 28 +++ Sources/PackLib/ArtifactInventory.swift | 66 +++++++ Sources/PackLib/MacArtifact.swift | 98 +++++++++++ Sources/PackLib/MacBundleAudit.swift | 124 +++++++++++++ Sources/PackLib/ProductBuilder.swift | 43 +++-- Sources/XToolSupport/DevCommand.swift | 76 ++++++-- Sources/XToolSupport/MacRunOperation.swift | 85 +++++++++ Tests/XToolTests/DevCommandTests.swift | 9 +- Tests/XToolTests/MacArtifactTests.swift | 193 +++++++++++++++++++++ integration-tests/suite/macos.bats | 51 ++++++ 11 files changed, 745 insertions(+), 35 deletions(-) create mode 100644 Sources/PackLib/ArtifactInventory.swift create mode 100644 Sources/PackLib/MacArtifact.swift create mode 100644 Sources/PackLib/MacBundleAudit.swift create mode 100644 Sources/XToolSupport/MacRunOperation.swift create mode 100644 Tests/XToolTests/MacArtifactTests.swift create mode 100644 integration-tests/suite/macos.bats diff --git a/Documentation/xtool.docc/MacOS.md b/Documentation/xtool.docc/MacOS.md index cb3b7997..6f6095ad 100644 --- a/Documentation/xtool.docc/MacOS.md +++ b/Documentation/xtool.docc/MacOS.md @@ -34,8 +34,9 @@ the manifest. Transfer all three together. The manifest has `version: 1`, their entitlement associations. It records signing intent, not release approval. This route performs no Apple account provisioning or identity signing. Linker -ad-hoc signatures may already be present. `--sign`, IPA output, `xtool dev run`, -and Xcode project generation are unavailable for macOS targets. A downstream +ad-hoc signatures may already be present. `--sign`, IPA output and Xcode +project generation are unavailable for macOS targets. `xtool dev run` builds +and launches a local Mac app; `--app` launches an existing app without rebuilding. A downstream Mac signer must sign nested code before the outer app and validate the result. The generic handoff does not select certificates or publish releases. @@ -45,6 +46,8 @@ The Swift Testing suite in `Tests/XToolTests` covers product selection, target validation, nested bundle assembly, resource relocation and signing handoffs. On macOS it compiles and executes disposable packages to test generated `Bundle.module` lookup after removing the original build directory. +The Bats suite in `integration-tests/suite` exercises the public CLI using the +installed Darwin SDK. ## Boundaries diff --git a/README.md b/README.md index a39ca3d6..85147946 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,34 @@ DEVICE SUBCOMMANDS: See 'xtool help ' for detailed help. ``` +### macOS development + +For a version 2 project targeting `arm64-apple-macosx15.0`: + +```sh +xtool dev build --configuration release --defer-signing +xtool dev verify xtool/MyApp.app +# On a Mac, build and run without adding signatures: +xtool dev run --configuration release -- --your-app-argument +# Or run an existing app, including one signed externally: +xtool dev run --app /path/to/MyApp.app -- --your-app-argument +``` + +The app runs in the foreground with terminal output and exit-status propagation. Ctrl-C +cancels the child process. Apps requiring entitlements or additional signing must be signed +externally first; this workflow does not provision, sign, notarize or install applications. + +Copy the `.app`, `.artifact.json`, `.signing.json`, and `.signing-inputs/` together for a +portable handoff. `dev verify` checks the unchanged **pre-signing** bundle, resource files, +executable permissions, internal symlinks and entitlement inputs. Signing changes those bytes; +use `dev run --app` to launch an externally signed app without checking obsolete build hashes. +The inventory also records nested-code signing order and audits static Mach-O dependencies. +It detects changes, but does not authenticate who built the app or prove arbitrary `dlopen` paths. + +Linux builds remain Release-only. A prepared SDK can be selected with `--swift-sdks-path`, +and `--scratch-path` selects a separate macOS build directory. macOS project generation, +identity signing and notarization remain separate work. + ### Library xtool includes a library that you can use to interact with Apple Developer Services, iOS devices, and more from your own app. You can use this by adding `XKit` as a SwiftPM dependency. diff --git a/Sources/PackLib/ArtifactInventory.swift b/Sources/PackLib/ArtifactInventory.swift new file mode 100644 index 00000000..faf92138 --- /dev/null +++ b/Sources/PackLib/ArtifactInventory.swift @@ -0,0 +1,66 @@ +import Crypto +import Foundation +import XUtils + +/// Portable, exact-byte inventory. This detects mutation; it is not a signature or attestation. +public struct ArtifactInventory: Codable, Sendable, Equatable { + public var version: Int = 1 + public var files: [String: Entry] + + public struct Entry: Codable, Sendable, Equatable { + public var kind: String + public var sha256: String? + public var executable: Bool + public var link: String? + } + + public init(directory: URL, hashContents: Bool = true) throws { + let manager = FileManager.default + let root = directory.resolvingSymlinksInPath().standardizedFileURL + var pending = try manager.contentsOfDirectory(atPath: root.path) + var entries: [String: Entry] = [:] + while let path = pending.popLast() { + let url = root.appendingPathComponent(path) + try ResourceDestination.validateRelativePath(path) + let values = try url.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey, .isSymbolicLinkKey]) + if values.isSymbolicLink == true { + let link = try manager.destinationOfSymbolicLink(atPath: url.path) + let resolved = url.resolvingSymlinksInPath().standardizedFileURL + guard !link.hasPrefix("/"), resolved.path.hasPrefix(root.path + "/"), + manager.fileExists(atPath: resolved.path) else { + throw StringError("Broken or escaping artifact symlink: \(path)") + } + entries[path] = Entry(kind: "symlink", executable: false, link: link) + } else if values.isDirectory == true { + entries[path] = Entry(kind: "directory", executable: false) + pending += try manager.contentsOfDirectory(atPath: url.path).map { path + "/" + $0 } + } else if values.isRegularFile == true { + let attributes = try manager.attributesOfItem(atPath: url.path) + let permissions = (attributes[.posixPermissions] as? NSNumber)?.intValue ?? 0 + entries[path] = Entry(kind: "file", sha256: hashContents ? try Self.hash(url) : nil, + executable: permissions & 0o111 != 0) + } else { + throw StringError("Unsupported artifact entry: \(path)") + } + } + guard !entries.isEmpty else { throw StringError("Artifact is empty: \(directory.path)") } + files = entries + } + + public func verify(directory: URL) throws { + guard version == 1 else { throw StringError("Unsupported artifact inventory version: \(version)") } + let actual = try Self(directory: directory) + let differences = Set(files.keys).union(actual.files.keys).filter { files[$0] != actual.files[$0] }.sorted() + guard differences.isEmpty else { + throw StringError("Artifact inventory mismatch: \(differences.joined(separator: ", "))") + } + } + + public static func hash(_ url: URL) throws -> String { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + var hash = SHA256() + while let data = try handle.read(upToCount: 1_048_576), !data.isEmpty { hash.update(data: data) } + return hash.finalize().map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Sources/PackLib/MacArtifact.swift b/Sources/PackLib/MacArtifact.swift new file mode 100644 index 00000000..cbad45fd --- /dev/null +++ b/Sources/PackLib/MacArtifact.swift @@ -0,0 +1,98 @@ +import Foundation +import XUtils + +/// Build handoff preserved beside the app. Signing changes these bytes and requires a new receipt. +public struct MacArtifact: Codable, Sendable { + public var version: Int = 1 + public var bundleName: String + public var target: ResolvedTarget + public var inventory: ArtifactInventory + public var signingInputs: [String: String] + /// Relative code locations, deepest first, with the outer app represented by an empty path. + public var signingOrder: [String] + + public static func manifestURL(for bundle: URL) -> URL { + bundle.deletingPathExtension().appendingPathExtension("artifact.json") + } + + public static func signingManifestURL(for bundle: URL) -> URL { + bundle.deletingPathExtension().appendingPathExtension("signing.json") + } + + public static func write(bundle: URL, signingManifest: URL) throws { + let manifest = try JSONDecoder().decode(DeferredSigningManifest.self, from: Data(contentsOf: signingManifest)) + try validateProducts(manifest, bundle: bundle) + let inventory = try ArtifactInventory(directory: bundle) + let order = try MacBundleAudit.validate(bundle: bundle, target: manifest.target) + var inputs = [signingManifest.lastPathComponent: try ArtifactInventory.hash(signingManifest)] + for product in manifest.products { + guard let path = product.entitlementsPath else { continue } + let input = try safeInput(path, beside: bundle) + inputs[path] = try ArtifactInventory.hash(input) + } + let artifact = Self(bundleName: bundle.lastPathComponent, target: manifest.target, + inventory: inventory, signingInputs: inputs, signingOrder: order) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(artifact).write(to: manifestURL(for: bundle), options: .atomic) + } + + public static func verify(bundle: URL) throws { + let artifact = try JSONDecoder().decode(Self.self, from: Data(contentsOf: manifestURL(for: bundle))) + guard artifact.version == 1, artifact.bundleName == bundle.lastPathComponent else { + throw StringError("Artifact manifest version or bundle name mismatch") + } + try artifact.inventory.verify(directory: bundle) + let signingName = signingManifestURL(for: bundle).lastPathComponent + guard artifact.signingInputs[signingName] != nil else { throw StringError("Missing signing manifest hash") } + for (path, expected) in artifact.signingInputs { + guard try ArtifactInventory.hash(safeInput(path, beside: bundle)) == expected else { + throw StringError("Signing input changed: \(path)") + } + } + let signingURL = try safeInput(signingName, beside: bundle) + let signing = try JSONDecoder().decode(DeferredSigningManifest.self, from: Data(contentsOf: signingURL)) + guard signing.target == artifact.target else { throw StringError("Signing and artifact targets differ") } + try validateProducts(signing, bundle: bundle) + for product in signing.products { + if let path = product.entitlementsPath, artifact.signingInputs[path] == nil { + throw StringError("Missing entitlement input hash: \(path)") + } + } + let order = try MacBundleAudit.validate(bundle: bundle, target: artifact.target) + guard order == artifact.signingOrder else { throw StringError("Nested code inventory changed") } + } + + private static func safeInput(_ path: String, beside bundle: URL) throws -> URL { + try ResourceDestination.validateRelativePath(path) + let parent = bundle.deletingLastPathComponent().resolvingSymlinksInPath() + let url = parent.appendingPathComponent(path).resolvingSymlinksInPath() + guard url.path.hasPrefix(parent.path + "/") else { throw StringError("Signing input escapes artifact: \(path)") } + return url + } + + private static func validateProducts(_ manifest: DeferredSigningManifest, bundle: URL) throws { + guard manifest.version == 1, manifest.target.platform == .macOS, + manifest.products.filter({ $0.kind == .application && $0.bundlePath.isEmpty }).count == 1, + Set(manifest.products.map(\.executablePath)).count == manifest.products.count else { + throw StringError("Invalid deferred signing product inventory") + } + let root = bundle.resolvingSymlinksInPath() + for product in manifest.products { + try ResourceDestination.validateRelativePath(product.executablePath) + if !product.bundlePath.isEmpty { try ResourceDestination.validateRelativePath(product.bundlePath) } + let executable = root.appendingPathComponent(product.executablePath).resolvingSymlinksInPath() + guard executable.path.hasPrefix(root.path + "/"), FileManager.default.isExecutableFile(atPath: executable.path) else { + throw StringError("Missing signing product executable: \(product.executablePath)") + } + if product.kind != .helperExecutable { + let plist = root.appendingPathComponent(product.bundlePath).appendingPathComponent("Contents/Info.plist") + let info = try PropertyListSerialization.propertyList(from: Data(contentsOf: plist), format: nil) as? [String: Any] + guard info?["CFBundleIdentifier"] as? String == product.bundleID, + info?["CFBundleExecutable"] as? String == executable.lastPathComponent else { + throw StringError("Signing product identity differs from Info.plist: \(product.bundleID)") + } + } + } + } +} diff --git a/Sources/PackLib/MacBundleAudit.swift b/Sources/PackLib/MacBundleAudit.swift new file mode 100644 index 00000000..e8e1d4d4 --- /dev/null +++ b/Sources/PackLib/MacBundleAudit.swift @@ -0,0 +1,124 @@ +import Foundation +import XUtils + +/// Structural validation for a relocated Mac bundle, independent of signing and host tools. +public enum MacBundleAudit { + public static func executable(in bundle: URL) throws -> URL { + let root = bundle.resolvingSymlinksInPath().standardizedFileURL + let plist = root.appendingPathComponent("Contents/Info.plist") + let info = try PropertyListSerialization.propertyList(from: Data(contentsOf: plist), format: nil) + guard let info = info as? [String: Any], info["CFBundlePackageType"] as? String == "APPL", + let name = info["CFBundleExecutable"] as? String, !name.contains("/") else { + throw StringError("Expected a macOS application with CFBundleExecutable: \(bundle.path)") + } + try ResourceDestination.validateRelativePath(name) + let executable = root.appendingPathComponent("Contents/MacOS/\(name)").resolvingSymlinksInPath() + guard executable.path.hasPrefix(root.path + "/"), FileManager.default.isExecutableFile(atPath: executable.path) else { + throw StringError("Application executable is missing or escapes its bundle") + } + return executable + } + + @discardableResult + public static func validate(bundle: URL, target: ResolvedTarget) throws -> [String] { + guard target.platform == .macOS else { throw StringError("Expected a macOS artifact target") } + let root = bundle.resolvingSymlinksInPath().standardizedFileURL + let main = try executable(in: root) + let inventory = try ArtifactInventory(directory: root, hashContents: false) + var code: [String: MachOBinary.Slice] = [:] + var signing: Set = [""] + for (path, entry) in inventory.files where entry.kind == "file" { + let url = root.appendingPathComponent(path) + let handle = try FileHandle(forReadingFrom: url) + let prefix = try handle.read(upToCount: 4) ?? Data() + try handle.close() + guard [Data([0xcf, 0xfa, 0xed, 0xfe]), Data([0xca, 0xfe, 0xba, 0xbe]), + Data([0xbe, 0xba, 0xfe, 0xca])].contains(prefix) else { continue } + let slices = try MachOBinary.inspect([UInt8](Data(contentsOf: url))) + let cpu: UInt32 = target.architecture == "arm64" ? 0x0100000c : 0x01000007 + guard let slice = slices.first(where: { $0.cpu == cpu }), [2, 6, 8].contains(slice.fileType), + slice.platform == 1, slice.minimumOS <= MachOBinary.packedVersion(target.deploymentTarget) else { + throw StringError("Incompatible nested code: \(path)") + } + code[path] = slice + signing.insert(signingPath(path)) + } + let mainPath = String(main.path.dropFirst(root.path.count + 1)) + guard code[mainPath]?.fileType == 2 else { throw StringError("Application executable is not macOS Mach-O code") } + try validateDependencies(code, root: root, main: mainPath) + return signing.sorted { + let left = $0.split(separator: "/").count, right = $1.split(separator: "/").count + return left == right ? $0 < $1 : left > right + } + } + + private static func signingPath(_ path: String) -> String { + let parts = path.split(separator: "/").map(String.init) + if let index = parts.lastIndex(where: { $0.hasSuffix(".framework") }), + String(parts[index].dropLast(".framework".count)) == parts.last { + return parts[...index].joined(separator: "/") + } + if parts.count >= 3, parts[parts.count - 3] == "Contents", parts[parts.count - 2] == "MacOS" { + return parts.dropLast(3).joined(separator: "/") + } + return path + } + + private static func validateDependencies(_ code: [String: MachOBinary.Slice], root: URL, main: String) throws { + func systemLibrary(_ path: String) -> Bool { + guard path.hasPrefix("/") else { return false } + let canonical = URL(fileURLWithPath: path).standardizedFileURL.path + return canonical.hasPrefix("/System/Library/") || canonical.hasPrefix("/usr/lib/") + } + func resolved(_ path: String, loader: String, executable: String) -> String? { + if systemLibrary(path) { return path } + let expanded: String + if path.hasPrefix("@loader_path/") { + expanded = (loader as NSString).deletingLastPathComponent + "/" + path.dropFirst(13) + } else if path.hasPrefix("@executable_path/") { + expanded = (executable as NSString).deletingLastPathComponent + "/" + path.dropFirst(17) + } else { return nil } + let url = root.appendingPathComponent(expanded).resolvingSymlinksInPath().standardizedFileURL + guard url.path.hasPrefix(root.path + "/") else { return nil } + return String(url.path.dropFirst(root.path.count + 1)) + } + func visit(_ path: String, executable: String, inherited: [String], visited: inout Set) throws { + guard visited.insert(path).inserted, let slice = code[path] else { return } + let rpaths = slice.rpaths.compactMap { resolved($0 + "/", loader: path, executable: executable) } + inherited + for dependency in slice.dependencies { + let name = dependency.path + if systemLibrary(name) { continue } + let candidates: [String] + if name.hasPrefix("@rpath/") { + candidates = rpaths.map { $0 + "/" + name.dropFirst(7) } + } else { + candidates = resolved(name, loader: path, executable: executable).map { [$0] } ?? [] + } + if candidates.contains(where: systemLibrary) { continue } + let match = candidates.compactMap { candidate -> String? in + let url = root.appendingPathComponent(candidate).resolvingSymlinksInPath().standardizedFileURL + guard url.path.hasPrefix(root.path + "/") else { return nil } + let relative = String(url.path.dropFirst(root.path.count + 1)) + return code[relative] != nil ? relative : nil + }.first + guard let match else { + if dependency.weak { continue } + throw StringError("Unresolved runtime dependency '\(name)' in \(path)") + } + try visit(match, executable: executable, inherited: rpaths, visited: &visited) + } + } + var covered: Set = [] + for (path, slice) in code where slice.fileType == 2 { + var visited: Set = [] + try visit(path, executable: path, inherited: [], visited: &visited) + covered.formUnion(visited) + } + // Also check code loaded dynamically and therefore absent from LC_LOAD_DYLIB edges. + let mainRpaths = code[main]?.rpaths.compactMap { resolved($0 + "/", loader: main, executable: main) } ?? [] + for (path, slice) in code where slice.fileType != 2 && !covered.contains(path) { + var visited: Set = [] + try visit(path, executable: main, inherited: mainRpaths, visited: &visited) + } + } +} diff --git a/Sources/PackLib/ProductBuilder.swift b/Sources/PackLib/ProductBuilder.swift index 90d33b5e..35a04ec9 100644 --- a/Sources/PackLib/ProductBuilder.swift +++ b/Sources/PackLib/ProductBuilder.swift @@ -380,6 +380,32 @@ struct MachOBinary { var installName: String? var rpaths: [String] = [] var hasCodeSignature = false + struct Dependency { + var path: String + var weak: Bool + } + var dependencies: [Dependency] = [] + + mutating func read(command: UInt32, load: [UInt8]) throws { + switch command { + case 0x32: // LC_BUILD_VERSION + guard platform == nil else { throw StringError("Duplicate Mach-O platform metadata") } + platform = try MachOBinary.word(load, 8) + minimumOS = try MachOBinary.word(load, 12) + sdk = try MachOBinary.word(load, 16) + case 0x24, 0x25: // legacy LC_VERSION_MIN_MACOSX / IPHONEOS + guard platform == nil else { throw StringError("Duplicate Mach-O platform metadata") } + platform = command == 0x24 ? 1 : 2 + minimumOS = try MachOBinary.word(load, 8) + sdk = try MachOBinary.word(load, 12) + case 0xd: installName = try MachOBinary.loadString(load) + case 0xc, 0x80000018, 0x8000001f, 0x80000023: + dependencies.append(.init(path: try MachOBinary.loadString(load), weak: command == 0x80000018)) + case 0x8000001c: rpaths.append(try MachOBinary.loadString(load)) + case 0x1d: hasCodeSignature = true + default: break + } + } } @discardableResult @@ -508,22 +534,7 @@ struct MachOBinary { let length = Int(try word(bytes, offset + 4)) guard length >= 8, length <= 32 + size - offset else { throw StringError("Invalid Mach-O load command") } let load = Array(bytes[offset..<(offset + length)]) - switch command { - case 0x32: // LC_BUILD_VERSION - guard slice.platform == nil else { throw StringError("Duplicate Mach-O platform metadata") } - slice.platform = try word(load, 8) - slice.minimumOS = try word(load, 12) - slice.sdk = try word(load, 16) - case 0x24, 0x25: // legacy LC_VERSION_MIN_MACOSX / IPHONEOS - guard slice.platform == nil else { throw StringError("Duplicate Mach-O platform metadata") } - slice.platform = command == 0x24 ? 1 : 2 - slice.minimumOS = try word(load, 8) - slice.sdk = try word(load, 12) - case 0xd: slice.installName = try loadString(load) - case 0x8000001c: slice.rpaths.append(try loadString(load)) - case 0x1d: slice.hasCodeSignature = true - default: break - } + try slice.read(command: command, load: load) offset += length } guard offset == 32 + size else { throw StringError("Inconsistent Mach-O command size") } diff --git a/Sources/XToolSupport/DevCommand.swift b/Sources/XToolSupport/DevCommand.swift index 300ac5f8..addac4f8 100644 --- a/Sources/XToolSupport/DevCommand.swift +++ b/Sources/XToolSupport/DevCommand.swift @@ -131,6 +131,7 @@ struct DevBuildCommand: AsyncParsableCommand { ) @OptionGroup var packOptions: PackOperation.BuildOptions + @OptionGroup var macOptions: MacBuildOptions @Flag( name: .shortAndLong, @@ -157,11 +158,15 @@ struct DevBuildCommand: AsyncParsableCommand { try request.validate(route: .build(sign: sign, ipa: ipa, deferSigning: deferSigning)) if request.platform == .macOS { - let output = try await DeferredMacBuildOperation(request: request, configuration: packOptions.configuration).run() + let output = try await DeferredMacBuildOperation( + request: request, configuration: packOptions.configuration, options: macOptions + ).run() print("Wrote to \(output.bundle.path)") print("Signing inputs: \(output.manifest.path)") + print("Artifact inventory: \(MacArtifact.manifestURL(for: output.bundle).path)") return } + guard macOptions.arguments.isEmpty else { throw Console.Error("SDK and scratch overrides require a macOS target") } let signingAuthToken: AuthToken? if sign { @@ -224,19 +229,32 @@ struct DevRunCommand: AsyncParsableCommand { commandName: "run", abstract: "Build and run app with SwiftPM", discussion: """ - This command deploys the SwiftPM-based iOS app in the current directory + Build and run the configured app. macOS apps run locally without adding signatures. + Use --app to run an existing macOS app without rebuilding. Output is streamed to + this terminal and the app's exit status is returned. """ ) @OptionGroup var packOptions: PackOperation.BuildOptions + @Option(help: "Override the configured target triple") + var triple: String? + + @Option(help: "Run an existing macOS .app without building or signing it") + var app: String? + + @Argument(parsing: .captureForPassthrough, help: "Arguments passed to the macOS app after --") + var arguments: [String] = [] + + var appArguments: [String] { arguments.first == "--" ? Array(arguments.dropFirst()) : arguments } + #if os(macOS) @Flag( name: .shortAndLong, help: "Target the iOS Simulator" ) var simulator = false - var triple: String? { + var simulatorTriple: String? { if simulator { #if arch(arm64) return "arm64-apple-ios-simulator" @@ -249,17 +267,32 @@ struct DevRunCommand: AsyncParsableCommand { return nil } #else - var triple: String? { nil } + var simulatorTriple: String? { nil } #endif @OptionGroup var connectionOptions: ConnectionOptions func run() async throws { + if let app { + guard triple == nil, simulatorTriple == nil else { + throw Console.Error("--app cannot be combined with --triple or --simulator") + } + try await MacRunOperation.run(bundle: URL(fileURLWithPath: app), arguments: appArguments) + return + } // Validate the configured target before the simulator override can hide it. - let request = try await DevBuildRequest.load() + let request = try await DevBuildRequest.load(cliTriple: triple) try request.validate(route: .run) + if request.platform == .macOS { + guard simulatorTriple == nil else { throw Console.Error("--simulator cannot run a macOS target") } + try MacRunOperation.requireHost() + let output = try await DeferredMacBuildOperation(request: request, configuration: packOptions.configuration).run() + try await MacRunOperation.run(bundle: output.bundle, arguments: appArguments) + return + } + guard arguments.isEmpty else { throw Console.Error("App arguments currently require a macOS target") } @Dependency(\.devCommandClient) var client - let output = try await client.legacyPack(triple ?? request.triple, packOptions.configuration, false) + let output = try await client.legacyPack(simulatorTriple ?? request.triple, packOptions.configuration, false) #if os(macOS) if simulator { @@ -341,19 +374,35 @@ struct DevBuildRequest: Sendable { } else if deferSigning { throw Console.Error("--defer-signing requires a macOS target") } - case .run, .xcode: + case .run: + break + case .xcode: guard platform != .macOS else { throw Console.Error(""" - macOS targets support only `xtool dev build --defer-signing`; run and Xcode project generation are unsupported + Xcode project generation for macOS is unsupported """) } } } } +struct MacBuildOptions: ParsableArguments { + @Option(name: .customLong("swift-sdks-path"), help: "Use a prepared Swift SDK directory for macOS builds") + var swiftSDKsPath: String? + + @Option(help: "Use a separate SwiftPM scratch directory for macOS builds") + var scratchPath: String? + + var arguments: [String] { + (swiftSDKsPath.map { ["--swift-sdks-path", $0] } ?? []) + + (scratchPath.map { ["--scratch-path", $0] } ?? []) + } +} + struct DeferredMacBuildOperation { let request: DevBuildRequest let configuration: BuildConfiguration + var options: MacBuildOptions? struct Output { let bundle: URL @@ -375,17 +424,18 @@ struct DeferredMacBuildOperation { throw Console.Error("Could not find Package.swift in this directory.") } @Dependency(\.devCommandClient) var client - try await client.ensureSDK() + if options?.swiftSDKsPath == nil { try await client.ensureSDK() } print("Planning...") let settings = try await BuildSettings( configuration: configuration, triple: request.triple, - buildSystem: .swiftPM, packagePath: request.directory.path + buildSystem: .swiftPM, packagePath: request.directory.path, options: options?.arguments ?? [] ) let plan = try await Planner(buildSettings: settings, schema: request.schema).createPlan() await LSPConfig.tryEnsure(plan: plan, settings: settings, schema: request.schema, currentDirectory: request.directory) // This deliberately bypasses PackOperation's automatic entitlement signing. let bundle = try await client.deferredPack(settings, plan) let manifest = try DeferredSigningHandoff.write(plan: plan, bundle: bundle, sourceDirectory: request.directory) + try MacArtifact.write(bundle: bundle, signingManifest: manifest) return Output(bundle: bundle, manifest: manifest) } } @@ -400,7 +450,7 @@ enum DeferredSigningHandoff { let name = bundle.deletingPathExtension().lastPathComponent let parent = bundle.deletingLastPathComponent() let inputs = parent.appendingPathComponent("\(name).signing-inputs", isDirectory: true) - let manifestURL = parent.appendingPathComponent("\(name).signing.json") + let manifestURL = MacArtifact.signingManifestURL(for: bundle) var files: [(String, Data)] = [] for index in manifest.products.indices { guard let path = manifest.products[index].entitlementsPath else { continue } @@ -416,6 +466,9 @@ enum DeferredSigningHandoff { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let data = try encoder.encode(manifest) + if FileManager.default.fileExists(atPath: inputs.path) { + try FileManager.default.removeItem(at: inputs) + } if !files.isEmpty { try FileManager.default.createDirectory(at: inputs, withIntermediateDirectories: true) for (filename, contents) in files { @@ -483,6 +536,7 @@ struct DevCommand: AsyncParsableCommand { DevXcodeCommand.self, DevBuildCommand.self, DevRunCommand.self, + DevVerifyCommand.self, DevBSPCommand.self, ], defaultSubcommand: DevRunCommand.self diff --git a/Sources/XToolSupport/MacRunOperation.swift b/Sources/XToolSupport/MacRunOperation.swift new file mode 100644 index 00000000..44f21f0c --- /dev/null +++ b/Sources/XToolSupport/MacRunOperation.swift @@ -0,0 +1,85 @@ +import ArgumentParser +import Dependencies +import Foundation +import PackLib +import Subprocess +import XUtils + +struct MacRunClient: DependencyKey, Sendable { + var isMacOS: @Sendable () -> Bool + var launch: @Sendable (URL, [String]) async throws -> Int32 + + static let liveValue = Self( + isMacOS: { + #if os(macOS) + true + #else + false + #endif + }, + launch: { executable, arguments in + let result = try await Subprocess.run( + .path(.init(executable.path)), arguments: .init(arguments), + platformOptions: .withGracefulShutDown, + output: .currentStandardOutput, error: .currentStandardError + ) + switch result.terminationStatus { + case .exited(let code): return code + case .signaled(let signal): return 128 + signal + } + } + ) + + static let testValue = Self(isMacOS: { false }, launch: { _, _ in throw StringError("Unexpected Mac launch") }) +} + +extension DependencyValues { + var macRunClient: MacRunClient { + get { self[MacRunClient.self] } + set { self[MacRunClient.self] = newValue } + } +} + +enum MacRunOperation { + static func requireHost() throws { + @Dependency(\.macRunClient) var client + guard client.isMacOS() else { + throw Console.Error("macOS apps must run on a Mac; transfer the app and its handoff files to a Mac first") + } + } + + static func run(bundle: URL, arguments: [String]) async throws { + try requireHost() + let executable = try MacBundleAudit.executable(in: bundle) + // Runtime validation is independent of the pre-signing byte inventory: an externally + // signed app is expected to differ from that inventory, and is never rewritten here. + let info = try PropertyListSerialization.propertyList( + from: Data(contentsOf: bundle.appendingPathComponent("Contents/Info.plist")), format: nil + ) as? [String: Any] + #if arch(arm64) + let architecture = "arm64" + #else + let architecture = "x86_64" + #endif + let target = try ResolvedTarget.resolve(cliTarget: "\(architecture)-apple-macosx" + + ((info?["LSMinimumSystemVersion"] as? String) ?? "15.0")) + try MacBundleAudit.validate(bundle: bundle, target: target) + print("Launching \(bundle.lastPathComponent)...") + @Dependency(\.macRunClient) var client + let status = try await client.launch(executable, arguments) + if status != 0 { throw ExitCode(status) } + } +} + +struct DevVerifyCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "verify", abstract: "Verify a macOS app and its unchanged deferred-signing handoff" + ) + @Argument(help: "Path to the .app; its .artifact.json and signing inputs must remain beside it") + var app: String + + func run() throws { + try MacArtifact.verify(bundle: URL(fileURLWithPath: app)) + print("Verified artifact and signing inputs: \(app)") + } +} diff --git a/Tests/XToolTests/DevCommandTests.swift b/Tests/XToolTests/DevCommandTests.swift index 3892fb47..5ef79fe3 100644 --- a/Tests/XToolTests/DevCommandTests.swift +++ b/Tests/XToolTests/DevCommandTests.swift @@ -78,10 +78,8 @@ struct DevCommandTests { try macOverride.validate(route: .build(sign: false, ipa: false, deferSigning: true)) } - @Test func unsupportedRunAndXcodeTripleOptionsRejectDuringParsing() { - #expect(throws: (any Error).self) { - try DevRunCommand.parse(["--triple", "arm64-apple-macosx"]) - } + @Test func runAcceptsTripleAndXcodeStillRejectsIt() throws { + #expect(try DevRunCommand.parse(["--triple", "arm64-apple-macosx"]).triple == "arm64-apple-macosx") #expect(throws: (any Error).self) { try DevXcodeCommand.parse(["--triple", "arm64-apple-macosx"]) } @@ -223,9 +221,8 @@ struct DevCommandTests { #expect(plan.target.deploymentTarget == "15.0") #expect(plan.app.resolvedSource == .executable(product: "Executable")) await recorder.record("deferred-pack") - // Assembly is stubbed; this test covers routing and the signing handoff. let bundle = directory.appendingPathComponent("App.app", isDirectory: true) - try FileManager.default.createDirectory(at: bundle, withIntermediateDirectories: true) + try MacArtifactTests.fixture(at: bundle) return bundle } let inspection = try packageInspection(root: directory) diff --git a/Tests/XToolTests/MacArtifactTests.swift b/Tests/XToolTests/MacArtifactTests.swift new file mode 100644 index 00000000..036f3cab --- /dev/null +++ b/Tests/XToolTests/MacArtifactTests.swift @@ -0,0 +1,193 @@ +import ArgumentParser +import Dependencies +import Foundation +import Testing +@testable import PackLib +@testable import XToolSupport + +struct MacArtifactTests { + static func fixture(at bundle: URL) throws { + let directory = bundle.appendingPathComponent("Contents/MacOS") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let info: [String: Any] = ["CFBundleExecutable": "App", "CFBundlePackageType": "APPL", + "CFBundleIdentifier": "test.App", "LSMinimumSystemVersion": "15.0"] + try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) + .write(to: bundle.appendingPathComponent("Contents/Info.plist")) + try binary().write(to: directory.appendingPathComponent("App")) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: directory.appendingPathComponent("App").path) + } + + static func binary(dependency: String? = nil, type: UInt32 = 2, rpath: String? = nil) -> Data { + func words(_ values: [UInt32]) -> Data { + values.reduce(into: Data()) { result, value in + var little = value.littleEndian + withUnsafeBytes(of: &little) { result.append(contentsOf: $0) } + } + } + var commands = words([0x32, 24, 1, 0x000f0000, 0x001a0000, 0]) + var count: UInt32 = 1 + for (command, path) in [(UInt32(0xc), dependency), (UInt32(0x8000001c), rpath)] { + guard let path else { continue } + let header: UInt32 = command == 0xc ? 24 : 12 + var string = Data(path.utf8) + Data([0]) + while (Int(header) + string.count) % 8 != 0 { string.append(0) } + commands += words([command, header + UInt32(string.count), header]) + if header == 24 { commands += words([0, 0, 0]) } + commands += string + count += 1 + } + return words([0xfeedfacf, 0x0100000c, 0, type, count, UInt32(commands.count), 0, 0]) + commands + } + + private func root() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func handoff(at root: URL) throws -> URL { + let bundle = root.appendingPathComponent("App.app") + try Self.fixture(at: bundle) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + let entitlements = root.appendingPathComponent("App.entitlements") + try PropertyListSerialization.data(fromPropertyList: ["test.flag": true], format: .xml, options: 0).write(to: entitlements) + let app = Plan.Product(target: target, type: .application, product: "App", bundleID: "test.App", + infoPlist: [:], resources: [], entitlementsPath: entitlements.path) + let manifest = try DeferredSigningHandoff.write(plan: Plan(target: target, app: app), bundle: bundle, sourceDirectory: root) + try MacArtifact.write(bundle: bundle, signingManifest: manifest) + return bundle + } + + @Test func relocatedHandoffVerifiesAndDetectsResourceMutation() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appendingPathComponent("source") + try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true) + _ = try handoff(at: source) + let moved = directory.appendingPathComponent("moved") + try FileManager.default.moveItem(at: source, to: moved) + let app = moved.appendingPathComponent("App.app") + try MacArtifact.verify(bundle: app) + try Data("changed".utf8).write(to: app.appendingPathComponent("Contents/extra.txt")) + #expect(throws: (any Error).self) { try MacArtifact.verify(bundle: app) } + } + + @Test func entitlementMutationIsDetected() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = try handoff(at: directory) + try Data("changed".utf8).write(to: directory.appendingPathComponent("App.signing-inputs/0.entitlements.plist")) + #expect(throws: (any Error).self) { try MacArtifact.verify(bundle: app) } + } + + @Test func executableBitMutationIsDetected() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = try handoff(at: directory) + try FileManager.default.setAttributes([.posixPermissions: 0o644], + ofItemAtPath: app.appendingPathComponent("Contents/MacOS/App").path) + #expect(throws: (any Error).self) { try MacArtifact.verify(bundle: app) } + } + + @Test func escapingSymlinkIsRejected() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createSymbolicLink( + atPath: directory.appendingPathComponent("escape").path, + withDestinationPath: "/tmp" + ) + #expect(throws: (any Error).self) { try ArtifactInventory(directory: directory) } + } + + @Test func dependencyClosureRejectsMissingLibraryAndAcceptsRelocatedLibrary() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = directory.appendingPathComponent("App.app") + try Self.fixture(at: app) + try Self.binary(dependency: "@rpath/libDependency.dylib", rpath: "@executable_path/../Frameworks") + .write(to: app.appendingPathComponent("Contents/MacOS/App")) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + #expect(throws: (any Error).self) { try MacBundleAudit.validate(bundle: app, target: target) } + let frameworks = app.appendingPathComponent("Contents/Frameworks") + try FileManager.default.createDirectory(at: frameworks, withIntermediateDirectories: true) + try Self.binary(type: 6).write(to: frameworks.appendingPathComponent("libDependency.dylib")) + #expect(try MacBundleAudit.validate(bundle: app, target: target) == ["Contents/Frameworks/libDependency.dylib", ""]) + } + + @Test func existingAppRunForwardsArgumentsAndExitStatusWithoutBuilding() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = directory.appendingPathComponent("App.app") + try Self.fixture(at: app) + let before = try ArtifactInventory(directory: app) + await #expect(throws: ExitCode(7)) { + try await withDependencies { + $0.macRunClient = MacRunClient(isMacOS: { true }, launch: { executable, arguments in + #expect(executable.lastPathComponent == "App") + #expect(arguments == ["--probe", "value with spaces"]) + return 7 + }) + } operation: { + try await DevRunCommand.parse(["--app", app.path, "--", "--probe", "value with spaces"]).run() + } + } + try before.verify(directory: app) + } + + @Test func frameworkHelpersPrecedeTheirContainingFrameworkInSigningOrder() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = directory.appendingPathComponent("App.app") + try Self.fixture(at: app) + let version = app.appendingPathComponent("Contents/Frameworks/Example.framework/Versions/A") + try FileManager.default.createDirectory(at: version.appendingPathComponent("Helpers"), withIntermediateDirectories: true) + try Self.binary(type: 6).write(to: version.appendingPathComponent("Example")) + try Self.binary().write(to: version.appendingPathComponent("Helpers/Nested")) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + #expect(try MacBundleAudit.validate(bundle: app, target: target) == [ + "Contents/Frameworks/Example.framework/Versions/A/Helpers/Nested", + "Contents/Frameworks/Example.framework", "", + ]) + } + + #if os(macOS) + static func nativeFixture(at bundle: URL) throws { + try fixture(at: bundle) + let source = bundle.deletingLastPathComponent().appendingPathComponent("main.swift") + try """ + import Foundation + guard Array(CommandLine.arguments.dropFirst()) == ["first", "two words"] else { exit(2) } + exit(7) + """.write(to: source, atomically: true, encoding: .utf8) + #if arch(arm64) + let architecture = "arm64" + #else + let architecture = "x86_64" + #endif + let compiler = Process() + compiler.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + compiler.arguments = ["swiftc", "-target", "\(architecture)-apple-macosx15.0", source.path, + "-o", bundle.appendingPathComponent("Contents/MacOS/App").path] + try compiler.run() + compiler.waitUntilExit() + try #require(compiler.terminationStatus == 0) + try FileManager.default.removeItem(at: source) + } + + @Test func nativeAppRunForwardsArgumentsAndExitStatus() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = directory.appendingPathComponent("App.app") + try Self.nativeFixture(at: app) + let before = try ArtifactInventory(directory: app) + await #expect(throws: ExitCode(7)) { + try await withDependencies { + $0.macRunClient = .liveValue + } operation: { + try await DevRunCommand.parse(["--app", app.path, "--", "first", "two words"]).run() + } + } + try before.verify(directory: app) + } + #endif +} diff --git a/integration-tests/suite/macos.bats b/integration-tests/suite/macos.bats new file mode 100644 index 00000000..2900eae7 --- /dev/null +++ b/integration-tests/suite/macos.bats @@ -0,0 +1,51 @@ +setup() { + bats_require_minimum_version 1.5.0 + bats_load_library bats-support + bats_load_library bats-assert + cd "$BATS_TEST_TMPDIR" +} + +@test "can cross-build and verify a relocated macOS app" { + mkdir -p MacSmoke/Sources/MacSmoke + cd MacSmoke + cat > Package.swift <<'SWIFT' +// swift-tools-version: 6.0 +import PackageDescription +let package = Package(name: "MacSmoke", platforms: [.macOS(.v15)], + products: [.executable(name: "MacSmoke", targets: ["MacSmoke"])], + targets: [.executableTarget(name: "MacSmoke", resources: [.copy("message.txt")])]) +SWIFT + cat > Sources/MacSmoke/main.swift <<'SWIFT' +import Foundation +let file = Bundle.module.url(forResource: "message", withExtension: "txt")! +print(try String(contentsOf: file, encoding: .utf8)) +SWIFT + echo 'macOS resource' > Sources/MacSmoke/message.txt + architecture="$(uname -m)" + [[ "$architecture" == aarch64 ]] && architecture=arm64 + cat > xtool.yml < relocated/MacSmoke.app/Contents/Resources/unexpected.txt + run xtool dev verify relocated/MacSmoke.app + assert_failure + assert_output --partial 'inventory mismatch' +} From bb4df0655c21e47ce9d3eb079b3804ff1d4b1d86 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 13 Oct 2026 20:22:44 +0200 Subject: [PATCH 16/59] dev: add verified macOS artifact signing Add `xtool dev sign`, which signs a copy of a verified build handoff into a new output directory: - Modes: ad-hoc, development and developer-id. Certificate modes take an explicit identity SHA-1; `--dry-run` prints the plan and also works on Linux. - The copy is checked against the handoff inventory before signing. Nested code is signed inside-out with `--force` and without `--deep`; developer-id uses the hardened runtime and a secure timestamp. Each product gets its own entitlements, also embedded in DER form. - The signed app is published by renaming it from a private staging directory next to the output. - `xtool dev verify --signed` runs `codesign --verify --strict` on every code node and checks the certificate category, team ID and effective entitlements, then writes a receipt. The handoff changes with it: entitlement inputs are stored under their content hash instead of clearing the inputs directory, MacBundleAudit treats extra executables in Contents/MacOS as their own signing nodes and resolves frameworks through their Info.plist, and MacArtifact validates each product's canonical path, layout and package type. --- README.md | 32 ++- Sources/PackLib/ArtifactInventory.swift | 4 + Sources/PackLib/MacArtifact.swift | 17 +- Sources/PackLib/MacBundleAudit.swift | 57 +++- Sources/PackLib/MacSigningPlan.swift | 105 ++++++++ Sources/PackLib/MacSigningReceipt.swift | 87 ++++++ Sources/XToolSupport/DevCommand.swift | 8 +- Sources/XToolSupport/MacRunOperation.swift | 21 +- Sources/XToolSupport/MacSignCommand.swift | 35 +++ .../XToolSupport/MacSigningOperation.swift | 212 +++++++++++++++ Tests/XToolTests/DevCommandTests.swift | 23 ++ Tests/XToolTests/MacArtifactTests.swift | 5 +- Tests/XToolTests/MacSigningAuditTests.swift | 95 +++++++ Tests/XToolTests/MacSigningTests.swift | 250 ++++++++++++++++++ 14 files changed, 930 insertions(+), 21 deletions(-) create mode 100644 Sources/PackLib/MacSigningPlan.swift create mode 100644 Sources/PackLib/MacSigningReceipt.swift create mode 100644 Sources/XToolSupport/MacSignCommand.swift create mode 100644 Sources/XToolSupport/MacSigningOperation.swift create mode 100644 Tests/XToolTests/MacSigningAuditTests.swift create mode 100644 Tests/XToolTests/MacSigningTests.swift diff --git a/README.md b/README.md index 85147946..3e029864 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ xtool dev run --app /path/to/MyApp.app -- --your-app-argument The app runs in the foreground with terminal output and exit-status propagation. Ctrl-C cancels the child process. Apps requiring entitlements or additional signing must be signed -externally first; this workflow does not provision, sign, notarize or install applications. +first; the run command does not add signatures or install applications. Copy the `.app`, `.artifact.json`, `.signing.json`, and `.signing-inputs/` together for a portable handoff. `dev verify` checks the unchanged **pre-signing** bundle, resource files, @@ -80,8 +80,34 @@ The inventory also records nested-code signing order and audits static Mach-O de It detects changes, but does not authenticate who built the app or prove arbitrary `dlopen` paths. Linux builds remain Release-only. A prepared SDK can be selected with `--swift-sdks-path`, -and `--scratch-path` selects a separate macOS build directory. macOS project generation, -identity signing and notarization remain separate work. +and `--scratch-path` selects a separate macOS build directory. macOS project generation +and provisioning remain separate work. + +### macOS signing + +Sign a verified build handoff on a Mac into a **new** output directory: + +```sh +xtool dev sign xtool/MyApp.app --output signed --mode ad-hoc --dry-run +xtool dev sign xtool/MyApp.app --output signed --mode ad-hoc +xtool dev verify signed/MyApp.app --signed +# Certificate modes require an explicit certificate SHA-1 fingerprint: +xtool dev sign xtool/MyApp.app --output release-signed --mode developer-id --identity "$CERTIFICATE_SHA1" +``` + +`development` selects an Apple Development certificate; `developer-id` requires a +Developer ID Application certificate, hardened runtime and a secure timestamp. The signer +checks the actual certificate fingerprint and category. It does not select identities, +create certificates or provision capabilities. Provisioning-dependent entitlements are rejected. + +Signing works on a private copy, applies each product's own entitlements in nested-code order, +and verifies signatures and effective entitlements before publishing. Input files remain unchanged; +existing output directories are rejected. The adjacent `.signing-receipt.json` records the original +artifact digest, signed inventory and verified signing metadata. Keep it with the signed app. +`dev verify --signed` checks that receipt and native signatures on a Mac. Inventories cover file +contents, executable bits and symlinks, not extended attributes, and are local integrity records +rather than authenticated build attestations. Old content-addressed entitlement inputs are retained +when replacing a build handoff so an interrupted update cannot delete the previous inputs. ### Library diff --git a/Sources/PackLib/ArtifactInventory.swift b/Sources/PackLib/ArtifactInventory.swift index faf92138..0f475915 100644 --- a/Sources/PackLib/ArtifactInventory.swift +++ b/Sources/PackLib/ArtifactInventory.swift @@ -56,6 +56,10 @@ public struct ArtifactInventory: Codable, Sendable, Equatable { } } + public static func hash(data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + public static func hash(_ url: URL) throws -> String { let handle = try FileHandle(forReadingFrom: url) defer { try? handle.close() } diff --git a/Sources/PackLib/MacArtifact.swift b/Sources/PackLib/MacArtifact.swift index cbad45fd..ef50979a 100644 --- a/Sources/PackLib/MacArtifact.swift +++ b/Sources/PackLib/MacArtifact.swift @@ -78,6 +78,7 @@ public struct MacArtifact: Codable, Sendable { throw StringError("Invalid deferred signing product inventory") } let root = bundle.resolvingSymlinksInPath() + var nodes: Set = [] for product in manifest.products { try ResourceDestination.validateRelativePath(product.executablePath) if !product.bundlePath.isEmpty { try ResourceDestination.validateRelativePath(product.bundlePath) } @@ -85,11 +86,25 @@ public struct MacArtifact: Codable, Sendable { guard executable.path.hasPrefix(root.path + "/"), FileManager.default.isExecutableFile(atPath: executable.path) else { throw StringError("Missing signing product executable: \(product.executablePath)") } + let canonicalPath = String(executable.path.dropFirst(root.path.count + 1)) + guard canonicalPath == product.executablePath else { + throw StringError("Signing product executable must use its canonical path") + } + let node = try MacBundleAudit.signingPath(for: canonicalPath, in: root) + guard nodes.insert(node).inserted, node == product.bundlePath else { + throw StringError("Signing products must identify unique signing nodes") + } + let name = executable.lastPathComponent + let layout = BundleLayout(platform: .macOS, kind: product.kind, name: name) + guard product.bundlePath == layout.bundlePath, product.executablePath == layout.executablePath else { + throw StringError("Signing product kind does not match its bundle location") + } if product.kind != .helperExecutable { let plist = root.appendingPathComponent(product.bundlePath).appendingPathComponent("Contents/Info.plist") let info = try PropertyListSerialization.propertyList(from: Data(contentsOf: plist), format: nil) as? [String: Any] guard info?["CFBundleIdentifier"] as? String == product.bundleID, - info?["CFBundleExecutable"] as? String == executable.lastPathComponent else { + info?["CFBundleExecutable"] as? String == name, + info?["CFBundlePackageType"] as? String == product.kind.fourCharCode else { throw StringError("Signing product identity differs from Info.plist: \(product.bundleID)") } } diff --git a/Sources/PackLib/MacBundleAudit.swift b/Sources/PackLib/MacBundleAudit.swift index e8e1d4d4..b880ee24 100644 --- a/Sources/PackLib/MacBundleAudit.swift +++ b/Sources/PackLib/MacBundleAudit.swift @@ -26,7 +26,7 @@ public enum MacBundleAudit { let main = try executable(in: root) let inventory = try ArtifactInventory(directory: root, hashContents: false) var code: [String: MachOBinary.Slice] = [:] - var signing: Set = [""] + var signing: Set = [] for (path, entry) in inventory.files where entry.kind == "file" { let url = root.appendingPathComponent(path) let handle = try FileHandle(forReadingFrom: url) @@ -41,7 +41,10 @@ public enum MacBundleAudit { throw StringError("Incompatible nested code: \(path)") } code[path] = slice - signing.insert(signingPath(path)) + let node = try signingPath(for: path, in: root) + guard signing.insert(node).inserted else { + throw StringError("Multiple principal executables share signing node '\(node)'; keep one framework version") + } } let mainPath = String(main.path.dropFirst(root.path.count + 1)) guard code[mainPath]?.fileType == 2 else { throw StringError("Application executable is not macOS Mach-O code") } @@ -52,18 +55,58 @@ public enum MacBundleAudit { } } - private static func signingPath(_ path: String) -> String { + /// Maps only a bundle's declared principal executable to its signing node. + /// Additional Mach-O files remain separate nodes, even in Contents/MacOS. + public static func signingPath(for executablePath: String, in bundle: URL) throws -> String { + try ResourceDestination.validateRelativePath(executablePath) + let root = bundle.resolvingSymlinksInPath().standardizedFileURL + let executable = root.appendingPathComponent(executablePath).resolvingSymlinksInPath().standardizedFileURL + guard executable.path.hasPrefix(root.path + "/") else { + throw StringError("Signing executable escapes bundle: \(executablePath)") + } + let path = String(executable.path.dropFirst(root.path.count + 1)) let parts = path.split(separator: "/").map(String.init) - if let index = parts.lastIndex(where: { $0.hasSuffix(".framework") }), - String(parts[index].dropLast(".framework".count)) == parts.last { - return parts[...index].joined(separator: "/") + if let index = parts.lastIndex(where: { $0.hasSuffix(".framework") }) { + let frameworkPath = parts[...index].joined(separator: "/") + let framework = root.appendingPathComponent(frameworkPath) + let version = executable.deletingLastPathComponent() + let candidates = [version.appendingPathComponent("Resources/Info.plist"), + framework.appendingPathComponent("Resources/Info.plist"), + framework.appendingPathComponent("Info.plist")] + if let plist = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) }) { + let info = try metadata(at: plist) + if let name = info["CFBundleExecutable"] as? String { + try ResourceDestination.validateRelativePath(name) + guard !name.contains("/") else { throw StringError("Invalid framework executable name") } + let expected = version.appendingPathComponent(name).resolvingSymlinksInPath() + // A helper's directory is not a framework principal location. + let principalDirectory = parts.count == index + 2 || + (parts.count == index + 4 && parts[index + 1] == "Versions") + if principalDirectory && expected == executable { return frameworkPath } + } + } else if String(parts[index].dropLast(".framework".count)) == parts.last, + parts.count == index + 2 || (parts.count == index + 4 && parts[index + 1] == "Versions") { + // Preserve support for minimal frameworks without metadata. + return frameworkPath + } } if parts.count >= 3, parts[parts.count - 3] == "Contents", parts[parts.count - 2] == "MacOS" { - return parts.dropLast(3).joined(separator: "/") + let bundlePath = parts.dropLast(3).joined(separator: "/") + let plist = root.appendingPathComponent(bundlePath).appendingPathComponent("Contents/Info.plist") + if FileManager.default.fileExists(atPath: plist.path), + try metadata(at: plist)["CFBundleExecutable"] as? String == parts.last { + return bundlePath + } } return path } + private static func metadata(at url: URL) throws -> [String: Any] { + guard let info = try PropertyListSerialization.propertyList(from: Data(contentsOf: url), format: nil) + as? [String: Any] else { throw StringError("Invalid bundle metadata: \(url.path)") } + return info + } + private static func validateDependencies(_ code: [String: MachOBinary.Slice], root: URL, main: String) throws { func systemLibrary(_ path: String) -> Bool { guard path.hasPrefix("/") else { return false } diff --git a/Sources/PackLib/MacSigningPlan.swift b/Sources/PackLib/MacSigningPlan.swift new file mode 100644 index 00000000..e6e25fe5 --- /dev/null +++ b/Sources/PackLib/MacSigningPlan.swift @@ -0,0 +1,105 @@ +import Crypto +import Foundation +import XUtils + +public enum MacSigningMode: String, Codable, Sendable, CaseIterable { + case adHoc = "ad-hoc" + case development + case developerID = "developer-id" +} + +/// Frozen signing inputs. Entitlements are copied into this plan, never reread during signing. +public struct MacSigningPlan: Codable, Sendable { + public struct Target: Codable, Sendable { + public var path: String + public var entitlements: Data? + + public init(path: String, entitlements: Data?) { + self.path = path + self.entitlements = entitlements + } + } + + public let bundleName: String + public let target: ResolvedTarget + public let mode: MacSigningMode + public let identity: String? + public let inputArtifactSHA256: String + public let inputInventory: ArtifactInventory + public let targets: [Target] + + public static func validateIdentity(mode: MacSigningMode, identity: String?) throws { + if mode == .adHoc { + guard identity == nil else { throw StringError("Ad-hoc signing cannot select a certificate identity") } + } else { + guard let identity, identity.count == 40, + identity.unicodeScalars.allSatisfy({ CharacterSet(charactersIn: "0123456789abcdefABCDEF").contains($0) }) else { + throw StringError("Certificate signing requires an explicit 40-character SHA-1 certificate fingerprint") + } + } + } + + public static func make(bundle: URL, mode: MacSigningMode, identity: String?) throws -> Self { + try validateIdentity(mode: mode, identity: identity) + try MacArtifact.verify(bundle: bundle) + let artifactURL = MacArtifact.manifestURL(for: bundle) + let artifactData = try Data(contentsOf: artifactURL) + let artifact = try JSONDecoder().decode(MacArtifact.self, from: artifactData) + let manifestURL = MacArtifact.signingManifestURL(for: bundle) + let manifestData = try Data(contentsOf: manifestURL) + guard ArtifactInventory.hash(data: manifestData) == artifact.signingInputs[manifestURL.lastPathComponent] else { + throw StringError("Signing manifest changed while planning") + } + let manifest = try JSONDecoder().decode(DeferredSigningManifest.self, from: manifestData) + var entitlementInputs: [String: Data] = [:] + var products = Set() + for product in manifest.products { + // MacArtifact.verify bound each product to its layout; a helper's bundle is its executable. + let path = product.bundlePath + guard artifact.signingOrder.contains(path), products.insert(path).inserted else { + throw StringError("Signing product does not uniquely match nested code: \(path)") + } + if let input = product.entitlementsPath { + let data = try Data(contentsOf: bundle.deletingLastPathComponent().appendingPathComponent(input)) + guard ArtifactInventory.hash(data: data) == artifact.signingInputs[input] else { + throw StringError("Entitlements changed while planning") + } + try validateEntitlements(data) + if mode == .developerID { + let values = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] + guard values?["com.apple.security.get-task-allow"] as? Bool != true, + values?["get-task-allow"] as? Bool != true else { + throw StringError("Developer ID distribution cannot enable get-task-allow") + } + } + entitlementInputs[path] = data + } + } + let result = Self(bundleName: bundle.lastPathComponent, target: artifact.target, mode: mode, + identity: identity?.uppercased(), inputArtifactSHA256: ArtifactInventory.hash(data: artifactData), + inputInventory: artifact.inventory, + targets: artifact.signingOrder.map { Target(path: $0, entitlements: entitlementInputs[$0]) }) + // Detect inputs changing while the plan was read. + try MacArtifact.verify(bundle: bundle) + guard try ArtifactInventory.hash(artifactURL) == result.inputArtifactSHA256 else { + throw StringError("Artifact handoff changed while planning signing") + } + return result + } + + public static func validateEntitlements(_ data: Data) throws { + guard let values = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { + throw StringError("Entitlements must be a property-list dictionary") + } + // These capabilities require provisioning or a separate activation contract. + let restricted = ["application-identifier", "com.apple.application-identifier", "com.apple.developer.team-identifier", + "keychain-access-groups", "com.apple.security.application-groups"] + guard !values.keys.contains(where: { restricted.contains($0) || $0.hasPrefix("com.apple.developer.") }) else { + throw StringError("Provisioning-dependent entitlements are not supported by Mac signing yet") + } + } + + public static func certificateFingerprint(_ data: Data) -> String { + Insecure.SHA1.hash(data: data).map { String(format: "%02X", $0) }.joined() + } +} diff --git a/Sources/PackLib/MacSigningReceipt.swift b/Sources/PackLib/MacSigningReceipt.swift new file mode 100644 index 00000000..46f9f12d --- /dev/null +++ b/Sources/PackLib/MacSigningReceipt.swift @@ -0,0 +1,87 @@ +import Foundation +import XUtils + +/// Exact signed bytes and their original build handoff; this is a local receipt, not an attestation. +public struct MacSigningReceipt: Codable, Sendable { + public struct Signature: Codable, Sendable, Equatable { + public let path: String + public let certificateSHA1: String? + public let teamIdentifier: String? + public let authority: String? + public let hardenedRuntime: Bool + public let timestamp: String? + + public init(path: String, certificateSHA1: String?, teamIdentifier: String?, authority: String?, + hardenedRuntime: Bool, timestamp: String?) { + self.path = path + self.certificateSHA1 = certificateSHA1 + self.teamIdentifier = teamIdentifier + self.authority = authority + self.hardenedRuntime = hardenedRuntime + self.timestamp = timestamp + } + } + + public var signatures: [Signature] = [] + public var version: Int = 1 + public let bundleName: String + public let target: ResolvedTarget + public let mode: MacSigningMode + public let identity: String? + public let inputArtifactSHA256: String + public let inventory: ArtifactInventory + public let targets: [MacSigningPlan.Target] + + public init(plan: MacSigningPlan, bundle: URL) throws { + bundleName = plan.bundleName + target = plan.target + mode = plan.mode + identity = plan.identity + inputArtifactSHA256 = plan.inputArtifactSHA256 + inventory = try ArtifactInventory(directory: bundle) + targets = plan.targets + } + + public static func receiptURL(for bundle: URL) -> URL { + bundle.deletingPathExtension().appendingPathExtension("signing-receipt.json") + } + + public func write(beside bundle: URL) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(self).write(to: Self.receiptURL(for: bundle), options: .atomic) + } + + @discardableResult + public static func verify(bundle: URL) throws -> Self { + let receipt = try JSONDecoder().decode(Self.self, from: Data(contentsOf: receiptURL(for: bundle))) + guard receipt.version == 1, receipt.bundleName == bundle.lastPathComponent, + receipt.inputArtifactSHA256.count == 64, + receipt.inputArtifactSHA256.allSatisfy({ $0.isHexDigit }) else { + throw StringError("Invalid Mac signing receipt") + } + try MacSigningPlan.validateIdentity(mode: receipt.mode, identity: receipt.identity) + try receipt.inventory.verify(directory: bundle) + let order = try MacBundleAudit.validate(bundle: bundle, target: receipt.target) + guard receipt.targets.map(\.path) == order else { throw StringError("Signing receipt code inventory mismatch") } + guard receipt.signatures.map(\.path) == order else { + throw StringError("Signing receipt lacks verified signature metadata") + } + for signature in receipt.signatures { + guard signature.certificateSHA1 == receipt.identity, + receipt.mode == .adHoc || signature.teamIdentifier?.isEmpty == false else { + throw StringError("Signing receipt certificate metadata mismatch") + } + if receipt.mode == .developerID { + guard signature.hardenedRuntime, signature.timestamp != nil, + signature.authority?.hasPrefix("Developer ID Application:") == true else { + throw StringError("Signing receipt lacks Developer ID distribution requirements") + } + } + } + for target in receipt.targets { + if let entitlements = target.entitlements { try MacSigningPlan.validateEntitlements(entitlements) } + } + return receipt + } +} diff --git a/Sources/XToolSupport/DevCommand.swift b/Sources/XToolSupport/DevCommand.swift index addac4f8..60f0df1b 100644 --- a/Sources/XToolSupport/DevCommand.swift +++ b/Sources/XToolSupport/DevCommand.swift @@ -459,16 +459,15 @@ enum DeferredSigningHandoff { guard try PropertyListSerialization.propertyList(from: data, options: [], format: nil) is [String: Any] else { throw Console.Error("Entitlements must be a property-list dictionary: \(path)") } - let filename = "\(index).entitlements.plist" + let filename = "\(index)-\(ArtifactInventory.hash(data: data)).entitlements.plist" files.append((filename, data)) manifest.products[index].entitlementsPath = "\(inputs.lastPathComponent)/\(filename)" } let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let data = try encoder.encode(manifest) - if FileManager.default.fileExists(atPath: inputs.path) { - try FileManager.default.removeItem(at: inputs) - } + // Content-addressed inputs keep the previous manifest usable until atomic replacement. + // Retain older generations: an interrupted build must not invalidate existing handoffs. if !files.isEmpty { try FileManager.default.createDirectory(at: inputs, withIntermediateDirectories: true) for (filename, contents) in files { @@ -537,6 +536,7 @@ struct DevCommand: AsyncParsableCommand { DevBuildCommand.self, DevRunCommand.self, DevVerifyCommand.self, + MacSignCommand.self, DevBSPCommand.self, ], defaultSubcommand: DevRunCommand.self diff --git a/Sources/XToolSupport/MacRunOperation.swift b/Sources/XToolSupport/MacRunOperation.swift index 44f21f0c..ec42c96f 100644 --- a/Sources/XToolSupport/MacRunOperation.swift +++ b/Sources/XToolSupport/MacRunOperation.swift @@ -71,15 +71,26 @@ enum MacRunOperation { } } -struct DevVerifyCommand: ParsableCommand { +struct DevVerifyCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( - commandName: "verify", abstract: "Verify a macOS app and its unchanged deferred-signing handoff" + commandName: "verify", abstract: "Verify a macOS build handoff or signed artifact" ) @Argument(help: "Path to the .app; its .artifact.json and signing inputs must remain beside it") var app: String - func run() throws { - try MacArtifact.verify(bundle: URL(fileURLWithPath: app)) - print("Verified artifact and signing inputs: \(app)") + @Flag(help: "Verify the adjacent signing receipt and native signatures instead of the pre-signing handoff") + var signed = false + + func run() async throws { + let bundle = URL(fileURLWithPath: app) + if signed { + let receipt = try MacSigningReceipt.verify(bundle: bundle) + // The receipt check already verified the inventory. + _ = try await MacSigningOperation.verify(bundle: bundle, receipt: receipt, verifyInventory: false) + print("Verified signed artifact and signatures: \(app)") + } else { + try MacArtifact.verify(bundle: bundle) + print("Verified artifact and signing inputs: \(app)") + } } } diff --git a/Sources/XToolSupport/MacSignCommand.swift b/Sources/XToolSupport/MacSignCommand.swift new file mode 100644 index 00000000..950ef157 --- /dev/null +++ b/Sources/XToolSupport/MacSignCommand.swift @@ -0,0 +1,35 @@ +import ArgumentParser +import Foundation +import PackLib + +struct MacSignCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "sign", abstract: "Sign a verified macOS build handoff into a new output directory" + ) + + @Argument(help: "Input .app with its unchanged artifact and deferred signing handoff") + var app: String + + @Option(help: "New output directory; its parent must exist, and existing output is never replaced") + var output: String + + @Option(help: "Explicit signing mode") + var mode: MacSigningMode + + @Option(help: "Exact 40-character certificate SHA-1 fingerprint; required for certificate signing") + var identity: String? + + @Flag(help: "Verify inputs and print the signing plan without modifying files or invoking signing tools") + var dryRun = false + + func validate() throws { + try MacSigningPlan.validateIdentity(mode: mode, identity: identity) + } + + func run() async throws { + try await MacSigningOperation.run(bundle: URL(fileURLWithPath: app), output: URL(fileURLWithPath: output), + mode: mode, identity: identity, dryRun: dryRun) + } +} + +extension MacSigningMode: ExpressibleByArgument {} diff --git a/Sources/XToolSupport/MacSigningOperation.swift b/Sources/XToolSupport/MacSigningOperation.swift new file mode 100644 index 00000000..b919598e --- /dev/null +++ b/Sources/XToolSupport/MacSigningOperation.swift @@ -0,0 +1,212 @@ +import Dependencies +import Foundation +import PackLib +import Subprocess +import XUtils + +struct MacSigningCommandResult: Sendable { + var output: Data + var error: Data +} + +struct MacSigningClient: DependencyKey, Sendable { + var isMacOS: @Sendable () -> Bool + var run: @Sendable (String, [String]) async throws -> MacSigningCommandResult + + static let liveValue = Self(isMacOS: { + #if os(macOS) + true + #else + false + #endif + }, run: { executable, arguments in + let result = try await Subprocess.run( + .path(.init(executable)), arguments: .init(arguments), + platformOptions: .withGracefulShutDown, output: .data(limit: .max), error: .data(limit: .max) + ) + do { try result.checkSuccess() } catch { + throw StringError("Mac signing tool failed: \(String(decoding: result.standardError, as: UTF8.self))") + } + return MacSigningCommandResult(output: result.standardOutput, error: result.standardError) + }) + + static let testValue = Self(isMacOS: { false }, run: { _, _ in throw StringError("Unexpected signing tool invocation") }) +} + +extension DependencyValues { + var macSigningClient: MacSigningClient { + get { self[MacSigningClient.self] } + set { self[MacSigningClient.self] = newValue } + } +} + +enum MacSigningOperation { + static func run(bundle: URL, output: URL, mode: MacSigningMode, identity: String?, dryRun: Bool) async throws { + let plan = try MacSigningPlan.make(bundle: bundle, mode: mode, identity: identity) + try validateOutput(output, source: bundle) + if dryRun { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + print(String(decoding: try encoder.encode(plan), as: UTF8.self)) + return + } + @Dependency(\.macSigningClient) var client + guard client.isMacOS() else { throw StringError("Mac signing requires a macOS host") } + let manager = FileManager.default + let stage = output.deletingLastPathComponent().appendingPathComponent(".xtool-sign-\(UUID().uuidString)") + try manager.createDirectory(at: stage, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) + defer { try? manager.removeItem(at: stage) } + let stagedBundle = stage.appendingPathComponent(plan.bundleName) + try manager.copyItem(at: bundle, to: stagedBundle) + try plan.inputInventory.verify(directory: stagedBundle) + try checkSource(bundle, plan: plan) + let inputs = stage.appendingPathComponent(".entitlements") + try manager.createDirectory(at: inputs, withIntermediateDirectories: false) + for (index, target) in plan.targets.enumerated() { + try Task.checkCancellation() + var arguments = ["--force", "--sign", plan.identity ?? "-"] + if mode == .developerID { + arguments += ["--options", "runtime", "--timestamp"] + } else { + arguments += ["--options", "0", "--timestamp=none"] + } + if let entitlements = target.entitlements { + let file = inputs.appendingPathComponent("\(index).plist") + try entitlements.write(to: file, options: .atomic) + arguments += ["--entitlements", file.path, "--generate-entitlement-der"] + } + arguments.append(codeURL(target.path, bundle: stagedBundle).path) + _ = try await client.run("/usr/bin/codesign", arguments) + } + try manager.removeItem(at: inputs) + var receipt = try MacSigningReceipt(plan: plan, bundle: stagedBundle) + receipt.signatures = try await verify(bundle: stagedBundle, receipt: receipt) + try checkSource(bundle, plan: plan) + try receipt.write(beside: stagedBundle) + try MacSigningReceipt.verify(bundle: stagedBundle) + try validateOutput(output, source: bundle) + try Task.checkCancellation() + try manager.moveItem(at: stage, to: output) + print("Signed app: \(output.appendingPathComponent(plan.bundleName).path)") + } + + @discardableResult + static func verify(bundle: URL, receipt: MacSigningReceipt, + verifyInventory: Bool = true) async throws -> [MacSigningReceipt.Signature] { + @Dependency(\.macSigningClient) var client + guard client.isMacOS() else { throw StringError("Native signature verification requires macOS") } + if verifyInventory { try receipt.inventory.verify(directory: bundle) } + let order = try MacBundleAudit.validate(bundle: bundle, target: receipt.target) + guard order == receipt.targets.map(\.path) else { throw StringError("Signed code inventory mismatch") } + let scratch = FileManager.default.temporaryDirectory.appendingPathComponent("xtool-sign-check-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700]) + defer { try? FileManager.default.removeItem(at: scratch) } + var signatures: [MacSigningReceipt.Signature] = [] + for (index, target) in receipt.targets.enumerated() { + let code = codeURL(target.path, bundle: bundle) + _ = try await client.run("/usr/bin/codesign", ["--verify", "--strict", "--verbose=2", code.path]) + let display = try await client.run("/usr/bin/codesign", ["--display", "--verbose=4", code.path]) + let metadata = String(decoding: display.error + display.output, as: UTF8.self) + try validateMetadata(metadata, mode: receipt.mode) + var fingerprint: String? + if let identity = receipt.identity { + let prefix = scratch.appendingPathComponent("\(index)-certificate").path + _ = try await client.run("/usr/bin/codesign", ["--display", "--extract-certificates", prefix, code.path]) + let leaf = try Data(contentsOf: URL(fileURLWithPath: prefix + "0")) + fingerprint = MacSigningPlan.certificateFingerprint(leaf) + guard fingerprint == identity.uppercased() else { + throw StringError("Signed certificate differs from requested fingerprint: \(target.path)") + } + } + let effective = try await client.run("/usr/bin/codesign", ["--display", "--entitlements", "-", "--xml", code.path]) + try validateEffectiveEntitlements(effective.output, expected: target.entitlements, path: target.path) + let lines = metadata.components(separatedBy: .newlines) + func value(_ prefix: String) -> String? { + lines.first(where: { $0.hasPrefix(prefix) }).map { String($0.dropFirst(prefix.count)) } + } + signatures.append(.init(path: target.path, certificateSHA1: fingerprint, + teamIdentifier: receipt.mode == .adHoc ? nil : value("TeamIdentifier="), + authority: value("Authority="), + hardenedRuntime: lines.contains { $0.hasPrefix("CodeDirectory ") && $0.contains("runtime") }, + timestamp: value("Timestamp="))) + } + guard Set(signatures.compactMap(\.teamIdentifier)).count <= 1 else { + throw StringError("Nested code has inconsistent signing teams") + } + if !receipt.signatures.isEmpty, !sameSignatures(receipt.signatures, signatures) { + throw StringError("Actual signature metadata differs from signing receipt") + } + if verifyInventory { try receipt.inventory.verify(directory: bundle) } + return signatures + } + + // codesign renders Timestamp in the host locale/timezone. Exact artifact bytes bind the + // timestamp token; compare its presence here so receipts remain portable between Macs. + private static func sameSignatures(_ expected: [MacSigningReceipt.Signature], + _ actual: [MacSigningReceipt.Signature]) -> Bool { + guard expected.count == actual.count else { return false } + return zip(expected, actual).allSatisfy { left, right in + left.path == right.path && left.certificateSHA1 == right.certificateSHA1 + && left.teamIdentifier == right.teamIdentifier && left.authority == right.authority + && left.hardenedRuntime == right.hardenedRuntime && (left.timestamp == nil) == (right.timestamp == nil) + } + } + + private static func validateMetadata(_ text: String, mode: MacSigningMode) throws { + let lines = text.components(separatedBy: .newlines) + if mode == .adHoc { + guard lines.contains("Signature=adhoc") else { throw StringError("Expected an ad-hoc signature") } + return + } + let prefix = mode == .developerID ? "Authority=Developer ID Application:" : "Authority=Apple Development:" + guard lines.contains(where: { $0.hasPrefix(prefix) }), + lines.contains(where: { $0.hasPrefix("TeamIdentifier=") && $0 != "TeamIdentifier=not set" }) else { + throw StringError("Signing certificate category does not match requested \(mode.rawValue) mode") + } + if mode == .developerID { + guard lines.contains(where: { $0.hasPrefix("CodeDirectory ") && $0.contains("runtime") }), + lines.contains(where: { $0.hasPrefix("Timestamp=") && $0 != "Timestamp=none" }) else { + throw StringError("Developer ID signing requires hardened runtime and a secure timestamp") + } + } + } + + private static func validateEffectiveEntitlements(_ actual: Data, expected: Data?, path: String) throws { + func dictionary(_ data: Data?) throws -> NSDictionary { + guard let data, !data.isEmpty else { return NSDictionary() } + guard let value = try PropertyListSerialization.propertyList(from: data, format: nil) as? NSDictionary else { + throw StringError("Signed entitlements are not a dictionary: \(path)") + } + return value + } + guard try dictionary(actual).isEqual(dictionary(expected)) else { + throw StringError("Effective signed entitlements differ from the handoff: \(path)") + } + } + + private static func checkSource(_ bundle: URL, plan: MacSigningPlan) throws { + try MacArtifact.verify(bundle: bundle) + guard try ArtifactInventory.hash(MacArtifact.manifestURL(for: bundle)) == plan.inputArtifactSHA256 else { + throw StringError("Source handoff changed during signing") + } + try plan.inputInventory.verify(directory: bundle) + } + + private static func validateOutput(_ output: URL, source: URL) throws { + let resolved = output.deletingLastPathComponent().resolvingSymlinksInPath() + .appendingPathComponent(output.lastPathComponent).standardizedFileURL + let source = source.resolvingSymlinksInPath().standardizedFileURL + guard !FileManager.default.fileExists(atPath: output.path), + !resolved.path.hasPrefix(source.path + "/"), resolved.path != source.path else { + throw StringError("Signing output must be a new directory outside the input app") + } + var directory: ObjCBool = false + guard FileManager.default.fileExists(atPath: output.deletingLastPathComponent().path, isDirectory: &directory), + directory.boolValue else { throw StringError("Signing output parent directory must already exist") } + } + + private static func codeURL(_ path: String, bundle: URL) -> URL { + path.isEmpty ? bundle : bundle.appendingPathComponent(path) + } +} diff --git a/Tests/XToolTests/DevCommandTests.swift b/Tests/XToolTests/DevCommandTests.swift index 5ef79fe3..c0b29165 100644 --- a/Tests/XToolTests/DevCommandTests.swift +++ b/Tests/XToolTests/DevCommandTests.swift @@ -300,6 +300,29 @@ struct DevCommandTests { } } + @Test func replacingHandoffPreservesPreviousEntitlementGeneration() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appendingPathComponent("entitlements.plist") + let first = try PropertyListSerialization.data(fromPropertyList: ["test.flag": true], format: .xml, options: 0) + try first.write(to: source) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + let app = product("App", kind: .application, target: target, entitlements: "entitlements.plist") + let plan = try Plan(target: target, app: app) + let bundle = directory.appendingPathComponent("App.app") + let url = try DeferredSigningHandoff.write(plan: plan, bundle: bundle, sourceDirectory: directory) + let previous = try JSONDecoder().decode(DeferredSigningManifest.self, from: Data(contentsOf: url)) + let previousPath = try #require(previous.products.first?.entitlementsPath) + let second = try PropertyListSerialization.data(fromPropertyList: ["test.flag": false], format: .xml, options: 0) + try second.write(to: source) + _ = try DeferredSigningHandoff.write(plan: plan, bundle: bundle, sourceDirectory: directory) + let current = try JSONDecoder().decode(DeferredSigningManifest.self, from: Data(contentsOf: url)) + let currentPath = try #require(current.products.first?.entitlementsPath) + #expect(currentPath != previousPath) + #expect(try Data(contentsOf: directory.appendingPathComponent(previousPath)) == first) + #expect(try Data(contentsOf: directory.appendingPathComponent(currentPath)) == second) + } + @Test func malformedEntitlementsRejectWithoutWritingHandoff() throws { let directory = try root() defer { try? FileManager.default.removeItem(at: directory) } diff --git a/Tests/XToolTests/MacArtifactTests.swift b/Tests/XToolTests/MacArtifactTests.swift index 036f3cab..8c550ffa 100644 --- a/Tests/XToolTests/MacArtifactTests.swift +++ b/Tests/XToolTests/MacArtifactTests.swift @@ -76,7 +76,10 @@ struct MacArtifactTests { let directory = try root() defer { try? FileManager.default.removeItem(at: directory) } let app = try handoff(at: directory) - try Data("changed".utf8).write(to: directory.appendingPathComponent("App.signing-inputs/0.entitlements.plist")) + let manifest = try JSONDecoder().decode(DeferredSigningManifest.self, + from: Data(contentsOf: directory.appendingPathComponent("App.signing.json"))) + let input = try #require(manifest.products.first?.entitlementsPath) + try Data("changed".utf8).write(to: directory.appendingPathComponent(input)) #expect(throws: (any Error).self) { try MacArtifact.verify(bundle: app) } } diff --git a/Tests/XToolTests/MacSigningAuditTests.swift b/Tests/XToolTests/MacSigningAuditTests.swift new file mode 100644 index 00000000..491504c7 --- /dev/null +++ b/Tests/XToolTests/MacSigningAuditTests.swift @@ -0,0 +1,95 @@ +import Foundation +import Testing +@testable import PackLib + +struct MacSigningAuditTests { + private func fixture() throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let app = root.appendingPathComponent("App.app") + try MacArtifactTests.fixture(at: app) + return app + } + + @Test func additionalMacOSExecutableIsAnIndependentSigningNode() throws { + let app = try fixture() + defer { try? FileManager.default.removeItem(at: app.deletingLastPathComponent()) } + try MacArtifactTests.binary().write(to: app.appendingPathComponent("Contents/MacOS/Helper")) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + #expect(try MacBundleAudit.validate(bundle: app, target: target) == ["Contents/MacOS/Helper", ""]) + } + + @Test func differentlyNamedVersionedFrameworkUsesMetadata() throws { + let app = try fixture() + defer { try? FileManager.default.removeItem(at: app.deletingLastPathComponent()) } + let framework = app.appendingPathComponent("Contents/Frameworks/Example.framework") + let version = framework.appendingPathComponent("Versions/A") + try FileManager.default.createDirectory(at: version.appendingPathComponent("Resources"), withIntermediateDirectories: true) + try PropertyListSerialization.data(fromPropertyList: ["CFBundleExecutable": "Actual", "CFBundlePackageType": "FMWK"], + format: .xml, options: 0).write(to: version.appendingPathComponent("Resources/Info.plist")) + try MacArtifactTests.binary(type: 6).write(to: version.appendingPathComponent("Actual")) + try FileManager.default.createSymbolicLink(atPath: framework.appendingPathComponent("Versions/Current").path, + withDestinationPath: "A") + try FileManager.default.createSymbolicLink(atPath: framework.appendingPathComponent("Actual").path, + withDestinationPath: "Versions/Current/Actual") + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + #expect(try MacBundleAudit.validate(bundle: app, target: target) == ["Contents/Frameworks/Example.framework", ""]) + #expect(try MacBundleAudit.signingPath(for: "Contents/Frameworks/Example.framework/Actual", in: app) + == "Contents/Frameworks/Example.framework") + } + + @Test func multiplePhysicalFrameworkVersionsAreRejected() throws { + let app = try fixture() + defer { try? FileManager.default.removeItem(at: app.deletingLastPathComponent()) } + for version in ["A", "B"] { + let directory = app.appendingPathComponent("Contents/Frameworks/Example.framework/Versions/" + version) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try MacArtifactTests.binary(type: 6).write(to: directory.appendingPathComponent("Example")) + } + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + #expect(throws: (any Error).self) { try MacBundleAudit.validate(bundle: app, target: target) } + } + + @Test func swappedSameNameExecutableIsRejected() throws { + let app = try fixture() + defer { try? FileManager.default.removeItem(at: app.deletingLastPathComponent()) } + let nested = app.appendingPathComponent("Contents/XPCServices/Other.xpc") + try MacArtifactTests.fixture(at: nested) + let product = try appProduct() + var manifest = try DeferredSigningManifest(plan: Plan(target: product.target, app: product)) + manifest.products[0].executablePath = "Contents/XPCServices/Other.xpc/Contents/MacOS/App" + try reject(manifest, app: app) + } + + @Test func executableAliasIsRejected() throws { + let app = try fixture() + defer { try? FileManager.default.removeItem(at: app.deletingLastPathComponent()) } + try FileManager.default.createSymbolicLink(atPath: app.appendingPathComponent("Contents/MacOS/Alias").path, + withDestinationPath: "App") + let product = try appProduct() + var manifest = try DeferredSigningManifest(plan: Plan(target: product.target, app: product)) + manifest.products[0].executablePath = "Contents/MacOS/Alias" + try reject(manifest, app: app) + } + + @Test func incorrectPackageTypeIsRejected() throws { + let app = try fixture() + defer { try? FileManager.default.removeItem(at: app.deletingLastPathComponent()) } + let plist = app.appendingPathComponent("Contents/Info.plist") + var info = try #require(PropertyListSerialization.propertyList(from: Data(contentsOf: plist), format: nil) as? [String: Any]) + info["CFBundlePackageType"] = "XPC!" + try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0).write(to: plist) + let product = try appProduct() + try reject(DeferredSigningManifest(plan: Plan(target: product.target, app: product)), app: app) + } + + private func appProduct() throws -> Plan.Product { + Plan.Product(target: try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0"), type: .application, + product: "App", bundleID: "test.App", infoPlist: [:], resources: []) + } + + private func reject(_ manifest: DeferredSigningManifest, app: URL) throws { + let url = app.deletingPathExtension().appendingPathExtension("signing.json") + try JSONEncoder().encode(manifest).write(to: url) + #expect(throws: (any Error).self) { try MacArtifact.write(bundle: app, signingManifest: url) } + } +} diff --git a/Tests/XToolTests/MacSigningTests.swift b/Tests/XToolTests/MacSigningTests.swift new file mode 100644 index 00000000..11d6be84 --- /dev/null +++ b/Tests/XToolTests/MacSigningTests.swift @@ -0,0 +1,250 @@ +import ArgumentParser +import ConcurrencyExtras +import Dependencies +import Foundation +import Testing +@testable import PackLib +@testable import XToolSupport + +struct MacSigningTests { + private func root() throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private func handoff(_ root: URL, entitlements: [String: Any] = [:]) throws -> URL { + let bundle = root.appendingPathComponent("App.app") + try MacArtifactTests.fixture(at: bundle) + let input = root.appendingPathComponent("input.plist") + try PropertyListSerialization.data(fromPropertyList: entitlements, format: .xml, options: 0).write(to: input) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + let app = Plan.Product(target: target, type: .application, product: "App", bundleID: "test.App", + infoPlist: [:], resources: [], entitlementsPath: input.path) + let manifest = try DeferredSigningHandoff.write(plan: Plan(target: target, app: app), bundle: bundle, sourceDirectory: root) + try MacArtifact.write(bundle: bundle, signingManifest: manifest) + return bundle + } + + @Test func requiresUnambiguousIdentityAndRejectsRestrictedEntitlements() throws { + #expect(throws: (any Error).self) { try MacSigningPlan.validateIdentity(mode: .development, identity: nil) } + #expect(throws: (any Error).self) { + try MacSigningPlan.validateIdentity(mode: .developerID, identity: "Developer ID Application: Someone") + } + #expect(throws: (any Error).self) { try MacSigningPlan.validateIdentity(mode: .adHoc, identity: "-") } + try MacSigningPlan.validateIdentity(mode: .development, identity: String(repeating: "a", count: 40)) + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = try handoff(directory, entitlements: ["com.apple.developer.icloud-services": ["CloudDocuments"]]) + #expect(throws: (any Error).self) { try MacSigningPlan.make(bundle: app, mode: .adHoc, identity: nil) } + } + + @Test func dryRunDoesNotCallToolsOrCreateOutput() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = try handoff(directory) + let before = try ArtifactInventory(directory: directory) + try await MacSigningOperation.run(bundle: app, output: directory.appendingPathComponent("signed"), + mode: .adHoc, identity: nil, dryRun: true) + try before.verify(directory: directory) + } + + @Test func signsCopyAndReceiptDetectsLaterMutation() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = try handoff(directory, entitlements: ["com.apple.security.app-sandbox": true]) + let original = try ArtifactInventory(directory: app) + let output = directory.appendingPathComponent("signed") + let captured = LockIsolated(nil) + let calls = LockIsolated<[[String]]>([]) + try await withDependencies { + $0.macSigningClient = MacSigningClient(isMacOS: { true }, run: { _, arguments in + calls.withValue { $0.append(arguments) } + if arguments.contains("--sign") { + let input = try #require(arguments.firstIndex(of: "--entitlements")) + let entitlementData = try Data(contentsOf: URL(fileURLWithPath: arguments[input + 1])) + captured.setValue(entitlementData) + let target = URL(fileURLWithPath: try #require(arguments.last)) + #expect(target.path != app.path) + try Data("signature".utf8).write(to: target.appendingPathComponent("Contents/mock-signature")) + } + if arguments.contains("--entitlements"), !arguments.contains("--sign") { + return MacSigningCommandResult(output: captured.value ?? Data(), error: Data()) + } + return MacSigningCommandResult(output: Data(), error: Data("Signature=adhoc\n".utf8)) + }) + } operation: { + try await MacSigningOperation.run(bundle: app, output: output, mode: .adHoc, identity: nil, dryRun: false) + } + try original.verify(directory: app) + try MacArtifact.verify(bundle: app) + let signed = output.appendingPathComponent("App.app") + let receipt = try MacSigningReceipt.verify(bundle: signed) + #expect(receipt.mode == .adHoc) + #expect(receipt.signatures.count == 1) + #expect(receipt.inputArtifactSHA256 == (try ArtifactInventory.hash(MacArtifact.manifestURL(for: app)))) + #expect(!calls.value.contains { $0.contains("--deep") }) + try Data("tampered".utf8).write(to: signed.appendingPathComponent("Contents/mock-signature")) + #expect(throws: (any Error).self) { try MacSigningReceipt.verify(bundle: signed) } + } + + @Test func signingFailureLeavesNoOutputOrStagingAndPreservesSource() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = try handoff(directory) + let before = try ArtifactInventory(directory: directory) + let output = directory.appendingPathComponent("signed") + await #expect(throws: (any Error).self) { + try await withDependencies { + $0.macSigningClient = MacSigningClient(isMacOS: { true }, run: { _, _ in + throw CocoaError(.fileWriteUnknown) + }) + } operation: { + try await MacSigningOperation.run(bundle: app, output: output, mode: .adHoc, identity: nil, dryRun: false) + } + } + try before.verify(directory: directory) + } + + @Test func existingOutputAndWrongEffectiveEntitlementsFailClosed() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = try handoff(directory, entitlements: ["com.apple.security.app-sandbox": true]) + await #expect(throws: (any Error).self) { + try await MacSigningOperation.run(bundle: app, output: directory, mode: .adHoc, identity: nil, dryRun: true) + } + let output = directory.appendingPathComponent("signed") + await #expect(throws: (any Error).self) { + try await withDependencies { + $0.macSigningClient = MacSigningClient(isMacOS: { true }, run: { _, _ in + MacSigningCommandResult(output: Data(), error: Data("Signature=adhoc\n".utf8)) + }) + } operation: { + try await MacSigningOperation.run(bundle: app, output: output, mode: .adHoc, identity: nil, dryRun: false) + } + } + #expect(!FileManager.default.fileExists(atPath: output.path)) + try MacArtifact.verify(bundle: app) + } + + @Test func outputThroughSymlinkIntoSourceIsRejected() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = try handoff(directory) + let alias = directory.appendingPathComponent("alias") + try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: app) + await #expect(throws: (any Error).self) { + try await MacSigningOperation.run(bundle: app, output: alias.appendingPathComponent("signed"), + mode: .adHoc, identity: nil, dryRun: true) + } + try MacArtifact.verify(bundle: app) + } + + @Test func entitlementInputsAreFrozenAndExtraCodeHasNoInheritedEntitlements() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = try handoff(directory, entitlements: ["com.apple.security.app-sandbox": true]) + let extra = app.appendingPathComponent("Contents/MacOS/extra") + try MacArtifactTests.binary().write(to: extra) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: extra.path) + try MacArtifact.write(bundle: app, signingManifest: directory.appendingPathComponent("App.signing.json")) + let plan = try MacSigningPlan.make(bundle: app, mode: .adHoc, identity: nil) + #expect(plan.targets.map(\.path) == ["Contents/MacOS/extra", ""]) + #expect(plan.targets.first?.entitlements == nil) + let entitlements = try #require(plan.targets.last?.entitlements) + let manifest = try JSONDecoder().decode(DeferredSigningManifest.self, + from: Data(contentsOf: directory.appendingPathComponent("App.signing.json"))) + let input = try #require(manifest.products.first?.entitlementsPath) + try Data("changed".utf8).write(to: directory.appendingPathComponent(input)) + #expect(plan.targets.last?.entitlements == entitlements) + #expect(throws: (any Error).self) { try MacSigningPlan.make(bundle: app, mode: .adHoc, identity: nil) } + } + + @Test(arguments: ["success", "certificate", "runtime", "timestamp", "category"]) + func certificateEvidenceMustMatchPolicy(failure: String) async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = try handoff(directory) + let certificate = Data("mock certificate from native codesign extraction".utf8) + let identity = failure == "certificate" ? String(repeating: "A", count: 40) + : MacSigningPlan.certificateFingerprint(certificate) + let output = directory.appendingPathComponent("signed") + let metadata = "Authority=\(failure == "category" ? "Apple Development:" : "Developer ID Application:") Example\n" + + "TeamIdentifier=TEAM12345\n" + + (failure == "runtime" ? "" : "CodeDirectory v=20500 flags=0x10000(runtime)\n") + + (failure == "timestamp" ? "" : "Timestamp=Oct 13, 2026 at 12:00:00\n") + let calls = LockIsolated<[[String]]>([]) + let client = MacSigningClient(isMacOS: { true }, run: { _, arguments in + calls.withValue { $0.append(arguments) } + if let index = arguments.firstIndex(of: "--extract-certificates") { + try certificate.write(to: URL(fileURLWithPath: arguments[index + 1] + "0")) + } + return MacSigningCommandResult(output: Data(), error: Data(metadata.utf8)) + }) + if failure == "success" { + try await withDependencies { $0.macSigningClient = client } operation: { + try await MacSigningOperation.run(bundle: app, output: output, mode: .developerID, + identity: identity, dryRun: false) + } + let receipt = try MacSigningReceipt.verify(bundle: output.appendingPathComponent("App.app")) + #expect(receipt.signatures.first?.certificateSHA1 == identity) + #expect(receipt.signatures.first?.teamIdentifier == "TEAM12345") + #expect(receipt.signatures.first?.hardenedRuntime == true) + let signing = try #require(calls.value.first { $0.contains("--sign") }) + #expect(signing.contains("--timestamp")) + #expect(signing.contains("runtime")) + } else { + await #expect(throws: (any Error).self) { + try await withDependencies { $0.macSigningClient = client } operation: { + try await MacSigningOperation.run(bundle: app, output: output, mode: .developerID, + identity: identity, dryRun: false) + } + } + #expect(!FileManager.default.fileExists(atPath: output.path)) + } + try MacArtifact.verify(bundle: app) + } + + #if os(macOS) + @Test func nativeAdHocSigningPreservesSourceAndRejectsTampering() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = directory.appendingPathComponent("App.app") + try MacArtifactTests.nativeFixture(at: app) + #if arch(arm64) + let architecture = "arm64" + #else + let architecture = "x86_64" + #endif + let target = try ResolvedTarget.resolve(cliTarget: "\(architecture)-apple-macosx15.0") + let product = Plan.Product(target: target, type: .application, product: "App", bundleID: "test.App", + infoPlist: [:], resources: []) + let manifest = try DeferredSigningHandoff.write(plan: Plan(target: target, app: product), + bundle: app, sourceDirectory: directory) + try MacArtifact.write(bundle: app, signingManifest: manifest) + let before = try ArtifactInventory(directory: app) + let output = directory.appendingPathComponent("signed") + try await withDependencies { + $0.macSigningClient = .liveValue + $0.macRunClient = .liveValue + } operation: { + let arguments = [app.path, "--output", output.path, "--mode", "ad-hoc"] + try await MacSignCommand.parse(arguments + ["--dry-run"]).run() + #expect(!FileManager.default.fileExists(atPath: output.path)) + try await MacSignCommand.parse(arguments).run() + let signed = output.appendingPathComponent("App.app") + try await DevVerifyCommand.parse([signed.path, "--signed"]).run() + await #expect(throws: ExitCode(7)) { + try await DevRunCommand.parse(["--app", signed.path, "--", "first", "two words"]).run() + } + await #expect(throws: (any Error).self) { try await MacSignCommand.parse(arguments).run() } + try Data("tampered".utf8).write(to: signed.appendingPathComponent("Contents/unexpected.txt")) + await #expect(throws: (any Error).self) { + try await DevVerifyCommand.parse([signed.path, "--signed"]).run() + } + } + try before.verify(directory: app) + try MacArtifact.verify(bundle: app) + } + #endif +} From 456a428d553dfa54716499ddf4d4494104f65509 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 13 Oct 2026 20:25:42 +0200 Subject: [PATCH 17/59] dev: add resumable macOS notarization Add `xtool dev notarize` with submit, status, resume, log, staple and verify actions for a signed developer-id app: - submit zips the app with `ditto` into a release directory and sends it with `notarytool` using a keychain profile. - Every step appends a numbered receipt that records the hashes of the uploaded archive and the signing receipt. Each resumed action checks them before contacting Apple, and an exclusive lock prevents concurrent runs on one directory. - resume waits for a known submission. Stapling requires an Accepted result whose log matches the submission ID and the archive hash. It staples a copy, validates it and publishes it by renaming. - Credentials stay in the keychain; only the profile name is passed or recorded. Document notarization in the README. --- README.md | 28 ++ Sources/XToolSupport/DevCommand.swift | 1 + .../XToolSupport/MacNotarizationCommand.swift | 68 ++++ .../MacNotarizationOperation.swift | 356 ++++++++++++++++++ Tests/XToolTests/MacNotarizationTests.swift | 216 +++++++++++ 5 files changed, 669 insertions(+) create mode 100644 Sources/XToolSupport/MacNotarizationCommand.swift create mode 100644 Sources/XToolSupport/MacNotarizationOperation.swift create mode 100644 Tests/XToolTests/MacNotarizationTests.swift diff --git a/README.md b/README.md index 3e029864..4641bc53 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,34 @@ contents, executable bits and symlinks, not extended attributes, and are local i rather than authenticated build attestations. Old content-addressed entitlement inputs are retained when replacing a build handoff so an interrupted update cannot delete the previous inputs. +### macOS notarization + +Use the Developer ID signed output and an existing, explicitly selected `notarytool` +keychain profile. These commands run on a Mac: + +```sh +xtool dev notarize submit release-signed/MyApp.app --output notarized --keychain-profile "$NOTARY_PROFILE" +xtool dev notarize resume notarized --keychain-profile "$NOTARY_PROFILE" --timeout 60 +xtool dev notarize log notarized --keychain-profile "$NOTARY_PROFILE" +# Once accepted: +xtool dev notarize staple notarized +xtool dev notarize verify notarized +``` + +`status` queries without waiting. `submit` never reuses an existing output directory. +An interrupted submission with no recorded UUID remains uncertain: recover with +`resume --submission-id UUID` only when Apple's completed log matches the original upload +SHA-256. Status, resume and log never upload again. A wait timeout leaves the recorded attempt +available for later queries. Credentials are neither created nor selected automatically. + +The release directory retains the original signed snapshot and upload ZIP, append-only receipt +files, and the Apple log binding acceptance to that exact archive. Stapling publishes a separate +`distribution/` containing the app, `distribution.zip` and a recovery receipt. Verification checks +that final inventory, archive hash, native signatures and stapled ticket. Interrupted distribution +publication can be resumed with `staple`. Preserve the complete directory for later verification. +These local receipts are not authenticated attestations or a guarantee of Gatekeeper approval. +The existing Fastlane release of xtool itself remains independent. + ### Library xtool includes a library that you can use to interact with Apple Developer Services, iOS devices, and more from your own app. You can use this by adding `XKit` as a SwiftPM dependency. diff --git a/Sources/XToolSupport/DevCommand.swift b/Sources/XToolSupport/DevCommand.swift index 60f0df1b..a1ae1c83 100644 --- a/Sources/XToolSupport/DevCommand.swift +++ b/Sources/XToolSupport/DevCommand.swift @@ -537,6 +537,7 @@ struct DevCommand: AsyncParsableCommand { DevRunCommand.self, DevVerifyCommand.self, MacSignCommand.self, + DevNotarizeCommand.self, DevBSPCommand.self, ], defaultSubcommand: DevRunCommand.self diff --git a/Sources/XToolSupport/MacNotarizationCommand.swift b/Sources/XToolSupport/MacNotarizationCommand.swift new file mode 100644 index 00000000..6c81b7d2 --- /dev/null +++ b/Sources/XToolSupport/MacNotarizationCommand.swift @@ -0,0 +1,68 @@ +import ArgumentParser +import Foundation +import PackLib +import XUtils + +struct DevNotarizeCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "notarize", abstract: "Submit, resume, and verify a Developer ID signed macOS app", + discussion: "Submit creates a new private release directory. Other actions use that directory. " + + "Credentials must already exist in an explicitly selected notarytool keychain profile. " + + "No signing or credential provisioning is performed." + ) + + enum Action: String, ExpressibleByArgument, CaseIterable { case submit, status, resume, log, staple, verify } + + @Argument(help: "submit, status, resume, log, staple, or verify") + var action: Action + @Argument(help: "Signed .app for submit; release directory for other actions") + var path: String + @Option(help: "New release directory (submit only)") + var output: String? + @Option(help: "Explicit existing notarytool keychain profile for network actions") + var keychainProfile: String? + @Option(help: "Bounded wait in seconds (resume only)") + var timeout: Int = 60 + @Option(help: "Recover unknown submission using a completed Apple log matching the archive (resume only)") + var submissionID: String? + + func validate() throws { + guard (action == .submit) == (output != nil) else { + throw ValidationError("--output is required for submit and unavailable for other actions") + } + guard timeout > 0, action == .resume || (timeout == 60 && submissionID == nil) else { + throw ValidationError("--timeout and --submission-id are supported only by resume") + } + if action == .staple || action == .verify { + guard keychainProfile == nil else { throw ValidationError("This action does not use keychain credentials") } + } else { + _ = try MacNotarizationOperation.authentication(keychainProfile) + } + } + + func run() async throws { + let url = URL(fileURLWithPath: path) + switch action { + case .submit: + guard let output else { throw StringError("Missing --output") } + try await MacNotarizationOperation.submit(bundle: url, directory: URL(fileURLWithPath: output), + profile: keychainProfile) + print("Submission recorded in \(output); use status or resume with that directory") + case .status, .resume: + let status = try await MacNotarizationOperation.update(directory: url, profile: keychainProfile, + wait: action == .resume, timeout: timeout, + recoveryID: submissionID) + print("Notarization status: \(status)") + if status == "Invalid" || status == "Rejected" { throw ExitCode.failure } + case .log: + let data = try await MacNotarizationOperation.log(directory: url, profile: keychainProfile) + print(String(decoding: data, as: UTF8.self)) + case .staple: + try await MacNotarizationOperation.staple(directory: url) + print("Stapled and sealed distribution in \(path)/distribution") + case .verify: + try await MacNotarizationOperation.verify(directory: url) + print("Verified archive, signatures, and stapled ticket in \(path)") + } + } +} diff --git a/Sources/XToolSupport/MacNotarizationOperation.swift b/Sources/XToolSupport/MacNotarizationOperation.swift new file mode 100644 index 00000000..e900a5e0 --- /dev/null +++ b/Sources/XToolSupport/MacNotarizationOperation.swift @@ -0,0 +1,356 @@ +import Dependencies +import Foundation +import PackLib +import Subprocess +import XUtils + +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif + +struct MacNotarizationClient: DependencyKey, Sendable { + var isMacOS: @Sendable () -> Bool + var run: @Sendable (String, [String]) async throws -> Data + var verifySignature: @Sendable (URL, MacSigningReceipt) async throws -> Void + + static let liveValue = Self( + isMacOS: { + #if os(macOS) + true + #else + false + #endif + }, + run: { executable, arguments in + let result = try await Subprocess.run( + .path(.init(executable)), arguments: .init(arguments), + output: .string(limit: .max), error: .currentStandardError + ).checkSuccess() + return Data(result.standardOutput.utf8) + }, + verifySignature: { _ = try await MacSigningOperation.verify(bundle: $0, receipt: $1, verifyInventory: false) } + ) + static let testValue = Self( + isMacOS: { false }, + run: { _, _ in throw StringError("Unexpected notarization command") }, + verifySignature: { _, _ in throw StringError("Unexpected signing verification") } + ) +} + +extension DependencyValues { + var macNotarizationClient: MacNotarizationClient { + get { self[MacNotarizationClient.self] } + set { self[MacNotarizationClient.self] = newValue } + } +} + +/// Append-only local evidence, not a cryptographic attestation. Upload bytes are never rewritten. +struct MacNotarizationReceipt: Codable, Sendable { + var version = 1 + var sequence = 0 + var bundleName: String + var signingReceiptSHA256: String + var uploadSHA256: String + var submissionID: String? + var status: String + var logSHA256: String? + var distributionSHA256: String? + var distributionInventory: ArtifactInventory? +} + +enum MacNotarizationOperation { + static func requireHost() throws { + @Dependency(\.macNotarizationClient) var client + guard client.isMacOS() else { throw StringError("Notarization requires a Mac") } + } + + static func authentication(_ profile: String?) throws -> [String] { + guard let profile, !profile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw StringError("Network notarization commands require an explicit --keychain-profile") + } + return ["--keychain-profile", profile] + } + + static func submit(bundle: URL, directory: URL, profile: String?) async throws { + try requireHost() + let auth = try authentication(profile) + let source = bundle.resolvingSymlinksInPath().standardizedFileURL + let output = directory.deletingLastPathComponent().resolvingSymlinksInPath() + .appendingPathComponent(directory.lastPathComponent).standardizedFileURL + guard output.path != source.path, !output.path.hasPrefix(source.path + "/") else { + throw StringError("Release directory must be outside the input app") + } + let receipt = try MacSigningReceipt.verify(bundle: bundle) + guard receipt.mode == .developerID else { throw StringError("Notarization requires Developer ID signing") } + @Dependency(\.macNotarizationClient) var client + try await client.verifySignature(bundle, receipt) + // Creation is exclusive. An existing output, including an interrupted attempt, is never resubmitted. + guard mkdir(directory.path, 0o700) == 0 else { + throw StringError("Release directory must be new; existing attempts are never resubmitted") + } + let lock = try acquireLock(directory) + defer { releaseLock(lock) } + let snapshot = directory.appendingPathComponent("signed", isDirectory: true) + try FileManager.default.createDirectory(at: snapshot, withIntermediateDirectories: false) + let app = snapshot.appendingPathComponent(bundle.lastPathComponent) + try FileManager.default.copyItem(at: bundle, to: app) + try FileManager.default.copyItem(at: MacSigningReceipt.receiptURL(for: bundle), + to: MacSigningReceipt.receiptURL(for: app)) + let copied = try MacSigningReceipt.verify(bundle: app) + try await client.verifySignature(app, copied) + let upload = directory.appendingPathComponent("upload.zip") + _ = try await client.run("/usr/bin/ditto", ["-c", "-k", "--keepParent", "--sequesterRsrc", app.path, upload.path]) + var state = MacNotarizationReceipt( + bundleName: app.lastPathComponent, + signingReceiptSHA256: try ArtifactInventory.hash(MacSigningReceipt.receiptURL(for: app)), + uploadSHA256: try ArtifactInventory.hash(upload), status: "submission-started" + ) + try append(&state, directory: directory) + let response = try await client.run("/usr/bin/xcrun", ["notarytool", "submit", upload.path, + "--no-wait", "--output-format", "json"] + auth) + let value = try JSONDecoder().decode(Submission.self, from: response) + guard UUID(uuidString: value.id) != nil else { throw StringError("Invalid notarization submission UUID") } + state.submissionID = value.id + state.status = "submitted" + try response.write(to: directory.appendingPathComponent("submission.json"), options: .atomic) + try append(&state, directory: directory) + } + + static func update(directory: URL, profile: String?, wait: Bool = false, timeout: Int = 60, + recoveryID: String? = nil) async throws -> String { + try requireHost() + let auth = try authentication(profile) + guard timeout > 0 else { throw StringError("Timeout must be positive") } + let lock = try acquireLock(directory) + defer { releaseLock(lock) } + var state = try load(directory) + try validateInputs(state, directory: directory) + if let recoveryID { + guard state.submissionID == nil, UUID(uuidString: recoveryID) != nil else { + throw StringError("Recovery requires an ambiguous submission and a valid explicit submission UUID") + } + // A completed Apple log must establish exact archive binding before adopting the supplied UUID. + _ = try await fetchLog(state, id: recoveryID, directory: directory, authentication: auth) + state.submissionID = recoveryID + state.status = "recovered" + try append(&state, directory: directory) + } + guard let id = state.submissionID else { + throw StringError("Submission outcome is unknown; use --submission-id with a completed matching Apple log. " + + "Do not resubmit.") + } + @Dependency(\.macNotarizationClient) var client + var arguments = ["notarytool", wait ? "wait" : "info", id, "--output-format", "json"] + auth + if wait { arguments += ["--timeout", "\(timeout)s"] } + let response = try await client.run("/usr/bin/xcrun", arguments) + let value = try JSONDecoder().decode(Submission.self, from: response) + guard value.id.lowercased() == id.lowercased(), let status = value.status, + ["Accepted", "Invalid", "In Progress", "Rejected"].contains(status) else { + throw StringError("Unexpected notarization response identity or status") + } + // Once stapled, observations cannot discard the distribution evidence. + if state.status == "stapled" { return state.status } + state.status = status + if status == "Accepted" || status == "Invalid" || status == "Rejected" { + let log = try await fetchLog(state, id: id, directory: directory, authentication: auth) + guard log.status == status else { throw StringError("Notarization status and log disagree") } + state.logSHA256 = try ArtifactInventory.hash(logURL(directory)) + } + try append(&state, directory: directory) + return state.status + } + + static func log(directory: URL, profile: String?) async throws -> Data { + try requireHost() + let auth = try authentication(profile) + let lock = try acquireLock(directory) + defer { releaseLock(lock) } + let state = try load(directory) + try validateInputs(state, directory: directory) + guard let id = state.submissionID else { throw StringError("Submission UUID is unknown") } + _ = try await fetchLog(state, id: id, directory: directory, authentication: auth) + return try Data(contentsOf: logURL(directory)) + } + + static func staple(directory: URL) async throws { + try requireHost() + let lock = try acquireLock(directory) + defer { releaseLock(lock) } + var state = try load(directory) + try validateInputs(state, directory: directory) + if state.status == "stapled" { try await verifyDistribution(state, directory: directory); return } + guard state.status == "Accepted" else { + throw StringError("Stapling requires an accepted archive-bound notarization log") + } + try verifyAcceptedLog(state, directory: directory) + let destination = directory.appendingPathComponent("distribution") + if FileManager.default.fileExists(atPath: destination.path) { + var recovered = try JSONDecoder().decode(MacNotarizationReceipt.self, + from: Data(contentsOf: destination.appendingPathComponent("receipt.json"))) + guard recovered.bundleName == state.bundleName, + recovered.signingReceiptSHA256 == state.signingReceiptSHA256, + recovered.uploadSHA256 == state.uploadSHA256, recovered.submissionID == state.submissionID, + recovered.logSHA256 == state.logSHA256 else { + throw StringError("Existing distribution is not bound to this submission") + } + try await verifyDistribution(recovered, directory: directory) + recovered.sequence = state.sequence + try append(&recovered, directory: directory) + return + } + let work = directory.appendingPathComponent("staple-" + UUID().uuidString) + try FileManager.default.createDirectory(at: work, withIntermediateDirectories: false) + defer { try? FileManager.default.removeItem(at: work) } + let source = signedApp(state, directory: directory) + let app = work.appendingPathComponent(state.bundleName) + try FileManager.default.copyItem(at: source, to: app) + @Dependency(\.macNotarizationClient) var client + let signing = try MacSigningReceipt.verify(bundle: source) + try await client.verifySignature(app, signing) + _ = try await client.run("/usr/bin/xcrun", ["stapler", "staple", app.path]) + _ = try await client.run("/usr/bin/xcrun", ["stapler", "validate", app.path]) + try await client.verifySignature(app, signing) + let zip = work.appendingPathComponent("distribution.zip") + _ = try await client.run("/usr/bin/ditto", ["-c", "-k", "--keepParent", "--sequesterRsrc", app.path, zip.path]) + state.distributionInventory = try ArtifactInventory(directory: app) + state.distributionSHA256 = try ArtifactInventory.hash(zip) + state.status = "stapled" + // Publish recoverable evidence together with the app; a crash before the event append is resumable. + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(state).write(to: work.appendingPathComponent("receipt.json"), options: .atomic) + try FileManager.default.moveItem(at: work, to: destination) + try append(&state, directory: directory) + } + + static func verify(directory: URL) async throws { + try requireHost() + let lock = try acquireLock(directory) + defer { releaseLock(lock) } + let state = try load(directory) + try validateInputs(state, directory: directory) + try await verifyDistribution(state, directory: directory) + } + + private static func verifyDistribution(_ state: MacNotarizationReceipt, directory: URL) async throws { + guard state.status == "stapled", let inventory = state.distributionInventory, + let expected = state.distributionSHA256 else { + throw StringError("No verified stapled distribution receipt") + } + try verifyAcceptedLog(state, directory: directory) + let distribution = directory.appendingPathComponent("distribution") + let app = distribution.appendingPathComponent(state.bundleName) + try inventory.verify(directory: app) + guard try ArtifactInventory.hash(distribution.appendingPathComponent("distribution.zip")) == expected else { + throw StringError("Distribution archive changed") + } + let signing = try MacSigningReceipt.verify(bundle: signedApp(state, directory: directory)) + @Dependency(\.macNotarizationClient) var client + try await client.verifySignature(app, signing) + _ = try await client.run("/usr/bin/xcrun", ["stapler", "validate", app.path]) + } + + /// The stored log must still match its recorded hash, bind this archive, and be accepted. + private static func verifyAcceptedLog(_ state: MacNotarizationReceipt, directory: URL) throws { + guard let logHash = state.logSHA256, try ArtifactInventory.hash(logURL(directory)) == logHash else { + throw StringError("Stored notarization log is missing or changed") + } + let log = try JSONDecoder().decode(NotaryLog.self, from: Data(contentsOf: logURL(directory))) + try validateLog(log, state: state, id: state.submissionID ?? "") + guard log.status == "Accepted" else { throw StringError("Notarization log is not accepted") } + } + + private static func logURL(_ directory: URL) -> URL { + directory.appendingPathComponent("notary-log.json") + } + + private static func signedApp(_ state: MacNotarizationReceipt, directory: URL) -> URL { + directory.appendingPathComponent("signed").appendingPathComponent(state.bundleName) + } + + struct Submission: Decodable { var id: String; var status: String? } + struct NotaryLog: Codable { var jobId: String; var sha256: String; var status: String } + + private static func fetchLog(_ state: MacNotarizationReceipt, id: String, directory: URL, + authentication: [String]) async throws -> NotaryLog { + @Dependency(\.macNotarizationClient) var client + let data = try await client.run("/usr/bin/xcrun", ["notarytool", "log", id] + authentication) + let log = try JSONDecoder().decode(NotaryLog.self, from: data) + try validateLog(log, state: state, id: id) + let destination = logURL(directory) + if let previous = state.logSHA256 { + guard try ArtifactInventory.hash(destination) == previous else { throw StringError("Stored notary log changed") } + let existing = try JSONDecoder().decode(NotaryLog.self, from: Data(contentsOf: destination)) + guard existing.jobId == log.jobId, existing.sha256 == log.sha256, existing.status == log.status else { + throw StringError("Notarization log changed") + } + } else { + try data.write(to: destination, options: .atomic) + } + return log + } + + static func validateLog(_ log: NotaryLog, state: MacNotarizationReceipt, id: String) throws { + guard log.jobId.lowercased() == id.lowercased(), log.sha256.lowercased() == state.uploadSHA256, + ["Accepted", "Invalid", "Rejected"].contains(log.status) else { + throw StringError("Apple notarization log does not bind this submission and exact archive") + } + } + + static func validateInputs(_ state: MacNotarizationReceipt, directory: URL) throws { + guard state.version == 1, state.bundleName.hasSuffix(".app"), + state.bundleName == URL(fileURLWithPath: state.bundleName).lastPathComponent, + !state.bundleName.contains("/"), !state.bundleName.contains("\\") else { + throw StringError("Invalid notarization receipt") + } + let app = signedApp(state, directory: directory) + guard try ArtifactInventory.hash(MacSigningReceipt.receiptURL(for: app)) == state.signingReceiptSHA256, + try ArtifactInventory.hash(directory.appendingPathComponent("upload.zip")) == state.uploadSHA256 else { + throw StringError("Signed receipt or submitted archive changed") + } + let signed = try MacSigningReceipt.verify(bundle: app) + guard signed.mode == .developerID else { throw StringError("Notarization requires Developer ID signing") } + } + + static func acquireLock(_ directory: URL) throws -> Int32 { + let path = directory.appendingPathComponent("operation.lock").path + let descriptor = open(path, O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, 0o600) + guard descriptor >= 0 else { throw StringError("Cannot open notarization lock") } + guard flock(descriptor, LOCK_EX | LOCK_NB) == 0 else { + close(descriptor) + throw StringError("Another notarization operation is running") + } + // Never unlink this inode. The kernel releases the lock even when the process crashes. + return descriptor + } + + static func releaseLock(_ descriptor: Int32) { + // Explicit unlock also releases copies inherited during a concurrent fork before exec. + _ = flock(descriptor, LOCK_UN) + close(descriptor) + } + + static func append(_ state: inout MacNotarizationReceipt, directory: URL) throws { + state.sequence += 1 + let url = directory.appendingPathComponent(String(format: "receipt-%06d.json", state.sequence)) + guard !FileManager.default.fileExists(atPath: url.path) else { throw StringError("Receipt event already exists") } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .prettyPrinted] + try encoder.encode(state).write(to: url, options: .atomic) + } + + static func load(_ directory: URL) throws -> MacNotarizationReceipt { + let names = try FileManager.default.contentsOfDirectory(atPath: directory.path) + .filter { $0.hasPrefix("receipt-") && $0.hasSuffix(".json") }.sorted() + guard let last = names.last else { throw StringError("No notarization receipt; do not retry an ambiguous submission") } + let state = try JSONDecoder().decode(MacNotarizationReceipt.self, + from: Data(contentsOf: directory.appendingPathComponent(last))) + guard state.version == 1, state.sequence == names.count, + names.enumerated().allSatisfy({ $0.element == String(format: "receipt-%06d.json", $0.offset + 1) }) else { + throw StringError("Invalid notarization receipt event sequence") + } + return state + } +} diff --git a/Tests/XToolTests/MacNotarizationTests.swift b/Tests/XToolTests/MacNotarizationTests.swift new file mode 100644 index 00000000..9e231f8f --- /dev/null +++ b/Tests/XToolTests/MacNotarizationTests.swift @@ -0,0 +1,216 @@ +import ArgumentParser +import Dependencies +import Foundation +import Testing +import XUtils +@testable import PackLib +@testable import XToolSupport + +struct MacNotarizationTests { + actor Commands { + var calls: [[String]] = [] + var interruptSubmit = false + var wrongLog = false + var status = "Accepted" + let id = "12345678-1234-1234-1234-123456789012" + let release: URL + + init(release: URL) { self.release = release } + func interrupt() { interruptSubmit = true } + func mismatch() { wrongLog = true } + func setStatus(_ value: String) { status = value } + + func run(_ executable: String, _ arguments: [String]) throws -> Data { + calls.append([executable] + arguments) + if executable == "/usr/bin/ditto" { + try Data("archive".utf8).write(to: URL(fileURLWithPath: arguments.last!)) + return Data() + } + if arguments.first == "stapler" { + if arguments[1] == "staple" { + try Data("ticket".utf8).write(to: URL(fileURLWithPath: arguments[2]).appendingPathComponent("ticket")) + } + return Data() + } + if arguments[1] == "submit", interruptSubmit { throw StringError("Interrupted after upload") } + if arguments[1] == "log" { + let hash = try ArtifactInventory.hash(release.appendingPathComponent("upload.zip")) + return try JSONEncoder().encode(MacNotarizationOperation.NotaryLog( + jobId: id, sha256: wrongLog ? String(repeating: "0", count: 64) : hash, status: status + )) + } + return Data("{\"id\":\"\(id)\",\"status\":\"\(status)\"}".utf8) + } + } + + func fixture() throws -> (URL, URL, URL) { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) + let app = root.appendingPathComponent("Example.app") + try MacArtifactTests.fixture(at: app) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + let inventory = try ArtifactInventory(directory: app) + let plan = MacSigningPlan(bundleName: app.lastPathComponent, target: target, mode: .developerID, + identity: String(repeating: "A", count: 40), + inputArtifactSHA256: String(repeating: "b", count: 64), inputInventory: inventory, + targets: [.init(path: "", entitlements: nil)]) + var receipt = try MacSigningReceipt(plan: plan, bundle: app) + receipt.signatures = [.init(path: "", certificateSHA1: plan.identity, teamIdentifier: "TESTTEAM", + authority: "Developer ID Application: Test", hardenedRuntime: true, + timestamp: "test timestamp")] + try receipt.write(beside: app) + return (root, app, root.appendingPathComponent("release")) + } + + func client(_ commands: Commands) -> MacNotarizationClient { + MacNotarizationClient(isMacOS: { true }, run: { try await commands.run($0, $1) }, verifySignature: { _, _ in }) + } + + @Test func completeFlowPreservesUploadAndVerifiesDistribution() async throws { + let (root, app, release) = try fixture() + defer { try? FileManager.default.removeItem(at: root) } + let commands = Commands(release: release) + try await withDependencies { $0.macNotarizationClient = client(commands) } operation: { () async throws in + try await MacNotarizationOperation.submit(bundle: app, directory: release, profile: "chosen-profile") + let original = try ArtifactInventory.hash(release.appendingPathComponent("upload.zip")) + #expect(try await MacNotarizationOperation.update(directory: release, profile: "chosen-profile") == "Accepted") + try await MacNotarizationOperation.staple(directory: release) + try await MacNotarizationOperation.verify(directory: release) + try await MacNotarizationOperation.staple(directory: release) + #expect(try ArtifactInventory.hash(release.appendingPathComponent("upload.zip")) == original) + #expect(try MacNotarizationOperation.load(release).status == "stapled") + let calls = await commands.calls + #expect(calls.filter { $0.contains("submit") }.count == 1) + #expect(calls.filter { $0.contains("staple") }.count == 1) + #expect(calls.contains { $0.contains("--no-wait") && $0.contains("chosen-profile") }) + try Data("tampered".utf8).write(to: release.appendingPathComponent("distribution/distribution.zip")) + await #expect(throws: (any Error).self) { try await MacNotarizationOperation.verify(directory: release) } + } + } + + @Test func interruptedSubmitNeverResubmitsAndCanRecoverBoundUUID() async throws { + let (root, app, release) = try fixture() + defer { try? FileManager.default.removeItem(at: root) } + let commands = Commands(release: release) + await commands.interrupt() + try await withDependencies { $0.macNotarizationClient = client(commands) } operation: { () async throws in + await #expect(throws: (any Error).self) { + try await MacNotarizationOperation.submit(bundle: app, directory: release, profile: "profile") + } + #expect(try MacNotarizationOperation.load(release).status == "submission-started") + await #expect(throws: (any Error).self) { + try await MacNotarizationOperation.submit(bundle: app, directory: release, profile: "profile") + } + await #expect(throws: (any Error).self) { + try await MacNotarizationOperation.update(directory: release, profile: "profile") + } + #expect(try await MacNotarizationOperation.update(directory: release, profile: "profile", wait: true, + timeout: 12, recoveryID: commands.id) == "Accepted") + let calls = await commands.calls + #expect(calls.filter { $0.contains("submit") }.count == 1) + #expect(calls.contains { $0.contains("wait") && $0.contains("12s") }) + } + } + + @Test func wrongAppleArchiveHashCannotAuthorizeStapling() async throws { + let (root, app, release) = try fixture() + defer { try? FileManager.default.removeItem(at: root) } + let commands = Commands(release: release) + try await withDependencies { $0.macNotarizationClient = client(commands) } operation: { () async throws in + try await MacNotarizationOperation.submit(bundle: app, directory: release, profile: "profile") + await commands.mismatch() + await #expect(throws: (any Error).self) { + try await MacNotarizationOperation.update(directory: release, profile: "profile") + } + await #expect(throws: (any Error).self) { try await MacNotarizationOperation.staple(directory: release) } + #expect(try MacNotarizationOperation.load(release).status == "submitted") + } + } + + @Test func changedUploadPreventsNetworkRequests() async throws { + let (root, app, release) = try fixture() + defer { try? FileManager.default.removeItem(at: root) } + let commands = Commands(release: release) + try await withDependencies { $0.macNotarizationClient = client(commands) } operation: { () async throws in + try await MacNotarizationOperation.submit(bundle: app, directory: release, profile: "profile") + let count = await commands.calls.count + try Data("changed".utf8).write(to: release.appendingPathComponent("upload.zip")) + await #expect(throws: (any Error).self) { + try await MacNotarizationOperation.update(directory: release, profile: "profile") + } + #expect(await commands.calls.count == count) + } + } + + @Test func invalidStatusPersistsWithoutDistribution() async throws { + let (root, app, release) = try fixture() + defer { try? FileManager.default.removeItem(at: root) } + let commands = Commands(release: release) + try await withDependencies { $0.macNotarizationClient = client(commands) } operation: { () async throws in + try await MacNotarizationOperation.submit(bundle: app, directory: release, profile: "profile") + await commands.setStatus("Invalid") + #expect(try await MacNotarizationOperation.update(directory: release, profile: "profile") == "Invalid") + await #expect(throws: (any Error).self) { try await MacNotarizationOperation.staple(directory: release) } + } + } + + @Test func outputInsideSourceRejectsBeforeMutationOrTools() async throws { + let (root, app, release) = try fixture() + defer { try? FileManager.default.removeItem(at: root) } + let before = try ArtifactInventory(directory: app) + let alias = root.appendingPathComponent("alias") + try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: app) + let commands = Commands(release: release) + try await withDependencies { $0.macNotarizationClient = client(commands) } operation: { + for directory in [app, app.appendingPathComponent("release"), alias.appendingPathComponent("release")] { + await #expect(throws: (any Error).self) { + try await MacNotarizationOperation.submit(bundle: app, directory: directory, profile: "profile") + } + } + try before.verify(directory: app) + #expect(await commands.calls.isEmpty) + } + } + + @Test func commandRejectsMissingProfileAndCrossActionFlags() throws { + #expect(throws: (any Error).self) { try DevNotarizeCommand.parse(["submit", "App.app", "--output", "release"]) } + #expect(throws: (any Error).self) { + try DevNotarizeCommand.parse(["verify", "release", "--keychain-profile", "profile"]) + } + #expect(throws: (any Error).self) { + try DevNotarizeCommand.parse(["status", "release", "--keychain-profile", "profile", "--submission-id", "id"]) + } + let parsed = try DevNotarizeCommand.parse(["resume", "release", "--keychain-profile", "profile", "--timeout", "12"]) + #expect(parsed.timeout == 12) + } + + @Test func promotedDistributionRecoversAfterReceiptWriteInterruption() async throws { + let (root, app, release) = try fixture() + defer { try? FileManager.default.removeItem(at: root) } + let commands = Commands(release: release) + try await withDependencies { $0.macNotarizationClient = client(commands) } operation: { () async throws in + try await MacNotarizationOperation.submit(bundle: app, directory: release, profile: "profile") + _ = try await MacNotarizationOperation.update(directory: release, profile: "profile") + try await MacNotarizationOperation.staple(directory: release) + let final = try MacNotarizationOperation.load(release) + // Model a crash after the distribution rename but before appending its final event. + try FileManager.default.removeItem(at: release.appendingPathComponent( + String(format: "receipt-%06d.json", final.sequence) + )) + try await MacNotarizationOperation.staple(directory: release) + try await MacNotarizationOperation.verify(directory: release) + #expect(await commands.calls.filter { $0.contains("staple") }.count == 1) + } + } + + @Test func advisoryLockExcludesConcurrentOperations() throws { + let (root, _, release) = try fixture() + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: release, withIntermediateDirectories: false) + let first = try MacNotarizationOperation.acquireLock(release) + #expect(throws: (any Error).self) { try MacNotarizationOperation.acquireLock(release) } + MacNotarizationOperation.releaseLock(first) + let next = try MacNotarizationOperation.acquireLock(release) + MacNotarizationOperation.releaseLock(next) + } +} From be38fb8a0cc2e60291ac2c1682b2512d5fb547a6 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Wed, 14 Oct 2026 00:35:20 +0200 Subject: [PATCH 18/59] packer: honour versioned macOS triples with Linux Swift SDKs SwiftPM only matches an installed Swift SDK by its unversioned triple, so `--triple arm64-apple-macosx16.0` could not use the Darwin SDK on Linux. Write a single-target destination (schema 4.0) derived from the installed SDK into `.build/xtool-sdk` and pass it with `--destination`, so SwiftPM applies the requested deployment version. Make relative SDK paths absolute, keep an absolute swiftResourcesPath, and put the toolset's rootPath on PATH so dsymutil is found. --- Sources/PackLib/BuildSettings.swift | 26 +++++- Sources/PackLib/SwiftSDKConfiguration.swift | 56 ++++++++++++ .../SwiftSDKConfigurationTests.swift | 86 +++++++++++++++++++ 3 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 Sources/PackLib/SwiftSDKConfiguration.swift create mode 100644 Tests/XToolTests/SwiftSDKConfigurationTests.swift diff --git a/Sources/PackLib/BuildSettings.swift b/Sources/PackLib/BuildSettings.swift index 324ba20a..5d00d0f3 100644 --- a/Sources/PackLib/BuildSettings.swift +++ b/Sources/PackLib/BuildSettings.swift @@ -260,10 +260,32 @@ public struct BuildSettings: Sendable { baseArguments = [tool] } + var options = resolvedBaseOptions + var environment = sdkEnvironment + #if !os(macOS) + let target = try TargetTriple(triple) + if buildSystem == .swiftPM && target.platform == .macOS { + let directory: URL + if let path = swiftSDKsPath { + directory = URL(fileURLWithPath: path, isDirectory: true) + } else { + directory = try DarwinSDK.current() + .orThrow(StringError("No Darwin SDK configured. Please run `xtool setup`.")) + .bundle.deletingLastPathComponent() + } + let cache = URL(fileURLWithPath: packagePath, isDirectory: true).appendingPathComponent(".build/xtool-sdk") + let destination = try SwiftSDKConfiguration.destination(in: directory, for: target, cacheDirectory: cache) + let compilerTriple = sdkOptions.firstIndex(of: "--triple").map { sdkOptions[$0 + 1] } + ?? Self.compilerTriple(for: target) + options = configOptions + ["--destination", destination.url.path, "--triple", compilerTriple] + customOptions + environment["PATH"] = (destination.toolSearchPaths + [ProcessInfo.processInfo.environment["PATH"] ?? ""]) + .joined(separator: ":") + } + #endif return Configuration( executable: executable, - arguments: .init(baseArguments + resolvedBaseOptions + arguments), - environment: .inherit.updating(sdkEnvironment), + arguments: .init(baseArguments + options + arguments), + environment: .inherit.updating(environment), platformOptions: .withGracefulShutDown, ) } diff --git a/Sources/PackLib/SwiftSDKConfiguration.swift b/Sources/PackLib/SwiftSDKConfiguration.swift new file mode 100644 index 00000000..2e71d7f4 --- /dev/null +++ b/Sources/PackLib/SwiftSDKConfiguration.swift @@ -0,0 +1,56 @@ +import Foundation +import XUtils + +enum SwiftSDKConfiguration { + struct Destination: Equatable { + let url: URL + let toolSearchPaths: [String] + } + + /// SwiftPM's bundle selector requires an exact target triple. A single-target + /// destination lets it apply the requested deployment version after decoding. + static func destination(in directory: URL, for target: TargetTriple, cacheDirectory: URL) throws -> Destination { + let identity = "\(target.architecture)-apple-macosx" + let bundles = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "artifactbundle" } + var matches: [[String: Any]] = [] + for bundle in bundles { + let metadata = bundle.appendingPathComponent("swift-sdk.json") + guard FileManager.default.fileExists(atPath: metadata.path) else { continue } + let data = try Data(contentsOf: metadata) + guard let document = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let triples = document["targetTriples"] as? [String: [String: Any]], + var properties = triples[identity] else { continue } + for key in ["sdkRootPath", "swiftResourcesPath", "swiftStaticResourcesPath"] { + if let path = properties[key] as? String { + properties[key] = URL(fileURLWithPath: path, relativeTo: bundle).standardizedFileURL.path + } + } + for key in ["includeSearchPaths", "librarySearchPaths", "toolsetPaths"] { + if let paths = properties[key] as? [String] { + properties[key] = paths.map { URL(fileURLWithPath: $0, relativeTo: bundle).standardizedFileURL.path } + } + } + matches.append(properties) + } + guard matches.count == 1, let properties = matches.first else { + throw StringError("Expected one Swift SDK for '\(identity)' in '\(directory.path)'; found \(matches.count)") + } + let data = try JSONSerialization.data(withJSONObject: [ + "schemaVersion": "4.0", "targetTriples": [identity: properties], + ], options: [.sortedKeys]) + let hash = ArtifactInventory.hash(data: data) + var toolSearchPaths: [String] = [] + for path in properties["toolsetPaths"] as? [String] ?? [] { + let toolset = URL(fileURLWithPath: path) + let contents = try JSONSerialization.jsonObject(with: Data(contentsOf: toolset)) as? [String: Any] + if let root = contents?["rootPath"] as? String { + toolSearchPaths.append(URL(fileURLWithPath: root, relativeTo: toolset).standardizedFileURL.path) + } + } + try FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) + let destination = cacheDirectory.appendingPathComponent("\(identity)-\(hash).json") + if !FileManager.default.fileExists(atPath: destination.path) { try data.write(to: destination, options: .atomic) } + return Destination(url: destination, toolSearchPaths: toolSearchPaths) + } +} diff --git a/Tests/XToolTests/SwiftSDKConfigurationTests.swift b/Tests/XToolTests/SwiftSDKConfigurationTests.swift new file mode 100644 index 00000000..92ed17da --- /dev/null +++ b/Tests/XToolTests/SwiftSDKConfigurationTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Testing +@testable import PackLib + +struct SwiftSDKConfigurationTests { + private func root() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true).standardizedFileURL + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root.resolvingSymlinksInPath() + } + + private func metadata(in bundle: URL) throws -> URL { + try FileManager.default.createDirectory(at: bundle, withIntermediateDirectories: true) + for path in ["Developer/SDKs/MacOSX.sdk", "swift_static", "include", "lib"] { + let directory = bundle.appendingPathComponent(path, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data(path.utf8).write(to: directory.appendingPathComponent("marker.txt")) + } + let metadata = bundle.appendingPathComponent("swift-sdk.json") + try Data(#"{"schemaVersion":"1.0","rootPath":"tools/bin"}"#.utf8) + .write(to: bundle.appendingPathComponent("toolset.json")) + try JSONSerialization.data(withJSONObject: [ + "schemaVersion": "4.0", "targetTriples": [ + "arm64-apple-macosx": [ + "sdkRootPath": "Developer/SDKs/MacOSX.sdk", + "swiftResourcesPath": "/absolute/swift", + "swiftStaticResourcesPath": "swift_static", + "includeSearchPaths": ["include"], + "librarySearchPaths": ["lib"], + "toolsetPaths": ["toolset.json"], + ], + "arm64-apple-ios": ["sdkRootPath": "Developer/SDKs/iPhoneOS.sdk"], + ], + ]).write(to: metadata) + return metadata + } + + @Test func destinationSelectsOneTargetAndPreservesSDKPathsWithoutChangingInstallation() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let bundle = directory.appendingPathComponent("darwin.artifactbundle", isDirectory: true) + let original = try metadata(in: bundle) + let before = try Data(contentsOf: original) + let target = try TargetTriple("arm64-apple-macosx16.0") + let cache = directory.appendingPathComponent("cache", isDirectory: true) + let destination = try SwiftSDKConfiguration.destination(in: directory, for: target, cacheDirectory: cache) + let document = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: destination.url)) as? [String: Any]) + let triples = try #require(document["targetTriples"] as? [String: [String: Any]]) + #expect(Set(triples.keys) == ["arm64-apple-macosx"]) + let properties = try #require(triples["arm64-apple-macosx"]) + #expect(properties["swiftResourcesPath"] as? String == "/absolute/swift") + for (key, marker) in [("sdkRootPath", "Developer/SDKs/MacOSX.sdk"), ("swiftStaticResourcesPath", "swift_static")] { + let path = try #require(properties[key] as? String) + #expect((path as NSString).isAbsolutePath) + #expect(try String(contentsOfFile: path + "/marker.txt", encoding: .utf8) == marker) + } + for (key, marker) in [("includeSearchPaths", "include"), ("librarySearchPaths", "lib")] { + let paths = try #require(properties[key] as? [String]) + #expect(paths.count == 1) + let path = try #require(paths.first) + #expect((path as NSString).isAbsolutePath) + #expect(try String(contentsOfFile: path + "/marker.txt", encoding: .utf8) == marker) + } + #expect(properties["toolsetPaths"] as? [String] == [bundle.appendingPathComponent("toolset.json").path]) + #expect(destination.toolSearchPaths == [bundle.appendingPathComponent("tools/bin").path]) + #expect(try Data(contentsOf: original) == before) + #expect(try SwiftSDKConfiguration.destination(in: directory, for: target, cacheDirectory: cache) == destination) + } + + @Test func missingAndAmbiguousSDKsFailWithoutPublishingConfiguration() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let target = try TargetTriple("arm64-apple-macosx15.0") + let cache = directory.appendingPathComponent("cache", isDirectory: true) + #expect(throws: (any Error).self) { + try SwiftSDKConfiguration.destination(in: directory, for: target, cacheDirectory: cache) + } + _ = try metadata(in: directory.appendingPathComponent("first.artifactbundle", isDirectory: true)) + _ = try metadata(in: directory.appendingPathComponent("second.artifactbundle", isDirectory: true)) + #expect(throws: (any Error).self) { + try SwiftSDKConfiguration.destination(in: directory, for: target, cacheDirectory: cache) + } + #expect(!FileManager.default.fileExists(atPath: cache.path)) + } +} From 2459c896f5d4aa1a0fa2cdaa13a1c81774bf6dc5 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Wed, 14 Oct 2026 03:41:38 +0200 Subject: [PATCH 19/59] packer: accept universal static archives MachOBinary.validate only recognised a static archive by the `!` magic at the start of the file. A universal binary whose slices are static archives, the usual layout of a `macos-arm64_x86_64` XCFramework slice, was validated as a dynamic library and failed with "Wrong Mach-O file type; expected 6". Mark slices read from archive members and decide per slice whether an object file or a dynamic library is expected. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/PackLib/ProductBuilder.swift | 15 ++++++++++----- Tests/XToolTests/BinaryLinkTests.swift | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/Sources/PackLib/ProductBuilder.swift b/Sources/PackLib/ProductBuilder.swift index 35a04ec9..b9f6b0ce 100644 --- a/Sources/PackLib/ProductBuilder.swift +++ b/Sources/PackLib/ProductBuilder.swift @@ -380,6 +380,8 @@ struct MachOBinary { var installName: String? var rpaths: [String] = [] var hasCodeSignature = false + /// Object files from a static archive, including archives inside a universal binary. + var archiveMember = false struct Dependency { var path: String var weak: Bool @@ -414,12 +416,11 @@ struct MachOBinary { throw StringError("Missing binary artifact: \(url.path)") } do { - let bytes = [UInt8](try Data(contentsOf: url)) - let archive = bytes.starts(with: Array("!\n".utf8)) - let slices = try inspect(bytes) + let slices = try inspect([UInt8](try Data(contentsOf: url))) let cpu = try cpuType(for: target.architecture) let matching = slices.filter { $0.cpu == cpu } guard !matching.isEmpty else { throw StringError("Wrong architecture; expected \(target.architecture)") } + let archive = matching.allSatisfy(\.archiveMember) let platform: UInt32 = switch target.platform { case .macOS: 1 case .iOS: 2 @@ -432,7 +433,7 @@ struct MachOBinary { guard slice.minimumOS <= packedVersion(target.deploymentTarget) else { throw StringError("Binary minimum OS exceeds deployment target \(target.deploymentTarget)") } - let expectedType: UInt32 = executable ? 2 : (archive ? 1 : 6) + let expectedType: UInt32 = executable ? 2 : (slice.archiveMember ? 1 : 6) guard slice.fileType == expectedType else { throw StringError("Wrong Mach-O file type; expected \(expectedType)") } if expectedType == 6 { try validateInstallName(slice.installName, binary: url) @@ -513,7 +514,11 @@ struct MachOBinary { let actualName = nameSize > 0 ? String(decoding: bytes[(offset + 60)..<(offset + 60 + nameSize)], as: UTF8.self) : name if !actualName.hasPrefix("__.SYMDEF") && actualName != "/" && actualName != "//" { - slices += try inspect(Array(bytes[(offset + 60 + nameSize)..<(offset + 60 + size)])) + slices += try inspect(Array(bytes[(offset + 60 + nameSize)..<(offset + 60 + size)])).map { + var member = $0 + member.archiveMember = true + return member + } } offset += 60 + size + size % 2 } diff --git a/Tests/XToolTests/BinaryLinkTests.swift b/Tests/XToolTests/BinaryLinkTests.swift index 6ffc376f..55d1b463 100644 --- a/Tests/XToolTests/BinaryLinkTests.swift +++ b/Tests/XToolTests/BinaryLinkTests.swift @@ -146,6 +146,27 @@ struct BinaryLinkTests { #expect(throws: (any Error).self) { try MachOBinary.inspect(Array(data.dropLast())) } } + @Test func universalStaticArchiveIsStatic() throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + func archive(_ object: Data) -> Data { + let size = String(object.count).padding(toLength: 10, withPad: " ", startingAt: 0) + return Data("!\nfile.o/ 0 0 0 100644 \(size)`\n".utf8) + object + } + let slices = [archive(binary(cpu: 0x01000007, type: 1)), archive(binary(type: 1))] + let header: [UInt32] = [0xcafebabe, 2, 0x01000007, 0, 48, UInt32(slices[0].count), 0, + 0x0100000c, 0, UInt32(48 + slices[0].count), UInt32(slices[1].count), 0] + var data = Data() + for word in header { data += Data((0..<4).map { UInt8(truncatingIfNeeded: word >> ((3 - $0) * 8)) }) } + let url = root.appendingPathComponent("Fat") + try (data + slices[0] + slices[1]).write(to: url) + for architecture in ["arm64", "x86_64"] { + let target = try ResolvedTarget.resolve(cliTarget: "\(architecture)-apple-macosx15.0") + #expect(try MachOBinary.validate(url, target: target, executable: false)) + #expect(throws: (any Error).self) { try MachOBinary.validate(url, target: target, executable: true) } + } + } + @Test func staticBinaryTargetsRequireRealArchive() async throws { let root = try temporaryRoot() defer { try? FileManager.default.removeItem(at: root) } From bf7e57cb802a98104c2754efbf2614246ffe5693 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Wed, 14 Oct 2026 03:44:23 +0200 Subject: [PATCH 20/59] packer: stop system rpaths from hiding missing embedded libraries MacBundleAudit accepted an @rpath dependency as soon as any rpath candidate was a system path. swiftc adds LC_RPATH /usr/lib/swift to every macOS executable, so every @rpath dependency of a Swift app was treated as a system library and skipped: a framework missing from Contents/Frameworks passed `dev build` and `dev verify` and then failed in dyld at launch. Require an @rpath dependency to resolve inside the bundle. Only Swift's back-deployment libraries (`@rpath/libswift*`) may resolve to a system rpath, since they ship with the OS. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/PackLib/MacBundleAudit.swift | 4 +++- Tests/XToolTests/MacArtifactTests.swift | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Sources/PackLib/MacBundleAudit.swift b/Sources/PackLib/MacBundleAudit.swift index b880ee24..446b2281 100644 --- a/Sources/PackLib/MacBundleAudit.swift +++ b/Sources/PackLib/MacBundleAudit.swift @@ -137,7 +137,6 @@ public enum MacBundleAudit { } else { candidates = resolved(name, loader: path, executable: executable).map { [$0] } ?? [] } - if candidates.contains(where: systemLibrary) { continue } let match = candidates.compactMap { candidate -> String? in let url = root.appendingPathComponent(candidate).resolvingSymlinksInPath().standardizedFileURL guard url.path.hasPrefix(root.path + "/") else { return nil } @@ -146,6 +145,9 @@ public enum MacBundleAudit { }.first guard let match else { if dependency.weak { continue } + // Swift's back-deployment runtime is referenced through @rpath and ships with the OS. + // Any other @rpath library must be embedded; a system rpath alone does not resolve it. + if name.hasPrefix("@rpath/libswift"), candidates.contains(where: systemLibrary) { continue } throw StringError("Unresolved runtime dependency '\(name)' in \(path)") } try visit(match, executable: executable, inherited: rpaths, visited: &visited) diff --git a/Tests/XToolTests/MacArtifactTests.swift b/Tests/XToolTests/MacArtifactTests.swift index 8c550ffa..66d08401 100644 --- a/Tests/XToolTests/MacArtifactTests.swift +++ b/Tests/XToolTests/MacArtifactTests.swift @@ -117,6 +117,20 @@ struct MacArtifactTests { #expect(try MacBundleAudit.validate(bundle: app, target: target) == ["Contents/Frameworks/libDependency.dylib", ""]) } + @Test func systemRpathDoesNotResolveEmbeddedLibraries() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = directory.appendingPathComponent("App.app") + try Self.fixture(at: app) + let executable = app.appendingPathComponent("Contents/MacOS/App") + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + // swiftc emits LC_RPATH /usr/lib/swift for every macOS executable. + try Self.binary(dependency: "@rpath/libMissing.dylib", rpath: "/usr/lib/swift").write(to: executable) + #expect(throws: (any Error).self) { try MacBundleAudit.validate(bundle: app, target: target) } + try Self.binary(dependency: "@rpath/libswift_Concurrency.dylib", rpath: "/usr/lib/swift").write(to: executable) + #expect(try MacBundleAudit.validate(bundle: app, target: target) == [""]) + } + @Test func existingAppRunForwardsArgumentsAndExitStatusWithoutBuilding() async throws { let directory = try root() defer { try? FileManager.default.removeItem(at: directory) } From 05c16459f6fea8d15464527cc656aa964f6293d1 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Wed, 14 Oct 2026 03:48:13 +0200 Subject: [PATCH 21/59] packer: copy legacy macOS resources into Contents/Resources xtool.yml's legacy `resources` list was copied to the bundle root on every platform. On macOS the bundle root is outside the code signature's resource seal, so codesign rejects the app, and `Bundle.main` does not look there either. Copy legacy resources to each product's resources directory instead: unchanged on iOS (the bundle root), Contents/Resources on macOS. Co-Authored-By: Claude Opus 5.5 (1M context) --- Documentation/xtool.docc/Configuration.md | 6 +++--- Sources/PackLib/BundleAssembler.swift | 7 ++++--- Tests/XToolTests/BundleAssemblerTests.swift | 16 ++++++++++++++++ 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/Documentation/xtool.docc/Configuration.md b/Documentation/xtool.docc/Configuration.md index 41aa121b..37928f7d 100644 --- a/Documentation/xtool.docc/Configuration.md +++ b/Documentation/xtool.docc/Configuration.md @@ -54,7 +54,7 @@ nestedProducts: | `infoPath` | Caller plist dictionary merged over generated defaults; caller identifiers and extension/XPC metadata are preserved. | | `entitlementsPath` | Caller plist dictionary associated with this product for the later signer. | | `iconPath` | Prepared `.png` or `.icns` in version 2; ICNS requires macOS. | -| `resources` | Legacy list of files/directories copied to the product bundle root. | +| `resources` | Legacy list of files/directories copied to the product's resources directory: the bundle root on iOS, `Contents/Resources` on macOS. | | `resourceCopies` | Explicit copies with `source` and `destination: {location, path}`. | | `nestedProducts` | Products with required `product`, `kind` and `source`; optional `bundleID`, `infoPath`, `entitlementsPath`, `resourceCopies`. | | `extensions` | Existing extension list with required `product`/`infoPath`; version 2 also accepts `source`/`resourceCopies`. | @@ -110,8 +110,8 @@ owner's `Contents/Frameworks`; helper-owned frameworks use `destination.path` is a nonempty relative path appended to that base. Absolute paths, empty components, `.`, `..`, backslashes and NUL are rejected. Assembly also rejects overlapping destinations, including case/Unicode-equivalent -collisions, and unsafe symlinks. Legacy `resources` retains bundle-root copying; -prefer `resourceCopies` for prepared macOS resources. Explicit extension resources +collisions, and unsafe symlinks. Legacy `resources` copies into each product's +resources directory; use `resourceCopies` to choose another destination. Explicit extension resources belong in the extension's Resources directory. Source-built macOS SwiftPM resource accessors are adapted for relocated bundles; diff --git a/Sources/PackLib/BundleAssembler.swift b/Sources/PackLib/BundleAssembler.swift index 844ec0a8..345bd6dd 100644 --- a/Sources/PackLib/BundleAssembler.swift +++ b/Sources/PackLib/BundleAssembler.swift @@ -135,9 +135,10 @@ public struct BundleAssembler: Sendable { } case .root(let path): let source = sourceURL(path) - // Legacy root resources retain their bundle-root destination on every platform. - let base = product.type == .helperExecutable ? layout.contentsPath : layout.bundlePath - assembly.operations.append(.init(path: joined(base, source.lastPathComponent), payload: .copy(source))) + // Legacy resources go where Bundle.main looks: the bundle root on iOS, Contents/Resources on + // macOS. The macOS bundle root is outside the code signature's resource seal. + let destination = ResourceDestination(path: source.lastPathComponent) + assembly.operations.append(.init(path: try layout.path(for: destination), payload: .copy(source))) case .copy(let copy): assembly.operations.append(.init(path: try layout.path(for: copy.destination), payload: .copy(sourceURL(copy.source)))) } diff --git a/Tests/XToolTests/BundleAssemblerTests.swift b/Tests/XToolTests/BundleAssemblerTests.swift index e5de4abf..52e4154d 100644 --- a/Tests/XToolTests/BundleAssemblerTests.swift +++ b/Tests/XToolTests/BundleAssemblerTests.swift @@ -356,6 +356,22 @@ struct BundleAssemblerTests { } } + @Test func macOSLegacyResourcesLandInsideTheResourceSeal() throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + try write(at: root.appendingPathComponent("legacy.txt")) + let app = try product(resources: [.root(source: "legacy.txt")]) + let ext = try product("Preview", kind: .appExtension, resources: [.root(source: "legacy.txt")]) + let plan = try Plan(target: app.target, app: app, extensions: [ext]) + let output = root.appendingPathComponent("App.app") + try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: try artifacts(plan, in: root), at: output) + #expect(FileManager.default.fileExists(atPath: output.appendingPathComponent("Contents/Resources/legacy.txt").path)) + #expect(FileManager.default.fileExists( + atPath: output.appendingPathComponent("Contents/PlugIns/Preview.appex/Contents/Resources/legacy.txt").path)) + let rootEntries = try FileManager.default.contentsOfDirectory(atPath: output.path) + #expect(rootEntries == ["Contents"]) + } + @Test func iosLayoutAndLegacyRootResourcesRemainFlat() throws { let root = try root() defer { try? FileManager.default.removeItem(at: root) } From c5e978aa8eec87dbd674aa24cc9813c240c814e1 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Wed, 14 Oct 2026 03:57:39 +0200 Subject: [PATCH 22/59] packer: find static archives of local XCFramework binary targets SwiftPM extracts remote and zipped binary targets into its artifacts directory, but leaves a local `.binaryTarget(path: "X.xcframework")` in place and only copies the slice's library into the bin directory. The static-archive lookup only searched the artifacts directory, so a local static XCFramework whose LibraryPath is not `lib.a` failed with "Missing binary target". Record the absolute path of local XCFrameworks from `swift package describe` on the planned binary target, and read that Info.plist first. Checked against a real local static XCFramework with `xtool dev build --defer-signing`: it fails before this change and builds and runs after it. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/PackLib/BuildPlan.swift | 4 ++- Sources/PackLib/BundleAssembler.swift | 6 ++-- Sources/PackLib/Planner.swift | 13 +++++++- Sources/PackLib/ProductBuilder.swift | 45 ++++++++++++++------------ Tests/XToolTests/BinaryLinkTests.swift | 27 ++++++++++++++++ Tests/XToolTests/PlannerTests.swift | 19 +++++++++-- 6 files changed, 87 insertions(+), 27 deletions(-) diff --git a/Sources/PackLib/BuildPlan.swift b/Sources/PackLib/BuildPlan.swift index 08568c8b..9eb062d6 100644 --- a/Sources/PackLib/BuildPlan.swift +++ b/Sources/PackLib/BuildPlan.swift @@ -27,7 +27,9 @@ public struct Plan: Sendable { public enum Resource: Codable, Sendable, Hashable { case bundle(package: String, target: String) - case binaryTarget(name: String) + /// `xcframework` is the absolute path of a local, unarchived XCFramework; SwiftPM extracts + /// remote and zipped binary targets into its artifacts directory instead. + case binaryTarget(name: String, xcframework: String? = nil) case library(name: String) case root(source: String) case copy(ResourceCopy) diff --git a/Sources/PackLib/BundleAssembler.swift b/Sources/PackLib/BundleAssembler.swift index 345bd6dd..d31fe851 100644 --- a/Sources/PackLib/BundleAssembler.swift +++ b/Sources/PackLib/BundleAssembler.swift @@ -99,13 +99,13 @@ public struct BundleAssembler: Sendable { payload: .symlink("../Resources/\(name)")), product: product, to: &assembly) } - case .binaryTarget(let name): + case .binaryTarget(let name, let xcframework): try validateName(name) let source = artifacts.directory.appendingPathComponent("\(name).framework") let binary = source.appendingPathComponent(name) if artifacts.staticBinaryTargets.contains(name) { - let looseArchive = ProductBuilder.staticArchive(forBinaryTarget: name, in: artifacts.directory, - target: plan.target) + let looseArchive = ProductBuilder.staticArchive(forBinaryTarget: name, xcframework: xcframework, + in: artifacts.directory, target: plan.target) guard FileManager.default.fileExists(atPath: binary.path) || FileManager.default.fileExists(atPath: looseArchive.path) else { throw StringError("Missing validated static binary target '\(name)' in \(artifacts.directory.path)") diff --git a/Sources/PackLib/Planner.swift b/Sources/PackLib/Planner.swift index 80df658a..36408ce4 100644 --- a/Sources/PackLib/Planner.swift +++ b/Sources/PackLib/Planner.swift @@ -239,7 +239,9 @@ public struct Planner: Sendable { } // Compiler plugins and their dependencies run on the host, never in the bundle. if described.type == "macro" || ["macro", "plugin"].contains(manifest.type) { continue } - if described.moduleType == "BinaryTarget" { append(.binaryTarget(name: identity.name)) } + if described.moduleType == "BinaryTarget" { + append(.binaryTarget(name: identity.name, xcframework: package.description.localXCFramework(described))) + } if described.resources?.isEmpty == false { append(.bundle(package: package.description.name, target: identity.name)) } @@ -306,6 +308,7 @@ private struct PackageDump: Decodable, Sendable { let name: String let moduleType: String let type: String? + let path: String? let resources: [Resource]? } @@ -316,9 +319,17 @@ private struct PackageDump: Decodable, Sendable { } let name: String + let path: String? let products: [Product]? let targets: [Target]? let platforms: [Platform]? + + /// Absolute path of a binary target's unarchived XCFramework inside this package. + func localXCFramework(_ target: Target) -> String? { + guard let path, let relative = target.path, relative.hasSuffix(".xcframework") else { return nil } + return URL(fileURLWithPath: relative, relativeTo: URL(fileURLWithPath: path, isDirectory: true)) + .standardizedFileURL.path + } } /// `describe` supplies resource bundle names, but only `dump-package` preserves diff --git a/Sources/PackLib/ProductBuilder.swift b/Sources/PackLib/ProductBuilder.swift index b9f6b0ce..bc8b5c10 100644 --- a/Sources/PackLib/ProductBuilder.swift +++ b/Sources/PackLib/ProductBuilder.swift @@ -111,10 +111,11 @@ public struct ProductBuilder: Sendable { var staticTargets: Set = [] for resource in Set(plan.allProducts.flatMap(\.resources)) { switch resource { - case .binaryTarget(let name): + case .binaryTarget(let name, let xcframework): try ResourceDestination.validateRelativePath(name) let framework = directory.appendingPathComponent("\(name).framework/\(name)") - let archive = Self.staticArchive(forBinaryTarget: name, in: directory, target: plan.target) + let archive = Self.staticArchive(forBinaryTarget: name, xcframework: xcframework, + in: directory, target: plan.target) let url = FileManager.default.fileExists(atPath: framework.path) ? framework : archive guard FileManager.default.fileExists(atPath: url.path) else { throw StringError("Missing binary target '\(name)': expected \(framework.path) or \(archive.path)") @@ -146,30 +147,34 @@ public struct ProductBuilder: Sendable { } /// SwiftPM copies a static xcframework slice into the bin directory under the slice's own - /// `LibraryPath`, which need not match the target name. Remote and archived binary targets are - /// extracted to `/artifacts///`; otherwise expect `lib.a`. - static func staticArchive(forBinaryTarget name: String, in directory: URL, target: ResolvedTarget) -> URL { + /// `LibraryPath`, which need not match the target name. Local binary targets name their + /// XCFramework; remote and archived ones are extracted to `/artifacts///`. + /// Otherwise expect `lib.a`. + static func staticArchive( + forBinaryTarget name: String, xcframework: String? = nil, in directory: URL, target: ResolvedTarget + ) -> URL { let manager = FileManager.default let artifacts = directory.deletingLastPathComponent().deletingLastPathComponent() .appendingPathComponent("artifacts", isDirectory: true) let variant: String? = target.platform == .iOSSimulator ? "simulator" : nil - let packages = (try? manager.contentsOfDirectory(atPath: artifacts.path).sorted()) ?? [] - for package in packages { + var candidates = xcframework.map { [URL(fileURLWithPath: $0, isDirectory: true)] } ?? [] + for package in (try? manager.contentsOfDirectory(atPath: artifacts.path).sorted()) ?? [] { let targetDirectory = artifacts.appendingPathComponent(package).appendingPathComponent(name) let entries = (try? manager.contentsOfDirectory(atPath: targetDirectory.path).sorted()) ?? [] - for entry in entries where entry.hasSuffix(".xcframework") { - let plist = targetDirectory.appendingPathComponent(entry).appendingPathComponent("Info.plist") - guard let data = try? Data(contentsOf: plist), - let info = try? PropertyListDecoder().decode(XCFrameworkInfo.self, from: data), - let library = info.AvailableLibraries.first(where: { - $0.SupportedPlatform == target.platform.packagePlatformName - && $0.SupportedPlatformVariant == variant - && $0.SupportedArchitectures.contains(target.architecture) - }), - library.LibraryPath.hasSuffix(".a"), !library.LibraryPath.contains("/"), - (try? ResourceDestination.validateRelativePath(library.LibraryPath)) != nil else { continue } - return directory.appendingPathComponent(library.LibraryPath) - } + candidates += entries.filter { $0.hasSuffix(".xcframework") }.map { targetDirectory.appendingPathComponent($0) } + } + for candidate in candidates { + let plist = candidate.appendingPathComponent("Info.plist") + guard let data = try? Data(contentsOf: plist), + let info = try? PropertyListDecoder().decode(XCFrameworkInfo.self, from: data), + let library = info.AvailableLibraries.first(where: { + $0.SupportedPlatform == target.platform.packagePlatformName + && $0.SupportedPlatformVariant == variant + && $0.SupportedArchitectures.contains(target.architecture) + }), + library.LibraryPath.hasSuffix(".a"), !library.LibraryPath.contains("/"), + (try? ResourceDestination.validateRelativePath(library.LibraryPath)) != nil else { continue } + return directory.appendingPathComponent(library.LibraryPath) } return directory.appendingPathComponent("lib\(name).a") } diff --git a/Tests/XToolTests/BinaryLinkTests.swift b/Tests/XToolTests/BinaryLinkTests.swift index 55d1b463..1f2a81d4 100644 --- a/Tests/XToolTests/BinaryLinkTests.swift +++ b/Tests/XToolTests/BinaryLinkTests.swift @@ -218,6 +218,33 @@ struct BinaryLinkTests { #expect(!FileManager.default.fileExists(atPath: output.appendingPathComponent("Contents/Frameworks/Static.framework").path)) } + @Test func staticBinaryTargetArchiveUsesLocalXCFrameworkLibraryPath() async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + // SwiftPM leaves local XCFrameworks in place; only the slice's library reaches the bin directory. + let xcframework = root.appendingPathComponent("Vendor/Static.xcframework") + try FileManager.default.createDirectory(at: xcframework, withIntermediateDirectories: true) + let libraries: [[String: Any]] = [ + ["LibraryPath": "libvendor.a", "SupportedPlatform": "macos", "SupportedArchitectures": ["arm64"]], + ] + try PropertyListSerialization.data(fromPropertyList: ["AvailableLibraries": libraries], format: .xml, options: 0) + .write(to: xcframework.appendingPathComponent("Info.plist")) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + try write(binary(), to: root.appendingPathComponent("App")) + var app = product("App", kind: .application, target: target) + app.resources = [.binaryTarget(name: "Static", xcframework: xcframework.path)] + let plan = try Plan(target: target, app: app) + let object = binary(type: 1) + let size = String(object.count).padding(toLength: 10, withPad: " ", startingAt: 0) + let header = "file.o/ 0 0 0 100644 \(size)`\n" + try (Data("!\n\(header)".utf8) + object).write(to: root.appendingPathComponent("libvendor.a")) + let settings = try await BuildSettings(configuration: .release, triple: target.triple, packagePath: root.path) + let artifacts = try await ProductBuilder(buildSettings: settings, plan: plan).build() + #expect(artifacts.staticBinaryTargets == ["Static"]) + try BundleAssembler(plan: plan, sourceDirectory: root) + .assemble(artifacts: artifacts, at: root.appendingPathComponent("App.app")) + } + @Test func dynamicInstallNameMustResolveInsideEmbeddedFramework() throws { let root = try temporaryRoot() defer { try? FileManager.default.removeItem(at: root) } diff --git a/Tests/XToolTests/PlannerTests.swift b/Tests/XToolTests/PlannerTests.swift index 922be5bd..053469cc 100644 --- a/Tests/XToolTests/PlannerTests.swift +++ b/Tests/XToolTests/PlannerTests.swift @@ -14,12 +14,12 @@ struct PlannerTests { private func package( _ name: String = "Root", products: [[String: Any]]? = nil, targets: [[String: Any]]? = nil, - dependencies: [[String: Any]] = [], platforms: [[String: String]] = [] + dependencies: [[String: Any]] = [], platforms: [[String: String]] = [], path: String? = nil ) throws -> Package { let targets = targets ?? [target("App")] return try Package( description: json([ - "name": name, "products": products ?? [product("App")], + "name": name, "path": path as Any? ?? NSNull(), "products": products ?? [product("App")], "platforms": platforms, "targets": targets.map { target -> [String: Any] in var described = target described.removeValue(forKey: "dependencies") @@ -246,6 +246,21 @@ struct PlannerTests { #expect(app.resources.contains(.binaryTarget(name: "Binary"))) } + @Test func localXCFrameworkBinaryTargetsRecordTheirPath() async throws { + func binary(_ name: String, path: String) -> [String: Any] { + var value = target(name, kind: "binary") + value["path"] = path + return value + } + let root = try package(targets: [ + target("App", dependencies: [["target": ["Local", NSNull()]], ["target": ["Zipped", NSNull()]]]), + binary("Local", path: "Vendor/Local.xcframework"), binary("Zipped", path: "Vendor/Zipped.zip"), + ], path: "/root") + let app = try await plan(["root": root], triple: "arm64-apple-macosx").app + #expect(Set(app.resources) == [.binaryTarget(name: "Local", xcframework: "/root/Vendor/Local.xcframework"), + .binaryTarget(name: "Zipped")]) + } + @Test func packageAliasesDisambiguateSameNamedProducts() async throws { let root = try package(targets: [target("App", dependencies: [dependency("Shared", package: "Alias")])], dependencies: [reference("one", alias: "Alias"), reference("two")]) From e8dd3d05b0902121dc6cd7af9a489a0526d4b779 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Wed, 14 Oct 2026 04:04:13 +0200 Subject: [PATCH 23/59] dev: reject unknown `dev run` arguments before `--` `dev run` captures app arguments with `.captureForPassthrough`, which also captures unknown options and words. Because `run` is the default `dev` subcommand, a typo stopped being a parse error: `xtool dev run --cofiguration release` built Debug and passed the typo to the app, and `xtool dev biuld` ran the app. Only accept captured arguments after `--`. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/XToolSupport/DevCommand.swift | 9 ++++++++- Tests/XToolTests/DevCommandTests.swift | 4 ++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Sources/XToolSupport/DevCommand.swift b/Sources/XToolSupport/DevCommand.swift index a1ae1c83..9ac31fa7 100644 --- a/Sources/XToolSupport/DevCommand.swift +++ b/Sources/XToolSupport/DevCommand.swift @@ -246,7 +246,14 @@ struct DevRunCommand: AsyncParsableCommand { @Argument(parsing: .captureForPassthrough, help: "Arguments passed to the macOS app after --") var arguments: [String] = [] - var appArguments: [String] { arguments.first == "--" ? Array(arguments.dropFirst()) : arguments } + var appArguments: [String] { Array(arguments.dropFirst()) } + + func validate() throws { + // Passthrough capture also swallows unknown options and words; only accept them after `--`. + if let first = arguments.first, first != "--" { + throw ValidationError("Unknown argument '\(first)'. Pass app arguments after '--'.") + } + } #if os(macOS) @Flag( diff --git a/Tests/XToolTests/DevCommandTests.swift b/Tests/XToolTests/DevCommandTests.swift index c0b29165..6f90237d 100644 --- a/Tests/XToolTests/DevCommandTests.swift +++ b/Tests/XToolTests/DevCommandTests.swift @@ -80,6 +80,10 @@ struct DevCommandTests { @Test func runAcceptsTripleAndXcodeStillRejectsIt() throws { #expect(try DevRunCommand.parse(["--triple", "arm64-apple-macosx"]).triple == "arm64-apple-macosx") + #expect(try DevRunCommand.parse(["--", "--cofiguration", "x"]).appArguments == ["--cofiguration", "x"]) + for typo in [["--cofiguration", "release"], ["biuld"], ["--app", "App.app", "--cofiguration", "release"]] { + #expect(throws: (any Error).self) { try DevRunCommand.parse(typo) } + } #expect(throws: (any Error).self) { try DevXcodeCommand.parse(["--triple", "arm64-apple-macosx"]) } From 63f55ed676b4415886de3f89a31bba832ce955c9 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Wed, 14 Oct 2026 04:15:21 +0200 Subject: [PATCH 24/59] packer: compile Darwin products for the planned deployment target The SwiftPM backend compiles Darwin targets for each package's declared platform minimum and ignores the version in `--triple`. For a package without `platforms`, xtool planned macOS 15.0 and the linker recorded `minos 15.0` (via `-platform_version`), but Swift compiled against the SwiftPM default, so macOS 15-only APIs such as `Mutex` failed availability checking. Pass `-target ` to the Swift, C and C++ compilers through the target-only toolset, for direct executables and for the generated wrapper package. The native fixture no longer declares platforms and uses `Mutex` in both build paths. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/PackLib/ProductBuilder.swift | 20 +++++++++++++------- Tests/XToolTests/ProductBuilderTests.swift | 13 +++++++++---- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/Sources/PackLib/ProductBuilder.swift b/Sources/PackLib/ProductBuilder.swift index bc8b5c10..2499421f 100644 --- a/Sources/PackLib/ProductBuilder.swift +++ b/Sources/PackLib/ProductBuilder.swift @@ -35,6 +35,7 @@ public struct ProductBuilder: Sendable { return false } if !libraries.isEmpty { try createWrapper(at: wrapper, root: root, products: libraries, sdkArguments: sdkArguments) } + let wrapperToolset = libraries.isEmpty ? [] : try toolsetOptions(name: "wrapper", swiftOptions: [], in: wrapper) // Share one scratch tree so resources and dependency libraries remain // beside all products, including mixed direct and wrapped products. @@ -54,11 +55,12 @@ public struct ProductBuilder: Sendable { name = product.targetName // Planning already resolved root dependencies; avoid dropping // its development-only dependencies from Package.resolved. - options = ["--disable-automatic-resolution"] + options = ["--disable-automatic-resolution"] + wrapperToolset case .executable(let sourceName): package = root name = sourceName - options = try directProductOptions(product, in: wrapper, sdkArguments: sdkArguments) + options = try toolsetOptions(name: product.targetName, swiftOptions: product.linkerArguments + sdkArguments, + in: wrapper) case .prebuilt: continue } @@ -179,17 +181,21 @@ public struct ProductBuilder: Sendable { return directory.appendingPathComponent("lib\(name).a") } - private func directProductOptions( - _ product: Plan.Product, in directory: URL, sdkArguments: [String] - ) throws -> [String] { + private func toolsetOptions(name: String, swiftOptions: [String], in directory: URL) throws -> [String] { // SwiftPM forwards CLI -Xlinker options to host macro executables too. // SDK toolset options belong only to the target toolchain instead. + // The SwiftPM backend compiles Darwin targets for each package's declared platform minimum + // and ignores the version in `--triple`, so pass the plan's deployment target explicitly. + let target = buildSettings.buildSystem == .swiftPM ? ["-target", plan.target.compilerTriple] : [] + guard !(target + swiftOptions).isEmpty else { return [] } let toolsets = directory.appendingPathComponent("toolsets", isDirectory: true) try FileManager.default.createDirectory(at: toolsets, withIntermediateDirectories: true) - let path = toolsets.appendingPathComponent("\(product.targetName).json") + let path = toolsets.appendingPathComponent("\(name).json") let data = try JSONSerialization.data(withJSONObject: [ "schemaVersion": "1.0", - "swiftCompiler": ["extraCLIOptions": product.linkerArguments + sdkArguments], + "swiftCompiler": ["extraCLIOptions": target + swiftOptions], + "cCompiler": ["extraCLIOptions": target], + "cxxCompiler": ["extraCLIOptions": target], ], options: [.sortedKeys]) try data.write(to: path) return ["--toolset", path.path] diff --git a/Tests/XToolTests/ProductBuilderTests.swift b/Tests/XToolTests/ProductBuilderTests.swift index db14284b..006df6c4 100644 --- a/Tests/XToolTests/ProductBuilderTests.swift +++ b/Tests/XToolTests/ProductBuilderTests.swift @@ -192,11 +192,10 @@ struct ProductBuilderTests { import PackageDescription let package = Package( name: "BuilderFixture", - platforms: [.macOS("15.0")], products: [.executable(name: "DirectExecutable", targets: ["Direct"]), - .library(name: "HelperLibrary", targets: ["Helper"])], + .library(name: "HelperLibrary", targets: ["Helper", "Gate"])], targets: [.executableTarget(name: "Direct", resources: [.copy("message.txt")]), - .target(name: "Helper")] + .target(name: "Helper"), .target(name: "Gate")] ) """ try Data(manifest.utf8).write(to: root.appendingPathComponent("Package.swift")) @@ -204,7 +203,13 @@ struct ProductBuilderTests { let helper = root.appendingPathComponent("Sources/Helper") try FileManager.default.createDirectory(at: direct, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: helper.appendingPathComponent("include"), withIntermediateDirectories: true) - try Data("import AppKit\nprint(NSApplication.self)".utf8).write(to: direct.appendingPathComponent("main.swift")) + // No declared platforms: both build paths must compile for the plan's macOS 15.0 deployment + // target, or the macOS 15-only Mutex fails availability checking. + try Data("import AppKit\nimport Synchronization\nlet lock = Mutex(0)\nprint(NSApplication.self, lock.withLock { $0 })".utf8) + .write(to: direct.appendingPathComponent("main.swift")) + let gate = root.appendingPathComponent("Sources/Gate") + try FileManager.default.createDirectory(at: gate, withIntermediateDirectories: true) + try Data("import Synchronization\npublic let gate = Mutex(0)".utf8).write(to: gate.appendingPathComponent("Gate.swift")) try Data("resource fixture".utf8).write(to: direct.appendingPathComponent("message.txt")) try Data("#include \nint main() { std::vector values(3, 1); return values.at(1); }".utf8) .write(to: helper.appendingPathComponent("Entry.cpp")) From 94585da25afff226f59008396a2aa2e8acc0385e Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Wed, 14 Oct 2026 10:49:51 +0200 Subject: [PATCH 25/59] packer: keep raw assets in iOS SwiftPM resource bundles Bundle assembly rejects raw asset catalogs, iconsets, storyboards and XIBs in every copied input. SwiftPM's native build copies these into resource bundles uncompiled, so an iOS app depending on a package with an asset catalog, which packaged on main, failed to assemble. Copy SwiftPM resource bundles as built on iOS again. User-supplied inputs on every platform, and resource bundles on macOS, are still rejected as documented. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/PackLib/BundleAssembler.swift | 19 ++++++++++------- Tests/XToolTests/BundleAssemblerTests.swift | 23 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/Sources/PackLib/BundleAssembler.swift b/Sources/PackLib/BundleAssembler.swift index d31fe851..0838ed39 100644 --- a/Sources/PackLib/BundleAssembler.swift +++ b/Sources/PackLib/BundleAssembler.swift @@ -41,6 +41,8 @@ public struct BundleAssembler: Sendable { private struct Operation: Hashable { var path: String var payload: Payload + /// iOS has always copied SwiftPM resource bundles as built, including uncompiled assets. + var allowsRawAssets = false } private struct Assembly { @@ -90,7 +92,8 @@ public struct BundleAssembler: Sendable { try validateName(target) let name = "\(package)_\(target).bundle" let path = joined(layout.resourcesPath, name) - appendDependency(.init(path: path, payload: .copy(artifacts.directory.appendingPathComponent(name))), + appendDependency(.init(path: path, payload: .copy(artifacts.directory.appendingPathComponent(name)), + allowsRawAssets: product.platform != .macOS), product: product, to: &assembly) if product.platform == .macOS && product.type == .helperExecutable { // Bare helpers retain SwiftPM's executable-directory lookup. Real bundles use @@ -183,7 +186,7 @@ public struct BundleAssembler: Sendable { } destinations.append(path) if case .copy(let source) = operation.payload { - try validateInput(source) + try validateInput(source, allowsRawAssets: operation.allowsRawAssets) let sourcePath = source.resolvingSymlinksInPath().path let outputPath = outputURL.resolvingSymlinksInPath().path guard outputPath != sourcePath, !outputPath.hasPrefix(sourcePath + "/") else { @@ -193,7 +196,7 @@ public struct BundleAssembler: Sendable { } } - private func validateInput(_ source: URL) throws { + private func validateInput(_ source: URL, allowsRawAssets: Bool = false) throws { let manager = FileManager.default guard manager.fileExists(atPath: source.path) else { throw StringError("Missing bundle input: \(source.path)") @@ -202,18 +205,20 @@ public struct BundleAssembler: Sendable { if try source.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink == true { throw StringError("Bundle input must be a payload rather than a top-level symbolic link: \(source.path)") } - try validateEntry(source, root: source) + try validateEntry(source, root: source, allowsRawAssets: allowsRawAssets) if try source.resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true { guard let enumerator = manager.enumerator(at: source, includingPropertiesForKeys: [.isSymbolicLinkKey]) else { throw StringError("Cannot enumerate bundle input: \(source.path)") } - while let entry = enumerator.nextObject() as? URL { try validateEntry(entry, root: source) } + while let entry = enumerator.nextObject() as? URL { + try validateEntry(entry, root: source, allowsRawAssets: allowsRawAssets) + } } } - private func validateEntry(_ entry: URL, root: URL) throws { + private func validateEntry(_ entry: URL, root: URL, allowsRawAssets: Bool) throws { let rawAssets = ["xcassets", "iconset", "storyboard", "xib"] - if rawAssets.contains(entry.pathExtension.lowercased()) { + if !allowsRawAssets && rawAssets.contains(entry.pathExtension.lowercased()) { throw StringError("Raw asset '\(entry.path)' requires Apple asset compilation; supply prepared " + "Assets.car, .icns, or compiled resources using resource-copies instead") } diff --git a/Tests/XToolTests/BundleAssemblerTests.swift b/Tests/XToolTests/BundleAssemblerTests.swift index 52e4154d..bf40d0f3 100644 --- a/Tests/XToolTests/BundleAssemblerTests.swift +++ b/Tests/XToolTests/BundleAssemblerTests.swift @@ -372,6 +372,29 @@ struct BundleAssemblerTests { #expect(rootEntries == ["Contents"]) } + @Test(arguments: ["arm64-apple-ios17.0", "arm64-apple-macosx15.0"]) + func swiftPMBundlesKeepRawAssetsOnlyOnIOS(triple: String) throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let target = try ResolvedTarget.resolve(cliTarget: triple) + let app = Plan.Product(target: target, type: .application, product: "App", bundleID: "test.App", infoPlist: [:], + resources: [.bundle(package: "Package", target: "Target")]) + let plan = try Plan(target: target, app: app) + let artifacts = try artifacts(plan, in: root) + // SwiftPM's native build copies asset catalogs and XIBs into resource bundles uncompiled. + let bundle = artifacts.directory.appendingPathComponent("Package_Target.bundle") + try write("{}", at: bundle.appendingPathComponent("Media.xcassets/Contents.json")) + try write(at: bundle.appendingPathComponent("View.xib")) + let output = root.appendingPathComponent("App.app") + let assemble = { try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: artifacts, at: output) } + if target.platform == .macOS { + #expect(throws: (any Error).self) { try assemble() } + } else { + try assemble() + #expect(FileManager.default.fileExists(atPath: output.appendingPathComponent("Package_Target.bundle/View.xib").path)) + } + } + @Test func iosLayoutAndLegacyRootResourcesRemainFlat() throws { let root = try root() defer { try? FileManager.default.removeItem(at: root) } From 083d04c8d824af6035f404d35c4a7f305d1e18f7 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Wed, 14 Oct 2026 10:51:16 +0200 Subject: [PATCH 26/59] packer: raise old package minimums to xtool's platform minimum ResolvedTarget used a package's declared platform version as the deployment target and then rejected it when it was below xtool's platform minimum. A package declaring `.iOS(.v12)`, which built on main with no way to override it in a version 1 xtool.yml, failed with "Deployment target 12.0 is below required minimum 13.0". Raise a package minimum below the platform minimum to that minimum. An explicit triple or schema version below a minimum is still rejected. Co-Authored-By: Claude Opus 5.5 (1M context) --- Documentation/xtool.docc/Control.md | 2 +- Sources/PackLib/TargetPlatform.swift | 11 +++++++---- Tests/XToolTests/PackContractTests.swift | 5 +++++ 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/Documentation/xtool.docc/Control.md b/Documentation/xtool.docc/Control.md index 98810854..d9c3e38f 100644 --- a/Documentation/xtool.docc/Control.md +++ b/Documentation/xtool.docc/Control.md @@ -28,7 +28,7 @@ deploymentTarget: "15.0" `xtool dev build --triple` and `xtool dev build-server --triple` override `target` in `xtool.yml`. When neither is set, both use `arm64-apple-ios`. macOS targets support `arm64` and `x86_64`; `macos` is accepted as an alias for `macosx`. Build macOS apps with `xtool dev build --defer-signing` and sign the resulting app later on a Mac. On Linux, use `--configuration release`; macOS Debug builds require signing support available on macOS. -The selected triple's OS version takes precedence over `deploymentTarget`, then the package's matching platform minimum, then xtool's platform minimum (iOS 13.0 or macOS 15.0). An explicit version cannot lower the schema, package, or platform minimum. For example, `arm64-apple-macosx16.0` selects macOS 16.0 while the SDK identity remains `arm64-apple-macosx`. +The selected triple's OS version takes precedence over `deploymentTarget`, then the package's matching platform minimum, then xtool's platform minimum (iOS 13.0 or macOS 15.0). A package minimum below xtool's platform minimum is raised to it. An explicit version cannot lower the schema, package, or platform minimum. For example, `arm64-apple-macosx16.0` selects macOS 16.0 while the SDK identity remains `arm64-apple-macosx`. Editor support uses the same target resolution and package minimum as builds. Swift 6.4 and later use `.bsp/xtool.json`, whose build-server command selects the effective target. Earlier toolchains use `.sourcekit-lsp/config.json` with separate Swift SDK and compiler triple settings. Restart the editor after changing these files. xtool refreshes or migrates only configurations matching its complete generated content; customized files are preserved. Set `skipLSP: true` to disable automatic editor configuration. diff --git a/Sources/PackLib/TargetPlatform.swift b/Sources/PackLib/TargetPlatform.swift index 3db99ad8..fa06ae48 100644 --- a/Sources/PackLib/TargetPlatform.swift +++ b/Sources/PackLib/TargetPlatform.swift @@ -74,7 +74,8 @@ public struct ResolvedTarget: Codable, Sendable, Equatable { /// Triple precedence: CLI > schema > iOS default. /// Deployment precedence: selected triple's version > schema > package > platform minimum. - /// A selected triple cannot lower an explicit schema/package minimum. + /// A package minimum below xtool's platform minimum is raised to it; an explicit triple or + /// schema version cannot lower the schema, package or platform minimum. public static func resolve( cliTarget: String? = nil, schema: PackSchema = .default, @@ -82,11 +83,13 @@ public struct ResolvedTarget: Codable, Sendable, Equatable { ) throws -> Self { let triple = cliTarget ?? schema.target ?? defaultTriple let parsed = try TargetTriple(triple) + let platformMinimum = parsed.platform.minimumDeploymentTarget let packageMinimum = packageDeploymentTargets[parsed.platform.packagePlatformName] - let deployment = parsed.deploymentTarget ?? schema.deploymentTarget - ?? packageMinimum ?? parsed.platform.minimumDeploymentTarget + if let packageMinimum { try TargetTriple.validateVersion(packageMinimum) } + let packageDefault = packageMinimum.map { version($0, isBelow: platformMinimum) ? platformMinimum : $0 } + let deployment = parsed.deploymentTarget ?? schema.deploymentTarget ?? packageDefault ?? platformMinimum try TargetTriple.validateVersion(deployment) - for minimum in [schema.deploymentTarget, packageMinimum, parsed.platform.minimumDeploymentTarget].compactMap({ $0 }) { + for minimum in [schema.deploymentTarget, packageMinimum, platformMinimum].compactMap({ $0 }) { try TargetTriple.validateVersion(minimum) guard !version(deployment, isBelow: minimum) else { throw StringError("Deployment target \(deployment) is below required minimum \(minimum)") diff --git a/Tests/XToolTests/PackContractTests.swift b/Tests/XToolTests/PackContractTests.swift index 2b64286d..ff064f54 100644 --- a/Tests/XToolTests/PackContractTests.swift +++ b/Tests/XToolTests/PackContractTests.swift @@ -49,6 +49,11 @@ struct PackContractTests { #expect(fromCLI.deploymentTarget == "17.0") let fromPackage = try ResolvedTarget.resolve(packageDeploymentTargets: ["ios": "16.0"]) #expect(fromPackage.deploymentTarget == "16.0") + // Packages declaring an older minimum than xtool supports, such as `.iOS(.v12)`, still build. + #expect(try ResolvedTarget.resolve(packageDeploymentTargets: ["ios": "12.0"]).deploymentTarget == "13.0") + #expect(try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx", + packageDeploymentTargets: ["macos": "13.0"]).deploymentTarget == "15.0") + #expect(throws: (any Error).self) { try ResolvedTarget.resolve(cliTarget: "arm64-apple-ios12.0") } #expect(throws: (any Error).self) { try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx14.0", schema: config) } From 06bb18e0df2bd121b26bfec3a81d667c2f1754a2 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Wed, 14 Oct 2026 10:53:36 +0200 Subject: [PATCH 27/59] dev: start the editor build server without package inspection `xtool dev build-server` ran the full planner on every start (`swift package show-dependencies`, `describe` and `dump-package` on the main `.build`), bypassing the server's own `--disable-automatic-resolution` and `--experimental-skip-acquiring-lock`. A broken manifest, a missing Info.plist or an ambiguous product kept the editor's build server from starting, and starting it could resolve over the network or wait on the `.build` lock. Without xtool.yml it also printed a warning to stdout, ahead of the build server protocol stream. The generated `.bsp/xtool.json` now pins the planned compiler triple (for example `arm64-apple-macosx15.0`), and the server resolves its target from that triple and xtool.yml alone. Configurations generated with an unversioned triple are still recognised and refreshed. The missing-xtool.yml warning goes to stderr. Co-Authored-By: Claude Opus 5.5 (1M context) --- Documentation/xtool.docc/Control.md | 2 +- Sources/XToolSupport/DevBSPCommand.swift | 2 +- Sources/XToolSupport/DevCommand.swift | 6 +++-- Sources/XToolSupport/LSPConfig.swift | 32 ++++++++++++++++------- Tests/XToolTests/DevCommandTests.swift | 2 +- Tests/XToolTests/EditorConfigTests.swift | 33 ++++++++++++++++++++++-- 6 files changed, 61 insertions(+), 16 deletions(-) diff --git a/Documentation/xtool.docc/Control.md b/Documentation/xtool.docc/Control.md index d9c3e38f..baba42ae 100644 --- a/Documentation/xtool.docc/Control.md +++ b/Documentation/xtool.docc/Control.md @@ -30,7 +30,7 @@ deploymentTarget: "15.0" The selected triple's OS version takes precedence over `deploymentTarget`, then the package's matching platform minimum, then xtool's platform minimum (iOS 13.0 or macOS 15.0). A package minimum below xtool's platform minimum is raised to it. An explicit version cannot lower the schema, package, or platform minimum. For example, `arm64-apple-macosx16.0` selects macOS 16.0 while the SDK identity remains `arm64-apple-macosx`. -Editor support uses the same target resolution and package minimum as builds. Swift 6.4 and later use `.bsp/xtool.json`, whose build-server command selects the effective target. Earlier toolchains use `.sourcekit-lsp/config.json` with separate Swift SDK and compiler triple settings. Restart the editor after changing these files. xtool refreshes or migrates only configurations matching its complete generated content; customized files are preserved. Set `skipLSP: true` to disable automatic editor configuration. +Editor support uses the same target resolution and package minimum as builds. Swift 6.4 and later use `.bsp/xtool.json`, whose build-server command pins the effective compiler triple (for example `arm64-apple-macosx15.0`), so starting the build server does not inspect the package. Earlier toolchains use `.sourcekit-lsp/config.json` with separate Swift SDK and compiler triple settings. Restart the editor after changing these files. xtool refreshes or migrates only configurations matching its complete generated content; customized files are preserved. Set `skipLSP: true` to disable automatic editor configuration. ## Change your bundle ID diff --git a/Sources/XToolSupport/DevBSPCommand.swift b/Sources/XToolSupport/DevBSPCommand.swift index 34a22d0a..34eae0a6 100644 --- a/Sources/XToolSupport/DevBSPCommand.swift +++ b/Sources/XToolSupport/DevBSPCommand.swift @@ -18,7 +18,7 @@ struct DevBSPCommand: AsyncParsableCommand { throw Console.Error("`xtool dev build-server` requires Swift 6.4 or later") } let request = try await DevBuildRequest.load(cliTriple: triple) - let (settings, _) = try await EditorTarget.buildSettings(request: request) + let settings = try await EditorTarget.serverSettings(request: request) try await Subprocess.run( settings.buildServerInvocation(), input: .currentStandardInput, diff --git a/Sources/XToolSupport/DevCommand.swift b/Sources/XToolSupport/DevCommand.swift index 9ac31fa7..43ea57c5 100644 --- a/Sources/XToolSupport/DevCommand.swift +++ b/Sources/XToolSupport/DevCommand.swift @@ -503,10 +503,12 @@ extension DevCommandClient: DependencyKey { if FileManager.default.fileExists(atPath: path.path) { return try await PackSchema(url: path) } - print(""" + // stderr: `dev build-server` speaks the build server protocol on stdout. + FileHandle.standardError.write(Data(""" warning: Could not locate configuration file '\(path.path)'. Using default \ configuration with 'com.example' organization ID. - """) + + """.utf8)) return .default }, ensureSDK: { try await EnsureSDKOperation(quiet: true).run() }, diff --git a/Sources/XToolSupport/LSPConfig.swift b/Sources/XToolSupport/LSPConfig.swift index cc484194..46929071 100644 --- a/Sources/XToolSupport/LSPConfig.swift +++ b/Sources/XToolSupport/LSPConfig.swift @@ -77,11 +77,8 @@ enum LSPConfig { let path: String if bsp { path = Self.bsp.0 - object = [ - "name": "xtool", "version": "1.0", "bspVersion": "2.2.0", - "languages": ["c", "cpp", "objective-c", "objective-cpp", "swift"], - "argv": ["/usr/bin/env", "xtool", "dev", "build-server", "--triple", target.triple], - ] + // Pin the planned deployment version so starting the server needs no package inspection. + object = bspObject(triple: target.compilerTriple) } else { path = legacyLSP.0 let sdkIndex = sdkOptions.firstIndex(of: "--swift-sdk") @@ -98,6 +95,14 @@ enum LSPConfig { return (path, String(decoding: data, as: UTF8.self)) } + private static func bspObject(triple: String) -> [String: Any] { + [ + "name": "xtool", "version": "1.0", "bspVersion": "2.2.0", + "languages": ["c", "cpp", "objective-c", "objective-cpp", "swift"], + "argv": ["/usr/bin/env", "xtool", "dev", "build-server", "--triple", triple], + ] + } + static func ensure( target: ResolvedTarget, sdkOptions: [String], supportsBSP: Bool, quiet: Bool = false, currentDirectory directory: URL ) throws { @@ -132,13 +137,13 @@ enum LSPConfig { if let old = try? JSONSerialization.jsonObject(with: Data(original.utf8)) as? NSDictionary, object == old { return true } - let triple: String? if bsp { + // Earlier generated configurations pinned the unversioned triple; recognize any valid one. let argv = object["argv"] as? [String] - triple = argv?.count == 6 ? argv?.last : nil - } else { - triple = (object["swiftPM"] as? [String: String])?["triple"] + guard argv?.count == 6, let triple = argv?.last, (try? TargetTriple(triple)) != nil else { return false } + return object == bspObject(triple: triple) as NSDictionary } + let triple = (object["swiftPM"] as? [String: String])?["triple"] guard let triple, let target = try? ResolvedTarget.resolve(cliTarget: triple), let expected = try? configuration(target: target, sdkOptions: [], bsp: bsp), let generated = try? JSONSerialization.jsonObject(with: Data(expected.1.utf8)) as? NSDictionary else { @@ -155,4 +160,13 @@ enum EditorTarget { let plan = try await Planner(buildSettings: settings, schema: request.schema).createPlan() return (try settings.withTarget(plan.target), plan.target) } + + /// The build server starts with the compiler triple pinned in the generated configuration. + /// Resolve it without the planner, so a broken manifest or a locked or unresolved `.build` + /// cannot keep the editor's build server from starting. + nonisolated(nonsending) static func serverSettings(request: DevBuildRequest) async throws -> BuildSettings { + let target = try ResolvedTarget.resolve(cliTarget: request.triple, schema: request.schema) + let settings = try await BuildSettings(configuration: .debug, triple: request.triple, packagePath: request.directory.path) + return try settings.withTarget(target) + } } diff --git a/Tests/XToolTests/DevCommandTests.swift b/Tests/XToolTests/DevCommandTests.swift index 6f90237d..cc5907a8 100644 --- a/Tests/XToolTests/DevCommandTests.swift +++ b/Tests/XToolTests/DevCommandTests.swift @@ -254,7 +254,7 @@ struct DevCommandTests { let editorConfig = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: editorConfigURL)) as? [String: Any]) if supportsBSP { #expect(editorConfig["argv"] as? [String] == [ - "/usr/bin/env", "xtool", "dev", "build-server", "--triple", "arm64-apple-macosx", + "/usr/bin/env", "xtool", "dev", "build-server", "--triple", "arm64-apple-macosx15.0", ]) } else { #expect(editorConfig["swiftPM"] as? [String: String] == [ diff --git a/Tests/XToolTests/EditorConfigTests.swift b/Tests/XToolTests/EditorConfigTests.swift index 93c0a7fa..d028dca3 100644 --- a/Tests/XToolTests/EditorConfigTests.swift +++ b/Tests/XToolTests/EditorConfigTests.swift @@ -35,7 +35,9 @@ struct EditorConfigTests { try LSPConfig.ensure(target: mac, sdkOptions: [], supportsBSP: bsp, quiet: true, currentDirectory: directory) let object = try #require(JSONSerialization.jsonObject(with: read(path, in: directory)) as? [String: Any]) if bsp { - #expect(object["argv"] as? [String] == ["/usr/bin/env", "xtool", "dev", "build-server", "--triple", mac.triple]) + #expect(object["argv"] as? [String] == [ + "/usr/bin/env", "xtool", "dev", "build-server", "--triple", "x86_64-apple-macosx15.0", + ]) } else { #expect(object["swiftPM"] as? [String: String] == [ "swiftSDK": "x86_64-apple-macosx", "triple": "x86_64-apple-macosx15.0", @@ -165,6 +167,33 @@ struct EditorConfigTests { } } + @Test func buildServerStartsWithoutInspectingThePackage() async throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let schema = try PackSchema(validating: .init(version: .v2, orgID: "test", product: "App", target: "arm64-apple-macosx")) + let request = try DevBuildRequest(schema: schema, cliTriple: "arm64-apple-macosx16.0", directory: directory) + let settings = try await withDependencies { + $0.packageInspection = PackageInspectionClient( + dependencies: { _ in Issue.record("Unexpected package inspection"); return Data() }, + describe: { _ in Issue.record("Unexpected package inspection"); return Data() }, + manifest: { _ in Issue.record("Unexpected package inspection"); return Data() } + ) + } operation: { + try await EditorTarget.serverSettings(request: request) + } + #expect(settings.sdkOptions == ["--swift-sdk", "arm64-apple-macosx", "--triple", "arm64-apple-macosx16.0"]) + // Configurations generated with the unversioned triple are still recognized and refreshed. + let old = (LSPConfig.bsp.0, """ + {"argv": ["/usr/bin/env", "xtool", "dev", "build-server", "--triple", "arm64-apple-macosx"], + "bspVersion": "2.2.0", "languages": ["c", "cpp", "objective-c", "objective-cpp", "swift"], + "name": "xtool", "version": "1.0"} + """) + try write(old, to: directory) + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx16.0") + try LSPConfig.ensure(target: target, sdkOptions: [], supportsBSP: true, quiet: true, currentDirectory: directory) + #expect(String(decoding: try read(LSPConfig.bsp.0, in: directory), as: UTF8.self).contains("arm64-apple-macosx16.0")) + } + @Test(arguments: [true, false]) func buildServerKeepsIOSDefaultAndPackageMinimum(bsp: Bool) async throws { let directory = try root() @@ -184,7 +213,7 @@ struct EditorConfigTests { if bsp { #expect(config.0 == LSPConfig.bsp.0) #expect(object["argv"] as? [String] == [ - "/usr/bin/env", "xtool", "dev", "build-server", "--triple", "arm64-apple-ios", + "/usr/bin/env", "xtool", "dev", "build-server", "--triple", "arm64-apple-ios14.0", ]) } else { #expect(config.0 == LSPConfig.legacyLSP.0) From c0ba7d02b98dd73dc3d6ecda6bb7c54f1d933787 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Wed, 14 Oct 2026 10:58:28 +0200 Subject: [PATCH 28/59] packer: pass per-product link options through the toolset linker Each direct product's toolset carried its rpaths, extension entry point and SDK `-platform_version` as Swift compiler options. Products share one scratch path, so the differing compile command lines made SwiftPM recompile every module for each product. Put those options in the toolset's `linker` section and keep only the deployment target in the compiler sections, so compile commands are identical across products. Omit empty toolset entries, which SwiftPM rejects. This alone does not remove recompilation for macOS products with resource bundles; the resource accessor overlay is handled next. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/PackLib/ProductBuilder.swift | 32 ++++++++++++++++++-------- Tests/XToolTests/BinaryLinkTests.swift | 6 +++-- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/Sources/PackLib/ProductBuilder.swift b/Sources/PackLib/ProductBuilder.swift index 2499421f..7f1e5bca 100644 --- a/Sources/PackLib/ProductBuilder.swift +++ b/Sources/PackLib/ProductBuilder.swift @@ -35,7 +35,7 @@ public struct ProductBuilder: Sendable { return false } if !libraries.isEmpty { try createWrapper(at: wrapper, root: root, products: libraries, sdkArguments: sdkArguments) } - let wrapperToolset = libraries.isEmpty ? [] : try toolsetOptions(name: "wrapper", swiftOptions: [], in: wrapper) + let wrapperToolset = libraries.isEmpty ? [] : try toolsetOptions(name: "wrapper", linkerOptions: [], in: wrapper) // Share one scratch tree so resources and dependency libraries remain // beside all products, including mixed direct and wrapped products. @@ -59,7 +59,8 @@ public struct ProductBuilder: Sendable { case .executable(let sourceName): package = root name = sourceName - options = try toolsetOptions(name: product.targetName, swiftOptions: product.linkerArguments + sdkArguments, + options = try toolsetOptions(name: product.targetName, + linkerOptions: Self.linkerOptions(product.linkerArguments + sdkArguments), in: wrapper) case .prebuilt: continue @@ -181,26 +182,37 @@ public struct ProductBuilder: Sendable { return directory.appendingPathComponent("lib\(name).a") } - private func toolsetOptions(name: String, swiftOptions: [String], in directory: URL) throws -> [String] { + private func toolsetOptions(name: String, linkerOptions: [String], in directory: URL) throws -> [String] { // SwiftPM forwards CLI -Xlinker options to host macro executables too. // SDK toolset options belong only to the target toolchain instead. // The SwiftPM backend compiles Darwin targets for each package's declared platform minimum // and ignores the version in `--triple`, so pass the plan's deployment target explicitly. + // Per-product options stay in the linker section: identical compiler options let products + // that share a scratch path reuse each other's compiled modules. let target = buildSettings.buildSystem == .swiftPM ? ["-target", plan.target.compilerTriple] : [] - guard !(target + swiftOptions).isEmpty else { return [] } + guard !(target + linkerOptions).isEmpty else { return [] } let toolsets = directory.appendingPathComponent("toolsets", isDirectory: true) try FileManager.default.createDirectory(at: toolsets, withIntermediateDirectories: true) let path = toolsets.appendingPathComponent("\(name).json") - let data = try JSONSerialization.data(withJSONObject: [ - "schemaVersion": "1.0", - "swiftCompiler": ["extraCLIOptions": target + swiftOptions], - "cCompiler": ["extraCLIOptions": target], - "cxxCompiler": ["extraCLIOptions": target], - ], options: [.sortedKeys]) + // SwiftPM rejects a toolset entry without options. + var toolset: [String: Any] = ["schemaVersion": "1.0"] + for (tool, options) in [("swiftCompiler", target), ("cCompiler", target), ("cxxCompiler", target), + ("linker", linkerOptions)] where !options.isEmpty { + toolset[tool] = ["extraCLIOptions": options] + } + let data = try JSONSerialization.data(withJSONObject: toolset, options: [.sortedKeys]) try data.write(to: path) return ["--toolset", path.path] } + /// Toolset linker options reach the linker directly; drop the compiler driver's `-Xlinker` prefixes. + static func linkerOptions(_ arguments: [String]) -> [String] { + stride(from: 0, to: arguments.count, by: 2).map { index in + precondition(arguments[index] == "-Xlinker" && index + 1 < arguments.count, "Expected -Xlinker pairs") + return arguments[index + 1] + } + } + private func validateProducts() throws { let names = plan.allProducts.compactMap { product -> String? in switch product.resolvedSource { diff --git a/Tests/XToolTests/BinaryLinkTests.swift b/Tests/XToolTests/BinaryLinkTests.swift index 1f2a81d4..7735d792 100644 --- a/Tests/XToolTests/BinaryLinkTests.swift +++ b/Tests/XToolTests/BinaryLinkTests.swift @@ -70,9 +70,11 @@ struct BinaryLinkTests { } let toolsetURL = root.appendingPathComponent("xtool/.xtool-tmp/toolsets/\(helper.targetName).json") let toolset = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: toolsetURL)) as? [String: Any]) + let linker = try #require(toolset["linker"] as? [String: Any]) + let arguments = try #require(linker["extraCLIOptions"] as? [String]) + let rpaths = arguments.indices.filter { arguments[$0] == "-rpath" }.map { arguments[$0 + 1] } let compiler = try #require(toolset["swiftCompiler"] as? [String: Any]) - let arguments = try #require(compiler["extraCLIOptions"] as? [String]) - let rpaths = arguments.indices.filter { arguments[$0] == "-rpath" }.map { arguments[$0 + 2] } + #expect(compiler["extraCLIOptions"] as? [String] == ["-target", target.compilerTriple]) #expect(rpaths == ["@executable_path/../Frameworks", "@executable_path/Frameworks"]) let output = root.appendingPathComponent("Relocated/App.app") From 0e98f8b5d2a8daf3dd78f923ca8fbf144dd15a8b Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Wed, 14 Oct 2026 11:00:09 +0200 Subject: [PATCH 29/59] packer: compile macOS resource accessors once for all products Each macOS product with SwiftPM resource bundles was built, then built again with its own `-vfsoverlay` for the adapted `Bundle.module` accessor. The overlay flag differed between products and between the two builds of a product, so every module was recompiled twice per product. Build all products first, then write one overlay covering the generated accessors of every built product and rebuild each product with that same overlay. In the relocation fixture (app, XPC service and helper sharing a resource library), the shared resource module now compiles twice per configuration instead of six times. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/PackLib/ProductBuilder.swift | 17 ++++++++++++----- Sources/PackLib/SwiftPMResourceAccessor.swift | 8 ++++---- .../SwiftPMResourceAccessorTests.swift | 8 ++++---- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/Sources/PackLib/ProductBuilder.swift b/Sources/PackLib/ProductBuilder.swift index 7f1e5bca..762cda11 100644 --- a/Sources/PackLib/ProductBuilder.swift +++ b/Sources/PackLib/ProductBuilder.swift @@ -44,6 +44,7 @@ public struct ProductBuilder: Sendable { } ? [] : ["--scratch-path", root.appendingPathComponent(".build").path] var directory: URL? var executables = prebuilt + var builds: [(settings: BuildSettings, arguments: [String])] = [] for product in plan.allProducts { if prebuilt[product.product] != nil { continue } let package: URL @@ -78,17 +79,23 @@ public struct ProductBuilder: Sendable { throw StringError("Build backend produced products in different resource directories") } directory = binDir - if let overlay = try SwiftPMResourceAccessor.overlay(for: product, in: binDir, output: wrapper) { - // SwiftPM regenerates accessors during planning. A compiler overlay adapts only - // generated target sources, without editing dependencies or racing that regeneration. - try await runner.build(productSettings, arguments + ["-Xswiftc", "-vfsoverlay", "-Xswiftc", overlay.path]) - } + builds.append((productSettings, arguments)) let executable = binDir.appendingPathComponent(name) guard FileManager.default.isExecutableFile(atPath: executable.path) else { throw StringError("Build backend did not produce executable '\(name)' at \(executable.path)") } executables[product.product] = executable } + let built = plan.allProducts.filter { prebuilt[$0.product] == nil } + if let directory, let overlay = try SwiftPMResourceAccessor.overlay(for: built, in: directory, output: wrapper) { + // SwiftPM regenerates accessors during planning. A compiler overlay adapts only + // generated target sources, without editing dependencies or racing that regeneration. + // Rebuild every product with the same overlay so shared modules compile once more, + // not once per product. + for build in builds { + try await runner.build(build.settings, build.arguments + ["-Xswiftc", "-vfsoverlay", "-Xswiftc", overlay.path]) + } + } let output = directory ?? root let staticTargets = try validateBinaryTargets(in: output) return BuildArtifacts(directory: output, executables: executables, staticBinaryTargets: staticTargets) diff --git a/Sources/PackLib/SwiftPMResourceAccessor.swift b/Sources/PackLib/SwiftPMResourceAccessor.swift index d5086c57..d61d1db9 100644 --- a/Sources/PackLib/SwiftPMResourceAccessor.swift +++ b/Sources/PackLib/SwiftPMResourceAccessor.swift @@ -4,9 +4,9 @@ import XUtils /// Native SwiftPM generates bundle-root lookup, which requires unsigned root aliases on macOS. /// Compile an adapted view of those generated sources; keep package sources and build fallbacks intact. enum SwiftPMResourceAccessor { - static func overlay(for product: Plan.Product, in directory: URL, output: URL) throws -> URL? { - guard product.platform == .macOS else { return nil } - let names = Set(product.resources.compactMap { resource -> String? in + /// One overlay covers every product: a resource target can live in a library that several products share. + static func overlay(for products: [Plan.Product], in directory: URL, output: URL) throws -> URL? { + let names = Set(products.filter { $0.platform == .macOS }.flatMap(\.resources).compactMap { resource -> String? in guard case .bundle(let package, let target) = resource else { return nil } return "\(package)_\(target).bundle" }) @@ -36,7 +36,7 @@ enum SwiftPMResourceAccessor { + names.subtracting(matched).sorted().joined(separator: ", ") + "; macOS bundles currently require Swift resource targets") } - let destination = output.appendingPathComponent("resource-accessors/\(product.targetName)") + let destination = output.appendingPathComponent("resource-accessors") try manager.createDirectory(at: destination, withIntermediateDirectories: true) var roots: [[String: String]] = [] for (index, file) in files.enumerated() { diff --git a/Tests/XToolTests/SwiftPMResourceAccessorTests.swift b/Tests/XToolTests/SwiftPMResourceAccessorTests.swift index ea98038c..5d653fd4 100644 --- a/Tests/XToolTests/SwiftPMResourceAccessorTests.swift +++ b/Tests/XToolTests/SwiftPMResourceAccessorTests.swift @@ -52,7 +52,7 @@ struct SwiftPMResourceAccessorTests { try manager.createDirectory(at: source.deletingLastPathComponent(), withIntermediateDirectories: true) try Data(generatedSource.utf8).write(to: source) } - let prepared = try SwiftPMResourceAccessor.overlay(for: product(), in: root, output: root.appendingPathComponent("owned")) + let prepared = try SwiftPMResourceAccessor.overlay(for: [product()], in: root, output: root.appendingPathComponent("owned")) let overlay = try #require(prepared) let json = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: overlay)) as? [String: Any]) #expect(json["use-external-names"] as? Bool == false) @@ -66,13 +66,13 @@ struct SwiftPMResourceAccessorTests { } // Repeated preparation stays deterministic and supports incremental/retry builds. let first = try Data(contentsOf: overlay) - _ = try SwiftPMResourceAccessor.overlay(for: product(), in: root, output: root.appendingPathComponent("owned")) + _ = try SwiftPMResourceAccessor.overlay(for: [product()], in: root, output: root.appendingPathComponent("owned")) #expect(try Data(contentsOf: overlay) == first) } @Test func iosKeepsNativeAccessors() throws { let missing = URL(fileURLWithPath: "/does-not-exist") - #expect(try SwiftPMResourceAccessor.overlay(for: product(platform: "ios"), in: missing, output: missing) == nil) + #expect(try SwiftPMResourceAccessor.overlay(for: [product(platform: "ios")], in: missing, output: missing) == nil) } @Test func missingResourceAccessorRejectsBeforeWriting() throws { @@ -80,7 +80,7 @@ struct SwiftPMResourceAccessorTests { defer { try? FileManager.default.removeItem(at: root) } try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) #expect(throws: (any Error).self) { - try SwiftPMResourceAccessor.overlay(for: product(), in: root, output: root.appendingPathComponent("owned")) + try SwiftPMResourceAccessor.overlay(for: [product()], in: root, output: root.appendingPathComponent("owned")) } #expect(try FileManager.default.contentsOfDirectory(atPath: root.path).isEmpty) } From b58060e07144ae97b475340b362c2a17966f7891 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Wed, 14 Oct 2026 13:55:40 +0200 Subject: [PATCH 30/59] dev: let dev run --help reach the argument parser DevRunCommand captured app arguments with .captureForPassthrough and rejected anything not preceded by `--` in validate(). The capture also swallowed --help, so `xtool dev run --help` failed with "Unknown argument '--help'" (exit 64). Collect app arguments with .postTerminator instead. ArgumentParser now handles help, reports typos itself ("Did you mean '--configuration'?"), and only values after `--` reach the app, so validate() and the dropFirst() are gone. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/XToolSupport/DevCommand.swift | 15 +++------------ Tests/XToolTests/DevCommandTests.swift | 3 +++ 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/Sources/XToolSupport/DevCommand.swift b/Sources/XToolSupport/DevCommand.swift index 43ea57c5..8798f835 100644 --- a/Sources/XToolSupport/DevCommand.swift +++ b/Sources/XToolSupport/DevCommand.swift @@ -243,17 +243,8 @@ struct DevRunCommand: AsyncParsableCommand { @Option(help: "Run an existing macOS .app without building or signing it") var app: String? - @Argument(parsing: .captureForPassthrough, help: "Arguments passed to the macOS app after --") - var arguments: [String] = [] - - var appArguments: [String] { Array(arguments.dropFirst()) } - - func validate() throws { - // Passthrough capture also swallows unknown options and words; only accept them after `--`. - if let first = arguments.first, first != "--" { - throw ValidationError("Unknown argument '\(first)'. Pass app arguments after '--'.") - } - } + @Argument(parsing: .postTerminator, help: "Arguments passed to the macOS app after --") + var appArguments: [String] = [] #if os(macOS) @Flag( @@ -297,7 +288,7 @@ struct DevRunCommand: AsyncParsableCommand { try await MacRunOperation.run(bundle: output.bundle, arguments: appArguments) return } - guard arguments.isEmpty else { throw Console.Error("App arguments currently require a macOS target") } + guard appArguments.isEmpty else { throw Console.Error("App arguments currently require a macOS target") } @Dependency(\.devCommandClient) var client let output = try await client.legacyPack(simulatorTriple ?? request.triple, packOptions.configuration, false) diff --git a/Tests/XToolTests/DevCommandTests.swift b/Tests/XToolTests/DevCommandTests.swift index cc5907a8..6524a56d 100644 --- a/Tests/XToolTests/DevCommandTests.swift +++ b/Tests/XToolTests/DevCommandTests.swift @@ -81,9 +81,12 @@ struct DevCommandTests { @Test func runAcceptsTripleAndXcodeStillRejectsIt() throws { #expect(try DevRunCommand.parse(["--triple", "arm64-apple-macosx"]).triple == "arm64-apple-macosx") #expect(try DevRunCommand.parse(["--", "--cofiguration", "x"]).appArguments == ["--cofiguration", "x"]) + #expect(try DevRunCommand.parse(["--"]).appArguments.isEmpty) for typo in [["--cofiguration", "release"], ["biuld"], ["--app", "App.app", "--cofiguration", "release"]] { #expect(throws: (any Error).self) { try DevRunCommand.parse(typo) } } + let help = #expect(throws: (any Error).self) { try DevRunCommand.parse(["--help"]) } + #expect(help.map { DevRunCommand.exitCode(for: $0) } == .success) #expect(throws: (any Error).self) { try DevXcodeCommand.parse(["--triple", "arm64-apple-macosx"]) } From adc753f6e085ecdadb67d78c6bde9c25364acbcf Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Wed, 14 Oct 2026 14:31:38 +0200 Subject: [PATCH 31/59] test: stub the Mac host explicitly in the macOS run rejection test macOSRunAndXcodeRejectBeforeEffects relied on MacRunClient's implicit testValue (isMacOS: false) to make requireHost() throw. Resolving an implicit test value goes through swift-dependencies' CachedValues, which calls swift-issue-reporting's TestContext.current. With the swift.org Swift 6.4.0 macOS toolchain (Swift Testing library 1902; swift-dependencies 1.17.1 resolves swift-issue-reporting 2.1.2) that call faults with SIGBUS and takes down the whole test process. Xcode 27's Swift 6.4 (swiftlang-6.4.0.34.1, Swift Testing library 2084), which CI uses, does not crash. Reported as pointfreeco/swift-dependencies#474. Override macRunClient with .testValue so the simulated host is explicit and no implicit lookup happens. Behaviour under test is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- Tests/XToolTests/DevCommandTests.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Tests/XToolTests/DevCommandTests.swift b/Tests/XToolTests/DevCommandTests.swift index 6524a56d..8be405c9 100644 --- a/Tests/XToolTests/DevCommandTests.swift +++ b/Tests/XToolTests/DevCommandTests.swift @@ -117,6 +117,9 @@ struct DevCommandTests { try await withDependencies { $0.devCommandClient = effects $0.keyValueStorage = ForbiddenAuthStorage() + // A non-Mac host, explicitly: resolving the implicit test value crashes with the + // swift.org Swift 6.4.0 macOS toolchain (not Xcode 27); pointfreeco/swift-dependencies#474. + $0.macRunClient = .testValue } operation: { try await DevRunCommand.parse([]).run() } From 47cb4d3b7b4bf4f73dc4b6c77b785c1abc8a23e8 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Wed, 14 Oct 2026 16:01:57 +0200 Subject: [PATCH 32/59] ci: run the macOS bats suite on the macOS host macos.bats only ran in the integration job, inside Linux Docker, so the CLI was never exercised end to end on a Mac: the implicit Darwin SDK, the toolset -target, and verify on the host that builds. Run the same suite in build-macos after the unit tests, reusing its xtool build. bats-core, bats-support and bats-assert are cloned at pinned release tags; llvm-objdump comes from the selected Xcode via xcrun. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/build.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8aa9b1f6..6065ed9a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,6 +38,16 @@ jobs: - name: Run tests run: | swift test + - name: Run macOS integration tests + run: | + for lib in bats-core@v1.14.0 bats-support@v0.3.0 bats-assert@v2.2.4; do + git -c advice.detachedHead=false clone -q --depth 1 --branch "${lib#*@}" "https://github.com/bats-core/${lib%@*}" ".build/bats/${lib%@*}" + done + mkdir -p .build/integration-bin + ln -fs "$PWD/.build/debug/xtool" .build/integration-bin/xtool + ln -fs "$(xcrun -f llvm-objdump)" .build/integration-bin/llvm-objdump + PATH="$PWD/.build/integration-bin:$PATH" BATS_LIB_PATH="$PWD/.build/bats" \ + .build/bats/bats-core/bin/bats integration-tests/suite/macos.bats build-ios: runs-on: xcode-27 steps: From cc9b2111ace8d109f89f59813c17f12393aa4f2e Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Wed, 14 Oct 2026 16:21:04 +0200 Subject: [PATCH 33/59] docs: describe macOS signing and notarization in the macOS guide MacOS.md was written before `dev sign` and `dev notarize` landed and still listed notarization and Developer ID signing as out of scope. Point the guide at both commands and narrow the boundary to what remains excluded: certificate creation, identity selection and provisioning-dependent entitlements. Co-Authored-By: Claude Opus 5.5 (1M context) --- Documentation/xtool.docc/MacOS.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Documentation/xtool.docc/MacOS.md b/Documentation/xtool.docc/MacOS.md index 6f6095ad..0ebd740e 100644 --- a/Documentation/xtool.docc/MacOS.md +++ b/Documentation/xtool.docc/MacOS.md @@ -36,9 +36,11 @@ their entitlement associations. It records signing intent, not release approval. This route performs no Apple account provisioning or identity signing. Linker ad-hoc signatures may already be present. `--sign`, IPA output and Xcode project generation are unavailable for macOS targets. `xtool dev run` builds -and launches a local Mac app; `--app` launches an existing app without rebuilding. A downstream -Mac signer must sign nested code before the outer app and validate the result. -The generic handoff does not select certificates or publish releases. +and launches a local Mac app; `--app` launches an existing app without rebuilding. On a Mac, +`xtool dev sign` signs the handoff into a new directory (ad-hoc, Apple Development or +Developer ID, with an explicit certificate fingerprint) and `xtool dev notarize` submits +Developer ID output with an existing `notarytool` keychain profile. The handoff does not +select certificates or publish releases. ## Tests @@ -52,8 +54,8 @@ installed Darwin SDK. ## Boundaries This support excludes native execution of macOS applications on Linux, an asset -compiler replacement, release publishing, notarization, universal binary +compiler replacement, release publishing, universal binary production and full Xcode project ingestion. Supply prepared ICNS/PNG, `Assets.car`, storyboards or nibs where needed; raw asset catalogs, iconsets, storyboards -and xibs are rejected by assembly. Signing with Developer ID, Gatekeeper -acceptance and application-specific entitlements require separate Mac work. +and xibs are rejected by assembly. Certificate creation, identity selection and +provisioning-dependent entitlements remain separate work. From f3907f972ee2b9c17284388645575d29b3b428ae Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Wed, 14 Oct 2026 21:07:27 +0200 Subject: [PATCH 34/59] packer: compile each package for its own deployment target 48441b6 passed `-target ` to the Swift, C and C++ compilers through the SwiftPM toolset. A toolset applies to every module in the build graph, so dependencies were compiled for the app's deployment target instead of their own platform minimum. That breaks packages whose backports are obsoleted on newer releases: building a macOS 15.0 app that depends on swift-perception failed in PerceptionCore with "'Bindable' is unavailable in macOS" on its own conformance extensions. `swift build` and Xcode compile each package for its own declared platforms. Drop the compiler sections; the toolset now carries only the per-product linker options. Binaries still record the planned deployment target through `-platform_version`, so `minos` is unchanged. A package whose own code needs newer APIs declares `platforms`, as it must for `swift build`. The native fixture now declares macOS 10.15 and ships a swift-perception-style backport obsoleted in macOS 14; it fails to compile with the previous toolset and passes now. A swift-perception app built with `xtool dev build --configuration debug` fails at 344c06c (8 unavailability errors) and succeeds here with `minos 15.0`. Co-Authored-By: Claude Opus 5.5 (1M context) --- Documentation/xtool.docc/MacOS.md | 4 +++- Sources/PackLib/ProductBuilder.swift | 21 +++++++-------------- Tests/XToolTests/BinaryLinkTests.swift | 4 ++-- Tests/XToolTests/ProductBuilderTests.swift | 14 +++++++++----- 4 files changed, 21 insertions(+), 22 deletions(-) diff --git a/Documentation/xtool.docc/MacOS.md b/Documentation/xtool.docc/MacOS.md index 0ebd740e..2a19be54 100644 --- a/Documentation/xtool.docc/MacOS.md +++ b/Documentation/xtool.docc/MacOS.md @@ -20,7 +20,9 @@ Omitting both retains the existing `arm64-apple-ios` default, even on a Mac. The deployment version comes from the selected triple, then the schema's `deploymentTarget`, then the package's platform minimum, then the platform minimum (macOS 15.0). A selected version below any explicit schema/package -minimum or the platform minimum is rejected. +minimum or the platform minimum is rejected. The deployment version is recorded in the +products' binaries; each package's code still compiles for its own declared platforms, as with +`swift build`, so declare `platforms` to use newer APIs without availability checks. The parser accepts arm64 and x86_64 macOS triples. Linux macOS Debug builds are rejected because SwiftPM requires unavailable `codesign` support. Use Release. SwiftBuild is rejected diff --git a/Sources/PackLib/ProductBuilder.swift b/Sources/PackLib/ProductBuilder.swift index 762cda11..481f53ac 100644 --- a/Sources/PackLib/ProductBuilder.swift +++ b/Sources/PackLib/ProductBuilder.swift @@ -35,7 +35,6 @@ public struct ProductBuilder: Sendable { return false } if !libraries.isEmpty { try createWrapper(at: wrapper, root: root, products: libraries, sdkArguments: sdkArguments) } - let wrapperToolset = libraries.isEmpty ? [] : try toolsetOptions(name: "wrapper", linkerOptions: [], in: wrapper) // Share one scratch tree so resources and dependency libraries remain // beside all products, including mixed direct and wrapped products. @@ -56,7 +55,7 @@ public struct ProductBuilder: Sendable { name = product.targetName // Planning already resolved root dependencies; avoid dropping // its development-only dependencies from Package.resolved. - options = ["--disable-automatic-resolution"] + wrapperToolset + options = ["--disable-automatic-resolution"] case .executable(let sourceName): package = root name = sourceName @@ -192,21 +191,15 @@ public struct ProductBuilder: Sendable { private func toolsetOptions(name: String, linkerOptions: [String], in directory: URL) throws -> [String] { // SwiftPM forwards CLI -Xlinker options to host macro executables too. // SDK toolset options belong only to the target toolchain instead. - // The SwiftPM backend compiles Darwin targets for each package's declared platform minimum - // and ignores the version in `--triple`, so pass the plan's deployment target explicitly. - // Per-product options stay in the linker section: identical compiler options let products - // that share a scratch path reuse each other's compiled modules. - let target = buildSettings.buildSystem == .swiftPM ? ["-target", plan.target.compilerTriple] : [] - guard !(target + linkerOptions).isEmpty else { return [] } + // Compiler options are deliberately absent: a toolset applies to every module, and each + // package must compile for its own platform minimum, as with `swift build` and Xcode. + // Dependencies such as swift-perception declare backports `obsoleted:` at newer releases. + // The product's deployment target reaches the binary through `-platform_version` here. + guard !linkerOptions.isEmpty else { return [] } let toolsets = directory.appendingPathComponent("toolsets", isDirectory: true) try FileManager.default.createDirectory(at: toolsets, withIntermediateDirectories: true) let path = toolsets.appendingPathComponent("\(name).json") - // SwiftPM rejects a toolset entry without options. - var toolset: [String: Any] = ["schemaVersion": "1.0"] - for (tool, options) in [("swiftCompiler", target), ("cCompiler", target), ("cxxCompiler", target), - ("linker", linkerOptions)] where !options.isEmpty { - toolset[tool] = ["extraCLIOptions": options] - } + let toolset: [String: Any] = ["schemaVersion": "1.0", "linker": ["extraCLIOptions": linkerOptions]] let data = try JSONSerialization.data(withJSONObject: toolset, options: [.sortedKeys]) try data.write(to: path) return ["--toolset", path.path] diff --git a/Tests/XToolTests/BinaryLinkTests.swift b/Tests/XToolTests/BinaryLinkTests.swift index 7735d792..f0924373 100644 --- a/Tests/XToolTests/BinaryLinkTests.swift +++ b/Tests/XToolTests/BinaryLinkTests.swift @@ -73,8 +73,8 @@ struct BinaryLinkTests { let linker = try #require(toolset["linker"] as? [String: Any]) let arguments = try #require(linker["extraCLIOptions"] as? [String]) let rpaths = arguments.indices.filter { arguments[$0] == "-rpath" }.map { arguments[$0 + 1] } - let compiler = try #require(toolset["swiftCompiler"] as? [String: Any]) - #expect(compiler["extraCLIOptions"] as? [String] == ["-target", target.compilerTriple]) + // Compiler options would apply to every package; deployment reaches only the link. + #expect(Set(toolset.keys) == ["schemaVersion", "linker"]) #expect(rpaths == ["@executable_path/../Frameworks", "@executable_path/Frameworks"]) let output = root.appendingPathComponent("Relocated/App.app") diff --git a/Tests/XToolTests/ProductBuilderTests.swift b/Tests/XToolTests/ProductBuilderTests.swift index 006df6c4..80f19f36 100644 --- a/Tests/XToolTests/ProductBuilderTests.swift +++ b/Tests/XToolTests/ProductBuilderTests.swift @@ -192,6 +192,7 @@ struct ProductBuilderTests { import PackageDescription let package = Package( name: "BuilderFixture", + platforms: [.macOS(.v10_15)], products: [.executable(name: "DirectExecutable", targets: ["Direct"]), .library(name: "HelperLibrary", targets: ["Helper", "Gate"])], targets: [.executableTarget(name: "Direct", resources: [.copy("message.txt")]), @@ -203,13 +204,16 @@ struct ProductBuilderTests { let helper = root.appendingPathComponent("Sources/Helper") try FileManager.default.createDirectory(at: direct, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: helper.appendingPathComponent("include"), withIntermediateDirectories: true) - // No declared platforms: both build paths must compile for the plan's macOS 15.0 deployment - // target, or the macOS 15-only Mutex fails availability checking. - try Data("import AppKit\nimport Synchronization\nlet lock = Mutex(0)\nprint(NSApplication.self, lock.withLock { $0 })".utf8) - .write(to: direct.appendingPathComponent("main.swift")) + try Data("import AppKit\nprint(NSApplication.self)".utf8).write(to: direct.appendingPathComponent("main.swift")) + // Modules compile for their package's own minimum (macOS 10.15), not the plan's 15.0: + // like swift-perception's Bindable, this backport's extension is unavailable from macOS 14. let gate = root.appendingPathComponent("Sources/Gate") try FileManager.default.createDirectory(at: gate, withIntermediateDirectories: true) - try Data("import Synchronization\npublic let gate = Mutex(0)".utf8).write(to: gate.appendingPathComponent("Gate.swift")) + try Data(""" + @available(macOS, introduced: 10.15, obsoleted: 14) + public struct Backport {} + extension Backport: Equatable {} + """.utf8).write(to: gate.appendingPathComponent("Gate.swift")) try Data("resource fixture".utf8).write(to: direct.appendingPathComponent("message.txt")) try Data("#include \nint main() { std::vector values(3, 1); return values.at(1); }".utf8) .write(to: helper.appendingPathComponent("Entry.cpp")) From 11429701cc11f5f8b2e3f626b42d1edcb6bb3961 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Thu, 15 Oct 2026 01:56:56 +0200 Subject: [PATCH 35/59] packer: keep raw assets in macOS SwiftPM resource bundles macOS assembly rejected any SwiftPM resource bundle containing a raw asset catalog, iconset, storyboard or XIB. SwiftPM's native build copies those into resource bundles uncompiled, so an app could not be packaged whenever a dependency shipped one: CodexBar fails on Vortex's Assets.xcassets, a bundle the app author cannot change. Copy SwiftPM resource bundles as built on macOS too, matching iOS (6f7d68e) and `swift build`. Inputs supplied through xtool.yml are still rejected. Co-Authored-By: Claude Opus 5.5 (1M context) --- Documentation/xtool.docc/Configuration.md | 3 ++- Documentation/xtool.docc/MacOS.md | 3 ++- Sources/PackLib/BundleAssembler.swift | 4 ++-- Tests/XToolTests/BundleAssemblerTests.swift | 12 +++++------- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Documentation/xtool.docc/Configuration.md b/Documentation/xtool.docc/Configuration.md index 37928f7d..bcd10055 100644 --- a/Documentation/xtool.docc/Configuration.md +++ b/Documentation/xtool.docc/Configuration.md @@ -118,7 +118,8 @@ Source-built macOS SwiftPM resource accessors are adapted for relocated bundles; separate relocation tests exercise the adapted `Bundle.module` accessor after original build inputs are removed. Prepared assets are copied without invoking `actool` or `ibtool`. Raw -`.xcassets`, `.iconset`, `.storyboard` and `.xib` inputs require preparation elsewhere. +`.xcassets`, `.iconset`, `.storyboard` and `.xib` inputs require preparation elsewhere; +SwiftPM resource bundles are copied as built, with any raw assets left uncompiled. Caller plist values override generated defaults, including identifiers. Ensure `CFBundleExecutable`, bundle kind and minimum OS agree with the actual product; diff --git a/Documentation/xtool.docc/MacOS.md b/Documentation/xtool.docc/MacOS.md index 2a19be54..2ee89f76 100644 --- a/Documentation/xtool.docc/MacOS.md +++ b/Documentation/xtool.docc/MacOS.md @@ -59,5 +59,6 @@ This support excludes native execution of macOS applications on Linux, an asset compiler replacement, release publishing, universal binary production and full Xcode project ingestion. Supply prepared ICNS/PNG, `Assets.car`, storyboards or nibs where needed; raw asset catalogs, iconsets, storyboards -and xibs are rejected by assembly. Certificate creation, identity selection and +and xibs you supply are rejected by assembly. SwiftPM resource bundles are copied as +built, so raw assets inside them stay uncompiled. Certificate creation, identity selection and provisioning-dependent entitlements remain separate work. diff --git a/Sources/PackLib/BundleAssembler.swift b/Sources/PackLib/BundleAssembler.swift index 0838ed39..31bfdb1e 100644 --- a/Sources/PackLib/BundleAssembler.swift +++ b/Sources/PackLib/BundleAssembler.swift @@ -41,7 +41,7 @@ public struct BundleAssembler: Sendable { private struct Operation: Hashable { var path: String var payload: Payload - /// iOS has always copied SwiftPM resource bundles as built, including uncompiled assets. + /// SwiftPM resource bundles are copied as built, including the assets SwiftPM leaves uncompiled. var allowsRawAssets = false } @@ -93,7 +93,7 @@ public struct BundleAssembler: Sendable { let name = "\(package)_\(target).bundle" let path = joined(layout.resourcesPath, name) appendDependency(.init(path: path, payload: .copy(artifacts.directory.appendingPathComponent(name)), - allowsRawAssets: product.platform != .macOS), + allowsRawAssets: true), product: product, to: &assembly) if product.platform == .macOS && product.type == .helperExecutable { // Bare helpers retain SwiftPM's executable-directory lookup. Real bundles use diff --git a/Tests/XToolTests/BundleAssemblerTests.swift b/Tests/XToolTests/BundleAssemblerTests.swift index bf40d0f3..a8d75b76 100644 --- a/Tests/XToolTests/BundleAssemblerTests.swift +++ b/Tests/XToolTests/BundleAssemblerTests.swift @@ -373,7 +373,7 @@ struct BundleAssemblerTests { } @Test(arguments: ["arm64-apple-ios17.0", "arm64-apple-macosx15.0"]) - func swiftPMBundlesKeepRawAssetsOnlyOnIOS(triple: String) throws { + func swiftPMBundlesKeepRawAssets(triple: String) throws { let root = try root() defer { try? FileManager.default.removeItem(at: root) } let target = try ResolvedTarget.resolve(cliTarget: triple) @@ -387,12 +387,10 @@ struct BundleAssemblerTests { try write(at: bundle.appendingPathComponent("View.xib")) let output = root.appendingPathComponent("App.app") let assemble = { try BundleAssembler(plan: plan, sourceDirectory: root).assemble(artifacts: artifacts, at: output) } - if target.platform == .macOS { - #expect(throws: (any Error).self) { try assemble() } - } else { - try assemble() - #expect(FileManager.default.fileExists(atPath: output.appendingPathComponent("Package_Target.bundle/View.xib").path)) - } + try assemble() + let resources = target.platform == .macOS ? "Contents/Resources/" : "" + #expect(FileManager.default.fileExists( + atPath: output.appendingPathComponent("\(resources)Package_Target.bundle/View.xib").path)) } @Test func iosLayoutAndLegacyRootResourcesRemainFlat() throws { From 1508a727e9dec1d0ab483423ec60cc438d36bda4 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Fri, 16 Oct 2026 03:55:18 +0200 Subject: [PATCH 36/59] packer: keep the requested triple for SwiftBuild builds 9f36e62 made every build pass `--triple `, which always carries a version. SwiftBuild turns a versioned triple into the deployment target of every package, so with Swift 6.4, where iOS builds default to SwiftBuild, dependencies compiled for the app's minimum instead of their own. A dependency with backports obsoleted at that release then fails to compile, as e7a5f52 describes for the SwiftPM backend. Keep the triple selected at initialization, as on main. The wrapper package's `platforms` already carries the plan's deployment target. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/PackLib/BuildSettings.swift | 6 ++++-- Tests/XToolTests/ProductBuilderTests.swift | 12 ++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/Sources/PackLib/BuildSettings.swift b/Sources/PackLib/BuildSettings.swift index 5d00d0f3..b2b200f7 100644 --- a/Sources/PackLib/BuildSettings.swift +++ b/Sources/PackLib/BuildSettings.swift @@ -140,8 +140,10 @@ public struct BuildSettings: Sendable { copy.sdkOptions = ["--swift-sdk", Self.sdkIdentity(for: try TargetTriple(triple)), "--triple", target.compilerTriple] case .swiftBuild: - // Retain the backend-specific toolset selected by the initializer. - copy.sdkOptions = ["--triple", target.compilerTriple] + sdkOptions.dropFirst(2) + // Keep the initializer's triple and toolset. SwiftBuild applies a versioned triple's + // deployment target to every package, so dependencies would compile for the app's + // minimum instead of their own; the wrapper package's `platforms` carries the plan's. + break } return copy } diff --git a/Tests/XToolTests/ProductBuilderTests.swift b/Tests/XToolTests/ProductBuilderTests.swift index 80f19f36..45008396 100644 --- a/Tests/XToolTests/ProductBuilderTests.swift +++ b/Tests/XToolTests/ProductBuilderTests.swift @@ -65,6 +65,18 @@ struct ProductBuilderTests { #expect(resolved.configuration == .debug) } + @Test(arguments: [("arm64-apple-ios", "arm64-apple-ios17.2"), ("arm64-apple-ios18.0", "arm64-apple-ios18.0")]) + func swiftBuildKeepsTheRequestedTriple(triple: String, planned: String) async throws { + // A versioned triple makes SwiftBuild compile every package for that deployment target; + // dependencies must keep their own minimum, so the plan's version stays out of `--triple`. + let target = try ResolvedTarget.resolve(cliTarget: triple, packageDeploymentTargets: ["ios": "17.2"]) + let settings = try await BuildSettings(configuration: .release, triple: target.triple, buildSystem: .swiftBuild) + let resolved = try settings.withTarget(target) + #expect(target.compilerTriple == planned) + #expect(resolved.sdkOptions == settings.sdkOptions) + #expect(Array(resolved.sdkOptions.prefix(2)) == ["--triple", triple]) + } + @Test(arguments: [BuildConfiguration.debug, .release]) func directExecutablesUseBackendPaths(configuration: BuildConfiguration) async throws { let root = try temporaryRoot() From 88a2cd2cb0412672b77e4a059437d74f80641643 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Fri, 16 Oct 2026 15:05:53 +0200 Subject: [PATCH 37/59] packer: lower the macOS deployment floor to 13.0 The 15.0 floor had no technical basis and raised packages that declare an older minimum: CodexBar (14.0) then compiled at 15.0, where APIs it uses are unavailable. AeroSpace (13.0) and CodexBar (14.0) build at their declared minimums with this floor, and their binaries record those as minos. Co-Authored-By: Claude Opus 5.5 (1M context) --- Documentation/xtool.docc/Control.md | 2 +- Documentation/xtool.docc/MacOS.md | 2 +- Sources/PackLib/TargetPlatform.swift | 2 +- Tests/XToolTests/PackContractTests.swift | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Documentation/xtool.docc/Control.md b/Documentation/xtool.docc/Control.md index baba42ae..afea6aae 100644 --- a/Documentation/xtool.docc/Control.md +++ b/Documentation/xtool.docc/Control.md @@ -28,7 +28,7 @@ deploymentTarget: "15.0" `xtool dev build --triple` and `xtool dev build-server --triple` override `target` in `xtool.yml`. When neither is set, both use `arm64-apple-ios`. macOS targets support `arm64` and `x86_64`; `macos` is accepted as an alias for `macosx`. Build macOS apps with `xtool dev build --defer-signing` and sign the resulting app later on a Mac. On Linux, use `--configuration release`; macOS Debug builds require signing support available on macOS. -The selected triple's OS version takes precedence over `deploymentTarget`, then the package's matching platform minimum, then xtool's platform minimum (iOS 13.0 or macOS 15.0). A package minimum below xtool's platform minimum is raised to it. An explicit version cannot lower the schema, package, or platform minimum. For example, `arm64-apple-macosx16.0` selects macOS 16.0 while the SDK identity remains `arm64-apple-macosx`. +The selected triple's OS version takes precedence over `deploymentTarget`, then the package's matching platform minimum, then xtool's platform minimum (iOS 13.0 or macOS 13.0). A package minimum below xtool's platform minimum is raised to it. An explicit version cannot lower the schema, package, or platform minimum. For example, `arm64-apple-macosx16.0` selects macOS 16.0 while the SDK identity remains `arm64-apple-macosx`. Editor support uses the same target resolution and package minimum as builds. Swift 6.4 and later use `.bsp/xtool.json`, whose build-server command pins the effective compiler triple (for example `arm64-apple-macosx15.0`), so starting the build server does not inspect the package. Earlier toolchains use `.sourcekit-lsp/config.json` with separate Swift SDK and compiler triple settings. Restart the editor after changing these files. xtool refreshes or migrates only configurations matching its complete generated content; customized files are preserved. Set `skipLSP: true` to disable automatic editor configuration. diff --git a/Documentation/xtool.docc/MacOS.md b/Documentation/xtool.docc/MacOS.md index 2ee89f76..ee09d29f 100644 --- a/Documentation/xtool.docc/MacOS.md +++ b/Documentation/xtool.docc/MacOS.md @@ -19,7 +19,7 @@ The optional CLI `--triple arm64-apple-macosx15.0` overrides the schema target. Omitting both retains the existing `arm64-apple-ios` default, even on a Mac. The deployment version comes from the selected triple, then the schema's `deploymentTarget`, then the package's platform minimum, then the platform -minimum (macOS 15.0). A selected version below any explicit schema/package +minimum (macOS 13.0). A selected version below any explicit schema/package minimum or the platform minimum is rejected. The deployment version is recorded in the products' binaries; each package's code still compiles for its own declared platforms, as with `swift build`, so declare `platforms` to use newer APIs without availability checks. diff --git a/Sources/PackLib/TargetPlatform.swift b/Sources/PackLib/TargetPlatform.swift index fa06ae48..3b180c98 100644 --- a/Sources/PackLib/TargetPlatform.swift +++ b/Sources/PackLib/TargetPlatform.swift @@ -10,7 +10,7 @@ public enum TargetPlatform: String, Codable, Sendable, CaseIterable { public var minimumDeploymentTarget: String { switch self { case .iOS, .iOSSimulator: "13.0" - case .macOS: "15.0" + case .macOS: "13.0" } } diff --git a/Tests/XToolTests/PackContractTests.swift b/Tests/XToolTests/PackContractTests.swift index ff064f54..bc26c550 100644 --- a/Tests/XToolTests/PackContractTests.swift +++ b/Tests/XToolTests/PackContractTests.swift @@ -52,8 +52,9 @@ struct PackContractTests { // Packages declaring an older minimum than xtool supports, such as `.iOS(.v12)`, still build. #expect(try ResolvedTarget.resolve(packageDeploymentTargets: ["ios": "12.0"]).deploymentTarget == "13.0") #expect(try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx", - packageDeploymentTargets: ["macos": "13.0"]).deploymentTarget == "15.0") + packageDeploymentTargets: ["macos": "12.0"]).deploymentTarget == "13.0") #expect(throws: (any Error).self) { try ResolvedTarget.resolve(cliTarget: "arm64-apple-ios12.0") } + #expect(throws: (any Error).self) { try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx12.0") } #expect(throws: (any Error).self) { try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx14.0", schema: config) } From 912d12ef98ec50937a3720c8da97a94e9fba7242 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Sat, 17 Oct 2026 15:02:08 +0200 Subject: [PATCH 38/59] test: expect the requested triple for SwiftBuild build servers 1f58f77 keeps the unversioned triple for SwiftBuild so packages compile at their own minimums; the editor's build server inherits that. The test still expected the versioned triple and failed on Swift 6.4, where SwiftBuild is selected. The xtool build-server command keeps pinning the versioned triple. Co-Authored-By: Claude Opus 5.5 (1M context) --- Tests/XToolTests/EditorConfigTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Tests/XToolTests/EditorConfigTests.swift b/Tests/XToolTests/EditorConfigTests.swift index d028dca3..ca33d06a 100644 --- a/Tests/XToolTests/EditorConfigTests.swift +++ b/Tests/XToolTests/EditorConfigTests.swift @@ -207,7 +207,9 @@ struct EditorConfigTests { let (settings, target) = try await EditorTarget.buildSettings(request: request) #expect(request.triple == ResolvedTarget.defaultTriple) #expect(target.compilerTriple == "arm64-apple-ios14.0") - #expect(settings.buildServerArguments.contains("arm64-apple-ios14.0")) + // SwiftBuild keeps the unversioned triple so packages index at their own minimums, as they build. + let indexTriple = settings.buildSystem == .swiftBuild ? "arm64-apple-ios" : "arm64-apple-ios14.0" + #expect(settings.buildServerArguments.suffix(2) == ["--triple", indexTriple]) let config = try LSPConfig.configuration(target: target, sdkOptions: settings.sdkOptions, bsp: bsp) let object = try #require(JSONSerialization.jsonObject(with: Data(config.1.utf8)) as? [String: Any]) if bsp { From 1d39d20df8c65116ff32b192fb427538623b7aeb Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Sat, 17 Oct 2026 01:34:41 +0200 Subject: [PATCH 39/59] packer: build macOS resource accessors in a single pass Products with macOS SwiftPM resource bundles were built twice per invocation: once plainly, then again with `-vfsoverlay` for the adapted `Bundle.module` accessor. The first pass of every invocation undid the flags of the previous invocation's last pass, so even a no-op rebuild recompiled every module twice. Pass the overlay from the first build on. Its path never changes, and before building it covers the accessors the previous build generated. After the build, any accessor whose adaptation changed (all of them on a clean build) is touched and the products are built once more with the same flags, which recompiles only those modules and their dependents. The wrapper package and toolsets are now written only when their contents change, so unchanged builds do not relink either. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/PackLib/ProductBuilder.swift | 119 ++++++++++++------ Sources/PackLib/SwiftPMResourceAccessor.swift | 43 +++++-- Sources/XUtils/Foundation+Utils.swift | 9 ++ Tests/XToolTests/ProductBuilderTests.swift | 57 +++++++++ .../SwiftPMResourceAccessorTests.swift | 6 +- 5 files changed, 181 insertions(+), 53 deletions(-) diff --git a/Sources/PackLib/ProductBuilder.swift b/Sources/PackLib/ProductBuilder.swift index 481f53ac..6a123678 100644 --- a/Sources/PackLib/ProductBuilder.swift +++ b/Sources/PackLib/ProductBuilder.swift @@ -36,14 +36,70 @@ public struct ProductBuilder: Sendable { } if !libraries.isEmpty { try createWrapper(at: wrapper, root: root, products: libraries, sdkArguments: sdkArguments) } + let builds = try invocations(settings: settings, root: root, wrapper: wrapper, prebuilt: prebuilt, + sdkArguments: sdkArguments) + + // SwiftPM regenerates accessors during planning. A compiler overlay adapts only generated + // target sources, without editing dependencies or racing that regeneration. Every build + // passes the same overlay path, so flags never change and no-op rebuilds stay no-ops. + // The overlay covers the accessors of the previous build; a later pass recompiles any + // accessor that it did not adapt yet, such as on a clean build. + let built = builds.map(\.product) + var overlay: SwiftPMResourceAccessor.Overlay? + if let first = builds.first, !SwiftPMResourceAccessor.bundleNames(for: built).isEmpty { + let directory = try await binaryDirectory(first.settings, first.arguments) + overlay = try SwiftPMResourceAccessor.overlay(for: built, in: directory, output: wrapper, requireAll: false) + } + let overlayArguments = overlay.map { ["-Xswiftc", "-vfsoverlay", "-Xswiftc", $0.url.path] } ?? [] + + var directory: URL? + var executables = prebuilt + for build in builds { + let arguments = build.arguments + overlayArguments + try await runner.build(build.settings, arguments) + let binDir = try await binaryDirectory(build.settings, arguments) + if let directory, directory != binDir { + throw StringError("Build backend produced products in different resource directories") + } + directory = binDir + let executable = binDir.appendingPathComponent(build.name) + guard FileManager.default.isExecutableFile(atPath: executable.path) else { + throw StringError("Build backend did not produce executable '\(build.name)' at \(executable.path)") + } + executables[build.product.product] = executable + } + if let directory, overlay != nil, + let final = try SwiftPMResourceAccessor.overlay(for: built, in: directory, output: wrapper), + !final.changedSources.isEmpty { + // Touching the generated sources makes the backend recompile exactly those modules + // (and their dependents) with the now-current adaptation; the flags stay unchanged. + for source in final.changedSources { + try FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: source.path) + } + for build in builds { + try await runner.build(build.settings, build.arguments + overlayArguments) + } + let settled = try SwiftPMResourceAccessor.overlay(for: built, in: directory, output: wrapper) + guard settled?.changedSources.isEmpty != false else { + throw StringError("Generated SwiftPM resource accessors changed during the build") + } + } + let output = directory ?? root + let staticTargets = try validateBinaryTargets(in: output) + return BuildArtifacts(directory: output, executables: executables, staticBinaryTargets: staticTargets) + } + + private typealias Invocation = (product: Plan.Product, name: String, settings: BuildSettings, arguments: [String]) + + private func invocations( + settings: BuildSettings, root: URL, wrapper: URL, prebuilt: [String: URL], sdkArguments: [String] + ) throws -> [Invocation] { // Share one scratch tree so resources and dependency libraries remain // beside all products, including mixed direct and wrapped products. let scratchOptions = settings.customOptions.contains { $0 == "--scratch-path" || $0.hasPrefix("--scratch-path=") } ? [] : ["--scratch-path", root.appendingPathComponent(".build").path] - var directory: URL? - var executables = prebuilt - var builds: [(settings: BuildSettings, arguments: [String])] = [] + var builds: [Invocation] = [] for product in plan.allProducts { if prebuilt[product.product] != nil { continue } let package: URL @@ -65,39 +121,17 @@ public struct ProductBuilder: Sendable { case .prebuilt: continue } - let productSettings = settings.withPackagePath(package.path) - let arguments = scratchOptions + ["--product", name] + options - try await runner.build(productSettings, arguments) - let path = try await runner.binaryPath(productSettings, arguments) - .trimmingCharacters(in: .whitespacesAndNewlines) - guard !path.isEmpty, !path.contains("\n"), path.hasPrefix("/") else { - throw StringError("Build backend returned an invalid binary output path: '\(path)'") - } - let binDir = URL(fileURLWithPath: path, isDirectory: true).standardizedFileURL - if let directory, directory != binDir { - throw StringError("Build backend produced products in different resource directories") - } - directory = binDir - builds.append((productSettings, arguments)) - let executable = binDir.appendingPathComponent(name) - guard FileManager.default.isExecutableFile(atPath: executable.path) else { - throw StringError("Build backend did not produce executable '\(name)' at \(executable.path)") - } - executables[product.product] = executable - } - let built = plan.allProducts.filter { prebuilt[$0.product] == nil } - if let directory, let overlay = try SwiftPMResourceAccessor.overlay(for: built, in: directory, output: wrapper) { - // SwiftPM regenerates accessors during planning. A compiler overlay adapts only - // generated target sources, without editing dependencies or racing that regeneration. - // Rebuild every product with the same overlay so shared modules compile once more, - // not once per product. - for build in builds { - try await runner.build(build.settings, build.arguments + ["-Xswiftc", "-vfsoverlay", "-Xswiftc", overlay.path]) - } + builds.append((product, name, settings.withPackagePath(package.path), scratchOptions + ["--product", name] + options)) } - let output = directory ?? root - let staticTargets = try validateBinaryTargets(in: output) - return BuildArtifacts(directory: output, executables: executables, staticBinaryTargets: staticTargets) + return builds + } + + private func binaryDirectory(_ settings: BuildSettings, _ arguments: [String]) async throws -> URL { + let path = try await runner.binaryPath(settings, arguments).trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty, !path.contains("\n"), path.hasPrefix("/") else { + throw StringError("Build backend returned an invalid binary output path: '\(path)'") + } + return URL(fileURLWithPath: path, isDirectory: true).standardizedFileURL } private func validatedPrebuiltProducts() throws -> [String: URL] { @@ -201,7 +235,7 @@ public struct ProductBuilder: Sendable { let path = toolsets.appendingPathComponent("\(name).json") let toolset: [String: Any] = ["schemaVersion": "1.0", "linker": ["extraCLIOptions": linkerOptions]] let data = try JSONSerialization.data(withJSONObject: toolset, options: [.sortedKeys]) - try data.write(to: path) + try data.writeIfChanged(to: path) return ["--toolset", path.path] } @@ -251,7 +285,12 @@ public struct ProductBuilder: Sendable { private func createWrapper( at directory: URL, root: URL, products: [Plan.Product], sdkArguments: [String] ) throws { - try? FileManager.default.removeItem(at: directory) + // Keep unchanged files untouched: new timestamps would relink every product, and the + // directory also holds the resource-accessor overlay of the previous build. + let sourcesDirectory = directory.appendingPathComponent("Sources", isDirectory: true) + let stale = Set((try? FileManager.default.contentsOfDirectory(atPath: sourcesDirectory.path)) ?? []) + .subtracting(products.map(\.targetName)) + for name in stale { try FileManager.default.removeItem(at: sourcesDirectory.appendingPathComponent(name)) } try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let targets = products.map { product in let sourceName: String @@ -278,11 +317,11 @@ public struct ProductBuilder: Sendable { ) """ - try Data(contents.utf8).write(to: directory.appendingPathComponent("Package.swift")) + try Data(contents.utf8).writeIfChanged(to: directory.appendingPathComponent("Package.swift")) for product in products { - let sources = directory.appendingPathComponent("Sources/\(product.targetName)", isDirectory: true) + let sources = sourcesDirectory.appendingPathComponent(product.targetName, isDirectory: true) try FileManager.default.createDirectory(at: sources, withIntermediateDirectories: true) - try Data().write(to: sources.appendingPathComponent("stub.c")) + try Data().writeIfChanged(to: sources.appendingPathComponent("stub.c")) } } } diff --git a/Sources/PackLib/SwiftPMResourceAccessor.swift b/Sources/PackLib/SwiftPMResourceAccessor.swift index d61d1db9..3bdd080c 100644 --- a/Sources/PackLib/SwiftPMResourceAccessor.swift +++ b/Sources/PackLib/SwiftPMResourceAccessor.swift @@ -4,17 +4,35 @@ import XUtils /// Native SwiftPM generates bundle-root lookup, which requires unsigned root aliases on macOS. /// Compile an adapted view of those generated sources; keep package sources and build fallbacks intact. enum SwiftPMResourceAccessor { - /// One overlay covers every product: a resource target can live in a library that several products share. - static func overlay(for products: [Plan.Product], in directory: URL, output: URL) throws -> URL? { - let names = Set(products.filter { $0.platform == .macOS }.flatMap(\.resources).compactMap { resource -> String? in + struct Overlay { + var url: URL + /// Generated accessors whose adapted copy changed while writing this overlay. + /// Modules compiled from them before used a stale or missing adaptation. + var changedSources: [URL] + } + + static func bundleNames(for products: [Plan.Product]) -> Set { + Set(products.filter { $0.platform == .macOS }.flatMap(\.resources).compactMap { resource -> String? in guard case .bundle(let package, let target) = resource else { return nil } return "\(package)_\(target).bundle" }) + } + + /// One overlay covers every product: a resource target can live in a library that several products share. + /// + /// The overlay path never changes, so compiler flags stay stable across builds. Before a build, + /// pass `requireAll: false` to cover the accessors an earlier build generated; after it, pass + /// `requireAll: true` and recompile whatever `changedSources` lists. + static func overlay( + for products: [Plan.Product], in directory: URL, output: URL, requireAll: Bool = true + ) throws -> Overlay? { + let names = bundleNames(for: products) guard !names.isEmpty else { return nil } let manager = FileManager.default // Keep the backend's spelling (not Foundation's canonicalized /private/var spelling): // VFS names must match the compiler's source arguments exactly. - let candidates = try manager.contentsOfDirectory(atPath: directory.path) + let entries = (try? manager.contentsOfDirectory(atPath: directory.path)) ?? [] + let candidates = entries .filter { $0.hasSuffix(".build") && !$0.hasSuffix("-tool.build") } .map { directory.appendingPathComponent($0) } .map { $0.appendingPathComponent("DerivedSources/resource_bundle_accessor.swift") } @@ -31,7 +49,7 @@ enum SwiftPMResourceAccessor { files.append((candidate, try adapted(source, bundleName: name))) } } - guard matched == names else { + if requireAll && matched != names { throw StringError("Missing supported generated SwiftPM resource accessor for: " + names.subtracting(matched).sorted().joined(separator: ", ") + "; macOS bundles currently require Swift resource targets") @@ -39,17 +57,20 @@ enum SwiftPMResourceAccessor { let destination = output.appendingPathComponent("resource-accessors") try manager.createDirectory(at: destination, withIntermediateDirectories: true) var roots: [[String: String]] = [] - for (index, file) in files.enumerated() { - let copy = destination.appendingPathComponent("\(index).swift") - try Data(file.1.utf8).write(to: copy, options: .atomic) - roots.append(["type": "file", "name": file.0.path, "external-contents": copy.path]) + var changed: [URL] = [] + for (source, contents) in files { + // Name copies after their module's build directory, which is unique within the bin directory. + let name = source.deletingLastPathComponent().deletingLastPathComponent().deletingPathExtension() + let copy = destination.appendingPathComponent("\(name.lastPathComponent).swift") + if try Data(contents.utf8).writeIfChanged(to: copy) { changed.append(source) } + roots.append(["type": "file", "name": source.path, "external-contents": copy.path]) } let overlay = destination.appendingPathComponent("overlay.json") let data = try JSONSerialization.data(withJSONObject: [ "version": 0, "use-external-names": false, "roots": roots, ], options: [.sortedKeys]) - try data.write(to: overlay, options: .atomic) - return overlay + try data.writeIfChanged(to: overlay) + return Overlay(url: overlay, changedSources: changed) } static func adapted(_ source: String, bundleName: String) throws -> String { diff --git a/Sources/XUtils/Foundation+Utils.swift b/Sources/XUtils/Foundation+Utils.swift index c3863d35..3e3bb4d3 100644 --- a/Sources/XUtils/Foundation+Utils.swift +++ b/Sources/XUtils/Foundation+Utils.swift @@ -21,6 +21,15 @@ extension Data { try self.init(contentsOf: file) #endif } + + /// Writes atomically unless the file already holds these bytes, so build systems + /// that compare timestamps see no change. Returns whether it wrote. + @discardableResult + package func writeIfChanged(to url: URL) throws -> Bool { + guard (try? Data(contentsOf: url)) != self else { return false } + try write(to: url, options: .atomic) + return true + } } extension FileManager { diff --git a/Tests/XToolTests/ProductBuilderTests.swift b/Tests/XToolTests/ProductBuilderTests.swift index 45008396..8a503b35 100644 --- a/Tests/XToolTests/ProductBuilderTests.swift +++ b/Tests/XToolTests/ProductBuilderTests.swift @@ -2,6 +2,7 @@ import Dependencies import Foundation import Testing @testable import PackLib +import XUtils struct ProductBuilderTests { private func product( @@ -140,6 +141,62 @@ struct ProductBuilderTests { #expect(await recorder.builds.allSatisfy { !$0.1.contains("-Xlinker") }) } + @Test func macOSResourceAccessorsBuildOnceWithStableFlags() async throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") + var app = product("App", source: .library(product: "AppLibrary"), target: target) + app.resources = [.bundle(package: "Package", target: "Resources")] + var helper = product("Helper", source: .library(product: "AppLibrary"), kind: .xpcService, target: target) + helper.resources = app.resources + let plan = try Plan(target: target, app: app, nestedProducts: [helper]) + let settings = try await BuildSettings(configuration: .release, triple: target.triple, buildSystem: .swiftPM, + packagePath: root.path) + let output = root.appendingPathComponent("target-output") + let accessor = output.appendingPathComponent("Resources.build/DerivedSources/resource_bundle_accessor.swift") + let generated = """ + import Foundation + extension Foundation.Bundle { + static let module: Bundle = { + let mainPath = Bundle.main.bundleURL.appendingPathComponent("Package_Resources.bundle").path + return Bundle(path: mainPath)! + }() + } + """ + func build() async throws -> [[String]] { + let recorder = Recorder(output: output) + _ = try await withDependencies { + $0.productBuildRunner = ProductBuildRunner( + build: { settings, arguments in + // SwiftPM generates the accessor while planning, before compiling. + try FileManager.default.createDirectory(at: accessor.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Data(generated.utf8).writeIfChanged(to: accessor) + try await recorder.build(settings, arguments: arguments) + }, + binaryPath: { await recorder.binaryPath($0, arguments: $1) }, + sdkLinkerArguments: { _ in [] }) + } operation: { + try await ProductBuilder(buildSettings: settings, plan: plan).build() + } + return await recorder.builds.map(\.1) + } + + // A clean build adapts the accessors only after SwiftPM generated them, so it compiles + // them a second time; the flags of both passes are identical, so other modules do not. + let clean = try await build() + #expect(clean.count == 4) + #expect(clean[0] == clean[2] && clean[1] == clean[3]) + #expect(clean.allSatisfy { $0.contains("-vfsoverlay") }) + let cleanDate = try FileManager.default.attributesOfItem(atPath: accessor.path)[.modificationDate] as? Date + + // A no-op rebuild runs one pass per product with exactly the previous flags. + let rebuilt = try await build() + #expect(rebuilt == Array(clean.prefix(2))) + let rebuiltDate = try FileManager.default.attributesOfItem(atPath: accessor.path)[.modificationDate] as? Date + #expect(cleanDate == rebuiltDate) + } + @Test func unsupportedCombinationsReject() async throws { let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") let debug = try await BuildSettings(configuration: .debug, triple: target.triple, buildSystem: .swiftPM) diff --git a/Tests/XToolTests/SwiftPMResourceAccessorTests.swift b/Tests/XToolTests/SwiftPMResourceAccessorTests.swift index 5d653fd4..7c91a391 100644 --- a/Tests/XToolTests/SwiftPMResourceAccessorTests.swift +++ b/Tests/XToolTests/SwiftPMResourceAccessorTests.swift @@ -53,7 +53,8 @@ struct SwiftPMResourceAccessorTests { try Data(generatedSource.utf8).write(to: source) } let prepared = try SwiftPMResourceAccessor.overlay(for: [product()], in: root, output: root.appendingPathComponent("owned")) - let overlay = try #require(prepared) + let overlay = try #require(prepared).url + #expect(prepared?.changedSources == [sources[0]]) let json = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: overlay)) as? [String: Any]) #expect(json["use-external-names"] as? Bool == false) let roots = try #require(json["roots"] as? [[String: String]]) @@ -66,8 +67,9 @@ struct SwiftPMResourceAccessorTests { } // Repeated preparation stays deterministic and supports incremental/retry builds. let first = try Data(contentsOf: overlay) - _ = try SwiftPMResourceAccessor.overlay(for: [product()], in: root, output: root.appendingPathComponent("owned")) + let repeated = try SwiftPMResourceAccessor.overlay(for: [product()], in: root, output: root.appendingPathComponent("owned")) #expect(try Data(contentsOf: overlay) == first) + #expect(repeated?.changedSources.isEmpty == true) } @Test func iosKeepsNativeAccessors() throws { From 8d29bad013af2a1ef75ffae04586d93eafeee56a Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Mon, 19 Oct 2026 13:36:47 +0200 Subject: [PATCH 40/59] packer: plan before building so clean builds compile accessors once A clean build of a macOS app with SwiftPM resource bundles compiled every module that owns a bundle, and every module depending on one, twice: the overlay adapts the generated `Bundle.module` accessors, but SwiftPM only writes them while planning the first build. CodexBar's clean release build took 778 s, against 421 s for `swift build`; the second pass recompiled the app module alone for 340 s. When the overlay does not cover every resource bundle, run `swift build --print-manifest-job-graph` with the same arguments first. It plans the build, which writes the accessors, and compiles nothing. The overlay then adapts them before the first compile, and the build runs once. The post-build pass stays as a fallback, and now also covers an accessor the build compiled without adaptation whose adapted copy was unchanged. That happened when `.build` was removed but `xtool/.xtool-tmp` was not: the build compiled SwiftPM's own accessor, found no changed copy, and shipped a binary that looks for resources at the build path. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/PackLib/ProductBuilder.swift | 61 ++++++++++++------- Sources/PackLib/SwiftPMResourceAccessor.swift | 6 +- Tests/XToolTests/ProductBuilderTests.swift | 45 +++++++++----- 3 files changed, 76 insertions(+), 36 deletions(-) diff --git a/Sources/PackLib/ProductBuilder.swift b/Sources/PackLib/ProductBuilder.swift index 6a123678..73bbf7d2 100644 --- a/Sources/PackLib/ProductBuilder.swift +++ b/Sources/PackLib/ProductBuilder.swift @@ -42,14 +42,11 @@ public struct ProductBuilder: Sendable { // SwiftPM regenerates accessors during planning. A compiler overlay adapts only generated // target sources, without editing dependencies or racing that regeneration. Every build // passes the same overlay path, so flags never change and no-op rebuilds stay no-ops. - // The overlay covers the accessors of the previous build; a later pass recompiles any - // accessor that it did not adapt yet, such as on a clean build. + // The overlay covers the accessors of the previous build. When accessors are missing, as on + // a clean build, a plan-only run generates them without compiling, so each module compiles + // once; a later pass recompiles any accessor that the overlay still did not adapt. let built = builds.map(\.product) - var overlay: SwiftPMResourceAccessor.Overlay? - if let first = builds.first, !SwiftPMResourceAccessor.bundleNames(for: built).isEmpty { - let directory = try await binaryDirectory(first.settings, first.arguments) - overlay = try SwiftPMResourceAccessor.overlay(for: built, in: directory, output: wrapper, requireAll: false) - } + let overlay = try await initialOverlay(for: builds, wrapper: wrapper) let overlayArguments = overlay.map { ["-Xswiftc", "-vfsoverlay", "-Xswiftc", $0.url.path] } ?? [] var directory: URL? @@ -68,20 +65,24 @@ public struct ProductBuilder: Sendable { } executables[build.product.product] = executable } - if let directory, overlay != nil, - let final = try SwiftPMResourceAccessor.overlay(for: built, in: directory, output: wrapper), - !final.changedSources.isEmpty { - // Touching the generated sources makes the backend recompile exactly those modules - // (and their dependents) with the now-current adaptation; the flags stay unchanged. - for source in final.changedSources { - try FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: source.path) - } - for build in builds { - try await runner.build(build.settings, build.arguments + overlayArguments) - } - let settled = try SwiftPMResourceAccessor.overlay(for: built, in: directory, output: wrapper) - guard settled?.changedSources.isEmpty != false else { - throw StringError("Generated SwiftPM resource accessors changed during the build") + if let directory, let overlay, + let final = try SwiftPMResourceAccessor.overlay(for: built, in: directory, output: wrapper) { + // An accessor the build's overlay did not cover compiled unadapted, even if its adapted + // copy is unchanged from an earlier build. + let stale = Set(final.changedSources + final.sources.filter { !overlay.sources.contains($0) }) + if !stale.isEmpty { + // Touching the generated sources makes the backend recompile exactly those modules + // (and their dependents) with the now-current adaptation; the flags stay unchanged. + for source in stale { + try FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: source.path) + } + for build in builds { + try await runner.build(build.settings, build.arguments + overlayArguments) + } + let settled = try SwiftPMResourceAccessor.overlay(for: built, in: directory, output: wrapper) + guard settled?.changedSources.isEmpty != false else { + throw StringError("Generated SwiftPM resource accessors changed during the build") + } } } let output = directory ?? root @@ -91,6 +92,17 @@ public struct ProductBuilder: Sendable { private typealias Invocation = (product: Plan.Product, name: String, settings: BuildSettings, arguments: [String]) + private func initialOverlay(for builds: [Invocation], wrapper: URL) async throws -> SwiftPMResourceAccessor.Overlay? { + let built = builds.map(\.product) + guard let first = builds.first, !SwiftPMResourceAccessor.bundleNames(for: built).isEmpty else { return nil } + let directory = try await binaryDirectory(first.settings, first.arguments) + let overlay = try SwiftPMResourceAccessor.overlay(for: built, in: directory, output: wrapper, requireAll: false) + guard let overlay, !overlay.complete else { return overlay } + let arguments = ["-Xswiftc", "-vfsoverlay", "-Xswiftc", overlay.url.path] + for build in builds { try await runner.plan(build.settings, build.arguments + arguments) } + return try SwiftPMResourceAccessor.overlay(for: built, in: directory, output: wrapper, requireAll: false) + } + private func invocations( settings: BuildSettings, root: URL, wrapper: URL, prebuilt: [String: URL], sdkArguments: [String] ) throws -> [Invocation] { @@ -371,6 +383,8 @@ struct ProductBuildRunner: Sendable { var build: @Sendable (BuildSettings, [String]) async throws -> Void var binaryPath: @Sendable (BuildSettings, [String]) async throws -> String var sdkLinkerArguments: @Sendable (BuildSettings) async throws -> [String] = { try await $0.macOSLinkerArguments() } + /// Plans a build without compiling; SwiftPM writes generated sources such as resource accessors while planning. + var plan: @Sendable (BuildSettings, [String]) async throws -> Void = { _, _ in } } extension ProductBuildRunner: DependencyKey { @@ -384,6 +398,11 @@ extension ProductBuildRunner: DependencyKey { let result = try await Subprocess.run(invocation, output: .string(limit: .max), error: .currentStandardError) .checkSuccess() return result.standardOutput + }, + plan: { settings, arguments in + let invocation = try await settings.swiftPMInvocation(forTool: "build", + arguments: arguments + ["--print-manifest-job-graph"]) + try await Subprocess.run(invocation, output: .discarded, error: .currentStandardError).checkSuccess() } ) } diff --git a/Sources/PackLib/SwiftPMResourceAccessor.swift b/Sources/PackLib/SwiftPMResourceAccessor.swift index 3bdd080c..ac0c0580 100644 --- a/Sources/PackLib/SwiftPMResourceAccessor.swift +++ b/Sources/PackLib/SwiftPMResourceAccessor.swift @@ -9,6 +9,10 @@ enum SwiftPMResourceAccessor { /// Generated accessors whose adapted copy changed while writing this overlay. /// Modules compiled from them before used a stale or missing adaptation. var changedSources: [URL] + /// Generated accessors this overlay adapts. + var sources: [URL] + /// Whether every resource bundle's generated accessor was found and adapted. + var complete: Bool } static func bundleNames(for products: [Plan.Product]) -> Set { @@ -70,7 +74,7 @@ enum SwiftPMResourceAccessor { "version": 0, "use-external-names": false, "roots": roots, ], options: [.sortedKeys]) try data.writeIfChanged(to: overlay) - return Overlay(url: overlay, changedSources: changed) + return Overlay(url: overlay, changedSources: changed, sources: files.map(\.0), complete: matched == names) } static func adapted(_ source: String, bundleName: String) throws -> String { diff --git a/Tests/XToolTests/ProductBuilderTests.swift b/Tests/XToolTests/ProductBuilderTests.swift index 8a503b35..8802f122 100644 --- a/Tests/XToolTests/ProductBuilderTests.swift +++ b/Tests/XToolTests/ProductBuilderTests.swift @@ -163,38 +163,55 @@ struct ProductBuilderTests { }() } """ - func build() async throws -> [[String]] { + @Sendable func generate() throws { + // SwiftPM generates the accessor while planning, before compiling. + try FileManager.default.createDirectory(at: accessor.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Data(generated.utf8).writeIfChanged(to: accessor) + } + func build(planGenerates: Bool = true) async throws -> (plans: [[String]], builds: [[String]]) { let recorder = Recorder(output: output) + let plans = Recorder(output: output) _ = try await withDependencies { $0.productBuildRunner = ProductBuildRunner( build: { settings, arguments in - // SwiftPM generates the accessor while planning, before compiling. - try FileManager.default.createDirectory(at: accessor.deletingLastPathComponent(), - withIntermediateDirectories: true) - try Data(generated.utf8).writeIfChanged(to: accessor) + try generate() try await recorder.build(settings, arguments: arguments) }, binaryPath: { await recorder.binaryPath($0, arguments: $1) }, - sdkLinkerArguments: { _ in [] }) + sdkLinkerArguments: { _ in [] }, + plan: { settings, arguments in + if planGenerates { try generate() } + try await plans.build(settings, arguments: arguments) + }) } operation: { try await ProductBuilder(buildSettings: settings, plan: plan).build() } - return await recorder.builds.map(\.1) + return (await plans.builds.map(\.1), await recorder.builds.map(\.1)) } - // A clean build adapts the accessors only after SwiftPM generated them, so it compiles - // them a second time; the flags of both passes are identical, so other modules do not. + // A clean build plans first, so the overlay adapts the accessors before anything compiles: + // one pass per product, with the flags it plans with. let clean = try await build() - #expect(clean.count == 4) - #expect(clean[0] == clean[2] && clean[1] == clean[3]) - #expect(clean.allSatisfy { $0.contains("-vfsoverlay") }) + #expect(clean.plans == clean.builds) + #expect(clean.builds.count == 2) + #expect(clean.builds.allSatisfy { $0.contains("-vfsoverlay") }) let cleanDate = try FileManager.default.attributesOfItem(atPath: accessor.path)[.modificationDate] as? Date - // A no-op rebuild runs one pass per product with exactly the previous flags. + // A no-op rebuild skips planning and runs one pass per product with exactly the previous flags. let rebuilt = try await build() - #expect(rebuilt == Array(clean.prefix(2))) + #expect(rebuilt.plans.isEmpty) + #expect(rebuilt.builds == clean.builds) let rebuiltDate = try FileManager.default.attributesOfItem(atPath: accessor.path)[.modificationDate] as? Date #expect(cleanDate == rebuiltDate) + + // If planning does not produce an accessor, the build adapts it afterwards and compiles it + // again with identical flags, so other modules do not recompile. This holds even though + // the adapted copy from the earlier build is unchanged: the build itself did not use it. + try FileManager.default.removeItem(at: output) + let fallback = try await build(planGenerates: false) + #expect(fallback.builds.count == 4) + #expect(fallback.builds[0] == fallback.builds[2] && fallback.builds[1] == fallback.builds[3]) } @Test func unsupportedCombinationsReject() async throws { From 6e96adf5ca7b45034e165aff2c21fcae552663e2 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 20 Oct 2026 01:05:03 +0200 Subject: [PATCH 41/59] docs: list the artifact manifest among the macOS handoff files MacOS.md named the app, the signing manifest and the signing inputs as the files to transfer, but `dev verify` and `dev sign` also read `.artifact.json`. Following the page left it behind and both commands failed on the Mac. The README already lists all four. Co-Authored-By: Claude Opus 5.5 (1M context) --- Documentation/xtool.docc/MacOS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Documentation/xtool.docc/MacOS.md b/Documentation/xtool.docc/MacOS.md index ee09d29f..7131b1cf 100644 --- a/Documentation/xtool.docc/MacOS.md +++ b/Documentation/xtool.docc/MacOS.md @@ -28,10 +28,10 @@ The parser accepts arm64 and x86_64 macOS triples. Linux macOS Debug builds are requires unavailable `codesign` support. Use Release. SwiftBuild is rejected for macOS; its existing iOS support does not establish macOS support. -The output is `xtool/.app`, accompanied by `.signing.json` -and, when supplied, `.signing-inputs/`. The CLI preserves copies of +The output is `xtool/.app`, accompanied by `.artifact.json`, +`.signing.json` and, when supplied, `.signing-inputs/`. The CLI preserves copies of caller entitlement dictionaries outside the app and rebases their paths in -the manifest. Transfer all three together. The manifest has `version: 1`, +the manifest. Transfer all of them together; `xtool dev verify` and `xtool dev sign` read each one. The manifest has `version: 1`, `status: deferred`, a resolved target, and product bundle/executable paths with their entitlement associations. It records signing intent, not release approval. From dda4c7b93d391dbca0fe1de16c377c71ad952a8b Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 20 Oct 2026 01:06:32 +0200 Subject: [PATCH 42/59] packer: keep the Swift SDK cache in the root package On Linux, every SwiftPM invocation for a macOS target writes its generated destination to `.build/xtool-sdk` under `packagePath`. The planner inspects dependencies through `withPackagePath(node.path)`, so each `describe` and `dump-package` wrote that file into the dependency: into `.build/checkouts/` for remote packages, and into the user's own source tree for `.package(path:)` dependencies. Capture the cache directory from the root package when the settings are created, so rebased copies keep writing there. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/PackLib/BuildSettings.swift | 10 ++++++++-- Tests/XToolTests/SwiftSDKConfigurationTests.swift | 8 ++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/Sources/PackLib/BuildSettings.swift b/Sources/PackLib/BuildSettings.swift index b2b200f7..53830c41 100644 --- a/Sources/PackLib/BuildSettings.swift +++ b/Sources/PackLib/BuildSettings.swift @@ -17,6 +17,10 @@ public struct BuildSettings: Sendable { public var sdkOptions: [String] public var sdkEnvironment: [Environment.Key: String?] + /// Where Linux writes the generated Swift SDK destination. It belongs to the root package: + /// copies from `withPackagePath` inspect dependencies and must not write into their directories. + let swiftSDKCacheDirectory: URL + private var configOptions: [String] { return [ "--configuration", configuration.rawValue, @@ -37,6 +41,8 @@ public struct BuildSettings: Sendable { options: [String] = [] ) async throws { self.packagePath = packagePath + self.swiftSDKCacheDirectory = URL(fileURLWithPath: packagePath, isDirectory: true) + .appendingPathComponent(".build/xtool-sdk") self.configuration = configuration self.customOptions = options self.triple = triple @@ -275,8 +281,8 @@ public struct BuildSettings: Sendable { .orThrow(StringError("No Darwin SDK configured. Please run `xtool setup`.")) .bundle.deletingLastPathComponent() } - let cache = URL(fileURLWithPath: packagePath, isDirectory: true).appendingPathComponent(".build/xtool-sdk") - let destination = try SwiftSDKConfiguration.destination(in: directory, for: target, cacheDirectory: cache) + let destination = try SwiftSDKConfiguration.destination(in: directory, for: target, + cacheDirectory: swiftSDKCacheDirectory) let compilerTriple = sdkOptions.firstIndex(of: "--triple").map { sdkOptions[$0 + 1] } ?? Self.compilerTriple(for: target) options = configOptions + ["--destination", destination.url.path, "--triple", compilerTriple] + customOptions diff --git a/Tests/XToolTests/SwiftSDKConfigurationTests.swift b/Tests/XToolTests/SwiftSDKConfigurationTests.swift index 92ed17da..3b0667e2 100644 --- a/Tests/XToolTests/SwiftSDKConfigurationTests.swift +++ b/Tests/XToolTests/SwiftSDKConfigurationTests.swift @@ -36,6 +36,14 @@ struct SwiftSDKConfigurationTests { return metadata } + @Test func dependencyInspectionKeepsTheRootPackagesSDKCache() async throws { + // The planner inspects each dependency through a copy rebased onto its directory. + let settings = try await BuildSettings(configuration: .release, triple: "arm64-apple-macosx15.0", + packagePath: "/work/App") + let dependency = settings.withPackagePath("/work/App/.build/checkouts/Dependency") + #expect(dependency.swiftSDKCacheDirectory.path == "/work/App/.build/xtool-sdk") + } + @Test func destinationSelectsOneTargetAndPreservesSDKPathsWithoutChangingInstallation() throws { let directory = try root() defer { try? FileManager.default.removeItem(at: directory) } From 6bb84372b69489bfbe996d000038d73f8b6618d9 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 20 Oct 2026 01:07:44 +0200 Subject: [PATCH 43/59] dev: audit existing apps against their own target in `dev run --app` `dev run --app` built its audit target with `ResolvedTarget.resolve`, from the host architecture and `LSMinimumSystemVersion`. That applied xtool's build policy to apps xtool did not build: - an app supporting macOS below 13.0 was rejected as below the required minimum; - an x86_64-only app on Apple silicon failed the audit for lacking an arm64 slice, although Rosetta runs it; - an app without `LSMinimumSystemVersion` was audited at 15.0, the former floor. Add `ResolvedTarget.existing(triple:)`, which validates the triple's syntax without the build minimums, and `MacBundleAudit.runTarget`. It picks the main executable's slice for the host architecture, falling back to x86_64 on arm64. The minimum comes from `LSMinimumSystemVersion`, or else from the minimum recorded in that slice. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/PackLib/MacBundleAudit.swift | 21 +++++++++++ Sources/PackLib/ProductBuilder.swift | 2 +- Sources/PackLib/TargetPlatform.swift | 11 ++++++ Sources/XToolSupport/MacRunOperation.swift | 10 ++--- Tests/XToolTests/MacArtifactTests.swift | 43 +++++++++++++++++++--- 5 files changed, 73 insertions(+), 14 deletions(-) diff --git a/Sources/PackLib/MacBundleAudit.swift b/Sources/PackLib/MacBundleAudit.swift index 446b2281..a836a741 100644 --- a/Sources/PackLib/MacBundleAudit.swift +++ b/Sources/PackLib/MacBundleAudit.swift @@ -19,6 +19,27 @@ public enum MacBundleAudit { return executable } + /// The target an existing app runs as on this host: the main executable's slice for the host + /// architecture, or its x86_64 slice under Rosetta on arm64, at the app's declared minimum. + /// Without `LSMinimumSystemVersion`, the minimum is the one recorded in that slice. + public static func runTarget(bundle: URL, hostArchitecture: String) throws -> ResolvedTarget { + let main = try executable(in: bundle) + let slices = try MachOBinary.inspect([UInt8](Data(contentsOf: main))) + let candidates = hostArchitecture == "arm64" ? ["arm64", "x86_64"] : [hostArchitecture] + var match: (architecture: String, slice: MachOBinary.Slice)? + for architecture in candidates where match == nil { + let cpu = try MachOBinary.cpuType(for: architecture) + match = slices.first { $0.cpu == cpu }.map { (architecture, $0) } + } + guard let (architecture, slice) = match else { + throw StringError("Application executable has no slice that runs on \(hostArchitecture)") + } + let info = try metadata(at: bundle.appendingPathComponent("Contents/Info.plist")) + let minimum = (info["LSMinimumSystemVersion"] as? String) + ?? "\(slice.minimumOS >> 16).\((slice.minimumOS >> 8) & 0xff).\(slice.minimumOS & 0xff)" + return try ResolvedTarget.existing(triple: "\(architecture)-apple-macosx\(minimum)") + } + @discardableResult public static func validate(bundle: URL, target: ResolvedTarget) throws -> [String] { guard target.platform == .macOS else { throw StringError("Expected a macOS artifact target") } diff --git a/Sources/PackLib/ProductBuilder.swift b/Sources/PackLib/ProductBuilder.swift index 73bbf7d2..508f7ba4 100644 --- a/Sources/PackLib/ProductBuilder.swift +++ b/Sources/PackLib/ProductBuilder.swift @@ -526,7 +526,7 @@ struct MachOBinary { } } - private static func cpuType(for architecture: String) throws -> UInt32 { + static func cpuType(for architecture: String) throws -> UInt32 { switch architecture { case "arm64": return 0x0100000c case "x86_64": return 0x01000007 diff --git a/Sources/PackLib/TargetPlatform.swift b/Sources/PackLib/TargetPlatform.swift index 3b180c98..2b669e31 100644 --- a/Sources/PackLib/TargetPlatform.swift +++ b/Sources/PackLib/TargetPlatform.swift @@ -104,6 +104,17 @@ public struct ResolvedTarget: Codable, Sendable, Equatable { ) } + /// Describes an existing app from its own metadata. Only the triple's syntax is validated: + /// xtool's deployment minimums apply to what it builds, not to apps it inspects. + public static func existing(triple: String) throws -> Self { + let parsed = try TargetTriple(triple) + guard let deployment = parsed.deploymentTarget else { + throw StringError("Expected a versioned target triple: \(triple)") + } + return Self(triple: triple, platform: parsed.platform, architecture: parsed.architecture, + deploymentTarget: deployment) + } + private static func version(_ value: String, isBelow minimum: String) -> Bool { let lhs = value.split(separator: ".").map { Int($0) ?? Int.max } let rhs = minimum.split(separator: ".").map { Int($0) ?? Int.max } diff --git a/Sources/XToolSupport/MacRunOperation.swift b/Sources/XToolSupport/MacRunOperation.swift index ec42c96f..a357ecdf 100644 --- a/Sources/XToolSupport/MacRunOperation.swift +++ b/Sources/XToolSupport/MacRunOperation.swift @@ -53,16 +53,12 @@ enum MacRunOperation { let executable = try MacBundleAudit.executable(in: bundle) // Runtime validation is independent of the pre-signing byte inventory: an externally // signed app is expected to differ from that inventory, and is never rewritten here. - let info = try PropertyListSerialization.propertyList( - from: Data(contentsOf: bundle.appendingPathComponent("Contents/Info.plist")), format: nil - ) as? [String: Any] #if arch(arm64) - let architecture = "arm64" + let host = "arm64" #else - let architecture = "x86_64" + let host = "x86_64" #endif - let target = try ResolvedTarget.resolve(cliTarget: "\(architecture)-apple-macosx" - + ((info?["LSMinimumSystemVersion"] as? String) ?? "15.0")) + let target = try MacBundleAudit.runTarget(bundle: bundle, hostArchitecture: host) try MacBundleAudit.validate(bundle: bundle, target: target) print("Launching \(bundle.lastPathComponent)...") @Dependency(\.macRunClient) var client diff --git a/Tests/XToolTests/MacArtifactTests.swift b/Tests/XToolTests/MacArtifactTests.swift index 66d08401..5947425e 100644 --- a/Tests/XToolTests/MacArtifactTests.swift +++ b/Tests/XToolTests/MacArtifactTests.swift @@ -6,25 +6,29 @@ import Testing @testable import XToolSupport struct MacArtifactTests { - static func fixture(at bundle: URL) throws { + static func fixture(at bundle: URL, minimumSystemVersion: String? = "15.0") throws { let directory = bundle.appendingPathComponent("Contents/MacOS") try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let info: [String: Any] = ["CFBundleExecutable": "App", "CFBundlePackageType": "APPL", - "CFBundleIdentifier": "test.App", "LSMinimumSystemVersion": "15.0"] + var info: [String: Any] = ["CFBundleExecutable": "App", "CFBundlePackageType": "APPL", + "CFBundleIdentifier": "test.App"] + info["LSMinimumSystemVersion"] = minimumSystemVersion try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) .write(to: bundle.appendingPathComponent("Contents/Info.plist")) try binary().write(to: directory.appendingPathComponent("App")) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: directory.appendingPathComponent("App").path) } - static func binary(dependency: String? = nil, type: UInt32 = 2, rpath: String? = nil) -> Data { + static func binary( + dependency: String? = nil, type: UInt32 = 2, rpath: String? = nil, + cpu: UInt32 = 0x0100000c, minimumOS: UInt32 = 0x000f0000 + ) -> Data { func words(_ values: [UInt32]) -> Data { values.reduce(into: Data()) { result, value in var little = value.littleEndian withUnsafeBytes(of: &little) { result.append(contentsOf: $0) } } } - var commands = words([0x32, 24, 1, 0x000f0000, 0x001a0000, 0]) + var commands = words([0x32, 24, 1, minimumOS, 0x001a0000, 0]) var count: UInt32 = 1 for (command, path) in [(UInt32(0xc), dependency), (UInt32(0x8000001c), rpath)] { guard let path else { continue } @@ -36,7 +40,7 @@ struct MacArtifactTests { commands += string count += 1 } - return words([0xfeedfacf, 0x0100000c, 0, type, count, UInt32(commands.count), 0, 0]) + commands + return words([0xfeedfacf, cpu, 0, type, count, UInt32(commands.count), 0, 0]) + commands } private func root() throws -> URL { @@ -151,6 +155,33 @@ struct MacArtifactTests { try before.verify(directory: app) } + @Test func existingAppTargetComesFromTheAppNotFromBuildPolicy() throws { + let directory = try root() + defer { try? FileManager.default.removeItem(at: directory) } + let app = directory.appendingPathComponent("App.app") + let executable = app.appendingPathComponent("Contents/MacOS/App") + + // An app older than xtool's macOS 13.0 build floor still validates. + try Self.fixture(at: app, minimumSystemVersion: "11.0") + try Self.binary(minimumOS: 0x000b0000).write(to: executable) + let old = try MacBundleAudit.runTarget(bundle: app, hostArchitecture: "arm64") + #expect(old.architecture == "arm64" && old.deploymentTarget == "11.0") + try MacBundleAudit.validate(bundle: app, target: old) + + // Without LSMinimumSystemVersion, the executable's recorded minimum applies. + try Self.fixture(at: app, minimumSystemVersion: nil) + try Self.binary(minimumOS: 0x000b0000).write(to: executable) + #expect(try MacBundleAudit.runTarget(bundle: app, hostArchitecture: "arm64").deploymentTarget == "11.0.0") + + // An Intel-only app runs under Rosetta on Apple silicon; an arm64-only app has no Intel slice. + try Self.binary(cpu: 0x01000007, minimumOS: 0x000b0000).write(to: executable) + let intel = try MacBundleAudit.runTarget(bundle: app, hostArchitecture: "arm64") + #expect(intel.architecture == "x86_64") + try MacBundleAudit.validate(bundle: app, target: intel) + try Self.binary().write(to: executable) + #expect(throws: (any Error).self) { try MacBundleAudit.runTarget(bundle: app, hostArchitecture: "x86_64") } + } + @Test func frameworkHelpersPrecedeTheirContainingFrameworkInSigningOrder() throws { let directory = try root() defer { try? FileManager.default.removeItem(at: directory) } From 3175d378e6cfd88f6220f622c8bfd6671ba8c855 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 20 Oct 2026 07:00:10 +0200 Subject: [PATCH 44/59] dev: pass the loaded schema to the iOS pack path DevBuildRequest already loads xtool.yml; PackOperation loaded it again, so a missing config warned twice (once on stderr, once on stdout). Pass the schema and the effective triple through legacyPack, drop the unused default triple, and share the Package.swift check with the deferred macOS build. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/XToolSupport/DevCommand.swift | 59 +++++++++++--------------- Tests/XToolTests/DevCommandTests.swift | 8 ++-- 2 files changed, 28 insertions(+), 39 deletions(-) diff --git a/Sources/XToolSupport/DevCommand.swift b/Sources/XToolSupport/DevCommand.swift index 8798f835..318412dc 100644 --- a/Sources/XToolSupport/DevCommand.swift +++ b/Sources/XToolSupport/DevCommand.swift @@ -19,9 +19,8 @@ struct PackOperation { } } - static let defaultTriple = "arm64-apple-ios" - - var triple: String? + var schema: PackSchema + var triple: String var buildOptions = BuildOptions(configuration: .debug) var xcode = false @@ -29,32 +28,15 @@ struct PackOperation { func run() async throws -> URL { // TODO: support running inside a child directory - let contents = try FileManager.default.contentsOfDirectory(atPath: ".") - guard contents.contains("Package.swift") - || contents.contains(where: { $0.hasPrefix("Package@") && $0.hasSuffix(".swift") }) - else { - throw Console.Error("Could not find Package.swift in this directory.") - } + try Self.requirePackageManifest(in: URL(fileURLWithPath: ".")) try await EnsureSDKOperation(quiet: true).run() print("Planning...") - let schema: PackSchema - let configPath = URL(fileURLWithPath: "xtool.yml") - if FileManager.default.fileExists(atPath: configPath.path) { - schema = try await PackSchema(url: configPath) - } else { - schema = .default - print(""" - warning: Could not locate configuration file '\(configPath.path)'. Using default \ - configuration with 'com.example' organization ID. - """) - } - let buildSettings = try await BuildSettings( configuration: buildOptions.configuration, - triple: triple ?? Self.defaultTriple, + triple: triple, options: [] ) @@ -101,6 +83,15 @@ struct PackOperation { return bundle } + + static func requirePackageManifest(in directory: URL) throws { + let contents = try FileManager.default.contentsOfDirectory(atPath: directory.path) + guard contents.contains("Package.swift") + || contents.contains(where: { $0.hasPrefix("Package@") && $0.hasSuffix(".swift") }) + else { + throw Console.Error("Could not find Package.swift in this directory.") + } + } } struct DevXcodeCommand: AsyncParsableCommand { @@ -113,7 +104,7 @@ struct DevXcodeCommand: AsyncParsableCommand { let request = try await DevBuildRequest.load() try request.validate(route: .xcode) @Dependency(\.devCommandClient) var client - let result = try await client.legacyPack(request.triple, .debug, true) + let result = try await client.legacyPack(request.schema, request.triple, .debug, true) print("Wrote to \(result.path)") } } @@ -149,7 +140,7 @@ struct DevBuildCommand: AsyncParsableCommand { @Option( help: ArgumentHelp( "Custom target triple to build for", - discussion: "Overrides xtool.yml target; otherwise defaults to '\(PackOperation.defaultTriple)'" + discussion: "Overrides xtool.yml target; otherwise defaults to '\(ResolvedTarget.defaultTriple)'" ) ) var triple: String? @@ -179,7 +170,7 @@ struct DevBuildCommand: AsyncParsableCommand { } @Dependency(\.devCommandClient) var client - let url = try await client.legacyPack(request.triple, packOptions.configuration, false) + let url = try await client.legacyPack(request.schema, request.triple, packOptions.configuration, false) if let signingAuthToken { let installDelegate = XToolInstallerDelegate() @@ -290,7 +281,7 @@ struct DevRunCommand: AsyncParsableCommand { } guard appArguments.isEmpty else { throw Console.Error("App arguments currently require a macOS target") } @Dependency(\.devCommandClient) var client - let output = try await client.legacyPack(simulatorTriple ?? request.triple, packOptions.configuration, false) + let output = try await client.legacyPack(request.schema, simulatorTriple ?? request.triple, packOptions.configuration, false) #if os(macOS) if simulator { @@ -416,11 +407,7 @@ struct DeferredMacBuildOperation { throw Console.Error("macOS Debug builds on Linux need unavailable codesign support; use --configuration release") } #endif - let contents = try FileManager.default.contentsOfDirectory(atPath: request.directory.path) - guard contents.contains("Package.swift") - || contents.contains(where: { $0.hasPrefix("Package@") && $0.hasSuffix(".swift") }) else { - throw Console.Error("Could not find Package.swift in this directory.") - } + try PackOperation.requirePackageManifest(in: request.directory) @Dependency(\.devCommandClient) var client if options?.swiftSDKsPath == nil { try await client.ensureSDK() } print("Planning...") @@ -482,7 +469,7 @@ struct DevCommandClient: Sendable { var currentDirectory: @Sendable () -> URL var loadSchema: @Sendable (URL) async throws -> PackSchema var ensureSDK: @Sendable () async throws -> Void - var legacyPack: @Sendable (String, BuildConfiguration, Bool) async throws -> URL + var legacyPack: @Sendable (PackSchema, String, BuildConfiguration, Bool) async throws -> URL var deferredPack: @Sendable (BuildSettings, Plan) async throws -> URL } @@ -503,8 +490,10 @@ extension DevCommandClient: DependencyKey { return .default }, ensureSDK: { try await EnsureSDKOperation(quiet: true).run() }, - legacyPack: { triple, configuration, xcode in - try await PackOperation(triple: triple, buildOptions: .init(configuration: configuration), xcode: xcode).run() + legacyPack: { schema, triple, configuration, xcode in + try await PackOperation( + schema: schema, triple: triple, buildOptions: .init(configuration: configuration), xcode: xcode + ).run() }, deferredPack: { settings, plan in try await Packer(buildSettings: settings, plan: plan).pack() @@ -515,7 +504,7 @@ extension DevCommandClient: DependencyKey { currentDirectory: { URL(fileURLWithPath: "/unexpected-command-directory") }, loadSchema: { _ in throw StringError("Unexpected schema load") }, ensureSDK: { throw StringError("Unexpected SDK setup") }, - legacyPack: { _, _, _ in throw StringError("Unexpected iOS packing") }, + legacyPack: { _, _, _, _ in throw StringError("Unexpected iOS packing") }, deferredPack: { _, _ in throw StringError("Unexpected deferred packing") } ) } diff --git a/Tests/XToolTests/DevCommandTests.swift b/Tests/XToolTests/DevCommandTests.swift index 8be405c9..f4756193 100644 --- a/Tests/XToolTests/DevCommandTests.swift +++ b/Tests/XToolTests/DevCommandTests.swift @@ -43,7 +43,7 @@ struct DevCommandTests { client.currentDirectory = { root } client.loadSchema = { _ in schema } client.ensureSDK = { Issue.record("Unexpected SDK setup"); throw StringError("Unexpected SDK setup") } - client.legacyPack = { _, _, _ in Issue.record("Unexpected legacy packing"); throw StringError("Unexpected legacy packing") } + client.legacyPack = { _, _, _, _ in Issue.record("Unexpected legacy packing"); throw StringError("Unexpected legacy packing") } client.deferredPack = { _, _ in Issue.record("Unexpected deferred packing"); throw StringError("Unexpected deferred packing") } return client } @@ -353,7 +353,7 @@ struct DevCommandTests { let configured = try schema(target: "arm64-apple-ios16.0") let recorder = Recorder() var effects = client(schema: configured, root: directory) - effects.legacyPack = { triple, configuration, xcode in + effects.legacyPack = { _, triple, configuration, xcode in #expect(triple == (override ?? "arm64-apple-ios16.0")) #expect(configuration == .debug) #expect(!xcode) @@ -378,7 +378,7 @@ struct DevCommandTests { try FileManager.default.createDirectory(at: app, withIntermediateDirectories: true) try Data("app executable".utf8).write(to: app.appendingPathComponent("App")) var effects = client(schema: .default, root: directory) - effects.legacyPack = { triple, _, xcode in + effects.legacyPack = { _, triple, _, xcode in #expect(triple == "arm64-apple-ios") #expect(!xcode) return app @@ -411,7 +411,7 @@ struct DevCommandTests { defer { try? FileManager.default.removeItem(at: directory) } let recorder = Recorder() var effects = client(schema: .default, root: directory) - effects.legacyPack = { triple, configuration, xcode in + effects.legacyPack = { _, triple, configuration, xcode in #expect(triple == "arm64-apple-ios") #expect(configuration == .debug) await recorder.record(xcode ? "xcode" : "run") From 1ca181197d934387a344be4d113e64a32997f724 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 20 Oct 2026 07:00:10 +0200 Subject: [PATCH 45/59] packer: format SDK identities and compiler triples in one place TargetPlatform.triple(architecture:version:) replaces the separate switches in BuildSettings, LSPConfig, SwiftSDKConfiguration and MacBundleAudit. BuildSettings stores its compiler triple instead of parsing --triple back out of sdkOptions. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/PackLib/BuildSettings.swift | 33 +++++---------------- Sources/PackLib/MacBundleAudit.swift | 2 +- Sources/PackLib/SwiftSDKConfiguration.swift | 2 +- Sources/PackLib/TargetPlatform.swift | 23 ++++++++++---- Sources/XToolSupport/LSPConfig.swift | 7 +---- 5 files changed, 29 insertions(+), 38 deletions(-) diff --git a/Sources/PackLib/BuildSettings.swift b/Sources/PackLib/BuildSettings.swift index 53830c41..6a43f792 100644 --- a/Sources/PackLib/BuildSettings.swift +++ b/Sources/PackLib/BuildSettings.swift @@ -16,6 +16,8 @@ public struct BuildSettings: Sendable { public var sdkOptions: [String] public var sdkEnvironment: [Environment.Key: String?] + /// The versioned triple SwiftPM compiles for; `withTarget` pins the plan's deployment target. + private var compilerTriple: String /// Where Linux writes the generated Swift SDK destination. It belongs to the root package: /// copies from `withPackagePath` inspect dependencies and must not write into their directories. @@ -47,6 +49,7 @@ public struct BuildSettings: Sendable { self.customOptions = options self.triple = triple let target = try TargetTriple(triple) + self.compilerTriple = target.compilerTriple // SwiftBuild has not been validated for macOS cross-compilation. Keep the // established iOS default, and use the proven SwiftPM backend for macOS. self.buildSystem = if let buildSystem { @@ -69,9 +72,9 @@ public struct BuildSettings: Sendable { // i.e. we can pass `--swift-sdk arm64-apple-ios` and it // just works. See: // https://github.com/swiftlang/swift-package-manager/pull/6828 - self.sdkOptions = ["--swift-sdk", Self.sdkIdentity(for: target)] + self.sdkOptions = ["--swift-sdk", target.sdkIdentity] if target.deploymentTarget != nil { - self.sdkOptions += ["--triple", Self.compilerTriple(for: target)] + self.sdkOptions += ["--triple", compilerTriple] } case .swiftBuild: guard target.platform != .macOS else { @@ -143,8 +146,8 @@ public struct BuildSettings: Sendable { var copy = self switch buildSystem { case .swiftPM: - copy.sdkOptions = ["--swift-sdk", Self.sdkIdentity(for: try TargetTriple(triple)), - "--triple", target.compilerTriple] + copy.compilerTriple = target.compilerTriple + copy.sdkOptions = ["--swift-sdk", try TargetTriple(triple).sdkIdentity, "--triple", target.compilerTriple] case .swiftBuild: // Keep the initializer's triple and toolset. SwiftBuild applies a versioned triple's // deployment target to every package, so dependencies would compile for the app's @@ -154,23 +157,6 @@ public struct BuildSettings: Sendable { return copy } - private static func sdkIdentity(for target: TargetTriple) -> String { - switch target.platform { - case .iOS: "\(target.architecture)-apple-ios" - case .iOSSimulator: "\(target.architecture)-apple-ios-simulator" - case .macOS: "\(target.architecture)-apple-macosx" - } - } - - private static func compilerTriple(for target: TargetTriple) -> String { - let version = target.deploymentTarget ?? target.platform.minimumDeploymentTarget - return switch target.platform { - case .iOS: "\(target.architecture)-apple-ios\(version)" - case .iOSSimulator: "\(target.architecture)-apple-ios\(version)-simulator" - case .macOS: "\(target.architecture)-apple-macosx\(version)" - } - } - /// LLD otherwise records the deployment version as the SDK version on Linux. /// Keep this target-only: SwiftPM CLI linker flags also affect host macros. func macOSLinkerArguments() async throws -> [String] { @@ -194,8 +180,7 @@ public struct BuildSettings: Sendable { throw StringError("Missing SDK version in \(sdkRoot.path)/SDKSettings.json") } try TargetTriple.validateVersion(version) - let tripleIndex = sdkOptions.firstIndex(of: "--triple") - let target = try TargetTriple(tripleIndex.map { sdkOptions[$0 + 1] } ?? triple) + let target = try TargetTriple(compilerTriple) return ["-Xlinker", "-platform_version", "-Xlinker", "macos", "-Xlinker", target.deploymentTarget ?? target.platform.minimumDeploymentTarget, "-Xlinker", version] } @@ -283,8 +268,6 @@ public struct BuildSettings: Sendable { } let destination = try SwiftSDKConfiguration.destination(in: directory, for: target, cacheDirectory: swiftSDKCacheDirectory) - let compilerTriple = sdkOptions.firstIndex(of: "--triple").map { sdkOptions[$0 + 1] } - ?? Self.compilerTriple(for: target) options = configOptions + ["--destination", destination.url.path, "--triple", compilerTriple] + customOptions environment["PATH"] = (destination.toolSearchPaths + [ProcessInfo.processInfo.environment["PATH"] ?? ""]) .joined(separator: ":") diff --git a/Sources/PackLib/MacBundleAudit.swift b/Sources/PackLib/MacBundleAudit.swift index a836a741..82e2cd8e 100644 --- a/Sources/PackLib/MacBundleAudit.swift +++ b/Sources/PackLib/MacBundleAudit.swift @@ -37,7 +37,7 @@ public enum MacBundleAudit { let info = try metadata(at: bundle.appendingPathComponent("Contents/Info.plist")) let minimum = (info["LSMinimumSystemVersion"] as? String) ?? "\(slice.minimumOS >> 16).\((slice.minimumOS >> 8) & 0xff).\(slice.minimumOS & 0xff)" - return try ResolvedTarget.existing(triple: "\(architecture)-apple-macosx\(minimum)") + return try ResolvedTarget.existing(triple: TargetPlatform.macOS.triple(architecture: architecture, version: minimum)) } @discardableResult diff --git a/Sources/PackLib/SwiftSDKConfiguration.swift b/Sources/PackLib/SwiftSDKConfiguration.swift index 2e71d7f4..c00ac0ad 100644 --- a/Sources/PackLib/SwiftSDKConfiguration.swift +++ b/Sources/PackLib/SwiftSDKConfiguration.swift @@ -10,7 +10,7 @@ enum SwiftSDKConfiguration { /// SwiftPM's bundle selector requires an exact target triple. A single-target /// destination lets it apply the requested deployment version after decoding. static func destination(in directory: URL, for target: TargetTriple, cacheDirectory: URL) throws -> Destination { - let identity = "\(target.architecture)-apple-macosx" + let identity = target.sdkIdentity let bundles = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) .filter { $0.pathExtension == "artifactbundle" } var matches: [[String: Any]] = [] diff --git a/Sources/PackLib/TargetPlatform.swift b/Sources/PackLib/TargetPlatform.swift index 2b669e31..3be95a38 100644 --- a/Sources/PackLib/TargetPlatform.swift +++ b/Sources/PackLib/TargetPlatform.swift @@ -25,6 +25,15 @@ public enum TargetPlatform: String, Codable, Sendable, CaseIterable { case .macOS: "MacOSX" } } + + /// Without a version this is the SDK identity; with one, the compiler triple. + public func triple(architecture: String, version: String = "") -> String { + switch self { + case .iOS: "\(architecture)-apple-ios\(version)" + case .iOSSimulator: "\(architecture)-apple-ios\(version)-simulator" + case .macOS: "\(architecture)-apple-macosx\(version)" + } + } } /// Parse only supported Apple targets; never infer the target from the host. @@ -55,6 +64,14 @@ public struct TargetTriple: Sendable, Equatable { deploymentTarget = version.isEmpty ? nil : version } + var sdkIdentity: String { + platform.triple(architecture: architecture) + } + + var compilerTriple: String { + platform.triple(architecture: architecture, version: deploymentTarget ?? platform.minimumDeploymentTarget) + } + static func validateVersion(_ value: String) throws { let parts = value.split(separator: ".", omittingEmptySubsequences: false) guard (1...3).contains(parts.count), parts.allSatisfy({ !$0.isEmpty && $0.allSatisfy({ $0.isASCII && $0.isNumber }) }) else { @@ -128,10 +145,6 @@ public struct ResolvedTarget: Codable, Sendable, Equatable { /// Canonical versioned target for compiler invocations. The SDK identity is selected separately. public var compilerTriple: String { - switch platform { - case .iOS: "\(architecture)-apple-ios\(deploymentTarget)" - case .iOSSimulator: "\(architecture)-apple-ios\(deploymentTarget)-simulator" - case .macOS: "\(architecture)-apple-macosx\(deploymentTarget)" - } + platform.triple(architecture: architecture, version: deploymentTarget) } } diff --git a/Sources/XToolSupport/LSPConfig.swift b/Sources/XToolSupport/LSPConfig.swift index 46929071..900ef40c 100644 --- a/Sources/XToolSupport/LSPConfig.swift +++ b/Sources/XToolSupport/LSPConfig.swift @@ -83,12 +83,7 @@ enum LSPConfig { path = legacyLSP.0 let sdkIndex = sdkOptions.firstIndex(of: "--swift-sdk") // Legacy SourceKit uses SwiftPM even when builds default to SwiftBuild. - let sdkPlatform = switch target.platform { - case .iOS: "ios" - case .iOSSimulator: "ios-simulator" - case .macOS: "macosx" - } - let sdk = sdkIndex.map { sdkOptions[$0 + 1] } ?? "\(target.architecture)-apple-\(sdkPlatform)" + let sdk = sdkIndex.map { sdkOptions[$0 + 1] } ?? target.platform.triple(architecture: target.architecture) object = ["swiftPM": ["swiftSDK": sdk, "triple": target.compilerTriple]] } let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) From c99f09e94b19ef2a7f4162995d8214f3635fc7ae Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 20 Oct 2026 07:00:10 +0200 Subject: [PATCH 46/59] packer: share one macOS Swift SDK lookup The linker's SDK version and the generated build destination each parsed swift-sdk.json bundles on their own. SwiftSDKConfiguration.properties now serves both, along with one Swift SDK directory fallback. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/PackLib/BuildSettings.swift | 62 +++++++-------------- Sources/PackLib/SwiftSDKConfiguration.swift | 13 ++++- 2 files changed, 30 insertions(+), 45 deletions(-) diff --git a/Sources/PackLib/BuildSettings.swift b/Sources/PackLib/BuildSettings.swift index 6a43f792..affedb9f 100644 --- a/Sources/PackLib/BuildSettings.swift +++ b/Sources/PackLib/BuildSettings.swift @@ -160,27 +160,14 @@ public struct BuildSettings: Sendable { /// LLD otherwise records the deployment version as the SDK version on Linux. /// Keep this target-only: SwiftPM CLI linker flags also affect host macros. func macOSLinkerArguments() async throws -> [String] { - let sdkRoot: URL - if let custom = swiftSDKsPath { - sdkRoot = try Self.macOSSDKRoot(in: URL(fileURLWithPath: custom), architecture: try TargetTriple(triple).architecture) - } else { - #if os(macOS) - sdkRoot = URL(fileURLWithPath: try await Self.xcrun(["--sdk", "macosx", "--show-sdk-path"])) - #else - guard let sdk = try DarwinSDK.current() else { - throw StringError("No Darwin SDK configured. Please run `xtool setup`.") - } - sdkRoot = try Self.macOSSDKRoot(in: sdk.bundle.deletingLastPathComponent(), - architecture: try TargetTriple(triple).architecture) - #endif - } - let data = try Data(contentsOf: sdkRoot.appendingPathComponent("SDKSettings.json")) + let target = try TargetTriple(compilerTriple) + let settingsURL = try await macOSSDKRoot(for: target).appendingPathComponent("SDKSettings.json") + let data = try Data(contentsOf: settingsURL) guard let settings = try JSONSerialization.jsonObject(with: data) as? [String: Any], let version = settings["Version"] as? String else { - throw StringError("Missing SDK version in \(sdkRoot.path)/SDKSettings.json") + throw StringError("Missing SDK version in \(settingsURL.path)") } try TargetTriple.validateVersion(version) - let target = try TargetTriple(compilerTriple) return ["-Xlinker", "-platform_version", "-Xlinker", "macos", "-Xlinker", target.deploymentTarget ?? target.platform.minimumDeploymentTarget, "-Xlinker", version] } @@ -193,23 +180,22 @@ public struct BuildSettings: Sendable { return nil } - private static func macOSSDKRoot(in directory: URL, architecture: String) throws -> URL { - let bundles = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) - .filter { $0.pathExtension == "artifactbundle" } - var roots: [URL] = [] - for bundle in bundles { - let metadata = bundle.appendingPathComponent("swift-sdk.json") - guard FileManager.default.fileExists(atPath: metadata.path) else { continue } - let data = try Data(contentsOf: metadata) - guard let settings = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let triples = settings["targetTriples"] as? [String: [String: Any]], - let path = triples["\(architecture)-apple-macosx"]?["sdkRootPath"] as? String else { continue } - roots.append(URL(fileURLWithPath: path, relativeTo: bundle).standardizedFileURL) - } - guard roots.count == 1, let root = roots.first else { - throw StringError("Expected exactly one macOS Swift SDK in \(directory.path); found \(roots.count)") + private func macOSSDKRoot(for target: TargetTriple) async throws -> URL { + #if os(macOS) + if swiftSDKsPath == nil { + return URL(fileURLWithPath: try await Self.xcrun(["--sdk", "macosx", "--show-sdk-path"])) } - return root + #endif + let properties = try SwiftSDKConfiguration.properties(in: swiftSDKsDirectory(), for: target) + return URL(fileURLWithPath: try (properties["sdkRootPath"] as? String) + .orThrow(StringError("Missing sdkRootPath for '\(target.sdkIdentity)'"))) + } + + private func swiftSDKsDirectory() throws -> URL { + if let path = swiftSDKsPath { return URL(fileURLWithPath: path, isDirectory: true) } + return try DarwinSDK.current() + .orThrow(StringError("No Darwin SDK configured. Please run `xtool setup`.")) + .bundle.deletingLastPathComponent() } /// Reject combinations whose failure is already known, before a builder @@ -258,15 +244,7 @@ public struct BuildSettings: Sendable { #if !os(macOS) let target = try TargetTriple(triple) if buildSystem == .swiftPM && target.platform == .macOS { - let directory: URL - if let path = swiftSDKsPath { - directory = URL(fileURLWithPath: path, isDirectory: true) - } else { - directory = try DarwinSDK.current() - .orThrow(StringError("No Darwin SDK configured. Please run `xtool setup`.")) - .bundle.deletingLastPathComponent() - } - let destination = try SwiftSDKConfiguration.destination(in: directory, for: target, + let destination = try SwiftSDKConfiguration.destination(in: swiftSDKsDirectory(), for: target, cacheDirectory: swiftSDKCacheDirectory) options = configOptions + ["--destination", destination.url.path, "--triple", compilerTriple] + customOptions environment["PATH"] = (destination.toolSearchPaths + [ProcessInfo.processInfo.environment["PATH"] ?? ""]) diff --git a/Sources/PackLib/SwiftSDKConfiguration.swift b/Sources/PackLib/SwiftSDKConfiguration.swift index c00ac0ad..e880cc0e 100644 --- a/Sources/PackLib/SwiftSDKConfiguration.swift +++ b/Sources/PackLib/SwiftSDKConfiguration.swift @@ -7,9 +7,8 @@ enum SwiftSDKConfiguration { let toolSearchPaths: [String] } - /// SwiftPM's bundle selector requires an exact target triple. A single-target - /// destination lets it apply the requested deployment version after decoding. - static func destination(in directory: URL, for target: TargetTriple, cacheDirectory: URL) throws -> Destination { + /// The `targetTriples` entry of the one macOS Swift SDK in `directory`, with paths made absolute. + static func properties(in directory: URL, for target: TargetTriple) throws -> [String: Any] { let identity = target.sdkIdentity let bundles = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) .filter { $0.pathExtension == "artifactbundle" } @@ -36,6 +35,14 @@ enum SwiftSDKConfiguration { guard matches.count == 1, let properties = matches.first else { throw StringError("Expected one Swift SDK for '\(identity)' in '\(directory.path)'; found \(matches.count)") } + return properties + } + + /// SwiftPM's bundle selector requires an exact target triple. A single-target + /// destination lets it apply the requested deployment version after decoding. + static func destination(in directory: URL, for target: TargetTriple, cacheDirectory: URL) throws -> Destination { + let identity = target.sdkIdentity + let properties = try properties(in: directory, for: target) let data = try JSONSerialization.data(withJSONObject: [ "schemaVersion": "4.0", "targetTriples": [identity: properties], ], options: [.sortedKeys]) From 73f06f954ef0c86291c44e493526395b1bbc6697 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 20 Oct 2026 07:00:10 +0200 Subject: [PATCH 47/59] packer: validate single path components in one place ResourceDestination.validateComponent replaces six copies of validateRelativePath plus a separator check. BundleAssembler reuses BundleLayout.joined instead of its own copy. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/PackLib/BundleAssembler.swift | 17 ++++++----------- Sources/PackLib/BundleLayout.swift | 8 +++++++- Sources/PackLib/MacBundleAudit.swift | 3 +-- Sources/PackLib/PackSchema.swift | 5 +---- Sources/PackLib/Packer.swift | 3 +-- Sources/PackLib/Planner.swift | 3 +-- Sources/PackLib/ProductBuilder.swift | 14 ++++---------- 7 files changed, 21 insertions(+), 32 deletions(-) diff --git a/Sources/PackLib/BundleAssembler.swift b/Sources/PackLib/BundleAssembler.swift index 31bfdb1e..20beed9b 100644 --- a/Sources/PackLib/BundleAssembler.swift +++ b/Sources/PackLib/BundleAssembler.swift @@ -75,7 +75,7 @@ public struct BundleAssembler: Sendable { } if let icon = product.iconPath { let source = sourceURL(icon) - let path = joined(layout.resourcesPath, source.lastPathComponent) + let path = layout.joined(layout.resourcesPath, source.lastPathComponent) assembly.operations.append(.init(path: path, payload: .copy(source))) } } @@ -91,14 +91,14 @@ public struct BundleAssembler: Sendable { try validateName(package) try validateName(target) let name = "\(package)_\(target).bundle" - let path = joined(layout.resourcesPath, name) + let path = layout.joined(layout.resourcesPath, name) appendDependency(.init(path: path, payload: .copy(artifacts.directory.appendingPathComponent(name)), allowsRawAssets: true), product: product, to: &assembly) if product.platform == .macOS && product.type == .helperExecutable { // Bare helpers retain SwiftPM's executable-directory lookup. Real bundles use // the resourceURL accessor compiled by ProductBuilder; root aliases cannot be signed. - appendDependency(.init(path: joined(layout.contentsPath, name), + appendDependency(.init(path: layout.joined(layout.contentsPath, name), payload: .symlink("../Resources/\(name)")), product: product, to: &assembly) } @@ -125,7 +125,7 @@ public struct BundleAssembler: Sendable { throw StringError("Framework '\(name)' has no readable binary at \(binary.path)") } if try !isArchive(binary) { - appendDependency(.init(path: joined(layout.frameworksPath, source.lastPathComponent), payload: .copy(source)), + appendDependency(.init(path: layout.joined(layout.frameworksPath, source.lastPathComponent), payload: .copy(source)), product: product, to: &assembly) } case .library(let name): @@ -133,7 +133,7 @@ public struct BundleAssembler: Sendable { let source = artifacts.directory.appendingPathComponent("lib\(name).dylib") try validateInput(source) if try !isArchive(source) { - appendDependency(.init(path: joined(layout.frameworksPath, source.lastPathComponent), payload: .copy(source)), + appendDependency(.init(path: layout.joined(layout.frameworksPath, source.lastPathComponent), payload: .copy(source)), product: product, to: &assembly) } case .root(let path): @@ -246,13 +246,8 @@ public struct BundleAssembler: Sendable { } private func validateName(_ name: String) throws { - try ResourceDestination.validateRelativePath(name) - guard !name.contains("/") else { throw StringError("Bundle input names must not contain path separators: '\(name)'") } + try ResourceDestination.validateComponent(name, "Bundle input name") } private func canonicalPath(_ path: String) -> String { path.precomposedStringWithCanonicalMapping.lowercased() } - - private func joined(_ base: String, _ path: String) -> String { - [base, path].filter { !$0.isEmpty }.joined(separator: "/") - } } diff --git a/Sources/PackLib/BundleLayout.swift b/Sources/PackLib/BundleLayout.swift index c66478c8..ad9e6e6d 100644 --- a/Sources/PackLib/BundleLayout.swift +++ b/Sources/PackLib/BundleLayout.swift @@ -54,7 +54,7 @@ public struct BundleLayout: Sendable, Equatable { return joined(base, destination.path) } - private func joined(_ base: String, _ path: String) -> String { + func joined(_ base: String, _ path: String) -> String { [base, path].filter { !$0.isEmpty }.joined(separator: "/") } } @@ -86,6 +86,12 @@ public struct ResourceDestination: Codable, Sendable, Hashable { throw StringError("Bundle destination must be a safe relative path: '\(path)'") } } + + /// One safe path component, such as a product or file name. + static func validateComponent(_ name: String, _ description: String) throws { + try validateRelativePath(name) + guard !name.contains("/") else { throw StringError("\(description) must be a single path component: '\(name)'") } + } } public struct ResourceCopy: Codable, Sendable, Hashable { diff --git a/Sources/PackLib/MacBundleAudit.swift b/Sources/PackLib/MacBundleAudit.swift index 82e2cd8e..009f7863 100644 --- a/Sources/PackLib/MacBundleAudit.swift +++ b/Sources/PackLib/MacBundleAudit.swift @@ -97,8 +97,7 @@ public enum MacBundleAudit { if let plist = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) }) { let info = try metadata(at: plist) if let name = info["CFBundleExecutable"] as? String { - try ResourceDestination.validateRelativePath(name) - guard !name.contains("/") else { throw StringError("Invalid framework executable name") } + try ResourceDestination.validateComponent(name, "Framework executable name") let expected = version.appendingPathComponent(name).resolvingSymlinksInPath() // A helper's directory is not a framework principal location. let principalDirectory = parts.count == index + 2 || diff --git a/Sources/PackLib/PackSchema.swift b/Sources/PackLib/PackSchema.swift index ad76a4a4..839f82b9 100644 --- a/Sources/PackLib/PackSchema.swift +++ b/Sources/PackLib/PackSchema.swift @@ -141,10 +141,7 @@ public struct PackSchema: Sendable { } private static func validateName(_ name: String) throws { - try ResourceDestination.validateRelativePath(name) - guard !name.contains("/") else { - throw StringError("xtool.yml: product name must be a single path component") - } + try ResourceDestination.validateComponent(name, "xtool.yml: product name") } /// Validate against the effective CLI/schema target, after precedence is applied. diff --git a/Sources/PackLib/Packer.swift b/Sources/PackLib/Packer.swift index 2e9de549..f53c112b 100644 --- a/Sources/PackLib/Packer.swift +++ b/Sources/PackLib/Packer.swift @@ -20,8 +20,7 @@ public struct Packer: Sendable { /// Assembly consumes completed build artifacts and never compiles or signs. public func assemble(artifacts: BuildArtifacts) async throws -> URL { - try ResourceDestination.validateRelativePath(plan.app.product) - guard !plan.app.product.contains("/") else { throw StringError("Application name must not contain path separators") } + try ResourceDestination.validateComponent(plan.app.product, "Application name") let output = try TemporaryDirectory(name: "\(plan.app.product).app") let root = URL(fileURLWithPath: buildSettings.packagePath, isDirectory: true).standardizedFileURL let assembler = BundleAssembler(plan: plan, sourceDirectory: root) diff --git a/Sources/PackLib/Planner.swift b/Sources/PackLib/Planner.swift index 36408ce4..0486e6ae 100644 --- a/Sources/PackLib/Planner.swift +++ b/Sources/PackLib/Planner.swift @@ -210,8 +210,7 @@ public struct Planner: Sendable { } private func validateProductName(_ name: String) throws { - try ResourceDestination.validateRelativePath(name) - guard !name.contains("/") else { throw StringError("Product name must be a single path component") } + try ResourceDestination.validateComponent(name, "Product name") } private struct TargetIdentity: Hashable { diff --git a/Sources/PackLib/ProductBuilder.swift b/Sources/PackLib/ProductBuilder.swift index 508f7ba4..79f25a76 100644 --- a/Sources/PackLib/ProductBuilder.swift +++ b/Sources/PackLib/ProductBuilder.swift @@ -227,8 +227,8 @@ public struct ProductBuilder: Sendable { && $0.SupportedPlatformVariant == variant && $0.SupportedArchitectures.contains(target.architecture) }), - library.LibraryPath.hasSuffix(".a"), !library.LibraryPath.contains("/"), - (try? ResourceDestination.validateRelativePath(library.LibraryPath)) != nil else { continue } + library.LibraryPath.hasSuffix(".a"), + (try? ResourceDestination.validateComponent(library.LibraryPath, "Library path")) != nil else { continue } return directory.appendingPathComponent(library.LibraryPath) } return directory.appendingPathComponent("lib\(name).a") @@ -272,10 +272,7 @@ public struct ProductBuilder: Sendable { } for product in plan.allProducts { try product.resolvedSource.validate() - try ResourceDestination.validateRelativePath(product.product) - guard !product.product.contains("/") else { - throw StringError("Product names must not contain path separators") - } + try ResourceDestination.validateComponent(product.product, "Product name") switch product.resolvedSource { case .prebuilt: if product.platform == .macOS && product.type != .helperExecutable @@ -286,10 +283,7 @@ public struct ProductBuilder: Sendable { case .executable where buildSettings.buildSystem != .swiftPM: throw StringError("Direct executable products currently require the SwiftPM backend") case .library(let name), .executable(let name): - try ResourceDestination.validateRelativePath(name) - guard !name.contains("/") else { - throw StringError("SwiftPM product names must not contain path separators") - } + try ResourceDestination.validateComponent(name, "SwiftPM product name") } } } From 177f3c0aa47f76f6d0f2b45c18bdbb66128109e3 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 20 Oct 2026 07:00:10 +0200 Subject: [PATCH 48/59] mac: reuse MachOBinary's CPU and platform constants in the bundle audit MacBundleAudit hard-coded the arm64/x86_64 CPU types and the macOS platform number that MachOBinary owns; an unsupported architecture fell through to x86_64 instead of throwing. MachOBinary.platformType names the platform numbers once. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/PackLib/MacBundleAudit.swift | 5 +++-- Sources/PackLib/ProductBuilder.swift | 14 +++++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/Sources/PackLib/MacBundleAudit.swift b/Sources/PackLib/MacBundleAudit.swift index 009f7863..cf37cc01 100644 --- a/Sources/PackLib/MacBundleAudit.swift +++ b/Sources/PackLib/MacBundleAudit.swift @@ -48,6 +48,8 @@ public enum MacBundleAudit { let inventory = try ArtifactInventory(directory: root, hashContents: false) var code: [String: MachOBinary.Slice] = [:] var signing: Set = [] + let cpu = try MachOBinary.cpuType(for: target.architecture) + let platform = MachOBinary.platformType(for: .macOS) for (path, entry) in inventory.files where entry.kind == "file" { let url = root.appendingPathComponent(path) let handle = try FileHandle(forReadingFrom: url) @@ -56,9 +58,8 @@ public enum MacBundleAudit { guard [Data([0xcf, 0xfa, 0xed, 0xfe]), Data([0xca, 0xfe, 0xba, 0xbe]), Data([0xbe, 0xba, 0xfe, 0xca])].contains(prefix) else { continue } let slices = try MachOBinary.inspect([UInt8](Data(contentsOf: url))) - let cpu: UInt32 = target.architecture == "arm64" ? 0x0100000c : 0x01000007 guard let slice = slices.first(where: { $0.cpu == cpu }), [2, 6, 8].contains(slice.fileType), - slice.platform == 1, slice.minimumOS <= MachOBinary.packedVersion(target.deploymentTarget) else { + slice.platform == platform, slice.minimumOS <= MachOBinary.packedVersion(target.deploymentTarget) else { throw StringError("Incompatible nested code: \(path)") } code[path] = slice diff --git a/Sources/PackLib/ProductBuilder.swift b/Sources/PackLib/ProductBuilder.swift index 79f25a76..b4796479 100644 --- a/Sources/PackLib/ProductBuilder.swift +++ b/Sources/PackLib/ProductBuilder.swift @@ -496,11 +496,7 @@ struct MachOBinary { let matching = slices.filter { $0.cpu == cpu } guard !matching.isEmpty else { throw StringError("Wrong architecture; expected \(target.architecture)") } let archive = matching.allSatisfy(\.archiveMember) - let platform: UInt32 = switch target.platform { - case .macOS: 1 - case .iOS: 2 - case .iOSSimulator: 7 - } + let platform = platformType(for: target.platform) for slice in matching { guard slice.platform == platform else { throw StringError("Wrong or missing Mach-O platform; expected \(target.platform.rawValue)") @@ -520,6 +516,14 @@ struct MachOBinary { } } + static func platformType(for platform: TargetPlatform) -> UInt32 { + switch platform { + case .macOS: 1 + case .iOS: 2 + case .iOSSimulator: 7 + } + } + static func cpuType(for architecture: String) throws -> UInt32 { switch architecture { case "arm64": return 0x0100000c From d9faa36611e79b2fd263af22c3c273549bc63bfb Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 20 Oct 2026 07:10:29 +0200 Subject: [PATCH 49/59] dev: reject provisioning-dependent entitlements when recording the handoff A deferred build accepted any entitlement dictionary, and restricted keys (application identifiers, team identifiers, keychain and app groups, com.apple.developer.*) only failed later, at `dev sign`, often on a different machine. DeferredSigningHandoff now applies the signer's MacSigningPlan.validateEntitlements while the build records its inputs, so the build fails before it publishes a handoff. The signer keeps its own check for handoffs edited after the build. Co-Authored-By: Claude Opus 5.5 (1M context) --- Documentation/xtool.docc/Configuration.md | 3 ++- README.md | 3 ++- Sources/XToolSupport/DevCommand.swift | 7 +++++-- Tests/XToolTests/DevCommandTests.swift | 10 ++++++++-- Tests/XToolTests/MacSigningTests.swift | 12 +++++++++++- 5 files changed, 28 insertions(+), 7 deletions(-) diff --git a/Documentation/xtool.docc/Configuration.md b/Documentation/xtool.docc/Configuration.md index bcd10055..f22325ac 100644 --- a/Documentation/xtool.docc/Configuration.md +++ b/Documentation/xtool.docc/Configuration.md @@ -125,4 +125,5 @@ Caller plist values override generated defaults, including identifiers. Ensure `CFBundleExecutable`, bundle kind and minimum OS agree with the actual product; xtool does not translate an Xcode project's build settings or extension policy. Deferred signing preserves entitlement dictionaries and their product associations -without approving or applying them. +without applying them. It rejects provisioning-dependent entitlements, which the signer +cannot apply. diff --git a/README.md b/README.md index 4641bc53..2d91dace 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,8 @@ xtool dev sign xtool/MyApp.app --output release-signed --mode developer-id --ide `development` selects an Apple Development certificate; `developer-id` requires a Developer ID Application certificate, hardened runtime and a secure timestamp. The signer checks the actual certificate fingerprint and category. It does not select identities, -create certificates or provision capabilities. Provisioning-dependent entitlements are rejected. +create certificates or provision capabilities. Provisioning-dependent entitlements are rejected +when `dev build --defer-signing` records the handoff, and again by the signer. Signing works on a private copy, applies each product's own entitlements in nested-code order, and verifies signatures and effective entitlements before publishing. Input files remain unchanged; diff --git a/Sources/XToolSupport/DevCommand.swift b/Sources/XToolSupport/DevCommand.swift index 318412dc..d5b78d33 100644 --- a/Sources/XToolSupport/DevCommand.swift +++ b/Sources/XToolSupport/DevCommand.swift @@ -441,8 +441,11 @@ enum DeferredSigningHandoff { guard let path = manifest.products[index].entitlementsPath else { continue } let source = URL(fileURLWithPath: path, relativeTo: sourceDirectory) let data = try Data(contentsOf: source) - guard try PropertyListSerialization.propertyList(from: data, options: [], format: nil) is [String: Any] else { - throw Console.Error("Entitlements must be a property-list dictionary: \(path)") + // Fail here, on the build machine, rather than later at signing. + do { + try MacSigningPlan.validateEntitlements(data) + } catch { + throw Console.Error("\(path): \(error)") } let filename = "\(index)-\(ArtifactInventory.hash(data: data)).entitlements.plist" files.append((filename, data)) diff --git a/Tests/XToolTests/DevCommandTests.swift b/Tests/XToolTests/DevCommandTests.swift index f4756193..9c34ff00 100644 --- a/Tests/XToolTests/DevCommandTests.swift +++ b/Tests/XToolTests/DevCommandTests.swift @@ -333,10 +333,16 @@ struct DevCommandTests { #expect(try Data(contentsOf: directory.appendingPathComponent(currentPath)) == second) } - @Test func malformedEntitlementsRejectWithoutWritingHandoff() throws { + @Test(arguments: ["malformed", "array", "com.apple.developer.homekit", "keychain-access-groups"]) + func unsupportedEntitlementsRejectWithoutWritingHandoff(entitlement: String) throws { let directory = try root() defer { try? FileManager.default.removeItem(at: directory) } - try Data("not a plist".utf8).write(to: directory.appendingPathComponent("bad.plist")) + let data = switch entitlement { + case "malformed": Data("not a plist".utf8) + case "array": try PropertyListSerialization.data(fromPropertyList: ["array"], format: .xml, options: 0) + default: try PropertyListSerialization.data(fromPropertyList: [entitlement: true], format: .xml, options: 0) + } + try data.write(to: directory.appendingPathComponent("bad.plist")) let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macosx15.0") let plan = try Plan(target: target, app: product("App", kind: .application, target: target, entitlements: "bad.plist")) #expect(throws: (any Error).self) { diff --git a/Tests/XToolTests/MacSigningTests.swift b/Tests/XToolTests/MacSigningTests.swift index 11d6be84..6b9b05d2 100644 --- a/Tests/XToolTests/MacSigningTests.swift +++ b/Tests/XToolTests/MacSigningTests.swift @@ -33,9 +33,19 @@ struct MacSigningTests { } #expect(throws: (any Error).self) { try MacSigningPlan.validateIdentity(mode: .adHoc, identity: "-") } try MacSigningPlan.validateIdentity(mode: .development, identity: String(repeating: "a", count: 40)) + let restricted = ["com.apple.developer.icloud-services": ["CloudDocuments"]] + let rejected = try root() + defer { try? FileManager.default.removeItem(at: rejected) } + #expect(throws: (any Error).self) { try handoff(rejected, entitlements: restricted) } + // The signer still rejects restricted entitlements in a handoff edited after the build. let directory = try root() defer { try? FileManager.default.removeItem(at: directory) } - let app = try handoff(directory, entitlements: ["com.apple.developer.icloud-services": ["CloudDocuments"]]) + let app = try handoff(directory, entitlements: ["com.apple.security.app-sandbox": true]) + let manifestURL = MacArtifact.signingManifestURL(for: app) + let manifest = try JSONDecoder().decode(DeferredSigningManifest.self, from: Data(contentsOf: manifestURL)) + let input = directory.appendingPathComponent(try #require(manifest.products.first?.entitlementsPath)) + try PropertyListSerialization.data(fromPropertyList: restricted, format: .xml, options: 0).write(to: input) + try MacArtifact.write(bundle: app, signingManifest: manifestURL) #expect(throws: (any Error).self) { try MacSigningPlan.make(bundle: app, mode: .adHoc, identity: nil) } } From 553cdb87c6c501190ad0f41cbc354b8969573441 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 20 Oct 2026 07:10:42 +0200 Subject: [PATCH 50/59] packer: accept internal symlinks under a symlinked package path BundleAssembler compared each symlink's resolved target against the unresolved input root, so a framework's own relative links (Versions/Current, the top-level binary) were rejected as escaping whenever the package path ran through a symlinked directory. macOS masked this for /var -> /private/var only because resolvingSymlinksInPath strips /private. ArtifactInventory already resolved its root; both now share ArtifactInventory.isContainedSymlink, which resolves both sides. The new test fails before this change with "Broken or escaping symbolic link in bundle input". Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/PackLib/ArtifactInventory.swift | 12 +++++++++--- Sources/PackLib/BundleAssembler.swift | 5 +---- Tests/XToolTests/BundleAssemblerTests.swift | 17 +++++++++++++++++ 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/Sources/PackLib/ArtifactInventory.swift b/Sources/PackLib/ArtifactInventory.swift index 0f475915..2d1f6612 100644 --- a/Sources/PackLib/ArtifactInventory.swift +++ b/Sources/PackLib/ArtifactInventory.swift @@ -14,6 +14,14 @@ public struct ArtifactInventory: Codable, Sendable, Equatable { public var link: String? } + /// A link is safe when it is relative and resolves to an existing item inside `root`. + /// Both sides resolve symlinks, so a symlinked parent of `root` cannot fail the comparison. + static func isContainedSymlink(_ url: URL, destination link: String, root: URL) -> Bool { + let root = root.resolvingSymlinksInPath().standardizedFileURL.path + let resolved = url.resolvingSymlinksInPath().standardizedFileURL.path + return !link.hasPrefix("/") && resolved.hasPrefix(root + "/") && FileManager.default.fileExists(atPath: resolved) + } + public init(directory: URL, hashContents: Bool = true) throws { let manager = FileManager.default let root = directory.resolvingSymlinksInPath().standardizedFileURL @@ -25,9 +33,7 @@ public struct ArtifactInventory: Codable, Sendable, Equatable { let values = try url.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey, .isSymbolicLinkKey]) if values.isSymbolicLink == true { let link = try manager.destinationOfSymbolicLink(atPath: url.path) - let resolved = url.resolvingSymlinksInPath().standardizedFileURL - guard !link.hasPrefix("/"), resolved.path.hasPrefix(root.path + "/"), - manager.fileExists(atPath: resolved.path) else { + guard Self.isContainedSymlink(url, destination: link, root: root) else { throw StringError("Broken or escaping artifact symlink: \(path)") } entries[path] = Entry(kind: "symlink", executable: false, link: link) diff --git a/Sources/PackLib/BundleAssembler.swift b/Sources/PackLib/BundleAssembler.swift index 20beed9b..a63ba6ba 100644 --- a/Sources/PackLib/BundleAssembler.swift +++ b/Sources/PackLib/BundleAssembler.swift @@ -224,10 +224,7 @@ public struct BundleAssembler: Sendable { } if try entry.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink == true { let link = try FileManager.default.destinationOfSymbolicLink(atPath: entry.path) - let resolved = entry.resolvingSymlinksInPath().standardizedFileURL.path - let rootPath = root.standardizedFileURL.path - guard !link.hasPrefix("/"), resolved.hasPrefix(rootPath + "/"), - FileManager.default.fileExists(atPath: resolved) else { + guard ArtifactInventory.isContainedSymlink(entry, destination: link, root: root) else { throw StringError("Broken or escaping symbolic link in bundle input: \(entry.path) -> \(link)") } } diff --git a/Tests/XToolTests/BundleAssemblerTests.swift b/Tests/XToolTests/BundleAssemblerTests.swift index a8d75b76..3c0c09d9 100644 --- a/Tests/XToolTests/BundleAssemblerTests.swift +++ b/Tests/XToolTests/BundleAssemblerTests.swift @@ -107,6 +107,23 @@ struct BundleAssemblerTests { return framework } + @Test func internalSymlinksAreAcceptedUnderASymlinkedPackagePath() throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let real = root.appendingPathComponent("real", isDirectory: true) + try FileManager.default.createDirectory(at: real, withIntermediateDirectories: true) + let linked = root.appendingPathComponent("linked", isDirectory: true) + try FileManager.default.createSymbolicLink(at: linked, withDestinationURL: real) + let app = try product(resources: [.binaryTarget(name: "Dynamic")]) + let plan = try Plan(target: app.target, app: app) + let artifacts = try artifacts(plan, in: linked) + _ = try framework("Dynamic", in: artifacts.directory) + let output = root.appendingPathComponent("App.app") + try BundleAssembler(plan: plan, sourceDirectory: linked).assemble(artifacts: artifacts, at: output) + let binary = output.appendingPathComponent("Contents/Frameworks/Dynamic.framework/Dynamic") + #expect(FileManager.default.fileExists(atPath: binary.path)) + } + @Test func frameworksStayIntactAndArchivesAreExcluded() throws { let root = try root() defer { try? FileManager.default.removeItem(at: root) } From e1dfd87193d7351d01d3bbe1ba6d1b90c686d9c9 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 20 Oct 2026 07:10:42 +0200 Subject: [PATCH 51/59] mac: run host tools through one MacHostClient Signing, notarization and `dev run` each declared a dependency client with its own copy of the macOS host check and a Subprocess wrapper. MacHostClient now owns the host check and the three ways these commands run tools: - capture: both streams captured; codesign reports metadata on stderr. - stream: stdout captured while notarytool and stapler progress reaches the terminal, as before. - launch: an app attached to the terminal, returning its exit status. MacNotarizationClient keeps only verifySignature, the seam tests use in place of native verification of genuinely signed apps. Unset members default to throwing, so tests stub just what they exercise. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/XToolSupport/MacHostClient.swift | 72 +++++++++++++++++++ .../MacNotarizationOperation.swift | 51 +++++-------- Sources/XToolSupport/MacRunOperation.swift | 40 +---------- .../XToolSupport/MacSigningOperation.swift | 51 ++----------- Tests/XToolTests/DevCommandTests.swift | 2 +- Tests/XToolTests/MacArtifactTests.swift | 4 +- Tests/XToolTests/MacNotarizationTests.swift | 19 ++--- Tests/XToolTests/MacSigningTests.swift | 23 +++--- 8 files changed, 122 insertions(+), 140 deletions(-) create mode 100644 Sources/XToolSupport/MacHostClient.swift diff --git a/Sources/XToolSupport/MacHostClient.swift b/Sources/XToolSupport/MacHostClient.swift new file mode 100644 index 00000000..14c03cf6 --- /dev/null +++ b/Sources/XToolSupport/MacHostClient.swift @@ -0,0 +1,72 @@ +import Dependencies +import Foundation +import PackLib +import Subprocess +import XUtils + +struct MacToolOutput: Sendable { + var output: Data + var error: Data +} + +/// The Mac host tools that signing, notarization and `dev run` invoke. +struct MacHostClient: DependencyKey, Sendable { + var isMacOS: @Sendable () -> Bool = { false } + /// Runs a tool to completion, capturing both streams; codesign reports metadata on stderr. + var capture: @Sendable (String, [String]) async throws -> MacToolOutput = { tool, _ in + throw StringError("Unexpected host tool invocation: \(tool)") + } + /// Runs a tool to completion, capturing stdout while stderr progress reaches the terminal. + var stream: @Sendable (String, [String]) async throws -> Data = { tool, _ in + throw StringError("Unexpected host tool invocation: \(tool)") + } + /// Runs an app attached to this terminal and returns its exit status. + var launch: @Sendable (URL, [String]) async throws -> Int32 = { _, _ in throw StringError("Unexpected Mac launch") } + + static let liveValue = Self( + isMacOS: { + #if os(macOS) + true + #else + false + #endif + }, + capture: { executable, arguments in + let result = try await Subprocess.run( + .path(.init(executable)), arguments: .init(arguments), + platformOptions: .withGracefulShutDown, output: .data(limit: .max), error: .data(limit: .max) + ) + do { try result.checkSuccess() } catch { + throw StringError("\(executable) failed: \(String(decoding: result.standardError, as: UTF8.self))") + } + return MacToolOutput(output: result.standardOutput, error: result.standardError) + }, + stream: { executable, arguments in + let result = try await Subprocess.run( + .path(.init(executable)), arguments: .init(arguments), + output: .string(limit: .max), error: .currentStandardError + ).checkSuccess() + return Data(result.standardOutput.utf8) + }, + launch: { executable, arguments in + let result = try await Subprocess.run( + .path(.init(executable.path)), arguments: .init(arguments), + platformOptions: .withGracefulShutDown, + output: .currentStandardOutput, error: .currentStandardError + ) + switch result.terminationStatus { + case .exited(let code): return code + case .signaled(let signal): return 128 + signal + } + } + ) + + static let testValue = Self() +} + +extension DependencyValues { + var macHostClient: MacHostClient { + get { self[MacHostClient.self] } + set { self[MacHostClient.self] = newValue } + } +} diff --git a/Sources/XToolSupport/MacNotarizationOperation.swift b/Sources/XToolSupport/MacNotarizationOperation.swift index e900a5e0..9e3018a5 100644 --- a/Sources/XToolSupport/MacNotarizationOperation.swift +++ b/Sources/XToolSupport/MacNotarizationOperation.swift @@ -1,7 +1,6 @@ import Dependencies import Foundation import PackLib -import Subprocess import XUtils #if canImport(Darwin) @@ -10,33 +9,14 @@ import Darwin import Glibc #endif +/// Tests replace native signature verification, which needs genuinely signed apps. struct MacNotarizationClient: DependencyKey, Sendable { - var isMacOS: @Sendable () -> Bool - var run: @Sendable (String, [String]) async throws -> Data var verifySignature: @Sendable (URL, MacSigningReceipt) async throws -> Void static let liveValue = Self( - isMacOS: { - #if os(macOS) - true - #else - false - #endif - }, - run: { executable, arguments in - let result = try await Subprocess.run( - .path(.init(executable)), arguments: .init(arguments), - output: .string(limit: .max), error: .currentStandardError - ).checkSuccess() - return Data(result.standardOutput.utf8) - }, verifySignature: { _ = try await MacSigningOperation.verify(bundle: $0, receipt: $1, verifyInventory: false) } ) - static let testValue = Self( - isMacOS: { false }, - run: { _, _ in throw StringError("Unexpected notarization command") }, - verifySignature: { _, _ in throw StringError("Unexpected signing verification") } - ) + static let testValue = Self(verifySignature: { _, _ in throw StringError("Unexpected signing verification") }) } extension DependencyValues { @@ -62,8 +42,8 @@ struct MacNotarizationReceipt: Codable, Sendable { enum MacNotarizationOperation { static func requireHost() throws { - @Dependency(\.macNotarizationClient) var client - guard client.isMacOS() else { throw StringError("Notarization requires a Mac") } + @Dependency(\.macHostClient) var host + guard host.isMacOS() else { throw StringError("Notarization requires a Mac") } } static func authentication(_ profile: String?) throws -> [String] { @@ -85,6 +65,7 @@ enum MacNotarizationOperation { let receipt = try MacSigningReceipt.verify(bundle: bundle) guard receipt.mode == .developerID else { throw StringError("Notarization requires Developer ID signing") } @Dependency(\.macNotarizationClient) var client + @Dependency(\.macHostClient) var host try await client.verifySignature(bundle, receipt) // Creation is exclusive. An existing output, including an interrupted attempt, is never resubmitted. guard mkdir(directory.path, 0o700) == 0 else { @@ -101,14 +82,14 @@ enum MacNotarizationOperation { let copied = try MacSigningReceipt.verify(bundle: app) try await client.verifySignature(app, copied) let upload = directory.appendingPathComponent("upload.zip") - _ = try await client.run("/usr/bin/ditto", ["-c", "-k", "--keepParent", "--sequesterRsrc", app.path, upload.path]) + _ = try await host.stream("/usr/bin/ditto", ["-c", "-k", "--keepParent", "--sequesterRsrc", app.path, upload.path]) var state = MacNotarizationReceipt( bundleName: app.lastPathComponent, signingReceiptSHA256: try ArtifactInventory.hash(MacSigningReceipt.receiptURL(for: app)), uploadSHA256: try ArtifactInventory.hash(upload), status: "submission-started" ) try append(&state, directory: directory) - let response = try await client.run("/usr/bin/xcrun", ["notarytool", "submit", upload.path, + let response = try await host.stream("/usr/bin/xcrun", ["notarytool", "submit", upload.path, "--no-wait", "--output-format", "json"] + auth) let value = try JSONDecoder().decode(Submission.self, from: response) guard UUID(uuidString: value.id) != nil else { throw StringError("Invalid notarization submission UUID") } @@ -141,10 +122,10 @@ enum MacNotarizationOperation { throw StringError("Submission outcome is unknown; use --submission-id with a completed matching Apple log. " + "Do not resubmit.") } - @Dependency(\.macNotarizationClient) var client + @Dependency(\.macHostClient) var host var arguments = ["notarytool", wait ? "wait" : "info", id, "--output-format", "json"] + auth if wait { arguments += ["--timeout", "\(timeout)s"] } - let response = try await client.run("/usr/bin/xcrun", arguments) + let response = try await host.stream("/usr/bin/xcrun", arguments) let value = try JSONDecoder().decode(Submission.self, from: response) guard value.id.lowercased() == id.lowercased(), let status = value.status, ["Accepted", "Invalid", "In Progress", "Rejected"].contains(status) else { @@ -207,13 +188,14 @@ enum MacNotarizationOperation { let app = work.appendingPathComponent(state.bundleName) try FileManager.default.copyItem(at: source, to: app) @Dependency(\.macNotarizationClient) var client + @Dependency(\.macHostClient) var host let signing = try MacSigningReceipt.verify(bundle: source) try await client.verifySignature(app, signing) - _ = try await client.run("/usr/bin/xcrun", ["stapler", "staple", app.path]) - _ = try await client.run("/usr/bin/xcrun", ["stapler", "validate", app.path]) + _ = try await host.stream("/usr/bin/xcrun", ["stapler", "staple", app.path]) + _ = try await host.stream("/usr/bin/xcrun", ["stapler", "validate", app.path]) try await client.verifySignature(app, signing) let zip = work.appendingPathComponent("distribution.zip") - _ = try await client.run("/usr/bin/ditto", ["-c", "-k", "--keepParent", "--sequesterRsrc", app.path, zip.path]) + _ = try await host.stream("/usr/bin/ditto", ["-c", "-k", "--keepParent", "--sequesterRsrc", app.path, zip.path]) state.distributionInventory = try ArtifactInventory(directory: app) state.distributionSHA256 = try ArtifactInventory.hash(zip) state.status = "stapled" @@ -248,8 +230,9 @@ enum MacNotarizationOperation { } let signing = try MacSigningReceipt.verify(bundle: signedApp(state, directory: directory)) @Dependency(\.macNotarizationClient) var client + @Dependency(\.macHostClient) var host try await client.verifySignature(app, signing) - _ = try await client.run("/usr/bin/xcrun", ["stapler", "validate", app.path]) + _ = try await host.stream("/usr/bin/xcrun", ["stapler", "validate", app.path]) } /// The stored log must still match its recorded hash, bind this archive, and be accepted. @@ -275,8 +258,8 @@ enum MacNotarizationOperation { private static func fetchLog(_ state: MacNotarizationReceipt, id: String, directory: URL, authentication: [String]) async throws -> NotaryLog { - @Dependency(\.macNotarizationClient) var client - let data = try await client.run("/usr/bin/xcrun", ["notarytool", "log", id] + authentication) + @Dependency(\.macHostClient) var host + let data = try await host.stream("/usr/bin/xcrun", ["notarytool", "log", id] + authentication) let log = try JSONDecoder().decode(NotaryLog.self, from: data) try validateLog(log, state: state, id: id) let destination = logURL(directory) diff --git a/Sources/XToolSupport/MacRunOperation.swift b/Sources/XToolSupport/MacRunOperation.swift index a357ecdf..952b9827 100644 --- a/Sources/XToolSupport/MacRunOperation.swift +++ b/Sources/XToolSupport/MacRunOperation.swift @@ -2,47 +2,11 @@ import ArgumentParser import Dependencies import Foundation import PackLib -import Subprocess import XUtils -struct MacRunClient: DependencyKey, Sendable { - var isMacOS: @Sendable () -> Bool - var launch: @Sendable (URL, [String]) async throws -> Int32 - - static let liveValue = Self( - isMacOS: { - #if os(macOS) - true - #else - false - #endif - }, - launch: { executable, arguments in - let result = try await Subprocess.run( - .path(.init(executable.path)), arguments: .init(arguments), - platformOptions: .withGracefulShutDown, - output: .currentStandardOutput, error: .currentStandardError - ) - switch result.terminationStatus { - case .exited(let code): return code - case .signaled(let signal): return 128 + signal - } - } - ) - - static let testValue = Self(isMacOS: { false }, launch: { _, _ in throw StringError("Unexpected Mac launch") }) -} - -extension DependencyValues { - var macRunClient: MacRunClient { - get { self[MacRunClient.self] } - set { self[MacRunClient.self] = newValue } - } -} - enum MacRunOperation { static func requireHost() throws { - @Dependency(\.macRunClient) var client + @Dependency(\.macHostClient) var client guard client.isMacOS() else { throw Console.Error("macOS apps must run on a Mac; transfer the app and its handoff files to a Mac first") } @@ -61,7 +25,7 @@ enum MacRunOperation { let target = try MacBundleAudit.runTarget(bundle: bundle, hostArchitecture: host) try MacBundleAudit.validate(bundle: bundle, target: target) print("Launching \(bundle.lastPathComponent)...") - @Dependency(\.macRunClient) var client + @Dependency(\.macHostClient) var client let status = try await client.launch(executable, arguments) if status != 0 { throw ExitCode(status) } } diff --git a/Sources/XToolSupport/MacSigningOperation.swift b/Sources/XToolSupport/MacSigningOperation.swift index b919598e..a3e52329 100644 --- a/Sources/XToolSupport/MacSigningOperation.swift +++ b/Sources/XToolSupport/MacSigningOperation.swift @@ -1,45 +1,8 @@ import Dependencies import Foundation import PackLib -import Subprocess import XUtils -struct MacSigningCommandResult: Sendable { - var output: Data - var error: Data -} - -struct MacSigningClient: DependencyKey, Sendable { - var isMacOS: @Sendable () -> Bool - var run: @Sendable (String, [String]) async throws -> MacSigningCommandResult - - static let liveValue = Self(isMacOS: { - #if os(macOS) - true - #else - false - #endif - }, run: { executable, arguments in - let result = try await Subprocess.run( - .path(.init(executable)), arguments: .init(arguments), - platformOptions: .withGracefulShutDown, output: .data(limit: .max), error: .data(limit: .max) - ) - do { try result.checkSuccess() } catch { - throw StringError("Mac signing tool failed: \(String(decoding: result.standardError, as: UTF8.self))") - } - return MacSigningCommandResult(output: result.standardOutput, error: result.standardError) - }) - - static let testValue = Self(isMacOS: { false }, run: { _, _ in throw StringError("Unexpected signing tool invocation") }) -} - -extension DependencyValues { - var macSigningClient: MacSigningClient { - get { self[MacSigningClient.self] } - set { self[MacSigningClient.self] = newValue } - } -} - enum MacSigningOperation { static func run(bundle: URL, output: URL, mode: MacSigningMode, identity: String?, dryRun: Bool) async throws { let plan = try MacSigningPlan.make(bundle: bundle, mode: mode, identity: identity) @@ -50,7 +13,7 @@ enum MacSigningOperation { print(String(decoding: try encoder.encode(plan), as: UTF8.self)) return } - @Dependency(\.macSigningClient) var client + @Dependency(\.macHostClient) var client guard client.isMacOS() else { throw StringError("Mac signing requires a macOS host") } let manager = FileManager.default let stage = output.deletingLastPathComponent().appendingPathComponent(".xtool-sign-\(UUID().uuidString)") @@ -76,7 +39,7 @@ enum MacSigningOperation { arguments += ["--entitlements", file.path, "--generate-entitlement-der"] } arguments.append(codeURL(target.path, bundle: stagedBundle).path) - _ = try await client.run("/usr/bin/codesign", arguments) + _ = try await client.capture("/usr/bin/codesign", arguments) } try manager.removeItem(at: inputs) var receipt = try MacSigningReceipt(plan: plan, bundle: stagedBundle) @@ -93,7 +56,7 @@ enum MacSigningOperation { @discardableResult static func verify(bundle: URL, receipt: MacSigningReceipt, verifyInventory: Bool = true) async throws -> [MacSigningReceipt.Signature] { - @Dependency(\.macSigningClient) var client + @Dependency(\.macHostClient) var client guard client.isMacOS() else { throw StringError("Native signature verification requires macOS") } if verifyInventory { try receipt.inventory.verify(directory: bundle) } let order = try MacBundleAudit.validate(bundle: bundle, target: receipt.target) @@ -105,21 +68,21 @@ enum MacSigningOperation { var signatures: [MacSigningReceipt.Signature] = [] for (index, target) in receipt.targets.enumerated() { let code = codeURL(target.path, bundle: bundle) - _ = try await client.run("/usr/bin/codesign", ["--verify", "--strict", "--verbose=2", code.path]) - let display = try await client.run("/usr/bin/codesign", ["--display", "--verbose=4", code.path]) + _ = try await client.capture("/usr/bin/codesign", ["--verify", "--strict", "--verbose=2", code.path]) + let display = try await client.capture("/usr/bin/codesign", ["--display", "--verbose=4", code.path]) let metadata = String(decoding: display.error + display.output, as: UTF8.self) try validateMetadata(metadata, mode: receipt.mode) var fingerprint: String? if let identity = receipt.identity { let prefix = scratch.appendingPathComponent("\(index)-certificate").path - _ = try await client.run("/usr/bin/codesign", ["--display", "--extract-certificates", prefix, code.path]) + _ = try await client.capture("/usr/bin/codesign", ["--display", "--extract-certificates", prefix, code.path]) let leaf = try Data(contentsOf: URL(fileURLWithPath: prefix + "0")) fingerprint = MacSigningPlan.certificateFingerprint(leaf) guard fingerprint == identity.uppercased() else { throw StringError("Signed certificate differs from requested fingerprint: \(target.path)") } } - let effective = try await client.run("/usr/bin/codesign", ["--display", "--entitlements", "-", "--xml", code.path]) + let effective = try await client.capture("/usr/bin/codesign", ["--display", "--entitlements", "-", "--xml", code.path]) try validateEffectiveEntitlements(effective.output, expected: target.entitlements, path: target.path) let lines = metadata.components(separatedBy: .newlines) func value(_ prefix: String) -> String? { diff --git a/Tests/XToolTests/DevCommandTests.swift b/Tests/XToolTests/DevCommandTests.swift index 9c34ff00..069721a3 100644 --- a/Tests/XToolTests/DevCommandTests.swift +++ b/Tests/XToolTests/DevCommandTests.swift @@ -119,7 +119,7 @@ struct DevCommandTests { $0.keyValueStorage = ForbiddenAuthStorage() // A non-Mac host, explicitly: resolving the implicit test value crashes with the // swift.org Swift 6.4.0 macOS toolchain (not Xcode 27); pointfreeco/swift-dependencies#474. - $0.macRunClient = .testValue + $0.macHostClient = .testValue } operation: { try await DevRunCommand.parse([]).run() } diff --git a/Tests/XToolTests/MacArtifactTests.swift b/Tests/XToolTests/MacArtifactTests.swift index 5947425e..2d9b0c8a 100644 --- a/Tests/XToolTests/MacArtifactTests.swift +++ b/Tests/XToolTests/MacArtifactTests.swift @@ -143,7 +143,7 @@ struct MacArtifactTests { let before = try ArtifactInventory(directory: app) await #expect(throws: ExitCode(7)) { try await withDependencies { - $0.macRunClient = MacRunClient(isMacOS: { true }, launch: { executable, arguments in + $0.macHostClient = MacHostClient(isMacOS: { true }, launch: { executable, arguments in #expect(executable.lastPathComponent == "App") #expect(arguments == ["--probe", "value with spaces"]) return 7 @@ -230,7 +230,7 @@ struct MacArtifactTests { let before = try ArtifactInventory(directory: app) await #expect(throws: ExitCode(7)) { try await withDependencies { - $0.macRunClient = .liveValue + $0.macHostClient = .liveValue } operation: { try await DevRunCommand.parse(["--app", app.path, "--", "first", "two words"]).run() } diff --git a/Tests/XToolTests/MacNotarizationTests.swift b/Tests/XToolTests/MacNotarizationTests.swift index 9e231f8f..ee56e26a 100644 --- a/Tests/XToolTests/MacNotarizationTests.swift +++ b/Tests/XToolTests/MacNotarizationTests.swift @@ -62,15 +62,16 @@ struct MacNotarizationTests { return (root, app, root.appendingPathComponent("release")) } - func client(_ commands: Commands) -> MacNotarizationClient { - MacNotarizationClient(isMacOS: { true }, run: { try await commands.run($0, $1) }, verifySignature: { _, _ in }) + func stub(_ dependencies: inout DependencyValues, _ commands: Commands) { + dependencies.macHostClient = MacHostClient(isMacOS: { true }, stream: { try await commands.run($0, $1) }) + dependencies.macNotarizationClient = MacNotarizationClient(verifySignature: { _, _ in }) } @Test func completeFlowPreservesUploadAndVerifiesDistribution() async throws { let (root, app, release) = try fixture() defer { try? FileManager.default.removeItem(at: root) } let commands = Commands(release: release) - try await withDependencies { $0.macNotarizationClient = client(commands) } operation: { () async throws in + try await withDependencies { stub(&$0, commands) } operation: { () async throws in try await MacNotarizationOperation.submit(bundle: app, directory: release, profile: "chosen-profile") let original = try ArtifactInventory.hash(release.appendingPathComponent("upload.zip")) #expect(try await MacNotarizationOperation.update(directory: release, profile: "chosen-profile") == "Accepted") @@ -93,7 +94,7 @@ struct MacNotarizationTests { defer { try? FileManager.default.removeItem(at: root) } let commands = Commands(release: release) await commands.interrupt() - try await withDependencies { $0.macNotarizationClient = client(commands) } operation: { () async throws in + try await withDependencies { stub(&$0, commands) } operation: { () async throws in await #expect(throws: (any Error).self) { try await MacNotarizationOperation.submit(bundle: app, directory: release, profile: "profile") } @@ -116,7 +117,7 @@ struct MacNotarizationTests { let (root, app, release) = try fixture() defer { try? FileManager.default.removeItem(at: root) } let commands = Commands(release: release) - try await withDependencies { $0.macNotarizationClient = client(commands) } operation: { () async throws in + try await withDependencies { stub(&$0, commands) } operation: { () async throws in try await MacNotarizationOperation.submit(bundle: app, directory: release, profile: "profile") await commands.mismatch() await #expect(throws: (any Error).self) { @@ -131,7 +132,7 @@ struct MacNotarizationTests { let (root, app, release) = try fixture() defer { try? FileManager.default.removeItem(at: root) } let commands = Commands(release: release) - try await withDependencies { $0.macNotarizationClient = client(commands) } operation: { () async throws in + try await withDependencies { stub(&$0, commands) } operation: { () async throws in try await MacNotarizationOperation.submit(bundle: app, directory: release, profile: "profile") let count = await commands.calls.count try Data("changed".utf8).write(to: release.appendingPathComponent("upload.zip")) @@ -146,7 +147,7 @@ struct MacNotarizationTests { let (root, app, release) = try fixture() defer { try? FileManager.default.removeItem(at: root) } let commands = Commands(release: release) - try await withDependencies { $0.macNotarizationClient = client(commands) } operation: { () async throws in + try await withDependencies { stub(&$0, commands) } operation: { () async throws in try await MacNotarizationOperation.submit(bundle: app, directory: release, profile: "profile") await commands.setStatus("Invalid") #expect(try await MacNotarizationOperation.update(directory: release, profile: "profile") == "Invalid") @@ -161,7 +162,7 @@ struct MacNotarizationTests { let alias = root.appendingPathComponent("alias") try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: app) let commands = Commands(release: release) - try await withDependencies { $0.macNotarizationClient = client(commands) } operation: { + try await withDependencies { stub(&$0, commands) } operation: { for directory in [app, app.appendingPathComponent("release"), alias.appendingPathComponent("release")] { await #expect(throws: (any Error).self) { try await MacNotarizationOperation.submit(bundle: app, directory: directory, profile: "profile") @@ -188,7 +189,7 @@ struct MacNotarizationTests { let (root, app, release) = try fixture() defer { try? FileManager.default.removeItem(at: root) } let commands = Commands(release: release) - try await withDependencies { $0.macNotarizationClient = client(commands) } operation: { () async throws in + try await withDependencies { stub(&$0, commands) } operation: { () async throws in try await MacNotarizationOperation.submit(bundle: app, directory: release, profile: "profile") _ = try await MacNotarizationOperation.update(directory: release, profile: "profile") try await MacNotarizationOperation.staple(directory: release) diff --git a/Tests/XToolTests/MacSigningTests.swift b/Tests/XToolTests/MacSigningTests.swift index 6b9b05d2..73348b2e 100644 --- a/Tests/XToolTests/MacSigningTests.swift +++ b/Tests/XToolTests/MacSigningTests.swift @@ -68,7 +68,7 @@ struct MacSigningTests { let captured = LockIsolated(nil) let calls = LockIsolated<[[String]]>([]) try await withDependencies { - $0.macSigningClient = MacSigningClient(isMacOS: { true }, run: { _, arguments in + $0.macHostClient = MacHostClient(isMacOS: { true }, capture: { _, arguments in calls.withValue { $0.append(arguments) } if arguments.contains("--sign") { let input = try #require(arguments.firstIndex(of: "--entitlements")) @@ -79,9 +79,9 @@ struct MacSigningTests { try Data("signature".utf8).write(to: target.appendingPathComponent("Contents/mock-signature")) } if arguments.contains("--entitlements"), !arguments.contains("--sign") { - return MacSigningCommandResult(output: captured.value ?? Data(), error: Data()) + return MacToolOutput(output: captured.value ?? Data(), error: Data()) } - return MacSigningCommandResult(output: Data(), error: Data("Signature=adhoc\n".utf8)) + return MacToolOutput(output: Data(), error: Data("Signature=adhoc\n".utf8)) }) } operation: { try await MacSigningOperation.run(bundle: app, output: output, mode: .adHoc, identity: nil, dryRun: false) @@ -106,7 +106,7 @@ struct MacSigningTests { let output = directory.appendingPathComponent("signed") await #expect(throws: (any Error).self) { try await withDependencies { - $0.macSigningClient = MacSigningClient(isMacOS: { true }, run: { _, _ in + $0.macHostClient = MacHostClient(isMacOS: { true }, capture: { _, _ in throw CocoaError(.fileWriteUnknown) }) } operation: { @@ -126,8 +126,8 @@ struct MacSigningTests { let output = directory.appendingPathComponent("signed") await #expect(throws: (any Error).self) { try await withDependencies { - $0.macSigningClient = MacSigningClient(isMacOS: { true }, run: { _, _ in - MacSigningCommandResult(output: Data(), error: Data("Signature=adhoc\n".utf8)) + $0.macHostClient = MacHostClient(isMacOS: { true }, capture: { _, _ in + MacToolOutput(output: Data(), error: Data("Signature=adhoc\n".utf8)) }) } operation: { try await MacSigningOperation.run(bundle: app, output: output, mode: .adHoc, identity: nil, dryRun: false) @@ -184,15 +184,15 @@ struct MacSigningTests { + (failure == "runtime" ? "" : "CodeDirectory v=20500 flags=0x10000(runtime)\n") + (failure == "timestamp" ? "" : "Timestamp=Oct 13, 2026 at 12:00:00\n") let calls = LockIsolated<[[String]]>([]) - let client = MacSigningClient(isMacOS: { true }, run: { _, arguments in + let client = MacHostClient(isMacOS: { true }, capture: { _, arguments in calls.withValue { $0.append(arguments) } if let index = arguments.firstIndex(of: "--extract-certificates") { try certificate.write(to: URL(fileURLWithPath: arguments[index + 1] + "0")) } - return MacSigningCommandResult(output: Data(), error: Data(metadata.utf8)) + return MacToolOutput(output: Data(), error: Data(metadata.utf8)) }) if failure == "success" { - try await withDependencies { $0.macSigningClient = client } operation: { + try await withDependencies { $0.macHostClient = client } operation: { try await MacSigningOperation.run(bundle: app, output: output, mode: .developerID, identity: identity, dryRun: false) } @@ -205,7 +205,7 @@ struct MacSigningTests { #expect(signing.contains("runtime")) } else { await #expect(throws: (any Error).self) { - try await withDependencies { $0.macSigningClient = client } operation: { + try await withDependencies { $0.macHostClient = client } operation: { try await MacSigningOperation.run(bundle: app, output: output, mode: .developerID, identity: identity, dryRun: false) } @@ -235,8 +235,7 @@ struct MacSigningTests { let before = try ArtifactInventory(directory: app) let output = directory.appendingPathComponent("signed") try await withDependencies { - $0.macSigningClient = .liveValue - $0.macRunClient = .liveValue + $0.macHostClient = .liveValue } operation: { let arguments = [app.path, "--output", output.path, "--mode", "ad-hoc"] try await MacSignCommand.parse(arguments + ["--dry-run"]).run() From 19055bea96a28123cf22b911e74f002a75b4218b Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 20 Oct 2026 07:10:42 +0200 Subject: [PATCH 52/59] packer: document both purposes of the -platform_version link option The comment named only the Linux SDK-version fix. The option also records the plan's deployment target as each product's minos on every host (see b94217d), which is why it is not limited to Linux hosts. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/PackLib/BuildSettings.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/PackLib/BuildSettings.swift b/Sources/PackLib/BuildSettings.swift index affedb9f..6600bfed 100644 --- a/Sources/PackLib/BuildSettings.swift +++ b/Sources/PackLib/BuildSettings.swift @@ -157,7 +157,9 @@ public struct BuildSettings: Sendable { return copy } - /// LLD otherwise records the deployment version as the SDK version on Linux. + /// Records the plan's deployment target as each product's minimum OS on every host; packages + /// still compile for their own minimums. Also passes the real SDK version, which LLD on Linux + /// would otherwise replace with the deployment version. /// Keep this target-only: SwiftPM CLI linker flags also affect host macros. func macOSLinkerArguments() async throws -> [String] { let target = try TargetTriple(compilerTriple) From 875538a78c9d7d7aecb7e75d2d428299c82604e9 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 20 Oct 2026 07:21:58 +0200 Subject: [PATCH 53/59] packer: bypass SwiftPM's cached build manifest in the shared scratch path Direct executables build from the root package and wrapped products from xtool/.xtool-tmp, all in one scratch path. SwiftPM's cached build manifest there does not track the package path or the --toolset. A product built after another package's invocation therefore reused that manifest: wrapped products failed with "No target named ... in build description", and direct executables linked silently without their toolset options (no bundle rpaths, minos and SDK left at the package minimum). The native fixture now alternates a direct XPC service after a wrapped extension. It fails without the flag (minos 11.0, missing rpaths) and passes with it. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/PackLib/ProductBuilder.swift | 6 ++++-- Tests/XToolTests/ProductBuilderTests.swift | 9 +++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Sources/PackLib/ProductBuilder.swift b/Sources/PackLib/ProductBuilder.swift index b4796479..10f54415 100644 --- a/Sources/PackLib/ProductBuilder.swift +++ b/Sources/PackLib/ProductBuilder.swift @@ -108,9 +108,11 @@ public struct ProductBuilder: Sendable { ) throws -> [Invocation] { // Share one scratch tree so resources and dependency libraries remain // beside all products, including mixed direct and wrapped products. - let scratchOptions = settings.customOptions.contains { + // SwiftPM's cached build manifest in that tree tracks neither the package path nor the toolset: + // reusing it would link a product with another invocation's options, or miss the product. + let scratchOptions = (settings.customOptions.contains { $0 == "--scratch-path" || $0.hasPrefix("--scratch-path=") - } ? [] : ["--scratch-path", root.appendingPathComponent(".build").path] + } ? [] : ["--scratch-path", root.appendingPathComponent(".build").path]) + ["--disable-build-manifest-caching"] var builds: [Invocation] = [] for product in plan.allProducts { if prebuilt[product.product] != nil { continue } diff --git a/Tests/XToolTests/ProductBuilderTests.swift b/Tests/XToolTests/ProductBuilderTests.swift index 8802f122..f9278e2c 100644 --- a/Tests/XToolTests/ProductBuilderTests.swift +++ b/Tests/XToolTests/ProductBuilderTests.swift @@ -280,9 +280,10 @@ struct ProductBuilderTests { name: "BuilderFixture", platforms: [.macOS(.v10_15)], products: [.executable(name: "DirectExecutable", targets: ["Direct"]), + .executable(name: "ServiceExecutable", targets: ["ServiceMain"]), .library(name: "HelperLibrary", targets: ["Helper", "Gate"])], targets: [.executableTarget(name: "Direct", resources: [.copy("message.txt")]), - .target(name: "Helper"), .target(name: "Gate")] + .executableTarget(name: "ServiceMain"), .target(name: "Helper"), .target(name: "Gate")] ) """ try Data(manifest.utf8).write(to: root.appendingPathComponent("Package.swift")) @@ -291,6 +292,9 @@ struct ProductBuilderTests { try FileManager.default.createDirectory(at: direct, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: helper.appendingPathComponent("include"), withIntermediateDirectories: true) try Data("import AppKit\nprint(NSApplication.self)".utf8).write(to: direct.appendingPathComponent("main.swift")) + let service = root.appendingPathComponent("Sources/ServiceMain") + try FileManager.default.createDirectory(at: service, withIntermediateDirectories: true) + try Data("print(1)".utf8).write(to: service.appendingPathComponent("main.swift")) // Modules compile for their package's own minimum (macOS 10.15), not the plan's 15.0: // like swift-perception's Bindable, this backport's extension is unavailable from macOS 14. let gate = root.appendingPathComponent("Sources/Gate") @@ -304,13 +308,14 @@ struct ProductBuilderTests { try Data("#include \nint main() { std::vector values(3, 1); return values.at(1); }".utf8) .write(to: helper.appendingPathComponent("Entry.cpp")) let target = try ResolvedTarget.resolve(cliTarget: "arm64-apple-macos15.0") + // Direct and wrapped products alternate, so each build follows another package's build in the shared scratch. let plan = try Plan(target: target, app: product("App", source: .executable(product: "DirectExecutable"), target: target), extensions: [product("Preview", source: .library(product: "HelperLibrary"), kind: .appExtension, target: target)], nestedProducts: [product("Helper", source: .library(product: "HelperLibrary"), kind: .helperExecutable, target: target), - product("Service", source: .library(product: "HelperLibrary"), + product("Service", source: .executable(product: "ServiceExecutable"), kind: .xpcService, target: target)]) let settings = try await BuildSettings(configuration: configuration, triple: target.triple, buildSystem: .swiftPM, packagePath: root.path) From cda06760fc35f9f14e129b63dd28a879ba5dffd1 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 20 Oct 2026 07:32:45 +0200 Subject: [PATCH 54/59] packer: query the build output directory once per package The output directory depends on the package and the scratch path, not the product. Ask SwiftPM once per package (root, wrapper) before building instead of after every product build plus once more for the overlay. Each query loads the whole package graph. On a fixture app with 20 dependencies, two root executables and a wrapped extension: 5 --show-bin-path calls drop to 2. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/PackLib/ProductBuilder.swift | 49 ++++++++++++---------- Tests/XToolTests/ProductBuilderTests.swift | 2 + 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/Sources/PackLib/ProductBuilder.swift b/Sources/PackLib/ProductBuilder.swift index 10f54415..d2fd8e4b 100644 --- a/Sources/PackLib/ProductBuilder.swift +++ b/Sources/PackLib/ProductBuilder.swift @@ -46,27 +46,20 @@ public struct ProductBuilder: Sendable { // a clean build, a plan-only run generates them without compiling, so each module compiles // once; a later pass recompiles any accessor that the overlay still did not adapt. let built = builds.map(\.product) - let overlay = try await initialOverlay(for: builds, wrapper: wrapper) + let directory = try await outputDirectory(for: builds) ?? root + let overlay = try await initialOverlay(for: builds, in: directory, wrapper: wrapper) let overlayArguments = overlay.map { ["-Xswiftc", "-vfsoverlay", "-Xswiftc", $0.url.path] } ?? [] - var directory: URL? var executables = prebuilt for build in builds { - let arguments = build.arguments + overlayArguments - try await runner.build(build.settings, arguments) - let binDir = try await binaryDirectory(build.settings, arguments) - if let directory, directory != binDir { - throw StringError("Build backend produced products in different resource directories") - } - directory = binDir - let executable = binDir.appendingPathComponent(build.name) + try await runner.build(build.settings, build.arguments + overlayArguments) + let executable = directory.appendingPathComponent(build.name) guard FileManager.default.isExecutableFile(atPath: executable.path) else { throw StringError("Build backend did not produce executable '\(build.name)' at \(executable.path)") } executables[build.product.product] = executable } - if let directory, let overlay, - let final = try SwiftPMResourceAccessor.overlay(for: built, in: directory, output: wrapper) { + if let overlay, let final = try SwiftPMResourceAccessor.overlay(for: built, in: directory, output: wrapper) { // An accessor the build's overlay did not cover compiled unadapted, even if its adapted // copy is unchanged from an earlier build. let stale = Set(final.changedSources + final.sources.filter { !overlay.sources.contains($0) }) @@ -85,17 +78,17 @@ public struct ProductBuilder: Sendable { } } } - let output = directory ?? root - let staticTargets = try validateBinaryTargets(in: output) - return BuildArtifacts(directory: output, executables: executables, staticBinaryTargets: staticTargets) + let staticTargets = try validateBinaryTargets(in: directory) + return BuildArtifacts(directory: directory, executables: executables, staticBinaryTargets: staticTargets) } private typealias Invocation = (product: Plan.Product, name: String, settings: BuildSettings, arguments: [String]) - private func initialOverlay(for builds: [Invocation], wrapper: URL) async throws -> SwiftPMResourceAccessor.Overlay? { + private func initialOverlay( + for builds: [Invocation], in directory: URL, wrapper: URL + ) async throws -> SwiftPMResourceAccessor.Overlay? { let built = builds.map(\.product) - guard let first = builds.first, !SwiftPMResourceAccessor.bundleNames(for: built).isEmpty else { return nil } - let directory = try await binaryDirectory(first.settings, first.arguments) + guard !SwiftPMResourceAccessor.bundleNames(for: built).isEmpty else { return nil } let overlay = try SwiftPMResourceAccessor.overlay(for: built, in: directory, output: wrapper, requireAll: false) guard let overlay, !overlay.complete else { return overlay } let arguments = ["-Xswiftc", "-vfsoverlay", "-Xswiftc", overlay.url.path] @@ -140,12 +133,22 @@ public struct ProductBuilder: Sendable { return builds } - private func binaryDirectory(_ settings: BuildSettings, _ arguments: [String]) async throws -> URL { - let path = try await runner.binaryPath(settings, arguments).trimmingCharacters(in: .whitespacesAndNewlines) - guard !path.isEmpty, !path.contains("\n"), path.hasPrefix("/") else { - throw StringError("Build backend returned an invalid binary output path: '\(path)'") + /// The output directory depends on the package and scratch path, not the product: ask once per package. + private func outputDirectory(for builds: [Invocation]) async throws -> URL? { + var directory: URL? + var packages: Set = [] + for build in builds where packages.insert(build.settings.packagePath).inserted { + let path = try await runner.binaryPath(build.settings, build.arguments).trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty, !path.contains("\n"), path.hasPrefix("/") else { + throw StringError("Build backend returned an invalid binary output path: '\(path)'") + } + let url = URL(fileURLWithPath: path, isDirectory: true).standardizedFileURL + if let directory, directory != url { + throw StringError("Build backend produced products in different resource directories") + } + directory = url } - return URL(fileURLWithPath: path, isDirectory: true).standardizedFileURL + return directory } private func validatedPrebuiltProducts() throws -> [String: URL] { diff --git a/Tests/XToolTests/ProductBuilderTests.swift b/Tests/XToolTests/ProductBuilderTests.swift index f9278e2c..f9b6fe1c 100644 --- a/Tests/XToolTests/ProductBuilderTests.swift +++ b/Tests/XToolTests/ProductBuilderTests.swift @@ -139,6 +139,8 @@ struct ProductBuilderTests { #expect(artifacts.executables["App"]?.lastPathComponent == "App-App") #expect(artifacts.executables["Preview"]?.lastPathComponent == "Preview-Extension") #expect(await recorder.builds.allSatisfy { !$0.1.contains("-Xlinker") }) + // Both products share the wrapper package, so its output directory is queried once. + #expect(await recorder.queries.count == 1) } @Test func macOSResourceAccessorsBuildOnceWithStableFlags() async throws { From e799262f6059a196094308dcd06b7f45d8813186 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 20 Oct 2026 07:40:06 +0200 Subject: [PATCH 55/59] packer: build all wrapped products in one SwiftPM invocation The wrapper package holds exactly the plan's library-sourced products, so a plain `swift build` of it builds all of them. Since the build manifest is no longer cached, each further `--product` invocation planned the whole package graph again. Direct executables keep one invocation each because each needs its own toolset. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/PackLib/ProductBuilder.swift | 44 +++++++++++----------- Tests/XToolTests/ProductBuilderTests.swift | 26 ++++++++----- 2 files changed, 38 insertions(+), 32 deletions(-) diff --git a/Sources/PackLib/ProductBuilder.swift b/Sources/PackLib/ProductBuilder.swift index d2fd8e4b..1fdd0a36 100644 --- a/Sources/PackLib/ProductBuilder.swift +++ b/Sources/PackLib/ProductBuilder.swift @@ -45,7 +45,7 @@ public struct ProductBuilder: Sendable { // The overlay covers the accessors of the previous build. When accessors are missing, as on // a clean build, a plan-only run generates them without compiling, so each module compiles // once; a later pass recompiles any accessor that the overlay still did not adapt. - let built = builds.map(\.product) + let built = builds.flatMap { $0.outputs.map(\.product) } let directory = try await outputDirectory(for: builds) ?? root let overlay = try await initialOverlay(for: builds, in: directory, wrapper: wrapper) let overlayArguments = overlay.map { ["-Xswiftc", "-vfsoverlay", "-Xswiftc", $0.url.path] } ?? [] @@ -53,11 +53,13 @@ public struct ProductBuilder: Sendable { var executables = prebuilt for build in builds { try await runner.build(build.settings, build.arguments + overlayArguments) - let executable = directory.appendingPathComponent(build.name) - guard FileManager.default.isExecutableFile(atPath: executable.path) else { - throw StringError("Build backend did not produce executable '\(build.name)' at \(executable.path)") + for (product, name) in build.outputs { + let executable = directory.appendingPathComponent(name) + guard FileManager.default.isExecutableFile(atPath: executable.path) else { + throw StringError("Build backend did not produce executable '\(name)' at \(executable.path)") + } + executables[product.product] = executable } - executables[build.product.product] = executable } if let overlay, let final = try SwiftPMResourceAccessor.overlay(for: built, in: directory, output: wrapper) { // An accessor the build's overlay did not cover compiled unadapted, even if its adapted @@ -82,12 +84,12 @@ public struct ProductBuilder: Sendable { return BuildArtifacts(directory: directory, executables: executables, staticBinaryTargets: staticTargets) } - private typealias Invocation = (product: Plan.Product, name: String, settings: BuildSettings, arguments: [String]) + private typealias Invocation = (outputs: [(product: Plan.Product, name: String)], settings: BuildSettings, arguments: [String]) private func initialOverlay( for builds: [Invocation], in directory: URL, wrapper: URL ) async throws -> SwiftPMResourceAccessor.Overlay? { - let built = builds.map(\.product) + let built = builds.flatMap { $0.outputs.map(\.product) } guard !SwiftPMResourceAccessor.bundleNames(for: built).isEmpty else { return nil } let overlay = try SwiftPMResourceAccessor.overlay(for: built, in: directory, output: wrapper, requireAll: false) guard let overlay, !overlay.complete else { return overlay } @@ -107,28 +109,26 @@ public struct ProductBuilder: Sendable { $0 == "--scratch-path" || $0.hasPrefix("--scratch-path=") } ? [] : ["--scratch-path", root.appendingPathComponent(".build").path]) + ["--disable-build-manifest-caching"] var builds: [Invocation] = [] + var wrapped: [(product: Plan.Product, name: String)] = [] for product in plan.allProducts { if prebuilt[product.product] != nil { continue } - let package: URL - let name: String - let options: [String] switch product.resolvedSource { case .library: - package = wrapper - name = product.targetName - // Planning already resolved root dependencies; avoid dropping - // its development-only dependencies from Package.resolved. - options = ["--disable-automatic-resolution"] - case .executable(let sourceName): - package = root - name = sourceName - options = try toolsetOptions(name: product.targetName, - linkerOptions: Self.linkerOptions(product.linkerArguments + sdkArguments), - in: wrapper) + wrapped.append((product, product.targetName)) + case .executable(let name): + let options = try toolsetOptions(name: product.targetName, + linkerOptions: Self.linkerOptions(product.linkerArguments + sdkArguments), + in: wrapper) + builds.append(([(product, name)], settings.withPackagePath(root.path), scratchOptions + ["--product", name] + options)) case .prebuilt: continue } - builds.append((product, name, settings.withPackagePath(package.path), scratchOptions + ["--product", name] + options)) + } + if !wrapped.isEmpty { + // The wrapper holds exactly the wrapped products, so one invocation builds them all; each + // further invocation would plan the whole package graph again. Planning already resolved + // root dependencies; avoid dropping its development-only dependencies from Package.resolved. + builds.append((wrapped, settings.withPackagePath(wrapper.path), scratchOptions + ["--disable-automatic-resolution"])) } return builds } diff --git a/Tests/XToolTests/ProductBuilderTests.swift b/Tests/XToolTests/ProductBuilderTests.swift index f9b6fe1c..3a8e2b52 100644 --- a/Tests/XToolTests/ProductBuilderTests.swift +++ b/Tests/XToolTests/ProductBuilderTests.swift @@ -27,11 +27,16 @@ struct ProductBuilderTests { func build(_ settings: BuildSettings, arguments: [String]) throws { builds.append((settings, arguments)) - let index = try #require(arguments.firstIndex(of: "--product")) + // Without --product, SwiftPM builds every target of the package: the wrapper's products. + let sources = URL(fileURLWithPath: settings.packagePath).appendingPathComponent("Sources").path + let names = try arguments.firstIndex(of: "--product").map { [arguments[$0 + 1]] } + ?? FileManager.default.contentsOfDirectory(atPath: sources) try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) - let executable = output.appendingPathComponent(arguments[index + 1]) - try Data("fixture".utf8).write(to: executable) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + for name in names { + let executable = output.appendingPathComponent(name) + try Data("fixture".utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + } } func binaryPath(_ settings: BuildSettings, arguments: [String]) -> String { @@ -139,7 +144,8 @@ struct ProductBuilderTests { #expect(artifacts.executables["App"]?.lastPathComponent == "App-App") #expect(artifacts.executables["Preview"]?.lastPathComponent == "Preview-Extension") #expect(await recorder.builds.allSatisfy { !$0.1.contains("-Xlinker") }) - // Both products share the wrapper package, so its output directory is queried once. + // Both products share the wrapper package: one invocation builds them, one query finds their directory. + #expect(await recorder.builds.count == 1) #expect(await recorder.queries.count == 1) } @@ -193,14 +199,14 @@ struct ProductBuilderTests { } // A clean build plans first, so the overlay adapts the accessors before anything compiles: - // one pass per product, with the flags it plans with. + // one pass for both wrapped products, with the flags it plans with. let clean = try await build() #expect(clean.plans == clean.builds) - #expect(clean.builds.count == 2) + #expect(clean.builds.count == 1) #expect(clean.builds.allSatisfy { $0.contains("-vfsoverlay") }) let cleanDate = try FileManager.default.attributesOfItem(atPath: accessor.path)[.modificationDate] as? Date - // A no-op rebuild skips planning and runs one pass per product with exactly the previous flags. + // A no-op rebuild skips planning and runs one pass with exactly the previous flags. let rebuilt = try await build() #expect(rebuilt.plans.isEmpty) #expect(rebuilt.builds == clean.builds) @@ -212,8 +218,8 @@ struct ProductBuilderTests { // the adapted copy from the earlier build is unchanged: the build itself did not use it. try FileManager.default.removeItem(at: output) let fallback = try await build(planGenerates: false) - #expect(fallback.builds.count == 4) - #expect(fallback.builds[0] == fallback.builds[2] && fallback.builds[1] == fallback.builds[3]) + #expect(fallback.builds.count == 2) + #expect(fallback.builds[0] == fallback.builds[1]) } @Test func unsupportedCombinationsReject() async throws { From 3e694d554675e0aaa1618d3798672c6b2af74575 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 20 Oct 2026 07:41:49 +0200 Subject: [PATCH 56/59] packer: parse Mach-O files from a mapped buffer without copying MachOBinary read each file into Data, copied it into [UInt8], and copied every universal slice, archive member and load command again. A large static archive from a binary target ended up resident several times. Map the file and parse rebased views of one buffer; only headers and load commands are touched. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/PackLib/MacBundleAudit.swift | 4 +-- Sources/PackLib/ProductBuilder.swift | 34 +++++++++++++++------- Tests/XToolTests/BinaryLinkTests.swift | 4 +-- Tests/XToolTests/ProductBuilderTests.swift | 2 +- 4 files changed, 28 insertions(+), 16 deletions(-) diff --git a/Sources/PackLib/MacBundleAudit.swift b/Sources/PackLib/MacBundleAudit.swift index cf37cc01..8cd9c53f 100644 --- a/Sources/PackLib/MacBundleAudit.swift +++ b/Sources/PackLib/MacBundleAudit.swift @@ -24,7 +24,7 @@ public enum MacBundleAudit { /// Without `LSMinimumSystemVersion`, the minimum is the one recorded in that slice. public static func runTarget(bundle: URL, hostArchitecture: String) throws -> ResolvedTarget { let main = try executable(in: bundle) - let slices = try MachOBinary.inspect([UInt8](Data(contentsOf: main))) + let slices = try MachOBinary.inspect(contentsOf: main) let candidates = hostArchitecture == "arm64" ? ["arm64", "x86_64"] : [hostArchitecture] var match: (architecture: String, slice: MachOBinary.Slice)? for architecture in candidates where match == nil { @@ -57,7 +57,7 @@ public enum MacBundleAudit { try handle.close() guard [Data([0xcf, 0xfa, 0xed, 0xfe]), Data([0xca, 0xfe, 0xba, 0xbe]), Data([0xbe, 0xba, 0xfe, 0xca])].contains(prefix) else { continue } - let slices = try MachOBinary.inspect([UInt8](Data(contentsOf: url))) + let slices = try MachOBinary.inspect(contentsOf: url) guard let slice = slices.first(where: { $0.cpu == cpu }), [2, 6, 8].contains(slice.fileType), slice.platform == platform, slice.minimumOS <= MachOBinary.packedVersion(target.deploymentTarget) else { throw StringError("Incompatible nested code: \(path)") diff --git a/Sources/PackLib/ProductBuilder.swift b/Sources/PackLib/ProductBuilder.swift index 1fdd0a36..e479f43d 100644 --- a/Sources/PackLib/ProductBuilder.swift +++ b/Sources/PackLib/ProductBuilder.swift @@ -468,7 +468,7 @@ struct MachOBinary { } var dependencies: [Dependency] = [] - mutating func read(command: UInt32, load: [UInt8]) throws { + mutating func read(command: UInt32, load: UnsafeRawBufferPointer) throws { switch command { case 0x32: // LC_BUILD_VERSION guard platform == nil else { throw StringError("Duplicate Mach-O platform metadata") } @@ -496,7 +496,7 @@ struct MachOBinary { throw StringError("Missing binary artifact: \(url.path)") } do { - let slices = try inspect([UInt8](try Data(contentsOf: url))) + let slices = try inspect(contentsOf: url) let cpu = try cpuType(for: target.architecture) let matching = slices.filter { $0.cpu == cpu } guard !matching.isEmpty else { throw StringError("Wrong architecture; expected \(target.architecture)") } @@ -558,8 +558,19 @@ struct MachOBinary { return (min(parts[0], 65535) << 16) | (min(parts[1], 255) << 8) | min(parts[2], 255) } - static func inspect(_ bytes: [UInt8]) throws -> [Slice] { - if bytes.starts(with: Array("!\n".utf8)) { return try archiveSlices(bytes) } + /// Maps the file instead of reading it: static archives can be hundreds of megabytes, + /// and parsing touches only headers and load commands. + static func inspect(contentsOf url: URL) throws -> [Slice] { + try inspect(Data(contentsOf: url, options: .alwaysMapped)) + } + + static func inspect(_ data: Data) throws -> [Slice] { + try data.withUnsafeBytes { try inspect($0) } + } + + /// Nested slices and archive members are parsed in place, without copying. + private static func inspect(_ bytes: UnsafeRawBufferPointer) throws -> [Slice] { + if bytes.starts(with: "!\n".utf8) { return try archiveSlices(bytes) } let magic = try word(bytes, 0) if magic == 0xbebafeca || magic == 0xcafebabe { let bigEndian = magic == 0xbebafeca @@ -572,7 +583,7 @@ struct MachOBinary { guard offset >= 8 + count * 20, offset <= bytes.count, size <= bytes.count - offset else { throw StringError("Invalid universal binary slice") } - let slices = try inspect(Array(bytes[offset..<(offset + size)])) + let slices = try inspect(UnsafeRawBufferPointer(rebasing: bytes[offset..<(offset + size)])) let cpu = try word(bytes, entry, bigEndian: bigEndian) guard slices.allSatisfy({ $0.cpu == cpu }) else { throw StringError("Universal binary CPU mismatch") } return slices @@ -582,7 +593,7 @@ struct MachOBinary { return [try thinSlice(bytes)] } - private static func archiveSlices(_ bytes: [UInt8]) throws -> [Slice] { + private static func archiveSlices(_ bytes: UnsafeRawBufferPointer) throws -> [Slice] { var offset = 8 var slices: [Slice] = [] while offset < bytes.count { @@ -598,7 +609,8 @@ struct MachOBinary { let actualName = nameSize > 0 ? String(decoding: bytes[(offset + 60)..<(offset + 60 + nameSize)], as: UTF8.self) : name if !actualName.hasPrefix("__.SYMDEF") && actualName != "/" && actualName != "//" { - slices += try inspect(Array(bytes[(offset + 60 + nameSize)..<(offset + 60 + size)])).map { + let member = UnsafeRawBufferPointer(rebasing: bytes[(offset + 60 + nameSize)..<(offset + 60 + size)]) + slices += try inspect(member).map { var member = $0 member.archiveMember = true return member @@ -610,7 +622,7 @@ struct MachOBinary { return slices } - private static func thinSlice(_ bytes: [UInt8]) throws -> Slice { + private static func thinSlice(_ bytes: UnsafeRawBufferPointer) throws -> Slice { var slice = Slice(cpu: try word(bytes, 4), fileType: try word(bytes, 12)) let count = Int(try word(bytes, 16)) let size = Int(try word(bytes, 20)) @@ -622,7 +634,7 @@ struct MachOBinary { let command = try word(bytes, offset) let length = Int(try word(bytes, offset + 4)) guard length >= 8, length <= 32 + size - offset else { throw StringError("Invalid Mach-O load command") } - let load = Array(bytes[offset..<(offset + length)]) + let load = UnsafeRawBufferPointer(rebasing: bytes[offset..<(offset + length)]) try slice.read(command: command, load: load) offset += length } @@ -630,7 +642,7 @@ struct MachOBinary { return slice } - private static func loadString(_ load: [UInt8]) throws -> String { + private static func loadString(_ load: UnsafeRawBufferPointer) throws -> String { let offset = Int(try word(load, 8)) guard offset >= 12, offset < load.count, let end = load[offset...].firstIndex(of: 0) else { throw StringError("Invalid Mach-O load-command string") @@ -638,7 +650,7 @@ struct MachOBinary { return String(decoding: load[offset.. UInt32 { + private static func word(_ bytes: UnsafeRawBufferPointer, _ offset: Int, bigEndian: Bool = false) throws -> UInt32 { guard offset >= 0, offset <= bytes.count, bytes.count - offset >= 4 else { throw StringError("Truncated Mach-O data") } return (0..<4).reduce(UInt32(0)) { value, index in value | (UInt32(bytes[offset + index]) << ((bigEndian ? 3 - index : index) * 8)) diff --git a/Tests/XToolTests/BinaryLinkTests.swift b/Tests/XToolTests/BinaryLinkTests.swift index f0924373..eed9f312 100644 --- a/Tests/XToolTests/BinaryLinkTests.swift +++ b/Tests/XToolTests/BinaryLinkTests.swift @@ -144,8 +144,8 @@ struct BinaryLinkTests { let header: [UInt32] = [0xcafebabe, 2, 0x01000007, 0, 48, 56, 0, 0x0100000c, 0, 104, 56, 0] for word in header { data += Data((0..<4).map { UInt8(truncatingIfNeeded: word >> ((3 - $0) * 8)) }) } data += slices[0] + slices[1] - #expect(try MachOBinary.inspect(Array(data)).count == 2) - #expect(throws: (any Error).self) { try MachOBinary.inspect(Array(data.dropLast())) } + #expect(try MachOBinary.inspect(data).count == 2) + #expect(throws: (any Error).self) { try MachOBinary.inspect(data.dropLast()) } } @Test func universalStaticArchiveIsStatic() throws { diff --git a/Tests/XToolTests/ProductBuilderTests.swift b/Tests/XToolTests/ProductBuilderTests.swift index 3a8e2b52..00a92d0c 100644 --- a/Tests/XToolTests/ProductBuilderTests.swift +++ b/Tests/XToolTests/ProductBuilderTests.swift @@ -339,7 +339,7 @@ struct ProductBuilderTests { let expectedSDK = try #require(try await settings.withTarget(target).macOSLinkerArguments().last) for product in plan.allProducts { let executable = try #require(artifacts.executables[product.product]) - let slice = try #require(MachOBinary.inspect(Array(Data(contentsOf: executable))).first) + let slice = try #require(MachOBinary.inspect(contentsOf: executable).first) #expect(slice.platform == 1) #expect(slice.minimumOS == MachOBinary.packedVersion("15.0")) #expect(slice.sdk == MachOBinary.packedVersion(expectedSDK)) From d93f75478de69bfb414540049a8a9aca424cf479 Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 20 Oct 2026 07:43:39 +0200 Subject: [PATCH 57/59] packer: bound concurrent package inspection to the core count The planner started describe and dump-package for every package in the graph at once: 2 processes per package, so hundreds on a large graph. Inspect at most one package per core at a time. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/PackLib/Planner.swift | 23 +++++++++++++++++------ Tests/XToolTests/PlannerTests.swift | 28 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/Sources/PackLib/Planner.swift b/Sources/PackLib/Planner.swift index 0486e6ae..99ce28cb 100644 --- a/Sources/PackLib/Planner.swift +++ b/Sources/PackLib/Planner.swift @@ -20,18 +20,30 @@ public struct Planner: Sendable { return decoder }() + /// Packages inspected at once, each by two SwiftPM processes. Unbounded, a large graph + /// would start hundreds of processes; beyond the core count they only compete. + static let inspectionLimit = max(2, ProcessInfo.processInfo.activeProcessorCount) + private func buildGraph() async throws -> PackageGraph { let dependencyRoot = try Self.decoder.decode( PackageDependency.self, from: try await inspection.dependencies(buildSettings) ) + var nodes: [PackageDependency] = [] + var visited: Set = [] + var dependencies = [dependencyRoot] + while let node = dependencies.popLast() { + guard visited.insert(node.identity).inserted else { continue } + dependencies.append(contentsOf: node.dependencies) + nodes.append(node) + } let packages = try await withThrowingTaskGroup( of: (String, InspectedPackage).self, returning: [String: InspectedPackage].self ) { group in - var visited: Set = [] - var dependencies = [dependencyRoot] - while let node = dependencies.popLast() { - guard visited.insert(node.identity).inserted else { continue } - dependencies.append(contentsOf: node.dependencies) + var packages: [String: InspectedPackage] = [:] + for (index, node) in nodes.enumerated() { + if index >= Self.inspectionLimit, let (identity, package) = try await group.next() { + packages[identity] = package + } group.addTask { let settings = buildSettings.withPackagePath(node.path) async let description = inspection.describe(settings) @@ -42,7 +54,6 @@ public struct Planner: Sendable { )) } } - var packages: [String: InspectedPackage] = [:] for try await (identity, package) in group { packages[identity] = package } return packages } diff --git a/Tests/XToolTests/PlannerTests.swift b/Tests/XToolTests/PlannerTests.swift index 053469cc..070775dc 100644 --- a/Tests/XToolTests/PlannerTests.swift +++ b/Tests/XToolTests/PlannerTests.swift @@ -79,6 +79,34 @@ struct PlannerTests { } } + @Test func inspectionConcurrencyIsBounded() async throws { + actor Gauge { + var current = 0, peak = 0 + func enter() { current += 1; peak = max(peak, current) } + func leave() { current -= 1 } + } + let gauge = Gauge() + let names = (0..<(Planner.inspectionLimit * 3)).map { "dep\($0)" } + let root = try package() + let dependencyTree = try json([ + "identity": "root", "path": "/root", + "dependencies": names.map { ["identity": $0, "path": "/\($0)", "dependencies": []] as [String: Any] }, + ]) + let client = PackageInspectionClient( + dependencies: { _ in dependencyTree }, + describe: { _ in + await gauge.enter() + try await Task.sleep(for: .milliseconds(5)) + await gauge.leave() + return root.description + }, + manifest: { _ in root.manifest } + ) + _ = try await plan(["root": root], clientOverride: client) + #expect(await gauge.peak <= Planner.inspectionLimit) + #expect(await gauge.peak > 1) + } + @Test func sameNamedTargetsInDifferentPackagesBothContributeResources() async throws { let root = try package(targets: [target("App", dependencies: [ dependency("First", package: "one"), dependency("Second", package: "two"), From e4961973fd77f1866cd620f4e5ae965f05b273fa Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 20 Oct 2026 07:52:44 +0200 Subject: [PATCH 58/59] utils: share one sorted, pretty-printed JSONEncoder setup Six writers of manifests, receipts and the signing dry-run each built the same encoder; one of them listed the options in the other order. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/PackLib/MacArtifact.swift | 4 +--- Sources/PackLib/MacSigningReceipt.swift | 4 +--- Sources/XToolSupport/DevCommand.swift | 4 +--- Sources/XToolSupport/MacNotarizationOperation.swift | 8 ++------ Sources/XToolSupport/MacSigningOperation.swift | 4 +--- Sources/XUtils/Foundation+Utils.swift | 9 +++++++++ 6 files changed, 15 insertions(+), 18 deletions(-) diff --git a/Sources/PackLib/MacArtifact.swift b/Sources/PackLib/MacArtifact.swift index ef50979a..4669c45c 100644 --- a/Sources/PackLib/MacArtifact.swift +++ b/Sources/PackLib/MacArtifact.swift @@ -32,9 +32,7 @@ public struct MacArtifact: Codable, Sendable { } let artifact = Self(bundleName: bundle.lastPathComponent, target: manifest.target, inventory: inventory, signingInputs: inputs, signingOrder: order) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - try encoder.encode(artifact).write(to: manifestURL(for: bundle), options: .atomic) + try JSONEncoder.sortedPretty().encode(artifact).write(to: manifestURL(for: bundle), options: .atomic) } public static func verify(bundle: URL) throws { diff --git a/Sources/PackLib/MacSigningReceipt.swift b/Sources/PackLib/MacSigningReceipt.swift index 46f9f12d..f8808097 100644 --- a/Sources/PackLib/MacSigningReceipt.swift +++ b/Sources/PackLib/MacSigningReceipt.swift @@ -47,9 +47,7 @@ public struct MacSigningReceipt: Codable, Sendable { } public func write(beside bundle: URL) throws { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - try encoder.encode(self).write(to: Self.receiptURL(for: bundle), options: .atomic) + try JSONEncoder.sortedPretty().encode(self).write(to: Self.receiptURL(for: bundle), options: .atomic) } @discardableResult diff --git a/Sources/XToolSupport/DevCommand.swift b/Sources/XToolSupport/DevCommand.swift index d5b78d33..8357e75c 100644 --- a/Sources/XToolSupport/DevCommand.swift +++ b/Sources/XToolSupport/DevCommand.swift @@ -451,9 +451,7 @@ enum DeferredSigningHandoff { files.append((filename, data)) manifest.products[index].entitlementsPath = "\(inputs.lastPathComponent)/\(filename)" } - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(manifest) + let data = try JSONEncoder.sortedPretty().encode(manifest) // Content-addressed inputs keep the previous manifest usable until atomic replacement. // Retain older generations: an interrupted build must not invalidate existing handoffs. if !files.isEmpty { diff --git a/Sources/XToolSupport/MacNotarizationOperation.swift b/Sources/XToolSupport/MacNotarizationOperation.swift index 9e3018a5..606a36a4 100644 --- a/Sources/XToolSupport/MacNotarizationOperation.swift +++ b/Sources/XToolSupport/MacNotarizationOperation.swift @@ -200,9 +200,7 @@ enum MacNotarizationOperation { state.distributionSHA256 = try ArtifactInventory.hash(zip) state.status = "stapled" // Publish recoverable evidence together with the app; a crash before the event append is resumable. - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - try encoder.encode(state).write(to: work.appendingPathComponent("receipt.json"), options: .atomic) + try JSONEncoder.sortedPretty().encode(state).write(to: work.appendingPathComponent("receipt.json"), options: .atomic) try FileManager.default.moveItem(at: work, to: destination) try append(&state, directory: directory) } @@ -319,9 +317,7 @@ enum MacNotarizationOperation { state.sequence += 1 let url = directory.appendingPathComponent(String(format: "receipt-%06d.json", state.sequence)) guard !FileManager.default.fileExists(atPath: url.path) else { throw StringError("Receipt event already exists") } - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys, .prettyPrinted] - try encoder.encode(state).write(to: url, options: .atomic) + try JSONEncoder.sortedPretty().encode(state).write(to: url, options: .atomic) } static func load(_ directory: URL) throws -> MacNotarizationReceipt { diff --git a/Sources/XToolSupport/MacSigningOperation.swift b/Sources/XToolSupport/MacSigningOperation.swift index a3e52329..895101e8 100644 --- a/Sources/XToolSupport/MacSigningOperation.swift +++ b/Sources/XToolSupport/MacSigningOperation.swift @@ -8,9 +8,7 @@ enum MacSigningOperation { let plan = try MacSigningPlan.make(bundle: bundle, mode: mode, identity: identity) try validateOutput(output, source: bundle) if dryRun { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - print(String(decoding: try encoder.encode(plan), as: UTF8.self)) + print(String(decoding: try JSONEncoder.sortedPretty().encode(plan), as: UTF8.self)) return } @Dependency(\.macHostClient) var client diff --git a/Sources/XUtils/Foundation+Utils.swift b/Sources/XUtils/Foundation+Utils.swift index 3e3bb4d3..e91f4347 100644 --- a/Sources/XUtils/Foundation+Utils.swift +++ b/Sources/XUtils/Foundation+Utils.swift @@ -32,6 +32,15 @@ extension Data { } } +extension JSONEncoder { + /// Stable, reviewable output for the manifests, receipts and plans xtool writes. + package static func sortedPretty() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + } +} + extension FileManager { /// Like `copyItem(at:to:)` but allows opting out of preserving the owner package func copyItem( From 9683d829791596de1d3c0a5e1565db1bd79dae4f Mon Sep 17 00:00:00 2001 From: Marius Rackwitz Date: Tue, 20 Oct 2026 11:46:35 +0200 Subject: [PATCH 59/59] planner: reuse inspections of version-pinned dependencies Planning ran `describe` and `dump-package` for every package on every build. A checkout pinned to a released version always inspects the same, so cache its raw output under .build/xtool-inspection. The key covers the toolchain version, identity, URL, version, checkout path and enabled traits. Local, branch and revision dependencies report no version and are inspected every time. An unreadable cache entry falls back to inspecting. On an app with two remote dependencies, the second build inspects only the root package: describe/dump-package calls drop from 3+3 to 1+1. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/PackLib/Planner.swift | 61 +++++++++++++++++++++++++---- Tests/XToolTests/PlannerTests.swift | 43 ++++++++++++++++++++ 2 files changed, 96 insertions(+), 8 deletions(-) diff --git a/Sources/PackLib/Planner.swift b/Sources/PackLib/Planner.swift index 99ce28cb..7b8f95ba 100644 --- a/Sources/PackLib/Planner.swift +++ b/Sources/PackLib/Planner.swift @@ -14,7 +14,7 @@ public struct Planner: Sendable { self.schema = schema } - private static let decoder: JSONDecoder = { + fileprivate static let decoder: JSONDecoder = { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase return decoder @@ -28,6 +28,8 @@ public struct Planner: Sendable { let dependencyRoot = try Self.decoder.decode( PackageDependency.self, from: try await inspection.dependencies(buildSettings) ) + let cacheDirectory = URL(fileURLWithPath: buildSettings.packagePath, isDirectory: true) + .appendingPathComponent(".build/xtool-inspection", isDirectory: true) var nodes: [PackageDependency] = [] var visited: Set = [] var dependencies = [dependencyRoot] @@ -36,6 +38,7 @@ public struct Planner: Sendable { dependencies.append(contentsOf: node.dependencies) nodes.append(node) } + let toolchain = nodes.contains(where: \.isPinned) ? try await inspection.toolchain() : "" let packages = try await withThrowingTaskGroup( of: (String, InspectedPackage).self, returning: [String: InspectedPackage].self ) { group in @@ -45,13 +48,8 @@ public struct Planner: Sendable { packages[identity] = package } group.addTask { - let settings = buildSettings.withPackagePath(node.path) - async let description = inspection.describe(settings) - async let manifest = inspection.manifest(settings) - return try await (node.identity, InspectedPackage( - description: Self.decoder.decode(PackageDump.self, from: jsonContents(description)), - manifest: Self.decoder.decode(PackageManifest.self, from: jsonContents(manifest)) - )) + let cache = node.cacheKey(toolchain: toolchain).map { cacheDirectory.appendingPathComponent("\($0).json") } + return try await (node.identity, inspect(node, cache: cache)) } } for try await (identity, package) in group { packages[identity] = package } @@ -63,6 +61,26 @@ public struct Planner: Sendable { return PackageGraph(rootIdentity: dependencyRoot.identity, packages: packages) } + /// A checkout pinned to a released version always inspects the same, so its results are reused across + /// builds. Local and branch dependencies change without a new version and are inspected every time. + private func inspect(_ node: PackageDependency, cache: URL?) async throws -> InspectedPackage { + if let cache, let data = try? Data(contentsOf: cache), + let package = try? JSONDecoder().decode(RawInspection.self, from: data).decoded() { + return package + } + let settings = buildSettings.withPackagePath(node.path) + async let description = inspection.describe(settings) + async let manifest = inspection.manifest(settings) + let raw = try await RawInspection(description: jsonContents(description), manifest: jsonContents(manifest)) + let package = try raw.decoded() + if let cache { + // A failed write only costs the next build this inspection. + try? FileManager.default.createDirectory(at: cache.deletingLastPathComponent(), withIntermediateDirectories: true) + try? JSONEncoder().encode(raw).write(to: cache, options: .atomic) + } + return package + } + public func createPlan() async throws -> Plan { let platform = try TargetTriple(buildSettings.triple).platform try schema.validate(for: platform) @@ -282,7 +300,32 @@ private func jsonContents(_ data: Data) -> Data { private struct PackageDependency: Decodable { let identity: String let path: String + let url: String? + let version: String? + let traits: [String]? let dependencies: [PackageDependency] + + /// Local and branch or revision dependencies report `unspecified`. + var isPinned: Bool { + url != nil && version?.wholeMatch(of: #/\d+\.\d+\.\d+([-+][0-9A-Za-z.+-]*)?/#) != nil + } + + func cacheKey(toolchain: String) -> String? { + guard isPinned, let url, let version else { return nil } + let key = [toolchain, identity, url, version, path] + (traits ?? []).sorted() + return ArtifactInventory.hash(data: Data(key.joined(separator: "\n").utf8)) + } +} + +/// SwiftPM's output for one package, kept verbatim so a cached copy decodes like a fresh one. +private struct RawInspection: Codable { + let description: Data + let manifest: Data + + func decoded() throws -> InspectedPackage { + InspectedPackage(description: try Planner.decoder.decode(PackageDump.self, from: description), + manifest: try Planner.decoder.decode(PackageManifest.self, from: manifest)) + } } private struct PackageDump: Decodable, Sendable { @@ -444,6 +487,8 @@ struct PackageInspectionClient: Sendable { var dependencies: @Sendable (BuildSettings) async throws -> Data var describe: @Sendable (BuildSettings) async throws -> Data var manifest: @Sendable (BuildSettings) async throws -> Data + /// Part of the inspection cache key: SwiftPM releases may describe the same checkout differently. + var toolchain: @Sendable () async throws -> String = { try await SwiftVersion.current.description } private static func run(_ settings: BuildSettings, arguments: [String]) async throws -> Data { let invocation = try await settings.swiftPMInvocation(forTool: "package", arguments: arguments) diff --git a/Tests/XToolTests/PlannerTests.swift b/Tests/XToolTests/PlannerTests.swift index 070775dc..7d55dd72 100644 --- a/Tests/XToolTests/PlannerTests.swift +++ b/Tests/XToolTests/PlannerTests.swift @@ -107,6 +107,49 @@ struct PlannerTests { #expect(await gauge.peak > 1) } + @Test func pinnedDependencyInspectionIsReused() async throws { + actor Calls { + var paths: [String] = [] + func record(_ path: String) { paths.append(path) } + func take() -> [String] { defer { paths = [] }; return paths.sorted() } + } + let calls = Calls() + let root = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let packages = try [root.path: package(), "/pinned": package("Pinned"), "/local": package("Local")] + func plan(version: String) async throws { + let tree = try json([ + "identity": "root", "url": root.path, "version": "unspecified", "path": root.path, "dependencies": [ + ["identity": "pinned", "url": "https://example.com/pinned", "version": version, "traits": ["default"], + "path": "/pinned", "dependencies": []] as [String: Any], + ["identity": "local", "url": "/local", "version": "unspecified", "path": "/local", "dependencies": []], + ], + ]) + let client = PackageInspectionClient( + dependencies: { _ in tree }, + describe: { settings in + await calls.record(settings.packagePath) + return try #require(packages[settings.packagePath]).description + }, + manifest: { settings in try #require(packages[settings.packagePath]).manifest }, + toolchain: { "6.2.0" } + ) + let settings = try await BuildSettings(configuration: .release, triple: "arm64-apple-ios", buildSystem: .swiftPM, + packagePath: root.path) + _ = try await withDependencies { + $0.packageInspection = client + } operation: { + try await Planner(buildSettings: settings, schema: .default).createPlan() + } + } + try await plan(version: "1.0.0") + #expect(await calls.take() == ["/local", "/pinned", root.path].sorted()) + try await plan(version: "1.0.0") + #expect(await calls.take() == ["/local", root.path].sorted()) + try await plan(version: "1.1.0") + #expect(await calls.take() == ["/local", "/pinned", root.path].sorted()) + } + @Test func sameNamedTargetsInDifferentPackagesBothContributeResources() async throws { let root = try package(targets: [target("App", dependencies: [ dependency("First", package: "one"), dependency("Second", package: "two"),