Eleven resolved decision tickets plus two research notes, produced with /wayfinder before any code was written. They live next to the code they explain rather than in the issue tracker: they are reasoning documents, not open work, and in a tracker they would drift from the code with no diff and no review. The value is in the rejected alternatives. Why a counter under a row lock and not BIGSERIAL, why an event log and not a column on messages, why resync is a 200 with a discriminated body and not a 4xx, why the fan-out trait was deleted again, why voice is peer-to-peer with a hard six-person cap and no speculative SFU seam, and why Linux needs a native media stack while the other three platforms do not. Two entries record where the spec was wrong and got corrected: created_at was never a safe sort key, and an unread count cannot be derived without fetching because seq counts events rather than messages. Open work is tracked as issues, not here.
11 KiB
Wo lebt die WS-Verbindung im Client
Type: grilling Status: resolved Blocked by: — Parent: ../map.md
Question
Läuft die WebSocket-Verbindung im Tauri-Rust-Layer oder im WebView-JavaScript — und wie geht das mit N Verbindungen zu N unabhängigen Servern zusammen?
Ausgangslage
Heute liegt die gesamte Serverkommunikation in Rust: src-tauri/src/state.rs hält den
reqwest::Client, das Token und die User-ID; commands/*.rs exponiert Tauri-Commands
ans Frontend. BASE_URL ist eine Konstante — das trägt das Multi-Server-Modell nicht
und muss ohnehin fallen.
Die Spannung
- Rust (
tokio-tungstenite, Events perapp.emit()ans Frontend): konsistent mit dem bestehenden Muster, Token bleibt im Rust-State und nie im WebView, eine Implementierung für alle vier Plattformen, volle Header-Kontrolle beim Handshake (löst das Token-im-Query-String-Problem aus Recherche: WS-Auth, Heartbeat und Reconnect), robusterer Reconnect ohne Abhängigkeit vom WebView-Lebenszyklus. - JavaScript (
WebSocket-API im WebView): eine Zeile statt einer Abhängigkeit, kein IPC-Hop zwischen Rust und Frontend — und WebRTC lebt später ohnehin im WebView. Ein Signaling-Kanal in Rust und ein Peer-Connection-Objekt in JS bedeutet zwei Lebenszyklen, die konsistent gehalten werden müssen.
Genau diese Aufteilung ist der Kern der Frage: Chat-Push in Rust ist unstrittig angenehm, Voice-Signaling in Rust ist es nicht.
Neues Argument aus der Auth-Recherche (14.08.2026)
Recherche: WS-Auth, Heartbeat und Reconnect hat eine
Asymmetrie gefunden, die schwerer wiegt als erwartet: die Browser-API verschluckt alle
Fehlerdetails. WHATWG schreibt vor, dass DNS-Fehler, Connection-Refused, TLS-Fehler,
Subprotocol-Mismatch und Abbruch nach dem Handshake für das Skript ununterscheidbar sein
müssen — immer CloseEvent.code === 1006, das error-Event ist ein leeres Event.
Im WebView ist damit „Token abgelaufen" nicht von „Server down" unterscheidbar.
tungstenite::Error::Http trägt dagegen die vollständige HTTP-Response inklusive Statuscode
und Body. Bei N Servern gleichzeitig — wo der Nutzer pro Server einen aussagekräftigen
Zustand sehen soll — ist das kein Detail. Dazu kommt: nur im Rust-Layer ist
Authorization: Bearer überhaupt möglich, der laut derselben Recherche einzige Kanal, der in
den Proxy-Defaults nicht mitgeloggt wird.
Beides zeigt in dieselbe Richtung. Wer sich für JS entscheidet, muss diese beiden Kosten ausdrücklich in Kauf nehmen — der WebRTC-Nähe zuliebe kann das trotzdem richtig sein, aber es ist jetzt eine bewusste Abwägung und keine Nebenwirkung mehr.
Zu entscheiden
- Ein Ort für beides, oder eine bewusste Trennung (Dumb-Pipe in Rust, Voice-Signaling in JS)? Falls Trennung: was ist der Preis an doppelter Auth- und Reconnect-Verwaltung, und rechtfertigt ihn die WebRTC-Nähe?
- Multi-Server: N Verbindungen zu N Servern. Wo liegt der Verbindungs-Pool, wie werden Events dem richtigen Server zugeordnet, wie sieht das im Frontend-State aus?
- Wie ersetzt
BASE_URLsich durch eine Server-Liste? Wo wird sie persistiert (heutetauri-plugin-storelaut README, im Code noch nicht vorhanden)? - Wenn Rust: welche Ereignisse gehen per
emitans Frontend, und in welcher Granularität? - Wenn JS: wie kommt das Token dorthin, ohne es dauerhaft im WebView zu halten?
Randbedingung
Die Antwort beeinflusst Verbindungsaufbau & Auth über WS (Header-Verfügbarkeit) und Client-Integration. Sie ist bewusst nicht von der WebRTC-Recherche blockiert — falls diese negativ ausfällt, wird das Ticket neu bewertet.
Answer
Entschieden am 14.08.2026.
1. Die Verbindung lebt im Tauri-Rust-Layer, eigene Implementierung
tokio-tungstenite, Verbindungs-Pool im AppState neben Token und Server-Liste, Zustellung
ans Frontend per app.emit(). Das ist keine neue Architektur, sondern die bestehende —
reqwest, Token und user_id liegen bereits dort.
Vier Gründe, jeder einzeln belegt in Recherche: WS-Auth, Heartbeat und Reconnect:
Authorization: Bearerist möglich. Laut der Log-Matrix der Recherche der einzige Kanal, den die Defaults von nginx, Apache und Caddy nicht mitschreiben — Caddy redigiert ihn sogar aktiv.- Fehler sind diagnostizierbar.
tungstenite::Error::Httpträgt die vollständige Server-Antwort. Im WebView schreibt WHATWG vor, dass alle Fehlerdetails ununterscheidbar sind (immer1006) — bei N Servern, für die je ein aussagekräftiger Zustand angezeigt werden soll, ist das disqualifizierend. - Das Token verlässt den Rust-Prozess nie.
- Eine Implementierung statt vier WebView-Engines (WebView2, WKWebView, Android-Chromium, WebKitGTK), und Reconnect und Pool überleben einen WebView-Reload.
Geprüft und verworfen: tauri-plugin-websocket. Der Mittelweg wäre real — der Socket
läuft dort in Rust, connect() nimmt headers. Dagegen sprechen: die API ist dünn
(connect/send/addListener/disconnect), Reconnect, Backoff und Multi-Server-Pool schreibt man
trotzdem selbst, nur eben in TypeScript; es gibt ein dokumentiertes Rennen mit
Nachrichtenverlust zwischen connect() und addListener()
(plugins-workspace#3152);
WebView-Reloads hinterlassen hängende Verbindungen
(#1507); und die Maintainer
schreiben selbst, sie könnten derzeit keine Zeit in das Plugin stecken
(Discussion #10096). Man bekäme
JS-Lebenszyklus, IPC-Hop und eine ungepflegte Abhängigkeit zugleich.
2. Die WebRTC-Spannung löst sich auf
Voice-Signaling läuft ebenfalls über den Rust-Socket; nur die RTCPeerConnection selbst
lebt im WebView. Eine SDP-Nachricht kostet damit zwei IPC-Hops — bei einer Handvoll
Nachrichten pro Anrufaufbau irrelevant, IPC liegt im Sub-Millisekunden-Bereich.
Die im Ticket formulierte Sorge („zwei Lebenszyklen, die konsistent gehalten werden müssen") war die falsche Abwägung: eine zweite Browser-WebSocket nur für Signaling würde eine zweite Auth ohne Header, einen zweiten Reconnect und ein Token im WebView zurückholen — also genau die drei Nachteile, die gerade verworfen wurden.
3. Rust macht den Backfill und emittiert aufgelöste Entitäten
Rust empfängt den Dumb-Pipe-Event, ruft selbst ?since=<cursor> über das bestehende
reqwest und emittiert die fertigen Entitäten. Damit liegt die Cursor-Buchhaltung pro
Conversation und pro Server dort, wo Verbindung, Token und Server-Liste ohnehin liegen —
an einer Stelle statt an zweien. Das Debouncing (zehn Signale in zwei Sekunden ergeben einen
Fetch) sitzt ebenfalls in Rust.
Das Frontend hat keine Cursor-Logik. Es bekommt „hier sind aktualisierte Entitäten für Conversation X auf Server Y" und ersetzt.
Der resync-Fall aus Monotoner Cursor muss als eigenes
Signal ans Frontend durchgereicht werden, damit es seinen Cache für die betroffene
Conversation verwirft — er ist kein Datenereignis, sondern eine Anweisung.
Bewusst in Kauf genommen: Rust lädt auch für Conversations nach, die gerade nicht sichtbar sind. Ob das gedrosselt werden soll — „nur einen Ungelesen-Punkt setzen statt zu laden" — ist eine Frage des Zustandsmodells und gehört nach Client-Integration.
4. Alle konfigurierten Server sind verbunden, solange die App im Vordergrund ist
Nicht nur der sichtbare. Das ist der Zweck des Multi-Server-Modells: Ungelesen-Anzeigen über Server hinweg funktionieren nur, wenn alle verbunden sind — sonst ist der Realtime-Layer für alles außer dem aktiven Server wertlos. Bei realistischen zwei bis fünf Servern sind N Sockets plus N Heartbeats unkritisch, und Background-Betrieb ist ohnehin out of scope, die Akkufrage stellt sich also nur im Vordergrund.
Keine plattformabhängige Politik: fehlende Ungelesen-Punkte auf dem Handy, die auf dem Desktop da sind, werden als Fehler wahrgenommen und nicht als Sparmaßnahme.
5. BASE_URL fällt; Geheimnisse kommen hinter eine Naht
Die Konstante BASE_URL in src-tauri/src/state.rs wird durch eine Server-Liste
ersetzt (URL, Anzeigename, User-ID), persistiert über tauri-plugin-store — unkritische
Metadaten.
Tokens und Refresh-Tokens laufen über ein schmales SecretStore-Trait mit zwei
Methoden. Dessen Implementierung darf vorerst das Einfachste sein, das funktioniert; die
endgültige Wahl trifft der E2EE-Folge-Effort, weil er exakt dasselbe Problem für
Geräteschlüssel hat — in verschärfter Form, denn ein Geräteschlüssel ist unwiederbringlich,
ein Token läuft ab. Dasselbe Problem zweimal zu lösen wäre Verschwendung; es jetzt falsch zu
lösen teurer als es zu vertagen.
Zur Einordnung der verworfenen Alternative: der OS-Keychain ist inhaltlich das richtige Ziel, hat aber auf Linux — einer erklärten Client-Plattform — die schwächste Stelle, weil Secret Service einen laufenden gnome-keyring oder KWallet voraussetzt.
Begriffe
| Begriff | Bedeutung |
|---|---|
| Server-Liste | Die im Client eingetragenen, voneinander unabhängigen Instanzen. Kein Server-zu-Server-Verkehr; der Client ist der einzige Aggregator. |
| Verbindungs-Pool | Die N offenen Sockets im AppState, einer je eingetragenem Server. |
SecretStore |
Naht für alles, was nicht im Klartext auf die Platte darf. Implementierung offen, wird vom E2EE-Effort gewählt. |
Bestandskorrektur
Der Client pollt heute doch — MessageList.tsx:33 fährt ein setInterval(load, 3000)
zusätzlich zum refreshKey. Die frühere Notiz in der Map („kein Realtime, nur refreshKey")
war falsch; der Client-README lag richtig. Ersetzt werden also beide Mechanismen.
Folgen für andere Tickets
- Verbindungsaufbau & Auth über WS: die
Token-Transportfrage ist damit praktisch entschieden (
Authorization: Bearer). Offen bleiben dort Token-Ablauf, Autorisierung pro Conversation, Heartbeat-Intervall, Verbindungslimits und der selbstgebaute Graceful Shutdown. - Client-Integration: das Zustandsmodell im Frontend wird deutlich kleiner, weil dort keine Cursor-Logik mehr liegt. Neu dazu kommt die Frage, ob Rust für unsichtbare Conversations überhaupt nachladen soll.
- Voice-Signaling-Protokoll: Signaling läuft über den
Rust-Socket, die
RTCPeerConnectionim WebView. Wo der signierte DTLS-Fingerprint entsteht — Rust oder WebCrypto im WebView — bleibt dort zu entscheiden.