Переезд с macOS на Windows для разработчика часто сопровождается болью от потери привычного инструментария. В моем случае решающим стимулом свитчнуться на ПК стала мощная видеокарта. Сейчас мой верный MacBook всё так же лежит на столе и даже подключен к мониторам, но по факту именно Windows (как бы сильно она мне ни не нравилась) стала основной рабочей системой.
И главной болью при этом переходе стал менеджер буфера обмена. На маке я привык к тому, что могу найти скопированный лог недельной давности за секунду, вставить текст без форматирования одним шорткатом и вообще не думать о том, что история куда-то исчезнет.
Штатный инструмент Windows (Win+V) разочаровал моментально: лимит в 25 элементов, отсутствие поиска и полное обнуление после перезагрузки ОС. Поиск альтернатив тоже не увенчался успехом: Ditto надежен, но выглядит как гость из 2005 года, а мощный CopyQ имеет перегруженный интерфейс суровой системной утилиты. Ни в одном из них не было современных функций вроде OCR «из коробки» или базовой интеграции с LLM для обработки текста на лету.
Решение напрашивалось само собой — написать свой велосипед. Но сделать его легким, быстрым и без Electron. В этой статье расскажу о том, как устроен Beetroot — менеджер буфера обмена с бесконечной историей, нативным OCR и AI-трансформациями.

Почему не Electron? Архитектура и стек
Создавать фоновую утилиту, которая будет постоянно висеть в трее и отъедать 150-300 МБ оперативной памяти (и это в лучшем случае) только ради отрисовки списка текстов — архитектурное преступление. Electron тащит за собой Chromium и Node.js, что гарантирует стабильный рендеринг, но для легковесной системной утилиты это слишком дорого.
Поэтому стек был выбран следующий: Tauri v2 + React 19 + TypeScript на фронте и Rust + SQLite на бэкенде.
Tauri использует нативный системный WebView (Edge WebView2 в случае Windows). Вся тяжелая логика, работа с ОС и БД вынесена в Rust. Результат:
Размер установщика: ~6 МБ (сравните с 80-150 МБ у базовых Electron-приложений).
Потребление памяти в простое: 30-50 МБ.
12
.rsфайлов, 25 IPC команд, 7 миграций БД и много-много тестов.
Давайте разберем самые интересные технические решения.

