mirror of
https://github.com/xtool-org/xtool.git
synced 2026-02-04 11:53:30 +01:00
- 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.
447 lines
16 KiB
Swift
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)
|
|
}
|
|
}
|