Skip to content

Commit 22f135b

Browse files
authored
Add URL scheme support for automation and external integration (#286)
1 parent 2db04d8 commit 22f135b

6 files changed

Lines changed: 243 additions & 26 deletions

File tree

Leader Key.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
423632282D6A806700878D92 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 423632272D6A806700878D92 /* Theme.swift */; };
1616
42454DDB2D71CB39004E1374 /* ConfigValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42454DDA2D71CB39004E1374 /* ConfigValidator.swift */; };
1717
42454DDD2D71CBAB004E1374 /* ConfigValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42454DDC2D71CBAB004E1374 /* ConfigValidatorTests.swift */; };
18+
EC5CEBC4C47B4C5DB2258813 /* URLSchemeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC5CEBC4C47B4C5DB2258814 /* URLSchemeTests.swift */; };
1819
425495402D75EFAD0020300E /* ForTheHorde.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4254953F2D75EFAD0020300E /* ForTheHorde.swift */; };
1920
426E625B2D2E6A98009FD2F2 /* CommandRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 426E625A2D2E6A98009FD2F2 /* CommandRunner.swift */; };
2021
4279AFED2C6A175500952A83 /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = 4279AFEC2C6A175500952A83 /* LaunchAtLogin */; };
@@ -25,6 +26,7 @@
2526
427C181A2BD3123C00955B98 /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = 427C18192BD3123C00955B98 /* Defaults */; };
2627
427C181C2BD314B500955B98 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C181B2BD314B500955B98 /* Constants.swift */; };
2728
427C18202BD31C3D00955B98 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C181F2BD31C3D00955B98 /* AppDelegate.swift */; };
29+
73192AF63CAF425397D7C0D1 /* URLSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73192AF63CAF425397D7C0D2 /* URLSchemeHandler.swift */; };
2830
427C18232BD31DF100955B98 /* Settings in Frameworks */ = {isa = PBXBuildFile; productRef = 427C18222BD31DF100955B98 /* Settings */; };
2931
427C18282BD31E2E00955B98 /* GeneralPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C18242BD31E2E00955B98 /* GeneralPane.swift */; };
3032
427C18292BD31E2E00955B98 /* MainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C18252BD31E2E00955B98 /* MainWindow.swift */; };
@@ -74,6 +76,7 @@
7476
423632272D6A806700878D92 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
7577
42454DDA2D71CB39004E1374 /* ConfigValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigValidator.swift; sourceTree = "<group>"; };
7678
42454DDC2D71CBAB004E1374 /* ConfigValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigValidatorTests.swift; sourceTree = "<group>"; };
79+
EC5CEBC4C47B4C5DB2258814 /* URLSchemeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSchemeTests.swift; sourceTree = "<group>"; };
7780
4254953F2D75EFAD0020300E /* ForTheHorde.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ForTheHorde.swift; sourceTree = "<group>"; };
7881
426E625A2D2E6A98009FD2F2 /* CommandRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandRunner.swift; sourceTree = "<group>"; };
7982
4279AFEA2C6A08B100952A83 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = "Leader Key/Support/Info.plist"; sourceTree = SOURCE_ROOT; };
@@ -85,6 +88,7 @@
8588
427C17FC2BD311B500955B98 /* UserConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserConfigTests.swift; sourceTree = "<group>"; };
8689
427C181B2BD314B500955B98 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = "<group>"; };
8790
427C181F2BD31C3D00955B98 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
91+
73192AF63CAF425397D7C0D2 /* URLSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSchemeHandler.swift; sourceTree = "<group>"; };
8892
427C18242BD31E2E00955B98 /* GeneralPane.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneralPane.swift; sourceTree = "<group>"; };
8993
427C18252BD31E2E00955B98 /* MainWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainWindow.swift; sourceTree = "<group>"; };
9094
427C18262BD31E2E00955B98 /* StatusItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusItem.swift; sourceTree = "<group>"; };
@@ -182,6 +186,7 @@
182186
children = (
183187
42B21FBB2D67566100F4A2C7 /* Alerts.swift */,
184188
427C181F2BD31C3D00955B98 /* AppDelegate.swift */,
189+
73192AF63CAF425397D7C0D2 /* URLSchemeHandler.swift */,
185190
42F4CDCE2D46E2B300D0DD76 /* Cheatsheet.swift */,
186191
426E625A2D2E6A98009FD2F2 /* CommandRunner.swift */,
187192
42454DDA2D71CB39004E1374 /* ConfigValidator.swift */,
@@ -221,6 +226,7 @@
221226
children = (
222227
4284834B2E813212009D7EEF /* KeyboardLayoutTests.swift */,
223228
42454DDC2D71CBAB004E1374 /* ConfigValidatorTests.swift */,
229+
EC5CEBC4C47B4C5DB2258814 /* URLSchemeTests.swift */,
224230
427C17FC2BD311B500955B98 /* UserConfigTests.swift */,
225231
);
226232
path = "Leader KeyTests";
@@ -431,6 +437,7 @@
431437
42F4CDD12D48C52400D0DD76 /* Extensions.swift in Sources */,
432438
427C182F2BD3206200955B98 /* UserState.swift in Sources */,
433439
427C18202BD31C3D00955B98 /* AppDelegate.swift in Sources */,
440+
73192AF63CAF425397D7C0D1 /* URLSchemeHandler.swift in Sources */,
434441
427C18502BD6652500955B98 /* Util.swift in Sources */,
435442
427C182A2BD31E2E00955B98 /* StatusItem.swift in Sources */,
436443
42F4CDCD2D45B13600D0DD76 /* KeyButton.swift in Sources */,
@@ -444,6 +451,7 @@
444451
buildActionMask = 2147483647;
445452
files = (
446453
42454DDD2D71CBAB004E1374 /* ConfigValidatorTests.swift in Sources */,
454+
EC5CEBC4C47B4C5DB2258813 /* URLSchemeTests.swift in Sources */,
447455
427C17FD2BD311B500955B98 /* UserConfigTests.swift in Sources */,
448456
4284834C2E813212009D7EEF /* KeyboardLayoutTests.swift in Sources */,
449457
);

