Files
xtool-mirror/Sources/PackLib/Planner.swift
Kabir Oberai 836fca8daf Improve tmpdir management (#147)
- We now have a single tmpdir "root" that can be recreated at launch to
clean up old stragglers
- The location of this tmpdir root can be controlled by `XTL_TMPDIR` or
`TMPDIR`. In general the path is `$TMPDIR/sh.xtool`.

With this change it should be possible to `export XTL_TMPDIR=/var/tmp`
if `/tmp` doesn't have enough space, which fixes #23.
2025-08-10 01:43:09 -04:00

447 lines
16 KiB
Swift

import Foundation
import XUtils
public struct Planner: Sendable {
public var buildSettings: BuildSettings
public var schema: PackSchema
public init(
buildSettings: BuildSettings,
schema: PackSchema
) {
self.buildSettings = buildSettings
self.schema = schema
}
private static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
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()
let packages = try await withThrowingTaskGroup(
of: (PackageDependency, PackageDump).self,
returning: [String: PackageDump].self
) { group in
var visited: Set<String> = []
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
}
}
return packages
}
var packagesByProductName: [String: String] = [:]
for (packageID, package) in packages {
for product in package.products ?? [] {
packagesByProductName[product.name] = packageID
}
}
let rootPackage = packages[dependencyRoot.identity]!
return PackageGraph(
root: rootPackage,
packages: packages,
packagesByProductName: packagesByProductName
)
}
public func createPlan() async throws -> Plan {
// TODO: cache plan using (Package.swift+Package.resolved) as the key?
let graph = try await buildGraph()
let app = try await product(
from: graph,
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,
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 = []
}
return Plan(app: app, extensions: extensionProducts)
}
// swiftlint:disable cyclomatic_complexity function_parameter_count
private func product(
from graph: PackageGraph,
matching name: String?,
type: Plan.ProductType,
plist: String?,
idSpecifier: PackSchema.IDSpecifier,
iconPath: String?,
rootResources: [String]?,
entitlementsPath: String?
) 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<String> = []
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 }
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) })
}
}
if let rootResources {
resources += rootResources.map { .root(source: $0) }
}
let bundleID = idSpecifier.formBundleID(product: library.name)
let deploymentTarget = graph.root.platforms?.first { $0.name == "ios" }?.version
?? Self.minSupportedIOSVersion
var infoPlist: [String: Sendable] = [
"CFBundleInfoDictionaryVersion": "6.0",
"CFBundleDevelopmentRegion": "en",
"CFBundleVersion": "1",
"CFBundleShortVersionString": "1.0.0",
"MinimumOSVersion": deploymentTarget,
"CFBundleIdentifier": bundleID,
"CFBundleName": library.name,
"CFBundleExecutable": library.name,
"CFBundleDisplayName": library.name,
"CFBundlePackageType": type.fourCharCode,
]
switch type {
case .application:
infoPlist["UIDeviceFamily"] = [1, 2]
infoPlist["UISupportedInterfaceOrientations"] = ["UIInterfaceOrientationPortrait"]
infoPlist["UISupportedInterfaceOrientations~ipad"] = [
"UIInterfaceOrientationPortrait",
"UIInterfaceOrientationPortraitUpsideDown",
"UIInterfaceOrientationLandscapeLeft",
"UIInterfaceOrientationLandscapeRight",
]
infoPlist["UILaunchScreen"] = [:] as [String: Sendable]
case .appExtension:
// Should set default parameters?
infoPlist["NSExtension"] = [:] as [String: Sendable]
}
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.")
}
}
return Plan.Product(
type: type,
product: library.name,
deploymentTarget: deploymentTarget,
bundleID: bundleID,
infoPlist: infoPlist,
resources: resources,
iconPath: iconPath,
entitlementsPath: entitlementsPath
)
}
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 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 _dumpAction(arguments: [String], path: String) async throws -> Data {
let dump = try await buildSettings.swiftPMInvocation(
forTool: "package",
arguments: arguments,
packagePathOverride: path
)
let pipe = Pipe()
dump.standardOutput = pipe
async let task = Data(reading: pipe.fileHandleForReading)
try await dump.runUntilExit()
return try await task
}
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)')
""")
}
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.
""")
}
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)).
""")
}
return product
}
}
}
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
let path: String // on disk
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
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if container.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 {
self = .other
}
}
}
struct Product: Decodable {
let name: String
let targets: [String]
let type: ProductType
}
struct Target: Decodable {
let name: String
let moduleType: String
let productDependencies: [String]?
let targetDependencies: [String]?
let resources: [Resource]?
}
struct Resource: Decodable {
let path: String
}
struct Platform: Decodable {
let name: String
let version: String
}
let name: String
let products: [Product]?
let targets: [Target]?
let platforms: [Platform]?
}
private struct PackageGraph {
let root: PackageDump
let packages: [String: PackageDump]
let packagesByProductName: [String: String]
func product(name productName: String) throws -> (PackageDump, PackageDump.Product) {
guard let packageID = packagesByProductName[productName] else {
throw StringError("Could not find package containing product '\(productName)'")
}
guard let package = packages[packageID] else {
throw StringError("Could not find package by id '\(packageID)'")
}
guard let product = package.products?.first(where: { $0.name == productName }) else {
throw StringError("Could not find product '\(productName)' in package '\(packageID)'")
}
return (package, product)
}
}