Как работает протокол X11 на самом нижнем уровне

в 16:50, , рубрики: linux, X11, низкоуровневое программирование, Программирование

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 из тела ответа берем длина дополнительной части и читаем ее тоже.

Вся информация с сервера приходит асинхронно, но ответы и ошибки всегда приходят в порядке запросов чьими результатами они являются.

  1. Ответы на запросы (Reply). Если запрос предполагает ответ от сервера, то сервер его присылает по сокете, как только обработает запрос. Если ответ содержит информацию, которая помещается в 32 байта, то это все что нужно принять. Если ответ длиннее, то в его теле содержится длина дополнительной части ответа.

Общий формат ответа такой:

Смещение Длина Имя поля Значение Заметки
0 1 code 1 1 == Reply
1 1 ? ? Или не используется или используется для какой-то часть ответа, длиной в 1 байт.
2 2 sequence s Это номер запроса, на котором ответ.
4 4 length n Длина ответа сверх первых 32 байта в двойных словах. Если не 0, то надо прочитать с сокета еще 4*n байта, чтобы взять весь ответ.

  1. События (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 ? Зависят от события.

  1. Ошибки. Присылаются если какой-то запрос клиента нельзя было исполнить потому что содержит какую-то ошибку в данных или параметрах. Формат ошибки такой:

Смещение Длина Имя поля Значение Заметки
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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js