A native macOS menu-bar translation tool. Click, translate, get back to work.
LingoBar lives in your macOS menu bar. Click the icon — or hit a global hotkey (default ⌥⌘T) — and a popover slides down with an input field already focused. Type, wait 500 ms, and the translation lands in the output field. Hit the hotkey again (or ⌘W, or Esc, or click outside) to dismiss it. That's the whole product: zero configuration to start, one popover to use, never any Dock icon or window clutter.
╭─ menu bar ───────────────────────╮
│ ... [🌐 LingoBar] 🔋 ⌚ 14:23 │
╰─────────────╲────────────────────╯
▼ click / ⌥⌘T
┌─[ Translate │ History │ Settings ]┐
│ Auto-detect ▾ │
│ Bonjour le monde 📋🔊 │
│ ─────────────────────────── 🍎 Apple
│ Auto ▾ │
│ Hello world 📋🔊 │
└───────────────────────────────────┘
- Pure native, no WebView — Swift 6 + AppKit, follows the system light/dark theme, hides from the Dock (
LSUIElement), runs as a normalNSStatusItem. - Zero-config translation — Apple's on-device Translation framework is enabled out of the box; Google Translate is available via its public web endpoint, no API key, no login, no card.
- Engine fallback chain — enable / reorder engines in Settings; on a per-request timeout (default 5 s) or error, the next checked engine takes over and its icon updates in the output header.
- Live, debounced translation — pause typing for ~500 ms and translation fires automatically; stop typing midway and the in-flight request is cancelled.
- History with session aggregation — one burst of typing produces one history row that updates in place, not a flood of partial entries. Stars float favorites to the top; cap is 500 entries with favorites exempt from eviction.
- Pin to keep open — a pin button locks the popover so click-outside /
Escwon't close it, useful while reading or referencing the panel. - Custom in-list reorder — engine rows are reordered with a vertical mouse-tracking drag confined to the list; nothing flies across the screen.
- Global hotkey, customizable — recorder lives inside the Settings tab (powered by KeyboardShortcuts); default
⌥⌘T.
The app is currently distributed unsigned, ad-hoc-codesigned. macOS Gatekeeper will refuse to launch it on first attempt — bypass it once and the app will launch normally afterwards.
- Download the latest
LingoBar.dmgfrom the Releases page and dragLingoBar.appinto/Applications. - In Finder, right-click
LingoBar.app→ Open. - The dialog will now have an Open button — click it. macOS records the exception; future launches don't prompt.
- Double-click
LingoBar.apponce and dismiss the "cannot be opened" dialog. - Open System Settings → Privacy & Security, scroll to the bottom.
- Click Open Anyway next to the LingoBar entry, then confirm in the next dialog.
# After dragging into /Applications:
xattr -cr /Applications/LingoBar.appThis removes the com.apple.quarantine extended attribute Gatekeeper reads. The app then launches like any signed app.
- Open / close — click the menu-bar icon, or press the global hotkey (default
⌥⌘T). Either action toggles the popover; both share one window instance. - Translate — start typing in the input field. The translation runs ~500 ms after you stop typing. The icon on the output header shows which engine actually returned the result (engines are tried top-to-bottom from the list in Settings; any timeout/error falls through to the next).
- Output language "Auto" — Chinese input → English output, anything else → Chinese output. Pick a specific language to lock it.
- Pin — click the pin in the input toolbar to keep the popover open across click-outside /
Esc. Pin state resets on app restart. - Idle retention — close the popover and the input/output/active tab are kept for 3 minutes; reopen within that window and you pick up where you left off. Past 3 minutes, the input clears and the tab resets to Translate.
- History — switch to the History tab to search, star favorites, delete single entries, or click an entry to refill the Translate tab. "Clear all" leaves favorites alone.
- Settings — the right-click menu's Settings… opens the popover directly on the Settings tab. Toggle Launch-at-Login, change the global hotkey, edit the per-request timeout, or check / reorder the engine list (drag a row inside the list to reorder; the dragged row is locked to the vertical axis).
macOS is the only supported development platform. The project ships an Xcode project and uses Swift Package Manager dependencies; no other-platform build instructions are provided.
| Dependency | Version |
|---|---|
| macOS | 26.4.1 |
| Xcode | 26.4.1 |
| Xcode Command Line Tools | 26.4.1 |
This is the environment the author develops and runs the app on, so it's known to build and run end-to-end. Lower versions may work but are not tested — no guarantees if you go below the listed versions.
- Check current version — open Apple menu → About This Mac, or run
sw_vers -productVersionin Terminal. - Upgrade — System Settings → General → Software Update (recommended; Apple's official channel installs the matching system frameworks the project depends on).
- Check whether Xcode is installed — open Spotlight and search for "Xcode", or run
xcode-select -pin Terminal (a path means CLT is registered). - Check the Xcode version —
xcodebuild -version. - Check the CLT version —
pkgutil --pkg-info=com.apple.pkg.CLTools_Executables. - Install Xcode — from the Mac App Store (recommended; auto-updates and bundles the matching CLT).
- Install / register the Command Line Tools standalone —
xcode-select --install. After Xcode is installed, also runsudo xcode-select -s /Applications/Xcode.app/Contents/Developeronce so command-line tools route to Xcode's toolchain. - Upgrade — Mac App Store handles Xcode upgrades; CLT upgrades come down via System Settings → General → Software Update.
Swift 6.3 ships with Xcode 26 — there's no separate Swift toolchain to install. The project's SwiftPM dependency (KeyboardShortcuts) resolves automatically the first time you open the project in Xcode.
# 1. Clone and enter the project
git clone https://github.com/yuman07/LingoBar.git
cd LingoBar
# 2. Open in Xcode — SwiftPM will resolve KeyboardShortcuts on first open
open LingoBar.xcodeproj
# 3. From Xcode: select the LingoBar scheme and press ⌘R to build & run.For a command-line Debug build without opening Xcode:
xcodebuild -project LingoBar.xcodeproj -scheme LingoBar -configuration Debug \
-derivedDataPath build build
open build/Build/Products/Debug/LingoBar.appLingoBar is a single-binary AppKit app that hides from the Dock and renders its UI inside one popover-style NSPanel anchored to the menu-bar status item. The popover hosts a custom segmented tab bar (Translate / History / Settings) with three sibling view controllers; tab state lives on a shared AppState. Translation requests are debounced in TranslationManager, which iterates the user's enabled engines top-to-bottom under a shared timeout, falling through on error and updating the output header's engine icon when one finally returns a result. History writes go to SwiftData with session-scoped aggregation (one burst of typing → one row that updates in place) and a 500-row cap that evicts the oldest non-favorite. Settings live in UserDefaults; the global hotkey is bound through Sindresorhus's KeyboardShortcuts package and updates the menu-item shortcut hint live.
| Concern | Choice |
|---|---|
| Platform | macOS 15.0+ (Apple Silicon only, ARM64) |
| Language | Swift 6 with SWIFT_STRICT_CONCURRENCY = complete |
| UI | AppKit (NSViewController / NSView), no SwiftUI, no WebView |
| Translation engines | Apple Translation framework (on-device); Google translate.googleapis.com public endpoint |
| Persistence | UserDefaults (settings); SwiftData (history) |
| Global hotkey | KeyboardShortcuts by Sindresorhus |
| Login item | ServiceManagement.SMAppService |
| TTS | AVSpeechSynthesizer |
| Distribution | Standalone DMG, non-sandboxed, Developer ID + Notarization (planned) |
flowchart LR
subgraph App["App entry"]
AppDelegate -->|owns| StatusBarController
end
subgraph MenuBar["Menu bar / popover"]
StatusBarController -->|toggles| Panel[StatusBarPopoverPanel]
Panel -->|hosts| Main[MainContentViewController]
Main -->|tabs| Tx[TranslationVC]
Main -->|tabs| Hx[HistoryVC]
Main -->|tabs| Sx[SettingsVC]
end
subgraph State["Shared state"]
AppState
AppSettings
end
Tx <-->|inputText / outputText| AppState
Hx <-->|activeTab / replay| AppState
Sx <-->|engineList / timeout| AppSettings
StatusBarController <-->|isPanelPinned / hotkey| AppState
Tx -->|translateWithDebounce| TM[TranslationManager]
TM -->|iterates active| AppEng[AppleTranslationEngine]
TM -->|fallback| GoogleEng[GoogleTranslationEngine]
AppEng -.->|on-device| AppleFw([Translation.framework])
GoogleEng -.->|HTTPS| GoogleEp([translate.googleapis.com])
TM -->|writes| SwiftData[(SwiftData store)]
Hx -->|reads / mutates| SwiftData
AppSettings -.->|reads / writes| Defaults[(UserDefaults)]
StatusBarController <-.->|registers shortcut| Shortcuts([KeyboardShortcuts])
Tx -->|speak| TTS[TTSService]
TTS -.->|drives| Speech([AVSpeechSynthesizer])
- Main data flow —
TranslationVCwrites the user's text intoAppState.inputText; theTranslationManagersubscriber debounces ~500 ms, iterates the user's active engines under a shared timeout, and writes the winner's text plus engine icon back throughAppState.outputText/currentEngineType. - Engine fallback —
TranslationManagerrunsAppEng → GoogleEng(or whatever order the user configured) sequentially. Each engine has the same per-request timeout fromAppSettings.engineTimeoutSeconds; an error or timeout drops to the next checked engine; an empty input resets the active engine to the first checked one. - External services —
KeyboardShortcutsowns the global hotkey ⇄StatusBarControllerround-trip; the AppleTranslationframework runs on-device and may surface a "language pack not installed" failure that the manager treats as a normal engine failure (next engine takes over). - Persistence —
UserDefaultsstores everything inAppSettings(engine order, enable set, timeout, language preferences, hotkey id); SwiftData backsTranslationRecordfor history and feeds the History tab's search and favorites; the two are deliberately separate so settings load instantly at launch and history never blocks the popover open.
LingoBar/
|-- LingoBarApp.swift # @main, hands off to AppDelegate
|-- AppDelegate.swift # NSApplicationDelegate, app-wide wiring
|-- AppState.swift # @MainActor ObservableObject, transient runtime state
|-- AppSettings.swift # @MainActor, UserDefaults-backed settings
|-- LingoBar.entitlements # network.client only (no sandbox)
|-- Localizable.xcstrings # en + zh-Hans
|-- Models/
| |-- SupportedLanguage.swift # 22 supported language cases + auto
| `-- TranslationRecord.swift # SwiftData @Model for history
|-- Services/
| |-- TranslationEngineProtocol.swift # engine contract + TranslationError
| |-- TranslationManager.swift # debounce, fallback chain, history writes
| |-- AppleTranslationEngine.swift # wraps Translation.framework
| |-- AppleTranslationHost.swift # SwiftUI host needed by the framework
| |-- GoogleTranslationEngine.swift # public translate.googleapis.com client
| `-- TTSService.swift # AVSpeechSynthesizer wrapper
|-- StatusBar/
| |-- StatusBarController.swift # NSStatusItem + popover toggle + right-click menu
| `-- StatusBarPopoverPanel.swift # NSPanel masked into a popover shape
|-- Utilities/
| `-- KeyboardShortcutNames.swift # KeyboardShortcuts.Name registry
`-- Views/
|-- MainContentViewController.swift # tab container + custom segmented control
|-- TranslationViewController.swift # Translate tab
|-- HistoryViewController.swift # History tab (SwiftData-backed)
|-- SettingsViewController.swift # Settings tab top rows + pills
|-- EngineSettingsViewController.swift # engine list + custom in-place reorder drag
|-- LanguagePopUpButton.swift # custom NSPopUpButton for language picker
`-- GrowingTextView.swift # auto-resizing input/output text view
The manager treats the engine list as an ordered, partially-checked sequence. The minimum surface is small enough to spell out:
| Concept | Source | Constraint |
|---|---|---|
| Engine list | AppSettings.engineList |
Always contains every supported engine; user controls order. |
| Enabled engines | AppSettings.enabledEngines |
A subset; the UI guarantees count ≥ 1. |
| Active engine | AppState.currentEngineType |
Reset to the first enabled engine whenever input becomes empty. |
| Per-request timeout | AppSettings.engineTimeoutSeconds (default 5, min 1) |
Shared across engines; applied per attempt, not per chain. |
For each translation attempt:
- Skip non-enabled engines — when iterating
engineList, drop entries that aren't inenabledEngines. Why: the order matters but disabled engines should be invisible to the chain, otherwise reordering UX would be coupled to enable/disable UX. - Start from the active engine — the first engine tried is
currentEngineType. Why: a user mid-session expects translations to keep coming from the engine that just succeeded — switching engines is a "last resort" event, not a default. - Fall through on failure — error or timeout drops to the next enabled engine in
engineListorder. Why: the user's order is also their preference order; respecting it is more predictable than any cleverer policy (cheapest-first, fastest-first), and avoids the manager second-guessing user intent. - Promote the winner — the engine that returned the result becomes the new
currentEngineTypeand its icon shows in the output header. Why: makes the fallback observable and gives the user a passive signal that a "preferred" engine failed; if it's wrong they can fix the order in Settings. - Reset on empty input — when input clears (manually, via the 3-minute retention expiry, or by clicking a history row that resets),
currentEngineTyperesets to the first enabled engine. Why: a fresh translation should start at the user's top preference, not inherit a stale fallback from the previous session. - All-fail surface — if every enabled engine fails, surface
allEnginesFailedin the output area; never silently fall back to a non-enabled engine. Why: the SPEC explicitly forbids hidden fallback to Apple — removing it from the enable list must really remove it from the chain, even if a network engine fails.
Complexity is O(k) per attempt where k is the number of enabled engines (≤ all supported engines, currently 2). The shared timeout lives on the manager so a slow engine can't block the chain longer than engineTimeoutSeconds.
The engine list is reordered without NSDraggingSession, so the dragged row is locked to the vertical axis and can never escape the popover. The flow:
- Hit-test —
EngineRowView.hitTestreturnsselffor any click outside the checkbox, so the row's ownmouseDownfires for handle / icon / label / padding, while clicks on the checkbox still route toNSButton. - Tracking loop —
mouseDowncalls into the parent VC, which captures the start mouse-Y in window coords and the row's flipped origin-Y, then enters awindow.nextEvent(matching: [.leftMouseDragged, .leftMouseUp], inMode: .eventTracking, dequeue: true)loop. - Per-event update — each
mouseDraggedevent computesdy = currentMouseY - startMouseYand setsrow.frame.origin.y = startFrameY - dy, clamped to[0, (count-1) * rowHeight]. Crossing another row's midpoint shifts the visual order array and animates the other rows to their new slots; the dragged row is excluded from the layout pass so its frame isn't fought. - Commit on
mouseUp— the dragged row snaps back to its target slot via an 18 ms animation; on completion the model is mutated throughAppSettings.moveEngine(from:to:). The mutation is deferred so the resulting@Publishednotification doesn't yank the visual mid-snap, and the index conversion handles the "drop above row N" semantics moveEngine expects when moving downward.
The result is a drag that feels like a System-Settings-style reorder but never escapes the panel.
Q: Does Google Translate need an API key?
No. The Google engine talks to
translate.googleapis.com/translate_a/single?client=gtx, the unauthenticated endpoint Chrome's built-in translator uses. There is no key, no login, no card on file. Be aware: it's an undocumented endpoint and could change without notice — that's why fallback to Apple is the default behavior when Google fails.
Q: Why is there no Dock icon?
LingoBar is a menu-bar utility. The
LSUIElementflag inInfo.plistkeeps it out of the Dock and the ⌘-Tab switcher. The status-bar item is the only visible affordance.
Q: Why Apple Silicon only?
The project's
ARCHS = arm64and the SPEC mandates ARM-only distribution. Apple'sTranslationframework also performs better on Apple Silicon's neural engines.
Q: Can I add another translation engine?
Yes — implement
TranslationEngineProtocoland add a case toTranslationEngineType(withdisplayNameandiconName). The Settings UI auto-renders new engines fromallCases. Public, key-less candidates worth considering: MyMemory (official, daily-quota), Lingva (Google proxy), DeepLX (DeepL web reverse-proxy, fragile).
- KeyboardShortcuts by Sindre Sorhus — global hotkey recording.
- Apple's Translation framework — on-device translation engine.
Distributed under the MIT License. © 2026 yuman.


