337 lines
13 KiB
Python
337 lines
13 KiB
Python
|
|
#!/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 = [
|
|||
|
|
"python@3.12", "python-tk", "gstreamer", "gst-plugins-base",
|
|||
|
|
"gst-plugins-good", "gst-plugins-bad", "gst-plugins-ugly",
|
|||
|
|
"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)
|
|||
|
|
|
|||
|
|
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")
|
|||
|
|
threading.Thread(target=self._install_worker, daemon=True).start()
|
|||
|
|
|
|||
|
|
def _install_worker(self):
|
|||
|
|
try:
|
|||
|
|
if IS_MAC:
|
|||
|
|
self._install_mac()
|
|||
|
|
elif IS_WIN:
|
|||
|
|
self._install_win()
|
|||
|
|
self.on_log("")
|
|||
|
|
self.on_log("Installation abgeschlossen.")
|
|||
|
|
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):
|
|||
|
|
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')
|
|||
|
|
lines.append('"$BREW" install ' + " ".join(BREW_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.")
|
|||
|
|
|
|||
|
|
# ---------- 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()
|