Кошка на рамках окон: сколько WinAPI нужно, чтобы десктопный питомец не мешал работать

Всем привет! Мой первый пост, но не первый проект, хотелось бы собрать фидбек от опытных разрабов или проггеров. Каждый совет делает меня лучше:)
Начну с главного: «навайбкодил», и мне не стыдно (немного если только). Claude писал код, а я сидел и пил чай. На самом деле кучу времени потратил на тесты и несколько дней на составление плана: «что должно быть внутри» и «какой функционал».
Что получилось: пиксельная кошка для Windows 10/11. Живёт на краях экрана и на верхних рамках окон, ест, спит, обижается, смотрит с тобой кино и прячется на созвонах. Главное требование: питомец не должен мешать. Из него и выросла вся техническая часть.
Стек — Tauri 2: Rust для WinAPI, Svelte 5 внутри WebView2. Rust только собирает геометрию и флаги, а поведение кошки — чистые функции на TypeScript, поэтому почти всё покрыто тестами без запуска окна.

Вывод: прозрачный WebView ловит мышь всем прямоугольником
Кошка нарисована в квадрате 32×32 и занимает от силы половину, а клики глотает весь квадрат: у WebView2 прозрачны только пиксели, не hit‑test. SetWindowRgn и layered‑окна не подходят — WebView2 рисует сам. WM_NCHITTEST тоже: мышь получает дочернее окно WebView2, а не наше.
Сделал наоборот. Окно по умолчанию прозрачно для мыши (WS_EX_TRANSPARENT). Отдельный поток раз в 33 мс смотрит, над какой клеткой спрайта курсор, и снимает флаг, только пока курсор над нарисованным пикселем. Форму кадра фронт присылает маской из 32 u32:
let want = !masks.contains(&label, x, y) || alt; // row >> x & 1
if want != ignoring && window.set_ignore_cursor_events(want).is_ok() {
ignoring = want;
}Грабли: окно нельзя сделать уже SM_CXMINTRACK (136 px при 100%), поэтому спрайт центрируется и координаты считаются от квадрата. Пока кнопка зажата, мышь не отпускаем, иначе перетаскивание рвётся. С Alt клик проходит насквозь всегда.
Опрос по таймеру — не самое красивое решение. Если знаете способ лучше для WebView2, жду в комментариях.
Фокус и Esc, который улетал в игру
Все окна неактивируемые (WS_EX_NOACTIVATE), иначе кошка свернёт игру. Но show() в Tauri вызывает ShowWindow(SW_SHOW), а тот активирует окно несмотря ни на что, поэтому меню показывается через SetWindowPos с SWP_NOACTIVATE.
Следом вылез баг: у меню нет фокуса, значит, клавиатура у игры. Нажал Esc, чтобы закрыть меню, — и в игре открылось главное меню. Пришлось ставить WH_KEYBOARD_LL, который съедает Esc, но только пока меню открыто. Важная тонкость: поток хука обязан разбирать сообщения (MsgWaitForMultipleObjects, а не sleep), иначе ввод всей системы ждёт его.
Всё остальное время хука нет, ввод читается через Raw Input в message‑only окно: он не встаёт в цепочку обработки нажатий и не добавляет задержку в играх.

Прогулки по рамкам: GetWindowRect цепляет невидимую рамку
EnumWindows отдаёт окна по z‑order, дальше фильтр, и каждое правило в нём — исправленный баг:
— GetWindowRect врёт. В Windows 10 у окна невидимые поля для ресайза, около 7 px, и кошка висела в воздухе. Видимую рамку отдаёт DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS).
— Невидимые видимые окна. Окна других рабочих столов и призраки UWP проходят IsWindowVisible. Отсекает DWMWA_CLOAKED.
— Оверлеи. NVIDIA и Steam держат прозрачные окна на весь экран поверх всего и «закрывают» все кромки. Признак — WS_EX_TRANSPARENT.
— Перекрытия. От кромки окна вычитаются куски, закрытые окнами выше. Это чистая функция на отрезках с тестами.
— Развёрнутое окно. Ходить можно по заголовку левее кнопок, их даёт DWMWA_CAPTION_BUTTON_BOUNDS, но относительно GetWindowRect, вместе с невидимыми полями.
— Полноэкранное видео. Windows не убирает панель задач из rcWork, и пол висел поверх фильма. Ещё Chrome на весь экран оставляет 1439 px из 1440, так что «на весь экран» считается с допуском.
Заголовки окон не читаются нигде: только рамка, стили, имя класса и exe.
Созвон без списка программ
Кошка на созвоне прячется. Список Zoom/Teams/Discord вечно отстаёт, поэтому я взял источник, откуда Windows берёт значок микрофона в трее:
HKCU\Software\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\microphoneУ каждой программы там LastUsedTimeStart и LastUsedTimeStop. Пока устройство занято, Stop равен нулю. Meet в браузере, Telegram, что угодно — видно без списка.
На живой машине вылезли два исключения. Программа упала, не отпустив микрофон, — Stop остался нулём навсегда, поэтому занятость старше 12 часов не считается. А NVIDIA Broadcast, Voicemeeter и OBS держат микрофон всегда, их пришлось внести в список из четырёх строк. Наружу модуль отдаёт только да/нет, звук не пишется.
Месть, которую не видно на записи
Обидишь кошку — уйдёт за край, а вернувшись, оставит лужу на окне программы, где ты сегодня провёл больше всего времени. Лужа на чужом окне в скриншоте или на демонстрации экрана выглядит как настоящая порча, поэтому её окно всегда исключено из захвата:
SetWindowDisplayAffinity(hwnd, WDA_EXCLUDEFROMCAPTURE)На мониторе видно, в OBS и Zoom — нет. Побочный эффект: для гифки лужу пришлось вклеивать при монтаже.

Цифры и неприятное
— установщик ~2,3 МБ, exe ~6 МБ;
— память — Tauri не волшебный: около 400 МБ частной памяти через 20 минут работы, exe плюс 14 процессов msedgewebview2. Каждое окно (кошка, миска, лежак, облачко, меню…) — свой WebView. Диспетчер задач показывает 8 МБ, но врёт. Ужать — следующая задача, советы приветствуются;
— процессор: 1–3% от восьми ядер;
— 762 теста vitest и 125 cargo;
— в сеть уходят только дата и язык — для новостей по выбранным темам.
Windows при установке скажет «Windows защитила ваш компьютер»: сертификат подписи стоит денег. Лицензия PolyForm Noncommercial — код открыт, продавать нельзя, так что это не open source в строгом смысле. Только Windows: под Linux почти ничего из этого не переносится.
Если поставите — напишите, что мешает. Именно «мешает» мне интереснее всего.
— исходники: Тык (гитхаб)
— сайт и установщик: Тык (тоже гитхаб)
— итч.ио: Тык
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.