secure push

This commit is contained in:
fy59 2026-11-09 19:21:49 +01:00
parent bc98626c80
commit 121bc2a2ef
29 changed files with 1934 additions and 79 deletions

1
bin/.local/bin/hyprwhspr Symbolic link
View file

@ -0,0 +1 @@
/home/fy59/.local/share/hyprwhspr-swayfx/1.52.1-sway-v1/launcher

1
bin/.local/bin/labfy-rgb Symbolic link
View file

@ -0,0 +1 @@
/home/fy59/labfy-rgb/build/labfy-rgb

View file

@ -2,7 +2,7 @@
"""Classement manuel de A_trier, compatible avec la photothèque Labfy.
Photos et vidéos -> Photos/AAAA/AAAA-MM-JJ/
RAW et XMP liés -> Originaux/AAAA/AAAA-MM-JJ/
RAW, TIFF et XMP liés -> Originaux/AAAA/AAAA-MM-JJ/
Aucun fichier existant n'est remplacé. Les autres fichiers restent dans A_trier.
"""
@ -22,10 +22,12 @@ import unicodedata
BASE = Path.home() / "Pictures" / "Phototheque"
RAW = {".arw", ".dng", ".cr2", ".cr3", ".nef", ".nrw", ".raf",
".orf", ".rw2", ".pef", ".srw", ".3fr", ".iiq"}
PHOTO = {".jpg", ".jpeg", ".jpe", ".png", ".tif", ".tiff",
TIFF = {".tif", ".tiff"}
ORIGINAL = RAW | TIFF
PHOTO = {".jpg", ".jpeg", ".jpe", ".png",
".heic", ".heif", ".avif", ".webp", ".bmp"}
VIDEO = {".mov", ".mp4", ".m4v", ".avi", ".mts", ".m2ts", ".3gp"}
MEDIA = RAW | PHOTO | VIDEO
MEDIA = ORIGINAL | PHOTO | VIDEO
DATE_NAME = re.compile(r"(?<!\d)((?:19|20)\d{2})[-_]?(\d{2})[-_]?(\d{2})(?!\d)")
EXIFTOOL = shutil.which("exiftool")
@ -89,17 +91,17 @@ def renamed(path, index):
def target_root(path, group, photos, originaux):
if path.suffix.lower() in RAW:
if path.suffix.lower() in ORIGINAL:
return originaux
if path.suffix.lower() != ".xmp":
return photos
associated = Path(path.name[:-4]).suffix.lower()
if associated in RAW:
if associated in ORIGINAL:
return originaux
if associated in PHOTO | VIDEO:
return photos
# XMP générique : privilégier les RAW s'ils sont présents.
return originaux if any(p.suffix.lower() in RAW for p in group) else photos
# XMP générique : privilégier les originaux (RAW ou TIFF) présents.
return originaux if any(p.suffix.lower() in ORIGINAL for p in group) else photos
def key(name):
@ -178,7 +180,7 @@ def run(args):
if not media:
orphans += len(group)
continue # Ne pas déplacer les XMP orphelins.
media.sort(key=lambda p: (p.suffix.lower() not in RAW,
media.sort(key=lambda p: (p.suffix.lower() not in ORIGINAL,
p.suffix.lower() in VIDEO, p.name))
try:
taken, origin = date_of(media[0])

View file

@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""Classement manuel de A_trier, compatible avec la photothèque Labfy.
Photos et vidéos -> Photos/AAAA/AAAA-MM-JJ/
RAW et XMP liés -> Originaux/AAAA/AAAA-MM-JJ/
Aucun fichier existant n'est remplacé. Les autres fichiers restent dans A_trier.
"""
import argparse
from collections import defaultdict
from datetime import date, datetime
import errno
import hashlib
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import unicodedata
BASE = Path.home() / "Pictures" / "Phototheque"
RAW = {".arw", ".dng", ".cr2", ".cr3", ".nef", ".nrw", ".raf",
".orf", ".rw2", ".pef", ".srw", ".3fr", ".iiq"}
PHOTO = {".jpg", ".jpeg", ".jpe", ".png", ".tif", ".tiff",
".heic", ".heif", ".avif", ".webp", ".bmp"}
VIDEO = {".mov", ".mp4", ".m4v", ".avi", ".mts", ".m2ts", ".3gp"}
MEDIA = RAW | PHOTO | VIDEO
DATE_NAME = re.compile(r"(?<!\d)((?:19|20)\d{2})[-_]?(\d{2})[-_]?(\d{2})(?!\d)")
EXIFTOOL = shutil.which("exiftool")
def valid_date(y, m, d):
try:
candidate = date(int(y), int(m), int(d))
return candidate if 1900 <= candidate.year <= 2100 else None
except ValueError:
return None
def date_of(path):
"""EXIF en priorité, ensuite le nom, puis date de modification."""
if EXIFTOOL:
try:
result = subprocess.run(
[EXIFTOOL, "-s3", "-d", "%Y-%m-%d",
"-DateTimeOriginal", "-CreateDate",
"-MediaCreateDate", "-TrackCreateDate", "--", str(path)],
capture_output=True, text=True, timeout=20, check=False,
)
for line in result.stdout.splitlines():
match = re.fullmatch(r"\s*(\d{4})-(\d{2})-(\d{2})\s*", line)
if match:
found = valid_date(*match.groups())
if found:
return found, "EXIF"
except (OSError, subprocess.TimeoutExpired):
pass
match = DATE_NAME.search(path.name)
if match:
found = valid_date(*match.groups())
if found:
return found, "nom du fichier"
return datetime.fromtimestamp(path.stat().st_mtime).date(), "modification"
def stem_for_group(path):
name = path.name
if path.suffix.lower() == ".xmp":
name = name[:-4]
pre = Path(name)
if pre.suffix.lower() in MEDIA:
name = pre.stem
else:
name = path.stem
return name.casefold()
def renamed(path, index):
if index == 0:
return path.name
if path.suffix.lower() == ".xmp":
without_xmp = path.name[:-4]
suffix = Path(without_xmp).suffix
if suffix.lower() in MEDIA:
return without_xmp[:-len(suffix)] + f"_{index}" + suffix + path.name[-4:]
return without_xmp + f"_{index}" + path.name[-4:]
return path.stem + f"_{index}" + path.suffix
def target_root(path, group, photos, originaux):
if path.suffix.lower() in RAW:
return originaux
if path.suffix.lower() != ".xmp":
return photos
associated = Path(path.name[:-4]).suffix.lower()
if associated in RAW:
return originaux
if associated in PHOTO | VIDEO:
return photos
# XMP générique : privilégier les RAW s'ils sont présents.
return originaux if any(p.suffix.lower() in RAW for p in group) else photos
def key(name):
return unicodedata.normalize("NFC", name).casefold()
def existing_names(folder, cache):
if folder not in cache:
if folder.is_symlink():
raise ValueError(f"Dossier de destination symbolique : {folder}")
cache[folder] = {key(p.name) for p in folder.iterdir()} if folder.exists() else set()
return cache[folder]
def digest(path):
hash_ = hashlib.sha256()
with path.open("rb") as file_:
for chunk in iter(lambda: file_.read(1024 * 1024), b""):
hash_.update(chunk)
return hash_.digest()
def move_without_overwrite(source, target):
"""Hardlink + unlink sur même volume, copie vérifiée sinon."""
try:
os.link(source, target, follow_symlinks=False)
except OSError as exc:
if exc.errno != errno.EXDEV:
raise
try:
with source.open("rb") as incoming, target.open("xb") as outgoing:
shutil.copyfileobj(incoming, outgoing, 1024 * 1024)
outgoing.flush()
os.fsync(outgoing.fileno())
shutil.copystat(source, target)
if digest(source) != digest(target):
raise OSError("Contrôle SHA-256 de la copie échoué")
except BaseException:
target.unlink(missing_ok=True)
raise
source.unlink()
def run(args):
source = args.source.expanduser().absolute()
photos = args.photos.expanduser().absolute()
originaux = args.originaux.expanduser().absolute()
if not source.is_dir() or source.is_symlink():
raise ValueError(f"Source absente ou symbolique : {source}")
for dest in (photos, originaux):
if dest == source or source in dest.parents:
raise ValueError("La destination ne doit pas être dans A_trier.")
if dest.is_symlink():
raise ValueError(f"Destination symbolique refusée : {dest}")
groups = defaultdict(list)
ignored = 0
for current, dirs, names in os.walk(source, followlinks=False):
dirs[:] = [d for d in dirs if not (Path(current) / d).is_symlink()]
for name in names:
file_ = Path(current) / name
if file_.is_symlink() or not file_.is_file():
ignored += 1
continue
if file_.suffix.lower() not in MEDIA | {".xmp"}:
ignored += 1
continue
groups[(file_.parent, stem_for_group(file_))].append(file_)
occupied = {}
moved = 0
errors = 0
orphans = 0
for _, group in sorted(groups.items(), key=lambda x: str(x[0])):
media = [p for p in group if p.suffix.lower() in MEDIA]
if not media:
orphans += len(group)
continue # Ne pas déplacer les XMP orphelins.
media.sort(key=lambda p: (p.suffix.lower() not in RAW,
p.suffix.lower() in VIDEO, p.name))
try:
taken, origin = date_of(media[0])
relative = Path(str(taken.year)) / taken.isoformat()
destinations = [
target_root(p, group, photos, originaux) / relative
for p in group
]
index = 0
while True:
candidates = [folder / renamed(p, index)
for p, folder in zip(group, destinations)]
identities = {(p.parent, key(p.name)) for p in candidates}
if len(identities) != len(candidates):
raise ValueError("Noms incompatibles (casse/Unicode) dans le lot")
if all(key(p.name) not in existing_names(p.parent, occupied)
for p in candidates):
break
index += 1
for file_, target in zip(group, candidates):
if args.dry_run:
print(f"[SIMULATION] {file_.relative_to(source)} -> "
f"{target.relative_to(BASE) if target.is_relative_to(BASE) else target}"
f" ({origin})")
else:
if target.parent.is_symlink():
raise ValueError(f"Dossier symbolique : {target.parent}")
target.parent.mkdir(parents=True, exist_ok=True)
move_without_overwrite(file_, target)
print(f"[OK] {file_.relative_to(source)} -> {target}")
existing_names(target.parent, occupied).add(key(target.name))
moved += 1
except (OSError, ValueError) as exc:
errors += 1
print(f"[ERREUR] {', '.join(p.name for p in group)} : {exc}",
file=sys.stderr)
label = "prévus" if args.dry_run else "déplacés"
print(f"\nFichiers {label} : {moved} ; ignorés : {ignored} ; "
f"XMP orphelins : {orphans} ; erreurs : {errors}")
return 1 if errors else 0
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("-n", "--dry-run", action="store_true",
help="simuler sans déplacer les fichiers")
parser.add_argument("--source", type=Path, default=BASE / "A_trier")
parser.add_argument("--photos", type=Path, default=BASE / "Photos")
parser.add_argument("--originaux", type=Path, default=BASE / "Originaux")
args = parser.parse_args()
try:
return run(args)
except (OSError, ValueError) as exc:
print(f"Erreur : {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,148 @@
# Labfy Update Center V1
Le clic sur l’indicateur de mises à jour de la barre ouvre le centre intégré à
QuickShell. L’indicateur reste accessible à zéro pour donner accès à la maintenance
et aux annonces. Le thème utilise les rôles Catppuccin existants, Lavender pour les
titres et des rayons de 4 px.
## Architecture initiale et évolution
`status/UpdatesIndicator.qml` lisait uniquement les compteurs Arch/AUR d’un JSON
atomique dans `$XDG_RUNTIME_DIR/labfy-quickshell-updates.json`, sans action au clic.
Le service utilisateur `labfy-quickshell-updates.service` exécutait
`bin/check-updates.py` ; son timer existant vérifiait toutes les dix minutes.
Les commandes étaient `checkupdates` et `yay -Qua`.
Ce service et ce timer restent les seuls propriétaires de la vérification
périodique. Le collecteur conserve maintenant les paquets, versions installées et
candidates, origine Arch/AUR, repères informatifs et état de chaque tentative.
La vérification manuelle demande le démarrage du même service. `checkupdates`
utilise sa base temporaire ; aucune synchronisation isolée de la base système,
installation ou élévation n’est effectuée pendant la collecte.
`status/UpdateCenter.qml` est un PopupWindow ancré à la barre.
`RightStatusArea.qml` et `Bar.qml` assurent l’exclusion avec les autres panneaux et
la fermeture lors d’une authentification. Le grab existant ferme le centre sur
clic extérieur ; Échap et le bouton × permettent également de fermer.
Le ciblage IPC par sortie est `updatesUi-<sortie>` : `open`, `close`, `isOpen`.
Aucune action d’installation n’est exposée par IPC.
## Données, erreurs et performances
Les listes officielles et AUR restent séparées. La recherche et les filtres sont
locaux. La liste virtualisée accepte les listes longues. Les repères noyau,
système, graphique et session ne prédisent jamais une panne ; les noyaux
personnalisés publiant un `pkgbase` dans `/usr/lib/modules` sont aussi reconnus.
Le fichier est publié par remplacement atomique. Un `flock` partagé protège les
vérifications et l’installation Labfy, y compris depuis plusieurs sorties. Ce
verrou ne prétend pas contrôler les outils externes : pacman garde son propre
verrou système. Les admissions QML sont synchrones pour rejeter un double clic.
Chaque commande de vérification dispose de 120 s, dans le service existant limité
à 270 s. Un état de progression abandonné devient réessayable après ce délai.
En cas d’échec d’une source, ses anciens résultats sont conservés avec l’erreur ;
ils ne deviennent pas un faux zéro. Une connexion HTTPS est sondée seulement après
une erreur du collecteur pour distinguer une connexion indisponible.
Le renderer observe le cache. Son ancien timer de récupération est réutilisé
uniquement avant le premier chargement ou pendant l’ouverture du popup ; aucun
nouveau timer ni démon n’est ajouté. Les processus de maintenance et d’actualités
sont ponctuels et asynchrones, lancés à l’ouverture.
## Installation interactive et aperçu
« Mettre à jour le système » demande une confirmation dans QuickShell, puis ouvre
Kitty avec `bin/update-terminal.py`. L’app_id dédié `labfy-update-center` cible
une règle dans `sway/.config/sway/rules` : fenêtre flottante, centrée, 65 % de la
largeur et 60 % de la hauteur de la sortie active. Installation et aperçu partagent
ce placement ; les autres fenêtres Kitty conservent leur comportement. Le terminal présente l’opération et exige la
saisie exacte de `METTRE A JOUR`. L’opération complète est `yay -Syu --confirm`,
avec les options de yay 13 suivantes :
- `--sudoloop=false` : aucune boucle de maintien de sudo ;
- `--cleanafter=false` : pas de nettoyage automatique des sources par Labfy ;
- `--useask=false` : pas d’acceptation automatique des conflits ;
- `--askremovemake` : question avant suppression des dépendances de compilation ;
- `--noanswerclean --noanswerdiff --noansweredit --noanswerupgrade` : pas de réponses
préenregistrées aux menus.
Les options sont limitées à cette invocation et ne sont pas sauvegardées. Le
terminal affiche le code de sortie, demande au service existant de revérifier,
puis attend Entrée. Après une erreur, il rappelle de terminer la mise à jour
complète avant toute autre installation. Les décisions interactives de yay et la
lecture des PKGBUILD restent à la charge de l’utilisateur.
« Aperçu Kitty » utilise `--dry-run` : aucune commande de paquets ni actualisation
n’est exécutée. Les tests de la branche affirmative interceptent tous les appels
au gestionnaire de paquets. Aucune installation réelle ne fait partie des tests.
## Maintenance en lecture seule
L’onglet affiche les 35 dernières lignes de transactions ALPM présentes dans une
queue de lecture de 256 Kio de `/var/log/pacman.log`, les fichiers `.pacnew` et
`.pacsave` accessibles sous `/etc`, et les services système/utilisateur en échec.
La recherche ne suit pas les symlinks de répertoire et est limitée à 4 s / 50 000
entrées de répertoire ; toute limite ou permission insuffisante est signalée.
Ce périmètre ne garantit pas la découverte de fichiers situés hors `/etc`.
Aucun fichier de configuration n’est fusionné, supprimé ou modifié.
`/run/reboot-required`, s’il existe, est présenté comme une demande détectée.
L’absence des modules du noyau actif est une recommandation, pas une preuve
universelle d’obligation de redémarrer. L’absence de signal ne garantit pas qu’un
redémarrage est inutile. Les journaux du système et du collecteur sont accessibles
dans Kitty. Aucun nettoyage de cache n’est exécuté.
## Annonces officielles
Source : <https://archlinux.org/feeds/news/>. Cache utilisateur
`$XDG_CACHE_HOME/labfy-update-news.json` (sinon `~/.cache`), renouvelé après 6 h
lors d’une ouverture. Après échec, un délai de 10 min évite les tentatives
répétées ; les annonces en cache restent visibles. La lecture réseau est limitée
à 1 Mio et 12 s. Les titres sont rendus comme texte brut. Seuls les liens HTTPS
vers `archlinux.org` sont ouvrables.
Le badge d’intervention manuelle est posé uniquement lorsque le titre officiel
l’annonce explicitement en anglais. L’absence de badge ne dispense pas de lire
l’annonce ; le contenu n’est pas analysé pour deviner une applicabilité. Aucune
correspondance avec les paquets installés et aucune exécution de texte distant.
## Validation non destructive
- `python3 -m unittest discover -s tests -p 'test_quickshell*.py' -v` : 16 tests,
dont 13 du collecteur/terminal, un scénario QML et deux de régression Hyprwhspr.
- Le scénario QML charge les composants de production avec commandes interceptées
dans une copie temporaire : zéro, 600 paquets Arch/AUR, recherche/filtres,
navigation, données de maintenance/annonces, actualisation et double appel,
récupération d’une vérification expirée, fermeture/réouverture et argv d’aperçu.
Il nécessite QuickShell et une session Wayland ; son popup synthétique désactive
le grab faute de série d’entrée utilisateur. Le popup de la vraie barre garde
son grab.
- Tests JavaScript existants : StatusColorRoles, PercentMath,
PercentPresentation, ScrollSteps, OSD observation.
- `qmllint` sur les cinq QML modifiés/ajoutés et `git diff --check`.
- Lecture réelle du service, des transactions, fichiers `/etc`, services en échec
et flux officiel ; ouverture du vrai Kitty avec `--dry-run` seulement.
Dépendances : Python standard, QuickShell/QtQuick.Controls, systemd utilisateur,
pacman-contrib (`checkupdates`), yay et Kitty, tous déjà installés. Aucune nouvelle
dépendance. Les réglages pacman et systemd sont préservés. La seule règle SwayFX ajoutée cible
l’app_id du terminal de mise à jour, à la demande de l’utilisateur.
## Résultat de validation en session
Validation finale : 16 tests Python passants en 18,921 s ; les cinq suites
JavaScript citées passent. `qmllint`, compilation Python et `git diff --check`
sont passants. `sway -C` retourne 0 (avertissements EGL de détection GPU), puis
le rechargement des règles Sway retourne `success: true`, sans redémarrage de
session. Un vrai Kitty lancé avec `--dry-run` et l’app_id de production est
confirmé dans `floating_nodes` de l’arbre Sway : rectangle 1482 × 830, position
401,319 sur la sortie actuelle. Seule cette fenêtre d’aperçu est ensuite fermée.
Lecture locale : 10 fichiers `.pacnew`/`.pacsave`, 35 lignes ALPM, aucun service
en échec signalé ; des permissions insuffisantes sous `/etc` sont remontées dans
l’interface. La recherche reste donc explicitement partielle. Le flux officiel
est téléchargé et ses annonces sont mises en cache. QuickShell reste actif avec
le PID 378053 et `NRestarts=0` ; aucun redémarrage du service n’a été demandé.
L’installation réelle, la résolution de conflits AUR et un redémarrage système
ne sont volontairement pas exécutés. Le contrôle visuel et ergonomique final
reste à effectuer par l’utilisateur dans les trois onglets.

View file

@ -59,7 +59,7 @@ PanelWindow {
closeApplications(); closeOverview(); closeClipboard();
controlCenter.visible = false; dateCenter.requestClose();
windowStrip.closeMenu(); rightStatusArea.closeRemovableMedia();
rightStatusArea.closePrinting(); rightStatusArea.closePhotoBackup(); closeDrawer();
rightStatusArea.closePrinting(); rightStatusArea.closePhotoBackup(); rightStatusArea.closeUpdates(); closeDrawer();
monitorService.open(screen.name);
}
Connections {
@ -192,6 +192,7 @@ PanelWindow {
rightStatusArea.closeRemovableMedia();
rightStatusArea.closePrinting();
rightStatusArea.closePhotoBackup();
rightStatusArea.closeUpdates();
bar.closeDrawer();
}
function onApplicationsOpening(output) {
@ -204,6 +205,7 @@ PanelWindow {
rightStatusArea.closeRemovableMedia();
rightStatusArea.closePrinting();
rightStatusArea.closePhotoBackup();
rightStatusArea.closeUpdates();
bar.closeDrawer();
}
}
@ -256,6 +258,7 @@ PanelWindow {
rightStatusArea.closeRemovableMedia();
rightStatusArea.closePrinting();
rightStatusArea.closePhotoBackup();
rightStatusArea.closeUpdates();
bar.closeDrawer();
clipboardLoader.active = true;
}
@ -282,6 +285,7 @@ PanelWindow {
rightStatusArea.closeRemovableMedia();
rightStatusArea.closePrinting();
rightStatusArea.closePhotoBackup();
rightStatusArea.closeUpdates();
bar.closeDrawer();
overviewRequested = true;
overviewLoader.active = true;
@ -348,6 +352,7 @@ PanelWindow {
windowStrip.closeMenu();
rightStatusArea.closePrinting();
rightStatusArea.closePhotoBackup();
rightStatusArea.closeUpdates();
controlCenter.openPage(page);
controlCenter.visible = true;
return true;
@ -366,6 +371,7 @@ PanelWindow {
dateCenter.requestClose();
rightStatusArea.closePrinting();
rightStatusArea.closePhotoBackup();
rightStatusArea.closeUpdates();
return windowStrip.openActiveMenu();
}
}
@ -406,6 +412,27 @@ PanelWindow {
}
}
// CONTRACT: the update center follows the existing per-output popup exclusion.
function toggleUpdates() {
if (authenticationActive) return false;
const opening = !rightStatusArea.updatesOpen;
closeMonitor(); closeApplications(); closeOverview(); closeClipboard();
controlCenter.visible = false; dateCenter.requestClose(); windowStrip.closeMenu();
rightStatusArea.closeRemovableMedia(); rightStatusArea.closePrinting();
rightStatusArea.closePhotoBackup(); closeDrawer();
rightStatusArea.setUpdatesOpen(opening);
return true;
}
IpcHandler {
target: "updatesUi-" + bar.screen.name
function open(): bool {
if (bar.authenticationActive) return false;
return rightStatusArea.updatesOpen || bar.toggleUpdates();
}
function close(): bool { rightStatusArea.closeUpdates(); return true; }
function isOpen(): bool { return rightStatusArea.updatesOpen; }
}
// Le bouton de sauvegarde utilise la même exclusion que les autres popups.
function togglePhotoBackup() {
closeMonitor();
@ -420,6 +447,7 @@ PanelWindow {
rightStatusArea.closeRemovableMedia();
rightStatusArea.closePrinting();
bar.closeDrawer();
rightStatusArea.closeUpdates();
rightStatusArea.setPhotoBackupOpen(opening);
return true;
}
@ -447,6 +475,7 @@ PanelWindow {
rightStatusArea.closeRemovableMedia();
rightStatusArea.closePrinting();
rightStatusArea.closePhotoBackup();
rightStatusArea.closeUpdates();
bar.closeDrawer();
if (opening) dateCenter.requestOpen();
else dateCenter.requestClose();
@ -465,6 +494,7 @@ PanelWindow {
rightStatusArea.closeRemovableMedia();
rightStatusArea.closePrinting();
rightStatusArea.closePhotoBackup();
rightStatusArea.closeUpdates();
}
Item {
@ -578,6 +608,7 @@ PanelWindow {
rightStatusArea.closeRemovableMedia();
rightStatusArea.closePrinting();
rightStatusArea.closePhotoBackup();
rightStatusArea.closeUpdates();
bar.closeDrawer();
}
}
@ -637,6 +668,7 @@ PanelWindow {
rightStatusArea.closeRemovableMedia();
rightStatusArea.closePrinting();
rightStatusArea.closePhotoBackup();
rightStatusArea.closeUpdates();
bar.closeDrawer();
controlCenter.currentPage = page;
controlCenter.visible = true;
@ -652,6 +684,7 @@ PanelWindow {
rightStatusArea.closeRemovableMedia();
rightStatusArea.closePrinting();
rightStatusArea.closePhotoBackup();
rightStatusArea.closeUpdates();
bar.closeDrawer();
controlCenter.currentPage = 0;
controlCenter.visible = opening;
@ -667,9 +700,11 @@ PanelWindow {
windowStrip.closeMenu();
rightStatusArea.closePrinting();
rightStatusArea.closePhotoBackup();
rightStatusArea.closeUpdates();
rightStatusArea.setRemovableMediaOpen(opening);
bar.closeDrawer();
}
onToggleUpdatesRequested: bar.toggleUpdates()
onTogglePrintingRequested: {
bar.closeMonitor();
if (bar.authenticationActive) return;
@ -682,6 +717,7 @@ PanelWindow {
windowStrip.closeMenu();
rightStatusArea.closeRemovableMedia();
rightStatusArea.closePhotoBackup();
rightStatusArea.closeUpdates();
bar.closeDrawer();
rightStatusArea.setPrintingOpen(opening);
}

View file

@ -396,3 +396,17 @@ Le dock à masquage automatique et le menu utilisent `applications/FavoritesServ
et `ApplicationIcon.qml`. Le stockage existant reste inchangé. Architecture,
interactions, tests headless, limites et procédure d’intégration :
[Dock Tela / favoris V1](../../../../docs/quickshell-dock-tela-shared-favorites-v1.md).
### Labfy Update Center V1
Le clic sur l’indicateur ouvre les paquets et versions Arch/AUR, la maintenance
locale et les annonces officielles. L’installation complète reste interactive
avec confirmations QuickShell et Kitty. « Aperçu Kitty » ne lance aucune
installation. Architecture, limites et validations :
[documentation du centre](../../../../docs/quickshell-update-center-v1.md).
Le Control Center contient aussi **Clavier RGB** : palette clavier saturée (Violet en premier), couleur
personnalisée, intensité HID et retour explicite au **Mode ASUS**, via le CLI C17
Labfy RGB sans privilège. Un clic sur une pastille applique la couleur ; les réglages personnalisés gardent leur validation explicite.
La [documentation RGB](controlcenter/RGB_KEYBOARD.md) distingue l'historique
commandé de l'état matériel et décrit l'option de restauration, désactivée par défaut.

View file

@ -0,0 +1,86 @@
#!/usr/bin/python
"""Probe Object Push support, or hand file selection and transfer to Blueman."""
import os
import re
import shutil
import subprocess
import sys
from urllib.parse import urlsplit, unquote
def select_files():
"""Use the configured XDG portal (termfilechooser/Yazi on this desktop)."""
import dbus
from dbus.mainloop.glib import DBusGMainLoop
from gi.repository import GLib
DBusGMainLoop(set_as_default=True)
bus = dbus.SessionBus()
loop = GLib.MainLoop()
result = []
failure = []
token = 'bluetooth_' + str(os.getpid())
# CONTRACT: subscribe before OpenFile so a fast cancellation cannot be lost.
request_path = '/org/freedesktop/portal/desktop/request/' + bus.get_unique_name()[1:].replace('.', '_') + '/' + token
def response(code, values):
if int(code) == 0:
result.extend(str(uri) for uri in values.get('uris', []))
elif int(code) != 1:
failure.append('Le sélecteur de fichiers XDG a échoué')
loop.quit()
match = bus.add_signal_receiver(response, signal_name='Response', dbus_interface='org.freedesktop.portal.Request', path=request_path)
try:
portal = bus.get_object('org.freedesktop.portal.Desktop', '/org/freedesktop/portal/desktop')
portal.OpenFile('', 'Envoyer un fichier par Bluetooth',
dbus.Dictionary({'handle_token': token, 'multiple': dbus.Boolean(False), 'directory': dbus.Boolean(False)}, signature='sv'),
dbus_interface='org.freedesktop.portal.FileChooser')
loop.run()
finally:
match.remove()
if failure:
raise RuntimeError(failure[0])
files = []
for uri in result:
parsed = urlsplit(uri)
if parsed.scheme != 'file' or parsed.netloc not in ('', 'localhost'):
raise ValueError('Sélectionner un fichier local')
path = unquote(parsed.path)
if not os.path.isfile(path):
raise ValueError('Le fichier sélectionné est indisponible')
files.append(path)
return files
def main():
mode, adapter, address = sys.argv[1:]
if mode not in ('probe', 'send') or not re.fullmatch(r'hci[0-9]+', adapter) or not re.fullmatch(r'(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}', address):
raise ValueError('Adresse Bluetooth invalide')
import dbus
objects = dbus.SystemBus().get_object('org.bluez', '/').GetManagedObjects(dbus_interface='org.freedesktop.DBus.ObjectManager')
path = next((p for p, v in objects.items() if 'org.bluez.Adapter1' in v and str(p).rsplit('/', 1)[-1] == adapter), None)
device = next((v['org.bluez.Device1'] for p, v in objects.items() if 'org.bluez.Device1' in v and str(v['org.bluez.Device1'].get('Adapter')) == path and str(v['org.bluez.Device1'].get('Address', '')).upper() == address.upper()), None)
if device is None or '00001105-0000-1000-8000-00805f9b34fb' not in [str(u).lower() for u in device.get('UUIDs', [])]:
print('unsupported')
return 0
if not shutil.which('blueman-sendto') or not os.path.isfile('/usr/lib/systemd/user/obex.service'):
print('Transfert indisponible : installer les dépendances avec sudo pacman -S blueman bluez-obex')
return 1
if mode == 'probe':
print('supported')
return 0
files = select_files()
if not files:
return 0 # Cancellation must never launch Blueman or a transfer.
# CONTRACT: explicit local absolute paths bypass Blueman's GTK chooser.
# Blueman owns progress/result; never pass --delete or connect generically.
return subprocess.call(['blueman-sendto', '--source', adapter, '--device', address, '--', *files])
if __name__ == '__main__':
try:
sys.exit(main())
except Exception as error:
print('Impossible de lancer l’envoi Bluetooth : ' + str(error))
sys.exit(1)

View file

@ -2,6 +2,14 @@
"""Publie un instantané Arch/AUR borné pour la barre QuickShell."""
import datetime
import fcntl
import functools
import re
import socket
import urllib.request
import urllib.parse
import xml.etree.ElementTree as ET
import time
import json
import os
import pathlib
@ -10,47 +18,232 @@ import sys
import tempfile
def count(command):
# CONTRACT: aucune mise à jour n'est appliquée ; chaque vérification est bornée.
result = subprocess.run(command, capture_output=True, text=True, timeout=120, check=False)
# CONTRACT: checkupdates utilise le code 2 pour « aucune mise à jour » ;
# INVARIANT: une sortie inattendue garde le chemin d'erreur existant.
if command[0] == "checkupdates" and result.returncode == 2 and not result.stdout.strip() and not result.stderr.strip():
return 0
# yay -Qua retourne 1 avec stdout/stderr vides quand aucune entrée AUR n'existe.
if command[0] == "yay" and result.returncode == 1 and not result.stdout.strip() and not result.stderr.strip():
return 0
if result.returncode != 0:
message = (result.stderr or result.stdout).strip().splitlines()
raise RuntimeError((message[-1] if message else f"{command[0]} : code {result.returncode}")[:160])
return len([line for line in result.stdout.splitlines() if line.strip()])
def now():
return datetime.datetime.now().astimezone().isoformat(timespec="seconds")
def main():
runtime = pathlib.Path(os.environ["XDG_RUNTIME_DIR"])
destination = runtime / "labfy-quickshell-updates.json"
state = {"repoUpdates": 0, "aurUpdates": 0, "totalUpdates": 0,
"lastCheck": datetime.datetime.now().astimezone().isoformat(timespec="seconds"), "error": ""}
try:
state["repoUpdates"] = count(["checkupdates"])
state["aurUpdates"] = count(["yay", "-Qua"])
state["totalUpdates"] = state["repoUpdates"] + state["aurUpdates"]
except (OSError, subprocess.TimeoutExpired, RuntimeError) as exc:
# INVARIANT: une vérification incomplète ne peut jamais publier un faux zéro.
state["error"] = str(exc).splitlines()[0][:160]
print(state["error"], file=sys.stderr)
fd, temporary = tempfile.mkstemp(prefix="labfy-updates-", dir=runtime)
def publish(destination, state):
# CONTRACT: readers only see complete UTF-8 snapshots, never a partial write.
fd, temporary = tempfile.mkstemp(prefix="labfy-updates-", dir=destination.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as stream:
json.dump(state, stream, ensure_ascii=False)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, destination)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
return 1 if state["error"] else 0
def read_cache(path):
try:
if path.stat().st_size > 4 * 1024 * 1024:
return {}
value = json.loads(path.read_text())
return value if isinstance(value, dict) else {}
except (OSError, ValueError):
return {}
@functools.lru_cache(maxsize=1)
def kernel_names():
names = set()
for path in pathlib.Path("/usr/lib/modules").glob("*/pkgbase"):
try:
names.add(path.read_text().strip())
except OSError:
pass
return names
def sensitive(name):
# WHY: component families are informational, never a prediction of breakage.
if re.fullmatch(r"linux(?:-zen|-lts|-hardened|-rt|-rt-lts)?(?:-headers)?", name):
return "Noyau"
if name.startswith("systemd") or name == "pacman":
return "Système"
if any(part in name for part in ("mesa", "nvidia", "vulkan", "xf86-video", "amdgpu", "amdvlk", "intel-media-driver", "libva-intel-driver", "linux-firmware-intel")):
return "Graphique"
if name.startswith(("sway", "quickshell")):
return "Session"
# Custom installed kernels also expose a pkgbase under /usr/lib/modules.
if name in kernel_names():
return "Noyau"
return ""
def packages(command, origin):
result = subprocess.run(command, capture_output=True, text=True, timeout=120, check=False,
env={**os.environ, "LC_ALL": "C", "NO_COLOR": "1"})
empty_code = 2 if command[0] == "checkupdates" else 1
if result.returncode == empty_code and not (result.stdout.strip() or result.stderr.strip()):
return []
if result.returncode:
raise RuntimeError((result.stderr or result.stdout or f"Code {result.returncode}").strip()[:500])
rows = []
for line in result.stdout.splitlines():
match = re.fullmatch(r"([a-zA-Z0-9@._+:-]+) (\S+) -> (\S+)", line.strip())
if not match:
raise RuntimeError("Sortie du collecteur inattendue : " + line[:160])
name, installed, available = match.groups()
rows.append(dict(name=name, installed=installed, available=available,
origin=origin, sensitive=sensitive(name)))
return rows
def offline():
# A failed collector is not necessarily a network failure. Probe only on error.
try:
with socket.create_connection(("archlinux.org", 443), timeout=3):
return False
except OSError:
return True
def check(runtime):
destination = runtime / "labfy-quickshell-updates.json"
state = read_cache(destination)
# CONTRACT: corrupt cached data must not prevent a fresh check from recovering.
valid = all(type(state.get(key)) is int and state[key] >= 0
for key in ("repoUpdates", "aurUpdates", "totalUpdates"))
rows = state.get("packages", [])
valid = valid and state["totalUpdates"] == state["repoUpdates"] + state["aurUpdates"]
valid = valid and isinstance(rows, list) and all(isinstance(row, dict)
and all(isinstance(row.get(key), str) for key in ("name", "installed", "available"))
and row.get("origin") in ("Arch", "AUR") for row in rows)
if not valid:
state = dict(repoUpdates=0, aurUpdates=0, totalUpdates=0,
packages=[], lastCheck="", error="")
state.update(checking=True, checkStarted=now(), error="", offline=False)
publish(destination, state)
errors = []
try:
for origin, command, key in (("Arch", ["checkupdates", "--nocolor"], "repoUpdates"),
("AUR", ["yay", "-Qua", "--color", "never"], "aurUpdates")):
try:
rows = packages(command, origin)
state["packages"] = [p for p in state.get("packages", []) if p["origin"] != origin] + rows
state[key] = len(rows)
except (OSError, subprocess.TimeoutExpired, RuntimeError) as exc:
errors.append(origin + " : " + str(exc)[:500])
state["totalUpdates"] = state["repoUpdates"] + state["aurUpdates"]
state["lastCheck"] = now()
if not errors:
state["lastSuccess"] = state["lastCheck"]
state["error"] = " ; ".join(errors)
state["offline"] = offline() if errors else False
finally:
state["checking"] = False
publish(destination, state)
return bool(errors)
def output(command):
result = subprocess.run(command, capture_output=True, text=True, timeout=12, check=False)
if result.returncode:
raise RuntimeError((result.stderr or f"{command[0]} : code {result.returncode}").strip()[:300])
return result.stdout
def maintenance():
result = dict(time=now(), history=[], files=[], failed=[], reboot="", errors=[])
try:
# Read a bounded tail even when pacman.log has years of transactions.
with open("/var/log/pacman.log", "rb") as stream:
stream.seek(0, 2)
stream.seek(max(0, stream.tell() - 262144))
lines = stream.read(262144).decode("utf-8", "replace").splitlines()
result["history"] = [line for line in lines if "[ALPM]" in line and any(
word in line for word in ("upgraded ", "installed ", "removed ", "transaction "))][-35:][::-1]
except OSError as exc:
result["errors"].append(str(exc))
# CONTRACT: /etc scan is read-only, does not follow symlinks, and reports limits.
errors = result["errors"]
visited = 0
deadline = time.monotonic() + 4
def walk_error(exc):
if len(errors) < 12:
errors.append(str(exc))
for root, dirs, files in os.walk("/etc", followlinks=False, onerror=walk_error):
visited += len(dirs) + len(files)
if visited > 50000 or time.monotonic() > deadline:
errors.append("Recherche /etc incomplète : limite de lecture atteinte")
break
for name in files:
if name.endswith((".pacnew", ".pacsave")):
result["files"].append(str(pathlib.Path(root) / name))
result["files"].sort()
for scope in ([], ["--user"]):
try:
lines = output(["systemctl", *scope, "--failed", "--no-legend", "--plain", "--no-pager"]).splitlines()
result["failed"] += [("Utilisateur : " if scope else "Système : ") + line for line in lines[:30]]
except (OSError, subprocess.TimeoutExpired, RuntimeError) as exc:
errors.append(str(exc))
if pathlib.Path("/run/reboot-required").exists():
result["reboot"] = "Demande de redémarrage détectée : /run/reboot-required."
elif not pathlib.Path("/usr/lib/modules", os.uname().release).exists():
result["reboot"] = "Recommandation : modules du noyau actif absents ; un redémarrage peut être nécessaire."
else:
result["reboot"] = "Aucun signal certain détecté. Un redémarrage peut rester conseillé après noyau, pilotes ou systemd."
return result
def parse_news(data):
root = ET.fromstring(data)
items = []
for item in root.findall("./channel/item")[:12]:
title = (item.findtext("title") or "")[:500]
link = item.findtext("link") or ""
url = urllib.parse.urlparse(link)
if url.scheme != "https" or url.netloc != "archlinux.org":
continue
# Only an explicit editorial statement in the official title is labeled.
explicit = bool(re.search(r"manual intervention (?:is )?required|requires manual intervention", title, re.I))
items.append(dict(title=title, date=(item.findtext("pubDate") or "")[:100],
link=link, manual=explicit))
return items
def news():
cache = pathlib.Path(os.environ.get("XDG_CACHE_HOME", str(pathlib.Path.home() / ".cache"))) / "labfy-update-news.json"
cache.parent.mkdir(parents=True, exist_ok=True)
old = read_cache(cache)
if time.time() - old.get("fetched", 0) < 21600:
return old
# Negative caching also prevents repeated network attempts while offline.
result = dict(old, error="", attempted=time.time())
if time.time() - old.get("attempted", 0) < 600:
return old
try:
request = urllib.request.Request("https://archlinux.org/feeds/news/", headers={"User-Agent": "Labfy-Update-Center/1"})
with urllib.request.urlopen(request, timeout=12) as response:
data = response.read(1048577)
if len(data) > 1048576:
raise ValueError("Flux trop volumineux")
result.update(items=parse_news(data), fetched=time.time(), time=now())
except (OSError, ValueError, ET.ParseError) as exc:
result["error"] = "Flux indisponible, cache conservé : " + str(exc)[:250]
publish(cache, result)
return result
def main():
mode = sys.argv[1] if len(sys.argv) > 1 else "check"
if mode in ("maintenance", "news"):
print(json.dumps(maintenance() if mode == "maintenance" else news(), ensure_ascii=False))
return 0
if mode != "check":
return 2
runtime = pathlib.Path(os.environ["XDG_RUNTIME_DIR"])
# Shared with the interactive installer: timer, direct invocation and all outputs
# cannot start overlapping package operations. Lifetime is the open descriptor.
with open(runtime / "labfy-updates.lock", "w") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return 0
return int(check(runtime))
if __name__ == "__main__":

View file

@ -0,0 +1,59 @@
#!/usr/bin/python3
"""Interactive, user-owned full upgrade; --dry-run never executes a package manager."""
import fcntl
import os
from pathlib import Path
import shutil
import subprocess
import sys
def main():
if sys.argv[1:] not in ([], ["--dry-run"]):
return 2
dry = sys.argv[1:] == ["--dry-run"]
print("Labfy Update Center\n")
print("Mise à jour complète des dépôts officiels et de l’AUR : yay -Syu --confirm")
print("Consultez les annonces Arch, les différences des PKGBUILD et les questions de yay.")
print("Aucune fusion de .pacnew, aucun nettoyage de cache automatique par Labfy.")
if dry:
print("\nAPERÇU : aucune installation ne sera exécutée.")
input("Entrée pour fermer cet aperçu… ")
return 0
if not sys.stdin.isatty() or os.geteuid() == 0 or not shutil.which("yay"):
print("Un terminal utilisateur et yay sont nécessaires.")
return 1
if input("\nSaisissez METTRE A JOUR pour continuer : ").strip() != "METTRE A JOUR":
print("Opération annulée.")
return 0
code = 1
with open(Path(os.environ["XDG_RUNTIME_DIR"]) / "labfy-updates.lock", "w") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
print("Une vérification ou une mise à jour Labfy est déjà en cours. Réessayez ensuite.")
input("Entrée pour fermer… ")
return 1
try:
# CONTRACT: argv is fixed; no remote text or selected package enters it.
# --confirm overrides a possible user configuration disabling questions.
code = subprocess.call(["yay", "-Syu", "--confirm", "--sudoloop=false", "--cleanafter=false", "--useask=false", "--askremovemake",
"--noanswerclean", "--noanswerdiff", "--noansweredit", "--noanswerupgrade"])
except (OSError, KeyboardInterrupt) as exc:
print("Opération interrompue :", exc)
finally:
# Release before asking the existing service to collect the new state.
fcntl.flock(lock, fcntl.LOCK_UN)
print("\nRésultat de yay :", code, "(0 = succès)")
if code:
print("Résolvez les erreurs et terminez la mise à jour complète avant toute autre installation.")
subprocess.run(["systemctl", "--user", "start", "labfy-quickshell-updates.service"], check=False)
input("\nConsultez le résultat ci-dessus. Entrée pour fermer… ")
return code
if __name__ == "__main__":
try:
raise SystemExit(main())
except (EOFError, KeyboardInterrupt):
raise SystemExit(130)

View file

@ -1,6 +1,7 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Io
import Quickshell.Bluetooth
import "../components"
import "../theme"
@ -10,6 +11,7 @@ Item {
required property var adapter
required property bool activePage
signal backRequested()
signal fileChooserRequested()
property var scanOwnedAdapter: null
property string stage: "main" // main, details, forget, block
property string selectedAddress: ""
@ -46,7 +48,47 @@ Item {
if (device.connected) return "Connecté";
return device.paired ? "Appairé • déconnecté" : "Non appairé";
}
function choose(device) { selectedAddress = device.address; message = ""; stage = "details"; }
property bool sendSupported: false
readonly property string sendHelper: Qt.resolvedUrl("../bin/bluetooth-send.py").toString().replace("file://", "")
Process {
id: sendProbe
property string address: ""
stdout: StdioCollector { onStreamFinished: {
if (sendProbe.address !== page.selectedAddress) return;
page.sendSupported = text.trim() === "supported";
if (text.trim() !== "supported" && text.trim() !== "unsupported") page.message = text.trim();
} }
}
Process {
id: sendProcess
stdout: StdioCollector { onStreamFinished: { if (text.trim()) page.message = text.trim(); } }
onExited: (code, status) => { if (code !== 0 || status !== 0) page.message = "Impossible de lancer ou terminer l’envoi Bluetooth. Vérifier Blueman et bluez-obex (sudo pacman -S blueman bluez-obex)."; }
}
// WHY: the details Loader destroys its button when the popup closes.
// CONTRACT: the page owns the timer/process; snapshot the destination before
// closing releases the keyboard grab and clears the device selection.
Timer {
id: sendLaunchTimer
interval: 50
onTriggered: sendProcess.running = true
}
function requestFileSend() {
if (sendProcess.running || sendLaunchTimer.running || !selectedDevice || !adapter) return;
message = "";
sendProcess.command = ["/usr/bin/python", sendHelper, "send", adapter.adapterId, selectedAddress];
fileChooserRequested();
sendLaunchTimer.start();
}
function probeSend() {
sendSupported = false;
if (sendProbe.running) sendProbe.running = false;
if (!selectedDevice || !adapter) return;
sendProbe.address = selectedAddress;
sendProbe.command = ["/usr/bin/python", sendHelper, "probe", adapter.adapterId, selectedAddress];
sendProbe.running = true;
}
onSelectedAddressChanged: probeSend()
function choose(device) { message = ""; selectedAddress = device.address; stage = "details"; probeSend(); }
function back() {
if (stage === "main") backRequested();
else if (stage === "forget" || stage === "block") stage = "details";
@ -158,6 +200,12 @@ Item {
}
ActionButton { label: "Oublier"; danger: true; visible: !!page.selectedDevice && page.selectedDevice.paired; onClicked: page.stage = "forget" }
}
ActionButton {
label: "Envoyer un fichier…"
visible: page.sendSupported
enabled: !sendProcess.running && !!page.selectedDevice && !page.selectedDevice.blocked && !!page.adapter && page.adapter.enabled
onClicked: page.requestFileSend()
}
Row {
spacing: 8
Text { width: 165; text: "Appareil de confiance"; color: Theme.foreground; font.pixelSize: 12 }

View file

@ -7,6 +7,7 @@ import Quickshell.Bluetooth
import "../components"
import "../theme"
import "../sessionui"
import "../rgbkeyboard"
PopupWindow {
id: popup
@ -18,7 +19,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, avatar: 14, audio: 15, keepAwake: 16 })
checkpointFailure: 13, avatar: 14, audio: 15, keepAwake: 16, RGB_KEYBOARD: 17 })
property int currentPage: pages.main
function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; }
signal thresholdApplied()
@ -131,6 +132,7 @@ PopupWindow {
: currentPage === pages.wallpaper ? 550
: currentPage === pages.appearance ? 610
: currentPage === pages.locationTime || currentPage === pages.sessionManager ? 650
: currentPage === pages.RGB_KEYBOARD ? 610
: currentPage === pages.audio ? 620 : 440
visible: false
// CONTRACT: PopupWindow n'applique un changement de grabFocus qu'après
@ -215,6 +217,14 @@ PopupWindow {
MouseArea { id: appearancePointer; anchors.fill: parent; hoverEnabled: true; onClicked: popup.openPage("appearance") }
}
Rectangle {
width: parent.width; height: 38; radius: 4
color: rgbPointer.containsMouse ? Theme.buttonHover : Theme.buttonBackground
NerdIcon { anchors.left: parent.left; anchors.leftMargin: 10; anchors.verticalCenter: parent.verticalCenter; text: "󰌌"; color: Theme.lavender; font.pixelSize: 18 }
Text { anchors.left: parent.left; anchors.leftMargin: 42; anchors.verticalCenter: parent.verticalCenter; text: "Clavier RGB"; color: Theme.foreground; font.pixelSize: 13 }
MouseArea { id: rgbPointer; anchors.fill: parent; hoverEnabled: true; onClicked: popup.openPage("RGB_KEYBOARD") }
}
Item {
width: parent.width
height: 30
@ -250,6 +260,7 @@ PopupWindow {
BluetoothPage {
id: bluetoothPage
onFileChooserRequested: popup.visible = false
adapter: popup.adapter
activePage: popup.visible && popup.currentPage === 2
onBackRequested: popup.openPage("main")
@ -372,6 +383,11 @@ PopupWindow {
controller: popup.keepAwakeController
onBackRequested: popup.openPage("main")
}
// Append-only: existing navigation indices and controllers are intact.
RgbKeyboardPage {
activePage: popup.visible && popup.currentPage === popup.pages.RGB_KEYBOARD
onBackRequested: popup.openPage("main")
}
}
}
}

View file

@ -0,0 +1,80 @@
# Clavier RGB — LABFY_RGB_V3
La sous-page interne `RGB_KEYBOARD` est ajoutée en fin du StackLayout (index 17),
sans déplacer les indices historiques. Elle s'ouvre depuis Réglages rapides →
Clavier RGB et utilise la navigation retour habituelle. Aucune nouvelle fenêtre.
Le singleton `rgbkeyboard/RgbKeyboardService.qml` possède un seul Process pour
l'ensemble des barres/écrans. Il invoque directement
`~/.local/bin/labfy-rgb` avec un tableau d'arguments, sans shell, sudo ou pkexec.
Ce chemin est actuellement un lien vers `/home/fy59/labfy-rgb/build/labfy-rgb`.
Le CLI utilise le périphérique I2C/ASUS/5570 découvert, vérifie son descripteur
et obtient un verrou exclusif. La règle uaccess dédiée fournit l'accès de la
session locale active ; QuickShell et le CLI restent des processus utilisateur.
## Affichage et commandes
`status --json` schéma 1 distingue disponibilité, observed_mode (actuellement
unknown) et commanded_mode/last_color/last_percent issus de l'historique local.
L'interface dit « Dernière commande » et ne présente pas ces données comme une
mesure de l'éclairage réel. En mode ASUS, la couleur physique reste inconnue.
Les huit pastilles utilisent une palette saturée pour le clavier uniquement :
Violet #8000FF (premier choix), Bleu #0040FF, Cyan #00FFFF, Vert #00FF40,
Rose #FF0080, Rouge #FF0000, Orange #FF6000, Jaune #FFFF00.
Le thème général Catppuccin Mocha et son accent restent inchangés.
Cliquer une pastille applique immédiatement `set #RRGGBB`, sans shell ni sudo,
avec l'intensité par défaut du CLI (100 %). Le brouillon est initialisé à Violet,
sans application automatique à l'ouverture. Saisir une couleur personnalisée ou
déplacer le slider ne lance aucun Process : utiliser Appliquer ou Intensité.
- Appliquer : `set #RRGGBB P`, couleur et intensité réunies en une transaction.
- Intensité : `brightness P`, exige une dernière couleur connue du CLI.
- Éteindre : `off`, intensité/RGB à zéro sous contrôle logiciel.
- Mode ASUS : `auto`, disponible même si le dernier status échoue.
- Actualiser : lecture `status --json` ; pas de polling permanent.
Un Process déjà réservé refuse toute autre commande. Les boutons sont désactivés
pendant son exécution. Après commande, une seule actualisation de statut suit.
Code non nul, démarrage impossible et sortie JSON invalide sont traités ; une
commande réussie ne promet pas un état visuel permanent. Les diagnostics sont
bornés dans l'interface. IPC `rgbKeyboard state` est strictement en lecture seule.
L'intensité HID est distincte de `asus::kbd_backlight` WMI. Les essais V3 n'ont
pas changé WMI. Leur interaction hors de ces essais n'est pas caractérisée :
ne pas synchroniser automatiquement les deux curseurs.
## Restauration au démarrage — opt-in
« Restaurer ma couleur au démarrage » est **désactivé par défaut**, laissé ainsi
pendant la validation réelle. Activer la case constitue le choix utilisateur.
La préférence et la dernière configuration acquittée depuis cette interface sont
conservées par QtCore.Settings dans `$XDG_CONFIG_HOME/labfy-rgb/quickshell.ini`
(ou `~/.config/labfy-rgb/quickshell.ini`). Pas de secret dans ce fichier.
À un prochain chargement de QuickShell, un seul contrôle différé est effectué.
Une restauration ne part que si l'option est active, le périphérique accessible,
le RGB et pourcentage valides, et la dernière préférence de mode logicielle.
Une consigne firmware connue du CLI ou demandée via Mode ASUS l'emporte.
Une opération échouée invalide la préférence de mode pour éviter un rejeu incertain.
Aucune boucle de retry, service root ou service/autostart systemd ajouté.
Aucune restauration automatique après veille n'est activée dans cette version.
Le cache runtime C est indépendant des préférences :
`$XDG_RUNTIME_DIR/labfy-rgb/state.bin`, répertoire 0700/fichier 0600,
format versionné 003, publié atomiquement. L'ancien cache V2 root n'est ni lu
ni migré. Le cache runtime n'est pas une autorité de capacités HID.
## Validation
`python3 tests/test_quickshell_rgb_keyboard.py` exécute le service QML de production
sur un exécutable fictif, avec répertoires de configuration/runtime temporaires.
Aucune écriture matérielle : arguments des huit préréglages, sérialisation, refresh,
erreur code 4, exécutable disparu et absence d'activation par défaut sont vérifiés.
`qmllint` porte sur les trois QML modifiés/ajoutés. Les journaux de la session
active et les observations visuelles sont conservés dans le projet CLI,
`/home/fy59/labfy-rgb/audit/v3/`. Le contrôle final du rendu et des préférences
reste accessible sans redémarrer SwayFX.

View file

@ -0,0 +1,112 @@
import QtQuick
import QtQuick.Controls
import "../components"
import "../theme"
import "../rgbkeyboard"
Item {
id: page
property bool activePage: false
property string selectedColor: "#8000FF"
property int selectedPercent: 100
signal backRequested()
onActivePageChanged: if (activePage && !RgbKeyboardService.busy) RgbKeyboardService.refresh()
Column {
anchors.left: parent.left; anchors.right: parent.right; anchors.top: parent.top
spacing: 10
Item {
width: parent.width; height: 28
NerdIcon { id: back; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter; text: "" }
Text { anchors.left: back.right; anchors.leftMargin: 8; anchors.verticalCenter: parent.verticalCenter
text: "Clavier RGB"; color: Theme.foreground; font.pixelSize: 16; font.bold: true }
MouseArea { anchors.left: parent.left; anchors.top: parent.top; anchors.bottom: parent.bottom; width: 180; onClicked: page.backRequested() }
}
Text {
width: parent.width; wrapMode: Text.Wrap; font.pixelSize: 12
color: RgbKeyboardService.available ? Theme.successForeground : Theme.warningForeground
text: RgbKeyboardService.busy ? "Commande en cours…" : RgbKeyboardService.available ? "ITE5570 · " + RgbKeyboardService.device : "Contrôleur indisponible"
}
Text {
width: parent.width; wrapMode: Text.Wrap; font.pixelSize: 12; color: Theme.secondaryForeground
text: "Dernière commande : " + (RgbKeyboardService.commandedMode === "software" ? "mode logiciel" : RgbKeyboardService.commandedMode === "firmware" ? "mode ASUS" : "inconnue")
+ (RgbKeyboardService.lastColor ? " · " + RgbKeyboardService.lastColor + " · " + RgbKeyboardService.lastPercent + " %" : "")
+ "\nHistorique local ; couleur et mode physiques non mesurés."
}
Grid {
width: parent.width; columns: 4; spacing: 6
Repeater {
model: RgbKeyboardService.palette
delegate: Rectangle {
id: swatch
required property var modelData
width: (page.width - 18) / 4; height: 42; radius: 4
color: pointer.containsMouse ? Theme.buttonHover : Theme.buttonBackground
border.width: page.selectedColor.toUpperCase() === modelData.color ? 1 : 0
border.color: Theme.lavender
Rectangle { width: 12; height: 12; radius: 4; color: swatch.modelData.color; anchors.horizontalCenter: parent.horizontalCenter; y: 5 }
Text { anchors.horizontalCenter: parent.horizontalCenter; y: 22; text: swatch.modelData.name; font.pixelSize: 10; color: Theme.foreground }
MouseArea {
id: pointer
objectName: "rgbPreset-" + swatch.modelData.name
anchors.fill: parent; hoverEnabled: true
enabled: !RgbKeyboardService.busy && RgbKeyboardService.available
onClicked: {
if (RgbKeyboardService.preset(swatch.modelData.color)) {
page.selectedColor = swatch.modelData.color;
page.selectedPercent = 100;
}
}
}
}
}
}
Row {
width: parent.width; spacing: 8
Rectangle { width: 30; height: 30; radius: 4; color: RgbKeyboardService.validColor(page.selectedColor) ? page.selectedColor : Theme.buttonBackground }
TextField {
width: parent.width - 38; height: 30; maximumLength: 7
text: page.selectedColor; placeholderText: "#RRGGBB"; color: Theme.foreground
font.pixelSize: 12; selectByMouse: true; enabled: !RgbKeyboardService.busy
onTextEdited: page.selectedColor = text
background: Rectangle { radius: 4; color: Theme.inputBackground; border.color: RgbKeyboardService.validColor(page.selectedColor) ? Theme.inputBorder : Theme.danger }
}
}
Text { text: "Intensité HID : " + page.selectedPercent + " %"; color: Theme.foreground; font.pixelSize: 12 }
Slider {
id: intensity; width: parent.width; height: 26; from: 0; to: 100; stepSize: 1
value: page.selectedPercent; enabled: !RgbKeyboardService.busy
// CONTRACT: dragging only updates the draft; no Process until Apply.
onMoved: page.selectedPercent = Math.round(value)
background: Rectangle { x: intensity.leftPadding; y: intensity.topPadding + intensity.availableHeight / 2 - height / 2
width: intensity.availableWidth; height: 6; radius: 3; color: Theme.buttonBackground
Rectangle { width: intensity.visualPosition * parent.width; height: parent.height; radius: 3; color: Theme.lavender } }
handle: Rectangle { x: intensity.leftPadding + intensity.visualPosition * (intensity.availableWidth - width)
y: intensity.topPadding + intensity.availableHeight / 2 - height / 2; width: 12; height: 16; radius: 3; color: Theme.lavender }
}
Row {
spacing: 6
ActionButton { label: "Appliquer"; enabled: !RgbKeyboardService.busy && RgbKeyboardService.available && RgbKeyboardService.validColor(page.selectedColor)
onClicked: RgbKeyboardService.applyColor(page.selectedColor, page.selectedPercent) }
ActionButton { label: "Intensité"; enabled: !RgbKeyboardService.busy && RgbKeyboardService.available && !!RgbKeyboardService.lastColor
onClicked: RgbKeyboardService.intensity(page.selectedPercent) }
ActionButton { label: "Éteindre"; enabled: !RgbKeyboardService.busy && RgbKeyboardService.available; onClicked: RgbKeyboardService.off() }
}
Row {
spacing: 6
// Restoration stays enabled on a failed/stale status; CLI rechecks identity.
ActionButton { objectName: "rgbFirmware"; label: "Mode ASUS"; enabled: !RgbKeyboardService.busy; onClicked: RgbKeyboardService.firmware() }
ActionButton { label: "Actualiser"; enabled: !RgbKeyboardService.busy; onClicked: RgbKeyboardService.refresh() }
}
CheckBox {
text: "Restaurer ma couleur au démarrage"; checked: RgbKeyboardService.restoreEnabled
enabled: !RgbKeyboardService.busy
onToggled: RgbKeyboardService.setRestoreEnabled(checked)
contentItem: Text { text: parent.text; color: Theme.foreground; font.pixelSize: 11; leftPadding: 28; verticalAlignment: Text.AlignVCenter }
indicator: Rectangle { width: 16; height: 16; radius: 3; anchors.verticalCenter: parent.verticalCenter; color: Theme.buttonBackground; border.color: Theme.outline
Rectangle { anchors.fill: parent; anchors.margins: 4; radius: 1; color: Theme.lavender; visible: RgbKeyboardService.restoreEnabled } }
}
Text { width: parent.width; wrapMode: Text.Wrap; text: "Désactivé par défaut. Mode ASUS empêche la restauration. Aucun réglage WMI modifié."
color: Theme.secondaryForeground; font.pixelSize: 10 }
Text { width: parent.width; wrapMode: Text.Wrap; text: RgbKeyboardService.message; color: Theme.secondaryForeground; font.pixelSize: 11 }
}
}

View file

@ -0,0 +1,156 @@
pragma Singleton
import QtQuick
import QtCore
import Quickshell
import Quickshell.Io
Singleton {
id: service
readonly property string executable: (Quickshell.env("HOME") || "") + "/.local/bin/labfy-rgb"
property string operation: ""
readonly property bool busy: operation !== "" || runner.running
property bool available: false
property string device: ""
// CONTRACT: these are command history, never an optical measurement or
// hardware mode readback. Missing/failed status discards stale UI history.
property string commandedMode: "unknown"
property string lastColor: ""
property int lastPercent: -1
property string message: "État non vérifié"
property string pendingColor: ""
property int pendingPercent: 100
property bool refreshPending: false
property bool startupPending: false
readonly property bool restoreEnabled: preferences.restoreEnabled
readonly property var palette: [
{name: "Violet", color: "#8000FF"}, {name: "Bleu", color: "#0040FF"},
{name: "Cyan", color: "#00FFFF"}, {name: "Vert", color: "#00FF40"},
{name: "Rose", color: "#FF0080"}, {name: "Rouge", color: "#FF0000"},
{name: "Orange", color: "#FF6000"}, {name: "Jaune", color: "#FFFF00"}
]
Settings {
id: preferences
// WHY: Settings.location is a URL; an explicit local-file URL avoids
// falling back to unset global application/organization identifiers.
location: "file://" + (Quickshell.env("XDG_CONFIG_HOME") || ((Quickshell.env("HOME") || "") + "/.config")) + "/labfy-rgb/quickshell.ini"
property bool restoreEnabled: false
property string savedColor: ""
property int savedPercent: 100
property string savedMode: "unknown"
}
function validColor(color) { return typeof color === "string" && /^#[0-9a-fA-F]{6}$/.test(color); }
function validPercent(percent) { return Number.isInteger(percent) && percent >= 0 && percent <= 100; }
function setRestoreEnabled(enabled) {
preferences.restoreEnabled = enabled;
// Only validated successful history can become a startup preference.
if (enabled && validColor(lastColor) && validPercent(lastPercent) && commandedMode === "software") {
preferences.savedColor = lastColor;
preferences.savedPercent = lastPercent;
preferences.savedMode = "software";
}
if (enabled && commandedMode === "firmware") preferences.savedMode = "firmware";
preferences.sync();
}
function launch(kind, args) {
if (busy) return false;
operation = kind;
runner.command = [executable].concat(args);
runner.running = true;
return true;
}
function refresh() { return launch("status", ["status", "--json"]); }
// CONTRACT: a preset click immediately sends exactly set + validated color.
// The CLI's default is 100%; subsequent intensity uses brightness explicitly.
// Share the same admission as custom colors, never start concurrent writes.
function preset(color) {
if (!validColor(color) || busy) return false;
pendingColor = color.toUpperCase(); pendingPercent = 100;
return launch("set", ["set", pendingColor]);
}
function applyColor(color, percent) {
if (!validColor(color) || !validPercent(percent) || busy) return false;
pendingColor = color.toUpperCase(); pendingPercent = percent;
return launch("set", ["set", pendingColor, String(percent)]);
}
function intensity(percent) {
if (!validPercent(percent) || !validColor(lastColor) || busy) return false;
pendingColor = lastColor; pendingPercent = percent;
return launch("brightness", ["brightness", String(percent)]);
}
function firmware() { return launch("auto", ["auto"]); }
function off() { return launch("off", ["off"]); }
function finish(code, status) {
if (!operation) return;
const kind = operation;
operation = "";
const okay = code === 0 && status === 0;
if (kind === "status") {
let value = null;
try { value = JSON.parse(output.text); } catch (_) {}
const valid = okay && value && value.schema === 1 && value.available === true
&& typeof value.device === "string" && /^\/dev\/hidraw[0-9]+$/.test(value.device)
&& ["unknown", "software", "firmware"].indexOf(value.commanded_mode) >= 0;
available = !!valid;
device = valid ? value.device : "";
commandedMode = valid ? value.commanded_mode : "unknown";
lastColor = valid && validColor(value.last_color) ? value.last_color : "";
lastPercent = valid && validPercent(value.last_percent) ? value.last_percent : -1;
if (!valid) message = "Contrôleur indisponible ou inaccessible (code " + code + ").";
else if (!message || message === "État non vérifié") message = "Contrôleur accessible";
// One-shot opt-in only. No permanent polling/retry and firmware
// preference wins over saved colors; invalid config never writes HID.
if (valid && commandedMode === "firmware") { preferences.savedMode = "firmware"; preferences.sync(); }
if (startupPending) {
startupPending = false;
if (valid && commandedMode !== "firmware" && preferences.restoreEnabled && preferences.savedMode === "software"
&& validColor(preferences.savedColor) && validPercent(preferences.savedPercent)) {
pendingColor = preferences.savedColor; pendingPercent = preferences.savedPercent;
startupRestore.start();
}
}
} else {
message = okay ? (kind === "auto" ? "Mode ASUS demandé ; couleur physique non mesurée." : "Consigne appliquée ; résultat visuel à vérifier.")
: "Commande refusée/échouée (code " + code + ") : " + errors.text.trim().slice(0, 240);
if (okay) {
if (kind === "auto") preferences.savedMode = "firmware";
else if (kind === "set" || kind === "brightness") {
preferences.savedColor = pendingColor; preferences.savedPercent = pendingPercent;
preferences.savedMode = "software";
} else if (kind === "off") { preferences.savedPercent = 0; }
preferences.sync();
} else {
// Do not replay a formerly successful preference after an
// uncertain hardware failure, including a recovery failure.
preferences.savedMode = "unknown"; preferences.sync();
}
refreshPending = true; followup.start();
}
}
Component.onCompleted: {
startupPending = preferences.restoreEnabled;
// The singleton is loaded by ControlCenter at session startup. A single
// delayed check never blocks startup and never writes while opted out.
initialCheck.start();
}
Timer { id: initialCheck; interval: 1500; onTriggered: service.refresh() }
Timer { id: startupRestore; interval: 0; onTriggered: service.applyColor(service.pendingColor, service.pendingPercent) }
Timer { id: followup; interval: 0; onTriggered: { if (service.refreshPending && !service.busy) { service.refreshPending = false; service.refresh(); } } }
Timer { id: failedStart; interval: 0; onTriggered: { if (service.operation && !runner.running) service.finish(-1, 1); } }
Process {
id: runner
stdout: StdioCollector { id: output; waitForEnd: true }
stderr: StdioCollector { id: errors; waitForEnd: true }
onExited: (code, status) => service.finish(code, status)
onRunningChanged: if (!running && service.operation) failedStart.start()
}
// Read-only diagnostics: no IPC action can trigger color or opt-in.
IpcHandler {
target: "rgbKeyboard"
function state(): string {
return JSON.stringify({busy: service.busy, available: service.available,
commandedMode: service.commandedMode, lastColor: service.lastColor,
lastPercent: service.lastPercent, restoreEnabled: service.restoreEnabled,
message: service.message});
}
}
}

View file

@ -0,0 +1 @@
singleton RgbKeyboardService 1.0 RgbKeyboardService.qml

View file

@ -16,6 +16,7 @@ Row {
signal openPageRequested(int page)
signal toggleControlCenterRequested()
signal toggleRemovableMediaRequested()
signal toggleUpdatesRequested()
signal togglePrintingRequested()
signal togglePhotoBackupRequested()
signal toggleClipboardRequested()
@ -24,6 +25,9 @@ Row {
function closeRemovableMedia() { removableMediaIndicator.popupOpen = false }
function setRemovableMediaOpen(open) { removableMediaIndicator.popupOpen = open }
readonly property bool removableMediaOpen: removableMediaIndicator.popupOpen
function closeUpdates() { updatesIndicator.popupOpen = false }
function setUpdatesOpen(open) { updatesIndicator.popupOpen = open }
readonly property bool updatesOpen: updatesIndicator.popupOpen
function closePrinting() { printingIndicator.popupOpen = false }
function setPrintingOpen(open) { printingIndicator.popupOpen = open }
readonly property bool printingOpen: printingIndicator.popupOpen
@ -91,7 +95,11 @@ Row {
unlimited: area.keepAwakeController.keepAwakeMinutes === 0
onActivated: area.openPageRequested(16)
}
UpdatesIndicator { }
UpdatesIndicator {
id: updatesIndicator
barWindow: area.barWindow
onToggleRequested: area.toggleUpdatesRequested()
}
RemovableMediaIndicator {
id: removableMediaIndicator
onToggleRequested: area.toggleRemovableMediaRequested()

View file

@ -0,0 +1,19 @@
import QtQuick
import "../theme"
Rectangle {
id: button
required property string text
signal clicked()
implicitWidth: label.implicitWidth + 18
implicitHeight: 28
radius: 4
opacity: enabled ? 1 : 0.5
color: mouse.containsMouse ? Theme.buttonHover : Theme.buttonBackground
border.color: activeFocus ? Theme.focus : Theme.outline
activeFocusOnTab: true
Keys.onReturnPressed: clicked()
Keys.onSpacePressed: clicked()
Text { id: label; anchors.centerIn: parent; text: button.text; color: Theme.foreground; font.pixelSize: 12 }
MouseArea { id: mouse; anchors.fill: parent; hoverEnabled: true; onClicked: button.clicked() }
}

View file

@ -0,0 +1,235 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Io
import "../theme"
PopupWindow {
id: center
required property var barWindow
required property var indicator
required property var updateState
property string query: ""
property string origin: "Tous"
// WHY: Process.running may change on the next event-loop turn. Synchronous
// admission flags reject two clicks delivered in the same turn.
property bool refreshPending: false
property bool terminalPending: false
property bool confirming: false
property string actionError: ""
property var maintenance: ({history: [], files: [], failed: [], errors: [], reboot: ""})
property var news: ({items: []})
readonly property string backend: Qt.resolvedUrl("../bin/check-updates.py").toString().replace("file://", "")
readonly property string terminalBackend: Qt.resolvedUrl("../bin/update-terminal.py").toString().replace("file://", "")
property double tick: Date.now()
readonly property bool staleCheck: Boolean(center.updateState.checking) && (!center.updateState.checkStarted || tick - new Date(center.updateState.checkStarted).getTime() > 270000)
readonly property bool checking: refreshPending || refreshProcess.running || (Boolean(center.updateState.checking) && !staleCheck)
readonly property var filtered: (center.updateState.packages || []).filter(p =>
(origin === "Tous" || p.origin === origin) && p.name.toLowerCase().includes(query.toLowerCase()))
anchor.window: barWindow
anchor.rect.x: Math.max(8, barWindow.width - implicitWidth - 8)
anchor.rect.y: barWindow.height + 6
anchor.edges: Edges.Top | Edges.Left
anchor.gravity: Edges.Bottom | Edges.Right
implicitWidth: Math.min(660, barWindow.screen.width - 24)
implicitHeight: Math.min(720, barWindow.screen.height - barWindow.height - 30)
color: "transparent"
visible: false
// CONTRACT: grab is fixed before mapping; outside click and Escape close the popup.
grabFocus: true
onVisibleChanged: {
confirming = false;
tick = Date.now();
if (visible) {
if (!maintenanceProcess.running) maintenanceProcess.running = true;
if (!newsProcess.running) newsProcess.running = true;
}
}
function refresh() {
if (checking || terminalPending) return;
actionError = "";
refreshPending = true;
refreshProcess.running = true;
}
function terminal(preview) {
if (terminalPending || checking || (!preview && !confirming)) return;
confirming = false;
// CONTRACT: direct argv, never a shell, package name or remote command.
// The dedicated app_id selects only this terminal for the Sway floating rule.
terminalProcess.command = ["kitty", "--class", "labfy-update-center", "--title", "Labfy Update Center", "python3", "-B", terminalBackend];
if (preview) terminalProcess.command = terminalProcess.command.concat(["--dry-run"]);
terminalPending = true;
terminalProcess.running = true;
visible = false;
}
Process {
id: refreshProcess
command: ["systemctl", "--user", "start", "labfy-quickshell-updates.service"]
stderr: StdioCollector { id: refreshError }
onExited: (code, status) => {
center.refreshPending = false;
indicator.reload();
if (code !== 0) center.actionError = refreshError.text || "Échec du service de vérification";
}
}
Process {
id: terminalProcess
onExited: (code, status) => {
center.terminalPending = false;
indicator.reload();
if (code !== 0) center.actionError = "Terminal terminé avec le code " + code;
}
}
Process {
id: maintenanceProcess
command: ["python3", "-B", center.backend, "maintenance"]
stdout: StdioCollector { id: maintenanceOutput; waitForEnd: true }
onExited: (code, status) => {
try { center.maintenance = JSON.parse(maintenanceOutput.text); }
catch (_) { center.maintenance = {history: [], files: [], failed: [], errors: ["Maintenance indisponible"], reboot: "État inconnu"}; }
}
}
Process {
id: newsProcess
command: ["python3", "-B", center.backend, "news"]
stdout: StdioCollector { id: newsOutput; waitForEnd: true }
onExited: (code, status) => {
try { center.news = JSON.parse(newsOutput.text); }
catch (_) { center.news = {items: [], error: "Flux indisponible"}; }
}
}
Rectangle {
anchors.fill: parent
focus: true
Keys.onEscapePressed: center.visible = false
radius: 4
color: Theme.popupBackground
border.color: Theme.outline
Column {
anchors.fill: parent; anchors.margins: 12; spacing: 8
Row {
width: parent.width; spacing: 8
Text { text: "Labfy Update Center"; color: Theme.lavender; font.pixelSize: 17; font.bold: true; width: parent.width - 38 }
UpdateAction { text: "×"; onClicked: center.visible = false }
}
Text {
width: parent.width
text: center.updateState.totalUpdates + (center.updateState.totalUpdates === 1 ? " mise à jour · " : " mises à jour · ") + center.updateState.repoUpdates + " officielles · " + center.updateState.aurUpdates + " AUR"
color: Theme.foreground; font.pixelSize: 13
}
Text {
width: parent.width
text: "Dernière tentative : " + (center.updateState.lastCheck ? new Date(center.updateState.lastCheck).toLocaleString(Qt.locale("fr_FR")) : "en attente")
+ (center.updateState.error && center.updateState.lastSuccess ? "\nDernier succès : " + new Date(center.updateState.lastSuccess).toLocaleString(Qt.locale("fr_FR")) : "")
color: Theme.secondaryForeground; font.pixelSize: 11; wrapMode: Text.Wrap
}
Text {
width: parent.width
text: center.checking ? "Vérification en cours…" : center.staleCheck ? "Vérification interrompue ou expirée · actualisez pour réessayer." : center.updateState.error ? (center.updateState.offline ? "Connexion indisponible · " : "") + center.updateState.error + "\nRésultats précédents conservés pour les sources en échec."
: "Contrôles terminés · " + (center.updateState.totalUpdates ? "mises à jour disponibles" : "système à jour")
color: center.updateState.error ? Theme.yellow : Theme.secondaryForeground; font.pixelSize: 11; wrapMode: Text.Wrap; textFormat: Text.PlainText
}
Row {
spacing: 6
UpdateAction { text: "Actualiser"; enabled: !center.checking && !center.terminalPending; onClicked: center.refresh() }
UpdateAction { text: "Mettre à jour le système"; enabled: !center.checking && !center.terminalPending; onClicked: center.confirming = !center.confirming }
UpdateAction { text: "Aperçu Kitty"; enabled: !center.checking && !center.terminalPending; onClicked: center.terminal(true) }
}
Column {
visible: center.confirming; width: parent.width; spacing: 5
Text { width: parent.width; text: "Ouvrir Kitty pour une mise à jour complète Arch + AUR ? Une seconde confirmation sera demandée dans le terminal. Lisez les annonces Arch avant de continuer."; wrapMode: Text.Wrap; color: Theme.yellow; font.pixelSize: 12 }
Row {
spacing: 8
UpdateAction { text: "Confirmer l’ouverture"; onClicked: center.terminal(false) }
UpdateAction { text: "Annuler"; onClicked: center.confirming = false }
}
}
Text { visible: !!center.actionError; width: parent.width; text: center.actionError; color: Theme.red; wrapMode: Text.Wrap; textFormat: Text.PlainText; font.pixelSize: 11 }
TabBar {
id: tabs; objectName: "updateTabs"; width: parent.width
background: Rectangle { color: Theme.surface0; radius: 4 }
Repeater {
model: ["Paquets", "Maintenance", "Annonces Arch"]
TabButton {
required property string modelData
text: modelData
contentItem: Text { text: parent.text; color: Theme.foreground; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter; font.pixelSize: 12 }
background: Rectangle { radius: 4; color: parent.checked ? Theme.surface2 : Theme.surface0 }
}
}
}
Item {
width: parent.width
height: parent.height - y
Column {
anchors.fill: parent; spacing: 6; visible: tabs.currentIndex === 0
Row {
width: parent.width; spacing: 6
TextField {
width: parent.width - 180; height: 30; placeholderText: "Rechercher un paquet…"
color: Theme.foreground; placeholderTextColor: Theme.secondaryForeground
background: Rectangle { color: Theme.inputBackground; border.color: Theme.inputBorder; radius: 4 }
onTextChanged: center.query = text
}
Repeater { model: ["Tous", "Arch", "AUR"]; UpdateAction { required property string modelData; text: modelData; border.color: center.origin === modelData ? Theme.lavender : Theme.outline; onClicked: center.origin = modelData } }
}
Text { width: parent.width; text: "Les repères noyau, système, graphique et session sont informatifs, sans prédiction de panne."; color: Theme.secondaryForeground; font.pixelSize: 11; wrapMode: Text.Wrap }
Text { visible: center.filtered.length === 0; text: "Aucun paquet à afficher"; color: Theme.secondaryForeground; font.pixelSize: 12 }
ListView {
width: parent.width; height: parent.height - y; clip: true
objectName: "updatePackageList"
model: center.filtered; spacing: 3; boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: ScrollBar { }
delegate: Rectangle {
required property var modelData
width: ListView.view.width; height: 49; radius: 4; color: Theme.surface0
Column {
anchors.fill: parent; anchors.margins: 6; spacing: 3
Text { width: parent.width; text: modelData.name + " · " + modelData.origin + (modelData.sensitive ? " · " + modelData.sensitive : ""); color: modelData.sensitive ? Theme.yellow : Theme.foreground; textFormat: Text.PlainText; elide: Text.ElideRight; font.pixelSize: 12 }
Text { width: parent.width; text: modelData.installed + " → " + modelData.available; color: Theme.secondaryForeground; textFormat: Text.PlainText; elide: Text.ElideRight; font.pixelSize: 11 }
}
}
}
}
Flickable {
anchors.fill: parent; clip: true; visible: tabs.currentIndex === 1
contentWidth: width; contentHeight: maintenanceColumn.implicitHeight; boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: ScrollBar { }
Column {
id: maintenanceColumn; width: parent.width; spacing: 8
Text { text: maintenanceProcess.running ? "Lecture en cours…" : "Relevé : " + (center.maintenance.time || "indisponible"); color: Theme.secondaryForeground; font.pixelSize: 11 }
Text { width: parent.width; text: center.maintenance.reboot; color: Theme.yellow; font.pixelSize: 12; wrapMode: Text.Wrap }
Text { width: parent.width; text: (center.maintenance.errors || []).join("\n"); visible: !!text; color: Theme.yellow; font.pixelSize: 11; wrapMode: Text.Wrap; textFormat: Text.PlainText }
Text { text: ".pacnew / .pacsave · /etc · lecture seule"; color: Theme.lavender; font.pixelSize: 13 }
Text { width: parent.width; text: center.maintenance.files.join("\n") || "Aucun fichier détecté dans les chemins accessibles"; color: Theme.foreground; font.pixelSize: 11; wrapMode: Text.Wrap; textFormat: Text.PlainText }
Text { text: "Services en échec"; color: Theme.lavender; font.pixelSize: 13 }
Text { width: parent.width; text: center.maintenance.failed.join("\n") || "Aucun échec signalé dans les périmètres lus"; color: Theme.foreground; font.pixelSize: 11; wrapMode: Text.Wrap; textFormat: Text.PlainText }
Row {
spacing: 6
UpdateAction { text: "Journal système"; onClicked: Quickshell.execDetached(["kitty", "journalctl", "-b", "-p", "warning"]) }
UpdateAction { text: "Journal du collecteur"; onClicked: Quickshell.execDetached(["kitty", "journalctl", "--user", "-u", "labfy-quickshell-updates.service"]) }
}
Text { text: "Dernières transactions · /var/log/pacman.log"; color: Theme.lavender; font.pixelSize: 13 }
Text { width: parent.width; text: center.maintenance.history.join("\n") || "Aucune transaction lisible"; color: Theme.foreground; font.pixelSize: 11; wrapMode: Text.Wrap; textFormat: Text.PlainText }
}
}
ListView {
anchors.fill: parent; clip: true; visible: tabs.currentIndex === 2
model: center.news.items || []; spacing: 6; boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: ScrollBar { }
header: Column {
width: parent.width; spacing: 6
Text { width: parent.width; text: "Flux officiel archlinux.org · cache 6 h\nAucune correspondance automatique avec les paquets installés."; color: Theme.secondaryForeground; font.pixelSize: 11; wrapMode: Text.Wrap }
Text { width: parent.width; text: newsProcess.running ? "Chargement…" : center.news.error || "Cache : " + (center.news.time || "indisponible"); color: Theme.yellow; font.pixelSize: 11; wrapMode: Text.Wrap; textFormat: Text.PlainText }
}
delegate: Rectangle {
required property var modelData
width: ListView.view.width; height: newsText.implicitHeight + 16; radius: 4; color: Theme.surface0
Text { id: newsText; anchors.left: parent.left; anchors.right: parent.right; anchors.top: parent.top; anchors.margins: 8; text: modelData.title + "\n" + modelData.date + (modelData.manual ? "\nIntervention manuelle explicitement annoncée" : ""); color: modelData.manual ? Theme.yellow : Theme.foreground; font.pixelSize: 12; wrapMode: Text.Wrap; textFormat: Text.PlainText }
MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: { if (/^https:\/\/archlinux\.org\//.test(modelData.link)) Qt.openUrlExternally(modelData.link); } }
}
}
}
}
}
}

View file

@ -7,6 +7,10 @@ import "StatusColorRoles.js" as StatusColorRoles
Item {
id: indicator
required property var barWindow
signal toggleRequested()
property alias popupOpen: center.visible
function reload() { snapshot.reload(); }
readonly property string statePath: Quickshell.env("XDG_RUNTIME_DIR") + "/labfy-quickshell-updates.json"
readonly property var state: {
const raw = snapshot.text();
@ -15,7 +19,11 @@ Item {
const value = JSON.parse(raw);
if (!Number.isSafeInteger(value.repoUpdates) || value.repoUpdates < 0
|| !Number.isSafeInteger(value.aurUpdates) || value.aurUpdates < 0
|| value.totalUpdates !== value.repoUpdates + value.aurUpdates)
|| value.totalUpdates !== value.repoUpdates + value.aurUpdates
|| (value.packages !== undefined && (!Array.isArray(value.packages)
|| !value.packages.every(p => p && typeof p.name === "string"
&& typeof p.installed === "string" && typeof p.available === "string"
&& (p.origin === "Arch" || p.origin === "AUR")))))
throw new Error("État invalide");
return value;
} catch (error) {
@ -24,7 +32,7 @@ Item {
}
readonly property bool hasError: Boolean(state.error)
readonly property bool pending: state.error === "Vérification en attente"
visible: hasError || state.totalUpdates > 0
visible: true
width: visible ? icon.width + (hasError ? 0 : count.implicitWidth + 3) + 6 : 0
height: 26
readonly property string detail: hasError
@ -33,7 +41,8 @@ Item {
+ "\n" + state.repoUpdates + " Arch\n" + state.aurUpdates + " AUR"
+ (state.lastCheck ? "\nDernière vérification : " + Qt.formatTime(new Date(state.lastCheck), "HH:mm") : "")
// Le timer systemd publie un fichier atomique ; le renderer ne lance jamais de gestionnaire de paquets.
UpdateCenter { id: center; barWindow: indicator.barWindow; indicator: indicator; updateState: indicator.state }
// Le timer systemd publie un fichier atomique ; le renderer ne vérifie jamais directement les paquets.
FileView {
id: snapshot
path: indicator.statePath
@ -42,12 +51,13 @@ Item {
onFileChanged: reload()
}
// Après un redémarrage, /run/user ne contient pas encore le fichier :
// QFileSystemWatcher ne voit pas sa création. Réessayer seulement jusqu'au premier chargement.
// QFileSystemWatcher ne voit pas sa création. Réessayer jusqu’au chargement ;
// ouvert, ce même timer détecte un collecteur expiré sans nouvel appel réseau.
Timer {
interval: 10000
repeat: true
running: !snapshot.loaded
onTriggered: snapshot.reload()
running: !snapshot.loaded || indicator.popupOpen
onTriggered: { snapshot.reload(); center.tick = Date.now(); }
}
Rectangle { anchors.fill: parent; radius: 4; color: pointer.containsMouse ? Theme.border : "transparent" }
NerdIcon {
@ -72,6 +82,6 @@ Item {
font.family: "JetBrainsMono Nerd Font Mono"
font.pixelSize: 12
}
MouseArea { id: pointer; anchors.fill: parent; hoverEnabled: true; acceptedButtons: Qt.NoButton }
MouseArea { id: pointer; anchors.fill: parent; hoverEnabled: true; onClicked: indicator.toggleRequested() }
StatusTooltip { target: indicator; hovered: pointer.containsMouse; message: indicator.detail }
}

View file

@ -20,6 +20,11 @@ for_window [app_id="labfy-yazi-filechooser"] floating enable, border pixel 2, mo
# avec la bordure serveur et les coins SwayFX déjà configurés globalement.
for_window [app_id="me.jtheoof.swappy"] floating enable, resize set 70ppt 70ppt, move position center, border pixel 2
# WHY: la mise à jour interactive doit rester lisible sans modifier le tiling.
# CONTRACT: installation et aperçu partagent cet app_id dédié ; les autres Kitty
# conservent leur placement. La taille relative reste adaptée à la sortie active.
for_window [app_id="^labfy-update-center$"] floating enable, resize set 65ppt 60ppt, move position center, border pixel 2
#=================================================
# Lardon
#=================================================

View file

@ -0,0 +1 @@
/usr/lib/systemd/user/obex.service

View file

@ -0,0 +1 @@
/home/fy59/.config/systemd/user/hyprwhspr.service

View file

@ -13,9 +13,9 @@ After=wireplumber.service
[Service]
Type=simple
ExecStartPre=/bin/bash -lc 'for i in $(seq 1 60); do ls "$XDG_RUNTIME_DIR"/wayland-* >/dev/null 2>&1 && exit 0; sleep 0.25; done; echo "Wayland socket not found"; exit 1'
ExecStartPre=/bin/bash -lc 'for i in $(seq 1 60); do if [ -n "$WAYLAND_DISPLAY" ]; then case "$WAYLAND_DISPLAY" in /*) wayland_socket="$WAYLAND_DISPLAY" ;; *) wayland_socket="${XDG_RUNTIME_DIR}/${WAYLAND_DISPLAY}" ;; esac; [ -S "$wayland_socket" ] && exit 0; fi; [ -n "$DISPLAY" ] && exit 0; if [ -n "$XDG_RUNTIME_DIR" ]; then for wayland_socket in "${XDG_RUNTIME_DIR}"/wayland-*; do [ -S "$wayland_socket" ] && exit 0; done; fi; sleep 0.25; done; echo "No usable Wayland socket or X11 DISPLAY found"; exit 1'
ExecStart=/usr/lib/hyprwhspr/bin/hyprwhspr
ExecStopPost=/bin/bash -c '( pkill -9 -f "hyprwhspr-virtual-keyboard" 2>/dev/null; pkill -9 -f "hyprwhspr-ydotool.sock" 2>/dev/null ) || true'
ExecStopPost=/bin/bash -c '( pkill -9 -f "hyprwhspr-virtual-keyboar[d]" 2>/dev/null; pkill -9 -f "hyprwhspr-ydotool.soc[k]" 2>/dev/null ) || true'
Environment=HYPRWHSPR_ROOT=/usr/lib/hyprwhspr
Environment=PYTHONUNBUFFERED=1
Restart=on-failure

View file

@ -0,0 +1,4 @@
# HYPRWHSPR_SWAYFX_FOCUS_AND_PASTE_FIX_V1
[Service]
ExecStart=
ExecStart="/home/fy59/.local/share/hyprwhspr-swayfx/1.52.1-sway-v1/launcher"

View file

@ -0,0 +1,159 @@
"""Exercise production RGB QML with an executable fixture; never open real HID."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
BASE = ROOT / 'quickshell/.config/quickshell/labfy-sway'
class RgbKeyboardTests(unittest.TestCase):
def test_production_process_flow_and_opt_out(self):
qs = shutil.which('qs')
if not qs:
self.skipTest('QuickShell unavailable')
with tempfile.TemporaryDirectory(prefix='labfy-rgb-qml-') as folder:
p = Path(folder)
(p/'runtime').mkdir(mode=0o700)
(p/'config').mkdir()
(p/'rgbkeyboard').mkdir()
shutil.copy(BASE/'rgbkeyboard/qmldir', p/'rgbkeyboard/qmldir')
source = (BASE/'rgbkeyboard/RgbKeyboardService.qml').read_text()
old = 'readonly property string executable: (Quickshell.env("HOME") || "") + "/.local/bin/labfy-rgb"'
self.assertIn(old, source)
source = source.replace(old, 'readonly property string executable: ' + json.dumps(str(p/'fake-cli')))
(p/'rgbkeyboard/RgbKeyboardService.qml').write_text(source)
# Only the executable path is replaced. All Process/state/validation
# logic is production code. Preferences and endpoint state are isolated.
(p/'fake-cli').write_text('''#!/usr/bin/python3
import json, os, sys
from pathlib import Path
p=Path(os.environ['RGB_FIXTURE'])
a=sys.argv[1:]
with (p/'calls').open('a') as f: f.write(json.dumps(a)+'\\n')
s=dict(schema=1,available=True,device='/dev/hidraw77',commanded_mode='unknown',last_color=None,last_percent=None)
if (p/'state').exists(): s=json.loads((p/'state').read_text())
if a[0]=='set' and a[1]=='#010101': print('fixture access denied',file=sys.stderr); sys.exit(4)
if a[0]=='set' and a[1]=='#000001': Path(sys.argv[0]).unlink()
if a[0]=='status': print(json.dumps(s))
elif a[0]=='set': s.update(commanded_mode='software',last_color=a[1],last_percent=int(a[2]) if len(a)>2 else 100); (p/'state').write_text(json.dumps(s))
elif a[0]=='brightness': s['last_percent']=int(a[1]); (p/'state').write_text(json.dumps(s))
elif a[0]=='off': s['last_percent']=0; (p/'state').write_text(json.dumps(s))
elif a[0]=='auto': s.update(commanded_mode='firmware',last_color=None,last_percent=None); (p/'state').write_text(json.dumps(s))
else: sys.exit(2)
''')
(p/'fake-cli').chmod(0o700)
(p/'shell.qml').write_text('''import QtQuick
import Quickshell
import "rgbkeyboard"
ShellRoot {
id: root
property int step: 0
property int presetIndex: 0
property int ticks: 0
function check(ok, message) { if (!ok) { console.error("RGB_FAIL " + message); Qt.exit(1); } }
Timer {
interval: 80; running: true; repeat: true
onTriggered: {
root.ticks++;
if (root.ticks > 100) { console.error("RGB_TIMEOUT"); Qt.exit(1); return; }
if (RgbKeyboardService.busy || RgbKeyboardService.refreshPending) return;
if (root.step === 0) {
root.check(!RgbKeyboardService.restoreEnabled, "default opt out");
root.check(RgbKeyboardService.palette.length === 8, "eight presets");
const golden = ["#8000FF","#0040FF","#00FFFF","#00FF40","#FF0080","#FF0000","#FF6000","#FFFF00"];
for (let i = 0; i < golden.length; ++i) root.check(RgbKeyboardService.palette[i].color === golden[i], "preset wire color");
root.check(!RgbKeyboardService.applyColor("#bad", 60), "reject bad color");
root.check(!RgbKeyboardService.intensity(101), "reject intensity");
RgbKeyboardService.refresh(); root.step++;
} else if (root.step === 1 && RgbKeyboardService.available) {
root.check(RgbKeyboardService.preset("#8000ff"), "apply");
root.check(!RgbKeyboardService.firmware(), "serialize writes"); root.step++;
} else if (root.step === 2 && RgbKeyboardService.lastColor === RgbKeyboardService.palette[root.presetIndex].color) {
root.check(RgbKeyboardService.lastPercent === 100, "preset CLI default percent");
root.presetIndex++;
if (root.presetIndex < RgbKeyboardService.palette.length) {
root.check(RgbKeyboardService.preset(RgbKeyboardService.palette[root.presetIndex].color), "next preset");
} else { RgbKeyboardService.intensity(30); root.step++; }
} else if (root.step === 3 && RgbKeyboardService.lastPercent === 30) {
RgbKeyboardService.off(); root.step++;
} else if (root.step === 4 && RgbKeyboardService.lastPercent === 0) {
RgbKeyboardService.firmware(); root.step++;
} else if (root.step === 5 && RgbKeyboardService.commandedMode === "firmware") {
root.check(RgbKeyboardService.lastColor === "", "no physical firmware color claim");
root.check(!RgbKeyboardService.restoreEnabled, "opt in not activated");
RgbKeyboardService.applyColor("#010101", 100); root.step++;
} else if (root.step === 6 && RgbKeyboardService.message.indexOf("code 4") >= 0) {
RgbKeyboardService.applyColor("#000001", 100); root.step++;
} else if (root.step === 7 && !RgbKeyboardService.available) {
root.check(!RgbKeyboardService.busy, "failed executable start releases ownership");
console.log("RGB_PASS"); Qt.exit(0);
}
}
}
}
''')
env = dict(os.environ, RGB_FIXTURE=str(p), XDG_CONFIG_HOME=str(p/'config'),
XDG_RUNTIME_DIR=str(p/'runtime'), QT_QPA_PLATFORM='offscreen')
result = subprocess.run([qs, '-p', str(p/'shell.qml')], env=env,
capture_output=True, text=True, timeout=15)
log = result.stdout + result.stderr
self.assertEqual(result.returncode, 0, log)
self.assertIn('RGB_PASS', log)
self.assertNotIn('Failed to initialize QSettings', log)
commands = [json.loads(line) for line in (p/'calls').read_text().splitlines()]
writes = [args for args in commands if args[0] != 'status']
self.assertEqual(writes, [['set',c] for c in ['#8000FF','#0040FF','#00FFFF','#00FF40','#FF0080','#FF0000','#FF6000','#FFFF00']] + [['brightness','30'],['off'],['auto'],['set','#010101','100'],['set','#000001','100']])
self.assertTrue(all(args == ['status','--json'] for args in commands if args[0]=='status'))
def test_startup_opt_in_firmware_priority_and_invalid_preferences(self):
qs = shutil.which('qs')
if not qs:
self.skipTest('QuickShell unavailable')
cases = [(False, 'software', 'unknown', '#B4BEFE', False),
(True, 'software', 'unknown', '#B4BEFE', True),
(True, 'firmware', 'unknown', '#B4BEFE', False),
(True, 'software', 'firmware', '#B4BEFE', False),
(True, 'software', 'unknown', '#INVALID', False)]
for enabled, saved_mode, native_mode, color, expect_set in cases:
with self.subTest(enabled=enabled, saved_mode=saved_mode, native_mode=native_mode, color=color), tempfile.TemporaryDirectory(prefix='labfy-rgb-startup-') as folder:
p = Path(folder)
(p/'runtime').mkdir(mode=0o700)
(p/'config/labfy-rgb').mkdir(parents=True)
(p/'rgbkeyboard').mkdir()
shutil.copy(BASE/'rgbkeyboard/qmldir', p/'rgbkeyboard/qmldir')
source = (BASE/'rgbkeyboard/RgbKeyboardService.qml').read_text()
old = 'readonly property string executable: (Quickshell.env("HOME") || "") + "/.local/bin/labfy-rgb"'
self.assertIn(old, source)
(p/'rgbkeyboard/RgbKeyboardService.qml').write_text(source.replace(old, 'readonly property string executable: '+json.dumps(str(p/'fake-cli'))))
(p/'config/labfy-rgb/quickshell.ini').write_text('[General]\nrestoreEnabled='+str(enabled).lower()+'\nsavedMode='+saved_mode+'\nsavedColor='+color+'\nsavedPercent=60\n')
(p/'fake-cli').write_text('''#!/usr/bin/python3
import os,sys,json
from pathlib import Path
p=Path(os.environ['RGB_FIXTURE'])
a=sys.argv[1:]
with (p/'calls').open('a') as f: f.write(json.dumps(a)+'\\n')
if a[0]=='status': print(json.dumps(dict(schema=1,available=True,device='/dev/hidraw77',commanded_mode=os.environ['NATIVE_MODE'],last_color=None,last_percent=None)))
''')
(p/'fake-cli').chmod(0o700)
(p/'shell.qml').write_text('''import QtQuick
import Quickshell
import "rgbkeyboard"
ShellRoot {
Component.onCompleted: console.log("OPT_IN", RgbKeyboardService.restoreEnabled)
Timer { running: true; interval: 2400; onTriggered: Qt.exit(0) }
}
''')
env = dict(os.environ, RGB_FIXTURE=str(p), NATIVE_MODE=native_mode,
XDG_CONFIG_HOME=str(p/'config'), XDG_RUNTIME_DIR=str(p/'runtime'), QT_QPA_PLATFORM='offscreen')
result = subprocess.run([qs,'-p',str(p/'shell.qml')],env=env,capture_output=True,text=True,timeout=8)
self.assertEqual(result.returncode,0,result.stdout+result.stderr)
commands = [json.loads(line) for line in (p/'calls').read_text().splitlines()]
writes = [args for args in commands if args[0] != 'status']
self.assertEqual(writes, [['set','#B4BEFE','60']] if expect_set else [])
if __name__ == '__main__':
unittest.main()

View file

@ -0,0 +1,112 @@
"""Run the production popup with fake commands; no package operation is reachable."""
import json
import os
from pathlib import Path
import subprocess
import shutil
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
BASE = ROOT / 'quickshell/.config/quickshell/labfy-sway'
QS = Path.home() / '.local/opt/quickshell-qt612-popup/bin/quickshell'
class UpdateUITests(unittest.TestCase):
def test_popup_states_filters_long_list_and_preview_argv(self):
if not QS.exists() or not os.environ.get('WAYLAND_DISPLAY'):
self.skipTest('QuickShell et session Wayland requis')
with tempfile.TemporaryDirectory(prefix='labfy-updates-ui-') as folder:
p = Path(folder)
(p/'runtime').mkdir(mode=0o700)
shutil.copytree(BASE, p/'config')
# All production Process commands are intercepted, including python3.
tool = '''#!/usr/bin/python3
import os,sys,json
from pathlib import Path
p=Path(os.environ['UPDATE_UI_FIXTURE'])
name=Path(sys.argv[0]).name
with (p/'commands').open('a') as f: f.write(json.dumps([name,*sys.argv[1:]])+'\\n')
if name=='python3':
if sys.argv[-1]=='maintenance': print(json.dumps(dict(time='fixture',history=['transaction completed'],files=['/etc/test.pacnew'],failed=[],errors=[],reboot='Unknown')))
elif sys.argv[-1]=='news': print(json.dumps(dict(items=[dict(title='Manual intervention required',date='today',link='https://archlinux.org/news/test/',manual=True)])))
'''
for name in ('python3', 'kitty', 'systemctl'):
(p/name).write_text(tool)
(p/name).chmod(0o755)
qml = '''import QtQuick
import Quickshell
import "SOURCE" as Production
ShellRoot {
PanelWindow { id: bar; anchors.top: true; anchors.left: true; anchors.right: true; implicitHeight: 30; exclusiveZone: 0; focusable: true }
QtObject { id: fakeIndicator; function reload() { } }
Production.UpdateCenter {
id: center; barWindow: bar; indicator: fakeIndicator
// No input serial exists in this synthetic window; real bar retains grabFocus.
grabFocus: false
updateState: ({repoUpdates: 0, aurUpdates: 0, totalUpdates: 0, packages: [], lastCheck: '', error: ''})
}
function check(value, message) { if (!value) { console.error('FAIL ' + message); Qt.exit(1); } }
function find(item, name) {
if (item.objectName === name) return item;
for (const child of (item.children || [])) { const found = find(child, name); if (found) return found; }
return null;
}
property int step: 0
Timer {
interval: 350; running: true; repeat: true
onTriggered: {
if (step === 0) { center.visible = true; }
else if (step === 1) {
check(center.visible, 'popup opens'); check(center.filtered.length === 0, 'zero');
const rows = []; for (let i=0; i<600; i++) rows.push({name: 'pkg-' + i, installed:'1', available:'2', origin: i%2 ? 'AUR' : 'Arch', sensitive: i===0 ? 'Noyau' : ''});
center.updateState = {repoUpdates:300,aurUpdates:300,totalUpdates:600,packages:rows,lastCheck:'2026-11-08T01:00:00Z',error:''};
} else if (step === 2) {
check(center.filtered.length === 600, 'long list');
const list = find(center.contentItem, 'updatePackageList'); check(list && list.count === 600, 'production list');
list.positionViewAtEnd(); center.origin = 'AUR';
} else if (step === 3) {
check(center.filtered.length === 300, 'AUR filter'); center.query = 'pkg-599';
} else if (step === 4) {
check(center.filtered.length === 1, 'search'); center.origin = 'Arch';
} else if (step === 5) {
check(center.filtered.length === 0, 'Arch filter');
center.updateState = {repoUpdates:0,aurUpdates:0,totalUpdates:0,packages:[],lastCheck:'',error:'network failure',offline:true};
const tabs = find(center.contentItem, 'updateTabs'); check(!!tabs, 'tabs exist'); tabs.currentIndex = 1;
} else if (step === 6) {
check(center.maintenance.files.length === 1, 'pacnew'); check(center.maintenance.history.length === 1, 'history');
find(center.contentItem, 'updateTabs').currentIndex = 2;
} else if (step === 7) {
check(center.news.items.length === 1, 'news'); center.refresh(); center.refresh();
} else if (step === 8) {
center.updateState = {repoUpdates:0,aurUpdates:0,totalUpdates:0,packages:[],lastCheck:'',error:'',checking:true,checkStarted:'2000-01-01T00:00:00Z'};
check(center.staleCheck && !center.checking, 'interrupted check recoverable');
center.visible = false;
} else if (step === 9) { check(!center.visible, 'popup closes'); center.visible = true; }
else if (step === 10) { check(center.visible, 'popup reopens'); center.terminal(true); }
else { console.info('PASS update popup states'); Qt.quit(); }
step++;
}
}
}
'''.replace('SOURCE', 'config/status')
(p/'shell.qml').write_text(qml)
env = dict(os.environ, PATH=f'{p}:/usr/bin', UPDATE_UI_FIXTURE=folder,
QT_QPA_PLATFORM='wayland', WAYLAND_DISPLAY=str(Path(os.environ['XDG_RUNTIME_DIR']) / os.environ['WAYLAND_DISPLAY']), XDG_RUNTIME_DIR=str(p/'runtime'))
result = subprocess.run([str(QS), '-p', str(p), '--no-color'], env=env, capture_output=True, text=True, timeout=20)
logs = result.stdout + result.stderr
self.assertEqual(result.returncode, 0, logs)
self.assertIn('PASS update popup states', logs)
self.assertNotIn('ReferenceError', logs)
self.assertNotIn('TypeError', logs)
commands = [json.loads(line) for line in (p/'commands').read_text().splitlines()]
terminals = [cmd for cmd in commands if cmd[0] == 'kitty']
self.assertEqual(len(terminals), 1, logs)
self.assertEqual(terminals[0][-1], '--dry-run')
self.assertEqual(terminals[0][1:3], ['--class', 'labfy-update-center'])
refreshes = [cmd for cmd in commands if cmd == ['systemctl', '--user', 'start', 'labfy-quickshell-updates.service']]
self.assertEqual(len(refreshes), 1)
if __name__ == '__main__':
unittest.main()

View file

@ -1,44 +1,151 @@
"""Non-destructive tests of the actual update collector and terminal boundary."""
import importlib.util
import io
import json
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SOURCE = Path(__file__).resolve().parents[1] / "quickshell/.config/quickshell/labfy-sway/bin/check-updates.py"
SPEC = importlib.util.spec_from_file_location("quickshell_updates", SOURCE)
updates = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(updates)
ROOT = Path(__file__).resolve().parents[1]
BASE = ROOT / "quickshell/.config/quickshell/labfy-sway"
class CountTests(unittest.TestCase):
def check_count(self, command, returncode, stdout, stderr, expected):
result = subprocess.CompletedProcess(command, returncode, stdout, stderr)
with mock.patch.object(updates.subprocess, "run", return_value=result) as run:
if isinstance(expected, type) and issubclass(expected, Exception):
with self.assertRaises(expected):
updates.count(command)
else:
self.assertEqual(updates.count(command), expected)
run.assert_called_once_with(command, capture_output=True, text=True, timeout=120, check=False)
def load(name, path):
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_checkupdates_counts_lines(self):
self.check_count(["checkupdates"], 0, "pkg-a 1 -> 2\npkg-b 3 -> 4\n", "", 2)
def test_checkupdates_no_updates(self):
self.check_count(["checkupdates"], 2, "", "", 0)
updates = load("quickshell_updates", BASE / "bin/check-updates.py")
terminal = load("quickshell_update_terminal", BASE / "bin/update-terminal.py")
def test_checkupdates_code_one_is_error(self):
self.check_count(["checkupdates"], 1, "", "", RuntimeError)
def test_checkupdates_code_two_with_stderr_is_error(self):
self.check_count(["checkupdates"], 2, "", "erreur inattendue", RuntimeError)
class CollectorTests(unittest.TestCase):
def collect(self, command, code, stdout="", stderr=""):
with mock.patch.object(updates.subprocess, "run", return_value=subprocess.CompletedProcess(command, code, stdout, stderr)):
return updates.packages(command, "Arch" if command[0] == "checkupdates" else "AUR")
def test_yay_no_updates(self):
self.check_count(["yay", "-Qua"], 1, "", "", 0)
def test_zero(self):
self.assertEqual(self.collect(["checkupdates"], 2), [])
self.assertEqual(self.collect(["yay", "-Qua"], 1), [])
self.assertEqual(self.collect(["yay", "-Qua"], 0), [])
def test_yay_counts_lines(self):
self.check_count(["yay", "-Qua"], 0, "aur-a 1 -> 2\naur-b 3 -> 4\n", "", 2)
def test_versions_and_sensitive(self):
rows = self.collect(["checkupdates"], 0, "linux-zen 1 -> 2\nmesa 3 -> 4\nquickshell-git 4 -> 5\n")
self.assertEqual([p["sensitive"] for p in rows], ["Noyau", "Graphique", "Session"])
self.assertEqual(rows[0]["installed"], "1")
self.assertEqual(rows[0]["available"], "2")
self.assertEqual(rows[0]["origin"], "Arch")
self.assertEqual(self.collect(["yay", "-Qua"], 0, "pkg-git 1 -> 2\n")[0]["origin"], "AUR")
def test_errors_not_zero(self):
for command, code, stdout, stderr in ((["checkupdates"], 1, "", ""), (["checkupdates"], 2, "", "failure"),
(["yay"], 1, "", "network"), (["yay"], 0, "bad output", "")):
with self.subTest(command=command, code=code, stderr=stderr):
with self.assertRaises(RuntimeError):
self.collect(command, code, stdout, stderr)
def test_snapshot_success_partial_failure_offline_recovery(self):
with tempfile.TemporaryDirectory() as folder:
runtime = Path(folder)
row = dict(name="linux", installed="1", available="2", origin="Arch", sensitive="Noyau")
with mock.patch.object(updates, "packages", side_effect=[[row], []]):
self.assertFalse(updates.check(runtime))
path = runtime / "labfy-quickshell-updates.json"
self.assertEqual(json.loads(path.read_text())["totalUpdates"], 1)
with mock.patch.object(updates, "packages", side_effect=[RuntimeError("network"), []]), mock.patch.object(updates, "offline", return_value=True):
self.assertTrue(updates.check(runtime))
state = json.loads(path.read_text())
self.assertTrue(state["offline"])
self.assertFalse(state["checking"])
self.assertEqual(state["packages"], [row])
self.assertEqual(state["totalUpdates"], 1)
with mock.patch.object(updates, "packages", side_effect=[[], []]):
self.assertFalse(updates.check(runtime))
self.assertEqual(json.loads(path.read_text())["totalUpdates"], 0)
def test_corrupt_snapshot_recovers(self):
with tempfile.TemporaryDirectory() as folder:
runtime = Path(folder)
path = runtime / "labfy-quickshell-updates.json"
updates.publish(path, dict(repoUpdates="invalid", packages=[None]))
with mock.patch.object(updates, "packages", side_effect=[[], []]):
self.assertFalse(updates.check(runtime))
self.assertEqual(json.loads(path.read_text())["totalUpdates"], 0)
def test_lock_prevents_second_check(self):
with tempfile.TemporaryDirectory() as folder, mock.patch.dict(os.environ, XDG_RUNTIME_DIR=folder), mock.patch.object(updates.sys, "argv", ["check-updates.py"]):
with open(Path(folder)/"labfy-updates.lock", "w") as lock:
updates.fcntl.flock(lock, updates.fcntl.LOCK_EX)
with mock.patch.object(updates, "check") as check:
self.assertEqual(updates.main(), 0)
check.assert_not_called()
def test_offline_probe(self):
with mock.patch.object(updates.socket, "create_connection", side_effect=OSError("offline")):
self.assertTrue(updates.offline())
def test_news_explicit_only_and_safe_links(self):
data = b'''<rss><channel><item><title>linux requires manual intervention</title><link>https://archlinux.org/news/test/</link><pubDate>today</pubDate></item><item><title>linux update</title><link>https://archlinux.org/news/other/</link></item><item><title>bad</title><link>file:///etc/passwd</link></item></channel></rss>'''
rows = updates.parse_news(data)
self.assertEqual(len(rows), 2)
self.assertTrue(rows[0]["manual"])
self.assertFalse(rows[1]["manual"])
def test_news_offline_cache_and_backoff(self):
with tempfile.TemporaryDirectory() as folder, mock.patch.dict(os.environ, XDG_CACHE_HOME=folder):
cache = Path(folder)/"labfy-update-news.json"
updates.publish(cache, dict(fetched=1, items=[dict(title="cached")]))
with mock.patch.object(updates.urllib.request, "urlopen", side_effect=OSError("offline")) as urlopen:
first = updates.news()
second = updates.news()
self.assertEqual(first["items"], second["items"])
self.assertIn("cache conservé", second["error"])
self.assertEqual(urlopen.call_count, 1)
def test_maintenance_read_only_and_partial_scan(self):
# Synthetic /etc fixture includes forbidden-to-change files and an access error.
def walk(root, **kwargs):
self.assertEqual(root, "/etc")
self.assertFalse(kwargs["followlinks"])
kwargs["onerror"](PermissionError("restricted"))
yield "/etc", [], ["foo.pacnew", "bar.pacsave", "normal"]
log = mock.mock_open(read_data=b"[2026-11-08] [ALPM] transaction completed\n")
log.return_value.tell.return_value = 55
with mock.patch("builtins.open", log), mock.patch.object(updates.os, "walk", side_effect=walk), mock.patch.object(updates, "output", return_value="bad.service loaded failed failed demo\n"):
result = updates.maintenance()
self.assertEqual(result["files"], ["/etc/bar.pacsave", "/etc/foo.pacnew"])
self.assertEqual(len(result["failed"]), 2)
self.assertTrue(result["errors"])
self.assertTrue(result["history"])
self.assertTrue(result["reboot"])
class TerminalTests(unittest.TestCase):
def test_preview_never_installs(self):
with mock.patch.object(terminal.sys, "argv", ["terminal", "--dry-run"]), mock.patch("builtins.input", return_value=""), mock.patch.object(terminal.subprocess, "call") as call, mock.patch.object(terminal.subprocess, "run") as run, mock.patch("sys.stdout", new_callable=io.StringIO):
self.assertEqual(terminal.main(), 0)
call.assert_not_called()
run.assert_not_called()
def test_declined_confirmation_never_installs(self):
with mock.patch.object(terminal.sys, "argv", ["terminal"]), mock.patch.object(terminal.sys.stdin, "isatty", return_value=True), mock.patch.object(terminal.os, "geteuid", return_value=1000), mock.patch.object(terminal.shutil, "which", return_value="/usr/bin/yay"), mock.patch("builtins.input", return_value="non"), mock.patch.object(terminal.subprocess, "call") as call, mock.patch("sys.stdout", new_callable=io.StringIO):
self.assertEqual(terminal.main(), 0)
call.assert_not_called()
def test_confirmed_argv_with_fake_process_only(self):
# Even the affirmative branch is intercepted: no real package manager is run.
with tempfile.TemporaryDirectory() as folder, mock.patch.dict(os.environ, XDG_RUNTIME_DIR=folder), mock.patch.object(terminal.sys, "argv", ["terminal"]), mock.patch.object(terminal.sys.stdin, "isatty", return_value=True), mock.patch.object(terminal.os, "geteuid", return_value=1000), mock.patch.object(terminal.shutil, "which", return_value="/usr/bin/yay"), mock.patch("builtins.input", side_effect=["METTRE A JOUR", ""]), mock.patch.object(terminal.subprocess, "call", return_value=0) as call, mock.patch.object(terminal.subprocess, "run") as run, mock.patch("sys.stdout", new_callable=io.StringIO):
self.assertEqual(terminal.main(), 0)
argv = call.call_args.args[0]
self.assertEqual(argv[:3], ["yay", "-Syu", "--confirm"])
self.assertIn("--sudoloop=false", argv)
self.assertNotIn("--noconfirm", argv)
run.assert_called_once()
if __name__ == "__main__":