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,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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user