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

70
.gitignore vendored Normal file
View File

@@ -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

14
.qoder/settings.json Normal file
View File

@@ -0,0 +1,14 @@
{
"permissions": {
"ask": [
"Read(!./**)",
"Edit(!./**)"
],
"allow": [
"Read(./**)",
"Edit(./**)"
]
},
"memoryImport": {},
"monitoring": {}
}

74
BUILD.md Normal file
View File

@@ -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

107
DEBUG.md Normal file
View File

@@ -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
```

23
Package.swift Normal file
View File

@@ -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"])
]),
]
)

152
README.md Normal file
View File

@@ -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 <repository-url>
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

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()

81
build-app.sh Executable file
View File

@@ -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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$APP_NAME</string>
<key>CFBundleIconFile</key>
<string>$APP_NAME</string>
<key>CFBundleIdentifier</key>
<string>com.uskey.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$APP_NAME</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2025. All rights reserved.</string>
</dict>
</plist>
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"

81
build-dmg.sh Executable file
View File

@@ -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"

37
create-icon.sh Executable file
View File

@@ -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"

BIN
static/uskey.icns Normal file

Binary file not shown.

BIN
static/uskey.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 KiB