Initial commit: uskey - macOS keyboard remapper

Features:
- Menu bar GUI with enable/disable toggle
- JSON-based configuration system
- File-based logging with debug support
- CGEventTap-based key remapping
- Custom app icon support
- DMG installer packaging

Core Components:
- AppDelegate: Application lifecycle and initialization
- EventTapManager: Event tap creation and management with proper pointer lifetime
- KeyMapper: Key mapping logic and configuration loading
- StatusBarController: Menu bar UI and user interactions
- Logger: File and console logging with configurable levels
- Config: JSON configuration parser with default creation

Build System:
- build-app.sh: Creates macOS .app bundle with icon
- build-dmg.sh: Generates distributable DMG installer
- create-icon.sh: Converts PNG to .icns format

Documentation:
- README.md: User guide and troubleshooting
- BUILD.md: Build instructions and packaging
- DEBUG.md: Debugging guide with log access

🤖 Generated with [Qoder](https://qoder.com)
This commit is contained in:
loveuer
2025-12-02 17:51:56 +08:00
commit 1e8b79585f
18 changed files with 1229 additions and 0 deletions

95
Sources/AppDelegate.swift Normal file
View File

@@ -0,0 +1,95 @@
@preconcurrency import Cocoa
@preconcurrency import ApplicationServices
@MainActor
class AppDelegate: NSObject, NSApplicationDelegate {
private var statusBarController: StatusBarController?
private var eventTapManager: EventTapManager?
func applicationDidFinishLaunching(_ notification: Notification) {
Logger.setup()
Logger.info("uskey - macOS Keyboard Remapper")
Logger.info("================================")
Logger.info("App bundle path: \(Bundle.main.bundlePath)")
let config = loadConfig()
Logger.logLevel = config.log.level
let keyMapper = KeyMapper(fromConfig: config)
keyMapper.printMappings()
Logger.info("Checking accessibility permissions...")
if !checkAccessibilityPermissions() {
Logger.error("Accessibility permissions not granted!")
showAccessibilityAlert()
return
}
Logger.info("Accessibility permissions granted")
eventTapManager = EventTapManager(keyMapper: keyMapper)
statusBarController = StatusBarController(eventTapManager: eventTapManager!, config: config)
statusBarController?.setupStatusBar()
Logger.info("Attempting to start event monitoring...")
if eventTapManager!.start() {
Logger.info("Event monitoring started successfully")
} else {
Logger.error("Failed to start event monitoring - check if app has accessibility permissions")
}
Logger.info("Application ready")
}
func applicationWillTerminate(_ notification: Notification) {
eventTapManager?.stop()
Logger.info("Application terminated")
Logger.cleanup()
}
private func loadConfig() -> Config {
let configPath = Config.getConfigPath()
Logger.info("Loading config from: \(configPath)")
do {
let config = try Config.load(from: configPath)
Logger.info("Configuration loaded successfully")
return config
} catch {
Logger.warning("Failed to load config: \(error.localizedDescription)")
Logger.info("Creating default configuration...")
do {
try Config.createDefault(at: configPath)
Logger.info("Default configuration created at: \(configPath)")
return try Config.load(from: configPath)
} catch {
Logger.error("Failed to create default config: \(error.localizedDescription)")
Logger.warning("Using fallback configuration")
return Config()
}
}
}
private func checkAccessibilityPermissions() -> Bool {
let trusted = AXIsProcessTrusted()
Logger.debug("AXIsProcessTrusted result: \(trusted)")
if !trusted {
Logger.info("Requesting accessibility permissions...")
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
AXIsProcessTrustedWithOptions(options)
}
return trusted
}
private func showAccessibilityAlert() {
let alert = NSAlert()
alert.messageText = "Accessibility Permissions Required"
alert.informativeText = "uskey needs accessibility permissions to remap keyboard keys.\n\nPlease grant permissions in:\nSystem Preferences > Privacy & Security > Accessibility\n\nAfter granting permissions, restart the app."
alert.alertStyle = .warning
alert.addButton(withTitle: "OK")
alert.runModal()
}
}

93
Sources/Config.swift Normal file
View File

@@ -0,0 +1,93 @@
@preconcurrency import Foundation
enum LogLevel: String, Codable {
case debug
case info
case warning
case error
func shouldLog(_ level: LogLevel) -> Bool {
let levels: [LogLevel] = [.debug, .info, .warning, .error]
guard let currentIndex = levels.firstIndex(of: self),
let targetIndex = levels.firstIndex(of: level) else {
return false
}
return targetIndex >= currentIndex
}
}
struct LogConfig: Codable {
let level: LogLevel
init(level: LogLevel = .info) {
self.level = level
}
}
struct KeyMapping: Codable {
let from: Int64
let to: Int64
}
struct MappingConfig: Codable {
private let mappings: [String: KeyMapping]
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
mappings = try container.decode([String: KeyMapping].self)
}
init(mappings: [String: KeyMapping] = [:]) {
self.mappings = mappings
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(mappings)
}
func getAllMappings() -> [(Int64, Int64)] {
return mappings.values.map { ($0.from, $0.to) }
}
}
struct Config: Codable {
let log: LogConfig
let mapping: MappingConfig
init(log: LogConfig = LogConfig(), mapping: MappingConfig = MappingConfig()) {
self.log = log
self.mapping = mapping
}
static func load(from path: String) throws -> Config {
let url = URL(fileURLWithPath: path)
let data = try Data(contentsOf: url)
let decoder = JSONDecoder()
return try decoder.decode(Config.self, from: data)
}
static func createDefault(at path: String) throws {
let defaultConfig = Config(
log: LogConfig(level: .info),
mapping: MappingConfig(mappings: [
"backslash2backspace": KeyMapping(from: 42, to: 51),
"backspace2backslash": KeyMapping(from: 51, to: 42)
])
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(defaultConfig)
try data.write(to: URL(fileURLWithPath: path))
}
static func getConfigPath() -> String {
let homeDir = FileManager.default.homeDirectoryForCurrentUser
let configDir = homeDir.appendingPathComponent(".config/uskey")
try? FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true)
return configDir.appendingPathComponent("config.json").path
}
}

