Refactor to allow install & purchase to report unknown app IDs via console instead of cryptically via a dialog.

Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com>
This commit is contained in:
Ross Goldberg 2024-10-28 20:18:49 -04:00
parent e4bc69cf5d
commit e639341d11
No known key found for this signature in database
18 changed files with 136 additions and 115 deletions

View file

@ -10,10 +10,43 @@ import CommerceKit
import PromiseKit import PromiseKit
import StoreFoundation 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: /// - 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<Void> {
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. /// - 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, /// - 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. /// the promise is rejected with the first error, after all remaining downloads are attempted.

View file

@ -26,9 +26,7 @@ extension MAS {
} }
func run(searcher: AppStoreSearcher) throws { func run(searcher: AppStoreSearcher) throws {
guard let result = try searcher.lookup(appID: appID).wait() else { let result = try searcher.lookup(appID: appID).wait()
throw MASError.unknownAppID(appID)
}
guard let url = URL(string: result.trackViewUrl) else { guard let url = URL(string: result.trackViewUrl) else {
throw MASError.runtimeError("Unable to construct URL from: \(result.trackViewUrl)") throw MASError.runtimeError("Unable to construct URL from: \(result.trackViewUrl)")

View file

@ -27,11 +27,7 @@ extension MAS {
func run(searcher: AppStoreSearcher) throws { func run(searcher: AppStoreSearcher) throws {
do { do {
guard let result = try searcher.lookup(appID: appID).wait() else { print(AppInfoFormatter.format(app: try searcher.lookup(appID: appID).wait()))
throw MASError.unknownAppID(appID)
}
print(AppInfoFormatter.format(app: result))
} catch { } catch {
throw error as? MASError ?? .searchFailed throw error as? MASError ?? .searchFailed
} }

View file

@ -23,10 +23,10 @@ extension MAS {
/// Runs the command. /// Runs the command.
func run() throws { 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 // Try to download applications with given identifiers and collect results
let appIDs = appIDs.filter { appID in let appIDs = appIDs.filter { appID in
if let appName = appLibrary.installedApps(withAppID: appID).first?.appName, !force { if let appName = appLibrary.installedApps(withAppID: appID).first?.appName, !force {
@ -38,7 +38,7 @@ extension MAS {
} }
do { do {
try downloadApps(withAppIDs: appIDs).wait() try downloadApps(withAppIDs: appIDs, verifiedBy: searcher).wait()
} catch { } catch {
throw error as? MASError ?? .downloadFailed(error: error as NSError) throw error as? MASError ?? .downloadFailed(error: error as NSError)
} }

View file

@ -63,9 +63,7 @@ private func openMacAppStore() -> Promise<Void> {
} }
private func openInMacAppStore(pageForAppID appID: AppID, searcher: AppStoreSearcher) throws { private func openInMacAppStore(pageForAppID appID: AppID, searcher: AppStoreSearcher) throws {
guard let result = try searcher.lookup(appID: appID).wait() else { let result = try searcher.lookup(appID: appID).wait()
throw MASError.runtimeError("Unknown app ID \(appID)")
}
guard var urlComponents = URLComponents(string: result.trackViewUrl) else { guard var urlComponents = URLComponents(string: result.trackViewUrl) else {
throw MASError.runtimeError("Unable to construct URL from: \(result.trackViewUrl)") throw MASError.runtimeError("Unable to construct URL from: \(result.trackViewUrl)")

View file

@ -30,11 +30,22 @@ extension MAS {
_ = try when( _ = try when(
fulfilled: fulfilled:
appLibrary.installedApps.map { installedApp in appLibrary.installedApps.map { installedApp in
firstly { searcher.lookup(appID: installedApp.itemIdentifier.appIDValue)
searcher.lookup(appID: installedApp.itemIdentifier.appIDValue) .done { storeApp in
} if installedApp.isOutdatedWhenComparedTo(storeApp) {
.done { storeApp in print(
guard let storeApp else { """
\(installedApp.itemIdentifier) \(installedApp.appName) \
(\(installedApp.bundleVersion) -> \(storeApp.version))
"""
)
}
}
.recover { error in
guard case MASError.unknownAppID = error else {
throw error
}
if verbose { if verbose {
printWarning( printWarning(
""" """
@ -43,18 +54,7 @@ extension MAS {
""" """
) )
} }
return
} }
if installedApp.isOutdatedWhenComparedTo(storeApp) {
print(
"""
\(installedApp.itemIdentifier) \(installedApp.appName) \
(\(installedApp.bundleVersion) -> \(storeApp.version))
"""
)
}
}
} }
) )
.wait() .wait()

View file

@ -20,10 +20,10 @@ extension MAS {
/// Runs the command. /// Runs the command.
func run() throws { 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 // Try to download applications with given identifiers and collect results
let appIDs = appIDs.filter { appID in let appIDs = appIDs.filter { appID in
if let appName = appLibrary.installedApps(withAppID: appID).first?.appName { if let appName = appLibrary.installedApps(withAppID: appID).first?.appName {
@ -35,7 +35,7 @@ extension MAS {
} }
do { do {
try downloadApps(withAppIDs: appIDs, purchasing: true).wait() try downloadApps(withAppIDs: appIDs, verifiedBy: searcher, purchasing: true).wait()
} catch { } catch {
throw error as? MASError ?? .downloadFailed(error: error as NSError) throw error as? MASError ?? .downloadFailed(error: error as NSError)
} }

View file

@ -71,16 +71,19 @@ extension MAS {
let promises = apps.map { installedApp in let promises = apps.map { installedApp in
// only upgrade apps whose local version differs from the store version // only upgrade apps whose local version differs from the store version
firstly { searcher.lookup(appID: installedApp.itemIdentifier.appIDValue)
searcher.lookup(appID: installedApp.itemIdentifier.appIDValue) .map { storeApp -> (SoftwareProduct, SearchResult)? in
} guard installedApp.isOutdatedWhenComparedTo(storeApp) else {
.map { result -> (SoftwareProduct, SearchResult)? in return nil
guard let storeApp = result, 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 } return try when(fulfilled: promises).wait().compactMap { $0 }

View file

@ -26,9 +26,7 @@ extension MAS {
} }
func run(searcher: AppStoreSearcher) throws { func run(searcher: AppStoreSearcher) throws {
guard let result = try searcher.lookup(appID: appID).wait() else { let result = try searcher.lookup(appID: appID).wait()
throw MASError.noSearchResultsFound
}
guard let urlString = result.sellerUrl else { guard let urlString = result.sellerUrl else {
throw MASError.noVendorWebsite throw MASError.noVendorWebsite

View file

@ -14,9 +14,11 @@ protocol AppStoreSearcher {
/// Looks up app details. /// Looks up app details.
/// ///
/// - Parameter appID: App ID. /// - Parameter appID: App ID.
/// - Returns: A `Promise` for the `SearchResult` for the given `appID`, `nil` if no apps match, /// - Returns: A `Promise` for the `SearchResult` for the given `appID` if `appID` is valid.
/// or an `Error` if any problems occur. /// A `Promise` for `MASError.unknownAppID(appID)` if `appID` is invalid.
func lookup(appID: AppID) -> Promise<SearchResult?> /// An `Promise` for some other `Error` if any problems occur.
func lookup(appID: AppID) -> Promise<SearchResult>
/// Searches for apps. /// Searches for apps.
/// ///
/// - Parameter searchTerm: Term for which to search. /// - Parameter searchTerm: Term for which to search.

View file

@ -32,49 +32,46 @@ class ITunesSearchAppStoreSearcher: AppStoreSearcher {
self.networkManager = networkManager self.networkManager = networkManager
} }
/// Looks up app details.
///
/// - Parameter appID: App ID. /// - Parameter appID: App ID.
/// - Returns: A `Promise` for the `SearchResult` for the given `appID`, `nil` if no apps match, /// - Returns: A `Promise` for the `SearchResult` for the given `appID` if `appID` is valid.
/// or an `Error` if any problems occur. /// A `Promise` for `MASError.unknownAppID(appID)` if `appID` is invalid.
func lookup(appID: AppID) -> Promise<SearchResult?> { /// An `Promise` for some other `Error` if any problems occur.
func lookup(appID: AppID) -> Promise<SearchResult> {
guard let url = lookupURL(forAppID: appID, inCountry: country) else { guard let url = lookupURL(forAppID: appID, inCountry: country) else {
fatalError("Failed to build URL for \(appID)") fatalError("Failed to build URL for \(appID)")
} }
return firstly { return
loadSearchResults(url) loadSearchResults(url)
} .then { results -> Guarantee<SearchResult> in
.then { results -> Guarantee<SearchResult?> in guard let result = results.first else {
guard let result = results.first else { throw MASError.unknownAppID(appID)
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
} }
// Update the search result with the version from the App Store page. guard let pageURL = URL(string: result.trackViewUrl) else {
var result = result return .value(result)
result.version = pageVersion.description }
return 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. /// Searches for apps from the MAS.
@ -106,36 +103,32 @@ class ITunesSearchAppStoreSearcher: AppStoreSearcher {
} }
private func loadSearchResults(_ url: URL) -> Promise<[SearchResult]> { private func loadSearchResults(_ url: URL) -> Promise<[SearchResult]> {
firstly { networkManager.loadData(from: url)
networkManager.loadData(from: url) .map { data in
} do {
.map { data in return try JSONDecoder().decode(SearchResultList.self, from: data).results
do { } catch {
return try JSONDecoder().decode(SearchResultList.self, from: data).results throw MASError.jsonParsing(data: data)
} catch { }
throw MASError.jsonParsing(data: data)
} }
}
} }
/// Scrape the app version from the App Store webpage at the given URL. /// 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. /// 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<Version?> { private func scrapeAppStoreVersion(_ pageURL: URL) -> Promise<Version?> {
firstly { networkManager.loadData(from: pageURL)
networkManager.loadData(from: pageURL) .map { data in
} guard
.map { data in let html = String(data: data, encoding: .utf8),
guard let capture = Self.appVersionExpression.firstMatch(in: html)?.captures[0],
let html = String(data: data, encoding: .utf8), let version = Version(tolerant: capture)
let capture = Self.appVersionExpression.firstMatch(in: html)?.captures[0], else {
let version = Version(tolerant: capture) return nil
else { }
return nil
}
return version return version
} }
} }
/// Builds the search URL for an app. /// Builds the search URL for an app.

View file

@ -26,7 +26,7 @@ public class HomeSpec: QuickSpec {
expect { expect {
try MAS.Home.parse(["999"]).run(searcher: searcher) try MAS.Home.parse(["999"]).run(searcher: searcher)
} }
.to(throwError(MASError.noSearchResultsFound)) .to(throwError(MASError.unknownAppID(999)))
} }
} }
} }

View file

@ -27,7 +27,7 @@ public class InfoSpec: QuickSpec {
expect { expect {
try MAS.Info.parse(["999"]).run(searcher: searcher) try MAS.Info.parse(["999"]).run(searcher: searcher)
} }
.to(throwError(MASError.noSearchResultsFound)) .to(throwError(MASError.unknownAppID(999)))
} }
it("displays app details") { it("displays app details") {
let mockResult = SearchResult( let mockResult = SearchResult(

View file

@ -19,7 +19,7 @@ public class InstallSpec: QuickSpec {
xdescribe("install command") { xdescribe("install command") {
xit("installs apps") { xit("installs apps") {
expect { expect {
try MAS.Install.parse([]).run(appLibrary: MockAppLibrary()) try MAS.Install.parse([]).run(appLibrary: MockAppLibrary(), searcher: MockAppStoreSearcher())
} }
.toNot(throwError()) .toNot(throwError())
} }

View file

@ -27,7 +27,7 @@ public class OpenSpec: QuickSpec {
expect { expect {
try MAS.Open.parse(["999"]).run(searcher: searcher) try MAS.Open.parse(["999"]).run(searcher: searcher)
} }
.to(throwError(MASError.noSearchResultsFound)) .to(throwError(MASError.unknownAppID(999)))
} }
} }
} }

View file

@ -19,7 +19,7 @@ public class PurchaseSpec: QuickSpec {
xdescribe("purchase command") { xdescribe("purchase command") {
xit("purchases apps") { xit("purchases apps") {
expect { expect {
try MAS.Purchase.parse(["999"]).run(appLibrary: MockAppLibrary()) try MAS.Purchase.parse(["999"]).run(appLibrary: MockAppLibrary(), searcher: MockAppStoreSearcher())
} }
.toNot(throwError()) .toNot(throwError())
} }

View file

@ -26,7 +26,7 @@ public class VendorSpec: QuickSpec {
expect { expect {
try MAS.Vendor.parse(["999"]).run(searcher: searcher) try MAS.Vendor.parse(["999"]).run(searcher: searcher)
} }
.to(throwError(MASError.noSearchResultsFound)) .to(throwError(MASError.unknownAppID(999)))
} }
} }
} }

View file

@ -17,9 +17,9 @@ class MockAppStoreSearcher: AppStoreSearcher {
.value(apps.filter { $1.trackName.contains(searchTerm) }.map { $1 }) .value(apps.filter { $1.trackName.contains(searchTerm) }.map { $1 })
} }
func lookup(appID: AppID) -> Promise<SearchResult?> { func lookup(appID: AppID) -> Promise<SearchResult> {
guard let result = apps[appID] else { guard let result = apps[appID] else {
return Promise(error: MASError.noSearchResultsFound) return Promise(error: MASError.unknownAppID(appID))
} }
return .value(result) return .value(result)