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 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<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.
/// - 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.

View file

@ -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)")

View file

@ -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
}

View file

@ -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)
}

View file

@ -63,9 +63,7 @@ private func openMacAppStore() -> Promise<Void> {
}
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)")

View file

@ -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()

View file

@ -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)
}

View file

@ -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 }

View file

@ -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

View file

@ -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<SearchResult?>
/// - 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<SearchResult>
/// Searches for apps.
///
/// - Parameter searchTerm: Term for which to search.

View file

@ -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<SearchResult?> {
/// - 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<SearchResult> {
guard let url = lookupURL(forAppID: appID, inCountry: country) else {
fatalError("Failed to build URL for \(appID)")
}
return firstly {
return
loadSearchResults(url)
}
.then { results -> Guarantee<SearchResult?> 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<SearchResult> 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<Version?> {
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.

View file

@ -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)))
}
}
}

View file

@ -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(

View file

@ -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())
}

View file

@ -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)))
}
}
}

View file

@ -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())
}

View file

@ -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)))
}
}
}

View file

@ -17,9 +17,9 @@ class MockAppStoreSearcher: AppStoreSearcher {
.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 {
return Promise(error: MASError.noSearchResultsFound)
return Promise(error: MASError.unknownAppID(appID))
}
return .value(result)