Почему вообще встал вопрос
В прошлой статье про домашний сервер на Tesla V100 была одна грабля, которая здесь выросла в отдельный проект. Я проверял, что уходит наружу из повседневных инструментов, и заодно разобрал переключатель раскладки Caramba Switcher - удобную утилиту, которая переворачивает ghbdtn в “привет”, если слово набрано не в той раскладке.
По лицензионному соглашению программа отправляет в Google Analytics только служебную статистику: версию, идентификатор установки, язык, события вроде “клик по интерфейсу”. Набранный текст в договоре не фигурирует. Я решил не просто поверить, а проверить руками: поднял прямое TLS-соединение с известным именем хоста и прочитал предъявленный сертификат. Единственное замеченное соединение шло на собственный CDN производителя - неожиданно, но не подозрительно захостенный на московском IP “Мегафона”, - а не на что-то постороннее. Отдельного соединения к Google Analytics я не поймал ни при запуске, ни при живом клике по интерфейсу.
А вот заглянуть внутрь зашифрованного трафика не вышло. SSLKEYLOGFILE - соглашение из мира OpenSSL и браузеров; закрытые Windows-программы обычно сидят на системном Schannel и его не замечают. Ставить MITM с собственным корневым сертификатом ради одной утилиты показалось избыточным. До конца убедиться не получилось - и раз не получилось, я снёс программу. Заодно нашёл, чем она на самом деле стучится: обычная библиотека автообновлений WinSparkle.
Это честный результат: я не поймал Карамбу на утечке текста. Я поймал только то, что она вообще ходит в сеть, не смог разобрать, что именно уходит внутри TLS, и не стал рисковать. Для рабочего инструмента, который видит вообще всё, что я набираю, порог “не уверен - не доверяю” показался правильным.
Дальше вопрос стал практическим: если распространённые свитчеры раскладки по умолчанию ходят в сеть (не обязательно злонамеренно - чаще всего это автообновление и телеметрия, но ходят), можно ли сделать такую же удобную вещь, но без этого вопроса вообще? Так появилась Зябра.
Что я смотрел перед своей архитектурой
Готовые проекты я разбирал как образцы устройства, не как код для копирования.
KeyFlow (weird-mirror/keyflow) - у него взята общая схема: один буфер текущего слова, решение по словарю, backspace, печать правильного слова, смена раскладки. Без нейросети.
Mahou - не образец поведения: перевод там в основном по горячей клавише, и есть сетевые проверки обновлений.
Идею с моделью (LSTM/ONNX, как у KeyboardSwitcher) отбросил: лишний вес, рантайм и неаудируемое решение “угадать слово” - то есть ровно то, от чего пытался уйти.
N-граммы в духе xneur тоже отбросил. Остались словари и простое правило “текущее слово побеждает”.
Жёсткое ограничение зафиксировал с первого дня: ничего не хранить и не открывать сеть. Программа - не журнал клавиш, не облачный корректор и не автообновление.
Архитектура: два слоя
Библиотека src/lib.rs помечена #![forbid(unsafe_code)]. У неё нет клавиатуры, файлов и сокетов - туда приходит уже собранное слово, оттуда выходит решение. Модули: keymap, dict, decide, engine. Её можно гонять тестами на любой платформе, включая CI на Linux.
Оболочки две, библиотека одна. Windows - src/main.rs и src/win/*, только она вызывает Win32. Зависимость windows 0.61 подключена исключительно под cfg(windows). Linux - src/linux/*: захват клавиатуры через evdev, печать через виртуальное устройство uinput и окно на X11. cargo test в CI на Ubuntu собирает обе оболочки, не заглушку.
На Windows поток данных такой:
клавиша → WH_KEYBOARD_LL → буфер одного слова в Engine → словари en/ru → если надо: VK_BACK, KEYEVENTF_UNICODE, затем смена раскладки
Хук мыши WH_MOUSE_LL только сбрасывает буфер по нажатиям кнопок мыши. Он не пишет координаты и не следит за окнами.
На Linux тот же Engine, другой ввод:
клавиша → evdev, устройство захвачено → буфер одного слова в Engine → словари en/ru → если надо: backspace, смена группы, те же физические клавиши
Виртуальное устройство называется «Zyabra», и его самого программа не захватывает: иначе она проглотила бы собственные нажатия. Раскладку читает у XKB (DISPLAY: X11, GNOME, KDE), у сокета Sway или у сокета Hyprland. Сокеты местные. Если в сессии нет обеих раскладок, программа не стартует.
Правило решения
Логика - в autocorrect (src/decide.rs), и она нарочно скучная.
Слово не трогают, если его основа уже есть в словаре текущей раскладки. Меняют только если в своей раскладке слова нет, а те же физические клавиши в другой раскладке дают словарное слово. Если слова нет ни там ни там - текст остаётся как есть. Смешанные алфавиты не трогают. Минимум две буквы.
Знаки считаются по клавише, не по алфавиту: запятая внутри cj,frf на другой раскладке - это б, получается собака. А запятая после слова так и остаётся запятой: ghbdtn, → привет,.
Раскладки совпадают не один в один: хорошо, набранное в английской раскладке - это [jhjij, не [hjij. asdf → фыва.
Отдельно есть force_convert - он словарь не спрашивает, им пользуется клавиша Pause. Хвост из небукв сначала отрезается, потом перекладывается основа, хвост возвращается как был.
Engine помнит только две вещи, и только в оперативной памяти:
незавершённое слово, не длиннее 48 символов;
одну последнюю замену - пару “как было / как стало”.
Pause при непустом буфере перекладывает текущее слово. Если буфер пуст, Pause меняет сохранённую пару местами и оставляет её - поэтому одно и то же слово переключается туда-обратно, пока не начнут новое слово, не нажмут Backspace по уже введённому тексту, не уйдут стрелками, не попадут в поле пароля и не нажмут «Пауза» в окне самой Зябры.
Пробел, Enter, Tab и знак препинания заканчивают слово. Enter и Tab повторяются виртуальными клавишами, пробел и знак - юникодом. Раскладку переключают уже после печати: иначе знаки препинания в другой раскладке означают другие символы.
Свои нажатия хук сознательно игнорирует: по флагу injected и dwExtraInfo == 0x5A594142. Из-за этого программу нельзя проверить “синтетическими” нажатиями через SendInput - хук обязан их не видеть, это защита от собственного зацикливания, а не дыра в тестировании (окно и логику всё равно проверял снаружи, см. грабли ниже).
Словари
Английский словарь - ENABLE, public domain, слова из 2–20 букв. Русский - словоформы danakt/russian-words, MIT; ё свёрнута в е, тоже 2–20 букв. Атрибуция обоих - в NOTICE.md репозитория.
Исходные списки лежат прямо в git: dictionaries/en.txt (~1,7 МБ, 172 704 слова) и dictionaries/ru.txt (~33 МБ, около 1,45 млн словоформ). Скрипт tools/build_dicts.py только пересобирает их из уже скачанных файлов в third_party/ (каталог в .gitignore) - сама программа ничего не скачивает и этот скрипт не запускает.
В exe словари попадают через gzip: build.rs жмёт их flate2 со сборкой rust_backend (без зависимости на C) прямо в OUT_DIR. В рантайме GzDecoder разворачивает текст в String, которая живёт до конца процесса через Box::leak, а дальше обычный бинарный поиск по отсортированным &str. flate2 здесь - распаковщик вшитых байтов, не сетевой клиент.
Несжатый текст раздувал exe примерно до 36 МБ; после gzip файл стал около 6,3 МБ. В памяти после старта списки всё равно развёрнуты полностью - сжимали файл на диске, а не рабочий набор в ОЗУ.
Клавиатура и раскладка
Карта соответствий - физические клавиши US QWERTY ↔ ЙЦУКЕН, scan set 1, включая знаки (,↔б, .↔ю, [↔х). Цифровой ряд со сдвигом тоже в карте, включая русские ! " № ; % : ? * ( ) _ +.
Язык берут у потока активного окна через GetKeyboardLayout, по младшим битам LANGID: 0x09 - английский, 0x19 - русский. Переключение - LoadKeyboardLayoutW для 00000409/00000419 с флагами KLF_NOTELLSHELL | KLF_SUBSTITUTE_OK, затем PostMessageW(WM_INPUTLANGCHANGEREQUEST) в активное окно.
Нужны установленные в системе английская раскладка и русский ЙЦУКЕН. Фонетическая раскладка не поддерживается.
На Linux карта клавиш та же: коды evdev совпадают со scan set 1, KEY_Q - это 0x10. Клавиатуру программа забирает через evdev и отдаёт нажатия виртуальным устройством, поэтому схема одна и для X11, и для Wayland. Раскладку спрашивает у XKB, если есть DISPLAY, иначе у сокета Sway или Hyprland. Нет обеих раскладок - старт с ошибкой, а не молчаливая работа в одном языке.
Печать замены идёт физическими клавишами, не юникодом. Сначала backspace, потом группа переключается на нужный язык и набирается слово. Знак в конце слова печатается ещё в старой группе, иначе запятая снова стала бы буквой, и только потом группа остаётся на новом языке. После реальной смены группы - пауза 20 мс, чтобы следующая клавиша не уехала в старую.
Окно Linux - голый X11, без GTK и без своего шрифта в файле. Крестик и «Выход» завершают программу и отпускают клавиатуру. Пока фокус в окне самой Зябры, клавиши проходят как есть и буфер слова сбрасывается. В трей это окно не прячется: на Windows крестик прячет, на Linux завершает.
Файл релиза zyabra-0.1.5-linux-x86_64 собран под обычный glibc. Самый новый символ в нём - GLIBC_2.34, то есть Ubuntu 22.04, Debian 12 и новее. Отдельная сборка нужна для другого процессора или для musl, не для каждого дистрибутива. Нужна группа input и /dev/uinput. Если устройство доступно только root, хватает правила udev KERNEL=="uinput", GROUP="input", MODE="0660". После добавления в группу нужен новый вход в сессию: старая сессия новой группы не видит.
Что программа сознательно не делает
Не пишет текст на диск и не ведёт журнал.
Не читает и не пишет буфер обмена.
Не открывает сеть. В
Cargo.tomlнетreqwest,hyperи других сетевых крейтов. На Linux есть только местные сокеты: сервер X, Sway и Hyprland. Это не выход в интернет.Не добавляет себя в автозагрузку и не просит права администратора.
На Windows не читает обычные поля пароля: у фокуса проверяется
GWL_STYLE & ES_PASSWORD(0x20). Если флаг есть, буфер забывают и клавишу пропускают. На Linux такого признака у клавиши нет, поэтому пароль читается так же, как обычный текст.
Честная оговорка, а не красивая формулировка: поле пароля в браузере на Windows часто идёт без флага ES_PASSWORD, а на Linux его нет совсем. Тогда текущее слово может какое-то время побыть в оперативной памяти процесса. На диск и в сеть оно оттуда не уходит - но говорить “пароли не видим” без уточнения было бы нечестно.
Ещё одно ограничение Windows: окна, запущенные от администратора, низкоуровневый хук непривилегированного процесса не видит в принципе. На Linux захват стоит на устройстве клавиатуры, до окна.
CI на каждый push гоняет тесты и отдельно падает, если в src, build.rs или Cargo.toml находятся TcpStream, UdpSocket, reqwest, hyper::, WinHttp, InternetOpen, HttpClient - то есть это не обещание в README, а проверка, которая ломает сборку. Единственный осознанный выход наружу - клик по ссылке в окне. На Windows это ShellExecuteW, на Linux - xdg-open, и оба открывают страницу репозитория. Это действие пользователя, а не фоновый запрос.
Один экземпляр. На Windows, если окно класса ZyabraWindow уже существует, повторный запуск просто показывает его. На Linux второй процесс упирается в flock файла $XDG_RUNTIME_DIR/zyabra.lock и выходит с «Зябра уже запущена.» После старта на Windows иконки на панели задач нет - программа сидит в трее, крестик прячет окно обратно, завершает только пункт «Выход». На Linux крестик завершает программу.
Грабли
Ниже - не список багов, а цена низкоуровневого хука и принципа “всё локально”.
1. Pause был одноразовым. В первой версии отмена забирала сохранённую пару и не клала её обратно - второе нажатие уже ничего не делало. Починка: пара меняется местами и остаётся, пока её явно не сотрут новым словом или другим событием сброса.
2. force_convert("ghbdtn,") давал приветб. Полная перекладка шла до отрезания хвоста, и запятая на другой раскладке становилась буквой б. Хвост нужно снимать до транспозиции, а не после.
3. Карта клавиш легко съезжает на одну позицию. asdf - это фыва, не фвыа. хорошо - [jhjij; строка [hjij - уже другое слово, хрошо. Один сдвинутый символ в таблице соответствий ловится только юнит-тестами на конкретных словах, не “на глаз”.
4. Словари в include_str! раздули файл до ~36 МБ - в основном за счёт русского списка (33 МБ текста, gzip -9 даёт около 4,3 МБ по отдельности). Сжал на этапе сборки, не выкидывая слова: в ОЗУ списки всё равно разворачиваются целиком. Это осознанная плата за правило “текущее слово побеждает” - выкинь редкую словоформу, и она станет ложным срабатыванием.
5. Кнопка “Пауза” вешала окно намертво, в заголовке появлялось “Не отвечает”. SetWindowTextW синхронно перерисовывает статический контрол и шлёт родителю WM_CTLCOLORSTATIC; обработчик снова пытался взять тот же Mutex, а std::sync::Mutex в Rust не реентерабельный. Поток окна при этом же обслуживает WH_KEYBOARD_LL, так что зависший wnd_proc останавливал и сам хук - то есть переставала реагировать вся клавиатура, не только кнопка. Лечится просто: скопировать HWND, отпустить блокировку и только потом звать Win32. Любой SendMessage из-под своего мьютекса на этом же потоке - потенциальная ловушка.
6. Ссылка на репозиторий требовала common controls 6, то есть манифест - а у rustc на windows-gnu свой манифест уже есть. Второй манифест линкер склеил криво, процесс не стартовал с ошибкой “side-by-side configuration is incorrect”. Решение - обычный STATIC с SS_NOTIFY, подчёркнутый Segoe UI и синий цвет в WM_CTLCOLORSTATIC. Клик всё равно открывает только URL своего же репозитория.
7. Серые плашки вокруг текста - это фон статиков по умолчанию, не дизайн. Пока WM_CTLCOLORSTATIC красил только ссылку, остальные контролы рисовались плашками COLOR_BTNFACE на белом окне. Привёл окно и текст к одному диалоговому цвету - ориентир был на компактное окно настроек вроде Lightshot: маленький диалог, ровный шрифт, кнопки снизу справа.
8. Зависшая старая версия маскировала новую. Пока висело зависшее 0.1.2 (см. граблю 5), новый exe молча находил класс окна ZyabraWindow и решал, что экземпляр уже запущен. Для пользователя это выглядело как “новая сборка не запускается”, хотя на деле мешал старый зависший процесс.
9. Крейт windows 0.61 не похож на старые примеры в сети. Хук и KBDLLHOOKSTRUCT лежат в Win32::UI::WindowsAndMessaging, а не в KeyboardAndMouse. Хэндлы - указатели, не isize. GetMessageW возвращает BOOL, а не Result. Флаги KLF_* не складываются через BitOr - их нужно паковать в ACTIVATE_KEYBOARD_LAYOUT_FLAGS вручную. TrackPopupMenuEx ждёт u32. Сырые указатели не Send, поэтому для состояния окна и хуков понадобился ручной unsafe impl Send.
10. Хук принципиально не проверить изнутри синтетическими нажатиями - свои события отфильтрованы нарочно (см. выше про injected/dwExtraInfo). Автотесты закрывают словарь, карту клавиш и правило Pause. Окно проверял снаружи: SendMessage по кнопке “Пауза”, SendMessageTimeout, чтобы поймать именно зависание (а не просто отсутствие ответа), PrintWindow для проверки вёрстки.
11. Имя с точкой не собирается как цель Cargo. zyabra-0.1.2 как [[bin]] не проходит - имя крейта не может содержать точку. Версия живёт в заголовке окна, в ресурсе VERSIONINFO и в имени файла релиза, а сама цель сборки остаётся zyabra. В релизе 0.1.5 это zyabra-0.1.5.exe и zyabra-0.1.5-linux-x86_64.
12. На Linux русские строки окна стали чёрными планками. ImageText16 заливает клетку каждой буквы фоном графического контекста. Фон по умолчанию - пиксель 1, на TrueColor это почти чёрный, а цвет буквы тоже чёрный. Латинская ссылка осталась видна, потому что она синяя. Починка: у текста и ссылки фон контекста равен фону окна, у подписей кнопок - цвету кнопки.
Чего эта статья не обещает
Что браузерные пароли никогда не попадают в буфер программы. На Windows попадают, если у контрола нет
ES_PASSWORD. На Linux поля пароля не отличить от обычного текста. Дальше они никуда не записываются и не передаются.Что программа видит все окна Windows. Админские окна для непривилегированного хука не существуют.
Что раскладка находится на любом Wayland. Без XWayland, Sway или Hyprland спросить не у кого, и программа не стартует.
Что словарь угадает слово, которого в нём нет. Такое слово перекладывает только Pause, и только по клавишам, без обучения и запоминания.
Что ложных срабатываний не бывает. Иногда бессмысленная строка случайно совпадает со словоформой в словаре - Pause возвращает её обратно вручную.
Что Карамба отправляет что-то плохое. Я не разобрал полезную нагрузку внутри TLS - только то, куда идёт соединение на уровне сертификата и хоста. Честный итог - “не смог убедиться”, а не “поймал за руку”.
Итог
Со стороны Карамбы проверяемо ровно то, что показал тот разбор: соединение на CDN производителя, а не куда-то ещё, и невозможность заглянуть глубже без MITM. Со стороны Зябры проверяемо больше и иначе - кодом и CI, а не доверием к лицензионному соглашению: нет сетевых зависимостей в Cargo.toml, есть автоматическая проверка на сетевые API при каждом коммите, словари вшиты в бинарник на этапе сборки, журнал не ведётся, буфер обмена не используется, а единственный выход наружу - явный клик пользователя по ссылке на тот же самый репозиторий, где можно проверить всё написанное выше.
С 0.1.5 та же библиотека работает и на Linux: evdev, uinput и окно X11. Рядом с zyabra-0.1.5.exe лежит zyabra-0.1.5-linux-x86_64.
Исходники и релиз - github.com/DmNep/zyabra, MIT.
danilovmy
Зачем весь этот слоп - непонятно. Заблокировать карамбе выход в интернет - религия не позволила? Не хватило подписки на реверс вызова библиотеки обновления?
Не топлю за carambа, но если это самостоятельная программа так и пишите про неё.