Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

馃馃徏 Rephrase async code as PromiseKit Promises #362

Merged
merged 8 commits into from
May 12, 2021
Merged
9 changes: 9 additions & 0 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@
"version": "9.1.0"
}
},
{
"package": "PromiseKit",
"repositoryURL": "https://github.com/mxcl/PromiseKit.git",
"state": {
"branch": null,
"revision": "aea48ea1855f5d82e2dffa6027afce3aab8f3dd7",
"version": "6.13.3"
}
},
{
"package": "Quick",
"repositoryURL": "https://github.com/Quick/Quick.git",
Expand Down
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ let package = Package(
.package(url: "https://github.com/Carthage/Commandant.git", from: "0.18.0"),
.package(url: "https://github.com/Quick/Nimble.git", from: "9.1.0"),
.package(url: "https://github.com/Quick/Quick.git", from: "4.0.0"),
.package(url: "https://github.com/mxcl/PromiseKit.git", from: "6.13.3"),
.package(url: "https://github.com/mxcl/Version.git", from: "2.0.0"),
],
targets: [
Expand All @@ -41,7 +42,7 @@ let package = Package(
),
.target(
name: "MasKit",
dependencies: ["Commandant", "Version"],
dependencies: ["Commandant", "PromiseKit", "Version"],
swiftSettings: [
.unsafeFlags([
"-I", "Sources/PrivateFrameworks/CommerceKit",
Expand Down
113 changes: 73 additions & 40 deletions Sources/MasKit/AppStore/Downloader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,61 +7,94 @@
//

import CommerceKit
import PromiseKit
import StoreFoundation

/// Monitors app download progress.
/// Downloads a list of apps, one after the other, printing progress to the console.
///
/// - Parameter adamId: An app ID?
/// - Parameter purchase: Flag indicating whether the app needs to be purchased.
/// - Parameter appIDs: The IDs of the apps to be downloaded
/// - Parameter purchase: Flag indicating whether the apps needs to be purchased.
/// Only works for free apps. Defaults to false.
/// - Returns: An error, if one occurred.
func download(_ adamId: UInt64, purchase: Bool = false) -> MASError? {
guard let account = ISStoreAccount.primaryAccount else {
return .notSignedIn
/// - 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: [UInt64], purchase: Bool = false) -> Promise<Void> {
var firstError: Error?
return appIDs.reduce(Guarantee<Void>.value(())) { previous, appID in
previous.then { downloadWithRetries(appID, purchase: purchase).recover { error in
if firstError == nil {
firstError = error
}
} }
}.done {
if let error = firstError {
throw error
}
}
}

guard let storeAccount = account as? ISStoreAccount
else { fatalError("Unable to cast StoreAccount to ISStoreAccount") }
let purchase = SSPurchase(adamId: adamId, account: storeAccount, purchase: purchase)

var purchaseError: MASError?
var observerIdentifier: CKDownloadQueueObserver?
private func downloadWithRetries(
_ appID: UInt64, purchase: Bool = false, attempts: Int = 3
) -> Promise<Void> {
download(appID, purchase: purchase).recover { error -> Promise<Void> in
guard attempts > 1 else {
throw error
}

let group = DispatchGroup()
group.enter()
purchase.perform { purchase, _, error, response in
if let error = error {
purchaseError = .purchaseFailed(error: error as NSError?)
group.leave()
return
// If the download failed due to network issues, try again. Otherwise, fail immediately.
guard case MASError.downloadFailed(let downloadError) = error,
case NSURLErrorDomain = downloadError?.domain else {
throw error
}

if let downloads = response?.downloads, downloads.count > 0, let purchase = purchase {
let observer = PurchaseDownloadObserver(purchase: purchase)
let attempts = attempts - 1
printWarning((downloadError ?? error).localizedDescription)
print("Trying again up to \(attempts) more \(attempts == 1 ? "time" : "times").")
return downloadWithRetries(appID, purchase: purchase, attempts: attempts)
}
}

/// Downloads an app, printing progress to the console.
///
/// - Parameter appID: The ID of the app to be downloaded
/// - Parameter purchase: Flag indicating whether the app needs to be purchased.
/// Only works for free apps. Defaults to false.
/// - Returns: A promise the completes when the download is complete.
private func download(_ appID: UInt64, purchase: Bool = false) -> Promise<Void> {
guard let account = ISStoreAccount.primaryAccount else {
return Promise(error: MASError.notSignedIn)
}

guard let storeAccount = account as? ISStoreAccount else {
fatalError("Unable to cast StoreAccount to ISStoreAccount")
}

observer.errorHandler = { error in
purchaseError = error
group.leave()
return Promise<SSPurchase> { seal in
let purchase = SSPurchase(adamId: appID, account: storeAccount, purchase: purchase)
purchase.perform { purchase, _, error, response in
if let error = error {
seal.reject(MASError.purchaseFailed(error: error as NSError?))
return
}

observer.completionHandler = {
group.leave()
guard response?.downloads.isEmpty == false, let purchase = purchase else {
print("No downloads")
seal.reject(MASError.noDownloads)
return
}

let downloadQueue = CKDownloadQueue.shared()
observerIdentifier = downloadQueue.add(observer)
} else {
print("No downloads")
purchaseError = .noDownloads
group.leave()
seal.fulfill(purchase)
}
}.then { purchase -> Promise<Void> in
let observer = PurchaseDownloadObserver(purchase: purchase)
let download = Promise<Void> { seal in
observer.errorHandler = seal.reject
observer.completionHandler = seal.fulfill_
}
}

group.wait()

if let observerIdentifier = observerIdentifier {
CKDownloadQueue.shared().remove(observerIdentifier)
let downloadQueue = CKDownloadQueue.shared()
let observerID = downloadQueue.add(observer)
return download.ensure {
downloadQueue.remove(observerID)
}
}

return purchaseError
}
2 changes: 1 addition & 1 deletion Sources/MasKit/Commands/Home.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public struct HomeCommand: CommandProtocol {
/// Runs the command.
public func run(_ options: HomeOptions) -> Result<Void, MASError> {
do {
guard let result = try storeSearch.lookup(app: options.appId) else {
guard let result = try storeSearch.lookup(app: options.appId).wait() else {
print("No results found")
return .failure(.noSearchResultsFound)
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/MasKit/Commands/Info.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public struct InfoCommand: CommandProtocol {
/// Runs the command.
public func run(_ options: InfoOptions) -> Result<Void, MASError> {
do {
guard let result = try storeSearch.lookup(app: options.appId) else {
guard let result = try storeSearch.lookup(app: options.appId).wait() else {
print("No results found")
return .failure(.noSearchResultsFound)
}
Expand Down
19 changes: 9 additions & 10 deletions Sources/MasKit/Commands/Install.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,23 +31,22 @@ public struct InstallCommand: CommandProtocol {
/// Runs the command.
public func run(_ options: Options) -> Result<Void, MASError> {
// Try to download applications with given identifiers and collect results
let downloadResults = options.appIds.compactMap { appId -> MASError? in
let appIds = options.appIds.filter { appId in
if let product = appLibrary.installedApp(forId: appId), !options.forceInstall {
printWarning("\(product.appName) is already installed")
return nil
return false
}

return download(appId)
return true
}

switch downloadResults.count {
case 0:
return .success(())
case 1:
return .failure(downloadResults[0])
default:
return .failure(.downloadFailed(error: nil))
do {
try downloadAll(appIds).wait()
} catch {
return .failure(error as? MASError ?? .downloadFailed(error: error as NSError))
}

return .success(())
}
}

Expand Down
28 changes: 11 additions & 17 deletions Sources/MasKit/Commands/Lucky.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public struct LuckyCommand: CommandProtocol {
var appId: Int?

do {
let results = try storeSearch.search(for: options.appName)
let results = try storeSearch.search(for: options.appName).wait()
guard let result = results.first else {
print("No results found")
return .failure(.noSearchResultsFound)
Expand Down Expand Up @@ -73,24 +73,18 @@ public struct LuckyCommand: CommandProtocol {
/// - Returns: Result of the operation.
fileprivate func install(_ appId: UInt64, options: Options) -> Result<Void, MASError> {
// Try to download applications with given identifiers and collect results
let downloadResults = [appId]
.compactMap { appId -> MASError? in
if let product = appLibrary.installedApp(forId: appId), !options.forceInstall {
printWarning("\(product.appName) is already installed")
return nil
}

return download(appId)
}

switch downloadResults.count {
case 0:
if let product = appLibrary.installedApp(forId: appId), !options.forceInstall {
printWarning("\(product.appName) is already installed")
return .success(())
case 1:
return .failure(downloadResults[0])
default:
return .failure(.downloadFailed(error: nil))
}

do {
try downloadAll([appId]).wait()
} catch {
return .failure(error as? MASError ?? .downloadFailed(error: error as NSError))
}

return .success(())
}
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/MasKit/Commands/Open.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public struct OpenCommand: CommandProtocol {
return .failure(.noSearchResultsFound)
}

guard let result = try storeSearch.lookup(app: appId)
guard let result = try storeSearch.lookup(app: appId).wait()
else {
print("No results found")
return .failure(.noSearchResultsFound)
Expand Down
34 changes: 14 additions & 20 deletions Sources/MasKit/Commands/Outdated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

import Commandant
import Foundation
import PromiseKit
import enum Swift.Result

/// Command which displays a list of installed apps which have available updates
/// ready to be installed from the Mac App Store.
Expand All @@ -34,19 +36,10 @@ public struct OutdatedCommand: CommandProtocol {

/// Runs the command.
public func run(_: Options) -> Result<Void, MASError> {
var failure: MASError?
let group = DispatchGroup()
for installedApp in appLibrary.installedApps {
group.enter()
storeSearch.lookup(app: installedApp.itemIdentifier.intValue) { storeApp, error in
defer { group.leave() }

guard error == nil else {
// Bubble up MASErrors
failure = error as? MASError ?? .searchFailed
return
}

let promises = appLibrary.installedApps.map { installedApp in
firstly {
storeSearch.lookup(app: installedApp.itemIdentifier.intValue)
}.done { storeApp in
guard let storeApp = storeApp else {
printWarning(
"""
Expand All @@ -66,12 +59,13 @@ public struct OutdatedCommand: CommandProtocol {
}
}

group.wait()

if let failure = failure {
return .failure(failure)
}

return .success(())
return firstly {
when(fulfilled: promises)
}.map {
Result<Void, MASError>.success(())
}.recover { error in
// Bubble up MASErrors
.value(Result<Void, MASError>.failure(error as? MASError ?? .searchFailed))
}.wait()
}
}
19 changes: 9 additions & 10 deletions Sources/MasKit/Commands/Purchase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,23 +30,22 @@ public struct PurchaseCommand: CommandProtocol {
/// Runs the command.
public func run(_ options: Options) -> Result<Void, MASError> {
// Try to download applications with given identifiers and collect results
let downloadResults = options.appIds.compactMap { appId -> MASError? in
let appIds = options.appIds.filter { appId in
if let product = appLibrary.installedApp(forId: appId) {
printWarning("\(product.appName) has already been purchased.")
return nil
return false
}

return download(appId, purchase: true)
return true
}

switch downloadResults.count {
case 0:
return .success(())
case 1:
return .failure(downloadResults[0])
default:
return .failure(.downloadFailed(error: nil))
do {
try downloadAll(appIds, purchase: true).wait()
} catch {
return .failure(error as? MASError ?? .downloadFailed(error: error as NSError))
}

return .success(())
}
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/MasKit/Commands/Search.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public struct SearchCommand: CommandProtocol {

public func run(_ options: Options) -> Result<Void, MASError> {
do {
let results = try storeSearch.search(for: options.appName)
let results = try storeSearch.search(for: options.appName).wait()
if results.isEmpty {
print("No results found")
return .failure(.noSearchResultsFound)
Expand Down