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:
70
.gitignore
vendored
Normal file
70
.gitignore
vendored
Normal 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
14
.qoder/settings.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"ask": [
|
||||||
|
"Read(!./**)",
|
||||||
|
"Edit(!./**)"
|
||||||
|
],
|
||||||
|
"allow": [
|
||||||
|
"Read(./**)",
|
||||||
|
"Edit(./**)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"memoryImport": {},
|
||||||
|
"monitoring": {}
|
||||||
|
}
|
||||||
74
BUILD.md
Normal file
74
BUILD.md
Normal 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
107
DEBUG.md
Normal 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
23
Package.swift
Normal 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
152
README.md
Normal 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
95
Sources/AppDelegate.swift
Normal 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
93
Sources/Config.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
114
Sources/EventTapManager.swift
Normal file
114
Sources/EventTapManager.swift
Normal 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
47
Sources/KeyMapper.swift
Normal 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
85
Sources/Logger.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
147
Sources/StatusBarController.swift
Normal file
147
Sources/StatusBarController.swift
Normal 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
9
Sources/main.swift
Normal 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
81
build-app.sh
Executable 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
81
build-dmg.sh
Executable 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
37
create-icon.sh
Executable 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
BIN
static/uskey.icns
Normal file
Binary file not shown.
BIN
static/uskey.png
Normal file
BIN
static/uskey.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 668 KiB |
Reference in New Issue
Block a user