Files
TZGyn 2d031e58a4 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.
2026-10-20 00:36:40 +08:00

202 lines
7.9 KiB
Swift

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
}
}
}