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

Каждый, кто работает с удаленными машинами, знает эту боль. Ты подключаешься к серверу, у которого три монитора, а 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?

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

Комментарии (3)


  1. Gromit7
    29.10.2026 20:42

    Litemanager надо пользоваться, только автор его чёт забросил. Автор может выложишь в открытый доступ код? Я допилю, честно честно.


    1. ruslan051982 Автор
      29.10.2026 20:42

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


  1. nikolas00_memew
    29.10.2026 20:42

    Открытый RustDesk. Со своим сервером ретранслятором (элементарно просто поднимается на впс/нас). Есть мелкие неудобства (а может и нет, мне лень разбираться). Сеть там поделена на глобальную и локальную, если в настройках прописать свой сервер то клиентов из глобальной не увидишь и наоборот. Клиенту надо сначала установить rustdesk, потом в настройках вбить адрес твоего ретранслятора - близко к фолу но энидески* так достали выпрашивать деньги что проще уже так.

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