Как написать свой 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, ¶ms) == 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?
Если кто-то хочет потестировать программу или посмотреть на демо — пишите в личку, скину ссылку.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.