Installation de GLF Update Manager - GLF-OS

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