ref: move code around
This commit is contained in:
@@ -7,8 +7,7 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
5405CF5C2EA1191A00613856 /* PreviewGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5405CF5B2EA1191A00613856 /* PreviewGenerator.swift */; };
|
||||
5405CF5E2EA1199B00613856 /* Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5405CF5D2EA1199B00613856 /* Shared.swift */; };
|
||||
5405CF5E2EA1199B00613856 /* MetaInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5405CF5D2EA1199B00613856 /* MetaInfo.swift */; };
|
||||
5405CF652EA1376B00613856 /* Zip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5405CF642EA1376B00613856 /* Zip.swift */; };
|
||||
54442C232E378BAF008A870E /* Quartz.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 54442C222E378BAF008A870E /* Quartz.framework */; };
|
||||
54442C302E378BAF008A870E /* QLPreview.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 54442C202E378BAF008A870E /* QLPreview.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
@@ -20,23 +19,30 @@
|
||||
545459C42EA469E4002892E5 /* defaultIcon.png in Resources */ = {isa = PBXBuildFile; fileRef = 54D3A6F22EA4603B001EF4F6 /* defaultIcon.png */; };
|
||||
545459C52EA469EA002892E5 /* template.html in Resources */ = {isa = PBXBuildFile; fileRef = 54D3A6F32EA4603B001EF4F6 /* template.html */; };
|
||||
545459C72EA4773A002892E5 /* AppIcon+Car.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545459C62EA4773A002892E5 /* AppIcon+Car.swift */; };
|
||||
545459C92EA47C37002892E5 /* Plist.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545459C82EA47C37002892E5 /* Plist.swift */; };
|
||||
54581FD12EB29A0B0043A0B3 /* QuickLookThumbnailing.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 54581FD02EB29A0B0043A0B3 /* QuickLookThumbnailing.framework */; };
|
||||
54581FD22EB29A0B0043A0B3 /* Quartz.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 54442C222E378BAF008A870E /* Quartz.framework */; };
|
||||
54581FDA2EB29A0B0043A0B3 /* QLThumbnail.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 54581FCF2EB29A0B0043A0B3 /* QLThumbnail.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
54581FE42EB29A2B0043A0B3 /* CoreUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 54D3A6F52EA4610B001EF4F6 /* CoreUI.framework */; };
|
||||
54581FEF2EB29A570043A0B3 /* Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5405CF5D2EA1199B00613856 /* Shared.swift */; };
|
||||
54581FEF2EB29A570043A0B3 /* MetaInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5405CF5D2EA1199B00613856 /* MetaInfo.swift */; };
|
||||
54581FF02EB29A5E0043A0B3 /* AppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D3A6ED2EA39CC6001EF4F6 /* AppIcon.swift */; };
|
||||
54581FF12EB29A620043A0B3 /* AppIcon+Car.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545459C62EA4773A002892E5 /* AppIcon+Car.swift */; };
|
||||
54581FF72EB29A820043A0B3 /* Zip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5405CF642EA1376B00613856 /* Zip.swift */; };
|
||||
54581FFD2EB29AB70043A0B3 /* Plist.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545459C82EA47C37002892E5 /* Plist.swift */; };
|
||||
545820032EB29B0A0043A0B3 /* RoundedIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D3A6EF2EA3F49F001EF4F6 /* RoundedIcon.swift */; };
|
||||
545820032EB29B0A0043A0B3 /* NSBezierPath+RoundedRect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D3A6EF2EA3F49F001EF4F6 /* NSBezierPath+RoundedRect.swift */; };
|
||||
545820222EB29B3D0043A0B3 /* ThumbnailProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5458201F2EB29B3D0043A0B3 /* ThumbnailProvider.swift */; };
|
||||
545820232EB29B4C0043A0B3 /* defaultIcon.png in Resources */ = {isa = PBXBuildFile; fileRef = 54D3A6F22EA4603B001EF4F6 /* defaultIcon.png */; };
|
||||
5469E11D2EA5930C00D46CE7 /* Entitlements.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5469E11C2EA5930C00D46CE7 /* Entitlements.swift */; };
|
||||
547F52DE2EB2C15D002B6D5F /* ExpirationStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 547F52DC2EB2C15D002B6D5F /* ExpirationStatus.swift */; };
|
||||
547F52E42EB2C3D8002B6D5F /* Html+iTunesPurchase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 547F52E32EB2C3D8002B6D5F /* Html+iTunesPurchase.swift */; };
|
||||
547F52E82EB2C41C002B6D5F /* HtmlGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 547F52E62EB2C41C002B6D5F /* HtmlGenerator.swift */; };
|
||||
547F52EB2EB2C672002B6D5F /* Html+FileInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 547F52E92EB2C672002B6D5F /* Html+FileInfo.swift */; };
|
||||
547F52ED2EB2C822002B6D5F /* Html+AppInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 547F52EC2EB2C822002B6D5F /* Html+AppInfo.swift */; };
|
||||
547F52EF2EB2C8E8002B6D5F /* Html+Provisioning.swift in Sources */ = {isa = PBXBuildFile; fileRef = 547F52EE2EB2C8E8002B6D5F /* Html+Provisioning.swift */; };
|
||||
547F52F42EB2CA05002B6D5F /* Html+Entitlements.swift in Sources */ = {isa = PBXBuildFile; fileRef = 547F52F32EB2CA05002B6D5F /* Html+Entitlements.swift */; };
|
||||
547F52F72EB2CAC7002B6D5F /* Html+Footer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 547F52F62EB2CAC7002B6D5F /* Html+Footer.swift */; };
|
||||
547F52F92EB2CBAB002B6D5F /* Date+Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = 547F52F82EB2CBAB002B6D5F /* Date+Format.swift */; };
|
||||
54D3A6EC2EA31B52001EF4F6 /* AppCategories.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D3A6EB2EA31B52001EF4F6 /* AppCategories.swift */; };
|
||||
54D3A6EE2EA39CC6001EF4F6 /* AppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D3A6ED2EA39CC6001EF4F6 /* AppIcon.swift */; };
|
||||
54D3A6F02EA3F49F001EF4F6 /* RoundedIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D3A6EF2EA3F49F001EF4F6 /* RoundedIcon.swift */; };
|
||||
54D3A6F02EA3F49F001EF4F6 /* NSBezierPath+RoundedRect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D3A6EF2EA3F49F001EF4F6 /* NSBezierPath+RoundedRect.swift */; };
|
||||
54D3A6F72EA46154001EF4F6 /* CoreUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 54D3A6F52EA4610B001EF4F6 /* CoreUI.framework */; };
|
||||
54D3A6FE2EA465B4001EF4F6 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 54D3A6FA2EA46588001EF4F6 /* CoreGraphics.framework */; };
|
||||
54E0875A2EB15DD000979D91 /* style.css in Resources */ = {isa = PBXBuildFile; fileRef = 54E087592EB15DD000979D91 /* style.css */; };
|
||||
@@ -75,8 +81,7 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
5405CF5B2EA1191A00613856 /* PreviewGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewGenerator.swift; sourceTree = "<group>"; };
|
||||
5405CF5D2EA1199B00613856 /* Shared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shared.swift; sourceTree = "<group>"; };
|
||||
5405CF5D2EA1199B00613856 /* MetaInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetaInfo.swift; sourceTree = "<group>"; };
|
||||
5405CF642EA1376B00613856 /* Zip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zip.swift; sourceTree = "<group>"; };
|
||||
54442BF42E378B71008A870E /* QLApps.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = QLApps.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
54442C202E378BAF008A870E /* QLPreview.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = QLPreview.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -90,17 +95,25 @@
|
||||
54442C752E378BE0008A870E /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/PreviewViewController.xib; sourceTree = "<group>"; };
|
||||
54442C772E378BE0008A870E /* QLPreview.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = QLPreview.entitlements; sourceTree = "<group>"; };
|
||||
545459C62EA4773A002892E5 /* AppIcon+Car.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppIcon+Car.swift"; sourceTree = "<group>"; };
|
||||
545459C82EA47C37002892E5 /* Plist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Plist.swift; sourceTree = "<group>"; };
|
||||
54581FCF2EB29A0B0043A0B3 /* QLThumbnail.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = QLThumbnail.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
54581FD02EB29A0B0043A0B3 /* QuickLookThumbnailing.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuickLookThumbnailing.framework; path = System/Library/Frameworks/QuickLookThumbnailing.framework; sourceTree = SDKROOT; };
|
||||
5458201D2EB29B3D0043A0B3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
5458201E2EB29B3D0043A0B3 /* QLThumbnail.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = QLThumbnail.entitlements; sourceTree = "<group>"; };
|
||||
5458201F2EB29B3D0043A0B3 /* ThumbnailProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailProvider.swift; sourceTree = "<group>"; };
|
||||
5469E11C2EA5930C00D46CE7 /* Entitlements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Entitlements.swift; sourceTree = "<group>"; };
|
||||
547F52DC2EB2C15D002B6D5F /* ExpirationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpirationStatus.swift; sourceTree = "<group>"; };
|
||||
547F52E32EB2C3D8002B6D5F /* Html+iTunesPurchase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Html+iTunesPurchase.swift"; sourceTree = "<group>"; };
|
||||
547F52E62EB2C41C002B6D5F /* HtmlGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HtmlGenerator.swift; sourceTree = "<group>"; };
|
||||
547F52E92EB2C672002B6D5F /* Html+FileInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Html+FileInfo.swift"; sourceTree = "<group>"; };
|
||||
547F52EC2EB2C822002B6D5F /* Html+AppInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Html+AppInfo.swift"; sourceTree = "<group>"; };
|
||||
547F52EE2EB2C8E8002B6D5F /* Html+Provisioning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Html+Provisioning.swift"; sourceTree = "<group>"; };
|
||||
547F52F32EB2CA05002B6D5F /* Html+Entitlements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Html+Entitlements.swift"; sourceTree = "<group>"; };
|
||||
547F52F62EB2CAC7002B6D5F /* Html+Footer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Html+Footer.swift"; sourceTree = "<group>"; };
|
||||
547F52F82EB2CBAB002B6D5F /* Date+Format.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Format.swift"; sourceTree = "<group>"; };
|
||||
5485EE362EB1460C009E3905 /* Network.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Network.framework; path = System/Library/Frameworks/Network.framework; sourceTree = SDKROOT; };
|
||||
54D3A6EB2EA31B52001EF4F6 /* AppCategories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCategories.swift; sourceTree = "<group>"; };
|
||||
54D3A6ED2EA39CC6001EF4F6 /* AppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIcon.swift; sourceTree = "<group>"; };
|
||||
54D3A6EF2EA3F49F001EF4F6 /* RoundedIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedIcon.swift; sourceTree = "<group>"; };
|
||||
54D3A6EF2EA3F49F001EF4F6 /* NSBezierPath+RoundedRect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSBezierPath+RoundedRect.swift"; sourceTree = "<group>"; };
|
||||
54D3A6F22EA4603B001EF4F6 /* defaultIcon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = defaultIcon.png; sourceTree = "<group>"; };
|
||||
54D3A6F32EA4603B001EF4F6 /* template.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = template.html; sourceTree = "<group>"; };
|
||||
54D3A6F52EA4610B001EF4F6 /* CoreUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = CoreUI.framework; sourceTree = "<group>"; };
|
||||
@@ -150,15 +163,22 @@
|
||||
541051562E37AFC10083670B /* src */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5405CF5D2EA1199B00613856 /* MetaInfo.swift */,
|
||||
54D3A6EB2EA31B52001EF4F6 /* AppCategories.swift */,
|
||||
54D3A6ED2EA39CC6001EF4F6 /* AppIcon.swift */,
|
||||
545459C62EA4773A002892E5 /* AppIcon+Car.swift */,
|
||||
54D3A6EF2EA3F49F001EF4F6 /* RoundedIcon.swift */,
|
||||
5405CF5D2EA1199B00613856 /* Shared.swift */,
|
||||
545459C82EA47C37002892E5 /* Plist.swift */,
|
||||
5405CF642EA1376B00613856 /* Zip.swift */,
|
||||
5469E11C2EA5930C00D46CE7 /* Entitlements.swift */,
|
||||
5405CF5B2EA1191A00613856 /* PreviewGenerator.swift */,
|
||||
547F52DC2EB2C15D002B6D5F /* ExpirationStatus.swift */,
|
||||
547F52E62EB2C41C002B6D5F /* HtmlGenerator.swift */,
|
||||
547F52EC2EB2C822002B6D5F /* Html+AppInfo.swift */,
|
||||
547F52EE2EB2C8E8002B6D5F /* Html+Provisioning.swift */,
|
||||
547F52F32EB2CA05002B6D5F /* Html+Entitlements.swift */,
|
||||
547F52E32EB2C3D8002B6D5F /* Html+iTunesPurchase.swift */,
|
||||
547F52E92EB2C672002B6D5F /* Html+FileInfo.swift */,
|
||||
547F52F62EB2CAC7002B6D5F /* Html+Footer.swift */,
|
||||
5405CF642EA1376B00613856 /* Zip.swift */,
|
||||
54D3A6EF2EA3F49F001EF4F6 /* NSBezierPath+RoundedRect.swift */,
|
||||
547F52F82EB2CBAB002B6D5F /* Date+Format.swift */,
|
||||
);
|
||||
path = src;
|
||||
sourceTree = "<group>";
|
||||
@@ -405,14 +425,21 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
54D3A6F02EA3F49F001EF4F6 /* RoundedIcon.swift in Sources */,
|
||||
54D3A6F02EA3F49F001EF4F6 /* NSBezierPath+RoundedRect.swift in Sources */,
|
||||
547F52E42EB2C3D8002B6D5F /* Html+iTunesPurchase.swift in Sources */,
|
||||
547F52F72EB2CAC7002B6D5F /* Html+Footer.swift in Sources */,
|
||||
5469E11D2EA5930C00D46CE7 /* Entitlements.swift in Sources */,
|
||||
54442C792E378BE0008A870E /* PreviewViewController.swift in Sources */,
|
||||
5405CF5C2EA1191A00613856 /* PreviewGenerator.swift in Sources */,
|
||||
547F52EF2EB2C8E8002B6D5F /* Html+Provisioning.swift in Sources */,
|
||||
547F52DE2EB2C15D002B6D5F /* ExpirationStatus.swift in Sources */,
|
||||
54D3A6EE2EA39CC6001EF4F6 /* AppIcon.swift in Sources */,
|
||||
545459C92EA47C37002892E5 /* Plist.swift in Sources */,
|
||||
5405CF5E2EA1199B00613856 /* Shared.swift in Sources */,
|
||||
547F52E82EB2C41C002B6D5F /* HtmlGenerator.swift in Sources */,
|
||||
547F52EB2EB2C672002B6D5F /* Html+FileInfo.swift in Sources */,
|
||||
547F52ED2EB2C822002B6D5F /* Html+AppInfo.swift in Sources */,
|
||||
547F52F42EB2CA05002B6D5F /* Html+Entitlements.swift in Sources */,
|
||||
5405CF5E2EA1199B00613856 /* MetaInfo.swift in Sources */,
|
||||
545459C72EA4773A002892E5 /* AppIcon+Car.swift in Sources */,
|
||||
547F52F92EB2CBAB002B6D5F /* Date+Format.swift in Sources */,
|
||||
54D3A6EC2EA31B52001EF4F6 /* AppCategories.swift in Sources */,
|
||||
5405CF652EA1376B00613856 /* Zip.swift in Sources */,
|
||||
);
|
||||
@@ -422,11 +449,10 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
54581FFD2EB29AB70043A0B3 /* Plist.swift in Sources */,
|
||||
54581FEF2EB29A570043A0B3 /* Shared.swift in Sources */,
|
||||
54581FEF2EB29A570043A0B3 /* MetaInfo.swift in Sources */,
|
||||
54581FF02EB29A5E0043A0B3 /* AppIcon.swift in Sources */,
|
||||
54581FF72EB29A820043A0B3 /* Zip.swift in Sources */,
|
||||
545820032EB29B0A0043A0B3 /* RoundedIcon.swift in Sources */,
|
||||
545820032EB29B0A0043A0B3 /* NSBezierPath+RoundedRect.swift in Sources */,
|
||||
545820222EB29B3D0043A0B3 /* ThumbnailProvider.swift in Sources */,
|
||||
54581FF12EB29A620043A0B3 /* AppIcon+Car.swift in Sources */,
|
||||
);
|
||||
@@ -501,7 +527,7 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 842;
|
||||
CURRENT_PROJECT_VERSION = 944;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = UY657LKNHJ;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
@@ -572,7 +598,7 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 842;
|
||||
CURRENT_PROJECT_VERSION = 944;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = UY657LKNHJ;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
|
||||
@@ -13,7 +13,8 @@ class PreviewViewController: NSViewController, QLPreviewingController {
|
||||
}
|
||||
|
||||
func preparePreviewOfFile(at url: URL) async throws {
|
||||
let html = generateHtml(at: url)
|
||||
let meta = MetaInfo(url)
|
||||
let html = HtmlGenerator(meta).applyHtmlTemplate()
|
||||
// sure, we could use `WKWebView`, but that requires the `com.apple.security.network.client` entitlement
|
||||
//let web = WKWebView(frame: self.view.bounds)
|
||||
let web = WebView(frame: self.view.bounds)
|
||||
|
||||
@@ -22,10 +22,8 @@ class ThumbnailProvider: QLThumbnailProvider {
|
||||
// Probably overwritten by Apple somehow
|
||||
|
||||
override func provideThumbnail(for request: QLFileThumbnailRequest, _ handler: @escaping (QLThumbnailReply?, Error?) -> Void) {
|
||||
let meta = QuickLookInfo(request.fileURL)
|
||||
let icon = AppIcon(meta)
|
||||
let plistApp = meta.readPlistApp()
|
||||
let img = icon.extractImage(from: plistApp).withRoundCorners()
|
||||
let meta = MetaInfo(request.fileURL)
|
||||
let img = AppIcon(meta).extractImage(from: meta.readPlistApp()).withRoundCorners()
|
||||
|
||||
// First way: Draw the thumbnail into the current context, set up with UIKit's coordinate system.
|
||||
let reply = QLThumbnailReply(contextSize: request.maximumSize, currentContextDrawing: { () -> Bool 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