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:
2026-10-20 00:36:40 +08:00
commit 2d031e58a4
47 changed files with 9149 additions and 0 deletions
+144
View File
@@ -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)
}
}
}
}
}
+296
View File
@@ -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 }
}
}
+285
View File
@@ -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)
}
}
}
+66
View File
@@ -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))
}
}
+201
View File
@@ -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
}
}
}
+256
View File
@@ -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)
}
}
}
+55
View File
@@ -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: " · ")
}
}
+333
View File
@@ -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
}
}
}
+141
View File
@@ -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)
}
}