diff --git a/deploy/worker-windows/rippy.ico b/deploy/worker-windows/rippy.ico index 9e748d2..568e5da 100644 Binary files a/deploy/worker-windows/rippy.ico and b/deploy/worker-windows/rippy.ico differ diff --git a/packaging/windows/build.py b/packaging/windows/build.py index 455e61a..cfdbe07 100644 --- a/packaging/windows/build.py +++ b/packaging/windows/build.py @@ -94,8 +94,24 @@ def pruefen() -> list: 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")): + icon = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico") + if not os.path.isfile(icon): maengel.append("deploy/worker-windows/rippy.ico fehlt.") + else: + # Nicht nur DA, sondern VOLLSTAENDIG. Am 28.08.2026 steckte genau ein + # Bild darin (256x256), und Windows zeigte auf Desktop, im Startmenue + # und in der Taskleiste ein leeres Blatt. Eine Datei, die es gibt, ist + # noch keine Datei, die funktioniert. + import sys as _sys + _sys.path.insert(0, HIER) + from icon import fehlende_pflichtgroessen + + fehlt = fehlende_pflichtgroessen(icon) + if fehlt: + maengel.append( + "rippy.ico fehlen die Groessen %s — Windows zeigt dann ein " + "leeres Blatt. Abhilfe: python packaging/windows/icon.py" + % (fehlt,)) try: import PyInstaller # noqa: F401 except ImportError: diff --git a/packaging/windows/icon.py b/packaging/windows/icon.py new file mode 100644 index 0000000..83a8a3a --- /dev/null +++ b/packaging/windows/icon.py @@ -0,0 +1,142 @@ +"""Ein Windows-Symbol, das Windows auch wirklich anzeigt. + +## Der Befund (28.08.2026) + +Commander: *„Startmenü, Desktop und Taskleisten-Icon ohne icon file (Weißes +Leeres Blatt)."* + +Nachgesehen, was in `deploy/worker-windows/rippy.ico` steckte: + + Bilder = 1 + 256x256 32 bit 5657 Bytes PNG + +**Genau ein Bild, und zwar das größte.** Windows holt sich für jede Stelle +die passende Größe aus der Datei: + + Taskleiste, Verknüpfung klein 16x16 + Desktop, Startmenü 32x32 / 48x48 + Große Kacheln, Alt-Tab 256x256 + +Fehlt die passende Größe, skaliert die Shell nicht immer selbst — sie zeigt +das leere Blatt. Bei einer Datei, die NUR ein PNG-komprimiertes 256er +enthält, passiert das zuverlässig. + +## Was diese Datei tut + +Sie baut aus dem vorhandenen Symbol eine `.ico` mit allen gebrauchten Größen. +Die kleinen Größen werden mit `LANCZOS` verkleinert — bei 16x16 ist das der +Unterschied zwischen einem erkennbaren Zeichen und Pixelbrei. + +Aufruf (macht der Bau selbst, siehe `build.py`): + + python packaging/windows/icon.py [--quelle DATEI] [--ziel DATEI] +""" + +import argparse +import os + +# Was Windows an den einzelnen Stellen holt. 24 und 64 sind fuer hohe +# Bildschirmaufloesungen (150 %/200 % Skalierung) — ohne sie skaliert die +# Shell aus 32 bzw. 48 hoch, und das sieht ausgefranst aus. +GROESSEN = (16, 20, 24, 32, 40, 48, 64, 128, 256) + +HIER = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.dirname(os.path.dirname(HIER)) +QUELLE = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico") + + +def groessen_in(pfad: str) -> list: + """Welche Bildgroessen stecken in einer .ico? (ohne Pillow, reines Lesen) + + Fuer die Pruefung im Bau: Eine Datei mit nur EINER Groesse ist genau der + Fehler, den es hier zu verhindern gilt. + """ + import struct + + with open(pfad, "rb") as f: + roh = f.read() + if len(roh) < 6: + return [] + reserviert, typ, anzahl = struct.unpack(" tuple: + """Welche Pflichtgroessen fehlen? Leer heisst: alles da.""" + vorhanden = set(groessen_in(pfad)) + return tuple(k for k in PFLICHTGROESSEN if k not in vorhanden) + + +def bauen(quelle: str = None, ziel: str = None) -> str: + """Baut die vollstaendige .ico. Gibt den Zielpfad zurueck. + + Wirft, wenn es nicht geht — ein Symbol, das still fehlschlaegt, fuehrt + genau zu dem leeren Blatt, das hier abgestellt werden soll. + """ + from PIL import Image + + quelle = quelle or QUELLE + ziel = ziel or quelle + + bild = Image.open(quelle) + bild = bild.convert("RGBA") + + # Aus dem groessten vorhandenen Bild verkleinern, nicht aus einem + # zufaellig ausgewaehlten: Pillow oeffnet bei einer .ico sonst die erste + # Groesse, und aus 16x16 laesst sich kein 256er machen. + if hasattr(bild, "size") and bild.size[0] < 256: + try: + bild = Image.open(quelle) + bild.size = (256, 256) # Pillow: gewuenschte Groesse waehlen + bild = bild.convert("RGBA") + except Exception: # noqa: BLE001 + pass + + ebenen = [] + for kante in GROESSEN: + if kante >= bild.size[0]: + ebenen.append(bild.copy().resize((kante, kante), Image.LANCZOS)) + else: + # LANCZOS auch beim Verkleinern: bei 16x16 ist das der Unterschied + # zwischen einem erkennbaren Zeichen und Pixelbrei. + ebenen.append(bild.resize((kante, kante), Image.LANCZOS)) + + ebenen[-1].save(ziel, format="ICO", + sizes=[(k, k) for k in GROESSEN]) + return ziel + + +def main(argv=None) -> int: + p = argparse.ArgumentParser(description="Baut eine vollstaendige rippy.ico") + p.add_argument("--quelle", default=QUELLE) + p.add_argument("--ziel", default=None) + p.add_argument("--nur-pruefen", action="store_true") + args = p.parse_args(argv) + + vorher = groessen_in(args.quelle) + print("Vorher: %s" % (vorher or "keine lesbare .ico")) + if args.nur_pruefen: + return 0 if len(vorher) > 1 else 1 + + ziel = bauen(args.quelle, args.ziel) + print("Nachher: %s" % groessen_in(ziel)) + print("Geschrieben: %s (%d Bytes)" % (ziel, os.path.getsize(ziel))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packaging/windows/test_icon.py b/packaging/windows/test_icon.py new file mode 100644 index 0000000..f358ef3 --- /dev/null +++ b/packaging/windows/test_icon.py @@ -0,0 +1,76 @@ +"""Das mitgelieferte Symbol muss alle Groessen haben, die Windows holt. + +## Der Befund (28.08.2026) + +Commander: *„Startmenue, Desktop und Taskleisten-Icon ohne icon file (Weisses +Leeres Blatt)."* + +In `deploy/worker-windows/rippy.ico` steckte GENAU EIN Bild: 256x256 als PNG. +Windows holt sich fuer jede Stelle die passende Groesse: + + Taskleiste, kleine Verknuepfung 16x16 + Desktop, Startmenue 32x32 / 48x48 + Alt-Tab, grosse Kacheln 256x256 + +Fehlt sie, skaliert die Shell nicht zuverlaessig selbst — sie zeigt das leere +Blatt. Bei einer Datei mit nur einem PNG-komprimierten 256er passiert das +regelmaessig. + +Diese Pruefung braucht kein Pillow und kein Windows: Sie liest den +ICO-Verzeichniskopf direkt. Damit laeuft sie auch auf der Ampel. +""" + +import os + +import pytest + +HIER = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.dirname(os.path.dirname(HIER)) +ICON = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico") + + +def _icon_modul(): + import sys + + sys.path.insert(0, HIER) + import icon + + return icon + + +def _groessen(): + return _icon_modul().groessen_in(ICON) + + +# Aus icon.py geholt, nicht abgeschrieben: Sonst koennte der Bau eine Groesse +# fordern, die dieser Test nicht prueft — oder umgekehrt. +PFLICHTGROESSEN = _icon_modul().PFLICHTGROESSEN + + +def test_icon_ist_da(): + assert os.path.isfile(ICON), ICON + + +def test_icon_ist_eine_gueltige_ico(): + assert _groessen(), "Kein lesbarer ICO-Verzeichniskopf" + + +@pytest.mark.parametrize("kante", PFLICHTGROESSEN) +def test_pflichtgroesse_ist_enthalten(kante): + """DER Fehler vom 28.08.2026: nur 256x256 drin, alles andere leer.""" + vorhanden = _groessen() + assert kante in vorhanden, ( + "%dx%d fehlt in rippy.ico (drin: %s). Windows zeigt an dieser Stelle " + "ein leeres Blatt. Abhilfe: python packaging/windows/icon.py" + % (kante, kante, vorhanden) + ) + + +def test_eine_einzige_groesse_reicht_nie(): + """Der Zustand, in dem die Datei war. Als eigener Test, damit die + Fehlermeldung die Ursache nennt und nicht nur eine fehlende Zahl.""" + assert len(_groessen()) > 1, ( + "rippy.ico enthaelt nur EINE Groesse — genau der Zustand, der zu den " + "leeren Symbolen auf Desktop, im Startmenue und in der Taskleiste " + "gefuehrt hat." + ) diff --git a/src/rippy/platform/win_registry.py b/src/rippy/platform/win_registry.py index 4ebb43c..bed74f8 100644 --- a/src/rippy/platform/win_registry.py +++ b/src/rippy/platform/win_registry.py @@ -47,6 +47,19 @@ 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. +# ## Warum der Name ueberschreibbar sein MUSS (28.08.2026) +# +# Er stand als Vorgabewert direkt in jeder Signatur. Vorgabewerte wertet +# Python zur DEFINITIONSZEIT aus — ein Test konnte den Namen also gar nicht +# umbiegen und arbeitete zwangslaeufig am ECHTEN System. +# +# Gemessen, was das anrichtet: Nach `Rippy.exe` (installiert) stand der +# Eintrag da; nach EINEM `pytest -q` war er weg. Der Commander sah Rippy +# danach nicht mehr unter „Installierte Apps" — sein Autostart-Eintrag ging +# denselben Weg. Der Test raeumte auf, was er nie angelegt hatte. +# +# Deshalb: `None` in der Signatur, aufgeloest zur LAUFZEIT. Damit wirkt ein +# `monkeypatch.setattr(reg, "SCHLUESSEL", ...)` ueberall. SCHLUESSEL = "Rippy" UNINSTALL_PFAD = r"Software\Microsoft\Windows\CurrentVersion\Uninstall" @@ -56,8 +69,8 @@ ANZEIGENAME = "Rippy" HERAUSGEBER = "Rippy" -def uninstall_pfad(schluessel: str = SCHLUESSEL) -> str: - return UNINSTALL_PFAD + "\\" + schluessel +def uninstall_pfad(schluessel: str = None) -> str: + return UNINSTALL_PFAD + "\\" + (schluessel or SCHLUESSEL) def _quoten(pfad: str) -> str: @@ -118,7 +131,7 @@ def _winreg(): def eintragen(installationsordner: str, deinstaller: str, version: str, - icon: str = "", schluessel: str = SCHLUESSEL) -> None: + icon: str = "", schluessel: str = None) -> None: """Schreibt den Eintrag für „Programme und Features".""" winreg = _winreg() werte = eintrag_werte( @@ -133,7 +146,7 @@ def eintragen(installationsordner: str, deinstaller: str, version: str, winreg.SetValueEx(k, name, 0, winreg.REG_SZ, str(wert)) -def austragen(schluessel: str = SCHLUESSEL) -> bool: +def austragen(schluessel: str = None) -> bool: """Entfernt den Eintrag. False, wenn es ihn gar nicht gab.""" winreg = _winreg() try: @@ -143,7 +156,7 @@ def austragen(schluessel: str = SCHLUESSEL) -> bool: return False -def ist_eingetragen(schluessel: str = SCHLUESSEL) -> bool: +def ist_eingetragen(schluessel: str = None) -> bool: winreg = _winreg() try: with winreg.OpenKey(winreg.HKEY_CURRENT_USER, uninstall_pfad(schluessel)): @@ -152,7 +165,7 @@ def ist_eingetragen(schluessel: str = SCHLUESSEL) -> bool: return False -def gelesene_werte(schluessel: str = SCHLUESSEL) -> dict: +def gelesene_werte(schluessel: str = None) -> dict: """Was WIRKLICH in der Registry steht — für `rippy doctor` und Tests. Nachsehen statt glauben: Ein Schreibvorgang, der quittiert wurde, ist @@ -176,28 +189,28 @@ def gelesene_werte(schluessel: str = SCHLUESSEL) -> dict: # ── Autostart ─────────────────────────────────────────────────────────── -def autostart_setzen(befehl: str, name: str = SCHLUESSEL) -> None: +def autostart_setzen(befehl: str, name: str = None) -> None: winreg = _winreg() with winreg.CreateKey(winreg.HKEY_CURRENT_USER, RUN_PFAD) as k: - winreg.SetValueEx(k, name, 0, winreg.REG_SZ, befehl) + winreg.SetValueEx(k, name or SCHLUESSEL, 0, winreg.REG_SZ, befehl) -def autostart_entfernen(name: str = SCHLUESSEL) -> bool: +def autostart_entfernen(name: str = None) -> bool: winreg = _winreg() try: with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_PFAD, 0, winreg.KEY_ALL_ACCESS) as k: - winreg.DeleteValue(k, name) + winreg.DeleteValue(k, name or SCHLUESSEL) return True except FileNotFoundError: return False -def autostart_wert(name: str = SCHLUESSEL) -> str: +def autostart_wert(name: str = None) -> str: winreg = _winreg() try: with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_PFAD) as k: - return winreg.QueryValueEx(k, name)[0] + return winreg.QueryValueEx(k, name or SCHLUESSEL)[0] except (FileNotFoundError, OSError): return "" diff --git a/src/rippy/test_windows_app.py b/src/rippy/test_windows_app.py index 186f505..6a97e8c 100644 --- a/src/rippy/test_windows_app.py +++ b/src/rippy/test_windows_app.py @@ -61,8 +61,23 @@ def test_ordner_ohne_abschliessenden_trenner(): # ── 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. + """Der ganze Kreislauf — vollstaendig in einem eigenen Sandkasten. + + ## ⚠️ WARUM HIER EIN EIGENER REGISTRY-SCHLUESSEL STEHT (28.08.2026) + + Weil hier vorher der ECHTE stand. Der Test schrieb hinein und loeschte ihn + im `finally` wieder — auch den des Commanders. Gemessen: + + Rippy.exe --nicht-starten -> Eintrag: DA (Rippy 2.0.0) + pytest -q -> Eintrag: WEG + + Rippy verschwand danach aus „Installierte Apps", und der + Autostart-Eintrag ging denselben Weg. Der Commander hat es gemeldet, + bevor ich es bemerkt habe. + + Das ist derselbe Fehler wie unten bei den Verknuepfungen, nur an einer + anderen Stelle: **Ein Test raeumt nur weg, was er selbst angelegt hat.** + Wenn „aufraeumen" fremden Zustand trifft, ist es kein Aufraeumen. ## ⚠️ WARUM HIER KEINE VERKNUEPFUNGEN ANGELEGT WERDEN (28.08.2026) @@ -90,6 +105,14 @@ def test_installation_legt_die_dateien_an_und_meldet_sich_bei_windows(tmp_path, from rippy.platform import verknuepfungen as vk from rippy.platform import win_registry as reg + # Sicherung 3 (28.08.2026): ein EIGENER Registry-Schluessel. + # Vorher schrieb dieser Test in den ECHTEN Eintrag und loeschte ihn im + # finally wieder -- also auch den des Commanders. Gemessen: nach + # `Rippy.exe` stand der Eintrag da, nach EINEM `pytest -q` war er weg, + # und Rippy verschwand aus "Installierte Apps". Der Autostart-Eintrag + # ging denselben Weg. + monkeypatch.setattr(reg, "SCHLUESSEL", "RippyTestInstallation") + # Sicherung 2: Selbst ein versehentliches `verknuepfen=True` bliebe folgenlos. angelegt = [] monkeypatch.setattr( @@ -137,6 +160,8 @@ def test_installation_ruehrt_die_echten_verknuepfungen_nicht_an(tmp_path, monkey from rippy.platform import verknuepfungen as vk from rippy.platform import win_registry as reg + monkeypatch.setattr(reg, "SCHLUESSEL", "RippyTestWaechter") + aufrufe = [] monkeypatch.setattr(vk, "anlegen", lambda *a, **k: aufrufe.append(a) or True) @@ -156,6 +181,49 @@ def test_installation_ruehrt_die_echten_verknuepfungen_nicht_an(tmp_path, monkey assert aufrufe == [] +@nur_windows +def test_die_testsuite_ruehrt_den_ECHTEN_eintrag_nicht_an(tmp_path, monkeypatch): + """Der Waechter gegen den Vorfall vom 28.08.2026. + + Er sieht VORHER nach, ob Rippy auf dieser Maschine installiert ist, + laesst dann eine vollstaendige Test-Installation samt Autostart laufen — + und prueft danach, dass sich am echten Eintrag NICHTS geaendert hat. + + Ohne diesen Waechter reicht ein vergessenes `monkeypatch` in einem + kuenftigen Test, und Rippy verschwindet beim naechsten `pytest -q` + wieder aus „Installierte Apps". Das ist schon einmal passiert, und der + Commander musste es melden. + """ + from rippy.platform import verknuepfungen as vk + from rippy.platform import win_registry as reg + + echt_vorher = reg.ist_eingetragen("Rippy") + autostart_vorher = reg.autostart_wert("Rippy") + + monkeypatch.setattr(reg, "SCHLUESSEL", "RippyTestUnberuehrt") + monkeypatch.setattr(vk, "alle_anlegen", lambda *a, **k: {}) + + attrappe = tmp_path / "q" / "RippySetup.exe" + attrappe.parent.mkdir() + attrappe.write_bytes(b"x" * 16) + monkeypatch.setattr(windows_app, "_eigene_datei", lambda: str(attrappe)) + + try: + windows_app.installieren(ziel=str(tmp_path / "i"), autostart=True, + starten_danach=False, verknuepfen=False) + finally: + reg.autostart_entfernen() + reg.austragen() + + assert reg.ist_eingetragen("Rippy") == echt_vorher, ( + "Die Testsuite hat den ECHTEN Uninstall-Eintrag veraendert — genau " + "dadurch verschwand Rippy aus 'Installierte Apps'." + ) + assert reg.autostart_wert("Rippy") == autostart_vorher, ( + "Die Testsuite hat den ECHTEN Autostart-Eintrag veraendert." + ) + + # ── Rueckmeldung ohne Konsole ─────────────────────────────────────────── def test_ohne_konsole_kommt_ein_fenster(monkeypatch): """Seit die EXE ohne Konsole gebaut wird (Wunsch des Commanders am