commit 1e8b79585f9d2da00f26714a456f9a4916b65396 Author: loveuer Date: Tue Dec 2 17:51:56 2025 +0800 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e5bc211 --- /dev/null +++ b/.gitignore @@ -0,0 +1,70 @@ +# macOS +.DS_Store +.AppleDouble +.LSOverride +Icon +._* +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Swift Package Manager +.build +.build/ +.swiftpm/ +/Packages +Package.resolved +*.swiftpm + +# Xcode +xcuserdata/ +DerivedData/ +*.xcodeproj +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +*.xcworkspace +!*.xcworkspace/contents.xcworkspacedata +*.xcuserdatad +*.xcuserstate +*.moved-aside +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +# Build artifacts +build/ +dist/ + +# Generated icon files +static/uskey.iconset/ + +# Logs +*.log + +# Crash reports +*.crash +*.ips + +# Editor +.idea/ +.vscode/ +*.swp +*.swo +*~ +.project +.settings/ +*.sublime-workspace +*.sublime-project + +# Temporary +*.tmp +*.temp +.cache/ + +# Credentials +.netrc diff --git a/.qoder/settings.json b/.qoder/settings.json new file mode 100644 index 0000000..0ee38cf --- /dev/null +++ b/.qoder/settings.json @@ -0,0 +1,14 @@ +{ + "permissions": { + "ask": [ + "Read(!./**)", + "Edit(!./**)" + ], + "allow": [ + "Read(./**)", + "Edit(./**)" + ] + }, + "memoryImport": {}, + "monitoring": {} +} \ No newline at end of file diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..084756b --- /dev/null +++ b/BUILD.md @@ -0,0 +1,74 @@ +# Build Scripts + +This directory contains scripts to build and package uskey for distribution. + +## Creating the App Icon + +If you have a custom icon, place a 1024x1024 PNG file at `static/uskey.png`, then run: + +```bash +./create-icon.sh +``` + +This will: +1. Create multiple icon sizes (16px to 1024px) for different display contexts +2. Generate a proper `.icns` file for macOS +3. Output to `static/uskey.icns` + +The build script will automatically use this icon when building the app. + +## Building the App Bundle + +To create a macOS `.app` bundle: + +```bash +./build-app.sh +``` + +This will: +1. Build the release binary using Swift Package Manager +2. Create a proper `.app` bundle structure +3. Add the `Info.plist` with app metadata +4. Copy the app icon to the bundle +5. Set the app to run as a menu bar utility (LSUIElement) + +The resulting app will be at `.build/release/uskey.app` + +## Creating a DMG Installer + +To create a distributable DMG file: + +```bash +./build-dmg.sh +``` + +This will: +1. Create a disk image with the app and Applications folder link +2. Configure the DMG appearance (icon layout) +3. Compress the DMG for distribution + +The resulting DMG will be at `.build/release/uskey-1.0.0.dmg` + +## One-Step Build + +To build both the app and DMG: + +```bash +./build-app.sh && ./build-dmg.sh +``` + +## Distribution + +After building the DMG, you can distribute `uskey-1.0.0.dmg` to users. They can: + +1. Open the DMG file +2. Drag `uskey.app` to the Applications folder +3. Launch from Applications or Spotlight +4. Grant Accessibility permissions when prompted + +## Notes + +- The app is built with `LSUIElement` set to `true`, so it runs as a menu bar-only app (no Dock icon) +- Minimum macOS version is set to 13.0 +- Bundle identifier is `com.uskey.app` +- Icon is automatically included if `static/uskey.icns` exists \ No newline at end of file diff --git a/DEBUG.md b/DEBUG.md new file mode 100644 index 0000000..2795dc3 --- /dev/null +++ b/DEBUG.md @@ -0,0 +1,107 @@ +# Debugging Guide + +## Log Files + +uskey writes detailed logs to help diagnose issues. + +### Log Location + +- Directory: `~/.config/uskey/logs/` +- Current log: `uskey-YYYY-MM-DD.log` (one file per day) + +### Accessing Logs + +**Via Menu Bar:** +1. Click the uskey keyboard icon in the menu bar +2. Select "Open Logs Folder" (⌘L) - Opens Finder at log location +3. Select "View Current Log" - Opens today's log in default text editor + +**Via Terminal:** +```bash +# View today's log +cat ~/.config/uskey/logs/uskey-$(date +%Y-%m-%d).log + +# Follow log in real-time +tail -f ~/.config/uskey/logs/uskey-$(date +%Y-%m-%d).log +``` + +## Log Levels + +Edit `~/.config/uskey/config.json` to change log level: + +```json +{ + "log": { + "level": "debug" + } +} +``` + +Available levels (least to most verbose): +- **error** - Only errors +- **warning** - Warnings and errors +- **info** - General information (default) +- **debug** - Detailed debugging info including every key remap + +After changing the level, select "Reload Configuration" (⌘R) from the menu. + +## Common Debug Scenarios + +### Mapping Not Enabling + +1. Set log level to `debug` +2. Reload configuration +3. Try to enable mapping +4. Check logs for: + ``` + [ERROR] Failed to create event tap + [DEBUG] AXIsProcessTrusted result: false + ``` + +If you see these errors: +- The app doesn't have accessibility permissions +- Go to System Preferences > Privacy & Security > Accessibility +- Add and enable uskey + +### Keys Not Remapping + +1. Set log level to `debug` +2. Press keys that should be remapped +3. Look for debug messages like: + ``` + [DEBUG] Remapping: 42 -> 51 + ``` + +If you don't see these messages: +- Mapping is disabled (enable it from menu) +- Key code is not in your configuration +- Event tap is not working + +### Configuration Issues + +Check logs for: +``` +[ERROR] Failed to load config: ... +[INFO] Creating default configuration... +``` + +This means the config file had issues and was recreated. + +## Reporting Issues + +When reporting issues, include: +1. macOS version +2. Log file with debug level enabled +3. Your config.json +4. Steps to reproduce + +Example: +```bash +# Gather debug info +echo "=== System Info ===" > debug-info.txt +sw_vers >> debug-info.txt +echo -e "\n=== Config ===" >> debug-info.txt +cat ~/.config/uskey/config.json >> debug-info.txt +echo -e "\n=== Logs ===" >> debug-info.txt +cat ~/.config/uskey/logs/uskey-$(date +%Y-%m-%d).log >> debug-info.txt +``` diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..0610e6e --- /dev/null +++ b/Package.swift @@ -0,0 +1,23 @@ +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "uskey", + platforms: [ + .macOS(.v13) + ], + products: [ + .executable(name: "uskey", targets: ["uskey"]) + ], + targets: [ + .executableTarget( + name: "uskey", + swiftSettings: [ + .unsafeFlags(["-Xfrontend", "-disable-availability-checking"]), + .unsafeFlags(["-Xfrontend", "-warn-concurrency"]), + .unsafeFlags(["-Xfrontend", "-enable-actor-data-race-checks"]) + ]), + ] +) \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..77377d7 --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +# uskey + +A macOS utility for remapping keyboard keys. + +## Features + +- Remap any keyboard key to another key +- Lightweight and efficient +- Native macOS integration using CoreGraphics +- JSON-based configuration +- Configurable logging levels + +## Requirements + +- macOS 13.0+ +- Swift 6.0+ + +## Installation + +### Option 1: Download DMG (Recommended) + +1. Download the latest `uskey-x.x.x.dmg` from releases +2. Open the DMG file +3. Drag `uskey.app` to the Applications folder +4. Launch from Applications or Spotlight +5. Grant Accessibility permissions when prompted + +### Option 2: Build from Source + +```bash +git clone +cd uskey +./build-app.sh && ./build-dmg.sh +``` + +The DMG will be created at `.build/release/uskey-1.0.0.dmg` + +For detailed build instructions, see [BUILD.md](BUILD.md) + +## Configuration + +The configuration file is automatically created at `~/.config/uskey/config.json` on first run. + +### Configuration Format + +```json +{ + "log": { + "level": "info" + }, + "mapping": { + "backslash2backspace": { + "from": 42, + "to": 51 + }, + "backspace2backslash": { + "from": 51, + "to": 42 + } + } +} +``` + +### Log Levels + +- `debug` - Detailed debugging information including every key remap +- `info` - General information messages (default) +- `warning` - Warning messages +- `error` - Error messages only + +### Key Codes + +Common macOS key codes: +- Backspace: 51 +- Backslash `\`: 42 +- Enter: 36 +- Tab: 48 +- Space: 49 + +You can add custom mappings by editing the config file and restarting the application. + +## Usage + +```bash +uskey +``` + +**Important:** On first run, you'll be prompted to grant Accessibility permissions in System Preferences > Privacy & Security > Accessibility. + +## Troubleshooting + +### Enable Mapping Doesn't Work + +If you can't enable mapping after installation: + +1. **Check Accessibility Permissions** + - Open System Preferences > Privacy & Security > Accessibility + - Ensure `uskey` is in the list and checked + - If not, click the `+` button and add the app + - After granting permissions, restart the app + +2. **Check Logs** + - Click the uskey menu bar icon + - Select "Open Logs Folder" (⌘L) + - Open the latest log file (e.g., `uskey-2025-12-02.log`) + - Look for ERROR messages + +3. **Enable Debug Logging** + - Edit `~/.config/uskey/config.json` + - Change `"level": "info"` to `"level": "debug"` + - Click "Reload Configuration" (⌘R) in the menu + - Try enabling mapping again + - Check logs for detailed debug information + +### Log Files Location + +Logs are stored at: `~/.config/uskey/logs/uskey-YYYY-MM-DD.log` + +You can view logs by: +- **Menu Bar**: Click uskey icon → "View Current Log" +- **Finder**: Click uskey icon → "Open Logs Folder" (⌘L) +- **Terminal**: `tail -f ~/.config/uskey/logs/uskey-$(date +%Y-%m-%d).log` + +### Common Issues + +**"Failed to create event tap"** +- Cause: Missing accessibility permissions +- Solution: Grant accessibility permissions and restart the app + +**Configuration not found** +- Cause: Config file doesn't exist +- Solution: The app will auto-create it at `~/.config/uskey/config.json` + +**Mapping not working** +- Cause: Event tap is not enabled +- Solution: Check logs and ensure accessibility permissions are granted + +## Development + +Build the project: +```bash +swift build +``` + +Run in debug mode: +```bash +swift run +``` + +## License + +MIT \ No newline at end of file diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift new file mode 100644 index 0000000..65595bd --- /dev/null +++ b/Sources/AppDelegate.swift @@ -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() + } +} \ No newline at end of file diff --git a/Sources/Config.swift b/Sources/Config.swift new file mode 100644 index 0000000..9aed78d --- /dev/null +++ b/Sources/Config.swift @@ -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 + } +} diff --git a/Sources/EventTapManager.swift b/Sources/EventTapManager.swift new file mode 100644 index 0000000..d5d4abb --- /dev/null +++ b/Sources/EventTapManager.swift @@ -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.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() + } + } +} \ No newline at end of file diff --git a/Sources/KeyMapper.swift b/Sources/KeyMapper.swift new file mode 100644 index 0000000..a308902 --- /dev/null +++ b/Sources/KeyMapper.swift @@ -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("") + } +} \ No newline at end of file diff --git a/Sources/Logger.swift b/Sources/Logger.swift new file mode 100644 index 0000000..801f72d --- /dev/null +++ b/Sources/Logger.swift @@ -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() + } +} diff --git a/Sources/StatusBarController.swift b/Sources/StatusBarController.swift new file mode 100644 index 0000000..5465950 --- /dev/null +++ b/Sources/StatusBarController.swift @@ -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() + } +} \ No newline at end of file diff --git a/Sources/main.swift b/Sources/main.swift new file mode 100644 index 0000000..a9f4734 --- /dev/null +++ b/Sources/main.swift @@ -0,0 +1,9 @@ +@preconcurrency import Cocoa + +let app = NSApplication.shared +let delegate = AppDelegate() +app.delegate = delegate + +app.setActivationPolicy(.accessory) + +app.run() diff --git a/build-app.sh b/build-app.sh new file mode 100755 index 0000000..db7736b --- /dev/null +++ b/build-app.sh @@ -0,0 +1,81 @@ +#!/bin/bash +set -e + +echo "Building uskey.app..." + +APP_NAME="uskey" +BUILD_DIR=".build/release" +APP_DIR="$BUILD_DIR/$APP_NAME.app" +CONTENTS_DIR="$APP_DIR/Contents" +MACOS_DIR="$CONTENTS_DIR/MacOS" +RESOURCES_DIR="$CONTENTS_DIR/Resources" +ICON_FILE="static/uskey.icns" + +echo "Step 1: Building release binary..." +swift build -c release + +echo "Step 2: Creating app icon (if needed)..." +if [ ! -f "$ICON_FILE" ]; then + echo " Icon not found, creating from PNG..." + ./create-icon.sh +fi + +echo "Step 3: Creating app bundle structure..." +rm -rf "$APP_DIR" +mkdir -p "$MACOS_DIR" +mkdir -p "$RESOURCES_DIR" + +echo "Step 4: Copying binary..." +cp "$BUILD_DIR/$APP_NAME" "$MACOS_DIR/$APP_NAME" + +echo "Step 5: Copying icon..." +if [ -f "$ICON_FILE" ]; then + cp "$ICON_FILE" "$RESOURCES_DIR/$APP_NAME.icns" + echo " Icon copied successfully" +else + echo " Warning: Icon file not found, skipping..." +fi + +echo "Step 6: Creating Info.plist..." +cat > "$CONTENTS_DIR/Info.plist" << EOF + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $APP_NAME + CFBundleIconFile + $APP_NAME + CFBundleIdentifier + com.uskey.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $APP_NAME + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 13.0 + LSUIElement + + NSHighResolutionCapable + + NSHumanReadableCopyright + Copyright © 2025. All rights reserved. + + +EOF + +echo "Step 7: Setting permissions..." +chmod +x "$MACOS_DIR/$APP_NAME" + +echo "" +echo "✅ App bundle created at: $APP_DIR" +echo "" +echo "To create DMG, run: ./build-dmg.sh" \ No newline at end of file diff --git a/build-dmg.sh b/build-dmg.sh new file mode 100755 index 0000000..c8a9b3c --- /dev/null +++ b/build-dmg.sh @@ -0,0 +1,81 @@ +#!/bin/bash +set -e + +APP_NAME="uskey" +VERSION="1.0.0" +BUILD_DIR=".build/release" +APP_DIR="$BUILD_DIR/$APP_NAME.app" +DMG_DIR=".build/dmg" +DMG_NAME="$APP_NAME-$VERSION.dmg" +DMG_TEMP="$DMG_DIR/temp.dmg" +DMG_FINAL="$BUILD_DIR/$DMG_NAME" + +if [ ! -d "$APP_DIR" ]; then + echo "Error: App bundle not found. Run ./build-app.sh first." + exit 1 +fi + +echo "Creating DMG for $APP_NAME..." + +echo "Step 1: Preparing DMG directory..." +rm -rf "$DMG_DIR" +mkdir -p "$DMG_DIR" + +echo "Step 2: Copying app to DMG directory..." +cp -R "$APP_DIR" "$DMG_DIR/" + +echo "Step 3: Creating Applications symlink..." +ln -s /Applications "$DMG_DIR/Applications" + +echo "Step 4: Creating temporary DMG..." +hdiutil create -volname "$APP_NAME" \ + -srcfolder "$DMG_DIR" \ + -ov -format UDRW \ + "$DMG_TEMP" + +echo "Step 5: Mounting temporary DMG..." +MOUNT_DIR=$(hdiutil attach "$DMG_TEMP" | grep Volumes | awk '{print $3}') + +echo "Step 6: Setting DMG appearance..." +echo ' +tell application "Finder" + tell disk "'$APP_NAME'" + open + set current view of container window to icon view + set toolbar visible of container window to false + set statusbar visible of container window to false + set the bounds of container window to {400, 100, 900, 400} + set viewOptions to the icon view options of container window + set arrangement of viewOptions to not arranged + set icon size of viewOptions to 72 + set position of item "'$APP_NAME'.app" of container window to {125, 150} + set position of item "Applications" of container window to {375, 150} + update without registering applications + delay 1 + end tell +end tell +' | osascript || true + +echo "Step 7: Unmounting temporary DMG..." +hdiutil detach "$MOUNT_DIR" || true +sleep 2 + +echo "Step 8: Converting to compressed DMG..." +rm -f "$DMG_FINAL" +hdiutil convert "$DMG_TEMP" \ + -format UDZO \ + -imagekey zlib-level=9 \ + -o "$DMG_FINAL" + +echo "Step 9: Cleaning up..." +rm -rf "$DMG_DIR" + +echo "" +echo "✅ DMG created successfully!" +echo " Location: $DMG_FINAL" +echo " Size: $(du -h "$DMG_FINAL" | cut -f1)" +echo "" +echo "To install:" +echo " 1. Open $DMG_FINAL" +echo " 2. Drag $APP_NAME.app to Applications folder" +echo " 3. Run from Applications or Spotlight" diff --git a/create-icon.sh b/create-icon.sh new file mode 100755 index 0000000..27f290b --- /dev/null +++ b/create-icon.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -e + +SOURCE_IMAGE="static/uskey.png" +ICONSET_DIR="static/uskey.iconset" + +if [ ! -f "$SOURCE_IMAGE" ]; then + echo "Error: Source image not found at $SOURCE_IMAGE" + exit 1 +fi + +echo "Creating app icon from $SOURCE_IMAGE..." + +echo "Step 1: Creating iconset directory..." +rm -rf "$ICONSET_DIR" +mkdir -p "$ICONSET_DIR" + +echo "Step 2: Generating icon sizes..." +sips -z 16 16 "$SOURCE_IMAGE" --out "$ICONSET_DIR/icon_16x16.png" > /dev/null +sips -z 32 32 "$SOURCE_IMAGE" --out "$ICONSET_DIR/icon_16x16@2x.png" > /dev/null +sips -z 32 32 "$SOURCE_IMAGE" --out "$ICONSET_DIR/icon_32x32.png" > /dev/null +sips -z 64 64 "$SOURCE_IMAGE" --out "$ICONSET_DIR/icon_32x32@2x.png" > /dev/null +sips -z 128 128 "$SOURCE_IMAGE" --out "$ICONSET_DIR/icon_128x128.png" > /dev/null +sips -z 256 256 "$SOURCE_IMAGE" --out "$ICONSET_DIR/icon_128x128@2x.png" > /dev/null +sips -z 256 256 "$SOURCE_IMAGE" --out "$ICONSET_DIR/icon_256x256.png" > /dev/null +sips -z 512 512 "$SOURCE_IMAGE" --out "$ICONSET_DIR/icon_256x256@2x.png" > /dev/null +sips -z 512 512 "$SOURCE_IMAGE" --out "$ICONSET_DIR/icon_512x512.png" > /dev/null +sips -z 1024 1024 "$SOURCE_IMAGE" --out "$ICONSET_DIR/icon_512x512@2x.png" > /dev/null + +echo "Step 3: Converting to .icns format..." +iconutil -c icns "$ICONSET_DIR" -o static/uskey.icns + +echo "Step 4: Cleaning up..." +rm -rf "$ICONSET_DIR" + +echo "" +echo "✅ Icon created successfully at: static/uskey.icns" diff --git a/static/uskey.icns b/static/uskey.icns new file mode 100644 index 0000000..93b1347 Binary files /dev/null and b/static/uskey.icns differ diff --git a/static/uskey.png b/static/uskey.png new file mode 100644 index 0000000..28b94f2 Binary files /dev/null and b/static/uskey.png differ