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.
202 lines
7.9 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|