Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,15 @@ disabled_rules:
- no_magic_numbers
- prefixed_toplevel_constant
- sorted_enum_cases
- strict_fileprivate
- vertical_whitespace_between_cases
- void_function_in_ternary
attributes:
always_on_line_above: ['@MainActor', '@OptionGroup']
closure_body_length:
warning: 40
cyclomatic_complexity:
warning: 11
file_types_order:
order:
- main_type
Expand All @@ -37,7 +41,7 @@ file_types_order:
- preview_provider
- library_content_provider
function_body_length:
warning: 55
warning: 70
indentation_width:
include_multiline_strings: false
number_separator:
Expand Down
9 changes: 9 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ let package = Package(
.package(url: "https://github.com/Quick/Nimble.git", from: "13.7.1"),
.package(url: "https://github.com/Quick/Quick.git", exact: "7.5.0"),
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"),
.package(url: "https://github.com/apple/swift-atomics.git", branch: "main"),
.package(url: "https://github.com/funky-monkey/IsoCountryCodes.git", from: "1.0.2"),
.package(url: "https://github.com/mxcl/Version.git", from: "2.1.0"),
],
Expand All @@ -25,6 +26,7 @@ let package = Package(
name: "mas",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Atomics", package: "swift-atomics"),
"IsoCountryCodes",
"Version",
],
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ $ mas search Xcode

#### `mas info`

`mas info <app-id>` displays more detailed information about an application available from the Mac App Store.
`mas info <app-id>` outputs more detailed information about an application available from the Mac App Store.

```console
$ mas info 497799835
Expand All @@ -118,7 +118,7 @@ All the commands in this section require you to be logged into an Apple Account

#### `mas list`

`mas list` displays all the applications on your Mac that were installed from the Mac App Store.
`mas list` outputs all the applications on your Mac that were installed from the Mac App Store.

```console
$ mas list
Expand All @@ -129,7 +129,7 @@ $ mas list

#### `mas outdated`

`mas outdated` displays all applications installed from the Mac App Store on your Mac that have pending upgrades.
`mas outdated` outputs all applications installed from the Mac App Store on your Mac that have pending upgrades.

