feat(windows): Desktop-Symbol und Startmenue-Eintrag — plus --oeffnen
Ampel / ampel (push) Failing after 47s

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 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-28 11:40:39 +02:00
co-authored by Claude Opus 5
parent f4a8d77598
commit 99c05865af
3 changed files with 365 additions and 1 deletions
+129
View File
@@ -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
+171
View File
@@ -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
+65 -1
View File
@@ -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.")