Skip to content
Open
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
8 changes: 7 additions & 1 deletion Sources/mas/Commands/MAS.Get.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,15 @@ extension MAS {
private var forceOptionGroup: ForceOptionGroup
@OptionGroup
private var catalogAppsOptionGroup: CatalogAppsOptionGroup
@OptionGroup
private var jobsOptionGroup: JobsOptionGroup

func run() async {
await AppStore.get.apps(withAppIDs: catalogAppsOptionGroup.appIDs, force: forceOptionGroup.force)
await AppStore.get.apps(
withAppIDs: catalogAppsOptionGroup.appIDs,
force: forceOptionGroup.force,
maxConcurrentTaskCount: jobsOptionGroup.jobs,
)
}
}
}
8 changes: 7 additions & 1 deletion Sources/mas/Commands/MAS.Install.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,15 @@ extension MAS {
private var forceOptionGroup: ForceOptionGroup
@OptionGroup
private var catalogAppsOptionGroup: CatalogAppsOptionGroup
@OptionGroup
private var jobsOptionGroup: JobsOptionGroup

func run() async {
await AppStore.install.apps(withAppIDs: catalogAppsOptionGroup.appIDs, force: forceOptionGroup.force)
await AppStore.install.apps(
withAppIDs: catalogAppsOptionGroup.appIDs,
force: forceOptionGroup.force,
maxConcurrentTaskCount: jobsOptionGroup.jobs,
)
}
}
}
2 changes: 1 addition & 1 deletion Sources/mas/Commands/MAS.Lucky.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ extension MAS {
throw MASError.noCatalogAppsFound(for: searchTerm)
}

await AppStore.install.apps(withADAMIDs: [adamID], force: forceOptionGroup.force)
await AppStore.install.apps(withADAMIDs: [adamID], force: forceOptionGroup.force, maxConcurrentTaskCount: 1)
}
}
}
3 changes: 3 additions & 0 deletions Sources/mas/Commands/MAS.Update.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,15 @@ extension MAS {
private var forceOptionGroup: ForceOptionGroup
@OptionGroup
private var outdatedAppsOptionGroup: OutdatedAppsOptionGroup
@OptionGroup
private var jobsOptionGroup: JobsOptionGroup

func run() async {
await AppStore.update.apps(
withADAMIDs: await outdatedAppsOptionGroup
.outdatedApps(considerAllOutdated: forceOptionGroup.force, withFullJSON: false)
.map(\.installedApp.adamID),
maxConcurrentTaskCount: jobsOptionGroup.jobs,
)
}
}
Expand Down
19 changes: 19 additions & 0 deletions Sources/mas/Commands/OptionGroups/JobsOptionGroup.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// JobsOptionGroup.swift
// mas
//
// Copyright © 2026 mas-cli. All rights reserved.
//

internal import ArgumentParser

