diff --git a/Sources/mas/AppStore/Downloader.swift b/Sources/mas/AppStore/Downloader.swift index e95bb1c..5ec5d61 100644 --- a/Sources/mas/AppStore/Downloader.swift +++ b/Sources/mas/AppStore/Downloader.swift @@ -10,10 +10,43 @@ import CommerceKit import PromiseKit import StoreFoundation -/// Downloads a list of apps, one after the other, printing progress to the console. +/// Sequentially downloads apps, printing progress to the console. +/// +/// Verifies that each supplied app ID is valid before attempting to download. /// /// - Parameters: -/// - appIDs: The IDs of the apps to be downloaded +/// - unverifiedAppIDs: The app IDs of the apps to be verified and downloaded. +/// - searcher: The `AppStoreSearcher` used to verify app IDs. +/// - purchasing: Flag indicating if the apps will be purchased. Only works for free apps. Defaults to false. +/// - 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 downloadApps( + withAppIDs unverifiedAppIDs: [AppID], + verifiedBy searcher: AppStoreSearcher, + purchasing: Bool = false +) -> Promise { + when(resolved: unverifiedAppIDs.map { searcher.lookup(appID: $0) }) + .then { results in + downloadApps( + withAppIDs: + results.compactMap { result in + switch result { + case .fulfilled(let searchResult): + return searchResult.trackId + case .rejected(let error): + printError(String(describing: error)) + return nil + } + }, + purchasing: purchasing + ) + } +} + +/// Sequentially downloads apps, printing progress to the console. +/// +/// - Parameters: +/// - appIDs: The app IDs of the apps to be downloaded. /// - purchasing: Flag indicating if the apps will be purchased. Only works for free apps. Defaults to false. /// - 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. diff --git a/Sources/mas/Commands/Home.swift b/Sources/mas/Commands/Home.swift index f35fa78..45d4f4f 100644 --- a/Sources/mas/Commands/Home.swift +++ b/Sources/mas/Commands/Home.swift @@ -26,9 +26,7 @@ extension MAS { } func run(searcher: AppStoreSearcher) throws { - guard let result = try searcher.lookup(appID: appID).wait() else { - throw MASError.unknownAppID(appID) - } + let result = try searcher.lookup(appID: appID).wait() guard let url = URL(string: result.trackViewUrl) else { throw MASError.runtimeError("Unable to construct URL from: \(result.trackViewUrl)") diff --git a/Sources/mas/Commands/Info.swift b/Sources/mas/Commands/Info.swift index 49e58c1..90f9647 100644 --- a/Sources/mas/Commands/Info.swift +++ b/Sources/mas/Commands/Info.swift @@ -27,11 +27,7 @@ extension MAS { func run(searcher: AppStoreSearcher) throws { do { - guard let result = try searcher.lookup(appID: appID).wait() else { - throw MASError.unknownAppID(appID) - } - - print(AppInfoFormatter.format(app: result)) + print(AppInfoFormatter.format(app: try searcher.lookup(appID: appID).wait())) } catch { throw error as? MASError ?? .searchFailed } diff --git a/Sources/mas/Commands/Install.swift b/Sources/mas/Commands/Install.swift index 32f03fd..df79730 100644 --- a/Sources/mas/Commands/Install.swift +++ b/Sources/mas/Commands/Install.swift @@ -23,10 +23,10 @@ extension MAS { /// Runs the command. func run() throws { - try run(appLibrary: SoftwareMapAppLibrary()) + try run(appLibrary: SoftwareMapAppLibrary(), searcher: ITunesSearchAppStoreSearcher()) } - func run(appLibrary: AppLibrary) throws { + func run(appLibrary: AppLibrary, searcher: AppStoreSearcher) throws { // Try to download applications with given identifiers and collect results let appIDs = appIDs.filter { appID in if let appName = appLibrary.installedApps(withAppID: appID).first?.appName, !force { @@ -38,7 +38,7 @@ extension MAS { } do { - try downloadApps(withAppIDs: appIDs).wait() + try downloadApps(withAppIDs: appIDs, verifiedBy: searcher).wait() } catch { throw error as? MASError ?? .downloadFailed(error: error as NSError) } diff --git a/Sources/mas/Commands/Open.swift b/Sources/mas/Commands/Open.swift index b56f00b..5195f45 100644 --- a/Sources/mas/Commands/Open.swift +++ b/Sources/mas/Commands/Open.swift @@ -63,9 +63,7 @@ private func openMacAppStore() -> Promise { } private func openInMacAppStore(pageForAppID appID: AppID, searcher: AppStoreSearcher) throws { - guard let result = try searcher.lookup(appID: appID).wait() else { - throw MASError.runtimeError("Unknown app ID \(appID)") - } + let result = try searcher.lookup(appID: appID).wait() guard var urlComponents = URLComponents(string: result.trackViewUrl) else { throw MASError.runtimeError("Unable to construct URL from: \(result.trackViewUrl)") diff --git a/Sources/mas/Commands/Outdated.swift b/Sources/mas/Commands/Outdated.swift index ecabb7e..ce0d97b 100644 --- a/Sources/mas/Commands/Outdated.swift +++ b/Sources/mas/Commands/Outdated.swift @@ -30,11 +30,22 @@ extension MAS { _ = try when( fulfilled: appLibrary.installedApps.map { installedApp in - firstly { - searcher.lookup(appID: installedApp.itemIdentifier.appIDValue) - } - .done { storeApp in - guard let storeApp else { + searcher.lookup(appID: installedApp.itemIdentifier.appIDValue) + .done { storeApp in + if installedApp.isOutdatedWhenComparedTo(storeApp) { + print( + """ + \(installedApp.itemIdentifier) \(installedApp.appName) \ + (\(installedApp.bundleVersion) -> \(storeApp.version)) + """ + ) + } + } + .recover { error in + guard case MASError.unknownAppID = error else { + throw error + } + if verbose { printWarning( """ @@ -43,18 +54,7 @@ extension MAS { """ ) } - return } - - if installedApp.isOutdatedWhenComparedTo(storeApp) { - print( - """ - \(installedApp.itemIdentifier) \(installedApp.appName) \ - (\(installedApp.bundleVersion) -> \(storeApp.version)) - """ - ) - } - } } ) .wait() diff --git a/Sources/mas/Commands/Purchase.swift b/Sources/mas/Commands/Purchase.swift index dab6d24..e624527 100644 --- a/Sources/mas/Commands/Purchase.swift +++ b/Sources/mas/Commands/Purchase.swift @@ -20,10 +20,10 @@ extension MAS { /// Runs the command. func run() throws { - try run(appLibrary: SoftwareMapAppLibrary()) + try run(appLibrary: SoftwareMapAppLibrary(), searcher: ITunesSearchAppStoreSearcher()) } - func run(appLibrary: AppLibrary) throws { + func run(appLibrary: AppLibrary, searcher: AppStoreSearcher) throws { // Try to download applications with given identifiers and collect results let appIDs = appIDs.filter { appID in if let appName = appLibrary.installedApps(withAppID: appID).first?.appName { @@ -35,7 +35,7 @@ extension MAS { } do { - try downloadApps(withAppIDs: appIDs, purchasing: true).wait() + try downloadApps(withAppIDs: appIDs, verifiedBy: searcher, purchasing: true).wait() } catch { throw error as? MASError ?? .downloadFailed(error: error as NSError) } diff --git a/Sources/mas/Commands/Upgrade.swift b/Sources/mas/Commands/Upgrade.swift index 94f8b50..4823976 100644 --- a/Sources/mas/Commands/Upgrade.swift +++ b/Sources/mas/Commands/Upgrade.swift @@ -71,16 +71,19 @@ extension MAS { let promises = apps.map { installedApp in // only upgrade apps whose local version differs from the store version - firstly { - searcher.lookup(appID: installedApp.itemIdentifier.appIDValue) - } - .map { result -> (SoftwareProduct, SearchResult)? in - guard let storeApp = result, installedApp.isOutdatedWhenComparedTo(storeApp) else { - return nil + searcher.lookup(appID: installedApp.itemIdentifier.appIDValue) + .map { storeApp -> (SoftwareProduct, SearchResult)? in + guard installedApp.isOutdatedWhenComparedTo(storeApp) else { + return nil + } + return (installedApp, storeApp) + } + .recover { error -> Promise<(SoftwareProduct, SearchResult)?> in + guard case MASError.unknownAppID = error else { + return Promise(error: error) + } + return .value(nil) } - - return (installedApp, storeApp) - } } return try when(fulfilled: promises).wait().compactMap { $0 } diff --git a/Sources/mas/Commands/Vendor.swift b/Sources/mas/Commands/Vendor.swift index 7256d85..387ee19 100644 --- a/Sources/mas/Commands/Vendor.swift +++ b/Sources/mas/Commands/Vendor.swift @@ -26,9 +26,7 @@ extension MAS { } func run(searcher: AppStoreSearcher) throws { - guard let result = try searcher.lookup(appID: appID).wait() else { - throw MASError.noSearchResultsFound - } + let result = try searcher.lookup(appID: appID).wait() guard let urlString = result.sellerUrl else { throw MASError.noVendorWebsite diff --git a/Sources/mas/Controllers/AppStoreSearcher.swift b/Sources/mas/Controllers/AppStoreSearcher.swift index 13e1663..51393c2 100644 --- a/Sources/mas/Controllers/AppStoreSearcher.swift +++ b/Sources/mas/Controllers/AppStoreSearcher.swift @@ -14,9 +14,11 @@ protocol AppStoreSearcher { /// Looks up app details. /// /// - Parameter appID: App ID. - /// - Returns: A `Promise` for the `SearchResult` for the given `appID`, `nil` if no apps match, - /// or an `Error` if any problems occur. - func lookup(appID: AppID) -> Promise + /// - Returns: A `Promise` for the `SearchResult` for the given `appID` if `appID` is valid. + /// A `Promise` for `MASError.unknownAppID(appID)` if `appID` is invalid. + /// An `Promise` for some other `Error` if any problems occur. + func lookup(appID: AppID) -> Promise + /// Searches for apps. /// /// - Parameter searchTerm: Term for which to search. diff --git a/Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift b/Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift index babaab9..72ef777 100644 --- a/Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift +++ b/Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift @@ -32,49 +32,46 @@ class ITunesSearchAppStoreSearcher: AppStoreSearcher { self.networkManager = networkManager } - /// Looks up app details. - /// /// - Parameter appID: App ID. - /// - Returns: A `Promise` for the `SearchResult` for the given `appID`, `nil` if no apps match, - /// or an `Error` if any problems occur. - func lookup(appID: AppID) -> Promise { + /// - Returns: A `Promise` for the `SearchResult` for the given `appID` if `appID` is valid. + /// A `Promise` for `MASError.unknownAppID(appID)` if `appID` is invalid. + /// An `Promise` for some other `Error` if any problems occur. + func lookup(appID: AppID) -> Promise { guard let url = lookupURL(forAppID: appID, inCountry: country) else { fatalError("Failed to build URL for \(appID)") } - return firstly { + return loadSearchResults(url) - } - .then { results -> Guarantee in - guard let result = results.first else { - return .value(nil) - } - - guard let pageURL = URL(string: result.trackViewUrl) else { - return .value(result) - } - - return firstly { - self.scrapeAppStoreVersion(pageURL) - } - .map { pageVersion in - guard - let pageVersion, - let searchVersion = Version(tolerant: result.version), - pageVersion > searchVersion - else { - return result + .then { results -> Guarantee in + guard let result = results.first else { + throw MASError.unknownAppID(appID) } - // Update the search result with the version from the App Store page. - var result = result - result.version = pageVersion.description - return result + guard let pageURL = URL(string: result.trackViewUrl) else { + return .value(result) + } + + return + self.scrapeAppStoreVersion(pageURL) + .map { pageVersion in + guard + let pageVersion, + let searchVersion = Version(tolerant: result.version), + pageVersion > searchVersion + else { + return result + } + + // Update the search result with the version from the App Store page. + var result = result + result.version = pageVersion.description + return result + } + .recover { _ in + // If we were unable to scrape the App Store page, assume compatibility. + .value(result) + } } - .recover { _ in - // If we were unable to scrape the App Store page, assume compatibility. - .value(result) - } - } } /// Searches for apps from the MAS. @@ -106,36 +103,32 @@ class ITunesSearchAppStoreSearcher: AppStoreSearcher { } private func loadSearchResults(_ url: URL) -> Promise<[SearchResult]> { - firstly { - networkManager.loadData(from: url) - } - .map { data in - do { - return try JSONDecoder().decode(SearchResultList.self, from: data).results - } catch { - throw MASError.jsonParsing(data: data) + networkManager.loadData(from: url) + .map { data in + do { + return try JSONDecoder().decode(SearchResultList.self, from: data).results + } catch { + throw MASError.jsonParsing(data: data) + } } - } } /// Scrape the app version from the App Store webpage at the given URL. /// /// App Store webpages frequently report a version that is newer than what is reported by the iTunes Search API. private func scrapeAppStoreVersion(_ pageURL: URL) -> Promise { - firstly { - networkManager.loadData(from: pageURL) - } - .map { data in - guard - let html = String(data: data, encoding: .utf8), - let capture = Self.appVersionExpression.firstMatch(in: html)?.captures[0], - let version = Version(tolerant: capture) - else { - return nil - } + networkManager.loadData(from: pageURL) + .map { data in + guard + let html = String(data: data, encoding: .utf8), + let capture = Self.appVersionExpression.firstMatch(in: html)?.captures[0], + let version = Version(tolerant: capture) + else { + return nil + } - return version - } + return version + } } /// Builds the search URL for an app. diff --git a/Tests/masTests/Commands/HomeSpec.swift b/Tests/masTests/Commands/HomeSpec.swift index 1bfc3f7..c5a0455 100644 --- a/Tests/masTests/Commands/HomeSpec.swift +++ b/Tests/masTests/Commands/HomeSpec.swift @@ -26,7 +26,7 @@ public class HomeSpec: QuickSpec { expect { try MAS.Home.parse(["999"]).run(searcher: searcher) } - .to(throwError(MASError.noSearchResultsFound)) + .to(throwError(MASError.unknownAppID(999))) } } } diff --git a/Tests/masTests/Commands/InfoSpec.swift b/Tests/masTests/Commands/InfoSpec.swift index b46465c..1cad137 100644 --- a/Tests/masTests/Commands/InfoSpec.swift +++ b/Tests/masTests/Commands/InfoSpec.swift @@ -27,7 +27,7 @@ public class InfoSpec: QuickSpec { expect { try MAS.Info.parse(["999"]).run(searcher: searcher) } - .to(throwError(MASError.noSearchResultsFound)) + .to(throwError(MASError.unknownAppID(999))) } it("displays app details") { let mockResult = SearchResult( diff --git a/Tests/masTests/Commands/InstallSpec.swift b/Tests/masTests/Commands/InstallSpec.swift index e2074c6..ea253f7 100644 --- a/Tests/masTests/Commands/InstallSpec.swift +++ b/Tests/masTests/Commands/InstallSpec.swift @@ -19,7 +19,7 @@ public class InstallSpec: QuickSpec { xdescribe("install command") { xit("installs apps") { expect { - try MAS.Install.parse([]).run(appLibrary: MockAppLibrary()) + try MAS.Install.parse([]).run(appLibrary: MockAppLibrary(), searcher: MockAppStoreSearcher()) } .toNot(throwError()) } diff --git a/Tests/masTests/Commands/OpenSpec.swift b/Tests/masTests/Commands/OpenSpec.swift index 6861740..c9cf874 100644 --- a/Tests/masTests/Commands/OpenSpec.swift +++ b/Tests/masTests/Commands/OpenSpec.swift @@ -27,7 +27,7 @@ public class OpenSpec: QuickSpec { expect { try MAS.Open.parse(["999"]).run(searcher: searcher) } - .to(throwError(MASError.noSearchResultsFound)) + .to(throwError(MASError.unknownAppID(999))) } } } diff --git a/Tests/masTests/Commands/PurchaseSpec.swift b/Tests/masTests/Commands/PurchaseSpec.swift index ac9db18..1ab423b 100644 --- a/Tests/masTests/Commands/PurchaseSpec.swift +++ b/Tests/masTests/Commands/PurchaseSpec.swift @@ -19,7 +19,7 @@ public class PurchaseSpec: QuickSpec { xdescribe("purchase command") { xit("purchases apps") { expect { - try MAS.Purchase.parse(["999"]).run(appLibrary: MockAppLibrary()) + try MAS.Purchase.parse(["999"]).run(appLibrary: MockAppLibrary(), searcher: MockAppStoreSearcher()) } .toNot(throwError()) } diff --git a/Tests/masTests/Commands/VendorSpec.swift b/Tests/masTests/Commands/VendorSpec.swift index 6aa8d8f..7eb0f58 100644 --- a/Tests/masTests/Commands/VendorSpec.swift +++ b/Tests/masTests/Commands/VendorSpec.swift @@ -26,7 +26,7 @@ public class VendorSpec: QuickSpec { expect { try MAS.Vendor.parse(["999"]).run(searcher: searcher) } - .to(throwError(MASError.noSearchResultsFound)) + .to(throwError(MASError.unknownAppID(999))) } } } diff --git a/Tests/masTests/Controllers/MockAppStoreSearcher.swift b/Tests/masTests/Controllers/MockAppStoreSearcher.swift index 1df0ef6..c2eb5fe 100644 --- a/Tests/masTests/Controllers/MockAppStoreSearcher.swift +++ b/Tests/masTests/Controllers/MockAppStoreSearcher.swift @@ -17,9 +17,9 @@ class MockAppStoreSearcher: AppStoreSearcher { .value(apps.filter { $1.trackName.contains(searchTerm) }.map { $1 }) } - func lookup(appID: AppID) -> Promise { + func lookup(appID: AppID) -> Promise { guard let result = apps[appID] else { - return Promise(error: MASError.noSearchResultsFound) + return Promise(error: MASError.unknownAppID(appID)) } return .value(result)