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(_ value: T, _ key: Key) { defaults.set(value, forKey: "konverter." + key.rawValue) } private static func read(_ 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 } } }