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:
@@ -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.2–14.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)
|
||||
Reference in New Issue
Block a user