Dotfiles_ArchAP/bin/.local/bin/appearance-app-sync.py

272 lines
13 KiB
Python
Executable file

#!/usr/bin/env python3
"""Distribuer l'état effectif Appearance aux applications du bureau."""
import argparse
import configparser
import fcntl
import json
import os
from pathlib import Path
import re
import signal
import subprocess
import sys
import tempfile
import time
ROOT = Path(__file__).resolve().parents[3]
PALETTE = ROOT / 'quickshell/.config/quickshell/labfy-sway/theme/catppuccin.json'
CONFIG = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config'))
STATE_DIR = Path(os.environ.get('XDG_STATE_HOME', Path.home() / '.local/state')) / 'labfy-appearance'
STATE = STATE_DIR / 'effective.json'
RESULT = STATE_DIR / 'last-applied.json'
FLAVORS = ('latte', 'frappe', 'macchiato', 'mocha')
ACCENTS = ('rosewater', 'flamingo', 'pink', 'mauve', 'red', 'maroon', 'peach',
'yellow', 'green', 'teal', 'sky', 'sapphire', 'blue', 'lavender')
COLOR_KEYS = set(ACCENTS) | {'text', 'subtext1', 'subtext0', 'overlay2', 'overlay1',
'overlay0', 'surface2', 'surface1', 'surface0',
'base', 'mantle', 'crust'}
HEX = re.compile(r'#[0-9a-fA-F]{6}\Z')
def atomic_write(path, data):
"""CONTRACT: un ancien fichier valide survit à tout échec avant os.replace."""
path.parent.mkdir(parents=True, exist_ok=True)
fd, name = tempfile.mkstemp(prefix=path.name + '.', suffix='.tmp', dir=path.parent)
try:
with os.fdopen(fd, 'wb') as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.replace(name, path)
directory = os.open(path.parent, os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
Path(name).unlink(missing_ok=True)
def read_effective(path=STATE):
raw = json.loads(path.read_text(encoding='utf-8'))
if not isinstance(raw, dict):
raise ValueError('effective.json doit être un objet')
keys = ('effectiveFlavor', 'effectiveAccent', 'effectiveDark',
'effectiveHighContrast', 'effectiveMode', 'revision')
value = {key: raw[key] for key in keys}
flavor, accent = value['effectiveFlavor'], value['effectiveAccent']
if flavor not in FLAVORS or accent not in ACCENTS:
raise ValueError('flavor/accent invalide')
if type(value['effectiveDark']) is not bool or type(value['effectiveHighContrast']) is not bool:
raise ValueError('booléen effectif invalide')
if type(value['revision']) is not int or value['revision'] < 0:
raise ValueError('revision invalide')
mode = value['effectiveMode']
if mode not in ('normal', 'sun-light', 'sun-dark'):
raise ValueError('mode invalide')
if value['effectiveDark'] != (flavor != 'latte'):
raise ValueError('dark/flavor incohérents')
if value['effectiveHighContrast'] != (mode != 'normal'):
raise ValueError('contrast/mode incohérents')
if mode == 'sun-light' and flavor != 'latte' or mode == 'sun-dark' and flavor == 'latte':
raise ValueError('flavor/mode incohérents')
return value
def read_palette(path=PALETTE):
raw = json.loads(path.read_text(encoding='utf-8'))
if not isinstance(raw, dict) or set(raw) != set(FLAVORS) or any(
not isinstance(p, dict) or set(p) != COLOR_KEYS or
not all(isinstance(c, str) and HEX.fullmatch(c) for c in p.values())
for p in raw.values()):
raise ValueError('palette Catppuccin invalide')
return raw
def run(*args):
return subprocess.run(args, check=True, capture_output=True, text=True, timeout=5)
def gtk_adapter(state, colors):
flavor = state['effectiveFlavor']
dark = state['effectiveDark']
# WHY: seul Mocha est installé localement ; inventer les autres noms donne
# un fallback GTK imprévisible. Adwaita respecte au moins Light/Dark.
name = 'Catppuccin-Mocha' if flavor == 'mocha' else 'Adwaita'
if name == 'Catppuccin-Mocha' and not (Path.home() / '.themes/Catppuccin-Mocha/gtk-3.0').is_dir():
name = 'Adwaita'
scheme = 'prefer-dark' if dark else 'prefer-light'
run('gsettings', 'set', 'org.gnome.desktop.interface', 'gtk-theme', name)
run('gsettings', 'set', 'org.gnome.desktop.interface', 'color-scheme', scheme)
return {'status': 'ok', 'theme': name, 'colorScheme': scheme,
'accent': 'rosewater (installé)' if name == 'Catppuccin-Mocha' else 'default',
'highContrast': 'unsupported' if state['effectiveHighContrast'] else 'off'}
def qt_palette(p, accent):
# CONTRACT: Qt ColorScheme contient 22 rôles QColor, en ordre documenté par
# les fichiers qt5ct/qt6ct existants ; alpha explicite, aucune struct native.
roles = ('text', 'surface1', 'surface2', 'surface0', 'crust', 'mantle',
'text', 'text', 'text', 'base', 'mantle', 'crust', accent,
'crust', 'blue', accent, 'mantle', 'text', 'base', 'text',
'overlay0', accent)
active = ['#ff' + p[r][1:] for r in roles]
inactive = active.copy()
disabled = active.copy()
for index in (0, 6, 8, 13, 14, 15):
inactive[index] = '#ff' + p['subtext0'][1:]
disabled[index] = '#ff' + p['overlay0'][1:]
return ('[ColorScheme]\n' +
'active_colors=' + ', '.join(active) + '\n' +
'inactive_colors=' + ', '.join(inactive) + '\n' +
'disabled_colors=' + ', '.join(disabled) + '\n').encode()
def qt_adapter(state, colors):
# WHY: les deux versions doivent pointer sur des palettes cohérentes.
# Les instances Qt déjà ouvertes peuvent garder la palette chargée.
for version in ('qt5ct', 'qt6ct'):
directory = CONFIG / version
settings = directory / (version + '.conf')
target = directory / 'colors/labfy-appearance.conf'
if not settings.is_file():
raise FileNotFoundError(settings)
content = settings.read_text(encoding='utf-8')
updated, count = re.subn(r'(?m)^color_scheme_path=.*$',
'color_scheme_path=' + str(target), content)
if count != 1:
raise ValueError('color_scheme_path manquant ou dupliqué: ' + str(settings))
atomic_write(target, qt_palette(colors[state['effectiveFlavor']], state['effectiveAccent']))
if updated != content:
atomic_write(settings, updated.encode())
return {'status': 'ok', 'flavor': state['effectiveFlavor'], 'live': 'new-window'}
def kitty_theme(p, accent, opaque):
def line(key, value):
return key + ' ' + value
lines = ['# Généré depuis theme/catppuccin.json ; ne pas éditer.',
line('foreground', p['text']), line('background', p['base']),
line('selection_foreground', p['base']),
line('selection_background', p['rosewater']),
line('cursor', p['rosewater']), line('cursor_text_color', p['base']),
line('active_border_color', p[accent]),
line('inactive_border_color', p['overlay0']),
line('active_tab_foreground', p['crust']),
line('active_tab_background', p[accent]),
line('inactive_tab_foreground', p['text']),
line('inactive_tab_background', p['mantle']),
line('tab_bar_background', p['crust']),
line('background_opacity', '1.0' if opaque else '1.0')]
# CONTRACT: l'opacité normale était la valeur implicite Kitty 1.0.
ansi = ('surface1', 'red', 'green', 'yellow', 'blue', 'pink', 'teal', 'subtext1',
'surface2', 'red', 'green', 'yellow', 'blue', 'pink', 'teal', 'text')
lines.extend(line('color' + str(i), p[name]) for i, name in enumerate(ansi))
return ('\n'.join(lines) + '\n').encode()
def kitty_adapter(state, colors):
path = CONFIG / 'kitty/generated-appearance.conf'
config = CONFIG / 'kitty/kitty.conf'
if 'include generated-appearance.conf' not in config.read_text(encoding='utf-8'):
raise ValueError('include Kitty manquant')
atomic_write(path, kitty_theme(colors[state['effectiveFlavor']], state['effectiveAccent'],
state['effectiveHighContrast']))
# WHY: l'instance actuelle surveille kitty.conf, mais pas nécessairement
# l'include créé après son lancement. Kitty 0.49.2 documente SIGUSR1 pour
# recharger la configuration sans exposer de socket remote-control.
signalled = 0
for entry in os.scandir('/proc'):
if not entry.name.isdecimal():
continue
try:
if entry.stat().st_uid == os.getuid() and os.readlink(entry.path + '/exe') == '/usr/bin/kitty':
os.kill(int(entry.name), signal.SIGUSR1)
signalled += 1
except (FileNotFoundError, PermissionError, ProcessLookupError):
continue
return {'status': 'ok', 'reload': 'SIGUSR1', 'instances': signalled, 'opacity': '1.0'}
def nvim_adapter(state, colors):
# CONTRACT: Neovim lit effective.json directement au démarrage et via fs_event.
return {'status': 'ok', 'contract': 'effective.json', 'revision': state['revision']}
def firefox_adapter(state, colors):
# CONTRACT: lecture seule de deux préférences ; jamais de modification du
# profil Firefox ouvert, de prefs.js ou des bases de données.
root = Path.home() / '.mozilla/firefox'
profiles = configparser.ConfigParser()
if not profiles.read(root / 'profiles.ini'):
return {'status': 'unsupported', 'reason': 'profiles.ini absent'}
install = next((s for s in profiles.sections() if s.startswith('Install')), None)
if not install:
return {'status': 'unsupported', 'reason': 'profil actif indéterminé'}
profile = (root / profiles[install]['Default']).resolve()
if not profile.is_relative_to(root.resolve()):
raise ValueError('chemin de profil Firefox invalide')
prefs = profile / 'prefs.js'
if prefs.stat().st_size > 4_000_000:
raise ValueError('prefs.js trop volumineux pour audit borné')
content = prefs.read_text(encoding='utf-8')
theme_match = re.search(r'^user_pref\("extensions.activeThemeID", "([^"\\]+)"\);$', content, re.M)
override_match = re.search(r'^user_pref\("layout.css.prefers-color-scheme.content-override", (\d+)\);$', content, re.M)
theme = theme_match.group(1) if theme_match else 'indéterminé'
override = int(override_match.group(1)) if override_match else 2
# WHY: Proton 1.1 fournit `theme` et `dark_theme` dans son manifeste ;
# le test Firefox ouvert a confirmé ses deux palettes avec le portail.
# CONTRACT: seuls les thèmes audités suivent le système ; l'override Auto
# reste obligatoire et cet adaptateur ne fait aucune écriture de profil.
if theme in ('default-theme@mozilla.org', 'proton-theme@mozilla.org') and override == 2:
return {'status': 'ok', 'integration': 'system', 'lightDark': 'supported',
'profileWrite': False, 'theme': theme,
'colorScheme': 'dark' if state['effectiveDark'] else 'light'}
return {'status': 'unsupported', 'theme': theme, 'contentOverride': override,
'reason': 'migration Firefox vers thème System et contenu Auto nécessaire'}
ADAPTERS = {'gtk': gtk_adapter, 'qt': qt_adapter, 'kitty': kitty_adapter,
'nvim': nvim_adapter, 'firefox': firefox_adapter}
def reconcile(state_path=STATE, result_path=RESULT, palette_path=PALETTE):
result_path.parent.mkdir(parents=True, exist_ok=True)
with open(result_path.parent / 'app-sync.lock', 'a+b') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
# INVARIANT: lecture sous verrou après tout prédécesseur ; une révision
# arrivée pendant l'application est relue avant de publier le résultat.
colors = read_palette(palette_path)
while True:
state = read_effective(state_path)
outcomes = {}
for name, adapter in ADAPTERS.items():
try:
outcomes[name] = adapter(state, colors)
except (OSError, ValueError, KeyError, subprocess.CalledProcessError,
subprocess.TimeoutExpired) as exc:
outcomes[name] = {'status': 'error', 'reason': str(exc)[:300]}
if read_effective(state_path) != state:
continue
result = {'revision': state['revision'], 'timestamp': int(time.time()),
'adapters': outcomes}
atomic_write(result_path, (json.dumps(result, ensure_ascii=False) + '\n').encode())
if read_effective(state_path) == state:
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=('reconcile',))
parser.parse_args()
try:
result = reconcile()
print(json.dumps(result, ensure_ascii=False))
return 1 if any(v['status'] == 'error' for v in result['adapters'].values()) else 0
except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc:
print('Erreur Appearance : ' + str(exc), file=sys.stderr)
return 1
if __name__ == '__main__':
raise SystemExit(main())