From 5908c2daac3e6e3a11bb6a298e0d1cb127849af3 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Fri, 9 Jan 2026 11:27:57 -0500 Subject: [PATCH] Warn if potentially undesired app folder modified. Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- README.md | 2 +- .../AppStore/AppStoreAction+download.swift | 84 ++++++++++----- Sources/mas/Commands/MAS.swift | 11 +- .../Controllers/InstalledApp+Spotlight.swift | 101 +++++++++--------- Sources/mas/Models/InstalledApp.swift | 2 +- Sources/mas/Network/URL.swift | 4 + Sources/mas/Utilities/Sudo.swift | 2 +- 7 files changed, 129 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index f3840965d..96b12140a 100644 --- a/README.md +++ b/README.md @@ -547,7 +547,7 @@ $ mdls -rn kMDItemAppStoreAdamID /Applications/WhatsApp.app If an app has been indexed in the MDS, the path to the app can be found: ```shell -mdfind 'kMDItemAppStoreAdamID == ' +mdfind 'kMDItemAppStoreAdamID = ' ``` If any App Store apps are not properly indexed, you can reindex: diff --git a/Sources/mas/AppStore/AppStoreAction+download.swift b/Sources/mas/AppStore/AppStoreAction+download.swift index 389165471..245cdc29a 100644 --- a/Sources/mas/AppStore/AppStoreAction+download.swift +++ b/Sources/mas/AppStore/AppStoreAction+download.swift @@ -191,7 +191,7 @@ private actor DownloadQueueObserver: CKDownloadQueueObserver { } catch { MAS.printer.warning( "Failed to read contents of download folder", - downloadFolderURL.path(percentEncoded: false).quoted, + downloadFolderURL.filePath.quoted, "for", snapshot.appNameAndVersion, error: error, @@ -238,7 +238,7 @@ private actor DownloadQueueObserver: CKDownloadQueueObserver { MAS.printer.clearCurrentLine(of: .standardOutput) do { - let appFolderPath: String? + let appFolderURL: URL? if let error = snapshot.error { guard error is Ignorable else { throw error @@ -251,7 +251,7 @@ private actor DownloadQueueObserver: CKDownloadQueueObserver { "progress cannot be displayed", terminator: "", ) - appFolderPath = try await install(appNameAndVersion: snapshot.appNameAndVersion) + appFolderURL = try await install(appNameAndVersion: snapshot.appNameAndVersion) MAS.printer.clearCurrentLine(of: .standardOutput) } else { guard !snapshot.isFailed else { @@ -265,13 +265,55 @@ private actor DownloadQueueObserver: CKDownloadQueueObserver { throw MASError.error("Download cancelled for \(snapshot.appNameAndVersion)") } - appFolderPath = snapshot.appFolderPath + appFolderURL = snapshot.appFolderPath.map { URL(filePath: $0, directoryHint: .isDirectory) } } MAS.printer.notice( [action.performed.capitalizingFirstCharacter, snapshot.appNameAndVersion] - + (appFolderPath.map { ["in", $0] } ?? []), // swiftformat:disable:this indent + + (appFolderURL.map { ["in", $0.filePath] } ?? []), // swiftformat:disable:this indent ) + + if let appFolderURL { + let fileManager = FileManager.default + if + try applicationsFolderURLs.contains( + where: { applicationsFolderURL in + var relationship = FileManager.URLRelationship.other + try fileManager.getRelationship( + &relationship, + ofDirectoryAt: applicationsFolderURL, + toItemAt: appFolderURL, + ) + return relationship == .contains + }, + ) + { + let appFolderPath = appFolderURL.filePath + let installedApps = try await installedApps(withADAMID: snapshot.adamID).filter { $0.path != appFolderPath } + if !installedApps.isEmpty { + MAS.printer.warning( + "Multiple installations of ", + snapshot.name ?? "unknown app", + " exist in the applications folders\n\n", + action.performed.capitalizingFirstCharacter, + ":\n", + appFolderPath, + "\n\nOthers:\n", + installedApps.map(\.path).sorted(using: .localizedStandard).joined(separator: "\n"), + separator: "", + ) + } + } else { + MAS.printer.warning( + snapshot.appNameAndVersion, + "was", + action.performed, + "outside of the applications folders:", + appFolderURL.filePath, + ) + } + } + resumeOnce { $0.resume() } } catch { resumeOnce { $0.resume(throwing: error) } @@ -295,8 +337,8 @@ private actor DownloadQueueObserver: CKDownloadQueueObserver { return hardLinkURL } - private func install(appNameAndVersion: String) async throws -> String { - guard let pkgHardLinkPath = pkgHardLinkURL?.path(percentEncoded: false) else { + private func install(appNameAndVersion: String) async throws -> URL { + guard let pkgHardLinkPath = pkgHardLinkURL?.filePath else { throw MASError.error("Failed to find pkg to \(action) \(appNameAndVersion)") } guard let receiptHardLinkURL else { @@ -335,22 +377,19 @@ private actor DownloadQueueObserver: CKDownloadQueueObserver { do { let fileManager = FileManager.default try run(asEffectiveUID: 0, andEffectiveGID: 0) { - if fileManager.fileExists(atPath: receiptURL.path(percentEncoded: false)) { + if fileManager.fileExists(atPath: receiptURL.filePath) { try fileManager.removeItem(at: receiptURL) } else { try fileManager.createDirectory(at: receiptURL.deletingLastPathComponent(), withIntermediateDirectories: true) } try fileManager.copyItem(at: receiptHardLinkURL, to: receiptURL) - try fileManager.setAttributes( - [.ownerAccountID: 0, .groupOwnerAccountID: 0], - ofItemAtPath: receiptURL.path(percentEncoded: false), - ) + try fileManager.setAttributes([.ownerAccountID: 0, .groupOwnerAccountID: 0], ofItemAtPath: receiptURL.filePath) } } catch { throw MASError.error( """ - Failed to copy receipt for \(appNameAndVersion) from \(receiptHardLinkURL.path(percentEncoded: false).quoted)\ - to \(receiptURL.path(percentEncoded: false).quoted) + Failed to copy receipt for \(appNameAndVersion) from \(receiptHardLinkURL.filePath.quoted) to\ + \(receiptURL.filePath.quoted) """, error: error, ) @@ -358,19 +397,20 @@ private actor DownloadQueueObserver: CKDownloadQueueObserver { _ = try await run( "/usr/bin/mdimport", - appFolderURL.path(percentEncoded: false), + appFolderURL.filePath, errorMessage: "Failed to \(action) \(appNameAndVersion) from \(pkgHardLinkPath)", ) LSRegisterURL(appFolderURL as NSURL, true) // swiftlint:disable:this legacy_objc_type - return appFolderURL.path(percentEncoded: false) + return appFolderURL } } private struct DownloadSnapshot: Sendable { // swiftlint:disable:this one_declaration_per_file let adamID: ADAMID let version: String? + let name: String? let appNameAndVersion: String let activePhaseType: PhaseType let phasePercentComplete: Float @@ -385,6 +425,7 @@ private struct DownloadSnapshot: Sendable { // swiftlint:disable:this one_declar } adamID = metadata.itemIdentifier + name = metadata.title version = metadata.bundleVersion appNameAndVersion = "\(metadata.title ?? "unknown app") (\(version ?? "unknown version"))" activePhaseType = PhaseType(action, rawValue: status.activePhase?.phaseType) @@ -463,10 +504,10 @@ private extension URL { return false } guard let fileID1 = try resourceValues(forKeys: [.fileResourceIdentifierKey]).fileResourceIdentifier else { - throw MASError.error("Failed to get file resource identifier for \(path(percentEncoded: false))") + throw MASError.error("Failed to get file resource identifier for \(filePath)") } guard let fileID2 = try url.resourceValues(forKeys: [.fileResourceIdentifierKey]).fileResourceIdentifier else { - throw MASError.error("Failed to get file resource identifier for \(url.path(percentEncoded: false))") + throw MASError.error("Failed to get file resource identifier for \(url.filePath)") } return fileID1.isEqual(fileID2) @@ -478,12 +519,7 @@ private func deleteTempFolder(containing url: URL?, fileType: String) { do { try FileManager.default.removeItem(at: url.deletingLastPathComponent()) } catch { - MAS.printer.warning( - "Failed to delete temp folder containing", - fileType, - url.path(percentEncoded: false), - error: error, - ) + MAS.printer.warning("Failed to delete temp folder containing", fileType, url.filePath, error: error) } } } diff --git a/Sources/mas/Commands/MAS.swift b/Sources/mas/Commands/MAS.swift index e74336b3c..c346f60fb 100644 --- a/Sources/mas/Commands/MAS.swift +++ b/Sources/mas/Commands/MAS.swift @@ -6,7 +6,7 @@ // internal import ArgumentParser -private import Foundation +internal import Foundation @main struct MAS: AsyncParsableCommand, Sendable { @@ -120,3 +120,12 @@ extension ParsableCommand { private func cast(_ instance: Any, as _: T.Type) -> T? { instance as? T } + +private let applicationsFolderPath = "/Applications" +private let applicationsFolderURL = URL(filePath: applicationsFolderPath, directoryHint: .isDirectory) + +let applicationsFolderURLs = UserDefaults(suiteName: "com.apple.appstored")? +.dictionary(forKey: "PreferredVolume")?["name"] // swiftformat:disable indent +.map { [applicationsFolderURL, URL(filePath: "/Volumes/\($0)\(applicationsFolderPath)", directoryHint: .isDirectory)] } +?? [applicationsFolderURL] +// swiftformat:enable indent diff --git a/Sources/mas/Controllers/InstalledApp+Spotlight.swift b/Sources/mas/Controllers/InstalledApp+Spotlight.swift index 87a156dc4..5a529aa84 100644 --- a/Sources/mas/Controllers/InstalledApp+Spotlight.swift +++ b/Sources/mas/Controllers/InstalledApp+Spotlight.swift @@ -9,65 +9,68 @@ private import Atomics private import Foundation private import ObjectiveC -@MainActor var installedApps: [InstalledApp] { get async throws { - var observer = (any NSObjectProtocol)?.none - defer { - if let observer { - NotificationCenter.default.removeObserver(observer) - } - } + try await mas.installedApps(matching: "kMDItemAppStoreAdamID LIKE '*'") + } +} - let query = NSMetadataQuery() - query.predicate = NSPredicate(format: "kMDItemAppStoreAdamID LIKE '*'") - query.searchScopes = UserDefaults(suiteName: "com.apple.appstored")? - .dictionary(forKey: "PreferredVolume")?["name"] // swiftformat:disable indent - .map { [applicationsFolder, "/Volumes/\($0)\(applicationsFolder)"] } - ?? [applicationsFolder] +func installedApps(withADAMID adamID: ADAMID) async throws -> [InstalledApp] { + try await installedApps(matching: "kMDItemAppStoreAdamID = \(adamID)") +} - return try await withCheckedThrowingContinuation { continuation in // swiftformat:enable indent - let alreadyResumed = ManagedAtomic(false) - observer = NotificationCenter.default.addObserver( - forName: .NSMetadataQueryDidFinishGathering, - object: query, - queue: nil, - ) { notification in - guard !alreadyResumed.exchange(true, ordering: .acquiringAndReleasing) else { - return - } - guard let query = notification.object as? NSMetadataQuery else { - continuation.resume( - throwing: MASError.error( - "Notification Center returned a \(type(of: notification.object)) instead of a NSMetadataQuery", - ), - ) - return - } +@MainActor +func installedApps(matching metadataQuery: String) async throws -> [InstalledApp] { + var observer = (any NSObjectProtocol)?.none + defer { + if let observer { + NotificationCenter.default.removeObserver(observer) + } + } - query.stop() + let query = NSMetadataQuery() + query.predicate = NSPredicate(format: metadataQuery) + query.searchScopes = applicationsFolderURLs + return try await withCheckedThrowingContinuation { continuation in + let alreadyResumed = ManagedAtomic(false) + observer = NotificationCenter.default.addObserver( + forName: .NSMetadataQueryDidFinishGathering, + object: query, + queue: nil, + ) { notification in + guard !alreadyResumed.exchange(true, ordering: .acquiringAndReleasing) else { + return + } + guard let query = notification.object as? NSMetadataQuery else { continuation.resume( - returning: query.results - .compactMap { result in // swiftformat:disable indent - (result as? NSMetadataItem).map { item in - InstalledApp( - adamID: item.value(forAttribute: "kMDItemAppStoreAdamID") as? ADAMID ?? 0, - bundleID: item.value(forAttribute: NSMetadataItemCFBundleIdentifierKey) as? String ?? "", - name: (item.value(forAttribute: "_kMDItemDisplayNameWithExtensions") as? String ?? "") - .removingSuffix(".app"), - path: item.value(forAttribute: NSMetadataItemPathKey) as? String ?? "", - version: item.value(forAttribute: NSMetadataItemVersionKey) as? String ?? "", - ) - } - } - .sorted(using: KeyPathComparator(\.name, comparator: .localizedStandard)), // swiftformat:enable indent + throwing: MASError.error( + "Notification Center returned a \(type(of: notification.object)) instead of a NSMetadataQuery", + ), ) + return } - query.start() + query.stop() + + continuation.resume( + returning: query.results + .compactMap { result in // swiftformat:disable indent + (result as? NSMetadataItem).map { item in + InstalledApp( + adamID: item.value(forAttribute: "kMDItemAppStoreAdamID") as? ADAMID ?? 0, + bundleID: item.value(forAttribute: NSMetadataItemCFBundleIdentifierKey) as? String ?? "", + name: (item.value(forAttribute: "_kMDItemDisplayNameWithExtensions") as? String ?? "") + .removingSuffix(".app"), + path: item.value(forAttribute: NSMetadataItemPathKey) as? String ?? "", + version: item.value(forAttribute: NSMetadataItemVersionKey) as? String ?? "", + ) + } + } + .sorted(using: KeyPathComparator(\.name, comparator: .localizedStandard)), // swiftformat:enable indent + ) } + + query.start() } } - -private let applicationsFolder = "/Applications" diff --git a/Sources/mas/Models/InstalledApp.swift b/Sources/mas/Models/InstalledApp.swift index 73a1f99f4..17e654103 100644 --- a/Sources/mas/Models/InstalledApp.swift +++ b/Sources/mas/Models/InstalledApp.swift @@ -9,7 +9,7 @@ struct InstalledApp: Sendable { let adamID: ADAMID let bundleID: String let name: String - let path: String // periphery:ignore + let path: String let version: String var isTestFlight: Bool { diff --git a/Sources/mas/Network/URL.swift b/Sources/mas/Network/URL.swift index 0a0d6bac6..942f38869 100644 --- a/Sources/mas/Network/URL.swift +++ b/Sources/mas/Network/URL.swift @@ -10,6 +10,10 @@ private import Foundation private import ObjectiveC extension URL { + var filePath: String { + String(path(percentEncoded: false).dropLast { $0 == "/" }) + } + func open(configuration: NSWorkspace.OpenConfiguration = NSWorkspace.OpenConfiguration()) async throws { try await NSWorkspace.shared.open(self, configuration: configuration) } diff --git a/Sources/mas/Utilities/Sudo.swift b/Sources/mas/Utilities/Sudo.swift index 272749a55..a9056304c 100644 --- a/Sources/mas/Utilities/Sudo.swift +++ b/Sources/mas/Utilities/Sudo.swift @@ -10,7 +10,7 @@ private import Darwin private import Foundation func sudo(_ executableName: String, args: some Sequence) throws { - guard let executablePath = Bundle.main.executableURL?.path(percentEncoded: false) else { + guard let executablePath = Bundle.main.executableURL?.filePath else { throw MASError.error("Failed to get the executable path for sudo \(executableName) \(args.joined(separator: " "))") }