Однажды — в одном немецкоязычном банке, в одном проекте, где аналитики писали спеки в виде формул, — я встретил Берта (имя изменено). Очень грамотного и умного математика-разработчика, но с совершенно расшатанными нервами и, видимо, с какой-то серьёзной психологической травмой. Когда где-то в опен-спейсе хлопала дверь, кто-то громко разговаривал или — не дай бог! — смеялся, он внезапно вскакивал с места, зыркал глазами в сторону провинившихся, топал ногами, злобно шипел и иногда даже подвывал. Вокруг него менялись люди: не все выдерживали — увольнялись, перебегали в другие команды, забивались куда-то подальше в норы от этого льва. Я сидел прямо напротив него, смотрел ему в глаза — без тени сожаления и возмущения — и просто продолжал невозмутимо пилить код. Он мне совершенно не мешал и не раздражал: всё происходило где-то за гранью внимания. А Берт через пару лет начал меня уважать за выдержку, мы даже интересно общались на профессиональные темы.
Но те времена давно прошли. Двадцать лет мне не нужно было переключать раскладку — писал на английском и немецком. А теперь надо снова много писать по-русски, и я вспомнил Берта. Я стал вести себя как он! Нашлёпал два предложения с объяснениями, почему всё так плохо и пошло не так, кто виноват, кого наказать и что вообще теперь делать. Поднимаешь глаза — «edjkbnm ytkmpz jcnfdbnm»: академическим слогом эмоции не передать, и эмодзи для них не найти. Или наоборот — набираешь в терминале команду «пшпфсщву» (gigacode) или даже «пше» (git), какое ещё к чёрту пше? …и снова в голове топает ногами Берт.
В общем, я написал своё. Не то чтобы прямо сам-сам, конечно, мне помогал один товарищ по имени Клод, но я генерил идеи, делал проверки на живой системе — словом, затачивал инструмент под себя и свою работу. LatCyr — открытое приложение в строке меню: само понимает, что слово набрано не в той раскладке, исправляет его и переключает раскладку. Работает в обе стороны, в терминалах (в том числе в терминальных панелях VS Code и Claude Desktop — их приложение надо один раз отметить в меню) и не трогает пароли. Swift, без Xcode, 160 тестов.

