ref: move code around

This commit is contained in:
relikd
2025-10-30 12:09:00 +01:00
parent df438c8581
commit d615ae0844
18 changed files with 822 additions and 799 deletions

View File

@@ -6,9 +6,9 @@ private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "App
struct AppIcon {
let meta: QuickLookInfo
let meta: MetaInfo
init(_ meta: QuickLookInfo) {
init(_ meta: MetaInfo) {
self.meta = meta
}
@@ -53,6 +53,128 @@ struct AppIcon {
}
// MARK: - Plist
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.
private 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.
private 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
}
}
}
// MARK: - Extension: NSImage
// AppIcon extension

86
src/Date+Format.swift Normal file
View File

@@ -0,0 +1,86 @@
import Foundation
import os // OSLog
private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Html+Date")
extension DateComponents {
/// @return Print largest component. E.g., "3 days" or "14 hours"
fileprivate func relativeDateString() -> String {
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .full
formatter.maximumUnitCount = 1
return formatter.string(from: self)!
}
}
extension Date {
/// @return Print the date with current locale and medium length style.
func mediumFormat() -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .medium
return formatter.string(from: self)
}
/// Parse date from plist regardless if it has `NSDate` or `NSString` type.
static func parseAny(_ value: Any?) -> Date? {
if let date = value as? Date {
return date
}
guard let stringValue = value as? String else {
return nil
}
// parse the date from a string
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z"
if let date = formatter.date(from: stringValue) {
return date
}
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
if let date = formatter.date(from: stringValue) {
return date
}
os_log(.error, log: log, "ERROR formatting date: %{public}@", stringValue)
return nil
}
/// @return Difference between two dates as components.
private func diff(_ other: Date) -> DateComponents {
return Calendar.current.dateComponents([.day, .hour, .minute], from: self, to: other)
}
/// @return Relative distance to today. E.g., "Expired today"
func relativeExpirationDateString() -> String {
let isPast = self < Date()
let isToday = Calendar.current.isDateInToday(self)
if isToday {
return isPast ? "<span>Expired today</span>" : "<span>Expires today</span>"
}
if isPast {
let comp = self.diff(Date())
return "<span>Expired \(comp.relativeDateString()) ago</span>"
}
let comp = Date().diff(self)
if comp.day! < 30 {
return "<span>Expires in \(comp.relativeDateString())</span>"
}
return "Expires in \(comp.relativeDateString())"
}
/// @return Relative distance to today. E.g., "DATE (Expires in 3 days)"
func formattedExpirationDate() -> String {
return "\(self.mediumFormat()) (\(relativeExpirationDateString()))"
}
/// @return Relative distance to today. E.g., "DATE (Created 3 days ago)"
func formattedCreationDate() -> String {
let isToday = Calendar.current.isDateInToday(self)
let comp = self.diff(Date())
return "\(self.mediumFormat()) (Created \(isToday ? "today" : "\(comp.relativeDateString()) ago"))"
}
}

View File

