Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 == <adam-id>'
mdfind 'kMDItemAppStoreAdamID = <adam-id>'
```

If any App Store apps are not properly indexed, you can reindex:
Expand Down
84 changes: 60 additions & 24 deletions Sources/mas/AppStore/AppStoreAction+download.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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) }
Expand All @@ -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 {
Expand Down Expand Up @@ -335,42 +377,40 @@ 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,
)
}

_ = 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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
}
}
Expand Down
11 changes: 10 additions & 1 deletion Sources/mas/Commands/MAS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
//

internal import ArgumentParser
private import Foundation
internal import Foundation

@main
struct MAS: AsyncParsableCommand, Sendable {
Expand Down Expand Up @@ -120,3 +120,12 @@ extension ParsableCommand {
private func cast<T>(_ 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
101 changes: 52 additions & 49 deletions Sources/mas/Controllers/InstalledApp+Spotlight.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 1 addition & 1 deletion Sources/mas/Models/InstalledApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions Sources/mas/Network/URL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/mas/Utilities/Sudo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ private import Darwin
private import Foundation

func sudo(_ executableName: String, args: some Sequence<String>) 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: " "))")
}

Expand Down
Loading