feat(appearance): integrate Night Light controls

This commit is contained in:
fy59 2026-10-05 04:04:02 +02:00
parent 3a37c90080
commit f96f75cc2b
14 changed files with 691 additions and 6 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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