From 52a0638f2bfa25f86082135f2d66e71324834d96 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Sat, 26 Oct 2024 11:43:08 -0400 Subject: [PATCH 01/21] Improve help output. Make fish shell completions more consistent with the help output. Resolve #543 Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/Commands/Account.swift | 2 +- Sources/mas/Commands/Home.swift | 4 +-- Sources/mas/Commands/Info.swift | 2 +- Sources/mas/Commands/Install.swift | 6 ++-- Sources/mas/Commands/List.swift | 2 +- Sources/mas/Commands/Lucky.swift | 10 +++++-- Sources/mas/Commands/Open.swift | 4 +-- Sources/mas/Commands/Outdated.swift | 4 +-- Sources/mas/Commands/Purchase.swift | 4 +-- Sources/mas/Commands/Reset.swift | 4 +-- Sources/mas/Commands/Search.swift | 4 +-- Sources/mas/Commands/SignIn.swift | 4 +-- Sources/mas/Commands/SignOut.swift | 2 +- Sources/mas/Commands/Uninstall.swift | 6 ++-- Sources/mas/Commands/Upgrade.swift | 11 ++++---- Sources/mas/Commands/Vendor.swift | 4 +-- Sources/mas/Commands/Version.swift | 2 +- contrib/completion/mas.fish | 42 +++++++++++++++------------- 18 files changed, 63 insertions(+), 54 deletions(-) diff --git a/Sources/mas/Commands/Account.swift b/Sources/mas/Commands/Account.swift index a9f90a6..328e612 100644 --- a/Sources/mas/Commands/Account.swift +++ b/Sources/mas/Commands/Account.swift @@ -12,7 +12,7 @@ import StoreFoundation extension MAS { struct Account: ParsableCommand { static let configuration = CommandConfiguration( - abstract: "Prints the primary account Apple ID" + abstract: "Display the Apple ID signed in in the Mac App Store" ) /// Runs the command. diff --git a/Sources/mas/Commands/Home.swift b/Sources/mas/Commands/Home.swift index c9e186e..8d86a5f 100644 --- a/Sources/mas/Commands/Home.swift +++ b/Sources/mas/Commands/Home.swift @@ -13,10 +13,10 @@ extension MAS { /// https://performance-partners.apple.com/search-api struct Home: ParsableCommand { static let configuration = CommandConfiguration( - abstract: "Opens MAS Preview app page in a browser" + abstract: "Open app's Mac App Store web page in the default web browser" ) - @Argument(help: "ID of app to show on MAS Preview") + @Argument(help: "App ID") var appID: AppID /// Runs the command. diff --git a/Sources/mas/Commands/Info.swift b/Sources/mas/Commands/Info.swift index 5352097..5cab3fd 100644 --- a/Sources/mas/Commands/Info.swift +++ b/Sources/mas/Commands/Info.swift @@ -17,7 +17,7 @@ extension MAS { abstract: "Display app information from the Mac App Store" ) - @Argument(help: "ID of app to show info") + @Argument(help: "App ID") var appID: AppID /// Runs the command. diff --git a/Sources/mas/Commands/Install.swift b/Sources/mas/Commands/Install.swift index 9b051e7..7247172 100644 --- a/Sources/mas/Commands/Install.swift +++ b/Sources/mas/Commands/Install.swift @@ -13,12 +13,12 @@ extension MAS { /// Installs previously purchased apps from the Mac App Store. struct Install: ParsableCommand { static let configuration = CommandConfiguration( - abstract: "Install from the Mac App Store" + abstract: "Install previously purchased app(s) from the Mac App Store" ) - @Flag(help: "force reinstall") + @Flag(help: "Force reinstall") var force = false - @Argument(help: "app ID(s) to install") + @Argument(help: "App ID(s)") var appIDs: [AppID] /// Runs the command. diff --git a/Sources/mas/Commands/List.swift b/Sources/mas/Commands/List.swift index 94544c9..6817fe5 100644 --- a/Sources/mas/Commands/List.swift +++ b/Sources/mas/Commands/List.swift @@ -12,7 +12,7 @@ extension MAS { /// Command which lists all installed apps. struct List: ParsableCommand { static let configuration = CommandConfiguration( - abstract: "Lists apps from the Mac App Store which are currently installed" + abstract: "List apps installed from the Mac App Store for the Apple ID of the current macOS user" ) /// Runs the command. diff --git a/Sources/mas/Commands/Lucky.swift b/Sources/mas/Commands/Lucky.swift index 781f55f..400e5ef 100644 --- a/Sources/mas/Commands/Lucky.swift +++ b/Sources/mas/Commands/Lucky.swift @@ -15,12 +15,16 @@ extension MAS { /// This is handy as many MAS titles can be long with embedded keywords. struct Lucky: ParsableCommand { static let configuration = CommandConfiguration( - abstract: "Install the first result from the Mac App Store" + abstract: + """ + Install the first app returned from searching the Mac App Store + (app must have been previously purchased) + """ ) - @Flag(help: "force reinstall") + @Flag(help: "Force reinstall") var force = false - @Argument(help: "the app name to install") + @Argument(help: "Search term") var searchTerm: String /// Runs the command. diff --git a/Sources/mas/Commands/Open.swift b/Sources/mas/Commands/Open.swift index a924895..365715e 100644 --- a/Sources/mas/Commands/Open.swift +++ b/Sources/mas/Commands/Open.swift @@ -16,10 +16,10 @@ extension MAS { /// https://performance-partners.apple.com/search-api struct Open: ParsableCommand { static let configuration = CommandConfiguration( - abstract: "Opens app page in 'App Store.app'" + abstract: "Open app page in 'App Store.app'" ) - @Argument(help: "the app ID") + @Argument(help: "App ID") var appID: AppID? /// Runs the command. diff --git a/Sources/mas/Commands/Outdated.swift b/Sources/mas/Commands/Outdated.swift index a495c1b..81b447a 100644 --- a/Sources/mas/Commands/Outdated.swift +++ b/Sources/mas/Commands/Outdated.swift @@ -15,10 +15,10 @@ extension MAS { /// ready to be installed from the Mac App Store. struct Outdated: ParsableCommand { static let configuration = CommandConfiguration( - abstract: "Lists pending updates from the Mac App Store" + abstract: "List pending app updates from the Mac App Store for the Apple ID of the current macOS user" ) - @Flag(help: "Show warnings about apps") + @Flag(help: "Display warnings about apps unknown to the Mac App Store") var verbose = false /// Runs the command. diff --git a/Sources/mas/Commands/Purchase.swift b/Sources/mas/Commands/Purchase.swift index d775c64..bdb8a58 100644 --- a/Sources/mas/Commands/Purchase.swift +++ b/Sources/mas/Commands/Purchase.swift @@ -12,10 +12,10 @@ import CommerceKit extension MAS { struct Purchase: ParsableCommand { static let configuration = CommandConfiguration( - abstract: "Purchase and download free apps from the Mac App Store" + abstract: "\"Purchase\" and install free apps from the Mac App Store" ) - @Argument(help: "app ID(s) to install") + @Argument(help: "App ID(s)") var appIDs: [AppID] /// Runs the command. diff --git a/Sources/mas/Commands/Reset.swift b/Sources/mas/Commands/Reset.swift index 37ff3e4..055fd2a 100644 --- a/Sources/mas/Commands/Reset.swift +++ b/Sources/mas/Commands/Reset.swift @@ -13,10 +13,10 @@ extension MAS { /// Kills several macOS processes as a means to reset the app store. struct Reset: ParsableCommand { static let configuration = CommandConfiguration( - abstract: "Resets the Mac App Store" + abstract: "Reset Mac App Store running processes" ) - @Flag(help: "Enable debug mode") + @Flag(help: "Output debug information") var debug = false /// Runs the command. diff --git a/Sources/mas/Commands/Search.swift b/Sources/mas/Commands/Search.swift index ae36163..9fffa22 100644 --- a/Sources/mas/Commands/Search.swift +++ b/Sources/mas/Commands/Search.swift @@ -17,9 +17,9 @@ extension MAS { abstract: "Search for apps from the Mac App Store" ) - @Flag(help: "Show price of found apps") + @Flag(help: "Display the price of each app") var price = false - @Argument(help: "the app name to search") + @Argument(help: "Search term") var searchTerm: String func run() throws { diff --git a/Sources/mas/Commands/SignIn.swift b/Sources/mas/Commands/SignIn.swift index c94da79..300acdf 100644 --- a/Sources/mas/Commands/SignIn.swift +++ b/Sources/mas/Commands/SignIn.swift @@ -13,10 +13,10 @@ extension MAS { struct SignIn: ParsableCommand { static let configuration = CommandConfiguration( commandName: "signin", - abstract: "Sign in to the Mac App Store" + abstract: "Sign in to an Apple ID in the Mac App Store" ) - @Flag(help: "Complete login with graphical dialog") + @Flag(help: "Provide password via graphical dialog") var dialog = false @Argument(help: "Apple ID") var appleID: String diff --git a/Sources/mas/Commands/SignOut.swift b/Sources/mas/Commands/SignOut.swift index 1c0d49d..6f55e76 100644 --- a/Sources/mas/Commands/SignOut.swift +++ b/Sources/mas/Commands/SignOut.swift @@ -13,7 +13,7 @@ extension MAS { struct SignOut: ParsableCommand { static let configuration = CommandConfiguration( commandName: "signout", - abstract: "Sign out of the Mac App Store" + abstract: "Sign out of the Apple ID currently signed in in the Mac App Store" ) /// Runs the command. diff --git a/Sources/mas/Commands/Uninstall.swift b/Sources/mas/Commands/Uninstall.swift index d9997a7..343d9df 100644 --- a/Sources/mas/Commands/Uninstall.swift +++ b/Sources/mas/Commands/Uninstall.swift @@ -13,13 +13,13 @@ extension MAS { /// Command which uninstalls apps managed by the Mac App Store. struct Uninstall: ParsableCommand { static let configuration = CommandConfiguration( - abstract: "Uninstall app installed from the Mac App Store" + abstract: "Uninstall app installed from the Mac App Store for the Apple ID of the current macOS user" ) /// Flag indicating that removal shouldn't be performed. - @Flag(help: "dry run") + @Flag(help: "Perform dry run") var dryRun = false - @Argument(help: "ID of app to uninstall") + @Argument(help: "App ID") var appID: AppID /// Runs the uninstall command. diff --git a/Sources/mas/Commands/Upgrade.swift b/Sources/mas/Commands/Upgrade.swift index 1c1630d..25743f0 100644 --- a/Sources/mas/Commands/Upgrade.swift +++ b/Sources/mas/Commands/Upgrade.swift @@ -14,11 +14,12 @@ extension MAS { /// Command which upgrades apps with new versions available in the Mac App Store. struct Upgrade: ParsableCommand { static let configuration = CommandConfiguration( - abstract: "Upgrade outdated apps from the Mac App Store" + abstract: + "Upgrade outdated app(s) installed from the Mac App Store for the Apple ID of the current macOS user" ) - @Argument(help: "app(s) to upgrade") - var appIDs: [String] = [] + @Argument(help: "App ID(s)/app name(s)") + var appIDOrNames: [String] = [] /// Runs the command. func run() throws { @@ -56,9 +57,9 @@ extension MAS { searcher: AppStoreSearcher ) throws -> [(SoftwareProduct, SearchResult)] { let apps = - appIDs.isEmpty + appIDOrNames.isEmpty ? appLibrary.installedApps - : appIDs.flatMap { appID in + : appIDOrNames.flatMap { appID in if let appID = AppID(appID) { // argument is an AppID, lookup apps by id using argument return appLibrary.installedApps(withAppID: appID) diff --git a/Sources/mas/Commands/Vendor.swift b/Sources/mas/Commands/Vendor.swift index 31189e3..d303c94 100644 --- a/Sources/mas/Commands/Vendor.swift +++ b/Sources/mas/Commands/Vendor.swift @@ -13,10 +13,10 @@ extension MAS { /// https://performance-partners.apple.com/search-api struct Vendor: ParsableCommand { static let configuration = CommandConfiguration( - abstract: "Opens vendor's app page in a browser" + abstract: "Open vendor's app web page in the default web browser" ) - @Argument(help: "the app ID to show the vendor's website") + @Argument(help: "App ID") var appID: AppID /// Runs the command. diff --git a/Sources/mas/Commands/Version.swift b/Sources/mas/Commands/Version.swift index 4493088..d2f67d7 100644 --- a/Sources/mas/Commands/Version.swift +++ b/Sources/mas/Commands/Version.swift @@ -12,7 +12,7 @@ extension MAS { /// Command which displays the version of the mas tool. struct Version: ParsableCommand { static let configuration = CommandConfiguration( - abstract: "Print version number" + abstract: "Display version number" ) /// Runs the command. diff --git a/contrib/completion/mas.fish b/contrib/completion/mas.fish index de9576f..eccb749 100644 --- a/contrib/completion/mas.fish +++ b/contrib/completion/mas.fish @@ -23,61 +23,65 @@ end complete -c mas -f ### account -complete -c mas -n "__fish_use_subcommand" -f -a account -d "Prints the primary account Apple ID" +complete -c mas -n "__fish_use_subcommand" -f -a account -d "Display the Apple ID signed in in the Mac App Store" complete -c mas -n "__fish_seen_subcommand_from help" -xa "account" ### help complete -c mas -n "__fish_use_subcommand" -f -a help -d "Display 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 "Opens MAS Preview app page in a browser" +complete -c mas -n "__fish_use_subcommand" -f -a home -d "Open app's Mac App Store web page 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 vendor" -xa "(__fish_mas_list_available)" +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_seen_subcommand_from help" -xa "info" ### install -complete -c mas -n "__fish_use_subcommand" -f -a install -d "Install from the Mac App Store" +complete -c mas -n "__fish_use_subcommand" -f -a install -d "Install previously purchased app(s) from the Mac App Store" complete -c mas -n "__fish_seen_subcommand_from help" -xa "install" complete -c mas -n "__fish_seen_subcommand_from install lucky" -l force -d "Force reinstall" ### list -complete -c mas -n "__fish_use_subcommand" -f -a list -d "Lists apps from the Mac App Store which are currently installed" +complete -c mas -n "__fish_use_subcommand" -f -a list -d "List apps installed from the Mac App Store for the Apple ID of the current macOS user" complete -c mas -n "__fish_seen_subcommand_from help" -xa "list" ### lucky -complete -c mas -n "__fish_use_subcommand" -f -a lucky -d "Install the first result from the Mac App Store" +complete -c mas -n "__fish_use_subcommand" -f -a lucky -d "Install the first app returned from searching the Mac App Store (app must have been previously purchased)" complete -c mas -n "__fish_seen_subcommand_from help" -xa "lucky" ### open -complete -c mas -n "__fish_use_subcommand" -f -a open -d "Opens app page in 'App Store.app'" +complete -c mas -n "__fish_use_subcommand" -f -a open -d "Open app page in 'App Store.app'" complete -c mas -n "__fish_seen_subcommand_from help" -xa "open" ### outdated -complete -c mas -n "__fish_use_subcommand" -f -a outdated -d "Lists pending updates from the Mac App Store" +complete -c mas -n "__fish_use_subcommand" -f -a outdated -d "List pending app updates from the Mac App Store for the Apple ID of the current macOS user" 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 apps unknown to the Mac App Store" +### purchase +complete -c mas -n "__fish_use_subcommand" -f -a purchase -d "\"Purchase\" and install free apps from the Mac App Store" +complete -c mas -n "__fish_seen_subcommand_from help" -xa "purchase" ### reset -complete -c mas -n "__fish_use_subcommand" -f -a reset -d "Resets the Mac App Store" +complete -c mas -n "__fish_use_subcommand" -f -a reset -d "Reset Mac App Store running processes" complete -c mas -n "__fish_seen_subcommand_from help" -xa "reset" -complete -c mas -n "__fish_seen_subcommand_from reset" -l debug -d "Enable debug mode" +complete -c mas -n "__fish_seen_subcommand_from reset" -l debug -d "Output debug information" ### search complete -c mas -n "__fish_use_subcommand" -f -a search -d "Search for apps from 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 "Show price of found apps" +complete -c mas -n "__fish_seen_subcommand_from search" -l price -d "Display the price of each app" ### signin -complete -c mas -n "__fish_use_subcommand" -f -a signin -d "Sign in to the Mac App Store" +complete -c mas -n "__fish_use_subcommand" -f -a signin -d "Sign in to an Apple ID in the Mac App Store" complete -c mas -n "__fish_seen_subcommand_from help" -xa "signin" -complete -c mas -n "__fish_seen_subcommand_from signin" -l dialog -d "Complete login with graphical dialog" +complete -c mas -n "__fish_seen_subcommand_from signin" -l dialog -d "Provide password via graphical dialog" ### signout -complete -c mas -n "__fish_use_subcommand" -f -a signout -d "Sign out of the Mac App Store" +complete -c mas -n "__fish_use_subcommand" -f -a signout -d "Sign out of the Apple ID currently signed in in the Mac App Store" complete -c mas -n "__fish_seen_subcommand_from help" -xa "signout" ### uninstall -complete -c mas -n "__fish_use_subcommand" -f -a uninstall -d "Uninstall app installed from the Mac App Store" +complete -c mas -n "__fish_use_subcommand" -f -a uninstall -d "Uninstall app installed from the Mac App Store for the Apple ID of the current macOS user" complete -c mas -n "__fish_seen_subcommand_from help" -xa "uninstall" -complete -c mas -n "__fish_seen_subcommand_from uninstall" -l dry-run -d "Dry run mode" +complete -c mas -n "__fish_seen_subcommand_from uninstall" -l dry-run -d "Perform dry run" complete -c mas -n "__fish_seen_subcommand_from uninstall" -x -a "(__fish_mas_list_installed)" ### upgrade -complete -c mas -n "__fish_use_subcommand" -f -a upgrade -d "Upgrade outdated apps from the Mac App Store" +complete -c mas -n "__fish_use_subcommand" -f -a upgrade -d "Upgrade outdated app(s) from the Mac App Store for the Apple ID of the current macOS user" complete -c mas -n "__fish_seen_subcommand_from help" -xa "upgrade" complete -c mas -n "__fish_seen_subcommand_from upgrade" -x -a "(__fish_mas_outdated_installed)" ### vendor -complete -c mas -n "__fish_use_subcommand" -f -a vendor -d "Opens vendor's app page in a browser" +complete -c mas -n "__fish_use_subcommand" -f -a vendor -d "Open vendor's app web page 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 "Print version number" +complete -c mas -n "__fish_use_subcommand" -f -a version -d "Display version number" complete -c mas -n "__fish_seen_subcommand_from help" -xa "version" From f83412bba188aed5076ffb829f4a90435f1d3ac8 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Sun, 27 Oct 2024 13:47:17 -0400 Subject: [PATCH 02/21] Increase minimum macOS version to 10.13, since Swift 5.7 is already used, which requires Xcode 14+ to compile, which only supports macOS deployment targets 10.13+. Use Swift 5.7.1, which is the newest version of Swift 5.7. Resolve #578 Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- .swift-version | 2 +- Makefile | 8 +- Package.swift | 4 +- Package/Distribution.plist | 2 +- README.md | 3 +- Sources/mas/AppStore/ISStoreAccount.swift | 88 ++++--------------- Sources/mas/Commands/SignOut.swift | 8 +- .../ExternalCommands/ExternalCommand.swift | 11 +-- Tests/masTests/Commands/SignInSpec.swift | 2 +- script/bottle | 4 +- 10 files changed, 28 insertions(+), 104 deletions(-) diff --git a/.swift-version b/.swift-version index 760606e..64ff7de 100644 --- a/.swift-version +++ b/.swift-version @@ -1 +1 @@ -5.7 +5.7.1 diff --git a/Makefile b/Makefile index 9b85b78..ec483e0 100644 --- a/Makefile +++ b/Makefile @@ -12,13 +12,7 @@ CMD_NAME = mas SHELL = /bin/sh PREFIX ?= /usr/local -# trunk -# SWIFT_VERSION = swift-DEVELOPMENT-SNAPSHOT-2020-04-23-a - -# Swift 5.3 -# SWIFT_VERSION = swift-5.3-DEVELOPMENT-SNAPSHOT-2020-04-21-a - -SWIFT_VERSION = 5.7 +SWIFT_VERSION = 5.7.1 # set EXECUTABLE_DIRECTORY according to your specific environment # run swift build and see where the output executable is created diff --git a/Package.swift b/Package.swift index 4e48ff7..c86eb3d 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.6.1 +// swift-tools-version:5.7.1 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "mas", platforms: [ - .macOS(.v10_11) + .macOS(.v10_13) ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. diff --git a/Package/Distribution.plist b/Package/Distribution.plist index 09ab7eb..571cb27 100644 --- a/Package/Distribution.plist +++ b/Package/Distribution.plist @@ -5,7 +5,7 @@ - + diff --git a/README.md b/README.md index 5f8c452..96b0ebf 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,8 @@ sudo port install mas #### 🍻 Custom Homebrew tap We provide a [custom Homebrew tap](https://github.com/mas-cli/homebrew-tap) with pre-built bottles -for all macOS versions since 10.11 (El Capitan). +for all macOS versions since 10.11 (El Capitan). The newest versions of mas, however, are only available +for macOS 10.13+ (High Sierra or newer). To install mas from our tap: diff --git a/Sources/mas/AppStore/ISStoreAccount.swift b/Sources/mas/AppStore/ISStoreAccount.swift index 56b7bd6..b8fc765 100644 --- a/Sources/mas/AppStore/ISStoreAccount.swift +++ b/Sources/mas/AppStore/ISStoreAccount.swift @@ -14,81 +14,23 @@ private let timeout = 30.0 extension ISStoreAccount: StoreAccount { static var primaryAccount: Promise { - if #available(macOS 10.13, *) { - return race( - Promise { seal in - ISServiceProxy.genericShared().accountService - .primaryAccount { storeAccount in - seal.fulfill(storeAccount) - } - }, - after(seconds: timeout) - .then { - Promise(error: MASError.notSignedIn) + race( + Promise { seal in + ISServiceProxy.genericShared().accountService + .primaryAccount { storeAccount in + seal.fulfill(storeAccount) } - ) - } - - return .value(CKAccountStore.shared().primaryAccount) + }, + after(seconds: timeout) + .then { + Promise(error: MASError.notSignedIn) + } + ) } - static func signIn(appleID: String, password: String, systemDialog: Bool) -> Promise { - // swift-format-ignore: UseEarlyExits - if #available(macOS 10.13, *) { - // Signing in is no longer possible as of High Sierra. - // https://github.com/mas-cli/mas/issues/164 - return Promise(error: MASError.notSupported) - // swiftlint:disable:next superfluous_else - } else { - return - primaryAccount - .then { account -> Promise in - if account.isSignedIn { - return Promise(error: MASError.alreadySignedIn(asAppleID: account.identifier)) - } - - let password = - password.isEmpty && !systemDialog - ? String(validatingUTF8: getpass("Password: ")) ?? "" - : password - - guard !password.isEmpty || systemDialog else { - return Promise(error: MASError.noPasswordProvided) - } - - let context = ISAuthenticationContext(accountID: 0) - context.appleIDOverride = appleID - - let signInPromise = - Promise { seal in - let accountService = ISServiceProxy.genericShared().accountService - accountService.setStoreClient(ISStoreClient(storeClientType: 0)) - accountService.signIn(with: context) { success, storeAccount, error in - if success, let storeAccount { - seal.fulfill(storeAccount) - } else { - seal.reject(MASError.signInFailed(error: error as NSError?)) - } - } - } - - if systemDialog { - return signInPromise - } - - context.demoMode = true - context.demoAccountName = appleID - context.demoAccountPassword = password - context.demoAutologinMode = true - - return race( - signInPromise, - after(seconds: timeout) - .then { - Promise(error: MASError.signInFailed(error: nil)) - } - ) - } - } + static func signIn(appleID _: String, password _: String, systemDialog _: Bool) -> Promise { + // Signing in is no longer possible as of High Sierra. + // https://github.com/mas-cli/mas/issues/164 + Promise(error: MASError.notSupported) } } diff --git a/Sources/mas/Commands/SignOut.swift b/Sources/mas/Commands/SignOut.swift index 6f55e76..45cf9a5 100644 --- a/Sources/mas/Commands/SignOut.swift +++ b/Sources/mas/Commands/SignOut.swift @@ -18,13 +18,7 @@ extension MAS { /// Runs the command. func run() throws { - if #available(macOS 10.13, *) { - ISServiceProxy.genericShared().accountService.signOut() - } else { - // Using CKAccountStore to sign out does nothing on High Sierra - // https://github.com/mas-cli/mas/issues/129 - CKAccountStore.shared().signOut() - } + ISServiceProxy.genericShared().accountService.signOut() } } } diff --git a/Sources/mas/ExternalCommands/ExternalCommand.swift b/Sources/mas/ExternalCommands/ExternalCommand.swift index 2168a05..b5a3487 100644 --- a/Sources/mas/ExternalCommands/ExternalCommand.swift +++ b/Sources/mas/ExternalCommands/ExternalCommand.swift @@ -56,15 +56,8 @@ extension ExternalCommand { process.standardOutput = stdoutPipe process.standardError = stderrPipe process.arguments = arguments - - if #available(macOS 10.13, *) { - process.executableURL = URL(fileURLWithPath: binaryPath) - try process.run() - } else { - process.launchPath = binaryPath - process.launch() - } - + process.executableURL = URL(fileURLWithPath: binaryPath) + try process.run() process.waitUntilExit() } } diff --git a/Tests/masTests/Commands/SignInSpec.swift b/Tests/masTests/Commands/SignInSpec.swift index ceea01c..57b8292 100644 --- a/Tests/masTests/Commands/SignInSpec.swift +++ b/Tests/masTests/Commands/SignInSpec.swift @@ -17,7 +17,7 @@ public class SignInSpec: QuickSpec { beforeSuite { MAS.initialize() } - // account command disabled since macOS 10.13 High Sierra https://github.com/mas-cli/mas#known-issues + // signin command disabled since macOS 10.13 High Sierra: https://github.com/mas-cli/mas#known-issues describe("signin command") { it("signs in") { expect { diff --git a/script/bottle b/script/bottle index a54733a..f4cb48c 100755 --- a/script/bottle +++ b/script/bottle @@ -17,8 +17,8 @@ BOTTLE_DIR="$BUILD_DIR/bottles" VERSION=$(script/version) ROOT_URL="https://github.com/mas-cli/mas/releases/download/v${VERSION}" -# Supports macOS 10.11 and later -OS_NAMES=(arm64_monterey monterey arm64_big_sur big_sur catalina mojave high_sierra sierra el_capitan) +# Supports macOS 10.13 and later +OS_NAMES=(arm64_monterey monterey arm64_big_sur big_sur catalina mojave high_sierra) # Semantic version number split into a list using Ugly, bash 3 compatible syntax IFS=" " read -r -a CURRENT_OS_VERSION <<<"$(sw_vers -productVersion | sed 's/\./ /g'))" From 705563ced99694dbc05d0c2d2583081575fdfc62 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Sun, 27 Oct 2024 14:53:38 -0400 Subject: [PATCH 03/21] Upgrade PromiseKit to 8.1.2. Partial #613 Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Package.resolved | 4 ++-- Package.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.resolved b/Package.resolved index 80d3efe..b8cf4ef 100644 --- a/Package.resolved +++ b/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/mxcl/PromiseKit.git", "state" : { - "revision" : "8a98e31a47854d3180882c8068cc4d9381bf382d", - "version" : "6.22.1" + "revision" : "6fcc08077124e9747f1ec7bd8bb78f5caffe5a79", + "version" : "8.1.2" } }, { diff --git a/Package.swift b/Package.swift index c86eb3d..79429f6 100644 --- a/Package.swift +++ b/Package.swift @@ -20,7 +20,7 @@ let package = Package( .package(url: "https://github.com/Quick/Nimble.git", from: "10.0.0"), .package(url: "https://github.com/Quick/Quick.git", from: "5.0.1"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), - .package(url: "https://github.com/mxcl/PromiseKit.git", from: "6.22.1"), + .package(url: "https://github.com/mxcl/PromiseKit.git", from: "8.1.2"), .package(url: "https://github.com/mxcl/Version.git", from: "2.1.0"), .package(url: "https://github.com/sharplet/Regex.git", from: "2.1.1"), ], From e79e6283ac90272a738e57f32dd8e1ff744d7297 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Sun, 27 Oct 2024 14:54:07 -0400 Subject: [PATCH 04/21] Use `ISO8601DateFormatter`. Resolve #613 Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/Formatters/AppInfoFormatter.swift | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/Sources/mas/Formatters/AppInfoFormatter.swift b/Sources/mas/Formatters/AppInfoFormatter.swift index a162d99..710a7b5 100644 --- a/Sources/mas/Formatters/AppInfoFormatter.swift +++ b/Sources/mas/Formatters/AppInfoFormatter.swift @@ -47,12 +47,8 @@ enum AppInfoFormatter { /// - Parameter serverDate: String containing a date in ISO-8601 format. /// - Returns: Simple date format. private static func humanReadableDate(_ serverDate: String) -> String { - let serverDateFormatter = DateFormatter() - serverDateFormatter.locale = Locale(identifier: "en_US_POSIX") - serverDateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" - - let humanDateFormatter = DateFormatter() - humanDateFormatter.dateFormat = "yyyy-MM-dd" - return serverDateFormatter.date(from: serverDate).flatMap(humanDateFormatter.string(from:)) ?? "" + let humanDateFormatter = ISO8601DateFormatter() + humanDateFormatter.formatOptions = [.withFullDate] + return ISO8601DateFormatter().date(from: serverDate).map(humanDateFormatter.string(from:)) ?? "" } } From 2a496b1f98218d06f9212611fd69b276e19d17cd Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Sat, 26 Oct 2024 11:41:01 -0400 Subject: [PATCH 05/21] Refactor `AppStoreSearcher` code. Move code from `AppStoreSearcher` to `ITunesSearchAppStoreSearcher`. Improve DocC. Improve Quick test names. Resolve #607 Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- .../mas/Controllers/AppStoreSearcher.swift | 89 ++--------- .../ITunesSearchAppStoreSearcher.swift | 139 ++++++++++++++---- .../ITunesSearchAppStoreSearcherSpec.swift | 4 +- 3 files changed, 119 insertions(+), 113 deletions(-) diff --git a/Sources/mas/Controllers/AppStoreSearcher.swift b/Sources/mas/Controllers/AppStoreSearcher.swift index 4e5b65e..13e1663 100644 --- a/Sources/mas/Controllers/AppStoreSearcher.swift +++ b/Sources/mas/Controllers/AppStoreSearcher.swift @@ -11,86 +11,15 @@ import PromiseKit /// Protocol for searching the MAS catalog. protocol AppStoreSearcher { + /// Looks up app details. + /// + /// - Parameter appID: App ID. + /// - Returns: A `Promise` for the `SearchResult` for the given `appID`, `nil` if no apps match, + /// or an `Error` if any problems occur. func lookup(appID: AppID) -> Promise + /// Searches for apps. + /// + /// - Parameter searchTerm: Term for which to search. + /// - Returns: A `Promise` of an `Array` of `SearchResult`s matching `searchTerm`. func search(for searchTerm: String) -> Promise<[SearchResult]> } - -enum Entity: String { - case desktopSoftware - case macSoftware - case iPadSoftware - case iPhoneSoftware = "software" -} - -private enum URLAction { - case lookup - case search - - var queryItemName: String { - switch self { - case .lookup: - return "id" - case .search: - return "term" - } - } -} - -// MARK: - Common methods -extension AppStoreSearcher { - /// Builds the search URL for an app. - /// - /// - Parameters: - /// - searchTerm: term for which to search in MAS. - /// - country: 2-letter ISO region code of the MAS in which to search. - /// - entity: OS platform of apps for which to search. - /// - Returns: URL for the search service or nil if searchTerm can't be encoded. - func searchURL( - for searchTerm: String, - inCountry country: String?, - ofEntity entity: Entity = .desktopSoftware - ) -> URL? { - url(.search, searchTerm, inCountry: country, ofEntity: entity) - } - - /// Builds the lookup URL for an app. - /// - /// - Parameters: - /// - appID: MAS app identifier. - /// - country: 2-letter ISO region code of the MAS in which to search. - /// - entity: OS platform of apps for which to search. - /// - Returns: URL for the lookup service or nil if appID can't be encoded. - func lookupURL( - forAppID appID: AppID, - inCountry country: String?, - ofEntity entity: Entity = .desktopSoftware - ) -> URL? { - url(.lookup, String(appID), inCountry: country, ofEntity: entity) - } - - private func url( - _ action: URLAction, - _ queryItemValue: String, - inCountry country: String?, - ofEntity entity: Entity = .desktopSoftware - ) -> URL? { - guard var components = URLComponents(string: "https://itunes.apple.com/\(action)") else { - return nil - } - - var queryItems = [ - URLQueryItem(name: "media", value: "software"), - URLQueryItem(name: "entity", value: entity.rawValue), - ] - - if let country { - queryItems.append(URLQueryItem(name: "country", value: country)) - } - - queryItems.append(URLQueryItem(name: action.queryItemName, value: queryItemValue)) - - components.queryItems = queryItems - - return components.url - } -} diff --git a/Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift b/Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift index c6de321..cc1939f 100644 --- a/Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift +++ b/Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift @@ -32,39 +32,11 @@ class ITunesSearchAppStoreSearcher: AppStoreSearcher { self.networkManager = networkManager } - /// Searches for an app. - /// - /// - Parameter searchTerm: a search term matched against app names - /// - Returns: A Promise of an Array of SearchResults matching searchTerm - func search(for searchTerm: String) -> Promise<[SearchResult]> { - // Search for apps for compatible platforms, in order of preference. - // Macs with Apple Silicon can run iPad and iPhone apps. - var entities = [Entity.desktopSoftware] - if SysCtlSystemCommand.isAppleSilicon { - entities += [.iPadSoftware, .iPhoneSoftware] - } - - let results = entities.map { entity in - guard let url = searchURL(for: searchTerm, inCountry: country, ofEntity: entity) else { - fatalError("Failed to build URL for \(searchTerm)") - } - return loadSearchResults(url) - } - - // Combine the results, removing any duplicates. - var seenAppIDs = Set() - return when(fulfilled: results) - .flatMapValues { $0 } - .filterValues { result in - seenAppIDs.insert(result.trackId).inserted - } - } - /// Looks up app details. /// - /// - Parameter appID: MAS ID of app - /// - Returns: A Promise for the search result record of app, or nil if no apps match the ID, - /// or an Error if there is a problem with the network request. + /// - Parameter appID: App ID. + /// - Returns: A `Promise` for the `SearchResult` for the given `appID`, `nil` if no apps match, + /// or an `Error` if any problems occur. func lookup(appID: AppID) -> Promise { guard let url = lookupURL(forAppID: appID, inCountry: country) else { fatalError("Failed to build URL for \(appID)") @@ -105,6 +77,34 @@ class ITunesSearchAppStoreSearcher: AppStoreSearcher { } } + /// Searches for apps from the MAS. + /// + /// - Parameter searchTerm: Term for which to search in the MAS. + /// - Returns: A `Promise` of an `Array` of `SearchResult`s matching `searchTerm`. + func search(for searchTerm: String) -> Promise<[SearchResult]> { + // Search for apps for compatible platforms, in order of preference. + // Macs with Apple Silicon can run iPad and iPhone apps. + var entities = [Entity.desktopSoftware] + if SysCtlSystemCommand.isAppleSilicon { + entities += [.iPadSoftware, .iPhoneSoftware] + } + + let results = entities.map { entity in + guard let url = searchURL(for: searchTerm, inCountry: country, ofEntity: entity) else { + fatalError("Failed to build URL for \(searchTerm)") + } + return loadSearchResults(url) + } + + // Combine the results, removing any duplicates. + var seenAppIDs = Set() + return when(fulfilled: results) + .flatMapValues { $0 } + .filterValues { result in + seenAppIDs.insert(result.trackId).inserted + } + } + private func loadSearchResults(_ url: URL) -> Promise<[SearchResult]> { firstly { networkManager.loadData(from: url) @@ -137,4 +137,81 @@ class ITunesSearchAppStoreSearcher: AppStoreSearcher { return version } } + + /// Builds the search URL for an app. + /// + /// - Parameters: + /// - searchTerm: term for which to search in MAS. + /// - country: 2-letter ISO region code of the MAS in which to search. + /// - entity: OS platform of apps for which to search. + /// - Returns: URL for the search service or nil if searchTerm can't be encoded. + func searchURL( + for searchTerm: String, + inCountry country: String?, + ofEntity entity: Entity = .desktopSoftware + ) -> URL? { + url(.search, searchTerm, inCountry: country, ofEntity: entity) + } + + /// Builds the lookup URL for an app. + /// + /// - Parameters: + /// - appID: App ID. + /// - country: 2-letter ISO region code of the MAS in which to search. + /// - entity: OS platform of apps for which to search. + /// - Returns: URL for the lookup service or nil if appID can't be encoded. + private func lookupURL( + forAppID appID: AppID, + inCountry country: String?, + ofEntity entity: Entity = .desktopSoftware + ) -> URL? { + url(.lookup, String(appID), inCountry: country, ofEntity: entity) + } + + private func url( + _ action: URLAction, + _ queryItemValue: String, + inCountry country: String?, + ofEntity entity: Entity = .desktopSoftware + ) -> URL? { + guard var components = URLComponents(string: "https://itunes.apple.com/\(action)") else { + return nil + } + + var queryItems = [ + URLQueryItem(name: "media", value: "software"), + URLQueryItem(name: "entity", value: entity.rawValue), + ] + + if let country { + queryItems.append(URLQueryItem(name: "country", value: country)) + } + + queryItems.append(URLQueryItem(name: action.queryItemName, value: queryItemValue)) + + components.queryItems = queryItems + + return components.url + } +} + +enum Entity: String { + case desktopSoftware + case macSoftware + case iPadSoftware + case iPhoneSoftware = "software" +} + +private enum URLAction { + case lookup + case search + + var queryItemName: String { + switch self { + case .lookup: + return "id" + case .search: + return "term" + } + } } diff --git a/Tests/masTests/Controllers/ITunesSearchAppStoreSearcherSpec.swift b/Tests/masTests/Controllers/ITunesSearchAppStoreSearcherSpec.swift index 3b72ee8..828ff31 100644 --- a/Tests/masTests/Controllers/ITunesSearchAppStoreSearcherSpec.swift +++ b/Tests/masTests/Controllers/ITunesSearchAppStoreSearcherSpec.swift @@ -17,13 +17,13 @@ public class ITunesSearchAppStoreSearcherSpec: QuickSpec { MAS.initialize() } describe("url string") { - it("contains the app name") { + it("contains the search term") { expect { ITunesSearchAppStoreSearcher().searchURL(for: "myapp", inCountry: "US")?.absoluteString } == "https://itunes.apple.com/search?media=software&entity=desktopSoftware&country=US&term=myapp" } - it("contains the encoded app name") { + it("contains the encoded search term") { expect { ITunesSearchAppStoreSearcher().searchURL(for: "My App", inCountry: "US")?.absoluteString } From 5bfd83f3c499b31ea03654e4de232199c04e44a6 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Mon, 28 Oct 2024 01:00:48 -0400 Subject: [PATCH 06/21] Cleanup help. Resolve #616 Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/Commands/List.swift | 2 +- Sources/mas/Commands/Outdated.swift | 2 +- Sources/mas/Commands/Uninstall.swift | 2 +- Sources/mas/Commands/Upgrade.swift | 2 +- contrib/completion/mas.fish | 8 ++++---- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Sources/mas/Commands/List.swift b/Sources/mas/Commands/List.swift index 6817fe5..6cd6652 100644 --- a/Sources/mas/Commands/List.swift +++ b/Sources/mas/Commands/List.swift @@ -12,7 +12,7 @@ extension MAS { /// Command which lists all installed apps. struct List: ParsableCommand { static let configuration = CommandConfiguration( - abstract: "List apps installed from the Mac App Store for the Apple ID of the current macOS user" + abstract: "List apps installed from the Mac App Store" ) /// Runs the command. diff --git a/Sources/mas/Commands/Outdated.swift b/Sources/mas/Commands/Outdated.swift index 81b447a..ecabb7e 100644 --- a/Sources/mas/Commands/Outdated.swift +++ b/Sources/mas/Commands/Outdated.swift @@ -15,7 +15,7 @@ extension MAS { /// ready to be installed from the Mac App Store. struct Outdated: ParsableCommand { static let configuration = CommandConfiguration( - abstract: "List pending app updates from the Mac App Store for the Apple ID of the current macOS user" + abstract: "List pending app updates from the Mac App Store" ) @Flag(help: "Display warnings about apps unknown to the Mac App Store") diff --git a/Sources/mas/Commands/Uninstall.swift b/Sources/mas/Commands/Uninstall.swift index 343d9df..0cada84 100644 --- a/Sources/mas/Commands/Uninstall.swift +++ b/Sources/mas/Commands/Uninstall.swift @@ -13,7 +13,7 @@ extension MAS { /// Command which uninstalls apps managed by the Mac App Store. struct Uninstall: ParsableCommand { static let configuration = CommandConfiguration( - abstract: "Uninstall app installed from the Mac App Store for the Apple ID of the current macOS user" + abstract: "Uninstall app installed from the Mac App Store" ) /// Flag indicating that removal shouldn't be performed. diff --git a/Sources/mas/Commands/Upgrade.swift b/Sources/mas/Commands/Upgrade.swift index 25743f0..2770f39 100644 --- a/Sources/mas/Commands/Upgrade.swift +++ b/Sources/mas/Commands/Upgrade.swift @@ -15,7 +15,7 @@ extension MAS { struct Upgrade: ParsableCommand { static let configuration = CommandConfiguration( abstract: - "Upgrade outdated app(s) installed from the Mac App Store for the Apple ID of the current macOS user" + "Upgrade outdated app(s) installed from the Mac App Store" ) @Argument(help: "App ID(s)/app name(s)") diff --git a/contrib/completion/mas.fish b/contrib/completion/mas.fish index eccb749..05101e5 100644 --- a/contrib/completion/mas.fish +++ b/contrib/completion/mas.fish @@ -40,7 +40,7 @@ complete -c mas -n "__fish_use_subcommand" -f -a install -d "Install previously complete -c mas -n "__fish_seen_subcommand_from help" -xa "install" complete -c mas -n "__fish_seen_subcommand_from install lucky" -l force -d "Force reinstall" ### list -complete -c mas -n "__fish_use_subcommand" -f -a list -d "List apps installed from the Mac App Store for the Apple ID of the current macOS user" +complete -c mas -n "__fish_use_subcommand" -f -a list -d "List apps installed from the Mac App Store" complete -c mas -n "__fish_seen_subcommand_from help" -xa "list" ### lucky complete -c mas -n "__fish_use_subcommand" -f -a lucky -d "Install the first app returned from searching the Mac App Store (app must have been previously purchased)" @@ -49,7 +49,7 @@ complete -c mas -n "__fish_seen_subcommand_from help" -xa "lucky" complete -c mas -n "__fish_use_subcommand" -f -a open -d "Open app page in 'App Store.app'" 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 for the Apple ID of the current macOS user" +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 apps unknown to the Mac App Store" ### purchase @@ -71,12 +71,12 @@ complete -c mas -n "__fish_seen_subcommand_from signin" -l dialog -d "Provide pa complete -c mas -n "__fish_use_subcommand" -f -a signout -d "Sign out of the Apple ID currently signed in in the Mac App Store" complete -c mas -n "__fish_seen_subcommand_from help" -xa "signout" ### uninstall -complete -c mas -n "__fish_use_subcommand" -f -a uninstall -d "Uninstall app installed from the Mac App Store for the Apple ID of the current macOS user" +complete -c mas -n "__fish_use_subcommand" -f -a uninstall -d "Uninstall app installed from the Mac App Store" complete -c mas -n "__fish_seen_subcommand_from help" -xa "uninstall" complete -c mas -n "__fish_seen_subcommand_from uninstall" -l dry-run -d "Perform dry run" complete -c mas -n "__fish_seen_subcommand_from uninstall" -x -a "(__fish_mas_list_installed)" ### upgrade -complete -c mas -n "__fish_use_subcommand" -f -a upgrade -d "Upgrade outdated app(s) from the Mac App Store for the Apple ID of the current macOS user" +complete -c mas -n "__fish_use_subcommand" -f -a upgrade -d "Upgrade outdated app(s) from the Mac App Store" complete -c mas -n "__fish_seen_subcommand_from help" -xa "upgrade" complete -c mas -n "__fish_seen_subcommand_from upgrade" -x -a "(__fish_mas_outdated_installed)" ### vendor From c0fffeddf326d12211d1769444ba546308919ea2 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Mon, 28 Oct 2024 09:25:16 -0400 Subject: [PATCH 07/21] Open the Mac App Store without any spurious error dialogs. Use PromiseKit properly. Don't use `OpenCommand`. Resolve #217 Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/Commands/Open.swift | 96 +++++++++++++++++--------- Tests/masTests/Commands/OpenSpec.swift | 18 ++--- 2 files changed, 68 insertions(+), 46 deletions(-) diff --git a/Sources/mas/Commands/Open.swift b/Sources/mas/Commands/Open.swift index 365715e..09f0c8d 100644 --- a/Sources/mas/Commands/Open.swift +++ b/Sources/mas/Commands/Open.swift @@ -6,8 +6,10 @@ // Copyright © 2016 mas-cli. All rights reserved. // +import AppKit import ArgumentParser import Foundation +import PromiseKit private let masScheme = "macappstore" @@ -24,43 +26,69 @@ extension MAS { /// Runs the command. func run() throws { - try run(searcher: ITunesSearchAppStoreSearcher(), openCommand: OpenSystemCommand()) + try run(searcher: ITunesSearchAppStoreSearcher()) } - func run(searcher: AppStoreSearcher, openCommand: ExternalCommand) throws { - do { - guard let appID else { - // If no app ID is given, just open the MAS GUI app - try openCommand.run(arguments: masScheme + "://") - return - } - - guard let result = try searcher.lookup(appID: appID).wait() else { - throw MASError.noSearchResultsFound - } - - guard var url = URLComponents(string: result.trackViewUrl) else { - throw MASError.searchFailed - } - url.scheme = masScheme - - guard let urlString = url.string else { - printError("Unable to construct URL") - throw MASError.searchFailed - } - do { - try openCommand.run(arguments: urlString) - } catch { - printError("Unable to launch open command") - throw MASError.searchFailed - } - if openCommand.failed { - printError("Open failed: (\(openCommand.process.terminationReason)) \(openCommand.stderr)") - throw MASError.searchFailed - } - } catch { - throw error as? MASError ?? .searchFailed + func run(searcher: AppStoreSearcher) throws { + guard let appID else { + // If no app ID is given, just open the MAS GUI app + try openMacAppStore().wait() + return } + try openInMacAppStore(pageForAppID: appID, searcher: searcher).wait() + } + } +} + +private func openMacAppStore() -> Promise { + Promise { seal in + guard let macappstoreSchemeURL = URL(string: "macappstore:") else { + throw MASError.notSupported + } + guard let appURL = NSWorkspace.shared.urlForApplication(toOpen: macappstoreSchemeURL) else { + throw MASError.notSupported + } + + if #available(macOS 10.15, *) { + NSWorkspace.shared.openApplication(at: appURL, configuration: NSWorkspace.OpenConfiguration()) { _, error in + if let error { + seal.reject(error) + } + seal.fulfill(()) + } + } else { + try NSWorkspace.shared.launchApplication(at: appURL, configuration: [:]) + seal.fulfill(()) + } + } +} + +private func openInMacAppStore(pageForAppID appID: AppID, searcher: AppStoreSearcher) -> Promise { + Promise { seal in + guard let result = try searcher.lookup(appID: appID).wait() else { + throw MASError.runtimeError("Unknown app ID \(appID)") + } + + guard var urlComponents = URLComponents(string: result.trackViewUrl) else { + throw MASError.runtimeError("Unable to construct URL from: \(result.trackViewUrl)") + } + + urlComponents.scheme = masScheme + + guard let url = urlComponents.url else { + throw MASError.runtimeError("Unable to construct URL from: \(urlComponents)") + } + + if #available(macOS 10.15, *) { + NSWorkspace.shared.open(url, configuration: NSWorkspace.OpenConfiguration()) { _, error in + if let error { + seal.reject(error) + } + seal.fulfill(()) + } + } else { + NSWorkspace.shared.open(url) + seal.fulfill(()) } } } diff --git a/Tests/masTests/Commands/OpenSpec.swift b/Tests/masTests/Commands/OpenSpec.swift index 5f3ee72..081201d 100644 --- a/Tests/masTests/Commands/OpenSpec.swift +++ b/Tests/masTests/Commands/OpenSpec.swift @@ -15,7 +15,6 @@ import Quick public class OpenSpec: QuickSpec { override public func spec() { let searcher = MockAppStoreSearcher() - let openCommand = MockOpenSystemCommand() beforeSuite { MAS.initialize() @@ -26,17 +25,17 @@ public class OpenSpec: QuickSpec { } it("fails to open app with invalid ID") { expect { - try MAS.Open.parse(["--", "-999"]).run(searcher: searcher, openCommand: openCommand) + try MAS.Open.parse(["--", "-999"]).run(searcher: searcher) } .to(throwError()) } it("can't find app with unknown ID") { expect { - try MAS.Open.parse(["999"]).run(searcher: searcher, openCommand: openCommand) + try MAS.Open.parse(["999"]).run(searcher: searcher) } .to(throwError(MASError.noSearchResultsFound)) } - it("opens app in MAS") { + xit("opens app in MAS") { let mockResult = SearchResult( trackId: 1111, trackViewUrl: "fakescheme://some/url", @@ -44,18 +43,13 @@ public class OpenSpec: QuickSpec { ) searcher.apps[mockResult.trackId] = mockResult expect { - try MAS.Open.parse([mockResult.trackId.description]) - .run(searcher: searcher, openCommand: openCommand) - return openCommand.arguments + try MAS.Open.parse([mockResult.trackId.description]).run(searcher: searcher) } - == ["macappstore://some/url"] } - it("just opens MAS if no app specified") { + xit("just opens MAS if no app specified") { expect { - try MAS.Open.parse([]).run(searcher: searcher, openCommand: openCommand) - return openCommand.arguments + try MAS.Open.parse([]).run(searcher: searcher) } - == ["macappstore://"] } } } From 9ebb01805d735ef83bece9edb3acb405a6888651 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Mon, 28 Oct 2024 11:26:11 -0400 Subject: [PATCH 08/21] Replace clunky `ExternalCommand` code that starts new processes with Apple library calls. Resolve #620 Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/Commands/Home.swift | 31 ++++----- Sources/mas/Commands/Open.swift | 45 +++++-------- Sources/mas/Commands/Vendor.swift | 39 +++++------- .../ITunesSearchAppStoreSearcher.swift | 6 +- .../ExternalCommands/ExternalCommand.swift | 63 ------------------- .../ExternalCommands/OpenSystemCommand.swift | 23 ------- .../SysCtlSystemCommand.swift | 41 ------------ Sources/mas/Network/URL.swift | 32 ++++++++++ Tests/masTests/Commands/HomeSpec.swift | 10 +-- Tests/masTests/Commands/VendorSpec.swift | 10 +-- .../MockOpenSystemCommand.swift | 27 -------- .../OpenSystemCommandSpec.swift | 30 --------- 12 files changed, 83 insertions(+), 274 deletions(-) delete mode 100644 Sources/mas/ExternalCommands/ExternalCommand.swift delete mode 100644 Sources/mas/ExternalCommands/OpenSystemCommand.swift delete mode 100644 Sources/mas/ExternalCommands/SysCtlSystemCommand.swift create mode 100644 Sources/mas/Network/URL.swift delete mode 100644 Tests/masTests/ExternalCommands/MockOpenSystemCommand.swift delete mode 100644 Tests/masTests/ExternalCommands/OpenSystemCommandSpec.swift diff --git a/Sources/mas/Commands/Home.swift b/Sources/mas/Commands/Home.swift index 8d86a5f..531b6c5 100644 --- a/Sources/mas/Commands/Home.swift +++ b/Sources/mas/Commands/Home.swift @@ -7,6 +7,7 @@ // import ArgumentParser +import Foundation extension MAS { /// Opens app page on MAS Preview. Uses the iTunes Lookup API: @@ -21,29 +22,19 @@ extension MAS { /// Runs the command. func run() throws { - try run(searcher: ITunesSearchAppStoreSearcher(), openCommand: OpenSystemCommand()) + try run(searcher: ITunesSearchAppStoreSearcher()) } - func run(searcher: AppStoreSearcher, openCommand: ExternalCommand) throws { - do { - guard let result = try searcher.lookup(appID: appID).wait() else { - throw MASError.noSearchResultsFound - } - - do { - try openCommand.run(arguments: result.trackViewUrl) - } catch { - printError("Unable to launch open command") - throw MASError.searchFailed - } - if openCommand.failed { - let reason = openCommand.process.terminationReason - printError("Open failed: (\(reason)) \(openCommand.stderr)") - throw MASError.searchFailed - } - } catch { - throw error as? MASError ?? .searchFailed + func run(searcher: AppStoreSearcher) throws { + guard let result = try searcher.lookup(appID: appID).wait() else { + throw MASError.noSearchResultsFound } + + guard let url = URL(string: result.trackViewUrl) else { + throw MASError.runtimeError("Unable to construct URL from: \(result.trackViewUrl)") + } + + try url.open().wait() } } } diff --git a/Sources/mas/Commands/Open.swift b/Sources/mas/Commands/Open.swift index 09f0c8d..b56f00b 100644 --- a/Sources/mas/Commands/Open.swift +++ b/Sources/mas/Commands/Open.swift @@ -8,7 +8,6 @@ import AppKit import ArgumentParser -import Foundation import PromiseKit private let masScheme = "macappstore" @@ -35,7 +34,7 @@ extension MAS { try openMacAppStore().wait() return } - try openInMacAppStore(pageForAppID: appID, searcher: searcher).wait() + try openInMacAppStore(pageForAppID: appID, searcher: searcher) } } } @@ -63,32 +62,20 @@ private func openMacAppStore() -> Promise { } } -private func openInMacAppStore(pageForAppID appID: AppID, searcher: AppStoreSearcher) -> Promise { - Promise { seal in - guard let result = try searcher.lookup(appID: appID).wait() else { - throw MASError.runtimeError("Unknown app ID \(appID)") - } - - guard var urlComponents = URLComponents(string: result.trackViewUrl) else { - throw MASError.runtimeError("Unable to construct URL from: \(result.trackViewUrl)") - } - - urlComponents.scheme = masScheme - - guard let url = urlComponents.url else { - throw MASError.runtimeError("Unable to construct URL from: \(urlComponents)") - } - - if #available(macOS 10.15, *) { - NSWorkspace.shared.open(url, configuration: NSWorkspace.OpenConfiguration()) { _, error in - if let error { - seal.reject(error) - } - seal.fulfill(()) - } - } else { - NSWorkspace.shared.open(url) - seal.fulfill(()) - } +private func openInMacAppStore(pageForAppID appID: AppID, searcher: AppStoreSearcher) throws { + guard let result = try searcher.lookup(appID: appID).wait() else { + throw MASError.runtimeError("Unknown app ID \(appID)") } + + guard var urlComponents = URLComponents(string: result.trackViewUrl) else { + throw MASError.runtimeError("Unable to construct URL from: \(result.trackViewUrl)") + } + + urlComponents.scheme = masScheme + + guard let url = urlComponents.url else { + throw MASError.runtimeError("Unable to construct URL from: \(urlComponents)") + } + + try url.open().wait() } diff --git a/Sources/mas/Commands/Vendor.swift b/Sources/mas/Commands/Vendor.swift index d303c94..7256d85 100644 --- a/Sources/mas/Commands/Vendor.swift +++ b/Sources/mas/Commands/Vendor.swift @@ -7,6 +7,7 @@ // import ArgumentParser +import Foundation extension MAS { /// Opens vendor's app page in a browser. Uses the iTunes Lookup API: @@ -21,33 +22,23 @@ extension MAS { /// Runs the command. func run() throws { - try run(searcher: ITunesSearchAppStoreSearcher(), openCommand: OpenSystemCommand()) + try run(searcher: ITunesSearchAppStoreSearcher()) } - func run(searcher: AppStoreSearcher, openCommand: ExternalCommand) throws { - do { - guard let result = try searcher.lookup(appID: appID).wait() else { - throw MASError.noSearchResultsFound - } - - guard let vendorWebsite = result.sellerUrl else { - throw MASError.noVendorWebsite - } - - do { - try openCommand.run(arguments: vendorWebsite) - } catch { - printError("Unable to launch open command") - throw MASError.searchFailed - } - if openCommand.failed { - let reason = openCommand.process.terminationReason - printError("Open failed: (\(reason)) \(openCommand.stderr)") - throw MASError.searchFailed - } - } catch { - throw error as? MASError ?? .searchFailed + func run(searcher: AppStoreSearcher) throws { + guard let result = try searcher.lookup(appID: appID).wait() else { + throw MASError.noSearchResultsFound } + + guard let urlString = result.sellerUrl else { + throw MASError.noVendorWebsite + } + + guard let url = URL(string: urlString) else { + throw MASError.runtimeError("Unable to construct URL from: \(urlString)") + } + + try url.open().wait() } } } diff --git a/Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift b/Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift index cc1939f..babaab9 100644 --- a/Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift +++ b/Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift @@ -85,9 +85,9 @@ class ITunesSearchAppStoreSearcher: AppStoreSearcher { // Search for apps for compatible platforms, in order of preference. // Macs with Apple Silicon can run iPad and iPhone apps. var entities = [Entity.desktopSoftware] - if SysCtlSystemCommand.isAppleSilicon { - entities += [.iPadSoftware, .iPhoneSoftware] - } + #if arch(arm64) + entities += [.iPadSoftware, .iPhoneSoftware] + #endif let results = entities.map { entity in guard let url = searchURL(for: searchTerm, inCountry: country, ofEntity: entity) else { diff --git a/Sources/mas/ExternalCommands/ExternalCommand.swift b/Sources/mas/ExternalCommands/ExternalCommand.swift deleted file mode 100644 index b5a3487..0000000 --- a/Sources/mas/ExternalCommands/ExternalCommand.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// ExternalCommand.swift -// mas -// -// Created by Ben Chatelain on 1/1/19. -// Copyright © 2019 mas-cli. All rights reserved. -// - -import Foundation - -/// Represents a CLI command. -protocol ExternalCommand { - var binaryPath: String { get set } - - var process: Process { get } - - var stdout: String { get } - var stderr: String { get } - var stdoutPipe: Pipe { get } - var stderrPipe: Pipe { get } - - var exitCode: Int32 { get } - var succeeded: Bool { get } - var failed: Bool { get } - - /// Runs the command. - func run(arguments: String...) throws -} - -/// Common implementation -extension ExternalCommand { - var stdout: String { - let data = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - return String(data: data, encoding: .utf8) ?? "" - } - - var stderr: String { - let data = stderrPipe.fileHandleForReading.readDataToEndOfFile() - return String(data: data, encoding: .utf8) ?? "" - } - - var exitCode: Int32 { - process.terminationStatus - } - - var succeeded: Bool { - process.terminationReason == .exit && exitCode == 0 - } - - var failed: Bool { - !succeeded - } - - /// Runs the command. - func run(arguments: String...) throws { - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - process.arguments = arguments - process.executableURL = URL(fileURLWithPath: binaryPath) - try process.run() - process.waitUntilExit() - } -} diff --git a/Sources/mas/ExternalCommands/OpenSystemCommand.swift b/Sources/mas/ExternalCommands/OpenSystemCommand.swift deleted file mode 100644 index 5d5e3cc..0000000 --- a/Sources/mas/ExternalCommands/OpenSystemCommand.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// OpenSystemCommand.swift -// mas -// -// Created by Ben Chatelain on 1/2/19. -// Copyright © 2019 mas-cli. All rights reserved. -// - -import Foundation - -/// Wrapper for the external 'open' system command (https://ss64.com/osx/open.html). -struct OpenSystemCommand: ExternalCommand { - var binaryPath: String - - let process = Process() - - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - - init(binaryPath: String = "/usr/bin/open") { - self.binaryPath = binaryPath - } -} diff --git a/Sources/mas/ExternalCommands/SysCtlSystemCommand.swift b/Sources/mas/ExternalCommands/SysCtlSystemCommand.swift deleted file mode 100644 index 1d04a2e..0000000 --- a/Sources/mas/ExternalCommands/SysCtlSystemCommand.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// SysCtlSystemCommand.swift -// mas -// -// Created by Chris Araman on 6/3/21. -// Copyright © 2021 mas-cli. All rights reserved. -// - -import Foundation - -/// Wrapper for the external 'sysctl' system command. -/// -/// See - https://ss64.com/osx/sysctl.html -struct SysCtlSystemCommand: ExternalCommand { - static var isAppleSilicon: Bool = { - let sysctl = Self() - do { - // Returns 1 on Apple Silicon even when run in an Intel context in Rosetta. - try sysctl.run(arguments: "-in", "hw.optional.arm64") - } catch { - fatalError("sysctl failed") - } - - guard sysctl.succeeded else { - fatalError("sysctl failed") - } - - return sysctl.stdout.trimmingCharacters(in: .newlines) == "1" - }() - - let process = Process() - - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - - var binaryPath: String - - init(binaryPath: String = "/usr/sbin/sysctl") { - self.binaryPath = binaryPath - } -} diff --git a/Sources/mas/Network/URL.swift b/Sources/mas/Network/URL.swift new file mode 100644 index 0000000..538c1a9 --- /dev/null +++ b/Sources/mas/Network/URL.swift @@ -0,0 +1,32 @@ +// +// URL.swift +// mas +// +// Created by Ross Goldberg on 2024-10-28. +// Copyright © 2024 mas-cli. All rights reserved. +// + +import AppKit +import Foundation +import PromiseKit + +extension URL { + func open() -> Promise { + Promise { seal in + if #available(macOS 10.15, *) { + NSWorkspace.shared.open(self, configuration: NSWorkspace.OpenConfiguration()) { _, error in + if let error { + seal.reject(error) + } + seal.fulfill(()) + } + } else { + if NSWorkspace.shared.open(self) { + seal.fulfill(()) + } else { + seal.reject(MASError.runtimeError("Failed to open \(self)")) + } + } + } + } +} diff --git a/Tests/masTests/Commands/HomeSpec.swift b/Tests/masTests/Commands/HomeSpec.swift index 8d3faac..c92b58b 100644 --- a/Tests/masTests/Commands/HomeSpec.swift +++ b/Tests/masTests/Commands/HomeSpec.swift @@ -14,7 +14,6 @@ import Quick public class HomeSpec: QuickSpec { override public func spec() { let searcher = MockAppStoreSearcher() - let openCommand = MockOpenSystemCommand() beforeSuite { MAS.initialize() @@ -25,13 +24,13 @@ public class HomeSpec: QuickSpec { } it("fails to open app with invalid ID") { expect { - try MAS.Home.parse(["--", "-999"]).run(searcher: searcher, openCommand: openCommand) + try MAS.Home.parse(["--", "-999"]).run(searcher: searcher) } .to(throwError()) } it("can't find app with unknown ID") { expect { - try MAS.Home.parse(["999"]).run(searcher: searcher, openCommand: openCommand) + try MAS.Home.parse(["999"]).run(searcher: searcher) } .to(throwError(MASError.noSearchResultsFound)) } @@ -43,11 +42,8 @@ public class HomeSpec: QuickSpec { ) searcher.apps[mockResult.trackId] = mockResult expect { - try MAS.Home.parse([String(mockResult.trackId)]) - .run(searcher: searcher, openCommand: openCommand) - return openCommand.arguments + try MAS.Home.parse([String(mockResult.trackId)]).run(searcher: searcher) } - == [mockResult.trackViewUrl] } } } diff --git a/Tests/masTests/Commands/VendorSpec.swift b/Tests/masTests/Commands/VendorSpec.swift index 1834cb3..5c7eb95 100644 --- a/Tests/masTests/Commands/VendorSpec.swift +++ b/Tests/masTests/Commands/VendorSpec.swift @@ -14,7 +14,6 @@ import Quick public class VendorSpec: QuickSpec { override public func spec() { let searcher = MockAppStoreSearcher() - let openCommand = MockOpenSystemCommand() beforeSuite { MAS.initialize() @@ -25,13 +24,13 @@ public class VendorSpec: QuickSpec { } it("fails to open app with invalid ID") { expect { - try MAS.Vendor.parse(["--", "-999"]).run(searcher: searcher, openCommand: openCommand) + try MAS.Vendor.parse(["--", "-999"]).run(searcher: searcher) } .to(throwError()) } it("can't find app with unknown ID") { expect { - try MAS.Vendor.parse(["999"]).run(searcher: searcher, openCommand: openCommand) + try MAS.Vendor.parse(["999"]).run(searcher: searcher) } .to(throwError(MASError.noSearchResultsFound)) } @@ -44,11 +43,8 @@ public class VendorSpec: QuickSpec { ) searcher.apps[mockResult.trackId] = mockResult expect { - try MAS.Vendor.parse([String(mockResult.trackId)]) - .run(searcher: searcher, openCommand: openCommand) - return openCommand.arguments + try MAS.Vendor.parse([String(mockResult.trackId)]).run(searcher: searcher) } - == [mockResult.sellerUrl] } } } diff --git a/Tests/masTests/ExternalCommands/MockOpenSystemCommand.swift b/Tests/masTests/ExternalCommands/MockOpenSystemCommand.swift deleted file mode 100644 index da30e13..0000000 --- a/Tests/masTests/ExternalCommands/MockOpenSystemCommand.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// MockOpenSystemCommand.swift -// masTests -// -// Created by Ben Chatelain on 1/4/19. -// Copyright © 2019 mas-cli. All rights reserved. -// - -import Foundation - -@testable import mas - -class MockOpenSystemCommand: ExternalCommand { - // Stub out protocol logic - var succeeded = true - var arguments: [String] = [] - - // unused - var binaryPath = "/dev/null" - var process = Process() - var stdoutPipe = Pipe() - var stderrPipe = Pipe() - - func run(arguments: String...) throws { - self.arguments = arguments - } -} diff --git a/Tests/masTests/ExternalCommands/OpenSystemCommandSpec.swift b/Tests/masTests/ExternalCommands/OpenSystemCommandSpec.swift deleted file mode 100644 index 9b8c1f5..0000000 --- a/Tests/masTests/ExternalCommands/OpenSystemCommandSpec.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// OpenSystemCommandSpec.swift -// masTests -// -// Created by Ben Chatelain on 2/24/20. -// Copyright © 2020 mas-cli. All rights reserved. -// - -import Nimble -import Quick - -@testable import mas - -public class OpenSystemCommandSpec: QuickSpec { - override public func spec() { - beforeSuite { - MAS.initialize() - } - describe("open system command") { - context("binary path") { - it("defaults to the macOS open command") { - expect(OpenSystemCommand().binaryPath) == "/usr/bin/open" - } - it("can be overridden") { - expect(OpenSystemCommand(binaryPath: "/dev/null").binaryPath) == "/dev/null" - } - } - } - } -} From 14dbd26d3afeb1a418c3fae07163b0a82ef2f310 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Mon, 28 Oct 2024 12:34:18 -0400 Subject: [PATCH 09/21] Simplify `open()` extension func for `URL`. Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/Network/URL.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/mas/Network/URL.swift b/Sources/mas/Network/URL.swift index 538c1a9..8795a85 100644 --- a/Sources/mas/Network/URL.swift +++ b/Sources/mas/Network/URL.swift @@ -21,11 +21,11 @@ extension URL { seal.fulfill(()) } } else { - if NSWorkspace.shared.open(self) { - seal.fulfill(()) - } else { - seal.reject(MASError.runtimeError("Failed to open \(self)")) + guard NSWorkspace.shared.open(self) else { + throw MASError.runtimeError("Failed to open \(self)") } + + seal.fulfill(()) } } } From 0a05cd438f61a5ac75b744c0b327b6b90b6cdfcf Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Mon, 28 Oct 2024 14:38:06 -0400 Subject: [PATCH 10/21] Add & use `MASError.unknownAppID(AppID)`. Partial #533 Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/Commands/Home.swift | 2 +- Sources/mas/Commands/Info.swift | 2 +- Sources/mas/Errors/MASError.swift | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/mas/Commands/Home.swift b/Sources/mas/Commands/Home.swift index 531b6c5..f35fa78 100644 --- a/Sources/mas/Commands/Home.swift +++ b/Sources/mas/Commands/Home.swift @@ -27,7 +27,7 @@ extension MAS { func run(searcher: AppStoreSearcher) throws { guard let result = try searcher.lookup(appID: appID).wait() else { - throw MASError.noSearchResultsFound + throw MASError.unknownAppID(appID) } guard let url = URL(string: result.trackViewUrl) else { diff --git a/Sources/mas/Commands/Info.swift b/Sources/mas/Commands/Info.swift index 5cab3fd..49e58c1 100644 --- a/Sources/mas/Commands/Info.swift +++ b/Sources/mas/Commands/Info.swift @@ -28,7 +28,7 @@ extension MAS { func run(searcher: AppStoreSearcher) throws { do { guard let result = try searcher.lookup(appID: appID).wait() else { - throw MASError.noSearchResultsFound + throw MASError.unknownAppID(appID) } print(AppInfoFormatter.format(app: result)) diff --git a/Sources/mas/Errors/MASError.swift b/Sources/mas/Errors/MASError.swift index 1d31b7f..3b9816a 100644 --- a/Sources/mas/Errors/MASError.swift +++ b/Sources/mas/Errors/MASError.swift @@ -27,6 +27,9 @@ enum MASError: Error, Equatable { case searchFailed case noSearchResultsFound + + case unknownAppID(AppID) + case noVendorWebsite case notInstalled(appID: AppID) @@ -83,6 +86,8 @@ extension MASError: CustomStringConvertible { return "Search failed" case .noSearchResultsFound: return "No results found" + case .unknownAppID(let appID): + return "Unknown app ID \(appID)" case .noVendorWebsite: return "App does not have a vendor website" case .notInstalled(let appID): From 9eef8b6cb89bad18b313983b3112556ec4bb0bfe Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Mon, 28 Oct 2024 14:43:01 -0400 Subject: [PATCH 11/21] Improve download functions. Partial #533 Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/AppStore/Downloader.swift | 24 ++++++++++++++---------- Sources/mas/AppStore/SSPurchase.swift | 11 ++++------- Sources/mas/Commands/Install.swift | 2 +- Sources/mas/Commands/Lucky.swift | 2 +- Sources/mas/Commands/Purchase.swift | 2 +- Sources/mas/Commands/Upgrade.swift | 2 +- 6 files changed, 22 insertions(+), 21 deletions(-) diff --git a/Sources/mas/AppStore/Downloader.swift b/Sources/mas/AppStore/Downloader.swift index 6435ff6..e95bb1c 100644 --- a/Sources/mas/AppStore/Downloader.swift +++ b/Sources/mas/AppStore/Downloader.swift @@ -14,17 +14,16 @@ import StoreFoundation /// /// - Parameters: /// - appIDs: The IDs of the apps to be downloaded -/// - purchase: Flag indicating whether the apps needs to be purchased. -/// Only works for free apps. Defaults to false. +/// - purchasing: Flag indicating if the apps will be purchased. Only works for free apps. Defaults to false. /// - Returns: A promise that completes when the downloads are complete. If any fail, /// the promise is rejected with the first error, after all remaining downloads are attempted. -func downloadAll(_ appIDs: [AppID], purchase: Bool = false) -> Promise { +func downloadApps(withAppIDs appIDs: [AppID], purchasing: Bool = false) -> Promise { var firstError: Error? return appIDs .reduce(Guarantee.value(())) { previous, appID in previous.then { - downloadWithRetries(appID, purchase: purchase) + downloadApp(withAppID: appID, purchasing: purchasing) .recover { error in if firstError == nil { firstError = error @@ -39,10 +38,15 @@ func downloadAll(_ appIDs: [AppID], purchase: Bool = false) -> Promise { } } -private func downloadWithRetries(_ appID: AppID, purchase: Bool = false, attempts: Int = 3) -> Promise { - SSPurchase().perform(appID: appID, purchase: purchase) +private func downloadApp( + withAppID appID: AppID, + purchasing: Bool = false, + withAttemptCount attemptCount: UInt32 = 3 +) -> Promise { + SSPurchase() + .perform(appID: appID, purchasing: purchasing) .recover { error in - guard attempts > 1 else { + guard attemptCount > 1 else { throw error } @@ -54,9 +58,9 @@ private func downloadWithRetries(_ appID: AppID, purchase: Bool = false, attempt throw error } - let attempts = attempts - 1 + let attemptCount = attemptCount - 1 printWarning((downloadError ?? error).localizedDescription) - printWarning("Trying again up to \(attempts) more \(attempts == 1 ? "time" : "times").") - return downloadWithRetries(appID, purchase: purchase, attempts: attempts) + printWarning("Trying again up to \(attemptCount) more \(attemptCount == 1 ? "time" : "times").") + return downloadApp(withAppID: appID, purchasing: purchasing, withAttemptCount: attemptCount) } } diff --git a/Sources/mas/AppStore/SSPurchase.swift b/Sources/mas/AppStore/SSPurchase.swift index cf712cd..a12a1c9 100644 --- a/Sources/mas/AppStore/SSPurchase.swift +++ b/Sources/mas/AppStore/SSPurchase.swift @@ -11,7 +11,7 @@ import PromiseKit import StoreFoundation extension SSPurchase { - func perform(appID: AppID, purchase: Bool) -> Promise { + func perform(appID: AppID, purchasing: Bool) -> Promise { var parameters: [String: Any] = [ "productType": "C", "price": 0, @@ -20,9 +20,11 @@ extension SSPurchase { "appExtVrsId": 0, ] - if purchase { + if purchasing { parameters["macappinstalledconfirmed"] = 1 parameters["pricingParameters"] = "STDQ" + // Possibly unnecessary… + isRedownload = false } else { parameters["pricingParameters"] = "STDRDL" } @@ -35,11 +37,6 @@ extension SSPurchase { itemIdentifier = appID - // Not sure if this is needed… - if purchase { - isRedownload = false - } - downloadMetadata = SSDownloadMetadata() downloadMetadata.kind = "software" downloadMetadata.itemIdentifier = appID diff --git a/Sources/mas/Commands/Install.swift b/Sources/mas/Commands/Install.swift index 7247172..32f03fd 100644 --- a/Sources/mas/Commands/Install.swift +++ b/Sources/mas/Commands/Install.swift @@ -38,7 +38,7 @@ extension MAS { } do { - try downloadAll(appIDs).wait() + try downloadApps(withAppIDs: appIDs).wait() } catch { throw error as? MASError ?? .downloadFailed(error: error as NSError) } diff --git a/Sources/mas/Commands/Lucky.swift b/Sources/mas/Commands/Lucky.swift index 400e5ef..dc57e41 100644 --- a/Sources/mas/Commands/Lucky.swift +++ b/Sources/mas/Commands/Lucky.swift @@ -66,7 +66,7 @@ extension MAS { printWarning("\(appName) is already installed") } else { do { - try downloadAll([appID]).wait() + try downloadApps(withAppIDs: [appID]).wait() } catch { throw error as? MASError ?? .downloadFailed(error: error as NSError) } diff --git a/Sources/mas/Commands/Purchase.swift b/Sources/mas/Commands/Purchase.swift index bdb8a58..dab6d24 100644 --- a/Sources/mas/Commands/Purchase.swift +++ b/Sources/mas/Commands/Purchase.swift @@ -35,7 +35,7 @@ extension MAS { } do { - try downloadAll(appIDs, purchase: true).wait() + try downloadApps(withAppIDs: appIDs, purchasing: true).wait() } catch { throw error as? MASError ?? .downloadFailed(error: error as NSError) } diff --git a/Sources/mas/Commands/Upgrade.swift b/Sources/mas/Commands/Upgrade.swift index 2770f39..94f8b50 100644 --- a/Sources/mas/Commands/Upgrade.swift +++ b/Sources/mas/Commands/Upgrade.swift @@ -46,7 +46,7 @@ extension MAS { ) do { - try downloadAll(apps.map(\.installedApp.itemIdentifier.appIDValue)).wait() + try downloadApps(withAppIDs: apps.map(\.installedApp.itemIdentifier.appIDValue)).wait() } catch { throw error as? MASError ?? .downloadFailed(error: error as NSError) } From e4bc69cf5d83ed41ebb26c872121ed9254427696 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Mon, 28 Oct 2024 15:23:20 -0400 Subject: [PATCH 12/21] Remove unnecessary tests. Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Tests/masTests/Commands/HomeSpec.swift | 17 ----------------- Tests/masTests/Commands/InfoSpec.swift | 6 ------ Tests/masTests/Commands/OpenSpec.swift | 22 ---------------------- Tests/masTests/Commands/VendorSpec.swift | 18 ------------------ 4 files changed, 63 deletions(-) diff --git a/Tests/masTests/Commands/HomeSpec.swift b/Tests/masTests/Commands/HomeSpec.swift index c92b58b..1bfc3f7 100644 --- a/Tests/masTests/Commands/HomeSpec.swift +++ b/Tests/masTests/Commands/HomeSpec.swift @@ -22,29 +22,12 @@ public class HomeSpec: QuickSpec { beforeEach { searcher.reset() } - it("fails to open app with invalid ID") { - expect { - try MAS.Home.parse(["--", "-999"]).run(searcher: searcher) - } - .to(throwError()) - } it("can't find app with unknown ID") { expect { try MAS.Home.parse(["999"]).run(searcher: searcher) } .to(throwError(MASError.noSearchResultsFound)) } - it("opens app on MAS Preview") { - let mockResult = SearchResult( - trackId: 1111, - trackViewUrl: "mas preview url", - version: "0.0" - ) - searcher.apps[mockResult.trackId] = mockResult - expect { - try MAS.Home.parse([String(mockResult.trackId)]).run(searcher: searcher) - } - } } } } diff --git a/Tests/masTests/Commands/InfoSpec.swift b/Tests/masTests/Commands/InfoSpec.swift index 04b393c..b46465c 100644 --- a/Tests/masTests/Commands/InfoSpec.swift +++ b/Tests/masTests/Commands/InfoSpec.swift @@ -23,12 +23,6 @@ public class InfoSpec: QuickSpec { beforeEach { searcher.reset() } - it("fails to open app with invalid ID") { - expect { - try MAS.Info.parse(["--", "-999"]).run(searcher: searcher) - } - .to(throwError()) - } it("can't find app with unknown ID") { expect { try MAS.Info.parse(["999"]).run(searcher: searcher) diff --git a/Tests/masTests/Commands/OpenSpec.swift b/Tests/masTests/Commands/OpenSpec.swift index 081201d..6861740 100644 --- a/Tests/masTests/Commands/OpenSpec.swift +++ b/Tests/masTests/Commands/OpenSpec.swift @@ -23,34 +23,12 @@ public class OpenSpec: QuickSpec { beforeEach { searcher.reset() } - it("fails to open app with invalid ID") { - expect { - try MAS.Open.parse(["--", "-999"]).run(searcher: searcher) - } - .to(throwError()) - } it("can't find app with unknown ID") { expect { try MAS.Open.parse(["999"]).run(searcher: searcher) } .to(throwError(MASError.noSearchResultsFound)) } - xit("opens app in MAS") { - let mockResult = SearchResult( - trackId: 1111, - trackViewUrl: "fakescheme://some/url", - version: "0.0" - ) - searcher.apps[mockResult.trackId] = mockResult - expect { - try MAS.Open.parse([mockResult.trackId.description]).run(searcher: searcher) - } - } - xit("just opens MAS if no app specified") { - expect { - try MAS.Open.parse([]).run(searcher: searcher) - } - } } } } diff --git a/Tests/masTests/Commands/VendorSpec.swift b/Tests/masTests/Commands/VendorSpec.swift index 5c7eb95..6aa8d8f 100644 --- a/Tests/masTests/Commands/VendorSpec.swift +++ b/Tests/masTests/Commands/VendorSpec.swift @@ -22,30 +22,12 @@ public class VendorSpec: QuickSpec { beforeEach { searcher.reset() } - it("fails to open app with invalid ID") { - expect { - try MAS.Vendor.parse(["--", "-999"]).run(searcher: searcher) - } - .to(throwError()) - } it("can't find app with unknown ID") { expect { try MAS.Vendor.parse(["999"]).run(searcher: searcher) } .to(throwError(MASError.noSearchResultsFound)) } - it("opens vendor app page in browser") { - let mockResult = SearchResult( - sellerUrl: "https://awesome.app", - trackId: 1111, - trackViewUrl: "https://apps.apple.com/us/app/awesome/id1111?mt=12&uo=4", - version: "0.0" - ) - searcher.apps[mockResult.trackId] = mockResult - expect { - try MAS.Vendor.parse([String(mockResult.trackId)]).run(searcher: searcher) - } - } } } } From e639341d11c052ad9f4ba752c6df8aaeb8687f8d Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Mon, 28 Oct 2024 20:18:49 -0400 Subject: [PATCH 13/21] Refactor to allow `install` & `purchase` to report unknown app IDs via console instead of cryptically via a dialog. Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/AppStore/Downloader.swift | 37 +++++- Sources/mas/Commands/Home.swift | 4 +- Sources/mas/Commands/Info.swift | 6 +- Sources/mas/Commands/Install.swift | 6 +- Sources/mas/Commands/Open.swift | 4 +- Sources/mas/Commands/Outdated.swift | 32 +++--- Sources/mas/Commands/Purchase.swift | 6 +- Sources/mas/Commands/Upgrade.swift | 21 ++-- Sources/mas/Commands/Vendor.swift | 4 +- .../mas/Controllers/AppStoreSearcher.swift | 8 +- .../ITunesSearchAppStoreSearcher.swift | 107 ++++++++---------- Tests/masTests/Commands/HomeSpec.swift | 2 +- Tests/masTests/Commands/InfoSpec.swift | 2 +- Tests/masTests/Commands/InstallSpec.swift | 2 +- Tests/masTests/Commands/OpenSpec.swift | 2 +- Tests/masTests/Commands/PurchaseSpec.swift | 2 +- Tests/masTests/Commands/VendorSpec.swift | 2 +- .../Controllers/MockAppStoreSearcher.swift | 4 +- 18 files changed, 136 insertions(+), 115 deletions(-) diff --git a/Sources/mas/AppStore/Downloader.swift b/Sources/mas/AppStore/Downloader.swift index e95bb1c..5ec5d61 100644 --- a/Sources/mas/AppStore/Downloader.swift +++ b/Sources/mas/AppStore/Downloader.swift @@ -10,10 +10,43 @@ import CommerceKit import PromiseKit import StoreFoundation -/// Downloads a list of apps, one after the other, printing progress to the console. +/// Sequentially downloads apps, printing progress to the console. +/// +/// Verifies that each supplied app ID is valid before attempting to download. /// /// - Parameters: -/// - appIDs: The IDs of the apps to be downloaded +/// - unverifiedAppIDs: The app IDs of the apps to be verified and 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. +/// - Returns: A `Promise` that completes when the downloads are complete. If any fail, +/// the promise is rejected with the first error, after all remaining downloads are attempted. +func downloadApps( + withAppIDs unverifiedAppIDs: [AppID], + verifiedBy searcher: AppStoreSearcher, + purchasing: Bool = false +) -> Promise { + when(resolved: unverifiedAppIDs.map { searcher.lookup(appID: $0) }) + .then { results in + downloadApps( + withAppIDs: + results.compactMap { result in + switch result { + case .fulfilled(let searchResult): + return searchResult.trackId + case .rejected(let error): + printError(String(describing: error)) + return nil + } + }, + purchasing: purchasing + ) + } +} + +/// 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. /// - Returns: A promise that completes when the downloads are complete. If any fail, /// the promise is rejected with the first error, after all remaining downloads are attempted. diff --git a/Sources/mas/Commands/Home.swift b/Sources/mas/Commands/Home.swift index f35fa78..45d4f4f 100644 --- a/Sources/mas/Commands/Home.swift +++ b/Sources/mas/Commands/Home.swift @@ -26,9 +26,7 @@ extension MAS { } func run(searcher: AppStoreSearcher) throws { - guard let result = try searcher.lookup(appID: appID).wait() else { - throw MASError.unknownAppID(appID) - } + let result = try searcher.lookup(appID: appID).wait() guard let url = URL(string: result.trackViewUrl) else { throw MASError.runtimeError("Unable to construct URL from: \(result.trackViewUrl)") diff --git a/Sources/mas/Commands/Info.swift b/Sources/mas/Commands/Info.swift index 49e58c1..90f9647 100644 --- a/Sources/mas/Commands/Info.swift +++ b/Sources/mas/Commands/Info.swift @@ -27,11 +27,7 @@ extension MAS { func run(searcher: AppStoreSearcher) throws { do { - guard let result = try searcher.lookup(appID: appID).wait() else { - throw MASError.unknownAppID(appID) - } - - print(AppInfoFormatter.format(app: result)) + print(AppInfoFormatter.format(app: try searcher.lookup(appID: appID).wait())) } catch { throw error as? MASError ?? .searchFailed } diff --git a/Sources/mas/Commands/Install.swift b/Sources/mas/Commands/Install.swift index 32f03fd..df79730 100644 --- a/Sources/mas/Commands/Install.swift +++ b/Sources/mas/Commands/Install.swift @@ -23,10 +23,10 @@ extension MAS { /// Runs the command. func run() throws { - try run(appLibrary: SoftwareMapAppLibrary()) + try run(appLibrary: SoftwareMapAppLibrary(), searcher: ITunesSearchAppStoreSearcher()) } - func run(appLibrary: AppLibrary) throws { + func run(appLibrary: AppLibrary, searcher: AppStoreSearcher) throws { // Try to download applications with given identifiers and collect results let appIDs = appIDs.filter { appID in if let appName = appLibrary.installedApps(withAppID: appID).first?.appName, !force { @@ -38,7 +38,7 @@ extension MAS { } do { - try downloadApps(withAppIDs: appIDs).wait() + try downloadApps(withAppIDs: appIDs, verifiedBy: searcher).wait() } catch { throw error as? MASError ?? .downloadFailed(error: error as NSError) } diff --git a/Sources/mas/Commands/Open.swift b/Sources/mas/Commands/Open.swift index b56f00b..5195f45 100644 --- a/Sources/mas/Commands/Open.swift +++ b/Sources/mas/Commands/Open.swift @@ -63,9 +63,7 @@ private func openMacAppStore() -> Promise { } private func openInMacAppStore(pageForAppID appID: AppID, searcher: AppStoreSearcher) throws { - guard let result = try searcher.lookup(appID: appID).wait() else { - throw MASError.runtimeError("Unknown app ID \(appID)") - } + let result = try searcher.lookup(appID: appID).wait() guard var urlComponents = URLComponents(string: result.trackViewUrl) else { throw MASError.runtimeError("Unable to construct URL from: \(result.trackViewUrl)") diff --git a/Sources/mas/Commands/Outdated.swift b/Sources/mas/Commands/Outdated.swift index ecabb7e..ce0d97b 100644 --- a/Sources/mas/Commands/Outdated.swift +++ b/Sources/mas/Commands/Outdated.swift @@ -30,11 +30,22 @@ extension MAS { _ = try when( fulfilled: appLibrary.installedApps.map { installedApp in - firstly { - searcher.lookup(appID: installedApp.itemIdentifier.appIDValue) - } - .done { storeApp in - guard let storeApp else { + searcher.lookup(appID: installedApp.itemIdentifier.appIDValue) + .done { storeApp in + if installedApp.isOutdatedWhenComparedTo(storeApp) { + print( + """ + \(installedApp.itemIdentifier) \(installedApp.appName) \ + (\(installedApp.bundleVersion) -> \(storeApp.version)) + """ + ) + } + } + .recover { error in + guard case MASError.unknownAppID = error else { + throw error + } + if verbose { printWarning( """ @@ -43,18 +54,7 @@ extension MAS { """ ) } - return } - - if installedApp.isOutdatedWhenComparedTo(storeApp) { - print( - """ - \(installedApp.itemIdentifier) \(installedApp.appName) \ - (\(installedApp.bundleVersion) -> \(storeApp.version)) - """ - ) - } - } } ) .wait() diff --git a/Sources/mas/Commands/Purchase.swift b/Sources/mas/Commands/Purchase.swift index dab6d24..e624527 100644 --- a/Sources/mas/Commands/Purchase.swift +++ b/Sources/mas/Commands/Purchase.swift @@ -20,10 +20,10 @@ extension MAS { /// Runs the command. func run() throws { - try run(appLibrary: SoftwareMapAppLibrary()) + try run(appLibrary: SoftwareMapAppLibrary(), searcher: ITunesSearchAppStoreSearcher()) } - func run(appLibrary: AppLibrary) throws { + func run(appLibrary: AppLibrary, searcher: AppStoreSearcher) throws { // Try to download applications with given identifiers and collect results let appIDs = appIDs.filter { appID in if let appName = appLibrary.installedApps(withAppID: appID).first?.appName { @@ -35,7 +35,7 @@ extension MAS { } do { - try downloadApps(withAppIDs: appIDs, purchasing: true).wait() + try downloadApps(withAppIDs: appIDs, verifiedBy: searcher, purchasing: true).wait() } catch { throw error as? MASError ?? .downloadFailed(error: error as NSError) } diff --git a/Sources/mas/Commands/Upgrade.swift b/Sources/mas/Commands/Upgrade.swift index 94f8b50..4823976 100644 --- a/Sources/mas/Commands/Upgrade.swift +++ b/Sources/mas/Commands/Upgrade.swift @@ -71,16 +71,19 @@ extension MAS { let promises = apps.map { installedApp in // only upgrade apps whose local version differs from the store version - firstly { - searcher.lookup(appID: installedApp.itemIdentifier.appIDValue) - } - .map { result -> (SoftwareProduct, SearchResult)? in - guard let storeApp = result, installedApp.isOutdatedWhenComparedTo(storeApp) else { - return nil + searcher.lookup(appID: installedApp.itemIdentifier.appIDValue) + .map { storeApp -> (SoftwareProduct, SearchResult)? in + guard installedApp.isOutdatedWhenComparedTo(storeApp) else { + return nil + } + return (installedApp, storeApp) + } + .recover { error -> Promise<(SoftwareProduct, SearchResult)?> in + guard case MASError.unknownAppID = error else { + return Promise(error: error) + } + return .value(nil) } - - return (installedApp, storeApp) - } } return try when(fulfilled: promises).wait().compactMap { $0 } diff --git a/Sources/mas/Commands/Vendor.swift b/Sources/mas/Commands/Vendor.swift index 7256d85..387ee19 100644 --- a/Sources/mas/Commands/Vendor.swift +++ b/Sources/mas/Commands/Vendor.swift @@ -26,9 +26,7 @@ extension MAS { } func run(searcher: AppStoreSearcher) throws { - guard let result = try searcher.lookup(appID: appID).wait() else { - throw MASError.noSearchResultsFound - } + let result = try searcher.lookup(appID: appID).wait() guard let urlString = result.sellerUrl else { throw MASError.noVendorWebsite diff --git a/Sources/mas/Controllers/AppStoreSearcher.swift b/Sources/mas/Controllers/AppStoreSearcher.swift index 13e1663..51393c2 100644 --- a/Sources/mas/Controllers/AppStoreSearcher.swift +++ b/Sources/mas/Controllers/AppStoreSearcher.swift @@ -14,9 +14,11 @@ protocol AppStoreSearcher { /// Looks up app details. /// /// - Parameter appID: App ID. - /// - Returns: A `Promise` for the `SearchResult` for the given `appID`, `nil` if no apps match, - /// or an `Error` if any problems occur. - func lookup(appID: AppID) -> Promise + /// - Returns: A `Promise` for the `SearchResult` for the given `appID` if `appID` is valid. + /// A `Promise` for `MASError.unknownAppID(appID)` if `appID` is invalid. + /// An `Promise` for some other `Error` if any problems occur. + func lookup(appID: AppID) -> Promise + /// Searches for apps. /// /// - Parameter searchTerm: Term for which to search. diff --git a/Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift b/Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift index babaab9..72ef777 100644 --- a/Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift +++ b/Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift @@ -32,49 +32,46 @@ class ITunesSearchAppStoreSearcher: AppStoreSearcher { self.networkManager = networkManager } - /// Looks up app details. - /// /// - Parameter appID: App ID. - /// - Returns: A `Promise` for the `SearchResult` for the given `appID`, `nil` if no apps match, - /// or an `Error` if any problems occur. - func lookup(appID: AppID) -> Promise { + /// - Returns: A `Promise` for the `SearchResult` for the given `appID` if `appID` is valid. + /// A `Promise` for `MASError.unknownAppID(appID)` if `appID` is invalid. + /// An `Promise` for some other `Error` if any problems occur. + func lookup(appID: AppID) -> Promise { guard let url = lookupURL(forAppID: appID, inCountry: country) else { fatalError("Failed to build URL for \(appID)") } - return firstly { + return loadSearchResults(url) - } - .then { results -> Guarantee in - guard let result = results.first else { - return .value(nil) - } - - guard let pageURL = URL(string: result.trackViewUrl) else { - return .value(result) - } - - return firstly { - self.scrapeAppStoreVersion(pageURL) - } - .map { pageVersion in - guard - let pageVersion, - let searchVersion = Version(tolerant: result.version), - pageVersion > searchVersion - else { - return result + .then { results -> Guarantee in + guard let result = results.first else { + throw MASError.unknownAppID(appID) } - // Update the search result with the version from the App Store page. - var result = result - result.version = pageVersion.description - return result + guard let pageURL = URL(string: result.trackViewUrl) else { + return .value(result) + } + + return + self.scrapeAppStoreVersion(pageURL) + .map { pageVersion in + guard + let pageVersion, + let searchVersion = Version(tolerant: result.version), + pageVersion > searchVersion + else { + return result + } + + // Update the search result with the version from the App Store page. + var result = result + result.version = pageVersion.description + return result + } + .recover { _ in + // If we were unable to scrape the App Store page, assume compatibility. + .value(result) + } } - .recover { _ in - // If we were unable to scrape the App Store page, assume compatibility. - .value(result) - } - } } /// Searches for apps from the MAS. @@ -106,36 +103,32 @@ class ITunesSearchAppStoreSearcher: AppStoreSearcher { } private func loadSearchResults(_ url: URL) -> Promise<[SearchResult]> { - firstly { - networkManager.loadData(from: url) - } - .map { data in - do { - return try JSONDecoder().decode(SearchResultList.self, from: data).results - } catch { - throw MASError.jsonParsing(data: data) + networkManager.loadData(from: url) + .map { data in + do { + return try JSONDecoder().decode(SearchResultList.self, from: data).results + } catch { + throw MASError.jsonParsing(data: data) + } } - } } /// Scrape the app version from the App Store webpage at the given URL. /// /// App Store webpages frequently report a version that is newer than what is reported by the iTunes Search API. private func scrapeAppStoreVersion(_ pageURL: URL) -> Promise { - firstly { - networkManager.loadData(from: pageURL) - } - .map { data in - guard - let html = String(data: data, encoding: .utf8), - let capture = Self.appVersionExpression.firstMatch(in: html)?.captures[0], - let version = Version(tolerant: capture) - else { - return nil - } + networkManager.loadData(from: pageURL) + .map { data in + guard + let html = String(data: data, encoding: .utf8), + let capture = Self.appVersionExpression.firstMatch(in: html)?.captures[0], + let version = Version(tolerant: capture) + else { + return nil + } - return version - } + return version + } } /// Builds the search URL for an app. diff --git a/Tests/masTests/Commands/HomeSpec.swift b/Tests/masTests/Commands/HomeSpec.swift index 1bfc3f7..c5a0455 100644 --- a/Tests/masTests/Commands/HomeSpec.swift +++ b/Tests/masTests/Commands/HomeSpec.swift @@ -26,7 +26,7 @@ public class HomeSpec: QuickSpec { expect { try MAS.Home.parse(["999"]).run(searcher: searcher) } - .to(throwError(MASError.noSearchResultsFound)) + .to(throwError(MASError.unknownAppID(999))) } } } diff --git a/Tests/masTests/Commands/InfoSpec.swift b/Tests/masTests/Commands/InfoSpec.swift index b46465c..1cad137 100644 --- a/Tests/masTests/Commands/InfoSpec.swift +++ b/Tests/masTests/Commands/InfoSpec.swift @@ -27,7 +27,7 @@ public class InfoSpec: QuickSpec { expect { try MAS.Info.parse(["999"]).run(searcher: searcher) } - .to(throwError(MASError.noSearchResultsFound)) + .to(throwError(MASError.unknownAppID(999))) } it("displays app details") { let mockResult = SearchResult( diff --git a/Tests/masTests/Commands/InstallSpec.swift b/Tests/masTests/Commands/InstallSpec.swift index e2074c6..ea253f7 100644 --- a/Tests/masTests/Commands/InstallSpec.swift +++ b/Tests/masTests/Commands/InstallSpec.swift @@ -19,7 +19,7 @@ public class InstallSpec: QuickSpec { xdescribe("install command") { xit("installs apps") { expect { - try MAS.Install.parse([]).run(appLibrary: MockAppLibrary()) + try MAS.Install.parse([]).run(appLibrary: MockAppLibrary(), searcher: MockAppStoreSearcher()) } .toNot(throwError()) } diff --git a/Tests/masTests/Commands/OpenSpec.swift b/Tests/masTests/Commands/OpenSpec.swift index 6861740..c9cf874 100644 --- a/Tests/masTests/Commands/OpenSpec.swift +++ b/Tests/masTests/Commands/OpenSpec.swift @@ -27,7 +27,7 @@ public class OpenSpec: QuickSpec { expect { try MAS.Open.parse(["999"]).run(searcher: searcher) } - .to(throwError(MASError.noSearchResultsFound)) + .to(throwError(MASError.unknownAppID(999))) } } } diff --git a/Tests/masTests/Commands/PurchaseSpec.swift b/Tests/masTests/Commands/PurchaseSpec.swift index ac9db18..1ab423b 100644 --- a/Tests/masTests/Commands/PurchaseSpec.swift +++ b/Tests/masTests/Commands/PurchaseSpec.swift @@ -19,7 +19,7 @@ public class PurchaseSpec: QuickSpec { xdescribe("purchase command") { xit("purchases apps") { expect { - try MAS.Purchase.parse(["999"]).run(appLibrary: MockAppLibrary()) + try MAS.Purchase.parse(["999"]).run(appLibrary: MockAppLibrary(), searcher: MockAppStoreSearcher()) } .toNot(throwError()) } diff --git a/Tests/masTests/Commands/VendorSpec.swift b/Tests/masTests/Commands/VendorSpec.swift index 6aa8d8f..7eb0f58 100644 --- a/Tests/masTests/Commands/VendorSpec.swift +++ b/Tests/masTests/Commands/VendorSpec.swift @@ -26,7 +26,7 @@ public class VendorSpec: QuickSpec { expect { try MAS.Vendor.parse(["999"]).run(searcher: searcher) } - .to(throwError(MASError.noSearchResultsFound)) + .to(throwError(MASError.unknownAppID(999))) } } } diff --git a/Tests/masTests/Controllers/MockAppStoreSearcher.swift b/Tests/masTests/Controllers/MockAppStoreSearcher.swift index 1df0ef6..c2eb5fe 100644 --- a/Tests/masTests/Controllers/MockAppStoreSearcher.swift +++ b/Tests/masTests/Controllers/MockAppStoreSearcher.swift @@ -17,9 +17,9 @@ class MockAppStoreSearcher: AppStoreSearcher { .value(apps.filter { $1.trackName.contains(searchTerm) }.map { $1 }) } - func lookup(appID: AppID) -> Promise { + func lookup(appID: AppID) -> Promise { guard let result = apps[appID] else { - return Promise(error: MASError.noSearchResultsFound) + return Promise(error: MASError.unknownAppID(appID)) } return .value(result) From 3d264675bfd7b4b5add195d462c12d23a4979346 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Tue, 29 Oct 2024 00:16:35 -0400 Subject: [PATCH 14/21] Move code to more appropriate files. Partial #533 Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/Commands/Uninstall.swift | 4 +-- .../Controllers/SoftwareMapAppLibrary.swift | 22 ++------------ Sources/mas/MAS.swift | 9 ------ Sources/mas/Models/AppID.swift | 17 +++++++++++ .../{Controllers => Utilities}/Finder.swift | 0 Sources/mas/Utilities/ProcessInfo.swift | 29 +++++++++++++++++++ 6 files changed, 50 insertions(+), 31 deletions(-) create mode 100644 Sources/mas/Models/AppID.swift rename Sources/mas/{Controllers => Utilities}/Finder.swift (100%) create mode 100644 Sources/mas/Utilities/ProcessInfo.swift diff --git a/Sources/mas/Commands/Uninstall.swift b/Sources/mas/Commands/Uninstall.swift index 0cada84..46ea06e 100644 --- a/Sources/mas/Commands/Uninstall.swift +++ b/Sources/mas/Commands/Uninstall.swift @@ -32,12 +32,12 @@ extension MAS { throw MASError.macOSUserMustBeRoot } - guard let username = getSudoUsername() else { + guard let username = ProcessInfo.processInfo.sudoUsername else { throw MASError.runtimeError("Could not determine the original username") } guard - let uid = getSudoUID(), + let uid = ProcessInfo.processInfo.sudoUID, seteuid(uid) == 0 else { throw MASError.runtimeError("Failed to switch effective user from 'root' to '\(username)'") diff --git a/Sources/mas/Controllers/SoftwareMapAppLibrary.swift b/Sources/mas/Controllers/SoftwareMapAppLibrary.swift index 7fc48e7..77e47e5 100644 --- a/Sources/mas/Controllers/SoftwareMapAppLibrary.swift +++ b/Sources/mas/Controllers/SoftwareMapAppLibrary.swift @@ -43,24 +43,6 @@ class SoftwareMapAppLibrary: AppLibrary { } } -func getSudoUsername() -> String? { - ProcessInfo.processInfo.environment["SUDO_USER"] -} - -func getSudoUID() -> uid_t? { - guard let uid = ProcessInfo.processInfo.environment["SUDO_UID"] else { - return nil - } - return uid_t(uid) -} - -func getSudoGID() -> gid_t? { - guard let gid = ProcessInfo.processInfo.environment["SUDO_GID"] else { - return nil - } - return gid_t(gid) -} - private func getOwnerAndGroupOfItem(atPath path: String) throws -> (uid_t, gid_t) { do { let attributes = try FileManager.default.attributesOfItem(atPath: path) @@ -75,11 +57,11 @@ private func getOwnerAndGroupOfItem(atPath path: String) throws -> (uid_t, gid_t } private func chown(paths: [String]) throws -> [String: (uid_t, gid_t)] { - guard let sudoUID = getSudoUID() else { + guard let sudoUID = ProcessInfo.processInfo.sudoUID else { throw MASError.runtimeError("Failed to get original uid") } - guard let sudoGID = getSudoGID() else { + guard let sudoGID = ProcessInfo.processInfo.sudoGID else { throw MASError.runtimeError("Failed to get original gid") } diff --git a/Sources/mas/MAS.swift b/Sources/mas/MAS.swift index aee1262..56e814f 100644 --- a/Sources/mas/MAS.swift +++ b/Sources/mas/MAS.swift @@ -7,7 +7,6 @@ // import ArgumentParser -import Foundation import PromiseKit @main @@ -55,11 +54,3 @@ struct MAS: ParsableCommand { Self.initialize() } } - -typealias AppID = UInt64 - -extension NSNumber { - var appIDValue: AppID { - uint64Value - } -} diff --git a/Sources/mas/Models/AppID.swift b/Sources/mas/Models/AppID.swift new file mode 100644 index 0000000..0b8689f --- /dev/null +++ b/Sources/mas/Models/AppID.swift @@ -0,0 +1,17 @@ +// +// AppID.swift +// mas +// +// Created by Ross Goldberg on 2024-10-29. +// Copyright © 2024 mas-cli. All rights reserved. +// + +import Foundation + +typealias AppID = UInt64 + +extension NSNumber { + var appIDValue: AppID { + uint64Value + } +} diff --git a/Sources/mas/Controllers/Finder.swift b/Sources/mas/Utilities/Finder.swift similarity index 100% rename from Sources/mas/Controllers/Finder.swift rename to Sources/mas/Utilities/Finder.swift diff --git a/Sources/mas/Utilities/ProcessInfo.swift b/Sources/mas/Utilities/ProcessInfo.swift new file mode 100644 index 0000000..a7e59b1 --- /dev/null +++ b/Sources/mas/Utilities/ProcessInfo.swift @@ -0,0 +1,29 @@ +// +// ProcessInfo.swift +// mas +// +// Created by Ross Goldberg on 2024-10-29. +// Copyright © 2024 mas-cli. All rights reserved. +// + +import Foundation + +extension ProcessInfo { + var sudoUsername: String? { + environment["SUDO_USER"] + } + + var sudoUID: uid_t? { + guard let uid = environment["SUDO_UID"] else { + return nil + } + return uid_t(uid) + } + + var sudoGID: gid_t? { + guard let gid = environment["SUDO_GID"] else { + return nil + } + return gid_t(gid) + } +} From 64ab55718afd96dddb2b18591db2c395a1d71c15 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Tue, 29 Oct 2024 03:38:12 -0400 Subject: [PATCH 15/21] Improve `upgrade` unknown app ID/name error output. Don't output a warning if nothing requires an upgrade. Partial #533 Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/Commands/Upgrade.swift | 17 ++++++++++++----- Tests/masTests/Commands/UpgradeSpec.swift | 2 +- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/Sources/mas/Commands/Upgrade.swift b/Sources/mas/Commands/Upgrade.swift index 4823976..21ec1ba 100644 --- a/Sources/mas/Commands/Upgrade.swift +++ b/Sources/mas/Commands/Upgrade.swift @@ -35,7 +35,6 @@ extension MAS { } guard !apps.isEmpty else { - printWarning("Nothing found to upgrade") return } @@ -59,14 +58,22 @@ extension MAS { let apps = appIDOrNames.isEmpty ? appLibrary.installedApps - : appIDOrNames.flatMap { appID in - if let appID = AppID(appID) { + : appIDOrNames.flatMap { appIDOrName in + if let appID = AppID(appIDOrName) { // argument is an AppID, lookup apps by id using argument - return appLibrary.installedApps(withAppID: appID) + let installedApps = appLibrary.installedApps(withAppID: appID) + if installedApps.isEmpty { + printError("Unknown app ID \(appID)") + } + return installedApps } // argument is not an AppID, lookup apps by name using argument - return appLibrary.installedApps(named: appID) + let installedApps = appLibrary.installedApps(named: appIDOrName) + if installedApps.isEmpty { + printError("Unknown app name '\(appIDOrName)'") + } + return installedApps } let promises = apps.map { installedApp in diff --git a/Tests/masTests/Commands/UpgradeSpec.swift b/Tests/masTests/Commands/UpgradeSpec.swift index 6630777..c3a805f 100644 --- a/Tests/masTests/Commands/UpgradeSpec.swift +++ b/Tests/masTests/Commands/UpgradeSpec.swift @@ -25,7 +25,7 @@ public class UpgradeSpec: QuickSpec { .run(appLibrary: MockAppLibrary(), searcher: MockAppStoreSearcher()) } } - == "Warning: Nothing found to upgrade\n" + .toNot(throwError()) } } } From 0b9c84bcb881bd2bad9c927b1d2c535218a35d12 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Tue, 29 Oct 2024 03:59:14 -0400 Subject: [PATCH 16/21] Improve help output for command arguments. Partial #533 Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/Commands/Install.swift | 2 +- Sources/mas/Commands/Purchase.swift | 2 +- Sources/mas/Commands/Upgrade.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/mas/Commands/Install.swift b/Sources/mas/Commands/Install.swift index df79730..a5c73aa 100644 --- a/Sources/mas/Commands/Install.swift +++ b/Sources/mas/Commands/Install.swift @@ -18,7 +18,7 @@ extension MAS { @Flag(help: "Force reinstall") var force = false - @Argument(help: "App ID(s)") + @Argument(help: ArgumentHelp("App ID", valueName: "app-id")) var appIDs: [AppID] /// Runs the command. diff --git a/Sources/mas/Commands/Purchase.swift b/Sources/mas/Commands/Purchase.swift index e624527..e0a7211 100644 --- a/Sources/mas/Commands/Purchase.swift +++ b/Sources/mas/Commands/Purchase.swift @@ -15,7 +15,7 @@ extension MAS { abstract: "\"Purchase\" and install free apps from the Mac App Store" ) - @Argument(help: "App ID(s)") + @Argument(help: ArgumentHelp("App ID", valueName: "app-id")) var appIDs: [AppID] /// Runs the command. diff --git a/Sources/mas/Commands/Upgrade.swift b/Sources/mas/Commands/Upgrade.swift index 21ec1ba..012cb50 100644 --- a/Sources/mas/Commands/Upgrade.swift +++ b/Sources/mas/Commands/Upgrade.swift @@ -18,7 +18,7 @@ extension MAS { "Upgrade outdated app(s) installed from the Mac App Store" ) - @Argument(help: "App ID(s)/app name(s)") + @Argument(help: ArgumentHelp("App ID/app name", valueName: "app-id-or-name")) var appIDOrNames: [String] = [] /// Runs the command. From 05674b2534aa4c09cd4dd3c1033b48744475ce85 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Tue, 29 Oct 2024 04:02:11 -0400 Subject: [PATCH 17/21] Remove duplicate error output for `lucky`. Partial #533 Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/Commands/Lucky.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/mas/Commands/Lucky.swift b/Sources/mas/Commands/Lucky.swift index dc57e41..f46a92a 100644 --- a/Sources/mas/Commands/Lucky.swift +++ b/Sources/mas/Commands/Lucky.swift @@ -38,7 +38,6 @@ extension MAS { do { let results = try searcher.search(for: searchTerm).wait() guard let result = results.first else { - printError("No results found") throw MASError.noSearchResultsFound } From 31dfe8117452d125f6a9ac1b64b55d8976e8a442 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Tue, 29 Oct 2024 04:08:56 -0400 Subject: [PATCH 18/21] Improve `lucky` & `search` error message. Partial #533 Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/Errors/MASError.swift | 2 +- Tests/masTests/Errors/MASErrorTestCase.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/mas/Errors/MASError.swift b/Sources/mas/Errors/MASError.swift index 3b9816a..f247fe3 100644 --- a/Sources/mas/Errors/MASError.swift +++ b/Sources/mas/Errors/MASError.swift @@ -85,7 +85,7 @@ extension MASError: CustomStringConvertible { case .searchFailed: return "Search failed" case .noSearchResultsFound: - return "No results found" + return "No apps found" case .unknownAppID(let appID): return "Unknown app ID \(appID)" case .noVendorWebsite: diff --git a/Tests/masTests/Errors/MASErrorTestCase.swift b/Tests/masTests/Errors/MASErrorTestCase.swift index 027a196..3a115b3 100644 --- a/Tests/masTests/Errors/MASErrorTestCase.swift +++ b/Tests/masTests/Errors/MASErrorTestCase.swift @@ -100,7 +100,7 @@ class MASErrorTestCase: XCTestCase { func testNoSearchResultsFound() { error = .noSearchResultsFound - XCTAssertEqual(error.description, "No results found") + XCTAssertEqual(error.description, "No apps found") } func testNoVendorWebsite() { From 1b43c89becc7e9f45e241a7d03c534a01caac38c Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Tue, 29 Oct 2024 04:56:37 -0400 Subject: [PATCH 19/21] Single source for Unknown app ID message. Resolve #533 Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/Commands/Upgrade.swift | 2 +- Sources/mas/Errors/MASError.swift | 2 +- Sources/mas/Models/AppID.swift | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/mas/Commands/Upgrade.swift b/Sources/mas/Commands/Upgrade.swift index 012cb50..3f1d3f4 100644 --- a/Sources/mas/Commands/Upgrade.swift +++ b/Sources/mas/Commands/Upgrade.swift @@ -63,7 +63,7 @@ extension MAS { // argument is an AppID, lookup apps by id using argument let installedApps = appLibrary.installedApps(withAppID: appID) if installedApps.isEmpty { - printError("Unknown app ID \(appID)") + printError(appID.unknownMessage) } return installedApps } diff --git a/Sources/mas/Errors/MASError.swift b/Sources/mas/Errors/MASError.swift index f247fe3..3c6ebfc 100644 --- a/Sources/mas/Errors/MASError.swift +++ b/Sources/mas/Errors/MASError.swift @@ -87,7 +87,7 @@ extension MASError: CustomStringConvertible { case .noSearchResultsFound: return "No apps found" case .unknownAppID(let appID): - return "Unknown app ID \(appID)" + return appID.unknownMessage case .noVendorWebsite: return "App does not have a vendor website" case .notInstalled(let appID): diff --git a/Sources/mas/Models/AppID.swift b/Sources/mas/Models/AppID.swift index 0b8689f..d7d1788 100644 --- a/Sources/mas/Models/AppID.swift +++ b/Sources/mas/Models/AppID.swift @@ -10,6 +10,12 @@ import Foundation typealias AppID = UInt64 +extension AppID { + var unknownMessage: String { + "Unknown app ID \(self)" + } +} + extension NSNumber { var appIDValue: AppID { uint64Value From 37823cc4dd0e5bba1e8065782ff5ef3f04a9d397 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Tue, 29 Oct 2024 06:14:19 -0400 Subject: [PATCH 20/21] Fix typos in DocC. Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/Controllers/AppStoreSearcher.swift | 4 ++-- Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/mas/Controllers/AppStoreSearcher.swift b/Sources/mas/Controllers/AppStoreSearcher.swift index 51393c2..e72c3f3 100644 --- a/Sources/mas/Controllers/AppStoreSearcher.swift +++ b/Sources/mas/Controllers/AppStoreSearcher.swift @@ -16,12 +16,12 @@ protocol AppStoreSearcher { /// - Parameter appID: App ID. /// - Returns: A `Promise` for the `SearchResult` for the given `appID` if `appID` is valid. /// A `Promise` for `MASError.unknownAppID(appID)` if `appID` is invalid. - /// An `Promise` for some other `Error` if any problems occur. + /// A `Promise` for some other `Error` if any problems occur. func lookup(appID: AppID) -> Promise /// Searches for apps. /// /// - Parameter searchTerm: Term for which to search. - /// - Returns: A `Promise` of an `Array` of `SearchResult`s matching `searchTerm`. + /// - Returns: A `Promise` for an `Array` of `SearchResult`s matching `searchTerm`. func search(for searchTerm: String) -> Promise<[SearchResult]> } diff --git a/Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift b/Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift index 72ef777..100b9d3 100644 --- a/Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift +++ b/Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift @@ -35,7 +35,7 @@ class ITunesSearchAppStoreSearcher: AppStoreSearcher { /// - Parameter appID: App ID. /// - Returns: A `Promise` for the `SearchResult` for the given `appID` if `appID` is valid. /// A `Promise` for `MASError.unknownAppID(appID)` if `appID` is invalid. - /// An `Promise` for some other `Error` if any problems occur. + /// A `Promise` for some other `Error` if any problems occur. func lookup(appID: AppID) -> Promise { guard let url = lookupURL(forAppID: appID, inCountry: country) else { fatalError("Failed to build URL for \(appID)") @@ -77,7 +77,7 @@ class ITunesSearchAppStoreSearcher: AppStoreSearcher { /// Searches for apps from the MAS. /// /// - Parameter searchTerm: Term for which to search in the MAS. - /// - Returns: A `Promise` of an `Array` of `SearchResult`s matching `searchTerm`. + /// - Returns: A `Promise` for an `Array` of `SearchResult`s matching `searchTerm`. func search(for searchTerm: String) -> Promise<[SearchResult]> { // Search for apps for compatible platforms, in order of preference. // Macs with Apple Silicon can run iPad and iPhone apps. From 5209ccd16b3bc8077d3530ec4c01dccddbd84566 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Wed, 30 Oct 2024 03:19:30 -0400 Subject: [PATCH 21/21] Improve downloading output. Update linting for access control on extensions. Resolve #307 Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- .swift-format | 2 +- .swiftformat | 1 - .swiftlint.yml | 1 + .../AppStore/PurchaseDownloadObserver.swift | 34 ++++++++++++++++--- Tests/masTests/.swift-format | 2 +- 5 files changed, 33 insertions(+), 7 deletions(-) diff --git a/.swift-format b/.swift-format index 693d879..2f15f28 100644 --- a/.swift-format +++ b/.swift-format @@ -28,7 +28,7 @@ "NeverForceUnwrap": true, "NeverUseForceTry": true, "NeverUseImplicitlyUnwrappedOptionals": true, - "NoAccessLevelOnExtensionDeclaration": true, + "NoAccessLevelOnExtensionDeclaration": false, "NoAssignmentInExpressions": true, "NoBlockComments": true, "NoCasesWithOnlyFallthrough": true, diff --git a/.swiftformat b/.swiftformat index 9089de8..d9ca163 100644 --- a/.swiftformat +++ b/.swiftformat @@ -31,7 +31,6 @@ # Rule options --commas always ---extensionacl on-declarations --hexliteralcase lowercase --importgrouping testable-last --lineaftermarks false diff --git a/.swiftlint.yml b/.swiftlint.yml index 3d19626..8457f4e 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -22,6 +22,7 @@ disabled_rules: - function_body_length - inert_defer - legacy_objc_type +- no_extension_access_modifier - no_grouping_extension - number_separator - one_declaration_per_file diff --git a/Sources/mas/AppStore/PurchaseDownloadObserver.swift b/Sources/mas/AppStore/PurchaseDownloadObserver.swift index 30b968c..fa3e9e7 100644 --- a/Sources/mas/AppStore/PurchaseDownloadObserver.swift +++ b/Sources/mas/AppStore/PurchaseDownloadObserver.swift @@ -9,11 +9,16 @@ import CommerceKit import StoreFoundation +private let downloadingPhase: Int64 = 0 +private let installingPhase: Int64 = 1 +private let downloadedPhase: Int64 = 5 + @objc class PurchaseDownloadObserver: NSObject, CKDownloadQueueObserver { let purchase: SSPurchase var completionHandler: (() -> Void)? var errorHandler: ((MASError) -> Void)? + var priorPhaseType: Int64? init(purchase: SSPurchase) { self.purchase = purchase @@ -30,6 +35,21 @@ class PurchaseDownloadObserver: NSObject, CKDownloadQueueObserver { if status.isFailed || status.isCancelled { queue.removeDownload(withItemIdentifier: download.metadata.itemIdentifier) } else { + if priorPhaseType != status.activePhase.phaseType { + switch status.activePhase.phaseType { + case downloadedPhase: + if priorPhaseType == downloadingPhase { + clearLine() + printInfo("Downloaded \(download.progressDescription)") + } + case installingPhase: + clearLine() + printInfo("Installing \(download.progressDescription)") + default: + break + } + priorPhaseType = status.activePhase.phaseType + } progress(status.progressState) } } @@ -39,7 +59,7 @@ class PurchaseDownloadObserver: NSObject, CKDownloadQueueObserver { return } clearLine() - printInfo("Downloading \(download.metadata.title)") + printInfo("Downloading \(download.progressDescription)") } func downloadQueue(_: CKDownloadQueue, changedWithRemoval download: SSDownload) { @@ -56,7 +76,7 @@ class PurchaseDownloadObserver: NSObject, CKDownloadQueueObserver { } else if status.isCancelled { errorHandler?(.cancelled) } else { - printInfo("Installed \(download.metadata.title)") + printInfo("Installed \(download.progressDescription)") completionHandler?() } } @@ -94,6 +114,12 @@ func progress(_ state: ProgressState) { fflush(stdout) } +private extension SSDownload { + var progressDescription: String { + "\(metadata.title) (\(metadata.bundleVersion ?? "unknown version"))" + } +} + extension SSDownloadStatus { var progressState: ProgressState { ProgressState(percentComplete: percentComplete, phase: activePhase.phaseDescription) @@ -103,9 +129,9 @@ extension SSDownloadStatus { extension SSDownloadPhase { var phaseDescription: String { switch phaseType { - case 0: + case downloadingPhase: return "Downloading" - case 1: + case installingPhase: return "Installing" default: return "Waiting" diff --git a/Tests/masTests/.swift-format b/Tests/masTests/.swift-format index 571f02f..9f8e2f7 100644 --- a/Tests/masTests/.swift-format +++ b/Tests/masTests/.swift-format @@ -28,7 +28,7 @@ "NeverForceUnwrap": false, "NeverUseForceTry": false, "NeverUseImplicitlyUnwrappedOptionals": true, - "NoAccessLevelOnExtensionDeclaration": true, + "NoAccessLevelOnExtensionDeclaration": false, "NoAssignmentInExpressions": true, "NoBlockComments": true, "NoCasesWithOnlyFallthrough": true,