Skip to content
Merged
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
8 changes: 8 additions & 0 deletions Leader Key.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
423632282D6A806700878D92 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 423632272D6A806700878D92 /* Theme.swift */; };
42454DDB2D71CB39004E1374 /* ConfigValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42454DDA2D71CB39004E1374 /* ConfigValidator.swift */; };
42454DDD2D71CBAB004E1374 /* ConfigValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42454DDC2D71CBAB004E1374 /* ConfigValidatorTests.swift */; };
EC5CEBC4C47B4C5DB2258813 /* URLSchemeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC5CEBC4C47B4C5DB2258814 /* URLSchemeTests.swift */; };
425495402D75EFAD0020300E /* ForTheHorde.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4254953F2D75EFAD0020300E /* ForTheHorde.swift */; };
426E625B2D2E6A98009FD2F2 /* CommandRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 426E625A2D2E6A98009FD2F2 /* CommandRunner.swift */; };
4279AFED2C6A175500952A83 /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = 4279AFEC2C6A175500952A83 /* LaunchAtLogin */; };
Expand All @@ -25,6 +26,7 @@
427C181A2BD3123C00955B98 /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = 427C18192BD3123C00955B98 /* Defaults */; };
427C181C2BD314B500955B98 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C181B2BD314B500955B98 /* Constants.swift */; };
427C18202BD31C3D00955B98 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C181F2BD31C3D00955B98 /* AppDelegate.swift */; };
73192AF63CAF425397D7C0D1 /* URLSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73192AF63CAF425397D7C0D2 /* URLSchemeHandler.swift */; };
427C18232BD31DF100955B98 /* Settings in Frameworks */ = {isa = PBXBuildFile; productRef = 427C18222BD31DF100955B98 /* Settings */; };
427C18282BD31E2E00955B98 /* GeneralPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C18242BD31E2E00955B98 /* GeneralPane.swift */; };
427C18292BD31E2E00955B98 /* MainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C18252BD31E2E00955B98 /* MainWindow.swift */; };
Expand Down Expand Up @@ -74,6 +76,7 @@
423632272D6A806700878D92 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
42454DDA2D71CB39004E1374 /* ConfigValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigValidator.swift; sourceTree = "<group>"; };
42454DDC2D71CBAB004E1374 /* ConfigValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigValidatorTests.swift; sourceTree = "<group>"; };
EC5CEBC4C47B4C5DB2258814 /* URLSchemeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSchemeTests.swift; sourceTree = "<group>"; };
4254953F2D75EFAD0020300E /* ForTheHorde.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ForTheHorde.swift; sourceTree = "<group>"; };
426E625A2D2E6A98009FD2F2 /* CommandRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandRunner.swift; sourceTree = "<group>"; };
4279AFEA2C6A08B100952A83 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = "Leader Key/Support/Info.plist"; sourceTree = SOURCE_ROOT; };
Expand All @@ -85,6 +88,7 @@
427C17FC2BD311B500955B98 /* UserConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserConfigTests.swift; sourceTree = "<group>"; };
427C181B2BD314B500955B98 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = "<group>"; };
427C181F2BD31C3D00955B98 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
73192AF63CAF425397D7C0D2 /* URLSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSchemeHandler.swift; sourceTree = "<group>"; };
427C18242BD31E2E00955B98 /* GeneralPane.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneralPane.swift; sourceTree = "<group>"; };
427C18252BD31E2E00955B98 /* MainWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainWindow.swift; sourceTree = "<group>"; };
427C18262BD31E2E00955B98 /* StatusItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusItem.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -182,6 +186,7 @@
children = (
42B21FBB2D67566100F4A2C7 /* Alerts.swift */,
427C181F2BD31C3D00955B98 /* AppDelegate.swift */,
73192AF63CAF425397D7C0D2 /* URLSchemeHandler.swift */,
42F4CDCE2D46E2B300D0DD76 /* Cheatsheet.swift */,
426E625A2D2E6A98009FD2F2 /* CommandRunner.swift */,
42454DDA2D71CB39004E1374 /* ConfigValidator.swift */,
Expand Down Expand Up @@ -221,6 +226,7 @@
children = (
4284834B2E813212009D7EEF /* KeyboardLayoutTests.swift */,
42454DDC2D71CBAB004E1374 /* ConfigValidatorTests.swift */,
EC5CEBC4C47B4C5DB2258814 /* URLSchemeTests.swift */,
427C17FC2BD311B500955B98 /* UserConfigTests.swift */,
);
path = "Leader KeyTests";
Expand Down Expand Up @@ -431,6 +437,7 @@
42F4CDD12D48C52400D0DD76 /* Extensions.swift in Sources */,
427C182F2BD3206200955B98 /* UserState.swift in Sources */,
427C18202BD31C3D00955B98 /* AppDelegate.swift in Sources */,
73192AF63CAF425397D7C0D1 /* URLSchemeHandler.swift in Sources */,
427C18502BD6652500955B98 /* Util.swift in Sources */,
427C182A2BD31E2E00955B98 /* StatusItem.swift in Sources */,
42F4CDCD2D45B13600D0DD76 /* KeyButton.swift in Sources */,
Expand All @@ -444,6 +451,7 @@
buildActionMask = 2147483647;
files = (
42454DDD2D71CBAB004E1374 /* ConfigValidatorTests.swift in Sources */,
EC5CEBC4C47B4C5DB2258813 /* URLSchemeTests.swift in Sources */,
427C17FD2BD311B500955B98 /* UserConfigTests.swift in Sources */,
4284834C2E813212009D7EEF /* KeyboardLayoutTests.swift in Sources */,
);
Expand Down
42 changes: 23 additions & 19 deletions Leader Key/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -218,41 +218,45 @@ class AppDelegate: NSObject, NSApplicationDelegate,
}

