Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2bffc7fe9 | ||
|
|
66ea614e0d |
@@ -6,3 +6,7 @@ srcpkgs/das-theme/files/xfwm4gen/xfwm4gen
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
|
||||
# Python
|
||||
.venv/
|
||||
__pycache__/
|
||||
|
||||
@@ -10,7 +10,7 @@ help:
|
||||
@echo "make qemu-bios boot the newest ISO in QEMU with legacy BIOS"
|
||||
@echo "make qemu-disk boot with a 20 GB virtual disk attached (for installer tests)"
|
||||
@echo "make qemu-server boot the newest server ISO on a serial console, with the disk"
|
||||
@echo "make test run Go tests"
|
||||
@echo "make test run Go and Python tests"
|
||||
@echo "make clean remove build output (keeps download caches)"
|
||||
|
||||
packages:
|
||||
@@ -37,6 +37,7 @@ qemu-server:
|
||||
test:
|
||||
cd srcpkgs/das-theme/files/xfwm4gen && go test ./...
|
||||
cd srcpkgs/das-tools/files/src && go test ./...
|
||||
cd srcpkgs/das-shell/files && PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -t .
|
||||
|
||||
# Build output may be owned by root when a build was interrupted, so remove it from the container
|
||||
clean:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
## Das OS
|
||||
### _A light, friendly Void Linux–based desktop with Xfce and a Ukrainian touch, plus a console server edition_
|
||||
|
||||
[](https://voidlinux.org) [](https://xfce.org) [](https://docs.voidlinux.org/config/services/) [](https://go.dev) [](https://www.docker.com) [](#)
|
||||
[](https://voidlinux.org) [](https://xfce.org) [](https://docs.voidlinux.org/config/services/) [](https://go.dev) [](https://www.docker.com) [](https://www.python.org) [](https://textual.textualize.io) [](#)
|
||||
|
||||
🇺🇦 [Українська версія](README_ua.md)
|
||||
|
||||
@@ -16,6 +16,7 @@ Void Linux is fast, simple and independent: runit instead of systemd, and its ow
|
||||
- [Welcome window](#Welcome) at the first login: language, home folders, updates, the developer profile and help
|
||||
- [Server image](#Server): a console live ISO with a text installer, SSH, firewall, fail2ban and Docker
|
||||
- [Command-line helpers](#Helpers) for everyday admin tasks: services (`dsv`), users (`duser`), firewall (`dfw`) and system settings (`dsys`)
|
||||
- [Terminal prompt and SSH login banner](#Terminal): the Das prompt by default, the `dprompt` prompt builder and the `dbanner` banner editor with the Das OS logo and the tryzub
|
||||
- [Developer profile](#DevProfile) in one command: Go, Python, Node.js, Docker, rootless Podman, VS Code and IDEs from Flathub
|
||||
- [Das OS look](#Look): light and dark themes, generated window frames, wallpapers and logo
|
||||
- [Desktop defaults](#Defaults) for Xfce that don't overwrite stock files
|
||||
@@ -134,7 +135,7 @@ Void Linux is fast, simple and independent: runit instead of systemd, and its ow
|
||||
- The installed system runs `sshd`, `chronyd`, `nftables`, `fail2ban`, `docker`, `socklog` and the chosen network service.
|
||||
- nftables lets in only SSH, ICMP and DHCPv6 (`/etc/nftables.conf`), more ports are opened with `dfw`. The rules live in their own table `inet das`, so reloading them keeps the Docker and fail2ban rules. Ports that containers publish pass through Docker's own rules.
|
||||
- fail2ban bans addresses that keep failing SSH logins, reading the socklog log. sshd doesn't accept root password logins.
|
||||
- Tools: Docker with Compose, lazydocker, lazygit, Midnight Commander, tmux, htop, Python 3, curl, `das-update --cli`, the [helpers](#Helpers) and xtools (`xi`, `xq`, `xrs`). There are no graphical libraries, and no firmware for NVIDIA graphics or sound cards (`/etc/xbps.d/das-server.conf` keeps updates from bringing it back).
|
||||
- Tools: Docker with Compose, lazydocker, lazygit, Midnight Commander, tmux, htop, Python 3, curl, `das-update --cli`, the [helpers](#Helpers), [`dprompt` and `dbanner`](#Terminal) and xtools (`xi`, `xq`, `xrs`). There are no graphical libraries, and no firmware for NVIDIA graphics or sound cards (`/etc/xbps.d/das-server.conf` keeps updates from bringing it back).
|
||||
|
||||
#### Command-line helpers<a id='Helpers' />
|
||||
**_Short commands for tasks that systemd distributions handle with systemctl, hostnamectl and the like, and that Void leaves to manual editing._**
|
||||
@@ -146,6 +147,19 @@ Void Linux is fast, simple and independent: runit instead of systemd, and its ow
|
||||
- `dsys` replaces hostnamectl, timedatectl and localectl: `info` (time sync, language, console, addresses), `hostname`, `timezone` (part of the name will do: `dsys timezone Kyiv`), `locale` (builds the glibc locale if needed) and `keymap` (with a Cyrillic console font for Cyrillic layouts).
|
||||
- They come in the `das-helpers` package and are on the desktop too. Both images also include `xtools-minimal` from Void for package work: `xi` installs, `xrs` searches, `xq` shows package details and `xcheckrestart` finds programs to restart after an update.
|
||||
|
||||
#### Terminal prompt and SSH login banner<a id='Terminal' />
|
||||
**_A two-line bash prompt out of the box, and two terminal editors to change it and to draw the banner shown at SSH login._**
|
||||
- The stock prompt: `┌─[✗]─[user@host]─[2026-10-21]─[08:12]─[~/dir]` and `└─╼ $` on the second line. `[✗]` appears only after a failed command, root's name is red, and without colours or on the Linux console the shape stays the same.
|
||||
- It comes from `/etc/bash/bashrc.d/das-prompt.sh`: the user's `~/.config/das/prompt.sh`, else `/etc/das/prompt.sh`, else the stock one. Void's `/etc/skel/.bashrc` sets its own PS1 afterwards, so that one is replaced once, but a user's own PS1 is never touched.
|
||||
- `dprompt` is a prompt builder: ready-made prompts (`das`, `das-git` with the git branch, venv and background jobs, `server`, `minimal`, `classic`), segments to add, move and colour, and a live preview as a normal user, after an error and as root. Ctrl+S applies it for yourself, Ctrl+G for every user through sudo. Without the editor: `dprompt list`, `dprompt show NAME`, `dprompt apply NAME [--system]`, `dprompt reset`.
|
||||
- Conditional parts (exit status, git, venv, jobs, SSH, a command's output) are computed by a `PROMPT_COMMAND` hook and only read by PS1, so a branch name is never run as code.
|
||||
- `dbanner` edits the banner shown at SSH login. It starts with a list of ready-made banners with a live preview: `das-classic` (a `#` frame with the name, IP and link), `double` (the host name in big figlet letters), `blocks` (the Das OS logo with system facts), `ukraine` (the lesser coat of arms of Ukraine), `tryzub`, `dashboard` and `minimal`.
|
||||
- The editor draws with the mouse or the keyboard: pen, eraser, line, boxes (single, double, rounded, heavy, ASCII, `#`, blocks), fill, text, big letters, select with copy and paste, undo. A palette offers box and block symbols, 16 colours for text and background and bold. Ready pictures go in with one click: the Das OS logo and the tryzub, plain or on a shield, in two sizes.
|
||||
- Fields show live values in a fixed width, so a frame never shifts: `{hostname}`, `{ip}`, `{uptime}`, `{mem}`, `{os}`, `{kernel}` and others, plus your own variables like `{url}`; `[[Ukrainian|English]]` labels follow the user's language.
|
||||
- Widgets add blocks under or beside the picture: disks with usage bars, memory, load, network, Docker containers, available updates, weather from wttr.in, SMART disk health, runit services that are down, fail2ban bans, snapper snapshots, the previous login, or any command or file with a script's output.
|
||||
- Quick widgets run at login with a short timeout. Slow or root-only ones (updates, weather, SMART, fail2ban) are refreshed by the `das-banner` runit service into `/var/cache/das/banner`, and `dbanner apply` enables it when a banner needs it. No cron is needed.
|
||||
- Drafts are kept in `~/.config/das/banner`; Ctrl+A or `dbanner apply NAME` writes `/etc/das/banner.json` through sudo. `dbanner off` and `on` turn the banner off for everyone, `~/.hushlogin` for one user; `"local": true` shows it on console logins too.
|
||||
|
||||
#### Developer profile<a id='DevProfile' />
|
||||
**_`dprofile install dev` turns a desktop or a server into a development machine, with a snapshot taken before it._**
|
||||
- Packages from the Void repository: `base-devel`, gdb, strace, Git with LFS, GitHub CLI, lazygit, Go with gopls, delve and golangci-lint, Python with pip, virtualenv and uv, Node.js, Docker with Compose and Buildx, lazydocker, Podman with podman-compose, jq, ripgrep, fd, fzf, direnv, shellcheck and SQLite.
|
||||
@@ -186,7 +200,7 @@ Void Linux is fast, simple and independent: runit instead of systemd, and its ow
|
||||
**_The whole build runs in a Docker container, so only Docker and QEMU are needed on the host._**
|
||||
1. `make packages` builds the local xbps repository.
|
||||
2. `make iso` builds the packages and then the ISO with `void-mklive`, using the profile in `live/` (package list, services, root file system overlay). `make iso-server` does the same for the server profile in `server/`.
|
||||
3. `make qemu` boots the newest ISO with UEFI. `make qemu-bios` uses legacy BIOS, and `make qemu-disk` also attaches a 20 GB virtual disk to install on. `make qemu-server` boots the server ISO with the disk on a serial console in the terminal (Ctrl-A X quits).
|
||||
3. `make qemu` boots the newest ISO with UEFI. `make qemu-bios` uses legacy BIOS, and `make qemu-disk` also attaches a 20 GB virtual disk to install on. `make qemu-server` boots the server ISO with the disk on a serial console in the terminal (Ctrl-A X quits). The guest's SSH is forwarded to `localhost:2222` (`DASOS_SSH_PORT` changes it).
|
||||
|
||||
#### CI build<a id='CI' />
|
||||
**_Every push to `main` or `dev` and every `v*` tag builds the packages and both ISOs on git.das.org.ua._**
|
||||
@@ -197,17 +211,17 @@ Void Linux is fast, simple and independent: runit instead of systemd, and its ow
|
||||
- A `v*` tag that matches `VERSION` also publishes both ISOs and `SHA256SUMS` as a release (`ci/release.sh`, with the job's own token): `git tag v0.8.0 && git push origin v0.8.0`. Other builds keep the ISOs on the CI host in `/var/lib/dasos-ci/out/iso`.
|
||||
|
||||
### 🚀 Building the application
|
||||
Requirements: Linux with Docker (the user must be in the `docker` group), QEMU and OVMF for testing, and Go 1.24+ with the GTK 3 development files for `make test`. About 10 GB of free disk space.
|
||||
Requirements: Linux with Docker (the user must be in the `docker` group), QEMU and OVMF for testing, and Go 1.24+ with the GTK 3 development files plus Python 3 for `make test`. About 10 GB of free disk space.
|
||||
```shell
|
||||
make iso # build packages and the live ISO (the first run downloads ~2 GB)
|
||||
make qemu # boot it in QEMU with UEFI
|
||||
make iso-server # build packages and the server ISO
|
||||
make qemu-server # boot it on a serial console with a disk to install on
|
||||
make test # run Go tests
|
||||
make test # run Go and Python tests
|
||||
make clean # remove build output, keep the download cache
|
||||
```
|
||||
The ISOs appear in `out/iso/dasos-<version>-x86_64.iso` and `out/iso/dasos-server-<version>-x86_64.iso`. Write them to a USB stick with `dd` or Ventoy.
|
||||
The ISOs appear in `out/iso/dasos-<version>-x86_64.iso` and `out/iso/dasos-server-<version>-x86_64.iso`. Write them to a USB stick with `dd` or Ventoy. For a Ventoy theme there is a 48×48 menu icon `live/branding/ventoy/dasos.png`: copy it to the theme's `icons/` folder and add `{ "key": "dasos", "class": "dasos" }` to `menu_class` in `ventoy.json`.
|
||||
|
||||
**Note:** Das OS is an early version (0.8.0). Packages and ISOs are built in CI; signed repository hosting is planned for an upcoming stage. Until the repository is hosted, an installed system gets updates from Void only. Das OS is based on Void Linux but is not affiliated with it.
|
||||
**Note:** Das OS is an early version (0.9.1). Packages and ISOs are built in CI; signed repository hosting is planned for an upcoming stage. Until the repository is hosted, an installed system gets updates from Void only. Das OS is based on Void Linux but is not affiliated with it.
|
||||
|
||||
###### _Made by -=:dAs:=-_
|
||||
|
||||
+21
-7
@@ -1,7 +1,7 @@
|
||||
## Das OS
|
||||
### _Легкий і дружній десктоп на базі Void Linux з Xfce та українським колоритом, а також консольна серверна версія_
|
||||
|
||||
[](https://voidlinux.org) [](https://xfce.org) [](https://docs.voidlinux.org/config/services/) [](https://go.dev) [](https://www.docker.com) [](#)
|
||||
[](https://voidlinux.org) [](https://xfce.org) [](https://docs.voidlinux.org/config/services/) [](https://go.dev) [](https://www.docker.com) [](https://www.python.org) [](https://textual.textualize.io) [](#)
|
||||
|
||||
🇬🇧 [English version](README.md)
|
||||
|
||||
@@ -16,6 +16,7 @@ Void Linux швидкий, простий і незалежний: runit зам
|
||||
- [Вікно привітання](#Welcome) під час першого входу: мова, теки, оновлення, профіль розробника та довідка
|
||||
- [Серверний образ](#Server): консольний live ISO з текстовим інсталятором, SSH, файрволом, fail2ban і Docker
|
||||
- [Консольні помічники](#Helpers) для щоденного адміністрування: служби (`dsv`), користувачі (`duser`), брандмауер (`dfw`) і системні налаштування (`dsys`)
|
||||
- [Запрошення терміналу й банер при вході по SSH](#Terminal): запрошення Das за замовчуванням, конструктор запрошень `dprompt` і редактор банера `dbanner` з логотипом Das OS і тризубом
|
||||
- [Профіль розробника](#DevProfile) однією командою: Go, Python, Node.js, Docker, rootless Podman, VS Code і середовища з Flathub
|
||||
- [Оформлення Das OS](#Look): світла й темна теми, згенеровані рамки вікон, шпалери та логотип
|
||||
- [Налаштування за замовчуванням](#Defaults) для Xfce, які не перезаписують стандартні файли
|
||||
@@ -134,7 +135,7 @@ Void Linux швидкий, простий і незалежний: runit зам
|
||||
- Встановлена система запускає `sshd`, `chronyd`, `nftables`, `fail2ban`, `docker`, `socklog` і вибрану мережеву службу.
|
||||
- nftables пропускає лише SSH, ICMP і DHCPv6 (`/etc/nftables.conf`), інші порти відкриває `dfw`. Правила лежать у власній таблиці `inet das`, тож їх перезавантаження не чіпає правил Docker і fail2ban. Порти, які публікують контейнери, проходять через власні правила Docker.
|
||||
- fail2ban блокує адреси, з яких раз у раз не вдається увійти по SSH, читаючи журнал socklog. sshd не приймає вхід root за паролем.
|
||||
- Інструменти: Docker з Compose, lazydocker, lazygit, Midnight Commander, tmux, htop, Python 3, curl, `das-update --cli`, [помічники](#Helpers) і xtools (`xi`, `xq`, `xrs`). Графічних бібліотек немає, як і прошивок для відеокарт NVIDIA та звукових карт (`/etc/xbps.d/das-server.conf` не дає оновленням повернути їх).
|
||||
- Інструменти: Docker з Compose, lazydocker, lazygit, Midnight Commander, tmux, htop, Python 3, curl, `das-update --cli`, [помічники](#Helpers), [`dprompt` і `dbanner`](#Terminal) та xtools (`xi`, `xq`, `xrs`). Графічних бібліотек немає, як і прошивок для відеокарт NVIDIA та звукових карт (`/etc/xbps.d/das-server.conf` не дає оновленням повернути їх).
|
||||
|
||||
#### Консольні помічники<a id='Helpers' />
|
||||
**_Короткі команди для задач, які в дистрибутивах із systemd розв'язують systemctl, hostnamectl тощо, а у Void доводиться робити вручну._**
|
||||
@@ -146,6 +147,19 @@ Void Linux швидкий, простий і незалежний: runit зам
|
||||
- `dsys` замінює hostnamectl, timedatectl і localectl: `info` (синхронізація часу, мова, консоль, адреси), `hostname`, `timezone` (досить частини назви: `dsys timezone Kyiv`), `locale` (збирає локаль glibc, якщо треба) і `keymap` (з кириличним шрифтом консолі для кириличних розкладок).
|
||||
- Вони входять у пакет `das-helpers` і є й на десктопі. Обидва образи також містять `xtools-minimal` з Void для роботи з пакетами: `xi` встановлює, `xrs` шукає, `xq` показує відомості про пакет, а `xcheckrestart` знаходить програми, які треба перезапустити після оновлення.
|
||||
|
||||
#### Запрошення терміналу й банер при вході по SSH<a id='Terminal' />
|
||||
**_Дворядкове запрошення bash одразу після встановлення і два редактори в терміналі: для запрошення і для банера, який видно при вході по SSH._**
|
||||
- Стандартне запрошення: `┌─[✗]─[user@host]─[2026-10-21]─[08:12]─[~/dir]` і `└─╼ $` у другому рядку. `[✗]` з'являється лише після команди з помилкою, ім'я root червоне, а без кольорів чи в консолі Linux вигляд той самий.
|
||||
- Його ставить `/etc/bash/bashrc.d/das-prompt.sh`: `~/.config/das/prompt.sh` користувача, інакше `/etc/das/prompt.sh`, інакше стандартне. `/etc/skel/.bashrc` з Void потім задає власний PS1, тож його замінено один раз, а власний PS1 користувача не чіпається.
|
||||
- `dprompt` — конструктор запрошень: готові варіанти (`das`, `das-git` з гілкою git, venv і фоновими задачами, `server`, `minimal`, `classic`), сегменти можна додавати, переставляти й фарбувати, а живий перегляд показує запрошення звичайного користувача, після помилки і для root. Ctrl+S застосовує його для себе, Ctrl+G — для всіх через sudo. Без редактора: `dprompt list`, `dprompt show НАЗВА`, `dprompt apply НАЗВА [--system]`, `dprompt reset`.
|
||||
- Умовні частини (код помилки, git, venv, задачі, SSH, вивід команди) обчислює хук у `PROMPT_COMMAND`, а PS1 лише читає їх, тож назва гілки ніколи не виконується як код.
|
||||
- `dbanner` редагує банер при вході по SSH. Він починає зі списку готових банерів із живим переглядом: `das-classic` (рамка з `#`, назва, IP і посилання), `double` (ім'я хоста великими літерами figlet), `blocks` (логотип Das OS і дані системи), `ukraine` (малий герб України), `tryzub`, `dashboard` і `minimal`.
|
||||
- Редактор малює мишею або з клавіатури: олівець, гумка, лінія, рамки (одинарна, подвійна, заокруглена, жирна, ASCII, `#`, блоки), заливка, текст, великі літери, виділення з копіюванням і вставкою, скасування. Палітра — символи рамок і блоків, 16 кольорів тексту й тла, жирний шрифт. Готові малюнки вставляються одним клацанням: логотип Das OS і тризуб, окремо чи на щиті, у двох розмірах.
|
||||
- Поля показують живі значення фіксованої ширини, тож рамка не зсувається: `{hostname}`, `{ip}`, `{uptime}`, `{mem}`, `{os}`, `{kernel}` та інші, а також власні змінні на кшталт `{url}`; підписи `[[українською|англійською]]` йдуть мовою користувача.
|
||||
- Віджети додають блоки під малюнком або праворуч: диски зі смужками заповнення, пам'ять, навантаження, мережа, контейнери Docker, доступні оновлення, погода з wttr.in, стан дисків SMART, служби runit, що не працюють, блокування fail2ban, знімки snapper, попередній вхід або будь-яка команда чи файл із виводом скрипта.
|
||||
- Швидкі віджети виконуються при вході з коротким тайм-аутом. Повільні чи ті, що потребують root (оновлення, погода, SMART, fail2ban), оновлює служба runit `das-banner` у `/var/cache/das/banner`, і `dbanner apply` вмикає її, коли банерові це треба. Cron не потрібен.
|
||||
- Чернетки лежать у `~/.config/das/banner`; Ctrl+A або `dbanner apply НАЗВА` записує `/etc/das/banner.json` через sudo. `dbanner off` і `on` вимикають банер для всіх, `~/.hushlogin` — для одного користувача; `"local": true` показує його й при вході в консоль.
|
||||
|
||||
#### Профіль розробника<a id='DevProfile' />
|
||||
**_`dprofile install dev` перетворює десктоп чи сервер на машину для розробки, перед цим створивши знімок системи._**
|
||||
- Пакети з репозиторію Void: `base-devel`, gdb, strace, Git з LFS, GitHub CLI, lazygit, Go з gopls, delve і golangci-lint, Python з pip, virtualenv і uv, Node.js, Docker з Compose і Buildx, lazydocker, Podman з podman-compose, jq, ripgrep, fd, fzf, direnv, shellcheck і SQLite.
|
||||
@@ -186,7 +200,7 @@ Void Linux швидкий, простий і незалежний: runit зам
|
||||
**_Усе збирання відбувається в Docker-контейнері, тож на хості потрібні лише Docker і QEMU._**
|
||||
1. `make packages` збирає локальний xbps-репозиторій.
|
||||
2. `make iso` збирає пакети, а потім ISO через `void-mklive` за профілем у `live/` (список пакетів, сервіси, оверлей кореневої файлової системи). `make iso-server` робить те саме для серверного профілю в `server/`.
|
||||
3. `make qemu` завантажує найновіший ISO з UEFI. `make qemu-bios` використовує старий BIOS, а `make qemu-disk` ще й підключає віртуальний диск на 20 ГБ для встановлення. `make qemu-server` завантажує серверний ISO з диском на послідовній консолі прямо в терміналі (вихід — Ctrl-A X).
|
||||
3. `make qemu` завантажує найновіший ISO з UEFI. `make qemu-bios` використовує старий BIOS, а `make qemu-disk` ще й підключає віртуальний диск на 20 ГБ для встановлення. `make qemu-server` завантажує серверний ISO з диском на послідовній консолі прямо в терміналі (вихід — Ctrl-A X). SSH гостьової системи доступний на `localhost:2222` (змінює `DASOS_SSH_PORT`).
|
||||
|
||||
#### Збирання в CI<a id='CI' />
|
||||
**_Кожен push у `main` чи `dev` і кожен тег `v*` збирає пакети й обидва ISO на git.das.org.ua._**
|
||||
@@ -197,17 +211,17 @@ Void Linux швидкий, простий і незалежний: runit зам
|
||||
- Тег `v*`, що збігається з `VERSION`, ще й публікує обидва ISO та `SHA256SUMS` як реліз (`ci/release.sh`, з власним токеном завдання): `git tag v0.8.0 && git push origin v0.8.0`. Інші збирання залишають ISO на CI-хості в `/var/lib/dasos-ci/out/iso`.
|
||||
|
||||
### 🚀 Збирання застосунку
|
||||
Вимоги: Linux із Docker (користувач має бути в групі `docker`), QEMU та OVMF для тестування, Go 1.24+ і файли розробки GTK 3 для `make test`. Приблизно 10 ГБ вільного місця.
|
||||
Вимоги: Linux із Docker (користувач має бути в групі `docker`), QEMU та OVMF для тестування, Go 1.24+ і файли розробки GTK 3 та Python 3 для `make test`. Приблизно 10 ГБ вільного місця.
|
||||
```shell
|
||||
make iso # зібрати пакети й live-ISO (перший запуск завантажує ~2 ГБ)
|
||||
make qemu # запустити його в QEMU з UEFI
|
||||
make iso-server # зібрати пакети й серверний ISO
|
||||
make qemu-server # запустити його на послідовній консолі з диском для встановлення
|
||||
make test # запустити Go-тести
|
||||
make test # запустити тести Go і Python
|
||||
make clean # видалити результати збирання, зберегти кеш завантажень
|
||||
```
|
||||
ISO з'являються в `out/iso/dasos-<версія>-x86_64.iso` і `out/iso/dasos-server-<версія>-x86_64.iso`. Записати їх на флешку можна через `dd` або Ventoy.
|
||||
ISO з'являються в `out/iso/dasos-<версія>-x86_64.iso` і `out/iso/dasos-server-<версія>-x86_64.iso`. Записати їх на флешку можна через `dd` або Ventoy. Для теми Ventoy є значок меню 48×48 `live/branding/ventoy/dasos.png`: скопіюйте його в теку `icons/` теми й додайте `{ "key": "dasos", "class": "dasos" }` до `menu_class` у `ventoy.json`.
|
||||
|
||||
**Примітка:** Das OS — рання версія (0.8.0). Пакети й ISO збираються в CI; підписаний репозиторій на хостингу заплановано на наступний етап. Поки репозиторій не розміщено на хостингу, встановлена система отримує оновлення лише з Void. Das OS базується на Void Linux, але не пов'язана з ним офіційно.
|
||||
**Примітка:** Das OS — рання версія (0.9.1). Пакети й ISO збираються в CI; підписаний репозиторій на хостингу заплановано на наступний етап. Поки репозиторій не розміщено на хостингу, встановлена система отримує оновлення лише з Void. Das OS базується на Void Linux, але не пов'язана з ним офіційно.
|
||||
|
||||
###### _Made by -=:dAs:=-_
|
||||
|
||||
@@ -62,12 +62,20 @@ Live ISO без графіки з консольним інсталятором:
|
||||
|
||||
## Етап 9. CI (готово)
|
||||
- [x] Workflow `.gitea/workflows/build.yml`: `make iso iso-server` на власному runner `dasci:host` (push у `main`/`dev`, теги `v*`, ручний запуск).
|
||||
- [x] CI-хост: Windows-машина (192.168.88.193), WSL2 Ubuntu 24.04 `dasci` у `D:\DasOS\wsl`, Docker Engine, `gitea-runner` 5.1.1 як служба systemd, автозапуск завданням планувальника (`ci/runner`).
|
||||
- [x] CI-хост: Windows-машина (192.168.88.193), WSL2 Ubuntu 24.04 `dasci` у `C:\DasOS\wsl` (SSD), Docker Engine, `gitea-runner` 5.1.1 як служба systemd, автозапуск завданням планувальника (`ci/runner`).
|
||||
- [x] `DASOS_OUT` у `scripts/builder.sh`: кеш `out/` на CI-хості в `/var/lib/dasos-ci/out`.
|
||||
- [x] Runner зареєстровано, перший запуск у Gitea (run 1) успішний за 46 хв.
|
||||
- [x] Після перезавантаження Windows runner піднімається сам, без входу в систему (завдання S4U).
|
||||
- [x] Тег `v*` публікує обидва ISO і `SHA256SUMS` як реліз Gitea (`ci/release.sh`). Ліміт вкладення релізу — 2 ГБ на файл (`[repository.release] FILE_MAX_SIZE`).
|
||||
- [ ] Перевірити першу публікацію на справжньому тегу.
|
||||
- [x] Перша публікація: реліз v0.8.0 з обома ISO і `SHA256SUMS` (завантаження ~9 хв). Перша спроба впала на 502: Traefik на git.das.org.ua обривав запити довші за 60 с, `readTimeout` піднято до 30m.
|
||||
|
||||
## Етап 10. Термінал (0.9.1)
|
||||
Пакет `das-shell` (десктоп і сервер), Python + Textual.
|
||||
- [x] Запрошення bash за замовчуванням, як на робочому комп'ютері автора: `┌─[✗]─[user@host]─[дата]─[час]─[каталог]` / `└─╼ $` (`/etc/bash/bashrc.d/das-prompt.sh`, заміна PS1 зі `/etc/skel/.bashrc` Void).
|
||||
- [x] `dprompt`: конструктор запрошень (5 готових, сегменти, кольори, живий перегляд), застосування для себе або для всіх.
|
||||
- [x] `dbanner`: редактор банера при вході по SSH (7 готових, полотно з інструментами, поля зі змінними, віджети, служба `das-banner` для кешу), малюнки: логотип Das OS і тризуб (`tools/art`).
|
||||
- [x] `scripts/run-qemu.sh`: SSH гостя на `localhost:2222`.
|
||||
- [ ] Перевірити в QEMU: сервер — банер при вході по SSH, `dbanner apply dashboard` зі службою, `ssh host cmd` без банера; десктоп — запрошення в xfce4-terminal, обидва редактори.
|
||||
|
||||
## Репозиторій пакетів
|
||||
- [ ] Підпис через `xbps-rindex --sign`, ключ поза репозиторієм.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
@@ -67,4 +67,5 @@ das-snapshots
|
||||
das-theme
|
||||
das-tools
|
||||
das-helpers
|
||||
das-shell
|
||||
xtools-minimal
|
||||
|
||||
@@ -101,6 +101,17 @@ sed -i \
|
||||
-e 's/-comp "${SQUASHFS_COMPRESSION}"/& -b 1M -Xbcj x86/' \
|
||||
"$mklive"
|
||||
|
||||
# Boot menu entries name the Das OS version instead of mklive's
|
||||
# "<title> <kernel version> <arch>" (GRUB for UEFI, isolinux for BIOS).
|
||||
isolinux=/opt/void-mklive/isolinux/isolinux.cfg.in
|
||||
entry_title='ENTRY_TITLE="${BOOT_TITLE} ${KERNELVERSION} ${title_sfx}(${TARGET_ARCH})"'
|
||||
if ! grep -qF -- "$entry_title" "$mklive" || ! grep -qF '@@BOOT_TITLE@@ @@KERNVER@@ @@ARCH@@' "$isolinux"; then
|
||||
echo "build-iso: boot menu titles not found in mklive, mklive changed; update this script" >&2
|
||||
exit 1
|
||||
fi
|
||||
sed -i 's/ENTRY_TITLE="${BOOT_TITLE} ${KERNELVERSION} ${title_sfx}(${TARGET_ARCH})"/ENTRY_TITLE="${BOOT_TITLE}${title_sfx:+ ${title_sfx% }}"/' "$mklive"
|
||||
sed -i 's/@@BOOT_TITLE@@ @@KERNVER@@ @@ARCH@@/@@BOOT_TITLE@@/' "$isolinux"
|
||||
|
||||
rsvg-convert /work/live/branding/splash.svg --output "$work/splash.png"
|
||||
rm -f "$iso"
|
||||
|
||||
@@ -115,6 +126,6 @@ SPLASH_IMAGE=$work/splash.png ./mklive.sh \
|
||||
-I "$include" \
|
||||
-l "$locale" \
|
||||
-k us \
|
||||
-T "$title" \
|
||||
-T "$title $version" \
|
||||
-C "$cmdline" \
|
||||
-o "$iso"
|
||||
|
||||
+3
-1
@@ -6,6 +6,7 @@
|
||||
# --disk attach out/qemu/disk.qcow2 (20 GB, created on first use) to test installing
|
||||
# --serial no window: the serial console in this terminal (server ISO), Ctrl-A X quits
|
||||
# Without a path the newest desktop ISO in out/iso is used (server ISO with --serial).
|
||||
# The guest's SSH is on localhost:2222 (DASOS_SSH_PORT changes it): ssh -p 2222 user@localhost
|
||||
set -euo pipefail
|
||||
|
||||
root=$(cd "$(dirname "$0")/.." && pwd)
|
||||
@@ -13,6 +14,7 @@ state=$root/out/qemu
|
||||
firmware=uefi
|
||||
disk=false
|
||||
serial=false
|
||||
ssh_port=${DASOS_SSH_PORT:-2222}
|
||||
iso=""
|
||||
|
||||
die() {
|
||||
@@ -43,7 +45,7 @@ fi
|
||||
|
||||
args=(
|
||||
-machine q35 -smp 4 -m 4096
|
||||
-device virtio-net-pci,netdev=net0 -netdev user,id=net0
|
||||
-device virtio-net-pci,netdev=net0 -netdev "user,id=net0,hostfwd=tcp:127.0.0.1:${ssh_port}-:22"
|
||||
-cdrom "$iso" -boot order=d
|
||||
)
|
||||
if [[ $serial == true ]]; then
|
||||
|
||||
@@ -66,7 +66,7 @@ t() {
|
||||
s_config) echo "Налаштування системи..." ;;
|
||||
s_post) echo "Локалі, знімки, initramfs (кілька хвилин)..." ;;
|
||||
s_boot) echo "Встановлення завантажувача..." ;;
|
||||
finished) echo "Das OS Server встановлено. Вийміть інсталяційний носій і перезавантажте сервер.\n\nВхід: %s, далі sudo. Служби: dsv. Користувачі: duser. Порти: dfw. Налаштування: dsys. Оновлення: das-update. Профіль розробника: dprofile install dev." ;;
|
||||
finished) echo "Das OS Server встановлено. Вийміть інсталяційний носій і перезавантажте сервер.\n\nВхід: %s, далі sudo. Служби: dsv. Користувачі: duser. Порти: dfw. Налаштування: dsys. Оновлення: das-update. Профіль розробника: dprofile install dev. Запрошення терміналу: dprompt. Банер при вході: dbanner." ;;
|
||||
reboot) echo "Перезавантажити зараз?" ;;
|
||||
failed) echo "Помилка встановлення. Журнал: %s" ;;
|
||||
root) echo "Інсталятор треба запускати від root (sudo das-server-install)." ;;
|
||||
@@ -105,7 +105,7 @@ t() {
|
||||
s_config) echo "Configuring the system..." ;;
|
||||
s_post) echo "Locales, snapshots, initramfs (a few minutes)..." ;;
|
||||
s_boot) echo "Installing the boot loader..." ;;
|
||||
finished) echo "Das OS Server is installed. Remove the installation medium and reboot.\n\nLog in as %s, then use sudo. Services: dsv. Users: duser. Ports: dfw. Settings: dsys. Updates: das-update. Developer profile: dprofile install dev." ;;
|
||||
finished) echo "Das OS Server is installed. Remove the installation medium and reboot.\n\nLog in as %s, then use sudo. Services: dsv. Users: duser. Ports: dfw. Settings: dsys. Updates: das-update. Developer profile: dprofile install dev. Terminal prompt: dprompt. Login banner: dbanner." ;;
|
||||
reboot) echo "Reboot now?" ;;
|
||||
failed) echo "Installation failed. Log: %s" ;;
|
||||
root) echo "The installer must run as root (sudo das-server-install)." ;;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Template file for 'das-server-installer'
|
||||
pkgname=das-server-installer
|
||||
version=0.3.0
|
||||
version=0.4.0
|
||||
revision=1
|
||||
depends="bash dialog das-install-common btrfs-progs dosfstools gptfdisk parted
|
||||
rsync util-linux grub grub-x86_64-efi efibootmgr"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Template file for 'das-server'
|
||||
pkgname=das-server
|
||||
version=0.2.0
|
||||
version=0.3.0
|
||||
revision=1
|
||||
depends="openssh chrony dhcpcd NetworkManager docker docker-compose nftables
|
||||
fail2ban socklog-void python3 curl lazydocker lazygit mc tmux htop
|
||||
das-snapshots das-update das-helpers xtools-minimal"
|
||||
das-snapshots das-update das-helpers das-shell xtools-minimal"
|
||||
short_desc="Das OS server: packages and secure defaults for SSH, firewall and Docker"
|
||||
maintainer="Das <anry.das@gmail.com>"
|
||||
license="MIT"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Das
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Das OS bash prompt: ~/.config/das/prompt.sh, else /etc/das/prompt.sh (both
|
||||
# written by dprompt), else the stock one.
|
||||
# shellcheck shell=bash
|
||||
|
||||
if [[ $- == *i* && -r /usr/lib/das/prompt-lib.sh ]]; then
|
||||
. /usr/lib/das/prompt-lib.sh
|
||||
for __das_f in "${XDG_CONFIG_HOME:-$HOME/.config}/das/prompt.sh" /etc/das/prompt.sh /usr/share/das/prompt/default.sh; do
|
||||
if [[ -r $__das_f ]]; then
|
||||
. "$__das_f"
|
||||
break
|
||||
fi
|
||||
done
|
||||
unset __das_f
|
||||
fi
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/python3
|
||||
"""dbanner of Das OS; the code is in /usr/lib/das/python/das_shell."""
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/usr/lib/das/python")
|
||||
|
||||
from das_shell.cli_banner import main # noqa: E402
|
||||
|
||||
sys.exit(main())
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/python3
|
||||
"""dprompt of Das OS; the code is in /usr/lib/das/python/das_shell."""
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/usr/lib/das/python")
|
||||
|
||||
from das_shell.cli_prompt import main # noqa: E402
|
||||
|
||||
sys.exit(main())
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
# Keeps the cached widgets of the login banner (dbanner) fresh
|
||||
exec 2>&1
|
||||
exec /usr/bin/dbanner refresh --loop
|
||||
@@ -0,0 +1,22 @@
|
||||
# bash completion for dbanner
|
||||
# shellcheck shell=bash
|
||||
_dbanner() {
|
||||
local cur=${COMP_WORDS[COMP_CWORD]}
|
||||
if ((COMP_CWORD == 1)); then
|
||||
mapfile -t COMPREPLY < <(compgen -W "list show apply off on refresh help" -- "$cur")
|
||||
return
|
||||
fi
|
||||
case ${COMP_WORDS[1]} in
|
||||
show | apply)
|
||||
if [[ $cur == -* ]]; then
|
||||
mapfile -t COMPREPLY < <(compgen -W "--no-color" -- "$cur")
|
||||
else
|
||||
local names
|
||||
names=$(ls /usr/share/das/banner/presets "${XDG_CONFIG_HOME:-$HOME/.config}/das/banner" 2>/dev/null | sed -n 's/\.json$//p')
|
||||
mapfile -t COMPREPLY < <(compgen -W "$names" -- "$cur"; compgen -f -X '!*.json' -- "$cur")
|
||||
fi
|
||||
;;
|
||||
refresh) mapfile -t COMPREPLY < <(compgen -W "--force" -- "$cur") ;;
|
||||
esac
|
||||
}
|
||||
complete -F _dbanner dbanner
|
||||
@@ -0,0 +1,22 @@
|
||||
# bash completion for dprompt
|
||||
# shellcheck shell=bash
|
||||
_dprompt() {
|
||||
local cur=${COMP_WORDS[COMP_CWORD]}
|
||||
if ((COMP_CWORD == 1)); then
|
||||
mapfile -t COMPREPLY < <(compgen -W "list show apply reset help" -- "$cur")
|
||||
return
|
||||
fi
|
||||
case ${COMP_WORDS[1]} in
|
||||
show | apply)
|
||||
if [[ $cur == -* ]]; then
|
||||
mapfile -t COMPREPLY < <(compgen -W "--system --script" -- "$cur")
|
||||
else
|
||||
local presets
|
||||
presets=$(ls /usr/share/das/prompt/presets 2>/dev/null | sed 's/\.json$//')
|
||||
mapfile -t COMPREPLY < <(compgen -W "$presets" -- "$cur"; compgen -f -X '!*.json' -- "$cur")
|
||||
fi
|
||||
;;
|
||||
reset) mapfile -t COMPREPLY < <(compgen -W "--system" -- "$cur") ;;
|
||||
esac
|
||||
}
|
||||
complete -F _dprompt dprompt
|
||||
@@ -0,0 +1,5 @@
|
||||
# Das OS login banner, see dbanner. It decides itself whether to show up:
|
||||
# SSH logins only (unless "local" is set), not after "dbanner off" or with ~/.hushlogin.
|
||||
case $- in
|
||||
*i*) [ -x /usr/bin/dbanner ] && /usr/bin/dbanner show --login ;;
|
||||
esac
|
||||
@@ -0,0 +1,53 @@
|
||||
# Functions behind the Das OS bash prompt (see dprompt). Sourced by
|
||||
# /etc/bash/bashrc.d/das-prompt.sh and by the prompt.sh files dprompt writes.
|
||||
# shellcheck shell=bash
|
||||
|
||||
# Colors when the terminal has at least 8 of them
|
||||
__das_has_color() {
|
||||
case $TERM in
|
||||
*color* | xterm* | rxvt* | screen* | tmux* | linux | alacritty | foot* | kitty*) return 0 ;;
|
||||
esac
|
||||
[[ $(tput colors 2>/dev/null) -ge 8 ]]
|
||||
}
|
||||
|
||||
# Sets __das_git to the branch (or short commit) and "*" when there are changes
|
||||
__das_git_info() {
|
||||
command -v git >/dev/null || return 1
|
||||
local branch
|
||||
branch=$(git symbolic-ref --short -q HEAD 2>/dev/null || git rev-parse --short -q HEAD 2>/dev/null) || return 1
|
||||
[[ -n $branch ]] || return 1
|
||||
if [[ -n $(git status --porcelain -uno 2>/dev/null | head -c1) ]]; then
|
||||
branch+="*"
|
||||
fi
|
||||
__das_git=$branch
|
||||
}
|
||||
|
||||
# Sets __das_jobs to the number of background jobs; fails when there are none
|
||||
__das_jobs_count() {
|
||||
local count='\j'
|
||||
__das_jobs=${count@P}
|
||||
((__das_jobs > 0))
|
||||
}
|
||||
|
||||
# Runs first in PROMPT_COMMAND: keeps the exit status for the prompt and fills
|
||||
# the variables of its conditional parts
|
||||
__das_prompt_hook() {
|
||||
__das_rc=$?
|
||||
# Void's /etc/skel/.bashrc sets its own PS1 after /etc/bash/bashrc.d: put
|
||||
# the Das OS one back, once, unless the user has a PS1 of their own
|
||||
if [[ -n ${__das_ps1+set} ]]; then
|
||||
[[ $PS1 == '[\u@\h \W]\$ ' ]] && PS1=$__das_ps1
|
||||
unset __das_ps1
|
||||
fi
|
||||
declare -F __das_prompt_dynamic >/dev/null && __das_prompt_dynamic
|
||||
return "$__das_rc"
|
||||
}
|
||||
|
||||
__das_prompt_install() {
|
||||
__das_ps1=$PS1
|
||||
local cmd
|
||||
for cmd in "${PROMPT_COMMAND[@]}"; do
|
||||
[[ $cmd == __das_prompt_hook ]] && return 0
|
||||
done
|
||||
PROMPT_COMMAND=(__das_prompt_hook "${PROMPT_COMMAND[@]}")
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Das OS shell tools: dprompt (bash prompt builder) and dbanner (login banner editor)."""
|
||||
|
||||
import os
|
||||
|
||||
VERSION = "0.1.0"
|
||||
|
||||
# Installed layout; DAS_SHELL_DATA points the tools at the source tree for tests
|
||||
DATA_DIR = os.environ.get("DAS_SHELL_DATA", "/usr/share/das")
|
||||
LIB_DIR = os.environ.get("DAS_SHELL_LIB", "/usr/lib/das")
|
||||
ETC_DIR = os.environ.get("DAS_SHELL_ETC", "/etc/das")
|
||||
CACHE_DIR = os.environ.get("DAS_SHELL_CACHE", "/var/cache/das/banner")
|
||||
|
||||
|
||||
def config_dir():
|
||||
"""The user's Das OS settings directory (~/.config/das)."""
|
||||
base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
|
||||
return os.path.join(base, "das")
|
||||
@@ -0,0 +1,426 @@
|
||||
"""Login banner model of dbanner: a grid of styled characters, fields and widgets.
|
||||
|
||||
A design is JSON:
|
||||
{"name": "...", "title": {"uk": ..., "en": ...}, "local": false,
|
||||
"vars": {"url": "https://..."},
|
||||
"styles": {"a": {"fg": "blue", "bg": null, "bold": false}, ...},
|
||||
"lines": ["####", ...], "style_map": ["aaaa", ...],
|
||||
"fields": [{"x": 4, "y": 1, "width": 20, "align": "left", "text": "{ip}", "style": "b"}],
|
||||
"widgets_position": "below", "widgets": [{"type": "builtin", "name": "disks"}, ...]}
|
||||
|
||||
style_map has one style letter per character of lines; a space is the
|
||||
terminal's own style. This module only needs the standard library: the
|
||||
banner is printed at every SSH login.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import unicodedata
|
||||
|
||||
from . import DATA_DIR, ETC_DIR, colors, config_dir
|
||||
from .i18n import UK, title
|
||||
|
||||
STYLE_KEYS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
ALIGNS = ("left", "center", "right")
|
||||
SYSTEM_FILE = os.path.join(ETC_DIR, "banner.json")
|
||||
OFF_FILE = os.path.join(ETC_DIR, "banner.off")
|
||||
|
||||
|
||||
def char_width(ch):
|
||||
return 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
|
||||
|
||||
|
||||
class Grid:
|
||||
"""Characters with a style key each; the canvas of the editor."""
|
||||
|
||||
def __init__(self, width=40, height=8, styles=None):
|
||||
self.width, self.height = width, height
|
||||
self.cells = [[[" ", " "] for _ in range(width)] for _ in range(height)]
|
||||
self.styles = styles if styles is not None else {}
|
||||
|
||||
# --- conversion -------------------------------------------------------
|
||||
@classmethod
|
||||
def from_design(cls, design):
|
||||
lines = design.get("lines") or []
|
||||
smap = design.get("style_map") or []
|
||||
width = max([len(line) for line in lines] + [1])
|
||||
grid = cls(width, max(len(lines), 1), copy.deepcopy(design.get("styles") or {}))
|
||||
for y, line in enumerate(lines):
|
||||
keys = smap[y] if y < len(smap) else ""
|
||||
for x, ch in enumerate(line):
|
||||
key = keys[x] if x < len(keys) else " "
|
||||
grid.cells[y][x] = [ch, key if key in grid.styles else " "]
|
||||
return grid
|
||||
|
||||
def to_design(self, design):
|
||||
"""Stores the grid into a copy of the design, dropping unused styles."""
|
||||
d = copy.deepcopy(design)
|
||||
lines, smap = [], []
|
||||
for row in self.cells:
|
||||
line = "".join(c[0] for c in row)
|
||||
keys = "".join(c[1] for c in row)
|
||||
# Trailing blank cells carry nothing
|
||||
n = len(line)
|
||||
while n and line[n - 1] == " " and keys[n - 1] == " ":
|
||||
n -= 1
|
||||
lines.append(line[:n])
|
||||
smap.append(keys[:n])
|
||||
while lines and not lines[-1]:
|
||||
lines.pop()
|
||||
smap.pop()
|
||||
used = {k for keys in smap for k in keys} | {f.get("style") for f in d.get("fields", [])}
|
||||
d["styles"] = {k: v for k, v in self.styles.items() if k in used}
|
||||
d["lines"], d["style_map"] = lines, smap
|
||||
d["canvas"] = {"width": self.width, "height": self.height}
|
||||
return d
|
||||
|
||||
def copy(self):
|
||||
g = Grid(self.width, self.height, copy.deepcopy(self.styles))
|
||||
g.cells = [[list(c) for c in row] for row in self.cells]
|
||||
return g
|
||||
|
||||
# --- styles -------------------------------------------------------------
|
||||
def style_key(self, fg=None, bg=None, bold=False):
|
||||
"""The key of a style, added when it is new."""
|
||||
if not fg and not bg and not bold:
|
||||
return " "
|
||||
want = {"fg": fg, "bg": bg, "bold": bool(bold)}
|
||||
for key, st in self.styles.items():
|
||||
if {"fg": st.get("fg"), "bg": st.get("bg"), "bold": bool(st.get("bold"))} == want:
|
||||
return key
|
||||
for key in STYLE_KEYS:
|
||||
if key not in self.styles:
|
||||
self.styles[key] = want
|
||||
return key
|
||||
raise ValueError("too many styles")
|
||||
|
||||
def style(self, key):
|
||||
st = self.styles.get(key) or {}
|
||||
return st.get("fg"), st.get("bg"), bool(st.get("bold"))
|
||||
|
||||
# --- drawing --------------------------------------------------------------
|
||||
def inside(self, x, y):
|
||||
return 0 <= x < self.width and 0 <= y < self.height
|
||||
|
||||
def get(self, x, y):
|
||||
return self.cells[y][x] if self.inside(x, y) else [" ", " "]
|
||||
|
||||
def put(self, x, y, ch, key):
|
||||
if self.inside(x, y):
|
||||
self.cells[y][x] = [ch, key]
|
||||
|
||||
def resize(self, width, height):
|
||||
width, height = max(1, width), max(1, height)
|
||||
cells = [[[" ", " "] for _ in range(width)] for _ in range(height)]
|
||||
for y in range(min(height, self.height)):
|
||||
for x in range(min(width, self.width)):
|
||||
cells[y][x] = self.cells[y][x]
|
||||
self.width, self.height, self.cells = width, height, cells
|
||||
|
||||
def text(self, x, y, text, key):
|
||||
for i, ch in enumerate(text):
|
||||
self.put(x + i, y, ch, key)
|
||||
|
||||
def line(self, x0, y0, x1, y1, ch, key):
|
||||
"""A straight line; horizontal and vertical ones use ch as given."""
|
||||
dx, dy = abs(x1 - x0), -abs(y1 - y0)
|
||||
sx, sy = (1 if x0 < x1 else -1), (1 if y0 < y1 else -1)
|
||||
err = dx + dy
|
||||
while True:
|
||||
self.put(x0, y0, ch, key)
|
||||
if x0 == x1 and y0 == y1:
|
||||
return
|
||||
e2 = 2 * err
|
||||
if e2 >= dy:
|
||||
err += dy
|
||||
x0 += sx
|
||||
if e2 <= dx:
|
||||
err += dx
|
||||
y0 += sy
|
||||
|
||||
def box(self, x0, y0, x1, y1, kind, key):
|
||||
x0, x1 = sorted((x0, x1))
|
||||
y0, y1 = sorted((y0, y1))
|
||||
tl, tr, bl, br, h, v = BOXES[kind]
|
||||
if x0 == x1 or y0 == y1:
|
||||
self.line(x0, y0, x1, y1, v if x0 == x1 else h, key)
|
||||
return
|
||||
for x in range(x0 + 1, x1):
|
||||
self.put(x, y0, h, key)
|
||||
self.put(x, y1, h, key)
|
||||
for y in range(y0 + 1, y1):
|
||||
self.put(x0, y, v, key)
|
||||
self.put(x1, y, v, key)
|
||||
self.put(x0, y0, tl, key)
|
||||
self.put(x1, y0, tr, key)
|
||||
self.put(x0, y1, bl, key)
|
||||
self.put(x1, y1, br, key)
|
||||
|
||||
def fill(self, x0, y0, x1, y1, ch, key):
|
||||
x0, x1 = sorted((x0, x1))
|
||||
y0, y1 = sorted((y0, y1))
|
||||
for y in range(y0, y1 + 1):
|
||||
for x in range(x0, x1 + 1):
|
||||
self.put(x, y, ch, key)
|
||||
|
||||
def region(self, x0, y0, x1, y1):
|
||||
"""A copy of a rectangle as a new grid with the same styles."""
|
||||
x0, x1 = sorted((x0, x1))
|
||||
y0, y1 = sorted((y0, y1))
|
||||
g = Grid(x1 - x0 + 1, y1 - y0 + 1, copy.deepcopy(self.styles))
|
||||
for y in range(g.height):
|
||||
for x in range(g.width):
|
||||
g.cells[y][x] = list(self.get(x0 + x, y0 + y))
|
||||
return g
|
||||
|
||||
def stamp(self, x, y, other, transparent=True):
|
||||
"""Draws another grid here; its styles are merged into ours."""
|
||||
keymap = {" ": " "}
|
||||
for key in {c[1] for row in other.cells for c in row}:
|
||||
if key != " ":
|
||||
fg, bg, bold = other.style(key)
|
||||
keymap[key] = self.style_key(fg, bg, bold)
|
||||
for dy, row in enumerate(other.cells):
|
||||
for dx, (ch, key) in enumerate(row):
|
||||
if transparent and ch == " " and key == " ":
|
||||
continue
|
||||
self.put(x + dx, y + dy, ch, keymap.get(key, " "))
|
||||
|
||||
|
||||
BOXES = {
|
||||
"single": "┌┐└┘─│",
|
||||
"double": "╔╗╚╝═║",
|
||||
"rounded": "╭╮╰╯─│",
|
||||
"heavy": "┏┓┗┛━┃",
|
||||
"ascii": "++++-|",
|
||||
"hash": "######",
|
||||
"block": "██████",
|
||||
}
|
||||
|
||||
|
||||
# --- designs ------------------------------------------------------------------
|
||||
|
||||
def normalize(design):
|
||||
d = copy.deepcopy(design)
|
||||
d.setdefault("name", "custom")
|
||||
d.setdefault("local", False)
|
||||
d.setdefault("vars", {})
|
||||
d.setdefault("styles", {})
|
||||
d.setdefault("lines", [])
|
||||
d.setdefault("style_map", [])
|
||||
d.setdefault("fields", [])
|
||||
d.setdefault("widgets", [])
|
||||
if d.get("widgets_position") not in ("below", "right"):
|
||||
d["widgets_position"] = "below"
|
||||
for f in d["fields"]:
|
||||
f.setdefault("align", "left")
|
||||
f.setdefault("style", " ")
|
||||
f.setdefault("width", len(f.get("text", "")))
|
||||
return d
|
||||
|
||||
|
||||
def preset_dir():
|
||||
return os.path.join(DATA_DIR, "banner", "presets")
|
||||
|
||||
|
||||
def art_dir():
|
||||
return os.path.join(DATA_DIR, "banner", "art")
|
||||
|
||||
|
||||
def drafts_dir():
|
||||
return os.path.join(config_dir(), "banner")
|
||||
|
||||
|
||||
def _json_names(directory):
|
||||
try:
|
||||
return sorted(f[:-5] for f in os.listdir(directory) if f.endswith(".json"))
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def list_presets():
|
||||
out = []
|
||||
for name in sorted(_json_names(preset_dir()), key=lambda n: (n != "das-classic", n)):
|
||||
try:
|
||||
out.append((name, title(load_file(os.path.join(preset_dir(), name + ".json")).get("title"), name)))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def list_drafts():
|
||||
return [(n, os.path.join(drafts_dir(), n + ".json")) for n in _json_names(drafts_dir())]
|
||||
|
||||
|
||||
def list_art():
|
||||
"""(name, title, design) of the ready-made pictures and the user's own."""
|
||||
out = []
|
||||
for directory in (art_dir(), os.path.join(config_dir(), "banner-art")):
|
||||
for name in _json_names(directory):
|
||||
try:
|
||||
d = load_file(os.path.join(directory, name + ".json"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
out.append((name, title(d.get("title"), name), d))
|
||||
return out
|
||||
|
||||
|
||||
def load_file(path):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return normalize(json.load(f))
|
||||
|
||||
|
||||
def load(spec):
|
||||
"""A preset name, a draft name or a path to a design file."""
|
||||
if os.sep in spec or spec.endswith(".json"):
|
||||
return load_file(spec)
|
||||
for directory in (preset_dir(), drafts_dir()):
|
||||
path = os.path.join(directory, spec + ".json")
|
||||
if os.path.exists(path):
|
||||
return load_file(path)
|
||||
raise FileNotFoundError(spec)
|
||||
|
||||
|
||||
def system_design():
|
||||
"""The banner shown at login: /etc/das/banner.json or the das-classic preset."""
|
||||
try:
|
||||
return load_file(SYSTEM_FILE)
|
||||
except (OSError, ValueError):
|
||||
return load_file(os.path.join(preset_dir(), "das-classic.json"))
|
||||
|
||||
|
||||
def dumps(design):
|
||||
return json.dumps(design, ensure_ascii=False, indent=2) + "\n"
|
||||
|
||||
|
||||
def save(design, path):
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(dumps(design))
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
# --- rendering ------------------------------------------------------------------
|
||||
|
||||
_VAR = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||
_LANG = re.compile(r"\[\[([^|\]]*)\|([^\]]*)\]\]")
|
||||
FIGLET_FONTS = ("standard", "small", "big", "slant", "smslant", "mini", "banner", "block",
|
||||
"shadow", "smshadow", "lean", "script", "digital")
|
||||
|
||||
|
||||
def expand(text, values):
|
||||
"""[[Ukrainian|English]] in the user's language, {name} replaced by its
|
||||
value; unknown names stay as they are."""
|
||||
text = _LANG.sub(lambda m: m.group(1) if UK else m.group(2), text)
|
||||
|
||||
def repl(m):
|
||||
value = values.get(m.group(1))
|
||||
return m.group(0) if value is None else str(value)
|
||||
return _VAR.sub(repl, text)
|
||||
|
||||
|
||||
def figlet(text, font):
|
||||
"""Big letters by figlet, or the text itself when figlet is missing."""
|
||||
try:
|
||||
out = subprocess.run(["figlet", "-f", font, "-w", "1000", text], capture_output=True,
|
||||
text=True, timeout=2).stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
out = ""
|
||||
lines = out.rstrip("\n").split("\n") if out.strip() else [text]
|
||||
while lines and not lines[-1].strip():
|
||||
lines.pop()
|
||||
return lines
|
||||
|
||||
|
||||
def fit(text, width, align):
|
||||
if len(text) > width:
|
||||
return text[:max(width - 1, 0)] + "…" if width else ""
|
||||
pad = width - len(text)
|
||||
if align == "right":
|
||||
return " " * pad + text
|
||||
if align == "center":
|
||||
return " " * (pad // 2) + text + " " * (pad - pad // 2)
|
||||
return text + " " * pad
|
||||
|
||||
|
||||
def field_lines(f, values):
|
||||
"""The lines a field draws: one, or several for big letters."""
|
||||
# An empty value leaves no dangling separator: "{host} · {url}" -> "host"
|
||||
text = expand(f.get("text", ""), values).strip(" ·|")
|
||||
width, align = int(f.get("width", 0)), f.get("align", "left")
|
||||
if f.get("font"):
|
||||
big = figlet(text, f["font"])
|
||||
if max(len(line) for line in big) <= width:
|
||||
# Align the block as a whole, so the letters stay together
|
||||
block = max(len(line) for line in big)
|
||||
return [fit(fit(line, block, "left"), width, align) for line in big]
|
||||
return [fit(text, width, align)]
|
||||
return [fit(text, width, align)]
|
||||
|
||||
|
||||
def draw_fields(grid, design, values):
|
||||
for f in design.get("fields", []):
|
||||
key = f.get("style", " ")
|
||||
x, y = int(f.get("x", 0)), int(f.get("y", 0))
|
||||
for i, line in enumerate(field_lines(f, values)):
|
||||
# Spaces of a field do not cover the picture under it
|
||||
for j, ch in enumerate(line):
|
||||
if ch != " " or not f.get("transparent", True):
|
||||
grid.put(x + j, y + i, ch, key if key in grid.styles else " ")
|
||||
|
||||
|
||||
def grid_lines(grid, color=True):
|
||||
"""The grid as terminal lines, trailing blanks removed."""
|
||||
out = []
|
||||
for row in grid.cells:
|
||||
n = len(row)
|
||||
while n and row[n - 1][0] == " " and (row[n - 1][1] == " " or not grid.style(row[n - 1][1])[1]):
|
||||
n -= 1
|
||||
line, current = [], " "
|
||||
for ch, key in row[:n]:
|
||||
if color and key != current:
|
||||
line.append(colors.sgr(*grid.style(key)) if key != " " else colors.RESET)
|
||||
current = key
|
||||
line.append(ch)
|
||||
if color and current != " ":
|
||||
line.append(colors.RESET)
|
||||
out.append("".join(line))
|
||||
while out and not out[-1].strip():
|
||||
out.pop()
|
||||
return out
|
||||
|
||||
|
||||
def visible_len(text):
|
||||
return sum(char_width(c) for c in re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", text))
|
||||
|
||||
|
||||
def render(design, values, widget_blocks, color=True):
|
||||
"""The whole banner as text: the picture with fields, then the widgets."""
|
||||
design = normalize(design)
|
||||
grid = Grid.from_design(design)
|
||||
if design.get("canvas"):
|
||||
grid.resize(max(grid.width, design["canvas"].get("width", 0)), grid.height)
|
||||
draw_fields(grid, design, values)
|
||||
art = grid_lines(grid, color)
|
||||
blocks = [b for b in widget_blocks if b]
|
||||
if not color:
|
||||
blocks = [[re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", line) for line in b] for b in blocks]
|
||||
widget_lines = [line for b in blocks for line in b]
|
||||
if design["widgets_position"] == "right" and art:
|
||||
width = max(visible_len(line) for line in art) + 3
|
||||
out = []
|
||||
for i in range(max(len(art), len(widget_lines))):
|
||||
left = art[i] if i < len(art) else ""
|
||||
right = widget_lines[i] if i < len(widget_lines) else ""
|
||||
out.append((left + " " * (width - visible_len(left)) + right) if right else left)
|
||||
return "\n".join(out) + "\n"
|
||||
out = list(art)
|
||||
if widget_lines:
|
||||
if out:
|
||||
out.append("")
|
||||
out += widget_lines
|
||||
return "\n".join(out) + "\n"
|
||||
@@ -0,0 +1,778 @@
|
||||
"""dbanner editor: a list of banners with a preview, and the drawing editor."""
|
||||
|
||||
import copy
|
||||
import os
|
||||
import re
|
||||
|
||||
from rich.text import Text
|
||||
from textual import work
|
||||
from textual.app import App
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||
from textual.message import Message
|
||||
from textual.screen import ModalScreen, Screen
|
||||
from textual.widgets import (Button, Checkbox, Footer, Header, Input, Label, ListItem, ListView, RadioButton,
|
||||
RadioSet, Select, Static, TabbedContent, TabPane)
|
||||
|
||||
from . import banner, widgets
|
||||
from .canvas import TOOLS, Canvas
|
||||
from .i18n import t, title
|
||||
from .tui_common import AskDialog, Swatches, run_as_root
|
||||
|
||||
TOOL_NAMES = {
|
||||
"pen": ("Олівець", "Pen"), "eraser": ("Гумка", "Eraser"), "line": ("Лінія", "Line"),
|
||||
"box": ("Рамка", "Box"), "fill": ("Заливка", "Fill"), "text": ("Текст", "Text"),
|
||||
"bigtext": ("Великі літери", "Big letters"), "field": ("Поле зі змінною", "Field with a value"),
|
||||
"art": ("Малюнок", "Picture"), "select": ("Виділення", "Select"),
|
||||
}
|
||||
TOOL_HELP = {
|
||||
"pen": ("Клацніть і ведіть мишею; або Enter і стрілки.", "Click and drag; or Enter and the arrows."),
|
||||
"eraser": ("Стирає клітинки під мишею.", "Clears the cells under the mouse."),
|
||||
"line": ("Тягніть від початку до кінця.", "Drag from start to end."),
|
||||
"box": ("Тягніть з кута в кут; вигляд рамки — нижче.", "Drag corner to corner; the box style is below."),
|
||||
"fill": ("Тягніть прямокутник: заповнить символом.", "Drag a rectangle: fills it with the symbol."),
|
||||
"text": ("Клацніть і друкуйте; Enter — новий рядок, Esc — кінець.",
|
||||
"Click and type; Enter for a new line, Esc to stop."),
|
||||
"bigtext": ("Клацніть, куди поставити великі літери (figlet).", "Click where the big letters go (figlet)."),
|
||||
"field": ("Тягніть по рядку: місце для {ip}, {hostname}… Клацання по полю — змінити.",
|
||||
"Drag along a row: room for {ip}, {hostname}… Click a field to change it."),
|
||||
"art": ("Клацніть, куди вставити логотип Das OS, тризуб чи ваш фрагмент.",
|
||||
"Click where to put the Das OS logo, the tryzub or your piece."),
|
||||
"select": ("Тягніть: Ctrl+C копіювати, Ctrl+X вирізати, Ctrl+V вставити біля курсора, Del — стерти.",
|
||||
"Drag: Ctrl+C copy, Ctrl+X cut, Ctrl+V paste at the cursor, Del clears."),
|
||||
}
|
||||
PALETTE = [
|
||||
"█▓▒░▀▄▌▐■□▪▫",
|
||||
"─│┌┐└┘├┤┬┴┼",
|
||||
"═║╔╗╚╝╠╣╦╩╬",
|
||||
"━┃┏┓┗┛╭╮╰╯╱╲",
|
||||
"★☆●○◆◇►◄▲▼♥♦",
|
||||
"#*+=-_|/\\<>~",
|
||||
"·•°…✓✗☀☁⚡✦❖⬢",
|
||||
]
|
||||
BOX_KINDS = [("single", "┌─┐"), ("double", "╔═╗"), ("rounded", "╭─╮"), ("heavy", "┏━┓"),
|
||||
("ascii", "+-+"), ("hash", "###"), ("block", "███")]
|
||||
|
||||
|
||||
def ansi_text(text):
|
||||
return Text.from_ansi(text, no_wrap=True)
|
||||
|
||||
|
||||
def unique_draft_name(base):
|
||||
taken = {n for n, _ in banner.list_drafts()} | {n for n, _ in banner.list_presets()}
|
||||
base = re.sub(r"[^\w.-]+", "-", base).strip("-") or "banner"
|
||||
name, n = base, 1
|
||||
while name in taken:
|
||||
n += 1
|
||||
name = f"{base}-{n}"
|
||||
return name
|
||||
|
||||
|
||||
# --- dialogs ------------------------------------------------------------------
|
||||
|
||||
class ArtDialog(ModalScreen):
|
||||
"""Choose a ready-made picture; dismisses with its design or None."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
ArtDialog { align: center middle; }
|
||||
ArtDialog > Horizontal { width: 90%; height: 80%; border: round $accent; background: $surface; }
|
||||
ArtDialog ListView { width: 40; }
|
||||
ArtDialog #art_preview { padding: 1 2; }
|
||||
"""
|
||||
BINDINGS = [("escape", "cancel", t("Скасувати", "Cancel"))]
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.items = banner.list_art()
|
||||
|
||||
def compose(self):
|
||||
with Horizontal():
|
||||
yield ListView(*[ListItem(Label(Text(title_))) for _, title_, _ in self.items], id="art_list")
|
||||
with Vertical():
|
||||
yield VerticalScroll(Static(id="art_preview"))
|
||||
yield Button(t("Вставити", "Insert"), variant="primary", id="ok")
|
||||
|
||||
def on_list_view_highlighted(self, event):
|
||||
i = event.list_view.index
|
||||
if i is not None and i < len(self.items):
|
||||
d = self.items[i][2]
|
||||
self.query_one("#art_preview", Static).update(
|
||||
ansi_text("\n".join(banner.grid_lines(banner.Grid.from_design(d)))))
|
||||
|
||||
def on_list_view_selected(self, event):
|
||||
self._ok()
|
||||
|
||||
def on_button_pressed(self, event):
|
||||
self._ok()
|
||||
|
||||
def _ok(self):
|
||||
i = self.query_one("#art_list", ListView).index
|
||||
self.dismiss(self.items[i][2] if i is not None and i < len(self.items) else None)
|
||||
|
||||
def action_cancel(self):
|
||||
self.dismiss(None)
|
||||
|
||||
|
||||
def field_dialog(f, new):
|
||||
variables = ", ".join("{" + k + "}" for k in widgets.PROVIDERS)
|
||||
fields = [
|
||||
("text", t(f"Текст зі змінними: {variables}, ваші змінні; [[укр|eng]] — двомовний підпис",
|
||||
f"Text with values: {variables}, your variables; [[ukr|eng]] for two languages"),
|
||||
f.get("text", "{hostname}")),
|
||||
("width", t("Ширина", "Width"), str(f.get("width", 20))),
|
||||
("align", t("Вирівнювання", "Alignment"),
|
||||
[(t("ліворуч", "left"), "left"), (t("по центру", "center"), "center"), (t("праворуч", "right"), "right")],
|
||||
f.get("align", "left")),
|
||||
("font", t("Великі літери (figlet)", "Big letters (figlet)"),
|
||||
[(t("ні", "no"), "")] + [(n, n) for n in banner.FIGLET_FONTS], f.get("font", "")),
|
||||
]
|
||||
if not new:
|
||||
fields.append(("delete", t("Видалити поле?", "Delete the field?"),
|
||||
[(t("ні", "no"), "no"), (t("так, видалити", "yes, delete"), "yes")], "no"))
|
||||
return AskDialog(t("Нове поле" if new else "Поле", "New field" if new else "Field"), fields)
|
||||
|
||||
|
||||
class Palette(Static):
|
||||
"""Symbols to click; posts Picked with the symbol."""
|
||||
|
||||
class Picked(Message):
|
||||
def __init__(self, char):
|
||||
super().__init__()
|
||||
self.char = char
|
||||
|
||||
def on_click(self, event):
|
||||
row, col = event.y, event.x // 2
|
||||
if 0 <= row < len(PALETTE) and 0 <= col < len(PALETTE[row]) and event.x % 2 == 0:
|
||||
self.post_message(self.Picked(PALETTE[row][col]))
|
||||
|
||||
|
||||
# --- the editor -----------------------------------------------------------------
|
||||
|
||||
class EditorScreen(Screen):
|
||||
CSS = """
|
||||
#tools { width: 40; border-right: solid $primary; padding: 0 1; }
|
||||
#tools Label.h { text-style: bold; color: $accent; margin-top: 1; }
|
||||
#help { color: $text-muted; height: auto; }
|
||||
#palette { height: auto; }
|
||||
#status { height: 1; color: $text-muted; padding: 0 1; }
|
||||
RadioSet { width: 100%; }
|
||||
.buttons { height: auto; }
|
||||
.buttons Button { margin-right: 1; min-width: 5; }
|
||||
.row { height: auto; }
|
||||
.row Input { width: 1fr; }
|
||||
#fields_list, #widget_list { height: 1fr; min-height: 6; }
|
||||
#preview_box { padding: 0 1; }
|
||||
#widget_out { height: auto; max-height: 12; border: round $primary; }
|
||||
"""
|
||||
BINDINGS = [
|
||||
Binding("ctrl+s", "save", t("Зберегти", "Save")),
|
||||
Binding("ctrl+z", "undo", t("Скасувати дію", "Undo")),
|
||||
Binding("ctrl+y", "redo", t("Повторити", "Redo")),
|
||||
Binding("ctrl+r", "resize", t("Розмір", "Size")),
|
||||
Binding("ctrl+c", "copy", t("Копіювати", "Copy"), show=False),
|
||||
Binding("ctrl+x", "cut", t("Вирізати", "Cut"), show=False),
|
||||
Binding("ctrl+v", "paste", t("Вставити", "Paste"), show=False),
|
||||
Binding("f5", "preview", t("Перегляд", "Preview")),
|
||||
Binding("ctrl+a", "apply", t("Застосувати", "Apply")),
|
||||
Binding("escape", "back", t("Назад", "Back")),
|
||||
]
|
||||
|
||||
def __init__(self, design, draft_name):
|
||||
super().__init__()
|
||||
self.design = banner.normalize(design)
|
||||
self.name_ = draft_name
|
||||
self.grid = banner.Grid.from_design(self.design)
|
||||
canvas = self.design.get("canvas") or {}
|
||||
self.grid.resize(max(self.grid.width, canvas.get("width", 0), 60),
|
||||
max(self.grid.height, canvas.get("height", 0), 10))
|
||||
self.fields = self.design["fields"]
|
||||
self.undo_stack, self.redo_stack = [], []
|
||||
self.dirty = False
|
||||
|
||||
# --- layout ------------------------------------------------------------
|
||||
def compose(self):
|
||||
yield Header()
|
||||
with Horizontal():
|
||||
with VerticalScroll(id="tools"):
|
||||
yield Label(Text(t("Інструмент", "Tool")), classes="h")
|
||||
with RadioSet(id="tool"):
|
||||
for i, tool in enumerate(TOOLS):
|
||||
yield RadioButton(t(*TOOL_NAMES[tool]), value=i == 0)
|
||||
yield Static(Text(t(*TOOL_HELP["pen"])), id="help")
|
||||
yield Label(Text(t("Символ", "Symbol")), classes="h")
|
||||
yield Palette(id="palette")
|
||||
with Horizontal(classes="row"):
|
||||
yield Input("█", max_length=1, id="char")
|
||||
yield Label(Text(t("Вигляд рамки", "Box style")), classes="h")
|
||||
yield Select([(f"{sample} {kind}", kind) for kind, sample in BOX_KINDS], value="single",
|
||||
allow_blank=False, id="box_kind")
|
||||
yield Label(Text(t("Колір символу", "Symbol color")), classes="h")
|
||||
yield Swatches("cyan", id="fg")
|
||||
yield Label(Text(t("Колір тла", "Background")), classes="h")
|
||||
yield Swatches(None, id="bg")
|
||||
yield Checkbox(t("Жирний", "Bold"), id="bold")
|
||||
with TabbedContent(id="tabs"):
|
||||
with TabPane(t("Малюнок", "Picture"), id="tab_draw"):
|
||||
yield Canvas(self.grid, self.fields, before=self.snapshot, id="canvas")
|
||||
yield Static(id="status")
|
||||
with TabPane(t("Поля", "Fields"), id="tab_fields"):
|
||||
yield Static(Text(t("Поля показують змінні: ім'я, IP, URL… Додаються інструментом "
|
||||
"«Поле зі змінною» на полотні.",
|
||||
"Fields show values: name, IP, URL… Add them with the \"Field with "
|
||||
"a value\" tool on the canvas.")))
|
||||
yield ListView(id="fields_list")
|
||||
with Horizontal(classes="buttons"):
|
||||
yield Button(t("Змінити", "Edit"), id="field_edit")
|
||||
yield Button(t("Видалити", "Delete"), id="field_del", variant="error")
|
||||
with TabPane(t("Віджети", "Widgets"), id="tab_widgets"):
|
||||
yield Select([(t("під малюнком", "below the picture"), "below"),
|
||||
(t("праворуч від малюнка", "right of the picture"), "right")],
|
||||
value=self.design["widgets_position"], allow_blank=False, id="position")
|
||||
yield ListView(id="widget_list")
|
||||
with Horizontal(classes="buttons"):
|
||||
yield Button("↑", id="w_up")
|
||||
yield Button("↓", id="w_down")
|
||||
yield Button(t("Вимк/увімк", "Off/on"), id="w_toggle")
|
||||
yield Button(t("Налаштувати", "Settings"), id="w_edit")
|
||||
yield Button(t("Запустити", "Run"), id="w_run")
|
||||
yield Button(t("Видалити", "Delete"), id="w_del", variant="error")
|
||||
yield Select([(f"{name_} — {title_}", name_) for name_, title_, _ in widgets.available()],
|
||||
prompt=t("+ готовий віджет", "+ ready-made widget"), id="w_add")
|
||||
with Horizontal(classes="row"):
|
||||
yield Input(placeholder=t("своя команда, напр. df -h /data", "own command, e.g. df -h /data"),
|
||||
id="w_cmd")
|
||||
yield Button(t("+ команда", "+ command"), id="w_add_cmd")
|
||||
with Horizontal(classes="row"):
|
||||
yield Input(placeholder=t("файл з виводом скрипта, напр. ~/scripts/out/hdd",
|
||||
"file with a script's output, e.g. ~/scripts/out/hdd"), id="w_file")
|
||||
yield Button(t("+ файл", "+ file"), id="w_add_file")
|
||||
yield VerticalScroll(Static(id="widget_out_text"), id="widget_out")
|
||||
with TabPane(t("Змінні", "Variables"), id="tab_vars"):
|
||||
yield Static(Text(t("Свої змінні для полів: {url}, {title}, {city} (місто для погоди)…",
|
||||
"Your own values for fields: {url}, {title}, {city} (weather city)…")))
|
||||
yield VerticalScroll(id="vars_box")
|
||||
with Horizontal(classes="row"):
|
||||
yield Input(placeholder=t("назва", "name"), id="var_name")
|
||||
yield Input(placeholder=t("значення", "value"), id="var_value")
|
||||
yield Button(t("Додати", "Add"), id="var_add")
|
||||
yield Static(Text("\n".join(f"{{{k}}} — {t(*v)}" for k, v in widgets.VAR_HELP.items()),
|
||||
style="dim"))
|
||||
yield Checkbox(t("Показувати й при вході в консоль (не лише SSH)",
|
||||
"Also show on console logins (not only SSH)"),
|
||||
bool(self.design.get("local")), id="local")
|
||||
with TabPane(t("Перегляд", "Preview"), id="tab_preview"):
|
||||
yield VerticalScroll(Static(id="preview"), id="preview_box")
|
||||
yield Footer()
|
||||
|
||||
async def on_mount(self):
|
||||
self.sub_title = self.name_
|
||||
self.render_palette()
|
||||
self.refresh_fields()
|
||||
self.refresh_widgets()
|
||||
await self.refresh_vars()
|
||||
self.update_status()
|
||||
self.query_one(Canvas).focus()
|
||||
|
||||
# --- tools panel ------------------------------------------------------------
|
||||
@property
|
||||
def canvas(self):
|
||||
return self.query_one(Canvas)
|
||||
|
||||
def render_palette(self):
|
||||
text = Text()
|
||||
for row in PALETTE:
|
||||
for ch in row:
|
||||
style = "reverse bold" if ch == self.canvas.char else ""
|
||||
text.append(ch, style=style)
|
||||
text.append(" ")
|
||||
text.append("\n")
|
||||
self.query_one("#palette", Static).update(text)
|
||||
|
||||
def on_palette_picked(self, event):
|
||||
self.set_char(event.char)
|
||||
|
||||
def set_char(self, ch):
|
||||
self.canvas.char = ch
|
||||
inp = self.query_one("#char", Input)
|
||||
if inp.value != ch:
|
||||
inp.value = ch
|
||||
self.render_palette()
|
||||
self.update_status()
|
||||
|
||||
def on_radio_set_changed(self, event: RadioSet.Changed):
|
||||
tool = TOOLS[event.index]
|
||||
self.canvas.tool = tool
|
||||
self.canvas.anchor = self.canvas.text_origin = None
|
||||
self.query_one("#help", Static).update(Text(t(*TOOL_HELP[tool])))
|
||||
self.update_status()
|
||||
self.canvas.focus()
|
||||
|
||||
def on_swatches_picked(self, event: Swatches.Picked):
|
||||
if event.swatches.id == "fg":
|
||||
self.canvas.fg = event.color
|
||||
else:
|
||||
self.canvas.bg = event.color
|
||||
self.update_status()
|
||||
|
||||
def update_status(self):
|
||||
c = self.canvas
|
||||
x, y = c.cursor
|
||||
style = " ".join(filter(None, ["bold" if c.bold else "", c.fg or "default", ("on " + c.bg) if c.bg else ""]))
|
||||
text = Text.assemble(
|
||||
f" {t(*TOOL_NAMES[c.tool])} │ ", (c.char * 3, style), f" │ x={x} y={y} │ "
|
||||
f"{self.grid.width}×{self.grid.height} │ {t('поля', 'fields')}: {len(self.fields)}")
|
||||
self.query_one("#status", Static).update(text)
|
||||
|
||||
def snapshot(self):
|
||||
self.undo_stack.append((self.grid.copy(), copy.deepcopy(self.fields)))
|
||||
del self.undo_stack[:-100]
|
||||
self.redo_stack.clear()
|
||||
self.dirty = True
|
||||
|
||||
def on_canvas_changed(self, event):
|
||||
self.update_status()
|
||||
|
||||
def on_canvas_request(self, event: Canvas.Request):
|
||||
if event.tool == "art":
|
||||
def put(d):
|
||||
if d:
|
||||
self.snapshot()
|
||||
self.grid.stamp(event.x, event.y, banner.Grid.from_design(d))
|
||||
self.canvas.refresh()
|
||||
self.app.push_screen(ArtDialog(), put)
|
||||
elif event.tool == "bigtext":
|
||||
def put_big(result):
|
||||
if not result or not result["text"]:
|
||||
return
|
||||
lines = banner.figlet(result["text"], result["font"])
|
||||
self.snapshot()
|
||||
key = self.canvas.key_now()
|
||||
for i, line in enumerate(lines):
|
||||
for j, ch in enumerate(line):
|
||||
if ch != " ":
|
||||
self.grid.put(event.x + j, event.y + i, ch, key)
|
||||
self.canvas.refresh()
|
||||
self.app.push_screen(AskDialog(t("Великі літери", "Big letters"), [
|
||||
("text", t("Текст", "Text"), "Das OS"),
|
||||
("font", t("Шрифт figlet", "figlet font"), [(n, n) for n in banner.FIGLET_FONTS], "standard")]),
|
||||
put_big)
|
||||
elif event.tool == "field":
|
||||
self.edit_field(event.field_index, event.x, event.y, event.width)
|
||||
|
||||
def edit_field(self, index, x=0, y=0, width=20):
|
||||
new = index is None
|
||||
f = {"x": x, "y": y, "width": max(width, 4) if width > 1 else 20, "text": "{hostname}"} if new \
|
||||
else self.fields[index]
|
||||
|
||||
def done(result):
|
||||
if not result:
|
||||
return
|
||||
self.snapshot()
|
||||
if result.get("delete") == "yes":
|
||||
del self.fields[index]
|
||||
else:
|
||||
target = dict(f) if new else f
|
||||
target.update(text=result["text"], align=result["align"],
|
||||
width=int(result["width"]) if result["width"].isdigit() else 20,
|
||||
style=self.canvas.key_now() if new else f.get("style", " "))
|
||||
if result["font"]:
|
||||
target["font"] = result["font"]
|
||||
else:
|
||||
target.pop("font", None)
|
||||
if new:
|
||||
self.fields.append(target)
|
||||
self.canvas.refresh()
|
||||
self.refresh_fields()
|
||||
self.update_status()
|
||||
self.app.push_screen(field_dialog(f, new), done)
|
||||
|
||||
# --- inputs ----------------------------------------------------------------------
|
||||
def on_input_changed(self, event: Input.Changed):
|
||||
if event.input.id == "char" and event.value:
|
||||
self.set_char(event.value[0])
|
||||
elif (event.input.id or "").startswith("var_v_"):
|
||||
self.design["vars"][event.input.id[6:]] = event.value
|
||||
self.dirty = True
|
||||
|
||||
def on_checkbox_changed(self, event: Checkbox.Changed):
|
||||
if event.checkbox.id == "bold":
|
||||
self.canvas.bold = event.value
|
||||
self.update_status()
|
||||
elif event.checkbox.id == "local":
|
||||
self.design["local"] = event.value
|
||||
self.dirty = True
|
||||
|
||||
def on_select_changed(self, event: Select.Changed):
|
||||
sid = event.select.id
|
||||
if sid == "box_kind":
|
||||
self.canvas.box_kind = event.value
|
||||
elif sid == "position":
|
||||
self.design["widgets_position"] = event.value
|
||||
self.dirty = True
|
||||
elif sid == "w_add" and event.value is not Select.NULL:
|
||||
self.design["widgets"].append({"type": "builtin", "name": event.value})
|
||||
event.select.clear()
|
||||
self.refresh_widgets(len(self.design["widgets"]) - 1)
|
||||
|
||||
# --- fields tab ---------------------------------------------------------------------
|
||||
def refresh_fields(self):
|
||||
lv = self.query_one("#fields_list", ListView)
|
||||
lv.clear()
|
||||
for f in self.fields:
|
||||
font = f" [{f['font']}]" if f.get("font") else ""
|
||||
lv.append(ListItem(Label(Text(f"({f['x']},{f['y']}) ×{f.get('width')} {f.get('align')}{font}: "
|
||||
f"{f.get('text', '')}"))))
|
||||
|
||||
# --- widgets tab --------------------------------------------------------------------
|
||||
def refresh_widgets(self, index=None):
|
||||
lv = self.query_one("#widget_list", ListView)
|
||||
lv.clear()
|
||||
for w in self.design["widgets"]:
|
||||
mark = "☐" if w.get("enabled") is False else "☑"
|
||||
mode = widgets.mode_of(w)
|
||||
info = t("при вході", "at login") if mode == "live" else \
|
||||
t(f"кеш, кожні {widgets.interval_of(w)} с", f"cached, every {widgets.interval_of(w)} s")
|
||||
label = Text.assemble(f"{mark} ", widgets.describe(w), (f" ({info})", "dim"))
|
||||
if w.get("title"):
|
||||
label.append(f" «{w['title']}»", style="italic")
|
||||
lv.append(ListItem(Label(label)))
|
||||
if index is not None:
|
||||
lv.index = index
|
||||
|
||||
def widget_index(self):
|
||||
i = self.query_one("#widget_list", ListView).index
|
||||
return i if i is not None and i < len(self.design["widgets"]) else None
|
||||
|
||||
@work(thread=True, exclusive=True, group="widget_run")
|
||||
def run_widget(self, w):
|
||||
lines = widgets.run(w, widgets.Values(self.design.get("vars")), self.design.get("vars"),
|
||||
timeout=float(w.get("timeout") or 30))
|
||||
out = "\n".join(lines) if lines else t("(порожньо: немає даних, помилка або потрібен root)",
|
||||
"(empty: no data, an error, or it needs root)")
|
||||
self.app.call_from_thread(self.query_one("#widget_out_text", Static).update, ansi_text(out))
|
||||
|
||||
def edit_widget(self, i):
|
||||
w = self.design["widgets"][i]
|
||||
|
||||
def done(result):
|
||||
if not result:
|
||||
return
|
||||
w["title"] = result["title"]
|
||||
for key in ("interval", "timeout"):
|
||||
if result[key].isdigit():
|
||||
w[key] = int(result[key])
|
||||
else:
|
||||
w.pop(key, None)
|
||||
if result["mode"]:
|
||||
w["mode"] = result["mode"]
|
||||
else:
|
||||
w.pop("mode", None)
|
||||
if w.get("type") == "command":
|
||||
w["cmd"] = result.get("cmd", w.get("cmd", ""))
|
||||
self.dirty = True
|
||||
self.refresh_widgets(i)
|
||||
fields = [("title", t("Заголовок над виводом (необов'язково)", "Heading above the output (optional)"),
|
||||
w.get("title", "")),
|
||||
("mode", t("Коли виконувати", "When to run"),
|
||||
[(t("як задумано віджетом", "as the widget intends"), ""),
|
||||
(t("при кожному вході (швидкі)", "at every login (quick ones)"), "live"),
|
||||
(t("службою das-banner, з кешем (повільні, від root)", "by the das-banner service, cached "
|
||||
"(slow ones, as root)"), "cached")], w.get("mode", "")),
|
||||
("interval", t("Оновлювати кеш кожні … секунд", "Refresh the cache every … seconds"),
|
||||
str(w.get("interval", ""))),
|
||||
("timeout", t("Тайм-аут, секунд", "Timeout, seconds"), str(w.get("timeout", "")))]
|
||||
if w.get("type") == "command":
|
||||
fields.insert(0, ("cmd", t("Команда", "Command"), w.get("cmd", "")))
|
||||
self.app.push_screen(AskDialog(widgets.describe(w), fields), done)
|
||||
|
||||
# --- variables tab -------------------------------------------------------------------
|
||||
async def refresh_vars(self):
|
||||
box = self.query_one("#vars_box", VerticalScroll)
|
||||
await box.remove_children()
|
||||
rows = []
|
||||
for key, value in self.design["vars"].items():
|
||||
rows.append(Horizontal(Label(Text(f"{{{key}}}"), classes="var_label"),
|
||||
Input(str(value), id="var_v_" + key),
|
||||
Button("✕", id="var_del_" + key), classes="row"))
|
||||
await box.mount(*rows)
|
||||
|
||||
# --- buttons -----------------------------------------------------------------------------
|
||||
async def on_button_pressed(self, event: Button.Pressed):
|
||||
bid = event.button.id or ""
|
||||
ws = self.design["widgets"]
|
||||
i = self.widget_index()
|
||||
if bid == "field_edit":
|
||||
n = self.query_one("#fields_list", ListView).index
|
||||
if n is not None and n < len(self.fields):
|
||||
self.edit_field(n)
|
||||
elif bid == "field_del":
|
||||
n = self.query_one("#fields_list", ListView).index
|
||||
if n is not None and n < len(self.fields):
|
||||
self.snapshot()
|
||||
del self.fields[n]
|
||||
self.refresh_fields()
|
||||
self.canvas.refresh()
|
||||
elif bid == "w_add_cmd":
|
||||
cmd = self.query_one("#w_cmd", Input).value.strip()
|
||||
if cmd:
|
||||
ws.append({"type": "command", "cmd": cmd})
|
||||
self.query_one("#w_cmd", Input).value = ""
|
||||
self.refresh_widgets(len(ws) - 1)
|
||||
elif bid == "w_add_file":
|
||||
path = self.query_one("#w_file", Input).value.strip()
|
||||
if path:
|
||||
ws.append({"type": "file", "path": path})
|
||||
self.query_one("#w_file", Input).value = ""
|
||||
self.refresh_widgets(len(ws) - 1)
|
||||
elif bid.startswith("var_del_"):
|
||||
self.design["vars"].pop(bid[8:], None)
|
||||
await self.refresh_vars()
|
||||
elif bid == "var_add":
|
||||
name = re.sub(r"\W", "", self.query_one("#var_name", Input).value)
|
||||
if name:
|
||||
self.design["vars"][name] = self.query_one("#var_value", Input).value
|
||||
self.query_one("#var_name", Input).value = self.query_one("#var_value", Input).value = ""
|
||||
await self.refresh_vars()
|
||||
elif i is not None:
|
||||
if bid == "w_up" and i > 0:
|
||||
ws[i - 1], ws[i] = ws[i], ws[i - 1]
|
||||
self.refresh_widgets(i - 1)
|
||||
elif bid == "w_down" and i + 1 < len(ws):
|
||||
ws[i + 1], ws[i] = ws[i], ws[i + 1]
|
||||
self.refresh_widgets(i + 1)
|
||||
elif bid == "w_toggle":
|
||||
ws[i]["enabled"] = ws[i].get("enabled") is False
|
||||
self.refresh_widgets(i)
|
||||
elif bid == "w_del":
|
||||
del ws[i]
|
||||
self.refresh_widgets(min(i, len(ws) - 1) if ws else None)
|
||||
elif bid == "w_edit":
|
||||
self.edit_widget(i)
|
||||
elif bid == "w_run":
|
||||
self.query_one("#widget_out_text", Static).update(Text(t("Виконується…", "Running…")))
|
||||
self.run_widget(ws[i])
|
||||
return
|
||||
self.dirty = True
|
||||
|
||||
# --- result ------------------------------------------------------------------------------
|
||||
def current_design(self):
|
||||
return self.grid.to_design(dict(self.design, fields=self.fields, name=self.name_))
|
||||
|
||||
def draft_path(self):
|
||||
return os.path.join(banner.drafts_dir(), self.name_ + ".json")
|
||||
|
||||
def action_save(self):
|
||||
banner.save(self.current_design(), self.draft_path())
|
||||
self.dirty = False
|
||||
self.notify(t(f"Збережено: {self.draft_path()}", f"Saved: {self.draft_path()}"))
|
||||
return True
|
||||
|
||||
def action_undo(self):
|
||||
if self.undo_stack:
|
||||
self.redo_stack.append((self.grid.copy(), copy.deepcopy(self.fields)))
|
||||
self._restore(*self.undo_stack.pop())
|
||||
|
||||
def action_redo(self):
|
||||
if self.redo_stack:
|
||||
self.undo_stack.append((self.grid.copy(), copy.deepcopy(self.fields)))
|
||||
self._restore(*self.redo_stack.pop())
|
||||
|
||||
def _restore(self, grid, fields):
|
||||
self.grid.cells, self.grid.styles = grid.cells, grid.styles
|
||||
self.grid.width, self.grid.height = grid.width, grid.height
|
||||
self.fields[:] = fields
|
||||
self.canvas.set_grid(self.grid, self.fields)
|
||||
self.refresh_fields()
|
||||
self.update_status()
|
||||
|
||||
def action_copy(self):
|
||||
if self.canvas.copy_selection():
|
||||
self.notify(t("Скопійовано", "Copied"))
|
||||
|
||||
def action_cut(self):
|
||||
self.canvas.copy_selection(cut=True)
|
||||
|
||||
def action_paste(self):
|
||||
self.canvas.paste()
|
||||
|
||||
def action_resize(self):
|
||||
def done(result):
|
||||
if result and result["w"].isdigit() and result["h"].isdigit():
|
||||
self.snapshot()
|
||||
self.grid.resize(min(int(result["w"]), 300), min(int(result["h"]), 100))
|
||||
self.canvas.set_grid(self.grid, self.fields)
|
||||
self.update_status()
|
||||
self.app.push_screen(AskDialog(t("Розмір полотна", "Canvas size"), [
|
||||
("w", t("Ширина, символів", "Width, characters"), str(self.grid.width)),
|
||||
("h", t("Висота, рядків", "Height, lines"), str(self.grid.height))]), done)
|
||||
|
||||
def action_preview(self):
|
||||
self.query_one("#tabs", TabbedContent).active = "tab_preview"
|
||||
self.query_one("#preview", Static).update(Text(t("Збираю дані…", "Collecting data…")))
|
||||
self.build_preview(self.current_design())
|
||||
|
||||
def on_tabbed_content_tab_activated(self, event):
|
||||
if event.pane.id == "tab_preview":
|
||||
self.query_one("#preview", Static).update(Text(t("Збираю дані…", "Collecting data…")))
|
||||
self.build_preview(self.current_design())
|
||||
elif event.pane.id == "tab_fields":
|
||||
self.refresh_fields()
|
||||
|
||||
@work(thread=True, exclusive=True, group="preview")
|
||||
def build_preview(self, design):
|
||||
values = widgets.Values(design.get("vars"))
|
||||
blocks = widgets.blocks(design, values, use_cache=False)
|
||||
text = banner.render(design, values, blocks)
|
||||
self.app.call_from_thread(self.query_one("#preview", Static).update, ansi_text(text))
|
||||
|
||||
def action_apply(self):
|
||||
self.action_save()
|
||||
if run_as_root(self.app, ["dbanner", "apply", self.draft_path()]):
|
||||
self.notify(t("Банер застосовано: його видно при вході по SSH.",
|
||||
"The banner is applied: it shows at SSH login."), timeout=6)
|
||||
|
||||
def action_back(self):
|
||||
if self.canvas.anchor is not None or self.canvas.selection or self.canvas.text_origin:
|
||||
self.canvas.anchor = self.canvas.selection = self.canvas.text_origin = None
|
||||
self.canvas.refresh()
|
||||
return
|
||||
if not self.dirty:
|
||||
self.dismiss(None)
|
||||
return
|
||||
|
||||
def done(result):
|
||||
if result is None:
|
||||
return
|
||||
if result["save"] == "yes":
|
||||
self.action_save()
|
||||
self.dismiss(None)
|
||||
self.app.push_screen(AskDialog(t("Є незбережені зміни", "There are unsaved changes"), [
|
||||
("save", t("Зберегти чернетку перед виходом?", "Save the draft before leaving?"),
|
||||
[(t("так, зберегти", "yes, save"), "yes"), (t("ні, відкинути", "no, discard"), "no")], "yes")]), done)
|
||||
|
||||
|
||||
# --- start screen ------------------------------------------------------------------
|
||||
|
||||
class StartScreen(Screen):
|
||||
CSS = """
|
||||
#list { width: 46; border-right: solid $primary; }
|
||||
#start_preview { padding: 0 1; }
|
||||
.buttons { height: 3; dock: bottom; }
|
||||
.buttons Button { margin-right: 1; }
|
||||
"""
|
||||
BINDINGS = [
|
||||
Binding("enter", "edit", t("Редагувати", "Edit"), show=False),
|
||||
Binding("e", "edit", t("Редагувати", "Edit")),
|
||||
Binding("n", "new", t("Новий", "New")),
|
||||
Binding("a", "apply", t("Застосувати", "Apply")),
|
||||
Binding("delete", "delete", t("Видалити чернетку", "Delete draft")),
|
||||
Binding("q", "app.quit", t("Вийти", "Quit")),
|
||||
]
|
||||
|
||||
def compose(self):
|
||||
yield Header()
|
||||
with Horizontal():
|
||||
yield ListView(id="list")
|
||||
yield VerticalScroll(Static(id="start_preview"))
|
||||
with Horizontal(classes="buttons"):
|
||||
yield Button(t("Редагувати копію", "Edit a copy"), id="edit", variant="primary")
|
||||
yield Button(t("Новий", "New"), id="new")
|
||||
yield Button(t("Застосувати", "Apply"), id="apply", variant="success")
|
||||
yield Button(t("Видалити чернетку", "Delete draft"), id="delete", variant="error")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self):
|
||||
self.reload()
|
||||
|
||||
def on_screen_resume(self):
|
||||
self.reload()
|
||||
|
||||
def reload(self):
|
||||
# (kind, name, path or None, label)
|
||||
self.entries = []
|
||||
if os.path.exists(banner.SYSTEM_FILE):
|
||||
self.entries.append(("system", "system", banner.SYSTEM_FILE,
|
||||
t("● Поточний банер системи", "● The system's current banner")))
|
||||
for name, path in banner.list_drafts():
|
||||
self.entries.append(("draft", name, path, t("✎ Чернетка: ", "✎ Draft: ") + name))
|
||||
for name, title_ in banner.list_presets():
|
||||
self.entries.append(("preset", name, os.path.join(banner.preset_dir(), name + ".json"), title_))
|
||||
lv = self.query_one("#list", ListView)
|
||||
index = lv.index or 0
|
||||
lv.clear()
|
||||
for kind, name, _, label in self.entries:
|
||||
lv.append(ListItem(Label(Text(label, style="bold" if kind != "preset" else ""))))
|
||||
lv.index = min(index, len(self.entries) - 1) if self.entries else None
|
||||
|
||||
def selected(self):
|
||||
i = self.query_one("#list", ListView).index
|
||||
return self.entries[i] if i is not None and i < len(self.entries) else None
|
||||
|
||||
def on_list_view_highlighted(self, event):
|
||||
entry = self.selected()
|
||||
if entry:
|
||||
self.query_one("#start_preview", Static).update(Text(t("Збираю дані…", "Collecting data…")))
|
||||
self.show_preview(entry[2])
|
||||
|
||||
def on_list_view_selected(self, event):
|
||||
self.action_edit()
|
||||
|
||||
@work(thread=True, exclusive=True, group="start_preview")
|
||||
def show_preview(self, path):
|
||||
try:
|
||||
design = banner.load_file(path)
|
||||
values = widgets.Values(design.get("vars"))
|
||||
text = banner.render(design, values, widgets.blocks(design, values, use_cache=True))
|
||||
except (OSError, ValueError) as e:
|
||||
text = str(e)
|
||||
self.app.call_from_thread(self.query_one("#start_preview", Static).update, ansi_text(text))
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed):
|
||||
getattr(self, "action_" + event.button.id)()
|
||||
|
||||
def action_edit(self):
|
||||
entry = self.selected()
|
||||
if not entry:
|
||||
return
|
||||
kind, name, path, _ = entry
|
||||
design = banner.load_file(path)
|
||||
draft = name if kind == "draft" else unique_draft_name(design.get("name", name) if kind == "system"
|
||||
else name + "-my")
|
||||
self.app.push_screen(EditorScreen(design, draft))
|
||||
|
||||
def action_new(self):
|
||||
def done(result):
|
||||
if result:
|
||||
design = {"name": result["name"], "canvas": {"width": 60, "height": 10}}
|
||||
self.app.push_screen(EditorScreen(design, unique_draft_name(result["name"])))
|
||||
self.app.push_screen(AskDialog(t("Новий банер", "New banner"),
|
||||
[("name", t("Назва чернетки", "Draft name"), "my-banner")]), done)
|
||||
|
||||
def action_apply(self):
|
||||
entry = self.selected()
|
||||
if entry and entry[0] != "system":
|
||||
if run_as_root(self.app, ["dbanner", "apply", entry[2]]):
|
||||
self.notify(t("Банер застосовано.", "The banner is applied."))
|
||||
self.reload()
|
||||
|
||||
def action_delete(self):
|
||||
entry = self.selected()
|
||||
if entry and entry[0] == "draft":
|
||||
os.remove(entry[2])
|
||||
self.notify(t(f"Видалено {entry[1]}", f"Deleted {entry[1]}"))
|
||||
self.reload()
|
||||
|
||||
|
||||
class BannerApp(App):
|
||||
TITLE = "dbanner"
|
||||
SUB_TITLE = t("вітальний банер при вході по SSH", "SSH login banner")
|
||||
|
||||
def __init__(self):
|
||||
# The terminal's own 16 colors: the picture looks as it will at login
|
||||
super().__init__(ansi_color=True)
|
||||
|
||||
def on_mount(self):
|
||||
self.push_screen(StartScreen())
|
||||
|
||||
|
||||
__all__ = ["BannerApp", "title"]
|
||||
@@ -0,0 +1,315 @@
|
||||
"""The drawing canvas of the dbanner editor: a Grid shown cell by cell, drawn with
|
||||
the mouse or the keyboard."""
|
||||
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
from textual import events
|
||||
from textual.geometry import Size
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.scroll_view import ScrollView
|
||||
from textual.strip import Strip
|
||||
|
||||
from . import banner
|
||||
|
||||
TOOLS = ("pen", "eraser", "line", "box", "fill", "text", "bigtext", "field", "art", "select")
|
||||
DRAG_TOOLS = ("line", "box", "fill", "select", "field")
|
||||
|
||||
|
||||
def rich_cell_style(grid, key):
|
||||
fg, bg, bold = grid.style(key)
|
||||
return Style(color=fg, bgcolor=bg, bold=bold)
|
||||
|
||||
|
||||
class Canvas(ScrollView, can_focus=True):
|
||||
"""Shows a grid with its fields. before() is called right before every
|
||||
change (for undo; a message would arrive after the change)."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
Canvas { background: $background; }
|
||||
Canvas:focus { border: none; }
|
||||
"""
|
||||
|
||||
cursor = reactive((0, 0), always_update=True)
|
||||
|
||||
class Changed(Message):
|
||||
pass
|
||||
|
||||
class Request(Message):
|
||||
"""A tool needs a dialog: big text, field, picture."""
|
||||
|
||||
def __init__(self, tool, x, y, width=0, field_index=None):
|
||||
super().__init__()
|
||||
self.tool, self.x, self.y, self.width, self.field_index = tool, x, y, width, field_index
|
||||
|
||||
def __init__(self, grid, fields, before=None, **kw):
|
||||
super().__init__(**kw)
|
||||
self.grid, self.fields = grid, fields
|
||||
self.before = before
|
||||
self.tool = "pen"
|
||||
self.char = "█"
|
||||
self.fg, self.bg, self.bold = "cyan", None, False
|
||||
self.box_kind = "single"
|
||||
self.anchor = None # start of a drag or of a keyboard shape
|
||||
self.last = None # last point of the pen
|
||||
self.selection = None # (x0, y0, x1, y1)
|
||||
self.clipboard = None
|
||||
self.text_origin = None
|
||||
|
||||
# --- geometry ------------------------------------------------------------
|
||||
def set_grid(self, grid, fields):
|
||||
self.grid, self.fields = grid, fields
|
||||
self.virtual_size = Size(grid.width + 1, grid.height + 1)
|
||||
self.refresh()
|
||||
|
||||
def on_mount(self):
|
||||
self.virtual_size = Size(self.grid.width + 1, self.grid.height + 1)
|
||||
|
||||
def key_now(self):
|
||||
return self.grid.style_key(self.fg, self.bg, self.bold)
|
||||
|
||||
def _cell_at(self, event):
|
||||
offset = event.get_content_offset(self)
|
||||
if offset is None:
|
||||
return None
|
||||
x, y = offset.x + int(self.scroll_offset.x), offset.y + int(self.scroll_offset.y)
|
||||
return (min(max(x, 0), self.grid.width - 1), min(max(y, 0), self.grid.height - 1))
|
||||
|
||||
# --- rendering --------------------------------------------------------------
|
||||
def _shape_preview(self):
|
||||
"""Cells the current drag would change: {(x, y): (char, key)}."""
|
||||
if self.anchor is None or self.tool not in DRAG_TOOLS or self.tool in ("select", "field"):
|
||||
return {}
|
||||
g = banner.Grid(self.grid.width, self.grid.height, self.grid.styles)
|
||||
x0, y0 = self.anchor
|
||||
x1, y1 = self.cursor
|
||||
key = self.key_now()
|
||||
if self.tool == "line":
|
||||
g.line(x0, y0, x1, y1, self.char, key)
|
||||
elif self.tool == "box":
|
||||
g.box(x0, y0, x1, y1, self.box_kind, key)
|
||||
else:
|
||||
g.fill(x0, y0, x1, y1, self.char, key)
|
||||
return {(x, y): c for y, row in enumerate(g.cells) for x, c in enumerate(row) if c != [" ", " "]}
|
||||
|
||||
def _field_cells(self):
|
||||
cells = {}
|
||||
for n, f in enumerate(self.fields):
|
||||
label = banner.fit(f.get("text", ""), int(f.get("width", 1)), f.get("align", "left"))
|
||||
for i, ch in enumerate(label):
|
||||
cells[(int(f["x"]) + i, int(f["y"]))] = (ch, f.get("style", " "), n)
|
||||
return cells
|
||||
|
||||
def render_line(self, y):
|
||||
sx, sy = self.scroll_offset
|
||||
row = y + int(sy)
|
||||
width = self.size.width
|
||||
if row > self.grid.height:
|
||||
return Strip.blank(width)
|
||||
if row == self.grid.height:
|
||||
# The bottom edge shows where the canvas ends
|
||||
edge = "▔" * self.grid.width + "◢"
|
||||
return Strip([Segment(edge[int(sx):], Style(color="grey35"))]).crop(0, width)
|
||||
preview = self._shape_preview()
|
||||
fields = self._field_cells()
|
||||
sel = None
|
||||
if self.selection:
|
||||
x0, y0, x1, y1 = self.selection
|
||||
sel = (min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1))
|
||||
if self.tool in ("select", "field") and self.anchor is not None:
|
||||
(ax, ay), (cx, cy) = self.anchor, self.cursor
|
||||
if self.tool == "field":
|
||||
cy = ay
|
||||
sel = (min(ax, cx), min(ay, cy), max(ax, cx), max(ay, cy))
|
||||
segs = []
|
||||
for x in range(int(sx), self.grid.width):
|
||||
ch, key = preview.get((x, row)) or self.grid.get(x, row)
|
||||
style = rich_cell_style(self.grid, key)
|
||||
if (x, row) in fields:
|
||||
ch, fkey, _ = fields[(x, row)]
|
||||
style = rich_cell_style(self.grid, fkey) + Style(underline=True, italic=True)
|
||||
if sel and sel[0] <= x <= sel[2] and sel[1] <= row <= sel[3]:
|
||||
style += Style(reverse=True)
|
||||
if (x, row) == tuple(self.cursor) and self.has_focus:
|
||||
style += Style(bgcolor="grey50" if ch == " " else None, reverse=ch != " ", blink=False)
|
||||
segs.append(Segment(ch, style))
|
||||
segs.append(Segment("▕", Style(color="grey35")))
|
||||
return Strip(segs).crop(0, width)
|
||||
|
||||
def watch_cursor(self, old, new):
|
||||
self.refresh()
|
||||
self.post_message(self.Changed())
|
||||
|
||||
# --- editing helpers ------------------------------------------------------------
|
||||
def _change(self):
|
||||
if self.before:
|
||||
self.before()
|
||||
|
||||
def _done(self):
|
||||
self.refresh()
|
||||
self.post_message(self.Changed())
|
||||
|
||||
def field_at(self, x, y):
|
||||
for n, f in enumerate(self.fields):
|
||||
if int(f["y"]) == y and int(f["x"]) <= x < int(f["x"]) + int(f.get("width", 1)):
|
||||
return n
|
||||
return None
|
||||
|
||||
def press(self, x, y):
|
||||
"""The mouse button or Enter at a cell."""
|
||||
tool = self.tool
|
||||
if tool in ("pen", "eraser"):
|
||||
self._change()
|
||||
ch, key = (self.char, self.key_now()) if tool == "pen" else (" ", " ")
|
||||
self.grid.put(x, y, ch, key)
|
||||
self.last = (x, y)
|
||||
self._done()
|
||||
elif tool == "field" and self.field_at(x, y) is not None:
|
||||
self.post_message(self.Request("field", x, y, field_index=self.field_at(x, y)))
|
||||
elif tool in DRAG_TOOLS:
|
||||
self.anchor = (x, y)
|
||||
if tool == "select":
|
||||
self.selection = None
|
||||
elif tool == "text":
|
||||
self.text_origin = (x, y)
|
||||
elif tool in ("bigtext", "art"):
|
||||
self.post_message(self.Request(tool, x, y))
|
||||
self.cursor = (x, y)
|
||||
|
||||
def drag(self, x, y):
|
||||
if self.tool in ("pen", "eraser") and self.last is not None:
|
||||
ch, key = (self.char, self.key_now()) if self.tool == "pen" else (" ", " ")
|
||||
self.grid.line(self.last[0], self.last[1], x, y, ch, key)
|
||||
self.last = (x, y)
|
||||
self.refresh()
|
||||
self.cursor = (x, y)
|
||||
|
||||
def release(self, x, y):
|
||||
"""Ends a drag: draws the shape."""
|
||||
self.last = None
|
||||
if self.anchor is None or self.tool not in DRAG_TOOLS:
|
||||
if self.tool in ("pen", "eraser"):
|
||||
self.post_message(self.Changed())
|
||||
return
|
||||
(x0, y0), self.anchor = self.anchor, None
|
||||
key = self.key_now()
|
||||
if self.tool == "select":
|
||||
self.selection = (x0, y0, x, y)
|
||||
elif self.tool == "field":
|
||||
x0, x1 = sorted((x0, x))
|
||||
self.post_message(self.Request("field", x0, y0, width=x1 - x0 + 1))
|
||||
else:
|
||||
self._change()
|
||||
if self.tool == "line":
|
||||
self.grid.line(x0, y0, x, y, self.char, key)
|
||||
elif self.tool == "box":
|
||||
self.grid.box(x0, y0, x, y, self.box_kind, key)
|
||||
elif self.tool == "fill":
|
||||
self.grid.fill(x0, y0, x, y, self.char, key)
|
||||
self._done()
|
||||
self.refresh()
|
||||
|
||||
# --- mouse --------------------------------------------------------------------------
|
||||
def on_mouse_down(self, event: events.MouseDown):
|
||||
cell = self._cell_at(event)
|
||||
if cell and event.button == 1:
|
||||
self.focus()
|
||||
self.capture_mouse()
|
||||
self.press(*cell)
|
||||
|
||||
def on_mouse_move(self, event: events.MouseMove):
|
||||
if event.button == 1:
|
||||
cell = self._cell_at(event)
|
||||
if cell:
|
||||
self.drag(*cell)
|
||||
|
||||
def on_mouse_up(self, event: events.MouseUp):
|
||||
self.release_mouse()
|
||||
cell = self._cell_at(event) or self.cursor
|
||||
self.release(*cell)
|
||||
|
||||
# --- keyboard -----------------------------------------------------------------------
|
||||
def on_key(self, event: events.Key):
|
||||
x, y = self.cursor
|
||||
moves = {"left": (-1, 0), "right": (1, 0), "up": (0, -1), "down": (0, 1)}
|
||||
if event.key in moves:
|
||||
dx, dy = moves[event.key]
|
||||
nx = min(max(x + dx, 0), self.grid.width - 1)
|
||||
ny = min(max(y + dy, 0), self.grid.height - 1)
|
||||
self.cursor = (nx, ny)
|
||||
if self.tool in ("pen", "eraser") and self.last is not None:
|
||||
self.drag(nx, ny)
|
||||
self.scroll_to_region_cell(nx, ny)
|
||||
event.stop()
|
||||
elif self.tool == "text" and self.text_origin is not None and event.key not in ("escape", "tab"):
|
||||
self._type(event)
|
||||
elif event.key in ("enter", "space"):
|
||||
self._keyboard_press(x, y)
|
||||
event.stop()
|
||||
elif event.key in ("delete", "backspace") and self.selection:
|
||||
self._change()
|
||||
x0, y0, x1, y1 = self.selection
|
||||
self.grid.fill(x0, y0, x1, y1, " ", " ")
|
||||
self._done()
|
||||
event.stop()
|
||||
elif event.key == "escape":
|
||||
self.anchor = self.last = self.selection = self.text_origin = None
|
||||
self.refresh()
|
||||
|
||||
def _keyboard_press(self, x, y):
|
||||
if self.tool in DRAG_TOOLS and self.anchor is not None:
|
||||
self.release(x, y)
|
||||
elif self.tool in ("pen", "eraser"):
|
||||
if self.last is None:
|
||||
self.press(x, y) # Enter starts drawing as the cursor moves
|
||||
else:
|
||||
self.last = None
|
||||
self.post_message(self.Changed())
|
||||
else:
|
||||
self.press(x, y)
|
||||
|
||||
def _type(self, event):
|
||||
x, y = self.cursor
|
||||
if event.key == "enter":
|
||||
ox, _ = self.text_origin
|
||||
self.cursor = (ox, min(y + 1, self.grid.height - 1))
|
||||
elif event.key == "backspace":
|
||||
if x > 0:
|
||||
self._change()
|
||||
self.grid.put(x - 1, y, " ", " ")
|
||||
self.cursor = (x - 1, y)
|
||||
self._done()
|
||||
elif event.character and event.is_printable:
|
||||
self._change()
|
||||
self.grid.put(x, y, event.character, self.key_now())
|
||||
self.cursor = (min(x + 1, self.grid.width - 1), y)
|
||||
self._done()
|
||||
else:
|
||||
return
|
||||
event.stop()
|
||||
|
||||
def scroll_to_region_cell(self, x, y):
|
||||
from textual.geometry import Region
|
||||
self.scroll_to_region(Region(x, y, 1, 1), animate=False)
|
||||
|
||||
# --- clipboard ------------------------------------------------------------------------
|
||||
def copy_selection(self, cut=False):
|
||||
if not self.selection:
|
||||
return False
|
||||
x0, y0, x1, y1 = self.selection
|
||||
self.clipboard = self.grid.region(x0, y0, x1, y1)
|
||||
if cut:
|
||||
self._change()
|
||||
self.grid.fill(x0, y0, x1, y1, " ", " ")
|
||||
self._done()
|
||||
return True
|
||||
|
||||
def paste(self):
|
||||
if self.clipboard is None:
|
||||
return False
|
||||
self._change()
|
||||
x, y = self.cursor
|
||||
self.grid.stamp(x, y, self.clipboard, transparent=False)
|
||||
self.selection = (x, y, x + self.clipboard.width - 1, y + self.clipboard.height - 1)
|
||||
self._done()
|
||||
return True
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Shared bits of the dprompt and dbanner command lines."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from .i18n import t
|
||||
|
||||
|
||||
def die(prog, uk, en, code=1):
|
||||
print(f"{prog}: {t(uk, en)}", file=sys.stderr)
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def need_root(argv):
|
||||
"""Runs the command again through sudo with the same arguments."""
|
||||
if os.geteuid() == 0:
|
||||
return
|
||||
script = os.path.abspath(sys.argv[0])
|
||||
try:
|
||||
os.execvp("sudo", ["sudo", sys.executable, script] + argv)
|
||||
except OSError as e:
|
||||
print(f"sudo: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def resolve(spec):
|
||||
"""A file argument as an absolute path (sudo starts elsewhere); names stay names."""
|
||||
if os.sep in spec or spec.endswith(".json"):
|
||||
return os.path.abspath(spec)
|
||||
return spec
|
||||
|
||||
|
||||
def run_tui(prog, loader):
|
||||
try:
|
||||
app = loader()
|
||||
except ImportError as e:
|
||||
die(prog, f"немає бібліотеки Textual ({e}); встановіть python3-textual",
|
||||
f"the Textual library is missing ({e}); install python3-textual")
|
||||
app.run()
|
||||
return 0
|
||||
|
||||
|
||||
def write_file(path, text, mode=0o644):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
os.chmod(tmp, mode)
|
||||
os.replace(tmp, path)
|
||||
@@ -0,0 +1,154 @@
|
||||
"""dbanner: the banner shown at SSH login. Without arguments it opens the editor."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from . import banner, widgets
|
||||
from .cli import die, need_root, resolve, run_tui
|
||||
from .i18n import t
|
||||
|
||||
PROG = "dbanner"
|
||||
SERVICE = "/etc/sv/das-banner"
|
||||
SERVICE_LINK = "/var/service/das-banner"
|
||||
|
||||
|
||||
def text(design, use_cache, color):
|
||||
values = widgets.Values(design.get("vars"))
|
||||
blocks = widgets.blocks(design, values, use_cache=use_cache)
|
||||
return banner.render(design, values, blocks, color=color)
|
||||
|
||||
|
||||
def wants_login_banner(design):
|
||||
if os.path.exists(banner.OFF_FILE) or os.path.exists(os.path.expanduser("~/.hushlogin")):
|
||||
return False
|
||||
if not sys.stdout.isatty():
|
||||
return False
|
||||
return bool(os.environ.get("SSH_TTY")) or bool(design.get("local"))
|
||||
|
||||
|
||||
def has_cached(design):
|
||||
return any(w.get("enabled") is not False and widgets.mode_of(w) == "cached"
|
||||
for w in design.get("widgets", []))
|
||||
|
||||
|
||||
def set_service(enabled):
|
||||
"""Enables the das-banner service (runit) that keeps cached widgets fresh."""
|
||||
if not os.path.isdir(os.path.dirname(SERVICE_LINK)) or not os.path.isdir(SERVICE):
|
||||
return False
|
||||
if enabled and not os.path.lexists(SERVICE_LINK):
|
||||
os.symlink(SERVICE, SERVICE_LINK)
|
||||
elif not enabled and os.path.lexists(SERVICE_LINK):
|
||||
os.remove(SERVICE_LINK)
|
||||
return True
|
||||
|
||||
|
||||
def apply(design):
|
||||
design = banner.normalize(design)
|
||||
banner.save(design, banner.SYSTEM_FILE)
|
||||
os.chmod(banner.SYSTEM_FILE, 0o644)
|
||||
cached = has_cached(design)
|
||||
service = set_service(cached)
|
||||
if service and cached:
|
||||
# The service fills the cache in a moment; this makes the first login see it too
|
||||
subprocess.Popen([sys.executable, os.path.abspath(sys.argv[0]), "refresh"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
|
||||
return cached and service
|
||||
|
||||
|
||||
USAGE = t("""Використання: dbanner [команда]
|
||||
(без команди) редактор банера з готовими варіантами
|
||||
list готові банери, ваші чернетки й малюнки
|
||||
show [НАЗВА|ФАЙЛ] показати банер (типово — системний) зі свіжими даними
|
||||
--no-color без кольорів
|
||||
apply НАЗВА|ФАЙЛ зробити банером при вході по SSH (/etc/das/banner.json)
|
||||
off | on вимкнути або ввімкнути банер
|
||||
refresh [--force] оновити віджети з кешем (робить служба das-banner)
|
||||
Банер показується при вході по SSH; "local": true у файлі — і при вході в консоль.
|
||||
Окремий користувач вимикає його файлом ~/.hushlogin.""",
|
||||
"""Usage: dbanner [command]
|
||||
(no command) banner editor with ready-made designs
|
||||
list ready-made banners, your drafts and pictures
|
||||
show [NAME|FILE] show a banner (default: the system one) with fresh data
|
||||
--no-color without colors
|
||||
apply NAME|FILE make it the SSH login banner (/etc/das/banner.json)
|
||||
off | on turn the banner off or on
|
||||
refresh [--force] update the cached widgets (the das-banner service does it)
|
||||
The banner is shown at SSH logins; "local": true in the file shows it on the
|
||||
console too. A user turns it off for themselves with ~/.hushlogin.""")
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
argv = sys.argv[1:] if argv is None else argv
|
||||
if not argv:
|
||||
from . import banner_app
|
||||
return run_tui(PROG, banner_app.BannerApp)
|
||||
cmd, args = argv[0], argv[1:]
|
||||
flags = {a for a in args if a.startswith("--")}
|
||||
args = [a for a in args if not a.startswith("--")]
|
||||
try:
|
||||
if cmd in ("-h", "--help", "help"):
|
||||
print(USAGE)
|
||||
elif cmd == "list":
|
||||
print(t("Готові:", "Ready-made:"))
|
||||
for name, title in banner.list_presets():
|
||||
print(f" {name:<14} {title}")
|
||||
drafts = banner.list_drafts()
|
||||
if drafts:
|
||||
print(t("Чернетки:", "Drafts:"))
|
||||
for name, path in drafts:
|
||||
print(f" {name:<14} {path}")
|
||||
print(t("Малюнки (вставляються в редакторі):", "Pictures (inserted in the editor):"))
|
||||
for name, title, _ in banner.list_art():
|
||||
print(f" {name:<14} {title}")
|
||||
elif cmd == "show":
|
||||
if "--login" in flags:
|
||||
design = banner.system_design()
|
||||
if wants_login_banner(design):
|
||||
sys.stdout.write(text(design, use_cache=True, color=True))
|
||||
sys.stdout.flush()
|
||||
# A hung widget thread must not hold the login
|
||||
os._exit(0)
|
||||
design = banner.load(args[0]) if args else banner.system_design()
|
||||
color = "--no-color" not in flags and sys.stdout.isatty() or "--color" in flags
|
||||
sys.stdout.write(text(design, use_cache=not args, color=color))
|
||||
elif cmd == "apply":
|
||||
if not args:
|
||||
die(PROG, "вкажіть назву або файл", "name a design or a file")
|
||||
spec = resolve(args[0])
|
||||
design = banner.load(spec)
|
||||
need_root(["apply", spec])
|
||||
cached = apply(design)
|
||||
print(t("Банер записано в /etc/das/banner.json, його видно при наступному вході по SSH.",
|
||||
"The banner is written to /etc/das/banner.json; it shows at the next SSH login."))
|
||||
if cached:
|
||||
print(t("Віджети з кешем оновлює служба das-banner (увімкнено).",
|
||||
"Cached widgets are updated by the das-banner service (enabled)."))
|
||||
elif cmd in ("off", "on"):
|
||||
need_root([cmd])
|
||||
if cmd == "off":
|
||||
open(banner.OFF_FILE, "w").close()
|
||||
elif os.path.exists(banner.OFF_FILE):
|
||||
os.remove(banner.OFF_FILE)
|
||||
print(t("Банер вимкнено." if cmd == "off" else "Банер увімкнено.",
|
||||
"The banner is off." if cmd == "off" else "The banner is on."))
|
||||
elif cmd == "refresh":
|
||||
if "--loop" in flags:
|
||||
while True:
|
||||
widgets.refresh(banner.system_design())
|
||||
time.sleep(60)
|
||||
widgets.refresh(banner.system_design(), force="--force" in flags)
|
||||
else:
|
||||
die(PROG, f"невідома команда: {cmd}\n{USAGE}", f"unknown command: {cmd}\n{USAGE}", 2)
|
||||
except FileNotFoundError as e:
|
||||
die(PROG, f"не знайдено: {e.filename or e}", f"not found: {e.filename or e}")
|
||||
except (ValueError, json.JSONDecodeError) as e:
|
||||
die(PROG, f"помилка у файлі: {e}", f"bad file: {e}")
|
||||
except PermissionError as e:
|
||||
die(PROG, f"немає доступу: {e.filename}", f"permission denied: {e.filename}")
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,132 @@
|
||||
"""dprompt: builds the bash prompt. Without arguments it opens the editor."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from . import ETC_DIR, config_dir, prompt
|
||||
from .cli import die, need_root, resolve, run_tui, write_file
|
||||
from .i18n import t
|
||||
|
||||
PROG = "dprompt"
|
||||
|
||||
|
||||
def targets(system):
|
||||
"""(prompt.sh, design json) of the user or of the whole system."""
|
||||
base = ETC_DIR if system else config_dir()
|
||||
return os.path.join(base, "prompt.sh"), os.path.join(base, "prompt.json")
|
||||
|
||||
|
||||
def current():
|
||||
"""The design in effect for this user: their own, the system one or the stock one."""
|
||||
for system in (False, True):
|
||||
path = targets(system)[1]
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
return prompt.load_file(path), path
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return prompt.load_preset("das"), None
|
||||
|
||||
|
||||
def apply(design, system):
|
||||
sh, js = targets(system)
|
||||
design = prompt.normalize(design)
|
||||
write_file(js, prompt.dumps(design))
|
||||
write_file(sh, prompt.script(design))
|
||||
return sh
|
||||
|
||||
|
||||
def reset(system):
|
||||
for path in targets(system):
|
||||
try:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
USAGE = t("""Використання: dprompt [команда]
|
||||
(без команди) редактор запрошення з переглядом
|
||||
list готові запрошення
|
||||
show [НАЗВА|ФАЙЛ] як виглядає запрошення (типово — поточне)
|
||||
--script код для bash замість вигляду
|
||||
apply НАЗВА|ФАЙЛ застосувати для себе (~/.config/das/prompt.sh)
|
||||
--system для всіх користувачів (/etc/das/prompt.sh, через sudo)
|
||||
reset [--system] повернути стандартне запрошення
|
||||
Нове запрошення діє в нових вікнах терміналу або після exec bash.""",
|
||||
"""Usage: dprompt [command]
|
||||
(no command) prompt editor with a live preview
|
||||
list ready-made prompts
|
||||
show [NAME|FILE] what the prompt looks like (default: the current one)
|
||||
--script the bash code instead
|
||||
apply NAME|FILE use it yourself (~/.config/das/prompt.sh)
|
||||
--system for every user (/etc/das/prompt.sh, through sudo)
|
||||
reset [--system] back to the stock prompt
|
||||
A new prompt works in new terminal windows or after exec bash.""")
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
argv = sys.argv[1:] if argv is None else argv
|
||||
if not argv:
|
||||
from . import prompt_app
|
||||
return run_tui(PROG, prompt_app.PromptApp)
|
||||
cmd, args = argv[0], argv[1:]
|
||||
system = "--system" in args
|
||||
args = [a for a in args if a != "--system"]
|
||||
try:
|
||||
if cmd in ("-h", "--help", "help"):
|
||||
print(USAGE)
|
||||
elif cmd == "list":
|
||||
for name, title in prompt.list_presets():
|
||||
print(f"{name:<12} {title}")
|
||||
elif cmd == "show":
|
||||
script = "--script" in args
|
||||
args = [a for a in args if a != "--script"]
|
||||
design = prompt.load(args[0]) if args else current()[0]
|
||||
if script:
|
||||
sys.stdout.write(prompt.script(design))
|
||||
else:
|
||||
for state in (prompt.sample_state(), prompt.sample_state(rc=1, git="main*", venv=".venv"),
|
||||
prompt.sample_state(root=True)):
|
||||
print(_ansi(design, state))
|
||||
print()
|
||||
elif cmd == "apply":
|
||||
if not args:
|
||||
die(PROG, "вкажіть назву або файл", "name a preset or a file")
|
||||
spec = resolve(args[0])
|
||||
if system:
|
||||
need_root(["apply", spec, "--system"])
|
||||
path = apply(prompt.load(spec), system)
|
||||
print(t(f"Запрошення записано в {path}. Відкрийте новий термінал або виконайте: exec bash",
|
||||
f"The prompt is written to {path}. Open a new terminal or run: exec bash"))
|
||||
elif cmd == "reset":
|
||||
if system:
|
||||
need_root(["reset", "--system"])
|
||||
reset(system)
|
||||
print(t("Повернуто стандартне запрошення (діє в новому терміналі).",
|
||||
"The stock prompt is back (in new terminals)."))
|
||||
else:
|
||||
die(PROG, f"невідома команда: {cmd}\n{USAGE}", f"unknown command: {cmd}\n{USAGE}", 2)
|
||||
except FileNotFoundError as e:
|
||||
die(PROG, f"не знайдено: {e.filename or e}", f"not found: {e.filename or e}")
|
||||
except (ValueError, json.JSONDecodeError) as e:
|
||||
die(PROG, f"помилка у файлі: {e}", f"bad file: {e}")
|
||||
except PermissionError as e:
|
||||
die(PROG, f"немає доступу: {e.filename}", f"permission denied: {e.filename}")
|
||||
return 0
|
||||
|
||||
|
||||
def _ansi(design, state):
|
||||
from . import colors
|
||||
out = []
|
||||
for text, style in prompt.preview(design, state):
|
||||
if style:
|
||||
words = style.split()
|
||||
out.append(colors.sgr(words[-1] if words[-1] != "default" else None, None, "bold" in words))
|
||||
else:
|
||||
out.append(colors.RESET)
|
||||
out.append(text)
|
||||
return "".join(out) + colors.RESET
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Terminal colors shared by the prompt and the banner: 16 ANSI names or #rrggbb."""
|
||||
|
||||
import re
|
||||
|
||||
BASIC = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"]
|
||||
NAMES = BASIC + ["bright_" + c for c in BASIC]
|
||||
|
||||
_HEX = re.compile(r"^#[0-9a-fA-F]{6}$")
|
||||
|
||||
|
||||
def valid(color):
|
||||
return color is None or color in NAMES or bool(_HEX.match(str(color)))
|
||||
|
||||
|
||||
def _code(color, background):
|
||||
if color in BASIC:
|
||||
return str((40 if background else 30) + BASIC.index(color))
|
||||
if color in NAMES:
|
||||
return str((100 if background else 90) + BASIC.index(color[7:]))
|
||||
if color and _HEX.match(color):
|
||||
r, g, b = (int(color[i:i + 2], 16) for i in (1, 3, 5))
|
||||
return f"{48 if background else 38};2;{r};{g};{b}"
|
||||
return None
|
||||
|
||||
|
||||
def sgr_params(fg=None, bg=None, bold=False):
|
||||
"""SGR parameters ("0;1;36") that reset the style first, then set it."""
|
||||
params = ["0"]
|
||||
if bold:
|
||||
params.append("1")
|
||||
for color, background in ((fg, False), (bg, True)):
|
||||
code = _code(color, background)
|
||||
if code:
|
||||
params.append(code)
|
||||
return ";".join(params)
|
||||
|
||||
|
||||
def sgr(fg=None, bg=None, bold=False):
|
||||
return f"\033[{sgr_params(fg, bg, bold)}m"
|
||||
|
||||
|
||||
RESET = "\033[0m"
|
||||
|
||||
|
||||
def rich_style(fg=None, bg=None, bold=False):
|
||||
"""The same style as a Rich style string ("bold bright_cyan on blue")."""
|
||||
parts = []
|
||||
if bold:
|
||||
parts.append("bold")
|
||||
parts.append(fg or "default")
|
||||
if bg:
|
||||
parts += ["on", bg]
|
||||
return " ".join(parts)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Ukrainian or English messages, chosen like the shell helpers do (LC_ALL, LC_MESSAGES, LANG)."""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def _ukrainian():
|
||||
for var in ("LC_ALL", "LC_MESSAGES", "LANG"):
|
||||
value = os.environ.get(var)
|
||||
if value:
|
||||
return value.startswith("uk")
|
||||
return False
|
||||
|
||||
|
||||
UK = _ukrainian()
|
||||
|
||||
|
||||
def t(uk, en):
|
||||
"""The message in the user's language."""
|
||||
return uk if UK else en
|
||||
|
||||
|
||||
def title(obj, default=""):
|
||||
"""A {"uk": ..., "en": ...} title (or a plain string) in the user's language."""
|
||||
if isinstance(obj, dict):
|
||||
return obj.get("uk" if UK else "en") or obj.get("en") or default
|
||||
return obj or default
|
||||
@@ -0,0 +1,426 @@
|
||||
"""Bash prompt model of dprompt: segments in a frame, turned into PS1 code or a preview.
|
||||
|
||||
A design is JSON:
|
||||
{"name": "das", "frame": "das", "frame_fg": "red", "open": "[", "close": "]",
|
||||
"sep": "─", "segments": [{"type": "status"}, {"type": "userhost"}, ...]}
|
||||
|
||||
Static segments become PS1 escapes (\\u, \\w, \\D{...}). Conditional ones (exit
|
||||
status, git branch, venv, jobs, ssh, command output) are computed by a function
|
||||
that the PROMPT_COMMAND hook of /usr/lib/das/prompt-lib.sh calls before each
|
||||
prompt; PS1 only reads their variables, so branch names are never evaluated as code.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
|
||||
from . import DATA_DIR, colors
|
||||
from .i18n import t, title
|
||||
|
||||
FRAMES = ("das", "plain")
|
||||
|
||||
# type: (Ukrainian name, English name, default options)
|
||||
SEGMENTS = {
|
||||
"status": ("Код помилки", "Exit status", {"fg": "white", "mode": "mark", "mark": "✗"}),
|
||||
"userhost": ("Користувач@хост", "User@host",
|
||||
{"fg": "bright_white", "at_fg": "yellow", "at_bold": True,
|
||||
"host_fg": "cyan", "host_bold": True, "root_fg": "red", "root_bold": True}),
|
||||
"user": ("Користувач", "User", {"fg": "bright_white", "root_fg": "red", "root_bold": True}),
|
||||
"host": ("Хост", "Host", {"fg": "cyan", "bold": True, "full": False}),
|
||||
"cwd": ("Каталог", "Directory", {"fg": "bright_green", "mode": "full", "depth": 3}),
|
||||
"date": ("Дата", "Date", {"fg": "white", "format": "%Y-%m-%d"}),
|
||||
"time": ("Час", "Time", {"fg": "white", "seconds": False}),
|
||||
"git": ("Гілка git", "Git branch", {"fg": "bright_magenta"}),
|
||||
"venv": ("Python venv", "Python venv", {"fg": "bright_blue"}),
|
||||
"jobs": ("Фонові задачі", "Background jobs", {"fg": "yellow", "prefix": "jobs:"}),
|
||||
"ssh": ("Ознака SSH", "SSH mark", {"fg": "bright_red", "bold": True, "text": "ssh"}),
|
||||
"text": ("Текст", "Text", {"fg": None, "text": ""}),
|
||||
"cmd": ("Вивід команди", "Command output", {"fg": "white", "cmd": ""}),
|
||||
"newline": ("Новий рядок", "New line", {}),
|
||||
"symbol": ("Символ $/#", "Symbol $/#", {"fg": "yellow", "char": ""}),
|
||||
}
|
||||
|
||||
# Glyphs missing from the Linux console font
|
||||
CONSOLE_GLYPHS = {"╼": ">", "✗": "x", "❯": ">", "➜": ">", "λ": ">", "⚙": "*", "●": "*"}
|
||||
|
||||
VOID_PS1 = "[\\u@\\h \\W]\\$ "
|
||||
|
||||
|
||||
def segment_name(seg_type):
|
||||
uk, en, _ = SEGMENTS[seg_type]
|
||||
return t(uk, en)
|
||||
|
||||
|
||||
def new_segment(seg_type):
|
||||
seg = {"type": seg_type}
|
||||
seg.update(copy.deepcopy(SEGMENTS[seg_type][2]))
|
||||
return seg
|
||||
|
||||
|
||||
def normalize(design):
|
||||
"""Fills in the defaults, so older or hand-written files keep working."""
|
||||
d = copy.deepcopy(design)
|
||||
d.setdefault("name", "custom")
|
||||
d.setdefault("frame", "das")
|
||||
if d["frame"] not in FRAMES:
|
||||
d["frame"] = "das"
|
||||
d.setdefault("frame_fg", "red")
|
||||
d.setdefault("open", "[" if d["frame"] == "das" else "")
|
||||
d.setdefault("close", "]" if d["frame"] == "das" else "")
|
||||
d.setdefault("sep", "─" if d["frame"] == "das" else " ")
|
||||
segs = []
|
||||
for seg in d.get("segments", []):
|
||||
if seg.get("type") not in SEGMENTS:
|
||||
continue
|
||||
full = new_segment(seg["type"])
|
||||
full.update(seg)
|
||||
segs.append(full)
|
||||
d["segments"] = segs
|
||||
return d
|
||||
|
||||
|
||||
# --- presets and files ------------------------------------------------------
|
||||
|
||||
def preset_dir():
|
||||
return os.path.join(DATA_DIR, "prompt", "presets")
|
||||
|
||||
|
||||
def list_presets():
|
||||
"""(name, title) of the shipped presets, "das" first."""
|
||||
out = []
|
||||
try:
|
||||
names = sorted(f[:-5] for f in os.listdir(preset_dir()) if f.endswith(".json"))
|
||||
except OSError:
|
||||
names = []
|
||||
for name in sorted(names, key=lambda n: (n != "das", n)):
|
||||
try:
|
||||
out.append((name, title(load_preset(name).get("title"), name)))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def load_preset(name):
|
||||
return load_file(os.path.join(preset_dir(), name + ".json"))
|
||||
|
||||
|
||||
def load_file(path):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return normalize(json.load(f))
|
||||
|
||||
|
||||
def load(spec):
|
||||
"""A preset name or a path to a design file."""
|
||||
if os.sep in spec or spec.endswith(".json"):
|
||||
return load_file(spec)
|
||||
return load_preset(spec)
|
||||
|
||||
|
||||
def dumps(design):
|
||||
return json.dumps(design, ensure_ascii=False, indent=2) + "\n"
|
||||
|
||||
|
||||
# --- pieces -------------------------------------------------------------------
|
||||
# Lit: literal text; Esc: a PS1 escape such as \u; Var: a variable the hook sets;
|
||||
# Dyn: pieces shown only when a condition holds. Style is (fg, bold) or None.
|
||||
|
||||
class Lit:
|
||||
def __init__(self, text, style=None):
|
||||
self.text, self.style = text, style
|
||||
|
||||
|
||||
class Esc:
|
||||
def __init__(self, code, style=None):
|
||||
self.code, self.style = code, style
|
||||
|
||||
|
||||
class Var:
|
||||
def __init__(self, name, style=None):
|
||||
self.name, self.style = name, style
|
||||
|
||||
|
||||
class Dyn:
|
||||
def __init__(self, cond, pieces, extra=None):
|
||||
self.cond, self.pieces, self.extra = cond, pieces, extra or {}
|
||||
|
||||
|
||||
def _st(seg, prefix=""):
|
||||
return (seg.get(prefix + "fg"), bool(seg.get(prefix + "bold")))
|
||||
|
||||
|
||||
def _content(seg, index, root):
|
||||
"""(pieces, condition or None, bracketed?) for one segment."""
|
||||
kind = seg["type"]
|
||||
style = _st(seg)
|
||||
if kind == "status":
|
||||
inner = [Var("rc", style)] if seg.get("mode") == "code" else [Lit(seg.get("mark") or "✗", style)]
|
||||
return inner, "error", True
|
||||
if kind == "userhost":
|
||||
user = _st(seg, "root_") if root else style
|
||||
return [Esc("\\u", user), Lit("@", _st(seg, "at_")), Esc("\\h", _st(seg, "host_"))], None, True
|
||||
if kind == "user":
|
||||
return [Esc("\\u", _st(seg, "root_") if root else style)], None, True
|
||||
if kind == "host":
|
||||
return [Esc("\\H" if seg.get("full") else "\\h", style)], None, True
|
||||
if kind == "cwd":
|
||||
return [Esc("\\W" if seg.get("mode") == "base" else "\\w", style)], None, True
|
||||
if kind == "date":
|
||||
return [Esc("\\D{%s}" % (seg.get("format") or "%Y-%m-%d"), style)], None, True
|
||||
if kind == "time":
|
||||
return [Esc("\\t" if seg.get("seconds") else "\\A", style)], None, True
|
||||
if kind == "git":
|
||||
return [Var("git", style)], "git", True
|
||||
if kind == "venv":
|
||||
return [Var("venv", style)], "venv", True
|
||||
if kind == "jobs":
|
||||
return [Lit(seg.get("prefix") or "", style), Var("jobs", style)], "jobs", True
|
||||
if kind == "ssh":
|
||||
return [Lit(seg.get("text") or "ssh", style)], "ssh", True
|
||||
if kind == "text":
|
||||
return [Lit(seg.get("text") or "", style)], None, bool(seg.get("bracket", False))
|
||||
if kind == "cmd":
|
||||
return [Var(f"cmd{index}", style)], f"cmd{index}", True
|
||||
if kind == "symbol":
|
||||
char = seg.get("char") or ""
|
||||
return [Lit(char, style) if char else Esc("\\$", style)], None, False
|
||||
raise ValueError(kind)
|
||||
|
||||
|
||||
def pieces(design, root=False):
|
||||
"""The prompt as a flat list of pieces."""
|
||||
d = normalize(design)
|
||||
frame = (d["frame_fg"], False)
|
||||
lines = [[]]
|
||||
for i, seg in enumerate(d["segments"]):
|
||||
if seg["type"] == "newline":
|
||||
lines.append([])
|
||||
else:
|
||||
lines[-1].append((i, seg))
|
||||
out = []
|
||||
for n, items in enumerate(lines):
|
||||
if n:
|
||||
out.append(Lit("\n"))
|
||||
if d["frame"] == "das" and len(lines) > 1:
|
||||
prefix = "┌─" if n == 0 else ("└─╼ " if n == len(lines) - 1 else "├─")
|
||||
out.append(Lit(prefix, frame))
|
||||
first_static = next((k for k, (i, s) in enumerate(items)
|
||||
if _content(s, i, root)[1] is None), len(items))
|
||||
for k, (i, seg) in enumerate(items):
|
||||
content, cond, bracket = _content(seg, i, root)
|
||||
is_symbol = seg["type"] == "symbol"
|
||||
group = []
|
||||
if bracket and d["open"]:
|
||||
group.append(Lit(d["open"], frame))
|
||||
group += content
|
||||
if bracket and d["close"]:
|
||||
group.append(Lit(d["close"], frame))
|
||||
sep = Lit(" " if is_symbol else d["sep"], frame) if d["sep"] else None
|
||||
if cond is not None:
|
||||
# A hidden segment must take its separator with it
|
||||
if k < first_static:
|
||||
group = group + ([sep] if sep and k + 1 < len(items) else [])
|
||||
elif sep:
|
||||
group = [sep] + group
|
||||
out.append(Dyn(cond, group, {"cmd": seg.get("cmd", "")}))
|
||||
else:
|
||||
if k > first_static and sep:
|
||||
out.append(sep)
|
||||
out += group
|
||||
out.append(Lit(" "))
|
||||
return _merge(out)
|
||||
|
||||
|
||||
def _merge(items):
|
||||
"""Joins neighbouring literals of the same style."""
|
||||
out = []
|
||||
for p in items:
|
||||
if isinstance(p, Lit) and out and isinstance(out[-1], Lit) and out[-1].style == p.style:
|
||||
out[-1] = Lit(out[-1].text + p.text, p.style)
|
||||
else:
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
# --- bash code ----------------------------------------------------------------
|
||||
|
||||
def _ps1_text(text):
|
||||
return text.replace("\\", "\\\\").replace("$", "\\044").replace("`", "\\140")
|
||||
|
||||
|
||||
def _sq(text):
|
||||
return "'" + text.replace("'", "'\\''") + "'"
|
||||
|
||||
|
||||
def _ansi_q(text):
|
||||
return text.replace("\\", "\\\\").replace("'", "\\'")
|
||||
|
||||
|
||||
class _Bash:
|
||||
def __init__(self, color, console):
|
||||
self.color, self.console = color, console
|
||||
|
||||
def glyphs(self, text):
|
||||
if self.console:
|
||||
for a, b in CONSOLE_GLYPHS.items():
|
||||
text = text.replace(a, b)
|
||||
return text
|
||||
|
||||
def ps1(self, items):
|
||||
out, current = [], None
|
||||
for p in items:
|
||||
style = getattr(p, "style", None)
|
||||
if self.color and not isinstance(p, Dyn) and style is not None and style != current:
|
||||
out.append("\\[\\e[%sm\\]" % colors.sgr_params(style[0], None, style[1]))
|
||||
current = style
|
||||
if isinstance(p, Lit):
|
||||
out.append(_ps1_text(self.glyphs(p.text)))
|
||||
elif isinstance(p, Esc):
|
||||
out.append(p.code)
|
||||
elif isinstance(p, Dyn):
|
||||
out.append("${__das_p_%s}" % p.cond)
|
||||
current = None # the dynamic part leaves its own color behind
|
||||
if self.color:
|
||||
out.append("\\[\\e[0m\\]")
|
||||
return "".join(out)
|
||||
|
||||
def value(self, items):
|
||||
"""A bash word with the dynamic part: $'...' literals and "$var"s."""
|
||||
out, current = [], None
|
||||
for p in items:
|
||||
style = getattr(p, "style", None)
|
||||
if self.color and style is not None and style != current:
|
||||
out.append("$'\\001\\e[%sm\\002'" % colors.sgr_params(style[0], None, style[1]))
|
||||
current = style
|
||||
if isinstance(p, Lit):
|
||||
out.append("$'%s'" % _ansi_q(self.glyphs(p.text)))
|
||||
elif isinstance(p, Var):
|
||||
out.append('"$__das_%s"' % p.name)
|
||||
return "".join(out) or "''"
|
||||
|
||||
def dynamic(self, items):
|
||||
lines = []
|
||||
for p in items:
|
||||
if not isinstance(p, Dyn):
|
||||
continue
|
||||
var, val = "__das_p_" + p.cond, self.value(p.pieces)
|
||||
test = {
|
||||
"error": "[[ $__das_rc != 0 ]]",
|
||||
"git": "__das_git_info",
|
||||
"venv": "[[ -n ${VIRTUAL_ENV:-} ]] && __das_venv=${VIRTUAL_ENV##*/}",
|
||||
"jobs": "__das_jobs_count",
|
||||
"ssh": "[[ -n ${SSH_CONNECTION:-} ]]",
|
||||
}.get(p.cond)
|
||||
if test is None: # command output
|
||||
lines.append(f" __das_{p.cond}=$({{ {p.extra['cmd']}; }} 2>/dev/null)")
|
||||
test = f"[[ -n $__das_{p.cond} ]]"
|
||||
lines.append(f" {var}=")
|
||||
lines.append(f" {test} && {var}={val}")
|
||||
return lines
|
||||
|
||||
|
||||
def script(design):
|
||||
"""The prompt.sh that /etc/bash/bashrc.d/das-prompt.sh sources."""
|
||||
d = normalize(design)
|
||||
out = [
|
||||
f"# Das OS bash prompt \"{d['name']}\", made by dprompt: run dprompt to change it.",
|
||||
"# shellcheck shell=bash disable=SC2016",
|
||||
]
|
||||
cwd = next((s for s in d["segments"] if s["type"] == "cwd"), None)
|
||||
if cwd and cwd.get("mode") == "short":
|
||||
out.append(f"PROMPT_DIRTRIM={int(cwd.get('depth') or 3)}")
|
||||
else:
|
||||
out.append("unset PROMPT_DIRTRIM")
|
||||
if any(s["type"] == "venv" for s in d["segments"]):
|
||||
out.append("VIRTUAL_ENV_DISABLE_PROMPT=1")
|
||||
variants = [("[[ $TERM == linux ]]", True, True),
|
||||
("__das_has_color", True, False),
|
||||
(None, False, False)]
|
||||
for n, (test, color, console) in enumerate(variants):
|
||||
b = _Bash(color, console)
|
||||
user, root = pieces(d, False), pieces(d, True)
|
||||
head = "if" if n == 0 else ("elif" if test else "else")
|
||||
out.append(f"{head} {test}; then" if test else "else")
|
||||
body = b.dynamic(user)
|
||||
out.append(" __das_prompt_dynamic() {")
|
||||
out += [" " + line for line in body] or [" :"]
|
||||
out.append(" }")
|
||||
out.append(" if ((EUID == 0)); then")
|
||||
out.append(" PS1=" + _sq(b.ps1(root)))
|
||||
out.append(" else")
|
||||
out.append(" PS1=" + _sq(b.ps1(user)))
|
||||
out.append(" fi")
|
||||
out.append("fi")
|
||||
out.append("case $TERM in xterm* | rxvt* | alacritty | foot*) PS1='\\[\\e]0;\\u@\\h: \\w\\a\\]'$PS1 ;; esac")
|
||||
out.append("__das_prompt_install")
|
||||
return "\n".join(out) + "\n"
|
||||
|
||||
|
||||
# --- preview ----------------------------------------------------------------------
|
||||
|
||||
def sample_state(**over):
|
||||
now = datetime.datetime.now()
|
||||
state = {"user": "das", "host": "dasos", "fqdn": "dasos.lan", "home": "/home/das",
|
||||
"cwd": "/home/das/Projects/web", "rc": 0, "git": "", "venv": "", "jobs": 0,
|
||||
"ssh": False, "root": False, "cmd": "…", "now": now}
|
||||
state.update(over)
|
||||
if state["root"]:
|
||||
state["user"] = "root"
|
||||
return state
|
||||
|
||||
|
||||
def _esc_value(code, s):
|
||||
if code == "\\u":
|
||||
return s["user"]
|
||||
if code == "\\h":
|
||||
return s["host"]
|
||||
if code == "\\H":
|
||||
return s["fqdn"]
|
||||
if code in ("\\w", "\\W"):
|
||||
home = "/root" if s["root"] else s["home"]
|
||||
cwd = s["cwd"] if not s["root"] else "/etc/sv"
|
||||
short = "~" + cwd[len(home):] if cwd.startswith(home) else cwd
|
||||
return short if code == "\\w" else (os.path.basename(cwd) or "/")
|
||||
if code.startswith("\\D{"):
|
||||
return s["now"].strftime(code[3:-1])
|
||||
if code == "\\A":
|
||||
return s["now"].strftime("%H:%M")
|
||||
if code == "\\t":
|
||||
return s["now"].strftime("%H:%M:%S")
|
||||
if code == "\\$":
|
||||
return "#" if s["root"] else "$"
|
||||
return code
|
||||
|
||||
|
||||
def _cond_holds(cond, s):
|
||||
return {"error": s["rc"] != 0, "git": bool(s["git"]), "venv": bool(s["venv"]),
|
||||
"jobs": s["jobs"] > 0, "ssh": s["ssh"]}.get(cond, bool(s["cmd"]))
|
||||
|
||||
|
||||
def _var_value(name, s):
|
||||
return {"rc": str(s["rc"]), "git": s["git"], "venv": s["venv"],
|
||||
"jobs": str(s["jobs"])}.get(name, s["cmd"])
|
||||
|
||||
|
||||
def preview(design, state):
|
||||
"""[(text, rich style or "")] of the prompt as the user would see it."""
|
||||
out = []
|
||||
|
||||
def walk(items):
|
||||
for p in items:
|
||||
style = colors.rich_style(p.style[0], None, p.style[1]) if getattr(p, "style", None) else ""
|
||||
if isinstance(p, Lit):
|
||||
out.append((p.text, style))
|
||||
elif isinstance(p, Esc):
|
||||
out.append((_esc_value(p.code, state), style))
|
||||
elif isinstance(p, Var):
|
||||
out.append((_var_value(p.name, state), style))
|
||||
elif isinstance(p, Dyn) and _cond_holds(p.cond, state):
|
||||
walk(p.pieces)
|
||||
|
||||
walk(pieces(design, state["root"]))
|
||||
return out
|
||||
|
||||
|
||||
def preview_text(design, state):
|
||||
return "".join(text for text, _ in preview(design, state))
|
||||
@@ -0,0 +1,293 @@
|
||||
"""dprompt editor: segments on the left, their settings on the right, a live preview below."""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from rich.text import Text
|
||||
from textual.app import App
|
||||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||
from textual.widgets import Button, Checkbox, Footer, Header, Input, Label, ListItem, ListView, Select, Static
|
||||
|
||||
from . import prompt
|
||||
from .cli_prompt import apply as apply_design, current
|
||||
from .i18n import t
|
||||
from .tui_common import NONE, AskDialog, color_options, color_value, run_as_root, to_select
|
||||
|
||||
# Settings of each segment type: (key, kind, Ukrainian label, English label)
|
||||
COLOR_KEYS = [("fg", "color", "Колір", "Color"), ("bold", "bool", "Жирний", "Bold")]
|
||||
OPTIONS = {
|
||||
"status": COLOR_KEYS + [("mode", "choice:mark=знак ✗|mark=mark ✗;code=код виходу|code=exit code", "Показ", "Shows"),
|
||||
("mark", "text", "Знак", "Mark")],
|
||||
"userhost": [("fg", "color", "Колір користувача", "User color"), ("bold", "bool", "Жирний", "Bold"),
|
||||
("at_fg", "color", "Колір @", "@ color"), ("at_bold", "bool", "@ жирне", "Bold @"),
|
||||
("host_fg", "color", "Колір хоста", "Host color"), ("host_bold", "bool", "Хост жирний", "Bold host"),
|
||||
("root_fg", "color", "Колір для root", "Color for root"), ("root_bold", "bool", "root жирний", "Bold root")],
|
||||
"user": COLOR_KEYS + [("root_fg", "color", "Колір для root", "Color for root"),
|
||||
("root_bold", "bool", "root жирний", "Bold root")],
|
||||
"host": COLOR_KEYS + [("full", "bool", "Повне ім'я (\\H)", "Full name (\\H)")],
|
||||
"cwd": COLOR_KEYS + [("mode", "choice:full=повний шлях|full=full path;base=лише остання тека|base=last folder only;"
|
||||
"short=останні N тек|short=last N folders", "Показ", "Shows"),
|
||||
("depth", "text", "N для «останні N тек»", "N for \"last N folders\"")],
|
||||
"date": COLOR_KEYS + [("format", "text", "Формат (strftime)", "Format (strftime)")],
|
||||
"time": COLOR_KEYS + [("seconds", "bool", "Із секундами", "With seconds")],
|
||||
"git": COLOR_KEYS,
|
||||
"venv": COLOR_KEYS,
|
||||
"jobs": COLOR_KEYS + [("prefix", "text", "Підпис перед числом", "Label before the number")],
|
||||
"ssh": COLOR_KEYS + [("text", "text", "Текст", "Text")],
|
||||
"text": COLOR_KEYS + [("text", "text", "Текст", "Text"), ("bracket", "bool", "У дужках", "In brackets")],
|
||||
"cmd": COLOR_KEYS + [("cmd", "text", "Команда (виконується перед кожним запрошенням)",
|
||||
"Command (runs before every prompt)")],
|
||||
"newline": [],
|
||||
"symbol": COLOR_KEYS + [("char", "text", "Символ (порожньо — $ або # для root)", "Symbol (empty: $, or # for root)")],
|
||||
}
|
||||
|
||||
STATES = [
|
||||
(("Звичайне", "Normal"), {}),
|
||||
(("Після помилки, git, venv, задачі, SSH", "After an error, git, venv, jobs, SSH"),
|
||||
{"rc": 1, "git": "main*", "venv": ".venv", "jobs": 1, "ssh": True, "cmd": "42"}),
|
||||
(("root", "root"), {"root": True}),
|
||||
]
|
||||
|
||||
|
||||
def _choices(kind):
|
||||
out = []
|
||||
for item in kind[7:].split(";"):
|
||||
uk, en = item.split("|")
|
||||
value, uk_label = uk.split("=")
|
||||
out.append((t(uk_label, en.split("=")[1]), value))
|
||||
return out
|
||||
|
||||
|
||||
class PromptApp(App):
|
||||
TITLE = "dprompt"
|
||||
SUB_TITLE = t("конструктор запрошення bash", "bash prompt builder")
|
||||
CSS = """
|
||||
#main { height: 1fr; }
|
||||
#left { width: 40; border-right: solid $primary; padding: 0 1; }
|
||||
#segments { height: 1fr; }
|
||||
#props { padding: 0 2; }
|
||||
#props Label { margin-top: 1; color: $text-muted; }
|
||||
#preview { height: auto; max-height: 14; border-top: solid $primary; padding: 0 1; }
|
||||
.buttons { height: auto; }
|
||||
.buttons Button { min-width: 5; margin-right: 1; }
|
||||
.h { text-style: bold; color: $accent; margin-top: 1; }
|
||||
"""
|
||||
BINDINGS = [
|
||||
("ctrl+s", "apply_me", t("Застосувати для себе", "Apply for me")),
|
||||
("ctrl+g", "apply_all", t("Для всіх (sudo)", "For everyone (sudo)")),
|
||||
("ctrl+e", "export", t("Зберегти файл", "Save file")),
|
||||
("ctrl+q", "quit", t("Вийти", "Quit")),
|
||||
]
|
||||
|
||||
def __init__(self, design=None):
|
||||
# The terminal's own 16 colors, as the prompt will use them
|
||||
super().__init__(ansi_color=True)
|
||||
self.design, self.source = (design, None) if design else current()
|
||||
self.design = prompt.normalize(self.design)
|
||||
self.props_lock = asyncio.Lock()
|
||||
|
||||
# --- layout ------------------------------------------------------------
|
||||
def compose(self):
|
||||
yield Header()
|
||||
with Horizontal(id="main"):
|
||||
with Vertical(id="left"):
|
||||
yield Label(Text(t("Готові запрошення", "Ready-made prompts")), classes="h")
|
||||
yield Select([(title, name) for name, title in prompt.list_presets()],
|
||||
prompt=t("вибрати…", "choose…"), id="preset")
|
||||
yield Label(Text(t("Сегменти (по порядку)", "Segments (in order)")), classes="h")
|
||||
yield ListView(id="segments")
|
||||
yield Select([(prompt.segment_name(k), k) for k in prompt.SEGMENTS],
|
||||
prompt=t("+ додати сегмент", "+ add a segment"), id="add")
|
||||
with Horizontal(classes="buttons"):
|
||||
yield Button("↑", id="up")
|
||||
yield Button("↓", id="down")
|
||||
yield Button(t("Видалити", "Remove"), id="remove", variant="error")
|
||||
yield VerticalScroll(id="props")
|
||||
yield Static(id="preview")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self):
|
||||
self.refresh_segments(0)
|
||||
|
||||
# --- segments list -------------------------------------------------------
|
||||
def refresh_segments(self, index=None):
|
||||
lv = self.query_one("#segments", ListView)
|
||||
lv.clear()
|
||||
lv.append(ListItem(Label(Text("⚙ " + t("Рамка й кольори", "Frame and colors"), style="bold"))))
|
||||
for seg in self.design["segments"]:
|
||||
extra = seg.get("text") or seg.get("cmd") or seg.get("char") or ""
|
||||
lv.append(ListItem(Label(Text(prompt.segment_name(seg["type"]) + (f" «{extra}»" if extra else "")))))
|
||||
if index is not None:
|
||||
lv.index = index
|
||||
self.call_after_refresh(self.show_props)
|
||||
self.update_preview()
|
||||
|
||||
def selected(self):
|
||||
"""Index of the chosen segment, or None for the frame row."""
|
||||
i = self.query_one("#segments", ListView).index
|
||||
return None if not i else i - 1
|
||||
|
||||
async def on_list_view_highlighted(self, event):
|
||||
await self.show_props()
|
||||
|
||||
# --- properties ----------------------------------------------------------
|
||||
async def show_props(self):
|
||||
async with self.props_lock:
|
||||
await self._show_props()
|
||||
|
||||
async def _show_props(self):
|
||||
box = self.query_one("#props", VerticalScroll)
|
||||
await box.remove_children()
|
||||
i = self.selected()
|
||||
widgets = []
|
||||
if i is None:
|
||||
d = self.design
|
||||
widgets += [Label(Text(t("Вигляд рамки", "Frame"))),
|
||||
Select([(t("Das: ┌─[…]─[…] / └─╼", "Das: ┌─[…]─[…] / └─╼"), "das"),
|
||||
(t("Простий рядок", "Plain line"), "plain")],
|
||||
value=d["frame"], allow_blank=False, id="p_frame"),
|
||||
Label(Text(t("Колір рамки й дужок", "Frame and bracket color"))),
|
||||
Select(color_options(), value=to_select(d["frame_fg"]), allow_blank=False, id="p_frame_fg"),
|
||||
Label(Text(t("Ліва дужка", "Left bracket"))), Input(d["open"], id="p_open"),
|
||||
Label(Text(t("Права дужка", "Right bracket"))), Input(d["close"], id="p_close"),
|
||||
Label(Text(t("Між сегментами", "Between segments"))), Input(d["sep"], id="p_sep")]
|
||||
else:
|
||||
seg = self.design["segments"][i]
|
||||
widgets.append(Label(Text(prompt.segment_name(seg["type"]), style="bold")))
|
||||
if not OPTIONS[seg["type"]]:
|
||||
widgets.append(Label(Text(t("Без налаштувань.", "Nothing to set."))))
|
||||
for key, kind, uk, en in OPTIONS[seg["type"]]:
|
||||
wid = "s_" + key
|
||||
if kind == "bool":
|
||||
widgets.append(Checkbox(t(uk, en), bool(seg.get(key)), id=wid))
|
||||
continue
|
||||
widgets.append(Label(Text(t(uk, en))))
|
||||
if kind == "color":
|
||||
widgets.append(Select(color_options(), value=to_select(seg.get(key)), allow_blank=False, id=wid))
|
||||
elif kind.startswith("choice:"):
|
||||
widgets.append(Select(_choices(kind), value=seg.get(key), allow_blank=False, id=wid))
|
||||
else:
|
||||
widgets.append(Input(str(seg.get(key) if seg.get(key) is not None else ""), id=wid))
|
||||
for w in widgets:
|
||||
# Events of these widgets belong to this segment, whatever is selected later
|
||||
w.das_target = i
|
||||
await box.mount(*widgets)
|
||||
|
||||
def _set(self, control, value):
|
||||
if not hasattr(control, "das_target") or not control.is_attached:
|
||||
return
|
||||
i, wid = control.das_target, control.id
|
||||
key = wid[2:]
|
||||
if wid.startswith("p_"):
|
||||
if key == "frame" and value != self.design["frame"]:
|
||||
# Sensible brackets for the new frame
|
||||
self.design.update({"das": {"open": "[", "close": "]", "sep": "─"},
|
||||
"plain": {"open": "", "close": "", "sep": " "}}[value])
|
||||
self.design["frame"] = value
|
||||
self.call_later(self.show_props)
|
||||
else:
|
||||
self.design[key] = value
|
||||
elif i is not None and i < len(self.design["segments"]):
|
||||
seg = self.design["segments"][i]
|
||||
if key == "depth":
|
||||
value = int(value) if str(value).isdigit() else 3
|
||||
seg[key] = value
|
||||
if key in ("text", "cmd", "char"):
|
||||
self.refresh_label(i)
|
||||
self.update_preview()
|
||||
|
||||
def refresh_label(self, i):
|
||||
seg = self.design["segments"][i]
|
||||
item = self.query_one("#segments", ListView).children[i + 1]
|
||||
extra = seg.get("text") or seg.get("cmd") or seg.get("char") or ""
|
||||
item.query_one(Label).update(Text(prompt.segment_name(seg["type"]) + (f" «{extra}»" if extra else "")))
|
||||
|
||||
def on_select_changed(self, event: Select.Changed):
|
||||
wid = event.select.id or ""
|
||||
if wid == "preset":
|
||||
if event.value is not Select.NULL:
|
||||
self.design = prompt.load_preset(event.value)
|
||||
self.refresh_segments(0)
|
||||
elif wid == "add":
|
||||
if event.value is not Select.NULL:
|
||||
i = self.selected()
|
||||
pos = len(self.design["segments"]) if i is None else i + 1
|
||||
self.design["segments"].insert(pos, prompt.new_segment(event.value))
|
||||
event.select.clear()
|
||||
self.refresh_segments(pos + 1)
|
||||
elif wid.startswith(("p_", "s_")):
|
||||
value = event.value
|
||||
if wid.endswith("fg"):
|
||||
value = color_value(value)
|
||||
self._set(event.select, value)
|
||||
|
||||
def on_checkbox_changed(self, event: Checkbox.Changed):
|
||||
self._set(event.checkbox, event.value)
|
||||
|
||||
def on_input_changed(self, event: Input.Changed):
|
||||
if (event.input.id or "").startswith(("p_", "s_")):
|
||||
self._set(event.input, event.value)
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed):
|
||||
i = self.selected()
|
||||
segs = self.design["segments"]
|
||||
if i is None:
|
||||
return
|
||||
if event.button.id == "remove":
|
||||
del segs[i]
|
||||
self.refresh_segments(min(i + 1, len(segs)))
|
||||
elif event.button.id == "up" and i > 0:
|
||||
segs[i - 1], segs[i] = segs[i], segs[i - 1]
|
||||
self.refresh_segments(i)
|
||||
elif event.button.id == "down" and i + 1 < len(segs):
|
||||
segs[i + 1], segs[i] = segs[i], segs[i + 1]
|
||||
self.refresh_segments(i + 2)
|
||||
|
||||
# --- preview ---------------------------------------------------------------
|
||||
def update_preview(self):
|
||||
out = Text()
|
||||
for n, ((uk, en), over) in enumerate(STATES):
|
||||
if n:
|
||||
out.append("\n")
|
||||
out.append(t(uk, en) + ":\n", style="dim italic")
|
||||
for text, style in prompt.preview(self.design, prompt.sample_state(**over)):
|
||||
out.append(text, style=style or None)
|
||||
out.append("ls", style="default")
|
||||
self.query_one("#preview", Static).update(out)
|
||||
|
||||
# --- actions -----------------------------------------------------------------
|
||||
def action_apply_me(self):
|
||||
path = apply_design(self.design, system=False)
|
||||
self.notify(t(f"Записано в {path}. Відкрийте новий термінал або exec bash.",
|
||||
f"Written to {path}. Open a new terminal or run exec bash."), timeout=6)
|
||||
|
||||
def action_apply_all(self):
|
||||
fd, path = tempfile.mkstemp(prefix="dprompt-", suffix=".json")
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(prompt.dumps(self.design))
|
||||
os.chmod(path, 0o644)
|
||||
ok = run_as_root(self, ["dprompt", "apply", path, "--system"])
|
||||
os.remove(path)
|
||||
if ok:
|
||||
self.notify(t("Запрошення для всіх записано в /etc/das/prompt.sh.",
|
||||
"The prompt for everyone is written to /etc/das/prompt.sh."), timeout=6)
|
||||
|
||||
def action_export(self):
|
||||
def done(result):
|
||||
if not result:
|
||||
return
|
||||
path = os.path.expanduser(result["path"])
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(prompt.dumps(dict(copy.deepcopy(self.design), name=os.path.basename(path)[:-5])))
|
||||
except OSError as e:
|
||||
self.notify(str(e), severity="error")
|
||||
return
|
||||
self.notify(t(f"Збережено: {path}", f"Saved: {path}"))
|
||||
self.push_screen(AskDialog(t("Зберегти запрошення у файл", "Save the prompt to a file"),
|
||||
[("path", t("Файл (.json)", "File (.json)"), "~/my-prompt.json")]), done)
|
||||
|
||||
|
||||
__all__ = ["PromptApp", "NONE"]
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Textual pieces shared by the dprompt and dbanner editors."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from rich.style import Style
|
||||
from rich.text import Text
|
||||
from textual import events
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.message import Message
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Button, Input, Label, Select
|
||||
|
||||
from . import colors
|
||||
from .i18n import t
|
||||
|
||||
NONE = "-" # Select has no None value
|
||||
|
||||
|
||||
def color_options(allow_none=True):
|
||||
names = {
|
||||
"black": ("чорний", "black"), "red": ("червоний", "red"), "green": ("зелений", "green"),
|
||||
"yellow": ("жовтий", "yellow"), "blue": ("синій", "blue"), "magenta": ("пурпуровий", "magenta"),
|
||||
"cyan": ("бірюзовий", "cyan"), "white": ("білий", "white"),
|
||||
}
|
||||
opts = [(t("— типовий —", "— default —"), NONE)] if allow_none else []
|
||||
for c in colors.NAMES:
|
||||
base = c[7:] if c.startswith("bright_") else c
|
||||
label = t(*names[base])
|
||||
if c.startswith("bright_"):
|
||||
label = t("яскравий ", "bright ") + label
|
||||
opts.append((Text.assemble(("██ ", c), label), c))
|
||||
return opts
|
||||
|
||||
|
||||
def color_value(value):
|
||||
return None if value in (NONE, Select.NULL, None) else value
|
||||
|
||||
|
||||
def to_select(color):
|
||||
return color if color in colors.NAMES else NONE
|
||||
|
||||
|
||||
class Swatches(Widget):
|
||||
"""A row of the 16 colors (and "none") to click; the chosen one is marked."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
Swatches { height: 2; width: 36; }
|
||||
"""
|
||||
|
||||
class Picked(Message):
|
||||
def __init__(self, swatches, color):
|
||||
super().__init__()
|
||||
self.swatches, self.color = swatches, color
|
||||
|
||||
def __init__(self, color=None, allow_none=True, **kw):
|
||||
super().__init__(**kw)
|
||||
self.color, self.allow_none = color, allow_none
|
||||
|
||||
def _items(self):
|
||||
return ([None] if self.allow_none else []) + list(colors.NAMES)
|
||||
|
||||
def render(self):
|
||||
text = Text()
|
||||
for i, c in enumerate(self._items()):
|
||||
if i == 9:
|
||||
text.append("\n")
|
||||
mark = "▣" if c == self.color else "■"
|
||||
if c is None:
|
||||
text.append("∅ " if c != self.color else "▣ ", Style(bold=True))
|
||||
else:
|
||||
text.append(mark * 2 + " " if c == self.color else "██ ", Style(color=c))
|
||||
return text
|
||||
|
||||
def on_click(self, event: events.Click):
|
||||
items = self._items()
|
||||
index = (event.y * 9) + event.x // 3
|
||||
if 0 <= event.x // 3 < 9 and 0 <= index < len(items):
|
||||
self.color = items[index]
|
||||
self.refresh()
|
||||
self.post_message(self.Picked(self, self.color))
|
||||
|
||||
|
||||
class AskDialog(ModalScreen):
|
||||
"""A few text fields with OK/Cancel; dismisses with {key: value} or None."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
AskDialog { align: center middle; }
|
||||
AskDialog > Vertical { width: 72; height: auto; border: round $accent; background: $surface; padding: 1 2; }
|
||||
AskDialog Label { margin-top: 1; }
|
||||
AskDialog Horizontal { height: auto; margin-top: 1; align-horizontal: right; }
|
||||
AskDialog Button { margin-left: 2; }
|
||||
"""
|
||||
BINDINGS = [("escape", "cancel", t("Скасувати", "Cancel"))]
|
||||
|
||||
def __init__(self, heading, fields):
|
||||
"""fields: [(key, label, value or a list of (label, value) choices, current)]"""
|
||||
super().__init__()
|
||||
self.heading, self.fields = heading, fields
|
||||
|
||||
def compose(self):
|
||||
with Vertical():
|
||||
yield Label(Text(self.heading, style="bold"))
|
||||
for key, label, value, *rest in self.fields:
|
||||
yield Label(Text(label))
|
||||
if isinstance(value, list):
|
||||
yield Select(value, value=rest[0] if rest else value[0][1], allow_blank=False, id="f_" + key)
|
||||
else:
|
||||
yield Input(str(value), id="f_" + key)
|
||||
with Horizontal():
|
||||
yield Button(t("Скасувати", "Cancel"), id="cancel")
|
||||
yield Button("OK", variant="primary", id="ok")
|
||||
|
||||
def on_mount(self):
|
||||
inputs = self.query(Input)
|
||||
if inputs:
|
||||
inputs.first().focus()
|
||||
|
||||
def _result(self):
|
||||
return {key: self.query_one("#f_" + key).value for key, *_ in self.fields}
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed):
|
||||
self.dismiss(self._result() if event.button.id == "ok" else None)
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted):
|
||||
self.dismiss(self._result())
|
||||
|
||||
def action_cancel(self):
|
||||
self.dismiss(None)
|
||||
|
||||
|
||||
def run_as_root(app, args):
|
||||
"""Runs one of our commands through sudo with the TUI suspended, so sudo can
|
||||
ask for the password. Returns True on success."""
|
||||
exe = "/usr/bin/" + args[0]
|
||||
if os.path.exists(exe):
|
||||
cmd = [exe] + args[1:]
|
||||
else: # from the source tree
|
||||
module = {"dprompt": "das_shell.cli_prompt", "dbanner": "das_shell.cli_banner"}[args[0]]
|
||||
env = [f"{k}={os.environ[k]}" for k in ("PYTHONPATH", "DAS_SHELL_DATA", "DAS_SHELL_ETC", "DAS_SHELL_CACHE")
|
||||
if k in os.environ]
|
||||
cmd = ["env"] + env + [sys.executable, "-m", module] + args[1:]
|
||||
if os.geteuid() != 0:
|
||||
cmd = ["sudo"] + cmd
|
||||
with app.suspend():
|
||||
print("\n$ " + " ".join(cmd))
|
||||
code = subprocess.run(cmd).returncode
|
||||
if code:
|
||||
input(t("Помилка. Enter — повернутися…", "Failed. Press Enter to go back…"))
|
||||
return code == 0
|
||||
|
||||
|
||||
def bar(*buttons):
|
||||
row = Horizontal(*buttons, classes="buttons")
|
||||
return row
|
||||
@@ -0,0 +1,415 @@
|
||||
"""Values for banner fields ({hostname}, {ip}, ...) and widgets: blocks of lines
|
||||
from built-in functions, scripts, commands or files.
|
||||
|
||||
"live" widgets run at login with a short timeout; "cached" ones are run by the
|
||||
das-banner service, and the login only reads their last output from CACHE_DIR.
|
||||
"""
|
||||
|
||||
import concurrent.futures
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from . import CACHE_DIR, DATA_DIR, colors
|
||||
from .i18n import t
|
||||
|
||||
LIVE_TIMEOUT = 1.5
|
||||
CACHED_TIMEOUT = 60
|
||||
|
||||
|
||||
# --- field values -----------------------------------------------------------------
|
||||
|
||||
def _read(path):
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _ips():
|
||||
try:
|
||||
out = subprocess.run(["ip", "-4", "-o", "addr", "show", "scope", "global"],
|
||||
capture_output=True, text=True, timeout=1).stdout
|
||||
ips = [line.split()[3].split("/")[0] for line in out.splitlines() if len(line.split()) > 3]
|
||||
if ips:
|
||||
return ips
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
s.connect(("192.0.2.1", 9)) # no packet is sent
|
||||
return [s.getsockname()[0]]
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def _os_name():
|
||||
for line in _read("/etc/os-release").splitlines():
|
||||
if line.startswith("PRETTY_NAME="):
|
||||
return line.split("=", 1)[1].strip().strip('"')
|
||||
return "Linux"
|
||||
|
||||
|
||||
def _duration(seconds):
|
||||
days, rest = divmod(int(seconds), 86400)
|
||||
hours, rest = divmod(rest, 3600)
|
||||
minutes = rest // 60
|
||||
parts = []
|
||||
if days:
|
||||
parts.append(t(f"{days} дн", f"{days} d"))
|
||||
if hours or days:
|
||||
parts.append(t(f"{hours} год", f"{hours} h"))
|
||||
if not days:
|
||||
parts.append(t(f"{minutes} хв", f"{minutes} min"))
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _uptime():
|
||||
try:
|
||||
return _duration(float(_read("/proc/uptime").split()[0]))
|
||||
except (IndexError, ValueError):
|
||||
return "?"
|
||||
|
||||
|
||||
def _meminfo():
|
||||
info = {}
|
||||
for line in _read("/proc/meminfo").splitlines():
|
||||
key, _, value = line.partition(":")
|
||||
try:
|
||||
info[key] = int(value.split()[0]) * 1024
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
return info
|
||||
|
||||
|
||||
def human(n):
|
||||
for unit in ("B", "K", "M", "G", "T", "P"):
|
||||
if abs(n) < 1024 or unit == "P":
|
||||
return f"{n:.0f}{unit}" if unit in ("B", "K") or n >= 100 else f"{n:.1f}{unit}"
|
||||
n /= 1024
|
||||
return str(n)
|
||||
|
||||
|
||||
def _mem():
|
||||
m = _meminfo()
|
||||
total = m.get("MemTotal", 0)
|
||||
used = total - m.get("MemAvailable", 0)
|
||||
return f"{human(used)}/{human(total)}"
|
||||
|
||||
|
||||
def _cpu():
|
||||
for line in _read("/proc/cpuinfo").splitlines():
|
||||
if line.startswith("model name"):
|
||||
return " ".join(line.split(":", 1)[1].split())
|
||||
return os.uname().machine
|
||||
|
||||
|
||||
def _users():
|
||||
try:
|
||||
out = subprocess.run(["who"], capture_output=True, text=True, timeout=1).stdout
|
||||
return str(len({line.split()[0] for line in out.splitlines() if line.strip()}))
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return "?"
|
||||
|
||||
|
||||
PROVIDERS = {
|
||||
"hostname": lambda: socket.gethostname().split(".")[0],
|
||||
"fqdn": socket.getfqdn,
|
||||
"ip": lambda: (_ips() or ["?"])[0],
|
||||
"ips": lambda: " ".join(_ips()),
|
||||
"os": _os_name,
|
||||
"kernel": lambda: os.uname().release,
|
||||
"arch": lambda: os.uname().machine,
|
||||
"uptime": _uptime,
|
||||
"load": lambda: " ".join(_read("/proc/loadavg").split()[:3]),
|
||||
"mem": _mem,
|
||||
"cpu": _cpu,
|
||||
"cores": lambda: str(os.cpu_count() or 1),
|
||||
"date": lambda: datetime.datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
"users": _users,
|
||||
"user": lambda: os.environ.get("USER") or os.environ.get("LOGNAME") or "?",
|
||||
}
|
||||
|
||||
VAR_HELP = {
|
||||
"hostname": ("ім'я комп'ютера", "host name"),
|
||||
"fqdn": ("повне ім'я в мережі", "fully qualified name"),
|
||||
"ip": ("перша IPv4-адреса", "first IPv4 address"),
|
||||
"ips": ("усі IPv4-адреси", "all IPv4 addresses"),
|
||||
"os": ("назва системи", "system name"),
|
||||
"kernel": ("версія ядра", "kernel version"),
|
||||
"arch": ("архітектура", "architecture"),
|
||||
"uptime": ("час роботи", "uptime"),
|
||||
"load": ("навантаження", "load average"),
|
||||
"mem": ("пам'ять зайнято/всього", "memory used/total"),
|
||||
"cpu": ("процесор", "processor"),
|
||||
"cores": ("кількість ядер", "number of cores"),
|
||||
"date": ("дата й час", "date and time"),
|
||||
"users": ("користувачів у системі", "users logged in"),
|
||||
"user": ("хто входить", "who logs in"),
|
||||
}
|
||||
|
||||
|
||||
class Values(dict):
|
||||
"""Field values, computed only when a field asks for them."""
|
||||
|
||||
def __init__(self, user_vars=None):
|
||||
super().__init__()
|
||||
self.user_vars = dict(user_vars or {})
|
||||
|
||||
def get(self, key, default=None):
|
||||
if key in self.user_vars:
|
||||
return self.user_vars[key]
|
||||
if key not in self and key in PROVIDERS:
|
||||
try:
|
||||
self[key] = PROVIDERS[key]()
|
||||
except Exception: # a broken value must not break the login
|
||||
self[key] = "?"
|
||||
return super().get(key, default)
|
||||
|
||||
|
||||
# --- built-in widgets -------------------------------------------------------------
|
||||
|
||||
def _bar(percent, width=10):
|
||||
full = round(percent / 100 * width)
|
||||
color = "green" if percent < 70 else ("yellow" if percent < 90 else "red")
|
||||
return (colors.sgr(color, None, True) + "█" * full + colors.sgr("bright_black") + "░" * (width - full)
|
||||
+ colors.RESET)
|
||||
|
||||
|
||||
PSEUDO_FS = {"proc", "sysfs", "devtmpfs", "devpts", "tmpfs", "cgroup", "cgroup2", "securityfs",
|
||||
"pstore", "efivarfs", "bpf", "debugfs", "tracefs", "configfs", "fusectl", "mqueue",
|
||||
"hugetlbfs", "autofs", "binfmt_misc", "overlay", "squashfs", "nsfs", "ramfs", "rpc_pipefs",
|
||||
"fuse.portal", "fuse.gvfsd-fuse", "iso9660", "zram"}
|
||||
|
||||
|
||||
def _mounts():
|
||||
seen, out = set(), []
|
||||
for line in _read("/proc/mounts").splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) < 3 or parts[2] in PSEUDO_FS or not parts[0].startswith("/"):
|
||||
continue
|
||||
mount = parts[1].replace("\\040", " ")
|
||||
if parts[0] in seen or mount.startswith(("/snap", "/var/lib/docker", "/.snapshots", "/run")):
|
||||
continue
|
||||
seen.add(parts[0])
|
||||
out.append(mount)
|
||||
return out
|
||||
|
||||
|
||||
def w_disks(args, values):
|
||||
lines = [colors.sgr("white", None, True) + t("Диски:", "Disks:") + colors.RESET]
|
||||
rows = []
|
||||
for mount in args.get("mounts") or _mounts():
|
||||
try:
|
||||
st = os.statvfs(mount)
|
||||
except OSError:
|
||||
continue
|
||||
total = st.f_blocks * st.f_frsize
|
||||
if not total:
|
||||
continue
|
||||
used = total - st.f_bfree * st.f_frsize
|
||||
rows.append((mount, total, used))
|
||||
width = max([len(r[0]) for r in rows] + [1])
|
||||
for mount, total, used in rows:
|
||||
pct = round(used * 100 / total)
|
||||
lines.append(f" {mount.ljust(width)} {human(total):>6} {_bar(pct)} {pct:3d}%")
|
||||
return lines
|
||||
|
||||
|
||||
def w_memory(args, values):
|
||||
m = _meminfo()
|
||||
lines = []
|
||||
for label, total, free in ((t("Пам'ять", "Memory"), m.get("MemTotal", 0), m.get("MemAvailable", 0)),
|
||||
("Swap", m.get("SwapTotal", 0), m.get("SwapFree", 0))):
|
||||
if not total:
|
||||
continue
|
||||
pct = round((total - free) * 100 / total)
|
||||
lines.append(f"{colors.sgr('white', None, True)}{label + ':':<9}{colors.RESET} "
|
||||
f"{human(total - free):>6}/{human(total):<6} {_bar(pct)} {pct:3d}%")
|
||||
return lines
|
||||
|
||||
|
||||
def w_uptime(args, values):
|
||||
return [f"{colors.sgr('white', None, True)}{t('Працює:', 'Uptime:')}{colors.RESET} "
|
||||
f"{colors.sgr('red', None, True)}{values.get('uptime')}{colors.RESET}"]
|
||||
|
||||
|
||||
def w_load(args, values):
|
||||
return [f"{colors.sgr('white', None, True)}{t('Навантаження:', 'Load:')}{colors.RESET} "
|
||||
f"{values.get('load')} ({t('ядер', 'cores')}: {values.get('cores')})"]
|
||||
|
||||
|
||||
def w_network(args, values):
|
||||
lines = [colors.sgr("white", None, True) + t("Мережа:", "Network:") + colors.RESET]
|
||||
try:
|
||||
out = subprocess.run(["ip", "-o", "addr", "show", "scope", "global"],
|
||||
capture_output=True, text=True, timeout=1).stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return []
|
||||
for line in out.splitlines():
|
||||
p = line.split()
|
||||
if len(p) > 3:
|
||||
lines.append(f" {p[1]:<10} {colors.sgr('cyan', None, True)}{p[3]}{colors.RESET}")
|
||||
return lines if len(lines) > 1 else []
|
||||
|
||||
|
||||
BUILTINS = {
|
||||
"uptime": (w_uptime, ("Час роботи", "Uptime")),
|
||||
"load": (w_load, ("Навантаження", "Load average")),
|
||||
"memory": (w_memory, ("Пам'ять і swap", "Memory and swap")),
|
||||
"disks": (w_disks, ("Диски", "Disks")),
|
||||
"network": (w_network, ("Мережа", "Network")),
|
||||
}
|
||||
|
||||
|
||||
def widget_dir():
|
||||
return os.path.join(DATA_DIR, "banner", "widgets")
|
||||
|
||||
|
||||
def script_meta(path):
|
||||
"""# dbanner-<key>: value lines of a widget script."""
|
||||
meta = {}
|
||||
for line in _read(path).splitlines()[:20]:
|
||||
if line.startswith("# dbanner-"):
|
||||
key, _, value = line[10:].partition(":")
|
||||
meta[key.strip()] = value.strip()
|
||||
return meta
|
||||
|
||||
|
||||
def available():
|
||||
"""(name, title, mode) of every built-in widget and widget script."""
|
||||
out = [(name, t(*names), "live") for name, (_, names) in BUILTINS.items()]
|
||||
try:
|
||||
scripts = sorted(f for f in os.listdir(widget_dir()) if f.endswith(".sh") and f != "lib.sh")
|
||||
except OSError:
|
||||
scripts = []
|
||||
for f in scripts:
|
||||
meta = script_meta(os.path.join(widget_dir(), f))
|
||||
out.append((f[:-3], t(meta.get("title-uk", f[:-3]), meta.get("title-en", f[:-3])),
|
||||
meta.get("mode", "live")))
|
||||
return out
|
||||
|
||||
|
||||
def describe(w):
|
||||
"""A short line about a widget for lists."""
|
||||
kind = w.get("type", "builtin")
|
||||
if kind == "builtin":
|
||||
names = {n: title_ for n, title_, _ in available()}
|
||||
return names.get(w.get("name"), w.get("name", "?"))
|
||||
if kind == "command":
|
||||
return "$ " + w.get("cmd", "")
|
||||
return t("файл ", "file ") + w.get("path", "")
|
||||
|
||||
|
||||
def mode_of(w):
|
||||
if w.get("mode"):
|
||||
return w["mode"]
|
||||
if w.get("type", "builtin") == "builtin" and w.get("name") not in BUILTINS:
|
||||
return script_meta(os.path.join(widget_dir(), w.get("name", "") + ".sh")).get("mode", "live")
|
||||
return "live"
|
||||
|
||||
|
||||
def interval_of(w):
|
||||
if w.get("interval"):
|
||||
return int(w["interval"])
|
||||
if w.get("type", "builtin") == "builtin":
|
||||
meta = script_meta(os.path.join(widget_dir(), w.get("name", "") + ".sh"))
|
||||
if meta.get("interval", "").isdigit():
|
||||
return int(meta["interval"])
|
||||
return 600
|
||||
|
||||
|
||||
def cache_path(w):
|
||||
key = hashlib.sha1(json.dumps(w, sort_keys=True).encode()).hexdigest()[:16]
|
||||
return os.path.join(CACHE_DIR, key + ".txt")
|
||||
|
||||
|
||||
def _env(design_vars):
|
||||
env = dict(os.environ)
|
||||
for key, value in (design_vars or {}).items():
|
||||
env["DBANNER_" + key.upper()] = str(value)
|
||||
env["DBANNER_LANG"] = "uk" if t("uk", "en") == "uk" else "en"
|
||||
return env
|
||||
|
||||
|
||||
def run(w, values, design_vars=None, timeout=None):
|
||||
"""Runs a widget now and returns its lines (empty when it fails)."""
|
||||
kind = w.get("type", "builtin")
|
||||
try:
|
||||
if kind == "file":
|
||||
text = _read(os.path.expanduser(w.get("path", "")))
|
||||
lines = text.rstrip("\n").split("\n") if text.strip() else []
|
||||
elif kind == "builtin" and w.get("name") in BUILTINS:
|
||||
lines = BUILTINS[w["name"]][0](w.get("args") or {}, values)
|
||||
else:
|
||||
if kind == "builtin":
|
||||
cmd = [os.path.join(widget_dir(), w.get("name", "") + ".sh")]
|
||||
else:
|
||||
cmd = ["sh", "-c", w.get("cmd", "")]
|
||||
limit = timeout or float(w.get("timeout") or (CACHED_TIMEOUT if mode_of(w) == "cached" else LIVE_TIMEOUT))
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, errors="replace",
|
||||
timeout=limit, env=_env(design_vars), stdin=subprocess.DEVNULL)
|
||||
out = proc.stdout.rstrip("\n")
|
||||
lines = out.split("\n") if out.strip() else []
|
||||
except Exception: # a broken widget must not break the login
|
||||
return []
|
||||
if lines and w.get("title"):
|
||||
lines = [colors.sgr("white", None, True) + w["title"] + colors.RESET] + lines
|
||||
return lines
|
||||
|
||||
|
||||
def read_cache(w):
|
||||
text = _read(cache_path(w))
|
||||
return text.rstrip("\n").split("\n") if text.strip() else []
|
||||
|
||||
|
||||
def blocks(design, values, use_cache=True):
|
||||
"""Lines of every widget of a design, in order; live ones run in parallel."""
|
||||
widgets = design.get("widgets", [])
|
||||
result = [[] for _ in widgets]
|
||||
jobs = {}
|
||||
# No "with": leaving it would wait for a widget that hangs
|
||||
pool = concurrent.futures.ThreadPoolExecutor(max_workers=8)
|
||||
for i, w in enumerate(widgets):
|
||||
if w.get("enabled") is False:
|
||||
continue
|
||||
if use_cache and mode_of(w) == "cached":
|
||||
result[i] = read_cache(w)
|
||||
else:
|
||||
jobs[pool.submit(run, w, values, design.get("vars"))] = i
|
||||
try:
|
||||
for fut in concurrent.futures.as_completed(jobs, timeout=LIVE_TIMEOUT + 1 if use_cache else None):
|
||||
result[jobs[fut]] = fut.result()
|
||||
except concurrent.futures.TimeoutError:
|
||||
pass
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
return result
|
||||
|
||||
|
||||
def refresh(design, force=False):
|
||||
"""Runs the cached widgets whose output is older than their interval."""
|
||||
values = None
|
||||
os.makedirs(CACHE_DIR, mode=0o755, exist_ok=True)
|
||||
for w in design.get("widgets", []):
|
||||
if w.get("enabled") is False or mode_of(w) != "cached":
|
||||
continue
|
||||
path = cache_path(w)
|
||||
try:
|
||||
age = time.time() - os.stat(path).st_mtime
|
||||
except OSError:
|
||||
age = None
|
||||
if not force and age is not None and age < interval_of(w):
|
||||
continue
|
||||
if values is None:
|
||||
values = Values(design.get("vars"))
|
||||
lines = run(w, values, design.get("vars"))
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines) + ("\n" if lines else ""))
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, path)
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"name": "das-logo-small",
|
||||
"title": {
|
||||
"uk": "Логотип Das OS, малий",
|
||||
"en": "Das OS logo, small"
|
||||
},
|
||||
"styles": {
|
||||
"a": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"b": {
|
||||
"fg": "bright_blue",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"c": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": "blue",
|
||||
"bold": false
|
||||
},
|
||||
"d": {
|
||||
"fg": "yellow",
|
||||
"bg": "bright_blue",
|
||||
"bold": false
|
||||
},
|
||||
"e": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": "yellow",
|
||||
"bold": false
|
||||
},
|
||||
"f": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": "bright_blue",
|
||||
"bold": false
|
||||
},
|
||||
"g": {
|
||||
"fg": "bright_blue",
|
||||
"bg": "bright_yellow",
|
||||
"bold": false
|
||||
},
|
||||
"h": {
|
||||
"fg": "blue",
|
||||
"bg": "bright_yellow",
|
||||
"bold": false
|
||||
},
|
||||
"i": {
|
||||
"fg": "bright_blue",
|
||||
"bg": "yellow",
|
||||
"bold": false
|
||||
}
|
||||
},
|
||||
"lines": [
|
||||
"",
|
||||
" ▄▄ ██ ▄▄",
|
||||
" ▀█▀█▀▀█▀█▀",
|
||||
" ▄▄▀▀▀▀▀▀▀▀▄▄",
|
||||
" ▀▀▀▀▀▀▀▀▀▀▀▀",
|
||||
" █▀█▀▀█▀█",
|
||||
" ▀▀ ██ ▀▀",
|
||||
""
|
||||
],
|
||||
"style_map": [
|
||||
"",
|
||||
" aa bb aa",
|
||||
" aacaddacaa",
|
||||
" bbeffggffebb",
|
||||
" bbdggffggdbb",
|
||||
" ahaiiaha",
|
||||
" aa bb aa",
|
||||
""
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "das-logo",
|
||||
"title": {
|
||||
"uk": "Логотип Das OS",
|
||||
"en": "Das OS logo"
|
||||
},
|
||||
"styles": {
|
||||
"a": {
|
||||
"fg": "bright_blue",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"b": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"c": {
|
||||
"fg": "bright_blue",
|
||||
"bg": "yellow",
|
||||
"bold": false
|
||||
},
|
||||
"d": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": "blue",
|
||||
"bold": false
|
||||
},
|
||||
"e": {
|
||||
"fg": "yellow",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"f": {
|
||||
"fg": "blue",
|
||||
"bg": "bright_yellow",
|
||||
"bold": false
|
||||
},
|
||||
"g": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": "bright_blue",
|
||||
"bold": false
|
||||
},
|
||||
"h": {
|
||||
"fg": "bright_blue",
|
||||
"bg": "bright_yellow",
|
||||
"bold": false
|
||||
},
|
||||
"i": {
|
||||
"fg": "yellow",
|
||||
"bg": "bright_blue",
|
||||
"bold": false
|
||||
}
|
||||
},
|
||||
"lines": [
|
||||
"",
|
||||
" ██",
|
||||
" ▄▄ ▄▀▀▄ ▄▄",
|
||||
" ▀█▀▀██████▀▀█▀",
|
||||
" █▀▀██████▀▀█",
|
||||
" ▄▄▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄▄▄",
|
||||
" ▀▀█▀▀▀▀▀▀▀▀▀▀▀▀▀▀█▀▀",
|
||||
" █▀▀██████▀▀█",
|
||||
" ▄█▀▀██████▀▀█▄",
|
||||
" ▀▀ ▀▀▀▀ ▀▀",
|
||||
" ██",
|
||||
""
|
||||
],
|
||||
"style_map": [
|
||||
"",
|
||||
" aa",
|
||||
" bb acca bb",
|
||||
" bbddbaeeabddbb",
|
||||
" bffbbaabbffb",
|
||||
" aaacccggghhgggcccaaa",
|
||||
" aaaiiihhhgghhhiiiaaa",
|
||||
" bddbbaabbddb",
|
||||
" bbffbaeeabffbb",
|
||||
" bb aiia bb",
|
||||
" aa",
|
||||
""
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "trident-shield",
|
||||
"title": {
|
||||
"uk": "Малий герб України (тризуб на щиті)",
|
||||
"en": "Lesser coat of arms of Ukraine (tryzub on a shield)"
|
||||
},
|
||||
"styles": {
|
||||
"a": {
|
||||
"fg": "blue",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"b": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"c": {
|
||||
"fg": "blue",
|
||||
"bg": "bright_yellow",
|
||||
"bold": false
|
||||
},
|
||||
"d": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": "blue",
|
||||
"bold": false
|
||||
}
|
||||
},
|
||||
"lines": [
|
||||
"██████████████████████",
|
||||
"██████████████████████",
|
||||
"████▀████████████▀████",
|
||||
"████▀█▀████████▀█▀████",
|
||||
"██████████████████████",
|
||||
"███████▀██████▀███████",
|
||||
"██████████████████████",
|
||||
"█████▀█▀█▀██▀█▀█▀█████",
|
||||
"██████▀█▀████▀█▀██████",
|
||||
"██████▀███▀▀███▀██████",
|
||||
"████▀▀▀██▀██▀██▀▀▀████",
|
||||
"███▀▀▀▀▀█▀██▀█▀▀▀▀▀███",
|
||||
" ▀██████▀████▀██████▀",
|
||||
" ▀▀▀████▀▀████▀▀▀",
|
||||
" ▀▀██▀▀"
|
||||
],
|
||||
"style_map": [
|
||||
"aaaaaaaaaaaaaaaaaaaaaa",
|
||||
"aaaaaaaaaabbaaaaaaaaaa",
|
||||
"aaabcaaaaabbaaaaacbaaa",
|
||||
"aaabdbcaaabbaaacbdbaaa",
|
||||
"aaabaabaaabbaaabaabaaa",
|
||||
"aaabaabcaabbaacbaabaaa",
|
||||
"aaabaabbaabbaabbaabaaa",
|
||||
"aaabacbdacbbcadbcabaaa",
|
||||
"aaabbbcacbaabcacbbbaaa",
|
||||
"aaabaadbbbccbbbdaabaaa",
|
||||
"aaabcccbbcbbcbbcccbaaa",
|
||||
"aaadddddbdbbdbdddddaaa",
|
||||
" aaaaaaadbbbbdaaaaaaa",
|
||||
" aaaaaaaddaaaaaaa",
|
||||
" aaaaaa"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "trident-small",
|
||||
"title": {
|
||||
"uk": "Тризуб, малий",
|
||||
"en": "Tryzub, small"
|
||||
},
|
||||
"styles": {
|
||||
"a": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
}
|
||||
},
|
||||
"lines": [
|
||||
"",
|
||||
" ▄ ██ ▄",
|
||||
" █▀▄ ██ ▄▀█",
|
||||
" █ █ ██ █ █",
|
||||
" █ ▄ ██ ▄ █",
|
||||
" █▄▄▀ ██ ▀▄▄█",
|
||||
" █▀█▄█ █▄█▀█",
|
||||
" █▄ ██████ ▄█",
|
||||
" ▀▀▀████▀▀▀",
|
||||
" ▀██▀",
|
||||
""
|
||||
],
|
||||
"style_map": [
|
||||
"",
|
||||
" a aa a",
|
||||
" aaa aa aaa",
|
||||
" a a aa a a",
|
||||
" a a aa a a",
|
||||
" aaaa aa aaaa",
|
||||
" aaaaa aaaaa",
|
||||
" aa aaaaaa aa",
|
||||
" aaaaaaaaaa",
|
||||
" aaaa",
|
||||
""
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "trident",
|
||||
"title": {
|
||||
"uk": "Тризуб",
|
||||
"en": "Tryzub"
|
||||
},
|
||||
"styles": {
|
||||
"a": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
}
|
||||
},
|
||||
"lines": [
|
||||
"",
|
||||
" ██",
|
||||
" █▄ ██ ▄█",
|
||||
" █▀█ ██ █▀█",
|
||||
" █ ██ ██ ██ █",
|
||||
" █ █ ██ █ █",
|
||||
" █ █ ██ █ █",
|
||||
" █▄█ █▀▀█ █▄█",
|
||||
" █ ▀▄██ ██▄▀ █",
|
||||
" █ █ ██ █ █",
|
||||
" ▀▀▀▀█▀██▀█▀▀▀▀",
|
||||
" ▀████▀",
|
||||
" ▀▀",
|
||||
""
|
||||
],
|
||||
"style_map": [
|
||||
"",
|
||||
" aa",
|
||||
" aa aa aa",
|
||||
" aaa aa aaa",
|
||||
" a aa aa aa a",
|
||||
" a a aa a a",
|
||||
" a a aa a a",
|
||||
" aaa aaaa aaa",
|
||||
" a aaaa aaaa a",
|
||||
" a a aa a a",
|
||||
" aaaaaaaaaaaaaa",
|
||||
" aaaaaa",
|
||||
" aa",
|
||||
""
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
{
|
||||
"name": "blocks",
|
||||
"title": {
|
||||
"uk": "Логотип Das OS і відомості про систему",
|
||||
"en": "Das OS logo and system facts"
|
||||
},
|
||||
"local": false,
|
||||
"vars": {
|
||||
"url": ""
|
||||
},
|
||||
"fields": [
|
||||
{
|
||||
"x": 26,
|
||||
"y": 0,
|
||||
"width": 50,
|
||||
"align": "left",
|
||||
"text": "{user}@{hostname}",
|
||||
"style": "j"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 1,
|
||||
"width": 50,
|
||||
"align": "left",
|
||||
"text": "──────────────────────────────",
|
||||
"style": "i"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 2,
|
||||
"width": 9,
|
||||
"align": "left",
|
||||
"text": "[[ОС|OS]]",
|
||||
"style": "k"
|
||||
},
|
||||
{
|
||||
"x": 35,
|
||||
"y": 2,
|
||||
"width": 41,
|
||||
"align": "left",
|
||||
"text": "{os}",
|
||||
"style": "l"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 3,
|
||||
"width": 9,
|
||||
"align": "left",
|
||||
"text": "[[Ядро|Kernel]]",
|
||||
"style": "k"
|
||||
},
|
||||
{
|
||||
"x": 35,
|
||||
"y": 3,
|
||||
"width": 41,
|
||||
"align": "left",
|
||||
"text": "{kernel}",
|
||||
"style": "l"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 4,
|
||||
"width": 9,
|
||||
"align": "left",
|
||||
"text": "[[Працює|Uptime]]",
|
||||
"style": "k"
|
||||
},
|
||||
{
|
||||
"x": 35,
|
||||
"y": 4,
|
||||
"width": 41,
|
||||
"align": "left",
|
||||
"text": "{uptime}",
|
||||
"style": "l"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 5,
|
||||
"width": 9,
|
||||
"align": "left",
|
||||
"text": "CPU",
|
||||
"style": "k"
|
||||
},
|
||||
{
|
||||
"x": 35,
|
||||
"y": 5,
|
||||
"width": 41,
|
||||
"align": "left",
|
||||
"text": "{cpu}",
|
||||
"style": "l"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 6,
|
||||
"width": 9,
|
||||
"align": "left",
|
||||
"text": "[[Пам'ять|Memory]]",
|
||||
"style": "k"
|
||||
},
|
||||
{
|
||||
"x": 35,
|
||||
"y": 6,
|
||||
"width": 41,
|
||||
"align": "left",
|
||||
"text": "{mem}",
|
||||
"style": "l"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 7,
|
||||
"width": 9,
|
||||
"align": "left",
|
||||
"text": "IP",
|
||||
"style": "k"
|
||||
},
|
||||
{
|
||||
"x": 35,
|
||||
"y": 7,
|
||||
"width": 41,
|
||||
"align": "left",
|
||||
"text": "{ips}",
|
||||
"style": "l"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 8,
|
||||
"width": 9,
|
||||
"align": "left",
|
||||
"text": "[[Вхід|Users]]",
|
||||
"style": "k"
|
||||
},
|
||||
{
|
||||
"x": 35,
|
||||
"y": 8,
|
||||
"width": 41,
|
||||
"align": "left",
|
||||
"text": "{users}",
|
||||
"style": "l"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 10,
|
||||
"width": 50,
|
||||
"align": "left",
|
||||
"text": "{url}",
|
||||
"style": "g"
|
||||
}
|
||||
],
|
||||
"widgets_position": "below",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "disks"
|
||||
}
|
||||
],
|
||||
"styles": {
|
||||
"a": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"b": {
|
||||
"fg": "blue",
|
||||
"bg": "bright_yellow",
|
||||
"bold": false
|
||||
},
|
||||
"c": {
|
||||
"fg": "bright_blue",
|
||||
"bg": "bright_yellow",
|
||||
"bold": false
|
||||
},
|
||||
"d": {
|
||||
"fg": "bright_blue",
|
||||
"bg": "yellow",
|
||||
"bold": false
|
||||
},
|
||||
"e": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": "bright_blue",
|
||||
"bold": false
|
||||
},
|
||||
"f": {
|
||||
"fg": "yellow",
|
||||
"bg": "bright_blue",
|
||||
"bold": false
|
||||
},
|
||||
"g": {
|
||||
"fg": "yellow",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"h": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": "blue",
|
||||
"bold": false
|
||||
},
|
||||
"i": {
|
||||
"fg": "bright_blue",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"j": {
|
||||
"fg": "bright_cyan",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
},
|
||||
"k": {
|
||||
"fg": "bright_blue",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
},
|
||||
"l": {
|
||||
"fg": "white",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
}
|
||||
},
|
||||
"lines": [
|
||||
"",
|
||||
" ██",
|
||||
" ▄▄ ▄▀▀▄ ▄▄",
|
||||
" ▀█▀▀██████▀▀█▀",
|
||||
" █▀▀██████▀▀█",
|
||||
" ▄▄▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄▄▄",
|
||||
" ▀▀█▀▀▀▀▀▀▀▀▀▀▀▀▀▀█▀▀",
|
||||
" █▀▀██████▀▀█",
|
||||
" ▄█▀▀██████▀▀█▄",
|
||||
" ▀▀ ▀▀▀▀ ▀▀",
|
||||
" ██"
|
||||
],
|
||||
"style_map": [
|
||||
"",
|
||||
" ii",
|
||||
" aa iddi aa",
|
||||
" aahhaiggiahhaa",
|
||||
" abbaaiiaabba",
|
||||
" iiidddeeecceeedddiii",
|
||||
" iiifffccceecccfffiii",
|
||||
" ahhaaiiaahha",
|
||||
" aabbaiggiabbaa",
|
||||
" aa iffi aa",
|
||||
" ii"
|
||||
],
|
||||
"canvas": {
|
||||
"width": 76,
|
||||
"height": 12
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"name": "das-classic",
|
||||
"title": {
|
||||
"uk": "Das класичний: рамка з #, назва, IP, посилання",
|
||||
"en": "Das classic: # frame, name, IP, link"
|
||||
},
|
||||
"local": false,
|
||||
"vars": {
|
||||
"title": "Das OS",
|
||||
"url": "https://das.org.ua/"
|
||||
},
|
||||
"fields": [
|
||||
{
|
||||
"x": 2,
|
||||
"y": 1,
|
||||
"width": 35,
|
||||
"align": "center",
|
||||
"text": "-=:dAs:=- {title}",
|
||||
"style": "c"
|
||||
},
|
||||
{
|
||||
"x": 18,
|
||||
"y": 2,
|
||||
"width": 19,
|
||||
"align": "left",
|
||||
"text": "{ip}",
|
||||
"style": "d"
|
||||
},
|
||||
{
|
||||
"x": 13,
|
||||
"y": 3,
|
||||
"width": 24,
|
||||
"align": "left",
|
||||
"text": "{url}",
|
||||
"style": "d"
|
||||
}
|
||||
],
|
||||
"widgets_position": "below",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "uptime"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "disks"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "memory"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "docker"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "services"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "updates"
|
||||
}
|
||||
],
|
||||
"styles": {
|
||||
"a": {
|
||||
"fg": "blue",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"b": {
|
||||
"fg": "red",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
},
|
||||
"c": {
|
||||
"fg": "white",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
},
|
||||
"d": {
|
||||
"fg": "cyan",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
}
|
||||
},
|
||||
"lines": [
|
||||
"#######################################",
|
||||
"# #",
|
||||
"# Server IP: #",
|
||||
"# Link: #",
|
||||
"#######################################"
|
||||
],
|
||||
"style_map": [
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"a a",
|
||||
"a bbbbbbbbbb a",
|
||||
"a bbbbb a",
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
],
|
||||
"canvas": {
|
||||
"width": 39,
|
||||
"height": 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"name": "dashboard",
|
||||
"title": {
|
||||
"uk": "Панель: усі віджети під заголовком",
|
||||
"en": "Dashboard: every widget under a header"
|
||||
},
|
||||
"local": false,
|
||||
"vars": {
|
||||
"url": "",
|
||||
"city": "Kyiv"
|
||||
},
|
||||
"fields": [
|
||||
{
|
||||
"x": 2,
|
||||
"y": 1,
|
||||
"width": 66,
|
||||
"align": "center",
|
||||
"text": "{hostname} · {ip} · {url}",
|
||||
"style": "b"
|
||||
}
|
||||
],
|
||||
"widgets_position": "below",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "uptime"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "load"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "memory"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "disks"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "network"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "docker"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "updates"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "weather"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "smart"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "services"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "fail2ban"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "snapshots"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "lastlog"
|
||||
}
|
||||
],
|
||||
"styles": {
|
||||
"a": {
|
||||
"fg": "bright_blue",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"b": {
|
||||
"fg": "bright_white",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
}
|
||||
},
|
||||
"lines": [
|
||||
"╭────────────────────────────────────────────────────────────────────╮",
|
||||
"│ │",
|
||||
"╰────────────────────────────────────────────────────────────────────╯"
|
||||
],
|
||||
"style_map": [
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"a a",
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
],
|
||||
"canvas": {
|
||||
"width": 70,
|
||||
"height": 3
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"name": "double",
|
||||
"title": {
|
||||
"uk": "Подвійна рамка з великим ім'ям сервера",
|
||||
"en": "Double frame with the server name in big letters"
|
||||
},
|
||||
"local": false,
|
||||
"vars": {
|
||||
"title": "Das OS Server",
|
||||
"url": ""
|
||||
},
|
||||
"fields": [
|
||||
{
|
||||
"x": 2,
|
||||
"y": 1,
|
||||
"width": 60,
|
||||
"align": "center",
|
||||
"text": "{hostname}",
|
||||
"style": "c",
|
||||
"font": "small"
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 5,
|
||||
"width": 60,
|
||||
"align": "center",
|
||||
"text": "{title}",
|
||||
"style": "d"
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 6,
|
||||
"width": 60,
|
||||
"align": "center",
|
||||
"text": "{url}",
|
||||
"style": "e"
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 8,
|
||||
"width": 60,
|
||||
"align": "center",
|
||||
"text": "IP {ip} · {os} · {kernel}",
|
||||
"style": "f"
|
||||
}
|
||||
],
|
||||
"widgets_position": "below",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "uptime"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "load"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "memory"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "disks"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "services"
|
||||
}
|
||||
],
|
||||
"styles": {
|
||||
"a": {
|
||||
"fg": "cyan",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
},
|
||||
"b": {
|
||||
"fg": "bright_black",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"c": {
|
||||
"fg": "bright_white",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
},
|
||||
"d": {
|
||||
"fg": "yellow",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
},
|
||||
"e": {
|
||||
"fg": "cyan",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"f": {
|
||||
"fg": "white",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
}
|
||||
},
|
||||
"lines": [
|
||||
"╔══════════════════════════════════════════════════════════════╗",
|
||||
"║ ║",
|
||||
"║ ║",
|
||||
"║ ║",
|
||||
"║ ║",
|
||||
"║ ║",
|
||||
"║ ║",
|
||||
"║──────────────────────────────────────────────────────────────║",
|
||||
"║ ║",
|
||||
"╚══════════════════════════════════════════════════════════════╝"
|
||||
],
|
||||
"style_map": [
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"a a",
|
||||
"a a",
|
||||
"a a",
|
||||
"a a",
|
||||
"a a",
|
||||
"a a",
|
||||
"abbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbba",
|
||||
"a a",
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
],
|
||||
"canvas": {
|
||||
"width": 64,
|
||||
"height": 10
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "minimal",
|
||||
"title": {
|
||||
"uk": "Мінімальний: один рядок",
|
||||
"en": "Minimal: one line"
|
||||
},
|
||||
"local": false,
|
||||
"vars": {},
|
||||
"fields": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 70,
|
||||
"align": "left",
|
||||
"text": "{hostname} · {ip} · [[працює|up]] {uptime} · {load}",
|
||||
"style": "a"
|
||||
}
|
||||
],
|
||||
"widgets_position": "below",
|
||||
"widgets": [],
|
||||
"styles": {
|
||||
"a": {
|
||||
"fg": "bright_cyan",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
}
|
||||
},
|
||||
"lines": [],
|
||||
"style_map": [],
|
||||
"canvas": {
|
||||
"width": 70,
|
||||
"height": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"name": "tryzub",
|
||||
"title": {
|
||||
"uk": "Малий тризуб з іменем і IP",
|
||||
"en": "Small tryzub with name and IP"
|
||||
},
|
||||
"local": false,
|
||||
"vars": {
|
||||
"url": ""
|
||||
},
|
||||
"fields": [
|
||||
{
|
||||
"x": 17,
|
||||
"y": 2,
|
||||
"width": 43,
|
||||
"align": "left",
|
||||
"text": "{hostname}",
|
||||
"style": "b"
|
||||
},
|
||||
{
|
||||
"x": 17,
|
||||
"y": 3,
|
||||
"width": 43,
|
||||
"align": "left",
|
||||
"text": "{ip}",
|
||||
"style": "c"
|
||||
},
|
||||
{
|
||||
"x": 17,
|
||||
"y": 5,
|
||||
"width": 43,
|
||||
"align": "left",
|
||||
"text": "[[Працює|Uptime]]: {uptime}",
|
||||
"style": "d"
|
||||
},
|
||||
{
|
||||
"x": 17,
|
||||
"y": 6,
|
||||
"width": 43,
|
||||
"align": "left",
|
||||
"text": "{url}",
|
||||
"style": "e"
|
||||
}
|
||||
],
|
||||
"widgets_position": "below",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "disks"
|
||||
}
|
||||
],
|
||||
"styles": {
|
||||
"a": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"b": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
},
|
||||
"c": {
|
||||
"fg": "bright_blue",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
},
|
||||
"d": {
|
||||
"fg": "white",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"e": {
|
||||
"fg": "bright_blue",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
}
|
||||
},
|
||||
"lines": [
|
||||
"",
|
||||
" ▄ ██ ▄",
|
||||
" █▀▄ ██ ▄▀█",
|
||||
" █ █ ██ █ █",
|
||||
" █ ▄ ██ ▄ █",
|
||||
" █▄▄▀ ██ ▀▄▄█",
|
||||
" █▀█▄█ █▄█▀█",
|
||||
" █▄ ██████ ▄█",
|
||||
" ▀▀▀████▀▀▀",
|
||||
" ▀██▀"
|
||||
],
|
||||
"style_map": [
|
||||
"",
|
||||
" a aa a",
|
||||
" aaa aa aaa",
|
||||
" a a aa a a",
|
||||
" a a aa a a",
|
||||
" aaaa aa aaaa",
|
||||
" aaaaa aaaaa",
|
||||
" aa aaaaaa aa",
|
||||
" aaaaaaaaaa",
|
||||
" aaaa"
|
||||
],
|
||||
"canvas": {
|
||||
"width": 60,
|
||||
"height": 11
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"name": "ukraine",
|
||||
"title": {
|
||||
"uk": "Тризуб: малий герб України і дані сервера",
|
||||
"en": "Tryzub: Ukrainian coat of arms and server facts"
|
||||
},
|
||||
"local": false,
|
||||
"vars": {
|
||||
"url": ""
|
||||
},
|
||||
"fields": [
|
||||
{
|
||||
"x": 26,
|
||||
"y": 1,
|
||||
"width": 54,
|
||||
"align": "left",
|
||||
"text": "{hostname}",
|
||||
"style": "e",
|
||||
"font": "small"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 6,
|
||||
"width": 54,
|
||||
"align": "left",
|
||||
"text": "[[Слава Україні!|Glory to Ukraine!]]",
|
||||
"style": "f"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 8,
|
||||
"width": 9,
|
||||
"align": "left",
|
||||
"text": "IP",
|
||||
"style": "f"
|
||||
},
|
||||
{
|
||||
"x": 35,
|
||||
"y": 8,
|
||||
"width": 45,
|
||||
"align": "left",
|
||||
"text": "{ip}",
|
||||
"style": "a"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 9,
|
||||
"width": 9,
|
||||
"align": "left",
|
||||
"text": "[[Сайт|Site]]",
|
||||
"style": "f"
|
||||
},
|
||||
{
|
||||
"x": 35,
|
||||
"y": 9,
|
||||
"width": 45,
|
||||
"align": "left",
|
||||
"text": "{url}",
|
||||
"style": "a"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 10,
|
||||
"width": 9,
|
||||
"align": "left",
|
||||
"text": "[[Працює|Uptime]]",
|
||||
"style": "f"
|
||||
},
|
||||
{
|
||||
"x": 35,
|
||||
"y": 10,
|
||||
"width": 45,
|
||||
"align": "left",
|
||||
"text": "{uptime}",
|
||||
"style": "a"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 11,
|
||||
"width": 9,
|
||||
"align": "left",
|
||||
"text": "[[Пам'ять|Memory]]",
|
||||
"style": "f"
|
||||
},
|
||||
{
|
||||
"x": 35,
|
||||
"y": 11,
|
||||
"width": 45,
|
||||
"align": "left",
|
||||
"text": "{mem}",
|
||||
"style": "a"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 12,
|
||||
"width": 9,
|
||||
"align": "left",
|
||||
"text": "[[ОС|OS]]",
|
||||
"style": "f"
|
||||
},
|
||||
{
|
||||
"x": 35,
|
||||
"y": 12,
|
||||
"width": 45,
|
||||
"align": "left",
|
||||
"text": "{os}",
|
||||
"style": "a"
|
||||
}
|
||||
],
|
||||
"widgets_position": "below",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "disks"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "services"
|
||||
}
|
||||
],
|
||||
"styles": {
|
||||
"a": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"b": {
|
||||
"fg": "blue",
|
||||
"bg": "bright_yellow",
|
||||
"bold": false
|
||||
},
|
||||
"c": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": "blue",
|
||||
"bold": false
|
||||
},
|
||||
"d": {
|
||||
"fg": "blue",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"e": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
},
|
||||
"f": {
|
||||
"fg": "bright_blue",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
}
|
||||
},
|
||||
"lines": [
|
||||
"██████████████████████",
|
||||
"██████████████████████",
|
||||
"████▀████████████▀████",
|
||||
"████▀█▀████████▀█▀████",
|
||||
"██████████████████████",
|
||||
"███████▀██████▀███████",
|
||||
"██████████████████████",
|
||||
"█████▀█▀█▀██▀█▀█▀█████",
|
||||
"██████▀█▀████▀█▀██████",
|
||||
"██████▀███▀▀███▀██████",
|
||||
"████▀▀▀██▀██▀██▀▀▀████",
|
||||
"███▀▀▀▀▀█▀██▀█▀▀▀▀▀███",
|
||||
" ▀██████▀████▀██████▀",
|
||||
" ▀▀▀████▀▀████▀▀▀",
|
||||
" ▀▀██▀▀"
|
||||
],
|
||||
"style_map": [
|
||||
"dddddddddddddddddddddd",
|
||||
"ddddddddddaadddddddddd",
|
||||
"dddabdddddaadddddbaddd",
|
||||
"dddacabdddaadddbacaddd",
|
||||
"dddaddadddaadddaddaddd",
|
||||
"dddaddabddaaddbaddaddd",
|
||||
"dddaddaaddaaddaaddaddd",
|
||||
"dddadbacdbaabdcabdaddd",
|
||||
"dddaaabdbaddabdbaaaddd",
|
||||
"dddaddcaaabbaaacddaddd",
|
||||
"dddabbbaabaabaabbbaddd",
|
||||
"dddcccccacaacacccccddd",
|
||||
" dddddddcaaaacddddddd",
|
||||
" dddddddccddddddd",
|
||||
" dddddd"
|
||||
],
|
||||
"canvas": {
|
||||
"width": 80,
|
||||
"height": 15
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
# dbanner-title-uk: Контейнери Docker
|
||||
# dbanner-title-en: Docker containers
|
||||
# dbanner-mode: live
|
||||
# Running and stopped containers; nothing when Docker is not there or not reachable.
|
||||
. "${0%/*}/lib.sh"
|
||||
command -v docker >/dev/null || exit 0
|
||||
mapfile -t states < <(timeout 1 docker ps -a --format '{{.State}}' 2>/dev/null) || exit 0
|
||||
((${#states[@]})) || exit 0
|
||||
running=0
|
||||
for s in "${states[@]}"; do [[ $s == running ]] && ((running++)); done
|
||||
stopped=$((${#states[@]} - running))
|
||||
color=$Green
|
||||
((stopped)) && color=$Yellow
|
||||
echo "$Bold$(say "Docker:" "Docker:")$Off $color$running$Off $(say "працює" "running"), $stopped $(say "зупинено" "stopped")"
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
# dbanner-title-uk: Заблоковані fail2ban адреси
|
||||
# dbanner-title-en: Addresses banned by fail2ban
|
||||
# dbanner-mode: cached
|
||||
# dbanner-interval: 300
|
||||
# How many addresses fail2ban keeps banned now and in total; needs root.
|
||||
. "${0%/*}/lib.sh"
|
||||
command -v fail2ban-client >/dev/null || exit 0
|
||||
((EUID == 0)) || exit 0
|
||||
out=$(timeout 5 fail2ban-client status sshd 2>/dev/null) || exit 0
|
||||
now=$(awk -F: '/Currently banned/ {gsub(/ /, "", $2); print $2}' <<<"$out")
|
||||
total=$(awk -F: '/Total banned/ {gsub(/ /, "", $2); print $2}' <<<"$out")
|
||||
echo "$Bold$(say "fail2ban (sshd):" "fail2ban (sshd):")$Off $Yellow${now:-0}$Off $(say "заблоковано зараз, усього" "banned now, total") ${total:-0}"
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
# dbanner-title-uk: Попередній вхід
|
||||
# dbanner-title-en: Previous login
|
||||
# dbanner-mode: live
|
||||
# The login before this one of the user who logs in (from last).
|
||||
. "${0%/*}/lib.sh"
|
||||
command -v last >/dev/null || exit 0
|
||||
user=${USER:-$(id -un)}
|
||||
line=$(timeout 1 last -n 2 -w --time-format iso "$user" 2>/dev/null | sed -n 2p)
|
||||
[[ -n $line && $line != wtmp* ]] || exit 0
|
||||
read -r _ tty from when _ <<<"$line"
|
||||
[[ $from == :* || $from =~ ^tty ]] && from=$tty
|
||||
echo "$Bold$(say "Попередній вхід:" "Previous login:")$Off ${when%+*} $(say "з" "from") $Cyan$from$Off"
|
||||
@@ -0,0 +1,14 @@
|
||||
# Shared by the dbanner widget scripts: colors and the user's language.
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034
|
||||
Off=$'\e[0m' Bold=$'\e[1;37m' Red=$'\e[1;31m' Green=$'\e[1;32m' Yellow=$'\e[1;33m' Cyan=$'\e[1;36m' Gray=$'\e[0;90m'
|
||||
|
||||
# say UKRAINIAN ENGLISH
|
||||
say() {
|
||||
if [[ ${DBANNER_LANG:-} == uk ]]; then echo "$1"; else echo "$2"; fi
|
||||
}
|
||||
|
||||
# level VALUE WARN CRIT: green below WARN, yellow below CRIT, red otherwise
|
||||
level() {
|
||||
if (($1 < $2)); then echo -n "$Green"; elif (($1 < $3)); then echo -n "$Yellow"; else echo -n "$Red"; fi
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
# dbanner-title-uk: Служби, що не працюють
|
||||
# dbanner-title-en: Services that are down
|
||||
# dbanner-mode: live
|
||||
# Enabled runit services that are not running; nothing when all of them are up.
|
||||
. "${0%/*}/lib.sh"
|
||||
dir=/var/service
|
||||
[[ -d $dir ]] || exit 0
|
||||
down=()
|
||||
for s in "$dir"/*/; do
|
||||
s=${s%/}
|
||||
[[ -f $s/down ]] && continue
|
||||
state=$(timeout 1 sv status "$s" 2>/dev/null) || state=down
|
||||
[[ $state == run:* ]] || down+=("${s##*/}")
|
||||
done
|
||||
((${#down[@]})) || exit 0
|
||||
echo "$Bold$(say "Не працюють:" "Down:")$Off $Red${down[*]}$Off $(say "(dsv status)" "(dsv status)")"
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
# dbanner-title-uk: Стан дисків (SMART)
|
||||
# dbanner-title-en: Disk health (SMART)
|
||||
# dbanner-mode: cached
|
||||
# dbanner-interval: 21600
|
||||
# Health, temperature and power-on hours of every disk; needs root and smartmontools.
|
||||
. "${0%/*}/lib.sh"
|
||||
command -v smartctl >/dev/null || exit 0
|
||||
((EUID == 0)) || exit 0
|
||||
first=1
|
||||
for dev in /sys/block/*; do
|
||||
name=${dev##*/}
|
||||
[[ $name == @(loop|ram|zram|sr|dm-|md)* ]] && continue
|
||||
out=$(smartctl -H -A -i "/dev/$name" 2>/dev/null) || [[ -n $out ]] || continue
|
||||
model=$(sed -n 's/^\(Device Model\|Model Number\|Product\): *//p' <<<"$out" | head -1)
|
||||
health=$(sed -n 's/.*\(overall-health\|Health Status\).*: *//p' <<<"$out" | head -1)
|
||||
temp=$(awk '/Temperature_Celsius|Airflow_Temperature/ {print $10; exit} /^Temperature:/ {print $2; exit}' <<<"$out")
|
||||
hours=$(awk '/Power_On_Hours/ {print $10; exit} /^Power On Hours:/ {gsub(",", "", $4); print $4; exit}' <<<"$out")
|
||||
[[ -n $health ]] || continue
|
||||
if ((first)); then echo "$Bold$(say "Стан дисків:" "Disk health:")$Off"; first=0; fi
|
||||
hcolor=$Green
|
||||
[[ $health == PASSED || $health == OK ]] || hcolor=$Red
|
||||
line=" /dev/$name ${model:+($model) }$hcolor$health$Off"
|
||||
[[ $temp =~ ^[0-9]+$ ]] && line+=", $(level "$temp" 55 70)$temp °C$Off"
|
||||
[[ $hours =~ ^[0-9]+$ ]] && line+=", $Cyan$hours $(say "год" "h")$Off"
|
||||
echo "$line"
|
||||
done
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
# dbanner-title-uk: Знімки Btrfs (snapper)
|
||||
# dbanner-title-en: Btrfs snapshots (snapper)
|
||||
# dbanner-mode: cached
|
||||
# dbanner-interval: 3600
|
||||
# Number of snapper snapshots of / and the date of the last one; needs root.
|
||||
. "${0%/*}/lib.sh"
|
||||
command -v snapper >/dev/null || exit 0
|
||||
((EUID == 0)) || exit 0
|
||||
mapfile -t rows < <(timeout 10 snapper -c root --csvout list --columns number,date 2>/dev/null | tail -n +3)
|
||||
((${#rows[@]})) || exit 0
|
||||
last=${rows[-1]#*,}
|
||||
echo "$Bold$(say "Знімки:" "Snapshots:")$Off ${#rows[@]}, $(say "останній" "last") $Cyan${last%:*}$Off"
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
# dbanner-title-uk: Доступні оновлення
|
||||
# dbanner-title-en: Available updates
|
||||
# dbanner-mode: cached
|
||||
# dbanner-interval: 21600
|
||||
# Package updates (xbps-install -Mun downloads the repository index, so it runs
|
||||
# from the das-banner service, not at login).
|
||||
. "${0%/*}/lib.sh"
|
||||
command -v xbps-install >/dev/null || exit 0
|
||||
count=$(xbps-install -Mun 2>/dev/null | wc -l) || exit 0
|
||||
if ((count)); then
|
||||
echo "$Bold$(say "Оновлення:" "Updates:")$Off $Yellow$count$Off $(say "пакетів — das-update" "packages — das-update")"
|
||||
else
|
||||
echo "$Bold$(say "Оновлення:" "Updates:")$Off $Green$(say "система оновлена" "up to date")$Off"
|
||||
fi
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
# dbanner-title-uk: Погода (wttr.in)
|
||||
# dbanner-title-en: Weather (wttr.in)
|
||||
# dbanner-mode: cached
|
||||
# dbanner-interval: 1800
|
||||
# Weather from wttr.in for the "city" variable of the banner (DBANNER_CITY).
|
||||
. "${0%/*}/lib.sh"
|
||||
command -v curl >/dev/null || exit 0
|
||||
city=${DBANNER_CITY:-}
|
||||
lang=${DBANNER_LANG:-en}
|
||||
line=$(curl -fsS -m 15 -H "Accept-Language: $lang" "https://wttr.in/${city// /+}?format=%l:+%c+%t+(%f),+%w,+%h&lang=$lang" 2>/dev/null) || exit 0
|
||||
[[ $line == *"Unknown location"* || -z $line ]] && exit 0
|
||||
echo "$Bold$(say "Погода:" "Weather:")$Off $Cyan$line$Off"
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "classic",
|
||||
"title": {"uk": "Класичне Void: [user@host dir]$", "en": "Classic Void: [user@host dir]$"},
|
||||
"frame": "plain",
|
||||
"open": "",
|
||||
"close": "",
|
||||
"sep": "",
|
||||
"segments": [
|
||||
{"type": "text", "text": "["},
|
||||
{"type": "userhost", "fg": null, "at_fg": null, "at_bold": false, "host_fg": null, "host_bold": false, "root_fg": null, "root_bold": false},
|
||||
{"type": "text", "text": " "},
|
||||
{"type": "cwd", "mode": "base", "fg": null},
|
||||
{"type": "text", "text": "]"},
|
||||
{"type": "symbol", "fg": null}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "das-git",
|
||||
"title": {"uk": "Das для розробника: git, venv, фонові задачі", "en": "Das for developers: git, venv, background jobs"},
|
||||
"frame": "das",
|
||||
"frame_fg": "red",
|
||||
"segments": [
|
||||
{"type": "status"},
|
||||
{"type": "userhost"},
|
||||
{"type": "time"},
|
||||
{"type": "cwd"},
|
||||
{"type": "git"},
|
||||
{"type": "venv"},
|
||||
{"type": "jobs"},
|
||||
{"type": "newline"},
|
||||
{"type": "symbol"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "das",
|
||||
"title": {"uk": "Das: дві лінії з датою й часом (стандарт)", "en": "Das: two lines with date and time (default)"},
|
||||
"frame": "das",
|
||||
"frame_fg": "red",
|
||||
"segments": [
|
||||
{"type": "status"},
|
||||
{"type": "userhost"},
|
||||
{"type": "date"},
|
||||
{"type": "time"},
|
||||
{"type": "cwd"},
|
||||
{"type": "newline"},
|
||||
{"type": "symbol"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "minimal",
|
||||
"title": {"uk": "Мінімальне: каталог, git і ❯", "en": "Minimal: directory, git and ❯"},
|
||||
"frame": "plain",
|
||||
"open": "",
|
||||
"close": "",
|
||||
"sep": " ",
|
||||
"segments": [
|
||||
{"type": "cwd", "mode": "short", "fg": "blue", "bold": true},
|
||||
{"type": "git", "fg": "magenta"},
|
||||
{"type": "status", "fg": "red", "bold": true},
|
||||
{"type": "symbol", "char": "❯", "fg": "green", "bold": true}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "server",
|
||||
"title": {"uk": "Сервер: ім'я хоста й ознака SSH помітні", "en": "Server: host name and SSH mark stand out"},
|
||||
"frame": "das",
|
||||
"frame_fg": "blue",
|
||||
"segments": [
|
||||
{"type": "status", "mode": "code", "fg": "bright_red", "bold": true},
|
||||
{"type": "ssh"},
|
||||
{"type": "userhost", "host_fg": "bright_yellow"},
|
||||
{"type": "time"},
|
||||
{"type": "cwd"},
|
||||
{"type": "newline"},
|
||||
{"type": "symbol", "fg": "bright_yellow"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Points das_shell at the source tree and a scratch /etc before it is imported."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
FILES = os.path.dirname(HERE)
|
||||
SCRATCH = tempfile.mkdtemp(prefix="das-shell-test-")
|
||||
|
||||
os.environ.update({
|
||||
"DAS_SHELL_DATA": os.path.join(FILES, "share"),
|
||||
"DAS_SHELL_LIB": FILES,
|
||||
"DAS_SHELL_ETC": os.path.join(SCRATCH, "etc"),
|
||||
"DAS_SHELL_CACHE": os.path.join(SCRATCH, "cache"),
|
||||
"XDG_CONFIG_HOME": os.path.join(SCRATCH, "config"),
|
||||
"LANG": "en_US.UTF-8",
|
||||
})
|
||||
os.environ.pop("LC_ALL", None)
|
||||
os.environ.pop("LC_MESSAGES", None)
|
||||
sys.path.insert(0, os.path.join(FILES, "python"))
|
||||
@@ -0,0 +1,108 @@
|
||||
import os
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from tests.common import SCRATCH
|
||||
|
||||
from das_shell import banner, widgets # noqa: E402
|
||||
|
||||
|
||||
class GridTest(unittest.TestCase):
|
||||
def test_round_trip(self):
|
||||
g = banner.Grid(10, 3)
|
||||
g.box(0, 0, 9, 2, "double", g.style_key("cyan", None, True))
|
||||
g.text(2, 1, "Дас", g.style_key("yellow"))
|
||||
d = g.to_design({"name": "x"})
|
||||
g2 = banner.Grid.from_design(d)
|
||||
self.assertEqual(banner.grid_lines(g2, color=False), ["╔════════╗", "║ Дас ║", "╚════════╝"])
|
||||
self.assertEqual(len(d["styles"]), 2)
|
||||
|
||||
def test_stamp_merges_styles(self):
|
||||
a = banner.Grid(4, 1)
|
||||
a.text(0, 0, "ab", a.style_key("red"))
|
||||
b = banner.Grid(6, 1)
|
||||
b.text(0, 0, "x", b.style_key("blue"))
|
||||
b.stamp(2, 0, a)
|
||||
self.assertEqual(banner.grid_lines(b, color=False), ["x ab"])
|
||||
self.assertEqual(b.style(b.get(2, 0)[1])[0], "red")
|
||||
|
||||
def test_region_and_resize(self):
|
||||
g = banner.Grid(5, 5)
|
||||
g.fill(1, 1, 3, 3, "#", " ")
|
||||
r = g.region(1, 1, 2, 2)
|
||||
self.assertEqual((r.width, r.height), (2, 2))
|
||||
g.resize(2, 2)
|
||||
self.assertEqual(banner.grid_lines(g, color=False), ["", " #"])
|
||||
|
||||
|
||||
class RenderTest(unittest.TestCase):
|
||||
def test_fields_keep_the_frame(self):
|
||||
d = banner.load("das-classic")
|
||||
values = widgets.Values({"title": "T", "url": "https://example.org/"})
|
||||
values["ip"] = "10.0.0.123"
|
||||
lines = banner.render(dict(d, widgets=[]), values, [], color=False).splitlines()
|
||||
self.assertEqual(len({len(line) for line in lines}), 1, lines)
|
||||
self.assertIn("10.0.0.123", lines[2])
|
||||
|
||||
def test_long_value_is_cut(self):
|
||||
self.assertEqual(banner.fit("abcdef", 4, "left"), "abc…")
|
||||
self.assertEqual(banner.fit("ab", 4, "center"), " ab ")
|
||||
|
||||
def test_languages_and_empty_values(self):
|
||||
values = widgets.Values({"url": ""})
|
||||
self.assertEqual(banner.expand("[[Сайт|Site]]: {url}", values), "Site: ")
|
||||
f = {"x": 0, "y": 0, "width": 20, "text": "{x} · {url}"}
|
||||
self.assertEqual(banner.field_lines(f, widgets.Values({"x": "host", "url": ""}))[0].rstrip(), "host")
|
||||
self.assertEqual(banner.expand("{unknown}", values), "{unknown}")
|
||||
|
||||
def test_widgets_right(self):
|
||||
d = {"lines": ["AB", "CD"], "widgets_position": "right"}
|
||||
out = banner.render(d, widgets.Values(), [["w1", "w2", "w3"]], color=False)
|
||||
self.assertEqual(out.splitlines(), ["AB w1", "CD w2", " w3"])
|
||||
|
||||
def test_every_preset_renders(self):
|
||||
for name, _ in banner.list_presets():
|
||||
d = banner.load(name)
|
||||
out = banner.render(d, widgets.Values(d["vars"]), [], color=True)
|
||||
self.assertTrue(out.strip(), name)
|
||||
|
||||
def test_art_pictures(self):
|
||||
names = {n for n, _, _ in banner.list_art()}
|
||||
self.assertTrue({"das-logo", "das-logo-small", "trident", "trident-small", "trident-shield"} <= names)
|
||||
|
||||
|
||||
class WidgetTest(unittest.TestCase):
|
||||
def test_slow_widget_does_not_hold_the_login(self):
|
||||
d = {"widgets": [{"type": "command", "cmd": "sleep 10"}, {"type": "command", "cmd": "echo hi"}]}
|
||||
start = time.monotonic()
|
||||
blocks = widgets.blocks(d, widgets.Values())
|
||||
self.assertLess(time.monotonic() - start, 4)
|
||||
self.assertEqual(blocks, [[], ["hi"]])
|
||||
|
||||
def test_cache(self):
|
||||
w = {"type": "command", "cmd": "echo cached", "mode": "cached", "interval": 1000}
|
||||
d = {"widgets": [w]}
|
||||
self.assertEqual(widgets.blocks(d, widgets.Values()), [[]])
|
||||
widgets.refresh(d)
|
||||
self.assertEqual(widgets.blocks(d, widgets.Values()), [["cached"]])
|
||||
|
||||
def test_file_widget(self):
|
||||
path = os.path.join(SCRATCH, "out.txt")
|
||||
with open(path, "w") as f:
|
||||
f.write("a\nb\n")
|
||||
self.assertEqual(widgets.run({"type": "file", "path": path}, widgets.Values()), ["a", "b"])
|
||||
|
||||
def test_builtins(self):
|
||||
values = widgets.Values()
|
||||
for name in widgets.BUILTINS:
|
||||
widgets.run({"type": "builtin", "name": name}, values)
|
||||
self.assertTrue(values.get("hostname"))
|
||||
self.assertNotEqual(values.get("uptime"), "?")
|
||||
|
||||
def test_vars_reach_scripts(self):
|
||||
d = {"vars": {"city": "Kyiv"}, "widgets": [{"type": "command", "cmd": "echo $DBANNER_CITY"}]}
|
||||
self.assertEqual(widgets.blocks(d, widgets.Values())[0], ["Kyiv"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,86 @@
|
||||
import datetime
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
from tests.common import FILES, SCRATCH
|
||||
|
||||
from das_shell import prompt # noqa: E402
|
||||
|
||||
NOW = datetime.datetime(2026, 10, 21, 8, 12)
|
||||
|
||||
|
||||
def text(design, **state):
|
||||
return prompt.preview_text(design, prompt.sample_state(now=NOW, **state))
|
||||
|
||||
|
||||
class PresetTest(unittest.TestCase):
|
||||
def test_presets_load(self):
|
||||
names = [n for n, _ in prompt.list_presets()]
|
||||
self.assertEqual(names[0], "das")
|
||||
self.assertTrue({"das", "das-git", "classic", "minimal", "server"} <= set(names))
|
||||
|
||||
def test_das_matches_the_authors_prompt(self):
|
||||
d = prompt.load_preset("das")
|
||||
self.assertEqual(text(d), "┌─[das@dasos]─[2026-10-21]─[08:12]─[~/Projects/web]\n└─╼ $ ")
|
||||
self.assertEqual(text(d, rc=1), "┌─[✗]─[das@dasos]─[2026-10-21]─[08:12]─[~/Projects/web]\n└─╼ $ ")
|
||||
self.assertEqual(text(d, root=True), "┌─[root@dasos]─[2026-10-21]─[08:12]─[/etc/sv]\n└─╼ # ")
|
||||
|
||||
def test_classic_is_void(self):
|
||||
self.assertEqual(text(prompt.load_preset("classic")), "[das@dasos web]$ ")
|
||||
|
||||
def test_hidden_parts_take_their_separator(self):
|
||||
d = prompt.load_preset("das-git")
|
||||
self.assertNotIn("──", text(d))
|
||||
self.assertIn("─[main*]─[.venv]─[jobs:2]", text(d, git="main*", venv=".venv", jobs=2))
|
||||
|
||||
def test_unknown_segments_are_dropped(self):
|
||||
d = prompt.normalize({"segments": [{"type": "nope"}, {"type": "cwd"}]})
|
||||
self.assertEqual([s["type"] for s in d["segments"]], ["cwd"])
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("bash"), "needs bash")
|
||||
class BashTest(unittest.TestCase):
|
||||
"""The generated script in a real bash, after Void's skel .bashrc."""
|
||||
|
||||
def run_prompt(self, preset, before="true", term="xterm-256color"):
|
||||
path = os.path.join(SCRATCH, preset + ".sh")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(prompt.script(prompt.load_preset(preset)))
|
||||
code = (f". {FILES}/prompt-lib.sh; . {path}; PS1='[\\u@\\h \\W]\\$ '; "
|
||||
f"{before}; __das_prompt_hook; printf '%s' \"${{PS1@P}}\"")
|
||||
out = subprocess.run(["bash", "--norc", "--noprofile", "-c", code], capture_output=True, text=True,
|
||||
env=dict(os.environ, TERM=term, HOME="/home/das"), cwd="/").stdout
|
||||
for seq in ("\x01", "\x02"):
|
||||
out = out.replace(seq, "")
|
||||
import re
|
||||
return re.sub(r"\x1b\[[0-9;]*m|\x1b\][^\a]*\a", "", out)
|
||||
|
||||
def test_script_is_valid(self):
|
||||
for name, _ in prompt.list_presets():
|
||||
path = os.path.join(SCRATCH, name + ".sh")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(prompt.script(prompt.load_preset(name)))
|
||||
subprocess.run(["bash", "-n", path], check=True)
|
||||
|
||||
def test_skel_ps1_is_replaced(self):
|
||||
out = self.run_prompt("das")
|
||||
self.assertTrue(out.startswith("┌─["), out)
|
||||
self.assertNotIn("[✗]", out)
|
||||
|
||||
def test_error_mark(self):
|
||||
self.assertIn("┌─[✗]─[", self.run_prompt("das", "false"))
|
||||
|
||||
def test_console_glyphs(self):
|
||||
out = self.run_prompt("das", "false", term="linux")
|
||||
self.assertIn("[x]", out)
|
||||
self.assertIn("└─> ", out)
|
||||
|
||||
def test_plain_terminal(self):
|
||||
out = self.run_prompt("das", term="dumb")
|
||||
self.assertTrue(out.startswith("┌─["), out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,57 @@
|
||||
# Template file for 'das-shell'
|
||||
# In-tree package: sources are in files/ (FILESDIR).
|
||||
pkgname=das-shell
|
||||
version=0.1.0
|
||||
revision=1
|
||||
create_wrksrc=yes
|
||||
hostmakedepends="python3"
|
||||
checkdepends="bash"
|
||||
depends="bash python3 python3-textual figlet"
|
||||
short_desc="Das OS terminal: bash prompt, dprompt and dbanner (SSH login banner)"
|
||||
maintainer="Das <anry.das@gmail.com>"
|
||||
license="MIT"
|
||||
homepage="https://git.das.org.ua/das/Das-OS"
|
||||
conf_files="/etc/bash/bashrc.d/das-prompt.sh /etc/profile.d/das-banner.sh"
|
||||
make_dirs="/var/cache/das/banner 0755 root root"
|
||||
pycompile_dirs="usr/lib/das/python"
|
||||
python_version=3
|
||||
|
||||
do_build() {
|
||||
# The stock prompt is the "das" preset turned into bash by the code itself
|
||||
PYTHONPATH="${FILESDIR}/python" DAS_SHELL_DATA="${FILESDIR}/share" \
|
||||
python3 -m das_shell.cli_prompt show das --script >default-prompt.sh
|
||||
}
|
||||
|
||||
do_check() {
|
||||
cd "${FILESDIR}" && PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -t .
|
||||
}
|
||||
|
||||
do_install() {
|
||||
local f
|
||||
vinstall "${FILESDIR}/bashrc.d/das-prompt.sh" 644 etc/bash/bashrc.d
|
||||
vinstall "${FILESDIR}/profile.d/das-banner.sh" 644 etc/profile.d
|
||||
vinstall "${FILESDIR}/prompt-lib.sh" 644 usr/lib/das
|
||||
vinstall default-prompt.sh 644 usr/share/das/prompt default.sh
|
||||
for f in "${FILESDIR}"/python/das_shell/*.py; do
|
||||
vinstall "${f}" 644 usr/lib/das/python/das_shell
|
||||
done
|
||||
for f in "${FILESDIR}"/share/prompt/presets/*.json; do
|
||||
vinstall "${f}" 644 usr/share/das/prompt/presets
|
||||
done
|
||||
for f in "${FILESDIR}"/share/banner/presets/*.json; do
|
||||
vinstall "${f}" 644 usr/share/das/banner/presets
|
||||
done
|
||||
for f in "${FILESDIR}"/share/banner/art/*.json; do
|
||||
vinstall "${f}" 644 usr/share/das/banner/art
|
||||
done
|
||||
for f in "${FILESDIR}"/share/banner/widgets/*.sh; do
|
||||
vinstall "${f}" 755 usr/share/das/banner/widgets
|
||||
done
|
||||
chmod 644 "${DESTDIR}/usr/share/das/banner/widgets/lib.sh"
|
||||
vsv das-banner
|
||||
for f in dprompt dbanner; do
|
||||
vbin "${FILESDIR}/bin/${f}"
|
||||
vcompletion "${FILESDIR}/${f}.bash-completion" bash ${f}
|
||||
done
|
||||
vlicense "${FILESDIR}/LICENSE"
|
||||
}
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
# Regenerates the ready-made dbanner pictures from the SVGs in this directory.
|
||||
# das-logo.svg: das-theme's logo with <use> expanded; trident*.svg: Lesser Coat
|
||||
# of Arms of Ukraine from Wikimedia Commons (public domain, state symbol).
|
||||
set -e
|
||||
cd "$(dirname "$0")/../.."
|
||||
A=srcpkgs/das-shell/files/share/banner/art
|
||||
T=tools/art2cells.py
|
||||
LOGO="#2f6fde=bright_blue #ffd23f=yellow #f5c400=bright_yellow #1f4fa8=blue"
|
||||
python3 $T tools/art/das-logo.svg 16 16 das-logo-small "Логотип Das OS, малий" "Das OS logo, small" $LOGO --mirror >$A/das-logo-small.json
|
||||
python3 $T tools/art/das-logo.svg 24 24 das-logo "Логотип Das OS" "Das OS logo" $LOGO --mirror >$A/das-logo.json
|
||||
python3 $T tools/art/trident.svg 16 22 trident-small "Тризуб, малий" "Tryzub, small" "#ffd500=bright_yellow" --mirror >$A/trident-small.json
|
||||
python3 $T tools/art/trident.svg 20 28 trident "Тризуб" "Tryzub" "#ffd500=bright_yellow" --mirror >$A/trident.json
|
||||
python3 $T tools/art/trident-shield.svg 22 30 trident-shield "Малий герб України (тризуб на щиті)" \
|
||||
"Lesser coat of arms of Ukraine (tryzub on a shield)" "#ffd500=bright_yellow" "#005bbb=blue" --mirror >$A/trident-shield.json
|
||||
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="-100 -100 200 200">
|
||||
<!-- das-logo.svg of das-theme with the <use> copies expanded (ImageMagick MSVG has no <use>) -->
|
||||
<polygon points="0.00,0.00 19.13,-46.19 0.00,-92.39 -19.13,-46.19" fill="#2f6fde"/>
|
||||
<polygon points="0.00,-25.40 8.61,-46.19 0.00,-66.98 -8.61,-46.19" fill="#ffd23f"/>
|
||||
<polygon points="0.00,0.00 46.19,-19.13 65.33,-65.33 19.13,-46.19" fill="#f5c400"/>
|
||||
<polygon points="17.96,-17.96 38.75,-26.57 47.36,-47.36 26.57,-38.75" fill="#1f4fa8"/>
|
||||
<polygon points="0.00,0.00 46.19,19.13 92.39,-0.00 46.19,-19.13" fill="#2f6fde"/>
|
||||
<polygon points="25.40,-0.00 46.19,8.61 66.98,-0.00 46.19,-8.61" fill="#ffd23f"/>
|
||||
<polygon points="-0.00,0.00 19.13,46.19 65.33,65.33 46.19,19.13" fill="#f5c400"/>
|
||||
<polygon points="17.96,17.96 26.57,38.75 47.36,47.36 38.75,26.57" fill="#1f4fa8"/>
|
||||
<polygon points="-0.00,0.00 -19.13,46.19 0.00,92.39 19.13,46.19" fill="#2f6fde"/>
|
||||
<polygon points="0.00,25.40 -8.61,46.19 0.00,66.98 8.61,46.19" fill="#ffd23f"/>
|
||||
<polygon points="0.00,-0.00 -46.19,19.13 -65.33,65.33 -19.13,46.19" fill="#f5c400"/>
|
||||
<polygon points="-17.96,17.96 -38.75,26.57 -47.36,47.36 -26.57,38.75" fill="#1f4fa8"/>
|
||||
<polygon points="0.00,-0.00 -46.19,-19.13 -92.39,0.00 -46.19,19.13" fill="#2f6fde"/>
|
||||
<polygon points="-25.40,0.00 -46.19,-8.61 -66.98,0.00 -46.19,8.61" fill="#ffd23f"/>
|
||||
<polygon points="0.00,0.00 -19.13,-46.19 -65.33,-65.33 -46.19,-19.13" fill="#f5c400"/>
|
||||
<polygon points="-17.96,-17.96 -26.57,-38.75 -47.36,-47.36 -38.75,-26.57" fill="#1f4fa8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="330" height="461" viewBox="-82.5 0 165 230.5" fill="#ffd500">
|
||||
<path d="M-81.25 1.25h162.5v172.5a31.25 31.25 0 0 1-18.578029 28.565428L0 228.867475l-62.671971-27.802047A31.25 31.25 0 0 1-81.25 172.5z" fill="#005bbb"/>
|
||||
<path d="M5.985561 78.82382a104.079383 104.079383 0 0 0 14.053598 56.017033 55 55 0 0 1-13.218774 70.637179A20 20 0 0 0 0 212.5a20 20 0 0 0-6.820384-7.021968 55 55 0 0 1-13.218774-70.637179A104.079383 104.079383 0 0 0-5.98556 78.82382l-1.599642-45.260519A30.103986 30.103986 0 0 1 0 12.5a30.103986 30.103986 0 0 1 7.585202 21.063301zM5 193.624749a45 45 0 0 0 6.395675-53.75496A114.079383 114.079383 0 0 1 0 112.734179a114.079383 114.079383 0 0 1-11.395675 27.13561A45 45 0 0 0-5 193.624749V162.5H5z"/>
|
||||
<path id="a" d="M27.779818 75.17546A62.64982 62.64982 0 0 1 60 27.5v145H0l-5-10a22.5 22.5 0 0 1 17.560976-21.95122l14.634147-3.292683a10 10 0 1 0-4.427443-19.503751zm5.998315 34.353887a20 20 0 0 1-4.387889 37.482848l-14.634146 3.292683A12.5 12.5 0 0 0 5 162.5h45V48.265462a52.64982 52.64982 0 0 0-12.283879 28.037802zM42 122.5h10v10H42z"/>
|
||||
<use xlink:href="#a" transform="scale(-1 1)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="330" height="461" viewBox="-82.5 0 165 230.5" fill="#ffd500">
|
||||
|
||||
<path d="M5.985561 78.82382a104.079383 104.079383 0 0 0 14.053598 56.017033 55 55 0 0 1-13.218774 70.637179A20 20 0 0 0 0 212.5a20 20 0 0 0-6.820384-7.021968 55 55 0 0 1-13.218774-70.637179A104.079383 104.079383 0 0 0-5.98556 78.82382l-1.599642-45.260519A30.103986 30.103986 0 0 1 0 12.5a30.103986 30.103986 0 0 1 7.585202 21.063301zM5 193.624749a45 45 0 0 0 6.395675-53.75496A114.079383 114.079383 0 0 1 0 112.734179a114.079383 114.079383 0 0 1-11.395675 27.13561A45 45 0 0 0-5 193.624749V162.5H5z"/>
|
||||
<path id="a" d="M27.779818 75.17546A62.64982 62.64982 0 0 1 60 27.5v145H0l-5-10a22.5 22.5 0 0 1 17.560976-21.95122l14.634147-3.292683a10 10 0 1 0-4.427443-19.503751zm5.998315 34.353887a20 20 0 0 1-4.387889 37.482848l-14.634146 3.292683A12.5 12.5 0 0 0 5 162.5h45V48.265462a52.64982 52.64982 0 0 0-12.283879 28.037802zM42 122.5h10v10H42z"/>
|
||||
<use xlink:href="#a" transform="scale(-1 1)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
Executable
+101
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Turns an SVG into a dbanner picture: half-block cells (▀ ▄ █) in ANSI colors.
|
||||
|
||||
tools/art2cells.py SVG WIDTH_PX HEIGHT_PX NAME "UK title" "EN title" \
|
||||
"#rrggbb=ansi_name" ... [--mirror] > srcpkgs/das-shell/files/share/banner/art/NAME.json
|
||||
|
||||
Each output cell holds two square pixels, so HEIGHT_PX must be even. The SVG is
|
||||
drawn 8 times larger by ImageMagick, and every pixel takes the color that
|
||||
covers most of its 8x8 block (or stays empty). Colors are matched to the
|
||||
nearest one given on the command line; --mirror copies the left half to the
|
||||
right for symmetric pictures. Needs ImageMagick and Pillow; this is a
|
||||
developer tool, the package ships only the JSON.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from collections import Counter
|
||||
|
||||
from PIL import Image
|
||||
|
||||
SCALE = 8
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
mirror = "--mirror" in args
|
||||
if mirror:
|
||||
args.remove("--mirror")
|
||||
svg, width, height, name, uk, en, *pairs = args
|
||||
width, height = int(width), int(height)
|
||||
palette = []
|
||||
for pair in pairs:
|
||||
hexcolor, ansi = pair.split("=")
|
||||
palette.append((tuple(int(hexcolor[i:i + 2], 16) for i in (1, 3, 5)), ansi))
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
png = os.path.join(tmp, "art.png")
|
||||
subprocess.run(["convert", "-background", "none", "-density", "600", svg,
|
||||
"-resize", f"{width * SCALE}x{height * SCALE}!", png], check=True)
|
||||
img = Image.open(png).convert("RGBA")
|
||||
|
||||
def nearest(rgb):
|
||||
return min(palette, key=lambda p: sum((a - b) ** 2 for a, b in zip(p[0], rgb)))[1]
|
||||
|
||||
pixels = []
|
||||
for py in range(height):
|
||||
row = []
|
||||
for px in range(width):
|
||||
votes = Counter()
|
||||
for y in range(py * SCALE, (py + 1) * SCALE):
|
||||
for x in range(px * SCALE, (px + 1) * SCALE):
|
||||
r, g, b, a = img.getpixel((x, y))
|
||||
votes[nearest((r, g, b)) if a >= 128 else None] += 1
|
||||
row.append(votes.most_common(1)[0][0])
|
||||
if mirror:
|
||||
# The source is symmetric; rounding is not, so copy the left half
|
||||
for x in range(width // 2):
|
||||
row[width - 1 - x] = row[x]
|
||||
pixels.append(row)
|
||||
|
||||
styles, keys = {}, "abcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
def key(fg, bg):
|
||||
want = {"fg": fg, "bg": bg, "bold": False}
|
||||
for k, v in styles.items():
|
||||
if v == want:
|
||||
return k
|
||||
k = keys[len(styles)]
|
||||
styles[k] = want
|
||||
return k
|
||||
|
||||
lines, smap = [], []
|
||||
for cy in range(height // 2):
|
||||
line, keyline = "", ""
|
||||
for x in range(width):
|
||||
top, bottom = pixels[2 * cy][x], pixels[2 * cy + 1][x]
|
||||
if top is None and bottom is None:
|
||||
ch, k = " ", " "
|
||||
elif top == bottom:
|
||||
ch, k = "█", key(top, None)
|
||||
elif bottom is None:
|
||||
ch, k = "▀", key(top, None)
|
||||
elif top is None:
|
||||
ch, k = "▄", key(bottom, None)
|
||||
else:
|
||||
ch, k = "▀", key(top, bottom)
|
||||
line += ch
|
||||
keyline += k
|
||||
lines.append(line.rstrip())
|
||||
smap.append(keyline[:len(line.rstrip())])
|
||||
design = {"name": name, "title": {"uk": uk, "en": en}, "styles": styles,
|
||||
"lines": lines, "style_map": smap}
|
||||
json.dump(design, sys.stdout, ensure_ascii=False, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Writes the ready-made dbanner designs to srcpkgs/das-shell/files/share/banner/presets.
|
||||
|
||||
The designs are drawn with the same Grid that the editor uses, so they stay
|
||||
editable in dbanner. Run after changing this file or the pictures in
|
||||
share/banner/art (tools/art/build.sh).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
FILES = os.path.join(ROOT, "srcpkgs", "das-shell", "files")
|
||||
sys.path.insert(0, os.path.join(FILES, "python"))
|
||||
|
||||
from das_shell import banner # noqa: E402
|
||||
|
||||
OUT = os.path.join(FILES, "share", "banner", "presets")
|
||||
|
||||
|
||||
def art(name):
|
||||
return banner.Grid.from_design(banner.load_file(os.path.join(FILES, "share", "banner", "art", name + ".json")))
|
||||
|
||||
|
||||
def field(grid, x, y, width, text, fg=None, bold=False, bg=None, align="left", font=None):
|
||||
f = {"x": x, "y": y, "width": width, "align": align, "text": text,
|
||||
"style": grid.style_key(fg, bg, bold)}
|
||||
if font:
|
||||
f["font"] = font
|
||||
return f
|
||||
|
||||
|
||||
def builtin(name, **extra):
|
||||
return dict({"type": "builtin", "name": name}, **extra)
|
||||
|
||||
|
||||
def save(name, uk, en, grid, fields, widgets, vars_=None, position="below"):
|
||||
d = {"name": name, "title": {"uk": uk, "en": en}, "local": False, "vars": vars_ or {},
|
||||
"fields": fields, "widgets_position": position, "widgets": widgets}
|
||||
d = grid.to_design(d)
|
||||
banner.save(d, os.path.join(OUT, name + ".json"))
|
||||
|
||||
|
||||
def das_classic():
|
||||
# The frame of the banner on the author's home server
|
||||
g = banner.Grid(39, 5)
|
||||
frame = g.style_key("blue")
|
||||
g.box(0, 0, 38, 4, "hash", frame)
|
||||
red = g.style_key("red", None, True)
|
||||
g.text(7, 2, "Server IP:", red)
|
||||
g.text(7, 3, "Link:", red)
|
||||
fields = [
|
||||
field(g, 2, 1, 35, "-=:dAs:=- {title}", "white", True, align="center"),
|
||||
field(g, 18, 2, 19, "{ip}", "cyan", True),
|
||||
field(g, 13, 3, 24, "{url}", "cyan", True),
|
||||
]
|
||||
widgets = [builtin("uptime"), builtin("disks"), builtin("memory"), builtin("docker"),
|
||||
builtin("services"), builtin("updates")]
|
||||
save("das-classic", "Das класичний: рамка з #, назва, IP, посилання", "Das classic: # frame, name, IP, link",
|
||||
g, fields, widgets, {"title": "Das OS", "url": "https://das.org.ua/"})
|
||||
|
||||
|
||||
def double():
|
||||
w = 64
|
||||
g = banner.Grid(w, 10)
|
||||
cyan = g.style_key("cyan", None, True)
|
||||
g.box(0, 0, w - 1, 9, "double", cyan)
|
||||
g.text(1, 7, "─" * (w - 2), g.style_key("bright_black"))
|
||||
fields = [
|
||||
field(g, 2, 1, w - 4, "{hostname}", "bright_white", True, align="center", font="small"),
|
||||
field(g, 2, 5, w - 4, "{title}", "yellow", True, align="center"),
|
||||
field(g, 2, 6, w - 4, "{url}", "cyan", align="center"),
|
||||
field(g, 2, 8, w - 4, "IP {ip} · {os} · {kernel}", "white", align="center"),
|
||||
]
|
||||
widgets = [builtin("uptime"), builtin("load"), builtin("memory"), builtin("disks"), builtin("services")]
|
||||
save("double", "Подвійна рамка з великим ім'ям сервера", "Double frame with the server name in big letters",
|
||||
g, fields, widgets, {"title": "Das OS Server", "url": ""})
|
||||
|
||||
|
||||
def info_column(g, x, y, width, label_fg, value_fg, rows):
|
||||
fields = []
|
||||
for i, (label, value) in enumerate(rows):
|
||||
if label is None:
|
||||
fields.append(field(g, x, y + i, width, value, label_fg))
|
||||
continue
|
||||
fields.append(field(g, x, y + i, 9, label, label_fg, True))
|
||||
fields.append(field(g, x + 9, y + i, width - 9, value, value_fg))
|
||||
return fields
|
||||
|
||||
|
||||
def blocks():
|
||||
logo = art("das-logo")
|
||||
g = banner.Grid(76, max(logo.height, 12))
|
||||
g.stamp(0, 0, logo)
|
||||
x = logo.width + 4
|
||||
fields = [field(g, x, 0, 76 - x, "{user}@{hostname}", "bright_cyan", True)]
|
||||
fields += info_column(g, x, 1, 76 - x, "bright_blue", "white", [
|
||||
(None, "─" * 30),
|
||||
("[[ОС|OS]]", "{os}"),
|
||||
("[[Ядро|Kernel]]", "{kernel}"),
|
||||
("[[Працює|Uptime]]", "{uptime}"),
|
||||
("CPU", "{cpu}"),
|
||||
("[[Пам'ять|Memory]]", "{mem}"),
|
||||
("IP", "{ips}"),
|
||||
("[[Вхід|Users]]", "{users}"),
|
||||
])
|
||||
fields.append(field(g, x, 10, 76 - x, "{url}", "yellow"))
|
||||
save("blocks", "Логотип Das OS і відомості про систему", "Das OS logo and system facts",
|
||||
g, fields, [builtin("disks")], {"url": ""})
|
||||
|
||||
|
||||
def dashboard():
|
||||
w = 70
|
||||
g = banner.Grid(w, 3)
|
||||
g.box(0, 0, w - 1, 2, "rounded", g.style_key("bright_blue"))
|
||||
fields = [field(g, 2, 1, w - 4, "{hostname} · {ip} · {url}", "bright_white", True, align="center")]
|
||||
widgets = [builtin(n) for n in ("uptime", "load", "memory", "disks", "network", "docker", "updates",
|
||||
"weather", "smart", "services", "fail2ban", "snapshots", "lastlog")]
|
||||
save("dashboard", "Панель: усі віджети під заголовком", "Dashboard: every widget under a header",
|
||||
g, fields, widgets, {"url": "", "city": "Kyiv"})
|
||||
|
||||
|
||||
def minimal():
|
||||
g = banner.Grid(70, 1)
|
||||
fields = [field(g, 0, 0, 70, "{hostname} · {ip} · [[працює|up]] {uptime} · {load}", "bright_cyan", True)]
|
||||
save("minimal", "Мінімальний: один рядок", "Minimal: one line", g, fields, [])
|
||||
|
||||
|
||||
def ukraine():
|
||||
shield = art("trident-shield")
|
||||
g = banner.Grid(80, shield.height)
|
||||
g.stamp(0, 0, shield)
|
||||
x = shield.width + 4
|
||||
blue, yellow = "bright_blue", "bright_yellow"
|
||||
fields = [
|
||||
field(g, x, 1, 80 - x, "{hostname}", yellow, True, font="small"),
|
||||
field(g, x, 6, 80 - x, "[[Слава Україні!|Glory to Ukraine!]]", blue, True),
|
||||
]
|
||||
fields += info_column(g, x, 8, 80 - x, blue, yellow, [
|
||||
("IP", "{ip}"),
|
||||
("[[Сайт|Site]]", "{url}"),
|
||||
("[[Працює|Uptime]]", "{uptime}"),
|
||||
("[[Пам'ять|Memory]]", "{mem}"),
|
||||
("[[ОС|OS]]", "{os}"),
|
||||
])
|
||||
save("ukraine", "Тризуб: малий герб України і дані сервера", "Tryzub: Ukrainian coat of arms and server facts",
|
||||
g, fields, [builtin("disks"), builtin("services")], {"url": ""})
|
||||
|
||||
|
||||
def tryzub_small():
|
||||
# A compact one for small screens: the small tryzub, a few lines of text
|
||||
tz = art("trident-small")
|
||||
g = banner.Grid(60, tz.height)
|
||||
g.stamp(0, 0, tz)
|
||||
x = tz.width + 3
|
||||
fields = [
|
||||
field(g, x, 2, 60 - x, "{hostname}", "bright_yellow", True),
|
||||
field(g, x, 3, 60 - x, "{ip}", "bright_blue", True),
|
||||
field(g, x, 5, 60 - x, "[[Працює|Uptime]]: {uptime}", "white"),
|
||||
field(g, x, 6, 60 - x, "{url}", "bright_blue"),
|
||||
]
|
||||
save("tryzub", "Малий тризуб з іменем і IP", "Small tryzub with name and IP", g, fields,
|
||||
[builtin("disks")], {"url": ""})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
for make in (das_classic, double, blocks, dashboard, minimal, ukraine, tryzub_small):
|
||||
make()
|
||||
Reference in New Issue
Block a user