mirror of
https://github.com/xtool-org/xtool.git
synced 2026-11-05 20:13:58 +01:00
## What does this PR do? Adds `Dependency`s for console IO. Also fixes #268 by explicitly handling non-pipe input. ## How was it tested? Ran on macOS + Linux in Docker. I have a future PR that overrides the dependencies in certain cases, which also appears to work correctly. ## AI tool usage How much of this PR was AI-assisted? (check one) - [ ] **0** - No AI was used to write code - [x] **1** - I was assisted by AI. I reviewed the finished result. - [ ] **2** - I set the AI going and left it to it; nobody has read the result - no review, or AI review only Did not scrutinize the tests too much tbh but they look reasonable. Easy enough to update/remove them in the future if needed. <!-- If an AI agent is filling this in: declare the level honestly, and open as a draft if it is #2. Do not lower the declared level to get the PR reviewed. --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **New Features** * Added support for reading command-line input, including asynchronous prompts, line-based responses, and graceful handling of end-of-input. * Added coordinated cleanup when commands finish. * **Improvements** * Command messages across authentication, device management, installation, SDK, and other workflows now use consistent output handling; displayed messages and command behavior remain unchanged. * Improved handling of complete output when the system temporarily cannot accept more data. * **Tests** * Added coverage for command input and output, including stream behavior, cancellation, and shutdown. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
220 lines
6.7 KiB
Swift
220 lines
6.7 KiB
Swift
import XUtils
|
|
import XCLIUtils
|
|
import Foundation
|
|
import XKit
|
|
import ArgumentParser
|
|
import Crypto
|
|
import Dependencies
|
|
|
|
enum AuthMode: String, CaseIterable, CustomStringConvertible, ExpressibleByArgument {
|
|
case key
|
|
case password
|
|
|
|
var description: String {
|
|
switch self {
|
|
case .key: "API Key (requires paid Apple Developer Program membership)"
|
|
case .password: "Password (works with any Apple ID but uses private APIs)"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct AuthOperation {
|
|
var username: String?
|
|
var password: String?
|
|
var logoutFromExisting: Bool
|
|
|
|
var mode: AuthMode?
|
|
var quiet = false
|
|
|
|
func run() async throws {
|
|
if let token = try? AuthToken.saved(), !logoutFromExisting {
|
|
if !quiet {
|
|
d.out.print("Logged in.\n\(token)")
|
|
}
|
|
return
|
|
}
|
|
|
|
let mode: AuthMode
|
|
if let existing = self.mode {
|
|
mode = existing
|
|
} else {
|
|
mode = try await Console.choose(
|
|
from: AuthMode.allCases,
|
|
onNoElement: { throw Console.Error("Mode selection is required") },
|
|
multiPrompt: "Select login mode",
|
|
formatter: \.description
|
|
)
|
|
}
|
|
|
|
let token = switch mode {
|
|
case .password:
|
|
try await logInWithPassword()
|
|
case .key:
|
|
try await logInWithKey()
|
|
}
|
|
try token.save()
|
|
|
|
d.out.print("Logged in.\n\(token)")
|
|
}
|
|
|
|
private func logInWithKey() async throws -> AuthToken {
|
|
let id = try await Console.promptRequired("Key ID: ", existing: nil)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
let issuerID = try await Console.promptRequired("Issuer ID: ", existing: nil)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
let path = try await Console.promptRequired("Key path: ", existing: nil)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
let pem = try String(decoding: Data(contentsOf: URL(fileURLWithPath: path)), as: UTF8.self)
|
|
do {
|
|
_ = try P256.Signing.PrivateKey(pemRepresentation: pem)
|
|
} catch {
|
|
throw Console.Error("Key is invalid: \(error)")
|
|
}
|
|
|
|
return AuthToken.appStoreConnect(.init(id: id, issuerID: issuerID, pem: pem))
|
|
}
|
|
|
|
private func logInWithPassword() async throws -> AuthToken {
|
|
d.out.print("""
|
|
Heads up:
|
|
This works by emulating a virtual Mac to interact with Apple services.
|
|
We use a third-party service to create a randomized device ID token
|
|
(Anisette Data) but your credentials are only ever sent to Apple.
|
|
Still, consider using a secondary Apple ID since this is a private API.
|
|
""")
|
|
|
|
let username = try await Console.promptRequired("Apple ID: ", existing: username)
|
|
|
|
let password: String
|
|
if let existing = self.password {
|
|
password = existing
|
|
} else {
|
|
password = try await Console.getPassword("Password: ")
|
|
}
|
|
guard !password.isEmpty else {
|
|
throw Console.Error("Password cannot be empty.")
|
|
}
|
|
|
|
d.out.print("Logging in...")
|
|
|
|
let authDelegate = XToolAuthDelegate()
|
|
let manager = DeveloperServicesLoginManager()
|
|
let token = try await manager.logIn(
|
|
withUsername: username,
|
|
password: password,
|
|
twoFactorDelegate: authDelegate
|
|
)
|
|
|
|
let client = DeveloperServicesClient(loginToken: token)
|
|
let teams = try await client.send(DeveloperServicesListTeamsRequest())
|
|
let team = try await Console.choose(
|
|
from: teams,
|
|
onNoElement: {
|
|
throw Console.Error("No development teams found")
|
|
},
|
|
multiPrompt: "\nSelect a team",
|
|
formatter: {
|
|
"\($0.name) (\($0.id.rawValue))"
|
|
}
|
|
)
|
|
|
|
@Dependency(\.anisetteDataProvider) var anisetteProvider
|
|
|
|
return AuthToken.xcode(.init(
|
|
appleID: username,
|
|
adsid: token.adsid,
|
|
token: token.token,
|
|
expiry: token.expiry,
|
|
teamID: team.id.rawValue,
|
|
anisetteProviderID: anisetteProvider.providerID
|
|
))
|
|
}
|
|
}
|
|
|
|
struct AuthLoginCommand: AsyncParsableCommand {
|
|
static let configuration = CommandConfiguration(
|
|
commandName: "login",
|
|
abstract: "Log in to Apple Developer Services"
|
|
)
|
|
|
|
@Option(name: [.short, .long], help: "Apple ID") var username: String?
|
|
@Option(name: [.short, .long]) var password: String?
|
|
@Option(name: [.short, .long]) var mode: AuthMode?
|
|
|
|
func run() async throws {
|
|
try await AuthOperation(
|
|
username: username,
|
|
password: password,
|
|
logoutFromExisting: true,
|
|
mode: mode
|
|
).run()
|
|
}
|
|
}
|
|
|
|
struct AuthLogoutCommand: AsyncParsableCommand {
|
|
static let configuration = CommandConfiguration(
|
|
commandName: "logout",
|
|
abstract: "Log out of Apple Developer Services"
|
|
)
|
|
|
|
@Flag(
|
|
name: [.short, .customLong("reset-2fa")],
|
|
help: ArgumentHelp(
|
|
"Reset 2-factor authentication data",
|
|
discussion: """
|
|
This resets the "pseudo-device" that xtool presents itself as \
|
|
when authenticating with Apple using the password login mode.
|
|
|
|
Effectively, this means you will be prompted to complete 2-factor \
|
|
authentication again the next time you log in.
|
|
"""
|
|
)
|
|
) var reset2FA = false
|
|
|
|
func run() async throws {
|
|
if (try? AuthToken.saved()) != nil {
|
|
try AuthToken.clear()
|
|
d.out.print("Logged out")
|
|
} else {
|
|
d.out.print("Already logged out")
|
|
}
|
|
|
|
if reset2FA {
|
|
@Dependency(\.anisetteDataProvider) var anisetteProvider
|
|
await anisetteProvider.resetProvisioning()
|
|
d.out.print("Forgot device")
|
|
}
|
|
}
|
|
}
|
|
|
|
struct AuthStatusCommand: AsyncParsableCommand {
|
|
static let configuration = CommandConfiguration(
|
|
commandName: "status",
|
|
abstract: "Get Apple Developer Services auth status"
|
|
)
|
|
|
|
func run() async throws {
|
|
if let token = try? AuthToken.saved() {
|
|
d.out.print("Logged in.\n\(token)")
|
|
} else {
|
|
d.out.print("Logged out")
|
|
}
|
|
}
|
|
}
|
|
|
|
struct AuthCommand: AsyncParsableCommand {
|
|
static let configuration = CommandConfiguration(
|
|
commandName: "auth",
|
|
abstract: "Manage Apple Developer Services authentication",
|
|
subcommands: [
|
|
AuthLoginCommand.self,
|
|
AuthLogoutCommand.self,
|
|
AuthStatusCommand.self,
|
|
],
|
|
defaultSubcommand: AuthLoginCommand.self
|
|
)
|
|
}
|