From c77336aa6b61d9994b87f99042331cc94a76acfb Mon Sep 17 00:00:00 2001 From: Aleksei Gurianov Date: Thu, 15 Jan 2026 10:18:05 +0300 Subject: [PATCH] Introduce TOML configuration support and deprecate JSON config (#68) - Added support for TOML configuration format alongside JSON. - Created a new `ConfigFormat` enum to manage file extensions and names. - Implemented encoding and decoding methods for TOML in `UserConfig`. - Updated file handling to prefer TOML format and added detection logic. - Refactored existing JSON handling to accommodate the new format. - Added conversion method to convert existing JSON configs to TOML. - Introduced comprehensive unit tests for TOML parsing and serialization. - Updated README and sample configuration file to reflect new TOML format. --- AGENTS.md | 20 +- Leader Key.xcodeproj/project.pbxproj | 28 + .../xcshareddata/swiftpm/Package.resolved | 11 +- Leader Key/AppResolver.swift | 141 ++++ Leader Key/Constants.swift | 4 +- Leader Key/Settings/AdvancedPane.swift | 38 ++ Leader Key/TOMLConfig.swift | 320 +++++++++ Leader Key/UserConfig.swift | 264 +++++-- Leader KeyTests/TOMLConfigTests.swift | 645 ++++++++++++++++++ Leader KeyTests/UserConfigTests.swift | 20 +- README.md | 11 +- config.sample.toml | 40 ++ 12 files changed, 1451 insertions(+), 91 deletions(-) create mode 100644 Leader Key/AppResolver.swift create mode 100644 Leader Key/TOMLConfig.swift create mode 100644 Leader KeyTests/TOMLConfigTests.swift create mode 100644 config.sample.toml diff --git a/AGENTS.md b/AGENTS.md index 55284c3a..b4969376 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,22 @@ This file provides guidance to coding agents when working with code in this repo ## Build & Test Commands - Build and run: `xcodebuild -scheme "Leader Key" -configuration Debug build` +- Build without signing (contributors without a signing certificate): + ``` + xcodebuild -scheme "Leader Key" -configuration Debug build \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO + ``` - Run all tests: `xcodebuild -scheme "Leader Key" -testPlan "TestPlan" test` +- Run all tests without signing: + ``` + xcodebuild -scheme "Leader Key" -testPlan "TestPlan" test \ + -configuration Debug \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO + ``` - Run single test: `xcodebuild -scheme "Leader Key" -testPlan "TestPlan" -only-testing:Leader KeyTests/UserConfigTests/testInitializesWithDefaults test` - Bump version: `bin/bump` - Create release: `bin/release` @@ -20,7 +35,7 @@ Leader Key is a macOS application that provides customizable keyboard shortcuts. - `AppDelegate`: Application lifecycle, global shortcuts registration, update management - `Controller`: Central event handling, manages key sequences and window display -- `UserConfig`: JSON configuration management with validation +- `UserConfig`: TOML-first (JSON legacy) configuration management with validation - `UserState`: Tracks navigation through key sequences - `MainWindow`: Base class for theme windows @@ -32,7 +47,7 @@ Leader Key is a macOS application that provides customizable keyboard shortcuts. **Configuration Flow:** -- Config stored at `~/Library/Application Support/Leader Key/config.json` +- Config stored at `~/Library/Application Support/Leader Key/config.toml` (legacy `config.json` supported) - `FileMonitor` watches for changes and triggers reload - `ConfigValidator` ensures no key conflicts - Actions support: applications, URLs, commands, folders @@ -56,4 +71,3 @@ Leader Key is a macOS application that provides customizable keyboard shortcuts. - **Documentation**: Use comments for complex logic or non-obvious implementations Follow Swift idioms and default formatting (4-space indentation, spaces around operators). - diff --git a/Leader Key.xcodeproj/project.pbxproj b/Leader Key.xcodeproj/project.pbxproj index 141cebf6..3a6aa1c5 100644 --- a/Leader Key.xcodeproj/project.pbxproj +++ b/Leader Key.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + C4C1F691881442199A5F55C4 /* TOMLConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C1F691881442199A5F55C5 /* TOMLConfig.swift */; }; + C4C1F691881442199A5F55C6 /* AppResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C1F691881442199A5F55C7 /* AppResolver.swift */; }; + C4C1F691881442199A5F55C8 /* TOMLConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C1F691881442199A5F55C9 /* TOMLConfigTests.swift */; }; 115AA5BF2DA521C600C17E18 /* ActionIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 115AA5BE2DA521C200C17E18 /* ActionIcon.swift */; }; 115AA5C22DA546D500C17E18 /* SymbolPicker in Frameworks */ = {isa = PBXBuildFile; productRef = 115AA5C12DA546D500C17E18 /* SymbolPicker */; }; 130196C62D73B3DE0093148B /* Breadcrumbs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130196C52D73B3DC0093148B /* Breadcrumbs.swift */; }; @@ -43,6 +46,7 @@ 4284834C2E813212009D7EEF /* KeyboardLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4284834B2E813212009D7EEF /* KeyboardLayoutTests.swift */; }; 42B21FBC2D67566100F4A2C7 /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42B21FBB2D67566100F4A2C7 /* Alerts.swift */; }; 42CCB5A32DAD257700356FC0 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = FBCA04D82D9F02F700271163 /* Kingfisher */; }; + C4C1F691881442199A5F55D0 /* TOMLKit in Frameworks */ = {isa = PBXBuildFile; productRef = C4C1F691881442199A5F55D1 /* TOMLKit */; }; 42DFCD722D5B7D48002EA111 /* Events.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42DFCD712D5B7D46002EA111 /* Events.swift */; }; 42E70A572C823FF200FCF902 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 42E70A562C823FF200FCF902 /* Sparkle */; }; 42F4CDC92D458FF700D0DD76 /* MainMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F4CDC82D458FF700D0DD76 /* MainMenu.swift */; }; @@ -68,6 +72,9 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + C4C1F691881442199A5F55C5 /* TOMLConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOMLConfig.swift; sourceTree = ""; }; + C4C1F691881442199A5F55C7 /* AppResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppResolver.swift; sourceTree = ""; }; + C4C1F691881442199A5F55C9 /* TOMLConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOMLConfigTests.swift; sourceTree = ""; }; 115AA5BE2DA521C200C17E18 /* ActionIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionIcon.swift; sourceTree = ""; }; 130196C52D73B3DC0093148B /* Breadcrumbs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Breadcrumbs.swift; sourceTree = ""; }; 423632142D678F4400878D92 /* TestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestPlan.xctestplan; sourceTree = ""; }; @@ -128,6 +135,7 @@ 427C181A2BD3123C00955B98 /* Defaults in Frameworks */, 4279AFED2C6A175500952A83 /* LaunchAtLogin in Frameworks */, 427C18172BD311ED00955B98 /* KeyboardShortcuts in Frameworks */, + C4C1F691881442199A5F55D0 /* TOMLKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -186,6 +194,7 @@ children = ( 42B21FBB2D67566100F4A2C7 /* Alerts.swift */, 427C181F2BD31C3D00955B98 /* AppDelegate.swift */, + C4C1F691881442199A5F55C7 /* AppResolver.swift */, 73192AF63CAF425397D7C0D2 /* URLSchemeHandler.swift */, 42F4CDCE2D46E2B300D0DD76 /* Cheatsheet.swift */, 426E625A2D2E6A98009FD2F2 /* CommandRunner.swift */, @@ -201,6 +210,7 @@ 427C182C2BD31F9500955B98 /* Settings.swift */, 427C18262BD31E2E00955B98 /* StatusItem.swift */, 423632272D6A806700878D92 /* Theme.swift */, + C4C1F691881442199A5F55C5 /* TOMLConfig.swift */, 427C183A2BD329F900955B98 /* UserConfig.swift */, 427C182E2BD3206200955B98 /* UserState.swift */, 427C184F2BD6652500955B98 /* Util.swift */, @@ -226,6 +236,7 @@ children = ( 4284834B2E813212009D7EEF /* KeyboardLayoutTests.swift */, 42454DDC2D71CBAB004E1374 /* ConfigValidatorTests.swift */, + C4C1F691881442199A5F55C9 /* TOMLConfigTests.swift */, EC5CEBC4C47B4C5DB2258814 /* URLSchemeTests.swift */, 427C17FC2BD311B500955B98 /* UserConfigTests.swift */, ); @@ -351,6 +362,7 @@ 4236321C2D6799B900878D92 /* XCRemoteSwiftPackageReference "SwiftFormatPlugins" */, 115AA5C02DA5463700C17E18 /* XCRemoteSwiftPackageReference "SymbolPicker" */, FB00D5262D824E2C00A37486 /* XCRemoteSwiftPackageReference "Kingfisher" */, + C4C1F691881442199A5F55D2 /* XCRemoteSwiftPackageReference "TOMLKit" */, ); productRefGroup = 427C17E82BD311B400955B98 /* Products */; projectDirPath = ""; @@ -409,6 +421,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C4C1F691881442199A5F55C4 /* TOMLConfig.swift in Sources */, + C4C1F691881442199A5F55C6 /* AppResolver.swift in Sources */, 427C182B2BD31E2E00955B98 /* Controller.swift in Sources */, 423632222D68CA6500878D92 /* MysteryBox.swift in Sources */, 605385A32D523CAD00BEDB4B /* Pulsate.swift in Sources */, @@ -451,6 +465,7 @@ buildActionMask = 2147483647; files = ( 42454DDD2D71CBAB004E1374 /* ConfigValidatorTests.swift in Sources */, + C4C1F691881442199A5F55C8 /* TOMLConfigTests.swift in Sources */, EC5CEBC4C47B4C5DB2258813 /* URLSchemeTests.swift in Sources */, 427C17FD2BD311B500955B98 /* UserConfigTests.swift in Sources */, 4284834C2E813212009D7EEF /* KeyboardLayoutTests.swift in Sources */, @@ -801,6 +816,14 @@ minimumVersion = 8.3.0; }; }; + C4C1F691881442199A5F55D2 /* XCRemoteSwiftPackageReference "TOMLKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/LebJe/TOMLKit.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.6.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -844,6 +867,11 @@ package = FB00D5262D824E2C00A37486 /* XCRemoteSwiftPackageReference "Kingfisher" */; productName = Kingfisher; }; + C4C1F691881442199A5F55D1 /* TOMLKit */ = { + isa = XCSwiftPackageProductDependency; + package = C4C1F691881442199A5F55D2 /* XCRemoteSwiftPackageReference "TOMLKit" */; + productName = TOMLKit; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 427C17DF2BD311B400955B98 /* Project object */; diff --git a/Leader Key.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Leader Key.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b9760019..2f8e1c9e 100644 --- a/Leader Key.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Leader Key.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "da47d6e5ab6b8a19e07061424069e09f3c36abd019782617be344b153679c7b4", + "originHash" : "db97b9c57123ee67f357e6057ae7e14e1a53e045feade852a08c87aacb14aa72", "pins" : [ { "identity" : "defaults", @@ -81,6 +81,15 @@ "revision" : "8bb08c982235b8e601bc500a41e770d7e198759b", "version" : "1.6.2" } + }, + { + "identity" : "tomlkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/LebJe/TOMLKit.git", + "state" : { + "revision" : "ec6198d37d495efc6acd4dffbd262cdca7ff9b3f", + "version" : "0.6.0" + } } ], "version" : 3 diff --git a/Leader Key/AppResolver.swift b/Leader Key/AppResolver.swift new file mode 100644 index 00000000..5aaa10e7 --- /dev/null +++ b/Leader Key/AppResolver.swift @@ -0,0 +1,141 @@ +import AppKit +import Foundation + +/// Resolves application names to their full paths +/// Supports: +/// - Full paths: /Applications/Safari.app +/// - App names: "Safari", "Terminal", "Visual Studio Code" +/// - Partial matches: "Code" → "Visual Studio Code.app" +struct AppResolver { + + /// Standard directories to search for applications + private static let searchPaths: [String] = [ + "/Applications", + "/System/Applications", + "/System/Applications/Utilities", + "~/Applications", + "/Applications/Utilities", + ] + private static let expandedSearchPaths: [String] = searchPaths.map { + ($0 as NSString).expandingTildeInPath + } + + /// Resolve an app name or path to a full application path + /// - Parameter value: App name (e.g., "Terminal") or path (e.g., "/Applications/Safari.app") + /// - Returns: The resolved full path, or the original value if not resolvable + static func resolve(_ value: String) -> String { + // Already a full path + if value.hasPrefix("/") || value.hasPrefix("~") { + return (value as NSString).expandingTildeInPath + } + + // If it ends with .app, search for it + if value.hasSuffix(".app") { + if let path = findApp(named: String(value.dropLast(4))) { + return path + } + return value + } + + // Try to find the app by name + if let path = findApp(named: value) { + return path + } + + // Return original value (might be a command or URL) + return value + } + + /// Find an application by name in standard locations + /// - Parameter name: The application name without .app extension + /// - Returns: Full path to the application, or nil if not found + static func findApp(named name: String) -> String? { + let appName = name.hasSuffix(".app") ? name : "\(name).app" + + // First try exact match + for searchPath in expandedSearchPaths { + let fullPath = (searchPath as NSString).appendingPathComponent(appName) + if FileManager.default.fileExists(atPath: fullPath) { + return fullPath + } + } + + // Try case-insensitive match + for searchPath in expandedSearchPaths { + if let match = findCaseInsensitive(name: name, in: searchPath) { + return match + } + } + + // Try using Launch Services to find the app + if let bundleURL = NSWorkspace.shared.urlForApplication( + withBundleIdentifier: bundleIdentifierGuess(for: name)) + { + return bundleURL.path + } + + return nil + } + + /// Find app case-insensitively in a directory + private static func findCaseInsensitive(name: String, in directory: String) -> String? { + let lowercaseName = name.lowercased() + + guard + let contents = try? FileManager.default.contentsOfDirectory( + atPath: directory) + else { + return nil + } + + var prefixMatch: (name: String, path: String)? + var containsMatch: (name: String, path: String)? + + func updateMatch( + _ match: inout (name: String, path: String)?, + candidateName: String, + candidatePath: String + ) { + if let current = match { + if candidateName.count < current.name.count { + match = (candidateName, candidatePath) + } + } else { + match = (candidateName, candidatePath) + } + } + + for item in contents where item.hasSuffix(".app") { + let itemName = String(item.dropLast(4)) + let lowercasedItemName = itemName.lowercased() + if lowercasedItemName == lowercaseName { + return (directory as NSString).appendingPathComponent(item) + } + + let itemPath = (directory as NSString).appendingPathComponent(item) + + // Partial matching: exact (case-insensitive) is first, then prefix, then shortest contains. + if lowercasedItemName.hasPrefix(lowercaseName) { + updateMatch(&prefixMatch, candidateName: lowercasedItemName, candidatePath: itemPath) + } else if lowercasedItemName.contains(lowercaseName) { + updateMatch(&containsMatch, candidateName: lowercasedItemName, candidatePath: itemPath) + } + } + + if let prefixMatch = prefixMatch { + return prefixMatch.path + } + + if let containsMatch = containsMatch { + return containsMatch.path + } + + return nil + } + + /// Guess the bundle identifier for common apps + private static func bundleIdentifierGuess(for name: String) -> String { + // Try a generic pattern + return "com.apple.\(name.replacingOccurrences(of: " ", with: ""))" + } +} diff --git a/Leader Key/Constants.swift b/Leader Key/Constants.swift index bb869a74..4e87bbd9 100644 --- a/Leader Key/Constants.swift +++ b/Leader Key/Constants.swift @@ -11,7 +11,7 @@ extension KeyboardShortcuts.Name { struct KeyMapEntry: Hashable, Codable { let code: UInt16 // Hardware scancode (49) let glyph: String // Visual symbol for UI ("␣") - let text: String // Text identifier for JSON ("space") + let text: String // Text identifier for config file ("space") let reserved: Bool // Whether this key can be bound by users } @@ -166,7 +166,7 @@ extension KeyMaps { return input } - /// Convert a key from any representation to text for JSON storage + /// Convert a key from any representation to text for config file storage static func text(for input: String) -> String? { // Try as glyph first if let entry = byGlyph[input] { diff --git a/Leader Key/Settings/AdvancedPane.swift b/Leader Key/Settings/AdvancedPane.swift index 62cdfb26..ab8211d0 100644 --- a/Leader Key/Settings/AdvancedPane.swift +++ b/Leader Key/Settings/AdvancedPane.swift @@ -17,6 +17,16 @@ struct AdvancedPane: View { @Default(.showAppIconsInCheatsheet) var showAppIconsInCheatsheet @Default(.screen) var screen + private var showConfigFormatSection: Bool { + config.configFormat == .json + } + + @ViewBuilder private var configFormatLabel: some View { + if showConfigFormatSection { + Text("Config format") + } + } + var body: some View { Settings.Container(contentWidth: contentWidth) { Settings.Section( @@ -48,6 +58,34 @@ struct AdvancedPane: View { } } + Settings.Section( + bottomDivider: showConfigFormatSection, + label: { configFormatLabel } + ) { + if showConfigFormatSection { + HStack { + Text("Current format:") + Text("JSON") + .fontWeight(.medium) + Text("(deprecated)") + .foregroundColor(.orange) + } + + VStack(alignment: .leading, spacing: 8) { + Text( + "TOML format offers a simpler, more readable syntax. Converting will backup your JSON config." + ) + .font(.callout) + .foregroundColor(.secondary) + + Button("Convert to TOML") { + config.convertToTOML() + } + } + .padding(.top, 4) + } + } + Settings.Section( title: "Modifier Keys", bottomDivider: true ) { diff --git a/Leader Key/TOMLConfig.swift b/Leader Key/TOMLConfig.swift new file mode 100644 index 00000000..fd267df8 --- /dev/null +++ b/Leader Key/TOMLConfig.swift @@ -0,0 +1,320 @@ +import Foundation +import TOMLKit + +/// TOML configuration parser and serializer for Leader Key +/// Uses TOMLKit for robust parsing, custom serialization for readable output +struct TOMLConfig { + + enum TOMLError: LocalizedError { + case invalidValue(message: String) + + var errorDescription: String? { + switch self { + case .invalidValue(let message): + return "Invalid TOML value: \(message)" + } + } + } + + // MARK: - Parsing (using TOMLKit) + + /// Parse TOML string into a Group structure + static func parse(_ content: String) throws -> Group { + let table = try TOMLTable(string: content) + return try parseTable(table, path: []) + } + + private static func parseTable(_ table: TOMLTable, path: [String]) throws -> Group { + try parseGroup(from: table, key: path.last) + } + + private static func parseGroupTable(_ table: TOMLTable, key: String) throws -> Group { + try parseGroup(from: table, key: key) + } + + private static func parseGroup(from table: TOMLTable, key: String?) throws -> Group { + let actions = try parseTableEntries(table) + let label = extractString(from: table["label"]) + let iconPath = extractString(from: table["icon"]) + return Group(key: key, label: label, iconPath: iconPath, actions: actions) + } + + /// Parse all entries in a TOML table, skipping metadata keys + private static func parseTableEntries(_ table: TOMLTable) throws -> [ActionOrGroup] { + var actions: [ActionOrGroup] = [] + + for (key, value) in table { + guard key != "label", key != "icon" else { continue } + + if let nestedTable = extractTable(from: value) { + if nestedTable["value"] != nil { + let action = try parseActionTable(nestedTable, key: key) + actions.append(.action(action)) + } else { + let group = try parseGroupTable(nestedTable, key: key) + actions.append(.group(group)) + } + } else { + let action = try parseActionValue(value, key: key) + actions.append(.action(action)) + } + } + + return actions + } + + /// Extract TOMLTable from either TOMLValue wrapper or direct TOMLTable + private static func extractTable(from value: any TOMLValueConvertible) -> TOMLTable? { + if let tomlValue = value as? TOMLValue { + return tomlValue.table + } + return value as? TOMLTable + } + + /// Extract String from either TOMLValue wrapper or direct String + private static func extractString(from value: (any TOMLValueConvertible)?) -> String? { + guard let value = value else { return nil } + if let tomlValue = value as? TOMLValue { + return tomlValue.string + } + return value as? String + } + + /// Extract TOMLArray from either TOMLValue wrapper or direct TOMLArray + private static func extractArray(from value: any TOMLValueConvertible) -> TOMLArray? { + if let tomlValue = value as? TOMLValue { + return tomlValue.array + } + return value as? TOMLArray + } + + private static func parseActionTable(_ table: TOMLTable, key: String) throws -> Action { + guard let value = extractString(from: table["value"]) else { + throw TOMLError.invalidValue(message: "Action table '\(key)' missing 'value' key") + } + + let label = extractString(from: table["label"]) + let iconPath = extractString(from: table["icon"]) + let explicitType = try extractType(from: table["type"], key: key) + + return createAction( + key: key, + value: value, + label: label, + iconPath: iconPath, + explicitType: explicitType + ) + } + + private static func parseActionValue(_ value: any TOMLValueConvertible, key: String) throws -> Action { + // Array syntax: ["value", "label"] + if let array = extractArray(from: value) { + guard array.count > 0 else { + throw TOMLError.invalidValue(message: "Empty array for key '\(key)'") + } + guard let actionValue = extractString(from: array[0]) else { + throw TOMLError.invalidValue(message: "First array element must be a string for key '\(key)'") + } + let label = array.count > 1 ? extractString(from: array[1]) : nil + return createAction(key: key, value: actionValue, label: label, iconPath: nil) + } + + // Simple string value + if let stringValue = extractString(from: value) { + return createAction(key: key, value: stringValue, label: nil, iconPath: nil) + } + + throw TOMLError.invalidValue(message: "Unsupported value type for key '\(key)': \(type(of: value))") + } + + private static func createAction( + key: String, + value: String, + label: String?, + iconPath: String?, + explicitType: Type? = nil + ) + -> Action + { + let resolvedValue = AppResolver.resolve(value) + let actionType = explicitType ?? inferType(resolvedValue) + return Action(key: key, type: actionType, label: label, value: resolvedValue, iconPath: iconPath) + } + + private static func inferType(_ value: String) -> Type { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + + if isURLWithScheme(trimmed) { + return .url + } + + if trimmed.hasPrefix("/") || trimmed.hasPrefix("~") { + if trimmed.lowercased().hasSuffix(".app") { + return .application + } + return .folder + } + + if AppResolver.findApp(named: trimmed) != nil { + return .application + } + + return .command + } + + // MARK: - Serialization (custom for readable output) + + /// Serialize a Group to TOML format with readable table sections + static func serialize(_ group: Group) -> String { + var lines: [String] = ["# Leader Key Configuration", ""] + + let rootActions = group.actions.compactMap { item -> Action? in + if case .action(let action) = item { return action } + return nil + } + + // Serialize inline actions (no icon) + for action in rootActions where !actionUsesTable(action) { + lines.append(serializeAction(action)) + } + + // Serialize table actions (with icon) + for action in rootActions where actionUsesTable(action) { + if lines.last?.isEmpty == false { lines.append("") } + lines.append(contentsOf: serializeActionTable(action, path: [])) + } + + // Serialize groups + for case .group(let subgroup) in group.actions { + if lines.last?.isEmpty == false { lines.append("") } + lines.append(contentsOf: serializeGroup(subgroup, path: [])) + } + + return lines.joined(separator: "\n") + } + + private static func serializeAction(_ action: Action) -> String { + let key = escapeKey(action.key ?? "?") + let value = serializeValue(action.value) + + if let label = action.label, !label.isEmpty { + return "\(key) = [\(value), \(escapeString(label))]" + } + return "\(key) = \(value)" + } + + private static func serializeValue(_ value: String) -> String { + if value.lowercased().hasSuffix(".app") { + let appName = (value as NSString).lastPathComponent.replacingOccurrences( + of: ".app", with: "") + if AppResolver.resolve(appName) == value { + return escapeString(appName) + } + } + return escapeString(value) + } + + private static let bareKeyScalars = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-") + + private static func escapeKey(_ key: String) -> String { + // TOML bare keys are limited to ASCII letters, digits, underscore, and dash. + let isBareKey = !key.isEmpty && key.unicodeScalars.allSatisfy { bareKeyScalars.contains($0) } + return isBareKey ? key : escapeString(key) + } + + private static func escapeString(_ str: String) -> String { + var escaped = str + escaped = escaped.replacingOccurrences(of: "\\", with: "\\\\") + escaped = escaped.replacingOccurrences(of: "\"", with: "\\\"") + escaped = escaped.replacingOccurrences(of: "\n", with: "\\n") + escaped = escaped.replacingOccurrences(of: "\r", with: "\\r") + escaped = escaped.replacingOccurrences(of: "\t", with: "\\t") + return "\"\(escaped)\"" + } + + private static func serializeGroup(_ group: Group, path: [String]) -> [String] { + var lines: [String] = [] + let key = escapeKey(group.key ?? "") + let currentPath = path + [key] + + lines.append("[\(currentPath.joined(separator: "."))]") + + if let label = group.label, !label.isEmpty { + lines.append("label = \(escapeString(label))") + } + + if let iconPath = group.iconPath { + lines.append("icon = \(escapeString(iconPath))") + } + + // Serialize inline actions + for case .action(let action) in group.actions where !actionUsesTable(action) { + lines.append(serializeAction(action)) + } + + lines.append("") + + // Serialize table actions + for case .action(let action) in group.actions where actionUsesTable(action) { + lines.append(contentsOf: serializeActionTable(action, path: currentPath)) + } + + // Serialize nested groups + for case .group(let subgroup) in group.actions { + lines.append(contentsOf: serializeGroup(subgroup, path: currentPath)) + } + + return lines + } + + private static func actionUsesTable(_ action: Action) -> Bool { + action.iconPath?.isEmpty == false || shouldSerializeType(action) + } + + private static func serializeActionTable(_ action: Action, path: [String]) -> [String] { + let key = escapeKey(action.key ?? "?") + let tablePath = (path + [key]).joined(separator: ".") + var lines: [String] = [] + lines.append("[\(tablePath)]") + lines.append("value = \(serializeValue(action.value))") + + if shouldSerializeType(action) { + lines.append("type = \(escapeString(action.type.rawValue))") + } + + if let label = action.label, !label.isEmpty { + lines.append("label = \(escapeString(label))") + } + + if let iconPath = action.iconPath, !iconPath.isEmpty { + lines.append("icon = \(escapeString(iconPath))") + } + + lines.append("") + return lines + } + + private static func shouldSerializeType(_ action: Action) -> Bool { + inferType(action.value) != action.type + } + + private static func extractType(from value: (any TOMLValueConvertible)?, key: String) throws -> Type? { + guard let stringValue = extractString(from: value) else { return nil } + if let parsedType = Type(rawValue: stringValue) { + return parsedType + } + throw TOMLError.invalidValue(message: "Invalid type '\(stringValue)' for key '\(key)'") + } + + private static func isURLWithScheme(_ value: String) -> Bool { + guard let colonIndex = value.firstIndex(of: ":") else { return false } + let scheme = value[.. Data { + switch format { + case .json: + let encoder = JSONEncoder() + encoder.outputFormatting = [ + .prettyPrinted, .withoutEscapingSlashes, .sortedKeys, + ] + return try encoder.encode(root) + + case .toml: + let tomlString = TOMLConfig.serialize(root) + guard let tomlData = tomlString.data(using: .utf8) else { + throw NSError( + domain: "UserConfig", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to encode TOML as UTF-8"] + ) + } + return tomlData + } + } + // MARK: - Directory Management static func defaultDirectory() -> String { @@ -208,8 +242,14 @@ class UserConfig: ObservableObject { // MARK: - File Operations + /// Path to the current config file (TOML preferred, falls back to JSON) var path: String { - (Defaults[.configDir] as NSString).appendingPathComponent(fileName) + path(for: configFormat) + } + + /// Path for a specific format + func path(for format: ConfigFormat) -> String { + (Defaults[.configDir] as NSString).appendingPathComponent(format.fileName) } var url: URL { @@ -217,7 +257,22 @@ class UserConfig: ObservableObject { } var exists: Bool { - fileManager.fileExists(atPath: path) + fileManager.fileExists(atPath: path(for: .toml)) + || fileManager.fileExists(atPath: path(for: .json)) + } + + /// Detect which config format exists (TOML preferred) + func detectConfigFormat() -> ConfigFormat { + let tomlPath = path(for: .toml) + let jsonPath = path(for: .json) + + if fileManager.fileExists(atPath: tomlPath) { + return .toml + } else if fileManager.fileExists(atPath: jsonPath) { + return .json + } + // Default to TOML for new configs + return .toml } private func ensureConfigFileExists() { @@ -231,14 +286,17 @@ class UserConfig: ObservableObject { } private func bootstrapConfig() throws { - guard let data = defaultConfig.data(using: .utf8) else { + // Create TOML config by default + guard let data = defaultConfigTOML.data(using: .utf8) else { throw NSError( domain: "UserConfig", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to encode default config"] ) } - try writeFile(data: data) + configFormat = .toml + let tomlPath = path(for: .toml) + try data.write(to: URL(fileURLWithPath: tomlPath), options: .atomic) } private func writeFile(data: Data) throws { @@ -294,33 +352,44 @@ class UserConfig: ObservableObject { return } + let format = detectConfigFormat() + configIOQueue.async { [weak self] in guard let self = self else { return } do { - let configString = try self.readFile() - - guard let jsonData = configString.data(using: .utf8) else { - throw NSError( - domain: "UserConfig", - code: 1, - userInfo: [ - NSLocalizedDescriptionKey: "Failed to encode config file as UTF-8" - ] - ) + let configPath = self.path(for: format) + let configString = try String(contentsOfFile: configPath, encoding: .utf8) + + let decodedRoot: Group + + switch format { + case .json: + guard let jsonData = configString.data(using: .utf8) else { + throw NSError( + domain: "UserConfig", + code: 1, + userInfo: [ + NSLocalizedDescriptionKey: "Failed to encode config file as UTF-8" + ] + ) + } + let decoder = JSONDecoder() + decodedRoot = try decoder.decode(Group.self, from: jsonData) + + case .toml: + decodedRoot = try TOMLConfig.parse(configString) } - let decoder = JSONDecoder() - let decodedRoot = try decoder.decode(Group.self, from: jsonData) let checksum = self.calculateChecksum(configString) let validationErrors = ConfigValidator.validate(group: decodedRoot) DispatchQueue.main.async { + self.configFormat = format self.root = decodedRoot self.lastReadChecksum = checksum self.setValidationErrors(validationErrors) self.isLoading = false - } } catch { DispatchQueue.main.async { @@ -331,6 +400,61 @@ class UserConfig: ObservableObject { } } + /// Convert current JSON config to TOML + func convertToTOML() { + let tomlContent = TOMLConfig.serialize(root) + let tomlPath = path(for: .toml) + let jsonPath = path(for: .json) + let backupPath = jsonPath + ".backup" + let checksum = calculateChecksum(tomlContent) + + func finalizeConversion(style: NSAlert.Style, informativeText: String) { + DispatchQueue.main.async { + self.configFormat = .toml + self.lastReadChecksum = checksum + _ = self.alertHandler.showAlert( + style: style, + message: "Configuration converted", + informativeText: informativeText, + buttons: ["OK"] + ) + } + } + + do { + // Write TOML file + try tomlContent.write(toFile: tomlPath, atomically: true, encoding: .utf8) + + // Rename JSON file as backup + if fileManager.fileExists(atPath: jsonPath) { + if fileManager.fileExists(atPath: backupPath) { + try fileManager.removeItem(atPath: backupPath) + } + try fileManager.moveItem(atPath: jsonPath, toPath: backupPath) + finalizeConversion( + style: .informational, + informativeText: + "Your configuration has been converted to TOML format.\nBackup saved as config.json.backup" + ) + } else { + finalizeConversion( + style: .warning, + informativeText: + "Your configuration has been converted to TOML format.\nNo JSON config was found to back up." + ) + } + } catch { + DispatchQueue.main.async { + _ = self.alertHandler.showAlert( + style: .warning, + message: "Conversion failed", + informativeText: "Failed to convert configuration: \(error.localizedDescription)", + buttons: ["OK"] + ) + } + } + } + // MARK: - Validation func validateWithoutAlerts() { @@ -372,32 +496,26 @@ extension UserConfig { } } -let defaultConfig = """ - { - "type": "group", - "actions": [ - { "key": "t", "type": "application", "value": "/System/Applications/Utilities/Terminal.app" }, - { - "key": "o", - "type": "group", - "actions": [ - { "key": "s", "type": "application", "value": "/Applications/Safari.app" }, - { "key": "e", "type": "application", "value": "/Applications/Mail.app" }, - { "key": "i", "type": "application", "value": "/System/Applications/Music.app" }, - { "key": "m", "type": "application", "value": "/Applications/Messages.app" } - ] - }, - { - "key": "r", - "type": "group", - "actions": [ - { "key": "e", "type": "url", "value": "raycast://extensions/raycast/emoji-symbols/search-emoji-symbols" }, - { "key": "p", "type": "url", "value": "raycast://confetti" }, - { "key": "c", "type": "url", "value": "raycast://extensions/raycast/system/open-camera" } - ] - } - ] - } +let defaultConfigTOML = """ + # Leader Key Configuration + # Edit this file to customize your keyboard shortcuts + + # Simple shortcuts - just press the key after the leader key + t = "Terminal" + + # Groups organize related shortcuts + [o] + label = "[apps]" + s = "Safari" + e = "Mail" + i = "Music" + m = "Messages" + + [r] + label = "[raycast]" + e = ["raycast://extensions/raycast/emoji-symbols/search-emoji-symbols", "emoji"] + p = ["raycast://confetti", "confetti"] + c = ["raycast://extensions/raycast/system/open-camera", "camera"] """ enum Type: String, Codable { @@ -417,7 +535,7 @@ protocol Item { } struct Action: Item, Codable, Equatable { - // UI-only stable identity. Not persisted to JSON. + // UI-only stable identity. Not persisted to config. var uiid: UUID = UUID() var key: String? @@ -427,9 +545,10 @@ struct Action: Item, Codable, Equatable { var iconPath: String? var displayName: String { - guard let labelValue = label else { return bestGuessDisplayName } - guard !labelValue.isEmpty else { return bestGuessDisplayName } - return labelValue + if let labelValue = label, !labelValue.isEmpty { + return labelValue + } + return bestGuessDisplayName } var bestGuessDisplayName: String { @@ -496,9 +615,10 @@ struct Group: Item, Codable, Equatable { var actions: [ActionOrGroup] var displayName: String { - guard let labelValue = label else { return "Group" } - if labelValue.isEmpty { return "Group" } - return labelValue + if let labelValue = label, !labelValue.isEmpty { + return labelValue + } + return "Group" } static func == (lhs: Group, rhs: Group) -> Bool { @@ -595,8 +715,8 @@ enum ActionOrGroup: Codable, Equatable { } try container.encode(action.type, forKey: .type) try container.encode(action.value, forKey: .value) - if action.label != nil && !action.label!.isEmpty { - try container.encodeIfPresent(action.label, forKey: .label) + if let label = action.label, !label.isEmpty { + try container.encode(label, forKey: .label) } try container.encodeIfPresent(action.iconPath, forKey: .iconPath) case .group(let group): @@ -609,8 +729,8 @@ enum ActionOrGroup: Codable, Equatable { } try container.encode(Type.group, forKey: .type) try container.encode(group.actions, forKey: .actions) - if group.label != nil && !group.label!.isEmpty { - try container.encodeIfPresent(group.label, forKey: .label) + if let label = group.label, !label.isEmpty { + try container.encode(label, forKey: .label) } try container.encodeIfPresent(group.iconPath, forKey: .iconPath) } diff --git a/Leader KeyTests/TOMLConfigTests.swift b/Leader KeyTests/TOMLConfigTests.swift new file mode 100644 index 00000000..e8d0ee35 --- /dev/null +++ b/Leader KeyTests/TOMLConfigTests.swift @@ -0,0 +1,645 @@ +import XCTest + +@testable import Leader_Key + +final class TOMLConfigTests: XCTestCase { + + // MARK: - Basic Parsing + + func testParseSimpleAction() throws { + let toml = """ + t = "Terminal" + """ + + let group = try TOMLConfig.parse(toml) + + XCTAssertEqual(group.actions.count, 1) + if case .action(let action) = group.actions[0] { + XCTAssertEqual(action.key, "t") + XCTAssertEqual(action.type, .application) + XCTAssertTrue(action.value.contains("Terminal")) + } else { + XCTFail("Expected action") + } + } + + func testParseURL() throws { + let toml = """ + g = "https://google.com" + """ + + let group = try TOMLConfig.parse(toml) + + XCTAssertEqual(group.actions.count, 1) + if case .action(let action) = group.actions[0] { + XCTAssertEqual(action.key, "g") + XCTAssertEqual(action.type, .url) + XCTAssertEqual(action.value, "https://google.com") + } else { + XCTFail("Expected action") + } + } + + func testParseRaycastURL() throws { + let toml = """ + e = "raycast://extensions/raycast/emoji-symbols/search-emoji-symbols" + """ + + let group = try TOMLConfig.parse(toml) + + if case .action(let action) = group.actions[0] { + XCTAssertEqual(action.type, .url) + XCTAssertTrue(action.value.hasPrefix("raycast://")) + } else { + XCTFail("Expected action") + } + } + + func testParseCustomSchemeURL() throws { + let toml = """ + x = "myapp:do-thing" + """ + + let group = try TOMLConfig.parse(toml) + + if case .action(let action) = group.actions[0] { + XCTAssertEqual(action.type, .url) + } else { + XCTFail("Expected action") + } + } + + func testParseFolder() throws { + let toml = """ + d = "~/Downloads" + """ + + let group = try TOMLConfig.parse(toml) + + if case .action(let action) = group.actions[0] { + XCTAssertEqual(action.key, "d") + XCTAssertEqual(action.type, .folder) + } else { + XCTFail("Expected action") + } + } + + func testParseArrayWithLabel() throws { + let toml = """ + v = ["Visual Studio Code", "VS Code"] + """ + + let group = try TOMLConfig.parse(toml) + + if case .action(let action) = group.actions[0] { + XCTAssertEqual(action.key, "v") + XCTAssertEqual(action.label, "VS Code") + } else { + XCTFail("Expected action") + } + } + + // MARK: - Groups + + func testParseGroup() throws { + let toml = """ + [l] + label = "[links]" + g = "https://github.com" + """ + + let group = try TOMLConfig.parse(toml) + + XCTAssertEqual(group.actions.count, 1) + if case .group(let subgroup) = group.actions[0] { + XCTAssertEqual(subgroup.key, "l") + XCTAssertEqual(subgroup.label, "[links]") + XCTAssertEqual(subgroup.actions.count, 1) + } else { + XCTFail("Expected group") + } + } + + func testParseNestedGroup() throws { + let toml = """ + [l] + label = "[links]" + g = "https://github.com" + + [l.m] + label = "[me]" + t = "https://twitter.com" + """ + + let group = try TOMLConfig.parse(toml) + + XCTAssertEqual(group.actions.count, 1) + if case .group(let linksGroup) = group.actions[0] { + XCTAssertEqual(linksGroup.key, "l") + XCTAssertEqual(linksGroup.label, "[links]") + + // Should have 1 action and 1 nested group + XCTAssertEqual(linksGroup.actions.count, 2) + + // Find the nested group + let nestedGroup = linksGroup.actions.first { item in + if case .group = item { return true } + return false + } + XCTAssertNotNil(nestedGroup) + + if case .group(let meGroup) = nestedGroup! { + XCTAssertEqual(meGroup.key, "m") + XCTAssertEqual(meGroup.label, "[me]") + } + } else { + XCTFail("Expected group") + } + } + + func testParseActionTable() throws { + let toml = """ + [r.e] + value = "raycast://extensions/raycast/emoji-symbols/search-emoji-symbols" + icon = "square.and.arrow.up.circle.fill" + """ + + let group = try TOMLConfig.parse(toml) + + XCTAssertEqual(group.actions.count, 1) + if case .group(let parentGroup) = group.actions[0] { + XCTAssertEqual(parentGroup.key, "r") + XCTAssertEqual(parentGroup.actions.count, 1) + if case .action(let action) = parentGroup.actions[0] { + XCTAssertEqual(action.key, "e") + XCTAssertEqual(action.type, .url) + XCTAssertTrue(action.value.hasPrefix("raycast://")) + XCTAssertEqual(action.iconPath, "square.and.arrow.up.circle.fill") + } else { + XCTFail("Expected action") + } + } else { + XCTFail("Expected group") + } + } + + func testParseActionTableWithExplicitType() throws { + let toml = """ + [x] + value = "https://example.com" + type = "command" + """ + + let group = try TOMLConfig.parse(toml) + + XCTAssertEqual(group.actions.count, 1) + if case .action(let action) = group.actions[0] { + XCTAssertEqual(action.key, "x") + XCTAssertEqual(action.type, .command) + } else { + XCTFail("Expected action") + } + } + + // MARK: - Comments + + func testIgnoresComments() throws { + let toml = """ + # This is a comment + t = "Terminal" # inline comment + # Another comment + """ + + let group = try TOMLConfig.parse(toml) + + XCTAssertEqual(group.actions.count, 1) + } + + func testCommentsInStrings() throws { + let toml = """ + t = "Terminal # not a comment" + """ + + let group = try TOMLConfig.parse(toml) + + if case .action(let action) = group.actions[0] { + // The value should contain the # because it's inside quotes + XCTAssertTrue(action.value.contains("#") || action.value.contains("Terminal")) + } else { + XCTFail("Expected action") + } + } + + // MARK: - Empty Lines + + func testIgnoresEmptyLines() throws { + let toml = """ + + t = "Terminal" + + g = "https://google.com" + + """ + + let group = try TOMLConfig.parse(toml) + + XCTAssertEqual(group.actions.count, 2) + } + + // MARK: - Serialization + + func testSerializeSimpleAction() throws { + let action = Action(key: "t", type: .application, value: "/Applications/Terminal.app") + let group = Group(key: nil, actions: [.action(action)]) + + let toml = TOMLConfig.serialize(group) + + XCTAssertTrue(toml.contains("t = ")) + } + + func testSerializeWithLabel() throws { + let action = Action( + key: "v", type: .application, label: "VS Code", + value: "/Applications/Visual Studio Code.app") + let group = Group(key: nil, actions: [.action(action)]) + + let toml = TOMLConfig.serialize(group) + + XCTAssertTrue(toml.contains("VS Code")) + } + + func testSerializeGroup() throws { + let linkAction = Action(key: "g", type: .url, value: "https://github.com") + let linksGroup = Group(key: "l", label: "[links]", actions: [.action(linkAction)]) + let rootGroup = Group(key: nil, actions: [.group(linksGroup)]) + + let toml = TOMLConfig.serialize(rootGroup) + + XCTAssertTrue(toml.contains("[l]")) + XCTAssertTrue(toml.contains("label = \"[links]\"")) + } + + func testSerializeActionTable() throws { + let action = Action( + key: "e", type: .url, + value: "raycast://extensions/raycast/emoji-symbols/search-emoji-symbols", + iconPath: "square.and.arrow.up.circle.fill" + ) + let group = Group(key: "r", actions: [.action(action)]) + let rootGroup = Group(key: nil, actions: [.group(group)]) + + let toml = TOMLConfig.serialize(rootGroup) + + XCTAssertTrue(toml.contains("[r.e]")) + XCTAssertTrue(toml.contains("value = \"raycast://extensions/raycast/emoji-symbols/search-emoji-symbols\"")) + XCTAssertTrue(toml.contains("icon = \"square.and.arrow.up.circle.fill\"")) + XCTAssertFalse(toml.contains("{")) + } + + func testSerializeExplicitTypeWhenMismatch() throws { + let action = Action(key: "u", type: .command, value: "https://example.com") + let group = Group(key: nil, actions: [.action(action)]) + + let toml = TOMLConfig.serialize(group) + + XCTAssertTrue(toml.contains("[u]")) + XCTAssertTrue(toml.contains("type = \"command\"")) + XCTAssertTrue(toml.contains("value = \"https://example.com\"")) + } + + // MARK: - Round Trip + + func testRoundTrip() throws { + let originalToml = """ + t = "Terminal" + + [l] + label = "[links]" + g = "https://github.com" + """ + + let group = try TOMLConfig.parse(originalToml) + let serialized = TOMLConfig.serialize(group) + let reparsed = try TOMLConfig.parse(serialized) + + // Compare structure + XCTAssertEqual(group.actions.count, reparsed.actions.count) + } + + // MARK: - Error Handling + + func testInvalidSyntaxThrows() throws { + let toml = """ + invalid line without equals + """ + + XCTAssertThrowsError(try TOMLConfig.parse(toml)) + } + + func testEmptyKeyThrows() throws { + let toml = """ + = "value" + """ + + XCTAssertThrowsError(try TOMLConfig.parse(toml)) + } + + // MARK: - Special Characters in Keys + + func testParseOpenBracketKey() throws { + let toml = """ + "[" = "https://example.com" + """ + + let group = try TOMLConfig.parse(toml) + + XCTAssertEqual(group.actions.count, 1) + if case .action(let action) = group.actions[0] { + XCTAssertEqual(action.key, "[") + XCTAssertEqual(action.type, .url) + } else { + XCTFail("Expected action") + } + } + + func testParseCloseBracketKey() throws { + let toml = """ + "]" = "https://example.com" + """ + + let group = try TOMLConfig.parse(toml) + + XCTAssertEqual(group.actions.count, 1) + if case .action(let action) = group.actions[0] { + XCTAssertEqual(action.key, "]") + } else { + XCTFail("Expected action") + } + } + + func testParseEqualsKey() throws { + let toml = """ + "=" = "https://example.com" + """ + + let group = try TOMLConfig.parse(toml) + + XCTAssertEqual(group.actions.count, 1) + if case .action(let action) = group.actions[0] { + XCTAssertEqual(action.key, "=") + } else { + XCTFail("Expected action") + } + } + + func testParseHashKey() throws { + let toml = """ + "#" = "https://example.com" + """ + + let group = try TOMLConfig.parse(toml) + + XCTAssertEqual(group.actions.count, 1) + if case .action(let action) = group.actions[0] { + XCTAssertEqual(action.key, "#") + } else { + XCTFail("Expected action") + } + } + + func testParseDotKey() throws { + let toml = """ + "." = "https://example.com" + """ + + let group = try TOMLConfig.parse(toml) + + XCTAssertEqual(group.actions.count, 1) + if case .action(let action) = group.actions[0] { + XCTAssertEqual(action.key, ".") + } else { + XCTFail("Expected action") + } + } + + func testParseSpaceKey() throws { + let toml = """ + " " = "https://example.com" + """ + + let group = try TOMLConfig.parse(toml) + + XCTAssertEqual(group.actions.count, 1) + if case .action(let action) = group.actions[0] { + XCTAssertEqual(action.key, " ") + } else { + XCTFail("Expected action") + } + } + + func testParseBacktickKey() throws { + let toml = """ + "`" = "https://example.com" + """ + + let group = try TOMLConfig.parse(toml) + + XCTAssertEqual(group.actions.count, 1) + if case .action(let action) = group.actions[0] { + XCTAssertEqual(action.key, "`") + } else { + XCTFail("Expected action") + } + } + + func testParseQuoteInValue() throws { + let toml = """ + t = "say \\"hello\\"" + """ + + let group = try TOMLConfig.parse(toml) + + if case .action(let action) = group.actions[0] { + XCTAssertTrue(action.value.contains("\"")) + } else { + XCTFail("Expected action") + } + } + + func testParseBackslashInValue() throws { + let toml = """ + t = "path\\\\to\\\\file" + """ + + let group = try TOMLConfig.parse(toml) + + if case .action(let action) = group.actions[0] { + XCTAssertTrue(action.value.contains("\\")) + } else { + XCTFail("Expected action") + } + } + + // MARK: - Serialization of Special Characters + + func testSerializeOpenBracketKey() throws { + let action = Action(key: "[", type: .url, value: "https://example.com") + let group = Group(key: nil, actions: [.action(action)]) + + let toml = TOMLConfig.serialize(group) + + XCTAssertTrue(toml.contains("\"[\"")) + } + + func testSerializeCloseBracketKey() throws { + let action = Action(key: "]", type: .url, value: "https://example.com") + let group = Group(key: nil, actions: [.action(action)]) + + let toml = TOMLConfig.serialize(group) + + XCTAssertTrue(toml.contains("\"]\"")) + } + + func testSerializeEqualsKey() throws { + let action = Action(key: "=", type: .url, value: "https://example.com") + let group = Group(key: nil, actions: [.action(action)]) + + let toml = TOMLConfig.serialize(group) + + XCTAssertTrue(toml.contains("\"=\"")) + } + + func testSerializeHashKey() throws { + let action = Action(key: "#", type: .url, value: "https://example.com") + let group = Group(key: nil, actions: [.action(action)]) + + let toml = TOMLConfig.serialize(group) + + XCTAssertTrue(toml.contains("\"#\"")) + } + + func testSerializeSpaceKey() throws { + let action = Action(key: " ", type: .url, value: "https://example.com") + let group = Group(key: nil, actions: [.action(action)]) + + let toml = TOMLConfig.serialize(group) + + XCTAssertTrue(toml.contains("\" \"")) + } + + func testSerializeBacktickKey() throws { + let action = Action(key: "`", type: .url, value: "https://example.com") + let group = Group(key: nil, actions: [.action(action)]) + + let toml = TOMLConfig.serialize(group) + + XCTAssertTrue(toml.contains("\"`\"")) + } + + // MARK: - Round Trip Special Characters + + func testRoundTripBracketKeys() throws { + let toml = """ + "[" = "https://open.com" + "]" = "https://close.com" + """ + + let group = try TOMLConfig.parse(toml) + let serialized = TOMLConfig.serialize(group) + let reparsed = try TOMLConfig.parse(serialized) + + XCTAssertEqual(group.actions.count, reparsed.actions.count) + + // Verify keys preserved + let keys = reparsed.actions.compactMap { item -> String? in + if case .action(let action) = item { return action.key } + return nil + } + XCTAssertTrue(keys.contains("[")) + XCTAssertTrue(keys.contains("]")) + } + + func testRoundTripSpecialCharacterKeys() throws { + let toml = """ + "=" = "https://equals.com" + "#" = "https://hash.com" + "." = "https://dot.com" + " " = "https://space.com" + """ + + let group = try TOMLConfig.parse(toml) + let serialized = TOMLConfig.serialize(group) + let reparsed = try TOMLConfig.parse(serialized) + + XCTAssertEqual(group.actions.count, reparsed.actions.count) + } + + // MARK: - Complex Nested Structure + + func testParseComplexConfig() throws { + let toml = """ + t = "Terminal" + + [o] + s = "Safari" + e = "/Applications/Mail.app" + + [r] + e = "raycast://extensions/raycast/emoji-symbols/search-emoji-symbols" + p = "raycast://confetti" + + [r.c] + value = "raycast://extensions/raycast/system/open-camera" + icon = "camera" + """ + + let group = try TOMLConfig.parse(toml) + + // Root action + let rootActions = group.actions.compactMap { item -> Action? in + if case .action(let action) = item { return action } + return nil + } + XCTAssertEqual(rootActions.count, 1) + XCTAssertEqual(rootActions[0].key, "t") + + // Groups + let groups = group.actions.compactMap { item -> Group? in + if case .group(let g) = item { return g } + return nil + } + XCTAssertEqual(groups.count, 2) + } + + func testParseGroupWithActionTable() throws { + let toml = """ + [r] + e = "raycast://emoji" + + [r.p] + value = "raycast://confetti" + icon = "party.popper" + label = "Party!" + """ + + let group = try TOMLConfig.parse(toml) + + if case .group(let rGroup) = group.actions[0] { + XCTAssertEqual(rGroup.key, "r") + XCTAssertEqual(rGroup.actions.count, 2) + + // Find the action with icon + let actionWithIcon = rGroup.actions.compactMap { item -> Action? in + if case .action(let action) = item, action.iconPath != nil { return action } + return nil + }.first + + XCTAssertNotNil(actionWithIcon) + XCTAssertEqual(actionWithIcon?.key, "p") + XCTAssertEqual(actionWithIcon?.iconPath, "party.popper") + XCTAssertEqual(actionWithIcon?.label, "Party!") + } else { + XCTFail("Expected group") + } + } +} diff --git a/Leader KeyTests/UserConfigTests.swift b/Leader KeyTests/UserConfigTests.swift index 6a8fb64f..186065df 100644 --- a/Leader KeyTests/UserConfigTests.swift +++ b/Leader KeyTests/UserConfigTests.swift @@ -102,15 +102,16 @@ final class UserConfigTests: XCTestCase { // First ensure we're in the default directory since custom dirs are no longer supported Defaults[.configDir] = UserConfig.defaultDirectory() - let invalidJSON = "{ invalid json }" - try invalidJSON.write(to: subject.url, atomically: true, encoding: .utf8) + // Invalid TOML - missing equals sign + let invalidTOML = "invalid toml without equals" + try invalidTOML.write(to: subject.url, atomically: true, encoding: .utf8) subject.ensureAndLoad() waitForConfigLoad() XCTAssertEqual(subject.root, emptyRoot) XCTAssertGreaterThan(testAlertManager.shownAlerts.count, 0) - // Verify that at least one warning alert was shown (JSON parsing errors are non-critical) + // Verify that at least one warning alert was shown (parsing errors are non-critical) XCTAssertTrue( testAlertManager.shownAlerts.contains { alert in alert.style == .warning @@ -118,16 +119,13 @@ final class UserConfigTests: XCTestCase { } func testValidationIssuesDoNotTriggerAlerts() throws { - let json = """ - { - "actions": [ - { "key": "a", "type": "application", "value": "/Applications/Safari.app" }, - { "key": "a", "type": "url", "value": "https://example.com" } - ] - } + // Use valid TOML with an invalid key to trigger validation + let toml = """ + badkey = "/Applications/Safari.app" + b = "https://example.com" """ - try json.write(to: subject.url, atomically: true, encoding: .utf8) + try toml.write(to: subject.url, atomically: true, encoding: .utf8) subject.ensureAndLoad() waitForConfigLoad() diff --git a/README.md b/README.md index 33588728..428e3514 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,13 @@ $ brew install leader-key - leadermm → Mute audio (`media mute`) - leaderwm → Maximize current window (`window maximize`) +## Configuration + +Leader Key uses a TOML config format inspired by [Hammerflow](https://hammerflow.dev/) and its [sample config](https://github.com/saml-dev/Hammerflow.spoon/blob/main/sample.toml). +Use [config.sample.toml](config.sample.toml) as a starting point. + +Default config path: `~/Library/Application Support/Leader Key/config.toml` (legacy `config.json` is still supported). + ## URL Scheme Leader Key supports URL scheme automation for integration with tools like Alfred, Raycast, shell scripts, and more. @@ -58,7 +65,7 @@ Leader Key supports URL scheme automation for integration with tools like Alfred # Reload configuration from disk open "leaderkey://config-reload" -# Show config.json in Finder +# Show config file in Finder open "leaderkey://config-reveal" ``` @@ -95,7 +102,7 @@ open "leaderkey://navigate?keys=a,b,c&execute=false" ### Example Use Cases - **Alfred/Raycast workflows**: Trigger Leader Key shortcuts programmatically -- **Shell scripts**: Automate configuration reloads after editing config.json +- **Shell scripts**: Automate configuration reloads after editing config file - **Keyboard maestro**: Chain Leader Key actions with other automations - **External triggers**: Open specific action sequences from other applications diff --git a/config.sample.toml b/config.sample.toml new file mode 100644 index 00000000..a6eb656d --- /dev/null +++ b/config.sample.toml @@ -0,0 +1,40 @@ +# Leader Key Configuration (sample) +# Keys map to actions. Use a single character key for each shortcut. +# Simple actions use: key = "value" +# Array syntax: key = ["value", "Label"] + +t = "Terminal" +d = "~/Downloads" +g = "https://github.com" +p = ["say hello", "Speak"] + +[o] +label = "[apps]" +s = "Safari" +c = ["Visual Studio Code", "VS Code"] +m = "/Applications/Mail.app" + +[w] +label = "[web]" +g = "https://github.com" +h = "https://news.ycombinator.com" + +[r] +label = "[raycast]" +e = "raycast://extensions/raycast/emoji-symbols/search-emoji-symbols" +p = "raycast://confetti" + +[r.c] +value = "raycast://extensions/raycast/system/open-camera" +label = "camera" +icon = "camera" + +[s] +label = "[system]" +l = ["pmset displaysleepnow", "Sleep Displays"] + +[s.k] +value = "/System/Library/CoreServices/Menu Extras/User.menu/Contents/Resources/CGSession -suspend" +type = "command" +label = "Lock Screen" +icon = "lock.fill"