Terminal: Das prompt, dprompt and dbanner (0.9.1)
build / build (push) Successful in 38m26s

- das-shell 0.1.0: the Das two-line bash prompt by default (replaces the
  PS1 of Void's skel .bashrc once), dprompt (Textual prompt builder with
  presets and live preview) and dbanner (SSH login banner editor: canvas
  tools, fields with values, widgets, das-banner service for cached ones,
  Das OS logo and tryzub pictures). Python tests in make test.
- das-server 0.3.0 depends on das-shell; the server installer mentions
  dprompt and dbanner; the desktop image includes it.
- run-qemu.sh forwards the guest's SSH to localhost:2222.
- Hardware fixes: Ventoy menu icon, boot menu titles "Das OS <version>".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
das
2026-10-21 09:17:10 +03:00
co-authored by Claude Opus 5.5
parent 66ea614e0d
commit a2bffc7fe9
72 changed files with 5506 additions and 23 deletions
+4
View File
@@ -6,3 +6,7 @@ srcpkgs/das-theme/files/xfwm4gen/xfwm4gen
# IDE
.idea/
# Python
.venv/
__pycache__/
+2 -1
View File
@@ -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:
+21 -7
View File
@@ -1,7 +1,7 @@
## Das OS
### _A light, friendly Void Linux–based desktop with Xfce and a Ukrainian touch, plus a console server edition_
[![Void Linux](https://img.shields.io/badge/Void_Linux-x86__64_glibc-green)](https://voidlinux.org) [![Xfce](https://img.shields.io/badge/Xfce-4.20-orange)](https://xfce.org) [![runit](https://img.shields.io/badge/init-runit-blue)](https://docs.voidlinux.org/config/services/) [![Go](https://img.shields.io/badge/Go-1.24+-blue)](https://go.dev) [![Docker](https://img.shields.io/badge/Docker-29-blue)](https://www.docker.com) [![Version](https://img.shields.io/badge/version-0.8.0-lightgrey)](#)
[![Void Linux](https://img.shields.io/badge/Void_Linux-x86__64_glibc-green)](https://voidlinux.org) [![Xfce](https://img.shields.io/badge/Xfce-4.20-orange)](https://xfce.org) [![runit](https://img.shields.io/badge/init-runit-blue)](https://docs.voidlinux.org/config/services/) [![Go](https://img.shields.io/badge/Go-1.24+-blue)](https://go.dev) [![Docker](https://img.shields.io/badge/Docker-29-blue)](https://www.docker.com) [![Python](https://img.shields.io/badge/Python-3-blue)](https://www.python.org) [![Textual](https://img.shields.io/badge/Textual-8.2-blue)](https://textual.textualize.io) [![Version](https://img.shields.io/badge/version-0.9.1-lightgrey)](#)
🇺🇦 [Українська версія](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
View File
@@ -1,7 +1,7 @@
## Das OS
### _Легкий і дружній десктоп на базі Void Linux з Xfce та українським колоритом, а також консольна серверна версія_
[![Void Linux](https://img.shields.io/badge/Void_Linux-x86__64_glibc-green)](https://voidlinux.org) [![Xfce](https://img.shields.io/badge/Xfce-4.20-orange)](https://xfce.org) [![runit](https://img.shields.io/badge/init-runit-blue)](https://docs.voidlinux.org/config/services/) [![Go](https://img.shields.io/badge/Go-1.24+-blue)](https://go.dev) [![Docker](https://img.shields.io/badge/Docker-29-blue)](https://www.docker.com) [![Version](https://img.shields.io/badge/version-0.8.0-lightgrey)](#)
[![Void Linux](https://img.shields.io/badge/Void_Linux-x86__64_glibc-green)](https://voidlinux.org) [![Xfce](https://img.shields.io/badge/Xfce-4.20-orange)](https://xfce.org) [![runit](https://img.shields.io/badge/init-runit-blue)](https://docs.voidlinux.org/config/services/) [![Go](https://img.shields.io/badge/Go-1.24+-blue)](https://go.dev) [![Docker](https://img.shields.io/badge/Docker-29-blue)](https://www.docker.com) [![Python](https://img.shields.io/badge/Python-3-blue)](https://www.python.org) [![Textual](https://img.shields.io/badge/Textual-8.2-blue)](https://textual.textualize.io) [![Version](https://img.shields.io/badge/version-0.9.1-lightgrey)](#)
🇬🇧 [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:=-_
+8
View File
@@ -69,6 +69,14 @@ Live ISO без графіки з консольним інсталятором:
- [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`, ключ поза репозиторієм.
- [ ] Хостинг репозиторію (власний сервер або GitHub Releases/Pages).
+1 -1
View File
@@ -1 +1 @@
0.8.0
0.9.1
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

+1
View File
@@ -67,4 +67,5 @@ das-snapshots
das-theme
das-tools
das-helpers
das-shell
xtools-minimal
+12 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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"
+2 -2
View File
@@ -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"
+21
View File
@@ -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
+9
View File
@@ -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())
+9
View File
@@ -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())
+4
View File
@@ -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
+53
View File
@@ -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
View File
@@ -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")"
+13
View File
@@ -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
View File
@@ -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
}
+17
View File
@@ -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
View File
@@ -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
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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"}
]
}
+21
View File
@@ -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()
+57
View File
@@ -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"
}
+15
View File
@@ -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
+19
View File
@@ -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

+6
View File
@@ -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

+6
View File
@@ -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

+101
View File
@@ -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()
+169
View File
@@ -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()