Implement store client

This commit is contained in:
Majd Alfhaily 2021-05-22 15:31:25 +02:00
parent 02369ffa4c
commit b903f9c2b2
9 changed files with 471 additions and 10 deletions

View File

@ -8,8 +8,8 @@
import Foundation
protocol HTTPRequest {
var endpoint: HTTPEndpoint { get }
var method: HTTPMethod { get }
var endpoint: HTTPEndpoint { get }
var headers: [String: String] { get }
var payload: HTTPPayload? { get }
}

View File

@ -20,9 +20,13 @@ extension HTTPResponse {
switch decoder {
case .json:
return try JSONDecoder().decode(type, from: data)
case .propertyList:
return try PropertyListDecoder().decode(type, from: data)
let decoder = JSONDecoder()
decoder.userInfo = [.init(rawValue: "data")!: data]
return try decoder.decode(type, from: data)
case .xml:
let decoder = PropertyListDecoder()
decoder.userInfo = [.init(rawValue: "data")!: data]
return try decoder.decode(type, from: data)
}
}
}
@ -30,7 +34,7 @@ extension HTTPResponse {
extension HTTPResponse {
enum Decoder {
case json
case propertyList
case xml
}
enum Error: Swift.Error {

View File

@ -0,0 +1,156 @@
//
// StoreClient.swift
// IPATool
//
// Created by Majd Alfhaily on 22.05.21.
//
import Foundation
protocol StoreClientInterface {
func authenticate(email: String, password: String, code: String?, completion: @escaping (Result<StoreResponse.Account, Error>) -> Void)
func item(identifier: String, directoryServicesIdentifier: String, completion: @escaping (Result<StoreResponse.Item, Error>) -> Void)
}
extension StoreClientInterface {
func authenticate(email: String,
password: String,
code: String? = nil,
completion: @escaping (Result<StoreResponse.Account, Swift.Error>) -> Void) {
authenticate(email: email, password: password, code: code, completion: completion)
}
func authenticate(email: String, password: String, code: String? = nil) throws -> StoreResponse.Account {
let semaphore = DispatchSemaphore(value: 0)
var result: Result<StoreResponse.Account, Error>?
authenticate(email: email, password: password, code: code) {
result = $0
semaphore.signal()
}
_ = semaphore.wait(timeout: .distantFuture)
switch result {
case .none:
throw StoreClient.Error.timeout
case let .failure(error):
throw error
case let .success(result):
return result
}
}
func item(identifier: String, directoryServicesIdentifier: String) throws -> StoreResponse.Item {
let semaphore = DispatchSemaphore(value: 0)
var result: Result<StoreResponse.Item, Error>?
item(identifier: identifier, directoryServicesIdentifier: directoryServicesIdentifier) {
result = $0
semaphore.signal()
}
_ = semaphore.wait(timeout: .distantFuture)
switch result {
case .none:
throw StoreClient.Error.timeout
case let .failure(error):
throw error
case let .success(result):
return result
}
}
}
final class StoreClient: StoreClientInterface {
private let httpClient: HTTPClient
init(httpClient: HTTPClient) {
self.httpClient = httpClient
}
func authenticate(email: String, password: String, code: String?, completion: @escaping (Result<StoreResponse.Account, Swift.Error>) -> Void) {
authenticate(email: email,
password: password,
code: code,
isFirstAttempt: true,
completion: completion)
}
private func authenticate(email: String,
password: String,
code: String?,
isFirstAttempt: Bool,
completion: @escaping (Result<StoreResponse.Account, Swift.Error>) -> Void) {
let request = StoreRequest.authenticate(email: email, password: password, code: code)
httpClient.send(request) { [weak self] result in
switch result {
case let .success(response):
do {
let decoded = try response.decode(StoreResponse.self, as: .xml)
switch decoded {
case let .account(account):
completion(.success(account))
case .item:
completion(.failure(Error.invalidResponse))
case let .failure(error):
switch error {
case StoreResponse.Error.invalidCredentials:
if isFirstAttempt {
return self?.authenticate(email: email,
password: password,
code: code,
isFirstAttempt: false,
completion: completion) ?? ()
}
completion(.failure(error))
default:
completion(.failure(error))
}
}
} catch {
completion(.failure(error))
}
case let .failure(error):
completion(.failure(error))
}
}
}
func item(identifier: String, directoryServicesIdentifier: String, completion: @escaping (Result<StoreResponse.Item, Swift.Error>) -> Void) {
let request = StoreRequest.download(appIdentifier: identifier, directoryServicesIdentifier: directoryServicesIdentifier)
httpClient.send(request) { result in
switch result {
case let .success(response):
do {
let decoded = try response.decode(StoreResponse.self, as: .xml)
switch decoded {
case let .item(item):
completion(.success(item))
case .account:
completion(.failure(Error.invalidResponse))
case let .failure(error):
completion(.failure(error))
}
} catch {
completion(.failure(error))
}
case let .failure(error):
completion(.failure(error))
}
}
}
}
extension StoreClient {
enum Error: Swift.Error {
case timeout
case invalidResponse
}
}

View File

@ -0,0 +1,40 @@
//
// StoreEndpoint.swift
// IPATool
//
// Created by Majd Alfhaily on 22.05.21.
//
import Foundation
enum StoreEndpoint {
case authenticate(prefix: String, guid: String)
case download(guid: String)
}
extension StoreEndpoint: HTTPEndpoint {
var url: URL {
var components = URLComponents(string: path)!
components.scheme = "https"
components.host = host
return components.url!
}
private var host: String {
switch self {
case let .authenticate(prefix, _):
return "\(prefix)-buy.itunes.apple.com"
case .download:
return "p25-buy.itunes.apple.com"
}
}
private var path: String {
switch self {
case let .authenticate(_, guid):
return "/WebObjects/MZFinance.woa/wa/authenticate?guid=\(guid)"
case let .download(guid):
return "/WebObjects/MZFinance.woa/wa/volumeStoreDownloadProduct?guid=\(guid)"
}
}
}

View File

@ -0,0 +1,107 @@
//
// StoreRequest.swift
// IPATool
//
// Created by Majd Alfhaily on 22.05.21.
//
import Foundation
enum StoreRequest {
case authenticate(email: String, password: String, code: String? = nil)
case download(appIdentifier: String, directoryServicesIdentifier: String)
}
extension StoreRequest: HTTPRequest {
var endpoint: HTTPEndpoint {
switch self {
case let .authenticate(_, _, code):
return StoreEndpoint.authenticate(prefix: (code == nil) ? "p25" : "p71", guid: guid)
case .download:
return StoreEndpoint.download(guid: guid)
}
}
var method: HTTPMethod {
return .post
}
var headers: [String: String] {
var headers: [String: String] = [
"User-Agent": "Configurator/2.0 (Macintosh; OS X 10.12.6; 16G29) AppleWebKit/2603.3.8",
"Content-Type": "application/x-www-form-urlencoded"
]
switch self {
case .authenticate:
break
case let .download(_, directoryServicesIdentifier):
headers["X-Dsid"] = directoryServicesIdentifier
headers["iCloud-DSID"] = directoryServicesIdentifier
}
return headers
}
var payload: HTTPPayload? {
switch self {
case let .authenticate(email, password, code):
return .xml([
"appleId": email,
"attempt": "\(code == nil ? "4" : "2")",
"createSession": "true",
"guid": guid,
"password": "\(password)\(code ?? "")",
"rmp": "0",
"why": "signIn"
])
case let .download(appIdentifier, _):
return .xml([
"creditDisplay": "",
"guid": guid,
"salableAdamId": "\(appIdentifier)"
])
}
}
}
extension StoreRequest {
// This identifier is calculated by reading the MAC address of the device and stripping the nonalphabetic characters from the string.
// https://stackoverflow.com/a/31838376
private var guid: String {
let MAC_ADDRESS_LENGTH = 6
let bsds: [String] = ["en0", "en1"]
var bsd: String = bsds[0]
var length : size_t = 0
var buffer : [CChar]
var bsdIndex = Int32(if_nametoindex(bsd))
if bsdIndex == 0 {
bsd = bsds[1]
bsdIndex = Int32(if_nametoindex(bsd))
guard bsdIndex != 0 else { fatalError("Could not read MAC address") }
}
let bsdData = Data(bsd.utf8)
var managementInfoBase = [CTL_NET, AF_ROUTE, 0, AF_LINK, NET_RT_IFLIST, bsdIndex]
guard sysctl(&managementInfoBase, 6, nil, &length, nil, 0) >= 0 else { fatalError("Could not read MAC address") }
buffer = [CChar](unsafeUninitializedCapacity: length, initializingWith: {buffer, initializedCount in
for x in 0..<length { buffer[x] = 0 }
initializedCount = length
})
guard sysctl(&managementInfoBase, 6, &buffer, &length, nil, 0) >= 0 else { fatalError("Could not read MAC address") }
let infoData = Data(bytes: buffer, count: length)
let indexAfterMsghdr = MemoryLayout<if_msghdr>.stride + 1
let rangeOfToken = infoData[indexAfterMsghdr...].range(of: bsdData)!
let lower = rangeOfToken.upperBound
let upper = lower + MAC_ADDRESS_LENGTH
let macAddressData = infoData[lower..<upper]
let addressBytes = macAddressData.map{ String(format:"%02x", $0) }
return addressBytes.joined().uppercased()
}
}

View File

@ -0,0 +1,128 @@
//
// StoreResponse.swift
// IPATool
//
// Created by Majd Alfhaily on 22.05.21.
//
import Foundation
enum StoreResponse {
case failure(error: Swift.Error)
case account(Account)
case item(Item)
}
extension StoreResponse {
struct Account {
let firstName: String
let lastName: String
let directoryServicesIdentifier: String
}
struct Item {
let url: URL
let md5: String
let signatures: [Signature]
let metadata: [String: Any]
}
enum Error: Int, Swift.Error {
case unknownError = 0
case genericError = 5002
case codeRequired = 1
case invalidLicense = 9610
case invalidCredentials = -5000
case invalidAccount = 5001
case invalidItem = -10000
}
}
extension StoreResponse: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let error = try container.decodeIfPresent(String.self, forKey: .error)
let message = try container.decodeIfPresent(String.self, forKey: .message)
if container.contains(.account) {
let directoryServicesIdentifier = try container.decode(String.self, forKey: .directoryServicesIdentifier)
let accountContainer = try container.nestedContainer(keyedBy: AccountInfoCodingKeys.self, forKey: .account)
let addressContainer = try accountContainer.nestedContainer(keyedBy: AddressCodingKeys.self, forKey: .address)
let firstName = try addressContainer.decode(String.self, forKey: .firstName)
let lastName = try addressContainer.decode(String.self, forKey: .lastName)
self = .account(.init(firstName: firstName, lastName: lastName, directoryServicesIdentifier: directoryServicesIdentifier))
} else if let items = try container.decodeIfPresent([Item].self, forKey: .items), let item = items.first {
self = .item(item)
} else if let error = error, !error.isEmpty {
self = .failure(error: Error(rawValue: Int(error) ?? 0) ?? .unknownError)
} else {
switch message {
case "Your account information was entered incorrectly.":
self = .failure(error: Error.invalidCredentials)
case "An Apple ID verification code is required to sign in. Type your password followed by the verification code shown on your other devices.":
self = .failure(error: Error.codeRequired)
default:
self = .failure(error: Error.unknownError)
}
}
}
private enum CodingKeys: String, CodingKey {
case directoryServicesIdentifier = "dsPersonId"
case message = "customerMessage"
case items = "songList"
case error = "failureType"
case account = "accountInfo"
}
private enum AccountInfoCodingKeys: String, CodingKey {
case address
}
private enum AddressCodingKeys: String, CodingKey {
case firstName
case lastName
}
}
extension StoreResponse.Item: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let md5 = try container.decode(String.self, forKey: .md5)
guard let key = CodingUserInfoKey(rawValue: "data"),
let data = decoder.userInfo[key] as? Data,
let json = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any],
let items = json["songList"] as? [[String: Any]],
let item = items.first(where: { $0["md5"] as? String == md5 }),
let metadata = item["metadata"] as? [String: Any]
else { throw StoreResponse.Error.invalidItem }
let absoluteUrl = try container.decode(String.self, forKey: .url)
self.md5 = md5
self.metadata = metadata
self.signatures = try container.decode([Signature].self, forKey: .signatures)
if let url = URL(string: absoluteUrl) {
self.url = url
} else {
let context = DecodingError.Context(codingPath: [CodingKeys.url], debugDescription: "URL contains illegal characters: \(absoluteUrl).")
throw DecodingError.keyNotFound(CodingKeys.url, context)
}
}
struct Signature: Decodable {
let id: Int
let sinf: Data
}
enum CodingKeys: String, CodingKey {
case url = "URL"
case metadata
case md5
case signatures = "sinfs"
}
}

