install_view.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  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. def _wrap_box_text(text: str, width: int) -> list[str]:
  17. """Zerlegt einen Text in Zeilen der angegebenen Breite fuer Box-Frames.
  18. Einfache wort-basierte Umbruchlogik. Worte die laenger als `width`
  19. sind werden hart umgebrochen.
  20. """
  21. words = text.split()
  22. if not words:
  23. return [""]
  24. lines: list[str] = []
  25. current = ""
  26. for word in words:
  27. if not current:
  28. current = word
  29. elif len(current) + 1 + len(word) <= width:
  30. current += " " + word
  31. else:
  32. lines.append(current)
  33. current = word
  34. if current:
  35. lines.append(current)
  36. # Harte Umbrueche fuer ueberlange Worte
  37. result: list[str] = []
  38. for line in lines:
  39. while len(line) > width:
  40. result.append(line[:width])
  41. line = line[width:]
  42. result.append(line)
  43. return result
  44. if TYPE_CHECKING:
  45. from ..detect import ExistingInstallation
  46. from ..runner import InstallRunner
  47. class InstallView(InstallerView):
  48. """Installations-Ansicht (F6)."""
  49. TITLE = "Installation"
  50. VIEW_ID = "install"
  51. MODES = ["server", "client", "standalone", "config"]
  52. def __init__(self, installer_config, existing: "ExistingInstallation | None" = None, **kwargs) -> None:
  53. super().__init__(installer_config, **kwargs)
  54. self._existing = existing
  55. self._runner: InstallRunner | None = None
  56. self._running = False
  57. def compose(self):
  58. yield Static("── Installation ──", classes="section-header")
  59. # Status-Info basierend auf bestehender Installation
  60. yield Label("", id="install-info")
  61. yield Label("", id="install-status")
  62. yield Label(self._render_bar(0.0), id="install-progress-bar")
  63. yield Label("")
  64. # Buttons — nebeneinander, kontextabhaengig
  65. from ..detect import InstallState
  66. has_existing = self._existing and self._existing.found
  67. with Horizontal(id="install-buttons"):
  68. if not has_existing:
  69. yield Button("Installation starten", variant="success", id="btn-start")
  70. else:
  71. state = self._existing.state
  72. if state == InstallState.SAME_VERSION:
  73. yield Button("Erneut installieren", variant="warning", id="btn-start")
  74. elif state == InstallState.NEWER_AVAILABLE:
  75. yield Button(
  76. f"Update auf {self._config.version}",
  77. variant="success", id="btn-start",
  78. )
  79. elif state == InstallState.OLDER_AVAILABLE:
  80. yield Button(
  81. f"Downgrade auf {self._config.version}",
  82. variant="warning", id="btn-start",
  83. )
  84. yield Button("Deinstallieren", variant="error", id="btn-uninstall")
  85. yield Button("Abbrechen", variant="default", id="btn-cancel", disabled=True)
  86. yield Label("")
  87. yield Static("── Log ──", classes="section-header")
  88. yield RichLog(highlight=True, markup=True, id="install-log", min_width=60)
  89. async def on_activate(self) -> None:
  90. """Zeigt Info ueber bestehende Installation."""
  91. info_label = self.query_one("#install-info", Label)
  92. from ..detect import InstallState
  93. if not self._existing or not self._existing.found:
  94. info_label.update("Keine bestehende Installation erkannt — Neuinstallation")
  95. return
  96. ex = self._existing
  97. lines = [f"Bestehende Installation erkannt:"]
  98. lines.append(f" Version: {ex.installed_version}")
  99. lines.append(f" Pfad: {ex.source_path}")
  100. lines.append(f" Service: {ex.service_name} ({'aktiv' if ex.service_active else 'inaktiv'})")
  101. if ex.state == InstallState.SAME_VERSION:
  102. lines.append(f"\n Gleiche Version — Erneut installieren aktualisiert Einstellungen")
  103. elif ex.state == InstallState.NEWER_AVAILABLE:
  104. lines.append(f"\n Update verfuegbar: {ex.installed_version} -> {self._config.version}")
  105. elif ex.state == InstallState.OLDER_AVAILABLE:
  106. lines.append(f"\n Downgrade: {ex.installed_version} -> {self._config.version}")
  107. info_label.update("\n".join(lines))
  108. def _log(self, message: str) -> None:
  109. """Schreibt eine Nachricht ins Log."""
  110. try:
  111. log = self.query_one("#install-log", RichLog)
  112. log.write(message)
  113. except Exception:
  114. pass
  115. @staticmethod
  116. def _render_bar(fraction: float, width: int = 40) -> str:
  117. """Rendert eine klassische ASCII-ProgressBar."""
  118. pct = int(fraction * 100)
  119. filled = int(width * fraction)
  120. bar = "\u2588" * filled + "\u2591" * (width - filled)
  121. return f"[{bar}] {pct}%"
  122. def _update_progress(self, fraction: float, step_name: str) -> None:
  123. """Aktualisiert ProgressBar und Status-Label."""
  124. try:
  125. bar_label = self.query_one("#install-progress-bar", Label)
  126. bar_label.update(self._render_bar(fraction))
  127. status = self.query_one("#install-status", Label)
  128. if fraction >= 1.0:
  129. status.update("Abgeschlossen!")
  130. else:
  131. status.update(step_name)
  132. except Exception:
  133. pass
  134. def _set_buttons_running(self, running: bool) -> None:
  135. """Schaltet Buttons fuer laufende Operation um."""
  136. for btn_id in ("btn-start", "btn-uninstall"):
  137. try:
  138. self.query_one(f"#{btn_id}", Button).disabled = running
  139. except Exception:
  140. pass
  141. try:
  142. self.query_one("#btn-cancel", Button).disabled = not running
  143. except Exception:
  144. pass
  145. async def on_button_pressed(self, event: Button.Pressed) -> None:
  146. if event.button.id == "btn-start":
  147. await self._start_installation()
  148. elif event.button.id == "btn-uninstall":
  149. await self._start_uninstall()
  150. elif event.button.id == "btn-cancel":
  151. self._cancel()
  152. async def _start_installation(self) -> None:
  153. """Startet Installation/Reinstall/Update."""
  154. # 1. Validierung aller Views — Fehler sammeln, nicht springen
  155. app = self.app
  156. errors: list[tuple[str, str]] = []
  157. for view in getattr(app, "_views", []):
  158. try:
  159. error = view.validate()
  160. if error:
  161. errors.append((view.TITLE, error))
  162. except Exception as e:
  163. errors.append((view.TITLE, f"Validierung fehlgeschlagen: {e}"))
  164. if errors:
  165. self._log("Validierungsfehler:\n")
  166. for tab_name, error_msg in errors:
  167. self._log(f" [{tab_name}] {error_msg}")
  168. self._log(f"\nBitte die markierten Tabs korrigieren.")
  169. return
  170. # 2. Preflight-Check
  171. try:
  172. from ..preflight import run_preflight, format_preflight
  173. self._log("Preflight-Check...")
  174. self._log("")
  175. preflight = run_preflight(self._config)
  176. self._log(format_preflight(preflight))
  177. self._log("")
  178. if not preflight.passed:
  179. self._log("Abgebrochen — Preflight-Check fehlgeschlagen.")
  180. return
  181. if preflight.warnings:
  182. self._log("Warnungen vorhanden — wird fortgesetzt.\n")
  183. except Exception as e:
  184. self._log(f"Preflight-Fehler: {e}")
  185. return
  186. # 3. Service stoppen falls aktiv
  187. if self._existing and self._existing.service_active:
  188. self._log("Stoppe laufenden Service...")
  189. from ..runner import InstallRunner
  190. temp_runner = InstallRunner(self._config, self._log, self._update_progress)
  191. await temp_runner._run_sudo(
  192. "systemctl", "stop", f"{self._existing.service_name}.service",
  193. )
  194. self._log(" -> Service gestoppt\n")
  195. # 4. Runner starten
  196. self._set_buttons_running(True)
  197. self._running = True
  198. from ..runner import InstallRunner
  199. self._runner = InstallRunner(
  200. config=self._config,
  201. log_callback=self._log,
  202. progress_callback=self._update_progress,
  203. )
  204. success = await self._runner.run()
  205. self._running = False
  206. self._set_buttons_running(False)
  207. if success:
  208. # Service starten falls gewuenscht — aber nur wenn kein
  209. # Reboot noetig ist. Bei Netzwerk-Aenderungen muss der User
  210. # sowieso rebooten, der Service startet dann automatisch
  211. # via systemd (autostart).
  212. if (
  213. self._config.install_service
  214. and self._config.autostart
  215. and not getattr(self._runner, "reboot_required", False)
  216. ):
  217. self._log("\nStarte Service...")
  218. await self._runner._run_sudo(
  219. "systemctl", "start",
  220. f"trixy-{self._config.mode}.service",
  221. )
  222. self._log(" -> Service gestartet")
  223. self._log("\nInstallation erfolgreich!")
  224. # Reboot-Warnung wenn Netzwerk-Aenderungen vorgenommen wurden
  225. if getattr(self._runner, "reboot_required", False):
  226. self._log("")
  227. self._log("╔══════════════════════════════════════════════════════════╗")
  228. self._log("║ !!! REBOOT ERFORDERLICH !!! ║")
  229. self._log("╠══════════════════════════════════════════════════════════╣")
  230. self._log("║ ║")
  231. self._log("║ Netzwerk-Aenderungen wurden geschrieben, sind aber ║")
  232. self._log("║ noch NICHT aktiv. Erst nach einem Reboot uebernimmt ║")
  233. self._log("║ der Pi die neue Konfiguration. ║")
  234. self._log("║ ║")
  235. for reason in getattr(self._runner, "reboot_reasons", []):
  236. # Text auf 54 Zeichen (Content-Breite zwischen ║ ... ║)
  237. # zerlegen damit der Rahmen nicht bricht
  238. for line in _wrap_box_text(f" - {reason}", width=54):
  239. self._log(f"║ {line:<54} ║")
  240. self._log("║ ║")
  241. self._log("║ WICHTIG: Wenn du gerade ueber SSH verbunden bist, ║")
  242. self._log("║ wird die Verbindung nach dem Reboot zum Trixy-Netz ║")
  243. self._log("║ wechseln. Die aktuelle Session bricht dann ab. ║")
  244. self._log("║ ║")
  245. self._log("║ Befehl zum Reboot: ║")
  246. self._log("║ ║")
  247. self._log("║ sudo reboot ║")
  248. self._log("║ ║")
  249. self._log("╚══════════════════════════════════════════════════════════╝")
  250. self._log("")
  251. else:
  252. self._log("\nInstallation fehlgeschlagen oder abgebrochen.")
  253. async def _start_uninstall(self) -> None:
  254. """Deinstalliert Trixy komplett inkl. aller System-Konfigurationen."""
  255. if not self._existing or not self._existing.found:
  256. self._log("Keine Installation zum Deinstallieren gefunden.")
  257. return
  258. self._log("=== Trixy Deinstallation ===\n")
  259. self._set_buttons_running(True)
  260. self._running = True
  261. from ..runner import InstallRunner
  262. self._runner = InstallRunner(self._config, self._log, self._update_progress)
  263. runner = self._runner
  264. ex = self._existing
  265. total_steps = 6
  266. step = 0
  267. # 1. Service stoppen und entfernen
  268. step += 1
  269. self._update_progress(step / total_steps, "Service entfernen")
  270. self._log(f"[{step}/{total_steps}] Service stoppen und entfernen...")
  271. if ex.service_active:
  272. await runner._run_sudo("systemctl", "stop", f"{ex.service_name}.service")
  273. self._log(" -> Service gestoppt")
  274. service_file = f"/etc/systemd/system/{ex.service_name}.service"
  275. if os.path.isfile(service_file):
  276. await runner._run_sudo(
  277. "systemctl", "disable", f"{ex.service_name}.service", check=False,
  278. )
  279. await runner._run_sudo("rm", "-f", service_file)
  280. await runner._run_sudo("systemctl", "daemon-reload")
  281. self._log(" -> Service-Datei entfernt")
  282. else:
  283. self._log(" -> Keine Service-Datei vorhanden")
  284. if self._cancelled():
  285. return
  286. # 2. WiFi-Hotspot entfernen (hostapd + dnsmasq)
  287. step += 1
  288. self._update_progress(step / total_steps, "WiFi-Konfiguration")
  289. self._log(f"\n[{step}/{total_steps}] WiFi-Hotspot Konfiguration entfernen...")
  290. if ex.wifi_enabled:
  291. await runner._run_sudo("systemctl", "stop", "hostapd", check=False)
  292. await runner._run_sudo("systemctl", "disable", "hostapd", check=False)
  293. if os.path.isfile("/etc/hostapd/hostapd.conf"):
  294. await runner._run_sudo("rm", "-f", "/etc/hostapd/hostapd.conf")
  295. self._log(" -> /etc/hostapd/hostapd.conf entfernt")
  296. await runner._run_sudo("systemctl", "stop", "dnsmasq", check=False)
  297. await runner._run_sudo("systemctl", "disable", "dnsmasq", check=False)
  298. if os.path.isfile("/etc/dnsmasq.conf.orig"):
  299. await runner._run_sudo(
  300. "mv", "/etc/dnsmasq.conf.orig", "/etc/dnsmasq.conf",
  301. )
  302. self._log(" -> /etc/dnsmasq.conf wiederhergestellt (Backup)")
  303. elif os.path.isfile("/etc/dnsmasq.conf"):
  304. await runner._run_sudo("rm", "-f", "/etc/dnsmasq.conf")
  305. self._log(" -> /etc/dnsmasq.conf entfernt")
  306. self._log(" -> hostapd + dnsmasq deaktiviert")
  307. else:
  308. self._log(" -> Kein WiFi-Hotspot konfiguriert")
  309. if self._cancelled():
  310. return
  311. # 3. Netzwerk-Konfiguration bereinigen
  312. step += 1
  313. self._update_progress(step / total_steps, "Netzwerk-Konfiguration")
  314. self._log(f"\n[{step}/{total_steps}] Netzwerk-Konfiguration bereinigen...")
  315. if ex.wifi_enabled:
  316. # Alte dhcpcd-Eintraege (Pre-Bookworm) falls vorhanden
  317. dhcpcd = "/etc/dhcpcd.conf"
  318. if os.path.isfile(dhcpcd):
  319. await runner._run_sudo(
  320. "sed", "-i", "/^# Trixy AP/,/^$/d", dhcpcd,
  321. )
  322. self._log(" -> Trixy-AP Eintrag aus /etc/dhcpcd.conf entfernt")
  323. # Bookworm: systemd-networkd Profil fuer wlan0
  324. networkd_file = "/etc/systemd/network/10-trixy-wlan0.network"
  325. if os.path.isfile(networkd_file):
  326. await runner._run_sudo("rm", "-f", networkd_file)
  327. self._log(f" -> {networkd_file} entfernt")
  328. # Bookworm: NetworkManager Unmanaged-Override
  329. nm_override = "/etc/NetworkManager/conf.d/99-trixy-wlan0-unmanaged.conf"
  330. if os.path.isfile(nm_override):
  331. await runner._run_sudo("rm", "-f", nm_override)
  332. self._log(f" -> {nm_override} entfernt")
  333. # NetworkManager reload damit wlan0 wieder managed wird
  334. await runner._run_sudo(
  335. "systemctl", "reload", "NetworkManager", check=False,
  336. )
  337. # dnsmasq Race-Condition-Fix
  338. dnsmasq_override = "/etc/systemd/system/dnsmasq.service.d/trixy-wait-wlan0.conf"
  339. if os.path.isfile(dnsmasq_override):
  340. await runner._run_sudo("rm", "-f", dnsmasq_override)
  341. await runner._run_sudo(
  342. "rmdir", "/etc/systemd/system/dnsmasq.service.d",
  343. check=False,
  344. )
  345. self._log(f" -> {dnsmasq_override} entfernt")
  346. # IP-Forwarding Drop-in
  347. ipfwd = "/etc/sysctl.d/99-trixy-ipforward.conf"
  348. if os.path.isfile(ipfwd):
  349. await runner._run_sudo("rm", "-f", ipfwd)
  350. self._log(f" -> {ipfwd} entfernt")
  351. await runner._run_sudo("systemctl", "daemon-reload", check=False)
  352. await runner._run_sudo(
  353. "iptables", "-t", "nat", "-D", "POSTROUTING",
  354. "-o", "eth0", "-j", "MASQUERADE",
  355. check=False,
  356. )
  357. if os.path.isfile("/etc/iptables.ipv4.nat"):
  358. await runner._run_sudo("rm", "-f", "/etc/iptables.ipv4.nat")
  359. self._log(" -> NAT-Regeln entfernt")
  360. await runner._run_sudo(
  361. "sed", "-i",
  362. "s/^net.ipv4.ip_forward=1/#net.ipv4.ip_forward=1/",
  363. "/etc/sysctl.conf",
  364. check=False,
  365. )
  366. await runner._run_sudo(
  367. "sysctl", "-w", "net.ipv4.ip_forward=0", check=False,
  368. )
  369. self._log(" -> IP-Forwarding deaktiviert")
  370. else:
  371. self._log(" -> Keine Netzwerk-Aenderungen vorhanden")
  372. if self._cancelled():
  373. return
  374. # 4. Hostname zuruecksetzen
  375. step += 1
  376. self._update_progress(step / total_steps, "Hostname")
  377. self._log(f"\n[{step}/{total_steps}] Hostname...")
  378. current_hostname = ""
  379. try:
  380. current_hostname = os.uname().nodename
  381. except Exception:
  382. pass
  383. if current_hostname:
  384. default_names = {"trixyone", "trixy", "trixysat", "trixycfg"}
  385. if current_hostname.lower() in default_names:
  386. await runner._run_sudo("hostnamectl", "set-hostname", "raspberrypi")
  387. self._log(" -> Hostname auf 'raspberrypi' zurueckgesetzt")
  388. else:
  389. self._log(f" -> Hostname '{current_hostname}' beibehalten (kein Trixy-Name)")
  390. else:
  391. self._log(f" -> Hostname unveraendert")
  392. if self._cancelled():
  393. return
  394. # 5. Installationsverzeichnis entfernen
  395. step += 1
  396. self._update_progress(step / total_steps, "Dateien entfernen")
  397. self._log(f"\n[{step}/{total_steps}] Installationsverzeichnis entfernen...")
  398. install_path = ex.install_path
  399. if install_path and os.path.isdir(install_path):
  400. self._log(f" Entferne {install_path}...")
  401. await runner._run_sudo("rm", "-rf", install_path)
  402. self._log(" -> Verzeichnis entfernt")
  403. else:
  404. self._log(f" -> Verzeichnis nicht gefunden: {install_path}")
  405. if self._cancelled():
  406. return
  407. # 6. Zusammenfassung
  408. step += 1
  409. self._update_progress(1.0, "Fertig")
  410. self._log(f"\n[{step}/{total_steps}] Deinstallation abgeschlossen:")
  411. self._log(f" Service: {ex.service_name} entfernt")
  412. self._log(f" Verzeichnis: {install_path} entfernt")
  413. if ex.wifi_enabled:
  414. self._log(f" WiFi-Hotspot: hostapd + dnsmasq deaktiviert")
  415. self._log(f" Netzwerk: NAT + IP-Forwarding entfernt")
  416. self._log(f"\n Hinweis: apt-Pakete (ffmpeg, portaudio, etc.) wurden")
  417. self._log(f" nicht entfernt — diese werden ggf. von anderen")
  418. self._log(f" Programmen benoetigt.")
  419. self._log("\n=== Deinstallation abgeschlossen ===")
  420. self._running = False
  421. self._set_buttons_running(False)
  422. def _cancelled(self) -> bool:
  423. """Prueft ob abgebrochen wurde."""
  424. if self._runner and self._runner._cancelled:
  425. self._log("\n[ABBRUCH] Deinstallation abgebrochen.")
  426. self._running = False
  427. self._set_buttons_running(False)
  428. return True
  429. return False
  430. def _cancel(self) -> None:
  431. """Bricht die laufende Operation ab."""
  432. if self._runner:
  433. self._runner.cancel()
  434. self._log("\nAbbruch angefordert...")