
— Поставил RuSwitcher. Пользуюсь четвёртый месяц. Люто, бешено доставляет. Зависимостей никаких. Рекомендую.
— НЕ СМЕШНО У МЕНЯ БРАТ УМЕР ОТ ЭТИХ ЗАВИСИМОСТЕЙ ТЫ ВРЁШЬ ЧТО ИХ НЕТ!!1
Если серьёзно — зависимостей у RuSwitcher действительно ноль: только системные фреймворки и чистый Swift, никакой телеметрии и ничего постороннего в Package.swift. Но начнём с боли.
Если вы пишете на двух языках, то знаете эту боль: набрал полстроки, поднял глаза — а там ghbdtn вместо «привет». На Windows эту проблему закрывает Punto Switcher. А на macOS? Его Mac‑версию Яндекс забросил ещё в 2017-м, да и у самого Punto хватает «сюрпризов»: встроенный кейлоггер‑«дневник», телеметрия, навязывание Яндекс‑сервисов и закрытый код. Мне хотелось простого, открытого и без слежки — поэтому я написал своё: RuSwitcher, лёгкий переключатель раскладки в меню‑баре. Open source (MIT), ноль зависимостей, ноль телеметрии.
В статье — как это устроено внутри: перехват клавиатуры через CGEventTap, динамический маппинг любых двух раскладок через UCKeyTranslate, и отдельно — раздел «грабли», включая историю про то, как я случайно выложил релиз, где DMG назывался 2.1.0, а внутри лежала сборка 2.0.3.
Чем не угодил Punto Switcher
Сразу оговорюсь: Punto тоже бесплатный, так что дело не в деньгах. ЭТО ДРУГОЕ;)
На macOS его фактически нет. Яндекс заморозил разработку Mac‑версии ещё в ноябре 2017-го, а последняя сборка вышла за ~15 месяцев до этого. То есть «Punto для Mac» — это заброшенный почти десять лет назад проект, без поддержки современных macOS.
Встроенный кейлоггер. В Punto есть «Дневник» — функция, которая пишет всё набранное с клавиатуры в файл. По умолчанию выключена и её можно запаролить — но по сути это штатный клавиатурный шпион внутри переключателя раскладки.
Телеметрия. По многочисленным сообщениям, программа отправляет данные о пользователе и конфигурации на серверы Яндекса — в том числе если при установке отказаться от обновлений.
Навязывание Яндекса. Установщик (на Windows) предлагает Яндекс.Браузер, меняет домашнюю страницу и поиск по умолчанию, ставит Яндекс‑расширения — фактически «ходячая реклама».
Закрытый код. Что именно программа делает с вашими нажатиями — проверить нельзя в принципе.
Буфер обмена. Пользователи годами жалуются: слежение за буфером отваливается (например, после гибернации), автозамена конфликтует с расширенным буфером Windows (Win+V) и вставляет чужой текст, а в Telegram/WhatsApp — стирает или перемешивает набранное. (Лично у меня Punto тоже периодически терял буфер обмена — это и стало последней каплей.)
Честно: «Дневник» по умолчанию выключен, а бандл с браузером — беда Windows‑инсталлятора. Но сам подход — закрытый код + телеметрия + встроенный логгер набора — для инструмента, который видит каждое ваше нажатие, мне не нравится категорически.
Чего хотел я:
Punto Switcher (macOS) | RuSwitcher | |
|---|---|---|
Поддержка macOS | заморожена с 2017 | активная, нативная |
Исходный код | закрытый | открытый, MIT |
Телеметрия | есть (по сообщениям) | нет |
Кейлоггер‑«дневник» | есть (опционально) | нет |
Навязывание ПО | да (Яндекс) | нет |
Зависимости | — | ноль |
Цена | бесплатно | бесплатно |
RuSwitcher — только macOS и нативно, открытый код (можно прочитать каждую строчку), ноль телеметрии, ничего не навязывает, а буфер обмена аккуратно сохраняется и восстанавливается (в 2.1.1 это отдельно закалили — см. раздел про грабли).
Что умеет
Одиночное нажатие Alt → конвертирует последнее слово (или выделенный текст) в другую раскладку и переключает её.
Повторный Alt → откатывает конвертацию обратно.
Работает с любой парой установленных раскладок, не только ru/en.
Запоминает раскладку по приложению (per‑app layout memory).
Меню‑бар утилита, без окна, автозапуск, 12 языков интерфейса.
Никакой телеметрии.

