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
73 changes: 58 additions & 15 deletions Sources/SwiftBundler/Bundler/DarwinBundler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,18 +90,19 @@ enum DarwinBundler: Bundler {
to: bundleStructure.mainExecutable
)

// Create PkgInfo and Info.plist
try createMetadataFiles(
// Copy app icon and package resources
let partialInfoPlist = try await copyResources(
bundleStructure: bundleStructure,
context: context,
additionalContext: additionalContext
)

// Copy app icon and package resources
try await copyResources(
// Create PkgInfo and Info.plist
try createMetadataFiles(
bundleStructure: bundleStructure,
context: context,
additionalContext: additionalContext
additionalContext: additionalContext,
partialInfoPlist: partialInfoPlist
)

// Copy helper executables, dynamic libraries and frameworks
Expand Down Expand Up @@ -131,7 +132,8 @@ enum DarwinBundler: Bundler {
private static func createMetadataFiles(
bundleStructure: DarwinAppBundleStructure,
context: BundlerContext,
additionalContext: Context
additionalContext: Context,
partialInfoPlist: [String: Any]?
) throws(Error) {
try Self.createPkgInfoFile(at: bundleStructure.pkgInfoFile)

Expand All @@ -140,7 +142,8 @@ enum DarwinBundler: Bundler {
appName: context.appName,
appConfiguration: context.appConfiguration,
platform: additionalContext.platform,
platformVersion: additionalContext.platformVersion
platformVersion: additionalContext.platformVersion,
partialInfoPlist: partialInfoPlist
)
}

Expand All @@ -149,25 +152,40 @@ enum DarwinBundler: Bundler {
bundleStructure: DarwinAppBundleStructure,
context: BundlerContext,
additionalContext: Context
) async throws(Error) {
) async throws(Error) -> [String: Any]? {
var partialInfoPlist: [String: Any]? = nil
if let path = context.appConfiguration.icon {
let icon = context.packageDirectory / path
try await Self.compileAppIcon(at: icon, to: bundleStructure.appIconFile)
partialInfoPlist = try await Self.compileAppIcon(
at: icon,
to: bundleStructure.appIconFile,
for: context.platform,
withPlatformVersion: additionalContext.platformVersion
)
}

do {
let appIconURL: URL?
if let iconFile = context.appConfiguration.icon {
appIconURL = context.packageDirectory / iconFile
} else {
appIconURL = nil
}
try await ResourceBundler.copyResources(
from: context.productsDirectory,
to: bundleStructure.resourcesDirectory,
fixBundles: !additionalContext.isXcodeBuild && !additionalContext.universal,
platform: context.platform,
platformVersion: additionalContext.platformVersion,
packageName: context.packageName,
productName: context.appConfiguration.product
productName: context.appConfiguration.product,
iconURL: appIconURL
)
} catch {
throw Error(.failedToCopyResourceBundles, cause: error)
}

return partialInfoPlist
}

/// Copies the app's helper executables, dynamic libraries and frameworks
Expand Down Expand Up @@ -268,7 +286,8 @@ enum DarwinBundler: Bundler {
}

do {
return try await ProvisioningProfileManager
return
try await ProvisioningProfileManager
.locateOrGenerateSuitableProvisioningProfile(
bundleIdentifier: context.appConfiguration.identifier,
deviceId: device.id,
Expand Down Expand Up @@ -316,7 +335,8 @@ enum DarwinBundler: Bundler {
appName: String,
appConfiguration: AppConfiguration.Flat,
platform: ApplePlatform,
platformVersion: String
platformVersion: String,
partialInfoPlist: [String: Any]?
) throws(Error) {
log.info("Creating 'Info.plist'")
try Error.catch(withMessage: .failedToCreateInfoPlist) {
Expand All @@ -325,7 +345,8 @@ enum DarwinBundler: Bundler {
appName: appName,
configuration: appConfiguration,
platform: platform.platform,
platformVersion: platformVersion
platformVersion: platformVersion,
partialInfoPlist: partialInfoPlist
)
}
}
Expand All @@ -338,13 +359,20 @@ enum DarwinBundler: Bundler {
/// - inputIconFile: The app's icon. Should be either an `icns` file or a
/// 1024x1024 `png` with an alpha channel.
/// - outputIconFile: The `icns` file to output to.
/// - platform: The platform the icon is being created for.
/// - platformVersion: The platform version the icon is being created for.
/// - Throws: If the png exists and there is an error while converting it to
/// `icns`, or if the file is neither an `icns` or a `png`.
private static func compileAppIcon(
at inputIconFile: URL,
to outputIconFile: URL
) async throws(Error) {
to outputIconFile: URL,
for platform: Platform,
withPlatformVersion platformVersion: String
) async throws(Error) -> [String: Any]? {
// Copy `AppIcon.icns` if present

var partialInfoPlist: [String: Any]? = nil

if inputIconFile.pathExtension == "icns" {
log.info("Copying '\(inputIconFile.lastPathComponent)'")
do {
Expand All @@ -355,6 +383,19 @@ enum DarwinBundler: Bundler {
cause: error
)
}
} else if inputIconFile.pathExtension == "icon" {
log.info(
"Creating '\(outputIconFile.lastPathComponent)' from '\(inputIconFile.lastPathComponent)'")

partialInfoPlist = try await Error.catch(withMessage: .failedToCreateIcon) {
return try await LayeredIconCompiler.createIcon(
from: inputIconFile,
outputFile: outputIconFile,
forPlatform: platform,
withPlatformVersion: platformVersion
)
}

} else if inputIconFile.pathExtension == "png" {
log.info(
"Creating '\(outputIconFile.lastPathComponent)' from '\(inputIconFile.lastPathComponent)'"
Expand All @@ -369,6 +410,8 @@ enum DarwinBundler: Bundler {
} else {
throw Error(.invalidAppIconFile(inputIconFile))
}

return partialInfoPlist
}

private static func embedProvisioningProfile(
Expand Down
144 changes: 144 additions & 0 deletions Sources/SwiftBundler/Bundler/LayeredIconCompiler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import Foundation

/// A utility for creating an ICNS from `.icon` files.
enum LayeredIconCompiler {
/// Creates an `AppIcon.icns` in the given directory from the given `.icon` file.
///
/// iOS is NOT supported.
/// - Parameters:
/// - icon: The input icon. Must be a `.icon` file. An error is returned
/// if the icon's path extension is not `icon` (case insensitive).
/// - outputFile: The location of the output `icns` file.
/// - platform: The platform the icon is being created for.
/// - version: The platform version the icon is being created for.
/// - Returns: A dictionary representation of the generated PartialInfo.plist.
static func createIcon(
from icon: URL,
outputFile: URL,
forPlatform platform: Platform,
withPlatformVersion version: String
) async throws(Error) -> [String: Any] {
guard icon.pathExtension.lowercased() == "icon" else {
throw Error(.notAnIconFile(icon))
}

let temporaryDirectory = FileManager.default.temporaryDirectory
let workPath = temporaryDirectory.appendingPathComponent("BundlerWork-\(UUID().uuidString)")

try FileManager.default.createDirectory(
at: workPath,
errorMessage: ErrorMessage.failedToCreateIconDirectory
)

let temporaryIcon = workPath.appendingPathComponent("AppIcon.icon")
try Error.catch(withMessage: .failedToCopyFile(icon, temporaryIcon)) {
try FileManager.default.copyItem(at: icon, to: temporaryIcon)
}

let partialInfoPlistPath = workPath.appendingPathComponent("PartialInfo.plist")
let targetDeviceArguments =
platform
.asApplePlatform?
.actoolTargetDeviceNames
.flatMap { ["--target-device", $0] } ?? []
let process = Process.create(
"/usr/bin/xcrun",
arguments: [
"actool",
"--compile", workPath.path,
"--enable-on-demand-resources", "NO",
"--app-icon", "AppIcon",
"--platform", platform.sdkName,
"--include-all-app-icons",
"--minimum-deployment-target", version,
"--output-partial-info-plist", partialInfoPlistPath.path,
] + targetDeviceArguments + [temporaryIcon.path]
)
do {
try await process.runAndWait()
} catch {
// Remove the work directory before throwing.
try? FileManager.default.removeItem(at: workPath)
throw Error(.failedToCompileIcon, cause: error)
}

if !partialInfoPlistPath.exists(withType: .file) {
try? FileManager.default.removeItem(at: workPath)
throw Error(.failedToCompileIcon)
}

let plistData: Data
do {
plistData = try Data(contentsOf: partialInfoPlistPath)
} catch {
try? FileManager.default.removeItem(at: workPath)
throw Error(.failedToCompileIcon, cause: error)
}

var plist: [String: Any]? = nil
do {
plist =
try PropertyListSerialization.propertyList(
from: plistData,
options: [],
format: nil
) as? [String: Any]
} catch {
try? FileManager.default.removeItem(at: workPath)
throw Error(.failedToDecodePartialInfoPlist(partialInfoPlistPath), cause: error)
}

guard let plist else {
try? FileManager.default.removeItem(at: workPath)
throw Error(.failedToDecodePartialInfoPlist(partialInfoPlistPath))
}

if platform == .macOS || platform == .macCatalyst {
let generatedIcns = workPath.appendingPathComponent("AppIcon.icns")

guard generatedIcns.exists(withType: .file) else {
try? FileManager.default.removeItem(at: workPath)
throw Error(.failedToCompileICNS)
}

do {
try FileManager.default.moveItem(at: generatedIcns, to: outputFile)
} catch {
try? FileManager.default.removeItem(at: workPath)
throw Error(.failedToCopyFile(generatedIcns, outputFile), cause: error)
}

try FileManager.default.removeItem(
at: workPath,
errorMessage: ErrorMessage.failedToRemoveIconDirectory
)
return plist
} else {
let fileEnumerator = FileManager.default.enumerator(
at: workPath, includingPropertiesForKeys: nil)
let pngFiles =
fileEnumerator?.allObjects.compactMap { $0 as? URL }
.filter { $0.pathExtension.lowercased() == "png" } ?? []

// Move all PNG files to the output directory
for pngFile in pngFiles {
let destination =
outputFile
.deletingLastPathComponent()
.appendingPathComponent(pngFile.lastPathComponent)
do {
try FileManager.default.moveItem(at: pngFile, to: destination)
} catch {
try? FileManager.default.removeItem(at: workPath)
throw Error(.failedToCopyFile(pngFile, destination), cause: error)
}
}
try FileManager.default.removeItem(
at: workPath,
errorMessage: ErrorMessage.failedToRemoveIconDirectory
)
}

return plist
}
}
45 changes: 45 additions & 0 deletions Sources/SwiftBundler/Bundler/LayeredIconCompilerError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import ErrorKit
import Foundation

extension LayeredIconCompiler {
typealias Error = RichError<ErrorMessage>

/// An error message related to ``LayeredIconCompiler``.
enum ErrorMessage: Throwable {
case notAnIconFile(URL)
case failedToCreateIconDirectory(URL)
case failedToCopyFile(URL, URL)
case failedToCompileIcon
case failedToDecodePartialInfoPlist(URL)
case failedToCompileICNS
case failedToRemoveIconDirectory(URL)

var userFriendlyMessage: String {
switch self {
case .notAnIconFile(let file):
return
"Expected icon file to have a '.icon' file extension, but got '\(file.path(relativeTo: .currentDirectory))'"
case .failedToCreateIconDirectory(let directory):
return """
Failed to create a temporary icon directory at '\(directory)'
"""
case .failedToCopyFile(let from, let to):
return """
Failed to copy file from '\(from.path(relativeTo: .currentDirectory))' to '\(to)'
"""
case .failedToCompileIcon:
return "Failed to create icon files"
case .failedToDecodePartialInfoPlist(let plistPath):
return """
Failed to decode the partial Info.plist at '\(plistPath)'
"""
case .failedToCompileICNS:
return "Failed to convert the icon to ICNS format"
case .failedToRemoveIconDirectory(let directory):
return """
Failed to remove the temporary icon directory at '\(directory)'
"""
}
}
}
}
Loading
Loading