Написал переключатель раскладки для macOS — работает в терминале и не трогает пароль sudo

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Однажды — в одном немецкоязычном банке, в одном проекте, где аналитики писали спеки в виде формул, — я встретил Берта (имя изменено). Очень грамотного и умного математика-разработчика, но с совершенно расшатанными нервами и, видимо, с какой-то серьёзной психологической травмой. Когда где-то в опен-спейсе хлопала дверь, кто-то громко разговаривал или — не дай бог! — смеялся, он внезапно вскакивал с места, зыркал глазами в сторону провинившихся, топал ногами, злобно шипел и иногда даже подвывал. Вокруг него менялись люди: не все выдерживали — увольнялись, перебегали в другие команды, забивались куда-то подальше в норы от этого льва. Я сидел прямо напротив него, смотрел ему в глаза — без тени сожаления и возмущения — и просто продолжал невозмутимо пилить код. Он мне совершенно не мешал и не раздражал: всё происходило где-то за гранью внимания. А Берт через пару лет начал меня уважать за выдержку, мы даже интересно общались на профессиональные темы.

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

В общем, я написал своё. Не то чтобы прямо сам-сам, конечно, мне помогал один товарищ по имени Клод, но я генерил идеи, делал проверки на живой системе — словом, затачивал инструмент под себя и свою работу. LatCyr — открытое приложение в строке меню: само понимает, что слово набрано не в той раскладке, исправляет его и переключает раскладку. Работает в обе стороны, в терминалах (в том числе в терминальных панелях VS Code и Claude Desktop — их приложение надо один раз отметить в меню) и не трогает пароли. Swift, без Xcode, 160 тестов.

Набираю «d ckexft» в английской раскладке — на пробеле оба слова становятся «в случае», раскладка переключается, дальше текст идёт по-русски
Набираю «d ckexft» в английской раскладке — на пробеле оба слова становятся «в случае», раскладка переключается, дальше текст идёт по-русски

Что умеет

Сначала коротко, потом самое интересное — как оно внутри.

По слову, набранному не в той раскладке, 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; короткие команды (cd, ls…)

Пароль 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 / read -s / getpass ждут пароль

выкл

вкл

Шелл в ожидании команды (zsh)

выкл

выкл

vim (normal mode), less

выкл

выкл

cat или обычная работающая команда

вкл

вкл

Всё, что читает пароль, выключает эхо (понятно), но оставляет канонический режим — построчное редактирование, чтобы 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-замена не пробуется в принципе, сразу синтетические нажатия. А дерево работает ровно на один вопрос: терминал сейчас в фокусе или нет.

В терминальной панели Claude Desktop набираю в русской раскладке «св» — на пробеле становится ; потом «ды» и Enter — выполняется
В терминальной панели Claude Desktop набираю в русской раскладке «св» — на пробеле становится ; потом «ды» и Enter — выполняется

Как решить «не та раскладка»: пары букв вместо букв

Первая эвристика была простой: все ли буквы слова частые в текущем алфавите. Она не отличала «ашду» (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.

Меню LatCyr: оба разрешения выданы, версия и сборка внизу
Меню LatCyr: оба разрешения выданы, версия и сборка внизу

Вместо финала

Проект животворящий — пользуюсь каждый день и чувствую, как возвращаются психологическая устойчивость и хорошее настроение, как растут когнитивные способности и творческий потенциал.

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

Источник: https://habr.com/ru/articles/1098482/


Интересные статьи

Интересные статьи

По информации СМИ, дочерняя структура одного из ключевых игроков российской электроники АО «Нанотроника» объявила об оптимизации штата. Речь идёт примерно о пятой части персонала. Как п...
Как в вашей компании менеджеры обычно начинают день? Они открывают почту или CRM и видят десятки входящих заявок: среди них есть и целевые обращения, и случайные контакты, и банальный спам. На сортиро...
Привет, меня зовут Евгений, я разработчик из Байовэр в компании НЛМК ИТ.Довелось мне тут столкнуться с разработкой системы опытно-промышленных испытаний на производстве, и если описать это к...
Привет, Хаброжители! Фундаментальные математические дисциплины, необходимые для понимания машинного обучения, — это линейная алгебра, аналитическая геометрия, векторный анализ, оптимизация, теория...
Настройка любой площадки для CMS — это рутинный процесс, который должен быть доведен до автоматизма в каждой уважающей себя компании. А потому частенько воспринимается, как восход солнца — это происхо...