diff --git a/AGENTS.md b/AGENTS.md index e8f861b..3628da9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,29 @@ rot heißt: nicht deployen). Nie freihändig per SSH auf der VM bauen. Bibliotheks-APIs: `--help`/Doku prüfen und die Fundstelle im Commit nennen. (Vorgefallen: erfundene Celery-Methode `self.send_task`, erfundene abcde-Flags.) +### Zwei Schreibfallen, die am 28.08.2026 mehrfach gekostet haben + +**1. Deutsche Anführungszeichen in doppelt gequoteten Zeichenketten.** +Das öffnende `„` ist U+201E und harmlos. Das schließende ist das **gerade +ASCII-Zeichen** `"` — es beendet die Python-Zeichenkette. Der Fehler erscheint +dann am Gedankenstrich dahinter, also an einer Stelle, an der nichts falsch +ist. An einem Tag vier Mal passiert. + + falsch: "einen Ordner unter „Benutzer" wählen" + richtig: 'einen Ordner unter „Benutzer" wählen' + +**Regel: Steht ein `„` in der Zeichenkette, gehört sie in EINFACHE +Anführungszeichen.** + +**2. Bash-Heredocs mit Windows-Pfaden.** `<<'EOF'` sollte literal sein, +halbiert in dieser Umgebung aber Backslashes — aus `C:\\Users` wird `C:\Users`, +und das ist eine ungültige Escape-Sequenz. An einem Tag fünf Mal passiert, +zuletzt beim Schreiben genau dieses Absatzes. + +**Regel: Dateien mit Windows-Pfaden oder Umlauten mit dem Write-Werkzeug +schreiben, nicht per Heredoc.** Wo es doch sein muss: Backslashes über +`chr(92)` zusammensetzen. + ## Workflow 1. **Read:** KONZEPT.md + ROADMAP.md lesen. Verstehen, welche Etappe dran ist. diff --git a/docker/ui/src/App.tsx b/docker/ui/src/App.tsx index 3943d69..7d6be1f 100644 --- a/docker/ui/src/App.tsx +++ b/docker/ui/src/App.tsx @@ -51,10 +51,45 @@ function AppInhalt() { // ihn statt des Dashboards. null = /setup noch nicht geprüft (kein Aufblitzen). const [setupDone, setSetupDone] = useState(null) + /* + * ⚠️ Ein fehlgeschlagener Abruf ist KEINE Aussage über die Einrichtung. + * + * Hier stand `.catch(() => setSetupDone(true))` mit der Begründung + * „API/Setup nicht erreichbar → nicht blockieren". Die Folge hat der + * Commander am 28.08.2026 gemeldet: **Nach einer frischen Installation + * fehlte der Ersteinrichtungs-Assistent.** + * + * Und zwar zwangsläufig: Das Fenster geht unmittelbar nach dem Setup auf, + * der Dienst braucht ein bis zwei Sekunden bis zur ersten Antwort — der + * erste Abruf scheitert also fast immer. Aus „ich konnte nicht nachsehen" + * wurde „ist schon erledigt", und der Assistent war für immer weg. + * + * Dasselbe Prinzip steht nebenan in `useEventStream.tsx`: Wer nichts Neues + * weiß, behält, was er wusste — und behauptet nichts. + * + * Deshalb: nachfragen, bis der Server ANTWORTET. Erst eine Antwort + * entscheidet. + */ useEffect(() => { - api.get('/setup') - .then((r) => setSetupDone(Boolean(r.data?.done))) - .catch(() => setSetupDone(true)) // API/Setup nicht erreichbar → nicht blockieren + let abgemeldet = false + let versuche = 0 + + const fragen = () => { + api.get('/setup') + .then((r) => { if (!abgemeldet) setSetupDone(Boolean(r.data?.done)) }) + .catch(() => { + if (abgemeldet) return + versuche += 1 + // Zwei Minuten lang alle zwei Sekunden. Antwortet der Server bis + // dahin nicht, hat der Nutzer ein anderes Problem als den + // Assistenten — dann zeigen wir die Oberfläche, damit er die Logs + // sehen kann. + if (versuche < 60) setTimeout(fragen, 2000) + else setSetupDone(true) + }) + } + fragen() + return () => { abgemeldet = true } }, []) const navItems: { id: Page; label: string; icon: any }[] = [ @@ -64,7 +99,22 @@ function AppInhalt() { { id: 'settings', label: 'Einstellungen', icon: SettingsIcon }, ] - if (setupDone === null) return null // kurzer Moment, bis /setup geantwortet hat + // Solange /setup noch nicht geantwortet hat: sagen, dass gewartet wird. + // Ein leeres Fenster sieht aus wie ein Absturz — und nach einer frischen + // Installation ist genau das der erste Eindruck. + if (setupDone === null) { + return ( +
+
+
+

