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
+232
View File
@@ -0,0 +1,232 @@
import AppKit
import Foundation
import PDFKit
import QuickLookThumbnailing
import SwiftUI
/// "Float": a borderless preview that hovers over other windows, the same
/// gesture Tangerine offers from the Control wheel.
final class FloatPanel: NSObject {
static let shared = FloatPanel()
private var window: NSWindow?
private var urls: [URL] = []
private var index = 0
private override init() { super.init() }
@MainActor
func show(urls: [URL]) {
self.urls = urls
index = 0
let window = ensureWindow()
if let screen = NSScreen.screens.first(where: { $0.frame.contains(NSEvent.mouseLocation) }) ?? NSScreen.main {
let frame = screen.visibleFrame
let size = CGSize(width: 420, height: 320)
window.setContentSize(size)
window.setFrameOrigin(CGPoint(
x: min(max(frame.minX + 16, NSEvent.mouseLocation.x - size.width / 2), frame.maxX - size.width - 16),
y: min(max(frame.minY + 16, NSEvent.mouseLocation.y - size.height - 24), frame.maxY - size.height - 16)
))
}
window.alphaValue = 0
window.orderFrontRegardless()
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.18
window.animator().alphaValue = 1
}
render()
}
@MainActor
private func ensureWindow() -> NSWindow {
if let window { return window }
let window = OverlayWindow(
contentRect: NSRect(x: 0, y: 0, width: 420, height: 320),
styleMask: [.borderless],
backing: .buffered,
defer: false
)
window.isOpaque = false
window.backgroundColor = .clear
window.hasShadow = true
window.level = .floating
window.isMovableByWindowBackground = true
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
window.contentView = NSHostingView(rootView: FloatView(controller: self))
self.window = window
return window
}
@MainActor
func render() {
guard let window, let content = window.contentView as? NSHostingView<FloatView> else { return }
content.rootView = FloatView(controller: self)
}
@MainActor
func close() {
guard let window else { return }
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.15
window.animator().alphaValue = 0
} completionHandler: {
window.orderOut(nil)
}
urls = []
}
@MainActor
func step(_ delta: Int) {
guard !urls.isEmpty else { return }
index = ((index + delta) % urls.count + urls.count) % urls.count
render()
}
var currentURL: URL? {
urls.indices.contains(index) ? urls[index] : nil
}
var counter: String? {
urls.count > 1 ? "\(index + 1) of \(urls.count)" : nil
}
@MainActor
func openInDefaultApp() {
guard let url = currentURL else { return }
NSWorkspace.shared.open(url)
}
@MainActor
func reveal() {
guard let url = currentURL else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
}
}
struct FloatView: View {
let controller: FloatPanel
@State private var image: NSImage?
@State private var failure: String?
@State private var isLoading = true
var body: some View {
VStack(spacing: 0) {
Group {
if let image {
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.padding(10)
} else if let failure {
VStack(spacing: 6) {
Image(systemName: "eye.slash")
.font(.system(size: 22))
Text(failure)
.font(.system(size: 11))
.multilineTextAlignment(.center)
}
.foregroundStyle(.secondary)
.padding()
} else {
ProgressView()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider().opacity(0.35)
HStack(spacing: 10) {
Image(systemName: "doc")
.font(.system(size: 11))
Text(controller.currentURL?.lastPathComponent ?? "")
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
.truncationMode(.middle)
if let counter = controller.counter {
Text(counter)
.font(.system(size: 10))
.foregroundStyle(.secondary)
}
Spacer()
Button {
controller.step(-1)
} label: { Image(systemName: "chevron.left") }
.buttonStyle(.plain)
Button {
controller.step(1)
} label: { Image(systemName: "chevron.right") }
.buttonStyle(.plain)
Button("Open") { controller.openInDefaultApp() }
.buttonStyle(.plain)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Color.accentColor)
Button {
controller.close()
} label: { Image(systemName: "xmark") }
.buttonStyle(.plain)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(Color.primary.opacity(0.1))
)
.task(id: controller.currentURL) {
await load()
}
}
private func load() async {
guard let url = controller.currentURL else {
image = nil
failure = "Nothing to preview"
isLoading = false
return
}
isLoading = true
failure = nil
// Images and PDFs get a real render; everything else falls back to a
// Quick Look thumbnail.
if FileKind.category(of: url) == .image, let loaded = NSImage(contentsOf: url) {
image = loaded
isLoading = false
return
}
if url.pathExtension.lowercased() == "pdf", let page = PDFKitPreview.image(for: url) {
image = page
isLoading = false
return
}
let request = QLThumbnailGenerator.Request(
fileAt: url,
size: CGSize(width: 900, height: 700),
scale: NSScreen.main?.backingScaleFactor ?? 2,
representationTypes: .all
)
let representation = try? await QLThumbnailGenerator.shared.generateBestRepresentation(for: request)
if let cgImage = representation?.cgImage {
image = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width / 2, height: cgImage.height / 2))
} else {
image = nil
failure = "No preview available for .\(url.pathExtension)"
}
isLoading = false
}
}
enum PDFKitPreview {
static func image(for url: URL) -> NSImage? {
guard let document = PDFDocument(url: url), let page = document.page(at: 0) else { return nil }
let bounds = page.bounds(for: .mediaBox)
let scale: CGFloat = 1.6
let size = CGSize(width: bounds.width * scale, height: bounds.height * scale)
let thumbnail = page.thumbnail(of: size, for: .mediaBox)
return thumbnail
}
}
+254
View File
@@ -0,0 +1,254 @@
import AppKit
import SwiftUI
/// The small job window. Tangerine opens one per job; a single stack that
/// shows every job is less noisy and can be dismissed in one click.
final class JobPanelController: NSObject {
static let shared = JobPanelController()
private var panel: NSPanel?
private override init() { super.init() }
func refresh(store: JobStore) {
guard Thread.isMainThread else {
DispatchQueue.main.async { self.refresh(store: store) }
return
}
guard Settings.shared.showProgressWindow else { return }
guard !store.jobs.isEmpty else {
panel?.orderOut(nil)
panel = nil
return
}
let panel = ensurePanel()
if !panel.isVisible {
position(panel, near: store.jobs.last?.origin)
panel.alphaValue = 0
panel.orderFrontRegardless()
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.18
panel.animator().alphaValue = 1
}
}
}
func dismissIfAllFinished(store: JobStore) {
guard store.activeCount == 0 else { return }
let work = DispatchWorkItem { [weak self] in
guard let self, let panel = self.panel, JobStore.shared.activeCount == 0 else { return }
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.2
panel.animator().alphaValue = 0
} completionHandler: {
panel.orderOut(nil)
JobStore.shared.dismissFinished()
self.panel = nil
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.6, execute: work)
}
func show() {
refresh(store: .shared)
}
private func ensurePanel() -> NSPanel {
if let panel { return panel }
let panel = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 120),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
panel.isFloatingPanel = true
panel.level = .floating
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = true
panel.hidesOnDeactivate = false
panel.becomesKeyOnlyIfNeeded = true
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
panel.contentView = NSHostingView(rootView: JobsView(store: .shared))
self.panel = panel
return panel
}
private func position(_ panel: NSPanel, near origin: CGPoint?) {
let size = panel.contentView?.fittingSize ?? CGSize(width: 320, height: 140)
panel.setContentSize(size)
let screen = NSScreen.screens.first { $0.frame.contains(origin ?? .zero) } ?? NSScreen.main
guard let frame = screen?.visibleFrame else { return }
var point = CGPoint(x: frame.midX - size.width / 2, y: frame.minY + 80)
if let origin {
point = CGPoint(x: origin.x + 26, y: origin.y - size.height / 2)
}
point.x = min(max(frame.minX + 12, point.x), frame.maxX - size.width - 12)
point.y = min(max(frame.minY + 12, point.y), frame.maxY - size.height - 12)
panel.setFrameOrigin(point)
}
/// Re-centres and resizes once the content changes.
func layout() {
guard let panel, panel.isVisible else { return }
let size = panel.contentView?.fittingSize ?? panel.frame.size
let anchor = CGPoint(x: panel.frame.minX, y: panel.frame.midY)
panel.setContentSize(size)
panel.setFrameOrigin(CGPoint(x: anchor.x, y: anchor.y - size.height / 2))
}
}
struct JobsView: View {
@ObservedObject var store: JobStore
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 6) {
Image(systemName: "arrow.triangle.2.circlepath")
.font(.system(size: 11, weight: .semibold))
Text(store.activeCount > 0 ? "Working…" : "Finished")
.font(.system(size: 11, weight: .semibold))
Spacer()
Button {
store.cancelAll()
} label: {
Image(systemName: "stop.circle")
}
.buttonStyle(.plain)
.help("Cancel everything")
.opacity(store.activeCount > 0 ? 1 : 0)
Button {
store.dismissFinished()
JobPanelController.shared.performClose()
} label: {
Image(systemName: "xmark.circle")
}
.buttonStyle(.plain)
.help("Close")
}
.padding(.horizontal, 12)
.padding(.top, 10)
.padding(.bottom, 6)
Divider().opacity(0.4)
ScrollView {
VStack(alignment: .leading, spacing: 8) {
ForEach(store.jobs) { job in
JobRow(job: job, store: store)
}
}
.padding(12)
}
.frame(maxHeight: 260)
}
.frame(width: 320)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(Color.primary.opacity(0.09))
)
.onReceive(store.$jobs) { _ in
DispatchQueue.main.async {
JobPanelController.shared.layout()
}
}
}
}
private struct JobRow: View {
let job: Job
@ObservedObject var store: JobStore
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: symbol)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(tint)
Text(job.title)
.font(.system(size: 11.5, weight: .medium))
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 4)
if job.status == .running {
Button {
store.cancel(job.id)
} label: {
Image(systemName: "xmark")
.font(.system(size: 9, weight: .bold))
}
.buttonStyle(.plain)
.help("Cancel this job")
} else {
Button {
store.remove(job.id)
} label: {
Image(systemName: "trash")
.font(.system(size: 9))
}
.buttonStyle(.plain)
.help("Remove")
}
}
if job.status == .running {
ProgressView(value: max(0.02, job.progress))
.progressViewStyle(.linear)
}
HStack(spacing: 6) {
Text(detail)
.font(.system(size: 10))
.foregroundStyle(.secondary)
.lineLimit(2)
Spacer(minLength: 4)
if !job.outputs.isEmpty, job.status == .finished {
Button("Reveal") {
NSWorkspace.shared.activateFileViewerSelecting(job.outputs)
}
.buttonStyle(.plain)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(Color.accentColor)
}
}
}
}
private var detail: String {
if let error = job.errorMessage { return error }
if job.sourceCount > 1, job.status == .finished, !job.outputs.isEmpty {
return "\(job.outputs.count) files written"
}
return job.detail.isEmpty ? job.title : job.detail
}
private var symbol: String {
switch job.status {
case .running: return "hourglass"
case .finished: return "checkmark.circle.fill"
case .failed: return "exclamationmark.triangle.fill"
case .cancelled: return "slash.circle"
}
}
private var tint: Color {
switch job.status {
case .running: return .secondary
case .finished: return .green
case .failed: return .orange
case .cancelled: return .secondary
}
}
}
extension JobPanelController {
func performClose() {
panel?.orderOut(nil)
panel = nil
JobStore.shared.dismissFinished()
}
}
+113
View File
@@ -0,0 +1,113 @@
import AppKit
import Foundation
/// The menu bar item: the app's only permanent UI, and the place to turn the
/// wheel off or fall back to picking files by hand.
final class MenuBarController: NSObject, NSMenuDelegate {
private var statusItem: NSStatusItem?
func install() {
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = item.button {
let image = NSImage(systemSymbolName: "arrow.triangle.2.circlepath", accessibilityDescription: "Konverter")
image?.isTemplate = true
button.image = image
button.setAccessibilityLabel("Konverter")
}
let menu = NSMenu()
menu.delegate = self
item.menu = menu
statusItem = item
rebuild(menu)
}
func menuNeedsUpdate(_ menu: NSMenu) {
rebuild(menu)
}
private func rebuild(_ menu: NSMenu) {
menu.removeAllItems()
let enabled = Settings.shared.enabled
let toggle = NSMenuItem(
title: enabled ? "Konverter Active" : "Konverter Paused",
action: #selector(toggleEnabled),
keyEquivalent: ""
)
toggle.target = self
toggle.state = enabled ? .on : .off
menu.addItem(toggle)
menu.addItem(.separator())
let convert = NSMenuItem(title: "Konvert Files…", action: #selector(convertFiles), keyEquivalent: "o")
convert.target = self
menu.addItem(convert)
let jobs = NSMenuItem(title: "Show Jobs", action: #selector(showJobs), keyEquivalent: "j")
jobs.target = self
menu.addItem(jobs)
menu.addItem(.separator())
let settings = NSMenuItem(title: "Settings…", action: #selector(showSettings), keyEquivalent: ",")
settings.target = self
menu.addItem(settings)
let about = NSMenuItem(title: "About Konverter", action: #selector(showAbout), keyEquivalent: "")
about.target = self
menu.addItem(about)
menu.addItem(.separator())
let quit = NSMenuItem(title: "Quit Konverter", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
quit.target = NSApp
menu.addItem(quit)
}
@objc private func toggleEnabled() {
Settings.shared.enabled.toggle()
if !Settings.shared.enabled {
WheelController.shared.cancel()
}
if let menu = statusItem?.menu {
rebuild(menu)
}
}
@objc private func convertFiles() {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = true
panel.allowsMultipleSelection = true
panel.prompt = "Choose"
panel.message = "Pick files to convert, then choose an output format"
NSApp.activate(ignoringOtherApps: true)
guard panel.runModal() == .OK, !panel.urls.isEmpty else { return }
// Let the panel close before the menu opens over it.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
QuickMenu.show(urls: panel.urls, at: NSEvent.mouseLocation)
}
}
@objc private func showJobs() {
JobStore.shared.panelVisible = true
JobPanelController.shared.show()
}
@objc private func showSettings() {
PreferencesWindowController.shared.show()
}
@objc private func showAbout() {
NSApp.activate(ignoringOtherApps: true)
NSApp.orderFrontStandardAboutPanel(options: [
.applicationName: "Konverter",
.applicationVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0",
.credits: NSAttributedString(
string: "Drag a file in Finder, then hold a key.\nShift converts · Command shows tools · Control shows file actions.\nEverything runs on this Mac.",
attributes: [.font: NSFont.systemFont(ofSize: 11)]
)
])
}
}
+88
View File
@@ -0,0 +1,88 @@
import AppKit
import Foundation
import SwiftUI
/// Small, non-modal confirmations ("Path copied", "3 files converted").
/// Nothing here blocks the user's flow, which is the whole point of the app.
enum Notifier {
private static var toastWindow: NSWindow?
private static var dismissWorkItem: DispatchWorkItem?
@MainActor
static func flash(title: String, body: String? = nil, symbol: String = "checkmark.circle.fill") {
guard Settings.shared.showProgressWindow else { return }
show(title: title, body: body, symbol: symbol, tone: .success, duration: 2.0)
}
@MainActor
static func error(_ title: String, _ message: String) {
show(title: title, body: message, symbol: "exclamationmark.triangle.fill", tone: .failure, duration: 6.0)
}
private enum Tone { case success, failure }
@MainActor
private static func show(title: String, body: String?, symbol: String, tone: Tone, duration: TimeInterval) {
dismissWorkItem?.cancel()
toastWindow?.close()
let accent: Color = tone == .success ? .green : .orange
let view = HStack(spacing: 10) {
Image(systemName: symbol)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(accent)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.system(size: 12, weight: .semibold))
.lineLimit(1)
if let body, !body.isEmpty {
Text(body)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(2)
.truncationMode(.middle)
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.frame(minWidth: 220, maxWidth: 420, alignment: .leading)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.primary.opacity(0.08))
)
let controller = NSHostingController(rootView: view)
let window = NSWindow(contentViewController: controller)
window.styleMask = [.borderless]
window.isOpaque = false
window.backgroundColor = .clear
window.hasShadow = true
window.level = .statusBar
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
window.ignoresMouseEvents = true
window.appearance = NSApp.effectiveAppearance
let size = controller.view.fittingSize
let screen = NSScreen.main ?? NSScreen.screens.first
if let frame = screen?.visibleFrame {
let origin = CGPoint(x: frame.midX - size.width / 2, y: frame.minY + 96)
window.setFrame(CGRect(origin: origin, size: size), display: true)
}
window.orderFrontRegardless()
toastWindow = window
let work = DispatchWorkItem { [weak window] in
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.25
window?.animator().alphaValue = 0
} completionHandler: {
window?.close()
}
if toastWindow === window { toastWindow = nil }
}
dismissWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: work)
}
}
+190
View File
@@ -0,0 +1,190 @@
import AppKit
import SwiftUI
/// The form a tool needs before it can run (quality, trim points, password …).
/// Values are remembered per tool, so the second time is still one drop.
final class OptionsWindowController: NSObject {
static let shared = OptionsWindowController()
private var window: NSWindow?
private var tool: ToolID?
private var urls: [URL] = []
private var origin: CGPoint?
private var params = ToolParams()
private override init() { super.init() }
func present(tool: ToolID, urls: [URL], origin: CGPoint?) {
self.tool = tool
self.urls = urls
self.origin = origin
params = Settings.shared.params(for: tool)
params.fillMissing(from: tool)
let view = OptionsView(
tool: tool,
params: params,
fileCount: urls.count,
onApply: { [weak self] values in self?.apply(values) },
onCancel: { [weak self] in self?.close() }
)
close()
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 340, height: 120),
styleMask: [.titled, .closable, .fullSizeContentView],
backing: .buffered,
defer: false
)
window.title = tool.title
window.titlebarAppearsTransparent = true
window.isMovableByWindowBackground = true
window.level = .floating
window.contentView = NSHostingView(rootView: view)
window.center()
if let origin, let screen = NSScreen.screens.first(where: { $0.frame.contains(origin) }) ?? NSScreen.main {
let frame = screen.visibleFrame
window.setFrameOrigin(CGPoint(
x: min(max(frame.minX + 20, origin.x - 170), frame.maxX - 360),
y: min(max(frame.minY + 20, origin.y - 180), frame.maxY - 220)
))
}
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
self.window = window
}
private func apply(_ values: ToolParams) {
guard let tool else { return }
Settings.shared.store(params: values, for: tool)
let captured = urls
let point = origin
close()
JobStore.shared.submit(urls: captured, target: .tool(tool), params: values, origin: point)
}
private func close() {
window?.orderOut(nil)
window = nil
}
}
struct OptionsView: View {
let tool: ToolID
let fileCount: Int
let onApply: (ToolParams) -> Void
let onCancel: () -> Void
@State private var values: [String: String] = [:]
init(
tool: ToolID,
params: ToolParams,
fileCount: Int,
onApply: @escaping (ToolParams) -> Void,
onCancel: @escaping () -> Void
) {
self.tool = tool
self.fileCount = fileCount
self.onApply = onApply
self.onCancel = onCancel
var initial: [String: String] = [:]
for field in tool.fields {
initial[field.id] = params[field.id] ?? field.defaultValue
}
_values = State(initialValue: initial)
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Image(systemName: tool.symbol)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Color.accentColor)
VStack(alignment: .leading, spacing: 1) {
Text(tool.title)
.font(.system(size: 13, weight: .semibold))
Text(fileCount == 1 ? "1 file" : "\(fileCount) files")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
}
ForEach(tool.fields) { field in
fieldView(field)
}
HStack {
Spacer()
Button("Cancel", action: onCancel)
.keyboardShortcut(.cancelAction)
Button("Apply") {
var params = ToolParams()
for (key, value) in values { params[key] = value }
onApply(params)
}
.keyboardShortcut(.defaultAction)
}
}
.padding(16)
.frame(width: 340)
}
@ViewBuilder
private func fieldView(_ field: ToolField) -> some View {
VStack(alignment: .leading, spacing: 3) {
HStack {
Text(field.label)
.font(.system(size: 11, weight: .medium))
Spacer()
if let unit = field.unit {
Text(unit)
.font(.system(size: 10))
.foregroundStyle(.secondary)
}
}
control(for: field)
if let help = field.help {
Text(help)
.font(.system(size: 9.5))
.foregroundStyle(.secondary)
}
}
}
@ViewBuilder
private func control(for field: ToolField) -> some View {
let binding = Binding<String>(
get: { values[field.id] ?? field.defaultValue },
set: { values[field.id] = $0 }
)
switch field.kind {
case .text:
TextField("", text: binding)
.textFieldStyle(.roundedBorder)
case .secret:
SecureField("", text: binding)
.textFieldStyle(.roundedBorder)
case .toggle:
Toggle("", isOn: Binding(
get: { (values[field.id] ?? field.defaultValue) == "true" },
set: { values[field.id] = $0 ? "true" : "false" }
))
.labelsHidden()
.toggleStyle(.switch)
case .choice(let options):
Picker("", selection: binding) {
ForEach(options, id: \.self) { option in
Text(option).tag(option)
}
}
.labelsHidden()
.pickerStyle(.menu)
case .integer, .decimal, .timecode:
TextField("", text: binding)
.textFieldStyle(.roundedBorder)
.font(.system(size: 12, design: .monospaced))
}
}
}
@@ -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
}
}
}
+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
}
}
+66
View File
@@ -0,0 +1,66 @@
import AppKit
import SwiftUI
/// Longer text results (Info, Read QR, Statistics, archive listings).
final class ResultWindowController {
static let shared = ResultWindowController()
private var window: NSWindow?
private init() {}
func show(title: String, body: String) {
let view = ResultView(title: title, text: body, onClose: { [weak self] in self?.close() })
close()
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 460, height: 320),
styleMask: [.titled, .closable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false
)
window.title = title
window.titlebarAppearsTransparent = true
window.level = .floating
window.contentView = NSHostingView(rootView: view)
window.center()
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
self.window = window
}
func close() {
window?.orderOut(nil)
window = nil
}
}
struct ResultView: View {
let title: String
let text: String
let onClose: () -> Void
var body: some View {
VStack(spacing: 0) {
ScrollView {
Text(text)
.font(.system(size: 11.5, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
}
Divider().opacity(0.4)
HStack {
Button("Copy") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
}
Spacer()
Button("Done", action: onClose)
.keyboardShortcut(.defaultAction)
}
.padding(10)
}
.frame(minWidth: 420, minHeight: 260)
}
}