mirror of
https://github.com/xtool-org/xtool.git
synced 2026-02-04 11:53:30 +01:00
302 lines
11 KiB
Swift
302 lines
11 KiB
Swift
import Foundation
|
|
|
|
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
|
|
}()
|
|
|
|
public func createPlan() async throws -> Plan {
|
|
// TODO: cache plan using (Package.swift+Package.resolved) as the key?
|
|
|
|
let dependencyData = try await _dumpAction(
|
|
arguments: ["show-dependencies", "--format", "json"],
|
|
path: buildSettings.packagePath
|
|
)
|
|
let dependencyRoot = try Self.decoder.decode(PackageDependency.self, from: dependencyData)
|
|
|
|
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]!
|
|
let deploymentTarget = rootPackage.platforms?.first { $0.name == "ios" }?.version ?? "13.0"
|
|
|
|
let libraries = rootPackage.products?.filter { $0.type == .autoLibrary } ?? []
|
|
|
|
let library = try selectLibrary(
|
|
from: libraries,
|
|
matching: schema.base.product
|
|
)
|
|
|
|
var resources: [Resource] = []
|
|
var visited: Set<String> = []
|
|
var targets = library.targets.map { (rootPackage, $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 ?? []) {
|
|
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)'")
|
|
}
|
|
if product.type == .dynamicLibrary {
|
|
resources.append(.library(name: productName))
|
|
}
|
|
targets.append(contentsOf: product.targets.map { (package, $0) })
|
|
}
|
|
}
|
|
|
|
if let rootResources = schema.base.resources {
|
|
resources += rootResources.map { .root(source: $0) }
|
|
}
|
|
|
|
let bundleID = schema.idSpecifier.formBundleID(product: library.name)
|
|
|
|
var infoPlist: [String: Sendable] = [
|
|
"CFBundleInfoDictionaryVersion": "6.0",
|
|
"UIRequiredDeviceCapabilities": ["arm64"],
|
|
"LSRequiresIPhoneOS": true,
|
|
"CFBundleSupportedPlatforms": ["iPhoneOS"],
|
|
"CFBundlePackageType": "APPL",
|
|
"UIDeviceFamily": [1, 2],
|
|
"CFBundleDevelopmentRegion": "en",
|
|
"UISupportedInterfaceOrientations": ["UIInterfaceOrientationPortrait"],
|
|
"UISupportedInterfaceOrientations~ipad": [
|
|
"UIInterfaceOrientationPortrait",
|
|
"UIInterfaceOrientationPortraitUpsideDown",
|
|
"UIInterfaceOrientationLandscapeLeft",
|
|
"UIInterfaceOrientationLandscapeRight"
|
|
],
|
|
"UILaunchScreen": [:] as [String: Sendable],
|
|
"CFBundleVersion": "1",
|
|
"CFBundleShortVersionString": "1.0.0",
|
|
"MinimumOSVersion": deploymentTarget,
|
|
"CFBundleIdentifier": bundleID,
|
|
"CFBundleName": "\(library.name)",
|
|
"CFBundleExecutable": "\(library.name)",
|
|
]
|
|
|
|
if let plist = self.schema.base.infoPath {
|
|
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: library.name,
|
|
deploymentTarget: deploymentTarget,
|
|
bundleID: bundleID,
|
|
infoPlist: infoPlist,
|
|
resources: resources,
|
|
iconPath: self.schema.base.iconPath
|
|
)
|
|
}
|
|
|
|
private func dumpPackage(at path: String) async throws -> PackageDump {
|
|
let data = try await _dumpAction(arguments: ["describe", "--type", "json"], path: path)
|
|
try Task.checkCancellation()
|
|
return try Self.decoder.decode(PackageDump.self, from: data)
|
|
}
|
|
|
|
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 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 enum Resource: Codable, Sendable {
|
|
case bundle(package: String, target: String)
|
|
case binaryTarget(name: String)
|
|
case library(name: String)
|
|
case root(source: String)
|
|
}
|
|
|
|
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]?
|
|
}
|