Skip to content

Commit

Permalink
Adopt structured concurrency API
Browse files Browse the repository at this point in the history
  • Loading branch information
majd committed Jan 17, 2022
1 parent b030cd4 commit 234ee2f
Show file tree
Hide file tree
Showing 13 changed files with 238 additions and 338 deletions.
38 changes: 38 additions & 0 deletions Source/AsyncSupport.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// AsyncSupport.swift
// IPATool
//
// Created by Majd Alfhaily on 04.01.22.
//

import Foundation
import ArgumentParser

protocol AsyncParsableCommand: ParsableCommand {
mutating func run() async throws
}

extension AsyncParsableCommand {
mutating func run() throws {
throw CleanExit.helpRequest(self)
}
}

protocol AsyncMain {
associatedtype Command: ParsableCommand
}

extension AsyncMain {
static func main() async {
do {
var command = try Command.parseAsRoot()
if var command = command as? AsyncParsableCommand {
try await command.run()
} else {
try command.run()
}
} catch {
Command.exit(withError: error)
}
}
}
45 changes: 26 additions & 19 deletions Source/Commands/Download.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import ArgumentParser
import Foundation

struct Download: ParsableCommand {
struct Download: AsyncParsableCommand {
static var configuration: CommandConfiguration {
return .init(abstract: "Download (encrypted) iOS app packages from the App Store.")
}
Expand Down Expand Up @@ -38,16 +38,20 @@ struct Download: ParsableCommand {
}

extension Download {
mutating func app(with bundleIdentifier: String, country: String) -> iTunesResponse.Result {
mutating func app(with bundleIdentifier: String, country: String) async -> iTunesResponse.Result {
logger.log("Creating HTTP client...", level: .debug)
let httpClient = HTTPClient(urlSession: URLSession.shared)
let httpClient = HTTPClient(session: URLSession.shared)

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

do {
logger.log("Querying the iTunes Store for '\(bundleIdentifier)' in country '\(country)'...", level: .info)
return try itunesClient.lookup(bundleIdentifier: bundleIdentifier, country: country, deviceFamily: deviceFamily)
return try await itunesClient.lookup(
bundleIdentifier: bundleIdentifier,
country: country,
deviceFamily: deviceFamily
)
} catch {
logger.log("\(error)", level: .debug)

Expand Down Expand Up @@ -101,21 +105,21 @@ extension Download {
}
}

mutating func authenticate(email: String, password: String) -> StoreResponse.Account {
mutating func authenticate(email: String, password: String) async -> StoreResponse.Account {
logger.log("Creating HTTP client...", level: .debug)
let httpClient = HTTPClient(urlSession: URLSession.shared)
let httpClient = HTTPClient(session: URLSession.shared)

logger.log("Creating App Store client...", level: .debug)
let storeClient = StoreClient(httpClient: httpClient)

do {
logger.log("Authenticating with the App Store...", level: .info)
return try storeClient.authenticate(email: email, password: password)
return try await storeClient.authenticate(email: email, password: password, code: nil)
} catch {
switch error {
case StoreResponse.Error.codeRequired:
do {
return try storeClient.authenticate(email: email, password: password, code: authCode())
return try await storeClient.authenticate(email: email, password: password, code: authCode())
} catch {
logger.log("\(error)", level: .debug)

Expand Down Expand Up @@ -156,16 +160,19 @@ extension Download {

}

mutating func item(from app: iTunesResponse.Result, account: StoreResponse.Account) -> StoreResponse.Item {
mutating func item(from app: iTunesResponse.Result, account: StoreResponse.Account) async -> StoreResponse.Item {
logger.log("Creating HTTP client...", level: .debug)
let httpClient = HTTPClient(urlSession: URLSession.shared)
let httpClient = HTTPClient(session: URLSession.shared)

logger.log("Creating App Store client...", level: .debug)
let storeClient = StoreClient(httpClient: httpClient)

do {
logger.log("Requesting a signed copy of '\(app.identifier)' from the App Store...", level: .info)
return try storeClient.item(identifier: "\(app.identifier)", directoryServicesIdentifier: account.directoryServicesIdentifier)
return try await storeClient.item(
identifier: "\(app.identifier)",
directoryServicesIdentifier: account.directoryServicesIdentifier
)
} catch {
logger.log("\(error)", level: .debug)

Expand All @@ -184,13 +191,13 @@ extension Download {
}
}

mutating func download(item: StoreResponse.Item, to targetURL: URL) {
mutating func download(item: StoreResponse.Item, to targetURL: URL) async {
logger.log("Creating download client...", level: .debug)
let downloadClient = HTTPDownloadClient()

do {
logger.log("Downloading app package...", level: .info)
try downloadClient.download(from: item.url, to: targetURL) { [logger] progress in
try await downloadClient.download(from: item.url, to: targetURL) { [logger] progress in
logger.log("Downloading app package... [\(Int((progress * 100).rounded()))%]",
prefix: "\u{1B}[1A\u{1B}[K",
level: .info)
Expand All @@ -216,10 +223,10 @@ extension Download {
_exit(1)
}
}
mutating func run() throws {

mutating func run() async throws {
// Query for app
let app: iTunesResponse.Result = app(with: bundleIdentifier, country: country)
let app: iTunesResponse.Result = await app(with: bundleIdentifier, country: country)
logger.log("Found app: \(app.name) (\(app.version)).", level: .debug)

// Get Apple ID email
Expand All @@ -229,11 +236,11 @@ extension Download {
let password: String = password()

// Authenticate with the App Store
let account: StoreResponse.Account = authenticate(email: email, password: password)
let account: StoreResponse.Account = await authenticate(email: email, password: password)
logger.log("Authenticated as '\(account.firstName) \(account.lastName)'.", level: .info)

// Query for store item
let item: StoreResponse.Item = item(from: app, account: account)
let item: StoreResponse.Item = await item(from: app, account: account)
logger.log("Received a response of the signed copy: \(item.md5).", level: .debug)

// Generate file name
Expand All @@ -243,7 +250,7 @@ extension Download {
logger.log("Output path: \(path).", level: .debug)

// Download app package
download(item: item, to: URL(fileURLWithPath: path))
await download(item: item, to: URL(fileURLWithPath: path))
logger.log("Saved app package to \(URL(fileURLWithPath: path).lastPathComponent).", level: .info)

// Apply patches
Expand Down
4 changes: 4 additions & 0 deletions Source/Commands/IPATool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,7 @@ struct IPATool: ParsableCommand {
subcommands: [Download.self, Search.self])
}
}

@main enum Main: AsyncMain {
typealias Command = IPATool
}
24 changes: 15 additions & 9 deletions Source/Commands/Search.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import ArgumentParser
import Foundation

struct Search: ParsableCommand {
struct Search: AsyncParsableCommand {
static var configuration: CommandConfiguration {
return .init(abstract: "Search for iOS apps available on the App Store.")
}
Expand All @@ -32,17 +32,23 @@ struct Search: ParsableCommand {
}

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

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


logger.log("Searching for '\(term)' using the '\(country)' store front...", level: .info)

do {
logger.log("Searching for '\(term)' using the '\(country)' store front...", level: .info)
let results = try itunesClient.search(term: term, limit: limit, country: country, deviceFamily: deviceFamily)

let results = try await itunesClient.search(
term: term,
limit: limit,
country: country,
deviceFamily: deviceFamily
)

guard !results.isEmpty else {
logger.log("No results found.", level: .error)
_exit(1)
Expand All @@ -56,9 +62,9 @@ extension Search {
}
}

mutating func run() throws {
mutating func run() async throws {
// Search the iTunes store
let results = results(with: term, country: country)
let results = await results(with: term, country: country)

// Compile output
let output = results
Expand Down
67 changes: 20 additions & 47 deletions Source/Networking/HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,57 +8,25 @@
import Foundation

protocol HTTPClientInterface {
func send(_ request: HTTPRequest, completion: @escaping (Result<HTTPResponse, Error>) -> Void)
}

extension HTTPClientInterface {
func send(_ request: HTTPRequest) throws -> HTTPResponse {
let semaphore = DispatchSemaphore(value: 0)
var result: Result<HTTPResponse, Error>?

send(request) {
result = $0
semaphore.signal()
}

_ = semaphore.wait(timeout: .distantFuture)

switch result {
case .none:
throw HTTPClient.Error.timeout
case let .failure(error):
throw error
case let .success(response):
return response
}
}
func send(_ request: HTTPRequest) async throws -> HTTPResponse
}

final class HTTPClient: HTTPClientInterface {
private let urlSession: URLSessionInterface
private let session: URLSessionInterface

init(urlSession: URLSessionInterface) {
self.urlSession = urlSession
init(session: URLSessionInterface) {
self.session = session
}

func send(_ request: HTTPRequest, completion: @escaping (Result<HTTPResponse, Swift.Error>) -> Void) {
do {
let urlRequest = try makeURLRequest(from: request)

urlSession.dataTask(with: urlRequest) { (data, response, error) in
if let error = error {
return completion(.failure(error))
}

guard let response = response as? HTTPURLResponse else {
return completion(.failure(Error.invalidResponse(response)))
}

completion(.success(.init(statusCode: response.statusCode, data: data)))
}.resume()
} catch {
completion(.failure(error))

func send(_ request: HTTPRequest) async throws -> HTTPResponse {
let request = try makeURLRequest(from: request)
let (data, response) = try await session.data(for: request)

guard let response = response as? HTTPURLResponse else {
throw Error.invalidResponse(response)
}

return HTTPResponse(statusCode: response.statusCode, data: data)
}

private func makeURLRequest(from request: HTTPRequest) throws -> URLRequest {
Expand All @@ -72,13 +40,18 @@ final class HTTPClient: HTTPClientInterface {
urlRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")

var urlComponents = URLComponents(string: request.endpoint.url.absoluteString)
urlComponents?.queryItems = !propertyList.isEmpty ? propertyList.map { URLQueryItem(name: $0.0, value: $0.1.description) } : nil
urlComponents?.queryItems = !propertyList.isEmpty ? propertyList.map {
URLQueryItem(name: $0.0, value: $0.1.description)
} : nil

switch request.method {
case .get:
urlRequest.url = urlComponents?.url
case .post:
urlRequest.httpBody = urlComponents?.percentEncodedQuery?.data(using: .utf8, allowLossyConversion: false)
urlRequest.httpBody = urlComponents?.percentEncodedQuery?.data(
using: .utf8,
allowLossyConversion: false
)
}
case let .xml(value):
urlRequest.setValue("application/xml", forHTTPHeaderField: "Content-Type")
Expand Down
Loading

0 comments on commit 234ee2f

Please sign in to comment.