View File

@ -42,7 +42,9 @@ final class iTunesClient: iTunesClientInterface {
}
func lookup(bundleIdentifier: String, completion: @escaping (Result<iTunesResponse.Result, Swift.Error>) -> Void) {
httpClient.send(iTunesRequest.lookup(bundleIdentifier: bundleIdentifier)) { result in
let request = iTunesRequest.lookup(bundleIdentifier: bundleIdentifier)
httpClient.send(request) { result in
switch result {
case let .success(response):
do {

View File

@ -12,14 +12,14 @@ enum iTunesRequest {
}
extension iTunesRequest: HTTPRequest {
var endpoint: HTTPEndpoint {
return iTunesEndpoint.lookup
}
var method: HTTPMethod {
return .get
}
var endpoint: HTTPEndpoint {
return iTunesEndpoint.lookup
}
var payload: HTTPPayload? {
switch self {
case let .lookup(bundleIdentifier):

View File

@ -22,6 +22,10 @@
A9CA48E5265926DB00BC09D5 /* iTunesRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CA48E4265926DB00BC09D5 /* iTunesRequest.swift */; };
A9CA48E72659273700BC09D5 /* iTunesEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CA48E62659273700BC09D5 /* iTunesEndpoint.swift */; };
A9CA48E92659281F00BC09D5 /* iTunesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CA48E82659281F00BC09D5 /* iTunesResponse.swift */; };
A9CA48EC26592DD100BC09D5 /* StoreClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CA48EB26592DD100BC09D5 /* StoreClient.swift */; };
A9CA48EE26592DD800BC09D5 /* StoreEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CA48ED26592DD800BC09D5 /* StoreEndpoint.swift */; };
A9CA48F026592DE100BC09D5 /* StoreRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CA48EF26592DE100BC09D5 /* StoreRequest.swift */; };
A9CA48F22659317E00BC09D5 /* StoreResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CA48F12659317E00BC09D5 /* StoreResponse.swift */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
@ -52,6 +56,10 @@
A9CA48E4265926DB00BC09D5 /* iTunesRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iTunesRequest.swift; sourceTree = "<group>"; };
A9CA48E62659273700BC09D5 /* iTunesEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iTunesEndpoint.swift; sourceTree = "<group>"; };
A9CA48E82659281F00BC09D5 /* iTunesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iTunesResponse.swift; sourceTree = "<group>"; };
A9CA48EB26592DD100BC09D5 /* StoreClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreClient.swift; sourceTree = "<group>"; };
A9CA48ED26592DD800BC09D5 /* StoreEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreEndpoint.swift; sourceTree = "<group>"; };
A9CA48EF26592DE100BC09D5 /* StoreRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreRequest.swift; sourceTree = "<group>"; };
A9CA48F12659317E00BC09D5 /* StoreResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreResponse.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -88,6 +96,7 @@
A9CA48CB2658FBEB00BC09D5 /* Commands */,
A9CA48E1265926BF00BC09D5 /* iTunes */,
A9CA48D02659138600BC09D5 /* Networking */,
A9CA48EA265929E000BC09D5 /* Store */,
A9CA48CE2658FC7400BC09D5 /* IPATool.swift */,
A9CA48C12658F93F00BC09D5 /* main.swift */,
);
@ -127,6 +136,17 @@
path = iTunes;
sourceTree = "<group>";
};
A9CA48EA265929E000BC09D5 /* Store */ = {
isa = PBXGroup;
children = (
A9CA48EB26592DD100BC09D5 /* StoreClient.swift */,
A9CA48ED26592DD800BC09D5 /* StoreEndpoint.swift */,
A9CA48EF26592DE100BC09D5 /* StoreRequest.swift */,
A9CA48F12659317E00BC09D5 /* StoreResponse.swift */,
);
path = Store;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -196,13 +216,17 @@
A9CA48D626591AC200BC09D5 /* HTTPClient.swift in Sources */,
A9CA48DA26591CE200BC09D5 /* HTTPMethod.swift in Sources */,
A9CA48E3265926D200BC09D5 /* iTunesClient.swift in Sources */,
A9CA48F026592DE100BC09D5 /* StoreRequest.swift in Sources */,
A9CA48D826591B0400BC09D5 /* URLSession.swift in Sources */,
A9CA48E5265926DB00BC09D5 /* iTunesRequest.swift in Sources */,
A9CA48D2265913B300BC09D5 /* HTTPRequest.swift in Sources */,
A9CA48C22658F93F00BC09D5 /* main.swift in Sources */,
A9CA48EC26592DD100BC09D5 /* StoreClient.swift in Sources */,
A9CA48E92659281F00BC09D5 /* iTunesResponse.swift in Sources */,
A9CA48EE26592DD800BC09D5 /* StoreEndpoint.swift in Sources */,
A9CA48DC26591CF100BC09D5 /* HTTPPayload.swift in Sources */,
A9CA48E72659273700BC09D5 /* iTunesEndpoint.swift in Sources */,
A9CA48F22659317E00BC09D5 /* StoreResponse.swift in Sources */,
A9CA48CD2658FBF700BC09D5 /* Download.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;