Stage 5: das-update and das-welcome (0.3.0)
das-tools (Go, GTK 3 via gotk3): - das-update: update window, tray icon when updates are available, --cli mode; xbps-install -Su between a snapper pre/post snapshot pair, privileged part through pkexec, rollback and reboot hints - das-welcome: first-login window with install (live), language, home folder renaming, updates, settings and help das-settings 0.3.0: create home folders with translated names. xdg-user-dirs 0.20 only looks for translations in the first XDG_DATA_DIRS entry with a locale directory (/usr/local/share/locale). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ qemu-disk:
|
||||
|
||||
test:
|
||||
cd srcpkgs/das-theme/files/xfwm4gen && go test ./...
|
||||
cd srcpkgs/das-tools/files/src && go test ./...
|
||||
|
||||
# Build output may be owned by root when a build was interrupted, so remove it from the container
|
||||
clean:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
## Das OS
|
||||
### _A light, friendly Void Linux–based desktop with Xfce and a Ukrainian touch_
|
||||
|
||||
[](https://voidlinux.org) [](https://xfce.org) [](https://docs.voidlinux.org/config/services/) [](https://go.dev) [](#)
|
||||
[](https://voidlinux.org) [](https://xfce.org) [](https://docs.voidlinux.org/config/services/) [](https://go.dev) [](#)
|
||||
|
||||
🇺🇦 [Українська версія](README_ua.md)
|
||||
|
||||
@@ -12,6 +12,8 @@ Void Linux is fast, simple and independent: runit instead of systemd, and its ow
|
||||
- [Live ISO](#LiveISO) that boots on UEFI and BIOS straight into the desktop
|
||||
- [Graphical installer](#Installer) (Calamares) set up for Void and runit
|
||||
- [Snapshots and rollback](#Snapshots): hourly Btrfs snapshots, bootable from the GRUB menu
|
||||
- [System update](#Update) with a snapshot taken before it: a window, a tray icon and a terminal mode
|
||||
- [Welcome window](#Welcome) at the first login: language, home folders, updates and help
|
||||
- [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
|
||||
- [Own package repository](#Repository) built by xbps-src from in-tree templates
|
||||
@@ -19,7 +21,7 @@ Void Linux is fast, simple and independent: runit instead of systemd, and its ow
|
||||
|
||||
### ⭐ Advantages
|
||||
- **Void underneath** – a fast runit boot, the xbps package manager and fresh software, with none of the manual setup.
|
||||
- **Safe updates** – the system can return to any snapshot, and personal files in `/home` are never part of a rollback.
|
||||
- **Safe updates** – `das-update` takes a snapshot before every update, the system can return to any snapshot, and personal files in `/home` are never part of a rollback.
|
||||
- **Light** – no systemd, plus Xfce, LightDM and PipeWire, so it runs well on older laptops too.
|
||||
- **Nothing hidden** – all customisations ship as ordinary xbps packages, so `xbps-query -f das-settings` shows exactly what changed.
|
||||
- **Users keep control** – defaults live in `/etc/xdg/das`, so a user's own settings always take precedence and upgrades never reset them.
|
||||
@@ -57,6 +59,16 @@ Void Linux is fast, simple and independent: runit instead of systemd, and its ow
|
||||
<td align="center"><i>Disk setup with Btrfs</i></td>
|
||||
<td align="center"><i>Snapshots in the boot menu</i></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="images/welcome.png" width="300" alt="Welcome window" /></td>
|
||||
<td align="center"><img src="images/update.png" width="300" alt="System update" /></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><i>Welcome window</i></td>
|
||||
<td align="center"><i>System update</i></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 📌 Using
|
||||
@@ -89,6 +101,23 @@ Void Linux is fast, simple and independent: runit instead of systemd, and its ow
|
||||
- `sudo snapper-rollback <number>` makes the snapshot the new root (the old one is kept as `@<date>`). The change takes effect after a reboot. It works both from the running system and from a booted snapshot.
|
||||
- Members of `wheel` can list snapshots without sudo: `snapper list`.
|
||||
|
||||
#### System update<a id='Update' />
|
||||
**_`das-update` installs updates between a pair of snapshots, so a bad update can be undone from the boot menu._**
|
||||
- Before updating, it creates a snapper *pre* snapshot, then runs `xbps-install -Su` (updating xbps itself first when needed), then creates the paired *post* snapshot, also when the update failed.
|
||||
- If the *pre* snapshot cannot be created, nothing is installed. Without Btrfs (ext4, XFS) it updates without a snapshot and says so.
|
||||
- When it finishes, it shows the snapshot number and how to roll back to it, and asks for a reboot when the kernel, glibc, runit or other core packages were updated.
|
||||
- The *System update* window lists the packages with the installed and new versions and shows the xbps output. The privileged part runs through pkexec (polkit action `ua.org.das.update`).
|
||||
- In the background it checks for updates 2 minutes after login and then every 6 hours, without root. The tray icon and a notification appear only when updates are available. A click opens the window, and a right click opens a menu with *Check now*. The check is off in the live session.
|
||||
- `das-update --cli` does the same in a terminal (servers, ssh). It asks before installing (`-y` skips the question) and runs itself again through sudo.
|
||||
|
||||
#### Welcome window<a id='Welcome' />
|
||||
**_`das-welcome` opens at the first login and gathers the first steps in one place._**
|
||||
- In the live session it offers to install Das OS.
|
||||
- The interface language can be chosen when the system has more than one locale built. It is stored in AccountsService and `~/.dmrc` and takes effect at the next login.
|
||||
- If the home folders still have English names (`Documents`, `Downloads`…), it offers to rename them to the system language, together with `user-dirs.dirs` and the file manager bookmarks. Folders the user moved, and those whose new name is already taken, are left alone.
|
||||
- It links to the system update, Xfce settings and help.
|
||||
- It opens by itself only once. The *Show at login* check box keeps it opening at every login.
|
||||
|
||||
#### Das OS look<a id='Look' />
|
||||
**_A modern minimal style with a blue and yellow eight-petal star from Ukrainian embroidery._**
|
||||
- The GTK themes `DasOS` and `DasOS-Dark` build on adw-gtk3 and add the Das OS accent colours. They also style the Xfce panel, which adw-gtk3 leaves black.
|
||||
@@ -102,11 +131,13 @@ Void Linux is fast, simple and independent: runit instead of systemd, and its ow
|
||||
- One bottom panel holds the Whisker menu, window buttons, tray, keyboard layout, volume, notifications, clock and a show-desktop button.
|
||||
- At the first login, `das-session-init` sets the wallpaper on every connected monitor, because Xfce stores wallpapers per monitor name.
|
||||
- LightDM uses the GTK greeter with the Das OS theme and wallpaper.
|
||||
- Home folders get names in the session language. xdg-user-dirs 0.20 looks for its translations only in the first `XDG_DATA_DIRS` entry that has a `locale` directory, the empty `/usr/local/share/locale`, so it created English folders. `/etc/X11/xinit/xinitrc.d/50-das-user-dirs` creates them before the Xfce session with a lookup path that finds the translations.
|
||||
|
||||
#### Own package repository<a id='Repository' />
|
||||
**_Every Das OS file is installed and updated by xbps._**
|
||||
- `srcpkgs/das-theme` contains the themes, xfwm4 frames, wallpapers and logo.
|
||||
- `srcpkgs/das-settings` contains the Xfce, LightDM and session defaults.
|
||||
- `srcpkgs/das-tools` contains `das-update` and `das-welcome` (Go, GTK 3 through gotk3) with their menu entries, autostart entries and polkit action.
|
||||
- `srcpkgs/das-snapshots` contains the snapper configuration, `das-snapper-setup` and the dracut module for booting snapshots.
|
||||
- `srcpkgs/das-installer` contains the Calamares settings, branding and installation scripts. It exists only on the live ISO.
|
||||
- `srcpkgs/calamares`, `srcpkgs/adw-gtk3` and `srcpkgs/font-jetbrains-mono` package upstream releases that Void does not have.
|
||||
@@ -119,7 +150,7 @@ Void Linux is fast, simple and independent: runit instead of systemd, and its ow
|
||||
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.
|
||||
|
||||
### 🚀 Building the application
|
||||
Requirements: Linux with Docker (the user must be in the `docker` group), QEMU and OVMF for testing, and Go 1.22+ 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 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
|
||||
@@ -128,6 +159,6 @@ make clean # remove build output, keep the download cache
|
||||
```
|
||||
The ISO appears in `out/iso/dasos-<version>-x86_64.iso`. Write it to a USB stick with `dd` or Ventoy.
|
||||
|
||||
**Note:** Das OS is an early version (0.2.0). The Go utilities `das-welcome` and `das-update` (an update with a snapshot taken before it), the developer profile and signed repository hosting are planned for upcoming stages. 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.3.0). A server image, the developer profile and signed repository hosting are planned for upcoming stages. 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:=-_
|
||||
|
||||
+35
-4
@@ -1,7 +1,7 @@
|
||||
## Das OS
|
||||
### _Легкий і дружній десктоп на базі Void Linux з Xfce та українським колоритом_
|
||||
|
||||
[](https://voidlinux.org) [](https://xfce.org) [](https://docs.voidlinux.org/config/services/) [](https://go.dev) [](#)
|
||||
[](https://voidlinux.org) [](https://xfce.org) [](https://docs.voidlinux.org/config/services/) [](https://go.dev) [](#)
|
||||
|
||||
🇬🇧 [English version](README.md)
|
||||
|
||||
@@ -12,6 +12,8 @@ Void Linux швидкий, простий і незалежний: runit зам
|
||||
- [Live ISO](#LiveISO), що завантажується з UEFI і BIOS одразу в робочий стіл
|
||||
- [Графічний інсталятор](#Installer) Calamares, налаштований під Void і runit
|
||||
- [Знімки й відкат](#Snapshots): щогодинні знімки Btrfs, які можна завантажити з меню GRUB
|
||||
- [Оновлення системи](#Update) зі знімком перед ним: вікно, значок у треї та консольний режим
|
||||
- [Вікно привітання](#Welcome) під час першого входу: мова, теки, оновлення та довідка
|
||||
- [Оформлення Das OS](#Look): світла й темна теми, згенеровані рамки вікон, шпалери та логотип
|
||||
- [Налаштування за замовчуванням](#Defaults) для Xfce, які не перезаписують стандартні файли
|
||||
- [Власний репозиторій пакетів](#Repository), який xbps-src збирає з шаблонів у дереві проєкту
|
||||
@@ -19,7 +21,7 @@ Void Linux швидкий, простий і незалежний: runit зам
|
||||
|
||||
### ⭐ Переваги
|
||||
- **Void усередині** – швидке завантаження з runit, менеджер пакетів xbps і свіжі програми без ручного налаштування.
|
||||
- **Безпечні оновлення** – систему можна повернути до будь-якого знімка, а особисті файли в `/home` відкат не зачіпає.
|
||||
- **Безпечні оновлення** – `das-update` робить знімок перед кожним оновленням, систему можна повернути до будь-якого знімка, а особисті файли в `/home` відкат не зачіпає.
|
||||
- **Легкість** – без systemd, а з Xfce, LightDM і PipeWire система добре працює навіть на старих ноутбуках.
|
||||
- **Нічого прихованого** – усі зміни постачаються звичайними xbps-пакетами, і `xbps-query -f das-settings` показує, що саме змінено.
|
||||
- **Контроль у користувача** – налаштування за замовчуванням лежать у `/etc/xdg/das`, тож власні налаштування користувача завжди мають пріоритет і не скидаються після оновлень.
|
||||
@@ -57,6 +59,16 @@ Void Linux швидкий, простий і незалежний: runit зам
|
||||
<td align="center"><i>Розмітка диска з Btrfs</i></td>
|
||||
<td align="center"><i>Знімки в меню завантаження</i></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="images/welcome.png" width="300" alt="Вікно привітання" /></td>
|
||||
<td align="center"><img src="images/update.png" width="300" alt="Оновлення системи" /></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><i>Вікно привітання</i></td>
|
||||
<td align="center"><i>Оновлення системи</i></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 📌 Використання
|
||||
@@ -89,6 +101,23 @@ Void Linux швидкий, простий і незалежний: runit зам
|
||||
- `sudo snapper-rollback <номер>` робить знімок новим коренем (старий зберігається як `@<дата>`). Зміна набуває чинності після перезавантаження. Команда працює і з робочої системи, і із завантаженого знімка.
|
||||
- Учасники групи `wheel` бачать знімки без sudo: `snapper list`.
|
||||
|
||||
#### Оновлення системи<a id='Update' />
|
||||
**_`das-update` встановлює оновлення між парою знімків, тож невдале оновлення можна скасувати з меню завантаження._**
|
||||
- Перед оновленням створює знімок snapper *pre*, потім запускає `xbps-install -Su` (спершу оновлює сам xbps, якщо треба), а наприкінці створює парний знімок *post*, навіть якщо оновлення не вдалося.
|
||||
- Якщо знімок *pre* не вдалося створити, нічого не встановлюється. Без Btrfs (ext4, XFS) оновлює без знімка й попереджає про це.
|
||||
- Після завершення показує номер знімка й те, як до нього повернутися, і просить перезавантажитися, якщо оновилися ядро, glibc, runit чи інші ключові пакети.
|
||||
- Вікно *Оновлення системи* показує список пакетів зі встановленою і новою версіями та вивід xbps. Частина, якій потрібні права root, запускається через pkexec (дія polkit `ua.org.das.update`).
|
||||
- У фоні перевіряє оновлення через 2 хвилини після входу, а далі кожні 6 годин, без прав root. Значок у треї й сповіщення з'являються лише тоді, коли є оновлення. Клік відкриває вікно, правий клік — меню з пунктом *Перевірити зараз*. У live-сесії перевірка вимкнена.
|
||||
- `das-update --cli` робить те саме в терміналі (сервери, ssh). Перед встановленням питає підтвердження (`-y` його пропускає) і сам перезапускається через sudo.
|
||||
|
||||
#### Вікно привітання<a id='Welcome' />
|
||||
**_`das-welcome` відкривається під час першого входу і збирає перші кроки в одному місці._**
|
||||
- У live-сесії пропонує встановити Das OS.
|
||||
- Мову інтерфейсу можна вибрати, якщо в системі зібрано більше однієї локалі. Вона зберігається в AccountsService і `~/.dmrc` і діє з наступного входу.
|
||||
- Якщо теки домашнього каталогу лишилися з англійськими назвами (`Documents`, `Downloads`…), пропонує перейменувати їх мовою системи разом із `user-dirs.dirs` і закладками файлового менеджера. Теки, які користувач переніс, і ті, чия нова назва вже зайнята, не чіпає.
|
||||
- Містить посилання на оновлення системи, налаштування Xfce і довідку.
|
||||
- Сам відкривається лише один раз. Прапорець *Показувати під час входу* залишає його на кожен вхід.
|
||||
|
||||
#### Оформлення Das OS<a id='Look' />
|
||||
**_Сучасний мінімалізм із синьо-жовтою восьмипелюстковою зіркою з української вишивки._**
|
||||
- GTK-теми `DasOS` і `DasOS-Dark` побудовані на adw-gtk3 і додають акцентні кольори Das OS. Вони також оформлюють панель Xfce, яку adw-gtk3 лишає чорною.
|
||||
@@ -102,11 +131,13 @@ Void Linux швидкий, простий і незалежний: runit зам
|
||||
- Одна нижня панель містить меню Whisker, кнопки вікон, трей, розкладку клавіатури, гучність, сповіщення, годинник і кнопку «показати стільницю».
|
||||
- При першому вході `das-session-init` встановлює шпалери на кожному підключеному моніторі, бо Xfce зберігає шпалери за назвою монітора.
|
||||
- LightDM використовує GTK-вітальню з темою і шпалерами Das OS.
|
||||
- Теки домашнього каталогу отримують назви мовою сеансу. xdg-user-dirs 0.20 шукає переклади лише в першій теці з `XDG_DATA_DIRS`, де є підкаталог `locale`, — порожній `/usr/local/share/locale`, тому створював теки англійською. `/etc/X11/xinit/xinitrc.d/50-das-user-dirs` створює їх ще до сеансу Xfce зі шляхом пошуку, де переклади знаходяться.
|
||||
|
||||
#### Власний репозиторій пакетів<a id='Repository' />
|
||||
**_Кожен файл Das OS встановлює й оновлює xbps._**
|
||||
- `srcpkgs/das-theme` містить теми, рамки xfwm4, шпалери й логотип.
|
||||
- `srcpkgs/das-settings` містить налаштування Xfce, LightDM і сесії.
|
||||
- `srcpkgs/das-tools` містить `das-update` і `das-welcome` (Go, GTK 3 через gotk3) з пунктами меню, автозапуском і дією polkit.
|
||||
- `srcpkgs/das-snapshots` містить конфігурацію snapper, `das-snapper-setup` і dracut-модуль для завантаження знімків.
|
||||
- `srcpkgs/das-installer` містить налаштування Calamares, брендинг і скрипти встановлення. Він є лише на live-ISO.
|
||||
- `srcpkgs/calamares`, `srcpkgs/adw-gtk3` і `srcpkgs/font-jetbrains-mono` пакують релізи, яких немає у Void.
|
||||
@@ -119,7 +150,7 @@ Void Linux швидкий, простий і незалежний: runit зам
|
||||
3. `make qemu` завантажує найновіший ISO з UEFI. `make qemu-bios` використовує старий BIOS, а `make qemu-disk` ще й підключає віртуальний диск на 20 ГБ для встановлення.
|
||||
|
||||
### 🚀 Збирання застосунку
|
||||
Вимоги: Linux із Docker (користувач має бути в групі `docker`), QEMU та OVMF для тестування, Go 1.22+ для `make test`. Приблизно 10 ГБ вільного місця.
|
||||
Вимоги: Linux із Docker (користувач має бути в групі `docker`), QEMU та OVMF для тестування, Go 1.24+ і файли розробки GTK 3 для `make test`. Приблизно 10 ГБ вільного місця.
|
||||
```shell
|
||||
make iso # зібрати пакети й live-ISO (перший запуск завантажує ~2 ГБ)
|
||||
make qemu # запустити його в QEMU з UEFI
|
||||
@@ -128,6 +159,6 @@ make clean # видалити результати збиранн
|
||||
```
|
||||
ISO з'являється в `out/iso/dasos-<версія>-x86_64.iso`. Записати його на флешку можна через `dd` або Ventoy.
|
||||
|
||||
**Примітка:** Das OS — рання версія (0.2.0). Go-утиліти `das-welcome` і `das-update` (оновлення зі знімком перед ним), профіль розробника та підписаний репозиторій на хостингу заплановані на наступні етапи. Поки репозиторій не розміщено на хостингу, встановлена система отримує оновлення лише з Void. Das OS базується на Void Linux, але не пов'язана з ним офіційно.
|
||||
**Примітка:** Das OS — рання версія (0.3.0). Серверний образ, профіль розробника та підписаний репозиторій на хостингу заплановані на наступні етапи. Поки репозиторій не розміщено на хостингу, встановлена система отримує оновлення лише з Void. Das OS базується на Void Linux, але не пов'язана з ним офіційно.
|
||||
|
||||
###### _Made by -=:dAs:=-_
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
# Das OS — що зробити далі
|
||||
|
||||
Стан на 2026-10-19: версія 0.2.0, етапи 1–4 готові (live ISO, власний репозиторій, оформлення, інсталятор Calamares зі знімками Btrfs). Далі: етап 5, потім серверний образ (етап 6).
|
||||
Стан на 2026-10-19: версія 0.3.0, етапи 1–5 готові (live ISO, власний репозиторій, оформлення, інсталятор Calamares зі знімками Btrfs, Go-утиліти `das-update` і `das-welcome`). Далі: серверний образ (етап 6).
|
||||
|
||||
## Етап 5. Go-утиліти
|
||||
- [ ] `das-update`: значок у треї, знімок snapper перед оновленням (`snapper create -t pre/post`), потім `xbps-install -Su` через pkexec; підказка про відкат, якщо щось зламалося.
|
||||
- [ ] `das-update` одразу з консольним режимом (`das-update --cli`, без треї): він потрібен серверному образу.
|
||||
- [ ] `das-welcome`: вікно першого входу (мова, оновлення, профіль розробника, посилання на довідку).
|
||||
- [ ] У `das-welcome` виправити назви тек у домашньому каталозі: xdg-user-dirs 0.20 створює `Desktop`, `Documents`… англійською навіть з `LANG=uk_UA` (переклади є, але не застосовуються). З'ясувати причину або перейменовувати теки самим.
|
||||
- [ ] Тести `go test ./...` для утиліт.
|
||||
## Етап 5. Go-утиліти (готово, лишилися перевірки)
|
||||
- [x] `das-update`: вікно, значок у треї (лише коли є оновлення), консольний режим `--cli`, знімок snapper pre/post, встановлення через pkexec, підказка про відкат.
|
||||
- [x] `das-welcome`: вікно першого входу (встановлення в live-сесії, мова, теки, оновлення, налаштування, довідка).
|
||||
- [x] Англійські назви тек: xdg-user-dirs 0.20 шукає переклади лише в першій теці `XDG_DATA_DIRS` з підкаталогом `locale` (порожня `/usr/local/share/locale`). Виправлено в `das-settings` (`/etc/X11/xinit/xinitrc.d/50-das-user-dirs`), уже створені теки перейменовує `das-welcome`.
|
||||
- [x] Тести `go test ./...` (`make test` і під час збирання пакета).
|
||||
- [ ] Перевірити оновлення зі знімком у встановленій системі на Btrfs (у live-сесії перевірено лише гілку без знімків).
|
||||
- [ ] Перевірити вибір мови в `das-welcome` (AccountsService і `~/.dmrc`): у live-образі зібрано лише одну локаль, тому рядок мови не показується.
|
||||
- [ ] Повідомити авторів xdg-user-dirs про помилку з `XDG_DATA_DIRS`.
|
||||
- [ ] Пункт «Профіль розробника» в `das-welcome` (разом з етапом 7).
|
||||
|
||||
## Етап 6. Серверний образ
|
||||
Live ISO без графіки з консольним інсталятором: `dasos-server-<версія>-x86_64.iso`.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -65,3 +65,4 @@ das-installer
|
||||
das-settings
|
||||
das-snapshots
|
||||
das-theme
|
||||
das-tools
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Sourced by LightDM's session wrapper before the Xfce session starts.
|
||||
#
|
||||
# xdg-user-dirs 0.20 loads its translations only from the first XDG_DATA_DIRS
|
||||
# entry with a locale directory, the empty /usr/local/share/locale, and so
|
||||
# creates Desktop, Documents... in English whatever the language. Create the
|
||||
# folders here with a lookup path that finds the translations; the later run
|
||||
# from the Xfce session then keeps them.
|
||||
if command -v xdg-user-dirs-update >/dev/null 2>&1; then
|
||||
XDG_DATA_DIRS=/usr/share xdg-user-dirs-update
|
||||
fi
|
||||
@@ -4,7 +4,7 @@
|
||||
# Defaults go to /etc/xdg/das, which /etc/profile.d/das-xdg.sh puts in front of
|
||||
# /etc/xdg: stock Xfce files stay untouched and users' own changes still win.
|
||||
pkgname=das-settings
|
||||
version=0.2.0
|
||||
version=0.3.0
|
||||
revision=1
|
||||
create_wrksrc=yes
|
||||
depends="das-theme lightdm lightdm-gtk-greeter xfce4-notifyd xfce4-panel
|
||||
@@ -26,6 +26,7 @@ do_install() {
|
||||
|
||||
vinstall "${FILESDIR}/bin/das-session-init" 755 usr/libexec/das
|
||||
vinstall "${FILESDIR}/autostart/das-session-init.desktop" 644 etc/xdg/autostart
|
||||
vinstall "${FILESDIR}/xinitrc.d/50-das-user-dirs" 755 etc/X11/xinit/xinitrc.d
|
||||
|
||||
vinstall "${FILESDIR}/lightdm/50-das.conf" 644 usr/share/lightdm/lightdm.conf.d
|
||||
vinstall "${FILESDIR}/lightdm/sv-conf" 644 etc/sv/lightdm conf
|
||||
|
||||
@@ -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,7 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Das OS update check
|
||||
Comment=Shows a tray icon when updates are available
|
||||
Exec=das-update --tray
|
||||
OnlyShowIn=XFCE;
|
||||
NoDisplay=true
|
||||
@@ -0,0 +1,12 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=System Update
|
||||
Name[uk]=Оновлення системи
|
||||
Comment=Install updates with a snapshot to roll back to
|
||||
Comment[uk]=Встановити оновлення зі знімком для відкату
|
||||
Exec=das-update
|
||||
Icon=software-update-available
|
||||
Terminal=false
|
||||
Categories=System;Settings;
|
||||
Keywords=update;upgrade;xbps;snapshot;
|
||||
Keywords[uk]=оновлення;xbps;знімок;
|
||||
@@ -0,0 +1,6 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Das OS welcome
|
||||
Exec=das-welcome --autostart
|
||||
OnlyShowIn=XFCE;
|
||||
NoDisplay=true
|
||||
@@ -0,0 +1,10 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Welcome to Das OS
|
||||
Name[uk]=Ласкаво просимо до Das OS
|
||||
Comment=Language, home folders, updates and help
|
||||
Comment[uk]=Мова, теки, оновлення та довідка
|
||||
Exec=das-welcome
|
||||
Icon=das-logo
|
||||
Terminal=false
|
||||
Categories=System;
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
|
||||
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
|
||||
<policyconfig>
|
||||
<vendor>Das OS</vendor>
|
||||
<vendor_url>https://git.das.org.ua/das/Das-OS</vendor_url>
|
||||
<action id="ua.org.das.update">
|
||||
<description>Update the system</description>
|
||||
<description xml:lang="uk">Оновлення системи</description>
|
||||
<message>Authentication is required to install system updates</message>
|
||||
<message xml:lang="uk">Для встановлення оновлень потрібна автентифікація</message>
|
||||
<icon_name>software-update-available</icon_name>
|
||||
<defaults>
|
||||
<allow_any>auth_admin</allow_any>
|
||||
<allow_inactive>auth_admin</allow_inactive>
|
||||
<allow_active>auth_admin_keep</allow_active>
|
||||
</defaults>
|
||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/das-update</annotate>
|
||||
</action>
|
||||
</policyconfig>
|
||||
@@ -0,0 +1,184 @@
|
||||
// Command das-update updates Das OS with a snapper snapshot taken before
|
||||
// the update, so a broken update can be rolled back from the boot menu.
|
||||
//
|
||||
// das-update window that lists and installs updates
|
||||
// das-update --tray background check, shows a tray icon when updates are available
|
||||
// das-update --cli the same in a terminal (servers, ssh); -y skips the question
|
||||
// das-update --apply the privileged part, started by the window through pkexec
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"dasos/tools/internal/i18n"
|
||||
"dasos/tools/internal/system"
|
||||
"dasos/tools/internal/update"
|
||||
"dasos/tools/internal/xbps"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cli := flag.Bool("cli", false, "update in the terminal")
|
||||
yes := flag.Bool("y", false, "with --cli: do not ask for confirmation")
|
||||
apply := flag.Bool("apply", false, "install the updates as root (used by the window)")
|
||||
tray := flag.Bool("tray", false, "check for updates in the background")
|
||||
lang := flag.String("lang", "", "message language, e.g. uk_UA.UTF-8 (pkexec clears LANG)")
|
||||
flag.Parse()
|
||||
if *lang != "" {
|
||||
i18n.SetLang(*lang)
|
||||
}
|
||||
|
||||
switch {
|
||||
case *apply:
|
||||
os.Exit(runApply())
|
||||
case *cli:
|
||||
os.Exit(runCLI(*yes))
|
||||
case *tray:
|
||||
os.Exit(runTray())
|
||||
default:
|
||||
os.Exit(runWindow())
|
||||
}
|
||||
}
|
||||
|
||||
// Machine-readable lines that --apply prints for the window, among the
|
||||
// plain output of xbps.
|
||||
const (
|
||||
markStep = "@@step "
|
||||
markSnapshot = "@@snapshot "
|
||||
markReboot = "@@reboot"
|
||||
markUpToDate = "@@uptodate"
|
||||
markError = "@@error "
|
||||
)
|
||||
|
||||
type markReporter struct{ w io.Writer }
|
||||
|
||||
func (r markReporter) Step(title string) { fmt.Fprintln(r.w, markStep+title) }
|
||||
func (r markReporter) Output() io.Writer { return r.w }
|
||||
func (r markReporter) Snapshot(n int) { fmt.Fprintf(r.w, "%s%d\n", markSnapshot, n) }
|
||||
|
||||
func runApply() int {
|
||||
if os.Geteuid() != 0 {
|
||||
fmt.Fprintln(os.Stderr, "das-update: "+i18n.T("update.needroot"))
|
||||
return 1
|
||||
}
|
||||
// Losing the window must not stop xbps halfway through a transaction.
|
||||
signal.Ignore(syscall.SIGHUP, syscall.SIGPIPE)
|
||||
r := markReporter{os.Stdout}
|
||||
res, err := update.System().Apply(context.Background(), r)
|
||||
if err != nil {
|
||||
fmt.Println(markError + err.Error())
|
||||
return 1
|
||||
}
|
||||
if len(res.Updates) == 0 {
|
||||
fmt.Println(markUpToDate)
|
||||
} else if res.Reboot {
|
||||
fmt.Println(markReboot)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type cliReporter struct{}
|
||||
|
||||
func (cliReporter) Step(title string) { fmt.Printf("\n==> %s\n", title) }
|
||||
func (cliReporter) Output() io.Writer { return os.Stdout }
|
||||
func (cliReporter) Snapshot(n int) { fmt.Println(i18n.T("update.snapshot", n)) }
|
||||
|
||||
func runCLI(yes bool) int {
|
||||
if !yes {
|
||||
fmt.Println(i18n.T("update.checking"))
|
||||
updates, err := xbps.Check(context.Background())
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, i18n.T("update.checkfailed", err))
|
||||
return 1
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
fmt.Println(i18n.T("update.uptodate"))
|
||||
return 0
|
||||
}
|
||||
printUpdates(os.Stdout, updates)
|
||||
if !confirm(os.Stdin) {
|
||||
fmt.Println(i18n.T("update.cancelled"))
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if os.Geteuid() != 0 {
|
||||
return sudo()
|
||||
}
|
||||
|
||||
signal.Ignore(syscall.SIGHUP)
|
||||
res, err := update.System().Apply(context.Background(), cliReporter{})
|
||||
fmt.Println()
|
||||
switch {
|
||||
case err != nil:
|
||||
fmt.Fprintf(os.Stderr, "%s %v\n", i18n.T("update.failed"), err)
|
||||
case len(res.Updates) == 0:
|
||||
fmt.Println(i18n.T("update.uptodate"))
|
||||
default:
|
||||
fmt.Println(i18n.T("update.done"))
|
||||
if res.Reboot {
|
||||
fmt.Println(i18n.T("update.reboot"))
|
||||
}
|
||||
}
|
||||
if res.Snapshot > 0 && len(res.Updates) > 0 {
|
||||
fmt.Println(i18n.T("update.rollback", res.Snapshot, res.Snapshot))
|
||||
}
|
||||
if err != nil {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// sudo runs the confirmed update again as root.
|
||||
func sudo() int {
|
||||
self, err := os.Executable()
|
||||
if err == nil {
|
||||
var path string
|
||||
path, err = exec.LookPath("sudo")
|
||||
if err == nil {
|
||||
err = syscall.Exec(path, []string{"sudo", self, "--cli", "-y", "--lang", system.Lang()}, os.Environ())
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "das-update: %s: %v\n", i18n.T("update.needroot"), err)
|
||||
return 1
|
||||
}
|
||||
|
||||
func printUpdates(w io.Writer, updates []xbps.Update) {
|
||||
fmt.Fprintln(w, i18n.T("update.available"))
|
||||
width := 0
|
||||
for _, u := range updates {
|
||||
width = max(width, len(u.Name))
|
||||
}
|
||||
for _, u := range updates {
|
||||
switch {
|
||||
case u.Action == "remove":
|
||||
fmt.Fprintf(w, " %-*s %s → −\n", width, u.Name, u.OldVersion)
|
||||
case u.OldVersion == "":
|
||||
fmt.Fprintf(w, " %-*s + %s\n", width, u.Name, u.NewVersion)
|
||||
default:
|
||||
fmt.Fprintf(w, " %-*s %s → %s\n", width, u.Name, u.OldVersion, u.NewVersion)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w, i18n.Plural("update.count", len(updates)))
|
||||
}
|
||||
|
||||
// confirm accepts Enter, y/yes and the Ukrainian т/так.
|
||||
func confirm(in io.Reader) bool {
|
||||
fmt.Print(i18n.T("update.confirm"))
|
||||
line, err := bufio.NewReader(in).ReadString('\n')
|
||||
if err != nil && line == "" {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||
case "", "y", "yes", "т", "так":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/gotk3/gotk3/glib"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
|
||||
"dasos/tools/internal/i18n"
|
||||
"dasos/tools/internal/system"
|
||||
"dasos/tools/internal/tray"
|
||||
"dasos/tools/internal/xbps"
|
||||
)
|
||||
|
||||
const (
|
||||
// Give the network time to come up after login.
|
||||
firstCheck = 2 * time.Minute
|
||||
checkInterval = 6 * time.Hour
|
||||
checkTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
type trayApp struct {
|
||||
icon *tray.Icon
|
||||
checking bool
|
||||
notified []string // the updates the last notification was about
|
||||
window *exec.Cmd
|
||||
}
|
||||
|
||||
func runTray() int {
|
||||
if system.IsLive() {
|
||||
// Updating the live session only fills its RAM overlay.
|
||||
return 0
|
||||
}
|
||||
gtk.Init(nil)
|
||||
t := &trayApp{}
|
||||
|
||||
menu, err := gtk.MenuNew()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "das-update:", err)
|
||||
return 1
|
||||
}
|
||||
for _, item := range []struct {
|
||||
label string
|
||||
action func()
|
||||
}{
|
||||
{i18n.T("update.tray.menu"), t.openWindow},
|
||||
{i18n.T("update.tray.check"), t.check},
|
||||
} {
|
||||
mi, err := gtk.MenuItemNewWithLabel(item.label)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "das-update:", err)
|
||||
return 1
|
||||
}
|
||||
mi.Connect("activate", item.action)
|
||||
menu.Append(mi)
|
||||
}
|
||||
t.icon = tray.New(iconName, t.openWindow, menu)
|
||||
|
||||
glib.TimeoutSecondsAdd(uint(firstCheck.Seconds()), func() bool {
|
||||
t.check()
|
||||
glib.TimeoutSecondsAdd(uint(checkInterval.Seconds()), func() bool {
|
||||
t.check()
|
||||
return true
|
||||
})
|
||||
return false
|
||||
})
|
||||
gtk.Main()
|
||||
return 0
|
||||
}
|
||||
|
||||
func (t *trayApp) check() {
|
||||
if t.checking {
|
||||
return
|
||||
}
|
||||
t.checking = true
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), checkTimeout)
|
||||
defer cancel()
|
||||
updates, err := xbps.Check(ctx)
|
||||
glib.IdleAdd(func() { t.show(updates, err) })
|
||||
}()
|
||||
}
|
||||
|
||||
func (t *trayApp) show(updates []xbps.Update, err error) {
|
||||
t.checking = false
|
||||
if err != nil {
|
||||
// Offline or a mirror problem: keep the current state, try again later.
|
||||
fmt.Fprintln(os.Stderr, "das-update:", err)
|
||||
return
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
t.icon.Hide()
|
||||
t.notified = nil
|
||||
return
|
||||
}
|
||||
summary := i18n.Plural("update.count", len(updates))
|
||||
t.icon.Show(summary)
|
||||
|
||||
names := make([]string, len(updates))
|
||||
for i, u := range updates {
|
||||
names[i] = u.Name + "-" + u.NewVersion
|
||||
}
|
||||
// Notify once per set of updates, not at every check.
|
||||
if !slices.Equal(names, t.notified) {
|
||||
t.notified = names
|
||||
exec.Command("notify-send", "--app-name=das-update", "--icon="+iconName,
|
||||
summary, i18n.T("update.notify")).Start()
|
||||
}
|
||||
}
|
||||
|
||||
// openWindow starts the update window and checks again once it is closed.
|
||||
func (t *trayApp) openWindow() {
|
||||
if t.window != nil {
|
||||
// das-update is a single-instance application: this raises it.
|
||||
exec.Command("/usr/bin/das-update").Start()
|
||||
return
|
||||
}
|
||||
cmd := exec.Command("/usr/bin/das-update")
|
||||
if err := cmd.Start(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "das-update:", err)
|
||||
return
|
||||
}
|
||||
t.window = cmd
|
||||
go func() {
|
||||
cmd.Wait()
|
||||
glib.IdleAdd(func() {
|
||||
t.window = nil
|
||||
t.check()
|
||||
})
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gotk3/gotk3/glib"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
|
||||
"dasos/tools/internal/i18n"
|
||||
"dasos/tools/internal/system"
|
||||
"dasos/tools/internal/xbps"
|
||||
)
|
||||
|
||||
const (
|
||||
appID = "ua.org.das.Update"
|
||||
iconName = "software-update-available"
|
||||
// pkexec exit codes when the password dialog was dismissed or failed
|
||||
pkexecDismissed = 126
|
||||
pkexecNotAuthed = 127
|
||||
)
|
||||
|
||||
type window struct {
|
||||
win *gtk.ApplicationWindow
|
||||
status *gtk.Label
|
||||
spinner *gtk.Spinner
|
||||
store *gtk.ListStore
|
||||
list *gtk.ScrolledWindow
|
||||
info *gtk.Label
|
||||
details *gtk.Expander
|
||||
log *gtk.TextView
|
||||
check *gtk.Button
|
||||
install *gtk.Button
|
||||
close *gtk.Button
|
||||
running bool
|
||||
snapshot int
|
||||
}
|
||||
|
||||
func runWindow() int {
|
||||
app, err := gtk.ApplicationNew(appID, glib.APPLICATION_FLAGS_NONE)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "das-update:", err)
|
||||
return 1
|
||||
}
|
||||
var w *window
|
||||
app.Connect("activate", func() {
|
||||
// A second "das-update" only raises the open window.
|
||||
if w != nil {
|
||||
w.win.Present()
|
||||
return
|
||||
}
|
||||
w, err = newWindow(app)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "das-update:", err)
|
||||
app.Quit()
|
||||
return
|
||||
}
|
||||
w.win.ShowAll()
|
||||
w.details.Hide()
|
||||
w.startCheck()
|
||||
})
|
||||
return app.Run(nil)
|
||||
}
|
||||
|
||||
func newWindow(app *gtk.Application) (*window, error) {
|
||||
w := &window{}
|
||||
var err error
|
||||
must := func(e error) {
|
||||
if err == nil {
|
||||
err = e
|
||||
}
|
||||
}
|
||||
|
||||
w.win, err = gtk.ApplicationWindowNew(app)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.win.SetTitle(i18n.T("update.title"))
|
||||
w.win.SetIconName(iconName)
|
||||
w.win.SetDefaultSize(620, 480)
|
||||
w.win.SetPosition(gtk.WIN_POS_CENTER)
|
||||
w.win.Connect("delete-event", func() bool { return w.running })
|
||||
|
||||
box, e := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 12)
|
||||
must(e)
|
||||
box.SetMarginStart(18)
|
||||
box.SetMarginEnd(18)
|
||||
box.SetMarginTop(18)
|
||||
box.SetMarginBottom(18)
|
||||
|
||||
header, e := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 12)
|
||||
must(e)
|
||||
icon, e := gtk.ImageNewFromIconName(iconName, gtk.ICON_SIZE_DIALOG)
|
||||
must(e)
|
||||
w.status, e = gtk.LabelNew("")
|
||||
must(e)
|
||||
w.status.SetXAlign(0)
|
||||
w.status.SetLineWrap(true)
|
||||
w.spinner, e = gtk.SpinnerNew()
|
||||
must(e)
|
||||
header.PackStart(icon, false, false, 0)
|
||||
header.PackStart(w.status, true, true, 0)
|
||||
header.PackEnd(w.spinner, false, false, 0)
|
||||
box.PackStart(header, false, false, 0)
|
||||
|
||||
w.store, e = gtk.ListStoreNew(glib.TYPE_STRING, glib.TYPE_STRING, glib.TYPE_STRING)
|
||||
must(e)
|
||||
tree, e := gtk.TreeViewNewWithModel(w.store)
|
||||
must(e)
|
||||
for i, title := range []string{"Пакет|Package", "Встановлено|Installed", "Нова версія|New version"} {
|
||||
r, e := gtk.CellRendererTextNew()
|
||||
must(e)
|
||||
col, e := gtk.TreeViewColumnNewWithAttribute(pick(title), r, "text", i)
|
||||
must(e)
|
||||
col.SetResizable(true)
|
||||
tree.AppendColumn(col)
|
||||
}
|
||||
w.list, e = gtk.ScrolledWindowNew(nil, nil)
|
||||
must(e)
|
||||
w.list.SetShadowType(gtk.SHADOW_IN)
|
||||
w.list.SetVExpand(true)
|
||||
w.list.Add(tree)
|
||||
box.PackStart(w.list, true, true, 0)
|
||||
|
||||
w.info, e = gtk.LabelNew("")
|
||||
must(e)
|
||||
w.info.SetXAlign(0)
|
||||
w.info.SetLineWrap(true)
|
||||
w.info.SetSelectable(true)
|
||||
box.PackStart(w.info, false, false, 0)
|
||||
|
||||
w.details, e = gtk.ExpanderNew(i18n.T("update.details"))
|
||||
must(e)
|
||||
w.log, e = gtk.TextViewNew()
|
||||
must(e)
|
||||
w.log.SetEditable(false)
|
||||
w.log.SetCursorVisible(false)
|
||||
w.log.SetMonospace(true)
|
||||
w.log.SetWrapMode(gtk.WRAP_WORD_CHAR)
|
||||
logScroll, e := gtk.ScrolledWindowNew(nil, nil)
|
||||
must(e)
|
||||
logScroll.SetShadowType(gtk.SHADOW_IN)
|
||||
logScroll.SetSizeRequest(-1, 180)
|
||||
logScroll.SetVExpand(true)
|
||||
logScroll.Add(w.log)
|
||||
w.details.Add(logScroll)
|
||||
box.PackStart(w.details, true, true, 0)
|
||||
|
||||
buttons, e := gtk.ButtonBoxNew(gtk.ORIENTATION_HORIZONTAL)
|
||||
must(e)
|
||||
buttons.SetLayout(gtk.BUTTONBOX_END)
|
||||
buttons.SetSpacing(6)
|
||||
w.check, e = gtk.ButtonNewWithLabel(i18n.T("update.check"))
|
||||
must(e)
|
||||
w.install, e = gtk.ButtonNewWithLabel(i18n.T("update.install"))
|
||||
must(e)
|
||||
w.close, e = gtk.ButtonNewWithLabel(i18n.T("update.close"))
|
||||
must(e)
|
||||
if e == nil {
|
||||
style, _ := w.install.GetStyleContext()
|
||||
style.AddClass("suggested-action")
|
||||
}
|
||||
buttons.Add(w.check)
|
||||
buttons.Add(w.install)
|
||||
buttons.Add(w.close)
|
||||
box.PackEnd(buttons, false, false, 0)
|
||||
|
||||
w.check.Connect("clicked", w.startCheck)
|
||||
w.install.Connect("clicked", w.startInstall)
|
||||
w.close.Connect("clicked", func() { w.win.Close() })
|
||||
|
||||
w.win.Add(box)
|
||||
return w, err
|
||||
}
|
||||
|
||||
// pick chooses from an inline "Ukrainian|English" pair.
|
||||
func pick(pair string) string {
|
||||
uk, en, _ := strings.Cut(pair, "|")
|
||||
if strings.HasPrefix(system.Lang(), "uk") {
|
||||
return uk
|
||||
}
|
||||
return en
|
||||
}
|
||||
|
||||
func (w *window) setBusy(busy bool, status string) {
|
||||
w.running = busy
|
||||
w.status.SetMarkup("<b><big>" + html.EscapeString(status) + "</big></b>")
|
||||
if busy {
|
||||
w.spinner.Start()
|
||||
} else {
|
||||
w.spinner.Stop()
|
||||
}
|
||||
w.check.SetSensitive(!busy)
|
||||
w.install.SetSensitive(!busy)
|
||||
w.close.SetSensitive(!busy)
|
||||
}
|
||||
|
||||
func (w *window) startCheck() {
|
||||
w.setBusy(true, i18n.T("update.checking"))
|
||||
w.info.SetText("")
|
||||
go func() {
|
||||
updates, err := xbps.Check(context.Background())
|
||||
glib.IdleAdd(func() { w.showUpdates(updates, err) })
|
||||
}()
|
||||
}
|
||||
|
||||
func (w *window) showUpdates(updates []xbps.Update, err error) {
|
||||
w.store.Clear()
|
||||
switch {
|
||||
case err != nil:
|
||||
w.setBusy(false, i18n.T("update.checkfailed", err))
|
||||
w.install.SetSensitive(false)
|
||||
return
|
||||
case len(updates) == 0:
|
||||
w.setBusy(false, i18n.T("update.uptodate"))
|
||||
w.install.SetSensitive(false)
|
||||
w.list.Hide()
|
||||
return
|
||||
}
|
||||
for _, u := range updates {
|
||||
newVersion := u.NewVersion
|
||||
if u.Action == "remove" {
|
||||
newVersion = "−"
|
||||
}
|
||||
w.store.Set(w.store.Append(), []int{0, 1, 2}, []any{u.Name, u.OldVersion, newVersion})
|
||||
}
|
||||
w.details.Hide()
|
||||
w.list.Show()
|
||||
w.setBusy(false, i18n.Plural("update.count", len(updates)))
|
||||
w.install.GrabFocus()
|
||||
}
|
||||
|
||||
func (w *window) startInstall() {
|
||||
w.setBusy(true, i18n.T("update.running"))
|
||||
w.snapshot = 0
|
||||
w.info.SetText("")
|
||||
buf, _ := w.log.GetBuffer()
|
||||
buf.SetText("")
|
||||
// The log takes the place of the package list while xbps works.
|
||||
w.list.Hide()
|
||||
w.details.Show()
|
||||
w.details.SetExpanded(true)
|
||||
|
||||
cmd := exec.Command("pkexec", "/usr/bin/das-update", "--apply", "--lang", system.Lang())
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err == nil {
|
||||
cmd.Stderr = cmd.Stdout
|
||||
err = cmd.Start()
|
||||
}
|
||||
if err != nil {
|
||||
w.finish(false, false, err.Error())
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
var reboot, upToDate bool
|
||||
var failure string
|
||||
sc := bufio.NewScanner(out)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
switch {
|
||||
case strings.HasPrefix(line, markStep):
|
||||
line = "\n==> " + strings.TrimPrefix(line, markStep)
|
||||
case strings.HasPrefix(line, markSnapshot):
|
||||
n, _ := strconv.Atoi(strings.TrimPrefix(line, markSnapshot))
|
||||
glib.IdleAdd(func() { w.snapshot = n })
|
||||
line = i18n.T("update.snapshot", n)
|
||||
case line == markReboot:
|
||||
reboot = true
|
||||
continue
|
||||
case line == markUpToDate:
|
||||
upToDate = true
|
||||
continue
|
||||
case strings.HasPrefix(line, markError):
|
||||
failure = strings.TrimPrefix(line, markError)
|
||||
}
|
||||
glib.IdleAdd(func() { w.appendLog(line + "\n") })
|
||||
}
|
||||
err := cmd.Wait()
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) && (exit.ExitCode() == pkexecDismissed || exit.ExitCode() == pkexecNotAuthed) && failure == "" {
|
||||
glib.IdleAdd(func() { w.cancelled() })
|
||||
return
|
||||
}
|
||||
if err != nil && failure == "" {
|
||||
failure = err.Error()
|
||||
}
|
||||
glib.IdleAdd(func() {
|
||||
if upToDate {
|
||||
w.setBusy(false, i18n.T("update.uptodate"))
|
||||
w.store.Clear()
|
||||
w.install.SetSensitive(false)
|
||||
return
|
||||
}
|
||||
w.finish(failure == "", reboot, failure)
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
func (w *window) appendLog(text string) {
|
||||
buf, _ := w.log.GetBuffer()
|
||||
buf.Insert(buf.GetEndIter(), text)
|
||||
buf.PlaceCursor(buf.GetEndIter())
|
||||
w.log.ScrollToMark(buf.GetInsert(), 0, false, 0, 0)
|
||||
}
|
||||
|
||||
func (w *window) cancelled() {
|
||||
w.setBusy(false, i18n.T("update.cancelled"))
|
||||
w.details.Hide()
|
||||
w.list.Show()
|
||||
}
|
||||
|
||||
func (w *window) finish(ok, reboot bool, failure string) {
|
||||
var info []string
|
||||
if ok {
|
||||
w.setBusy(false, i18n.T("update.done"))
|
||||
w.store.Clear()
|
||||
w.install.SetSensitive(false)
|
||||
if reboot {
|
||||
info = append(info, i18n.T("update.reboot"))
|
||||
}
|
||||
} else {
|
||||
w.setBusy(false, i18n.T("update.failed"))
|
||||
w.details.SetExpanded(true)
|
||||
if failure != "" {
|
||||
w.appendLog("\n" + failure + "\n")
|
||||
}
|
||||
}
|
||||
if w.snapshot > 0 {
|
||||
info = append(info, i18n.T("update.snapshot", w.snapshot), i18n.T("update.rollback", w.snapshot, w.snapshot))
|
||||
}
|
||||
w.info.SetText(strings.Join(info, "\n"))
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var localeNames = map[string]string{
|
||||
"uk_UA": "Українська",
|
||||
"en_US": "English (US)",
|
||||
"en_GB": "English (UK)",
|
||||
"de_DE": "Deutsch",
|
||||
"pl_PL": "Polski",
|
||||
}
|
||||
|
||||
// availableLocales lists the built UTF-8 locales as LANG values, e.g. uk_UA.UTF-8.
|
||||
func availableLocales() []string {
|
||||
out, err := exec.Command("locale", "-a").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return parseLocales(string(out))
|
||||
}
|
||||
|
||||
func parseLocales(out string) []string {
|
||||
var locales []string
|
||||
for _, l := range strings.Fields(out) {
|
||||
base, codeset, ok := strings.Cut(l, ".")
|
||||
if !ok || !strings.EqualFold(strings.ReplaceAll(codeset, "-", ""), "utf8") || !strings.Contains(base, "_") {
|
||||
continue
|
||||
}
|
||||
locales = append(locales, base+".UTF-8")
|
||||
}
|
||||
sort.Strings(locales)
|
||||
return locales
|
||||
}
|
||||
|
||||
func currentLocale() string {
|
||||
lang := os.Getenv("LANG")
|
||||
base, _, _ := strings.Cut(lang, ".")
|
||||
return base + ".UTF-8"
|
||||
}
|
||||
|
||||
func localeName(locale string) string {
|
||||
base, _, _ := strings.Cut(locale, ".")
|
||||
if name, ok := localeNames[base]; ok {
|
||||
return name
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
var objectPath = regexp.MustCompile(`object path "([^"]+)"`)
|
||||
|
||||
// setLanguage makes LightDM start the next session with the locale. LightDM
|
||||
// takes it from AccountsService when that runs, and from ~/.dmrc otherwise,
|
||||
// so both are set.
|
||||
func setLanguage(locale string) error {
|
||||
errDmrc := writeDmrc(locale)
|
||||
errAccounts := setAccountsLanguage(locale)
|
||||
if errDmrc != nil && errAccounts != nil {
|
||||
return errors.Join(errDmrc, errAccounts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setAccountsLanguage(locale string) error {
|
||||
u, err := user.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := exec.Command("dbus-send", "--system", "--print-reply", "--dest=org.freedesktop.Accounts",
|
||||
"/org/freedesktop/Accounts", "org.freedesktop.Accounts.FindUserByName", "string:"+u.Username).Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("AccountsService: %w", err)
|
||||
}
|
||||
m := objectPath.FindSubmatch(out)
|
||||
if m == nil {
|
||||
return fmt.Errorf("AccountsService: no user object in %q", out)
|
||||
}
|
||||
if out, err := exec.Command("dbus-send", "--system", "--print-reply", "--dest=org.freedesktop.Accounts",
|
||||
string(m[1]), "org.freedesktop.Accounts.User.SetLanguage", "string:"+locale).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("AccountsService: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeDmrc sets Language in the [Desktop] section of ~/.dmrc.
|
||||
func writeDmrc(locale string) error {
|
||||
path := filepath.Join(os.Getenv("HOME"), ".dmrc")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte(setDmrcLanguage(string(data), locale)), 0o644)
|
||||
}
|
||||
|
||||
func setDmrcLanguage(data, locale string) string {
|
||||
lines := strings.Split(strings.TrimRight(data, "\n"), "\n")
|
||||
if data == "" {
|
||||
lines = nil
|
||||
}
|
||||
section, inserted := -1, false
|
||||
for i, l := range lines {
|
||||
t := strings.TrimSpace(l)
|
||||
switch {
|
||||
case t == "[Desktop]":
|
||||
section = i
|
||||
case strings.HasPrefix(t, "["):
|
||||
if section >= 0 && !inserted {
|
||||
lines = append(lines[:i], append([]string{"Language=" + locale}, lines[i:]...)...)
|
||||
inserted = true
|
||||
}
|
||||
section = -1
|
||||
case section >= 0 && strings.HasPrefix(t, "Language="):
|
||||
lines[i] = "Language=" + locale
|
||||
inserted = true
|
||||
}
|
||||
if inserted {
|
||||
break
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case inserted:
|
||||
case section >= 0:
|
||||
lines = append(lines, "Language="+locale)
|
||||
default:
|
||||
lines = append(lines, "[Desktop]", "Language="+locale)
|
||||
}
|
||||
return strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseLocales(t *testing.T) {
|
||||
got := parseLocales("C\nC.utf8\nPOSIX\nuk_UA.utf8\nen_US.UTF-8\nde_DE.iso88591\n")
|
||||
want := []string{"en_US.UTF-8", "uk_UA.UTF-8"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("parseLocales = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDmrcLanguage(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{"", "[Desktop]\nLanguage=en_US.UTF-8\n"},
|
||||
{"[Desktop]\nSession=xfce\n", "[Desktop]\nSession=xfce\nLanguage=en_US.UTF-8\n"},
|
||||
{"[Desktop]\nLanguage=uk_UA.UTF-8\nSession=xfce\n", "[Desktop]\nLanguage=en_US.UTF-8\nSession=xfce\n"},
|
||||
{"[Desktop]\nSession=xfce\n[Other]\nLanguage=x\n", "[Desktop]\nSession=xfce\nLanguage=en_US.UTF-8\n[Other]\nLanguage=x\n"},
|
||||
{"[Other]\nKey=1\n", "[Other]\nKey=1\n[Desktop]\nLanguage=en_US.UTF-8\n"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := setDmrcLanguage(tt.in, "en_US.UTF-8"); got != tt.want {
|
||||
t.Errorf("setDmrcLanguage(%q) =\n%q\nwant\n%q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
// Command das-welcome is the Das OS first-login window: interface language,
|
||||
// home folder names, updates, settings and help.
|
||||
//
|
||||
// das-welcome show the window
|
||||
// das-welcome --autostart show it only if the user did not turn that off
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"html"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gotk3/gotk3/glib"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
|
||||
"dasos/tools/internal/i18n"
|
||||
"dasos/tools/internal/system"
|
||||
"dasos/tools/internal/userdirs"
|
||||
)
|
||||
|
||||
const (
|
||||
appID = "ua.org.das.Welcome"
|
||||
helpURL = "https://git.das.org.ua/das/Das-OS"
|
||||
)
|
||||
|
||||
func main() {
|
||||
autostart := flag.Bool("autostart", false, "show the window only if it is enabled at login")
|
||||
flag.Parse()
|
||||
|
||||
if *autostart && !showAtLogin() {
|
||||
return
|
||||
}
|
||||
app, err := gtk.ApplicationNew(appID, glib.APPLICATION_FLAGS_NONE)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "das-welcome:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
var win *gtk.ApplicationWindow
|
||||
app.Connect("activate", func() {
|
||||
if win != nil {
|
||||
win.Present()
|
||||
return
|
||||
}
|
||||
win, err = newWindow(app)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "das-welcome:", err)
|
||||
app.Quit()
|
||||
return
|
||||
}
|
||||
win.ShowAll()
|
||||
})
|
||||
os.Exit(app.Run(nil))
|
||||
}
|
||||
|
||||
// settingsFile records whether the window opens at login. Without it the
|
||||
// window opens once: at the first login of a user (or every live session).
|
||||
func settingsFile() string {
|
||||
dir := os.Getenv("XDG_CONFIG_HOME")
|
||||
if dir == "" {
|
||||
dir = filepath.Join(os.Getenv("HOME"), ".config")
|
||||
}
|
||||
return filepath.Join(dir, "das", "welcome")
|
||||
}
|
||||
|
||||
func showAtLogin() bool {
|
||||
data, err := os.ReadFile(settingsFile())
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(string(data)) == "autostart=true"
|
||||
}
|
||||
|
||||
func setShowAtLogin(on bool) error {
|
||||
path := settingsFile()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte(fmt.Sprintf("autostart=%v\n", on)), 0o644)
|
||||
}
|
||||
|
||||
// builder collects the first error of a chain of gotk3 constructors.
|
||||
type builder struct{ err error }
|
||||
|
||||
func (b *builder) check(err error) {
|
||||
if b.err == nil {
|
||||
b.err = err
|
||||
}
|
||||
}
|
||||
|
||||
func (b *builder) label(text string, wrap bool) *gtk.Label {
|
||||
l, err := gtk.LabelNew(text)
|
||||
b.check(err)
|
||||
if err == nil {
|
||||
l.SetXAlign(0)
|
||||
l.SetLineWrap(wrap)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func (b *builder) button(text string, onClick func()) *gtk.Button {
|
||||
btn, err := gtk.ButtonNewWithLabel(text)
|
||||
b.check(err)
|
||||
if err == nil {
|
||||
btn.SetVAlign(gtk.ALIGN_CENTER)
|
||||
btn.Connect("clicked", onClick)
|
||||
}
|
||||
return btn
|
||||
}
|
||||
|
||||
// row is one entry of the list: icon, title, description and controls.
|
||||
func (b *builder) row(icon, title, desc string, controls ...gtk.IWidget) (*gtk.Box, *gtk.Label) {
|
||||
box, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 14)
|
||||
b.check(err)
|
||||
img, err := gtk.ImageNewFromIconName(icon, gtk.ICON_SIZE_DND)
|
||||
b.check(err)
|
||||
text, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 2)
|
||||
b.check(err)
|
||||
if b.err != nil {
|
||||
return box, nil
|
||||
}
|
||||
img.SetPixelSize(32)
|
||||
t := b.label("", false)
|
||||
t.SetMarkup("<b>" + html.EscapeString(title) + "</b>")
|
||||
d := b.label(desc, true)
|
||||
if style, err := d.GetStyleContext(); err == nil {
|
||||
style.AddClass("dim-label")
|
||||
}
|
||||
text.PackStart(t, false, false, 0)
|
||||
text.PackStart(d, false, false, 0)
|
||||
box.PackStart(img, false, false, 0)
|
||||
box.PackStart(text, true, true, 0)
|
||||
for _, c := range controls {
|
||||
box.PackEnd(c, false, false, 0)
|
||||
}
|
||||
return box, d
|
||||
}
|
||||
|
||||
func newWindow(app *gtk.Application) (*gtk.ApplicationWindow, error) {
|
||||
b := &builder{}
|
||||
win, err := gtk.ApplicationWindowNew(app)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
win.SetTitle(i18n.T("welcome.title"))
|
||||
win.SetIconName("das-logo")
|
||||
win.SetDefaultSize(640, -1)
|
||||
win.SetPosition(gtk.WIN_POS_CENTER)
|
||||
|
||||
outer, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 16)
|
||||
b.check(err)
|
||||
if b.err != nil {
|
||||
return nil, b.err
|
||||
}
|
||||
outer.SetMarginStart(24)
|
||||
outer.SetMarginEnd(24)
|
||||
outer.SetMarginTop(24)
|
||||
outer.SetMarginBottom(18)
|
||||
|
||||
header, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 16)
|
||||
b.check(err)
|
||||
logo, err := gtk.ImageNewFromIconName("das-logo", gtk.ICON_SIZE_DIALOG)
|
||||
b.check(err)
|
||||
titles, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 4)
|
||||
b.check(err)
|
||||
if b.err != nil {
|
||||
return nil, b.err
|
||||
}
|
||||
logo.SetPixelSize(72)
|
||||
title := b.label("", false)
|
||||
title.SetMarkup(`<span size="xx-large" weight="bold">` + html.EscapeString(i18n.T("welcome.title")) + "</span>")
|
||||
titles.SetVAlign(gtk.ALIGN_CENTER)
|
||||
titles.PackStart(title, false, false, 0)
|
||||
titles.PackStart(b.label(i18n.T("welcome.subtitle"), true), false, false, 0)
|
||||
header.PackStart(logo, false, false, 0)
|
||||
header.PackStart(titles, true, true, 0)
|
||||
outer.PackStart(header, false, false, 0)
|
||||
|
||||
list, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 14)
|
||||
b.check(err)
|
||||
add := func(row *gtk.Box) {
|
||||
if sep, err := gtk.SeparatorNew(gtk.ORIENTATION_HORIZONTAL); err == nil {
|
||||
list.PackStart(sep, false, false, 0)
|
||||
}
|
||||
list.PackStart(row, false, false, 0)
|
||||
}
|
||||
|
||||
if system.IsLive() {
|
||||
install := b.button(i18n.T("welcome.install"), func() { launch("das-install") })
|
||||
if style, err := install.GetStyleContext(); err == nil {
|
||||
style.AddClass("suggested-action")
|
||||
}
|
||||
row, _ := b.row("das-logo", i18n.T("welcome.install"), i18n.T("welcome.install.desc"), install)
|
||||
add(row)
|
||||
}
|
||||
if row := languageRow(b); row != nil {
|
||||
add(row)
|
||||
}
|
||||
if row := foldersRow(b); row != nil {
|
||||
add(row)
|
||||
}
|
||||
if !system.IsLive() {
|
||||
row, _ := b.row("software-update-available", i18n.T("welcome.update"), i18n.T("welcome.update.desc"),
|
||||
b.button(i18n.T("welcome.update.open"), func() { launch("das-update") }))
|
||||
add(row)
|
||||
}
|
||||
row, _ := b.row("preferences-desktop", i18n.T("welcome.settings"), i18n.T("welcome.settings.desc"),
|
||||
b.button(i18n.T("welcome.open"), func() { launch("xfce4-settings-manager") }))
|
||||
add(row)
|
||||
row, _ = b.row("help-browser", i18n.T("welcome.help"), i18n.T("welcome.help.desc"),
|
||||
b.button(i18n.T("welcome.open"), func() { launch("xdg-open", helpURL) }))
|
||||
add(row)
|
||||
outer.PackStart(list, false, false, 0)
|
||||
|
||||
footer, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 12)
|
||||
b.check(err)
|
||||
autostart, err := gtk.CheckButtonNewWithLabel(i18n.T("welcome.autostart"))
|
||||
b.check(err)
|
||||
if b.err != nil {
|
||||
return nil, b.err
|
||||
}
|
||||
// The window opens by itself only once unless the user asks for more.
|
||||
autostart.SetActive(false)
|
||||
if _, err := os.Stat(settingsFile()); err == nil {
|
||||
autostart.SetActive(showAtLogin())
|
||||
} else if err := setShowAtLogin(false); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "das-welcome:", err)
|
||||
}
|
||||
autostart.Connect("toggled", func() {
|
||||
if err := setShowAtLogin(autostart.GetActive()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "das-welcome:", err)
|
||||
}
|
||||
})
|
||||
footer.PackStart(autostart, false, false, 0)
|
||||
footer.PackEnd(b.button(i18n.T("welcome.close"), func() { win.Close() }), false, false, 0)
|
||||
outer.PackEnd(footer, false, false, 0)
|
||||
|
||||
win.Add(outer)
|
||||
return win, b.err
|
||||
}
|
||||
|
||||
func launch(name string, args ...string) {
|
||||
if err := exec.Command(name, args...).Start(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "das-welcome:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// languageRow offers the UTF-8 locales the system has built. It returns nil
|
||||
// when there is nothing to choose from.
|
||||
func languageRow(b *builder) *gtk.Box {
|
||||
locales := availableLocales()
|
||||
if len(locales) < 2 {
|
||||
return nil
|
||||
}
|
||||
combo, err := gtk.ComboBoxTextNew()
|
||||
b.check(err)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
current := currentLocale()
|
||||
for _, l := range locales {
|
||||
combo.Append(l, localeName(l))
|
||||
}
|
||||
if !combo.SetActiveID(current) {
|
||||
combo.SetActive(0)
|
||||
}
|
||||
combo.SetVAlign(gtk.ALIGN_CENTER)
|
||||
|
||||
var desc *gtk.Label
|
||||
apply := b.button(i18n.T("welcome.apply"), func() {
|
||||
if err := setLanguage(combo.GetActiveID()); err != nil {
|
||||
desc.SetText(i18n.T("welcome.error", err))
|
||||
return
|
||||
}
|
||||
desc.SetText(i18n.T("welcome.lang.done"))
|
||||
})
|
||||
row, d := b.row("preferences-desktop-locale", i18n.T("welcome.lang"), i18n.T("welcome.lang.desc"), apply, combo)
|
||||
desc = d
|
||||
return row
|
||||
}
|
||||
|
||||
// foldersRow offers to rename home folders that kept English names.
|
||||
func foldersRow(b *builder) *gtk.Box {
|
||||
home := os.Getenv("HOME")
|
||||
renames, err := pendingRenames(home)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "das-welcome:", err)
|
||||
return nil
|
||||
}
|
||||
if len(renames) == 0 {
|
||||
return nil
|
||||
}
|
||||
names := make([]string, len(renames))
|
||||
for i, r := range renames {
|
||||
names[i] = filepath.Base(r.From)
|
||||
}
|
||||
var desc *gtk.Label
|
||||
var rename *gtk.Button
|
||||
rename = b.button(i18n.T("welcome.dirs.rename"), func() {
|
||||
if err := userdirs.Apply(home, renames); err != nil {
|
||||
desc.SetText(i18n.T("welcome.error", err))
|
||||
return
|
||||
}
|
||||
desc.SetText(i18n.T("welcome.dirs.done"))
|
||||
rename.SetSensitive(false)
|
||||
})
|
||||
row, d := b.row("folder", i18n.T("welcome.dirs"), i18n.T("welcome.dirs.desc", strings.Join(names, ", ")), rename)
|
||||
desc = d
|
||||
return row
|
||||
}
|
||||
|
||||
func pendingRenames(home string) ([]userdirs.Rename, error) {
|
||||
data, err := os.ReadFile(userdirs.ConfigFile(home))
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx := context.Background()
|
||||
english, err := userdirs.Defaults(ctx, "C.UTF-8")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
localized, err := userdirs.Defaults(ctx, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return userdirs.Plan(home, userdirs.Parse(string(data)), english, localized), nil
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module dasos/tools
|
||||
|
||||
go 1.24
|
||||
|
||||
require github.com/gotk3/gotk3 v0.6.5-0.20251124190141-e7a9e823ca35
|
||||
@@ -0,0 +1,2 @@
|
||||
github.com/gotk3/gotk3 v0.6.5-0.20251124190141-e7a9e823ca35 h1:BelWQzAzJfSMA1qbuzoV9Tp57+NCvYouEA5cWVpYcSk=
|
||||
github.com/gotk3/gotk3 v0.6.5-0.20251124190141-e7a9e823ca35/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q=
|
||||
@@ -0,0 +1,122 @@
|
||||
// Package i18n holds the user-visible strings of the Das OS tools in
|
||||
// Ukrainian and English. Ukrainian is used when LC_ALL, LC_MESSAGES or LANG
|
||||
// (first one set wins, as in gettext) starts with "uk"; English otherwise.
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var ukrainian = detect(os.Getenv)
|
||||
|
||||
func detect(getenv func(string) string) bool {
|
||||
for _, name := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} {
|
||||
if v := getenv(name); v != "" {
|
||||
return strings.HasPrefix(v, "uk")
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SetLang overrides the detected language, e.g. for a helper started by
|
||||
// pkexec, which clears the environment.
|
||||
func SetLang(lang string) {
|
||||
ukrainian = strings.HasPrefix(lang, "uk")
|
||||
}
|
||||
|
||||
// T returns the message for key, formatted with args like fmt.Sprintf.
|
||||
// An unknown key is returned as is, so a missing translation stays visible.
|
||||
func T(key string, args ...any) string {
|
||||
m, ok := messages[key]
|
||||
if !ok {
|
||||
return key
|
||||
}
|
||||
s := m.en
|
||||
if ukrainian {
|
||||
s = m.uk
|
||||
}
|
||||
if len(args) > 0 {
|
||||
return fmt.Sprintf(s, args...)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Plural picks the Ukrainian form for n (one, few, many); English uses the
|
||||
// "one" form for 1 and "many" otherwise. The keys are key+".one", ".few", ".many".
|
||||
func Plural(key string, n int) string {
|
||||
form := "many"
|
||||
if ukrainian {
|
||||
switch {
|
||||
case n%10 == 1 && n%100 != 11:
|
||||
form = "one"
|
||||
case n%10 >= 2 && n%10 <= 4 && (n%100 < 12 || n%100 > 14):
|
||||
form = "few"
|
||||
}
|
||||
} else if n == 1 {
|
||||
form = "one"
|
||||
}
|
||||
return T(key+"."+form, n)
|
||||
}
|
||||
|
||||
type message struct{ uk, en string }
|
||||
|
||||
var messages = map[string]message{
|
||||
// das-update
|
||||
"update.title": {"Оновлення системи", "System update"},
|
||||
"update.checking": {"Перевірка оновлень…", "Checking for updates…"},
|
||||
"update.uptodate": {"Система оновлена.", "The system is up to date."},
|
||||
"update.available": {"Доступні оновлення:", "Updates available:"},
|
||||
"update.count.one": {"Доступне %d оновлення", "%d update available"},
|
||||
"update.count.few": {"Доступні %d оновлення", "%d updates available"},
|
||||
"update.count.many": {"Доступно %d оновлень", "%d updates available"},
|
||||
"update.check": {"Перевірити", "Check"},
|
||||
"update.install": {"Оновити", "Update"},
|
||||
"update.close": {"Закрити", "Close"},
|
||||
"update.details": {"Подробиці", "Details"},
|
||||
"update.running": {"Оновлення… Не вимикайте комп'ютер.", "Updating… Do not turn off the computer."},
|
||||
"update.done": {"Систему оновлено.", "The system has been updated."},
|
||||
"update.reboot": {"Оновлено ядро або системні бібліотеки: перезавантажте комп'ютер.", "The kernel or system libraries were updated: restart the computer."},
|
||||
"update.failed": {"Оновлення не вдалося.", "The update failed."},
|
||||
"update.cancelled": {"Оновлення скасовано.", "The update was cancelled."},
|
||||
"update.checkfailed": {"Не вдалося перевірити оновлення: %v", "Could not check for updates: %v"},
|
||||
"update.snapshot": {"Знімок перед оновленням: №%d.", "Snapshot before the update: #%d."},
|
||||
"update.nosnapshots": {"Знімки вимкнені (корінь не на Btrfs), оновлення без знімка.", "Snapshots are off (root is not on Btrfs), updating without one."},
|
||||
"update.rollback": {"Якщо після оновлення щось не працює, перезавантажтеся, у меню завантаження виберіть «Das OS snapshots» → знімок №%d, перевірте систему й виконайте «sudo snapper-rollback %d».", "If something does not work after the update, restart, choose \"Das OS snapshots\" → snapshot #%d in the boot menu, check the system and run \"sudo snapper-rollback %d\"."},
|
||||
"update.step.sync": {"Оновлення списку пакетів", "Refreshing the package index"},
|
||||
"update.step.pre": {"Створення знімка перед оновленням", "Creating a snapshot before the update"},
|
||||
"update.step.xbps": {"Оновлення менеджера пакетів xbps", "Updating the xbps package manager"},
|
||||
"update.step.install": {"Встановлення оновлень", "Installing updates"},
|
||||
"update.step.post": {"Створення знімка після оновлення", "Creating a snapshot after the update"},
|
||||
"update.confirm": {"Встановити оновлення? [Т/н] ", "Install the updates? [Y/n] "},
|
||||
"update.needroot": {"для оновлення потрібні права root", "updating needs root privileges"},
|
||||
"update.notify": {"Натисніть, щоб переглянути й встановити.", "Click to review and install them."},
|
||||
"update.tray.menu": {"Оновити систему…", "Update the system…"},
|
||||
"update.tray.check": {"Перевірити зараз", "Check now"},
|
||||
|
||||
// das-welcome
|
||||
"welcome.title": {"Ласкаво просимо до Das OS", "Welcome to Das OS"},
|
||||
"welcome.subtitle": {"Кілька кроків, щоб налаштувати систему під себе.", "A few steps to make the system your own."},
|
||||
"welcome.install": {"Встановити Das OS", "Install Das OS"},
|
||||
"welcome.install.desc": {"Ви в live-сесії. Встановіть систему на диск, щоб зберігати зміни.", "This is a live session. Install the system to a disk to keep your changes."},
|
||||
"welcome.lang": {"Мова інтерфейсу", "Interface language"},
|
||||
"welcome.lang.desc": {"Зміниться після наступного входу.", "Takes effect at the next login."},
|
||||
"welcome.lang.done": {"Мову змінено. Вийдіть із сеансу й увійдіть знову.", "Language changed. Log out and back in."},
|
||||
"welcome.apply": {"Застосувати", "Apply"},
|
||||
"welcome.dirs": {"Теки домашнього каталогу", "Home folders"},
|
||||
"welcome.dirs.desc": {"Теки «%s» мають англійські назви. Перейменувати їх мовою системи?", "The folders \"%s\" have English names. Rename them to the system language?"},
|
||||
"welcome.dirs.rename": {"Перейменувати", "Rename"},
|
||||
"welcome.dirs.done": {"Теки перейменовано.", "The folders have been renamed."},
|
||||
"welcome.update": {"Оновлення", "Updates"},
|
||||
"welcome.update.desc": {"Перед кожним оновленням створюється знімок системи для відкату.", "A system snapshot is taken before every update so it can be rolled back."},
|
||||
"welcome.update.open": {"Перевірити", "Check"},
|
||||
"welcome.settings": {"Налаштування", "Settings"},
|
||||
"welcome.settings.desc": {"Тема, екран, клавіатура, принтери та інше.", "Theme, display, keyboard, printers and more."},
|
||||
"welcome.open": {"Відкрити", "Open"},
|
||||
"welcome.help": {"Довідка", "Help"},
|
||||
"welcome.help.desc": {"Опис системи, знімки й відкат, відомі проблеми.", "About the system, snapshots and rollback, known issues."},
|
||||
"welcome.autostart": {"Показувати під час входу", "Show at login"},
|
||||
"welcome.close": {"Закрити", "Close"},
|
||||
"welcome.error": {"Помилка: %v", "Error: %v"},
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package i18n
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDetect(t *testing.T) {
|
||||
tests := []struct {
|
||||
env map[string]string
|
||||
want bool
|
||||
}{
|
||||
{map[string]string{"LANG": "uk_UA.UTF-8"}, true},
|
||||
{map[string]string{"LANG": "en_US.UTF-8"}, false},
|
||||
{map[string]string{"LANG": "uk_UA.UTF-8", "LC_MESSAGES": "en_US.UTF-8"}, false},
|
||||
{map[string]string{"LANG": "en_US.UTF-8", "LC_ALL": "uk_UA.UTF-8"}, true},
|
||||
{map[string]string{}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := detect(func(k string) string { return tt.env[k] }); got != tt.want {
|
||||
t.Errorf("detect(%v) = %v, want %v", tt.env, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlural(t *testing.T) {
|
||||
SetLang("uk")
|
||||
defer SetLang("")
|
||||
for n, want := range map[int]string{
|
||||
1: "Доступне 1 оновлення", 3: "Доступні 3 оновлення", 5: "Доступно 5 оновлень",
|
||||
11: "Доступно 11 оновлень", 21: "Доступне 21 оновлення", 112: "Доступно 112 оновлень",
|
||||
} {
|
||||
if got := Plural("update.count", n); got != want {
|
||||
t.Errorf("Plural(%d) = %q, want %q", n, got, want)
|
||||
}
|
||||
}
|
||||
SetLang("en")
|
||||
if got := Plural("update.count", 1); got != "1 update available" {
|
||||
t.Errorf("English Plural(1) = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Package snapper creates the pre/post snapshot pair around a system update.
|
||||
package snapper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config is the snapper configuration of the root file system, created by
|
||||
// das-snapper-setup when the system was installed on Btrfs.
|
||||
const Config = "root"
|
||||
|
||||
// Available reports whether snapshots of the root file system are set up.
|
||||
func Available() bool {
|
||||
if _, err := exec.LookPath("snapper"); err != nil {
|
||||
return false
|
||||
}
|
||||
_, err := os.Stat("/etc/snapper/configs/" + Config)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Pre creates a "pre" snapshot and returns its number.
|
||||
func Pre(ctx context.Context, description string) (int, error) {
|
||||
out, err := exec.CommandContext(ctx, "snapper", "-c", Config, "create",
|
||||
"--type", "pre", "--print-number", "--cleanup-algorithm", "number",
|
||||
"--description", description).CombinedOutput()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("snapper create: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return ParseNumber(string(out))
|
||||
}
|
||||
|
||||
// Post creates the "post" snapshot paired with pre.
|
||||
func Post(ctx context.Context, pre int, description string) error {
|
||||
out, err := exec.CommandContext(ctx, "snapper", "-c", Config, "create",
|
||||
"--type", "post", "--pre-number", strconv.Itoa(pre), "--cleanup-algorithm", "number",
|
||||
"--description", description).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("snapper create: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseNumber reads the snapshot number printed by "snapper create --print-number".
|
||||
func ParseNumber(out string) (int, error) {
|
||||
s := strings.TrimSpace(out)
|
||||
if i := strings.LastIndexByte(s, '\n'); i >= 0 {
|
||||
s = s[i+1:]
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil || n <= 0 {
|
||||
return 0, fmt.Errorf("snapper printed %q, expected a snapshot number", out)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package snapper
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseNumber(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want int
|
||||
wantErr bool
|
||||
}{
|
||||
{"42\n", 42, false},
|
||||
{"warning: something\n7\n", 7, false},
|
||||
{"", 0, true},
|
||||
{"0\n", 0, true},
|
||||
{"Creating snapshot failed.\n", 0, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, err := ParseNumber(tt.in)
|
||||
if got != tt.want || (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseNumber(%q) = %d, %v; want %d, error %v", tt.in, got, err, tt.want, tt.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package system answers questions about the running Das OS system.
|
||||
package system
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IsLive reports whether this is the live ISO session: mklive boots it
|
||||
// with root=live:CDLABEL=... on the kernel command line.
|
||||
func IsLive() bool {
|
||||
data, err := os.ReadFile("/proc/cmdline")
|
||||
return err == nil && liveRoot(string(data))
|
||||
}
|
||||
|
||||
func liveRoot(cmdline string) bool {
|
||||
for _, p := range strings.Fields(cmdline) {
|
||||
if strings.HasPrefix(p, "root=live:") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Lang is the session language, as passed to helpers that run with a
|
||||
// cleared environment.
|
||||
func Lang() string {
|
||||
for _, name := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} {
|
||||
if v := os.Getenv(name); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return "C"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package system
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLiveRoot(t *testing.T) {
|
||||
tests := map[string]bool{
|
||||
"initrd=/boot/initrd root=live:CDLABEL=VOID_LIVE init=/sbin/init ro live.user=live\n": true,
|
||||
"BOOT_IMAGE=/@/boot/vmlinuz-6.12.50_1 root=UUID=1234 ro rootflags=subvol=@ quiet\n": false,
|
||||
"": false,
|
||||
}
|
||||
for cmdline, want := range tests {
|
||||
if got := liveRoot(cmdline); got != want {
|
||||
t.Errorf("liveRoot(%q) = %v, want %v", cmdline, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// GtkStatusIcon is deprecated in GTK 3 but is the XEmbed tray icon that the
|
||||
// Xfce panel's status tray shows, and gotk3 does not wrap it.
|
||||
#define GDK_DISABLE_DEPRECATION_WARNINGS
|
||||
#include <gtk/gtk.h>
|
||||
#include "tray.h"
|
||||
|
||||
static void on_activate(GtkStatusIcon *icon, gpointer data) {
|
||||
dasTrayActivate();
|
||||
}
|
||||
|
||||
static void on_popup(GtkStatusIcon *icon, guint button, guint time, gpointer data) {
|
||||
dasTrayPopup();
|
||||
}
|
||||
|
||||
GtkStatusIcon *das_tray_new(const char *icon_name) {
|
||||
GtkStatusIcon *icon = gtk_status_icon_new_from_icon_name(icon_name);
|
||||
gtk_status_icon_set_visible(icon, FALSE);
|
||||
g_signal_connect(icon, "activate", G_CALLBACK(on_activate), NULL);
|
||||
g_signal_connect(icon, "popup-menu", G_CALLBACK(on_popup), NULL);
|
||||
return icon;
|
||||
}
|
||||
|
||||
void das_tray_set(GtkStatusIcon *icon, gboolean visible, const char *tooltip) {
|
||||
gtk_status_icon_set_tooltip_text(icon, tooltip);
|
||||
gtk_status_icon_set_visible(icon, visible);
|
||||
}
|
||||
|
||||
void das_tray_popup(uintptr_t menu) {
|
||||
gtk_menu_popup_at_pointer(GTK_MENU((gpointer)menu), NULL);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Package tray shows a notification area icon with a click action and a
|
||||
// right-click menu. Only one icon per process is supported.
|
||||
package tray
|
||||
|
||||
/*
|
||||
#cgo pkg-config: gtk+-3.0
|
||||
#include <stdlib.h>
|
||||
#include "tray.h"
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
)
|
||||
|
||||
// Icon is the tray icon. Call its methods on the GTK main thread only.
|
||||
type Icon struct {
|
||||
icon *C.GtkStatusIcon
|
||||
}
|
||||
|
||||
var (
|
||||
onActivate func()
|
||||
menu *gtk.Menu
|
||||
)
|
||||
|
||||
// New creates a hidden icon. activate runs on a left click; m pops up on a
|
||||
// right click.
|
||||
func New(iconName string, activate func(), m *gtk.Menu) *Icon {
|
||||
name := C.CString(iconName)
|
||||
defer C.free(unsafe.Pointer(name))
|
||||
onActivate, menu = activate, m
|
||||
return &Icon{icon: C.das_tray_new(name)}
|
||||
}
|
||||
|
||||
// Show shows the icon with a tooltip.
|
||||
func (i *Icon) Show(tooltip string) { i.set(true, tooltip) }
|
||||
|
||||
// Hide removes the icon from the tray.
|
||||
func (i *Icon) Hide() { i.set(false, "") }
|
||||
|
||||
func (i *Icon) set(visible bool, tooltip string) {
|
||||
t := C.CString(tooltip)
|
||||
defer C.free(unsafe.Pointer(t))
|
||||
v := C.gboolean(0)
|
||||
if visible {
|
||||
v = 1
|
||||
}
|
||||
C.das_tray_set(i.icon, v, t)
|
||||
}
|
||||
|
||||
//export dasTrayActivate
|
||||
func dasTrayActivate() {
|
||||
if onActivate != nil {
|
||||
onActivate()
|
||||
}
|
||||
}
|
||||
|
||||
//export dasTrayPopup
|
||||
func dasTrayPopup() {
|
||||
if menu != nil {
|
||||
menu.ShowAll()
|
||||
C.das_tray_popup(C.uintptr_t(menu.Native()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#include <stdint.h>
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
GtkStatusIcon *das_tray_new(const char *icon_name);
|
||||
void das_tray_set(GtkStatusIcon *icon, gboolean visible, const char *tooltip);
|
||||
void das_tray_popup(uintptr_t menu);
|
||||
|
||||
// Implemented in Go (tray.go).
|
||||
extern void dasTrayActivate(void);
|
||||
extern void dasTrayPopup(void);
|
||||
@@ -0,0 +1,128 @@
|
||||
// Package update runs a full system update wrapped in a snapper pre/post
|
||||
// snapshot pair, so a broken update can be rolled back from the boot menu.
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
|
||||
"dasos/tools/internal/i18n"
|
||||
"dasos/tools/internal/snapper"
|
||||
"dasos/tools/internal/xbps"
|
||||
)
|
||||
|
||||
// Description marks the snapshots in "snapper list".
|
||||
const Description = "das-update"
|
||||
|
||||
// Reporter receives the progress of Apply.
|
||||
type Reporter interface {
|
||||
Step(title string)
|
||||
// Output receives the output of the commands Apply runs.
|
||||
Output() io.Writer
|
||||
// Snapshot is called once the "pre" snapshot exists.
|
||||
Snapshot(n int)
|
||||
}
|
||||
|
||||
// Result describes a finished update.
|
||||
type Result struct {
|
||||
Updates []xbps.Update
|
||||
Snapshot int // number of the "pre" snapshot, 0 without snapshots
|
||||
Reboot bool // the kernel or core libraries changed
|
||||
}
|
||||
|
||||
// Updater holds the system operations, replaced in tests.
|
||||
type Updater struct {
|
||||
Run func(ctx context.Context, out io.Writer, name string, args ...string) error
|
||||
Pending func(ctx context.Context) ([]xbps.Update, error)
|
||||
Snapshots func() bool
|
||||
Pre func(ctx context.Context, description string) (int, error)
|
||||
Post func(ctx context.Context, pre int, description string) error
|
||||
}
|
||||
|
||||
// System returns the Updater for the running system. It needs root.
|
||||
func System() *Updater {
|
||||
return &Updater{
|
||||
Run: run,
|
||||
Pending: pending,
|
||||
Snapshots: snapper.Available,
|
||||
Pre: snapper.Pre,
|
||||
Post: snapper.Post,
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, out io.Writer, name string, args ...string) error {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Stdout = out
|
||||
cmd.Stderr = out
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pending lists updates against the index synced by the first step.
|
||||
func pending(ctx context.Context) ([]xbps.Update, error) {
|
||||
out, err := exec.CommandContext(ctx, "xbps-install", "-un").CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xbps-install -un: %w: %s", err, out)
|
||||
}
|
||||
return xbps.ParseDryRun(string(out)), nil
|
||||
}
|
||||
|
||||
// Apply updates the whole system. Without pending updates it does nothing
|
||||
// and returns an empty Result. When snapshots are set up, a failure to take
|
||||
// the "pre" snapshot stops the update: updating without a way back is
|
||||
// exactly what das-update exists to prevent.
|
||||
func (u *Updater) Apply(ctx context.Context, r Reporter) (Result, error) {
|
||||
var res Result
|
||||
|
||||
r.Step(i18n.T("update.step.sync"))
|
||||
if err := u.Run(ctx, r.Output(), "xbps-install", "-S"); err != nil {
|
||||
return res, err
|
||||
}
|
||||
updates, err := u.Pending(ctx)
|
||||
if err != nil {
|
||||
// An outdated xbps refuses to plan the update; it is updated first anyway.
|
||||
updates = []xbps.Update{{Name: "xbps", Action: "update"}}
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return res, nil
|
||||
}
|
||||
res.Updates = updates
|
||||
res.Reboot = xbps.NeedsReboot(updates)
|
||||
|
||||
if u.Snapshots() {
|
||||
r.Step(i18n.T("update.step.pre"))
|
||||
n, err := u.Pre(ctx, Description)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Snapshot = n
|
||||
r.Snapshot(n)
|
||||
} else {
|
||||
r.Step(i18n.T("update.nosnapshots"))
|
||||
}
|
||||
|
||||
err = u.install(ctx, r, updates)
|
||||
if res.Snapshot > 0 {
|
||||
// Taken after a failure too: the pair shows what the update changed.
|
||||
r.Step(i18n.T("update.step.post"))
|
||||
if perr := u.Post(ctx, res.Snapshot, Description); perr != nil && err == nil {
|
||||
err = perr
|
||||
}
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (u *Updater) install(ctx context.Context, r Reporter, updates []xbps.Update) error {
|
||||
if xbps.SelfUpdate(updates) {
|
||||
r.Step(i18n.T("update.step.xbps"))
|
||||
if err := u.Run(ctx, r.Output(), "xbps-install", "-yu", "xbps"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
r.Step(i18n.T("update.step.install"))
|
||||
return u.Run(ctx, r.Output(), "xbps-install", "-yu")
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"dasos/tools/internal/xbps"
|
||||
)
|
||||
|
||||
type recorder struct {
|
||||
steps []string
|
||||
snapshot int
|
||||
}
|
||||
|
||||
func (r *recorder) Step(title string) { r.steps = append(r.steps, title) }
|
||||
func (r *recorder) Output() io.Writer { return io.Discard }
|
||||
func (r *recorder) Snapshot(n int) { r.snapshot = n }
|
||||
|
||||
// fake records the system operations in calls.
|
||||
type fake struct {
|
||||
calls []string
|
||||
pending []xbps.Update
|
||||
snapshots bool
|
||||
failOn string // command line that fails
|
||||
}
|
||||
|
||||
func (f *fake) updater() *Updater {
|
||||
return &Updater{
|
||||
Run: func(_ context.Context, _ io.Writer, name string, args ...string) error {
|
||||
line := strings.Join(append([]string{name}, args...), " ")
|
||||
f.calls = append(f.calls, line)
|
||||
if line == f.failOn {
|
||||
return errors.New("failed")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Pending: func(context.Context) ([]xbps.Update, error) { return f.pending, nil },
|
||||
Snapshots: func() bool { return f.snapshots },
|
||||
Pre: func(context.Context, string) (int, error) {
|
||||
f.calls = append(f.calls, "pre")
|
||||
return 12, nil
|
||||
},
|
||||
Post: func(_ context.Context, pre int, _ string) error {
|
||||
f.calls = append(f.calls, "post")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyUpToDate(t *testing.T) {
|
||||
f := &fake{snapshots: true}
|
||||
res, err := f.updater().Apply(context.Background(), &recorder{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []string{"xbps-install -S"}; !reflect.DeepEqual(f.calls, want) {
|
||||
t.Errorf("calls = %q, want %q", f.calls, want)
|
||||
}
|
||||
if len(res.Updates) != 0 || res.Snapshot != 0 {
|
||||
t.Errorf("result = %+v, want empty", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyWithSnapshots(t *testing.T) {
|
||||
f := &fake{snapshots: true, pending: []xbps.Update{{Name: "linux6.12"}, {Name: "xbps"}}}
|
||||
r := &recorder{}
|
||||
res, err := f.updater().Apply(context.Background(), r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"xbps-install -S", "pre", "xbps-install -yu xbps", "xbps-install -yu", "post"}
|
||||
if !reflect.DeepEqual(f.calls, want) {
|
||||
t.Errorf("calls = %q, want %q", f.calls, want)
|
||||
}
|
||||
if res.Snapshot != 12 || r.snapshot != 12 || !res.Reboot {
|
||||
t.Errorf("result = %+v, reported snapshot %d; want snapshot 12 and reboot", res, r.snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFailureStillTakesPost(t *testing.T) {
|
||||
f := &fake{snapshots: true, pending: []xbps.Update{{Name: "firefox"}}, failOn: "xbps-install -yu"}
|
||||
res, err := f.updater().Apply(context.Background(), &recorder{})
|
||||
if err == nil {
|
||||
t.Fatal("Apply succeeded, want the install error")
|
||||
}
|
||||
want := []string{"xbps-install -S", "pre", "xbps-install -yu", "post"}
|
||||
if !reflect.DeepEqual(f.calls, want) {
|
||||
t.Errorf("calls = %q, want %q", f.calls, want)
|
||||
}
|
||||
if res.Snapshot != 12 || res.Reboot {
|
||||
t.Errorf("result = %+v, want snapshot 12, no reboot", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyWithoutSnapshots(t *testing.T) {
|
||||
f := &fake{pending: []xbps.Update{{Name: "firefox"}}}
|
||||
res, err := f.updater().Apply(context.Background(), &recorder{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"xbps-install -S", "xbps-install -yu"}
|
||||
if !reflect.DeepEqual(f.calls, want) {
|
||||
t.Errorf("calls = %q, want %q", f.calls, want)
|
||||
}
|
||||
if res.Snapshot != 0 {
|
||||
t.Errorf("snapshot = %d, want 0", res.Snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyStopsWhenPreSnapshotFails(t *testing.T) {
|
||||
f := &fake{snapshots: true, pending: []xbps.Update{{Name: "firefox"}}}
|
||||
u := f.updater()
|
||||
u.Pre = func(context.Context, string) (int, error) { return 0, errors.New("no space") }
|
||||
if _, err := u.Apply(context.Background(), &recorder{}); err == nil {
|
||||
t.Fatal("Apply succeeded, want the snapshot error")
|
||||
}
|
||||
if want := []string{"xbps-install -S"}; !reflect.DeepEqual(f.calls, want) {
|
||||
t.Errorf("calls = %q, want %q (nothing installed)", f.calls, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// Package userdirs renames the XDG user folders (Desktop, Documents...) to
|
||||
// the names of the session language.
|
||||
//
|
||||
// xdg-user-dirs 0.20 loads its translations only from the first
|
||||
// XDG_DATA_DIRS entry that has a locale directory. In a Void session that is
|
||||
// the empty /usr/local/share/locale, so folders are created with English
|
||||
// names whatever the language. das-settings works around it for new users;
|
||||
// this package fixes folders that were already created.
|
||||
package userdirs
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Rename moves one user folder.
|
||||
type Rename struct {
|
||||
Key string // DESKTOP, DOCUMENTS ...
|
||||
From, To string // absolute paths
|
||||
}
|
||||
|
||||
// Dirs maps a key such as DOCUMENTS to its value in user-dirs.dirs, e.g. "$HOME/Documents".
|
||||
type Dirs map[string]string
|
||||
|
||||
var line = regexp.MustCompile(`^XDG_([A-Z]+)_DIR="(.*)"$`)
|
||||
|
||||
// Parse reads the user-dirs.dirs format.
|
||||
func Parse(data string) Dirs {
|
||||
d := Dirs{}
|
||||
for _, l := range strings.Split(data, "\n") {
|
||||
if m := line.FindStringSubmatch(strings.TrimSpace(l)); m != nil {
|
||||
d[m[1]] = m[2]
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// ConfigFile returns the path of the user's user-dirs.dirs.
|
||||
func ConfigFile(home string) string {
|
||||
if c := os.Getenv("XDG_CONFIG_HOME"); c != "" {
|
||||
return filepath.Join(c, "user-dirs.dirs")
|
||||
}
|
||||
return filepath.Join(home, ".config", "user-dirs.dirs")
|
||||
}
|
||||
|
||||
// Defaults asks xdg-user-dirs-update which folder names it would create for
|
||||
// the given LC_ALL value ("" keeps the session language), with a lookup
|
||||
// path that finds the translations. It runs in a scratch home.
|
||||
func Defaults(ctx context.Context, lcAll string) (Dirs, error) {
|
||||
tmp, err := os.MkdirTemp("", "das-userdirs-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
|
||||
cmd := exec.CommandContext(ctx, "xdg-user-dirs-update")
|
||||
cmd.Env = append(os.Environ(), "HOME="+tmp, "XDG_CONFIG_HOME="+filepath.Join(tmp, ".config"),
|
||||
"XDG_DATA_DIRS=/usr/share")
|
||||
if lcAll != "" {
|
||||
cmd.Env = append(cmd.Env, "LC_ALL="+lcAll)
|
||||
}
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("xdg-user-dirs-update: %w: %s", err, out)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(tmp, ".config", "user-dirs.dirs"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Parse(string(data)), nil
|
||||
}
|
||||
|
||||
// Plan lists the folders still named as in english that have a different
|
||||
// name in localized. Folders the user moved or renamed are left alone, and
|
||||
// so are those whose new name is already taken.
|
||||
func Plan(home string, current, english, localized Dirs) []Rename {
|
||||
var renames []Rename
|
||||
for key, value := range current {
|
||||
if value != english[key] || localized[key] == "" || localized[key] == value {
|
||||
continue
|
||||
}
|
||||
from, to := Expand(home, value), Expand(home, localized[key])
|
||||
if from == home || to == home {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Lstat(to); err == nil {
|
||||
continue
|
||||
}
|
||||
renames = append(renames, Rename{Key: key, From: from, To: to})
|
||||
}
|
||||
sort.Slice(renames, func(i, j int) bool { return renames[i].Key < renames[j].Key })
|
||||
return renames
|
||||
}
|
||||
|
||||
// Expand turns a user-dirs.dirs value into an absolute path.
|
||||
func Expand(home, value string) string {
|
||||
if value == "$HOME" {
|
||||
return home
|
||||
}
|
||||
if strings.HasPrefix(value, "$HOME/") {
|
||||
return filepath.Join(home, value[len("$HOME/"):])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// Apply renames the folders, then points user-dirs.dirs and the GTK file
|
||||
// chooser bookmarks at the new names.
|
||||
func Apply(home string, renames []Rename) error {
|
||||
for _, r := range renames {
|
||||
if err := os.Rename(r.From, r.To); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
config := ConfigFile(home)
|
||||
data, err := os.ReadFile(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFile(config, rewriteConfig(string(data), home, renames)); err != nil {
|
||||
return err
|
||||
}
|
||||
bookmarks := filepath.Join(filepath.Dir(config), "gtk-3.0", "bookmarks")
|
||||
if data, err := os.ReadFile(bookmarks); err == nil {
|
||||
return writeFile(bookmarks, rewriteBookmarks(string(data), renames))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rewriteConfig(data, home string, renames []Rename) string {
|
||||
lines := strings.Split(data, "\n")
|
||||
for i, l := range lines {
|
||||
m := line.FindStringSubmatch(strings.TrimSpace(l))
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
for _, r := range renames {
|
||||
if r.Key == m[1] {
|
||||
lines[i] = fmt.Sprintf("XDG_%s_DIR=%q", r.Key, collapse(home, r.To))
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// collapse is the inverse of Expand.
|
||||
func collapse(home, path string) string {
|
||||
if rel, err := filepath.Rel(home, path); err == nil && !strings.HasPrefix(rel, "..") {
|
||||
return "$HOME/" + rel
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// rewriteBookmarks updates "file:///path [label]" lines of the GTK bookmarks file.
|
||||
func rewriteBookmarks(data string, renames []Rename) string {
|
||||
var b strings.Builder
|
||||
sc := bufio.NewScanner(strings.NewReader(data))
|
||||
for sc.Scan() {
|
||||
l := sc.Text()
|
||||
for _, r := range renames {
|
||||
old := fileURI(r.From)
|
||||
if l == old || strings.HasPrefix(l, old+" ") {
|
||||
l = fileURI(r.To) + l[len(old):]
|
||||
}
|
||||
}
|
||||
b.WriteString(l + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func fileURI(path string) string {
|
||||
return (&url.URL{Scheme: "file", Path: path}).String()
|
||||
}
|
||||
|
||||
// writeFile replaces path atomically, keeping its permissions.
|
||||
func writeFile(path, data string) error {
|
||||
mode := os.FileMode(0o644)
|
||||
if fi, err := os.Stat(path); err == nil {
|
||||
mode = fi.Mode().Perm()
|
||||
}
|
||||
tmp := path + ".das-tmp"
|
||||
if err := os.WriteFile(tmp, []byte(data), mode); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package userdirs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const englishConfig = `# This file is written by xdg-user-dirs-update
|
||||
XDG_DESKTOP_DIR="$HOME/Desktop"
|
||||
XDG_DOCUMENTS_DIR="$HOME/Documents"
|
||||
XDG_DOWNLOAD_DIR="$HOME/Downloads"
|
||||
XDG_MUSIC_DIR="$HOME/Music"
|
||||
XDG_PUBLICSHARE_DIR="$HOME/Public"
|
||||
`
|
||||
|
||||
var (
|
||||
english = Dirs{"DESKTOP": "$HOME/Desktop", "DOCUMENTS": "$HOME/Documents", "DOWNLOAD": "$HOME/Downloads",
|
||||
"MUSIC": "$HOME/Music", "PUBLICSHARE": "$HOME/Public"}
|
||||
ukrainian = Dirs{"DESKTOP": "$HOME/Стільниця", "DOCUMENTS": "$HOME/Документи", "DOWNLOAD": "$HOME/Завантажене",
|
||||
"MUSIC": "$HOME/Музика", "PUBLICSHARE": "$HOME/Загальне"}
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
if got := Parse(englishConfig); !reflect.DeepEqual(got, english) {
|
||||
t.Errorf("Parse = %v, want %v", got, english)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanAndApply(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
for _, d := range []string{"Desktop", "Documents", "Music", "Загальне", ".config/gtk-3.0"} {
|
||||
if err := os.MkdirAll(filepath.Join(home, d), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(home, "Documents", "cv.odt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The user moved Music elsewhere: it must stay.
|
||||
config := strings.Replace(englishConfig, `"$HOME/Music"`, `"/data/Music"`, 1)
|
||||
writeTestFile(t, filepath.Join(home, ".config", "user-dirs.dirs"), config)
|
||||
writeTestFile(t, filepath.Join(home, ".config", "gtk-3.0", "bookmarks"),
|
||||
"file://"+home+"/Documents\nfile://"+home+"/Desktop Стіл\nfile:///tmp\n")
|
||||
|
||||
renames := Plan(home, Parse(config), english, ukrainian)
|
||||
want := []Rename{
|
||||
{"DESKTOP", filepath.Join(home, "Desktop"), filepath.Join(home, "Стільниця")},
|
||||
{"DOCUMENTS", filepath.Join(home, "Documents"), filepath.Join(home, "Документи")},
|
||||
// Downloads does not exist yet: only the configuration changes.
|
||||
{"DOWNLOAD", filepath.Join(home, "Downloads"), filepath.Join(home, "Завантажене")},
|
||||
// PUBLICSHARE is skipped: Загальне already exists.
|
||||
}
|
||||
if !reflect.DeepEqual(renames, want) {
|
||||
t.Fatalf("Plan =\n%v\nwant\n%v", renames, want)
|
||||
}
|
||||
|
||||
if err := Apply(home, renames); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, "Документи", "cv.odt")); err != nil {
|
||||
t.Errorf("file not moved with its folder: %v", err)
|
||||
}
|
||||
got := Parse(readTestFile(t, filepath.Join(home, ".config", "user-dirs.dirs")))
|
||||
wantDirs := Dirs{"DESKTOP": "$HOME/Стільниця", "DOCUMENTS": "$HOME/Документи", "DOWNLOAD": "$HOME/Завантажене",
|
||||
"MUSIC": "/data/Music", "PUBLICSHARE": "$HOME/Public"}
|
||||
if !reflect.DeepEqual(got, wantDirs) {
|
||||
t.Errorf("user-dirs.dirs = %v, want %v", got, wantDirs)
|
||||
}
|
||||
bookmarks := readTestFile(t, filepath.Join(home, ".config", "gtk-3.0", "bookmarks"))
|
||||
wantBookmarks := fileURI(filepath.Join(home, "Документи")) + "\n" +
|
||||
fileURI(filepath.Join(home, "Стільниця")) + " Стіл\nfile:///tmp\n"
|
||||
if bookmarks != wantBookmarks {
|
||||
t.Errorf("bookmarks =\n%s\nwant\n%s", bookmarks, wantBookmarks)
|
||||
}
|
||||
|
||||
// Nothing left to do afterwards.
|
||||
if again := Plan(home, got, english, ukrainian); len(again) != 0 {
|
||||
t.Errorf("second Plan = %v, want none", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandCollapse(t *testing.T) {
|
||||
for _, v := range []string{"$HOME/Документи", "/data/Music"} {
|
||||
if got := collapse("/home/u", Expand("/home/u", v)); got != v {
|
||||
t.Errorf("collapse(Expand(%q)) = %q", v, got)
|
||||
}
|
||||
}
|
||||
if got := Expand("/home/u", "$HOME"); got != "/home/u" {
|
||||
t.Errorf("Expand($HOME) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestFile(t *testing.T, path, data string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func readTestFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Package xbps finds pending system updates with xbps.
|
||||
package xbps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Update is one package that a full system update would change.
|
||||
type Update struct {
|
||||
Name string
|
||||
OldVersion string // empty for a new dependency
|
||||
NewVersion string
|
||||
Action string // "update", "install", "remove" ...
|
||||
}
|
||||
|
||||
// Check lists pending updates without root: -M syncs the repository index
|
||||
// into memory only, -n makes it a dry run.
|
||||
func Check(ctx context.Context) ([]Update, error) {
|
||||
out, err := exec.CommandContext(ctx, "xbps-install", "-SMun").CombinedOutput()
|
||||
if err != nil {
|
||||
// xbps refuses to plan anything while xbps itself is outdated
|
||||
if strings.Contains(string(out), "must be updated") {
|
||||
return []Update{{Name: "xbps", Action: "update"}}, nil
|
||||
}
|
||||
if msg := strings.TrimSpace(string(out)); msg != "" {
|
||||
return nil, fmt.Errorf("xbps-install: %w: %s", err, msg)
|
||||
}
|
||||
return nil, fmt.Errorf("xbps-install: %w", err)
|
||||
}
|
||||
updates := ParseDryRun(string(out))
|
||||
if len(updates) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
listed, err := exec.CommandContext(ctx, "xbps-query", "-l").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xbps-query: %w", err)
|
||||
}
|
||||
installed := ParseInstalled(string(listed))
|
||||
for i := range updates {
|
||||
updates[i].OldVersion = installed[updates[i].Name]
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
// ParseDryRun parses "xbps-install -un" output: one transaction entry per
|
||||
// line, "<pkgver> <action> <arch> <repository> [sizes]". Other lines are skipped.
|
||||
func ParseDryRun(out string) []Update {
|
||||
var updates []Update
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 3 {
|
||||
continue
|
||||
}
|
||||
name, version, ok := SplitPkgver(f[0])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch f[1] {
|
||||
case "update", "install", "remove", "reinstall", "downgrade":
|
||||
updates = append(updates, Update{Name: name, NewVersion: version, Action: f[1]})
|
||||
}
|
||||
}
|
||||
sort.Slice(updates, func(i, j int) bool { return updates[i].Name < updates[j].Name })
|
||||
return updates
|
||||
}
|
||||
|
||||
// ParseInstalled parses "xbps-query -l" ("ii <pkgver> <description>") into
|
||||
// a name → version map.
|
||||
func ParseInstalled(out string) map[string]string {
|
||||
m := make(map[string]string)
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 2 {
|
||||
continue
|
||||
}
|
||||
if name, version, ok := SplitPkgver(f[1]); ok {
|
||||
m[name] = version
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// SplitPkgver splits "name-version_revision" at the last dash. The version
|
||||
// part always contains "_revision", which tells pkgvers from other words.
|
||||
func SplitPkgver(pkgver string) (name, version string, ok bool) {
|
||||
i := strings.LastIndexByte(pkgver, '-')
|
||||
if i <= 0 || i == len(pkgver)-1 || !strings.Contains(pkgver[i+1:], "_") {
|
||||
return "", "", false
|
||||
}
|
||||
return pkgver[:i], pkgver[i+1:], true
|
||||
}
|
||||
|
||||
// NeedsReboot reports whether updating these packages replaces something
|
||||
// that a running system keeps loaded: the kernel, the C library or the
|
||||
// init system.
|
||||
func NeedsReboot(updates []Update) bool {
|
||||
for _, u := range updates {
|
||||
switch {
|
||||
case u.Name == "glibc", u.Name == "runit", u.Name == "runit-void", u.Name == "dbus",
|
||||
u.Name == "elogind", u.Name == "eudev", u.Name == "linux-firmware":
|
||||
return true
|
||||
case strings.HasPrefix(u.Name, "linux") && strings.Trim(u.Name[len("linux"):], "0123456789.") == "":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SelfUpdate reports whether xbps itself is in the list. xbps must be
|
||||
// updated on its own before the rest of the system.
|
||||
func SelfUpdate(updates []Update) bool {
|
||||
for _, u := range updates {
|
||||
if u.Name == "xbps" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package xbps
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseDryRun(t *testing.T) {
|
||||
out := `[*] Updating repository ` + "`https://repo-default.voidlinux.org/current/x86_64-repodata'" + ` ...
|
||||
firefox-144.0_1 update x86_64 https://repo-default.voidlinux.org/current 245MB 70MB
|
||||
libfoo-2.1_3 install x86_64 https://repo-default.voidlinux.org/current 1024KB 300KB
|
||||
linux6.12-6.12.50_1 update x86_64 https://repo-default.voidlinux.org/current
|
||||
gtk+3-3.24.50_1 update x86_64 https://repo-default.voidlinux.org/current
|
||||
oldpkg-1.0_1 remove x86_64 https://repo-default.voidlinux.org/current
|
||||
bash-5.3_2 configure x86_64 https://repo-default.voidlinux.org/current
|
||||
`
|
||||
got := ParseDryRun(out)
|
||||
want := []Update{
|
||||
{Name: "firefox", NewVersion: "144.0_1", Action: "update"},
|
||||
{Name: "gtk+3", NewVersion: "3.24.50_1", Action: "update"},
|
||||
{Name: "libfoo", NewVersion: "2.1_3", Action: "install"},
|
||||
{Name: "linux6.12", NewVersion: "6.12.50_1", Action: "update"},
|
||||
{Name: "oldpkg", NewVersion: "1.0_1", Action: "remove"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("ParseDryRun:\n got %+v\nwant %+v", got, want)
|
||||
}
|
||||
if got := ParseDryRun(""); len(got) != 0 {
|
||||
t.Errorf("ParseDryRun(empty) = %+v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInstalled(t *testing.T) {
|
||||
out := "ii acl-2.4.0_1 Access Control List filesystem support\n" +
|
||||
"ii base-container-0.3_3 Void Linux base system meta\n" +
|
||||
"\n"
|
||||
want := map[string]string{"acl": "2.4.0_1", "base-container": "0.3_3"}
|
||||
if got := ParseInstalled(out); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("ParseInstalled = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitPkgver(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, name, version string
|
||||
ok bool
|
||||
}{
|
||||
{"xbps-0.60.5_1", "xbps", "0.60.5_1", true},
|
||||
{"font-jetbrains-mono-2.304_1", "font-jetbrains-mono", "2.304_1", true},
|
||||
{"[*]", "", "", false},
|
||||
{"Updating", "", "", false},
|
||||
{"name-", "", "", false},
|
||||
{"no-revision-1.0", "", "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
name, version, ok := SplitPkgver(tt.in)
|
||||
if name != tt.name || version != tt.version || ok != tt.ok {
|
||||
t.Errorf("SplitPkgver(%q) = %q, %q, %v; want %q, %q, %v", tt.in, name, version, ok, tt.name, tt.version, tt.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsReboot(t *testing.T) {
|
||||
tests := []struct {
|
||||
names []string
|
||||
want bool
|
||||
}{
|
||||
{[]string{"firefox", "mousepad"}, false},
|
||||
{[]string{"linux6.12"}, true},
|
||||
{[]string{"linux"}, true},
|
||||
{[]string{"glibc"}, true},
|
||||
{[]string{"linux-headers", "linux-firmware-network"}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
var updates []Update
|
||||
for _, n := range tt.names {
|
||||
updates = append(updates, Update{Name: n})
|
||||
}
|
||||
if got := NeedsReboot(updates); got != tt.want {
|
||||
t.Errorf("NeedsReboot(%v) = %v, want %v", tt.names, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# Template file for 'das-tools'
|
||||
# In-tree package: Go sources are in files/src (FILESDIR).
|
||||
pkgname=das-tools
|
||||
version=0.1.0
|
||||
revision=1
|
||||
create_wrksrc=yes
|
||||
# Like build_style=go: the Go linker decides about PIE, xbps-src must not check it
|
||||
nopie=yes
|
||||
hostmakedepends="go pkg-config"
|
||||
makedepends="gtk+3-devel"
|
||||
depends="dbus libnotify polkit sudo xdg-user-dirs xdg-utils"
|
||||
short_desc="Das OS tools: das-update (updates with snapshots) and das-welcome"
|
||||
maintainer="Das <anry.das@gmail.com>"
|
||||
license="MIT"
|
||||
homepage="https://git.das.org.ua/das/Das-OS"
|
||||
|
||||
do_build() {
|
||||
cp -rL "${FILESDIR}/src/." .
|
||||
export GOCACHE="${wrksrc}/.gocache" GOMODCACHE="${wrksrc}/.gomodcache"
|
||||
export GOTOOLCHAIN=local GOFLAGS="-trimpath -modcacherw"
|
||||
go test ./...
|
||||
go build -o bin/ ./cmd/...
|
||||
}
|
||||
|
||||
do_install() {
|
||||
vbin bin/das-update
|
||||
vbin bin/das-welcome
|
||||
vinstall "${FILESDIR}/data/das-update.desktop" 644 usr/share/applications
|
||||
vinstall "${FILESDIR}/data/das-welcome.desktop" 644 usr/share/applications
|
||||
vinstall "${FILESDIR}/data/das-update-tray.desktop" 644 etc/xdg/autostart
|
||||
vinstall "${FILESDIR}/data/das-welcome-autostart.desktop" 644 etc/xdg/autostart das-welcome.desktop
|
||||
vinstall "${FILESDIR}/data/ua.org.das.update.policy" 644 usr/share/polkit-1/actions
|
||||
vlicense "${FILESDIR}/LICENSE"
|
||||
}
|
||||
Reference in New Issue
Block a user