Что умеет
Сначала коротко, потом самое интересное — как оно внутри.
По слову, набранному не в той раскладке, LatCyr делает две вещи: исправляет слово и переключает раскладку, так что дальше печатаешь уже нормально. Срабатывает либо на границе слова (пробел, запятая, Enter — «нщгегиуюсщь.» превращается в «youtube.com.»; в адресной строке браузера тоже работает), либо раньше: если по первым двум буквам понятно, что раскладка не та, переключает сразу, не дожидаясь конца слова.
Отдельно — то, чего обычно в таких утилитах нет:
Терминалы. Terminal.app и iTerm2 — сразу, терминальные панели внутри Electron-приложений — после того как приложение один раз отмечено в меню как «гибридное». «св» →
cd, «ды» →ls— и ещё с десяток коротких команд из встроенного списка (rm,mv,ps,vi…), и это реально выполняется шеллом.Пароли. Не трогает ни поля паролей, ни пароль sudo в терминале — об этом ниже отдельная история, потому что там интересно.
Домены. «фьфящтюву» →
amazon.de, хотя статистически это подозрительно похоже на русское слово.Мелочи вроде «d ujhjl» → «в город» — одно- и двухбуквенные служебные слова, которые сами по себе не детектируются, подтягиваются за следующим словом.
Всё локально. Приложение само ничего не отправляет и не скачивает (наружу ведут только ссылки из меню, которые открываются в браузере), набранный текст даже в лог не пишется (ниже расскажу, как именно не пишется — это оказалось отдельной задачей).
А чем плохи Punto и Caramba?
Ничем — это хорошие программы, и честнее сказать, чем LatCyr от них отличается, чем делать вид, что их нет.
Punto Switcher — классика от Яндекса, бесплатный. Но Mac-версия живёт своей жизнью: на Windows вышла уже 4.5, на Mac — 2.1.5, и обновляется она редко. Код закрыт.
Caramba Switcher — продолжение Punto от его же автора, Сергея Москалёва: после ухода из Яндекса он сделал новый переключатель с самообучающейся моделью. На Mac продаётся через App Store по годовой лицензии — несколько долларов в год после пробного периода. Активно развивается: исправление опечаток, «ё», списки исключений слов и приложений, еженедельные обновления языковой модели. Код закрыт.
Punto Switcher | Caramba Switcher | LatCyr | |
|---|---|---|---|
Цена | бесплатно | годовая лицензия после пробного периода | бесплатно |
Исходный код | закрыт | закрыт | открыт (Apache 2.0) |
Сеть | — | обновления модели скачиваются | приложение само ничего не отправляет и не скачивает |
Терминалы | — | в IDE и средах разработки обычно отключается | Terminal, iTerm2, панели xterm.js; короткие команды ( |
Пароль sudo в терминале | — | на длинный пароль из разных классов символов переключается на английский | по режиму tty не трогает вообще |
Исправление опечаток, «ё», автозамена | автозамена | да | нет |
Ручная конвертация выделенного | да | да (двойной Shift) | нет |
Список исключённых приложений, настройки | да | да | нет: только слова-исключения и отметка «гибридное» |
Прочерк значит «в описании не нашёл», а не «точно нет».
Из открытых для macOS есть ещё RuSwitcher — про него недавно писали здесь же, на Хабре. Он устроен иначе: слово или выделенный текст конвертируется по нажатию Alt, а LatCyr определяет раскладку сам и работает в терминале.
Если коротко: Caramba — более зрелый продукт для набора обычного текста, и если вы не живёте в терминале, он вполне может оказаться удобнее. LatCyr вырос из другой боли — из терминала, где автопереключатели обычно просто выключают, — и из нежелания пускать закрытый код к каждой своей клавише.
Почему «просто перехватывай клавиши» не работает
Задача выглядит как вечер пет-проджекта: лови keyDown, собирай слово, проверь по словарю, замени. Спойлер: на каждом шаге macOS подкидывает отдельный квест. Вот те, что запомнились.
Enter, который уезжает раньше тебя
Первую версию я сделал честно: event tap в режиме «только слушать» (.listenOnly), чтобы не мешать системе. Всё работало, пока я не заметил странное в Safari. Печатаешь адрес не в той раскладке, жмёшь Enter — адресная строка показывает уже исправленный текст, а страница открывается по старому, кривому.
Причина простая, когда её знаешь. В режиме «слушать» твой callback получает копию события, а оригинал система доставляет приложению параллельно. Для букв это не видно, но Enter не печатает символ — он запускает действие. Исправил я строку, а навигация уже уехала.
Поэтому tap стал активным (.defaultTap): если на Enter сработала коррекция, клавиша проглатывается, слово правится, Enter отправляется заново. Цена — теперь вся система ждёт твой callback на каждой клавише. Это дисциплинирует: внутри обработчика нельзя ни файл открыть, ни строку отформатировать — всё, без чего можно обойтись, уезжает в асинхрон, логирование тоже. Без чего обойтись нельзя — сама замена слова на Enter: она обязана закончиться до того, как Enter уйдёт приложению, так что AX-вызов остаётся прямо в callback’е. Это осознанный компромисс, и у него есть страховка: если callback выполняется слишком долго, система молча отключает tap (.tapDisabledByTimeout) — меню продолжает показывать «включено», а клавиши больше не приходят. Поэтому первой строкой обработчика ловим это событие и включаем tap обратно.
Исправлять слово рядом с человеком, который печатает дальше
Вторая засада. Слова правятся через Accessibility API, и наивная реализация ищет слово назад от текущей позиции курсора. Между «пользователь нажал пробел» и «мы применили правку» есть маленькая задержка (50 мс — даём приложению напечатать сам пробел). Печатаешь без паузы — и за эти 50 мс курсор уже уехал в следующее слово. Поиск находит не то, правка тихо отменяется, в логах пусто, выглядит как «иногда не срабатывает».
Лечение — «якорь». В момент границы слова, синхронно, до всяких задержек, запоминаем, где слово лежит на самом деле: элемент и диапазон. Правка через 50 мс идёт по якорю, а не по «где там сейчас курсор».
Терминал — не текстовое поле
В Terminal.app и iTerm2 то, что ты видишь, — рендер pty. Редактируемой строки там нет: можно записать текст через AX API, получить «успех» и ровно ничего не поменять. Так что в терминалах — другой путь: синтетические нажатия. Backspace N раз, потом один Unicode-ввод с исправленным словом.
Мелкий, но важный трюк: свои же синтетические нажатия помечаются маркером, и перехватчик их игнорирует. Иначе они долетали бы обратно в tap, портили буфер и вызывали коррекцию коррекции.
Чего там нет — так это поддержки vim в normal mode и всяких less/htop: там буквы — это команды, и слепо жать за пользователя нельзя. Это осознанный компромисс, честно написан в README.
Пароль sudo. Как понять, что это пароль?
Самое страшное в проекте. Утилита перехватывает каждую клавишу и умеет перепечатывать слова. Теперь представь: твой пароль случайно похож на слово в чужой раскладке. Вводишь его вслепую — утилита радостно «исправляет»: стирает введённое и впечатывает конверсию. На экране ничего не видно (эхо выключено), просто sudo говорит «Sorry, try again» — а ты ведь точно набрал правильно. Пара таких попыток подряд — и можно упереться в блокировку учётки.
В обычных полях всё решает роль AXSecureTextField — их пропускаем, включая браузерные поля пароля, которые отдают защищённость сабролем. А в терминале роли для текста pty не существует вообще. Шелл не включает Secure Event Input. Для перехватчика пароль sudo неотличим от обычных слов.
Тут помогло не думать, а измерить. Я взял tcgetattr и обошёл живые терминалы в разных состояниях. Результат:
Что в терминале | ECHO | ICANON |
|---|---|---|
sudo / ssh / | выкл | вкл |
Шелл в ожидании команды (zsh) | выкл | выкл |
vim (normal mode), less | выкл | выкл |
| вкл | вкл |
Всё, что читает пароль, выключает эхо (понятно), но оставляет канонический режим — построчное редактирование, чтобы Backspace в пароле работал. Это и есть формальная подпись. Правило влезает в одну строку:
localFlags & tcflag_t(ECHO) == 0 && localFlags & tcflag_t(ICANON) != 0
Дальше три следствия.
Первое: проверяем все tty пользователя, а не активный. Какой /dev/ttys* стоит за этой вкладкой, знает не каждый терминал, а панели tmux вообще принадлежат серверу tmux. Поэтому опрашиваем все /dev/ttys* с нашим uid. Пароль, ожидающий в соседнем окне, приостанавливает правки и тут — перестраховка в правильную сторону.
Второе: проверка — только перед самой правкой, не на каждой клавише. Один tty опрашивается за ~0.1 мс, но callback от клавиатуры синхронный, лишний раз дёргать его нельзя.
Третье: в лог уходит только факт «пропустили, потому что ждут пароль». Даже замаскированная длина слова — [9 cyr] — для пароля это утечка.
Отдельный приятный факт: Terminal.app на запросе пароля сам включает Secure Event Input, и клавиши до tap вообще не долетают (проверил через sudo: «secure event input on, held by com.apple.Terminal»). Вся эта механика нужна в основном для терминалов внутри Electron-приложений — о них следующий раздел.
Что честно не покрыто: пароль, который спрашивает удалённая сторона — за ssh, docker exec, mosh. Локальный tty в этот момент в raw-режиме чужой сессии, по флагам пароль не разглядеть. Там остаётся Secure Keyboard Entry, об этом написано в README.
Терминал внутри Electron: маленький детектив
Современные терминалы живут не только в Terminal.app. В VS Code, Cursor, Codex, Claude Desktop есть терминальные панели, и у всех внутри Electron + xterm.js. Хочется, чтобы «св» → cd работало и там. Две проблемы попались по дороге, обе любопытные.
Проблема первая: у Electron-приложения нет дерева доступности, пока его не попросишь. Для него это лишний расход памяти и CPU. Просьба выглядит как атрибут AXManualAccessibility, выставленный приложению. Попросить нужно заранее, при активации, а не в момент первой подозрительной клавиши: Chromium строит дерево асинхронно, и первая команда после запуска приложения без этого теряется. По этой же причине в меню есть пункт «Добавить текущее приложение как гибридное» — пользователь сам говорит, каким его приложениям платить за дерево.
А дальше подвох. Codex внутри ChatGPT.app отверг AXManualAccessibility с ошибкой -25205 (атрибут не поддерживается). Но у Chromium есть второй атрибут, за которым он следит сам: AXEnhancedUserInterface. Codex на него отвечает -25208, «not implemented» — и при этом дерево строит. Код ошибки врёт, дерево появляется, «св» чинится в cd. Проверено на живой системе, иначе бы не поверил. Поэтому результат выставления атрибута никому не доверяем, просто пишем в лог. А fallback этот даём только настоящим Electron (опознаём по Contents/Resources/app.asar в бандле): в обычном Chrome тот же атрибут включает тяжёлую полную доступность, а в нативных приложениях может подпортить анимации окон — даром не нужно.
Проблема вторая: дерево построено, а где в нём терминал? В том же приложении сидит чат, редактор, настройки — все они текстовые поля, и чинить «св» в чате с коллегами нельзя никак. Взял AX-зонд и прошёлся по живому Claude Desktop:
терминальная панель —
AXTextField, описаниеTerminal input, среди DOM-классов естьxterm-helper-textarea;поле чата —
AXTextArea, описаниеPrompt, классProseMirror.
xterm-helper-textarea — имя скрытого input’а, через который xterm.js принимает ввод. А поскольку на xterm.js построены терминалы VS Code, Cursor, Codex и Claude Desktop, один CSS-класс отличает терминал от чата во всех этих приложениях сразу (Hyper и Tabby тоже на xterm.js, так что должны работать, но их я не проверял):
guard AXUIElementCopyAttributeValue(element, "AXDOMClassList" as CFString, &value) == .success, let classes = value as? [String] else { return false } return classes.contains("xterm-helper-textarea")
И побочный эффект, который был неприятным, пока его не понял. Стоит дереву появиться — поля гибридного приложения начинают выглядеть редактируемыми через AX. Запись в них рапортует успех и не применяет ничего — та же ловушка, что в терминалах. Поэтому в гибридных приложениях AX-замена не пробуется в принципе, сразу синтетические нажатия. А дерево работает ровно на один вопрос: терминал сейчас в фокусе или нет.

Как решить «не та раскладка»: пары букв вместо букв
Первая эвристика была простой: все ли буквы слова частые в текущем алфавите. Она не отличала «ашду» (file в русской раскладке) от настоящего русского слова — все буквы там действительно частые.
Рабочий вариант — биграммы. Построены две модели по открытым частотным словарям (FrequencyWords, CC BY-SA 4.0): вероятности пар букв с учётом краёв слова для русского и английского. Дальше честное отношение правдоподобий: насколько конверсия правдоподобнее в целевом языке по сравнению с набранным в текущем. «аш» в начале и «ду» в конце — плохие пары для русского, и «ашду» тонет против file.
Порог зависит от длины. У трёхбуквенного слова всего четыре перехода, его оценка шумит — порог 2.5. У пяти букв и длиннее — 1.0. Пороги не подкручиваются под отдельные слова (это путь в никуда), об отдельных словах ниже.
Три места, где модель сама не справляется, и почему:
Пунктуация. Точка в «фьфящтюву» моделью пропускается, а оригинал оценивается целиком — буквы б, ю, ж, х, э стоят на пунктуационных клавишах, но в слове это буквы. Ранняя версия вырезала их из обеих строк. В итоге «был» оценивался как «ыл», «ещё» как «ещ» — и утилита исправляла самые частые слова русского языка. Маленькая правка, огромный эффект.
Домены. «нщгегиуюсщь» состоит из частых русских букв и правдоподобно выглядит русским словом. Это youtube.com. Биграммы его, кстати, тоже ловят, но сверху стоит безусловное правило: конверсия целиком из
[a-z0-9.-], TLD из ручного списка, первая метка не короче трёх букв. В списке нет tv и cz — это живые русские окончания «-юем» и «-юся»: «горюем» и «боюся» дороже домена.Исключения.
http,npm, «будут» и компания. Проверяются до любого скоринга. Пользователь может добавить своё слово прямо из меню, выделив его.
У Apple две русские раскладки
Про это я узнал не вовремя. В macOS есть «Russian» и «Russian – PC», и у них разная физическая раскладка пунктуационных клавиш: ё в разных местах, а ` и / в «Russian» работают иначе. Если вариант не учитывать, буфер слова рвётся посреди «ю» (это клавиша с точкой). Вариант определяется по input source ID и единообразно уходит в сборку слова, детектор и замену — рассинхрон любого из трёх ломает всё остальное.
Как проверяется качество
Юнит-тестов 160, и они покрывают всё чистое: конвертацию в обе стороны для обеих русских раскладок, модель, пороги. Но детектор этим не измерить — тесты проверяют логику, а качество решений измеряется на данных.
Поэтому рядом живёт скрипт-замерщик: прогоны по токенам man-страниц, по частотным словарям, по словарю из NSSpellChecker. Любое изменение таблиц или порогов едет через него: считаем баланс пойманных и ложных срабатываний, взвешивая по частоте. По уникальным словам «был» весит как редкое существительное, а в реальности пользователь замечает именно «был».
Это, кстати, отдельный приятный момент настройки: приносишь слово «не ловится», оцениваешь его LLR, смотришь кривую по десяткам тысяч слов — и решаешь: сдвиг порога или точечное исключение. Дисциплина вместо подкручивания под кейсы.
Приватность
Программа сидит на перехвате клавиатуры, поэтому паранойя тут не украшение, а требование.
Сети нет совсем. Логи по умолчанию выключены, а когда включены — набранного текста в них нет в принципе: строка маскируется в момент создания ([6 cyr] вместо слова), исходник не хранится, и напечатать его не сможет ни интерполяция, ни отладчик. Режима «пиши как есть» нет специально: в терминале пароль неотличим от текста, и любой такой режим рано или поздно записал бы пароль в файл, который уходит в поддержку.
Попробовать
Скачать: https://github.com/vvnovg/LatCyr/releases/latest — сборка подписана Developer ID и нотаризована Apple, открывается двойным кликом. Нужна macOS 14 или новее, работает на Apple Silicon и Intel.
Понадобятся два разрешения — Accessibility и Input Monitoring; приложение показывает их статус в меню и предлагает открыть нужный раздел настроек. При обновлениях разрешения сохраняются.
Для терминальных панелей VS Code, Cursor, Claude Desktop и т.п.: переключитесь в приложение и выберите в меню LatCyr «Добавить текущее приложение как гибридное».
Сборка из исходников:
swift build, никакого Xcode. Лицензия — Apache 2.0.

Вместо финала
Проект животворящий — пользуюсь каждый день и чувствую, как возвращаются психологическая устойчивость и хорошее настроение, как растут когнитивные способности и творческий потенциал.
Бесплатно и открыто. Если пригодилось — есть страницы поддержки в README (GitHub Sponsors и CloudTips). Слова, которые ловятся не так, приносите в issues — у замерщика есть протокол, он за минуту подскажет, это повод двигать порог или точечное исключение.
