ref: move code around
This commit is contained in:
@@ -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
86
src/Date+Format.swift
Normal 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"))"
|
||||
}
|
||||
}
|
||||
@@ -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(" = ") ?? "")
|
||||
|
||||
|
||||
25
src/ExpirationStatus.swift
Normal file
25
src/ExpirationStatus.swift
Normal 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
103
src/Html+AppInfo.swift
Normal 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) ? "- " : " "
|
||||
}
|
||||
|
||||
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),
|
||||
])
|
||||
}
|
||||
}
|
||||
36
src/Html+Entitlements.swift
Normal file
36
src/Html+Entitlements.swift
Normal 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
43
src/Html+FileInfo.swift
Normal 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: "&")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
}
|
||||
11
src/Html+Footer.swift
Normal file
11
src/Html+Footer.swift
Normal 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
160
src/Html+Provisioning.swift
Normal 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"
|
||||
}
|
||||
70
src/Html+iTunesPurchase.swift
Normal file
70
src/Html+iTunesPurchase.swift
Normal 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
73
src/HtmlGenerator.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
190
src/Plist.swift
190
src/Plist.swift
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) ? "- " : " "
|
||||
}
|
||||
|
||||
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: "&")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
}
|
||||
|
||||
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user