Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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
Loading