Merge branch 'main' into releases/release-1.8.7

# Conflicts:
#	.swiftlint.yml
#	script/bottle
This commit is contained in:
Ben Chatelain 2024-11-02 12:08:50 -06:00
commit 750930e01a
No known key found for this signature in database
59 changed files with 557 additions and 770 deletions

View file

@ -28,7 +28,7 @@
"NeverForceUnwrap": true, "NeverForceUnwrap": true,
"NeverUseForceTry": true, "NeverUseForceTry": true,
"NeverUseImplicitlyUnwrappedOptionals": true, "NeverUseImplicitlyUnwrappedOptionals": true,
"NoAccessLevelOnExtensionDeclaration": true, "NoAccessLevelOnExtensionDeclaration": false,
"NoAssignmentInExpressions": true, "NoAssignmentInExpressions": true,
"NoBlockComments": true, "NoBlockComments": true,
"NoCasesWithOnlyFallthrough": true, "NoCasesWithOnlyFallthrough": true,

View file

@ -1 +1 @@
5.7 5.7.1

View file

@ -31,7 +31,6 @@
# Rule options # Rule options
--commas always --commas always
--extensionacl on-declarations
--hexliteralcase lowercase --hexliteralcase lowercase
--importgrouping testable-last --importgrouping testable-last
--lineaftermarks false --lineaftermarks false

View file

@ -22,6 +22,7 @@ disabled_rules:
- function_body_length - function_body_length
- inert_defer - inert_defer
- legacy_objc_type - legacy_objc_type
- no_extension_access_modifier
- no_grouping_extension - no_grouping_extension
- number_separator - number_separator
- one_declaration_per_file - one_declaration_per_file

View file

@ -12,13 +12,7 @@ CMD_NAME = mas
SHELL = /bin/sh SHELL = /bin/sh
PREFIX ?= $(shell brew --prefix) PREFIX ?= $(shell brew --prefix)
# trunk SWIFT_VERSION = 5.7.1
# 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
# set EXECUTABLE_DIRECTORY according to your specific environment # set EXECUTABLE_DIRECTORY according to your specific environment
# run swift build and see where the output executable is created # run swift build and see where the output executable is created

View file

