Skip to content

Commit c4f4225

Browse files
committed
refactor(xcassets): compile catalogs in Packer, not Planner
Move asset catalog compilation out of plan creation and into the packer so plans stay declarative and Xcode project generation can reference the .xcassets source directly. Diagnostics are now drained after both planning and packing.
1 parent f376da8 commit c4f4225

4 files changed

Lines changed: 167 additions & 148 deletions

File tree

Sources/PackLib/Packer.swift

Lines changed: 133 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import Foundation
22
import XUtils
3+
import XCAssetCompiler
34

45
public struct Packer: Sendable {
56
public let buildSettings: BuildSettings
67
public let plan: Plan
8+
public let diagnostics: Diagnostics
79

8-
public init(buildSettings: BuildSettings, plan: Plan) {
10+
public init(buildSettings: BuildSettings, plan: Plan, diagnostics: Diagnostics = Diagnostics()) {
911
self.plan = plan
1012
self.buildSettings = buildSettings
13+
self.diagnostics = diagnostics
1114
}
1215

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

8790
try await withThrowingTaskGroup(of: Void.self) { group in
8891
for product in plan.allProducts {
89-
try pack(
90-
product: product,
91-
binDir: binDir,
92-
outputURL: product.directory(inApp: outputURL),
93-
&group
94-
)
92+
let productOutputURL = product.directory(inApp: outputURL)
93+
group.addTask {
94+
try await pack(
95+
product: product,
96+
binDir: binDir,
97+
outputURL: productOutputURL
98+
)
99+
}
95100
}
96101

97102
while !group.isEmpty {
@@ -112,103 +117,147 @@ public struct Packer: Sendable {
112117
return dest
113118
}
114119

120+
// swiftlint:disable:next function_body_length
115121
@Sendable private func pack(
116122
product: Plan.Product,
117123
binDir: URL,
118-
outputURL: URL,
119-
_ group: inout ThrowingTaskGroup<Void, Error>
120-
) throws {
121-
@Sendable func packFileToRoot(srcName: String) async throws {
122-
let srcURL = URL(fileURLWithPath: srcName)
123-
let destURL = outputURL.appendingPathComponent(srcURL.lastPathComponent)
124-
try FileManager.default.copyItem(at: srcURL, to: destURL)
125-
126-
try Task.checkCancellation()
127-
}
124+
outputURL: URL
125+
) async throws {
126+
try? FileManager.default.createDirectory(at: outputURL, withIntermediateDirectories: true)
128127

129-
@Sendable func packFile(srcName: String, dstName: String? = nil, sign: Bool = false) async throws {
130-
let srcURL = URL(fileURLWithPath: srcName, relativeTo: binDir)
131-
let dstURL = URL(fileURLWithPath: dstName ?? srcURL.lastPathComponent, relativeTo: outputURL)
132-
try? FileManager.default.createDirectory(at: dstURL.deletingLastPathComponent(), withIntermediateDirectories: true)
133-
try FileManager.default.copyItem(at: srcURL, to: dstURL)
128+
let compiled: CompiledCatalog?
129+
if let catalogPath = product.assetCatalogPath {
130+
let compiler = XCAssetCompiler(
131+
deploymentTarget: product.deploymentTarget,
132+
diagnostics: diagnostics
133+
)
134+
compiled = try await compiler.compile(catalog: URL(fileURLWithPath: catalogPath))
135+
} else {
136+
compiled = nil
137+
}
134138

135-
try Task.checkCancellation()
139+
let effectiveIconPath: String?
140+
if let compiled, compiled.primaryIconName != nil {
141+
if product.iconPath != nil {
142+
await diagnostics.warn(
143+
"xtool.yml: iconPath is ignored because the asset catalog supplies an AppIcon."
144+
)
145+
}
146+
effectiveIconPath = nil
147+
} else {
148+
effectiveIconPath = product.iconPath
136149
}
137150

138-
// Ensure output directory is available
139-
try? FileManager.default.createDirectory(at: outputURL, withIntermediateDirectories: true)
151+
try await withThrowingTaskGroup(of: Void.self) { group in
152+
@Sendable func packFileToRoot(srcName: String) async throws {
153+
let srcURL = URL(fileURLWithPath: srcName)
154+
let destURL = outputURL.appendingPathComponent(srcURL.lastPathComponent)
155+
try FileManager.default.copyItem(at: srcURL, to: destURL)
140156

141-
for command in product.resources {
142-
group.addTask {
143-
switch command {
144-
case .bundle(let package, let target):
145-
try await packFile(srcName: "\(package)_\(target).bundle")
146-
case .binaryTarget(let name):
147-
let src = URL(fileURLWithPath: "\(name).framework/\(name)", relativeTo: binDir)
148-
let magic = Data("!<arch>\n".utf8)
149-
let thinMagic = Data("!<thin>\n".utf8)
150-
guard let bytes = try? FileHandle(forReadingFrom: src).read(upToCount: magic.count) else {
151-
// if we can't find the binary, it might be a static framework that SwiftPM
152-
// did not copy into the .build directory. we don't need to pack it anyway.
153-
break
154-
}
155-
// if the magic matches one of these it's a static archive; don't embed it.
156-
// https://github.com/apple/llvm-project/blob/e716ff14c46490d2da6b240806c04e2beef01f40/llvm/include/llvm/Object/Archive.h#L33
157-
// swiftlint:disable:previous line_length
158-
if bytes != magic && bytes != thinMagic {
159-
try await packFile(srcName: "\(name).framework", dstName: "Frameworks/\(name).framework", sign: true)
157+
try Task.checkCancellation()
158+
}
159+
160+
@Sendable func packFile(srcName: String, dstName: String? = nil, sign: Bool = false) async throws {
161+
let srcURL = URL(fileURLWithPath: srcName, relativeTo: binDir)
162+
let dstURL = URL(fileURLWithPath: dstName ?? srcURL.lastPathComponent, relativeTo: outputURL)
163+
try? FileManager.default.createDirectory(
164+
at: dstURL.deletingLastPathComponent(),
165+
withIntermediateDirectories: true
166+
)
167+
try FileManager.default.copyItem(at: srcURL, to: dstURL)
168+
169+
try Task.checkCancellation()
170+
}
171+
172+
for command in product.resources {
173+
group.addTask {
174+
switch command {
175+
case .bundle(let package, let target):
176+
try await packFile(srcName: "\(package)_\(target).bundle")
177+
case .binaryTarget(let name):
178+
let src = URL(fileURLWithPath: "\(name).framework/\(name)", relativeTo: binDir)
179+
let magic = Data("!<arch>\n".utf8)
180+
let thinMagic = Data("!<thin>\n".utf8)
181+
guard let bytes = try? FileHandle(forReadingFrom: src).read(upToCount: magic.count) else {
182+
// if we can't find the binary, it might be a static framework that SwiftPM
183+
// did not copy into the .build directory. we don't need to pack it anyway.
184+
break
185+
}
186+
// if the magic matches one of these it's a static archive; don't embed it.
187+
// https://github.com/apple/llvm-project/blob/e716ff14c46490d2da6b240806c04e2beef01f40/llvm/include/llvm/Object/Archive.h#L33
188+
// swiftlint:disable:previous line_length
189+
if bytes != magic && bytes != thinMagic {
190+
try await packFile(srcName: "\(name).framework", dstName: "Frameworks/\(name).framework", sign: true)
191+
}
192+
case .library(let name):
193+
try await packFile(srcName: "lib\(name).dylib", dstName: "Frameworks/lib\(name).dylib", sign: true)
194+
case .root(let source):
195+
try await packFileToRoot(srcName: source)
160196
}
161-
case .library(let name):
162-
try await packFile(srcName: "lib\(name).dylib", dstName: "Frameworks/lib\(name).dylib", sign: true)
163-
case .root(let source):
164-
try await packFileToRoot(srcName: source)
165197
}
166198
}
167-
}
168-
if let iconPath = product.iconPath {
169-
group.addTask {
170-
try await packFileToRoot(srcName: iconPath)
171-
}
172-
}
173-
for compiledAsset in product.compiledAssets {
174-
let bytes = compiledAsset.carData
175-
group.addTask {
176-
let destURL = outputURL.appendingPathComponent("Assets.car")
177-
try bytes.write(to: destURL)
178-
try Task.checkCancellation()
199+
if let iconPath = effectiveIconPath {
200+
group.addTask {
201+
try await packFileToRoot(srcName: iconPath)
202+
}
179203
}
180-
for emittedFile in compiledAsset.emittedFiles {
204+
if let compiled {
205+
let carData = compiled.carData
181206
group.addTask {
182-
let destURL = outputURL.appendingPathComponent(emittedFile.name)
183-
try emittedFile.data.write(to: destURL)
207+
let destURL = outputURL.appendingPathComponent("Assets.car")
208+
try carData.write(to: destURL)
184209
try Task.checkCancellation()
185210
}
211+
for emittedFile in compiled.emittedFiles {
212+
let name = emittedFile.name
213+
let data = emittedFile.data
214+
group.addTask {
215+
let destURL = outputURL.appendingPathComponent(name)
216+
try data.write(to: destURL)
217+
try Task.checkCancellation()
218+
}
219+
}
186220
}
187-
}
188-
group.addTask {
189-
try await packFile(srcName: product.targetName, dstName: product.product)
190-
}
191-
group.addTask {
192-
var info = product.infoPlist
193-
194-
if product.type == .application {
195-
info["UIRequiredDeviceCapabilities"] = ["arm64"]
196-
info["LSRequiresIPhoneOS"] = true
197-
info["CFBundleSupportedPlatforms"] = ["iPhoneOS"]
221+
group.addTask {
222+
try await packFile(srcName: product.targetName, dstName: product.product)
198223
}
224+
group.addTask {
225+
var info = product.infoPlist
226+
227+
if product.type == .application {
228+
info["UIRequiredDeviceCapabilities"] = ["arm64"]
229+
info["LSRequiresIPhoneOS"] = true
230+
info["CFBundleSupportedPlatforms"] = ["iPhoneOS"]
231+
}
199232

200-
if let iconPath = product.iconPath {
201-
let iconName = URL(fileURLWithPath: iconPath).deletingPathExtension().lastPathComponent
202-
info["CFBundleIconFile"] = iconName
233+
if let compiled {
234+
info.merge(compiled.infoPlistAdditions, uniquingKeysWith: { _, new in new })
235+
}
236+
237+
if let iconPath = effectiveIconPath {
238+
let iconName = URL(fileURLWithPath: iconPath).deletingPathExtension().lastPathComponent
239+
info["CFBundleIconFile"] = iconName
240+
}
241+
242+
let infoPath = outputURL.appendingPathComponent("Info.plist")
243+
let encodedPlist = try PropertyListSerialization.data(
244+
fromPropertyList: info,
245+
format: .xml,
246+
options: 0
247+
)
248+
try encodedPlist.write(to: infoPath)
203249
}
204250

205-
let infoPath = outputURL.appendingPathComponent("Info.plist")
206-
let encodedPlist = try PropertyListSerialization.data(
207-
fromPropertyList: info,
208-
format: .xml,
209-
options: 0
210-
)
211-
try encodedPlist.write(to: infoPath)
251+
while !group.isEmpty {
252+
do {
253+
try await group.next()
254+
} catch is CancellationError {
255+
// continue
256+
} catch {
257+
group.cancelAll()
258+
throw error
259+
}
260+
}
212261
}
213262
}
214263
}

Sources/PackLib/Planner.swift

Lines changed: 5 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import Foundation
22
import XUtils
3-
import XCAssetCompiler
43

54
public struct Planner: Sendable {
65
public var buildSettings: BuildSettings
@@ -208,38 +207,16 @@ public struct Planner: Sendable {
208207
}
209208
}
210209

211-
var compiledAssets: [Plan.CompiledAsset] = []
212-
var effectiveIconPath = iconPath
213-
if let catalogPath = assetCatalogPath {
214-
let compiler = XCAssetCompiler(deploymentTarget: deploymentTarget, diagnostics: diagnostics)
215-
let result = try await compiler.compile(catalog: URL(fileURLWithPath: catalogPath))
216-
infoPlist.merge(result.infoPlistAdditions, uniquingKeysWith: { _, new in new })
217-
if result.primaryIconName != nil, effectiveIconPath != nil {
218-
await diagnostics.warn(
219-
"xtool.yml: iconPath is ignored because the asset catalog supplies an AppIcon."
220-
)
221-
effectiveIconPath = nil
222-
} else if result.primaryIconName != nil {
223-
effectiveIconPath = nil
224-
}
225-
compiledAssets.append(Plan.CompiledAsset(
226-
carData: result.carData,
227-
emittedFiles: result.emittedFiles.map {
228-
Plan.CompiledAsset.EmittedFile(name: $0.name, data: $0.data)
229-
}
230-
))
231-
}
232-
233210
return Plan.Product(
234211
type: type,
235212
product: library.name,
236213
deploymentTarget: deploymentTarget,
237214
bundleID: bundleID,
238215
infoPlist: infoPlist,
239216
resources: resources,
240-
iconPath: effectiveIconPath,
217+
iconPath: iconPath,
241218
entitlementsPath: entitlementsPath,
242-
compiledAssets: compiledAssets
219+
assetCatalogPath: assetCatalogPath
243220
)
244221
}
245222

@@ -334,28 +311,6 @@ public struct Plan: Sendable {
334311
case root(source: String)
335312
}
336313

337-
public struct CompiledAsset: Sendable {
338-
public struct EmittedFile: Sendable {
339-
public var name: String
340-
public var data: Data
341-
342-
public init(name: String, data: Data) {
343-
self.name = name
344-
self.data = data
345-
}
346-
}
347-
348-
public var carData: Data
349-
/// Loose files to drop into the bundle root alongside Assets.car
350-
/// (e.g. appicon source PNGs that SpringBoard reads as fallbacks).
351-
public var emittedFiles: [EmittedFile]
352-
353-
public init(carData: Data, emittedFiles: [EmittedFile] = []) {
354-
self.carData = carData
355-
self.emittedFiles = emittedFiles
356-
}
357-
}
358-
359314
public struct Product: Sendable {
360315
public var type: ProductType
361316
public var product: String
@@ -365,7 +320,7 @@ public struct Plan: Sendable {
365320
public var resources: [Resource]
366321
public var iconPath: String?
367322
public var entitlementsPath: String?
368-
public var compiledAssets: [CompiledAsset]
323+
public var assetCatalogPath: String?
369324

370325
public init(
371326
type: ProductType,
@@ -376,7 +331,7 @@ public struct Plan: Sendable {
376331
resources: [Resource],
377332
iconPath: String?,
378333
entitlementsPath: String?,
379-
compiledAssets: [CompiledAsset] = []
334+
assetCatalogPath: String? = nil
380335
) {
381336
self.type = type
382337
self.product = product
@@ -386,7 +341,7 @@ public struct Plan: Sendable {
386341
self.resources = resources
387342
self.iconPath = iconPath
388343
self.entitlementsPath = entitlementsPath
389-
self.compiledAssets = compiledAssets
344+
self.assetCatalogPath = assetCatalogPath
390345
}
391346

392347
public var targetName: String {

0 commit comments

Comments
 (0)