feat(appearance): synchronize desktop application themes
This commit is contained in:
parent
172fa98f1d
commit
69aff76fc4
15 changed files with 695 additions and 6 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -10,6 +10,11 @@ sway/.config/sway/generated/theme.conf.tmp
|
|||
sway/.config/sway/generated/wallpaper.conf
|
||||
sway/.config/sway/generated/wallpaper.conf.tmp
|
||||
|
||||
# 17G: palettes applicatives recréées depuis l'état Appearance effectif.
|
||||
kitty/.config/kitty/generated-appearance.conf
|
||||
qt5ct/.config/qt5ct/colors/labfy-appearance.conf
|
||||
qt6ct/.config/qt6ct/colors/labfy-appearance.conf
|
||||
|
||||
# Local editor/application backups
|
||||
*.bak
|
||||
*.old
|
||||
|
|
|
|||
272
bin/.local/bin/appearance-app-sync.py
Executable file
272
bin/.local/bin/appearance-app-sync.py
Executable file
|
|
@ -0,0 +1,272 @@
|
|||
#!/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())
|
||||
150
docs/appearance-app-sync.md
Normal file
150
docs/appearance-app-sync.md
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
# Synchronisation des thèmes applicatifs 17G
|
||||
|
||||
## Contrat et architecture
|
||||
|
||||
`$XDG_STATE_HOME/labfy-appearance/effective.json` est la seule source des
|
||||
choix applicatifs. `appearance-app-sync.py reconcile` consomme uniquement
|
||||
`effectiveFlavor`, `effectiveAccent`, `effectiveDark`,
|
||||
`effectiveHighContrast`, `effectiveMode` et `revision`. Il rejette un schéma
|
||||
absent, invalide ou incohérent avant toute modification. Il ne lit ni les
|
||||
coordonnées, ni les lieux sauvegardés, ni l'analyse du wallpaper.
|
||||
|
||||
Le synchroniseur prend un verrou `app-sync.lock`, relit l'état après chaque
|
||||
passage des adaptateurs et ne publie `last-applied.json` qu'avec la dernière
|
||||
révision observée. Le résultat contient un statut indépendant par adaptateur.
|
||||
Les fichiers générés sont écrits par fichier temporaire, `fsync`, puis
|
||||
`os.replace`. Les palettes Qt et Kitty viennent toutes de
|
||||
`quickshell/.config/quickshell/labfy-sway/theme/catppuccin.json`.
|
||||
Les trois fichiers de palettes générés sont ignorés par Git et recréés par
|
||||
le service à la réconciliation ; seuls les points d'entrée sont versionnés.
|
||||
|
||||
`labfy-appearance-app-sync.path` observe `effective.json` et déclenche le
|
||||
service one-shot. Le service est aussi voulu par la cible de session UWSM,
|
||||
ce qui réconcilie après login. Aucun polling ni daemon Python. Les limites
|
||||
de fréquence systemd sont désactivées pour accepter les rafales de révisions ;
|
||||
le verrou sérialise les exécutions. Une panne d'un adaptateur est enregistrée
|
||||
sans bloquer les suivants. `last-applied.json` hors Git contient la révision,
|
||||
les résultats et un timestamp ; il ne contient pas de lieu.
|
||||
|
||||
## État de départ audité le 5 octobre 2026
|
||||
|
||||
| Écosystème | État réellement constaté |
|
||||
| --- | --- |
|
||||
| GTK | `gsettings` demandait `catppuccin-mocha-lavender-standard+default` et `prefer-dark`. Seul `~/.themes/Catppuccin-Mocha` était présent ; son `index.theme` et son CSS utilisent Rosewater. Le nom demandé ne correspondait pas au répertoire installé. |
|
||||
| Qt | `qt5ct` et `qt6ct` installés, palettes Mocha fixes ; `QT_QPA_PLATFORMTHEME` absent de l'environnement systemd de session. |
|
||||
| Kitty | 0.49.2, instance active, include Mocha fixe, opacité implicite 1.0. |
|
||||
| Neovim | 0.12.5, plugin Catppuccin, `init.lua` et lualine fixés sur Mocha. |
|
||||
| Firefox avant migration utilisateur | 157.0, profil actif identifié par `profiles.ini`, thème intégré Dark (`firefox-compact-dark@mozilla.org`), contenu forcé clair par `layout.css.prefers-color-scheme.content-override=1`. Aucun `user.js`, `userChrome.css` ou `userContent.css` trouvé. Aucun profil n'est modifié par 17G. |
|
||||
|
||||
## Capacités et limites
|
||||
|
||||
| Application | Light/Dark | Flavor exact | High Contrast | Live |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| GTK classique | oui via `gsettings` | Mocha installé avec Rosewater ; autres flavors absents, fallback Adwaita | partiel : préférence et thème clair/sombre ; aucune variante Catppuccin HC installée | nouvelles fenêtres ; recharge des fenêtres existantes selon application |
|
||||
| GTK4/libadwaita | préférence système Light/Dark | thème custom non garanti pour libadwaita | non démontré | selon application |
|
||||
| Qt5/Qt6 | oui | quatre palettes générées Lavender | pas de palette HC distincte | nouvelles applications ; redémarrage possible des instances ouvertes |
|
||||
| Kitty | oui | quatre flavors depuis la palette unique | fond opaque 1.0 ; déjà opaque en mode normal | SIGUSR1 documenté, envoyé aux processus Kitty du même utilisateur |
|
||||
| Neovim | oui | quatre flavors Catppuccin | Visual/Search/StatusLine/séparateurs adaptés, sans dimming | `uv.fs_event`, testé sans redémarrage |
|
||||
| Firefox actuel | oui : thème Proton 1.1 à deux palettes et apparence des sites Auto | aucun flavor Catppuccin | non | chrome sans redémarrage ; page locale actualisée lors du test |
|
||||
|
||||
Pour GTK, `Catppuccin-Mocha` est le répertoire réellement présent. Latte,
|
||||
Frappé et Macchiato Lavender n'étaient pas installés ; l'adaptateur choisit
|
||||
Adwaita pour ne pas publier un nom de thème inexistant. Installer des variantes
|
||||
auditées de [Catppuccin GTK](https://github.com/catppuccin/gtk) est un travail
|
||||
distinct. Un thème GTK custom ne recolore pas automatiquement libadwaita.
|
||||
|
||||
`uwsm/.config/uwsm/env` exporte `QT_QPA_PLATFORMTHEME=qt5ct`. Sur cette
|
||||
machine, le plugin Qt6 expose aussi la clé `qt5ct` ; les deux versions ont été
|
||||
observées ouvrant leurs plugins et leurs palettes générées avec `strace`.
|
||||
Un nouveau login est nécessaire pour que tous les lanceurs et processus de
|
||||
session héritent de cette variable. L'environnement systemd/DBus courant a
|
||||
été mis à jour pour les nouvelles applications lancées par ces services.
|
||||
|
||||
Kitty inclut `generated-appearance.conf` ; le synchroniseur envoie SIGUSR1
|
||||
après chaque publication. La documentation locale Kitty 0.49.2 confirme ce
|
||||
signal de rechargement. Le synchroniseur n'ouvre aucun socket de contrôle.
|
||||
L'opacité normale préexistante était 1.0 ; Sun Light et Sun Dark restent à 1.0.
|
||||
|
||||
Neovim lit l'état avant de choisir son premier colorscheme, puis surveille
|
||||
le répertoire de `effective.json` pour les renommages atomiques. Un état absent
|
||||
ou invalide retombe sur Mocha sans bloquer l'éditeur. Le watcher est fermé à
|
||||
`VimLeavePre`. `lazy-lock.json` préexistant reste hors chantier.
|
||||
|
||||
Firefox consulte la préférence système/portail quand le thème actif prend
|
||||
en charge les deux palettes et que le contenu est configuré sur Auto. Le portail existant
|
||||
`xdg-desktop-portal-gtk` a renvoyé `1` en sombre et `2` en Sun Light.
|
||||
La [source Mozilla](https://searchfox.org/firefox-main/source/modules/libpref/init/StaticPrefList.yaml)
|
||||
définit l'override contenu `0=Dark`, `1=Light`, `2=Auto`. La migration doit
|
||||
être faite dans l'interface Firefox : choisir un thème compatible avec les
|
||||
deux palettes, dont le thème intégré **System** ou le Proton 1.1 audité, et
|
||||
l'apparence des sites **Automatic**, puis vérifier localement
|
||||
`matchMedia('(prefers-color-scheme: dark)').matches` en Mocha, Sun Light et
|
||||
Sun Dark. Le chrome et le contenu doivent être contrôlés séparément. Aucune
|
||||
écriture de `prefs.js`, base SQLite ou fichier d'extensions n'est permise
|
||||
pendant que Firefox est ouvert.
|
||||
|
||||
Avant la migration, une page HTML locale a été ouverte dans le profil actif et a affiché
|
||||
`light` en Mocha, Sun Light **et** Sun Dark. Ce test confirme que le contenu
|
||||
ne suivait alors pas la préférence système. La fenêtre de test a été fermée.
|
||||
|
||||
## Validation de migration du 5 octobre 2026
|
||||
|
||||
Après les changements manuels dans Firefox, le profil actif découvert par
|
||||
`profiles.ini` indique `proton-theme@mozilla.org`, et ne contient
|
||||
plus de préférence `layout.css.prefers-color-scheme.content-override` :
|
||||
la valeur par défaut Auto vaut 2. Le thème intégré **System** n'est donc pas
|
||||
le thème actif, malgré le choix annoncé dans l'interface. Le manifeste local
|
||||
du thème Proton 1.1 possède `theme` et `dark_theme`. Les essais réels ont
|
||||
montré que son chrome suit la préférence du portail : sombre en Mocha normal,
|
||||
clair en Sun Light, sombre en Sun Dark, sans redémarrage. La page HTML locale
|
||||
a rendu `dark`, `light`, `dark` ; lors du premier passage en Sun Light, un
|
||||
rechargement a accéléré l'observation de `light`. Lors du second passage, la
|
||||
page déjà ouverte a basculé après un délai sans rechargement. Le portail
|
||||
a rendu respectivement 1, 2, 1. L'adaptateur accepte donc ce thème précis
|
||||
comme intégration Light/Dark système, tout en signalant son identifiant réel.
|
||||
Il ne lit que `profiles.ini` et `prefs.js` et n'écrit aucun fichier Firefox.
|
||||
Le thème Proton n'est pas littéralement le thème intégré **System** ; cette
|
||||
nuance n'empêche pas la synchronisation Light/Dark observée.
|
||||
|
||||
## Vérification et retour arrière
|
||||
|
||||
Les transitions Mocha → Latte → Sun Light → Sun Dark → Frappé → Macchiato →
|
||||
Mocha ont produit des résultats indépendants pour les cinq adaptateurs.
|
||||
GTK a basculé `prefer-dark`/`prefer-light`, Qt et Kitty ont généré les bonnes
|
||||
palettes, Neovim a basculé live vers Latte, et le portail a publié `1`/`2`.
|
||||
Des captures de Thunar GTK3, qt5ct, qt6ct, Kitty, Neovim et Firefox en Mocha
|
||||
et Sun Light ainsi que des vues globales sont conservées dans
|
||||
`~/.local/state/labfy-appearance/captures/17g/` avec permissions privées.
|
||||
Thunar et les fenêtres de configuration Qt montrent le basculement visuel
|
||||
clair/sombre ; Kitty et Neovim montrent Latte. Une trace d'ouverture GTK4
|
||||
confirme que `~/.themes/Catppuccin-Mocha/gtk-4.0/gtk.css` est réellement lu
|
||||
en Mocha. Avant la migration, Firefox conservait le chrome sombre en Sun Light ;
|
||||
après la migration, il passe au chrome clair. La validation
|
||||
de lisibilité physique en extérieur reste à faire par l'utilisateur ; une
|
||||
capture ne la remplace pas.
|
||||
|
||||
Validation automatisée finale : 63 tests Python, `py_compile`, `luac -p`,
|
||||
démarrage Neovim headless, `qmllint` sur 52 QML,
|
||||
`systemd-analyze --user verify` et `git diff --check`.
|
||||
L'instance Neovim ouverte est passée à Latte via `fs_event`. Une rafale de
|
||||
six changements a laissé le `.path` actif avec la dernière révision publiée.
|
||||
Une réconciliation mesurée a duré environ 73 ms sur cette machine.
|
||||
|
||||
Pour désactiver :
|
||||
|
||||
```sh
|
||||
systemctl --user disable --now labfy-appearance-app-sync.path labfy-appearance-app-sync.service
|
||||
```
|
||||
|
||||
Réappliquer l'état courant après une installation ou un redémarrage :
|
||||
|
||||
```sh
|
||||
~/.local/bin/appearance-app-sync.py reconcile
|
||||
```
|
||||
|
||||
Les configurations générées restent utilisables sans le service. Pour
|
||||
retrouver les chemins initiaux, rétablir l'include Kitty
|
||||
`kitty-themes/themes/mocha.conf`, les deux `color_scheme_path` vers
|
||||
`catppuccin-mocha-lavender.conf`, supprimer l'export UWSM et choisir
|
||||
explicitement Mocha dans Neovim. Réactiver le service se fait avec
|
||||
`systemctl --user enable --now` pour les deux unités.
|
||||
|
|
@ -29,6 +29,7 @@ cursor_stop_blinking_after 0
|
|||
cursor_trail 0
|
||||
shell_integration no-cursor
|
||||
|
||||
include kitty-themes/themes/mocha.conf
|
||||
# CONTRACT: le synchroniseur publie ce fichier atomiquement depuis la palette commune.
|
||||
include generated-appearance.conf
|
||||
|
||||
map alt+c no_cp
|
||||
|
|
|
|||
|
|
@ -115,4 +115,5 @@ end, {
|
|||
desc = "Docker terminal",
|
||||
})
|
||||
|
||||
vim.cmd.colorscheme "catppuccin-mocha"
|
||||
-- CONTRACT: démarrage et instances ouvertes consomment le même état effectif.
|
||||
require("config.appearance").start()
|
||||
|
|
|
|||
70
nvim/.config/nvim/lua/config/appearance.lua
Normal file
70
nvim/.config/nvim/lua/config/appearance.lua
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
-- WHY: le thème de l'éditeur suit la révision Appearance, sans minuterie ni
|
||||
-- dépendance envers le processus QuickShell.
|
||||
local M = {}
|
||||
local uv = vim.uv
|
||||
local path = (vim.env.XDG_STATE_HOME or (vim.env.HOME .. "/.local/state"))
|
||||
.. "/labfy-appearance/effective.json"
|
||||
local revision = -1
|
||||
local watcher
|
||||
|
||||
local function state()
|
||||
local file = io.open(path, "r")
|
||||
if not file then return nil end
|
||||
local data = file:read("*a")
|
||||
file:close()
|
||||
local ok, value = pcall(vim.json.decode, data)
|
||||
if not ok or type(value) ~= "table" then return nil end
|
||||
local flavors = { latte = true, frappe = true, macchiato = true, mocha = true }
|
||||
if not flavors[value.effectiveFlavor] or type(value.revision) ~= "number"
|
||||
or value.revision < 0 or value.revision % 1 ~= 0
|
||||
or type(value.effectiveHighContrast) ~= "boolean" then return nil end
|
||||
return value
|
||||
end
|
||||
|
||||
local function apply()
|
||||
local current = state()
|
||||
local flavor = current and current.effectiveFlavor or "mocha"
|
||||
local current_revision = current and current.revision or -1
|
||||
if current_revision == revision and vim.g.colors_name == "catppuccin-" .. flavor then return end
|
||||
-- CONTRACT: erreurs de l'état ou du plugin n'empêchent pas l'éditeur de démarrer.
|
||||
local ok = pcall(function()
|
||||
require("catppuccin").setup({
|
||||
flavour = flavor,
|
||||
dim_inactive = { enabled = false },
|
||||
custom_highlights = function(colors)
|
||||
if not (current and current.effectiveHighContrast) then return {} end
|
||||
return {
|
||||
Visual = { bg = colors.surface2, fg = colors.text },
|
||||
Search = { bg = colors.yellow, fg = colors.base },
|
||||
IncSearch = { bg = colors.peach, fg = colors.base },
|
||||
StatusLine = { bg = colors.surface2, fg = colors.text },
|
||||
WinSeparator = { fg = colors.overlay2 },
|
||||
}
|
||||
end,
|
||||
})
|
||||
vim.cmd.colorscheme("catppuccin-" .. flavor)
|
||||
end)
|
||||
if not ok then pcall(vim.cmd.colorscheme, "default") end
|
||||
revision = current_revision
|
||||
end
|
||||
|
||||
function M.start()
|
||||
apply()
|
||||
-- fs_event sur le répertoire observe l'écriture tmp + rename atomique.
|
||||
-- Un seul handle par instance ; VimLeavePre ferme explicitement sa ressource.
|
||||
watcher = uv.new_fs_event()
|
||||
if watcher then
|
||||
local directory = vim.fn.fnamemodify(path, ":h")
|
||||
if uv.fs_stat(directory) then
|
||||
local ok = watcher:start(directory, {}, function(err, filename)
|
||||
if not err and filename == "effective.json" then vim.schedule(apply) end
|
||||
end)
|
||||
if not ok then watcher:close(); watcher = nil end
|
||||
else watcher:close(); watcher = nil end
|
||||
end
|
||||
vim.api.nvim_create_autocmd("VimLeavePre", { callback = function()
|
||||
if watcher then watcher:stop(); watcher:close(); watcher = nil end
|
||||
end })
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
@ -4,7 +4,7 @@ return {
|
|||
dependencies = { "nvim-tree/nvim-web-devicons" },
|
||||
opts = {
|
||||
options = {
|
||||
theme = "catppuccin-mocha",
|
||||
theme = "auto",
|
||||
icons_enabled = true,
|
||||
section_separators = "",
|
||||
component_separators = "",
|
||||
|
|
@ -20,4 +20,3 @@ return {
|
|||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
[Appearance]
|
||||
color_scheme_path=/home/fy59/.config/qt5ct/colors/catppuccin-mocha-lavender.conf
|
||||
color_scheme_path=/home/fy59/.config/qt5ct/colors/labfy-appearance.conf
|
||||
custom_palette=true
|
||||
standard_dialogs=default
|
||||
style=Fusion
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
[Appearance]
|
||||
color_scheme_path=/home/fy59/.config/qt6ct/colors/catppuccin-mocha-lavender.conf
|
||||
color_scheme_path=/home/fy59/.config/qt6ct/colors/labfy-appearance.conf
|
||||
custom_palette=true
|
||||
standard_dialogs=default
|
||||
style=Fusion
|
||||
|
|
|
|||
10
systemd/.config/systemd/user/labfy-appearance-app-sync.path
Normal file
10
systemd/.config/systemd/user/labfy-appearance-app-sync.path
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[Unit]
|
||||
Description=Observer les changements Appearance effectifs
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
[Path]
|
||||
PathChanged=%h/.local/state/labfy-appearance/effective.json
|
||||
Unit=labfy-appearance-app-sync.service
|
||||
|
||||
[Install]
|
||||
WantedBy=wayland-session@sway.desktop.target
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
[Unit]
|
||||
Description=Synchroniser les applications avec l'état Appearance effectif
|
||||
# WHY: plusieurs révisions légitimes peuvent arriver en une seconde ; le lock
|
||||
# applicatif sérialise les exécutions, la limite systemd ne doit pas tuer le path.
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=%h/.local/bin/appearance-app-sync.py reconcile
|
||||
|
||||
[Install]
|
||||
WantedBy=wayland-session@sway.desktop.target
|
||||
|
|
@ -0,0 +1 @@
|
|||
../labfy-appearance-app-sync.path
|
||||
|
|
@ -0,0 +1 @@
|
|||
../labfy-appearance-app-sync.service
|
||||
164
tests/test_appearance_app_sync.py
Normal file
164
tests/test_appearance_app_sync.py
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
"""Contrats de validation et publication du synchroniseur Appearance."""
|
||||
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/appearance-app-sync.py'
|
||||
spec = importlib.util.spec_from_file_location('appearance_app_sync', SOURCE)
|
||||
sync = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(sync)
|
||||
|
||||
|
||||
class SyncTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name)
|
||||
self.state = self.root / 'effective.json'
|
||||
self.result = self.root / 'last-applied.json'
|
||||
self.palette = sync.PALETTE
|
||||
self.value = {'effectiveFlavor': 'mocha', 'effectiveAccent': 'lavender',
|
||||
'effectiveDark': True, 'effectiveHighContrast': False,
|
||||
'effectiveMode': 'normal', 'revision': 4,
|
||||
'latitude': 48.0, 'savedLocations': ['secret']}
|
||||
self.write()
|
||||
|
||||
def write(self):
|
||||
self.state.write_text(json.dumps(self.value), encoding='utf-8')
|
||||
|
||||
def test_projection_excludes_location(self):
|
||||
state = sync.read_effective(self.state)
|
||||
self.assertEqual(set(state), {'effectiveFlavor', 'effectiveAccent',
|
||||
'effectiveDark', 'effectiveHighContrast',
|
||||
'effectiveMode', 'revision'})
|
||||
|
||||
def test_bad_state_never_runs_adapter_or_publishes(self):
|
||||
for mutation in ({'revision': None}, {'effectiveFlavor': 'invalid'},
|
||||
{'effectiveDark': False}, {'effectiveMode': 'sun-light'}):
|
||||
with self.subTest(mutation=mutation):
|
||||
self.value.update(mutation)
|
||||
self.write()
|
||||
with mock.patch.object(sync, 'ADAPTERS', {'gtk': mock.Mock()}) as adapters:
|
||||
with self.assertRaises(ValueError):
|
||||
sync.reconcile(self.state, self.result, self.palette)
|
||||
adapters['gtk'].assert_not_called()
|
||||
self.assertFalse(self.result.exists())
|
||||
self.value = {'effectiveFlavor': 'mocha', 'effectiveAccent': 'lavender',
|
||||
'effectiveDark': True, 'effectiveHighContrast': False,
|
||||
'effectiveMode': 'normal', 'revision': 4}
|
||||
del self.value['revision']
|
||||
self.write()
|
||||
with self.assertRaises(KeyError):
|
||||
sync.reconcile(self.state, self.result, self.palette)
|
||||
self.assertFalse(self.result.exists())
|
||||
|
||||
def test_partial_failure_and_privacy(self):
|
||||
def failed(*args):
|
||||
raise OSError('indisponible')
|
||||
with mock.patch.object(sync, 'ADAPTERS', {'gtk': failed,
|
||||
'qt': lambda *_: {'status': 'ok'}}):
|
||||
result = sync.reconcile(self.state, self.result, self.palette)
|
||||
self.assertEqual(result['adapters']['gtk']['status'], 'error')
|
||||
self.assertEqual(result['adapters']['qt']['status'], 'ok')
|
||||
content = self.result.read_text()
|
||||
self.assertNotIn('latitude', content)
|
||||
self.assertNotIn('secret', content)
|
||||
|
||||
def test_new_revision_wins_during_adapter_execution(self):
|
||||
seen = []
|
||||
def mutate_once(state, colors):
|
||||
seen.append(state['revision'])
|
||||
if len(seen) == 1:
|
||||
self.value['revision'] = 5
|
||||
self.write()
|
||||
return {'status': 'ok'}
|
||||
with mock.patch.object(sync, 'ADAPTERS', {'gtk': mutate_once}):
|
||||
result = sync.reconcile(self.state, self.result, self.palette)
|
||||
self.assertEqual(seen, [4, 5])
|
||||
self.assertEqual(result['revision'], 5)
|
||||
|
||||
def test_missing_palette_does_not_replace_config(self):
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
sync.reconcile(self.state, self.result, self.root / 'missing.json')
|
||||
self.assertFalse(self.result.exists())
|
||||
|
||||
def test_qt_kitty_palette_is_shared(self):
|
||||
p = sync.read_palette()['latte']
|
||||
self.assertIn(p['base'].encode(), sync.kitty_theme(p, 'lavender', True))
|
||||
self.assertIn(('#ff' + p['base'][1:]).encode(), sync.qt_palette(p, 'lavender'))
|
||||
self.assertEqual(len(sync.qt_palette(p, 'lavender').splitlines()), 4)
|
||||
|
||||
def test_gtk_adapter_uses_effective_dark(self):
|
||||
self.value.update(effectiveFlavor='latte', effectiveDark=False)
|
||||
with mock.patch.object(sync, 'run') as run:
|
||||
result = sync.gtk_adapter(self.value, sync.read_palette())
|
||||
self.assertEqual(result['colorScheme'], 'prefer-light')
|
||||
self.assertEqual(result['theme'], 'Adwaita')
|
||||
self.assertEqual(run.call_count, 2)
|
||||
|
||||
def test_qt_adapter_writes_both_versions(self):
|
||||
for version in ('qt5ct', 'qt6ct'):
|
||||
config = self.root / version / (version + '.conf')
|
||||
config.parent.mkdir(parents=True)
|
||||
config.write_text('[Appearance]\ncolor_scheme_path=/old\nstyle=Fusion\n')
|
||||
with mock.patch.object(sync, 'CONFIG', self.root):
|
||||
sync.qt_adapter(self.value, sync.read_palette())
|
||||
for version in ('qt5ct', 'qt6ct'):
|
||||
self.assertTrue((self.root / version / 'colors/labfy-appearance.conf').is_file())
|
||||
content = (self.root / version / (version + '.conf')).read_text()
|
||||
self.assertIn('style=Fusion', content)
|
||||
self.assertIn('labfy-appearance.conf', content)
|
||||
|
||||
def test_kitty_adapter_signals_only_verified_process(self):
|
||||
kitty = self.root / 'kitty'
|
||||
kitty.mkdir()
|
||||
(kitty / 'kitty.conf').write_text('include generated-appearance.conf\n')
|
||||
with mock.patch.object(sync, 'CONFIG', self.root), \
|
||||
mock.patch.object(sync.os, 'scandir', return_value=[]):
|
||||
result = sync.kitty_adapter(self.value, sync.read_palette())
|
||||
self.assertEqual(result['instances'], 0)
|
||||
self.assertIn('foreground', (kitty / 'generated-appearance.conf').read_text())
|
||||
|
||||
def test_nvim_contract_reports_revision(self):
|
||||
self.assertEqual(sync.nvim_adapter(self.value, {})['revision'], 4)
|
||||
|
||||
def test_firefox_profile_is_read_only_and_reports_migration(self):
|
||||
firefox = self.root / '.mozilla/firefox'
|
||||
firefox.mkdir(parents=True)
|
||||
(firefox / 'profiles.ini').write_text('[InstallABCD]\nDefault=profile\n')
|
||||
profile = firefox / 'profile'
|
||||
profile.mkdir()
|
||||
prefs = profile / 'prefs.js'
|
||||
prefs.write_text('user_pref("extensions.activeThemeID", "firefox-compact-dark@mozilla.org");\n'
|
||||
'user_pref("layout.css.prefers-color-scheme.content-override", 1);\n')
|
||||
before = prefs.read_bytes()
|
||||
with mock.patch.object(sync.Path, 'home', return_value=self.root):
|
||||
result = sync.firefox_adapter(self.value, {})
|
||||
self.assertEqual(result['status'], 'unsupported')
|
||||
self.assertEqual(result['contentOverride'], 1)
|
||||
self.assertEqual(prefs.read_bytes(), before)
|
||||
|
||||
def test_firefox_proton_dual_palette_follows_system_without_profile_write(self):
|
||||
firefox = self.root / '.mozilla/firefox'
|
||||
firefox.mkdir(parents=True)
|
||||
(firefox / 'profiles.ini').write_text('[InstallABCD]\nDefault=profile\n')
|
||||
profile = firefox / 'profile'
|
||||
profile.mkdir()
|
||||
prefs = profile / 'prefs.js'
|
||||
prefs.write_text('user_pref("extensions.activeThemeID", "proton-theme@mozilla.org");\n')
|
||||
before = prefs.read_bytes()
|
||||
with mock.patch.object(sync.Path, 'home', return_value=self.root):
|
||||
result = sync.firefox_adapter(self.value, {})
|
||||
self.assertEqual(result['status'], 'ok')
|
||||
self.assertEqual(result['integration'], 'system')
|
||||
self.assertEqual(result['lightDark'], 'supported')
|
||||
self.assertIs(result['profileWrite'], False)
|
||||
self.assertEqual(prefs.read_bytes(), before)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
3
uwsm/.config/uwsm/env
Normal file
3
uwsm/.config/uwsm/env
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# CONTRACT: UWSM transmet ces variables au compositeur et aux applications
|
||||
# lancées dans la session au prochain login.
|
||||
export QT_QPA_PLATFORMTHEME=qt5ct
|
||||
Loading…
Reference in a new issue