#!/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): """Polecenie jako bieżący użytkownik. Loguje output.""" GUI.log("$ " + " ".join(cmd), "cmd") try: p = subprocess.Popen(cmd, cwd=cwd, 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 ensure_dirs(): try: for d in (WORK, TOOLS, OUTPUT, DIAG, os.path.join(REPO, "stable")): os.makedirs(d, exist_ok=True) probe = os.path.join(WORK, ".wtest") open(probe, "w").close() os.unlink(probe) except (PermissionError, OSError): # WORK był tworzony przez roota (stare uruchomienia) – oddaj właściciela GUI.log("⚠ ~/.pagan-gui należy do roota – przejmuję (sudo)", "warn") run_root(["chown", "-R", f"{os.getuid()}:{os.getgid()}", WORK]) for d in (WORK, TOOLS, OUTPUT, DIAG, os.path.join(REPO, "stable")): os.makedirs(d, exist_ok=True) 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(): ensure_dirs() 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 build_queue(names, auto_push): ensure_dirs() # 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 = run_root([PAGSYNC, "--once", "--build", pkg, "--rootfs", ROOTFS], env=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(): ensure_dirs() 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() @staticmethod def log(msg, tag=""): GUI.log_queue.put((msg, tag)) _flog(msg) def __init__(self, root): self.root = root self.pkgs = [] self.busy = False root.title("PaganOS – lokalny build + push na VPS") root.geometry("1120x680") 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) 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 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()