feat(greetd): add Labfy GTK greeter
This commit is contained in:
parent
fbb5740f9e
commit
1cfaade6ac
23 changed files with 1464 additions and 2 deletions
2
greetd/.gitignore
vendored
Normal file
2
greetd/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
111
greetd/README.md
Normal file
111
greetd/README.md
Normal 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
63
greetd/VALIDATION.md
Normal 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.
|
||||
7
greetd/config/config.toml
Normal file
7
greetd/config/config.toml
Normal 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"
|
||||
18
greetd/config/labfy-sway.conf
Normal file
18
greetd/config/labfy-sway.conf
Normal 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
22
greetd/scripts/audit-greeter.sh
Executable 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
59
greetd/scripts/install.sh
Executable 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
11
greetd/scripts/rollback.sh
Executable 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
4
greetd/scripts/run-ui
Executable 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
|
||||
32
greetd/scripts/smoke-headless.sh
Executable file
32
greetd/scripts/smoke-headless.sh
Executable 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"
|
||||
7
greetd/scripts/start-compositor
Executable file
7
greetd/scripts/start-compositor
Executable 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
20
greetd/src/accounts.py
Normal 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
60
greetd/src/auth.py
Normal 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
|
||||
101
greetd/src/avatar_backend.py
Normal file
101
greetd/src/avatar_backend.py
Normal 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
26
greetd/src/avatar_icon.py
Normal 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
84
greetd/src/greetd_ipc.py
Normal 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
391
greetd/src/labfy_greeter.py
Normal 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())
|
||||
61
greetd/src/system_state.py
Normal file
61
greetd/src/system_state.py
Normal 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)
|
||||
33
greetd/style/labfy-greeter.css
Normal file
33
greetd/style/labfy-greeter.css
Normal 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; }
|
||||
190
greetd/tests/test_greeter.py
Normal file
190
greetd/tests/test_greeter.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue