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
@@ -0,0 +1,224 @@
import AppKit
import SwiftUI
/// Settings window. Also the place where the active conversion engines are
/// listed, so "why can't I export MKV?" has an answer in the app itself.
final class PreferencesWindowController {
static let shared = PreferencesWindowController()
private var window: NSWindow?
private init() {}
func show() {
if let window {
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
return
}
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 520, height: 620),
styleMask: [.titled, .closable, .fullSizeContentView],
backing: .buffered,
defer: false
)
window.title = "Konverter Settings"
window.titlebarAppearsTransparent = true
window.contentView = NSHostingView(rootView: PreferencesView())
window.center()
window.isReleasedWhenClosed = false
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
self.window = window
}
}
struct PreferencesView: View {
@ObservedObject private var settings = Settings.shared
@State private var loginItemError: String?
var body: some View {
TabView {
general.tabItem { Label("General", systemImage: "gearshape") }
wheel.tabItem { Label("Wheel", systemImage: "circle.hexagongrid") }
engines.tabItem { Label("Engines", systemImage: "cpu") }
}
.padding(16)
.frame(width: 520, height: 600)
}
private var general: some View {
Form {
Section {
Toggle("Konverter is active", isOn: $settings.enabled)
Toggle("Enable at login", isOn: Binding(
get: { settings.openAtLogin },
set: { value in
if LoginItem.set(value) {
settings.openAtLogin = value
loginItemError = nil
} else {
settings.openAtLogin = LoginItem.isEnabled
loginItemError = "Could not update the login item. Move Konverter to /Applications and try again."
}
}
))
if let loginItemError {
Text(loginItemError)
.font(.system(size: 10.5))
.foregroundStyle(.orange)
}
} header: {
Text("Startup")
}
Section {
Picker("Save converted files", selection: $settings.outputLocation) {
ForEach(OutputLocation.allCases) { location in
Text(location.title).tag(location)
}
}
if settings.outputLocation == .chosenFolder {
HStack {
Text(settings.outputFolderPath.isEmpty ? "No folder chosen" : settings.outputFolderPath)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Button("Choose…") { chooseFolder() }
}
}
Picker("When a file already exists", selection: $settings.conflictPolicy) {
ForEach(ConflictPolicy.allCases) { policy in
Text(policy.title).tag(policy)
}
}
} header: {
Text("Output")
}
Section {
Toggle("Show the job window", isOn: $settings.showProgressWindow)
Toggle("Reveal files in Finder when finished", isOn: $settings.revealWhenFinished)
Toggle("Play a sound when finished", isOn: $settings.playSound)
} header: {
Text("Feedback")
}
}
.formStyle(.grouped)
}
private var wheel: some View {
Form {
Section {
Picker("Convert", selection: $settings.convertKey) {
ForEach(TriggerKey.allCases) { key in Text(key.title).tag(key) }
}
Picker("Tools", selection: $settings.toolsKey) {
ForEach(TriggerKey.allCases) { key in Text(key.title).tag(key) }
}
Picker("Actions", selection: $settings.actionsKey) {
ForEach(TriggerKey.allCases) { key in Text(key.title).tag(key) }
}
Toggle("Tools also require Shift", isOn: $settings.requireShiftForTools)
} header: {
Text("Trigger keys")
} footer: {
Text("Start dragging a file in Finder, then hold a key. The wheel stays open until you drop or press Escape. Keys must differ, otherwise the first match wins (Actions, then Tools, then Convert).")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
Section {
Picker("Style", selection: $settings.wheelStyle) {
ForEach(WheelStyle.allCases) { style in Text(style.title).tag(style) }
}
HStack {
Text("Radius")
Slider(value: $settings.wheelRadius, in: 120...260, step: 4)
Text("\(Int(settings.wheelRadius))")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 28)
}
Toggle("Show labels", isOn: $settings.showLabels)
Toggle("Dim the background", isOn: $settings.dimBackground)
} header: {
Text("Appearance")
}
}
.formStyle(.grouped)
}
private var engines: some View {
Form {
Section {
Toggle("Use external tools", isOn: $settings.useExternalTools)
Text("Konverter always works with Apple's frameworks. ffmpeg and LibreOffice only widen the format list.")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
} header: {
Text("Conversion engines")
}
Section {
engineRow(name: "Core (ImageIO, PDFKit, AVFoundation, textutil)", available: true, detail: "Always available")
engineRow(
name: "ffmpeg",
available: Toolchain.hasFFmpeg,
detail: Toolchain.ffmpeg ?? "Install with: brew install ffmpeg"
)
engineRow(
name: "LibreOffice",
available: Toolchain.hasLibreOffice,
detail: Toolchain.soffice ?? "Install with: brew install --cask libreoffice"
)
} header: {
Text("Detected")
}
Section {
Text("Active: \(Toolchain.summary)")
.font(.system(size: 11))
if let advice = Toolchain.missingToolAdvice {
Text(advice)
.font(.system(size: 10.5, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
} header: {
Text("Summary")
}
}
.formStyle(.grouped)
}
private func engineRow(name: String, available: Bool, detail: String) -> some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: available ? "checkmark.circle.fill" : "circle.dashed")
.foregroundStyle(available ? Color.green : Color.secondary)
.font(.system(size: 12))
VStack(alignment: .leading, spacing: 1) {
Text(name).font(.system(size: 12, weight: .medium))
Text(detail)
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
}
private func chooseFolder() {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.canCreateDirectories = true
panel.allowsMultipleSelection = false
panel.prompt = "Use Folder"
NSApp.activate(ignoringOtherApps: true)
if panel.runModal() == .OK, let url = panel.url {
settings.outputFolderPath = url.path
}
}
}