feat(appearance): add integrated wallpaper manager
This commit is contained in:
parent
9274fbfd3a
commit
51b65624e8
14 changed files with 940 additions and 23 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -7,6 +7,8 @@ hyprwhspr/.local/share/
|
||||||
**/node_modules/
|
**/node_modules/
|
||||||
sway/.config/sway/generated/theme.conf
|
sway/.config/sway/generated/theme.conf
|
||||||
sway/.config/sway/generated/theme.conf.tmp
|
sway/.config/sway/generated/theme.conf.tmp
|
||||||
|
sway/.config/sway/generated/wallpaper.conf
|
||||||
|
sway/.config/sway/generated/wallpaper.conf.tmp
|
||||||
|
|
||||||
# Local editor/application backups
|
# Local editor/application backups
|
||||||
*.bak
|
*.bak
|
||||||
|
|
|
||||||
|
|
@ -98,10 +98,14 @@ def main():
|
||||||
or previous.get('effectiveAccent') != args.accent
|
or previous.get('effectiveAccent') != args.accent
|
||||||
or previous.get('effectiveMode') != 'normal')
|
or previous.get('effectiveMode') != 'normal')
|
||||||
revision = max(0, int(previous.get('revision', 0))) + int(changed)
|
revision = max(0, int(previous.get('revision', 0))) + int(changed)
|
||||||
state = {'version': 1, 'effectiveFlavor': args.flavor,
|
# CONTRACT: le moteur de thème conserve les champs wallpaper
|
||||||
'effectiveMode': 'normal', 'effectiveDark': args.flavor != 'latte',
|
# du schéma 2 ; changer une palette n'efface pas l'état 17C.
|
||||||
'effectiveHighContrast': False, 'effectiveAccent': args.accent,
|
state = dict(previous)
|
||||||
'revision': revision}
|
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 ;
|
# CONTRACT: le backend persiste l'état après validation Sway ;
|
||||||
# le contrôleur l'expose aux consommateurs et le lit au restart.
|
# le contrôleur l'expose aux consommateurs et le lit au restart.
|
||||||
atomic_write(STATE, (json.dumps(state, separators=(',', ':')) + '\n').encode('utf-8'))
|
atomic_write(STATE, (json.dumps(state, separators=(',', ':')) + '\n').encode('utf-8'))
|
||||||
|
|
|
||||||
358
bin/.local/bin/wallpaper-manager.py
Executable file
358
bin/.local/bin/wallpaper-manager.py
Executable file
|
|
@ -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())
|
||||||
|
|
@ -26,6 +26,8 @@ Bar
|
||||||
├── Volume
|
├── Volume
|
||||||
├── Brightness
|
├── Brightness
|
||||||
├── Power Profile
|
├── Power Profile
|
||||||
|
├── Apparence
|
||||||
|
│ └── Fond d'écran
|
||||||
├── Battery limit
|
├── Battery limit
|
||||||
└── Session
|
└── 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
|
`k10temp`/`amdgpu` par identité, noms et labels. Les scans Wi-Fi et Bluetooth
|
||||||
ne sont possédés que par les pages ouvertes.
|
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
|
## Parité avec i3status-rs
|
||||||
|
|
||||||
| Ancien bloc | QuickShell |
|
| Ancien bloc | QuickShell |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,12 @@ PopupWindow {
|
||||||
id: popup
|
id: popup
|
||||||
|
|
||||||
required property var barWindow
|
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()
|
signal thresholdApplied()
|
||||||
property var pendingAction: null
|
property var pendingAction: null
|
||||||
// Une seule table associe les libellés, confirmations et commandes de session.
|
// 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.
|
// Une nouvelle ouverture recommence sur MAIN ; les pages libèrent leurs scans à la fermeture.
|
||||||
onVisibleChanged: if (!visible) {
|
onVisibleChanged: if (!visible) {
|
||||||
currentPage = 0;
|
openPage("main");
|
||||||
pendingAction = null;
|
pendingAction = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,7 +55,7 @@ PopupWindow {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pendingAction = selected;
|
pendingAction = selected;
|
||||||
currentPage = 4;
|
openPage("confirmation");
|
||||||
}
|
}
|
||||||
|
|
||||||
function executeSessionAction(id) {
|
function executeSessionAction(id) {
|
||||||
|
|
@ -72,7 +77,8 @@ PopupWindow {
|
||||||
implicitHeight: currentPage === 0 ? mainPage.implicitHeight + 32
|
implicitHeight: currentPage === 0 ? mainPage.implicitHeight + 32
|
||||||
: currentPage === 3 ? sessionPage.implicitHeight + 32
|
: currentPage === 3 ? sessionPage.implicitHeight + 32
|
||||||
: currentPage === 4 ? confirmationPage.implicitHeight + 32
|
: currentPage === 4 ? confirmationPage.implicitHeight + 32
|
||||||
: currentPage === 5 ? batteryPage.implicitHeight + 32 : 440
|
: currentPage === 5 ? batteryPage.implicitHeight + 32
|
||||||
|
: currentPage === 7 ? 550 : 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
|
||||||
// fermeture/réouverture ; le prendre dès MAIN garde le clavier disponible
|
// fermeture/réouverture ; le prendre dès MAIN garde le clavier disponible
|
||||||
|
|
@ -109,12 +115,12 @@ PopupWindow {
|
||||||
WifiTile {
|
WifiTile {
|
||||||
width: (parent.width - parent.spacing) / 2
|
width: (parent.width - parent.spacing) / 2
|
||||||
wifiDevice: popup.wifiDevice
|
wifiDevice: popup.wifiDevice
|
||||||
onDetailsRequested: popup.currentPage = 1
|
onDetailsRequested: popup.openPage("wifi")
|
||||||
}
|
}
|
||||||
BluetoothTile {
|
BluetoothTile {
|
||||||
width: (parent.width - parent.spacing) / 2
|
width: (parent.width - parent.spacing) / 2
|
||||||
adapter: popup.adapter
|
adapter: popup.adapter
|
||||||
onDetailsRequested: popup.currentPage = 2
|
onDetailsRequested: popup.openPage("bluetooth")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,6 +128,14 @@ PopupWindow {
|
||||||
BrightnessSlider { width: parent.width }
|
BrightnessSlider { width: parent.width }
|
||||||
PowerProfile { 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 {
|
Item {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: 30
|
height: 30
|
||||||
|
|
@ -142,7 +156,7 @@ PopupWindow {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
acceptedButtons: Qt.LeftButton
|
acceptedButtons: Qt.LeftButton
|
||||||
onClicked: popup.currentPage = 3
|
onClicked: popup.openPage("session")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -152,20 +166,20 @@ PopupWindow {
|
||||||
id: wifiPage
|
id: wifiPage
|
||||||
wifiDevice: popup.wifiDevice
|
wifiDevice: popup.wifiDevice
|
||||||
activePage: popup.visible && popup.currentPage === 1
|
activePage: popup.visible && popup.currentPage === 1
|
||||||
onBackRequested: popup.currentPage = 0
|
onBackRequested: popup.openPage("main")
|
||||||
}
|
}
|
||||||
|
|
||||||
BluetoothPage {
|
BluetoothPage {
|
||||||
id: bluetoothPage
|
id: bluetoothPage
|
||||||
adapter: popup.adapter
|
adapter: popup.adapter
|
||||||
activePage: popup.visible && popup.currentPage === 2
|
activePage: popup.visible && popup.currentPage === 2
|
||||||
onBackRequested: popup.currentPage = 0
|
onBackRequested: popup.openPage("main")
|
||||||
}
|
}
|
||||||
|
|
||||||
SessionPage {
|
SessionPage {
|
||||||
id: sessionPage
|
id: sessionPage
|
||||||
actions: popup.sessionActions
|
actions: popup.sessionActions
|
||||||
onBackRequested: popup.currentPage = 0
|
onBackRequested: popup.openPage("main")
|
||||||
onActionRequested: id => popup.requestSessionAction(id)
|
onActionRequested: id => popup.requestSessionAction(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,7 +189,7 @@ PopupWindow {
|
||||||
description: "", accent: Theme.foreground })
|
description: "", accent: Theme.foreground })
|
||||||
onCancelled: {
|
onCancelled: {
|
||||||
popup.pendingAction = null;
|
popup.pendingAction = null;
|
||||||
popup.currentPage = 3;
|
popup.openPage("session");
|
||||||
}
|
}
|
||||||
onConfirmed: id => {
|
onConfirmed: id => {
|
||||||
if (popup.pendingAction && popup.pendingAction.id === id)
|
if (popup.pendingAction && popup.pendingAction.id === id)
|
||||||
|
|
@ -186,9 +200,19 @@ PopupWindow {
|
||||||
BatterySettings {
|
BatterySettings {
|
||||||
id: batteryPage
|
id: batteryPage
|
||||||
activePage: popup.visible && popup.currentPage === 5
|
activePage: popup.visible && popup.currentPage === 5
|
||||||
onBackRequested: popup.currentPage = 0
|
onBackRequested: popup.openPage("main")
|
||||||
onThresholdApplied: popup.thresholdApplied()
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,8 @@ Singleton {
|
||||||
// 17B ne définit que le mode normal ; les décisions Sun arrivent après.
|
// 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 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 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.
|
// CONTRACT: le backend écrit cet état atomiquement après validation Sway.
|
||||||
// Le repli Mocha fonctionne avant la première génération ; aucune lecture
|
// 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.
|
// par frame ni polling. Les futurs adaptateurs lisent cet état via l'IPC.
|
||||||
|
|
@ -22,7 +24,7 @@ Singleton {
|
||||||
function initialState() {
|
function initialState() {
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(stateFile.text());
|
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)
|
&& accents.includes(value.effectiveAccent)
|
||||||
&& value.effectiveMode === "normal") return value;
|
&& value.effectiveMode === "normal") return value;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
@ -36,6 +38,44 @@ Singleton {
|
||||||
property string effectiveAccent: initial.effectiveAccent
|
property string effectiveAccent: initial.effectiveAccent
|
||||||
property int revision: Number.isSafeInteger(initial.revision) && initial.revision >= 0
|
property int revision: Number.isSafeInteger(initial.revision) && initial.revision >= 0
|
||||||
? 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.
|
// 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.
|
// La révision avance une seule fois après tout changement effectif.
|
||||||
|
|
@ -55,6 +95,8 @@ Singleton {
|
||||||
return JSON.stringify({ effectiveFlavor: effectiveFlavor,
|
return JSON.stringify({ effectiveFlavor: effectiveFlavor,
|
||||||
effectiveMode: effectiveMode, effectiveDark: effectiveDark,
|
effectiveMode: effectiveMode, effectiveDark: effectiveDark,
|
||||||
effectiveHighContrast: effectiveHighContrast,
|
effectiveHighContrast: effectiveHighContrast,
|
||||||
effectiveAccent: effectiveAccent, revision: revision });
|
effectiveAccent: effectiveAccent, effectiveWallpaper: effectiveWallpaper,
|
||||||
|
wallpaperMode: wallpaperMode, wallpaperDirectory: wallpaperDirectory,
|
||||||
|
revision: revision });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
Mauve. Ce choix rend les contrôles historiques Mauve légèrement plus bleus
|
||||||
qu'avant. Les trois anciens Lavender restent l'accent principal.
|
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
|
`qs -c labfy-sway ipc call appearance effectiveState` expose l'état complet
|
||||||
et `qs -c labfy-sway ipc call appearance setTheme mocha lavender` change
|
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 :
|
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,
|
Elle écrit atomiquement le fichier runtime Sway, valide la configuration,
|
||||||
recharge Sway, persiste l'état effectif, puis met à jour QuickShell. En cas
|
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
|
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
|
l'état QuickShell précédent. Aucune décision automatique de profil n'existe
|
||||||
du wallpaper n'existe encore dans le QML.
|
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
|
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
|
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
|
complète indépendante. `sway/style` inclut le repli, puis un glob de fichier
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,14 @@ Les couleurs et variables d'apparence viennent du
|
||||||
`theme-default.conf` assure le démarrage Mocha avant génération ;
|
`theme-default.conf` assure le démarrage Mocha avant génération ;
|
||||||
`generated/theme.conf` est l'état runtime créé atomiquement par
|
`generated/theme.conf` est l'état runtime créé atomiquement par
|
||||||
`bin/.local/bin/generate-appearance.py` depuis la palette QuickShell.
|
`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
|
Le compositeur actif est **SwayFX 0.6** avec `scenefx`. La session UWSM
|
||||||
sélectionne `sway.desktop` et charge `config` : `bind`, `input`, `rules`,
|
sélectionne `sway.desktop` et charge `config` : `bind`, `input`, `rules`,
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
### Layout
|
### Layout
|
||||||
#
|
#
|
||||||
|
|
||||||
# Default wallpaper (more resolutions are available in /usr/share/backgrounds/sway/)
|
# CONTRACT: le repli versionné reste valide si le fichier runtime est absent.
|
||||||
output * bg /usr/share/backgrounds/sway/Sway_Wallpaper_Blue_1920x1080.png fill
|
include ~/.config/sway/wallpaper-default.conf
|
||||||
# Seul écran utilisé : origine logique stable pour les fenêtres et la barre.
|
# 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
|
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.
|
# Le fichier runtime, s'il existe, remplace les variables sans palette parallèle.
|
||||||
include ~/.config/sway/theme-default.conf
|
include ~/.config/sway/theme-default.conf
|
||||||
include ~/.config/sway/generated/*.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
|
# target title bg text indicator border
|
||||||
client.focused $accent $base $text $rosewater $accent
|
client.focused $accent $base $text $rosewater $accent
|
||||||
|
|
|
||||||
2
sway/.config/sway/wallpaper-default.conf
Normal file
2
sway/.config/sway/wallpaper-default.conf
Normal file
|
|
@ -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
|
||||||
142
tests/test_wallpaper_manager.py
Normal file
142
tests/test_wallpaper_manager.py
Normal file
|
|
@ -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()
|
||||||
Loading…
Reference in a new issue