1. Мониторинг буфера обмена и детекция менеджеров паролей
Самая частая проблема самописных буферов — они воруют пароли из 1Password или Bitwarden. В Beetroot используется event-driven подход (через tauri-plugin-clipboard, никакого постоянного поллинга), поверх которого накручена кастомная логика детекции чувствительных данных через Win32 API.
Когда в буфер попадает новый элемент, мы перечисляем все его форматы. Если находим специфические маркеры (например, Clipboard Viewer Ignore или ExcludeClipboardContentFromMonitorProcessing), элемент отбрасывается.
// Перечисляем все форматы через Win32 API pub fn get_format_names() -> Vec<String> { unsafe { if OpenClipboard(HWND::default()).is_err() { return vec![]; } let mut format = EnumClipboardFormats(0); while format != 0 { if format >= 0xC000 { // Кастомные форматы let mut buf = [0u16; 256]; let len = GetClipboardFormatNameW(format, &mut buf); if len > 0 { names.push(String::from_utf16_lossy(&buf[..len as usize])); } } format = EnumClipboardFormats(format); } let _ = CloseClipboard(); } }
Дополнительно реализован троттлинг в 300мс, чтобы не захлебнуться от спама событиями, лимиты на размер (1 МБ для текста, 50 МБ для изображений) и exponential backoff (до 3 попыток) на случай, если буфер заблокирован другим приложением.
2. Н��тивный OCR без раздувания бинарника
Часто для распознавания текста (OCR) разработчики тянут тяжеловесный Tesseract или отправляют данные в облако. Я решил использовать нативный движок самой Windows (начиная с Windows 10) — Windows.Media.Ocr.
Для этого используется крейт windows (0.58). Мы берем изображение, декодируем его, конвертируем в нужный формат (Bgra8) и скармливаем локальному движку:
pub fn recognize_text(image_path: &str) -> Result<String, AppError> { let file = StorageFile::GetFileFromPathAsync(&path)...; let decoder = BitmapDecoder::CreateAsync(&stream)...; let raw_bitmap = decoder.GetSoftwareBitmapAsync()...; // OCR требует BGRA8 let bitmap = SoftwareBitmap::Convert(&raw_bitmap, BitmapPixelFormat::Bgra8)?; // Движок из языковых пакетов Windows let engine = OcrEngine::TryCreateFromUserProfileLanguages()?; let result = engine.RecognizeAsync(&bitmap)...; Ok(result.Text()?.to_string()) }
Интересная деталь: При вызове может вернуться ошибка E_POINTER (0x80004003). Это означает, что в ОС не установлены нужные языковые пакеты. В Beetroot эта ситуация перехватывается и пользователю выдается понятное сообщение.
3. Global Hotkeys и магия с раскладками
Менеджер буфера обмена должен вызываться мгновенно поверх всех окон. Для этого в Rust-бэкенде крутится отдельный поток с циклом сообщений Windows (Message Loop).
fn hotkey_thread_main(app_handle: AppHandle, ...) { // Форсируем создание message queue unsafe { PeekMessageW(&mut msg, None, 0, 0, PM_NOREMOVE); } // Таймер: проверяем раскладку каждые 250ms unsafe { SetTimer(None, 0, 250, None); } loop { let ret = unsafe { GetMessageW(&mut msg, None, 0, 0) }; match msg.message { WM_HOTKEY => handle_hotkey_press(...), WM_TIMER => handle_layout_change(...), // Поллинг (fallback) WM_LAYOUT_CHANGED => handle_layout_change(...), // WM_INPUTLANGCHANGE → subclass → сюда _ => {} } } }
Здесь реализована двойная система детекции смены раскладки: таймер на 250мс как fallback и мгновенное срабатывание через window subclass (перехват WM_INPUTLANGCHANGE).
Отдельная боль — работа с AltGr на не-US раскладках (AZERTY, QWERTZ). Система воспринимает AltGr как Ctrl+Alt. Поэтому при регистрации шорткатов мы проверяем раскладку и, если нужно, регистрируем обе комбинации:
if needs_altgr_variant(def) { let altgr_mods = win_mods | MOD_CONTROL; manager.register(altgr_id, altgr_mods, def.code)?; }
А чтобы окно появлялось ровно на том мониторе, где сейчас находится курсор мыши (и не приходилось искать его на втором экране), мы оперируем физическими пикселями, чтобы избежать багов с масштабированием (DPI mismatch):
fn try_center_on_cursor_monitor(win: &WebviewWindow) { let cursor = win.cursor_position()?; let monitors = win.available_monitors()?; let target = monitors.iter().find(|m| { cursor.x >= x && cursor.x < x + w && cursor.y >= y && cursor.y < y + h }); // Все координаты в ФИЗИЧЕСКИХ пикселях let x = (mon_x + (mon_w - win_w) / 2.0).max(mon_x).min(mon_x + mon_w - win_w); win.set_position(PhysicalPosition::new(x as i32, y as i32))?; }
4. Как работает вставка текста (IPC Tauri)
Когда пользователь выбирает элемент в React-интерфейсе, фронтенд вызывает Tauri команду (IPC). В Tauri v2 этот мост работает очень быстро.
Процесс вставки выглядит так: мы прячем окно утилиты, ждем 50мс (чтобы фокус вернулся в предыдущее приложение), и затем атомарно эмулируем нажатие Ctrl+V через Win32 API.
#[tauri::command] pub fn paste_selected_item(app: tauri::AppHandle) -> Result<(), AppError> { win.hide()?; std::thread::sleep(Duration::from_millis(50)); // 4 события: Ctrl down, V down, V up, Ctrl up — атомарно через SendInput let inputs = [ kbd_input(VK_CONTROL, 0), kbd_input(VK_V, 0), kbd_input(VK_V, KEYEVENTF_KEYUP), kbd_input(VK_CONTROL, KEYEVENTF_KEYUP), ]; unsafe { SendInput(4, inputs.as_ptr(), ...); } }
Важный нюанс: при программной вставке Beetroot сам модифицирует буфер обмена. Чтобы не записать этот же элемент в историю как новый (бесконечный цикл), мы ставим "suppress flag" на 2 секунды, заставляя наш слушатель игнорировать этот конкретный ивент.
5. Как правильно бэкапить SQLite в WAL-режиме
Beetroot использует режим WAL (Write-Ahead Logging) для SQLite, чтобы фоновый слушатель мог писать в базу, пока UI-поток выполняет поиск. Из-за этого базу нельзя просто скопировать средствами ОС — вы получите битый файл, если в этот момент идут транзакции.
Единственный безопасный путь — использовать официальный SQLite Backup API. В Rust (через крейт rusqlite) это реализовано так:
fn sqlite_backup_to_file(conn: &rusqlite::Connection, dest: &Path) -> Result<(), Box<dyn std::error::Error>> { let tmp = dest.with_extension("tmp"); let copy_result = (|| -> Result<(), Box<dyn std::error::Error>> { let mut dst = rusqlite::Connection::open(&tmp)?; let backup = rusqlite::backup::Backup::new(conn, &mut dst)?; // 100 страниц за итерацию, 10ms пауза backup.run_to_completion(100, Duration::from_millis(10), None)?; drop(backup); // Checkpoint WAL в основной файл, переключаем на DELETE mode let _ = dst.execute_batch("PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"); drop(dst); Ok(()) })(); // ... логика атомарного переименования }
Здесь есть три критически важных момента:
Порционное копирование: Мы копируем по 100 страниц, после чего отпускаем блокировку на 10мс. Это значит, что во время создания бэкапа менеджер буфера обмена продолжает мгновенно реагировать на новые скопированные элементы.
PRAGMA wal_checkpoint(TRUNCATE): Перед финализацией бэкапа мы принудительно вливаем все WAL-записи в основной файл.Переход в DELETE mode: Бэкап должен быть единым файлом. Если оставить его в WAL-режиме, рядом появятся файлы
-walи-shm, что усложнит верификацию и перенос.
6. Атомарные операции везде (tmp -> rename)
Прямая запись в файл бэкапа опасна. Если в момент сохранения моргнет свет, мы получим наполовину записанный (битый) бэкап. А при следующем сбое программа попытается восстановиться из него и окончательно потеряет данные.
Поэтому везде используется паттерн tmp -> rename:
Пишем данные во временный файл
clipboard...tmp.Вызываем
fs::rename(), который на файловых системах NTFS/ext4 является атомарной операцией.
Результат: частично перезаписанного файла не бывает в природе. В Beetroot этот паттерн применяется абсолютно везде, в том числе при сохранении картинок из буфера обмена.
7. Трёхфазное восстановление (Crash Recovery)
Система должна уметь лечить себя сама. Процесс восстановления (Recovery Flow) в Beetroot работает следующим образом:
Фаза 1: Детекция
При каждом запуске приложения выполняется PRAGMA quick_check(1) — быстрая проверка структуры БД. При обновлении версии приложения запускается уже full_integrity_check, который проверяет не только структуру, но и сами данные.
Если в рантайме (во время работы) база ловит ошибку SQLITE_CORRUPT (11) или SQLITE_NOTADB (26), приложение понимает, что дело дрянь, и создает рядом с базой пустой файл-маркер FORCE_RECOVERY.
Фаза 2 и 3: Лечение
При следующем запуске Beetroot видит маркер FORCE_RECOVERY еще до попытки открыть базу (иначе приложение может упасть просто при чтении битых заголовков). Запускается алгоритм спасения:
Битая БД переименовывается (сохраняется для возможного ручного анализа).
Удаляются старые сайдкары (
-wal,-shm).Приложение перебирает доступные бэкапы (сначала свежие timestamped, затем старые legacy-бэкапы, затем снапшоты версий).
Каждый бэкап перед подменой проверяется на целостность (
full_integrity_check). Если антивирус когда-то повредил бэкап, мы его пропустим.Если найден живой бэкап — восстанавливаемся из него. Если нет (крайний случай) — создаем пустую БД.
После этого пользователю показывается нативное уведомление о том, что произошел сбой и данные были восстановлены.
8. Главный враг SQLite: Облачные диски
Многие пользователи любят переносить папку с данными (%LOCALAPPDATA%) в OneDrive или Dropbox, чтобы синхронизировать буфер обмена между компьютерами. Для SQLite в WAL-режиме это смертный приговор.
Облачные клиенты могут:
Синхронизировать основной
.dbфайл, но не успеть загрузить.db-wal.Удалить файл
-shm, посчитав его "временным мусором".Заблокировать файл (lock) прямо в момент записи транзакции приложением.
Поэтому в Beetroot встроена система детекции. При старте приложение проверяет путь к базе:
const CLOUD_SYNC_MARKERS: &[(&str, &str)] = &[ ("onedrive", "OneDrive"), ("dropbox", "Dropbox"), ("google drive", "Google Drive"), ("icloud", "iCloud"), ]; // ... логика поиска маркеров в пути ...
Если база находится внутри облачной папки, Beetroot не блокирует работу жестко, но выводит явное предупреждение о высоком риске потери данных.
А вот если база лежит на сетевом диске (Network Drive), который система определяет через Win32 API GetDriveTypeW, то запуск блокируется полностью, так как сетевые протоколы (SMB/NFS) не гарантируют корректную работу локальных файловых блокировок (locks), критически важных для SQLite.
Итоги: выжимаем мегабайты и убиваем зависимость от облака
Чтобы впихнуть полноценный менеджер с базой данных, нативным OCR и системными хуками в 6 МБ, пришлось выкрутить ручки компилятора на максимум. В Cargo.toml включен режим суровой диеты: opt-level = 3, lto = true, codegen-units = 1 (собирается дольше, зато бинарник худеет) и strip = true.
Я делал инструмент в первую очередь для себя, поэтому в приложении нет ни байта телеметрии. AI-трансформации работают по параноидальной модели BYOK (Bring Your Own Key) — запросы летят напрямую с вашего ПК на серверы OpenAI, минуя любые сторонние прокладки. Но моя главная цель на ближайшие релизы — полная интеграция с Ollama. Хочу, чтобы рефакторинг скопированного кода или саммаризация текстов летали на локальных Llama 3 / Qwen, вообще без доступа в интернет и оглядки на платные API.
Если вы всё ещё пишете десктопные фоновые утилиты на Electron — искренне советую пощупать Tauri v2. Оставлять привычный React на фронтенде для красивого UI, а всю системную грязь, работу с памятью и потоками спускать в быстрый Rust — это, пожалуй, лучший DX (Developer Experience), который я получал за последнее время.
Где посмотреть и потрогать:
Релизы и документация: GitHub — beetroot-releases
Пакетные менеджеры: ставится в одну команду через
winget,scoopилиchocolatey.
Интересно услышать от Хаброжителей: сталкивались ли вы с мистическими багами Edge WebView2 при разработке под Windows? И какие еще неочевидные функции вы используете в своих менеджерах буфера обмена (может, я упустил какую-то киллер-фичу)? Буду рад пообщаться в комментариях!
