Display arrangement persistence + centering fix, packaging hardening
- core/display.py: write_positions() persists Arrangement's drag-and-drop
layout into niri's config.kdl (one output { position x= y= } per output),
validated via `niri validate` on a temp copy with a .kdl.bak backup before
writing — same pattern as core/keybindings.py's rebind(). Previously the
page only ever called `niri msg output … position set`, which niri treats
as live-only and drops on the next login/reload.
- gui/pages/display.py: Arrangement's Apply now runs each output's `niri msg
output … position set` synchronously instead of queuing them all on the
single-shot ProcessRunner (which rejects a second run() while the first is
still async) — a 2-monitor apply previously moved only the first output
and silently dropped the rest. _dock_to_nearest keeps the free axis at the
dragged position (so a shorter display can sit vertically centered next to
a taller rotated one) rather than forcing corner alignment.
- core/panel.py, files/__init__.py: incidental fixes alongside the above.
- packaging/: signing-key generation script + build-user systemd setup for
the [tanin] AUR auto-rebuild pipeline; PKGBUILD bumped to pkgrel=5.
- src/taninux/browser/: new module for browser theme sync (Fuji accent).
This commit is contained in:
@@ -25,7 +25,10 @@ The central `taninux` app is organized around five pillars:
|
|||||||
| `tsettings` | Settings app (network, display, audio, users, …) |
|
| `tsettings` | Settings app (network, display, audio, users, …) |
|
||||||
| `thub` | Software Hub (repo/AUR/flatpak install) |
|
| `thub` | Software Hub (repo/AUR/flatpak install) |
|
||||||
| `tfiles` | Native GTK4 file manager |
|
| `tfiles` | Native GTK4 file manager |
|
||||||
| `tmusic` | Music app |
|
|
||||||
|
`tmusic` (TIDAL client) has moved to its own repo, `taninux-music`
|
||||||
|
(GPL-3.0, depends on `taninux[gui]`), and is no longer part of the base
|
||||||
|
ISO.
|
||||||
|
|
||||||
The GTK apps run on a custom libadwaita-free backend
|
The GTK apps run on a custom libadwaita-free backend
|
||||||
(`src/taninux/gui/ui/native.py` + `style_apple.css`); `TANINUX_UI=adw`
|
(`src/taninux/gui/ui/native.py` + `style_apple.css`); `TANINUX_UI=adw`
|
||||||
|
|||||||
+91
-15
@@ -44,7 +44,7 @@ AUR/Binär-Pakete dürfen **nicht** nach `/home` schreiben. Daher:
|
|||||||
`taninux.kgva.ch` als Reverse-Proxy/Vanity davor.
|
`taninux.kgva.ch` als Reverse-Proxy/Vanity davor.
|
||||||
- **Statisches Verzeichnis** hinter `taninux.kgva.ch`: `*.pkg.tar.zst` + `tanin.db`.
|
- **Statisches Verzeichnis** hinter `taninux.kgva.ch`: `*.pkg.tar.zst` + `tanin.db`.
|
||||||
|
|
||||||
`pacman.conf`-Snippet für User:
|
`pacman.conf`-Snippet für **bestehende** User (unverändert, siehe Warnung):
|
||||||
```ini
|
```ini
|
||||||
[tanin]
|
[tanin]
|
||||||
# TEMPORÄR + UNSICHER: TrustAll akzeptiert JEDES Paket ohne Signaturprüfung.
|
# TEMPORÄR + UNSICHER: TrustAll akzeptiert JEDES Paket ohne Signaturprüfung.
|
||||||
@@ -54,22 +54,73 @@ AUR/Binär-Pakete dürfen **nicht** nach `/home` schreiben. Daher:
|
|||||||
Server = https://taninux.kgva.ch/$arch
|
Server = https://taninux.kgva.ch/$arch
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Repo-Signing (TODO)
|
> **Wichtig:** Dieses Snippet bleibt bewusst auf `Optional TrustAll`, auch
|
||||||
|
> nachdem Signing verdrahtet ist (siehe unten) — würde man es hier auf
|
||||||
|
> `Required` umstellen, würde das nächste `-Syu` bei allen bestehenden
|
||||||
|
> Usern gegen das *heute* unsignierte Repo fehlschlagen. Neue Installs
|
||||||
|
> (ISO/Calamares) bekommen `Required DatabaseOptional` bereits vorkonfiguriert
|
||||||
|
> (siehe `iso/overrides/airootfs/etc/pacman.conf`). Bestehende User steigen
|
||||||
|
> über den **Cutover**-Abschnitt unten manuell um, sobald das Repo signiert
|
||||||
|
> ausgeliefert wird.
|
||||||
|
|
||||||
Zielzustand (bevor das Repo öffentlich empfohlen wird):
|
#### Repo-Signing
|
||||||
1. Packager-GPG-Key erzeugen (`gpg --full-gen-key`) und öffentlich ablegen
|
|
||||||
(z. B. `taninux.kgva.ch/tanin.gpg`), damit User ihn importieren können.
|
Verdrahtet (`packaging/`):
|
||||||
2. DB **signiert** bauen: `repo-add -s tanin.db.tar.zst *.pkg.tar.zst`
|
- `packaging/gen-signing-key.sh` erzeugt den Packager-Key EINMALIG
|
||||||
(`-s` signiert DB + jedes hinzugefügte Paket mit dem Packager-Key).
|
(Ed25519, `gpg --batch --gen-key`, interaktive Passphrase — kein
|
||||||
3. Key beim User installieren (`pacman-key --add`, `pacman-key --lsign-key`)
|
Hardcoding) und druckt danach die Export-/Cutover-Schritte aus.
|
||||||
und dann in `pacman.conf` scharfschalten:
|
- `build-tanin-repo.sh`, `finish-tanin-repo.sh`, `aur-autoupdate.sh` lesen
|
||||||
```ini
|
`GPGKEY` aus der Umgebung und **brechen fail-closed ab**, wenn er fehlt —
|
||||||
SigLevel = Required DatabaseOptional
|
es wird nie stillschweigend unsigniert gebaut. Pakete werden mit
|
||||||
```
|
`makepkg --sign` (bzw. bei `aur-autoupdate.sh`, das im isolierten
|
||||||
→ jedes Paket muss signiert sein; die DB-Signatur ist optional.
|
`makechrootpkg`-Chroot baut, per `gpg --detach-sign` direkt nach dem
|
||||||
4. **Calamares / ISO** ✅ — `archiso`-Profil mit `[tanin]` vorkonfiguriert +
|
Kopieren aus dem Chroot) signiert, die DB mit `repo-add -s -k "$GPGKEY"`.
|
||||||
|
|
||||||
|
Noch manuell/offen:
|
||||||
|
1. `packaging/gen-signing-key.sh` einmal ausführen (noch nicht geschehen —
|
||||||
|
dieser Repo-Stand enthält KEINEN Key) und den privaten Key sicher
|
||||||
|
sichern (siehe Skript-Output).
|
||||||
|
2. Public Key exportieren nach `iso/overrides/airootfs/etc/pacman.d/tanin-repo.gpg`
|
||||||
|
(+ `iso/build-profile/...`, wird bei jedem `bootstrap-profile.sh`-Lauf
|
||||||
|
aus `overrides/` neu kopiert) und öffentlich unter
|
||||||
|
`taninux.kgva.ch/tanin.gpg` (o. ä.) ablegen.
|
||||||
|
3. In `iso/overrides/airootfs/root/customize_airootfs.sh` eine zweite
|
||||||
|
`pacman-key --lsign-key <packager-fpr>`-Zeile ergänzen (siehe Kommentar
|
||||||
|
dort) — ohne die bleibt der Key zwar importiert, aber nicht getrustet.
|
||||||
|
4. `GPGKEY` in `packaging/systemd/tanin-aur-update.service` setzen (dort
|
||||||
|
als Kommentar vorbereitet) und den geheimen Key ins `GNUPGHOME` des
|
||||||
|
`tanin-build`-Users legen (siehe „Build-Infrastruktur" unten).
|
||||||
|
5. Sobald das ausgelieferte Repo tatsächlich signierte Pakete + DB serviert:
|
||||||
|
bestehende User per **Cutover**-Anleitung (unten) umstellen.
|
||||||
|
6. **Calamares / ISO** ✅ — `archiso`-Profil mit `[tanin]` vorkonfiguriert +
|
||||||
Calamares `packages`-Modul, das `tanin-desktop` installiert
|
Calamares `packages`-Modul, das `tanin-desktop` installiert
|
||||||
(`iso/build-profile/`, gebaute ISO in `iso/out/`).
|
(`iso/build-profile/`, gebaute ISO in `iso/out/`). Neue Installs starten
|
||||||
|
direkt mit `Required DatabaseOptional`.
|
||||||
|
|
||||||
|
#### Cutover für bestehende User (Optional TrustAll → Required DatabaseOptional)
|
||||||
|
|
||||||
|
Erst durchführen, wenn Schritt 1–4 oben erledigt sind und `taninux.kgva.ch`
|
||||||
|
(bzw. die Gitea-Registry) tatsächlich signierte Pakete + eine signierte DB
|
||||||
|
ausliefert — vorher würde `Required` jedes `-Syu` brechen.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1) Packager-Pubkey importieren + lokal signieren (einmalig, als root)
|
||||||
|
curl -o /tmp/tanin-repo.gpg https://taninux.kgva.ch/tanin.gpg # oder gleicher Pfad wie oben
|
||||||
|
pacman-key --add /tmp/tanin-repo.gpg
|
||||||
|
pacman-key --lsign-key <packager-fingerprint> # Fingerprint aus gen-signing-key.sh
|
||||||
|
|
||||||
|
# 2) In /etc/pacman.conf das [tanin]-SigLevel umstellen
|
||||||
|
# von SigLevel = Optional TrustAll
|
||||||
|
# auf SigLevel = Required DatabaseOptional
|
||||||
|
|
||||||
|
# 3) Testen
|
||||||
|
sudo pacman -Sy
|
||||||
|
sudo pacman -S tanin-desktop --dbonly # oder ein beliebiges [tanin]-Paket
|
||||||
|
```
|
||||||
|
|
||||||
|
Schlägt Schritt 3 fehl ("signature is unknown trust" o. ä.), war entweder
|
||||||
|
Schritt 1 unvollständig (Key nicht lsigned) oder das Repo liefert noch
|
||||||
|
nicht signierte Pakete — dann bei `Optional TrustAll` bleiben.
|
||||||
|
|
||||||
## Dark Mode / Portal (Session-Fix)
|
## Dark Mode / Portal (Session-Fix)
|
||||||
|
|
||||||
@@ -83,6 +134,31 @@ exec-once = dbus-update-activation-environment --systemd --all
|
|||||||
exec-once = systemctl --user start <wm>-session.target
|
exec-once = systemctl --user start <wm>-session.target
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Build-Infrastruktur
|
||||||
|
|
||||||
|
Der tägliche AUR-Rebuild (`packaging/aur-autoupdate.sh`, via
|
||||||
|
`packaging/systemd/tanin-aur-update.timer`) braucht `makechrootpkg` /
|
||||||
|
`arch-nspawn` / `mkarchroot` als root — effektiv root-äquivalent (baut
|
||||||
|
beliebige, aus dem AUR gezogene `PKGBUILD`s in einem chroot). Das lief bisher
|
||||||
|
per NOPASSWD-`sudo` auf dem persönlichen Account `karim`. Stattdessen jetzt:
|
||||||
|
ein dedizierter, gesperrter Systemaccount **`tanin-build`**
|
||||||
|
(`packaging/systemd/setup-build-user.sh`, Home `/var/lib/tanin-build`, kein
|
||||||
|
Passwort/Login) — nur dieser Account bekommt den NOPASSWD-Sudo-Grant
|
||||||
|
(`packaging/systemd/tanin-aur-update.sudoers`), und der systemd-Service läuft
|
||||||
|
mit `User=tanin-build`/`Group=tanin-build`. Ein kompromittiertes AUR-PKGBUILD
|
||||||
|
(oder ein Bug in der Pipeline) kann so nicht mehr `karim`s eigentlichen
|
||||||
|
Account root-äquivalent übernehmen — nur den isolierten Build-Account, der
|
||||||
|
sonst nichts kann.
|
||||||
|
|
||||||
|
Konsequenzen für Pfade: Der systemd-Unit nutzt `%h`-Spezifier, die jetzt gegen
|
||||||
|
`tanin-build`s Home auflösen, nicht mehr `/home/karim`. `setup-build-user.sh`
|
||||||
|
synct dafür einmalig einen Checkout nach `/var/lib/tanin-build/taninux` (muss
|
||||||
|
nach `git pull` erneut laufen) und legt `/var/lib/tanin-build/.gnupg` an — dort
|
||||||
|
muss der geheime Packager-Key (siehe „Repo-Signing" oben) importiert sein,
|
||||||
|
sonst kann `tanin-build` nichts signieren. Einrichtung: `sudo
|
||||||
|
packaging/systemd/setup-build-user.sh` (erstellt User, synct Repo, installiert
|
||||||
|
Sudoers-Drop-in + `.service`/`.timer` als System-Units).
|
||||||
|
|
||||||
## Hosts
|
## Hosts
|
||||||
|
|
||||||
- **Code:** `git.openbureau.ch/karim/taninux` (+ Repo für die eww-Config,
|
- **Code:** `git.openbureau.ch/karim/taninux` (+ Repo für die eww-Config,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# pacman.conf for the LIVE system and (via unpackfs) the installed target.
|
# pacman.conf for the LIVE system and (via unpackfs) the installed target.
|
||||||
# [tanin]'s DB-signing key is imported into the keyring by customize_airootfs.sh,
|
# [tanin]'s packager key is imported + locally signed into the keyring by
|
||||||
# so Optional TrustAll verifies cleanly here (unlike the build conf which uses Never).
|
# customize_airootfs.sh, so Required DatabaseOptional (every package must
|
||||||
|
# carry a valid signature from a trusted key; the DB signature itself is
|
||||||
|
# optional) verifies cleanly here.
|
||||||
[options]
|
[options]
|
||||||
HoldPkg = pacman glibc
|
HoldPkg = pacman glibc
|
||||||
Architecture = auto
|
Architecture = auto
|
||||||
@@ -21,5 +23,5 @@ Include = /etc/pacman.d/mirrorlist
|
|||||||
Include = /etc/pacman.d/mirrorlist
|
Include = /etc/pacman.d/mirrorlist
|
||||||
|
|
||||||
[tanin]
|
[tanin]
|
||||||
SigLevel = Optional TrustAll
|
SigLevel = Required DatabaseOptional
|
||||||
Server = https://git.openbureau.ch/api/packages/karim/arch/tanin/$arch
|
Server = https://git.openbureau.ch/api/packages/karim/arch/tanin/$arch
|
||||||
|
|||||||
@@ -6,7 +6,17 @@ set -e -u
|
|||||||
sed -i 's/#\(en_US\.UTF-8 UTF-8\)/\1/' /etc/locale.gen
|
sed -i 's/#\(en_US\.UTF-8 UTF-8\)/\1/' /etc/locale.gen
|
||||||
locale-gen
|
locale-gen
|
||||||
|
|
||||||
# --- trust the [tanin] DB-signing key (so Optional TrustAll verifies) ---
|
# --- trust the [tanin] key(s) (so Required DatabaseOptional verifies) ---
|
||||||
|
# tanin-repo.gpg currently holds Gitea's repo-DB-signing key (fingerprint
|
||||||
|
# below), which covers the optional DB signature. Per-PACKAGE signatures
|
||||||
|
# (now Required) are made by the packaging/gen-signing-key.sh packager key
|
||||||
|
# instead, so after running that script ONCE:
|
||||||
|
# 1) re-export tanin-repo.gpg to also contain the packager pubkey, e.g.
|
||||||
|
# `gpg --export <gitea-fpr> <packager-fpr> > .../pacman.d/tanin-repo.gpg`
|
||||||
|
# (see the exact steps gen-signing-key.sh prints), then
|
||||||
|
# 2) add a second line here: `pacman-key --lsign-key <packager-fpr>`
|
||||||
|
# — without step 2, Required package-signature checks will fail even though
|
||||||
|
# the key is imported, because it isn't locally trusted yet.
|
||||||
pacman-key --init
|
pacman-key --init
|
||||||
pacman-key --populate archlinux
|
pacman-key --populate archlinux
|
||||||
pacman-key --add /etc/pacman.d/tanin-repo.gpg
|
pacman-key --add /etc/pacman.d/tanin-repo.gpg
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
# Build-time pacman.conf for mkarchiso (pulls live packages incl. tanin-desktop).
|
# Build-time pacman.conf for mkarchiso (pulls live packages incl. tanin-desktop).
|
||||||
# Replaces releng's pacman.conf. [tanin] is added so the airootfs can include
|
# Replaces releng's pacman.conf. [tanin] is added so the airootfs can include
|
||||||
# our packages. SigLevel=Never on [tanin] keeps the BUILD simple (no keyring
|
# our packages.
|
||||||
# bootstrap during mkarchiso); the LIVE/target keyring trusts the key properly
|
|
||||||
# (see airootfs/etc/pacman.conf + customize_airootfs.sh).
|
|
||||||
[options]
|
[options]
|
||||||
HoldPkg = pacman glibc
|
HoldPkg = pacman glibc
|
||||||
Architecture = auto
|
Architecture = auto
|
||||||
@@ -21,5 +19,11 @@ Include = /etc/pacman.d/mirrorlist
|
|||||||
Include = /etc/pacman.d/mirrorlist
|
Include = /etc/pacman.d/mirrorlist
|
||||||
|
|
||||||
[tanin]
|
[tanin]
|
||||||
SigLevel = Never
|
# Required DatabaseOptional assumes packaging/gen-signing-key.sh has been run
|
||||||
|
# and its packager pubkey is already trusted on THIS build host's own pacman
|
||||||
|
# keyring (mkarchiso's pacstrap reads the host's /etc/pacman.d/gnupg, not the
|
||||||
|
# airootfs's — that trust is set up separately by customize_airootfs.sh for
|
||||||
|
# the live/target system). Until the key exists and is trusted here, building
|
||||||
|
# will fail closed rather than pull unsigned [tanin] packages into the ISO.
|
||||||
|
SigLevel = Required DatabaseOptional
|
||||||
Server = https://git.openbureau.ch/api/packages/karim/arch/tanin/$arch
|
Server = https://git.openbureau.ch/api/packages/karim/arch/tanin/$arch
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# pacman.conf for the LIVE system and (via unpackfs) the installed target.
|
# pacman.conf for the LIVE system and (via unpackfs) the installed target.
|
||||||
# [tanin]'s DB-signing key is imported into the keyring by customize_airootfs.sh,
|
# [tanin]'s packager key is imported + locally signed into the keyring by
|
||||||
# so Optional TrustAll verifies cleanly here (unlike the build conf which uses Never).
|
# customize_airootfs.sh, so Required DatabaseOptional (every package must
|
||||||
|
# carry a valid signature from a trusted key; the DB signature itself is
|
||||||
|
# optional) verifies cleanly here.
|
||||||
[options]
|
[options]
|
||||||
HoldPkg = pacman glibc
|
HoldPkg = pacman glibc
|
||||||
Architecture = auto
|
Architecture = auto
|
||||||
@@ -21,5 +23,5 @@ Include = /etc/pacman.d/mirrorlist
|
|||||||
Include = /etc/pacman.d/mirrorlist
|
Include = /etc/pacman.d/mirrorlist
|
||||||
|
|
||||||
[tanin]
|
[tanin]
|
||||||
SigLevel = Optional TrustAll
|
SigLevel = Required DatabaseOptional
|
||||||
Server = https://git.openbureau.ch/api/packages/karim/arch/tanin/$arch
|
Server = https://git.openbureau.ch/api/packages/karim/arch/tanin/$arch
|
||||||
|
|||||||
@@ -6,7 +6,17 @@ set -e -u
|
|||||||
sed -i 's/#\(en_US\.UTF-8 UTF-8\)/\1/' /etc/locale.gen
|
sed -i 's/#\(en_US\.UTF-8 UTF-8\)/\1/' /etc/locale.gen
|
||||||
locale-gen
|
locale-gen
|
||||||
|
|
||||||
# --- trust the [tanin] DB-signing key (so Optional TrustAll verifies) ---
|
# --- trust the [tanin] key(s) (so Required DatabaseOptional verifies) ---
|
||||||
|
# tanin-repo.gpg currently holds Gitea's repo-DB-signing key (fingerprint
|
||||||
|
# below), which covers the optional DB signature. Per-PACKAGE signatures
|
||||||
|
# (now Required) are made by the packaging/gen-signing-key.sh packager key
|
||||||
|
# instead, so after running that script ONCE:
|
||||||
|
# 1) re-export tanin-repo.gpg to also contain the packager pubkey, e.g.
|
||||||
|
# `gpg --export <gitea-fpr> <packager-fpr> > .../pacman.d/tanin-repo.gpg`
|
||||||
|
# (see the exact steps gen-signing-key.sh prints), then
|
||||||
|
# 2) add a second line here: `pacman-key --lsign-key <packager-fpr>`
|
||||||
|
# — without step 2, Required package-signature checks will fail even though
|
||||||
|
# the key is imported, because it isn't locally trusted yet.
|
||||||
pacman-key --init
|
pacman-key --init
|
||||||
pacman-key --populate archlinux
|
pacman-key --populate archlinux
|
||||||
pacman-key --add /etc/pacman.d/tanin-repo.gpg
|
pacman-key --add /etc/pacman.d/tanin-repo.gpg
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
# Build-time pacman.conf for mkarchiso (pulls live packages incl. tanin-desktop).
|
# Build-time pacman.conf for mkarchiso (pulls live packages incl. tanin-desktop).
|
||||||
# Replaces releng's pacman.conf. [tanin] is added so the airootfs can include
|
# Replaces releng's pacman.conf. [tanin] is added so the airootfs can include
|
||||||
# our packages. SigLevel=Never on [tanin] keeps the BUILD simple (no keyring
|
# our packages.
|
||||||
# bootstrap during mkarchiso); the LIVE/target keyring trusts the key properly
|
|
||||||
# (see airootfs/etc/pacman.conf + customize_airootfs.sh).
|
|
||||||
[options]
|
[options]
|
||||||
HoldPkg = pacman glibc
|
HoldPkg = pacman glibc
|
||||||
Architecture = auto
|
Architecture = auto
|
||||||
@@ -21,5 +19,11 @@ Include = /etc/pacman.d/mirrorlist
|
|||||||
Include = /etc/pacman.d/mirrorlist
|
Include = /etc/pacman.d/mirrorlist
|
||||||
|
|
||||||
[tanin]
|
[tanin]
|
||||||
SigLevel = Never
|
# Required DatabaseOptional assumes packaging/gen-signing-key.sh has been run
|
||||||
|
# and its packager pubkey is already trusted on THIS build host's own pacman
|
||||||
|
# keyring (mkarchiso's pacstrap reads the host's /etc/pacman.d/gnupg, not the
|
||||||
|
# airootfs's — that trust is set up separately by customize_airootfs.sh for
|
||||||
|
# the live/target system). Until the key exists and is trusted here, building
|
||||||
|
# will fail closed rather than pull unsigned [tanin] packages into the ISO.
|
||||||
|
SigLevel = Required DatabaseOptional
|
||||||
Server = https://git.openbureau.ch/api/packages/karim/arch/tanin/$arch
|
Server = https://git.openbureau.ch/api/packages/karim/arch/tanin/$arch
|
||||||
|
|||||||
@@ -20,6 +20,14 @@
|
|||||||
# TANIN_CHROOT clean chroot location (default ~/.cache/tanin-chroot)
|
# TANIN_CHROOT clean chroot location (default ~/.cache/tanin-chroot)
|
||||||
# TANIN_AUR_CACHE AUR clone + state cache (default ~/.cache/tanin-aur)
|
# TANIN_AUR_CACHE AUR clone + state cache (default ~/.cache/tanin-aur)
|
||||||
# TANIN_PUBLISH_CMD upload hook, e.g. an rsync (default empty = local only)
|
# TANIN_PUBLISH_CMD upload hook, e.g. an rsync (default empty = local only)
|
||||||
|
# GPGKEY packager key fingerprint (REQUIRED — see gen-signing-key.sh)
|
||||||
|
#
|
||||||
|
# All the above default under $HOME, which under the shipped systemd service
|
||||||
|
# (User=tanin-build) resolves to /var/lib/tanin-build — so the dedicated build
|
||||||
|
# user gets its own repo/chroot/cache, isolated from the human account. The
|
||||||
|
# signing key must be reachable by that same user: either its GNUPGHOME
|
||||||
|
# (default ~/.gnupg, i.e. /var/lib/tanin-build/.gnupg under the service) holds
|
||||||
|
# the packager secret key, or GNUPGHOME is pointed at wherever it lives.
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
REPO_NAME="tanin"
|
REPO_NAME="tanin"
|
||||||
@@ -28,6 +36,7 @@ DB="$OUT/$REPO_NAME.db.tar.zst"
|
|||||||
CHROOT="${TANIN_CHROOT:-$HOME/.cache/tanin-chroot}"
|
CHROOT="${TANIN_CHROOT:-$HOME/.cache/tanin-chroot}"
|
||||||
CACHE="${TANIN_AUR_CACHE:-$HOME/.cache/tanin-aur}"
|
CACHE="${TANIN_AUR_CACHE:-$HOME/.cache/tanin-aur}"
|
||||||
PUBLISH="${TANIN_PUBLISH_CMD:-}"
|
PUBLISH="${TANIN_PUBLISH_CMD:-}"
|
||||||
|
GPGKEY="${GPGKEY:-}"
|
||||||
|
|
||||||
# AUR-only deps of TANINUX (not in the official repos). Mirrors the list in
|
# AUR-only deps of TANINUX (not in the official repos). Mirrors the list in
|
||||||
# finish-tanin-repo.sh — keep them in sync.
|
# finish-tanin-repo.sh — keep them in sync.
|
||||||
@@ -36,6 +45,13 @@ AUR_PKGS=(eww-git tiramisu-git waypaper calamares librewolf-bin
|
|||||||
|
|
||||||
log() { printf '[%s] %s\n' "$REPO_NAME-aur" "$*"; }
|
log() { printf '[%s] %s\n' "$REPO_NAME-aur" "$*"; }
|
||||||
|
|
||||||
|
# makechrootpkg builds inside an isolated chroot with no access to the host's
|
||||||
|
# GPG agent/keyring, so packages are signed here on the host, right after
|
||||||
|
# they're copied out of the chroot into $OUT — not with makepkg --sign inside it.
|
||||||
|
sign_pkg() {
|
||||||
|
gpg --batch --yes --detach-sign --use-agent -u "$GPGKEY" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
# --- one-time setup --------------------------------------------------------
|
# --- one-time setup --------------------------------------------------------
|
||||||
setup() {
|
setup() {
|
||||||
sudo pacman -S --needed --noconfirm devtools git jq curl
|
sudo pacman -S --needed --noconfirm devtools git jq curl
|
||||||
@@ -96,7 +112,11 @@ build_one() {
|
|||||||
rm -f "$dir"/*.pkg.tar.zst
|
rm -f "$dir"/*.pkg.tar.zst
|
||||||
# -c = clean copy of the chroot each time; -r = which chroot; -- = makepkg args
|
# -c = clean copy of the chroot each time; -r = which chroot; -- = makepkg args
|
||||||
if ( cd "$dir" && makechrootpkg -c -r "$CHROOT" -- --noconfirm ); then
|
if ( cd "$dir" && makechrootpkg -c -r "$CHROOT" -- --noconfirm ); then
|
||||||
cp "$dir"/*.pkg.tar.zst "$OUT"/ && log " ok: $p"
|
cp "$dir"/*.pkg.tar.zst "$OUT"/ || { log " COPY FAILED: $p"; return 1; }
|
||||||
|
for f in "$dir"/*.pkg.tar.zst; do
|
||||||
|
sign_pkg "$OUT/$(basename "$f")" || { log " SIGN FAILED: $p"; return 1; }
|
||||||
|
done
|
||||||
|
log " ok: $p (signed)"
|
||||||
built_version "$dir" > "$CACHE/$p.ver" 2>/dev/null || true
|
built_version "$dir" > "$CACHE/$p.ver" 2>/dev/null || true
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -106,6 +126,13 @@ build_one() {
|
|||||||
|
|
||||||
run() {
|
run() {
|
||||||
[ -d "$CHROOT/root" ] || { log "no chroot — run '$0 setup' first"; exit 1; }
|
[ -d "$CHROOT/root" ] || { log "no chroot — run '$0 setup' first"; exit 1; }
|
||||||
|
# Fail closed: never build/publish unsigned packages.
|
||||||
|
if [ -z "$GPGKEY" ]; then
|
||||||
|
log "GPGKEY is not set — refusing to build unsigned packages."
|
||||||
|
log "Run packaging/gen-signing-key.sh once (if you haven't), then set GPGKEY"
|
||||||
|
log "(e.g. Environment=GPGKEY=<fingerprint> in tanin-aur-update.service)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
mkdir -p "$OUT" "$CACHE"
|
mkdir -p "$OUT" "$CACHE"
|
||||||
local built=0 failed=0
|
local built=0 failed=0
|
||||||
for p in "${AUR_PKGS[@]}"; do
|
for p in "${AUR_PKGS[@]}"; do
|
||||||
@@ -113,8 +140,8 @@ run() {
|
|||||||
done
|
done
|
||||||
|
|
||||||
if [ "$built" -gt 0 ]; then
|
if [ "$built" -gt 0 ]; then
|
||||||
log "refreshing DB ($built rebuilt)"
|
log "refreshing DB ($built rebuilt, signed with $GPGKEY)"
|
||||||
repo-add -q "$DB" "$OUT"/*.pkg.tar.zst >/dev/null
|
repo-add -s -k "$GPGKEY" -q "$DB" "$OUT"/*.pkg.tar.zst >/dev/null
|
||||||
# keep only the newest file per package on disk
|
# keep only the newest file per package on disk
|
||||||
command -v paccache >/dev/null && paccache -rq -k1 -c "$OUT" >/dev/null 2>&1 || true
|
command -v paccache >/dev/null && paccache -rq -k1 -c "$OUT" >/dev/null 2>&1 || true
|
||||||
if [ -n "$PUBLISH" ]; then
|
if [ -n "$PUBLISH" ]; then
|
||||||
|
|||||||
@@ -16,6 +16,17 @@ DB="$OUT/tanin.db.tar.zst"
|
|||||||
LOGDIR="$OUT/logs"
|
LOGDIR="$OUT/logs"
|
||||||
mkdir -p "$OUT" "$LOGDIR"
|
mkdir -p "$OUT" "$LOGDIR"
|
||||||
|
|
||||||
|
# Fail closed: never build/publish unsigned packages. Run packaging/gen-signing-key.sh
|
||||||
|
# once, then `export GPGKEY=<fingerprint>` (or put it in your shell rc) before building.
|
||||||
|
GPGKEY="${GPGKEY:-}"
|
||||||
|
if [ -z "$GPGKEY" ]; then
|
||||||
|
echo "!! GPGKEY is not set — refusing to build unsigned packages." >&2
|
||||||
|
echo " Run packaging/gen-signing-key.sh once (if you haven't), then:" >&2
|
||||||
|
echo " export GPGKEY=<packager key fingerprint>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export GPGKEY
|
||||||
|
|
||||||
# tanin-icons builds from the installed Adwaita theme -> needs librsvg + python.
|
# tanin-icons builds from the installed Adwaita theme -> needs librsvg + python.
|
||||||
OWN_SIMPLE=(tanin-greet tanin-libadwaita tanin-setup tanin-eww tanin-icons tanin-desktop)
|
OWN_SIMPLE=(tanin-greet tanin-libadwaita tanin-setup tanin-eww tanin-icons tanin-desktop)
|
||||||
OWN_BUILD=(taninux)
|
OWN_BUILD=(taninux)
|
||||||
@@ -27,7 +38,7 @@ AUR_REBUILD=(eww-git tiramisu-git waypaper calamares librewolf-bin
|
|||||||
build() {
|
build() {
|
||||||
local p="$1" log="$LOGDIR/$p.log"
|
local p="$1" log="$LOGDIR/$p.log"
|
||||||
echo "==> building $p (log: $log)"
|
echo "==> building $p (log: $log)"
|
||||||
( cd "$HERE/$p" && makepkg -d -f --noconfirm ) >"$log" 2>&1 \
|
( cd "$HERE/$p" && makepkg -d -f --sign --noconfirm ) >"$log" 2>&1 \
|
||||||
&& cp "$HERE/$p/"*.pkg.tar.zst "$OUT/" 2>/dev/null \
|
&& cp "$HERE/$p/"*.pkg.tar.zst "$OUT/" 2>/dev/null \
|
||||||
&& echo " ok" || { echo " FAILED ($p) — see $log"; return 1; }
|
&& echo " ok" || { echo " FAILED ($p) — see $log"; return 1; }
|
||||||
}
|
}
|
||||||
@@ -46,11 +57,12 @@ echo "** build logs (incl. makepkg warnings) in: $LOGDIR"
|
|||||||
echo "** AUR rebuilds TODO (not in official repos): ${AUR_REBUILD[*]}"
|
echo "** AUR rebuilds TODO (not in official repos): ${AUR_REBUILD[*]}"
|
||||||
echo " build each with paru/makepkg and copy the .pkg.tar.zst into $OUT, then re-run."
|
echo " build each with paru/makepkg and copy the .pkg.tar.zst into $OUT, then re-run."
|
||||||
|
|
||||||
# refresh the DB (unsigned for now -> pacman.conf: SigLevel = Optional TrustAll)
|
# refresh the DB — packages are signed above (makepkg --sign, key=$GPGKEY);
|
||||||
|
# sign the DB too so pacman.conf can require Required DatabaseOptional.
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
pkgs=("$OUT"/*.pkg.tar.zst)
|
pkgs=("$OUT"/*.pkg.tar.zst)
|
||||||
if (( ${#pkgs[@]} )); then
|
if (( ${#pkgs[@]} )); then
|
||||||
( cd "$OUT" && repo-add -q "$DB" ./*.pkg.tar.zst >/dev/null 2>&1 )
|
( cd "$OUT" && repo-add -s -k "$GPGKEY" -q "$DB" ./*.pkg.tar.zst >/dev/null 2>&1 )
|
||||||
echo "== [tanin] repo refreshed at $OUT =="
|
echo "== [tanin] repo refreshed at $OUT =="
|
||||||
ls -1 "$OUT" | grep -E '\.pkg\.tar\.zst$|tanin\.db'
|
ls -1 "$OUT" | grep -E '\.pkg\.tar\.zst$|tanin\.db'
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -11,9 +11,20 @@ HERE="$(cd "$(dirname "$0")" && pwd)"
|
|||||||
OUT="${TANIN_REPO_DIR:-$HOME/projects/tanin-repo}"
|
OUT="${TANIN_REPO_DIR:-$HOME/projects/tanin-repo}"
|
||||||
mkdir -p "$OUT"
|
mkdir -p "$OUT"
|
||||||
|
|
||||||
|
# Fail closed: never build/publish unsigned packages. Run packaging/gen-signing-key.sh
|
||||||
|
# once, then `export GPGKEY=<fingerprint>` before running this script.
|
||||||
|
GPGKEY="${GPGKEY:-}"
|
||||||
|
if [ -z "$GPGKEY" ]; then
|
||||||
|
echo "!! GPGKEY is not set — refusing to build unsigned packages." >&2
|
||||||
|
echo " Run packaging/gen-signing-key.sh once (if you haven't), then:" >&2
|
||||||
|
echo " export GPGKEY=<packager key fingerprint>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export GPGKEY
|
||||||
|
|
||||||
echo "==> taninux (+ build deps)"
|
echo "==> taninux (+ build deps)"
|
||||||
sudo pacman -S --needed --noconfirm python-build python-installer python-hatchling
|
sudo pacman -S --needed --noconfirm python-build python-installer python-hatchling
|
||||||
( cd "$HERE/taninux" && makepkg -sf --noconfirm ) \
|
( cd "$HERE/taninux" && makepkg -sf --sign --noconfirm ) \
|
||||||
&& cp "$HERE/taninux/"*.pkg.tar.zst "$OUT/" && echo " ok" || echo " FAILED (taninux)"
|
&& cp "$HERE/taninux/"*.pkg.tar.zst "$OUT/" && echo " ok" || echo " FAILED (taninux)"
|
||||||
|
|
||||||
AUR_PKGS="eww-git tiramisu-git waypaper calamares librewolf-bin arch-update timeshift-autosnap xdg-terminal-exec paru"
|
AUR_PKGS="eww-git tiramisu-git waypaper calamares librewolf-bin arch-update timeshift-autosnap xdg-terminal-exec paru"
|
||||||
@@ -22,7 +33,7 @@ tmp="$(mktemp -d)"
|
|||||||
for p in $AUR_PKGS; do
|
for p in $AUR_PKGS; do
|
||||||
echo " -- $p"
|
echo " -- $p"
|
||||||
if git clone --depth=1 "https://aur.archlinux.org/$p.git" "$tmp/$p" >/dev/null 2>&1 \
|
if git clone --depth=1 "https://aur.archlinux.org/$p.git" "$tmp/$p" >/dev/null 2>&1 \
|
||||||
&& ( cd "$tmp/$p" && makepkg -sf --noconfirm ); then
|
&& ( cd "$tmp/$p" && makepkg -sf --sign --noconfirm ); then
|
||||||
cp "$tmp/$p/"*.pkg.tar.zst "$OUT/" && echo " ok"
|
cp "$tmp/$p/"*.pkg.tar.zst "$OUT/" && echo " ok"
|
||||||
else
|
else
|
||||||
echo " FAILED ($p)"
|
echo " FAILED ($p)"
|
||||||
@@ -30,8 +41,8 @@ for p in $AUR_PKGS; do
|
|||||||
done
|
done
|
||||||
rm -rf "$tmp"
|
rm -rf "$tmp"
|
||||||
|
|
||||||
echo "==> refresh DB"
|
echo "==> refresh DB (signed with $GPGKEY)"
|
||||||
( cd "$OUT" && repo-add -q "$OUT/tanin.db.tar.zst" "$OUT"/*.pkg.tar.zst )
|
( cd "$OUT" && repo-add -s -k "$GPGKEY" -q "$OUT/tanin.db.tar.zst" "$OUT"/*.pkg.tar.zst )
|
||||||
echo "== [tanin] now contains =="
|
echo "== [tanin] now contains =="
|
||||||
ls -1 "$OUT" | grep -E '\.pkg\.tar\.zst$|tanin\.db$'
|
ls -1 "$OUT" | grep -E '\.pkg\.tar\.zst$|tanin\.db$'
|
||||||
echo
|
echo
|
||||||
|
|||||||
Executable
+92
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Generate the TANINUX packager signing key — THE DISTRO'S ROOT OF TRUST.
|
||||||
|
#
|
||||||
|
# Run this ONCE, by the maintainer, on a trusted machine. The resulting key
|
||||||
|
# signs every [tanin] package (makepkg --sign) and the repo DB (repo-add -s);
|
||||||
|
# every user who runs `pacman-key --lsign-key <fingerprint>` is trusting this
|
||||||
|
# key to vouch for everything TANINUX ships. Guard the private key + its
|
||||||
|
# passphrase like the root of the distro, because that's what it is.
|
||||||
|
#
|
||||||
|
# ./gen-signing-key.sh # prompts for Name-Email + a
|
||||||
|
# # gpg-agent passphrase prompt
|
||||||
|
# NAME_EMAIL=pkg@taninux.example ./gen-signing-key.sh
|
||||||
|
#
|
||||||
|
# This script does NOT set GPGKEY / wire it into the build scripts — that's a
|
||||||
|
# manual step below, so a compromised shell env can't silently point the
|
||||||
|
# build at the wrong key.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
command -v gpg >/dev/null || { echo "!! gpg not found" >&2; exit 1; }
|
||||||
|
|
||||||
|
NAME_REAL="TANINUX Packager"
|
||||||
|
NAME_EMAIL="${NAME_EMAIL:-}"
|
||||||
|
EXPIRE="${EXPIRE:-2y}"
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
|
||||||
|
if [ -z "$NAME_EMAIL" ]; then
|
||||||
|
read -rp "Packager key Name-Email (placeholder, doesn't need to receive mail): " NAME_EMAIL
|
||||||
|
fi
|
||||||
|
[ -n "$NAME_EMAIL" ] || { echo "!! Name-Email is required" >&2; exit 1; }
|
||||||
|
|
||||||
|
echo "== generating: $NAME_REAL <$NAME_EMAIL> (Ed25519, expires in $EXPIRE) =="
|
||||||
|
echo " gpg will prompt (via pinentry/gpg-agent) for a passphrase — set a real one."
|
||||||
|
|
||||||
|
PARAMFILE="$(mktemp)"
|
||||||
|
trap 'rm -f "$PARAMFILE"' EXIT
|
||||||
|
cat > "$PARAMFILE" <<EOF
|
||||||
|
%ask-passphrase
|
||||||
|
Key-Type: eddsa
|
||||||
|
Key-Curve: ed25519
|
||||||
|
Key-Usage: sign
|
||||||
|
Name-Real: $NAME_REAL
|
||||||
|
Name-Email: $NAME_EMAIL
|
||||||
|
Expire-Date: $EXPIRE
|
||||||
|
%commit
|
||||||
|
EOF
|
||||||
|
|
||||||
|
gpg --batch --gen-key "$PARAMFILE"
|
||||||
|
|
||||||
|
FPR="$(gpg --list-keys --with-colons "$NAME_EMAIL" 2>/dev/null \
|
||||||
|
| awk -F: '/^fpr:/ { print $10; exit }')"
|
||||||
|
|
||||||
|
if [ -z "$FPR" ]; then
|
||||||
|
echo "!! key created but the fingerprint could not be auto-detected." >&2
|
||||||
|
echo " run: gpg --list-keys \"$NAME_EMAIL\"" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOT
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
TANINUX packager key created
|
||||||
|
Fingerprint: $FPR
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
Next steps (manual — nothing below runs automatically):
|
||||||
|
|
||||||
|
1) Export the PUBLIC key into the target keyring source, so
|
||||||
|
customize_airootfs.sh's \`pacman-key --add\` picks it up on the next ISO
|
||||||
|
build. Re-export replaces/updates the file (see the comment left in
|
||||||
|
customize_airootfs.sh for the matching --lsign-key line to add):
|
||||||
|
gpg --export "$FPR" > "$ROOT/iso/overrides/airootfs/etc/pacman.d/tanin-repo.gpg"
|
||||||
|
gpg --export "$FPR" > "$ROOT/iso/build-profile/airootfs/etc/pacman.d/tanin-repo.gpg"
|
||||||
|
|
||||||
|
2) Publish the public key for existing/new users to import (see the
|
||||||
|
Cutover section in docs/distribution.md), e.g.:
|
||||||
|
gpg --export "$FPR" > /path/to/served/tanin-repo.gpg
|
||||||
|
and record "$FPR" in docs/distribution.md's Cutover section.
|
||||||
|
|
||||||
|
3) Point the build scripts at it (build-tanin-repo.sh, finish-tanin-repo.sh,
|
||||||
|
aur-autoupdate.sh all read GPGKEY from the environment and refuse to
|
||||||
|
build unsigned if it's unset):
|
||||||
|
export GPGKEY="$FPR"
|
||||||
|
For the systemd-driven daily rebuild (packaging/systemd/tanin-aur-update.service,
|
||||||
|
running as the dedicated tanin-build user — see setup-build-user.sh),
|
||||||
|
set Environment=GPGKEY=$FPR in the unit, or an EnvironmentFile it reads,
|
||||||
|
and make sure GNUPGHOME for tanin-build actually holds this secret key.
|
||||||
|
|
||||||
|
4) BACK UP THE PRIVATE KEY (\`gpg --export-secret-keys $FPR\`) to an offline,
|
||||||
|
encrypted location. Losing it means re-keying the whole distro and
|
||||||
|
asking every user to re-import; leaking it means anyone can sign
|
||||||
|
packages TANINUX users will install as trusted.
|
||||||
|
EOT
|
||||||
Executable
+95
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# One-time setup of the dedicated "tanin-build" account for the daily [tanin]
|
||||||
|
# AUR rebuild, so the root-equivalent makechrootpkg/arch-nspawn/mkarchroot
|
||||||
|
# sudo grant (tanin-aur-update.sudoers) lives on a locked system account
|
||||||
|
# instead of the maintainer's own login ("karim"). Run as root (or via sudo)
|
||||||
|
# on the machine that will run tanin-aur-update.timer.
|
||||||
|
#
|
||||||
|
# sudo ./setup-build-user.sh
|
||||||
|
#
|
||||||
|
# What it does:
|
||||||
|
# 1) create the tanin-build system user, home /var/lib/tanin-build, locked
|
||||||
|
# password (no interactive/SSH login — only systemd + sudo can act as it)
|
||||||
|
# 2) clone/sync this checkout's packaging scripts into ~tanin-build/taninux,
|
||||||
|
# because the systemd unit's %h paths resolve against tanin-build's home,
|
||||||
|
# not /home/karim
|
||||||
|
# 3) prepare ~tanin-build/.gnupg (mode 700) for the packager signing key
|
||||||
|
# 4) install the sudoers drop-in + systemd unit/timer
|
||||||
|
#
|
||||||
|
# What it deliberately does NOT do:
|
||||||
|
# - generate or import the signing key (packaging/gen-signing-key.sh is a
|
||||||
|
# separate, explicit step — key material shouldn't be created as a side
|
||||||
|
# effect of account provisioning)
|
||||||
|
# - enable/start the timer (review Environment=GPGKEY=... in the unit first)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
[ "$(id -u)" -eq 0 ] || { echo "!! run as root (sudo ./setup-build-user.sh)" >&2; exit 1; }
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)" # .../packaging/systemd
|
||||||
|
ROOT="$(cd "$HERE/../.." && pwd)" # taninux checkout root
|
||||||
|
BUILD_USER="tanin-build"
|
||||||
|
BUILD_HOME="/var/lib/tanin-build"
|
||||||
|
|
||||||
|
echo "==> user: $BUILD_USER (system account, home=$BUILD_HOME)"
|
||||||
|
if ! id "$BUILD_USER" >/dev/null 2>&1; then
|
||||||
|
useradd --system --create-home --home-dir "$BUILD_HOME" \
|
||||||
|
--shell /usr/bin/bash "$BUILD_USER"
|
||||||
|
passwd --lock "$BUILD_USER" # no password login — only sudo (via the
|
||||||
|
# NOPASSWD drop-in) and systemd User= can act as it
|
||||||
|
else
|
||||||
|
echo " already exists — skipping useradd"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> syncing packaging scripts to $BUILD_HOME/taninux"
|
||||||
|
# The systemd unit uses %h-relative paths (ExecStart=%h/taninux/packaging/...),
|
||||||
|
# which resolve against tanin-build's home — so a copy of the checkout (or at
|
||||||
|
# least packaging/) has to live there too, owned by tanin-build, not karim.
|
||||||
|
install -d -o "$BUILD_USER" -g "$BUILD_USER" "$BUILD_HOME/taninux"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude '.git' --exclude 'iso/out' --exclude 'iso/build-profile' \
|
||||||
|
"$ROOT/" "$BUILD_HOME/taninux/"
|
||||||
|
chown -R "$BUILD_USER:$BUILD_USER" "$BUILD_HOME/taninux"
|
||||||
|
echo " NOTE: re-run this script (or your own sync) after pulling changes —"
|
||||||
|
echo " it is a one-shot copy, not a live checkout."
|
||||||
|
|
||||||
|
echo "==> GPG homedir for the packager key"
|
||||||
|
install -d -m700 -o "$BUILD_USER" -g "$BUILD_USER" "$BUILD_HOME/.gnupg"
|
||||||
|
cat <<EOT
|
||||||
|
$BUILD_HOME/.gnupg is ready but EMPTY. The packager secret key created by
|
||||||
|
packaging/gen-signing-key.sh must be imported here before the daily
|
||||||
|
rebuild can sign anything, e.g. (as $BUILD_USER):
|
||||||
|
sudo -u $BUILD_USER gpg --homedir $BUILD_HOME/.gnupg --import packager-secret.asc
|
||||||
|
Trust implication: whoever can read $BUILD_HOME/.gnupg's secring can sign
|
||||||
|
packages as TANINUX — keep its permissions at 700/600 and don't put it on
|
||||||
|
a shared or less-trusted host than the maintainer's own signing machine.
|
||||||
|
EOT
|
||||||
|
|
||||||
|
echo "==> makepkg env"
|
||||||
|
install -d -m755 -o "$BUILD_USER" -g "$BUILD_USER" "$BUILD_HOME/.cache" "$BUILD_HOME/.config"
|
||||||
|
|
||||||
|
echo "==> installing sudoers drop-in"
|
||||||
|
install -m440 "$HERE/tanin-aur-update.sudoers" /etc/sudoers.d/tanin-aur-update
|
||||||
|
visudo -cf /etc/sudoers.d/tanin-aur-update
|
||||||
|
|
||||||
|
echo "==> installing systemd unit + timer (SYSTEM units — the .service sets"
|
||||||
|
echo " User=/Group=$BUILD_USER itself, so it must run under the system"
|
||||||
|
echo " manager, not --user; that's also what makes the sudo NOPASSWD"
|
||||||
|
echo " grant for tanin-build actually apply)"
|
||||||
|
install -m644 "$HERE/tanin-aur-update.service" /etc/systemd/system/
|
||||||
|
install -m644 "$HERE/tanin-aur-update.timer" /etc/systemd/system/
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
cat <<EOT
|
||||||
|
|
||||||
|
== setup done ==
|
||||||
|
Still TODO before the timer can run for real:
|
||||||
|
1) packaging/gen-signing-key.sh (if not already done), then import the
|
||||||
|
secret key into $BUILD_HOME/.gnupg as shown above.
|
||||||
|
2) sudo systemctl edit tanin-aur-update.service
|
||||||
|
and set Environment=GPGKEY=<fingerprint> (or uncomment it in
|
||||||
|
/etc/systemd/system/tanin-aur-update.service directly).
|
||||||
|
3) sudo -u $BUILD_USER $BUILD_HOME/taninux/packaging/aur-autoupdate.sh setup
|
||||||
|
(one-time: installs devtools + creates the makechrootpkg chroot —
|
||||||
|
needs the sudo grant just installed, so run this AFTER step 4 too)
|
||||||
|
4) sudo systemctl enable --now tanin-aur-update.timer
|
||||||
|
EOT
|
||||||
@@ -1,13 +1,25 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=TANINUX [tanin] repo — daily AUR rebuild
|
Description=TANINUX [tanin] repo — daily AUR rebuild
|
||||||
Documentation=file:%h/projects/taninux/packaging/aur-autoupdate.sh
|
Documentation=file:%h/taninux/packaging/aur-autoupdate.sh
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
# Adjust the path if your checkout lives elsewhere.
|
# Dedicated, locked build account (see setup-build-user.sh) — NOT the human
|
||||||
ExecStart=%h/projects/taninux/packaging/aur-autoupdate.sh run
|
# "karim" account. Isolates the root-equivalent makechrootpkg/arch-nspawn/
|
||||||
|
# mkarchroot sudo grant (tanin-aur-update.sudoers) off the personal login.
|
||||||
|
User=tanin-build
|
||||||
|
Group=tanin-build
|
||||||
|
# %h now resolves against tanin-build's home (/var/lib/tanin-build by
|
||||||
|
# setup-build-user.sh), NOT /home/karim — so this checkout must live there
|
||||||
|
# too (setup-build-user.sh clones/syncs it in). Adjust if it lives elsewhere.
|
||||||
|
ExecStart=%h/taninux/packaging/aur-autoupdate.sh run
|
||||||
|
# The packager signing key: GPGKEY must be set, and GNUPGHOME (default
|
||||||
|
# %h/.gnupg = /var/lib/tanin-build/.gnupg) must actually contain that secret
|
||||||
|
# key for tanin-build — see gen-signing-key.sh + setup-build-user.sh.
|
||||||
|
# Environment=GPGKEY=<packager key fingerprint, from gen-signing-key.sh>
|
||||||
|
# Environment=GNUPGHOME=%h/.gnupg # only needed if it's not already the default
|
||||||
# Be a good citizen — this is a background rebuild, not interactive work.
|
# Be a good citizen — this is a background rebuild, not interactive work.
|
||||||
Nice=15
|
Nice=15
|
||||||
IOSchedulingClass=idle
|
IOSchedulingClass=idle
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
# Install: sudo install -m440 tanin-aur-update.sudoers /etc/sudoers.d/tanin-aur-update
|
# Install: sudo install -m440 tanin-aur-update.sudoers /etc/sudoers.d/tanin-aur-update
|
||||||
# Validate: sudo visudo -cf /etc/sudoers.d/tanin-aur-update
|
# Validate: sudo visudo -cf /etc/sudoers.d/tanin-aur-update
|
||||||
#
|
#
|
||||||
# Replace "karim" if the timer runs as a different user. These are exactly the
|
# Subject is "tanin-build", a dedicated, locked (no password login) system
|
||||||
# helpers devtools' makechrootpkg shells out to as root; nothing broader.
|
# account created by setup-build-user.sh — NOT the human "karim" account.
|
||||||
karim ALL=(root) NOPASSWD: /usr/bin/makechrootpkg, /usr/bin/arch-nspawn, /usr/bin/mkarchroot
|
# makechrootpkg/arch-nspawn/mkarchroot are effectively unrestricted root (they
|
||||||
|
# bind-mount, chroot, and run arbitrary PKGBUILD-controlled commands as root),
|
||||||
|
# so this NOPASSWD grant is root-equivalent. Keeping it on a dedicated build
|
||||||
|
# account rather than the personal login means a compromised AUR PKGBUILD (or
|
||||||
|
# a bug in this pipeline) can't NOPASSWD-root the maintainer's own account —
|
||||||
|
# it's contained to whatever tanin-build can already reach.
|
||||||
|
tanin-build ALL=(root) NOPASSWD: /usr/bin/makechrootpkg, /usr/bin/arch-nspawn, /usr/bin/mkarchroot
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# Vor Publish: url + license bestätigen, source auf GitHub-Tag setzen.
|
# Vor Publish: url + license bestätigen, source auf GitHub-Tag setzen.
|
||||||
pkgname=taninux
|
pkgname=taninux
|
||||||
pkgver=0.2.0
|
pkgver=0.2.0
|
||||||
pkgrel=2
|
pkgrel=5
|
||||||
pkgdesc="Central Linux management for Arch — GTK System Settings, Software Hub and TUI"
|
pkgdesc="Central Linux management for Arch — GTK System Settings, Software Hub and TUI"
|
||||||
arch=('any')
|
arch=('any')
|
||||||
url="https://taninux.kgva.ch"
|
url="https://taninux.kgva.ch"
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ taninux = "taninux.cli:main"
|
|||||||
taninux-gtk = "taninux.gui.app:main"
|
taninux-gtk = "taninux.gui.app:main"
|
||||||
tsettings = "taninux.gui.app:main_settings"
|
tsettings = "taninux.gui.app:main_settings"
|
||||||
thub = "taninux.gui.app:main_hub"
|
thub = "taninux.gui.app:main_hub"
|
||||||
tmusic = "taninux.music.app:main"
|
|
||||||
tfiles = "taninux.files.app:main"
|
tfiles = "taninux.files.app:main"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# TANINUX-Browser
|
||||||
|
|
||||||
|
LibreWolf im TANINUX-Look — **ohne** eigenen Build oder Fork. Ein generiertes
|
||||||
|
`userChrome.css` / `userContent.css` + `user.js` lackiert LibreWolf auf die
|
||||||
|
TANINUX-Design-Sprache um. Das CSS wird aus demselben Fuji-`Accent` gerendert
|
||||||
|
wie Settings/Music/Calendar (`taninux.gui.accents`), also trägt der Browser den
|
||||||
|
gewählten Akzent und Hell/Dunkel automatisch mit.
|
||||||
|
|
||||||
|
## Warum kein Fork (wie Zen)?
|
||||||
|
|
||||||
|
Firefox/LibreWolf-UI ist **kein GTK** — die „Chrome" ist Mozillas eigene
|
||||||
|
CSS-Engine. Ein echter Fork (Gecko patchen, rebranden, selbst bauen) bedeutet
|
||||||
|
einen Multi-GB-Build plus dauerhafte Upstream-Pflege bei jedem Security-Update.
|
||||||
|
Der userChrome-Weg holt ~90 % des „fühlt sich wie TANINUX an"-Effekts für
|
||||||
|
quasi 0 Wartungskosten — vor allem zusammen mit den **nativen vertikalen Tabs**
|
||||||
|
von LibreWolf 136+ (`sidebar.verticalTabs`), die das Zen-artige Layout liefern.
|
||||||
|
|
||||||
|
## Anwenden
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m taninux.browser # gespeicherte GUI-Präferenz anwenden
|
||||||
|
python -m taninux.browser --accent take --scheme dark
|
||||||
|
python -m taninux.browser --print # userChrome.css nach stdout (kein Schreiben)
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach LibreWolf neu starten. Geschrieben wird je Profil:
|
||||||
|
|
||||||
|
- `<profil>/chrome/userChrome.css` — Chrome (Tabs, URL-Bar, Sidebar, Toolbar)
|
||||||
|
- `<profil>/chrome/userContent.css` — Akzent auf `about:`-Seiten
|
||||||
|
- `<profil>/user.js` — Layout-Prefs (vertikale Tabs, userChrome aktivieren).
|
||||||
|
Eine bestehende `user.js` bleibt erhalten; nur der Block zwischen den
|
||||||
|
`TANINUX-managed`-Markern wird idempotent ersetzt.
|
||||||
|
|
||||||
|
Profile werden über `~/.librewolf/profiles.ini` gefunden (Fallback: `*.default*`).
|
||||||
|
|
||||||
|
## ISO / Live-Akzentwechsel (TODO)
|
||||||
|
|
||||||
|
- `apply()` in den Settings-„Apply"-Pfad hängen (neben `style.write_global_theme`),
|
||||||
|
damit ein Akzentwechsel in den Settings auch den Browser nachzieht.
|
||||||
|
- Ins ISO: nach dem ersten LibreWolf-Start einmal `python -m taninux.browser`
|
||||||
|
laufen lassen (oder ein Default-Profil vorseeden).
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""TANINUX-Browser — LibreWolf im TANINUX-Look (userChrome-Restyle).
|
||||||
|
|
||||||
|
Kein eigener Build, kein Fork: ein generiertes userChrome/userContent.css +
|
||||||
|
user.js, das LibreWolf auf die TANINUX-Design-Sprache umlackiert. Gespeist aus
|
||||||
|
demselben Fuji-`Accent` wie Settings/Music/Calendar (taninux.gui.accents), also
|
||||||
|
trägt der Browser den gewählten Akzent und Hell/Dunkel automatisch mit.
|
||||||
|
|
||||||
|
python -m taninux.browser # gespeicherte Präferenz anwenden
|
||||||
|
python -m taninux.browser --print # CSS nach stdout (kein Schreiben)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from taninux.browser.apply import apply
|
||||||
|
from taninux.browser.theme import userchrome_css, usercontent_css, user_js
|
||||||
|
|
||||||
|
__all__ = ["apply", "userchrome_css", "usercontent_css", "user_js"]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""CLI: TANINUX-Look in die LibreWolf-Profile schreiben (oder CSS ausgeben)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from taninux.browser import theme
|
||||||
|
from taninux.browser.apply import apply as apply_theme
|
||||||
|
from taninux.browser.apply import _resolve_dark
|
||||||
|
from taninux.gui.accents import by_key
|
||||||
|
from taninux.gui.config import load as load_pref
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
ap = argparse.ArgumentParser(prog="taninux.browser", description=__doc__)
|
||||||
|
ap.add_argument("--accent", help="Akzent-Key (ume/shinkai/take/chikyu/beni); "
|
||||||
|
"Default: gespeicherte Präferenz")
|
||||||
|
ap.add_argument("--scheme", choices=["system", "light", "dark"],
|
||||||
|
help="Hell/Dunkel; Default: gespeicherte Präferenz")
|
||||||
|
ap.add_argument("--print", action="store_true", dest="print_only",
|
||||||
|
help="userChrome.css nach stdout statt in die Profile schreiben")
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
if args.print_only:
|
||||||
|
pref = load_pref()
|
||||||
|
acc = by_key(args.accent or pref["accent"])
|
||||||
|
dark = _resolve_dark(args.scheme or pref["color_scheme"])
|
||||||
|
sys.stdout.write(theme.userchrome_css(acc, dark))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
touched = apply_theme(color_scheme=args.scheme, accent_key=args.accent)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
print(f"Fehler: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not touched:
|
||||||
|
print("Keine LibreWolf-Profile gefunden.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
for p in touched:
|
||||||
|
print(f" ✓ {p.name}")
|
||||||
|
print(f"{len(touched)} Profil(e) aktualisiert. LibreWolf neu starten.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Schreibt das generierte TANINUX-Theme in die LibreWolf-Profile.
|
||||||
|
|
||||||
|
Findet alle Profile unter ~/.librewolf (über profiles.ini, Fallback Glob),
|
||||||
|
legt je Profil chrome/userChrome.css + chrome/userContent.css + user.js an.
|
||||||
|
Dark/Light + Akzent kommen aus der gespeicherten GUI-Präferenz (gui.config),
|
||||||
|
sodass der Browser denselben Fuji-Akzent trägt wie Settings/Music/Calendar.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import configparser
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from taninux.browser import theme
|
||||||
|
from taninux.gui.accents import by_key
|
||||||
|
from taninux.gui.config import load as load_pref
|
||||||
|
|
||||||
|
|
||||||
|
def _librewolf_root() -> Path:
|
||||||
|
"""Profil-Wurzel finden. Neuere LibreWolf-Builds nutzen das XDG-„dedicated
|
||||||
|
profiles"-Layout (~/.config/librewolf/librewolf), ältere ~/.librewolf."""
|
||||||
|
cfg = Path(os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config"))
|
||||||
|
for cand in (cfg / "librewolf" / "librewolf", cfg / "librewolf",
|
||||||
|
Path.home() / ".librewolf"):
|
||||||
|
if (cand / "profiles.ini").exists():
|
||||||
|
return cand
|
||||||
|
return Path.home() / ".librewolf"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_dark(color_scheme: str) -> bool:
|
||||||
|
"""True wenn dunkel. 'system' → gsettings lesen (wie gui/style._resolve_dark)."""
|
||||||
|
if color_scheme == "dark":
|
||||||
|
return True
|
||||||
|
if color_scheme == "light":
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
["gsettings", "get", "org.gnome.desktop.interface", "color-scheme"],
|
||||||
|
capture_output=True, text=True, timeout=5, check=False,
|
||||||
|
).stdout
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return True
|
||||||
|
return "prefer-light" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def _profiles(root: Path) -> list[Path]:
|
||||||
|
"""Profil-Verzeichnisse aus profiles.ini; Fallback: *.default*-Globs."""
|
||||||
|
ini = root / "profiles.ini"
|
||||||
|
found: list[Path] = []
|
||||||
|
if ini.exists():
|
||||||
|
cp = configparser.ConfigParser()
|
||||||
|
try:
|
||||||
|
cp.read(ini)
|
||||||
|
except configparser.Error:
|
||||||
|
cp = None # type: ignore[assignment]
|
||||||
|
if cp is not None:
|
||||||
|
for sect in cp.sections():
|
||||||
|
if not sect.startswith("Profile"):
|
||||||
|
continue
|
||||||
|
path = cp.get(sect, "Path", fallback=None)
|
||||||
|
if not path:
|
||||||
|
continue
|
||||||
|
is_rel = cp.get(sect, "IsRelative", fallback="1") == "1"
|
||||||
|
found.append((root / path) if is_rel else Path(path))
|
||||||
|
if not found:
|
||||||
|
found = [p for p in root.glob("*.default*") if p.is_dir()]
|
||||||
|
return [p for p in found if p.is_dir()]
|
||||||
|
|
||||||
|
|
||||||
|
def apply(color_scheme: str | None = None, accent_key: str | None = None) -> list[Path]:
|
||||||
|
"""Theme in alle LibreWolf-Profile schreiben. Gibt die getroffenen Profile zurück."""
|
||||||
|
pref = load_pref()
|
||||||
|
color_scheme = color_scheme or pref["color_scheme"]
|
||||||
|
accent_key = accent_key or pref["accent"]
|
||||||
|
acc = by_key(accent_key)
|
||||||
|
dark = _resolve_dark(color_scheme)
|
||||||
|
|
||||||
|
root = _librewolf_root()
|
||||||
|
if not root.exists():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Kein LibreWolf-Profilordner unter {root}. "
|
||||||
|
"LibreWolf einmal starten, damit ein Profil angelegt wird."
|
||||||
|
)
|
||||||
|
|
||||||
|
touched: list[Path] = []
|
||||||
|
for prof in _profiles(root):
|
||||||
|
chrome = prof / "chrome"
|
||||||
|
chrome.mkdir(parents=True, exist_ok=True)
|
||||||
|
_write_managed(chrome / "userChrome.css", theme.userchrome_css(acc, dark))
|
||||||
|
_write_managed(chrome / "userContent.css", theme.usercontent_css(acc, dark))
|
||||||
|
# user.js nur Layout-Prefs — bestehende user.js NICHT überschreiben,
|
||||||
|
# sondern unsere Marker-Sektion idempotent ersetzen.
|
||||||
|
_merge_user_js(prof / "user.js", theme.user_js(dark))
|
||||||
|
touched.append(prof)
|
||||||
|
return touched
|
||||||
|
|
||||||
|
|
||||||
|
_MANAGED_MARKER = "TANINUX-managed"
|
||||||
|
_MARK_BEGIN = "// >>> TANINUX-managed (browser) >>>"
|
||||||
|
_MARK_END = "// <<< TANINUX-managed (browser) <<<"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_managed(path: Path, content: str) -> None:
|
||||||
|
"""Write our generated CSS, backing up a pre-existing hand-authored file.
|
||||||
|
|
||||||
|
If the target exists and is NOT already one of ours (no TANINUX marker),
|
||||||
|
copy it to ``<name>.taninux.bak`` once before overwriting, so a user's own
|
||||||
|
userChrome/userContent is never silently lost.
|
||||||
|
"""
|
||||||
|
if path.exists():
|
||||||
|
try:
|
||||||
|
current = path.read_text(encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
current = ""
|
||||||
|
if _MANAGED_MARKER not in current:
|
||||||
|
bak = path.with_name(path.name + ".taninux.bak")
|
||||||
|
if not bak.exists():
|
||||||
|
shutil.copy2(path, bak)
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_user_js(path: Path, block: str) -> None:
|
||||||
|
"""Unsere Prefs zwischen Markern einfügen/ersetzen, Rest des user.js wahren."""
|
||||||
|
payload = f"{_MARK_BEGIN}\n{block.strip()}\n{_MARK_END}\n"
|
||||||
|
try:
|
||||||
|
existing = path.read_text(encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
existing = ""
|
||||||
|
if _MARK_BEGIN in existing and _MARK_END in existing:
|
||||||
|
pre = existing.split(_MARK_BEGIN)[0].rstrip()
|
||||||
|
post = existing.split(_MARK_END, 1)[1].lstrip()
|
||||||
|
merged = "\n\n".join(x for x in (pre, payload.strip(), post) if x) + "\n"
|
||||||
|
else:
|
||||||
|
merged = (existing.rstrip() + "\n\n" + payload) if existing.strip() else payload
|
||||||
|
path.write_text(merged, encoding="utf-8")
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""TANINUX-Browser — userChrome/userContent-Generator (LibreWolf-Basis).
|
||||||
|
|
||||||
|
Firefox/LibreWolf-UI ist KEIN GTK: die „Chrome" (Tabs, URL-Bar, Toolbar) ist
|
||||||
|
Mozillas eigene CSS-Engine. Wir können die GTK-Widgets also nicht
|
||||||
|
wiederverwenden — wohl aber die *Werte* der TANINUX-Design-Sprache: den live
|
||||||
|
Fuji-Akzent, die Shell-Töne (#0f0f0f / #f3f1ec), 14px-Radien, Pills und die
|
||||||
|
Inter/Iosevka-Typografie. Genau wie tanin-calendar rendert dieser Generator aus
|
||||||
|
EINEM `Accent`-Objekt (taninux.gui.accents), damit der Browser den gewählten
|
||||||
|
Akzent und Hell/Dunkel automatisch mitträgt.
|
||||||
|
|
||||||
|
Bewusst GTK-frei (kein `gi`-Import), damit das Modul aus CLI/Installer ohne
|
||||||
|
GTK-Runtime läuft. Die Surface-Töne sind 1:1 mit gui/style.py (= eww-Palette).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from taninux.gui.accents import Accent
|
||||||
|
|
||||||
|
# Surfaces 1:1 mit gui/style.py (_DARK_SURF / _LIGHT_SURF). Bewusst dupliziert
|
||||||
|
# statt importiert: style.py zieht `gi`/GTK herein, das wollen wir hier nicht.
|
||||||
|
_DARK = dict(bg="#0f0f0f", bg_alt="#191919", bg_hi="#242424", view="#141414",
|
||||||
|
card="#191919", fg="#f0f0f0", fg_dim="rgba(240,240,240,0.55)",
|
||||||
|
border="rgba(240,240,240,0.10)", hover="rgba(255,255,255,0.07)",
|
||||||
|
field="#191919")
|
||||||
|
_LIGHT = dict(bg="#f3f1ec", bg_alt="#e4e4e4", bg_hi="#d6d6d6", view="#faf8f4",
|
||||||
|
card="#ffffff", fg="#0f0f0f", fg_dim="rgba(15,15,15,0.55)",
|
||||||
|
border="rgba(15,15,15,0.12)", hover="rgba(0,0,0,0.05)",
|
||||||
|
field="#ffffff")
|
||||||
|
|
||||||
|
_FONT = '-apple-system, "SF Pro Text", "Inter", "Cantarell", sans-serif'
|
||||||
|
|
||||||
|
|
||||||
|
def _contrast_fg(hex_color: str) -> str:
|
||||||
|
"""Lesbarer Vordergrund (dunkel/weiß) auf der Akzentfläche — wie gui/style."""
|
||||||
|
h = hex_color.lstrip("#")
|
||||||
|
r, g, b = (int(h[i:i + 2], 16) / 255 for i in (0, 2, 4))
|
||||||
|
lum = 0.2126 * r + 0.7152 * g + 0.0588 * b
|
||||||
|
return "#0f0f0f" if lum > 0.6 else "#ffffff"
|
||||||
|
|
||||||
|
|
||||||
|
def _rgba(hex_color: str, alpha: float) -> str:
|
||||||
|
h = hex_color.lstrip("#")
|
||||||
|
r, g, b = (int(h[i:i + 2], 16) for i in (0, 2, 4))
|
||||||
|
return f"rgba({r},{g},{b},{alpha})"
|
||||||
|
|
||||||
|
|
||||||
|
def userchrome_css(acc: Accent, dark: bool) -> str:
|
||||||
|
"""Vollständiges userChrome.css für die gewählte Palette.
|
||||||
|
|
||||||
|
Setzt zuerst die Firefox-`--lwt-*`/`--toolbar-*`-Variablen auf die TANINUX-
|
||||||
|
Töne (so folgt die ganze Chrome ohne pro-Element-Regeln), dann die TANINUX-
|
||||||
|
Specials: Pill-URL-Bar, Sidebar-Tabs im `.tn-nav`-Stil (Akzent-Wash + Border
|
||||||
|
bei :selected), Akzent-Fokusring.
|
||||||
|
"""
|
||||||
|
p = _DARK if dark else _LIGHT
|
||||||
|
acc_fg = _contrast_fg(acc.hex)
|
||||||
|
sel_bg = _rgba(acc.hex, 0.18 if dark else 1.0) # selektierter Tab
|
||||||
|
sel_border = _rgba(acc.hex, 0.32)
|
||||||
|
return f"""/* TANINUX-managed — userChrome.css (LibreWolf). Nicht von Hand editieren.
|
||||||
|
{'dark' if dark else 'light'} · accent {acc.label} ({acc.hex})
|
||||||
|
Quelle: src/taninux/browser/theme.py (rendert aus taninux.gui.accents). */
|
||||||
|
|
||||||
|
:root {{
|
||||||
|
/* Lightweight-Theme-Variablen: färben Toolbar, Tabs, URL-Bar global. */
|
||||||
|
--lwt-accent-color: {p['bg']} !important;
|
||||||
|
--lwt-text-color: {p['fg']} !important;
|
||||||
|
--toolbar-bgcolor: {p['bg']} !important;
|
||||||
|
--toolbar-color: {p['fg']} !important;
|
||||||
|
--toolbarbutton-icon-fill: {p['fg']} !important;
|
||||||
|
--tabpanel-background-color: {p['view']} !important;
|
||||||
|
|
||||||
|
/* URL-/Such-Feld */
|
||||||
|
--toolbar-field-background-color: {p['field']} !important;
|
||||||
|
--toolbar-field-color: {p['fg']} !important;
|
||||||
|
--toolbar-field-focus-background-color: {p['card']} !important;
|
||||||
|
--toolbar-field-border-color: transparent !important;
|
||||||
|
|
||||||
|
/* Akzent — Tab-Auswahl, Fokus, Links */
|
||||||
|
--tab-selected-bgcolor: {sel_bg} !important;
|
||||||
|
--tab-selected-textcolor: {acc.hex if dark else acc_fg} !important;
|
||||||
|
--lwt-tab-line-color: {acc.hex} !important;
|
||||||
|
--focus-outline-color: {acc.hex} !important;
|
||||||
|
--toolbarbutton-active-background: {_rgba(acc.hex, 0.18)} !important;
|
||||||
|
|
||||||
|
--tn-accent: {acc.hex};
|
||||||
|
--tn-radius: 14px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* Globale Display-Typografie wie die TANINUX-Bar/Apps. */
|
||||||
|
* {{ font-family: {_FONT} !important; }}
|
||||||
|
|
||||||
|
/* ===================================================================== TABS */
|
||||||
|
/* Runde Tabs mit Akzent-Wash bei Auswahl — wie `.tn-nav > row:selected`. */
|
||||||
|
.tabbrowser-tab .tab-content {{ border-radius: 10px; }}
|
||||||
|
.tab-background {{
|
||||||
|
border-radius: 10px !important;
|
||||||
|
margin: 2px 4px !important;
|
||||||
|
border: 1px solid transparent !important;
|
||||||
|
}}
|
||||||
|
.tabbrowser-tab:hover .tab-background {{
|
||||||
|
background-color: {p['hover']} !important;
|
||||||
|
}}
|
||||||
|
.tabbrowser-tab[selected] .tab-background {{
|
||||||
|
background-color: {sel_bg} !important;
|
||||||
|
border-color: {sel_border} !important;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* ============================================================ VERTICAL TABS */
|
||||||
|
/* Zen-artige Sidebar (FF/LibreWolf native vertical tabs). Glas-Panel-Ton wie
|
||||||
|
die `.tn-sidebar`. */
|
||||||
|
#sidebar-box,
|
||||||
|
#vertical-tabs,
|
||||||
|
.sidebar-panel {{
|
||||||
|
background-color: {p['bg']} !important;
|
||||||
|
color: {p['fg']} !important;
|
||||||
|
border-right: 1px solid {p['border']} !important;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* ================================================================== URL-BAR */
|
||||||
|
/* Pill — wie alle TANINUX-Eingaben. */
|
||||||
|
#urlbar,
|
||||||
|
#urlbar-background,
|
||||||
|
#searchbar {{
|
||||||
|
border-radius: 999px !important;
|
||||||
|
border: 1px solid {p['border']} !important;
|
||||||
|
background-color: {p['field']} !important;
|
||||||
|
}}
|
||||||
|
#urlbar[focused] > #urlbar-background {{
|
||||||
|
border-color: {acc.hex} !important;
|
||||||
|
box-shadow: 0 0 0 2px {_rgba(acc.hex, 0.25)} !important;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* ================================================================== BUTTONS */
|
||||||
|
/* Toolbar-Buttons rund + Akzent-Hover. */
|
||||||
|
toolbarbutton .toolbarbutton-icon,
|
||||||
|
#PanelUI-button {{ border-radius: 999px !important; }}
|
||||||
|
toolbarbutton:hover .toolbarbutton-icon {{
|
||||||
|
background-color: {p['hover']} !important;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* ============================================================== NAV-TOOLBAR */
|
||||||
|
#nav-bar,
|
||||||
|
#navigator-toolbox {{
|
||||||
|
background-color: {p['bg']} !important;
|
||||||
|
border-bottom: 1px solid {p['border']} !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* ================================================================== POPOVER */
|
||||||
|
/* Menüs/Panels: gerundet, TANINUX-Karte. */
|
||||||
|
menupopup,
|
||||||
|
panel,
|
||||||
|
.panel-arrowcontent {{
|
||||||
|
--panel-border-radius: var(--tn-radius) !important;
|
||||||
|
--panel-background: {p['bg_alt']} !important;
|
||||||
|
--panel-color: {p['fg']} !important;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* ============================================================== FOCUS-RING */
|
||||||
|
:root {{ --focus-outline: 2px solid {acc.hex} !important; }}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def usercontent_css(acc: Accent, dark: bool) -> str:
|
||||||
|
"""userContent.css — Akzent auf internen about:-Seiten (New Tab, Settings)."""
|
||||||
|
p = _DARK if dark else _LIGHT
|
||||||
|
return f"""/* TANINUX-managed — userContent.css (LibreWolf). {'dark' if dark else 'light'}.
|
||||||
|
Akzentuiert about:- und interne Seiten passend zur Chrome. */
|
||||||
|
@-moz-document url-prefix(about:) {{
|
||||||
|
:root {{
|
||||||
|
--in-content-accent-color: {acc.hex} !important;
|
||||||
|
--in-content-primary-button-background: {acc.hex} !important;
|
||||||
|
--in-content-focus-outline-color: {acc.hex} !important;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# Prefs, die LibreWolf zum Layout/Userchrome zwingen. Wird als user.js geschrieben.
|
||||||
|
def user_js(dark: bool) -> str:
|
||||||
|
"""user.js — aktiviert userChrome + das Zen-artige Vertical-Tab-Layout."""
|
||||||
|
scheme = 0 if dark else 1 # layout.css.prefers-color-scheme.content-override
|
||||||
|
return f"""// TANINUX-managed — LibreWolf-Prefs für den TANINUX-Browser-Look.
|
||||||
|
// userChrome/userContent.css aktivieren:
|
||||||
|
user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", true);
|
||||||
|
|
||||||
|
// Zen-artiges Layout: native vertikale Tabs + Sidebar-Revamp (FF/LibreWolf 136+).
|
||||||
|
user_pref("sidebar.revamp", true);
|
||||||
|
user_pref("sidebar.verticalTabs", true);
|
||||||
|
|
||||||
|
// Dichtes, ruhiges UI (kompakt, keine Animations-Overkill).
|
||||||
|
user_pref("browser.uidensity", 1);
|
||||||
|
user_pref("browser.compactmode.show", true);
|
||||||
|
|
||||||
|
// Hell/Dunkel an die gewählte TANINUX-Palette koppeln.
|
||||||
|
user_pref("layout.css.prefers-color-scheme.content-override", {scheme});
|
||||||
|
user_pref("ui.systemUsesDarkTheme", {1 if dark else 0});
|
||||||
|
"""
|
||||||
@@ -10,9 +10,11 @@ die Änderungen wieder weg.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -37,6 +39,19 @@ class Output:
|
|||||||
transform: str # "normal" / "90" / "180" / "270"
|
transform: str # "normal" / "90" / "180" / "270"
|
||||||
enabled: bool
|
enabled: bool
|
||||||
modes: list[tuple[int, int, float]] = field(default_factory=list) # (w, h, hz)
|
modes: list[tuple[int, int, float]] = field(default_factory=list) # (w, h, hz)
|
||||||
|
# Logische Größe wie von niri gemeldet (`logical.width/height`): hat Scale
|
||||||
|
# UND Rotation bereits eingerechnet (bei 90°/270° also hochkant vertauscht).
|
||||||
|
# width/height oben sind die rohen Modus-Pixel — für's Arrangement immer
|
||||||
|
# logical_width/height nehmen, sonst liegt ein gedrehter Monitor falsch.
|
||||||
|
logical_width: int = 0
|
||||||
|
logical_height: int = 0
|
||||||
|
# Physische Panelgröße in mm aus dem EDID (niris `physical_size`), IMMER im
|
||||||
|
# unrotierten (Hardware-)Seitenverhältnis — anders als logical_width/height
|
||||||
|
# rotiert niri diese Werte NICHT mit. Fürs Arrangement (Proportionen nach
|
||||||
|
# echter Bildschirmgrösse statt nur Logisch-Pixeln) selbst tauschen, wenn
|
||||||
|
# transform 90/270 ist — siehe gui/pages/display.py.
|
||||||
|
physical_width_mm: int = 0
|
||||||
|
physical_height_mm: int = 0
|
||||||
|
|
||||||
|
|
||||||
# -- Lesen ----------------------------------------------------------------
|
# -- Lesen ----------------------------------------------------------------
|
||||||
@@ -83,6 +98,10 @@ def outputs() -> list[Output]:
|
|||||||
logical = o.get("logical") or None
|
logical = o.get("logical") or None
|
||||||
enabled = logical is not None
|
enabled = logical is not None
|
||||||
|
|
||||||
|
phys = o.get("physical_size") or None
|
||||||
|
phys_w = int(phys[0]) if phys and len(phys) == 2 else 0
|
||||||
|
phys_h = int(phys[1]) if phys and len(phys) == 2 else 0
|
||||||
|
|
||||||
# current_mode ist ein Index in modes (null wenn aus / unbekannt).
|
# current_mode ist ein Index in modes (null wenn aus / unbekannt).
|
||||||
cur_idx = o.get("current_mode")
|
cur_idx = o.get("current_mode")
|
||||||
if isinstance(cur_idx, int) and 0 <= cur_idx < len(modes):
|
if isinstance(cur_idx, int) and 0 <= cur_idx < len(modes):
|
||||||
@@ -105,6 +124,10 @@ def outputs() -> list[Output]:
|
|||||||
transform=_norm_transform(logical.get("transform", "normal")) if logical else "normal",
|
transform=_norm_transform(logical.get("transform", "normal")) if logical else "normal",
|
||||||
enabled=enabled,
|
enabled=enabled,
|
||||||
modes=modes,
|
modes=modes,
|
||||||
|
logical_width=int(logical.get("width", 0)) if logical else 0,
|
||||||
|
logical_height=int(logical.get("height", 0)) if logical else 0,
|
||||||
|
physical_width_mm=phys_w,
|
||||||
|
physical_height_mm=phys_h,
|
||||||
))
|
))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -133,6 +156,7 @@ def apply_argv(
|
|||||||
scale: float | None = None,
|
scale: float | None = None,
|
||||||
transform: str | None = None,
|
transform: str | None = None,
|
||||||
enabled: bool | None = None,
|
enabled: bool | None = None,
|
||||||
|
position: tuple[int, int] | None = None,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""argv für GENAU eine Änderung an `out` via `niri msg output`.
|
"""argv für GENAU eine Änderung an `out` via `niri msg output`.
|
||||||
Unprivilegiert ausführbar. Genau einer der Keyword-Parameter sollte gesetzt sein.
|
Unprivilegiert ausführbar. Genau einer der Keyword-Parameter sollte gesetzt sein.
|
||||||
@@ -150,9 +174,121 @@ def apply_argv(
|
|||||||
return base + ["scale", _fmt_scale(scale)]
|
return base + ["scale", _fmt_scale(scale)]
|
||||||
if transform is not None:
|
if transform is not None:
|
||||||
return base + ["transform", transform]
|
return base + ["transform", transform]
|
||||||
|
if position is not None:
|
||||||
|
x, y = position
|
||||||
|
return base + ["position", "set", str(int(x)), str(int(y))]
|
||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
# -- Persistenz in die niri-Config (config.kdl) ---------------------------
|
||||||
|
#
|
||||||
|
# `niri msg output … position set` ist nur LIVE (nach Reload/Login weg). Damit
|
||||||
|
# eine Anordnung dauerhaft bleibt, schreiben wir zusätzlich `position x=… y=…`
|
||||||
|
# in den passenden `output "…" { }`-Block der config.kdl. Muster wie in
|
||||||
|
# core/keybindings.py: byte-genau erhalten, mit `niri validate` prüfen, vor dem
|
||||||
|
# Schreiben ein `.kdl.bak`-Backup anlegen. niri live-reloadet die Datei selbst.
|
||||||
|
|
||||||
|
_OUT_OPEN_RE = re.compile(r'^(?P<indent>\s*)output\s+"(?P<name>[^"]+)"\s*\{')
|
||||||
|
_POS_LINE_RE = re.compile(r'^\s*position\s+')
|
||||||
|
|
||||||
|
|
||||||
|
def niri_config_path() -> Path:
|
||||||
|
env = os.environ.get("NIRI_CONFIG")
|
||||||
|
if env:
|
||||||
|
return Path(env)
|
||||||
|
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
||||||
|
return Path(base) / "niri" / "config.kdl"
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_kdl(text: str) -> tuple[bool, str]:
|
||||||
|
"""`niri validate` auf einer Temp-Kopie. (True, '') wenn niri fehlt."""
|
||||||
|
tmp = ""
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False) as tf:
|
||||||
|
tf.write(text)
|
||||||
|
tmp = tf.name
|
||||||
|
r = subprocess.run(["niri", "validate", "-c", tmp],
|
||||||
|
capture_output=True, text=True, timeout=5)
|
||||||
|
if r.returncode == 0:
|
||||||
|
return True, ""
|
||||||
|
return False, "niri rejected the config"
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return True, ""
|
||||||
|
finally:
|
||||||
|
if tmp:
|
||||||
|
Path(tmp).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _block_end(lines: list[str], open_idx: int) -> int:
|
||||||
|
"""Index der schließenden `}`-Zeile des bei open_idx geöffneten Blocks
|
||||||
|
(per Klammertiefe). Fällt auf die letzte Zeile zurück, falls unbalanciert."""
|
||||||
|
depth = 0
|
||||||
|
for i in range(open_idx, len(lines)):
|
||||||
|
depth += lines[i].count("{") - lines[i].count("}")
|
||||||
|
if depth == 0:
|
||||||
|
return i
|
||||||
|
return len(lines) - 1
|
||||||
|
|
||||||
|
|
||||||
|
def _set_position_in_block(lines: list[str], open_idx: int, x: int, y: int) -> None:
|
||||||
|
"""Setzt/ersetzt `position x=… y=…` im output-Block ab open_idx (in-place)."""
|
||||||
|
indent = _OUT_OPEN_RE.match(lines[open_idx]).group("indent")
|
||||||
|
end_idx = _block_end(lines, open_idx)
|
||||||
|
for i in range(open_idx + 1, end_idx + 1):
|
||||||
|
if _POS_LINE_RE.match(lines[i]):
|
||||||
|
pos_indent = re.match(r"^(\s*)", lines[i]).group(1)
|
||||||
|
lines[i] = f"{pos_indent}position x={int(x)} y={int(y)}\n"
|
||||||
|
return
|
||||||
|
lines.insert(open_idx + 1, f"{indent} position x={int(x)} y={int(y)}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def write_positions(positions: dict[str, tuple[int, int]]) -> tuple[bool, str]:
|
||||||
|
"""Schreibt `position` je Output persistent in die config.kdl.
|
||||||
|
|
||||||
|
Vorhandene `output`-Blöcke werden ergänzt/aktualisiert; fehlt ein Output,
|
||||||
|
wird ein neuer Block angehängt. Validiert vor dem Schreiben; bei jedem
|
||||||
|
Problem bleibt die Config unangetastet. (ok, message).
|
||||||
|
"""
|
||||||
|
if not positions:
|
||||||
|
return True, "nothing to save"
|
||||||
|
path = niri_config_path()
|
||||||
|
try:
|
||||||
|
text = path.read_text()
|
||||||
|
except OSError as e:
|
||||||
|
return False, f"cannot read niri config: {e}"
|
||||||
|
|
||||||
|
lines = text.splitlines(keepends=True)
|
||||||
|
remaining = {n: (int(x), int(y)) for n, (x, y) in positions.items()}
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
m = _OUT_OPEN_RE.match(lines[i])
|
||||||
|
if m and m.group("name") in remaining:
|
||||||
|
x, y = remaining.pop(m.group("name"))
|
||||||
|
_set_position_in_block(lines, i, x, y)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Outputs ohne Block in der Config → neuen anhängen.
|
||||||
|
for name, (x, y) in remaining.items():
|
||||||
|
if lines and not lines[-1].endswith("\n"):
|
||||||
|
lines[-1] += "\n"
|
||||||
|
lines.append(f'output "{name}" {{\n position x={x} y={y}\n}}\n')
|
||||||
|
|
||||||
|
new_text = "".join(lines)
|
||||||
|
if new_text == text:
|
||||||
|
return True, "unchanged"
|
||||||
|
|
||||||
|
ok, err = _validate_kdl(new_text)
|
||||||
|
if not ok:
|
||||||
|
return False, err
|
||||||
|
try:
|
||||||
|
if path.exists():
|
||||||
|
shutil.copy2(path, path.with_suffix(".kdl.bak"))
|
||||||
|
path.write_text(new_text)
|
||||||
|
except OSError as e:
|
||||||
|
return False, f"cannot write niri config: {e}"
|
||||||
|
return True, "ok"
|
||||||
|
|
||||||
|
|
||||||
# -- Night Light / Farbtemperatur (wlsunset) ------------------------------
|
# -- Night Light / Farbtemperatur (wlsunset) ------------------------------
|
||||||
#
|
#
|
||||||
# TANINUX verwaltet einen EIGENEN systemd-User-Service
|
# TANINUX verwaltet einen EIGENEN systemd-User-Service
|
||||||
|
|||||||
@@ -31,8 +31,18 @@ _DEFAULTS = {
|
|||||||
"osd": {"enabled": True},
|
"osd": {"enabled": True},
|
||||||
# Kalender-Panel: Schwellen für heutige/anstehende Termine (eww konsumiert).
|
# Kalender-Panel: Schwellen für heutige/anstehende Termine (eww konsumiert).
|
||||||
"calendar": {"today_min": 2, "max_entries": 5, "days_ahead": 7},
|
"calendar": {"today_min": 2, "max_entries": 5, "days_ahead": 7},
|
||||||
|
# Hauptdisplay: Connector-Name (z.B. "DP-3") des Outputs mit Dock/Control-
|
||||||
|
# Center/Kalender/Musik/Sysmon/Updates/Spotlight/OSD/Overview-Trigger.
|
||||||
|
# "" = unset -> panelcfg.sh nimmt den ersten verbundenen Output.
|
||||||
|
"display": {"main_output": ""},
|
||||||
|
# Overview-Trigger: eigene Hover-Zone (eww), da niris eingebauter
|
||||||
|
# Eck-Trigger (oben links) nicht konfigurierbar ist. "off" | "top-left" |
|
||||||
|
# "top-center".
|
||||||
|
"overview": {"trigger": "top-center"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OVERVIEW_TRIGGERS = ("off", "top-left", "top-center")
|
||||||
|
|
||||||
|
|
||||||
def _panel_path() -> Path:
|
def _panel_path() -> Path:
|
||||||
return data_dir() / "panel.json"
|
return data_dir() / "panel.json"
|
||||||
@@ -98,6 +108,12 @@ def load() -> dict:
|
|||||||
cfg["calendar"][k] = int(cal[k])
|
cfg["calendar"][k] = int(cal[k])
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
disp = data.get("display")
|
||||||
|
if isinstance(disp, dict) and isinstance(disp.get("main_output"), str):
|
||||||
|
cfg["display"]["main_output"] = disp["main_output"]
|
||||||
|
ov = data.get("overview")
|
||||||
|
if isinstance(ov, dict) and ov.get("trigger") in OVERVIEW_TRIGGERS:
|
||||||
|
cfg["overview"]["trigger"] = ov["trigger"]
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
@@ -134,6 +150,14 @@ def save(cfg: dict, *, apply: bool = True) -> None:
|
|||||||
"enabled": bool(cfg.get("osd", {}).get("enabled", True)),
|
"enabled": bool(cfg.get("osd", {}).get("enabled", True)),
|
||||||
},
|
},
|
||||||
"calendar": _calendar_out(cfg.get("calendar", {})),
|
"calendar": _calendar_out(cfg.get("calendar", {})),
|
||||||
|
"display": {
|
||||||
|
"main_output": str(cfg.get("display", {}).get("main_output", "") or ""),
|
||||||
|
},
|
||||||
|
"overview": {
|
||||||
|
"trigger": (cfg.get("overview", {}).get("trigger")
|
||||||
|
if cfg.get("overview", {}).get("trigger") in OVERVIEW_TRIGGERS
|
||||||
|
else "top-center"),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
p = _panel_path()
|
p = _panel_path()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""TANINUX Files — nativer Dateimanager im Apple-Look von TANINUX.
|
"""TANINUX Files — nativer Dateimanager im Apple-Look von TANINUX.
|
||||||
|
|
||||||
Schwesternapp zu TANINUX Music (`tmusic`): teilt sich dieselbe libadwaita-freie
|
Teilt sich dieselbe libadwaita-freie UI-Naht (``gui/ui/native`` +
|
||||||
UI-Naht (``gui/ui/native`` + ``style_apple.css``) und Akzent-/Hell-Dunkel-Logik
|
``style_apple.css``) und Akzent-/Hell-Dunkel-Logik (``gui/style``) wie die
|
||||||
(``gui/style``). Aufbau analog zur Music-Shell:
|
übrigen TANINUX-GTK-Apps (u.a. das ausgelagerte `taninux-music`/`tmusic`).
|
||||||
|
Aufbau:
|
||||||
|
|
||||||
places.py – Sidebar-Orte (XDG-Verzeichnisse + eingehängte Laufwerke)
|
places.py – Sidebar-Orte (XDG-Verzeichnisse + eingehängte Laufwerke)
|
||||||
window.py – Shell (Sidebar · Toolbar/Breadcrumb · Datei-Raster)
|
window.py – Shell (Sidebar · Toolbar/Breadcrumb · Datei-Raster)
|
||||||
|
|||||||
@@ -6,13 +6,19 @@ die niri-Config (config.kdl).
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import subprocess
|
||||||
|
|
||||||
import gi
|
import gi
|
||||||
|
|
||||||
gi.require_version("Gtk", "4.0")
|
gi.require_version("Gtk", "4.0")
|
||||||
from gi.repository import Gtk # noqa: E402
|
gi.require_version("Gdk", "4.0")
|
||||||
|
from gi.repository import Gdk, Gtk # noqa: E402
|
||||||
from taninux.gui import ui
|
from taninux.gui import ui
|
||||||
|
|
||||||
from taninux.core import compositor, display
|
from taninux.core import compositor, display, panel
|
||||||
|
from taninux.gui import config as gui_config
|
||||||
|
from taninux.gui.accents import by_key as _accent_by_key
|
||||||
from taninux.gui.console import RunnerCard
|
from taninux.gui.console import RunnerCard
|
||||||
from taninux.gui.process import ProcessRunner
|
from taninux.gui.process import ProcessRunner
|
||||||
|
|
||||||
@@ -46,13 +52,467 @@ def build() -> Gtk.Widget:
|
|||||||
out = kwargs.pop("out")
|
out = kwargs.pop("out")
|
||||||
runner.run(display.apply_argv(out, **kwargs), privileged=False)
|
runner.run(display.apply_argv(out, **kwargs), privileged=False)
|
||||||
|
|
||||||
|
enabled_outs = [o for o in outs if o.enabled]
|
||||||
|
if len(enabled_outs) > 1:
|
||||||
|
page.add(_arrangement_group(enabled_outs))
|
||||||
|
|
||||||
for out in outs:
|
for out in outs:
|
||||||
page.add(_output_group(out, apply))
|
page.add(_output_group(out, apply))
|
||||||
|
|
||||||
|
page.add(_overview_trigger_group())
|
||||||
page.add(_night_light_group())
|
page.add(_night_light_group())
|
||||||
return page
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
# -- Arrangement (Multi-Monitor-Layout) ------------------------------------
|
||||||
|
#
|
||||||
|
# Kanten-Magnetismus statt starrem Grid: pro Achse wird unabhängig geprüft, ob
|
||||||
|
# die gezogene Kante nahe an einer Kante eines anderen Monitors liegt (rechts
|
||||||
|
# an links, gleiche Höhe, …). Trifft das zu, "klebt" diese Achse dort fest;
|
||||||
|
# die andere Achse bleibt frei verschiebbar. Bei zwei nebeneinander liegenden
|
||||||
|
# Monitoren ergibt das genau die zwei erwarteten Zustände (links/rechts) plus
|
||||||
|
# Höhen-Snap; ein gedrehter Monitor lässt sich an der Kante frei hoch/runter
|
||||||
|
# schieben, ohne die angedockte Seite zu verlieren.
|
||||||
|
_ARR_SNAP_DIST = 120 # logische px Toleranz fürs Einrasten an einer Kante
|
||||||
|
_ARR_GRID = 10 # Fallback-Rundung während des Ziehens
|
||||||
|
# Beim Loslassen dockt der Monitor bündig an den Nachbarn — mit MINDESTENS so
|
||||||
|
# viel geteilter Kante, dass der Cursor immer rüberkommt (keine Lücke, keine
|
||||||
|
# reine Eck-Berührung). Die freie Achse behält die gezogene Position (z.B.
|
||||||
|
# vertikal zentriert), damit beliebige Anordnungen möglich sind.
|
||||||
|
_ARR_MIN_OVERLAP = 100
|
||||||
|
|
||||||
|
|
||||||
|
def _accent_rgb() -> tuple[float, float, float]:
|
||||||
|
"""App-Akzent (Fuji) als RGB 0..1 fürs Canvas-Highlight; Blau als Fallback."""
|
||||||
|
try:
|
||||||
|
h = _accent_by_key(gui_config.load().get("accent", "")).hex.lstrip("#")
|
||||||
|
return (int(h[0:2], 16) / 255, int(h[2:4], 16) / 255, int(h[4:6], 16) / 255)
|
||||||
|
except (ValueError, KeyError, OSError):
|
||||||
|
return (0.20, 0.55, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _visual_size_corrections(outs: list[display.Output]) -> dict[str, float]:
|
||||||
|
"""Grössenkorrektur je Output, damit die Boxen die ECHTE Bildschirmgrösse
|
||||||
|
widerspiegeln, nicht nur Auflösung/Scale. logical_width/height allein
|
||||||
|
stimmt nur, wenn `scale` zufällig zur echten Panel-DPI passt — bei
|
||||||
|
bewusst falsch gesetztem Scale (z.B. 4K-Panel mit scale=1 für mehr
|
||||||
|
Pixel) würde das den Monitor optisch falsch gross/klein zeigen.
|
||||||
|
|
||||||
|
physical_size (mm, aus EDID) ist die verlässliche Quelle. Jeder Output
|
||||||
|
bekommt einen Skalar = (sein mm/logical-px-Verhältnis) / (Durchschnitt
|
||||||
|
aller Outputs) — bei normal konfigurierten Setups (Scale passt zur
|
||||||
|
realen DPI) ist das ≈1 für alle, ändert also nichts sichtbar; nur bei
|
||||||
|
einer echten DPI/Scale-Diskrepanz weicht es ab. Fehlt physical_size
|
||||||
|
(z.B. virtuelle Outputs), bleibt der Skalar 1.0 (reine Logisch-Grösse).
|
||||||
|
Position bleibt IMMER in echten Logisch-Pixeln — nur die Grösse wird
|
||||||
|
korrigiert, das hält die Positions-/Snap-Mathematik unverändert korrekt.
|
||||||
|
"""
|
||||||
|
ratios: dict[str, float] = {}
|
||||||
|
for o in outs:
|
||||||
|
pw, ph = o.physical_width_mm, o.physical_height_mm
|
||||||
|
if o.transform in ("90", "270"): # physical_size ist NIE rotiert
|
||||||
|
pw, ph = ph, pw
|
||||||
|
lw = o.logical_width or ((o.width / o.scale) if o.width and o.scale else 0)
|
||||||
|
lh = o.logical_height or ((o.height / o.scale) if o.height and o.scale else 0)
|
||||||
|
if pw and ph and lw and lh:
|
||||||
|
ratios[o.name] = math.hypot(pw, ph) / math.hypot(lw, lh)
|
||||||
|
if not ratios:
|
||||||
|
return {o.name: 1.0 for o in outs}
|
||||||
|
mean = sum(ratios.values()) / len(ratios)
|
||||||
|
return {o.name: (ratios.get(o.name, mean) / mean) for o in outs}
|
||||||
|
|
||||||
|
|
||||||
|
class _ArrangementCanvas(Gtk.DrawingArea):
|
||||||
|
"""Monitore als Rechtecke zum Verschieben — Anordnung wie in den
|
||||||
|
üblichen Desktop-Settings (GNOME/Windows). Zieht nur die Vorschau;
|
||||||
|
übernommen wird erst über den Apply-Button (siehe _arrangement_group),
|
||||||
|
live via `niri msg output <name> position set <x> <y>`; nicht
|
||||||
|
persistent (siehe Modul-Docstring)."""
|
||||||
|
|
||||||
|
def __init__(self, outs: list[display.Output], on_moved) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._outs = outs
|
||||||
|
self._on_moved = on_moved
|
||||||
|
# logische Größe je Output — niris `logical.width/height` hat Scale
|
||||||
|
# UND Rotation schon eingerechnet (bei 90°/270° hochkant vertauscht);
|
||||||
|
# aus width/height (rohe Modus-Pixel) lässt sich das nicht ableiten.
|
||||||
|
corrections = _visual_size_corrections(outs)
|
||||||
|
self._rects: dict[str, list[float]] = {}
|
||||||
|
# Echte Logisch-Größe (OHNE optische Korrektur) — nur DIESE zählt für
|
||||||
|
# Snap/Dock/die an niri gesendeten Kanten. _rects trägt die korrigierte
|
||||||
|
# Größe, die ist rein fürs Zeichnen; würde man damit andocken, entstünde
|
||||||
|
# bei abweichender Korrektur eine Lücke → Display unerreichbar.
|
||||||
|
self._logical: dict[str, tuple[float, float]] = {}
|
||||||
|
for o in outs:
|
||||||
|
lw = o.logical_width or ((o.width / o.scale) if o.width and o.scale else 320)
|
||||||
|
lh = o.logical_height or ((o.height / o.scale) if o.height and o.scale else 200)
|
||||||
|
c = corrections.get(o.name, 1.0)
|
||||||
|
self._logical[o.name] = (float(lw), float(lh))
|
||||||
|
self._rects[o.name] = [float(o.x), float(o.y), float(lw) * c, float(lh) * c]
|
||||||
|
|
||||||
|
self._drag_name: str | None = None
|
||||||
|
self._drag_offset = (0.0, 0.0)
|
||||||
|
self._drag_raw = (0.0, 0.0) # unsnapped Position, damit Snap nicht "klebt" wenn man rauszieht
|
||||||
|
self._accent = _accent_rgb()
|
||||||
|
self._cursor_name: str | None = None
|
||||||
|
|
||||||
|
# Skalierung/Offset EINMAL aus der Ausgangslage berechnen (inkl.
|
||||||
|
# Rand zum freien Ziehen) und danach fix lassen — nur so bleibt der
|
||||||
|
# Cursor während des Ziehens deckungsgleich mit dem Rechteck. Würde
|
||||||
|
# man die Bounding-Box aus der jeweils aktuellen (gezogenen) Position
|
||||||
|
# neu berechnen, "atmet" die ganze Ansicht bei jedem Frame mit.
|
||||||
|
self._bounds = self._initial_bounds()
|
||||||
|
|
||||||
|
self.set_content_height(340)
|
||||||
|
self.set_hexpand(True)
|
||||||
|
self.set_draw_func(self._draw)
|
||||||
|
|
||||||
|
drag = Gtk.GestureDrag.new()
|
||||||
|
drag.connect("drag-begin", self._on_drag_begin)
|
||||||
|
drag.connect("drag-update", self._on_drag_update)
|
||||||
|
drag.connect("drag-end", self._on_drag_end)
|
||||||
|
self.add_controller(drag)
|
||||||
|
|
||||||
|
# Greif-Cursor über einem Monitor — signalisiert "ziehbar".
|
||||||
|
motion = Gtk.EventControllerMotion.new()
|
||||||
|
motion.connect("motion", self._on_motion)
|
||||||
|
self.add_controller(motion)
|
||||||
|
|
||||||
|
def _set_cursor(self, name: str) -> None:
|
||||||
|
if name != self._cursor_name:
|
||||||
|
self._cursor_name = name
|
||||||
|
self.set_cursor(Gdk.Cursor.new_from_name(name, None))
|
||||||
|
|
||||||
|
def _on_motion(self, _c, wx: float, wy: float) -> None:
|
||||||
|
if self._drag_name is not None:
|
||||||
|
return
|
||||||
|
self._set_cursor("grab" if self._hit(wx, wy) else "default")
|
||||||
|
|
||||||
|
def _initial_bounds(self) -> tuple[float, float, float, float]:
|
||||||
|
xs = [r[0] for r in self._rects.values()]
|
||||||
|
ys = [r[1] for r in self._rects.values()]
|
||||||
|
x2s = [r[0] + r[2] for r in self._rects.values()]
|
||||||
|
y2s = [r[1] + r[3] for r in self._rects.values()]
|
||||||
|
min_x, min_y = min(xs), min(ys)
|
||||||
|
span_x = max(max(x2s) - min_x, 1.0)
|
||||||
|
span_y = max(max(y2s) - min_y, 1.0)
|
||||||
|
# Etwas Rand, damit Ziehen über die Ausgangsanordnung hinaus nicht
|
||||||
|
# sofort die Skalierung verändert — knapp gehalten, damit die Monitore
|
||||||
|
# den Canvas gut ausfüllen (nicht als Briefmarken in der Mitte).
|
||||||
|
pad = max(span_x, span_y) * 0.22 + 100
|
||||||
|
return (min_x - pad, min_y - pad, span_x + 2 * pad, span_y + 2 * pad)
|
||||||
|
|
||||||
|
def _scale_and_offset(self, widget_w: int, widget_h: int) -> tuple[float, float, float]:
|
||||||
|
min_x, min_y, span_x, span_y = self._bounds
|
||||||
|
margin = 24
|
||||||
|
s = min(
|
||||||
|
(widget_w - 2 * margin) / span_x,
|
||||||
|
(widget_h - 2 * margin) / span_y,
|
||||||
|
)
|
||||||
|
s = max(min(s, 0.4), 0.01)
|
||||||
|
off_x = margin - min_x * s + (widget_w - 2 * margin - span_x * s) / 2
|
||||||
|
off_y = margin - min_y * s + (widget_h - 2 * margin - span_y * s) / 2
|
||||||
|
return s, off_x, off_y
|
||||||
|
|
||||||
|
def _screen_rect(self, name: str, widget_w: int, widget_h: int) -> tuple[float, float, float, float]:
|
||||||
|
s, off_x, off_y = self._scale_and_offset(widget_w, widget_h)
|
||||||
|
x, y, w, h = self._rects[name]
|
||||||
|
return (x * s + off_x, y * s + off_y, max(w * s, 1.0), max(h * s, 1.0))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rounded_rect(cr, x, y, w, h, r) -> None:
|
||||||
|
r = min(r, w / 2, h / 2)
|
||||||
|
cr.new_sub_path()
|
||||||
|
cr.arc(x + w - r, y + r, r, -math.pi / 2, 0)
|
||||||
|
cr.arc(x + w - r, y + h - r, r, 0, math.pi / 2)
|
||||||
|
cr.arc(x + r, y + h - r, r, math.pi / 2, math.pi)
|
||||||
|
cr.arc(x + r, y + r, r, math.pi, 3 * math.pi / 2)
|
||||||
|
cr.close_path()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _centered_text(cr, text, cx, cy, size, weight, rgba) -> None:
|
||||||
|
cr.select_font_face("Sans", 0, weight)
|
||||||
|
cr.set_font_size(size)
|
||||||
|
ext = cr.text_extents(text)
|
||||||
|
cr.set_source_rgba(*rgba)
|
||||||
|
cr.move_to(cx - ext.width / 2 - ext.x_bearing, cy - ext.height / 2 - ext.y_bearing)
|
||||||
|
cr.show_text(text)
|
||||||
|
|
||||||
|
def _draw(self, _area, cr, widget_w, widget_h) -> None:
|
||||||
|
ctx = self.get_style_context()
|
||||||
|
is_dark = ctx.has_class("tn-dark") if hasattr(ctx, "has_class") else False
|
||||||
|
ar, ag, ab = self._accent
|
||||||
|
for out in self._outs:
|
||||||
|
rx, ry, rw, rh = self._screen_rect(out.name, widget_w, widget_h)
|
||||||
|
selected = out.name == self._drag_name
|
||||||
|
|
||||||
|
self._rounded_rect(cr, rx, ry, rw, rh, 10)
|
||||||
|
if selected:
|
||||||
|
cr.set_source_rgba(ar, ag, ab, 0.92)
|
||||||
|
elif is_dark:
|
||||||
|
cr.set_source_rgba(1, 1, 1, 0.16)
|
||||||
|
else:
|
||||||
|
cr.set_source_rgba(0, 0, 0, 0.05)
|
||||||
|
cr.fill()
|
||||||
|
|
||||||
|
self._rounded_rect(cr, rx + 0.75, ry + 0.75, rw - 1.5, rh - 1.5, 10)
|
||||||
|
if selected:
|
||||||
|
cr.set_source_rgba(ar, ag, ab, 1.0)
|
||||||
|
cr.set_line_width(2.0)
|
||||||
|
else:
|
||||||
|
cr.set_source_rgba(1, 1, 1, 0.35) if is_dark else cr.set_source_rgba(0.5, 0.5, 0.5, 0.30)
|
||||||
|
cr.set_line_width(1.25)
|
||||||
|
cr.stroke()
|
||||||
|
|
||||||
|
# Label (Modellname + Auflösung), innerhalb der Kachel abgeschnitten.
|
||||||
|
name = (out.model or out.make or out.name).strip() or out.name
|
||||||
|
sub = f"{out.logical_width or out.width}×{out.logical_height or out.height}"
|
||||||
|
if selected:
|
||||||
|
fg = (1, 1, 1, 1.0)
|
||||||
|
fg_dim = (1, 1, 1, 0.75)
|
||||||
|
elif is_dark:
|
||||||
|
fg = (1, 1, 1, 0.98)
|
||||||
|
fg_dim = (1, 1, 1, 0.72)
|
||||||
|
else:
|
||||||
|
fg = (0.15, 0.15, 0.17, 0.90)
|
||||||
|
fg_dim = (0.15, 0.15, 0.17, 0.55)
|
||||||
|
cr.save()
|
||||||
|
self._rounded_rect(cr, rx, ry, rw, rh, 10)
|
||||||
|
cr.clip()
|
||||||
|
cx, cy = rx + rw / 2, ry + rh / 2
|
||||||
|
self._centered_text(cr, name, cx, cy - 8, 13, 1, fg)
|
||||||
|
self._centered_text(cr, sub, cx, cy + 9, 10, 0, fg_dim)
|
||||||
|
cr.restore()
|
||||||
|
|
||||||
|
def _hit(self, wx: float, wy: float) -> str | None:
|
||||||
|
alloc = self.get_allocation()
|
||||||
|
for out in self._outs:
|
||||||
|
rx, ry, rw, rh = self._screen_rect(out.name, alloc.width, alloc.height)
|
||||||
|
if rx <= wx <= rx + rw and ry <= wy <= ry + rh:
|
||||||
|
return out.name
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _snap_axis(self, name: str, raw: float, size: float, axis: int) -> float:
|
||||||
|
"""Rastet `raw` (x wenn axis=0, y wenn axis=1) an der nächsten Kante
|
||||||
|
eines ANDEREN Monitors ein, sofern sie innerhalb der Toleranz liegt.
|
||||||
|
Ohne Treffer: leichtes Grid-Runden, damit die Position nicht subpixelig
|
||||||
|
zittert. Jede Achse wird unabhängig geprüft — das ist genau das
|
||||||
|
"Kante sticky, sonst frei verschiebbar"-Verhalten."""
|
||||||
|
best = None
|
||||||
|
best_dist = _ARR_SNAP_DIST
|
||||||
|
for other_name in self._rects:
|
||||||
|
if other_name == name:
|
||||||
|
continue
|
||||||
|
o_pos = self._rects[other_name][axis]
|
||||||
|
o_size = self._logical[other_name][axis]
|
||||||
|
for candidate in (
|
||||||
|
o_pos - size, # vor dem anderen andocken (links von / oberhalb)
|
||||||
|
o_pos + o_size, # hinter dem anderen andocken (rechts von / unterhalb)
|
||||||
|
o_pos, # Kante bündig (gleiche Höhe / linksbündig)
|
||||||
|
o_pos + o_size - size, # gegenüberliegende Kante bündig
|
||||||
|
o_pos + (o_size - size) / 2, # mittig zentriert (weicher Übergang)
|
||||||
|
):
|
||||||
|
dist = abs(candidate - raw)
|
||||||
|
if dist < best_dist:
|
||||||
|
best_dist = dist
|
||||||
|
best = candidate
|
||||||
|
if best is not None:
|
||||||
|
return best
|
||||||
|
return round(raw / _ARR_GRID) * _ARR_GRID
|
||||||
|
|
||||||
|
def _on_drag_begin(self, gesture, start_x, start_y) -> None:
|
||||||
|
name = self._hit(start_x, start_y)
|
||||||
|
self._drag_name = name
|
||||||
|
if name is not None:
|
||||||
|
self._set_cursor("grabbing")
|
||||||
|
alloc = self.get_allocation()
|
||||||
|
rx, ry, _, _ = self._screen_rect(name, alloc.width, alloc.height)
|
||||||
|
self._drag_offset = (start_x - rx, start_y - ry)
|
||||||
|
self._drag_raw = (self._rects[name][0], self._rects[name][1])
|
||||||
|
self.queue_draw()
|
||||||
|
|
||||||
|
def _on_drag_update(self, gesture, offset_x, offset_y) -> None:
|
||||||
|
if self._drag_name is None:
|
||||||
|
return
|
||||||
|
ok, start_x, start_y = gesture.get_start_point()
|
||||||
|
if not ok:
|
||||||
|
return
|
||||||
|
alloc = self.get_allocation()
|
||||||
|
s, off_x, off_y = self._scale_and_offset(alloc.width, alloc.height)
|
||||||
|
wx = start_x + offset_x - self._drag_offset[0]
|
||||||
|
wy = start_y + offset_y - self._drag_offset[1]
|
||||||
|
raw_x = (wx - off_x) / s
|
||||||
|
raw_y = (wy - off_y) / s
|
||||||
|
self._drag_raw = (raw_x, raw_y)
|
||||||
|
|
||||||
|
name = self._drag_name
|
||||||
|
w, h = self._logical[name]
|
||||||
|
snapped_x = self._snap_axis(name, raw_x, w, axis=0)
|
||||||
|
snapped_y = self._snap_axis(name, raw_y, h, axis=1)
|
||||||
|
rect = self._rects[name]
|
||||||
|
rect[0], rect[1] = snapped_x, snapped_y
|
||||||
|
self.queue_draw()
|
||||||
|
|
||||||
|
def _on_drag_end(self, gesture, offset_x, offset_y) -> None:
|
||||||
|
name = self._drag_name
|
||||||
|
self._drag_name = None
|
||||||
|
self._set_cursor("default")
|
||||||
|
if name is not None:
|
||||||
|
self._dock_to_nearest(name)
|
||||||
|
self._on_moved(self._normalized_positions())
|
||||||
|
self.queue_draw()
|
||||||
|
|
||||||
|
def _dock_to_nearest(self, name: str) -> None:
|
||||||
|
"""Klebt den gezogenen Monitor bündig an die nächste Kante des nächsten
|
||||||
|
Nachbarn — mit garantierter Überlappung, damit der Cursor die geteilte
|
||||||
|
Kante überqueren kann. Die FREIE Achse behält die gezogene Position
|
||||||
|
(nur geklemmt auf mind. `ov` Überlappung), sodass beliebige Anordnungen
|
||||||
|
möglich bleiben — insbesondere ein vertikal ZENTRIERTER Querschirm neben
|
||||||
|
einem hochkant gedrehten. (Ein gedrehter Monitor ist höher als der
|
||||||
|
Querschirm; sein oberer/unterer Rand hat dann bauartbedingt kein
|
||||||
|
Gegenüber — dort kreuzt der Cursor nicht, das ist Geometrie, kein Bug.)"""
|
||||||
|
x, y = self._rects[name][0], self._rects[name][1]
|
||||||
|
w, h = self._logical[name]
|
||||||
|
others = [n for n in self._rects if n != name]
|
||||||
|
if not others:
|
||||||
|
return
|
||||||
|
cx, cy = x + w / 2, y + h / 2
|
||||||
|
|
||||||
|
def center(n: str) -> tuple[float, float]:
|
||||||
|
ox, oy = self._rects[n][0], self._rects[n][1]
|
||||||
|
ow, oh = self._logical[n]
|
||||||
|
return (ox + ow / 2, oy + oh / 2)
|
||||||
|
|
||||||
|
tn = min(others, key=lambda n: math.hypot(*(
|
||||||
|
a - b for a, b in zip(center(n), (cx, cy)))))
|
||||||
|
ox, oy = self._rects[tn][0], self._rects[tn][1]
|
||||||
|
ow, oh = self._logical[tn]
|
||||||
|
ov = min(_ARR_MIN_OVERLAP, w, h, ow, oh)
|
||||||
|
|
||||||
|
def clamp(v: float, lo: float, hi: float) -> float:
|
||||||
|
return max(lo, min(v, hi))
|
||||||
|
|
||||||
|
# bündig an rechter/linker/unterer/oberer Kante des Nachbarn; die freie
|
||||||
|
# Achse behält die gezogene Position, geklemmt auf mind. `ov` Überlappung.
|
||||||
|
cands = [
|
||||||
|
(ox + ow, clamp(y, oy - h + ov, oy + oh - ov)),
|
||||||
|
(ox - w, clamp(y, oy - h + ov, oy + oh - ov)),
|
||||||
|
(clamp(x, ox - w + ov, ox + ow - ov), oy + oh),
|
||||||
|
(clamp(x, ox - w + ov, ox + ow - ov), oy - h),
|
||||||
|
]
|
||||||
|
bx, by = min(cands, key=lambda c: math.hypot(c[0] - x, c[1] - y))
|
||||||
|
self._rects[name][0], self._rects[name][1] = bx, by
|
||||||
|
|
||||||
|
def _normalized_positions(self) -> dict[str, tuple[int, int]]:
|
||||||
|
"""Ganzes Layout auf den Ursprung (0,0) verschoben — hält die an niri
|
||||||
|
gesendeten Koordinaten sauber statt driftender Absolutwerte."""
|
||||||
|
min_x = min(r[0] for r in self._rects.values())
|
||||||
|
min_y = min(r[1] for r in self._rects.values())
|
||||||
|
return {
|
||||||
|
n: (int(round(r[0] - min_x)), int(round(r[1] - min_y)))
|
||||||
|
for n, r in self._rects.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _arrangement_group(outs: list[display.Output]) -> ui.Group:
|
||||||
|
group = ui.Group(
|
||||||
|
title="Arrangement",
|
||||||
|
description="Drag each screen to where it sits on your desk. "
|
||||||
|
"They stay snapped together, so your pointer can always "
|
||||||
|
"move between them. Apply when it looks right.",
|
||||||
|
)
|
||||||
|
by_name = {o.name: o for o in outs}
|
||||||
|
# Drag nur Vorschau (siehe _ArrangementCanvas) — gesammelt und erst beim
|
||||||
|
# Klick auf "Apply" an niri geschickt.
|
||||||
|
pending: dict[str, tuple[int, int]] = {}
|
||||||
|
|
||||||
|
# Der Canvas meldet nach jedem Ziehen das GESAMTE, auf (0,0) normalisierte
|
||||||
|
# Layout — so wird immer eine lückenlose Anordnung angewandt.
|
||||||
|
def on_moved(positions: dict[str, tuple[int, int]]) -> None:
|
||||||
|
pending.clear()
|
||||||
|
pending.update(positions)
|
||||||
|
|
||||||
|
canvas = _ArrangementCanvas(outs, on_moved)
|
||||||
|
frame = Gtk.Frame()
|
||||||
|
frame.set_child(canvas)
|
||||||
|
frame.set_margin_top(6)
|
||||||
|
frame.set_margin_bottom(6)
|
||||||
|
group.add(frame)
|
||||||
|
|
||||||
|
status = Gtk.Label(xalign=0.0, wrap=True)
|
||||||
|
status.add_css_class("dim-label")
|
||||||
|
|
||||||
|
def _on_apply(_btn: Gtk.Button) -> None:
|
||||||
|
if not pending:
|
||||||
|
status.set_text("No changes to apply.")
|
||||||
|
return
|
||||||
|
# SYNCHRON pro Output, nicht über den geteilten ProcessRunner: der ist
|
||||||
|
# single-shot (lehnt einen zweiten run() ab, solange der erste async
|
||||||
|
# läuft), sodass eine Schleife mit mehreren Monitoren nur den ERSTEN
|
||||||
|
# verschoben und den Rest still verworfen hätte. `niri msg output …
|
||||||
|
# position set` ist unprivilegiert und praktisch instant — ein kurzer
|
||||||
|
# subprocess.run blockiert die UI nicht spürbar und bewegt garantiert
|
||||||
|
# alle Bildschirme.
|
||||||
|
failed: list[str] = []
|
||||||
|
for name, (x, y) in pending.items():
|
||||||
|
argv = display.apply_argv(by_name[name], position=(x, y))
|
||||||
|
try:
|
||||||
|
r = subprocess.run(argv, capture_output=True, text=True, timeout=5)
|
||||||
|
if r.returncode != 0:
|
||||||
|
failed.append(name)
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
failed.append(name)
|
||||||
|
if failed:
|
||||||
|
status.set_text("Could not move: " + ", ".join(failed))
|
||||||
|
return
|
||||||
|
# Persistent in die niri-Config schreiben, damit die Anordnung einen
|
||||||
|
# Reboot/Login überlebt (live via `niri msg` ist sonst weg). Schlägt nur
|
||||||
|
# das Speichern fehl, bleibt die Live-Anordnung trotzdem aktiv.
|
||||||
|
ok, msg = display.write_positions(dict(pending))
|
||||||
|
if ok:
|
||||||
|
status.set_text("Arrangement applied and saved.")
|
||||||
|
else:
|
||||||
|
status.set_text(f"Applied live, but not saved: {msg}")
|
||||||
|
pending.clear()
|
||||||
|
|
||||||
|
apply_btn = Gtk.Button(label="Apply")
|
||||||
|
apply_btn.add_css_class("suggested-action")
|
||||||
|
apply_btn.add_css_class("pill")
|
||||||
|
apply_btn.set_halign(Gtk.Align.START)
|
||||||
|
apply_btn.connect("clicked", _on_apply)
|
||||||
|
|
||||||
|
btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
btn_box.set_margin_bottom(6)
|
||||||
|
btn_box.append(apply_btn)
|
||||||
|
btn_box.append(status)
|
||||||
|
group.add(btn_box)
|
||||||
|
|
||||||
|
# -- Hauptdisplay --------------------------------------------------------
|
||||||
|
# Der Output mit Dock/Control-Center/Kalender/Musik/Sysmon/Updates/
|
||||||
|
# Spotlight/OSD/Overview-Trigger (eww-Seite, siehe panelcfg.sh). Wird
|
||||||
|
# sofort übernommen (kein Teil des Position-Apply oben — eigene Einstellung).
|
||||||
|
names = [o.name for o in outs]
|
||||||
|
cfg = panel.load()
|
||||||
|
current_main = cfg.get("display", {}).get("main_output", "") or names[0]
|
||||||
|
if current_main not in names:
|
||||||
|
current_main = names[0]
|
||||||
|
|
||||||
|
main_row = ui.ComboRow(title="Main display")
|
||||||
|
main_row.set_subtitle("Owns the dock, control center and overview trigger")
|
||||||
|
main_row.set_model(Gtk.StringList.new(names))
|
||||||
|
main_row.set_selected(names.index(current_main))
|
||||||
|
|
||||||
|
def _on_main_changed(r: ui.ComboRow, _p) -> None:
|
||||||
|
cfg2 = panel.load()
|
||||||
|
cfg2.setdefault("display", {})["main_output"] = names[r.get_selected()]
|
||||||
|
panel.save(cfg2)
|
||||||
|
|
||||||
|
main_row.connect("notify::selected", _on_main_changed)
|
||||||
|
group.add(main_row)
|
||||||
|
|
||||||
|
return group
|
||||||
|
|
||||||
|
|
||||||
def _output_group(out: display.Output, apply) -> ui.Group:
|
def _output_group(out: display.Output, apply) -> ui.Group:
|
||||||
desc = f"{out.make} {out.model}".strip() or None
|
desc = f"{out.make} {out.model}".strip() or None
|
||||||
group = ui.Group(title=out.name, description=desc)
|
group = ui.Group(title=out.name, description=desc)
|
||||||
@@ -129,6 +589,43 @@ def _output_group(out: display.Output, apply) -> ui.Group:
|
|||||||
return group
|
return group
|
||||||
|
|
||||||
|
|
||||||
|
# niri's own Overview hot corner is fest verdrahtet auf oben-links und lässt
|
||||||
|
# sich nicht abschalten/verschieben (kein Config-Knopf dafür — geprüft gegen
|
||||||
|
# /usr/share/doc/niri/default-config.kdl). Diese eigene eww-Hover-Zone ist
|
||||||
|
# daher ZUSÄTZLICH zum nativen Eck-Trigger, nicht dessen Ersatz.
|
||||||
|
_OVERVIEW_LABELS = {
|
||||||
|
"off": "Off",
|
||||||
|
"top-left": "Top-left corner",
|
||||||
|
"top-center": "Top-center (near the clock)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _overview_trigger_group() -> ui.Group:
|
||||||
|
group = ui.Group(
|
||||||
|
title="Overview trigger",
|
||||||
|
description="An extra hover zone at the top edge to open niri's "
|
||||||
|
"Overview. niri's own top-left corner trigger can't be "
|
||||||
|
"moved or disabled, so it stays active alongside this.",
|
||||||
|
)
|
||||||
|
cfg = panel.load()
|
||||||
|
current = cfg.get("overview", {}).get("trigger", "top-center")
|
||||||
|
if current not in panel.OVERVIEW_TRIGGERS:
|
||||||
|
current = "top-center"
|
||||||
|
|
||||||
|
row = ui.ComboRow(title="Position")
|
||||||
|
row.set_model(Gtk.StringList.new([_OVERVIEW_LABELS[t] for t in panel.OVERVIEW_TRIGGERS]))
|
||||||
|
row.set_selected(panel.OVERVIEW_TRIGGERS.index(current))
|
||||||
|
|
||||||
|
def _on_changed(r: ui.ComboRow, _p) -> None:
|
||||||
|
cfg2 = panel.load()
|
||||||
|
cfg2.setdefault("overview", {})["trigger"] = panel.OVERVIEW_TRIGGERS[r.get_selected()]
|
||||||
|
panel.save(cfg2)
|
||||||
|
|
||||||
|
row.connect("notify::selected", _on_changed)
|
||||||
|
group.add(row)
|
||||||
|
return group
|
||||||
|
|
||||||
|
|
||||||
def _night_light_group() -> ui.Group:
|
def _night_light_group() -> ui.Group:
|
||||||
group = ui.Group(
|
group = ui.Group(
|
||||||
title="Night light",
|
title="Night light",
|
||||||
|
|||||||
Reference in New Issue
Block a user