From 5bb057a1e3d90fd9aebc59bed5f704fd313e0878 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 18:51:55 +0100 Subject: [PATCH] ProcessManager: spawn / stdin / kill / captured / event bus (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - actor ProcessManager: runStreaming + runCaptured (concurrent pipe drain — no 64 KiB deadlock), sendStdin with independent stdin map (#84), kill/killAll, duplicate-id rejection (#116) - Multicast AsyncStream bus: stdout/stderr/exit/error/ jsonRow (ROW_COLORS_JSON: prefix stripped) - exit emitted exactly once, gated on both pipes reaching EOF so buffered output is never lost on fast exits or kills - ARGYLL_NOT_INTERACTIVE=1 on every child; argv logged with ~ home sanitization; subprocess stdout→info, stderr→warn - ProcessLineDecoder (byte-split at \n, UTF-8 safe, CRLF, unterminated tail flush), JSONAccumulator (multiline JSON for instlist/profcheck/ manifest), ProcessID conventions - 19 new tests incl. large-output captured run and stdin round-trip - Fixup: entitlements gain com.apple.security.device.usb; sidebar preset/Calibrate disabled and only Stage 1 enabled per #1 AC Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ICCeryCore/Logging/AppLogger.swift | 55 +++ .../ICCeryCore/Logging/LogSanitizer.swift | 17 + .../ICCeryCore/Process/JSONAccumulator.swift | 59 +++ .../ICCeryCore/Process/ProcessEvent.swift | 36 ++ .../ICCeryCore/Process/ProcessID.swift | 17 + .../Process/ProcessLineDecoder.swift | 41 ++ .../ICCeryCore/Process/ProcessManager.swift | 354 ++++++++++++++++++ Resources/ICCery.entitlements | 2 + Sources/ICCery/SidebarView.swift | 16 +- .../ICCeryCoreTests/ProcessManagerTests.swift | 262 +++++++++++++ 10 files changed, 855 insertions(+), 4 deletions(-) create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Logging/AppLogger.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Logging/LogSanitizer.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Process/JSONAccumulator.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessEvent.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessLineDecoder.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift create mode 100644 Tests/ICCeryCoreTests/ProcessManagerTests.swift diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Logging/AppLogger.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/AppLogger.swift new file mode 100644 index 0000000..0012e14 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/AppLogger.swift @@ -0,0 +1,55 @@ +import Foundation +import OSLog + +/// Severity levels, matching the v1 `log_level` setting values. +public enum LogLevel: String, Codable, Sendable, CaseIterable { + case error, warn, info, debug, trace + + var osType: OSLogType { + switch self { + case .error: return .error + case .warn: return .default + case .info: return .info + case .debug: return .debug + case .trace: return .debug + } + } + + var rank: Int { + switch self { + case .error: return 0 + case .warn: return 1 + case .info: return 2 + case .debug: return 3 + case .trace: return 4 + } + } +} + +/// Central logger. For M1 PR2 this writes to `os.Logger` only; +/// issue #5 adds the rolling file sink and runtime `setLevel`. +public struct AppLogger: Sendable { + public static let shared = AppLogger(category: "app") + + private let osLog: Logger + public let category: String + + public init(category: String) { + self.category = category + self.osLog = Logger( + subsystem: AppPaths.bundleIdentifier, + category: category + ) + } + + public func log(_ level: LogLevel, _ message: @autoclosure () -> String) { + let text = LogSanitizer.sanitize(message()) + osLog.log(level: level.osType, "\(text, privacy: .public)") + } + + public func error(_ message: @autoclosure () -> String) { log(.error, message()) } + public func warn(_ message: @autoclosure () -> String) { log(.warn, message()) } + public func info(_ message: @autoclosure () -> String) { log(.info, message()) } + public func debug(_ message: @autoclosure () -> String) { log(.debug, message()) } + public func trace(_ message: @autoclosure () -> String) { log(.trace, message()) } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Logging/LogSanitizer.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/LogSanitizer.swift new file mode 100644 index 0000000..a5d54e0 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/LogSanitizer.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Rewrites the user's home directory to `~` in log output +/// (docs/03 §Logging hygiene — `sanitize_arg_for_logging`). +public enum LogSanitizer { + /// Replaces every occurrence of the current user's home path with `~`. + public static func sanitize(_ text: String) -> String { + let home = NSHomeDirectory() + guard !home.isEmpty else { return text } + return text.replacingOccurrences(of: home, with: "~") + } + + /// Sanitizes an argv list for display. + public static func sanitizeArgs(_ args: [String]) -> String { + args.map(sanitize).joined(separator: " ") + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/JSONAccumulator.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/JSONAccumulator.swift new file mode 100644 index 0000000..40e2c23 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/JSONAccumulator.swift @@ -0,0 +1,59 @@ +import Foundation + +/// Accumulates stdout lines into a complete JSON document. +/// +/// Several Argyll tools (`instlist`, `profcheck`, `printtarg` manifest) +/// emit pretty-printed multi-line JSON on stdout. Individual lines are +/// *not* valid JSON — only the whole block is — so callers route stdout +/// lines here and get `Data` back once the buffer parses. +/// +/// `ROW_COLORS_JSON: ` lines never reach this type; ProcessManager +/// diverts them to `jsonRow` events first. +public struct JSONAccumulator: Sendable { + private var buffer = Data() + + public init() {} + + /// Appends one stdout line. Returns the complete document bytes when + /// the accumulated buffer forms valid JSON, otherwise `nil`. + public mutating func feed(line: String) -> Data? { + buffer.append(Data(line.utf8)) + buffer.append(0x0A) + return tryParse() + } + + /// Attempts to decode the accumulated buffer; clears it on success. + public mutating func decode(_ type: T.Type) -> T? { + guard let data = tryParse() else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } + + /// Raw buffer when it parses, `nil` while still incomplete. + public var completeData: Data? { + var copy = self + return copy.tryParse() + } + + public mutating func reset() { + buffer.removeAll(keepingCapacity: false) + } + + public var isEmpty: Bool { buffer.isEmpty } + + private mutating func tryParse() -> Data? { + // Cheap gate: JSON documents start with { or [. + guard let first = buffer.first(where: { !$0.isJSONWhitespace }), + first == UInt8(ascii: "{") || first == UInt8(ascii: "[") + else { return nil } + guard (try? JSONSerialization.jsonObject(with: buffer)) != nil else { return nil } + let out = buffer + buffer.removeAll(keepingCapacity: false) + return out + } +} + +private extension UInt8 { + var isJSONWhitespace: Bool { + self == 0x20 || self == 0x09 || self == 0x0A || self == 0x0D + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessEvent.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessEvent.swift new file mode 100644 index 0000000..d04acf3 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessEvent.swift @@ -0,0 +1,36 @@ +import Foundation + +/// Events on the process bus — the v2 equivalent of the v1 Tauri events +/// `process:stdout|stderr|exit|error|json_row` (docs/02 §Event bus). +public enum ProcessEvent: Sendable, Equatable { + /// Non-JSON stdout line. (`process:stdout`) + case stdout(id: String, line: String) + /// stderr line. (`process:stderr`) + case stderr(id: String, line: String) + /// Child exited; 0 = success. (`process:exit`) + case exit(id: String, code: Int32) + /// Spawn failure. (`process:error`) + case error(id: String, message: String) + /// Stdout line began with `ROW_COLORS_JSON: ` — prefix stripped, + /// payload is the remaining raw bytes. (`process:json_row`) + case jsonRow(id: String, payload: Data) + + public var id: String { + switch self { + case .stdout(let id, _), .stderr(let id, _), .exit(let id, _), + .error(let id, _), .jsonRow(let id, _): + return id + } + } +} + +public enum ProcessError: Error, Equatable, Sendable { + /// A child with this id is still running (#116). + case duplicateID(String) + /// No child registered under this id. + case unknownID(String) + /// Process refused to launch. + case spawnFailed(String) + /// stdin write failed (pipe closed / process gone). + case stdinFailed(String) +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift new file mode 100644 index 0000000..8377a51 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Deterministic process ids (docs/02 §Event bus). Listeners must always +/// filter events on `id` — historical bug #56 was an id mismatch. +public enum ProcessID { + public static let instlist = "instlist" + + public static func targen(_ basename: String) -> String { "targen_\(basename)" } + public static func printtarg(_ basename: String) -> String { "printtarg_\(basename)" } + public static func chartread(_ basename: String) -> String { "chartread_\(basename)" } + public static func average(_ basename: String) -> String { "average_\(basename)" } + public static func colprof(_ basename: String) -> String { "colprof_\(basename)" } + public static func profcheck(ti3Path: String) -> String { "profcheck_\(ti3Path)" } + public static func iccgamut(stem: String) -> String { "iccgamut_\(stem)" } + public static func printcal(_ stem: String) -> String { "printcal_\(stem)" } + public static func applycal(_ stem: String) -> String { "applycal_\(stem)" } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessLineDecoder.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessLineDecoder.swift new file mode 100644 index 0000000..ab79ff6 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessLineDecoder.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Incremental byte→line decoder for process pipes. +/// +/// Splits raw `availableData` chunks at `0x0A`. A newline byte can never +/// appear inside a multi-byte UTF-8 sequence (continuation bytes are +/// ≥ 0x80), so splitting bytes at `\n` is always scalar-safe; each line +/// is then decoded with a lossy fallback for non-UTF-8 output. +public struct ProcessLineDecoder: Sendable { + public private(set) var pending = Data() + + public init() {} + + /// Feeds a chunk; returns every complete line found (without `\n`). + public mutating func feed(_ chunk: Data) -> [String] { + guard !chunk.isEmpty else { return [] } + pending.append(chunk) + var lines: [String] = [] + while let nl = pending.firstIndex(of: 0x0A) { + var slice = pending.prefix(upTo: nl) + pending = pending.suffix(from: pending.index(after: nl)) + // Tolerate CRLF output. + if slice.last == 0x0D { slice = slice.dropLast() } + lines.append(Self.decode(slice)) + } + return lines + } + + /// Flushes any unterminated remainder at EOF. Returns `nil` when empty. + public mutating func finish() -> String? { + guard !pending.isEmpty else { return nil } + var rest = pending + pending.removeAll(keepingCapacity: false) + if rest.last == 0x0D { rest = rest.dropLast() } + return rest.isEmpty ? nil : Self.decode(rest) + } + + private static func decode(_ bytes: Data.SubSequence) -> String { + String(decoding: bytes, as: UTF8.self) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift new file mode 100644 index 0000000..1e130af --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift @@ -0,0 +1,354 @@ +import Foundation + +/// Captured output from `runCaptured` (used by printcal/applycal — +/// the only tools whose results arrive as one-shot output). +public struct CapturedResult: Sendable, Equatable { + public let stdout: String + public let stderr: String + public let exitCode: Int32 +} + +/// Spawn / stdin / kill / event bus for Argyll sidecar children +/// (docs/02 §Event bus, docs/03 §Process manager). +/// +/// Invariants: +/// - Duplicate `id` while a child runs is rejected (#116). +/// - The stdin handle lives in its own map, independent of wait, so +/// `sendStdin` never blocks on process exit (#84). +/// - `ARGYLL_NOT_INTERACTIVE=1` is set on every child. +/// - stdout lines beginning `ROW_COLORS_JSON: ` become `jsonRow` events +/// with the prefix stripped; all other stdout is `stdout` events. +/// - `exit` is emitted exactly once per child, and only after both +/// output pipes reach EOF — so no buffered output is lost on fast +/// exits or kills. +/// - `kill` drops the stdin handle so writers fail fast. +public actor ProcessManager { + + public static let rowColorsPrefix = "ROW_COLORS_JSON: " + + public static let shared = ProcessManager() + + // MARK: - Event bus (multicast) + + private var subscribers: [UUID: AsyncStream.Continuation] = [:] + + /// Subscribe to the event bus. Each call returns an independent + /// stream; every event is delivered to every live subscriber. + public nonisolated func events() -> AsyncStream { + AsyncStream { continuation in + let token = UUID() + Task { await self.addSubscriber(continuation, token: token) } + continuation.onTermination = { _ in + Task { await self.removeSubscriber(token) } + } + } + } + + private func addSubscriber( + _ continuation: AsyncStream.Continuation, + token: UUID + ) { + subscribers[token] = continuation + } + + private func removeSubscriber(_ token: UUID) { + subscribers.removeValue(forKey: token) + } + + private func emit(_ event: ProcessEvent) { + for continuation in subscribers.values { + continuation.yield(event) + } + } + + // MARK: - Child registry + + private struct RunningChild { + let process: Process + /// stdin lives in its own slot, independent of process wait (#84). + var stdin: FileHandle? + var stdoutDecoder: ProcessLineDecoder + var stderrDecoder: ProcessLineDecoder + var stdoutEOF = false + var stderrEOF = false + /// Set by the termination handler; `exit` is emitted once both + /// pipes have also reached EOF. + var pendingExitCode: Int32? + var finalized = false + } + + private var children: [String: RunningChild] = [:] + /// Processes owned by `runCaptured` (dup detection + kill support). + private var captured: [String: Process] = [:] + + /// Ids of currently-running children. + public var runningIDs: [String] { Array(children.keys) + captured.keys } + + public func isRunning(_ id: String) -> Bool { + children[id] != nil || captured[id] != nil + } + + // MARK: - Spawn (streaming) + + /// Spawns a streaming child. Returns after spawn; callers wait for + /// `exit(id:)` events — never assume the return means the tool + /// finished (docs/03). + public func runStreaming( + id: String, + binary: URL, + arguments: [String], + workingDirectory: URL? = nil, + environment: [String: String] = [:] + ) throws { + guard !isRunning(id) else { throw ProcessError.duplicateID(id) } + + let process = Process() + let stdinPipe = Pipe() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.executableURL = binary + process.arguments = arguments + process.currentDirectoryURL = workingDirectory + process.standardInput = stdinPipe + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + process.environment = childEnvironment(extra: environment) + + AppLogger(category: "process").debug( + "spawn \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))" + ) + + children[id] = RunningChild( + process: process, + stdin: stdinPipe.fileHandleForWriting, + stdoutDecoder: ProcessLineDecoder(), + stderrDecoder: ProcessLineDecoder() + ) + + do { + try process.run() + } catch { + children.removeValue(forKey: id) + emit(.error(id: id, message: error.localizedDescription)) + throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)") + } + + let stdoutHandle = stdoutPipe.fileHandleForReading + let stderrHandle = stderrPipe.fileHandleForReading + stdoutHandle.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard let self else { return } + Task { await self.ingestOutput(data, id: id, isStderr: false, handle: handle) } + } + stderrHandle.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard let self else { return } + Task { await self.ingestOutput(data, id: id, isStderr: true, handle: handle) } + } + + process.terminationHandler = { [weak self] proc in + guard let self else { return } + Task { await self.didTerminate(id: id, code: proc.terminationStatus) } + } + } + + // MARK: - Spawn (captured) + + /// Runs a child to completion and returns all output. Reads stdout + /// and stderr concurrently so a full pipe buffer can never deadlock + /// the child. Used by `printcal` / `applycal` (docs/03). + public func runCaptured( + id: String, + binary: URL, + arguments: [String], + workingDirectory: URL? = nil, + environment: [String: String] = [:] + ) async throws -> CapturedResult { + guard !isRunning(id) else { throw ProcessError.duplicateID(id) } + + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.executableURL = binary + process.arguments = arguments + process.currentDirectoryURL = workingDirectory + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + process.environment = childEnvironment(extra: environment) + + AppLogger(category: "process").debug( + "spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))" + ) + + // Register before run() so a concurrent duplicate spawn fails. + captured[id] = process + + do { + try process.run() + } catch { + captured.removeValue(forKey: id) + emit(.error(id: id, message: error.localizedDescription)) + throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)") + } + + async let outData = Task.detached { + stdoutPipe.fileHandleForReading.readDataToEndOfFile() + }.value + async let errData = Task.detached { + stderrPipe.fileHandleForReading.readDataToEndOfFile() + }.value + + let code = await withCheckedContinuation { continuation in + process.terminationHandler = { proc in + continuation.resume(returning: proc.terminationStatus) + } + } + + let (out, err) = await (outData, errData) + // If kill() already reaped this child, its exit event went out. + if captured.removeValue(forKey: id) != nil { + emit(.exit(id: id, code: code)) + } + + return CapturedResult( + stdout: String(decoding: out, as: UTF8.self), + stderr: String(decoding: err, as: UTF8.self), + exitCode: code + ) + } + + // MARK: - stdin + + /// Writes the exact bytes (caller includes `\n`) to a child's stdin + /// and flushes (docs/03 §stdin protocol). + public func sendStdin(id: String, bytes: Data) throws { + guard let child = children[id] else { throw ProcessError.unknownID(id) } + guard let handle = child.stdin else { + throw ProcessError.stdinFailed("stdin closed for \(id)") + } + do { + try handle.write(contentsOf: bytes) + } catch { + throw ProcessError.stdinFailed("\(id): \(error.localizedDescription)") + } + } + + public func sendStdin(id: String, text: String) throws { + try sendStdin(id: id, bytes: Data(text.utf8)) + } + + // MARK: - Kill + + /// Terminates a child. The `exit` event still fires exactly once. + /// stdin is dropped immediately so writers fail fast (docs/03 rule 7). + public func kill(id: String) { + if var child = children[id] { + try? child.stdin?.close() + child.stdin = nil + children[id] = child + if child.process.isRunning { + child.process.terminate() + } else { + Task { await self.didTerminate(id: id, code: child.process.terminationStatus) } + } + return + } + if let process = captured[id] { + if process.isRunning { process.terminate() } + if captured.removeValue(forKey: id) != nil { + emit(.exit(id: id, code: process.terminationStatus)) + } + } + } + + /// Terminates every running child; returns how many were signaled + /// (`kill_all_processes`, docs/03). Mandatory on app exit (#147/#149). + @discardableResult + public func killAll() -> Int { + let ids = Array(children.keys) + Array(captured.keys) + for id in ids { kill(id: id) } + return ids.count + } + + // MARK: - Internals + + private func childEnvironment(extra: [String: String]) -> [String: String] { + var env = ProcessInfo.processInfo.environment + env["ARGYLL_NOT_INTERACTIVE"] = "1" + for (key, value) in extra { env[key] = value } + return env + } + + private func ingestOutput( + _ data: Data, + id: String, + isStderr: Bool, + handle: FileHandle + ) { + guard var child = children[id] else { return } + + if data.isEmpty { + // EOF on this pipe. + handle.readabilityHandler = nil + if isStderr { child.stderrEOF = true } else { child.stdoutEOF = true } + children[id] = child + maybeFinalize(id: id) + return + } + + let lines: [String] = isStderr + ? child.stderrDecoder.feed(data) + : child.stdoutDecoder.feed(data) + children[id] = child + + let log = AppLogger(category: "subprocess") + for line in lines { + if !isStderr, line.hasPrefix(Self.rowColorsPrefix) { + let payload = Data(line.dropFirst(Self.rowColorsPrefix.count).utf8) + emit(.jsonRow(id: id, payload: payload)) + } else if isStderr { + log.warn("[\(id)] \(line)") + emit(.stderr(id: id, line: line)) + } else { + log.info("[\(id)] \(line)") + emit(.stdout(id: id, line: line)) + } + } + } + + private func didTerminate(id: String, code: Int32) { + guard var child = children[id], !child.finalized else { return } + child.pendingExitCode = code + try? child.stdin?.close() + child.stdin = nil + children[id] = child + maybeFinalize(id: id) + } + + /// Emits `exit` once the child has terminated *and* both pipes have + /// drained to EOF, so no buffered output is lost. + private func maybeFinalize(id: String) { + guard var child = children[id], + let code = child.pendingExitCode, + child.stdoutEOF, child.stderrEOF, + !child.finalized + else { return } + child.finalized = true + children.removeValue(forKey: id) + + // Flush unterminated tail lines. + if var decoder = Optional(child.stdoutDecoder), + let tail = decoder.finish() { + if tail.hasPrefix(Self.rowColorsPrefix) { + emit(.jsonRow(id: id, payload: Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8))) + } else { + emit(.stdout(id: id, line: tail)) + } + } + if var decoder = Optional(child.stderrDecoder), + let tail = decoder.finish() { + emit(.stderr(id: id, line: tail)) + } + emit(.exit(id: id, code: code)) + } +} diff --git a/Resources/ICCery.entitlements b/Resources/ICCery.entitlements index df6ecba..d8671ee 100644 --- a/Resources/ICCery.entitlements +++ b/Resources/ICCery.entitlements @@ -4,5 +4,7 @@ + com.apple.security.device.usb + diff --git a/Sources/ICCery/SidebarView.swift b/Sources/ICCery/SidebarView.swift index a165035..0cefc64 100644 --- a/Sources/ICCery/SidebarView.swift +++ b/Sources/ICCery/SidebarView.swift @@ -31,21 +31,24 @@ struct SidebarView: View { Divider().overlay(Theme.border) - // Preset select (`#presetSelect`). Preset engine lands in #11. + // Preset select (`#presetSelect`). Disabled until the preset + // engine lands in issue #11. Picker("Preset", selection: .constant("none")) { Text("No preset").tag("none") } .pickerStyle(.menu) + .disabled(true) .padding(.horizontal, 12) .padding(.vertical, 8) - // Calibrate Printer (`#btnCalibratePrinter`); `#calStatusChip` - // is hidden until the calibration library lands in #29. + // Calibrate Printer (`#btnCalibratePrinter`). Disabled until + // Stage 0 lands in issue #29; `#calStatusChip` likewise. Button(action: { model.enterCalibration() }) { Label("Calibrate Printer", systemImage: "slider.horizontal.3") .frame(maxWidth: .infinity) } .controlSize(.large) + .disabled(true) .padding(.horizontal, 12) Divider().overlay(Theme.border) @@ -56,7 +59,9 @@ struct SidebarView: View { ForEach(WizardStage.stepperStages, id: \.self) { stage in StepperRow( stage: stage, - isActive: model.stage == stage + isActive: model.stage == stage, + // Only Stage 1 until artefact gating lands in #4. + isEnabled: stage == .generate ) { model.go(to: stage) } @@ -74,6 +79,7 @@ struct SidebarView: View { private struct StepperRow: View { let stage: WizardStage let isActive: Bool + let isEnabled: Bool let action: () -> Void var body: some View { @@ -97,6 +103,8 @@ private struct StepperRow: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.45) .background( RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium) .fill(isActive ? Theme.accent.opacity(0.15) : .clear) diff --git a/Tests/ICCeryCoreTests/ProcessManagerTests.swift b/Tests/ICCeryCoreTests/ProcessManagerTests.swift new file mode 100644 index 0000000..3473d74 --- /dev/null +++ b/Tests/ICCeryCoreTests/ProcessManagerTests.swift @@ -0,0 +1,262 @@ +import Testing +import Foundation +@testable import ICCeryCore + +/// Helpers shared across ProcessManager tests. Fixture binaries are shell +/// scripts written to a temp dir — no resource bundling required. +@Suite("ProcessManager", .serialized) +struct ProcessManagerTests { + + // MARK: - Fixture plumbing + + private static let fixtureDir: URL = { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-pm-tests-\(UUID().uuidString)") + try! FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + }() + + /// Writes a shell script fixture and returns its executable URL. + private func script(_ name: String, _ body: String) throws -> URL { + let url = Self.fixtureDir.appendingPathComponent(name) + try body.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: url.path + ) + return url + } + + /// Collects events for `id` until `.exit`, `timeout` seconds max. + private func collect( + _ manager: ProcessManager, + id: String, + timeout: TimeInterval = 10 + ) async -> [ProcessEvent] { + await withCheckedContinuation { cont in + let box = Box() + Task { + for await event in manager.events() { + guard event.id == id else { continue } + box.append(event) + if case .exit = event { break } + } + if box.finish() { cont.resume(returning: box.events) } + } + Task { + try? await Task.sleep(for: .seconds(timeout)) + if box.finish() { cont.resume(returning: box.events) } + } + } + } + + private final class Box: @unchecked Sendable { + private let lock = NSLock() + private var _events: [ProcessEvent] = [] + private var finished = false + var events: [ProcessEvent] { lock.lock(); defer { lock.unlock() }; return _events } + func append(_ e: ProcessEvent) { lock.lock(); _events.append(e); lock.unlock() } + func finish() -> Bool { lock.lock(); defer { lock.unlock() }; if finished { return false }; finished = true; return true } + } + + // MARK: - Tests + + @Test func streamsStdoutAndEmitsExit() async throws { + let pm = ProcessManager() + let bin = try script("lines.sh", "#!/bin/sh\necho hello\necho world\n") + async let events = collect(pm, id: "t1") + try await pm.runStreaming(id: "t1", binary: bin, arguments: []) + let evs = await events + let lines = evs.compactMap { e -> String? in + if case .stdout(_, let l) = e { return l }; return nil + } + #expect(lines == ["hello", "world"]) + #expect(evs.contains(.exit(id: "t1", code: 0))) + } + + @Test func routesStderrSeparately() async throws { + let pm = ProcessManager() + let bin = try script("err.sh", "#!/bin/sh\necho out\necho oops 1>&2\n") + async let evs = collect(pm, id: "t2") + try await pm.runStreaming(id: "t2", binary: bin, arguments: []) + let events = await evs + #expect(events.contains(.stdout(id: "t2", line: "out"))) + #expect(events.contains(.stderr(id: "t2", line: "oops"))) + } + + @Test func stripsRowColorsJSONPrefix() async throws { + let pm = ProcessManager() + let bin = try script( + "rows.sh", + "#!/bin/sh\necho 'ROW_COLORS_JSON: {\"row\":1}'\necho plain\n" + ) + async let evs = collect(pm, id: "t3") + try await pm.runStreaming(id: "t3", binary: bin, arguments: []) + let events = await evs + let rows = events.compactMap { e -> String? in + if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) } + return nil + } + #expect(rows == ["{\"row\":1}"]) + #expect(events.contains(.stdout(id: "t3", line: "plain"))) + // Prefixed lines must not leak into stdout. + #expect(!events.contains(.stdout(id: "t3", line: "ROW_COLORS_JSON: {\"row\":1}"))) + } + + @Test func unterminatedTailFlushesOnExit() async throws { + let pm = ProcessManager() + let bin = try script("tail.sh", "#!/bin/sh\nprintf 'no-newline'\n") + async let evs = collect(pm, id: "t4") + try await pm.runStreaming(id: "t4", binary: bin, arguments: []) + #expect(await evs.contains(.stdout(id: "t4", line: "no-newline"))) + } + + @Test func stdinRoundTrip() async throws { + let pm = ProcessManager() + // Read two lines then exit naturally — a killed sh would lose its + // buffered stdio output, which is exactly the chartread pattern. + let bin = try script( + "echo.sh", + "#!/bin/sh\nIFS= read -r a; echo \"got:$a\"\nIFS= read -r b; echo \"got:$b\"\n" + ) + async let evs = collect(pm, id: "t5") + try await pm.runStreaming(id: "t5", binary: bin, arguments: []) + try await pm.sendStdin(id: "t5", text: " \n") + try await pm.sendStdin(id: "t5", text: "d\n") + let events = await evs + #expect(events.contains(.stdout(id: "t5", line: "got: "))) + #expect(events.contains(.stdout(id: "t5", line: "got:d"))) + } + + @Test func duplicateIDRejected() async throws { + let pm = ProcessManager() + let bin = try script("slow.sh", "#!/bin/sh\nsleep 30\n") + try await pm.runStreaming(id: "t6", binary: bin, arguments: []) + await #expect(throws: ProcessError.duplicateID("t6")) { + try await pm.runStreaming(id: "t6", binary: bin, arguments: []) + } + await pm.kill(id: "t6") + } + + @Test func killEmitsExitAndClosesStdin() async throws { + let pm = ProcessManager() + let bin = try script("slow2.sh", "#!/bin/sh\ncat\n") + async let evs = collect(pm, id: "t7") + try await pm.runStreaming(id: "t7", binary: bin, arguments: []) + await pm.kill(id: "t7") + let events = await evs + // exit emitted exactly once + let exits = events.filter { if case .exit = $0 { return true }; return false } + #expect(exits.count == 1) + await #expect(throws: ProcessError.unknownID("t7")) { + try await pm.sendStdin(id: "t7", text: "d\n") + } + } + + @Test func killAllCountsSignaled() async throws { + let pm = ProcessManager() + let bin = try script("slow3.sh", "#!/bin/sh\nsleep 30\n") + try await pm.runStreaming(id: "a", binary: bin, arguments: []) + try await pm.runStreaming(id: "b", binary: bin, arguments: []) + let count = await pm.killAll() + #expect(count == 2) + } + + @Test func capturedRunReturnsBothStreams() async throws { + let pm = ProcessManager() + let bin = try script("cap.sh", "#!/bin/sh\necho out-data\necho err-data 1>&2\nexit 3\n") + let result = try await pm.runCaptured(id: "cap", binary: bin, arguments: []) + #expect(result.stdout.contains("out-data")) + #expect(result.stderr.contains("err-data")) + #expect(result.exitCode == 3) + } + + @Test func capturedRunDoesNotDeadlockOnLargeOutput() async throws { + let pm = ProcessManager() + // 5000 lines each stream exceeds the 64 KiB pipe buffer. + let bin = try script( + "big.sh", + "#!/bin/sh\ni=0; while [ $i -lt 5000 ]; do echo \"out-$i\"; echo \"err-$i\" 1>&2; i=$((i+1)); done\n" + ) + let result = try await pm.runCaptured(id: "big", binary: bin, arguments: []) + #expect(result.stdout.contains("out-4999")) + #expect(result.stderr.contains("err-4999")) + } + + @Test func argyllEnvVarIsSet() async throws { + let pm = ProcessManager() + let bin = try script("env.sh", "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n") + async let evs = collect(pm, id: "t10") + try await pm.runStreaming(id: "t10", binary: bin, arguments: []) + #expect(await evs.contains(.stdout(id: "t10", line: "ANI=1"))) + } + + @Test func unknownIDStdinThrows() async throws { + let pm = ProcessManager() + await #expect(throws: ProcessError.unknownID("nope")) { + try await pm.sendStdin(id: "nope", text: "d\n") + } + } +} + +@Suite("ProcessLineDecoder") +struct ProcessLineDecoderTests { + @Test func splitsAcrossChunkBoundaries() { + var d = ProcessLineDecoder() + #expect(d.feed(Data("he".utf8)) == []) + #expect(d.feed(Data("llo\nwor".utf8)) == ["hello"]) + #expect(d.feed(Data("ld\n".utf8)) == ["world"]) + #expect(d.finish() == nil) + } + + @Test func crlfIsStripped() { + var d = ProcessLineDecoder() + #expect(d.feed(Data("a\r\nb\r\n".utf8)) == ["a", "b"]) + } + + @Test func finishReturnsRemainder() { + var d = ProcessLineDecoder() + _ = d.feed(Data("x".utf8)) + #expect(d.finish() == "x") + #expect(d.finish() == nil) + } +} + +@Suite("JSONAccumulator") +struct JSONAccumulatorTests { + @Test func multilinePrettyJSON() { + var acc = JSONAccumulator() + #expect(acc.feed(line: "{") == nil) + #expect(acc.feed(line: " \"k\": 1") == nil) + let done = acc.feed(line: "}") + #expect(done != nil) + let obj = try? JSONSerialization.jsonObject(with: done!) as? [String: Int] + #expect(obj?["k"] == 1) + } + + @Test func nonJSONLinesIgnored() { + var acc = JSONAccumulator() + #expect(acc.feed(line: "Reading instrument...") == nil) + #expect(acc.feed(line: "still text") == nil) + #expect(acc.completeData == nil) + } + + @Test func decodeTyped() { + struct Doc: Decodable { let n: Int } + var acc = JSONAccumulator() + // Split so the doc completes on the second feed. + #expect(acc.feed(line: "{\"n\":") == nil) + let data = acc.feed(line: "7}") + #expect(data != nil) + let doc = data.flatMap { try? JSONDecoder().decode(Doc.self, from: $0) } + #expect(doc?.n == 7) + #expect(acc.isEmpty) + } +} + +@Suite("LogSanitizer") +struct LogSanitizerTests { + @Test func homeIsRewritten() { + let path = "\(NSHomeDirectory())/Documents/foo.ti1" + #expect(LogSanitizer.sanitize(path) == "~/Documents/foo.ti1") + } +} -- 2.39.5