diff --git a/Leader Key.xcodeproj/project.pbxproj b/Leader Key.xcodeproj/project.pbxproj index ff46d55e..141cebf6 100644 --- a/Leader Key.xcodeproj/project.pbxproj +++ b/Leader Key.xcodeproj/project.pbxproj @@ -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 */; }; @@ -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 */; }; @@ -74,6 +76,7 @@ 423632272D6A806700878D92 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; 42454DDA2D71CB39004E1374 /* ConfigValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigValidator.swift; sourceTree = ""; }; 42454DDC2D71CBAB004E1374 /* ConfigValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigValidatorTests.swift; sourceTree = ""; }; + EC5CEBC4C47B4C5DB2258814 /* URLSchemeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSchemeTests.swift; sourceTree = ""; }; 4254953F2D75EFAD0020300E /* ForTheHorde.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ForTheHorde.swift; sourceTree = ""; }; 426E625A2D2E6A98009FD2F2 /* CommandRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandRunner.swift; sourceTree = ""; }; 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 @@ 427C17FC2BD311B500955B98 /* UserConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserConfigTests.swift; sourceTree = ""; }; 427C181B2BD314B500955B98 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 427C181F2BD31C3D00955B98 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 73192AF63CAF425397D7C0D2 /* URLSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSchemeHandler.swift; sourceTree = ""; }; 427C18242BD31E2E00955B98 /* GeneralPane.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneralPane.swift; sourceTree = ""; }; 427C18252BD31E2E00955B98 /* MainWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainWindow.swift; sourceTree = ""; }; 427C18262BD31E2E00955B98 /* StatusItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusItem.swift; sourceTree = ""; }; @@ -182,6 +186,7 @@ children = ( 42B21FBB2D67566100F4A2C7 /* Alerts.swift */, 427C181F2BD31C3D00955B98 /* AppDelegate.swift */, + 73192AF63CAF425397D7C0D2 /* URLSchemeHandler.swift */, 42F4CDCE2D46E2B300D0DD76 /* Cheatsheet.swift */, 426E625A2D2E6A98009FD2F2 /* CommandRunner.swift */, 42454DDA2D71CB39004E1374 /* ConfigValidator.swift */, @@ -221,6 +226,7 @@ children = ( 4284834B2E813212009D7EEF /* KeyboardLayoutTests.swift */, 42454DDC2D71CBAB004E1374 /* ConfigValidatorTests.swift */, + EC5CEBC4C47B4C5DB2258814 /* URLSchemeTests.swift */, 427C17FC2BD311B500955B98 /* UserConfigTests.swift */, ); path = "Leader KeyTests"; @@ -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 */, @@ -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 */, ); diff --git a/Leader Key/AppDelegate.swift b/Leader Key/AppDelegate.swift index 68190e69..9ecc0318 100644 --- a/Leader Key/AppDelegate.swift +++ b/Leader Key/AppDelegate.swift @@ -218,33 +218,37 @@ 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()) @@ -252,7 +256,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, var delayMs = 100 for key in remainingKeys { delay(delayMs) { [weak self] in - self?.controller.handleKey(key) + self?.controller.handleKey(key, execute: execute) } delayMs += 100 } diff --git a/Leader Key/Controller.swift b/Leader Key/Controller.swift index a6285373..37c0eecd 100644 --- a/Leader Key/Controller.swift +++ b/Leader Key/Controller.swift @@ -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 @@ -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) } diff --git a/Leader Key/URLSchemeHandler.swift b/Leader Key/URLSchemeHandler.swift new file mode 100644 index 00000000..fc1d8522 --- /dev/null +++ b/Leader Key/URLSchemeHandler.swift @@ -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 + } + } +} diff --git a/Leader KeyTests/URLSchemeTests.swift b/Leader KeyTests/URLSchemeTests.swift new file mode 100644 index 00000000..93ae3ce9 --- /dev/null +++ b/Leader KeyTests/URLSchemeTests.swift @@ -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) + } +} diff --git a/README.md b/README.md index 7603b5f8..33588728 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,58 @@ $ brew install leader-key - leadermm → Mute audio (`media mute`) - leaderwm → 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?