Как написать свой AnyDesk на C++ и не сойти с ума: битва с OLE, GDI+ и нейросетями

—

от автора

Привет, Хабр!

Каждый, кто работает с удаленными машинами, знает эту боль. Ты подключаешься к серверу, у которого три монитора, а AnyDesk или TeamViewer показывают тебе один общий экран. Или того хуже — заставляют переключаться между ними. Работать с базами данных или версткой в таких условиях — это пытка.

Так родилась идея написать свою программу для удаленного доступа. Без тяжелых Electron.js оберток, весом в 200 МБ, а нативное приложение на Win32 API, которое весит меньше мегабайта и показывает каждый монитор в отдельном окне.

В этой статье я расскажу, как мы (я и нейросети) строили OmniDesk с нуля, какие костыли пришлось изобретать для буфера обмена и почему TCP_NODELAY — это спасение.

Архитектура: Два канала — два порта

Сначала проект был консольным. Прописываешь IP, запускаешь, смотришь. Но для работы через NAT и обхода блокировок потребовался облачный сервер (Хаб).

Главная архитектурная ошибка, которую я допустил в начале, — попытка запихнуть управление и видео в один TCP-сокет. Когда пошли тяжелые кадры, управление мышью начинало жутко лагать. Пришлось разделить потоки на два порта: 8888 (только команды мыши, клавиатуры и буфер) и 8889 (тяжелый видеопоток). Позже я узнал, что можно было просто открыть два сокета на одном порту, но изоляция каналов сработала безотказно.

Видео на честных 60 FPS

Проблема с захватом экрана оказалась не в самом захвате, а в синхронизации потоков. Программа всегда должна работать как Сервер (ждать подключения), а при необходимости становиться Клиентом. Из-за этого на старте сессии постоянно возникали рассинхроны. Клиент ждал видеопоток, а сервер ждал токен начала трансляции. В итоге окно удаленного монитора не появлялось.

Решили через жесткий формат пакетов. Внутри видео-канала мы определили строгую структуру: сначала шлем MAGIC (маркер), затем ID монитора, затем размер кадра.

Вот как выглядит захват и сжатие экрана (кусок из Server.h):

// Захват экрана и сжатие в JPEG через GDI+static bool CaptureScreenToJpegBuffer(RECT rect, std::vector<char>& out, const CLSID& jpegClsid) {    // ... захват через BitBlt ...    Gdiplus::Bitmap bitmap(bmp, NULL);    IStream* stream = nullptr;    if (CreateStreamOnHGlobal(NULL, TRUE, &stream) == S_OK) {        Gdiplus::EncoderParameters params;        params.Count = 1;        params.Parameter[0].Guid = Gdiplus::EncoderQuality;        params.Parameter[0].Type = Gdiplus::EncoderParameterValueTypeLong;        params.Parameter[0].NumberOfValues = 1;        ULONG quality = 85; // Баланс размера и качества        params.Parameter[0].Value = &quality;                // Сжимаем прямо в память        if (bitmap.Save(stream, &jpegClsid, &params) == Gdiplus::Ok) {            // ... извлекаем байты в буфер ...        }    }    return ok;}

А чтобы избежать задержек, мы наглухо отключили алгоритм Нагла (TCP_NODELAY). Без этого Winsock копит мелкие пакеты, чтобы отправить их разом, что добавляет задержку в 40-200 мс.

Самая большая боль: Буфер обмена и OLE COM

Текст копировать легко. А вот файлы и папки — это ад. Если гнать файл по сети в момент нажатия Ctrl+C, проводник зависнет намертво. А если копируешь папку на 10 гигабайт — ты просто убьешь сеть.

Пришлось лезть в дебри Win32 OLE COM. Windows использует интерфейсы IDataObject и IStream для виртуальных файлов. Идея простая: мы даем системе объект, который притворяется файлом. Когда пользователь жмет Ctrl+V, проводник сам запрашивает у нас данные порциями.

