AUFGERAUMT: Root auf 10 sichtbare Elemente reduziert

Der Nutzer hat recht: Der Ordner war voller Entwicklungs-Muell.
Jetzt ist sauber getrennt:

ROOT (was der Nutzer sieht und braucht):
- run.py                     = das Programm
- hms_app/                   = der Anwendungscode
- HMS MediaEngine.app        = macOS Doppelklick-Starter
- HMS-Start.vbs              = Windows Doppelklick-Starter
- HMS-Install.vbs             = Windows Erst-Installation
- HMS-Mac-Install.command     = macOS Homebrew-Installation
- HMS-Portable-Install.command = macOS Portable-Installation (16GB-Fix)
- installer_gui.py           = grafischer Installer
- launcher.pyw + launcher_core.py = interne Start-Logik
- LIESMICH.txt               = 10-Zeilen-Kurzanleitung
- .gitignore

_entwicklung/ (alles andere, NICHT benoetigt):
- packages/ apps/ native/ plugins/ tools/ schemas/ tests/ docs/
  build/ fixture_profiles/
- PLAN.md STATUS.md ERRORS.md TEST_REPORT.md CHANGELOG.md README.md
- pyproject.toml uv.lock setup_*.sh/ps1 make_mac_app.py

Diese Trennung gilt ab sofort fuer alle Commits. Der Nutzer kann
_entwicklung/ loeschen wenn er Platz braucht - die App laeuft ohne.

