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
37 changes: 33 additions & 4 deletions Sources/mas/AppStore/Downloader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,50 @@ private import CommerceKit
struct Downloader {
let printer: Printer

func downloadApps(
withAppIDs appIDs: [AppID],
purchasing: Bool,
forceDownload: Bool,
installedApps: [InstalledApp],
searcher: AppStoreSearcher
) async {
for appID in appIDs.filter({ appID in
if let installedApp = installedApps.first(where: { appID.matches($0) }), !forceDownload {
printer.warning(
purchasing ? "Already purchased: " : "Already installed: ",
installedApp.name,
" (",
appID,
")",
separator: ""
)
return false
}
return true
}) {
do {
try await downloadApp(withADAMID: try await appID.adamID(searcher: searcher), purchasing: purchasing)
} catch {
printer.error(error: error)
}
}
}

func downloadApp(
withAppID appID: AppID,
withADAMID adamID: ADAMID,
purchasing: Bool = false,
withAttemptCount attemptCount: UInt32 = 3
) async throws {
do {
let purchase = await SSPurchase(appID: appID, purchasing: purchasing)
let purchase = await SSPurchase(adamID: adamID, 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()
try await PurchaseDownloadObserver(adamID: adamID, printer: printer).observeDownloadQueue()
continuation.resume()
} catch {
continuation.resume(throwing: error)
Expand Down Expand Up @@ -53,7 +82,7 @@ struct Downloader {
error,
separator: ""
)
try await downloadApp(withAppID: appID, purchasing: purchasing, withAttemptCount: attemptCount)
try await downloadApp(withADAMID: adamID, purchasing: purchasing, withAttemptCount: attemptCount)
}
}
}
12 changes: 6 additions & 6 deletions Sources/mas/AppStore/PurchaseDownloadObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ private var initialPhaseType: Int64 { 4 }
private var downloadedPhaseType: Int64 { 5 }

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

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

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

Expand All @@ -32,14 +32,14 @@ class PurchaseDownloadObserver: CKDownloadQueueObserver {
func downloadQueue(_ queue: CKDownloadQueue, statusChangedFor download: SSDownload) {
guard
let metadata = download.metadata,
metadata.itemIdentifier == appID,
metadata.itemIdentifier == adamID,
let status = download.status
else {
return
}

if status.isFailed || status.isCancelled {
queue.removeDownload(withItemIdentifier: metadata.itemIdentifier)
queue.removeDownload(withItemIdentifier: adamID)
} else {
prevPhaseType = printer.progress(for: metadata.appNameAndVersion, status: status, prevPhaseType: prevPhaseType)
}
Expand All @@ -52,7 +52,7 @@ class PurchaseDownloadObserver: CKDownloadQueueObserver {
func downloadQueue(_: CKDownloadQueue, changedWithRemoval download: SSDownload) {
guard
let metadata = download.metadata,
metadata.itemIdentifier == appID,
metadata.itemIdentifier == adamID,
let status = download.status
else {
return
Expand Down
13 changes: 6 additions & 7 deletions Sources/mas/AppStore/SSPurchase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,21 @@
private import StoreFoundation

extension SSPurchase {
convenience init(appID: AppID, purchasing: Bool) async {
convenience init(adamID: ADAMID, purchasing: Bool) async {
self.init(
buyParameters: """
productType=C&price=0&salableAdamId=\(appID)&pg=default&appExtVrsId=0&pricingParameters=\
\(purchasing ? "STDQ&macappinstalledconfirmed=1" : "STDRDL")
productType=C&price=0&pg=default&appExtVrsId=0&pricingParameters=\
\(purchasing ? "STDQ&macappinstalledconfirmed=1" : "STDRDL")&salableAdamId=\(adamID)
"""
)

// Possibly unnecessary…
isRedownload = !purchasing

itemIdentifier = appID
itemIdentifier = adamID

let downloadMetadata = SSDownloadMetadata()
downloadMetadata.kind = "software"
downloadMetadata.itemIdentifier = appID
let downloadMetadata = SSDownloadMetadata(kind: "software")
downloadMetadata.itemIdentifier = adamID
self.downloadMetadata = downloadMetadata

do {
Expand Down
25 changes: 7 additions & 18 deletions Sources/mas/Commands/Install.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,24 +26,13 @@ extension MAS {

func run(installedApps: [InstalledApp], searcher: AppStoreSearcher) async throws {
try await mas.run { printer in
await run(downloader: Downloader(printer: printer), installedApps: installedApps, searcher: searcher)
}
}

private func run(downloader: Downloader, installedApps: [InstalledApp], searcher: AppStoreSearcher) async {
for appID in appIDsOptionGroup.appIDs.filter({ appID in
if let installedApp = installedApps.first(where: { $0.id == appID }), !forceOptionGroup.force {
downloader.printer.warning("Already installed:", installedApp.idAndName)
return false
}
return true
}) {
do {
_ = try await searcher.lookup(appID: appID)
try await downloader.downloadApp(withAppID: appID)
} catch {
downloader.printer.error(error: error)
}
await Downloader(printer: printer).downloadApps(
withAppIDs: appIDsOptionGroup.appIDs,
purchasing: false,
forceDownload: forceOptionGroup.force,
installedApps: installedApps,
searcher: searcher
)
}
}
}
Expand Down
26 changes: 12 additions & 14 deletions Sources/mas/Commands/Lucky.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,21 +43,19 @@ extension MAS {
throw MASError.noSearchResultsFound(for: searchTerm)
}

try await install(appID: result.trackId, installedApps: installedApps, downloader: downloader)
}

/// Installs an app.
///
/// - Parameters:
/// - appID: App ID.
/// - installedApps: List of installed apps.
/// - downloader: `Downloader`.
/// - Throws: Any error that occurs while attempting to install the app.
private func install(appID: AppID, installedApps: [InstalledApp], downloader: Downloader) async throws {
if let installedApp = installedApps.first(where: { $0.id == appID }), !forceOptionGroup.force {
downloader.printer.warning("Already installed:", installedApp.idAndName)
let adamID = result.adamID

if let installedApp = installedApps.first(where: { $0.adamID == adamID }), !forceOptionGroup.force {
downloader.printer.warning(
"Already installed: ",
installedApp.name,
" (search term ",
searchTerm,
")",
separator: ""
)
} else {
try await downloader.downloadApp(withAppID: appID)
try await downloader.downloadApp(withADAMID: adamID)
}
}
}
Expand Down
13 changes: 9 additions & 4 deletions Sources/mas/Commands/Open.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ extension MAS {
abstract: "Open app page in 'App Store.app'"
)

@Argument(help: "App ID")
var appID: AppID?
@Flag(name: .customLong("bundle"), help: ArgumentHelp("Process all app IDs as bundle IDs"))
var forceBundleID = false
@Argument(help: ArgumentHelp("App ID", valueName: "app-id"))
var appIDString: String?

/// Runs the command.
func run() async throws {
Expand All @@ -34,13 +36,16 @@ extension MAS {
}

private func run(printer _: Printer, searcher: AppStoreSearcher) async throws {
guard let appID else {
guard let appIDString else {
// If no app ID was given, just open the MAS GUI app
try await openMacAppStore()
return
}

try await openInMacAppStore(pageForAppID: appID, searcher: searcher)
try await openInMacAppStore(
pageForAppID: AppID(from: appIDString, forceBundleID: forceBundleID),
searcher: searcher
)
}
}
}
Expand Down
8 changes: 7 additions & 1 deletion Sources/mas/Commands/OptionGroups/AppIDsOptionGroup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
internal import ArgumentParser

struct AppIDsOptionGroup: ParsableArguments {
@Flag(name: .customLong("bundle"), help: ArgumentHelp("Process all app IDs as bundle IDs"))
var forceBundleID = false
@Argument(help: ArgumentHelp("App ID", valueName: "app-id"))
var appIDs: [AppID]
var appIDStrings: [String]

var appIDs: [AppID] {
appIDStrings.compactMap { AppID(from: $0, forceBundleID: forceBundleID) }
}
}
2 changes: 1 addition & 1 deletion Sources/mas/Commands/Outdated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ extension MAS {
let storeApp = try await searcher.lookup(appID: installedApp.id)
if installedApp.isOutdated(comparedTo: storeApp) {
printer.info(
installedApp.id,
installedApp.adamID,
" ",
installedApp.name,
" (",
Expand Down
25 changes: 7 additions & 18 deletions Sources/mas/Commands/Purchase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,24 +24,13 @@ extension MAS {

func run(installedApps: [InstalledApp], searcher: AppStoreSearcher) async throws {
try await mas.run { printer in
await run(downloader: Downloader(printer: printer), installedApps: installedApps, searcher: searcher)
}
}

private func run(downloader: Downloader, installedApps: [InstalledApp], searcher: AppStoreSearcher) async {
for appID in appIDsOptionGroup.appIDs.filter({ appID in
if let installedApp = installedApps.first(where: { $0.id == appID }) {
downloader.printer.warning("Already purchased:", installedApp.idAndName)
return false
}
return true
}) {
do {
_ = try await searcher.lookup(appID: appID)
try await downloader.downloadApp(withAppID: appID, purchasing: true)
} catch {
downloader.printer.error(error: error)
}
await Downloader(printer: printer).downloadApps(
withAppIDs: appIDsOptionGroup.appIDs,
purchasing: true,
forceDownload: false,
installedApps: installedApps,
searcher: searcher
)
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/mas/Commands/Uninstall.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ extension MAS {

var uninstallingAppSet = OrderedSet<InstalledApp>()
for appID in appIDsOptionGroup.appIDs {
let apps = installedApps.filter { $0.id == appID }
let apps = installedApps.filter { appID.matches($0) }
apps.isEmpty
? printer.error(appID.notInstalledMessage) // swiftformat:disable:this indent
: uninstallingAppSet.formUnion(apps)
Expand Down
31 changes: 12 additions & 19 deletions Sources/mas/Commands/Upgrade.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ extension MAS {

@OptionGroup
var verboseOptionGroup: VerboseOptionGroup
@Argument(help: ArgumentHelp("App ID/name", valueName: "app-id-or-name"))
var appIDOrNames = [String]()
@Flag(name: .customLong("bundle"), help: ArgumentHelp("Process all app IDs as bundle IDs"))
var forceBundleID = false
@Argument(help: ArgumentHelp("App ID", valueName: "app-id"))
var appIDStrings = [String]()

/// Runs the command.
func run() async throws {
Expand Down Expand Up @@ -49,9 +51,9 @@ extension MAS {
separator: ""
)

for appID in apps.map(\.storeApp.trackId) {
for adamID in apps.map(\.storeApp.adamID) {
do {
try await downloader.downloadApp(withAppID: appID)
try await downloader.downloadApp(withADAMID: adamID)
} catch {
downloader.printer.error(error: error)
}
Expand All @@ -63,30 +65,21 @@ extension MAS {
installedApps: [InstalledApp],
searcher: AppStoreSearcher
) async -> [(installedApp: InstalledApp, storeApp: SearchResult)] {
let apps = appIDOrNames.isEmpty
let apps = appIDStrings.isEmpty
? installedApps // swiftformat:disable:this indent
: appIDOrNames.flatMap { appIDOrName in
if let appID = AppID(appIDOrName) {
// Find installed apps by app ID argument
let installedApps = installedApps.filter { $0.id == appID }
if installedApps.isEmpty {
printer.error(appID.notInstalledMessage)
}
return installedApps
}

// Find installed apps by name argument
let installedApps = installedApps.filter { $0.name == appIDOrName }
: appIDStrings.flatMap { appIDString in
let appID = AppID(from: appIDString, forceBundleID: forceBundleID)
let installedApps = installedApps.filter { appID.matches($0) }
if installedApps.isEmpty {
printer.error("No installed apps named", appIDOrName)
printer.error(appID.notInstalledMessage)
}
return installedApps
}

var outdatedApps = [(InstalledApp, SearchResult)]()
for installedApp in apps {
do {
let storeApp = try await searcher.lookup(appID: installedApp.id)
let storeApp = try await searcher.lookup(appID: .adamID(installedApp.adamID))
if installedApp.isOutdated(comparedTo: storeApp) {
outdatedApps.append((installedApp, storeApp))
}
Expand Down
9 changes: 8 additions & 1 deletion Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,14 @@ struct ITunesSearchAppStoreSearcher: AppStoreSearcher {
/// - Returns: URL for the lookup service.
/// - Throws: An `MASError.urlParsing` if `appID` can't be encoded.
private func lookupURL(forAppID appID: AppID, inRegion region: String) throws -> URL {
try url("lookup", URLQueryItem(name: "id", value: String(appID)), inRegion: region)
let queryItem =
switch appID {
case let .adamID(adamID):
URLQueryItem(name: "id", value: String(adamID))
case let .bundleID(bundleID):
URLQueryItem(name: "bundleId", value: bundleID)
}
return try url("lookup", queryItem, inRegion: region)
}

/// Builds the search URL for an app.
Expand Down
2 changes: 1 addition & 1 deletion Sources/mas/Controllers/SpotlightInstalledApps.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ var installedApps: [InstalledApp] {
.compactMap { result in // swiftformat:disable indent
if let item = result as? NSMetadataItem {
InstalledApp(
id: item.value(forAttribute: "kMDItemAppStoreAdamID") as? AppID ?? 0,
adamID: item.value(forAttribute: "kMDItemAppStoreAdamID") as? ADAMID ?? 0,
bundleID: item.value(forAttribute: NSMetadataItemCFBundleIdentifierKey) as? String ?? "",
name: (item.value(forAttribute: "_kMDItemDisplayNameWithExtensions") as? String ?? "").removingSuffix(
".app"
Expand Down
Loading