fix(windows): kein CMD-Fenster mehr — die EXE ist ein Fenster-Programm
Ampel / ampel (push) Failing after 55s

Commander: "Unterbinde das CMD Fenster was sich beim oeffnen mitoeffnet."

Mein erster Anlauf wollte die Konsole VERSTECKEN und war falsch, aus zwei
Gruenden:

* Eine Onefile-EXE ist ZWEI Prozesse (gemessen: PID 33172 mit 8 MB, PID
  33580 mit 103 MB, beide Rippy.exe). Die Pruefung "haengt genau EIN Prozess
  an der Konsole" war damit nie wahr, das Fenster blieb einfach stehen.
* Selbst wenn sie ginge, waere das Fenster erst nach ein bis zwei Sekunden
  weg. Ein Aufblitzen ist kein "unterbunden".

Jetzt wird die Konsole gar nicht erst erzeugt: --windowed statt --console.
Nachgemessen am fertigen Bau -- PE-Subsystem 2 (GUI) statt 3 (Konsole), und
beim Klick auf die Desktop-Verknuepfung ist genau EIN sichtbares Fenster da:
die WinForms-Oberflaeche. Kein ConsoleWindowClass-Fenster von Rippy, weder
sichtbar noch unsichtbar.

Was dazugehoert, damit daraus kein stiller Fehlschlag wird:

* Ohne Konsole ist sys.stdout None. uvicorn schreibt auf stderr und waere
  mitten im Start gestorben, ohne dass irgendwo etwas stuende. einstieg.py
  haengt sich deshalb an die Konsole des Aufrufers (--status in PowerShell
  schreibt weiter dorthin) oder leitet nach %LOCALAPPDATA%\Rippy\rippy.log um.
* Der Installer meldet sein Ergebnis in einem Fenster, wenn keine Konsole da
  ist -- sonst saehe ein gescheiterter Doppelklick aus wie einer, der nichts
  tut. Bei --still und mit Konsole bleibt es bei der Textzeile.

Gegengeprueft am laufenden Programm: /api/health, / und /api/capabilities
antworten mit 200, und rippy.log faengt auf, was vorher ins Leere ging.

fix(drives): linux.py reicht die detection-Funktionen wieder durch

Die Ampel war seit Lauf 181 rot -- vier Commits lang, und ich hatte es
uebersehen. Ursache:

    docker/api/main.py:54
    AttributeError: module 'rippy.drives.linux' has no attribute 'drive_status'

main.py holt sich beim Import device_discovery.drive_status. Der
Windows-Treiber hat die Funktion; linux.py hatte sie nicht mehr, seit
detection (und damit fcntl) bewusst nicht mehr oben importiert wird -- sonst
waere main.py unter Windows nicht ladbar. Unter Windows lief also alles, auf
Linux waere der API-Container gar nicht hochgekommen.

Behoben mit PEP 562 (__getattr__), demselben Kniff wie in store/__init__.py:
Der Import bleibt ohne fcntl moeglich, wer die Funktionen BENUTZT bekommt sie.

Dazu drei weitere Fehler, die nur auf Linux auftraten:

* katalog.py baute Windows-Pfade mit os.path.join. Auf dem Linux-Runner wurde
  daraus C:\Program Files (x86)\MakeMKV/makemkvcon64.exe. Jetzt entscheidet
  der Pfad selbst ueber den Trenner, nicht die rechnende Maschine.
* verknuepfungen.py nahm os.path.dirname fuer den Arbeitsordner einer .lnk.
  Eine .lnk zeigt IMMER auf einen Windows-Pfad -- also ntpath.
* waechter.py las 0.0 als Zeitpunkt statt als "noch nie geprueft". Auf einer
  frisch gestarteten Maschine ist time.monotonic() klein, also fiel die erste
  Laufwerks-Abfrage aus. None statt 0.0.

Alle vier haben jetzt Tests, die auf JEDER Plattform laufen -- der
Treiber-Test schiebt eine detection-Attrappe unter, der Waechter-Test stellt
eine Uhr bei 0,5 s nach, die Pfad-Tests pruefen beide Zweige. Beim
Wegnehmen der Durchreiche rot gesehen (5 Fehlschlaege), danach wieder gruen.
Damit faengt die Ampel so etwas kuenftig, ohne dass es eine Linux-Umgebung
auf dem Entwicklungsrechner braucht.