```console
$ mas outdated
Expand Down
114 changes: 40 additions & 74 deletions Sources/mas/AppStore/Downloader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,86 +8,52 @@

private import CommerceKit

/// Sequentially downloads apps, printing progress to the console.
///
/// Verifies that each supplied app ID is valid before attempting to download.
///
/// - Parameters:
/// - appIDs: The app IDs of the apps to be verified & downloaded.
/// - searcher: The `AppStoreSearcher` used to verify app IDs.
/// - purchasing: Flag indicating if the apps will be purchased. Only works for free apps. Defaults to false.
/// - Throws: If any download fails, immediately throws an error.
func downloadApps(
withAppIDs appIDs: [AppID],
verifiedBy searcher: AppStoreSearcher,
purchasing: Bool = false
) async throws {
for appID in appIDs {
struct Downloader {
let printer: Printer

func downloadApp(
withAppID appID: AppID,
purchasing: Bool = false,
withAttemptCount attemptCount: UInt32 = 3
) async throws {
do {
_ = try await searcher.lookup(appID: appID)
try await downloadApp(withAppID: appID, purchasing: purchasing, withAttemptCount: 3)
let purchase = await SSPurchase(appID: appID, purchasing: purchasing)
_ = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
CKPurchaseController.shared().perform(purchase, withOptions: 0) { _, _, error, response in
if let error {
continuation.resume(throwing: error)
} else if response?.downloads.isEmpty == false {
Task {
do {
try await PurchaseDownloadObserver(appID: appID, printer: printer).observeDownloadQueue()
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
}
} else {
continuation.resume(throwing: MASError.noDownloads)
}
}
}
} catch {
guard case MASError.unknownAppID = error else {
guard attemptCount > 1 else {
throw error
}
printWarning(error)
}
}
}

/// Sequentially downloads apps, printing progress to the console.
///
/// - Parameters:
/// - appIDs: The app IDs of the apps to be downloaded.
/// - purchasing: Flag indicating if the apps will be purchased. Only works for free apps. Defaults to false.
/// - Throws: If a download fails, immediately throws an error.
func downloadApps(withAppIDs appIDs: [AppID], purchasing: Bool = false) async throws {
for appID in appIDs {
try await downloadApp(withAppID: appID, purchasing: purchasing, withAttemptCount: 3)
}
}

private func downloadApp(withAppID appID: AppID, purchasing: Bool, withAttemptCount attemptCount: UInt32) async throws {
do {
try await downloadApp(withAppID: appID, purchasing: purchasing)
} catch {
guard attemptCount > 1 else {
throw error
}

// If the download failed due to network issues, try again. Otherwise, fail immediately.
guard
case let MASError.downloadFailed(downloadError) = error,
downloadError.domain == NSURLErrorDomain
else {
throw error
}

let attemptCount = attemptCount - 1
printWarning(downloadError.localizedDescription)
printWarning("Retrying…", attemptCount, attemptCount == 1 ? "attempt remaining" : "attempts remaining")
try await downloadApp(withAppID: appID, purchasing: purchasing, withAttemptCount: attemptCount)
}
}

private func downloadApp(withAppID appID: AppID, purchasing: Bool = false) async throws {
let purchase = await SSPurchase(appID: appID, purchasing: purchasing)
_ = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
CKPurchaseController.shared().perform(purchase, withOptions: 0) { _, _, error, response in
if let error {
continuation.resume(throwing: MASError(purchaseFailedError: error))
} else if response?.downloads.isEmpty == false {
Task {
do {
try await PurchaseDownloadObserver(appID: appID).observeDownloadQueue()
continuation.resume()
} catch {
continuation.resume(throwing: MASError(purchaseFailedError: error))
}
}
} else {
continuation.resume(throwing: MASError.noDownloads)
// If the download failed due to network issues, try again. Otherwise, fail immediately.
guard (error as NSError).domain == NSURLErrorDomain else {
throw error
}

let attemptCount = attemptCount - 1
printer.warning(
"Network error (",
attemptCount,
attemptCount == 1 ? " attempt remaining):\n" : " attempts remaining):\n",
error
)
try await downloadApp(withAppID: appID, purchasing: purchasing, withAttemptCount: attemptCount)
}
}
}
99 changes: 58 additions & 41 deletions Sources/mas/AppStore/PurchaseDownloadObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,15 @@ private var downloadedPhaseType: Int64 { 5 }

class PurchaseDownloadObserver: CKDownloadQueueObserver {
private let appID: AppID
private let printer: Printer

private var completionHandler: (() -> Void)?
private var errorHandler: ((MASError) -> Void)?
private var errorHandler: ((Error) -> Void)?
private var prevPhaseType: Int64?

init(appID: AppID) {
init(appID: AppID, printer: Printer) {
self.appID = appID
self.printer = printer
}

deinit {
Expand All @@ -38,29 +41,7 @@ class PurchaseDownloadObserver: CKDownloadQueueObserver {
if status.isFailed || status.isCancelled {
queue.removeDownload(withItemIdentifier: download.metadata.itemIdentifier)
} else {
let currPhaseType = status.activePhase.phaseType
let prevPhaseType = prevPhaseType
if prevPhaseType != currPhaseType {
switch currPhaseType {
case downloadingPhaseType:
if prevPhaseType == initialPhaseType {
terminateEphemeralPrinting()
printNotice("Downloading", download.progressDescription)
}
case downloadedPhaseType:
if prevPhaseType == downloadingPhaseType {
terminateEphemeralPrinting()
printNotice("Downloaded", download.progressDescription)
}
case installingPhaseType:
terminateEphemeralPrinting()
printNotice("Installing", download.progressDescription)
default:
break
}
self.prevPhaseType = currPhaseType
}
progress(status.progressState)
prevPhaseType = printer.progress(of: download, prevPhaseType: prevPhaseType)
}
}

Expand All @@ -76,13 +57,13 @@ class PurchaseDownloadObserver: CKDownloadQueueObserver {
return
}

terminateEphemeralPrinting()
printer.terminateEphemeral()
if status.isFailed {
errorHandler?(MASError(downloadFailedError: status.error))
errorHandler?(status.error)
} else if status.isCancelled {
errorHandler?(.cancelled)
errorHandler?(MASError.cancelled)
} else {
printNotice("Installed", download.progressDescription)
printer.notice("Installed", download.progressDescription)
completionHandler?()
}
}
Expand All @@ -98,24 +79,58 @@ private struct ProgressState {
}
}

private func progress(_ state: ProgressState) {
// Don't display the progress bar if we're not on a terminal
guard isatty(fileno(stdout)) != 0 else {
return
}

let barLength = 60
let completeLength = Int(state.percentComplete * Float(barLength))
let bar = (0..<barLength).map { $0 < completeLength ? "#" : "-" }.joined()
printEphemeral(bar, state.percentage, state.phase, terminator: "")
}

private extension SSDownload {
var progressDescription: String {
"\(metadata.title) (\(metadata.bundleVersion ?? "unknown version"))"
}
}

private extension Printer {
func progress(of download: SSDownload, prevPhaseType: Int64?) -> Int64 {
let currPhaseType = download.status.activePhase.phaseType
if prevPhaseType != currPhaseType {
switch currPhaseType {
case downloadingPhaseType:
if prevPhaseType == initialPhaseType {
progressHeader(for: download)
}
case downloadedPhaseType:
if prevPhaseType == downloadingPhaseType {
progressHeader(for: download)
}
case installingPhaseType:
progressHeader(for: download)
default:
break
}
}

if isatty(fileno(stdout)) != 0 {
// Only output the progress bar if connected to a terminal
let progressState = download.status.progressState
let totalLength = 60
let completedLength = Int(progressState.percentComplete * Float(totalLength))
ephemeral(
String(repeating: "#", count: completedLength),
String(repeating: "-", count: totalLength - completedLength),
" ",
progressState.percentage,
" ",
progressState.phase,
separator: "",
terminator: ""
)
}

return currPhaseType
}

private func progressHeader(for download: SSDownload) {
terminateEphemeral()
notice(download.status.activePhase.phaseDescription, download.progressDescription)
}
}

private extension SSDownloadStatus {
var progressState: ProgressState {
ProgressState(percentComplete: percentComplete, phase: activePhase.phaseDescription)
Expand All @@ -125,6 +140,8 @@ private extension SSDownloadStatus {
private extension SSDownloadPhase {
var phaseDescription: String {
switch phaseType {
case downloadedPhaseType:
"Downloaded"
case downloadingPhaseType:
"Downloading"
case installingPhaseType:
Expand Down
10 changes: 7 additions & 3 deletions Sources/mas/Commands/Account.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,19 @@
internal import ArgumentParser

extension MAS {
/// Displays the Apple Account signed in to the Mac App Store.
/// Outputs the Apple Account signed in to the Mac App Store.
struct Account: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Display the Apple Account signed in to the Mac App Store"
abstract: "Output the Apple Account signed in to the Mac App Store"
)

/// Runs the command.
func run() async throws {
printInfo(try await appleAccount.emailAddress)
try await mas.run { try await run(printer: $0) }
}

func run(printer: Printer) async throws {
printer.info(try await appleAccount.emailAddress)
}
}
}
Loading