Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
716b302374 | ||
|
|
20d6bf7fdc | ||
|
|
5bb057a1e3 | ||
|
|
b998b48abf |
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
|
||||
/// Resolves Argyll sidecar binaries (docs/04 §0.1 `resolve_binary`).
|
||||
///
|
||||
/// Order:
|
||||
/// 1. Settings `argyll_binary_dir` override — only if `<dir>/<name>`
|
||||
/// exists there.
|
||||
/// 2. Bundled `<bundle>/Resources/Argyll/<platform>/<name>`.
|
||||
/// On macOS, `macos-universal` wins whenever it contains the `instlist`
|
||||
/// marker; otherwise `macos-arm64` / `macos-x86_64` by host arch.
|
||||
/// 3. If nothing exists the *constructed* bundled path is still returned
|
||||
/// — a missing binary surfaces later as `process:error` on spawn,
|
||||
/// matching v1 semantics.
|
||||
public struct BinaryResolver: Sendable {
|
||||
|
||||
/// Root that contains the platform dirs — `Bundle.resource/Argyll` in
|
||||
/// the app, a fixture dir in tests.
|
||||
public let bundledRoot: URL
|
||||
/// `settings.argyll_binary_dir`, already expanded to a URL.
|
||||
public let overrideDir: URL?
|
||||
/// Host architecture directory names, universal preferred.
|
||||
public let archDirs: [String]
|
||||
|
||||
public init(
|
||||
bundledRoot: URL = AppPaths.bundledArgyllDir,
|
||||
overrideDir: URL? = nil,
|
||||
archDirs: [String]? = nil
|
||||
) {
|
||||
self.bundledRoot = bundledRoot
|
||||
self.overrideDir = overrideDir
|
||||
#if arch(arm64)
|
||||
let fallback = ["macos-arm64", "macos-aarch64"]
|
||||
#else
|
||||
let fallback = ["macos-x86_64"]
|
||||
#endif
|
||||
self.archDirs = archDirs ?? ["macos-universal"] + fallback
|
||||
}
|
||||
|
||||
/// Marker used to decide whether `macos-universal` is usable.
|
||||
public static let markerBinary = "instlist"
|
||||
|
||||
/// Resolves a tool name to an absolute URL (never throws — see type
|
||||
/// docs). `name` is the bare tool name, e.g. `"targen"`.
|
||||
public func resolve(_ name: String) -> URL {
|
||||
let fm = FileManager.default
|
||||
|
||||
if let dir = overrideDir {
|
||||
let candidate = dir.appendingPathComponent(name)
|
||||
if fm.fileExists(atPath: candidate.path) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return bundledRoot
|
||||
.appendingPathComponent(platformDir(), isDirectory: true)
|
||||
.appendingPathComponent(name, isDirectory: false)
|
||||
}
|
||||
|
||||
/// The bundled platform directory that resolution will use.
|
||||
public func platformDir() -> String {
|
||||
let fm = FileManager.default
|
||||
let universal = bundledRoot.appendingPathComponent("macos-universal")
|
||||
if fm.fileExists(
|
||||
atPath: universal.appendingPathComponent(Self.markerBinary).path
|
||||
) {
|
||||
return "macos-universal"
|
||||
}
|
||||
for dir in archDirs where dir != "macos-universal" {
|
||||
if fm.fileExists(
|
||||
atPath: bundledRoot
|
||||
.appendingPathComponent(dir)
|
||||
.appendingPathComponent(Self.markerBinary).path
|
||||
) {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
// Nothing present — still return the preferred dir so the error
|
||||
// message points at where the user should drop binaries.
|
||||
return archDirs.first ?? "macos-universal"
|
||||
}
|
||||
|
||||
/// Bundled mock tool (tracked in git under `Resources/Argyll/mocks/`).
|
||||
public func mock(_ name: String) -> URL {
|
||||
bundledRoot
|
||||
.appendingPathComponent("mocks", isDirectory: true)
|
||||
.appendingPathComponent("\(name).mock", isDirectory: false)
|
||||
}
|
||||
|
||||
/// Bundled reference gamut (`Resources/Argyll/reference_gamuts/`).
|
||||
public func referenceGamut(_ name: String) -> URL {
|
||||
bundledRoot
|
||||
.appendingPathComponent("reference_gamuts", isDirectory: true)
|
||||
.appendingPathComponent(name, isDirectory: false)
|
||||
}
|
||||
|
||||
/// Whether the resolved path exists and is executable.
|
||||
public func exists(_ url: URL) -> Bool {
|
||||
FileManager.default.isExecutableFile(atPath: url.path)
|
||||
}
|
||||
}
|
||||
@@ -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()) }
|
||||
}
|
||||
@@ -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: " ")
|
||||
}
|
||||
}
|
||||
@@ -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<T: Decodable>(_ 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)" }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<ProcessEvent>.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<ProcessEvent> {
|
||||
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<ProcessEvent>.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))
|
||||
}
|
||||
}
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/bin/bash
|
||||
# Mock script for chartread -u
|
||||
# This script simulates the behaviour of chartread for testing purposes.
|
||||
|
||||
# Check for --xy argument or MOCK_XY_TABLE environment variable
|
||||
IS_XY=0
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "--xy" ]; then
|
||||
IS_XY=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$IS_XY" = "1" ] || [ "${MOCK_XY_TABLE}" = "1" ]; then
|
||||
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
||||
read -r _calib
|
||||
echo "Calibration successful."
|
||||
|
||||
echo "Please place sheet 1 of 1 on the table"
|
||||
echo "hit return to continue, Esc or 'q' to give up"
|
||||
read -r _sheet1
|
||||
|
||||
echo "locate patch A1 with the sight,"
|
||||
echo "then hit return to continue"
|
||||
read -r _fid1
|
||||
|
||||
echo "locate patch B24 with the sight,"
|
||||
echo "then hit return to continue"
|
||||
read -r _fid2
|
||||
|
||||
echo "Reading sheet 1..."
|
||||
sleep 0.5
|
||||
|
||||
# Emit mock JSON for strip A
|
||||
cat << 'EOF'
|
||||
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expcted": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]}
|
||||
EOF
|
||||
|
||||
# Emit mock JSON for strip B
|
||||
cat << 'EOF'
|
||||
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expcted": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}
|
||||
EOF
|
||||
|
||||
echo "Sheet 1 of 1 read OK"
|
||||
echo "Please remove last sheet from table"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Handheld / strip reader simulation
|
||||
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
||||
|
||||
# We don't really wait for input, just wait 1 second
|
||||
sleep 1
|
||||
echo "Calibration successful."
|
||||
echo "Hit [Space] to read strip A (or 's' to skip)."
|
||||
|
||||
sleep 1
|
||||
echo "Reading strip A..."
|
||||
|
||||
# Emit mock JSON for strip A
|
||||
cat << 'EOF'
|
||||
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expcted": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]}
|
||||
EOF
|
||||
|
||||
echo "Hit [Space] to read strip B (or 's' to skip)."
|
||||
sleep 1
|
||||
echo "Reading strip B..."
|
||||
|
||||
# Emit mock JSON for strip B
|
||||
cat << 'EOF'
|
||||
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}
|
||||
EOF
|
||||
|
||||
echo "Ready to read... done."
|
||||
exit 0
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
# Mock script for colprof
|
||||
# Simulates colprof execution and outputs progress log
|
||||
|
||||
basename="$1"
|
||||
# Find last argument if -D or other flags are used
|
||||
for arg in "$@"; do
|
||||
basename="$arg"
|
||||
done
|
||||
|
||||
echo "colprof: Starting profile calculation for $basename"
|
||||
sleep 1
|
||||
echo "Gamut mapping calculation..."
|
||||
sleep 1
|
||||
echo "Fitting cLUT grid points..."
|
||||
sleep 1
|
||||
echo "Writing ICC profile $basename.icc..."
|
||||
touch "$basename.icc"
|
||||
echo "Done."
|
||||
exit 0
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
# Mock script for profcheck
|
||||
# Simulates real ArgyllCMS profcheck -v -k -s -u output
|
||||
|
||||
echo "profcheck: Checking profile accuracy..."
|
||||
echo "No of test patches = 52"
|
||||
sleep 1
|
||||
cat << 'EOF'
|
||||
{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}
|
||||
EOF
|
||||
echo "Profile check complete, errors(CIEDE2000): max. = 2.41, avg. = 0.85, RMS = 1.02"
|
||||
exit 0
|
||||
@@ -0,0 +1,16 @@
|
||||
CGATS.17
|
||||
NUMBER_OF_FIELDS 4
|
||||
BEGIN_DATA_FORMAT
|
||||
INDEX LAB_L LAB_A LAB_B
|
||||
END_DATA_FORMAT
|
||||
NUMBER_OF_SETS 8
|
||||
BEGIN_DATA
|
||||
0 0.0 0.0 0.0
|
||||
1 100.0 0.0 0.0
|
||||
2 53.2 80.1 67.2
|
||||
3 87.7 -86.2 83.2
|
||||
4 97.1 -21.6 94.5
|
||||
5 32.3 79.2 -107.9
|
||||
6 60.3 98.2 -60.8
|
||||
7 91.1 -48.1 -14.1
|
||||
END_DATA
|
||||
@@ -4,5 +4,7 @@
|
||||
<dict>
|
||||
<!-- App Sandbox intentionally absent: ICCery must spawn Argyll tools,
|
||||
read/write user-chosen working directories, and talk to lp/CUPS. -->
|
||||
<key>com.apple.security.device.usb</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import ICCeryCore
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
@@ -19,15 +20,24 @@ struct ICCeryApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
/// AppDelegate: quit when the single window closes, and give later
|
||||
/// milestones a hook to `killAll` Argyll children before teardown
|
||||
/// (#147/#149 — wired once ProcessManager exists in #2).
|
||||
/// AppDelegate: quit when the single window closes, and `killAll` Argyll
|
||||
/// children before teardown (#147/#149). Termination is deferred until
|
||||
/// `killAll` has signaled every child so `chartread` can park an XY head
|
||||
/// when the UI already sent `q\n`.
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private var terminationRequested = false
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
// Issue #2+: ProcessManager.shared.killAll()
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
guard !terminationRequested else { return .terminateNow }
|
||||
terminationRequested = true
|
||||
Task {
|
||||
await ProcessManager.shared.killAll()
|
||||
NSApplication.shared.reply(toApplicationShouldTerminate: true)
|
||||
}
|
||||
return .terminateLater
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("BinaryResolver")
|
||||
struct BinaryResolverTests {
|
||||
|
||||
private func makeTree(_ body: (URL) throws -> Void) throws -> URL {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-resolver-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
try body(root)
|
||||
return root
|
||||
}
|
||||
|
||||
private func touch(_ url: URL, executable: Bool = true) throws {
|
||||
FileManager.default.createFile(atPath: url.path, contents: Data())
|
||||
if executable {
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: 0o755], ofItemAtPath: url.path
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func overrideDirWinsWhenFileExists() throws {
|
||||
let override = try makeTree { root in
|
||||
try touch(root.appendingPathComponent("targen"))
|
||||
}
|
||||
let bundled = try makeTree { _ in }
|
||||
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
|
||||
#expect(r.resolve("targen") == override.appendingPathComponent("targen"))
|
||||
}
|
||||
|
||||
@Test func overrideFallsThroughWhenMissing() throws {
|
||||
let override = try makeTree { _ in }
|
||||
let bundled = try makeTree { root in
|
||||
let dir = root.appendingPathComponent("macos-universal")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
try touch(dir.appendingPathComponent("instlist"))
|
||||
}
|
||||
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
|
||||
#expect(r.resolve("targen").path.contains("macos-universal/targen"))
|
||||
}
|
||||
|
||||
@Test func universalPreferredWhenMarkerPresent() throws {
|
||||
let bundled = try makeTree { root in
|
||||
for dir in ["macos-universal", "macos-x86_64"] {
|
||||
let d = root.appendingPathComponent(dir)
|
||||
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
|
||||
try touch(d.appendingPathComponent("instlist"))
|
||||
}
|
||||
}
|
||||
let r = BinaryResolver(bundledRoot: bundled)
|
||||
#expect(r.platformDir() == "macos-universal")
|
||||
}
|
||||
|
||||
@Test func fallsBackToArchDir() throws {
|
||||
let bundled = try makeTree { root in
|
||||
let d = root.appendingPathComponent("macos-x86_64")
|
||||
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
|
||||
try touch(d.appendingPathComponent("instlist"))
|
||||
}
|
||||
let r = BinaryResolver(
|
||||
bundledRoot: bundled,
|
||||
archDirs: ["macos-universal", "macos-x86_64"]
|
||||
)
|
||||
#expect(r.platformDir() == "macos-x86_64")
|
||||
}
|
||||
|
||||
@Test func missingEverythingReturnsConstructedPath() throws {
|
||||
let bundled = try makeTree { _ in }
|
||||
let r = BinaryResolver(bundledRoot: bundled)
|
||||
// v1 semantic: path is returned; spawn surfaces the error.
|
||||
#expect(r.resolve("targen").path.hasSuffix("macos-universal/targen"))
|
||||
#expect(!r.exists(r.resolve("targen")))
|
||||
}
|
||||
|
||||
@Test func mockAndGamutPaths() throws {
|
||||
let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x"))
|
||||
#expect(r.mock("chartread").path == "/x/mocks/chartread.mock")
|
||||
#expect(r.referenceGamut("sRGB.gam").path == "/x/reference_gamuts/sRGB.gam")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+16
@@ -19,9 +19,25 @@ targets:
|
||||
- path: Resources
|
||||
excludes:
|
||||
- ICCery.entitlements
|
||||
- Argyll
|
||||
- path: Resources/Argyll
|
||||
type: folder
|
||||
dependencies:
|
||||
- package: ICCeryCore
|
||||
product: ICCeryCore
|
||||
postBuildScripts:
|
||||
- name: Copy Argyll sidecars
|
||||
script: |
|
||||
set -e
|
||||
SRC="${SRCROOT}/Vendor/Argyll"
|
||||
DEST="${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Argyll"
|
||||
if [ -d "$SRC" ]; then
|
||||
mkdir -p "$DEST"
|
||||
rsync -a "$SRC/" "$DEST/"
|
||||
else
|
||||
echo "note: Vendor/Argyll absent — run scripts/fetch-argyll.sh"
|
||||
fi
|
||||
basedOnDependencyAnalysis: false
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.gronod.iccery2
|
||||
|
||||
Executable
+118
@@ -0,0 +1,118 @@
|
||||
#!/bin/sh
|
||||
# scripts/fetch-argyll.sh
|
||||
#
|
||||
# Downloads the Gronod ArgyllCMS fork release (macOS universal binaries)
|
||||
# into Vendor/Argyll/. POSIX sh + curl + tar — no Node dependency.
|
||||
#
|
||||
# Env overrides (parity with v1 fetch-argyll.mjs):
|
||||
# ARGYLL_SERVER_URL default https://git.i3omb.com
|
||||
# ARGYLL_REPO default gronod/argyllcms
|
||||
# ARGYLL_RELEASE_TAG default: latest release
|
||||
# GITEA_TOKEN optional, for private repos
|
||||
#
|
||||
# Layout produced (docs/04 §0.6, docs/02 §Sidecar layout):
|
||||
# Vendor/Argyll/macos-universal/<tools> # marker binary: instlist
|
||||
# Mocks and reference_gamuts are tracked under Resources/Argyll/ —
|
||||
# they ship in git, not in the release tarball.
|
||||
|
||||
set -eu
|
||||
|
||||
SERVER="${ARGYLL_SERVER_URL:-https://git.i3omb.com}"
|
||||
REPO="${ARGYLL_REPO:-gronod/argyllcms}"
|
||||
TAG="${ARGYLL_RELEASE_TAG:-}"
|
||||
SUFFIX="_macOS_universal_bin.tgz"
|
||||
PLATFORM_DIR="macos-universal"
|
||||
MARKER="instlist"
|
||||
|
||||
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
DEST="$ROOT/Vendor/Argyll/$PLATFORM_DIR"
|
||||
|
||||
FORCE=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--force) FORCE=1 ;;
|
||||
*) echo "usage: $0 [--force]" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$FORCE" -eq 0 ] && [ -x "$DEST/$MARKER" ]; then
|
||||
echo "ArgyllCMS binaries already present at $DEST (use --force to re-download)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
AUTH_HEADER=""
|
||||
if [ -n "${GITEA_TOKEN:-}" ]; then
|
||||
AUTH_HEADER="Authorization: token $GITEA_TOKEN"
|
||||
fi
|
||||
|
||||
api_get() {
|
||||
if [ -n "$AUTH_HEADER" ]; then
|
||||
curl -fsSL -H 'Accept: application/json' -H "$AUTH_HEADER" "$1"
|
||||
else
|
||||
curl -fsSL -H 'Accept: application/json' "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ -n "$TAG" ]; then
|
||||
API_URL="$SERVER/api/v1/repos/$REPO/releases/tags/$TAG"
|
||||
else
|
||||
API_URL="$SERVER/api/v1/repos/$REPO/releases/latest"
|
||||
fi
|
||||
|
||||
echo "Fetching release info from $API_URL"
|
||||
RELEASE_JSON="$(api_get "$API_URL")" || {
|
||||
echo "error: failed to fetch release info (set GITEA_TOKEN if the repo is private)" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Find the macOS universal asset's browser_download_url without jq.
|
||||
ASSET_URL="$(printf '%s' "$RELEASE_JSON" \
|
||||
| tr ',' '\n' \
|
||||
| grep '"browser_download_url"' \
|
||||
| grep "$SUFFIX" \
|
||||
| sed -E 's/.*"browser_download_url"[^"]*"([^"]+)".*/\1/' \
|
||||
| head -n 1)"
|
||||
|
||||
if [ -z "$ASSET_URL" ]; then
|
||||
echo "error: no release asset matching '*$SUFFIX' on $API_URL" >&2
|
||||
echo "looked-for pattern: Argyll_<tag>_<sha>$SUFFIX" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Downloading $ASSET_URL"
|
||||
TMPDIR_FETCH="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMPDIR_FETCH"' EXIT
|
||||
ARCHIVE="$TMPDIR_FETCH/argyll.tgz"
|
||||
|
||||
if [ -n "$AUTH_HEADER" ]; then
|
||||
curl -fSL -o "$ARCHIVE" -H "$AUTH_HEADER" "$ASSET_URL"
|
||||
else
|
||||
curl -fSL -o "$ARCHIVE" "$ASSET_URL"
|
||||
fi
|
||||
|
||||
EXTRACT="$TMPDIR_FETCH/extract"
|
||||
mkdir -p "$EXTRACT"
|
||||
tar -xzf "$ARCHIVE" -C "$EXTRACT"
|
||||
|
||||
# Archive contains Argyll_V*/bin/ (or a bare bin/).
|
||||
BIN_DIR=""
|
||||
for d in "$EXTRACT"/Argyll_V*/bin "$EXTRACT"/bin; do
|
||||
if [ -d "$d" ]; then BIN_DIR="$d"; break; fi
|
||||
done
|
||||
if [ -z "$BIN_DIR" ]; then
|
||||
echo "error: archive has no Argyll_V*/bin or bin/ directory" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$DEST"
|
||||
cp -R "$BIN_DIR"/. "$DEST"/
|
||||
find "$DEST" -type f -exec chmod 0755 {} +
|
||||
# Downloads carry com.apple.quarantine; the app cannot spawn quarantined tools.
|
||||
xattr -dr com.apple.quarantine "$DEST" 2>/dev/null || true
|
||||
|
||||
if [ ! -x "$DEST/$MARKER" ]; then
|
||||
echo "error: marker binary $MARKER missing after extraction" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK: $(ls "$DEST" | wc -l | tr -d ' ') tools installed to $DEST"
|
||||
Reference in New Issue
Block a user