feat(windows): V2-4 fertig — RippySetup.exe, Taskmanager-Eintrag, Deinstallation
Ampel / ampel (push) Failing after 46s

WAS: Eine 28,6-MB-Datei, drei Betriebsarten. Doppelklick installiert,
`--dienst` laeuft im Hintergrund, `--deinstallieren` raeumt sich weg.
Dazu packaging/windows/build.py, das sie baut.

DIE ZWEI COMMANDER-WUENSCHE, GEMESSEN:

1. EINTRAG IM TASKMANAGER
     ProcessName   Rippy
     Beschreibung  Rippy — automatisches Ripping
     Produkt       Rippy
     Version       2.0.0
   Der Prozessname kommt vom Dateinamen (bei der Installation kopiert sich
   die Datei als Rippy.exe), die Beschreibung aus einer eingebetteten
   Versions-Ressource. Ohne die steht dort nichts, und Windows SmartScreen
   sieht eine EXE ohne Herausgeberangabe genauso misstrauisch wie ein Mensch.

2. EINTRAG IN PROGRAMME UND FEATURES
   Aus der Registry zurueckgelesen, nicht behauptet:
     DisplayName      Rippy
     DisplayVersion   2.0.0
     Publisher        Rippy
     InstallLocation  <Zielordner>
     UninstallString  "<pfad>\Rippy.exe" --deinstallieren
     EstimatedSize    29241  (KiB)
     NoModify/NoRepair 1
   HKCU statt HKLM: Der Eintrag erscheint genauso in "Apps & Features", die
   Installation laeuft aber ohne UAC-Abfrage durch.

VOLLER KREISLAUF GEMESSEN: installieren -> Rippy.exe + rippy.ico liegen da,
Registry-Eintrag da, Autostart da; Dienst starten -> nach 1 s bereit, alle
sieben Endpunkte HTTP 200, Oberflaeche kommt; deinstallieren -> nach ~4 s
sind Datei, Ordner, Registry-Eintrag, Autostart UND das Aufraeum-Skript weg.

VIER FEHLER, DIE NUR DIE FERTIGE EXE ZEIGT:

1. rippy/store baute die Engine BEIM IMPORT, mit PostgreSQL als Vorgabe.
   Im Windows-Paket gibt es psycopg2 bewusst nicht -> die EXE starb sofort.
   Die Engine entsteht jetzt beim ersten Zugriff. Und der Fehler war
   grundsaetzlicher als der fehlende Treiber: Ein Modul, das beim Import
   schon eine Verbindung aufbaut, laesst sich gar nicht mehr umstellen —
   store.verbinden() kaeme immer zu spaet.

2. celery_client baute den Broker-Client beim Import. Dasselbe Muster, und
   `from celery_client import celery_client` loeste es sogar dann aus, wenn
   nie ein Rip angestossen wird. Jetzt traege, mit KeinBroker als klarer
   Ansage statt eines Importfehlers.

3. Die Selbstloeschung beim Deinstallieren ging als EIN Argument an cmd.
   Python maskiert dabei die inneren Anfuehrungszeichen — cmd suchte einen
   Dateinamen mit Backslashes davor, fand nichts und meldete nichts (>nul).
   Registry und Autostart waren weg, die 28-MB-Datei blieb liegen: ein still
   scheiternder Hintergrundprozess, wie ihn AGENTS.md beschreibt. Jetzt ein
   Aufraeum-Skript, das 15-mal versucht (die Onefile-EXE laeuft als ZWEI
   Prozesse; der Starter haelt die Datei nach dem Ende noch offen).

4. deinstallieren() raeumte den STANDARD-Ordner auf statt des tatsaechlichen.
   Wer nach --ziel D:\Rippy installiert hatte, dessen Dateien waeren
   geblieben, waehrend ein fremder Ordner angefasst worden waere. Der Pfad
   steht in der Registry (InstallLocation) und wird jetzt dort gelesen.

WAS NOCH NICHT GEHT, ausdruecklich: Ein RIP laesst sich unter Windows noch
nicht anstossen — die Zustellung laeuft weiter ueber Celery. Die Umstellung
auf die LocalQueue ist V2-5. Oberflaeche, Laufwerks-Erkennung, Auswurf und
alles Lesende laufen.

