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,201 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import ServiceManagement
|
||||
|
||||
/// Trigger keys. Tangerine's split: Shift converts, Command fills the wheel
|
||||
/// with file tools, Control with file actions.
|
||||
enum TriggerKey: String, CaseIterable, Identifiable, Codable {
|
||||
case shift, control, option, command, function
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .shift: return "Shift ⇧"
|
||||
case .control: return "Control ⌃"
|
||||
case .option: return "Option ⌥"
|
||||
case .command: return "Command ⌘"
|
||||
case .function: return "Function fn"
|
||||
}
|
||||
}
|
||||
|
||||
var flag: NSEvent.ModifierFlags {
|
||||
switch self {
|
||||
case .shift: return .shift
|
||||
case .control: return .control
|
||||
case .option: return .option
|
||||
case .command: return .command
|
||||
case .function: return .function
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum OutputLocation: String, CaseIterable, Identifiable, Codable {
|
||||
case besideOriginal, chosenFolder
|
||||
|
||||
var id: String { rawValue }
|
||||
var title: String {
|
||||
switch self {
|
||||
case .besideOriginal: return "Beside the original"
|
||||
case .chosenFolder: return "In a folder I choose"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ConflictPolicy: String, CaseIterable, Identifiable, Codable {
|
||||
case uniqueName, overwrite
|
||||
|
||||
var id: String { rawValue }
|
||||
var title: String {
|
||||
switch self {
|
||||
case .uniqueName: return "Keep both (add a number)"
|
||||
case .overwrite: return "Replace the existing file"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum WheelStyle: String, CaseIterable, Identifiable, Codable {
|
||||
case radial, list
|
||||
|
||||
var id: String { rawValue }
|
||||
var title: String {
|
||||
switch self {
|
||||
case .radial: return "Wheel"
|
||||
case .list: return "List"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All preferences. Stored in `UserDefaults` under `konverter.*` so the app
|
||||
/// stays a single self-contained bundle with no sandbox container.
|
||||
final class Settings: ObservableObject {
|
||||
static let shared = Settings()
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
|
||||
@Published var convertKey: TriggerKey { didSet { write(convertKey, .convertKey) } }
|
||||
@Published var toolsKey: TriggerKey { didSet { write(toolsKey, .toolsKey) } }
|
||||
@Published var actionsKey: TriggerKey { didSet { write(actionsKey, .actionsKey) } }
|
||||
|
||||
/// When on, the tools wheel needs Shift + toolsKey so a plain ⌘-drag in
|
||||
/// other apps does not pop the wheel.
|
||||
@Published var requireShiftForTools: Bool { didSet { write(requireShiftForTools, .requireShiftForTools) } }
|
||||
|
||||
@Published var enabled: Bool { didSet { write(enabled, .enabled) } }
|
||||
@Published var allowActionsOnFolders: Bool { didSet { write(allowActionsOnFolders, .allowActionsOnFolders) } }
|
||||
|
||||
@Published var outputLocation: OutputLocation { didSet { write(outputLocation.rawValue, .outputLocation) } }
|
||||
@Published var outputFolderPath: String { didSet { write(outputFolderPath, .outputFolderPath) } }
|
||||
@Published var conflictPolicy: ConflictPolicy { didSet { write(conflictPolicy.rawValue, .conflictPolicy) } }
|
||||
|
||||
@Published var wheelStyle: WheelStyle { didSet { write(wheelStyle.rawValue, .wheelStyle) } }
|
||||
@Published var wheelRadius: Double { didSet { write(wheelRadius, .wheelRadius) } }
|
||||
@Published var showLabels: Bool { didSet { write(showLabels, .showLabels) } }
|
||||
@Published var dimBackground: Bool { didSet { write(dimBackground, .dimBackground) } }
|
||||
|
||||
@Published var showProgressWindow: Bool { didSet { write(showProgressWindow, .showProgressWindow) } }
|
||||
@Published var revealWhenFinished: Bool { didSet { write(revealWhenFinished, .revealWhenFinished) } }
|
||||
@Published var playSound: Bool { didSet { write(playSound, .playSound) } }
|
||||
@Published var useExternalTools: Bool { didSet { write(useExternalTools, .useExternalTools) } }
|
||||
|
||||
@Published var openAtLogin: Bool { didSet { write(openAtLogin, .openAtLogin) } }
|
||||
|
||||
/// Remembers per-tool form values so repeat jobs are still one drop.
|
||||
private var toolParamsCache: [String: [String: String]] = [:]
|
||||
|
||||
private init() {
|
||||
convertKey = Settings.read(.convertKey, default: .shift)
|
||||
toolsKey = Settings.read(.toolsKey, default: .command)
|
||||
actionsKey = Settings.read(.actionsKey, default: .control)
|
||||
requireShiftForTools = Settings.readBool(.requireShiftForTools, default: true)
|
||||
enabled = Settings.readBool(.enabled, default: true)
|
||||
allowActionsOnFolders = Settings.readBool(.allowActionsOnFolders, default: true)
|
||||
outputLocation = Settings.read(.outputLocation, default: .besideOriginal)
|
||||
outputFolderPath = Settings.readString(.outputFolderPath, default: "")
|
||||
conflictPolicy = Settings.read(.conflictPolicy, default: .uniqueName)
|
||||
wheelStyle = Settings.read(.wheelStyle, default: .radial)
|
||||
wheelRadius = Settings.readDouble(.wheelRadius, default: 168)
|
||||
showLabels = Settings.readBool(.showLabels, default: true)
|
||||
dimBackground = Settings.readBool(.dimBackground, default: false)
|
||||
showProgressWindow = Settings.readBool(.showProgressWindow, default: true)
|
||||
revealWhenFinished = Settings.readBool(.revealWhenFinished, default: true)
|
||||
playSound = Settings.readBool(.playSound, default: true)
|
||||
useExternalTools = Settings.readBool(.useExternalTools, default: true)
|
||||
openAtLogin = LoginItem.isEnabled
|
||||
}
|
||||
|
||||
var resolvedOutputFolder: URL {
|
||||
if outputLocation == .chosenFolder, !outputFolderPath.isEmpty {
|
||||
return URL(fileURLWithPath: outputFolderPath)
|
||||
}
|
||||
return FileManager.default.homeDirectoryForCurrentUser
|
||||
}
|
||||
|
||||
// MARK: - Tool parameter memory
|
||||
|
||||
func params(for tool: ToolID) -> ToolParams {
|
||||
var params = ToolParams.defaults(for: tool)
|
||||
if let stored = toolParamsCache[tool.rawValue] {
|
||||
for (key, value) in stored { params[key] = value }
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func store(params: ToolParams, for tool: ToolID) {
|
||||
toolParamsCache[tool.rawValue] = params.dictionary
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private enum Key: String {
|
||||
case convertKey, toolsKey, actionsKey, requireShiftForTools, enabled, allowActionsOnFolders
|
||||
case outputLocation, outputFolderPath, conflictPolicy
|
||||
case wheelStyle, wheelRadius, showLabels, dimBackground
|
||||
case showProgressWindow, revealWhenFinished, playSound, useExternalTools, openAtLogin
|
||||
}
|
||||
|
||||
private func write<T>(_ value: T, _ key: Key) {
|
||||
defaults.set(value, forKey: "konverter." + key.rawValue)
|
||||
}
|
||||
|
||||
private static func read<T: RawRepresentable>(_ key: Key, default fallback: T) -> T where T.RawValue == String {
|
||||
guard let raw = UserDefaults.standard.string(forKey: "konverter." + key.rawValue),
|
||||
let value = T(rawValue: raw) else { return fallback }
|
||||
return value
|
||||
}
|
||||
|
||||
private static func readString(_ key: Key, default fallback: String) -> String {
|
||||
UserDefaults.standard.string(forKey: "konverter." + key.rawValue) ?? fallback
|
||||
}
|
||||
|
||||
private static func readBool(_ key: Key, default fallback: Bool) -> Bool {
|
||||
guard UserDefaults.standard.object(forKey: "konverter." + key.rawValue) != nil else { return fallback }
|
||||
return UserDefaults.standard.bool(forKey: "konverter." + key.rawValue)
|
||||
}
|
||||
|
||||
private static func readDouble(_ key: Key, default fallback: Double) -> Double {
|
||||
guard UserDefaults.standard.object(forKey: "konverter." + key.rawValue) != nil else { return fallback }
|
||||
return UserDefaults.standard.double(forKey: "konverter." + key.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
enum LoginItem {
|
||||
static var isEnabled: Bool {
|
||||
SMAppService.mainApp.status == .enabled
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func set(_ enabled: Bool) -> Bool {
|
||||
let service = SMAppService.mainApp
|
||||
do {
|
||||
if enabled {
|
||||
if service.status != .enabled { try service.register() }
|
||||
} else {
|
||||
if service.status == .enabled { try service.unregister() }
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user