Files

385 lines
15 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""HMS MediaEngine Grafischer Installer (Fenster statt Konsole).
Wird vom macOS-App-Bundle oder HMS-Start.vbs (Windows) geöffnet, wenn
Abhaengigkeiten fehlen. Der Nutzer sieht ein Fenster kein Terminal:
- macOS: GStreamer/PyGObject via Homebrew. Native Passwort-Eingabe durch
macOS-Systemdialog (osascript 'with administrator privileges').
- Windows: Offizielle Installer-GUIs (GStreamer-MSI, Python-Setup) werden
gestartet der Nutzer klickt nur 'Weiter/Fertig'.
Nach der Installation: Button 'MediaEngine starten' die App laeuft
danach unsichtbar im Hintergrund, nur der Browser ist sichtbar.
"""
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
import threading
import urllib.request
import webbrowser
from pathlib import Path
import tkinter as tk
from tkinter import ttk, messagebox
ROOT = Path(__file__).resolve().parent
IS_MAC = sys.platform == "darwin"
IS_WIN = sys.platform == "win32"
GST_MSI = ("https://gstreamer.freedesktop.org/data/pkg/windows/"
"1.28.4/msvc/gstreamer-1.0-msvc-x86_64-1.28.4.msi")
PY_EXE = "https://www.python.org/ftp/python/3.13.2/python-3.13.2-amd64.exe"
BREW_PKGS_FULL = [
"python@3.12", "python-tk", "gstreamer", "gst-plugins-base",
"gst-plugins-good", "gst-plugins-bad", "gst-plugins-ugly",
"gst-libav", "pygobject",
]
# Kompakt: ohne gst-plugins-bad/ugly. Spart ca. 1-1,5 GB; Effekte
# sinken von 21 auf 15 (Blur/Schaerfen/Solarize/Burn/Dilate/Exclusion
# stecken in gst-plugins-bad). Videowiedergabe (libav), Compositor
# und 15 Effekte bleiben voll nutzbar.
BREW_PKGS_COMPACT = [
"python@3.12", "python-tk", "gstreamer", "gst-plugins-base",
"gst-plugins-good", "gst-libav", "pygobject",
]
def brew_path() -> str | None:
for p in ("/opt/homebrew/bin/brew", "/usr/local/bin/brew"):
if Path(p).exists():
return p
return None
def gst_ok() -> bool:
try:
import gi # noqa: F401
gi.require_version("Gst", "1.0")
from gi.repository import Gst
Gst.init(None)
return True
except Exception: # noqa: BLE001
return False
def py_ok() -> bool:
try:
r = subprocess.run(["python", "--version"], capture_output=True,
timeout=10,
creationflags=0x08000000 if IS_WIN else 0)
return r.returncode == 0
except Exception: # noqa: BLE001
return False
def mac_python_ok() -> bool:
"""Ein python3, das spaeter auch gi importieren kann (brew-Python)."""
for cand in ("/opt/homebrew/bin/python3", "/usr/local/bin/python3",
"python3"):
try:
r = subprocess.run([cand, "-c", "import tkinter"],
capture_output=True, timeout=10)
if r.returncode == 0:
return True
except Exception: # noqa: BLE001
continue
return False
def dl(url: str, dest: Path) -> None:
def hook(n, bs, total):
if total > 0:
pct = min(100, n * bs * 100 // total)
app.on_progress(pct)
urllib.request.urlretrieve(url, dest, hook)
def run_quiet(cmd: list[str]) -> None:
subprocess.run(cmd, check=False,
creationflags=0x08000000 if IS_WIN else 0)
class InstallerApp:
def __init__(self):
self.root = tk.Tk()
self.root.title("HMS MediaEngine Installation")
self.root.geometry("680x520")
self.root.configure(bg="#16181d")
try:
style = ttk.Style(self.root)
style.theme_use("clam")
except Exception: # noqa: BLE001
pass
self.steps: list[tuple[str, bool]] = []
self.log_lines: list[str] = []
self._build()
self.refresh_status()
# ---------- UI ----------
def _build(self):
head = tk.Frame(self.root, bg="#16181d")
head.pack(fill="x", padx=16, pady=(12, 6))
tk.Label(head, text="HMS MediaEngine",
font=("System", 17, "bold"),
bg="#16181d", fg="#e8eaee").pack(anchor="w")
tk.Label(head, text="Grafische Installation kein Terminal nötig",
bg="#16181d", fg="#8a8f9a").pack(anchor="w")
self.status_var = tk.StringVar()
tk.Label(self.root, textvariable=self.status_var,
bg="#16181d", fg="#c8ccd4").pack(fill="x", padx=16)
var_row = tk.Frame(self.root, bg="#16181d")
var_row.pack(fill="x", padx=16, pady=(0, 2))
tk.Label(var_row, text="Installationsumfang:",
bg="#16181d", fg="#8a8f9a").pack(side="left")
self.variant = tk.StringVar(
value="Vollständig (alle 21 Effekte)")
ttk.Combobox(
var_row, textvariable=self.variant, state="readonly",
width=38, values=(
"Vollständig (alle 21 Effekte)",
"Kompakt (15 Effekte, spart ca. 1,5 GB)")
).pack(side="left", padx=8)
self.bar = ttk.Progressbar(self.root, mode="determinate",
length=640)
self.bar.pack(fill="x", padx=16, pady=8)
logbox = tk.Frame(self.root, bg="#0d0f12")
logbox.pack(fill="both", expand=True, padx=16, pady=6)
self.log = tk.Text(logbox, bg="#0d0f12", fg="#c8ccd4",
insertbackground="#c8ccd4", height=14,
relief="flat", font=("Menlo" if IS_MAC
else "Consolas", 10))
self.log.pack(fill="both", expand=True, side="left")
scroll = ttk.Scrollbar(logbox, command=self.log.yview)
scroll.pack(side="right", fill="y")
self.log.configure(yscrollcommand=scroll.set)
btns = tk.Frame(self.root, bg="#16181d")
btns.pack(fill="x", padx=16, pady=10)
self.install_btn = tk.Button(
btns, text="Fehlendes installieren",
command=self.start_install, bg="#1d3a5f", fg="#dbe9ff",
activebackground="#2a4f7f", relief="flat", padx=14, pady=7)
self.install_btn.pack(side="left")
self.start_btn = tk.Button(
btns, text="▶ MediaEngine starten",
command=self.start_engine, bg="#1f4d2a", fg="#d8ffd8",
activebackground="#2a6a3a", relief="flat", padx=14, pady=7)
self.start_btn.pack(side="left", padx=8)
tk.Button(btns, text="Beenden", command=self.root.destroy,
bg="#262a33", fg="#c8ccd4", relief="flat",
padx=14, pady=7).pack(side="right")
def on_log(self, text: str):
self.log_lines.append(text)
self.log.insert("end", text + "\n")
self.log.see("end")
self.root.update_idletasks()
def on_progress(self, pct: int):
self.bar["value"] = pct
self.root.update_idletasks()
def on_status(self, text: str):
self.status_var.set(text)
self.root.update_idletasks()
# ---------- Status ----------
def refresh_status(self):
self.steps = []
if IS_WIN:
self.steps.append(("Python 3.13", py_ok()))
gs = (os.environ.get("GSTREAMER_1_0_ROOT_MSVC_X86_64")
or "")
self.steps.append(("GStreamer 1.28",
bool(gs) or Path(
"C:\\gstreamer\\1.0\\msvc_x86_64"
).exists()))
elif IS_MAC:
self.steps.append(("Homebrew", brew_path() is not None))
self.steps.append(("Python 3 (mit tkinter)", mac_python_ok()))
self.steps.append(("GStreamer + PyGObject", gst_ok()))
else:
self.steps.append(("GStreamer (PyGObject)", gst_ok()))
self.log.delete("1.0", "end")
for name, ok in self.steps:
mark = "" if ok else ""
self.on_log(f"{mark} {name}: "
+ ("vorhanden" if ok else "fehlt wird installiert"))
missing = [n for n, ok in self.steps if not ok]
if not missing:
self.on_status("Alles bereit du kannst MediaEngine starten.")
self.install_btn.config(state="disabled")
self.start_btn.config(state="normal", bg="#1f4d2a")
else:
self.on_status("Fehlt: " + ", ".join(missing)
+ " → Button links drücken.")
self.install_btn.config(state="normal")
self.start_btn.config(state="disabled", bg="#262a33")
# ---------- Installation ----------
def start_install(self):
self.install_btn.config(state="disabled")
variant = self.variant.get() # tk-Variable im Hauptthread lesen
threading.Thread(target=self._install_worker, args=(variant,),
daemon=True).start()
def _install_worker(self, variant: str):
try:
if IS_MAC:
self._install_mac(variant)
elif IS_WIN:
self._install_win()
self.on_log("")
self.on_log("Installation abgeschlossen.")
if IS_MAC:
self.root.after(0, self._offer_cleanup)
self.on_log("Falls gerade etwas installiert wurde, das Python "
"betrifft: dieses Fenster einmal schließen und "
"die App erneut starten.")
self.refresh_status()
except Exception as e: # noqa: BLE001
self.on_log(f"FEHLER: {e}")
self.on_status("Installation fehlgeschlagen siehe Log. "
"(Meldung an den Support reicht zum Fixen.)")
self.install_btn.config(state="normal")
def _osascript_admin(self, script: str, prompt: str) -> str:
esc = script.replace('\\', '\\\\').replace('"', '\\"')
code = (f'do shell script "{esc}" '
f'with administrator privileges '
f'with prompt "{prompt}"')
r = subprocess.run(["osascript", "-e", code],
capture_output=True, text=True, timeout=3600)
if r.returncode != 0:
raise RuntimeError(r.stderr.strip() or "osascript fehlgeschlagen")
return r.stdout
def _install_mac(self, variant: str):
self.on_status("macOS: Installation läuft (Passwort-Dialog folgt)…")
lines = ["set -e"]
brew = brew_path()
if brew is None:
self.on_log("Homebrew fehlt wird installiert (mehrere Minuten)…")
lines.append(
'NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL '
'https://raw.githubusercontent.com/Homebrew/'
'install/HEAD/install.sh)"')
# brew-Pfad robust im Skript auswaehlen (Apple Silicon vs. Intel);
# absolute Pfade machen ein eval/shellenv ueberfluessig
lines.append(
'if [ -x /opt/homebrew/bin/brew ]; then '
'BREW=/opt/homebrew/bin/brew; '
'else BREW=/usr/local/bin/brew; fi')
pkgs = (BREW_PKGS_FULL if variant.startswith("Voll")
else BREW_PKGS_COMPACT)
self.on_log("Variante: " + variant)
lines.append('"$BREW" install ' + " ".join(pkgs))
script = "\n".join(lines)
with tempfile.NamedTemporaryFile("w", suffix=".sh",
delete=False) as f:
f.write(script)
sh = f.name
os.chmod(sh, 0o700)
self.on_log("Starte Installation (macOS fragt nach deinem "
"Administrator-Passwort)…")
out = self._osascript_admin(f"/bin/bash {sh}",
"HMS MediaEngine: GStreamer und "
"Python-Komponenten installieren")
for line in (out or "").strip().splitlines()[-12:]:
self.on_log(" " + line)
Path(sh).unlink(missing_ok=True)
def _install_win(self):
if not Path("C:\\gstreamer\\1.0\\msvc_x86_64").exists() \
and not os.environ.get("GSTREAMER_1_0_ROOT_MSVC_X86_64"):
self.on_status("Windows: GStreamer-Installer (GUI) bitte "
"mit 'Weiter/Fertig' bestätigen…")
dest = Path(tempfile.gettempdir()) / "gstreamer.msi"
self.on_log("Lade GStreamer-Installer herunter…")
dl(GST_MSI, dest)
self.on_log("Öffne GStreamer-Installation (Fenster erscheint)…")
r = subprocess.run(["msiexec", "/i", str(dest)],
capture_output=False, timeout=3600)
self.on_log(f"GStreamer-Installer beendet (Code {r.returncode}).")
if not py_ok():
self.on_status("Windows: Python-Installer (GUI) bitte "
"'Add python.exe to PATH' anhaken und installieren…")
dest = Path(tempfile.gettempdir()) / "python-setup.exe"
self.on_log("Lade Python-Installer herunter…")
dl(PY_EXE, dest)
self.on_log("Öffne Python-Installation (Fenster erscheint)…")
subprocess.run([str(dest)], timeout=3600)
self.on_log("Python-Installation abgeschlossen.")
self.on_log("WICHTIG: Falls PATH soeben geändert wurde, dieses "
"Fenster schließen und HMS-Start.vbs erneut "
"doppelklicken.")
def _offer_cleanup(self):
"""Homebrew-Cache aufräumen gibt ca. 0,5-2 GB frei."""
try:
if messagebox.askyesno(
"HMS MediaEngine",
"Homebrew-Cache jetzt aufräumen?\n\n"
"Löscht heruntergeladene Installationspakete und "
"gibt etwa 0,5-2 GB Festplatte frei.\n"
"Die Installation selbst bleibt unverändert.",
parent=self.root):
brew = brew_path() or "/opt/homebrew/bin/brew"
self.on_log("Räume Homebrew-Cache auf…")
r = subprocess.run([brew, "cleanup", "--prune=all"],
capture_output=True, timeout=600)
self.on_log(
"Cache aufgeräumt." if r.returncode == 0
else f"cleanup endete mit Code {r.returncode}")
except Exception as e: # noqa: BLE001
self.on_log(f"cleanup übersprungen: {e}")
# ---------- Start ----------
def start_engine(self):
try:
if IS_WIN:
pyw = "pythonw"
subprocess.Popen(
[pyw if Path(sys.executable).stem == "python"
else sys.executable, str(ROOT / "run.py")],
cwd=str(ROOT),
creationflags=0x08000000,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
else:
subprocess.Popen(
["python3", str(ROOT / "run.py")], cwd=str(ROOT),
stdout=open("/tmp/hms-mediaengine.log", "ab"),
stderr=subprocess.STDOUT,
start_new_session=True)
self.on_status("MediaEngine startet Browser öffnet sich…")
self.root.after(2500, lambda: webbrowser.open(
"http://localhost:8080"))
self.root.after(4000, self.root.destroy)
except Exception as e: # noqa: BLE001
messagebox.showerror("HMS MediaEngine",
f"Start fehlgeschlagen:\n{e}")
def run(self):
self.root.mainloop()
if __name__ == "__main__":
app = InstallerApp()
app.run()