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,285 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
enum JobTarget {
|
||||
case format(Format)
|
||||
case tool(ToolID)
|
||||
case action(ActionID)
|
||||
}
|
||||
|
||||
enum JobStatus {
|
||||
case running
|
||||
case finished
|
||||
case failed
|
||||
case cancelled
|
||||
|
||||
var isTerminal: Bool { self != .running }
|
||||
}
|
||||
|
||||
struct Job: Identifiable {
|
||||
let id = UUID()
|
||||
var title: String
|
||||
var detail: String = ""
|
||||
var progress: Double = 0
|
||||
var status: JobStatus = .running
|
||||
var outputs: [URL] = []
|
||||
var errorMessage: String?
|
||||
var message: String?
|
||||
var messageTitle: String?
|
||||
var startedAt = Date()
|
||||
var origin: CGPoint?
|
||||
var target: JobTarget
|
||||
var sourceCount: Int
|
||||
}
|
||||
|
||||
/// Runs conversions off the main thread and publishes their state to the
|
||||
/// progress panel. Two jobs run at once; the queue keeps heavy video work from
|
||||
/// starving the UI.
|
||||
final class JobStore: ObservableObject {
|
||||
static let shared = JobStore()
|
||||
|
||||
@Published private(set) var jobs: [Job] = []
|
||||
@Published var panelVisible = false
|
||||
|
||||
private var tokens: [UUID: Cancellation] = [:]
|
||||
private let queue: OperationQueue = {
|
||||
let queue = OperationQueue()
|
||||
queue.name = "dev.tzgyn.konverter.jobs"
|
||||
queue.maxConcurrentOperationCount = 2
|
||||
queue.qualityOfService = .userInitiated
|
||||
return queue
|
||||
}()
|
||||
|
||||
private init() {}
|
||||
|
||||
var activeCount: Int {
|
||||
jobs.filter { !$0.status.isTerminal }.count
|
||||
}
|
||||
|
||||
func submit(urls: [URL], target: JobTarget, params: ToolParams? = nil, origin: CGPoint? = nil) {
|
||||
guard !urls.isEmpty else { return }
|
||||
let token = Cancellation()
|
||||
let job = Job(
|
||||
title: title(for: target, urls: urls),
|
||||
origin: origin,
|
||||
target: target,
|
||||
sourceCount: urls.count
|
||||
)
|
||||
jobs.append(job)
|
||||
tokens[job.id] = token
|
||||
|
||||
if Settings.shared.showProgressWindow {
|
||||
panelVisible = true
|
||||
JobPanelController.shared.refresh(store: self)
|
||||
}
|
||||
|
||||
queue.addOperation { [weak self] in
|
||||
self?.execute(jobID: job.id, urls: urls, target: target, params: params, token: token)
|
||||
}
|
||||
}
|
||||
|
||||
func cancel(_ id: UUID) {
|
||||
tokens[id]?.cancel()
|
||||
mutate(id) { job in
|
||||
if job.status == .running { job.detail = "Cancelling…" }
|
||||
}
|
||||
}
|
||||
|
||||
func cancelAll() {
|
||||
for (id, token) in tokens {
|
||||
token.cancel()
|
||||
mutate(id) { job in
|
||||
if job.status == .running { job.detail = "Cancelling…" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dismissFinished() {
|
||||
jobs.removeAll { $0.status.isTerminal }
|
||||
if jobs.isEmpty { panelVisible = false }
|
||||
JobPanelController.shared.refresh(store: self)
|
||||
}
|
||||
|
||||
func remove(_ id: UUID) {
|
||||
jobs.removeAll { $0.id == id }
|
||||
if jobs.isEmpty { panelVisible = false }
|
||||
JobPanelController.shared.refresh(store: self)
|
||||
}
|
||||
|
||||
// MARK: - Execution
|
||||
|
||||
private func execute(jobID: UUID, urls: [URL], target: JobTarget, params: ToolParams?, token: Cancellation) {
|
||||
do {
|
||||
switch target {
|
||||
case .action(let action):
|
||||
try performAction(action, urls: urls, token: token, jobID: jobID)
|
||||
|
||||
case .format(let format):
|
||||
var outputs: [URL] = []
|
||||
var firstError: Error?
|
||||
for (index, url) in urls.enumerated() {
|
||||
try token.check()
|
||||
let destination = OutputNaming.destination(for: url, format: format)
|
||||
mutate(jobID) { job in
|
||||
job.detail = urls.count > 1 ? "\(url.lastPathComponent) → \(format.displayName)" : "Converting to \(format.displayName)"
|
||||
}
|
||||
do {
|
||||
let produced = try Converter.convert(
|
||||
source: url,
|
||||
to: format,
|
||||
at: destination,
|
||||
cancellation: token,
|
||||
progress: { [weak self] fraction in
|
||||
self?.setProgress(jobID, (Double(index) + fraction) / Double(urls.count))
|
||||
}
|
||||
)
|
||||
outputs.append(contentsOf: produced)
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
if firstError == nil { firstError = error }
|
||||
}
|
||||
}
|
||||
if outputs.isEmpty, let firstError { throw firstError }
|
||||
if let firstError {
|
||||
finish(jobID, outputs: outputs, error: firstError)
|
||||
} else {
|
||||
finish(jobID, outputs: outputs, error: nil)
|
||||
}
|
||||
|
||||
case .tool(let tool):
|
||||
let resolved = params ?? Settings.shared.params(for: tool)
|
||||
let outcome = try ToolRunner.run(
|
||||
tool: tool,
|
||||
inputs: urls,
|
||||
params: resolved,
|
||||
cancellation: token,
|
||||
progress: { [weak self] fraction in
|
||||
self?.setProgress(jobID, fraction)
|
||||
}
|
||||
)
|
||||
finish(jobID, outputs: outcome.outputs, error: nil, outcome: outcome)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
mutate(jobID) { job in
|
||||
job.status = .cancelled
|
||||
job.detail = "Cancelled"
|
||||
}
|
||||
tokens.removeValue(forKey: jobID)
|
||||
DispatchQueue.main.async { JobPanelController.shared.refresh(store: self) }
|
||||
} catch {
|
||||
finish(jobID, outputs: [], error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func performAction(_ action: ActionID, urls: [URL], token: Cancellation, jobID: UUID) throws {
|
||||
var failure: Error?
|
||||
let ready = DispatchSemaphore(value: 0)
|
||||
Task { @MainActor in
|
||||
do {
|
||||
try await FileActions.perform(action, urls: urls)
|
||||
} catch {
|
||||
failure = error
|
||||
}
|
||||
ready.signal()
|
||||
}
|
||||
ready.wait()
|
||||
try token.check()
|
||||
if let failure { throw failure }
|
||||
mutate(jobID) { job in
|
||||
job.detail = "Done"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - State updates
|
||||
|
||||
private func title(for target: JobTarget, urls: [URL]) -> String {
|
||||
let name = urls.count == 1
|
||||
? urls[0].lastPathComponent
|
||||
: "\(urls.count) files"
|
||||
switch target {
|
||||
case .format(let format): return "\(name) → \(format.displayName)"
|
||||
case .tool(let tool): return "\(tool.title) · \(name)"
|
||||
case .action(let action): return "\(action.title) · \(name)"
|
||||
}
|
||||
}
|
||||
|
||||
private func setProgress(_ id: UUID, _ value: Double) {
|
||||
mutate(id, throttle: true) { job in
|
||||
job.progress = max(job.progress, min(1, value))
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(_ id: UUID, outputs: [URL], error: Error?, outcome: ToolOutcome? = nil) {
|
||||
tokens.removeValue(forKey: id)
|
||||
mutate(id) { job in
|
||||
job.outputs = outputs
|
||||
job.progress = 1
|
||||
if let error {
|
||||
job.status = .failed
|
||||
job.errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
||||
job.detail = "Failed"
|
||||
} else {
|
||||
job.status = .finished
|
||||
job.detail = outputs.count > 1 ? "\(outputs.count) files" : "Done"
|
||||
job.message = outcome?.message
|
||||
job.messageTitle = outcome?.messageTitle
|
||||
}
|
||||
}
|
||||
|
||||
guard let job = jobs.first(where: { $0.id == id }) else { return }
|
||||
|
||||
if let error = job.errorMessage {
|
||||
let title = job.title
|
||||
DispatchQueue.main.async {
|
||||
Notifier.error(title, error)
|
||||
}
|
||||
} else if let message = job.message {
|
||||
let title = job.messageTitle ?? "Result"
|
||||
DispatchQueue.main.async {
|
||||
ResultWindowController.shared.show(title: title, body: message)
|
||||
}
|
||||
}
|
||||
|
||||
if Settings.shared.playSound, job.status == .finished, error == nil {
|
||||
DispatchQueue.main.async {
|
||||
NSSound(named: "Glass")?.play()
|
||||
}
|
||||
}
|
||||
|
||||
let revealable = job.outputs
|
||||
if Settings.shared.revealWhenFinished, !revealable.isEmpty, error == nil {
|
||||
DispatchQueue.main.async {
|
||||
NSWorkspace.shared.activateFileViewerSelecting(revealable)
|
||||
// The progress panel would otherwise stay on top of Finder.
|
||||
JobPanelController.shared.dismissIfAllFinished(store: self)
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
JobPanelController.shared.refresh(store: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a mutation on the main thread; `throttle` skips updates that
|
||||
/// would only nudge the progress bar by a hair.
|
||||
private func mutate(_ id: UUID, throttle: Bool = false, _ body: @escaping (inout Job) -> Void) {
|
||||
let apply = { [weak self] in
|
||||
guard let self, let index = self.jobs.firstIndex(where: { $0.id == id }) else { return }
|
||||
if throttle {
|
||||
let before = self.jobs[index].progress
|
||||
body(&self.jobs[index])
|
||||
let after = self.jobs[index].progress
|
||||
if abs(after - before) < 0.01, after < 1 { return }
|
||||
} else {
|
||||
body(&self.jobs[index])
|
||||
}
|
||||
JobPanelController.shared.refresh(store: self)
|
||||
}
|
||||
if Thread.isMainThread {
|
||||
apply()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: apply)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user