Skip to content

Commit cd54b3b

Browse files
authored
Implement purchase command (#56)
1 parent 82f70f2 commit cd54b3b

14 files changed

+339
-137
lines changed

.github/workflows/integration-tests.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ jobs:
3030
fail-fast: false
3131
matrix:
3232
macos: [macos-10.15, macos-11, macos-12]
33-
command: [auth, download, search]
33+
command: [auth, download, purchase, search]
3434
steps:
3535
- name: Download binary
3636
uses: actions/download-artifact@v2

README.md

+20
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,26 @@ OPTIONS:
8282
-h, --help Show help information.
8383
```
8484

85+
To obtain a license for an app, use the `purchase` command.
86+
87+
```
88+
OVERVIEW: Obtain a license for the app from the App Store.
89+
90+
USAGE: ipatool purchase --bundle-identifier <bundle-identifier> [--country <country>] [--device-family <device-family>] [--log-level <log-level>]
91+
92+
OPTIONS:
93+
-b, --bundle-identifier <bundle-identifier>
94+
The bundle identifier of the target iOS app.
95+
-c, --country <country> The two-letter (ISO 3166-1 alpha-2) country code for
96+
the iTunes Store. (default: US)
97+
-d, --device-family <device-family>
98+
The device family to limit the search query to.
99+
(default: iPhone)
100+
--log-level <log-level> The log level. (default: info)
101+
--version Show the version.
102+
-h, --help Show help information.
103+
```
104+
85105
To download a copy of the ipa file, use the `download` command.
86106

87107
```

Sources/CLI/Commands/Download.swift

+15-14
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@ struct Download: AsyncParsableCommand {
1919
@Option(name: [.short, .long], help: "The bundle identifier of the target iOS app.")
2020
private var bundleIdentifier: String
2121

22-
@Option(name: [.short, .long], help: "The two-letter (ISO 3166-1 alpha-2) country code for the iTunes Store.")
23-
private var country: String = "US"
22+
@Option(
23+
name: [.customShort("c"), .customLong("country")],
24+
help: "The two-letter (ISO 3166-1 alpha-2) country code for the iTunes Store."
25+
)
26+
private var countryCode: String = "US"
2427

2528
@Option(name: [.short, .long], help: "The device family to limit the search query to.")
2629
private var deviceFamily: DeviceFamily = .phone
@@ -35,18 +38,18 @@ struct Download: AsyncParsableCommand {
3538
}
3639

3740
extension Download {
38-
private mutating func app(with bundleIdentifier: String, country: String) async -> iTunesResponse.Result {
41+
private mutating func app(with bundleIdentifier: String, countryCode: String) async -> iTunesResponse.Result {
3942
logger.log("Creating HTTP client...", level: .debug)
4043
let httpClient = HTTPClient(session: URLSession.shared)
4144

4245
logger.log("Creating iTunes client...", level: .debug)
4346
let itunesClient = iTunesClient(httpClient: httpClient)
4447

4548
do {
46-
logger.log("Querying the iTunes Store for '\(bundleIdentifier)' in country '\(country)'...", level: .info)
49+
logger.log("Querying the iTunes Store for '\(bundleIdentifier)' in country '\(countryCode)'...", level: .info)
4750
return try await itunesClient.lookup(
4851
bundleIdentifier: bundleIdentifier,
49-
country: country,
52+
countryCode: countryCode,
5053
deviceFamily: deviceFamily
5154
)
5255
} catch {
@@ -74,24 +77,22 @@ extension Download {
7477
logger.log("Requesting a signed copy of '\(app.identifier)' from the App Store...", level: .info)
7578
return try await storeClient.item(
7679
identifier: "\(app.identifier)",
77-
directoryServicesIdentifier: account.directoryServicesIdentifier,
78-
passwordToken: account.passwordToken,
79-
country: country
80+
directoryServicesIdentifier: account.directoryServicesIdentifier
8081
)
8182
} catch {
8283
logger.log("\(error)", level: .debug)
8384

8485
switch error {
8586
case StoreClient.Error.invalidResponse:
8687
logger.log("Received invalid response.", level: .error)
87-
case StoreClient.Error.purchaseFailed:
88-
logger.log("Buying the app failed.", level: .error)
8988
case StoreResponse.Error.invalidItem:
9089
logger.log("Received invalid store item.", level: .error)
9190
case StoreResponse.Error.invalidLicense:
92-
logger.log("Your Apple ID does not have a license for this app. Download the app on an iOS device to obtain a license.", level: .error)
93-
case StoreResponse.Error.wrongCountry:
94-
logger.log("Your Apple ID is not valid for the country you specified.", level: .error)
91+
logger.log("Your Apple ID does not have a license for this app. Use the \"purchase\" command to obtain a license.", level: .error)
92+
case StoreResponse.Error.invalidCountry:
93+
logger.log("The country provided does not match with the account you are using. Supply a valid country using the \"--country\" flag.", level: .error)
94+
case StoreResponse.Error.passwordTokenExpired:
95+
logger.log("Token expired. Login again using the \"auth\" command.", level: .error)
9596
default:
9697
logger.log("An unknown error has occurred.", level: .error)
9798
}
@@ -167,7 +168,7 @@ extension Download {
167168
logger.log("Authenticated as '\(account.name)'.", level: .info)
168169

169170
// Query for app
170-
let app: iTunesResponse.Result = await app(with: bundleIdentifier, country: country)
171+
let app: iTunesResponse.Result = await app(with: bundleIdentifier, countryCode: countryCode)
171172
logger.log("Found app: \(app.name) (\(app.version)).", level: .debug)
172173

173174
// Query for store item

Sources/CLI/Commands/IPATool.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ struct IPATool: ParsableCommand {
1313
commandName: "ipatool",
1414
abstract: "A cli tool for interacting with Apple's ipa files.",
1515
version: Package.version,
16-
subcommands: [Auth.self, Download.self, Search.self]
16+
subcommands: [Auth.self, Download.self, Purchase.self, Search.self]
1717
)
1818
}
1919
}

Sources/CLI/Commands/Purchase.swift

+121
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
//
2+
// Purchase.swift
3+
// IPATool
4+
//
5+
// Created by Majd Alfhaily on 22.03.22.
6+
//
7+
8+
import ArgumentParser
9+
import Foundation
10+
import Networking
11+
import StoreAPI
12+
import Persistence
13+
14+
struct Purchase: AsyncParsableCommand {
15+
static var configuration: CommandConfiguration {
16+
return .init(abstract: "Obtain a license for the app from the App Store.")
17+
}
18+
19+
@Option(name: [.short, .long], help: "The bundle identifier of the target iOS app.")
20+
private var bundleIdentifier: String
21+
22+
@Option(
23+
name: [.customShort("c"), .customLong("country")],
24+
help: "The two-letter (ISO 3166-1 alpha-2) country code for the iTunes Store."
25+
)
26+
private var countryCode: String = "US"
27+
28+
@Option(name: [.short, .long], help: "The device family to limit the search query to.")
29+
private var deviceFamily: DeviceFamily = .phone
30+
31+
@Option(name: [.long], help: "The log level.")
32+
private var logLevel: LogLevel = .info
33+
34+
lazy var logger = ConsoleLogger(level: logLevel)
35+
}
36+
37+
extension Purchase {
38+
private mutating func app(with bundleIdentifier: String) async -> iTunesResponse.Result {
39+
logger.log("Creating HTTP client...", level: .debug)
40+
let httpClient = HTTPClient(session: URLSession.shared)
41+
42+
logger.log("Creating iTunes client...", level: .debug)
43+
let itunesClient = iTunesClient(httpClient: httpClient)
44+
45+
do {
46+
logger.log("Querying the iTunes Store for '\(bundleIdentifier)' in country '\(countryCode)'...", level: .info)
47+
return try await itunesClient.lookup(
48+
bundleIdentifier: bundleIdentifier,
49+
countryCode: countryCode,
50+
deviceFamily: deviceFamily
51+
)
52+
} catch {
53+
logger.log("\(error)", level: .debug)
54+
55+
switch error {
56+
case iTunesClient.Error.appNotFound:
57+
logger.log("Could not find app.", level: .error)
58+
default:
59+
logger.log("An unknown error has occurred.", level: .error)
60+
}
61+
62+
_exit(1)
63+
}
64+
}
65+
66+
private mutating func purchase(app: iTunesResponse.Result, account: Account) async {
67+
logger.log("Creating HTTP client...", level: .debug)
68+
let httpClient = HTTPClient(session: URLSession.shared)
69+
70+
logger.log("Creating App Store client...", level: .debug)
71+
let storeClient = StoreClient(httpClient: httpClient)
72+
73+
do {
74+
logger.log("Obtaining a license for '\(app.identifier)' from the App Store...", level: .info)
75+
try await storeClient.purchase(
76+
identifier: "\(app.identifier)",
77+
directoryServicesIdentifier: account.directoryServicesIdentifier,
78+
passwordToken: account.passwordToken,
79+
countryCode: countryCode
80+
)
81+
} catch {
82+
logger.log("\(error)", level: .debug)
83+
84+
switch error {
85+
case StoreClient.Error.purchaseFailed:
86+
logger.log("Purchase failed.", level: .error)
87+
case StoreClient.Error.duplicateLicense:
88+
logger.log("A license already exists for this item.", level: .error)
89+
case StoreResponse.Error.invalidCountry:
90+
logger.log("The country provided does not match with the account you are using. Supply a valid country using the \"--country\" flag.", level: .error)
91+
case StoreResponse.Error.passwordTokenExpired:
92+
logger.log("Token expired. Login again using the \"auth\" command.", level: .error)
93+
default:
94+
logger.log("An unknown error has occurred.", level: .error)
95+
}
96+
97+
_exit(1)
98+
}
99+
100+
}
101+
102+
mutating func run() async throws {
103+
// Authenticate with the App Store
104+
let keychainStore = KeychainStore(service: "ipatool.service")
105+
106+
guard let account: Account = try keychainStore.value(forKey: "account") else {
107+
logger.log("Authentication required. Run \"ipatool auth --help\" for help.", level: .error)
108+
_exit(1)
109+
}
110+
logger.log("Authenticated as '\(account.name)'.", level: .info)
111+
112+
// Query for app
113+
let app: iTunesResponse.Result = await app(with: bundleIdentifier)
114+
logger.log("Found app: \(app.name) (\(app.version)).", level: .debug)
115+
116+
// Obtain a license
117+
await purchase(app: app, account: account)
118+
logger.log("Obtained a license for '\(app.identifier)'.", level: .debug)
119+
logger.log("Done.", level: .info)
120+
}
121+
}

Sources/CLI/Commands/Search.swift

+9-6
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,11 @@ struct Search: AsyncParsableCommand {
2121
@Option(name: [.short, .long], help: "The maximum amount of search results to retrieve.")
2222
private var limit: Int = 5
2323

24-
@Option(name: [.short, .long], help: "The two-letter (ISO 3166-1 alpha-2) country code for the iTunes Store.")
25-
private var country: String = "US"
24+
@Option(
25+
name: [.customShort("c"), .customLong("country")],
26+
help: "The two-letter (ISO 3166-1 alpha-2) country code for the iTunes Store."
27+
)
28+
private var countryCode: String = "US"
2629

2730
@Option(name: [.short, .long], help: "The device family to limit the search query to.")
2831
private var deviceFamily: DeviceFamily = .phone
@@ -34,20 +37,20 @@ struct Search: AsyncParsableCommand {
3437
}
3538

3639
extension Search {
37-
mutating func results(with term: String, country: String) async -> [iTunesResponse.Result] {
40+
mutating func results(with term: String) async -> [iTunesResponse.Result] {
3841
logger.log("Creating HTTP client...", level: .debug)
3942
let httpClient = HTTPClient(session: URLSession.shared)
4043

4144
logger.log("Creating iTunes client...", level: .debug)
4245
let itunesClient = iTunesClient(httpClient: httpClient)
4346

44-
logger.log("Searching for '\(term)' using the '\(country)' store front...", level: .info)
47+
logger.log("Searching for '\(term)' using the '\(countryCode)' store front...", level: .info)
4548

4649
do {
4750
let results = try await itunesClient.search(
4851
term: term,
4952
limit: limit,
50-
country: country,
53+
countryCode: countryCode,
5154
deviceFamily: deviceFamily
5255
)
5356

@@ -66,7 +69,7 @@ extension Search {
6669

6770
mutating func run() async throws {
6871
// Search the iTunes store
69-
let results = await results(with: term, country: country)
72+
let results = await results(with: term)
7073

7174
// Compile output
7275
let output = results

Sources/Networking/HTTPResponse.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import Foundation
99

1010
public struct HTTPResponse {
1111
public let statusCode: Int
12-
let data: Data?
12+
public let data: Data?
1313
}
1414

1515
extension HTTPResponse {

Sources/StoreAPI/Common/Storefront.swift

+9-6
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
//
22
// Storefront.swift
3-
//
3+
// StoreAPI
44
//
55
// Created by Benjamin Altpeter on 20.03.22.
66
//
77

88
import Foundation
99

10-
// List from: https://web.archive.org/web/20191206001952/https://affiliate.itunes.apple.com/resources/documentation/linking-to-the-itunes-music-store/#appendix
10+
// https://web.archive.org/web/20191206001952/https://affiliate.itunes.apple.com/resources/documentation/linking-to-the-itunes-music-store
1111
public enum Storefront: String, CaseIterable {
1212
case AE = "143481"
1313
case AG = "143540"
@@ -139,9 +139,12 @@ public enum Storefront: String, CaseIterable {
139139
case VN = "143471"
140140
case YE = "143571"
141141
case ZA = "143472"
142-
143-
// Adapted after: https://stackoverflow.com/a/52148845
144-
static func forCountry(_ country: String) -> Self? {
145-
return self.allCases.first{ "\($0)" == country.uppercased() }
142+
143+
init?(countryCode: String) {
144+
guard let value = Storefront.allCases.first(where: { "\($0)" == countryCode }) else {
145+
return nil
146+
}
147+
148+
self = value
146149
}
147150
}

0 commit comments

Comments
 (0)