install_view.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. # -*- coding: utf-8 -*-
  2. """
  3. F6: Installation — ProgressBar, Log, Start/Abbrechen/Deinstallieren.
  4. Zeigt kontextabhaengige Buttons:
  5. - Neuinstallation: "Installation starten"
  6. - Gleiche Version: "Erneut installieren" + "Deinstallieren"
  7. - Neuere Version: "Update auf {version}" + "Deinstallieren"
  8. - Aeltere Version: "Downgrade auf {version}" + "Deinstallieren"
  9. """
  10. from __future__ import annotations
  11. import os
  12. from typing import TYPE_CHECKING
  13. from textual.containers import Horizontal
  14. from textual.widgets import Button, Label, RichLog, Static
  15. from .base import InstallerView
  16. if TYPE_CHECKING:
  17. from ..detect import ExistingInstallation
  18. from ..runner import InstallRunner
  19. class InstallView(InstallerView):
  20. """Installations-Ansicht (F6)."""
  21. TITLE = "Installation"
  22. VIEW_ID = "install"
  23. MODES = ["server", "client", "standalone", "config"]
  24. def __init__(self, installer_config, existing: "ExistingInstallation | None" = None, **kwargs) -> None:
  25. super().__init__(installer_config, **kwargs)
  26. self._existing = existing
  27. self._runner: InstallRunner | None = None
  28. self._running = False
  29. def compose(self):
  30. yield Static("── Installation ──", classes="section-header")
  31. # Status-Info basierend auf bestehender Installation
  32. yield Label("", id="install-info")
  33. yield Label("", id="install-status")
  34. yield Label(self._render_bar(0.0), id="install-progress-bar")
  35. yield Label("")
  36. # Buttons — nebeneinander, kontextabhaengig
  37. from ..detect import InstallState
  38. has_existing = self._existing and self._existing.found
  39. with Horizontal(id="install-buttons"):
  40. if not has_existing:
  41. yield Button("Installation starten", variant="success", id="btn-start")
  42. else:
  43. state = self._existing.state
  44. if state == InstallState.SAME_VERSION:
  45. yield Button("Erneut installieren", variant="warning", id="btn-start")
  46. elif state == InstallState.NEWER_AVAILABLE:
  47. yield Button(
  48. f"Update auf {self._config.version}",
  49. variant="success", id="btn-start",
  50. )
  51. elif state == InstallState.OLDER_AVAILABLE:
  52. yield Button(
  53. f"Downgrade auf {self._config.version}",
  54. variant="warning", id="btn-start",
  55. )
  56. yield Button("Deinstallieren", variant="error", id="btn-uninstall")
  57. yield Button("Abbrechen", variant="default", id="btn-cancel", disabled=True)
  58. yield Label("")
  59. yield Static("── Log ──", classes="section-header")
  60. yield RichLog(highlight=True, markup=True, id="install-log", min_width=60)
  61. async def on_activate(self) -> None:
  62. """Zeigt Info ueber bestehende Installation."""
  63. info_label = self.query_one("#install-info", Label)
  64. from ..detect import InstallState
  65. if not self._existing or not self._existing.found:
  66. info_label.update("Keine bestehende Installation erkannt — Neuinstallation")
  67. return
  68. ex = self._existing
  69. lines = [f"Bestehende Installation erkannt:"]
  70. lines.append(f" Version: {ex.installed_version}")
  71. lines.append(f" Pfad: {ex.source_path}")
  72. lines.append(f" Service: {ex.service_name} ({'aktiv' if ex.service_active else 'inaktiv'})")
  73. if ex.state == InstallState.SAME_VERSION:
  74. lines.append(f"\n Gleiche Version — Erneut installieren aktualisiert Einstellungen")
  75. elif ex.state == InstallState.NEWER_AVAILABLE:
  76. lines.append(f"\n Update verfuegbar: {ex.installed_version} -> {self._config.version}")
  77. elif ex.state == InstallState.OLDER_AVAILABLE:
  78. lines.append(f"\n Downgrade: {ex.installed_version} -> {self._config.version}")
  79. info_label.update("\n".join(lines))
  80. def _log(self, message: str) -> None:
  81. """Schreibt eine Nachricht ins Log."""
  82. try:
  83. log = self.query_one("#install-log", RichLog)
  84. log.write(message)
  85. except Exception:
  86. pass
  87. @staticmethod
  88. def _render_bar(fraction: float, width: int = 40) -> str:
  89. """Rendert eine klassische ASCII-ProgressBar."""
  90. pct = int(fraction * 100)
  91. filled = int(width * fraction)
  92. bar = "\u2588" * filled + "\u2591" * (width - filled)
  93. return f"[{bar}] {pct}%"
  94. def _update_progress(self, fraction: float, step_name: str) -> None:
  95. """Aktualisiert ProgressBar und Status-Label."""
  96. try:
  97. bar_label = self.query_one("#install-progress-bar", Label)
  98. bar_label.update(self._render_bar(fraction))
  99. status = self.query_one("#install-status", Label)
  100. if fraction >= 1.0:
  101. status.update("Abgeschlossen!")
  102. else:
  103. status.update(step_name)
  104. except Exception:
  105. pass
  106. def _set_buttons_running(self, running: bool) -> None:
  107. """Schaltet Buttons fuer laufende Operation um."""
  108. for btn_id in ("btn-start", "btn-uninstall"):
  109. try:
  110. self.query_one(f"#{btn_id}", Button).disabled = running
  111. except Exception:
  112. pass
  113. try:
  114. self.query_one("#btn-cancel", Button).disabled = not running
  115. except Exception:
  116. pass
  117. async def on_button_pressed(self, event: Button.Pressed) -> None:
  118. if event.button.id == "btn-start":
  119. await self._start_installation()
  120. elif event.button.id == "btn-uninstall":
  121. await self._start_uninstall()
  122. elif event.button.id == "btn-cancel":
  123. self._cancel()
  124. async def _start_installation(self) -> None:
  125. """Startet Installation/Reinstall/Update."""
  126. # 1. Validierung aller Views — Fehler sammeln, nicht springen
  127. app = self.app
  128. errors: list[tuple[str, str]] = []
  129. for view in getattr(app, "_views", []):
  130. try:
  131. error = view.validate()
  132. if error:
  133. errors.append((view.TITLE, error))
  134. except Exception as e:
  135. errors.append((view.TITLE, f"Validierung fehlgeschlagen: {e}"))
  136. if errors:
  137. self._log("Validierungsfehler:\n")
  138. for tab_name, error_msg in errors:
  139. self._log(f" [{tab_name}] {error_msg}")
  140. self._log(f"\nBitte die markierten Tabs korrigieren.")
  141. return
  142. # 2. Preflight-Check
  143. try:
  144. from ..preflight import run_preflight, format_preflight
  145. self._log("Preflight-Check...")
  146. self._log("")
  147. preflight = run_preflight(self._config)
  148. self._log(format_preflight(preflight))
  149. self._log("")
  150. if not preflight.passed:
  151. self._log("Abgebrochen — Preflight-Check fehlgeschlagen.")
  152. return
  153. if preflight.warnings:
  154. self._log("Warnungen vorhanden — wird fortgesetzt.\n")
  155. except Exception as e:
  156. self._log(f"Preflight-Fehler: {e}")
  157. return
  158. # 3. Service stoppen falls aktiv
  159. if self._existing and self._existing.service_active:
  160. self._log("Stoppe laufenden Service...")
  161. from ..runner import InstallRunner
  162. temp_runner = InstallRunner(self._config, self._log, self._update_progress)
  163. await temp_runner._run_sudo(
  164. "systemctl", "stop", f"{self._existing.service_name}.service",
  165. )
  166. self._log(" -> Service gestoppt\n")
  167. # 4. Runner starten
  168. self._set_buttons_running(True)
  169. self._running = True
  170. from ..runner import InstallRunner
  171. self._runner = InstallRunner(
  172. config=self._config,
  173. log_callback=self._log,
  174. progress_callback=self._update_progress,
  175. )
  176. success = await self._runner.run()
  177. self._running = False
  178. self._set_buttons_running(False)
  179. if success:
  180. # Service starten falls gewuenscht
  181. if self._config.install_service and self._config.autostart:
  182. self._log("\nStarte Service...")
  183. await self._runner._run_sudo(
  184. "systemctl", "start",
  185. f"trixy-{self._config.mode}.service",
  186. )
  187. self._log(" -> Service gestartet")
  188. self._log("\nInstallation erfolgreich!")
  189. else:
  190. self._log("\nInstallation fehlgeschlagen oder abgebrochen.")
  191. async def _start_uninstall(self) -> None:
  192. """Deinstalliert Trixy komplett inkl. aller System-Konfigurationen."""
  193. if not self._existing or not self._existing.found:
  194. self._log("Keine Installation zum Deinstallieren gefunden.")
  195. return
  196. self._log("=== Trixy Deinstallation ===\n")
  197. self._set_buttons_running(True)
  198. self._running = True
  199. from ..runner import InstallRunner
  200. self._runner = InstallRunner(self._config, self._log, self._update_progress)
  201. runner = self._runner
  202. ex = self._existing
  203. total_steps = 6
  204. step = 0
  205. # 1. Service stoppen und entfernen
  206. step += 1
  207. self._update_progress(step / total_steps, "Service entfernen")
  208. self._log(f"[{step}/{total_steps}] Service stoppen und entfernen...")
  209. if ex.service_active:
  210. await runner._run_sudo("systemctl", "stop", f"{ex.service_name}.service")
  211. self._log(" -> Service gestoppt")
  212. service_file = f"/etc/systemd/system/{ex.service_name}.service"
  213. if os.path.isfile(service_file):
  214. await runner._run_sudo(
  215. "systemctl", "disable", f"{ex.service_name}.service", check=False,
  216. )
  217. await runner._run_sudo("rm", "-f", service_file)
  218. await runner._run_sudo("systemctl", "daemon-reload")
  219. self._log(" -> Service-Datei entfernt")
  220. else:
  221. self._log(" -> Keine Service-Datei vorhanden")
  222. if self._cancelled():
  223. return
  224. # 2. WiFi-Hotspot entfernen (hostapd + dnsmasq)
  225. step += 1
  226. self._update_progress(step / total_steps, "WiFi-Konfiguration")
  227. self._log(f"\n[{step}/{total_steps}] WiFi-Hotspot Konfiguration entfernen...")
  228. if ex.wifi_enabled:
  229. await runner._run_sudo("systemctl", "stop", "hostapd", check=False)
  230. await runner._run_sudo("systemctl", "disable", "hostapd", check=False)
  231. if os.path.isfile("/etc/hostapd/hostapd.conf"):
  232. await runner._run_sudo("rm", "-f", "/etc/hostapd/hostapd.conf")
  233. self._log(" -> /etc/hostapd/hostapd.conf entfernt")
  234. await runner._run_sudo("systemctl", "stop", "dnsmasq", check=False)
  235. await runner._run_sudo("systemctl", "disable", "dnsmasq", check=False)
  236. if os.path.isfile("/etc/dnsmasq.conf.orig"):
  237. await runner._run_sudo(
  238. "mv", "/etc/dnsmasq.conf.orig", "/etc/dnsmasq.conf",
  239. )
  240. self._log(" -> /etc/dnsmasq.conf wiederhergestellt (Backup)")
  241. elif os.path.isfile("/etc/dnsmasq.conf"):
  242. await runner._run_sudo("rm", "-f", "/etc/dnsmasq.conf")
  243. self._log(" -> /etc/dnsmasq.conf entfernt")
  244. self._log(" -> hostapd + dnsmasq deaktiviert")
  245. else:
  246. self._log(" -> Kein WiFi-Hotspot konfiguriert")
  247. if self._cancelled():
  248. return
  249. # 3. Netzwerk-Konfiguration bereinigen
  250. step += 1
  251. self._update_progress(step / total_steps, "Netzwerk-Konfiguration")
  252. self._log(f"\n[{step}/{total_steps}] Netzwerk-Konfiguration bereinigen...")
  253. if ex.wifi_enabled:
  254. dhcpcd = "/etc/dhcpcd.conf"
  255. if os.path.isfile(dhcpcd):
  256. await runner._run_sudo(
  257. "sed", "-i", "/^# Trixy AP/,/^$/d", dhcpcd,
  258. )
  259. self._log(" -> Trixy-AP Eintrag aus /etc/dhcpcd.conf entfernt")
  260. await runner._run_sudo(
  261. "iptables", "-t", "nat", "-D", "POSTROUTING",
  262. "-o", "eth0", "-j", "MASQUERADE",
  263. check=False,
  264. )
  265. if os.path.isfile("/etc/iptables.ipv4.nat"):
  266. await runner._run_sudo("rm", "-f", "/etc/iptables.ipv4.nat")
  267. self._log(" -> NAT-Regeln entfernt")
  268. await runner._run_sudo(
  269. "sed", "-i",
  270. "s/^net.ipv4.ip_forward=1/#net.ipv4.ip_forward=1/",
  271. "/etc/sysctl.conf",
  272. check=False,
  273. )
  274. await runner._run_sudo(
  275. "sysctl", "-w", "net.ipv4.ip_forward=0", check=False,
  276. )
  277. self._log(" -> IP-Forwarding deaktiviert")
  278. else:
  279. self._log(" -> Keine Netzwerk-Aenderungen vorhanden")
  280. if self._cancelled():
  281. return
  282. # 4. Hostname zuruecksetzen
  283. step += 1
  284. self._update_progress(step / total_steps, "Hostname")
  285. self._log(f"\n[{step}/{total_steps}] Hostname...")
  286. current_hostname = ""
  287. try:
  288. current_hostname = os.uname().nodename
  289. except Exception:
  290. pass
  291. if current_hostname:
  292. default_names = {"trixyone", "trixy", "trixysat", "trixycfg"}
  293. if current_hostname.lower() in default_names:
  294. await runner._run_sudo("hostnamectl", "set-hostname", "raspberrypi")
  295. self._log(" -> Hostname auf 'raspberrypi' zurueckgesetzt")
  296. else:
  297. self._log(f" -> Hostname '{current_hostname}' beibehalten (kein Trixy-Name)")
  298. else:
  299. self._log(f" -> Hostname unveraendert")
  300. if self._cancelled():
  301. return
  302. # 5. Installationsverzeichnis entfernen
  303. step += 1
  304. self._update_progress(step / total_steps, "Dateien entfernen")
  305. self._log(f"\n[{step}/{total_steps}] Installationsverzeichnis entfernen...")
  306. install_path = ex.install_path
  307. if install_path and os.path.isdir(install_path):
  308. self._log(f" Entferne {install_path}...")
  309. await runner._run_sudo("rm", "-rf", install_path)
  310. self._log(" -> Verzeichnis entfernt")
  311. else:
  312. self._log(f" -> Verzeichnis nicht gefunden: {install_path}")
  313. if self._cancelled():
  314. return
  315. # 6. Zusammenfassung
  316. step += 1
  317. self._update_progress(1.0, "Fertig")
  318. self._log(f"\n[{step}/{total_steps}] Deinstallation abgeschlossen:")
  319. self._log(f" Service: {ex.service_name} entfernt")
  320. self._log(f" Verzeichnis: {install_path} entfernt")
  321. if ex.wifi_enabled:
  322. self._log(f" WiFi-Hotspot: hostapd + dnsmasq deaktiviert")
  323. self._log(f" Netzwerk: NAT + IP-Forwarding entfernt")
  324. self._log(f"\n Hinweis: apt-Pakete (ffmpeg, portaudio, etc.) wurden")
  325. self._log(f" nicht entfernt — diese werden ggf. von anderen")
  326. self._log(f" Programmen benoetigt.")
  327. self._log("\n=== Deinstallation abgeschlossen ===")
  328. self._running = False
  329. self._set_buttons_running(False)
  330. def _cancelled(self) -> bool:
  331. """Prueft ob abgebrochen wurde."""
  332. if self._runner and self._runner._cancelled:
  333. self._log("\n[ABBRUCH] Deinstallation abgebrochen.")
  334. self._running = False
  335. self._set_buttons_running(False)
  336. return True
  337. return False
  338. def _cancel(self) -> None:
  339. """Bricht die laufende Operation ab."""
  340. if self._runner:
  341. self._runner.cancel()
  342. self._log("\nAbbruch angefordert...")