mirror of
https://github.com/mas-cli/mas
synced 2024-12-03 16:59:24 +00:00
Merge branch 'main' into releases/release-1.8.7
# Conflicts: # .swiftlint.yml # script/bottle
This commit is contained in:
commit
750930e01a
59 changed files with 557 additions and 770 deletions
|
@ -28,7 +28,7 @@
|
|||
"NeverForceUnwrap": true,
|
||||
"NeverUseForceTry": true,
|
||||
"NeverUseImplicitlyUnwrappedOptionals": true,
|
||||
"NoAccessLevelOnExtensionDeclaration": true,
|
||||
"NoAccessLevelOnExtensionDeclaration": false,
|
||||
"NoAssignmentInExpressions": true,
|
||||
"NoBlockComments": true,
|
||||
"NoCasesWithOnlyFallthrough": true,
|
||||
|
|
|
@ -1 +1 @@
|
|||
5.7
|
||||
5.7.1
|
||||
|
|
|
@ -31,7 +31,6 @@
|
|||
|
||||
# Rule options
|
||||
--commas always
|
||||
--extensionacl on-declarations
|
||||
--hexliteralcase lowercase
|
||||
--importgrouping testable-last
|
||||
--lineaftermarks false
|
||||
|
|
|
@ -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
|
||||
|
|
8
Makefile
8
Makefile
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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"),
|
||||
],
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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:
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)'")
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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:)) ?? ""
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
23
Sources/mas/Models/AppID.swift
Normal file
23
Sources/mas/Models/AppID.swift
Normal 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
|
||||
}
|
||||
}
|
32
Sources/mas/Network/URL.swift
Normal file
32
Sources/mas/Network/URL.swift
Normal 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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
29
Sources/mas/Utilities/ProcessInfo.swift
Normal file
29
Sources/mas/Utilities/ProcessInfo.swift
Normal 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)
|
||||
}
|
||||
}
|
|
@ -28,7 +28,7 @@
|
|||
"NeverForceUnwrap": false,
|
||||
"NeverUseForceTry": false,
|
||||
"NeverUseImplicitlyUnwrappedOptionals": true,
|
||||
"NoAccessLevelOnExtensionDeclaration": true,
|
||||
"NoAccessLevelOnExtensionDeclaration": false,
|
||||
"NoAssignmentInExpressions": true,
|
||||
"NoBlockComments": true,
|
||||
"NoCasesWithOnlyFallthrough": true,
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -25,7 +25,7 @@ public class UpgradeSpec: QuickSpec {
|
|||
.run(appLibrary: MockAppLibrary(), searcher: MockAppStoreSearcher())
|
||||
}
|
||||
}
|
||||
== "Warning: Nothing found to upgrade\n"
|
||||
.toNot(throwError())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue