From f0705834d44c62dc7a6fb00f4fb6b5b2cc8d1614 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 | 16 +- Package.resolved | 11 +- Package.swift | 18 +- Sources/XCLIUtils/CommandInput.swift | 65 ++++++ .../{XToolSupport => XCLIUtils}/Console.swift | 69 ++---- Sources/XCLIUtils/DataChannel.swift | 132 ++++++++++++ Sources/XCLIUtils/DispatchInput.swift | 142 +++++++++++++ Sources/XCLIUtils/ShutdownRegistry.swift | 44 ++++ .../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 | 64 ++++++ Sources/XUtils/Dependencies+Utils.swift | 10 + Sources/XUtils/Foundation+Utils.swift | 2 +- Sources/XUtils/System+Utils.swift | 27 +++ Tests/XToolTests/CommandInputTests.swift | 103 +++++++++ Tests/XToolTests/CommandOutputTests.swift | 131 ++++++++++++ Tests/XToolTests/DataChannelTests.swift | 200 ++++++++++++++++++ Tests/XToolTests/DispatchInputTests.swift | 193 +++++++++++++++++ Tests/XToolTests/InputTestSupport.swift | 24 +++ Tests/XToolTests/ShutdownRegistryTests.swift | 41 ++++ 42 files changed, 1402 insertions(+), 206 deletions(-) create mode 100644 Sources/XCLIUtils/CommandInput.swift rename Sources/{XToolSupport => XCLIUtils}/Console.swift (56%) create mode 100644 Sources/XCLIUtils/DataChannel.swift create mode 100644 Sources/XCLIUtils/DispatchInput.swift 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/DataChannelTests.swift create mode 100644 Tests/XToolTests/DispatchInputTests.swift create mode 100644 Tests/XToolTests/InputTestSupport.swift create mode 100644 Tests/XToolTests/ShutdownRegistryTests.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 20aa64a1..70833bc1 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -42,17 +42,19 @@ opt_in_rules: analyzer_rules: - unused_import custom_rules: + prefer_dependency_print: + name: "Dependency Output" + included: '/Sources/' + excluded: '/(Tests|Examples)/' + regex: '(? Data? + + package init( + read: @escaping @Sendable (DataChannel.Limit) async throws -> Data?, + ) { + self.readImpl = read + } + + package init(channel: DataChannel) { + self.init(read: { try await channel.read(upTo: $0) }) + } + + package func read(upTo limit: DataChannel.Limit = .unlimited) async throws -> Data? { + try Task.checkCancellation() + return try await readImpl(limit) + } + + /// Cancellation discards bytes already consumed for this line. + package func readLine() async throws -> String? { + var line = Data() + while let chunk = try await read(upTo: .newline) { + line.append(chunk) + if line.last == UInt8(ascii: "\n") { + line.removeLast() + return String(decoding: line, as: UTF8.self).trimmingTrailingCarriageReturn() + } + } + guard !line.isEmpty else { return nil } + return String(decoding: line, as: UTF8.self).trimmingTrailingCarriageReturn() + } + + package static var liveValue: CommandInput { + @Dependency(\.shutdownRegistry) var shutdownRegistry + do { + let input = try DispatchInput(fileDesciptor: .standardInput) + shutdownRegistry.register { await input.close() } + return Self(read: { try await input.read(upTo: $0) }) + } catch { + return Self(read: { _ in throw error }) + } + } + + package static var testValue: CommandInput { + Self(read: unimplemented("CommandInput.read", placeholder: nil)) + } +} + +private extension String { + func trimmingTrailingCarriageReturn() -> String { + hasSuffix("\r") ? String(dropLast()) : self + } +} + +extension DependencyValues { + package var input: CommandInput { + get { self[CommandInput.self] } + set { self[CommandInput.self] = newValue } + } +} 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..ef5abae8 100644 --- a/Sources/XToolSupport/Console.swift +++ b/Sources/XCLIUtils/Console.swift @@ -1,48 +1,15 @@ import Foundation -import XKit import XUtils -import NIOPosix -import NIOCore +import Dependencies -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 { + d.out.print(message, terminator: "", flush: true) + guard let line = try await d.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 +22,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 +58,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 +67,7 @@ enum Console { } } - static func choose( + package static func choose( from elements: [T], onNoElement: () throws -> T, multiPrompt: @autoclosure () -> String, @@ -112,9 +79,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 +91,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 +102,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/DataChannel.swift b/Sources/XCLIUtils/DataChannel.swift new file mode 100644 index 00000000..1b2aa39d --- /dev/null +++ b/Sources/XCLIUtils/DataChannel.swift @@ -0,0 +1,132 @@ +import Foundation +import ConcurrencyExtras + +/// A bi-directional channel for streaming data from producer -> consumer. +/// +/// Supports backpressure via `onRequest`. +public struct DataChannel: Sendable { + enum Phase { + case active + case closed(Error?) + } + + public enum Errors: Error { + case concurrentRead + } + + public struct Limit: Sendable { + public var limit: @Sendable (Data) throws -> Int + + public init(limit: @escaping @Sendable (Data) throws -> Int) { + self.limit = limit + } + + public static var unlimited: Limit { + Limit { _ in .max } + } + + public static func bytes(_ count: Int) -> Limit { + Limit { _ in count } + } + + public static var newline: Limit { + Limit { buffer in + if let newlineIndex = buffer.firstIndex(of: UInt8(ascii: "\n")) { + return buffer.distance(from: buffer.startIndex, to: newlineIndex) + 1 + } else { + return .max + } + } + } + } + + private struct State { + var waiter: (@Sendable () -> Void)? + var phase: Phase = .active + var buffer = Data() + var request: (@Sendable () -> Void)? + } + private let state = LockIsolated(State()) + + public init() {} + + public func onRequest(_ request: @escaping @Sendable () -> Void) { + self.state.withValue { $0.request = request } + } + + public func write(_ data: Data) { + let waiter: (@Sendable () -> Void)? = self.state.withValue { state in + guard case .active = state.phase else { return nil } + state.buffer.append(contentsOf: data) + return state.waiter.take() + } + waiter?() + } + + public func finish(error: Error?) { + let waiter: (@Sendable () -> Void)? = self.state.withValue { state in + guard case .active = state.phase else { return nil } + state.phase = .closed(error) + return state.waiter.take() + } + waiter?() + } + + private enum Outcome: Sendable { + case result(Result) + case callRequest((@Sendable () -> Void)?) + } + + nonisolated(nonsending) public func read(drain: @Sendable (inout Data) throws -> T) async throws -> T? { + while true { + try Task.checkCancellation() + + let result: Result? = await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let outcome: Outcome = state.withValue { state in + guard !Task.isCancelled else { + return .result(.failure(CancellationError())) + } + if !state.buffer.isEmpty { + return .result(Result { try drain(&state.buffer) }) + } + switch state.phase { + case .active: + guard state.waiter == nil else { + return .result(.failure(Errors.concurrentRead)) + } + state.waiter = { continuation.resume(returning: nil) } + return .callRequest(state.request) + case .closed(nil): + return .result(.success(nil)) + case .closed(let error?): + return .result(.failure(error)) + } + } + switch outcome { + case .result(let result): + continuation.resume(returning: result) + case .callRequest(let request): + request?() + } + } + } onCancel: { + self.state.withValue { $0.waiter.take()?() } + } + + if let result { + return try result.get() + } + } + } + + nonisolated(nonsending) public func read(upTo limit: Limit = .unlimited) async throws -> Data? { + try await self.read { buffer in + let count = min(try limit.limit(buffer), buffer.count) + guard count > 0 else { return Data() } + let result = buffer.prefix(count) + buffer.removeFirst(count) + return result + } + } +} diff --git a/Sources/XCLIUtils/DispatchInput.swift b/Sources/XCLIUtils/DispatchInput.swift new file mode 100644 index 00000000..27bf2814 --- /dev/null +++ b/Sources/XCLIUtils/DispatchInput.swift @@ -0,0 +1,142 @@ +import Foundation +import XUtils +import ConcurrencyExtras + +final class DispatchInput: Sendable { + private struct State { + var hasClosed = false + var isReading = false + } + + static let defaultChunkSize: Int = 64 * 1024 + + let queue = DispatchQueue(label: "xtool.dispatch-reader") + let channel: DispatchIO + let chunkSize: Int + private let closeSignal: Task + private let dataChannel = DataChannel() + private let state = LockIsolated(State()) + + init(fileDesciptor: FileDescriptor, chunkSize: Int = DispatchInput.defaultChunkSize) throws { + let closeSignal = Task { _ = try? await Task.never() } + self.closeSignal = closeSignal + self.chunkSize = chunkSize + + enum InitState { + case pending + case done(onClose: @Sendable (Int32) -> Void) + case failed(Int32) + } + let initState = LockIsolated(.pending) + self.channel = DispatchIO( + type: .stream, + fileDescriptor: fileDesciptor.rawValue, + queue: queue, + ) { err in + initState.withValue { state in + switch state { + case .pending: + state = .failed(err) + case .done(let onClose): + onClose(err) + case .failed: // wat + break + } + } + } + channel.setLimit(lowWater: 1) + + try initState.withValue { state in + switch state { + case .pending: + state = .done { [weak self] err in + self?.didClose(code: err) + closeSignal.cancel() + } + case .done: // wat + break + case .failed(let err): + throw Errno(rawValue: err) + } + } + + dataChannel.onRequest { [weak self] in + self?.onRequest() + } + } + + nonisolated(nonsending) func read(upTo limit: DataChannel.Limit = .unlimited) async throws -> Data? { + try await dataChannel.read(upTo: limit) + } + + private func onRequest() { + let didMarkStarted = state.withValue { state in + guard !state.isReading else { return false } + state.isReading = true + return true + } + guard didMarkStarted else { return } + channel.read( + offset: 0, + length: chunkSize, + queue: queue, + ) { [weak self] done, data, error in + guard let self else { return } + let data = data.map { Data($0) } + self.onRead( + done: done, + data: data, + error: error, + ) + } + } + + private func onRead( + done: Bool, + data: Data?, + error: Int32, + ) { + if done { + state.withValue { $0.isReading = false } + } + let data = data.map { Data($0) } + if let data, !data.isEmpty { + dataChannel.write(data) + } + if error != 0 { + requestClose(error: Errno(rawValue: error)) + } else if done, data?.isEmpty == true { + requestClose(error: nil) + } + } + + nonisolated(nonsending) func close() async { + requestClose(error: nil) + await closeSignal.value + } + + private func requestClose(error: Error?) { + guard markClosedIfNeeded() else { return } + channel.close(flags: .stop) + dataChannel.finish(error: error) + } + + private func didClose(code: Int32) { + guard markClosedIfNeeded() else { return } + dataChannel.finish(error: code == 0 ? nil : Errno(rawValue: code)) + } + + private func markClosedIfNeeded() -> Bool { + state.withValue { state in + let hadClosed = state.hasClosed + state.hasClosed = true + return !hadClosed + } + } + + deinit { + guard !state.withValue(\.hasClosed) else { return } + channel.close(flags: .stop) + dataChannel.finish(error: nil) + } +} diff --git a/Sources/XCLIUtils/ShutdownRegistry.swift b/Sources/XCLIUtils/ShutdownRegistry.swift new file mode 100644 index 00000000..b4c7eae6 --- /dev/null +++ b/Sources/XCLIUtils/ShutdownRegistry.swift @@ -0,0 +1,44 @@ +import ConcurrencyExtras +import Dependencies + +/// Anything registered here will be cleaned up when the process exits +public final class ShutdownRegistry: DependencyKey, Sendable { + public static var liveValue: ShutdownRegistry { ShutdownRegistry() } + public static var testValue: ShutdownRegistry { ShutdownRegistry() } + + private struct State { + var operations: [@Sendable () async -> Void] = [] + var shutdownTask: Task? + } + + private let state = LockIsolated(State()) + + public init() {} + + /// Register cleanup before shutdown begins. + public func register(_ operation: @escaping @Sendable () async -> Void) { + state.withValue { $0.operations.append(operation) } + } + + /// Runs cleanup in reverse registration order, once, even for concurrent callers. + public func shutdown() async { + let task = state.withValue { state in + if let task = state.shutdownTask { return task } + let pending = state.operations.reversed() + state.operations.removeAll() + let task = Task { + for operation in pending { await operation() } + } + state.shutdownTask = task + return 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..f201f291 --- /dev/null +++ b/Sources/XUtils/CommandOutput.swift @@ -0,0 +1,64 @@ +// swiftlint:disable prefer_dependency_print + +import Dependencies +import Foundation +import CXKit + +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..dd7bb4c0 100644 --- a/Sources/XUtils/System+Utils.swift +++ b/Sources/XUtils/System+Utils.swift @@ -28,6 +28,33 @@ 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 == .resourceTemporarilyUnavailable || error == .wouldBlock { + var descriptor = pollfd(fd: rawValue, events: Int16(POLLOUT), revents: 0) + while true { + try Task.checkCancellation() + // periodically wake up to check for cancellation + let result = poll(&descriptor, 1, 100) + if result > 0 { break } + if result == -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..d7bba913 --- /dev/null +++ b/Tests/XToolTests/CommandInputTests.swift @@ -0,0 +1,103 @@ +@testable import XCLIUtils +import ConcurrencyExtras +import Foundation +import Testing + +@Suite(.timeLimit(.minutes(1))) +struct CommandInputTests { + @Test(arguments: [Data("bar".utf8), Data([0, 255, 128, 13, 10])]) + func rawReadCoalescesBytesAfterLine(remainder: Data) async throws { + let channel = DataChannel() + let input = CommandInput(channel: channel) + channel.write(Data("foo\n".utf8) + remainder) + channel.write(Data("next\nlast".utf8)) + channel.finish(error: nil) + + #expect(try await input.readLine() == "foo") + #expect(try await input.read() == remainder + Data("next\nlast".utf8)) + #expect(try await input.read() == nil) + #expect(try await input.readLine() == nil) + } + + @Test + func lineReadsAfterRawReadPreserveFollowingLines() async throws { + let channel = DataChannel() + let input = CommandInput(channel: channel) + channel.write(Data("raw".utf8)) + #expect(try await input.read() == Data("raw".utf8)) + channel.write(Data("foo\r\n\nbar\nlast".utf8)) + channel.finish(error: nil) + + #expect(try await input.readLine() == "foo") + #expect(try await input.readLine() == "") + #expect(try await input.readLine() == "bar") + #expect(try await input.read() == Data("last".utf8)) + #expect(try await input.readLine() == nil) + } + + @Test + func lineReadAccumulatesSplitUTF8AndCRLF() async throws { + let channel = DataChannel() + let input = CommandInput(channel: channel) + // Split both a Unicode scalar and the CRLF terminator across reads. + let chunks = LockIsolated([ + Data([0xF0, 0x9F]), + Data([0x98, 0x80, 13]), + Data("\n\nlast".utf8), + ]) + channel.onRequest { [channel] in + let chunk = chunks.withValue { $0.isEmpty ? nil : $0.removeFirst() } + if let chunk { + channel.write(chunk) + } else { + channel.finish(error: nil) + } + } + try await withInputTestTimeout { () async throws in + #expect(try await input.readLine() == "😀") + #expect(try await input.readLine() == "") + #expect(try await input.readLine() == "last") + #expect(try await input.readLine() == nil) + } cleanup: { + channel.finish(error: nil) + } + } + + @Test + @available(anyAppleOS 26.0, *) + func cancellingLineDiscardsConsumedPrefixAndPreservesFutureInput() async throws { + let channel = DataChannel() + defer { channel.finish(error: nil) } + let input = CommandInput(channel: channel) + channel.write(Data("discarded prefix".utf8)) + let reading = Task.immediate { try await input.readLine() } + defer { reading.cancel() } + + try await withInputTestTimeout { () async throws in + reading.cancel() + await #expect(throws: CancellationError.self) { try await reading.value } + channel.write(Data("next\nkept".utf8)) + #expect(try await input.readLine() == "next") + #expect(try await input.read() == Data("kept".utf8)) + } cleanup: { + channel.finish(error: nil) + } + } + + @Test + @available(anyAppleOS 26.0, *) + func finishingChannelWakesLineReadAndReturnsItsUnterminatedBytes() async throws { + let channel = DataChannel() + let input = CommandInput(channel: channel) + channel.write(Data("partial".utf8)) + let reading = Task.immediate { try await input.readLine() } + defer { reading.cancel(); channel.finish(error: nil) } + try await withInputTestTimeout { () async throws in + channel.finish(error: nil) + #expect(try await reading.value == "partial") + #expect(try await input.read() == nil) + } cleanup: { + channel.finish(error: nil) + } + } +} diff --git a/Tests/XToolTests/CommandOutputTests.swift b/Tests/XToolTests/CommandOutputTests.swift new file mode 100644 index 00000000..cd0d4f51 --- /dev/null +++ b/Tests/XToolTests/CommandOutputTests.swift @@ -0,0 +1,131 @@ +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(.timeLimit(.minutes(1))) + func fullNonblockingPipeWriteCanBeCancelled() async throws { + let pipe = Pipe() + let writer = FileDescriptor(rawValue: pipe.fileHandleForWriting.fileDescriptor) + let flags = fcntl(writer.rawValue, F_GETFL) + try #require(flags != -1) + try #require(fcntl(writer.rawValue, F_SETFL, flags | O_NONBLOCK) == 0) + + let padding = Data(repeating: 255, count: 4096) + while true { + do { + _ = try padding.withUnsafeBytes { try writer.write($0) } + } catch let error as Errno where error.rawValue == EAGAIN || error.rawValue == EWOULDBLOCK { + break + } + } + + let (started, continuation) = AsyncStream.makeStream(of: Never.self) + let finished = LockIsolated(false) + let writing = Task.detached { + defer { + try? pipe.fileHandleForWriting.close() + finished.setValue(true) + } + continuation.finish() + try padding.withUnsafeBytes { try writer.writeAllWaiting($0) } + } + for await _ in started {} + // Give the writer time to enter poll before cancelling, without draining the pipe. + try await Task.sleep(for: .milliseconds(20)) + writing.cancel() + let deadline = ContinuousClock.now.advanced(by: .seconds(2)) + while !finished.value && ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + if !finished.value { + Issue.record("Cancelled writer did not stop while the pipe remained full") + // Unblock a regressed writer so the test fails instead of hanging. + _ = try pipe.fileHandleForReading.readToEnd() + } + await #expect(throws: CancellationError.self) { + try await writing.value + } + } + + @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/DataChannelTests.swift b/Tests/XToolTests/DataChannelTests.swift new file mode 100644 index 00000000..b0593d89 --- /dev/null +++ b/Tests/XToolTests/DataChannelTests.swift @@ -0,0 +1,200 @@ +@testable import XCLIUtils +import ConcurrencyExtras +import CustomDump +import Foundation +import Testing + +@Suite(.timeLimit(.minutes(1))) +struct DataChannelTests { + private enum TestError: Error { case producer, drain } + + @Test(arguments: [1, 3, 100]) + func byteLimitPreservesRemainder(limit: Int) async throws { + let channel = DataChannel() + channel.write(Data([0, 255])) + channel.write(Data([128, 13, 10])) + channel.finish(error: nil) + let bytes = Data([0, 255, 128, 13, 10]) + + let first = try await channel.read(upTo: .bytes(limit)) + expectNoDifference(first, Data(bytes.prefix(limit))) + if limit < bytes.count { + let remainder = try await channel.read() + expectNoDifference(remainder, Data(bytes.dropFirst(limit))) + } + #expect(try await channel.read() == nil) + #expect(try await channel.read() == nil) + } + + @Test + func zeroLimitDoesNotConsumeBufferedBytes() async throws { + let channel = DataChannel() + channel.write(Data("kept".utf8)) + channel.finish(error: nil) + #expect(try await channel.read(upTo: .bytes(0)) == Data()) + let bytes = try await channel.read() + expectNoDifference(bytes, Data("kept".utf8)) + } + + @Test(arguments: [false, true]) + func newlinePreservesRemainderAfterPartialRead(consumePrefix: Bool) async throws { + let channel = DataChannel() + channel.write(Data("abfirst\nsecond line with a long remainder".utf8)) + channel.finish(error: nil) + if consumePrefix { + let prefix = try await channel.read(upTo: .bytes(2)) + expectNoDifference(prefix, Data("ab".utf8)) + } + let line = try await channel.read(upTo: .newline) + expectNoDifference(line, Data((consumePrefix ? "first\n" : "abfirst\n").utf8)) + let remainder = try await channel.read() + expectNoDifference(remainder, Data("second line with a long remainder".utf8)) + } + + @Test + func newlineReturnsEmptyLineAndUnterminatedChunk() async throws { + let channel = DataChannel() + channel.write(Data("\npartial".utf8)) + channel.finish(error: nil) + #expect(try await channel.read(upTo: .newline) == Data("\n".utf8)) + let remainder = try await channel.read(upTo: .newline) + expectNoDifference(remainder, Data("partial".utf8)) + #expect(try await channel.read() == nil) + } + + @Test + func throwingLimitLeavesBufferAvailable() async throws { + let channel = DataChannel() + channel.write(Data("kept".utf8)) + channel.finish(error: nil) + await #expect(throws: TestError.drain) { + try await channel.read(drain: { _ in throw TestError.drain }) + } + let bytes = try await channel.read() + expectNoDifference(bytes, Data("kept".utf8)) + } + + @Test(arguments: [false, true]) + func finishDrainsBufferAndKeepsFirstTerminalResult(failed: Bool) async throws { + let channel = DataChannel() + channel.write(Data("before".utf8)) + channel.finish(error: failed ? TestError.producer : nil) + channel.write(Data("ignored".utf8)) + channel.finish(error: failed ? nil : TestError.producer) + let bytes = try await channel.read() + expectNoDifference(bytes, Data("before".utf8)) + for _ in 0..<2 { + if failed { + await #expect(throws: TestError.producer) { try await channel.read() } + } else { + #expect(try await channel.read() == nil) + } + } + } + + @Test + func requestsDataOnlyWhenBufferIsEmpty() async throws { + let channel = DataChannel() + defer { channel.finish(error: nil) } + let requests = LockIsolated(0) + channel.onRequest { [channel] in + requests.withValue { $0 += 1 } + channel.write(Data("new".utf8)) + } + channel.write(Data("buffered".utf8)) + try await withInputTestTimeout { + let buffered = try await channel.read() + expectNoDifference(buffered, Data("buffered".utf8)) + #expect(requests.value == 0) + for expectedRequests in 1...2 { + let bytes = try await channel.read() + expectNoDifference(bytes, Data("new".utf8)) + #expect(requests.value == expectedRequests) + } + } cleanup: { + channel.finish(error: nil) + } + channel.finish(error: nil) + #expect(try await channel.read() == nil) + #expect(requests.value == 2) + } + + @Test(arguments: [false, true]) + @available(anyAppleOS 26.0, *) + func finishWakesPendingRead(failed: Bool) async throws { + let channel = DataChannel() + defer { channel.finish(error: nil) } + let reading = Task.immediate { try await channel.read() } + defer { reading.cancel() } + try await withInputTestTimeout { () async throws in + channel.finish(error: failed ? TestError.producer : nil) + if failed { + await #expect(throws: TestError.producer) { try await reading.value } + } else { + #expect(try await reading.value == nil) + } + } cleanup: { + channel.finish(error: nil) + } + } + + @Test + @available(anyAppleOS 26.0, *) + func concurrentReadThrowsWithoutDisruptingPendingRead() async throws { + let channel = DataChannel() + defer { channel.finish(error: nil) } + let reading = Task.immediate { try await channel.read() } + defer { reading.cancel() } + + try await withInputTestTimeout { + await #expect(throws: DataChannel.Errors.concurrentRead) { + try await channel.read() + } + + channel.write(Data("first".utf8)) + let first = try await reading.value + expectNoDifference(first, Data("first".utf8)) + + channel.write(Data("next".utf8)) + let next = try await channel.read() + expectNoDifference(next, Data("next".utf8)) + } cleanup: { + channel.finish(error: nil) + } + } + + @Test + @available(anyAppleOS 26.0, *) + func cancelledPendingReadAllowsAnotherRead() async throws { + let channel = DataChannel() + defer { channel.finish(error: nil) } + let reading = Task.immediate { try await channel.read() } + defer { reading.cancel() } + try await withInputTestTimeout { + reading.cancel() + await #expect(throws: CancellationError.self) { try await reading.value } + channel.write(Data("kept".utf8)) + let bytes = try await channel.read() + expectNoDifference(bytes, Data("kept".utf8)) + } cleanup: { + channel.finish(error: nil) + } + } + + @Test + func alreadyCancelledReadDoesNotConsumeBufferOrRequestData() async throws { + let channel = DataChannel() + defer { channel.finish(error: nil) } + let requested = LockIsolated(false) + channel.onRequest { requested.setValue(true) } + channel.write(Data("kept".utf8)) + let reading = Task { + withUnsafeCurrentTask { $0?.cancel() } + return try await channel.read() + } + await #expect(throws: CancellationError.self) { try await reading.value } + #expect(!requested.value) + let bytes = try await channel.read() + expectNoDifference(bytes, Data("kept".utf8)) + } +} diff --git a/Tests/XToolTests/DispatchInputTests.swift b/Tests/XToolTests/DispatchInputTests.swift new file mode 100644 index 00000000..34ce9fb6 --- /dev/null +++ b/Tests/XToolTests/DispatchInputTests.swift @@ -0,0 +1,193 @@ +@testable import XCLIUtils +import CustomDump +import Foundation +import Testing +import XUtils + +@Suite(.timeLimit(.minutes(1))) +struct DispatchInputTests { + @Test + func commandInputReadsLinesAcrossDispatchBatches() async throws { + let pipe = Pipe() + let descriptor = FileDescriptor(rawValue: pipe.fileHandleForReading.fileDescriptor) + try pipe.fileHandleForWriting.write(contentsOf: Data("abfirst\r\n\nlast".utf8)) + try pipe.fileHandleForWriting.close() + try await withReader(descriptor: descriptor, chunkSize: 2) { reader async throws in + let input = CommandInput(read: { try await reader.read(upTo: $0) }) + #expect(try await input.read(upTo: .bytes(2)) == Data("ab".utf8)) + #expect(try await input.readLine() == "first") + #expect(try await input.readLine() == "") + #expect(try await input.readLine() == "last") + #expect(try await input.read() == nil) + } + } + + @Test + func pipeDeliversShortChunksBeforeEOF() async throws { + let pipe = Pipe() + let descriptor = FileDescriptor(rawValue: pipe.fileHandleForReading.fileDescriptor) + let flags = fcntl(descriptor.rawValue, F_GETFL) + try await withReader(descriptor: descriptor) { reader in + for bytes in [Data("first\n".utf8), Data([0, 255, 128])] { + try pipe.fileHandleForWriting.write(contentsOf: bytes) + let chunk = try await reader.read() + expectNoDifference(chunk, bytes) + } + try pipe.fileHandleForWriting.close() + #expect(try await reader.read() == nil) + #expect(try await reader.read() == nil) + } + #expect(fcntl(descriptor.rawValue, F_GETFL) == flags) + } + + @Test + func pipePreservesBytesAcrossBatches() async throws { + let pipe = Pipe() + let descriptor = FileDescriptor(rawValue: pipe.fileHandleForReading.fileDescriptor) + let bytes = Data((0..<1027).map { UInt8(truncatingIfNeeded: $0) }) + try pipe.fileHandleForWriting.write(contentsOf: bytes) + try pipe.fileHandleForWriting.close() + try await withReader(descriptor: descriptor, chunkSize: 64) { reader in + var received = Data() + while let chunk = try await reader.read() { + #expect(!chunk.isEmpty) + #expect(chunk.count <= 64) + received.append(chunk) + } + expectNoDifference(received, bytes) + } + } + + @Test(arguments: [false, true]) + @available(anyAppleOS 26.0, *) + func closeFinishesBeforeOrDuringRead(pendingRead: Bool) async throws { + let pipe = Pipe() + let descriptor = FileDescriptor(rawValue: pipe.fileHandleForReading.fileDescriptor) + let flags = fcntl(descriptor.rawValue, F_GETFL) + try await withReader(descriptor: descriptor) { reader async throws in + if pendingRead { + let reading = Task.immediate { try await reader.read() } + defer { reading.cancel() } + await reader.close() + #expect(try await reading.value == nil) + } else { + await reader.close() + } + #expect(try await reader.read() == nil) + await reader.close() + } + #expect(fcntl(descriptor.rawValue, F_GETFL) == flags) + } + + @Test + func nullInputClosesAfterEOF() async throws { + let descriptor = try FileDescriptor.open("/dev/null", .readOnly) + defer { try? descriptor.close() } + try await withReader(descriptor: descriptor) { reader async throws in + #expect(try await reader.read() == nil) + await reader.close() + #expect(try await reader.read() == nil) + } + #expect(fcntl(descriptor.rawValue, F_GETFL) != -1) + } + + @Test + func readErrorSurvivesClose() async throws { + let descriptor = try FileDescriptor.open("/dev/null", .writeOnly) + defer { try? descriptor.close() } + try await withReader(descriptor: descriptor) { reader in + await #expect(throws: Errno.badFileDescriptor) { try await reader.read() } + await reader.close() + await #expect(throws: Errno.badFileDescriptor) { try await reader.read() } + } + } + + @Test + func invalidDescriptorFailsDuringInitOrRead() async throws { + // DispatchIO can report initialization failure before or after init returns. + await #expect(throws: Errno.badFileDescriptor) { + try await withReader(descriptor: FileDescriptor(rawValue: -1)) { reader in + _ = try await reader.read() + } + } + } + + @Test + func fileReadsAreBoundedAndPreserveAllBytes() async throws { + let path = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: path) } + let bytes = Data((0..<10_003).map { UInt8(truncatingIfNeeded: $0) }) + try bytes.write(to: path) + let descriptor = try FileDescriptor.open(FilePath(path.path), .readOnly) + defer { try? descriptor.close() } + try await withReader(descriptor: descriptor, chunkSize: 1024) { reader in + var received = Data() + while let chunk = try await reader.read() { + #expect(!chunk.isEmpty) + #expect(chunk.count <= 1024) + received.append(chunk) + } + expectNoDifference(received, bytes) + #expect(try await reader.read() == nil) + } + } + + @Test + func idleConsumerDoesNotStartAnotherBatch() async throws { + let path = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: path) } + try Data(repeating: 255, count: 16 * 1024).write(to: path) + let descriptor = try FileDescriptor.open(FilePath(path.path), .readOnly) + defer { try? descriptor.close() } + try await withReader(descriptor: descriptor, chunkSize: 1024) { reader in + #expect(try await reader.read() != nil) + // Allow any unintended read-ahead to run while the consumer is idle. + try await Task.sleep(for: .milliseconds(20)) + await reader.close() + let offset = try descriptor.seek(offset: 0, from: .current) + #expect(offset > 0) + #expect(offset <= 1024) + } + } + + @Test + func deinitClosesPendingIOAndRestoresDescriptor() async throws { + let pipe = Pipe() + let descriptor = FileDescriptor(rawValue: pipe.fileHandleForReading.fileDescriptor) + let flags = fcntl(descriptor.rawValue, F_GETFL) + weak var weakReader: DispatchInput? + do { + let reader: DispatchInput = try DispatchInput(fileDesciptor: descriptor) + weakReader = reader + try pipe.fileHandleForWriting.write(contentsOf: Data("a".utf8)) + let chunk = try await withInputTestTimeout { + try await reader.read() + } cleanup: { [reader] in + await reader.close() + } + expectNoDifference(chunk, Data("a".utf8)) + #expect(fcntl(descriptor.rawValue, F_GETFL) & O_NONBLOCK != 0) + } + #expect(weakReader == nil) + // poll for channel.close() to finish + let deadline = ContinuousClock.now + .seconds(2) + while fcntl(descriptor.rawValue, F_GETFL) != flags { + try #require(ContinuousClock.now < deadline, "Timed out waiting for input operation") + try await Task.sleep(for: .milliseconds(1)) + } + } + + private func withReader( + descriptor: FileDescriptor, + chunkSize: Int = DispatchInput.defaultChunkSize, + operation: (DispatchInput) async throws -> Void, + ) async throws { + let reader = try DispatchInput(fileDesciptor: descriptor, chunkSize: chunkSize) + defer { await reader.close() } + try await withInputTestTimeout { + try await operation(reader) + } cleanup: { + await reader.close() + } + } +} diff --git a/Tests/XToolTests/InputTestSupport.swift b/Tests/XToolTests/InputTestSupport.swift new file mode 100644 index 00000000..5b245983 --- /dev/null +++ b/Tests/XToolTests/InputTestSupport.swift @@ -0,0 +1,24 @@ +import Foundation +import Testing + +/// Releases suspended reads on timeout so a regression cannot hang the test process. +func withInputTestTimeout( + _ timeout: Duration = .seconds(5), + operation: () async throws -> T, + cleanup: @escaping @Sendable () async -> Void, +) async throws -> T { + let watchdog = Task { + do { + try await Task.sleep(for: timeout) + } catch { + return + } + Issue.record("Input operation exceeded timeout of \(timeout)") + await cleanup() + } + defer { + watchdog.cancel() + await watchdog.value + } + return try await operation() +} diff --git a/Tests/XToolTests/ShutdownRegistryTests.swift b/Tests/XToolTests/ShutdownRegistryTests.swift new file mode 100644 index 00000000..7ce341e1 --- /dev/null +++ b/Tests/XToolTests/ShutdownRegistryTests.swift @@ -0,0 +1,41 @@ +import Testing +import XCLIUtils + +struct ShutdownRegistryTests { + @Test + func concurrentShutdownRunsCleanupOnceInReverseOrder() async { + let registry = ShutdownRegistry() + let events = ShutdownEvents() + registry.register { await events.append(1) } + 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 concurrentRegistrationsAreAllIncludedInShutdown() async { + let registry = ShutdownRegistry() + let events = ShutdownEvents() + await withTaskGroup(of: Void.self) { group in + for value in 0..<100 { + group.addTask { + registry.register { await events.append(value) } + } + } + } + await registry.shutdown() + #expect(await events.values.sorted() == Array(0..<100)) + } +} + +private actor ShutdownEvents { + var values: [Int] = [] + + func append(_ value: Int) { + values.append(value) + } +}