feat(appearance): synchronize desktop application themes

This commit is contained in:
fy59 2026-10-05 06:53:41 +02:00
parent 172fa98f1d
commit 69aff76fc4
15 changed files with 695 additions and 6 deletions

5
.gitignore vendored
View file

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

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

View file

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

View file

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

View 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

View file

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

View file

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

View file

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

View 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

View file

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

View file

@ -0,0 +1 @@
../labfy-appearance-app-sync.path

View file

@ -0,0 +1 @@
../labfy-appearance-app-sync.service

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