diff --git a/README.md b/README.md index e150569..ca6d95a 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,10 @@ The central `taninux` app is organized around five pillars: | `tsettings` | Settings app (network, display, audio, users, …) | | `thub` | Software Hub (repo/AUR/flatpak install) | | `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 (`src/taninux/gui/ui/native.py` + `style_apple.css`); `TANINUX_UI=adw` diff --git a/docs/distribution.md b/docs/distribution.md index 5948801..69960b5 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -44,7 +44,7 @@ AUR/Binär-Pakete dürfen **nicht** nach `/home` schreiben. Daher: `taninux.kgva.ch` als Reverse-Proxy/Vanity davor. - **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 [tanin] # 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 ``` - #### 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): - 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. - 2. DB **signiert** bauen: `repo-add -s tanin.db.tar.zst *.pkg.tar.zst` - (`-s` signiert DB + jedes hinzugefügte Paket mit dem Packager-Key). - 3. Key beim User installieren (`pacman-key --add`, `pacman-key --lsign-key`) - und dann in `pacman.conf` scharfschalten: - ```ini - SigLevel = Required DatabaseOptional - ``` - → jedes Paket muss signiert sein; die DB-Signatur ist optional. -4. **Calamares / ISO** ✅ — `archiso`-Profil mit `[tanin]` vorkonfiguriert + - Calamares `packages`-Modul, das `tanin-desktop` installiert - (`iso/build-profile/`, gebaute ISO in `iso/out/`). + #### Repo-Signing + + Verdrahtet (`packaging/`): + - `packaging/gen-signing-key.sh` erzeugt den Packager-Key EINMALIG + (Ed25519, `gpg --batch --gen-key`, interaktive Passphrase — kein + Hardcoding) und druckt danach die Export-/Cutover-Schritte aus. + - `build-tanin-repo.sh`, `finish-tanin-repo.sh`, `aur-autoupdate.sh` lesen + `GPGKEY` aus der Umgebung und **brechen fail-closed ab**, wenn er fehlt — + es wird nie stillschweigend unsigniert gebaut. Pakete werden mit + `makepkg --sign` (bzw. bei `aur-autoupdate.sh`, das im isolierten + `makechrootpkg`-Chroot baut, per `gpg --detach-sign` direkt nach dem + 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 `-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 + (`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 # 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) @@ -83,6 +134,31 @@ exec-once = dbus-update-activation-environment --systemd --all exec-once = systemctl --user start -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 - **Code:** `git.openbureau.ch/karim/taninux` (+ Repo für die eww-Config, diff --git a/iso/build-profile/airootfs/etc/pacman.conf b/iso/build-profile/airootfs/etc/pacman.conf index 691d50a..50f3989 100644 --- a/iso/build-profile/airootfs/etc/pacman.conf +++ b/iso/build-profile/airootfs/etc/pacman.conf @@ -1,6 +1,8 @@ # 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, -# so Optional TrustAll verifies cleanly here (unlike the build conf which uses Never). +# [tanin]'s packager key is imported + locally signed into the keyring by +# 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] HoldPkg = pacman glibc Architecture = auto @@ -21,5 +23,5 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist [tanin] -SigLevel = Optional TrustAll +SigLevel = Required DatabaseOptional Server = https://git.openbureau.ch/api/packages/karim/arch/tanin/$arch diff --git a/iso/build-profile/airootfs/root/customize_airootfs.sh b/iso/build-profile/airootfs/root/customize_airootfs.sh index 9cdd448..8116f76 100755 --- a/iso/build-profile/airootfs/root/customize_airootfs.sh +++ b/iso/build-profile/airootfs/root/customize_airootfs.sh @@ -6,7 +6,17 @@ set -e -u sed -i 's/#\(en_US\.UTF-8 UTF-8\)/\1/' /etc/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 > .../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 ` +# — 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 --populate archlinux pacman-key --add /etc/pacman.d/tanin-repo.gpg diff --git a/iso/build-profile/pacman.conf b/iso/build-profile/pacman.conf index a608a54..40e36a4 100644 --- a/iso/build-profile/pacman.conf +++ b/iso/build-profile/pacman.conf @@ -1,8 +1,6 @@ # 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 -# our packages. SigLevel=Never on [tanin] keeps the BUILD simple (no keyring -# bootstrap during mkarchiso); the LIVE/target keyring trusts the key properly -# (see airootfs/etc/pacman.conf + customize_airootfs.sh). +# our packages. [options] HoldPkg = pacman glibc Architecture = auto @@ -21,5 +19,11 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist [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 diff --git a/iso/overrides/airootfs/etc/pacman.conf b/iso/overrides/airootfs/etc/pacman.conf index 691d50a..50f3989 100644 --- a/iso/overrides/airootfs/etc/pacman.conf +++ b/iso/overrides/airootfs/etc/pacman.conf @@ -1,6 +1,8 @@ # 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, -# so Optional TrustAll verifies cleanly here (unlike the build conf which uses Never). +# [tanin]'s packager key is imported + locally signed into the keyring by +# 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] HoldPkg = pacman glibc Architecture = auto @@ -21,5 +23,5 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist [tanin] -SigLevel = Optional TrustAll +SigLevel = Required DatabaseOptional Server = https://git.openbureau.ch/api/packages/karim/arch/tanin/$arch diff --git a/iso/overrides/airootfs/root/customize_airootfs.sh b/iso/overrides/airootfs/root/customize_airootfs.sh index 9cdd448..8116f76 100755 --- a/iso/overrides/airootfs/root/customize_airootfs.sh +++ b/iso/overrides/airootfs/root/customize_airootfs.sh @@ -6,7 +6,17 @@ set -e -u sed -i 's/#\(en_US\.UTF-8 UTF-8\)/\1/' /etc/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 > .../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 ` +# — 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 --populate archlinux pacman-key --add /etc/pacman.d/tanin-repo.gpg diff --git a/iso/overrides/pacman.conf b/iso/overrides/pacman.conf index a608a54..40e36a4 100644 --- a/iso/overrides/pacman.conf +++ b/iso/overrides/pacman.conf @@ -1,8 +1,6 @@ # 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 -# our packages. SigLevel=Never on [tanin] keeps the BUILD simple (no keyring -# bootstrap during mkarchiso); the LIVE/target keyring trusts the key properly -# (see airootfs/etc/pacman.conf + customize_airootfs.sh). +# our packages. [options] HoldPkg = pacman glibc Architecture = auto @@ -21,5 +19,11 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist [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 diff --git a/packaging/aur-autoupdate.sh b/packaging/aur-autoupdate.sh index b021457..579ce2f 100755 --- a/packaging/aur-autoupdate.sh +++ b/packaging/aur-autoupdate.sh @@ -20,6 +20,14 @@ # TANIN_CHROOT clean chroot location (default ~/.cache/tanin-chroot) # TANIN_AUR_CACHE AUR clone + state cache (default ~/.cache/tanin-aur) # 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 REPO_NAME="tanin" @@ -28,6 +36,7 @@ DB="$OUT/$REPO_NAME.db.tar.zst" CHROOT="${TANIN_CHROOT:-$HOME/.cache/tanin-chroot}" CACHE="${TANIN_AUR_CACHE:-$HOME/.cache/tanin-aur}" PUBLISH="${TANIN_PUBLISH_CMD:-}" +GPGKEY="${GPGKEY:-}" # AUR-only deps of TANINUX (not in the official repos). Mirrors the list in # 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" "$*"; } +# 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 -------------------------------------------------------- setup() { sudo pacman -S --needed --noconfirm devtools git jq curl @@ -96,7 +112,11 @@ build_one() { rm -f "$dir"/*.pkg.tar.zst # -c = clean copy of the chroot each time; -r = which chroot; -- = makepkg args 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 return 0 fi @@ -106,6 +126,13 @@ build_one() { run() { [ -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= in tanin-aur-update.service)." + exit 1 + fi mkdir -p "$OUT" "$CACHE" local built=0 failed=0 for p in "${AUR_PKGS[@]}"; do @@ -113,8 +140,8 @@ run() { done if [ "$built" -gt 0 ]; then - log "refreshing DB ($built rebuilt)" - repo-add -q "$DB" "$OUT"/*.pkg.tar.zst >/dev/null + log "refreshing DB ($built rebuilt, signed with $GPGKEY)" + repo-add -s -k "$GPGKEY" -q "$DB" "$OUT"/*.pkg.tar.zst >/dev/null # keep only the newest file per package on disk command -v paccache >/dev/null && paccache -rq -k1 -c "$OUT" >/dev/null 2>&1 || true if [ -n "$PUBLISH" ]; then diff --git a/packaging/build-tanin-repo.sh b/packaging/build-tanin-repo.sh index 347af21..84fded9 100755 --- a/packaging/build-tanin-repo.sh +++ b/packaging/build-tanin-repo.sh @@ -16,6 +16,17 @@ DB="$OUT/tanin.db.tar.zst" LOGDIR="$OUT/logs" mkdir -p "$OUT" "$LOGDIR" +# Fail closed: never build/publish unsigned packages. Run packaging/gen-signing-key.sh +# once, then `export GPGKEY=` (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=" >&2 + exit 1 +fi +export GPGKEY + # 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_BUILD=(taninux) @@ -27,7 +38,7 @@ AUR_REBUILD=(eww-git tiramisu-git waypaper calamares librewolf-bin build() { local p="$1" log="$LOGDIR/$p.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 \ && 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 " 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 pkgs=("$OUT"/*.pkg.tar.zst) 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 ==" ls -1 "$OUT" | grep -E '\.pkg\.tar\.zst$|tanin\.db' else diff --git a/packaging/finish-tanin-repo.sh b/packaging/finish-tanin-repo.sh index 8186b96..38d5c90 100755 --- a/packaging/finish-tanin-repo.sh +++ b/packaging/finish-tanin-repo.sh @@ -11,9 +11,20 @@ HERE="$(cd "$(dirname "$0")" && pwd)" OUT="${TANIN_REPO_DIR:-$HOME/projects/tanin-repo}" mkdir -p "$OUT" +# Fail closed: never build/publish unsigned packages. Run packaging/gen-signing-key.sh +# once, then `export GPGKEY=` 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=" >&2 + exit 1 +fi +export GPGKEY + echo "==> taninux (+ build deps)" 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)" 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 echo " -- $p" 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" else echo " FAILED ($p)" @@ -30,8 +41,8 @@ for p in $AUR_PKGS; do done rm -rf "$tmp" -echo "==> refresh DB" -( cd "$OUT" && repo-add -q "$OUT/tanin.db.tar.zst" "$OUT"/*.pkg.tar.zst ) +echo "==> refresh DB (signed with $GPGKEY)" +( cd "$OUT" && repo-add -s -k "$GPGKEY" -q "$OUT/tanin.db.tar.zst" "$OUT"/*.pkg.tar.zst ) echo "== [tanin] now contains ==" ls -1 "$OUT" | grep -E '\.pkg\.tar\.zst$|tanin\.db$' echo diff --git a/packaging/gen-signing-key.sh b/packaging/gen-signing-key.sh new file mode 100755 index 0000000..6bd74f5 --- /dev/null +++ b/packaging/gen-signing-key.sh @@ -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 ` 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" </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 < "$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 diff --git a/packaging/systemd/setup-build-user.sh b/packaging/systemd/setup-build-user.sh new file mode 100755 index 0000000..b510828 --- /dev/null +++ b/packaging/systemd/setup-build-user.sh @@ -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 < 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 < (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 diff --git a/packaging/systemd/tanin-aur-update.service b/packaging/systemd/tanin-aur-update.service index f0f3c82..86ba8f7 100644 --- a/packaging/systemd/tanin-aur-update.service +++ b/packaging/systemd/tanin-aur-update.service @@ -1,13 +1,25 @@ [Unit] 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 Wants=network-online.target [Service] Type=oneshot -# Adjust the path if your checkout lives elsewhere. -ExecStart=%h/projects/taninux/packaging/aur-autoupdate.sh run +# Dedicated, locked build account (see setup-build-user.sh) — NOT the human +# "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= +# 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. Nice=15 IOSchedulingClass=idle diff --git a/packaging/systemd/tanin-aur-update.sudoers b/packaging/systemd/tanin-aur-update.sudoers index 60b0a69..1814d7c 100644 --- a/packaging/systemd/tanin-aur-update.sudoers +++ b/packaging/systemd/tanin-aur-update.sudoers @@ -2,6 +2,12 @@ # Install: sudo install -m440 tanin-aur-update.sudoers /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 -# helpers devtools' makechrootpkg shells out to as root; nothing broader. -karim ALL=(root) NOPASSWD: /usr/bin/makechrootpkg, /usr/bin/arch-nspawn, /usr/bin/mkarchroot +# Subject is "tanin-build", a dedicated, locked (no password login) system +# account created by setup-build-user.sh — NOT the human "karim" account. +# 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 diff --git a/packaging/taninux/PKGBUILD b/packaging/taninux/PKGBUILD index 6f5eec0..ac4acd2 100644 --- a/packaging/taninux/PKGBUILD +++ b/packaging/taninux/PKGBUILD @@ -5,7 +5,7 @@ # Vor Publish: url + license bestätigen, source auf GitHub-Tag setzen. pkgname=taninux pkgver=0.2.0 -pkgrel=2 +pkgrel=5 pkgdesc="Central Linux management for Arch — GTK System Settings, Software Hub and TUI" arch=('any') url="https://taninux.kgva.ch" diff --git a/pyproject.toml b/pyproject.toml index f4ec46c..fac0977 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,6 @@ taninux = "taninux.cli:main" taninux-gtk = "taninux.gui.app:main" tsettings = "taninux.gui.app:main_settings" thub = "taninux.gui.app:main_hub" -tmusic = "taninux.music.app:main" tfiles = "taninux.files.app:main" [build-system] diff --git a/src/taninux/browser/README.md b/src/taninux/browser/README.md new file mode 100644 index 0000000..aa00724 --- /dev/null +++ b/src/taninux/browser/README.md @@ -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: + +- `/chrome/userChrome.css` — Chrome (Tabs, URL-Bar, Sidebar, Toolbar) +- `/chrome/userContent.css` — Akzent auf `about:`-Seiten +- `/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). diff --git a/src/taninux/browser/__init__.py b/src/taninux/browser/__init__.py new file mode 100644 index 0000000..1ac2f01 --- /dev/null +++ b/src/taninux/browser/__init__.py @@ -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"] diff --git a/src/taninux/browser/__main__.py b/src/taninux/browser/__main__.py new file mode 100644 index 0000000..404479c --- /dev/null +++ b/src/taninux/browser/__main__.py @@ -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()) diff --git a/src/taninux/browser/apply.py b/src/taninux/browser/apply.py new file mode 100644 index 0000000..220c612 --- /dev/null +++ b/src/taninux/browser/apply.py @@ -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 ``.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") diff --git a/src/taninux/browser/theme.py b/src/taninux/browser/theme.py new file mode 100644 index 0000000..c7c2e2c --- /dev/null +++ b/src/taninux/browser/theme.py @@ -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}); +""" diff --git a/src/taninux/core/display.py b/src/taninux/core/display.py index 852fb5e..7a19729 100644 --- a/src/taninux/core/display.py +++ b/src/taninux/core/display.py @@ -10,9 +10,11 @@ die Änderungen wieder weg. from __future__ import annotations import json +import os import re import shutil import subprocess +import tempfile from dataclasses import dataclass, field from pathlib import Path @@ -37,6 +39,19 @@ class Output: transform: str # "normal" / "90" / "180" / "270" enabled: bool 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 ---------------------------------------------------------------- @@ -83,6 +98,10 @@ def outputs() -> list[Output]: logical = o.get("logical") or 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). cur_idx = o.get("current_mode") 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", enabled=enabled, 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 @@ -133,6 +156,7 @@ def apply_argv( scale: float | None = None, transform: str | None = None, enabled: bool | None = None, + position: tuple[int, int] | None = None, ) -> list[str]: """argv für GENAU eine Änderung an `out` via `niri msg output`. Unprivilegiert ausführbar. Genau einer der Keyword-Parameter sollte gesetzt sein. @@ -150,9 +174,121 @@ def apply_argv( return base + ["scale", _fmt_scale(scale)] if transform is not None: return base + ["transform", transform] + if position is not None: + x, y = position + return base + ["position", "set", str(int(x)), str(int(y))] 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\s*)output\s+"(?P[^"]+)"\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) ------------------------------ # # TANINUX verwaltet einen EIGENEN systemd-User-Service diff --git a/src/taninux/core/panel.py b/src/taninux/core/panel.py index 1ba54d4..939bb09 100644 --- a/src/taninux/core/panel.py +++ b/src/taninux/core/panel.py @@ -31,8 +31,18 @@ _DEFAULTS = { "osd": {"enabled": True}, # Kalender-Panel: Schwellen für heutige/anstehende Termine (eww konsumiert). "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: return data_dir() / "panel.json" @@ -98,6 +108,12 @@ def load() -> dict: cfg["calendar"][k] = int(cal[k]) except (TypeError, ValueError): 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 @@ -134,6 +150,14 @@ def save(cfg: dict, *, apply: bool = True) -> None: "enabled": bool(cfg.get("osd", {}).get("enabled", True)), }, "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() try: diff --git a/src/taninux/files/__init__.py b/src/taninux/files/__init__.py index 270c350..54b9da5 100644 --- a/src/taninux/files/__init__.py +++ b/src/taninux/files/__init__.py @@ -1,8 +1,9 @@ """TANINUX Files — nativer Dateimanager im Apple-Look von TANINUX. -Schwesternapp zu TANINUX Music (`tmusic`): teilt sich dieselbe libadwaita-freie -UI-Naht (``gui/ui/native`` + ``style_apple.css``) und Akzent-/Hell-Dunkel-Logik -(``gui/style``). Aufbau analog zur Music-Shell: +Teilt sich dieselbe libadwaita-freie UI-Naht (``gui/ui/native`` + +``style_apple.css``) und Akzent-/Hell-Dunkel-Logik (``gui/style``) wie die +übrigen TANINUX-GTK-Apps (u.a. das ausgelagerte `taninux-music`/`tmusic`). +Aufbau: places.py – Sidebar-Orte (XDG-Verzeichnisse + eingehängte Laufwerke) window.py – Shell (Sidebar · Toolbar/Breadcrumb · Datei-Raster) diff --git a/src/taninux/gui/pages/display.py b/src/taninux/gui/pages/display.py index 535c867..1f096b0 100644 --- a/src/taninux/gui/pages/display.py +++ b/src/taninux/gui/pages/display.py @@ -6,13 +6,19 @@ die niri-Config (config.kdl). """ from __future__ import annotations +import math +import subprocess + import gi 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.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.process import ProcessRunner @@ -46,13 +52,467 @@ def build() -> Gtk.Widget: out = kwargs.pop("out") 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: page.add(_output_group(out, apply)) + page.add(_overview_trigger_group()) page.add(_night_light_group()) 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 position set `; 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: desc = f"{out.make} {out.model}".strip() or None group = ui.Group(title=out.name, description=desc) @@ -129,6 +589,43 @@ def _output_group(out: display.Output, apply) -> ui.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: group = ui.Group( title="Night light",