diff --git a/QLApps.xcodeproj/project.pbxproj b/QLApps.xcodeproj/project.pbxproj index 6cf858d..7a511f1 100644 --- a/QLApps.xcodeproj/project.pbxproj +++ b/QLApps.xcodeproj/project.pbxproj @@ -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 = ""; }; - 5405CF5D2EA1199B00613856 /* Shared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shared.swift; sourceTree = ""; }; + 5405CF5D2EA1199B00613856 /* MetaInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetaInfo.swift; sourceTree = ""; }; 5405CF642EA1376B00613856 /* Zip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zip.swift; sourceTree = ""; }; 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 = ""; }; 54442C772E378BE0008A870E /* QLPreview.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = QLPreview.entitlements; sourceTree = ""; }; 545459C62EA4773A002892E5 /* AppIcon+Car.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppIcon+Car.swift"; sourceTree = ""; }; - 545459C82EA47C37002892E5 /* Plist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Plist.swift; sourceTree = ""; }; 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 = ""; }; 5458201E2EB29B3D0043A0B3 /* QLThumbnail.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = QLThumbnail.entitlements; sourceTree = ""; }; 5458201F2EB29B3D0043A0B3 /* ThumbnailProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailProvider.swift; sourceTree = ""; }; 5469E11C2EA5930C00D46CE7 /* Entitlements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Entitlements.swift; sourceTree = ""; }; + 547F52DC2EB2C15D002B6D5F /* ExpirationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpirationStatus.swift; sourceTree = ""; }; + 547F52E32EB2C3D8002B6D5F /* Html+iTunesPurchase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Html+iTunesPurchase.swift"; sourceTree = ""; }; + 547F52E62EB2C41C002B6D5F /* HtmlGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HtmlGenerator.swift; sourceTree = ""; }; + 547F52E92EB2C672002B6D5F /* Html+FileInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Html+FileInfo.swift"; sourceTree = ""; }; + 547F52EC2EB2C822002B6D5F /* Html+AppInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Html+AppInfo.swift"; sourceTree = ""; }; + 547F52EE2EB2C8E8002B6D5F /* Html+Provisioning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Html+Provisioning.swift"; sourceTree = ""; }; + 547F52F32EB2CA05002B6D5F /* Html+Entitlements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Html+Entitlements.swift"; sourceTree = ""; }; + 547F52F62EB2CAC7002B6D5F /* Html+Footer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Html+Footer.swift"; sourceTree = ""; }; + 547F52F82EB2CBAB002B6D5F /* Date+Format.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Format.swift"; sourceTree = ""; }; 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 = ""; }; 54D3A6ED2EA39CC6001EF4F6 /* AppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIcon.swift; sourceTree = ""; }; - 54D3A6EF2EA3F49F001EF4F6 /* RoundedIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedIcon.swift; sourceTree = ""; }; + 54D3A6EF2EA3F49F001EF4F6 /* NSBezierPath+RoundedRect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSBezierPath+RoundedRect.swift"; sourceTree = ""; }; 54D3A6F22EA4603B001EF4F6 /* defaultIcon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = defaultIcon.png; sourceTree = ""; }; 54D3A6F32EA4603B001EF4F6 /* template.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = template.html; sourceTree = ""; }; 54D3A6F52EA4610B001EF4F6 /* CoreUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = CoreUI.framework; sourceTree = ""; }; @@ -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 = ""; @@ -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; diff --git a/QLPreview/PreviewViewController.swift b/QLPreview/PreviewViewController.swift index 33fe541..ee40ca2 100644 --- a/QLPreview/PreviewViewController.swift +++ b/QLPreview/PreviewViewController.swift @@ -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) diff --git a/QLThumbnail/ThumbnailProvider.swift b/QLThumbnail/ThumbnailProvider.swift index 2f85108..c95cead 100644 --- a/QLThumbnail/ThumbnailProvider.swift +++ b/QLThumbnail/ThumbnailProvider.swift @@ -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 diff --git a/src/AppIcon.swift b/src/AppIcon.swift index 4e5b5f8..4d067ff 100644 --- a/src/AppIcon.swift +++ b/src/AppIcon.swift @@ -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 diff --git a/src/Date+Format.swift b/src/Date+Format.swift new file mode 100644 index 0000000..08ee0dd --- /dev/null +++ b/src/Date+Format.swift @@ -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 ? "Expired today" : "Expires today" + } + + if isPast { + let comp = self.diff(Date()) + return "Expired \(comp.relativeDateString()) ago" + } + + let comp = Date().diff(self) + if comp.day! < 30 { + return "Expires in \(comp.relativeDateString())" + } + 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"))" + } +} diff --git a/src/Entitlements.swift b/src/Entitlements.swift index 0a72f41..49d050d 100644 --- a/src/Entitlements.swift +++ b/src/Entitlements.swift @@ -56,7 +56,7 @@ struct Entitlements { } /// Print formatted plist in a @c \
 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(" = ") ?? "")
 		
diff --git a/src/ExpirationStatus.swift b/src/ExpirationStatus.swift
new file mode 100644
index 0000000..41912fe
--- /dev/null
+++ b/src/ExpirationStatus.swift
@@ -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"
+		}
+	}
+}
diff --git a/src/Html+AppInfo.swift b/src/Html+AppInfo.swift
new file mode 100644
index 0000000..398f033
--- /dev/null
+++ b/src/Html+AppInfo.swift
@@ -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..\n"
+			output += recursiveDict(subDict, withReplacements: replacements, level + 1)
+			output += "\n"
+		} else if let number = value as? NSNumber {
+			output += "\(localizedKey): \(number.boolValue ? "YES" : "NO")
" + } else { + output += "\(localizedKey): \(value)
" + } + } + 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 "
\(recursiveDict(value, withReplacements: localizedKeys))
" + } + + 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), + ]) + } +} diff --git a/src/Html+Entitlements.swift b/src/Html+Entitlements.swift new file mode 100644 index 0000000..c809338 --- /dev/null +++ b/src/Html+Entitlements.swift @@ -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", + ]) + } +} diff --git a/src/Html+FileInfo.swift b/src/Html+FileInfo.swift new file mode 100644 index 0000000..390cb7f --- /dev/null +++ b/src/Html+FileInfo.swift @@ -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: ">") +} diff --git a/src/Html+Footer.swift b/src/Html+Footer.swift new file mode 100644 index 0000000..86b3c2f --- /dev/null +++ b/src/Html+Footer.swift @@ -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 ?? "", + ]) + } +} diff --git a/src/Html+Provisioning.swift b/src/Html+Provisioning.swift new file mode 100644 index 0000000..9446119 --- /dev/null +++ b/src/Html+Provisioning.swift @@ -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? + 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 = "No invalidity date in certificate" + } + 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 ?? "Team name not available", + "TeamIds": (provisionPlist["TeamIdentifier"] as? [String])?.joined(separator: ", ") ?? "Team ID not available", + "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 = "\n" + if let header = header { + table += "\n" + } + for row in data { + table += "\n" + } + return table + "
\(header.joined(separator: ""))
\(row.joined(separator: ""))
\n" +} diff --git a/src/Html+iTunesPurchase.swift b/src/Html+iTunesPurchase.swift new file mode 100644 index 0000000..b937422 --- /dev/null +++ b/src/Html+iTunesPurchase.swift @@ -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 ?? "", + ]) + } +} diff --git a/src/HtmlGenerator.swift b/src/HtmlGenerator.swift new file mode 100644 index 0000000..9ee4f29 --- /dev/null +++ b/src/HtmlGenerator.swift @@ -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 + } +} diff --git a/src/Shared.swift b/src/MetaInfo.swift similarity index 67% rename from src/Shared.swift rename to src/MetaInfo.swift index 948e465..d4acb41 100644 --- a/src/Shared.swift +++ b/src/MetaInfo.swift @@ -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 - } -} diff --git a/src/RoundedIcon.swift b/src/NSBezierPath+RoundedRect.swift similarity index 100% rename from src/RoundedIcon.swift rename to src/NSBezierPath+RoundedRect.swift diff --git a/src/Plist.swift b/src/Plist.swift deleted file mode 100644 index e4bdd6b..0000000 --- a/src/Plist.swift +++ /dev/null @@ -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 - } - } -} diff --git a/src/PreviewGenerator.swift b/src/PreviewGenerator.swift deleted file mode 100644 index e8134fc..0000000 --- a/src/PreviewGenerator.swift +++ /dev/null @@ -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 = "\n" - if let header = header { - table += "\n" - } - for row in data { - table += "\n" - } - return table + "
\(header.joined(separator: ""))
\(row.joined(separator: ""))
\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..\n" - output += recursiveDict(subDict, withReplacements: replacements, level + 1) - output += "\n" - } else if let number = value as? NSNumber { - output += "\(localizedKey): \(number.boolValue ? "YES" : "NO")
" - } else { - output += "\(localizedKey): \(value)
" - } - } - 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 ? "Expired today" : "Expires today" - } - - if isPast { - let comp = dateDiff(date, Date()) - return "Expired \(relativeDateString(comp)) ago" - } - - let comp = dateDiff(Date(), date) - if comp.day! < 30 { - return "Expires in \(relativeDateString(comp))" - } - 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 "
\(recursiveDict(value, withReplacements: localizedKeys))
" - } - - 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? - 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 = "No invalidity date in certificate" - } - 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 ?? "Team name not available", - "TeamIds": (provisionPlist["TeamIdentifier"] as? [String])?.joined(separator: ", ") ?? "Team ID not available", - "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) -}