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:
2026-10-20 00:36:40 +08:00
commit 2d031e58a4
47 changed files with 9149 additions and 0 deletions
+256
View File
@@ -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)
}
}
}