From 72e395c5da72c3a265842d28396df98e932bf607 Mon Sep 17 00:00:00 2001 From: relikd Date: Sun, 30 Nov 2025 15:36:34 +0100 Subject: [PATCH] ref: data structures for plist files --- Config.xcconfig | 2 +- QLAppBundle.xcodeproj/project.pbxproj | 36 +++- src/{MetaInfo+Apk.swift => ApkManifest.swift} | 0 src/AppIcon.swift | 48 +---- src/MetaInfo.swift | 10 -- src/Plist+Icon.swift | 65 +++++++ src/Plist+Info.swift | 70 ++++++++ src/Plist+MobileProvision.swift | 61 +++++++ src/Plist+iTunesMetadata.swift | 77 ++++++++ src/Preview+AppInfo.swift | 44 ++--- src/Preview+Entitlements.swift | 6 +- src/Preview+Provisioning.swift | 165 ++++-------------- src/Preview+TransportSecurity.swift | 4 +- src/Preview+iTunesPurchase.swift | 64 +------ src/PreviewGenerator.swift | 12 +- src/Provisioning.swift | 58 ++++++ 16 files changed, 430 insertions(+), 292 deletions(-) rename src/{MetaInfo+Apk.swift => ApkManifest.swift} (100%) create mode 100644 src/Plist+Icon.swift create mode 100644 src/Plist+Info.swift create mode 100644 src/Plist+MobileProvision.swift create mode 100644 src/Plist+iTunesMetadata.swift create mode 100644 src/Provisioning.swift diff --git a/Config.xcconfig b/Config.xcconfig index 67df077..e5e9158 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -11,4 +11,4 @@ MACOSX_DEPLOYMENT_TARGET = 10.15 MARKETING_VERSION = 1.4.0 PRODUCT_NAME = QLAppBundle PRODUCT_BUNDLE_IDENTIFIER = de.relikd.QLAppBundle -CURRENT_PROJECT_VERSION = 1930 +CURRENT_PROJECT_VERSION = 1981 diff --git a/QLAppBundle.xcodeproj/project.pbxproj b/QLAppBundle.xcodeproj/project.pbxproj index acfd248..ffcdff9 100644 --- a/QLAppBundle.xcodeproj/project.pbxproj +++ b/QLAppBundle.xcodeproj/project.pbxproj @@ -9,8 +9,8 @@ /* Begin PBXBuildFile section */ 5405CF5E2EA1199B00613856 /* MetaInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5405CF5D2EA1199B00613856 /* MetaInfo.swift */; }; 5405CF652EA1376B00613856 /* Zip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5405CF642EA1376B00613856 /* Zip.swift */; }; - 540B77D92ED79BBD009E030C /* MetaInfo+Apk.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540B77D82ED79BB2009E030C /* MetaInfo+Apk.swift */; }; - 540B77DA2ED79C6B009E030C /* MetaInfo+Apk.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540B77D82ED79BB2009E030C /* MetaInfo+Apk.swift */; }; + 540B77D92ED79BBD009E030C /* ApkManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540B77D82ED79BB2009E030C /* ApkManifest.swift */; }; + 540B77DA2ED79C6B009E030C /* ApkManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540B77D82ED79BB2009E030C /* ApkManifest.swift */; }; 540B77DC2ED79CC1009E030C /* AndroidXML in Frameworks */ = {isa = PBXBuildFile; productRef = 540B77DB2ED79CC1009E030C /* AndroidXML */; }; 540B77DE2ED79CC8009E030C /* AndroidXML in Frameworks */ = {isa = PBXBuildFile; productRef = 540B77DD2ED79CC8009E030C /* AndroidXML */; }; 5412DECE2EBC168600F9040D /* Preview+ArchiveInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5412DECD2EBC168600F9040D /* Preview+ArchiveInfo.swift */; }; @@ -41,6 +41,12 @@ 547F52F42EB2CA05002B6D5F /* Preview+Entitlements.swift in Sources */ = {isa = PBXBuildFile; fileRef = 547F52F32EB2CA05002B6D5F /* Preview+Entitlements.swift */; }; 547F52F72EB2CAC7002B6D5F /* Preview+Footer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 547F52F62EB2CAC7002B6D5F /* Preview+Footer.swift */; }; 547F52F92EB2CBAB002B6D5F /* Date+Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = 547F52F82EB2CBAB002B6D5F /* Date+Format.swift */; }; + 54993B882EDB9AA1008B656D /* Plist+Info.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54993B872EDB9A9B008B656D /* Plist+Info.swift */; }; + 54993B8A2EDBA596008B656D /* Plist+Icon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54993B892EDBA596008B656D /* Plist+Icon.swift */; }; + 54993B8B2EDBA75A008B656D /* Plist+Icon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54993B892EDBA596008B656D /* Plist+Icon.swift */; }; + 54993B932EDBB41D008B656D /* Plist+iTunesMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54993B922EDBB419008B656D /* Plist+iTunesMetadata.swift */; }; + 54993B952EDBC819008B656D /* Plist+MobileProvision.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54993B942EDBC813008B656D /* Plist+MobileProvision.swift */; }; + 54993B972EDC7C65008B656D /* Provisioning.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54993B962EDC7C61008B656D /* Provisioning.swift */; }; 549E3B9F2EBA9D2500ADFF56 /* QLAppBundle Preview Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 54442C202E378BAF008A870E /* QLAppBundle Preview Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 549E3BA12EBAE7D300ADFF56 /* URL+File.swift in Sources */ = {isa = PBXBuildFile; fileRef = 549E3BA02EBAE7D300ADFF56 /* URL+File.swift */; }; 549E3BA22EBAECD400ADFF56 /* URL+File.swift in Sources */ = {isa = PBXBuildFile; fileRef = 549E3BA02EBAE7D300ADFF56 /* URL+File.swift */; }; @@ -130,7 +136,7 @@ /* Begin PBXFileReference section */ 5405CF5D2EA1199B00613856 /* MetaInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetaInfo.swift; sourceTree = ""; }; 5405CF642EA1376B00613856 /* Zip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zip.swift; sourceTree = ""; }; - 540B77D82ED79BB2009E030C /* MetaInfo+Apk.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MetaInfo+Apk.swift"; sourceTree = ""; }; + 540B77D82ED79BB2009E030C /* ApkManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApkManifest.swift; sourceTree = ""; }; 5412DECD2EBC168600F9040D /* Preview+ArchiveInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preview+ArchiveInfo.swift"; sourceTree = ""; }; 5412DECF2EBC283000F9040D /* RuntimeError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeError.swift; sourceTree = ""; }; 54352E8A2EB6A79A0082F61D /* AssetCarReader.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AssetCarReader.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -160,6 +166,11 @@ 547F52F82EB2CBAB002B6D5F /* Date+Format.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Format.swift"; sourceTree = ""; }; 547F52FB2EB37F10002B6D5F /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 547F52FC2EB37F3A002B6D5F /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; + 54993B872EDB9A9B008B656D /* Plist+Info.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Plist+Info.swift"; sourceTree = ""; }; + 54993B892EDBA596008B656D /* Plist+Icon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Plist+Icon.swift"; sourceTree = ""; }; + 54993B922EDBB419008B656D /* Plist+iTunesMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Plist+iTunesMetadata.swift"; sourceTree = ""; }; + 54993B942EDBC813008B656D /* Plist+MobileProvision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Plist+MobileProvision.swift"; sourceTree = ""; }; + 54993B962EDC7C61008B656D /* Provisioning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Provisioning.swift; sourceTree = ""; }; 549E3B9E2EBA8FDA00ADFF56 /* CHANGELOG.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; 549E3BA02EBAE7D300ADFF56 /* URL+File.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+File.swift"; sourceTree = ""; }; 549E3BA32EBC021500ADFF56 /* Preview+TransportSecurity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preview+TransportSecurity.swift"; sourceTree = ""; }; @@ -222,10 +233,15 @@ isa = PBXGroup; children = ( 5405CF5D2EA1199B00613856 /* MetaInfo.swift */, - 540B77D82ED79BB2009E030C /* MetaInfo+Apk.swift */, 5412DECF2EBC283000F9040D /* RuntimeError.swift */, - 54D3A6EB2EA31B52001EF4F6 /* AppCategories.swift */, 54D3A6ED2EA39CC6001EF4F6 /* AppIcon.swift */, + 54993B892EDBA596008B656D /* Plist+Icon.swift */, + 54993B872EDB9A9B008B656D /* Plist+Info.swift */, + 54993B922EDBB419008B656D /* Plist+iTunesMetadata.swift */, + 54993B942EDBC813008B656D /* Plist+MobileProvision.swift */, + 54993B962EDC7C61008B656D /* Provisioning.swift */, + 540B77D82ED79BB2009E030C /* ApkManifest.swift */, + 54D3A6EB2EA31B52001EF4F6 /* AppCategories.swift */, 5469E11C2EA5930C00D46CE7 /* Entitlements.swift */, 547F52DC2EB2C15D002B6D5F /* ExpirationStatus.swift */, 547F52E62EB2C41C002B6D5F /* PreviewGenerator.swift */, @@ -552,20 +568,25 @@ 54D3A6F02EA3F49F001EF4F6 /* NSBezierPath+RoundedRect.swift in Sources */, 547F52E42EB2C3D8002B6D5F /* Preview+iTunesPurchase.swift in Sources */, 547F52F72EB2CAC7002B6D5F /* Preview+Footer.swift in Sources */, + 54993B8A2EDBA596008B656D /* Plist+Icon.swift in Sources */, + 54993B972EDC7C65008B656D /* Provisioning.swift in Sources */, 5469E11D2EA5930C00D46CE7 /* Entitlements.swift in Sources */, 54442C792E378BE0008A870E /* PreviewViewController.swift in Sources */, 547F52EF2EB2C8E8002B6D5F /* Preview+Provisioning.swift in Sources */, 547F52DE2EB2C15D002B6D5F /* ExpirationStatus.swift in Sources */, 54D3A6EE2EA39CC6001EF4F6 /* AppIcon.swift in Sources */, + 54993B952EDBC819008B656D /* Plist+MobileProvision.swift in Sources */, 547F52E82EB2C41C002B6D5F /* PreviewGenerator.swift in Sources */, + 54993B882EDB9AA1008B656D /* Plist+Info.swift in Sources */, 547F52EB2EB2C672002B6D5F /* Preview+FileInfo.swift in Sources */, 547F52ED2EB2C822002B6D5F /* Preview+AppInfo.swift in Sources */, 549E3BA12EBAE7D300ADFF56 /* URL+File.swift in Sources */, 547F52F42EB2CA05002B6D5F /* Preview+Entitlements.swift in Sources */, - 540B77D92ED79BBD009E030C /* MetaInfo+Apk.swift in Sources */, + 540B77D92ED79BBD009E030C /* ApkManifest.swift in Sources */, 5405CF5E2EA1199B00613856 /* MetaInfo.swift in Sources */, 547F52F92EB2CBAB002B6D5F /* Date+Format.swift in Sources */, 54D3A6EC2EA31B52001EF4F6 /* AppCategories.swift in Sources */, + 54993B932EDBB41D008B656D /* Plist+iTunesMetadata.swift in Sources */, 5405CF652EA1376B00613856 /* Zip.swift in Sources */, 5412DED02EBC283000F9040D /* RuntimeError.swift in Sources */, ); @@ -581,7 +602,8 @@ 54AE5BFF2EB3DB1000B4CFC7 /* ThumbnailProvider.swift in Sources */, 549E3BA22EBAECD400ADFF56 /* URL+File.swift in Sources */, 547899752EB38F3D00F96B80 /* AppIcon.swift in Sources */, - 540B77DA2ED79C6B009E030C /* MetaInfo+Apk.swift in Sources */, + 540B77DA2ED79C6B009E030C /* ApkManifest.swift in Sources */, + 54993B8B2EDBA75A008B656D /* Plist+Icon.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/src/MetaInfo+Apk.swift b/src/ApkManifest.swift similarity index 100% rename from src/MetaInfo+Apk.swift rename to src/ApkManifest.swift diff --git a/src/AppIcon.swift b/src/AppIcon.swift index 75bc773..2eaf7ec 100644 --- a/src/AppIcon.swift +++ b/src/AppIcon.swift @@ -17,7 +17,7 @@ struct AppIcon { func extractImageForThumbnail() -> NSImage { switch meta.type { case .IPA, .Archive, .Extension: - extractImage(from: meta.readPlistApp()) + extractImage(from: meta.readPlist_Icon()?.filenames) case .APK: extractImage(from: meta.readApkIconOnly()) } @@ -33,7 +33,7 @@ struct AppIcon { /// Try multiple methods to extract image. /// This method will always return an image even if none is found, in which case it returns the default image. - func extractImage(from appPlist: PlistDict?) -> NSImage { + func extractImage(from plistIcons: [String]?) -> NSImage { // no need to unwrap the plist, and most .ipa should include the Artwork anyway if meta.type == .IPA { if let data = meta.zipFile!.unzipFile("iTunesArtwork") { @@ -44,7 +44,7 @@ struct AppIcon { } // Extract image name from app plist - var plistImgNames = (appPlist == nil) ? [] : iconNamesFromPlist(appPlist!) + var plistImgNames = plistIcons ?? [] os_log(.debug, log: log, "[icon] icon names in plist: %{public}@", plistImgNames) // If no previous filename works (or empty), try default icon names @@ -90,33 +90,6 @@ 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] { - // 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 [] - } - /// 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] = [] @@ -161,21 +134,6 @@ extension AppIcon { } 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 { diff --git a/src/MetaInfo.swift b/src/MetaInfo.swift index 8d78553..c7a5841 100644 --- a/src/MetaInfo.swift +++ b/src/MetaInfo.swift @@ -85,16 +85,6 @@ struct MetaInfo { return try? Data(contentsOf: self.effectiveUrl(osxSubdir, 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", osxSubdir: nil)?.asPlistOrNil() - case .APK: - return nil // not applicable for Android - } - } } diff --git a/src/Plist+Icon.swift b/src/Plist+Icon.swift new file mode 100644 index 0000000..9386478 --- /dev/null +++ b/src/Plist+Icon.swift @@ -0,0 +1,65 @@ + +extension MetaInfo { + /// Read `Info.plist`. (used for `ThumbnailProvider`) + func readPlist_Icon() -> Plist_Icon? { + if let x = self.readPayloadFile("Info.plist", osxSubdir: nil)?.asPlistOrNil() { + return Plist_Icon(x) + } + return nil + } +} + + +// MARK: - Plist_Icon + +/// Representation of `Info.plist` (containing only the icon extractor). +/// Seperate from main class because everything else is not needed for `ThumbnailProvider` +struct Plist_Icon { + let filenames: [String] + + init(_ plist: PlistDict) { + filenames = parseIconNames(plist) + } +} + +/// Find icon filenames. +/// @return Filenames which do not necessarily exist on filesystem. This may include `@2x` and/or no file extension. +private func parseIconNames(_ plist: PlistDict) -> [String] { + // Check for CFBundleIcons (since 5.0) + if let icons = unpackNameList(plist["CFBundleIcons"]) { + return icons + } + // iPad-only apps + if let icons = unpackNameList(plist["CFBundleIcons~ipad"]) { + return icons + } + // Check for CFBundleIconFiles (since 3.2) + if let icons = plist["CFBundleIconFiles"] as? [String], !icons.isEmpty { + return icons + } + // key found on iTunesU app + if let icons = plist["Icon files"] as? [String], !icons.isEmpty { + return icons + } + // Check for CFBundleIconFile (legacy, before 3.2) + if let icon = plist["CFBundleIconFile"] as? String { // may be nil + return [icon] + } + return [] +} + +/// Deep select icons from plist key `CFBundleIcons` and `CFBundleIcons~ipad` +/// @return Guarantees a non-empty array (or `nil`) +private func unpackNameList(_ bundleDict: Any?) -> [String]? { + if let bundleDict = bundleDict as? PlistDict { + if let primaryDict = bundleDict["CFBundlePrimaryIcon"] as? PlistDict { + if let icons = primaryDict["CFBundleIconFiles"] as? [String], !icons.isEmpty { + return icons + } + if let name = primaryDict["CFBundleIconName"] as? String { // key found on a .tipa file + return [name] + } + } + } + return nil +} diff --git a/src/Plist+Info.swift b/src/Plist+Info.swift new file mode 100644 index 0000000..f8af37d --- /dev/null +++ b/src/Plist+Info.swift @@ -0,0 +1,70 @@ +import Foundation + +extension MetaInfo { + /// Read `Info.plist`. (used for `PreviewProvider`) + func readPlist_Info() -> Plist_Info? { + if let x = self.readPayloadFile("Info.plist", osxSubdir: nil)?.asPlistOrNil() { + return Plist_Info(x, isOSX: isOSX) + } + return nil + } +} + + +// MARK: - Plist_Info + +/// Representation of `Info.plist` of an `.ipa` bundle +struct Plist_Info { + let bundleId: String? + let name: String? + let version: String? + let buildVersion: String? + + let exePath: String? + let sdkVersion: String? + let minOS: String? + let extensionType: String? + + let icons: [String] + let deviceFamily: [String] + let transportSecurity: PlistDict? + + init(_ plist: PlistDict, isOSX: Bool) { + bundleId = plist["CFBundleIdentifier"] as? String + name = plist["CFBundleDisplayName"] as? String ?? plist["CFBundleName"] as? String + version = plist["CFBundleShortVersionString"] as? String + buildVersion = plist["CFBundleVersion"] as? String + exePath = plist["CFBundleExecutable"] as? String + sdkVersion = plist["DTSDKName"] as? String + minOS = plist[isOSX ? "LSMinimumSystemVersion" : "MinimumOSVersion"] as? String + extensionType = (plist["NSExtension"] as? PlistDict)?["NSExtensionPointIdentifier"] as? String + icons = Plist_Icon(plist).filenames + deviceFamily = parseDeviceFamily(plist, isOSX: isOSX) + transportSecurity = plist["NSAppTransportSecurity"] as? PlistDict + } +} + +private func parseDeviceFamily(_ plist: PlistDict, isOSX: Bool) -> [String] { + if isOSX { + return plist["CFBundleSupportedPlatforms"] as? [String] ?? ["macOS"] + } + + if let platforms = (plist["UIDeviceFamily"] as? [Int])?.compactMap({ + switch $0 { + case 1: "iPhone" + case 2: "iPad" + case 3: "TV" + case 4: "Watch" + default: nil + } + }), platforms.count > 0 { + return platforms + } + + if let minVersion = plist["MinimumOSVersion"] as? String { + if minVersion.hasPrefix("1.") || minVersion.hasPrefix("2.") || minVersion.hasPrefix("3.") { + return ["iPhone"] + } + } + return [] +} diff --git a/src/Plist+MobileProvision.swift b/src/Plist+MobileProvision.swift new file mode 100644 index 0000000..702d291 --- /dev/null +++ b/src/Plist+MobileProvision.swift @@ -0,0 +1,61 @@ +import Foundation + +extension MetaInfo { + /// Read `embedded.mobileprovision` (if available) and decode with CMS decoder. + func readPlist_MobileProvision() -> Plist_MobileProvision? { + guard let provisionData = self.readPayloadFile("embedded.mobileprovision", osxSubdir: nil), + let plist = provisionData.decodeCMS().asPlistOrNil() else { + return nil + } + return Plist_MobileProvision(plist, isOSX: self.isOSX) + } +} + +// MARK: - Plist_MobileProvision + +/// Representation of `embedded.mobileprovision` +struct Plist_MobileProvision { + let creationDate: Date? + let expireDate: Date? + let profileId: String? + let profileName: String? + /// Something like "Development" or "Distribution (App Store)". + let profileType: String + /// Either "Mac" or "iOS" + let profilePlatform: String + let teamName: String? + let teamIds: [String] + let devices: [String] + let certificates: [ProvisioningCertificate] + let entitlements: PlistDict? + + init(_ plist: PlistDict, isOSX: Bool) { + creationDate = plist["CreationDate"] as? Date + expireDate = plist["ExpirationDate"] as? Date + profileId = plist["UUID"] as? String + profileName = plist["Name"] as? String + profileType = parseProfileType(plist, isOSX: isOSX) + profilePlatform = isOSX ? "Mac" : "iOS" + teamName = plist["TeamName"] as? String + teamIds = plist["TeamIdentifier"] as? [String] ?? [] + devices = plist["ProvisionedDevices"] as? [String] ?? [] + certificates = (plist["DeveloperCertificates"] as? [Data] ?? []).compactMap { + ProvisioningCertificate($0) + } + entitlements = plist["Entitlements"] as? PlistDict + } +} + +/// Returns provision type string like "Development" or "Distribution (App Store)". +private func parseProfileType(_ plist: PlistDict, isOSX: Bool) -> String { + let hasDevices = plist["ProvisionedDevices"] is [Any] + if isOSX { + return hasDevices ? "Development" : "Distribution (App Store)" + } + if hasDevices { + let getTaskAllow = (plist["Entitlements"] as? PlistDict)?["get-task-allow"] as? Bool ?? false + return getTaskAllow ? "Development" : "Distribution (Ad Hoc)" + } + let isEnterprise = plist["ProvisionsAllDevices"] as? Bool ?? false + return isEnterprise ? "Enterprise" : "Distribution (App Store)" +} diff --git a/src/Plist+iTunesMetadata.swift b/src/Plist+iTunesMetadata.swift new file mode 100644 index 0000000..bdc7f43 --- /dev/null +++ b/src/Plist+iTunesMetadata.swift @@ -0,0 +1,77 @@ +import Foundation + +extension MetaInfo { + /// Read `iTunesMetadata.plist` (if available) + func readPlist_iTunesMetadata() -> Plist_iTunesMetadata? { + assert(type == .IPA) + // not `readPayloadFile` because plist is in root dir + guard let plist = self.zipFile!.unzipFile("iTunesMetadata.plist")?.asPlistOrNil() else { + return nil + } + return Plist_iTunesMetadata(plist) + } +} + + +// MARK: - Plist_iTunesMetadata + +/// Representation of `iTunesMetadata.plist` +struct Plist_iTunesMetadata { + let appId: Int? + let appName: String? + let price: String? + let genres: [String] + // purchase info + let releaseDate: Date? + let purchaseDate: Date? + // account info + let appleId: String? + let firstName: String? + let lastName: String? + + init(_ plist: PlistDict) { + appId = plist["itemId"] as? Int + appName = plist["itemName"] as? String + price = plist["priceDisplay"] as? String + genres = formattedGenres(plist) + // download info + let downloadInfo = plist["com.apple.iTunesStore.downloadInfo"] as? PlistDict + purchaseDate = Date.parseAny(downloadInfo?["purchaseDate"] ?? plist["purchaseDate"]) + releaseDate = Date.parseAny(downloadInfo?["releaseDate"] ?? plist["releaseDate"]) + // AppleId & purchaser name + let accountInfo = downloadInfo?["accountInfo"] as? PlistDict ?? [:] + appleId = accountInfo["AppleID"] as? String ?? plist["appleId"] as? String + firstName = accountInfo["FirstName"] as? String + lastName = accountInfo["LastName"] as? String + } + + /// Returns `" ()"` (with empty values omitted) + var purchaserName: String? { + let fn = firstName ?? "" + let ln = lastName ?? "" + let aid = appleId ?? "" + switch (fn.isEmpty, ln.isEmpty, aid.isEmpty) { + case (true, true, true): return nil + case (true, true, false): return "\(aid)" + case (_, _, false): return "\(fn) \(ln) (\(aid))" + case (_, _, true): return "\(fn) \(ln)" + } + } +} + +/// Concatenate all (sub)genres into flat list. +private func formattedGenres(_ plist: PlistDict) -> [String] { + var genres: [String] = [] + let genreId = plist["genreId"] as? Int ?? 0 + if let mainGenre = AppCategories[genreId] ?? plist["genre"] as? String { + genres.append(mainGenre) + } + + for subgenre in plist["subgenres"] as? [PlistDict] ?? [] { + let subgenreId = subgenre["genreId"] as? Int ?? 0 + if let subgenreStr = AppCategories[subgenreId] ?? subgenre["genre"] as? String { + genres.append(subgenreStr) + } + } + return genres +} diff --git a/src/Preview+AppInfo.swift b/src/Preview+AppInfo.swift index 99cbff5..13656f9 100644 --- a/src/Preview+AppInfo.swift +++ b/src/Preview+AppInfo.swift @@ -1,45 +1,21 @@ import Foundation extension PreviewGenerator { - private func deviceFamilyList(_ appPlist: PlistDict, isOSX: Bool) -> String { - if isOSX { - return (appPlist["CFBundleSupportedPlatforms"] as? [String])?.joined(separator: ", ") ?? "macOS" - } - let 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.") { - return "iPhone" - } - return platforms ?? "" - } - /// Process info stored in `Info.plist` - mutating func procAppInfoApple(_ appPlist: PlistDict, isOSX: Bool) { - let minVersion = appPlist[isOSX ? "LSMinimumSystemVersion" : "MinimumOSVersion"] as? String ?? "" - - let extensionType = (appPlist["NSExtension"] as? PlistDict)?["NSExtensionPointIdentifier"] as? String + mutating func procAppInfoApple(_ appPlist: Plist_Info) { self.apply([ "AppInfoHidden": CLASS_VISIBLE, - "AppName": appPlist["CFBundleDisplayName"] as? String ?? appPlist["CFBundleName"] as? String ?? "", - "AppVersion": appPlist["CFBundleShortVersionString"] as? String ?? "", - "AppBuildVer": appPlist["CFBundleVersion"] as? String ?? "", - "AppId": appPlist["CFBundleIdentifier"] as? String ?? "", + "AppName": appPlist.name ?? "", + "AppVersion": appPlist.version ?? "", + "AppBuildVer": appPlist.buildVersion ?? "", + "AppId": appPlist.bundleId ?? "", - "AppExtensionTypeHidden": extensionType != nil ? CLASS_VISIBLE : CLASS_HIDDEN, - "AppExtensionType": extensionType ?? "", + "AppExtensionTypeHidden": appPlist.extensionType != nil ? CLASS_VISIBLE : CLASS_HIDDEN, + "AppExtensionType": appPlist.extensionType ?? "", - "AppDeviceFamily": deviceFamilyList(appPlist, isOSX: isOSX), - "AppSDK": appPlist["DTSDKName"] as? String ?? "", - "AppMinOS": minVersion, + "AppDeviceFamily": appPlist.deviceFamily.joined(separator: ", "), + "AppSDK": appPlist.sdkVersion ?? "", + "AppMinOS": appPlist.minOS ?? "", ]) } diff --git a/src/Preview+Entitlements.swift b/src/Preview+Entitlements.swift index 14851ee..81ec66d 100644 --- a/src/Preview+Entitlements.swift +++ b/src/Preview+Entitlements.swift @@ -22,9 +22,9 @@ extension PreviewGenerator { } /// 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) + mutating func procEntitlements(_ meta: MetaInfo, _ appPlist: Plist_Info?, _ provisionPlist: Plist_MobileProvision?) { + var entitlements = readEntitlements(meta, appPlist?.exePath) + entitlements.applyFallbackIfNeeded(provisionPlist?.entitlements) if entitlements.html == nil && !entitlements.hasError { return diff --git a/src/Preview+Provisioning.swift b/src/Preview+Provisioning.swift index 5725bc8..295e990 100644 --- a/src/Preview+Provisioning.swift +++ b/src/Preview+Provisioning.swift @@ -1,148 +1,57 @@ 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", osxSubdir: nil) 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 PreviewGenerator { - - // 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) { + mutating func procProvision(_ provisionPlist: Plist_MobileProvision?) { guard let provisionPlist else { return } - let creationDate = provisionPlist["CreationDate"] as? Date - let expireDate = provisionPlist["ExpirationDate"] as? Date - let devices = getDeviceList(provisionPlist) - let certs = getCertificateList(provisionPlist) - + let deviceCount = provisionPlist.devices.count self.apply([ "ProvisionHidden": CLASS_VISIBLE, - "ProvisionProfileName": provisionPlist["Name"] as? String ?? "", - "ProvisionProfileId": provisionPlist["UUID"] as? String ?? "", - "ProvisionTeamName": provisionPlist["TeamName"] as? String ?? "Team name not available", - "ProvisionTeamIds": (provisionPlist["TeamIdentifier"] as? [String])?.joined(separator: ", ") ?? "Team ID not available", - "ProvisionCreateDate": creationDate?.formattedCreationDate() ?? "", - "ProvisionExpireDate": expireDate?.formattedExpirationDate() ?? "", - "ProvisionExpireStatus": ExpirationStatus(expireDate).cssClass(), + "ProvisionProfileName": provisionPlist.profileName ?? "", + "ProvisionProfileId": provisionPlist.profileId ?? "", + "ProvisionTeamName": provisionPlist.teamName ?? "Team name not available", + "ProvisionTeamIds": provisionPlist.teamIds.isEmpty ? "Team ID not available" : provisionPlist.teamIds.joined(separator: ", "), + "ProvisionCreateDate": provisionPlist.creationDate?.formattedCreationDate() ?? "", + "ProvisionExpireDate": provisionPlist.expireDate?.formattedExpirationDate() ?? "", + "ProvisionExpireStatus": ExpirationStatus(provisionPlist.expireDate).cssClass(), - "ProvisionProfilePlatform": isOSX ? "Mac" : "iOS", - "ProvisionProfileType": stringForProfileType(provisionPlist, isOSX: isOSX), + "ProvisionProfilePlatform": provisionPlist.profilePlatform, + "ProvisionProfileType": provisionPlist.profileType, - "ProvisionDeviceCount": devices.isEmpty ? "No Devices" : "\(devices.count) Device\(devices.count == 1 ? "" : "s")", - "ProvisionDeviceIds": devices.isEmpty ? "Distribution Profile" : formatAsTable(devices, header: ["", "UDID"]), + "ProvisionDeviceCount": deviceCount == 0 ? "No Devices" : "\(deviceCount) Device\(deviceCount == 1 ? "" : "s")", + "ProvisionDeviceIds": deviceCount == 0 ? "Distribution Profile" : formatAsTable(groupDevices(provisionPlist.devices), header: ["", "UDID"]), - "ProvisionDevelopCertificates": certs.isEmpty ? "No Developer Certificates" : formatAsTable(certs), + "ProvisionDevelopCertificates": provisionPlist.certificates.isEmpty ? "No Developer Certificates" + : formatAsTable( + provisionPlist.certificates + .sorted { $0.subject < $1.subject } + .map {TableRow([$0.subject, $0.expiration?.relativeExpirationDateString() ?? "No invalidity date in certificate"])} + ), ]) } } +/// Group device ids by first letter (`d -> device02`) +private func groupDevices(_ devices: [String]) -> [TableRow] { + var currentPrefix: String? = nil + return devices.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] + } +} + + private typealias TableRow = [String] /// Print html table with arbitrary number of columns diff --git a/src/Preview+TransportSecurity.swift b/src/Preview+TransportSecurity.swift index c25a108..8cedeb5 100644 --- a/src/Preview+TransportSecurity.swift +++ b/src/Preview+TransportSecurity.swift @@ -43,8 +43,8 @@ private func recursiveTransportSecurity(_ dictionary: PlistDict, _ level: Int = extension PreviewGenerator { /// Process ATS info in `Info.plist` - mutating func procTransportSecurity(_ appPlist: PlistDict?) { - guard let value = appPlist?["NSAppTransportSecurity"] as? PlistDict else { + mutating func procTransportSecurity(_ appPlist: Plist_Info?) { + guard let value = appPlist?.transportSecurity else { return } diff --git a/src/Preview+iTunesPurchase.swift b/src/Preview+iTunesPurchase.swift index 5b10578..c016eea 100644 --- a/src/Preview+iTunesPurchase.swift +++ b/src/Preview+iTunesPurchase.swift @@ -1,69 +1,21 @@ 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, .APK: - return nil - } - } -} - - extension PreviewGenerator { - /// 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?) { + mutating func procItunesMeta(_ itunesPlist: Plist_iTunesMetadata?) { guard let itunesPlist else { 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": CLASS_VISIBLE, - "iTunesId": (itunesPlist["itemId"] as? Int)?.description ?? "", - "iTunesName": itunesPlist["itemName"] as? String ?? "", - "iTunesGenres": formattedGenres(itunesPlist), - "iTunesReleaseDate": releaseDate?.mediumFormat() ?? "", + "iTunesId": itunesPlist.appId?.description ?? "", + "iTunesName": itunesPlist.appName ?? "", + "iTunesGenres": itunesPlist.genres.joined(separator: ", "), + "iTunesReleaseDate": itunesPlist.releaseDate?.mediumFormat() ?? "", - "iTunesAppleId": name, - "iTunesPurchaseDate": purchaseDate?.mediumFormat() ?? "", - "iTunesPrice": itunesPlist["priceDisplay"] as? String ?? "", + "iTunesAppleId": itunesPlist.purchaserName ?? "", + "iTunesPurchaseDate": itunesPlist.purchaseDate?.mediumFormat() ?? "", + "iTunesPrice": itunesPlist.price ?? "", ]) } } diff --git a/src/PreviewGenerator.swift b/src/PreviewGenerator.swift index a3666cc..2e571a3 100644 --- a/src/PreviewGenerator.swift +++ b/src/PreviewGenerator.swift @@ -26,22 +26,22 @@ struct PreviewGenerator { switch meta.type { case .IPA, .Archive, .Extension: - guard let plistApp = meta.readPlistApp() else { + guard let plistApp = meta.readPlist_Info() else { throw RuntimeError("Info.plist not found") } - procAppInfoApple(plistApp, isOSX: meta.isOSX) + procAppInfoApple(plistApp) if meta.type == .IPA { - procItunesMeta(meta.readPlistItunes()) + procItunesMeta(meta.readPlist_iTunesMetadata()) } else if meta.type == .Archive { procArchiveInfo(meta.readPlistXCArchive()) } procTransportSecurity(plistApp) - let plistProvision = meta.readPlistProvision() + let plistProvision = meta.readPlist_MobileProvision() procEntitlements(meta, plistApp, plistProvision) - procProvision(plistProvision, isOSX: meta.isOSX) + procProvision(plistProvision) // App Icon (last, because the image uses a lot of memory) - data["AppIcon"] = AppIcon(meta).extractImage(from: plistApp).withRoundCorners().asBase64() + data["AppIcon"] = AppIcon(meta).extractImage(from: plistApp.icons).withRoundCorners().asBase64() case .APK: guard let manifest = meta.readApkManifest() else { diff --git a/src/Provisioning.swift b/src/Provisioning.swift new file mode 100644 index 0000000..ea9cd4e --- /dev/null +++ b/src/Provisioning.swift @@ -0,0 +1,58 @@ +import Foundation +import os // OSLog + +private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Provisioning") + +extension Data { + /// In-memory decode of `embedded.mobileprovision` + func decodeCMS() -> Data { + var decoder: CMSDecoder? = nil + CMSDecoderCreate(&decoder) + return self.withUnsafeBytes { ptr in + CMSDecoderUpdateMessage(decoder!, ptr.baseAddress!, self.count) + CMSDecoderFinalizeMessage(decoder!) + var dataRef: CFData? + CMSDecoderCopyContent(decoder!, &dataRef) + return Data(referencing: dataRef!) + } + } +} + +struct ProvisioningCertificate { + let subject: String + let expiration: Date? + + /// Parse subject and expiration date from certificate. + init?(_ data: Data) { + guard let cert = SecCertificateCreateWithData(nil, data as CFData) else { + return nil + } + guard let subj = SecCertificateCopySubjectSummary(cert) as? String else { + os_log(.error, log: log, "Could not get subject from certificate") + return nil + } + subject = subj + expiration = parseInvalidityDate(cert, subject: subj) + } +} + +/// Process a single certificate. Extract invalidity / expiration date. +/// @param subject just used for printing error logs. +private func parseInvalidityDate(_ 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) +}