← Back to project
● Shipped P2 Size S Mac utility

Mac-Translator — Notes

Chronological decision log, macOS permission gotchas, and working-session hours.

Notes & Decision Log

Format: YYYY-MM-DD — context — decision/finding.

Decisions

  • 2026-04-05 — Foundation: a translator that doesn’t break reading flow. Audited existing tools (DeepL desktop, Apple Translate, Translate Tab, PopClip, built-in Sequoia Translate). All require leaving the sentence being read. The pain isn’t translation quality, it’s the context switch.
  • 2026-04-05 — Input gesture audit. Force Click sits unused on every Mac since 2015 (Apple uses it only for Look Up). Reframing it as Translate Selection: one-handed, zero-modal, invisible until needed. Picked as primary trigger; ⌘⌥T chord as fallback for non-trackpad cases.
  • 2026-04-05Kill the window. Initial sketch had a window with history + source-language picker. Killing the window forced three constraints: ≤280 char output, no history UI, no source-language picker. Less surface = more focus.
  • 2026-04-05 (P0) — Swift + AppKit NSStatusItem over Electron. Native single binary, no runtime, sub-100 MB RAM idle.
  • 2026-04-05 (P0) — DeepL Free API over Google Translate or Apple Translate framework. VN↔EN quality materially better; free tier 500K chars/month covers 40 translations/day with 12% utilization.
  • 2026-04-06 (P1) — ⌘⌥T global chord first (easier than CGEventTap), end-to-end smoke test path. NSEvent.addGlobalMonitorForEvents(matching: .keyDown) works trivially.
  • 2026-04-06 (P2) — Accessibility API over simulated ⌘C. Doesn’t clobber the user’s clipboard. AXFocusedUIElementAXSelectedText works in Mail, Notes, Slack, Safari, Messages, PDFs.
  • 2026-04-12 (P3)Force Click via CGEventTap, not NSEvent. NSEvent global monitor silently doesn’t fire for .pressure events. CGEventTap on leftMouseDragged reading kCGMouseEventPressure + a 5px movement filter (to distinguish from press-and-drag) works.
  • 2026-04-12 (P3) — Pressure threshold 0.7 (Apple stage-2 is ~0.5). Higher threshold reduces false positives during regular clicks.
  • 2026-04-12 (P3) — 800 ms debounce after fire. One physical Force Click generates many leftMouseDragged events as pressure ramps 0 → 1; without debounce the same press fires translation 5–8 times.
  • 2026-04-13 (P4) — NSPanel over NSWindow. NSPanel is borderless, no Dock tile, doesn’t steal focus. Position = NSEvent.mouseLocation + (12, -8), clamp inside NSScreen.visibleFrame.
  • 2026-04-13 (P4) — Dismiss on any keypress (not just Escape). Reasoning: if user resumes typing, the HUD is in the way. Escape is the explicit dismiss; any-key is the implicit “I’ve read it, let me get back to work.”
  • 2026-04-19 (P5) — Auto-detect source language via DeepL’s detected_source_language response field. Rule: EN → VI, VI → EN, else → EN. No picker.
  • 2026-04-19 (P5) — ≤280 char output or truncate with ”…”. Selections longer than this almost always mean the user wanted a real translator, not a HUD.
  • 2026-05-20 (P6) — Permission startup guard. AXIsProcessTrustedWithOptions + IOHIDCheckAccess. Log AX = false / InputMonitoring = denied on launch — the entire monitoring system. Console.app + filter is enough.
  • 2026-05-20 — Last verify pending: one full clean rebuild → tccutil reset → re-grant → restart → smoke test cycle still needs to be done before declaring P6 done.

