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.
|
"""Classement manuel de A_trier, compatible avec la photothèque Labfy.
|
||||||
|
|
||||||
Photos et vidéos -> Photos/AAAA/AAAA-MM-JJ/
|
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.
|
Aucun fichier existant n'est remplacé. Les autres fichiers restent dans A_trier.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -22,10 +22,12 @@ import unicodedata
|
||||||
BASE = Path.home() / "Pictures" / "Phototheque"
|
BASE = Path.home() / "Pictures" / "Phototheque"
|
||||||
RAW = {".arw", ".dng", ".cr2", ".cr3", ".nef", ".nrw", ".raf",
|
RAW = {".arw", ".dng", ".cr2", ".cr3", ".nef", ".nrw", ".raf",
|
||||||
".orf", ".rw2", ".pef", ".srw", ".3fr", ".iiq"}
|
".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"}
|
".heic", ".heif", ".avif", ".webp", ".bmp"}
|
||||||
VIDEO = {".mov", ".mp4", ".m4v", ".avi", ".mts", ".m2ts", ".3gp"}
|
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)")
|
DATE_NAME = re.compile(r"(?<!\d)((?:19|20)\d{2})[-_]?(\d{2})[-_]?(\d{2})(?!\d)")
|
||||||
EXIFTOOL = shutil.which("exiftool")
|
EXIFTOOL = shutil.which("exiftool")
|
||||||
|
|
||||||
|
|
@ -89,17 +91,17 @@ def renamed(path, index):
|
||||||
|
|
||||||
|
|
||||||
def target_root(path, group, photos, originaux):
|
def target_root(path, group, photos, originaux):
|
||||||
if path.suffix.lower() in RAW:
|
if path.suffix.lower() in ORIGINAL:
|
||||||
return originaux
|
return originaux
|
||||||
if path.suffix.lower() != ".xmp":
|
if path.suffix.lower() != ".xmp":
|
||||||
return photos
|
return photos
|
||||||
associated = Path(path.name[:-4]).suffix.lower()
|
associated = Path(path.name[:-4]).suffix.lower()
|
||||||
if associated in RAW:
|
if associated in ORIGINAL:
|
||||||
return originaux
|
return originaux
|
||||||
if associated in PHOTO | VIDEO:
|
if associated in PHOTO | VIDEO:
|
||||||
return photos
|
return photos
|
||||||
# XMP générique : privilégier les RAW s'ils sont présents.
|
# XMP générique : privilégier les originaux (RAW ou TIFF) présents.
|
||||||
return originaux if any(p.suffix.lower() in RAW for p in group) else photos
|
return originaux if any(p.suffix.lower() in ORIGINAL for p in group) else photos
|
||||||
|
|
||||||
|
|
||||||
def key(name):
|
def key(name):
|
||||||
|
|
@ -178,7 +180,7 @@ def run(args):
|
||||||
if not media:
|
if not media:
|
||||||
orphans += len(group)
|
orphans += len(group)
|
||||||
continue # Ne pas déplacer les XMP orphelins.
|
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))
|
p.suffix.lower() in VIDEO, p.name))
|
||||||
try:
|
try:
|
||||||
taken, origin = date_of(media[0])
|
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.
|
||||||
|
|
@ -14,4 +14,4 @@
|
||||||
"stop_sound_path": "ping-down.ogg",
|
"stop_sound_path": "ping-down.ogg",
|
||||||
"audio_ducking": true,
|
"audio_ducking": true,
|
||||||
"whisper_prompt_fr": "Transcrire en fran\u00e7ais avec des phrases compl\u00e8tes, une ponctuation claire et des majuscules correctes au d\u00e9but des phrases, pour les noms propres et les titres, comme dans un texte r\u00e9dig\u00e9"
|
"whisper_prompt_fr": "Transcrire en fran\u00e7ais avec des phrases compl\u00e8tes, une ponctuation claire et des majuscules correctes au d\u00e9but des phrases, pour les noms propres et les titres, comme dans un texte r\u00e9dig\u00e9"
|
||||||
}
|
}
|
||||||
|
|
@ -59,7 +59,7 @@ PanelWindow {
|
||||||
closeApplications(); closeOverview(); closeClipboard();
|
closeApplications(); closeOverview(); closeClipboard();
|
||||||
controlCenter.visible = false; dateCenter.requestClose();
|
controlCenter.visible = false; dateCenter.requestClose();
|
||||||
windowStrip.closeMenu(); rightStatusArea.closeRemovableMedia();
|
windowStrip.closeMenu(); rightStatusArea.closeRemovableMedia();
|
||||||
rightStatusArea.closePrinting(); rightStatusArea.closePhotoBackup(); closeDrawer();
|
rightStatusArea.closePrinting(); rightStatusArea.closePhotoBackup(); rightStatusArea.closeUpdates(); closeDrawer();
|
||||||
monitorService.open(screen.name);
|
monitorService.open(screen.name);
|
||||||
}
|
}
|
||||||
Connections {
|
Connections {
|
||||||
|
|
@ -192,6 +192,7 @@ PanelWindow {
|
||||||
rightStatusArea.closeRemovableMedia();
|
rightStatusArea.closeRemovableMedia();
|
||||||
rightStatusArea.closePrinting();
|
rightStatusArea.closePrinting();
|
||||||
rightStatusArea.closePhotoBackup();
|
rightStatusArea.closePhotoBackup();
|
||||||
|
rightStatusArea.closeUpdates();
|
||||||
bar.closeDrawer();
|
bar.closeDrawer();
|
||||||
}
|
}
|
||||||
function onApplicationsOpening(output) {
|
function onApplicationsOpening(output) {
|
||||||
|
|
@ -204,6 +205,7 @@ PanelWindow {
|
||||||
rightStatusArea.closeRemovableMedia();
|
rightStatusArea.closeRemovableMedia();
|
||||||
rightStatusArea.closePrinting();
|
rightStatusArea.closePrinting();
|
||||||
rightStatusArea.closePhotoBackup();
|
rightStatusArea.closePhotoBackup();
|
||||||
|
rightStatusArea.closeUpdates();
|
||||||
bar.closeDrawer();
|
bar.closeDrawer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -256,6 +258,7 @@ PanelWindow {
|
||||||
rightStatusArea.closeRemovableMedia();
|
rightStatusArea.closeRemovableMedia();
|
||||||
rightStatusArea.closePrinting();
|
rightStatusArea.closePrinting();
|
||||||
rightStatusArea.closePhotoBackup();
|
rightStatusArea.closePhotoBackup();
|
||||||
|
rightStatusArea.closeUpdates();
|
||||||
bar.closeDrawer();
|
bar.closeDrawer();
|
||||||
clipboardLoader.active = true;
|
clipboardLoader.active = true;
|
||||||
}
|
}
|
||||||
|
|
@ -282,6 +285,7 @@ PanelWindow {
|
||||||
rightStatusArea.closeRemovableMedia();
|
rightStatusArea.closeRemovableMedia();
|
||||||
rightStatusArea.closePrinting();
|
rightStatusArea.closePrinting();
|
||||||
rightStatusArea.closePhotoBackup();
|
rightStatusArea.closePhotoBackup();
|
||||||
|
rightStatusArea.closeUpdates();
|
||||||
bar.closeDrawer();
|
bar.closeDrawer();
|
||||||
overviewRequested = true;
|
overviewRequested = true;
|
||||||
overviewLoader.active = true;
|
overviewLoader.active = true;
|
||||||
|
|
@ -348,6 +352,7 @@ PanelWindow {
|
||||||
windowStrip.closeMenu();
|
windowStrip.closeMenu();
|
||||||
rightStatusArea.closePrinting();
|
rightStatusArea.closePrinting();
|
||||||
rightStatusArea.closePhotoBackup();
|
rightStatusArea.closePhotoBackup();
|
||||||
|
rightStatusArea.closeUpdates();
|
||||||
controlCenter.openPage(page);
|
controlCenter.openPage(page);
|
||||||
controlCenter.visible = true;
|
controlCenter.visible = true;
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -366,6 +371,7 @@ PanelWindow {
|
||||||
dateCenter.requestClose();
|
dateCenter.requestClose();
|
||||||
rightStatusArea.closePrinting();
|
rightStatusArea.closePrinting();
|
||||||
rightStatusArea.closePhotoBackup();
|
rightStatusArea.closePhotoBackup();
|
||||||
|
rightStatusArea.closeUpdates();
|
||||||
return windowStrip.openActiveMenu();
|
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.
|
// Le bouton de sauvegarde utilise la même exclusion que les autres popups.
|
||||||
function togglePhotoBackup() {
|
function togglePhotoBackup() {
|
||||||
closeMonitor();
|
closeMonitor();
|
||||||
|
|
@ -420,6 +447,7 @@ PanelWindow {
|
||||||
rightStatusArea.closeRemovableMedia();
|
rightStatusArea.closeRemovableMedia();
|
||||||
rightStatusArea.closePrinting();
|
rightStatusArea.closePrinting();
|
||||||
bar.closeDrawer();
|
bar.closeDrawer();
|
||||||
|
rightStatusArea.closeUpdates();
|
||||||
rightStatusArea.setPhotoBackupOpen(opening);
|
rightStatusArea.setPhotoBackupOpen(opening);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -447,6 +475,7 @@ PanelWindow {
|
||||||
rightStatusArea.closeRemovableMedia();
|
rightStatusArea.closeRemovableMedia();
|
||||||
rightStatusArea.closePrinting();
|
rightStatusArea.closePrinting();
|
||||||
rightStatusArea.closePhotoBackup();
|
rightStatusArea.closePhotoBackup();
|
||||||
|
rightStatusArea.closeUpdates();
|
||||||
bar.closeDrawer();
|
bar.closeDrawer();
|
||||||
if (opening) dateCenter.requestOpen();
|
if (opening) dateCenter.requestOpen();
|
||||||
else dateCenter.requestClose();
|
else dateCenter.requestClose();
|
||||||
|
|
@ -465,6 +494,7 @@ PanelWindow {
|
||||||
rightStatusArea.closeRemovableMedia();
|
rightStatusArea.closeRemovableMedia();
|
||||||
rightStatusArea.closePrinting();
|
rightStatusArea.closePrinting();
|
||||||
rightStatusArea.closePhotoBackup();
|
rightStatusArea.closePhotoBackup();
|
||||||
|
rightStatusArea.closeUpdates();
|
||||||
}
|
}
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
|
|
@ -578,6 +608,7 @@ PanelWindow {
|
||||||
rightStatusArea.closeRemovableMedia();
|
rightStatusArea.closeRemovableMedia();
|
||||||
rightStatusArea.closePrinting();
|
rightStatusArea.closePrinting();
|
||||||
rightStatusArea.closePhotoBackup();
|
rightStatusArea.closePhotoBackup();
|
||||||
|
rightStatusArea.closeUpdates();
|
||||||
bar.closeDrawer();
|
bar.closeDrawer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -637,6 +668,7 @@ PanelWindow {
|
||||||
rightStatusArea.closeRemovableMedia();
|
rightStatusArea.closeRemovableMedia();
|
||||||
rightStatusArea.closePrinting();
|
rightStatusArea.closePrinting();
|
||||||
rightStatusArea.closePhotoBackup();
|
rightStatusArea.closePhotoBackup();
|
||||||
|
rightStatusArea.closeUpdates();
|
||||||
bar.closeDrawer();
|
bar.closeDrawer();
|
||||||
controlCenter.currentPage = page;
|
controlCenter.currentPage = page;
|
||||||
controlCenter.visible = true;
|
controlCenter.visible = true;
|
||||||
|
|
@ -652,6 +684,7 @@ PanelWindow {
|
||||||
rightStatusArea.closeRemovableMedia();
|
rightStatusArea.closeRemovableMedia();
|
||||||
rightStatusArea.closePrinting();
|
rightStatusArea.closePrinting();
|
||||||
rightStatusArea.closePhotoBackup();
|
rightStatusArea.closePhotoBackup();
|
||||||
|
rightStatusArea.closeUpdates();
|
||||||
bar.closeDrawer();
|
bar.closeDrawer();
|
||||||
controlCenter.currentPage = 0;
|
controlCenter.currentPage = 0;
|
||||||
controlCenter.visible = opening;
|
controlCenter.visible = opening;
|
||||||
|
|
@ -667,9 +700,11 @@ PanelWindow {
|
||||||
windowStrip.closeMenu();
|
windowStrip.closeMenu();
|
||||||
rightStatusArea.closePrinting();
|
rightStatusArea.closePrinting();
|
||||||
rightStatusArea.closePhotoBackup();
|
rightStatusArea.closePhotoBackup();
|
||||||
|
rightStatusArea.closeUpdates();
|
||||||
rightStatusArea.setRemovableMediaOpen(opening);
|
rightStatusArea.setRemovableMediaOpen(opening);
|
||||||
bar.closeDrawer();
|
bar.closeDrawer();
|
||||||
}
|
}
|
||||||
|
onToggleUpdatesRequested: bar.toggleUpdates()
|
||||||
onTogglePrintingRequested: {
|
onTogglePrintingRequested: {
|
||||||
bar.closeMonitor();
|
bar.closeMonitor();
|
||||||
if (bar.authenticationActive) return;
|
if (bar.authenticationActive) return;
|
||||||
|
|
@ -682,6 +717,7 @@ PanelWindow {
|
||||||
windowStrip.closeMenu();
|
windowStrip.closeMenu();
|
||||||
rightStatusArea.closeRemovableMedia();
|
rightStatusArea.closeRemovableMedia();
|
||||||
rightStatusArea.closePhotoBackup();
|
rightStatusArea.closePhotoBackup();
|
||||||
|
rightStatusArea.closeUpdates();
|
||||||
bar.closeDrawer();
|
bar.closeDrawer();
|
||||||
rightStatusArea.setPrintingOpen(opening);
|
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,
|
et `ApplicationIcon.qml`. Le stockage existant reste inchangé. Architecture,
|
||||||
interactions, tests headless, limites et procédure d’intégration :
|
interactions, tests headless, limites et procédure d’intégration :
|
||||||
[Dock Tela / favoris V1](../../../../docs/quickshell-dock-tela-shared-favorites-v1.md).
|
[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."""
|
"""Publie un instantané Arch/AUR borné pour la barre QuickShell."""
|
||||||
|
|
||||||
import datetime
|
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 json
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
@ -10,47 +18,232 @@ import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
def count(command):
|
def now():
|
||||||
# CONTRACT: aucune mise à jour n'est appliquée ; chaque vérification est bornée.
|
return datetime.datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
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 main():
|
def publish(destination, state):
|
||||||
runtime = pathlib.Path(os.environ["XDG_RUNTIME_DIR"])
|
# CONTRACT: readers only see complete UTF-8 snapshots, never a partial write.
|
||||||
destination = runtime / "labfy-quickshell-updates.json"
|
fd, temporary = tempfile.mkstemp(prefix="labfy-updates-", dir=destination.parent)
|
||||||
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)
|
|
||||||
try:
|
try:
|
||||||
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
||||||
json.dump(state, stream, ensure_ascii=False)
|
json.dump(state, stream, ensure_ascii=False)
|
||||||
stream.write("\n")
|
|
||||||
stream.flush()
|
stream.flush()
|
||||||
os.fsync(stream.fileno())
|
os.fsync(stream.fileno())
|
||||||
os.replace(temporary, destination)
|
os.replace(temporary, destination)
|
||||||
finally:
|
finally:
|
||||||
if os.path.exists(temporary):
|
if os.path.exists(temporary):
|
||||||
os.unlink(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__":
|
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
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
import Quickshell
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
import Quickshell.Bluetooth
|
import Quickshell.Bluetooth
|
||||||
import "../components"
|
import "../components"
|
||||||
import "../theme"
|
import "../theme"
|
||||||
|
|
@ -10,6 +11,7 @@ Item {
|
||||||
required property var adapter
|
required property var adapter
|
||||||
required property bool activePage
|
required property bool activePage
|
||||||
signal backRequested()
|
signal backRequested()
|
||||||
|
signal fileChooserRequested()
|
||||||
property var scanOwnedAdapter: null
|
property var scanOwnedAdapter: null
|
||||||
property string stage: "main" // main, details, forget, block
|
property string stage: "main" // main, details, forget, block
|
||||||
property string selectedAddress: ""
|
property string selectedAddress: ""
|
||||||
|
|
@ -46,7 +48,47 @@ Item {
|
||||||
if (device.connected) return "Connecté";
|
if (device.connected) return "Connecté";
|
||||||
return device.paired ? "Appairé • déconnecté" : "Non appairé";
|
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() {
|
function back() {
|
||||||
if (stage === "main") backRequested();
|
if (stage === "main") backRequested();
|
||||||
else if (stage === "forget" || stage === "block") stage = "details";
|
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: "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 {
|
Row {
|
||||||
spacing: 8
|
spacing: 8
|
||||||
Text { width: 165; text: "Appareil de confiance"; color: Theme.foreground; font.pixelSize: 12 }
|
Text { width: 165; text: "Appareil de confiance"; color: Theme.foreground; font.pixelSize: 12 }
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import Quickshell.Bluetooth
|
||||||
import "../components"
|
import "../components"
|
||||||
import "../theme"
|
import "../theme"
|
||||||
import "../sessionui"
|
import "../sessionui"
|
||||||
|
import "../rgbkeyboard"
|
||||||
|
|
||||||
PopupWindow {
|
PopupWindow {
|
||||||
id: popup
|
id: popup
|
||||||
|
|
@ -18,7 +19,7 @@ PopupWindow {
|
||||||
readonly property var pages: ({ main: 0, wifi: 1, bluetooth: 2, session: 3,
|
readonly property var pages: ({ main: 0, wifi: 1, bluetooth: 2, session: 3,
|
||||||
confirmation: 4, battery: 5, appearance: 6, wallpaper: 7, theme: 8,
|
confirmation: 4, battery: 5, appearance: 6, wallpaper: 7, theme: 8,
|
||||||
nightLight: 9, sunMode: 10, locationTime: 11, sessionManager: 12,
|
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
|
property int currentPage: pages.main
|
||||||
function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; }
|
function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; }
|
||||||
signal thresholdApplied()
|
signal thresholdApplied()
|
||||||
|
|
@ -131,6 +132,7 @@ PopupWindow {
|
||||||
: currentPage === pages.wallpaper ? 550
|
: currentPage === pages.wallpaper ? 550
|
||||||
: currentPage === pages.appearance ? 610
|
: currentPage === pages.appearance ? 610
|
||||||
: currentPage === pages.locationTime || currentPage === pages.sessionManager ? 650
|
: currentPage === pages.locationTime || currentPage === pages.sessionManager ? 650
|
||||||
|
: currentPage === pages.RGB_KEYBOARD ? 610
|
||||||
: currentPage === pages.audio ? 620 : 440
|
: currentPage === pages.audio ? 620 : 440
|
||||||
visible: false
|
visible: false
|
||||||
// CONTRACT: PopupWindow n'applique un changement de grabFocus qu'après
|
// 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") }
|
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 {
|
Item {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: 30
|
height: 30
|
||||||
|
|
@ -250,6 +260,7 @@ PopupWindow {
|
||||||
|
|
||||||
BluetoothPage {
|
BluetoothPage {
|
||||||
id: bluetoothPage
|
id: bluetoothPage
|
||||||
|
onFileChooserRequested: popup.visible = false
|
||||||
adapter: popup.adapter
|
adapter: popup.adapter
|
||||||
activePage: popup.visible && popup.currentPage === 2
|
activePage: popup.visible && popup.currentPage === 2
|
||||||
onBackRequested: popup.openPage("main")
|
onBackRequested: popup.openPage("main")
|
||||||
|
|
@ -372,6 +383,11 @@ PopupWindow {
|
||||||
controller: popup.keepAwakeController
|
controller: popup.keepAwakeController
|
||||||
onBackRequested: popup.openPage("main")
|
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 openPageRequested(int page)
|
||||||
signal toggleControlCenterRequested()
|
signal toggleControlCenterRequested()
|
||||||
signal toggleRemovableMediaRequested()
|
signal toggleRemovableMediaRequested()
|
||||||
|
signal toggleUpdatesRequested()
|
||||||
signal togglePrintingRequested()
|
signal togglePrintingRequested()
|
||||||
signal togglePhotoBackupRequested()
|
signal togglePhotoBackupRequested()
|
||||||
signal toggleClipboardRequested()
|
signal toggleClipboardRequested()
|
||||||
|
|
@ -24,6 +25,9 @@ Row {
|
||||||
function closeRemovableMedia() { removableMediaIndicator.popupOpen = false }
|
function closeRemovableMedia() { removableMediaIndicator.popupOpen = false }
|
||||||
function setRemovableMediaOpen(open) { removableMediaIndicator.popupOpen = open }
|
function setRemovableMediaOpen(open) { removableMediaIndicator.popupOpen = open }
|
||||||
readonly property bool removableMediaOpen: removableMediaIndicator.popupOpen
|
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 closePrinting() { printingIndicator.popupOpen = false }
|
||||||
function setPrintingOpen(open) { printingIndicator.popupOpen = open }
|
function setPrintingOpen(open) { printingIndicator.popupOpen = open }
|
||||||
readonly property bool printingOpen: printingIndicator.popupOpen
|
readonly property bool printingOpen: printingIndicator.popupOpen
|
||||||
|
|
@ -91,7 +95,11 @@ Row {
|
||||||
unlimited: area.keepAwakeController.keepAwakeMinutes === 0
|
unlimited: area.keepAwakeController.keepAwakeMinutes === 0
|
||||||
onActivated: area.openPageRequested(16)
|
onActivated: area.openPageRequested(16)
|
||||||
}
|
}
|
||||||
UpdatesIndicator { }
|
UpdatesIndicator {
|
||||||
|
id: updatesIndicator
|
||||||
|
barWindow: area.barWindow
|
||||||
|
onToggleRequested: area.toggleUpdatesRequested()
|
||||||
|
}
|
||||||
RemovableMediaIndicator {
|
RemovableMediaIndicator {
|
||||||
id: removableMediaIndicator
|
id: removableMediaIndicator
|
||||||
onToggleRequested: area.toggleRemovableMediaRequested()
|
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 {
|
Item {
|
||||||
id: indicator
|
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 string statePath: Quickshell.env("XDG_RUNTIME_DIR") + "/labfy-quickshell-updates.json"
|
||||||
readonly property var state: {
|
readonly property var state: {
|
||||||
const raw = snapshot.text();
|
const raw = snapshot.text();
|
||||||
|
|
@ -15,7 +19,11 @@ Item {
|
||||||
const value = JSON.parse(raw);
|
const value = JSON.parse(raw);
|
||||||
if (!Number.isSafeInteger(value.repoUpdates) || value.repoUpdates < 0
|
if (!Number.isSafeInteger(value.repoUpdates) || value.repoUpdates < 0
|
||||||
|| !Number.isSafeInteger(value.aurUpdates) || value.aurUpdates < 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");
|
throw new Error("État invalide");
|
||||||
return value;
|
return value;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -24,7 +32,7 @@ Item {
|
||||||
}
|
}
|
||||||
readonly property bool hasError: Boolean(state.error)
|
readonly property bool hasError: Boolean(state.error)
|
||||||
readonly property bool pending: state.error === "Vérification en attente"
|
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
|
width: visible ? icon.width + (hasError ? 0 : count.implicitWidth + 3) + 6 : 0
|
||||||
height: 26
|
height: 26
|
||||||
readonly property string detail: hasError
|
readonly property string detail: hasError
|
||||||
|
|
@ -33,7 +41,8 @@ Item {
|
||||||
+ "\n" + state.repoUpdates + " Arch\n" + state.aurUpdates + " AUR"
|
+ "\n" + state.repoUpdates + " Arch\n" + state.aurUpdates + " AUR"
|
||||||
+ (state.lastCheck ? "\nDernière vérification : " + Qt.formatTime(new Date(state.lastCheck), "HH:mm") : "")
|
+ (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 {
|
FileView {
|
||||||
id: snapshot
|
id: snapshot
|
||||||
path: indicator.statePath
|
path: indicator.statePath
|
||||||
|
|
@ -42,12 +51,13 @@ Item {
|
||||||
onFileChanged: reload()
|
onFileChanged: reload()
|
||||||
}
|
}
|
||||||
// Après un redémarrage, /run/user ne contient pas encore le fichier :
|
// 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 {
|
Timer {
|
||||||
interval: 10000
|
interval: 10000
|
||||||
repeat: true
|
repeat: true
|
||||||
running: !snapshot.loaded
|
running: !snapshot.loaded || indicator.popupOpen
|
||||||
onTriggered: snapshot.reload()
|
onTriggered: { snapshot.reload(); center.tick = Date.now(); }
|
||||||
}
|
}
|
||||||
Rectangle { anchors.fill: parent; radius: 4; color: pointer.containsMouse ? Theme.border : "transparent" }
|
Rectangle { anchors.fill: parent; radius: 4; color: pointer.containsMouse ? Theme.border : "transparent" }
|
||||||
NerdIcon {
|
NerdIcon {
|
||||||
|
|
@ -72,6 +82,6 @@ Item {
|
||||||
font.family: "JetBrainsMono Nerd Font Mono"
|
font.family: "JetBrainsMono Nerd Font Mono"
|
||||||
font.pixelSize: 12
|
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 }
|
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.
|
# 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
|
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
|
# 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]
|
[Service]
|
||||||
Type=simple
|
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
|
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=HYPRWHSPR_ROOT=/usr/lib/hyprwhspr
|
||||||
Environment=PYTHONUNBUFFERED=1
|
Environment=PYTHONUNBUFFERED=1
|
||||||
Restart=on-failure
|
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 importlib.util
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
SOURCE = Path(__file__).resolve().parents[1] / "quickshell/.config/quickshell/labfy-sway/bin/check-updates.py"
|
BASE = ROOT / "quickshell/.config/quickshell/labfy-sway"
|
||||||
SPEC = importlib.util.spec_from_file_location("quickshell_updates", SOURCE)
|
|
||||||
updates = importlib.util.module_from_spec(SPEC)
|
|
||||||
SPEC.loader.exec_module(updates)
|
|
||||||
|
|
||||||
|
|
||||||
class CountTests(unittest.TestCase):
|
def load(name, path):
|
||||||
def check_count(self, command, returncode, stdout, stderr, expected):
|
spec = importlib.util.spec_from_file_location(name, path)
|
||||||
result = subprocess.CompletedProcess(command, returncode, stdout, stderr)
|
module = importlib.util.module_from_spec(spec)
|
||||||
with mock.patch.object(updates.subprocess, "run", return_value=result) as run:
|
spec.loader.exec_module(module)
|
||||||
if isinstance(expected, type) and issubclass(expected, Exception):
|
return module
|
||||||
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 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):
|
updates = load("quickshell_updates", BASE / "bin/check-updates.py")
|
||||||
self.check_count(["checkupdates"], 2, "", "", 0)
|
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):
|
class CollectorTests(unittest.TestCase):
|
||||||
self.check_count(["checkupdates"], 2, "", "erreur inattendue", RuntimeError)
|
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):
|
def test_zero(self):
|
||||||
self.check_count(["yay", "-Qua"], 1, "", "", 0)
|
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):
|
def test_versions_and_sensitive(self):
|
||||||
self.check_count(["yay", "-Qua"], 0, "aur-a 1 -> 2\naur-b 3 -> 4\n", "", 2)
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue