🔒 Repository is read-only – file editing is disabled.

PaganLinux/pagan-gui-build.py main

608 linii Raw ← Powrót
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
pagan-gui-build.py – GUI do lokalnych buildów PaganOS + push na VPS.
(architektura Wayland-friendly: GUI jako użytkownik, root tylko dla pagsync)

Listuje pakiety z receptur (git.paganlinux.eu/recipes.git), pozwala wybrać
kolejkę, buduje SEKWENCYJNIE na PC (pagsync z VPS, rootfs lokalny – hasło
sudo pytane w okienku, bez terminala) i wysyła .pag na VPS (rsync -> podpis
GPG na serwerze -> pagsync --gen-repo).

Uruchomienie:   python3 pagan-gui-build.py

Konfiguracja (env): PAGAN_VPS_HOST, PAGAN_SSH_KEY, PAGAN_GPG_KEY,
    PAGAN_ROOTFS, PAGAN_RECIPES_URL, PAGAN_JOBS, PAGAN_GUI_WORK
"""
import os
import queue
import re
import shutil
import subprocess
import sys
import threading
import tkinter as tk
from tkinter import simpledialog, ttk

# ── Konfiguracja ──────────────────────────────────────────────────────────
HOME = os.path.expanduser("~")
WORK = os.environ.get("PAGAN_GUI_WORK", os.path.join(HOME, ".pagan-gui"))
RECIPES_URL = os.environ.get("PAGAN_RECIPES_URL", "https://git.paganlinux.eu/recipes.git")
VPS_HOST = os.environ.get("PAGAN_VPS_HOST", "root@paganlinux.eu")
SSH_KEY = os.environ.get("PAGAN_SSH_KEY", os.path.join(HOME, ".ssh", "id_ed25519_pagan"))
GPG_KEY = os.environ.get("PAGAN_GPG_KEY", "A14CBE52BE0D63F5")
REPO_STABLE_VPS = "/var/www/repo.paganlinux.eu/stable"
JOBS = os.environ.get("PAGAN_JOBS", str(os.cpu_count() or 4))
ROOTFS = os.environ.get("PAGAN_ROOTFS") or (
    "/mnt/pagan" if os.path.isdir("/mnt/pagan/usr/bin") else "/mnt/pagan-native-rootfs"
)

RECIPES = os.path.join(WORK, "recipes")
TOOLS = os.path.join(WORK, "tools")
OUTPUT = os.path.join(WORK, "output")
REPO = os.path.join(WORK, "repo")
STATE = os.path.join(WORK, "state.json")
DIAG = os.path.join(WORK, "diagnostics")
LOCK = os.path.join(WORK, "pagsync.lock")
GUILOG = os.path.join(WORK, "gui.log")
PAGBUILD = os.path.join(TOOLS, "pagbuild")
PAGSYNC = os.path.join(TOOLS, "pagsync")

SSH_OPTS = ["-i", SSH_KEY, "-o", "BatchMode=yes", "-o", "ConnectTimeout=15",
            "-o", "StrictHostKeyChecking=accept-new", "-o", "IdentitiesOnly=yes",
            "-o", "UserKnownHostsFile=" + os.path.join(HOME, ".ssh", "known_hosts")]

# ── Sudo przez okienko (bez terminala, Wayland-safe) ─────────────────────
_pw = None
_pw_evt = threading.Event()
_pw_cb = None          # ustawiane przez GUI: pokaż okienko na głównym wątku


def _ask_password_ui():
    """Wywoływane na głównym wątku Tk – okienko z hasłem sudo."""
    global _pw
    d = simpledialog.askstring("Hasło sudo",
                               "pagbuild/pagsync wymaga root (chroot).\n"
                               "Podaj hasło sudo:", show="*")
    _pw = d
    _pw_evt.set()


def _sudo_authed() -> bool:
    if subprocess.run(["sudo", "-n", "true"], capture_output=True).returncode == 0:
        return True
    global _pw
    _pw = None
    _pw_evt.clear()
    if _pw_cb:
        _pw_cb()
    return _pw_evt.wait(timeout=180) and bool(_pw)


def run_root(cmd, env=None, cwd=None):
    """Polecenie jako root przez `sudo -S` (hasło z okienka). Loguje output."""
    if not _sudo_authed():
        GUI.log("❌ Brak autoryzacji sudo – przerwano.", "err")
        return 1
    envpairs = []
    if env:
        envpairs = [f"{k}={v}" for k, v in env.items()]
    full = ["sudo", "-S", "-E", "/usr/bin/env", *envpairs, *cmd]
    GUI.log("$ (sudo) " + " ".join(cmd), "cmd")
    try:
        p = subprocess.Popen(full, cwd=cwd, stdin=subprocess.PIPE,
                             stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                             text=True, errors="replace")
        p.stdin.write((_pw or "") + "\n")
        p.stdin.flush()
        p.stdin.close()
        for line in p.stdout:
            line = line.rstrip()
            if not line:
                continue
            tag = ""
            if any(s in line for s in ("✅", "Sukces", "OK")):
                tag = "ok"
            elif any(s in line for s in ("❌", "FAIL", "Błąd", "error",
                                         "denied", "permitted", "wrong password")):
                tag = "err"
            elif any(s in line for s in ("⚠", "WARN")):
                tag = "warn"
            GUI.log(line, tag)
        p.wait()
        return p.returncode
    except Exception as e:
        GUI.log(f"❌ wyjątek: {e}", "err")
        return 1


def run(cmd, cwd=None, env=None):
    """Polecenie jako bieżący użytkownik. Loguje output."""
    GUI.log("$ " + " ".join(cmd), "cmd")
    try:
        p = subprocess.Popen(cmd, cwd=cwd, env=env, stdout=subprocess.PIPE,
                             stderr=subprocess.STDOUT, text=True, errors="replace")
        for line in p.stdout:
            line = line.rstrip()
            if not line:
                continue
            tag = ""
            if any(s in line for s in ("✅", "Sukces", "OK")):
                tag = "ok"
            elif any(s in line for s in ("❌", "FAIL", "Błąd", "error")):
                tag = "err"
            elif any(s in line for s in ("⚠", "WARN")):
                tag = "warn"
            GUI.log(line, tag)
        p.wait()
        return p.returncode
    except Exception as e:
        GUI.log(f"❌ wyjątek: {e}", "err")
        return 1


def ssh(cmd):
    return run(["ssh", *SSH_OPTS, VPS_HOST] + cmd)


def _writable(p):
    """Czy katalog istnieje i da się w nim pisać (probe file)."""
    if not os.path.isdir(p):
        return True
    try:
        t = os.path.join(p, ".wtest")
        open(t, "w").close()
        os.unlink(t)
        return True
    except OSError:
        return False


def ensure_dirs():
    """Tworzy katalogi robocze i (raz) przejmuje WORK z roota na użytkownika.
    Zwraca True gdy wszystko jest zapisywalne."""
    for d in (WORK, TOOLS, OUTPUT, DIAG, os.path.join(REPO, "stable")):
        try:
            os.makedirs(d, exist_ok=True)
        except OSError:
            pass
    dirty = [d for d in (WORK, RECIPES, TOOLS, OUTPUT, DIAG, os.path.join(REPO, "stable"))
             if os.path.isdir(d) and not _writable(d)]
    if not dirty:
        return True
    GUI.log("⚠ katalogi robocze należą do roota – przejmuję na użytkownika (sudo)", "warn")
    rc = run_root(["chown", "-R", f"{os.getuid()}:{os.getgid()}", WORK])
    if rc != 0:
        GUI.log("❌ nie udało się przejąć ~/.pagan-gui – sprawdź hasło sudo", "err")
        return False
    return all(_writable(d) for d in dirty)


def _flog(msg: str):
    try:
        with open(GUILOG, "a", encoding="utf-8") as fh:
            fh.write(msg + "\n")
    except OSError:
        pass


# ── Akcje ─────────────────────────────────────────────────────────────────
def sync_recipes():
    if not ensure_dirs():
        return
    GUI.log("── Sync receptur ──", "hdr")
    if os.path.isdir(os.path.join(RECIPES, ".git")):
        run(["git", "-C", RECIPES, "pull", "--ff-only", "origin", "main"])
    else:
        run(["git", "clone", RECIPES_URL, RECIPES])
    if not os.path.exists(PAGBUILD) or not os.path.exists(PAGSYNC):
        GUI.log("── Pobieram pagbuild/pagsync z VPS ──", "hdr")
        for dst, src in ((PAGBUILD, "/opt/pagan-web-v2/pagbuild"),
                         (PAGSYNC, "/usr/bin/pagsync")):
            run(["scp", *SSH_OPTS, f"{VPS_HOST}:{src}", dst])
            os.chmod(dst, 0o755)
    GUI.log("✅ Sync gotowy", "ok")


def recipe_list():
    out = []
    if not os.path.isdir(RECIPES):
        return out
    for root, _dirs, files in os.walk(RECIPES):
        for fn in ("PAGBUILD.yaml", "package.yml", "recipe.yaml"):
            if fn in files:
                fp = os.path.join(root, fn)
                try:
                    txt = open(fp, encoding="utf-8", errors="replace").read()
                except OSError:
                    continue
                m = re.search(r"(?m)^pkgname:\s*['\"]?([\w+.-]+)", txt)
                v = re.search(r"(?m)^pkgver:\s*['\"]?([\w.+~-]+)", txt)
                d = re.search(r"(?m)^pkgdesc:\s*['\"]?([^\n'\"]+)", txt)
                name = m.group(1) if m else os.path.basename(root)
                cat = os.path.relpath(root, RECIPES).split(os.sep)[0]
                out.append((name, v.group(1) if v else "?", cat,
                            (d.group(1).strip() if d else ""), fp))
                break
    out.sort(key=lambda x: x[0].lower())
    return out


def _run_capture(prefix, cmd, env=None, cwd=None):
    """Wykonuje polecenie, loguje linie i zwraca (rc, lista_linii).
    prefix: lista (np. ['sudo','-S','-E','/usr/bin/env', ...] albo [])."""
    full = prefix + cmd
    GUI.log("$ " + " ".join(full), "cmd")
    lines = []
    try:
        p = subprocess.Popen(full, cwd=cwd, env=env, stdin=subprocess.PIPE,
                             stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                             text=True, errors="replace")
        if prefix and prefix[0] == "sudo":
            p.stdin.write((_pw or "") + "\n")
            p.stdin.flush()
        p.stdin.close()
        for ln in p.stdout:
            ln = ln.rstrip()
            lines.append(ln)
            if not ln:
                continue
            tag = ""
            if any(s in ln for s in ("✅", "Sukces", "OK")):
                tag = "ok"
            elif any(s in ln for s in ("❌", "FAIL", "Błąd", "error", "denied", "permitted")):
                tag = "err"
            elif any(s in ln for s in ("⚠", "WARN")):
                tag = "warn"
            GUI.log(ln, tag)
        p.wait()
        return p.returncode, lines
    except Exception as e:
        GUI.log(f"❌ wyjątek: {e}", "err")
        return 1, lines


def _missing_names_from_log(lines):
    """Wyciąga nazwy brakujących pakietów z logu pagsync (auto-deps)."""
    out = []
    for ln in lines:
        m = re.search(r"brak receptury i brak w repo: (.+)$", ln)
        if not m:
            m = re.search(r"brak receptury dostarczającej: (.+)$", ln)
        if m:
            for x in re.split(r"[,\s]+", m.group(1).strip()):
                if x and x not in out:
                    out.append(x)
    return out[:6]


def _fill_missing_from_repo(names):
    """Dla brakujących pakietów: pag search -> pobierz .pag z repo VPS ->
    pagsync --install (do rootfs buildera). Zwraca listę doinstalowanych."""
    filled = []
    for name in names:
        GUI.log(f"── Brak {name}: pag search … ──", "hdr")
        rc, out = _run_capture([], ["pag", "search", name])
        found = rc == 0 and any(re.match(rf"^\s*{re.escape(name)}\b", ln) for ln in out)
        if not found:
            GUI.log(f"⤼ {name}: brak też w repo (pag search) – pomijam", "warn")
            continue
        # pobierz nazwę pliku z repo.json (VPS)
        try:
            import json as _json
            import urllib.request as _ur
            req = _ur.Request("https://repo.paganlinux.eu/stable/repo.json",
                              headers={"User-Agent": "pag-gui"})
            data = _json.load(_ur.urlopen(req, timeout=30))
        except Exception as e:
            GUI.log(f"⤼ {name}: nie mogę pobrać repo.json: {e}", "warn")
            continue
        entry = next((p for p in data.get("packages", []) if p.get("name") == name), None)
        if not entry or not entry.get("filename"):
            GUI.log(f"⤼ {name}: brak wpisu w repo.json", "warn")
            continue
        fname = entry["filename"]
        stable = os.path.join(REPO, "stable")
        os.makedirs(stable, exist_ok=True)
        dst = os.path.join(stable, fname)
        GUI.log(f"↓ pobieram {fname}")
        try:
            import urllib.request as _ur
            _ur.urlretrieve(f"https://repo.paganlinux.eu/stable/{fname}", dst)
        except Exception as e:
            GUI.log(f"⤼ {name}: pobieranie nieudane: {e}", "warn")
            continue
        GUI.log(f"📦 {name}: instaluję do rootfs (pagsync --install)")
        env = {"PAGAN_RECIPES": RECIPES, "PAGAN_REPO": REPO,
               "PAGAN_PAGBUILD": PAGBUILD, "PAGAN_ROOTFS": ROOTFS,
               "PAGAN_STATE": STATE, "PAGAN_BUILD_OUT": OUTPUT,
               "PAGAN_DIAG": DIAG, "PAGAN_LOCK": LOCK, "PAGAN_DO_SIGN": ""}
        if run_root([PAGSYNC, "--once", "--install", name, "--rootfs", ROOTFS], env=env) == 0:
            filled.append(name)
    return filled


def _sudo_prefix():
    return ["sudo", "-S", "-E"] if os.geteuid() != 0 else []


def _run_build(pkg, env):
    """pagsync --build jako root; zwraca (rc, linie_logu)."""
    if not _sudo_authed():
        return 1, []
    return _run_capture(_sudo_prefix(),
                        [PAGSYNC, "--once", "--build", pkg, "--rootfs", ROOTFS],
                        env=env)


def check_updates(pkg=None, apply=False):
    """Heurystyka aktualizacji receptur (pagsync --check-updates).
    pkg=None -> skan wszystkich; pkg=nazwa -> tylko ten pakiet.
    apply=True -> podbija pkgver/pkgrel (zmiany w recipes + commit)."""
    if not ensure_dirs():
        return
    GUI.log("── Sync receptur (git pull) ──", "hdr")
    if os.path.isdir(os.path.join(RECIPES, ".git")):
        run(["git", "-C", RECIPES, "pull", "--ff-only", "origin", "main"])
    GUI.log("── " + ("Podbijam (--apply)" if apply else "Sprawdzam nowsze wersje") + " ──", "hdr")
    env = {"PAGAN_RECIPES": RECIPES, "PAGAN_STATE": STATE,
           "PAGAN_LOCK": LOCK, "PATH": os.environ.get("PATH", "")}
    cmd = [PAGSYNC, "--once", "--check-updates"]
    if pkg:
        cmd += ["--update-pkg", pkg]
    if apply:
        cmd += ["--apply"]
    rc = run(cmd, env=env)
    if rc != 0:
        GUI.log("⚠ check-updates zakończone z błędem", "warn")
    else:
        GUI.log("✅ Sprawdzone" + (" – pkgver podbite (commit lokalny)" if apply else ""), "ok")
    if apply:
        GUI.log("ℹ Odśwież listę pakietów (zmieniły się wersje)", "warn")
        GUI.instance.pkgs = recipe_list()
        GUI.instance.root.after(0, GUI.instance.render_pkgs)


def build_queue(names, auto_push):
    if not ensure_dirs():
        return
    # git safe.directory dla roota (pagsync robi pull w recipes jako root)
    safe_env = {"GIT_CONFIG_COUNT": "1",
                "GIT_CONFIG_KEY_0": "safe.directory",
                "GIT_CONFIG_VALUE_0": RECIPES}
    for pkg in names:
        GUI.log(f"── [{pkg}] build ──", "hdr")
        env = dict(safe_env)
        env.update({
            "PAGAN_RECIPES": RECIPES, "PAGAN_REPO": REPO,
            "PAGAN_PAGBUILD": PAGBUILD, "PAGAN_ROOTFS": ROOTFS,
            "PAGAN_STATE": STATE, "PAGAN_BUILD_OUT": OUTPUT,
            "PAGAN_DIAG": DIAG, "PAGAN_LOCK": LOCK,
            "PAGAN_DO_SIGN": "", "MAKEFLAGS": f"-j{JOBS}",
        })
        rc, loglines = _run_build(pkg, env)
        if rc != 0:
            missing = _missing_names_from_log(loglines)
            if missing:
                GUI.log(f"ℹ {pkg}: brakujące do doinstalowania: {', '.join(missing)}", "warn")
                filled = _fill_missing_from_repo(missing)
                if filled:
                    GUI.log(f"🔁 {pkg}: ponawiam build po doinstalowaniu: {', '.join(filled)}")
                    rc, _log2 = _run_build(pkg, env)
            if rc != 0:
                GUI.log(f"⚠ {pkg}: build FAIL (rc={rc})", "warn")
    GUI.log("🏁 Kolejka buildów zakończona", "hdr")
    if auto_push:
        push_to_vps()


def push_to_vps():
    if not ensure_dirs():
        return
    stable = os.path.join(REPO, "stable")
    GUI.log("── rsync .pag na VPS ──", "hdr")
    ssh_cmd = " ".join(["ssh"] + SSH_OPTS)
    if run(["rsync", "-avz", "--update", "--mkpath", "-e", ssh_cmd,
            stable + "/", f"{VPS_HOST}:{REPO_STABLE_VPS}/"]):
        GUI.log("❌ rsync nieudany", "err")
        return
    GUI.log("── Podpis .pag na VPS (klucz zostaje na serwerze) ──", "hdr")
    ssh(["bash", "-c",
         f"cd {REPO_STABLE_VPS} && for f in *.pag; do [ -f \"$f.asc\" ] || "
         f"gpg --detach-sign --armor --batch --no-tty --local-user {GPG_KEY} \"$f\" "
         f"2>/dev/null && echo '  🔏 $f.asc'; done; true"])
    GUI.log("── repo.json (VPS) ──", "hdr")
    ssh(["bash", "-c",
         f"PAGAN_DO_SIGN=1 PAGAN_GPG_KEY={GPG_KEY} pagsync --gen-repo 2>&1 | tail -3"])
    GUI.log("✅ Push zakończony", "ok")


# ── GUI ───────────────────────────────────────────────────────────────────
class GUI:
    log_queue = queue.Queue()
    instance = None

    @staticmethod
    def log(msg, tag=""):
        GUI.log_queue.put((msg, tag))
        _flog(msg)

    def __init__(self, root):
        GUI.instance = self
        self.root = root
        self.pkgs = []
        self.busy = False
        root.title("PaganOS – lokalny build + push na VPS")
        root.geometry("1120x720")

        top = ttk.Frame(root, padding=6)
        top.pack(fill="x")
        for text, fn in (
            ("🔄 Pobierz receptury", self.cmd_sync),
            ("➕ Dodaj do kolejki", self.cmd_add),
            ("🗑 Usuń z kolejki", self.cmd_remove),
            ("🔨 Zbuduj kolejkę", lambda: self.cmd_build(False)),
            ("🔨+📤 Zbuduj i wyślij", lambda: self.cmd_build(True)),
            ("📤 Wyślij na VPS", self.cmd_push),
            ("🧹 Wyczyść kolejki", self.cmd_clear),
        ):
            ttk.Button(top, text=text, command=fn).pack(side="left", padx=2)

        top2 = ttk.Frame(root, padding=(6, 0, 6, 4))
        top2.pack(fill="x")
        for text, fn in (
            ("📈 Sprawdź nowsze wersje (heurystyka)", lambda: self._submit(check_updates, None, False)),
            ("⬆ Podbij zaznaczony pakiet (--apply)", self.cmd_bump),
        ):
            ttk.Button(top2, text=text, command=fn).pack(side="left", padx=2)
        ttk.Label(top2, text="zaznacz 1 pakiet na liście → podbija pkgver + commit",
                  foreground="#666").pack(side="left", padx=6)

        self.status = tk.Label(root, text="init…", anchor="w", bg="#111", fg="#8cf", padx=8)
        self.status.pack(fill="x")

        mid = ttk.Frame(root, padding=6)
        mid.pack(fill="both", expand=True)
        left = ttk.LabelFrame(mid, text="Pakiety (recipes.git) – Ctrl/Shift: wiele")
        left.pack(side="left", fill="both", expand=True, padx=(0, 4))
        self.filter_var = tk.StringVar()
        ttk.Entry(left, textvariable=self.filter_var).pack(fill="x", padx=4, pady=(4, 2))
        self.filter_var.trace_add("write", lambda *a: self.render_pkgs())
        self.listbox = tk.Listbox(left, selectmode="extended", exportselection=False,
                                  font=("monospace", 9))
        self.listbox.pack(fill="both", expand=True, padx=4, pady=4)
        self.count_lbl = ttk.Label(left, text="")
        self.count_lbl.pack(anchor="w", padx=4)

        qf = ttk.LabelFrame(mid, text="Kolejka (kernel najpierw!)")
        qf.pack(side="left", fill="y", padx=4)
        self.queue_lb = tk.Listbox(qf, selectmode="extended", width=34, font=("monospace", 9))
        self.queue_lb.pack(fill="both", expand=True, padx=4, pady=4)

        cons = ttk.LabelFrame(root, text="Konsola")
        cons.pack(fill="both", expand=True, padx=6, pady=(0, 6))
        self.txt = tk.Text(cons, height=14, state="disabled", bg="#0d1117", fg="#ddd",
                           font=("monospace", 9), wrap="none")
        self.txt.pack(fill="both", expand=True, padx=4, pady=4)
        self.txt.tag_configure("ok", foreground="#3fb950")
        self.txt.tag_configure("err", foreground="#f85149")
        self.txt.tag_configure("warn", foreground="#d29922")
        self.txt.tag_configure("hdr", foreground="#58a6ff")
        self.txt.tag_configure("cmd", foreground="#888")

        self._taskq = queue.Queue()
        self.worker = threading.Thread(target=self._worker, daemon=True)
        self.worker.start()
        self.root.after(80, self._drain_log)
        self.cmd_sync()

    # --- wątek roboczy / log ---
    def _worker(self):
        while True:
            task, args = self._taskq.get()
            self.busy = True
            self.root.after(0, lambda: self.status.config(text="⏳ pracuję…"))
            try:
                task(*args)
            except Exception as e:
                GUI.log(f"❌ {e}", "err")
            finally:
                self.busy = False
                self._taskq.task_done()
                self.root.after(0, lambda: self.status.config(text="gotowy"))

    def _submit(self, fn, *args):
        if self.busy:
            GUI.log("⏳ Poczekaj – trwa inna operacja", "warn")
            return
        self._taskq.put((fn, args))

    def _drain_log(self):
        try:
            while True:
                msg, tag = GUI.log_queue.get_nowait()
                self.txt.config(state="normal")
                self.txt.insert("end", msg + "\n", tag)
                self.txt.see("end")
                self.txt.config(state="disabled")
        except queue.Empty:
            pass
        self.root.after(80, self._drain_log)

    # --- akcje ---
    def cmd_sync(self):
        self._submit(self._do_sync)

    def _do_sync(self):
        sync_recipes()
        self.pkgs = recipe_list()
        self.root.after(0, self.render_pkgs)

    def cmd_clear(self):
        self.queue_lb.delete(0, "end")

    def cmd_add(self):
        existing = set(self.queue_lb.get(0, "end"))
        for i in self.listbox.curselection():
            name = self.pkgs[i][0]
            if name not in existing:
                self.queue_lb.insert("end", name)

    def cmd_remove(self):
        for i in reversed(self.queue_lb.curselection()):
            self.queue_lb.delete(i)

    def cmd_build(self, auto_push):
        names = list(self.queue_lb.get(0, "end"))
        if not names:
            GUI.log("Brak pakietów w kolejce", "warn")
            return
        self._submit(build_queue, names, auto_push)

    def cmd_push(self):
        self._submit(push_to_vps)

    def cmd_bump(self):
        sel = self.listbox.curselection()
        if len(sel) != 1:
            GUI.log("Zaznacz DOKŁADNIE jeden pakiet na liście, żeby podbić wersję", "warn")
            return
        name = self.pkgs[sel[0]][0]
        GUI.log(f"⬆ Podbijam {name} (pkgver + pkgrel=1, sha=SKIP)", "hdr")
        self._submit(check_updates, name, True)

    def render_pkgs(self):
        f = self.filter_var.get().lower()
        self.listbox.delete(0, "end")
        shown = 0
        for p in self.pkgs:
            name, ver, cat = p[0], p[1], p[2]
            if f and f not in name.lower() and f not in cat.lower():
                continue
            self.listbox.insert("end", f"{name:<32} {ver:<12} {cat}")
            shown += 1
        self.count_lbl.config(text=f"pokazano {shown} z {len(self.pkgs)}")


def main():
    global _pw_cb
    _flog("\n===== start pagan-gui-build " + __import__("datetime").datetime.now().isoformat() + " =====")

    def _hook(et, ev, tb):
        import traceback as _tb
        msg = "".join(_tb.format_exception(et, ev, tb))
        _flog("❌ NIEZŁAPANY WYJĄTEK:\n" + msg)
        print(msg, file=sys.stderr)

    sys.excepthook = _hook
    if not os.path.isdir(ROOTFS):
        print(f"⚠ Uwaga: rootfs nie istnieje: {ROOTFS}\n"
              f"   Ustaw PAGAN_ROOTFS=/ścieżka/do/rootfs")
    root = tk.Tk()
    _pw_cb = lambda: root.after(0, _ask_password_ui)
    gui = GUI(root)
    root.mainloop()


if __name__ == "__main__":
    main()