feat(windows): V2-4 fertig — RippySetup.exe, Taskmanager-Eintrag, Deinstallation
Ampel / ampel (push) Failing after 46s
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:
co-authored by
Claude Opus 5
parent
e5254c23f0
commit
059183c651
@@ -54,3 +54,4 @@ logs/
|
|||||||
# Data
|
# Data
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
dist/
|
||||||
|
|||||||
@@ -2,16 +2,66 @@
|
|||||||
|
|
||||||
Bis 23.07. gab es überhaupt keinen Code-Pfad, der je einen Rip auslöste —
|
Bis 23.07. gab es überhaupt keinen Code-Pfad, der je einen Rip auslöste —
|
||||||
kein POST /jobs, kein udev-Daemon. Dieser Client schließt die Lücke.
|
kein POST /jobs, kein udev-Daemon. Dieser Client schließt die Lücke.
|
||||||
|
|
||||||
|
## Warum Celery hier erst bei Bedarf entsteht (V2-4, 28.08.2026)
|
||||||
|
|
||||||
|
Hier stand `celery_client = Celery(...)` auf Modulebene. Damit brauchte JEDER
|
||||||
|
Import von `main.py` ein funktionierendes Celery — auch dann, wenn nie ein Rip
|
||||||
|
angestoßen wird. Im Windows-Paket ist Celery bewusst NICHT enthalten (der
|
||||||
|
Standalone-Betrieb hat keinen Broker), und die fertige EXE starb sofort beim
|
||||||
|
Start:
|
||||||
|
|
||||||
|
File "celery_client.py", ...
|
||||||
|
celery_client = Celery("rippy_api", broker=REDIS_URL, ...)
|
||||||
|
ModuleNotFoundError: No module named 'celery.fixups'
|
||||||
|
|
||||||
|
Dasselbe Muster wie beim Store, der seine Engine beim Import baute: Was erst
|
||||||
|
bei der ersten Benutzung gebraucht wird, soll auch erst dann entstehen.
|
||||||
|
|
||||||
|
**Was das für den Windows-Betrieb bedeutet — ehrlich gesagt:** Die Oberfläche,
|
||||||
|
die Laufwerks-Erkennung und alles Lesende laufen dort. Ein RIP anzustoßen geht
|
||||||
|
noch nicht, weil die Zustellung weiterhin über Celery läuft; die Umstellung auf
|
||||||
|
die LocalQueue steht in Etappe V2-5. Bis dahin sagt `start_rip()` das
|
||||||
|
ausdrücklich, statt mit einem Importfehler zu sterben oder still nichts zu tun.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from celery import Celery
|
|
||||||
from celery.utils import worker_direct
|
|
||||||
|
|
||||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
|
||||||
celery_client = Celery("rippy_api", broker=REDIS_URL, backend=REDIS_URL)
|
_client = None
|
||||||
|
|
||||||
|
|
||||||
|
class KeinBroker(RuntimeError):
|
||||||
|
"""Es gibt hier kein Celery — mit Ansage statt mit Importfehler."""
|
||||||
|
|
||||||
|
|
||||||
|
def hole_client():
|
||||||
|
"""Der Celery-Client, beim ERSTEN Zugriff gebaut."""
|
||||||
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
try:
|
||||||
|
from celery import Celery
|
||||||
|
except ImportError as e:
|
||||||
|
raise KeinBroker(
|
||||||
|
"Celery ist in dieser Installation nicht enthalten. Rippy läuft "
|
||||||
|
"hier im Standalone-Betrieb; das Anstoßen von Rips über einen "
|
||||||
|
"Broker ist damit nicht möglich (Umstellung auf die lokale "
|
||||||
|
"Auftrags-Queue: Etappe V2-5)."
|
||||||
|
) from e
|
||||||
|
_client = Celery("rippy_api", broker=REDIS_URL, backend=REDIS_URL)
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name):
|
||||||
|
"""`celery_client` von außen — baut den Client bei Bedarf.
|
||||||
|
|
||||||
|
Damit bleiben die bestehenden `from celery_client import celery_client`
|
||||||
|
unverändert gültig, ohne dass der Import schon einen Broker verlangt.
|
||||||
|
"""
|
||||||
|
if name == "celery_client":
|
||||||
|
return hole_client()
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
|
||||||
|
|
||||||
def transcode_queue(node: str = None):
|
def transcode_queue(node: str = None):
|
||||||
@@ -25,7 +75,9 @@ def transcode_queue(node: str = None):
|
|||||||
if not node:
|
if not node:
|
||||||
return "transcode"
|
return "transcode"
|
||||||
try:
|
try:
|
||||||
antworten = celery_client.control.ping(timeout=1.0) or []
|
from celery.utils import worker_direct
|
||||||
|
|
||||||
|
antworten = hole_client().control.ping(timeout=1.0) or []
|
||||||
online = {k for antwort in antworten for k in antwort.keys()}
|
online = {k for antwort in antworten for k in antwort.keys()}
|
||||||
if node in online:
|
if node in online:
|
||||||
return worker_direct(node)
|
return worker_direct(node)
|
||||||
@@ -36,6 +88,6 @@ def transcode_queue(node: str = None):
|
|||||||
|
|
||||||
def start_rip(device_path: str, job_id: str, target_dir: str = None):
|
def start_rip(device_path: str, job_id: str, target_dir: str = None):
|
||||||
"""Schickt den Rip-Task an den Worker (Task-Name aus worker/tasks.py)."""
|
"""Schickt den Rip-Task an den Worker (Task-Name aus worker/tasks.py)."""
|
||||||
return celery_client.send_task(
|
return hole_client().send_task(
|
||||||
"worker.tasks.rip_disc", args=[device_path, job_id, target_dir]
|
"worker.tasks.rip_disc", args=[device_path, job_id, target_dir]
|
||||||
)
|
)
|
||||||
|
|||||||
+6
-4
@@ -24,7 +24,8 @@ from rippy.bus.waechter import Waechter
|
|||||||
import phasen
|
import phasen
|
||||||
import presets as preset_auswahl
|
import presets as preset_auswahl
|
||||||
import rohdaten
|
import rohdaten
|
||||||
from celery_client import celery_client, start_rip
|
import celery_client as celery_anbindung
|
||||||
|
from celery_client import start_rip
|
||||||
from rippy.drives.cdrom import CDS_DISC_OK, CDS_NO_DISC, CDS_TRAY_OPEN
|
from rippy.drives.cdrom import CDS_DISC_OK, CDS_NO_DISC, CDS_TRAY_OPEN
|
||||||
|
|
||||||
from config_validation import validate_config, ConfigValidationError
|
from config_validation import validate_config, ConfigValidationError
|
||||||
@@ -1213,7 +1214,8 @@ async def scan_tracks_starten(name: str):
|
|||||||
raise HTTPException(status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job")
|
raise HTTPException(status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job")
|
||||||
|
|
||||||
await asyncio.to_thread(db.save_settings, {"status": "running"}, f"tracks:{device_path}")
|
await asyncio.to_thread(db.save_settings, {"status": "running"}, f"tracks:{device_path}")
|
||||||
celery_client.send_task("worker.tasks.scan_tracks", args=[device_path])
|
celery_anbindung.hole_client().send_task(
|
||||||
|
"worker.tasks.scan_tracks", args=[device_path])
|
||||||
return {"status": "scanning"}
|
return {"status": "scanning"}
|
||||||
|
|
||||||
|
|
||||||
@@ -1271,7 +1273,7 @@ async def retry_transcode(job_id: str):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
meta = {}
|
meta = {}
|
||||||
from celery_client import transcode_queue
|
from celery_client import transcode_queue
|
||||||
celery_client.send_task(
|
celery_anbindung.hole_client().send_task(
|
||||||
"worker.tasks.transcode_files",
|
"worker.tasks.transcode_files",
|
||||||
args=[job_id, raw_dir, final_dir],
|
args=[job_id, raw_dir, final_dir],
|
||||||
queue=transcode_queue(meta.get("transcode_node")),
|
queue=transcode_queue(meta.get("transcode_node")),
|
||||||
@@ -1401,7 +1403,7 @@ PING_ALTER_MAX_SEKUNDEN = 30
|
|||||||
def _ping_jetzt() -> list:
|
def _ping_jetzt() -> list:
|
||||||
"""Pingt sofort (blockiert ~1 s) und füllt den Vorrat."""
|
"""Pingt sofort (blockiert ~1 s) und füllt den Vorrat."""
|
||||||
try:
|
try:
|
||||||
antworten = celery_client.control.ping(timeout=1.0) or []
|
antworten = celery_anbindung.hole_client().control.ping(timeout=1.0) or []
|
||||||
knoten = [k for antwort in antworten for k in antwort.keys()]
|
knoten = [k for antwort in antworten for k in antwort.keys()]
|
||||||
except Exception:
|
except Exception:
|
||||||
knoten = []
|
knoten = []
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from winlauf import OHNE_FENSTER
|
from rippy.platform.winlauf import OHNE_FENSTER
|
||||||
|
|
||||||
|
|
||||||
HB_ENCODER_KOPF = re.compile(r"^-e,\s*--encoder\b")
|
HB_ENCODER_KOPF = re.compile(r"^-e,\s*--encoder\b")
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ from rippy.drives.linux import ( # noqa: F401
|
|||||||
_auswurf_geglueckt,
|
_auswurf_geglueckt,
|
||||||
)
|
)
|
||||||
from rippy.drives.linux import auswerfen_versuchen as wirf_disc_aus # noqa: F401
|
from rippy.drives.linux import auswerfen_versuchen as wirf_disc_aus # noqa: F401
|
||||||
from winlauf import OHNE_FENSTER
|
from rippy.platform.winlauf import OHNE_FENSTER
|
||||||
|
|
||||||
RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
|
RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import re
|
|||||||
import subprocess
|
import subprocess
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
from winlauf import OHNE_FENSTER
|
from rippy.platform.winlauf import OHNE_FENSTER
|
||||||
|
|
||||||
# `DRV:<index>,<zustand>,<flags>,<?>,"<Laufwerk>","<Disc>","<Gerät>"`
|
# `DRV:<index>,<zustand>,<flags>,<?>,"<Laufwerk>","<Disc>","<Gerät>"`
|
||||||
DRV_ZEILE = re.compile(r'^DRV:(\d+),(\d+),(\d+),(\d+),"([^"]*)","([^"]*)","([^"]*)"')
|
DRV_ZEILE = re.compile(r'^DRV:(\d+),(\d+),(\d+),(\d+),"([^"]*)","([^"]*)","([^"]*)"')
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""Baut RippySetup.exe — eine Datei, die installiert, laeuft und sich entfernt.
|
||||||
|
|
||||||
|
## Aufruf
|
||||||
|
|
||||||
|
python packaging/windows/build.py [--ausgabe ORDNER] [--version 2.0.0]
|
||||||
|
|
||||||
|
Voraussetzungen (in der Bau-Umgebung, nicht beim Nutzer):
|
||||||
|
* Python 3.12 mit den API-Abhaengigkeiten (docker/api/requirements.txt)
|
||||||
|
* pyinstaller, pystray, pillow
|
||||||
|
* Das UI muss gebaut sein: cd docker/ui && npm run build
|
||||||
|
|
||||||
|
## Warum die Versions-Ressource nicht optional ist
|
||||||
|
|
||||||
|
Ohne sie steht im Taskmanager unter „Beschreibung" nichts, und in den
|
||||||
|
Datei-Eigenschaften fehlt jede Angabe zu Herausgeber und Version. Eine EXE
|
||||||
|
ohne diese Angaben sieht aus wie etwas, das man nicht ausfuehren sollte —
|
||||||
|
und Windows SmartScreen sieht das genauso.
|
||||||
|
|
||||||
|
Der Commander hat ausdruecklich einen Eintrag im Taskmanager gewuenscht.
|
||||||
|
Der Prozessname kommt vom Dateinamen (`Rippy.exe`), die BESCHREIBUNG
|
||||||
|
daneben aus dieser Ressource.
|
||||||
|
|
||||||
|
## Was mit hinein muss
|
||||||
|
|
||||||
|
docker/api/ die API-Module (main.py und Nachbarn)
|
||||||
|
docker/ui/dist/ die gebaute Weboberflaeche
|
||||||
|
src/rippy/ der Kern (kommt ueber den Import automatisch mit)
|
||||||
|
rippy.ico Symbol fuer Tray, Verknuepfung und Programmliste
|
||||||
|
|
||||||
|
`docker/api` liegt im Paket unter `api/`, `docker/ui/dist` unter `ui/` —
|
||||||
|
genau dort sucht `rippy/daemon.py` sie, wenn `sys._MEIPASS` gesetzt ist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
HIER = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
REPO = os.path.dirname(os.path.dirname(HIER))
|
||||||
|
|
||||||
|
VERSION_VORLAGE = """
|
||||||
|
VSVersionInfo(
|
||||||
|
ffi=FixedFileInfo(
|
||||||
|
filevers=({v0}, {v1}, {v2}, 0),
|
||||||
|
prodvers=({v0}, {v1}, {v2}, 0),
|
||||||
|
mask=0x3f, flags=0x0, OS=0x40004, fileType=0x1, subtype=0x0,
|
||||||
|
date=(0, 0)
|
||||||
|
),
|
||||||
|
kids=[
|
||||||
|
StringFileInfo([
|
||||||
|
StringTable(u'040704b0', [
|
||||||
|
StringStruct(u'CompanyName', u'Rippy'),
|
||||||
|
StringStruct(u'FileDescription', u'Rippy — automatisches Ripping'),
|
||||||
|
StringStruct(u'FileVersion', u'{version}'),
|
||||||
|
StringStruct(u'InternalName', u'Rippy'),
|
||||||
|
StringStruct(u'OriginalFilename', u'Rippy.exe'),
|
||||||
|
StringStruct(u'ProductName', u'Rippy'),
|
||||||
|
StringStruct(u'ProductVersion', u'{version}')])
|
||||||
|
]),
|
||||||
|
# 0x0407 = Deutsch, 0x04b0 = Unicode. Ohne den passenden Eintrag zeigt
|
||||||
|
# Windows die Angaben je nach Systemsprache gar nicht an.
|
||||||
|
VarFileInfo([VarStruct(u'Translation', [1031, 1200])])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def versionsdatei(version: str, ordner: str) -> str:
|
||||||
|
teile = (version.split("-")[0].split(".") + ["0", "0", "0"])[:3]
|
||||||
|
pfad = os.path.join(ordner, "version_info.txt")
|
||||||
|
with open(pfad, "w", encoding="utf-8") as f:
|
||||||
|
f.write(VERSION_VORLAGE.format(
|
||||||
|
version=version, v0=int(teile[0]), v1=int(teile[1]), v2=int(teile[2])))
|
||||||
|
return pfad
|
||||||
|
|
||||||
|
|
||||||
|
def pruefen() -> list:
|
||||||
|
"""Alles da? Erst pruefen, dann bauen — wie install.sh es macht.
|
||||||
|
|
||||||
|
Ein Bau, der auf halber Strecke abbricht, hinterlaesst eine EXE, die
|
||||||
|
aussieht wie fertig und es nicht ist.
|
||||||
|
"""
|
||||||
|
maengel = []
|
||||||
|
if sys.platform != "win32":
|
||||||
|
maengel.append("Eine Windows-.exe laesst sich nur unter Windows bauen.")
|
||||||
|
if not os.path.isfile(os.path.join(REPO, "docker", "api", "main.py")):
|
||||||
|
maengel.append("docker/api/main.py fehlt.")
|
||||||
|
if not os.path.isdir(os.path.join(REPO, "docker", "ui", "dist")):
|
||||||
|
maengel.append(
|
||||||
|
"docker/ui/dist fehlt — die Oberflaeche ist nicht gebaut. "
|
||||||
|
"Abhilfe: cd docker/ui && npm ci && npm run build")
|
||||||
|
if not os.path.isfile(os.path.join(REPO, "deploy", "worker-windows", "rippy.ico")):
|
||||||
|
maengel.append("deploy/worker-windows/rippy.ico fehlt.")
|
||||||
|
try:
|
||||||
|
import PyInstaller # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
maengel.append("pyinstaller fehlt (pip install pyinstaller).")
|
||||||
|
for modul, paket in (("pystray", "pystray"), ("PIL", "pillow")):
|
||||||
|
try:
|
||||||
|
__import__(modul)
|
||||||
|
except ImportError:
|
||||||
|
maengel.append(f"{modul} fehlt (pip install {paket}) — ohne das kein Tray-Symbol.")
|
||||||
|
return maengel
|
||||||
|
|
||||||
|
|
||||||
|
def bauen(ausgabe: str, version: str) -> str:
|
||||||
|
arbeit = os.path.join(ausgabe, "_bau")
|
||||||
|
os.makedirs(arbeit, exist_ok=True)
|
||||||
|
|
||||||
|
trenner = ";" # Windows-Trennzeichen fuer --add-data
|
||||||
|
befehl = [
|
||||||
|
sys.executable, "-m", "PyInstaller",
|
||||||
|
"--noconfirm", "--clean",
|
||||||
|
"--onefile",
|
||||||
|
# --windowed waere schoener (kein Konsolenfenster), macht aber jede
|
||||||
|
# Ausgabe unsichtbar — auch Fehlermeldungen beim Installieren. Der
|
||||||
|
# Installer laeuft deshalb MIT Konsole; der Dienst startet spaeter
|
||||||
|
# ohne Fenster (CREATE_NO_WINDOW in winlauf.py).
|
||||||
|
"--console",
|
||||||
|
"--name", "RippySetup",
|
||||||
|
"--icon", os.path.join(REPO, "deploy", "worker-windows", "rippy.ico"),
|
||||||
|
"--version-file", versionsdatei(version, arbeit),
|
||||||
|
"--distpath", ausgabe,
|
||||||
|
"--workpath", os.path.join(arbeit, "work"),
|
||||||
|
"--specpath", arbeit,
|
||||||
|
"--paths", os.path.join(REPO, "src"),
|
||||||
|
"--paths", os.path.join(REPO, "docker", "api"),
|
||||||
|
"--add-data", os.path.join(REPO, "docker", "api") + trenner + "api",
|
||||||
|
"--add-data", os.path.join(REPO, "docker", "ui", "dist") + trenner + "ui",
|
||||||
|
"--add-data", os.path.join(REPO, "deploy", "worker-windows", "rippy.ico") + trenner + ".",
|
||||||
|
# Was PyInstaller nicht von allein findet: dynamisch importierte Module.
|
||||||
|
"--hidden-import", "uvicorn.logging",
|
||||||
|
"--hidden-import", "uvicorn.loops.auto",
|
||||||
|
"--hidden-import", "uvicorn.protocols.http.auto",
|
||||||
|
"--hidden-import", "uvicorn.protocols.websockets.auto",
|
||||||
|
"--hidden-import", "uvicorn.lifespan.on",
|
||||||
|
"--hidden-import", "rippy.drives.windows",
|
||||||
|
"--hidden-import", "pystray._win32",
|
||||||
|
# Nicht mitnehmen: Der Windows-Treiber braucht kein psycopg2/celery,
|
||||||
|
# und beides zieht Ballast nach.
|
||||||
|
"--exclude-module", "psycopg2",
|
||||||
|
"--exclude-module", "tkinter",
|
||||||
|
os.path.join(REPO, "packaging", "windows", "einstieg.py"),
|
||||||
|
]
|
||||||
|
|
||||||
|
print("PyInstaller wird aufgerufen …\n")
|
||||||
|
ergebnis = subprocess.run(befehl, cwd=REPO)
|
||||||
|
if ergebnis.returncode != 0:
|
||||||
|
raise SystemExit("PyInstaller ist gescheitert (Code %d)." % ergebnis.returncode)
|
||||||
|
|
||||||
|
exe = os.path.join(ausgabe, "RippySetup.exe")
|
||||||
|
if not os.path.isfile(exe):
|
||||||
|
raise SystemExit("PyInstaller meldete Erfolg, aber %s gibt es nicht." % exe)
|
||||||
|
return exe
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
p = argparse.ArgumentParser(description="Baut RippySetup.exe")
|
||||||
|
p.add_argument("--ausgabe", default=os.path.join(REPO, "dist", "windows"))
|
||||||
|
p.add_argument("--version", default=os.environ.get("RIPPY_VERSION", "2.0.0"))
|
||||||
|
p.add_argument("--nur-pruefen", action="store_true")
|
||||||
|
args = p.parse_args(argv)
|
||||||
|
|
||||||
|
print("Rippy — Windows-Bau")
|
||||||
|
print(" Repo %s" % REPO)
|
||||||
|
print(" Ausgabe %s" % args.ausgabe)
|
||||||
|
print(" Version %s" % args.version)
|
||||||
|
print()
|
||||||
|
|
||||||
|
maengel = pruefen()
|
||||||
|
if maengel:
|
||||||
|
print("Der Bau kann so nicht laufen:")
|
||||||
|
for m in maengel:
|
||||||
|
print(" X %s" % m)
|
||||||
|
return 1
|
||||||
|
print("Alle Voraussetzungen erfuellt.")
|
||||||
|
if args.nur_pruefen:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
os.makedirs(args.ausgabe, exist_ok=True)
|
||||||
|
exe = bauen(args.ausgabe, args.version)
|
||||||
|
|
||||||
|
groesse = os.path.getsize(exe) / 1024**2
|
||||||
|
print()
|
||||||
|
print("Fertig: %s (%.1f MB)" % (exe, groesse))
|
||||||
|
|
||||||
|
arbeit = os.path.join(args.ausgabe, "_bau")
|
||||||
|
shutil.rmtree(arbeit, ignore_errors=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Einstiegspunkt der gebuendelten RippySetup.exe.
|
||||||
|
|
||||||
|
Bewusst winzig: PyInstaller braucht EINE Datei als Startpunkt, und alles
|
||||||
|
Weitere gehoert ins Paket `rippy`, wo es getestet werden kann. Was hier
|
||||||
|
steht, laesst sich nicht testen — also steht hier so wenig wie moeglich.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import multiprocessing
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
# Pflicht in gebuendelten Programmen: Ohne diesen Aufruf startet ein
|
||||||
|
# Kindprozess unter Windows die ganze EXE erneut — eine Startschleife,
|
||||||
|
# die sich als "das Programm oeffnet sich immer wieder" zeigt.
|
||||||
|
multiprocessing.freeze_support()
|
||||||
|
|
||||||
|
from rippy.windows_app import main as rippy_main
|
||||||
|
|
||||||
|
return rippy_main()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -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.
|
||||||
|
"""
|
||||||
@@ -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) == ""
|
||||||
@@ -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)
|
||||||
@@ -23,12 +23,12 @@ from rippy.queue import lokal
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def q(tmp_path):
|
def q(tmp_path):
|
||||||
"""Frische SQLite-Datenbank je Test, danach zurückdrehen."""
|
"""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.verbinden(f"sqlite:///{(tmp_path / 'q.db').as_posix()}")
|
||||||
store.init_db()
|
store.init_db()
|
||||||
yield lokal
|
yield lokal
|
||||||
store.engine.dispose()
|
store.engine_holen().dispose()
|
||||||
store.DATABASE_URL, store.engine = vorher_url, vorher_engine
|
store.zustand_wiederherstellen(vorher)
|
||||||
|
|
||||||
|
|
||||||
def _lease_zuruecksetzen(auftrag_id, sekunden):
|
def _lease_zuruecksetzen(auftrag_id, sekunden):
|
||||||
|
|||||||
+83
-27
@@ -120,7 +120,63 @@ def _engine_bauen(url: str):
|
|||||||
return eng
|
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:
|
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
|
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.
|
merkt von dieser Umstellung nichts. Genau darauf achtet rippy/queue/lokal.py.
|
||||||
"""
|
"""
|
||||||
global engine, DATABASE_URL
|
global _engine, DATABASE_URL
|
||||||
DATABASE_URL = 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
|
umschichten), ist Alembic die richtige Antwort — dann aber mit einer
|
||||||
bewussten Stempel-Runde.
|
bewussten Stempel-Runde.
|
||||||
"""
|
"""
|
||||||
inspektor = inspect(engine)
|
inspektor = inspect(engine_holen())
|
||||||
vorhandene_tabellen = set(inspektor.get_table_names())
|
vorhandene_tabellen = set(inspektor.get_table_names())
|
||||||
for tabelle, spalte, typ in schema.NACHZUTRAGENDE_SPALTEN:
|
for tabelle, spalte, typ in schema.NACHZUTRAGENDE_SPALTEN:
|
||||||
if tabelle not in vorhandene_tabellen:
|
if tabelle not in vorhandene_tabellen:
|
||||||
@@ -179,7 +235,7 @@ def migrieren() -> None:
|
|||||||
spalten = {s["name"] for s in inspektor.get_columns(tabelle)}
|
spalten = {s["name"] for s in inspektor.get_columns(tabelle)}
|
||||||
if spalte in spalten:
|
if spalte in spalten:
|
||||||
continue
|
continue
|
||||||
with engine.begin() as conn:
|
with engine_holen().begin() as conn:
|
||||||
conn.exec_driver_sql(f"ALTER TABLE {tabelle} ADD COLUMN {spalte} {typ}")
|
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
|
Wer zuerst startet — API oder Worker —, migriert; der andere findet die
|
||||||
Spalten dann bereits vor. Das war schon vor der Zusammenlegung so.
|
Spalten dann bereits vor. Das war schon vor der Zusammenlegung so.
|
||||||
"""
|
"""
|
||||||
metadata.create_all(engine)
|
metadata.create_all(engine_holen())
|
||||||
migrieren()
|
migrieren()
|
||||||
|
|
||||||
|
|
||||||
@@ -198,7 +254,7 @@ def insert_job(
|
|||||||
job_id: str, device: str, disc_type: str = None, title: str = None,
|
job_id: str, device: str, disc_type: str = None, title: str = None,
|
||||||
target_dir: str = None, meta: str = None,
|
target_dir: str = None, meta: str = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
with engine.begin() as conn:
|
with engine_holen().begin() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
jobs.insert().values(
|
jobs.insert().values(
|
||||||
id=job_id,
|
id=job_id,
|
||||||
@@ -215,14 +271,14 @@ def insert_job(
|
|||||||
|
|
||||||
|
|
||||||
def update_job(job_id: str, **fields) -> None:
|
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))
|
conn.execute(jobs.update().where(jobs.c.id == job_id).values(**fields))
|
||||||
|
|
||||||
|
|
||||||
def get_job(job_id: str) -> dict:
|
def get_job(job_id: str) -> dict:
|
||||||
"""Ganze Job-Zeile — der Worker braucht Titel + Metadaten für die
|
"""Ganze Job-Zeile — der Worker braucht Titel + Metadaten für die
|
||||||
Ordner-Benennung und die Media-Server-Aufbereitung (NFO/Poster)."""
|
Ordner-Benennung und die Media-Server-Aufbereitung (NFO/Poster)."""
|
||||||
with engine.connect() as conn:
|
with engine_holen().connect() as conn:
|
||||||
zeile = conn.execute(
|
zeile = conn.execute(
|
||||||
select(jobs).where(jobs.c.id == job_id)
|
select(jobs).where(jobs.c.id == job_id)
|
||||||
).mappings().first()
|
).mappings().first()
|
||||||
@@ -231,7 +287,7 @@ def get_job(job_id: str) -> dict:
|
|||||||
|
|
||||||
def get_job_status(job_id: str) -> str:
|
def get_job_status(job_id: str) -> str:
|
||||||
"""Nur der Status — der Worker prüft damit kooperative Abbruch-Anfragen."""
|
"""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(
|
zeile = conn.execute(
|
||||||
select(jobs.c.status).where(jobs.c.id == job_id)
|
select(jobs.c.status).where(jobs.c.id == job_id)
|
||||||
).first()
|
).first()
|
||||||
@@ -239,7 +295,7 @@ def get_job_status(job_id: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def list_jobs(limit: int = 100) -> list:
|
def list_jobs(limit: int = 100) -> list:
|
||||||
with engine.connect() as conn:
|
with engine_holen().connect() as conn:
|
||||||
zeilen = conn.execute(
|
zeilen = conn.execute(
|
||||||
select(jobs).order_by(jobs.c.created_at.desc()).limit(limit)
|
select(jobs).order_by(jobs.c.created_at.desc()).limit(limit)
|
||||||
).mappings().all()
|
).mappings().all()
|
||||||
@@ -252,7 +308,7 @@ def list_jobs_mit_status(stati) -> list:
|
|||||||
Basis der Zombie-Erkennung: Jobs, die behaupten, es arbeite gerade jemand
|
Basis der Zombie-Erkennung: Jobs, die behaupten, es arbeite gerade jemand
|
||||||
an ihnen. Bewusst NUR diese schmale Auswahl statt der ganzen Zeile.
|
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(
|
zeilen = conn.execute(
|
||||||
select(jobs.c.id, jobs.c.status, jobs.c.title, jobs.c.created_at)
|
select(jobs.c.id, jobs.c.status, jobs.c.title, jobs.c.created_at)
|
||||||
.where(jobs.c.status.in_(list(stati)))
|
.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:
|
def delete_job(job_id: str) -> None:
|
||||||
"""Entfernt EINEN Job-Eintrag (nur die DB-Zeile — Dateien bleiben)."""
|
"""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))
|
conn.execute(jobs.delete().where(jobs.c.id == job_id))
|
||||||
|
|
||||||
|
|
||||||
def delete_finished_jobs() -> int:
|
def delete_finished_jobs() -> int:
|
||||||
"""Räumt alle erledigten Jobs (completed/failed) aus der Liste. Dateien bleiben."""
|
"""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(
|
ergebnis = conn.execute(
|
||||||
jobs.delete().where(jobs.c.status.in_(("completed", "failed")))
|
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
|
Datenverlust. `pending` zählt bewusst mit: so ein Job kann jeden Moment
|
||||||
anlaufen.
|
anlaufen.
|
||||||
"""
|
"""
|
||||||
with engine.connect() as conn:
|
with engine_holen().connect() as conn:
|
||||||
zeile = conn.execute(
|
zeile = conn.execute(
|
||||||
select(jobs.c.id)
|
select(jobs.c.id)
|
||||||
.where(jobs.c.status.in_(
|
.where(jobs.c.status.in_(
|
||||||
@@ -295,7 +351,7 @@ def hat_arbeit() -> bool:
|
|||||||
|
|
||||||
def has_active_job(device: str) -> bool:
|
def has_active_job(device: str) -> bool:
|
||||||
"""True, wenn auf dem Gerät ein Job läuft oder wartet (Eject-Schutz)."""
|
"""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(
|
zeile = conn.execute(
|
||||||
select(jobs.c.id)
|
select(jobs.c.id)
|
||||||
.where(jobs.c.device == device)
|
.where(jobs.c.device == device)
|
||||||
@@ -337,14 +393,14 @@ def meta_merken(job_id: str, **felder) -> None:
|
|||||||
|
|
||||||
# ───────────────────────────────────────────────────────────── Logs ────
|
# ───────────────────────────────────────────────────────────── Logs ────
|
||||||
def add_log(level: str, source: str, message: str) -> None:
|
def add_log(level: str, source: str, message: str) -> None:
|
||||||
with engine.begin() as conn:
|
with engine_holen().begin() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
logs.insert().values(ts=utcnow(), level=level, source=source, message=message)
|
logs.insert().values(ts=utcnow(), level=level, source=source, message=message)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def list_logs(limit: int = 200) -> list:
|
def list_logs(limit: int = 200) -> list:
|
||||||
with engine.connect() as conn:
|
with engine_holen().connect() as conn:
|
||||||
zeilen = conn.execute(
|
zeilen = conn.execute(
|
||||||
select(logs).order_by(logs.c.id.desc()).limit(limit)
|
select(logs).order_by(logs.c.id.desc()).limit(limit)
|
||||||
).mappings().all()
|
).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.
|
Nachricht über die Welt"). Zu klären in V2-2, wenn der Store-Port steht.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with engine.connect() as conn:
|
with engine_holen().connect() as conn:
|
||||||
zeile = conn.execute(
|
zeile = conn.execute(
|
||||||
select(settings_table.c.value).where(settings_table.c.key == key)
|
select(settings_table.c.value).where(settings_table.c.key == key)
|
||||||
).first()
|
).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
|
"""Upsert in die settings-Tabelle — der Worker legt hier z. B. die
|
||||||
Track-Scan-Ergebnisse ab (key 'tracks:<device>'), die API liest sie."""
|
Track-Scan-Ergebnisse ab (key 'tracks:<device>'), die API liest sie."""
|
||||||
payload = json.dumps(werte)
|
payload = json.dumps(werte)
|
||||||
with engine.begin() as conn:
|
with engine_holen().begin() as conn:
|
||||||
vorhanden = conn.execute(
|
vorhanden = conn.execute(
|
||||||
select(settings_table.c.key).where(settings_table.c.key == key)
|
select(settings_table.c.key).where(settings_table.c.key == key)
|
||||||
).first()
|
).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)."""
|
"""Worker meldet Name + Encoder-Fähigkeiten + Werkzeug-Versionen (Upsert)."""
|
||||||
payload = json.dumps(encoder_liste)
|
payload = json.dumps(encoder_liste)
|
||||||
info_payload = json.dumps(info or {})
|
info_payload = json.dumps(info or {})
|
||||||
with engine.begin() as conn:
|
with engine_holen().begin() as conn:
|
||||||
vorhanden = conn.execute(
|
vorhanden = conn.execute(
|
||||||
workers.select().where(workers.c.name == name)
|
workers.select().where(workers.c.name == name)
|
||||||
).first()
|
).first()
|
||||||
@@ -432,7 +488,7 @@ def save_worker(name: str, encoder_liste: list, info: dict = None) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def list_workers() -> list:
|
def list_workers() -> list:
|
||||||
with engine.connect() as conn:
|
with engine_holen().connect() as conn:
|
||||||
zeilen = conn.execute(select(workers)).mappings().all()
|
zeilen = conn.execute(select(workers)).mappings().all()
|
||||||
ergebnis = []
|
ergebnis = []
|
||||||
for z in zeilen:
|
for z in zeilen:
|
||||||
@@ -453,7 +509,7 @@ def list_workers() -> list:
|
|||||||
|
|
||||||
def delete_worker(name: str) -> None:
|
def delete_worker(name: str) -> None:
|
||||||
"""Entfernt einen (verwaisten) Worker-Eintrag aus der Liste."""
|
"""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))
|
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.
|
unvollständig — dann wird NICHTS als Leiche gewertet.
|
||||||
"""
|
"""
|
||||||
grenze = utcnow() - timedelta(seconds=sekunden)
|
grenze = utcnow() - timedelta(seconds=sekunden)
|
||||||
with engine.connect() as conn:
|
with engine_holen().connect() as conn:
|
||||||
anzahl = conn.execute(
|
anzahl = conn.execute(
|
||||||
select(func.count()).select_from(workers).where(workers.c.last_seen >= grenze)
|
select(func.count()).select_from(workers).where(workers.c.last_seen >= grenze)
|
||||||
).scalar()
|
).scalar()
|
||||||
@@ -474,12 +530,12 @@ def zaehle_online_worker(sekunden: int = 120) -> int:
|
|||||||
|
|
||||||
# ──────────────────────────────────────────────────── Speicherziele ────
|
# ──────────────────────────────────────────────────── Speicherziele ────
|
||||||
def list_mounts() -> list:
|
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()]
|
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:
|
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(
|
conn.execute(
|
||||||
storage_mounts.insert().values(
|
storage_mounts.insert().values(
|
||||||
name=name, typ=typ, quelle=quelle,
|
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:
|
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))
|
conn.execute(storage_mounts.delete().where(storage_mounts.c.name == name))
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ from rippy import store
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sqlite_store(tmp_path):
|
def sqlite_store(tmp_path):
|
||||||
"""Store auf eine frische SQLite-Datei umstellen und danach zurückdrehen."""
|
"""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.verbinden(f"sqlite:///{(tmp_path / 'rippy.db').as_posix()}")
|
||||||
store.init_db()
|
store.init_db()
|
||||||
yield store
|
yield store
|
||||||
store.engine.dispose()
|
store.engine_holen().dispose()
|
||||||
store.DATABASE_URL, store.engine = vorher_url, vorher_engine
|
store.zustand_wiederherstellen(vorher)
|
||||||
|
|
||||||
|
|
||||||
def test_init_db_legt_alle_tabellen_an(sqlite_store):
|
def test_init_db_legt_alle_tabellen_an(sqlite_store):
|
||||||
|
|||||||
@@ -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() == ""
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user