Rippy startet …

+

+ warte auf den Dienst +

+
+
+ ) + } if (!setupDone) return setSetupDone(true)} /> return ( diff --git a/src/rippy/einrichtung.py b/src/rippy/einrichtung.py index e2a4431..ff91408 100644 --- a/src/rippy/einrichtung.py +++ b/src/rippy/einrichtung.py @@ -124,12 +124,93 @@ def pruefe_platz(ziel: str, frei_mb=None) -> dict: "Platz schaffen oder einen anderen Zielordner wählen.") -def pruefe_schreibrecht(ziel: str, probe=None) -> dict: +def laeuft_erhoeht(erhoeht=None) -> bool: + """Läuft dieser Prozess mit Administratorrechten? + + Wichtig für die Diagnose, nicht als Anforderung: **Rippy braucht keine.** + Erhöht gestartet handelt es sich sogar Nachteile ein — siehe unten. + """ + if erhoeht is not None: + return bool(erhoeht) + if os.name != "nt": + return False + import ctypes + + try: + return bool(ctypes.WinDLL("shell32").IsUserAnAdmin()) + except (AttributeError, OSError): + return False + + +def netzlaufwerke(pruefer=None) -> list: + """Die eingebundenen Netzlaufwerke, wie DIESER Prozess sie sieht.""" + if pruefer is not None: + return list(pruefer) + if os.name != "nt": + return [] + import ctypes + import string + + DRIVE_REMOTE = 4 + try: + k = ctypes.WinDLL("kernel32") + maske = k.GetLogicalDrives() + return [b + ":" for i, b in enumerate(string.ascii_uppercase) + if (maske & (1 << i)) + and k.GetDriveTypeW(b + ":\\") == DRIVE_REMOTE] + except (AttributeError, OSError): + return [] + + +def pruefe_administrator(erhoeht=None, laufwerke=None) -> dict: + r"""Als Administrator gestartet? Dann fehlen die Netzlaufwerke. + + ## Der Befund des Commanders (28.08.2026) + + > „Wenn man das Tool als Admin startet, kann man im explorer kein + > netzlaufwerk wählen. Wenn man im explorer ein via windows eingebundenes + > netzlaufwerk einbindet dann kommt der fehler das man nicht installieren + > kann, weil die schreibrechte fehlen." + + Beides ist Windows-Verhalten, kein Rippy-Fehler — aber Rippy hat ihn + hineinlaufen lassen. Ein erhöhter Prozess bekommt ein ANDERES + Zugriffstoken, und eingebundene Netzlaufwerke hängen am Token der + Sitzung. `X:` existiert dort schlicht nicht. (Auf diesem Rechner + nachgesehen: `EnableLinkedConnections` ist nicht gesetzt — der Standard.) + + Der Teufelskreis, in den das führt: + + C:\Program Files\… verlangt Administrator + Administrator versteckt die Netzlaufwerke + Netzlaufwerk wirkt dann wie „keine Schreibrechte" + + **Rippy braucht überhaupt keine Administratorrechte.** Der Vorgabeordner + liegt unter `%LOCALAPPDATA%`, der Autostart unter `HKCU`. + """ + if not laeuft_erhoeht(erhoeht): + return _eintrag("Rechte", OK, "normal gestartet (so ist es richtig)") + gefunden = netzlaufwerke(laufwerke) + text = "als Administrator gestartet" + return _eintrag( + "Rechte", WARNUNG, text, + "Rippy braucht keine Administratorrechte — und erhöht sieht es die " + "eingebundenen Netzlaufwerke NICHT" + + (" (%s fehlen hier)" % ", ".join(gefunden) if gefunden else "") + + ". Für ein Netzlaufwerk den UNC-Pfad eintippen " + "(\\\\Server\\Freigabe) oder Rippy ohne Administrator starten.") + + +def pruefe_schreibrecht(ziel: str, probe=None, erhoeht=None) -> dict: """Darf Rippy dorthin schreiben? Wirklich probiert, nicht geraten. Ein `os.access(..., W_OK)` lügt unter Windows: Es beantwortet die Frage - nach dem Schreibrecht anhand des Nur-Lesen-Flags, nicht anhand der - Zugriffsrechte. Also wird eine Datei angelegt und wieder gelöscht. + nach dem Nur-Lesen-Flag, nicht nach den Zugriffsrechten. Also wird eine + Datei angelegt und wieder gelöscht. + + Und der Grund wird BENANNT. „Schreibrechte fehlen" war die Meldung, die + der Commander bei einem Netzlaufwerk bekam — richtig im Wortlaut, + unbrauchbar als Auskunft: Das Laufwerk war für den erhöhten Prozess gar + nicht da. """ if probe is not None: geklappt, grund = probe @@ -137,11 +218,38 @@ def pruefe_schreibrecht(ziel: str, probe=None) -> dict: geklappt, grund = _schreibprobe(ziel) if geklappt: return _eintrag("Schreibrecht", OK, ziel) + + laufwerk = os.path.splitdrive(ziel or "")[0] + if laufwerk and len(laufwerk) == 2 and not os.path.isdir(laufwerk + "\\"): + # Ein Laufwerksbuchstabe, den es in DIESEM Prozess nicht gibt. + zusatz = (" Als Administrator gestartete Programme sehen die " + "eingebundenen Netzlaufwerke der Sitzung nicht." + if laeuft_erhoeht(erhoeht) else "") + return _eintrag( + "Schreibrecht", FEHLER, + "%s — Laufwerk %s ist hier nicht vorhanden" % (ziel, laufwerk), + ("Statt des Laufwerksbuchstabens den UNC-Pfad eintragen " + "(\\\\Server\\Freigabe)." + zusatz)) + + if _unter_programme(ziel): + return _eintrag( + "Schreibrecht", FEHLER, "%s (%s)" % (ziel, grund), + 'Der Ordner „Programme" verlangt Administratorrechte — und die ' + 'verstecken wiederum die Netzlaufwerke. Rippy braucht sie nicht: ' + 'Der Vorgabeordner unter „Benutzer" funktioniert ohne.') + return _eintrag("Schreibrecht", FEHLER, "%s (%s)" % (ziel, grund), 'Einen Ordner unterhalb von „Benutzer" wählen — dort ' 'braucht Rippy keine Administratorrechte.') +def _unter_programme(ziel: str) -> bool: + """Liegt das Ziel unter „Programme"? (reine Funktion)""" + unten = (ziel or "").lower().replace("/", "\\") + return ("\\program files" in unten or unten.startswith("program files") + or "\\programme\\" in unten) + + def _schreibprobe(ziel: str): import tempfile @@ -227,7 +335,8 @@ def pruefe_werkzeuge(bestand=None) -> list: # ── Alles zusammen ────────────────────────────────────────────────────── def alle_pruefungen(ziel: str, port: int = STANDARD_PORT) -> list: """Der vollständige Voraussetzungs-Bericht, in Anzeigereihenfolge.""" - return ([pruefe_windows(), pruefe_webview(), pruefe_laufwerk()] + return ([pruefe_windows(), pruefe_webview(), pruefe_administrator(), + pruefe_laufwerk()] + pruefe_werkzeuge() + [pruefe_platz(ziel), pruefe_schreibrecht(ziel), pruefe_port(port)]) diff --git a/src/rippy/setup_fenster.py b/src/rippy/setup_fenster.py index 0e55b87..d0ac816 100644 --- a/src/rippy/setup_fenster.py +++ b/src/rippy/setup_fenster.py @@ -145,23 +145,31 @@ class Bruecke: def _ablage_speichern(pfad: str) -> None: """Die gewählte Ablage in die Einstellungen schreiben. - Über die laufende Datenbank, nicht über eine Konfigurationsdatei: Das UI - liest `outputDir` aus den Einstellungen, und genau dort muss der Wert - landen, damit die Wahl im Assistenten überhaupt etwas bewirkt. + ## Der Fehler, der das Setup abgebrochen hat (28.08.2026) + + Hier stand `import db as api_db`, nachdem `daemon._api_pfad()` in den + Suchpfad gehängt worden war. Beim Commander brach die Installation ab mit: + + Fehlgeschlagen: ModuleNotFoundError: No module named 'db' + + Und zwar zu Recht: **`docker/api/db.py` gibt es nicht.** Seit der + Zusammenlegung in V2-1 ist der Store `rippy.store`; `main.py` schreibt + seitdem `from rippy import store as db`. Ich habe ein Modul importiert, + das ich mir aus einem Alias zusammengereimt habe — genau die Sorte + erfundener Schnittstelle, vor der AGENTS.md Regel D warnt, nur diesmal + im eigenen Code. + + Der Store wird direkt benutzt. Ein Umweg über den Suchpfad der API war + ohnehin überflüssig. """ - from rippy import config, daemon, store + from rippy import config, store werte = config.laden() store.verbinden(config.datenbank_url(werte)) - import sys - - sys.path.insert(0, daemon._api_pfad()) - import db as api_db # noqa: E402 - - api_db.init_db() - aktuell = api_db.get_settings() or {} + store.init_db() + aktuell = store.get_settings() or {} aktuell["outputDir"] = pfad - api_db.save_settings(aktuell) + store.save_settings(aktuell) def starten() -> dict: diff --git a/src/rippy/test_einrichtung.py b/src/rippy/test_einrichtung.py index 8b5eebd..0ddc91d 100644 --- a/src/rippy/test_einrichtung.py +++ b/src/rippy/test_einrichtung.py @@ -90,6 +90,57 @@ def test_schreibprobe_am_echten_ordner(tmp_path): assert list(tmp_path.iterdir()) == [] +# ── Administrator und Netzlaufwerke ───────────────────────────────────── +def test_normal_gestartet_ist_der_richtige_zustand(): + """Rippy braucht KEINE Administratorrechte — Vorgabeordner unter + %LOCALAPPDATA%, Autostart unter HKCU.""" + befund = e.pruefe_administrator(erhoeht=False) + assert befund["zustand"] == e.OK + + +def test_als_administrator_wird_vor_den_fehlenden_netzlaufwerken_gewarnt(): + """DER Befund des Commanders (28.08.2026): „Wenn man das Tool als Admin + startet, kann man im explorer kein netzlaufwerk waehlen." + + Ein erhoehter Prozess bekommt ein anderes Zugriffstoken; eingebundene + Netzlaufwerke haengen am Token der Sitzung. Windows-Verhalten, kein + Rippy-Fehler — aber Rippy hat ihn hineinlaufen lassen.""" + befund = e.pruefe_administrator(erhoeht=True, laufwerke=["X:", "Y:", "Z:"]) + assert befund["zustand"] == e.WARNUNG + assert "X:" in befund["abhilfe"] + assert "UNC" in befund["abhilfe"] or "Server" in befund["abhilfe"] + + +def test_fehlendes_laufwerk_wird_beim_namen_genannt(): + """„Schreibrechte fehlen" war richtig im Wortlaut und unbrauchbar als + Auskunft: Das Laufwerk war fuer den erhoehten Prozess gar nicht da.""" + befund = e.pruefe_schreibrecht( + "Q:" + chr(92) + "Rippy", probe=(False, "Pfad nicht gefunden"), + erhoeht=True) + assert befund["zustand"] == e.FEHLER + assert "Q:" in befund["text"] + assert "nicht vorhanden" in befund["text"] + assert "Administrator" in befund["abhilfe"] + + +def test_programme_ordner_erklaert_den_teufelskreis(): + """Program Files verlangt Administrator, Administrator versteckt die + Netzlaufwerke — und Rippy braucht beides nicht.""" + befund = e.pruefe_schreibrecht( + "C:" + chr(92) + "Program Files (x86)" + chr(92) + "Rippy", + probe=(False, "Zugriff verweigert")) + assert befund["zustand"] == e.FEHLER + assert "Administratorrechte" in befund["abhilfe"] + assert "Vorgabeordner" in befund["abhilfe"] + + +def test_programme_wird_erkannt(): + B = chr(92) + assert e._unter_programme("C:" + B + "Program Files" + B + "Rippy") is True + assert e._unter_programme("C:" + B + "Program Files (x86)" + B + "R") is True + assert e._unter_programme("C:" + B + "Users" + B + "X" + B + "Rippy") is False + + def test_belegter_port_ist_nur_eine_warnung(): """Sehr oft ist es ein bereits laufendes Rippy — und dann ist eine Neuinstallation genau richtig.""" @@ -164,6 +215,8 @@ def test_alles_gruen_sagt_es_auch_so(): def test_jede_pruefung_hat_dieselbe_form(): """Ein fehlendes Feld waere in der Oberflaeche ein `undefined`.""" for befund in ([e.pruefe_windows(22631), e.pruefe_webview(version=""), + e.pruefe_administrator(erhoeht=True, laufwerke=["X:"]), + e.pruefe_administrator(erhoeht=False), e.pruefe_laufwerk(laufwerke=[]), e.pruefe_platz("C:\\x", frei_mb=9999), e.pruefe_port(7788, belegt=False)]