fix(windows): kein CMD-Fenster mehr — die EXE ist ein Fenster-Programm
Ampel / ampel (push) Failing after 55s
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:
co-authored by
Claude Opus 5
parent
e1577a4f47
commit
bfc0cada62
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 181–184 (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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 181–184)
|
||||
#
|
||||
# `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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 181–184)
|
||||
|
||||
`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 ""
|
||||
|
||||
|
||||
|
||||
@@ -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 181–184 (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
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user