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:
2026-07-13 14:19:50 +02:00
parent 8fb8efcbbd
commit faaba27ed4
26 changed files with 1516 additions and 56 deletions
+4 -1
View File
@@ -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`
+92 -16
View File
@@ -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"`.
Calamares `packages`-Modul, das `tanin-desktop` installiert
(`iso/build-profile/`, gebaute ISO in `iso/out/`). 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
(`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 14 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,
+5 -3
View File
@@ -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
+8 -4
View File
@@ -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
+5 -3
View File
@@ -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
+8 -4
View File
@@ -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
+30 -3
View File
@@ -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
+15 -3
View File
@@ -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
+15 -4
View File
@@ -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
+92
View File
@@ -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
+95
View File
@@ -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
+15 -3
View File
@@ -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
+9 -3
View File
@@ -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
+1 -1
View File
@@ -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"
-1
View File
@@ -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]
+41
View File
@@ -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).
+16
View File
@@ -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"]
+46
View File
@@ -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())
+137
View File
@@ -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")
+197
View File
@@ -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});
"""
+136
View File
@@ -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
+24
View File
@@ -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:
+4 -3
View File
@@ -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)
+499 -2
View File
@@ -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",