Add Konverter: a local-only menu bar file converter
Drag files in Finder, hold a modifier, and drop on a wheel of outputs. Conversions run entirely on-device through ImageIO, PDFKit, AVFoundation, the bundled CLI tools, and optional ffmpeg/LibreOffice. Includes a --self-test suite (41 checks) and a --convert CLI mode so the conversion matrix can be verified without the UI.
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
import Foundation
|
||||
|
||||
/// A cooperative cancellation token shared between a job and the external
|
||||
/// processes it spawns. Converters run off the main thread, so every mutation
|
||||
/// is lock protected.
|
||||
final class Cancellation {
|
||||
private let lock = NSLock()
|
||||
private var cancelled = false
|
||||
private var processes: [Process] = []
|
||||
|
||||
var isCancelled: Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return cancelled
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
lock.lock()
|
||||
cancelled = true
|
||||
let running = processes
|
||||
lock.unlock()
|
||||
for process in running where process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
func register(_ process: Process) {
|
||||
lock.lock()
|
||||
let alreadyCancelled = cancelled
|
||||
if !alreadyCancelled { processes.append(process) }
|
||||
lock.unlock()
|
||||
if alreadyCancelled, process.isRunning { process.terminate() }
|
||||
}
|
||||
|
||||
func check() throws {
|
||||
if isCancelled { throw CancellationError() }
|
||||
}
|
||||
}
|
||||
|
||||
struct ShellError: LocalizedError {
|
||||
let command: String
|
||||
let status: Int32
|
||||
let stderr: String
|
||||
|
||||
var errorDescription: String? {
|
||||
let detail = stderr.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if detail.isEmpty {
|
||||
return "\(command) exited with status \(status)."
|
||||
}
|
||||
// External tools can be very chatty; keep the first meaningful line.
|
||||
let line = detail
|
||||
.split(separator: "\n")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.last { !$0.isEmpty } ?? detail
|
||||
return "\(command): \(line.prefix(400))"
|
||||
}
|
||||
}
|
||||
|
||||
/// Thin wrapper around `Process` with deadlock-free pipe draining.
|
||||
enum Shell {
|
||||
struct Result {
|
||||
let status: Int32
|
||||
let stdout: String
|
||||
let stderr: String
|
||||
}
|
||||
|
||||
private final class Box<T> {
|
||||
var value: T
|
||||
init(_ value: T) { self.value = value }
|
||||
}
|
||||
|
||||
/// Locate an executable by name, searching `extraPaths` first and then
|
||||
/// `$PATH`. Returns the last match so Homebrew's copies win over system
|
||||
/// stubs (e.g. the `/usr/bin/ffmpeg` shim that only opens the App Store).
|
||||
static func find(_ name: String, extraPaths: [String] = []) -> String? {
|
||||
var search = extraPaths
|
||||
if let path = ProcessInfo.processInfo.environment["PATH"] {
|
||||
search += path.split(separator: ":").map(String.init)
|
||||
}
|
||||
var found: String?
|
||||
for directory in search {
|
||||
let full = directory.hasSuffix("/") ? directory + name : directory + "/" + name
|
||||
if FileManager.default.isExecutableFile(atPath: full) {
|
||||
found = full
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func run(
|
||||
_ executable: String,
|
||||
_ arguments: [String],
|
||||
currentDirectory: URL? = nil,
|
||||
cancellation: Cancellation? = nil
|
||||
) throws -> Result {
|
||||
try cancellation?.check()
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: executable)
|
||||
process.arguments = arguments
|
||||
if let currentDirectory { process.currentDirectoryURL = currentDirectory }
|
||||
|
||||
let outPipe = Pipe()
|
||||
let errPipe = Pipe()
|
||||
process.standardOutput = outPipe
|
||||
process.standardError = errPipe
|
||||
process.standardInput = FileHandle.nullDevice
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
throw ShellError(
|
||||
command: (arguments.first ?? executable),
|
||||
status: -1,
|
||||
stderr: error.localizedDescription
|
||||
)
|
||||
}
|
||||
cancellation?.register(process)
|
||||
|
||||
// Drain both pipes on separate queues: reading them in sequence can
|
||||
// deadlock when a tool fills the buffer we are not reading yet.
|
||||
let outBox = Box(Data())
|
||||
let errBox = Box(Data())
|
||||
let group = DispatchGroup()
|
||||
DispatchQueue.global(qos: .utility).async(group: group) {
|
||||
outBox.value = (try? outPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
}
|
||||
DispatchQueue.global(qos: .utility).async(group: group) {
|
||||
errBox.value = (try? errPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
}
|
||||
group.wait()
|
||||
process.waitUntilExit()
|
||||
|
||||
let result = Result(
|
||||
status: process.terminationStatus,
|
||||
stdout: String(data: outBox.value, encoding: .utf8) ?? "",
|
||||
stderr: String(data: errBox.value, encoding: .utf8) ?? ""
|
||||
)
|
||||
|
||||
try cancellation?.check()
|
||||
if result.status != 0 {
|
||||
throw ShellError(
|
||||
command: (arguments.first ?? executable),
|
||||
status: result.status,
|
||||
stderr: result.stderr.isEmpty ? result.stdout : result.stderr
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Best-effort variant used for optional probes (`ffprobe`, `mdls`, …).
|
||||
static func output(_ executable: String, _ arguments: [String]) -> String? {
|
||||
(try? run(executable, arguments))?.stdout
|
||||
}
|
||||
|
||||
/// Runs a tool whose stdout is binary (compressed streams) and returns it.
|
||||
static func runData(
|
||||
_ executable: String,
|
||||
_ arguments: [String],
|
||||
cancellation: Cancellation? = nil
|
||||
) throws -> Data {
|
||||
try cancellation?.check()
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: executable)
|
||||
process.arguments = arguments
|
||||
let outPipe = Pipe()
|
||||
let errPipe = Pipe()
|
||||
process.standardOutput = outPipe
|
||||
process.standardError = errPipe
|
||||
process.standardInput = FileHandle.nullDevice
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
throw ShellError(command: arguments.first ?? executable, status: -1, stderr: error.localizedDescription)
|
||||
}
|
||||
cancellation?.register(process)
|
||||
|
||||
let outBox = Box(Data())
|
||||
let errBox = Box(Data())
|
||||
let group = DispatchGroup()
|
||||
DispatchQueue.global(qos: .utility).async(group: group) {
|
||||
outBox.value = (try? outPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
}
|
||||
DispatchQueue.global(qos: .utility).async(group: group) {
|
||||
errBox.value = (try? errPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
}
|
||||
group.wait()
|
||||
process.waitUntilExit()
|
||||
|
||||
let stderr = String(data: errBox.value, encoding: .utf8) ?? ""
|
||||
try cancellation?.check()
|
||||
if process.terminationStatus != 0 {
|
||||
throw ShellError(command: arguments.first ?? executable, status: process.terminationStatus, stderr: stderr)
|
||||
}
|
||||
return outBox.value
|
||||
}
|
||||
|
||||
/// Runs a tool and reports progress parsed from `key=value` lines on
|
||||
/// stdout (what `ffmpeg -progress pipe:1` emits).
|
||||
static func runReportingProgress(
|
||||
_ executable: String,
|
||||
_ arguments: [String],
|
||||
cancellation: Cancellation? = nil,
|
||||
onProgress: @escaping (Double, String) -> Void
|
||||
) throws {
|
||||
try cancellation?.check()
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: executable)
|
||||
process.arguments = arguments
|
||||
let outPipe = Pipe()
|
||||
let errPipe = Pipe()
|
||||
process.standardOutput = outPipe
|
||||
process.standardError = errPipe
|
||||
process.standardInput = FileHandle.nullDevice
|
||||
|
||||
let errBox = Box(Data())
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
errBox.value = (try? errPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
}
|
||||
|
||||
let buffer = Box(Data())
|
||||
let lock = NSLock()
|
||||
outPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let chunk = handle.availableData
|
||||
guard !chunk.isEmpty else { return }
|
||||
lock.lock()
|
||||
buffer.value.append(chunk)
|
||||
let text = String(data: buffer.value, encoding: .utf8) ?? ""
|
||||
var lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
|
||||
let remainder = lines.popLast() ?? ""
|
||||
buffer.value = Data(remainder.utf8)
|
||||
lock.unlock()
|
||||
for line in lines { onProgress(0, line) }
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
outPipe.fileHandleForReading.readabilityHandler = nil
|
||||
throw ShellError(command: arguments.first ?? executable, status: -1, stderr: error.localizedDescription)
|
||||
}
|
||||
cancellation?.register(process)
|
||||
process.waitUntilExit()
|
||||
outPipe.fileHandleForReading.readabilityHandler = nil
|
||||
|
||||
let stderr = String(data: errBox.value, encoding: .utf8) ?? ""
|
||||
try cancellation?.check()
|
||||
if process.terminationStatus != 0 {
|
||||
throw ShellError(command: arguments.first ?? executable, status: process.terminationStatus, stderr: stderr)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user