| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513 |
- # -*- coding: utf-8 -*-
- """
- F6: Installation — ProgressBar, Log, Start/Abbrechen/Deinstallieren.
- Zeigt kontextabhaengige Buttons:
- - Neuinstallation: "Installation starten"
- - Gleiche Version: "Erneut installieren" + "Deinstallieren"
- - Neuere Version: "Update auf {version}" + "Deinstallieren"
- - Aeltere Version: "Downgrade auf {version}" + "Deinstallieren"
- """
- from __future__ import annotations
- import os
- from typing import TYPE_CHECKING
- from textual.containers import Horizontal
- from textual.widgets import Button, Label, RichLog, Static
- from .base import InstallerView
- def _wrap_box_text(text: str, width: int) -> list[str]:
- """Zerlegt einen Text in Zeilen der angegebenen Breite fuer Box-Frames.
- Einfache wort-basierte Umbruchlogik. Worte die laenger als `width`
- sind werden hart umgebrochen.
- """
- words = text.split()
- if not words:
- return [""]
- lines: list[str] = []
- current = ""
- for word in words:
- if not current:
- current = word
- elif len(current) + 1 + len(word) <= width:
- current += " " + word
- else:
- lines.append(current)
- current = word
- if current:
- lines.append(current)
- # Harte Umbrueche fuer ueberlange Worte
- result: list[str] = []
- for line in lines:
- while len(line) > width:
- result.append(line[:width])
- line = line[width:]
- result.append(line)
- return result
- if TYPE_CHECKING:
- from ..detect import ExistingInstallation
- from ..runner import InstallRunner
- class InstallView(InstallerView):
- """Installations-Ansicht (F6)."""
- TITLE = "Installation"
- VIEW_ID = "install"
- MODES = ["server", "client", "standalone", "config"]
- def __init__(self, installer_config, existing: "ExistingInstallation | None" = None, **kwargs) -> None:
- super().__init__(installer_config, **kwargs)
- self._existing = existing
- self._runner: InstallRunner | None = None
- self._running = False
- def compose(self):
- yield Static("── Installation ──", classes="section-header")
- # Status-Info basierend auf bestehender Installation
- yield Label("", id="install-info")
- yield Label("", id="install-status")
- yield Label(self._render_bar(0.0), id="install-progress-bar")
- yield Label("")
- # Buttons — nebeneinander, kontextabhaengig
- from ..detect import InstallState
- has_existing = self._existing and self._existing.found
- with Horizontal(id="install-buttons"):
- if not has_existing:
- yield Button("Installation starten", variant="success", id="btn-start")
- else:
- state = self._existing.state
- if state == InstallState.SAME_VERSION:
- yield Button("Erneut installieren", variant="warning", id="btn-start")
- elif state == InstallState.NEWER_AVAILABLE:
- yield Button(
- f"Update auf {self._config.version}",
- variant="success", id="btn-start",
- )
- elif state == InstallState.OLDER_AVAILABLE:
- yield Button(
- f"Downgrade auf {self._config.version}",
- variant="warning", id="btn-start",
- )
- yield Button("Deinstallieren", variant="error", id="btn-uninstall")
- yield Button("Abbrechen", variant="default", id="btn-cancel", disabled=True)
- yield Label("")
- yield Static("── Log ──", classes="section-header")
- yield RichLog(highlight=True, markup=True, id="install-log", min_width=60)
- async def on_activate(self) -> None:
- """Zeigt Info ueber bestehende Installation."""
- info_label = self.query_one("#install-info", Label)
- from ..detect import InstallState
- if not self._existing or not self._existing.found:
- info_label.update("Keine bestehende Installation erkannt — Neuinstallation")
- return
- ex = self._existing
- lines = [f"Bestehende Installation erkannt:"]
- lines.append(f" Version: {ex.installed_version}")
- lines.append(f" Pfad: {ex.source_path}")
- lines.append(f" Service: {ex.service_name} ({'aktiv' if ex.service_active else 'inaktiv'})")
- if ex.state == InstallState.SAME_VERSION:
- lines.append(f"\n Gleiche Version — Erneut installieren aktualisiert Einstellungen")
- elif ex.state == InstallState.NEWER_AVAILABLE:
- lines.append(f"\n Update verfuegbar: {ex.installed_version} -> {self._config.version}")
- elif ex.state == InstallState.OLDER_AVAILABLE:
- lines.append(f"\n Downgrade: {ex.installed_version} -> {self._config.version}")
- info_label.update("\n".join(lines))
- def _log(self, message: str) -> None:
- """Schreibt eine Nachricht ins Log."""
- try:
- log = self.query_one("#install-log", RichLog)
- log.write(message)
- except Exception:
- pass
- @staticmethod
- def _render_bar(fraction: float, width: int = 40) -> str:
- """Rendert eine klassische ASCII-ProgressBar."""
- pct = int(fraction * 100)
- filled = int(width * fraction)
- bar = "\u2588" * filled + "\u2591" * (width - filled)
- return f"[{bar}] {pct}%"
- def _update_progress(self, fraction: float, step_name: str) -> None:
- """Aktualisiert ProgressBar und Status-Label."""
- try:
- bar_label = self.query_one("#install-progress-bar", Label)
- bar_label.update(self._render_bar(fraction))
- status = self.query_one("#install-status", Label)
- if fraction >= 1.0:
- status.update("Abgeschlossen!")
- else:
- status.update(step_name)
- except Exception:
- pass
- def _set_buttons_running(self, running: bool) -> None:
- """Schaltet Buttons fuer laufende Operation um."""
- for btn_id in ("btn-start", "btn-uninstall"):
- try:
- self.query_one(f"#{btn_id}", Button).disabled = running
- except Exception:
- pass
- try:
- self.query_one("#btn-cancel", Button).disabled = not running
- except Exception:
- pass
- async def on_button_pressed(self, event: Button.Pressed) -> None:
- if event.button.id == "btn-start":
- await self._start_installation()
- elif event.button.id == "btn-uninstall":
- await self._start_uninstall()
- elif event.button.id == "btn-cancel":
- self._cancel()
- async def _start_installation(self) -> None:
- """Startet Installation/Reinstall/Update."""
- # 1. Validierung aller Views — Fehler sammeln, nicht springen
- app = self.app
- errors: list[tuple[str, str]] = []
- for view in getattr(app, "_views", []):
- try:
- error = view.validate()
- if error:
- errors.append((view.TITLE, error))
- except Exception as e:
- errors.append((view.TITLE, f"Validierung fehlgeschlagen: {e}"))
- if errors:
- self._log("Validierungsfehler:\n")
- for tab_name, error_msg in errors:
- self._log(f" [{tab_name}] {error_msg}")
- self._log(f"\nBitte die markierten Tabs korrigieren.")
- return
- # 2. Preflight-Check
- try:
- from ..preflight import run_preflight, format_preflight
- self._log("Preflight-Check...")
- self._log("")
- preflight = run_preflight(self._config)
- self._log(format_preflight(preflight))
- self._log("")
- if not preflight.passed:
- self._log("Abgebrochen — Preflight-Check fehlgeschlagen.")
- return
- if preflight.warnings:
- self._log("Warnungen vorhanden — wird fortgesetzt.\n")
- except Exception as e:
- self._log(f"Preflight-Fehler: {e}")
- return
- # 3. Service stoppen falls aktiv
- if self._existing and self._existing.service_active:
- self._log("Stoppe laufenden Service...")
- from ..runner import InstallRunner
- temp_runner = InstallRunner(self._config, self._log, self._update_progress)
- await temp_runner._run_sudo(
- "systemctl", "stop", f"{self._existing.service_name}.service",
- )
- self._log(" -> Service gestoppt\n")
- # 4. Runner starten
- self._set_buttons_running(True)
- self._running = True
- from ..runner import InstallRunner
- self._runner = InstallRunner(
- config=self._config,
- log_callback=self._log,
- progress_callback=self._update_progress,
- )
- success = await self._runner.run()
- self._running = False
- self._set_buttons_running(False)
- if success:
- # Service starten falls gewuenscht — aber nur wenn kein
- # Reboot noetig ist. Bei Netzwerk-Aenderungen muss der User
- # sowieso rebooten, der Service startet dann automatisch
- # via systemd (autostart).
- if (
- self._config.install_service
- and self._config.autostart
- and not getattr(self._runner, "reboot_required", False)
- ):
- self._log("\nStarte Service...")
- await self._runner._run_sudo(
- "systemctl", "start",
- f"trixy-{self._config.mode}.service",
- )
- self._log(" -> Service gestartet")
- self._log("\nInstallation erfolgreich!")
- # Reboot-Warnung wenn Netzwerk-Aenderungen vorgenommen wurden
- if getattr(self._runner, "reboot_required", False):
- self._log("")
- self._log("╔══════════════════════════════════════════════════════════╗")
- self._log("║ !!! REBOOT ERFORDERLICH !!! ║")
- self._log("╠══════════════════════════════════════════════════════════╣")
- self._log("║ ║")
- self._log("║ Netzwerk-Aenderungen wurden geschrieben, sind aber ║")
- self._log("║ noch NICHT aktiv. Erst nach einem Reboot uebernimmt ║")
- self._log("║ der Pi die neue Konfiguration. ║")
- self._log("║ ║")
- for reason in getattr(self._runner, "reboot_reasons", []):
- # Text auf 54 Zeichen (Content-Breite zwischen ║ ... ║)
- # zerlegen damit der Rahmen nicht bricht
- for line in _wrap_box_text(f" - {reason}", width=54):
- self._log(f"║ {line:<54} ║")
- self._log("║ ║")
- self._log("║ WICHTIG: Wenn du gerade ueber SSH verbunden bist, ║")
- self._log("║ wird die Verbindung nach dem Reboot zum Trixy-Netz ║")
- self._log("║ wechseln. Die aktuelle Session bricht dann ab. ║")
- self._log("║ ║")
- self._log("║ Befehl zum Reboot: ║")
- self._log("║ ║")
- self._log("║ sudo reboot ║")
- self._log("║ ║")
- self._log("╚══════════════════════════════════════════════════════════╝")
- self._log("")
- else:
- self._log("\nInstallation fehlgeschlagen oder abgebrochen.")
- async def _start_uninstall(self) -> None:
- """Deinstalliert Trixy komplett inkl. aller System-Konfigurationen."""
- if not self._existing or not self._existing.found:
- self._log("Keine Installation zum Deinstallieren gefunden.")
- return
- self._log("=== Trixy Deinstallation ===\n")
- self._set_buttons_running(True)
- self._running = True
- from ..runner import InstallRunner
- self._runner = InstallRunner(self._config, self._log, self._update_progress)
- runner = self._runner
- ex = self._existing
- total_steps = 6
- step = 0
- # 1. Service stoppen und entfernen
- step += 1
- self._update_progress(step / total_steps, "Service entfernen")
- self._log(f"[{step}/{total_steps}] Service stoppen und entfernen...")
- if ex.service_active:
- await runner._run_sudo("systemctl", "stop", f"{ex.service_name}.service")
- self._log(" -> Service gestoppt")
- service_file = f"/etc/systemd/system/{ex.service_name}.service"
- if os.path.isfile(service_file):
- await runner._run_sudo(
- "systemctl", "disable", f"{ex.service_name}.service", check=False,
- )
- await runner._run_sudo("rm", "-f", service_file)
- await runner._run_sudo("systemctl", "daemon-reload")
- self._log(" -> Service-Datei entfernt")
- else:
- self._log(" -> Keine Service-Datei vorhanden")
- if self._cancelled():
- return
- # 2. WiFi-Hotspot entfernen (hostapd + dnsmasq)
- step += 1
- self._update_progress(step / total_steps, "WiFi-Konfiguration")
- self._log(f"\n[{step}/{total_steps}] WiFi-Hotspot Konfiguration entfernen...")
- if ex.wifi_enabled:
- await runner._run_sudo("systemctl", "stop", "hostapd", check=False)
- await runner._run_sudo("systemctl", "disable", "hostapd", check=False)
- if os.path.isfile("/etc/hostapd/hostapd.conf"):
- await runner._run_sudo("rm", "-f", "/etc/hostapd/hostapd.conf")
- self._log(" -> /etc/hostapd/hostapd.conf entfernt")
- await runner._run_sudo("systemctl", "stop", "dnsmasq", check=False)
- await runner._run_sudo("systemctl", "disable", "dnsmasq", check=False)
- if os.path.isfile("/etc/dnsmasq.conf.orig"):
- await runner._run_sudo(
- "mv", "/etc/dnsmasq.conf.orig", "/etc/dnsmasq.conf",
- )
- self._log(" -> /etc/dnsmasq.conf wiederhergestellt (Backup)")
- elif os.path.isfile("/etc/dnsmasq.conf"):
- await runner._run_sudo("rm", "-f", "/etc/dnsmasq.conf")
- self._log(" -> /etc/dnsmasq.conf entfernt")
- self._log(" -> hostapd + dnsmasq deaktiviert")
- else:
- self._log(" -> Kein WiFi-Hotspot konfiguriert")
- if self._cancelled():
- return
- # 3. Netzwerk-Konfiguration bereinigen
- step += 1
- self._update_progress(step / total_steps, "Netzwerk-Konfiguration")
- self._log(f"\n[{step}/{total_steps}] Netzwerk-Konfiguration bereinigen...")
- if ex.wifi_enabled:
- # Alte dhcpcd-Eintraege (Pre-Bookworm) falls vorhanden
- dhcpcd = "/etc/dhcpcd.conf"
- if os.path.isfile(dhcpcd):
- await runner._run_sudo(
- "sed", "-i", "/^# Trixy AP/,/^$/d", dhcpcd,
- )
- self._log(" -> Trixy-AP Eintrag aus /etc/dhcpcd.conf entfernt")
- # Bookworm: systemd-networkd Profil fuer wlan0
- networkd_file = "/etc/systemd/network/10-trixy-wlan0.network"
- if os.path.isfile(networkd_file):
- await runner._run_sudo("rm", "-f", networkd_file)
- self._log(f" -> {networkd_file} entfernt")
- # Bookworm: NetworkManager Unmanaged-Override
- nm_override = "/etc/NetworkManager/conf.d/99-trixy-wlan0-unmanaged.conf"
- if os.path.isfile(nm_override):
- await runner._run_sudo("rm", "-f", nm_override)
- self._log(f" -> {nm_override} entfernt")
- # NetworkManager reload damit wlan0 wieder managed wird
- await runner._run_sudo(
- "systemctl", "reload", "NetworkManager", check=False,
- )
- # dnsmasq Race-Condition-Fix
- dnsmasq_override = "/etc/systemd/system/dnsmasq.service.d/trixy-wait-wlan0.conf"
- if os.path.isfile(dnsmasq_override):
- await runner._run_sudo("rm", "-f", dnsmasq_override)
- await runner._run_sudo(
- "rmdir", "/etc/systemd/system/dnsmasq.service.d",
- check=False,
- )
- self._log(f" -> {dnsmasq_override} entfernt")
- # IP-Forwarding Drop-in
- ipfwd = "/etc/sysctl.d/99-trixy-ipforward.conf"
- if os.path.isfile(ipfwd):
- await runner._run_sudo("rm", "-f", ipfwd)
- self._log(f" -> {ipfwd} entfernt")
- await runner._run_sudo("systemctl", "daemon-reload", check=False)
- await runner._run_sudo(
- "iptables", "-t", "nat", "-D", "POSTROUTING",
- "-o", "eth0", "-j", "MASQUERADE",
- check=False,
- )
- if os.path.isfile("/etc/iptables.ipv4.nat"):
- await runner._run_sudo("rm", "-f", "/etc/iptables.ipv4.nat")
- self._log(" -> NAT-Regeln entfernt")
- await runner._run_sudo(
- "sed", "-i",
- "s/^net.ipv4.ip_forward=1/#net.ipv4.ip_forward=1/",
- "/etc/sysctl.conf",
- check=False,
- )
- await runner._run_sudo(
- "sysctl", "-w", "net.ipv4.ip_forward=0", check=False,
- )
- self._log(" -> IP-Forwarding deaktiviert")
- else:
- self._log(" -> Keine Netzwerk-Aenderungen vorhanden")
- if self._cancelled():
- return
- # 4. Hostname zuruecksetzen
- step += 1
- self._update_progress(step / total_steps, "Hostname")
- self._log(f"\n[{step}/{total_steps}] Hostname...")
- current_hostname = ""
- try:
- current_hostname = os.uname().nodename
- except Exception:
- pass
- if current_hostname:
- default_names = {"trixyone", "trixy", "trixysat", "trixycfg"}
- if current_hostname.lower() in default_names:
- await runner._run_sudo("hostnamectl", "set-hostname", "raspberrypi")
- self._log(" -> Hostname auf 'raspberrypi' zurueckgesetzt")
- else:
- self._log(f" -> Hostname '{current_hostname}' beibehalten (kein Trixy-Name)")
- else:
- self._log(f" -> Hostname unveraendert")
- if self._cancelled():
- return
- # 5. Installationsverzeichnis entfernen
- step += 1
- self._update_progress(step / total_steps, "Dateien entfernen")
- self._log(f"\n[{step}/{total_steps}] Installationsverzeichnis entfernen...")
- install_path = ex.install_path
- if install_path and os.path.isdir(install_path):
- self._log(f" Entferne {install_path}...")
- await runner._run_sudo("rm", "-rf", install_path)
- self._log(" -> Verzeichnis entfernt")
- else:
- self._log(f" -> Verzeichnis nicht gefunden: {install_path}")
- if self._cancelled():
- return
- # 6. Zusammenfassung
- step += 1
- self._update_progress(1.0, "Fertig")
- self._log(f"\n[{step}/{total_steps}] Deinstallation abgeschlossen:")
- self._log(f" Service: {ex.service_name} entfernt")
- self._log(f" Verzeichnis: {install_path} entfernt")
- if ex.wifi_enabled:
- self._log(f" WiFi-Hotspot: hostapd + dnsmasq deaktiviert")
- self._log(f" Netzwerk: NAT + IP-Forwarding entfernt")
- self._log(f"\n Hinweis: apt-Pakete (ffmpeg, portaudio, etc.) wurden")
- self._log(f" nicht entfernt — diese werden ggf. von anderen")
- self._log(f" Programmen benoetigt.")
- self._log("\n=== Deinstallation abgeschlossen ===")
- self._running = False
- self._set_buttons_running(False)
- def _cancelled(self) -> bool:
- """Prueft ob abgebrochen wurde."""
- if self._runner and self._runner._cancelled:
- self._log("\n[ABBRUCH] Deinstallation abgebrochen.")
- self._running = False
- self._set_buttons_running(False)
- return True
- return False
- def _cancel(self) -> None:
- """Bricht die laufende Operation ab."""
- if self._runner:
- self._runner.cancel()
- self._log("\nAbbruch angefordert...")
|