Leader Key/AppDelegate.swift

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -218,41 +218,45 @@ class AppDelegate: NSObject, NSApplicationDelegate,
218218
}
219219

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

223-
if url.host == "settings" {
223+
switch action {
224+
case .settings:
224225
showSettings()
225-
return
226-
}
227-
if url.host == "about" {
226+
case .about:
228227
NSApp.orderFrontStandardAboutPanel(nil)
228+
case .configReload:
229+
config.reloadFromFile()
230+
case .configReveal:
231+
NSWorkspace.shared.selectFile(config.path, inFileViewerRootedAtPath: "")
232+
case .activate:
233+
activate()
234+
case .hide:
235+
hide()
236+
case .reset:
237+
state.clear()
238+
case .navigate(let keys, let execute):
239+
show()
240+
processKeys(keys, execute: execute)
241+
case .show:
242+
show()
243+
case .invalid:
229244
return
230245
}
231-
232-
show()
233-
234-
if url.host == "navigate",
235-
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
236-
let queryItems = components.queryItems,
237-
let keysParam = queryItems.first(where: { $0.name == "keys" })?.value
238-
{
239-
let keys = keysParam.split(separator: ",").map(String.init)
240-
processKeys(keys)
241-
}
242246
}
243247

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

247-
controller.handleKey(keys[0])
251+
controller.handleKey(keys[0], execute: execute)
248252

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

252256
var delayMs = 100
253257
for key in remainingKeys {
254258
delay(delayMs) { [weak self] in
255-
self?.controller.handleKey(key)
259+
self?.controller.handleKey(key, execute: execute)
256260
}
257261
delayMs += 100
258262
}

Leader Key/Controller.swift

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ class Controller {
127127
}
128128
}
129129

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