Реализация IStream::Read — это сердце магии. Мы не грузим весь файл в память, а запрашиваем чанки по сети:

STDMETHODIMP Read(void* pv, ULONG cb, ULONG* pcbRead) {    // Формируем запрос: "Сервер, дай мне кусок файла с такого-то смещения"    OleChunkRequest request{};    request.opcode = 4;    request.fileIndex = m_FileIndex;    request.byteOffset = m_BytesRead;    request.bytesRequested = static_cast<uint32_t>(bytesToFetch);    // Шлем запрос    if (::send(g_ActiveClipboardSocket, (const char*)&request, sizeof(OleChunkRequest), 0) <= 0) {        return E_FAIL;    }    // Ждем ответ — бинарный кусок файла    if (!RecvAll(g_ActiveClipboardSocket, (char*)pv, bytesToFetch)) {        return E_FAIL;    }    OmniCrypto::ProcessData((char*)pv, bytesToFetch, MSG_FILE_CHUNK);    m_BytesRead += bytesToFetch;    if (pcbRead != nullptr) *pcbRead = bytesToFetch;    return S_OK;}

Это работает потрясающе. Вы копируете папку с 1000 файлов на удаленной машине, вставляете у себя — и Windows сама подтягивает данные по мере необходимости, не забивая диск и оперативку.

ИИ писал код, а я восстанавливал бэкапы

Отдельная боль — работа с нейросетями. Я не программист в классическом смысле, поэтому 90% кода генерировал Google ИИ (тот, что в поиске по кнопке «Режим ИИ»).

Сначала это казалось магией. Но когда дело дошло до сложной многопоточности (а в удаленном доступе всё многопоточно), начался ад. Нейросеть постоянно путалась в мьютексах. Программа могла спокойно работать несколько часов, а потом вылетать с Access Violation в случайном месте. Кучу раз я откатывал проект из бэкапов, потому что ИИ предлагал «оптимизировать» код, ломал синхронизацию, и всё переставало работать.

Настоящий прорыв случился, когда я перестал просить «напиши мне идеальный код», и начал просить «объясни, почему тут возникает дедлок».

Мультимониторность через один сокет

Сейчас все мониторы передаются через один видеосокет. Сервер просто перебирает их в цикле, делает скриншоты, сжимает и шлет пакеты, в которых указан ID монитора. Клиент читает пакет, смотрит ID и понимает, в какое окно рисовать этот кадр.

// Клиент читает поток и разбирает егоif (!RecvAll(sock, (char*)&magic, sizeof(magic))) break;if (magic != 0x4F4D4E49) break; // Проверка маркераint mId = 0; uint64_t size = 0;if (!RecvAll(sock, (char*)&mId, sizeof(mId))) break;     // Какой монитор?if (!RecvAll(sock, (char*)&size, sizeof(size))) break;   // Размер картинки?// Если ID нет в памяти — создаем окно, если есть — рисуем кадр

Итог

В итоге получилась программа весом меньше мегабайта, которая:

  • Работает нативно под Windows (никакой Electron.js).

  • Шифрует трафик алгоритмом ChaCha20.

  • Поддерживает копирование файлов «на лету» через OLE COM.

  • Показывает каждый монитор в отдельном окне.

Исходники я пока держу закрытыми (в планах коммерческая лицензия), но если вам интересны какие-то конкретные архитектурные решения — добро пожаловать в комментарии. С радостью расскажу, как ловили баги с CreateStreamOnHGlobal или как заставили GDI+ работать без утечек памяти!

А как вы решаете проблему удаленной работы с многомониторными станциями? Или до сих пор мучаетесь с переключением экранов в AnyDesk?

Если кто-то хочет потестировать программу или посмотреть на демо — пишите в личку, скину ссылку.

ссылка на оригинал статьи https://habr.com/ru/articles/1101118/