Architecture
Sister docs: PRD (intent), Implementation (deep-dive), Notes (decision log).
System view
flowchart TB
classDef input fill:#cce0e8,stroke:#1a1a1d,color:#1a1a1d,stroke-width:2px
classDef app fill:#faedd6,stroke:#1a1a1d,color:#1a1a1d,stroke-width:2px
classDef os fill:#e0d5ed,stroke:#1a1a1d,color:#1a1a1d,stroke-width:2px
classDef ext fill:#f4d6db,stroke:#1a1a1d,color:#1a1a1d,stroke-width:2px
subgraph User["👆 User input"]
FC["Force Click
(Force Touch trackpad)"]
Key["⌘⌥T chord
(any keyboard)"]
end
subgraph macOS["🍎 macOS event layer"]
Tap["CGEventTap
leftMouseDragged
+ kCGMouseEventPressure"]
Mon["NSEvent global monitor
(key events only)"]
AX["Accessibility API
AXFocusedUIElement
AXSelectedText"]
end
FC --> Tap
Key --> Mon
subgraph App["📦 Mac-Translator.app (ad-hoc signed)"]
Trig["Trigger dispatcher
(pressure ≥0.7 + movement filter)"]
Sel["Selection fetcher
(AX bridge)"]
Client["DeepL API client
(URLSession)"]
HUD["NSPanel HUD
(borderless, floating)"]
Status["NSStatusItem
menubar icon"]
Guard["Startup permission check
(AX + Input Monitoring)"]
end
Tap --> Trig
Mon --> Trig
Trig --> Sel
Sel --> AX
AX --> Sel
Sel --> Client
Client --> HUD
Status --> Trig
subgraph Ext["☁️ External"]
DeepL["DeepL Free API
api-free.deepl.com"]
end
Client -.HTTPS POST.-> DeepL
DeepL -.JSON.-> Client
Guard -.startup log.-> Status
class FC,Key input
class Tap,Mon,AX os
class Trig,Sel,Client,HUD,Status,Guard app
class DeepL ext
Event flow — Force Click trigger
[1] User Force Clicks on trackpad while text is selected
│
▼
[2] macOS dispatches leftMouseDragged events with kCGMouseEventPressure values
(pressure ramps 0.0 → 1.0 as user presses)
│
▼
[3] CGEventTap callback fires for every dragged event:
- Read pressure = CGEventGetDoubleValueField(event, kCGMouseEventPressure)
- If pressure ≥ 0.7 AND cumulative movement < 5px since mouseDown:
→ classify as Force Click (not press-and-drag)
- Else: pass through, no action
│
▼
[4] Debounce: ignore subsequent pressure ≥0.7 for 800ms
(one Force Click can span 50+ dragged events)
│
▼
[5] Selection fetcher (AX bridge):
- systemWideElement = AXUIElementCreateSystemWide()
- AXUIElementCopyAttributeValue(systemWide, kAXFocusedUIElementAttribute, &focused)
- AXUIElementCopyAttributeValue(focused, kAXSelectedTextAttribute, &text)
- If text nil OR len(text) == 0 → bail silently (no HUD)
│
▼
[6] DeepL API client:
- URLSession POST https://api-free.deepl.com/v2/translate
Authorization: DeepL-Auth-Key <key>
body: text=<selection>&target_lang=VI (or EN depending on detected source)
- Timeout 3s, fail loud (HUD shows "translation failed")
│
▼
[7] NSPanel HUD:
- frame.origin = NSEvent.mouseLocation + (12, -8)
- clamp inside screen bounds
- panel.becomesKeyOnlyIfNeeded = true (doesn't steal focus)
- panel.level = .floating
- SwiftUI view: translation text, ≤280 chars or truncate "…"
│
▼
[8] Local event monitor on HUD:
- .keyDown (any key) → dismiss
- special-case Escape → dismiss
- On dismiss: panel.orderOut, monitor removed
Event flow — ⌘⌥T fallback
[1] NSEvent.addGlobalMonitorForEvents(matching: .keyDown) fires for every keydown
│
▼
[2] Filter: modifierFlags == [.command, .option] AND keyCode == kVK_ANSI_T
│
▼
[3] → Same flow from step [5] above (selection fetcher → DeepL → HUD)
Why two trigger paths: Force Click pressure events are NOT delivered to NSEvent global monitors. Keyboard events ARE. So Force Click needs CGEventTap, ⌘⌥T uses the simpler NSEvent monitor.
Permission flow (first launch)
[1] App launch
│
▼
[2] Startup permission check (Guard):
- AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true])
→ if false: System Settings opens to Privacy → Accessibility
→ log "AX = false" to console
- IOHIDCheckAccess(kIOHIDRequestTypeListenEvent)
→ if denied: System Settings → Input Monitoring
→ log "InputMonitoring = denied"
│
▼
[3] User toggles permissions ON in System Settings
│
▼
[4] ⚠️ App must be RESTARTED — event watchers registered while AX was false
stay dead silently after AX is granted
│
▼
[5] On restart:
- CGEventTap created (now AX=true, tap actually fires)
- NSEvent global monitor created
- HUD ready
Component responsibilities
| Component | Owns | Doesn’t own |
|---|
| CGEventTap | Force Click pressure detection, movement filter | Selection extraction, translation |
| NSEvent global monitor | ⌘⌥T keyboard chord detection | Mouse events (Force Click) |
| Selection fetcher (AX bridge) | Pulling selected text via Accessibility API | Triggering, translation, display |
| DeepL API client | HTTPS round-trip, auto-detect source language, timeout | What text to translate, where to show result |
| NSPanel HUD | Borderless floating window, cursor positioning, Escape/keypress dismiss | Translation logic, gesture handling |
| NSStatusItem (menubar) | On/off toggle, quit, settings entry | Translation flow |
| Startup permission guard | Detecting AX + Input Monitoring grants, prompting | Restart prompt (user must do manually) |
Failure modes & recovery
| Failure | Detect | Recovery | Time |
|---|
| AX permission revoked (after rebuild) | Startup log “AX = false” | tccutil reset Accessibility com.your.app → retoggle → restart app | ~30s |
| Input Monitoring revoked | Startup log “InputMonitoring = denied” | Retoggle in System Settings → restart | ~30s |
| Force Click not firing | CGEventTap callback never reached | Verify Input Monitoring grant; check tap wasn’t disabled by timeout (re-enable) | ~10s |
| DeepL API timeout | URLSession 3s timeout | HUD shows “translation failed”; user retries | n/a |
| DeepL quota exceeded | HTTP 456 response | HUD shows “quota exceeded”; defer until next month | until reset |
| Selected text is nil (focus on non-AX-cooperative app) | AX bridge returns empty | Silent bail, no HUD | n/a |
| HUD off-screen (cursor near edge) | NSScreen clamp logic | HUD position clamped to visibleFrame | automatic |
| App rebuild between grant and test | Permission silently revoked, watchers dead | Document ritual: never rebuild between grant and test | n/a |
Why these choices
| Decision | Alternative considered | Why this won |
|---|
| CGEventTap over NSEvent global monitor for Force Click | NSEvent.addGlobalMonitorForEvents(matching: .pressure) | NSEvent global monitor silently doesn’t fire for .pressure events. Cost 4 hours of “why isn’t my handler called” before discovering this |
| AX API over forcing ⌘C | Simulated ⌘C + read NSPasteboard | Clobbers the user’s clipboard; intrusive; doesn’t work in protected apps |
| NSPanel over NSWindow | NSWindow | NSPanel is borderless, no Dock tile, doesn’t steal focus, perfect for HUDs |
| DeepL over Apple Translate framework | Apple Translate (offline) | DeepL quality on VN↔EN materially better; offline fallback deferred |
| Cursor-anchored HUD over screen-centered | Centered modal | Centered = “modal panel = visit an app”. Cursor-anchored = “translation lives where you’re reading” |
| Single .app over installer | DMG with installer | Personal app, drop-in install is enough; no auto-update needed |
| Ad-hoc sign over Developer ID | Apple Developer Program ($99/y) | Personal use, accepts permission rebuild cost in exchange |
| Force Click primary, ⌘⌥T fallback | ⌘⌥T only | Gesture is the product (see PRD §5); chord covers non-trackpad cases |
See also
- Implementation — code structure, CGEventTap details, AX bridge code
- Notes — the 6 hours of macOS gotchas