Skip to content

Commit

Permalink
Merge pull request mas-cli#689 from rgoldberg/684-region
Browse files Browse the repository at this point in the history
Use MAS region instead of macOS region & add `mas region`
  • Loading branch information
rgoldberg authored Dec 30, 2024
2 parents 11a3555 + f1c21b8 commit f34efa9
Show file tree
Hide file tree
Showing 12 changed files with 171 additions and 40 deletions.
9 changes: 9 additions & 0 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -31,6 +32,7 @@ let package = Package(
name: "mas",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
"IsoCountryCodes",
"PromiseKit",
"Regex",
"Version",
Expand Down
25 changes: 25 additions & 0 deletions Sources/mas/AppStore/Storefront.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
27 changes: 27 additions & 0 deletions Sources/mas/Commands/Region.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
5 changes: 2 additions & 3 deletions Sources/mas/Commands/Search.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
28 changes: 26 additions & 2 deletions Sources/mas/Controllers/AppStoreSearcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<SearchResult>

/// 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<SearchResult>
func lookup(appID: AppID) -> Promise<SearchResult> {
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)
}
}
53 changes: 25 additions & 28 deletions Sources/mas/Controllers/ITunesSearchAppStoreSearcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<SearchResult> {
guard let url = lookupURL(forAppID: appID, inCountry: country) else {
func lookup(appID: AppID, inRegion region: ISORegion?) -> Promise<SearchResult> {
guard let url = lookupURL(forAppID: appID, inRegion: region) else {
fatalError("Failed to build URL for \(appID)")
}
return
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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))
Expand Down
1 change: 1 addition & 0 deletions Sources/mas/MAS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ struct MAS: ParsableCommand {
Open.self,
Outdated.self,
Purchase.self,
Region.self,
Reset.self,
Search.self,
SignIn.self,
Expand Down
34 changes: 34 additions & 0 deletions Sources/mas/Utilities/ISORegion.swift
Original file line number Diff line number Diff line change
@@ -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 {}
14 changes: 12 additions & 2 deletions Tests/masTests/Controllers/ITunesSearchAppStoreSearcherSpec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
10 changes: 5 additions & 5 deletions Tests/masTests/Controllers/MockAppStoreSearcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<SearchResult> {
func lookup(appID: AppID, inRegion _: ISORegion?) -> Promise<SearchResult> {
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 })
}
}
3 changes: 3 additions & 0 deletions contrib/completion/mas.fish
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down

0 comments on commit f34efa9

Please sign in to comment.