X11 это тот механизм на чем работает весь графический интерфейс Unix подобных ОС.
Но мало кто знает как он работает на самом деле. Потому что с годами он оброс слоями и слоями библиотек, которые стремятся скрыть саму сущность протокола.
А протокол в своей сути прекрасен. Он лаконичен и почти совершенен.
В Интернете есть полная документация по протоколу. Но дело в том, что эта документация большая, написана не совсем ясным языком и по сути представляет просто спецификация. Важные моменты никак не обозначены, а как использовать тоже оставлено на фантазии читателя.
А все книги и статьи по использованию X11 описывают это через библиотеки прокладки типа XLib и XCB, и даже, что хуже, GTK или Qt.
Так что документацию приходится читать всю и самому выделять что важно, а что не очень. Придумывать сценарии использования и писать хотя бы короткие программы чтобы испробовать как все работает на самом деле.
Как бы то ни было, если кому-то интересно как все работает на самом деле, пожалуйста под кат.
Суть
Суть X11 в том, что есть программа сервер (X server) которая ожидает подключения и выполняет те команды которые получает от клиента. Например создать графическое окно. Нарисовать что-то и так далее.
Клиенты подключаются к серверу через обычный сокет. Посылают команды и получают обратно ответы, ошибки, если что-то пошло не так, а также события (например перемещения мыши, нажимания на кнопки и т.п.)
Клиент, по сути это консольная программа, которая с графикой не имеет ничего общего, кроме этого сетевого соединения.
Протокол
Весь основной протокол описан в документе X Window System Protocol
Самое полезное в этом документе, это приложение «B», где описано побайтно что и куда присылается и принимается.
Я буду цитировать отрывки, чтобы иллюстрировать текст.
Идентификаторы
Все объекты в X имеют идентификатор. Это 32 битовое число, которое генерирует клиент и передает серверу чтобы обозначит создаваемый объект. Например окно, курсор, картинка и т.д.
Другой тип идентификаторы это ATOM. Атомы это тоже 32 битовые числа, но их генерирует сервер. Клиент передает серверу какую-то символьную строку, а сервер в ответ дает число. На одинаковых строках всегда соответствует одинаковое число. Это похоже на хеширование, но сделано по другому – сервер просто хранит список строк и присваивает им номера. Если какой-то клиент запросит атом для строки которая уже находится в списке, ему возвращают номер строки в списке.
Атомы используются прежде всего чтобы разные клиенты могли обменивать информацию друг с другом используя стандартные текстовые идентификаторы.
А чтобы не грузить сетевой обмен длинными текстовыми идентификаторами, передаются собственно числа.
Чтобы снизить нагрузку на сервер, самые важные атомы определены в стандарте и всегда имеют одни и те же значения. Если кому-то интересно, список здесь:
То что написано большими буквами, является та строка из которой генерирован атом:
atomPRIMARY = 1
atomSECONDARY = 2
atomARC = 3
atomATOM = 4
atomBITMAP = 5
atomCARDINAL = 6
atomCOLORMAP = 7
atomCURSOR = 8
atomCUT_BUFFER0 = 9
atomCUT_BUFFER1 = 10
atomCUT_BUFFER2 = 11
atomCUT_BUFFER3 = 12
atomCUT_BUFFER4 = 13
atomCUT_BUFFER5 = 14
atomCUT_BUFFER6 = 15
atomCUT_BUFFER7 = 16
atomDRAWABLE = 17
atomFONT = 18
atomINTEGER = 19
atomPIXMAP = 20
atomPOINT = 21
atomRECTANGLE = 22
atomRESOURCE_MANAGER = 23
atomRGB_COLOR_MAP = 24
atomRGB_BEST_MAP = 25
atomRGB_BLUE_MAP = 26
atomRGB_DEFAULT_MAP = 27
atomRGB_GRAY_MAP = 28
atomRGB_GREEN_MAP = 29
atomRGB_RED_MAP = 30
atomSTRING = 31
atomVISUALID = 32
atomWINDOW = 33
atomWM_COMMAND = 34
atomWM_HINTS = 35
atomWM_CLIENT_MACHINE = 36
atomWM_ICON_NAME = 37
atomWM_ICON_SIZE = 38
atomWM_NAME = 39
atomWM_NORMAL_HINTS = 40
atomWM_SIZE_HINTS = 41
atomWM_ZOOM_HINTS = 42
atomMIN_SPACE = 43
atomNORM_SPACE = 44
atomMAX_SPACE = 45
atomEND_SPACE = 46
atomSUPERSCRIPT_X = 47
atomSUPERSCRIPT_Y = 48
atomSUBSCRIPT_X = 49
atomSUBSCRIPT_Y = 50
atomUNDERLINE_POSITION = 51
atomUNDERLINE_THICKNESS= 52
atomSTRIKEOUT_ASCENT = 53
atomSTRIKEOUT_DESCENT = 54
atomITALIC_ANGLE = 55
atomX_HEIGHT = 56
atomQUAD_WIDTH = 57
atomWEIGHT = 58
atomPOINT_SIZE = 59
atomRESOLUTION = 60
atomCOPYRIGHT = 61
atomNOTICE = 62
atomFONT_NAME = 63
atomFAMILY_NAME = 64
atomFULL_NAME = 65
atomCAP_HEIGHT = 66
atomWM_CLASS = 67
atomWM_TRANSIENT_FOR = 68
Запросы
Все запросы в X11 бинарные, с полями разной длины. По сути, здесь есть поля длиной в 1 байт, 2 байта и 4 байта.
Первые 4 байта запроса всегда присутствуют и всегда содержат одинаковую информацию:
Смещение | Длина | Содержание |
---|---|---|
0 | 1 | Код команды. Основной протокол использует только значения от 1 до 127, а значения больше 127 выделены расширениям. |
1 | 1 | Подкоманда или какой-то параметр запроса длиной в 1 байт или не используется. |
2 | 2 | Длина всего запроса в двойных словах (4 байта). |
Прочтя этот заголовок, сервер уже знает сколько байт (а точнее двойные слова) еще надо прочесть чтобы забрать весь запрос.
Чтобы не быть слишком голословным покажу простой пример:
Запрос «DestroyWindow» кодируется вот так (допустим хотим закрыть окно с ID 0x12345678):
Смещение | Длина | Значение | Заметки |
---|---|---|---|
0 | 1 | 0x03 | 3 это код операции DestroyWindow |
1 | 1 | 0x00 | Не используется. Значение может быть любое. Сервер все-равно его не смотрит. |
2 | 2 | 0x0002 | Длина запроса 2 двойных слова или 8 байт. |
4 | 4 | 0x12345678 | Идентификатор окна. |
Или в итоге, по сокете уходит вот что: 03 00 02 00 78 56 34 12
Получив этот запрос, X сервер закроет окно с идентификатором 0x12345678
В документации протокола (а точнее в приложении), вот это запрос DestroyWindow описан следующим синтаксисом:
1 4 opcode
1 unused
2 2 request length
4 WINDOW window
А сейчас что-то посложнее: «CreateWindow».
Предварительно надо выбрать идентификатор окна. Выберем опять 0x12345678 чтобы было попроще.
Еще понадобиться идентификатор коренного окна (это служебное окно, которое занимает весь дисплей и является родительским для всех окон верхнего уровня. Допустим его идентификатор 0x9abcdef0 (а откуда взять реальные значения, я расскажу немножко позже).
Смещение | Длина | Имя поля | Значение | Заметки |
---|---|---|---|---|
0 | 1 | opcode | 0x01 | Операция CreateWindow == 1 |
1 | 1 | depth | 0x00 | Глубина цвета окна. 0 значит CopyFromParent |
2 | 2 | length | 0x0008 | |
4 | 4 | wid | 0x12345678 | Идентификатор который выбрали. |
8 | 4 | parent | 0x9abcdef0 | |
12 | 2 | x | 0x64 | Это X координата верхнего левого угла нашего окна. |
14 | 2 | y | 0x65 | Это Y координата окна. |
16 | 2 | width | 0xc8 | Ширина окна. |
18 | 2 | height | 0x66 | Высота окна. |
20 | 2 | border | 0x0000 | Ширина рамки окна. |
22 | 2 | class | 0x0001 | 1 это окно InputOutput. Есть и InputOnly, но они слишком специфические (да и не окна по сути) и их не будем рассматривать здесь. |
24 | 4 | visual | 0x00000000 | 0 значит скопировать из родителя. Visual это какое-то абстрактное представление экрана в котором я так и не разобрался. Но CopyFromParent работает всегда. ;) |
28 | 4 | value_mask | 0x00000000 | Здесь кончается фиксированная часть запроса. (длиной в 8 двойных словах). Если нужно, можно задать дополнительные параметры окна. Для этого нужно в value_mask поставит единицы в некоторые биты, поставить необходимые параметры после 32го байта запроса и соответственно увеличит длины запроса в поле length на нужное число двойных слов. |
И так итоговый запрос который отправляем на сокет: 01 00 08 00 78 65 43 21 f0 de bc 9a 64 65 c8 66 00 00 01 00 00 00 00 00 00 00 00 00
Вот и полное описание запроса в приложении протокола:
1 1 opcode
1 CARD8 depth
2 8+n request length
4 WINDOW wid
4 WINDOW parent
2 INT16 x
2 INT16 y
2 CARD16 width
2 CARD16 height
2 CARD16 border-width
2 class
0 CopyFromParent
1 InputOutput
2 InputOnly
4 VISUALID visual
0 CopyFromParent
4 BITMASK value-mask (has n bits set to 1)
#x00000001 background-pixmap
#x00000002 background-pixel
#x00000004 border-pixmap
#x00000008 border-pixel
#x00000010 bit-gravity
#x00000020 win-gravity
#x00000040 backing-store
#x00000080 backing-planes
#x00000100 backing-pixel
#x00000200 override-redirect
#x00000400 save-under
#x00000800 event-mask
#x00001000 do-not-propagate-mask
#x00002000 colormap
#x00004000 cursor
4n LISTofVALUE value-list
VALUEs
4 PIXMAP background-pixmap
0 None
1 ParentRelative
4 CARD32 background-pixel
4 PIXMAP border-pixmap
0 CopyFromParent
4 CARD32 border-pixel
1 BITGRAVITY bit-gravity
1 WINGRAVITY win-gravity
1 backing-store
0 NotUseful
1 WhenMapped
2 Always
4 CARD32 backing-planes
4 CARD32 backing-pixel
1 BOOL override-redirect
1 BOOL save-under
4 SETofEVENT event-mask
4 SETofDEVICEEVENT do-not-propagate-mask
4 COLORMAP colormap
0 CopyFromParent
4 CURSOR cursor
0 None
Немножко сложнее, но надеюсь более-менее понятно… Сложность здесь из-за того, что в запросе можно передать кучу параметров окна разного вида и формата. Но по сути все идет последовательно и более-менее логично.
После получения этого запроса, сервер создает окно с заданными параметрами. Но это окно не появится, так как все еще не показано на экране. Делаем это через запрос «MapWindow». На фоне прежнего, он совсем простенький:
Смещение | Длина | Имя поля | Значение | Заметки |
---|---|---|---|---|
0 | 1 | opcode | 0x08 | Операция MapWindow == 8 |
1 | 1 | 0x00 | Не используется | |
2 | 2 | length | 0x0002 | Длина 8 байт. |
4 | 4 | wid | 0x12345678 | Это ID нашего окна. |
На сокет уходит: 08 00 02 00 78 56 34 12
а окно становится видным.
Ответы
Сервер тоже присылает нам по сокете информацию. Она бывает 3 вида: Ответы (Reply), События (Events) и Ошибки (Errors).
Все три виды имеют длину минимум 32 байта. (А события и ошибки всегда точно 32 байта). Так что чтение из сервера происходит всегда порциями в 32 байта и если это Reply из тела ответа берем длина дополнительной части и читаем ее тоже.
Вся информация с сервера приходит асинхронно, но ответы и ошибки всегда приходят в порядке запросов чьими результатами они являются.
- Ответы на запросы (Reply). Если запрос предполагает ответ от сервера, то сервер его присылает по сокете, как только обработает запрос. Если ответ содержит информацию, которая помещается в 32 байта, то это все что нужно принять. Если ответ длиннее, то в его теле содержится длина дополнительной части ответа.
Общий формат ответа такой:
Смещение | Длина | Имя поля | Значение | Заметки |
---|---|---|---|---|
0 | 1 | code | 1 | 1 == Reply |
1 | 1 | ? | ? | Или не используется или используется для какой-то часть ответа, длиной в 1 байт. |
2 | 2 | sequence | s | Это номер запроса, на котором ответ. |
4 | 4 | length | n | Длина ответа сверх первых 32 байта в двойных словах. Если не 0, то надо прочитать с сокета еще 4*n байта, чтобы взять весь ответ. |
- События (Events). Содержат те же 32 байта и генерируются в ответ на какие-то события в GUI. Чтобы получать некоторые события, клиент должен подписаться на них, когда создает окно, например.
Некоторые события обще-системного характера присылаются всегда и всем.
Формат событии такой:
Смещение | Длина | Имя поля | Значение | Заметки |
---|---|---|---|---|
0 | 1 | code | 2..127 [+128] | > 1 для событий. Если событие прислано от другого клиента через SendEvent, то к номеру события прибавляется 128. (Старший бит устанавливается в 1). |
1 | 1 | detail | ? | Деталь о событии если помещается в 1 байт. |
2 | 2 | sequence | ? | Номер запроса, после которого случилось событие. |
4 | 4 | timestamp | time | Время возникновения события |
8 | 24 | ? | Зависят от события. |
- Ошибки. Присылаются если какой-то запрос клиента нельзя было исполнить потому что содержит какую-то ошибку в данных или параметрах. Формат ошибки такой:
Смещение | Длина | Имя поля | Значение | Заметки |
---|---|---|---|---|
0 | 1 | code | 0 | 0 == Error |
1 | 1 | error code | 1..255 | Код ошибки. |
2 | 2 | sequence | ? | Номер ошибочного запроса. |
4 | 28 | data | ? | Подробности об ошибке. Зависит от кода ошибки. |
Подключение.
А сейчас сделаем шаг назад и рассмотрим наверное самое сложное в X11 – подключение к серверу. К сожалению процедура сложная и запутанная и является камнем преткновения для прямого использования X11.
Именно подключение поднимает уровень вхождения в технологию.
Как мы увидели само использование протокола достаточно просто. Но подключение – это что-то с чем-то!
Само подключение по сути простое создаем сокет и выполняем connect на него. Но сперва надо узнать адрес сервера. Для этого есть алгоритм:
Смотрим на содержание переменной окружения DISPLAY
. Если существует, она содержит адрес X11 сервера в формате: [host]:D.S
.
host – это хост сервера. Это может быть имя домейна, может быть строкой "/unix" или просто отсутствовать. Отсутствующий host
равен "/unix"
и означает что сервер слушает на unix domain сокете на локальной машине.
Кстати, это самый частый случай. Если host присутствует, это значит что подключаться надо к этому хосту, по TCP, через IP6 адрес.
D
это номер дисплея, а S
это номер экрана. В большинстве случаев на современных конфигурациях номер экрана будет 0, даже если мониторы больше одного. Все они виртуально объединены в один экран.
От номера дисплея зависит порт подключения к серверу. Если по TCP, то сервер слушает на порт 6000+D. Если подключаемся через unix domain сокет, он находится по адресу /tmp/.X11-unix/X{D}
– то есть, нулевой дисплей на /tmp/.X11-unix/X0
, первый на /tmp/.X11-unix/X1
и т.д.
И вот, мы подключились к сокету. После подключения, нельзя просто так посылать запросы. Надо сперва отправить на сервер информацию о себе и авторизоваться на сервере.
Все это содержится в первом (а точнее нулевом) запросе, который нестандартный и содержит:
Смещение | Длина | Имя поля | Значение | Заметки |
---|---|---|---|---|
0 | 1 | byte_order | "B" или "l" | B (0x42) означает BIG-ENDIAN, a "l"(0x6c) – little-endian. |
1 | 1 | 0x00 | Не используется | |
2 | 2 | major_ver | 11 | Мажорная версия протокола |
4 | 2 | minor_ver | 0 | Минорная версия протокола |
6 | 2 | auth_proto_len | n | Длина имя протокола авторизации |
8 | 2 | auth_data_len | d | Длина данных авторизации |
10 | n | auth_proto | string | Протокол авторизации |
10+n | pad(n) | Выравнивание к двойному слову. | ||
10+n+pad(n) | d | auth_data | string | Данные авторизации |
10+n+pad(n)+d | pad(d) | Выравнивание к двойному слову. |
Первый байт определяет в каком формате наша программа понимает числа. Сервер будет присылать нам все числа длиннее одного байта в этом формате и будет понимать числа которые мы присылаем в этом формате.
Потом следует минимальная версия протокола, которая подошла бы программы. Если сервер поддерживает версию ниже этой, то подключение будет отклонено.
Потом следует имя протокола авторизации и собственно данные авторизации. Это типа доказательство, что эта программа имеет право подключаться к серверу X11.
Откуда берем имя протокола и данные об авторизации? Они находятся в файле, путь к которому находится в переменную окружения $XAUTHORITY
. Если эта переменная не существует можно поискать в файле $HOME/.Xauthority
– это самый распространенный вариант. Если у вашего приложения нет прав доступа к этому файлу или файл не существует, то значит у вас нет доступа к этому X11 серверу.
Файл бинарный и его формат не слишком хорошо задокументирован. Мне пришлось спрашивать на stackoverflow чтобы разобраться, да и то получилось лишь частично.
Так, структура файла, это последовательность записей вот таких структур:
typedef struct xauth {
unsigned short family;
unsigned short address_length;
char *address;
unsigned short number_length;
char *number;
unsigned short name_length;
char *name;
unsigned short data_length;
char *data;
} Xauth;
Но во первых, в файле, конечно указателей нет. Все строки вписаны просто последовательно, символ за символом в файле. Во вторых – все двухбайтовые числа всегда являются big-endian. Вне зависимости от архитектуры компьютера.
address
– это HOST адрес сервера.
number
– это номер дисплея, которого мы уже определили из переменной $DISPLAY, записанный в виде текстовой строки!
name
– это имя протокола. В настоящем времени и насколько я знаю, используется только MIT-MAGIC-COOKIE-1
протокол.
data
– это массив байтов, примерно вот такой: 07 bd 70 26 1а ab 4c 7c 35 3c c1 b2 cc 25 a2 29
. который мы должны переслать серверу в знак, что у нас доступ позволен.
Перебираем этот файл пока не найдем запись, у которой HOST совпадает с хостом из $DISPLAY и номер дисплея с номером дисплея из $DISPLAY. Из этой записи достаем имя протокола и данные авторизации.
И так мы собрали все необходимые данные о нулевом запросе и формируем его:
Смещение | Длина | Имя поля | Байты | Пояснение |
---|---|---|---|---|
0 | 1 | byte_order | 0x6c | l |
1 | 1 | 0x00 | ||
2 | 2 | major_ver | 0x0b 0x00 | 0x000b |
4 | 2 | minor_ver | 0x00 0x00 | 0x0000 |
6 | 2 | auth_proto_len | 0x12 | length("MIT-MAGIC-COOKIE-1") = 18 |
8 | 2 | auth_data_len | 0x10 | length(cookie) = 16 |
10 | 18 | auth_proto | 4d 49 54 2d 4d 41 47 49 43 2d 43 4f 4f 4b 49 45 2d 31 | "MIT-MAGIC-COOKIE-1" |
28 | 16 | auth_data | 07 bd 70 26 1а ab 4c 7c 35 3c c1 b2 cc 25 a2 29 |
К серверу уходит: 6c 00 0b 00 00 00 12 10 4d 49 54 2d 4d 41 47 49 43 2d 43 4f 4f 4b 49 45 2d 31 07 bd 70 26 1а ab 4c 7c 35 3c c1 b2 cc 25 a2 29
На что сервер может ответить тремя возможными ответами. Вариант ответа определяется по первому байту. Он может быть:
0: Подключение отклонено. Весь ответ содержит:
Смещение | Длина | Имя поля | Байты | Пояснение |
---|---|---|---|---|
0 | 1 | reply | 0x00 | Failed |
1 | 1 | n | n | Длина текстового ответа. |
2 | 2 | major_ver | 0x0b 0x00 | Мажорная версия протокола. |
4 | 2 | minor_ver | 0x00 0x00 | Минорная версия протокола. |
6 | 2 | data_len | (n+p)/4 | Длина дополнительной информации в двойных словах. |
8 | n | data | Какое-то текстовое сообщение об ошибке. | |
8+n | p | pad(n) | Выравнивание до двойного слова. |
2: Нужна дополнительная аутентификация. Я этого варианта не изучал потому что так и не успел найти систему, которая так бы отвечала…
1: Подключение принято.
Самый хороший для нас вариант. Ответ очень длинный и сложный, содержит главные параметры системы, которые мы должны запомнить и использовать позже в наших запросов.
Я так и не смог нарисовать такую сложную табличку, чтобы все разложить по полочкам. Поэтому вот вам описания ответа из документации протокола:
1 1 Success
1 unused
2 CARD16 protocol-major-version
2 CARD16 protocol-minor-version
2 8+2n+(v+p+m)/4 length in 4-byte units of
"additional data"
4 CARD32 release-number
4 CARD32 resource-id-base
4 CARD32 resource-id-mask
4 CARD32 motion-buffer-size
2 v length of vendor
2 CARD16 maximum-request-length
1 CARD8 number of SCREENs in roots
1 n number for FORMATs in
pixmap-formats
1 image-byte-order
0 LSBFirst
1 MSBFirst
1 bitmap-format-bit-order
0 LeastSignificant
1 MostSignificant
1 CARD8 bitmap-format-scanline-unit
1 CARD8 bitmap-format-scanline-pad
1 KEYCODE min-keycode
1 KEYCODE max-keycode
4 unused
v STRING8 vendor
p unused, p=pad(v)
8n LISTofFORMAT pixmap-formats
m LISTofSCREEN roots (m is always a multiple of 4)
FORMAT
1 CARD8 depth
1 CARD8 bits-per-pixel
1 CARD8 scanline-pad
5 unused
SCREEN
4 WINDOW root
4 COLORMAP default-colormap
4 CARD32 white-pixel
4 CARD32 black-pixel
4 SETofEVENT current-input-masks
2 CARD16 width-in-pixels
2 CARD16 height-in-pixels
2 CARD16 width-in-millimeters
2 CARD16 height-in-millimeters
2 CARD16 min-installed-maps
2 CARD16 max-installed-maps
4 VISUALID root-visual
1 backing-stores
0 Never
1 WhenMapped
2 Always
1 BOOL save-unders
1 CARD8 root-depth
1 CARD8 number of DEPTHs in allowed-depths
n LISTofDEPTH allowed-depths (n is always a
multiple of 4)
DEPTH
1 CARD8 depth
1 unused
2 n number of VISUALTYPES in visuals
4 unused
24n LISTofVISUALTYPE visuals
VISUALTYPE
4 VISUALID visual-id
1 class
0 StaticGray
1 GrayScale
2 StaticColor
3 PseudoColor
4 TrueColor
5 DirectColor
1 CARD8 bits-per-rgb-value
2 CARD16 colormap-entries
4 CARD32 red-mask
4 CARD32 green-mask
4 CARD32 blue-mask
4 unused
Но как бы и сложным это не выглядело бы, всю информацию не надо запоминать или даже анализировать.
Мы от этого ответа возьмем только то, что важно для нас. И это во первых два числа из полей: resource-id-base
и resource-id-mask
. Они дают нам диапазон в котором надо генерировать ID константы для всех объектов GUI. (Не забывайте, что в X11 все идентификаторы объектов генерируются на стороне клиента, а серверу именно клиент говорит какой будет ID окна или других объектов.)
Так, у сервера есть только одно ограничение – каждой программе он выделяет диапазон в котором идентификаторы должны помещаться. Так идентификатор должен содержать только те биты которые в resource-id-mask установлены в единицу. И идентификатор должен начинать с resource-id_base.
Еще надо запомнить для будущего использования диапазон клавиатурных кодов (min-keycode/max-keycode), найти в ответе те форматы изображений, которые программа может использовать и которые ей удобны.
Еще обязательно надо найти подходящий SCREEN из списка и оттуда взять идентификатор коренного окна. Он нам нужен, в качестве родительского окна для всех окон верхнего уровня, которые мы будем создавать.
Все остального более или менее можно проигнорировать.
Я обычно ищу во всем этом многообразии тот SCREEN, который меня устраивает (32 бит TrueColor) и использую только его. А если сервер такое не поддерживает, просто заканчиваю работу. Это сильно упрощает работу и код.
Заключение
Ну это все для первого раза. Надеюсь сумел все объяснить яснее чем в документации и дать то понимание, которое позволит дальше свободно читать документацию (А она и правда хороша, если человек умеет ее понимать).
В качестве упражнения предлагаю конкурс-челендж: Написать программу на bash которая устанавливает соединение с X сервером и создает и показывает окно с заголовком «X11 rules».
Если никто не справится или не захочет, я попробую написать ее в качестве примера для следующей статьи цикла.
Спрашивайте в комментариях если что не ясно. Если что не нравиться тоже пишите. Статья может и будет редактироваться по мере обсуждения.
Автор: John Found