@ -32,8 +32,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/mxcl/PromiseKit.git", "location" : "https://github.com/mxcl/PromiseKit.git",
"state" : { "state" : {
"revision" : "8a98e31a47854d3180882c8068cc4d9381bf382d", "revision" : "6fcc08077124e9747f1ec7bd8bb78f5caffe5a79",
"version" : "6.22.1" "version" : "8.1.2"
} }
}, },
{ {

View file

@ -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. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@ -6,7 +6,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "mas", name: "mas",
platforms: [ platforms: [
.macOS(.v10_11) .macOS(.v10_13)
], ],
products: [ products: [
// Products define the executables and libraries a package produces, and make them visible to other packages. // Products define the executables and libraries a package produces, and make them visible to other packages.
@ -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/Nimble.git", from: "10.0.0"),
.package(url: "https://github.com/Quick/Quick.git", from: "5.0.1"), .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/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/mxcl/Version.git", from: "2.1.0"),
.package(url: "https://github.com/sharplet/Regex.git", from: "2.1.1"), .package(url: "https://github.com/sharplet/Regex.git", from: "2.1.1"),
], ],

View file

@ -5,7 +5,7 @@
<options customize="never" require-scripts="false"/> <options customize="never" require-scripts="false"/>
<volume-check> <volume-check>
<allowed-os-versions> <allowed-os-versions>
<os-version min="10.11"/> <os-version min="10.13"/>
</allowed-os-versions> </allowed-os-versions>
</volume-check> </volume-check>
<choices-outline> <choices-outline>

View file

@ -41,7 +41,8 @@ sudo port install mas
#### 🍻 Custom Homebrew tap #### 🍻 Custom Homebrew tap
We provide a [custom Homebrew tap](https://github.com/mas-cli/homebrew-tap) with pre-built bottles 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: To install mas from our tap:

View file

@ -10,21 +10,53 @@ import CommerceKit
import PromiseKit import PromiseKit
import StoreFoundation 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: /// - Parameters:
/// - appIDs: The IDs of the apps to be downloaded /// - unverifiedAppIDs: The app IDs of the apps to be verified and downloaded.
/// - purchase: Flag indicating whether the apps needs to be purchased. /// - searcher: The `AppStoreSearcher` used to verify app IDs.
/// 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 downloadApps(
withAppIDs unverifiedAppIDs: [AppID],
verifiedBy searcher: AppStoreSearcher,
purchasing: Bool = false
) -> Promise<Void> {
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, /// - 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. /// the promise is rejected with the first error, after all remaining downloads are attempted.
func downloadAll(_ appIDs: [AppID], purchase: Bool = false) -> Promise<Void> { func downloadApps(withAppIDs appIDs: [AppID], purchasing: Bool = false) -> Promise<Void> {
var firstError: Error? var firstError: Error?
return return
appIDs appIDs
.reduce(Guarantee.value(())) { previous, appID in .reduce(Guarantee.value(())) { previous, appID in
previous.then { previous.then {
downloadWithRetries(appID, purchase: purchase) downloadApp(withAppID: appID, purchasing: purchasing)
.recover { error in .recover { error in
if firstError == nil { if firstError == nil {
firstError = error firstError = error
@ -39,10 +71,15 @@ func downloadAll(_ appIDs: [AppID], purchase: Bool = false) -> Promise<Void> {
} }
} }
private func downloadWithRetries(_ appID: AppID, purchase: Bool = false, attempts: Int = 3) -> Promise<Void> { private func downloadApp(
SSPurchase().perform(appID: appID, purchase: purchase) withAppID appID: AppID,
purchasing: Bool = false,
withAttemptCount attemptCount: UInt32 = 3
) -> Promise<Void> {
SSPurchase()
.perform(appID: appID, purchasing: purchasing)
.recover { error in .recover { error in
guard attempts > 1 else { guard attemptCount > 1 else {
throw error throw error
} }
@ -54,9 +91,9 @@ private func downloadWithRetries(_ appID: AppID, purchase: Bool = false, attempt
throw error throw error
} }
let attempts = attempts - 1 let attemptCount = attemptCount - 1
printWarning((downloadError ?? error).localizedDescription) printWarning((downloadError ?? error).localizedDescription)
printWarning("Trying again up to \(attempts) more \(attempts == 1 ? "time" : "times").") printWarning("Trying again up to \(attemptCount) more \(attemptCount == 1 ? "time" : "times").")
return downloadWithRetries(appID, purchase: purchase, attempts: attempts) return downloadApp(withAppID: appID, purchasing: purchasing, withAttemptCount: attemptCount)
} }
} }

View file

@ -14,8 +14,7 @@ private let timeout = 30.0
extension ISStoreAccount: StoreAccount { extension ISStoreAccount: StoreAccount {
static var primaryAccount: Promise<ISStoreAccount> { static var primaryAccount: Promise<ISStoreAccount> {
if #available(macOS 10.13, *) { race(
return race(
Promise { seal in Promise { seal in
ISServiceProxy.genericShared().accountService ISServiceProxy.genericShared().accountService
.primaryAccount { storeAccount in .primaryAccount { storeAccount in
@ -29,66 +28,9 @@ extension ISStoreAccount: StoreAccount {
) )
} }
return .value(CKAccountStore.shared().primaryAccount) static func signIn(appleID _: String, password _: String, systemDialog _: Bool) -> Promise<ISStoreAccount> {
}
static func signIn(appleID: String, password: String, systemDialog: Bool) -> Promise<ISStoreAccount> {
// swift-format-ignore: UseEarlyExits
if #available(macOS 10.13, *) {
// Signing in is no longer possible as of High Sierra. // Signing in is no longer possible as of High Sierra.
// https://github.com/mas-cli/mas/issues/164 // https://github.com/mas-cli/mas/issues/164
return Promise(error: MASError.notSupported) Promise(error: MASError.notSupported)
// swiftlint:disable:next superfluous_else
} else {
return
primaryAccount
.then { account -> Promise<ISStoreAccount> 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<ISStoreAccount> { 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))
}
)
}
}
} }
} }

View file

@ -9,11 +9,16 @@
import CommerceKit import CommerceKit
import StoreFoundation import StoreFoundation
private let downloadingPhase: Int64 = 0
private let installingPhase: Int64 = 1
private let downloadedPhase: Int64 = 5
@objc @objc
class PurchaseDownloadObserver: NSObject, CKDownloadQueueObserver { class PurchaseDownloadObserver: NSObject, CKDownloadQueueObserver {
let purchase: SSPurchase let purchase: SSPurchase
var completionHandler: (() -> Void)? var completionHandler: (() -> Void)?
var errorHandler: ((MASError) -> Void)? var errorHandler: ((MASError) -> Void)?
var priorPhaseType: Int64?
init(purchase: SSPurchase) { init(purchase: SSPurchase) {
self.purchase = purchase self.purchase = purchase
@ -30,6 +35,21 @@ class PurchaseDownloadObserver: NSObject, CKDownloadQueueObserver {
if status.isFailed || status.isCancelled { if status.isFailed || status.isCancelled {
queue.removeDownload(withItemIdentifier: download.metadata.itemIdentifier) queue.removeDownload(withItemIdentifier: download.metadata.itemIdentifier)
} else { } 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) progress(status.progressState)
} }
} }
@ -39,7 +59,7 @@ class PurchaseDownloadObserver: NSObject, CKDownloadQueueObserver {
return return
} }
clearLine() clearLine()
printInfo("Downloading \(download.metadata.title)") printInfo("Downloading \(download.progressDescription)")
} }
func downloadQueue(_: CKDownloadQueue, changedWithRemoval download: SSDownload) { func downloadQueue(_: CKDownloadQueue, changedWithRemoval download: SSDownload) {
@ -56,7 +76,7 @@ class PurchaseDownloadObserver: NSObject, CKDownloadQueueObserver {
} else if status.isCancelled { } else if status.isCancelled {
errorHandler?(.cancelled) errorHandler?(.cancelled)
} else { } else {
printInfo("Installed \(download.metadata.title)") printInfo("Installed \(download.progressDescription)")
completionHandler?() completionHandler?()
} }
} }
@ -94,6 +114,12 @@ func progress(_ state: ProgressState) {
fflush(stdout) fflush(stdout)
} }
private extension SSDownload {
var progressDescription: String {
"\(metadata.title) (\(metadata.bundleVersion ?? "unknown version"))"
}
}
extension SSDownloadStatus { extension SSDownloadStatus {
var progressState: ProgressState { var progressState: ProgressState {
ProgressState(percentComplete: percentComplete, phase: activePhase.phaseDescription) ProgressState(percentComplete: percentComplete, phase: activePhase.phaseDescription)
@ -103,9 +129,9 @@ extension SSDownloadStatus {
extension SSDownloadPhase { extension SSDownloadPhase {
var phaseDescription: String { var phaseDescription: String {
switch phaseType { switch phaseType {
case 0: case downloadingPhase:
return "Downloading" return "Downloading"
case 1: case installingPhase:
return "Installing" return "Installing"
default: default:
return "Waiting" return "Waiting"

View file

@ -11,7 +11,7 @@ import PromiseKit
import StoreFoundation import StoreFoundation
extension SSPurchase { extension SSPurchase {
func perform(appID: AppID, purchase: Bool) -> Promise<Void> { func perform(appID: AppID, purchasing: Bool) -> Promise<Void> {
var parameters: [String: Any] = [ var parameters: [String: Any] = [
"productType": "C", "productType": "C",
"price": 0, "price": 0,
@ -20,9 +20,11 @@ extension SSPurchase {
"appExtVrsId": 0, "appExtVrsId": 0,
] ]
if purchase { if purchasing {
parameters["macappinstalledconfirmed"] = 1 parameters["macappinstalledconfirmed"] = 1
parameters["pricingParameters"] = "STDQ" parameters["pricingParameters"] = "STDQ"
// Possibly unnecessary
isRedownload = false
} else { } else {
parameters["pricingParameters"] = "STDRDL" parameters["pricingParameters"] = "STDRDL"
} }
@ -35,11 +37,6 @@ extension SSPurchase {
itemIdentifier = appID itemIdentifier = appID
// Not sure if this is needed
if purchase {
isRedownload = false
}
downloadMetadata = SSDownloadMetadata() downloadMetadata = SSDownloadMetadata()
downloadMetadata.kind = "software" downloadMetadata.kind = "software"
downloadMetadata.itemIdentifier = appID downloadMetadata.itemIdentifier = appID

View file

@ -12,7 +12,7 @@ import StoreFoundation
extension MAS { extension MAS {
struct Account: ParsableCommand { struct Account: ParsableCommand {
static let configuration = CommandConfiguration( 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. /// Runs the command.

View file

@ -7,43 +7,32 @@
// //
import ArgumentParser import ArgumentParser
import Foundation
extension MAS { extension MAS {
/// Opens app page on MAS Preview. Uses the iTunes Lookup API: /// Opens app page on MAS Preview. Uses the iTunes Lookup API:
/// https://performance-partners.apple.com/search-api /// https://performance-partners.apple.com/search-api
struct Home: ParsableCommand { struct Home: ParsableCommand {
static let configuration = CommandConfiguration( 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 var appID: AppID
/// Runs the command. /// Runs the command.
func run() throws { func run() throws {
try run(searcher: ITunesSearchAppStoreSearcher(), openCommand: OpenSystemCommand()) try run(searcher: ITunesSearchAppStoreSearcher())
} }
func run(searcher: AppStoreSearcher, openCommand: ExternalCommand) throws { func run(searcher: AppStoreSearcher) throws {
do { let result = try searcher.lookup(appID: appID).wait()
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)")
} }
do { try url.open().wait()
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
}
} }
} }
} }

View file

@ -17,7 +17,7 @@ extension MAS {
abstract: "Display app information from the Mac App Store" abstract: "Display app information from the Mac App Store"
) )
@Argument(help: "ID of app to show info") @Argument(help: "App ID")
var appID: AppID var appID: AppID
/// Runs the command. /// Runs the command.
@ -27,11 +27,7 @@ extension MAS {
func run(searcher: AppStoreSearcher) throws { func run(searcher: AppStoreSearcher) throws {
do { do {
guard let result = try searcher.lookup(appID: appID).wait() else { print(AppInfoFormatter.format(app: try searcher.lookup(appID: appID).wait()))
throw MASError.noSearchResultsFound
}
print(AppInfoFormatter.format(app: result))
} catch { } catch {
throw error as? MASError ?? .searchFailed throw error as? MASError ?? .searchFailed
} }

View file

@ -13,20 +13,20 @@ extension MAS {
/// Installs previously purchased apps from the Mac App Store. /// Installs previously purchased apps from the Mac App Store.
struct Install: ParsableCommand { struct Install: ParsableCommand {
static let configuration = CommandConfiguration( 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 var force = false
@Argument(help: "app ID(s) to install") @Argument(help: ArgumentHelp("App ID", valueName: "app-id"))
var appIDs: [AppID] var appIDs: [AppID]
/// Runs the command. /// Runs the command.
func run() throws { 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 // Try to download applications with given identifiers and collect results
let appIDs = appIDs.filter { appID in let appIDs = appIDs.filter { appID in
if let appName = appLibrary.installedApps(withAppID: appID).first?.appName, !force { if let appName = appLibrary.installedApps(withAppID: appID).first?.appName, !force {
@ -38,7 +38,7 @@ extension MAS {
} }
do { do {
try downloadAll(appIDs).wait() try downloadApps(withAppIDs: appIDs, verifiedBy: searcher).wait()
} catch { } catch {
throw error as? MASError ?? .downloadFailed(error: error as NSError) throw error as? MASError ?? .downloadFailed(error: error as NSError)
} }

View file

@ -12,7 +12,7 @@ extension MAS {
/// Command which lists all installed apps. /// Command which lists all installed apps.
struct List: ParsableCommand { struct List: ParsableCommand {
static let configuration = CommandConfiguration( 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"
) )
/// Runs the command. /// Runs the command.

View file

@ -15,12 +15,16 @@ extension MAS {
/// This is handy as many MAS titles can be long with embedded keywords. /// This is handy as many MAS titles can be long with embedded keywords.
struct Lucky: ParsableCommand { struct Lucky: ParsableCommand {
static let configuration = CommandConfiguration( 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 var force = false
@Argument(help: "the app name to install") @Argument(help: "Search term")
var searchTerm: String var searchTerm: String
/// Runs the command. /// Runs the command.
@ -34,7 +38,6 @@ extension MAS {
do { do {
let results = try searcher.search(for: searchTerm).wait() let results = try searcher.search(for: searchTerm).wait()
guard let result = results.first else { guard let result = results.first else {
printError("No results found")
throw MASError.noSearchResultsFound throw MASError.noSearchResultsFound
} }
@ -62,7 +65,7 @@ extension MAS {
printWarning("\(appName) is already installed") printWarning("\(appName) is already installed")
} else { } else {
do { do {
try downloadAll([appID]).wait() try downloadApps(withAppIDs: [appID]).wait()
} catch { } catch {
throw error as? MASError ?? .downloadFailed(error: error as NSError) throw error as? MASError ?? .downloadFailed(error: error as NSError)
} }

View file

@ -6,8 +6,9 @@
// Copyright © 2016 mas-cli. All rights reserved. // Copyright © 2016 mas-cli. All rights reserved.
// //
import AppKit
import ArgumentParser import ArgumentParser
import Foundation import PromiseKit
private let masScheme = "macappstore" private let masScheme = "macappstore"
@ -16,51 +17,63 @@ extension MAS {
/// https://performance-partners.apple.com/search-api /// https://performance-partners.apple.com/search-api
struct Open: ParsableCommand { struct Open: ParsableCommand {
static let configuration = CommandConfiguration( 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? var appID: AppID?
/// Runs the command. /// Runs the command.
func run() throws { func run() throws {
try run(searcher: ITunesSearchAppStoreSearcher(), openCommand: OpenSystemCommand()) try run(searcher: ITunesSearchAppStoreSearcher())
} }
func run(searcher: AppStoreSearcher, openCommand: ExternalCommand) throws { func run(searcher: AppStoreSearcher) throws {
do {
guard let appID else { guard let appID else {
// If no app ID is given, just open the MAS GUI app // If no app ID is given, just open the MAS GUI app
try openCommand.run(arguments: masScheme + "://") try openMacAppStore().wait()
return return
} }
try openInMacAppStore(pageForAppID: appID, searcher: searcher)
guard let result = try searcher.lookup(appID: appID).wait() else { }
throw MASError.noSearchResultsFound }
} }
guard var url = URLComponents(string: result.trackViewUrl) else { private func openMacAppStore() -> Promise<Void> {
throw MASError.searchFailed 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
} }
url.scheme = masScheme
guard let urlString = url.string else { if #available(macOS 10.15, *) {
printError("Unable to construct URL") NSWorkspace.shared.openApplication(at: appURL, configuration: NSWorkspace.OpenConfiguration()) { _, error in
throw MASError.searchFailed if let error {
seal.reject(error)
} }
do { seal.fulfill(())
try openCommand.run(arguments: urlString)
} catch {
printError("Unable to launch open command")
throw MASError.searchFailed
} }
if openCommand.failed { } else {
printError("Open failed: (\(openCommand.process.terminationReason)) \(openCommand.stderr)") try NSWorkspace.shared.launchApplication(at: appURL, configuration: [:])
throw MASError.searchFailed seal.fulfill(())
}
} catch {
throw error as? MASError ?? .searchFailed
} }
} }
} }
private func openInMacAppStore(pageForAppID appID: AppID, searcher: AppStoreSearcher) throws {
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)")
}
urlComponents.scheme = masScheme
guard let url = urlComponents.url else {
throw MASError.runtimeError("Unable to construct URL from: \(urlComponents)")
}
try url.open().wait()
} }

View file

@ -15,10 +15,10 @@ extension MAS {
/// ready to be installed from the Mac App Store. /// ready to be installed from the Mac App Store.
struct Outdated: ParsableCommand { struct Outdated: ParsableCommand {
static let configuration = CommandConfiguration( static let configuration = CommandConfiguration(
abstract: "Lists pending updates from the Mac App Store" abstract: "List pending app updates from the Mac App Store"
) )
@Flag(help: "Show warnings about apps") @Flag(help: "Display warnings about apps unknown to the Mac App Store")
var verbose = false var verbose = false
/// Runs the command. /// Runs the command.
@ -30,22 +30,8 @@ extension MAS {
_ = try when( _ = try when(
fulfilled: fulfilled:
appLibrary.installedApps.map { installedApp in appLibrary.installedApps.map { installedApp in
firstly {
searcher.lookup(appID: installedApp.itemIdentifier.appIDValue) searcher.lookup(appID: installedApp.itemIdentifier.appIDValue)
}
.done { storeApp in .done { storeApp in
guard let storeApp else {
if verbose {
printWarning(
"""
Identifier \(installedApp.itemIdentifier) not found in store. \
Was expected to identify \(installedApp.appName).
"""
)
}
return
}
if installedApp.isOutdatedWhenComparedTo(storeApp) { if installedApp.isOutdatedWhenComparedTo(storeApp) {
print( print(
""" """
@ -55,6 +41,20 @@ extension MAS {
) )
} }
} }
.recover { error in
guard case MASError.unknownAppID = error else {
throw error
}
if verbose {
printWarning(
"""
Identifier \(installedApp.itemIdentifier) not found in store. \
Was expected to identify \(installedApp.appName).
"""
)
}
}
} }
) )
.wait() .wait()

View file

@ -12,18 +12,18 @@ import CommerceKit
extension MAS { extension MAS {
struct Purchase: ParsableCommand { struct Purchase: ParsableCommand {
static let configuration = CommandConfiguration( 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: ArgumentHelp("App ID", valueName: "app-id"))
var appIDs: [AppID] var appIDs: [AppID]
/// Runs the command. /// Runs the command.
func run() throws { 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 // Try to download applications with given identifiers and collect results
let appIDs = appIDs.filter { appID in let appIDs = appIDs.filter { appID in
if let appName = appLibrary.installedApps(withAppID: appID).first?.appName { if let appName = appLibrary.installedApps(withAppID: appID).first?.appName {
@ -35,7 +35,7 @@ extension MAS {
} }
do { do {
try downloadAll(appIDs, purchase: true).wait() try downloadApps(withAppIDs: appIDs, verifiedBy: searcher, purchasing: true).wait()
} catch { } catch {
throw error as? MASError ?? .downloadFailed(error: error as NSError) throw error as? MASError ?? .downloadFailed(error: error as NSError)
} }

View file

@ -13,10 +13,10 @@ extension MAS {
/// Kills several macOS processes as a means to reset the app store. /// Kills several macOS processes as a means to reset the app store.
struct Reset: ParsableCommand { struct Reset: ParsableCommand {
static let configuration = CommandConfiguration( 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 var debug = false
/// Runs the command. /// Runs the command.

View file

@ -17,9 +17,9 @@ extension MAS {
abstract: "Search for apps from the Mac App Store" 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 var price = false
@Argument(help: "the app name to search") @Argument(help: "Search term")
var searchTerm: String var searchTerm: String
func run() throws { func run() throws {

View file

@ -13,10 +13,10 @@ extension MAS {
struct SignIn: ParsableCommand { struct SignIn: ParsableCommand {
static let configuration = CommandConfiguration( static let configuration = CommandConfiguration(
commandName: "signin", 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 var dialog = false
@Argument(help: "Apple ID") @Argument(help: "Apple ID")
var appleID: String var appleID: String

View file

@ -13,18 +13,12 @@ extension MAS {
struct SignOut: ParsableCommand { struct SignOut: ParsableCommand {
static let configuration = CommandConfiguration( static let configuration = CommandConfiguration(
commandName: "signout", 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. /// Runs the command.
func run() throws { func run() throws {
if #available(macOS 10.13, *) {
ISServiceProxy.genericShared().accountService.signOut() 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()
}
} }
} }
} }

View file

@ -17,9 +17,9 @@ extension MAS {
) )
/// Flag indicating that removal shouldn't be performed. /// Flag indicating that removal shouldn't be performed.
@Flag(help: "dry run") @Flag(help: "Perform dry run")
var dryRun = false var dryRun = false
@Argument(help: "ID of app to uninstall") @Argument(help: "App ID")
var appID: AppID var appID: AppID
/// Runs the uninstall command. /// Runs the uninstall command.
@ -32,12 +32,12 @@ extension MAS {
throw MASError.macOSUserMustBeRoot throw MASError.macOSUserMustBeRoot
} }
guard let username = getSudoUsername() else { guard let username = ProcessInfo.processInfo.sudoUsername else {
throw MASError.runtimeError("Could not determine the original username") throw MASError.runtimeError("Could not determine the original username")
} }
guard guard
let uid = getSudoUID(), let uid = ProcessInfo.processInfo.sudoUID,
seteuid(uid) == 0 seteuid(uid) == 0
else { else {
throw MASError.runtimeError("Failed to switch effective user from 'root' to '\(username)'") throw MASError.runtimeError("Failed to switch effective user from 'root' to '\(username)'")

View file

@ -14,11 +14,12 @@ extension MAS {
/// Command which upgrades apps with new versions available in the Mac App Store. /// Command which upgrades apps with new versions available in the Mac App Store.
struct Upgrade: ParsableCommand { struct Upgrade: ParsableCommand {
static let configuration = CommandConfiguration( static let configuration = CommandConfiguration(
abstract: "Upgrade outdated apps from the Mac App Store" abstract:
"Upgrade outdated app(s) installed from the Mac App Store"
) )
@Argument(help: "app(s) to upgrade") @Argument(help: ArgumentHelp("App ID/app name", valueName: "app-id-or-name"))
var appIDs: [String] = [] var appIDOrNames: [String] = []
/// Runs the command. /// Runs the command.
func run() throws { func run() throws {
@ -34,7 +35,6 @@ extension MAS {
} }
guard !apps.isEmpty else { guard !apps.isEmpty else {
printWarning("Nothing found to upgrade")
return return
} }
@ -45,7 +45,7 @@ extension MAS {
) )
do { do {
try downloadAll(apps.map(\.installedApp.itemIdentifier.appIDValue)).wait() try downloadApps(withAppIDs: apps.map(\.installedApp.itemIdentifier.appIDValue)).wait()
} catch { } catch {
throw error as? MASError ?? .downloadFailed(error: error as NSError) throw error as? MASError ?? .downloadFailed(error: error as NSError)
} }
@ -56,30 +56,41 @@ extension MAS {
searcher: AppStoreSearcher searcher: AppStoreSearcher
) throws -> [(SoftwareProduct, SearchResult)] { ) throws -> [(SoftwareProduct, SearchResult)] {
let apps = let apps =
appIDs.isEmpty appIDOrNames.isEmpty
? appLibrary.installedApps ? appLibrary.installedApps
: appIDs.flatMap { appID in : appIDOrNames.flatMap { appIDOrName in
if let appID = AppID(appID) { if let appID = AppID(appIDOrName) {
// argument is an AppID, lookup apps by id using argument // 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(appID.unknownMessage)
}
return installedApps
} }
// argument is not an AppID, lookup apps by name using argument // 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 let promises = apps.map { installedApp in
// only upgrade apps whose local version differs from the store version // only upgrade apps whose local version differs from the store version
firstly {
searcher.lookup(appID: installedApp.itemIdentifier.appIDValue) searcher.lookup(appID: installedApp.itemIdentifier.appIDValue)
} .map { storeApp -> (SoftwareProduct, SearchResult)? in
.map { result -> (SoftwareProduct, SearchResult)? in guard installedApp.isOutdatedWhenComparedTo(storeApp) else {
guard let storeApp = result, installedApp.isOutdatedWhenComparedTo(storeApp) else {
return nil return nil
} }
return (installedApp, storeApp) return (installedApp, storeApp)
} }
.recover { error -> Promise<(SoftwareProduct, SearchResult)?> in
guard case MASError.unknownAppID = error else {
return Promise(error: error)
}
return .value(nil)
}
} }
return try when(fulfilled: promises).wait().compactMap { $0 } return try when(fulfilled: promises).wait().compactMap { $0 }

View file

@ -7,47 +7,36 @@
// //
import ArgumentParser import ArgumentParser
import Foundation
extension MAS { extension MAS {
/// Opens vendor's app page in a browser. Uses the iTunes Lookup API: /// Opens vendor's app page in a browser. Uses the iTunes Lookup API:
/// https://performance-partners.apple.com/search-api /// https://performance-partners.apple.com/search-api
struct Vendor: ParsableCommand { struct Vendor: ParsableCommand {
static let configuration = CommandConfiguration( 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 var appID: AppID
/// Runs the command. /// Runs the command.
func run() throws { func run() throws {
try run(searcher: ITunesSearchAppStoreSearcher(), openCommand: OpenSystemCommand()) try run(searcher: ITunesSearchAppStoreSearcher())
} }
func run(searcher: AppStoreSearcher, openCommand: ExternalCommand) throws { func run(searcher: AppStoreSearcher) throws {
do { let result = try searcher.lookup(appID: appID).wait()
guard let result = try searcher.lookup(appID: appID).wait() else {
throw MASError.noSearchResultsFound
}
guard let vendorWebsite = result.sellerUrl else { guard let urlString = result.sellerUrl else {
throw MASError.noVendorWebsite throw MASError.noVendorWebsite
} }
do { guard let url = URL(string: urlString) else {
try openCommand.run(arguments: vendorWebsite) throw MASError.runtimeError("Unable to construct URL from: \(urlString)")
} 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
} }
try url.open().wait()
} }
} }
} }

View file

@ -12,7 +12,7 @@ extension MAS {
/// Command which displays the version of the mas tool. /// Command which displays the version of the mas tool.
struct Version: ParsableCommand { struct Version: ParsableCommand {
static let configuration = CommandConfiguration( static let configuration = CommandConfiguration(
abstract: "Print version number" abstract: "Display version number"
) )
/// Runs the command. /// Runs the command.

View file

@ -11,86 +11,17 @@ import PromiseKit
/// Protocol for searching the MAS catalog. /// Protocol for searching the MAS catalog.
protocol AppStoreSearcher { protocol AppStoreSearcher {
func lookup(appID: AppID) -> Promise<SearchResult?> /// Looks up app details.
///
/// - 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.
/// A `Promise` for some other `Error` if any problems occur.
func lookup(appID: AppID) -> Promise<SearchResult>
/// Searches for apps.
///
/// - Parameter searchTerm: Term for which to search.
/// - Returns: A `Promise` for an `Array` of `SearchResult`s matching `searchTerm`.
func search(for searchTerm: String) -> Promise<[SearchResult]> 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
}
}

View file

@ -32,58 +32,27 @@ class ITunesSearchAppStoreSearcher: AppStoreSearcher {
self.networkManager = networkManager self.networkManager = networkManager
} }
/// Searches for an app. /// - Parameter appID: App ID.
/// /// - Returns: A `Promise` for the `SearchResult` for the given `appID` if `appID` is valid.
/// - Parameter searchTerm: a search term matched against app names /// A `Promise` for `MASError.unknownAppID(appID)` if `appID` is invalid.
/// - Returns: A Promise of an Array of SearchResults matching searchTerm /// A `Promise` for some other `Error` if any problems occur.
func search(for searchTerm: String) -> Promise<[SearchResult]> { func lookup(appID: AppID) -> 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<AppID>()
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.
func lookup(appID: AppID) -> Promise<SearchResult?> {
guard let url = lookupURL(forAppID: appID, inCountry: country) else { guard let url = lookupURL(forAppID: appID, inCountry: country) else {
fatalError("Failed to build URL for \(appID)") fatalError("Failed to build URL for \(appID)")
} }
return firstly { return
loadSearchResults(url) loadSearchResults(url)
} .then { results -> Guarantee<SearchResult> in
.then { results -> Guarantee<SearchResult?> in
guard let result = results.first else { guard let result = results.first else {
return .value(nil) throw MASError.unknownAppID(appID)
} }
guard let pageURL = URL(string: result.trackViewUrl) else { guard let pageURL = URL(string: result.trackViewUrl) else {
return .value(result) return .value(result)
} }
return firstly { return
self.scrapeAppStoreVersion(pageURL) self.scrapeAppStoreVersion(pageURL)
}
.map { pageVersion in .map { pageVersion in
guard guard
let pageVersion, let pageVersion,
@ -105,10 +74,36 @@ class ITunesSearchAppStoreSearcher: AppStoreSearcher {
} }
} }
private func loadSearchResults(_ url: URL) -> Promise<[SearchResult]> { /// Searches for apps from the MAS.
firstly { ///
networkManager.loadData(from: url) /// - Parameter searchTerm: Term for which to search in the MAS.
/// - 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.
var entities = [Entity.desktopSoftware]
#if arch(arm64)
entities += [.iPadSoftware, .iPhoneSoftware]
#endif
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<AppID>()
return when(fulfilled: results)
.flatMapValues { $0 }
.filterValues { result in
seenAppIDs.insert(result.trackId).inserted
}
}
private func loadSearchResults(_ url: URL) -> Promise<[SearchResult]> {
networkManager.loadData(from: url)
.map { data in .map { data in
do { do {
return try JSONDecoder().decode(SearchResultList.self, from: data).results return try JSONDecoder().decode(SearchResultList.self, from: data).results
@ -122,9 +117,7 @@ class ITunesSearchAppStoreSearcher: AppStoreSearcher {
/// ///
/// App Store webpages frequently report a version that is newer than what is reported by the iTunes Search API. /// 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<Version?> { private func scrapeAppStoreVersion(_ pageURL: URL) -> Promise<Version?> {
firstly {
networkManager.loadData(from: pageURL) networkManager.loadData(from: pageURL)
}
.map { data in .map { data in
guard guard
let html = String(data: data, encoding: .utf8), let html = String(data: data, encoding: .utf8),
@ -137,4 +130,81 @@ class ITunesSearchAppStoreSearcher: AppStoreSearcher {
return version 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"
}
}
} }

View file

@ -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) { private func getOwnerAndGroupOfItem(atPath path: String) throws -> (uid_t, gid_t) {
do { do {
let attributes = try FileManager.default.attributesOfItem(atPath: path) 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)] { 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") 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") throw MASError.runtimeError("Failed to get original gid")
} }

View file

@ -27,6 +27,9 @@ enum MASError: Error, Equatable {
case searchFailed case searchFailed
case noSearchResultsFound case noSearchResultsFound
case unknownAppID(AppID)
case noVendorWebsite case noVendorWebsite
case notInstalled(appID: AppID) case notInstalled(appID: AppID)
@ -82,7 +85,9 @@ extension MASError: CustomStringConvertible {
case .searchFailed: case .searchFailed:
return "Search failed" return "Search failed"
case .noSearchResultsFound: case .noSearchResultsFound:
return "No results found" return "No apps found"
case .unknownAppID(let appID):
return appID.unknownMessage
case .noVendorWebsite: case .noVendorWebsite:
return "App does not have a vendor website" return "App does not have a vendor website"
case .notInstalled(let appID): case .notInstalled(let appID):

View file

@ -1,70 +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
if #available(macOS 10.13, *) {
process.executableURL = URL(fileURLWithPath: binaryPath)
try process.run()
} else {
process.launchPath = binaryPath
process.launch()
}
process.waitUntilExit()
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -47,12 +47,8 @@ enum AppInfoFormatter {
/// - Parameter serverDate: String containing a date in ISO-8601 format. /// - Parameter serverDate: String containing a date in ISO-8601 format.
/// - Returns: Simple date format. /// - Returns: Simple date format.
private static func humanReadableDate(_ serverDate: String) -> String { private static func humanReadableDate(_ serverDate: String) -> String {
let serverDateFormatter = DateFormatter() let humanDateFormatter = ISO8601DateFormatter()
serverDateFormatter.locale = Locale(identifier: "en_US_POSIX") humanDateFormatter.formatOptions = [.withFullDate]
serverDateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" return ISO8601DateFormatter().date(from: serverDate).map(humanDateFormatter.string(from:)) ?? ""
let humanDateFormatter = DateFormatter()
humanDateFormatter.dateFormat = "yyyy-MM-dd"
return serverDateFormatter.date(from: serverDate).flatMap(humanDateFormatter.string(from:)) ?? ""
} }
} }

View file

@ -7,7 +7,6 @@
// //
import ArgumentParser import ArgumentParser
import Foundation
import PromiseKit import PromiseKit
@main @main
@ -55,11 +54,3 @@ struct MAS: ParsableCommand {
Self.initialize() Self.initialize()
} }
} }
typealias AppID = UInt64
extension NSNumber {
var appIDValue: AppID {
uint64Value
}
}

View file

@ -0,0 +1,23 @@
//
// AppID.swift
// mas
//
// Created by Ross Goldberg on 2024-10-29.
// Copyright © 2024 mas-cli. All rights reserved.
//
import Foundation
typealias AppID = UInt64
extension AppID {
var unknownMessage: String {
"Unknown app ID \(self)"
}
}
extension NSNumber {
var appIDValue: AppID {
uint64Value
}
}

View file

@ -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<Void> {
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 {
guard NSWorkspace.shared.open(self) else {
throw MASError.runtimeError("Failed to open \(self)")
}
seal.fulfill(())
}
}
}
}

View file

@ -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)
}
}

View file

@ -28,7 +28,7 @@
"NeverForceUnwrap": false, "NeverForceUnwrap": false,
"NeverUseForceTry": false, "NeverUseForceTry": false,
"NeverUseImplicitlyUnwrappedOptionals": true, "NeverUseImplicitlyUnwrappedOptionals": true,
"NoAccessLevelOnExtensionDeclaration": true, "NoAccessLevelOnExtensionDeclaration": false,
"NoAssignmentInExpressions": true, "NoAssignmentInExpressions": true,
"NoBlockComments": true, "NoBlockComments": true,
"NoCasesWithOnlyFallthrough": true, "NoCasesWithOnlyFallthrough": true,

View file

@ -14,7 +14,6 @@ import Quick
public class HomeSpec: QuickSpec { public class HomeSpec: QuickSpec {
override public func spec() { override public func spec() {
let searcher = MockAppStoreSearcher() let searcher = MockAppStoreSearcher()
let openCommand = MockOpenSystemCommand()
beforeSuite { beforeSuite {
MAS.initialize() MAS.initialize()
@ -23,31 +22,11 @@ public class HomeSpec: QuickSpec {
beforeEach { beforeEach {
searcher.reset() searcher.reset()
} }
it("fails to open app with invalid ID") {
expect {
try MAS.Home.parse(["--", "-999"]).run(searcher: searcher, openCommand: openCommand)
}
.to(throwError())
}
it("can't find app with unknown ID") { it("can't find app with unknown ID") {
expect { expect {
try MAS.Home.parse(["999"]).run(searcher: searcher, openCommand: openCommand) try MAS.Home.parse(["999"]).run(searcher: searcher)
} }
.to(throwError(MASError.noSearchResultsFound)) .to(throwError(MASError.unknownAppID(999)))
}
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, openCommand: openCommand)
return openCommand.arguments
}
== [mockResult.trackViewUrl]
} }
} }
} }

View file

@ -23,17 +23,11 @@ public class InfoSpec: QuickSpec {
beforeEach { beforeEach {
searcher.reset() 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") { it("can't find app with unknown ID") {
expect { expect {
try MAS.Info.parse(["999"]).run(searcher: searcher) try MAS.Info.parse(["999"]).run(searcher: searcher)
} }
.to(throwError(MASError.noSearchResultsFound)) .to(throwError(MASError.unknownAppID(999)))
} }
it("displays app details") { it("displays app details") {
let mockResult = SearchResult( let mockResult = SearchResult(

View file

@ -19,7 +19,7 @@ public class InstallSpec: QuickSpec {
xdescribe("install command") { xdescribe("install command") {
xit("installs apps") { xit("installs apps") {
expect { expect {
try MAS.Install.parse([]).run(appLibrary: MockAppLibrary()) try MAS.Install.parse([]).run(appLibrary: MockAppLibrary(), searcher: MockAppStoreSearcher())
} }
.toNot(throwError()) .toNot(throwError())
} }

View file

@ -15,7 +15,6 @@ import Quick
public class OpenSpec: QuickSpec { public class OpenSpec: QuickSpec {
override public func spec() { override public func spec() {
let searcher = MockAppStoreSearcher() let searcher = MockAppStoreSearcher()
let openCommand = MockOpenSystemCommand()
beforeSuite { beforeSuite {
MAS.initialize() MAS.initialize()
@ -24,38 +23,11 @@ public class OpenSpec: QuickSpec {
beforeEach { beforeEach {
searcher.reset() searcher.reset()
} }
it("fails to open app with invalid ID") {
expect {
try MAS.Open.parse(["--", "-999"]).run(searcher: searcher, openCommand: openCommand)
}
.to(throwError())
}
it("can't find app with unknown ID") { it("can't find app with unknown ID") {
expect { expect {
try MAS.Open.parse(["999"]).run(searcher: searcher, openCommand: openCommand) try MAS.Open.parse(["999"]).run(searcher: searcher)
} }
.to(throwError(MASError.noSearchResultsFound)) .to(throwError(MASError.unknownAppID(999)))
}
it("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, openCommand: openCommand)
return openCommand.arguments
}
== ["macappstore://some/url"]
}
it("just opens MAS if no app specified") {
expect {
try MAS.Open.parse([]).run(searcher: searcher, openCommand: openCommand)
return openCommand.arguments
}
== ["macappstore://"]
} }
} }
} }

View file

@ -19,7 +19,7 @@ public class PurchaseSpec: QuickSpec {
xdescribe("purchase command") { xdescribe("purchase command") {
xit("purchases apps") { xit("purchases apps") {
expect { expect {
try MAS.Purchase.parse(["999"]).run(appLibrary: MockAppLibrary()) try MAS.Purchase.parse(["999"]).run(appLibrary: MockAppLibrary(), searcher: MockAppStoreSearcher())
} }
.toNot(throwError()) .toNot(throwError())
} }

View file

@ -17,7 +17,7 @@ public class SignInSpec: QuickSpec {
beforeSuite { beforeSuite {
MAS.initialize() 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") { describe("signin command") {
it("signs in") { it("signs in") {
expect { expect {

View file

@ -25,7 +25,7 @@ public class UpgradeSpec: QuickSpec {
.run(appLibrary: MockAppLibrary(), searcher: MockAppStoreSearcher()) .run(appLibrary: MockAppLibrary(), searcher: MockAppStoreSearcher())
} }
} }
== "Warning: Nothing found to upgrade\n" .toNot(throwError())
} }
} }
} }

View file

@ -14,7 +14,6 @@ import Quick
public class VendorSpec: QuickSpec { public class VendorSpec: QuickSpec {
override public func spec() { override public func spec() {
let searcher = MockAppStoreSearcher() let searcher = MockAppStoreSearcher()
let openCommand = MockOpenSystemCommand()
beforeSuite { beforeSuite {
MAS.initialize() MAS.initialize()
@ -23,32 +22,11 @@ public class VendorSpec: QuickSpec {
beforeEach { beforeEach {
searcher.reset() searcher.reset()
} }
it("fails to open app with invalid ID") {
expect {
try MAS.Vendor.parse(["--", "-999"]).run(searcher: searcher, openCommand: openCommand)
}
.to(throwError())
}
it("can't find app with unknown ID") { it("can't find app with unknown ID") {
expect { expect {
try MAS.Vendor.parse(["999"]).run(searcher: searcher, openCommand: openCommand) try MAS.Vendor.parse(["999"]).run(searcher: searcher)
} }
.to(throwError(MASError.noSearchResultsFound)) .to(throwError(MASError.unknownAppID(999)))
}
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, openCommand: openCommand)
return openCommand.arguments
}
== [mockResult.sellerUrl]
} }
} }
} }

View file

@ -17,13 +17,13 @@ public class ITunesSearchAppStoreSearcherSpec: QuickSpec {
MAS.initialize() MAS.initialize()
} }
describe("url string") { describe("url string") {
it("contains the app name") { it("contains the search term") {
expect { expect {
ITunesSearchAppStoreSearcher().searchURL(for: "myapp", inCountry: "US")?.absoluteString ITunesSearchAppStoreSearcher().searchURL(for: "myapp", inCountry: "US")?.absoluteString
} }
== "https://itunes.apple.com/search?media=software&entity=desktopSoftware&country=US&term=myapp" == "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 { expect {
ITunesSearchAppStoreSearcher().searchURL(for: "My App", inCountry: "US")?.absoluteString ITunesSearchAppStoreSearcher().searchURL(for: "My App", inCountry: "US")?.absoluteString
} }

View file

@ -17,9 +17,9 @@ class MockAppStoreSearcher: AppStoreSearcher {
.value(apps.filter { $1.trackName.contains(searchTerm) }.map { $1 }) .value(apps.filter { $1.trackName.contains(searchTerm) }.map { $1 })
} }
func lookup(appID: AppID) -> Promise<SearchResult?> { func lookup(appID: AppID) -> Promise<SearchResult> {
guard let result = apps[appID] else { guard let result = apps[appID] else {
return Promise(error: MASError.noSearchResultsFound) return Promise(error: MASError.unknownAppID(appID))
} }
return .value(result) return .value(result)

View file

@ -100,7 +100,7 @@ class MASErrorTestCase: XCTestCase {
func testNoSearchResultsFound() { func testNoSearchResultsFound() {
error = .noSearchResultsFound error = .noSearchResultsFound
XCTAssertEqual(error.description, "No results found") XCTAssertEqual(error.description, "No apps found")
} }
func testNoVendorWebsite() { func testNoVendorWebsite() {

View file

@ -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
}
}

View file

@ -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"
}
}
}
}
}

View file

@ -23,61 +23,65 @@ end
complete -c mas -f complete -c mas -f
### account ### 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" complete -c mas -n "__fish_seen_subcommand_from help" -xa "account"
### help ### help
complete -c mas -n "__fish_use_subcommand" -f -a help -d "Display general or command-specific help" complete -c mas -n "__fish_use_subcommand" -f -a help -d "Display general or command-specific help"
complete -c mas -n "__fish_seen_subcommand_from help" -xa "help" complete -c mas -n "__fish_seen_subcommand_from help" -xa "help"
### home ### 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 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 ### info
complete -c mas -n "__fish_use_subcommand" -f -a info -d "Display app information from the Mac App Store" complete -c mas -n "__fish_use_subcommand" -f -a info -d "Display app information from the Mac App Store"
complete -c mas -n "__fish_seen_subcommand_from help" -xa "info" complete -c mas -n "__fish_seen_subcommand_from help" -xa "info"
### install ### 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 help" -xa "install"
complete -c mas -n "__fish_seen_subcommand_from install lucky" -l force -d "Force reinstall" complete -c mas -n "__fish_seen_subcommand_from install lucky" -l force -d "Force reinstall"
### list ### 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"
complete -c mas -n "__fish_seen_subcommand_from help" -xa "list" complete -c mas -n "__fish_seen_subcommand_from help" -xa "list"
### lucky ### 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" complete -c mas -n "__fish_seen_subcommand_from help" -xa "lucky"
### open ### 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" complete -c mas -n "__fish_seen_subcommand_from help" -xa "open"
### outdated ### 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"
complete -c mas -n "__fish_seen_subcommand_from help" -xa "outdated" 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 ### 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 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 ### 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_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 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 ### 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 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 ### 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" complete -c mas -n "__fish_seen_subcommand_from help" -xa "signout"
### uninstall ### 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"
complete -c mas -n "__fish_seen_subcommand_from help" -xa "uninstall" 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)" complete -c mas -n "__fish_seen_subcommand_from uninstall" -x -a "(__fish_mas_list_installed)"
### upgrade ### 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"
complete -c mas -n "__fish_seen_subcommand_from help" -xa "upgrade" 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)" complete -c mas -n "__fish_seen_subcommand_from upgrade" -x -a "(__fish_mas_outdated_installed)"
### vendor ### 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" complete -c mas -n "__fish_seen_subcommand_from help" -xa "vendor"
### version ### 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" complete -c mas -n "__fish_seen_subcommand_from help" -xa "version"

View file

@ -20,7 +20,7 @@ CORE_TAP_PATH="$(brew --repository homebrew/core)"
MAS_VERSION=$(script/version) MAS_VERSION=$(script/version)
ROOT_URL="https://github.com/mas-cli/mas/releases/download/v${MAS_VERSION}" ROOT_URL="https://github.com/mas-cli/mas/releases/download/v${MAS_VERSION}"
# Supports macOS 10.11 and later # Supports macOS 10.13 and later
OS_NAMES=( OS_NAMES=(
sonoma sonoma
arm64_sonoma arm64_sonoma