Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ jobs:
- name: Build
run: |
swift build --product xtool && .build/debug/xtool --help
- name: Run tests
run: |
swift test
build-ios:
runs-on: macos-26
steps:
Expand Down
20 changes: 19 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

46 changes: 32 additions & 14 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ let package = Package(
.package(url: "https://github.com/mxcl/Version", from: "2.1.0"),
.package(url: "https://github.com/jpsim/Yams", from: "5.1.3"),
.package(url: "https://github.com/saagarjha/unxip", from: "3.2.0"),
.package(url: "https://github.com/tayloraswift/swift-png", from: "4.5.0"),

// TODO: just depend on tuist/XcodeProj instead
.package(url: "https://github.com/yonaskolb/XcodeGen", from: "2.45.4"),
Expand Down Expand Up @@ -138,20 +139,20 @@ let package = Package(
"XToolSupport",
]
),
.testTarget(
name: "XKitTests",
dependencies: [
"XKit",
.product(name: "SuperutilsTestSupport", package: "xtool-core")
],
exclude: [
"config/config-template.json",
],
resources: [
.copy("config/config.json"),
.copy("config/test.app"),
]
),
// .testTarget(
// name: "XKitTests",
// dependencies: [
// "XKit",
// .product(name: "SuperutilsTestSupport", package: "xtool-core")
// ],
// exclude: [
// "config/config-template.json",
// ],
// resources: [
// .copy("config/config.json"),
// .copy("config/test.app"),
// ]
// ),
.target(
name: "XToolSupport",
dependencies: [
Expand All @@ -170,10 +171,27 @@ let package = Package(
name: "PackLib",
dependencies: [
"XUtils",
"XCAssetCompiler",
.product(name: "Yams", package: "Yams"),
.product(name: "XcodeGenKit", package: "XcodeGen", condition: .when(platforms: [.macOS])),
]
),
.target(
name: "XCAssetCompiler",
dependencies: [
"XUtils",
.product(name: "PNG", package: "swift-png"),
]
),
.testTarget(
name: "XCAssetCompilerTests",
dependencies: [
"XCAssetCompiler",
],
resources: [
.copy("Fixtures"),
]
),
.executableTarget(
name: "xtool",
dependencies: [
Expand Down
5 changes: 5 additions & 0 deletions Sources/PackLib/PackSchema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public struct PackSchemaBase: Codable, Sendable {

public var iconPath: String?
public var resources: [String]?
public var assetCatalogs: [String]?

public var extensions: [Extension]?

Expand Down Expand Up @@ -72,6 +73,10 @@ public struct PackSchema: Sendable {
throw StringError("xtool.yml: iconPath should have a 'png' path extension. Got '\(ext)'.")
}
}

if let catalogs = base.assetCatalogs, catalogs.count > 1 {
throw StringError("xtool.yml: assetCatalogs supports at most one catalog in this release.")
}
}

// swiftlint:disable:next force_try
Expand Down
206 changes: 135 additions & 71 deletions Sources/PackLib/Packer.swift
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import Foundation
import XUtils
import XCAssetCompiler

public struct Packer: Sendable {
public let buildSettings: BuildSettings
public let plan: Plan
public let diagnostics: Diagnostics

public init(buildSettings: BuildSettings, plan: Plan) {
public init(buildSettings: BuildSettings, plan: Plan, diagnostics: Diagnostics = Diagnostics()) {
self.plan = plan
self.buildSettings = buildSettings
self.diagnostics = diagnostics
}

private func build() async throws {
Expand Down Expand Up @@ -86,12 +89,14 @@ public struct Packer: Sendable {

try await withThrowingTaskGroup(of: Void.self) { group in
for product in plan.allProducts {
try pack(
product: product,
binDir: binDir,
outputURL: product.directory(inApp: outputURL),
&group
)
let productOutputURL = product.directory(inApp: outputURL)
group.addTask {
try await pack(
product: product,
binDir: binDir,
outputURL: productOutputURL
)
}
}

while !group.isEmpty {
Expand All @@ -112,88 +117,147 @@ public struct Packer: Sendable {
return dest
}

// swiftlint:disable:next function_body_length
@Sendable private func pack(
product: Plan.Product,
binDir: URL,
outputURL: URL,
_ group: inout ThrowingTaskGroup<Void, Error>
) 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()
}
outputURL: URL
) async throws {
try? FileManager.default.createDirectory(at: outputURL, withIntermediateDirectories: true)

@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)
let compiled: CompiledCatalog?
if let catalogPath = product.assetCatalogPath {
let compiler = XCAssetCompiler(
deploymentTarget: product.deploymentTarget,
diagnostics: diagnostics
)
compiled = try await compiler.compile(catalog: URL(fileURLWithPath: catalogPath))
} else {
compiled = nil
}

try Task.checkCancellation()
let effectiveIconPath: String?
if let compiled, compiled.primaryIconName != nil {
if product.iconPath != nil {
await diagnostics.warn(
"xtool.yml: iconPath is ignored because the asset catalog supplies an AppIcon."
)
}
effectiveIconPath = nil
} else {
effectiveIconPath = product.iconPath
}

// Ensure output directory is available
try? FileManager.default.createDirectory(at: outputURL, withIntermediateDirectories: true)
try await withThrowingTaskGroup(of: Void.self) { group in
@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)

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("!<arch>\n".utf8)
let thinMagic = Data("!<thin>\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
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()
}

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("!<arch>\n".utf8)
let thinMagic = Data("!<thin>\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 .root(let source):
try await packFileToRoot(srcName: source)
}
// 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)
}
}
if let iconPath = effectiveIconPath {
group.addTask {
try await packFileToRoot(srcName: iconPath)
}
}
if let compiled {
let carData = compiled.carData
group.addTask {
let destURL = outputURL.appendingPathComponent("Assets.car")
try carData.write(to: destURL)
try Task.checkCancellation()
}
for emittedFile in compiled.emittedFiles {
let name = emittedFile.name
let data = emittedFile.data
group.addTask {
let destURL = outputURL.appendingPathComponent(name)
try data.write(to: destURL)
try Task.checkCancellation()
}
case .library(let name):
try await packFile(srcName: "lib\(name).dylib", dstName: "Frameworks/lib\(name).dylib", sign: true)
case .root(let source):
try await packFileToRoot(srcName: source)
}
}
}
if let iconPath = product.iconPath {
group.addTask {
try await packFileToRoot(srcName: iconPath)
try await packFile(srcName: product.targetName, dstName: product.product)
}
}
group.addTask {
try await packFile(srcName: product.targetName, dstName: product.product)
}
group.addTask {
var info = product.infoPlist
group.addTask {
var info = product.infoPlist

if product.type == .application {
info["UIRequiredDeviceCapabilities"] = ["arm64"]
info["LSRequiresIPhoneOS"] = true
info["CFBundleSupportedPlatforms"] = ["iPhoneOS"]
}
if product.type == .application {
info["UIRequiredDeviceCapabilities"] = ["arm64"]
info["LSRequiresIPhoneOS"] = true
info["CFBundleSupportedPlatforms"] = ["iPhoneOS"]
}

if let iconPath = product.iconPath {
let iconName = URL(fileURLWithPath: iconPath).deletingPathExtension().lastPathComponent
info["CFBundleIconFile"] = iconName
if let compiled {
info.merge(compiled.infoPlistAdditions, uniquingKeysWith: { _, new in new })
}

if let iconPath = effectiveIconPath {
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 infoPath = outputURL.appendingPathComponent("Info.plist")
let encodedPlist = try PropertyListSerialization.data(
fromPropertyList: info,
format: .xml,
options: 0
)
try encodedPlist.write(to: infoPath)
while !group.isEmpty {
do {
try await group.next()
} catch is CancellationError {
// continue
} catch {
group.cancelAll()
throw error
}
}
}
}
}
Expand Down
Loading