А теперь интересное — как оно работает.
1. Перехват клавиатуры: CGEventTap
Чтобы реагировать на Alt и считать набранное слово, нужно видеть все нажатия системно. В macOS для этого есть CGEventTap. Я слушаю keyDown и flagsChanged (модификаторы), в режиме listenOnly — мы не блокируем и не меняем события, только наблюдаем:
let mask: CGEventMask = (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.flagsChanged.rawValue) guard let tap = CGEvent.tapCreate( tap: .cghidEventTap, place: .tailAppendEventTap, options: .listenOnly, eventsOfInterest: mask, callback: keyboardCallback, userInfo: Unmanaged.passUnretained(self).toOpaque() ) else { // нет разрешения Input Monitoring return false }
Колбэк — это C‑функция (event tap не принимает замыкания с захватом), поэтому контекст прокидывается через userInfo и распаковывается обратно в объект:
let monitor = Unmanaged<KeyboardMonitor> .fromOpaque(userInfo).takeUnretainedValue()
Важная деталь: event tap система может отключить при таймауте или перегрузке (tapDisabledByTimeout). Это надо ловить и включать заново, иначе приложение тихо «оглохнет»:
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { CGEvent.tapEnable(tap: monitor.eventTap!, enable: true) return Unmanaged.passRetained(event) }
2. Динамический маппинг раскладок через UCKeyTranslate
Самое частое, что видишь в подобных проектах — захардкоженная таблица «й→q, ц→w…». Это работает ровно для ЙЦУКЕН↔QWERTY и ломается на всём остальном (немецкая, французская, армянская…).
Я пошёл другим путём: спрашиваю у самой системы, какой символ даёт keycode в конкретной раскладке. За это отвечает Carbon‑функция UCKeyTranslate. Берём данные раскладки (kTISPropertyUnicodeKeyLayoutData) и прогоняем keycode:
let result = layoutData.withUnsafeBytes { raw -> OSStatus in let ptr = raw.baseAddress!.assumingMemoryBound(to: UCKeyboardLayout.self) return UCKeyTranslate( ptr, keycode, UInt16(kUCKeyActionDown), modifierKeyState, UInt32(LMGetKbdType()), UInt32(kUCKeyTranslateNoDeadKeysMask), &deadKeyState, chars.count, &length, &chars ) }
Дальше строю карту символ_в_раскладке_A → символ_в_раскладке_B, перебирая keycodes 0–50 с шифтом и без. Карта кэшируется по ключу "layoutID_A→layoutID_B". Итог: конвертация работает между любыми двумя раскладками, которые есть в системе, без единой захардкоженной таблицы.
3. Как подменяется текст (и почему это компромисс)
Окей, слово определили и сконвертировали. Как заменить его в чужом приложении, к которому у нас нет доступа к тексту?
Честный ответ: через эмуляцию буфера обмена. Алгоритм:
Shift+Left × N— выделить N последних символов;Cmd+C— скопировать;прочитать
NSPasteboard, сконвертировать строку;Cmd+V— вставить обратно.
Звучит просто, но дьявол в деталях. Главная проблема — мы затираем буфер обмена пользователя. Поэтому до начала делается снапшот всех типов данных пастборда, а после — восстановление через 2 секунды (с защитой на случай выхода из приложения):
savedClipboardItems = snapshotPasteboard(pasteboard) // ... конвертация ... scheduleClipboardRestore() // вернуть оригинал через 2с
И да — это не самый изящный подход, и я честно об этом скажу в разделе планов. Но именно буфер обмена работает практически везде, в отличие от прямой записи через Accessibility API, которую половина приложений (Electron, веб‑вью) не поддерживает.
4. Как не зациклиться на собственных событиях
Мы и слушаем клавиатуру, и сами генерим нажатия (Shift+Left, Cmd+C, Cmd+V). Если не отфильтровать свои события — получим бесконечный цикл. Решение: помечаем синтезированные события маркером в userData источника:
let kRuSwitcherEventMarker: Int64 = 0x52555300 // "RUS\0" в ASCII source?.userData = kRuSwitcherEventMarker // в колбэке: if event.getIntegerValueField(.eventSourceUserData) == kRuSwitcherEventMarker { return Unmanaged.passRetained(event) // это мы сами — игнор }
5. Грабли macOS
Самое полезное — то, на чём набил шишки.
Разрешения. Нужны и Accessibility, и Input Monitoring — два разных переключателя в System Settings. Хуже: при обновлении приложения macOS может сбросить разрешения (если изменилась подпись). Поэтому я держу флаг «разрешения были выданы» и при их пропаже после апдейта показываю пользователю объяснение и сбрасываю старые записи через tccutil reset.


Терминалы. В Terminal.app и iTerm2 конвертация не работает: там Cmd+C — это не «копировать», а SIGINT. Честно вынес в known limitations.
Тайминги. Между симулированными нажатиями нужны микро‑паузы (usleep), иначе приложение не успевает обработать выделение/вставку. Это хрупко и зависит от приложения — отдельная головная боль.
Подпись и нотаризация. Без нотаризации Gatekeeper выдаёт «Apple не может проверить, что приложение не содержит вредоносного ПО». Сборка подписывается Developer ID, отправляется на нотаризацию (notarytool submit --wait) и стейплится (stapler staple).
6. Честная история про релиз, который сломался
Тут расскажу, как сам себя подставил — Хабр такое любит.
В какой‑то момент пользователь написал: «через Homebrew ставится старая версия, а в DMG с именем 2.1.0 внутри лежит 2.0.3». И он был прав.
Причина оказалась в скрипте сборки DMG. Имя файла бралось из version.json (источник правды), а само приложение скрипт не пересобирал — просто паковал тот RuSwitcher.app, что лежал рядом в рабочей папке. А там осталась старая сборка. В итоге: DMG называется 2.1.0, а бинарь внутри — 2.0.3. И что хуже — авто‑апдейтер сравнивал заявленную 2.1.0 с зашитой 2.0.3 и предлагал «обновиться» по кругу.
Починка — сделать пайплайн самопроверяющимся. Теперь скрипт DMG:
всегда пересобирает приложение перед упаковкой;
жёстко падает, если версия в бандле не совпадает с
version.json;сам вписывает финальный
sha256и версию вversion.jsonи в Homebrew‑cask, чтобы манифест не расходился с артефактом.
BUNDLE_VERSION=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \ "$APP_PATH/Contents/Info.plist") if [ "$BUNDLE_VERSION" != "$VERSION" ]; then echo "ERROR: bundle is $BUNDLE_VERSION but version.json is $VERSION" exit 1 # отказываемся паковать DMG с расхождением версии fi
Мораль: если у вас есть «единый источник правды», убедитесь, что в него действительно всё упирается, а не «обычно упирается».
7. Безопасность авто‑обновления
Приложение, которое перехватывает клавиатуру и умеет само себя заменять скачанным DMG — это лакомый вектор. Поэтому авто‑апдейт закалён:
sha256проверки обязательны (нет хэша — откат на загрузку в браузере);перед заменой проверяется подпись Developer ID с пиннингом Team ID:
let requirement = "anchor apple generic and certificate leaf[subject.OU] = \"9GEWCZ59HK\"" // codesign --verify --deep --strict -R=<requirement> <app>
сверяется bundle id и версия смонтированного приложения.
То есть даже если кто‑то подменит и DMG, и хэш — без валидной подписи моей команды установка не пройдёт.
8. Чего пока нет и куда двигаюсь
Главный технический долг — тот самый копи‑пастный движок конверсии. В планах — гибрид:
хранить буфер последних нажатий (keycodes) и конвертировать их напрямую, без чтения поля и без
Cmd+C;вставлять через
CGEvent.keyboardSetUnicodeString, не трогая буфер обмена вообще;Accessibility API для уже выделенного мышью текста;
старый копи‑паст оставить последним фолбэком.
Телеметрия, навязывание софта, реклама, кейлоггер — шутка для внимательных и дочитавших до конца))
Это уберёт бóльшую часть «граблей» из раздела 5 для основного сценария.
Итог
RuSwitcher — это ~3500 строк чистого Swift 6, только системные фреймворки (AppKit, Carbon, CoreGraphics, ServiceManagement), меню‑бар, без сэндбокса (нужен для event tap), без телеметрии. Ставится через Homebrew или DMG.

Буду рад фидбеку, issue и PR — проект живой, баг‑репорты реально читаются и чинятся.
Последний релиз: https://github.com/rashn/RuSwitcher/releases/latest
