From af73aed5ee867277606c9da9e84e58751c990483 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Sun, 29 Dec 2024 04:35:40 -0500 Subject: [PATCH 1/2] Misc DocC cleanup. Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/Commands/Search.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Sources/mas/Commands/Search.swift b/Sources/mas/Commands/Search.swift index 9fffa227c..dd511c8ec 100644 --- a/Sources/mas/Commands/Search.swift +++ b/Sources/mas/Commands/Search.swift @@ -9,9 +9,8 @@ import ArgumentParser extension MAS { - /// Search the Mac App Store using the iTunes Search API. - /// - /// See - https://performance-partners.apple.com/search-api + /// Search the Mac App Store. Uses the iTunes Search API: + /// https://performance-partners.apple.com/search-api struct Search: ParsableCommand { static let configuration = CommandConfiguration( abstract: "Search for apps from the Mac App Store" From f1c21b8e590404127b192bc1378485515348d9f4 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Sun, 29 Dec 2024 04:57:13 -0500 Subject: [PATCH 2/2] Add `region` command. Use region from `SKStorefront` obtained from `SKPaymentQueue` for macOS 10.15+. Pass `region` as argument to `lookup` & `search` functions instead of saving as a class member to isolate region to specific calls. Rename `country` as `region` as much as possible. Minor cleanup. Resolve #684 Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Package.resolved | 9 ++++ Package.swift | 2 + Sources/mas/AppStore/Storefront.swift | 25 +++++++++ Sources/mas/Commands/Region.swift | 27 ++++++++++ .../mas/Controllers/AppStoreSearcher.swift | 28 +++++++++- .../ITunesSearchAppStoreSearcher.swift | 53 +++++++++---------- Sources/mas/MAS.swift | 1 + Sources/mas/Utilities/ISORegion.swift | 34 ++++++++++++ .../ITunesSearchAppStoreSearcherSpec.swift | 14 ++++- .../Controllers/MockAppStoreSearcher.swift | 10 ++-- contrib/completion/mas.fish | 3 ++ 11 files changed, 169 insertions(+), 37 deletions(-) create mode 100644 Sources/mas/AppStore/Storefront.swift create mode 100644 Sources/mas/Commands/Region.swift create mode 100644 Sources/mas/Utilities/ISORegion.swift diff --git a/Package.resolved b/Package.resolved index b8cf4ef7e..0fa1be597 100644 --- a/Package.resolved +++ b/Package.resolved @@ -18,6 +18,15 @@ "version" : "2.2.2" } }, + { + "identity" : "isocountrycodes", + "kind" : "remoteSourceControl", + "location" : "https://github.com/funky-monkey/IsoCountryCodes.git", + "state" : { + "revision" : "c571f2133f32d56f6ef4af0d210bd1fc7adf6a02", + "version" : "1.0.2" + } + }, { "identity" : "nimble", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 79429f6d5..5f1bed6b7 100644 --- a/Package.swift +++ b/Package.swift @@ -20,6 +20,7 @@ let package = Package( .package(url: "https://github.com/Quick/Nimble.git", from: "10.0.0"), .package(url: "https://github.com/Quick/Quick.git", from: "5.0.1"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), + .package(url: "https://github.com/funky-monkey/IsoCountryCodes.git", from: "1.0.2"), .package(url: "https://github.com/mxcl/PromiseKit.git", from: "8.1.2"), .package(url: "https://github.com/mxcl/Version.git", from: "2.1.0"), .package(url: "https://github.com/sharplet/Regex.git", from: "2.1.1"), @@ -31,6 +32,7 @@ let package = Package( name: "mas", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), + "IsoCountryCodes", "PromiseKit", "Regex", "Version", diff --git a/Sources/mas/AppStore/Storefront.swift b/Sources/mas/AppStore/Storefront.swift new file mode 100644 index 000000000..949a854f4 --- /dev/null +++ b/Sources/mas/AppStore/Storefront.swift @@ -0,0 +1,25 @@ +// +// Storefront.swift +// mas +// +// Created by Ross Goldberg on 2024-12-29. +// Copyright (c) 2024 mas-cli. All rights reserved. +// + +import StoreKit + +enum Storefront { + static var isoRegion: ISORegion? { + if #available(macOS 10.15, *) { + if let storefront = SKPaymentQueue.default().storefront { + return findISORegion(forAlpha3Code: storefront.countryCode) + } + } + + guard let alpha2 = Locale.autoupdatingCurrent.regionCode else { + return nil + } + + return findISORegion(forAlpha2Code: alpha2) + } +} diff --git a/Sources/mas/Commands/Region.swift b/Sources/mas/Commands/Region.swift new file mode 100644 index 000000000..5fbbee9d6 --- /dev/null +++ b/Sources/mas/Commands/Region.swift @@ -0,0 +1,27 @@ +// +// Region.swift +// mas +// +// Created by Ross Goldberg on 2024-12-29. +// Copyright (c) 2024 mas-cli. All rights reserved. +// + +import ArgumentParser + +extension MAS { + /// Command which interacts with the current region for the Mac App Store. + struct Region: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Display the region of the Mac App Store" + ) + + /// Runs the command. + func run() throws { + guard let region = Storefront.isoRegion else { + throw MASError.runtimeError("Could not obtain Mac App Store region") + } + + print(region.alpha2) + } + } +} diff --git a/Sources/mas/Controllers/AppStoreSearcher.swift b/Sources/mas/Controllers/AppStoreSearcher.swift index e72c3f33d..2ab789e62 100644 --- a/Sources/mas/Controllers/AppStoreSearcher.swift +++ b/Sources/mas/Controllers/AppStoreSearcher.swift @@ -11,17 +11,41 @@ import PromiseKit /// Protocol for searching the MAS catalog. protocol AppStoreSearcher { + /// Looks up app details. + /// + /// - Parameters: + /// - appID: App ID. + /// - region: The `ISORegion` of the storefront in which to lookup apps. + /// - Returns: A `Promise` for the `SearchResult` for the given `appID` if `appID` is valid. + /// A `Promise` for `MASError.unknownAppID(appID)` if `appID` is invalid. + /// A `Promise` for some other `Error` if any problems occur. + func lookup(appID: AppID, inRegion region: ISORegion?) -> Promise + + /// Searches for apps. + /// + /// - Parameters: + /// - searchTerm: Term for which to search. + /// - region: The `ISORegion` of the storefront in which to search for apps. + /// - Returns: A `Promise` for an `Array` of `SearchResult`s matching `searchTerm`. + func search(for searchTerm: String, inRegion region: ISORegion?) -> Promise<[SearchResult]> +} + +extension AppStoreSearcher { /// Looks up app details. /// /// - Parameter appID: App ID. /// - Returns: A `Promise` for the `SearchResult` for the given `appID` if `appID` is valid. /// A `Promise` for `MASError.unknownAppID(appID)` if `appID` is invalid. /// A `Promise` for some other `Error` if any problems occur. - func lookup(appID: AppID) -> Promise + func lookup(appID: AppID) -> Promise { + lookup(appID: appID, inRegion: Storefront.isoRegion) + } /// Searches for apps. /// /// - Parameter searchTerm: Term for which to search. /// - Returns: A `Promise` for an `Array` of `SearchResult`s matching `searchTerm`. - func search(for searchTerm: String) -> Promise<[SearchResult]> + func search(for searchTerm: String) -> Promise<[SearchResult]> { + search(for: searchTerm, inRegion: Storefront.isoRegion) + } } diff --git a/Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift b/Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift index 815fb72bc..f15c1d38a 100644 --- a/Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift +++ b/Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift @@ -11,33 +11,28 @@ import PromiseKit import Regex import Version -/// Manages searching the MAS catalog through the iTunes Search and Lookup APIs. +/// Manages searching the MAS catalog. Uses the iTunes Search and Lookup APIs: +/// https://performance-partners.apple.com/search-api struct ITunesSearchAppStoreSearcher: AppStoreSearcher { private static let appVersionExpression = Regex(#"\"versionDisplay\"\:\"([^\"]+)\""#) - // CommerceKit and StoreFoundation don't seem to expose the region of the Apple ID signed - // into the App Store. Instead, we'll make an educated guess that it matches the currently - // selected locale in macOS. This obviously isn't always going to match, but it's probably - // better than passing no "country" at all to the iTunes Search API. - // https://performance-partners.apple.com/search-api - private let country: String? private let networkManager: NetworkManager /// Designated initializer. - init( - country: String? = Locale.autoupdatingCurrent.regionCode, - networkManager: NetworkManager = NetworkManager() - ) { - self.country = country + init(networkManager: NetworkManager = NetworkManager()) { self.networkManager = networkManager } - /// - Parameter appID: App ID. + /// Looks up app details. + /// + /// - Parameters: + /// - appID: App ID. + /// - region: The `ISORegion` of the storefront in which to lookup apps. /// - Returns: A `Promise` for the `SearchResult` for the given `appID` if `appID` is valid. /// A `Promise` for `MASError.unknownAppID(appID)` if `appID` is invalid. /// A `Promise` for some other `Error` if any problems occur. - func lookup(appID: AppID) -> Promise { - guard let url = lookupURL(forAppID: appID, inCountry: country) else { + func lookup(appID: AppID, inRegion region: ISORegion?) -> Promise { + guard let url = lookupURL(forAppID: appID, inRegion: region) else { fatalError("Failed to build URL for \(appID)") } return @@ -74,11 +69,13 @@ struct ITunesSearchAppStoreSearcher: AppStoreSearcher { } } - /// Searches for apps from the MAS. + /// Searches for apps. /// - /// - Parameter searchTerm: Term for which to search in the MAS. + /// - Parameters: + /// - searchTerm: Term for which to search. + /// - region: The `ISORegion` of the storefront in which to search for apps. /// - Returns: A `Promise` for an `Array` of `SearchResult`s matching `searchTerm`. - func search(for searchTerm: String) -> Promise<[SearchResult]> { + func search(for searchTerm: String, inRegion region: ISORegion?) -> Promise<[SearchResult]> { // Search for apps for compatible platforms, in order of preference. // Macs with Apple Silicon can run iPad and iPhone apps. #if arch(arm64) @@ -88,7 +85,7 @@ struct ITunesSearchAppStoreSearcher: AppStoreSearcher { #endif let results = entities.map { entity in - guard let url = searchURL(for: searchTerm, inCountry: country, ofEntity: entity) else { + guard let url = searchURL(for: searchTerm, inRegion: region, ofEntity: entity) else { fatalError("Failed to build URL for \(searchTerm)") } return loadSearchResults(url) @@ -136,36 +133,36 @@ struct ITunesSearchAppStoreSearcher: AppStoreSearcher { /// /// - Parameters: /// - searchTerm: term for which to search in MAS. - /// - country: 2-letter ISO region code of the MAS in which to search. + /// - region: 2-letter ISO region code of the MAS in which to search. /// - entity: OS platform of apps for which to search. /// - Returns: URL for the search service or nil if searchTerm can't be encoded. func searchURL( for searchTerm: String, - inCountry country: String?, + inRegion region: ISORegion?, ofEntity entity: Entity = .desktopSoftware ) -> URL? { - url(.search, searchTerm, inCountry: country, ofEntity: entity) + url(.search, searchTerm, inRegion: region, ofEntity: entity) } /// Builds the lookup URL for an app. /// /// - Parameters: /// - appID: App ID. - /// - country: 2-letter ISO region code of the MAS in which to search. + /// - region: 2-letter ISO region code of the MAS in which to search. /// - entity: OS platform of apps for which to search. /// - Returns: URL for the lookup service or nil if appID can't be encoded. private func lookupURL( forAppID appID: AppID, - inCountry country: String?, + inRegion region: ISORegion?, ofEntity entity: Entity = .desktopSoftware ) -> URL? { - url(.lookup, String(appID), inCountry: country, ofEntity: entity) + url(.lookup, String(appID), inRegion: region, ofEntity: entity) } private func url( _ action: URLAction, _ queryItemValue: String, - inCountry country: String?, + inRegion region: ISORegion?, ofEntity entity: Entity = .desktopSoftware ) -> URL? { guard var components = URLComponents(string: "https://itunes.apple.com/\(action)") else { @@ -177,8 +174,8 @@ struct ITunesSearchAppStoreSearcher: AppStoreSearcher { URLQueryItem(name: "entity", value: entity.rawValue), ] - if let country { - queryItems.append(URLQueryItem(name: "country", value: country)) + if let region { + queryItems.append(URLQueryItem(name: "country", value: region.alpha2)) } queryItems.append(URLQueryItem(name: action.queryItemName, value: queryItemValue)) diff --git a/Sources/mas/MAS.swift b/Sources/mas/MAS.swift index 56e814fff..9dc9abaac 100644 --- a/Sources/mas/MAS.swift +++ b/Sources/mas/MAS.swift @@ -23,6 +23,7 @@ struct MAS: ParsableCommand { Open.self, Outdated.self, Purchase.self, + Region.self, Reset.self, Search.self, SignIn.self, diff --git a/Sources/mas/Utilities/ISORegion.swift b/Sources/mas/Utilities/ISORegion.swift new file mode 100644 index 000000000..4880d41ef --- /dev/null +++ b/Sources/mas/Utilities/ISORegion.swift @@ -0,0 +1,34 @@ +// +// ISORegion.swift +// mas +// +// Created by Ross Goldberg on 2024-12-29. +// Copyright (c) 2024 mas-cli. All rights reserved. +// + +import IsoCountryCodes + +func findISORegion(forAlpha2Code alpha2Code: String) -> ISORegion? { + let alpha2Code = alpha2Code.uppercased() + return IsoCountries.allCountries.first { $0.alpha2 == alpha2Code } +} + +func findISORegion(forAlpha3Code alpha3Code: String) -> ISORegion? { + let alpha3Code = alpha3Code.uppercased() + return IsoCountries.allCountries.first { $0.alpha3 == alpha3Code } +} + +// periphery:ignore +protocol ISORegion { + var name: String { get } + var numeric: String { get } + var alpha2: String { get } + var alpha3: String { get } + var calling: String { get } + var currency: String { get } + var continent: String { get } + var flag: String? { get } + var fractionDigits: Int { get } +} + +extension IsoCountryInfo: ISORegion {} diff --git a/Tests/masTests/Controllers/ITunesSearchAppStoreSearcherSpec.swift b/Tests/masTests/Controllers/ITunesSearchAppStoreSearcherSpec.swift index 0654badba..4734753c4 100644 --- a/Tests/masTests/Controllers/ITunesSearchAppStoreSearcherSpec.swift +++ b/Tests/masTests/Controllers/ITunesSearchAppStoreSearcherSpec.swift @@ -19,13 +19,23 @@ public class ITunesSearchAppStoreSearcherSpec: QuickSpec { describe("url string") { it("contains the search term") { expect { - ITunesSearchAppStoreSearcher().searchURL(for: "myapp", inCountry: "US")?.absoluteString + ITunesSearchAppStoreSearcher() + .searchURL( + for: "myapp", + inRegion: findISORegion(forAlpha2Code: "US") + )? + .absoluteString } == "https://itunes.apple.com/search?media=software&entity=desktopSoftware&country=US&term=myapp" } it("contains the encoded search term") { expect { - ITunesSearchAppStoreSearcher().searchURL(for: "My App", inCountry: "US")?.absoluteString + ITunesSearchAppStoreSearcher() + .searchURL( + for: "My App", + inRegion: findISORegion(forAlpha2Code: "US") + )? + .absoluteString } == "https://itunes.apple.com/search?media=software&entity=desktopSoftware&country=US&term=My%20App" } diff --git a/Tests/masTests/Controllers/MockAppStoreSearcher.swift b/Tests/masTests/Controllers/MockAppStoreSearcher.swift index a39511f66..535e23dd3 100644 --- a/Tests/masTests/Controllers/MockAppStoreSearcher.swift +++ b/Tests/masTests/Controllers/MockAppStoreSearcher.swift @@ -17,15 +17,15 @@ struct MockAppStoreSearcher: AppStoreSearcher { self.apps = apps } - func search(for searchTerm: String) -> Promise<[SearchResult]> { - .value(apps.filter { $1.trackName.contains(searchTerm) }.map { $1 }) - } - - func lookup(appID: AppID) -> Promise { + func lookup(appID: AppID, inRegion _: ISORegion?) -> Promise { guard let result = apps[appID] else { return Promise(error: MASError.unknownAppID(appID)) } return .value(result) } + + func search(for searchTerm: String, inRegion _: ISORegion?) -> Promise<[SearchResult]> { + .value(apps.filter { $1.trackName.contains(searchTerm) }.map { $1 }) + } } diff --git a/contrib/completion/mas.fish b/contrib/completion/mas.fish index be4278a93..28738a171 100644 --- a/contrib/completion/mas.fish +++ b/contrib/completion/mas.fish @@ -55,6 +55,9 @@ complete -c mas -n "__fish_seen_subcommand_from outdated" -l verbose -d "Display ### purchase complete -c mas -n "__fish_use_subcommand" -f -a purchase -d "\"Purchase\" and install free apps from the Mac App Store" complete -c mas -n "__fish_seen_subcommand_from help" -xa "purchase" +### region +complete -c mas -n "__fish_use_subcommand" -f -a region -d "Display the region of the Mac App Store" +complete -c mas -n "__fish_seen_subcommand_from help" -xa "region" ### reset complete -c mas -n "__fish_use_subcommand" -f -a reset -d "Reset Mac App Store running processes" complete -c mas -n "__fish_seen_subcommand_from help" -xa "reset"