From b903f9c2b2e534bccb863b147e2386a8a42b807e Mon Sep 17 00:00:00 2001 From: Majd Alfhaily Date: Sat, 22 May 2021 15:31:25 +0200 Subject: [PATCH] Implement store client --- Source/Networking/HTTPRequest.swift | 2 +- Source/Networking/HTTPResponse.swift | 12 ++- Source/Store/StoreClient.swift | 156 +++++++++++++++++++++++++++ Source/Store/StoreEndpoint.swift | 40 +++++++ Source/Store/StoreRequest.swift | 107 ++++++++++++++++++ Source/Store/StoreResponse.swift | 128 ++++++++++++++++++++++ Source/iTunes/iTunesClient.swift | 4 +- Source/iTunes/iTunesRequest.swift | 8 +- ipatool.xcodeproj/project.pbxproj | 24 +++++ 9 files changed, 471 insertions(+), 10 deletions(-) create mode 100644 Source/Store/StoreClient.swift create mode 100644 Source/Store/StoreEndpoint.swift create mode 100644 Source/Store/StoreRequest.swift create mode 100644 Source/Store/StoreResponse.swift diff --git a/Source/Networking/HTTPRequest.swift b/Source/Networking/HTTPRequest.swift index e97ce47..0d30ebb 100644 --- a/Source/Networking/HTTPRequest.swift +++ b/Source/Networking/HTTPRequest.swift @@ -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 } } diff --git a/Source/Networking/HTTPResponse.swift b/Source/Networking/HTTPResponse.swift index ffc4bad..f296540 100644 --- a/Source/Networking/HTTPResponse.swift +++ b/Source/Networking/HTTPResponse.swift @@ -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 { diff --git a/Source/Store/StoreClient.swift b/Source/Store/StoreClient.swift new file mode 100644 index 0000000..a43a48d --- /dev/null +++ b/Source/Store/StoreClient.swift @@ -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) -> Void) + func item(identifier: String, directoryServicesIdentifier: String, completion: @escaping (Result) -> Void) +} + +extension StoreClientInterface { + func authenticate(email: String, + password: String, + code: String? = nil, + completion: @escaping (Result) -> 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? + + 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? + + 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) -> 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) -> 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) -> 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 + } +} diff --git a/Source/Store/StoreEndpoint.swift b/Source/Store/StoreEndpoint.swift new file mode 100644 index 0000000..75fcef2 --- /dev/null +++ b/Source/Store/StoreEndpoint.swift @@ -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)" + } + } +} diff --git a/Source/Store/StoreRequest.swift b/Source/Store/StoreRequest.swift new file mode 100644 index 0000000..5b39986 --- /dev/null +++ b/Source/Store/StoreRequest.swift @@ -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..= 0 else { fatalError("Could not read MAC address") } + + let infoData = Data(bytes: buffer, count: length) + let indexAfterMsghdr = MemoryLayout.stride + 1 + let rangeOfToken = infoData[indexAfterMsghdr...].range(of: bsdData)! + let lower = rangeOfToken.upperBound + let upper = lower + MAC_ADDRESS_LENGTH + let macAddressData = infoData[lower..) -> 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 { diff --git a/Source/iTunes/iTunesRequest.swift b/Source/iTunes/iTunesRequest.swift index 0b5ccd2..ab26b9e 100644 --- a/Source/iTunes/iTunesRequest.swift +++ b/Source/iTunes/iTunesRequest.swift @@ -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): diff --git a/ipatool.xcodeproj/project.pbxproj b/ipatool.xcodeproj/project.pbxproj index 7eca01c..79d9de8 100644 --- a/ipatool.xcodeproj/project.pbxproj +++ b/ipatool.xcodeproj/project.pbxproj @@ -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 = ""; }; A9CA48E62659273700BC09D5 /* iTunesEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iTunesEndpoint.swift; sourceTree = ""; }; A9CA48E82659281F00BC09D5 /* iTunesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iTunesResponse.swift; sourceTree = ""; }; + A9CA48EB26592DD100BC09D5 /* StoreClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreClient.swift; sourceTree = ""; }; + A9CA48ED26592DD800BC09D5 /* StoreEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreEndpoint.swift; sourceTree = ""; }; + A9CA48EF26592DE100BC09D5 /* StoreRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreRequest.swift; sourceTree = ""; }; + A9CA48F12659317E00BC09D5 /* StoreResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreResponse.swift; sourceTree = ""; }; /* 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 = ""; }; + A9CA48EA265929E000BC09D5 /* Store */ = { + isa = PBXGroup; + children = ( + A9CA48EB26592DD100BC09D5 /* StoreClient.swift */, + A9CA48ED26592DD800BC09D5 /* StoreEndpoint.swift */, + A9CA48EF26592DE100BC09D5 /* StoreRequest.swift */, + A9CA48F12659317E00BC09D5 /* StoreResponse.swift */, + ); + path = Store; + sourceTree = ""; + }; /* 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;