├── .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 |
--------------------------------------------------------------------------------