Ampel lokal: 582 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-28 12:41:10 +02:00
co-authored by Claude Opus 5
parent e1577a4f47
commit bfc0cada62
13 changed files with 568 additions and 114 deletions
+10 -5
View File
@@ -123,11 +123,16 @@ def bauen(ausgabe: str, version: str) -> str:
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",
# KEINE Konsole. Wunsch des Commanders am 28.08.2026: "Unterbinde
# das CMD Fenster was sich beim Oeffnen mitoeffnet." Ein Verstecken
# zur Laufzeit reicht nicht -- eine Onefile-EXE laeuft als ZWEI
# Prozesse (die Pruefung "haengt genau einer an der Konsole" ist damit
# nie wahr), und selbst wenn, blitzte das Fenster ein bis zwei
# Sekunden auf, bis Python so weit ist.
#
# Was dafuer noetig ist, steht in packaging/windows/einstieg.py:
# ohne Konsole ist sys.stdout None, und uvicorn wuerde daran sterben.
"--windowed",
"--name", "RippySetup",
"--icon", os.path.join(REPO, "deploy", "worker-windows", "rippy.ico"),
"--version-file", versionsdatei(version, arbeit),
+37
View File
@@ -3,18 +3,55 @@
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.
## Warum hier trotzdem etwas zur Ausgabe steht
Die EXE ist als Fenster-Programm gebaut (`--windowed`), damit beim Start
KEINE Konsole aufgeht — ausdruecklicher Wunsch des Commanders am 28.08.2026:
„Unterbinde das CMD Fenster was sich beim Oeffnen mitoeffnet."
Der Preis: `sys.stdout` ist dann `None`. Jede Bibliothek, die auf `stderr`
schreibt — uvicorn tut das —, wuerde mit
`AttributeError: 'NoneType' object has no attribute 'write'` sterben, und
zwar mitten im Start, ohne dass irgendwo etwas stuende.
Deshalb ganz zu Anfang, VOR allen anderen Importen aus `rippy`:
1. In einem Terminal gestartet? -> dort hineinschreiben
2. Sonst -> in %LOCALAPPDATA%\\Rippy\\rippy.log
"""
import multiprocessing
import sys
def _ausgabe_richten() -> None:
"""Dafuer sorgen, dass es eine Ausgabe GIBT — Konsole oder Datei."""
from rippy.platform import winlauf
if not winlauf.ohne_konsole():
return # aus dem Quellcode gestartet
if winlauf.an_elternkonsole_haengen():
# Ein Terminal ist da. Die Stroeme neu oeffnen, sonst bleibt
# sys.stdout trotz Konsole None.
try:
sys.stdout = open("CONOUT$", "w", encoding="utf-8", errors="replace",
buffering=1)
sys.stderr = sys.stdout
return
except OSError:
pass
winlauf.ausgabe_umleiten()
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()
_ausgabe_richten()
from rippy.windows_app import main as rippy_main
return rippy_main()
+44
View File
@@ -218,6 +218,50 @@ def test_haengendes_laufwerk_haelt_den_waechter_nicht_an():
assert bus.typen == ["job.progress"]
class FrischGestartet:
"""Eine Uhr, die gerade erst angefangen hat zu zählen.
`time.monotonic()` zählt ab einem beliebigen Nullpunkt — unter Linux ab
dem Start der Maschine. Auf einem Rechner, der seit Wochen läuft, ist der
Wert riesig; auf einem frisch gestarteten (oder in einem Container) ist er
klein. Genau dieser Unterschied hat die Ampel rot gemacht.
"""
def __init__(self, wert=0.5):
self.wert = wert
def monotonic(self):
return self.wert
def test_erste_abfrage_faellt_nicht_aus_wenn_die_uhr_bei_null_steht(monkeypatch):
"""DER Fehler aus den Ampel-Läufen 181184 (28.08.2026).
`_laufwerke_geprueft` stand auf `0.0` und sollte „noch nie geprüft"
heißen. Verglichen wurde es aber mit `time.monotonic()`. Auf einer frisch
gestarteten Maschine ist `jetzt - 0.0` KLEIN — also sah der Wächter aus,
als hätte er die Laufwerke gerade eben schon gelesen, und die erste
Abfrage fiel ersatzlos aus.
Unter Windows fiel das nie auf, weil der Zähler dort schon beim ersten
Start groß ist. Dieser Test stellt den kleinen Zähler nach und läuft
deshalb auf JEDER Plattform.
"""
from rippy.bus import waechter as modul
monkeypatch.setattr(modul, "time", FrischGestartet(0.5))
aufrufe = []
w = Waechter(FakeStore(), FakeBus(),
laufwerke_lesen=lambda: aufrufe.append(1) or [_lw("/dev/sr0")],
laufwerks_takt=999)
w.einmal()
assert len(aufrufe) == 1, (
"Die erste Laufwerks-Abfrage ist ausgefallen: 0.0 wurde als Zeitpunkt "
"gelesen statt als 'noch nie geprueft'."
)
def test_laufwerke_werden_seltener_abgefragt_als_jobs():
"""Ein ioctl kostet mehr als ein SELECT. Ohne den eigenen Takt liefe
jede Sekunde eine Laufwerksabfrage — auf einem klemmenden Laufwerk waere
+15 -4
View File
@@ -181,11 +181,20 @@ class Waechter:
self._laufwerks_takt = laufwerks_takt
self._jobs: dict = {}
self._laufwerke: dict = {}
self._laufwerke_geprueft = 0.0
# None heißt „noch nie geprüft" — NICHT 0.0. `time.monotonic()` zählt
# ab einem beliebigen Nullpunkt; unter Linux ist das der Start der
# Maschine. Auf einem frisch gestarteten System ist `jetzt - 0.0`
# deshalb KLEIN, und die erste Abfrage fiel schlicht aus.
#
# Auf dem Linux-Runner der Ampel genau so passiert (28.08.2026): null
# Laufwerks-Abfragen statt einer. Unter Windows fällt es nie auf, weil
# der Zähler dort schon beim ersten Start groß ist — ein Fehler, den
# nur die andere Plattform zeigt.
self._laufwerke_geprueft = None
self._system_lesen = system_lesen
self._system_takt = system_takt
self._system = None
self._system_geprueft = 0.0
self._system_geprueft = None
self._letzte_log_id = None
self._letzter_lauf = 0.0
self._fehler_in_folge = 0
@@ -264,7 +273,8 @@ class Waechter:
if self._system_lesen is None:
return 0
jetzt = time.monotonic()
if jetzt - self._system_geprueft < self._system_takt:
if (self._system_geprueft is not None
and jetzt - self._system_geprueft < self._system_takt):
return 0
self._system_geprueft = jetzt
try:
@@ -289,7 +299,8 @@ class Waechter:
if self._laufwerke_lesen is None:
return 0
jetzt = time.monotonic()
if jetzt - self._laufwerke_geprueft < self._laufwerks_takt:
if (self._laufwerke_geprueft is not None
and jetzt - self._laufwerke_geprueft < self._laufwerks_takt):
return 0
self._laufwerke_geprueft = jetzt
try:
+31
View File
@@ -54,6 +54,37 @@ from rippy.drives.cdrom import ( # noqa: F401
CDS_TRAY_OPEN,
)
# Was `detection.py` beisteuert — auf Anfrage, nicht beim Import.
#
# ## Warum das hier stehen MUSS (Ampel rot seit 28.08.2026, Lauf 181184)
#
# `docker/api/main.py` holt sich beim Import `device_discovery.drive_status`.
# Der Windows-Treiber hat die Funktion; dieses Modul hatte sie nicht mehr,
# seit `detection` (und damit `fcntl`) bewusst nicht mehr oben importiert
# wird. Folge: Unter Windows lief alles, auf Linux starb main.py beim Import
# mit `AttributeError: module 'rippy.drives.linux' has no attribute
# 'drive_status'` — **der API-Container wäre gar nicht hochgekommen**.
#
# Lokal fiel es nicht auf, weil hier Windows läuft und `treiber()` dann gar
# nicht zu diesem Modul greift. Genau dafür ist die Ampel da — und genau
# deshalb ist Regel A („grün, bevor irgendetwas fertig heißt") keine Formalie.
#
# PEP 562: `__getattr__` wird nur gefragt, wenn der Name nicht schon als
# Modul-Variable existiert. Damit bleibt `import rippy.drives.linux` unter
# Windows möglich (kein `fcntl` nötig), und wer die Funktionen wirklich
# BENUTZT, bekommt sie — oder einen lauten ImportError.
_AUS_DETECTION = ("drive_status", "disc_status", "disc_size_bytes",
"detect_disc_type", "classify")
def __getattr__(name):
if name in _AUS_DETECTION:
from rippy.drives import detection
return getattr(detection, name)
raise AttributeError("module %r has no attribute %r" % (__name__, name))
# Wie lange auf die Schublade gewartet wird. Ein Laufwerk braucht dafür ein
# bis zwei Sekunden; fünf sind reichlich und blockieren nichts Wichtiges.
AUSWURF_WARTEN_SEKUNDEN = 5
+55 -1
View File
@@ -71,4 +71,58 @@ def test_windows_treiber_kann_alles_pflichtgemaesse(name):
reason="der Linux-Treiber braucht fcntl")
@pytest.mark.parametrize("name", PFLICHT)
def test_linux_treiber_kann_alles_pflichtgemaesse(name):
assert callable(getattr(drives.treiber("linux"), name, None))
assert callable(getattr(drives.treiber("linux"), name, None)), (
f"Dem Linux-Treiber fehlt {name}(). Das faellt sonst erst im Betrieb "
"als AttributeError auf — auf der anderen Plattform."
)
# Was `linux.py` nicht selbst hat, sondern aus `detection.py` durchreicht.
# Getrennt gefuehrt, weil genau diese Trennung der Fehler war.
AUS_DETECTION = ("drive_status", "disc_status", "disc_size_bytes",
"detect_disc_type", "classify")
@pytest.mark.parametrize("name", AUS_DETECTION)
def test_linux_treiber_reicht_die_detection_funktionen_durch(name, monkeypatch):
"""DER Fehler, der die Ampel vier Commits lang rot hielt (28.08.2026).
`docker/api/main.py` holt sich beim Import `device_discovery.drive_status`.
Der Windows-Treiber hat die Funktion. `linux.py` hatte sie NICHT mehr,
seit `detection` (und damit `fcntl`) bewusst nicht mehr oben importiert
wird — sonst waere `main.py` unter Windows nicht ladbar.
Folge: Unter Windows lief alles, auf Linux starb `main.py` beim Import
mit `AttributeError: module 'rippy.drives.linux' has no attribute
'drive_status'`. **Der API-Container waere gar nicht hochgekommen.**
Der Test oben faellt unter Windows aus (er braucht `fcntl`) — dieser
hier NICHT: Er schiebt eine Attrappe von `detection` unter und prueft
nur die Durchreiche. Damit haette der Fehler auch hier auffallen muessen,
nicht erst auf dem Linux-Runner.
"""
import types
from rippy.drives import linux
attrappe = types.ModuleType("rippy.drives.detection")
for wie_es_heisst in AUS_DETECTION:
setattr(attrappe, wie_es_heisst,
lambda *a, _n=wie_es_heisst, **k: _n)
monkeypatch.setitem(sys.modules, "rippy.drives.detection", attrappe)
durchgereicht = getattr(linux, name, None)
assert callable(durchgereicht), (
f"linux.py reicht {name}() nicht durch — genau daran ist die API "
"auf Linux beim Import gestorben."
)
assert durchgereicht() == name
def test_unbekannte_namen_werfen_weiter(monkeypatch):
"""Die Durchreiche darf kein Sammelbecken werden: Ein Tippfehler muss
ein AttributeError bleiben, sonst verschwindet er still."""
from rippy.drives import linux
with pytest.raises(AttributeError):
linux.gibt_es_nicht
+99 -48
View File
@@ -1,13 +1,22 @@
"""Kind-Prozesse ohne Fenster — und die eigene Konsole.
"""Kind-Prozesse ohne Fenster — und die Ausgabe ohne Konsole.
Ein Konsolenfenster laesst sich im Test nicht ansehen. Geprueft wird deshalb
die ENTSCHEIDUNG: Gehoert die Konsole uns, oder haben wir sie geerbt? Genau
daran haengt, ob `konsole_verbergen()` ein Fremdkoerper ausblendet oder dem
Nutzer sein Terminal wegnimmt.
Ein Konsolenfenster laesst sich in einem Test nicht ansehen. Geprueft wird
deshalb das, woran es beim ersten Anlauf gescheitert ist: die ENTSCHEIDUNG,
wohin die Ausgabe geht, wenn es keine Konsole gibt.
## Warum das nicht Nebensache ist
Die EXE wird seit dem 28.08.2026 als Fenster-Programm gebaut, damit beim
Doppelklick keine schwarze Box aufgeht. Damit ist `sys.stdout` aber `None`.
Ohne Umleitung wuerde jede Bibliothek, die auf `stderr` schreibt, mit
`AttributeError: 'NoneType' object has no attribute 'write'` sterben — mitten
im Start, und nirgends stuende etwas. Genau der stille Fehlschlag, vor dem
AGENTS.md warnt.
"""
import os
import subprocess
import sys
import pytest
@@ -16,6 +25,7 @@ from rippy.platform import winlauf
nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows")
# ── Kind-Prozesse ───────────────────────────────────────────────────────
def test_flag_ist_auf_windows_gesetzt_und_sonst_null():
"""Auf Linux ist CREATE_NO_WINDOW nicht vorhanden; `creationflags=0` wird
dort akzeptiert und ignoriert. So laeuft derselbe Code auf beiden Seiten."""
@@ -25,48 +35,89 @@ def test_flag_ist_auf_windows_gesetzt_und_sonst_null():
assert winlauf.OHNE_FENSTER == 0
def test_ohne_windows_wird_nichts_verborgen():
"""Der Kern laeuft auch unter Linux. Ein ctypes.WinDLL waere dort ein
harter Fehler mitten im Start."""
# ── Gibt es ueberhaupt eine Ausgabe? ────────────────────────────────────
def test_fehlende_standardausgabe_wird_erkannt(monkeypatch):
"""Der Fall in der fertigen EXE: PyInstaller setzt `sys.stdout` auf None."""
monkeypatch.setattr(sys, "stdout", None)
assert winlauf.ohne_konsole() is True
def test_ein_strom_ohne_dateinummer_zaehlt_auch_als_ohne_konsole(monkeypatch):
"""pytest ersetzt stdout durch einen Auffang-Puffer. Der hat kein
`fileno` — und ein Meldungsfenster waere in einem Testlauf das Letzte,
was jemand gebrauchen kann."""
class Puffer:
write = staticmethod(lambda _: None)
monkeypatch.setattr(sys, "stdout", Puffer())
assert winlauf.ohne_konsole() is True
def test_mit_echter_ausgabe_ist_alles_in_ordnung(monkeypatch, tmp_path):
datei = tmp_path / "echt.txt"
with open(datei, "w", encoding="utf-8") as f:
monkeypatch.setattr(sys, "stdout", f)
assert winlauf.ohne_konsole() is False
# ── Die Protokolldatei ──────────────────────────────────────────────────
def test_protokoll_liegt_bei_rippy():
"""Nicht im TEMP: Was Rippy ueber sich aufschreibt, gehoert zu Rippy und
verschwindet beim Deinstallieren mit."""
pfad = winlauf.protokolldatei(os.path.join("C:" + os.sep, "Basis"))
assert pfad.endswith(os.path.join("Rippy", "rippy.log"))
assert pfad.startswith(os.path.join("C:" + os.sep, "Basis"))
def test_umleiten_schreibt_wirklich(tmp_path, monkeypatch):
ziel = str(tmp_path / "unterordner" / "rippy.log")
vorher_out, vorher_err = sys.stdout, sys.stderr
try:
assert winlauf.ausgabe_umleiten(ziel) == ziel
print("eine Zeile mit Umlaut: ä")
sys.stdout.flush()
finally:
sys.stdout, sys.stderr = vorher_out, vorher_err
assert "eine Zeile mit Umlaut" in open(ziel, encoding="utf-8").read()
def test_umleiten_haengt_an_statt_zu_ueberschreiben(tmp_path):
"""Sonst waere nach jedem Neustart das Protokoll des letzten Fehlers weg
— also genau das, was man dann sucht."""
ziel = str(tmp_path / "rippy.log")
vorher_out, vorher_err = sys.stdout, sys.stderr
try:
winlauf.ausgabe_umleiten(ziel)
print("erster Start")
sys.stdout.flush()
winlauf.ausgabe_umleiten(ziel)
print("zweiter Start")
sys.stdout.flush()
finally:
sys.stdout, sys.stderr = vorher_out, vorher_err
inhalt = open(ziel, encoding="utf-8").read()
assert "erster Start" in inhalt and "zweiter Start" in inhalt
def test_unbeschreibbarer_ort_laesst_den_start_nicht_platzen(monkeypatch):
"""Lieber ins Nichts schreiben als beim Start sterben. Ein Programm, das
wegen seiner Protokolldatei nicht hochkommt, ist schlimmer als eines
ohne Protokoll."""
def geht_nicht(*a, **k):
raise OSError("kein Platz")
monkeypatch.setattr(winlauf.os, "makedirs", geht_nicht)
vorher_out, vorher_err = sys.stdout, sys.stderr
try:
winlauf.ausgabe_umleiten("/gibt/es/nicht/rippy.log")
print("das darf nicht werfen")
finally:
sys.stdout, sys.stderr = vorher_out, vorher_err
# ── Das Meldungsfenster ─────────────────────────────────────────────────
def test_ohne_windows_kein_meldungsfenster():
if os.name == "nt":
pytest.skip("prueft das Verhalten auf Nicht-Windows")
assert winlauf.konsole_verbergen() is False
assert winlauf.konsole_gehoert_uns() is False
@nur_windows
def test_geerbte_konsole_wird_in_ruhe_gelassen():
"""DER Fallstrick. `Rippy.exe --status` in einem Terminal wuerde bei
einer naiven Umsetzung das Terminal des Nutzers verschwinden lassen.
Dieser Test laeuft SELBST in einem Terminal (pytest wurde von einer Shell
gestartet) — an dieser Konsole haengt also mehr als ein Prozess. Genau
der Fall, in dem nichts passieren darf.
"""
if winlauf.konsole_gehoert_uns():
pytest.skip("Dieser Lauf hat eine eigene Konsole — kein Erbfall")
assert winlauf.konsole_verbergen() is False
@nur_windows
def test_eigene_konsole_wird_erkannt():
"""Ein frisch gestarteter Prozess MIT eigener Konsole muss sich als
Eigentuemer erkennen — sonst waere die Konsole nie zu verbergen und die
schwarze Box bliebe bei jedem Doppelklick stehen."""
programm = (
"import sys; sys.path.insert(0, %r);"
"from rippy.platform import winlauf;"
"print(winlauf.konsole_gehoert_uns())"
% os.path.join(os.path.dirname(os.path.dirname(
os.path.dirname(os.path.abspath(__file__)))))
)
import sys
ergebnis = subprocess.run(
[sys.executable, "-c", programm],
capture_output=True, text=True, timeout=60,
# NEUE Konsole: Damit hat der Kindprozess eine eigene, an der sonst
# niemand haengt. Ohne dieses Flag erbte er unsere.
creationflags=subprocess.CREATE_NEW_CONSOLE,
)
assert ergebnis.stdout.strip() == "True", ergebnis.stderr
assert winlauf.meldung_zeigen("egal") is False
assert winlauf.an_elternkonsole_haengen() is False
+7 -1
View File
@@ -33,6 +33,7 @@ der Browser auf; läuft er nicht, wird er zuerst gestartet. Ein Symbol, das
beim zweiten Klick einen zweiten Server startet, wäre eine Falle.
"""
import ntpath
import os
import subprocess
@@ -83,8 +84,13 @@ def skript(lnk: str, ziel: str, argumente: str = "", icon: str = "",
zeilen.append("$s.IconLocation = '%s'" % _ps_text(icon))
if beschreibung:
zeilen.append("$s.Description = '%s'" % _ps_text(beschreibung))
# ntpath, nicht os.path: Eine .lnk zeigt IMMER auf einen
# Windows-Pfad — auch wenn dieser Code gerade auf dem Linux-Runner
# der Ampel laeuft. `os.path.dirname` gab dort fuer
# C:\Programme\Rippy\Rippy.exe einen LEEREN String zurueck, und der
# Test schlug nur dort fehl.
zeilen.append("$s.WorkingDirectory = '%s'"
% _ps_text(arbeitsordner or os.path.dirname(ziel)))
% _ps_text(arbeitsordner or ntpath.dirname(ziel)))
zeilen.append("$s.Save()")
return "; ".join(zeilen)
+98 -36
View File
@@ -26,65 +26,127 @@ derselbe Code auf beiden Seiten läuft.
## Die eigene Konsole (28.08.2026)
Dieselbe Frage noch einmal, nur andersherum: Rippy.exe ist als
Konsolenprogramm gebaut — der Installer MUSS seine Meldungen zeigen können.
Ein Doppelklick auf das Desktop-Symbol öffnet damit aber erst eine schwarze
Box und dann das Fenster. Für ein Programm, das ein „echter Client" sein
soll, ist das ein Fremdkörper.
Commander: *„Unterbinde das CMD Fenster was sich beim Öffnen mitöffnet."*
`konsole_verbergen()` blendet sie aus — aber nur die EIGENE. Wer
`Rippy.exe --oeffnen` in einem Terminal eintippt, dessen Konsole gehört ihm,
nicht Rippy; sie zu verstecken würde ihm sein Fenster wegnehmen. Woran man
das unterscheidet: `GetConsoleProcessList` sagt, wie viele Prozesse an dieser
Konsole hängen. Genau einer — dann ist es die eigene.
Der erste Anlauf wollte das Fenster VERSTECKEN: `GetConsoleProcessList`
fragen, und bei genau einem angehängten Prozess `ShowWindow(SW_HIDE)`. Zwei
Gründe, warum das nicht reicht:
1. **Eine Onefile-EXE ist ZWEI Prozesse** — ein Starter und die eigentliche
Anwendung. An der Konsole hängen also immer zwei. Die Prüfung war damit
nie wahr, und das Fenster blieb einfach stehen. (Gemessen: PID 33172 mit
8 MB, PID 33580 mit 103 MB — beide `Rippy.exe`.)
2. Selbst wenn sie ginge, wäre das Fenster erst nach ein bis zwei Sekunden
weg — so lange braucht Python bis zur ersten eigenen Zeile. Ein
Aufblitzen ist kein „unterbunden".
**Die Konsole wird deshalb gar nicht erst erzeugt.** Die EXE ist als
Fenster-Programm gebaut (`--windowed`), nicht als Konsolen-Programm. Windows
legt dann für keinen Start eine Konsole an.
Der Preis: Es gibt keine Standardausgabe mehr — `sys.stdout` ist `None`.
Deshalb die drei Funktionen hier unten:
an_elternkonsole_haengen() in einem Terminal gestartet? Dann DORT
hineinschreiben (fuer --status, --hilfe)
ausgabe_umleiten() sonst: in eine Protokolldatei
meldung_zeigen() und was der Nutzer sehen MUSS, kommt in
ein Fenster statt in ein schwarzes Nichts
Ein Programm ohne Konsole, das seine Meldungen ins Leere schreibt, wäre
genau der stille Fehlschlag, vor dem AGENTS.md warnt.
"""
import os
import subprocess
import sys
# 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)
SW_HIDE = 0
ATTACH_PARENT_PROCESS = -1
MB_OK = 0x0
MB_ICONINFORMATION = 0x40
MB_ICONERROR = 0x10
def konsole_gehoert_uns() -> bool:
"""Hängt NUR dieser Prozess an der Konsole?
def ohne_konsole() -> bool:
"""Läuft dieser Prozess ohne Standardausgabe?
False heißt: Wir haben sie geerbt (Terminal, `cmd`, PowerShell). Dann ist
sie fremdes Eigentum — Hände weg. Ohne diese Unterscheidung würde ein
`Rippy.exe --status` im Terminal das Terminal des Nutzers verschwinden
lassen.
Das ist der Normalfall in einer `--windowed` gebauten EXE. `print()` würde
dann werfen oder ins Leere gehen — beides schlecht, deshalb wird es
abgefragt statt angenommen.
"""
return sys.stdout is None or getattr(sys.stdout, "fileno", None) is None
def an_elternkonsole_haengen() -> bool:
"""An die Konsole des Aufrufers andocken, falls es eine gibt.
Wer `Rippy.exe --status` in PowerShell eintippt, soll die Antwort DORT
sehen. Ein Fenster-Programm hat zwar keine eigene Konsole, darf sich aber
an die des Aufrufers hängen — genau dafür gibt es
`ATTACH_PARENT_PROCESS`. Beim Doppelklick gibt es keine, dann ist das
Ergebnis schlicht False und der Aufrufer leitet um.
"""
if os.name != "nt":
return False
import ctypes
try:
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
puffer = (ctypes.c_uint * 8)()
anzahl = kernel32.GetConsoleProcessList(puffer, 8)
return bool(ctypes.WinDLL("kernel32", use_last_error=True)
.AttachConsole(ATTACH_PARENT_PROCESS))
except (AttributeError, OSError):
return False
return anzahl == 1
def konsole_verbergen() -> bool:
"""Blendet die eigene Konsole aus. True, wenn es wirklich passiert ist.
def protokolldatei(basis: str = None) -> str:
"""Wohin die Ausgabe geht, wenn niemand zusieht. (reine Funktion)
Kein `FreeConsole()`: Das schließt auch die Standardausgabe, und ein
späteres `print` würde dann werfen. Verstecken reicht — das Fenster ist
weg, die Ströme bleiben heil.
Neben die Datenbank, nicht ins TEMP: Was Rippy über sich selbst
aufschreibt, gehört zu Rippy und wird beim Deinstallieren mit entfernt.
"""
import ctypes
import os
wurzel = basis or os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
return os.path.join(wurzel, "Rippy", "rippy.log")
if os.name != "nt" or not konsole_gehoert_uns():
return False
def ausgabe_umleiten(pfad: str = None) -> str:
"""Standardausgabe in die Protokolldatei. Gibt den Pfad zurück, sonst "".
OHNE das würde jede Bibliothek, die auf `sys.stderr` schreibt, in einer
`--windowed` EXE mit `AttributeError: 'NoneType' object has no attribute
'write'` sterben — mitten im Start, ohne dass irgendwo etwas stünde.
"""
pfad = pfad or protokolldatei()
try:
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
fenster = kernel32.GetConsoleWindow()
if not fenster:
return False
return bool(ctypes.WinDLL("user32", use_last_error=True)
.ShowWindow(fenster, SW_HIDE))
os.makedirs(os.path.dirname(pfad), exist_ok=True)
strom = open(pfad, "a", encoding="utf-8", errors="replace", buffering=1)
except OSError:
# Selbst das ging nicht. Dann lieber ins Nichts als beim Start sterben.
try:
strom = open(os.devnull, "w")
except OSError:
return ""
sys.stdout = strom
sys.stderr = strom
return pfad
def meldung_zeigen(text: str, titel: str = "Rippy", fehler: bool = False) -> bool:
"""Ein Meldungsfenster. True, wenn es wirklich gezeigt wurde.
Für alles, was der Nutzer sehen MUSS und ohne Konsole sonst nie sähe —
vor allem das Ergebnis der Installation.
"""
if os.name != "nt":
return False
import ctypes
symbol = MB_ICONERROR if fehler else MB_ICONINFORMATION
try:
ctypes.WinDLL("user32", use_last_error=True).MessageBoxW(
None, str(text), str(titel), MB_OK | symbol)
return True
except (AttributeError, OSError):
return False
+40
View File
@@ -156,6 +156,46 @@ def test_installation_ruehrt_die_echten_verknuepfungen_nicht_an(tmp_path, monkey
assert aufrufe == []
# ── Rueckmeldung ohne Konsole ───────────────────────────────────────────
def test_ohne_konsole_kommt_ein_fenster(monkeypatch):
"""Seit die EXE ohne Konsole gebaut wird (Wunsch des Commanders am
28.08.2026: „Unterbinde das CMD Fenster"), geht `melden()` beim
Doppelklick in eine Protokolldatei — die niemand aufschlaegt. Ein
gescheiterter Installer saehe damit exakt aus wie einer, der nichts tut."""
from rippy.platform import winlauf
gezeigt = []
monkeypatch.setattr(winlauf, "ohne_konsole", lambda: True)
monkeypatch.setattr(winlauf, "meldung_zeigen",
lambda text, titel="Rippy", fehler=False:
gezeigt.append((text, fehler)) or True)
assert windows_app.abschluss_melden("Fertig: C:\\Rippy") is True
assert gezeigt == [("Fertig: C:\\Rippy", False)]
def test_mit_konsole_kommt_KEIN_fenster(monkeypatch):
"""Wer das Setup in PowerShell aufruft, will keinen Klick wegdruecken,
sondern die Zeile lesen."""
from rippy.platform import winlauf
monkeypatch.setattr(winlauf, "ohne_konsole", lambda: False)
monkeypatch.setattr(winlauf, "meldung_zeigen",
lambda *a, **k: pytest.fail("kein Fenster erwartet"))
assert windows_app.abschluss_melden("Fertig") is False
def test_still_heisst_still(monkeypatch):
"""`--still` ist fuer unbeaufsichtigte Installationen. Ein Meldungsfenster
wuerde dort ewig auf einen Klick warten, den niemand macht."""
from rippy.platform import winlauf
monkeypatch.setattr(winlauf, "ohne_konsole", lambda: True)
monkeypatch.setattr(winlauf, "meldung_zeigen",
lambda *a, **k: pytest.fail("kein Fenster bei --still"))
assert windows_app.abschluss_melden("Fertig", still=True) is False
# ── Die Oberflaeche neben dem Programm ──────────────────────────────────
def test_ui_wird_neben_das_programm_gelegt(tmp_path, monkeypatch):
"""DER Befund vom 28.08.2026: Ein Dienst lief eine Stunde, `/api/health`
+41 -5
View File
@@ -38,7 +38,9 @@ Uninstall-Zweig der Registry (`DisplayVersion`). Das ist kein Notbehelf,
sondern die einzige Stelle, an der sie unter Windows überhaupt steht.
"""
import ntpath
import os
import posixpath
import shutil
import subprocess
@@ -84,6 +86,40 @@ def werkzeug_ordner(basis: str = None) -> str:
return os.path.join(os.path.expanduser("~"), ".local", "share", "rippy", "tools")
def verbinden(basis: str, *teile) -> str:
r"""Pfadteile mit dem Trenner der BASIS verbinden, nicht dem des Systems.
## Warum nicht `os.path.join` (Ampel rot 28.08.2026, Läufe 181184)
`os.path.join` nimmt immer den Trenner der laufenden Maschine. Auf dem
Linux-Runner der Ampel wurde aus `C:\Program Files (x86)\MakeMKV` und
`makemkvcon64.exe` dann:
C:\Program Files (x86)\MakeMKV/makemkvcon64.exe
Ein Pfad, den es auf keiner Maschine gibt. Fünf Tests sind daran
gescheitert und zwar NUR auf Linux, weshalb es hier lange niemand sah.
Ein Werkzeug-Pfad beschreibt einen Ort auf der Maschine, die ihn
NENNT nicht auf der, die gerade rechnet. Deshalb entscheidet die
Basis: Laufwerksbuchstabe oder Backslash heißt Windows.
"""
modul = ntpath if _sieht_nach_windows_aus(basis) else posixpath
return modul.join(basis, *teile)
def _sieht_nach_windows_aus(pfad: str) -> bool:
"""`C:\\…` oder irgendein Backslash — dann ist es ein Windows-Pfad."""
pfad = pfad or ""
return "\\" in pfad or (len(pfad) > 1 and pfad[1] == ":")
def ordner_von(datei: str) -> str:
"""Der Ordner einer Datei — nach dem Trenner der DATEI (siehe `verbinden`)."""
modul = ntpath if _sieht_nach_windows_aus(datei) else posixpath
return modul.dirname(datei)
def _entfalten(pfad: str, umgebung: dict) -> str:
"""%ProgramFiles%\\X -> C:\\Program Files\\X (mit eigener Umgebung testbar)."""
ergebnis = pfad
@@ -107,15 +143,15 @@ def kandidaten(name: str, eigener_ordner: str = None, umgebung: dict = None) ->
# 2. Rippys eigener Ordner — VOR dem System, damit eine selbst gepflegte
# Fassung eine alte Systeminstallation schlägt.
for programm in werkzeug["programme"]:
pfade.append(os.path.join(eigener_ordner, name, programm))
pfade.append(os.path.join(eigener_ordner, programm))
pfade.append(verbinden(eigener_ordner, name, programm))
pfade.append(verbinden(eigener_ordner, programm))
# 4. Bekannte Installationsorte.
for ort in werkzeug["orte"]:
entfaltet = _entfalten(ort, umgebung)
if not entfaltet:
continue
for programm in werkzeug["programme"]:
pfade.append(os.path.join(entfaltet, programm))
pfade.append(verbinden(entfaltet, programm))
return pfade
@@ -151,7 +187,7 @@ def finden(name: str, eingestellt: str = "", eigener_ordner: str = None,
ordner = aus_registry(WERKZEUGE[name]["anzeige"]).get("ordner", "")
if ordner:
for programm in WERKZEUGE[name]["programme"]:
pfad = os.path.join(ordner, programm)
pfad = verbinden(ordner, programm)
if existiert(pfad):
return pfad
return ""
@@ -179,7 +215,7 @@ def ordner_aus_eintrag(werte: dict) -> str:
for schluessel in ("DisplayIcon", "UninstallString"):
datei = programmdatei_aus(werte.get(schluessel) or "")
if datei:
return os.path.dirname(datei)
return ordner_von(datei)
return ""
+48
View File
@@ -14,6 +14,8 @@ laeuft auf jeder Plattform.
from rippy.tools import katalog
BACKSLASH = chr(92)
# Eine Umgebung ohne echte Systemvariablen, damit die Pfade vorhersagbar sind.
UMGEBUNG = {
"ProgramFiles": r"C:\Program Files",
@@ -35,6 +37,52 @@ def _keine_registry(_):
return {}
# ── Pfade zusammensetzen ────────────────────────────────────────────────
#
# Diese fuenf Tests gibt es wegen der Ampel-Laeufe 181184 (28.08.2026): Sie
# waren rot, und zwar NUR auf Linux. `os.path.join` nimmt immer den Trenner
# der laufenden Maschine — auf dem Linux-Runner wurde aus
# `C:\Program Files (x86)\MakeMKV` + `makemkvcon64.exe` dann
# `C:\Program Files (x86)\MakeMKV/makemkvcon64.exe`, ein Pfad, den es auf
# keiner Maschine gibt.
#
# Sie pruefen BEIDE Zweige, laufen also ueberall und faenden den Fehler auch
# unter Windows. Genau das war vorher nicht so.
def test_windows_pfad_bekommt_backslashes():
assert katalog.verbinden(r"C:\Program Files (x86)\MakeMKV",
"makemkvcon64.exe") == \
r"C:\Program Files (x86)\MakeMKV\makemkvcon64.exe"
def test_linux_pfad_bekommt_schraegstriche():
assert katalog.verbinden("/usr/local/bin", "makemkvcon") == \
"/usr/local/bin/makemkvcon"
def test_laufwerksbuchstabe_allein_reicht_als_hinweis():
"""`D:` ohne Backslash ist trotzdem Windows — und wird auch so verbunden.
Der erste Anlauf dieses Tests erwartete `D:\\Rippy\\tools` und war damit
falsch: `D:` OHNE Backslash bedeutet unter Windows der aktuelle Ordner
auf Laufwerk D", nicht dessen Wurzel. `ntpath.join` setzt deshalb
absichtlich keinen Trenner davor. Geprueft wird hier also, dass die
Windows-Regeln gelten nicht, dass eine bequeme Erwartung erfuellt wird.
"""
assert katalog.verbinden("D:", "Rippy", "tools") == r"D:Rippy\tools"
assert katalog.verbinden("D:" + BACKSLASH, "Rippy") == r"D:\Rippy"
def test_ordner_von_folgt_dem_pfad_nicht_der_maschine():
assert katalog.ordner_von(r"C:\Program Files (x86)\MakeMKV\uninstall.exe") \
== r"C:\Program Files (x86)\MakeMKV"
assert katalog.ordner_von("/opt/makemkv/bin/makemkvcon") == "/opt/makemkv/bin"
def test_leerer_pfad_gilt_nicht_als_windows():
assert katalog._sieht_nach_windows_aus("") is False
assert katalog._sieht_nach_windows_aus(None) is False
# ── Suchreihenfolge ─────────────────────────────────────────────────────
def test_eingestellter_pfad_schlaegt_alles():
"""Wer einen Pfad eintraegt, will genau den — sonst haette er nichts
+43 -14
View File
@@ -89,6 +89,30 @@ def melden(text: str) -> None:
pass
def abschluss_melden(text: str, still: bool = False, fehler: bool = False) -> bool:
"""Das Ergebnis so melden, dass der Nutzer es WIRKLICH sieht.
Seit die EXE ohne Konsole gebaut wird (Wunsch des Commanders am
28.08.2026: Unterbinde das CMD Fenster"), geht `melden()` beim
Doppelklick in eine Protokolldatei die niemand aufschlägt. Ein
gescheiterter Installer sähe damit exakt aus wie einer, der nichts tut.
Deshalb hier zusätzlich ein Meldungsfenster, und zwar NUR wenn keine
Konsole da ist: Wer das Setup in PowerShell aufruft, will keinen Klick
wegdrücken, sondern die Zeile lesen.
Gibt zurück, ob ein Fenster gezeigt wurde.
"""
melden(text.replace("\n\n", "\n"))
if still:
return False
from rippy.platform import winlauf
if not winlauf.ohne_konsole():
return False
return winlauf.meldung_zeigen(text, fehler=fehler)
# ═══════════════════════════════════════════════════ Installation ═══════
def installieren(ziel: str = None, autostart: bool = True,
starten_danach: bool = True, verknuepfen: bool = True) -> str:
@@ -573,32 +597,37 @@ def main(argv=None) -> int:
deinstallieren(still="--still" in argv)
return 0
if "--oeffnen" in argv:
# Rippy.exe ist ein Konsolenprogramm (der Installer MUSS seine
# Meldungen zeigen koennen). Beim Doppelklick auf das Desktop-Symbol
# blitzte damit erst eine schwarze Box auf und dann das Fenster --
# ein Fremdkoerper in einem Programm, das ein echter Client sein soll.
# Verborgen wird nur die EIGENE Konsole, nie eine geerbte.
from rippy.platform.winlauf import konsole_verbergen
konsole_verbergen()
return oeffnen(int(wert("--port", STANDARD_PORT)),
als_fenster="--im-browser" not in argv)
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,
verknuepfen="--keine-verknuepfungen" not in argv,
)
try:
ziel = installieren(
ziel=wert("--ziel"),
autostart="--kein-autostart" not in argv,
starten_danach="--nicht-starten" not in argv,
verknuepfen="--keine-verknuepfungen" not in argv,
)
except Exception as e: # noqa: BLE001
# Ohne Konsole waere ein Absturz hier VOLLKOMMEN unsichtbar: Der
# Doppelklick tut nichts, und niemand erfaehrt warum. Erst zeigen,
# dann weiterwerfen — der Rueckgabewert bleibt ein Fehler.
abschluss_melden("Die Installation ist gescheitert:\n\n%s: %s"
% (type(e).__name__, e), still="--still" in argv,
fehler=True)
raise
melden("Rippy wurde nach %s installiert." % ziel)
melden("Die Oberfläche öffnet sich gleich in einem eigenen Fenster.")
if "--nicht-starten" not in argv:
# Als eigener Prozess und aus dem INSTALLIERTEN Programm heraus:
# Das Setup soll sich beenden koennen, ohne das Fenster mitzunehmen.
time.sleep(3)
starte_fensterprozess(os.path.join(ziel, "Rippy.exe"))
else:
abschluss_melden("Rippy wurde installiert:\n\n%s" % ziel,
still="--still" in argv)
return 0