View File

@@ -0,0 +1,114 @@
@preconcurrency import Cocoa
@preconcurrency import CoreGraphics
@preconcurrency import ApplicationServices
class EventTapManager {
private var eventTap: CFMachPort?
private var runLoopSource: CFRunLoopSource?
private var isEnabled: Bool = false
var keyMapper: KeyMapper
init(keyMapper: KeyMapper) {
self.keyMapper = keyMapper
}
func start() -> Bool {
guard !isEnabled else { return true }
Logger.debug("Creating event tap...")
let eventMask = (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue)
let selfPtr = Unmanaged.passUnretained(self).toOpaque()
let callback: CGEventTapCallBack = { proxy, type, event, refcon in
guard type == .keyDown || type == .keyUp else {
return Unmanaged.passRetained(event)
}
guard let refcon = refcon else {
Logger.error("Event callback: refcon is nil")
return Unmanaged.passRetained(event)
}
let manager = Unmanaged<EventTapManager>.fromOpaque(refcon).takeUnretainedValue()
let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
if manager.keyMapper.hasMappingFor(keyCode: keyCode) {
if let mappedKey = manager.keyMapper.getMappedKey(for: keyCode) {
Logger.debug("Remapping: \(keyCode) -> \(mappedKey)")
event.setIntegerValueField(.keyboardEventKeycode, value: mappedKey)
}
}
return Unmanaged.passRetained(event)
}
eventTap = CGEvent.tapCreate(
tap: .cgSessionEventTap,
place: .headInsertEventTap,
options: .defaultTap,
eventsOfInterest: CGEventMask(eventMask),
callback: callback,
userInfo: selfPtr
)
guard let eventTap = eventTap else {
Logger.error("Failed to create event tap - check accessibility permissions")
return false
}
Logger.debug("Event tap created successfully")
runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0)
guard let runLoopSource = runLoopSource else {
Logger.error("Failed to create run loop source")
return false
}
Logger.debug("Adding event tap to run loop...")
CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
CGEvent.tapEnable(tap: eventTap, enable: true)
isEnabled = true
Logger.info("Event tap started")
return true
}
func stop() {
guard isEnabled else { return }
Logger.debug("Stopping event tap...")
if let eventTap = eventTap {
CGEvent.tapEnable(tap: eventTap, enable: false)
if let runLoopSource = runLoopSource {
CFRunLoopRemoveSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
}
}
eventTap = nil
runLoopSource = nil
isEnabled = false
Logger.info("Event tap stopped")
}
func isRunning() -> Bool {
return isEnabled
}
func reload(config: Config) {
let wasEnabled = isEnabled
if wasEnabled {
stop()
}
keyMapper.loadFromConfig(config)
if wasEnabled {
_ = start()
}
}
}

