From af9c398571d691fdee4d32f81a97aaafa08736fa Mon Sep 17 00:00:00 2001 From: relikd Date: Wed, 5 Nov 2025 18:18:08 +0100 Subject: [PATCH] feat: support for macOS xcarchive --- src/AppIcon.swift | 5 ++- src/MetaInfo.swift | 63 ++++++++++++++++++++++++---------- src/Preview+AppInfo.swift | 18 +++++++--- src/Preview+Entitlements.swift | 6 ++-- src/Preview+Provisioning.swift | 2 +- src/PreviewGenerator.swift | 2 +- 6 files changed, 64 insertions(+), 32 deletions(-) diff --git a/src/AppIcon.swift b/src/AppIcon.swift index ba008fb..66a554d 100644 --- a/src/AppIcon.swift +++ b/src/AppIcon.swift @@ -54,7 +54,7 @@ struct AppIcon { /// Extract an image from `Assets.car` func imageFromAssetsCar(_ imageName: String) -> NSImage? { - guard let data = meta.readPayloadFile("Assets.car") else { + guard let data = meta.readPayloadFile("Assets.car", osxSubdir: "Resources") else { return nil } return CarReader(data)?.imageFromAssetsCar(imageName) @@ -114,10 +114,9 @@ extension AppIcon { } 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 + let parentDir = meta.effectiveUrl("Resources", iconPath).deletingLastPathComponent().path guard let files = try? FileManager.default.contentsOfDirectory(atPath: parentDir) else { continue } diff --git a/src/MetaInfo.swift b/src/MetaInfo.swift index 65d9a84..a1eec3d 100644 --- a/src/MetaInfo.swift +++ b/src/MetaInfo.swift @@ -16,17 +16,18 @@ enum FileType { struct MetaInfo { let UTI: String let url: URL - let effectiveUrl: URL? // if set, will point to the app inside of an archive + private 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 // relict of the past when ProvisionQL also processed provision profiles + let isOSX: Bool /// Use file url and UTI type to generate an info object to pass around. init(_ url: URL) { self.url = url self.UTI = try! url.resourceValues(forKeys: [.typeIdentifierKey]).typeIdentifier ?? "Unknown" + var isOSX = false var effective: URL? = nil var zipFile: ZipFile? = nil @@ -36,26 +37,46 @@ struct MetaInfo { zipFile = ZipFile(self.url.path) case "com.apple.xcode.archive": self.type = FileType.Archive - effective = appPathForArchive(self.url) + let productsDir = url.appendingPathComponent("Products", isDirectory: true) + if productsDir.exists() { + if let bundleDir = recursiveSearchInfoPlist(productsDir) { + isOSX = bundleDir.appendingPathComponent("MacOS").exists() && bundleDir.lastPathComponent == "Contents" + effective = bundleDir + } + } case "com.apple.application-and-system-extension": self.type = FileType.Extension default: os_log(.error, log: log, "Unsupported file type: %{public}@", self.UTI) fatalError() } + self.isOSX = isOSX self.zipFile = zipFile - self.effectiveUrl = effective + self.effectiveUrl = effective ?? url + } + + /// Evaluate path with `osxSubdir` and `filename` + func effectiveUrl(_ osxSubdir: String?, _ filename: String) -> URL { + switch self.type { + case .IPA: + return effectiveUrl + case .Archive, .Extension: + if isOSX, let osxSubdir { + return effectiveUrl + .appendingPathComponent(osxSubdir, isDirectory: true) + .appendingPathComponent(filename, isDirectory: false) + } + return effectiveUrl.appendingPathComponent(filename, isDirectory: false) + } } /// Load a file from bundle into memory. Either by file path or via unzip. - func readPayloadFile(_ filename: String) -> Data? { - switch (self.type) { + func readPayloadFile(_ filename: String, osxSubdir: String?) -> Data? { + switch self.type { case .IPA: return zipFile!.unzipFile("Payload/*.app/".appending(filename)) - case .Archive: - return try? Data(contentsOf: effectiveUrl!.appendingPathComponent(filename)) - case .Extension: - return try? Data(contentsOf: url.appendingPathComponent(filename)) + case .Archive, .Extension: + return try? Data(contentsOf: self.effectiveUrl(osxSubdir, filename)) } } @@ -63,7 +84,7 @@ struct MetaInfo { func readPlistApp() -> PlistDict? { switch self.type { case .IPA, .Archive, .Extension: - return self.readPayloadFile("Info.plist")?.asPlistOrNil() + return self.readPayloadFile("Info.plist", osxSubdir: nil)?.asPlistOrNil() } } } @@ -88,14 +109,20 @@ extension Data { } -// MARK: - Meta data for QuickLook +// MARK: - helper methods -/// Search an archive for the .app or .ipa bundle. -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 { - return x.first +/// breadth-first search for `Info.plist` +private func recursiveSearchInfoPlist(_ url: URL) -> URL? { + var queue: [URL] = [url] + while !queue.isEmpty { + let current = queue.removeLast() + if let subfiles = try? FileManager.default.contentsOfDirectory(at: current, includingPropertiesForKeys: []) { + for fname in subfiles { + if fname.lastPathComponent == "Info.plist" { + return fname.deletingLastPathComponent() + } + } + queue.append(contentsOf: subfiles) } } return nil diff --git a/src/Preview+AppInfo.swift b/src/Preview+AppInfo.swift index 37c1171..f4e3ccc 100644 --- a/src/Preview+AppInfo.swift +++ b/src/Preview+AppInfo.swift @@ -56,9 +56,11 @@ extension PreviewGenerator { return "No exceptions" } - /// Process info stored in `Info.plist` - mutating func procAppInfo(_ appPlist: PlistDict) { - var platforms = (appPlist["UIDeviceFamily"] as? [Int])?.compactMap({ + 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" @@ -70,8 +72,14 @@ extension PreviewGenerator { let minVersion = appPlist["MinimumOSVersion"] as? String ?? "" if platforms?.isEmpty ?? true, minVersion.hasPrefix("1.") || minVersion.hasPrefix("2.") || minVersion.hasPrefix("3.") { - platforms = "iPhone" + return "iPhone" } + return platforms ?? "" + } + + /// Process info stored in `Info.plist` + mutating func procAppInfo(_ appPlist: PlistDict, isOSX: Bool) { + let minVersion = appPlist[isOSX ? "LSMinimumSystemVersion" : "MinimumOSVersion"] as? String ?? "" let extensionType = (appPlist["NSExtension"] as? PlistDict)?["NSExtensionPointIdentifier"] as? String self.apply([ @@ -83,7 +91,7 @@ extension PreviewGenerator { "AppExtensionTypeHidden": extensionType != nil ? "" : CLASS_HIDDEN, "AppExtensionType": extensionType ?? "", - "AppDeviceFamily": platforms ?? "", + "AppDeviceFamily": deviceFamilyList(appPlist, isOSX: isOSX), "AppSDK": appPlist["DTSDKName"] as? String ?? "", "AppMinOS": minVersion, "AppTransportSecurity": formattedAppTransportSecurity(appPlist), diff --git a/src/Preview+Entitlements.swift b/src/Preview+Entitlements.swift index 044131c..bcc4e3c 100644 --- a/src/Preview+Entitlements.swift +++ b/src/Preview+Entitlements.swift @@ -16,10 +16,8 @@ extension PreviewGenerator { } 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) + case .Archive, .Extension: + return Entitlements(forBinary: meta.effectiveUrl("MacOS", bundleExecutable).path) } } diff --git a/src/Preview+Provisioning.swift b/src/Preview+Provisioning.swift index e1cee9e..eea5e61 100644 --- a/src/Preview+Provisioning.swift +++ b/src/Preview+Provisioning.swift @@ -7,7 +7,7 @@ private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Htm extension MetaInfo { /// Read `embedded.mobileprovision` file and decode with CMS decoder. func readPlistProvision() -> PlistDict? { - guard let provisionData = self.readPayloadFile("embedded.mobileprovision") else { + 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 } diff --git a/src/PreviewGenerator.swift b/src/PreviewGenerator.swift index 362f298..97709ba 100644 --- a/src/PreviewGenerator.swift +++ b/src/PreviewGenerator.swift @@ -16,7 +16,7 @@ struct PreviewGenerator { data["QuickLookTitle"] = stringForFileType(meta) - procAppInfo(plistApp) + procAppInfo(plistApp, isOSX: meta.isOSX) procItunesMeta(plistItunes) procProvision(plistProvision, isOSX: meta.isOSX) procEntitlements(meta, plistApp, plistProvision)