GLF Update Manager - GLF-OS
GUI pour gérer les mises à jour GLF-OS :
- activer / couper l'auto-màj (timer boot + 12 h)
- planifier un jour / heure (sinon màj manuelle)
- choisir la puissance CPU pendant
glf-update(standard / éco) - lancer une màj depuis le menu
Fichiers (dans customConfig/) :
glf-update-manager.nix(icône + génère prefs au 1er boot)glf-update-manager.py
glf-update-manager-prefs.nix est créé tout seul au 1er rebuild.
1. Ouvrir le terminal
2. Créer glf-update-manager.nix
sudo nano /etc/nixos/customConfig/glf-update-manager.nix
Code à recopier (Vous avez un bouton à droite du code pour copier le bloc en entier.)
{ lib, config, pkgs, ... }:
let
# --- Config ---
cfg = config.glf.updateManager;
stateDir = "/var/lib/glf-update-manager";
liveModuleDir =
let m = builtins.match ".*/(customConfig.*)" (toString ./.);
in if m != null then "/etc/nixos/${builtins.head m}" else "/etc/nixos/customConfig";
prefsFile = "${liveModuleDir}/glf-update-manager-prefs.nix";
managerPy = ./glf-update-manager.py;
glfUpdateBin = "/run/current-system/sw/bin/glf-update";
weekdayOk = lib.elem cfg.schedule.weekday [
"Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun"
];
calendar =
let
hh = lib.fixedWidthString 2 "0" (toString cfg.schedule.hour);
mm = lib.fixedWidthString 2 "0" (toString cfg.schedule.minute);
in
"${cfg.schedule.weekday} *-*-* ${hh}:${mm}:00";
# --- Power wrapper ---
glfUpdatePowered = pkgs.writeShellApplication {
name = "glf-update-powered";
runtimeInputs = with pkgs; [ coreutils util-linux systemd ];
text = ''
set -euo pipefail
STATE="${stateDir}/power-mode"
MODE="standard"
GLF_UPDATE="${glfUpdateBin}"
if [ -r "$STATE" ]; then
MODE=$(tr -d '[:space:]' < "$STATE" || true)
fi
case "$MODE" in
standard|eco) ;;
*) MODE="standard" ;;
esac
if [ ! -x "$GLF_UPDATE" ]; then
echo "[ERROR] glf-update introuvable : $GLF_UPDATE" >&2
exit 127
fi
echo "[INFO] GLF Update Manager — mode: $MODE" >&2
case "$MODE" in
eco)
CORES=$(nproc 2>/dev/null || echo 1)
HALF=$(( CORES / 2 ))
if [ "$HALF" -lt 1 ]; then HALF=1; fi
QUOTA=$(( HALF * 100 ))
echo "[INFO] Éco — $HALF/$CORES cœurs (CPUQuota=$QUOTA%)" >&2
if command -v systemd-run >/dev/null 2>&1; then
exec systemd-run --quiet --collect --scope \
-p "CPUQuota=$QUOTA%" \
-p CPUWeight=20 \
nice -n 15 ionice -c 3 "$GLF_UPDATE" "$@"
fi
exec nice -n 15 ionice -c 3 "$GLF_UPDATE" "$@"
;;
*)
exec "$GLF_UPDATE" "$@"
;;
esac
'';
};
# --- GUI ---
pythonEnv = pkgs.python3.withPackages (ps: [ ps.pygobject3 ]);
kdialogPkg =
if pkgs.kdePackages ? kdialog then pkgs.kdePackages.kdialog
else if pkgs ? kdialog then pkgs.kdialog
else null;
# --- Icon ---
iconSvg = pkgs.writeText "glf-update-manager.svg" ''
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#1a7ab8"/>
<stop offset="100%" stop-color="#0a3558"/>
</linearGradient>
<linearGradient id="arr" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="#7ec0ef"/>
<stop offset="100%" stop-color="#f2f9ff"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="56" fill="url(#bg)"/>
<path fill="none" stroke="#eef7ff" stroke-width="16" stroke-linecap="round"
d="M196 128a68 68 0 1 1-20-48"/>
<path fill="url(#arr)" d="M176 58l34 34h-20v40h-28V92h-20z"/>
<g transform="translate(128 128) scale(0.48)">
<polygon fill="#eef7ff" points="0,-88 22,-44 0,-18 -22,-44"/>
<polygon fill="#eef7ff" points="0,88 -22,44 0,18 22,44"/>
<polygon fill="#eef7ff" points="88,0 44,-22 18,0 44,22"/>
<polygon fill="#eef7ff" points="-88,0 -44,22 -18,0 -44,-22"/>
<polygon fill="#8ec8ef" points="62,-62 52,-26 18,-18 26,-52"/>
<polygon fill="#8ec8ef" points="62,62 26,52 18,18 52,26"/>
<polygon fill="#8ec8ef" points="-62,62 -52,26 -18,18 -26,52"/>
<polygon fill="#8ec8ef" points="-62,-62 -26,-52 -18,-18 -52,-26"/>
</g>
</svg>
'';
iconName = "glf-update-manager";
glfUpdateManager = pkgs.stdenvNoCC.mkDerivation {
pname = iconName;
version = "1.0";
dontUnpack = true;
nativeBuildInputs = with pkgs; [
wrapGAppsHook3
gobject-introspection
makeWrapper
librsvg
gtk3
];
buildInputs = with pkgs; [
gtk3
glib
pango
gdk-pixbuf
atk
adw-gtk3
pythonEnv
];
installPhase = ''
runHook preInstall
mkdir -p $out/bin
mkdir -p $out/share/applications
mkdir -p $out/share/glf-update-manager
cp ${managerPy} $out/share/glf-update-manager/glf-update-manager.py
mkdir -p \
$out/share/icons/hicolor/scalable/apps \
$out/share/icons/hicolor/48x48/apps \
$out/share/icons/hicolor/128x128/apps \
$out/share/icons/hicolor/256x256/apps \
$out/share/icons/Tela-circle/48x48/apps \
$out/share/icons/Tela-circle/256x256/apps \
$out/share/icons/Tela-circle-dark/48x48/apps \
$out/share/icons/Tela-circle-dark/256x256/apps \
$out/share/icons/Tela-circle-light/48x48/apps \
$out/share/icons/Tela-circle-light/256x256/apps
cp ${iconSvg} $out/share/icons/hicolor/scalable/apps/${iconName}.svg
${pkgs.librsvg}/bin/rsvg-convert -w 48 -h 48 \
-o $out/share/icons/hicolor/48x48/apps/${iconName}.png ${iconSvg}
${pkgs.librsvg}/bin/rsvg-convert -w 128 -h 128 \
-o $out/share/icons/hicolor/128x128/apps/${iconName}.png ${iconSvg}
${pkgs.librsvg}/bin/rsvg-convert -w 256 -h 256 \
-o $out/share/icons/hicolor/256x256/apps/${iconName}.png ${iconSvg}
for theme in Tela-circle Tela-circle-dark Tela-circle-light; do
cp $out/share/icons/hicolor/48x48/apps/${iconName}.png \
$out/share/icons/$theme/48x48/apps/${iconName}.png
cp $out/share/icons/hicolor/256x256/apps/${iconName}.png \
$out/share/icons/$theme/256x256/apps/${iconName}.png
done
iconPath="$out/share/icons/hicolor/256x256/apps/${iconName}.png"
makeWrapper ${pythonEnv}/bin/python3 $out/bin/${iconName} \
--add-flags "$out/share/glf-update-manager/glf-update-manager.py" \
--set GLF_UM_STATE_DIR ${lib.escapeShellArg stateDir} \
--set GLF_UM_PREFS_FILE ${lib.escapeShellArg prefsFile} \
--set GLF_UM_ICON "$iconPath" \
--prefix PATH : ${glfUpdatePowered}/bin \
${lib.optionalString (kdialogPkg != null) "--prefix PATH : ${kdialogPkg}/bin \\"}
--prefix PATH : ${pkgs.zenity}/bin \
--prefix PATH : /run/wrappers/bin \
--prefix PATH : /run/current-system/sw/bin
cat > $out/share/applications/${iconName}.desktop <<EOF
[Desktop Entry]
Type=Application
Name=GLF Update Manager
Name[fr]=Mises à jour GLF-OS
Comment=Planifier et lancer les mises à jour GLF-OS
Comment[fr]=Activer/désactiver l'auto-màj, planifier et choisir la puissance
Exec=${iconName}
Icon=$iconPath
Terminal=false
Categories=System;Settings;
Keywords=update;glf;nixos;upgrade;
StartupWMClass=${iconName}
EOF
${pkgs.gtk3}/bin/gtk-update-icon-cache -f -t $out/share/icons/hicolor 2>/dev/null || true
runHook postInstall
'';
meta.mainProgram = iconName;
};
# --- Effective auto ---
effectiveAutoUpgrade = if cfg.schedule.enable then false else cfg.autoUpgrade;
prefsDefault = ''
{
glf.updateManager = {
autoUpgrade = true;
schedule = {
enable = false;
weekday = "Sat";
hour = 16;
minute = 0;
};
powerMode = "standard";
};
}
'';
in
{
imports = lib.optional (builtins.pathExists ./glf-update-manager-prefs.nix)
./glf-update-manager-prefs.nix;
# --- Options ---
options.glf.updateManager = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Activer GLF Update Manager";
};
autoUpgrade = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Mises à jour auto (timer GLF : boot + 12 h)";
};
schedule = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Planification hebdomadaire";
};
weekday = lib.mkOption {
type = lib.types.str;
default = "Sat";
description = "Mon Tue Wed Thu Fri Sat Sun";
};
hour = lib.mkOption {
type = lib.types.ints.between 0 23;
default = 16;
description = "Heure (0–23)";
};
minute = lib.mkOption {
type = lib.types.ints.between 0 59;
default = 0;
description = "Minute (0–59)";
};
};
powerMode = lib.mkOption {
type = lib.types.str;
default = "standard";
apply = v: if v == "eco" then "eco" else "standard";
description = "Puissance pendant glf-update (standard ou eco)";
};
};
# --- Module ---
config = lib.mkIf cfg.enable (
lib.mkMerge [
{
assertions = [
{
assertion = weekdayOk;
message = "glf.updateManager.schedule.weekday doit être Mon|Tue|Wed|Thu|Fri|Sat|Sun";
}
];
glf.autoUpgrade = lib.mkForce effectiveAutoUpgrade;
systemd.timers.glfos-update.wantedBy = lib.mkForce (
lib.optionals effectiveAutoUpgrade [ "timers.target" ]
);
system.activationScripts.glfUpdateManagerPrefs = lib.stringAfter [ "etc" ] ''
if [ ! -f ${prefsFile} ]; then
mkdir -p "$(dirname ${prefsFile})"
cat > ${prefsFile} <<'EOF'
${prefsDefault}EOF
chmod 664 ${prefsFile} 2>/dev/null || true
fi
'';
system.activationScripts.glfUpdateManagerTimers = lib.stringAfter [ "etc" ] (
if effectiveAutoUpgrade then
''
${pkgs.systemd}/bin/systemctl start glfos-update.timer 2>/dev/null || true
''
else
''
${pkgs.systemd}/bin/systemctl stop glfos-update.timer 2>/dev/null || true
${pkgs.systemd}/bin/systemctl reset-failed glfos-update.timer 2>/dev/null || true
''
);
systemd.tmpfiles.rules = [
"d ${stateDir} 0775 root wheel -"
"Z ${stateDir} 0775 root wheel -"
"f ${stateDir}/power-mode 0664 root wheel - ${cfg.powerMode}"
"z ${stateDir}/power-mode 0664 root wheel -"
];
systemd.services.glfos-update.serviceConfig.ExecStart =
lib.mkForce "${glfUpdatePowered}/bin/glf-update-powered --notify";
environment.systemPackages =
[
glfUpdateManager
glfUpdatePowered
pkgs.zenity
]
++ lib.optionals (kdialogPkg != null) [ kdialogPkg ];
}
(lib.mkIf cfg.schedule.enable {
systemd.timers."glfos-update-calendar" = {
description = "GLF-OS scheduled update (${calendar})";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = calendar;
Persistent = true;
Unit = "glfos-update.service";
};
};
})
]
);
}
Coller le contenu.
Ctrl + X -> O -> Entrée
3. Créer glf-update-manager.py
sudo nano /etc/nixos/customConfig/glf-update-manager.py
Code à recopier (Vous avez un bouton à droite du code pour copier le bloc en entier.)
#!/usr/bin/env python3
# --- GLF Update Manager ---
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
import tempfile
import threading
from pathlib import Path
# --- Paths ---
DEMO = os.environ.get("GLF_UM_DEMO", "").lower() in ("1", "true", "yes")
_default_state = "/tmp/glf-update-manager-demo" if DEMO else "/var/lib/glf-update-manager"
STATE_DIR = Path(os.environ.get("GLF_UM_STATE_DIR", _default_state))
POWER_FILE = STATE_DIR / "power-mode"
PREFS_FILE = Path(
os.environ.get(
"GLF_UM_PREFS_FILE",
str(Path(__file__).resolve().parent / "glf-update-manager-prefs.nix"),
)
)
# --- Constants ---
WEEKDAYS = [
("Mon", "Lundi"),
("Tue", "Mardi"),
("Wed", "Mercredi"),
("Thu", "Jeudi"),
("Fri", "Vendredi"),
("Sat", "Samedi"),
("Sun", "Dimanche"),
]
POWER_MODES = [
("standard", "Standard — tous les cœurs"),
("eco", "Éco — moitié des cœurs (moins chauffant)"),
]
DEFAULTS = {
"autoUpgrade": True,
"scheduleEnable": False,
"weekday": "Sat",
"hour": 16,
"minute": 0,
"powerMode": "standard",
}
# --- Icon ---
def _resolve_icon_path() -> str:
env = os.environ.get("GLF_UM_ICON", "").strip()
if env and Path(env).is_file() and env.lower().endswith(".png"):
return env
for size in (256, 128, 48):
for base in (
f"/run/current-system/sw/share/icons/hicolor/{size}x{size}/apps/glf-update-manager.png",
f"/usr/share/icons/hicolor/{size}x{size}/apps/glf-update-manager.png",
):
if Path(base).is_file():
return base
here = Path(__file__).resolve().parent
png = here / "glf-update-manager.png"
if png.is_file():
return str(png)
return ""
def _set_window_icon(window, Gtk) -> None:
icon = _resolve_icon_path()
if not icon:
return
try:
window.set_icon_from_file(icon)
except Exception:
pass
try:
Gtk.Window.set_default_icon_from_file(icon)
except Exception:
pass
# --- Power mode ---
def read_power_mode() -> str:
try:
mode = POWER_FILE.read_text().strip()
if mode in ("standard", "eco"):
return mode
if mode == "performance":
return "standard"
except FileNotFoundError:
pass
return DEFAULTS["powerMode"]
def _pkexec_write(path: Path, data: bytes) -> None:
pkexec = "/run/wrappers/bin/pkexec"
if not Path(pkexec).is_file():
pkexec = shutil.which("pkexec") or "pkexec"
proc = subprocess.run(
[
pkexec,
"bash",
"-c",
f'mkdir -p "{path.parent}" && cat > "{path}"',
],
input=data,
capture_output=True,
timeout=120,
)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or b"").decode(errors="replace").strip()
raise RuntimeError(err or "pkexec a refusé l'écriture.")
def write_power_mode(mode: str) -> None:
if mode not in ("standard", "eco"):
mode = "standard"
payload = mode + "\n"
if DEMO:
STATE_DIR.mkdir(parents=True, exist_ok=True)
POWER_FILE.write_text(payload)
return
try:
POWER_FILE.write_text(payload)
return
except PermissionError:
_pkexec_write(POWER_FILE, payload.encode())
# --- Prefs ---
def parse_prefs_nix(text: str) -> dict:
cfg = dict(DEFAULTS)
def _bool(key: str, pattern: str) -> None:
m = re.search(pattern, text, re.M)
if m:
cfg[key] = m.group(1) == "true"
_bool("autoUpgrade", r"autoUpgrade\s*=\s*(true|false)\s*;")
_bool("scheduleEnable", r"schedule\s*=\s*\{[^}]*?enable\s*=\s*(true|false)\s*;")
m = re.search(r'weekday\s*=\s*"([A-Za-z]+)"\s*;', text)
if m and m.group(1) in dict(WEEKDAYS):
cfg["weekday"] = m.group(1)
m = re.search(r"hour\s*=\s*(\d+)\s*;", text)
if m:
cfg["hour"] = max(0, min(23, int(m.group(1))))
m = re.search(r"minute\s*=\s*(\d+)\s*;", text)
if m:
cfg["minute"] = max(0, min(59, int(m.group(1))))
m = re.search(r'powerMode\s*=\s*"(performance|standard|eco)"\s*;', text)
if m:
cfg["powerMode"] = "standard" if m.group(1) == "performance" else m.group(1)
return cfg
def load_settings() -> dict:
cfg = dict(DEFAULTS)
if PREFS_FILE.is_file():
try:
cfg.update(parse_prefs_nix(PREFS_FILE.read_text(encoding="utf-8")))
except OSError:
pass
cfg["powerMode"] = read_power_mode() if POWER_FILE.exists() else cfg["powerMode"]
return cfg
def render_prefs_nix(cfg: dict) -> str:
auto = "true" if cfg["autoUpgrade"] else "false"
sched = "true" if cfg["scheduleEnable"] else "false"
return f"""{{
glf.updateManager = {{
autoUpgrade = {auto};
schedule = {{
enable = {sched};
weekday = "{cfg["weekday"]}";
hour = {int(cfg["hour"])};
minute = {int(cfg["minute"])};
}};
powerMode = "{cfg["powerMode"]}";
}};
}}
"""
def write_prefs_nix(cfg: dict) -> None:
content = render_prefs_nix(cfg)
if DEMO:
PREFS_FILE.parent.mkdir(parents=True, exist_ok=True)
PREFS_FILE.write_text(content, encoding="utf-8")
return
try:
PREFS_FILE.write_text(content, encoding="utf-8")
except PermissionError:
_pkexec_write(PREFS_FILE, content.encode())
# --- Env ---
def _make_env() -> dict:
env = os.environ.copy()
extra = "/run/wrappers/bin:/run/current-system/sw/bin:/nix/var/nix/profiles/default/bin"
env["PATH"] = extra + ":" + env.get("PATH", "")
return env
def _make_askpass_script() -> str | None:
desktop = (
os.environ.get("XDG_CURRENT_DESKTOP", "")
+ os.environ.get("DESKTOP_SESSION", "")
).lower()
prefer_kde = "kde" in desktop or "plasma" in desktop
tools = ("kdialog", "zenity") if prefer_kde else ("zenity", "kdialog")
candidates: list[tuple[str, str]] = []
for tool in tools:
found = shutil.which(tool)
if found:
candidates.append((tool, found))
for base in ("/run/wrappers/bin", "/run/current-system/sw/bin"):
p = f"{base}/{tool}"
if Path(p).is_file():
candidates.append((tool, p))
seen: set[str] = set()
for tool, found in candidates:
if found in seen:
continue
seen.add(found)
try:
if tool == "zenity":
cmd_line = f'{found} --password --title="GLF OS" --text="Mot de passe :"'
else:
cmd_line = f'{found} --password "Mot de passe :"'
fd, tmp = tempfile.mkstemp(prefix="glf-um-askpass-", suffix=".sh")
os.close(fd)
path = Path(tmp)
path.write_text(f"#!/bin/sh\nexec {cmd_line}\n", encoding="utf-8")
path.chmod(0o755)
return str(path)
except OSError:
continue
return None
_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
def _strip_ansi(text: str) -> str:
return _ANSI_RE.sub("", text)
def _short_msg(ok: bool, detail: str, *, ok_text: str, fail_text: str) -> str:
detail = (detail or "").strip()
if ok:
return ok_text
if not detail:
return fail_text
first = detail.splitlines()[0].strip()
if len(first) > 120:
first = first[:117] + "…"
return f"{fail_text} — {first}" if first else fail_text
def show_log_dialog(parent, title: str, text: str) -> None:
from gi.repository import Gtk
dlg = Gtk.Dialog(title=title, transient_for=parent, modal=True)
dlg.set_default_size(560, 420)
dlg.set_resizable(True)
dlg.add_button("Fermer", Gtk.ResponseType.CLOSE)
content = dlg.get_content_area()
content.set_border_width(10)
content.set_spacing(8)
hint = Gtk.Label(label="Défilez pour voir tout le log.")
hint.set_xalign(0)
content.pack_start(hint, False, False, 0)
scrolled = Gtk.ScrolledWindow()
scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scrolled.set_shadow_type(Gtk.ShadowType.IN)
scrolled.set_size_request(520, 320)
scrolled.set_hexpand(True)
scrolled.set_vexpand(True)
tv = Gtk.TextView()
tv.set_editable(False)
tv.set_cursor_visible(False)
tv.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
tv.set_monospace(True)
tv.get_buffer().set_text(text or "(vide)")
scrolled.add(tv)
content.pack_start(scrolled, True, True, 0)
dlg.show_all()
dlg.run()
dlg.destroy()
# --- Apply config ---
def apply_system_config() -> tuple[bool, str]:
if DEMO:
return True, "Mode démo — aucun rebuild système."
nh = shutil.which("nh") or "/run/current-system/sw/bin/nh"
if not Path(nh).is_file():
return False, "nh introuvable."
cmd = [
nh,
"os",
"switch",
"/etc/nixos",
"-H",
"GLF-OS",
"--",
"--accept-flake-config",
]
env = _make_env()
askpass = _make_askpass_script()
if not askpass:
return False, (
"Mot de passe requis pour nh os switch. "
"Installez kdialog (KDE) ou zenity, ou lancez : glf-update"
)
env["SUDO_ASKPASS"] = askpass
env["NH_ASKPASS"] = askpass
env["SUDO_ASKPASS_REQUIRE"] = "force"
try:
proc = subprocess.run(
cmd, capture_output=True, text=True, timeout=7200, env=env
)
if proc.returncode == 0:
return True, "Configuration appliquée."
err = _strip_ansi((proc.stderr or proc.stdout or "").strip())
return False, err or f"Échec nh (code {proc.returncode})."
except FileNotFoundError:
return False, "nh introuvable."
except subprocess.TimeoutExpired:
return False, "Délai dépassé pendant le rebuild."
except Exception as exc:
return False, str(exc)
finally:
if askpass:
try:
Path(askpass).unlink(missing_ok=True)
except OSError:
pass
# --- Manual update ---
def _resolve_update_bin() -> str:
for name in ("glf-update-powered", "glf-update"):
path = shutil.which(name)
if path:
return path
cand = f"/run/current-system/sw/bin/{name}"
if Path(cand).is_file():
return cand
return ""
def run_manual_update() -> tuple[bool, str]:
if DEMO:
return True, "Mode démo — glf-update non lancé."
binary = _resolve_update_bin()
if not binary:
return False, "glf-update-powered / glf-update introuvable."
env = _make_env()
askpass = _make_askpass_script()
if askpass:
env["SUDO_ASKPASS"] = askpass
env["NH_ASKPASS"] = askpass
env["SUDO_ASKPASS_REQUIRE"] = "force"
sudo = "/run/wrappers/bin/sudo"
if Path(sudo).is_file():
cmd = [sudo, "-A", binary]
else:
cmd = [binary]
try:
proc = subprocess.run(
cmd, capture_output=True, text=True, timeout=14400, env=env
)
out = _strip_ansi(((proc.stdout or "") + "\n" + (proc.stderr or "")).strip())
if proc.returncode == 0 or proc.returncode == 75:
return True, out or "Mise à jour terminée."
return False, out or f"Échec (code {proc.returncode})."
except FileNotFoundError:
return False, "sudo / glf-update introuvable."
except subprocess.TimeoutExpired:
return False, "Délai dépassé pendant la mise à jour."
except Exception as exc:
return False, str(exc)
finally:
if askpass:
try:
Path(askpass).unlink(missing_ok=True)
except OSError:
pass
# --- System status ---
def read_system_status() -> str:
cfg = load_settings()
auto_pref = "oui" if cfg["autoUpgrade"] else "non"
sched_pref = "oui" if cfg["scheduleEnable"] else "non"
def _active(unit: str) -> str:
try:
r = subprocess.run(
["systemctl", "is-active", unit],
capture_output=True,
text=True,
timeout=3,
)
state = (r.stdout or "").strip() or "inconnu"
return state
except Exception:
return "inconnu"
stock = _active("glfos-update.timer")
calendar = _active("glfos-update-calendar.timer")
lines = [
f"Prefs : auto={auto_pref} · planning={sched_pref} · puissance={cfg['powerMode']}",
f"Timer auto (boot/12h) : {stock}",
f"Timer planning : {calendar}",
]
if not cfg["autoUpgrade"] and stock == "active":
lines.append("⚠ Auto désactivée dans prefs mais le timer tourne encore → Appliquer la config")
return "\n".join(lines)
# --- Theme ---
def _desktop_is_kde() -> bool:
blob = (
os.environ.get("XDG_CURRENT_DESKTOP", "")
+ " "
+ os.environ.get("DESKTOP_SESSION", "")
+ " "
+ os.environ.get("XDG_SESSION_DESKTOP", "")
).lower()
return "kde" in blob or "plasma" in blob
def _portal_color_scheme() -> bool | None:
methods = (
(
"org.freedesktop.portal.Settings.Read",
("org.freedesktop.appearance", "color-scheme"),
),
(
"org.freedesktop.portal.Settings.ReadOne",
("org.freedesktop.appearance", "color-scheme"),
),
)
for obj_path in (
"/org/freedesktop/portal/desktop",
"/org/freedesktop/portal/Desktop",
):
for method, args in methods:
try:
out = subprocess.check_output(
[
"gdbus",
"call",
"--session",
"--dest",
"org.freedesktop.portal.Desktop",
"--object-path",
obj_path,
"--method",
method,
*args,
],
text=True,
stderr=subprocess.DEVNULL,
timeout=2,
)
nums = re.findall(r"\bu?int32\s+(\d+)\b|\b(\d+)\b", out)
vals = [int(a or b) for a, b in nums]
if not vals:
continue
code = vals[-1]
if code == 1:
return True
if code == 2:
return False
except Exception:
continue
return None
def _gtk3_settings_prefer_dark() -> bool | None:
for base in (
Path.home() / ".config" / "gtk-3.0" / "settings.ini",
Path.home() / ".config" / "gtk-4.0" / "settings.ini",
):
try:
if not base.is_file():
continue
text = base.read_text(encoding="utf-8", errors="ignore")
m = re.search(
r"(?im)^\s*gtk-application-prefer-dark-theme\s*=\s*([01]|true|false)\s*$",
text,
)
if not m:
continue
v = m.group(1).lower()
if v in ("1", "true"):
return True
if v in ("0", "false"):
return False
except OSError:
continue
return None
def _parse_rgb(value: str) -> tuple[int, int, int] | None:
v = value.strip().strip('"').strip("'")
if v.startswith("#") and len(v) >= 7:
try:
return int(v[1:3], 16), int(v[3:5], 16), int(v[5:7], 16)
except ValueError:
return None
parts = re.split(r"[,\s]+", v)
if len(parts) >= 3:
try:
return int(float(parts[0])), int(float(parts[1])), int(float(parts[2]))
except ValueError:
return None
return None
def _luma(rgb: tuple[int, int, int]) -> float:
r, g, b = rgb
return 0.299 * r + 0.587 * g + 0.114 * b
def _kde_luminance_dark() -> bool | None:
for cfg in (
Path.home() / ".config" / "kdeglobals",
Path.home() / ".config" / "kdedefaults" / "kdeglobals",
):
try:
if not cfg.is_file():
continue
text = cfg.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
for section in ("Colors:View", "Colors:Window"):
m = re.search(
rf"(?ims)^\s*\[{re.escape(section)}\]\s*$(.*?)(?=^\s*\[|\Z)",
text,
)
if not m:
continue
body = m.group(1)
bm = re.search(r"(?im)^\s*BackgroundNormal\s*=\s*(.+?)\s*$", body)
if not bm:
continue
rgb = _parse_rgb(bm.group(1))
if rgb is None:
continue
return _luma(rgb) < 128.0
return None
def _name_looks_dark(name: str) -> bool | None:
n = name.strip().lower().replace(" ", "")
if not n:
return None
if any(x in n for x in ("dark", "night", "black", "dim", "ombra", "noir", "twilight")):
return True
if any(x in n for x in ("light", "clair", "bright", "day")):
return False
if n in ("breeze", "org.kde.breeze.desktop"):
return False
return None
def _kde_prefers_dark() -> bool | None:
lum = _kde_luminance_dark()
if lum is not None:
return lum
def _kread_bin(name: str) -> str | None:
found = shutil.which(name)
if found:
return found
for base in ("/run/current-system/sw/bin", "/run/wrappers/bin"):
p = f"{base}/{name}"
if Path(p).is_file():
return p
return None
for kread in ("kreadconfig6", "kreadconfig5"):
kread_path = _kread_bin(kread)
if not kread_path:
continue
for group, key in (
("General", "ColorScheme"),
("KDE", "LookAndFeelPackage"),
):
try:
val = subprocess.check_output(
[
kread_path,
"--file",
"kdeglobals",
"--group",
group,
"--key",
key,
],
text=True,
stderr=subprocess.DEVNULL,
timeout=2,
).strip()
hit = _name_looks_dark(val)
if hit is not None:
return hit
except Exception:
continue
for cfg in (
Path.home() / ".config" / "kdeglobals",
Path.home() / ".config" / "kdedefaults" / "kdeglobals",
):
try:
if not cfg.is_file():
continue
text = cfg.read_text(encoding="utf-8", errors="ignore")
for key in ("ColorScheme", "LookAndFeelPackage"):
m = re.search(rf"(?im)^\s*{key}\s*=\s*(.+?)\s*$", text)
if not m:
continue
hit = _name_looks_dark(m.group(1))
if hit is not None:
return hit
except OSError:
continue
return None
def _gnome_prefers_dark() -> bool | None:
try:
scheme = subprocess.check_output(
["gsettings", "get", "org.gnome.desktop.interface", "color-scheme"],
text=True,
stderr=subprocess.DEVNULL,
timeout=2,
).strip().strip("'\"").lower()
if "dark" in scheme:
return True
if "light" in scheme:
return False
except Exception:
pass
try:
theme = subprocess.check_output(
["gsettings", "get", "org.gnome.desktop.interface", "gtk-theme"],
text=True,
stderr=subprocess.DEVNULL,
timeout=2,
).lower()
hit = _name_looks_dark(theme.strip().strip("'\""))
if hit is not None:
return hit
except Exception:
pass
return None
def system_prefers_dark() -> bool:
forced = os.environ.get("GLF_UM_THEME", "").strip().lower()
if forced in ("dark", "sombre", "1", "true", "night"):
return True
if forced in ("light", "clair", "0", "false", "day"):
return False
if _desktop_is_kde():
kde = _kde_prefers_dark()
if kde is not None:
return kde
gtk_ini = _gtk3_settings_prefer_dark()
if gtk_ini is not None:
return gtk_ini
portal = _portal_color_scheme()
if portal is not None:
return portal
return False
portal = _portal_color_scheme()
if portal is not None:
return portal
gnome = _gnome_prefers_dark()
if gnome is not None:
return gnome
gtk_ini = _gtk3_settings_prefer_dark()
if gtk_ini is not None:
return gtk_ini
gtk_theme = os.environ.get("GTK_THEME", "").lower()
hit = _name_looks_dark(gtk_theme)
if hit is not None:
return hit
return False
def apply_theme(Gtk) -> bool:
settings = Gtk.Settings.get_default()
if settings is None:
return False
dark = system_prefers_dark()
settings.set_property("gtk-application-prefer-dark-theme", bool(dark))
try:
current = str(settings.get_property("gtk-theme-name") or "")
low = current.lower()
if dark:
if "breeze" in low:
target = "Breeze-Dark"
else:
target = "adw-gtk3-dark"
else:
if "breeze" in low:
target = "Breeze"
elif "adw-gtk3" in low or low in ("", "adwaita", "adwaita-dark"):
target = "adw-gtk3"
elif low.endswith("-dark"):
target = current[: -len("-dark")] or "adw-gtk3"
else:
target = current or "adw-gtk3"
settings.set_property("gtk-theme-name", "")
settings.set_property("gtk-theme-name", target)
except Exception:
pass
return dark
# --- GUI ---
def run_gui() -> None:
try:
import gi
except ModuleNotFoundError:
print(
"PyGObject (gi) manquant.\n"
"Lance : ./run-demo.sh\n"
"ou :\n"
" nix-shell -p python3 python3Packages.pygobject3 gtk3 gobject-introspection \\\n"
" --run 'GLF_UM_DEMO=1 python3 glf-update-manager.py'",
file=sys.stderr,
)
sys.exit(1)
gi.require_version("Gtk", "3.0")
from gi.repository import GLib, Gtk
GLib.set_prgname("glf-update-manager")
try:
from gi.repository import Gdk
Gdk.set_program_class("glf-update-manager")
except Exception:
pass
apply_theme(Gtk)
desktop = (
os.environ.get("XDG_CURRENT_DESKTOP", "")
+ os.environ.get("DESKTOP_SESSION", "")
).lower()
if "gnome" in desktop:
try:
from gi.repository import Gio
def _on_gnome_scheme_changed(_settings, _key):
apply_theme(Gtk)
_gs = Gio.Settings.new("org.gnome.desktop.interface")
_gs.connect("changed::color-scheme", _on_gnome_scheme_changed)
_gs.connect("changed::gtk-theme", _on_gnome_scheme_changed)
except Exception:
pass
class Win(Gtk.Window):
def __init__(self):
super().__init__(title="Mises à jour GLF-OS")
self.set_default_size(440, 560)
self.set_border_width(16)
self._last_dark = system_prefers_dark()
_set_window_icon(self, Gtk)
self.cfg = load_settings()
self._busy = False
root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=14)
self.add(root)
title = Gtk.Label()
title.set_markup("<span size='large'><b>Mises à jour GLF-OS</b></span>")
root.pack_start(title, False, False, 0)
if DEMO:
demo = Gtk.Label(
label="MODE DÉMO — rien n'est écrit sur le système réel"
)
demo.set_line_wrap(True)
root.pack_start(demo, False, False, 0)
# --- Auto au lancement ---
fr_auto = Gtk.Frame(label="Mise à jour automatique")
box_auto = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
box_auto.set_border_width(10)
fr_auto.add(box_auto)
root.pack_start(fr_auto, False, False, 0)
self.sw_auto = Gtk.Switch()
self.sw_auto.set_active(self.cfg["autoUpgrade"])
self.sw_auto.connect("notify::active", self._on_auto_toggled)
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
lbl = Gtk.Label(label="Auto au lancement (boot + toutes les 12 h)")
lbl.set_xalign(0)
lbl.set_line_wrap(True)
row.pack_start(lbl, True, True, 0)
row.pack_end(self.sw_auto, False, False, 0)
box_auto.pack_start(row, False, False, 0)
hint = Gtk.Label()
hint.set_markup(
"<span size='small'>Désactivez l'auto pour utiliser la planification.</span>"
)
hint.set_xalign(0)
hint.set_line_wrap(True)
box_auto.pack_start(hint, False, False, 0)
self.sys_status = Gtk.Label(label="")
self.sys_status.set_xalign(0)
self.sys_status.set_line_wrap(True)
self.sys_status.set_selectable(True)
box_auto.pack_start(self.sys_status, False, False, 0)
self._refresh_sys_status()
# --- Planification ---
self.fr_sched = Gtk.Frame(label="Planification (si auto désactivée)")
box_sched = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
box_sched.set_border_width(10)
self.fr_sched.add(box_sched)
root.pack_start(self.fr_sched, False, False, 0)
self.sw_sched = Gtk.Switch()
self.sw_sched.set_active(self.cfg["scheduleEnable"])
self.sw_sched.connect("notify::active", self._on_sched_toggled)
row2 = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
lbl2 = Gtk.Label(label="Mise à jour planifiée chaque semaine")
lbl2.set_xalign(0)
row2.pack_start(lbl2, True, True, 0)
row2.pack_end(self.sw_sched, False, False, 0)
box_sched.pack_start(row2, False, False, 0)
grid = Gtk.Grid()
grid.set_column_spacing(10)
grid.set_row_spacing(8)
box_sched.pack_start(grid, False, False, 0)
grid.attach(Gtk.Label(label="Jour", xalign=0), 0, 0, 1, 1)
self.combo_day = Gtk.ComboBoxText()
for key, label in WEEKDAYS:
self.combo_day.append(key, label)
self.combo_day.set_active_id(self.cfg["weekday"])
grid.attach(self.combo_day, 1, 0, 1, 1)
grid.attach(Gtk.Label(label="Heure", xalign=0), 0, 1, 1, 1)
time_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
self.spin_h = Gtk.SpinButton.new_with_range(0, 23, 1)
self.spin_h.set_value(self.cfg["hour"])
self.spin_m = Gtk.SpinButton.new_with_range(0, 59, 1)
self.spin_m.set_value(self.cfg["minute"])
time_box.pack_start(self.spin_h, False, False, 0)
time_box.pack_start(Gtk.Label(label=":"), False, False, 0)
time_box.pack_start(self.spin_m, False, False, 0)
grid.attach(time_box, 1, 1, 1, 1)
# --- Puissance ---
fr_pow = Gtk.Frame(label="Puissance pendant la mise à jour")
box_pow = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
box_pow.set_border_width(10)
fr_pow.add(box_pow)
root.pack_start(fr_pow, False, False, 0)
self.pow_group = None
self.pow_buttons = {}
for key, label in POWER_MODES:
rb = Gtk.RadioButton.new_with_label_from_widget(self.pow_group, label)
if self.pow_group is None:
self.pow_group = rb
rb.set_active(self.cfg["powerMode"] == key)
rb.connect("toggled", self._on_power_toggled, key)
self.pow_buttons[key] = rb
box_pow.pack_start(rb, False, False, 0)
# --- Actions ---
self.status = Gtk.Label(label="")
self.status.set_xalign(0)
self.status.set_line_wrap(True)
self.status.set_max_width_chars(56)
root.pack_start(self.status, False, False, 0)
btn_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
root.pack_start(btn_row, False, False, 0)
self.btn_apply = Gtk.Button(label="Appliquer la config")
self.btn_apply.connect("clicked", self._on_apply)
btn_row.pack_start(self.btn_apply, True, True, 0)
self.btn_update = Gtk.Button(label="Mettre à jour maintenant")
self.btn_update.get_style_context().add_class("suggested-action")
self.btn_update.connect("clicked", self._on_update)
btn_row.pack_start(self.btn_update, True, True, 0)
self._sync_sensitivity()
GLib.timeout_add_seconds(2, self._poll_theme)
def _poll_theme(self) -> bool:
dark = system_prefers_dark()
if dark != self._last_dark:
self._last_dark = dark
apply_theme(Gtk)
try:
self.queue_draw()
except Exception:
pass
return True
def _refresh_sys_status(self) -> None:
try:
text = read_system_status()
except Exception as exc:
text = f"État système illisible : {exc}"
self.sys_status.set_text(text)
def _collect(self) -> dict:
auto = self.sw_auto.get_active()
sched = self.sw_sched.get_active() and not auto
return {
"autoUpgrade": auto and not sched,
"scheduleEnable": sched,
"weekday": self.combo_day.get_active_id() or "Sat",
"hour": int(self.spin_h.get_value()),
"minute": int(self.spin_m.get_value()),
"powerMode": next(
(k for k, b in self.pow_buttons.items() if b.get_active()),
"standard",
),
}
def _sync_sensitivity(self) -> None:
auto = self.sw_auto.get_active()
self.fr_sched.set_sensitive(not auto)
if auto and self.sw_sched.get_active():
self.sw_sched.set_active(False)
sched_on = self.sw_sched.get_active() and not auto
self.combo_day.set_sensitive(sched_on)
self.spin_h.set_sensitive(sched_on)
self.spin_m.set_sensitive(sched_on)
def _on_auto_toggled(self, *_args) -> None:
self._sync_sensitivity()
def _on_sched_toggled(self, *_args) -> None:
self._sync_sensitivity()
def _on_power_toggled(self, button, key: str) -> None:
if not button.get_active():
return
try:
write_power_mode(key)
self._set_status(f"Mode puissance : {key} (immédiat).")
except Exception as exc:
self._set_status(f"Impossible d'écrire le mode : {exc}", error=True)
def _set_busy(self, busy: bool) -> None:
self._busy = busy
self.btn_apply.set_sensitive(not busy)
self.btn_update.set_sensitive(not busy)
def _set_status(self, text: str, error: bool = False) -> None:
if error:
self.status.set_markup(f"<span foreground='#c62828'>{GLib.markup_escape_text(text)}</span>")
else:
self.status.set_text(text)
def _on_apply(self, *_args) -> None:
if self._busy:
return
cfg = self._collect()
self._set_busy(True)
self._set_status("Écriture des préférences… puis nh os switch (mot de passe si demandé)…")
def work():
try:
write_power_mode(cfg["powerMode"])
write_prefs_nix(cfg)
ok, msg = apply_system_config()
except Exception as exc:
ok, msg = False, str(exc)
def done():
self._set_busy(False)
self._refresh_sys_status()
if ok:
cfg2 = load_settings()
if cfg2["autoUpgrade"]:
self._set_status("Config appliquée — auto-màj activée.")
elif cfg2["scheduleEnable"]:
self._set_status(
f"Config appliquée — planning "
f"{cfg2['weekday']} {cfg2['hour']:02d}:{cfg2['minute']:02d}."
)
else:
self._set_status(
"Config appliquée — auto-màj désactivée (prefs: autoUpgrade=false)."
)
else:
short = _short_msg(
False, msg, ok_text="", fail_text="Échec de l'application"
)
self._set_status(short, error=True)
if msg and (len(msg) > 120 or "\n" in msg):
show_log_dialog(self, "Détails — application config", msg)
GLib.idle_add(done)
threading.Thread(target=work, daemon=True).start()
def _on_update(self, *_args) -> None:
if self._busy:
return
cfg = self._collect()
self._set_busy(True)
self._set_status("Lancement de glf-update…")
def work():
try:
write_power_mode(cfg["powerMode"])
ok, msg = run_manual_update()
except Exception as exc:
ok, msg = False, str(exc)
def done():
self._set_busy(False)
short = _short_msg(
ok,
msg,
ok_text="Mise à jour terminée.",
fail_text="Échec de la mise à jour",
)
self._set_status(short, error=not ok)
if msg and (len(msg) > 80 or "\n" in msg):
show_log_dialog(
self,
"Log — mise à jour" if ok else "Détails — échec màj",
msg,
)
GLib.idle_add(done)
threading.Thread(target=work, daemon=True).start()
win = Win()
win.connect("destroy", Gtk.main_quit)
win.show_all()
Gtk.main()
# --- Main ---
def main() -> None:
if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help"):
print(
"glf-update-manager — GUI mises à jour GLF-OS\n"
" (sans argument) ouvre l'interface\n"
"Variables : GLF_UM_DEMO=1 GLF_UM_STATE_DIR=… GLF_UM_PREFS_FILE=…"
)
return
run_gui()
if __name__ == "__main__":
main()
Coller le contenu.
Ctrl + X -> O -> Entrée
4. Modifier default.nix
sudo nano /etc/nixos/customConfig/default.nix
Dans imports :
./glf-update-manager.nix
(pas besoin d'importer prefs : le module le fait tout seul)
Ctrl + X -> O -> Entrée
5. Rebuild
glf-update && reboot
Au boot : glf-update-manager-prefs.nix apparaît à côté des 2 fichiers.
6. Lancer
Menu applications -> Mises à jour GLF-OS
ou :
glf-update-manager
Icone
Désinstallation
Retirer ./glf-update-manager.nix de default.nix, puis :
sudo rm -f /etc/nixos/customConfig/glf-update-manager.nix
sudo rm -f /etc/nixos/customConfig/glf-update-manager.py
sudo rm -f /etc/nixos/customConfig/glf-update-manager-prefs.nix
sudo rm -f /etc/nixos/customConfig/glf-update-manager.nix.save
glf-update && reboot

