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