Переезд с macOS на Windows для разработчика часто сопровождается болью от потери привычного инструментария. В моем случае решающим стимулом свитчнуться на ПК стала мощная видеокарта. Сейчас мой верный MacBook всё так же лежит на столе и даже подключен к мониторам, но по факту именно Windows (как бы сильно она мне ни не нравилась) стала основной рабочей системой.

И главной болью при этом переходе стал менеджер буфера обмена. На маке я привык к тому, что могу найти скопированный лог недельной давности за секунду, вставить текст без форматирования одним шорткатом и вообще не думать о том, что история куда-то исчезнет.

Штатный инструмент Windows (Win+V) разочаровал моментально: лимит в 25 элементов, отсутствие поиска и полное обнуление после перезагрузки ОС. Поиск альтернатив тоже не увенчался успехом: Ditto надежен, но выглядит как гость из 2005 года, а мощный CopyQ имеет перегруженный интерфейс суровой системной утилиты. Ни в одном из них не было современных функций вроде OCR «из коробки» или базовой интеграции с LLM для обработки текста на лету.

Решение напрашивалось само собой — написать свой велосипед. Но сделать его легким, быстрым и без Electron. В этой статье расскажу о том, как устроен Beetroot — менеджер буфера обмена с бесконечной историей, нативным OCR и AI-трансформациями.

Beetroot
Beetroot

Почему не 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 миграций БД и много-много тестов.

Давайте разберем самые интересные технические решения.

Чистый черный для OLED
Чистый черный для OLED

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(())
    })();
    // ... логика атомарного переименования
}

Здесь есть три критически важных момента:

  1. Порционное копирование: Мы копируем по 100 страниц, после чего отпускаем блокировку на 10мс. Это значит, что во время создания бэкапа менеджер буфера обмена продолжает мгновенно реагировать на новые скопированные элементы.

  2. PRAGMA wal_checkpoint(TRUNCATE): Перед финализацией бэкапа мы принудительно вливаем все WAL-записи в основной файл.

  3. Переход в DELETE mode: Бэкап должен быть единым файлом. Если оставить его в WAL-режиме, рядом появятся файлы -wal и -shm, что усложнит верификацию и перенос.

6. Атомарные операции везде (tmp -> rename)

Прямая запись в файл бэкапа опасна. Если в момент сохранения моргнет свет, мы получим наполовину записанный (битый) бэкап. А при следующем сбое программа попытается восстановиться из него и окончательно потеряет данные.

Поэтому везде используется паттерн tmp -> rename:

  1. Пишем данные во временный файл clipboard...tmp.

  2. Вызываем 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 еще до попытки открыть базу (иначе приложение может упасть просто при чтении битых заголовков). Запускается алгоритм спасения:

  1. Битая БД переименовывается (сохраняется для возможного ручного анализа).

  2. Удаляются старые сайдкары (-wal, -shm).

  3. Приложение перебирает доступные бэкапы (сначала свежие timestamped, затем старые legacy-бэкапы, затем снапшоты версий).

  4. Каждый бэкап перед подменой проверяется на целостность (full_integrity_check). Если антивирус когда-то повредил бэкап, мы его пропустим.

  5. Если найден живой бэкап — восстанавливаемся из него. Если нет (крайний случай) — создаем пустую БД.

После этого пользователю показывается нативное уведомление о том, что произошел сбой и данные были восстановлены.

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? И какие еще неочевидные функции вы используете в своих менеджерах буфера обмена (может, я упустил какую-то киллер-фичу)? Буду рад пообщаться в комментариях!