From 7952d0f18fd39916630ad5c5aae0c5d417754e46 Mon Sep 17 00:00:00 2001 From: Chris Araman Date: Mon, 3 May 2021 22:53:25 -0700 Subject: [PATCH 1/8] =?UTF-8?q?=F0=9F=93=A6=20Depend=20on=20PromiseKit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Package.resolved | 9 +++++++++ Package.swift | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Package.resolved b/Package.resolved index 34cfee556..a4a61a89d 100644 --- a/Package.resolved +++ b/Package.resolved @@ -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", diff --git a/Package.swift b/Package.swift index a58868257..62caf469b 100644 --- a/Package.swift +++ b/Package.swift @@ -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: [ @@ -41,7 +42,7 @@ let package = Package( ), .target( name: "MasKit", - dependencies: ["Commandant", "Version"], + dependencies: ["Commandant", "PromiseKit", "Version"], swiftSettings: [ .unsafeFlags([ "-I", "Sources/PrivateFrameworks/CommerceKit", From 2ad8695295a28692df58f9d1520b9aa88606ffd4 Mon Sep 17 00:00:00 2001 From: Chris Araman Date: Fri, 23 Apr 2021 00:01:18 -0700 Subject: [PATCH 2/8] =?UTF-8?q?=F0=9F=94=A5=20Initialize=20PromiseKit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/MasKit/MasKit.swift | 27 +++++++++++++++++++ Sources/mas/main.swift | 2 ++ .../Commands/AccountCommandSpec.swift | 3 +++ .../Commands/HomeCommandSpec.swift | 3 +++ .../Commands/InfoCommandSpec.swift | 3 +++ .../Commands/InstallCommandSpec.swift | 3 +++ .../Commands/ListCommandSpec.swift | 3 +++ .../Commands/LuckyCommandSpec.swift | 3 +++ .../Commands/OpenCommandSpec.swift | 3 +++ .../Commands/OutdatedCommandSpec.swift | 3 +++ .../Commands/PurchaseCommandSpec.swift | 3 +++ .../Commands/ResetCommandSpec.swift | 3 +++ .../Commands/SearchCommandSpec.swift | 3 +++ .../Commands/SignInCommandSpec.swift | 3 +++ .../Commands/SignOutCommandSpec.swift | 3 +++ .../Commands/UninstallCommandSpec.swift | 3 +++ .../Commands/UpgradeCommandSpec.swift | 3 +++ .../Commands/VendorCommandSpec.swift | 3 +++ .../Commands/VersionCommandSpec.swift | 3 +++ .../Controllers/MasAppLibrarySpec.swift | 3 +++ .../Controllers/MasStoreSearchSpec.swift | 3 +++ .../MasKitTests/Errors/MASErrorTestCase.swift | 1 + .../OpenSystemCommandSpec.swift | 3 +++ .../Formatters/AppListFormatterSpec.swift | 3 +++ .../SearchResultFormatterSpec.swift | 3 +++ .../Models/SearchResultListSpec.swift | 3 +++ .../MasKitTests/Models/SearchResultSpec.swift | 3 +++ .../Network/NetworkManagerTests.swift | 5 ++++ Tests/MasKitTests/OutputListener.swift | 4 +-- Tests/MasKitTests/OutputListenerSpec.swift | 3 +++ 30 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 Sources/MasKit/MasKit.swift diff --git a/Sources/MasKit/MasKit.swift b/Sources/MasKit/MasKit.swift new file mode 100644 index 000000000..20708db88 --- /dev/null +++ b/Sources/MasKit/MasKit.swift @@ -0,0 +1,27 @@ +// +// MasKit.swift +// MasKit +// +// Created by Chris Araman on 4/22/21. +// Copyright © 2021 mas-cli. All rights reserved. +// + +import PromiseKit + +public enum MasKit { + public static func initialize() { + PromiseKit.conf.Q.map = .global() + PromiseKit.conf.Q.return = .global() + PromiseKit.conf.logHandler = { event in + switch event { + case .waitOnMainThread: + // Ignored. This is a console app that waits on the main thread for + // promises to be processed on the global DispatchQueue. + break + default: + // Other events indicate a programming error. + fatalError("PromiseKit event: \(event)") + } + } + } +} diff --git a/Sources/mas/main.swift b/Sources/mas/main.swift index a91e01088..ca5b2cfcc 100644 --- a/Sources/mas/main.swift +++ b/Sources/mas/main.swift @@ -10,6 +10,8 @@ import Commandant import Foundation import MasKit +MasKit.initialize() + struct StderrOutputStream: TextOutputStream { mutating func write(_ string: String) { fputs(string, stderr) diff --git a/Tests/MasKitTests/Commands/AccountCommandSpec.swift b/Tests/MasKitTests/Commands/AccountCommandSpec.swift index 8a3905fa3..3a84b721e 100644 --- a/Tests/MasKitTests/Commands/AccountCommandSpec.swift +++ b/Tests/MasKitTests/Commands/AccountCommandSpec.swift @@ -13,6 +13,9 @@ import Quick public class AccountCommandSpec: QuickSpec { public override func spec() { + beforeSuite { + MasKit.initialize() + } describe("Account command") { it("displays active account") { let cmd = AccountCommand() diff --git a/Tests/MasKitTests/Commands/HomeCommandSpec.swift b/Tests/MasKitTests/Commands/HomeCommandSpec.swift index bbcf6b60e..8630ec8b1 100644 --- a/Tests/MasKitTests/Commands/HomeCommandSpec.swift +++ b/Tests/MasKitTests/Commands/HomeCommandSpec.swift @@ -22,6 +22,9 @@ public class HomeCommandSpec: QuickSpec { let openCommand = OpenSystemCommandMock() let cmd = HomeCommand(storeSearch: storeSearch, openCommand: openCommand) + beforeSuite { + MasKit.initialize() + } describe("home command") { beforeEach { storeSearch.reset() diff --git a/Tests/MasKitTests/Commands/InfoCommandSpec.swift b/Tests/MasKitTests/Commands/InfoCommandSpec.swift index dc5d7a615..a8893ef34 100644 --- a/Tests/MasKitTests/Commands/InfoCommandSpec.swift +++ b/Tests/MasKitTests/Commands/InfoCommandSpec.swift @@ -36,6 +36,9 @@ public class InfoCommandSpec: QuickSpec { """ + beforeSuite { + MasKit.initialize() + } describe("Info command") { beforeEach { storeSearch.reset() diff --git a/Tests/MasKitTests/Commands/InstallCommandSpec.swift b/Tests/MasKitTests/Commands/InstallCommandSpec.swift index e692e46bb..f0402a002 100644 --- a/Tests/MasKitTests/Commands/InstallCommandSpec.swift +++ b/Tests/MasKitTests/Commands/InstallCommandSpec.swift @@ -13,6 +13,9 @@ import Quick public class InstallCommandSpec: QuickSpec { public override func spec() { + beforeSuite { + MasKit.initialize() + } describe("install command") { it("installs apps") { let cmd = InstallCommand() diff --git a/Tests/MasKitTests/Commands/ListCommandSpec.swift b/Tests/MasKitTests/Commands/ListCommandSpec.swift index 5c9cc02f8..aa834aece 100644 --- a/Tests/MasKitTests/Commands/ListCommandSpec.swift +++ b/Tests/MasKitTests/Commands/ListCommandSpec.swift @@ -13,6 +13,9 @@ import Quick public class ListCommandSpec: QuickSpec { public override func spec() { + beforeSuite { + MasKit.initialize() + } describe("list command") { it("lists stuff") { let list = ListCommand() diff --git a/Tests/MasKitTests/Commands/LuckyCommandSpec.swift b/Tests/MasKitTests/Commands/LuckyCommandSpec.swift index 0dd637517..6107f661b 100644 --- a/Tests/MasKitTests/Commands/LuckyCommandSpec.swift +++ b/Tests/MasKitTests/Commands/LuckyCommandSpec.swift @@ -13,6 +13,9 @@ import Quick public class LuckyCommandSpec: QuickSpec { public override func spec() { + beforeSuite { + MasKit.initialize() + } describe("lucky command") { it("installs the first app matching a search") { let cmd = LuckyCommand() diff --git a/Tests/MasKitTests/Commands/OpenCommandSpec.swift b/Tests/MasKitTests/Commands/OpenCommandSpec.swift index 1b192b35b..7c3a2eeec 100644 --- a/Tests/MasKitTests/Commands/OpenCommandSpec.swift +++ b/Tests/MasKitTests/Commands/OpenCommandSpec.swift @@ -23,6 +23,9 @@ public class OpenCommandSpec: QuickSpec { let openCommand = OpenSystemCommandMock() let cmd = OpenCommand(storeSearch: storeSearch, openCommand: openCommand) + beforeSuite { + MasKit.initialize() + } describe("open command") { beforeEach { storeSearch.reset() diff --git a/Tests/MasKitTests/Commands/OutdatedCommandSpec.swift b/Tests/MasKitTests/Commands/OutdatedCommandSpec.swift index 65be5e2f0..f8fef58de 100644 --- a/Tests/MasKitTests/Commands/OutdatedCommandSpec.swift +++ b/Tests/MasKitTests/Commands/OutdatedCommandSpec.swift @@ -13,6 +13,9 @@ import Quick public class OutdatedCommandSpec: QuickSpec { public override func spec() { + beforeSuite { + MasKit.initialize() + } describe("outdated command") { it("displays apps with pending updates") { let cmd = OutdatedCommand() diff --git a/Tests/MasKitTests/Commands/PurchaseCommandSpec.swift b/Tests/MasKitTests/Commands/PurchaseCommandSpec.swift index f241d8a1b..e35d43bd5 100644 --- a/Tests/MasKitTests/Commands/PurchaseCommandSpec.swift +++ b/Tests/MasKitTests/Commands/PurchaseCommandSpec.swift @@ -13,6 +13,9 @@ import Quick public class PurchaseCommandSpec: QuickSpec { public override func spec() { + beforeSuite { + MasKit.initialize() + } describe("purchase command") { it("purchases apps") { let cmd = PurchaseCommand() diff --git a/Tests/MasKitTests/Commands/ResetCommandSpec.swift b/Tests/MasKitTests/Commands/ResetCommandSpec.swift index 114505729..1763b0901 100644 --- a/Tests/MasKitTests/Commands/ResetCommandSpec.swift +++ b/Tests/MasKitTests/Commands/ResetCommandSpec.swift @@ -13,6 +13,9 @@ import Quick public class ResetCommandSpec: QuickSpec { public override func spec() { + beforeSuite { + MasKit.initialize() + } describe("reset command") { it("updates stuff") { let cmd = ResetCommand() diff --git a/Tests/MasKitTests/Commands/SearchCommandSpec.swift b/Tests/MasKitTests/Commands/SearchCommandSpec.swift index d2017c17d..b10d9fe58 100644 --- a/Tests/MasKitTests/Commands/SearchCommandSpec.swift +++ b/Tests/MasKitTests/Commands/SearchCommandSpec.swift @@ -21,6 +21,9 @@ public class SearchCommandSpec: QuickSpec { ) let storeSearch = StoreSearchMock() + beforeSuite { + MasKit.initialize() + } describe("search command") { beforeEach { storeSearch.reset() diff --git a/Tests/MasKitTests/Commands/SignInCommandSpec.swift b/Tests/MasKitTests/Commands/SignInCommandSpec.swift index ac40ad32a..f9852a126 100644 --- a/Tests/MasKitTests/Commands/SignInCommandSpec.swift +++ b/Tests/MasKitTests/Commands/SignInCommandSpec.swift @@ -13,6 +13,9 @@ import Quick public class SignInCommandSpec: QuickSpec { public override func spec() { + beforeSuite { + MasKit.initialize() + } describe("signn command") { it("updates stuff") { let cmd = SignInCommand() diff --git a/Tests/MasKitTests/Commands/SignOutCommandSpec.swift b/Tests/MasKitTests/Commands/SignOutCommandSpec.swift index 95fd417fa..94bcfe909 100644 --- a/Tests/MasKitTests/Commands/SignOutCommandSpec.swift +++ b/Tests/MasKitTests/Commands/SignOutCommandSpec.swift @@ -13,6 +13,9 @@ import Quick public class SignOutCommandSpec: QuickSpec { public override func spec() { + beforeSuite { + MasKit.initialize() + } describe("signout command") { it("updates stuff") { let cmd = SignOutCommand() diff --git a/Tests/MasKitTests/Commands/UninstallCommandSpec.swift b/Tests/MasKitTests/Commands/UninstallCommandSpec.swift index 8620b75fa..3d100fbde 100644 --- a/Tests/MasKitTests/Commands/UninstallCommandSpec.swift +++ b/Tests/MasKitTests/Commands/UninstallCommandSpec.swift @@ -14,6 +14,9 @@ import Quick public class UninstallCommandSpec: QuickSpec { public override func spec() { + beforeSuite { + MasKit.initialize() + } describe("uninstall command") { let appId = 12345 let app = SoftwareProductMock( diff --git a/Tests/MasKitTests/Commands/UpgradeCommandSpec.swift b/Tests/MasKitTests/Commands/UpgradeCommandSpec.swift index 577f33854..01011e832 100644 --- a/Tests/MasKitTests/Commands/UpgradeCommandSpec.swift +++ b/Tests/MasKitTests/Commands/UpgradeCommandSpec.swift @@ -13,6 +13,9 @@ import Quick public class UpgradeCommandSpec: QuickSpec { public override func spec() { + beforeSuite { + MasKit.initialize() + } describe("upgrade command") { it("updates stuff") { let cmd = UpgradeCommand() diff --git a/Tests/MasKitTests/Commands/VendorCommandSpec.swift b/Tests/MasKitTests/Commands/VendorCommandSpec.swift index 53ca8ff90..d1dbbd5c9 100644 --- a/Tests/MasKitTests/Commands/VendorCommandSpec.swift +++ b/Tests/MasKitTests/Commands/VendorCommandSpec.swift @@ -22,6 +22,9 @@ public class VendorCommandSpec: QuickSpec { let openCommand = OpenSystemCommandMock() let cmd = VendorCommand(storeSearch: storeSearch, openCommand: openCommand) + beforeSuite { + MasKit.initialize() + } describe("vendor command") { beforeEach { storeSearch.reset() diff --git a/Tests/MasKitTests/Commands/VersionCommandSpec.swift b/Tests/MasKitTests/Commands/VersionCommandSpec.swift index e1e6f8906..7abd19ede 100644 --- a/Tests/MasKitTests/Commands/VersionCommandSpec.swift +++ b/Tests/MasKitTests/Commands/VersionCommandSpec.swift @@ -13,6 +13,9 @@ import Quick public class VersionCommandSpec: QuickSpec { public override func spec() { + beforeSuite { + MasKit.initialize() + } describe("version command") { it("displays the current version") { let cmd = VersionCommand() diff --git a/Tests/MasKitTests/Controllers/MasAppLibrarySpec.swift b/Tests/MasKitTests/Controllers/MasAppLibrarySpec.swift index ebde38fb4..26e0d7a90 100644 --- a/Tests/MasKitTests/Controllers/MasAppLibrarySpec.swift +++ b/Tests/MasKitTests/Controllers/MasAppLibrarySpec.swift @@ -15,6 +15,9 @@ public class MasAppLibrarySpec: QuickSpec { public override func spec() { let library = MasAppLibrary(softwareMap: SoftwareMapMock(products: apps)) + beforeSuite { + MasKit.initialize() + } describe("mas app library") { it("contains all installed apps") { expect(library.installedApps.count) == apps.count diff --git a/Tests/MasKitTests/Controllers/MasStoreSearchSpec.swift b/Tests/MasKitTests/Controllers/MasStoreSearchSpec.swift index bc2018f39..5b27688a1 100644 --- a/Tests/MasKitTests/Controllers/MasStoreSearchSpec.swift +++ b/Tests/MasKitTests/Controllers/MasStoreSearchSpec.swift @@ -13,6 +13,9 @@ import Quick public class MasStoreSearchSpec: QuickSpec { public override func spec() { + beforeSuite { + MasKit.initialize() + } describe("store") { context("when searched") { it("can find slack") { diff --git a/Tests/MasKitTests/Errors/MASErrorTestCase.swift b/Tests/MasKitTests/Errors/MASErrorTestCase.swift index 4130f7a12..ed53777d6 100644 --- a/Tests/MasKitTests/Errors/MASErrorTestCase.swift +++ b/Tests/MasKitTests/Errors/MASErrorTestCase.swift @@ -30,6 +30,7 @@ class MASErrorTestCase: XCTestCase { override func setUp() { super.setUp() + MasKit.initialize() nserror = NSError(domain: errorDomain, code: 999) localizedDescription = "foo" } diff --git a/Tests/MasKitTests/ExternalCommands/OpenSystemCommandSpec.swift b/Tests/MasKitTests/ExternalCommands/OpenSystemCommandSpec.swift index b3d31d5db..13236e7a3 100644 --- a/Tests/MasKitTests/ExternalCommands/OpenSystemCommandSpec.swift +++ b/Tests/MasKitTests/ExternalCommands/OpenSystemCommandSpec.swift @@ -13,6 +13,9 @@ import Quick public class OpenSystemCommandSpec: QuickSpec { public override func spec() { + beforeSuite { + MasKit.initialize() + } describe("open system command") { context("binary path") { it("defaults to the macOS open command") { diff --git a/Tests/MasKitTests/Formatters/AppListFormatterSpec.swift b/Tests/MasKitTests/Formatters/AppListFormatterSpec.swift index 913cd2d54..147d8b7e1 100644 --- a/Tests/MasKitTests/Formatters/AppListFormatterSpec.swift +++ b/Tests/MasKitTests/Formatters/AppListFormatterSpec.swift @@ -17,6 +17,9 @@ public class AppListsFormatterSpec: QuickSpec { let format = AppListFormatter.format(products:) var products: [SoftwareProduct] = [] + beforeSuite { + MasKit.initialize() + } describe("app list formatter") { beforeEach { products = [] diff --git a/Tests/MasKitTests/Formatters/SearchResultFormatterSpec.swift b/Tests/MasKitTests/Formatters/SearchResultFormatterSpec.swift index 90ab429e2..00d1fec2e 100644 --- a/Tests/MasKitTests/Formatters/SearchResultFormatterSpec.swift +++ b/Tests/MasKitTests/Formatters/SearchResultFormatterSpec.swift @@ -17,6 +17,9 @@ public class SearchResultsFormatterSpec: QuickSpec { let format = SearchResultFormatter.format(results:includePrice:) var results: [SearchResult] = [] + beforeSuite { + MasKit.initialize() + } describe("search results formatter") { beforeEach { results = [] diff --git a/Tests/MasKitTests/Models/SearchResultListSpec.swift b/Tests/MasKitTests/Models/SearchResultListSpec.swift index e6bfd0b9f..6cb11d417 100644 --- a/Tests/MasKitTests/Models/SearchResultListSpec.swift +++ b/Tests/MasKitTests/Models/SearchResultListSpec.swift @@ -14,6 +14,9 @@ import Quick public class SearchResultListSpec: QuickSpec { public override func spec() { + beforeSuite { + MasKit.initialize() + } describe("search result list") { it("can parse bbedit") { let data = Data(from: "search/bbedit.json") diff --git a/Tests/MasKitTests/Models/SearchResultSpec.swift b/Tests/MasKitTests/Models/SearchResultSpec.swift index 12d41076d..be149d2c0 100644 --- a/Tests/MasKitTests/Models/SearchResultSpec.swift +++ b/Tests/MasKitTests/Models/SearchResultSpec.swift @@ -14,6 +14,9 @@ import Quick public class SearchResultSpec: QuickSpec { public override func spec() { + beforeSuite { + MasKit.initialize() + } describe("search result") { it("can parse things") { let data = Data(from: "search/things-that-go-bump.json") diff --git a/Tests/MasKitTests/Network/NetworkManagerTests.swift b/Tests/MasKitTests/Network/NetworkManagerTests.swift index c78690c8a..31d3520ec 100644 --- a/Tests/MasKitTests/Network/NetworkManagerTests.swift +++ b/Tests/MasKitTests/Network/NetworkManagerTests.swift @@ -11,6 +11,11 @@ import XCTest @testable import MasKit class NetworkManagerTests: XCTestCase { + public override func setUp() { + super.setUp() + MasKit.initialize() + } + func testSuccessfulAsyncResponse() { // Setup our objects let session = NetworkSessionMock() diff --git a/Tests/MasKitTests/OutputListener.swift b/Tests/MasKitTests/OutputListener.swift index 47458dd98..434655e1d 100644 --- a/Tests/MasKitTests/OutputListener.swift +++ b/Tests/MasKitTests/OutputListener.swift @@ -15,7 +15,7 @@ class OutputListener { var contents = "" init() { - MasKit.printObserver = { [weak self] text in + printObserver = { [weak self] text in strongify(self) { context in context.contents += text } @@ -23,6 +23,6 @@ class OutputListener { } deinit { - MasKit.printObserver = nil + printObserver = nil } } diff --git a/Tests/MasKitTests/OutputListenerSpec.swift b/Tests/MasKitTests/OutputListenerSpec.swift index fd92e85ee..8505a7264 100644 --- a/Tests/MasKitTests/OutputListenerSpec.swift +++ b/Tests/MasKitTests/OutputListenerSpec.swift @@ -13,6 +13,9 @@ import Quick public class OutputListenerSpec: QuickSpec { public override func spec() { + beforeSuite { + MasKit.initialize() + } describe("output listener") { it("can intercept a single line written stdout") { let output = OutputListener() From a9b018854d50d240939585b9d465290039751f86 Mon Sep 17 00:00:00 2001 From: Chris Araman Date: Thu, 22 Apr 2021 19:15:57 -0700 Subject: [PATCH 3/8] =?UTF-8?q?=F0=9F=A4=9E=F0=9F=8F=BC=20Rephrase=20MasSt?= =?UTF-8?q?oreSearch=20as=20Promises?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MasKit/Controllers/MasStoreSearch.swift | 88 ++++++++----------- Sources/MasKit/Network/NetworkManager.swift | 29 ++---- Sources/MasKit/Network/NetworkSession.swift | 3 +- .../Network/URLSession+NetworkSession.swift | 17 +++- .../Network/NetworkManagerTests.swift | 25 ++---- .../Network/NetworkSessionMock.swift | 10 ++- .../Network/NetworkSessionMockFromFile.swift | 7 +- 7 files changed, 76 insertions(+), 103 deletions(-) diff --git a/Sources/MasKit/Controllers/MasStoreSearch.swift b/Sources/MasKit/Controllers/MasStoreSearch.swift index 79d533412..be20aa10f 100644 --- a/Sources/MasKit/Controllers/MasStoreSearch.swift +++ b/Sources/MasKit/Controllers/MasStoreSearch.swift @@ -7,6 +7,7 @@ // import Foundation +import PromiseKit import Version /// Manages searching the MAS catalog through the iTunes Search and Lookup APIs. @@ -37,13 +38,12 @@ class MasStoreSearch: StoreSearch { return } - loadSearchResults(url) { results, error in - if let error = error { - completion(nil, error) - return - } - + firstly { + loadSearchResults(url) + }.done { results in completion(results, nil) + }.catch { error in + completion(nil, error) } } @@ -59,81 +59,65 @@ class MasStoreSearch: StoreSearch { return } - loadSearchResults(url) { results, error in - if let error = error { - completion(nil, error) - return - } - - completion(results?.first, nil) + firstly { + loadSearchResults(url) + }.done { results in + completion(results.first, nil) + }.catch { error in + completion(nil, error) } } - private func loadSearchResults(_ url: URL, _ completion: @escaping ([SearchResult]?, Error?) -> Void) { - networkManager.loadData(from: url) { data, error in - guard let data = data else { - if let error = error { - completion(nil, error) - } else { - completion(nil, MASError.noData) - } - - return - } - - var results: SearchResultList + private func loadSearchResults(_ url: URL) -> Promise<[SearchResult]> { + firstly { + networkManager.loadData(from: url) + }.map { data -> SearchResultList in do { - results = try JSONDecoder().decode(SearchResultList.self, from: data) + return try JSONDecoder().decode(SearchResultList.self, from: data) } catch { - completion(nil, MASError.jsonParsing(error: error as NSError)) - return + throw MASError.jsonParsing(error: error as NSError) } - - let group = DispatchGroup() - for index in results.results.indices { - let result = results.results[index] + }.then { list -> Promise<[SearchResult]> in + var results = list.results + let scraping = results.indices.compactMap { index -> Guarantee? in + let result = results[index] guard let searchVersion = Version(tolerant: result.version), let pageUrl = URL(string: result.trackViewUrl) else { - continue + return nil } - group.enter() - self.scrapeVersionFromPage(pageUrl) { pageVersion in + return firstly { + self.scrapeVersionFromPage(pageUrl) + }.done { pageVersion in if let pageVersion = pageVersion, pageVersion > searchVersion { - results.results[index].version = pageVersion.description + results[index].version = pageVersion.description } - - group.leave() } } - group.notify(queue: DispatchQueue.global()) { - completion(results.results, nil) - } + return when(fulfilled: scraping).map { results } } } // The App Store often lists a newer version available in an app's page than in // the search results. We attempt to scrape it here. - private func scrapeVersionFromPage(_ pageUrl: URL, _ completion: @escaping (Version?) -> Void) { - networkManager.loadData(from: pageUrl) { data, _ in - guard let data = data else { - completion(nil) - return - } - + private func scrapeVersionFromPage(_ pageUrl: URL) -> Guarantee { + firstly { + networkManager.loadData(from: pageUrl) + }.map { data in let html = String(decoding: data, as: UTF8.self) let fullRange = NSRange(html.startIndex.. Void) { - session.loadData(from: url, completionHandler: completionHandler) + /// - Returns: A Promise for the Data of the response. + func loadData(from url: URL) -> Promise { + session.loadData(from: url) } /// Loads data synchronously. @@ -39,26 +40,6 @@ class NetworkManager { /// - Parameter url: URL to load data from. /// - Returns: The Data of the response. func loadDataSync(from url: URL) throws -> Data { - var data: Data? - var error: Error? - let group = DispatchGroup() - group.enter() - session.loadData(from: url) { - data = $0 - error = $1 - group.leave() - } - - group.wait() - - guard error == nil else { - throw error! - } - - guard data != nil else { - throw MASError.noData - } - - return data! + try session.loadData(from: url).wait() } } diff --git a/Sources/MasKit/Network/NetworkSession.swift b/Sources/MasKit/Network/NetworkSession.swift index a3ef2cff6..28cd64950 100644 --- a/Sources/MasKit/Network/NetworkSession.swift +++ b/Sources/MasKit/Network/NetworkSession.swift @@ -7,7 +7,8 @@ // import Foundation +import PromiseKit protocol NetworkSession { - func loadData(from url: URL, completionHandler: @escaping (Data?, Error?) -> Void) + func loadData(from url: URL) -> Promise } diff --git a/Sources/MasKit/Network/URLSession+NetworkSession.swift b/Sources/MasKit/Network/URLSession+NetworkSession.swift index 66c08f5ff..2b8c5c177 100644 --- a/Sources/MasKit/Network/URLSession+NetworkSession.swift +++ b/Sources/MasKit/Network/URLSession+NetworkSession.swift @@ -7,12 +7,21 @@ // import Foundation +import PromiseKit extension URLSession: NetworkSession { - open func loadData(from url: URL, completionHandler: @escaping (Data?, Error?) -> Void) { - let task = dataTask(with: url) { data, _, error in - completionHandler(data, error) + open func loadData(from url: URL) -> Promise { + Promise { seal in + dataTask(with: url) { data, _, error in + if let data = data { + seal.fulfill(data) + } else if let error = error { + seal.reject(error) + } else { + seal.reject(MASError.noData) + } + } + .resume() } - task.resume() } } diff --git a/Tests/MasKitTests/Network/NetworkManagerTests.swift b/Tests/MasKitTests/Network/NetworkManagerTests.swift index 31d3520ec..382e4f636 100644 --- a/Tests/MasKitTests/Network/NetworkManagerTests.swift +++ b/Tests/MasKitTests/Network/NetworkManagerTests.swift @@ -16,7 +16,7 @@ class NetworkManagerTests: XCTestCase { MasKit.initialize() } - func testSuccessfulAsyncResponse() { + func testSuccessfulAsyncResponse() throws { // Setup our objects let session = NetworkSessionMock() let manager = NetworkManager(session: session) @@ -29,15 +29,8 @@ class NetworkManagerTests: XCTestCase { let url = URL(fileURLWithPath: "url") // Perform the request and verify the result - var response: Data? - var error: Error? - manager.loadData(from: url) { - response = $0 - error = $1 - } - + let response = try manager.loadData(from: url).wait() XCTAssertEqual(response, data) - XCTAssertNil(error) } func testSuccessfulSyncResponse() throws { @@ -68,14 +61,14 @@ class NetworkManagerTests: XCTestCase { let url = URL(fileURLWithPath: "url") // Perform the request and verify the result - var error: Error! - manager.loadData(from: url) { error = $1 } - guard let masError = error as? MASError else { - XCTFail("Error is of unexpected type.") - return - } + XCTAssertThrowsError(try manager.loadData(from: url).wait()) { error in + guard let masError = error as? MASError else { + XCTFail("Error is of unexpected type.") + return + } - XCTAssertEqual(masError, MASError.noData) + XCTAssertEqual(masError, MASError.noData) + } } func testFailureSyncResponse() { diff --git a/Tests/MasKitTests/Network/NetworkSessionMock.swift b/Tests/MasKitTests/Network/NetworkSessionMock.swift index 8bd84087b..5455aebce 100644 --- a/Tests/MasKitTests/Network/NetworkSessionMock.swift +++ b/Tests/MasKitTests/Network/NetworkSessionMock.swift @@ -7,7 +7,7 @@ // import Foundation - +import PromiseKit @testable import MasKit /// Mock NetworkSession for testing. @@ -22,7 +22,11 @@ class NetworkSessionMock: NetworkSession { /// - Parameters: /// - url: unused /// - completionHandler: Closure which is delivered either data or an error. - func loadData(from _: URL, completionHandler: @escaping (Data?, Error?) -> Void) { - completionHandler(data, error) + func loadData(from _: URL) -> Promise { + guard let data = data else { + return Promise(error: error ?? MASError.noData) + } + + return .value(data) } } diff --git a/Tests/MasKitTests/Network/NetworkSessionMockFromFile.swift b/Tests/MasKitTests/Network/NetworkSessionMockFromFile.swift index 539fa7777..52c5d1299 100644 --- a/Tests/MasKitTests/Network/NetworkSessionMockFromFile.swift +++ b/Tests/MasKitTests/Network/NetworkSessionMockFromFile.swift @@ -7,6 +7,7 @@ // import Foundation +import PromiseKit /// Mock NetworkSession for testing with saved JSON response payload files. class NetworkSessionMockFromFile: NetworkSessionMock { @@ -25,16 +26,16 @@ class NetworkSessionMockFromFile: NetworkSessionMock { /// - Parameters: /// - url: unused /// - completionHandler: Closure which is delivered either data or an error. - override func loadData(from _: URL, completionHandler: @escaping (Data?, Error?) -> Void) { + override func loadData(from _: URL) -> Promise { guard let fileURL = Bundle.url(for: responseFile) else { fatalError("Unable to load file \(responseFile)") } do { let data = try Data(contentsOf: fileURL, options: .mappedIfSafe) - completionHandler(data, nil) + return .value(data) } catch { print("Error opening file: \(error)") - completionHandler(nil, error) + return Promise(error: error) } } } From 9494dea40318315225d3db3d1c3ddc250090aca0 Mon Sep 17 00:00:00 2001 From: Chris Araman Date: Thu, 6 May 2021 15:08:56 -0700 Subject: [PATCH 4/8] =?UTF-8?q?=F0=9F=A4=9E=F0=9F=8F=BC=20Rephrase=20Store?= =?UTF-8?q?Search=20as=20Promises?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/MasKit/Commands/Home.swift | 2 +- Sources/MasKit/Commands/Info.swift | 2 +- Sources/MasKit/Commands/Lucky.swift | 2 +- Sources/MasKit/Commands/Open.swift | 2 +- Sources/MasKit/Commands/Outdated.swift | 34 +++++------ Sources/MasKit/Commands/Search.swift | 2 +- Sources/MasKit/Commands/Upgrade.swift | 4 +- Sources/MasKit/Commands/Vendor.swift | 2 +- .../MasKit/Controllers/MasStoreSearch.swift | 28 +++------ Sources/MasKit/Controllers/StoreSearch.swift | 57 +------------------ .../Controllers/MasStoreSearchSpec.swift | 4 +- .../Controllers/StoreSearchMock.swift | 15 +++-- .../Controllers/StoreSearchSpec.swift | 11 ++-- 13 files changed, 47 insertions(+), 118 deletions(-) diff --git a/Sources/MasKit/Commands/Home.swift b/Sources/MasKit/Commands/Home.swift index 2caf200b2..db7b1c397 100644 --- a/Sources/MasKit/Commands/Home.swift +++ b/Sources/MasKit/Commands/Home.swift @@ -38,7 +38,7 @@ public struct HomeCommand: CommandProtocol { /// Runs the command. public func run(_ options: HomeOptions) -> Result { 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) } diff --git a/Sources/MasKit/Commands/Info.swift b/Sources/MasKit/Commands/Info.swift index 26f3221fc..dc3ad35d8 100644 --- a/Sources/MasKit/Commands/Info.swift +++ b/Sources/MasKit/Commands/Info.swift @@ -29,7 +29,7 @@ public struct InfoCommand: CommandProtocol { /// Runs the command. public func run(_ options: InfoOptions) -> Result { 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) } diff --git a/Sources/MasKit/Commands/Lucky.swift b/Sources/MasKit/Commands/Lucky.swift index 97f30f831..e3c2bf6ef 100644 --- a/Sources/MasKit/Commands/Lucky.swift +++ b/Sources/MasKit/Commands/Lucky.swift @@ -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) diff --git a/Sources/MasKit/Commands/Open.swift b/Sources/MasKit/Commands/Open.swift index 8394ac3a9..6446701b0 100644 --- a/Sources/MasKit/Commands/Open.swift +++ b/Sources/MasKit/Commands/Open.swift @@ -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) diff --git a/Sources/MasKit/Commands/Outdated.swift b/Sources/MasKit/Commands/Outdated.swift index e8f5f0053..da3c60408 100644 --- a/Sources/MasKit/Commands/Outdated.swift +++ b/Sources/MasKit/Commands/Outdated.swift @@ -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. @@ -34,19 +36,10 @@ public struct OutdatedCommand: CommandProtocol { /// Runs the command. public func run(_: Options) -> Result { - 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( """ @@ -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.success(()) + }.recover { error in + // Bubble up MASErrors + .value(Result.failure(error as? MASError ?? .searchFailed)) + }.wait() } } diff --git a/Sources/MasKit/Commands/Search.swift b/Sources/MasKit/Commands/Search.swift index 627211840..9800edd4e 100644 --- a/Sources/MasKit/Commands/Search.swift +++ b/Sources/MasKit/Commands/Search.swift @@ -30,7 +30,7 @@ public struct SearchCommand: CommandProtocol { public func run(_ options: Options) -> Result { 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) diff --git a/Sources/MasKit/Commands/Upgrade.swift b/Sources/MasKit/Commands/Upgrade.swift index b243d8e32..d085661a2 100644 --- a/Sources/MasKit/Commands/Upgrade.swift +++ b/Sources/MasKit/Commands/Upgrade.swift @@ -94,7 +94,7 @@ public struct UpgradeCommand: CommandProtocol { for installedApp in apps { // only upgrade apps whose local version differs from the store version group.enter() - storeSearch.lookup(app: installedApp.itemIdentifier.intValue) { result, _ in + try storeSearch.lookup(app: installedApp.itemIdentifier.intValue).done { result in defer { group.leave() } if let storeApp = result, installedApp.isOutdatedWhenComparedTo(storeApp) { @@ -103,7 +103,7 @@ public struct UpgradeCommand: CommandProtocol { outdated.append(installedApp) } - } + }.wait() } group.wait() diff --git a/Sources/MasKit/Commands/Vendor.swift b/Sources/MasKit/Commands/Vendor.swift index 38bb56db6..07b9c357a 100644 --- a/Sources/MasKit/Commands/Vendor.swift +++ b/Sources/MasKit/Commands/Vendor.swift @@ -38,7 +38,7 @@ public struct VendorCommand: CommandProtocol { /// Runs the command. public func run(_ options: VendorOptions) -> Result { do { - guard let result = try storeSearch.lookup(app: options.appId) + guard let result = try storeSearch.lookup(app: options.appId).wait() else { print("No results found") return .failure(.noSearchResultsFound) diff --git a/Sources/MasKit/Controllers/MasStoreSearch.swift b/Sources/MasKit/Controllers/MasStoreSearch.swift index be20aa10f..feb9d37db 100644 --- a/Sources/MasKit/Controllers/MasStoreSearch.swift +++ b/Sources/MasKit/Controllers/MasStoreSearch.swift @@ -31,41 +31,27 @@ class MasStoreSearch: StoreSearch { /// - Parameter appName: MAS ID of app /// - Parameter completion: A closure that receives the search results or an Error if there is a /// problem with the network request. Results array will be empty if there were no matches. - func search(for appName: String, _ completion: @escaping ([SearchResult]?, Error?) -> Void) { + func search(for appName: String) -> Promise<[SearchResult]> { guard let url = searchURL(for: appName) else { - completion(nil, MASError.urlEncoding) - return + return Promise(error: MASError.urlEncoding) } - firstly { - loadSearchResults(url) - }.done { results in - completion(results, nil) - }.catch { error in - completion(nil, error) - } + return loadSearchResults(url) } /// Looks up app details. /// /// - Parameter appId: MAS ID of app - /// - Parameter completion: A closure that receives the search result record of app, or nil if no apps match the ID, + /// - Returns: A Promise for the search result record of app, or nil if no apps match the ID, /// or an Error if there is a problem with the network request. - func lookup(app appId: Int, _ completion: @escaping (SearchResult?, Error?) -> Void) { + func lookup(app appId: Int) -> Promise { guard let url = lookupURL(forApp: appId) else { - completion(nil, MASError.urlEncoding) - return + return Promise(error: MASError.urlEncoding) } - firstly { - loadSearchResults(url) - }.done { results in - completion(results.first, nil) - }.catch { error in - completion(nil, error) - } + return loadSearchResults(url).map { results in results.first } } private func loadSearchResults(_ url: URL) -> Promise<[SearchResult]> { diff --git a/Sources/MasKit/Controllers/StoreSearch.swift b/Sources/MasKit/Controllers/StoreSearch.swift index 304570179..5dc097ed8 100644 --- a/Sources/MasKit/Controllers/StoreSearch.swift +++ b/Sources/MasKit/Controllers/StoreSearch.swift @@ -7,67 +7,16 @@ // import Foundation +import PromiseKit /// Protocol for searching the MAS catalog. protocol StoreSearch { - func lookup(app appId: Int, _ completion: @escaping (SearchResult?, Error?) -> Void) - func search(for appName: String, _ completion: @escaping ([SearchResult]?, Error?) -> Void) + func lookup(app appId: Int) -> Promise + func search(for appName: String) -> Promise<[SearchResult]> } // MARK: - Common methods extension StoreSearch { - /// Looks up app details. - /// - /// - Parameter appId: MAS ID of app - /// - Returns: Search result record of app or nil if no apps match the ID. - /// - Throws: Error if there is a problem with the network request. - func lookup(app appId: Int) throws -> SearchResult? { - var result: SearchResult? - var error: Error? - - let group = DispatchGroup() - group.enter() - lookup(app: appId) { - result = $0 - error = $1 - group.leave() - } - - group.wait() - - if let error = error { - throw error - } - - return result - } - - /// Searches for an app. - /// - /// - Parameter appName: MAS ID of app - /// - Returns: Search results. Empty if there were no matches. - /// - Throws: Error if there is a problem with the network request. - func search(for appName: String) throws -> [SearchResult] { - var results: [SearchResult]? - var error: Error? - - let group = DispatchGroup() - group.enter() - search(for: appName) { - results = $0 - error = $1 - group.leave() - } - - group.wait() - - if let error = error { - throw error - } - - return results! - } - /// Builds the search URL for an app. /// /// - Parameter appName: MAS app identifier. diff --git a/Tests/MasKitTests/Controllers/MasStoreSearchSpec.swift b/Tests/MasKitTests/Controllers/MasStoreSearchSpec.swift index 5b27688a1..4599b7898 100644 --- a/Tests/MasKitTests/Controllers/MasStoreSearchSpec.swift +++ b/Tests/MasKitTests/Controllers/MasStoreSearchSpec.swift @@ -24,7 +24,7 @@ public class MasStoreSearchSpec: QuickSpec { var results: [SearchResult] do { - results = try storeSearch.search(for: "slack") + results = try storeSearch.search(for: "slack").wait() expect(results.count) == 39 } catch { let maserror = error as! MASError @@ -43,7 +43,7 @@ public class MasStoreSearchSpec: QuickSpec { var lookup: SearchResult? do { - lookup = try storeSearch.lookup(app: appId) + lookup = try storeSearch.lookup(app: appId).wait() } catch { let maserror = error as! MASError if case .jsonParsing(let nserror) = maserror { diff --git a/Tests/MasKitTests/Controllers/StoreSearchMock.swift b/Tests/MasKitTests/Controllers/StoreSearchMock.swift index 20a479211..2873e60df 100644 --- a/Tests/MasKitTests/Controllers/StoreSearchMock.swift +++ b/Tests/MasKitTests/Controllers/StoreSearchMock.swift @@ -6,31 +6,30 @@ // Copyright © 2019 mas-cli. All rights reserved. // +import PromiseKit @testable import MasKit class StoreSearchMock: StoreSearch { var apps: [Int: SearchResult] = [:] - func search(for appName: String, _ completion: @escaping ([SearchResult]?, Error?) -> Void) { + func search(for appName: String) -> Promise<[SearchResult]> { let filtered = apps.filter { $1.trackName.contains(appName) } let results = filtered.map { $1 } - completion(results, nil) + return .value(results) } - func lookup(app appId: Int, _ completion: @escaping (SearchResult?, Error?) -> Void) { + func lookup(app appId: Int) -> Promise { // Negative numbers are invalid guard appId > 0 else { - completion(nil, MASError.searchFailed) - return + return Promise(error: MASError.searchFailed) } guard let result = apps[appId] else { - completion(nil, MASError.noSearchResultsFound) - return + return Promise(error: MASError.noSearchResultsFound) } - completion(result, nil) + return .value(result) } func reset() { diff --git a/Tests/MasKitTests/Controllers/StoreSearchSpec.swift b/Tests/MasKitTests/Controllers/StoreSearchSpec.swift index d41ce89f8..59c152079 100644 --- a/Tests/MasKitTests/Controllers/StoreSearchSpec.swift +++ b/Tests/MasKitTests/Controllers/StoreSearchSpec.swift @@ -5,19 +5,20 @@ // Created by Ben Chatelain on 1/11/19. // Copyright © 2019 mas-cli. All rights reserved. // + import Nimble +import PromiseKit import Quick - @testable import MasKit /// Protocol minimal implementation struct StoreSearchForTesting: StoreSearch { - func lookup(app _: Int, _ completion: @escaping (SearchResult?, Error?) -> Void) { - completion(nil, nil) + func lookup(app _: Int) -> Promise { + .value(nil) } - func search(for _: String, _ completion: @escaping ([SearchResult]?, Error?) -> Void) { - completion([], nil) + func search(for _: String) -> Promise<[SearchResult]> { + .value([]) } } From 9f340bdce9d89f0d6f92a94831138e69258f9128 Mon Sep 17 00:00:00 2001 From: Chris Araman Date: Thu, 6 May 2021 15:14:56 -0700 Subject: [PATCH 5/8] =?UTF-8?q?=F0=9F=A4=9E=F0=9F=8F=BC=20Rephrase=20Downl?= =?UTF-8?q?oader=20as=20Promises?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/MasKit/AppStore/Downloader.swift | 84 ++++++++++++------------ Sources/MasKit/Commands/Install.swift | 19 +++--- Sources/MasKit/Commands/Lucky.swift | 26 +++----- Sources/MasKit/Commands/Purchase.swift | 19 +++--- Sources/MasKit/Commands/Upgrade.swift | 54 +++++---------- 5 files changed, 89 insertions(+), 113 deletions(-) diff --git a/Sources/MasKit/AppStore/Downloader.swift b/Sources/MasKit/AppStore/Downloader.swift index 75b0ef83d..fde3e4430 100644 --- a/Sources/MasKit/AppStore/Downloader.swift +++ b/Sources/MasKit/AppStore/Downloader.swift @@ -7,61 +7,63 @@ // 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 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: A promise that completes when the downloads are complete. +func downloadAll(_ appIDs: [UInt64], purchase: Bool = false) -> Promise { + appIDs.reduce(Promise.value) { promise, appID in + promise.then { download(appID, purchase: purchase) } + } +} + +/// 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: An error, if one occurred. -func download(_ adamId: UInt64, purchase: Bool = false) -> MASError? { +/// - Returns: A promise the completes when the download is complete. +private func download(_ appID: UInt64, purchase: Bool = false) -> Promise { guard let account = ISStoreAccount.primaryAccount else { - return .notSignedIn + return Promise(error: MASError.notSignedIn) } - 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? - - 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 let downloads = response?.downloads, downloads.count > 0, let purchase = purchase { - let observer = PurchaseDownloadObserver(purchase: purchase) + guard let storeAccount = account as? ISStoreAccount else { + fatalError("Unable to cast StoreAccount to ISStoreAccount") + } - observer.errorHandler = { error in - purchaseError = error - group.leave() + return Promise { 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 in + let observer = PurchaseDownloadObserver(purchase: purchase) + let download = Promise { 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 } diff --git a/Sources/MasKit/Commands/Install.swift b/Sources/MasKit/Commands/Install.swift index cbfcfaa99..f941cbf47 100644 --- a/Sources/MasKit/Commands/Install.swift +++ b/Sources/MasKit/Commands/Install.swift @@ -31,23 +31,22 @@ public struct InstallCommand: CommandProtocol { /// Runs the command. public func run(_ options: Options) -> Result { // 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(()) } } diff --git a/Sources/MasKit/Commands/Lucky.swift b/Sources/MasKit/Commands/Lucky.swift index e3c2bf6ef..8b35a90b1 100644 --- a/Sources/MasKit/Commands/Lucky.swift +++ b/Sources/MasKit/Commands/Lucky.swift @@ -73,24 +73,18 @@ public struct LuckyCommand: CommandProtocol { /// - Returns: Result of the operation. fileprivate func install(_ appId: UInt64, options: Options) -> Result { // 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(()) } } diff --git a/Sources/MasKit/Commands/Purchase.swift b/Sources/MasKit/Commands/Purchase.swift index f9ea5dc03..913802750 100644 --- a/Sources/MasKit/Commands/Purchase.swift +++ b/Sources/MasKit/Commands/Purchase.swift @@ -30,23 +30,22 @@ public struct PurchaseCommand: CommandProtocol { /// Runs the command. public func run(_ options: Options) -> Result { // 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(()) } } diff --git a/Sources/MasKit/Commands/Upgrade.swift b/Sources/MasKit/Commands/Upgrade.swift index d085661a2..a48384a24 100644 --- a/Sources/MasKit/Commands/Upgrade.swift +++ b/Sources/MasKit/Commands/Upgrade.swift @@ -8,6 +8,8 @@ import Commandant import Foundation +import PromiseKit +import enum Swift.Result /// Command which upgrades apps with new versions available in the Mac App Store. public struct UpgradeCommand: CommandProtocol { @@ -49,27 +51,14 @@ public struct UpgradeCommand: CommandProtocol { print("Upgrading \(apps.count) outdated application\(apps.count > 1 ? "s" : ""):") print(apps.map { "\($0.appName) (\($0.bundleVersion))" }.joined(separator: ", ")) - var updatedAppCount = 0 - var failedUpgradeResults = [MASError]() - for app in apps { - if let upgradeResult = download(app.itemIdentifier.uint64Value) { - failedUpgradeResults.append(upgradeResult) - } else { - updatedAppCount += 1 - } + let appIds = apps.map(\.itemIdentifier.uint64Value) + do { + try downloadAll(appIds).wait() + } catch { + return .failure(error as? MASError ?? .downloadFailed(error: error as NSError)) } - switch failedUpgradeResults.count { - case 0: - if updatedAppCount == 0 { - print("Everything is up-to-date") - } - return .success(()) - case 1: - return .failure(failedUpgradeResults[0]) - default: - return .failure(.downloadFailed(error: nil)) - } + return .success(()) } private func findOutdatedApps(_ options: Options) throws -> [SoftwareProduct] { @@ -88,27 +77,20 @@ public struct UpgradeCommand: CommandProtocol { } } - var outdated = [SoftwareProduct]() - let group = DispatchGroup() - let semaphore = DispatchSemaphore(value: 1) - for installedApp in apps { + let promises = apps.map { installedApp in // only upgrade apps whose local version differs from the store version - group.enter() - try storeSearch.lookup(app: installedApp.itemIdentifier.intValue).done { result in - defer { group.leave() } - - if let storeApp = result, installedApp.isOutdatedWhenComparedTo(storeApp) { - semaphore.wait() - defer { semaphore.signal() } - - outdated.append(installedApp) + firstly { + storeSearch.lookup(app: installedApp.itemIdentifier.intValue) + }.map { result -> SoftwareProduct? in + guard let storeApp = result, installedApp.isOutdatedWhenComparedTo(storeApp) else { + return nil } - }.wait() - } - group.wait() + return installedApp + } + } - return outdated + return try when(fulfilled: promises).wait().compactMap { $0 } } } From 25045fa0dde52c2fe484726001b023ecc2b2725c Mon Sep 17 00:00:00 2001 From: Chris Araman Date: Thu, 6 May 2021 18:48:24 -0700 Subject: [PATCH 6/8] =?UTF-8?q?=F0=9F=94=A5=20Attempt=20all=20downloads,?= =?UTF-8?q?=20reject=20with=20first=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/MasKit/AppStore/Downloader.swift | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Sources/MasKit/AppStore/Downloader.swift b/Sources/MasKit/AppStore/Downloader.swift index fde3e4430..28b65328b 100644 --- a/Sources/MasKit/AppStore/Downloader.swift +++ b/Sources/MasKit/AppStore/Downloader.swift @@ -15,10 +15,20 @@ import StoreFoundation /// - 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: A promise that completes when the downloads are complete. +/// - 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 { - appIDs.reduce(Promise.value) { promise, appID in - promise.then { download(appID, purchase: purchase) } + var firstError: Error? + return appIDs.reduce(Guarantee.value(())) { previous, appID in + previous.then { download(appID, purchase: purchase).recover { error in + if firstError == nil { + firstError = error + } + } } + }.done { + if let error = firstError { + throw error + } } } From c3ef7b40b74da13ad8952f5bda73a180462673e8 Mon Sep 17 00:00:00 2001 From: Chris Araman Date: Sat, 8 May 2021 10:17:43 -0700 Subject: [PATCH 7/8] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Remove=20loadDataSync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/MasKit/Network/NetworkManager.swift | 8 -------- Tests/MasKitTests/Network/NetworkManagerTests.swift | 4 ++-- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/Sources/MasKit/Network/NetworkManager.swift b/Sources/MasKit/Network/NetworkManager.swift index 630e49796..73336120a 100644 --- a/Sources/MasKit/Network/NetworkManager.swift +++ b/Sources/MasKit/Network/NetworkManager.swift @@ -34,12 +34,4 @@ class NetworkManager { func loadData(from url: URL) -> Promise { session.loadData(from: url) } - - /// Loads data synchronously. - /// - /// - Parameter url: URL to load data from. - /// - Returns: The Data of the response. - func loadDataSync(from url: URL) throws -> Data { - try session.loadData(from: url).wait() - } } diff --git a/Tests/MasKitTests/Network/NetworkManagerTests.swift b/Tests/MasKitTests/Network/NetworkManagerTests.swift index 382e4f636..71b291da5 100644 --- a/Tests/MasKitTests/Network/NetworkManagerTests.swift +++ b/Tests/MasKitTests/Network/NetworkManagerTests.swift @@ -46,7 +46,7 @@ class NetworkManagerTests: XCTestCase { let url = URL(fileURLWithPath: "url") // Perform the request and verify the result - let result = try manager.loadDataSync(from: url) + let result = try manager.loadData(from: url).wait() XCTAssertEqual(result, data) } @@ -82,7 +82,7 @@ class NetworkManagerTests: XCTestCase { let url = URL(fileURLWithPath: "url") // Perform the request and verify the result - XCTAssertThrowsError(try manager.loadDataSync(from: url)) { error in + XCTAssertThrowsError(try manager.loadData(from: url).wait()) { error in guard let error = error as? MASError else { XCTFail("Error is of unexpected type.") return From 4dee61d46764ebc2ff71b9a6c307f84c75375e03 Mon Sep 17 00:00:00 2001 From: Chris Araman Date: Sat, 8 May 2021 19:49:17 -0700 Subject: [PATCH 8/8] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Retry=20downloads=20up?= =?UTF-8?q?on=20network=20failure=20up=20to=20three=20times?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/MasKit/AppStore/Downloader.swift | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/Sources/MasKit/AppStore/Downloader.swift b/Sources/MasKit/AppStore/Downloader.swift index 28b65328b..831b6d0a3 100644 --- a/Sources/MasKit/AppStore/Downloader.swift +++ b/Sources/MasKit/AppStore/Downloader.swift @@ -20,7 +20,7 @@ import StoreFoundation func downloadAll(_ appIDs: [UInt64], purchase: Bool = false) -> Promise { var firstError: Error? return appIDs.reduce(Guarantee.value(())) { previous, appID in - previous.then { download(appID, purchase: purchase).recover { error in + previous.then { downloadWithRetries(appID, purchase: purchase).recover { error in if firstError == nil { firstError = error } @@ -32,6 +32,27 @@ func downloadAll(_ appIDs: [UInt64], purchase: Bool = false) -> Promise { } } +private func downloadWithRetries( + _ appID: UInt64, purchase: Bool = false, attempts: Int = 3 +) -> Promise { + download(appID, purchase: purchase).recover { error -> Promise in + guard attempts > 1 else { + throw error + } + + // 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 + } + + 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