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,144 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// The Control wheel: things you can do with a file that are not conversions.
|
||||
enum ActionID: String, CaseIterable, Identifiable, Codable {
|
||||
case airdrop, float, copy, move, copyPath, terminal, revealInFinder
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .airdrop: return "AirDrop"
|
||||
case .float: return "Float"
|
||||
case .copy: return "Copy"
|
||||
case .move: return "Move"
|
||||
case .copyPath: return "Copy Path"
|
||||
case .terminal: return "Terminal"
|
||||
case .revealInFinder: return "Reveal"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .airdrop: return "airdrop"
|
||||
case .float: return "macwindow.on.rectangle"
|
||||
case .copy: return "doc.on.doc"
|
||||
case .move: return "arrow.right.doc.on.clipboard"
|
||||
case .copyPath: return "text.cursor"
|
||||
case .terminal: return "terminal"
|
||||
case .revealInFinder: return "folder"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ActionError: LocalizedError {
|
||||
case needsSingleFile
|
||||
case noDestination
|
||||
case unavailable(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .needsSingleFile: return "This action works on one file at a time."
|
||||
case .noDestination: return "No destination folder was chosen."
|
||||
case .unavailable(let reason): return reason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// File actions never touch the file's contents, and never use a conversion
|
||||
/// slot — matching Tangerine's behaviour.
|
||||
enum FileActions {
|
||||
@MainActor
|
||||
static func perform(_ action: ActionID, urls: [URL], settings: Settings = .shared) async throws {
|
||||
guard !urls.isEmpty else { throw ActionError.noDestination }
|
||||
switch action {
|
||||
case .airdrop:
|
||||
try performAirDrop(urls: urls)
|
||||
case .float:
|
||||
FloatPanel.shared.show(urls: urls)
|
||||
case .copy:
|
||||
if let destination = try await chooseDestination() {
|
||||
try transfer(urls: urls, to: destination, moving: false)
|
||||
}
|
||||
case .move:
|
||||
if let destination = try await chooseDestination() {
|
||||
try transfer(urls: urls, to: destination, moving: true)
|
||||
}
|
||||
case .copyPath:
|
||||
copyPaths(urls: urls)
|
||||
case .terminal:
|
||||
try openTerminal(urls: urls)
|
||||
case .revealInFinder:
|
||||
NSWorkspace.shared.activateFileViewerSelecting(urls)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func performAirDrop(urls: [URL]) throws {
|
||||
guard let service = NSSharingService(named: .sendViaAirDrop) else {
|
||||
throw ActionError.unavailable("AirDrop is not available on this Mac.")
|
||||
}
|
||||
guard service.canPerform(withItems: urls) else {
|
||||
throw ActionError.unavailable("AirDrop cannot send this selection.")
|
||||
}
|
||||
service.perform(withItems: urls)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func chooseDestination() async throws -> URL? {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canCreateDirectories = true
|
||||
panel.prompt = "Choose"
|
||||
panel.message = "Choose a destination folder"
|
||||
panel.directoryURL = Settings.shared.resolvedOutputFolder
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
return panel.runModal() == .OK ? panel.url : nil
|
||||
}
|
||||
|
||||
private static func transfer(urls: [URL], to destination: URL, moving: Bool) throws {
|
||||
let manager = FileManager.default
|
||||
for url in urls {
|
||||
var target = destination.appendingPathComponent(url.lastPathComponent)
|
||||
target = OutputNaming.availableURL(for: target)
|
||||
do {
|
||||
if moving {
|
||||
try manager.moveItem(at: url, to: target)
|
||||
} else {
|
||||
try manager.copyItem(at: url, to: target)
|
||||
}
|
||||
} catch {
|
||||
throw ActionError.unavailable("Could not \(moving ? "move" : "copy") \(url.lastPathComponent): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func copyPaths(urls: [URL]) {
|
||||
let text = urls.map(\.path).joined(separator: "\n")
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(text, forType: .string)
|
||||
Notifier.flash(
|
||||
title: urls.count == 1 ? "Path copied" : "\(urls.count) paths copied",
|
||||
body: text.count > 120 ? String(text.prefix(120)) + "…" : text
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func openTerminal(urls: [URL]) throws {
|
||||
let directory = urls[0].deletingLastPathComponent()
|
||||
let terminal = URL(fileURLWithPath: "/System/Applications/Utilities/Terminal.app")
|
||||
let configuration = NSWorkspace.OpenConfiguration()
|
||||
NSWorkspace.shared.open([directory], withApplicationAt: terminal, configuration: configuration) { _, error in
|
||||
if let error {
|
||||
DispatchQueue.main.async {
|
||||
Notifier.error("Terminal", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import Foundation
|
||||
|
||||
/// The broad family a file belongs to. Drives which wheel is shown and which
|
||||
/// converter runs.
|
||||
enum FileCategory: String, CaseIterable, Identifiable, Codable {
|
||||
case image, video, audio, pdf, document, slides, spreadsheet, ebook, subtitle, archive, folder, unknown
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .image: return "Images"
|
||||
case .video: return "Video"
|
||||
case .audio: return "Audio"
|
||||
case .pdf: return "PDF"
|
||||
case .document: return "Documents"
|
||||
case .slides: return "Slides"
|
||||
case .spreadsheet: return "Spreadsheets"
|
||||
case .ebook: return "EPUB"
|
||||
case .subtitle: return "Subtitles"
|
||||
case .archive: return "Archives"
|
||||
case .folder: return "Folders"
|
||||
case .unknown: return "Files"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .image: return "photo"
|
||||
case .video: return "film"
|
||||
case .audio: return "waveform"
|
||||
case .pdf: return "doc.richtext"
|
||||
case .document: return "doc.text"
|
||||
case .slides: return "rectangle.on.rectangle.angled"
|
||||
case .spreadsheet: return "tablecells"
|
||||
case .ebook: return "book"
|
||||
case .subtitle: return "captions.bubble"
|
||||
case .archive: return "doc.zipper"
|
||||
case .folder: return "folder"
|
||||
case .unknown: return "doc"
|
||||
}
|
||||
}
|
||||
|
||||
/// Wheels only make sense for these.
|
||||
static var convertible: [FileCategory] {
|
||||
[.image, .video, .audio, .pdf, .document, .slides, .spreadsheet, .ebook, .subtitle, .archive]
|
||||
}
|
||||
}
|
||||
|
||||
/// A canonical output extension.
|
||||
enum Format: String, CaseIterable, Identifiable, Codable, Hashable {
|
||||
// Images
|
||||
case jpg, png, heic, webp, tiff, bmp, gif, avif, svg, ico
|
||||
// Documents
|
||||
case pdf
|
||||
case docx, doc, pages, rtf, txt, md, html, odt, epub
|
||||
// Slides
|
||||
case pptx, ppt, key
|
||||
// Spreadsheets
|
||||
case xlsx, xls, ods, csv, tsv, numbers
|
||||
// Audio
|
||||
case mp3, m4a, wav, flac, ogg, opus, aiff, wma
|
||||
// Video
|
||||
case mp4, mov, mkv, webm, avi, wmv, m4v
|
||||
// Subtitles
|
||||
case srt, vtt
|
||||
// Archives
|
||||
case zip, tar, gzip
|
||||
|
||||
var id: String { rawValue }
|
||||
var ext: String { rawValue }
|
||||
var displayName: String { rawValue.uppercased() }
|
||||
|
||||
/// Which wheel this format belongs to when offered as an output.
|
||||
var category: FileCategory {
|
||||
switch self {
|
||||
case .jpg, .png, .heic, .webp, .tiff, .bmp, .gif, .avif, .svg, .ico: return .image
|
||||
case .pdf: return .pdf
|
||||
case .docx, .doc, .pages, .rtf, .txt, .md, .html, .odt: return .document
|
||||
case .epub: return .ebook
|
||||
case .pptx, .ppt, .key: return .slides
|
||||
case .xlsx, .xls, .ods, .csv, .tsv, .numbers: return .spreadsheet
|
||||
case .mp3, .m4a, .wav, .flac, .ogg, .opus, .aiff, .wma: return .audio
|
||||
case .mp4, .mov, .mkv, .webm, .avi, .wmv, .m4v: return .video
|
||||
case .srt, .vtt: return .subtitle
|
||||
case .zip, .tar, .gzip: return .archive
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .jpg, .png, .heic, .webp, .tiff, .bmp, .gif, .avif, .ico: return "photo"
|
||||
case .svg: return "scribble.variable"
|
||||
case .pdf: return "doc.richtext"
|
||||
case .md: return "text.alignleft"
|
||||
case .txt: return "doc.plaintext"
|
||||
case .html: return "chevron.left.forwardslash.chevron.right"
|
||||
case .docx, .doc: return "doc.text"
|
||||
case .rtf: return "doc.richtext"
|
||||
case .odt: return "doc.text"
|
||||
case .pages: return "doc.text"
|
||||
case .epub: return "book"
|
||||
case .pptx, .ppt: return "rectangle.on.rectangle.angled"
|
||||
case .key: return "rectangle.on.rectangle.angled"
|
||||
case .xlsx, .xls: return "tablecells"
|
||||
case .ods: return "tablecells"
|
||||
case .csv, .tsv: return "tablecells.badge.ellipsis"
|
||||
case .numbers: return "tablecells"
|
||||
case .mp3, .m4a: return "waveform"
|
||||
case .wav, .flac, .aiff: return "waveform"
|
||||
case .ogg, .opus, .wma: return "waveform"
|
||||
case .mp4, .mov, .mkv, .webm, .avi, .wmv, .m4v: return "film"
|
||||
case .srt, .vtt: return "captions.bubble"
|
||||
case .zip, .tar, .gzip: return "doc.zipper"
|
||||
}
|
||||
}
|
||||
|
||||
/// Uniform type identifier, also used for ImageIO destinations.
|
||||
var uti: String? {
|
||||
switch self {
|
||||
case .jpg: return "public.jpeg"
|
||||
case .png: return "public.png"
|
||||
case .heic: return "public.heic"
|
||||
case .webp: return "org.webmproject.webp"
|
||||
case .tiff: return "public.tiff"
|
||||
case .bmp: return "com.microsoft.bmp"
|
||||
case .gif: return "com.compuserve.gif"
|
||||
case .avif: return "public.avif"
|
||||
case .svg: return "public.svg-image"
|
||||
case .ico: return "com.microsoft.ico"
|
||||
case .pdf: return "com.adobe.pdf"
|
||||
case .txt: return "public.plain-text"
|
||||
case .html: return "public.html"
|
||||
case .rtf: return "public.rtf"
|
||||
case .docx: return "org.openxmlformats.wordprocessingml.document"
|
||||
case .doc: return "com.microsoft.word.doc"
|
||||
case .odt: return "org.oasis-open.opendocument.text"
|
||||
case .epub: return "org.idpf.epub-container"
|
||||
case .pptx: return "org.openxmlformats.presentationml.presentation"
|
||||
case .xlsx: return "org.openxmlformats.spreadsheetml.sheet"
|
||||
case .csv: return "public.comma-separated-values-text"
|
||||
case .tsv: return "public.tab-separated-values-text"
|
||||
case .mp3: return "public.mp3"
|
||||
case .m4a: return "public.mpeg-4-audio"
|
||||
case .wav: return "com.microsoft.waveform-audio"
|
||||
case .aiff: return "public.aiff-audio"
|
||||
case .flac: return "org.xiph.flac"
|
||||
case .ogg: return "org.xiph.ogg"
|
||||
case .opus: return "org.xiph.opus"
|
||||
case .mp4: return "public.mpeg-4"
|
||||
case .mov: return "com.apple.quicktime-movie"
|
||||
case .mkv: return "org.matroska.mkv"
|
||||
case .srt: return "public.srt-subtitle"
|
||||
case .vtt: return "public.vtt-subtitle"
|
||||
case .zip: return "public.zip-archive"
|
||||
case .tar: return "public.tar-archive"
|
||||
case .gzip: return "org.gnu.gnu-zip-archive"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats Apple's frameworks cannot *write*; ffmpeg is required.
|
||||
var requiresFFmpeg: Bool {
|
||||
switch self {
|
||||
case .mkv, .webm, .avi, .wmv, .mp3, .flac, .ogg, .opus, .wma: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats that need LibreOffice to write with real layout fidelity.
|
||||
var requiresLibreOffice: Bool {
|
||||
switch self {
|
||||
case .pages, .key, .numbers, .ppt, .pptx, .xls: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Lossy formats take a quality knob.
|
||||
var isLossy: Bool {
|
||||
switch self {
|
||||
case .jpg, .heic, .avif, .webp, .mp3, .m4a, .ogg, .opus, .wma, .mp4, .mkv, .webm, .avi, .wmv, .m4v:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func from(extension ext: String) -> Format? {
|
||||
Format(rawValue: FileKind.canonicalExtension(ext))
|
||||
}
|
||||
}
|
||||
|
||||
/// Input side: figure out what a dropped file is.
|
||||
enum FileKind {
|
||||
private static let extensionMap: [String: FileCategory] = [
|
||||
// Images
|
||||
"jpg": .image, "jpeg": .image, "png": .image, "heic": .image, "heif": .image,
|
||||
"webp": .image, "tif": .image, "tiff": .image, "bmp": .image, "gif": .image,
|
||||
"avif": .image, "svg": .image, "ico": .image, "jp2": .image, "psd": .image,
|
||||
"exr": .image, "tga": .image, "raw": .image, "dng": .image, "cr2": .image,
|
||||
// Video
|
||||
"mp4": .video, "mov": .video, "m4v": .video, "mkv": .video, "webm": .video,
|
||||
"avi": .video, "wmv": .video, "flv": .video, "mpg": .video, "mpeg": .video,
|
||||
"3gp": .video, "ts": .video, "mts": .video,
|
||||
// Audio
|
||||
"mp3": .audio, "m4a": .audio, "m4b": .audio, "m4r": .audio, "wav": .audio,
|
||||
"flac": .audio, "ogg": .audio, "oga": .audio, "opus": .audio, "aiff": .audio,
|
||||
"aif": .audio, "aifc": .audio, "wma": .audio, "caf": .audio, "aac": .audio,
|
||||
"ape": .audio,
|
||||
// PDF
|
||||
"pdf": .pdf,
|
||||
// Documents
|
||||
"docx": .document, "doc": .document, "rtf": .document, "rtfd": .document,
|
||||
"odt": .document, "txt": .document, "text": .document, "md": .document,
|
||||
"markdown": .document, "html": .document, "htm": .document, "xml": .document,
|
||||
"webarchive": .document, "wordml": .document, "tex": .document, "log": .document,
|
||||
"pages": .document,
|
||||
// Slides
|
||||
"ppt": .slides, "pptx": .slides, "key": .slides, "odp": .slides,
|
||||
// Spreadsheets
|
||||
"xlsx": .spreadsheet, "xls": .spreadsheet, "ods": .spreadsheet, "csv": .spreadsheet,
|
||||
"tsv": .spreadsheet, "numbers": .spreadsheet,
|
||||
// Ebook
|
||||
"epub": .ebook, "mobi": .ebook, "azw3": .ebook,
|
||||
// Subtitles
|
||||
"srt": .subtitle, "vtt": .subtitle, "ass": .subtitle, "ssa": .subtitle, "sub": .subtitle,
|
||||
// Archives
|
||||
"zip": .archive, "tar": .archive, "gz": .archive, "tgz": .archive, "bz2": .archive,
|
||||
"xz": .archive, "rar": .archive, "7z": .archive, "zst": .archive
|
||||
]
|
||||
|
||||
private static let canonicalMap: [String: String] = [
|
||||
"jpeg": "jpg", "tif": "tiff", "markdown": "md", "htm": "html", "text": "txt",
|
||||
"tgz": "tar", "aif": "aiff", "aifc": "aiff", "oga": "ogg", "m4b": "m4a",
|
||||
"heif": "heic", "wordml": "odt"
|
||||
]
|
||||
|
||||
static func canonicalExtension(_ ext: String) -> String {
|
||||
let lower = ext.lowercased()
|
||||
return canonicalMap[lower] ?? lower
|
||||
}
|
||||
|
||||
static func category(of url: URL) -> FileCategory {
|
||||
if (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true {
|
||||
return .folder
|
||||
}
|
||||
return category(forExtension: url.pathExtension)
|
||||
}
|
||||
|
||||
static func category(forExtension ext: String) -> FileCategory {
|
||||
extensionMap[ext.lowercased()] ?? .unknown
|
||||
}
|
||||
|
||||
static func format(of url: URL) -> Format? {
|
||||
Format(rawValue: canonicalExtension(url.pathExtension))
|
||||
}
|
||||
|
||||
static func isSupportedInput(_ url: URL) -> Bool {
|
||||
category(of: url) != .unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// What a wheel can offer for a given selection. Kept separate from the UI so
|
||||
/// the same table drives the drag wheel, the menu bar fallback and the
|
||||
/// Quick Action.
|
||||
enum ConversionCatalog {
|
||||
static let targets: [FileCategory: [Format]] = [
|
||||
.image: [.png, .jpg, .heic, .webp, .tiff, .bmp, .gif, .avif, .pdf],
|
||||
.video: [.mp4, .mov, .mkv, .webm, .avi, .wmv, .gif, .mp3, .m4a, .wav],
|
||||
.audio: [.mp3, .m4a, .wav, .flac, .ogg, .opus, .aiff, .wma],
|
||||
.pdf: [.docx, .pptx, .xlsx, .jpg, .png, .epub, .txt, .md, .html],
|
||||
.document: [.docx, .md, .txt, .rtf, .html, .epub, .pdf],
|
||||
.slides: [.pdf, .jpg, .png],
|
||||
.spreadsheet: [.xlsx, .ods, .csv, .tsv, .pdf],
|
||||
.ebook: [.txt, .html, .md, .pdf],
|
||||
.subtitle: [.srt, .vtt, .txt],
|
||||
.archive: [.zip, .tar, .gzip]
|
||||
]
|
||||
|
||||
/// Outputs to show for a selection: the category's list minus the format
|
||||
/// the file already is. A mixed selection falls back to the intersection.
|
||||
static func outputs(for categories: [FileCategory]) -> [Format] {
|
||||
let unique = Set(categories)
|
||||
guard !unique.isEmpty, unique.count == 1, let category = unique.first else {
|
||||
return []
|
||||
}
|
||||
return targets[category] ?? []
|
||||
}
|
||||
|
||||
/// True when every input already has this extension (so it is pointless to
|
||||
/// offer it).
|
||||
static func isRedundant(_ format: Format, urls: [URL]) -> Bool {
|
||||
guard !urls.isEmpty else { return false }
|
||||
return urls.allSatisfy { FileKind.canonicalExtension($0.pathExtension) == format.ext }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Foundation
|
||||
|
||||
/// Where a produced file goes, and what it is called.
|
||||
enum OutputNaming {
|
||||
/// ``name.pdf`` for ``photo.jpg`` — appends ``-2``, ``-3`` … when the
|
||||
/// target already exists (unless the user asked to overwrite).
|
||||
static func destination(for source: URL, format: Format, settings: Settings = .shared) -> URL {
|
||||
let base = source.deletingPathExtension().lastPathComponent
|
||||
let directory: URL
|
||||
switch settings.outputLocation {
|
||||
case .besideOriginal:
|
||||
directory = source.deletingLastPathComponent()
|
||||
case .chosenFolder:
|
||||
let folder = settings.resolvedOutputFolder
|
||||
directory = FileManager.default.fileExists(atPath: folder.path)
|
||||
? folder
|
||||
: source.deletingLastPathComponent()
|
||||
}
|
||||
let candidate = directory.appendingPathComponent(base).appendingPathExtension(format.ext)
|
||||
return settings.conflictPolicy == .overwrite ? candidate : availableURL(for: candidate)
|
||||
}
|
||||
|
||||
/// A sibling of any output a tool produces (e.g. ``photo-compressed.jpg``).
|
||||
static func sibling(for source: URL, suffix: String, extension ext: String? = nil) -> URL {
|
||||
let base = source.deletingPathExtension().lastPathComponent
|
||||
let directory = source.deletingLastPathComponent()
|
||||
let resolvedExtension = ext ?? source.pathExtension
|
||||
return availableURL(
|
||||
for: directory.appendingPathComponent(base + suffix).appendingPathExtension(resolvedExtension)
|
||||
)
|
||||
}
|
||||
|
||||
/// A working directory for multi-file outputs (split pages, extracted
|
||||
/// images, unarchived trees).
|
||||
static func artifactDirectory(for source: URL, suffix: String) -> URL {
|
||||
let base = source.deletingPathExtension().lastPathComponent
|
||||
let directory = source.deletingLastPathComponent()
|
||||
let url = directory.appendingPathComponent(base + suffix, isDirectory: true)
|
||||
var candidate = url
|
||||
var index = 2
|
||||
while FileManager.default.fileExists(atPath: candidate.path) {
|
||||
candidate = directory.appendingPathComponent("\(base + suffix) \(index)", isDirectory: true)
|
||||
index += 1
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
/// ``foo 2.txt``, ``foo 3.txt`` … until the name is free.
|
||||
static func availableURL(for url: URL) -> URL {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return url }
|
||||
let directory = url.deletingLastPathComponent()
|
||||
let ext = url.pathExtension
|
||||
let base = url.deletingPathExtension().lastPathComponent
|
||||
var index = 2
|
||||
while true {
|
||||
let name = ext.isEmpty ? "\(base) \(index)" : "\(base) \(index).\(ext)"
|
||||
let candidate = directory.appendingPathComponent(name)
|
||||
if !FileManager.default.fileExists(atPath: candidate.path) { return candidate }
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
static func uniqueChild(of directory: URL, suggestedName: String) -> URL {
|
||||
availableURL(for: directory.appendingPathComponent(suggestedName))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import ServiceManagement
|
||||
|
||||
/// Trigger keys. Tangerine's split: Shift converts, Command fills the wheel
|
||||
/// with file tools, Control with file actions.
|
||||
enum TriggerKey: String, CaseIterable, Identifiable, Codable {
|
||||
case shift, control, option, command, function
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .shift: return "Shift ⇧"
|
||||
case .control: return "Control ⌃"
|
||||
case .option: return "Option ⌥"
|
||||
case .command: return "Command ⌘"
|
||||
case .function: return "Function fn"
|
||||
}
|
||||
}
|
||||
|
||||
var flag: NSEvent.ModifierFlags {
|
||||
switch self {
|
||||
case .shift: return .shift
|
||||
case .control: return .control
|
||||
case .option: return .option
|
||||
case .command: return .command
|
||||
case .function: return .function
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum OutputLocation: String, CaseIterable, Identifiable, Codable {
|
||||
case besideOriginal, chosenFolder
|
||||
|
||||
var id: String { rawValue }
|
||||
var title: String {
|
||||
switch self {
|
||||
case .besideOriginal: return "Beside the original"
|
||||
case .chosenFolder: return "In a folder I choose"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ConflictPolicy: String, CaseIterable, Identifiable, Codable {
|
||||
case uniqueName, overwrite
|
||||
|
||||
var id: String { rawValue }
|
||||
var title: String {
|
||||
switch self {
|
||||
case .uniqueName: return "Keep both (add a number)"
|
||||
case .overwrite: return "Replace the existing file"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum WheelStyle: String, CaseIterable, Identifiable, Codable {
|
||||
case radial, list
|
||||
|
||||
var id: String { rawValue }
|
||||
var title: String {
|
||||
switch self {
|
||||
case .radial: return "Wheel"
|
||||
case .list: return "List"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All preferences. Stored in `UserDefaults` under `konverter.*` so the app
|
||||
/// stays a single self-contained bundle with no sandbox container.
|
||||
final class Settings: ObservableObject {
|
||||
static let shared = Settings()
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
|
||||
@Published var convertKey: TriggerKey { didSet { write(convertKey, .convertKey) } }
|
||||
@Published var toolsKey: TriggerKey { didSet { write(toolsKey, .toolsKey) } }
|
||||
@Published var actionsKey: TriggerKey { didSet { write(actionsKey, .actionsKey) } }
|
||||
|
||||
/// When on, the tools wheel needs Shift + toolsKey so a plain ⌘-drag in
|
||||
/// other apps does not pop the wheel.
|
||||
@Published var requireShiftForTools: Bool { didSet { write(requireShiftForTools, .requireShiftForTools) } }
|
||||
|
||||
@Published var enabled: Bool { didSet { write(enabled, .enabled) } }
|
||||
@Published var allowActionsOnFolders: Bool { didSet { write(allowActionsOnFolders, .allowActionsOnFolders) } }
|
||||
|
||||
@Published var outputLocation: OutputLocation { didSet { write(outputLocation.rawValue, .outputLocation) } }
|
||||
@Published var outputFolderPath: String { didSet { write(outputFolderPath, .outputFolderPath) } }
|
||||
@Published var conflictPolicy: ConflictPolicy { didSet { write(conflictPolicy.rawValue, .conflictPolicy) } }
|
||||
|
||||
@Published var wheelStyle: WheelStyle { didSet { write(wheelStyle.rawValue, .wheelStyle) } }
|
||||
@Published var wheelRadius: Double { didSet { write(wheelRadius, .wheelRadius) } }
|
||||
@Published var showLabels: Bool { didSet { write(showLabels, .showLabels) } }
|
||||
@Published var dimBackground: Bool { didSet { write(dimBackground, .dimBackground) } }
|
||||
|
||||
@Published var showProgressWindow: Bool { didSet { write(showProgressWindow, .showProgressWindow) } }
|
||||
@Published var revealWhenFinished: Bool { didSet { write(revealWhenFinished, .revealWhenFinished) } }
|
||||
@Published var playSound: Bool { didSet { write(playSound, .playSound) } }
|
||||
@Published var useExternalTools: Bool { didSet { write(useExternalTools, .useExternalTools) } }
|
||||
|
||||
@Published var openAtLogin: Bool { didSet { write(openAtLogin, .openAtLogin) } }
|
||||
|
||||
/// Remembers per-tool form values so repeat jobs are still one drop.
|
||||
private var toolParamsCache: [String: [String: String]] = [:]
|
||||
|
||||
private init() {
|
||||
convertKey = Settings.read(.convertKey, default: .shift)
|
||||
toolsKey = Settings.read(.toolsKey, default: .command)
|
||||
actionsKey = Settings.read(.actionsKey, default: .control)
|
||||
requireShiftForTools = Settings.readBool(.requireShiftForTools, default: true)
|
||||
enabled = Settings.readBool(.enabled, default: true)
|
||||
allowActionsOnFolders = Settings.readBool(.allowActionsOnFolders, default: true)
|
||||
outputLocation = Settings.read(.outputLocation, default: .besideOriginal)
|
||||
outputFolderPath = Settings.readString(.outputFolderPath, default: "")
|
||||
conflictPolicy = Settings.read(.conflictPolicy, default: .uniqueName)
|
||||
wheelStyle = Settings.read(.wheelStyle, default: .radial)
|
||||
wheelRadius = Settings.readDouble(.wheelRadius, default: 168)
|
||||
showLabels = Settings.readBool(.showLabels, default: true)
|
||||
dimBackground = Settings.readBool(.dimBackground, default: false)
|
||||
showProgressWindow = Settings.readBool(.showProgressWindow, default: true)
|
||||
revealWhenFinished = Settings.readBool(.revealWhenFinished, default: true)
|
||||
playSound = Settings.readBool(.playSound, default: true)
|
||||
useExternalTools = Settings.readBool(.useExternalTools, default: true)
|
||||
openAtLogin = LoginItem.isEnabled
|
||||
}
|
||||
|
||||
var resolvedOutputFolder: URL {
|
||||
if outputLocation == .chosenFolder, !outputFolderPath.isEmpty {
|
||||
return URL(fileURLWithPath: outputFolderPath)
|
||||
}
|
||||
return FileManager.default.homeDirectoryForCurrentUser
|
||||
}
|
||||
|
||||
// MARK: - Tool parameter memory
|
||||
|
||||
func params(for tool: ToolID) -> ToolParams {
|
||||
var params = ToolParams.defaults(for: tool)
|
||||
if let stored = toolParamsCache[tool.rawValue] {
|
||||
for (key, value) in stored { params[key] = value }
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func store(params: ToolParams, for tool: ToolID) {
|
||||
toolParamsCache[tool.rawValue] = params.dictionary
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private enum Key: String {
|
||||
case convertKey, toolsKey, actionsKey, requireShiftForTools, enabled, allowActionsOnFolders
|
||||
case outputLocation, outputFolderPath, conflictPolicy
|
||||
case wheelStyle, wheelRadius, showLabels, dimBackground
|
||||
case showProgressWindow, revealWhenFinished, playSound, useExternalTools, openAtLogin
|
||||
}
|
||||
|
||||
private func write<T>(_ value: T, _ key: Key) {
|
||||
defaults.set(value, forKey: "konverter." + key.rawValue)
|
||||
}
|
||||
|
||||
private static func read<T: RawRepresentable>(_ key: Key, default fallback: T) -> T where T.RawValue == String {
|
||||
guard let raw = UserDefaults.standard.string(forKey: "konverter." + key.rawValue),
|
||||
let value = T(rawValue: raw) else { return fallback }
|
||||
return value
|
||||
}
|
||||
|
||||
private static func readString(_ key: Key, default fallback: String) -> String {
|
||||
UserDefaults.standard.string(forKey: "konverter." + key.rawValue) ?? fallback
|
||||
}
|
||||
|
||||
private static func readBool(_ key: Key, default fallback: Bool) -> Bool {
|
||||
guard UserDefaults.standard.object(forKey: "konverter." + key.rawValue) != nil else { return fallback }
|
||||
return UserDefaults.standard.bool(forKey: "konverter." + key.rawValue)
|
||||
}
|
||||
|
||||
private static func readDouble(_ key: Key, default fallback: Double) -> Double {
|
||||
guard UserDefaults.standard.object(forKey: "konverter." + key.rawValue) != nil else { return fallback }
|
||||
return UserDefaults.standard.double(forKey: "konverter." + key.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
enum LoginItem {
|
||||
static var isEnabled: Bool {
|
||||
SMAppService.mainApp.status == .enabled
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func set(_ enabled: Bool) -> Bool {
|
||||
let service = SMAppService.mainApp
|
||||
do {
|
||||
if enabled {
|
||||
if service.status != .enabled { try service.register() }
|
||||
} else {
|
||||
if service.status == .enabled { try service.unregister() }
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
|
||||
/// Optional external helpers. Konverter works without them — they only widen
|
||||
/// the conversion matrix (formats Apple's frameworks cannot write) and are
|
||||
/// detected once at launch.
|
||||
enum Toolchain {
|
||||
private static let homebrewPaths = ["/opt/homebrew/bin", "/usr/local/bin"]
|
||||
|
||||
/// ffmpeg/ffprobe: MKV, WebM, AVI, WMV, MP3, FLAC, OGG, Opus, WMA.
|
||||
static let ffmpeg: String? = Shell.find("ffmpeg", extraPaths: homebrewPaths)
|
||||
static let ffprobe: String? = Shell.find("ffprobe", extraPaths: homebrewPaths)
|
||||
|
||||
/// LibreOffice: PPT/PPTX rendering, XLS/XLSX/ODS, PAGES, Keynote.
|
||||
static let soffice: String? = {
|
||||
let candidates = [
|
||||
"/Applications/LibreOffice.app/Contents/MacOS/soffice",
|
||||
"/Applications/LibreOffice.app/Contents/MacOS/soffice.bin"
|
||||
]
|
||||
for candidate in candidates where FileManager.default.isExecutableFile(atPath: candidate) {
|
||||
return candidate
|
||||
}
|
||||
return Shell.find("soffice", extraPaths: homebrewPaths)
|
||||
}()
|
||||
|
||||
static var hasFFmpeg: Bool { ffmpeg != nil }
|
||||
static var hasLibreOffice: Bool { soffice != nil }
|
||||
|
||||
/// `curl`-free, sandbox-free best-effort image rasteriser for SVG and other
|
||||
/// formats ImageIO refuses to decode but Quick Look understands.
|
||||
static let qlmanage: String? = Shell.find("qlmanage", extraPaths: ["/usr/bin"])
|
||||
|
||||
static let ditto = "/usr/bin/ditto"
|
||||
static let tar = "/usr/bin/tar"
|
||||
static let gzip = "/usr/bin/gzip"
|
||||
static let gunzip = "/usr/bin/gunzip"
|
||||
static let textutil = "/usr/bin/textutil"
|
||||
static let afconvert = "/usr/bin/afconvert"
|
||||
static let zip = "/usr/bin/zip"
|
||||
static let unzip = "/usr/bin/unzip"
|
||||
|
||||
/// Human readable summary for the About/Settings pane.
|
||||
static var summary: String {
|
||||
var parts: [String] = ["Core (ImageIO, PDFKit, AVFoundation)"]
|
||||
if hasFFmpeg { parts.append("ffmpeg") }
|
||||
if hasLibreOffice { parts.append("LibreOffice") }
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
static var missingToolAdvice: String? {
|
||||
var advice: [String] = []
|
||||
if !hasFFmpeg { advice.append("brew install ffmpeg") }
|
||||
if !hasLibreOffice { advice.append("brew install --cask libreoffice") }
|
||||
return advice.isEmpty ? nil : advice.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import Foundation
|
||||
|
||||
/// A tool: an in-place transform that is not a plain format change.
|
||||
enum ToolID: String, CaseIterable, Identifiable, Codable {
|
||||
// Images
|
||||
case compressImage, resizeImage, cropImage, rotateImage, flipImage
|
||||
case grayscaleImage, stripMetadata, watermarkImage, readQR
|
||||
// Video
|
||||
case compressVideo, trimVideo, muteVideo, speedVideo, snapshotVideo
|
||||
case videoToGIF, resizeVideo
|
||||
// Audio
|
||||
case trimAudio, normalizeAudio, channelsAudio, speedAudio, bleepAudio, compressAudio
|
||||
// PDF
|
||||
case mergePDF, splitPDF, rotatePDF, compressPDF, encryptPDF, decryptPDF
|
||||
case imagesToPDF, collageImages, extractPDFText
|
||||
// Documents
|
||||
case documentToPDF, wordCount
|
||||
// Spreadsheets
|
||||
case sheetsToCSV
|
||||
// Subtitles
|
||||
case shiftSubtitle
|
||||
// Archives
|
||||
case extractArchive, listArchive
|
||||
// Media metadata
|
||||
case mediaInfo
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .compressImage: return "Compress"
|
||||
case .resizeImage: return "Resize"
|
||||
case .cropImage: return "Crop"
|
||||
case .rotateImage: return "Rotate"
|
||||
case .flipImage: return "Flip"
|
||||
case .grayscaleImage: return "Grayscale"
|
||||
case .stripMetadata: return "Strip Metadata"
|
||||
case .watermarkImage: return "Watermark"
|
||||
case .readQR: return "Read QR"
|
||||
case .compressVideo: return "Compress"
|
||||
case .trimVideo: return "Trim"
|
||||
case .muteVideo: return "Mute"
|
||||
case .speedVideo: return "Speed"
|
||||
case .snapshotVideo: return "Snapshot"
|
||||
case .videoToGIF: return "To GIF"
|
||||
case .resizeVideo: return "Resize"
|
||||
case .trimAudio: return "Trim"
|
||||
case .normalizeAudio: return "Normalize"
|
||||
case .channelsAudio: return "Channels"
|
||||
case .speedAudio: return "Speed"
|
||||
case .bleepAudio: return "Bleep"
|
||||
case .compressAudio: return "Compress"
|
||||
case .mergePDF: return "Merge"
|
||||
case .splitPDF: return "Split"
|
||||
case .rotatePDF: return "Rotate"
|
||||
case .compressPDF: return "Compress"
|
||||
case .encryptPDF: return "Encrypt"
|
||||
case .decryptPDF: return "Decrypt"
|
||||
case .imagesToPDF: return "Images to PDF"
|
||||
case .collageImages: return "Collage"
|
||||
case .extractPDFText: return "Extract Text"
|
||||
case .documentToPDF: return "To PDF"
|
||||
case .wordCount: return "Statistics"
|
||||
case .sheetsToCSV: return "To CSV"
|
||||
case .shiftSubtitle: return "Shift Time"
|
||||
case .extractArchive: return "Extract"
|
||||
case .listArchive: return "List Contents"
|
||||
case .mediaInfo: return "Info"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .compressImage, .compressVideo, .compressAudio, .compressPDF: return "arrow.down.right.and.arrow.up.left"
|
||||
case .resizeImage, .resizeVideo: return "arrow.up.left.and.arrow.down.right"
|
||||
case .cropImage: return "crop"
|
||||
case .rotateImage, .rotatePDF: return "rotate.right"
|
||||
case .flipImage: return "arrow.left.and.right.righttriangle.left.righttriangle.right"
|
||||
case .grayscaleImage: return "circle.lefthalf.filled"
|
||||
case .stripMetadata, .mediaInfo: return "info.circle"
|
||||
case .watermarkImage: return "signature"
|
||||
case .readQR: return "qrcode.viewfinder"
|
||||
case .trimVideo, .trimAudio: return "scissors"
|
||||
case .muteVideo, .bleepAudio: return "speaker.slash"
|
||||
case .speedVideo, .speedAudio: return "gauge.with.dots.needle.67percent"
|
||||
case .snapshotVideo: return "camera"
|
||||
case .videoToGIF: return "rectangle.stack"
|
||||
case .normalizeAudio: return "waveform.path.ecg"
|
||||
case .channelsAudio: return "speaker.wave.2"
|
||||
case .mergePDF: return "square.stack.3d.down.right"
|
||||
case .splitPDF: return "scissors"
|
||||
case .encryptPDF: return "lock"
|
||||
case .decryptPDF: return "lock.open"
|
||||
case .imagesToPDF: return "doc.badge.plus"
|
||||
case .collageImages: return "rectangle.grid.2x2"
|
||||
case .extractPDFText: return "text.viewfinder"
|
||||
case .documentToPDF: return "doc.richtext"
|
||||
case .wordCount: return "number"
|
||||
case .sheetsToCSV: return "tablecells.badge.ellipsis"
|
||||
case .shiftSubtitle: return "clock.arrow.circlepath"
|
||||
case .extractArchive: return "archivebox"
|
||||
case .listArchive: return "list.bullet.rectangle"
|
||||
}
|
||||
}
|
||||
|
||||
var category: FileCategory {
|
||||
switch self {
|
||||
case .compressImage, .resizeImage, .cropImage, .rotateImage, .flipImage,
|
||||
.grayscaleImage, .stripMetadata, .watermarkImage, .readQR:
|
||||
return .image
|
||||
case .compressVideo, .trimVideo, .muteVideo, .speedVideo, .snapshotVideo,
|
||||
.videoToGIF, .resizeVideo:
|
||||
return .video
|
||||
case .trimAudio, .normalizeAudio, .channelsAudio, .speedAudio, .bleepAudio, .compressAudio:
|
||||
return .audio
|
||||
case .mergePDF, .splitPDF, .rotatePDF, .compressPDF, .encryptPDF, .decryptPDF,
|
||||
.imagesToPDF, .collageImages, .extractPDFText:
|
||||
return .pdf
|
||||
case .documentToPDF, .wordCount:
|
||||
return .document
|
||||
case .sheetsToCSV:
|
||||
return .spreadsheet
|
||||
case .shiftSubtitle:
|
||||
return .subtitle
|
||||
case .extractArchive, .listArchive:
|
||||
return .archive
|
||||
case .mediaInfo:
|
||||
return .video
|
||||
}
|
||||
}
|
||||
|
||||
/// Tools that only make sense for a multi-file selection.
|
||||
var requiresMultipleFiles: Bool {
|
||||
self == .mergePDF || self == .imagesToPDF || self == .collageImages
|
||||
}
|
||||
|
||||
var minimumFileCount: Int { requiresMultipleFiles ? 2 : 1 }
|
||||
|
||||
/// Tools that produce a derived file instead of a transformed copy.
|
||||
var producesArtifacts: Bool {
|
||||
switch self {
|
||||
case .readQR, .extractPDFText, .listArchive, .mediaInfo, .wordCount:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Some tools can only work on narrower inputs (PDF tools on PDFs already,
|
||||
/// video-only tools on video).
|
||||
static func tools(for category: FileCategory) -> [ToolID] {
|
||||
allCases.filter { $0.category == category }
|
||||
}
|
||||
}
|
||||
|
||||
/// One editable field in a tool's options form.
|
||||
struct ToolField: Identifiable, Hashable {
|
||||
enum Kind: Hashable {
|
||||
case text
|
||||
case secret
|
||||
case integer(min: Int, max: Int)
|
||||
case decimal(min: Double, max: Double)
|
||||
case toggle
|
||||
case choice([String])
|
||||
case timecode
|
||||
}
|
||||
|
||||
let id: String
|
||||
let label: String
|
||||
let kind: Kind
|
||||
let defaultValue: String
|
||||
let unit: String?
|
||||
let help: String?
|
||||
|
||||
init(_ id: String, _ label: String, _ kind: Kind, default value: String, unit: String? = nil, help: String? = nil) {
|
||||
self.id = id
|
||||
self.label = label
|
||||
self.kind = kind
|
||||
self.defaultValue = value
|
||||
self.unit = unit
|
||||
self.help = help
|
||||
}
|
||||
}
|
||||
|
||||
extension ToolID {
|
||||
var fields: [ToolField] {
|
||||
switch self {
|
||||
case .compressImage:
|
||||
return [ToolField("quality", "Quality", .integer(min: 1, max: 100), default: "70", unit: "%")]
|
||||
case .resizeImage:
|
||||
return [
|
||||
ToolField("width", "Max width", .integer(min: 16, max: 20000), default: "1920", unit: "px"),
|
||||
ToolField("height", "Max height", .integer(min: 16, max: 20000), default: "1920", unit: "px"),
|
||||
ToolField("percent", "…or scale", .integer(min: 1, max: 400), default: "100", unit: "%")
|
||||
]
|
||||
case .cropImage:
|
||||
return [
|
||||
ToolField("inset", "Inset from each edge", .integer(min: 0, max: 4000), default: "0", unit: "px"),
|
||||
ToolField("aspect", "Aspect", .choice(["Keep", "1:1", "4:3", "3:2", "16:9", "9:16"]), default: "Keep")
|
||||
]
|
||||
case .rotateImage, .rotatePDF:
|
||||
return [ToolField("angle", "Rotation", .choice(["90° right", "180°", "90° left"]), default: "90° right")]
|
||||
case .flipImage:
|
||||
return [ToolField("axis", "Axis", .choice(["Horizontal", "Vertical"]), default: "Horizontal")]
|
||||
case .watermarkImage:
|
||||
return [
|
||||
ToolField("text", "Text", .text, default: "©"),
|
||||
ToolField("position", "Position", .choice(["Bottom Right", "Bottom Left", "Top Right", "Top Left", "Center"]), default: "Bottom Right"),
|
||||
ToolField("opacity", "Opacity", .integer(min: 5, max: 100), default: "45", unit: "%"),
|
||||
ToolField("size", "Size", .integer(min: 8, max: 40), default: "16", unit: "% of width")
|
||||
]
|
||||
case .compressVideo:
|
||||
return [ToolField("preset", "Quality", .choice(["Low", "Medium", "High"]), default: "Medium")]
|
||||
case .trimVideo, .trimAudio:
|
||||
return [
|
||||
ToolField("start", "Start", .timecode, default: "00:00:00"),
|
||||
ToolField("end", "End", .timecode, default: "", help: "Leave empty to keep the tail")
|
||||
]
|
||||
case .speedVideo, .speedAudio:
|
||||
return [ToolField("rate", "Speed", .decimal(min: 0.25, max: 4), default: "2", unit: "×")]
|
||||
case .snapshotVideo:
|
||||
return [ToolField("at", "Timestamp", .timecode, default: "00:00:01")]
|
||||
case .videoToGIF:
|
||||
return [
|
||||
ToolField("fps", "Frame rate", .integer(min: 5, max: 30), default: "12", unit: "fps"),
|
||||
ToolField("width", "Width", .integer(min: 120, max: 1600), default: "480", unit: "px")
|
||||
]
|
||||
case .resizeVideo:
|
||||
return [ToolField("width", "Width", .integer(min: 160, max: 7680), default: "1280", unit: "px")]
|
||||
case .bleepAudio:
|
||||
return [
|
||||
ToolField("start", "Mute from", .timecode, default: "00:00:00"),
|
||||
ToolField("end", "Mute until", .timecode, default: "00:00:01")
|
||||
]
|
||||
case .compressAudio:
|
||||
return [ToolField("bitrate", "Bitrate", .choice(["64", "96", "128", "192", "256", "320"]), default: "128", unit: "kbps")]
|
||||
case .channelsAudio:
|
||||
return [ToolField("mode", "Channels", .choice(["Mono", "Stereo"]), default: "Mono")]
|
||||
case .splitPDF:
|
||||
return [ToolField("pages", "Pages per file", .integer(min: 1, max: 500), default: "1")]
|
||||
case .compressPDF:
|
||||
return [ToolField("quality", "Image quality", .integer(min: 10, max: 100), default: "55", unit: "%")]
|
||||
case .encryptPDF:
|
||||
return [
|
||||
ToolField("password", "Password", .secret, default: ""),
|
||||
ToolField("owner", "Owner password", .secret, default: "", help: "Optional, defaults to the password")
|
||||
]
|
||||
case .decryptPDF:
|
||||
return [ToolField("password", "Password", .secret, default: "")]
|
||||
case .collageImages:
|
||||
return [
|
||||
ToolField("columns", "Columns", .integer(min: 1, max: 8), default: "3"),
|
||||
ToolField("page", "Page", .choice(["A4", "Letter", "Square"]), default: "A4"),
|
||||
ToolField("margin", "Margin", .integer(min: 0, max: 120), default: "24", unit: "pt"),
|
||||
ToolField("captions", "File names", .toggle, default: "false")
|
||||
]
|
||||
case .shiftSubtitle:
|
||||
return [ToolField("offset", "Shift by", .decimal(min: -600, max: 600), default: "1", unit: "s")]
|
||||
case .listArchive, .extractArchive, .wordCount, .mediaInfo, .readQR, .stripMetadata,
|
||||
.grayscaleImage, .normalizeAudio, .extractPDFText, .documentToPDF, .sheetsToCSV,
|
||||
.muteVideo, .mergePDF, .imagesToPDF:
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Values collected from a tool's options form. Round-trips through
|
||||
/// `UserDefaults` per tool so repeat use is one drop.
|
||||
struct ToolParams {
|
||||
private var storage: [String: String] = [:]
|
||||
|
||||
subscript(key: String) -> String? {
|
||||
get { storage[key] }
|
||||
set { storage[key] = newValue }
|
||||
}
|
||||
|
||||
func string(_ key: String, default fallback: String = "") -> String {
|
||||
let value = storage[key]?.trimmingCharacters(in: .whitespaces) ?? ""
|
||||
return value.isEmpty ? fallback : value
|
||||
}
|
||||
|
||||
func int(_ key: String, default fallback: Int) -> Int {
|
||||
Int(string(key)) ?? fallback
|
||||
}
|
||||
|
||||
func double(_ key: String, default fallback: Double) -> Double {
|
||||
Double(string(key)) ?? fallback
|
||||
}
|
||||
|
||||
func bool(_ key: String, default fallback: Bool) -> Bool {
|
||||
guard let value = storage[key] else { return fallback }
|
||||
return value == "true" || value == "1"
|
||||
}
|
||||
|
||||
static func defaults(for tool: ToolID) -> ToolParams {
|
||||
var params = ToolParams()
|
||||
for field in tool.fields {
|
||||
params[field.id] = field.defaultValue
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
mutating func fillMissing(from tool: ToolID) {
|
||||
for field in tool.fields where storage[field.id] == nil {
|
||||
storage[field.id] = field.defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
var isEmpty: Bool { storage.isEmpty }
|
||||
|
||||
var dictionary: [String: String] { storage }
|
||||
}
|
||||
|
||||
/// Timecodes come in as `mm:ss`, `hh:mm:ss` or plain seconds.
|
||||
enum Timecode {
|
||||
static func seconds(from string: String) -> Double? {
|
||||
let trimmed = string.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty { return nil }
|
||||
let parts = trimmed.split(separator: ":").map(String.init)
|
||||
switch parts.count {
|
||||
case 1:
|
||||
return Double(parts[0])
|
||||
case 2:
|
||||
guard let minutes = Double(parts[0]), let seconds = Double(parts[1]) else { return nil }
|
||||
return minutes * 60 + seconds
|
||||
case 3:
|
||||
guard let h = Double(parts[0]), let m = Double(parts[1]), let s = Double(parts[2]) else { return nil }
|
||||
return h * 3600 + m * 60 + s
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// One wedge of the wheel.
|
||||
struct WheelItem: Identifiable {
|
||||
enum Payload {
|
||||
case format(Format)
|
||||
case tool(ToolID)
|
||||
case action(ActionID)
|
||||
}
|
||||
|
||||
let id: String
|
||||
let payload: Payload
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
let symbol: String
|
||||
let availability: Availability
|
||||
|
||||
var isEnabled: Bool { availability.isAvailable }
|
||||
}
|
||||
|
||||
enum WheelKind {
|
||||
case convert
|
||||
case tools
|
||||
case actions
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .convert: return "Convert"
|
||||
case .tools: return "Tools"
|
||||
case .actions: return "Actions"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the wheel contents for whatever is being dragged.
|
||||
enum WheelBuilder {
|
||||
static func kind(for modifiers: NSEvent.ModifierFlags, settings: Settings = .shared) -> WheelKind? {
|
||||
if modifiers.contains(settings.actionsKey.flag) { return .actions }
|
||||
if modifiers.contains(settings.toolsKey.flag) {
|
||||
if settings.requireShiftForTools, !modifiers.contains(.shift) { return nil }
|
||||
return .tools
|
||||
}
|
||||
if modifiers.contains(settings.convertKey.flag) { return .convert }
|
||||
return nil
|
||||
}
|
||||
|
||||
static func items(kind: WheelKind, urls: [URL], settings: Settings = .shared) -> [WheelItem] {
|
||||
switch kind {
|
||||
case .convert:
|
||||
return conversionItems(urls: urls)
|
||||
case .tools:
|
||||
return toolItems(urls: urls)
|
||||
case .actions:
|
||||
return actionItems(urls: urls, settings: settings)
|
||||
}
|
||||
}
|
||||
|
||||
private static func conversionItems(urls: [URL]) -> [WheelItem] {
|
||||
let categories = urls.map { FileKind.category(of: $0) }
|
||||
let formats = ConversionCatalog.outputs(for: categories)
|
||||
guard let category = categories.first, !formats.isEmpty else { return [] }
|
||||
|
||||
return formats.compactMap { format in
|
||||
guard !ConversionCatalog.isRedundant(format, urls: urls) else { return nil }
|
||||
let availability = Converter.availability(target: format, source: category)
|
||||
return WheelItem(
|
||||
id: "format-\(format.ext)",
|
||||
payload: .format(format),
|
||||
title: format.displayName,
|
||||
subtitle: availability.isAvailable ? nil : availability.reason,
|
||||
symbol: format.symbol,
|
||||
availability: availability
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func toolItems(urls: [URL]) -> [WheelItem] {
|
||||
let categories = Set(urls.map { FileKind.category(of: $0) })
|
||||
guard categories.count == 1, let category = categories.first else { return [] }
|
||||
|
||||
return ToolID.tools(for: category).compactMap { tool in
|
||||
guard urls.count >= tool.minimumFileCount else { return nil }
|
||||
var availability = Availability.ok
|
||||
if tool.producesArtifacts, urls.count > 1 {
|
||||
availability = .ok
|
||||
}
|
||||
if requiresFFmpeg(tool), !Toolchain.hasFFmpeg {
|
||||
availability = .unavailable("Needs ffmpeg — brew install ffmpeg")
|
||||
}
|
||||
return WheelItem(
|
||||
id: "tool-\(tool.rawValue)",
|
||||
payload: .tool(tool),
|
||||
title: tool.title,
|
||||
subtitle: availability.isAvailable ? nil : availability.reason,
|
||||
symbol: tool.symbol,
|
||||
availability: availability
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func requiresFFmpeg(_ tool: ToolID) -> Bool {
|
||||
switch tool {
|
||||
case .normalizeAudio: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func actionItems(urls: [URL], settings: Settings) -> [WheelItem] {
|
||||
let allFolders = urls.allSatisfy { FileKind.category(of: $0) == .folder }
|
||||
return ActionID.allCases.compactMap { action in
|
||||
if allFolders, action != .airdrop, action != .copy, action != .move,
|
||||
action != .copyPath, action != .terminal, action != .revealInFinder {
|
||||
return nil
|
||||
}
|
||||
_ = settings
|
||||
return WheelItem(
|
||||
id: "action-\(action.rawValue)",
|
||||
payload: .action(action),
|
||||
title: action.title,
|
||||
subtitle: nil,
|
||||
symbol: action.symbol,
|
||||
availability: .ok
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-line description of the selection for the centre of the wheel.
|
||||
static func summary(for urls: [URL]) -> String {
|
||||
guard let first = urls.first else { return "" }
|
||||
if urls.count > 1 { return "\(urls.count) files" }
|
||||
return first.lastPathComponent
|
||||
}
|
||||
|
||||
static func icon(for urls: [URL]) -> NSImage {
|
||||
guard let first = urls.first else {
|
||||
return NSImage(systemSymbolName: "doc", accessibilityDescription: nil) ?? NSImage()
|
||||
}
|
||||
return NSWorkspace.shared.icon(forFile: first.path)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user