47
Sources/KeyMapper.swift Normal file
View File

@@ -0,0 +1,47 @@
@preconcurrency import Foundation
@preconcurrency import CoreGraphics
struct KeyMapper {
private var mappings: [Int64: Int64] = [:]
init() {
}
init(fromConfig config: Config) {
loadFromConfig(config)
}
mutating func loadFromConfig(_ config: Config) {
mappings.removeAll()
for (from, to) in config.mapping.getAllMappings() {
mappings[from] = to
Logger.debug("Loaded mapping: \(from) -> \(to)")
}
}
mutating func addMapping(from: Int64, to: Int64) {
mappings[from] = to
}
mutating func removeMapping(from: Int64) {
mappings.removeValue(forKey: from)
}
func getMappedKey(for keyCode: Int64) -> Int64? {
return mappings[keyCode]
}
func hasMappingFor(keyCode: Int64) -> Bool {
return mappings[keyCode] != nil
}
func printMappings() {
Logger.info("")
Logger.info("Current key mappings:")
Logger.info("====================")
for (from, to) in mappings.sorted(by: { $0.key < $1.key }) {
Logger.info(" \(from) -> \(to)")
}
Logger.info("")
}
}

85
Sources/Logger.swift Normal file
View File

@@ -0,0 +1,85 @@
@preconcurrency import Foundation
struct Logger {
nonisolated(unsafe) static var logLevel: LogLevel = .info
nonisolated(unsafe) private static var logFileHandle: FileHandle?
nonisolated(unsafe) private static var logFilePath: String?
static func setup() {
let logsDir = getLogsDirectory()
try? FileManager.default.createDirectory(at: URL(fileURLWithPath: logsDir), withIntermediateDirectories: true)
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
let dateString = dateFormatter.string(from: Date())
logFilePath = "\(logsDir)/uskey-\(dateString).log"
if !FileManager.default.fileExists(atPath: logFilePath!) {
FileManager.default.createFile(atPath: logFilePath!, contents: nil)
}
logFileHandle = FileHandle(forWritingAtPath: logFilePath!)
logFileHandle?.seekToEndOfFile()
info("Logger initialized, log file: \(logFilePath!)")
}
static func getLogFilePath() -> String? {
return logFilePath
}
static func getLogsDirectory() -> String {
let homeDir = FileManager.default.homeDirectoryForCurrentUser
return homeDir.appendingPathComponent(".config/uskey/logs").path
}
static func debug(_ message: String) {
log(.debug, message)
}
static func info(_ message: String) {
log(.info, message)
}
static func warning(_ message: String) {
log(.warning, message)
}
static func error(_ message: String) {
log(.error, message)
}
private static func log(_ level: LogLevel, _ message: String) {
guard logLevel.shouldLog(level) else { return }
let timestamp = Date()
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
let timeString = formatter.string(from: timestamp)
let levelString: String
switch level {
case .debug:
levelString = "DEBUG"
case .info:
levelString = "INFO"
case .warning:
levelString = "WARN"
case .error:
levelString = "ERROR"
}
let logMessage = "[\(timeString)] [\(levelString)] \(message)\n"
print(logMessage, terminator: "")
if let data = logMessage.data(using: .utf8) {
logFileHandle?.write(data)
}
}
static func cleanup() {
logFileHandle?.closeFile()
}
}

View File

