Files
konverter/Sources/Konverter/Core/JobStore.swift
T
TZGyn 2d031e58a4 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.
2026-10-20 00:36:40 +08:00

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)
}
}
}