feat(appearance): integrate Night Light controls
This commit is contained in:
parent
3a37c90080
commit
f96f75cc2b
14 changed files with 691 additions and 6 deletions
324
bin/.local/bin/night-light-manager.py
Normal file
324
bin/.local/bin/night-light-manager.py
Normal 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())
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
110
tests/test_night_light_manager.py
Normal file
110
tests/test_night_light_manager.py
Normal 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()
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in a new issue