feat(greetd): add Labfy GTK greeter

This commit is contained in:
fy59 2026-10-07 09:42:55 +02:00
parent fbb5740f9e
commit 1cfaade6ac
23 changed files with 1464 additions and 2 deletions

2
greetd/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
__pycache__/
*.pyc

111
greetd/README.md Normal file
View file

@ -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/<username>` : 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`.

63
greetd/VALIDATION.md Normal file
View file

@ -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.

View file

@ -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"

View file

@ -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

22
greetd/scripts/audit-greeter.sh Executable file
View file

@ -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

59
greetd/scripts/install.sh Executable file
View file

@ -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é.'

11
greetd/scripts/rollback.sh Executable file
View file

@ -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.'

4
greetd/scripts/run-ui Executable file
View file

@ -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

View file

@ -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"

View file

@ -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

20
greetd/src/accounts.py Normal file
View file

@ -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)

60
greetd/src/auth.py Normal file
View file

@ -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

View file

@ -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:]))

26
greetd/src/avatar_icon.py Normal file
View file

@ -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

84
greetd/src/greetd_ipc.py Normal file
View file

@ -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": []})

391
greetd/src/labfy_greeter.py Normal file
View file

@ -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())

View file

@ -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)

View file

@ -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; }

View file

@ -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()

View file

@ -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

View file

@ -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
}
}
}

View file

@ -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")
}
}
}
}