Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The description uses parentheses for 'JSON legacy' which could be clearer. Consider revising to 'Configuration management with TOML format (JSON deprecated) and validation' for better clarity.

Suggested change
- `UserConfig`: TOML-first (JSON legacy) configuration management with validation
- `UserConfig`: Configuration management with TOML format (JSON deprecated) and validation

Copilot uses AI. Check for mistakes.
- `UserState`: Tracks navigation through key sequences
- `MainWindow`: Base class for theme windows

Expand All @@ -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
Expand All @@ -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).

28 changes: 28 additions & 0 deletions Leader Key.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */; };
Expand All @@ -68,6 +72,9 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
C4C1F691881442199A5F55C5 /* TOMLConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOMLConfig.swift; sourceTree = "<group>"; };
C4C1F691881442199A5F55C7 /* AppResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppResolver.swift; sourceTree = "<group>"; };
C4C1F691881442199A5F55C9 /* TOMLConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOMLConfigTests.swift; sourceTree = "<group>"; };
115AA5BE2DA521C200C17E18 /* ActionIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionIcon.swift; sourceTree = "<group>"; };
130196C52D73B3DC0093148B /* Breadcrumbs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Breadcrumbs.swift; sourceTree = "<group>"; };
423632142D678F4400878D92 /* TestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestPlan.xctestplan; sourceTree = "<group>"; };
Expand Down Expand Up @@ -128,6 +135,7 @@
427C181A2BD3123C00955B98 /* Defaults in Frameworks */,
4279AFED2C6A175500952A83 /* LaunchAtLogin in Frameworks */,
427C18172BD311ED00955B98 /* KeyboardShortcuts in Frameworks */,
C4C1F691881442199A5F55D0 /* TOMLKit in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -186,6 +194,7 @@
children = (
42B21FBB2D67566100F4A2C7 /* Alerts.swift */,
427C181F2BD31C3D00955B98 /* AppDelegate.swift */,
C4C1F691881442199A5F55C7 /* AppResolver.swift */,
73192AF63CAF425397D7C0D2 /* URLSchemeHandler.swift */,
42F4CDCE2D46E2B300D0DD76 /* Cheatsheet.swift */,
426E625A2D2E6A98009FD2F2 /* CommandRunner.swift */,
Expand All @@ -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 */,
Expand All @@ -226,6 +236,7 @@
children = (
4284834B2E813212009D7EEF /* KeyboardLayoutTests.swift */,
42454DDC2D71CBAB004E1374 /* ConfigValidatorTests.swift */,
C4C1F691881442199A5F55C9 /* TOMLConfigTests.swift */,
EC5CEBC4C47B4C5DB2258814 /* URLSchemeTests.swift */,
427C17FC2BD311B500955B98 /* UserConfigTests.swift */,
);
Expand Down Expand Up @@ -351,6 +362,7 @@
4236321C2D6799B900878D92 /* XCRemoteSwiftPackageReference "SwiftFormatPlugins" */,
115AA5C02DA5463700C17E18 /* XCRemoteSwiftPackageReference "SymbolPicker" */,
FB00D5262D824E2C00A37486 /* XCRemoteSwiftPackageReference "Kingfisher" */,
C4C1F691881442199A5F55D2 /* XCRemoteSwiftPackageReference "TOMLKit" */,
);
productRefGroup = 427C17E82BD311B400955B98 /* Products */;
projectDirPath = "";
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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 */;
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

141 changes: 141 additions & 0 deletions Leader Key/AppResolver.swift
Original file line number Diff line number Diff line change
@@ -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: ""))"
}
}
4 changes: 2 additions & 2 deletions Leader Key/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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] {
Expand Down
38 changes: 38 additions & 0 deletions Leader Key/Settings/AdvancedPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
) {
Expand Down
Loading
Loading