Files
QLAppBundle/src/Plist.swift
2025-10-28 21:21:21 +01:00

191 lines
5.7 KiB
Swift

import Foundation
import os // OSLog
private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Plist")
typealias PlistDict = [String: Any] // basically an untyped Dict
// MARK: -
extension Data {
/// Helper for optional chaining.
func asPlistOrNil() -> PlistDict? {
if self.isEmpty {
return nil
}
// var format: PropertyListSerialization.PropertyListFormat = .xml
do {
return try PropertyListSerialization.propertyList(from: self, format: nil) as? PlistDict
} catch {
os_log(.error, log: log, "ERROR reading plist %{public}@", error.localizedDescription)
return nil
}
}
}
// MARK: -
extension QuickLookInfo {
/// Read app default `Info.plist`.
func readPlistApp() -> PlistDict? {
switch self.type {
case .IPA, .Archive, .Extension:
return self.readPayloadFile("Info.plist")?.asPlistOrNil()
}
}
/// Read `iTunesMetadata.plist` if available
func readPlistItunes() -> PlistDict? {
switch self.type {
case .IPA:
return self.zipFile!.unzipFile("iTunesMetadata.plist")?.asPlistOrNil()
case .Archive, .Extension:
return nil
}
}
/// Read `embedded.mobileprovision` file and decode with CMS decoder.
func readPlistProvision() -> PlistDict? {
guard let provisionData = self.readPayloadFile("embedded.mobileprovision") else {
os_log(.info, log: log, "No embedded.mobileprovision file for %{public}@", self.url.path)
return nil
}
var decoder: CMSDecoder? = nil
CMSDecoderCreate(&decoder)
let data = provisionData.withUnsafeBytes { ptr in
CMSDecoderUpdateMessage(decoder!, ptr.baseAddress!, provisionData.count)
CMSDecoderFinalizeMessage(decoder!)
var dataRef: CFData?
CMSDecoderCopyContent(decoder!, &dataRef)
return Data(referencing: dataRef!)
}
return data.asPlistOrNil()
}
}
// MARK: -
extension AppIcon {
/// Parse app plist to find the bundle icon filename.
/// @param appPlist If `nil`, will load plist on the fly (used for thumbnail)
/// @return Filenames which do not necessarily exist on filesystem. This may include `@2x` and/or no file extension.
func iconNamesFromPlist(_ appPlist: PlistDict?) -> [String] {
let appPlist = appPlist == nil ? meta.readPlistApp()! : appPlist!
// Check for CFBundleIcons (since 5.0)
if let icons = unpackNameListFromPlistDict(appPlist["CFBundleIcons"]), !icons.isEmpty {
return icons
}
// iPad-only apps
if let icons = unpackNameListFromPlistDict(appPlist["CFBundleIcons~ipad"]), !icons.isEmpty {
return icons
}
// Check for CFBundleIconFiles (since 3.2)
if let icons = appPlist["CFBundleIconFiles"] as? [String], !icons.isEmpty {
return icons
}
// key found on iTunesU app
if let icons = appPlist["Icon files"] as? [String], !icons.isEmpty {
return icons
}
// Check for CFBundleIconFile (legacy, before 3.2)
if let icon = appPlist["CFBundleIconFile"] as? String { // may be nil
return [icon]
}
return [] // [self sortedByResolution:icons];
}
/// Given a filename, search Bundle or Filesystem for files that match. Select the filename with the highest resolution.
func expandImageName(_ iconList: [String]) -> String? {
var matches: [String] = []
switch meta.type {
case .IPA:
guard let zipFile = meta.zipFile else {
// in case unzip in memory is not available, fallback to pattern matching with dynamic suffix
return "Payload/*.app/\(iconList.first!)*"
}
for iconPath in iconList {
let zipPath = "Payload/*.app/\(iconPath)*"
for zip in zipFile.filesMatching(zipPath) {
if zip.sizeUncompressed > 0 {
matches.append(zip.filepath)
}
}
if matches.count > 0 {
break
}
}
case .Archive, .Extension:
let basePath = meta.effectiveUrl ?? meta.url
for iconPath in iconList {
let fileName = iconPath.components(separatedBy: "/").last!
let parentDir = basePath.appendingPathComponent(iconPath, isDirectory: false).deletingLastPathComponent().path
guard let files = try? FileManager.default.contentsOfDirectory(atPath: parentDir) else {
continue
}
for file in files {
if file.hasPrefix(fileName) {
let fullPath = parentDir + "/" + file
if let fSize = try? FileManager.default.attributesOfItem(atPath: fullPath)[FileAttributeKey.size] as? Int {
if fSize > 0 {
matches.append(fullPath)
}
}
}
}
if matches.count > 0 {
break
}
}
}
return matches.isEmpty ? nil : sortedByResolution(matches).first
}
/// Deep select icons from plist key `CFBundleIcons` and `CFBundleIcons~ipad`
private func unpackNameListFromPlistDict(_ bundleDict: Any?) -> [String]? {
if let bundleDict = bundleDict as? PlistDict {
if let primaryDict = bundleDict["CFBundlePrimaryIcon"] as? PlistDict {
if let icons = primaryDict["CFBundleIconFiles"] as? [String] {
return icons
}
if let name = primaryDict["CFBundleIconName"] as? String { // key found on a .tipa file
return [name]
}
}
}
return nil
}
/// @return lower index means higher resolution.
private func resolutionIndex(_ iconName: String) -> Int {
let lower = iconName.lowercased()
// "defaultX" = launch image
let penalty = lower.contains("small") || lower.hasPrefix("default") ? 20 : 0
let resolutionOrder: [String] = [
"@3x", "180", "167", "152", "@2x", "120",
"144", "114", "87", "80", "76", "72", "58", "57"
]
for (i, res) in resolutionOrder.enumerated() {
if iconName.contains(res) {
return i + penalty
}
}
return 50 + penalty
}
/// Given a list of filenames, order them highest resolution first.
private func sortedByResolution(_ icons: [String]) -> [String] {
return icons.sorted { (icon1, icon2) -> Bool in
let index1 = self.resolutionIndex(icon1)
let index2 = self.resolutionIndex(icon2)
return index1 < index2
}
}
}