@@ -56,7 +56,7 @@ struct Entitlements {
}
/// Print formatted plist in a @c \<pre> tag
func format(_ plist: [String: Any]?) -> String? {
private func format(_ plist: [String: Any]?) -> String? {
guard let plist else {
return codeSignError // may be nil
}
@@ -68,7 +68,7 @@ struct Entitlements {
// MARK: - SecCode in-memory reader
/// use in-memory `SecCode` for entitlement extraction
func getSecCodeEntitlements() -> PlistDict? {
private func getSecCodeEntitlements() -> PlistDict? {
let url = URL(fileURLWithPath: self.binaryPath)
var codeRef: SecStaticCode?
SecStaticCodeCreateWithPath(url as CFURL, [], &codeRef)
@@ -114,7 +114,7 @@ struct Entitlements {
// MARK: - Plist formatter
/// Print recursive tree of key-value mappings.
func recursiveKeyValue(_ value: Any, _ output: inout String, _ level: Int = -1, _ key: String? = nil) {
private func recursiveKeyValue(_ value: Any, _ output: inout String, _ level: Int = -1, _ key: String? = nil) {
let indent = level > 0 ? String(repeating: " ", count: level * 4) : ""
let prefix = indent + (key?.appending(" = ") ?? "")

View File

@@ -0,0 +1,25 @@
import Foundation
enum ExpirationStatus {
case Expired
case Expiring
case Valid
/// Check time between date and now. Set Expiring if less than 30 days until expiration
init(_ date: Date?) {
if date == nil || date!.timeIntervalSinceNow < 0 {
self = .Expired
}
let components = Calendar.current.dateComponents([.day], from: Date(), to: date!)
self = components.day! < 30 ? .Expiring : .Valid
}
/// @return CSS class for expiration status.
func cssClass() -> String {
switch self {
case .Expired: return "expired"
case .Expiring: return "expiring"
case .Valid: return "valid"
}
}
}

103
src/Html+AppInfo.swift Normal file
View File

@@ -0,0 +1,103 @@
import Foundation
/// Print recursive tree of key-value mappings.
private func recursiveDict(_ dictionary: [String: Any], withReplacements replacements: [String: String] = [:], _ level: Int = 0) -> String {
var output = ""
for (key, value) in dictionary {
let localizedKey = replacements[key] ?? key
for _ in 0..<level {
output += (level == 1) ? "- " : "&nbsp;&nbsp;"
}
if let subDict = value as? [String: Any] {
output += "\(localizedKey):<div class=\"list\">\n"
output += recursiveDict(subDict, withReplacements: replacements, level + 1)
output += "</div>\n"
} else if let number = value as? NSNumber {
output += "\(localizedKey): \(number.boolValue ? "YES" : "NO")<br />"
} else {
output += "\(localizedKey): \(value)<br />"
}
}
return output
}
extension HtmlGenerator {
/// @return List of ATS flags.
private func formattedAppTransportSecurity(_ appPlist: PlistDict) -> String {
if let value = appPlist["NSAppTransportSecurity"] as? PlistDict {
let localizedKeys = [
"NSAllowsArbitraryLoads": "Allows Arbitrary Loads",
"NSAllowsArbitraryLoadsForMedia": "Allows Arbitrary Loads for Media",
"NSAllowsArbitraryLoadsInWebContent": "Allows Arbitrary Loads in Web Content",
"NSAllowsLocalNetworking": "Allows Local Networking",
"NSExceptionDomains": "Exception Domains",
"NSIncludesSubdomains": "Includes Subdomains",
"NSRequiresCertificateTransparency": "Requires Certificate Transparency",
"NSExceptionAllowsInsecureHTTPLoads": "Allows Insecure HTTP Loads",
"NSExceptionMinimumTLSVersion": "Minimum TLS Version",
"NSExceptionRequiresForwardSecrecy": "Requires Forward Secrecy",
"NSThirdPartyExceptionAllowsInsecureHTTPLoads": "Allows Insecure HTTP Loads",
"NSThirdPartyExceptionMinimumTLSVersion": "Minimum TLS Version",
"NSThirdPartyExceptionRequiresForwardSecrecy": "Requires Forward Secrecy",
]
return "<div class=\"list\">\(recursiveDict(value, withReplacements: localizedKeys))</div>"
}
let sdkName = appPlist["DTSDKName"] as? String ?? "0"
let sdkNumber = Double(sdkName.trimmingCharacters(in: .letters)) ?? 0
if sdkNumber < 9.0 {
return "Not applicable before iOS 9.0"
}
return "No exceptions"
}
/// Process info stored in `Info.plist`
mutating func procAppInfo(_ appPlist: PlistDict?) {
guard let appPlist else {
self.apply([
"AppInfoHidden": "hiddenDiv",
"ProvisionTitleHidden": "",
])
return
}
var platforms = (appPlist["UIDeviceFamily"] as? [Int])?.compactMap({
switch $0 {
case 1: return "iPhone"
case 2: return "iPad"
case 3: return "TV"
case 4: return "Watch"
default: return nil
}
}).joined(separator: ", ")
let minVersion = appPlist["MinimumOSVersion"] as? String ?? ""
if platforms?.isEmpty ?? true, minVersion.hasPrefix("1.") || minVersion.hasPrefix("2.") || minVersion.hasPrefix("3.") {
platforms = "iPhone"
}
let extensionType = (appPlist["NSExtension"] as? PlistDict)?["NSExtensionPointIdentifier"] as? String
self.apply([
"AppInfoHidden": "",
"ProvisionTitleHidden": "hiddenDiv",
"CFBundleName": appPlist["CFBundleDisplayName"] as? String ?? appPlist["CFBundleName"] as? String ?? "",
"CFBundleShortVersionString": appPlist["CFBundleShortVersionString"] as? String ?? "",
"CFBundleVersion": appPlist["CFBundleVersion"] as? String ?? "",
"CFBundleIdentifier": appPlist["CFBundleIdentifier"] as? String ?? "",
"ExtensionTypeHidden": extensionType != nil ? "" : "hiddenDiv",
"ExtensionType": extensionType ?? "",
"UIDeviceFamily": platforms ?? "",
"DTSDKName": appPlist["DTSDKName"] as? String ?? "",
"MinimumOSVersion": minVersion,
"AppTransportSecurityFormatted": formattedAppTransportSecurity(appPlist),
])
}
}

View File

@@ -0,0 +1,36 @@
import Foundation
extension HtmlGenerator {
/// Search for app binary and run `codesign` on it.
private func readEntitlements(_ meta: MetaInfo, _ bundleExecutable: String?) -> Entitlements {
guard let bundleExecutable else {
return Entitlements.withoutBinary()
}
switch meta.type {
case .IPA:
let tmpPath = NSTemporaryDirectory() + "/" + UUID().uuidString
try! FileManager.default.createDirectory(atPath: tmpPath, withIntermediateDirectories: true)
defer {
try? FileManager.default.removeItem(atPath: tmpPath)
}
try! meta.zipFile!.unzipFile("Payload/*.app/\(bundleExecutable)", toDir: tmpPath)
return Entitlements(forBinary: tmpPath + "/" + bundleExecutable)
case .Archive:
return Entitlements(forBinary: meta.effectiveUrl!.path + "/" + bundleExecutable)
case .Extension:
return Entitlements(forBinary: meta.url.path + "/" + bundleExecutable)
}
}
/// Process compiled binary and provision plist to extract `Entitlements`
mutating func procEntitlements(_ meta: MetaInfo, _ appPlist: PlistDict?, _ provisionPlist: PlistDict?) {
var entitlements = readEntitlements(meta, appPlist?["CFBundleExecutable"] as? String)
entitlements.applyFallbackIfNeeded(provisionPlist?["Entitlements"] as? PlistDict)
self.apply([
"EntitlementsWarningHidden": entitlements.hasError ? "" : "hiddenDiv",
"EntitlementsFormatted": entitlements.html ?? "No Entitlements",
])
}
}

43
src/Html+FileInfo.swift Normal file
View File

@@ -0,0 +1,43 @@
import Foundation
extension HtmlGenerator {
/// Calculate file / folder size.
private func getFileSize(_ path: String) -> Int64 {
var isDir: ObjCBool = false
FileManager.default.fileExists(atPath: path, isDirectory: &isDir)
if !isDir.boolValue {
return try! FileManager.default.attributesOfItem(atPath: path)[.size] as! Int64
}
var fileSize: Int64 = 0
for child in try! FileManager.default.subpathsOfDirectory(atPath: path) {
fileSize += try! FileManager.default.attributesOfItem(atPath: path + "/" + child)[.size] as! Int64
}
return fileSize
}
/// Process meta information about the file itself. Like file size and last modification.
mutating func procFileInfo(_ url: URL) {
let formattedValue : String
if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) {
let size = ByteCountFormatter.string(fromByteCount: getFileSize(url.path), countStyle: .file)
formattedValue = "\(size), Modified \((attrs[.modificationDate] as! Date).mediumFormat())"
} else {
formattedValue = ""
}
self.apply([
"FileName": escapeXML(url.lastPathComponent),
"FileInfo": formattedValue,
])
}
}
/// Replace occurrences of chars `&"'<>` with html encoding.
private func escapeXML(_ stringToEscape: String) -> String {
return stringToEscape
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&apos;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
}

11
src/Html+Footer.swift Normal file
View File

@@ -0,0 +1,11 @@
import Foundation
extension HtmlGenerator {
/// Process meta information about the plugin. Like version and debug flag.
mutating func procFooterInfo() {
self.apply([
"BundleShortVersionString": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "",
"BundleVersion": Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "",
])
}
}

160
src/Html+Provisioning.swift Normal file
View File

@@ -0,0 +1,160 @@
import Foundation
import os // OSLog
private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Html+Certificates")
extension MetaInfo {
/// 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()
}
}
extension HtmlGenerator {
// MARK: - Certificates
/// Process a single certificate. Extract invalidity / expiration date.
/// @param subject just used for printing error logs.
private func getCertificateInvalidityDate(_ certificate: SecCertificate, subject: String) -> Date? {
var error: Unmanaged<CFError>?
guard let outerDict = SecCertificateCopyValues(certificate, [kSecOIDInvalidityDate] as CFArray, &error) as? PlistDict else {
os_log(.error, log: log, "Could not get values in '%{public}@' certificate, error = %{public}@", subject, error?.takeUnretainedValue().localizedDescription ?? "unknown error")
return nil
}
guard let innerDict = outerDict[kSecOIDInvalidityDate as String] as? PlistDict else {
os_log(.error, log: log, "No invalidity values in '%{public}@' certificate, dictionary = %{public}@", subject, outerDict)
return nil
}
// NOTE: the invalidity date type of kSecPropertyTypeDate is documented as a CFStringRef in the "Certificate, Key, and Trust Services Reference".
// In reality, it's a __NSTaggedDate (presumably a tagged pointer representing an NSDate.) But to be sure, we'll check:
guard let dateString = innerDict[kSecPropertyKeyValue as String] else {
os_log(.error, log: log, "No invalidity date in '%{public}@' certificate, dictionary = %{public}@", subject, innerDict)
return nil
}
return Date.parseAny(dateString);
}
/// Process list of all certificates. Return a two column table with subject and expiration date.
private func getCertificateList(_ provisionPlist: PlistDict) -> [TableRow] {
guard let certs = provisionPlist["DeveloperCertificates"] as? [Data] else {
return []
}
return certs.compactMap {
guard let cert = SecCertificateCreateWithData(nil, $0 as CFData) else {
return nil
}
guard let subject = SecCertificateCopySubjectSummary(cert) as? String else {
os_log(.error, log: log, "Could not get subject from certificate")
return nil
}
let expiration: String
if let invalidityDate = getCertificateInvalidityDate(cert, subject: subject) {
expiration = invalidityDate.relativeExpirationDateString()
} else {
expiration = "<span class='warning'>No invalidity date in certificate</span>"
}
return TableRow([subject, expiration])
}.sorted { $0[0] < $1[0] }
}
// MARK: - Provisioning
/// Returns provision type string like "Development" or "Distribution (App Store)".
private func stringForProfileType(_ provisionPlist: PlistDict, isOSX: Bool) -> String {
let hasDevices = provisionPlist["ProvisionedDevices"] is [Any]
if isOSX {
return hasDevices ? "Development" : "Distribution (App Store)"
}
if hasDevices {
let getTaskAllow = (provisionPlist["Entitlements"] as? PlistDict)?["get-task-allow"] as? Bool ?? false
return getTaskAllow ? "Development" : "Distribution (Ad Hoc)"
}
let isEnterprise = provisionPlist["ProvisionsAllDevices"] as? Bool ?? false
return isEnterprise ? "Enterprise" : "Distribution (App Store)"
}
/// Enumerate all entries from provison plist with key `ProvisionedDevices`
private func getDeviceList(_ provisionPlist: PlistDict) -> [TableRow] {
guard let devArr = provisionPlist["ProvisionedDevices"] as? [String] else {
return []
}
var currentPrefix: String? = nil
return devArr.sorted().map { device in
// compute the prefix for the first column of the table
let displayPrefix: String
let devicePrefix = String(device.prefix(1))
if currentPrefix != devicePrefix {
currentPrefix = devicePrefix
displayPrefix = "\(devicePrefix)"
} else {
displayPrefix = ""
}
return [displayPrefix, device]
}
}
/// Process info stored in `embedded.mobileprovision`
mutating func procProvision(_ provisionPlist: PlistDict?, isOSX: Bool) {
guard let provisionPlist else {
self.apply(["ProvisionHidden": "hiddenDiv"])
return
}
let creationDate = provisionPlist["CreationDate"] as? Date
let expireDate = provisionPlist["ExpirationDate"] as? Date
let devices = getDeviceList(provisionPlist)
let certs = getCertificateList(provisionPlist)
self.apply([
"ProvisionHidden": "",
"ProfileName": provisionPlist["Name"] as? String ?? "",
"ProfileUUID": provisionPlist["UUID"] as? String ?? "",
"TeamName": provisionPlist["TeamName"] as? String ?? "<em>Team name not available</em>",
"TeamIds": (provisionPlist["TeamIdentifier"] as? [String])?.joined(separator: ", ") ?? "<em>Team ID not available</em>",
"CreationDateFormatted": creationDate?.formattedCreationDate() ?? "",
"ExpirationDateFormatted": expireDate?.formattedExpirationDate() ?? "",
"ExpStatus": ExpirationStatus(expireDate).cssClass(),
"ProfilePlatform": isOSX ? "Mac" : "iOS",
"ProfileType": stringForProfileType(provisionPlist, isOSX: isOSX),
"ProvisionedDevicesCount": devices.isEmpty ? "No Devices" : "\(devices.count) Device\(devices.count == 1 ? "" : "s")",
"ProvisionedDevicesFormatted": devices.isEmpty ? "Distribution Profile" : formatAsTable(devices, header: ["", "UDID"]),
"DeveloperCertificatesFormatted": certs.isEmpty ? "No Developer Certificates" : formatAsTable(certs),
])
}
}
private typealias TableRow = [String]
/// Print html table with arbitrary number of columns
/// @param header If set, start the table with a `tr` column row.
private func formatAsTable(_ data: [[String]], header: TableRow? = nil) -> String {
var table = "<table>\n"
if let header = header {
table += "<tr><th>\(header.joined(separator: "</th><th>"))</th></tr>\n"
}
for row in data {
table += "<tr><td>\(row.joined(separator: "</td><td>"))</td></tr>\n"
}
return table + "</table>\n"
}

View File

@@ -0,0 +1,70 @@
import Foundation
extension MetaInfo {
/// Read `iTunesMetadata.plist` if available
func readPlistItunes() -> PlistDict? {
switch self.type {
case .IPA:
// not `readPayloadFile` because plist is in root dir
return self.zipFile!.unzipFile("iTunesMetadata.plist")?.asPlistOrNil()
case .Archive, .Extension:
return nil
}
}
}
extension HtmlGenerator {
/// Concatenate all (sub)genres into a comma separated list.
private func formattedGenres(_ itunesPlist: PlistDict) -> String {
var genres: [String] = []
let genreId = itunesPlist["genreId"] as? Int ?? 0
if let mainGenre = AppCategories[genreId] ?? itunesPlist["genre"] as? String {
genres.append(mainGenre)
}
for subgenre in itunesPlist["subgenres"] as? [PlistDict] ?? [] {
let subgenreId = subgenre["genreId"] as? Int ?? 0
if let subgenreStr = AppCategories[subgenreId] ?? subgenre["genre"] as? String {
genres.append(subgenreStr)
}
}
return genres.joined(separator: ", ")
}
/// Process info stored in `iTunesMetadata.plist`
mutating func procItunesMeta(_ itunesPlist: PlistDict?) {
guard let itunesPlist else {
self.apply(["iTunesHidden": "hiddenDiv"])
return
}
let downloadInfo = itunesPlist["com.apple.iTunesStore.downloadInfo"] as? PlistDict
let accountInfo = downloadInfo?["accountInfo"] as? PlistDict ?? [:]
let purchaseDate = Date.parseAny(downloadInfo?["purchaseDate"] ?? itunesPlist["purchaseDate"])
let releaseDate = Date.parseAny(downloadInfo?["releaseDate"] ?? itunesPlist["releaseDate"])
// AppleId & purchaser name
let appleId = accountInfo["AppleID"] as? String ?? itunesPlist["appleId"] as? String ?? ""
let firstName = accountInfo["FirstName"] as? String ?? ""
let lastName = accountInfo["LastName"] as? String ?? ""
let name: String
if !firstName.isEmpty || !lastName.isEmpty {
name = "\(firstName) \(lastName) (\(appleId))"
} else {
name = appleId
}
self.apply([
"iTunesHidden": "",
"iTunesId": (itunesPlist["itemId"] as? Int)?.description ?? "", // description]
"iTunesName": itunesPlist["itemName"] as? String ?? "",
"iTunesGenres": formattedGenres(itunesPlist),
"iTunesReleaseDate": releaseDate?.mediumFormat() ?? "",
"iTunesAppleId": name,
"iTunesPurchaseDate": purchaseDate?.mediumFormat() ?? "",
"iTunesPrice": itunesPlist["priceDisplay"] as? String ?? "",
])
}
}

73
src/HtmlGenerator.swift Normal file
View File

@@ -0,0 +1,73 @@
import Foundation
struct HtmlGenerator {
var data: [String: String] = [:] // used for TAG replacements
let meta: MetaInfo
init(_ meta: MetaInfo) {
self.meta = meta
let plistApp = meta.readPlistApp()
let plistItunes = meta.readPlistItunes()
let plistProvision = meta.readPlistProvision()
data["AppInfoTitle"] = stringForFileType(meta)
procAppInfo(plistApp)
procItunesMeta(plistItunes)
procProvision(plistProvision, isOSX: meta.isOSX)
// Entitlements
procEntitlements(meta, plistApp, plistProvision)
procFileInfo(meta.url)
procFooterInfo()
// App Icon (last, because the image uses a lot of memory)
data["AppIcon"] = AppIcon(meta).extractImage(from: plistApp).withRoundCorners().asBase64()
// insert CSS styles
let cssURL = Bundle.main.url(forResource: "style", withExtension: "css")!
data["CSS"] = try! String(contentsOf: cssURL, encoding: .utf8)
}
mutating func apply(_ values: [String: String]) {
data.merge(values) { (_, new) in new }
}
/// Title of the preview window
private func stringForFileType(_ meta: MetaInfo) -> String {
switch meta.type {
case .IPA: return "App info"
case .Archive: return "Archive info"
case .Extension: return "App extension info"
}
}
/// prepare html, replace values
func applyHtmlTemplate() -> String {
let templateURL = Bundle.main.url(forResource: "template", withExtension: "html")!
let html = try! String(contentsOf: templateURL, encoding: .utf8)
// this is less efficient
// for (key, value) in templateValues {
// html = html.replacingOccurrences(of: "__\(key)__", with: value)
// }
var rv = ""
var prevLoc = html.startIndex
let regex = try! NSRegularExpression(pattern: "__[^ _]{1,40}?__")
regex.enumerateMatches(in: html, range: NSRange(location: 0, length: html.count), using: { match, flags, stop in
let start = html.index(html.startIndex, offsetBy: match!.range.lowerBound)
let key = String(html[html.index(start, offsetBy: 2) ..< html.index(start, offsetBy: match!.range.length - 2)])
// append unrelated text up to this key
rv.append(contentsOf: html[prevLoc ..< start])
prevLoc = html.index(start, offsetBy: match!.range.length)
// append key if exists (else remove template-key)
if let value = data[key] {
rv.append(value)
} else {
// os_log(.debug, log: log, "unknown template key: %{public}@", key)
}
})
// append remaining text
rv.append(contentsOf: html[prevLoc ..< html.endIndex])
return rv
}
}

View File

@@ -1,7 +1,9 @@
import Foundation
import os // OSLog
private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Shared")
private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "MetaInfo")
typealias PlistDict = [String: Any] // basically an untyped Dict
// Init QuickLook Type
@@ -11,14 +13,14 @@ enum FileType {
case Extension
}
struct QuickLookInfo {
struct MetaInfo {
let UTI: String
let url: URL
let effectiveUrl: URL? // if set, will point to the app inside of an archive
let type: FileType
let zipFile: ZipFile? // only set for zipped file types
let isOSX = false
let isOSX = false // relict of the past when ProvisionQL also processed provision profiles
/// Use file url and UTI type to generate an info object to pass around.
init(_ url: URL) {
@@ -56,13 +58,40 @@ struct QuickLookInfo {
return try? Data(contentsOf: url.appendingPathComponent(filename))
}
}
/// Read app default `Info.plist`. (used for both, Preview and Thumbnail)
func readPlistApp() -> PlistDict? {
switch self.type {
case .IPA, .Archive, .Extension:
return self.readPayloadFile("Info.plist")?.asPlistOrNil()
}
}
}
// MARK: - Plist
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: - Meta data for QuickLook
/// Search an archive for the .app or .ipa bundle.
func appPathForArchive(_ url: URL) -> URL? {
private func appPathForArchive(_ url: URL) -> URL? {
let appsDir = url.appendingPathComponent("Products/Applications/")
if FileManager.default.fileExists(atPath: appsDir.path) {
if let x = try? FileManager.default.contentsOfDirectory(at: appsDir, includingPropertiesForKeys: nil), !x.isEmpty {
@@ -71,21 +100,3 @@ func appPathForArchive(_ url: URL) -> URL? {
}
return nil;
}
// MARK: - Other helper
enum ExpirationStatus {
case Expired
case Expiring
case Valid
/// Check time between date and now. Set Expiring if less than 30 days until expiration
init(_ date: Date?) {
if date == nil || date!.timeIntervalSinceNow < 0 {
self = .Expired
}
let components = Calendar.current.dateComponents([.day], from: Date(), to: date!)
self = components.day! < 30 ? .Expiring : .Valid
}
}

View File

@@ -1,190 +0,0 @@
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
}
}
}

View File

@@ -1,552 +0,0 @@
import Foundation
import os // OSLog
private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "PreviewGenerator")
typealias HtmlDict = [String: String] // used for TAG replacements
// MARK: - Generic data formatting & printing
typealias TableRow = [String]
/// Print html table with arbitrary number of columns
/// @param header If set, start the table with a `tr` column row.
func formatAsTable(_ data: [[String]], header: TableRow? = nil) -> String {
var table = "<table>\n"
if let header = header {
table += "<tr><th>\(header.joined(separator: "</th><th>"))</th></tr>\n"
}
for row in data {
table += "<tr><td>\(row.joined(separator: "</td><td>"))</td></tr>\n"
}
return table + "</table>\n"
}
/// Print recursive tree of key-value mappings.
func recursiveDict(_ dictionary: [String: Any], withReplacements replacements: [String: String] = [:], _ level: Int = 0) -> String {
var output = ""
for (key, value) in dictionary {
let localizedKey = replacements[key] ?? key
for _ in 0..<level {
output += (level == 1) ? "- " : "&nbsp;&nbsp;"
}
if let subDict = value as? [String: Any] {
output += "\(localizedKey):<div class=\"list\">\n"
output += recursiveDict(subDict, withReplacements: replacements, level + 1)
output += "</div>\n"
} else if let number = value as? NSNumber {
output += "\(localizedKey): \(number.boolValue ? "YES" : "NO")<br />"
} else {
output += "\(localizedKey): \(value)<br />"
}
}
return output
}
/// Replace occurrences of chars `&"'<>` with html encoding.
func escapeXML(_ stringToEscape: String) -> String {
return stringToEscape
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&apos;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
}
// MARK: - Date processing
/// @return Difference between two dates as components.
func dateDiff(_ start: Date, _ end: Date) -> DateComponents {
return Calendar.current.dateComponents([.day, .hour, .minute], from: start, to: end)
}
/// @return Print largest component. E.g., "3 days" or "14 hours"
func relativeDateString(_ comp: DateComponents) -> String {
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .full
formatter.maximumUnitCount = 1
return formatter.string(from: comp)!
}
/// @return Print the date with current locale and medium length style.
func formattedDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .medium
return formatter.string(from: date)
}
/// Parse date from plist regardless if it has `NSDate` or `NSString` type.
func parseDate(_ value: Any?) -> Date? {
if let date = value as? Date {
return date
}
guard let stringValue = value as? String else {
return nil
}
// parse the date from a string
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z"
if let date = formatter.date(from: stringValue) {
return date
}
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
if let date = formatter.date(from: stringValue) {
return date
}
os_log(.error, log: log, "ERROR formatting date: %{public}@", stringValue)
return nil
}
/// @return Relative distance to today. E.g., "Expired today"
func relativeExpirationDateString(_ date: Date) -> String {
let isPast = date < Date()
let isToday = Calendar.current.isDateInToday(date)
if isToday {
return isPast ? "<span>Expired today</span>" : "<span>Expires today</span>"
}
if isPast {
let comp = dateDiff(date, Date())
return "<span>Expired \(relativeDateString(comp)) ago</span>"
}
let comp = dateDiff(Date(), date)
if comp.day! < 30 {
return "<span>Expires in \(relativeDateString(comp))</span>"
}
return "Expires in \(relativeDateString(comp))"
}
/// @return Relative distance to today. E.g., "DATE (Expires in 3 days)"
func formattedExpirationDate(_ date: Date) -> String {
return "\(formattedDate(date)) (\(relativeExpirationDateString(date)))"
}
/// @return Relative distance to today. E.g., "DATE (Created 3 days ago)"
func formattedCreationDate(_ date: Date) -> String {
let isToday = Calendar.current.isDateInToday(date)
let comp = dateDiff(date, Date())
return "\(formattedDate(date)) (Created \(isToday ? "today" : "\(relativeDateString(comp)) ago"))"
}
/// @return CSS class for expiration status.
func classNameForExpirationStatus(_ date: Date?) -> String {
switch ExpirationStatus(date) {
case .Expired: return "expired"
case .Expiring: return "expiring"
case .Valid: return "valid"
}
}
// MARK: - App Info
/// @return List of ATS flags.
func formattedAppTransportSecurity(_ appPlist: PlistDict) -> String {
if let value = appPlist["NSAppTransportSecurity"] as? PlistDict {
let localizedKeys = [
"NSAllowsArbitraryLoads": "Allows Arbitrary Loads",
"NSAllowsArbitraryLoadsForMedia": "Allows Arbitrary Loads for Media",
"NSAllowsArbitraryLoadsInWebContent": "Allows Arbitrary Loads in Web Content",
"NSAllowsLocalNetworking": "Allows Local Networking",
"NSExceptionDomains": "Exception Domains",
"NSIncludesSubdomains": "Includes Subdomains",
"NSRequiresCertificateTransparency": "Requires Certificate Transparency",
"NSExceptionAllowsInsecureHTTPLoads": "Allows Insecure HTTP Loads",
"NSExceptionMinimumTLSVersion": "Minimum TLS Version",
"NSExceptionRequiresForwardSecrecy": "Requires Forward Secrecy",
"NSThirdPartyExceptionAllowsInsecureHTTPLoads": "Allows Insecure HTTP Loads",
"NSThirdPartyExceptionMinimumTLSVersion": "Minimum TLS Version",
"NSThirdPartyExceptionRequiresForwardSecrecy": "Requires Forward Secrecy",
]
return "<div class=\"list\">\(recursiveDict(value, withReplacements: localizedKeys))</div>"
}
let sdkName = appPlist["DTSDKName"] as? String ?? "0"
let sdkNumber = Double(sdkName.trimmingCharacters(in: .letters)) ?? 0
if sdkNumber < 9.0 {
return "Not applicable before iOS 9.0"
}
return "No exceptions"
}
/// Process info stored in `Info.plist`
func procAppInfo(_ appPlist: PlistDict?) -> HtmlDict {
guard let appPlist else {
return [
"AppInfoHidden": "hiddenDiv",
"ProvisionTitleHidden": "",
]
}
var platforms = (appPlist["UIDeviceFamily"] as? [Int])?.compactMap({
switch $0 {
case 1: return "iPhone"
case 2: return "iPad"
case 3: return "TV"
case 4: return "Watch"
default: return nil
}
}).joined(separator: ", ")
let minVersion = appPlist["MinimumOSVersion"] as? String ?? ""
if platforms?.isEmpty ?? true, minVersion.hasPrefix("1.") || minVersion.hasPrefix("2.") || minVersion.hasPrefix("3.") {
platforms = "iPhone"
}
let extensionType = (appPlist["NSExtension"] as? PlistDict)?["NSExtensionPointIdentifier"] as? String
return [
"AppInfoHidden": "",
"ProvisionTitleHidden": "hiddenDiv",
"CFBundleName": appPlist["CFBundleDisplayName"] as? String ?? appPlist["CFBundleName"] as? String ?? "",
"CFBundleShortVersionString": appPlist["CFBundleShortVersionString"] as? String ?? "",
"CFBundleVersion": appPlist["CFBundleVersion"] as? String ?? "",
"CFBundleIdentifier": appPlist["CFBundleIdentifier"] as? String ?? "",
"ExtensionTypeHidden": extensionType != nil ? "" : "hiddenDiv",
"ExtensionType": extensionType ?? "",
"UIDeviceFamily": platforms ?? "",
"DTSDKName": appPlist["DTSDKName"] as? String ?? "",
"MinimumOSVersion": minVersion,
"AppTransportSecurityFormatted": formattedAppTransportSecurity(appPlist),
]
}
// MARK: - iTunes Purchase Information
/// Concatenate all (sub)genres into a comma separated list.
func formattedGenres(_ itunesPlist: PlistDict) -> String {
var genres: [String] = []
let genreId = itunesPlist["genreId"] as? Int ?? 0
if let mainGenre = AppCategories[genreId] ?? itunesPlist["genre"] as? String {
genres.append(mainGenre)
}
for subgenre in itunesPlist["subgenres"] as? [PlistDict] ?? [] {
let subgenreId = subgenre["genreId"] as? Int ?? 0
if let subgenreStr = AppCategories[subgenreId] ?? subgenre["genre"] as? String {
genres.append(subgenreStr)
}
}
return genres.joined(separator: ", ")
}
/// Process info stored in `iTunesMetadata.plist`
func parseItunesMeta(_ itunesPlist: PlistDict?) -> HtmlDict {
guard let itunesPlist else {
return ["iTunesHidden": "hiddenDiv"]
}
let downloadInfo = itunesPlist["com.apple.iTunesStore.downloadInfo"] as? PlistDict
let accountInfo = downloadInfo?["accountInfo"] as? PlistDict ?? [:]
let purchaseDate = parseDate(downloadInfo?["purchaseDate"] ?? itunesPlist["purchaseDate"])
let releaseDate = parseDate(downloadInfo?["releaseDate"] ?? itunesPlist["releaseDate"])
// AppleId & purchaser name
let appleId = accountInfo["AppleID"] as? String ?? itunesPlist["appleId"] as? String ?? ""
let firstName = accountInfo["FirstName"] as? String ?? ""
let lastName = accountInfo["LastName"] as? String ?? ""
let name: String
if !firstName.isEmpty || !lastName.isEmpty {
name = "\(firstName) \(lastName) (\(appleId))"
} else {
name = appleId
}
os_log(.error, log: log, "id: %{public}@", String(describing: itunesPlist["itemId"]))
return [
"iTunesHidden": "",
"iTunesId": (itunesPlist["itemId"] as? Int)?.description ?? "", // description]
"iTunesName": itunesPlist["itemName"] as? String ?? "",
"iTunesGenres": formattedGenres(itunesPlist),
"iTunesReleaseDate": releaseDate == nil ? "" : formattedDate(releaseDate!),
"iTunesAppleId": name,
"iTunesPurchaseDate": purchaseDate == nil ? "" : formattedDate(purchaseDate!),
"iTunesPrice": itunesPlist["priceDisplay"] as? String ?? "",
]
}
// MARK: - Certificates
/// Process a single certificate. Extract invalidity / expiration date.
/// @param subject just used for printing error logs.
func getCertificateInvalidityDate(_ certificate: SecCertificate, subject: String) -> Date? {
var error: Unmanaged<CFError>?
guard let outerDict = SecCertificateCopyValues(certificate, [kSecOIDInvalidityDate] as CFArray, &error) as? PlistDict else {
os_log(.error, log: log, "Could not get values in '%{public}@' certificate, error = %{public}@", subject, error?.takeUnretainedValue().localizedDescription ?? "unknown error")
return nil
}
guard let innerDict = outerDict[kSecOIDInvalidityDate as String] as? PlistDict else {
os_log(.error, log: log, "No invalidity values in '%{public}@' certificate, dictionary = %{public}@", subject, outerDict)
return nil
}
// NOTE: the invalidity date type of kSecPropertyTypeDate is documented as a CFStringRef in the "Certificate, Key, and Trust Services Reference".
// In reality, it's a __NSTaggedDate (presumably a tagged pointer representing an NSDate.) But to be sure, we'll check:
guard let dateString = innerDict[kSecPropertyKeyValue as String] else {
os_log(.error, log: log, "No invalidity date in '%{public}@' certificate, dictionary = %{public}@", subject, innerDict)
return nil
}
return parseDate(dateString);
}
/// Process list of all certificates. Return a two column table with subject and expiration date.
func getCertificateList(_ provisionPlist: PlistDict) -> [TableRow] {
guard let certs = provisionPlist["DeveloperCertificates"] as? [Data] else {
return []
}
return certs.compactMap {
guard let cert = SecCertificateCreateWithData(nil, $0 as CFData) else {
return nil
}
guard let subject = SecCertificateCopySubjectSummary(cert) as? String else {
os_log(.error, log: log, "Could not get subject from certificate")
return nil
}
let expiration: String
if let invalidityDate = getCertificateInvalidityDate(cert, subject: subject) {
expiration = relativeExpirationDateString(invalidityDate)
} else {
expiration = "<span class='warning'>No invalidity date in certificate</span>"
}
return TableRow([subject, expiration])
}.sorted { $0[0] < $1[0] }
}
// MARK: - Provisioning
/// Returns provision type string like "Development" or "Distribution (App Store)".
func stringForProfileType(_ provisionPlist: PlistDict, isOSX: Bool) -> String {
let hasDevices = provisionPlist["ProvisionedDevices"] is [Any]
if isOSX {
return hasDevices ? "Development" : "Distribution (App Store)"
}
if hasDevices {
let getTaskAllow = (provisionPlist["Entitlements"] as? PlistDict)?["get-task-allow"] as? Bool ?? false
return getTaskAllow ? "Development" : "Distribution (Ad Hoc)"
}
let isEnterprise = provisionPlist["ProvisionsAllDevices"] as? Bool ?? false
return isEnterprise ? "Enterprise" : "Distribution (App Store)"
}
/// Enumerate all entries from provison plist with key `ProvisionedDevices`
func getDeviceList(_ provisionPlist: PlistDict) -> [TableRow] {
guard let devArr = provisionPlist["ProvisionedDevices"] as? [String] else {
return []
}
var currentPrefix: String? = nil
return devArr.sorted().map { device in
// compute the prefix for the first column of the table
let displayPrefix: String
let devicePrefix = String(device.prefix(1))
if currentPrefix != devicePrefix {
currentPrefix = devicePrefix
displayPrefix = "\(devicePrefix)"
} else {
displayPrefix = ""
}
return [displayPrefix, device]
}
}
/// Process info stored in `embedded.mobileprovision`
func procProvision(_ provisionPlist: PlistDict?, isOSX: Bool) -> HtmlDict {
guard let provisionPlist else {
return ["ProvisionHidden": "hiddenDiv"]
}
let creationDate = provisionPlist["CreationDate"] as? Date
let expireDate = provisionPlist["ExpirationDate"] as? Date
let devices = getDeviceList(provisionPlist)
let certs = getCertificateList(provisionPlist)
return [
"ProvisionHidden": "",
"ProfileName": provisionPlist["Name"] as? String ?? "",
"ProfileUUID": provisionPlist["UUID"] as? String ?? "",
"TeamName": provisionPlist["TeamName"] as? String ?? "<em>Team name not available</em>",
"TeamIds": (provisionPlist["TeamIdentifier"] as? [String])?.joined(separator: ", ") ?? "<em>Team ID not available</em>",
"CreationDateFormatted": creationDate == nil ? "" : formattedCreationDate(creationDate!),
"ExpirationDateFormatted": expireDate == nil ? "" : formattedExpirationDate(expireDate!),
"ExpStatus": classNameForExpirationStatus(expireDate),
"ProfilePlatform": isOSX ? "Mac" : "iOS",
"ProfileType": stringForProfileType(provisionPlist, isOSX: isOSX),
"ProvisionedDevicesCount": devices.isEmpty ? "No Devices" : "\(devices.count) Device\(devices.count == 1 ? "" : "s")",
"ProvisionedDevicesFormatted": devices.isEmpty ? "Distribution Profile" : formatAsTable(devices, header: ["", "UDID"]),
"DeveloperCertificatesFormatted": certs.isEmpty ? "No Developer Certificates" : formatAsTable(certs),
]
}
// MARK: - Entitlements
/// Search for app binary and run `codesign` on it.
func readEntitlements(_ meta: QuickLookInfo, _ bundleExecutable: String?) -> Entitlements {
guard let bundleExecutable else {
return Entitlements.withoutBinary()
}
switch meta.type {
case .IPA:
let tmpPath = NSTemporaryDirectory() + "/" + UUID().uuidString
try! FileManager.default.createDirectory(atPath: tmpPath, withIntermediateDirectories: true)
defer {
try? FileManager.default.removeItem(atPath: tmpPath)
}
try! meta.zipFile!.unzipFile("Payload/*.app/\(bundleExecutable)", toDir: tmpPath)
return Entitlements(forBinary: tmpPath + "/" + bundleExecutable)
case .Archive:
return Entitlements(forBinary: meta.effectiveUrl!.path + "/" + bundleExecutable)
case .Extension:
return Entitlements(forBinary: meta.url.path + "/" + bundleExecutable)
}
}
/// Process compiled binary and provision plist to extract `Entitlements`
func procEntitlements(_ meta: QuickLookInfo, _ appPlist: PlistDict?, _ provisionPlist: PlistDict?) -> HtmlDict {
var entitlements = readEntitlements(meta, appPlist?["CFBundleExecutable"] as? String)
entitlements.applyFallbackIfNeeded(provisionPlist?["Entitlements"] as? PlistDict)
return [
"EntitlementsWarningHidden": entitlements.hasError ? "" : "hiddenDiv",
"EntitlementsFormatted": entitlements.html ?? "No Entitlements",
]
}
// MARK: - File Info
/// Title of the preview window
func stringForFileType(_ meta: QuickLookInfo) -> String {
switch meta.type {
case .IPA: return "App info"
case .Archive: return "Archive info"
case .Extension: return "App extension info"
}
}
/// Calculate file / folder size.
func getFileSize(_ path: String) -> Int64 {
var isDir: ObjCBool = false
FileManager.default.fileExists(atPath: path, isDirectory: &isDir)
if !isDir.boolValue {
return try! FileManager.default.attributesOfItem(atPath: path)[.size] as! Int64
}
var fileSize: Int64 = 0
for child in try! FileManager.default.subpathsOfDirectory(atPath: path) {
fileSize += try! FileManager.default.attributesOfItem(atPath: path + "/" + child)[.size] as! Int64
}
return fileSize
}
/// Process meta information about the file itself. Like file size and last modification.
func procFileInfo(_ url: URL) -> HtmlDict {
let formattedValue : String
if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) {
let size = ByteCountFormatter.string(fromByteCount: getFileSize(url.path), countStyle: .file)
formattedValue = "\(size), Modified \(formattedDate(attrs[.modificationDate] as! Date))"
} else {
formattedValue = ""
}
return [
"FileName": escapeXML(url.lastPathComponent),
"FileInfo": formattedValue,
]
}
// MARK: - Footer Info
/// Process meta information about the plugin. Like version and debug flag.
func procFooterInfo() -> HtmlDict {
return [
"BundleShortVersionString": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "",
"BundleVersion": Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "",
]
}
// MARK: - Main Entry
func applyHtmlTemplate(_ templateValues: HtmlDict) -> String {
let templateURL = Bundle.main.url(forResource: "template", withExtension: "html")!
let html = try! String(contentsOf: templateURL, encoding: .utf8)
// this is less efficient
// for (key, value) in templateValues {
// html = html.replacingOccurrences(of: "__\(key)__", with: value)
// }
var rv = ""
var prevLoc = html.startIndex
let regex = try! NSRegularExpression(pattern: "__[^ _]{1,40}?__")
regex.enumerateMatches(in: html, range: NSRange(location: 0, length: html.count), using: { match, flags, stop in
let start = html.index(html.startIndex, offsetBy: match!.range.lowerBound)
let key = String(html[html.index(start, offsetBy: 2) ..< html.index(start, offsetBy: match!.range.length - 2)])
// append unrelated text up to this key
rv.append(contentsOf: html[prevLoc ..< start])
prevLoc = html.index(start, offsetBy: match!.range.length)
// append key if exists (else remove template-key)
if let value = templateValues[key] {
rv.append(value)
} else {
// os_log(.debug, log: log, "unknown template key: %{public}@", key)
}
})
// append remaining text
rv.append(contentsOf: html[prevLoc ..< html.endIndex])
return rv
}
func generateHtml(at url: URL) -> String {
let meta = QuickLookInfo(url)
var infoLayer: HtmlDict = [
"AppInfoTitle": stringForFileType(meta),
]
// App Info
let plistApp = meta.readPlistApp()
infoLayer.merge(procAppInfo(plistApp)) { (_, new) in new }
let plistItunes = meta.readPlistItunes()
infoLayer.merge(parseItunesMeta(plistItunes)) { (_, new) in new }
// Provisioning
let plistProvision = meta.readPlistProvision()
infoLayer.merge(procProvision(plistProvision, isOSX: meta.isOSX)) { (_, new) in new }
// Entitlements
let entitlements = procEntitlements(meta, plistApp, plistProvision)
infoLayer.merge(entitlements) { (_, new) in new }
// File Info
infoLayer.merge(procFileInfo(url)) { (_, new) in new }
// Footer Info
infoLayer.merge(procFooterInfo()) { (_, new) in new }
// App Icon (last, because the image uses a lot of memory)
let icon = AppIcon(meta)
infoLayer["AppIcon"] = icon.extractImage(from: plistApp).withRoundCorners().asBase64()
// insert CSS styles
let cssURL = Bundle.main.url(forResource: "style", withExtension: "css")!
infoLayer["CSS"] = try! String(contentsOf: cssURL, encoding: .utf8)
// prepare html, replace values
return applyHtmlTemplate(infoLayer)
}