diff --git a/greetd/.gitignore b/greetd/.gitignore new file mode 100644 index 000000000..7a60b85e1 --- /dev/null +++ b/greetd/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/greetd/README.md b/greetd/README.md new file mode 100644 index 000000000..ededf75d9 --- /dev/null +++ b/greetd/README.md @@ -0,0 +1,111 @@ +# Labfy Greeter GTK4 V1 + +## Contrat + +`greetd` ouvre la session PAM. Le client IPC envoie exclusivement +`["/usr/bin/uwsm", "start", "default"]` dans `start_session`, avec `env=[]`. +Le greeter GTK termine ensuite, `run-ui` demande la sortie du Sway greeter, +et `greetd` démarre alors la session authentifiée. Aucun code du greeter ne +lance Sway utilisateur ou Session Restore V2. `/etc/pam.d/greetd` reste intact. + +## Fichiers + +- `src/greetd_ipc.py` : trames JSON UTF-8 bornées à 64 KiB, socket Unix. +- `src/auth.py` : réponses PAM `secret`, `visible`, `info`, `error`, invites successives et nouvel essai. +- `src/accounts.py` : comptes NSS dans les limites de `/etc/login.defs`, avec saisie manuelle. +- `src/system_state.py` : batterie, rétroéclairage et dernier nom réussi. +- `src/avatar_icon.py` : lecture de la copie PNG AccountsService root-owned. +- `src/avatar_backend.py` : conversion bornée puis `SetIconFile` via D-Bus système. +- `src/labfy_greeter.py` : fenêtre GTK4 et mode `--preview`. +- `style/labfy-greeter.css` : palette Mocha/Lavender versionnée autonome. +- `config/labfy-sway.conf` : Sway greeter séparé, sans `include` utilisateur. +- `scripts/start-compositor` : environnement HOME/XDG greeter, préserve `GREETD_SOCK`. +- `scripts/run-ui` : ferme le compositor après GTK. + +La palette provient de `quickshell/.config/quickshell/labfy-sway/theme/catppuccin.json`. +Le fond système `/etc/greetd/background/sway.png` est lu sans dépendance au HOME utilisateur. +La ligne `output *` fournit le fallback ; `eDP-1` reprend le mode et l'échelle de cette machine. + +Quick Settings → Apparence → Avatar propose un sélecteur de PNG, JPEG ou WebP. +`python-pillow` convertit l'image en PNG carré de 256 px, après vérification +de la taille du fichier et du nombre de pixels. AccountsService publie ensuite +la copie système. Le greeter ne consulte que +`/var/lib/AccountsService/icons/` : fichier régulier root-owned, +PNG de dimensions bornées. Une image absente ou invalide laisse l'icône +utilisateur par défaut. L'outil de réglage utilise la politique standard +`org.freedesktop.accounts.change-own-user-data` ; il n'installe ni sudoers ni +helper root. La page QuickShell se trouve sous Apparence → Avatar ; elle ne +versionne aucune image personnelle. + +## Prévisualisation sans authentification + +```sh +GDK_BACKEND=wayland python3 greetd/src/labfy_greeter.py --preview +``` + +Dans ce mode, le sélecteur est de démonstration, le champ et les actions sont +inactifs, le slider ne peut pas écrire et aucun `GREETD_SOCK` n'est requis. +La date utilise la locale système de `/etc/locale.conf`. + +## Validation locale + +```sh +python3 -m unittest discover -s greetd/tests -v +python3 -m py_compile greetd/src/*.py +sway --validate --config greetd/config/labfy-sway.conf +git diff --check +``` + +Pour prouver l'accès à la luminosité sans variation visible, un membre du +groupe `video` peut écrire la valeur courante dans `brightness` puis la relire. +Le test automatique n'écrit que dans un sysfs fictif temporaire. + +## Installation + +Depuis la copie source, avec les droits root : + +```sh +sudo greetd/scripts/install.sh --prepare +sudo greetd/scripts/install.sh --activate-after-visual-approval +``` + +`--prepare` sauve byte-for-byte les fichiers existants sous +`/var/lib/labfy-greeter/backup/` et vérifie leur SHA-256 avant toute écriture +sous `/etc/greetd`. Il installe code, CSS et configuration Sway avec ownership +root, prépare state/cache en `greeter:greeter` et active `getty@tty2.service`. +L'installateur accepte la configuration tuigreet sauvegardée ou la +configuration Labfy déjà active ; toute autre configuration active est refusée. +La commande `--activate-after-visual-approval` remplace uniquement +`/etc/greetd/config.toml`. Elle ne redémarre jamais greetd. +`/etc/greetd/sway` reste présent jusqu'à la recette réelle réussie. + +Avant logout, vérifier `systemctl is-active getty@tty2.service` et le chemin +de secours `Ctrl+Alt+F2` → connexion console. En cas d'échec, depuis tty2 : + +```sh +sudo /usr/local/libexec/labfy-greeter/rollback --restart +``` + +Le rollback root-owned vérifie les hashes, remet `config.toml` initial, +recharge systemd et ne redémarre greetd que sur demande explicite +`--restart` depuis le TTY. Les sauvegardes restent disponibles après rollback. + +## Première recette réelle + +Après approbation visuelle et activation sur disque, effectuer le logout normal +QuickShell, contrôler l'interface et la luminosité sur VT1, puis authentifier. +Vérifier ensuite `loginctl`, `XDG_RUNTIME_DIR`, `systemd --user`, UWSM, +`quickshell-labfy-sway.service`, l'unicité QuickShell et du chooser Session +Restore V2, autotiling, Limusic et transparency helper. Aucun commit/push avant +cette recette complète. + +Les boutons d'alimentation ne sont activés que si `org.freedesktop.login1.Manager` +retourne `yes` à `CanReboot` ou `CanPowerOff` dans le contexte greeter. +Une réponse `challenge`, `no` ou une erreur les désactive. Aucun privilège +supplémentaire n'est installé. Sur la machine validée, logind retourne +`challenge` : les boutons restent désactivés par la politique en place. + +Le thème de login reste Mocha/Lavender en V1. La mise à l'échelle validée +concerne l'écran `eDP-1` ; `output *` fournit un fallback. Session Restore V2 +reste indépendant du greeter : seul le démarrage UWSM lui transmet la session. +La recette et ses limites sont consignées dans `VALIDATION.md`. diff --git a/greetd/VALIDATION.md b/greetd/VALIDATION.md new file mode 100644 index 000000000..7a286b22d --- /dev/null +++ b/greetd/VALIDATION.md @@ -0,0 +1,63 @@ +# Recette Labfy Greeter GTK4 V1 + +## Gates de publication + +- `USER_VISUAL_VALIDATION=PASS` ; `REAL_GREETER_LOGIN=PASS`. +- `BRIGHTNESS_VT1=PASS` ; `SESSION_RESTORE_CHOOSER_COUNT=1` ; + `SESSION_RESTORE_V2_AFTER_GREETER=PASS` : constats utilisateur après login. +- `UWSM_SYSTEMD_SESSION_CHAIN=PASS` ; `GREETER_RUNTIME_HOME_INDEPENDENT=PASS`. +- `GREETER_PERMISSIONS=PASS` ; `RECOVERY_TTY2=READY`. +- `AUTH_SECRET_NOT_LOGGED=PASS` ; `PUBLICATION_PRIVACY_SCAN=PASS` ; + `TESTS=PASS`. +- `POWER_ACTIONS=DISABLED_BY_POLICY`. + +## Chaîne de connexion + +La validation visuelle et une connexion sur le greeter réel ont été confirmées +par l'utilisateur. Le chemin observé est : greetd → PAM → Labfy Greeter → +`/usr/bin/uwsm start default` → session Wayland Sway → `systemd --user`. +Le client IPC envoie un argv fixe à `start_session` ; il ne lance pas UWSM +lui-même. Après login, aucun processus du greeter ou de son Sway temporaire +ne reste ouvert. + +`loginctl` indique une session `Service=greetd`, `Type=wayland` et un +`XDG_RUNTIME_DIR` propre au compte utilisateur. Les services +`wayland-wm@sway.desktop.service` et `quickshell-labfy-sway.service` sont +actifs ; un seul processus QuickShell, autotiling, Limusic et l'aide à la +transparence sont observés selon leurs modes habituels. + +L'utilisateur a constaté sur VT1 que le curseur modifie la luminosité, sans +élévation de privilège. Après un checkpoint éligible, il a observé exactement +un chooser Session Restore V2 et une restauration réussie. Aucun code +Session Restore V2 n'a été modifié pour ce greeter. + +## Contrôles techniques + +- Faux socket greetd : trames bornées, invites PAM `secret`, `visible`, `info`, + `error`, plusieurs invites, erreur puis nouvel essai, `start_session` avec + `['/usr/bin/uwsm', 'start', 'default']` uniquement. +- Secret synthétique : absent des sorties stdout/stderr capturées par le test. +- Comptes NSS, batterie absente ou présente, luminosité bornée et avatar + facultatif : tests Python réussis. +- CSS GTK chargé sans erreur ; `qmllint`, `py_compile`, validation Sway et + `git diff --check` réussis. +- Code, CSS, config et scripts exécutables : `root:root`, non modifiables par + `greeter`. État/cache : `greeter:greeter` en mode `0700`. +- Runtime sans dépendance au HOME utilisateur pour code, CSS, fond, config, + état ou authentification. + +## Récupération et limites + +La sauvegarde initiale et son manifeste SHA-256 restent dans +`/var/lib/labfy-greeter/backup/` et ne sont pas versionnés. Le secours +`getty@tty2.service` reste actif. Depuis `Ctrl+Alt+F2`, la commande +`sudo /usr/local/libexec/labfy-greeter/rollback --restart` restaure tuigreet. +Le `config.toml` initial sauvegardé a pour SHA-256 +`6c2dc3c9e0973a87ff0fb33de05e7e5c697303aec6e5e399e3caf5577cf1096a` ; +la configuration Labfy active a pour SHA-256 +`33f2b27dfffdd0a6b160ecfc9b47fb570f09fba647b0b37c276c747dbe55e9f5`. + +`CanReboot` et `CanPowerOff` répondent `challenge` sous `greeter` : les boutons +sont désactivés par la politique logind actuelle. Le thème V1 est fixe +Mocha/Lavender. La sortie physique validée est `eDP-1`, avec fallback Sway +`output *` si la sortie change. diff --git a/greetd/config/config.toml b/greetd/config/config.toml new file mode 100644 index 000000000..a32ca29a2 --- /dev/null +++ b/greetd/config/config.toml @@ -0,0 +1,7 @@ +# Labfy Greeter. L'argv UWSM authentifié est fixé dans greetd_ipc.py. +[terminal] +vt = 1 + +[default_session] +command = "/usr/local/libexec/labfy-greeter/start-compositor" +user = "greeter" diff --git a/greetd/config/labfy-sway.conf b/greetd/config/labfy-sway.conf new file mode 100644 index 000000000..af2742b12 --- /dev/null +++ b/greetd/config/labfy-sway.conf @@ -0,0 +1,18 @@ +# WHY: compositor temporaire du compte greeter, isolé de /etc/sway/config.d/*. +# CONTRACT: la session authentifiée est démarrée par greetd via UWSM, jamais par ce compositor. +output * bg /etc/greetd/background/sway.png fill +output eDP-1 mode 3200x2000@60Hz scale 1.4 +input type:keyboard { + xkb_layout fr + xkb_variant oss + xkb_numlock enabled +} +input type:touchpad { + dwt enabled + tap enabled + natural_scroll enabled + middle_emulation enabled +} +default_border none +# Le lanceur ferme ce Sway lorsque GTK termine après start_session. +exec /usr/local/libexec/labfy-greeter/run-ui diff --git a/greetd/scripts/audit-greeter.sh b/greetd/scripts/audit-greeter.sh new file mode 100755 index 000000000..2d0e8165d --- /dev/null +++ b/greetd/scripts/audit-greeter.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# Audit pré-switch en contexte greeter ; aucune action reboot/poweroff n'est exécutée. +set -eu +[ "$(id -u)" -eq 0 ] || { echo 'Exécuter comme root' >&2; exit 1; } +( cd /var/lib/labfy-greeter/backup && sha256sum -c SHA256SUMS ) +for method in CanReboot CanPowerOff; do + printf '%s=' "$method" + runuser -u greeter -- /usr/bin/busctl --system call org.freedesktop.login1 /org/freedesktop/login1 org.freedesktop.login1.Manager "$method" +done +# CONTRACT: écrire exactement la valeur relue, sans modifier la luminosité visuelle. +runuser -u greeter -- /usr/bin/python3 - <<'PY' +from pathlib import Path +for path in sorted(Path('/sys/class/backlight').glob('*/brightness')): + value = path.read_text().strip() + path.write_text(value + '\n') + if path.read_text().strip() != value: + raise SystemExit('BRIGHTNESS_PERMISSION=FAIL') + print('BRIGHTNESS_PERMISSION=PASS device=' + path.parent.name) + break +else: + print('BRIGHTNESS_PERMISSION=NO_DEVICE') +PY diff --git a/greetd/scripts/install.sh b/greetd/scripts/install.sh new file mode 100755 index 000000000..6977e1313 --- /dev/null +++ b/greetd/scripts/install.sh @@ -0,0 +1,59 @@ +#!/bin/sh +# Installation préparatoire. Activation explicite uniquement après approbation visuelle. +set -eu +[ "$(id -u)" -eq 0 ] || { echo 'Exécuter comme root' >&2; exit 1; } +[ "$#" -eq 1 ] && { [ "$1" = --prepare ] || [ "$1" = --activate-after-visual-approval ]; } || { echo 'Usage: install.sh --prepare|--activate-after-visual-approval' >&2; exit 1; } +SOURCE=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +BACKUP=/var/lib/labfy-greeter/backup +install -d -o root -g root -m 0755 /var/lib/labfy-greeter +install -d -o root -g root -m 0700 "$BACKUP" +# CONTRACT: sauvegardes byte-for-byte avant toute écriture sous /etc/greetd. +for file in config.toml sway labfy-sway.conf; do + if [ -e "/etc/greetd/$file" ] && [ ! -e "$BACKUP/$file" ]; then + cp -p -- "/etc/greetd/$file" "$BACKUP/$file" + fi +done +( cd "$BACKUP" && for file in config.toml sway labfy-sway.conf; do + if [ -f "$file" ]; then sha256sum "$file"; fi +done ) > "$BACKUP/SHA256SUMS" +( cd "$BACKUP" && sha256sum -c SHA256SUMS ) +# INVARIANT: seule la configuration d'origine ou notre configuration active +# peut être remplacée. Cela permet de réinstaller le greeter sans accepter +# une modification tierce de /etc/greetd/config.toml. +if ! cmp -s /etc/greetd/config.toml "$BACKUP/config.toml" && + ! cmp -s /etc/greetd/config.toml "$SOURCE/config/config.toml"; then + echo 'Configuration greetd active inconnue depuis la sauvegarde' >&2 + exit 1 +fi +install -d -o greeter -g greeter -m 0700 /var/lib/labfy-greeter/config /var/lib/labfy-greeter/state /var/cache/labfy-greeter +install -d -o root -g root -m 0755 /usr/local/libexec/labfy-greeter /usr/local/share/labfy-greeter +for file in labfy_greeter.py greetd_ipc.py auth.py accounts.py system_state.py avatar_icon.py avatar_backend.py; do + install -o root -g root -m 0644 "$SOURCE/src/$file" "/usr/local/libexec/labfy-greeter/$file" +done +install -o root -g root -m 0755 "$SOURCE/scripts/run-ui" /usr/local/libexec/labfy-greeter/run-ui +install -o root -g root -m 0755 "$SOURCE/scripts/start-compositor" /usr/local/libexec/labfy-greeter/start-compositor +install -o root -g root -m 0755 "$SOURCE/scripts/rollback.sh" /usr/local/libexec/labfy-greeter/rollback +install -o root -g root -m 0644 "$SOURCE/style/labfy-greeter.css" /usr/local/share/labfy-greeter/labfy-greeter.css +install -o root -g root -m 0644 "$SOURCE/config/labfy-sway.conf" /etc/greetd/labfy-sway.conf +# WHY: pkexec nettoie XDG_RUNTIME_DIR ; même --validate exige un répertoire +# runtime possédé par l'appelant. Le backend headless évite de demander un +# siège logind au processus root de validation ; ces variables sont temporaires +# et ne sont jamais transmises au vrai greeter ni à la session UWSM. +validate_dir=$(mktemp -d) +if ! XDG_RUNTIME_DIR="$validate_dir" WLR_BACKENDS=headless WLR_RENDERER=pixman \ + /usr/bin/sway --validate --config /etc/greetd/labfy-sway.conf; then + rm -rf -- "$validate_dir" + exit 1 +fi +rm -rf -- "$validate_dir" +# Le tty2 est requis comme voie de récupération avant tout basculement. +systemctl enable --now getty@tty2.service +systemctl is-active --quiet getty@tty2.service +if [ "$1" = --prepare ]; then + echo 'Préparation terminée ; config.toml actif inchangé.' + exit 0 +fi +[ -e "$BACKUP/config.toml" ] || { echo 'Sauvegarde greetd absente' >&2; exit 1; } +install -o root -g root -m 0644 "$SOURCE/config/config.toml" /etc/greetd/config.toml +# INVARIANT: aucun restart greetd ; le changement attend le logout normal. +echo 'Configuration activée sur disque. greetd ne sera pas redémarré.' diff --git a/greetd/scripts/rollback.sh b/greetd/scripts/rollback.sh new file mode 100755 index 000000000..cd83543f9 --- /dev/null +++ b/greetd/scripts/rollback.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu +[ "$(id -u)" -eq 0 ] || { echo 'Exécuter comme root' >&2; exit 1; } +BACKUP=/var/lib/labfy-greeter/backup +( cd "$BACKUP" && sha256sum -c SHA256SUMS ) +install -o root -g root -m 0644 "$BACKUP/config.toml" /etc/greetd/config.toml +systemctl daemon-reload +if [ "${1:-}" = --restart ]; then + systemctl restart greetd.service +fi +echo 'Configuration tuigreet restaurée ; restart uniquement si --restart.' diff --git a/greetd/scripts/run-ui b/greetd/scripts/run-ui new file mode 100755 index 000000000..2e815e7ff --- /dev/null +++ b/greetd/scripts/run-ui @@ -0,0 +1,4 @@ +#!/bin/sh +# Le compositor greeter doit terminer avant que greetd lance la session authentifiée. +/usr/bin/python3 /usr/local/libexec/labfy-greeter/labfy_greeter.py +/usr/bin/swaymsg exit >/dev/null 2>&1 diff --git a/greetd/scripts/smoke-headless.sh b/greetd/scripts/smoke-headless.sh new file mode 100755 index 000000000..a0c898ce0 --- /dev/null +++ b/greetd/scripts/smoke-headless.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# Vérifie le démarrage Sway + GTK installés comme greeter, sans VT ni login réel. +set -eu +[ "$(id -u)" -eq 0 ] || { echo 'Exécuter comme root' >&2; exit 1; } +runtime=$(mktemp -d) +chown greeter:greeter "$runtime" +chmod 0700 "$runtime" +set +e +timeout --signal=TERM --kill-after=2s 10s \ + runuser -u greeter -- env XDG_RUNTIME_DIR="$runtime" \ + WLR_BACKENDS=headless WLR_RENDERER=pixman GREETD_SOCK="$runtime/unused" \ + /usr/local/libexec/labfy-greeter/start-compositor >"$runtime/smoke.log" 2>&1 & +runner=$! +sleep 3 +if pgrep -u greeter -f '^/usr/bin/python3 /usr/local/libexec/labfy-greeter/labfy_greeter.py$' >/dev/null; then + gtk_started=yes +else + gtk_started=no +fi +wait "$runner" +result=$? +set -e +if [ "$gtk_started" = yes ] && { [ "$result" -eq 124 ] || [ "$result" -eq 137 ]; } && \ + ! pgrep -u greeter -f '^/usr/bin/python3 /usr/local/libexec/labfy-greeter/labfy_greeter.py$' >/dev/null; then + echo 'HEADLESS_GREETER_SMOKE=PASS (GTK actif, arrêt contrôlé, aucun processus résiduel)' +else + cat "$runtime/smoke.log" >&2 + echo "HEADLESS_GREETER_SMOKE=FAIL gtk=$gtk_started exit=$result" >&2 + rm -rf -- "$runtime" + exit 1 +fi +rm -rf -- "$runtime" diff --git a/greetd/scripts/start-compositor b/greetd/scripts/start-compositor new file mode 100755 index 000000000..aace712e2 --- /dev/null +++ b/greetd/scripts/start-compositor @@ -0,0 +1,7 @@ +#!/bin/sh +# Environnement XDG propre, GREETD_SOCK hérité pour le protocole IPC. +export HOME=/var/lib/labfy-greeter +export XDG_CONFIG_HOME=/var/lib/labfy-greeter/config +export XDG_STATE_HOME=/var/lib/labfy-greeter/state +export XDG_CACHE_HOME=/var/cache/labfy-greeter +exec /usr/bin/sway --config /etc/greetd/labfy-sway.conf diff --git a/greetd/src/accounts.py b/greetd/src/accounts.py new file mode 100644 index 000000000..8772e9c54 --- /dev/null +++ b/greetd/src/accounts.py @@ -0,0 +1,20 @@ +"""Énumération NSS des comptes humains sans fixer un nom d'utilisateur.""" +import pwd + + +def uid_limits(path="/etc/login.defs"): + limits = {"UID_MIN": 1000, "UID_MAX": 60000} + with open(path, encoding="utf-8") as stream: + for line in stream: + parts = line.split("#", 1)[0].split() + if len(parts) == 2 and parts[0] in limits: + limits[parts[0]] = int(parts[1]) + return limits["UID_MIN"], limits["UID_MAX"] + + +def users(entries=None, limits=None): + entries = pwd.getpwall() if entries is None else entries + low, high = uid_limits() if limits is None else limits + return sorted((entry.pw_name for entry in entries if low <= entry.pw_uid <= high + and entry.pw_shell.rsplit("/", 1)[-1] not in ("nologin", "false") + and entry.pw_name), key=str.casefold) diff --git a/greetd/src/auth.py b/greetd/src/auth.py new file mode 100644 index 000000000..58eb21784 --- /dev/null +++ b/greetd/src/auth.py @@ -0,0 +1,60 @@ +"""Machine PAM indépendante de GTK pour préserver les invites multiples.""" +from greetd_ipc import Client, ProtocolError + + +class AuthFlow: + def __init__(self, socket_path=None): + self.socket_path = socket_path + self.client = None + self.username = None + self.pending = None + self.authenticated = False + + def begin(self, username): + if not username or len(username) > 256 or any(ord(c) < 32 for c in username): + raise ValueError("Nom utilisateur invalide") + self.reset() + self.username = username + self.client = Client(self.socket_path) + return self._accept(self.client.create(username)) + + def answer(self, value=None): + if self.pending is None or self.client is None: + raise ProtocolError("Aucune invite PAM active") + kind = self.pending["auth_message_type"] + if kind in ("secret", "visible") and value is None: + raise ProtocolError("Réponse PAM requise") + if kind in ("info", "error"): + value = None + self.pending = None + return self._accept(self.client.answer(value)) + + def _accept(self, reply): + self.pending = None + if reply["type"] == "auth_message": + self.pending = reply + elif reply["type"] == "success": + self.authenticated = True + elif reply["type"] == "error": + self.reset() + return reply + + def start(self): + if not self.authenticated or self.client is None: + raise ProtocolError("Session non authentifiée") + reply = self.client.start() + if reply["type"] == "success": + self.client.close() + self.client = None + return reply + + def reset(self): + if self.client is not None: + try: + self.client.cancel() + except (OSError, ProtocolError): + pass + self.client.close() + self.client = None + self.pending = None + self.authenticated = False diff --git a/greetd/src/avatar_backend.py b/greetd/src/avatar_backend.py new file mode 100644 index 000000000..87935a899 --- /dev/null +++ b/greetd/src/avatar_backend.py @@ -0,0 +1,101 @@ +#!/usr/bin/python3 +"""Publie l'avatar du compte courant via AccountsService, sans privilège local.""" +import json +import os +from pathlib import Path +import pwd +import stat +import sys +from urllib.parse import unquote, urlsplit + +from PIL import Image, ImageOps, UnidentifiedImageError +from avatar_icon import system_icon +import gi +from gi.repository import Gio, GLib + +MAX_SOURCE_BYTES = 12 * 1024 * 1024 +MAX_SOURCE_PIXELS = 16_000_000 +Image.MAX_IMAGE_PIXELS = MAX_SOURCE_PIXELS + + +class AvatarError(Exception): + pass + + +def source_path(value): + """CONTRACT: seule une image locale régulière du compte appelant est acceptée.""" + url = urlsplit(value) + if url.scheme not in ("", "file") or url.netloc not in ("", "localhost"): + raise AvatarError("Choisissez un fichier image local.") + path = Path(unquote(url.path) if url.scheme else value).resolve(strict=True) + info = path.stat() + if not stat.S_ISREG(info.st_mode) or not 0 < info.st_size <= MAX_SOURCE_BYTES: + raise AvatarError("L’image doit être un fichier de 12 Mio au maximum.") + return path + + +def render_png(source, target): + """WHY: conversion bornée en PNG carré pour le contrat SetIconFile.""" + try: + with Image.open(source) as opened: + if opened.width * opened.height > MAX_SOURCE_PIXELS: + raise AvatarError("Image trop grande.") + opened.load() + normalized = ImageOps.exif_transpose(opened) + square = ImageOps.fit(normalized.convert("RGBA"), (256, 256), method=Image.Resampling.LANCZOS) + square.save(target, format="PNG", optimize=True) + except (OSError, UnidentifiedImageError, Image.DecompressionBombError, Image.DecompressionBombWarning) as exc: + raise AvatarError("Image illisible ou trop grande.") from exc + + +def set_avatar(value): + source = source_path(value) + username = pwd.getpwuid(os.getuid()).pw_name + data_home = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share") + directory = data_home / "labfy-greeter" + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + destination = directory / "avatar.png" + temporary = directory / f".avatar-{os.getpid()}.png" + try: + with temporary.open("xb") as output: + os.fchmod(output.fileno(), 0o600) + render_png(source, output) + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, destination) + # AccountsService ingère le fichier comme appelant puis publie sa copie root-owned. + bus = Gio.bus_get_sync(Gio.BusType.SYSTEM) + user_path = bus.call_sync("org.freedesktop.Accounts", "/org/freedesktop/Accounts", + "org.freedesktop.Accounts", "FindUserById", + GLib.Variant("(x)", (os.getuid(),)), GLib.VariantType.new("(o)"), + Gio.DBusCallFlags.NONE, 10000).unpack()[0] + bus.call_sync("org.freedesktop.Accounts", user_path, "org.freedesktop.Accounts.User", + "SetIconFile", GLib.Variant("(s)", (str(destination),)), None, + Gio.DBusCallFlags.NONE, 15000) + finally: + temporary.unlink(missing_ok=True) + published = system_icon(username) + if published is None: + raise AvatarError("La copie système de l’avatar est indisponible.") + return str(published) + + +def main(argv): + try: + if argv == ["current"]: + icon = system_icon(pwd.getpwuid(os.getuid()).pw_name) + print(json.dumps({"path": str(icon) if icon else ""})) + elif len(argv) == 2 and argv[0] == "set": + print(json.dumps({"path": set_avatar(argv[1])})) + else: + raise AvatarError("Commande d’avatar invalide.") + except (AvatarError, OSError, GLib.Error, ValueError) as exc: + # Les erreurs D-Bus ne sont pas reproduites dans l'interface utilisateur. + message = str(exc) if isinstance(exc, AvatarError) else "Impossible de modifier l’avatar." + print(json.dumps({"error": message})) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/greetd/src/avatar_icon.py b/greetd/src/avatar_icon.py new file mode 100644 index 000000000..758318bff --- /dev/null +++ b/greetd/src/avatar_icon.py @@ -0,0 +1,26 @@ +"""Lecture stricte d'une icône AccountsService publiée hors du HOME utilisateur.""" +from pathlib import Path +import stat + +ICON_ROOT = Path("/var/lib/AccountsService/icons") + + +def system_icon(username, root=ICON_ROOT): + # CONTRACT: ne jamais suivre un chemin arbitraire renvoyé par D-Bus ou un + # alias HOME ; seuls les PNG réguliers root-owned du répertoire système passent. + if not username or username in (".", "..") or "/" in username or "\\" in username: + return None + path = root / username + try: + info = path.lstat() + if stat.S_ISREG(info.st_mode) and info.st_uid == 0 and 0 < info.st_size <= 2 * 1024 * 1024: + with path.open("rb") as stream: + header = stream.read(24) + if header[:8] == b"\x89PNG\r\n\x1a\n" and header[12:16] == b"IHDR": + width = int.from_bytes(header[16:20], "big") + height = int.from_bytes(header[20:24], "big") + if 0 < width <= 512 and 0 < height <= 512: + return path + except OSError: + pass + return None diff --git a/greetd/src/greetd_ipc.py b/greetd/src/greetd_ipc.py new file mode 100644 index 000000000..3244b313d --- /dev/null +++ b/greetd/src/greetd_ipc.py @@ -0,0 +1,84 @@ +"""Client borné pour le protocole greetd-ipc(7).""" +import json +import os +import socket +import struct + +MAX_FRAME = 65536 +SESSION_COMMAND = ["/usr/bin/uwsm", "start", "default"] + + +class ProtocolError(Exception): + pass + + +class Client: + """Une connexion par conversation PAM ; aucun contenu secret n'est journalisé.""" + + def __init__(self, path=None): + self.path = path or os.environ.get("GREETD_SOCK") + if not self.path: + raise ProtocolError("Socket greetd indisponible") + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.settimeout(10) + try: + self.sock.connect(self.path) + except OSError: + self.sock.close() + raise + + def close(self): + self.sock.close() + + def __enter__(self): + return self + + def __exit__(self, *_): + self.close() + + def request(self, message): + # CONTRACT: longueur native uint32 suivie de JSON UTF-8, sans données sensibles en logs. + raw = json.dumps(message, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + if len(raw) > MAX_FRAME: + raise ProtocolError("Message trop long") + self.sock.sendall(struct.pack("=I", len(raw)) + raw) + size = struct.unpack("=I", self._read(4))[0] + if not 0 < size <= MAX_FRAME: + raise ProtocolError("Réponse de taille invalide") + try: + reply = json.loads(self._read(size).decode("utf-8")) + except (UnicodeError, ValueError) as exc: + raise ProtocolError("Réponse greetd invalide") from exc + if not isinstance(reply, dict) or reply.get("type") not in ("success", "error", "auth_message"): + raise ProtocolError("Type de réponse greetd invalide") + if reply["type"] == "auth_message" and (reply.get("auth_message_type") not in ("secret", "visible", "info", "error") or not isinstance(reply.get("auth_message"), str)): + raise ProtocolError("Message PAM invalide") + if reply["type"] == "error" and reply.get("error_type") not in ("auth_error", "error"): + raise ProtocolError("Erreur greetd invalide") + return reply + + def _read(self, count): + parts = [] + while count: + part = self.sock.recv(count) + if not part: + raise ProtocolError("Connexion greetd interrompue") + parts.append(part) + count -= len(part) + return b"".join(parts) + + def create(self, username): + return self.request({"type": "create_session", "username": username}) + + def answer(self, response=None): + message = {"type": "post_auth_message_response"} + if response is not None: + message["response"] = response + return self.request(message) + + def cancel(self): + return self.request({"type": "cancel_session"}) + + def start(self): + # INVARIANT: la commande authentifiée est fixe et exécutée par greetd, jamais ici. + return self.request({"type": "start_session", "cmd": SESSION_COMMAND, "env": []}) diff --git a/greetd/src/labfy_greeter.py b/greetd/src/labfy_greeter.py new file mode 100644 index 000000000..a10ae34fa --- /dev/null +++ b/greetd/src/labfy_greeter.py @@ -0,0 +1,391 @@ +#!/usr/bin/python3 +"""Interface GTK4 Labfy ; la session réelle est créée exclusivement par greetd.""" +import argparse +from datetime import datetime +import locale +from pathlib import Path +import threading + +import gi +gi.require_version("Gtk", "4.0") +gi.require_version("Gdk", "4.0") +from gi.repository import Gdk, Gio, GLib, Gtk + +from accounts import users +from auth import AuthFlow +from avatar_icon import system_icon +from system_state import battery, backlight, read_last_user, save_last_user, set_brightness + +CSS = Path("/usr/local/share/labfy-greeter/labfy-greeter.css") +if not CSS.exists(): + CSS = Path(__file__).resolve().parent.parent / "style/labfy-greeter.css" + + +class Greeter(Gtk.Application): + def __init__(self, preview=False): + super().__init__(application_id="org.labfy.Greeter.Preview" if preview else "org.labfy.Greeter") + self.preview = preview + self.flow = None if preview else AuthFlow() + self.busy = False + self.current_user = None + self.pending_kind = None + self.light = None + self.start_called = False + self.selection_ready = False + self.connect("activate", self.activate) + + def activate(self, *_): + provider = Gtk.CssProvider() + provider.load_from_path(str(CSS)) + Gtk.StyleContext.add_provider_for_display(Gdk.Display.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + self.window = Gtk.ApplicationWindow(application=self, title="Labfy Greeter") + self.window.set_default_size(1200, 760) + if not self.preview: + self.window.fullscreen() + canvas = Gtk.Overlay() + canvas.add_css_class("canvas") + self.window.set_child(canvas) + shade = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + shade.add_css_class("shade") + canvas.set_child(shade) + layout = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24) + layout.set_margin_top(48) + layout.set_margin_bottom(36) + layout.set_margin_start(32) + layout.set_margin_end(32) + shade.append(layout) + + # INVARIANT: l'horloge et la date sont centrées dans toute la fenêtre ; + # aucun widget de statut ne participe à leur allocation horizontale. + clock = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + clock.set_halign(Gtk.Align.CENTER) + layout.append(clock) + self.time = Gtk.Label() + self.time.add_css_class("clock") + clock.append(self.time) + self.date = Gtk.Label() + self.date.add_css_class("date") + clock.append(self.date) + self.refresh_clock() + GLib.timeout_add_seconds(15, self.refresh_clock) + + middle = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, hexpand=True, vexpand=True) + middle.set_valign(Gtk.Align.CENTER) + layout.append(middle) + card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=15) + card.add_css_class("login-card") + card.set_halign(Gtk.Align.CENTER) + card.set_size_request(410, -1) + middle.append(card) + self.avatar_picture = Gtk.Picture() + self.avatar_picture.set_size_request(68, 68) + self.avatar_picture.set_content_fit(Gtk.ContentFit.COVER) + self.avatar_picture.add_css_class("avatar-picture") + self.avatar_picture.set_halign(Gtk.Align.CENTER) + card.append(self.avatar_picture) + self.avatar_fallback = Gtk.Label(label="") + self.avatar_fallback.add_css_class("avatar") + card.append(self.avatar_fallback) + title = Gtk.Label(label="Bienvenue") + title.add_css_class("card-title") + card.append(title) + self.selector = Gtk.DropDown.new_from_strings((['Utilisateur de démonstration'] if self.preview else users()) + ['Autre utilisateur…']) + self.selector.add_css_class("user-selector") + self.selector.connect("notify::selected", self.user_changed) + card.append(self.selector) + self.manual = Gtk.Entry(placeholder_text="Nom utilisateur") + self.manual.connect("activate", lambda *_: self.submit()) + self.manual.connect("changed", lambda *_: self.refresh_avatar(self.manual.get_text().strip())) + self.manual.set_visible(False) + card.append(self.manual) + self.prompt = Gtk.Label(label="Mot de passe") + self.prompt.set_xalign(0) + self.prompt.add_css_class("secondary") + card.append(self.prompt) + self.entry = Gtk.Entry() + self.entry.set_visibility(False) + self.entry.connect("activate", lambda *_: self.submit()) + card.append(self.entry) + self.error = Gtk.Label() + self.error.add_css_class("error") + self.error.set_wrap(True) + self.error.set_xalign(0) + card.append(self.error) + self.button = Gtk.Button(label="Connexion") + self.button.add_css_class("primary") + self.button.connect("clicked", lambda *_: self.submit()) + card.append(self.button) + if self.preview: + self.button.set_sensitive(False) + self.entry.set_sensitive(False) + self.error.set_text("Aperçu · authentification désactivée") + else: + remembered = read_last_user() + names = users() + if remembered in names: + self.selector.set_selected(names.index(remembered)) + self.entry.set_sensitive(False) + self.refresh_avatar(self.username() if not self.preview else "") + + # CenterBox maintient la luminosité au centre même si l'état batterie + # change de longueur ou disparaît ; le bloc haut reste indépendant. + bottom = Gtk.CenterBox() + layout.append(bottom) + self.battery_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=7) + self.battery_box.set_valign(Gtk.Align.CENTER) + bottom.set_start_widget(self.battery_box) + self.battery_icon = Gtk.Label() + self.battery_icon.add_css_class("battery-icon") + self.battery_box.append(self.battery_icon) + self.battery_label = Gtk.Label() + self.battery_label.add_css_class("battery") + self.battery_box.append(self.battery_label) + self.refresh_battery() + GLib.timeout_add_seconds(20, self.refresh_battery) + light_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + bottom.set_center_widget(light_box) + self.light_label = Gtk.Label(label="☀ Luminosité indisponible") + self.light_label.set_xalign(0) + light_box.append(self.light_label) + self.slider = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 10, 100, 1) + self.slider.set_size_request(220, -1) + self.slider.set_draw_value(False) + self.slider.connect("value-changed", self.light_changed) + light_box.append(self.slider) + self.refresh_light() + GLib.timeout_add_seconds(20, self.refresh_light) + power_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + power_box.set_valign(Gtk.Align.CENTER) + bottom.set_end_widget(power_box) + brand = Gtk.Label(label="ArchASP · Sway") + brand.add_css_class("secondary") + power_box.append(brand) + for label, method in (("Redémarrer", "Reboot"), ("Éteindre", "PowerOff")): + button = Gtk.Button(label=label) + button.add_css_class("power") + button.set_sensitive(not self.preview and self.can_power(method)) + button.connect("clicked", self.confirm_power, method) + power_box.append(button) + self.window.present() + if not self.preview: + # CONTRACT: le compte déjà sélectionné entre directement dans le + # dialogue PAM ; seul le nom manuel exige une validation explicite. + self.user_changed() + self.selection_ready = True + GLib.idle_add(self.begin_selected_user) + + def refresh_clock(self): + now = datetime.now().astimezone() + self.time.set_text(now.strftime("%H:%M")) + self.date.set_text(now.strftime("%A %-d %B %Y").capitalize()) + return True + + def refresh_battery(self): + state = battery() + if state is None: + self.battery_box.set_visible(False) + self.battery_icon.set_text("") + self.battery_label.set_text("") + else: + self.battery_box.set_visible(True) + percent, status = state + icon = "" if status in ("Charging", "Not charging") else ("" if percent >= 80 else "" if percent >= 40 else "") + translated = {"Charging": "En charge", "Discharging": "Décharge", "Full": "Chargé", "Not charging": "Branché"}.get(status, status) + self.battery_icon.set_text(icon) + self.battery_label.set_text(f"{percent} % · {translated}") + return True + + def refresh_light(self): + self.light = backlight() + if self.light is None: + self.light_label.set_text("☀ Luminosité indisponible") + self.slider.set_sensitive(False) + else: + _, maximum, current = self.light + percent = max(10, min(100, round(100 * current / maximum))) + self.slider.handler_block_by_func(self.light_changed) + self.slider.set_value(percent) + self.slider.handler_unblock_by_func(self.light_changed) + self.slider.set_sensitive(not self.preview) + self.light_label.set_text(f"☀ Luminosité {percent} %" + (" · aperçu" if self.preview else "")) + return True + + def light_changed(self, *_): + percent = round(self.slider.get_value()) + self.light_label.set_text(f"☀ Luminosité {percent} %") + if not self.preview and self.light: + if getattr(self, "light_timer", None): + GLib.source_remove(self.light_timer) + self.light_timer = GLib.timeout_add(250, self.apply_light, percent) + + def apply_light(self, percent): + self.light_timer = None + if self.light: + try: + set_brightness(self.light[0], self.light[1], percent) + except (OSError, ValueError): + self.refresh_light() + return False + + def user_changed(self, *_): + other = self.selector.get_selected() == self.selector.get_model().get_n_items() - 1 + self.manual.set_visible(other) + if other: + self.manual.grab_focus() + self.refresh_avatar(self.username() if not self.preview else "") + if not self.preview and self.flow: + self.flow.reset() + self.entry.set_text("") + self.entry.set_visibility(False) + self.entry.set_sensitive(False) + self.button.set_label("Continuer" if other else "Connexion") + self.prompt.set_text("Saisissez le nom utilisateur" if other else "Chargement de l’authentification…") + self.error.set_text("") + if self.selection_ready and not other: + self.begin_selected_user() + + def begin_selected_user(self): + if not self.preview and not self.busy and self.username() and self.selector.get_selected() != self.selector.get_model().get_n_items() - 1: + self.current_user = self.username() + self.prompt.set_text("Chargement de l’authentification…") + self.perform(self.flow.begin, self.current_user) + return False + + def username(self): + if self.selector.get_selected() == self.selector.get_model().get_n_items() - 1: + return self.manual.get_text().strip() + return self.selector.get_selected_item().get_string() + + def refresh_avatar(self, username): + # INVARIANT: l'image de login provient uniquement d'AccountsService, + # avec ownership root et dimensions validées ; aucun accès au HOME. + icon = system_icon(username) + if icon: + try: + self.avatar_picture.set_paintable(Gdk.Texture.new_from_filename(str(icon))) + self.avatar_picture.set_visible(True) + self.avatar_fallback.set_visible(False) + return + except GLib.Error: + pass + self.avatar_picture.set_visible(False) + self.avatar_fallback.set_visible(True) + + def submit(self): + if self.preview or self.busy: + return + if self.flow.pending: + kind = self.flow.pending["auth_message_type"] + response = self.entry.get_text() if kind in ("secret", "visible") else None + self.entry.set_text("") + self.perform(self.flow.answer, response) + else: + username = self.username() + if not username: + self.error.set_text("Saisissez un nom utilisateur.") + return + self.current_user = username + self.perform(self.flow.begin, username) + + def perform(self, func, *args): + self.busy = True + self.button.set_sensitive(False) + self.entry.set_sensitive(False) + self.selector.set_sensitive(False) + def run(): + try: + result = func(*args) + GLib.idle_add(self.handle_reply, result) + except Exception: + # CONTRACT: aucune exception, requête ou réponse PAM n'est imprimée. + GLib.idle_add(self.handle_failure) + threading.Thread(target=run, daemon=True).start() + + def handle_reply(self, reply): + self.busy = False + self.selector.set_sensitive(True) + if reply["type"] == "auth_message": + kind = reply["auth_message_type"] + self.prompt.set_text(reply["auth_message"]) + self.error.set_text(reply["auth_message"] if kind == "error" else "") + self.entry.set_visible(kind in ("secret", "visible")) + self.entry.set_visibility(kind == "visible") + self.entry.set_sensitive(kind in ("secret", "visible")) + self.button.set_label("Connexion" if kind == "secret" else "Valider" if kind == "visible" else "Continuer") + self.entry.grab_focus() if kind in ("secret", "visible") else self.button.grab_focus() + elif reply["type"] == "success": + if self.start_called: + try: + save_last_user(self.current_user) + except OSError: + pass + self.quit() + return False + self.start_called = True + self.prompt.set_text("Ouverture de session…") + self.perform(self.flow.start) + return False + elif reply["type"] == "error": + self.start_called = False + self.error.set_text("Authentification refusée. Réessayez." if reply["error_type"] == "auth_error" else "Erreur de connexion. Réessayez.") + self.entry.set_text("") + self.entry.set_visible(True) + self.entry.set_sensitive(False) + self.button.set_label("Réessayer") + self.button.set_sensitive(True) + return False + + def handle_failure(self): + self.busy = False + self.selector.set_sensitive(True) + self.flow.reset() + self.start_called = False + self.entry.set_text("") + self.entry.set_sensitive(False) + self.error.set_text("Connexion à greetd impossible. Réessayez.") + self.button.set_label("Réessayer") + self.button.set_sensitive(True) + return False + + def can_power(self, method): + try: + bus = Gio.bus_get_sync(Gio.BusType.SYSTEM) + result = bus.call_sync("org.freedesktop.login1", "/org/freedesktop/login1", "org.freedesktop.login1.Manager", "Can" + method, None, GLib.VariantType.new("(s)"), Gio.DBusCallFlags.NONE, 2000) + return result.unpack()[0] == "yes" + except GLib.Error: + return False + + def confirm_power(self, _button, method): + dialog = Gtk.AlertDialog() + dialog.set_message("Redémarrer l’ordinateur ?" if method == "Reboot" else "Éteindre l’ordinateur ?") + dialog.set_buttons(["Annuler", "Confirmer"]) + dialog.set_default_button(0) + dialog.set_cancel_button(0) + dialog.choose(self.window, None, self.power_chosen, method, dialog) + + def power_chosen(self, dialog, result, method, _dialog): + try: + if dialog.choose_finish(result) != 1 or self.preview or not self.can_power(method): + return + bus = Gio.bus_get_sync(Gio.BusType.SYSTEM) + bus.call_sync("org.freedesktop.login1", "/org/freedesktop/login1", "org.freedesktop.login1.Manager", method, GLib.Variant("(b)", (False,)), None, Gio.DBusCallFlags.NONE, 2000) + except GLib.Error: + self.error.set_text("Action d’alimentation indisponible.") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--preview", action="store_true") + args = parser.parse_args() + # La session de preview peut porter une locale différente de la locale système. + try: + system_locale = next((line.split("=", 1)[1].strip().strip('"') for line in Path("/etc/locale.conf").read_text().splitlines() if line.startswith("LANG=")), "") + locale.setlocale(locale.LC_TIME, system_locale or "") + except (OSError, locale.Error): + locale.setlocale(locale.LC_TIME, "") + app = Greeter(preview=args.preview) + return app.run([]) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/greetd/src/system_state.py b/greetd/src/system_state.py new file mode 100644 index 000000000..88daf53c0 --- /dev/null +++ b/greetd/src/system_state.py @@ -0,0 +1,61 @@ +"""État local lu sans privilège et écriture bornée du rétroéclairage.""" +from pathlib import Path +import os +import tempfile + + +def battery(root=Path("/sys/class/power_supply")): + for path in sorted(root.glob("BAT*")): + try: + capacity = int((path / "capacity").read_text().strip()) + status = (path / "status").read_text().strip() + if 0 <= capacity <= 100: + return capacity, status + except (OSError, ValueError): + continue + return None + + +def backlight(root=Path("/sys/class/backlight")): + for path in sorted(root.iterdir() if root.exists() else []): + try: + maximum = int((path / "max_brightness").read_text().strip()) + current = int((path / "brightness").read_text().strip()) + if maximum > 0 and 0 <= current <= maximum: + return path, maximum, current + except (OSError, ValueError): + continue + return None + + +def set_brightness(path, maximum, percent): + if not 10 <= percent <= 100 or maximum <= 0: + raise ValueError("Luminosité invalide") + # INVARIANT: jamais zéro ; l'écriture n'est déclenchée qu'à la fin du déplacement du slider. + value = max(1, round(maximum * percent / 100)) + (path / "brightness").write_text(str(value)) + + +def read_last_user(path=Path("/var/lib/labfy-greeter/state/last-user")): + try: + value = path.read_text(encoding="utf-8").strip() + return value if value and len(value) <= 256 and "\n" not in value else None + except OSError: + return None + + +def save_last_user(username, directory=Path("/var/lib/labfy-greeter/state")): + if not username or len(username) > 256 or any(ord(c) < 32 for c in username): + raise ValueError("Nom utilisateur invalide") + # CONTRACT: seul le nom réussi est publié, par remplacement atomique en mode 0600. + fd, name = tempfile.mkstemp(prefix=".last-user-", dir=directory) + try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as stream: + stream.write(username + "\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, directory / "last-user") + finally: + if os.path.exists(name): + os.unlink(name) diff --git a/greetd/style/labfy-greeter.css b/greetd/style/labfy-greeter.css new file mode 100644 index 000000000..e437740c6 --- /dev/null +++ b/greetd/style/labfy-greeter.css @@ -0,0 +1,33 @@ +/* Couleurs issues de theme/catppuccin.json, flavor mocha ; CSS autonome au runtime. */ +* { font-family: "JetBrainsMono Nerd Font", monospace; color: #cdd6f4; } +.canvas { background: #1e1e2e url('/etc/greetd/background/sway.png') center center / cover no-repeat; } +.shade { background: alpha(#11111b, 0.55); } +.clock { font-size: 76px; font-weight: 700; } +.date { font-size: 20px; color: #cdd6f4; } +.battery { font-size: 12px; color: #a6adc8; } +.battery-icon { font-size: 15px; color: #cdd6f4; } +.login-card { background: alpha(#181825, 0.94); border: 1px solid #b4befe; border-radius: 8px; padding: 30px; box-shadow: 0 12px 35px alpha(#11111b, 0.4); } +.avatar { color: #b4befe; font-size: 44px; } +.avatar-picture { border-radius: 8px; border: 1px solid #585b70; } +.card-title { font-size: 23px; font-weight: 700; margin-bottom: 8px; } +.secondary { color: #a6adc8; font-size: 13px; } +.error { color: #f38ba8; font-size: 13px; } +entry, dropdown > button { background: #313244; color: #cdd6f4; border: 1px solid #585b70; border-radius: 8px; padding: 10px; min-height: 26px; } +entry:focus, dropdown > button:focus, button:focus-visible { outline: 2px solid #b4befe; outline-offset: 2px; border-color: #b4befe; } +/* Le popover du GtkDropDown a ses propres nœuds CSS : la palette du bouton ne s'y propage pas. */ +dropdown.user-selector popover > contents { background: #181825; border: 1px solid #585b70; border-radius: 8px; box-shadow: 0 8px 22px alpha(#11111b, 0.45); } +dropdown.user-selector popover listview { background: transparent; color: #cdd6f4; } +dropdown.user-selector popover listview row { background: transparent; color: #cdd6f4; border-radius: 6px; } +dropdown.user-selector popover listview row:hover { background: #45475a; } +dropdown.user-selector popover listview row:selected { background: #b4befe; color: #11111b; } +dropdown.user-selector popover listview row:selected label { color: #11111b; } +button { background: #45475a; border: 1px solid #585b70; border-radius: 8px; padding: 10px 15px; } +button:hover { background: #585b70; } +button:active { background: #313244; } +button.primary { background: #b4befe; color: #11111b; font-weight: 700; border-color: #b4befe; } +button.primary label { color: #11111b; } +button.primary:hover { background: #cdd6f4; } +button:disabled { opacity: 0.55; } +scale trough { background: #45475a; border-radius: 8px; min-height: 7px; } +scale highlight { background: #b4befe; border-radius: 8px; } +scale slider { background: #cdd6f4; border: 2px solid #b4befe; min-width: 17px; min-height: 17px; } diff --git a/greetd/tests/test_greeter.py b/greetd/tests/test_greeter.py new file mode 100644 index 000000000..af5021221 --- /dev/null +++ b/greetd/tests/test_greeter.py @@ -0,0 +1,190 @@ +import contextlib +import io +import json +import os +from pathlib import Path +import pwd +import socket +import struct +import sys +import tempfile +import threading +import unittest + +from PIL import Image + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) +from auth import AuthFlow +from greetd_ipc import Client, ProtocolError, SESSION_COMMAND, MAX_FRAME +from accounts import users +from system_state import battery, backlight, set_brightness +from avatar_backend import AvatarError, render_png, source_path +from avatar_icon import system_icon + +SECRET = "LABFY_SYNTHETIC_SECRET_59f3c4e2" + + +class FakeGreetd: + def __init__(self, replies): + self.temp = tempfile.TemporaryDirectory() + self.path = self.temp.name + "/socket" + self.server = socket.socket(socket.AF_UNIX) + self.server.bind(self.path) + self.server.listen(1) + self.replies = replies + self.requests = [] + self.thread = threading.Thread(target=self.run, daemon=True) + self.thread.start() + + def run(self): + connection, _ = self.server.accept() + with connection: + for reply in self.replies: + header = connection.recv(4) + if not header: + break + size = struct.unpack("=I", header)[0] + assert size <= MAX_FRAME + data = b"" + while len(data) < size: + data += connection.recv(size - len(data)) + request = json.loads(data) + self.requests.append(request) + if request["type"] == "start_session": + assert request["cmd"] == SESSION_COMMAND + assert request["env"] == [] + encoded = json.dumps(reply).encode() + connection.sendall(struct.pack("=I", len(encoded)) + encoded) + + def close(self): + self.server.close() + self.thread.join(2) + self.temp.cleanup() + + +def msg(kind, value="Message PAM"): + return {"type": "auth_message", "auth_message_type": kind, "auth_message": value} + + +class Tests(unittest.TestCase): + def test_full_pam_sequence_and_secret_absent_from_outputs(self): + fake = FakeGreetd([msg("secret"), msg("info"), msg("visible"), msg("error"), msg("secret"), + {"type": "success"}, {"type": "success"}]) + output = io.StringIO() + try: + with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output): + flow = AuthFlow(fake.path) + self.assertEqual(flow.begin("example")["auth_message_type"], "secret") + self.assertEqual(flow.answer(SECRET)["auth_message_type"], "info") + self.assertEqual(flow.answer()["auth_message_type"], "visible") + self.assertEqual(flow.answer("code")["auth_message_type"], "error") + self.assertEqual(flow.answer()["auth_message_type"], "secret") + self.assertEqual(flow.answer("second")["type"], "success") + self.assertEqual(flow.start()["type"], "success") + self.assertNotIn(SECRET, output.getvalue()) + self.assertEqual(fake.requests[1]["response"], SECRET) + self.assertNotIn("response", fake.requests[2]) + self.assertEqual(fake.requests[-1]["cmd"], SESSION_COMMAND) + finally: + fake.close() + + def test_auth_error_retry(self): + first = FakeGreetd([msg("secret"), {"type": "error", "error_type": "auth_error", "description": "bad"}, {"type": "success"}]) + second = FakeGreetd([msg("visible"), {"type": "success"}, {"type": "success"}]) + try: + flow = AuthFlow(first.path) + flow.begin("example") + self.assertEqual(flow.answer("wrong")["error_type"], "auth_error") + self.assertFalse(flow.authenticated) + flow.socket_path = second.path + self.assertEqual(flow.begin("manual-nss-user")["auth_message_type"], "visible") + self.assertEqual(flow.answer("code")["type"], "success") + self.assertEqual(flow.start()["type"], "success") + self.assertEqual(second.requests[0]["username"], "manual-nss-user") + finally: + first.close() + second.close() + + def test_accounts(self): + Row = pwd.struct_passwd + entries = [Row(("system", "x", 999, 1, "", "/", "/bin/bash")), + Row(("human", "x", 1000, 1, "", "/", "/bin/bash")), + Row(("blocked", "x", 1001, 1, "", "/", "/usr/bin/nologin")), + Row(("false", "x", 1002, 1, "", "/", "/bin/false")), + Row(("another", "x", 1003, 1, "Example", "/", "/bin/zsh"))] + self.assertEqual(users(entries, (1000, 60000)), ["another", "human"]) + + def test_battery_and_backlight(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + self.assertIsNone(battery(root)) + bat = root / "BAT1" + bat.mkdir() + (bat / "capacity").write_text("60") + for status in ("Charging", "Discharging", "Full", "Not charging"): + (bat / "status").write_text(status) + self.assertEqual(battery(root), (60, status)) + light = root / "backlight" / "amdgpu_bl1" + light.mkdir(parents=True) + (light / "max_brightness").write_text("400000") + (light / "brightness").write_text("200000") + self.assertEqual(backlight(root / "backlight"), (light, 400000, 200000)) + set_brightness(light, 400000, 10) + self.assertEqual((light / "brightness").read_text(), "40000") + set_brightness(light, 400000, 100) + self.assertEqual((light / "brightness").read_text(), "400000") + (light / "max_brightness").write_text("0") + self.assertIsNone(backlight(root / "backlight")) + (light / "max_brightness").unlink() + self.assertIsNone(backlight(root / "backlight")) + self.assertIsNone(backlight(root / "missing")) + with self.assertRaises(OSError): + set_brightness(root / "missing", 400000, 50) + + def test_reject_oversized_response(self): + temp = tempfile.TemporaryDirectory() + path = temp.name + "/socket" + server = socket.socket(socket.AF_UNIX) + server.bind(path) + server.listen(1) + def serve(): + conn, _ = server.accept() + with conn: + conn.recv(4096) + conn.sendall(struct.pack("=I", MAX_FRAME + 1)) + thread = threading.Thread(target=serve, daemon=True) + thread.start() + try: + with Client(path) as client: + with self.assertRaises(ProtocolError): + client.create("example") + finally: + server.close() + thread.join(2) + temp.cleanup() + + def test_avatar_conversion_and_system_boundary(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + original = root / "portrait.jpg" + Image.new("RGB", (400, 200), (10, 20, 30)).save(original) + self.assertEqual(source_path(original.as_uri()), original) + converted = root / "avatar.png" + render_png(original, converted) + with Image.open(converted) as image: + self.assertEqual(image.format, "PNG") + self.assertEqual(image.size, (256, 256)) + with self.assertRaises(AvatarError): + source_path("https://example.invalid/avatar.png") + too_large = root / "too-large.jpg" + too_large.write_bytes(b"x" * (12 * 1024 * 1024 + 1)) + with self.assertRaises(AvatarError): + source_path(str(too_large)) + self.assertIsNone(system_icon("../etc/passwd", root)) + self.assertIsNone(system_icon("example", root)) + (root / "example").symlink_to(converted) + self.assertIsNone(system_icon("example", root)) + + +if __name__ == "__main__": + unittest.main() diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/AppearancePage.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/AppearancePage.qml index 90110bf41..f2e0a0e40 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/AppearancePage.qml +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/AppearancePage.qml @@ -6,6 +6,7 @@ Item { id: page signal backRequested() signal wallpaperRequested() + signal avatarRequested() signal themeRequested() signal nightLightRequested() signal sunModeRequested() @@ -32,6 +33,18 @@ Item { } MouseArea { id: pointer; anchors.fill: parent; hoverEnabled: true; onClicked: page.wallpaperRequested() } } + Rectangle { + width: parent.width; height: 72; radius: 4 + color: avatarPointer.containsMouse ? Theme.buttonHover : Theme.buttonBackground + NerdIcon { anchors.left: parent.left; anchors.leftMargin: 12; anchors.verticalCenter: parent.verticalCenter; text: ""; color: Theme.accent; font.pixelSize: 22 } + Column { + anchors.left: parent.left; anchors.leftMargin: 47; anchors.verticalCenter: parent.verticalCenter + spacing: 4 + Text { text: "Avatar"; color: Theme.foreground; font.pixelSize: 14; font.bold: true } + Text { text: "Image de l’écran de connexion"; color: Theme.secondaryForeground; font.pixelSize: 11 } + } + MouseArea { id: avatarPointer; anchors.fill: parent; hoverEnabled: true; onClicked: page.avatarRequested() } + } Rectangle { width: parent.width; height: 72; radius: 4 color: themePointer.containsMouse ? Theme.buttonHover : Theme.buttonBackground diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/AvatarPage.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/AvatarPage.qml new file mode 100644 index 000000000..2f15a367e --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/AvatarPage.qml @@ -0,0 +1,141 @@ +import QtQuick +import QtQuick.Dialogs +import Quickshell.Io +import "../components" +import "../theme" + +Item { + id: page + required property bool activePage + signal backRequested() + property string currentPath: "" + property string selectedUrl: "" + property string feedback: "" + property bool failed: false + readonly property string backend: "/usr/local/libexec/labfy-greeter/avatar_backend.py" + + onActivePageChanged: if (activePage) { + feedback = ""; + currentProcess.command = ["python3", backend, "current"]; + currentProcess.running = true; + } + + Process { + id: currentProcess + stdout: StdioCollector { id: currentOutput; waitForEnd: true } + onExited: (code, status) => { + if (!page.activePage) return; + if (code !== 0 || status !== 0) { + page.failed = true; + page.feedback = "Service avatar indisponible."; + page.currentPath = ""; + return; + } + try { + const result = JSON.parse(currentOutput.text); + page.currentPath = result.path || ""; + } catch (_) { + page.failed = true; + page.feedback = "Service avatar indisponible."; + page.currentPath = ""; + } + } + } + Process { + id: setProcess + stdout: StdioCollector { id: setOutput; waitForEnd: true } + onExited: (code, status) => { + try { + const result = JSON.parse(setOutput.text); + if (code !== 0 || status !== 0 || !result.path) { + page.failed = true; + page.feedback = result.error || "Impossible de modifier l’avatar."; + return; + } + page.currentPath = result.path; + page.selectedUrl = ""; + page.failed = false; + page.feedback = "Avatar enregistré pour le prochain écran de connexion."; + } catch (_) { + page.failed = true; + page.feedback = "Impossible de modifier l’avatar."; + } + } + } + FileDialog { + id: picker + title: "Choisir un avatar" + fileMode: FileDialog.OpenFile + nameFilters: ["Images (*.png *.jpg *.jpeg *.webp)"] + onAccepted: { + page.selectedUrl = selectedFile.toString(); + page.feedback = ""; + } + } + + Column { + anchors.fill: parent + spacing: 13 + Row { + spacing: 8; height: 30 + ActionButton { label: ""; onClicked: page.backRequested() } + Text { text: "Avatar"; color: Theme.foreground; font.pixelSize: 16; font.bold: true; height: 30; verticalAlignment: Text.AlignVCenter } + } + Text { + width: parent.width + text: "Image affichée sur l’écran de connexion" + color: Theme.secondaryForeground + font.pixelSize: 11 + wrapMode: Text.Wrap + } + Rectangle { + width: 112; height: 112; radius: 8 + anchors.horizontalCenter: parent.horizontalCenter + color: Theme.buttonBackground + border.color: Theme.border + clip: true + Image { + id: avatarImage + anchors.fill: parent; anchors.margins: 2 + source: page.selectedUrl || (page.currentPath ? "file://" + page.currentPath : "") + fillMode: Image.PreserveAspectCrop + sourceSize.width: 256; sourceSize.height: 256 + asynchronous: true; cache: false + visible: source !== "" && status === Image.Ready + } + NerdIcon { + anchors.centerIn: parent + text: ""; color: Theme.accent; font.pixelSize: 44 + visible: avatarImage.status !== Image.Ready + } + } + Row { + spacing: 8 + ActionButton { label: "Choisir une image"; enabled: !setProcess.running; onClicked: picker.open() } + ActionButton { + label: setProcess.running ? "Enregistrement…" : "Appliquer" + enabled: !!page.selectedUrl && !setProcess.running + onClicked: { + page.feedback = ""; + setProcess.command = ["python3", page.backend, "set", page.selectedUrl]; + setProcess.running = true; + } + } + } + Text { + width: parent.width + text: page.feedback + visible: !!page.feedback + color: page.failed ? Theme.warningForeground : Theme.successForeground + font.pixelSize: 11 + wrapMode: Text.Wrap + } + Text { + width: parent.width + text: "PNG, JPEG ou WebP · 12 Mio maximum. L’image est ajustée au format carré." + color: Theme.secondaryForeground + font.pixelSize: 10 + wrapMode: Text.Wrap + } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml index 62c9c776d..444b18693 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml @@ -17,7 +17,7 @@ PopupWindow { readonly property var pages: ({ main: 0, wifi: 1, bluetooth: 2, session: 3, confirmation: 4, battery: 5, appearance: 6, wallpaper: 7, theme: 8, nightLight: 9, sunMode: 10, locationTime: 11, sessionManager: 12, - checkpointFailure: 13 }) + checkpointFailure: 13, avatar: 14 }) property int currentPage: pages.main function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; } signal thresholdApplied() @@ -115,7 +115,7 @@ PopupWindow { : currentPage === 4 ? confirmationPage.implicitHeight + 32 : currentPage === 5 ? batteryPage.implicitHeight + 32 : currentPage === pages.wallpaper ? 550 - : currentPage === pages.appearance ? 520 + : currentPage === pages.appearance ? 610 : currentPage === pages.locationTime || currentPage === pages.sessionManager ? 650 : 440 visible: false // CONTRACT: PopupWindow n'applique un changement de grabFocus qu'après @@ -250,6 +250,7 @@ PopupWindow { onNightLightRequested: popup.openPage("nightLight") onSunModeRequested: popup.openPage("sunMode") onLocationTimeRequested: popup.openPage("locationTime") + onAvatarRequested: popup.openPage("avatar") } WallpaperPage { @@ -315,6 +316,11 @@ PopupWindow { } } } + // Append-only: historical StackLayout indices remain unchanged. + AvatarPage { + activePage: popup.visible && popup.currentPage === popup.pages.avatar + onBackRequested: popup.openPage("appearance") + } } } }