65edf8369e
Ampel / ampel (push) Successful in 28s
Commander-Wunsch: nicht ins Benutzerprofil, sondern wie jedes andere Programm
nach C:\Program Files - und selbst waehlbar, mit diesem Standard.
## Was neu ist
- Feld "Installieren nach" mit Knopf "Aendern ..." (Ordner-Dialog). Standard
ist [Environment]::GetFolderPath("ProgramFiles") + "Rippy Worker" - der
ECHTE Pfad; deutsche Windows-Versionen zeigen im Explorer "Programme", der
Pfad heisst trotzdem "Program Files".
- Der Ordner-Dialog liefert den ELTERN-Ordner, "Rippy Worker" haengt der
Installer selbst an. Sonst koennte die Deinstallation einen fremden Ordner
mitloeschen - sie macht Remove-Item -Recurse auf das Zielverzeichnis.
- install.ps1 bekommt -InstallDir mit demselben Standard.
## Drei Folgen, die mitbehandelt werden mussten
1. RECHTE. In C:\Program Files darf nur ein Administrator schreiben. Die .exe
traegt jetzt ein requireAdministrator-Manifest (-requireAdmin in
build-exe.ps1), fragt also beim Doppelklick EINMAL per UAC - danach stimmen
die Rechte fuer alles Weitere. Verifiziert: "requireAdministrator" steckt im
Manifest, "asInvoker" nicht mehr. Zusaetzlich prueft der Installer VOR dem
Entpacken, ob er dort schreiben darf, und nennt bei Nein die zwei Wege
(als Administrator starten ODER Ziel ins eigene Profil legen) - statt
irgendwo tief "Zugriff verweigert" zu produzieren.
2. DAS LOG. tray.py schrieb worker.log NEBEN das Programm. Unter Program Files
darf ein normaler Benutzer das nicht - der Worker waere beim Starten
gescheitert, und zwar still. Das Log liegt jetzt in
%LOCALAPPDATA%\Rippy Worker\worker.log, also da, wo veraenderliche Daten
hingehoeren. Rueckfall auf das Programmverzeichnis bleibt fuer
Installationen ins eigene Profil.
3. DER AUTOSTART. Bei einer Installation unter Programme ist es eine
Installation FUER DIE MASCHINE - der Autostart geht deshalb in den
Autostart-Ordner aller Benutzer (CommonStartup). Das loest zugleich ein
Rechte-Problem: erhoeht man ueber ein FREMDES Administratorkonto, waere
GetFolderPath("Startup") der Ordner dieses Admins, also der falsche. Bei
einem Ziel im eigenen Profil bleibt es persoenlich. uninstall.ps1 raeumt
beide Orte ab.
## Geprueft
- Leerzeichen im Pfad: die erzeugte .bat mit cd /d "%~dp0", PATH-Erweiterung
und relativem Aufruf laeuft aus "C:\...\Rippy Test Ordner" korrekt durch.
- Alle drei .ps1 ASCII-rein und fehlerfrei geparst.
- RippyWorkerSetup.exe neu gebaut (52736 -> 57344 Bytes, Version 1.0.1.0).
UI: Der Copy-Paste-Befehl fuer den CLI-Weg sagt jetzt, dass PowerShell als
Administrator laufen muss, und nennt -InstallDir als Ausweg ohne Adminrechte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
149 lines
4.6 KiB
Python
149 lines
4.6 KiB
Python
"""Tray-Symbol für den NATIVEN Windows-Worker (nicht im Docker-Image genutzt).
|
|
|
|
Zeigt den Worker-Status unten rechts neben der Uhr und bietet:
|
|
Start/Stopp, Rippy im Browser öffnen, Log anzeigen, Beenden. Gestartet
|
|
wird es über start-tray.bat (setzt die Verbindungs-Umgebung und ruft
|
|
pythonw.exe auf — kein Konsolenfenster). Der Worker-Prozess (celery,
|
|
Queue transcode) läuft als Kind-Prozess und stoppt mit dem Tray.
|
|
|
|
Abhängigkeiten pystray + Pillow installiert NUR der Windows-Installer
|
|
(install.ps1) — das Docker-Image bleibt davon unberührt.
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import webbrowser
|
|
|
|
try:
|
|
import pystray
|
|
from PIL import Image, ImageDraw
|
|
except ImportError:
|
|
print("Tray braucht pystray + Pillow — install.ps1 erneut ausführen.")
|
|
sys.exit(1)
|
|
|
|
BASIS = os.path.dirname(os.path.abspath(__file__))
|
|
RIPPY_HOST = os.getenv("RIPPY_TRAY_HOST", "")
|
|
WORKER_NAME = os.getenv("WORKER_NAME", "windows-worker")
|
|
|
|
|
|
def _log_pfad() -> str:
|
|
"""Wohin das Worker-Log geschrieben wird.
|
|
|
|
NICHT ins Programmverzeichnis (Befund 25.07.2026, Commander-Wunsch): Der
|
|
Worker wird jetzt standardmäßig nach `C:\\Program Files\\Rippy Worker`
|
|
installiert, und dort darf ein normaler Benutzer nicht schreiben. Ein Log
|
|
neben dem Programm hätte den Worker beim Starten scheitern lassen — genau
|
|
die Art Fehler, die man erst merkt, wenn nichts passiert.
|
|
|
|
Deshalb ins Benutzerprofil (`%LOCALAPPDATA%\\Rippy Worker`), wie es sich für
|
|
veränderliche Daten gehört. Nur falls das nicht klappt, bleibt das
|
|
Programmverzeichnis der Rückfall — bei einer Installation ins Benutzerprofil
|
|
(frei wählbar) ist es ohnehin beschreibbar.
|
|
"""
|
|
basis_daten = os.getenv("LOCALAPPDATA") or os.getenv("APPDATA") or ""
|
|
if basis_daten:
|
|
ordner = os.path.join(basis_daten, "Rippy Worker")
|
|
try:
|
|
os.makedirs(ordner, exist_ok=True)
|
|
return os.path.join(ordner, "worker.log")
|
|
except OSError:
|
|
pass
|
|
return os.path.join(BASIS, "worker.log")
|
|
|
|
|
|
LOG_PFAD = _log_pfad()
|
|
|
|
prozess = None
|
|
|
|
|
|
def disc_bild(laeuft: bool) -> Image.Image:
|
|
"""Disc-Symbol im Rippy-Look: Indigo wenn der Worker läuft, grau sonst."""
|
|
bild = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
|
|
zeichner = ImageDraw.Draw(bild)
|
|
farbe = (99, 102, 241, 255) if laeuft else (125, 132, 145, 255)
|
|
zeichner.ellipse([4, 4, 60, 60], fill=farbe)
|
|
zeichner.ellipse([24, 24, 40, 40], fill=(255, 255, 255, 255))
|
|
return bild
|
|
|
|
|
|
def worker_laeuft() -> bool:
|
|
return prozess is not None and prozess.poll() is None
|
|
|
|
|
|
def worker_starten(icon=None, item=None):
|
|
global prozess
|
|
if worker_laeuft():
|
|
return
|
|
celery = os.path.join(BASIS, "venv", "Scripts", "celery.exe")
|
|
log = open(LOG_PFAD, "a", encoding="utf-8", errors="replace")
|
|
prozess = subprocess.Popen(
|
|
[celery, "-A", "celery_app", "worker", "--loglevel=info",
|
|
"-Q", "transcode", "--pool=solo", "-n", f"{WORKER_NAME}@%h"],
|
|
cwd=BASIS, stdout=log, stderr=subprocess.STDOUT,
|
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
)
|
|
if icon:
|
|
icon.icon = disc_bild(True)
|
|
icon.update_menu()
|
|
|
|
|
|
def worker_stoppen(icon=None, item=None):
|
|
global prozess
|
|
if worker_laeuft():
|
|
prozess.terminate()
|
|
try:
|
|
prozess.wait(15)
|
|
except subprocess.TimeoutExpired:
|
|
prozess.kill()
|
|
prozess = None
|
|
if icon:
|
|
icon.icon = disc_bild(False)
|
|
icon.update_menu()
|
|
|
|
|
|
def umschalten(icon, item):
|
|
if worker_laeuft():
|
|
worker_stoppen(icon)
|
|
else:
|
|
worker_starten(icon)
|
|
|
|
|
|
def rippy_oeffnen(icon, item):
|
|
if RIPPY_HOST:
|
|
webbrowser.open(f"http://{RIPPY_HOST}")
|
|
|
|
|
|
def log_anzeigen(icon, item):
|
|
if os.path.exists(LOG_PFAD):
|
|
os.startfile(LOG_PFAD) # noqa: S606 — bewusst: Standard-Editor des Nutzers
|
|
|
|
|
|
def beenden(icon, item):
|
|
worker_stoppen()
|
|
icon.stop()
|
|
|
|
|
|
menue = pystray.Menu(
|
|
pystray.MenuItem(
|
|
lambda item: f'Rippy-Worker "{WORKER_NAME}": {"läuft" if worker_laeuft() else "gestoppt"}',
|
|
None, enabled=False,
|
|
),
|
|
pystray.Menu.SEPARATOR,
|
|
pystray.MenuItem(
|
|
lambda item: "Worker stoppen" if worker_laeuft() else "Worker starten",
|
|
umschalten,
|
|
),
|
|
pystray.MenuItem("Rippy öffnen", rippy_oeffnen),
|
|
pystray.MenuItem("Log anzeigen", log_anzeigen),
|
|
pystray.Menu.SEPARATOR,
|
|
pystray.MenuItem("Beenden (stoppt den Worker)", beenden),
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
tray = pystray.Icon("rippy-worker", disc_bild(False), "Rippy Worker", menue)
|
|
worker_starten()
|
|
tray.icon = disc_bild(worker_laeuft())
|
|
tray.run()
|