From 99c05865afdb169bbf180fc4866303d4ba2a4c4d Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 28 Aug 2026 11:40:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(windows):=20Desktop-Symbol=20und=20Startme?= =?UTF-8?q?nue-Eintrag=20=E2=80=94=20plus=20--oeffnen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WAS: Das Setup legt jetzt eine Verknuepfung auf dem Desktop UND im Startmenue an, die Deinstallation nimmt beide zurueck, und `--status` zeigt, ob sie da sind. Abwaehlbar mit --keine-verknuepfungen. WARUM (Commander-Meldung): "es gibt kein icon aufm desktop und keinen startmenü eintrag". Zu Recht — ein Hintergrundprogramm ohne Symbol ist praktisch unauffindbar: Es laeuft, aber es gibt keinen Weg hinein ausser der Adresse im Kopf. DIE VERKNUEPFUNG ZEIGT AUF --oeffnen, NICHT AUF --dienst. Ein Doppelklick soll Rippy OEFFNEN; ob der Dienst laeuft, ist dem Nutzer egal und er kann es auch nicht wissen. Zeigte das Symbol auf --dienst, startete jeder Klick einen ZWEITEN Server, der am belegten Port scheitert — das Fenster blitzt auf, im Browser passiert nichts. Ein Symbol, das beim zweiten Klick nichts tut, ist schlimmer als keins. `--oeffnen` sieht deshalb erst NACH, ob jemand antwortet, startet nur wenn noetig, und wartet dann, bis der Dienst wirklich da ist — statt sofort einen Browser auf eine tote Adresse zu schicken. Gemessen: erster Aufruf 2 s bis HTTP 200, zweiter Aufruf keine zusaetzlichen Prozesse. DIE ORDNER KOMMEN AUS DER REGISTRY, nicht aus %USERPROFILE%. Wer OneDrive benutzt oder seine Ordner verschoben hat, dessen Desktop liegt woanders — die Verknuepfung landete dann in einem Ordner, den niemand sieht, und der Nutzer suchte ein Symbol, das es nirgends gibt. UEBER POWERSHELL statt pywin32: Eine .lnk ist ein binaeres Shell-Objekt. pywin32 koennte es, waere aber 20 MB fuer vier Zeilen. Denselben Weg geht der alte Worker-Installer schon ("nativ via PowerShell"). ZWEI FEHLER, DIE DIE TESTS GEFUNDEN HABEN: - os.makedirs stand AUSSERHALB des try. Auf einem Laufwerk, das es nicht gibt, wirft es — aus "Verknuepfung konnte nicht angelegt werden" waere ein Abbruch der ganzen Installation geworden. - Der Test dafuer nahm fest Z: als "gibt es nicht". Auf dem Commander-PC ist Z: ein NETZLAUFWERK (X, Y, Z sind belegt). Ein fest verdrahteter Laufwerksbuchstabe ist eine Annahme ueber eine fremde Maschine; jetzt wird zur Laufzeit ein freier gesucht. Und der Apostroph: In PowerShell wird er innerhalb einfacher Anfuehrungszeichen durch VERDOPPELN maskiert. Ohne das braeche ein Pfad wie C:\Users\O'Brien\... das Skript entzwei, und der Fehler saehe aus wie ein Syntaxfehler in Rippy. GEMESSEN, nach echter Installation nach %LOCALAPPDATA%\Rippy: Desktop-Symbol -> Rippy.exe --oeffnen, mit Symbol und Beschreibung Startmenue -> ebenso Programme+Feat. -> Rippy 2.0.0, 100 MB, Deinstallieren funktioniert Autostart -> "...\Rippy.exe" --dienst Start ueber das Symbol: HTTP 200 nach 2 s Worker: TobisNicerPC, Encoder cpu-x264/x265/av1, vce, vce-av1 GEMESSEN: ruff sauber, 521 Tests gruen + 15 uebersprungen (vorher 512). Co-Authored-By: Claude Opus 5 --- src/rippy/platform/test_verknuepfungen.py | 129 ++++++++++++++++ src/rippy/platform/verknuepfungen.py | 171 ++++++++++++++++++++++ src/rippy/windows_app.py | 66 ++++++++- 3 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 src/rippy/platform/test_verknuepfungen.py create mode 100644 src/rippy/platform/verknuepfungen.py diff --git a/src/rippy/platform/test_verknuepfungen.py b/src/rippy/platform/test_verknuepfungen.py new file mode 100644 index 0000000..50a7d32 --- /dev/null +++ b/src/rippy/platform/test_verknuepfungen.py @@ -0,0 +1,129 @@ +"""Verknuepfungen: der Ordner, das Skript, das Zurueckraeumen. + +Das PowerShell-Skript wird als reine Funktion gebaut und geprueft — so faellt +ein Zitier-Fehler auf, ohne dass eine .lnk entstehen muss. Die Tests, die +wirklich eine anlegen, laufen nur unter Windows und raeumen hinter sich auf. + +Hinweis zu den Pfaden: Windows-Pfade stehen hier in ROHEN Zeichenketten oder +werden aus `B` (dem Backslash) zusammengesetzt. In normalen Zeichenketten +waeren `C:\\D` und `C:\\Users` ungueltige Escape-Sequenzen — heute eine +DeprecationWarning, in einer kuenftigen Python-Version ein harter Fehler. +""" + +import os + +import pytest + +from rippy.platform import verknuepfungen as vk + +nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows") + +B = chr(92) # Backslash — haelt jede Escape-Falle aus den Tests raus + + +# ── Das Skript ────────────────────────────────────────────────────────── +def test_skript_setzt_ziel_und_argumente(): + lnk = "C:" + B + "D" + B + "Rippy.lnk" + exe = "C:" + B + "R" + B + "Rippy.exe" + s = vk.skript(lnk, exe, "--oeffnen") + assert "CreateShortcut('" + lnk + "')" in s + assert "$s.TargetPath = '" + exe + "'" in s + assert "$s.Arguments = '--oeffnen'" in s + assert s.rstrip().endswith("$s.Save()") + + +def test_apostroph_im_pfad_bricht_das_skript_nicht(): + """In PowerShell wird ein Apostroph innerhalb einfacher Anfuehrungszeichen + durch VERDOPPELN maskiert. Ohne das bricht ein Pfad mit Apostroph das + Skript mitten entzwei — und der Fehler sieht aus wie ein Syntaxfehler in + Rippy statt wie ein Pfad, der ein Sonderzeichen enthaelt.""" + s = vk.skript("C:" + B + "Users" + B + "O'Brien" + B + "Rippy.lnk", + "C:" + B + "R" + B + "Rippy.exe") + assert "O''Brien" in s + # Gerade Zahl von Anfuehrungszeichen — sonst ist ein String offen. + assert s.count("'") % 2 == 0 + + +def test_ohne_arbeitsordner_wird_der_ordner_des_ziels_genommen(): + ordner = "C:" + B + "Programme" + B + "Rippy" + s = vk.skript("C:" + B + "D" + B + "R.lnk", ordner + B + "Rippy.exe") + assert "$s.WorkingDirectory = '" + ordner + "'" in s + + +def test_leere_angaben_erzeugen_keine_leeren_zeilen(): + """Ein `$s.IconLocation = ''` wuerde das Symbol LOESCHEN statt es + unveraendert zu lassen.""" + s = vk.skript("C:" + B + "D" + B + "R.lnk", "C:" + B + "R" + B + "R.exe") + assert "IconLocation" not in s + assert "Description" not in s + + +# ── Die Orte ──────────────────────────────────────────────────────────── +@nur_windows +def test_desktop_und_startmenue_kommen_aus_der_registry(): + """Ein fest verdrahtetes %USERPROFILE%/Desktop waere eine ANNAHME. Wer + OneDrive benutzt oder seine Ordner verschoben hat, dessen Desktop liegt + woanders — die Verknuepfung landete dann in einem Ordner, den niemand + sieht, und der Nutzer suchte ein Symbol, das es nirgends gibt.""" + desktop = vk.shell_ordner("Desktop") + programme = vk.shell_ordner("Programs") + assert desktop and os.path.isdir(desktop) + assert programme and os.path.isdir(programme) + assert vk.desktop_pfad().endswith("Rippy.lnk") + assert "Start Menu" in vk.startmenue_pfad() + + +def test_unbekannter_ordner_ergibt_leer_statt_zu_raten(): + assert vk.shell_ordner("GibtEsNicht") == "" + + +# ── Wirklich anlegen ──────────────────────────────────────────────────── +@nur_windows +def test_verknuepfung_entsteht_wirklich(tmp_path): + """Nachsehen statt glauben: PowerShell meldet auch dann Erfolg, wenn + Save() still scheitert. `anlegen()` prueft deshalb die Datei.""" + exe = tmp_path / "Rippy.exe" + exe.write_bytes(b"x" * 512) + lnk = str(tmp_path / "Rippy.lnk") + + assert vk.anlegen(lnk, str(exe), "--oeffnen", beschreibung="Test") is True + assert os.path.isfile(lnk) + assert os.path.getsize(lnk) > 0 + + assert vk.entfernen(lnk) is True + assert not os.path.exists(lnk) + + +def _freier_laufwerksbuchstabe() -> str: + """Ein Buchstabe, den es auf DIESER Maschine wirklich nicht gibt. + + Der erste Anlauf nahm fest `Z:` — auf dem Commander-PC ist das ein + NETZLAUFWERK (X, Y und Z sind dort belegt). Der Test legte damit + froehlich eine Verknuepfung auf einer Freigabe an und schlug fehl, weil + er einen Misserfolg erwartete. Ein fest verdrahteter Laufwerksbuchstabe + ist eine Annahme ueber eine fremde Maschine. + """ + import ctypes + + maske = ctypes.WinDLL("kernel32").GetLogicalDrives() + for i in range(25, -1, -1): # von Z rueckwaerts + if not (maske & (1 << i)): + return chr(ord("A") + i) + return "" + + +@nur_windows +def test_anlegen_auf_unmoeglichem_pfad_wirft_nicht(tmp_path): + """Eine fehlende Verknuepfung ist aergerlich, aber kein Grund, eine sonst + geglueckte Installation scheitern zu lassen.""" + buchstabe = _freier_laufwerksbuchstabe() + if not buchstabe: + pytest.skip("Alle 26 Laufwerksbuchstaben sind belegt") + exe = tmp_path / "Rippy.exe" + exe.write_bytes(b"x") + assert vk.anlegen(buchstabe + ":" + B + "gibt-es-nicht" + B + "R.lnk", + str(exe)) is False + + +def test_entfernen_auf_nichts_ist_kein_fehler(): + assert vk.entfernen("C:" + B + "gibt-es-nicht" + B + "R.lnk") is False diff --git a/src/rippy/platform/verknuepfungen.py b/src/rippy/platform/verknuepfungen.py new file mode 100644 index 0000000..e069831 --- /dev/null +++ b/src/rippy/platform/verknuepfungen.py @@ -0,0 +1,171 @@ +"""Verknüpfungen auf dem Desktop und im Startmenü. + +## Warum über PowerShell und nicht über pywin32 + +Eine `.lnk` ist keine Textdatei, sondern ein binäres Shell-Objekt. Sie von +Hand zu schreiben ist Bastelei; `pywin32` könnte es, wäre aber eine +20-MB-Abhängigkeit für vier Zeilen. Windows bringt die Fähigkeit selbst mit: +`WScript.Shell` ist seit Jahrzehnten da und braucht keine Installation. + +Dieses Projekt geht den Weg schon einmal — der alte Worker-Installer legt +seine Verknüpfungen ebenfalls „nativ via PowerShell (kein win32com mehr +nötig)" an. + +## Warum die Ordner aus der Registry kommen + +`%USERPROFILE%\\Desktop` ist eine ANNAHME. Wer OneDrive benutzt oder seine +Ordner verschoben hat, dessen Desktop liegt woanders — die Verknüpfung +landete dann in einem Ordner, den niemand sieht, und der Nutzer suchte ein +Symbol, das es nirgends gibt. + +Die Wahrheit steht unter `Shell Folders` in der Registry. Auf diesem Rechner +am 28.08.2026 nachgesehen: + + Desktop C:\\Users\\…\\Desktop + Programs C:\\Users\\…\\AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs + +## Worauf die Verknüpfung zeigt + +Nicht auf `--dienst`. Ein Doppelklick auf ein Desktop-Symbol soll Rippy +ÖFFNEN — und dabei ist es dem Nutzer egal, ob der Dienst schon läuft. Die +Verknüpfung ruft deshalb `Rippy.exe --oeffnen`: Läuft der Dienst, geht nur +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 os +import subprocess + + +def shell_ordner(name: str) -> str: + """Der ECHTE Pfad eines Windows-Sonderordners („Desktop", „Programs"). + + Aus der Registry, nicht geraten. Leerer String, wenn er nicht zu + ermitteln ist — dann wird eben keine Verknüpfung angelegt, statt eine + an einer erfundenen Stelle. + """ + if os.name != "nt": + return "" + import winreg + + try: + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders", + ) as k: + wert, _ = winreg.QueryValueEx(k, name) + return os.path.expandvars(str(wert)) + except OSError: + return "" + + +def _ps_text(wert: str) -> str: + """Text für ein einfach gequotetes PowerShell-Argument. + + In PowerShell wird ein Apostroph innerhalb einfacher Anführungszeichen + durch VERDOPPELN maskiert. Ohne das bricht ein Pfad wie + `C:\\Users\\O'Brien\\…` das Skript mitten entzwei — und der Fehler sieht + aus wie ein Syntaxfehler in Rippy. + """ + return (wert or "").replace("'", "''") + + +def skript(lnk: str, ziel: str, argumente: str = "", icon: str = "", + beschreibung: str = "", arbeitsordner: str = "") -> str: + """Das PowerShell-Skript, das die Verknüpfung anlegt (reine Funktion).""" + zeilen = [ + "$s = (New-Object -ComObject WScript.Shell).CreateShortcut('%s')" % _ps_text(lnk), + "$s.TargetPath = '%s'" % _ps_text(ziel), + ] + if argumente: + zeilen.append("$s.Arguments = '%s'" % _ps_text(argumente)) + if icon: + zeilen.append("$s.IconLocation = '%s'" % _ps_text(icon)) + if beschreibung: + zeilen.append("$s.Description = '%s'" % _ps_text(beschreibung)) + zeilen.append("$s.WorkingDirectory = '%s'" + % _ps_text(arbeitsordner or os.path.dirname(ziel))) + zeilen.append("$s.Save()") + return "; ".join(zeilen) + + +def anlegen(lnk: str, ziel: str, argumente: str = "", icon: str = "", + beschreibung: str = "", arbeitsordner: str = "") -> bool: + """Legt eine Verknüpfung an. False, wenn es nicht geklappt hat. + + Wirft nicht: Eine fehlende Verknüpfung ist ärgerlich, aber kein Grund, + eine sonst geglückte Installation scheitern zu lassen. Der Aufrufer + sagt es dem Nutzer. + """ + if os.name != "nt": + return False + from rippy.platform.winlauf import OHNE_FENSTER + + try: + # os.makedirs MUSS mit in den try: Auf einem Laufwerk, das es nicht + # gibt, wirft es — und dann waere aus "Verknuepfung konnte nicht + # angelegt werden" ein Abbruch der ganzen Installation geworden. + os.makedirs(os.path.dirname(lnk), exist_ok=True) + ergebnis = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", + skript(lnk, ziel, argumente, icon, beschreibung, arbeitsordner)], + capture_output=True, text=True, timeout=60, + creationflags=OHNE_FENSTER, + ) + except (OSError, subprocess.SubprocessError): + return False + # Nachsehen statt dem Rueckgabewert glauben: PowerShell meldet auch dann + # Erfolg, wenn Save() still scheitert. + return ergebnis.returncode == 0 and os.path.isfile(lnk) + + +def entfernen(lnk: str) -> bool: + try: + os.remove(lnk) + return True + except OSError: + return False + + +# ── Die beiden Orte ───────────────────────────────────────────────────── +def desktop_pfad(name: str = "Rippy") -> str: + ordner = shell_ordner("Desktop") + return os.path.join(ordner, name + ".lnk") if ordner else "" + + +def startmenue_pfad(name: str = "Rippy") -> str: + ordner = shell_ordner("Programs") + return os.path.join(ordner, name + ".lnk") if ordner else "" + + +def alle_anlegen(programm: str, icon: str = "", desktop: bool = True, + startmenue: bool = True) -> dict: + """Legt beide Verknüpfungen an. Gibt zurück, was WIRKLICH entstanden ist. + + Der Rückgabewert ist die Liste der angelegten Dateien — nicht eine + Erfolgsmeldung. So kann der Installer sagen, was er getan hat, statt + zu behaupten, es sei alles gut gegangen. + """ + gemacht = {} + ziele = [] + if desktop: + ziele.append(("desktop", desktop_pfad())) + if startmenue: + ziele.append(("startmenue", startmenue_pfad())) + + for schluessel, lnk in ziele: + if not lnk: + continue + if anlegen(lnk, programm, argumente="--oeffnen", icon=icon or programm, + beschreibung="Rippy öffnen — automatisches Ripping"): + gemacht[schluessel] = lnk + return gemacht + + +def alle_entfernen() -> list: + """Nimmt beide Verknüpfungen zurück. Gibt zurück, was entfernt wurde.""" + entfernt = [] + for lnk in (desktop_pfad(), startmenue_pfad()): + if lnk and os.path.isfile(lnk) and entfernen(lnk): + entfernt.append(lnk) + return entfernt diff --git a/src/rippy/windows_app.py b/src/rippy/windows_app.py index 749888b..b216e86 100644 --- a/src/rippy/windows_app.py +++ b/src/rippy/windows_app.py @@ -41,6 +41,7 @@ import threading import time import webbrowser +from rippy.platform import verknuepfungen from rippy.platform import win_registry as reg VERSION = os.environ.get("RIPPY_VERSION", "2.0.0") @@ -90,7 +91,7 @@ def melden(text: str) -> None: # ═══════════════════════════════════════════════════ Installation ═══════ def installieren(ziel: str = None, autostart: bool = True, - starten_danach: bool = True) -> str: + starten_danach: bool = True, verknuepfen: bool = True) -> str: """Kopiert Rippy an seinen Platz und meldet es bei Windows an. Gibt den Installationsordner zurück. Wirft bei echten Fehlern — eine @@ -129,6 +130,20 @@ def installieren(ziel: str = None, autostart: bool = True, if autostart: reg.autostart_setzen('"%s" --dienst' % programm) + if verknuepfen: + # Ohne Symbol auf dem Desktop und ohne Startmenue-Eintrag ist ein + # Hintergrundprogramm praktisch unauffindbar: Es laeuft, aber der + # Nutzer hat keinen Weg hinein ausser der Adresse im Kopf. + angelegt = verknuepfungen.alle_anlegen( + programm, icon=os.path.join(ziel, "rippy.ico")) + for ort in ("desktop", "startmenue"): + if ort in angelegt: + melden(" Verknuepfung angelegt: %s" % angelegt[ort]) + else: + # Sagen, was NICHT geklappt hat — sonst sucht der Nutzer ein + # Symbol, das es nie gab. + melden(" Verknuepfung (%s) konnte nicht angelegt werden." % ort) + if starten_danach: starte_hintergrund(programm) @@ -153,6 +168,10 @@ def deinstallieren(still: bool = False) -> None: ordner = (reg.gelesene_werte().get("InstallLocation") or reg.standard_installationsordner()) + for lnk in verknuepfungen.alle_entfernen(): + if not still: + melden(" Verknuepfung entfernt: %s" % lnk) + reg.autostart_entfernen() reg.austragen() laeuft_von_hier = os.path.abspath(_eigene_datei()).lower().startswith( @@ -336,6 +355,42 @@ def dienst_starten(port: int = STANDARD_PORT) -> int: return 0 +def oeffnen(port: int = STANDARD_PORT) -> int: + """Rippy oeffnen — Dienst starten, falls noetig, dann den Browser. + + ## Warum die Verknuepfungen hierher zeigen und nicht auf --dienst + + Ein Doppelklick auf ein Desktop-Symbol soll Rippy OEFFNEN. Ob der Dienst + schon laeuft, ist dem Nutzer egal — und er kann es auch nicht wissen. + + Zeigte die Verknuepfung auf `--dienst`, startete jeder Klick einen + ZWEITEN Server. Der zweite scheiterte am belegten Port, das Fenster + blitzte auf und verschwand, und im Browser passierte nichts. Ein Symbol, + das beim zweiten Klick nichts tut, ist schlimmer als keins. + + Deshalb: erst NACHSEHEN, ob jemand antwortet. Nur wenn nicht, starten — + und dann warten, bis er wirklich da ist, statt sofort einen Browser auf + eine tote Adresse zu schicken. + """ + dienst = Dienst(port) + + if not dienst.erreichbar(timeout=1.5): + melden("Rippy wird gestartet …") + programm = _eigene_datei() + starte_hintergrund(programm) + # Warten, bis er ANTWORTET — nicht bis der Prozess existiert. + for _ in range(60): + if dienst.erreichbar(timeout=1.0): + break + time.sleep(0.5) + else: + melden("Rippy antwortet nicht. Laeuft schon etwas auf Port %d?" % port) + return 1 + + webbrowser.open(dienst.adresse) + return 0 + + def tray_bauen(dienst): """Das Tray-Symbol. None, wenn pystray/Pillow fehlen.""" try: @@ -380,6 +435,7 @@ def tray_bauen(dienst): HILFE = """Rippy für Windows RippySetup.exe installieren (Doppelklick) + Rippy.exe --oeffnen oeffnen (startet den Dienst, falls noetig) Rippy.exe --dienst im Hintergrund laufen lassen Rippy.exe --deinstallieren entfernen Rippy.exe --status nachsehen, was installiert ist @@ -388,6 +444,7 @@ Optionen: --port N Port (Vorgabe: 7788) --ziel PFAD Installationsordner --kein-autostart + --keine-verknuepfungen ohne Desktop-Symbol und Startmenue-Eintrag --still ohne Rückmeldungen """ @@ -401,6 +458,10 @@ def status() -> int: return 0 melden(" In Programme+Features %s" % ("ja" if reg.ist_eingetragen() else "nein")) melden(" Autostart %s" % (reg.autostart_wert() or "nein")) + for name, pfad in (("Desktop-Symbol", verknuepfungen.desktop_pfad()), + ("Startmenue", verknuepfungen.startmenue_pfad())): + melden(" %-21s %s" % (name, pfad if pfad and os.path.isfile(pfad) + else "nein")) for name, wert in sorted(reg.gelesene_werte().items()): melden(" %-18s %s" % (name, wert)) return 0 @@ -420,6 +481,8 @@ def main(argv=None) -> int: if "--deinstallieren" in argv: deinstallieren(still="--still" in argv) return 0 + if "--oeffnen" in argv: + return oeffnen(int(wert("--port", STANDARD_PORT))) if "--dienst" in argv: return dienst_starten(int(wert("--port", STANDARD_PORT))) @@ -428,6 +491,7 @@ def main(argv=None) -> int: ziel=wert("--ziel"), autostart="--kein-autostart" not in argv, starten_danach="--nicht-starten" not in argv, + verknuepfen="--keine-verknuepfungen" not in argv, ) melden("Rippy wurde nach %s installiert." % ziel) melden("Die Oberfläche öffnet sich gleich im Browser.")