feat(appearance): add integrated wallpaper manager

This commit is contained in:
fy59 2026-10-04 21:24:16 +02:00
parent 9274fbfd3a
commit 51b65624e8
14 changed files with 940 additions and 23 deletions

2
.gitignore vendored
View file

@ -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

View file

@ -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'))

View 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())

View file

@ -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 |

View file

@ -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
}
}
}
}

View file

@ -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")
}
}
}
}

View file

@ -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.

View file

@ -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
}
}
}

View file

@ -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 });
}
}

View file

@ -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

View file

@ -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`,

View file

@ -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

View 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

View 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()