← Back to project
● Shipped P2 Size S Mac utility

Mac-Translator — Implementation

Tech stack deep-dive: CGEventTap, Accessibility API bridge, NSPanel HUD, permission model, reproducibility.

Implementation

Sister docs: PRD (intent), Architecture (system view), Notes (decision log).

TL;DR

A single-purpose macOS menubar translator built over 2 weekends:

  • Single .app bundle, 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 .pressure events)
  • 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

LayerComponentNotes
LanguageSwift 5Native macOS, single binary
UI (menubar)SwiftUINSStatusItem + Menu
UI (HUD)AppKit NSPanelBorderless, floating, doesn’t steal focus
Global mouse eventsCoreGraphics CGEventTapleftMouseDragged + kCGMouseEventPressure
Global key eventsNSEvent global monitor.keyDown filtered to ⌘⌥T
Selected textAccessibility API (AX)AXFocusedUIElementAXSelectedText
Translation backendDeepL Free APIapi-free.deepl.com/v2/translate
HTTPURLSession3 s timeout, JSON decode
BuildXcode 16 / swift buildAd-hoc sign (codesign --sign -)
Distributiondrop-in .app bundleNo 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:

  1. .cgSessionEventTap (not .cghidEventTap) — session-level for current user
  2. .listenOnly (not .defaultTap) — read-only, doesn’t risk getting disabled for slow handler
  3. Movement filter (< 5 px) prevents press-and-drag from firing
  4. 800 ms debounce — one physical Force Click spans many leftMouseDragged events as pressure ramps
  5. 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:

OperationLatencyNotes
CGEventTap callback dispatch<1 msper leftMouseDragged event
AX selected-text fetch~5 mssystem-wide → focused → selectedText
DeepL API round-trip (median)180 msEU region routing
DeepL API round-trip (p95)~340 ms
NSPanel show + SwiftUI render~25 msfirst-show; subsequent re-uses cached panel
Gesture → HUD visible (total)~220 msForce Click detect + AX + DeepL + render
Dismiss<10 msorderOut + monitor remove

Usage numbers (steady state, month 2)

MetricValue
Daily translations~40
Force Click trigger share78%
⌘⌥T trigger share22%
Avg selection length~50 chars
Monthly DeepL chars used~60K (12% of 500K free quota)
Monthly cost$0

Reliability features

FeatureHow
Permission startup checkLog AX = false / InputMonitoring = denied on launch
Movement filter5 px threshold distinguishes Force Click from press-and-drag
Pressure debounce800 ms ignore after fire — one physical click spans many events
Off-screen HUD clampNSScreen.visibleFrame bounds check
Silent bail on empty selectionNo HUD, no error noise
3 s DeepL timeoutURLSession fails loud rather than spinning
LSUIElement=trueNo Dock tile, menubar-only presence

Security model

ThreatMitigation
API key in binaryStored in Keychain on first run, not embedded in plist
Clipboard exposureAX API does NOT touch NSPasteboard — user’s clipboard untouched
Selected text exfilTLS to api-free.deepl.com; no logging of selections
Arbitrary key captureGlobal monitor only reads (.listenOnly tap; NSEvent global monitor is read-only by OS design)
Ad-hoc sign means anyone can rebuild and impersonateAcceptable 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