@@ -0,0 +1,147 @@
@preconcurrency import Cocoa
@preconcurrency import ApplicationServices
@MainActor
class StatusBarController {
private var statusItem: NSStatusItem?
private var eventTapManager: EventTapManager
private var config: Config
init(eventTapManager: EventTapManager, config: Config) {
self.eventTapManager = eventTapManager
self.config = config
}
func setupStatusBar() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
if let button = statusItem?.button {
button.image = NSImage(systemSymbolName: "keyboard", accessibilityDescription: "uskey")
button.action = #selector(statusBarButtonClicked)
button.target = self
}
updateMenu()
}
@objc private func statusBarButtonClicked() {
updateMenu()
}
private func updateMenu() {
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "uskey - Keyboard Remapper", action: nil, keyEquivalent: ""))
menu.addItem(NSMenuItem.separator())
let isEnabled = eventTapManager.isRunning()
let toggleItem = NSMenuItem(
title: isEnabled ? "Enabled ✅" : "Enabled ❌",
action: #selector(toggleMapping),
keyEquivalent: ""
)
toggleItem.target = self
menu.addItem(toggleItem)
menu.addItem(NSMenuItem.separator())
menu.addItem(NSMenuItem(title: "Current Mappings:", action: nil, keyEquivalent: ""))
let mappings = config.mapping.getAllMappings()
if mappings.isEmpty {
let item = NSMenuItem(title: " No mappings configured", action: nil, keyEquivalent: "")
item.isEnabled = false
menu.addItem(item)
} else {
for (from, to) in mappings.sorted(by: { $0.0 < $1.0 }) {
let item = NSMenuItem(title: " \(from)\(to)", action: nil, keyEquivalent: "")
item.isEnabled = false
menu.addItem(item)
}
}
menu.addItem(NSMenuItem.separator())
let reloadItem = NSMenuItem(title: "Reload Configuration", action: #selector(reloadConfig), keyEquivalent: "r")
reloadItem.target = self
menu.addItem(reloadItem)
menu.addItem(NSMenuItem.separator())
let openLogsItem = NSMenuItem(title: "Open Logs Folder", action: #selector(openLogsFolder), keyEquivalent: "l")
openLogsItem.target = self
menu.addItem(openLogsItem)
if let _ = Logger.getLogFilePath() {
let viewLogItem = NSMenuItem(title: "View Current Log", action: #selector(viewCurrentLog), keyEquivalent: "")
viewLogItem.target = self
menu.addItem(viewLogItem)
}
menu.addItem(NSMenuItem.separator())
let quitItem = NSMenuItem(title: "Quit", action: #selector(quitApp), keyEquivalent: "q")
quitItem.target = self
menu.addItem(quitItem)
self.statusItem?.menu = menu
}
@objc private func toggleMapping() {
Logger.debug("Toggle mapping clicked, current state: \(eventTapManager.isRunning())")
if eventTapManager.isRunning() {
eventTapManager.stop()
Logger.info("Mapping disabled by user")
} else {
Logger.info("Attempting to enable mapping...")
if eventTapManager.start() {
Logger.info("Mapping enabled by user")
} else {
Logger.error("Failed to enable mapping - check accessibility permissions")
showAlert(title: "Error", message: "Failed to enable key mapping.\n\nPlease ensure:\n1. Accessibility permissions are granted\n2. The app is allowed in System Preferences > Privacy & Security > Accessibility\n\nCheck logs for details.")
}
}
updateMenu()
}
@objc private func reloadConfig() {
Logger.info("Reloading configuration...")
do {
let configPath = Config.getConfigPath()
config = try Config.load(from: configPath)
Logger.logLevel = config.log.level
eventTapManager.reload(config: config)
Logger.info("Configuration reloaded successfully")
updateMenu()
} catch {
Logger.error("Failed to reload configuration: \(error)")
showAlert(title: "Error", message: "Failed to reload configuration: \(error.localizedDescription)")
}
}
@objc private func openLogsFolder() {
let logsDir = Logger.getLogsDirectory()
Logger.info("Opening logs folder: \(logsDir)")
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: logsDir)
}
@objc private func viewCurrentLog() {
if let logPath = Logger.getLogFilePath() {
Logger.info("Opening log file: \(logPath)")
NSWorkspace.shared.openFile(logPath)
}
}
@objc private func quitApp() {
Logger.info("Quitting application")
NSApplication.shared.terminate(nil)
}
private func showAlert(title: String, message: String) {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.alertStyle = .warning
alert.addButton(withTitle: "OK")
alert.runModal()
}
}

9
Sources/main.swift Normal file
View File

@@ -0,0 +1,9 @@
@preconcurrency import Cocoa
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.setActivationPolicy(.accessory)
app.run()