Показываем одно окно Windows на планшете через обычный браузер
Мне нужно было показывать на планшете окно одной программы с рабочего компьютера. Не весь экран, а именно одно окно, и только для просмотра: планшет нужен, чтобы смотреть картинку, а не чтобы кто-то случайно двинул мышкой что-то на ПК.
Готовых решений хватает: Spacedesk, Sunshine с Moonlight, VNC, демонстрация экрана в любом мессенджере для звонков. Часть из них превращает планшет во второй монитор, части нужен клиент на стороне устройства, часть показывает экран целиком, у некоторых свои условия лицензирования, которые стоит прочитать до того, как вы положитесь на инструмент. Мне хотелось чего-то проще: запустил на ПК, открыл адрес в браузере планшета, увидел окно. Получившуюся программу я выложил в открытый доступ. Она никого не заменяет, просто закрывает конкретную задачу, и если у вас такая же, возможно, вам пригодится.
Проект называется WindowStream: https://github.com/eugenyh/WindowStream
Что это
Небольшое приложение для Windows, которое живёт в трее. Вы выбираете окно в меню, и оно начинает транслироваться по локальной сети как MJPEG по HTTP. На планшете, телефоне или другом компьютере достаточно открыть в браузере адрес вида http://192.168.1.20:8080/. Ничего ставить на устройство не надо, управлять ПК с него нельзя.
Написано на Python. Работал я на Windows 11, смотрел с Android-планшета, а также с телефона, подключённого к точке доступа Windows.
На странице просмотра есть зум пальцами (до 8 раз), перемещение картинки, двойное касание, полноэкранный режим и индикатор состояния: идёт трансляция, пауза или нет связи. Если сеть пропала или программу перезапустили, страница переподключается сама. Заголовок вкладки браузера совпадает с заголовком транслируемого окна.
Как это устроено внутри
Захват. Режимов два. В режиме screen через библиотеку mss копируется область окна прямо с экрана. Это быстро и работает почти всегда, но если поверх окна что-то лежит, оно попадёт в кадр. В режиме printwindow вызывается PrintWindow с флагами PW_CLIENTONLY и PW_RENDERFULLCONTENT: окно рисует себя само, поэтому перекрытие другими окнами не мешает. Минус в том, что некоторые приложения с аппаратной отрисовкой отдают таким способом чёрный кадр. Поэтому режим переключается в настройках, и по умолчанию стоит printwindow.
Снимается всегда клиентская область, без заголовка и рамки, в реальных пикселях (процесс объявлен DPI-aware, иначе при масштабе 125 или 150 процентов координаты уезжают).
Не кодировать лишнее. Если картинка в окне не менялась, отправлять нечего. Для каждого кадра считается CRC32 от сырого буфера, и если он совпал с предыдущим, кадр пропускается. У карты, которая большую часть времени стоит на месте, это заметно снижает нагрузку и на процессор, и на сеть.
JPEG кодируется через PyTurboJPEG (обёртка над libjpeg-turbo). При масштабе 1.0 буфер от mss в формате BGRX уходит в кодировщик напрямую, без промежуточного изображения Pillow. Если libjpeg-turbo не найден, программа сообщает об этом и работает через Pillow: медленнее, но работает.
Раздача. Никаких веб-фреймворков: стандартный ThreadingHTTPServer и multipart/x-mixed-replace. Один поток захватывает и кодирует, последний JPEG кладётся в общий буфер вместе с номером кадра и временем захвата. Буфер защищён Condition. Каждое подключение зрителя живёт в своём потоке, ждёт новый номер кадра и отправляет самый свежий. Медленный клиент просто пропускает кадры, очередь не копится, и остальные зрители его не ощущают.
Если картинка не менялась 5 секунд, сервер повторно отправляет последний кадр с тем же номером. Так соединение не считается оборванным, а страница отличает «нового кадра нет» от «связь потеряна». Обычный <img src="/stream"> тоже показывает поток, лишние заголовки браузер игнорирует.
Страница просмотра. Поток на странице разбирается вручную через fetch, потому что мне нужны заголовки X-Seq и X-Ts, из которых считаются частота кадров и задержка. Для задержки страница раз в секунду опрашивает /status, где лежит время ПК, и по измерению с наименьшим временем отклика из последних тридцати оценивает расхождение часов между ПК и планшетом. Это оценка, а не точное измерение, но для ощущения «сколько отстаёт» её хватает.
Мелочи, на которых я застрял
Про них в документации библиотек пишут не очень заметно, поэтому оставлю здесь.
Меню в трее. На Windows pystray собирает меню один раз. Список открытых окон, который должен обновляться, поэтому устаревал: окна, открытые после запуска, в меню не попадали. Помогло наследование от pystray.Icon с перехватом правого клика (WM_RBUTTONUP) и вызовом update_menu() перед показом.
Вывод --help из собранного exe. Я собираю exe через PyInstaller с флагами --onefile --noconsole, чтобы не было чёрного окна. Побочный эффект: у такого приложения нет stdout, и --help или --list ничего не печатают. Простой AttachConsole(ATTACH_PARENT_PROCESS) консоль cmd не находил, потому что --onefile запускает приложение через загрузчик PyInstaller, у которого консоли нет. Пришлось подниматься по цепочке родительских процессов, пока консоль не найдётся. И ещё одна деталь: приглашение cmd возвращается раньше, чем появляется текст (приложение оконное), поэтому для ожидания нужно запускать start /wait "" WindowStream.exe --help. Подключаться к консоли стоит только для этих ключей, иначе закрытие окна консоли завершит трей-приложение.
Поиск окна после смены заголовка. Во многих программах в заголовке окна имя открытого файла. Открыли другой файл, и точное совпадение по заголовку не работает. Поэтому вместе с заголовком запоминается имя процесса, и если заголовок не нашёлся, берётся самое большое видимое окно этого процесса.
Тесты на Linux. Логика настроек, страницы и локализации покрыта юнит-тестами, а тесты запускаются даже без Windows: модули pywin32, winreg, mss и pystray подменяются заглушками. Это окупилось: тесты нашли настоящую ошибку. Некорректное значение в settings.json (например, "port": "abc") роняло загрузку настроек из-за конфликта имён в вызове T(key, key=...), где T это функция перевода строк. Исправил, сделав первый параметр только позиционным.
Как пользоваться
Проще всего взять готовый WindowStream.exe из раздела Releases в репозитории. Python не нужен. Файл не подписан, поэтому SmartScreen и антивирусы могут ругаться, это обычная история для сборок PyInstaller. При первом запуске Windows спросит про брандмауэр: разрешайте только частные сети.
Дальше в трее появится серый значок. Правая кнопка, пункт с окном, выбираете нужное окно, и трансляция стартует. Если навести курсор на значок, в подсказке будут адрес для планшета, частота захвата, частота новых кадров и число подключённых зрителей.
Из исходников:
git clone https://github.com/eugenyh/WindowStream.git
cd WindowStream
pip install -r requirements.txt
python window_stream.pyБиблиотеку libjpeg-turbo лучше поставить отдельно, иначе кодирование пойдёт через Pillow. Настройки лежат в %APPDATA%\WindowStream\settings.json, ключи командной строки главнее файла, а --save записывает параметры запуска в файл. Есть автозапуск вместе с Windows: если нужное окно к моменту входа ещё не открыто, программа ждёт его и проверяет каждые 5 секунд. Интерфейс на английском и русском, руководства для готового exe есть на русском, английском и немецком.
Ограничения, о которых надо знать
Без этого раздела статья была бы нечестной, поэтому пишу прямо.
Пароля и шифрования нет, только обычный HTTP. Любой, кто видит ваш компьютер в сети и знает адрес, может смотреть окно. Пользоваться стоит только в доверенных сетях, не стримить окна с конфиденциальными данными там, где есть посторонние, и не открывать порт в интернет. Если нужен доступ из другой сети, используйте VPN.
Свёрнутое окно не снимается: на планшете остаётся последний кадр. Часть окна за краем экрана не перерисовывается самой Windows. В режиме screen в кадр попадают перекрывающие окна, в режиме printwindow часть приложений даёт чёрную картинку. Окна программ, запущенных от администратора, требуют запуска WindowStream тоже от администратора. Звука нет, управления нет, только Windows.
Вместо итога
Проект небольшой и сделан под конкретную задачу, но в нём вся механика, которая нужна для такой трансляции, и она умещается в один читаемый файл. Лицензия MIT, так что берите, форкайте, разбирайте на части. Если что-то не работает у вас или хочется другой функциональности, пишите в issues на GitHub.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.