# 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 per `app.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*](03-research-ws-auth-reconnect.md) 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_URL` sich durch eine Server-Liste? Wo wird sie persistiert (heute `tauri-plugin-store` laut README, im Code noch nicht vorhanden)? - Wenn Rust: welche Ereignisse gehen per `emit` ans 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*](03-research-ws-auth-reconnect.md): - **`Authorization: Bearer` ist 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::Http` trägt die vollständige Server-Antwort. Im WebView schreibt WHATWG vor, dass alle Fehlerdetails ununterscheidbar sind (immer `1006`) — 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](https://github.com/tauri-apps/plugins-workspace/issues/3152)); WebView-Reloads hinterlassen hängende Verbindungen ([#1507](https://github.com/tauri-apps/plugins-workspace/issues/1507)); und die Maintainer schreiben selbst, sie könnten derzeit keine Zeit in das Plugin stecken ([Discussion #10096](https://github.com/orgs/tauri-apps/discussions/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=` ü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*](02-monotoner-cursor.md) 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*](10-client-integration.md). ### 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*](07-verbindungsaufbau-und-auth.md): 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*](10-client-integration.md): 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*](09-voice-signaling-protokoll.md): Signaling läuft über den Rust-Socket, die `RTCPeerConnection` im WebView. Wo der signierte DTLS-Fingerprint entsteht — Rust oder WebCrypto im WebView — bleibt dort zu entscheiden.