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
+110
View File
@@ -0,0 +1,110 @@
import AppKit
import Foundation
/// Native menu fallback for the paths where there is no drag to follow: the
/// menu bar's "Konvert Files…" item and the Finder Quick Action.
enum QuickMenu {
private final class Choice: NSObject {
let handler: () -> Void
init(_ handler: @escaping () -> Void) {
self.handler = handler
}
@objc func fire() {
handler()
}
}
/// The menu items hold their target weakly, so the choices are retained
/// here for the duration of the (synchronous) popup.
private static var retained: [Choice] = []
static func show(urls: [URL], at point: CGPoint) {
guard !urls.isEmpty else { return }
retained.removeAll()
let menu = NSMenu()
menu.autoenablesItems = false
let header = NSMenuItem(
title: WheelBuilder.summary(for: urls),
action: nil,
keyEquivalent: ""
)
header.isEnabled = false
menu.addItem(header)
menu.addItem(.separator())
let category = FileKind.category(of: urls[0])
if ConversionCatalog.outputs(for: urls.map { FileKind.category(of: $0) }).isEmpty {
let item = NSMenuItem(title: "No conversions for this selection", action: nil, keyEquivalent: "")
item.isEnabled = false
menu.addItem(item)
} else {
let convert = NSMenu()
for item in WheelBuilder.items(kind: .convert, urls: urls) {
let entry = NSMenuItem(title: item.title, action: #selector(Choice.fire), keyEquivalent: "")
entry.target = makeChoice {
JobStore.shared.submit(urls: urls, target: .format(format(from: item)))
}
entry.isEnabled = item.isEnabled
if let reason = item.availability.reason { entry.toolTip = reason }
convert.addItem(entry)
}
let submenu = NSMenuItem(title: "Convert", action: nil, keyEquivalent: "")
submenu.submenu = convert
menu.addItem(submenu)
}
let tools = WheelBuilder.items(kind: .tools, urls: urls)
if category != .unknown, !tools.isEmpty {
let submenu = NSMenu()
for item in tools {
guard case .tool(let tool) = item.payload else { continue }
let entry = NSMenuItem(title: tool.title, action: #selector(Choice.fire), keyEquivalent: "")
entry.target = makeChoice {
if tool.fields.isEmpty {
JobStore.shared.submit(urls: urls, target: .tool(tool))
} else {
OptionsWindowController.shared.present(tool: tool, urls: urls, origin: point)
}
}
entry.isEnabled = item.isEnabled
if let reason = item.availability.reason { entry.toolTip = reason }
submenu.addItem(entry)
}
let wrapper = NSMenuItem(title: "Tools", action: nil, keyEquivalent: "")
wrapper.submenu = submenu
menu.addItem(wrapper)
}
let actions = NSMenu()
for item in WheelBuilder.items(kind: .actions, urls: urls) {
guard case .action(let action) = item.payload else { continue }
let entry = NSMenuItem(title: action.title, action: #selector(Choice.fire), keyEquivalent: "")
entry.target = makeChoice {
JobStore.shared.submit(urls: urls, target: .action(action))
}
actions.addItem(entry)
}
let actionsWrapper = NSMenuItem(title: "Actions", action: nil, keyEquivalent: "")
actionsWrapper.submenu = actions
menu.addItem(actionsWrapper)
menu.popUp(positioning: nil, at: point, in: nil)
retained.removeAll()
}
private static func makeChoice(_ handler: @escaping () -> Void) -> Choice {
let choice = Choice(handler)
retained.append(choice)
return choice
}
private static func format(from item: WheelItem) -> Format {
if case .format(let format) = item.payload { return format }
return .png
}
}