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.
286 lines
9.6 KiB
Swift
286 lines
9.6 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|