diff --git a/bin/.local/bin/night-light-manager.py b/bin/.local/bin/night-light-manager.py new file mode 100644 index 000000000..9295ca49a --- /dev/null +++ b/bin/.local/bin/night-light-manager.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +"""Préférences Night Light locales et cycle de vie de l'unique wlsunset.service.""" +import argparse +import fcntl +import json +import math +import os +from pathlib import Path +import signal +import subprocess +import sys +import tempfile +import time + +CONFIG = 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' +LOCK = STATE.parent / 'wallpaper.lock' +UNIT = 'wlsunset.service' +WLSUNSET = '/usr/bin/wlsunset' +MODES = ('off', 'auto', 'on') +MIN_NIGHT, MAX_NIGHT, STEP = 2500, 5000, 100 +DAY_TEMPERATURE = 6500 + + +def read_json(path): + try: + return json.loads(path.read_text(encoding='utf-8')) + except FileNotFoundError: + return {} + + +def atomic_json(path, value): + # CONTRACT: préférences et état sont chacun publiés par replace durable ; + # les permissions 0600 protègent les coordonnées locales hors Git. + path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix='.night-light-', dir=path.parent) + try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, 'w', encoding='utf-8') as stream: + json.dump(value, stream, ensure_ascii=False, separators=(',', ':')) + stream.write('\n') + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + directory = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + Path(temporary).unlink(missing_ok=True) + + +def restore(path, old): + if old is None: + path.unlink(missing_ok=True) + else: + fd, temporary = tempfile.mkstemp(prefix='.night-light-rollback-', dir=path.parent) + try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, 'wb') as stream: + stream.write(old) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + Path(temporary).unlink(missing_ok=True) + + +def temperature(value): + # INVARIANT: aucun arrondi implicite ni NaN ne peut devenir un argument CLI. + if isinstance(value, bool) or not isinstance(value, int) or not MIN_NIGHT <= value <= MAX_NIGHT or value % STEP: + raise ValueError('Température de nuit invalide (2500–5000 K, pas de 100 K)') + return value + + +def coordinates(pref): + try: + lat, lon = float(pref['latitude']), float(pref['longitude']) + if not math.isfinite(lat) or not math.isfinite(lon) or not -90 <= lat <= 90 or not -180 <= lon <= 180: + raise ValueError() + return str(pref['latitude']), str(pref['longitude']) + except (KeyError, TypeError, ValueError): + raise ValueError('Planification à configurer') from None + + +def normalized(pref): + result = dict(pref) + mode = result.get('nightLightMode', 'auto' if 'latitude' in result and 'longitude' in result else 'off') + if mode not in MODES: + raise ValueError('Mode Night Light invalide') + result.update(version=max(3, int(result.get('version', 2))), + nightLightMode=mode, + nightTemperature=temperature(result.get('nightTemperature', 3000)), + dayTemperature=result.get('dayTemperature', DAY_TEMPERATURE), + scheduleType=result.get('scheduleType', 'solar')) + if result['dayTemperature'] != DAY_TEMPERATURE or result['scheduleType'] != 'solar': + raise ValueError('Configuration Night Light non prise en charge') + if mode == 'auto': + coordinates(result) + return result + + +def effective(mode, suspended=False): + # CONTRACT 17F: une suspension Soleil n'écrase jamais le choix utilisateur. + return 'off' if suspended else mode + + +def argv(pref): + pref = normalized(pref) + mode = effective(pref['nightLightMode']) + if mode == 'off': + return None + temperatures = ['-t', str(pref['nightTemperature']), '-T', str(pref['dayTemperature'])] + if mode == 'auto': + lat, lon = coordinates(pref) + return [WLSUNSET, '-l', lat, '-L', lon, *temperatures] + # WHY: wlsunset 0.4.0 refuse -T == -t. Un horaire manuel valide démarre + # le processus ; ExecStartPost sélectionne ensuite FORCE_LOW via SIGUSR1. + return [WLSUNSET, '-S', '06:00', '-s', '18:00', *temperatures] + + +def systemctl(*args): + result = subprocess.run(['systemctl', '--user', *args], text=True, capture_output=True) + if result.returncode: + raise RuntimeError('Échec de gestion de wlsunset.service') + return result.stdout.strip() + + +def unit_state(): + lines = systemctl('show', UNIT, '-p', 'ActiveState', '-p', 'MainPID').splitlines() + fields = dict(line.split('=', 1) for line in lines if '=' in line) + return fields.get('ActiveState') == 'active', int(fields.get('MainPID', '0')) + + +def wlsunset_pids(): + # INVARIANT: inspection seule ; aucun pkill/killall global n'est permis. + found = [] + for entry in Path('/proc').iterdir(): + if not entry.name.isdigit(): + continue + try: + # Le nom du processus couvre aussi un lancement externe par PATH. + if (entry / 'comm').read_text(encoding='ascii').strip() == 'wlsunset': + found.append(int(entry.name)) + except (FileNotFoundError, PermissionError, ProcessLookupError): + pass + return found + + +def verify(pref): + active, main_pid = unit_state() + pids = wlsunset_pids() + mode = effective(pref['nightLightMode']) + if mode == 'off': + if active or pids: + raise RuntimeError('Night Light Off : wlsunset encore actif') + elif not active or pids != [main_pid]: + raise RuntimeError('wlsunset absent, dupliqué ou hors unité systemd') + else: + actual = (Path('/proc') / str(main_pid) / 'cmdline').read_bytes().split(b'\0')[:-1] + if actual != [part.encode() for part in argv(pref)]: + raise RuntimeError('Arguments wlsunset incohérents') + + +def apply_service(pref): + mode = effective(pref['nightLightMode']) + systemctl('stop' if mode == 'off' else 'restart', UNIT) + verify(pref) + + +def snapshot(pref, state): + return {'nightLightMode': pref['nightLightMode'], + 'effectiveNightLightMode': effective(pref['nightLightMode'], state.get('nightLightSuspended', False)), + 'nightLightSuspended': bool(state.get('nightLightSuspended', False)), + 'nightTemperature': pref['nightTemperature'], 'dayTemperature': pref['dayTemperature'], + 'scheduleType': pref['scheduleType'], 'scheduleConfigured': 'latitude' in pref and 'longitude' in pref} + + +def publish(value): + payload = json.dumps(value, separators=(',', ':')) + try: + result = subprocess.run(['qs', '-c', 'labfy-sway', 'ipc', 'call', 'appearance', 'publishNightLight', payload], + text=True, capture_output=True) + except FileNotFoundError: + return + # QuickShell peut être absent ; systemd et les préférences restent autonomes. + if result.returncode == 0 and result.stdout.strip() != 'true': + raise RuntimeError('QuickShell a refusé l’état Night Light') + + +def change(mode=None, night=None): + LOCK.parent.mkdir(parents=True, exist_ok=True) + with open(LOCK, 'a+b') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + old_pref_bytes = CONFIG.read_bytes() if CONFIG.exists() else None + old_state_bytes = STATE.read_bytes() if STATE.exists() else None + old_pref = normalized(read_json(CONFIG)) + pref = dict(old_pref) + if mode is not None: + if mode not in MODES: + raise ValueError('Mode Night Light invalide') + pref['nightLightMode'] = mode + if night is not None: + pref['nightTemperature'] = temperature(night) + pref = normalized(pref) + state = read_json(STATE) + previous_mode = old_pref['nightLightMode'] + if pref == old_pref: + verify(pref) + return snapshot(pref, state) + new_state = dict(state) + new_state.update(effectiveNightLightMode=effective(pref['nightLightMode']), + nightLightSuspended=False) + try: + atomic_json(CONFIG, pref) + apply_service(pref) + atomic_json(STATE, new_state) + result = snapshot(pref, new_state) + publish(result) + return result + except Exception: + restore(CONFIG, old_pref_bytes) + restore(STATE, old_state_bytes) + try: + apply_service(old_pref) + except Exception as rollback_error: + raise RuntimeError('Échec Night Light et rollback du service') from rollback_error + raise + + +def reconcile(): + LOCK.parent.mkdir(parents=True, exist_ok=True) + with open(LOCK, 'a+b') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + pref = normalized(read_json(CONFIG)) + state = read_json(STATE) + try: + verify(pref) + except RuntimeError: + # Une instance étrangère ne peut être supprimée par cette unité. + active, main_pid = unit_state() + if any(pid != main_pid for pid in wlsunset_pids()): + raise RuntimeError('wlsunset externe détecté ; réconciliation refusée') + apply_service(pref) + state.update(effectiveNightLightMode=effective(pref['nightLightMode']), + nightLightSuspended=False) + if read_json(CONFIG) != pref: + atomic_json(CONFIG, pref) + if read_json(STATE) != state: + atomic_json(STATE, state) + return snapshot(pref, state) + + +def launch(): + pref = normalized(read_json(CONFIG)) + command = argv(pref) + if command is None: + return 0 + os.execv(command[0], command) + + +def post_start(): + pref = normalized(read_json(CONFIG)) + if pref['nightLightMode'] != 'on': + return 0 + # systemd ne fournit pas MAINPID dans cet ExecStartPost sur cette version. + # Interroger le PID de l'unité et attendre la fin de l'exec Python -> wlsunset. + deadline = time.monotonic() + 3 + pid = 0 + while time.monotonic() < deadline: + pid = int(systemctl('show', '-P', 'MainPID', UNIT) or '0') + try: + if pid > 0 and (Path('/proc') / str(pid) / 'cmdline').read_bytes().startswith(WLSUNSET.encode() + b'\0'): + break + except FileNotFoundError: + pass + time.sleep(0.1) + else: + raise RuntimeError('PID wlsunset principal invalide') + # CONTRACT: SIGUSR1 parcourt AUTO -> FORCE_HIGH -> FORCE_LOW en 0.4.0. + # Laisser l'event loop traiter chaque signal pour ne jamais perdre un cran. + time.sleep(0.3) + os.kill(pid, signal.SIGUSR1) + time.sleep(0.3) + os.kill(pid, signal.SIGUSR1) + return 0 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest='action', required=True) + for action in ('current', 'reconcile', 'validate', 'launch', 'post-start'): + sub.add_parser(action) + sub.add_parser('set-mode').add_argument('mode') + sub.add_parser('set-night-temperature').add_argument('value') + args = parser.parse_args() + try: + if args.action == 'launch': + return launch() + if args.action == 'post-start': + return post_start() + if args.action == 'reconcile': + result = reconcile() + elif args.action == 'set-mode': + result = change(mode=args.mode) + elif args.action == 'set-night-temperature': + if not args.value.isdecimal(): + raise ValueError('Température de nuit invalide') + result = change(night=int(args.value)) + else: + pref = normalized(read_json(CONFIG)) + result = snapshot(pref, read_json(STATE)) + if args.action == 'current': + verify(pref) + print(json.dumps(result, ensure_ascii=False, separators=(',', ':'))) + return 0 + except (OSError, ValueError, RuntimeError, BlockingIOError) as exc: + print('Erreur : ' + str(exc), file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/bin/.local/bin/wallpaper-manager.py b/bin/.local/bin/wallpaper-manager.py index f09f687d0..e73c0b40d 100755 --- a/bin/.local/bin/wallpaper-manager.py +++ b/bin/.local/bin/wallpaper-manager.py @@ -37,7 +37,7 @@ GENERATED_THEME = SWAY / 'generated/theme.conf' ALIASES = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'labfy-appearance/wallpapers' FLAVORS = ('latte', 'frappe', 'macchiato', 'mocha') STATE_VERSION = 3 -PREF_VERSION = 2 +PREF_VERSION = 3 def atomic_write(path, data): diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/AppearancePage.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/AppearancePage.qml index 774445c44..e49b6ddff 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/AppearancePage.qml +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/AppearancePage.qml @@ -7,6 +7,7 @@ Item { signal backRequested() signal wallpaperRequested() signal themeRequested() + signal nightLightRequested() readonly property var flavorNames: ({ latte: "Latte", frappe: "Frappé", macchiato: "Macchiato", mocha: "Mocha" }) Column { @@ -44,5 +45,21 @@ Item { } MouseArea { id: themePointer; anchors.fill: parent; hoverEnabled: true; onClicked: page.themeRequested() } } + Rectangle { + width: parent.width; height: 72; radius: 4 + color: nightPointer.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: "Lumière nocturne"; color: Theme.foreground; font.pixelSize: 14; font.bold: true } + Text { + text: AppearanceController.nightLightMode === "auto" ? "Auto · " + AppearanceController.nightTemperature + " K" + : AppearanceController.nightLightMode === "on" ? "Activée · " + AppearanceController.nightTemperature + " K" + : "Désactivée" + color: Theme.secondaryForeground; font.pixelSize: 11 + } + } + MouseArea { id: nightPointer; anchors.fill: parent; hoverEnabled: true; onClicked: page.nightLightRequested() } + } } } diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml index d1a3bea9b..7e1e79d92 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml @@ -14,7 +14,7 @@ PopupWindow { // 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, theme: 8 }) + confirmation: 4, battery: 5, appearance: 6, wallpaper: 7, theme: 8, nightLight: 9 }) property int currentPage: pages.main function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; } signal thresholdApplied() @@ -208,6 +208,7 @@ PopupWindow { onBackRequested: popup.openPage("main") onWallpaperRequested: popup.openPage("wallpaper") onThemeRequested: popup.openPage("theme") + onNightLightRequested: popup.openPage("nightLight") } WallpaperPage { @@ -218,6 +219,9 @@ PopupWindow { ThemePage { onBackRequested: popup.openPage("appearance") } + NightLightPage { + onBackRequested: popup.openPage("appearance") + } } } } diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/NightLightPage.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/NightLightPage.qml new file mode 100644 index 000000000..5643c6964 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/NightLightPage.qml @@ -0,0 +1,82 @@ +import QtQuick +import QtQuick.Controls +import "../theme" + +Item { + id: page + signal backRequested() + property int draftTemperature: AppearanceController.nightTemperature + readonly property bool busy: AppearanceController.nightLightBusy + Connections { + target: AppearanceController + function onNightTemperatureChanged() { page.draftTemperature = AppearanceController.nightTemperature; } + } + Column { + anchors.fill: parent + spacing: 14 + Row { + spacing: 8; height: 30 + ActionButton { label: ""; onClicked: page.backRequested() } + Text { text: "Lumière nocturne"; color: Theme.foreground; font.pixelSize: 16; font.bold: true; height: 30; verticalAlignment: Text.AlignVCenter } + } + Text { text: "MODE"; color: Theme.accentForeground; font.pixelSize: 11; font.bold: true } + Row { + spacing: 8 + Repeater { + model: [ + { key: "off", label: "Désactivée" }, + { key: "auto", label: "Auto" }, + { key: "on", label: "Activée" } + ] + delegate: Rectangle { + required property var modelData + readonly property bool selected: AppearanceController.nightLightMode === modelData.key + width: (page.width - 16) / 3; height: 44; radius: 4 + color: selected ? Theme.selectedBackground : Theme.buttonBackground + border.width: selected ? 2 : 1 + border.color: selected ? Theme.accent : Theme.border + opacity: page.busy ? 0.6 : 1 + Text { anchors.centerIn: parent; text: modelData.label; color: parent.selected ? Theme.selectedForeground : Theme.foreground; font.pixelSize: 11; font.bold: parent.selected } + MouseArea { anchors.fill: parent; enabled: !page.busy && !parent.selected; onClicked: AppearanceController.setNightLightMode(modelData.key) } + } + } + } + Text { + width: parent.width + text: AppearanceController.nightLightMode === "auto" ? "Lever et coucher du soleil" + : AppearanceController.nightLightMode === "on" ? AppearanceController.nightTemperature + " K en permanence" + : "Couleurs neutres" + color: Theme.secondaryForeground; font.pixelSize: 12 + } + Text { text: "TEMPÉRATURE DE NUIT"; color: Theme.accentForeground; font.pixelSize: 11; font.bold: true } + Row { + width: parent.width; spacing: 10 + Slider { + id: nightSlider + width: parent.width - 74 + from: 2500; to: 5000; stepSize: 100 + value: page.draftTemperature + enabled: !page.busy + onMoved: page.draftTemperature = Math.round(value / 100) * 100 + } + Text { text: page.draftTemperature + " K"; color: Theme.foreground; font.pixelSize: 12; width: 64; verticalAlignment: Text.AlignVCenter; height: nightSlider.height } + } + ActionButton { + label: "Appliquer" + enabled: !page.busy && page.draftTemperature !== AppearanceController.nightTemperature + onClicked: AppearanceController.setNightTemperature(page.draftTemperature) + } + Text { + width: parent.width; wrapMode: Text.Wrap + text: AppearanceController.nightScheduleConfigured ? "Planification automatique · Position locale configurée" : "Planification à configurer" + color: Theme.secondaryForeground; font.pixelSize: 11 + } + Text { + visible: page.busy || !!AppearanceController.nightLightError + width: parent.width; wrapMode: Text.Wrap + text: page.busy ? "Application…" : AppearanceController.nightLightError + color: AppearanceController.nightLightError ? Theme.warningForeground : Theme.secondaryForeground + font.pixelSize: 11 + } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/WALLPAPER.md b/quickshell/.config/quickshell/labfy-sway/controlcenter/WALLPAPER.md index ea73d94ba..ec5ce61c8 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/WALLPAPER.md +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/WALLPAPER.md @@ -39,7 +39,7 @@ 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, +`$XDG_STATE_HOME/labfy-appearance/effective.json` en schéma 3. 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 diff --git a/quickshell/.config/quickshell/labfy-sway/shell.qml b/quickshell/.config/quickshell/labfy-sway/shell.qml index 6b8357b0b..fb2dfbf5f 100644 --- a/quickshell/.config/quickshell/labfy-sway/shell.qml +++ b/quickshell/.config/quickshell/labfy-sway/shell.qml @@ -21,6 +21,9 @@ ShellRoot { function publishState(payload: string): bool { return AppearanceController.acceptPublished(payload); } + function publishNightLight(payload: string): bool { + return AppearanceController.acceptPublishedNightLight(payload); + } } // Une barre par écran, y compris si les sorties changent pendant la session. // CONTRACT: un seul serveur D-Bus pour toutes les sorties. diff --git a/quickshell/.config/quickshell/labfy-sway/theme/AUTO_THEME.md b/quickshell/.config/quickshell/labfy-sway/theme/AUTO_THEME.md index f64625ee5..39141c051 100644 --- a/quickshell/.config/quickshell/labfy-sway/theme/AUTO_THEME.md +++ b/quickshell/.config/quickshell/labfy-sway/theme/AUTO_THEME.md @@ -73,7 +73,7 @@ non cachées observées ont duré environ 0,03 à 0,10 s pour 1080p/4K et En Auto, l'image est décodée et analysée avant toute écriture. Le backend prépare `generated/wallpaper.conf` et `generated/theme.conf` si nécessaires, valide Sway, recharge Sway, confirme `swaybg`, puis écrit `effective.json` -schéma 3 et les préférences schéma 2 avant publication IPC QuickShell. Un +schéma 3 et les préférences schéma 3 (depuis 17E) avant publication IPC QuickShell. Un verrou non bloquant commun empêche deux applications simultanées. Si une étape échoue, les anciens fichiers générés et JSON sont restaurés et Sway est rechargé. Un échec d'analyse est signalé distinctement avant écriture ; diff --git a/quickshell/.config/quickshell/labfy-sway/theme/AppearanceController.qml b/quickshell/.config/quickshell/labfy-sway/theme/AppearanceController.qml index e089d37e9..a43246ece 100644 --- a/quickshell/.config/quickshell/labfy-sway/theme/AppearanceController.qml +++ b/quickshell/.config/quickshell/labfy-sway/theme/AppearanceController.qml @@ -11,6 +11,7 @@ Singleton { 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" + readonly property string nightLightBackend: (Quickshell.env("HOME") || "/home/fy59") + "/.local/bin/night-light-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. @@ -47,6 +48,16 @@ Singleton { property string themeMode: initial.themeMode === "wallpaper" ? "wallpaper" : "manual" property string manualFlavor: flavors.includes(initial.manualFlavor) ? initial.manualFlavor : effectiveFlavor property var wallpaperAnalysis: initial.wallpaperAnalysis || null + // CONTRACT: le choix persistant reste distinct du mode effectif ; 17F + // pourra suspendre la correction sans écraser la préférence utilisateur. + property string nightLightMode: "auto" + property string effectiveNightLightMode: "auto" + property bool nightLightSuspended: false + property int nightTemperature: 3000 + property int dayTemperature: 6500 + property bool nightScheduleConfigured: false + property bool nightLightBusy: false + property string nightLightError: "" property bool appearanceBusy: false property string appearanceError: "" property bool wallpaperApplying: false @@ -93,6 +104,13 @@ Singleton { manualFlavor = value.manualFlavor; wallpaperDirectory = value.wallpaperDirectory || wallpaperDirectory; wallpaperAnalysis = value.wallpaperAnalysis || value.analysis || null; + if (value.nightLightMode === "off" || value.nightLightMode === "auto" || value.nightLightMode === "on") + nightLightMode = value.nightLightMode; + if (value.effectiveNightLightMode === "off" || value.effectiveNightLightMode === "auto" || value.effectiveNightLightMode === "on") + effectiveNightLightMode = value.effectiveNightLightMode; + if (typeof value.nightLightSuspended === "boolean") nightLightSuspended = value.nightLightSuspended; + if (Number.isInteger(value.nightTemperature)) nightTemperature = value.nightTemperature; + if (Number.isInteger(value.dayTemperature)) dayTemperature = value.dayTemperature; revision = value.revision; appearanceError = ""; wallpaperError = ""; @@ -106,6 +124,72 @@ Singleton { } catch (_) { return false; } } + function acceptNightLight(value) { + if (!value || !["off", "auto", "on"].includes(value.nightLightMode) + || !["off", "auto", "on"].includes(value.effectiveNightLightMode) + || !Number.isInteger(value.nightTemperature) + || value.dayTemperature !== 6500) return false; + nightLightMode = value.nightLightMode; + effectiveNightLightMode = value.effectiveNightLightMode; + nightLightSuspended = value.nightLightSuspended === true; + nightTemperature = value.nightTemperature; + dayTemperature = value.dayTemperature; + nightScheduleConfigured = value.scheduleConfigured === true; + nightLightError = ""; + return true; + } + function acceptPublishedNightLight(payload) { + try { return acceptNightLight(JSON.parse(payload)); } + catch (_) { return false; } + } + Process { + id: nightStartup + command: ["python", appearance.nightLightBackend, "reconcile"] + running: true + stdout: StdioCollector { id: nightStartupOutput; waitForEnd: true } + stderr: StdioCollector { id: nightStartupError; waitForEnd: true } + onExited: (code, status) => { + if (code !== 0 || status !== 0) { + appearance.nightLightError = nightStartupError.text.trim() || "Réconciliation Lumière nocturne impossible"; + return; + } + try { appearance.acceptNightLight(JSON.parse(nightStartupOutput.text)); } + catch (_) { appearance.nightLightError = "État Lumière nocturne invalide"; } + } + } + Process { + id: nightWriter + stdout: StdioCollector { id: nightOutput; waitForEnd: true } + stderr: StdioCollector { id: nightErrors; waitForEnd: true } + onExited: (code, status) => { + appearance.nightLightBusy = false; + if (code !== 0 || status !== 0) { + appearance.nightLightError = nightErrors.text.trim() || "Application Lumière nocturne impossible"; + return; + } + try { + if (!appearance.acceptNightLight(JSON.parse(nightOutput.text))) + appearance.nightLightError = "Réponse Lumière nocturne invalide"; + } catch (_) { appearance.nightLightError = "Réponse Lumière nocturne invalide"; } + } + } + function runNightLight(args) { + if (nightLightBusy) return false; + nightLightBusy = true; + nightLightError = ""; + nightWriter.command = ["python", nightLightBackend].concat(args); + nightWriter.running = true; + return true; + } + function setNightLightMode(mode) { + if (!["off", "auto", "on"].includes(mode) || mode === nightLightMode) return false; + return runNightLight(["set-mode", mode]); + } + function setNightTemperature(value) { + if (!Number.isInteger(value) || value < 2500 || value > 5000 || value % 100 !== 0) return false; + return runNightLight(["set-night-temperature", String(value)]); + } + Process { id: wallpaperWriter stdout: StdioCollector { id: wallpaperOutput; waitForEnd: true } @@ -170,6 +254,9 @@ Singleton { effectiveAccent: effectiveAccent, effectiveWallpaper: effectiveWallpaper, wallpaperMode: wallpaperMode, wallpaperDirectory: wallpaperDirectory, themeMode: themeMode, manualFlavor: manualFlavor, + nightLightMode: nightLightMode, effectiveNightLightMode: effectiveNightLightMode, + nightLightSuspended: nightLightSuspended, nightTemperature: nightTemperature, + dayTemperature: dayTemperature, revision: revision }); } } diff --git a/quickshell/.config/quickshell/labfy-sway/theme/NIGHT_LIGHT.md b/quickshell/.config/quickshell/labfy-sway/theme/NIGHT_LIGHT.md new file mode 100644 index 000000000..cbfdf1d9b --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/theme/NIGHT_LIGHT.md @@ -0,0 +1,50 @@ +# Lumière nocturne — 17E + +La page **Réglages rapides → Apparence → Lumière nocturne** propose Désactivée, +Auto et Activée. `nightLightMode` est le choix persistant de l'utilisateur : +`off`, `auto` ou `on`. `effectiveNightLightMode` décrit le mode appliqué. +`nightLightSuspended` vaut `false` en 17E. Le futur Sun Mode pourra le mettre +à `true` et suspendre la correction sans modifier `nightLightMode` ; la +priorité prévue est Sun Mode > Night Light. 17E ne démarre pas Sun Mode. + +Les préférences Appearance restent dans +`$XDG_CONFIG_HOME/labfy-appearance/preferences.json`, hors Git (schéma 3). +La migration conserve les champs 17D et ajoute `nightLightMode`, +`nightTemperature`, `dayTemperature`, `scheduleType=solar` et la position +locale. Les coordonnées historiques ont été migrées avant le changement de +l'unité systemd. Aucune géolocalisation distante n'est utilisée. Sur une +nouvelle installation sans position, le repli est Off et Auto indique +« Planification à configurer » ; aucune coordonnée n'est inventée. + +Auto reprend exactement la planification solaire native de wlsunset 0.4.0, +avec la position locale, 3000 K la nuit et 6500 K le jour. Activée force la +température de nuit en permanence. La version locale refuse une température +haute égale à la basse ; le service démarre avec un horaire manuel valide, +puis `ExecStartPost` utilise deux SIGUSR1 documentés pour atteindre +`FORCE_LOW`. Le démarrage et chaque reprise du service réappliquent ce mode. +Désactivée arrête le service : la destruction du contrôle gamma Wayland par +wlsunset rend le contrôle au compositeur. La perception du retour neutre doit +être confirmée sur l'écran physique ; une capture ne contient pas forcément +la correction gamma. + +`wlsunset.service` reste l'unique propriétaire du processus. Le launcher +`night-light-manager.py launch` lit les préférences validées, construit un +argv sans shell, puis `exec` `/usr/bin/wlsunset`. L'unité reste enabled pour +la prochaine session. En Off, le launcher quitte sans daemon et le backend +arrête l'unité dans la session courante. QuickShell n'est pas le daemon gamma : +son arrêt ou redémarrage ne coupe pas wlsunset. La commande `reconcile` +compare préférence, unité, PID et argv, répare une unité arrêtée en Auto/On +ou active en Off, et refuse une instance étrangère sans `pkill` global. + +Le backend expose `current`, `validate`, `set-mode`, +`set-night-temperature` et `reconcile`, avec JSON sur stdout et erreurs sur +stderr. Une écriture de préférences passe par fichier temporaire, flush, +fsync et replace. Si l'application ou la publication échoue, le backend +restaure les JSON précédents et le service. Les températures de nuit admises +vont de 2500 à 5000 K par pas de 100 K ; l'interface applique la valeur avec +un bouton, sans relancer le service pendant le glissement. La température de +jour reste 6500 K en V1. Aucun polling de l'unité n'est effectué. + +Changer la lumière nocturne ne modifie pas le thème, l'accent ou le fond +d'écran. Les champs effectifs ajoutés à `effective.json` ne prétendent pas +connaître la température instantanée pendant une transition wlsunset. diff --git a/quickshell/.config/quickshell/labfy-sway/theme/README.md b/quickshell/.config/quickshell/labfy-sway/theme/README.md index 440c3741b..5bccb70ca 100644 --- a/quickshell/.config/quickshell/labfy-sway/theme/README.md +++ b/quickshell/.config/quickshell/labfy-sway/theme/README.md @@ -65,6 +65,8 @@ reste séparé dans `generated/wallpaper.conf` ; voir préserve les champs wallpaper du schéma effectif 3 lors d'un changement de thème. L'algorithme Auto, ses seuils et la migration sont documentés dans [AUTO_THEME.md](AUTO_THEME.md). +La lumière nocturne 17E est indépendante du flavor et du fond ; son service, +ses modes et ses préférences sont décrits dans [NIGHT_LIGHT.md](NIGHT_LIGHT.md). Le repli versionné `sway/theme-default.conf` fournit Mocha même avant toute génération. Ses quelques variables sont un artefact dérivé, pas une palette complète indépendante. `sway/style` inclut le repli, puis un glob de fichier diff --git a/systemd/.config/systemd/user/wlsunset.service b/systemd/.config/systemd/user/wlsunset.service index a364bdfcf..ddb0eded0 100644 --- a/systemd/.config/systemd/user/wlsunset.service +++ b/systemd/.config/systemd/user/wlsunset.service @@ -4,7 +4,13 @@ PartOf=graphical-session.target After=graphical-session.target [Service] -ExecStart=/usr/bin/wlsunset -l 48.85 -L 2.35 -t 3000 -T 6500 +Type=exec +# CONTRACT: le launcher lit les préférences locales puis exec wlsunset ; +# systemd garde ainsi le PID et le contrôle exclusif de la correction gamma. +ExecStart=/usr/bin/python /home/fy59/.local/bin/night-light-manager.py launch +# WHY: wlsunset 0.4.0 refuse -T == -t ; son SIGUSR1 documenté permet +# AUTO -> FORCE_HIGH -> FORCE_LOW lorsque le mode ON est demandé. +ExecStartPost=/usr/bin/python /home/fy59/.local/bin/night-light-manager.py post-start Restart=on-failure [Install] diff --git a/tests/test_night_light_manager.py b/tests/test_night_light_manager.py new file mode 100644 index 000000000..0637fe8f5 --- /dev/null +++ b/tests/test_night_light_manager.py @@ -0,0 +1,110 @@ +"""Contrats Night Light sur préférences et service simulés, sans localisation réelle.""" +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +SOURCE = Path(__file__).resolve().parents[1] / 'bin/.local/bin/night-light-manager.py' +SPEC = importlib.util.spec_from_file_location('night_light_manager', SOURCE) +night = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(night) + + +class NightLightTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.patchers = [mock.patch.object(night, 'CONFIG', self.root / 'preferences.json'), + mock.patch.object(night, 'STATE', self.root / 'effective.json'), + mock.patch.object(night, 'LOCK', self.root / 'wallpaper.lock')] + for patcher in self.patchers: + patcher.start() + self.pref = {'version': 2, 'themeMode': 'manual', 'manualFlavor': 'mocha', + 'wallpaperDirectory': '/fixture', 'nightLightMode': 'auto', + 'nightTemperature': 3000, 'dayTemperature': 6500, + 'scheduleType': 'solar', 'latitude': '12.5', 'longitude': '-8.25'} + night.atomic_json(night.CONFIG, self.pref) + night.atomic_json(night.STATE, {'effectiveFlavor': 'mocha', 'revision': 5}) + + def tearDown(self): + for patcher in reversed(self.patchers): + patcher.stop() + self.temp.cleanup() + + def test_migration_preserves_other_preferences(self): + result = night.normalized(self.pref) + self.assertEqual(result['version'], 3) + self.assertEqual(result['themeMode'], 'manual') + self.assertEqual(result['wallpaperDirectory'], '/fixture') + + def test_auto_argv_uses_solar_and_baseline(self): + self.assertEqual(night.argv(self.pref), ['/usr/bin/wlsunset', '-l', '12.5', '-L', '-8.25', + '-t', '3000', '-T', '6500']) + + def test_on_argv_uses_manual_schedule_for_force_low(self): + pref = {**self.pref, 'nightLightMode': 'on'} + self.assertEqual(night.argv(pref), ['/usr/bin/wlsunset', '-S', '06:00', '-s', '18:00', + '-t', '3000', '-T', '6500']) + + def test_off_has_no_process_argv(self): + self.assertIsNone(night.argv({**self.pref, 'nightLightMode': 'off'})) + + def test_invalid_mode_and_temperature(self): + for mode in ('invalid', '', None): + with self.subTest(mode=mode), self.assertRaises(ValueError): + night.normalized({**self.pref, 'nightLightMode': mode}) + for value in ('3500', '', None, float('nan'), 2400, 5100, 3550, True): + with self.subTest(value=value), self.assertRaises(ValueError): + night.temperature(value) + + def test_fresh_install_has_no_invented_coordinates(self): + self.assertEqual(night.normalized({})['nightLightMode'], 'off') + with self.assertRaisesRegex(ValueError, 'Planification à configurer'): + night.normalized({'nightLightMode': 'auto'}) + + def test_atomic_write_keeps_previous_on_replace_failure(self): + before = night.CONFIG.read_bytes() + with mock.patch.object(night.os, 'replace', side_effect=OSError('échec')): + with self.assertRaises(OSError): + night.atomic_json(night.CONFIG, {'bad': True}) + self.assertEqual(night.CONFIG.read_bytes(), before) + + def test_change_rolls_back_preferences_and_state(self): + before_pref = night.CONFIG.read_bytes() + before_state = night.STATE.read_bytes() + with mock.patch.object(night, 'apply_service', side_effect=[RuntimeError('service'), None]) as apply: + with self.assertRaisesRegex(RuntimeError, 'service'): + night.change(mode='off') + self.assertEqual(apply.call_count, 2) + self.assertEqual(night.CONFIG.read_bytes(), before_pref) + self.assertEqual(night.STATE.read_bytes(), before_state) + + def test_change_keeps_appearance_fields(self): + with mock.patch.object(night, 'apply_service'), mock.patch.object(night, 'publish'): + result = night.change(mode='off') + self.assertEqual(result['nightLightMode'], 'off') + self.assertEqual(json.loads(night.STATE.read_text())['effectiveFlavor'], 'mocha') + self.assertEqual(json.loads(night.CONFIG.read_text())['manualFlavor'], 'mocha') + + def test_reconcile_repairs_missing_service(self): + with mock.patch.object(night, 'verify', side_effect=RuntimeError('absent')), \ + mock.patch.object(night, 'unit_state', return_value=(False, 0)), \ + mock.patch.object(night, 'wlsunset_pids', return_value=[]), \ + mock.patch.object(night, 'apply_service') as apply: + result = night.reconcile() + apply.assert_called_once() + self.assertEqual(result['effectiveNightLightMode'], 'auto') + + def test_future_suspension_preserves_preference(self): + pref = night.normalized(self.pref) + state = {'nightLightSuspended': True} + result = night.snapshot(pref, state) + self.assertEqual(result['nightLightMode'], 'auto') + self.assertEqual(result['effectiveNightLightMode'], 'off') + self.assertEqual(night.effective('on', True), 'off') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_wallpaper_manager.py b/tests/test_wallpaper_manager.py index d357ad970..c788718d8 100644 --- a/tests/test_wallpaper_manager.py +++ b/tests/test_wallpaper_manager.py @@ -65,7 +65,7 @@ class WallpaperManagerTest(unittest.TestCase): 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'], 2) + self.assertEqual(json.loads(manager.PREFERENCES.read_text())['version'], 3) def test_atomic_replace_keeps_old_file_on_write_failure(self): target = self.root / 'atomic.txt'