├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── INSTALL.md ├── LICENSE ├── Makefile ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── CmdKeyHappyCore.swift ├── debug.swift ├── log.swift ├── main.swift └── signal-handler.swift ├── cmd-key-happy-restart.sh └── com.frobware.cmd-key-happy.plist /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/swiftpackagemanager,swiftpm,swift 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=swiftpackagemanager,swiftpm,swift 3 | 4 | ### Swift ### 5 | # Xcode 6 | # 7 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 8 | 9 | ## User settings 10 | xcuserdata/ 11 | 12 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 13 | *.xcscmblueprint 14 | *.xccheckout 15 | 16 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 17 | build/ 18 | DerivedData/ 19 | *.moved-aside 20 | *.pbxuser 21 | !default.pbxuser 22 | *.mode1v3 23 | !default.mode1v3 24 | *.mode2v3 25 | !default.mode2v3 26 | *.perspectivev3 27 | !default.perspectivev3 28 | 29 | ## Obj-C/Swift specific 30 | *.hmap 31 | 32 | ## App packaging 33 | *.ipa 34 | *.dSYM.zip 35 | *.dSYM 36 | 37 | ## Playgrounds 38 | timeline.xctimeline 39 | playground.xcworkspace 40 | 41 | # Swift Package Manager 42 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 43 | # Packages/ 44 | # Package.pins 45 | # Package.resolved 46 | # *.xcodeproj 47 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 48 | # hence it is not needed unless you have added a package configuration file to your project 49 | # .swiftpm 50 | 51 | .build/ 52 | 53 | # CocoaPods 54 | # We recommend against adding the Pods directory to your .gitignore. However 55 | # you should judge for yourself, the pros and cons are mentioned at: 56 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 57 | # Pods/ 58 | # Add this line if you want to avoid checking in source code from the Xcode workspace 59 | # *.xcworkspace 60 | 61 | # Carthage 62 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 63 | # Carthage/Checkouts 64 | 65 | Carthage/Build/ 66 | 67 | # Accio dependency management 68 | Dependencies/ 69 | .accio/ 70 | 71 | # fastlane 72 | # It is recommended to not store the screenshots in the git repo. 73 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 74 | # For more information about the recommended setup visit: 75 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 76 | 77 | fastlane/report.xml 78 | fastlane/Preview.html 79 | fastlane/screenshots/**/*.png 80 | fastlane/test_output 81 | 82 | # Code Injection 83 | # After new code Injection tools there's a generated folder /iOSInjectionProject 84 | # https://github.com/johnno1962/injectionforxcode 85 | 86 | iOSInjectionProject/ 87 | 88 | ### SwiftPackageManager ### 89 | Packages 90 | xcuserdata 91 | *.xcodeproj 92 | 93 | 94 | ### SwiftPM ### 95 | 96 | 97 | # End of https://www.toptal.com/developers/gitignore/api/swiftpackagemanager,swiftpm,swift 98 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | Build the application: 4 | 5 | $ make 6 | 7 | Install (default is ~/.local/bin) 8 | 9 | $ make install 10 | 11 | Install the launchd configuration: 12 | 13 | $ make install-plist 14 | 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) <2009, 2025> Andrew McDermott 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2009, 2010, 2013, 2025 Andrew McDermott 2 | # 3 | # Source can be cloned from: 4 | # 5 | # https://github.com/frobware/cmd-key-happy.git 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | 25 | INSTALL = /usr/bin/install 26 | INSTALL_ROOT = $(HOME)/.local 27 | 28 | LAUNCHD_AGENTS_DIR = $(HOME)/Library/LaunchAgents 29 | LAUNCHD_LABEL = com.frobware.cmd-key-happy 30 | PLIST_FILE = $(LAUNCHD_AGENTS_DIR)/$(LAUNCHD_LABEL).plist 31 | 32 | # Build mode: debug or release (default: release) 33 | BUILD_MODE ?= release 34 | BUILD_DIR = .build/$(BUILD_MODE) 35 | CMD_KEY_HAPPY_BINARY = $(BUILD_DIR)/cmd-key-happy 36 | 37 | cmd-key-happy: 38 | swift build -c $(BUILD_MODE) 39 | 40 | .PHONY: install install-plist 41 | .PHONY: start stop clean 42 | 43 | install: cmd-key-happy 44 | $(INSTALL) -d $(INSTALL_ROOT)/bin 45 | $(INSTALL) -m 555 $(CMD_KEY_HAPPY_BINARY) $(INSTALL_ROOT)/bin/cmd-key-happy 46 | $(INSTALL) -m 555 cmd-key-happy-restart.sh $(INSTALL_ROOT)/bin/cmd-key-happy-restart 47 | 48 | install-plist: 49 | mkdir -p $(LAUNCHD_AGENTS_DIR) 50 | -launchctl stop $(LAUNCHD_LABEL) 51 | -launchctl unload $(PLIST_FILE) 52 | $(RM) $(PLIST_FILE) 53 | sed -e 's~%INSTALL_ROOT~$(INSTALL_ROOT)~' $(LAUNCHD_LABEL).plist > $(PLIST_FILE) 54 | chmod 644 $(PLIST_FILE) 55 | launchctl load -S Aqua $(PLIST_FILE) 56 | launchctl start $(LAUNCHD_LABEL) 57 | 58 | stop: 59 | launchctl stop $(LAUNCHD_LABEL) 60 | 61 | start: 62 | launchctl start $(LAUNCHD_LABEL) 63 | 64 | uninstall: 65 | -pkill cmd-key-happy 66 | -$(RM) $(INSTALL_ROOT)/bin/cmd-key-happy 67 | -$(RM) $(INSTALL_ROOT)/bin/cmd-key-happy-restart 68 | -launchctl stop $(LAUNCHD_LABEL) 69 | -launchctl unload $(PLIST_FILE) 70 | -$(RM) $(PLIST_FILE) 71 | 72 | TCC_DB = "/Library/Application Support/com.apple.TCC/TCC.db" 73 | 74 | install-security-exception-for-cmd-key-happy: 75 | sudo sqlite3 $(TCC_DB) 'insert or ignore into access values("kTCCServiceAccessibility", "$(INSTALL_ROOT)/bin/cmd-key-happy", 1, 1, 0, null)' 76 | sudo sqlite3 $(TCC_DB) 'select * from access' 77 | 78 | list-tcc-access: 79 | sudo sqlite3 $(TCC_DB) 'select * from access' 80 | 81 | uninstall-security-exception-for-cmd-key-happy: 82 | sudo sqlite3 $(TCC_DB) 'delete from access where client like "%cmd-key-happy%"' 83 | 84 | clean: 85 | swift package clean 86 | rm -rf .build 87 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "29a10ae3ff4c6a883f36dd42d825c43fd2530fb1eb56143bb45f32a3f8a8518c", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-argument-parser", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/apple/swift-argument-parser.git", 8 | "state" : { 9 | "revision" : "41982a3656a71c768319979febd796c6fd111d5c", 10 | "version" : "1.5.0" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "cmd-key-happy", 6 | platforms: [ 7 | .macOS(.v11) 8 | ], 9 | dependencies: [ 10 | .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), 11 | ], 12 | targets: [ 13 | .executableTarget( 14 | name: "cmd-key-happy", 15 | dependencies: [.product(name: "ArgumentParser", package: "swift-argument-parser")] 16 | ), 17 | ] 18 | ) 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # macOS Command/Option Key Mapper (for Linux refugees) 2 | 3 | A utility that makes your macOS keyboard work like a Linux keyboard by swapping modifier keys: 4 | - The physical Option (⌥) key functions as Command (⌘) 5 | - The physical Command (⌘) key functions as Option/Alt (⌥) 6 | 7 | ## Why Use This? 8 | 9 | On Linux, the Alt key (next to spacebar) is used for terminal shortcuts like alt-backspace and alt-f. This utility places that same functionality on the same physical key on your Mac keyboard, making muscle memory work across both systems. 10 | 11 | ## Common Use Cases 12 | 13 | - Terminal navigation (alt-f, alt-b, alt-backspace) 14 | - Emacs in terminal mode (`emacs -nw`) 15 | - Any command-line tool that uses readline 16 | 17 | ## Installation 18 | 19 | [Instructions coming soon] 20 | 21 | ## Configuration 22 | 23 | The configuration file is located at: `~/Library/Application Support/com.frobware.cmd-key-happy/config`. 24 | 25 | (TODO) This file is created automatically when you run cmd-key-happy for the first time, along with the necessary directory structure. 26 | 27 | The configuration file is line-oriented. Each line specifies the name of an application for which the modifiers option and commands will be swapped for all input. For example: 28 | 29 | ```plaintext 30 | Alacritty 31 | Ghostty 32 | kitty 33 | ``` 34 | 35 | Explanation: 36 | 37 | - Alacritty: The utility will swap command and option keys when using the Alacritty terminal. 38 | - Ghostty: The same behaviour applies to this application. 39 | - kitty: Likewise, the keys are swapped for kitty. 40 | 41 | The application name must match the name as it appears in the system’s application list. 42 | -------------------------------------------------------------------------------- /Sources/CmdKeyHappyCore.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import CoreGraphics 3 | import Dispatch 4 | import Foundation 5 | import os.log 6 | 7 | extension CGEventFlags { 8 | var description: String { 9 | var parts: [String] = [] 10 | if contains(.maskShift) { parts.append("shift") } 11 | if contains(.maskControl) { parts.append("control") } 12 | if contains(.maskAlternate) { parts.append("option") } 13 | if contains(.maskCommand) { parts.append("command") } 14 | if contains(.maskSecondaryFn) { parts.append("fn") } 15 | if contains(.maskNumericPad) { parts.append("numpad") } 16 | if contains(.maskHelp) { parts.append("help") } 17 | return parts.isEmpty ? "none" : parts.joined(separator: "+") 18 | } 19 | } 20 | 21 | class TappedApp { 22 | let pid: pid_t 23 | let name: String 24 | var tap: CFMachPort? 25 | 26 | init(pid: pid_t, name: String) { 27 | self.pid = pid 28 | self.name = name 29 | } 30 | } 31 | 32 | class CmdKeyHappyCore { 33 | private var isStopping = false 34 | private var tappedApps: [pid_t: TappedApp] = [:] 35 | private let runLoop: CFRunLoop 36 | private var currentConfiguration: Set 37 | 38 | init(runLoop: CFRunLoop = CFRunLoopGetMain()) { 39 | self.runLoop = runLoop 40 | self.currentConfiguration = [] 41 | } 42 | 43 | /// Configures and starts monitoring for the specified applications. 44 | /// - Parameter appsToTap: List of application names to monitor 45 | func configure(appsToTap: [String]) { 46 | self.currentConfiguration = Set(appsToTap) 47 | untapAll() 48 | tapRunningApps() 49 | } 50 | 51 | /// Starts the event loop and application monitoring 52 | func start() { 53 | setupWorkspaceNotifications() 54 | CFRunLoopRun() 55 | } 56 | 57 | private func untapAll() { 58 | for pid in tappedApps.keys { 59 | removeTap(forPid: pid) 60 | } 61 | } 62 | 63 | private func tapRunningApps() { 64 | for app in NSWorkspace.shared.runningApplications { 65 | guard let appName = app.localizedName, 66 | currentConfiguration.contains(appName) else { continue } 67 | tapApp(for: app.processIdentifier, appName: appName) 68 | } 69 | } 70 | 71 | private func setupWorkspaceNotifications() { 72 | NSWorkspace.shared.notificationCenter.addObserver( 73 | self, 74 | selector: #selector(handleAppLaunched(_:)), 75 | name: NSWorkspace.didLaunchApplicationNotification, 76 | object: nil 77 | ) 78 | 79 | NSWorkspace.shared.notificationCenter.addObserver( 80 | self, 81 | selector: #selector(handleAppTerminated(_:)), 82 | name: NSWorkspace.didTerminateApplicationNotification, 83 | object: nil 84 | ) 85 | } 86 | 87 | func shutdown() { 88 | stop() 89 | cleanup() 90 | CFRunLoopStop(self.runLoop) 91 | } 92 | 93 | func stop() { 94 | guard !isStopping else { return } 95 | isStopping = true 96 | untapAll() 97 | NSWorkspace.shared.notificationCenter.removeObserver(self) 98 | } 99 | 100 | private func cleanup() { 101 | tappedApps.removeAll() 102 | } 103 | 104 | private func removeTap(forPid pid: pid_t) { 105 | guard let tappedApp = tappedApps[pid], let tap = tappedApp.tap else { return } 106 | 107 | CGEvent.tapEnable(tap: tap, enable: false) 108 | CFMachPortInvalidate(tap) 109 | 110 | if let runLoopSource = CFMachPortCreateRunLoopSource(nil, tap, 0) { 111 | CFRunLoopRemoveSource(self.runLoop, runLoopSource, .commonModes) 112 | } 113 | 114 | CKHLog.info("Removed event tap for PID: \(pid), appName: \(tappedApp.name)") 115 | tappedApps.removeValue(forKey: pid) 116 | } 117 | 118 | private static let eventCallback: CGEventTapCallBack = { _, type, event, userInfo in 119 | guard type == .keyDown else { 120 | return Unmanaged.passUnretained(event) 121 | } 122 | 123 | guard let userInfo = userInfo else { 124 | CKHLog.error("Missing userInfo in callback") 125 | return Unmanaged.passUnretained(event) 126 | } 127 | 128 | let targetProcessID = event.getIntegerValueField(.eventTargetUnixProcessID) 129 | let tappedApp = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() 130 | guard tappedApp.pid == targetProcessID else { 131 | return Unmanaged.passUnretained(event) 132 | } 133 | 134 | guard event.flags.contains(.maskCommand) || event.flags.contains(.maskAlternate) else { 135 | return Unmanaged.passUnretained(event) 136 | } 137 | 138 | CKHLog.debug("option^=command for PID: \(tappedApp.pid), appName: \(tappedApp.name)") 139 | 140 | // Swap Command and Option modifier keys. symmetricDifference 141 | // performs XOR on the flags, which works here because we know 142 | // from the guard that exactly one of these modifiers is 143 | // pressed (not neither, not both). XOR will therefore remove 144 | // the pressed modifier and add the unpressed one in a single 145 | // operation. 146 | event.flags = event.flags.symmetricDifference([.maskCommand, .maskAlternate]) 147 | return Unmanaged.passUnretained(event) 148 | } 149 | 150 | @objc private func handleAppLaunched(_ notification: Notification) { 151 | guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, 152 | let appName = app.localizedName, 153 | currentConfiguration.contains(appName) else { 154 | return 155 | } 156 | 157 | tapApp(for: app.processIdentifier, appName: appName) 158 | } 159 | 160 | @objc private func handleAppTerminated(_ notification: Notification) { 161 | guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { 162 | return 163 | } 164 | 165 | removeTap(forPid: app.processIdentifier) 166 | } 167 | 168 | private func tapApp(for pid: pid_t, appName: String) { 169 | if let tappedApp = tappedApps[pid], tappedApp.tap != nil { 170 | return 171 | } 172 | 173 | let eventMask: CGEventMask = (1 << CGEventType.keyDown.rawValue | 1 << CGEventType.flagsChanged.rawValue) 174 | let tappedApp = tappedApps[pid] ?? TappedApp(pid: pid, name: appName) 175 | let userInfo = Unmanaged.passUnretained(tappedApp).toOpaque() 176 | 177 | guard let tap = CGEvent.tapCreate( 178 | tap: .cgAnnotatedSessionEventTap, 179 | place: .tailAppendEventTap, 180 | options: .defaultTap, 181 | eventsOfInterest: eventMask, 182 | callback: CmdKeyHappyCore.eventCallback, 183 | userInfo: userInfo 184 | ) else { 185 | let error = String(cString: strerror(errno)) 186 | CKHLog.error("Failed to create event tap: \(error)") 187 | return 188 | } 189 | 190 | let runLoopSource = CFMachPortCreateRunLoopSource(nil, tap, 0) 191 | CFRunLoopAddSource(self.runLoop, runLoopSource, .commonModes) 192 | 193 | tappedApp.tap = tap 194 | tappedApps[pid] = tappedApp 195 | CKHLog.info("Event tap created for PID \(pid), appName: \(appName)") 196 | } 197 | 198 | deinit { 199 | cleanup() 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /Sources/debug.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @inline(__always) 4 | func debugVariable(_ name: String, _ object: T, file: String = #file, line: Int = #line) { 5 | let retainCount = CFGetRetainCount(object as CFTypeRef) 6 | let address = Unmanaged.passUnretained(object).toOpaque() 7 | let filename = (file as NSString).lastPathComponent 8 | CKHLog.debug("RC [\(filename):\(line)]:\(name)(\(type(of: object)) \(address)) \(retainCount)") 9 | } 10 | -------------------------------------------------------------------------------- /Sources/log.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import os.log 3 | 4 | struct CKHLog { 5 | private static let logger = Logger(subsystem: "com.frobware.ckh", category: "default") 6 | private static let isConsoleEnabled = !CommandLine.arguments.contains("--headless") 7 | private static let processID = ProcessInfo.processInfo.processIdentifier 8 | private static let processName = ProcessInfo.processInfo.processName 9 | 10 | enum LogLevel: String { 11 | case info, debug, warning, error, critical 12 | } 13 | 14 | static func info(_ message: String, file: String = #file, line: Int = #line) { 15 | logMessage(level: .info, message: message, file: file, line: line) 16 | } 17 | 18 | static func debug(_ message: String, file: String = #file, line: Int = #line) { 19 | logMessage(level: .debug, message: message, file: file, line: line) 20 | } 21 | 22 | static func warning(_ message: String, file: String = #file, line: Int = #line) { 23 | logMessage(level: .warning, message: message, file: file, line: line) 24 | } 25 | 26 | static func error(_ message: String, file: String = #file, line: Int = #line) { 27 | logMessage(level: .error, message: message, file: file, line: line) 28 | } 29 | 30 | static func critical(_ message: String, file: String = #file, line: Int = #line) { 31 | logMessage(level: .critical, message: message, file: file, line: line) 32 | } 33 | 34 | private static func logMessage(level: LogLevel, message: String, file: String, line: Int) { 35 | let filename = (file as NSString).lastPathComponent 36 | let fileLineInfo = level == .debug ? " (\(filename):\(line))" : "" 37 | 38 | if isConsoleEnabled { 39 | print("\(ProcessInfo.processInfo.processName)[\(ProcessInfo.processInfo.processIdentifier)]: \(level): \(message)\(fileLineInfo)") 40 | fflush(stdout) 41 | return 42 | } 43 | 44 | let ulsMessage = "\(message)\(fileLineInfo)" 45 | 46 | switch level { 47 | case .info: 48 | logger.info("\(ulsMessage, privacy: .public)") 49 | case .debug: 50 | logger.debug("\(ulsMessage, privacy: .public)") 51 | case .warning: 52 | logger.log(level: .default, "\(ulsMessage, privacy: .public)") 53 | case .error: 54 | logger.error("\(ulsMessage, privacy: .public)") 55 | case .critical: 56 | logger.fault("\(ulsMessage, privacy: .public)") 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/main.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import Foundation 3 | import ApplicationServices 4 | 5 | enum AccessibilityError: Error, LocalizedError { 6 | case permissionDenied 7 | 8 | var errorDescription: String? { 9 | switch self { 10 | case .permissionDenied: 11 | return """ 12 | Accessibility permissions are required for keyboard monitoring. 13 | 1. Open System Settings > Privacy & Security > Accessibility. 14 | 2. Grant permission for this application. 15 | 3. Run the application again. 16 | """ 17 | } 18 | } 19 | } 20 | 21 | struct AccessibilityPermissions { 22 | static func checkPermissions() throws { 23 | let options = [ 24 | "AXTrustedCheckOptionPrompt": true 25 | ] as CFDictionary 26 | 27 | if !AXIsProcessTrustedWithOptions(options) { 28 | throw AccessibilityError.permissionDenied 29 | } 30 | } 31 | } 32 | 33 | extension CmdKeyHappyCore { 34 | func checkPermissionsAndStart() throws { 35 | try AccessibilityPermissions.checkPermissions() 36 | start() 37 | } 38 | } 39 | 40 | class ConfigFileWatcher { 41 | private var source: DispatchSourceFileSystemObject? 42 | private let callback: () -> Void 43 | private let path: String 44 | private var fileHandle: Int32 = -1 45 | 46 | /// Initialise with a path and optional file handle. If a file 47 | /// handle is provided, the watcher takes ownership and will close 48 | /// it when stopped. 49 | init(path: String, fileHandle: Int32? = nil, callback: @escaping () -> Void) { 50 | self.path = path 51 | self.fileHandle = fileHandle ?? -1 52 | self.callback = callback 53 | } 54 | 55 | func start() throws { 56 | if fileHandle == -1 { 57 | fileHandle = open(path, O_EVTONLY) 58 | guard fileHandle != -1 else { 59 | let error = String(cString: strerror(errno)) 60 | throw ConfigError.readError(path, NSError(domain: NSPOSIXErrorDomain, code: Int(errno), userInfo: [NSLocalizedDescriptionKey: error])) 61 | } 62 | } 63 | 64 | let source = DispatchSource.makeFileSystemObjectSource( 65 | fileDescriptor: fileHandle, 66 | eventMask: .write, 67 | queue: .main 68 | ) 69 | source.setEventHandler { [weak self] in 70 | self?.callback() 71 | } 72 | source.setCancelHandler { [fileHandle] in 73 | close(fileHandle) 74 | } 75 | self.source = source 76 | source.resume() 77 | CKHLog.info("Started watching configuration file: \(path)") 78 | } 79 | 80 | func stop() { 81 | source?.cancel() 82 | source = nil 83 | CKHLog.info("Stopped watching configuration file: \(path)") 84 | } 85 | 86 | deinit { 87 | stop() 88 | } 89 | } 90 | 91 | enum ConfigError: Error, LocalizedError { 92 | case fileNotFound(String) 93 | case readError(String, Error) 94 | case notRegularFile(String) 95 | case failedToCreateDirectory(String, Error) 96 | 97 | var errorDescription: String? { 98 | switch self { 99 | case .fileNotFound(let path): 100 | return "\(path): No such file or directory" 101 | case .readError(let path, let error): 102 | return "\(path): \(error.localizedDescription)" 103 | case .notRegularFile(let path): 104 | return "\(path): Not a regular file" 105 | case .failedToCreateDirectory(let path, let error): 106 | return "\(path): Failed to create directory: \(error.localizedDescription)" 107 | } 108 | } 109 | } 110 | 111 | struct ConfigFileLoader { 112 | private let fileManager: FileManager 113 | 114 | init(fileManager: FileManager = .default) { 115 | self.fileManager = fileManager 116 | } 117 | 118 | /// Validates and resolves a path to ensure it points to a regular file. 119 | /// - Parameter path: Path to validate 120 | /// - Returns: The resolved path (if symlink) 121 | /// - Throws: ConfigError if validation fails 122 | func validatePath(_ path: String) throws -> String { 123 | let resolvedPath: String 124 | do { 125 | resolvedPath = try fileManager.destinationOfSymbolicLink(atPath: path) 126 | } catch { 127 | // Not a symlink, use original path. 128 | resolvedPath = path 129 | } 130 | 131 | var isDirectory: ObjCBool = false 132 | guard fileManager.fileExists(atPath: resolvedPath, isDirectory: &isDirectory) else { 133 | // Use original path in error. 134 | throw ConfigError.fileNotFound(path) 135 | } 136 | 137 | guard !isDirectory.boolValue else { 138 | // Use original path in error. 139 | throw ConfigError.notRegularFile(path) 140 | } 141 | 142 | // Check that the final destination is a regular file. 143 | let attributes = try? fileManager.attributesOfItem(atPath: resolvedPath) 144 | let fileType = attributes?[.type] as? FileAttributeType 145 | guard fileType == .typeRegular else { 146 | // Use original path in error. 147 | throw ConfigError.notRegularFile(path) 148 | } 149 | 150 | return resolvedPath 151 | } 152 | 153 | /// Loads and validates a configuration file 154 | /// - Parameter path: Path to the configuration file 155 | /// - Returns: Array of non-empty lines from the file 156 | /// - Throws: ConfigError if validation or reading fails 157 | func loadConfigFile(_ path: String?) throws -> [String] { 158 | guard let path = path else { return [] } 159 | 160 | let resolvedPath = try validatePath(path) 161 | 162 | do { 163 | let fileContents = try String(contentsOfFile: resolvedPath, encoding: .utf8) 164 | return fileContents 165 | .split(separator: "\n") 166 | .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } 167 | .filter { !$0.isEmpty } 168 | } catch { 169 | throw ConfigError.readError(path, error) // Use original path in error 170 | } 171 | } 172 | } 173 | 174 | struct CmdKeyHappyApp: ParsableCommand { 175 | static let configuration = CommandConfiguration( 176 | commandName: "cmd-key-happy", 177 | abstract: "A utility to swap command and option keys for specific apps" 178 | ) 179 | 180 | @Option(name: .shortAndLong, help: "Path to configuration file") 181 | private var config: String? 182 | 183 | @Flag(name: .long, help: "Run without console output") 184 | private var headless = false 185 | 186 | @Argument(help: "Names of apps to monitor") 187 | private var apps: [String] = [] 188 | 189 | private var isUsingDefaultConfig = false 190 | 191 | private var configLoader: ConfigFileLoader { 192 | ConfigFileLoader() 193 | } 194 | 195 | mutating func validate() throws { 196 | if config == nil && apps.isEmpty { 197 | let paths = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true) 198 | guard let appSupport = paths.first else { 199 | throw ConfigError.failedToCreateDirectory("Could not determine Application Support directory path", NSError(domain: NSCocoaErrorDomain, code: -1)) 200 | } 201 | 202 | let configDir = (appSupport as NSString).appendingPathComponent("com.frobware.cmd-key-happy") 203 | 204 | if !FileManager.default.fileExists(atPath: configDir) { 205 | do { 206 | try FileManager.default.createDirectory(atPath: configDir, withIntermediateDirectories: true) 207 | } catch { 208 | throw ConfigError.failedToCreateDirectory(configDir, error) 209 | } 210 | } 211 | 212 | config = (configDir as NSString).appendingPathComponent("config") 213 | isUsingDefaultConfig = true 214 | } 215 | } 216 | 217 | private func setupConfigFileWatcher(cmdKeyHappy: CmdKeyHappyCore, fileHandle: Int32) -> ConfigFileWatcher? { 218 | guard let configPath = config, apps.isEmpty else { 219 | return nil 220 | } 221 | 222 | return ConfigFileWatcher(path: configPath, fileHandle: fileHandle) { [self] in 223 | do { 224 | let newApps = try configLoader.loadConfigFile(config) 225 | cmdKeyHappy.configure(appsToTap: newApps) 226 | if newApps.isEmpty { 227 | CKHLog.info("Configuration reloaded: no apps configured") 228 | } else { 229 | CKHLog.info("Configuration reloaded: monitoring apps: [\(newApps.joined(separator: ", "))]") 230 | } 231 | } catch ConfigError.fileNotFound(let path) where isUsingDefaultConfig { 232 | CKHLog.info("Configuration file removed: \(path) - continuing with empty configuration") 233 | cmdKeyHappy.configure(appsToTap: []) 234 | } catch ConfigError.notRegularFile(let path) { 235 | CKHLog.error("Configuration file is no longer valid: \(path) - continuing with previous configuration") 236 | } catch { 237 | CKHLog.error("Failed to reload configuration: \(error.localizedDescription) - continuing with previous configuration") 238 | } 239 | } 240 | } 241 | 242 | func run() throws { 243 | let signalHandler = SignalHandler() 244 | let cmdKeyHappy = CmdKeyHappyCore() 245 | 246 | var initialApps: [String] 247 | var configFileWatcher: ConfigFileWatcher? 248 | 249 | if !apps.isEmpty { 250 | initialApps = apps 251 | } else { 252 | let fileHandle = open(config!, O_EVTONLY) 253 | if fileHandle != -1 { 254 | do { 255 | initialApps = try configLoader.loadConfigFile(config) 256 | if initialApps.isEmpty { 257 | CKHLog.info("Starting with empty configuration") 258 | } 259 | configFileWatcher = setupConfigFileWatcher(cmdKeyHappy: cmdKeyHappy, fileHandle: fileHandle) 260 | try configFileWatcher?.start() 261 | } catch ConfigError.fileNotFound(let path) where isUsingDefaultConfig { 262 | CKHLog.info("No configuration file found at default path: \(path) - continuing with empty configuration") 263 | initialApps = [] 264 | close(fileHandle) 265 | } catch { 266 | close(fileHandle) 267 | throw error 268 | } 269 | } else { 270 | let error = String(cString: strerror(errno)) 271 | throw ConfigError.readError(config!, NSError(domain: NSPOSIXErrorDomain, code: Int(errno), userInfo: [NSLocalizedDescriptionKey: error])) 272 | } 273 | } 274 | 275 | cmdKeyHappy.configure(appsToTap: initialApps) 276 | 277 | signalHandler.addHandler(for: [SIGTERM, SIGINT]) { signo in 278 | CKHLog.info("Received shutdown signal: Initiating shutdown...") 279 | configFileWatcher?.stop() 280 | cmdKeyHappy.shutdown() 281 | } 282 | 283 | signalHandler.addHandler(for: [SIGHUP]) { _ in 284 | CKHLog.info("Received SIGHUP: Reloading configuration (note: file watching is enabled)") 285 | if !apps.isEmpty { 286 | CKHLog.info("Ignoring SIGHUP as apps were specified via command line") 287 | return 288 | } 289 | do { 290 | let newApps = try configLoader.loadConfigFile(config) 291 | cmdKeyHappy.configure(appsToTap: newApps) 292 | if newApps.isEmpty { 293 | CKHLog.info("Configuration reloaded: no apps configured") 294 | } else { 295 | CKHLog.info("Configuration reloaded: monitoring apps: [\(newApps.joined(separator: ", "))]") 296 | } 297 | } catch { 298 | CKHLog.error("Failed to reload configuration: \(error.localizedDescription) - continuing with previous configuration") 299 | } 300 | } 301 | 302 | try AccessibilityPermissions.checkPermissions() 303 | cmdKeyHappy.start() 304 | } 305 | } 306 | 307 | CmdKeyHappyApp.main() 308 | -------------------------------------------------------------------------------- /Sources/signal-handler.swift: -------------------------------------------------------------------------------- 1 | import Dispatch 2 | 3 | /// Manages Unix signal handling using GCD dispatch sources. 4 | /// 5 | /// This class allows multiple signals to share the same action and 6 | /// ensures safe handling by transitioning signal handling from the 7 | /// asynchronous context, where only a limited set of operations are 8 | /// safe, to the main dispatch queue. 9 | class SignalHandler { 10 | private var signalSources: [Int32: DispatchSourceSignal] = [:] 11 | private var sharedHandlers: [(signals: [Int32], action: (Int32) -> Void)] = [] 12 | 13 | /// Adds a handler for one or more signals. 14 | /// 15 | /// - Parameters: 16 | /// - signals: An array of signal numbers to handle. 17 | /// - action: A closure to execute when any of the signals is received. 18 | /// The signal number is passed to the closure. 19 | func addHandler(for signals: [Int32], action: @escaping (Int32) -> Void) { 20 | for signo in signals { 21 | guard !sharedHandlers.contains(where: { $0.signals.contains(signo) }) else { 22 | continue 23 | } 24 | signal(signo, SIG_IGN) 25 | let source = DispatchSource.makeSignalSource(signal: signo, queue: .main) 26 | source.setEventHandler { 27 | action(signo) 28 | } 29 | source.resume() 30 | signalSources[signo] = source 31 | } 32 | 33 | sharedHandlers.append((signals: signals, action: action)) 34 | } 35 | 36 | /// Removes a handler for the specified signals. 37 | /// 38 | /// - Parameter signals: The signals to stop handling. 39 | func removeHandler(for signals: [Int32]) { 40 | for signo in signals { 41 | signalSources[signo]?.cancel() 42 | signalSources.removeValue(forKey: signo) 43 | } 44 | sharedHandlers.removeAll { handler in 45 | handler.signals.allSatisfy(signals.contains) 46 | } 47 | } 48 | 49 | /// Cancels all active signal sources and cleans up resources. 50 | func cleanup() { 51 | signalSources.values.forEach { $0.cancel() } 52 | signalSources.removeAll() 53 | sharedHandlers.removeAll() 54 | } 55 | 56 | deinit { 57 | cleanup() 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /cmd-key-happy-restart.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | launchctl stop com.frobware.cmd-key-happy 4 | launchctl start com.frobware.cmd-key-happy 5 | -------------------------------------------------------------------------------- /com.frobware.cmd-key-happy.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Label 6 | com.frobware.cmd-key-happy 7 | Program 8 | %INSTALL_ROOT/bin/cmd-key-happy 9 | RunAtLoad 10 | 11 | LimitLoadToSessionType 12 | Aqua 13 | 14 | 15 | --------------------------------------------------------------------------------