tv-anarchy/Sources/TVAnarchyCore/ProcessRunner.swift
Natalie 92b38b1bae refactor(tv-anarchy): rename PlumTV→TVAnarchy and land session work
Renames Sources/PlumTV→TVAnarchy and PlumTVCore→TVAnarchyCore (the rename
the auto-commit service couldn't stage — it git-add'd the old, now-gone
paths and aborted every cycle), and commits the accumulated work:

- Library: black-built index fast path (LibraryIndex + scanFromIndex) with
  NFS-walk fallback; incremental --add on download-complete; mtime staleness
  gate; loose-file series-collapse fix; determinate scan/index progress.
- Cover art: keyless TVmaze cartoon-vs-live-action disambiguation (type/year).
- Player: sleep timer (timed + end-of-episode); visibility-gated polling.
- Home: Continue Watching cover art + live refresh; Recently Added; adult gate.
- Logs: multi-line selection + copy; truncated giant tx-list errors.
- Hover previews (opt-in) via black ffmpeg + scp.

Also gitignores foreign project trees (governor/mcp/fleet/recommender) that
sit in this directory but belong to their own repos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:04:22 -07:00

92 lines
3.8 KiB
Swift

import Foundation
public struct ProcessResult: Sendable {
public let status: Int32
public let stdout: String
public let stderr: String
public var ok: Bool { status == 0 }
}
/// Minimal blocking command runner. Outputs here are tiny (a line of JSON), so
/// the read-to-EOF pattern is safe. Always call off the main thread.
public enum ProcessRunner {
public static func run(_ launchPath: String, _ args: [String]) -> ProcessResult {
let p = Process()
p.executableURL = URL(fileURLWithPath: launchPath)
p.arguments = args
let out = Pipe()
let err = Pipe()
p.standardOutput = out
p.standardError = err
do {
try p.run()
} catch {
return ProcessResult(status: -1, stdout: "", stderr: "spawn failed: \(error.localizedDescription)")
}
let o = out.fileHandleForReading.readDataToEndOfFile()
let e = err.fileHandleForReading.readDataToEndOfFile()
p.waitUntilExit()
return ProcessResult(status: p.terminationStatus,
stdout: String(decoding: o, as: UTF8.self),
stderr: String(decoding: e, as: UTF8.self))
}
/// Run a command string under an interactive login shell (`/bin/zsh -ilc`) so
/// the user's full PATH (bun in ~/.bun/bin, uv, ) is available even from a GUI
/// app's minimal environment. `-i` is required because bun's PATH is exported
/// from ~/.zshrc, which a non-interactive login shell does NOT source.
/// Output is drained on background queues to avoid pipe-buffer deadlock, and
/// the process is terminated if it exceeds `timeout`.
public static func runShell(_ command: String, timeout: TimeInterval, cwd: String? = nil) -> ProcessResult {
let p = Process()
p.executableURL = URL(fileURLWithPath: "/bin/zsh")
p.arguments = ["-ilc", command]
if let cwd { p.currentDirectoryURL = URL(fileURLWithPath: cwd) }
let out = Pipe()
let err = Pipe()
p.standardOutput = out
p.standardError = err
let sem = DispatchSemaphore(value: 0)
p.terminationHandler = { _ in sem.signal() }
let group = DispatchGroup()
let q = DispatchQueue(label: "procrunner.read", attributes: .concurrent)
let box = OutputBox()
for (handle, isErr) in [(out.fileHandleForReading, false), (err.fileHandleForReading, true)] {
group.enter()
q.async {
let d = handle.readDataToEndOfFile()
box.set(d, isErr: isErr)
group.leave()
}
}
do { try p.run() } catch {
return ProcessResult(status: -1, stdout: "", stderr: "spawn failed: \(error.localizedDescription)")
}
var timedOut = false
if sem.wait(timeout: .now() + timeout) == .timedOut {
timedOut = true
p.terminate()
sem.wait()
}
group.wait()
let (o, e) = box.get()
let stderr = timedOut ? "timed out after \(Int(timeout))s\n" + String(decoding: e, as: UTF8.self)
: String(decoding: e, as: UTF8.self)
return ProcessResult(status: timedOut ? -2 : p.terminationStatus,
stdout: String(decoding: o, as: UTF8.self),
stderr: stderr)
}
}
/// Thread-safe collector for the two output streams drained concurrently.
private final class OutputBox: @unchecked Sendable {
private let lock = NSLock()
private var outData = Data()
private var errData = Data()
func set(_ d: Data, isErr: Bool) { lock.lock(); if isErr { errData = d } else { outData = d }; lock.unlock() }
func get() -> (Data, Data) { lock.lock(); defer { lock.unlock() }; return (outData, errData) }
}