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
|
||||||
sway/.config/sway/generated/wallpaper.conf.tmp
|
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
|
# Local editor/application backups
|
||||||
*.bak
|
*.bak
|
||||||
*.old
|
*.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
|
cursor_trail 0
|
||||||
shell_integration no-cursor
|
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
|
map alt+c no_cp
|
||||||
|
|
|
||||||
|
|
@ -115,4 +115,5 @@ end, {
|
||||||
desc = "Docker terminal",
|
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" },
|
dependencies = { "nvim-tree/nvim-web-devicons" },
|
||||||
opts = {
|
opts = {
|
||||||
options = {
|
options = {
|
||||||
theme = "catppuccin-mocha",
|
theme = "auto",
|
||||||
icons_enabled = true,
|
icons_enabled = true,
|
||||||
section_separators = "",
|
section_separators = "",
|
||||||
component_separators = "",
|
component_separators = "",
|
||||||
|
|
@ -20,4 +20,3 @@ return {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[Appearance]
|
[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
|
custom_palette=true
|
||||||
standard_dialogs=default
|
standard_dialogs=default
|
||||||
style=Fusion
|
style=Fusion
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[Appearance]
|
[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
|
custom_palette=true
|
||||||
standard_dialogs=default
|
standard_dialogs=default
|
||||||
style=Fusion
|
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