Вкладка браузера, которая притворяется X11-сервером: настоящий рабочий стол Linux, игры из GNOME и даже Firefox рисуются на <canvas> — а сами приложения живут в Docker и не подозревают, что говорят с браузером.
Живой демки в этот раз нет: в отличие от прошлых проектов, одной вкладкой не обойтись — приложениям нужен настоящий Linux в Docker. Поэтому — видео.
Запускаю сапёр (gnome-mines), маджонг и Firefox — всё рисуется прямо в браузере.
Что это
x11.js — это X11-сервер, который работает во вкладке браузера. Не эмулятор и не «оформление под Linux»: настоящий X-сервер. Он разбирает протокол X11, рисует окна на <canvas> и превращает движения мыши и нажатия клавиш в X-события. А приложения — рабочий стол MATE, gnome-mines, маджонг, Tetravex, файловый менеджер Caja и даже Firefox — запускаются в Docker и рисуются в эту вкладку, не подозревая, что на том конце браузер.

gnome-mahjongg: плитки, символы и объёмные грани нарисованы через RENDER и собраны на canvas.
Сервер и клиент — наоборот
В X11 есть приятная путаница в словах. Сервер здесь — это не «что-то в облаке», а сам дисплей: то, что рисует окна и читает ввод. А клиенты — это приложения; они подключаются к серверу и просят его «нарисуй прямоугольник», «выведи текст», «отдай события клавиатуры».
x11.js ставит сервер (дисплей) — в браузер, а клиентов (приложения) — в Docker. Слова оказываются вывернутыми наизнанку относительно того, где что крутится: «сервер» — у вас во вкладке, а «клиентские» Firefox и игры — на бэкенде.
Как это устроено
Между ними — тупой мост. В Docker крутится крошечный Node-процесс: с одной стороны он слушает обычный Unix-сокет дисплея :0 (/tmp/.X11-unix/X0), с другой — WebSocket. Всё, что он делает, — перекладывает сырые байты протокола туда-сюда. Про X11 он не знает ровным счётом ничего.
Вся логика — в браузере. В packages/client (TypeScript + Vite) живёт настоящая реализация сервера: парсер запросов, рисование на canvas, превращение DOM-событий в X-события. Приложение в контейнере открывает DISPLAY=:0, шлёт запросы в сокет — а отвечает ему вкладка браузера через мост.
Что пришлось реализовать
X11 — протокол старый и большой, но чтобы поднять настоящий GTK-рабочий стол, нужно немало:
- Ядро протокола — окна, свойства, рисование примитивов,
PutImage. - RENDER — самое важное для современных приложений. Через него идёт весь текст (глифами), композитинг, градиенты, трансформации и цветные ARGB-курсоры. Без RENDER не было бы ни шрифтов, ни иконок.
- Ввод — клавиатура (через XKB), мышь, пассивные и неявные захваты указателя, фокус и даже copy-paste между приложениями.
- Оконный менеджер. Сам по себе X окна не двигает — этим занимается отдельный клиент. Я взял
metacity: он таскает и ресайзит окна, поднимает по клику, а кнопки «свернуть/развернуть/закрыть» работают через EWMH-сообщения.
Плюс заглушки для XInput2, RANDR, MIT-SHM и SHAPE — ровно настолько, чтобы приложения не падали на старте.
Отдельная маленькая история — разделяемая память. GTK любит грузить картинки через MIT-SHM: положить пиксели в shared memory и сказать серверу «забери оттуда». Но у браузера доступа к памяти контейнера нет. Решение простое: не объявлять это расширение — и приложения сами откатываются на обычную пересылку картинок по сокету. Иконки, которые поначалу были пустыми, тут же появились.

Caja, файловый менеджер MATE: иконки папок — это SVG из adwaita-icon-theme, отрисованные через RENDER.
Что было сложнее всего
- Курсоры. Долго указатель оставался обычной стрелкой везде. Оказалось, современный GTK делает курсоры (текстовый «I-beam», ресайзные стрелки) тоже через RENDER — а я это игнорировал. Стоило реализовать
RENDER CreateCursor, как указатель начал менять форму над текстом и над рамкой окна. - Панель задач. Кнопки списка окон на нижней панели так и не заработали: это вынесенный в отдельный процесс XEmbed-апплет, и его GTK не доводит клики до своих безоконных кнопок. До апплета клик доходит — а дальше тишина. Пожалуй, единственная честная дыра.
- Одна вкладка. Мост держит ровно одно соединение: откроешь вторую вкладку — она перехватит сессию и отключит первую. Я сам на этом попался: запустил в двух местах и долго не мог понять, почему Firefox внезапно умирает.
Firefox
Про Firefox стоит сказать отдельно — это лучший стресс-тест. Многопроцессный, тяжёлый, с непростым UI. И он работает: открывается, показывает вкладки и адресную строку, ходит в настоящий интернет по HTTPS и рендерит страницы. Пришлось только заставить его рисовать программно — GLX/OpenGL у нас нет, — но это одна переменная окружения. Если уж в этом X-сервере живёт Firefox, значит, протокола реализовано достаточно.

Настоящий Firefox: вкладки, адресная строка, замочек HTTPS — и страница, отрисованная в canvas.
Как делалось
Почти всё это собрано в паре с LLM-агентом. Я ставил задачу — «почини вот это, проверь, что запускается такое-то приложение» — а агент копался в протоколе, ловил расхождения в байтах, сверял с тем, что ждёт GTK, и чинил. Моя роль была скорее направлять и проверять результат глазами в браузере. Кроме самого x11.js, получился ещё и любопытный опыт: насколько далеко можно уехать на таком взаимодействии. Оказалось — довольно далеко.
Итог
Получилась штука, которую приятно открыть и потыкать: не имитация Linux, а настоящий X11-сервер во вкладке — с рабочим столом, играми, файловым менеджером и браузером. Приложения при этом самые обычные, из репозиториев Debian; они даже не знают, что их дисплей — это <canvas> где-то в Chrome.