GEMESSEN: ruff sauber, 456 Tests gruen + 15 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-28 11:00:39 +02:00
co-authored by Claude Opus 5
parent e5254c23f0
commit 059183c651
17 changed files with 1251 additions and 46 deletions
+5
View File
@@ -0,0 +1,5 @@
"""Plattform-Anbindung: Windows-Dienst/Tray/Registry, systemd, Prozessstart.
Alles, was NUR auf einer Plattform gilt und nichts mit dem Fachkern zu tun
hat. Der Kern kennt diese Module nicht — er kennt die Ports.
"""
+128
View File
@@ -0,0 +1,128 @@
"""Die Werte für „Programme und Features" — geprüft, bevor sie geschrieben werden.
Die reinen Funktionen laufen auf jeder Plattform. Die Tests, die wirklich in
die Registry schreiben, laufen nur unter Windows und räumen hinter sich auf —
sie benutzen einen eigenen Schlüsselnamen, damit eine echte Installation
niemals angefasst wird.
"""
import os
import pytest
from rippy.platform import win_registry as reg
TEST_SCHLUESSEL = "RippyTestEintrag" # NIE der echte
nur_windows = pytest.mark.skipif(os.name != "nt", reason="Registry gibt es nur unter Windows")
# ── Die Werte selbst ────────────────────────────────────────────────────
def test_pflichtwerte_sind_gesetzt():
"""Windows zeigt einen Eintrag OHNE DisplayName gar nicht an, und ohne
UninstallString ist der Deinstallieren-Knopf tot."""
werte = reg.eintrag_werte(r"C:\Rippy", r"C:\Rippy\RippySetup.exe", "2.0.0")
assert werte["DisplayName"]
assert werte["UninstallString"]
def test_uninstallstring_ist_gequotet():
"""DER klassische Fehler. Ohne Anfuehrungszeichen liest Windows
„C:\\Program Files\\Rippy\\x.exe" als „C:\\Program" mit Argumenten —
beim Deinstallieren passiert dann nichts oder etwas Falsches.
"""
werte = reg.eintrag_werte(
r"C:\Program Files\Rippy", r"C:\Program Files\Rippy\RippySetup.exe", "2.0.0")
assert werte["UninstallString"].startswith('"C:\\Program Files\\Rippy\\RippySetup.exe"')
assert werte["UninstallString"].endswith("--deinstallieren")
def test_stille_deinstallation_ist_vorgesehen():
"""Windows benutzt QuietUninstallString bei automatisierten Entfernungen.
Fehlt er, faellt Windows auf den lauten zurueck und ein Dialog blockiert
eine unbeaufsichtigte Abmeldung."""
werte = reg.eintrag_werte(r"C:\Rippy", r"C:\Rippy\s.exe", "2.0.0")
assert "--still" in werte["QuietUninstallString"]
def test_reparieren_und_aendern_sind_ausgeblendet():
"""Rippy kann beides nicht. Knoepfe, die ins Leere fuehren, sind
dieselbe Sorte Placebo wie ein Fortschrittsbalken ohne Fortschritt."""
werte = reg.eintrag_werte(r"C:\Rippy", r"C:\Rippy\s.exe", "2.0.0")
assert werte["NoModify"] == 1
assert werte["NoRepair"] == 1
def test_groesse_wird_in_kib_erwartet():
"""EstimatedSize ist in KiB. Wer Bytes eintraegt, meldet in „Programme und
Features" das Tausendfache — aus 90 MB werden 90 GB."""
werte = reg.eintrag_werte(r"C:\Rippy", r"C:\Rippy\s.exe", "2.0.0", groesse_kib=92_160)
assert werte["EstimatedSize"] == 92_160 # = 90 MiB
def test_ohne_icon_kein_leerer_eintrag():
"""Ein leerer DisplayIcon laesst Windows das Standard-Symbol zeigen —
aber ein Wert, der da ist und ins Leere zeigt, macht ein kaputtes Bild."""
assert "DisplayIcon" not in reg.eintrag_werte(r"C:\R", r"C:\R\s.exe", "2.0.0")
assert "DisplayIcon" in reg.eintrag_werte(r"C:\R", r"C:\R\s.exe", "2.0.0", icon=r"C:\R\r.ico")
def test_uninstall_pfad_liegt_unter_dem_richtigen_schluessel():
pfad = reg.uninstall_pfad("Rippy")
assert pfad.startswith(r"Software\Microsoft\Windows\CurrentVersion\Uninstall")
assert pfad.endswith(r"\Rippy")
def test_installationsordner_braucht_keine_adminrechte():
"""Program Files waere gewohnter, verlangt aber bei JEDER Installation
und jedem Update eine UAC-Abfrage."""
ordner = reg.standard_installationsordner()
assert "Rippy" in ordner
if os.name == "nt":
assert "Program Files" not in ordner
def test_ordnergroesse_zaehlt_wirklich(tmp_path):
(tmp_path / "a.bin").write_bytes(b"x" * 4096)
unter = tmp_path / "unter"
unter.mkdir()
(unter / "b.bin").write_bytes(b"y" * 4096)
assert reg.ordnergroesse_kib(str(tmp_path)) == 8
# ── Wirklich schreiben (nur Windows) ────────────────────────────────────
@nur_windows
def test_eintrag_landet_wirklich_in_der_registry(tmp_path):
"""Nachsehen statt glauben: Ein Schreibvorgang, der quittiert wurde, ist
kein Beweis. Genau diese Lehre steht in AGENTS.md."""
ordner = str(tmp_path)
(tmp_path / "RippySetup.exe").write_bytes(b"x" * 1024)
deinstaller = str(tmp_path / "RippySetup.exe")
try:
reg.eintragen(ordner, deinstaller, "2.0.0-test", schluessel=TEST_SCHLUESSEL)
assert reg.ist_eingetragen(TEST_SCHLUESSEL) is True
gelesen = reg.gelesene_werte(TEST_SCHLUESSEL)
assert gelesen["DisplayName"] == "Rippy"
assert gelesen["DisplayVersion"] == "2.0.0-test"
assert gelesen["UninstallString"].startswith('"' + deinstaller + '"')
assert gelesen["NoModify"] == 1
finally:
reg.austragen(TEST_SCHLUESSEL)
assert reg.ist_eingetragen(TEST_SCHLUESSEL) is False
@nur_windows
def test_austragen_auf_nichts_ist_kein_fehler():
assert reg.austragen("RippyGibtEsNicht") is False
@nur_windows
def test_autostart_setzen_lesen_entfernen(tmp_path):
name = "RippyTestAutostart"
befehl = '"' + str(tmp_path / "Rippy.exe") + '"'
try:
reg.autostart_setzen(befehl, name=name)
assert reg.autostart_wert(name) == befehl
finally:
reg.autostart_entfernen(name)
assert reg.autostart_wert(name) == ""
+217
View File
@@ -0,0 +1,217 @@
"""Windows-Registrierung: „Programme und Features" und Autostart.
## Wie ein Programm in „Programme und Features" landet
Nicht durch das Kopieren von Dateien. Windows liest diese Liste ausschließlich
aus der Registry, unter:
HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\<Schlüssel>
HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\<Schlüssel>
HKCU ist die Installation für DIESEN Benutzer, HKLM die für alle — letztere
braucht Administratorrechte. Rippy nimmt HKCU: Der Eintrag erscheint genauso in
„Apps & Features", aber die Installation läuft ohne UAC-Abfrage durch.
Pflicht sind genau zwei Werte:
DisplayName was in der Liste steht
UninstallString was Windows startet, wenn man „Deinstallieren" klickt
Alles andere ist Kür, aber es macht den Unterschied zwischen einem
gepflegten Eintrag und einem, der aussieht wie Schadsoftware:
`DisplayVersion`, `Publisher`, `InstallLocation`, `DisplayIcon`,
`EstimatedSize` (in KiB!), `NoModify`, `NoRepair`.
## Autostart
HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run
Bewusst HKCU und nicht der `Startup`-Ordner: Ein Eintrag in Run ist mit einem
Registry-Zugriff wieder weg, eine Verknüpfung im Startup-Ordner muss man
suchen. Und bewusst NICHT als Windows-Dienst — der bräuchte Administrator­
rechte bei jeder Installation und jedem Update.
## Warum die Werte hier getrennt gebaut werden
`eintrag_werte()` und `uninstall_pfad()` sind reine Funktionen. Damit lässt
sich prüfen, dass die Pflichtwerte gesetzt sind und der Deinstallations-Befehl
richtig gequotet ist — ohne in die Registry zu schreiben. Ein falsch
gequoteter `UninstallString` ist ein klassischer Fehler: Steht der Pfad mit
Leerzeichen ohne Anführungszeichen drin, startet Windows beim Deinstallieren
das falsche Programm oder gar nichts.
"""
import os
import sys
# Der Schlüsselname unter Uninstall. Muss über Versionen hinweg GLEICH bleiben —
# ändert er sich, entsteht bei einem Update ein zweiter Eintrag, und der alte
# bleibt als Leiche stehen.
SCHLUESSEL = "Rippy"
UNINSTALL_PFAD = r"Software\Microsoft\Windows\CurrentVersion\Uninstall"
RUN_PFAD = r"Software\Microsoft\Windows\CurrentVersion\Run"
ANZEIGENAME = "Rippy"
HERAUSGEBER = "Rippy"
def uninstall_pfad(schluessel: str = SCHLUESSEL) -> str:
return UNINSTALL_PFAD + "\\" + schluessel
def _quoten(pfad: str) -> str:
"""Pfad für die Kommandozeile in Anführungszeichen — immer.
„C:\\Program Files\\Rippy\\" ohne Anführungszeichen liest Windows als
„C:\\Program" mit den Argumenten „Files\\Rippy\\". Beim Deinstallieren
passiert dann entweder nichts oder etwas Falsches.
"""
return '"' + pfad.strip('"') + '"'
def eintrag_werte(installationsordner: str, deinstaller: str, version: str,
icon: str = "", groesse_kib: int = 0) -> dict:
"""Die Werte für „Programme und Features" (reine Funktion).
`groesse_kib` ist in KiB — Windows zeigt sie als „Größe" an. Wer Bytes
einträgt, meldet 1000-mal zu viel; das ist ein häufiger Fehler.
"""
werte = {
"DisplayName": ANZEIGENAME,
"DisplayVersion": version,
"Publisher": HERAUSGEBER,
"InstallLocation": installationsordner,
"UninstallString": _quoten(deinstaller) + " --deinstallieren",
"QuietUninstallString": _quoten(deinstaller) + " --deinstallieren --still",
# Rippy kann sich nicht selbst reparieren oder teilweise installieren —
# die Knöpfe dafür gehören also ausgeblendet, statt ins Leere zu führen.
"NoModify": 1,
"NoRepair": 1,
}
if icon:
werte["DisplayIcon"] = icon
if groesse_kib:
werte["EstimatedSize"] = int(groesse_kib)
return werte
def ordnergroesse_kib(ordner: str) -> int:
"""Größe eines Ordners in KiB — für den EstimatedSize-Wert."""
summe = 0
for wurzel, _, dateien in os.walk(ordner):
for name in dateien:
try:
summe += os.path.getsize(os.path.join(wurzel, name))
except OSError:
pass
return summe // 1024
# ── Schreiben und Entfernen (nur unter Windows) ─────────────────────────
def _winreg():
if os.name != "nt":
raise RuntimeError("Registry-Zugriff gibt es nur unter Windows.")
import winreg
return winreg
def eintragen(installationsordner: str, deinstaller: str, version: str,
icon: str = "", schluessel: str = SCHLUESSEL) -> None:
"""Schreibt den Eintrag für „Programme und Features"."""
winreg = _winreg()
werte = eintrag_werte(
installationsordner, deinstaller, version, icon,
ordnergroesse_kib(installationsordner),
)
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, uninstall_pfad(schluessel)) as k:
for name, wert in werte.items():
if isinstance(wert, int):
winreg.SetValueEx(k, name, 0, winreg.REG_DWORD, wert)
else:
winreg.SetValueEx(k, name, 0, winreg.REG_SZ, str(wert))
def austragen(schluessel: str = SCHLUESSEL) -> bool:
"""Entfernt den Eintrag. False, wenn es ihn gar nicht gab."""
winreg = _winreg()
try:
winreg.DeleteKey(winreg.HKEY_CURRENT_USER, uninstall_pfad(schluessel))
return True
except FileNotFoundError:
return False
def ist_eingetragen(schluessel: str = SCHLUESSEL) -> bool:
winreg = _winreg()
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, uninstall_pfad(schluessel)):
return True
except FileNotFoundError:
return False
def gelesene_werte(schluessel: str = SCHLUESSEL) -> dict:
"""Was WIRKLICH in der Registry steht — für `rippy doctor` und Tests.
Nachsehen statt glauben: Ein Schreibvorgang, der quittiert wurde, ist
kein Beweis dafür, dass der Eintrag angekommen ist.
"""
winreg = _winreg()
ergebnis = {}
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, uninstall_pfad(schluessel)) as k:
i = 0
while True:
try:
name, wert, _ = winreg.EnumValue(k, i)
except OSError:
break
ergebnis[name] = wert
i += 1
except FileNotFoundError:
pass
return ergebnis
# ── Autostart ───────────────────────────────────────────────────────────
def autostart_setzen(befehl: str, name: str = SCHLUESSEL) -> None:
winreg = _winreg()
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, RUN_PFAD) as k:
winreg.SetValueEx(k, name, 0, winreg.REG_SZ, befehl)
def autostart_entfernen(name: str = SCHLUESSEL) -> bool:
winreg = _winreg()
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_PFAD, 0,
winreg.KEY_ALL_ACCESS) as k:
winreg.DeleteValue(k, name)
return True
except FileNotFoundError:
return False
def autostart_wert(name: str = SCHLUESSEL) -> str:
winreg = _winreg()
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_PFAD) as k:
return winreg.QueryValueEx(k, name)[0]
except (FileNotFoundError, OSError):
return ""
def standard_installationsordner() -> str:
"""%LOCALAPPDATA%\\Rippy — dorthin darf ohne Administratorrechte geschrieben werden.
`Program Files` wäre der gewohnte Ort, verlangt aber bei JEDER Installation
und jedem Update eine UAC-Abfrage. Für ein Programm, das sich selbst
aktualisieren soll, ist das der falsche Handel.
"""
basis = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
return os.path.join(basis, "Rippy")
def laeuft_als_gebuendelte_exe() -> bool:
return getattr(sys, "frozen", False)
+32
View File
@@ -0,0 +1,32 @@
"""Kind-Prozesse starten, ohne dass ein Konsolenfenster aufblitzt.
## Der Befund, der das nötig gemacht hat (26.07.2026)
Commander: *„Es geht übrigens immer alle paar Sekunden ne CMD auf."* Und er hatte
recht — es war kein Geist, sondern der Herzschlag des Workers.
Der Windows-Worker läuft als `pythonw.exe`, also ohne eigene Konsole (das Tray
startet ihn mit CREATE_NO_WINDOW). Startet ein Prozess OHNE Konsole ein
Konsolenprogramm, legt Windows dafür eine NEUE Konsole an — und die ist sichtbar.
`capture_output=True` hilft nicht: Es leitet die Datenströme um, unterdrückt aber
kein Fenster.
Und der Worker startet solche Programme oft: `caps.py` fragt jede Minute
`HandBrakeCLI --help`, `--preset-list` und `--version` ab, dazu kommt die
Schlüssel-Automatik mit `makemkvcon`. Vier bis fünf Fenster pro Minute, in
Schüben — genau das „alle paar Sekunden".
## Warum eine eigene Datei
Damit es an EINER Stelle richtig ist. Die betroffenen Aufrufe stehen in caps.py,
ripping.py und schluessel.py; jeder hätte das Flag einzeln vergessen können, und
genau so ist es passiert. Auf Linux ist `CREATE_NO_WINDOW` nicht vorhanden und
`creationflags=0` eine Nulloperation — im Worker-Container gegengeprüft, damit
derselbe Code auf beiden Seiten läuft.
"""
import subprocess
# Auf Windows das Flag, auf Linux 0 (dort wird creationflags=0 akzeptiert und
# ignoriert — am 26.07.2026 im Worker-Container gemessen).
OHNE_FENSTER = getattr(subprocess, "CREATE_NO_WINDOW", 0)
+3 -3
View File
@@ -23,12 +23,12 @@ from rippy.queue import lokal
@pytest.fixture
def q(tmp_path):
"""Frische SQLite-Datenbank je Test, danach zurückdrehen."""
vorher_url, vorher_engine = store.DATABASE_URL, store.engine
vorher = store.zustand_sichern()
store.verbinden(f"sqlite:///{(tmp_path / 'q.db').as_posix()}")
store.init_db()
yield lokal
store.engine.dispose()
store.DATABASE_URL, store.engine = vorher_url, vorher_engine
store.engine_holen().dispose()
store.zustand_wiederherstellen(vorher)
def _lease_zuruecksetzen(auftrag_id, sekunden):
+83 -27
View File
@@ -120,7 +120,63 @@ def _engine_bauen(url: str):
return eng
engine = _engine_bauen(DATABASE_URL)
_engine = None
def engine_holen():
"""Die Engine — beim ERSTEN Zugriff gebaut, nicht beim Import.
## Warum das wichtig ist (Vorfall 28.08.2026)
Hier stand `engine = _engine_bauen(DATABASE_URL)` auf Modulebene. Die
Vorgabe von `DATABASE_URL` ist PostgreSQL — also versuchte SQLAlchemy schon
beim IMPORT dieses Moduls, den psycopg2-Dialekt zu laden. Im
Windows-Paket gibt es psycopg2 bewusst nicht (der Standalone-Betrieb
benutzt SQLite), und die fertige EXE starb sofort:
File "rippy/store/__init__.py", line 123, in <module>
ModuleNotFoundError: No module named 'psycopg2'
Das Tueckische: Im Repo und in beiden Containern faellt es nie auf, weil
psycopg2 dort installiert ist. Erst das gebaute Paket zeigt es.
Und der Fehler ist grundsaetzlicher als der fehlende Treiber: Ein Modul,
das beim Import schon eine Datenbankverbindung aufbaut, laesst sich nicht
mehr umstellen — `verbinden()` kaeme immer zu spaet.
"""
global _engine
if _engine is None:
_engine = _engine_bauen(DATABASE_URL)
return _engine
def __getattr__(name):
"""`store.engine` von aussen — baut die Engine bei Bedarf.
PEP 562: Wird nur gefragt, wenn der Name NICHT schon als Modul-Variable
existiert. Innerhalb dieses Moduls wird deshalb `engine_holen()` benutzt.
"""
if name == "engine":
return engine_holen()
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def zustand_sichern():
"""(URL, Engine) merken — OHNE eine Engine zu bauen.
Fuer Tests und Werkzeuge, die den Store voruebergehend umstellen. Der
naheliegende Weg `vorher = store.engine` ist FALSCH: Er baut die Engine
erst, obwohl vielleicht nie eine gebraucht wurde — und beim
Zurueckschreiben mit `store.engine = vorher` entsteht eine echte
Modul-Variable, die `__getattr__` von da an dauerhaft ueberdeckt.
Genau daran sind am 28.08.2026 zwanzig Tests gescheitert.
"""
return DATABASE_URL, _engine
def zustand_wiederherstellen(zustand) -> None:
global DATABASE_URL, _engine
DATABASE_URL, _engine = zustand
def verbinden(url: str) -> None:
@@ -134,9 +190,9 @@ def verbinden(url: str) -> None:
lesen — ein `from rippy.store import engine` bindet den alten Wert fest und
merkt von dieser Umstellung nichts. Genau darauf achtet rippy/queue/lokal.py.
"""
global engine, DATABASE_URL
global _engine, DATABASE_URL
DATABASE_URL = url
engine = _engine_bauen(url)
_engine = _engine_bauen(url)
@@ -171,7 +227,7 @@ def migrieren() -> None:
umschichten), ist Alembic die richtige Antwort — dann aber mit einer
bewussten Stempel-Runde.
"""
inspektor = inspect(engine)
inspektor = inspect(engine_holen())
vorhandene_tabellen = set(inspektor.get_table_names())
for tabelle, spalte, typ in schema.NACHZUTRAGENDE_SPALTEN:
if tabelle not in vorhandene_tabellen:
@@ -179,7 +235,7 @@ def migrieren() -> None:
spalten = {s["name"] for s in inspektor.get_columns(tabelle)}
if spalte in spalten:
continue
with engine.begin() as conn:
with engine_holen().begin() as conn:
conn.exec_driver_sql(f"ALTER TABLE {tabelle} ADD COLUMN {spalte} {typ}")
@@ -189,7 +245,7 @@ def init_db() -> None:
Wer zuerst startet — API oder Worker —, migriert; der andere findet die
Spalten dann bereits vor. Das war schon vor der Zusammenlegung so.
"""
metadata.create_all(engine)
metadata.create_all(engine_holen())
migrieren()
@@ -198,7 +254,7 @@ def insert_job(
job_id: str, device: str, disc_type: str = None, title: str = None,
target_dir: str = None, meta: str = None,
) -> None:
with engine.begin() as conn:
with engine_holen().begin() as conn:
conn.execute(
jobs.insert().values(
id=job_id,
@@ -215,14 +271,14 @@ def insert_job(
def update_job(job_id: str, **fields) -> None:
with engine.begin() as conn:
with engine_holen().begin() as conn:
conn.execute(jobs.update().where(jobs.c.id == job_id).values(**fields))
def get_job(job_id: str) -> dict:
"""Ganze Job-Zeile — der Worker braucht Titel + Metadaten für die
Ordner-Benennung und die Media-Server-Aufbereitung (NFO/Poster)."""
with engine.connect() as conn:
with engine_holen().connect() as conn:
zeile = conn.execute(
select(jobs).where(jobs.c.id == job_id)
).mappings().first()
@@ -231,7 +287,7 @@ def get_job(job_id: str) -> dict:
def get_job_status(job_id: str) -> str:
"""Nur der Status — der Worker prüft damit kooperative Abbruch-Anfragen."""
with engine.connect() as conn:
with engine_holen().connect() as conn:
zeile = conn.execute(
select(jobs.c.status).where(jobs.c.id == job_id)
).first()
@@ -239,7 +295,7 @@ def get_job_status(job_id: str) -> str:
def list_jobs(limit: int = 100) -> list:
with engine.connect() as conn:
with engine_holen().connect() as conn:
zeilen = conn.execute(
select(jobs).order_by(jobs.c.created_at.desc()).limit(limit)
).mappings().all()
@@ -252,7 +308,7 @@ def list_jobs_mit_status(stati) -> list:
Basis der Zombie-Erkennung: Jobs, die behaupten, es arbeite gerade jemand
an ihnen. Bewusst NUR diese schmale Auswahl statt der ganzen Zeile.
"""
with engine.connect() as conn:
with engine_holen().connect() as conn:
zeilen = conn.execute(
select(jobs.c.id, jobs.c.status, jobs.c.title, jobs.c.created_at)
.where(jobs.c.status.in_(list(stati)))
@@ -262,13 +318,13 @@ def list_jobs_mit_status(stati) -> list:
def delete_job(job_id: str) -> None:
"""Entfernt EINEN Job-Eintrag (nur die DB-Zeile — Dateien bleiben)."""
with engine.begin() as conn:
with engine_holen().begin() as conn:
conn.execute(jobs.delete().where(jobs.c.id == job_id))
def delete_finished_jobs() -> int:
"""Räumt alle erledigten Jobs (completed/failed) aus der Liste. Dateien bleiben."""
with engine.begin() as conn:
with engine_holen().begin() as conn:
ergebnis = conn.execute(
jobs.delete().where(jobs.c.status.in_(("completed", "failed")))
)
@@ -283,7 +339,7 @@ def hat_arbeit() -> bool:
Datenverlust. `pending` zählt bewusst mit: so ein Job kann jeden Moment
anlaufen.
"""
with engine.connect() as conn:
with engine_holen().connect() as conn:
zeile = conn.execute(
select(jobs.c.id)
.where(jobs.c.status.in_(
@@ -295,7 +351,7 @@ def hat_arbeit() -> bool:
def has_active_job(device: str) -> bool:
"""True, wenn auf dem Gerät ein Job läuft oder wartet (Eject-Schutz)."""
with engine.connect() as conn:
with engine_holen().connect() as conn:
zeile = conn.execute(
select(jobs.c.id)
.where(jobs.c.device == device)
@@ -337,14 +393,14 @@ def meta_merken(job_id: str, **felder) -> None:
# ───────────────────────────────────────────────────────────── Logs ────
def add_log(level: str, source: str, message: str) -> None:
with engine.begin() as conn:
with engine_holen().begin() as conn:
conn.execute(
logs.insert().values(ts=utcnow(), level=level, source=source, message=message)
)
def list_logs(limit: int = 200) -> list:
with engine.connect() as conn:
with engine_holen().connect() as conn:
zeilen = conn.execute(
select(logs).order_by(logs.c.id.desc()).limit(limit)
).mappings().all()
@@ -374,7 +430,7 @@ def get_settings(key: str = "ui", bei_fehler_leer: bool = False) -> dict:
Nachricht über die Welt"). Zu klären in V2-2, wenn der Store-Port steht.
"""
try:
with engine.connect() as conn:
with engine_holen().connect() as conn:
zeile = conn.execute(
select(settings_table.c.value).where(settings_table.c.key == key)
).first()
@@ -394,7 +450,7 @@ def save_settings(werte: dict, key: str = "ui") -> None:
"""Upsert in die settings-Tabelle — der Worker legt hier z. B. die
Track-Scan-Ergebnisse ab (key 'tracks:<device>'), die API liest sie."""
payload = json.dumps(werte)
with engine.begin() as conn:
with engine_holen().begin() as conn:
vorhanden = conn.execute(
select(settings_table.c.key).where(settings_table.c.key == key)
).first()
@@ -413,7 +469,7 @@ def save_worker(name: str, encoder_liste: list, info: dict = None) -> None:
"""Worker meldet Name + Encoder-Fähigkeiten + Werkzeug-Versionen (Upsert)."""
payload = json.dumps(encoder_liste)
info_payload = json.dumps(info or {})
with engine.begin() as conn:
with engine_holen().begin() as conn:
vorhanden = conn.execute(
workers.select().where(workers.c.name == name)
).first()
@@ -432,7 +488,7 @@ def save_worker(name: str, encoder_liste: list, info: dict = None) -> None:
def list_workers() -> list:
with engine.connect() as conn:
with engine_holen().connect() as conn:
zeilen = conn.execute(select(workers)).mappings().all()
ergebnis = []
for z in zeilen:
@@ -453,7 +509,7 @@ def list_workers() -> list:
def delete_worker(name: str) -> None:
"""Entfernt einen (verwaisten) Worker-Eintrag aus der Liste."""
with engine.begin() as conn:
with engine_holen().begin() as conn:
conn.execute(workers.delete().where(workers.c.name == name))
@@ -465,7 +521,7 @@ def zaehle_online_worker(sekunden: int = 120) -> int:
unvollständig — dann wird NICHTS als Leiche gewertet.
"""
grenze = utcnow() - timedelta(seconds=sekunden)
with engine.connect() as conn:
with engine_holen().connect() as conn:
anzahl = conn.execute(
select(func.count()).select_from(workers).where(workers.c.last_seen >= grenze)
).scalar()
@@ -474,12 +530,12 @@ def zaehle_online_worker(sekunden: int = 120) -> int:
# ──────────────────────────────────────────────────── Speicherziele ────
def list_mounts() -> list:
with engine.connect() as conn:
with engine_holen().connect() as conn:
return [dict(z) for z in conn.execute(select(storage_mounts)).mappings().all()]
def save_mount(name: str, typ: str, quelle: str, optionen: str, username: str, passwort: str) -> None:
with engine.begin() as conn:
with engine_holen().begin() as conn:
conn.execute(
storage_mounts.insert().values(
name=name, typ=typ, quelle=quelle,
@@ -489,5 +545,5 @@ def save_mount(name: str, typ: str, quelle: str, optionen: str, username: str, p
def delete_mount(name: str) -> None:
with engine.begin() as conn:
with engine_holen().begin() as conn:
conn.execute(storage_mounts.delete().where(storage_mounts.c.name == name))
+3 -3
View File
@@ -18,12 +18,12 @@ from rippy import store
@pytest.fixture
def sqlite_store(tmp_path):
"""Store auf eine frische SQLite-Datei umstellen und danach zurückdrehen."""
vorher_url, vorher_engine = store.DATABASE_URL, store.engine
vorher = store.zustand_sichern()
store.verbinden(f"sqlite:///{(tmp_path / 'rippy.db').as_posix()}")
store.init_db()
yield store
store.engine.dispose()
store.DATABASE_URL, store.engine = vorher_url, vorher_engine
store.engine_holen().dispose()
store.zustand_wiederherstellen(vorher)
def test_init_db_legt_alle_tabellen_an(sqlite_store):
+91
View File
@@ -0,0 +1,91 @@
"""Die Windows-App: Installation, Deinstallation, Aufraeum-Skript.
Die reinen Funktionen laufen ueberall. Was wirklich installiert, laeuft nur
unter Windows und in einem Testordner — die echte Installation wird nie
angefasst.
Hinweis zu den Backslashes: Windows-Pfade stehen hier durchweg in ROHEN
Zeichenketten. In einer normalen waere ein Pfad wie C:\\Program die
ungueltige Escape-Sequenz Backslash-P — heute eine DeprecationWarning, in
einer kuenftigen Python-Version ein harter Fehler.
"""
import os
import pytest
from rippy import windows_app
nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows")
BACKSLASH = chr(92)
# ── Das Aufraeum-Skript ─────────────────────────────────────────────────
def test_pfade_stehen_in_anfuehrungszeichen():
"""DER Fehler vom 28.08.2026.
Der Loeschbefehl ging als EIN Argument an cmd. Python maskiert dabei die
inneren Anfuehrungszeichen, bei cmd kam ein Dateiname MIT vorangestellten
Backslashes an. `del` loeschte nichts und meldete nichts (die Ausgabe war
nach nul umgeleitet) — Registry-Eintrag und Autostart waren weg, die
28-MB-Datei blieb liegen. Ein Skript hat das Problem nicht: Sein Inhalt
geht durch keine Argument-Zerlegung.
"""
s = windows_app.aufraeum_skript(
r"C:\Program Files\Rippy\Rippy.exe", r"C:\Program Files\Rippy")
assert '"' + r"C:\Program Files\Rippy\Rippy.exe" + '"' in s
assert BACKSLASH + '"' not in s, "Anfuehrungszeichen duerfen NICHT maskiert sein"
def test_skript_versucht_es_mehrfach():
"""Eine PyInstaller-Onefile-EXE laeuft als ZWEI Prozesse; der Starter
haelt die Datei nach dem Ende noch offen. Ein einziger Versuch schlaegt
dann manchmal fehl — und zwar unsichtbar."""
s = windows_app.aufraeum_skript(r"C:\R\Rippy.exe", r"C:\R")
assert "for /l" in s
assert "(1,1,15)" in s
def test_skript_loescht_sich_selbst():
"""Sonst bleibt nach jeder Deinstallation eine .bat im Temp liegen."""
assert "%~f0" in windows_app.aufraeum_skript(r"C:\R\Rippy.exe", r"C:\R")
def test_ordner_ohne_abschliessenden_trenner():
"""rmdir will den Ordner OHNE Schluss-Backslash — mit scheitert es."""
s = windows_app.aufraeum_skript(r"C:\R\Rippy.exe", "C:" + BACKSLASH + "R" + BACKSLASH)
assert 'rmdir "' + r"C:\R" + '"' in s
# ── Installation (echt, in einem Testordner) ────────────────────────────
@nur_windows
def test_installation_legt_die_dateien_an_und_meldet_sich_bei_windows(tmp_path, monkeypatch):
"""Der ganze Kreislauf. Der Registry-Schluessel ist der ECHTE — deshalb
wird am Ende immer aufgeraeumt, auch wenn der Test scheitert."""
from rippy.platform import win_registry as reg
# Nicht die eigene Datei kopieren (das waere python.exe), sondern eine
# Attrappe: geprueft wird die Verdrahtung, nicht shutil.copy2.
attrappe = tmp_path / "quelle" / "RippySetup.exe"
attrappe.parent.mkdir()
attrappe.write_bytes(b"x" * 2048)
monkeypatch.setattr(windows_app, "_eigene_datei", lambda: str(attrappe))
ziel = str(tmp_path / "installiert")
try:
ordner = windows_app.installieren(ziel=ziel, autostart=True,
starten_danach=False)
assert os.path.isfile(os.path.join(ordner, "Rippy.exe"))
assert reg.ist_eingetragen() is True
werte = reg.gelesene_werte()
assert werte["InstallLocation"] == ziel
assert werte["DisplayName"] == "Rippy"
assert reg.autostart_wert().endswith("--dienst")
finally:
reg.autostart_entfernen()
reg.austragen()
assert reg.ist_eingetragen() is False
assert reg.autostart_wert() == ""
+434
View File
@@ -0,0 +1,434 @@
"""Rippy für Windows: Installer, Hintergrunddienst und Deinstallation in EINER Datei.
## Warum eine Datei und nicht drei
Die naheliegende Aufteilung wäre `RippySetup.exe`, `Rippy.exe` und
`RippyUninstall.exe`. Drei PyInstaller-Bauten, drei Signaturen, drei
Versionsstände, die auseinanderlaufen können — und ein Deinstaller, der sich
selbst löschen müsste.
Stattdessen: **ein Programm, drei Betriebsarten.**
RippySetup.exe installiert (Doppelklick)
Rippy.exe --dienst laeuft im Hintergrund
Rippy.exe --deinstallieren raeumt sich weg
Bei der Installation kopiert sich die Datei nach
`%LOCALAPPDATA%\\Rippy\\Rippy.exe`. Der Name ist Absicht: **Im Taskmanager
steht dann „Rippy.exe"** — was der Commander ausdrücklich wollte. Ein Prozess,
den man nicht wiederfindet, kann man auch nicht beenden.
## Warum kein Windows-Dienst
Ein echter Dienst startet vor der Anmeldung — klingt besser, kostet aber bei
JEDER Installation und jedem Update eine Administrator-Abfrage, und ein Dienst
kann kein Tray-Symbol zeigen (er hat keine Benutzersitzung). Rippy nimmt den
Autostart-Eintrag unter `HKCU\\...\\Run`: kein UAC, Tray funktioniert, und mit
einem Registry-Eintrag wieder weg.
## Warum das Tray-Symbol keine Nebensache ist
Ein Hintergrundprozess ohne sichtbaren Zugang ist eine Zumutung: Man weiß
nicht, ob er läuft, kommt nicht an die Oberfläche und kann ihn nicht beenden,
ohne den Taskmanager zu bemühen. Das Symbol beantwortet alle drei Fragen.
"""
import os
import shutil
import subprocess
import sys
import threading
import time
import webbrowser
from rippy.platform import win_registry as reg
VERSION = os.environ.get("RIPPY_VERSION", "2.0.0")
STANDARD_PORT = 7788
# ═══════════════════════════════════════════════════════════ Hilfen ═════
def _eigene_datei() -> str:
"""Der Pfad DIESER ausführbaren Datei (auch im PyInstaller-Paket)."""
if getattr(sys, "frozen", False):
return sys.executable
return os.path.abspath(sys.argv[0])
def _mitgeliefert(name: str) -> str:
"""Eine mitgepackte Datei (Icon, UI, API) im Paket oder im Repo finden."""
gebuendelt = getattr(sys, "_MEIPASS", None)
if gebuendelt:
return os.path.join(gebuendelt, name)
hier = os.path.dirname(os.path.abspath(__file__))
repo = os.path.dirname(os.path.dirname(hier))
return os.path.join(repo, name)
def _icon_pfad() -> str:
for kandidat in (_mitgeliefert("rippy.ico"),
_mitgeliefert(os.path.join("deploy", "worker-windows", "rippy.ico"))):
if os.path.isfile(kandidat):
return kandidat
return ""
def melden(text: str) -> None:
"""Ausgabe, die auch ohne Konsole nicht abstürzt.
Eine als `--windowed` gebaute EXE hat keine Standardausgabe; `print()`
schreibt dann ins Leere oder wirft. Und unter Windows kann eine
Konsolen-Ausgabe an einem Umlaut sterben (cp1252) — genau daran ist der
erste Standalone-Start gescheitert. Deshalb hier: nie werfen.
"""
try:
sys.stdout.write(text + "\n")
sys.stdout.flush()
except Exception:
pass
# ═══════════════════════════════════════════════════ Installation ═══════
def installieren(ziel: str = None, autostart: bool = True,
starten_danach: bool = True) -> str:
"""Kopiert Rippy an seinen Platz und meldet es bei Windows an.
Gibt den Installationsordner zurück. Wirft bei echten Fehlern — eine
halb geglückte Installation soll NICHT wie ein Erfolg aussehen.
"""
ziel = ziel or reg.standard_installationsordner()
os.makedirs(ziel, exist_ok=True)
quelle = _eigene_datei()
programm = os.path.join(ziel, "Rippy.exe")
# Sich selbst kopieren. Läuft gerade eine ältere Fassung, ist die Datei
# gesperrt — dann daneben legen und beim nächsten Start tauschen.
if os.path.abspath(quelle).lower() != os.path.abspath(programm).lower():
try:
shutil.copy2(quelle, programm)
except PermissionError:
ersatz = programm + ".neu"
shutil.copy2(quelle, ersatz)
melden("Rippy läuft gerade — die neue Fassung wird beim nächsten "
"Start übernommen.")
programm = ersatz
icon = _icon_pfad()
if icon and os.path.isfile(icon):
try:
shutil.copy2(icon, os.path.join(ziel, "rippy.ico"))
except OSError:
pass
# Der Eintrag in „Programme und Features" — ohne ihn ist Rippy ein
# Programm, das man nur von Hand wieder loswird.
reg.eintragen(ziel, programm, VERSION,
icon=os.path.join(ziel, "rippy.ico") if icon else "")
if autostart:
reg.autostart_setzen('"%s" --dienst' % programm)
if starten_danach:
starte_hintergrund(programm)
return ziel
def starte_hintergrund(programm: str) -> None:
"""Startet Rippy als Hintergrundprozess — ohne aufblitzendes Fenster."""
from rippy.platform.winlauf import OHNE_FENSTER
subprocess.Popen([programm, "--dienst"], creationflags=OHNE_FENSTER,
close_fds=True)
def deinstallieren(still: bool = False) -> None:
"""Nimmt alle Spuren zurück: Autostart, Registry-Eintrag, Dateien."""
# WOHIN wurde installiert? Das steht in der Registry, nicht im Code.
# Vorher stand hier `reg.standard_installationsordner()` — wer nach
# `--ziel D:\Rippy` installiert hatte, dessen Dateien waeren geblieben,
# waehrend ein fremder Ordner unter %LOCALAPPDATA% angefasst worden waere.
# InstallLocation wird genau dafuer beim Eintragen mitgeschrieben.
ordner = (reg.gelesene_werte().get("InstallLocation")
or reg.standard_installationsordner())
reg.autostart_entfernen()
reg.austragen()
laeuft_von_hier = os.path.abspath(_eigene_datei()).lower().startswith(
os.path.abspath(ordner).lower())
# Die eigene Datei kann sich nicht selbst loeschen, solange sie laeuft.
# Also: alles andere jetzt, die EXE per Aufraeum-Befehl danach.
for name in os.listdir(ordner) if os.path.isdir(ordner) else []:
pfad = os.path.join(ordner, name)
if laeuft_von_hier and os.path.abspath(pfad).lower() == os.path.abspath(_eigene_datei()).lower():
continue
try:
shutil.rmtree(pfad) if os.path.isdir(pfad) else os.remove(pfad)
except OSError:
pass
if laeuft_von_hier:
selbst_loeschen(_eigene_datei(), ordner)
if not still:
melden("Rippy wurde entfernt.")
AUFRAEUM_SKRIPT = """@echo off
rem Erzeugt von Rippy beim Deinstallieren. Loescht sich selbst am Ende.
for /l %%n in (1,1,15) do (
ping 127.0.0.1 -n 3 >nul
del /q "{exe}" >nul 2>&1
if not exist "{exe}" goto weg
)
goto ende
:weg
rmdir "{ordner}" >nul 2>&1
:ende
del /q "%~f0" >nul 2>&1
"""
def aufraeum_skript(exe: str, ordner: str) -> str:
"""Der Inhalt des Aufraeum-Skripts (reine Funktion, damit pruefbar)."""
return AUFRAEUM_SKRIPT.format(exe=exe, ordner=ordner.rstrip("\\/"))
def selbst_loeschen(exe: str, ordner: str) -> str:
r"""Loescht die eigene EXE und den Ordner — nachdem dieser Prozess weg ist.
Gibt den Pfad des Aufraeum-Skripts zurueck (fuer Tests und Diagnose).
## Warum ein Skript und kein cmd-Einzeiler (gemessen 28.08.2026)
Der erste Versuch uebergab den ganzen Befehl als EIN Argument an
`cmd /c`. Python maskiert dabei die inneren Anfuehrungszeichen — bei cmd
kam an:
del /q \"C:\Users\...\Rippy.exe\"
Mit den Backslashes davor ist das ein anderer Dateiname, also loeschte
`del` nichts und meldete auch nichts (die Ausgabe war nach >nul
umgeleitet). Ergebnis: Registry-Eintrag und Autostart waren weg, die
28-MB-Datei blieb liegen — und der Fehlschlag war unsichtbar. Genau die
Sorte still scheiternder Hintergrundprozess, vor der AGENTS.md warnt.
Ein Skript hat das Problem nicht: Sein Inhalt geht durch keine
Argument-Zerlegung.
## Warum es fuenfzehnmal versucht
Eine PyInstaller-Onefile-EXE laeuft als ZWEI Prozesse — ein Starter und
die eigentliche Anwendung. Der Starter raeumt nach dem Ende noch sein
Entpack-Verzeichnis auf und haelt die Datei so lange offen. Zwei Sekunden
sind dafuer mal genug und mal nicht.
Klappt es in einer halben Minute nicht, bleibt eine Datei liegen. Das ist
unschoen, aber harmlos: Registry-Eintrag und Autostart sind da schon weg,
Rippy startet also nicht mehr mit.
"""
import tempfile
from rippy.platform.winlauf import OHNE_FENSTER
skript = os.path.join(tempfile.gettempdir(), "rippy-aufraeumen.bat")
# ascii + replace: Der Pfad kann Umlaute enthalten, und cmd liest .bat
# in der Konsolen-Codepage. Ein Fragezeichen im Pfad ist besser als ein
# Skript, das cmd gar nicht erst annimmt.
with open(skript, "w", encoding="ascii", errors="replace", newline="\r\n") as f:
f.write(aufraeum_skript(exe, ordner))
subprocess.Popen(["cmd", "/c", skript], creationflags=OHNE_FENSTER,
close_fds=True)
return skript
# ═══════════════════════════════════════════════ Der Hintergrunddienst ══
class Dienst:
"""Rippy im Hintergrund: der Server in einem Thread, das Tray-Symbol davor."""
def __init__(self, port: int = STANDARD_PORT):
self.port = port
self._server = None
self._laeuft = False
# ── Server ──────────────────────────────────────────────────────────
def server_starten(self) -> None:
from rippy import config, daemon, store
werte = config.laden(flags={"server": {"port": self.port}})
store.verbinden(config.datenbank_url(werte))
app, _ = daemon.anwendung_bauen(werte)
import uvicorn
self._server = uvicorn.Server(uvicorn.Config(
app, host="127.0.0.1", port=self.port, log_level="warning"))
self._laeuft = True
self._server.run()
def stoppen(self) -> None:
self._laeuft = False
if self._server is not None:
self._server.should_exit = True
@property
def adresse(self) -> str:
return "http://127.0.0.1:%d" % self.port
def erreichbar(self, timeout: float = 1.0) -> bool:
"""Antwortet der Server WIRKLICH? Nicht: läuft der Thread noch.
Ein lebender Thread ist kein Beweis dafür, dass jemand bedient wird —
dieselbe Unterscheidung wie überall sonst in diesem Projekt.
"""
import urllib.error
import urllib.request
try:
with urllib.request.urlopen(self.adresse + "/api/health", timeout=timeout):
return True
except (urllib.error.URLError, OSError):
return False
def dienst_starten(port: int = STANDARD_PORT) -> int:
"""Server im Hintergrund-Thread, Tray-Symbol im Vordergrund.
Die Reihenfolge ist unter Windows Pflicht: `pystray` muss im HAUPT-Thread
laufen, sonst bekommt es keine Fensternachrichten und das Symbol
erscheint nie.
"""
dienst = Dienst(port)
threading.Thread(target=dienst.server_starten, daemon=True,
name="rippy-server").start()
# Kurz warten und NACHSEHEN, ob der Server wirklich antwortet.
for _ in range(30):
if dienst.erreichbar(timeout=0.5):
break
time.sleep(0.5)
symbol = tray_bauen(dienst)
if symbol is None:
# Kein Tray möglich (fehlende Bibliothek): Dann eben ohne — der
# Dienst ist wichtiger als sein Symbol. Aber es wird gesagt.
melden("Tray-Symbol nicht verfügbar — Rippy läuft trotzdem auf "
+ dienst.adresse)
try:
while True:
time.sleep(3600)
except KeyboardInterrupt:
pass
return 0
symbol.run()
dienst.stoppen()
return 0
def tray_bauen(dienst):
"""Das Tray-Symbol. None, wenn pystray/Pillow fehlen."""
try:
import pystray
from PIL import Image
except ImportError:
return None
icon = _icon_pfad()
try:
bild = Image.open(icon) if icon and os.path.isfile(icon) else None
except OSError:
bild = None
if bild is None:
from PIL import ImageDraw
bild = Image.new("RGB", (64, 64), (16, 20, 32))
zeichner = ImageDraw.Draw(bild)
zeichner.ellipse((8, 8, 56, 56), outline=(245, 158, 11), width=6)
zeichner.ellipse((26, 26, 38, 38), fill=(245, 158, 11))
def oeffnen(_=None, __=None):
webbrowser.open(dienst.adresse)
def beenden(symbol, _=None):
dienst.stoppen()
symbol.stop()
def zustand(_=None):
return "Läuft — " + dienst.adresse if dienst.erreichbar() else "Startet …"
menue = pystray.Menu(
pystray.MenuItem("Rippy öffnen", oeffnen, default=True),
pystray.MenuItem(zustand, None, enabled=False),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Beenden", beenden),
)
return pystray.Icon("Rippy", bild, "Rippy", menue)
# ═══════════════════════════════════════════════════════ Einstieg ═══════
HILFE = """Rippy für Windows
RippySetup.exe installieren (Doppelklick)
Rippy.exe --dienst im Hintergrund laufen lassen
Rippy.exe --deinstallieren entfernen
Rippy.exe --status nachsehen, was installiert ist
Optionen:
--port N Port (Vorgabe: 7788)
--ziel PFAD Installationsordner
--kein-autostart
--still ohne Rückmeldungen
"""
def status() -> int:
melden("Rippy %s" % VERSION)
melden(" Diese Datei %s" % _eigene_datei())
melden(" Installationsordner %s" % reg.standard_installationsordner())
if os.name != "nt":
melden(" (kein Windows — Registry wird nicht gelesen)")
return 0
melden(" In Programme+Features %s" % ("ja" if reg.ist_eingetragen() else "nein"))
melden(" Autostart %s" % (reg.autostart_wert() or "nein"))
for name, wert in sorted(reg.gelesene_werte().items()):
melden(" %-18s %s" % (name, wert))
return 0
def main(argv=None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
def wert(flag, standard=None):
return argv[argv.index(flag) + 1] if flag in argv else standard
if "--hilfe" in argv or "--help" in argv or "-h" in argv:
melden(HILFE)
return 0
if "--status" in argv:
return status()
if "--deinstallieren" in argv:
deinstallieren(still="--still" in argv)
return 0
if "--dienst" in argv:
return dienst_starten(int(wert("--port", STANDARD_PORT)))
# Ohne Argument: installieren. Das ist der Doppelklick-Fall.
ziel = installieren(
ziel=wert("--ziel"),
autostart="--kein-autostart" not in argv,
starten_danach="--nicht-starten" not in argv,
)
melden("Rippy wurde nach %s installiert." % ziel)
melden("Die Oberfläche öffnet sich gleich im Browser.")
if "--nicht-starten" not in argv:
time.sleep(3)
webbrowser.open("http://127.0.0.1:%d" % int(wert("--port", STANDARD_PORT)))
return 0
if __name__ == "__main__":
raise SystemExit(main())