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.
195 lines
11 KiB
Markdown
195 lines
11 KiB
Markdown
# 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=<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*](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.
|