diff --git a/.gitignore b/.gitignore index a1297cb9a..7e0dc1848 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ hyprwhspr/.local/share/ **/node_modules/ sway/.config/sway/generated/theme.conf sway/.config/sway/generated/theme.conf.tmp +sway/.config/sway/generated/wallpaper.conf +sway/.config/sway/generated/wallpaper.conf.tmp # Local editor/application backups *.bak diff --git a/bin/.local/bin/generate-appearance.py b/bin/.local/bin/generate-appearance.py index 71fa8a8a6..2ab505165 100755 --- a/bin/.local/bin/generate-appearance.py +++ b/bin/.local/bin/generate-appearance.py @@ -98,10 +98,14 @@ def main(): or previous.get('effectiveAccent') != args.accent or previous.get('effectiveMode') != 'normal') revision = max(0, int(previous.get('revision', 0))) + int(changed) - state = {'version': 1, 'effectiveFlavor': args.flavor, - 'effectiveMode': 'normal', 'effectiveDark': args.flavor != 'latte', - 'effectiveHighContrast': False, 'effectiveAccent': args.accent, - 'revision': revision} + # CONTRACT: le moteur de thème conserve les champs wallpaper + # du schéma 2 ; changer une palette n'efface pas l'état 17C. + state = dict(previous) + state.update(version=max(1, int(previous.get('version', 1))), + effectiveFlavor=args.flavor, effectiveMode='normal', + effectiveDark=args.flavor != 'latte', + effectiveHighContrast=False, effectiveAccent=args.accent, + revision=revision) # CONTRACT: le backend persiste l'état après validation Sway ; # le contrôleur l'expose aux consommateurs et le lit au restart. atomic_write(STATE, (json.dumps(state, separators=(',', ':')) + '\n').encode('utf-8')) diff --git a/bin/.local/bin/wallpaper-manager.py b/bin/.local/bin/wallpaper-manager.py new file mode 100755 index 000000000..f1cae0698 --- /dev/null +++ b/bin/.local/bin/wallpaper-manager.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +"""Galerie locale et publication transactionnelle du wallpaper Sway (schéma 2).""" +import argparse +import fcntl +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import time +import warnings +from urllib.parse import unquote, urlsplit + +from PIL import Image, ImageOps, UnidentifiedImageError + +# WHY: une image piégée ne doit pas faire exploser la mémoire du processus +# de scan ; la limite s'applique à un fichier, pas à la taille du dossier. +Image.MAX_IMAGE_PIXELS = 50_000_000 +warnings.simplefilter('error', Image.DecompressionBombWarning) + + +DEFAULT = Path('/usr/share/backgrounds/sway/Sway_Wallpaper_Blue_1920x1080.png') +SUFFIXES = {'.jpg', '.jpeg', '.png', '.webp'} +THUMB_VERSION = 1 +PAGE_SIZE = 24 +SWAY = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'sway' +PREFERENCES = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'labfy-appearance/preferences.json' +STATE = Path(os.environ.get('XDG_STATE_HOME', Path.home() / '.local/state')) / 'labfy-appearance/effective.json' +CACHE = Path(os.environ.get('XDG_CACHE_HOME', Path.home() / '.cache')) / 'labfy-appearance/wallpapers' +GENERATED = SWAY / 'generated/wallpaper.conf' +ALIASES = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'labfy-appearance/wallpapers' + + +def atomic_write(path, data): + """Un rename sur le même volume préserve l'ancien fichier en cas d'échec.""" + path.parent.mkdir(parents=True, exist_ok=True) + fd, name = tempfile.mkstemp(prefix=path.name + '.', suffix='.tmp', dir=path.parent) + try: + with os.fdopen(fd, 'wb') as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, path) + directory = os.open(path.parent, os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + Path(name).unlink(missing_ok=True) + + +def write_json(path, value): + atomic_write(path, (json.dumps(value, ensure_ascii=False, separators=(',', ':')) + '\n').encode()) + + +def read_json(path): + try: + return json.loads(path.read_text(encoding='utf-8')) + except (OSError, ValueError): + return {} + + +def initial_directory(): + """Préférence valide, .wallpapers, Pictures/Wallpapers, XDG Pictures, HOME.""" + pref = read_json(PREFERENCES).get('wallpaperDirectory') + home = Path.home() + pictures = home / 'Pictures' + user_dirs = home / '.config/user-dirs.dirs' + try: + for line in user_dirs.read_text(encoding='utf-8').splitlines(): + if line.startswith('XDG_PICTURES_DIR='): + pictures = Path(line.split('=', 1)[1].strip('"').replace('$HOME', str(home))) + except OSError: + pass + for item in (pref, str(home / '.wallpapers'), str(home / 'Pictures/Wallpapers'), str(pictures), str(home)): + if item and Path(item).is_dir(): + return str(Path(item).expanduser().resolve()) + return str(home) + + +def validate_image(path): + """La cible d'un symlink doit être régulière et décodable ; l'extension seule ne suffit pas.""" + path = Path(path).expanduser() + if path.suffix.lower() not in SUFFIXES or not path.is_file(): + raise ValueError('Fichier image absent ou format non pris en charge') + canonical = path.resolve(strict=True) + if not canonical.is_file(): + raise ValueError('Cible image non régulière') + with Image.open(canonical) as image: + image.verify() + with Image.open(canonical) as image: + if image.format not in {'JPEG', 'PNG', 'WEBP'} or image.width < 1 or image.height < 1: + raise ValueError('Image invalide ou format non pris en charge') + image.load() + width, height, fmt = image.width, image.height, image.format + stat = canonical.stat() + return {'path': str(path.absolute()), 'canonicalPath': str(canonical), + 'filename': path.name, 'width': width, 'height': height, 'format': fmt, + 'size': stat.st_size, 'mtime': stat.st_mtime_ns} + + +def cache_key(meta): + payload = json.dumps([THUMB_VERSION, meta['canonicalPath'], meta['size'], meta['mtime']], + ensure_ascii=False, separators=(',', ':')) + return hashlib.sha256(payload.encode()).hexdigest() + + +def thumbnail(meta): + target = CACHE / (cache_key(meta) + '.png') + if not target.is_file(): + with Image.open(meta['canonicalPath']) as image: + image = ImageOps.exif_transpose(image) + image.thumbnail((320, 320), Image.Resampling.LANCZOS) + if image.mode not in ('RGB', 'RGBA'): + image = image.convert('RGBA' if 'A' in image.getbands() else 'RGB') + CACHE.mkdir(parents=True, exist_ok=True) + fd, name = tempfile.mkstemp(prefix='.thumbnail-', suffix='.png', dir=CACHE) + try: + with os.fdopen(fd, 'wb') as stream: + image.save(stream, format='PNG') + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, target) + finally: + Path(name).unlink(missing_ok=True) + # Le nettoyage n'inspecte que ce cache et garde une borne locale. + cached = list(CACHE.glob('*.png')) + if len(cached) > 512: + for old in sorted(cached, key=lambda p: p.stat().st_mtime_ns)[:len(cached) - 448]: + if old != target: + old.unlink(missing_ok=True) + return target.as_uri() + + +def scan(directory, page): + if not Path(directory).is_dir(): + raise ValueError('Dossier introuvable') + # WHY: seules 24 images par requête sont décodées ; l'UI demande la page + # suivante sur action explicite, sans daemon, polling ni processus par delegate. + candidates = sorted((p for p in Path(directory).iterdir() if not p.name.startswith('.') + and p.suffix.lower() in SUFFIXES and p.is_file()), + key=lambda p: (p.name.casefold(), p.name)) + entries = [] + offset = page * PAGE_SIZE + for item in candidates[offset:offset + PAGE_SIZE]: + try: + meta = validate_image(item) + meta['thumbnail'] = thumbnail(meta) + entries.append(meta) + except (OSError, ValueError, UnidentifiedImageError, + Image.DecompressionBombError, Image.DecompressionBombWarning): + continue + return {'version': 1, 'directory': str(Path(directory).resolve()), 'page': page, + 'hasMore': offset + PAGE_SIZE < len(candidates), 'items': entries} + + +def render_wallpaper(path): + # CONTRACT: SwayFX 0.6 ne parse pas les chemins cités avec espaces. + # Le chemin transmis ici est un alias ASCII du fichier utilisateur. + if not re.fullmatch(r'/[A-Za-z0-9_./-]+', path): + raise ValueError('Chemin alias non représentable dans Sway') + return ('# Généré par wallpaper-manager.py ; ne pas éditer.\n' + + 'set $wallpaper ' + path + '\n').encode('utf-8') + + +def safe_alias(meta): + """Lien persistant sans caractères spéciaux pour le parseur Sway.""" + ALIASES.mkdir(parents=True, exist_ok=True) + digest = hashlib.sha256(meta['canonicalPath'].encode()).hexdigest()[:24] + alias = ALIASES / ('image-' + digest + Path(meta['path']).suffix.lower()) + fd, temporary = tempfile.mkstemp(prefix='.link-', dir=ALIASES) + os.close(fd) + Path(temporary).unlink() + try: + os.symlink(meta['canonicalPath'], temporary) + os.replace(temporary, alias) + finally: + Path(temporary).unlink(missing_ok=True) + return str(alias) + + +def command(*args): + result = subprocess.run(args, capture_output=True, text=True, timeout=15, check=False) + # WHY: SwayFX 0.6 peut renvoyer 0 avec « Error(s) loading config! ». + if result.returncode or 'Error(s) loading config!' in result.stderr: + raise RuntimeError((result.stderr or result.stdout).strip() or 'Commande Sway échouée') + if args[0] == 'swaymsg': + try: + if any(not item.get('success', False) for item in json.loads(result.stdout)): + raise RuntimeError('Sway a refusé le rechargement') + except (ValueError, TypeError, AttributeError) as exc: + raise RuntimeError('Réponse swaymsg invalide') from exc + return result + + +def swaybg_matches(path): + """Vérifie le fond réellement transmis au swaybg lancé par Sway.""" + for proc in Path('/proc').iterdir(): + if not proc.name.isdecimal(): + continue + try: + args = (proc / 'cmdline').read_bytes().split(b'\0') + if args and Path(os.fsdecode(args[0])).name == 'swaybg': + decoded = [os.fsdecode(a) for a in args] + if '-i' in decoded and decoded[decoded.index('-i') + 1] == path and '-m' in decoded \ + and decoded[decoded.index('-m') + 1] == 'fill': + return True + except (OSError, IndexError): + pass + return False + + +def active_wallpaper(): + """Migration 17B : découvrir l'image en cours sans changer Sway.""" + for proc in Path('/proc').iterdir(): + if not proc.name.isdecimal(): + continue + try: + args = [os.fsdecode(a) for a in (proc / 'cmdline').read_bytes().split(b'\0')] + if args and Path(args[0]).name == 'swaybg' and '-i' in args: + path = Path(args[args.index('-i') + 1]) + return str(path.resolve()) if path.is_file() else str(path) + except (OSError, IndexError): + pass + return str(DEFAULT) + + +def current(): + state = read_json(STATE) + path = state.get('effectiveWallpaper') or active_wallpaper() + if not Path(path).is_file(): + return {'version': 2, 'effectiveWallpaper': path, 'wallpaperMissing': True, + 'fallback': str(DEFAULT), 'wallpaperDirectory': initial_directory(), + 'wallpaperMode': 'fill', 'revision': state.get('revision', 0)} + return {'version': 2, 'effectiveWallpaper': path, 'wallpaperMissing': False, + 'wallpaperDirectory': initial_directory(), 'wallpaperMode': 'fill', + 'revision': state.get('revision', 0)} + + +def apply(path): + # INVARIANT: lock couvre config, reload et état ; deux appels ne s'entremêlent pas. + STATE.parent.mkdir(parents=True, exist_ok=True) + with open(STATE.parent / 'wallpaper.lock', 'a+b') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + meta = validate_image(path) + target = meta['path'] + old_config = GENERATED.read_bytes() if GENERATED.exists() else None + old_state = STATE.read_bytes() if STATE.exists() else None + old = read_json(STATE) + try: + alias = safe_alias(meta) + if old.get('effectiveWallpaper', active_wallpaper()) == target and swaybg_matches(alias): + return current() + atomic_write(GENERATED, render_wallpaper(alias)) + command('sway', '--validate', '-c', str(SWAY / 'config')) + command('swaymsg', 'reload') + deadline = time.monotonic() + 4 + while time.monotonic() < deadline and not swaybg_matches(alias): + time.sleep(0.1) + if not swaybg_matches(alias): + raise RuntimeError('Sway n’a pas appliqué le fond demandé') + # WHY: préserver tous les champs 17B et inconnus pour la migration + # et les futurs consommateurs ; seul le wallpaper et sa révision changent. + new_state = dict(old) + new_state.setdefault('effectiveFlavor', 'mocha') + new_state.setdefault('effectiveAccent', 'lavender') + new_state.setdefault('effectiveMode', 'normal') + new_state.setdefault('effectiveDark', new_state['effectiveFlavor'] != 'latte') + new_state.setdefault('effectiveHighContrast', False) + new_state.update(version=2, effectiveWallpaper=target, wallpaperMode='fill', + revision=max(0, int(old.get('revision', 0))) + 1) + write_json(STATE, new_state) + # Seul l'alias actif est requis au prochain login ; les anciens + # liens créés par ce backend ne doivent pas s'accumuler. + for previous_alias in ALIASES.glob('image-*'): + if str(previous_alias) != alias and previous_alias.is_symlink(): + try: + previous_alias.unlink() + except OSError: + pass + return current() + except Exception: + if old_config is None: + GENERATED.unlink(missing_ok=True) + else: + atomic_write(GENERATED, old_config) + if old_state is None: + STATE.unlink(missing_ok=True) + else: + atomic_write(STATE, old_state) + command('swaymsg', 'reload') + raise + + +def set_directory(path): + if path.startswith('file:'): + uri = urlsplit(path) + if uri.scheme != 'file' or uri.netloc not in ('', 'localhost'): + raise ValueError('URI de dossier non locale') + path = unquote(uri.path) + directory = Path(path).expanduser().resolve(strict=True) + if not directory.is_dir(): + raise ValueError('Dossier introuvable') + pref = read_json(PREFERENCES) + pref.update(version=1, wallpaperDirectory=str(directory), wallpaperMode='fill') + write_json(PREFERENCES, pref) + return {'version': 1, 'wallpaperDirectory': str(directory), 'wallpaperMode': 'fill'} + + +def reconcile(): + """Après suppression de l'image active, revenir explicitement au repli versionné.""" + state = current() + if not state['wallpaperMissing']: + return state + missing = state['effectiveWallpaper'] + result = apply(str(DEFAULT)) + result['recoveredFromMissing'] = missing + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest='action', required=True) + sub.add_parser('current') + sub.add_parser('reconcile') + scan_parser = sub.add_parser('scan') + scan_parser.add_argument('directory') + scan_parser.add_argument('--page', type=int, default=0) + for action in ('validate', 'thumbnail', 'apply', 'set-directory'): + sub.add_parser(action).add_argument('path') + args = parser.parse_args() + try: + if args.action == 'current': result = current() + elif args.action == 'reconcile': result = reconcile() + elif args.action == 'scan': + if args.page < 0: raise ValueError('Page invalide') + result = scan(args.directory, args.page) + elif args.action == 'validate': result = validate_image(args.path) + elif args.action == 'thumbnail': + result = validate_image(args.path) + result['thumbnail'] = thumbnail(result) + elif args.action == 'apply': result = apply(args.path) + else: result = set_directory(args.path) + print(json.dumps(result, ensure_ascii=False, separators=(',', ':'))) + return 0 + except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired, + UnidentifiedImageError, Image.DecompressionBombError, + Image.DecompressionBombWarning, BlockingIOError) as exc: + print('Erreur : ' + str(exc), file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/quickshell/.config/quickshell/labfy-sway/README.md b/quickshell/.config/quickshell/labfy-sway/README.md index af915af3c..6d8182adf 100644 --- a/quickshell/.config/quickshell/labfy-sway/README.md +++ b/quickshell/.config/quickshell/labfy-sway/README.md @@ -26,6 +26,8 @@ Bar ├── Volume ├── Brightness ├── Power Profile + ├── Apparence + │ └── Fond d'écran ├── Battery limit └── Session ``` @@ -50,6 +52,10 @@ le System Monitor résout dynamiquement les noeuds DRM AMD et hwmon `k10temp`/`amdgpu` par identité, noms et labels. Les scans Wi-Fi et Bluetooth ne sont possédés que par les pages ouvertes. +Le gestionnaire de fonds d'écran local est décrit dans +[WALLPAPER.md](controlcenter/WALLPAPER.md). Il utilise Sway et `swaybg`, sans +service permanent ni changement automatique du thème. + ## Parité avec i3status-rs | Ancien bloc | QuickShell | diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/AppearancePage.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/AppearancePage.qml new file mode 100644 index 000000000..656cf169f --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/AppearancePage.qml @@ -0,0 +1,39 @@ +import QtQuick +import "../components" +import "../theme" + +Item { + id: page + signal backRequested() + signal wallpaperRequested() + + Column { + anchors.fill: parent + spacing: 14 + Row { + spacing: 10 + ActionButton { label: ""; onClicked: page.backRequested() } + Text { text: "Apparence"; color: Theme.foreground; font.pixelSize: 16; font.bold: true; height: 30; verticalAlignment: Text.AlignVCenter } + } + Rectangle { + width: parent.width; height: 72; radius: 4 + color: pointer.containsMouse ? Theme.buttonHover : Theme.buttonBackground + NerdIcon { anchors.left: parent.left; anchors.leftMargin: 12; anchors.verticalCenter: parent.verticalCenter; text: "󰸉"; color: Theme.accent; font.pixelSize: 22 } + Column { + anchors.left: parent.left; anchors.leftMargin: 47; anchors.verticalCenter: parent.verticalCenter + spacing: 4 + Text { text: "Fond d'écran"; color: Theme.foreground; font.pixelSize: 14; font.bold: true } + Text { text: "Galerie locale et choix explicite"; color: Theme.secondaryForeground; font.pixelSize: 11 } + } + MouseArea { id: pointer; anchors.fill: parent; hoverEnabled: true; onClicked: page.wallpaperRequested() } + } + Rectangle { + width: parent.width; height: 58; radius: 4; color: Theme.buttonBackground + Text { + anchors.left: parent.left; anchors.leftMargin: 12; anchors.verticalCenter: parent.verticalCenter + text: "Thème • " + AppearanceController.effectiveFlavor + " / " + AppearanceController.effectiveAccent + color: Theme.foreground; font.pixelSize: 12 + } + } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml index e26f8424d..d3136bdb8 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml @@ -11,7 +11,12 @@ PopupWindow { id: popup required property var barWindow - property int currentPage: 0 // 0 = MAIN, 1 = Wi-Fi, 2 = Bluetooth, 3 = Session, 4 = confirmation, 5 = Batterie. + // CONTRACT: indices externes historiques conservés ; tout nouveau routage + // passe par ces noms afin de ne pas multiplier les indices StackLayout. + readonly property var pages: ({ main: 0, wifi: 1, bluetooth: 2, session: 3, + confirmation: 4, battery: 5, appearance: 6, wallpaper: 7 }) + property int currentPage: pages.main + function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; } signal thresholdApplied() property var pendingAction: null // Une seule table associe les libellés, confirmations et commandes de session. @@ -38,7 +43,7 @@ PopupWindow { // Une nouvelle ouverture recommence sur MAIN ; les pages libèrent leurs scans à la fermeture. onVisibleChanged: if (!visible) { - currentPage = 0; + openPage("main"); pendingAction = null; } @@ -50,7 +55,7 @@ PopupWindow { return; } pendingAction = selected; - currentPage = 4; + openPage("confirmation"); } function executeSessionAction(id) { @@ -72,7 +77,8 @@ PopupWindow { implicitHeight: currentPage === 0 ? mainPage.implicitHeight + 32 : currentPage === 3 ? sessionPage.implicitHeight + 32 : currentPage === 4 ? confirmationPage.implicitHeight + 32 - : currentPage === 5 ? batteryPage.implicitHeight + 32 : 440 + : currentPage === 5 ? batteryPage.implicitHeight + 32 + : currentPage === 7 ? 550 : 440 visible: false // CONTRACT: PopupWindow n'applique un changement de grabFocus qu'après // fermeture/réouverture ; le prendre dès MAIN garde le clavier disponible @@ -109,12 +115,12 @@ PopupWindow { WifiTile { width: (parent.width - parent.spacing) / 2 wifiDevice: popup.wifiDevice - onDetailsRequested: popup.currentPage = 1 + onDetailsRequested: popup.openPage("wifi") } BluetoothTile { width: (parent.width - parent.spacing) / 2 adapter: popup.adapter - onDetailsRequested: popup.currentPage = 2 + onDetailsRequested: popup.openPage("bluetooth") } } @@ -122,6 +128,14 @@ PopupWindow { BrightnessSlider { width: parent.width } PowerProfile { width: parent.width } + Rectangle { + width: parent.width; height: 38; radius: 4 + color: appearancePointer.containsMouse ? Theme.buttonHover : Theme.buttonBackground + NerdIcon { anchors.left: parent.left; anchors.leftMargin: 10; anchors.verticalCenter: parent.verticalCenter; text: "󰸉"; color: Theme.accent; font.pixelSize: 18 } + Text { anchors.left: parent.left; anchors.leftMargin: 42; anchors.verticalCenter: parent.verticalCenter; text: "Apparence"; color: Theme.foreground; font.pixelSize: 13 } + MouseArea { id: appearancePointer; anchors.fill: parent; hoverEnabled: true; onClicked: popup.openPage("appearance") } + } + Item { width: parent.width height: 30 @@ -142,7 +156,7 @@ PopupWindow { anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.LeftButton - onClicked: popup.currentPage = 3 + onClicked: popup.openPage("session") } } } @@ -152,20 +166,20 @@ PopupWindow { id: wifiPage wifiDevice: popup.wifiDevice activePage: popup.visible && popup.currentPage === 1 - onBackRequested: popup.currentPage = 0 + onBackRequested: popup.openPage("main") } BluetoothPage { id: bluetoothPage adapter: popup.adapter activePage: popup.visible && popup.currentPage === 2 - onBackRequested: popup.currentPage = 0 + onBackRequested: popup.openPage("main") } SessionPage { id: sessionPage actions: popup.sessionActions - onBackRequested: popup.currentPage = 0 + onBackRequested: popup.openPage("main") onActionRequested: id => popup.requestSessionAction(id) } @@ -175,7 +189,7 @@ PopupWindow { description: "", accent: Theme.foreground }) onCancelled: { popup.pendingAction = null; - popup.currentPage = 3; + popup.openPage("session"); } onConfirmed: id => { if (popup.pendingAction && popup.pendingAction.id === id) @@ -186,9 +200,19 @@ PopupWindow { BatterySettings { id: batteryPage activePage: popup.visible && popup.currentPage === 5 - onBackRequested: popup.currentPage = 0 + onBackRequested: popup.openPage("main") onThresholdApplied: popup.thresholdApplied() } + + AppearancePage { + onBackRequested: popup.openPage("main") + onWallpaperRequested: popup.openPage("wallpaper") + } + + WallpaperPage { + activePage: popup.visible && popup.currentPage === popup.pages.wallpaper + onBackRequested: popup.openPage("appearance") + } } } } diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/WALLPAPER.md b/quickshell/.config/quickshell/labfy-sway/controlcenter/WALLPAPER.md new file mode 100644 index 000000000..84b3b77af --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/WALLPAPER.md @@ -0,0 +1,52 @@ +# Fond d'écran 17C + +Réglages rapides → Apparence → Fond d'écran utilise `FolderDialog` de +`QtQuick.Dialogs`, disponible dans le Qt local. Le choix d'un dossier enregistre +uniquement la préférence et relance le scan. Un clic ou Précédent/Suivant ne +fait que sélectionner et prévisualiser ; seul Appliquer passe par +`AppearanceController.applyWallpaper(path)`. + +`bin/.local/bin/wallpaper-manager.py` expose `current`, `scan DIRECTORY +--page N`, `validate FILE`, `thumbnail FILE`, `set-directory DIRECTORY`, +`apply FILE` et `reconcile`. Les réponses réussies sont des objets JSON sur +stdout ; les erreurs vont sur stderr avec code non nul. Le backend n'est pas +un daemon. Chaque page charge au plus 24 images, y compris ses miniatures ; +« Voir plus » demande la page suivante. Les fichiers cassés sont sautés et +une extension ne suffit pas : Pillow doit ouvrir et décoder l'image. Formats +validés : JPEG, PNG et WebP. Les chemins via symlink restent acceptés. + +Le manifeste contient `path`, `filename`, `thumbnail`, `width`, `height`, +`format`, `size`, `mtime` et `canonicalPath`. Le cache dans +`$XDG_CACHE_HOME/labfy-appearance/wallpapers/` contient des PNG de 320 px +maximum, créés par fichier temporaire puis `os.replace`. La clé SHA-256 +comprend le chemin canonique, la taille, `mtime_ns` et la version du schéma +thumbnail. Au delà de 512 PNG, le backend supprime seulement les plus anciens +de ce cache jusqu'à 448 entrées ; aucune maintenance périodique n'est lancée. + +Le dossier initial suit cet ordre : dernière préférence valide dans +`$XDG_CONFIG_HOME/labfy-appearance/preferences.json`, `~/.wallpapers`, +`~/Pictures/Wallpapers`, `XDG_PICTURES_DIR`, puis `$HOME`. Aucun dossier n'est +créé par cette recherche. Le mode de rendu reste `fill`. + +Le fichier runtime `generated/wallpaper.conf` est distinct de +`generated/theme.conf` et n'est pas versionné. Le repli versionné Sway est +`wallpaper-default.conf`. SwayFX 0.6 ne lit pas correctement un chemin avec +espaces dans `output bg`, même cité. Le backend garde donc le chemin original +dans l'état utilisateur et crée atomiquement un symlink persistant avec un nom +ASCII sûr dans `$XDG_CONFIG_HOME/labfy-appearance/wallpapers/`. Sway lit cet +alias. Le chemin de l'utilisateur n'est jamais une commande shell. + +L'application tient un verrou non bloquant, vérifie le décodage, écrit +`wallpaper.conf` par temp/flush/fsync/replace, lance `sway --validate`, +`swaymsg reload`, puis vérifie le `swaybg` effectif avant de publier +`$XDG_STATE_HOME/labfy-appearance/effective.json` en schéma 2. En cas d'échec, +elle restaure la configuration et l'état précédents puis recharge Sway. +Les champs 17B de thème restent intacts ; la révision augmente une fois par +application réussie et jamais par sélection. Les états 17B sans wallpaper +sont lus avec le fond actif de `swaybg` comme valeur initiale. + +Si l'image courante disparaît, `current` signale `wallpaperMissing` sans +interrompre le rendu déjà présent. `reconcile` restaure le repli versionné au +prochain appel explicite ; un nouvel `apply` valide peut aussi la remplacer. +Le thème, Night Light et Sun Mode ne sont jamais calculés depuis l'image en +17C. L'état final validé de ce lot reste Mocha/Lavender et le fond Sway bleu. diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/WallpaperPage.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/WallpaperPage.qml new file mode 100644 index 000000000..85e54d038 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/WallpaperPage.qml @@ -0,0 +1,233 @@ +import QtQuick +import QtQuick.Dialogs +import Quickshell +import Quickshell.Io +import "../theme" + +Item { + id: page + required property bool activePage + signal backRequested() + property var items: [] + property var selected: null + property bool selectionValid: false + property var currentMeta: null + property string directory: "" + property string feedback: "" + property bool hasMore: false + property bool scanning: false + property int nextPage: 0 + readonly property string backend: AppearanceController.wallpaperBackend + readonly property var previewMeta: selected || currentMeta + onSelectedChanged: { + selectionValid = false; + if (validationProcess.running) validationProcess.running = false; + if (selected) { + validationProcess.command = ["python", backend, "validate", selected.path]; + validationProcess.running = true; + } + } + + function start() { + currentProcess.command = ["python", backend, "current"]; + currentProcess.running = true; + refreshCurrent(); + } + function refreshCurrent() { + currentImage.command = ["python", backend, "thumbnail", AppearanceController.effectiveWallpaper]; + currentImage.running = true; + } + function scan(reset) { + if (!directory) return; + if (reset) { items = []; selected = null; nextPage = 0; hasMore = false; } + if (scanProcess.running) scanProcess.running = false; + scanning = true; + scanProcess.command = ["python", backend, "scan", directory, "--page", String(nextPage)]; + scanProcess.running = true; + } + function changeDirectory(url) { + // CONTRACT: le picker ne sélectionne ni n'applique de wallpaper. + if (scanProcess.running) scanProcess.running = false; + directoryProcess.command = ["python", backend, "set-directory", url.toString()]; + directoryProcess.running = true; + } + function move(step) { + const index = items.findIndex(item => selected && item.path === selected.path); + const next = index + step; + if (next >= 0 && next < items.length) selected = items[next]; + else if (index < 0 && items.length) selected = items[step > 0 ? 0 : items.length - 1]; + } + onActivePageChanged: { + if (activePage) start(); + else { + if (scanProcess.running) scanProcess.running = false; + if (validationProcess.running) validationProcess.running = false; + if (currentImage.running) currentImage.running = false; + if (currentProcess.running) currentProcess.running = false; + } + } + Connections { + target: AppearanceController + function onWallpaperApplied() { page.feedback = "Fond d'écran appliqué"; page.refreshCurrent(); } + } + + Process { + id: validationProcess + stdout: StdioCollector { id: validationOutput; waitForEnd: true } + onExited: (code, status) => { + if (!page.activePage || !page.selected || code !== 0 || status !== 0) return; + try { + const result = JSON.parse(validationOutput.text); + page.selectionValid = result.path === page.selected.path; + } catch (_) { page.selectionValid = false; } + } + } + Process { + id: currentProcess + stdout: StdioCollector { id: currentOutput; waitForEnd: true } + stderr: StdioCollector { waitForEnd: true } + onExited: (code, status) => { + if (!page.activePage || code !== 0 || status !== 0) return; + try { + const data = JSON.parse(currentOutput.text); + AppearanceController.effectiveWallpaper = data.effectiveWallpaper; + page.directory = data.wallpaperDirectory; + AppearanceController.wallpaperDirectory = data.wallpaperDirectory; + page.feedback = data.wallpaperMissing ? "Fond actuel introuvable : le prochain choix utilisera le repli." : ""; + page.scan(true); + } catch (_) { page.feedback = "État des fonds d'écran indisponible"; } + } + } + Process { + id: currentImage + stdout: StdioCollector { id: imageOutput; waitForEnd: true } + onExited: (code, status) => { + if (!page.activePage || code !== 0 || status !== 0) return; + try { page.currentMeta = JSON.parse(imageOutput.text); } + catch (_) { page.currentMeta = null; } + } + } + Process { + id: scanProcess + stdout: StdioCollector { id: scanOutput; waitForEnd: true } + stderr: StdioCollector { id: scanError; waitForEnd: true } + onExited: (code, status) => { + if (!page.activePage) return; + page.scanning = false; + if (code !== 0 || status !== 0) { page.feedback = scanError.text.trim() || "Lecture du dossier impossible"; return; } + try { + const data = JSON.parse(scanOutput.text); + if (data.directory !== page.directory && data.directory !== page.directory.replace(/\/$/, "")) return; + page.items = page.items.concat(data.items); + page.nextPage = data.page + 1; + page.hasMore = data.hasMore; + } catch (_) { page.feedback = "Galerie invalide"; } + } + } + Process { + id: directoryProcess + stdout: StdioCollector { id: directoryOutput; waitForEnd: true } + stderr: StdioCollector { id: directoryError; waitForEnd: true } + onExited: (code, status) => { + if (!page.activePage) return; + if (code !== 0 || status !== 0) { page.feedback = directoryError.text.trim() || "Dossier invalide"; return; } + try { + const data = JSON.parse(directoryOutput.text); + page.directory = data.wallpaperDirectory; + AppearanceController.wallpaperDirectory = page.directory; + page.feedback = ""; + page.scan(true); + } catch (_) { page.feedback = "Dossier invalide"; } + } + } + FolderDialog { + id: folderPicker + title: "Choisir un dossier de fonds d'écran" + onAccepted: page.changeDirectory(selectedFolder) + } + + Column { + anchors.fill: parent + spacing: 8 + Row { + spacing: 7; height: 30 + ActionButton { label: ""; onClicked: page.backRequested() } + Text { text: "Fond d'écran"; height: 30; verticalAlignment: Text.AlignVCenter; color: Theme.foreground; font.pixelSize: 16; font.bold: true } + } + Text { text: page.selected ? "APERÇU DE LA SÉLECTION" : "APERÇU ACTUEL"; color: Theme.accentForeground; font.bold: true; font.pixelSize: 11 } + Rectangle { + width: parent.width; height: 110; radius: 4; color: Theme.buttonBackground + Image { + anchors.fill: parent; anchors.margins: 3 + source: page.previewMeta ? page.previewMeta.thumbnail : "" + fillMode: Image.PreserveAspectFit; asynchronous: true; sourceSize.width: 320; sourceSize.height: 320 + } + } + Text { + width: parent.width; text: page.previewMeta + ? page.previewMeta.filename + " • " + page.previewMeta.width + " × " + page.previewMeta.height + " • " + page.previewMeta.format + : AppearanceController.effectiveWallpaper + elide: Text.ElideMiddle; color: Theme.secondaryForeground; font.pixelSize: 10 + } + Row { + width: parent.width; spacing: 6; height: 30 + Text { text: "GALERIE"; width: parent.width - 100; height: 30; verticalAlignment: Text.AlignVCenter; color: Theme.accentForeground; font.bold: true; font.pixelSize: 11 } + ActionButton { label: "Dossier…"; onClicked: folderPicker.open() } + } + Text { width: parent.width; text: page.directory; elide: Text.ElideMiddle; color: Theme.secondaryForeground; font.pixelSize: 10 } + GridView { + id: gallery + width: parent.width; height: 158; clip: true + cellWidth: Math.floor(width / 3); cellHeight: 78 + model: page.items + delegate: Rectangle { + required property var modelData + readonly property bool chosen: page.selected && page.selected.path === modelData.path + readonly property bool active: AppearanceController.effectiveWallpaper === modelData.path + width: gallery.cellWidth - 5; height: gallery.cellHeight - 5; radius: 4 + color: Theme.buttonBackground + border.width: chosen ? 2 : 1; border.color: chosen ? Theme.accent : Theme.border + Image { + anchors.fill: parent; anchors.margins: 3 + source: modelData.thumbnail; fillMode: Image.PreserveAspectFit + asynchronous: true; sourceSize.width: 160; sourceSize.height: 160 + } + Rectangle { + visible: parent.active; anchors.right: parent.right; anchors.top: parent.top + width: 20; height: 20; radius: 4; color: Theme.accent + Text { anchors.centerIn: parent; text: "✓"; color: Theme.onAccent; font.pixelSize: 13; font.bold: true } + } + MouseArea { anchors.fill: parent; onClicked: page.selected = modelData } + } + Text { + anchors.centerIn: parent + visible: !page.scanning && page.items.length === 0 + text: "Aucun fond d'écran trouvé"; color: Theme.secondaryForeground; font.pixelSize: 11 + } + } + ActionButton { visible: page.hasMore; label: page.scanning ? "Chargement…" : "Voir plus"; enabled: !page.scanning; onClicked: page.scan(false) } + Text { + width: parent.width; text: page.selected + ? page.selected.filename + " • " + page.selected.width + " × " + page.selected.height + " • " + page.selected.format + : "Choisir une miniature pour l'aperçu" + elide: Text.ElideMiddle; color: Theme.foreground; font.pixelSize: 11 + } + Row { + spacing: 7 + ActionButton { label: "Précédent"; enabled: page.items.length > 0; onClicked: page.move(-1) } + ActionButton { label: "Suivant"; enabled: page.items.length > 0; onClicked: page.move(1) } + ActionButton { + label: AppearanceController.wallpaperApplying ? "Application…" : "Appliquer" + enabled: !!page.selected && page.selectionValid && !AppearanceController.wallpaperApplying + && page.selected.path !== AppearanceController.effectiveWallpaper + onClicked: { page.feedback = ""; AppearanceController.applyWallpaper(page.selected.path); } + } + } + Text { + width: parent.width; visible: !!page.feedback || !!AppearanceController.wallpaperError + text: AppearanceController.wallpaperError || page.feedback + color: AppearanceController.wallpaperError ? Theme.warningForeground : Theme.successForeground + wrapMode: Text.Wrap; font.pixelSize: 11 + } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/theme/AppearanceController.qml b/quickshell/.config/quickshell/labfy-sway/theme/AppearanceController.qml index 90225bd07..fe84dfef2 100644 --- a/quickshell/.config/quickshell/labfy-sway/theme/AppearanceController.qml +++ b/quickshell/.config/quickshell/labfy-sway/theme/AppearanceController.qml @@ -9,6 +9,8 @@ Singleton { // 17B ne définit que le mode normal ; les décisions Sun arrivent après. readonly property var flavors: ["latte", "frappe", "macchiato", "mocha"] readonly property var accents: ["rosewater", "flamingo", "pink", "mauve", "red", "maroon", "peach", "yellow", "green", "teal", "sky", "sapphire", "blue", "lavender"] + readonly property string defaultWallpaper: "/usr/share/backgrounds/sway/Sway_Wallpaper_Blue_1920x1080.png" + readonly property string wallpaperBackend: (Quickshell.env("HOME") || "/home/fy59") + "/.local/bin/wallpaper-manager.py" // CONTRACT: le backend écrit cet état atomiquement après validation Sway. // Le repli Mocha fonctionne avant la première génération ; aucune lecture // par frame ni polling. Les futurs adaptateurs lisent cet état via l'IPC. @@ -22,7 +24,7 @@ Singleton { function initialState() { try { const value = JSON.parse(stateFile.text()); - if (value.version === 1 && flavors.includes(value.effectiveFlavor) + if ((value.version === 1 || value.version === 2) && flavors.includes(value.effectiveFlavor) && accents.includes(value.effectiveAccent) && value.effectiveMode === "normal") return value; } catch (_) {} @@ -36,6 +38,44 @@ Singleton { property string effectiveAccent: initial.effectiveAccent property int revision: Number.isSafeInteger(initial.revision) && initial.revision >= 0 ? initial.revision : 0 + // CONTRACT: la sélection QML ne touche pas à ces propriétés ; le backend + // ne publie le chemin et la révision qu'après reload et vérification swaybg. + property string effectiveWallpaper: initial.effectiveWallpaper || defaultWallpaper + property string wallpaperMode: "fill" + property string wallpaperDirectory: "" + property bool wallpaperApplying: false + property string wallpaperError: "" + signal wallpaperApplied() + + Process { + id: wallpaperWriter + stdout: StdioCollector { id: wallpaperOutput; waitForEnd: true } + stderr: StdioCollector { id: wallpaperErrors; waitForEnd: true } + onExited: (code, status) => { + appearance.wallpaperApplying = false; + if (code !== 0 || status !== 0) { + appearance.wallpaperError = wallpaperErrors.text.trim() || "Application impossible"; + return; + } + try { + const state = JSON.parse(wallpaperOutput.text); + if (!state.effectiveWallpaper || !Number.isSafeInteger(state.revision)) throw new Error("État invalide"); + appearance.effectiveWallpaper = state.effectiveWallpaper; + appearance.wallpaperMode = state.wallpaperMode; + appearance.revision = state.revision; + appearance.wallpaperApplied(); + } catch (_) { appearance.wallpaperError = "Réponse wallpaper invalide"; } + } + } + + function applyWallpaper(path) { + if (wallpaperApplying || !path || path === effectiveWallpaper) return false; + wallpaperError = ""; + wallpaperApplying = true; + wallpaperWriter.command = ["python", wallpaperBackend, "apply", path]; + wallpaperWriter.running = true; + return true; + } // INVARIANT: une paire refusée ne change aucun champ ni la révision. // La révision avance une seule fois après tout changement effectif. @@ -55,6 +95,8 @@ Singleton { return JSON.stringify({ effectiveFlavor: effectiveFlavor, effectiveMode: effectiveMode, effectiveDark: effectiveDark, effectiveHighContrast: effectiveHighContrast, - effectiveAccent: effectiveAccent, revision: revision }); + effectiveAccent: effectiveAccent, effectiveWallpaper: effectiveWallpaper, + wallpaperMode: wallpaperMode, wallpaperDirectory: wallpaperDirectory, + revision: revision }); } } diff --git a/quickshell/.config/quickshell/labfy-sway/theme/README.md b/quickshell/.config/quickshell/labfy-sway/theme/README.md index 3f8f9375e..bdb35efa7 100644 --- a/quickshell/.config/quickshell/labfy-sway/theme/README.md +++ b/quickshell/.config/quickshell/labfy-sway/theme/README.md @@ -44,7 +44,7 @@ Lavender. Les séries CPU/RAM du System Monitor restent intentionnellement Mauve. Ce choix rend les contrôles historiques Mauve légèrement plus bleus qu'avant. Les trois anciens Lavender restent l'accent principal. -L'API pour 17C est `AppearanceController.applyTheme(flavor, accent)` ; l'IPC +L'API de thème est `AppearanceController.applyTheme(flavor, accent)` ; l'IPC `qs -c labfy-sway ipc call appearance effectiveState` expose l'état complet et `qs -c labfy-sway ipc call appearance setTheme mocha lavender` change QuickShell en direct. La commande unifiée depuis le dépôt est : @@ -56,8 +56,11 @@ python bin/.local/bin/generate-appearance.py latte --accent lavender --apply Elle écrit atomiquement le fichier runtime Sway, valide la configuration, recharge Sway, persiste l'état effectif, puis met à jour QuickShell. En cas d'échec elle restaure la dernière génération valide, l'état persistant et -l'état QuickShell précédent. Aucune décision automatique de profil ni gestion -du wallpaper n'existe encore dans le QML. +l'état QuickShell précédent. Aucune décision automatique de profil n'existe +encore. Le wallpaper 17C est séparé dans `generated/wallpaper.conf` ; voir +[WALLPAPER.md](../controlcenter/WALLPAPER.md). `generate-appearance.py` +préserve les champs wallpaper du schéma effectif 2 lors d'un changement de +thème. Le repli versionné `sway/theme-default.conf` fournit Mocha même avant toute génération. Ses quelques variables sont un artefact dérivé, pas une palette complète indépendante. `sway/style` inclut le repli, puis un glob de fichier diff --git a/sway/.config/sway/README.md b/sway/.config/sway/README.md index 09cbbc55d..bcf27f77f 100644 --- a/sway/.config/sway/README.md +++ b/sway/.config/sway/README.md @@ -5,6 +5,14 @@ Les couleurs et variables d'apparence viennent du `theme-default.conf` assure le démarrage Mocha avant génération ; `generated/theme.conf` est l'état runtime créé atomiquement par `bin/.local/bin/generate-appearance.py` depuis la palette QuickShell. +`wallpaper-default.conf` définit le repli bleu `fill` ; +`generated/wallpaper.conf` redéfinit seulement `$wallpaper`. Une unique +directive `output * bg $wallpaper fill` applique le résultat. Le glob +`generated/*.conf` peut être vide et le repli reste valide. Les chemins +utilisateur contenant des espaces ou accents sont représentés par un lien +symbolique persistant à nom ASCII dans +`$XDG_CONFIG_HOME/labfy-appearance/wallpapers/`, car SwayFX 0.6 refuse +ces chemins dans une directive `output bg`, même entre guillemets. Le compositeur actif est **SwayFX 0.6** avec `scenefx`. La session UWSM sélectionne `sway.desktop` et charge `config` : `bind`, `input`, `rules`, diff --git a/sway/.config/sway/style b/sway/.config/sway/style index 448412494..00614fae9 100644 --- a/sway/.config/sway/style +++ b/sway/.config/sway/style @@ -2,8 +2,8 @@ ### Layout # -# Default wallpaper (more resolutions are available in /usr/share/backgrounds/sway/) - output * bg /usr/share/backgrounds/sway/Sway_Wallpaper_Blue_1920x1080.png fill +# CONTRACT: le repli versionné reste valide si le fichier runtime est absent. + include ~/.config/sway/wallpaper-default.conf # Seul écran utilisé : origine logique stable pour les fenêtres et la barre. output eDP-1 mode 3200x2000@60Hz pos 0 0 scale 1.4 @@ -14,6 +14,8 @@ # Le fichier runtime, s'il existe, remplace les variables sans palette parallèle. include ~/.config/sway/theme-default.conf include ~/.config/sway/generated/*.conf + # INVARIANT: une seule directive output lit la dernière variable wallpaper. + output * bg $wallpaper fill # target title bg text indicator border client.focused $accent $base $text $rosewater $accent diff --git a/sway/.config/sway/wallpaper-default.conf b/sway/.config/sway/wallpaper-default.conf new file mode 100644 index 000000000..bcd299df6 --- /dev/null +++ b/sway/.config/sway/wallpaper-default.conf @@ -0,0 +1,2 @@ +# Repli wallpaper 17C : ne dépend pas de l'état utilisateur généré. +set $wallpaper /usr/share/backgrounds/sway/Sway_Wallpaper_Blue_1920x1080.png diff --git a/tests/test_wallpaper_manager.py b/tests/test_wallpaper_manager.py new file mode 100644 index 000000000..c839a4247 --- /dev/null +++ b/tests/test_wallpaper_manager.py @@ -0,0 +1,142 @@ +"""Contrats locaux du gestionnaire wallpaper, sans recharger la session réelle.""" +import importlib.util +import fcntl +import json +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +from PIL import Image + + +SOURCE = Path(__file__).resolve().parents[1] / 'bin/.local/bin/wallpaper-manager.py' +SPEC = importlib.util.spec_from_file_location('wallpaper_manager', SOURCE) +manager = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(manager) + + +class WallpaperManagerTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.patchers = [ + mock.patch.object(manager, 'CACHE', self.root / 'cache'), + mock.patch.object(manager, 'ALIASES', self.root / 'aliases'), + mock.patch.object(manager, 'PREFERENCES', self.root / 'config/preferences.json'), + mock.patch.object(manager, 'STATE', self.root / 'state/effective.json'), + mock.patch.object(manager, 'GENERATED', self.root / 'sway/generated/wallpaper.conf'), + mock.patch.object(manager, 'SWAY', self.root / 'sway'), + ] + for patcher in self.patchers: + patcher.start() + self.image = self.root / "fond d'écran (été).png" + Image.new('RGB', (640, 360), 'blue').save(self.image) + + def tearDown(self): + for patcher in reversed(self.patchers): + patcher.stop() + self.temp.cleanup() + + def test_scan_thumbnail_and_cache_key(self): + bad = self.root / 'cassé.png' + bad.write_text('pas une image') + (self.root / 'notes.json').write_text('{}') + link = self.root / 'lien.png' + link.symlink_to(self.image) + result = manager.scan(self.root, 0) + self.assertEqual({x['filename'] for x in result['items']}, {self.image.name, link.name}) + self.assertEqual(len({x['thumbnail'] for x in result['items']}), 1) + meta = manager.validate_image(self.image) + with Image.open(Path(manager.thumbnail(meta).removeprefix('file://'))) as thumb: + self.assertEqual(thumb.size, (320, 180)) + self.assertEqual(thumb.format, 'PNG') + key = manager.cache_key(meta) + meta['mtime'] += 1 + self.assertNotEqual(key, manager.cache_key(meta)) + + def test_invalid_inputs_and_directory_uri(self): + for path in (self.root / 'absent.png', self.root, self.root / 'texte.png'): + if path.name == 'texte.png': path.write_text('invalide') + with self.assertRaises((ValueError, OSError)): + manager.validate_image(path) + chosen = manager.set_directory(self.root.as_uri()) + self.assertEqual(chosen['wallpaperDirectory'], str(self.root)) + self.assertEqual(json.loads(manager.PREFERENCES.read_text())['version'], 1) + + def test_atomic_replace_keeps_old_file_on_write_failure(self): + target = self.root / 'atomic.txt' + target.write_bytes(b'ancien') + with mock.patch.object(manager.os, 'replace', side_effect=OSError('échec')): + with self.assertRaises(OSError): + manager.atomic_write(target, b'nouveau') + self.assertEqual(target.read_bytes(), b'ancien') + + def test_safe_alias_and_sway_syntax(self): + meta = manager.validate_image(self.image) + alias = manager.safe_alias(meta) + self.assertEqual(Path(alias).resolve(), self.image.resolve()) + self.assertNotIn('é', alias) + self.assertNotIn(' ', alias) + content = manager.render_wallpaper(alias).decode() + self.assertIn(alias, content) + self.assertNotIn(str(self.image), content) + with self.assertRaises(ValueError): + manager.render_wallpaper(str(self.image)) + + def test_validate_checks_sway_stderr_even_with_zero_exit(self): + completed = mock.Mock(returncode=0, stderr='Error(s) loading config!', stdout='') + with mock.patch.object(manager.subprocess, 'run', return_value=completed): + with self.assertRaises(RuntimeError): + manager.command('sway', '--validate') + + def test_simultaneous_apply_is_rejected_before_writing(self): + manager.STATE.parent.mkdir(parents=True) + lock_path = manager.STATE.parent / 'wallpaper.lock' + with open(lock_path, 'a+b') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + with self.assertRaises(BlockingIOError): + manager.apply(str(self.image)) + self.assertFalse(manager.GENERATED.exists()) + + def test_interrupted_thumbnail_is_not_published(self): + meta = manager.validate_image(self.image) + target = manager.CACHE / (manager.cache_key(meta) + '.png') + with mock.patch.object(manager.os, 'replace', side_effect=OSError('interrompu')): + with self.assertRaises(OSError): + manager.thumbnail(meta) + self.assertFalse(target.exists()) + self.assertEqual(list(manager.CACHE.glob('.thumbnail-*')), []) + + def test_reconcile_missing_image_uses_versioned_default(self): + manager.write_json(manager.STATE, {'version': 2, + 'effectiveWallpaper': str(self.root / 'gone.png')}) + with mock.patch.object(manager, 'apply', return_value={'effectiveWallpaper': str(manager.DEFAULT)}) as apply: + result = manager.reconcile() + apply.assert_called_once_with(str(manager.DEFAULT)) + self.assertEqual(result['recoveredFromMissing'], str(self.root / 'gone.png')) + + def test_apply_preserves_theme_and_rolls_back_on_failure(self): + old = {'version': 1, 'effectiveFlavor': 'mocha', 'effectiveAccent': 'lavender', + 'effectiveDark': True, 'effectiveHighContrast': False, 'revision': 8} + manager.write_json(manager.STATE, old) + manager.atomic_write(manager.GENERATED, b'ancien') + with mock.patch.object(manager, 'command') as command, \ + mock.patch.object(manager, 'swaybg_matches', return_value=True): + result = manager.apply(str(self.image)) + self.assertEqual(result['revision'], 9) + state = manager.read_json(manager.STATE) + self.assertEqual(state['effectiveFlavor'], 'mocha') + self.assertEqual(state['effectiveAccent'], 'lavender') + self.assertEqual(state['version'], 2) + self.assertIn(b'set $wallpaper ', manager.GENERATED.read_bytes()) + self.assertEqual(command.call_count, 2) + with mock.patch.object(manager, 'command', side_effect=[RuntimeError('validate'), None]): + with self.assertRaises(RuntimeError): + manager.apply(str(self.image)) + self.assertEqual(manager.read_json(manager.STATE), state) + self.assertIn(b'set $wallpaper ', manager.GENERATED.read_bytes()) + + +if __name__ == '__main__': + unittest.main()