From 121bc2a2efbad0c04a6bbb35f248254974010ace Mon Sep 17 00:00:00 2001 From: fy59 Date: Mon, 9 Nov 2026 19:21:49 +0100 Subject: [PATCH] secure push --- bin/.local/bin/hyprwhspr | 1 + bin/.local/bin/labfy-rgb | 1 + bin/.local/bin/trier-photos | 18 +- bin/.local/bin/trier-photos.bak-20261104-tiff | 241 +++++++++++++++++ docs/quickshell-update-center-v1.md | 148 ++++++++++ hyprwhspr/.config/hyprwhspr/config.json | 2 +- .../.config/quickshell/labfy-sway/Bar.qml | 38 ++- .../.config/quickshell/labfy-sway/README.md | 14 + .../labfy-sway/bin/bluetooth-send.py | 86 ++++++ .../labfy-sway/bin/check-updates.py | 253 +++++++++++++++--- .../labfy-sway/bin/update-terminal.py | 59 ++++ .../controlcenter/BluetoothPage.qml | 50 +++- .../controlcenter/ControlCenter.qml | 18 +- .../labfy-sway/controlcenter/RGB_KEYBOARD.md | 80 ++++++ .../controlcenter/RgbKeyboardPage.qml | 112 ++++++++ .../rgbkeyboard/RgbKeyboardService.qml | 156 +++++++++++ .../quickshell/labfy-sway/rgbkeyboard/qmldir | 1 + .../labfy-sway/status/RightStatusArea.qml | 10 +- .../labfy-sway/status/UpdateAction.qml | 19 ++ .../labfy-sway/status/UpdateCenter.qml | 235 ++++++++++++++++ .../labfy-sway/status/UpdatesIndicator.qml | 24 +- sway/.config/sway/rules | 5 + .../systemd/user/dbus-org.bluez.obex.service | 1 + .../hyprwhspr.service | 1 + .../.config/systemd/user/hyprwhspr.service | 4 +- .../50-sway-focus-paste.conf | 4 + tests/test_quickshell_rgb_keyboard.py | 159 +++++++++++ tests/test_quickshell_update_center_ui.py | 112 ++++++++ tests/test_quickshell_updates.py | 161 +++++++++-- 29 files changed, 1934 insertions(+), 79 deletions(-) create mode 120000 bin/.local/bin/hyprwhspr create mode 120000 bin/.local/bin/labfy-rgb create mode 100755 bin/.local/bin/trier-photos.bak-20261104-tiff create mode 100644 docs/quickshell-update-center-v1.md create mode 100644 quickshell/.config/quickshell/labfy-sway/bin/bluetooth-send.py create mode 100644 quickshell/.config/quickshell/labfy-sway/bin/update-terminal.py create mode 100644 quickshell/.config/quickshell/labfy-sway/controlcenter/RGB_KEYBOARD.md create mode 100644 quickshell/.config/quickshell/labfy-sway/controlcenter/RgbKeyboardPage.qml create mode 100644 quickshell/.config/quickshell/labfy-sway/rgbkeyboard/RgbKeyboardService.qml create mode 100644 quickshell/.config/quickshell/labfy-sway/rgbkeyboard/qmldir create mode 100644 quickshell/.config/quickshell/labfy-sway/status/UpdateAction.qml create mode 100644 quickshell/.config/quickshell/labfy-sway/status/UpdateCenter.qml create mode 120000 systemd/.config/systemd/user/dbus-org.bluez.obex.service create mode 120000 systemd/.config/systemd/user/graphical-session.target.wants/hyprwhspr.service create mode 100644 systemd/.config/systemd/user/hyprwhspr.service.d/50-sway-focus-paste.conf create mode 100644 tests/test_quickshell_rgb_keyboard.py create mode 100644 tests/test_quickshell_update_center_ui.py diff --git a/bin/.local/bin/hyprwhspr b/bin/.local/bin/hyprwhspr new file mode 120000 index 000000000..712cdd0a8 --- /dev/null +++ b/bin/.local/bin/hyprwhspr @@ -0,0 +1 @@ +/home/fy59/.local/share/hyprwhspr-swayfx/1.52.1-sway-v1/launcher \ No newline at end of file diff --git a/bin/.local/bin/labfy-rgb b/bin/.local/bin/labfy-rgb new file mode 120000 index 000000000..2ad976c8c --- /dev/null +++ b/bin/.local/bin/labfy-rgb @@ -0,0 +1 @@ +/home/fy59/labfy-rgb/build/labfy-rgb \ No newline at end of file diff --git a/bin/.local/bin/trier-photos b/bin/.local/bin/trier-photos index 871076e5b..dedde006c 100755 --- a/bin/.local/bin/trier-photos +++ b/bin/.local/bin/trier-photos @@ -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"(? 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"(? " + 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()) diff --git a/docs/quickshell-update-center-v1.md b/docs/quickshell-update-center-v1.md new file mode 100644 index 000000000..d574cba25 --- /dev/null +++ b/docs/quickshell-update-center-v1.md @@ -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-` : `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 : . 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. diff --git a/hyprwhspr/.config/hyprwhspr/config.json b/hyprwhspr/.config/hyprwhspr/config.json index da6dc6f9d..774093c4a 100644 --- a/hyprwhspr/.config/hyprwhspr/config.json +++ b/hyprwhspr/.config/hyprwhspr/config.json @@ -14,4 +14,4 @@ "stop_sound_path": "ping-down.ogg", "audio_ducking": true, "whisper_prompt_fr": "Transcrire en fran\u00e7ais avec des phrases compl\u00e8tes, une ponctuation claire et des majuscules correctes au d\u00e9but des phrases, pour les noms propres et les titres, comme dans un texte r\u00e9dig\u00e9" -} +} \ No newline at end of file diff --git a/quickshell/.config/quickshell/labfy-sway/Bar.qml b/quickshell/.config/quickshell/labfy-sway/Bar.qml index c0bf4f92f..664d6ed7a 100644 --- a/quickshell/.config/quickshell/labfy-sway/Bar.qml +++ b/quickshell/.config/quickshell/labfy-sway/Bar.qml @@ -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); } diff --git a/quickshell/.config/quickshell/labfy-sway/README.md b/quickshell/.config/quickshell/labfy-sway/README.md index 6adff6e17..dc86185b2 100644 --- a/quickshell/.config/quickshell/labfy-sway/README.md +++ b/quickshell/.config/quickshell/labfy-sway/README.md @@ -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. diff --git a/quickshell/.config/quickshell/labfy-sway/bin/bluetooth-send.py b/quickshell/.config/quickshell/labfy-sway/bin/bluetooth-send.py new file mode 100644 index 000000000..5e6646e21 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/bin/bluetooth-send.py @@ -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) diff --git a/quickshell/.config/quickshell/labfy-sway/bin/check-updates.py b/quickshell/.config/quickshell/labfy-sway/bin/check-updates.py index de454e73f..5a0b483d9 100644 --- a/quickshell/.config/quickshell/labfy-sway/bin/check-updates.py +++ b/quickshell/.config/quickshell/labfy-sway/bin/check-updates.py @@ -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__": diff --git a/quickshell/.config/quickshell/labfy-sway/bin/update-terminal.py b/quickshell/.config/quickshell/labfy-sway/bin/update-terminal.py new file mode 100644 index 000000000..8821c5e9f --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/bin/update-terminal.py @@ -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) diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/BluetoothPage.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/BluetoothPage.qml index 308f0b16b..f74ccff03 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/BluetoothPage.qml +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/BluetoothPage.qml @@ -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 } diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml index 4c21d892b..945bc5091 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml @@ -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") + } } } } diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/RGB_KEYBOARD.md b/quickshell/.config/quickshell/labfy-sway/controlcenter/RGB_KEYBOARD.md new file mode 100644 index 000000000..20b40d220 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/RGB_KEYBOARD.md @@ -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. diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/RgbKeyboardPage.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/RgbKeyboardPage.qml new file mode 100644 index 000000000..d56851acd --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/RgbKeyboardPage.qml @@ -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 } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/rgbkeyboard/RgbKeyboardService.qml b/quickshell/.config/quickshell/labfy-sway/rgbkeyboard/RgbKeyboardService.qml new file mode 100644 index 000000000..f40db004c --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/rgbkeyboard/RgbKeyboardService.qml @@ -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}); + } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/rgbkeyboard/qmldir b/quickshell/.config/quickshell/labfy-sway/rgbkeyboard/qmldir new file mode 100644 index 000000000..0c2eedbef --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/rgbkeyboard/qmldir @@ -0,0 +1 @@ +singleton RgbKeyboardService 1.0 RgbKeyboardService.qml diff --git a/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml b/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml index 53f165af3..b5ab1d53c 100644 --- a/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml +++ b/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml @@ -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() diff --git a/quickshell/.config/quickshell/labfy-sway/status/UpdateAction.qml b/quickshell/.config/quickshell/labfy-sway/status/UpdateAction.qml new file mode 100644 index 000000000..15dfb0297 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/status/UpdateAction.qml @@ -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() } +} diff --git a/quickshell/.config/quickshell/labfy-sway/status/UpdateCenter.qml b/quickshell/.config/quickshell/labfy-sway/status/UpdateCenter.qml new file mode 100644 index 000000000..f55cef257 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/status/UpdateCenter.qml @@ -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); } } + } + } + } + } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/status/UpdatesIndicator.qml b/quickshell/.config/quickshell/labfy-sway/status/UpdatesIndicator.qml index 75664bdc4..9fb2cf003 100644 --- a/quickshell/.config/quickshell/labfy-sway/status/UpdatesIndicator.qml +++ b/quickshell/.config/quickshell/labfy-sway/status/UpdatesIndicator.qml @@ -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 } } diff --git a/sway/.config/sway/rules b/sway/.config/sway/rules index af15cc3e1..f6072125c 100644 --- a/sway/.config/sway/rules +++ b/sway/.config/sway/rules @@ -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 #================================================= diff --git a/systemd/.config/systemd/user/dbus-org.bluez.obex.service b/systemd/.config/systemd/user/dbus-org.bluez.obex.service new file mode 120000 index 000000000..b55f90862 --- /dev/null +++ b/systemd/.config/systemd/user/dbus-org.bluez.obex.service @@ -0,0 +1 @@ +/usr/lib/systemd/user/obex.service \ No newline at end of file diff --git a/systemd/.config/systemd/user/graphical-session.target.wants/hyprwhspr.service b/systemd/.config/systemd/user/graphical-session.target.wants/hyprwhspr.service new file mode 120000 index 000000000..a30c8fa3a --- /dev/null +++ b/systemd/.config/systemd/user/graphical-session.target.wants/hyprwhspr.service @@ -0,0 +1 @@ +/home/fy59/.config/systemd/user/hyprwhspr.service \ No newline at end of file diff --git a/systemd/.config/systemd/user/hyprwhspr.service b/systemd/.config/systemd/user/hyprwhspr.service index 35b701277..59ddff74e 100644 --- a/systemd/.config/systemd/user/hyprwhspr.service +++ b/systemd/.config/systemd/user/hyprwhspr.service @@ -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 diff --git a/systemd/.config/systemd/user/hyprwhspr.service.d/50-sway-focus-paste.conf b/systemd/.config/systemd/user/hyprwhspr.service.d/50-sway-focus-paste.conf new file mode 100644 index 000000000..f10490f8c --- /dev/null +++ b/systemd/.config/systemd/user/hyprwhspr.service.d/50-sway-focus-paste.conf @@ -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" diff --git a/tests/test_quickshell_rgb_keyboard.py b/tests/test_quickshell_rgb_keyboard.py new file mode 100644 index 000000000..41f08dbfb --- /dev/null +++ b/tests/test_quickshell_rgb_keyboard.py @@ -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() diff --git a/tests/test_quickshell_update_center_ui.py b/tests/test_quickshell_update_center_ui.py new file mode 100644 index 000000000..43cc935fc --- /dev/null +++ b/tests/test_quickshell_update_center_ui.py @@ -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() diff --git a/tests/test_quickshell_updates.py b/tests/test_quickshell_updates.py index d2aad21d6..99e3fb2ee 100644 --- a/tests/test_quickshell_updates.py +++ b/tests/test_quickshell_updates.py @@ -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'''linux requires manual interventionhttps://archlinux.org/news/test/todaylinux updatehttps://archlinux.org/news/other/badfile:///etc/passwd''' + 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__":