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,
"NeverUseForceTry": true,
"NeverUseImplicitlyUnwrappedOptionals": true,
"NoAccessLevelOnExtensionDeclaration": true,
"NoAccessLevelOnExtensionDeclaration": false,
"NoAssignmentInExpressions": true,
"NoBlockComments": true,
"NoCasesWithOnlyFallthrough": true,

View file

@ -1 +1 @@
5.7
5.7.1

View file

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

View file

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

View file

@ -12,13 +12,7 @@ CMD_NAME = mas
SHELL = /bin/sh
PREFIX ?= $(shell brew --prefix)
# 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

View file

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

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.
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.
@ -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"),
],

View file

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

View file

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

View file

@ -10,21 +10,53 @@ 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
/// - purchase: Flag indicating whether the apps needs to be purchased.
/// Only works for free apps. Defaults to false.
/// - 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<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,
/// 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?
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 +71,15 @@ func downloadAll(_ appIDs: [AppID], purchase: Bool = false) -> Promise<Void> {
}
}
private func downloadWithRetries(_ appID: AppID, purchase: Bool = false, attempts: Int = 3) -> Promise<Void> {
SSPurchase().perform(appID: appID, purchase: purchase)
private func downloadApp(
withAppID appID: AppID,
purchasing: Bool = false,
withAttemptCount attemptCount: UInt32 = 3
) -> Promise<Void> {
SSPurchase()
.perform(appID: appID, purchasing: purchasing)
.recover { error in
guard attempts > 1 else {
guard attemptCount > 1 else {
throw error
}
@ -54,9 +91,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)
}
}

View file

@ -14,81 +14,23 @@ private let timeout = 30.0
extension ISStoreAccount: StoreAccount {
static var primaryAccount: Promise<ISStoreAccount> {
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<ISStoreAccount> {
// 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<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))
}
)
}
}
static func signIn(appleID _: String, password _: String, systemDialog _: Bool) -> Promise<ISStoreAccount> {
// Signing in is no longer possible as of High Sierra.
// https://github.com/mas-cli/mas/issues/164
Promise(error: MASError.notSupported)
}
}

View file

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

View file

@ -11,7 +11,7 @@ import PromiseKit
import StoreFoundation
extension SSPurchase {
func perform(appID: AppID, purchase: Bool) -> Promise<Void> {
func perform(appID: AppID, purchasing: Bool) -> Promise<Void> {
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

View file

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

View file

@ -7,43 +7,32 @@
//
import ArgumentParser
import Foundation
extension MAS {
/// Opens app page on MAS Preview. Uses the iTunes Lookup API:
/// 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.
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
}
func run(searcher: AppStoreSearcher) throws {
let result = try searcher.lookup(appID: appID).wait()
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
guard let url = URL(string: result.trackViewUrl) else {
throw MASError.runtimeError("Unable to construct URL from: \(result.trackViewUrl)")
}
try url.open().wait()
}
}
}

View file

@ -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.
@ -27,11 +27,7 @@ extension MAS {
func run(searcher: AppStoreSearcher) throws {
do {
guard let result = try searcher.lookup(appID: appID).wait() else {
throw MASError.noSearchResultsFound
}
print(AppInfoFormatter.format(app: result))
print(AppInfoFormatter.format(app: try searcher.lookup(appID: appID).wait()))
} catch {
throw error as? MASError ?? .searchFailed
}

View file

@ -13,20 +13,20 @@ 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: ArgumentHelp("App ID", valueName: "app-id"))
var appIDs: [AppID]
/// 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 downloadAll(appIDs).wait()
try downloadApps(withAppIDs: appIDs, verifiedBy: searcher).wait()
} catch {
throw error as? MASError ?? .downloadFailed(error: error as NSError)
}

View file

@ -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"
)
/// Runs the command.

View file

@ -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.
@ -34,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
}
@ -62,7 +65,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)
}

View file

@ -6,8 +6,9 @@
// Copyright © 2016 mas-cli. All rights reserved.
//
import AppKit
import ArgumentParser
import Foundation
import PromiseKit
private let masScheme = "macappstore"
@ -16,51 +17,63 @@ 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.
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)
}
}
}
private func openMacAppStore() -> Promise<Void> {
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) 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.
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"
)
@Flag(help: "Show warnings about apps")
@Flag(help: "Display warnings about apps unknown to the Mac App Store")
var verbose = false
/// Runs the command.
@ -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()

View file

@ -12,18 +12,18 @@ 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: ArgumentHelp("App ID", valueName: "app-id"))
var appIDs: [AppID]
/// 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 downloadAll(appIDs, purchase: true).wait()
try downloadApps(withAppIDs: appIDs, verifiedBy: searcher, purchasing: true).wait()
} catch {
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.
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.

View file

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

View file

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

View file

@ -13,18 +13,12 @@ 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.
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()
}
}
}

