From 6b3ff0bafe1fc589c4033661f78e6eab94f904b9 Mon Sep 17 00:00:00 2001 From: Kabir Oberai Date: Tue, 22 Sep 2026 00:59:18 -0400 Subject: [PATCH] cli: IO dependencies --- .swiftlint.yml | 9 + Package.swift | 17 +- Sources/XCLIUtils/CommandInput.swift | 212 ++++++++++++++++++ .../{XToolSupport => XCLIUtils}/Console.swift | 72 ++---- Sources/XCLIUtils/ShutdownRegistry.swift | 42 ++++ .../OpenAPI/LoggingMiddleware.swift | 5 +- ...ndSlamTwoFactorAuthenticateOperation.swift | 5 +- .../XKit/Installation/ProfileManager.swift | 7 +- Sources/XToolSupport/AuthCommand.swift | 20 +- Sources/XToolSupport/AuthToken.swift | 2 + Sources/XToolSupport/ConnectionOptions.swift | 4 +- .../DSCommands/DSCertificatesCommand.swift | 29 +-- .../XToolSupport/DSCommands/DSCommand.swift | 3 +- .../DSCommands/DSDevicesCommand.swift | 17 +- .../DSCommands/DSIdentifiersCommand.swift | 11 +- .../DSCommands/DSProfilesCommand.swift | 35 +-- .../DSCommands/DSTeamsCommand.swift | 4 +- Sources/XToolSupport/DevBSPCommand.swift | 2 + Sources/XToolSupport/DevCommand.swift | 29 +-- Sources/XToolSupport/DevicesCommand.swift | 5 +- Sources/XToolSupport/InstallCommand.swift | 7 +- Sources/XToolSupport/LSPConfig.swift | 5 +- Sources/XToolSupport/LaunchCommand.swift | 4 +- Sources/XToolSupport/NewCommand.swift | 12 +- Sources/XToolSupport/SDKBuilder.swift | 35 ++- Sources/XToolSupport/SDKCommand.swift | 35 +-- Sources/XToolSupport/UninstallCommand.swift | 5 +- Sources/XToolSupport/XTool.swift | 10 +- .../XToolSupport/XToolInstallerDelegate.swift | 25 +-- Sources/XUtils/CommandOutput.swift | 65 ++++++ Sources/XUtils/Dependencies+Utils.swift | 10 + Sources/XUtils/Foundation+Utils.swift | 2 +- Sources/XUtils/System+Utils.swift | 21 ++ Tests/XToolTests/CommandInputTests.swift | 107 +++++++++ Tests/XToolTests/CommandOutputTests.swift | 86 +++++++ Tests/XToolTests/ShutdownRegistryTests.swift | 35 +++ 36 files changed, 796 insertions(+), 198 deletions(-) create mode 100644 Sources/XCLIUtils/CommandInput.swift rename Sources/{XToolSupport => XCLIUtils}/Console.swift (56%) create mode 100644 Sources/XCLIUtils/ShutdownRegistry.swift create mode 100644 Sources/XUtils/CommandOutput.swift create mode 100644 Sources/XUtils/Dependencies+Utils.swift create mode 100644 Tests/XToolTests/CommandInputTests.swift create mode 100644 Tests/XToolTests/CommandOutputTests.swift create mode 100644 Tests/XToolTests/ShutdownRegistryTests.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 20aa64a1..41afba1a 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -42,6 +42,15 @@ opt_in_rules: analyzer_rules: - unused_import custom_rules: + prefer_dependency_print: + name: "Dependency Output" + included: '/Sources/' + excluded: '/(Tests|Examples)/' + regex: '(? Data? + package var close: @Sendable () async -> Void + private let lines = LineBuffer() + + package init( + read: @escaping @Sendable () async throws -> Data?, + close: @escaping @Sendable () async -> Void = {} + ) { + self.read = read + self.close = close + } + + package func readLine() async throws -> String? { + try await lines.readLine(read: read) + } + + package static var liveValue: Self { + let input = StandardCommandInput() + return Self(read: { try await input.read() }, close: { await input.close() }) + } + + package static var testValue: Self { Self(read: unimplemented("CommandInput.read", placeholder: nil)) } + + private actor LineBuffer { + var buffer = Data() + var ended = false + + func readLine(read: @Sendable () async throws -> Data?) async throws -> String? { + while true { + try Task.checkCancellation() + if let newline = buffer.firstIndex(of: UInt8(ascii: "\n")) { + let line = String(decoding: buffer[.. String { + hasSuffix("\r") ? String(dropLast()) : self + } +} + +extension DependencyValues { + package var commandInput: CommandInput { + get { self[CommandInput.self] } + set { self[CommandInput.self] = newValue } + } +} + +/// Buffers packets received before a command starts reading, without decoding bytes. +package struct CommandInputQueue: Sendable { + private let stream: AsyncThrowingStream + private let continuation: AsyncThrowingStream.Continuation + + package init() { + (stream, continuation) = AsyncThrowingStream.makeStream(of: Data.self) + } + + package var input: CommandInput { + CommandInput(read: { try await read() }, close: { finish() }) + } + + package func read() async throws -> Data? { + try Task.checkCancellation() + var iterator = stream.makeAsyncIterator() + let chunk = try await iterator.next() + try Task.checkCancellation() + return chunk + } + + package func receive(_ data: Data?) { + if let data { continuation.yield(data) } else { finish() } + } + + package func finish(throwing error: (any Error)? = nil) { + continuation.finish(throwing: error) + } +} + +/// Lazily opens stdin, retaining one reader across successive local prompts. +actor StandardCommandInput { + static let chunkSize = 64 * 1024 + private let descriptor: XUtils.FileDescriptor + private var source: Task? + private var fileRead: Task? + private var isClosed = false + + private enum Source: Sendable { + case file(FileHandle) + case pipe(any Channel, CommandInputQueue, flags: Int32) + } + + init(descriptor: XUtils.FileDescriptor = .standardInput) { + self.descriptor = descriptor + } + + func read() async throws -> Data? { + try Task.checkCancellation() + guard !isClosed else { return nil } + if source == nil { + source = Task { try await open() } + @Dependency(\.shutdownRegistry) var shutdownRegistry + await shutdownRegistry.register { await self.close() } + } + guard let opening = source else { return nil } + let source = try await opening.value + try Task.checkCancellation() + guard !isClosed else { return nil } + let data: Data? + switch source { + case .file(let handle): + let task = Task { + try await NIOThreadPool.singleton.runIfActive { + let data = try handle.read(upToCount: Self.chunkSize) + return data?.isEmpty == false ? data : nil + } + } + fileRead = task + defer { fileRead = nil } + data = try await task.cancellableValue + case .pipe(let channel, let queue, _): + channel.read() + data = try await queue.read() + } + try Task.checkCancellation() + return data + } + + func close() async { + guard !isClosed else { return } + isClosed = true + guard let source = try? await source?.value else { return } + switch source { + case .file(let handle): + _ = try? await fileRead?.value + try? handle.close() + case .pipe(let channel, let queue, let flags): + queue.finish() + try? await channel.close().get() + // dup shares status flags with stdin; undo NIO's O_NONBLOCK change. + _ = fcntl(descriptor.rawValue, F_SETFL, flags) + } + } + + private func open() async throws -> Source { + var info = stat() + guard fstat(descriptor.rawValue, &info) == 0 else { throw Errno(rawValue: errno) } + let duplicate = try descriptor.duplicate() + let kind = info.st_mode & mode_t(S_IFMT) + // Regular files and /dev/null cannot be registered with epoll on Linux. + if kind == mode_t(S_IFREG) || (kind == mode_t(S_IFCHR) && isatty(descriptor.rawValue) == 0) { + return .file(FileHandle(fileDescriptor: duplicate.rawValue, closeOnDealloc: true)) + } + let flags = fcntl(descriptor.rawValue, F_GETFL) + let queue = CommandInputQueue() + do { + let channel = try await NIOPipeBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .channelOption(ChannelOptions.autoRead, value: false) + .channelOption(ChannelOptions.maxMessagesPerRead, value: 1) + .channelOption(ChannelOptions.recvAllocator, value: FixedSizeRecvByteBufferAllocator(capacity: Self.chunkSize)) + .takingOwnershipOfDescriptor(input: duplicate.rawValue) { channel in + channel.pipeline.addHandler(Receiver(queue: queue)).map { channel } + } + return .pipe(channel, queue, flags: flags) + } catch { + try? duplicate.close() + _ = fcntl(descriptor.rawValue, F_SETFL, flags) + throw error + } + } + + private final class Receiver: ChannelInboundHandler, Sendable { + typealias InboundIn = ByteBuffer + let queue: CommandInputQueue + + init(queue: CommandInputQueue) { self.queue = queue } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + queue.receive(Data(unwrapInboundIn(data).readableBytesView)) + } + + func channelInactive(context: ChannelHandlerContext) { queue.finish() } + + func errorCaught(context: ChannelHandlerContext, error: any Error) { + queue.finish(throwing: error) + context.close(promise: nil) + } + } +} diff --git a/Sources/XToolSupport/Console.swift b/Sources/XCLIUtils/Console.swift similarity index 56% rename from Sources/XToolSupport/Console.swift rename to Sources/XCLIUtils/Console.swift index ba6136e1..111cc856 100644 --- a/Sources/XToolSupport/Console.swift +++ b/Sources/XCLIUtils/Console.swift @@ -1,48 +1,16 @@ import Foundation -import XKit import XUtils -import NIOPosix -import NIOCore - -enum Console { - private static func withStdio( - _ body: ( - _ stdin: NIOAsyncChannelInboundStream, - _ stdout: NIOAsyncChannelOutboundWriter - ) async throws -> T - ) async throws -> T { - try await NIOPipeBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .takingOwnershipOfDescriptors( - input: FileDescriptor.standardInput.duplicate().rawValue, - output: FileDescriptor.standardOutput.duplicate().rawValue - ) - .flatMapThrowing { try NIOAsyncChannel(wrappingChannelSynchronously: $0) } - .get() - .executeThenClose { try await body($0, $1) } - } - - static func prompt(_ message: String) async throws -> String { - try await withStdio { stdin, stdout in - try await stdout.write(ByteBuffer(bytes: message.utf8)) - - fflush(stdoutSafe) - - var data = Data() - for try await chunk in stdin { - let view = chunk.readableBytesView - if let endIndex = view.firstIndex(of: UInt8(ascii: "\n")) { - data += view[.. String { + @Dependency(\.commandInput) var input + d.out.print(message, terminator: "", flush: true) + guard let line = try await input.readLine() else { throw Console.Error("Input ended before a response was received.") } + return line } - static func promptRequired(_ message: String, existing: String?) async throws -> String { + package static func promptRequired(_ message: String, existing: String?) async throws -> String { let value: String if let existing { value = existing @@ -55,12 +23,12 @@ enum Console { return value } - static func getPassword(_ message: String) async throws -> String { + package static func getPassword(_ message: String) async throws -> String { if !message.isEmpty { - print(message, terminator: "") + d.out.print(message, terminator: "") } let password = try await withoutEcho { try await prompt("") } - print() + d.out.print() return password } @@ -91,7 +59,7 @@ enum Console { #endif } - static func chooseNumber(in range: Range) async throws -> Int { + package static func chooseNumber(in range: Range) async throws -> Int { let message = "Choice (\(range.lowerBound)-\(range.upperBound - 1)): " while true { if let choice = try await Int(prompt(message)), range.contains(choice) { @@ -100,7 +68,7 @@ enum Console { } } - static func choose( + package static func choose( from elements: [T], onNoElement: () throws -> T, multiPrompt: @autoclosure () -> String, @@ -112,9 +80,9 @@ enum Console { case 1: return elements[0] default: - print(multiPrompt()) + d.out.print(multiPrompt()) try elements.enumerated().forEach { index, element in - try print("\(index): \(formatter(element))") + try d.out.print("\(index): \(formatter(element))") } let choice = try await chooseNumber(in: elements.indices) return elements[choice] @@ -124,7 +92,7 @@ enum Console { private static let yesSet: Set = ["yes", "y"] private static let noSet: Set = ["no", "n"] - static func confirm(_ message: String) async throws -> Bool { + package static func confirm(_ message: String) async throws -> Bool { while true { let resp = try await prompt("\(message) (yes/no): ").lowercased() if yesSet.contains(resp) { @@ -135,9 +103,9 @@ enum Console { } } - struct Error: Swift.Error, CustomStringConvertible { - let description: String - init(_ description: String) { + package struct Error: Swift.Error, CustomStringConvertible { + package let description: String + package init(_ description: String) { self.description = description } } diff --git a/Sources/XCLIUtils/ShutdownRegistry.swift b/Sources/XCLIUtils/ShutdownRegistry.swift new file mode 100644 index 00000000..70d53224 --- /dev/null +++ b/Sources/XCLIUtils/ShutdownRegistry.swift @@ -0,0 +1,42 @@ +import Dependencies + +/// Anything registered here will be cleaned up when the process exits +public actor ShutdownRegistry: DependencyKey { + public static var liveValue: ShutdownRegistry { ShutdownRegistry() } + public static var testValue: ShutdownRegistry { ShutdownRegistry() } + + private var operations: [@Sendable () async -> Void] = [] + private var shutdownTask: Task? + + public init() {} + + public func register(_ operation: @escaping @Sendable () async -> Void) async { + if shutdownTask != nil { + await operation() + } else { + operations.append(operation) + } + } + + /// Runs cleanup in reverse registration order, once, even for concurrent callers. + public func shutdown() async { + if let shutdownTask { + await shutdownTask.value + return + } + let pending = operations.reversed() + operations.removeAll() + let task = Task { + for operation in pending { await operation() } + } + shutdownTask = task + await task.value + } +} + +extension DependencyValues { + public var shutdownRegistry: ShutdownRegistry { + get { self[ShutdownRegistry.self] } + set { self[ShutdownRegistry.self] = newValue } + } +} diff --git a/Sources/XKit/DeveloperServices/OpenAPI/LoggingMiddleware.swift b/Sources/XKit/DeveloperServices/OpenAPI/LoggingMiddleware.swift index 70b71879..a459a91c 100644 --- a/Sources/XKit/DeveloperServices/OpenAPI/LoggingMiddleware.swift +++ b/Sources/XKit/DeveloperServices/OpenAPI/LoggingMiddleware.swift @@ -1,3 +1,4 @@ +import XUtils import Foundation import OpenAPIRuntime import HTTPTypes @@ -23,7 +24,7 @@ struct LoggingMiddleware: ClientMiddleware { ) != nil else { return (response, body) } - print("\n\(operationID) response status -> \(response.status)") + d.out.print("\n\(operationID) response status -> \(response.status)") if let unwrapped = body { let data = try await Data(collecting: unwrapped, upTo: .max) @@ -31,7 +32,7 @@ struct LoggingMiddleware: ClientMiddleware { body = .init(data) let text = String(decoding: data, as: UTF8.self) - print("\(operationID) response body -> \(text)") + d.out.print("\(operationID) response body -> \(text)") } return (response, body) diff --git a/Sources/XKit/GrandSlam/Operations/GrandSlamTwoFactorAuthenticateOperation.swift b/Sources/XKit/GrandSlam/Operations/GrandSlamTwoFactorAuthenticateOperation.swift index 64f362b6..6fc70799 100644 --- a/Sources/XKit/GrandSlam/Operations/GrandSlamTwoFactorAuthenticateOperation.swift +++ b/Sources/XKit/GrandSlam/Operations/GrandSlamTwoFactorAuthenticateOperation.swift @@ -6,6 +6,7 @@ // Copyright © 2019 Kabir Oberai. All rights reserved. // +import XUtils import Foundation public protocol TwoFactorAuthDelegate: AnyObject, Sendable { @@ -30,7 +31,7 @@ struct GrandSlamTwoFactorAuthenticateOperation { do { try await client.send(GrandSlamSMSAuthRequest(loginData: loginData, phoneNumberID: phoneNumberID)) } catch let error as HTTPResponseError where error.status.code == 403 { - print(""" + d.out.print(""" warning: Could not send verification code via SMS. Falling back to trusted device authentication... """) try await performTrustedDeviceAuth() @@ -62,7 +63,7 @@ struct GrandSlamTwoFactorAuthenticateOperation { do { try await client.send(request) } catch let error as HTTPResponseError where error.status.code == 403 { - print(""" + d.out.print(""" warning: Could not send verification code. Generate one manually. See https://github.com/xtool-org/xtool/discussions/368 """) diff --git a/Sources/XKit/Installation/ProfileManager.swift b/Sources/XKit/Installation/ProfileManager.swift index 36dea36b..8dc1ee04 100644 --- a/Sources/XKit/Installation/ProfileManager.swift +++ b/Sources/XKit/Installation/ProfileManager.swift @@ -7,6 +7,7 @@ // import Foundation +import XUtils import SwiftyMobileDevice public final class ProfileManager { @@ -53,16 +54,16 @@ public final class ProfileManager { for (bundleID, profiles) in toInstall { guard profiles.count == 1 else { - print("Unexpected # of profiles for \(bundleID): \(profiles.count). Skipping.") + d.out.print("Unexpected # of profiles for \(bundleID): \(profiles.count). Skipping.") continue } let profile = profiles[0] let installedProfiles = installed[bundleID] ?? [] for installedProfile in installedProfiles { - print("Removing \(installedProfile.1.uuid) for \(bundleID)") + d.out.print("Removing \(installedProfile.1.uuid) for \(bundleID)") try client.removeProfile(withUUID: installedProfile.1.uuid) } - print("Installing profile with UUID \(profile.1.uuid)") + d.out.print("Installing profile with UUID \(profile.1.uuid)") try client.install(profile: profile.0.data()) } } diff --git a/Sources/XToolSupport/AuthCommand.swift b/Sources/XToolSupport/AuthCommand.swift index 4a88b91d..6916ad66 100644 --- a/Sources/XToolSupport/AuthCommand.swift +++ b/Sources/XToolSupport/AuthCommand.swift @@ -1,3 +1,5 @@ +import XUtils +import XCLIUtils import Foundation import XKit import ArgumentParser @@ -27,7 +29,7 @@ struct AuthOperation { func run() async throws { if let token = try? AuthToken.saved(), !logoutFromExisting { if !quiet { - print("Logged in.\n\(token)") + d.out.print("Logged in.\n\(token)") } return } @@ -52,7 +54,7 @@ struct AuthOperation { } try token.save() - print("Logged in.\n\(token)") + d.out.print("Logged in.\n\(token)") } private func logInWithKey() async throws -> AuthToken { @@ -76,7 +78,7 @@ struct AuthOperation { } private func logInWithPassword() async throws -> AuthToken { - print(""" + 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 @@ -96,7 +98,7 @@ struct AuthOperation { throw Console.Error("Password cannot be empty.") } - print("Logging in...") + d.out.print("Logging in...") let authDelegate = XToolAuthDelegate() let manager = DeveloperServicesLoginManager() @@ -175,15 +177,15 @@ struct AuthLogoutCommand: AsyncParsableCommand { func run() async throws { if (try? AuthToken.saved()) != nil { try AuthToken.clear() - print("Logged out") + d.out.print("Logged out") } else { - print("Already logged out") + d.out.print("Already logged out") } if reset2FA { @Dependency(\.anisetteDataProvider) var anisetteProvider await anisetteProvider.resetProvisioning() - print("Forgot device") + d.out.print("Forgot device") } } } @@ -196,9 +198,9 @@ struct AuthStatusCommand: AsyncParsableCommand { func run() async throws { if let token = try? AuthToken.saved() { - print("Logged in.\n\(token)") + d.out.print("Logged in.\n\(token)") } else { - print("Logged out") + d.out.print("Logged out") } } } diff --git a/Sources/XToolSupport/AuthToken.swift b/Sources/XToolSupport/AuthToken.swift index 0319a7bf..e0f591a6 100644 --- a/Sources/XToolSupport/AuthToken.swift +++ b/Sources/XToolSupport/AuthToken.swift @@ -1,3 +1,5 @@ +import XCLIUtils +import XUtils import Foundation import XKit import Dependencies diff --git a/Sources/XToolSupport/ConnectionOptions.swift b/Sources/XToolSupport/ConnectionOptions.swift index 44aed5eb..7180ea90 100644 --- a/Sources/XToolSupport/ConnectionOptions.swift +++ b/Sources/XToolSupport/ConnectionOptions.swift @@ -1,3 +1,5 @@ +import XUtils +import XCLIUtils import Foundation import XKit import ArgumentParser @@ -17,7 +19,7 @@ struct ConnectionOptions: ParsableArguments { extension ConnectionOptions.WithoutSearchMode { func client(searchMode search: ClientDevice.SearchMode) async throws -> ClientDevice { - print("Waiting for device to be connected...") + d.out.print("Waiting for device to be connected...") let stream = try await ClientDevice.search(mode: search) for await devices in stream { diff --git a/Sources/XToolSupport/DSCommands/DSCertificatesCommand.swift b/Sources/XToolSupport/DSCommands/DSCertificatesCommand.swift index 790c02ec..9912caca 100644 --- a/Sources/XToolSupport/DSCommands/DSCertificatesCommand.swift +++ b/Sources/XToolSupport/DSCommands/DSCertificatesCommand.swift @@ -1,3 +1,4 @@ +import XUtils import Foundation import ArgumentParser import XKit @@ -33,57 +34,57 @@ struct DSCertificatesListCommand: AsyncParsableCommand { .reduce(into: [], +=) for certificate in certificates { - print("- id: \(certificate.id)") + d.out.print("- id: \(certificate.id)") guard let attributes = certificate.attributes else { continue } if let name = attributes.name { - print(" name: \(name)") + d.out.print(" name: \(name)") } if let certificateType = attributes.certificateType { - print(" type: \(certificateType.rawValue)") + d.out.print(" type: \(certificateType.rawValue)") } if let displayName = attributes.displayName { - print(" display name: \(displayName)") + d.out.print(" display name: \(displayName)") } if let serialNumber = attributes.serialNumber { - print(" serial number: \(serialNumber)") + d.out.print(" serial number: \(serialNumber)") } if let platform = attributes.platform { - print(" platform: \(platform.rawValue)") + d.out.print(" platform: \(platform.rawValue)") } if let expirationDate = attributes.expirationDate { - print(" expiry: \(expirationDate.formatted(.dateTime))") + d.out.print(" expiry: \(expirationDate.formatted(.dateTime))") } if let contentString = attributes.certificateContent { guard let content = Data(base64Encoded: contentString) else { - print(" content: error: bad base64") + d.out.print(" content: error: bad base64") continue } do { let certificate = try Certificate(data: content) - print(" content:") + d.out.print(" content:") switch Result(catching: { try certificate.developerIdentity() }) { case .success(let id): - print(" common name: \(id)") + d.out.print(" common name: \(id)") case .failure(let error): - print(" common name: error: \(error)") + d.out.print(" common name: error: \(error)") } switch Result(catching: { try certificate.teamID() }) { case .success(let teamID): - print(" team id: \(teamID)") + d.out.print(" team id: \(teamID)") case .failure(let error): - print(" team id: error: \(error)") + d.out.print(" team id: error: \(error)") } } catch { - print(" content: error: \(error)") + d.out.print(" content: error: \(error)") } } } diff --git a/Sources/XToolSupport/DSCommands/DSCommand.swift b/Sources/XToolSupport/DSCommands/DSCommand.swift index 58b7323a..aca95610 100644 --- a/Sources/XToolSupport/DSCommands/DSCommand.swift +++ b/Sources/XToolSupport/DSCommands/DSCommand.swift @@ -1,3 +1,4 @@ +import XUtils import Foundation import XKit import ArgumentParser @@ -13,7 +14,7 @@ struct DSAnisetteCommand: AsyncParsableCommand { @Dependency(\.anisetteDataProvider) var provider let res = try await provider.fetchAnisetteData() for (key, value) in res.dictionary.sorted(by: { $0.key < $1.key }) { - print("\(key): \(value)") + d.out.print("\(key): \(value)") } } } diff --git a/Sources/XToolSupport/DSCommands/DSDevicesCommand.swift b/Sources/XToolSupport/DSCommands/DSDevicesCommand.swift index 87b7cc5f..86e51009 100644 --- a/Sources/XToolSupport/DSCommands/DSDevicesCommand.swift +++ b/Sources/XToolSupport/DSCommands/DSDevicesCommand.swift @@ -1,3 +1,4 @@ +import XUtils import Foundation import ArgumentParser import XKit @@ -32,37 +33,37 @@ struct DSDevicesListCommand: AsyncParsableCommand { .reduce(into: [], +=) for device in devices { - print("- id: \(device.id)") + d.out.print("- id: \(device.id)") guard let attributes = device.attributes else { continue } if let name = attributes.name { - print(" name: \(name)") + d.out.print(" name: \(name)") } if let platform = attributes.platform { - print(" platform: \(platform.rawValue)") + d.out.print(" platform: \(platform.rawValue)") } if let udid = attributes.udid { - print(" udid: \(udid)") + d.out.print(" udid: \(udid)") } if let deviceClass = attributes.deviceClass { - print(" device class: \(deviceClass.rawValue)") + d.out.print(" device class: \(deviceClass.rawValue)") } if let status = attributes.status { - print(" status: \(status.rawValue)") + d.out.print(" status: \(status.rawValue)") } if let model = attributes.model { - print(" model: \(model)") + d.out.print(" model: \(model)") } if let addedDate = attributes.addedDate { - print(" added date: \(addedDate.formatted(.dateTime))") + d.out.print(" added date: \(addedDate.formatted(.dateTime))") } } } diff --git a/Sources/XToolSupport/DSCommands/DSIdentifiersCommand.swift b/Sources/XToolSupport/DSCommands/DSIdentifiersCommand.swift index 38150ffc..5e2a4ab4 100644 --- a/Sources/XToolSupport/DSCommands/DSIdentifiersCommand.swift +++ b/Sources/XToolSupport/DSCommands/DSIdentifiersCommand.swift @@ -1,3 +1,4 @@ +import XUtils import Foundation import ArgumentParser import XKit @@ -32,21 +33,21 @@ struct DSIdentifiersListCommand: AsyncParsableCommand { .reduce(into: [], +=) for bundleID in bundleIDs { - print("- id: \(bundleID.id)") + d.out.print("- id: \(bundleID.id)") guard let attributes = bundleID.attributes else { continue } if let name = attributes.name { - print(" name: \(name)") + d.out.print(" name: \(name)") } if let identifier = attributes.identifier { - print(" identifier: \(identifier)") + d.out.print(" identifier: \(identifier)") } if let platform = attributes.platform { - print(" platform: \(platform.rawValue)") + d.out.print(" platform: \(platform.rawValue)") } if let seedId = attributes.seedId { - print(" seedId: \(seedId)") + d.out.print(" seedId: \(seedId)") } } } diff --git a/Sources/XToolSupport/DSCommands/DSProfilesCommand.swift b/Sources/XToolSupport/DSCommands/DSProfilesCommand.swift index 680c148f..69221ad7 100644 --- a/Sources/XToolSupport/DSCommands/DSProfilesCommand.swift +++ b/Sources/XToolSupport/DSCommands/DSProfilesCommand.swift @@ -1,3 +1,4 @@ +import XUtils import Foundation import ArgumentParser import XKit @@ -35,64 +36,64 @@ struct DSProfilesListCommand: AsyncParsableCommand { .reduce(into: [], +=) for profile in profiles { - print("- id: \(profile.id)") + d.out.print("- id: \(profile.id)") guard let attributes = profile.attributes else { continue } if let name = attributes.name { - print(" name: \(name)") + d.out.print(" name: \(name)") } if let platform = attributes.platform { - print(" platform: \(platform.rawValue)") + d.out.print(" platform: \(platform.rawValue)") } if let profileType = attributes.profileType { - print(" profile type: \(profileType.rawValue)") + d.out.print(" profile type: \(profileType.rawValue)") } if let profileState = attributes.profileState { - print(" profile state: \(profileState.rawValue)") + d.out.print(" profile state: \(profileState.rawValue)") } if let uuid = attributes.uuid { - print(" uuid: \(uuid)") + d.out.print(" uuid: \(uuid)") } if let createdDate = attributes.createdDate { - print(" created date: \(createdDate.formatted(.dateTime))") + d.out.print(" created date: \(createdDate.formatted(.dateTime))") } if let expirationDate = attributes.expirationDate { - print(" expiration date: \(expirationDate.formatted(.dateTime))") + d.out.print(" expiration date: \(expirationDate.formatted(.dateTime))") } if let contentString = attributes.profileContent { guard let contentData = Data(base64Encoded: contentString) else { - print(" content: error: bad base64") + d.out.print(" content: error: bad base64") continue } do { let profile = try Mobileprovision(data: contentData) let digest = try profile.digest() - print(" team identifiers:") + d.out.print(" team identifiers:") for teamID in digest.teamIdentifiers { - print(" - \(teamID.rawValue)") + d.out.print(" - \(teamID.rawValue)") } - print(" certificates:") + d.out.print(" certificates:") for certificate in digest.certificates { - print(" - \(certificate.serialNumber())") + d.out.print(" - \(certificate.serialNumber())") } - print(" devices:") + d.out.print(" devices:") for device in digest.devices { - print(" - \(device)") + d.out.print(" - \(device)") } let entitlements = try? String(decoding: Self.encoder.encode(digest.entitlements), as: UTF8.self) - print(" entitlements: \(entitlements ?? "")") + d.out.print(" entitlements: \(entitlements ?? "")") } catch { - print(" content: error: \(error)") + d.out.print(" content: error: \(error)") } } } diff --git a/Sources/XToolSupport/DSCommands/DSTeamsCommand.swift b/Sources/XToolSupport/DSCommands/DSTeamsCommand.swift index 704d4712..01bebcb7 100644 --- a/Sources/XToolSupport/DSCommands/DSTeamsCommand.swift +++ b/Sources/XToolSupport/DSCommands/DSTeamsCommand.swift @@ -1,3 +1,5 @@ +import XCLIUtils +import XUtils import Foundation import ArgumentParser import XKit @@ -27,7 +29,7 @@ struct DSTeamsListCommand: AsyncParsableCommand { } let client = DeveloperServicesClient(authData: authData) let teams: [DeveloperServicesTeam] = try await client.send(DeveloperServicesListTeamsRequest()) - print( + d.out.print( teams.map { "\($0.name) [\($0.status)]: \($0.id.rawValue)" + $0.memberships.map { "\n- \($0.name) (\($0.platform))" }.joined() diff --git a/Sources/XToolSupport/DevBSPCommand.swift b/Sources/XToolSupport/DevBSPCommand.swift index 4d3b9f5f..0bf50a20 100644 --- a/Sources/XToolSupport/DevBSPCommand.swift +++ b/Sources/XToolSupport/DevBSPCommand.swift @@ -1,3 +1,5 @@ +import XCLIUtils +import XUtils import ArgumentParser import Foundation import XKit diff --git a/Sources/XToolSupport/DevCommand.swift b/Sources/XToolSupport/DevCommand.swift index 369bfa8d..801f341d 100644 --- a/Sources/XToolSupport/DevCommand.swift +++ b/Sources/XToolSupport/DevCommand.swift @@ -1,3 +1,4 @@ +import XCLIUtils import Foundation import ArgumentParser import PackLib @@ -38,7 +39,7 @@ struct PackOperation { try await EnsureSDKOperation(quiet: true).run() - print("Planning...") + d.out.print("Planning...") let schema: PackSchema let configPath = URL(fileURLWithPath: "xtool.yml") @@ -46,7 +47,7 @@ struct PackOperation { schema = try await PackSchema(url: configPath) } else { schema = .default - print(""" + d.out.print(""" warning: Could not locate configuration file '\(configPath.path)'. Using default \ configuration with 'com.example' organization ID. """) @@ -91,7 +92,7 @@ struct PackOperation { } return try await group.reduce(into: [:]) { $0[$1.0] = $1.1 } } - print("Applying entitlements...") + d.out.print("Applying entitlements...") try await Signer.first().sign( app: bundle, identity: .adhoc, @@ -112,7 +113,7 @@ struct DevXcodeCommand: AsyncParsableCommand { func run() async throws { let result = try await PackOperation(xcode: true).run() - print("Wrote to \(result.path)") + d.out.print("Wrote to \(result.path)") } } @@ -167,12 +168,12 @@ struct DevBuildCommand: AsyncParsableCommand { delegate: installDelegate ) do { - defer { print() } + defer { d.out.print() } try await installer.signInPlace(app: url) } catch let error as CancellationError { throw error } catch { - print("Error: \(error)") + d.out.print("Error: \(error)") throw ExitCode.failure } } @@ -187,19 +188,19 @@ struct DevBuildCommand: AsyncParsableCommand { let ipaURL = try await compressor.compress(directory: payloadDir) { progress in if let progress { let percent = Int(progress * 100) - print("\rPackaging... \(percent)%", terminator: "") + d.out.print("\rPackaging... \(percent)%", terminator: "") } else { - print("\rPackaging...", terminator: "") + d.out.print("\rPackaging...", terminator: "") } } - print() + d.out.print() try? FileManager.default.removeItem(at: finalURL) try FileManager.default.moveItem(at: ipaURL, to: finalURL) } else { finalURL = url } - print("Wrote to \(finalURL.path)") + d.out.print("Wrote to \(finalURL.path)") } } @@ -251,7 +252,7 @@ struct DevRunCommand: AsyncParsableCommand { let token = try AuthToken.saved() let client = try await connectionOptions.client() - print("Installing to device: \(client.deviceName) (udid: \(client.udid))") + d.out.print("Installing to device: \(client.deviceName) (udid: \(client.udid))") let installDelegate = XToolInstallerDelegate() let installer = IntegratedInstaller( @@ -259,7 +260,7 @@ struct DevRunCommand: AsyncParsableCommand { delegate: installDelegate ) - defer { print() } + defer { d.out.print() } do { try await installer.install( @@ -271,7 +272,7 @@ struct DevRunCommand: AsyncParsableCommand { } catch let error as CancellationError { throw error } catch { - print("\nError: \(error)") + d.out.print("\nError: \(error)") throw ExitCode.failure } } @@ -309,7 +310,7 @@ struct SimInstallOperation { output: .discarded ) .checkSuccess() - print("Installed to simulator") + d.out.print("Installed to simulator") } } #endif diff --git a/Sources/XToolSupport/DevicesCommand.swift b/Sources/XToolSupport/DevicesCommand.swift index 2a2c2d38..93f0f354 100644 --- a/Sources/XToolSupport/DevicesCommand.swift +++ b/Sources/XToolSupport/DevicesCommand.swift @@ -1,3 +1,4 @@ +import XUtils import Foundation import XKit import ArgumentParser @@ -31,10 +32,10 @@ struct DevicesCommand: AsyncParsableCommand { guard wait else { break } if shouldPrint { shouldPrint = false - print("Waiting for devices to be connected...") + d.out.print("Waiting for devices to be connected...") } } - print(foundDevices.map { "\($0.deviceName) [\($0.connectionType)]: \($0.udid)" }.joined(separator: "\n")) + d.out.print(foundDevices.map { "\($0.deviceName) [\($0.connectionType)]: \($0.udid)" }.joined(separator: "\n")) } } diff --git a/Sources/XToolSupport/InstallCommand.swift b/Sources/XToolSupport/InstallCommand.swift index 6ac024e2..fa64d791 100644 --- a/Sources/XToolSupport/InstallCommand.swift +++ b/Sources/XToolSupport/InstallCommand.swift @@ -1,3 +1,4 @@ +import XUtils import Foundation import XKit import SwiftyMobileDevice @@ -20,7 +21,7 @@ struct InstallCommand: AsyncParsableCommand { let client = try await connectionOptions.client() - print("Installing to device: \(client.deviceName) (udid: \(client.udid))") + d.out.print("Installing to device: \(client.deviceName) (udid: \(client.udid))") let installDelegate = XToolInstallerDelegate() let installer = IntegratedInstaller( @@ -35,11 +36,11 @@ struct InstallCommand: AsyncParsableCommand { lookupMode: .only(client.connectionType), configureDevice: false, ) - print("\nSuccessfully installed!") + d.out.print("\nSuccessfully installed!") } catch let error as CancellationError { throw error } catch { - print("\nError: \(error)") + d.out.print("\nError: \(error)") throw ExitCode.failure } } diff --git a/Sources/XToolSupport/LSPConfig.swift b/Sources/XToolSupport/LSPConfig.swift index b7482b56..914da43c 100644 --- a/Sources/XToolSupport/LSPConfig.swift +++ b/Sources/XToolSupport/LSPConfig.swift @@ -1,3 +1,4 @@ +import XUtils import Foundation import PackLib @@ -34,7 +35,7 @@ enum LSPConfig { do { try await ensure(using: schema, quiet: quiet, currentDirectory: directory) } catch { - print("warning: failed to validate LSP configuration: \(error)") + d.out.print("warning: failed to validate LSP configuration: \(error)") } } @@ -80,7 +81,7 @@ enum LSPConfig { } if !quiet { - print(""" + d.out.print(""" [LSP] Created \(primary.0) for enhanced IDE support. You may need to restart your editor for the change to take effect. You can disable this with `skipLSP: true` in xtool.yml. diff --git a/Sources/XToolSupport/LaunchCommand.swift b/Sources/XToolSupport/LaunchCommand.swift index c6561289..1fb72691 100644 --- a/Sources/XToolSupport/LaunchCommand.swift +++ b/Sources/XToolSupport/LaunchCommand.swift @@ -1,3 +1,5 @@ +import XCLIUtils +import XUtils import Foundation import XKit import SwiftyMobileDevice @@ -33,7 +35,7 @@ struct LaunchCommand: AsyncParsableCommand { throw Console.Error("Could not find an installed app with bundle ID '\(bundleID)'") } - print("Launching \(executable.lastPathComponent)...") + d.out.print("Launching \(executable.lastPathComponent)...") let debugserver = try DebugserverClient(device: client.device, label: "xtool") guard try debugserver.launch(executable: executable, arguments: args) == "OK" else { diff --git a/Sources/XToolSupport/NewCommand.swift b/Sources/XToolSupport/NewCommand.swift index 29d0ab71..7ab839df 100644 --- a/Sources/XToolSupport/NewCommand.swift +++ b/Sources/XToolSupport/NewCommand.swift @@ -1,3 +1,5 @@ +import XUtils +import XCLIUtils import ArgumentParser import Foundation import PackLib @@ -61,7 +63,7 @@ struct NewCommand: AsyncParsableCommand { throw Console.Error("Cannot create \(name): a file already exists at that path.") } - print("Creating package: \(name)") + d.out.print("Creating package: \(name)") let moduleName = name.replacingOccurrences(of: "-", with: "_") @@ -162,7 +164,7 @@ struct NewCommand: AsyncParsableCommand { at: url.deletingLastPathComponent(), withIntermediateDirectories: true ) - print("Creating \(path)") + d.out.print("Creating \(path)") try "\(contents)\n".write(to: url, atomically: true, encoding: .utf8) } @@ -172,8 +174,8 @@ struct NewCommand: AsyncParsableCommand { currentDirectory: baseURL, ) - print("\nFinished generating project \(name). Next steps:") - print("- Enter the directory with `cd \(name)`") - print("- Build and run with `xtool dev`") + d.out.print("\nFinished generating project \(name). Next steps:") + d.out.print("- Enter the directory with `cd \(name)`") + d.out.print("- Build and run with `xtool dev`") } } diff --git a/Sources/XToolSupport/SDKBuilder.swift b/Sources/XToolSupport/SDKBuilder.swift index c0632c48..f9f7b92d 100644 --- a/Sources/XToolSupport/SDKBuilder.swift +++ b/Sources/XToolSupport/SDKBuilder.swift @@ -1,3 +1,4 @@ +import XCLIUtils import Foundation import Dependencies import libunxip @@ -135,13 +136,13 @@ struct SDKBuilder { let iPhoneSimSDK = try sdk(platform: "iPhoneSimulator", prefix: "iPhoneSimulator") let macOSSDK = try sdk(platform: "MacOSX", prefix: "MacOSX") - print(""" + d.out.print(""" - \(iPhoneOSSDK) - \(iPhoneSimSDK) - \(macOSSDK) """) - print("[Writing metadata]") + d.out.print("[Writing metadata]") try """ { "schemaVersion": "1.0", @@ -246,7 +247,7 @@ struct SDKBuilder { if let envOverride, let path = ProcessInfo.processInfo.environment[envOverride] { let source = URL(fileURLWithPath: path) if source.dirExists { - print("[Copying \(displayName)] \(source.path)") + d.out.print("[Copying \(displayName)] \(source.path)") try await Subprocess.run( .name("cp"), arguments: ["-RL", "\(source.path)/.", "."], @@ -267,7 +268,7 @@ struct SDKBuilder { return } - print("[Extracting \(displayName)] \(source.path)") + d.out.print("[Extracting \(displayName)] \(source.path)") try await Subprocess.run( .name("tar"), arguments: ["xzf", source.path], @@ -289,7 +290,7 @@ struct SDKBuilder { case .unknown: nil } - defer { print() } + defer { d.out.print() } try await Subprocess.run( .name("tar"), arguments: ["xzf", "-"], @@ -308,8 +309,7 @@ struct SDKBuilder { guard let length else { continue } let progress = Int(Double(totalWritten) / Double(length) * 100) - print("\r[Downloading \(displayName)] \(progress)%", terminator: "") - fflush(stdoutSafe) + d.out.print("\r[Downloading \(displayName)] \(progress)%", terminator: "", flush: true) } } try await execution.standardInputWriter.finish() @@ -525,7 +525,7 @@ struct SDKBuilder { let sourceContentsDir = source.appendingPathComponent("Contents") let expectedContentsDir = expectedAppDir.appendingPathComponent("Contents") try FileManager.default.createDirectory(at: expectedContentsDir, withIntermediateDirectories: true) - print("[Copying Xcode.app] This might take a minute...") + d.out.print("[Copying Xcode.app] This might take a minute...") for child in Self.expectedXcodeChildren { let expectedFile = expectedContentsDir.appendingPathComponent(child) let sourceFile = sourceContentsDir.appendingPathComponent(child) @@ -559,13 +559,11 @@ struct SDKBuilder { count += 1 if let wanted { let progress = Int(Double(count) / Double(wanted) * 100) - print("\r[Installing SDKs] \(progress)%", terminator: "") - fflush(stdoutSafe) + d.out.print("\r[Installing SDKs] \(progress)%", terminator: "", flush: true) } if count % 100 == 0 { if wanted == nil { - print("\r[Installing SDKs] Installed \(count) files", terminator: "") - fflush(stdoutSafe) + d.out.print("\r[Installing SDKs] Installed \(count) files", terminator: "", flush: true) } await Task.yield() } @@ -589,16 +587,16 @@ struct SDKBuilder { if wanted != nil { // otherwise the last log might say 99% - print("\r[Installing SDKs] 100%", terminator: "") + d.out.print("\r[Installing SDKs] 100%", terminator: "") } - print() + d.out.print() if let cleanupStageDir { - print("[Cleaning up]") + d.out.print("[Cleaning up]") try FileManager.default.removeItem(at: cleanupStageDir) } - print("[Finalizing SDKs]") + d.out.print("[Finalizing SDKs]") for platform in ["iPhoneOS", "MacOSX", "iPhoneSimulator"] { let lib = "../../../../../Library" @@ -749,8 +747,7 @@ struct SDKBuilder { for try await chunk in observer { read += chunk.count let progress = Int(Double(read) / Double(length) * 100) - print("\r[Extracting XIP] \(progress)%", terminator: "") - fflush(stdoutSafe) + d.out.print("\r[Extracting XIP] \(progress)%", terminator: "", flush: true) if read == length { break } } }() @@ -786,7 +783,7 @@ struct SDKBuilder { } _ = try await readTask - print() + d.out.print() return wanted } diff --git a/Sources/XToolSupport/SDKCommand.swift b/Sources/XToolSupport/SDKCommand.swift index 39aea7a6..ff9d8de1 100644 --- a/Sources/XToolSupport/SDKCommand.swift +++ b/Sources/XToolSupport/SDKCommand.swift @@ -1,3 +1,4 @@ +import XCLIUtils import Foundation import XKit import ArgumentParser @@ -51,7 +52,7 @@ struct DevSDKBuildCommand: AsyncParsableCommand { let output = URL(fileURLWithPath: outputDir, isDirectory: true).appending(path: "darwin.xtoolsdk") let builder = SDKBuilder(input: input, output: output, arch: builderArch, mode: .buildSlim) try await builder.buildSDK() - print("Built SDK at \(output.path). You can install it with `xtool sdk install`.") + d.out.print("Built SDK at \(output.path). You can install it with `xtool sdk install`.") } } @@ -122,7 +123,7 @@ struct DevSDKRemoveCommand: AsyncParsableCommand { throw Console.Error("Cannot remove SDK: no Darwin SDK installed") } try sdk.remove() - print("Uninstalled SDK") + d.out.print("Uninstalled SDK") } } @@ -134,12 +135,12 @@ struct DevSDKStatusCommand: AsyncParsableCommand { func run() async throws { if let sdk = try DarwinSDK.current() { - print("Darwin SDK is installed") - print(" Path: \(sdk.bundle.path)") - print(" Flavor: \(sdk.flavor)") - print(" Version: \(sdk.version)") + d.out.print("Darwin SDK is installed") + d.out.print(" Path: \(sdk.bundle.path)") + d.out.print(" Flavor: \(sdk.flavor)") + d.out.print(" Version: \(sdk.version)") } else { - print("Not installed") + d.out.print("Not installed") } } } @@ -156,14 +157,14 @@ struct EnsureSDKOperation { func run() async throws { #if os(macOS) if !quiet { - print("Skipping Darwin SDK setup since we're on macOS.") + d.out.print("Skipping Darwin SDK setup since we're on macOS.") } #else let sdk = try DarwinSDK.current() switch sdk.map({ ($0.isUpToDate(), $0.flavor) }) { case (true, _)?: // swiftlint:disable:this optional_enum_case_matching if !quiet { - print("Darwin SDK is up to date.") + d.out.print("Darwin SDK is up to date.") } case (false, .slim)?: // swiftlint:disable:this optional_enum_case_matching throw Console.Error(""" @@ -174,10 +175,10 @@ struct EnsureSDKOperation { xtool sdk install [--slim] """) case (false, .normal)?: // swiftlint:disable:this optional_enum_case_matching - print("Darwin SDK is out of date. Rebuilding...") + d.out.print("Darwin SDK is out of date. Rebuilding...") try await UpdateSDKOperation().run() case (false, .legacy)?: // swiftlint:disable:this optional_enum_case_matching - print(""" + d.out.print(""" Darwin SDK is incompatible: built with an older version of xtool. Requesting re-install. This is a one-time rebuild; after this, @@ -186,7 +187,7 @@ struct EnsureSDKOperation { """) try await generateSDK() case nil: - print("Now generating the Darwin SDK.\n") + d.out.print("Now generating the Darwin SDK.\n") try await generateSDK() } @@ -217,13 +218,13 @@ struct InstallSDKOperation { func run() async throws { #if os(macOS) - print("Skipping SDK install; the iOS SDK ships with Xcode on macOS") + d.out.print("Skipping SDK install; the iOS SDK ships with Xcode on macOS") #else let temporaryBundle = try DarwinSDK.prepareTemporaryBundle() if path.hasSuffix(".xtoolsdk") { - print("Installing prebuilt SDK...") + d.out.print("Installing prebuilt SDK...") try await FileManager.default.copyItem(at: URL(filePath: path), to: temporaryBundle.url, preserveOwner: false) } else { // validate input before removing existing SDK @@ -236,7 +237,7 @@ struct InstallSDKOperation { } if let sdk = try DarwinSDK.current() { - print("Removing existing SDK...") + d.out.print("Removing existing SDK...") try sdk.remove() } @@ -248,7 +249,7 @@ struct InstallSDKOperation { struct UpdateSDKOperation { func run() async throws { #if os(macOS) - print("Skipping SDK install; the iOS SDK ships with Xcode on macOS") + d.out.print("Skipping SDK install; the iOS SDK ships with Xcode on macOS") #else guard let existing = try DarwinSDK.current() else { throw Console.Error("Could not locate existing SDK; cannot perform update.") @@ -280,7 +281,7 @@ struct UpdateSDKOperation { try existing.remove() try await temporaryBundle.install() - print("Updated SDK") + d.out.print("Updated SDK") #endif } } diff --git a/Sources/XToolSupport/UninstallCommand.swift b/Sources/XToolSupport/UninstallCommand.swift index 2bdbc5be..08f88d6f 100644 --- a/Sources/XToolSupport/UninstallCommand.swift +++ b/Sources/XToolSupport/UninstallCommand.swift @@ -1,3 +1,4 @@ +import XUtils import Foundation import XKit import SwiftyMobileDevice @@ -24,9 +25,9 @@ struct UninstallCommand: AsyncParsableCommand { progress: { _ in } ) } catch { - print("Failed: \(error)") + d.out.print("Failed: \(error)") return } - print("Success!") + d.out.print("Success!") } } diff --git a/Sources/XToolSupport/XTool.swift b/Sources/XToolSupport/XTool.swift index 2cec336e..4b4cbeb3 100644 --- a/Sources/XToolSupport/XTool.swift +++ b/Sources/XToolSupport/XTool.swift @@ -1,7 +1,9 @@ +import XCLIUtils import Foundation import XKit import ArgumentParser import XUtils +import Dependencies public enum XTool { public static func run(arguments: [String]? = nil) async throws { @@ -56,6 +58,8 @@ extension ParsableCommand where Self: SendableMetatype { for await _ in canStart {} guard !Task.isCancelled else { return } do { + @Dependency(\.shutdownRegistry) var shutdownRegistry + defer { await shutdownRegistry.shutdown() } var command = try self.parseAsRoot(arguments) if var asyncCommand = command as? AsyncParsableCommand { try await asyncCommand.run() @@ -63,12 +67,12 @@ extension ParsableCommand where Self: SendableMetatype { try command.run() } } catch where error is CancellationError || Task.isCancelled { - print("Cancelled.") + d.out.print("Cancelled.", stream: .stderr) self.exit() } catch { if ProcessInfo.processInfo.environment["XTL_DEBUG_ERRORS"] != nil { - print("ERROR DETAILS:") - print(String(reflecting: error)) + d.out.print("ERROR DETAILS:") + d.out.print(String(reflecting: error)) } self.exit(withError: error) } diff --git a/Sources/XToolSupport/XToolInstallerDelegate.swift b/Sources/XToolSupport/XToolInstallerDelegate.swift index 1c5bcc5b..2f1cea47 100644 --- a/Sources/XToolSupport/XToolInstallerDelegate.swift +++ b/Sources/XToolSupport/XToolInstallerDelegate.swift @@ -1,6 +1,9 @@ +import XCLIUtils import Foundation import XKit import ConcurrencyExtras +import Dependencies +import XUtils final class XToolAuthDelegate: TwoFactorAuthDelegate { func fetchCode() async -> String? { @@ -9,7 +12,8 @@ final class XToolAuthDelegate: TwoFactorAuthDelegate { } actor XToolInstallerDelegate: IntegratedInstallerDelegate { - init() {} + private nonisolated let _output = Dependency(\.out) + private nonisolated var output: CommandOutput { _output.wrappedValue } private let updateTask = LockIsolated?>(nil) @@ -23,8 +27,7 @@ actor XToolInstallerDelegate: IntegratedInstallerDelegate { case nil: text = "Continuing..." } - print("\n\(text)", terminator: "") - fflush(stdoutSafe) + output.print("\n\(text)", terminator: "", flush: true) } var prevStage: String? @@ -52,19 +55,15 @@ actor XToolInstallerDelegate: IntegratedInstallerDelegate { if stage != prevStage { if let progString = progString { - print("\n[\(stage)] \(progString)", terminator: "") - fflush(stdoutSafe) + output.print("\n[\(stage)] \(progString)", terminator: "", flush: true) } else { - print("\n[\(stage)] ...", terminator: "") - fflush(stdoutSafe) + output.print("\n[\(stage)] ...", terminator: "", flush: true) } } else if progString != prevProgress { if let progString = progString { - print("\r[\(stage)] \(progString)", terminator: "") - fflush(stdoutSafe) + output.print("\r[\(stage)] \(progString)", terminator: "", flush: true) } else { - print("\r[\(stage)]", terminator: "") - fflush(stdoutSafe) + output.print("\r[\(stage)]", terminator: "", flush: true) } } } @@ -79,8 +78,8 @@ actor XToolInstallerDelegate: IntegratedInstallerDelegate { } func confirmRevocation(of certificates: [DeveloperServicesCertificate]) async -> Bool { - print("\nThe following certificates must be revoked:") - print( + output.print("\nThe following certificates must be revoked:") + output.print( certificates.map { "- \($0.attributes!.name!) (expires \($0.attributes!.expirationDate!.formatted(date: .abbreviated, time: .shortened)))" }.joined(separator: "\n") diff --git a/Sources/XUtils/CommandOutput.swift b/Sources/XUtils/CommandOutput.swift new file mode 100644 index 00000000..5faf1040 --- /dev/null +++ b/Sources/XUtils/CommandOutput.swift @@ -0,0 +1,65 @@ +// swiftlint:disable prefer_dependency_print + +import Dependencies +import Foundation +import CXKit + +/// Task-scoped command output, shared by build, packaging, and device operations. +public struct CommandOutput: DependencyKey, Sendable { + public enum Stream: String, Codable, Sendable { + case stdout + case stderr + } + + public var write: @Sendable (_ data: Data, _ stream: Stream, _ flush: Bool) -> Void + + public init(write: @escaping @Sendable (Data, Stream, Bool) -> Void) { + self.write = write + } + + public func print( + _ message: String = "", + terminator: String = "\n", + stream: Stream = .stdout, + flush: Bool = false + ) { + write(Data((message + terminator).utf8), stream, flush) + } + + @_disfavoredOverload + public func print( + _ items: Any..., + separator: String = " ", + terminator: String = "\n", + stream: Stream = .stdout, + flush: Bool = false + ) { + print( + items.map { String(describing: $0) }.joined(separator: separator), + terminator: terminator, + stream: stream, + flush: flush + ) + } + + public static var liveValue: Self { + let outputLock = NSLock() + return Self { data, stream, flush in + let descriptor: FileDescriptor = stream == .stdout ? .standardOutput : .standardError + // Keep concurrent messages together, including across partial writes. + outputLock.withLock { + try? data.withUnsafeBytes { try descriptor.writeAllWaiting($0) } + if flush { fflush(get_stdout()) } + } + } + } + + public static var testValue: Self { Self(write: unimplemented("CommandOutput.write")) } +} + +extension DependencyValues { + public var out: CommandOutput { + get { self[CommandOutput.self] } + set { self[CommandOutput.self] = newValue } + } +} diff --git a/Sources/XUtils/Dependencies+Utils.swift b/Sources/XUtils/Dependencies+Utils.swift new file mode 100644 index 00000000..cc25878f --- /dev/null +++ b/Sources/XUtils/Dependencies+Utils.swift @@ -0,0 +1,10 @@ +import Dependencies + +/// The current `DependencyValues`. +/// +/// Prefer adding `@Dependency` fields to your type when possible, as +/// they remember the value assigned during initialization. +public var d: DependencyValues { + @Dependency(\.self) var values + return values +} diff --git a/Sources/XUtils/Foundation+Utils.swift b/Sources/XUtils/Foundation+Utils.swift index c3863d35..1af45333 100644 --- a/Sources/XUtils/Foundation+Utils.swift +++ b/Sources/XUtils/Foundation+Utils.swift @@ -43,7 +43,7 @@ extension FileManager { do { try result.checkSuccess() } catch { - print("Error copying \(srcURL.path) to \(dstURL.path): \(result.standardError)") + d.out.print("Error copying \(srcURL.path) to \(dstURL.path): \(result.standardError)") throw error } return diff --git a/Sources/XUtils/System+Utils.swift b/Sources/XUtils/System+Utils.swift index df47da9f..51f7605b 100644 --- a/Sources/XUtils/System+Utils.swift +++ b/Sources/XUtils/System+Utils.swift @@ -28,6 +28,27 @@ extension FilePath { #endif extension FileDescriptor { + /// Writes every byte, waiting for capacity if the descriptor is nonblocking. + package func writeAllWaiting(_ bytes: UnsafeRawBufferPointer) throws { + var offset = 0 + while offset < bytes.count { + do { + // Swift System retries EINTR; retain the offset when a write is partial. + let count = try write(UnsafeRawBufferPointer(rebasing: bytes[offset...])) + guard count > 0 else { throw Errno.ioError } + offset += count + } catch let error as Errno where error.rawValue == EAGAIN || error.rawValue == EWOULDBLOCK { + var descriptor = pollfd(fd: rawValue, events: Int16(POLLOUT), revents: 0) + while poll(&descriptor, 1, -1) == -1 { + let error = Errno(rawValue: errno) + guard error != .interrupted else { continue } + throw error + } + // Retry write to report terminal errors (including POLLERR/POLLNVAL). + } + } + } + enum LockMode { case shared case exclusive diff --git a/Tests/XToolTests/CommandInputTests.swift b/Tests/XToolTests/CommandInputTests.swift new file mode 100644 index 00000000..e0ffdb0a --- /dev/null +++ b/Tests/XToolTests/CommandInputTests.swift @@ -0,0 +1,107 @@ +@testable import XCLIUtils +import Dependencies +import Foundation +import Testing +import XUtils + +struct CommandInputTests { + @Test(.timeLimit(.minutes(1))) + func pipeReadsBytesAndEOFWithoutClosingOriginal() async throws { + let pipe = Pipe() + let descriptor = FileDescriptor(rawValue: pipe.fileHandleForReading.fileDescriptor) + let flags = fcntl(descriptor.rawValue, F_GETFL) + let reader = StandardCommandInput(descriptor: descriptor) + defer { await reader.close() } + let bytes = Data([0, 255, 128, 10]) + try pipe.fileHandleForWriting.write(contentsOf: bytes) + try pipe.fileHandleForWriting.close() + #expect(try await reader.read() == bytes) + #expect(try await reader.read() == nil) + await reader.close() + #expect(fcntl(descriptor.rawValue, F_GETFL) == flags) + } + + @Test(.timeLimit(.minutes(1))) + func redirectedFileUsesBoundedChunks() async throws { + let path = URL(filePath: "/tmp/xtool-input-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: path) } + let bytes = Data(repeating: 255, count: StandardCommandInput.chunkSize * 2 + 7) + try bytes.write(to: path) + let descriptor = try FileDescriptor.open(FilePath(path.path), .readOnly) + defer { try? descriptor.close() } + let reader = StandardCommandInput(descriptor: descriptor) + defer { await reader.close() } + var received = Data() + while let chunk = try await reader.read() { + #expect(chunk.count <= StandardCommandInput.chunkSize) + received.append(chunk) + } + #expect(received == bytes) + #expect(try await reader.read() == nil) + await reader.close() + #expect(fcntl(descriptor.rawValue, F_GETFL) != -1) + } + + @Test(.timeLimit(.minutes(1))) + func nullInputIsEOF() async throws { + let descriptor = try FileDescriptor.open("/dev/null", .readOnly) + defer { try? descriptor.close() } + let reader = StandardCommandInput(descriptor: descriptor) + defer { await reader.close() } + #expect(try await reader.read() == nil) + } + + @Test(.timeLimit(.minutes(1))) + func closingOrCancellingUnblocksPipeRead() async throws { + for cancel in [false, true] { + let pipe = Pipe() + let descriptor = FileDescriptor(rawValue: pipe.fileHandleForReading.fileDescriptor) + let reader = StandardCommandInput(descriptor: descriptor) + let task = Task { try await reader.read() } + // Wait until NIO has opened the duplicate and the pipe read can be pending. + while fcntl(descriptor.rawValue, F_GETFL) & O_NONBLOCK == 0 { + try await Task.sleep(for: .milliseconds(1)) + } + if cancel { task.cancel() } + await reader.close() + do { + #expect(try await task.value == nil) + #expect(!cancel) + } catch is CancellationError { + #expect(cancel) + } + } + } + + #if os(macOS) + @Test(.timeLimit(.minutes(1))) + func terminalInputPreservesEchoAndCanonicalMode() async throws { + var controller: Int32 = -1 + var terminal: Int32 = -1 + try #require(openpty(&controller, &terminal, nil, nil, nil) == 0) + let controllerHandle = FileHandle(fileDescriptor: controller, closeOnDealloc: true) + let descriptor = FileDescriptor(rawValue: terminal) + defer { try? descriptor.close() } + var original = termios() + try #require(tcgetattr(terminal, &original) == 0) + let flags = fcntl(terminal, F_GETFL) + let reader = StandardCommandInput(descriptor: descriptor) + defer { await reader.close() } + try await withDependencies { + $0.commandInput = CommandInput(read: { try await reader.read() }) + $0.out.write = { _, _, flush in #expect(flush) } + } operation: { + try controllerHandle.write(contentsOf: Data("first\nsecond\n".utf8)) + #expect(try await Console.prompt("First: ") == "first") + #expect(try await Console.prompt("Second: ") == "second") + } + await reader.close() + var restored = termios() + try #require(tcgetattr(terminal, &restored) == 0) + #expect(original.c_lflag == restored.c_lflag) + #expect(original.c_iflag == restored.c_iflag) + #expect(fcntl(terminal, F_GETFL) == flags) + try controllerHandle.close() + } + #endif +} diff --git a/Tests/XToolTests/CommandOutputTests.swift b/Tests/XToolTests/CommandOutputTests.swift new file mode 100644 index 00000000..a81fe94a --- /dev/null +++ b/Tests/XToolTests/CommandOutputTests.swift @@ -0,0 +1,86 @@ +import ConcurrencyExtras +import Dependencies +import Foundation +import Testing +import XUtils + +struct CommandOutputTests { + @Test + func dependencyOutputSupportsStringAndVariadicPrinting() { + struct Output: Equatable, Sendable { + var message: String + var stream: CommandOutput.Stream + var flush: Bool + } + let received = LockIsolated<[Output]>([]) + withDependencies { + $0.out.write = { data, stream, flush in + received.withValue { + $0.append(Output(message: String(decoding: data, as: UTF8.self), stream: stream, flush: flush)) + } + } + } operation: { + d.out.print() + d.out.print("message") + d.out.print("progress", 42, separator: ": ", terminator: "", stream: .stderr, flush: true) + } + #expect(received.value == [ + Output(message: "\n", stream: .stdout, flush: false), + Output(message: "message\n", stream: .stdout, flush: false), + Output(message: "progress: 42", stream: .stderr, flush: true), + ]) + } + + @Test(.timeLimit(.minutes(1))) + func fullNonblockingPipePreservesAllBytes() async throws { + let pipe = Pipe() + let writer = FileDescriptor(rawValue: pipe.fileHandleForWriting.fileDescriptor) + let reader = FileDescriptor(rawValue: pipe.fileHandleForReading.fileDescriptor) + let flags = fcntl(writer.rawValue, F_GETFL) + try #require(flags != -1) + try #require(fcntl(writer.rawValue, F_SETFL, flags | O_NONBLOCK) == 0) + + // Fill the pipe before invoking the output writer, forcing EAGAIN. + let padding = Data(repeating: 255, count: 4096) + var prefix = Data() + while true { + do { + let count = try padding.withUnsafeBytes { try writer.write($0) } + prefix.append(padding.prefix(count)) + } catch let error as Errno where error.rawValue == EAGAIN || error.rawValue == EWOULDBLOCK { + break + } + } + let message = Data([0, 255, 128]) + Data(String(repeating: "output é 🌍\n", count: 100_000).utf8) + let writing = Task.detached { + defer { try? pipe.fileHandleForWriting.close() } + try message.withUnsafeBytes { try writer.writeAllWaiting($0) } + } + // Let the writer encounter the full pipe before draining it. + try await Task.sleep(for: .milliseconds(20)) + let received = try await Task.detached { + var received = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while true { + let count = try buffer.withUnsafeMutableBytes { try reader.read(into: $0) } + if count == 0 { return received } + received.append(contentsOf: buffer.prefix(count)) + } + }.value + try await writing.value + #expect(received == prefix + message) + #expect(fcntl(reader.rawValue, F_GETFL) != -1) + } + + @Test + func invalidDescriptorThrowsInsteadOfRaisingAnException() { + let descriptor = FileDescriptor(rawValue: -1) + #expect(throws: Errno.badFileDescriptor) { + try Data("output".utf8).withUnsafeBytes { try descriptor.writeAllWaiting($0) } + } + // Empty output does not attempt a system call. + #expect(throws: Never.self) { + try Data().withUnsafeBytes { try descriptor.writeAllWaiting($0) } + } + } +} diff --git a/Tests/XToolTests/ShutdownRegistryTests.swift b/Tests/XToolTests/ShutdownRegistryTests.swift new file mode 100644 index 00000000..14191ea2 --- /dev/null +++ b/Tests/XToolTests/ShutdownRegistryTests.swift @@ -0,0 +1,35 @@ +import Testing +import XCLIUtils + +struct ShutdownRegistryTests { + @Test + func concurrentShutdownRunsCleanupOnceInReverseOrder() async { + let registry = ShutdownRegistry() + let events = ShutdownEvents() + await registry.register { await events.append(1) } + await registry.register { await events.append(2) } + await withTaskGroup(of: Void.self) { group in + for _ in 0..<3 { group.addTask { await registry.shutdown() } } + } + #expect(await events.values == [2, 1]) + await registry.shutdown() + #expect(await events.values == [2, 1]) + } + + @Test + func registrationAfterShutdownCleansUpImmediately() async { + let registry = ShutdownRegistry() + let events = ShutdownEvents() + await registry.shutdown() + await registry.register { await events.append(1) } + #expect(await events.values == [1]) + } +} + +private actor ShutdownEvents { + var values: [Int] = [] + + func append(_ value: Int) { + values.append(value) + } +}