struct JobsOptionGroup: ParsableArguments {
@Option(name: [.customShort("j"), .long], help: "Maximum number of apps to download & install concurrently")
private(set) var jobs = 4

func validate() throws(ValidationError) {
if jobs < 1 {
throw .init("--jobs must be at least 1")
}
}
}
16 changes: 10 additions & 6 deletions Sources/mas/Commands/OptionGroups/OutdatedAppsOptionGroup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,16 @@ struct OutdatedAppsOptionGroup: ParsableArguments {

let newVersionGate = OSAllocatedUnfairLock(initialState: String?.none)
do {
try await AppStore.install.app(withADAMID: installedApp.adamID) { appStoreVersion, shouldOutput in
if shouldOutput, let appStoreVersion, installedApp.version != appStoreVersion {
newVersionGate.withLock { $0 = appStoreVersion }
}
return true
}
try await AppStore.install.app(
withADAMID: installedApp.adamID,
shouldCancel: { appStoreVersion, shouldOutput in
if shouldOutput, let appStoreVersion, installedApp.version != appStoreVersion {
newVersionGate.withLock { $0 = appStoreVersion }
}
return true
},
board: ProgressBoard(),
)
} catch is CancellationError {
// Fallthrough
} catch {
Expand Down
90 changes: 58 additions & 32 deletions Sources/mas/Models/AppStoreAction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,15 @@ enum AppStoreAction: String {
}
}

func apps(withAppIDs appIDs: [AppID], force: Bool) async {
await apps(withADAMIDs: await appIDs.catalogApps.map(\.adamID), force: force)
func apps(withAppIDs appIDs: [AppID], force: Bool, maxConcurrentTaskCount: Int) async {
await apps(
withADAMIDs: await appIDs.catalogApps.map(\.adamID),
force: force,
maxConcurrentTaskCount: maxConcurrentTaskCount,
)
}

func apps(withADAMIDs adamIDs: [ADAMID], force: Bool) async {
func apps(withADAMIDs adamIDs: [ADAMID], force: Bool, maxConcurrentTaskCount: Int) async {
let installedAppByADAMID = await installedApps(withAppIDs: adamIDs.map(AppID.adamID), withFullJSON: false) { _ in }
.reduce(into: [ADAMID: InstalledApp]()) { $0[$1.adamID] = $1 }
await apps(
Expand All @@ -60,19 +64,41 @@ enum AppStoreAction: String {
MAS.printer.warning("Already ", performed, " ", installedApp.name, " (", adamID, ")", separator: "")
return false
},
maxConcurrentTaskCount: maxConcurrentTaskCount,
)
}

func apps(withADAMIDs adamIDs: [ADAMID]) async {
func apps(withADAMIDs adamIDs: [ADAMID], maxConcurrentTaskCount: Int) async {
guard !adamIDs.isEmpty else {
return
}

let board = ProgressBoard()
await OrderedSet(adamIDs)
.forEach(attemptTo: "\(self) app for ADAM ID") { try await app(withADAMID: $0) { _, _ in false } }
.concurrentForEach(attemptTo: "\(self) app for ADAM ID", maxConcurrentTaskCount: maxConcurrentTaskCount) {
try await app(withADAMID: $0, shouldCancel: { _, _ in false }, board: board)
}
}

func app(
withADAMID adamID: ADAMID,
shouldCancel: @escaping @Sendable (String?, Bool) -> Bool,
board: ProgressBoard,
) async throws {
await board.update(adamID: adamID, row: "\(adamID) \(performing.uppercasingFirst)")
do {
try await performApp(withADAMID: adamID, shouldCancel: shouldCancel, board: board)
} catch {
await board.finish(adamID: adamID)
throw error
}
}

func app(withADAMID adamID: ADAMID, shouldCancel: @escaping @Sendable (String?, Bool) -> Bool) async throws {
private func performApp(
withADAMID adamID: ADAMID,
shouldCancel: @escaping @Sendable (String?, Bool) -> Bool,
board: ProgressBoard,
) async throws {
let (eventStream, eventContinuation) = AsyncStream.makeStream(of: QueueEvent.self)
let observerUUID = await DownloadQueueObserver(
action: self,
Expand Down Expand Up @@ -161,6 +187,7 @@ enum AppStoreAction: String {
error: error,
)
}
var permanentLines = [String]()
switch snapshot.activePhaseType {
case prevPhaseType:
break
Expand All @@ -169,53 +196,48 @@ enum AppStoreAction: String {
.downloaded where prevPhaseType == .downloading,
.performing
: // swiftformat:disable:this indent
MAS.printer.clearCurrentLine(of: .standardOutput)
MAS.printer.notice(snapshot.activePhaseType, snapshot.appNameAndVersion)
permanentLines.append("\(snapshot.activePhaseType) \(snapshot.appNameAndVersion)")
default:
break
}
if
FileHandle.standardOutput.isTerminal,
snapshot.phasePercentComplete != 0 || snapshot.activePhaseType != .processing
{
// Output the progress bar iff connected to a terminal
var row = String?.none
if snapshot.phasePercentComplete != 0 || snapshot.activePhaseType != .processing {
let totalLength = 60
let completedLength = Int(snapshot.phasePercentComplete * .init(totalLength))
MAS.printer.clearCurrentLine(of: .standardOutput)
MAS.printer.info(
String(repeating: "#", count: completedLength),
String(repeating: "-", count: totalLength - completedLength),
" ",
UInt64((snapshot.phasePercentComplete * 100).rounded()),
"% ",
snapshot.activePhaseType.performed,
separator: "",
terminator: "",
)
row = """
\(snapshot.appNameAndVersion) \
\(String(repeating: "#", count: completedLength))\(String(repeating: "-", count: totalLength - completedLength)) \
\(UInt64((snapshot.phasePercentComplete * 100).rounded()))% \(snapshot.activePhaseType.performed)
"""
}
await board.update(adamID: adamID, permanentLines: permanentLines, row: row)
prevPhaseType = snapshot.activePhaseType
case let .removed(snapshot):
MAS.printer.clearCurrentLine(of: .standardOutput)
let appFolderURL: URL?
if let error = snapshot.error {
guard error is Ignorable else {
throw error
}

MAS.printer.notice(PhaseType.downloaded, snapshot.appNameAndVersion)
MAS.printer.notice(performing.uppercasingFirst, snapshot.appNameAndVersion)
MAS.printer.info(rawValue.uppercasingFirst, "progress cannot be displayed", terminator: "")
await board.update(
adamID: adamID,
permanentLines: [
"\(PhaseType.downloaded) \(snapshot.appNameAndVersion)",
"\(performing.uppercasingFirst) \(snapshot.appNameAndVersion)",
],
row: "\(snapshot.appNameAndVersion) \(rawValue.uppercasingFirst) progress cannot be displayed",
)
appFolderURL = try await install(
appNameAndVersion: snapshot.appNameAndVersion,
pkgHardLinkURL: pkgHardLinkURL,
receiptHardLinkURL: receiptHardLinkURL,
)
MAS.printer.clearCurrentLine(of: .standardOutput)
} else {
guard !snapshot.isFailed else {
throw MASError.error("Failed to download \(snapshot.appNameAndVersion)")
}
guard !shouldCancel(snapshot.version, false) else {
await board.finish(adamID: adamID)
return
}
guard !snapshot.isCancelled else {
Expand All @@ -225,9 +247,13 @@ enum AppStoreAction: String {
appFolderURL = snapshot.appFolderPath.map { .init(folderPath: $0) }
}

MAS.printer.notice(
[performed.uppercasingFirst, snapshot.appNameAndVersion]
+ (appFolderURL.map { ["in", $0.filePath] } ?? .init()),
await board.finish(
adamID: adamID,
permanentLines: [
([performed.uppercasingFirst, snapshot.appNameAndVersion]
+ (appFolderURL.map { ["in", $0.filePath] } ?? .init()))
.joined(separator: " "),
],
)

if let appFolderURL {
Expand Down
55 changes: 49 additions & 6 deletions Sources/mas/Utilities/Output/Printer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
private import ArgumentParser
private import Atomics
internal import Foundation
private import OrderedCollections

/// Prints to `FileHandle`s like `.standardOutput` & `.standardError` with ANSI
/// color codes when connected to a terminal.
Expand Down Expand Up @@ -83,12 +84,6 @@ struct Printer {
problem(items, prefix: errorPrefix, format: errorFormat, error: error, separator: separator, terminator: terminator)
}

func clearCurrentLine(of fileHandle: FileHandle) {
if fileHandle.isTerminal {
try? fileHandle.write(contentsOf: clearLineData)
}
}

private func problem(
_ items: [Any],
prefix: String,
Expand Down Expand Up @@ -160,6 +155,54 @@ extension String {
}
}

/// Tracks live progress lines for concurrently running operations, each
/// identified by an `ADAMID`, redrawing all of them in place as a block at the
/// bottom of the terminal; permanent lines interleaved with an update are
/// printed above that block so they stay in the scrollback.
///
/// When `stdout` isn't a terminal, the live block is skipped entirely &
/// permanent lines are printed as they arrive.
actor ProgressBoard {
private var rows = OrderedDictionary<ADAMID, String>()
private var lineCount = 0

func update(adamID: ADAMID, permanentLines: [String] = [], row: String? = nil) {
rows[adamID] = row ?? rows[adamID] ?? ""
redraw(permanentLines: permanentLines)
}

func finish(adamID: ADAMID, permanentLines: [String] = []) {
rows.removeValue(forKey: adamID)
redraw(permanentLines: permanentLines)
}

private func redraw(permanentLines: [String]) {
guard FileHandle.standardOutput.isTerminal else {
for line in permanentLines {
MAS.printer.notice(line)
}
return
}

if lineCount > 0 {
try? FileHandle.standardOutput.write(contentsOf: moveUpData(lineCount))
}
for line in permanentLines {
try? FileHandle.standardOutput.write(contentsOf: clearLineData)
MAS.printer.notice(line)
}
for row in rows.values {
try? FileHandle.standardOutput.write(contentsOf: clearLineData)
MAS.printer.info(row)
}
lineCount = rows.count
}
}

private func moveUpData(_ lineCount: Int) -> Data {
Data("\(csi)\(lineCount)A".utf8)
}

private func indent(_ item: Any, with indent: String) -> String {
.init(describing: item).replacing(nonEmptyLineStartRegex, with: indent)
}
Expand Down
12 changes: 12 additions & 0 deletions Sources/mas/Utilities/Swift/Collection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ extension Collection where Element: Sendable {
}
}

/// Runs `body` concurrently for each element, letting elements that can
/// succeed finish even if others fail.
func concurrentForEach(
attemptTo perform: String,
maxConcurrentTaskCount: Int = defaultMaxConcurrentTaskCount,
_ body: @escaping @Sendable (Element) async throws -> Void,
) async {
_ = await concurrentCompactMap(attemptingTo: perform, maxConcurrentTaskCount: maxConcurrentTaskCount) {
try await body($0) as Void?
}
}

private func concurrentTransform<T: Sendable>(
maxConcurrentTaskCount: Int,
_ transform: @escaping @Sendable (Element) async throws -> T,
Expand Down