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