Implementation
Sister docs: PRD (intent), Architecture (system view), Notes (decision log).
TL;DR
A single-purpose macOS menubar translator built over 2 weekends:
- Single
.appbundle, ad-hoc signed, no installer, no analytics, no subscription - Force Click anywhere triggers translation of OS-level selection (78% of daily usage)
- ⌘⌥T global chord as fallback (22%)
- Floating NSPanel HUD next to cursor, Escape or any keypress dismisses
- CGEventTap for Force Click pressure (NSEvent global monitor doesn’t see
.pressureevents) - Accessibility API for selected text (no clipboard clobber)
- DeepL Free API backend, ~180 ms median latency, ~$0/month at 40 translations/day
- Time-to-translation gesture→HUD: 220 ms
Stack
| Layer | Component | Notes |
|---|---|---|
| Language | Swift 5 | Native macOS, single binary |
| UI (menubar) | SwiftUI | NSStatusItem + Menu |
| UI (HUD) | AppKit NSPanel | Borderless, floating, doesn’t steal focus |
| Global mouse events | CoreGraphics CGEventTap | leftMouseDragged + kCGMouseEventPressure |
| Global key events | NSEvent global monitor | .keyDown filtered to ⌘⌥T |
| Selected text | Accessibility API (AX) | AXFocusedUIElement → AXSelectedText |
| Translation backend | DeepL Free API | api-free.deepl.com/v2/translate |
| HTTP | URLSession | 3 s timeout, JSON decode |
| Build | Xcode 16 / swift build | Ad-hoc sign (codesign --sign -) |
| Distribution | drop-in .app bundle | No DMG, no notarization |
Directory layout
Mac-Translator/
├── Mac-Translator.app/
│ └── Contents/
│ ├── Info.plist # LSUIElement=true (no Dock), bundle id
│ ├── MacOS/
│ │ └── Mac-Translator # compiled binary
│ └── Resources/
│ └── AppIcon.icns
└── Sources/
├── App.swift # @main, AppDelegate, NSStatusItem setup
├── PermissionGuard.swift # AX + Input Monitoring startup check (~60 lines)
├── ForceClickTap.swift # CGEventTap setup + pressure filter (~120 lines)
├── HotkeyMonitor.swift # NSEvent global monitor for ⌘⌥T (~40 lines)
├── SelectionFetcher.swift # AX bridge → selected text (~80 lines)
├── DeepLClient.swift # URLSession POST, auto-detect, decode (~100 lines)
├── HUDPanel.swift # NSPanel subclass + SwiftUI hosting (~110 lines)
└── HUDView.swift # SwiftUI view for translation text (~50 lines)
Total: ~600 LOC Swift.
Force Click detection (CGEventTap)
The piece that cost 4 hours before it worked:
// ForceClickTap.swift (abridged)
let mask = (1 << CGEventType.leftMouseDragged.rawValue)
let tap = CGEvent.tapCreate(
tap: .cgSessionEventTap,
place: .headInsertEventTap,
options: .listenOnly,
eventsOfInterest: CGEventMask(mask),
callback: { _, type, event, _ in
let pressure = event.getDoubleValueField(.mouseEventPressure)
let location = event.location
// Track cumulative movement since mouseDown to distinguish
// Force Click from press-and-drag
let dx = location.x - State.mouseDownLocation.x
let dy = location.y - State.mouseDownLocation.y
let movement = sqrt(dx*dx + dy*dy)
if pressure >= 0.7 && movement < 5 && !State.recentlyFired {
State.recentlyFired = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
State.recentlyFired = false
}
DispatchQueue.main.async {
ForceClickTap.shared.onForceClick?()
}
}
return Unmanaged.passUnretained(event)
},
userInfo: nil
)
Critical details:
.cgSessionEventTap(not.cghidEventTap) — session-level for current user.listenOnly(not.defaultTap) — read-only, doesn’t risk getting disabled for slow handler- Movement filter (
< 5 px) prevents press-and-drag from firing - 800 ms debounce — one physical Force Click spans many
leftMouseDraggedevents as pressure ramps - Threshold 0.7 — Apple’s stage-2 Force Click threshold is ~0.5; 0.7 reduces false positives
Accessibility API bridge (selected text)
// SelectionFetcher.swift (abridged)
func fetchSelectedText() -> String? {
let systemWide = AXUIElementCreateSystemWide()
var focused: AnyObject?
let err1 = AXUIElementCopyAttributeValue(
systemWide, kAXFocusedUIElementAttribute as CFString, &focused)
guard err1 == .success, let element = focused else { return nil }
var text: AnyObject?
let err2 = AXUIElementCopyAttributeValue(
element as! AXUIElement, kAXSelectedTextAttribute as CFString, &text)
guard err2 == .success, let str = text as? String, !str.isEmpty else {
return nil
}
return str
}
Why AX over forced ⌘C: doesn’t clobber the user’s clipboard. Works in Mail, Notes, Slack, Safari, Messages, PDFs, most native apps. Doesn’t work in some Electron/Chromium apps that don’t expose AX text properly — for those, fall back silently (no HUD).
⌘⌥T fallback (NSEvent global monitor)
// HotkeyMonitor.swift (abridged)
let monitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { event in
let cmdOpt: NSEvent.ModifierFlags = [.command, .option]
let masked = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if masked == cmdOpt && event.keyCode == kVK_ANSI_T {
TranslationFlow.run()
}
}
Note: .pressure events deliberately omitted — global monitor doesn’t deliver them. That’s what CGEventTap is for.
DeepL client
// DeepLClient.swift (abridged)
struct DeepLResponse: Decodable {
struct Translation: Decodable {
let detectedSourceLanguage: String
let text: String
}
let translations: [Translation]
}
func translate(_ text: String) async throws -> (String, String) {
var req = URLRequest(url: URL(string: "https://api-free.deepl.com/v2/translate")!)
req.httpMethod = "POST"
req.setValue("DeepL-Auth-Key \(apiKey)", forHTTPHeaderField: "Authorization")
req.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
req.timeoutInterval = 3.0
// target_lang inferred from detected source (EN→VI, VI→EN, else→EN)
let body = "text=\(text.urlEncoded)&target_lang=\(targetLang)"
req.httpBody = body.data(using: .utf8)
let (data, _) = try await URLSession.shared.data(for: req)
let decoded = try JSONDecoder.deepL.decode(DeepLResponse.self, from: data)
let t = decoded.translations[0]
return (t.text, t.detectedSourceLanguage)
}
Target language rule:
- detected = EN → target VI
- detected = VI → target EN
- else → target EN
HUD positioning
// HUDPanel.swift (abridged)
func show(text: String, at cursor: NSPoint) {
let truncated = text.count <= 280 ? text : String(text.prefix(280)) + "…"
hostingView.rootView = HUDView(text: truncated)
let size = hostingView.fittingSize
var origin = NSPoint(x: cursor.x + 12, y: cursor.y - size.height - 8)
// Clamp inside visible screen
if let screen = NSScreen.screens.first(where: { $0.frame.contains(cursor) }) {
let vis = screen.visibleFrame
origin.x = min(max(origin.x, vis.minX + 8), vis.maxX - size.width - 8)
origin.y = min(max(origin.y, vis.minY + 8), vis.maxY - size.height - 8)
}
setFrame(NSRect(origin: origin, size: size), display: true)
orderFrontRegardless()
installDismissMonitor()
}
Dismiss monitor: local + global keyDown monitor that calls orderOut(nil) on any key and removes itself.
Permission startup guard
// PermissionGuard.swift (abridged)
let opts: NSDictionary = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true]
let axOK = AXIsProcessTrustedWithOptions(opts)
if !axOK {
NSLog("[Mac-Translator] AX = false — Accessibility not granted")
NSLog("[Mac-Translator] Open System Settings → Privacy → Accessibility, toggle ON, then RESTART this app.")
}
let imOK = IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) == kIOHIDAccessTypeGranted
if !imOK {
NSLog("[Mac-Translator] InputMonitoring = denied")
}
The log line is the entire monitoring system. Open Console.app, filter by Mac-Translator, and you immediately see whether permissions are alive.
Performance numbers
Measured on M2 Max MBP, idle conditions:
| Operation | Latency | Notes |
|---|---|---|
| CGEventTap callback dispatch | <1 ms | per leftMouseDragged event |
| AX selected-text fetch | ~5 ms | system-wide → focused → selectedText |
| DeepL API round-trip (median) | 180 ms | EU region routing |
| DeepL API round-trip (p95) | ~340 ms | |
| NSPanel show + SwiftUI render | ~25 ms | first-show; subsequent re-uses cached panel |
| Gesture → HUD visible (total) | ~220 ms | Force Click detect + AX + DeepL + render |
| Dismiss | <10 ms | orderOut + monitor remove |
Usage numbers (steady state, month 2)
| Metric | Value |
|---|---|
| Daily translations | ~40 |
| Force Click trigger share | 78% |
| ⌘⌥T trigger share | 22% |
| Avg selection length | ~50 chars |
| Monthly DeepL chars used | ~60K (12% of 500K free quota) |
| Monthly cost | $0 |
Reliability features
| Feature | How |
|---|---|
| Permission startup check | Log AX = false / InputMonitoring = denied on launch |
| Movement filter | 5 px threshold distinguishes Force Click from press-and-drag |
| Pressure debounce | 800 ms ignore after fire — one physical click spans many events |
| Off-screen HUD clamp | NSScreen.visibleFrame bounds check |
| Silent bail on empty selection | No HUD, no error noise |
| 3 s DeepL timeout | URLSession fails loud rather than spinning |
| LSUIElement=true | No Dock tile, menubar-only presence |
Security model
| Threat | Mitigation |
|---|---|
| API key in binary | Stored in Keychain on first run, not embedded in plist |
| Clipboard exposure | AX API does NOT touch NSPasteboard — user’s clipboard untouched |
| Selected text exfil | TLS to api-free.deepl.com; no logging of selections |
| Arbitrary key capture | Global monitor only reads (.listenOnly tap; NSEvent global monitor is read-only by OS design) |
| Ad-hoc sign means anyone can rebuild and impersonate | Acceptable for personal app; not distributed |
Reproducibility — quickstart for a forker
# 1. Clone repo
git clone https://github.com/<you>/mac-translator.git
cd mac-translator
# 2. Get a DeepL Free API key (free, no card)
# https://www.deepl.com/pro-api → register → copy key
# 3. Open in Xcode (or swift build)
open Mac-Translator.xcodeproj
# 4. Build
xcodebuild -scheme Mac-Translator -configuration Release build
# 5. Ad-hoc sign (Xcode does it by default for personal team)
# Or manually:
# codesign --force --sign - --deep \
# build/Release/Mac-Translator.app
# 6. Copy to /Applications
cp -R build/Release/Mac-Translator.app /Applications/
# 7. Launch
open -a Mac-Translator
# 8. System Settings → Privacy & Security:
# - Accessibility → toggle ON Mac-Translator
# - Input Monitoring → toggle ON Mac-Translator
# 9. *** RESTART THE APP *** (Cmd-Q, then re-launch)
# Watchers registered while AX was false stay dead silently.
# 10. Paste DeepL API key into menubar → Settings → API Key
# 11. Select text anywhere, Force Click, see HUD.
Total: ~10 min if Xcode already installed.
Future work
- TOTP-protected settings (currently API key in Keychain with default ACL)
- Multi-target language picker (currently auto VI↔EN only)
- Apple Translate framework offline fallback (when DeepL quota or network fails)
- Glass-effect HUD (currently flat NSPanel; nice-to-have)
- Developer ID + notarization (only if distributing outside operator’s Mac)
- Last verify: one clean rebuild → permission ritual → smoke test cycle to declare P6 done
License & attribution
Personal project. Built on:
- DeepL API Free tier
- macOS Accessibility framework, Core Graphics event taps, AppKit NSPanel