🔒 Repository is read-only – file editing is disabled.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
#!/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()