Gotchas

  • P3NSEvent.addGlobalMonitorForEvents(matching: .pressure) silently doesn’t fire. Force Click pressure events are NOT delivered to NSEvent global monitors. Burned 4 hours of “why isn’t my handler called” before discovering this. The fix is CGEventTap on leftMouseDragged with kCGMouseEventPressure + movement filter.
  • P3leftMouseDragged fires constantly during press-and-drag (selecting text). Without a movement filter (<5 px since mouseDown), every drag past pressure 0.7 triggers translation. The cumulative-movement test cleanly distinguishes Force Click from drag-select.
  • P3.cgSessionEventTap vs .cghidEventTap: session is per-user, HID is system-wide. Session is correct for a user app.
  • P3.listenOnly vs .defaultTap: defaultTap can be auto-disabled by macOS if your callback is slow. .listenOnly is read-only and immune. Always use .listenOnly when you don’t need to consume events.
  • P3 — One physical Force Click spans 50+ leftMouseDragged events as pressure ramps. Without an 800 ms debounce, one click fires translation 5–8 times.
  • P6AX / Input Monitoring / Screen Recording grants reset on every ad-hoc rebuild. The codehash (cdhash) changes on every build, and macOS treats the new hash as a different app — silently revoking all previously-granted permissions. You don’t get an error. You get a working build that just doesn’t see global events anymore. Burned 2 evenings before figuring this out.
  • P6 — Fix workflow: tccutil reset Accessibility com.your.app → retoggle in System Settings → restart the app.
  • P6Watchers registered while AX=false stay dead silently after AX granted. Granting Accessibility in System Settings doesn’t retroactively activate event monitors that were created when AX was false. Must explicitly Cmd-Q and re-launch.
  • P6 — Corollary: don’t rebuild between “grant permission” and “test.” The cycle “grant → rebuild → test” looks like the permission was never granted. Always run the same binary you granted the permission to.
  • P6LSUIElement=true in Info.plist hides both the Dock tile AND the window-switcher entry. Correct for a menubar-only utility.
  • P4 — NSPanel needs becomesKeyOnlyIfNeeded = true AND worksWhenModal = true to not steal focus when other apps have modal dialogs. Otherwise the HUD shows but the underlying app’s modal panel disappears.
  • P2 — Accessibility API returns nil for selected text in some Electron/Chromium apps (varies by version). Silent bail (no HUD, no error) is the right UX — don’t pop an error for unsupported apps.

Working-session log

DateHoursWhatOutcome
2026-04-05~2 hFoundation: JTBD, alternatives audit, gesture decisionForce Click picked as primary, window killed
2026-04-05 evening~3 hP0: menubar scaffold + DeepL clientNSStatusItem + on/off toggle + API client class
2026-04-06~2 hP1: ⌘⌥T global chord → HUD smoke testEnd-to-end translation works via keyboard
2026-04-06 evening~3 hP2: Accessibility API selected-text extractionWorks in Mail, Notes, Slack, Safari, Messages, PDFs
2026-04-12 morning~4 hP3 attempt 1: NSEvent .pressure monitorWasted — pressure events don’t reach global monitors
2026-04-12 afternoon~3 hP3 attempt 2: CGEventTapForce Click fires correctly with movement + debounce filters
2026-04-13~3 hP4: NSPanel HUD + cursor positioning + dismissFloating HUD shows next to cursor, Escape/any-key dismisses
2026-04-19~2 hP5: auto-detect language + 280-char truncateEN↔VI flow auto, longer selections truncate
2026-05-15 evening~2 hP6 attempt 1: AX/IM permission debugWasted — kept rebuilding between grant and test
2026-05-16 evening~2 hP6 attempt 2: still rebuilding too eagerlyWasted — same trap, different symptom
2026-05-20~2 hP6: permission guard + restart ritual documentedStartup log line catches grant loss; ritual written down
Total~28 hoursAll 6 phases shippedDaily-driver since week 2

Of those 28 hours, ~10 hours were burned on macOS gotchas (NSEvent vs CGEventTap, permission rebuild trap, dead watchers). If skipping those: the actual product is ~18 hours of work. The blog post exists to save the next builder those 10 hours.