secure push
This commit is contained in:
parent
bc98626c80
commit
121bc2a2ef
29 changed files with 1934 additions and 79 deletions
1
bin/.local/bin/hyprwhspr
Symbolic link
1
bin/.local/bin/hyprwhspr
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/home/fy59/.local/share/hyprwhspr-swayfx/1.52.1-sway-v1/launcher
|
||||
1
bin/.local/bin/labfy-rgb
Symbolic link
1
bin/.local/bin/labfy-rgb
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/home/fy59/labfy-rgb/build/labfy-rgb
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
"""Classement manuel de A_trier, compatible avec la photothèque Labfy.
|
||||
|
||||
Photos et vidéos -> Photos/AAAA/AAAA-MM-JJ/
|
||||
RAW et XMP liés -> Originaux/AAAA/AAAA-MM-JJ/
|
||||
RAW, TIFF et XMP liés -> Originaux/AAAA/AAAA-MM-JJ/
|
||||
Aucun fichier existant n'est remplacé. Les autres fichiers restent dans A_trier.
|
||||
"""
|
||||
|
||||
|
|
@ -22,10 +22,12 @@ import unicodedata
|
|||
BASE = Path.home() / "Pictures" / "Phototheque"
|
||||
RAW = {".arw", ".dng", ".cr2", ".cr3", ".nef", ".nrw", ".raf",
|
||||
".orf", ".rw2", ".pef", ".srw", ".3fr", ".iiq"}
|
||||
PHOTO = {".jpg", ".jpeg", ".jpe", ".png", ".tif", ".tiff",
|
||||
TIFF = {".tif", ".tiff"}
|
||||
ORIGINAL = RAW | TIFF
|
||||
PHOTO = {".jpg", ".jpeg", ".jpe", ".png",
|
||||
".heic", ".heif", ".avif", ".webp", ".bmp"}
|
||||
VIDEO = {".mov", ".mp4", ".m4v", ".avi", ".mts", ".m2ts", ".3gp"}
|
||||
MEDIA = RAW | PHOTO | VIDEO
|
||||
MEDIA = ORIGINAL | PHOTO | VIDEO
|
||||
DATE_NAME = re.compile(r"(?<!\d)((?:19|20)\d{2})[-_]?(\d{2})[-_]?(\d{2})(?!\d)")
|
||||
EXIFTOOL = shutil.which("exiftool")
|
||||
|
||||
|
|
@ -89,17 +91,17 @@ def renamed(path, index):
|
|||
|
||||
|
||||
def target_root(path, group, photos, originaux):
|
||||
if path.suffix.lower() in RAW:
|
||||
if path.suffix.lower() in ORIGINAL:
|
||||
return originaux
|
||||
if path.suffix.lower() != ".xmp":
|
||||
return photos
|
||||
associated = Path(path.name[:-4]).suffix.lower()
|
||||
if associated in RAW:
|
||||
if associated in ORIGINAL:
|
||||
return originaux
|
||||
if associated in PHOTO | VIDEO:
|
||||
return photos
|
||||
# XMP générique : privilégier les RAW s'ils sont présents.
|
||||
return originaux if any(p.suffix.lower() in RAW for p in group) else photos
|
||||
# XMP générique : privilégier les originaux (RAW ou TIFF) présents.
|
||||
return originaux if any(p.suffix.lower() in ORIGINAL for p in group) else photos
|
||||
|
||||
|
||||
def key(name):
|
||||
|
|
@ -178,7 +180,7 @@ def run(args):
|
|||
if not media:
|
||||
orphans += len(group)
|
||||
continue # Ne pas déplacer les XMP orphelins.
|
||||
media.sort(key=lambda p: (p.suffix.lower() not in RAW,
|
||||
media.sort(key=lambda p: (p.suffix.lower() not in ORIGINAL,
|
||||
p.suffix.lower() in VIDEO, p.name))
|
||||
try:
|
||||
taken, origin = date_of(media[0])
|
||||
|
|
|
|||
241
bin/.local/bin/trier-photos.bak-20261104-tiff
Executable file
241
bin/.local/bin/trier-photos.bak-20261104-tiff
Executable file
|
|
@ -0,0 +1,241 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Classement manuel de A_trier, compatible avec la photothèque Labfy.
|
||||
|
||||
Photos et vidéos -> Photos/AAAA/AAAA-MM-JJ/
|
||||
RAW et XMP liés -> Originaux/AAAA/AAAA-MM-JJ/
|
||||
Aucun fichier existant n'est remplacé. Les autres fichiers restent dans A_trier.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime
|
||||
import errno
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import unicodedata
|
||||
|
||||
BASE = Path.home() / "Pictures" / "Phototheque"
|
||||
RAW = {".arw", ".dng", ".cr2", ".cr3", ".nef", ".nrw", ".raf",
|
||||
".orf", ".rw2", ".pef", ".srw", ".3fr", ".iiq"}
|
||||
PHOTO = {".jpg", ".jpeg", ".jpe", ".png", ".tif", ".tiff",
|
||||
".heic", ".heif", ".avif", ".webp", ".bmp"}
|
||||
VIDEO = {".mov", ".mp4", ".m4v", ".avi", ".mts", ".m2ts", ".3gp"}
|
||||
MEDIA = RAW | PHOTO | VIDEO
|
||||
DATE_NAME = re.compile(r"(?<!\d)((?:19|20)\d{2})[-_]?(\d{2})[-_]?(\d{2})(?!\d)")
|
||||
EXIFTOOL = shutil.which("exiftool")
|
||||
|
||||
|
||||
def valid_date(y, m, d):
|
||||
try:
|
||||
candidate = date(int(y), int(m), int(d))
|
||||
return candidate if 1900 <= candidate.year <= 2100 else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def date_of(path):
|
||||
"""EXIF en priorité, ensuite le nom, puis date de modification."""
|
||||
if EXIFTOOL:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[EXIFTOOL, "-s3", "-d", "%Y-%m-%d",
|
||||
"-DateTimeOriginal", "-CreateDate",
|
||||
"-MediaCreateDate", "-TrackCreateDate", "--", str(path)],
|
||||
capture_output=True, text=True, timeout=20, check=False,
|
||||
)
|
||||
for line in result.stdout.splitlines():
|
||||
match = re.fullmatch(r"\s*(\d{4})-(\d{2})-(\d{2})\s*", line)
|
||||
if match:
|
||||
found = valid_date(*match.groups())
|
||||
if found:
|
||||
return found, "EXIF"
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
match = DATE_NAME.search(path.name)
|
||||
if match:
|
||||
found = valid_date(*match.groups())
|
||||
if found:
|
||||
return found, "nom du fichier"
|
||||
return datetime.fromtimestamp(path.stat().st_mtime).date(), "modification"
|
||||
|
||||
|
||||
def stem_for_group(path):
|
||||
name = path.name
|
||||
if path.suffix.lower() == ".xmp":
|
||||
name = name[:-4]
|
||||
pre = Path(name)
|
||||
if pre.suffix.lower() in MEDIA:
|
||||
name = pre.stem
|
||||
else:
|
||||
name = path.stem
|
||||
return name.casefold()
|
||||
|
||||
|
||||
def renamed(path, index):
|
||||
if index == 0:
|
||||
return path.name
|
||||
if path.suffix.lower() == ".xmp":
|
||||
without_xmp = path.name[:-4]
|
||||
suffix = Path(without_xmp).suffix
|
||||
if suffix.lower() in MEDIA:
|
||||
return without_xmp[:-len(suffix)] + f"_{index}" + suffix + path.name[-4:]
|
||||
return without_xmp + f"_{index}" + path.name[-4:]
|
||||
return path.stem + f"_{index}" + path.suffix
|
||||
|
||||
|
||||
def target_root(path, group, photos, originaux):
|
||||
if path.suffix.lower() in RAW:
|
||||
return originaux
|
||||
if path.suffix.lower() != ".xmp":
|
||||
return photos
|
||||
associated = Path(path.name[:-4]).suffix.lower()
|
||||
if associated in RAW:
|
||||
return originaux
|
||||
if associated in PHOTO | VIDEO:
|
||||
return photos
|
||||
# XMP générique : privilégier les RAW s'ils sont présents.
|
||||
return originaux if any(p.suffix.lower() in RAW for p in group) else photos
|
||||
|
||||
|
||||
def key(name):
|
||||
return unicodedata.normalize("NFC", name).casefold()
|
||||
|
||||
|
||||
def existing_names(folder, cache):
|
||||
if folder not in cache:
|
||||
if folder.is_symlink():
|
||||
raise ValueError(f"Dossier de destination symbolique : {folder}")
|
||||
cache[folder] = {key(p.name) for p in folder.iterdir()} if folder.exists() else set()
|
||||
return cache[folder]
|
||||
|
||||
|
||||
def digest(path):
|
||||
hash_ = hashlib.sha256()
|
||||
with path.open("rb") as file_:
|
||||
for chunk in iter(lambda: file_.read(1024 * 1024), b""):
|
||||
hash_.update(chunk)
|
||||
return hash_.digest()
|
||||
|
||||
|
||||
def move_without_overwrite(source, target):
|
||||
"""Hardlink + unlink sur même volume, copie vérifiée sinon."""
|
||||
try:
|
||||
os.link(source, target, follow_symlinks=False)
|
||||
except OSError as exc:
|
||||
if exc.errno != errno.EXDEV:
|
||||
raise
|
||||
try:
|
||||
with source.open("rb") as incoming, target.open("xb") as outgoing:
|
||||
shutil.copyfileobj(incoming, outgoing, 1024 * 1024)
|
||||
outgoing.flush()
|
||||
os.fsync(outgoing.fileno())
|
||||
shutil.copystat(source, target)
|
||||
if digest(source) != digest(target):
|
||||
raise OSError("Contrôle SHA-256 de la copie échoué")
|
||||
except BaseException:
|
||||
target.unlink(missing_ok=True)
|
||||
raise
|
||||
source.unlink()
|
||||
|
||||
|
||||
def run(args):
|
||||
source = args.source.expanduser().absolute()
|
||||
photos = args.photos.expanduser().absolute()
|
||||
originaux = args.originaux.expanduser().absolute()
|
||||
if not source.is_dir() or source.is_symlink():
|
||||
raise ValueError(f"Source absente ou symbolique : {source}")
|
||||
for dest in (photos, originaux):
|
||||
if dest == source or source in dest.parents:
|
||||
raise ValueError("La destination ne doit pas être dans A_trier.")
|
||||
if dest.is_symlink():
|
||||
raise ValueError(f"Destination symbolique refusée : {dest}")
|
||||
|
||||
groups = defaultdict(list)
|
||||
ignored = 0
|
||||
for current, dirs, names in os.walk(source, followlinks=False):
|
||||
dirs[:] = [d for d in dirs if not (Path(current) / d).is_symlink()]
|
||||
for name in names:
|
||||
file_ = Path(current) / name
|
||||
if file_.is_symlink() or not file_.is_file():
|
||||
ignored += 1
|
||||
continue
|
||||
if file_.suffix.lower() not in MEDIA | {".xmp"}:
|
||||
ignored += 1
|
||||
continue
|
||||
groups[(file_.parent, stem_for_group(file_))].append(file_)
|
||||
|
||||
occupied = {}
|
||||
moved = 0
|
||||
errors = 0
|
||||
orphans = 0
|
||||
for _, group in sorted(groups.items(), key=lambda x: str(x[0])):
|
||||
media = [p for p in group if p.suffix.lower() in MEDIA]
|
||||
if not media:
|
||||
orphans += len(group)
|
||||
continue # Ne pas déplacer les XMP orphelins.
|
||||
media.sort(key=lambda p: (p.suffix.lower() not in RAW,
|
||||
p.suffix.lower() in VIDEO, p.name))
|
||||
try:
|
||||
taken, origin = date_of(media[0])
|
||||
relative = Path(str(taken.year)) / taken.isoformat()
|
||||
destinations = [
|
||||
target_root(p, group, photos, originaux) / relative
|
||||
for p in group
|
||||
]
|
||||
index = 0
|
||||
while True:
|
||||
candidates = [folder / renamed(p, index)
|
||||
for p, folder in zip(group, destinations)]
|
||||
identities = {(p.parent, key(p.name)) for p in candidates}
|
||||
if len(identities) != len(candidates):
|
||||
raise ValueError("Noms incompatibles (casse/Unicode) dans le lot")
|
||||
if all(key(p.name) not in existing_names(p.parent, occupied)
|
||||
for p in candidates):
|
||||
break
|
||||
index += 1
|
||||
for file_, target in zip(group, candidates):
|
||||
if args.dry_run:
|
||||
print(f"[SIMULATION] {file_.relative_to(source)} -> "
|
||||
f"{target.relative_to(BASE) if target.is_relative_to(BASE) else target}"
|
||||
f" ({origin})")
|
||||
else:
|
||||
if target.parent.is_symlink():
|
||||
raise ValueError(f"Dossier symbolique : {target.parent}")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
move_without_overwrite(file_, target)
|
||||
print(f"[OK] {file_.relative_to(source)} -> {target}")
|
||||
existing_names(target.parent, occupied).add(key(target.name))
|
||||
moved += 1
|
||||
except (OSError, ValueError) as exc:
|
||||
errors += 1
|
||||
print(f"[ERREUR] {', '.join(p.name for p in group)} : {exc}",
|
||||
file=sys.stderr)
|
||||
|
||||
label = "prévus" if args.dry_run else "déplacés"
|
||||
print(f"\nFichiers {label} : {moved} ; ignorés : {ignored} ; "
|
||||
f"XMP orphelins : {orphans} ; erreurs : {errors}")
|
||||
return 1 if errors else 0
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("-n", "--dry-run", action="store_true",
|
||||
help="simuler sans déplacer les fichiers")
|
||||
parser.add_argument("--source", type=Path, default=BASE / "A_trier")
|
||||
parser.add_argument("--photos", type=Path, default=BASE / "Photos")
|
||||
parser.add_argument("--originaux", type=Path, default=BASE / "Originaux")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
return run(args)
|
||||
except (OSError, ValueError) as exc:
|
||||
print(f"Erreur : {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
148
docs/quickshell-update-center-v1.md
Normal file
148
docs/quickshell-update-center-v1.md
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
# Labfy Update Center V1
|
||||
|
||||
Le clic sur l’indicateur de mises à jour de la barre ouvre le centre intégré à
|
||||
QuickShell. L’indicateur reste accessible à zéro pour donner accès à la maintenance
|
||||
et aux annonces. Le thème utilise les rôles Catppuccin existants, Lavender pour les
|
||||
titres et des rayons de 4 px.
|
||||
|
||||
## Architecture initiale et évolution
|
||||
|
||||
`status/UpdatesIndicator.qml` lisait uniquement les compteurs Arch/AUR d’un JSON
|
||||
atomique dans `$XDG_RUNTIME_DIR/labfy-quickshell-updates.json`, sans action au clic.
|
||||
Le service utilisateur `labfy-quickshell-updates.service` exécutait
|
||||
`bin/check-updates.py` ; son timer existant vérifiait toutes les dix minutes.
|
||||
Les commandes étaient `checkupdates` et `yay -Qua`.
|
||||
|
||||
Ce service et ce timer restent les seuls propriétaires de la vérification
|
||||
périodique. Le collecteur conserve maintenant les paquets, versions installées et
|
||||
candidates, origine Arch/AUR, repères informatifs et état de chaque tentative.
|
||||
La vérification manuelle demande le démarrage du même service. `checkupdates`
|
||||
utilise sa base temporaire ; aucune synchronisation isolée de la base système,
|
||||
installation ou élévation n’est effectuée pendant la collecte.
|
||||
|
||||
`status/UpdateCenter.qml` est un PopupWindow ancré à la barre.
|
||||
`RightStatusArea.qml` et `Bar.qml` assurent l’exclusion avec les autres panneaux et
|
||||
la fermeture lors d’une authentification. Le grab existant ferme le centre sur
|
||||
clic extérieur ; Échap et le bouton × permettent également de fermer.
|
||||
Le ciblage IPC par sortie est `updatesUi-<sortie>` : `open`, `close`, `isOpen`.
|
||||
Aucune action d’installation n’est exposée par IPC.
|
||||
|
||||
## Données, erreurs et performances
|
||||
|
||||
Les listes officielles et AUR restent séparées. La recherche et les filtres sont
|
||||
locaux. La liste virtualisée accepte les listes longues. Les repères noyau,
|
||||
système, graphique et session ne prédisent jamais une panne ; les noyaux
|
||||
personnalisés publiant un `pkgbase` dans `/usr/lib/modules` sont aussi reconnus.
|
||||
|
||||
Le fichier est publié par remplacement atomique. Un `flock` partagé protège les
|
||||
vérifications et l’installation Labfy, y compris depuis plusieurs sorties. Ce
|
||||
verrou ne prétend pas contrôler les outils externes : pacman garde son propre
|
||||
verrou système. Les admissions QML sont synchrones pour rejeter un double clic.
|
||||
Chaque commande de vérification dispose de 120 s, dans le service existant limité
|
||||
à 270 s. Un état de progression abandonné devient réessayable après ce délai.
|
||||
En cas d’échec d’une source, ses anciens résultats sont conservés avec l’erreur ;
|
||||
ils ne deviennent pas un faux zéro. Une connexion HTTPS est sondée seulement après
|
||||
une erreur du collecteur pour distinguer une connexion indisponible.
|
||||
|
||||
Le renderer observe le cache. Son ancien timer de récupération est réutilisé
|
||||
uniquement avant le premier chargement ou pendant l’ouverture du popup ; aucun
|
||||
nouveau timer ni démon n’est ajouté. Les processus de maintenance et d’actualités
|
||||
sont ponctuels et asynchrones, lancés à l’ouverture.
|
||||
|
||||
## Installation interactive et aperçu
|
||||
|
||||
« Mettre à jour le système » demande une confirmation dans QuickShell, puis ouvre
|
||||
Kitty avec `bin/update-terminal.py`. L’app_id dédié `labfy-update-center` cible
|
||||
une règle dans `sway/.config/sway/rules` : fenêtre flottante, centrée, 65 % de la
|
||||
largeur et 60 % de la hauteur de la sortie active. Installation et aperçu partagent
|
||||
ce placement ; les autres fenêtres Kitty conservent leur comportement. Le terminal présente l’opération et exige la
|
||||
saisie exacte de `METTRE A JOUR`. L’opération complète est `yay -Syu --confirm`,
|
||||
avec les options de yay 13 suivantes :
|
||||
|
||||
- `--sudoloop=false` : aucune boucle de maintien de sudo ;
|
||||
- `--cleanafter=false` : pas de nettoyage automatique des sources par Labfy ;
|
||||
- `--useask=false` : pas d’acceptation automatique des conflits ;
|
||||
- `--askremovemake` : question avant suppression des dépendances de compilation ;
|
||||
- `--noanswerclean --noanswerdiff --noansweredit --noanswerupgrade` : pas de réponses
|
||||
préenregistrées aux menus.
|
||||
|
||||
Les options sont limitées à cette invocation et ne sont pas sauvegardées. Le
|
||||
terminal affiche le code de sortie, demande au service existant de revérifier,
|
||||
puis attend Entrée. Après une erreur, il rappelle de terminer la mise à jour
|
||||
complète avant toute autre installation. Les décisions interactives de yay et la
|
||||
lecture des PKGBUILD restent à la charge de l’utilisateur.
|
||||
|
||||
« Aperçu Kitty » utilise `--dry-run` : aucune commande de paquets ni actualisation
|
||||
n’est exécutée. Les tests de la branche affirmative interceptent tous les appels
|
||||
au gestionnaire de paquets. Aucune installation réelle ne fait partie des tests.
|
||||
|
||||
## Maintenance en lecture seule
|
||||
|
||||
L’onglet affiche les 35 dernières lignes de transactions ALPM présentes dans une
|
||||
queue de lecture de 256 Kio de `/var/log/pacman.log`, les fichiers `.pacnew` et
|
||||
`.pacsave` accessibles sous `/etc`, et les services système/utilisateur en échec.
|
||||
La recherche ne suit pas les symlinks de répertoire et est limitée à 4 s / 50 000
|
||||
entrées de répertoire ; toute limite ou permission insuffisante est signalée.
|
||||
Ce périmètre ne garantit pas la découverte de fichiers situés hors `/etc`.
|
||||
Aucun fichier de configuration n’est fusionné, supprimé ou modifié.
|
||||
|
||||
`/run/reboot-required`, s’il existe, est présenté comme une demande détectée.
|
||||
L’absence des modules du noyau actif est une recommandation, pas une preuve
|
||||
universelle d’obligation de redémarrer. L’absence de signal ne garantit pas qu’un
|
||||
redémarrage est inutile. Les journaux du système et du collecteur sont accessibles
|
||||
dans Kitty. Aucun nettoyage de cache n’est exécuté.
|
||||
|
||||
## Annonces officielles
|
||||
|
||||
Source : <https://archlinux.org/feeds/news/>. Cache utilisateur
|
||||
`$XDG_CACHE_HOME/labfy-update-news.json` (sinon `~/.cache`), renouvelé après 6 h
|
||||
lors d’une ouverture. Après échec, un délai de 10 min évite les tentatives
|
||||
répétées ; les annonces en cache restent visibles. La lecture réseau est limitée
|
||||
à 1 Mio et 12 s. Les titres sont rendus comme texte brut. Seuls les liens HTTPS
|
||||
vers `archlinux.org` sont ouvrables.
|
||||
|
||||
Le badge d’intervention manuelle est posé uniquement lorsque le titre officiel
|
||||
l’annonce explicitement en anglais. L’absence de badge ne dispense pas de lire
|
||||
l’annonce ; le contenu n’est pas analysé pour deviner une applicabilité. Aucune
|
||||
correspondance avec les paquets installés et aucune exécution de texte distant.
|
||||
|
||||
## Validation non destructive
|
||||
|
||||
- `python3 -m unittest discover -s tests -p 'test_quickshell*.py' -v` : 16 tests,
|
||||
dont 13 du collecteur/terminal, un scénario QML et deux de régression Hyprwhspr.
|
||||
- Le scénario QML charge les composants de production avec commandes interceptées
|
||||
dans une copie temporaire : zéro, 600 paquets Arch/AUR, recherche/filtres,
|
||||
navigation, données de maintenance/annonces, actualisation et double appel,
|
||||
récupération d’une vérification expirée, fermeture/réouverture et argv d’aperçu.
|
||||
Il nécessite QuickShell et une session Wayland ; son popup synthétique désactive
|
||||
le grab faute de série d’entrée utilisateur. Le popup de la vraie barre garde
|
||||
son grab.
|
||||
- Tests JavaScript existants : StatusColorRoles, PercentMath,
|
||||
PercentPresentation, ScrollSteps, OSD observation.
|
||||
- `qmllint` sur les cinq QML modifiés/ajoutés et `git diff --check`.
|
||||
- Lecture réelle du service, des transactions, fichiers `/etc`, services en échec
|
||||
et flux officiel ; ouverture du vrai Kitty avec `--dry-run` seulement.
|
||||
|
||||
Dépendances : Python standard, QuickShell/QtQuick.Controls, systemd utilisateur,
|
||||
pacman-contrib (`checkupdates`), yay et Kitty, tous déjà installés. Aucune nouvelle
|
||||
dépendance. Les réglages pacman et systemd sont préservés. La seule règle SwayFX ajoutée cible
|
||||
l’app_id du terminal de mise à jour, à la demande de l’utilisateur.
|
||||
|
||||
## Résultat de validation en session
|
||||
|
||||
Validation finale : 16 tests Python passants en 18,921 s ; les cinq suites
|
||||
JavaScript citées passent. `qmllint`, compilation Python et `git diff --check`
|
||||
sont passants. `sway -C` retourne 0 (avertissements EGL de détection GPU), puis
|
||||
le rechargement des règles Sway retourne `success: true`, sans redémarrage de
|
||||
session. Un vrai Kitty lancé avec `--dry-run` et l’app_id de production est
|
||||
confirmé dans `floating_nodes` de l’arbre Sway : rectangle 1482 × 830, position
|
||||
401,319 sur la sortie actuelle. Seule cette fenêtre d’aperçu est ensuite fermée.
|
||||
|
||||
Lecture locale : 10 fichiers `.pacnew`/`.pacsave`, 35 lignes ALPM, aucun service
|
||||
en échec signalé ; des permissions insuffisantes sous `/etc` sont remontées dans
|
||||
l’interface. La recherche reste donc explicitement partielle. Le flux officiel
|
||||
est téléchargé et ses annonces sont mises en cache. QuickShell reste actif avec
|
||||
le PID 378053 et `NRestarts=0` ; aucun redémarrage du service n’a été demandé.
|
||||
L’installation réelle, la résolution de conflits AUR et un redémarrage système
|
||||
ne sont volontairement pas exécutés. Le contrôle visuel et ergonomique final
|
||||
reste à effectuer par l’utilisateur dans les trois onglets.
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
singleton RgbKeyboardService 1.0 RgbKeyboardService.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()
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
235
quickshell/.config/quickshell/labfy-sway/status/UpdateCenter.qml
Normal file
235
quickshell/.config/quickshell/labfy-sway/status/UpdateCenter.qml
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import "../theme"
|
||||
|
||||
PopupWindow {
|
||||
id: center
|
||||
required property var barWindow
|
||||
required property var indicator
|
||||
required property var updateState
|
||||
property string query: ""
|
||||
property string origin: "Tous"
|
||||
// WHY: Process.running may change on the next event-loop turn. Synchronous
|
||||
// admission flags reject two clicks delivered in the same turn.
|
||||
property bool refreshPending: false
|
||||
property bool terminalPending: false
|
||||
property bool confirming: false
|
||||
property string actionError: ""
|
||||
property var maintenance: ({history: [], files: [], failed: [], errors: [], reboot: ""})
|
||||
property var news: ({items: []})
|
||||
readonly property string backend: Qt.resolvedUrl("../bin/check-updates.py").toString().replace("file://", "")
|
||||
readonly property string terminalBackend: Qt.resolvedUrl("../bin/update-terminal.py").toString().replace("file://", "")
|
||||
property double tick: Date.now()
|
||||
readonly property bool staleCheck: Boolean(center.updateState.checking) && (!center.updateState.checkStarted || tick - new Date(center.updateState.checkStarted).getTime() > 270000)
|
||||
readonly property bool checking: refreshPending || refreshProcess.running || (Boolean(center.updateState.checking) && !staleCheck)
|
||||
readonly property var filtered: (center.updateState.packages || []).filter(p =>
|
||||
(origin === "Tous" || p.origin === origin) && p.name.toLowerCase().includes(query.toLowerCase()))
|
||||
anchor.window: barWindow
|
||||
anchor.rect.x: Math.max(8, barWindow.width - implicitWidth - 8)
|
||||
anchor.rect.y: barWindow.height + 6
|
||||
anchor.edges: Edges.Top | Edges.Left
|
||||
anchor.gravity: Edges.Bottom | Edges.Right
|
||||
implicitWidth: Math.min(660, barWindow.screen.width - 24)
|
||||
implicitHeight: Math.min(720, barWindow.screen.height - barWindow.height - 30)
|
||||
color: "transparent"
|
||||
visible: false
|
||||
// CONTRACT: grab is fixed before mapping; outside click and Escape close the popup.
|
||||
grabFocus: true
|
||||
onVisibleChanged: {
|
||||
confirming = false;
|
||||
tick = Date.now();
|
||||
if (visible) {
|
||||
if (!maintenanceProcess.running) maintenanceProcess.running = true;
|
||||
if (!newsProcess.running) newsProcess.running = true;
|
||||
}
|
||||
}
|
||||
function refresh() {
|
||||
if (checking || terminalPending) return;
|
||||
actionError = "";
|
||||
refreshPending = true;
|
||||
refreshProcess.running = true;
|
||||
}
|
||||
function terminal(preview) {
|
||||
if (terminalPending || checking || (!preview && !confirming)) return;
|
||||
confirming = false;
|
||||
// CONTRACT: direct argv, never a shell, package name or remote command.
|
||||
// The dedicated app_id selects only this terminal for the Sway floating rule.
|
||||
terminalProcess.command = ["kitty", "--class", "labfy-update-center", "--title", "Labfy Update Center", "python3", "-B", terminalBackend];
|
||||
if (preview) terminalProcess.command = terminalProcess.command.concat(["--dry-run"]);
|
||||
terminalPending = true;
|
||||
terminalProcess.running = true;
|
||||
visible = false;
|
||||
}
|
||||
Process {
|
||||
id: refreshProcess
|
||||
command: ["systemctl", "--user", "start", "labfy-quickshell-updates.service"]
|
||||
stderr: StdioCollector { id: refreshError }
|
||||
onExited: (code, status) => {
|
||||
center.refreshPending = false;
|
||||
indicator.reload();
|
||||
if (code !== 0) center.actionError = refreshError.text || "Échec du service de vérification";
|
||||
}
|
||||
}
|
||||
Process {
|
||||
id: terminalProcess
|
||||
onExited: (code, status) => {
|
||||
center.terminalPending = false;
|
||||
indicator.reload();
|
||||
if (code !== 0) center.actionError = "Terminal terminé avec le code " + code;
|
||||
}
|
||||
}
|
||||
Process {
|
||||
id: maintenanceProcess
|
||||
command: ["python3", "-B", center.backend, "maintenance"]
|
||||
stdout: StdioCollector { id: maintenanceOutput; waitForEnd: true }
|
||||
onExited: (code, status) => {
|
||||
try { center.maintenance = JSON.parse(maintenanceOutput.text); }
|
||||
catch (_) { center.maintenance = {history: [], files: [], failed: [], errors: ["Maintenance indisponible"], reboot: "État inconnu"}; }
|
||||
}
|
||||
}
|
||||
Process {
|
||||
id: newsProcess
|
||||
command: ["python3", "-B", center.backend, "news"]
|
||||
stdout: StdioCollector { id: newsOutput; waitForEnd: true }
|
||||
onExited: (code, status) => {
|
||||
try { center.news = JSON.parse(newsOutput.text); }
|
||||
catch (_) { center.news = {items: [], error: "Flux indisponible"}; }
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
focus: true
|
||||
Keys.onEscapePressed: center.visible = false
|
||||
radius: 4
|
||||
color: Theme.popupBackground
|
||||
border.color: Theme.outline
|
||||
Column {
|
||||
anchors.fill: parent; anchors.margins: 12; spacing: 8
|
||||
Row {
|
||||
width: parent.width; spacing: 8
|
||||
Text { text: "Labfy Update Center"; color: Theme.lavender; font.pixelSize: 17; font.bold: true; width: parent.width - 38 }
|
||||
UpdateAction { text: "×"; onClicked: center.visible = false }
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: center.updateState.totalUpdates + (center.updateState.totalUpdates === 1 ? " mise à jour · " : " mises à jour · ") + center.updateState.repoUpdates + " officielles · " + center.updateState.aurUpdates + " AUR"
|
||||
color: Theme.foreground; font.pixelSize: 13
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Dernière tentative : " + (center.updateState.lastCheck ? new Date(center.updateState.lastCheck).toLocaleString(Qt.locale("fr_FR")) : "en attente")
|
||||
+ (center.updateState.error && center.updateState.lastSuccess ? "\nDernier succès : " + new Date(center.updateState.lastSuccess).toLocaleString(Qt.locale("fr_FR")) : "")
|
||||
color: Theme.secondaryForeground; font.pixelSize: 11; wrapMode: Text.Wrap
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: center.checking ? "Vérification en cours…" : center.staleCheck ? "Vérification interrompue ou expirée · actualisez pour réessayer." : center.updateState.error ? (center.updateState.offline ? "Connexion indisponible · " : "") + center.updateState.error + "\nRésultats précédents conservés pour les sources en échec."
|
||||
: "Contrôles terminés · " + (center.updateState.totalUpdates ? "mises à jour disponibles" : "système à jour")
|
||||
color: center.updateState.error ? Theme.yellow : Theme.secondaryForeground; font.pixelSize: 11; wrapMode: Text.Wrap; textFormat: Text.PlainText
|
||||
}
|
||||
Row {
|
||||
spacing: 6
|
||||
UpdateAction { text: "Actualiser"; enabled: !center.checking && !center.terminalPending; onClicked: center.refresh() }
|
||||
UpdateAction { text: "Mettre à jour le système"; enabled: !center.checking && !center.terminalPending; onClicked: center.confirming = !center.confirming }
|
||||
UpdateAction { text: "Aperçu Kitty"; enabled: !center.checking && !center.terminalPending; onClicked: center.terminal(true) }
|
||||
}
|
||||
Column {
|
||||
visible: center.confirming; width: parent.width; spacing: 5
|
||||
Text { width: parent.width; text: "Ouvrir Kitty pour une mise à jour complète Arch + AUR ? Une seconde confirmation sera demandée dans le terminal. Lisez les annonces Arch avant de continuer."; wrapMode: Text.Wrap; color: Theme.yellow; font.pixelSize: 12 }
|
||||
Row {
|
||||
spacing: 8
|
||||
UpdateAction { text: "Confirmer l’ouverture"; onClicked: center.terminal(false) }
|
||||
UpdateAction { text: "Annuler"; onClicked: center.confirming = false }
|
||||
}
|
||||
}
|
||||
Text { visible: !!center.actionError; width: parent.width; text: center.actionError; color: Theme.red; wrapMode: Text.Wrap; textFormat: Text.PlainText; font.pixelSize: 11 }
|
||||
TabBar {
|
||||
id: tabs; objectName: "updateTabs"; width: parent.width
|
||||
background: Rectangle { color: Theme.surface0; radius: 4 }
|
||||
Repeater {
|
||||
model: ["Paquets", "Maintenance", "Annonces Arch"]
|
||||
TabButton {
|
||||
required property string modelData
|
||||
text: modelData
|
||||
contentItem: Text { text: parent.text; color: Theme.foreground; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter; font.pixelSize: 12 }
|
||||
background: Rectangle { radius: 4; color: parent.checked ? Theme.surface2 : Theme.surface0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
Item {
|
||||
width: parent.width
|
||||
height: parent.height - y
|
||||
Column {
|
||||
anchors.fill: parent; spacing: 6; visible: tabs.currentIndex === 0
|
||||
Row {
|
||||
width: parent.width; spacing: 6
|
||||
TextField {
|
||||
width: parent.width - 180; height: 30; placeholderText: "Rechercher un paquet…"
|
||||
color: Theme.foreground; placeholderTextColor: Theme.secondaryForeground
|
||||
background: Rectangle { color: Theme.inputBackground; border.color: Theme.inputBorder; radius: 4 }
|
||||
onTextChanged: center.query = text
|
||||
}
|
||||
Repeater { model: ["Tous", "Arch", "AUR"]; UpdateAction { required property string modelData; text: modelData; border.color: center.origin === modelData ? Theme.lavender : Theme.outline; onClicked: center.origin = modelData } }
|
||||
}
|
||||
Text { width: parent.width; text: "Les repères noyau, système, graphique et session sont informatifs, sans prédiction de panne."; color: Theme.secondaryForeground; font.pixelSize: 11; wrapMode: Text.Wrap }
|
||||
Text { visible: center.filtered.length === 0; text: "Aucun paquet à afficher"; color: Theme.secondaryForeground; font.pixelSize: 12 }
|
||||
ListView {
|
||||
width: parent.width; height: parent.height - y; clip: true
|
||||
objectName: "updatePackageList"
|
||||
model: center.filtered; spacing: 3; boundsBehavior: Flickable.StopAtBounds
|
||||
ScrollBar.vertical: ScrollBar { }
|
||||
delegate: Rectangle {
|
||||
required property var modelData
|
||||
width: ListView.view.width; height: 49; radius: 4; color: Theme.surface0
|
||||
Column {
|
||||
anchors.fill: parent; anchors.margins: 6; spacing: 3
|
||||
Text { width: parent.width; text: modelData.name + " · " + modelData.origin + (modelData.sensitive ? " · " + modelData.sensitive : ""); color: modelData.sensitive ? Theme.yellow : Theme.foreground; textFormat: Text.PlainText; elide: Text.ElideRight; font.pixelSize: 12 }
|
||||
Text { width: parent.width; text: modelData.installed + " → " + modelData.available; color: Theme.secondaryForeground; textFormat: Text.PlainText; elide: Text.ElideRight; font.pixelSize: 11 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Flickable {
|
||||
anchors.fill: parent; clip: true; visible: tabs.currentIndex === 1
|
||||
contentWidth: width; contentHeight: maintenanceColumn.implicitHeight; boundsBehavior: Flickable.StopAtBounds
|
||||
ScrollBar.vertical: ScrollBar { }
|
||||
Column {
|
||||
id: maintenanceColumn; width: parent.width; spacing: 8
|
||||
Text { text: maintenanceProcess.running ? "Lecture en cours…" : "Relevé : " + (center.maintenance.time || "indisponible"); color: Theme.secondaryForeground; font.pixelSize: 11 }
|
||||
Text { width: parent.width; text: center.maintenance.reboot; color: Theme.yellow; font.pixelSize: 12; wrapMode: Text.Wrap }
|
||||
Text { width: parent.width; text: (center.maintenance.errors || []).join("\n"); visible: !!text; color: Theme.yellow; font.pixelSize: 11; wrapMode: Text.Wrap; textFormat: Text.PlainText }
|
||||
Text { text: ".pacnew / .pacsave · /etc · lecture seule"; color: Theme.lavender; font.pixelSize: 13 }
|
||||
Text { width: parent.width; text: center.maintenance.files.join("\n") || "Aucun fichier détecté dans les chemins accessibles"; color: Theme.foreground; font.pixelSize: 11; wrapMode: Text.Wrap; textFormat: Text.PlainText }
|
||||
Text { text: "Services en échec"; color: Theme.lavender; font.pixelSize: 13 }
|
||||
Text { width: parent.width; text: center.maintenance.failed.join("\n") || "Aucun échec signalé dans les périmètres lus"; color: Theme.foreground; font.pixelSize: 11; wrapMode: Text.Wrap; textFormat: Text.PlainText }
|
||||
Row {
|
||||
spacing: 6
|
||||
UpdateAction { text: "Journal système"; onClicked: Quickshell.execDetached(["kitty", "journalctl", "-b", "-p", "warning"]) }
|
||||
UpdateAction { text: "Journal du collecteur"; onClicked: Quickshell.execDetached(["kitty", "journalctl", "--user", "-u", "labfy-quickshell-updates.service"]) }
|
||||
}
|
||||
Text { text: "Dernières transactions · /var/log/pacman.log"; color: Theme.lavender; font.pixelSize: 13 }
|
||||
Text { width: parent.width; text: center.maintenance.history.join("\n") || "Aucune transaction lisible"; color: Theme.foreground; font.pixelSize: 11; wrapMode: Text.Wrap; textFormat: Text.PlainText }
|
||||
}
|
||||
}
|
||||
ListView {
|
||||
anchors.fill: parent; clip: true; visible: tabs.currentIndex === 2
|
||||
model: center.news.items || []; spacing: 6; boundsBehavior: Flickable.StopAtBounds
|
||||
ScrollBar.vertical: ScrollBar { }
|
||||
header: Column {
|
||||
width: parent.width; spacing: 6
|
||||
Text { width: parent.width; text: "Flux officiel archlinux.org · cache 6 h\nAucune correspondance automatique avec les paquets installés."; color: Theme.secondaryForeground; font.pixelSize: 11; wrapMode: Text.Wrap }
|
||||
Text { width: parent.width; text: newsProcess.running ? "Chargement…" : center.news.error || "Cache : " + (center.news.time || "indisponible"); color: Theme.yellow; font.pixelSize: 11; wrapMode: Text.Wrap; textFormat: Text.PlainText }
|
||||
}
|
||||
delegate: Rectangle {
|
||||
required property var modelData
|
||||
width: ListView.view.width; height: newsText.implicitHeight + 16; radius: 4; color: Theme.surface0
|
||||
Text { id: newsText; anchors.left: parent.left; anchors.right: parent.right; anchors.top: parent.top; anchors.margins: 8; text: modelData.title + "\n" + modelData.date + (modelData.manual ? "\nIntervention manuelle explicitement annoncée" : ""); color: modelData.manual ? Theme.yellow : Theme.foreground; font.pixelSize: 12; wrapMode: Text.Wrap; textFormat: Text.PlainText }
|
||||
MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: { if (/^https:\/\/archlinux\.org\//.test(modelData.link)) Qt.openUrlExternally(modelData.link); } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
#=================================================
|
||||
|
|
|
|||
1
systemd/.config/systemd/user/dbus-org.bluez.obex.service
Symbolic link
1
systemd/.config/systemd/user/dbus-org.bluez.obex.service
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/usr/lib/systemd/user/obex.service
|
||||
|
|
@ -0,0 +1 @@
|
|||
/home/fy59/.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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
159
tests/test_quickshell_rgb_keyboard.py
Normal file
159
tests/test_quickshell_rgb_keyboard.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""Exercise production RGB QML with an executable fixture; never open real HID."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BASE = ROOT / 'quickshell/.config/quickshell/labfy-sway'
|
||||
|
||||
class RgbKeyboardTests(unittest.TestCase):
|
||||
def test_production_process_flow_and_opt_out(self):
|
||||
qs = shutil.which('qs')
|
||||
if not qs:
|
||||
self.skipTest('QuickShell unavailable')
|
||||
with tempfile.TemporaryDirectory(prefix='labfy-rgb-qml-') as folder:
|
||||
p = Path(folder)
|
||||
(p/'runtime').mkdir(mode=0o700)
|
||||
(p/'config').mkdir()
|
||||
(p/'rgbkeyboard').mkdir()
|
||||
shutil.copy(BASE/'rgbkeyboard/qmldir', p/'rgbkeyboard/qmldir')
|
||||
source = (BASE/'rgbkeyboard/RgbKeyboardService.qml').read_text()
|
||||
old = 'readonly property string executable: (Quickshell.env("HOME") || "") + "/.local/bin/labfy-rgb"'
|
||||
self.assertIn(old, source)
|
||||
source = source.replace(old, 'readonly property string executable: ' + json.dumps(str(p/'fake-cli')))
|
||||
(p/'rgbkeyboard/RgbKeyboardService.qml').write_text(source)
|
||||
# Only the executable path is replaced. All Process/state/validation
|
||||
# logic is production code. Preferences and endpoint state are isolated.
|
||||
(p/'fake-cli').write_text('''#!/usr/bin/python3
|
||||
import json, os, sys
|
||||
from pathlib import Path
|
||||
p=Path(os.environ['RGB_FIXTURE'])
|
||||
a=sys.argv[1:]
|
||||
with (p/'calls').open('a') as f: f.write(json.dumps(a)+'\\n')
|
||||
s=dict(schema=1,available=True,device='/dev/hidraw77',commanded_mode='unknown',last_color=None,last_percent=None)
|
||||
if (p/'state').exists(): s=json.loads((p/'state').read_text())
|
||||
if a[0]=='set' and a[1]=='#010101': print('fixture access denied',file=sys.stderr); sys.exit(4)
|
||||
if a[0]=='set' and a[1]=='#000001': Path(sys.argv[0]).unlink()
|
||||
if a[0]=='status': print(json.dumps(s))
|
||||
elif a[0]=='set': s.update(commanded_mode='software',last_color=a[1],last_percent=int(a[2]) if len(a)>2 else 100); (p/'state').write_text(json.dumps(s))
|
||||
elif a[0]=='brightness': s['last_percent']=int(a[1]); (p/'state').write_text(json.dumps(s))
|
||||
elif a[0]=='off': s['last_percent']=0; (p/'state').write_text(json.dumps(s))
|
||||
elif a[0]=='auto': s.update(commanded_mode='firmware',last_color=None,last_percent=None); (p/'state').write_text(json.dumps(s))
|
||||
else: sys.exit(2)
|
||||
''')
|
||||
(p/'fake-cli').chmod(0o700)
|
||||
(p/'shell.qml').write_text('''import QtQuick
|
||||
import Quickshell
|
||||
import "rgbkeyboard"
|
||||
ShellRoot {
|
||||
id: root
|
||||
property int step: 0
|
||||
property int presetIndex: 0
|
||||
property int ticks: 0
|
||||
function check(ok, message) { if (!ok) { console.error("RGB_FAIL " + message); Qt.exit(1); } }
|
||||
Timer {
|
||||
interval: 80; running: true; repeat: true
|
||||
onTriggered: {
|
||||
root.ticks++;
|
||||
if (root.ticks > 100) { console.error("RGB_TIMEOUT"); Qt.exit(1); return; }
|
||||
if (RgbKeyboardService.busy || RgbKeyboardService.refreshPending) return;
|
||||
if (root.step === 0) {
|
||||
root.check(!RgbKeyboardService.restoreEnabled, "default opt out");
|
||||
root.check(RgbKeyboardService.palette.length === 8, "eight presets");
|
||||
const golden = ["#8000FF","#0040FF","#00FFFF","#00FF40","#FF0080","#FF0000","#FF6000","#FFFF00"];
|
||||
for (let i = 0; i < golden.length; ++i) root.check(RgbKeyboardService.palette[i].color === golden[i], "preset wire color");
|
||||
root.check(!RgbKeyboardService.applyColor("#bad", 60), "reject bad color");
|
||||
root.check(!RgbKeyboardService.intensity(101), "reject intensity");
|
||||
RgbKeyboardService.refresh(); root.step++;
|
||||
} else if (root.step === 1 && RgbKeyboardService.available) {
|
||||
root.check(RgbKeyboardService.preset("#8000ff"), "apply");
|
||||
root.check(!RgbKeyboardService.firmware(), "serialize writes"); root.step++;
|
||||
} else if (root.step === 2 && RgbKeyboardService.lastColor === RgbKeyboardService.palette[root.presetIndex].color) {
|
||||
root.check(RgbKeyboardService.lastPercent === 100, "preset CLI default percent");
|
||||
root.presetIndex++;
|
||||
if (root.presetIndex < RgbKeyboardService.palette.length) {
|
||||
root.check(RgbKeyboardService.preset(RgbKeyboardService.palette[root.presetIndex].color), "next preset");
|
||||
} else { RgbKeyboardService.intensity(30); root.step++; }
|
||||
} else if (root.step === 3 && RgbKeyboardService.lastPercent === 30) {
|
||||
RgbKeyboardService.off(); root.step++;
|
||||
} else if (root.step === 4 && RgbKeyboardService.lastPercent === 0) {
|
||||
RgbKeyboardService.firmware(); root.step++;
|
||||
} else if (root.step === 5 && RgbKeyboardService.commandedMode === "firmware") {
|
||||
root.check(RgbKeyboardService.lastColor === "", "no physical firmware color claim");
|
||||
root.check(!RgbKeyboardService.restoreEnabled, "opt in not activated");
|
||||
RgbKeyboardService.applyColor("#010101", 100); root.step++;
|
||||
} else if (root.step === 6 && RgbKeyboardService.message.indexOf("code 4") >= 0) {
|
||||
RgbKeyboardService.applyColor("#000001", 100); root.step++;
|
||||
} else if (root.step === 7 && !RgbKeyboardService.available) {
|
||||
root.check(!RgbKeyboardService.busy, "failed executable start releases ownership");
|
||||
console.log("RGB_PASS"); Qt.exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
''')
|
||||
env = dict(os.environ, RGB_FIXTURE=str(p), XDG_CONFIG_HOME=str(p/'config'),
|
||||
XDG_RUNTIME_DIR=str(p/'runtime'), QT_QPA_PLATFORM='offscreen')
|
||||
result = subprocess.run([qs, '-p', str(p/'shell.qml')], env=env,
|
||||
capture_output=True, text=True, timeout=15)
|
||||
log = result.stdout + result.stderr
|
||||
self.assertEqual(result.returncode, 0, log)
|
||||
self.assertIn('RGB_PASS', log)
|
||||
self.assertNotIn('Failed to initialize QSettings', log)
|
||||
commands = [json.loads(line) for line in (p/'calls').read_text().splitlines()]
|
||||
writes = [args for args in commands if args[0] != 'status']
|
||||
self.assertEqual(writes, [['set',c] for c in ['#8000FF','#0040FF','#00FFFF','#00FF40','#FF0080','#FF0000','#FF6000','#FFFF00']] + [['brightness','30'],['off'],['auto'],['set','#010101','100'],['set','#000001','100']])
|
||||
self.assertTrue(all(args == ['status','--json'] for args in commands if args[0]=='status'))
|
||||
|
||||
def test_startup_opt_in_firmware_priority_and_invalid_preferences(self):
|
||||
qs = shutil.which('qs')
|
||||
if not qs:
|
||||
self.skipTest('QuickShell unavailable')
|
||||
cases = [(False, 'software', 'unknown', '#B4BEFE', False),
|
||||
(True, 'software', 'unknown', '#B4BEFE', True),
|
||||
(True, 'firmware', 'unknown', '#B4BEFE', False),
|
||||
(True, 'software', 'firmware', '#B4BEFE', False),
|
||||
(True, 'software', 'unknown', '#INVALID', False)]
|
||||
for enabled, saved_mode, native_mode, color, expect_set in cases:
|
||||
with self.subTest(enabled=enabled, saved_mode=saved_mode, native_mode=native_mode, color=color), tempfile.TemporaryDirectory(prefix='labfy-rgb-startup-') as folder:
|
||||
p = Path(folder)
|
||||
(p/'runtime').mkdir(mode=0o700)
|
||||
(p/'config/labfy-rgb').mkdir(parents=True)
|
||||
(p/'rgbkeyboard').mkdir()
|
||||
shutil.copy(BASE/'rgbkeyboard/qmldir', p/'rgbkeyboard/qmldir')
|
||||
source = (BASE/'rgbkeyboard/RgbKeyboardService.qml').read_text()
|
||||
old = 'readonly property string executable: (Quickshell.env("HOME") || "") + "/.local/bin/labfy-rgb"'
|
||||
self.assertIn(old, source)
|
||||
(p/'rgbkeyboard/RgbKeyboardService.qml').write_text(source.replace(old, 'readonly property string executable: '+json.dumps(str(p/'fake-cli'))))
|
||||
(p/'config/labfy-rgb/quickshell.ini').write_text('[General]\nrestoreEnabled='+str(enabled).lower()+'\nsavedMode='+saved_mode+'\nsavedColor='+color+'\nsavedPercent=60\n')
|
||||
(p/'fake-cli').write_text('''#!/usr/bin/python3
|
||||
import os,sys,json
|
||||
from pathlib import Path
|
||||
p=Path(os.environ['RGB_FIXTURE'])
|
||||
a=sys.argv[1:]
|
||||
with (p/'calls').open('a') as f: f.write(json.dumps(a)+'\\n')
|
||||
if a[0]=='status': print(json.dumps(dict(schema=1,available=True,device='/dev/hidraw77',commanded_mode=os.environ['NATIVE_MODE'],last_color=None,last_percent=None)))
|
||||
''')
|
||||
(p/'fake-cli').chmod(0o700)
|
||||
(p/'shell.qml').write_text('''import QtQuick
|
||||
import Quickshell
|
||||
import "rgbkeyboard"
|
||||
ShellRoot {
|
||||
Component.onCompleted: console.log("OPT_IN", RgbKeyboardService.restoreEnabled)
|
||||
Timer { running: true; interval: 2400; onTriggered: Qt.exit(0) }
|
||||
}
|
||||
''')
|
||||
env = dict(os.environ, RGB_FIXTURE=str(p), NATIVE_MODE=native_mode,
|
||||
XDG_CONFIG_HOME=str(p/'config'), XDG_RUNTIME_DIR=str(p/'runtime'), QT_QPA_PLATFORM='offscreen')
|
||||
result = subprocess.run([qs,'-p',str(p/'shell.qml')],env=env,capture_output=True,text=True,timeout=8)
|
||||
self.assertEqual(result.returncode,0,result.stdout+result.stderr)
|
||||
commands = [json.loads(line) for line in (p/'calls').read_text().splitlines()]
|
||||
writes = [args for args in commands if args[0] != 'status']
|
||||
self.assertEqual(writes, [['set','#B4BEFE','60']] if expect_set else [])
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
112
tests/test_quickshell_update_center_ui.py
Normal file
112
tests/test_quickshell_update_center_ui.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"""Run the production popup with fake commands; no package operation is reachable."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BASE = ROOT / 'quickshell/.config/quickshell/labfy-sway'
|
||||
QS = Path.home() / '.local/opt/quickshell-qt612-popup/bin/quickshell'
|
||||
|
||||
|
||||
class UpdateUITests(unittest.TestCase):
|
||||
def test_popup_states_filters_long_list_and_preview_argv(self):
|
||||
if not QS.exists() or not os.environ.get('WAYLAND_DISPLAY'):
|
||||
self.skipTest('QuickShell et session Wayland requis')
|
||||
with tempfile.TemporaryDirectory(prefix='labfy-updates-ui-') as folder:
|
||||
p = Path(folder)
|
||||
(p/'runtime').mkdir(mode=0o700)
|
||||
shutil.copytree(BASE, p/'config')
|
||||
# All production Process commands are intercepted, including python3.
|
||||
tool = '''#!/usr/bin/python3
|
||||
import os,sys,json
|
||||
from pathlib import Path
|
||||
p=Path(os.environ['UPDATE_UI_FIXTURE'])
|
||||
name=Path(sys.argv[0]).name
|
||||
with (p/'commands').open('a') as f: f.write(json.dumps([name,*sys.argv[1:]])+'\\n')
|
||||
if name=='python3':
|
||||
if sys.argv[-1]=='maintenance': print(json.dumps(dict(time='fixture',history=['transaction completed'],files=['/etc/test.pacnew'],failed=[],errors=[],reboot='Unknown')))
|
||||
elif sys.argv[-1]=='news': print(json.dumps(dict(items=[dict(title='Manual intervention required',date='today',link='https://archlinux.org/news/test/',manual=True)])))
|
||||
'''
|
||||
for name in ('python3', 'kitty', 'systemctl'):
|
||||
(p/name).write_text(tool)
|
||||
(p/name).chmod(0o755)
|
||||
qml = '''import QtQuick
|
||||
import Quickshell
|
||||
import "SOURCE" as Production
|
||||
ShellRoot {
|
||||
PanelWindow { id: bar; anchors.top: true; anchors.left: true; anchors.right: true; implicitHeight: 30; exclusiveZone: 0; focusable: true }
|
||||
QtObject { id: fakeIndicator; function reload() { } }
|
||||
Production.UpdateCenter {
|
||||
id: center; barWindow: bar; indicator: fakeIndicator
|
||||
// No input serial exists in this synthetic window; real bar retains grabFocus.
|
||||
grabFocus: false
|
||||
updateState: ({repoUpdates: 0, aurUpdates: 0, totalUpdates: 0, packages: [], lastCheck: '', error: ''})
|
||||
}
|
||||
function check(value, message) { if (!value) { console.error('FAIL ' + message); Qt.exit(1); } }
|
||||
function find(item, name) {
|
||||
if (item.objectName === name) return item;
|
||||
for (const child of (item.children || [])) { const found = find(child, name); if (found) return found; }
|
||||
return null;
|
||||
}
|
||||
property int step: 0
|
||||
Timer {
|
||||
interval: 350; running: true; repeat: true
|
||||
onTriggered: {
|
||||
if (step === 0) { center.visible = true; }
|
||||
else if (step === 1) {
|
||||
check(center.visible, 'popup opens'); check(center.filtered.length === 0, 'zero');
|
||||
const rows = []; for (let i=0; i<600; i++) rows.push({name: 'pkg-' + i, installed:'1', available:'2', origin: i%2 ? 'AUR' : 'Arch', sensitive: i===0 ? 'Noyau' : ''});
|
||||
center.updateState = {repoUpdates:300,aurUpdates:300,totalUpdates:600,packages:rows,lastCheck:'2026-11-08T01:00:00Z',error:''};
|
||||
} else if (step === 2) {
|
||||
check(center.filtered.length === 600, 'long list');
|
||||
const list = find(center.contentItem, 'updatePackageList'); check(list && list.count === 600, 'production list');
|
||||
list.positionViewAtEnd(); center.origin = 'AUR';
|
||||
} else if (step === 3) {
|
||||
check(center.filtered.length === 300, 'AUR filter'); center.query = 'pkg-599';
|
||||
} else if (step === 4) {
|
||||
check(center.filtered.length === 1, 'search'); center.origin = 'Arch';
|
||||
} else if (step === 5) {
|
||||
check(center.filtered.length === 0, 'Arch filter');
|
||||
center.updateState = {repoUpdates:0,aurUpdates:0,totalUpdates:0,packages:[],lastCheck:'',error:'network failure',offline:true};
|
||||
const tabs = find(center.contentItem, 'updateTabs'); check(!!tabs, 'tabs exist'); tabs.currentIndex = 1;
|
||||
} else if (step === 6) {
|
||||
check(center.maintenance.files.length === 1, 'pacnew'); check(center.maintenance.history.length === 1, 'history');
|
||||
find(center.contentItem, 'updateTabs').currentIndex = 2;
|
||||
} else if (step === 7) {
|
||||
check(center.news.items.length === 1, 'news'); center.refresh(); center.refresh();
|
||||
} else if (step === 8) {
|
||||
center.updateState = {repoUpdates:0,aurUpdates:0,totalUpdates:0,packages:[],lastCheck:'',error:'',checking:true,checkStarted:'2000-01-01T00:00:00Z'};
|
||||
check(center.staleCheck && !center.checking, 'interrupted check recoverable');
|
||||
center.visible = false;
|
||||
} else if (step === 9) { check(!center.visible, 'popup closes'); center.visible = true; }
|
||||
else if (step === 10) { check(center.visible, 'popup reopens'); center.terminal(true); }
|
||||
else { console.info('PASS update popup states'); Qt.quit(); }
|
||||
step++;
|
||||
}
|
||||
}
|
||||
}
|
||||
'''.replace('SOURCE', 'config/status')
|
||||
(p/'shell.qml').write_text(qml)
|
||||
env = dict(os.environ, PATH=f'{p}:/usr/bin', UPDATE_UI_FIXTURE=folder,
|
||||
QT_QPA_PLATFORM='wayland', WAYLAND_DISPLAY=str(Path(os.environ['XDG_RUNTIME_DIR']) / os.environ['WAYLAND_DISPLAY']), XDG_RUNTIME_DIR=str(p/'runtime'))
|
||||
result = subprocess.run([str(QS), '-p', str(p), '--no-color'], env=env, capture_output=True, text=True, timeout=20)
|
||||
logs = result.stdout + result.stderr
|
||||
self.assertEqual(result.returncode, 0, logs)
|
||||
self.assertIn('PASS update popup states', logs)
|
||||
self.assertNotIn('ReferenceError', logs)
|
||||
self.assertNotIn('TypeError', logs)
|
||||
commands = [json.loads(line) for line in (p/'commands').read_text().splitlines()]
|
||||
terminals = [cmd for cmd in commands if cmd[0] == 'kitty']
|
||||
self.assertEqual(len(terminals), 1, logs)
|
||||
self.assertEqual(terminals[0][-1], '--dry-run')
|
||||
self.assertEqual(terminals[0][1:3], ['--class', 'labfy-update-center'])
|
||||
refreshes = [cmd for cmd in commands if cmd == ['systemctl', '--user', 'start', 'labfy-quickshell-updates.service']]
|
||||
self.assertEqual(len(refreshes), 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -1,44 +1,151 @@
|
|||
"""Non-destructive tests of the actual update collector and terminal boundary."""
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1] / "quickshell/.config/quickshell/labfy-sway/bin/check-updates.py"
|
||||
SPEC = importlib.util.spec_from_file_location("quickshell_updates", SOURCE)
|
||||
updates = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(updates)
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BASE = ROOT / "quickshell/.config/quickshell/labfy-sway"
|
||||
|
||||
|
||||
class CountTests(unittest.TestCase):
|
||||
def check_count(self, command, returncode, stdout, stderr, expected):
|
||||
result = subprocess.CompletedProcess(command, returncode, stdout, stderr)
|
||||
with mock.patch.object(updates.subprocess, "run", return_value=result) as run:
|
||||
if isinstance(expected, type) and issubclass(expected, Exception):
|
||||
with self.assertRaises(expected):
|
||||
updates.count(command)
|
||||
else:
|
||||
self.assertEqual(updates.count(command), expected)
|
||||
run.assert_called_once_with(command, capture_output=True, text=True, timeout=120, check=False)
|
||||
def load(name, path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
def test_checkupdates_counts_lines(self):
|
||||
self.check_count(["checkupdates"], 0, "pkg-a 1 -> 2\npkg-b 3 -> 4\n", "", 2)
|
||||
|
||||
def test_checkupdates_no_updates(self):
|
||||
self.check_count(["checkupdates"], 2, "", "", 0)
|
||||
updates = load("quickshell_updates", BASE / "bin/check-updates.py")
|
||||
terminal = load("quickshell_update_terminal", BASE / "bin/update-terminal.py")
|
||||
|
||||
def test_checkupdates_code_one_is_error(self):
|
||||
self.check_count(["checkupdates"], 1, "", "", RuntimeError)
|
||||
|
||||
def test_checkupdates_code_two_with_stderr_is_error(self):
|
||||
self.check_count(["checkupdates"], 2, "", "erreur inattendue", RuntimeError)
|
||||
class CollectorTests(unittest.TestCase):
|
||||
def collect(self, command, code, stdout="", stderr=""):
|
||||
with mock.patch.object(updates.subprocess, "run", return_value=subprocess.CompletedProcess(command, code, stdout, stderr)):
|
||||
return updates.packages(command, "Arch" if command[0] == "checkupdates" else "AUR")
|
||||
|
||||
def test_yay_no_updates(self):
|
||||
self.check_count(["yay", "-Qua"], 1, "", "", 0)
|
||||
def test_zero(self):
|
||||
self.assertEqual(self.collect(["checkupdates"], 2), [])
|
||||
self.assertEqual(self.collect(["yay", "-Qua"], 1), [])
|
||||
self.assertEqual(self.collect(["yay", "-Qua"], 0), [])
|
||||
|
||||
def test_yay_counts_lines(self):
|
||||
self.check_count(["yay", "-Qua"], 0, "aur-a 1 -> 2\naur-b 3 -> 4\n", "", 2)
|
||||
def test_versions_and_sensitive(self):
|
||||
rows = self.collect(["checkupdates"], 0, "linux-zen 1 -> 2\nmesa 3 -> 4\nquickshell-git 4 -> 5\n")
|
||||
self.assertEqual([p["sensitive"] for p in rows], ["Noyau", "Graphique", "Session"])
|
||||
self.assertEqual(rows[0]["installed"], "1")
|
||||
self.assertEqual(rows[0]["available"], "2")
|
||||
self.assertEqual(rows[0]["origin"], "Arch")
|
||||
self.assertEqual(self.collect(["yay", "-Qua"], 0, "pkg-git 1 -> 2\n")[0]["origin"], "AUR")
|
||||
|
||||
def test_errors_not_zero(self):
|
||||
for command, code, stdout, stderr in ((["checkupdates"], 1, "", ""), (["checkupdates"], 2, "", "failure"),
|
||||
(["yay"], 1, "", "network"), (["yay"], 0, "bad output", "")):
|
||||
with self.subTest(command=command, code=code, stderr=stderr):
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.collect(command, code, stdout, stderr)
|
||||
|
||||
def test_snapshot_success_partial_failure_offline_recovery(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
runtime = Path(folder)
|
||||
row = dict(name="linux", installed="1", available="2", origin="Arch", sensitive="Noyau")
|
||||
with mock.patch.object(updates, "packages", side_effect=[[row], []]):
|
||||
self.assertFalse(updates.check(runtime))
|
||||
path = runtime / "labfy-quickshell-updates.json"
|
||||
self.assertEqual(json.loads(path.read_text())["totalUpdates"], 1)
|
||||
with mock.patch.object(updates, "packages", side_effect=[RuntimeError("network"), []]), mock.patch.object(updates, "offline", return_value=True):
|
||||
self.assertTrue(updates.check(runtime))
|
||||
state = json.loads(path.read_text())
|
||||
self.assertTrue(state["offline"])
|
||||
self.assertFalse(state["checking"])
|
||||
self.assertEqual(state["packages"], [row])
|
||||
self.assertEqual(state["totalUpdates"], 1)
|
||||
with mock.patch.object(updates, "packages", side_effect=[[], []]):
|
||||
self.assertFalse(updates.check(runtime))
|
||||
self.assertEqual(json.loads(path.read_text())["totalUpdates"], 0)
|
||||
|
||||
def test_corrupt_snapshot_recovers(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
runtime = Path(folder)
|
||||
path = runtime / "labfy-quickshell-updates.json"
|
||||
updates.publish(path, dict(repoUpdates="invalid", packages=[None]))
|
||||
with mock.patch.object(updates, "packages", side_effect=[[], []]):
|
||||
self.assertFalse(updates.check(runtime))
|
||||
self.assertEqual(json.loads(path.read_text())["totalUpdates"], 0)
|
||||
|
||||
def test_lock_prevents_second_check(self):
|
||||
with tempfile.TemporaryDirectory() as folder, mock.patch.dict(os.environ, XDG_RUNTIME_DIR=folder), mock.patch.object(updates.sys, "argv", ["check-updates.py"]):
|
||||
with open(Path(folder)/"labfy-updates.lock", "w") as lock:
|
||||
updates.fcntl.flock(lock, updates.fcntl.LOCK_EX)
|
||||
with mock.patch.object(updates, "check") as check:
|
||||
self.assertEqual(updates.main(), 0)
|
||||
check.assert_not_called()
|
||||
|
||||
def test_offline_probe(self):
|
||||
with mock.patch.object(updates.socket, "create_connection", side_effect=OSError("offline")):
|
||||
self.assertTrue(updates.offline())
|
||||
|
||||
def test_news_explicit_only_and_safe_links(self):
|
||||
data = b'''<rss><channel><item><title>linux requires manual intervention</title><link>https://archlinux.org/news/test/</link><pubDate>today</pubDate></item><item><title>linux update</title><link>https://archlinux.org/news/other/</link></item><item><title>bad</title><link>file:///etc/passwd</link></item></channel></rss>'''
|
||||
rows = updates.parse_news(data)
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertTrue(rows[0]["manual"])
|
||||
self.assertFalse(rows[1]["manual"])
|
||||
|
||||
def test_news_offline_cache_and_backoff(self):
|
||||
with tempfile.TemporaryDirectory() as folder, mock.patch.dict(os.environ, XDG_CACHE_HOME=folder):
|
||||
cache = Path(folder)/"labfy-update-news.json"
|
||||
updates.publish(cache, dict(fetched=1, items=[dict(title="cached")]))
|
||||
with mock.patch.object(updates.urllib.request, "urlopen", side_effect=OSError("offline")) as urlopen:
|
||||
first = updates.news()
|
||||
second = updates.news()
|
||||
self.assertEqual(first["items"], second["items"])
|
||||
self.assertIn("cache conservé", second["error"])
|
||||
self.assertEqual(urlopen.call_count, 1)
|
||||
|
||||
def test_maintenance_read_only_and_partial_scan(self):
|
||||
# Synthetic /etc fixture includes forbidden-to-change files and an access error.
|
||||
def walk(root, **kwargs):
|
||||
self.assertEqual(root, "/etc")
|
||||
self.assertFalse(kwargs["followlinks"])
|
||||
kwargs["onerror"](PermissionError("restricted"))
|
||||
yield "/etc", [], ["foo.pacnew", "bar.pacsave", "normal"]
|
||||
log = mock.mock_open(read_data=b"[2026-11-08] [ALPM] transaction completed\n")
|
||||
log.return_value.tell.return_value = 55
|
||||
with mock.patch("builtins.open", log), mock.patch.object(updates.os, "walk", side_effect=walk), mock.patch.object(updates, "output", return_value="bad.service loaded failed failed demo\n"):
|
||||
result = updates.maintenance()
|
||||
self.assertEqual(result["files"], ["/etc/bar.pacsave", "/etc/foo.pacnew"])
|
||||
self.assertEqual(len(result["failed"]), 2)
|
||||
self.assertTrue(result["errors"])
|
||||
self.assertTrue(result["history"])
|
||||
self.assertTrue(result["reboot"])
|
||||
|
||||
|
||||
class TerminalTests(unittest.TestCase):
|
||||
def test_preview_never_installs(self):
|
||||
with mock.patch.object(terminal.sys, "argv", ["terminal", "--dry-run"]), mock.patch("builtins.input", return_value=""), mock.patch.object(terminal.subprocess, "call") as call, mock.patch.object(terminal.subprocess, "run") as run, mock.patch("sys.stdout", new_callable=io.StringIO):
|
||||
self.assertEqual(terminal.main(), 0)
|
||||
call.assert_not_called()
|
||||
run.assert_not_called()
|
||||
|
||||
def test_declined_confirmation_never_installs(self):
|
||||
with mock.patch.object(terminal.sys, "argv", ["terminal"]), mock.patch.object(terminal.sys.stdin, "isatty", return_value=True), mock.patch.object(terminal.os, "geteuid", return_value=1000), mock.patch.object(terminal.shutil, "which", return_value="/usr/bin/yay"), mock.patch("builtins.input", return_value="non"), mock.patch.object(terminal.subprocess, "call") as call, mock.patch("sys.stdout", new_callable=io.StringIO):
|
||||
self.assertEqual(terminal.main(), 0)
|
||||
call.assert_not_called()
|
||||
|
||||
def test_confirmed_argv_with_fake_process_only(self):
|
||||
# Even the affirmative branch is intercepted: no real package manager is run.
|
||||
with tempfile.TemporaryDirectory() as folder, mock.patch.dict(os.environ, XDG_RUNTIME_DIR=folder), mock.patch.object(terminal.sys, "argv", ["terminal"]), mock.patch.object(terminal.sys.stdin, "isatty", return_value=True), mock.patch.object(terminal.os, "geteuid", return_value=1000), mock.patch.object(terminal.shutil, "which", return_value="/usr/bin/yay"), mock.patch("builtins.input", side_effect=["METTRE A JOUR", ""]), mock.patch.object(terminal.subprocess, "call", return_value=0) as call, mock.patch.object(terminal.subprocess, "run") as run, mock.patch("sys.stdout", new_callable=io.StringIO):
|
||||
self.assertEqual(terminal.main(), 0)
|
||||
argv = call.call_args.args[0]
|
||||
self.assertEqual(argv[:3], ["yay", "-Syu", "--confirm"])
|
||||
self.assertIn("--sudoloop=false", argv)
|
||||
self.assertNotIn("--noconfirm", argv)
|
||||
run.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
Loading…
Reference in a new issue