diff --git a/Sources/mas/Commands/MAS.Get.swift b/Sources/mas/Commands/MAS.Get.swift index 47bcfb475..2569f038b 100644 --- a/Sources/mas/Commands/MAS.Get.swift +++ b/Sources/mas/Commands/MAS.Get.swift @@ -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, + ) } } } diff --git a/Sources/mas/Commands/MAS.Install.swift b/Sources/mas/Commands/MAS.Install.swift index f20eadd97..ed4ad54f5 100644 --- a/Sources/mas/Commands/MAS.Install.swift +++ b/Sources/mas/Commands/MAS.Install.swift @@ -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, + ) } } } diff --git a/Sources/mas/Commands/MAS.Lucky.swift b/Sources/mas/Commands/MAS.Lucky.swift index cbde46aab..1b9c5e600 100644 --- a/Sources/mas/Commands/MAS.Lucky.swift +++ b/Sources/mas/Commands/MAS.Lucky.swift @@ -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) } } } diff --git a/Sources/mas/Commands/MAS.Update.swift b/Sources/mas/Commands/MAS.Update.swift index 9dd3ec15c..b7e921767 100644 --- a/Sources/mas/Commands/MAS.Update.swift +++ b/Sources/mas/Commands/MAS.Update.swift @@ -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, ) } } diff --git a/Sources/mas/Commands/OptionGroups/JobsOptionGroup.swift b/Sources/mas/Commands/OptionGroups/JobsOptionGroup.swift new file mode 100644 index 000000000..3daa7b094 --- /dev/null +++ b/Sources/mas/Commands/OptionGroups/JobsOptionGroup.swift @@ -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") + } + } +} diff --git a/Sources/mas/Commands/OptionGroups/OutdatedAppsOptionGroup.swift b/Sources/mas/Commands/OptionGroups/OutdatedAppsOptionGroup.swift index 04a28e928..66ff0c301 100644 --- a/Sources/mas/Commands/OptionGroups/OutdatedAppsOptionGroup.swift +++ b/Sources/mas/Commands/OptionGroups/OutdatedAppsOptionGroup.swift @@ -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 { diff --git a/Sources/mas/Models/AppStoreAction.swift b/Sources/mas/Models/AppStoreAction.swift index 1cdcd88e6..049a0625c 100644 --- a/Sources/mas/Models/AppStoreAction.swift +++ b/Sources/mas/Models/AppStoreAction.swift @@ -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( @@ -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, @@ -161,6 +187,7 @@ enum AppStoreAction: String { error: error, ) } + var permanentLines = [String]() switch snapshot.activePhaseType { case prevPhaseType: break @@ -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 { @@ -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 { diff --git a/Sources/mas/Utilities/Output/Printer.swift b/Sources/mas/Utilities/Output/Printer.swift index 9b87ea308..f42064abc 100644 --- a/Sources/mas/Utilities/Output/Printer.swift +++ b/Sources/mas/Utilities/Output/Printer.swift @@ -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. @@ -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, @@ -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() + 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) } diff --git a/Sources/mas/Utilities/Swift/Collection.swift b/Sources/mas/Utilities/Swift/Collection.swift index 79961fef4..72cd6265d 100644 --- a/Sources/mas/Utilities/Swift/Collection.swift +++ b/Sources/mas/Utilities/Swift/Collection.swift @@ -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( maxConcurrentTaskCount: Int, _ transform: @escaping @Sendable (Element) async throws -> T,