160160
switch hit {
161161
case .action(let action):
162-
if let mods = modifiers, isInStickyMode(mods) {
163-
runAction(action)
164-
} else {
165-
hide {
166-
self.runAction(action)
162+
if execute {
163+
if let mods = modifiers, isInStickyMode(mods) {
164+
runAction(action)
165+
} else {
166+
hide {
167+
self.runAction(action)
168+
}
167169
}
168170
}
171+
// If execute is false, just stay visible showing the matched action
169172
case .group(let group):
170-
if let mods = modifiers, shouldRunGroupSequenceWithModifiers(mods) {
173+
if execute, let mods = modifiers, shouldRunGroupSequenceWithModifiers(mods) {
171174
hide {
172175
self.runGroup(group)
173176
}

Leader Key/URLSchemeHandler.swift

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import Foundation
2+
3+
enum URLSchemeAction: Equatable {
4+
case settings
5+
case about
6+
case configReload
7+
case configReveal
8+
case activate
9+
case hide
10+
case reset
11+
case navigate(keys: [String], execute: Bool)
12+
case show // Fallback for unknown hosts
13+
case invalid // Invalid scheme
14+
}
15+
16+
class URLSchemeHandler {
17+
static func parse(_ url: URL) -> URLSchemeAction {
18+
guard url.scheme == "leaderkey" else {
19+
return .invalid
20+
}
21+
22+
switch url.host {
23+
case "settings":
24+
return .settings
25+
case "about":
26+
return .about
27+
case "config-reload":
28+
return .configReload
29+
case "config-reveal":
30+
return .configReveal
31+
case "activate":
32+
return .activate
33+
case "hide":
34+
return .hide
35+
case "reset":
36+
return .reset
37+
case "navigate":
38+
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
39+
let queryItems = components.queryItems,
40+
let keysParam = queryItems.first(where: { $0.name == "keys" })?.value
41+
else {
42+
return .show
43+
}
44+
let keys = keysParam.split(separator: ",").map(String.init)
45+
let execute = queryItems.first(where: { $0.name == "execute" })?.value != "false"
46+
return .navigate(keys: keys, execute: execute)
47+
default:
48+
return .show
49+
}
50+
}
51+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import XCTest
2+
@testable import Leader_Key
3+
4+
final class URLSchemeTests: XCTestCase {
5+
6+
// MARK: - Configuration Management Tests
7+
8+
func testConfigReloadURL() {
9+
let url = URL(string: "leaderkey://config-reload")!
10+
let action = URLSchemeHandler.parse(url)
11+
XCTAssertEqual(action, .configReload)
12+
}
13+
14+
func testConfigRevealURL() {
15+
let url = URL(string: "leaderkey://config-reveal")!
16+
let action = URLSchemeHandler.parse(url)
17+
XCTAssertEqual(action, .configReveal)
18+
}
19+
20+
// MARK: - Window Control Tests
21+
22+
func testActivateURL() {
23+
let url = URL(string: "leaderkey://activate")!
24+
let action = URLSchemeHandler.parse(url)
25+
XCTAssertEqual(action, .activate)
26+
}
27+
28+
func testHideURL() {
29+
let url = URL(string: "leaderkey://hide")!
30+
let action = URLSchemeHandler.parse(url)
31+
XCTAssertEqual(action, .hide)
32+
}
33+
34+
func testResetURL() {
35+
let url = URL(string: "leaderkey://reset")!
36+
let action = URLSchemeHandler.parse(url)
37+
XCTAssertEqual(action, .reset)
38+
}
39+
40+
// MARK: - Settings & Info Tests
41+
42+
func testSettingsURL() {
43+
let url = URL(string: "leaderkey://settings")!
44+
let action = URLSchemeHandler.parse(url)
45+
XCTAssertEqual(action, .settings)
46+
}
47+
48+
func testAboutURL() {
49+
let url = URL(string: "leaderkey://about")!
50+
let action = URLSchemeHandler.parse(url)
51+
XCTAssertEqual(action, .about)
52+
}
53+
54+
// MARK: - Navigation Tests
55+
56+
func testNavigateWithKeys() {
57+
let url = URL(string: "leaderkey://navigate?keys=a,b,c")!
58+
let action = URLSchemeHandler.parse(url)
59+
XCTAssertEqual(action, .navigate(keys: ["a", "b", "c"], execute: true))
60+
}
61+
62+
func testNavigateWithExecuteFalse() {
63+
let url = URL(string: "leaderkey://navigate?keys=a,b&execute=false")!
64+
let action = URLSchemeHandler.parse(url)
65+
XCTAssertEqual(action, .navigate(keys: ["a", "b"], execute: false))
66+
}
67+
68+
func testNavigateWithExecuteTrue() {
69+
let url = URL(string: "leaderkey://navigate?keys=x,y&execute=true")!
70+
let action = URLSchemeHandler.parse(url)
71+
XCTAssertEqual(action, .navigate(keys: ["x", "y"], execute: true))
72+
}
73+
74+
func testNavigateWithSingleKey() {
75+
let url = URL(string: "leaderkey://navigate?keys=z")!
76+
let action = URLSchemeHandler.parse(url)
77+
XCTAssertEqual(action, .navigate(keys: ["z"], execute: true))
78+
}
79+
80+
func testNavigateWithoutKeys() {
81+
let url = URL(string: "leaderkey://navigate")!
82+
let action = URLSchemeHandler.parse(url)
83+
XCTAssertEqual(action, .show)
84+
}
85+
86+
// MARK: - Invalid URL Tests
87+
88+
func testInvalidScheme() {
89+
let url = URL(string: "invalid://settings")!
90+
let action = URLSchemeHandler.parse(url)
91+
XCTAssertEqual(action, .invalid)
92+
}
93+
94+
func testUnknownHost() {
95+
let url = URL(string: "leaderkey://unknown")!
96+
let action = URLSchemeHandler.parse(url)
97+
XCTAssertEqual(action, .show)
98+
}
99+
}

README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,58 @@ $ brew install leader-key
4747
- <kbd>leader</kbd><kbd>m</kbd><kbd>m</kbd> → Mute audio (`media mute`)
4848
- <kbd>leader</kbd><kbd>w</kbd><kbd>m</kbd> → Maximize current window (`window maximize`)
4949

50+
## URL Scheme
51+
52+
Leader Key supports URL scheme automation for integration with tools like Alfred, Raycast, shell scripts, and more.
53+
54+
### Available URL Schemes
55+
56+
#### Configuration Management
57+
```bash
58+
# Reload configuration from disk
59+
open "leaderkey://config-reload"
60+
61+
# Show config.json in Finder
62+
open "leaderkey://config-reveal"
63+
```
64+
65+
#### Window Control
66+
```bash
67+
# Show Leader Key window
68+
open "leaderkey://activate"
69+
70+
# Hide Leader Key window
71+
open "leaderkey://hide"
72+
73+
# Clear navigation state (return to root)
74+
open "leaderkey://reset"
75+
```
76+
77+
#### Settings & Info
78+
```bash
79+
# Open settings window
80+
open "leaderkey://settings"
81+
82+
# Show about dialog
83+
open "leaderkey://about"
84+
```
85+
86+
#### Navigation
87+
```bash
88+
# Navigate through keys and execute actions
89+
open "leaderkey://navigate?keys=a,b,c"
90+
91+
# Navigate without executing (preview mode)
92+
open "leaderkey://navigate?keys=a,b,c&execute=false"
93+
```
94+
95+
### Example Use Cases
96+
97+
- **Alfred/Raycast workflows**: Trigger Leader Key shortcuts programmatically
98+
- **Shell scripts**: Automate configuration reloads after editing config.json
99+
- **Keyboard maestro**: Chain Leader Key actions with other automations
100+
- **External triggers**: Open specific action sequences from other applications
101+
50102
## FAQ
51103

52104
#### What do I set as my Leader Key?

0 commit comments

Comments
 (0)