— Поставил 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. Как подменяется текст (и почему это компромисс)

Окей, слово определили и сконвертировали. Как заменить его в чужом приложении, к которому у нас нет доступа к тексту?

Честный ответ: через эмуляцию буфера обмена. Алгоритм:

  1. Shift+Left × N — выделить N последних символов;

  2. Cmd+C — скопировать;

  3. прочитать NSPasteboard, сконвертировать строку;

  4. 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 — проект живой, баг‑репорты реально читаются и чинятся.