View file

@ -17,9 +17,9 @@ extension MAS {
)
/// 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.
@ -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)'")

View file

@ -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"
)
@Argument(help: "app(s) to upgrade")
var appIDs: [String] = []
@Argument(help: ArgumentHelp("App ID/app name", valueName: "app-id-or-name"))
var appIDOrNames: [String] = []
/// Runs the command.
func run() throws {
@ -34,7 +35,6 @@ extension MAS {
}
guard !apps.isEmpty else {
printWarning("Nothing found to upgrade")
return
}
@ -45,7 +45,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)
}
@ -56,30 +56,41 @@ extension MAS {
searcher: AppStoreSearcher
) throws -> [(SoftwareProduct, SearchResult)] {
let apps =
appIDs.isEmpty
appIDOrNames.isEmpty
? appLibrary.installedApps
: appIDs.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(appID.unknownMessage)
}
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
// 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 }

View file

@ -7,47 +7,36 @@
//
import ArgumentParser
import Foundation
extension MAS {
/// Opens vendor's app page in a browser. Uses the iTunes Lookup API:
/// 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.
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
}
func run(searcher: AppStoreSearcher) throws {
let result = try searcher.lookup(appID: appID).wait()
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
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()
}
}
}

View file

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

View file

@ -11,86 +11,17 @@ import PromiseKit
/// Protocol for searching the MAS catalog.
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]>
}
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,17 +32,59 @@ class ITunesSearchAppStoreSearcher: AppStoreSearcher {
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.
/// 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> {
guard let url = lookupURL(forAppID: appID, inCountry: country) else {
fatalError("Failed to build URL for \(appID)")
}
return
loadSearchResults(url)
.then { results -> Guarantee<SearchResult> in
guard let result = results.first else {
throw MASError.unknownAppID(appID)
}
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)
}
}
}
/// Searches for apps from the MAS.
///
/// - Parameter searchTerm: a search term matched against app names
/// - Returns: A Promise of an Array of SearchResults matching searchTerm
/// - 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 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 {
@ -60,81 +102,109 @@ class ITunesSearchAppStoreSearcher: AppStoreSearcher {
}
}
/// 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 {
fatalError("Failed to build URL for \(appID)")
}
return firstly {
loadSearchResults(url)
}
.then { results -> Guarantee<SearchResult?> 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
}
// 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)
}
}
}
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<Version?> {
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.
///
/// - 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) {
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")
}

View file

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

View file

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

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,
"NeverUseForceTry": false,
"NeverUseImplicitlyUnwrappedOptionals": true,
"NoAccessLevelOnExtensionDeclaration": true,
"NoAccessLevelOnExtensionDeclaration": false,
"NoAssignmentInExpressions": true,
"NoBlockComments": true,
"NoCasesWithOnlyFallthrough": true,

View file

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

View file

@ -23,17 +23,11 @@ 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)
}
.to(throwError(MASError.noSearchResultsFound))
.to(throwError(MASError.unknownAppID(999)))
}
it("displays app details") {
let mockResult = SearchResult(

View file

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

View file

@ -15,7 +15,6 @@ import Quick
public class OpenSpec: QuickSpec {
override public func spec() {
let searcher = MockAppStoreSearcher()
let openCommand = MockOpenSystemCommand()
beforeSuite {
MAS.initialize()
@ -24,38 +23,11 @@ public class OpenSpec: QuickSpec {
beforeEach {
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") {
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") {
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://"]
.to(throwError(MASError.unknownAppID(999)))
}
}
}

View file

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

View file

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

View file

@ -25,7 +25,7 @@ public class UpgradeSpec: QuickSpec {
.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 {
override public func spec() {
let searcher = MockAppStoreSearcher()
let openCommand = MockOpenSystemCommand()
beforeSuite {
MAS.initialize()
@ -23,32 +22,11 @@ public class VendorSpec: QuickSpec {
beforeEach {
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") {
expect {
try MAS.Vendor.parse(["999"]).run(searcher: searcher, openCommand: openCommand)
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, openCommand: openCommand)
return openCommand.arguments
}
== [mockResult.sellerUrl]
.to(throwError(MASError.unknownAppID(999)))
}
}
}

View file

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

View file

@ -17,9 +17,9 @@ class MockAppStoreSearcher: AppStoreSearcher {
.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 {
return Promise(error: MASError.noSearchResultsFound)
return Promise(error: MASError.unknownAppID(appID))
}
return .value(result)

View file

@ -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() {

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
### 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"
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"
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_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"
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"

View file

@ -20,7 +20,7 @@ CORE_TAP_PATH="$(brew --repository homebrew/core)"
MAS_VERSION=$(script/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=(
sonoma
arm64_sonoma