Verifiziert: App startet nach Aufraeumen unveraendert (Health 200).
This commit is contained in:
HMS MediaEngine Agent
2026-09-11 23:44:06 +02:00
parent 696e8eb1b3
commit 362e089be0
338 changed files with 24 additions and 387 deletions
@@ -0,0 +1,25 @@
"""hms_plugin_sdk Plugin-API, Manifest, Validierung, Lifecycle (PLAN.md §14)."""
from hms_plugin_sdk.lifecycle import (
InvalidTransitionError,
LifecycleState,
PluginLifecycleManager,
PluginRecord,
)
from hms_plugin_sdk.manifest import (
PluginKind,
load_manifest,
validate_manifest,
validate_plugin_zip,
)
__all__ = [
"PluginKind",
"load_manifest",
"validate_manifest",
"validate_plugin_zip",
"LifecycleState",
"PluginRecord",
"PluginLifecycleManager",
"InvalidTransitionError",
]
@@ -0,0 +1,227 @@
"""Plugin-Lifecycle-Manager (PLAN.md §14.5, §14.6, §26.3).
Lebenszyklus:
discovered → validated → installed → enabled → compiled → active
↘ quarantined / incompatible
Regeln:
- Übergänge nur entlang definierter Kanten; Sprünge sind Fehler
- Validierung umfasst Manifest-Schema, API-Kompatibilität,
Pfadsicherheit, Backend-/Shader-Prüfung (§14.5)
- ein fehlerhaftes Plugin wird quarantiniert, ohne ein Projekt unbrauchbar
zu machen (§3.5); der Effekt wird überbrückt (bypass_on_error)
- Show-Lock (§26.3): Installieren/Updaten ist gesperrt; Enable/Disable
und Parameter bleiben erlaubt (§17.7 Live-Modus)
- Doppelte Plugin-IDs sind Fehler, keine stillen Überschreibungen
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path
from hms_plugin_sdk.manifest import load_manifest
class LifecycleState(StrEnum):
"""Zustände gemäß §14.5."""
DISCOVERED = "discovered"
VALIDATED = "validated"
INSTALLED = "installed"
ENABLED = "enabled"
COMPILED = "compiled"
ACTIVE = "active"
QUARANTINED = "quarantined"
INCOMPATIBLE = "incompatible"
DISABLED = "disabled"
# Erlaubte Übergänge (§14.5-Lebenszyklusgraph)
_TRANSITIONS: dict[LifecycleState, frozenset[LifecycleState]] = {
LifecycleState.DISCOVERED: frozenset(
{LifecycleState.VALIDATED, LifecycleState.INCOMPATIBLE, LifecycleState.QUARANTINED}
),
LifecycleState.VALIDATED: frozenset(
{LifecycleState.INSTALLED, LifecycleState.INCOMPATIBLE, LifecycleState.QUARANTINED}
),
LifecycleState.INSTALLED: frozenset(
{LifecycleState.ENABLED, LifecycleState.DISABLED, LifecycleState.QUARANTINED}
),
LifecycleState.ENABLED: frozenset(
{LifecycleState.COMPILED, LifecycleState.DISABLED, LifecycleState.QUARANTINED}
),
LifecycleState.COMPILED: frozenset(
{LifecycleState.ACTIVE, LifecycleState.QUARANTINED, LifecycleState.DISABLED}
),
LifecycleState.ACTIVE: frozenset(
{LifecycleState.DISABLED, LifecycleState.QUARANTINED}
),
LifecycleState.DISABLED: frozenset(
{LifecycleState.ENABLED, LifecycleState.QUARANTINED}
),
LifecycleState.QUARANTINED: frozenset(), # manuelle Entfernung/Neuinstallation
LifecycleState.INCOMPATIBLE: frozenset(),
}
class InvalidTransitionError(Exception):
"""Unerlaubter Zustandsübergang im Lebenszyklus."""
@dataclass
class PluginRecord:
"""Ein Plugin im Lifecycle-Manager."""
plugin_id: str
version: str
state: LifecycleState = LifecycleState.DISCOVERED
package_hash: str | None = None
last_error: str | None = None
manifest: dict = field(default_factory=dict)
class PluginLifecycleManager:
"""Verwaltet den Lebenszyklus aller installierten Plugins (§14.5).
- discover(): Verzeichnis scannen, Manifest laden, Manifest-Validierung
- advance(): zustandsgeprüfter Übergang
- quarantine(): Fehlerfall mit Ursache (§3.5, §14.5)
- Show-Lock: install_validate/enable_new blockiert Strukturänderungen
(§26.3); Aktivieren bereits installierter Plugins bleibt erlaubt
"""
def __init__(self, show_lock: bool = False) -> None:
self._plugins: dict[str, PluginRecord] = {}
self._show_lock = show_lock
@property
def show_lock(self) -> bool:
return self._show_lock
def set_show_lock(self, enabled: bool) -> None:
"""§26.3: Show-Lock verhindert Plugininstallation/-update."""
self._show_lock = enabled
# ---------- Discovery & Validierung (§14.5) ----------
def discover(self, plugin_dir: Path) -> list[str]:
"""Scannt ein Verzeichnis mit Plugin-Ordnern.
Lädt plugin.json, validiert es (inkl. Shader-Existenz) und
überführt jedes Plugin in VALIDATED oder INCOMPATIBLE/QUARANTINED.
Rückgabe: Liste diagnostizierter Fehler (leer = alles sauber).
"""
errors: list[str] = []
if not plugin_dir.is_dir():
return [f"Plugin-Verzeichnis fehlt: {plugin_dir}"]
for child in sorted(plugin_dir.iterdir()):
if not child.is_dir():
continue
manifest_path = child / "plugin.json"
if not manifest_path.is_file():
continue # kein Plugin-Ordner (z. B. .git)
manifest, validation_errors = load_manifest(child)
if validation_errors:
pid = manifest.get("id") or child.name
errors.extend(f"{pid}: {e}" for e in validation_errors)
record = PluginRecord(
plugin_id=pid,
version=str(manifest.get("version", "0.0.0")),
state=LifecycleState.QUARANTINED,
last_error="; ".join(validation_errors),
manifest=manifest,
)
self._upsert(record)
continue
pid = manifest["id"]
record = PluginRecord(
plugin_id=pid,
version=manifest["version"],
state=LifecycleState.VALIDATED,
manifest=manifest,
)
self._upsert(record)
return errors
# ---------- Übergänge (§14.5) ----------
def advance(self, plugin_id: str, new_state: LifecycleState) -> LifecycleState:
"""Führt einen Lifecycle-Übergang aus; wirft bei illegaler Kante.
Show-Lock (§26.3): Übergänge, die Installation/Update bedeuten
(INSTALLED von DISCOVERED/VALIDATED), sind gesperrt. Aktivieren
bereits installierter Plugins (ENABLED/COMPILED/ACTIVE) bleibt
erlaubt (§17.7 Live: Layerparameter und Livefunktionen nutzbar).
"""
record = self._plugins.get(plugin_id)
if record is None:
raise KeyError(f"unbekanntes Plugin {plugin_id!r}")
current = record.state
if new_state not in _TRANSITIONS[current]:
raise InvalidTransitionError(
f"{plugin_id}: Übergang {current.value}{new_state.value} nicht erlaubt"
)
if (
self._show_lock
and new_state is LifecycleState.INSTALLED
and current in (LifecycleState.DISCOVERED, LifecycleState.VALIDATED)
):
raise InvalidTransitionError(
f"{plugin_id}: Installation im Show-Lock gesperrt (§26.3)"
)
record.state = new_state
record.last_error = None
return record.state
def quarantine(self, plugin_id: str, reason: str) -> None:
"""Fehlerfall: Plugin überbrücken, Projekt bleibt nutzbar (§3.5)."""
record = self._plugins.get(plugin_id)
if record is None:
raise KeyError(f"unbekanntes Plugin {plugin_id!r}")
record.state = LifecycleState.QUARANTINED
record.last_error = reason
# ---------- Abfragen ----------
def get(self, plugin_id: str) -> PluginRecord | None:
return self._plugins.get(plugin_id)
def by_state(self, state: LifecycleState) -> list[PluginRecord]:
return [r for r in self._plugins.values() if r.state is state]
def active_plugins(self) -> list[PluginRecord]:
return self.by_state(LifecycleState.ACTIVE)
def all(self) -> list[PluginRecord]:
return list(self._plugins.values())
def compatible_backends(self, plugin_id: str) -> frozenset[str]:
"""Gemeinsame Backends: deklariert im Manifest und lokal verfügbar."""
record = self._plugins.get(plugin_id)
if record is None:
return frozenset()
declared = set(record.manifest.get("entrypoints", {}))
supported = set(
record.manifest.get("capabilities", {}).get("supported_backends", [])
)
return frozenset(declared & supported)
# ---------- Interna ----------
def _upsert(self, record: PluginRecord) -> None:
existing = self._plugins.get(record.plugin_id)
if existing is not None and existing.version != record.version:
# Versionskonflikt: neuer Stand gewinnt nur ohne Show-Lock.
# Ein Update startet einen neuen Zyklus: discover() hat das
# neue Manifest bereits validiert (§14.5), also ist VALIDATED
# der korrekte Zustand eine erneute Installation ist nötig,
# der alte INSTALLED/ACTIVE-Status gilt nicht mehr.
if self._show_lock:
raise InvalidTransitionError(
f"{record.plugin_id}: Versionswechsel {existing.version}"
f"{record.version} im Show-Lock gesperrt (§26.3)"
)
self._plugins[record.plugin_id] = record
@@ -0,0 +1,227 @@
"""Plugin-Manifest und Validierung (PLAN.md §14.214.6, §27.2).
Sicherheitsgrenzen:
- Pfadsicherheit: keine absoluten Pfade, kein '..' in Manifest und ZIP
- ZIP-Bomb-Limits, Dateigrößenlimits, erlaubte Dateitypen
- eindeutige Plugin-ID (reverse-dns), SemVer, api_version
- Shader-Dateien müssen je deklariertem Backend existieren
- max. 8 generische DMX-Slots je Effektinstanz (§14.7)
"""
from __future__ import annotations
import json
import zipfile
from enum import StrEnum
from pathlib import Path, PurePosixPath
from typing import Any
MANIFEST_SCHEMA_VERSION = 1
MAX_PLUGIN_FILES = 512
MAX_TOTAL_UNPACKED = 32 * 1024 * 1024
MAX_FILE_SIZE = 8 * 1024 * 1024
_ALLOWED_SUFFIXES = {
".json",
".hlsl",
".frag",
".vert",
".glsl",
".png",
".md",
".txt",
".toml",
".csv",
}
_ALLOWED_BACKENDS = {"d3d11", "gl", "gles"}
class PluginKind(StrEnum):
SOURCE = "source"
GENERATOR = "generator"
FILTER = "filter"
TRANSITION = "transition"
MIXER = "mixer"
OUTPUT = "output"
CONTROL = "control"
AUTOMATION = "automation"
def _safe_relative(raw: str) -> PurePosixPath | None:
"""Prüft Pfadsicherheit; None wenn unsicher (absolut oder Traversal)."""
if not raw:
return None
p = PurePosixPath(raw)
if p.is_absolute() or ".." in p.parts:
return None
return p
def _validate_parameters(params: list[dict[str, Any]]) -> list[str]:
errors: list[str] = []
seen: set[str] = set()
total_dmx_slots = 0
for param in params:
pid = param.get("id")
if not pid or not isinstance(pid, str):
errors.append("parameter without id")
continue
if pid in seen:
errors.append(f"duplicate parameter id: {pid}")
seen.add(pid)
ptype = param.get("type")
if ptype not in {"float", "int", "enum", "bool", "color"}:
errors.append(f"parameter {pid}: invalid type {ptype!r}")
if ptype == "float":
for key in ("minimum", "maximum", "default"):
if key not in param:
errors.append(f"parameter {pid}: missing {key}")
slots = param.get("dmx_slots", [])
if not isinstance(slots, list) or any(not isinstance(s, int) for s in slots):
errors.append(f"parameter {pid}: dmx_slots must be int list")
slots = []
total_dmx_slots += len(slots)
if total_dmx_slots > 8:
errors.append(f"dmx slot footprint {total_dmx_slots} exceeds 8 (§14.7)")
return errors
def _valid_plugin_id(pid: str) -> bool:
if ".." in pid or len(pid) < 5:
return False
parts = pid.split(".")
if len(parts) < 2:
return False
allowed = set("abcdefghijklmnopqrstuvwxyz0123456789._-")
return all(c in allowed for c in pid)
def _valid_semver(version: str) -> bool:
parts = version.split(".")
if len(parts) != 3:
return False
try:
for p in parts:
int(p)
except ValueError:
return False
return True
def validate_manifest(
manifest: dict[str, Any], plugin_root: Path | None = None
) -> list[str]:
"""Validiert ein geparstes Manifest; leere Fehlerliste = gültig.
plugin_root: wenn gesetzt, werden deklarierte Shader auf Existenz geprüft.
"""
errors: list[str] = []
if manifest.get("schema_version") != MANIFEST_SCHEMA_VERSION:
errors.append(f"schema_version must be {MANIFEST_SCHEMA_VERSION}")
pid = manifest.get("id", "")
if not isinstance(pid, str) or not _valid_plugin_id(pid):
errors.append(f"invalid plugin id: {pid!r} (expected reverse-dns)")
for key in ("name", "version", "vendor"):
value = manifest.get(key)
if not isinstance(value, str) or not value:
errors.append(f"missing or empty {key}")
if not _valid_semver(manifest.get("version", "")):
errors.append("version must be semantic (X.Y.Z)")
if manifest.get("api_version") != MANIFEST_SCHEMA_VERSION:
errors.append(f"api_version must be {MANIFEST_SCHEMA_VERSION}")
if manifest.get("kind") not in {k.value for k in PluginKind}:
errors.append(f"invalid kind: {manifest.get('kind')!r}")
entrypoints = manifest.get("entrypoints", {})
if not isinstance(entrypoints, dict) or not entrypoints:
errors.append("entrypoints required")
else:
supported = set(manifest.get("capabilities", {}).get("supported_backends", []))
unknown = supported - _ALLOWED_BACKENDS
if unknown:
errors.append(f"unsupported backends: {sorted(unknown)}")
for backend, entry in entrypoints.items():
if backend not in _ALLOWED_BACKENDS:
errors.append(f"entrypoint backend {backend!r} not allowed")
continue
if backend in supported:
passes = entry.get("passes", [])
if not passes:
errors.append(f"entrypoint {backend}: no passes")
for pas in passes:
shader_key = "pixel_shader" if "pixel_shader" in pas else "fragment"
shader_rel = pas.get(shader_key)
if not shader_rel:
errors.append(f"entrypoint {backend}: pass without shader")
continue
sp = _safe_relative(shader_rel)
if sp is None:
errors.append(f"unsafe shader path: {shader_rel!r}")
continue
if plugin_root is not None and not (plugin_root / sp).is_file():
errors.append(f"missing shader file: {shader_rel}")
params = manifest.get("parameters", [])
if not isinstance(params, list):
errors.append("parameters must be a list")
else:
errors.extend(_validate_parameters(params))
if manifest.get("failure_mode") not in {"bypass", "hold", "black"}:
errors.append("failure_mode must be bypass|hold|black")
return errors
def validate_plugin_zip(zip_path: Path) -> list[str]:
"""Prüft ein Plugin-ZIP: Pfadsicherheit, Limits, Typen, Manifest (§27.2)."""
errors: list[str] = []
try:
with zipfile.ZipFile(zip_path) as zf:
names = zf.namelist()
if len(names) > MAX_PLUGIN_FILES:
errors.append(f"too many files: {len(names)} > {MAX_PLUGIN_FILES}")
total = 0
for info in zf.infolist():
if info.is_dir():
continue
total += info.file_size
if info.file_size > MAX_FILE_SIZE:
errors.append(f"file too large: {info.filename}")
if _safe_relative(info.filename) is None:
errors.append(f"unsafe path in zip: {info.filename!r}")
if Path(info.filename).suffix.lower() not in _ALLOWED_SUFFIXES:
errors.append(f"disallowed file type: {info.filename}")
if total > MAX_TOTAL_UNPACKED:
errors.append(f"zip too large unpacked: {total} > {MAX_TOTAL_UNPACKED}")
manifest_name = next(
(n for n in names if n.endswith("plugin.json") and n.count("/") == 1),
None,
)
if manifest_name is None:
errors.append("plugin.json not found at package root")
else:
manifest = json.loads(zf.read(manifest_name))
errors.extend(validate_manifest(manifest))
except zipfile.BadZipFile:
errors.append("not a valid zip file")
except json.JSONDecodeError as exc:
errors.append(f"plugin.json invalid JSON: {exc}")
return errors
def load_manifest(plugin_dir: Path) -> tuple[dict[str, Any], list[str]]:
"""Lädt und validiert plugin.json aus einem Plugin-Verzeichnis."""
manifest_path = plugin_dir / "plugin.json"
if not manifest_path.is_file():
return {}, ["plugin.json missing"]
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
return {}, [f"plugin.json invalid JSON: {exc}"]
return manifest, validate_manifest(manifest, plugin_root=plugin_dir)