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.
225 lines
8.6 KiB
Swift
225 lines
8.6 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|