diff options
Diffstat (limited to 'raveos-app-installer/raveos-app-installer.py')
| -rw-r--r-- | raveos-app-installer/raveos-app-installer.py | 1288 |
1 files changed, 1288 insertions, 0 deletions
diff --git a/raveos-app-installer/raveos-app-installer.py b/raveos-app-installer/raveos-app-installer.py new file mode 100644 index 0000000..2c51a73 --- /dev/null +++ b/raveos-app-installer/raveos-app-installer.py @@ -0,0 +1,1288 @@ +#!/usr/bin/env python3 +""" +RaveOS App Installer +==================== +Egyetlen fájlos, letisztult PyQt6 alkalmazástelepítő a RaveOS-hez. + +Tervezési elvek: + * A welcome app vizuális témája: dark charcoal, muted natural green accent, + Bebas Neue display font, minimal lekerekítés, semmi neon / cyberpunk. + * USER-ként fut (NEM root). A rendszerszintű parancsok (pacman) per-művelet + `pkexec`-en mennek -> Wayland/Hyprland-helyes (a root GUI Waylanden gáz). + * Háttér QThread a telepítéshez -> a GUI nem fagy be / nem zár be magától. + * GUI + CLI ugyanabból a fájlból (argparse). + * Az alkalmazás-katalógus külön `app-list.json`-ban (te frissíted GitHubról). + +Backendek (app "type" mezője): + pacman -> pkexec pacman -S ... (id = csomagnév) + aur -> yay -S ... user-ként (id = AUR csomagnév) + flatpak -> flatpak install --user ... (id = flatpak app id, flathubról) + script -> bash -c <id> (id = a futtatandó parancs) + +Függőségek: python-pyqt6, polkit (pkexec), flatpak, opcionálisan yay. +Hyprlandon kell egy polkit agent is (pl. hyprpolkit-agent vagy polkit-gnome). +""" + +import argparse +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +# ───────────────────────────────────────────────────────────────────────────── +# Konstansok / útvonalak +# ───────────────────────────────────────────────────────────────────────────── + +APP_NAME = "RaveOS App Installer" +APP_ID = "raveos-app-installer" # Wayland app_id (Hyprland window rule-okhoz) +APP_VERSION = "3.1" + +# app-list.json keresési sorrend: a script mellett -> /usr/share -> ~/.config +_SCRIPT_DIR = Path(__file__).resolve().parent +APP_LIST_CANDIDATES = [ + _SCRIPT_DIR / "app-list.json", + Path("/usr/share/raveos/app-list.json"), + Path.home() / ".config" / "raveos" / "app-list.json", +] + +CATEGORY_ALL = "All" + + +# ───────────────────────────────────────────────────────────────────────────── +# Téma (a welcome app stílusa) +# ───────────────────────────────────────────────────────────────────────────── + +C = { + "bg": "#232427", + "header": "#1b1c1e", + "surface": "#2b2c30", + "surface_hi": "#34353a", + "border": "#3a3b40", + "border_hi": "#4a4b52", + "accent": "#62a052", # muted natural green + "accent_hi": "#72b062", + "accent_dim": "#4d8043", + "text": "#eaeaea", + "text_dim": "#9298a0", + "text_mut": "#6c727a", + "danger": "#b5544a", + "danger_hi": "#c56458", +} + +# Per-type kis badge színek (visszafogottak) +TYPE_LABEL = { + "pacman": "pacman", + "aur": "AUR", + "flatpak": "flatpak", + "flatpak-user": "flatpak", + "script": "script", + "message": "info", +} + + +def build_qss() -> str: + return f""" + QWidget {{ + background: {C['bg']}; + color: {C['text']}; + font-size: 13px; + }} + QToolTip {{ + background: {C['surface']}; + color: {C['text']}; + border: 1px solid {C['border']}; + padding: 4px; + }} + + /* Fejléc sáv */ + QFrame#Header {{ + background: {C['header']}; + border: none; + border-bottom: 1px solid {C['border']}; + }} + + /* Kártya-szerű felületek */ + QFrame#Card, QScrollArea, QFrame#ListHost {{ + background: {C['bg']}; + border: none; + }} + + /* Label-ek átlátszó háttere (ne nyomja rá a QWidget bg-t) */ + QLabel {{ background: transparent; }} + + /* App sorok */ + QFrame#Row {{ + background: {C['surface']}; + border: 1px solid {C['border']}; + border-radius: 4px; + }} + QFrame#Row:hover {{ + background: {C['surface_hi']}; + border: 1px solid {C['border_hi']}; + }} + + /* Kereső / combo */ + QLineEdit, QComboBox {{ + background: {C['surface']}; + border: 1px solid {C['border']}; + border-radius: 4px; + padding: 7px 10px; + color: {C['text']}; + selection-background-color: {C['accent_dim']}; + }} + QLineEdit:focus, QComboBox:focus {{ + border: 1px solid {C['accent']}; + }} + QComboBox::drop-down {{ border: none; width: 22px; }} + QComboBox QAbstractItemView {{ + background: {C['surface']}; + border: 1px solid {C['border']}; + selection-background-color: {C['accent_dim']}; + outline: none; + }} + + /* Szegmentált fülek (All / Installed) */ + QPushButton#Tab {{ + background: transparent; + border: 1px solid {C['border']}; + border-radius: 4px; + padding: 6px 18px; + color: {C['text_dim']}; + }} + QPushButton#Tab:hover {{ color: {C['text']}; border-color: {C['border_hi']}; }} + QPushButton#Tab:checked {{ + background: {C['surface_hi']}; + color: {C['text']}; + border-color: {C['border_hi']}; + }} + + /* Gombok */ + QPushButton {{ + background: {C['surface']}; + border: 1px solid {C['border']}; + border-radius: 4px; + padding: 9px 18px; + color: {C['text']}; + }} + QPushButton:hover {{ background: {C['surface_hi']}; border-color: {C['border_hi']}; }} + QPushButton:disabled {{ color: {C['text_mut']}; border-color: {C['border']}; }} + + QPushButton#Primary {{ + background: {C['accent']}; + border: 1px solid {C['accent']}; + color: #15240f; + font-weight: 600; + }} + QPushButton#Primary:hover {{ background: {C['accent_hi']}; border-color: {C['accent_hi']}; }} + QPushButton#Primary:disabled {{ background: {C['accent_dim']}; border-color: {C['accent_dim']}; color: #2a3a22; }} + + QPushButton#Danger {{ border-color: {C['danger']}; color: {C['danger_hi']}; }} + QPushButton#Danger:hover {{ background: {C['danger']}; color: {C['text']}; }} + QPushButton#Danger:disabled {{ color: {C['text_mut']}; border-color: {C['border']}; }} + + /* Checkbox: zöld kitöltés bekapcsoláskor (asset nélkül) */ + QCheckBox {{ spacing: 0; }} + QCheckBox::indicator {{ + width: 17px; height: 17px; + border: 1px solid {C['border_hi']}; + border-radius: 3px; + background: transparent; + }} + QCheckBox::indicator:hover {{ border-color: {C['accent']}; }} + QCheckBox::indicator:checked {{ + background: {C['accent']}; + border: 1px solid {C['accent']}; + }} + + /* Log */ + QPlainTextEdit#Log {{ + background: {C['header']}; + border: 1px solid {C['border']}; + border-radius: 4px; + color: {C['text_dim']}; + font-family: "JetBrains Mono", "DejaVu Sans Mono", monospace; + font-size: 12px; + padding: 6px; + }} + + /* Progress */ + QProgressBar {{ + background: {C['surface']}; + border: 1px solid {C['border']}; + border-radius: 4px; + text-align: center; + color: {C['text']}; + height: 22px; + }} + QProgressBar::chunk {{ background: {C['accent']}; border-radius: 3px; }} + + /* Scrollbar */ + QScrollBar:vertical {{ background: transparent; width: 10px; margin: 0; }} + QScrollBar::handle:vertical {{ + background: {C['border_hi']}; border-radius: 5px; min-height: 28px; + }} + QScrollBar::handle:vertical:hover {{ background: {C['text_mut']}; }} + QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }} + QScrollBar::add-page, QScrollBar::sub-page {{ background: transparent; }} + """ + + +# ───────────────────────────────────────────────────────────────────────────── +# Adat: katalógus betöltés + telepítettség detektálás +# ───────────────────────────────────────────────────────────────────────────── + +def load_catalog() -> list: + """app-list.json beolvasása az első létező útvonalról.""" + for path in APP_LIST_CANDIDATES: + if path.is_file(): + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + raise RuntimeError(f"Hibás app-list.json ({path}): {exc}") + apps = data.get("apps", data) if isinstance(data, dict) else data + cleaned = [] + for a in apps: + if not isinstance(a, dict) or "name" not in a or "id" not in a: + continue + a.setdefault("type", "pacman") + a.setdefault("category", "Other") + a.setdefault("description", a.get("desc", "")) + cleaned.append(a) + cleaned.sort(key=lambda x: x["name"].lower()) + return cleaned + raise RuntimeError( + "Nem található app-list.json. Keresett helyek:\n " + + "\n ".join(str(p) for p in APP_LIST_CANDIDATES) + ) + + +def _query_set(cmd) -> set: + try: + out = subprocess.run(cmd, capture_output=True, text=True, timeout=20) + return {l.strip() for l in out.stdout.splitlines() if l.strip()} + except (subprocess.SubprocessError, FileNotFoundError, OSError): + return set() + + +def installed_index() -> dict: + """Egyetlen lekérdezés backendenként -> gyors membership check.""" + pac = _query_set(["pacman", "-Qq"]) + flat = _query_set(["flatpak", "list", "--app", "--columns=application"]) + return {"pacman": pac, "flatpak": flat} + + +def is_installed(app: dict, idx: dict) -> bool: + t = app.get("type", "pacman") + + if t in ("pacman", "aur"): + packages = app.get("packages") or [app["id"]] + return all(p in idx["pacman"] for p in packages) + + if t == "flatpak-user": + fid = app.get("flatpak_id", "") + return bool(fid) and fid in idx["flatpak"] + + if t == "script": + check_pkgs = app.get("check_packages") + if check_pkgs: + return all(p in idx["pacman"] for p in check_pkgs) + chk = app.get("check") + if not chk: + return False + if chk.startswith("/") and Path(chk).exists(): + return True + try: + return subprocess.run(["bash", "-c", chk], + capture_output=True, timeout=15).returncode == 0 + except (subprocess.SubprocessError, OSError): + return False + + return False + + +# ───────────────────────────────────────────────────────────────────────────── +# Parancs-építés (telepítés / eltávolítás), batch-elve backendenként +# ───────────────────────────────────────────────────────────────────────────── + +def _root_prefix(gui: bool) -> list: + """GUI -> sudo -S (jelszó stdin-ről), terminál -> sudo, ha már root -> semmi.""" + if os.geteuid() == 0: + return [] + return ["sudo", "-S"] + + +def plan_commands(apps: list, install: bool, gui: bool) -> list: + """ + Visszaad egy listát: [(címke, parancs_lista), ...]. + Backendenként batch-eli a csomagokat -> kevesebb pkexec prompt. + """ + import shlex + pac, aur, flat, scripts, post = [], [], [], [], [] + + for a in apps: + t = a.get("type", "pacman") + if t == "pacman": + pkgs = a.get("packages") or [a["id"]] + pac.extend(pkgs) + if install: + for cmd_str in a.get("post_install", []): + post.append((f"post: {a['name']}", shlex.split(cmd_str))) + elif t == "aur": + pkgs = a.get("packages") or [a["id"]] + aur.extend(pkgs) + elif t == "flatpak-user": + fid = a.get("flatpak_id") + if fid: + flat.append((fid, a.get("post_install", []), a["name"])) + elif t == "script": + if install or a.get("removable", True): + scripts.append(a) + + rp = _root_prefix(gui) + steps = [] + + if pac: + if install: + steps.append((f"pacman: {', '.join(pac)}", + rp + ["pacman", "-S", "--needed", "--noconfirm", *pac])) + else: + steps.append((f"pacman -R: {', '.join(pac)}", + rp + ["pacman", "-Rns", "--noconfirm", *pac])) + + if aur: + if install: + # --answer* nelkul a yay a PKGBUILD-diff/edit prompt-nal orokre + # varakozik (nincs csatolt terminal a stream_command-hoz) + steps.append((f"AUR: {', '.join(aur)}", + ["yay", "-S", "--needed", "--noconfirm", + "--answerclean", "None", "--answerdiff", "None", + "--answeredit", "None", "--answerupgrade", "None", + *aur])) + else: + steps.append((f"AUR -R: {', '.join(aur)}", + ["yay", "-Rns", "--noconfirm", *aur])) + + if flat and install: + steps.append(("flathub remote", [ + "flatpak", "remote-add", "--if-not-exists", "--user", + "flathub", "https://dl.flathub.org/repo/flathub.flatpakrepo" + ])) + + for fid, post_cmds, name in flat: + if install: + steps.append((f"flatpak: {fid}", + ["flatpak", "install", "--user", "--noninteractive", + "--or-update", "flathub", fid])) + for cmd_str in post_cmds: + steps.append((f"post: {name}", shlex.split(cmd_str))) + else: + steps.append((f"flatpak uninstall: {fid}", + ["flatpak", "uninstall", "--user", "-y", fid])) + + # Script env: a szkript tudja ki a tényleges user (sudo után root fut) + _caller = os.environ.get("USER") or os.environ.get("LOGNAME") or "" + _caller_home = os.path.expanduser("~") + _caller_uid = str(os.getuid()) + _xdg_runtime = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{_caller_uid}") + _config_dir = str(_SCRIPT_DIR / "data" / "configs") + _script_env = [ + "env", + f"INSTALL_USER={_caller}", + f"INSTALL_HOME={_caller_home}", + f"TARGET_UID={_caller_uid}", + f"TARGET_XDG_RUNTIME_DIR={_xdg_runtime}", + f"RAVEOS_CONFIG_PACKAGE_DIR={_config_dir}", + ] + + for a in scripts: + script_name = a.get("script", "") + script_path = _SCRIPT_DIR / "data" / "actions" / script_name + if script_name and script_path.exists(): + action = "install" if install else "remove" + steps.append((f"script: {a['name']}", + rp + _script_env + ["bash", str(script_path), action])) + + steps.extend(post) + return steps + + +def stream_command(label: str, cmd: list, emit, password: str = "") -> bool: + """Parancs futtatása élő kimenettel. emit(str) -> log sor. True ha sikeres.""" + emit(f"$ {' '.join(cmd)}") + try: + needs_pw = len(cmd) >= 2 and cmd[0] == "sudo" and cmd[1] == "-S" + stdin_pipe = subprocess.PIPE if needs_pw else None + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, stdin=stdin_pipe, + text=True, bufsize=1) + if needs_pw and password: + proc.stdin.write(password + "\n") + proc.stdin.flush() + proc.stdin.close() + except (FileNotFoundError, OSError) as exc: + emit(f"[HIBA] nem indítható: {exc}") + return False + for line in proc.stdout: + emit(line.rstrip("\n")) + proc.wait() + ok = proc.returncode == 0 + emit(f"[{'OK' if ok else 'HIBA(' + str(proc.returncode) + ')'}] {label}") + return ok + + +# ───────────────────────────────────────────────────────────────────────────── +# GUI rész (csak ha van PyQt6 / display) +# ───────────────────────────────────────────────────────────────────────────── + +BACKUP_LIST_CANDIDATES = [ + _SCRIPT_DIR / "data/backup/backup-paths.json", + Path("/usr/share/raveos/backup-paths.json"), +] + + +def load_backup_catalog() -> list: + for path in BACKUP_LIST_CANDIDATES: + if path.is_file(): + try: + data = json.loads(path.read_text(encoding="utf-8")) + return data.get("groups", []) + except (json.JSONDecodeError, OSError): + return [] + return [] + + +def run_gui() -> int: + from PyQt6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QLabel, QPushButton, QLineEdit, QComboBox, QScrollArea, QFrame, + QCheckBox, QPlainTextEdit, QProgressBar, QButtonGroup, QSizePolicy, + QMessageBox, QFileDialog, QDialog, + ) + from PyQt6.QtCore import Qt, QThread, pyqtSignal + from PyQt6.QtGui import QFont, QFontDatabase + + # ── display font helper (Bebas Neue, fallbackkel) ──────────────────────── + def display_font(size: int, weight=QFont.Weight.Normal) -> QFont: + for fam in ("Bebas Neue", "Oswald", "Anton"): + if fam in QFontDatabase.families(): + f = QFont(fam, size) + f.setLetterSpacing(QFont.SpacingType.PercentageSpacing, 104) + return f + f = QFont() # fallback: rendszer sans, bold + tracking + f.setPointSize(size) + f.setBold(True) + f.setLetterSpacing(QFont.SpacingType.PercentageSpacing, 102) + return f + + # ── háttér worker ──────────────────────────────────────────────────────── + class Worker(QThread): + log = pyqtSignal(str) + progress = pyqtSignal(int) # 0..100 + done = pyqtSignal(int, int) # ok_steps, total_steps + + def __init__(self, steps, password: str = ""): + super().__init__() + self.steps = steps + self.password = password + + def run(self): + total = len(self.steps) + ok = 0 + for i, (label, cmd) in enumerate(self.steps): + self.log.emit("") + self.log.emit(f"── {label} ──") + if stream_command(label, cmd, self.log.emit, self.password): + ok += 1 + self.progress.emit(int((i + 1) / total * 100)) + self.done.emit(ok, total) + + # ── egy app sor widget ─────────────────────────────────────────────────── + class Row(QFrame): + def __init__(self, app, installed): + super().__init__() + self.app = app + self.installed = installed + self.is_message = app.get("type") == "message" + self.setObjectName("Row") + lay = QHBoxLayout(self) + lay.setContentsMargins(12, 9, 12, 9) + lay.setSpacing(10) + + self.cb = QCheckBox() + if self.is_message: + self.cb.setVisible(False) + self.cb.setEnabled(False) + lay.addWidget(self.cb) + + mid = QVBoxLayout() + mid.setSpacing(2) + top = QLabel(app["name"]) + top.setStyleSheet(f"font-weight:600; font-size:14px; color:{C['text']};") + sub = QLabel(f"{app['category']} · {TYPE_LABEL.get(app['type'], app['type'])}" + + (f" · {app['description']}" if app["description"] else "")) + sub.setStyleSheet(f"color:{C['text_dim']}; font-size:12px;") + mid.addWidget(top) + mid.addWidget(sub) + lay.addLayout(mid, 1) + + if self.is_message: + status = QLabel("info") + status.setStyleSheet(f"color:{C['accent_hi']}; font-size:12px;") + else: + status = QLabel("telepítve" if installed else "nincs telepítve") + status.setStyleSheet( + f"color:{C['accent_hi'] if installed else C['text_mut']}; font-size:12px;") + lay.addWidget(status) + + # sorra kattintás is váltja a checkboxot (message tipusnal dialogot mutat) + self.setCursor(Qt.CursorShape.PointingHandCursor) + + def mousePressEvent(self, e): + if e.button() == Qt.MouseButton.LeftButton: + if self.is_message: + QMessageBox.information( + self, self.app["name"], self.app.get("message", "")) + else: + self.cb.toggle() + super().mousePressEvent(e) + + # ── backup worker ──────────────────────────────────────────────────────── + class BackupWorker(QThread): + log = pyqtSignal(str) + progress = pyqtSignal(int) + done = pyqtSignal(bool, str) + + def __init__(self, paths, dest, restore=False, password=""): + super().__init__() + self.paths = paths # [(label, abs_path, requires_root), ...] + self.dest = dest # str: .tar.gz útvonal + self.restore = restore + self.password = password + + def _sudo_run(self, cmd, timeout=60): + if os.geteuid() == 0: + return subprocess.run(cmd, capture_output=True, timeout=timeout) + return subprocess.run( + ["sudo", "-S", *cmd], input=(self.password + "\n").encode(), + capture_output=True, timeout=timeout, + ) + + def _backup_root_item(self, tf, p): + """Root-vedett fajl/konyvtar hozzaadasa sudo tar streammel.""" + import io + import tarfile + parent = str(Path(p).parent) + name = Path(p).name + proc = self._sudo_run(["tar", "-cf", "-", "-C", parent, name], timeout=120) + if proc.returncode != 0: + raise RuntimeError(proc.stderr.decode(errors="replace").strip()[:200] or "sudo tar sikertelen") + with tarfile.open(fileobj=io.BytesIO(proc.stdout)) as inner: + for member in inner.getmembers(): + f = inner.extractfile(member) if member.isfile() else None + tf.addfile(member, f) + + def _restore_root_item(self, tf, member, dest_path): + """Egy tar tagot kozvetlenul sudo tar extract-tel allit vissza, hogy + az eredeti tulajdonos/jog megmaradjon - egy nem-privilegizalt + kibontas (pl. sudo cp egy user-tulajdonu temp fajlbol) elveszitene + az eredeti (pl. root:root) tulajdonost. + + A jelszot SUDO_ASKPASS-szal adjuk at, NEM a "sudo -S" stdin + trukkel, mert itt a tar extract sajat binaris payload-ja is a + stdin-en jon - ha a jelszo ugyanoda irodna, sudo tobbet olvasna + ki a bufferelt read()-jevel mint a jelszo sora, es osszekeveredne + a tar adattal ("not a tar archive" hiba). + """ + import io + import shlex + import tarfile + import tempfile + + fileobj = tf.extractfile(member) if member.isfile() else None + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as mini: + mini.addfile(member, fileobj) + + dest_parent = str(Path(dest_path).parent) + self._sudo_run(["mkdir", "-p", dest_parent], timeout=15) + + if os.geteuid() == 0: + proc = subprocess.run( + ["tar", "-xf", "-", "-C", dest_parent], + input=buf.getvalue(), capture_output=True, timeout=60, + ) + else: + fd, askpass = tempfile.mkstemp(prefix="raveos-askpass-", suffix=".sh") + try: + with os.fdopen(fd, "w") as f: + f.write(f"#!/bin/sh\nprintf '%s\\n' {shlex.quote(self.password)}\n") + os.chmod(askpass, 0o700) + env = os.environ.copy() + env["SUDO_ASKPASS"] = askpass + proc = subprocess.run( + ["sudo", "-A", "tar", "-xf", "-", "-C", dest_parent], + input=buf.getvalue(), capture_output=True, timeout=60, env=env, + ) + finally: + os.unlink(askpass) + + if proc.returncode != 0: + raise RuntimeError( + proc.stderr.decode(errors="replace").strip()[:200] or "sudo tar extract sikertelen") + + def run(self): + import tarfile + total = len(self.paths) + if not self.restore: + try: + with tarfile.open(self.dest, "w:gz") as tf: + for i, (label, p, requires_root) in enumerate(self.paths): + self.log.emit(f"+ {label} ({p})") + try: + if requires_root: + self._backup_root_item(tf, p) + elif Path(p).exists(): + tf.add(p, arcname=Path(p).name) + else: + self.log.emit(f" [kihagyva, nem létezik]") + except Exception as exc: + self.log.emit(f" [HIBA, kihagyva: {exc}]") + self.progress.emit(int((i + 1) / total * 100)) + self.done.emit(True, "Mentés kész.") + except Exception as exc: + self.done.emit(False, str(exc)) + else: + try: + root_dest = { + Path(p).name: p for _, p, requires_root in self.paths if requires_root + } + home_base = str(Path(self.paths[0][1]).parent) + with tarfile.open(self.dest, "r:gz") as tf: + total_members = len(tf.getmembers()) + for i, member in enumerate(tf.getmembers()): + self.log.emit(f"< {member.name}") + top = member.name.split("/")[0] + try: + if top in root_dest: + self._restore_root_item(tf, member, root_dest[top]) + else: + tf.extract(member, path=home_base, filter="data") + except Exception as exc: + self.log.emit(f" [HIBA, kihagyva: {exc}]") + self.progress.emit(int((i + 1) / total_members * 100)) + self.done.emit(True, "Visszaállítás kész.") + except Exception as exc: + self.done.emit(False, str(exc)) + + # ── fő ablak ───────────────────────────────────────────────────────────── + class Main(QMainWindow): + def __init__(self, catalog): + super().__init__() + from PyQt6.QtWidgets import QStackedWidget + self.catalog = catalog + self.idx = installed_index() + self.rows = [] + self.worker = None + self.backup_worker = None + self.backup_checkboxes = {} # item_id -> (QCheckBox, abs_path, label, requires_root) + self._sudo_pw = "" + self._sudo_pw_ts = 0.0 # timestamp: mikor lett megjegyezve + + self.setWindowTitle(APP_NAME) + self.resize(1060, 820) + + root = QWidget() + self.setCentralWidget(root) + outer = QVBoxLayout(root) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + + # ── Header ─────────────────────────────────────────────────────── + header = QFrame() + header.setObjectName("Header") + hl = QHBoxLayout(header) + hl.setContentsMargins(20, 14, 20, 14) + wm = QHBoxLayout() + wm.setSpacing(0) + rave = QLabel("RAVE") + rave.setFont(display_font(26)) + rave.setStyleSheet(f"color:{C['text']};") + os_ = QLabel("OS") + os_.setFont(display_font(26)) + os_.setStyleSheet(f"color:{C['accent']};") + wm.addWidget(rave) + wm.addWidget(os_) + self.header_sep = QLabel(" · APP INSTALLER") + self.header_sep.setFont(display_font(15)) + self.header_sep.setStyleSheet(f"color:{C['text_dim']};") + wm.addWidget(self.header_sep) + hl.addLayout(wm) + hl.addStretch(1) + self.btn_nav = QPushButton("Backup / Restore") + self.btn_nav.clicked.connect(self._go_backup) + hl.addWidget(self.btn_nav) + hl.addSpacing(12) + ver = QLabel(f"v{APP_VERSION}") + ver.setStyleSheet(f"color:{C['text_mut']};") + hl.addWidget(ver) + outer.addWidget(header) + + # ── Stack ───────────────────────────────────────────────────────── + self.stack = QStackedWidget() + outer.addWidget(self.stack, 1) + + # ── Oldal 0: Apps ───────────────────────────────────────────────── + apps_page = QWidget() + bl = QVBoxLayout(apps_page) + bl.setContentsMargins(20, 16, 20, 16) + bl.setSpacing(12) + + frow = QHBoxLayout() + frow.setSpacing(8) + self.tab_all = QPushButton("All") + self.tab_inst = QPushButton("Installed") + for t in (self.tab_all, self.tab_inst): + t.setObjectName("Tab") + t.setCheckable(True) + self.tab_all.setChecked(True) + grp = QButtonGroup(self) + grp.setExclusive(True) + grp.addButton(self.tab_all) + grp.addButton(self.tab_inst) + self.tab_all.clicked.connect(self._refresh_and_filter) + self.tab_inst.clicked.connect(self._refresh_and_filter) + frow.addWidget(self.tab_all) + frow.addWidget(self.tab_inst) + frow.addSpacing(8) + self.search = QLineEdit() + self.search.setPlaceholderText("Keresés…") + self.search.textChanged.connect(self.apply_filter) + frow.addWidget(self.search, 1) + self.cat = QComboBox() + cats = [CATEGORY_ALL] + sorted({a["category"] for a in catalog}) + self.cat.addItems(cats) + self.cat.currentTextChanged.connect(self.apply_filter) + frow.addWidget(self.cat) + bl.addLayout(frow) + + self.scroll = QScrollArea() + self.scroll.setWidgetResizable(True) + self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + host = QFrame() + host.setObjectName("ListHost") + self.list_lay = QVBoxLayout(host) + self.list_lay.setContentsMargins(0, 0, 6, 0) + self.list_lay.setSpacing(7) + self.list_lay.addStretch(1) + self.scroll.setWidget(host) + bl.addWidget(self.scroll, 1) + + self.log = QPlainTextEdit() + self.log.setObjectName("Log") + self.log.setReadOnly(True) + self.log.setFixedHeight(120) + self.log.hide() + bl.addWidget(self.log) + + arow = QHBoxLayout() + arow.setSpacing(8) + self.btn_install = QPushButton("Telepítés") + self.btn_install.setObjectName("Primary") + self.btn_install.clicked.connect(lambda: self.run_action(True)) + self.btn_remove = QPushButton("Eltávolítás") + self.btn_remove.setObjectName("Danger") + self.btn_remove.clicked.connect(lambda: self.run_action(False)) + self.btn_remove.hide() + self.bar = QProgressBar() + self.bar.setValue(0) + arow.addWidget(self.btn_install) + arow.addWidget(self.btn_remove) + arow.addWidget(self.bar, 1) + bl.addLayout(arow) + + self.stack.addWidget(apps_page) + + # ── Oldal 1: Backup / Restore ───────────────────────────────────── + bk_page = QWidget() + bbl = QVBoxLayout(bk_page) + bbl.setContentsMargins(20, 16, 20, 16) + bbl.setSpacing(12) + + bk_scroll = QScrollArea() + bk_scroll.setWidgetResizable(True) + bk_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + bk_host = QWidget() + bk_vl = QVBoxLayout(bk_host) + bk_vl.setContentsMargins(0, 0, 6, 0) + bk_vl.setSpacing(14) + + home = str(Path.home()) + for bgrp in load_backup_catalog(): + gl = QLabel(bgrp.get("label", bgrp["id"])) + gl.setStyleSheet(f"font-weight:600; font-size:13px; color:{C['accent']}; background:transparent;") + bk_vl.addWidget(gl) + desc = bgrp.get("description", "") + if desc: + dl = QLabel(desc) + dl.setStyleSheet(f"color:{C['text_dim']}; font-size:11px; background:transparent;") + dl.setWordWrap(True) + bk_vl.addWidget(dl) + for item in bgrp.get("items", []): + brow = QFrame() + brow.setObjectName("Row") + rl = QHBoxLayout(brow) + rl.setContentsMargins(12, 7, 12, 7) + rl.setSpacing(10) + cb = QCheckBox() + cb.setChecked(item.get("default_selected", False)) + abs_path = str(Path(home) / item["path"]) if item.get("scope","home") == "home" else item["path"] + self.backup_checkboxes[item["id"]] = ( + cb, abs_path, item.get("label", item["id"]), + item.get("requires_root", False), + ) + rl.addWidget(cb) + mid = QVBoxLayout() + mid.setSpacing(1) + tl = QLabel(item.get("label", item["id"])) + tl.setStyleSheet(f"font-weight:600; font-size:13px; color:{C['text']}; background:transparent;") + sl = QLabel(abs_path) + sl.setStyleSheet(f"color:{C['text_dim']}; font-size:11px; background:transparent;") + mid.addWidget(tl) + mid.addWidget(sl) + if item.get("note"): + nl = QLabel(item["note"]) + nl.setStyleSheet(f"color:{C['danger_hi']}; font-size:11px; background:transparent;") + mid.addWidget(nl) + rl.addLayout(mid, 1) + if item.get("size_hint"): + hl2 = QLabel(item["size_hint"]) + hl2.setStyleSheet(f"color:{C['text_mut']}; font-size:11px; background:transparent;") + rl.addWidget(hl2) + bk_vl.addWidget(brow) + + bk_vl.addStretch(1) + bk_scroll.setWidget(bk_host) + bbl.addWidget(bk_scroll, 1) + + dest_row = QHBoxLayout() + dest_row.setSpacing(8) + self.dest_edit = QLineEdit() + self.dest_edit.setPlaceholderText("Backup fájl útvonala (.tar.gz)…") + self.dest_edit.setText(str(Path.home() / "raveos-backup.tar.gz")) + browse_btn = QPushButton("Tallóz") + browse_btn.clicked.connect(self._bk_browse) + dest_row.addWidget(self.dest_edit, 1) + dest_row.addWidget(browse_btn) + bbl.addLayout(dest_row) + + self.bk_log = QPlainTextEdit() + self.bk_log.setObjectName("Log") + self.bk_log.setReadOnly(True) + self.bk_log.setFixedHeight(120) + self.bk_log.hide() + bbl.addWidget(self.bk_log) + + bk_arow = QHBoxLayout() + bk_arow.setSpacing(8) + self.btn_bk_save = QPushButton("Mentés") + self.btn_bk_save.setObjectName("Primary") + self.btn_bk_save.clicked.connect(self._do_backup) + self.btn_bk_restore = QPushButton("Visszaállítás") + self.btn_bk_restore.setObjectName("Danger") + self.btn_bk_restore.clicked.connect(self._do_restore) + self.bk_bar = QProgressBar() + self.bk_bar.setValue(0) + bk_arow.addWidget(self.btn_bk_save) + bk_arow.addWidget(self.btn_bk_restore) + bk_arow.addWidget(self.bk_bar, 1) + bbl.addLayout(bk_arow) + + self.stack.addWidget(bk_page) + + self.build_rows() + self.apply_filter() + + # ── frissítés ───────────────────────────────────────────────────────── + def _refresh_and_filter(self): + self.idx = installed_index() + self.build_rows() + self.apply_filter() + + # ── navigáció ───────────────────────────────────────────────────────── + def _go_backup(self): + self.stack.setCurrentIndex(1) + self.header_sep.setText(" · BACKUP / RESTORE") + self.btn_nav.setText("← Vissza") + self.btn_nav.clicked.disconnect() + self.btn_nav.clicked.connect(self._go_apps) + + def _go_apps(self): + self.stack.setCurrentIndex(0) + self.header_sep.setText(" · APP INSTALLER") + self.btn_nav.setText("Backup / Restore") + self.btn_nav.clicked.disconnect() + self.btn_nav.clicked.connect(self._go_backup) + + # ── lista építés ────────────────────────────────────────────────────── + def build_rows(self): + for r in self.rows: + r.setParent(None) + self.rows.clear() + for app in self.catalog: + inst = is_installed(app, self.idx) + row = Row(app, inst) + self.list_lay.insertWidget(self.list_lay.count() - 1, row) + self.rows.append(row) + + def apply_filter(self): + q = self.search.text().strip().lower() + cat = self.cat.currentText() + only_inst = self.tab_inst.isChecked() + self.btn_remove.setVisible(only_inst) + self.btn_install.setVisible(not only_inst) + for r in self.rows: + show = True + if only_inst and not r.installed: + show = False + if not only_inst and r.installed: + show = False + if cat != CATEGORY_ALL and r.app["category"] != cat: + show = False + if q and q not in r.app["name"].lower() \ + and q not in r.app["description"].lower(): + show = False + r.setVisible(show) + + def selected(self): + return [r.app for r in self.rows + if r.cb.isChecked() and r.isVisible() and not r.is_message] + + # ── app akció ───────────────────────────────────────────────────────── + def _ask_password(self) -> str: + """Jelszó dialog — egyszer fut az akció elején. Üres string = megszakítva.""" + err_msg = "" + while True: + dlg = QDialog(self) + dlg.setWindowTitle("Hitelesítés szükséges") + dlg.setFixedWidth(360) + lay = QVBoxLayout(dlg) + lay.setSpacing(10) + lay.setContentsMargins(20, 16, 20, 16) + + lbl = QLabel(err_msg if err_msg else "Sudo jelszó:") + lbl.setStyleSheet( + f"color: {'#e06c6c' if err_msg else C['text_dim']}; font-size: 13px;") + lbl.setWordWrap(True) + lay.addWidget(lbl) + + pw_edit = QLineEdit() + pw_edit.setEchoMode(QLineEdit.EchoMode.Password) + pw_edit.setPlaceholderText("Jelszó…") + pw_edit.setStyleSheet( + f"background: {C['surface']}; color: {C['text']};" + "border: 1px solid #444; border-radius: 4px;" + "padding: 6px 10px; font-size: 13px;") + lay.addWidget(pw_edit) + + btn_row = QHBoxLayout() + btn_ok = QPushButton("OK") + btn_ok.setDefault(True) + btn_cancel = QPushButton("Mégse") + btn_ok.setFixedHeight(32) + btn_cancel.setFixedHeight(32) + btn_ok.setStyleSheet( + f"background: {C['accent']}; color: {C['text']};" + "border-radius: 4px; font-size: 13px;") + btn_cancel.setStyleSheet( + f"background: {C['surface']}; color: {C['text_dim']};" + "border-radius: 4px; font-size: 13px;") + btn_ok.clicked.connect(dlg.accept) + btn_cancel.clicked.connect(dlg.reject) + pw_edit.returnPressed.connect(dlg.accept) + btn_row.addStretch() + btn_row.addWidget(btn_cancel) + btn_row.addWidget(btn_ok) + lay.addLayout(btn_row) + + if not dlg.exec(): + return "" + + pw = pw_edit.text() + if not pw: + err_msg = "A jelszó nem lehet üres." + continue + + try: + chk = subprocess.run( + ["sudo", "-S", "true"], + input=pw + "\n", + capture_output=True, + text=True, + timeout=8, + ) + if chk.returncode == 0: + return pw + err_msg = "Hibás jelszó, próbáld újra." + except Exception: + # Ha az ellenőrzés nem sikerül, elfogadjuk és hagyjuk a parancsot hibázni + return pw + + def run_action(self, install: bool): + apps = self.selected() + if not apps: + self.log.appendPlainText("Nincs kijelölve semmi.") + return + if self.worker and self.worker.isRunning(): + return + steps = plan_commands(apps, install, gui=True) + if not steps: + self.log.appendPlainText("Nincs végrehajtható művelet.") + return + + # Jelszó — 5 percen belül megjegyzett jelszót újra felhasználjuk + import time + if os.geteuid() != 0: + if self._sudo_pw and (time.time() - self._sudo_pw_ts) < 300: + password = self._sudo_pw + else: + password = self._ask_password() + if not password: + return + self._sudo_pw = password + self._sudo_pw_ts = time.time() + else: + password = "" + + self.set_busy(True) + self.bar.setValue(0) + self.log.clear() + self.log.show() + self.log.appendPlainText(("Telepítés" if install else "Eltávolítás") + + f" indul ({len(apps)} app)…") + self.worker = Worker(steps, password) + self.worker.log.connect(self.log.appendPlainText) + self.worker.progress.connect(self.bar.setValue) + self.worker.done.connect(self.on_done) + self.worker.start() + + def on_done(self, ok, total): + self.bar.setValue(100) + self.log.appendPlainText(f"\nKész: {ok}/{total} lépés sikeres.") + self.idx = installed_index() + keep_scroll = self.scroll.verticalScrollBar().value() + self.build_rows() + self.apply_filter() + self.scroll.verticalScrollBar().setValue(keep_scroll) + self.set_busy(False) + + def set_busy(self, busy: bool): + self.btn_install.setEnabled(not busy) + self.btn_remove.setEnabled(not busy) + self.search.setEnabled(not busy) + self.cat.setEnabled(not busy) + + # ── backup akciók ───────────────────────────────────────────────────── + def _bk_browse(self): + path, _ = QFileDialog.getSaveFileName( + self, "Backup fájl", str(Path.home()), "Tar archive (*.tar.gz)") + if path: + if not path.endswith(".tar.gz"): + path += ".tar.gz" + self.dest_edit.setText(path) + + def _bk_selected(self): + return [(label, abs_path, requires_root) + for cb, abs_path, label, requires_root in self.backup_checkboxes.values() + if cb.isChecked()] + + def _bk_password_if_needed(self, paths): + """Jelszo bekerese, ha a kijelolt elemek kozott van requires_root.""" + if os.geteuid() == 0 or not any(rr for _, _, rr in paths): + return "", True + import time + if self._sudo_pw and (time.time() - self._sudo_pw_ts) < 300: + return self._sudo_pw, True + password = self._ask_password() + if not password: + return "", False + self._sudo_pw = password + self._sudo_pw_ts = time.time() + return password, True + + def _bk_set_busy(self, busy): + self.btn_bk_save.setEnabled(not busy) + self.btn_bk_restore.setEnabled(not busy) + self.dest_edit.setEnabled(not busy) + + def _do_backup(self): + paths = self._bk_selected() + if not paths: + self.bk_log.appendPlainText("Nincs kijelölve semmi.") + return + dest = self.dest_edit.text().strip() + if not dest: + self.bk_log.appendPlainText("Add meg a célfájlt.") + return + password, ok = self._bk_password_if_needed(paths) + if not ok: + return + self.bk_log.clear() + self.bk_log.show() + self.bk_log.appendPlainText(f"Mentés indul -> {dest}") + self.bk_bar.setValue(0) + self._bk_set_busy(True) + self.backup_worker = BackupWorker(paths, dest, restore=False, password=password) + self.backup_worker.log.connect(self.bk_log.appendPlainText) + self.backup_worker.progress.connect(self.bk_bar.setValue) + self.backup_worker.done.connect(self._bk_done) + self.backup_worker.start() + + def _do_restore(self): + dest = self.dest_edit.text().strip() + if not dest or not Path(dest).is_file(): + self.bk_log.appendPlainText("Nem létező vagy érvénytelen backup fájl.") + return + paths = self._bk_selected() + if not paths: + self.bk_log.appendPlainText("Jelölj ki legalább egy elemet.") + return + password, ok = self._bk_password_if_needed(paths) + if not ok: + return + self.bk_log.clear() + self.bk_log.show() + self.bk_log.appendPlainText(f"Visszaállítás indul <- {dest}") + self.bk_bar.setValue(0) + self._bk_set_busy(True) + self.backup_worker = BackupWorker(paths, dest, restore=True, password=password) + self.backup_worker.log.connect(self.bk_log.appendPlainText) + self.backup_worker.progress.connect(self.bk_bar.setValue) + self.backup_worker.done.connect(self._bk_done) + self.backup_worker.start() + + def _bk_done(self, ok, msg): + self.bk_bar.setValue(100 if ok else 0) + self.bk_log.appendPlainText(f"\n{'OK' if ok else 'HIBA'}: {msg}") + self._bk_set_busy(False) + + # ── alkalmazás bootstrap ───────────────────────────────────────────────── + if shutil.which("hyprctl"): + subprocess.run( + ["hyprctl", "keyword", "windowrulev2", + f"opacity 1.0 1.0, class:^({APP_ID})$"], + capture_output=True, + ) + + app = QApplication(sys.argv) + app.setApplicationName(APP_NAME) + app.setApplicationDisplayName(APP_NAME) + app.setDesktopFileName(APP_ID) # Wayland app_id -> Hyprland window rule + + try: + catalog = load_catalog() + except RuntimeError as exc: + QMessageBox.critical(None, APP_NAME, str(exc)) + return 1 + + win = Main(catalog) + win.setStyleSheet(build_qss()) + win.show() + return app.exec() + + +# ───────────────────────────────────────────────────────────────────────────── +# CLI rész +# ───────────────────────────────────────────────────────────────────────────── + +def _resolve(names, catalog): + by = {a["name"].lower(): a for a in catalog} + found, missing = [], [] + for n in names: + a = by.get(n.lower()) + (found if a else missing).append(a if a else n) + return found, missing + + +def cli_list(catalog, only_installed=False): + idx = installed_index() + for a in catalog: + inst = is_installed(a, idx) + if only_installed and not inst: + continue + mark = "[x]" if inst else "[ ]" + print(f"{mark} {a['name']:<22} {a['category']:<10} " + f"{TYPE_LABEL.get(a['type'], a['type'])}") + + +def cli_action(names, catalog, install: bool): + found, missing = _resolve(names, catalog) + for m in missing: + print(f"Nem található: {m}") + if not found: + return 1 + steps = plan_commands(found, install, gui=False) + if not steps: + print("Nincs végrehajtható lépés.") + return 1 + + # sudo -S jelszo nelkul a stdin pipe nyitva marad iras/EOF nelkul, + # es a folyamat orokre lefagy - a GUI-nak van jelszo dialogja, a + # CLI-nek eddig nem volt. + password = "" + if os.geteuid() != 0 and any( + len(cmd) >= 2 and cmd[0] == "sudo" and cmd[1] == "-S" for _, cmd in steps + ): + import getpass + password = getpass.getpass("Sudo jelszó: ") + + ok = 0 + for label, cmd in steps: + print(f"\n── {label} ──") + if stream_command(label, cmd, print, password): + ok += 1 + print(f"\nKész: {ok}/{len(steps)} lépés sikeres.") + return 0 if ok == len(steps) else 1 + + +def main(): + parser = argparse.ArgumentParser( + prog=APP_ID, description="RaveOS App Installer (GUI + CLI)") + parser.add_argument("--list", action="store_true", help="elérhető appok listája") + parser.add_argument("--installed", action="store_true", help="csak telepítettek") + parser.add_argument("--install", nargs="+", metavar="APP", help="app(ok) telepítése") + parser.add_argument("--remove", nargs="+", metavar="APP", help="app(ok) eltávolítása") + parser.add_argument("--gui", action="store_true", help="GUI indítása (alapértelmezett)") + parser.add_argument("--version", action="version", version=f"%(prog)s {APP_VERSION}") + args = parser.parse_args() + + cli_mode = args.list or args.installed or args.install or args.remove + if not cli_mode: + # GUI-t user-ként indítsd (Wayland) — root GUI ne! + if os.geteuid() == 0: + print("Figyelem: a GUI-t NE rootként indítsd. " + "Futtasd sima userként, a jogot pkexec kéri majd.") + try: + sys.exit(run_gui()) + except ModuleNotFoundError: + print("PyQt6 nincs telepítve. Telepítsd: sudo pacman -S python-pyqt6") + sys.exit(1) + + try: + catalog = load_catalog() + except RuntimeError as exc: + print(exc) + sys.exit(1) + + if args.list or args.installed: + cli_list(catalog, only_installed=args.installed) + sys.exit(0) + if args.install: + sys.exit(cli_action(args.install, catalog, install=True)) + if args.remove: + sys.exit(cli_action(args.remove, catalog, install=False)) + + +if __name__ == "__main__": + main() |