From 97aff8c5df7e6c567c5ede156876ff6dc797efcc Mon Sep 17 00:00:00 2001 From: "S. Zachariah Sprackett" Date: Sun, 16 Nov 2025 10:55:26 -0800 Subject: [PATCH 1/3] Add URL scheme support for automation and external integration Implements leaderkey:// URL handlers for: - Configuration management (config-reload, config-reveal) - Window control (activate, hide, reset) - Navigation with execute parameter - Settings and about dialogs --- Leader Key/AppDelegate.swift | 29 +++++++++++++++++--- Leader Key/Controller.swift | 17 +++++++----- README.md | 52 ++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 11 deletions(-) diff --git a/Leader Key/AppDelegate.swift b/Leader Key/AppDelegate.swift index 68190e69..6ba69cb0 100644 --- a/Leader Key/AppDelegate.swift +++ b/Leader Key/AppDelegate.swift @@ -228,6 +228,26 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSApp.orderFrontStandardAboutPanel(nil) return } + if url.host == "config-reload" { + config.reloadFromFile() + return + } + if url.host == "config-reveal" { + NSWorkspace.shared.selectFile(config.path, inFileViewerRootedAtPath: "") + return + } + if url.host == "activate" { + activate() + return + } + if url.host == "hide" { + hide() + return + } + if url.host == "reset" { + state.clear() + return + } show() @@ -237,14 +257,15 @@ class AppDelegate: NSObject, NSApplicationDelegate, let keysParam = queryItems.first(where: { $0.name == "keys" })?.value { let keys = keysParam.split(separator: ",").map(String.init) - processKeys(keys) + let shouldExecute = queryItems.first(where: { $0.name == "execute" })?.value != "false" + processKeys(keys, execute: shouldExecute) } } - 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 +273,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/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? From 8d81169e1876858348c57d8370108d056cbf325f Mon Sep 17 00:00:00 2001 From: "S. Zachariah Sprackett" Date: Sun, 16 Nov 2025 12:58:17 -0800 Subject: [PATCH 2/3] Add requested URLSchemeTests --- Leader Key.xcodeproj/project.pbxproj | 4 + Leader KeyTests/URLSchemeTests.swift | 225 +++++++++++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 Leader KeyTests/URLSchemeTests.swift diff --git a/Leader Key.xcodeproj/project.pbxproj b/Leader Key.xcodeproj/project.pbxproj index ff46d55e..76592376 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 */; }; @@ -74,6 +75,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; }; @@ -221,6 +223,7 @@ children = ( 4284834B2E813212009D7EEF /* KeyboardLayoutTests.swift */, 42454DDC2D71CBAB004E1374 /* ConfigValidatorTests.swift */, + EC5CEBC4C47B4C5DB2258814 /* URLSchemeTests.swift */, 427C17FC2BD311B500955B98 /* UserConfigTests.swift */, ); path = "Leader KeyTests"; @@ -444,6 +447,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 KeyTests/URLSchemeTests.swift b/Leader KeyTests/URLSchemeTests.swift new file mode 100644 index 00000000..87d68a5e --- /dev/null +++ b/Leader KeyTests/URLSchemeTests.swift @@ -0,0 +1,225 @@ +import XCTest +@testable import Leader_Key + +final class URLSchemeTests: XCTestCase { + var mockAppDelegate: MockAppDelegate! + + override func setUp() { + super.setUp() + mockAppDelegate = MockAppDelegate() + } + + override func tearDown() { + mockAppDelegate = nil + super.tearDown() + } + + // MARK: - Configuration Management Tests + + func testConfigReloadURL() { + let url = URL(string: "leaderkey://config-reload")! + mockAppDelegate.application(NSApplication.shared, open: [url]) + + XCTAssertTrue(mockAppDelegate.configReloadCalled) + XCTAssertFalse(mockAppDelegate.settingsCalled) + } + + func testConfigRevealURL() { + let url = URL(string: "leaderkey://config-reveal")! + mockAppDelegate.application(NSApplication.shared, open: [url]) + + XCTAssertTrue(mockAppDelegate.configRevealCalled) + XCTAssertFalse(mockAppDelegate.settingsCalled) + } + + // MARK: - Window Control Tests + + func testActivateURL() { + let url = URL(string: "leaderkey://activate")! + mockAppDelegate.application(NSApplication.shared, open: [url]) + + XCTAssertTrue(mockAppDelegate.activateCalled) + XCTAssertFalse(mockAppDelegate.hideCalled) + } + + func testHideURL() { + let url = URL(string: "leaderkey://hide")! + mockAppDelegate.application(NSApplication.shared, open: [url]) + + XCTAssertTrue(mockAppDelegate.hideCalled) + XCTAssertFalse(mockAppDelegate.activateCalled) + } + + func testResetURL() { + let url = URL(string: "leaderkey://reset")! + mockAppDelegate.application(NSApplication.shared, open: [url]) + + XCTAssertTrue(mockAppDelegate.resetCalled) + } + + // MARK: - Settings & Info Tests + + func testSettingsURL() { + let url = URL(string: "leaderkey://settings")! + mockAppDelegate.application(NSApplication.shared, open: [url]) + + XCTAssertTrue(mockAppDelegate.settingsCalled) + } + + func testAboutURL() { + let url = URL(string: "leaderkey://about")! + mockAppDelegate.application(NSApplication.shared, open: [url]) + + XCTAssertTrue(mockAppDelegate.aboutCalled) + } + + // MARK: - Navigation Tests + + func testNavigateWithKeys() { + let url = URL(string: "leaderkey://navigate?keys=a,b,c")! + mockAppDelegate.application(NSApplication.shared, open: [url]) + + XCTAssertTrue(mockAppDelegate.navigateCalled) + XCTAssertEqual(mockAppDelegate.lastNavigateKeys, ["a", "b", "c"]) + XCTAssertTrue(mockAppDelegate.lastNavigateExecute) + } + + func testNavigateWithExecuteFalse() { + let url = URL(string: "leaderkey://navigate?keys=a,b&execute=false")! + mockAppDelegate.application(NSApplication.shared, open: [url]) + + XCTAssertTrue(mockAppDelegate.navigateCalled) + XCTAssertEqual(mockAppDelegate.lastNavigateKeys, ["a", "b"]) + XCTAssertFalse(mockAppDelegate.lastNavigateExecute) + } + + func testNavigateWithExecuteTrue() { + let url = URL(string: "leaderkey://navigate?keys=x,y&execute=true")! + mockAppDelegate.application(NSApplication.shared, open: [url]) + + XCTAssertTrue(mockAppDelegate.navigateCalled) + XCTAssertEqual(mockAppDelegate.lastNavigateKeys, ["x", "y"]) + XCTAssertTrue(mockAppDelegate.lastNavigateExecute) + } + + func testNavigateWithSingleKey() { + let url = URL(string: "leaderkey://navigate?keys=z")! + mockAppDelegate.application(NSApplication.shared, open: [url]) + + XCTAssertTrue(mockAppDelegate.navigateCalled) + XCTAssertEqual(mockAppDelegate.lastNavigateKeys, ["z"]) + XCTAssertTrue(mockAppDelegate.lastNavigateExecute) + } + + // MARK: - Invalid URL Tests + + func testInvalidScheme() { + let url = URL(string: "invalid://settings")! + mockAppDelegate.application(NSApplication.shared, open: [url]) + + XCTAssertFalse(mockAppDelegate.settingsCalled) + } + + func testUnknownHost() { + let url = URL(string: "leaderkey://unknown")! + mockAppDelegate.application(NSApplication.shared, open: [url]) + + // Should show the window but not call any specific handlers + XCTAssertTrue(mockAppDelegate.showCalled) + XCTAssertFalse(mockAppDelegate.settingsCalled) + XCTAssertFalse(mockAppDelegate.configReloadCalled) + } + + func testNavigateWithoutKeys() { + let url = URL(string: "leaderkey://navigate")! + mockAppDelegate.application(NSApplication.shared, open: [url]) + + // Should show window but not navigate + XCTAssertTrue(mockAppDelegate.showCalled) + XCTAssertFalse(mockAppDelegate.navigateCalled) + } + + // MARK: - Multiple URL Tests + + func testMultipleURLsProcessedInOrder() { + let urls = [ + URL(string: "leaderkey://config-reload")!, + URL(string: "leaderkey://settings")! + ] + mockAppDelegate.application(NSApplication.shared, open: urls) + + XCTAssertTrue(mockAppDelegate.configReloadCalled) + XCTAssertTrue(mockAppDelegate.settingsCalled) + } +} + +// MARK: - Mock AppDelegate + +class MockAppDelegate: NSObject { + var settingsCalled = false + var aboutCalled = false + var configReloadCalled = false + var configRevealCalled = false + var activateCalled = false + var hideCalled = false + var resetCalled = false + var navigateCalled = false + var showCalled = false + + var lastNavigateKeys: [String]? + var lastNavigateExecute: Bool = true + + func application(_ application: NSApplication, open urls: [URL]) { + for url in urls { + handleURL(url) + } + } + + private func handleURL(_ url: URL) { + guard url.scheme == "leaderkey" else { return } + + if url.host == "settings" { + settingsCalled = true + return + } + if url.host == "about" { + aboutCalled = true + return + } + if url.host == "config-reload" { + configReloadCalled = true + return + } + if url.host == "config-reveal" { + configRevealCalled = true + return + } + if url.host == "activate" { + activateCalled = true + return + } + if url.host == "hide" { + hideCalled = true + return + } + if url.host == "reset" { + resetCalled = true + return + } + + showCalled = true + + 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) + let shouldExecute = queryItems.first(where: { $0.name == "execute" })?.value != "false" + + navigateCalled = true + lastNavigateKeys = keys + lastNavigateExecute = shouldExecute + } + } +} From d13aef9d23b29f2c81f0b16518e8fa298faeea24 Mon Sep 17 00:00:00 2001 From: "S. Zachariah Sprackett" Date: Mon, 17 Nov 2025 16:40:15 -0800 Subject: [PATCH 3/3] Revise tests per feedback --- Leader Key.xcodeproj/project.pbxproj | 4 + Leader Key/AppDelegate.swift | 47 +++---- Leader Key/URLSchemeHandler.swift | 51 ++++++++ Leader KeyTests/URLSchemeTests.swift | 188 +++++---------------------- 4 files changed, 101 insertions(+), 189 deletions(-) create mode 100644 Leader Key/URLSchemeHandler.swift diff --git a/Leader Key.xcodeproj/project.pbxproj b/Leader Key.xcodeproj/project.pbxproj index 76592376..141cebf6 100644 --- a/Leader Key.xcodeproj/project.pbxproj +++ b/Leader Key.xcodeproj/project.pbxproj @@ -26,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 */; }; @@ -87,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 = ""; }; @@ -184,6 +186,7 @@ children = ( 42B21FBB2D67566100F4A2C7 /* Alerts.swift */, 427C181F2BD31C3D00955B98 /* AppDelegate.swift */, + 73192AF63CAF425397D7C0D2 /* URLSchemeHandler.swift */, 42F4CDCE2D46E2B300D0DD76 /* Cheatsheet.swift */, 426E625A2D2E6A98009FD2F2 /* CommandRunner.swift */, 42454DDA2D71CB39004E1374 /* ConfigValidator.swift */, @@ -434,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 */, diff --git a/Leader Key/AppDelegate.swift b/Leader Key/AppDelegate.swift index 6ba69cb0..9ecc0318 100644 --- a/Leader Key/AppDelegate.swift +++ b/Leader Key/AppDelegate.swift @@ -218,48 +218,31 @@ 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) - return - } - if url.host == "config-reload" { + case .configReload: config.reloadFromFile() - return - } - if url.host == "config-reveal" { + case .configReveal: NSWorkspace.shared.selectFile(config.path, inFileViewerRootedAtPath: "") - return - } - if url.host == "activate" { + case .activate: activate() - return - } - if url.host == "hide" { + case .hide: hide() - return - } - if url.host == "reset" { + 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) - let shouldExecute = queryItems.first(where: { $0.name == "execute" })?.value != "false" - processKeys(keys, execute: shouldExecute) - } } private func processKeys(_ keys: [String], execute: Bool = true) { 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 index 87d68a5e..93ae3ce9 100644 --- a/Leader KeyTests/URLSchemeTests.swift +++ b/Leader KeyTests/URLSchemeTests.swift @@ -2,224 +2,98 @@ import XCTest @testable import Leader_Key final class URLSchemeTests: XCTestCase { - var mockAppDelegate: MockAppDelegate! - - override func setUp() { - super.setUp() - mockAppDelegate = MockAppDelegate() - } - - override func tearDown() { - mockAppDelegate = nil - super.tearDown() - } // MARK: - Configuration Management Tests func testConfigReloadURL() { let url = URL(string: "leaderkey://config-reload")! - mockAppDelegate.application(NSApplication.shared, open: [url]) - - XCTAssertTrue(mockAppDelegate.configReloadCalled) - XCTAssertFalse(mockAppDelegate.settingsCalled) + let action = URLSchemeHandler.parse(url) + XCTAssertEqual(action, .configReload) } func testConfigRevealURL() { let url = URL(string: "leaderkey://config-reveal")! - mockAppDelegate.application(NSApplication.shared, open: [url]) - - XCTAssertTrue(mockAppDelegate.configRevealCalled) - XCTAssertFalse(mockAppDelegate.settingsCalled) + let action = URLSchemeHandler.parse(url) + XCTAssertEqual(action, .configReveal) } // MARK: - Window Control Tests func testActivateURL() { let url = URL(string: "leaderkey://activate")! - mockAppDelegate.application(NSApplication.shared, open: [url]) - - XCTAssertTrue(mockAppDelegate.activateCalled) - XCTAssertFalse(mockAppDelegate.hideCalled) + let action = URLSchemeHandler.parse(url) + XCTAssertEqual(action, .activate) } func testHideURL() { let url = URL(string: "leaderkey://hide")! - mockAppDelegate.application(NSApplication.shared, open: [url]) - - XCTAssertTrue(mockAppDelegate.hideCalled) - XCTAssertFalse(mockAppDelegate.activateCalled) + let action = URLSchemeHandler.parse(url) + XCTAssertEqual(action, .hide) } func testResetURL() { let url = URL(string: "leaderkey://reset")! - mockAppDelegate.application(NSApplication.shared, open: [url]) - - XCTAssertTrue(mockAppDelegate.resetCalled) + let action = URLSchemeHandler.parse(url) + XCTAssertEqual(action, .reset) } // MARK: - Settings & Info Tests func testSettingsURL() { let url = URL(string: "leaderkey://settings")! - mockAppDelegate.application(NSApplication.shared, open: [url]) - - XCTAssertTrue(mockAppDelegate.settingsCalled) + let action = URLSchemeHandler.parse(url) + XCTAssertEqual(action, .settings) } func testAboutURL() { let url = URL(string: "leaderkey://about")! - mockAppDelegate.application(NSApplication.shared, open: [url]) - - XCTAssertTrue(mockAppDelegate.aboutCalled) + let action = URLSchemeHandler.parse(url) + XCTAssertEqual(action, .about) } // MARK: - Navigation Tests func testNavigateWithKeys() { let url = URL(string: "leaderkey://navigate?keys=a,b,c")! - mockAppDelegate.application(NSApplication.shared, open: [url]) - - XCTAssertTrue(mockAppDelegate.navigateCalled) - XCTAssertEqual(mockAppDelegate.lastNavigateKeys, ["a", "b", "c"]) - XCTAssertTrue(mockAppDelegate.lastNavigateExecute) + 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")! - mockAppDelegate.application(NSApplication.shared, open: [url]) - - XCTAssertTrue(mockAppDelegate.navigateCalled) - XCTAssertEqual(mockAppDelegate.lastNavigateKeys, ["a", "b"]) - XCTAssertFalse(mockAppDelegate.lastNavigateExecute) + 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")! - mockAppDelegate.application(NSApplication.shared, open: [url]) - - XCTAssertTrue(mockAppDelegate.navigateCalled) - XCTAssertEqual(mockAppDelegate.lastNavigateKeys, ["x", "y"]) - XCTAssertTrue(mockAppDelegate.lastNavigateExecute) + let action = URLSchemeHandler.parse(url) + XCTAssertEqual(action, .navigate(keys: ["x", "y"], execute: true)) } func testNavigateWithSingleKey() { let url = URL(string: "leaderkey://navigate?keys=z")! - mockAppDelegate.application(NSApplication.shared, open: [url]) + let action = URLSchemeHandler.parse(url) + XCTAssertEqual(action, .navigate(keys: ["z"], execute: true)) + } - XCTAssertTrue(mockAppDelegate.navigateCalled) - XCTAssertEqual(mockAppDelegate.lastNavigateKeys, ["z"]) - XCTAssertTrue(mockAppDelegate.lastNavigateExecute) + 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")! - mockAppDelegate.application(NSApplication.shared, open: [url]) - - XCTAssertFalse(mockAppDelegate.settingsCalled) + let action = URLSchemeHandler.parse(url) + XCTAssertEqual(action, .invalid) } func testUnknownHost() { let url = URL(string: "leaderkey://unknown")! - mockAppDelegate.application(NSApplication.shared, open: [url]) - - // Should show the window but not call any specific handlers - XCTAssertTrue(mockAppDelegate.showCalled) - XCTAssertFalse(mockAppDelegate.settingsCalled) - XCTAssertFalse(mockAppDelegate.configReloadCalled) - } - - func testNavigateWithoutKeys() { - let url = URL(string: "leaderkey://navigate")! - mockAppDelegate.application(NSApplication.shared, open: [url]) - - // Should show window but not navigate - XCTAssertTrue(mockAppDelegate.showCalled) - XCTAssertFalse(mockAppDelegate.navigateCalled) - } - - // MARK: - Multiple URL Tests - - func testMultipleURLsProcessedInOrder() { - let urls = [ - URL(string: "leaderkey://config-reload")!, - URL(string: "leaderkey://settings")! - ] - mockAppDelegate.application(NSApplication.shared, open: urls) - - XCTAssertTrue(mockAppDelegate.configReloadCalled) - XCTAssertTrue(mockAppDelegate.settingsCalled) - } -} - -// MARK: - Mock AppDelegate - -class MockAppDelegate: NSObject { - var settingsCalled = false - var aboutCalled = false - var configReloadCalled = false - var configRevealCalled = false - var activateCalled = false - var hideCalled = false - var resetCalled = false - var navigateCalled = false - var showCalled = false - - var lastNavigateKeys: [String]? - var lastNavigateExecute: Bool = true - - func application(_ application: NSApplication, open urls: [URL]) { - for url in urls { - handleURL(url) - } - } - - private func handleURL(_ url: URL) { - guard url.scheme == "leaderkey" else { return } - - if url.host == "settings" { - settingsCalled = true - return - } - if url.host == "about" { - aboutCalled = true - return - } - if url.host == "config-reload" { - configReloadCalled = true - return - } - if url.host == "config-reveal" { - configRevealCalled = true - return - } - if url.host == "activate" { - activateCalled = true - return - } - if url.host == "hide" { - hideCalled = true - return - } - if url.host == "reset" { - resetCalled = true - return - } - - showCalled = true - - 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) - let shouldExecute = queryItems.first(where: { $0.name == "execute" })?.value != "false" - - navigateCalled = true - lastNavigateKeys = keys - lastNavigateExecute = shouldExecute - } + let action = URLSchemeHandler.parse(url) + XCTAssertEqual(action, .show) } }