private func handleURL(_ url: URL) {
guard url.scheme == "leaderkey" else { return }
let action = URLSchemeHandler.parse(url)

if url.host == "settings" {
switch action {
case .settings:
showSettings()
return
}
if url.host == "about" {
case .about:
NSApp.orderFrontStandardAboutPanel(nil)
case .configReload:
config.reloadFromFile()
case .configReveal:
NSWorkspace.shared.selectFile(config.path, inFileViewerRootedAtPath: "")
case .activate:
activate()
case .hide:
hide()
case .reset:
state.clear()
case .navigate(let keys, let execute):
show()
processKeys(keys, execute: execute)
case .show:
show()
case .invalid:
return
}

show()

if url.host == "navigate",
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let queryItems = components.queryItems,
let keysParam = queryItems.first(where: { $0.name == "keys" })?.value
{
let keys = keysParam.split(separator: ",").map(String.init)
processKeys(keys)
}
}

private func processKeys(_ keys: [String]) {
private func processKeys(_ keys: [String], execute: Bool = true) {
guard !keys.isEmpty else { return }

controller.handleKey(keys[0])
controller.handleKey(keys[0], execute: execute)

if keys.count > 1 {
let remainingKeys = Array(keys.dropFirst())

var delayMs = 100
for key in remainingKeys {
delay(delayMs) { [weak self] in
self?.controller.handleKey(key)
self?.controller.handleKey(key, execute: execute)
}
delayMs += 100
}
Expand Down
17 changes: 10 additions & 7 deletions Leader Key/Controller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ class Controller {
}
}

func handleKey(_ key: String, withModifiers modifiers: NSEvent.ModifierFlags? = nil) {
func handleKey(_ key: String, withModifiers modifiers: NSEvent.ModifierFlags? = nil, execute: Bool = true) {
if key == "?" {
showCheatsheet()
return
Expand Down Expand Up @@ -159,15 +159,18 @@ class Controller {

switch hit {
case .action(let action):
if let mods = modifiers, isInStickyMode(mods) {
runAction(action)
} else {
hide {
self.runAction(action)
if execute {
if let mods = modifiers, isInStickyMode(mods) {
runAction(action)
} else {
hide {
self.runAction(action)
}
}
}
// If execute is false, just stay visible showing the matched action
case .group(let group):
if let mods = modifiers, shouldRunGroupSequenceWithModifiers(mods) {
if execute, let mods = modifiers, shouldRunGroupSequenceWithModifiers(mods) {
hide {
self.runGroup(group)
}
Expand Down
51 changes: 51 additions & 0 deletions Leader Key/URLSchemeHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Foundation

enum URLSchemeAction: Equatable {
case settings
case about
case configReload
case configReveal
case activate
case hide
case reset
case navigate(keys: [String], execute: Bool)
case show // Fallback for unknown hosts
case invalid // Invalid scheme
}

class URLSchemeHandler {
static func parse(_ url: URL) -> URLSchemeAction {
guard url.scheme == "leaderkey" else {
return .invalid
}

switch url.host {
case "settings":
return .settings
case "about":
return .about
case "config-reload":
return .configReload
case "config-reveal":
return .configReveal
case "activate":
return .activate
case "hide":
return .hide
case "reset":
return .reset
case "navigate":
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let queryItems = components.queryItems,
let keysParam = queryItems.first(where: { $0.name == "keys" })?.value
else {
return .show
}
let keys = keysParam.split(separator: ",").map(String.init)
let execute = queryItems.first(where: { $0.name == "execute" })?.value != "false"
return .navigate(keys: keys, execute: execute)
default:
return .show
}
}
}
99 changes: 99 additions & 0 deletions Leader KeyTests/URLSchemeTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import XCTest
@testable import Leader_Key

final class URLSchemeTests: XCTestCase {

// MARK: - Configuration Management Tests

func testConfigReloadURL() {
let url = URL(string: "leaderkey://config-reload")!
let action = URLSchemeHandler.parse(url)
XCTAssertEqual(action, .configReload)
}

func testConfigRevealURL() {
let url = URL(string: "leaderkey://config-reveal")!
let action = URLSchemeHandler.parse(url)
XCTAssertEqual(action, .configReveal)
}

// MARK: - Window Control Tests

func testActivateURL() {
let url = URL(string: "leaderkey://activate")!
let action = URLSchemeHandler.parse(url)
XCTAssertEqual(action, .activate)
}

func testHideURL() {
let url = URL(string: "leaderkey://hide")!
let action = URLSchemeHandler.parse(url)
XCTAssertEqual(action, .hide)
}

func testResetURL() {
let url = URL(string: "leaderkey://reset")!
let action = URLSchemeHandler.parse(url)
XCTAssertEqual(action, .reset)
}

// MARK: - Settings & Info Tests

func testSettingsURL() {
let url = URL(string: "leaderkey://settings")!
let action = URLSchemeHandler.parse(url)
XCTAssertEqual(action, .settings)
}

func testAboutURL() {
let url = URL(string: "leaderkey://about")!
let action = URLSchemeHandler.parse(url)
XCTAssertEqual(action, .about)
}

// MARK: - Navigation Tests

func testNavigateWithKeys() {
let url = URL(string: "leaderkey://navigate?keys=a,b,c")!
let action = URLSchemeHandler.parse(url)
XCTAssertEqual(action, .navigate(keys: ["a", "b", "c"], execute: true))
}

func testNavigateWithExecuteFalse() {
let url = URL(string: "leaderkey://navigate?keys=a,b&execute=false")!
let action = URLSchemeHandler.parse(url)
XCTAssertEqual(action, .navigate(keys: ["a", "b"], execute: false))
}

func testNavigateWithExecuteTrue() {
let url = URL(string: "leaderkey://navigate?keys=x,y&execute=true")!
let action = URLSchemeHandler.parse(url)
XCTAssertEqual(action, .navigate(keys: ["x", "y"], execute: true))
}

func testNavigateWithSingleKey() {
let url = URL(string: "leaderkey://navigate?keys=z")!
let action = URLSchemeHandler.parse(url)
XCTAssertEqual(action, .navigate(keys: ["z"], execute: true))
}

func testNavigateWithoutKeys() {
let url = URL(string: "leaderkey://navigate")!
let action = URLSchemeHandler.parse(url)
XCTAssertEqual(action, .show)
}

// MARK: - Invalid URL Tests

func testInvalidScheme() {
let url = URL(string: "invalid://settings")!
let action = URLSchemeHandler.parse(url)
XCTAssertEqual(action, .invalid)
}

func testUnknownHost() {
let url = URL(string: "leaderkey://unknown")!
let action = URLSchemeHandler.parse(url)
XCTAssertEqual(action, .show)
}
}
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,58 @@ $ brew install leader-key
- <kbd>leader</kbd><kbd>m</kbd><kbd>m</kbd> → Mute audio (`media mute`)
- <kbd>leader</kbd><kbd>w</kbd><kbd>m</kbd> → Maximize current window (`window maximize`)

## URL Scheme

Leader Key supports URL scheme automation for integration with tools like Alfred, Raycast, shell scripts, and more.

### Available URL Schemes

#### Configuration Management
```bash
# Reload configuration from disk
open "leaderkey://config-reload"

# Show config.json in Finder
open "leaderkey://config-reveal"
```

#### Window Control
```bash
# Show Leader Key window
open "leaderkey://activate"

# Hide Leader Key window
open "leaderkey://hide"

# Clear navigation state (return to root)
open "leaderkey://reset"
```

#### Settings & Info
```bash
# Open settings window
open "leaderkey://settings"

# Show about dialog
open "leaderkey://about"
```

#### Navigation
```bash
# Navigate through keys and execute actions
open "leaderkey://navigate?keys=a,b,c"

# Navigate without executing (preview mode)
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
- **Keyboard maestro**: Chain Leader Key actions with other automations
- **External triggers**: Open specific action sequences from other applications

## FAQ

#### What do I set as my Leader Key?
Expand Down