From f9030b62d6a9250379a2f8bcd070d017606ca3ca Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Wed, 7 May 2025 23:29:29 -0400 Subject: [PATCH 01/12] Fix capitalization in `MASError.runtimeError.description`. Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/Errors/MASError.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/mas/Errors/MASError.swift b/Sources/mas/Errors/MASError.swift index 9a53b2e92..4628f8a7e 100644 --- a/Sources/mas/Errors/MASError.swift +++ b/Sources/mas/Errors/MASError.swift @@ -68,7 +68,7 @@ extension MASError: CustomStringConvertible { case let .purchaseFailed(error): "Download request failed: \(error.localizedDescription)" case let .runtimeError(message): - "Runtime Error: \(message)" + "Runtime error: \(message)" case let .searchFailed(error): "Search failed: \(error.localizedDescription)" case let .unknownAppID(appID): From 762c5ac1dd57370206335a68c5f4d2e834a72c12 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Thu, 8 May 2025 06:57:30 -0400 Subject: [PATCH 02/12] Delete period from `MASError.notSupported.description`. Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/Errors/MASError.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/mas/Errors/MASError.swift b/Sources/mas/Errors/MASError.swift index 4628f8a7e..f47fd176c 100644 --- a/Sources/mas/Errors/MASError.swift +++ b/Sources/mas/Errors/MASError.swift @@ -62,7 +62,7 @@ extension MASError: CustomStringConvertible { "No apps installed with app ID \(appIDs.map { String($0) }.joined(separator: ", "))" case .notSupported: """ - This command is not supported on this macOS version due to changes in macOS. + This command is not supported on this macOS version due to changes in macOS See: https://github.com/mas-cli/mas#known-issues """ case let .purchaseFailed(error): From 01d4783e6c038b85146fcfb500378f931b9bc3f4 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Thu, 8 May 2025 05:20:09 -0400 Subject: [PATCH 03/12] When uninstalling, warn for each app ID that isn't installed instead of erroring only if none of the app IDs are installed. Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- .swiftlint.yml | 1 + Sources/mas/Commands/Uninstall.swift | 17 ++++++++++++----- Sources/mas/Errors/MASError.swift | 3 --- Sources/mas/Models/InstalledApp.swift | 2 +- Tests/masTests/Commands/UninstallSpec.swift | 4 ++-- 5 files changed, 16 insertions(+), 11 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index 62a791f3f..55f4789ec 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -25,6 +25,7 @@ disabled_rules: - prefixed_toplevel_constant - sorted_enum_cases - vertical_whitespace_between_cases +- void_function_in_ternary attributes: always_on_line_above: ['@MainActor', '@OptionGroup'] closure_body_length: diff --git a/Sources/mas/Commands/Uninstall.swift b/Sources/mas/Commands/Uninstall.swift index 679cc9ad9..2720dd0f2 100644 --- a/Sources/mas/Commands/Uninstall.swift +++ b/Sources/mas/Commands/Uninstall.swift @@ -43,13 +43,20 @@ extension MAS { throw MASError.runtimeError("Failed to switch effective user from 'root' to '\(username)'") } - let installedApps = installedApps.filter { appIDsOptionGroup.appIDs.contains($0.id) } - guard !installedApps.isEmpty else { - throw MASError.notInstalled(appIDs: appIDsOptionGroup.appIDs) + var uninstallingAppSet = Set() + for appID in appIDsOptionGroup.appIDs { + let foundApps = installedApps.filter { $0.id == appID } + foundApps.isEmpty // swiftformat:disable:next indent + ? printWarning("No installed apps with app ID", appID) + : uninstallingAppSet.formUnion(foundApps) + } + + guard !uninstallingAppSet.isEmpty else { + return } if dryRun { - for installedApp in installedApps { + for installedApp in uninstallingAppSet { printNotice("'", installedApp.name, "' '", installedApp.path, "'", separator: "") } printNotice("(not removed, dry run)") @@ -58,7 +65,7 @@ extension MAS { throw MASError.runtimeError("Failed to revert effective user from '\(username)' back to 'root'") } - try uninstallApps(atPaths: installedApps.map(\.path)) + try uninstallApps(atPaths: uninstallingAppSet.map(\.path)) } } } diff --git a/Sources/mas/Errors/MASError.swift b/Sources/mas/Errors/MASError.swift index f47fd176c..d197fe906 100644 --- a/Sources/mas/Errors/MASError.swift +++ b/Sources/mas/Errors/MASError.swift @@ -16,7 +16,6 @@ enum MASError: Error, Equatable { case noDownloads case noSearchResultsFound case noVendorWebsite - case notInstalled(appIDs: [AppID]) case notSupported case purchaseFailed(error: NSError) case runtimeError(String) @@ -58,8 +57,6 @@ extension MASError: CustomStringConvertible { "No apps found" case .noVendorWebsite: "App does not have a vendor website" - case let .notInstalled(appIDs): - "No apps installed with app ID \(appIDs.map { String($0) }.joined(separator: ", "))" case .notSupported: """ This command is not supported on this macOS version due to changes in macOS diff --git a/Sources/mas/Models/InstalledApp.swift b/Sources/mas/Models/InstalledApp.swift index 9fe301b4e..5763c4e77 100644 --- a/Sources/mas/Models/InstalledApp.swift +++ b/Sources/mas/Models/InstalledApp.swift @@ -9,7 +9,7 @@ private import Foundation private import Version -struct InstalledApp: Sendable { +struct InstalledApp: Hashable, Sendable { let id: AppID let name: String // periphery:ignore diff --git a/Tests/masTests/Commands/UninstallSpec.swift b/Tests/masTests/Commands/UninstallSpec.swift index 656a33dc1..569a3a8f2 100644 --- a/Tests/masTests/Commands/UninstallSpec.swift +++ b/Tests/masTests/Commands/UninstallSpec.swift @@ -30,7 +30,7 @@ final class UninstallSpec: QuickSpec { try MAS.Uninstall.parse(["--dry-run", String(appID)]).run(installedApps: []) ) ) - == UnvaluedConsequences(MASError.notInstalled(appIDs: [appID])) + == UnvaluedConsequences(nil, "No installed apps with app ID \(appID)") } it("finds an app") { expect( @@ -48,7 +48,7 @@ final class UninstallSpec: QuickSpec { try MAS.Uninstall.parse([String(appID)]).run(installedApps: []) ) ) - == UnvaluedConsequences(MASError.notInstalled(appIDs: [appID])) + == UnvaluedConsequences(nil, "No installed apps with app ID \(appID)") } it("removes an app") { expect( From 7b89014d43f6fb81e64058c7930efffc959ad21e Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Thu, 8 May 2025 07:03:16 -0400 Subject: [PATCH 04/12] Improve "No installed apps with app ID" warnings. Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/Commands/Uninstall.swift | 2 +- Sources/mas/Commands/Upgrade.swift | 2 +- Sources/mas/Models/AppID.swift | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/mas/Commands/Uninstall.swift b/Sources/mas/Commands/Uninstall.swift index 2720dd0f2..984f10656 100644 --- a/Sources/mas/Commands/Uninstall.swift +++ b/Sources/mas/Commands/Uninstall.swift @@ -47,7 +47,7 @@ extension MAS { for appID in appIDsOptionGroup.appIDs { let foundApps = installedApps.filter { $0.id == appID } foundApps.isEmpty // swiftformat:disable:next indent - ? printWarning("No installed apps with app ID", appID) + ? printError(appID.notInstalledMessage) : uninstallingAppSet.formUnion(foundApps) } diff --git a/Sources/mas/Commands/Upgrade.swift b/Sources/mas/Commands/Upgrade.swift index 15e5e7ff9..50832f11c 100644 --- a/Sources/mas/Commands/Upgrade.swift +++ b/Sources/mas/Commands/Upgrade.swift @@ -62,7 +62,7 @@ extension MAS { // Find installed apps by app ID argument let installedApps = installedApps.filter { $0.id == appID } if installedApps.isEmpty { - printError(appID.unknownMessage) + printError(appID.notInstalledMessage) } return installedApps } diff --git a/Sources/mas/Models/AppID.swift b/Sources/mas/Models/AppID.swift index 3093aae79..88cd018e9 100644 --- a/Sources/mas/Models/AppID.swift +++ b/Sources/mas/Models/AppID.swift @@ -9,7 +9,7 @@ typealias AppID = UInt64 extension AppID { - var unknownMessage: String { - "Unknown app ID \(self)" + var notInstalledMessage: String { + "No installed apps with app ID \(self)" } } From 566d647f15a1131195a9035863b3c3531c2d9e6b Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Thu, 8 May 2025 08:37:03 -0400 Subject: [PATCH 05/12] Improve `String`s. Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/Commands/Reset.swift | 2 +- Sources/mas/Commands/Uninstall.swift | 2 +- Sources/mas/Commands/Upgrade.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/mas/Commands/Reset.swift b/Sources/mas/Commands/Reset.swift index 887a46e2c..733c199b2 100644 --- a/Sources/mas/Commands/Reset.swift +++ b/Sources/mas/Commands/Reset.swift @@ -74,7 +74,7 @@ extension MAS { try FileManager.default.removeItem(atPath: directory) } catch { if debug { - printError("removeItemAtPath:\"", directory, "\" failed, ", error, separator: "") + printError("Failed to delete download directory ", directory, "\n", error, separator: "") } } } diff --git a/Sources/mas/Commands/Uninstall.swift b/Sources/mas/Commands/Uninstall.swift index 984f10656..0540f8db8 100644 --- a/Sources/mas/Commands/Uninstall.swift +++ b/Sources/mas/Commands/Uninstall.swift @@ -145,7 +145,7 @@ private func delete(pathsFromOwnerIDsByPath ownerIDsByPath: [String: (uid_t, gid throw MASError.runtimeError( """ Failed to obtain Finder access: finder.items().object(atLocation: URL(fileURLWithPath:\ - \"\(path)\") is a '\(type(of: object))' that does not conform to 'FinderItem' + \"\(path)\") is a \(type(of: object)) that does not conform to FinderItem """ ) } diff --git a/Sources/mas/Commands/Upgrade.swift b/Sources/mas/Commands/Upgrade.swift index 50832f11c..33a350618 100644 --- a/Sources/mas/Commands/Upgrade.swift +++ b/Sources/mas/Commands/Upgrade.swift @@ -17,7 +17,7 @@ extension MAS { @OptionGroup var verboseOptionGroup: VerboseOptionGroup - @Argument(help: ArgumentHelp("App ID/app name", valueName: "app-id-or-name")) + @Argument(help: ArgumentHelp("App ID/name", valueName: "app-id-or-name")) var appIDOrNames = [String]() /// Runs the command. From ecd959477f430ffa20a9a00ce6e11a45a8de67d0 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Wed, 7 May 2025 23:22:36 -0400 Subject: [PATCH 06/12] Improve progress output in `PurchaseDownloadObserver.swift`. Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- .../AppStore/PurchaseDownloadObserver.swift | 82 +++++++++++-------- 1 file changed, 47 insertions(+), 35 deletions(-) diff --git a/Sources/mas/AppStore/PurchaseDownloadObserver.swift b/Sources/mas/AppStore/PurchaseDownloadObserver.swift index 30364ce23..59a315f2d 100644 --- a/Sources/mas/AppStore/PurchaseDownloadObserver.swift +++ b/Sources/mas/AppStore/PurchaseDownloadObserver.swift @@ -38,29 +38,7 @@ class PurchaseDownloadObserver: CKDownloadQueueObserver { if status.isFailed || status.isCancelled { queue.removeDownload(withItemIdentifier: download.metadata.itemIdentifier) } else { - let currPhaseType = status.activePhase.phaseType - let prevPhaseType = prevPhaseType - if prevPhaseType != currPhaseType { - switch currPhaseType { - case downloadingPhaseType: - if prevPhaseType == initialPhaseType { - terminateEphemeralPrinting() - printNotice("Downloading", download.progressDescription) - } - case downloadedPhaseType: - if prevPhaseType == downloadingPhaseType { - terminateEphemeralPrinting() - printNotice("Downloaded", download.progressDescription) - } - case installingPhaseType: - terminateEphemeralPrinting() - printNotice("Installing", download.progressDescription) - default: - break - } - self.prevPhaseType = currPhaseType - } - progress(status.progressState) + prevPhaseType = download.printProgress(prevPhaseType: prevPhaseType) } } @@ -98,22 +76,54 @@ private struct ProgressState { } } -private func progress(_ state: ProgressState) { - // Don't display the progress bar if we're not on a terminal - guard isatty(fileno(stdout)) != 0 else { - return - } - - let barLength = 60 - let completeLength = Int(state.percentComplete * Float(barLength)) - let bar = (0.. Int64 { + let currPhaseType = status.activePhase.phaseType + if prevPhaseType != currPhaseType { + switch currPhaseType { + case downloadingPhaseType: + if prevPhaseType == initialPhaseType { + printProgressHeader() + } + case downloadedPhaseType: + if prevPhaseType == downloadingPhaseType { + printProgressHeader() + } + case installingPhaseType: + printProgressHeader() + default: + break + } + } + + if isatty(fileno(stdout)) != 0 { + // Only display the progress bar if connected to a terminal + let progressState = status.progressState + let totalLength = 60 + let completedLength = Int(progressState.percentComplete * Float(totalLength)) + printEphemeral( + String(repeating: "#", count: completedLength), + String(repeating: "-", count: totalLength - completedLength), + " ", + progressState.percentage, + " ", + progressState.phase, + separator: "", + terminator: "" + ) + } + + return currPhaseType + } + + private func printProgressHeader() { + terminateEphemeralPrinting() + printNotice(status.activePhase.phaseDescription, progressDescription) + } } private extension SSDownloadStatus { @@ -125,6 +135,8 @@ private extension SSDownloadStatus { private extension SSDownloadPhase { var phaseDescription: String { switch phaseType { + case downloadedPhaseType: + "Downloaded" case downloadingPhaseType: "Downloading" case installingPhaseType: From adcebc9ffd1ecf43028f01154ce301255d90d291 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Thu, 8 May 2025 10:09:49 -0400 Subject: [PATCH 07/12] Substitute `output` in place of `display`. Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- README.md | 6 +++--- .../mas/AppStore/PurchaseDownloadObserver.swift | 2 +- Sources/mas/Commands/Account.swift | 4 ++-- Sources/mas/Commands/Config.swift | 4 ++-- Sources/mas/Commands/Info.swift | 4 ++-- .../OptionGroups/VerboseOptionGroup.swift | 2 +- Sources/mas/Commands/Outdated.swift | 2 +- Sources/mas/Commands/Region.swift | 4 ++-- Sources/mas/Commands/Search.swift | 2 +- Sources/mas/Commands/Version.swift | 4 ++-- Sources/mas/Formatters/AppInfoFormatter.swift | 2 +- .../mas/Formatters/SearchResultFormatter.swift | 2 +- Sources/mas/Models/SearchResult.swift | 2 +- Tests/masTests/Commands/AccountSpec.swift | 2 +- Tests/masTests/Commands/InfoSpec.swift | 2 +- Tests/masTests/Commands/OutdatedSpec.swift | 2 +- Tests/masTests/Commands/VersionSpec.swift | 2 +- contrib/completion/mas.fish | 16 ++++++++-------- script/version | 2 +- 19 files changed, 33 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 93cf4d3eb..21487f8a1 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ $ mas search Xcode #### `mas info` -`mas info ` displays more detailed information about an application available from the Mac App Store. +`mas info ` outputs more detailed information about an application available from the Mac App Store. ```console $ mas info 497799835 @@ -118,7 +118,7 @@ All the commands in this section require you to be logged into an Apple Account #### `mas list` -`mas list` displays all the applications on your Mac that were installed from the Mac App Store. +`mas list` outputs all the applications on your Mac that were installed from the Mac App Store. ```console $ mas list @@ -129,7 +129,7 @@ $ mas list #### `mas outdated` -`mas outdated` displays all applications installed from the Mac App Store on your Mac that have pending upgrades. +`mas outdated` outputs all applications installed from the Mac App Store on your Mac that have pending upgrades. ```console $ mas outdated diff --git a/Sources/mas/AppStore/PurchaseDownloadObserver.swift b/Sources/mas/AppStore/PurchaseDownloadObserver.swift index 59a315f2d..8bc37a895 100644 --- a/Sources/mas/AppStore/PurchaseDownloadObserver.swift +++ b/Sources/mas/AppStore/PurchaseDownloadObserver.swift @@ -101,7 +101,7 @@ private extension SSDownload { } if isatty(fileno(stdout)) != 0 { - // Only display the progress bar if connected to a terminal + // Only output the progress bar if connected to a terminal let progressState = status.progressState let totalLength = 60 let completedLength = Int(progressState.percentComplete * Float(totalLength)) diff --git a/Sources/mas/Commands/Account.swift b/Sources/mas/Commands/Account.swift index 3b2a95c15..2b8a94f64 100644 --- a/Sources/mas/Commands/Account.swift +++ b/Sources/mas/Commands/Account.swift @@ -9,10 +9,10 @@ internal import ArgumentParser extension MAS { - /// Displays the Apple Account signed in to the Mac App Store. + /// Outputs the Apple Account signed in to the Mac App Store. struct Account: AsyncParsableCommand { static let configuration = CommandConfiguration( - abstract: "Display the Apple Account signed in to the Mac App Store" + abstract: "Output the Apple Account signed in to the Mac App Store" ) /// Runs the command. diff --git a/Sources/mas/Commands/Config.swift b/Sources/mas/Commands/Config.swift index e46bbb180..7a54b4c9a 100644 --- a/Sources/mas/Commands/Config.swift +++ b/Sources/mas/Commands/Config.swift @@ -13,10 +13,10 @@ private var unknown: String { "unknown" } private var sysCtlByName: String { "sysctlbyname" } extension MAS { - /// Displays mas config & related system info. + /// Outputs mas config & related system info. struct Config: AsyncParsableCommand { static let configuration = CommandConfiguration( - abstract: "Display mas config & related system info" + abstract: "Output mas config & related system info" ) @Flag(help: "Output as Markdown") diff --git a/Sources/mas/Commands/Info.swift b/Sources/mas/Commands/Info.swift index 82842a5f8..e23b1cc6a 100644 --- a/Sources/mas/Commands/Info.swift +++ b/Sources/mas/Commands/Info.swift @@ -9,14 +9,14 @@ internal import ArgumentParser extension MAS { - /// Displays app information from the Mac App Store. + /// Outputs app information from the Mac App Store. /// /// Uses the iTunes Lookup API: /// /// https://performance-partners.apple.com/search-api struct Info: AsyncParsableCommand { static let configuration = CommandConfiguration( - abstract: "Display app information from the Mac App Store" + abstract: "Output app information from the Mac App Store" ) @OptionGroup diff --git a/Sources/mas/Commands/OptionGroups/VerboseOptionGroup.swift b/Sources/mas/Commands/OptionGroups/VerboseOptionGroup.swift index b4bb34535..f0ba67b42 100644 --- a/Sources/mas/Commands/OptionGroups/VerboseOptionGroup.swift +++ b/Sources/mas/Commands/OptionGroups/VerboseOptionGroup.swift @@ -9,7 +9,7 @@ internal import ArgumentParser struct VerboseOptionGroup: ParsableArguments { - @Flag(help: "Display warnings about app IDs unknown to the Mac App Store") + @Flag(help: "Output warnings about app IDs unknown to the Mac App Store") var verbose = false func printProblem(forError error: Error, expectedAppName appName: String) { diff --git a/Sources/mas/Commands/Outdated.swift b/Sources/mas/Commands/Outdated.swift index 3f71fa1b2..917692331 100644 --- a/Sources/mas/Commands/Outdated.swift +++ b/Sources/mas/Commands/Outdated.swift @@ -9,7 +9,7 @@ internal import ArgumentParser extension MAS { - /// Displays a list of installed apps which have updates available to be + /// Outputs a list of installed apps which have updates available to be /// installed from the Mac App Store. struct Outdated: AsyncParsableCommand { static let configuration = CommandConfiguration( diff --git a/Sources/mas/Commands/Region.swift b/Sources/mas/Commands/Region.swift index 24d0ebc67..3d6c46ef0 100644 --- a/Sources/mas/Commands/Region.swift +++ b/Sources/mas/Commands/Region.swift @@ -9,10 +9,10 @@ internal import ArgumentParser extension MAS { - /// Displays the region of the Mac App Store. + /// Outputs the region of the Mac App Store. struct Region: AsyncParsableCommand { static let configuration = CommandConfiguration( - abstract: "Display the region of the Mac App Store" + abstract: "Output the region of the Mac App Store" ) /// Runs the command. diff --git a/Sources/mas/Commands/Search.swift b/Sources/mas/Commands/Search.swift index 2b64d8e5c..a614c7b76 100644 --- a/Sources/mas/Commands/Search.swift +++ b/Sources/mas/Commands/Search.swift @@ -19,7 +19,7 @@ extension MAS { abstract: "Search for apps in the Mac App Store" ) - @Flag(help: "Display the price of each app") + @Flag(help: "Output the price of each app") var price = false @OptionGroup var searchTermOptionGroup: SearchTermOptionGroup diff --git a/Sources/mas/Commands/Version.swift b/Sources/mas/Commands/Version.swift index bfeadc6c3..08755a4fe 100644 --- a/Sources/mas/Commands/Version.swift +++ b/Sources/mas/Commands/Version.swift @@ -9,10 +9,10 @@ internal import ArgumentParser extension MAS { - /// Displays the version of the mas tool. + /// Outputs the version of the mas tool. struct Version: ParsableCommand { static let configuration = CommandConfiguration( - abstract: "Display version number" + abstract: "Output version number" ) /// Runs the command. diff --git a/Sources/mas/Formatters/AppInfoFormatter.swift b/Sources/mas/Formatters/AppInfoFormatter.swift index 8c3e02960..12671d5c4 100644 --- a/Sources/mas/Formatters/AppInfoFormatter.swift +++ b/Sources/mas/Formatters/AppInfoFormatter.swift @@ -16,7 +16,7 @@ enum AppInfoFormatter { /// - Returns: Multiline text output. static func format(app: SearchResult) -> String { """ - \(app.trackName) \(app.version) [\(app.displayPrice)] + \(app.trackName) \(app.version) [\(app.outputPrice)] By: \(app.sellerName) Released: \(humanReadableDate(app.currentVersionReleaseDate)) Minimum OS: \(app.minimumOsVersion) diff --git a/Sources/mas/Formatters/SearchResultFormatter.swift b/Sources/mas/Formatters/SearchResultFormatter.swift index a9f02eff9..22f5d7b83 100644 --- a/Sources/mas/Formatters/SearchResultFormatter.swift +++ b/Sources/mas/Formatters/SearchResultFormatter.swift @@ -26,7 +26,7 @@ enum SearchResultFormatter { result.trackId, result.trackName.padding(toLength: maxAppNameLength, withPad: " ", startingAt: 0), result.version, - result.displayPrice + result.outputPrice ) } .joined(separator: "\n") diff --git a/Sources/mas/Models/SearchResult.swift b/Sources/mas/Models/SearchResult.swift index 718832da1..3e2e8477c 100644 --- a/Sources/mas/Models/SearchResult.swift +++ b/Sources/mas/Models/SearchResult.swift @@ -20,7 +20,7 @@ struct SearchResult: Decodable { } extension SearchResult { - var displayPrice: String { + var outputPrice: String { formattedPrice ?? "?" } } diff --git a/Tests/masTests/Commands/AccountSpec.swift b/Tests/masTests/Commands/AccountSpec.swift index a2990353d..eae8eaaf8 100644 --- a/Tests/masTests/Commands/AccountSpec.swift +++ b/Tests/masTests/Commands/AccountSpec.swift @@ -14,7 +14,7 @@ import Quick final class AccountSpec: AsyncSpec { override static func spec() { describe("account command") { - it("displays not supported warning") { + it("outputs not supported warning") { await expecta(await consequencesOf(try await MAS.Account.parse([]).run())) == UnvaluedConsequences(MASError.notSupported) } diff --git a/Tests/masTests/Commands/InfoSpec.swift b/Tests/masTests/Commands/InfoSpec.swift index 6285d69a1..1dd7cb26a 100644 --- a/Tests/masTests/Commands/InfoSpec.swift +++ b/Tests/masTests/Commands/InfoSpec.swift @@ -20,7 +20,7 @@ final class InfoSpec: AsyncSpec { ) == UnvaluedConsequences(MASError.unknownAppID(999)) } - it("displays app details") { + it("outputs app details") { let mockResult = SearchResult( currentVersionReleaseDate: "2019-01-07T18:53:13Z", fileSizeBytes: "1024", diff --git a/Tests/masTests/Commands/OutdatedSpec.swift b/Tests/masTests/Commands/OutdatedSpec.swift index dcaeef704..1907b059e 100644 --- a/Tests/masTests/Commands/OutdatedSpec.swift +++ b/Tests/masTests/Commands/OutdatedSpec.swift @@ -14,7 +14,7 @@ import Quick final class OutdatedSpec: AsyncSpec { override static func spec() { describe("outdated command") { - it("displays apps with pending updates") { + it("outputs apps with pending updates") { let mockSearchResult = SearchResult( currentVersionReleaseDate: "2024-09-02T00:27:00Z", diff --git a/Tests/masTests/Commands/VersionSpec.swift b/Tests/masTests/Commands/VersionSpec.swift index 49999a33e..ea035337a 100644 --- a/Tests/masTests/Commands/VersionSpec.swift +++ b/Tests/masTests/Commands/VersionSpec.swift @@ -14,7 +14,7 @@ import Quick final class VersionSpec: QuickSpec { override static func spec() { describe("version command") { - it("displays the current version") { + it("outputs the current version") { expect(consequencesOf(try MAS.Version.parse([]).run())) == UnvaluedConsequences(nil, "\(Package.version)\n") } } diff --git a/contrib/completion/mas.fish b/contrib/completion/mas.fish index 1f7ad659a..a464a100d 100644 --- a/contrib/completion/mas.fish +++ b/contrib/completion/mas.fish @@ -21,21 +21,21 @@ end complete -c mas -f ### account -complete -c mas -n "__fish_use_subcommand" -f -a account -d "Display the Apple Account signed in to the Mac App Store" +complete -c mas -n "__fish_use_subcommand" -f -a account -d "Output the Apple Account signed in to the Mac App Store" complete -c mas -n "__fish_seen_subcommand_from help" -xa "account" ### config -complete -c mas -n "__fish_use_subcommand" -f -a config -d "Display mas config & related system info" +complete -c mas -n "__fish_use_subcommand" -f -a config -d "Output mas config & related system info" complete -c mas -n "__fish_seen_subcommand_from help" -xa "config" complete -c mas -n "__fish_seen_subcommand_from config" -l markdown -d "Output as Markdown" ### help -complete -c mas -n "__fish_use_subcommand" -f -a help -d "Display general or command-specific help" +complete -c mas -n "__fish_use_subcommand" -f -a help -d "Output general or command-specific help" complete -c mas -n "__fish_seen_subcommand_from help" -xa "help" ### home complete -c mas -n "__fish_use_subcommand" -f -a home -d "Open Mac App Store app pages in the default web browser" complete -c mas -n "__fish_seen_subcommand_from help" -xa "home" complete -c mas -n "__fish_seen_subcommand_from home info install open purchase vendor" -xa "(__fish_mas_list_available)" ### info -complete -c mas -n "__fish_use_subcommand" -f -a info -d "Display app information from the Mac App Store" +complete -c mas -n "__fish_use_subcommand" -f -a info -d "Output app information from the Mac App Store" complete -c mas -n "__fish_seen_subcommand_from help" -xa "info" ### install complete -c mas -n "__fish_use_subcommand" -f -a install -d "Install previously purchased apps from the Mac App Store" @@ -53,12 +53,12 @@ complete -c mas -n "__fish_seen_subcommand_from help" -xa "open" ### outdated complete -c mas -n "__fish_use_subcommand" -f -a outdated -d "List pending app updates from the Mac App Store" complete -c mas -n "__fish_seen_subcommand_from help" -xa "outdated" -complete -c mas -n "__fish_seen_subcommand_from outdated" -l verbose -d "Display warnings about app IDs unknown to the Mac App Store" +complete -c mas -n "__fish_seen_subcommand_from outdated" -l verbose -d "Output warnings about app IDs unknown to the Mac App Store" ### purchase complete -c mas -n "__fish_use_subcommand" -f -a purchase -d "\"Purchase\" & install free apps from the Mac App Store" complete -c mas -n "__fish_seen_subcommand_from help" -xa "purchase" ### region -complete -c mas -n "__fish_use_subcommand" -f -a region -d "Display the region of the Mac App Store" +complete -c mas -n "__fish_use_subcommand" -f -a region -d "Output the region of the Mac App Store" complete -c mas -n "__fish_seen_subcommand_from help" -xa "region" ### reset complete -c mas -n "__fish_use_subcommand" -f -a reset -d "Reset Mac App Store running processes" @@ -67,7 +67,7 @@ complete -c mas -n "__fish_seen_subcommand_from reset" -l debug -d "Output debug ### search complete -c mas -n "__fish_use_subcommand" -f -a search -d "Search for apps in the Mac App Store" complete -c mas -n "__fish_seen_subcommand_from help" -xa "search" -complete -c mas -n "__fish_seen_subcommand_from search" -l price -d "Display the price of each app" +complete -c mas -n "__fish_seen_subcommand_from search" -l price -d "Output the price of each app" ### signin complete -c mas -n "__fish_use_subcommand" -f -a signin -d "Sign in to an Apple Account in the Mac App Store" complete -c mas -n "__fish_seen_subcommand_from help" -xa "signin" @@ -88,5 +88,5 @@ complete -c mas -n "__fish_seen_subcommand_from upgrade" -x -a "(__fish_mas_outd complete -c mas -n "__fish_use_subcommand" -f -a vendor -d "Open apps' vendor pages in the default web browser" complete -c mas -n "__fish_seen_subcommand_from help" -xa "vendor" ### version -complete -c mas -n "__fish_use_subcommand" -f -a version -d "Display version number" +complete -c mas -n "__fish_use_subcommand" -f -a version -d "Output version number" complete -c mas -n "__fish_seen_subcommand_from help" -xa "version" diff --git a/script/version b/script/version index b9c76aa44..9d8b2eb4a 100755 --- a/script/version +++ b/script/version @@ -3,7 +3,7 @@ # script/version # mas # -# Displays the mas version. +# Outputs the mas version. # . "${0:a:h}/_setup_script" From ced626a27b5811389a66c2c31f84b8278074b4db Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Thu, 8 May 2025 13:41:07 -0400 Subject: [PATCH 08/12] Reorder `InstalledApp` properties. Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/Controllers/SpotlightInstalledApps.swift | 2 +- Sources/mas/Models/InstalledApp.swift | 2 +- Tests/masTests/Commands/OutdatedSpec.swift | 2 +- Tests/masTests/Commands/UninstallSpec.swift | 2 +- Tests/masTests/Formatters/AppListFormatterSpec.swift | 6 +++--- Tests/masTests/Models/InstalledAppSpec.swift | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Sources/mas/Controllers/SpotlightInstalledApps.swift b/Sources/mas/Controllers/SpotlightInstalledApps.swift index fa6f1168b..ea97eca45 100644 --- a/Sources/mas/Controllers/SpotlightInstalledApps.swift +++ b/Sources/mas/Controllers/SpotlightInstalledApps.swift @@ -46,10 +46,10 @@ var installedApps: [InstalledApp] { if let item = result as? NSMetadataItem { InstalledApp( id: item.value(forAttribute: "kMDItemAppStoreAdamID") as? AppID ?? 0, + bundleID: item.value(forAttribute: NSMetadataItemCFBundleIdentifierKey) as? String ?? "", name: (item.value(forAttribute: "_kMDItemDisplayNameWithExtensions") as? String ?? "").removingSuffix( ".app" ), - bundleID: item.value(forAttribute: NSMetadataItemCFBundleIdentifierKey) as? String ?? "", path: item.value(forAttribute: NSMetadataItemPathKey) as? String ?? "", version: item.value(forAttribute: NSMetadataItemVersionKey) as? String ?? "" ) diff --git a/Sources/mas/Models/InstalledApp.swift b/Sources/mas/Models/InstalledApp.swift index 5763c4e77..c4c9a4ee0 100644 --- a/Sources/mas/Models/InstalledApp.swift +++ b/Sources/mas/Models/InstalledApp.swift @@ -11,9 +11,9 @@ private import Version struct InstalledApp: Hashable, Sendable { let id: AppID - let name: String // periphery:ignore let bundleID: String + let name: String let path: String let version: String } diff --git a/Tests/masTests/Commands/OutdatedSpec.swift b/Tests/masTests/Commands/OutdatedSpec.swift index 1907b059e..b6891846a 100644 --- a/Tests/masTests/Commands/OutdatedSpec.swift +++ b/Tests/masTests/Commands/OutdatedSpec.swift @@ -34,8 +34,8 @@ final class OutdatedSpec: AsyncSpec { installedApps: [ InstalledApp( id: mockSearchResult.trackId, - name: mockSearchResult.trackName, bundleID: "au.id.haroldchu.mac.Bandwidth", + name: mockSearchResult.trackName, path: "/Applications/Bandwidth+.app", version: "1.27" ), diff --git a/Tests/masTests/Commands/UninstallSpec.swift b/Tests/masTests/Commands/UninstallSpec.swift index 569a3a8f2..7d623a0ba 100644 --- a/Tests/masTests/Commands/UninstallSpec.swift +++ b/Tests/masTests/Commands/UninstallSpec.swift @@ -16,8 +16,8 @@ final class UninstallSpec: QuickSpec { let appID = 12345 as AppID let app = InstalledApp( id: appID, - name: "Some App", bundleID: "com.some.app", + name: "Some App", path: "/tmp/Some.app", version: "1.0" ) diff --git a/Tests/masTests/Formatters/AppListFormatterSpec.swift b/Tests/masTests/Formatters/AppListFormatterSpec.swift index c846328f7..9f9bd96f1 100644 --- a/Tests/masTests/Formatters/AppListFormatterSpec.swift +++ b/Tests/masTests/Formatters/AppListFormatterSpec.swift @@ -23,8 +23,8 @@ final class AppListFormatterSpec: QuickSpec { it("can format a single installed app") { let installedApp = InstalledApp( id: 12345, - name: "Awesome App", bundleID: "", + name: "Awesome App", path: "", version: "19.2.1" ) @@ -37,15 +37,15 @@ final class AppListFormatterSpec: QuickSpec { [ InstalledApp( id: 12345, - name: "Awesome App", bundleID: "", + name: "Awesome App", path: "", version: "19.2.1" ), InstalledApp( id: 67890, - name: "Even Better App", bundleID: "", + name: "Even Better App", path: "", version: "1.2.0" ), diff --git a/Tests/masTests/Models/InstalledAppSpec.swift b/Tests/masTests/Models/InstalledAppSpec.swift index 79d2a039c..839b9f56d 100644 --- a/Tests/masTests/Models/InstalledAppSpec.swift +++ b/Tests/masTests/Models/InstalledAppSpec.swift @@ -15,8 +15,8 @@ final class InstalledAppSpec: QuickSpec { override static func spec() { let app = InstalledApp( id: 111, - name: "App", bundleID: "", + name: "App", path: "", version: "1.0.0" ) From cf148aff55e98a06c900a9047a568098b6b7169e Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Fri, 9 May 2025 01:11:47 -0400 Subject: [PATCH 09/12] Improve warnings & error messages. Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/Commands/Region.swift | 2 +- Sources/mas/Commands/Uninstall.swift | 2 +- Sources/mas/Commands/Upgrade.swift | 2 +- Sources/mas/Errors/MASError.swift | 6 +++--- script/_setup_script | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/mas/Commands/Region.swift b/Sources/mas/Commands/Region.swift index 3d6c46ef0..a5c3d12a0 100644 --- a/Sources/mas/Commands/Region.swift +++ b/Sources/mas/Commands/Region.swift @@ -18,7 +18,7 @@ extension MAS { /// Runs the command. func run() async throws { guard let region = await isoRegion else { - throw MASError.runtimeError("Could not obtain Mac App Store region") + throw MASError.runtimeError("Failed to obtain the region of the Mac App Store") } printInfo(region.alpha2) diff --git a/Sources/mas/Commands/Uninstall.swift b/Sources/mas/Commands/Uninstall.swift index 0540f8db8..4847ba4af 100644 --- a/Sources/mas/Commands/Uninstall.swift +++ b/Sources/mas/Commands/Uninstall.swift @@ -33,7 +33,7 @@ extension MAS { } guard let username = ProcessInfo.processInfo.sudoUsername else { - throw MASError.runtimeError("Could not determine the original username") + throw MASError.runtimeError("Failed to determine the original username") } guard diff --git a/Sources/mas/Commands/Upgrade.swift b/Sources/mas/Commands/Upgrade.swift index 33a350618..fd20478f0 100644 --- a/Sources/mas/Commands/Upgrade.swift +++ b/Sources/mas/Commands/Upgrade.swift @@ -70,7 +70,7 @@ extension MAS { // Find installed apps by name argument let installedApps = installedApps.filter { $0.name == appIDOrName } if installedApps.isEmpty { - printError("Unknown app name '", appIDOrName, "'", separator: "") + printWarning("No installed apps named", appIDOrName) } return installedApps } diff --git a/Sources/mas/Errors/MASError.swift b/Sources/mas/Errors/MASError.swift index d197fe906..bd3379b18 100644 --- a/Sources/mas/Errors/MASError.swift +++ b/Sources/mas/Errors/MASError.swift @@ -60,7 +60,7 @@ extension MASError: CustomStringConvertible { case .notSupported: """ This command is not supported on this macOS version due to changes in macOS - See: https://github.com/mas-cli/mas#known-issues + See https://github.com/mas-cli/mas#known-issues """ case let .purchaseFailed(error): "Download request failed: \(error.localizedDescription)" @@ -69,9 +69,9 @@ extension MASError: CustomStringConvertible { case let .searchFailed(error): "Search failed: \(error.localizedDescription)" case let .unknownAppID(appID): - "App ID \(appID) not found in Mac App Store" + "App ID \(appID) not found in the Mac App Store" case let .urlParsing(string): - "Unable to parse URL from: \(string)" + "Unable to parse URL from \(string)" } } } diff --git a/script/_setup_script b/script/_setup_script index bc3717d29..6f804f2a7 100755 --- a/script/_setup_script +++ b/script/_setup_script @@ -37,6 +37,6 @@ unset WORDCHARS mas_dir="${0:a:h:h}" if ! cd -- "${mas_dir}"; then - printf $'Error: Could not cd into mas directory: %s\n' "${mas_dir}" >&2 + printf $'Error: Failed to cd into mas directory: %s\n' "${mas_dir}" >&2 exit 1 fi From 148f461c476e78a40d4cd5adc36915231667e990 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Fri, 9 May 2025 06:02:57 -0400 Subject: [PATCH 10/12] Output errors & warnings, but continue processing other apps instead of short-circuiting. Improve related warnings, errors, & code. Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- .swiftlint.yml | 2 +- Sources/mas/AppStore/Downloader.swift | 91 +++------ Sources/mas/Commands/Home.swift | 20 +- Sources/mas/Commands/Info.swift | 16 +- Sources/mas/Commands/Install.swift | 32 +-- Sources/mas/Commands/Lucky.swift | 25 +-- .../OptionGroups/VerboseOptionGroup.swift | 2 +- Sources/mas/Commands/Outdated.swift | 8 +- Sources/mas/Commands/Purchase.swift | 33 ++- Sources/mas/Commands/Search.swift | 5 +- Sources/mas/Commands/Uninstall.swift | 190 ++++++++++-------- Sources/mas/Commands/Upgrade.swift | 18 +- Sources/mas/Commands/Vendor.swift | 27 +-- Sources/mas/Errors/MASError.swift | 14 +- Sources/mas/Formatters/Printing.swift | 8 +- Sources/mas/Models/InstalledApp.swift | 4 + Sources/mas/Utilities/ProcessInfo.swift | 12 +- Tests/masTests/Commands/HomeSpec.swift | 2 +- Tests/masTests/Commands/InfoSpec.swift | 2 +- Tests/masTests/Commands/SearchSpec.swift | 5 +- Tests/masTests/Commands/VendorSpec.swift | 2 +- 21 files changed, 255 insertions(+), 263 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index 55f4789ec..6fbaabb8d 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -38,7 +38,7 @@ file_types_order: - preview_provider - library_content_provider function_body_length: - warning: 55 + warning: 65 indentation_width: include_multiline_strings: false number_separator: diff --git a/Sources/mas/AppStore/Downloader.swift b/Sources/mas/AppStore/Downloader.swift index d836b81f5..50028851d 100644 --- a/Sources/mas/AppStore/Downloader.swift +++ b/Sources/mas/AppStore/Downloader.swift @@ -8,48 +8,31 @@ private import CommerceKit -/// Sequentially downloads apps, printing progress to the console. -/// -/// Verifies that each supplied app ID is valid before attempting to download. -/// -/// - Parameters: -/// - appIDs: The app IDs of the apps to be verified & downloaded. -/// - searcher: The `AppStoreSearcher` used to verify app IDs. -/// - purchasing: Flag indicating if the apps will be purchased. Only works for free apps. Defaults to false. -/// - Throws: If any download fails, immediately throws an error. -func downloadApps( - withAppIDs appIDs: [AppID], - verifiedBy searcher: AppStoreSearcher, - purchasing: Bool = false +func downloadApp( + withAppID appID: AppID, + purchasing: Bool = false, + withAttemptCount attemptCount: UInt32 = 3 ) async throws { - for appID in appIDs { - do { - _ = try await searcher.lookup(appID: appID) - try await downloadApp(withAppID: appID, purchasing: purchasing, withAttemptCount: 3) - } catch { - guard case MASError.unknownAppID = error else { - throw error + do { + let purchase = await SSPurchase(appID: appID, purchasing: purchasing) + _ = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + CKPurchaseController.shared().perform(purchase, withOptions: 0) { _, _, error, response in + if let error { + continuation.resume(throwing: MASError(purchaseFailedError: error)) + } else if response?.downloads.isEmpty == false { + Task { + do { + try await PurchaseDownloadObserver(appID: appID).observeDownloadQueue() + continuation.resume() + } catch { + continuation.resume(throwing: MASError(purchaseFailedError: error)) + } + } + } else { + continuation.resume(throwing: MASError.noDownloads) + } } - printWarning(error) } - } -} - -/// Sequentially downloads apps, printing progress to the console. -/// -/// - Parameters: -/// - appIDs: The app IDs of the apps to be downloaded. -/// - purchasing: Flag indicating if the apps will be purchased. Only works for free apps. Defaults to false. -/// - Throws: If a download fails, immediately throws an error. -func downloadApps(withAppIDs appIDs: [AppID], purchasing: Bool = false) async throws { - for appID in appIDs { - try await downloadApp(withAppID: appID, purchasing: purchasing, withAttemptCount: 3) - } -} - -private func downloadApp(withAppID appID: AppID, purchasing: Bool, withAttemptCount attemptCount: UInt32) async throws { - do { - try await downloadApp(withAppID: appID, purchasing: purchasing) } catch { guard attemptCount > 1 else { throw error @@ -64,30 +47,12 @@ private func downloadApp(withAppID appID: AppID, purchasing: Bool, withAttemptCo } let attemptCount = attemptCount - 1 - printWarning(downloadError.localizedDescription) - printWarning("Retrying…", attemptCount, attemptCount == 1 ? "attempt remaining" : "attempts remaining") + printWarning( + "Network error (", + attemptCount, + attemptCount == 1 ? " attempt remaining):\n" : " attempts remaining):\n", + downloadError.localizedDescription + ) try await downloadApp(withAppID: appID, purchasing: purchasing, withAttemptCount: attemptCount) } } - -private func downloadApp(withAppID appID: AppID, purchasing: Bool = false) async throws { - let purchase = await SSPurchase(appID: appID, purchasing: purchasing) - _ = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - CKPurchaseController.shared().perform(purchase, withOptions: 0) { _, _, error, response in - if let error { - continuation.resume(throwing: MASError(purchaseFailedError: error)) - } else if response?.downloads.isEmpty == false { - Task { - do { - try await PurchaseDownloadObserver(appID: appID).observeDownloadQueue() - continuation.resume() - } catch { - continuation.resume(throwing: MASError(purchaseFailedError: error)) - } - } - } else { - continuation.resume(throwing: MASError.noDownloads) - } - } - } -} diff --git a/Sources/mas/Commands/Home.swift b/Sources/mas/Commands/Home.swift index 1f26467b5..6494a5c64 100644 --- a/Sources/mas/Commands/Home.swift +++ b/Sources/mas/Commands/Home.swift @@ -24,19 +24,21 @@ extension MAS { var appIDsOptionGroup: AppIDsOptionGroup /// Runs the command. - func run() async throws { - try await run(searcher: ITunesSearchAppStoreSearcher()) + func run() async { + await run(searcher: ITunesSearchAppStoreSearcher()) } - func run(searcher: AppStoreSearcher) async throws { + func run(searcher: AppStoreSearcher) async { for appID in appIDsOptionGroup.appIDs { - let result = try await searcher.lookup(appID: appID) - - guard let url = URL(string: result.trackViewUrl) else { - throw MASError.urlParsing(result.trackViewUrl) + do { + let result = try await searcher.lookup(appID: appID) + guard let url = URL(string: result.trackViewUrl) else { + throw MASError.urlParsing(result.trackViewUrl) + } + try await url.open() + } catch { + printError(error) } - - try await url.open() } } } diff --git a/Sources/mas/Commands/Info.swift b/Sources/mas/Commands/Info.swift index e23b1cc6a..6d6cbd3f1 100644 --- a/Sources/mas/Commands/Info.swift +++ b/Sources/mas/Commands/Info.swift @@ -7,6 +7,7 @@ // internal import ArgumentParser +private import Foundation extension MAS { /// Outputs app information from the Mac App Store. @@ -23,19 +24,20 @@ extension MAS { var appIDsOptionGroup: AppIDsOptionGroup /// Runs the command. - func run() async throws { - try await run(searcher: ITunesSearchAppStoreSearcher()) + func run() async { + await run(searcher: ITunesSearchAppStoreSearcher()) } - func run(searcher: AppStoreSearcher) async throws { - var separator = "" + func run(searcher: AppStoreSearcher) async { + var spacing = "" for appID in appIDsOptionGroup.appIDs { do { - printInfo("", AppInfoFormatter.format(app: try await searcher.lookup(appID: appID)), separator: separator) - separator = "\n" + printInfo("", AppInfoFormatter.format(app: try await searcher.lookup(appID: appID)), separator: spacing) } catch { - throw MASError(searchFailedError: error) + print(spacing, to: .standardError) + printError(error) } + spacing = "\n" } } } diff --git a/Sources/mas/Commands/Install.swift b/Sources/mas/Commands/Install.swift index 92a2bf656..aa580a1b8 100644 --- a/Sources/mas/Commands/Install.swift +++ b/Sources/mas/Commands/Install.swift @@ -21,24 +21,24 @@ extension MAS { var appIDsOptionGroup: AppIDsOptionGroup /// Runs the command. - func run() async throws { - try await run(installedApps: await installedApps, searcher: ITunesSearchAppStoreSearcher()) + func run() async { + await run(installedApps: await installedApps, searcher: ITunesSearchAppStoreSearcher()) } - func run(installedApps: [InstalledApp], searcher: AppStoreSearcher) async throws { - do { - try await downloadApps( - withAppIDs: appIDsOptionGroup.appIDs.filter { appID in - if let appName = installedApps.first(where: { $0.id == appID })?.name, !forceOptionGroup.force { - printWarning(appName, "is already installed") - return false - } - return true - }, - verifiedBy: searcher - ) - } catch { - throw MASError(downloadFailedError: error) + func run(installedApps: [InstalledApp], searcher: AppStoreSearcher) async { + for appID in appIDsOptionGroup.appIDs.filter({ appID in + if let installedApp = installedApps.first(where: { $0.id == appID }), !forceOptionGroup.force { + printWarning("Already installed:", installedApp.idAndName) + return false + } + return true + }) { + do { + _ = try await searcher.lookup(appID: appID) + try await downloadApp(withAppID: appID) + } catch { + printError(error) + } } } } diff --git a/Sources/mas/Commands/Lucky.swift b/Sources/mas/Commands/Lucky.swift index 9b9444b97..f859985fb 100644 --- a/Sources/mas/Commands/Lucky.swift +++ b/Sources/mas/Commands/Lucky.swift @@ -34,16 +34,13 @@ extension MAS { } func run(installedApps: [InstalledApp], searcher: AppStoreSearcher) async throws { - do { - let results = try await searcher.search(for: searchTermOptionGroup.searchTerm) - guard let result = results.first else { - throw MASError.noSearchResultsFound - } - - try await install(appID: result.trackId, installedApps: installedApps) - } catch { - throw MASError(searchFailedError: error) + let searchTerm = searchTermOptionGroup.searchTerm + let results = try await searcher.search(for: searchTerm) + guard let result = results.first else { + throw MASError.noSearchResultsFound(for: searchTerm) } + + try await install(appID: result.trackId, installedApps: installedApps) } /// Installs an app. @@ -53,14 +50,10 @@ extension MAS { /// - installedApps: List of installed apps. /// - Throws: Any error that occurs while attempting to install the app. private func install(appID: AppID, installedApps: [InstalledApp]) async throws { - if let appName = installedApps.first(where: { $0.id == appID })?.name, !forceOptionGroup.force { - printWarning(appName, "is already installed") + if let installedApp = installedApps.first(where: { $0.id == appID }), !forceOptionGroup.force { + printWarning("Already installed:", installedApp.idAndName) } else { - do { - try await downloadApps(withAppIDs: [appID]) - } catch { - throw MASError(downloadFailedError: error) - } + try await downloadApp(withAppID: appID) } } } diff --git a/Sources/mas/Commands/OptionGroups/VerboseOptionGroup.swift b/Sources/mas/Commands/OptionGroups/VerboseOptionGroup.swift index f0ba67b42..d54cea746 100644 --- a/Sources/mas/Commands/OptionGroups/VerboseOptionGroup.swift +++ b/Sources/mas/Commands/OptionGroups/VerboseOptionGroup.swift @@ -18,7 +18,7 @@ struct VerboseOptionGroup: ParsableArguments { return } if verbose { - printWarning(error, "; was expected to identify ", appName, separator: "") + printWarning(error, "; was expected to identify: ", appName, separator: "") } } } diff --git a/Sources/mas/Commands/Outdated.swift b/Sources/mas/Commands/Outdated.swift index 917692331..38496ad28 100644 --- a/Sources/mas/Commands/Outdated.swift +++ b/Sources/mas/Commands/Outdated.swift @@ -20,11 +20,11 @@ extension MAS { var verboseOptionGroup: VerboseOptionGroup /// Runs the command. - func run() async throws { - try await run(installedApps: await installedApps, searcher: ITunesSearchAppStoreSearcher()) + func run() async { + await run(installedApps: await installedApps, searcher: ITunesSearchAppStoreSearcher()) } - func run(installedApps: [InstalledApp], searcher: AppStoreSearcher) async throws { + func run(installedApps: [InstalledApp], searcher: AppStoreSearcher) async { for installedApp in installedApps { do { let storeApp = try await searcher.lookup(appID: installedApp.id) @@ -41,7 +41,7 @@ extension MAS { separator: "" ) } - } catch let error as MASError { + } catch { verboseOptionGroup.printProblem(forError: error, expectedAppName: installedApp.name) } } diff --git a/Sources/mas/Commands/Purchase.swift b/Sources/mas/Commands/Purchase.swift index 000286aae..b499749de 100644 --- a/Sources/mas/Commands/Purchase.swift +++ b/Sources/mas/Commands/Purchase.swift @@ -19,25 +19,24 @@ extension MAS { var appIDsOptionGroup: AppIDsOptionGroup /// Runs the command. - func run() async throws { - try await run(installedApps: await installedApps, searcher: ITunesSearchAppStoreSearcher()) + func run() async { + await run(installedApps: await installedApps, searcher: ITunesSearchAppStoreSearcher()) } - func run(installedApps: [InstalledApp], searcher: AppStoreSearcher) async throws { - do { - try await downloadApps( - withAppIDs: appIDsOptionGroup.appIDs.filter { appID in - if let appName = installedApps.first(where: { $0.id == appID })?.name { - printWarning(appName, "has already been purchased") - return false - } - return true - }, - verifiedBy: searcher, - purchasing: true - ) - } catch { - throw MASError(downloadFailedError: error) + func run(installedApps: [InstalledApp], searcher: AppStoreSearcher) async { + for appID in appIDsOptionGroup.appIDs.filter({ appID in + if let installedApp = installedApps.first(where: { $0.id == appID }) { + printWarning("Already purchased:", installedApp.idAndName) + return false + } + return true + }) { + do { + _ = try await searcher.lookup(appID: appID) + try await downloadApp(withAppID: appID, purchasing: true) + } catch { + printError(error) + } } } } diff --git a/Sources/mas/Commands/Search.swift b/Sources/mas/Commands/Search.swift index a614c7b76..a5d66a488 100644 --- a/Sources/mas/Commands/Search.swift +++ b/Sources/mas/Commands/Search.swift @@ -31,9 +31,10 @@ extension MAS { func run(searcher: AppStoreSearcher) async throws { do { - let results = try await searcher.search(for: searchTermOptionGroup.searchTerm) + let searchTerm = searchTermOptionGroup.searchTerm + let results = try await searcher.search(for: searchTerm) if results.isEmpty { - throw MASError.noSearchResultsFound + throw MASError.noSearchResultsFound(for: searchTerm) } printInfo(SearchResultFormatter.format(results, includePrice: price)) diff --git a/Sources/mas/Commands/Uninstall.swift b/Sources/mas/Commands/Uninstall.swift index 4847ba4af..b31ac276c 100644 --- a/Sources/mas/Commands/Uninstall.swift +++ b/Sources/mas/Commands/Uninstall.swift @@ -32,25 +32,7 @@ extension MAS { throw MASError.macOSUserMustBeRoot } - guard let username = ProcessInfo.processInfo.sudoUsername else { - throw MASError.runtimeError("Failed to determine the original username") - } - - guard - let uid = ProcessInfo.processInfo.sudoUID, - seteuid(uid) == 0 - else { - throw MASError.runtimeError("Failed to switch effective user from 'root' to '\(username)'") - } - - var uninstallingAppSet = Set() - for appID in appIDsOptionGroup.appIDs { - let foundApps = installedApps.filter { $0.id == appID } - foundApps.isEmpty // swiftformat:disable:next indent - ? printError(appID.notInstalledMessage) - : uninstallingAppSet.formUnion(foundApps) - } - + let uninstallingAppSet = try uninstallingAppSet(fromInstalledApps: installedApps) guard !uninstallingAppSet.isEmpty else { return } @@ -61,12 +43,49 @@ extension MAS { } printNotice("(not removed, dry run)") } else { - guard seteuid(0) == 0 else { - throw MASError.runtimeError("Failed to revert effective user from '\(username)' back to 'root'") + try uninstallApps(atPaths: uninstallingAppSet.map(\.path)) + } + } + + private func uninstallingAppSet(fromInstalledApps installedApps: [InstalledApp]) throws -> Set { + guard let sudoGroupName = ProcessInfo.processInfo.sudoGroupName else { + throw MASError.runtimeError("Failed to determine the original group name") + } + guard let sudoGID = ProcessInfo.processInfo.sudoGID else { + throw MASError.runtimeError("Failed to get original gid") + } + guard setegid(sudoGID) == 0 else { + throw MASError.runtimeError("Failed to switch effective group from 'wheel' to '\(sudoGroupName)'") + } + defer { + if setegid(0) != 0 { + printWarning("Failed to revert effective group from '", sudoGroupName, "' back to 'wheel'", separator: "") } + } - try uninstallApps(atPaths: uninstallingAppSet.map(\.path)) + guard let sudoUserName = ProcessInfo.processInfo.sudoUserName else { + throw MASError.runtimeError("Failed to determine the original user name") } + guard let sudoUID = ProcessInfo.processInfo.sudoUID else { + throw MASError.runtimeError("Failed to get original uid") + } + guard seteuid(sudoUID) == 0 else { + throw MASError.runtimeError("Failed to switch effective user from 'root' to '\(sudoUserName)'") + } + defer { + if seteuid(0) != 0 { + printWarning("Failed to revert effective user from '", sudoUserName, "' back to 'root'", separator: "") + } + } + + var uninstallingAppSet = Set() + for appID in appIDsOptionGroup.appIDs { + let apps = installedApps.filter { $0.id == appID } + apps.isEmpty // swiftformat:disable:next indent + ? printError(appID.notInstalledMessage) + : uninstallingAppSet.formUnion(apps) + } + return uninstallingAppSet } } } @@ -76,110 +95,103 @@ extension MAS { /// - Parameter appPaths: Paths to apps to be uninstalled. /// - Throws: An `Error` if any problem occurs. private func uninstallApps(atPaths appPaths: [String]) throws { - try delete(pathsFromOwnerIDsByPath: try chown(paths: appPaths)) -} + let finderItems = try finderItems() -private func chown(paths: [String]) throws -> [String: (uid_t, gid_t)] { - guard let sudoUID = ProcessInfo.processInfo.sudoUID else { + guard let uid = ProcessInfo.processInfo.sudoUID else { throw MASError.runtimeError("Failed to get original uid") } - - guard let sudoGID = ProcessInfo.processInfo.sudoGID else { + guard let gid = ProcessInfo.processInfo.sudoGID else { throw MASError.runtimeError("Failed to get original gid") } - let ownerIDsByPath = try paths.reduce(into: [:]) { dict, path in - dict[path] = try getOwnerAndGroupOfItem(atPath: path) - } - - var chownedIDsByPath = [String: (uid_t, gid_t)]() - for (path, ownerIDs) in ownerIDsByPath { - guard chown(path, sudoUID, sudoGID) == 0 else { - for (chownedPath, chownedIDs) in chownedIDsByPath // swiftformat:disable:next indent - where chown(chownedPath, chownedIDs.0, chownedIDs.1) != 0 { - printError( + for appPath in appPaths { + guard let (appUID, appGID) = uidAndGid(forPath: appPath) else { + continue + } + guard chown(appPath, uid, gid) == 0 else { + printError("Failed to change ownership of '", appPath, "' to uid ", uid, " & gid ", gid, separator: "") + continue + } + var chownPath = appPath + defer { + if chown(chownPath, appUID, appGID) != 0 { + printWarning( "Failed to revert ownership of '", - path, + chownPath, "' back to uid ", - chownedIDs.0, + appUID, " & gid ", - chownedIDs.1, + appGID, separator: "" ) } - throw MASError.runtimeError("Failed to change ownership of '\(path)' to uid \(sudoUID) & gid \(sudoGID)") } - chownedIDsByPath[path] = ownerIDs - } - - return ownerIDsByPath -} - -private func getOwnerAndGroupOfItem(atPath path: String) throws -> (uid_t, gid_t) { - do { - let attributes = try FileManager.default.attributesOfItem(atPath: path) - guard - let uid = attributes[.ownerAccountID] as? uid_t, - let gid = attributes[.groupOwnerAccountID] as? gid_t - else { - throw MASError.runtimeError("Failed to determine running user's uid & gid") - } - return (uid, gid) - } -} - -private func delete(pathsFromOwnerIDsByPath ownerIDsByPath: [String: (uid_t, gid_t)]) throws { - guard let finder = SBApplication(bundleIdentifier: "com.apple.finder") as FinderApplication? else { - throw MASError.runtimeError("Failed to obtain Finder access: com.apple.finder does not exist") - } - - guard let items = finder.items else { - throw MASError.runtimeError("Failed to obtain Finder access: finder.items does not exist") - } - - for (path, ownerIDs) in ownerIDsByPath { - let object = items().object(atLocation: URL(fileURLWithPath: path)) - + let object = finderItems.object(atLocation: URL(fileURLWithPath: appPath)) guard let item = object as? FinderItem else { - throw MASError.runtimeError( + printError( """ - Failed to obtain Finder access: finder.items().object(atLocation: URL(fileURLWithPath:\ - \"\(path)\") is a \(type(of: object)) that does not conform to FinderItem + Failed to obtain Finder access: finderItems.object(atLocation: URL(fileURLWithPath:\ + \"\(appPath)\") is a \(type(of: object)) that does not conform to FinderItem """ ) + continue } guard let delete = item.delete else { - throw MASError.runtimeError("Failed to obtain Finder access: FinderItem.delete does not exist") + printError("Failed to obtain Finder access: FinderItem.delete does not exist") + continue } - let uid = ownerIDs.0 - let gid = ownerIDs.1 guard let deletedURLString = (delete() as FinderItem).URL else { - throw MASError.runtimeError( + printError( """ - Failed to revert ownership of deleted '\(path)' back to uid \(uid) & gid \(gid):\ + Failed to revert ownership of deleted '\(appPath)' back to uid \(appUID) & gid \(appGID):\ delete result did not have a URL """ ) + continue } guard let deletedURL = URL(string: deletedURLString) else { - throw MASError.runtimeError( + printError( """ - Failed to revert ownership of deleted '\(path)' back to uid \(uid) & gid \(gid):\ + Failed to revert ownership of deleted '\(appPath)' back to uid \(appUID) & gid \(appGID):\ delete result URL is invalid: \(deletedURLString) """ ) + continue } - let deletedPath = deletedURL.path - printInfo("Deleted '", path, "' to '", deletedPath, "'", separator: "") - guard chown(deletedPath, uid, gid) == 0 else { - throw MASError.runtimeError( - "Failed to revert ownership of deleted '\(deletedPath)' back to uid \(uid) & gid \(gid)" - ) + chownPath = deletedURL.path + printInfo("Deleted '", appPath, "' to '", chownPath, "'", separator: "") + } +} + +private func finderItems() throws -> SBElementArray { + guard let finder = SBApplication(bundleIdentifier: "com.apple.finder") as FinderApplication? else { + throw MASError.runtimeError("Failed to obtain Finder access: com.apple.finder does not exist") + } + guard let items = finder.items else { + throw MASError.runtimeError("Failed to obtain Finder access: finder.items does not exist") + } + return items() +} + +private func uidAndGid(forPath path: String) -> (uid_t, gid_t)? { + do { + let attributes = try FileManager.default.attributesOfItem(atPath: path) + guard let uid = attributes[.ownerAccountID] as? uid_t else { + printError("Failed to determine uid of", path) + return nil } + guard let gid = attributes[.groupOwnerAccountID] as? gid_t else { + printError("Failed to determine gid of", path) + return nil + } + return (uid, gid) + } catch { + printError(error) + return nil } } diff --git a/Sources/mas/Commands/Upgrade.swift b/Sources/mas/Commands/Upgrade.swift index fd20478f0..32f82c73d 100644 --- a/Sources/mas/Commands/Upgrade.swift +++ b/Sources/mas/Commands/Upgrade.swift @@ -21,11 +21,11 @@ extension MAS { var appIDOrNames = [String]() /// Runs the command. - func run() async throws { - try await run(installedApps: await installedApps, searcher: ITunesSearchAppStoreSearcher()) + func run() async { + await run(installedApps: await installedApps, searcher: ITunesSearchAppStoreSearcher()) } - func run(installedApps: [InstalledApp], searcher: AppStoreSearcher) async throws { + func run(installedApps: [InstalledApp], searcher: AppStoreSearcher) async { let apps = await findOutdatedApps(installedApps: installedApps, searcher: searcher) guard !apps.isEmpty else { @@ -44,10 +44,12 @@ extension MAS { separator: "" ) - do { - try await downloadApps(withAppIDs: apps.map(\.storeApp.trackId)) - } catch { - throw MASError(downloadFailedError: error) + for appID in apps.map(\.storeApp.trackId) { + do { + try await downloadApp(withAppID: appID) + } catch { + printError(error) + } } } @@ -70,7 +72,7 @@ extension MAS { // Find installed apps by name argument let installedApps = installedApps.filter { $0.name == appIDOrName } if installedApps.isEmpty { - printWarning("No installed apps named", appIDOrName) + printError("No installed apps named", appIDOrName) } return installedApps } diff --git a/Sources/mas/Commands/Vendor.swift b/Sources/mas/Commands/Vendor.swift index 1ed71ed1c..f5410776b 100644 --- a/Sources/mas/Commands/Vendor.swift +++ b/Sources/mas/Commands/Vendor.swift @@ -24,23 +24,24 @@ extension MAS { var appIDsOptionGroup: AppIDsOptionGroup /// Runs the command. - func run() async throws { - try await run(searcher: ITunesSearchAppStoreSearcher()) + func run() async { + await run(searcher: ITunesSearchAppStoreSearcher()) } - func run(searcher: AppStoreSearcher) async throws { + func run(searcher: AppStoreSearcher) async { for appID in appIDsOptionGroup.appIDs { - let result = try await searcher.lookup(appID: appID) - - guard let urlString = result.sellerUrl else { - throw MASError.noVendorWebsite - } - - guard let url = URL(string: urlString) else { - throw MASError.urlParsing(urlString) + do { + let result = try await searcher.lookup(appID: appID) + guard let urlString = result.sellerUrl else { + throw MASError.noVendorWebsite(forAppID: appID) + } + guard let url = URL(string: urlString) else { + throw MASError.urlParsing(urlString) + } + try await url.open() + } catch { + printError(error) } - - try await url.open() } } } diff --git a/Sources/mas/Errors/MASError.swift b/Sources/mas/Errors/MASError.swift index bd3379b18..b53db0a81 100644 --- a/Sources/mas/Errors/MASError.swift +++ b/Sources/mas/Errors/MASError.swift @@ -14,8 +14,8 @@ enum MASError: Error, Equatable { case jsonParsing(data: Data) case macOSUserMustBeRoot case noDownloads - case noSearchResultsFound - case noVendorWebsite + case noSearchResultsFound(for: String) + case noVendorWebsite(forAppID: AppID) case notSupported case purchaseFailed(error: NSError) case runtimeError(String) @@ -53,10 +53,10 @@ extension MASError: CustomStringConvertible { "Apps installed from the Mac App Store require root permission to remove" case .noDownloads: "No downloads began" - case .noSearchResultsFound: - "No apps found" - case .noVendorWebsite: - "App does not have a vendor website" + case let .noSearchResultsFound(searchTerm): + "No apps found in the Mac App Store for search term: \(searchTerm)" + case let .noVendorWebsite(appID): + "No vendor website available for app ID \(appID)" case .notSupported: """ This command is not supported on this macOS version due to changes in macOS @@ -69,7 +69,7 @@ extension MASError: CustomStringConvertible { case let .searchFailed(error): "Search failed: \(error.localizedDescription)" case let .unknownAppID(appID): - "App ID \(appID) not found in the Mac App Store" + "No apps found in the Mac App Store for app ID \(appID)" case let .urlParsing(string): "Unable to parse URL from \(string)" } diff --git a/Sources/mas/Formatters/Printing.swift b/Sources/mas/Formatters/Printing.swift index 2005c0416..cfc339600 100644 --- a/Sources/mas/Formatters/Printing.swift +++ b/Sources/mas/Formatters/Printing.swift @@ -61,10 +61,10 @@ func printWarning(_ items: Any..., separator: String = " ", terminator: String = // Yellow, underlined "Warning:" prefix print( "\(csi)4;33mWarning:\(csi)0m \(message(items, separator: separator, terminator: terminator))", - to: FileHandle.standardError + to: .standardError ) } else { - print("Warning: \(message(items, separator: separator, terminator: terminator))", to: FileHandle.standardError) + print("Warning: \(message(items, separator: separator, terminator: terminator))", to: .standardError) } } @@ -74,10 +74,10 @@ func printError(_ items: Any..., separator: String = " ", terminator: String = " // Red, underlined "Error:" prefix print( "\(csi)4;31mError:\(csi)0m \(message(items, separator: separator, terminator: terminator))", - to: FileHandle.standardError + to: .standardError ) } else { - print("Error: \(message(items, separator: separator, terminator: terminator))", to: FileHandle.standardError) + print("Error: \(message(items, separator: separator, terminator: terminator))", to: .standardError) } } diff --git a/Sources/mas/Models/InstalledApp.swift b/Sources/mas/Models/InstalledApp.swift index c4c9a4ee0..d6ffaaa09 100644 --- a/Sources/mas/Models/InstalledApp.swift +++ b/Sources/mas/Models/InstalledApp.swift @@ -19,6 +19,10 @@ struct InstalledApp: Hashable, Sendable { } extension InstalledApp { + var idAndName: String { + "app ID \(id) (\(name))" + } + /// Determines whether the app is considered outdated. /// /// Updates that require a higher OS version are excluded. diff --git a/Sources/mas/Utilities/ProcessInfo.swift b/Sources/mas/Utilities/ProcessInfo.swift index c8ebfda2a..c8e89fa62 100644 --- a/Sources/mas/Utilities/ProcessInfo.swift +++ b/Sources/mas/Utilities/ProcessInfo.swift @@ -9,10 +9,20 @@ internal import Foundation extension ProcessInfo { - var sudoUsername: String? { + var sudoUserName: String? { environment["SUDO_USER"] } + var sudoGroupName: String? { + guard + let sudoGID, + let group = getgrgid(sudoGID) + else { + return nil + } + return String(validatingUTF8: group.pointee.gr_name) + } + var sudoUID: uid_t? { if let uid = environment["SUDO_UID"] { uid_t(uid) diff --git a/Tests/masTests/Commands/HomeSpec.swift b/Tests/masTests/Commands/HomeSpec.swift index 23d89776f..a1b95dfa3 100644 --- a/Tests/masTests/Commands/HomeSpec.swift +++ b/Tests/masTests/Commands/HomeSpec.swift @@ -18,7 +18,7 @@ final class HomeSpec: AsyncSpec { await expecta( await consequencesOf(try await MAS.Home.parse(["999"]).run(searcher: MockAppStoreSearcher())) ) - == UnvaluedConsequences(MASError.unknownAppID(999)) + == UnvaluedConsequences(nil, "", "Error: No apps found in the Mac App Store for app ID 999\n") } } } diff --git a/Tests/masTests/Commands/InfoSpec.swift b/Tests/masTests/Commands/InfoSpec.swift index 1dd7cb26a..e6bfde784 100644 --- a/Tests/masTests/Commands/InfoSpec.swift +++ b/Tests/masTests/Commands/InfoSpec.swift @@ -18,7 +18,7 @@ final class InfoSpec: AsyncSpec { await expecta( await consequencesOf(try await MAS.Info.parse(["999"]).run(searcher: MockAppStoreSearcher())) ) - == UnvaluedConsequences(MASError.unknownAppID(999)) + == UnvaluedConsequences(nil, "", "Error: No apps found in the Mac App Store for app ID 999\n") } it("outputs app details") { let mockResult = SearchResult( diff --git a/Tests/masTests/Commands/SearchSpec.swift b/Tests/masTests/Commands/SearchSpec.swift index c7dbb2f69..b1e2e5a58 100644 --- a/Tests/masTests/Commands/SearchSpec.swift +++ b/Tests/masTests/Commands/SearchSpec.swift @@ -29,12 +29,13 @@ final class SearchSpec: AsyncSpec { == UnvaluedConsequences(nil, " 1111 slack (0.0)\n") } it("fails when searching for nonexistent app") { + let searchTerm = "nonexistent" await expecta( await consequencesOf( - try await MAS.Search.parse(["nonexistent"]).run(searcher: MockAppStoreSearcher()) + try await MAS.Search.parse([searchTerm]).run(searcher: MockAppStoreSearcher()) ) ) - == UnvaluedConsequences(MASError.noSearchResultsFound) + == UnvaluedConsequences(MASError.noSearchResultsFound(for: searchTerm)) } } } diff --git a/Tests/masTests/Commands/VendorSpec.swift b/Tests/masTests/Commands/VendorSpec.swift index 3a7322f6b..d72401cae 100644 --- a/Tests/masTests/Commands/VendorSpec.swift +++ b/Tests/masTests/Commands/VendorSpec.swift @@ -18,7 +18,7 @@ final class VendorSpec: AsyncSpec { await expecta( await consequencesOf(try await MAS.Vendor.parse(["999"]).run(searcher: MockAppStoreSearcher())) ) - == UnvaluedConsequences(MASError.unknownAppID(999)) + == UnvaluedConsequences(nil, "", "Error: No apps found in the Mac App Store for app ID 999\n") } } } From b84cc3be25c39b3b4a2db4f655651eca16e1990d Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Sat, 10 May 2025 22:53:22 -0400 Subject: [PATCH 11/12] Rename `Printing.swift` as `Printer.swift`. Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/Formatters/{Printing.swift => Printer.swift} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename Sources/mas/Formatters/{Printing.swift => Printer.swift} (96%) diff --git a/Sources/mas/Formatters/Printing.swift b/Sources/mas/Formatters/Printer.swift similarity index 96% rename from Sources/mas/Formatters/Printing.swift rename to Sources/mas/Formatters/Printer.swift index cfc339600..d4e5a26c9 100644 --- a/Sources/mas/Formatters/Printing.swift +++ b/Sources/mas/Formatters/Printer.swift @@ -1,9 +1,9 @@ // -// Printing.swift +// Printer.swift // mas // -// Created by Andrew Naylor on 2016-09-14. -// Copyright © 2016 Andrew Naylor. All rights reserved. +// Created by Ross Goldberg on 2025-05-10. +// Copyright © 2025 mas-cli. All rights reserved. // internal import Foundation From 6aa3ea5f3af9fc03a9a696b427c142ad778e1ed8 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Sat, 10 May 2025 22:51:33 -0400 Subject: [PATCH 12/12] Implement `Printer` framework. Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- .swiftlint.yml | 5 +- Package.resolved | 9 + Package.swift | 2 + Sources/mas/AppStore/Downloader.swift | 81 ++++----- .../AppStore/PurchaseDownloadObserver.swift | 39 +++-- Sources/mas/Commands/Account.swift | 6 +- Sources/mas/Commands/Config.swift | 12 +- Sources/mas/Commands/Home.swift | 12 +- Sources/mas/Commands/Info.swift | 16 +- Sources/mas/Commands/Install.swift | 18 +- Sources/mas/Commands/List.swift | 14 +- Sources/mas/Commands/Lucky.swift | 16 +- Sources/mas/Commands/Open.swift | 8 +- .../OptionGroups/VerboseOptionGroup.swift | 6 +- Sources/mas/Commands/Outdated.swift | 14 +- Sources/mas/Commands/Purchase.swift | 18 +- Sources/mas/Commands/Region.swift | 7 +- Sources/mas/Commands/Reset.swift | 14 +- Sources/mas/Commands/Search.swift | 17 +- Sources/mas/Commands/SignIn.swift | 6 +- Sources/mas/Commands/SignOut.swift | 6 +- Sources/mas/Commands/Uninstall.swift | 76 ++++----- Sources/mas/Commands/Upgrade.swift | 27 +-- Sources/mas/Commands/Vendor.swift | 12 +- Sources/mas/Commands/Version.swift | 8 +- Sources/mas/Errors/MASError.swift | 26 +-- Sources/mas/Formatters/Printer.swift | 161 +++++++++++------- Tests/masTests/Commands/AccountSpec.swift | 3 +- Tests/masTests/Commands/HomeSpec.swift | 3 +- Tests/masTests/Commands/InfoSpec.swift | 3 +- Tests/masTests/Commands/ListSpec.swift | 3 +- Tests/masTests/Commands/OpenSpec.swift | 3 +- Tests/masTests/Commands/SearchSpec.swift | 7 +- Tests/masTests/Commands/SignInSpec.swift | 4 +- Tests/masTests/Commands/VendorSpec.swift | 3 +- .../Utilities/UnvaluedConsequences.swift | 12 +- .../Utilities/ValuedConsequences.swift | 12 +- docs/sample.swift | 6 +- 38 files changed, 407 insertions(+), 288 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index 6fbaabb8d..6a1a55e15 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -24,12 +24,15 @@ disabled_rules: - no_magic_numbers - prefixed_toplevel_constant - sorted_enum_cases +- strict_fileprivate - vertical_whitespace_between_cases - void_function_in_ternary attributes: always_on_line_above: ['@MainActor', '@OptionGroup'] closure_body_length: warning: 40 +cyclomatic_complexity: + warning: 11 file_types_order: order: - main_type @@ -38,7 +41,7 @@ file_types_order: - preview_provider - library_content_provider function_body_length: - warning: 65 + warning: 70 indentation_width: include_multiline_strings: false number_separator: diff --git a/Package.resolved b/Package.resolved index aeeff8fdd..a6c521d8b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -54,6 +54,15 @@ "version" : "1.5.0" } }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "branch" : "main", + "revision" : "d61ca105d6fb41e00dae7d9f0c8db44a715516f8" + } + }, { "identity" : "version", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 4fad16ffa..439d09c32 100644 --- a/Package.swift +++ b/Package.swift @@ -17,6 +17,7 @@ let package = Package( .package(url: "https://github.com/Quick/Nimble.git", from: "13.7.1"), .package(url: "https://github.com/Quick/Quick.git", exact: "7.5.0"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), + .package(url: "https://github.com/apple/swift-atomics.git", branch: "main"), .package(url: "https://github.com/funky-monkey/IsoCountryCodes.git", from: "1.0.2"), .package(url: "https://github.com/mxcl/Version.git", from: "2.1.0"), ], @@ -25,6 +26,7 @@ let package = Package( name: "mas", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Atomics", package: "swift-atomics"), "IsoCountryCodes", "Version", ], diff --git a/Sources/mas/AppStore/Downloader.swift b/Sources/mas/AppStore/Downloader.swift index 50028851d..8a8366189 100644 --- a/Sources/mas/AppStore/Downloader.swift +++ b/Sources/mas/AppStore/Downloader.swift @@ -8,51 +8,52 @@ private import CommerceKit -func downloadApp( - withAppID appID: AppID, - purchasing: Bool = false, - withAttemptCount attemptCount: UInt32 = 3 -) async throws { - do { - let purchase = await SSPurchase(appID: appID, purchasing: purchasing) - _ = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - CKPurchaseController.shared().perform(purchase, withOptions: 0) { _, _, error, response in - if let error { - continuation.resume(throwing: MASError(purchaseFailedError: error)) - } else if response?.downloads.isEmpty == false { - Task { - do { - try await PurchaseDownloadObserver(appID: appID).observeDownloadQueue() - continuation.resume() - } catch { - continuation.resume(throwing: MASError(purchaseFailedError: error)) +struct Downloader { + let printer: Printer + + func downloadApp( + withAppID appID: AppID, + purchasing: Bool = false, + withAttemptCount attemptCount: UInt32 = 3 + ) async throws { + do { + let purchase = await SSPurchase(appID: appID, purchasing: purchasing) + _ = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + CKPurchaseController.shared().perform(purchase, withOptions: 0) { _, _, error, response in + if let error { + continuation.resume(throwing: error) + } else if response?.downloads.isEmpty == false { + Task { + do { + try await PurchaseDownloadObserver(appID: appID, printer: printer).observeDownloadQueue() + continuation.resume() + } catch { + continuation.resume(throwing: error) + } } + } else { + continuation.resume(throwing: MASError.noDownloads) } - } else { - continuation.resume(throwing: MASError.noDownloads) } } - } - } catch { - guard attemptCount > 1 else { - throw error - } + } catch { + guard attemptCount > 1 else { + throw error + } - // If the download failed due to network issues, try again. Otherwise, fail immediately. - guard - case let MASError.downloadFailed(downloadError) = error, - downloadError.domain == NSURLErrorDomain - else { - throw error - } + // If the download failed due to network issues, try again. Otherwise, fail immediately. + guard (error as NSError).domain == NSURLErrorDomain else { + throw error + } - let attemptCount = attemptCount - 1 - printWarning( - "Network error (", - attemptCount, - attemptCount == 1 ? " attempt remaining):\n" : " attempts remaining):\n", - downloadError.localizedDescription - ) - try await downloadApp(withAppID: appID, purchasing: purchasing, withAttemptCount: attemptCount) + let attemptCount = attemptCount - 1 + printer.warning( + "Network error (", + attemptCount, + attemptCount == 1 ? " attempt remaining):\n" : " attempts remaining):\n", + error + ) + try await downloadApp(withAppID: appID, purchasing: purchasing, withAttemptCount: attemptCount) + } } } diff --git a/Sources/mas/AppStore/PurchaseDownloadObserver.swift b/Sources/mas/AppStore/PurchaseDownloadObserver.swift index 8bc37a895..50adc65fa 100644 --- a/Sources/mas/AppStore/PurchaseDownloadObserver.swift +++ b/Sources/mas/AppStore/PurchaseDownloadObserver.swift @@ -15,12 +15,15 @@ private var downloadedPhaseType: Int64 { 5 } class PurchaseDownloadObserver: CKDownloadQueueObserver { private let appID: AppID + private let printer: Printer + private var completionHandler: (() -> Void)? - private var errorHandler: ((MASError) -> Void)? + private var errorHandler: ((Error) -> Void)? private var prevPhaseType: Int64? - init(appID: AppID) { + init(appID: AppID, printer: Printer) { self.appID = appID + self.printer = printer } deinit { @@ -38,7 +41,7 @@ class PurchaseDownloadObserver: CKDownloadQueueObserver { if status.isFailed || status.isCancelled { queue.removeDownload(withItemIdentifier: download.metadata.itemIdentifier) } else { - prevPhaseType = download.printProgress(prevPhaseType: prevPhaseType) + prevPhaseType = printer.progress(of: download, prevPhaseType: prevPhaseType) } } @@ -54,13 +57,13 @@ class PurchaseDownloadObserver: CKDownloadQueueObserver { return } - terminateEphemeralPrinting() + printer.terminateEphemeral() if status.isFailed { - errorHandler?(MASError(downloadFailedError: status.error)) + errorHandler?(status.error) } else if status.isCancelled { - errorHandler?(.cancelled) + errorHandler?(MASError.cancelled) } else { - printNotice("Installed", download.progressDescription) + printer.notice("Installed", download.progressDescription) completionHandler?() } } @@ -80,21 +83,23 @@ private extension SSDownload { var progressDescription: String { "\(metadata.title) (\(metadata.bundleVersion ?? "unknown version"))" } +} - func printProgress(prevPhaseType: Int64?) -> Int64 { - let currPhaseType = status.activePhase.phaseType +private extension Printer { + func progress(of download: SSDownload, prevPhaseType: Int64?) -> Int64 { + let currPhaseType = download.status.activePhase.phaseType if prevPhaseType != currPhaseType { switch currPhaseType { case downloadingPhaseType: if prevPhaseType == initialPhaseType { - printProgressHeader() + progressHeader(for: download) } case downloadedPhaseType: if prevPhaseType == downloadingPhaseType { - printProgressHeader() + progressHeader(for: download) } case installingPhaseType: - printProgressHeader() + progressHeader(for: download) default: break } @@ -102,10 +107,10 @@ private extension SSDownload { if isatty(fileno(stdout)) != 0 { // Only output the progress bar if connected to a terminal - let progressState = status.progressState + let progressState = download.status.progressState let totalLength = 60 let completedLength = Int(progressState.percentComplete * Float(totalLength)) - printEphemeral( + ephemeral( String(repeating: "#", count: completedLength), String(repeating: "-", count: totalLength - completedLength), " ", @@ -120,9 +125,9 @@ private extension SSDownload { return currPhaseType } - private func printProgressHeader() { - terminateEphemeralPrinting() - printNotice(status.activePhase.phaseDescription, progressDescription) + private func progressHeader(for download: SSDownload) { + terminateEphemeral() + notice(download.status.activePhase.phaseDescription, download.progressDescription) } } diff --git a/Sources/mas/Commands/Account.swift b/Sources/mas/Commands/Account.swift index 2b8a94f64..3ede79cb6 100644 --- a/Sources/mas/Commands/Account.swift +++ b/Sources/mas/Commands/Account.swift @@ -17,7 +17,11 @@ extension MAS { /// Runs the command. func run() async throws { - printInfo(try await appleAccount.emailAddress) + try await mas.run { try await run(printer: $0) } + } + + func run(printer: Printer) async throws { + printer.info(try await appleAccount.emailAddress) } } } diff --git a/Sources/mas/Commands/Config.swift b/Sources/mas/Commands/Config.swift index 7a54b4c9a..95957c7a1 100644 --- a/Sources/mas/Commands/Config.swift +++ b/Sources/mas/Commands/Config.swift @@ -23,11 +23,15 @@ extension MAS { var markdown = false /// Runs the command. - func run() async { + func run() async throws { + try await mas.run { await run(printer: $0) } + } + + func run(printer: Printer) async { if markdown { - printInfo("```text") + printer.info("```text") } - printInfo( + printer.info( """ mas ▁▁▁▁ \(Package.version) arch ▁▁▁ \(configStringValue("hw.machine")) @@ -45,7 +49,7 @@ extension MAS { """ ) if markdown { - printInfo("```") + printer.info("```") } } } diff --git a/Sources/mas/Commands/Home.swift b/Sources/mas/Commands/Home.swift index 6494a5c64..e0a024403 100644 --- a/Sources/mas/Commands/Home.swift +++ b/Sources/mas/Commands/Home.swift @@ -24,11 +24,15 @@ extension MAS { var appIDsOptionGroup: AppIDsOptionGroup /// Runs the command. - func run() async { - await run(searcher: ITunesSearchAppStoreSearcher()) + func run() async throws { + try await run(searcher: ITunesSearchAppStoreSearcher()) } - func run(searcher: AppStoreSearcher) async { + func run(searcher: AppStoreSearcher) async throws { + try await mas.run { await run(printer: $0, searcher: searcher) } + } + + private func run(printer: Printer, searcher: AppStoreSearcher) async { for appID in appIDsOptionGroup.appIDs { do { let result = try await searcher.lookup(appID: appID) @@ -37,7 +41,7 @@ extension MAS { } try await url.open() } catch { - printError(error) + printer.error(error: error) } } } diff --git a/Sources/mas/Commands/Info.swift b/Sources/mas/Commands/Info.swift index 6d6cbd3f1..651b567dc 100644 --- a/Sources/mas/Commands/Info.swift +++ b/Sources/mas/Commands/Info.swift @@ -24,18 +24,22 @@ extension MAS { var appIDsOptionGroup: AppIDsOptionGroup /// Runs the command. - func run() async { - await run(searcher: ITunesSearchAppStoreSearcher()) + func run() async throws { + try await run(searcher: ITunesSearchAppStoreSearcher()) } - func run(searcher: AppStoreSearcher) async { + func run(searcher: AppStoreSearcher) async throws { + try await mas.run { await run(printer: $0, searcher: searcher) } + } + + private func run(printer: Printer, searcher: AppStoreSearcher) async { var spacing = "" for appID in appIDsOptionGroup.appIDs { do { - printInfo("", AppInfoFormatter.format(app: try await searcher.lookup(appID: appID)), separator: spacing) + printer.info("", AppInfoFormatter.format(app: try await searcher.lookup(appID: appID)), separator: spacing) } catch { - print(spacing, to: .standardError) - printError(error) + printer.log(spacing, to: .standardError) + printer.error(error: error) } spacing = "\n" } diff --git a/Sources/mas/Commands/Install.swift b/Sources/mas/Commands/Install.swift index aa580a1b8..5ea48930d 100644 --- a/Sources/mas/Commands/Install.swift +++ b/Sources/mas/Commands/Install.swift @@ -21,23 +21,29 @@ extension MAS { var appIDsOptionGroup: AppIDsOptionGroup /// Runs the command. - func run() async { - await run(installedApps: await installedApps, searcher: ITunesSearchAppStoreSearcher()) + func run() async throws { + try await run(installedApps: await installedApps, searcher: ITunesSearchAppStoreSearcher()) } - func run(installedApps: [InstalledApp], searcher: AppStoreSearcher) async { + func run(installedApps: [InstalledApp], searcher: AppStoreSearcher) async throws { + try await mas.run { printer in + await run(downloader: Downloader(printer: printer), installedApps: installedApps, searcher: searcher) + } + } + + private func run(downloader: Downloader, installedApps: [InstalledApp], searcher: AppStoreSearcher) async { for appID in appIDsOptionGroup.appIDs.filter({ appID in if let installedApp = installedApps.first(where: { $0.id == appID }), !forceOptionGroup.force { - printWarning("Already installed:", installedApp.idAndName) + downloader.printer.warning("Already installed:", installedApp.idAndName) return false } return true }) { do { _ = try await searcher.lookup(appID: appID) - try await downloadApp(withAppID: appID) + try await downloader.downloadApp(withAppID: appID) } catch { - printError(error) + downloader.printer.error(error: error) } } } diff --git a/Sources/mas/Commands/List.swift b/Sources/mas/Commands/List.swift index b0b84d618..25e46f0f0 100644 --- a/Sources/mas/Commands/List.swift +++ b/Sources/mas/Commands/List.swift @@ -16,13 +16,17 @@ extension MAS { ) /// Runs the command. - func run() async { - run(installedApps: await installedApps) + func run() async throws { + try run(installedApps: await installedApps) } - func run(installedApps: [InstalledApp]) { + func run(installedApps: [InstalledApp]) throws { + try mas.run { run(printer: $0, installedApps: installedApps) } + } + + private func run(printer: Printer, installedApps: [InstalledApp]) { if installedApps.isEmpty { - printError( + printer.error( """ No installed apps found @@ -33,7 +37,7 @@ extension MAS { """ ) } else { - printInfo(AppListFormatter.format(installedApps)) + printer.info(AppListFormatter.format(installedApps)) } } } diff --git a/Sources/mas/Commands/Lucky.swift b/Sources/mas/Commands/Lucky.swift index f859985fb..c6cbb0c9c 100644 --- a/Sources/mas/Commands/Lucky.swift +++ b/Sources/mas/Commands/Lucky.swift @@ -34,13 +34,18 @@ extension MAS { } func run(installedApps: [InstalledApp], searcher: AppStoreSearcher) async throws { + try await mas.run { printer in + try await run(downloader: Downloader(printer: printer), installedApps: installedApps, searcher: searcher) + } + } + + private func run(downloader: Downloader, installedApps: [InstalledApp], searcher: AppStoreSearcher) async throws { let searchTerm = searchTermOptionGroup.searchTerm let results = try await searcher.search(for: searchTerm) guard let result = results.first else { throw MASError.noSearchResultsFound(for: searchTerm) } - - try await install(appID: result.trackId, installedApps: installedApps) + try await install(appID: result.trackId, installedApps: installedApps, downloader: downloader) } /// Installs an app. @@ -48,12 +53,13 @@ extension MAS { /// - Parameters: /// - appID: App ID. /// - installedApps: List of installed apps. + /// - downloader: `Downloader`. /// - Throws: Any error that occurs while attempting to install the app. - private func install(appID: AppID, installedApps: [InstalledApp]) async throws { + private func install(appID: AppID, installedApps: [InstalledApp], downloader: Downloader) async throws { if let installedApp = installedApps.first(where: { $0.id == appID }), !forceOptionGroup.force { - printWarning("Already installed:", installedApp.idAndName) + downloader.printer.warning("Already installed:", installedApp.idAndName) } else { - try await downloadApp(withAppID: appID) + try await downloader.downloadApp(withAppID: appID) } } } diff --git a/Sources/mas/Commands/Open.swift b/Sources/mas/Commands/Open.swift index 3167275ea..429579d9d 100644 --- a/Sources/mas/Commands/Open.swift +++ b/Sources/mas/Commands/Open.swift @@ -31,6 +31,10 @@ extension MAS { } func run(searcher: AppStoreSearcher) async throws { + try await mas.run { try await run(printer: $0, searcher: searcher) } + } + + private func run(printer _: Printer, searcher: AppStoreSearcher) async throws { guard let appID else { // If no app ID was given, just open the MAS GUI app try await openMacAppStore() @@ -43,10 +47,10 @@ extension MAS { private func openMacAppStore() async throws { guard let macappstoreSchemeURL = URL(string: "macappstore:") else { - throw MASError.notSupported + throw MASError.runtimeError("Failed to create URL from macappstore scheme") } guard let appURL = NSWorkspace.shared.urlForApplication(toOpen: macappstoreSchemeURL) else { - throw MASError.notSupported + throw MASError.runtimeError("Failed to find app to open macappstore URLs") } try await NSWorkspace.shared.openApplication(at: appURL, configuration: NSWorkspace.OpenConfiguration()) diff --git a/Sources/mas/Commands/OptionGroups/VerboseOptionGroup.swift b/Sources/mas/Commands/OptionGroups/VerboseOptionGroup.swift index d54cea746..ad20f7dfd 100644 --- a/Sources/mas/Commands/OptionGroups/VerboseOptionGroup.swift +++ b/Sources/mas/Commands/OptionGroups/VerboseOptionGroup.swift @@ -12,13 +12,13 @@ struct VerboseOptionGroup: ParsableArguments { @Flag(help: "Output warnings about app IDs unknown to the Mac App Store") var verbose = false - func printProblem(forError error: Error, expectedAppName appName: String) { + func printProblem(forError error: Error, expectedAppName appName: String, printer: Printer) { guard case MASError.unknownAppID = error else { - printError(error) + printer.error(error: error) return } if verbose { - printWarning(error, "; was expected to identify: ", appName, separator: "") + printer.warning(error, "; was expected to identify: ", appName, separator: "") } } } diff --git a/Sources/mas/Commands/Outdated.swift b/Sources/mas/Commands/Outdated.swift index 38496ad28..7fb8998ea 100644 --- a/Sources/mas/Commands/Outdated.swift +++ b/Sources/mas/Commands/Outdated.swift @@ -20,16 +20,20 @@ extension MAS { var verboseOptionGroup: VerboseOptionGroup /// Runs the command. - func run() async { - await run(installedApps: await installedApps, searcher: ITunesSearchAppStoreSearcher()) + func run() async throws { + try await run(installedApps: await installedApps, searcher: ITunesSearchAppStoreSearcher()) } - func run(installedApps: [InstalledApp], searcher: AppStoreSearcher) async { + func run(installedApps: [InstalledApp], searcher: AppStoreSearcher) async throws { + try await mas.run { await run(printer: $0, installedApps: installedApps, searcher: searcher) } + } + + private func run(printer: Printer, installedApps: [InstalledApp], searcher: AppStoreSearcher) async { for installedApp in installedApps { do { let storeApp = try await searcher.lookup(appID: installedApp.id) if installedApp.isOutdated(comparedTo: storeApp) { - printInfo( + printer.info( installedApp.id, " ", installedApp.name, @@ -42,7 +46,7 @@ extension MAS { ) } } catch { - verboseOptionGroup.printProblem(forError: error, expectedAppName: installedApp.name) + verboseOptionGroup.printProblem(forError: error, expectedAppName: installedApp.name, printer: printer) } } } diff --git a/Sources/mas/Commands/Purchase.swift b/Sources/mas/Commands/Purchase.swift index b499749de..9e2640d9f 100644 --- a/Sources/mas/Commands/Purchase.swift +++ b/Sources/mas/Commands/Purchase.swift @@ -19,23 +19,29 @@ extension MAS { var appIDsOptionGroup: AppIDsOptionGroup /// Runs the command. - func run() async { - await run(installedApps: await installedApps, searcher: ITunesSearchAppStoreSearcher()) + func run() async throws { + try await run(installedApps: await installedApps, searcher: ITunesSearchAppStoreSearcher()) } - func run(installedApps: [InstalledApp], searcher: AppStoreSearcher) async { + func run(installedApps: [InstalledApp], searcher: AppStoreSearcher) async throws { + try await mas.run { printer in + await run(downloader: Downloader(printer: printer), installedApps: installedApps, searcher: searcher) + } + } + + private func run(downloader: Downloader, installedApps: [InstalledApp], searcher: AppStoreSearcher) async { for appID in appIDsOptionGroup.appIDs.filter({ appID in if let installedApp = installedApps.first(where: { $0.id == appID }) { - printWarning("Already purchased:", installedApp.idAndName) + downloader.printer.warning("Already purchased:", installedApp.idAndName) return false } return true }) { do { _ = try await searcher.lookup(appID: appID) - try await downloadApp(withAppID: appID, purchasing: true) + try await downloader.downloadApp(withAppID: appID, purchasing: true) } catch { - printError(error) + downloader.printer.error(error: error) } } } diff --git a/Sources/mas/Commands/Region.swift b/Sources/mas/Commands/Region.swift index a5c3d12a0..d6cb5b37e 100644 --- a/Sources/mas/Commands/Region.swift +++ b/Sources/mas/Commands/Region.swift @@ -17,11 +17,14 @@ extension MAS { /// Runs the command. func run() async throws { + try await mas.run { try await run(printer: $0) } + } + + func run(printer: Printer) async throws { guard let region = await isoRegion else { throw MASError.runtimeError("Failed to obtain the region of the Mac App Store") } - - printInfo(region.alpha2) + printer.info(region.alpha2) } } } diff --git a/Sources/mas/Commands/Reset.swift b/Sources/mas/Commands/Reset.swift index 733c199b2..be28f395a 100644 --- a/Sources/mas/Commands/Reset.swift +++ b/Sources/mas/Commands/Reset.swift @@ -20,7 +20,11 @@ extension MAS { var debug = false /// Runs the command. - func run() { + func run() throws { + try mas.run { run(printer: $0) } + } + + func run(printer: Printer) { // The "Reset Application" command in the Mac App Store debug menu performs // the following steps // @@ -59,9 +63,9 @@ extension MAS { kill.launch() kill.waitUntilExit() - if kill.terminationStatus != 0, debug { + if kill.terminationStatus != 0 { let output = stderr.fileHandleForReading.readDataToEndOfFile() - printError( + printer.error( "killall failed:", String(data: output, encoding: .utf8) ?? "Error info not available", separator: "\n" @@ -73,9 +77,7 @@ extension MAS { do { try FileManager.default.removeItem(atPath: directory) } catch { - if debug { - printError("Failed to delete download directory ", directory, "\n", error, separator: "") - } + printer.error("Failed to delete download directory", directory, error: error) } } } diff --git a/Sources/mas/Commands/Search.swift b/Sources/mas/Commands/Search.swift index a5d66a488..187029899 100644 --- a/Sources/mas/Commands/Search.swift +++ b/Sources/mas/Commands/Search.swift @@ -30,17 +30,16 @@ extension MAS { } func run(searcher: AppStoreSearcher) async throws { - do { - let searchTerm = searchTermOptionGroup.searchTerm - let results = try await searcher.search(for: searchTerm) - if results.isEmpty { - throw MASError.noSearchResultsFound(for: searchTerm) - } + try await mas.run { try await run(printer: $0, searcher: searcher) } + } - printInfo(SearchResultFormatter.format(results, includePrice: price)) - } catch { - throw MASError(searchFailedError: error) + private func run(printer: Printer, searcher: AppStoreSearcher) async throws { + let searchTerm = searchTermOptionGroup.searchTerm + let results = try await searcher.search(for: searchTerm) + guard !results.isEmpty else { + throw MASError.noSearchResultsFound(for: searchTerm) } + printer.info(SearchResultFormatter.format(results, includePrice: price)) } } } diff --git a/Sources/mas/Commands/SignIn.swift b/Sources/mas/Commands/SignIn.swift index 213111710..5ebddb209 100644 --- a/Sources/mas/Commands/SignIn.swift +++ b/Sources/mas/Commands/SignIn.swift @@ -26,9 +26,13 @@ extension MAS { /// Runs the command. func run() throws { + try mas.run { run(printer: $0) } + } + + func run(printer: Printer) { // Signing in is no longer possible as of High Sierra. // https://github.com/mas-cli/mas/issues/164 - throw MASError.notSupported + printer.error(error: MASError.notSupported) } } } diff --git a/Sources/mas/Commands/SignOut.swift b/Sources/mas/Commands/SignOut.swift index de5351c50..d36b89e2e 100644 --- a/Sources/mas/Commands/SignOut.swift +++ b/Sources/mas/Commands/SignOut.swift @@ -18,7 +18,11 @@ extension MAS { ) /// Runs the command. - func run() { + func run() throws { + try mas.run { run(printer: $0) } + } + + func run(printer _: Printer) { ISServiceProxy.genericShared().accountService.signOut() } } diff --git a/Sources/mas/Commands/Uninstall.swift b/Sources/mas/Commands/Uninstall.swift index b31ac276c..ee4eedbf7 100644 --- a/Sources/mas/Commands/Uninstall.swift +++ b/Sources/mas/Commands/Uninstall.swift @@ -28,28 +28,35 @@ extension MAS { } func run(installedApps: [InstalledApp]) throws { + try mas.run { try run(printer: $0, installedApps: installedApps) } + } + + private func run(printer: Printer, installedApps: [InstalledApp]) throws { guard NSUserName() == "root" else { - throw MASError.macOSUserMustBeRoot + throw MASError.runtimeError("Apps installed from the Mac App Store require root permission to remove") } - let uninstallingAppSet = try uninstallingAppSet(fromInstalledApps: installedApps) + let uninstallingAppSet = try uninstallingAppSet(fromInstalledApps: installedApps, printer: printer) guard !uninstallingAppSet.isEmpty else { return } if dryRun { for installedApp in uninstallingAppSet { - printNotice("'", installedApp.name, "' '", installedApp.path, "'", separator: "") + printer.notice("'", installedApp.name, "' '", installedApp.path, "'", separator: "") } - printNotice("(not removed, dry run)") + printer.notice("(not removed, dry run)") } else { - try uninstallApps(atPaths: uninstallingAppSet.map(\.path)) + try uninstallApps(atPaths: uninstallingAppSet.map(\.path), printer: printer) } } - private func uninstallingAppSet(fromInstalledApps installedApps: [InstalledApp]) throws -> Set { + private func uninstallingAppSet( + fromInstalledApps installedApps: [InstalledApp], + printer: Printer + ) throws -> Set { guard let sudoGroupName = ProcessInfo.processInfo.sudoGroupName else { - throw MASError.runtimeError("Failed to determine the original group name") + throw MASError.runtimeError("Failed to get original group name") } guard let sudoGID = ProcessInfo.processInfo.sudoGID else { throw MASError.runtimeError("Failed to get original gid") @@ -59,12 +66,12 @@ extension MAS { } defer { if setegid(0) != 0 { - printWarning("Failed to revert effective group from '", sudoGroupName, "' back to 'wheel'", separator: "") + printer.warning("Failed to revert effective group from '", sudoGroupName, "' back to 'wheel'", separator: "") } } guard let sudoUserName = ProcessInfo.processInfo.sudoUserName else { - throw MASError.runtimeError("Failed to determine the original user name") + throw MASError.runtimeError("Failed to get original user name") } guard let sudoUID = ProcessInfo.processInfo.sudoUID else { throw MASError.runtimeError("Failed to get original uid") @@ -74,7 +81,7 @@ extension MAS { } defer { if seteuid(0) != 0 { - printWarning("Failed to revert effective user from '", sudoUserName, "' back to 'root'", separator: "") + printer.warning("Failed to revert effective user from '", sudoUserName, "' back to 'root'", separator: "") } } @@ -82,7 +89,7 @@ extension MAS { for appID in appIDsOptionGroup.appIDs { let apps = installedApps.filter { $0.id == appID } apps.isEmpty // swiftformat:disable:next indent - ? printError(appID.notInstalledMessage) + ? printer.error(appID.notInstalledMessage) : uninstallingAppSet.formUnion(apps) } return uninstallingAppSet @@ -92,9 +99,11 @@ extension MAS { /// Uninstalls all apps located at any of the elements of `appPaths`. /// -/// - Parameter appPaths: Paths to apps to be uninstalled. +/// - Parameters: +/// - appPaths: Paths to apps to be uninstalled. +/// - printer: `Printer`. /// - Throws: An `Error` if any problem occurs. -private func uninstallApps(atPaths appPaths: [String]) throws { +private func uninstallApps(atPaths appPaths: [String], printer: Printer) throws { let finderItems = try finderItems() guard let uid = ProcessInfo.processInfo.sudoUID else { @@ -105,17 +114,24 @@ private func uninstallApps(atPaths appPaths: [String]) throws { } for appPath in appPaths { - guard let (appUID, appGID) = uidAndGid(forPath: appPath) else { + let attributes = try FileManager.default.attributesOfItem(atPath: appPath) + guard let appUID = attributes[.ownerAccountID] as? uid_t else { + printer.error("Failed to determine uid of", appPath) continue } + guard let appGID = attributes[.groupOwnerAccountID] as? gid_t else { + printer.error("Failed to determine gid of", appPath) + continue + } + guard chown(appPath, uid, gid) == 0 else { - printError("Failed to change ownership of '", appPath, "' to uid ", uid, " & gid ", gid, separator: "") + printer.error("Failed to change ownership of '", appPath, "' to uid ", uid, " & gid ", gid, separator: "") continue } var chownPath = appPath defer { if chown(chownPath, appUID, appGID) != 0 { - printWarning( + printer.warning( "Failed to revert ownership of '", chownPath, "' back to uid ", @@ -129,7 +145,7 @@ private func uninstallApps(atPaths appPaths: [String]) throws { let object = finderItems.object(atLocation: URL(fileURLWithPath: appPath)) guard let item = object as? FinderItem else { - printError( + printer.error( """ Failed to obtain Finder access: finderItems.object(atLocation: URL(fileURLWithPath:\ \"\(appPath)\") is a \(type(of: object)) that does not conform to FinderItem @@ -139,12 +155,12 @@ private func uninstallApps(atPaths appPaths: [String]) throws { } guard let delete = item.delete else { - printError("Failed to obtain Finder access: FinderItem.delete does not exist") + printer.error("Failed to obtain Finder access: FinderItem.delete does not exist") continue } guard let deletedURLString = (delete() as FinderItem).URL else { - printError( + printer.error( """ Failed to revert ownership of deleted '\(appPath)' back to uid \(appUID) & gid \(appGID):\ delete result did not have a URL @@ -154,7 +170,7 @@ private func uninstallApps(atPaths appPaths: [String]) throws { } guard let deletedURL = URL(string: deletedURLString) else { - printError( + printer.error( """ Failed to revert ownership of deleted '\(appPath)' back to uid \(appUID) & gid \(appGID):\ delete result URL is invalid: \(deletedURLString) @@ -164,7 +180,7 @@ private func uninstallApps(atPaths appPaths: [String]) throws { } chownPath = deletedURL.path - printInfo("Deleted '", appPath, "' to '", chownPath, "'", separator: "") + printer.info("Deleted '", appPath, "' to '", chownPath, "'", separator: "") } } @@ -177,21 +193,3 @@ private func finderItems() throws -> SBElementArray { } return items() } - -private func uidAndGid(forPath path: String) -> (uid_t, gid_t)? { - do { - let attributes = try FileManager.default.attributesOfItem(atPath: path) - guard let uid = attributes[.ownerAccountID] as? uid_t else { - printError("Failed to determine uid of", path) - return nil - } - guard let gid = attributes[.groupOwnerAccountID] as? gid_t else { - printError("Failed to determine gid of", path) - return nil - } - return (uid, gid) - } catch { - printError(error) - return nil - } -} diff --git a/Sources/mas/Commands/Upgrade.swift b/Sources/mas/Commands/Upgrade.swift index 32f82c73d..d6f8e8fcf 100644 --- a/Sources/mas/Commands/Upgrade.swift +++ b/Sources/mas/Commands/Upgrade.swift @@ -21,18 +21,24 @@ extension MAS { var appIDOrNames = [String]() /// Runs the command. - func run() async { - await run(installedApps: await installedApps, searcher: ITunesSearchAppStoreSearcher()) + func run() async throws { + try await run(installedApps: await installedApps, searcher: ITunesSearchAppStoreSearcher()) } - func run(installedApps: [InstalledApp], searcher: AppStoreSearcher) async { - let apps = await findOutdatedApps(installedApps: installedApps, searcher: searcher) + func run(installedApps: [InstalledApp], searcher: AppStoreSearcher) async throws { + try await mas.run { printer in + await run(downloader: Downloader(printer: printer), installedApps: installedApps, searcher: searcher) + } + } + + private func run(downloader: Downloader, installedApps: [InstalledApp], searcher: AppStoreSearcher) async { + let apps = await findOutdatedApps(printer: downloader.printer, installedApps: installedApps, searcher: searcher) guard !apps.isEmpty else { return } - printInfo( + downloader.printer.info( "Upgrading ", apps.count, " outdated application", @@ -46,14 +52,15 @@ extension MAS { for appID in apps.map(\.storeApp.trackId) { do { - try await downloadApp(withAppID: appID) + try await downloader.downloadApp(withAppID: appID) } catch { - printError(error) + downloader.printer.error(error: error) } } } private func findOutdatedApps( + printer: Printer, installedApps: [InstalledApp], searcher: AppStoreSearcher ) async -> [(installedApp: InstalledApp, storeApp: SearchResult)] { @@ -64,7 +71,7 @@ extension MAS { // Find installed apps by app ID argument let installedApps = installedApps.filter { $0.id == appID } if installedApps.isEmpty { - printError(appID.notInstalledMessage) + printer.error(appID.notInstalledMessage) } return installedApps } @@ -72,7 +79,7 @@ extension MAS { // Find installed apps by name argument let installedApps = installedApps.filter { $0.name == appIDOrName } if installedApps.isEmpty { - printError("No installed apps named", appIDOrName) + printer.error("No installed apps named", appIDOrName) } return installedApps } @@ -85,7 +92,7 @@ extension MAS { outdatedApps.append((installedApp, storeApp)) } } catch { - verboseOptionGroup.printProblem(forError: error, expectedAppName: installedApp.name) + verboseOptionGroup.printProblem(forError: error, expectedAppName: installedApp.name, printer: printer) } } return outdatedApps diff --git a/Sources/mas/Commands/Vendor.swift b/Sources/mas/Commands/Vendor.swift index f5410776b..295a5b921 100644 --- a/Sources/mas/Commands/Vendor.swift +++ b/Sources/mas/Commands/Vendor.swift @@ -24,11 +24,15 @@ extension MAS { var appIDsOptionGroup: AppIDsOptionGroup /// Runs the command. - func run() async { - await run(searcher: ITunesSearchAppStoreSearcher()) + func run() async throws { + try await run(searcher: ITunesSearchAppStoreSearcher()) } - func run(searcher: AppStoreSearcher) async { + func run(searcher: AppStoreSearcher) async throws { + try await mas.run { await run(printer: $0, searcher: searcher) } + } + + private func run(printer: Printer, searcher: AppStoreSearcher) async { for appID in appIDsOptionGroup.appIDs { do { let result = try await searcher.lookup(appID: appID) @@ -40,7 +44,7 @@ extension MAS { } try await url.open() } catch { - printError(error) + printer.error(error: error) } } } diff --git a/Sources/mas/Commands/Version.swift b/Sources/mas/Commands/Version.swift index 08755a4fe..15d9ba2b9 100644 --- a/Sources/mas/Commands/Version.swift +++ b/Sources/mas/Commands/Version.swift @@ -16,8 +16,12 @@ extension MAS { ) /// Runs the command. - func run() { - printInfo(Package.version) + func run() throws { + try mas.run { run(printer: $0) } + } + + func run(printer: Printer) { + printer.info(Package.version) } } } diff --git a/Sources/mas/Errors/MASError.swift b/Sources/mas/Errors/MASError.swift index b53db0a81..183de5b02 100644 --- a/Sources/mas/Errors/MASError.swift +++ b/Sources/mas/Errors/MASError.swift @@ -10,30 +10,14 @@ internal import Foundation enum MASError: Error, Equatable { case cancelled - case downloadFailed(error: NSError) case jsonParsing(data: Data) - case macOSUserMustBeRoot case noDownloads case noSearchResultsFound(for: String) case noVendorWebsite(forAppID: AppID) case notSupported - case purchaseFailed(error: NSError) case runtimeError(String) - case searchFailed(error: NSError) case unknownAppID(AppID) case urlParsing(String) - - init(downloadFailedError: Error) { - self = (downloadFailedError as? Self) ?? .downloadFailed(error: downloadFailedError as NSError) - } - - init(purchaseFailedError: Error) { - self = (purchaseFailedError as? Self) ?? .purchaseFailed(error: purchaseFailedError as NSError) - } - - init(searchFailedError: Error) { - self = (searchFailedError as? Self) ?? .searchFailed(error: searchFailedError as NSError) - } } extension MASError: CustomStringConvertible { @@ -41,16 +25,12 @@ extension MASError: CustomStringConvertible { switch self { case .cancelled: "Download cancelled" - case let .downloadFailed(error): - "Download failed: \(error.localizedDescription)" case let .jsonParsing(data): if let unparsable = String(data: data, encoding: .utf8) { "Unable to parse response as JSON:\n\(unparsable)" } else { "Unable to parse response as JSON" } - case .macOSUserMustBeRoot: - "Apps installed from the Mac App Store require root permission to remove" case .noDownloads: "No downloads began" case let .noSearchResultsFound(searchTerm): @@ -62,12 +42,8 @@ extension MASError: CustomStringConvertible { This command is not supported on this macOS version due to changes in macOS See https://github.com/mas-cli/mas#known-issues """ - case let .purchaseFailed(error): - "Download request failed: \(error.localizedDescription)" case let .runtimeError(message): - "Runtime error: \(message)" - case let .searchFailed(error): - "Search failed: \(error.localizedDescription)" + message case let .unknownAppID(appID): "No apps found in the Mac App Store for app ID \(appID)" case let .urlParsing(string): diff --git a/Sources/mas/Formatters/Printer.swift b/Sources/mas/Formatters/Printer.swift index d4e5a26c9..61348d202 100644 --- a/Sources/mas/Formatters/Printer.swift +++ b/Sources/mas/Formatters/Printer.swift @@ -6,6 +6,8 @@ // Copyright © 2025 mas-cli. All rights reserved. // +private import ArgumentParser +private import Atomics internal import Foundation // A collection of output formatting helpers @@ -13,81 +15,118 @@ internal import Foundation /// Terminal Control Sequence Indicator. private var csi: String { "\u{001B}[" } -// periphery:ignore -// swiftlint:disable:next unused_declaration -func print(_ items: Any..., to fileHandle: FileHandle, separator: String = " ", terminator: String = "\n") { - print(message(items, separator: separator, terminator: terminator), to: fileHandle) -} +struct Printer { + private let errorCounter = ManagedAtomic(0) + + var errorCount: UInt64 { errorCounter.load(ordering: .acquiring) } -func print(_ message: String, to fileHandle: FileHandle) { - if let data = message.data(using: .utf8) { - fileHandle.write(data) + fileprivate init() { + // Do nothing } -} -/// Prints to `stdout`. -func printInfo(_ items: Any..., separator: String = " ", terminator: String = "\n") { - print(items, separator: separator, terminator: terminator) -} + func log(_ message: String, to fileHandle: FileHandle) { + if let data = message.data(using: .utf8) { + fileHandle.write(data) + } + } -/// Clears current line from `stdout`, then prints to `stdout`, then flushes `stdout`. -func printEphemeral(_ items: Any..., separator: String = " ", terminator: String = "\n") { - clearCurrentLine(fromStream: stdout) - print(items, separator: separator, terminator: terminator) - fflush(stdout) -} + /// Prints to `stdout`. + func info(_ items: Any..., separator: String = " ", terminator: String = "\n") { + print(items, separator: separator, terminator: terminator) + } -/// Clears current line from `stdout`. -func terminateEphemeralPrinting() { - clearCurrentLine(fromStream: stdout) -} + /// Clears current line from `stdout`, then prints to `stdout`, then flushes `stdout`. + func ephemeral(_ items: Any..., separator: String = " ", terminator: String = "\n") { + clearCurrentLine(fromStream: stdout) + print(items, separator: separator, terminator: terminator) + fflush(stdout) + } -/// Prints to `stdout`; if connected to a terminal, prefixes a blue arrow. -func printNotice(_ items: Any..., separator: String = " ", terminator: String = "\n") { - if isatty(fileno(stdout)) != 0 { - // Blue bold arrow, Bold text - print( - "\(csi)1;34m==>\(csi)0m \(csi)1m\(message(items, separator: separator, terminator: terminator))\(csi)0m", - terminator: "" - ) - } else { - print("==> \(message(items, separator: separator, terminator: terminator))", terminator: "") + /// Clears current line from `stdout`. + func terminateEphemeral() { + clearCurrentLine(fromStream: stdout) } -} -/// Prints to `stderr`; if connected to a terminal, prefixes "Warning:" underlined in yellow. -func printWarning(_ items: Any..., separator: String = " ", terminator: String = "\n") { - if isatty(fileno(stderr)) != 0 { - // Yellow, underlined "Warning:" prefix - print( - "\(csi)4;33mWarning:\(csi)0m \(message(items, separator: separator, terminator: terminator))", - to: .standardError - ) - } else { - print("Warning: \(message(items, separator: separator, terminator: terminator))", to: .standardError) + /// Prints to `stdout`; if connected to a terminal, prefixes a blue arrow. + func notice(_ items: Any..., separator: String = " ", terminator: String = "\n") { + if isatty(fileno(stdout)) != 0 { + // Blue bold arrow, Bold text + print( + "\(csi)1;34m==>\(csi)0m \(csi)1m\(message(items, separator: separator, terminator: terminator))\(csi)0m", + terminator: "" + ) + } else { + print("==> \(message(items, separator: separator, terminator: terminator))", terminator: "") + } } -} -/// Prints to `stderr`; if connected to a terminal, prefixes "Error:" underlined in red. -func printError(_ items: Any..., separator: String = " ", terminator: String = "\n") { - if isatty(fileno(stderr)) != 0 { - // Red, underlined "Error:" prefix - print( - "\(csi)4;31mError:\(csi)0m \(message(items, separator: separator, terminator: terminator))", - to: .standardError - ) - } else { - print("Error: \(message(items, separator: separator, terminator: terminator))", to: .standardError) + /// Prints to `stderr`; if connected to a terminal, prefixes "Warning:" underlined in yellow. + func warning(_ items: Any..., separator: String = " ", terminator: String = "\n") { + if isatty(fileno(stderr)) != 0 { + // Yellow, underlined "Warning:" prefix + log( + "\(csi)4;33mWarning:\(csi)0m \(message(items, separator: separator, terminator: terminator))", + to: .standardError + ) + } else { + log("Warning: \(message(items, separator: separator, terminator: terminator))", to: .standardError) + } + } + + /// Prints to `stderr`; if connected to a terminal, prefixes "Error:" underlined in red. + func error(_ items: Any..., error: Error? = nil, separator: String = " ", terminator: String = "\n") { + errorCounter.wrappingIncrement(ordering: .relaxed) + + let terminator = + if let error { + "\(items.isEmpty ? "" : "\n")\(error)\(terminator)" + } else { + terminator + } + + if isatty(fileno(stderr)) != 0 { + // Red, underlined "Error:" prefix + log( + "\(csi)4;31mError:\(csi)0m \(message(items, separator: separator, terminator: terminator))", + to: .standardError + ) + } else { + log("Error: \(message(items, separator: separator, terminator: terminator))", to: .standardError) + } + } + + func clearCurrentLine(fromStream stream: UnsafeMutablePointer) { + if isatty(fileno(stream)) != 0 { + print(csi, "2K", csi, "0G", separator: "", terminator: "") + fflush(stream) + } + } + + private func message(_ items: Any..., separator: String = " ", terminator: String = "\n") -> String { + items.map { String(describing: $0) }.joined(separator: separator).appending(terminator) } } -private func message(_ items: Any..., separator: String = " ", terminator: String = "\n") -> String { - items.map { String(describing: $0) }.joined(separator: separator).appending(terminator) +func run(_ expression: (Printer) throws -> Void) throws { + let printer = Printer() + do { + try expression(printer) + } catch { + printer.error(error: error) + } + if printer.errorCount > 0 { + throw ExitCode(1) + } } -func clearCurrentLine(fromStream stream: UnsafeMutablePointer) { - if isatty(fileno(stream)) != 0 { - print(csi, "2K", csi, "0G", separator: "", terminator: "") - fflush(stream) +func run(_ expression: (Printer) async throws -> Void) async throws { + let printer = Printer() + do { + try await expression(printer) + } catch { + printer.error(error: error) + } + if printer.errorCount > 0 { + throw ExitCode(1) } } diff --git a/Tests/masTests/Commands/AccountSpec.swift b/Tests/masTests/Commands/AccountSpec.swift index eae8eaaf8..b2c51e653 100644 --- a/Tests/masTests/Commands/AccountSpec.swift +++ b/Tests/masTests/Commands/AccountSpec.swift @@ -6,6 +6,7 @@ // Copyright © 2018 mas-cli. All rights reserved. // +private import ArgumentParser private import Nimble import Quick @@ -16,7 +17,7 @@ final class AccountSpec: AsyncSpec { describe("account command") { it("outputs not supported warning") { await expecta(await consequencesOf(try await MAS.Account.parse([]).run())) - == UnvaluedConsequences(MASError.notSupported) + == UnvaluedConsequences(ExitCode(1), "", "Error: \(MASError.notSupported)\n") } } } diff --git a/Tests/masTests/Commands/HomeSpec.swift b/Tests/masTests/Commands/HomeSpec.swift index a1b95dfa3..5d377b316 100644 --- a/Tests/masTests/Commands/HomeSpec.swift +++ b/Tests/masTests/Commands/HomeSpec.swift @@ -6,6 +6,7 @@ // Copyright © 2018 mas-cli. All rights reserved. // +private import ArgumentParser private import Nimble import Quick @@ -18,7 +19,7 @@ final class HomeSpec: AsyncSpec { await expecta( await consequencesOf(try await MAS.Home.parse(["999"]).run(searcher: MockAppStoreSearcher())) ) - == UnvaluedConsequences(nil, "", "Error: No apps found in the Mac App Store for app ID 999\n") + == UnvaluedConsequences(ExitCode(1), "", "Error: No apps found in the Mac App Store for app ID 999\n") } } } diff --git a/Tests/masTests/Commands/InfoSpec.swift b/Tests/masTests/Commands/InfoSpec.swift index e6bfde784..23f326f44 100644 --- a/Tests/masTests/Commands/InfoSpec.swift +++ b/Tests/masTests/Commands/InfoSpec.swift @@ -6,6 +6,7 @@ // Copyright © 2018 mas-cli. All rights reserved. // +private import ArgumentParser private import Nimble import Quick @@ -18,7 +19,7 @@ final class InfoSpec: AsyncSpec { await expecta( await consequencesOf(try await MAS.Info.parse(["999"]).run(searcher: MockAppStoreSearcher())) ) - == UnvaluedConsequences(nil, "", "Error: No apps found in the Mac App Store for app ID 999\n") + == UnvaluedConsequences(ExitCode(1), "", "Error: No apps found in the Mac App Store for app ID 999\n") } it("outputs app details") { let mockResult = SearchResult( diff --git a/Tests/masTests/Commands/ListSpec.swift b/Tests/masTests/Commands/ListSpec.swift index 85b76573f..a20cac67e 100644 --- a/Tests/masTests/Commands/ListSpec.swift +++ b/Tests/masTests/Commands/ListSpec.swift @@ -6,6 +6,7 @@ // Copyright © 2018 mas-cli. All rights reserved. // +private import ArgumentParser private import Nimble import Quick @@ -17,7 +18,7 @@ final class ListSpec: QuickSpec { it("lists apps") { expect(consequencesOf(try MAS.List.parse([]).run(installedApps: []))) == UnvaluedConsequences( - nil, + ExitCode(1), "", """ Error: No installed apps found diff --git a/Tests/masTests/Commands/OpenSpec.swift b/Tests/masTests/Commands/OpenSpec.swift index f62e0aaea..339491be4 100644 --- a/Tests/masTests/Commands/OpenSpec.swift +++ b/Tests/masTests/Commands/OpenSpec.swift @@ -6,6 +6,7 @@ // Copyright © 2019 mas-cli. All rights reserved. // +private import ArgumentParser private import Nimble import Quick @@ -18,7 +19,7 @@ final class OpenSpec: AsyncSpec { await expecta( await consequencesOf(try await MAS.Open.parse(["999"]).run(searcher: MockAppStoreSearcher())) ) - == UnvaluedConsequences(MASError.unknownAppID(999)) + == UnvaluedConsequences(ExitCode(1), "", "Error: \(MASError.unknownAppID(999))\n") } } } diff --git a/Tests/masTests/Commands/SearchSpec.swift b/Tests/masTests/Commands/SearchSpec.swift index b1e2e5a58..3d7148136 100644 --- a/Tests/masTests/Commands/SearchSpec.swift +++ b/Tests/masTests/Commands/SearchSpec.swift @@ -6,6 +6,7 @@ // Copyright © 2018 mas-cli. All rights reserved. // +private import ArgumentParser private import Nimble import Quick @@ -35,7 +36,11 @@ final class SearchSpec: AsyncSpec { try await MAS.Search.parse([searchTerm]).run(searcher: MockAppStoreSearcher()) ) ) - == UnvaluedConsequences(MASError.noSearchResultsFound(for: searchTerm)) + == UnvaluedConsequences( + ExitCode(1), + "", + "Error: No apps found in the Mac App Store for search term: \(searchTerm)\n" + ) } } } diff --git a/Tests/masTests/Commands/SignInSpec.swift b/Tests/masTests/Commands/SignInSpec.swift index d87bf24af..ae49c765f 100644 --- a/Tests/masTests/Commands/SignInSpec.swift +++ b/Tests/masTests/Commands/SignInSpec.swift @@ -6,6 +6,7 @@ // Copyright © 2018 mas-cli. All rights reserved. // +private import ArgumentParser private import Nimble import Quick @@ -15,7 +16,8 @@ final class SignInSpec: QuickSpec { override static func spec() { describe("signin command") { it("signs in") { - expect(consequencesOf(try MAS.SignIn.parse(["", ""]).run())) == UnvaluedConsequences(MASError.notSupported) + expect(consequencesOf(try MAS.SignIn.parse(["", ""]).run())) + == UnvaluedConsequences(ExitCode(1), "", "Error: \(MASError.notSupported)\n") } } } diff --git a/Tests/masTests/Commands/VendorSpec.swift b/Tests/masTests/Commands/VendorSpec.swift index d72401cae..5aa8bfd3b 100644 --- a/Tests/masTests/Commands/VendorSpec.swift +++ b/Tests/masTests/Commands/VendorSpec.swift @@ -6,6 +6,7 @@ // Copyright © 2019 mas-cli. All rights reserved. // +private import ArgumentParser private import Nimble import Quick @@ -18,7 +19,7 @@ final class VendorSpec: AsyncSpec { await expecta( await consequencesOf(try await MAS.Vendor.parse(["999"]).run(searcher: MockAppStoreSearcher())) ) - == UnvaluedConsequences(nil, "", "Error: No apps found in the Mac App Store for app ID 999\n") + == UnvaluedConsequences(ExitCode(1), "", "Error: No apps found in the Mac App Store for app ID 999\n") } } } diff --git a/Tests/masTests/Utilities/UnvaluedConsequences.swift b/Tests/masTests/Utilities/UnvaluedConsequences.swift index 7bb58bb64..ee47fbba1 100644 --- a/Tests/masTests/Utilities/UnvaluedConsequences.swift +++ b/Tests/masTests/Utilities/UnvaluedConsequences.swift @@ -36,14 +36,14 @@ struct UnvaluedConsequences: Equatable { func consequencesOf( streamEncoding: String.Encoding = .utf8, - _ expression: @autoclosure @escaping () throws -> Void + _ expression: @autoclosure () throws -> Void ) -> UnvaluedConsequences { consequences(streamEncoding: streamEncoding, expression) } func consequencesOf( streamEncoding: String.Encoding = .utf8, - _ expression: @autoclosure @escaping () async throws -> Void + _ expression: @autoclosure () async throws -> Void ) async -> UnvaluedConsequences { await consequences(streamEncoding: streamEncoding, expression) } @@ -51,7 +51,7 @@ func consequencesOf( // periphery:ignore func consequencesOf( streamEncoding: String.Encoding = .utf8, - _ body: @escaping () throws -> Void + _ body: () throws -> Void ) -> UnvaluedConsequences { consequences(streamEncoding: streamEncoding, body) } @@ -59,14 +59,14 @@ func consequencesOf( // periphery:ignore func consequencesOf( streamEncoding: String.Encoding = .utf8, - _ body: @escaping () async throws -> Void + _ body: () async throws -> Void ) async -> UnvaluedConsequences { await consequences(streamEncoding: streamEncoding, body) } private func consequences( streamEncoding: String.Encoding = .utf8, - _ body: @escaping () throws -> Void + _ body: () throws -> Void ) -> UnvaluedConsequences { let outOriginalFD = fileno(stdout) let errOriginalFD = fileno(stderr) @@ -112,7 +112,7 @@ private func consequences( private func consequences( streamEncoding: String.Encoding = .utf8, - _ body: @escaping () async throws -> Void + _ body: () async throws -> Void ) async -> UnvaluedConsequences { let outOriginalFD = fileno(stdout) let errOriginalFD = fileno(stderr) diff --git a/Tests/masTests/Utilities/ValuedConsequences.swift b/Tests/masTests/Utilities/ValuedConsequences.swift index a9e3294aa..5c0334282 100644 --- a/Tests/masTests/Utilities/ValuedConsequences.swift +++ b/Tests/masTests/Utilities/ValuedConsequences.swift @@ -38,14 +38,14 @@ struct ValuedConsequences: Equatable { func consequencesOf( streamEncoding: String.Encoding = .utf8, - _ expression: @autoclosure @escaping () throws -> E + _ expression: @autoclosure () throws -> E ) -> ValuedConsequences { consequences(streamEncoding: streamEncoding, expression) } func consequencesOf( streamEncoding: String.Encoding = .utf8, - _ expression: @autoclosure @escaping () async throws -> E + _ expression: @autoclosure () async throws -> E ) async -> ValuedConsequences { await consequences(streamEncoding: streamEncoding, expression) } @@ -53,7 +53,7 @@ func consequencesOf( // periphery:ignore func consequencesOf( streamEncoding: String.Encoding = .utf8, - _ body: @escaping () throws -> E + _ body: () throws -> E ) -> ValuedConsequences { consequences(streamEncoding: streamEncoding, body) } @@ -61,14 +61,14 @@ func consequencesOf( // periphery:ignore func consequencesOf( streamEncoding: String.Encoding = .utf8, - _ body: @escaping () async throws -> E + _ body: () async throws -> E ) async -> ValuedConsequences { await consequences(streamEncoding: streamEncoding, body) } private func consequences( streamEncoding: String.Encoding = .utf8, - _ body: @escaping () throws -> E + _ body: () throws -> E ) -> ValuedConsequences { let outOriginalFD = fileno(stdout) let errOriginalFD = fileno(stderr) @@ -116,7 +116,7 @@ private func consequences( private func consequences( streamEncoding: String.Encoding = .utf8, - _ body: @escaping () async throws -> E + _ body: () async throws -> E ) async -> ValuedConsequences { let outOriginalFD = fileno(stdout) let errOriginalFD = fileno(stderr) diff --git a/docs/sample.swift b/docs/sample.swift index 290789aeb..6240769f3 100644 --- a/docs/sample.swift +++ b/docs/sample.swift @@ -56,7 +56,7 @@ APIClient.getAwesomeness { [weak self] result in /// Use if-let to check for not `nil` (even if using an implicitly unwrapped variable from an API). func someUnauditedAPI(thing: String?) { if let thing { - printInfo(thing) + printer.info(thing) } } @@ -70,9 +70,9 @@ func doSomeWork() -> Response { switch response { case .success(let data): - printInfo("The response returned successfully", data) + printer.info("The response returned successfully", data) case .failure(let error): - printError("An error occurred:", error) + printer.error("An error occurred:", error: error) } // MARK: Organization