docs(realtime): record the design decisions behind the realtime layer
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.
This commit is contained in:
87
docs/realtime/issues/01-research-webrtc-tauri-mobile.md
Normal file
87
docs/realtime/issues/01-research-webrtc-tauri-mobile.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Recherche: WebRTC im Tauri-WebView (Mobile)
|
||||||
|
|
||||||
|
Type: research
|
||||||
|
Status: resolved
|
||||||
|
Blocked by: —
|
||||||
|
Parent: ../map.md
|
||||||
|
|
||||||
|
## Question
|
||||||
|
|
||||||
|
Funktioniert WebRTC — insbesondere `getUserMedia` für Mikrofonzugriff und `RTCPeerConnection` —
|
||||||
|
im WebView einer Tauri-v2-App auf **Android** und **iOS**? Und wenn ja, unter welchen
|
||||||
|
Bedingungen?
|
||||||
|
|
||||||
|
Das ist die riskanteste Unbekannte des gesamten Efforts. Fällt die Antwort negativ aus,
|
||||||
|
ist das Voice-Signaling-Design für die Hälfte der Zielplattformen wertlos, und die
|
||||||
|
Voice-Topologie-Frage sieht völlig anders aus.
|
||||||
|
|
||||||
|
### Zu klären
|
||||||
|
|
||||||
|
- **iOS**: Tauri v2 rendert in `WKWebView`, nicht in Safari. `getUserMedia` ist dort
|
||||||
|
historisch problematisch gewesen. Gilt das noch? Ab welcher iOS-Version? Braucht es
|
||||||
|
Entitlements, `Info.plist`-Einträge (`NSMicrophoneUsageDescription`), oder
|
||||||
|
WebView-Konfiguration, die Tauri nicht standardmäßig setzt?
|
||||||
|
- **Android**: Tauri v2 nutzt Android System WebView. Wie werden
|
||||||
|
`PermissionRequest`-Callbacks (`RESOURCE_AUDIO_CAPTURE`) durchgereicht? Muss die
|
||||||
|
Tauri-App das abfangen, oder passiert es automatisch?
|
||||||
|
- **Berechtigungen**: Wie beantragt eine Tauri-v2-App Mikrofonrechte auf beiden
|
||||||
|
Plattformen? Gibt es dafür ein offizielles Plugin oder Muster?
|
||||||
|
- **Alternative**: Falls WebView-WebRTC ausfällt — gibt es einen brauchbaren nativen
|
||||||
|
Pfad? (`webrtc-rs` im Tauri-Rust-Layer, Audio-Streams zwischen Rust und WebView).
|
||||||
|
Reifegrad, Mobile-Tauglichkeit, Aufwand.
|
||||||
|
- **Desktop-Gegenprobe**: Läuft es auf Windows (WebView2) und Linux (WebKitGTK)?
|
||||||
|
Auf Linux ist WebRTC in WebKitGTK-Builds nicht selbstverständlich einkompiliert.
|
||||||
|
|
||||||
|
### Quellenqualität
|
||||||
|
|
||||||
|
Primärquellen bevorzugen: Tauri-v2-Dokumentation und Issue-Tracker, WebKit-/Chromium-
|
||||||
|
Release-Notes, Apple-Entwicklerdoku. Blogposts nur als Hinweisgeber, nicht als Beleg —
|
||||||
|
und immer mit Datum, dieses Feld bewegt sich.
|
||||||
|
|
||||||
|
### Erwartetes Ergebnis
|
||||||
|
|
||||||
|
Eine Markdown-Datei im Repo mit: klarer Ja/Nein/Teilweise-Antwort pro Plattform,
|
||||||
|
den nötigen Konfigurationsschritten, den Versionsuntergrenzen, und den Quellen. Wenn
|
||||||
|
irgendwo „unklar" steht, muss dabeistehen, welches Experiment die Frage entscheiden würde.
|
||||||
|
|
||||||
|
## Answer
|
||||||
|
|
||||||
|
Vollständiges Ergebnis: [`../research/01-webrtc-tauri-mobile.md`](../research/01-webrtc-tauri-mobile.md)
|
||||||
|
|
||||||
|
| Plattform | Antwort | Aufwand |
|
||||||
|
|---|---|---|
|
||||||
|
| **iOS** (WKWebView) | **Ja**, ab 14.3 (praktisch 15.0) | eine Zeile: `NSMicrophoneUsageDescription` in `Info.ios.plist` |
|
||||||
|
| **Android** (System WebView) | **Ja**, ab API 21 | zwei Manifest-Zeilen: `RECORD_AUDIO` **und** `MODIFY_AUDIO_SETTINGS` |
|
||||||
|
| **Windows** (WebView2) | **Ja**, ohne Konfiguration | — |
|
||||||
|
| **Linux** (WebKitGTK 4.1) | **Nein** | drei unabhängige Gates, eines davon distro-seitig unlösbar |
|
||||||
|
|
||||||
|
**Council-Risiko 2 ist positiv aufgelöst.** Mobile trägt WebRTC im WebView; das
|
||||||
|
Voice-Signaling-Design ist für Android und iOS nicht wertlos. *Voice-Topologie* ist entblockt.
|
||||||
|
|
||||||
|
Belegt gegen die tatsächlich aufgelösten Versionen (`wry 0.55.1` über
|
||||||
|
`tauri-runtime-wry 2.11.4` mit `^0.55.0`), nicht gegen `dev` — wichtig, weil wry 0.56
|
||||||
|
die Permission-Lage geändert hat, aber von Tauri 2.11.x nicht gezogen werden kann.
|
||||||
|
|
||||||
|
### Drei Befunde, die andere Tickets verändern
|
||||||
|
|
||||||
|
1. **Der Problemfall ist Linux, nicht Mobile.** `ENABLE_WEB_RTC` folgt
|
||||||
|
`ENABLE_EXPERIMENTAL_FEATURES` (= OFF); Debian/Ubuntu und Alpine setzen es belegbar
|
||||||
|
nicht. Dazu `WebKitSettings:enable-webrtc` Default FALSE (von wry nie gesetzt) und
|
||||||
|
`permission-request` Default Deny (in 0.55.1 nicht verbunden). Gate 2 und 3 wären per
|
||||||
|
`with_webview` lösbar — Gate 1 hängt am Distro-Paket des Nutzers.
|
||||||
|
→ eigenes Ticket *Voice auf Linux*.
|
||||||
|
2. **Der Signaling-Server kann DTLS-Fingerprints tauschen** (RFC 8827) und damit den
|
||||||
|
Audio-Stream vollständig MITMen. Der spezifizierte Gegenmechanismus (W3C Identity for
|
||||||
|
WebRTC, IdP) ist in **keiner** der vier Ziel-Engines implementiert. Fingerprints müssen
|
||||||
|
durch einen E2EE-authentifizierten Kanal übertragen und gepinnt werden.
|
||||||
|
→ harte Randbedingung für *Voice-Signaling-Protokoll*.
|
||||||
|
3. **wry vergibt Mikrofonrechte ohne App-Beteiligung** — auf iOS in 0.55.1 bedingungslos
|
||||||
|
`Grant` für jeden Origin. Unkritisch, solange nur eigener gebündelter Code im WebView
|
||||||
|
läuft; sofort kritisch, sobald fremde Origins (iframe, Preview-Einbettung) dazukommen.
|
||||||
|
→ Architekturregel in den Notes der Map.
|
||||||
|
|
||||||
|
### Offen (blockiert die Spec nicht, gehört vor die Implementierung)
|
||||||
|
|
||||||
|
Sieben benannte Experimente in §8 der Ergebnisdatei. Höchste Priorität E1: `getUserMedia`
|
||||||
|
auf einem physischen iPhone, je einmal mit und ohne Info.plist-Key — entscheidet zugleich
|
||||||
|
das offene Upstream-Issue tauri#12068.
|
||||||
229
docs/realtime/issues/02-monotoner-cursor.md
Normal file
229
docs/realtime/issues/02-monotoner-cursor.md
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
# Monotoner Cursor
|
||||||
|
|
||||||
|
Type: grilling
|
||||||
|
Status: resolved
|
||||||
|
Blocked by: —
|
||||||
|
Parent: ../map.md
|
||||||
|
|
||||||
|
## Question
|
||||||
|
|
||||||
|
Worauf zeigt der `?since=<cursor>`-Backfill, sodass ein Client garantiert **keine**
|
||||||
|
Nachricht überspringt?
|
||||||
|
|
||||||
|
Das ist Risiko 1 aus dem Council-Verdikt und die Voraussetzung dafür, dass die
|
||||||
|
Best-effort-Zustellung überhaupt tragfähig ist. Ohne monotonen Cursor ist „WS-Event
|
||||||
|
verpasst, hol es per REST nach" ein Versprechen, das der Server nicht halten kann.
|
||||||
|
|
||||||
|
### Warum die naheliegenden Antworten falsch sind
|
||||||
|
|
||||||
|
- **`created_at`** — Zeitstempel kollidieren bei gleichzeitigen Sendern, und ein
|
||||||
|
Cursor auf einem nicht-eindeutigen Wert verliert oder dupliziert an der Grenze.
|
||||||
|
- **`BIGSERIAL` / `id`** — Sequenzen vergeben IDs bei `INSERT`, aber Transaktionen
|
||||||
|
werden in **Commit-Reihenfolge** sichtbar. Transaktion A holt ID 100, Transaktion B
|
||||||
|
holt ID 101 und committet zuerst. Ein Client liest bis `since=101`, dann committet A.
|
||||||
|
Nachricht 100 wird **nie** ausgeliefert. Bei zwei gleichzeitigen Sendern ist das kein
|
||||||
|
Randfall, sondern der Normalfall.
|
||||||
|
|
||||||
|
Unter E2EE kann der Server den Verlust weder erkennen noch reparieren — er sieht den
|
||||||
|
Inhalt nicht und kann keine Lückenprüfung über die Nachrichtenfolge machen.
|
||||||
|
|
||||||
|
### Optionen, die zu bewerten sind
|
||||||
|
|
||||||
|
1. **Sequenz pro Conversation unter Lock** — beim Insert `SELECT ... FOR UPDATE` auf
|
||||||
|
eine Zählerzeile der Conversation, dann `seq = counter + 1`. Lückenlos und monoton
|
||||||
|
*pro Conversation*, was genau die Granularität des Dumb-Pipe-Events ist. Kosten:
|
||||||
|
Serialisierung der Schreiber pro Conversation. Ist das bei den Zielgrößen ein Problem?
|
||||||
|
2. **Snowflake-/ULID-artige ID** — zeitbasiert + Zufall, monoton generierbar ohne
|
||||||
|
DB-Rundtrip. Löst die Eindeutigkeit, aber **nicht** das Commit-Order-Problem.
|
||||||
|
3. **Sichtbarkeitsbasierter Cursor** — `pg_current_snapshot()` / `xmin`-basiertes
|
||||||
|
Nachlesen, sodass der Cursor die Transaktionssichtbarkeit abbildet statt der
|
||||||
|
Insert-Reihenfolge. Korrekt, aber deutlich exotischer.
|
||||||
|
4. **Outbox-Tabelle mit eigener Sequenz**, geschrieben in derselben Transaktion,
|
||||||
|
gelesen in Commit-Reihenfolge.
|
||||||
|
|
||||||
|
### Zu entscheiden
|
||||||
|
|
||||||
|
- Welche Option — und warum überwiegt sie die anderen bei den Faktoren Simplizität
|
||||||
|
(harte Regel: eine Binary + Postgres) und Korrektheit?
|
||||||
|
- Ist der Cursor **pro Conversation** oder **pro Server/Nutzer**? Das Dumb-Pipe-Event
|
||||||
|
trägt `{conversation, cursor}`, also spricht viel für pro Conversation — aber ein
|
||||||
|
Client, der 40 Conversations offen hat, muss dann 40 Cursor verwalten.
|
||||||
|
- Wie verhalten sich **Edits** und **Soft-Deletes**? Beide ändern eine bestehende Zeile,
|
||||||
|
ohne einen neuen Cursor-Wert zu erzeugen — ein Client, der nur ab `since` nachlädt,
|
||||||
|
sieht sie nie. Braucht es einen zweiten Kanal, eine `updated_at`-Achse, oder wird jede
|
||||||
|
Änderung als neuer Cursor-Eintrag geführt?
|
||||||
|
- Wie sieht die **Migration** aus? `messages` hat heute keine solche Spalte.
|
||||||
|
|
||||||
|
### Abnahmekriterium
|
||||||
|
|
||||||
|
Das Ticket ist gelöst, wenn es einen kleinen, lauffähigen Test gibt, der unter zwei
|
||||||
|
gleichzeitigen Sendern die Lücke **im gewählten Ansatz nicht mehr** reproduziert — und
|
||||||
|
sie im naiven `BIGSERIAL`-Ansatz reproduziert, damit der Test beweist, dass er misst,
|
||||||
|
was er messen soll.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Answer
|
||||||
|
|
||||||
|
Entschieden am 14.08.2026. Vier Entscheidungen, die zusammen einen Mechanismus ergeben.
|
||||||
|
|
||||||
|
### 1. Ein Zähler pro Conversation, vergeben unter Zeilen-Lock
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE conversations ADD COLUMN seq BIGINT NOT NULL DEFAULT 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
Jede Mutation zieht die nächste Nummer **in derselben Transaktion** wie die Änderung:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE conversations SET seq = seq + 1 WHERE id = $1 RETURNING seq;
|
||||||
|
```
|
||||||
|
|
||||||
|
Der `UPDATE` nimmt einen Zeilen-Lock auf die Conversation. Ein zweiter Schreiber in
|
||||||
|
dieselbe Conversation blockiert bis zum Commit des ersten — **damit ist Commit-Reihenfolge
|
||||||
|
gleich `seq`-Reihenfolge**, lückenlos und monoton. Genau das ist die Eigenschaft, die
|
||||||
|
`BIGSERIAL` nicht hat: dort wird die Nummer beim `INSERT` vergeben, sichtbar wird die Zeile
|
||||||
|
aber erst beim Commit, und diese beiden Reihenfolgen können auseinanderlaufen.
|
||||||
|
|
||||||
|
Die Korrektheit folgt hier aus der Konstruktion, nicht aus einer Heuristik: solange die
|
||||||
|
Nummer unter demselben Lock vergeben wird, unter dem die Zeile entsteht, kann ein Leser
|
||||||
|
mit `seq > N` nichts überspringen.
|
||||||
|
|
||||||
|
**Bonus, der die Wahl mitentschieden hat:** der Zähler *ist* der aktuelle Maximal-Cursor.
|
||||||
|
Erstabruf, Ungelesen-Anzeige und der Dumb-Pipe-Event lesen ein Feld statt zu scannen.
|
||||||
|
|
||||||
|
**Bewusst in Kauf genommen:** Schreiber derselben Conversation serialisieren (bei Chat
|
||||||
|
irrelevant — Menschen tippen), und pro Mutation entsteht eine neue Zeilenversion auf
|
||||||
|
`conversations`, die Autovacuum abräumt.
|
||||||
|
|
||||||
|
**Verworfen:** Snowflake/ULID lösen nur Eindeutigkeit, nicht die Commit-Reihenfolge.
|
||||||
|
Advisory-Lock + `MAX(seq)+1` ist gleich korrekt und vermeidet den Schreib-Churn, braucht
|
||||||
|
aber einen Hash der UUID auf int64 (Kollisionen kosten Durchsatz) und einen Scan für den
|
||||||
|
Maximal-Cursor. Sichtbarkeitsbasierte Cursor (`pg_current_snapshot()`, `xmin`) sind
|
||||||
|
theoretisch am saubersten, brechen aber die Dumb-Pipe-Form `{conversation, cursor}`, weil
|
||||||
|
der Cursor kein `bigint` mehr wäre.
|
||||||
|
|
||||||
|
### 2. Die Änderungsachse ist ein Event-Log, keine Spalte auf `messages`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE conversation_events (
|
||||||
|
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||||
|
seq BIGINT NOT NULL,
|
||||||
|
kind TEXT NOT NULL, -- message_created | message_edited | message_deleted | reaction_added | ...
|
||||||
|
target_id UUID NOT NULL,
|
||||||
|
at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (conversation_id, seq)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Jede Mutation hängt eine Zeile an, mit der Nummer aus dem Conversation-Zähler. Das löst
|
||||||
|
das Problem, an dem eine bloße Spalte auf `messages` scheitert: eine bearbeitete oder
|
||||||
|
soft-gelöschte Nachricht erzeugt keinen neuen Cursor-Wert und wäre für einen
|
||||||
|
`?since`-Backfill unsichtbar.
|
||||||
|
|
||||||
|
Das Log deckt zusätzlich alles ab, was **keine** Nachrichtenzeile hat — Reactions,
|
||||||
|
Attachments, Mitgliederwechsel, Umbenennungen — und trägt damit die Fog-Themen Presence,
|
||||||
|
Typing und Read-Receipts später ohne Umbau mit. Und: **die Log-Zeile ist buchstäblich der
|
||||||
|
Dumb-Pipe-Event**; `NOTIFY` kann direkt darauf feuern.
|
||||||
|
|
||||||
|
Preis, bewusst akzeptiert: eine zweite Tabelle und ein zusätzlicher Insert pro Mutation.
|
||||||
|
|
||||||
|
### 3. Genau ein Zähler, und `created_at` verliert seine Sortierrolle
|
||||||
|
|
||||||
|
Es gibt **einen** Zähler pro Conversation, den das Log verbraucht. Eine Nachricht speichert
|
||||||
|
die `seq` ihres `message_created`-Events als **unveränderlichen Sortierschlüssel**:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE messages ADD COLUMN seq BIGINT NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX messages_conversation_seq_idx ON messages(conversation_id, seq);
|
||||||
|
```
|
||||||
|
|
||||||
|
Damit fällt `created_at` als Sortierkriterium weg und wird reine Anzeigeinformation. Das
|
||||||
|
ist kein Nebeneffekt, sondern eine Korrektur: `created_at` ist heute `NOW()`, in Postgres
|
||||||
|
der **Transaktionsstart**. Zwei gleichzeitige Sender bekommen Zeitstempel, deren
|
||||||
|
Reihenfolge der Commit-Reihenfolge widersprechen kann — und der Wert ist nicht einmal
|
||||||
|
eindeutig. Die bestehende Sortierung in `list_messages` ist damit schon heute falsch,
|
||||||
|
unabhängig vom Realtime-Umbau.
|
||||||
|
|
||||||
|
Eine bearbeitete Nachricht bekommt ein neues Event mit höherer `seq`, behält aber ihre
|
||||||
|
eigene `seq` — sie springt also im Cursor-Raum ans Ende, nicht im Chatverlauf.
|
||||||
|
|
||||||
|
### 4. Der Backfill liefert aufgelösten Zustand, nicht rohe Events
|
||||||
|
|
||||||
|
`GET …?since=N` liest das Log ab `N`, sammelt die betroffenen IDs und gibt den **aktuellen
|
||||||
|
Zustand** dieser Entitäten zurück, plus den neuen Cursor. Der Client ersetzt schlicht, was
|
||||||
|
er bekommt — keine Event-Interpretation, keine Abspiel-Logik.
|
||||||
|
|
||||||
|
Drei Eigenschaften, die daraus folgen:
|
||||||
|
|
||||||
|
- **Idempotent.** Ein doppelt zugestelltes Signal kann nichts kaputtmachen.
|
||||||
|
- **Koaleszierend.** Zehn Edits derselben Nachricht ergeben eine Zeile im Ergebnis.
|
||||||
|
- **Das Log ist reine Signalisierung** und damit wegwerfbar — es ist nicht die
|
||||||
|
Wahrheitsquelle, die Tabellen sind es.
|
||||||
|
|
||||||
|
**Wichtig:** der Backfill darf Soft-Deletes **nicht** herausfiltern. Er muss Tombstones
|
||||||
|
liefern, sonst erfährt der Client nie von einer Löschung. Das weicht vom heutigen
|
||||||
|
Verhalten von `list_messages` ab (`WHERE deleted_at IS NULL`).
|
||||||
|
|
||||||
|
Verworfen wurde die Alternative „rohe Events, Client spielt sie ab": sie böte eine echte
|
||||||
|
Änderungshistorie, macht aber das Log zur Wahrheitsquelle (also unbeschneidbar), verlangt
|
||||||
|
idempotente Client-Logik pro Event-Typ, und unter E2EE müssten die Events selbst
|
||||||
|
verschlüsselt werden — sonst leckt das Log genau die Metadaten, die der Blob schützen soll.
|
||||||
|
|
||||||
|
### 5. Keine Retention, aber ein Antwortlimit und ein `resync`-Pfad
|
||||||
|
|
||||||
|
Das Log wird vorerst **nicht** beschnitten. Eine Zeile pro Mutation ist für Postgres nichts,
|
||||||
|
und eine Aufräum-Mechanik, die niemand braucht, ist Wartungslast mit Fehlerpotenzial —
|
||||||
|
zumal sie bei der Regel „eine Binary + Postgres" entweder als Timer-Task in den Server
|
||||||
|
wandert oder `pg_cron` dazuzieht.
|
||||||
|
|
||||||
|
Stattdessen bekommt der Backfill ein hartes Limit pro Antwort. Wird es überschritten,
|
||||||
|
antwortet der Server mit `resync` statt mit einem Delta; der Client verwirft seinen Cache
|
||||||
|
für diese Conversation und lädt frisch.
|
||||||
|
|
||||||
|
**Dieser Pfad wird ohnehin gebraucht** — für das Handy, das drei Wochen aus war, für die
|
||||||
|
allererste Verbindung ohne Cursor, und laut
|
||||||
|
[*Recherche: WS-Auth, Heartbeat und Reconnect*](03-research-ws-auth-reconnect.md) auch als
|
||||||
|
Reaktion auf `RecvError::Lagged`. Damit sind drei verschiedene Situationen auf **einen**
|
||||||
|
Mechanismus abgebildet.
|
||||||
|
|
||||||
|
Retention lässt sich später nachrüsten, ohne das Protokoll zu ändern.
|
||||||
|
|
||||||
|
### Begriffe
|
||||||
|
|
||||||
|
Für die einheitliche Sprache im Projekt festgehalten:
|
||||||
|
|
||||||
|
| Begriff | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| **`seq`** | Monoton steigende Nummer pro Conversation, vergeben unter dem Zeilen-Lock der Conversation. Der einzige Ordnungsbegriff — nicht `created_at`, nicht `id`. |
|
||||||
|
| **Cursor** | Der `seq`-Stand, den ein Client für eine Conversation zuletzt gesehen hat. Ein `bigint`, mehr nicht. |
|
||||||
|
| **Event** | Eine Zeile in `conversation_events`. Signalisiert, *dass* sich etwas geändert hat; nicht *was* der neue Zustand ist. |
|
||||||
|
| **Backfill** | `?since=<cursor>`: der aufgelöste aktuelle Zustand aller seither betroffenen Entitäten. |
|
||||||
|
| **Resync** | Antwort des Servers, wenn ein Delta nicht sinnvoll bildbar ist (Cursor zu alt, kein Cursor, Limit überschritten). Der Client verwirft seinen Cache für die Conversation. |
|
||||||
|
| **Tombstone** | Eine soft-gelöschte Zeile, die der Backfill **mitliefert**, damit der Client die Löschung erfährt. |
|
||||||
|
|
||||||
|
### Abnahmekriterium — Status
|
||||||
|
|
||||||
|
Das Kriterium verlangte einen lauffähigen Test. **Er ist spezifiziert, aber nicht
|
||||||
|
geschrieben** — dieser Effort plant, und der Test setzt die Migration und den geänderten
|
||||||
|
Handler voraus, also Implementierungsarbeit. Die Spezifikation:
|
||||||
|
|
||||||
|
> Zwei Transaktionen A und B fügen gleichzeitig in dieselbe Conversation ein. A beginnt
|
||||||
|
> zuerst und committet zuletzt. Ein Leser pollt zwischen den Commits mit `?since=<cursor>`
|
||||||
|
> und merkt sich alles Gelieferte. Erwartung: nach beiden Commits hat der Leser **beide**
|
||||||
|
> Nachrichten gesehen.
|
||||||
|
>
|
||||||
|
> Der Test muss in der naiven Variante (`BIGSERIAL` oder `created_at` als Cursor)
|
||||||
|
> **fehlschlagen** — sonst misst er nicht, was er messen soll.
|
||||||
|
|
||||||
|
Der eigentliche Beweis liegt in der Konstruktion (§1); der Test bewacht die Implementierung
|
||||||
|
dagegen, dass jemand den `UPDATE … RETURNING` später aus der Transaktion herauszieht oder
|
||||||
|
durch ein `SELECT max(...)` ersetzt.
|
||||||
|
|
||||||
|
### Offene Kopplung
|
||||||
|
|
||||||
|
**Wo die Event-Zeile geschrieben wird — im Handler oder per DB-Trigger — ist hier bewusst
|
||||||
|
nicht entschieden.** Diese Frage steht in
|
||||||
|
[*Fan-out-Architektur*](08-fan-out-architektur.md), weil sie dort mit `NOTIFY` zusammenfällt:
|
||||||
|
ein Trigger feuert garantiert auf jedem Schreibweg und macht die zweite
|
||||||
|
Fan-out-Implementierung fast geschenkt, ein Handler-Aufruf ist dafür leichter zu lesen.
|
||||||
116
docs/realtime/issues/03-research-ws-auth-reconnect.md
Normal file
116
docs/realtime/issues/03-research-ws-auth-reconnect.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Recherche: WS-Auth, Heartbeat und Reconnect
|
||||||
|
|
||||||
|
Type: research
|
||||||
|
Status: resolved
|
||||||
|
Blocked by: —
|
||||||
|
Parent: ../map.md
|
||||||
|
|
||||||
|
## Question
|
||||||
|
|
||||||
|
Welche etablierten Muster gibt es für Authentifizierung, Verbindungserhalt und Reconnect
|
||||||
|
bei langlebigen WebSocket-Verbindungen — und welche davon passen zu Axum 0.8 und einem
|
||||||
|
JWT-basierten Auth-Modell?
|
||||||
|
|
||||||
|
### Zu klären
|
||||||
|
|
||||||
|
- **Token-Übergabe beim Handshake.** Die Browser-`WebSocket`-API erlaubt keine
|
||||||
|
benutzerdefinierten Header. Übliche Auswege: Token im Query-String (landet in
|
||||||
|
Server-Logs — bei diesem Bedrohungsmodell heikel), `Sec-WebSocket-Protocol`-Missbrauch,
|
||||||
|
Cookie, oder ein erstes Auth-Frame nach dem Handshake mit kurzem Timeout. Was ist
|
||||||
|
gängige Praxis, was sind die dokumentierten Nachteile? **Achtung:** falls die
|
||||||
|
Verbindung im Tauri-Rust-Layer lebt (siehe *Wo lebt die WS-Verbindung im Client*),
|
||||||
|
entfällt die Header-Beschränkung komplett — die Recherche soll beide Fälle abdecken.
|
||||||
|
- **Token-Ablauf bei laufender Verbindung.** Das JWT ist beim Handshake gültig und läuft
|
||||||
|
nach Minuten ab, während die Verbindung Stunden steht. Muster: Verbindungs-TTL mit
|
||||||
|
erzwungenem Reconnect, In-Band-Re-Auth-Frame, serverseitige Revocation-Liste. Was
|
||||||
|
machen andere?
|
||||||
|
- **Heartbeat.** WebSocket hat Ping/Pong auf Protokollebene. Wann reicht das, wann braucht
|
||||||
|
es Application-Level-Heartbeats? Übliche Intervalle. Verhalten hinter Proxies und
|
||||||
|
Corporate-Firewalls (relevant: Firmenkunden sind Zielgruppe).
|
||||||
|
- **Reconnect-Strategie.** Exponentielles Backoff mit Jitter — konkrete Parameter aus
|
||||||
|
der Praxis. Thundering-Herd-Verhalten, wenn ein Server neu startet und alle Clients
|
||||||
|
gleichzeitig zurückkommen.
|
||||||
|
- **Axum 0.8 konkret.** Wie sieht ein WebSocket-Upgrade mit State-Zugriff und Middleware
|
||||||
|
aus? Greift `middleware::from_fn_with_state` beim Upgrade-Request? Wie werden viele
|
||||||
|
gleichzeitige Verbindungen sauber verwaltet und beim Shutdown beendet?
|
||||||
|
- **`tokio::sync::broadcast` und langsame Empfänger.** Der Kanal verwirft Nachrichten für
|
||||||
|
hinterherhinkende Receiver (`RecvError::Lagged`). Wie behandelt man das korrekt, wenn
|
||||||
|
„Lagged" eigentlich „Resync jetzt" bedeuten soll? Gibt es dafür ein etabliertes Muster
|
||||||
|
oder bessere Kanaltypen für diesen Zweck?
|
||||||
|
|
||||||
|
### Quellenqualität
|
||||||
|
|
||||||
|
Axum-/Tokio-Dokumentation und Quellcode, RFC 6455, MDN. Praxisberichte nur mit Datum.
|
||||||
|
|
||||||
|
### Erwartetes Ergebnis
|
||||||
|
|
||||||
|
Eine Markdown-Datei im Repo: pro Teilfrage die Optionen mit ihren dokumentierten Vor-
|
||||||
|
und Nachteilen, jeweils belegt. **Keine Empfehlung** — die Entscheidung fällt im Ticket
|
||||||
|
*Verbindungsaufbau & Auth über WS*, dieses Ticket liefert nur die Faktenlage.
|
||||||
|
|
||||||
|
## Answer
|
||||||
|
|
||||||
|
Vollständiges Ergebnis: [`../research/03-ws-auth-reconnect.md`](../research/03-ws-auth-reconnect.md)
|
||||||
|
— Faktenlage zu allen sechs Teilfragen, Quellenverzeichnis und ein Anhang „Was nicht belegt
|
||||||
|
werden konnte" mit 17 Punkten. Keine Empfehlung, wie gefordert.
|
||||||
|
|
||||||
|
### Die vier Befunde, die andere Tickets verändern
|
||||||
|
|
||||||
|
**1. Der `Authorization`-Header ist der leiseste Kanal — und das ist belegbar, nicht Folklore.**
|
||||||
|
In den Default-Konfigurationen von nginx, Apache und Caddy landet der Query-String im
|
||||||
|
Access-Log; Caddy redigiert `Authorization` aktiv zu `REDACTED`, `request.uri` dagegen nicht.
|
||||||
|
Kein Transportweg schützt vor dem Betreiber selbst — der Server *muss* das Token sehen. Der
|
||||||
|
Unterschied zwischen den Optionen ist ausschließlich, was den Handshake in Logfiles, Backups
|
||||||
|
und APM-Systemen **überlebt**. Nebenbei entkräftet: die verbreiteten Referrer- und
|
||||||
|
Browser-History-Argumente gegen den Query-String ließen sich für `new WebSocket(url)` **nicht**
|
||||||
|
belegen und gehören nicht ins Entscheidungsticket.
|
||||||
|
|
||||||
|
**2. `Sec-WebSocket-Protocol` ist für dieses Bedrohungsmodell schlechter als gedacht** — Caddy
|
||||||
|
loggt per Default *alle* Header außer der Redaktionsliste, und dieser gehört nicht dazu. Dazu
|
||||||
|
kommt: ein JWT ist zwar ein legaler Subprotocol-Token, aber `=` ist als Separator verboten
|
||||||
|
(Base64-Padding), und der Browser erzwingt ein Echo des Subprotokolls durch den Server.
|
||||||
|
|
||||||
|
**3. Die Browser-API verschluckt alle Fehlerdetails — der Rust-Layer nicht.** 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`.
|
||||||
|
Im WebView ist „Token abgelaufen" also nicht von „Server down" unterscheidbar. `tungstenite::Error::Http`
|
||||||
|
trägt dagegen die vollständige HTTP-Response inklusive Statuscode und Body. Diese Asymmetrie
|
||||||
|
zieht sich durch fast jede Teilfrage → starkes Argument in *Wo lebt die WS-Verbindung im Client*.
|
||||||
|
|
||||||
|
**4. `axum::serve(...).with_graceful_shutdown(...)` beendet WebSocket-Verbindungen nicht und
|
||||||
|
wartet nicht auf sie.** Belegt vierfach: der `close_rx`-Guard hängt nur an der hyper-Task, das
|
||||||
|
`on_upgrade`-`tokio::spawn` nicht; `UpgradeableConnection::graceful_shutdown` ist nach dem Hijack
|
||||||
|
laut hyper-util-Doku „does nothing"; axum-Issue #3003 (offen seit 21.10.2024) und PR #3831
|
||||||
|
(offen seit 11.07.2026) beschreiben genau das. **Das Projekt muss WS-Verbindungen selbst
|
||||||
|
tracken und beenden** — `CancellationToken` + `TaskTracker` sind die belegten Bausteine.
|
||||||
|
|
||||||
|
### Weitere Faktenlage, kompakt
|
||||||
|
|
||||||
|
- **`middleware::from_fn_with_state` greift beim Upgrade-Request** — das Upgrade ist ein normaler
|
||||||
|
GET, 401/403 sind regulär sendbar.
|
||||||
|
- **Token-Ablauf:** SignalR dokumentiert ausdrücklich, dass Auth **nicht** revalidiert wird.
|
||||||
|
Optionen sind TTL/Reconnect (AWS API GW: 2 h / 10 min idle, hart), In-Band-Refresh (Centrifugo,
|
||||||
|
Ably — Token landet dann in keinem Log) oder eine Revocation-Liste (im Betreiber-Angreifer-Modell
|
||||||
|
per Definition wirkungslos).
|
||||||
|
- **Heartbeat:** Protokoll-Ping ist aus dem WebView **nicht auslösbar** (WHATWG), im Rust-Layer
|
||||||
|
antwortet tungstenite automatisch. Proxy-Korridor der dokumentierten Idle-Timeouts: Azure
|
||||||
|
AppGW 20 s, GCP 30 s, nginx 60 s. Die verbreiteten Cloudflare-„100 s" und die HAProxy-Defaults
|
||||||
|
sind Mythen und ließen sich nicht belegen. Etablierte Intervalle: Socket.IO 25 s, Phoenix 30 s,
|
||||||
|
Centrifugo 25 s.
|
||||||
|
- **Reconnect:** Praxiswerte reichen von 10 ms (Phoenix, ohne Jitter) bis 15 s (Ably). Gegen
|
||||||
|
Thundering Herd sind vier Ansätze belegt: Client-Jitter, server-gesteuerter Hinweis
|
||||||
|
(`Retry-After`, Discord Op 7), Draining und Accept-Rate-Limiting — mit Postmortems von Discord
|
||||||
|
(29.04.2026) und Slack (15.03.2021).
|
||||||
|
- **`RecvError::Lagged(n)`:** `n` = übersprungene Nachrichten, der Receiver bleibt brauchbar, der
|
||||||
|
Cursor springt auf die älteste noch vorhandene Nachricht. ⚠️ **„Lagged = Resync" ist nirgends
|
||||||
|
als offizielles Muster dokumentiert** — gängige Community-Praxis, aber ohne Primärquelle.
|
||||||
|
Belegbar sind nur die Bausteine (`resubscribe()`, `Receiver::len()` als Frühwarnung,
|
||||||
|
`mpsc::try_send` pro Client).
|
||||||
|
- **`tokio::sync::watch` passt auffällig gut zum Dumb Pipe** — „retains only the last sent value"
|
||||||
|
ist exakt die Semantik von „der Cursor der Conversation X ist jetzt N". Kein Lag-Fehler möglich.
|
||||||
|
→ *Fan-out-Architektur*.
|
||||||
|
- **`PgListener` belegt eine dedizierte Postgres-Connection außerhalb des Pools** (aktuell
|
||||||
|
`max_connections = 10`), reconnected aber selbst. `NOTIFY` ist transaktional. Neue Gefahr:
|
||||||
|
eine volle Notify-Queue lässt Transaktionen **beim Commit fehlschlagen**.
|
||||||
|
- **Bestandsaufnahme:** `axum = "0.8.9"` ist **ohne `ws`-Feature** eingebunden, es gibt keinen
|
||||||
|
Graceful Shutdown, und `Extension<Claims>` wird nirgends gelesen.
|
||||||
151
docs/realtime/issues/04-voice-topologie.md
Normal file
151
docs/realtime/issues/04-voice-topologie.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# Voice-Topologie: Mesh oder SFU
|
||||||
|
|
||||||
|
Type: grilling
|
||||||
|
Status: resolved
|
||||||
|
Blocked by: —
|
||||||
|
Parent: ../map.md
|
||||||
|
|
||||||
|
## Question
|
||||||
|
|
||||||
|
Läuft Audio direkt zwischen den Clients (P2P-Mesh) oder über einen serverseitigen
|
||||||
|
Medien-Router (SFU) — und was davon legt das Signaling-Protokoll heute schon fest?
|
||||||
|
|
||||||
|
Diese Frage wurde beim Charting bewusst offengelassen (Q6). **Entblockt am 14.08.2026:**
|
||||||
|
[*Recherche: WebRTC im Tauri-WebView (Mobile)*](01-research-webrtc-tauri-mobile.md) hat
|
||||||
|
bestätigt, dass Android, iOS und Windows WebRTC im WebView tragen. Linux nicht — das ist
|
||||||
|
eine eigene Frage in [*Voice auf Linux*](11-voice-auf-linux.md), deren Antwort auf diese
|
||||||
|
hier zurückwirken kann.
|
||||||
|
|
||||||
|
### Spannungsfeld
|
||||||
|
|
||||||
|
- **Mesh** passt zum Bedrohungsmodell: der Server sieht nie Klartext-Audio, DTLS-SRTP
|
||||||
|
bleibt zwischen den Peers. Passt außerdem zur harten Deployment-Regel — nichts
|
||||||
|
Zusätzliches zu betreiben. **Skaliert aber nicht**: bei n Teilnehmern hält jeder n−1
|
||||||
|
Verbindungen und lädt sein Audio n−1 mal hoch. Praktische Obergrenze liegt bei etwa
|
||||||
|
vier bis sechs Teilnehmern, auf Mobile eher darunter.
|
||||||
|
- **SFU** skaliert auf Dutzende Teilnehmer, **terminiert aber DTLS** und sieht damit den
|
||||||
|
Audio-Klartext. Das widerspricht dem Bedrohungsmodell „Betreiber ist Angreifer"
|
||||||
|
frontal. E2EE über einen SFU braucht Insertable Streams / SFrame — das ist ein
|
||||||
|
eigenes Forschungsprojekt und im Rust-Ökosystem dünn besiedelt. Zusätzlich ein
|
||||||
|
weiterer zu betreibender Dienst, gegen die Simplizitätsregel.
|
||||||
|
|
||||||
|
### Zu entscheiden
|
||||||
|
|
||||||
|
- Welche **Teilnehmerzahl** muss ein Voice-Room realistisch tragen? Zwei Personen im
|
||||||
|
Gespräch, ein Team-Standup mit acht, oder ein Discord-artiger Kanal mit dreißig?
|
||||||
|
Diese Zahl entscheidet die Frage fast allein.
|
||||||
|
- Wenn Mesh: was passiert **oberhalb** der Grenze — harte Begrenzung mit klarer
|
||||||
|
Fehlermeldung, oder Degradation?
|
||||||
|
- Wenn SFU irgendwann: welche **Nähte** muss das Signaling-Protokoll heute schon haben,
|
||||||
|
damit der Wechsel kein Bruch ist? (Wer bietet an, wer antwortet, wie werden Streams
|
||||||
|
identifiziert.)
|
||||||
|
- **TURN**: P2P scheitert in restriktiven Firmennetzen ohne Relay — und Firmen sind
|
||||||
|
Zielgruppe. Bestätigt ist bereits: TURN bleibt optionale externe Abhängigkeit. Was
|
||||||
|
genau ist die Zusage an den Nutzer, wenn kein TURN konfiguriert ist? Und wie merkt
|
||||||
|
der Client das früh genug, um es ehrlich anzuzeigen statt in einen Timeout zu laufen?
|
||||||
|
- Wie werden **TURN-Credentials** übermittelt, ohne sie fest in den Client zu backen?
|
||||||
|
(Zeitbeschränkte HMAC-Credentials sind der übliche Weg — gehört das ins
|
||||||
|
Signaling-Protokoll?)
|
||||||
|
|
||||||
|
### Zwei SDP-Fragen aus der WebRTC-Recherche, die hierher gehören
|
||||||
|
|
||||||
|
Beide sind formal Codec-/Transport-Themen, aber es sind SDP- bzw. ICE-Entscheidungen und
|
||||||
|
damit Topologie-nah:
|
||||||
|
|
||||||
|
- **`iceTransportPolicy: "relay"` erzwingen?** Verhindert, dass der Peer die IP-Adresse
|
||||||
|
des Gegenübers lernt (W3C WebRTC 1.0 nennt das ausdrücklich als Zweck). Der Server
|
||||||
|
kennt die IP ohnehin aus der WebSocket-Verbindung, also kostet es nichts an Vertraulichkeit
|
||||||
|
gegenüber dem Betreiber — es reduziert strikt die Exposition gegenüber dem *Peer*. Preis:
|
||||||
|
ohne TURN gibt es dann gar keine Verbindung mehr, auch im LAN nicht.
|
||||||
|
- **Opus mit `cbr=1` (und ggf. `usedtx=0`) verhandeln?** Variable Bitrate leckt über
|
||||||
|
Paketgrößen ein Sprech-/Pausen-Muster — ein feindlicher TURN-Betreiber bekommt damit
|
||||||
|
eine Voice-Activity-Detection frei Haus, obwohl er den Klartext nicht sieht. Konstante
|
||||||
|
Bitrate schließt das gegen Bandbreite.
|
||||||
|
|
||||||
|
### Randbedingung
|
||||||
|
|
||||||
|
Diese Entscheidung darf die Grenze „dieser Effort endet beim Signaling" nicht
|
||||||
|
überschreiten. Codecs, Echo-Cancellation und Audio-Pipeline sind out of scope — die beiden
|
||||||
|
Punkte oben sind die Ausnahme, weil sie im SDP ausgehandelt werden und damit ins Protokoll
|
||||||
|
gehören.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Answer
|
||||||
|
|
||||||
|
Entschieden am 14.08.2026.
|
||||||
|
|
||||||
|
### 1. Mesh mit harter Obergrenze von sechs Teilnehmern
|
||||||
|
|
||||||
|
P2P-Mesh, DTLS-SRTP bleibt zwischen den Peers, **der Server fasst nie Audio an**. Das
|
||||||
|
entspricht dem Bedrohungsmodell direkt und braucht keinen weiteren Dienst — die harte Regel
|
||||||
|
„eine Binary + Postgres" bleibt für Voice unangetastet.
|
||||||
|
|
||||||
|
Oberhalb von sechs wird der Beitritt **abgelehnt**, nicht degradiert: eine klare Meldung ist
|
||||||
|
ehrlicher als ein Gespräch, das für alle schlechter wird, ohne dass jemand versteht warum.
|
||||||
|
Bei sechs Teilnehmern lädt jeder Client fünf Ströme hoch, also grob 120 kbit/s aufwärts — die
|
||||||
|
Grenze, ab der Mesh in der Praxis kippt.
|
||||||
|
|
||||||
|
**Kein spekulativer Vorbau für einen späteren SFU.** Das ist der Kern der Entscheidung und
|
||||||
|
bewusst gegen den Reflex getroffen: ein SFU-Protokoll ist client-zu-Server adressiert, ein
|
||||||
|
Mesh-Protokoll peer-zu-peer. Eine „SFU-taugliche Naht" heute wäre Selbsttäuschung — sie würde
|
||||||
|
Komplexität kosten und im Ernstfall trotzdem nicht passen. **Ein SFU wäre ein bewusster
|
||||||
|
Protokollbruch in einem eigenen Effort**, und das steht so in der Map.
|
||||||
|
|
||||||
|
Verworfen: SFU von Anfang an. Er trüge Dutzende Teilnehmer, und SFrame/Insertable Streams
|
||||||
|
sind laut Recherche auf allen Zielplattformen verfügbar (WKWebView ab 15.4, Chromium ab 141).
|
||||||
|
Dagegen stehen drei Kosten: ein weiterer zu betreibender Dienst gegen die Deployment-Regel,
|
||||||
|
ein erheblich schwereres Krypto-Design zulasten des E2EE-Efforts, und selbst mit SFrame
|
||||||
|
behielte der SFU Zugriff auf RTP-Metadaten — die `audio-level`-Header-Erweiterung ist ein
|
||||||
|
direktes Sprechaktivitäts-Orakel und müsste eigens wegverhandelt werden.
|
||||||
|
|
||||||
|
Ebenfalls verworfen: den SFU dauerhaft auszuschließen. Das hätte das Profil geschärft, aber
|
||||||
|
die Tür für Firmenkunden mit größeren Runden zugezogen — eine Produktentscheidung, die es
|
||||||
|
ohne Not heute nicht braucht.
|
||||||
|
|
||||||
|
### 2. Eine Datenschutz-Politik je Instanz, vom Server gesetzt
|
||||||
|
|
||||||
|
Die Instanz konfiguriert **einen** Schalter, der zwei Dinge zugleich regelt und im Signaling
|
||||||
|
an den Client übermittelt wird; der Client zeigt sichtbar an, welcher Modus gilt.
|
||||||
|
|
||||||
|
| Schalterstellung | `iceTransportPolicy` | Opus |
|
||||||
|
|---|---|---|
|
||||||
|
| **streng** | `"relay"` erzwungen — der Peer lernt die IP-Adresse nie | `cbr=1`, `usedtx=0` |
|
||||||
|
| **offen** | Standard-ICE (host / srflx / relay) | Standard (VBR mit DTX) |
|
||||||
|
|
||||||
|
Die Kopplung ist nicht Bequemlichkeit, sondern folgt der Bedrohung: **beide Lecks entstehen
|
||||||
|
am selben Ort.** Läuft Audio über das Relay, sieht der Betreiber — der erklärte Angreifer —
|
||||||
|
die Paketgrößen und hat damit einen Sprechaktivitäts-Detektor, obwohl er den Inhalt nicht
|
||||||
|
lesen kann. Läuft es direkt, bekommt er die Pakete gar nicht zu sehen, dafür lernt der Peer
|
||||||
|
die IP. Ein Schalter, ein anzuzeigender Zustand.
|
||||||
|
|
||||||
|
**Warum je Instanz und nicht global:** auf einem öffentlichen Server telefonieren Fremde
|
||||||
|
miteinander, und dass zwei Unbekannte gegenseitig ihre Anschlussadresse lernen, ist für ein
|
||||||
|
Datenschutzprodukt untragbar — dort gehört „streng" hin, und TURN wird auf dieser Instanz zur
|
||||||
|
Voraussetzung. Auf einer Firmeninstanz sind die Peers Kollegen im selben Netz; dort wäre
|
||||||
|
erzwungenes Relay nur Bandbreitenverschwendung, und ein Gespräch zwischen zwei Rechnern im
|
||||||
|
selben Büro liefe ohne Not über den Server.
|
||||||
|
|
||||||
|
**Bewusst in Kauf genommen:** im offenen Modus bleibt das Sprechmuster für Netzbeobachter auf
|
||||||
|
dem Pfad sichtbar, und die Zusage an den Nutzer hängt am Betreiber — deshalb muss der Client
|
||||||
|
sie anzeigen und nicht nur intern kennen.
|
||||||
|
|
||||||
|
**Damit bleibt TURN die optionale Abhängigkeit**, die die Map festgelegt hat: ohne TURN läuft
|
||||||
|
Voice im offenen Modus eingeschränkt (Firmen-NATs), im strengen Modus gar nicht — und der
|
||||||
|
Betreiber weiß, worauf er sich einlässt, weil er den Schalter selbst umlegt.
|
||||||
|
|
||||||
|
### 3. Beide Medienpfade müssen die Politik umsetzen
|
||||||
|
|
||||||
|
Aus [*Voice auf Linux*](11-voice-auf-linux.md): `RTCPeerConnection` im WebView auf drei
|
||||||
|
Plattformen, `livekit/rust-sdks` auf Linux. Sowohl `iceTransportPolicy` als auch die
|
||||||
|
Opus-`fmtp`-Parameter müssen auf beiden Wegen gesetzt werden können — beides ist
|
||||||
|
Standard-WebRTC und in beiden Stacks vorhanden, gehört aber in die Spec, damit es nicht auf
|
||||||
|
einem der Pfade vergessen wird.
|
||||||
|
|
||||||
|
### Folgen
|
||||||
|
|
||||||
|
[*Voice-Signaling-Protokoll*](09-voice-signaling-protokoll.md) ist damit entblockt. Zwei
|
||||||
|
Dinge gehen von hier dorthin: die **Raumgröße** muss serverseitig durchgesetzt werden (der
|
||||||
|
Beitritt wird abgelehnt, nicht nur in der UI verhindert), und die **Politik-Übermittlung**
|
||||||
|
gehört ins Signaling — zusammen mit den TURN-Zugangsdaten, die im strengen Modus zwingend
|
||||||
|
sind.
|
||||||
194
docs/realtime/issues/05-wo-lebt-die-ws-verbindung.md
Normal file
194
docs/realtime/issues/05-wo-lebt-die-ws-verbindung.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# 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.
|
||||||
183
docs/realtime/issues/06-dumb-pipe-event-und-backfill.md
Normal file
183
docs/realtime/issues/06-dumb-pipe-event-und-backfill.md
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
# Dumb-Pipe-Event & Backfill-Endpunkt
|
||||||
|
|
||||||
|
Type: grilling
|
||||||
|
Status: resolved
|
||||||
|
Blocked by: —
|
||||||
|
Parent: ../map.md
|
||||||
|
|
||||||
|
## Question
|
||||||
|
|
||||||
|
Wie sieht der Änderungs-Event genau aus, und welche Semantik hat der
|
||||||
|
`?since`-Backfill-Endpunkt, der ihn einlöst?
|
||||||
|
|
||||||
|
Die Protokollform ist entschieden (Dumb Pipe: der Socket trägt „hier hat sich etwas
|
||||||
|
geändert", der Client lädt per REST nach). Dieses Ticket macht sie präzise.
|
||||||
|
|
||||||
|
**Entblockt am 14.08.2026** durch [*Monotoner Cursor*](02-monotoner-cursor.md) — das hat
|
||||||
|
mehrere Fragen dieses Tickets bereits beantwortet. Was dort feststeht und hier **nicht**
|
||||||
|
neu aufgemacht wird:
|
||||||
|
|
||||||
|
- Ein Zähler `seq` pro Conversation, vergeben unter Zeilen-Lock. Der Cursor ist ein `bigint`.
|
||||||
|
- Änderungsachse ist die Tabelle `conversation_events`; **eine Log-Zeile ist der Event**.
|
||||||
|
Damit sind Edits, Soft-Deletes, Reactions und Attachments abgedeckt — sie brauchen hier
|
||||||
|
keine eigene Achse mehr.
|
||||||
|
- Der Backfill liefert **aufgelösten Zustand inkl. Tombstones**, nicht rohe Events.
|
||||||
|
- Initialer Zustand ohne Cursor, zu alter Cursor und Antwortlimit laufen alle über
|
||||||
|
**denselben `resync`-Pfad**.
|
||||||
|
- Sortierung erfolgt über `messages.seq`, nicht über `created_at`.
|
||||||
|
|
||||||
|
### Zu entscheiden
|
||||||
|
|
||||||
|
- **Event-Format.** `{"conversation": "<uuid>", "cursor": <n>}` ist die Arbeitsannahme.
|
||||||
|
Reicht das? Braucht es einen Ressourcen-Diskriminator (`messages` vs. `reactions` vs.
|
||||||
|
`members`), oder ist ein Cursor pro Conversation, der alle Änderungen umfasst,
|
||||||
|
einfacher und ausreichend? Ein Diskriminator spart Fetches, kostet aber die Einfachheit,
|
||||||
|
die den Dumb Pipe überhaupt rechtfertigt.
|
||||||
|
- **Serialisierung.** JSON oder etwas Kompakteres. Bei ~40 Byte Nutzlast ist der
|
||||||
|
Unterschied vermutlich irrelevant — dann ist JSON die faule, richtige Antwort. Prüfen,
|
||||||
|
nicht annehmen.
|
||||||
|
- **Versionierung.** Braucht ein Protokoll mit einem Event-Typ überhaupt eine Version?
|
||||||
|
Was ist die Regel für die Zukunft, wenn Presence und Typing dazukommen (siehe Fog)?
|
||||||
|
- **Backfill-Endpunkt.** Erweiterung von `GET /conversations/{id}/messages` um `?since`,
|
||||||
|
oder ein neuer Endpunkt? Paginierung, Obergrenze pro Antwort, Verhalten bei sehr
|
||||||
|
großem Rückstand (Client war zwei Wochen offline).
|
||||||
|
- **Genaue Form der `resync`-Antwort.** Eigener HTTP-Status, ein Feld in der normalen
|
||||||
|
Antwort, oder ein eigener Endpunkt? Und wie groß ist das Antwortlimit tatsächlich?
|
||||||
|
- **`kind`-Taxonomie im Log.** Welche Werte gibt es, und wie fein? `message_created` vs.
|
||||||
|
`message_edited` vs. `message_deleted` einzeln, oder ein gemeinsames `message_changed`?
|
||||||
|
Der Backfill löst ohnehin auf — feinere Werte nützen also nur der Server-Seite und
|
||||||
|
späteren Konsumenten.
|
||||||
|
- **Endpunkt-Zuschnitt.** Ein Backfill pro Conversation, oder einer über alle Conversations
|
||||||
|
eines Servers (der Client hat womöglich 40 offene)? Letzteres spart Requests beim
|
||||||
|
Wiederverbinden, verkompliziert aber Limit und `resync`, weil beides dann pro Conversation
|
||||||
|
unterschiedlich ausfallen kann.
|
||||||
|
- **Was bleibt im Klartext.** Auch wenn der Inhalt später ein Blob ist: Conversation-ID,
|
||||||
|
Cursor, Absender und Zeitstempel muss der Server sehen, um zu routen und zu ordnen.
|
||||||
|
Diese Grenze hier explizit festhalten — sie ist die Zusage an den E2EE-Folge-Effort.
|
||||||
|
|
||||||
|
### Randbedingung
|
||||||
|
|
||||||
|
Der Event muss unter dem **8-kB-Payload-Limit von Postgres `NOTIFY`** bleiben. Das ist
|
||||||
|
kein Zufall, sondern der Grund, warum diese Protokollform gewählt wurde — siehe
|
||||||
|
*Fan-out-Architektur*.
|
||||||
|
|
||||||
|
Flüchtiges (SDP/ICE, später Typing) läuft **nicht** über diesen Event. Die Naht verläuft
|
||||||
|
entlang persistiert vs. flüchtig; das flüchtige Format entsteht in
|
||||||
|
*Voice-Signaling-Protokoll*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Answer
|
||||||
|
|
||||||
|
Entschieden am 14.08.2026.
|
||||||
|
|
||||||
|
### 1. Der Event ist minimal: `{conversation, seq}`
|
||||||
|
|
||||||
|
Rund 60 Byte. Die Server-Identität steckt nicht drin — sie ergibt sich aus dem Socket, über
|
||||||
|
den der Event kam. Der `seq` ist kein Luxus: der Empfänger verwirft damit sofort, was er
|
||||||
|
schon hat, statt blind nachzuladen.
|
||||||
|
|
||||||
|
**Verworfen wurden `kind` und `target_id` im Event.** Das entscheidende Argument war die
|
||||||
|
Umkehrbarkeit: ein zusätzliches Feld lässt sich später **additiv** ergänzen, weil ein
|
||||||
|
unbekanntes Feld ignorierbar ist. Es gibt also keinen Grund, heute dafür zu bezahlen. Sollte
|
||||||
|
sich der vorhergesehene Fall einstellen — Read-Receipts aus der Fog bumpen den Zähler in
|
||||||
|
einem aktiven Channel und lösen bei allen Clients Backfills aus, die nichts Sichtbares
|
||||||
|
ändern — wird `kind` nachgerüstet, ohne dass Bestehendes bricht.
|
||||||
|
|
||||||
|
Damit bleibt `conversation_events.kind` **rein serverintern**. Die Taxonomie ist ein
|
||||||
|
Implementierungsdetail, kein Protokollbestandteil, und muss nicht versioniert werden.
|
||||||
|
|
||||||
|
### 2. Aufholen nach Reconnect: Manifest, dann gezielte Backfills
|
||||||
|
|
||||||
|
Ein leichter Endpunkt liefert für alle Conversations, in denen der Nutzer Mitglied ist, den
|
||||||
|
aktuellen `seq`-Stand — eine Liste `{conversation_id, seq}`. Das ist **eine** günstige
|
||||||
|
Abfrage, weil `conversations.seq` seit [*Monotoner Cursor*](02-monotoner-cursor.md) als
|
||||||
|
Spalte existiert: ein Join über `conversation_members`, kein Scan über Nachrichten.
|
||||||
|
|
||||||
|
Der Client vergleicht mit seinen eigenen Cursorn und holt nur die tatsächlich geänderten.
|
||||||
|
Bei vierzig Conversations und drei geänderten sind das 1 + 3 Requests statt 40 — pro Server,
|
||||||
|
und der Client hält mehrere gleichzeitig.
|
||||||
|
|
||||||
|
Der Endpunkt beantwortet zwei weitere Fragen gratis: die Ungelesen-Anzeige über alle
|
||||||
|
Conversations, und den Erstverbindungsfall ohne jeden Cursor.
|
||||||
|
|
||||||
|
Verworfen: ein Sammel-Backfill mit Cursor-Map (müsste ein POST mit Body sein, Limit und
|
||||||
|
`resync` würden pro Conversation unterschiedlich, und eine große Antwort ist bei Abbruch
|
||||||
|
komplett verloren) sowie N parallele Backfills (vierzig Requests pro Reconnect, davon
|
||||||
|
siebenunddreißig leer — genau das Verhalten, das beim Server-Neustart einen Thundering Herd
|
||||||
|
erzeugt).
|
||||||
|
|
||||||
|
### 3. `resync` ist ein unterschiedener Antworttyp, kein Fehlerstatus
|
||||||
|
|
||||||
|
Der Backfill antwortet immer mit 200:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "kind": "delta", "cursor": 4711, "messages": [...], "reactions": [...], "attachments": [...] }
|
||||||
|
{ "kind": "resync", "cursor": 4711 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Grund ist praktisch, nicht ästhetisch: `resync` ist kein Fehler, sondern ein erwarteter
|
||||||
|
Ausgang — er tritt bei jeder Erstverbindung auf. Als 4xx liefe er durch
|
||||||
|
`error_for_status()`-Reflexe, landete in Fehler-Logs und Alarmen, und die Reconnect-Logik
|
||||||
|
könnte ihn mit einem echten Netzfehler verwechseln. Ein unterschiedener Antworttyp zwingt
|
||||||
|
den Client, beide Fälle explizit zu behandeln — was er ohnehin muss.
|
||||||
|
|
||||||
|
**Antwortlimit: 500 Entitäten als Startwert.** Eine Stellschraube, nachträglich änderbar
|
||||||
|
ohne Protokolländerung. Wird es überschritten, ist die Antwort `resync`.
|
||||||
|
|
||||||
|
Der `delta`-Fall liefert **Tombstones mit** — soft-gelöschte Zeilen müssen im Ergebnis
|
||||||
|
erscheinen, sonst erfährt der Client nie von einer Löschung. Das weicht vom heutigen
|
||||||
|
Verhalten von `list_messages` ab (`WHERE deleted_at IS NULL`).
|
||||||
|
|
||||||
|
### 4. Die Klartext-Grenze, konkret gezogen
|
||||||
|
|
||||||
|
Bindend für den E2EE-Folge-Effort. **Klartext bleibt**, weil der Server es zum Routen,
|
||||||
|
Ordnen, Autorisieren und Durchsetzen von Eindeutigkeit braucht:
|
||||||
|
|
||||||
|
`conversation_id` · `seq` · alle IDs · `user_id` (nur der Autor darf bearbeiten) ·
|
||||||
|
`created_at` / `updated_at` / `deleted_at` · `attachments.size_bytes` · `attachments.url`
|
||||||
|
|
||||||
|
Zeitstempel zu verschlüsseln wurde erwogen und verworfen: der Betreiber sieht den Zeitpunkt
|
||||||
|
ohnehin am Socket-Verkehr, der Gewinn wäre null.
|
||||||
|
|
||||||
|
**Blob wird:**
|
||||||
|
|
||||||
|
| Feld | Begründung |
|
||||||
|
|---|---|
|
||||||
|
| `messages.content` | der eigentliche Inhalt |
|
||||||
|
| `attachments.filename` | ein Dateiname wie `Kündigung_Mueller.pdf` ist so sensibel wie der Nachrichtentext |
|
||||||
|
| `attachments.mime_type` | verrät die Art des Dokuments |
|
||||||
|
| `reactions.emoji` | **deterministisch** verschlüsselt, damit `UNIQUE (message_id, user_id, emoji)` weiter greift — der Server weist Doppelreaktionen ab, ohne sie lesen zu können |
|
||||||
|
|
||||||
|
`size_bytes` bleibt zwangsläufig sichtbar.
|
||||||
|
|
||||||
|
Der **Mechanismus** bleibt dem E2EE-Effort überlassen, die **Feldliste** nicht. Begründung:
|
||||||
|
die Migration über diese Tabellen fällt in diesem Effort an — die Grenze später zu ziehen
|
||||||
|
hieße, dieselben Tabellen ein zweites Mal zu migrieren, und bis dahin lägen Dateinamen im
|
||||||
|
Klartext auf einem Server, dessen Betreiber als Angreifer gilt.
|
||||||
|
|
||||||
|
### 5. Abgeleitet, nicht eigens entschieden
|
||||||
|
|
||||||
|
Diese drei folgen aus dem Obigen; sie sind als Vorschlag zu lesen und jederzeit umkehrbar:
|
||||||
|
|
||||||
|
- **Serialisierung: JSON.** Bei 60 Byte Nutzlast bringt ein Binärformat nichts, was den
|
||||||
|
Werkzeugverlust aufwöge.
|
||||||
|
- **Keine Protokollversion.** Ein Event-Typ, Felder werden additiv ergänzt, unbekannte
|
||||||
|
Felder ignoriert. Ein Versionsfeld wäre Zeremonie ohne Gegenwert.
|
||||||
|
- **Drei Endpunkte statt einer Erweiterung von `/messages`:**
|
||||||
|
|
||||||
|
| Endpunkt | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `GET /sync` | Manifest: `{conversation_id, seq}` für alle Mitgliedschaften |
|
||||||
|
| `GET /conversations/{id}/changes?since=N` | Delta oder `resync` |
|
||||||
|
| `GET /conversations/{id}/messages?before=<seq>&limit=` | seitenweise Historie — für `resync` und für Zurückscrollen |
|
||||||
|
|
||||||
|
`/changes` bekommt einen eigenen Endpunkt, weil seine Antwort nicht nur Nachrichten
|
||||||
|
enthält, sondern auch Reactions, Attachments und Tombstones — eine andere Form als die von
|
||||||
|
`/messages`.
|
||||||
|
|
||||||
|
### Nebenbefund
|
||||||
|
|
||||||
|
`list_messages` gibt heute **alle** Nachrichten einer Conversation unbegrenzt zurück
|
||||||
|
(`ORDER BY created_at ASC`, kein Limit). Das ist unabhängig vom Realtime-Umbau ein Problem
|
||||||
|
und wird durch die Seitenaufteilung oben mit behoben.
|
||||||
207
docs/realtime/issues/07-verbindungsaufbau-und-auth.md
Normal file
207
docs/realtime/issues/07-verbindungsaufbau-und-auth.md
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
# Verbindungsaufbau & Auth über WS
|
||||||
|
|
||||||
|
Type: grilling
|
||||||
|
Status: resolved
|
||||||
|
Blocked by: —
|
||||||
|
Parent: ../map.md
|
||||||
|
|
||||||
|
## Question
|
||||||
|
|
||||||
|
Wie authentifiziert sich eine WebSocket-Verbindung, wie bleibt sie am Leben, und was
|
||||||
|
passiert, wenn das Token abläuft, während sie steht?
|
||||||
|
|
||||||
|
Die Faktenlage liegt vor: [*Recherche: WS-Auth, Heartbeat und Reconnect*](03-research-ws-auth-reconnect.md)
|
||||||
|
(**entblockt am 14.08.2026**), Volltext in
|
||||||
|
[`../research/03-ws-auth-reconnect.md`](../research/03-ws-auth-reconnect.md). Dieses Ticket
|
||||||
|
entscheidet.
|
||||||
|
|
||||||
|
### Vier Befunde, die die Optionen bereits verschoben haben
|
||||||
|
|
||||||
|
1. **Kein Transportweg schützt vor dem Betreiber** — der Server muss das Token sehen. Der
|
||||||
|
Unterschied ist ausschließlich, was den Handshake in Logs, Backups und APM-Systemen
|
||||||
|
**überlebt**. In den Defaults von nginx, Apache und Caddy landet der Query-String im
|
||||||
|
Access-Log; Caddy redigiert `Authorization` aktiv, `request.uri` nicht.
|
||||||
|
2. **`Sec-WebSocket-Protocol` ist schlechter als erwartet** — Caddy loggt per Default alle
|
||||||
|
Header außer der Redaktionsliste, und dieser gehört nicht dazu. Dazu: `=` ist als
|
||||||
|
Separator verboten (JWT-Base64-Padding), und der Server muss das Subprotokoll echoen.
|
||||||
|
3. **Die Browser-API verschluckt alle Fehlerdetails** (WHATWG-Vorgabe): im WebView ist
|
||||||
|
„Token abgelaufen" nicht von „Server down" unterscheidbar — immer `CloseEvent.code === 1006`.
|
||||||
|
`tungstenite::Error::Http` trägt dagegen die vollständige Response. Wenn die Verbindung im
|
||||||
|
Rust-Layer lebt, ist `Authorization: Bearer` möglich **und** die Fehlerdiagnose brauchbar;
|
||||||
|
beides zusammen macht die Entscheidung in
|
||||||
|
[*Wo lebt die WS-Verbindung im Client*](05-wo-lebt-die-ws-verbindung.md) fast zur Vorentscheidung
|
||||||
|
für dieses Ticket.
|
||||||
|
4. **Verworfen, nicht übernehmen:** Referrer-Leak und Browser-History als Argumente gegen den
|
||||||
|
Query-String ließen sich für `new WebSocket(url)` **nicht** belegen. Belegbar bleiben
|
||||||
|
Access-Logs, Log-Aggregation und deren Backups — das reicht als Argument.
|
||||||
|
|
||||||
|
### Zusätzlich zu entscheiden: Graceful Shutdown selbst bauen
|
||||||
|
|
||||||
|
`axum::serve(...).with_graceful_shutdown(...)` **beendet WebSocket-Verbindungen nicht und
|
||||||
|
wartet nicht auf sie** — vierfach belegt (hyper-util-Doku „does nothing" nach dem Hijack,
|
||||||
|
axum-Issue #3003 offen seit 21.10.2024, PR #3831 offen seit 11.07.2026, und der `close_rx`-Guard
|
||||||
|
hängt nur an der hyper-Task). Solange #3831 nicht gemerged ist, muss das Projekt WS-Verbindungen
|
||||||
|
selbst tracken. Belegte Bausteine: `CancellationToken` (ein Child-Token pro Verbindung) plus
|
||||||
|
`TaskTracker` (auf N Tasks warten). Beim Shutdown einen Close-Frame senden — `WebSocket::close`
|
||||||
|
existiert seit axum 0.8 nicht mehr, der Frame geht explizit über `send`; Code 1001 „going away"
|
||||||
|
oder 1012 „service restart".
|
||||||
|
|
||||||
|
Weitere belegte Randbedingungen: `middleware::from_fn_with_state` **greift** beim
|
||||||
|
Upgrade-Request (401/403 regulär sendbar); Protokoll-Ping ist aus dem WebView **nicht auslösbar**;
|
||||||
|
dokumentierter Proxy-Idle-Korridor: Azure AppGW 20 s, GCP 30 s, nginx 60 s (die verbreiteten
|
||||||
|
Cloudflare- und HAProxy-Werte sind Mythen und ließen sich nicht belegen).
|
||||||
|
|
||||||
|
**Bestandsaufnahme:** `axum = "0.8.9"` ist **ohne `ws`-Feature** eingebunden, es gibt keinen
|
||||||
|
Graceful Shutdown, und `Extension<Claims>` wird nirgends gelesen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Answer
|
||||||
|
|
||||||
|
Entschieden am 14.08.2026. Bestand: Access-Token 15 Minuten (`auth.rs:22`), Refresh-Token
|
||||||
|
30 Tage, gehasht in `auth_tokens` und löschbar — Widerruf existiert bereits auf Refresh-Ebene.
|
||||||
|
|
||||||
|
### 1. Handshake: `Authorization: Bearer` (abgeleitet)
|
||||||
|
|
||||||
|
Folgt aus [*Wo lebt die WS-Verbindung im Client*](05-wo-lebt-die-ws-verbindung.md): die
|
||||||
|
Verbindung lebt in Rust, eigene Header sind also möglich. Laut Recherche der einzige Kanal,
|
||||||
|
den die Defaults von nginx, Apache und Caddy nicht mitschreiben.
|
||||||
|
`middleware::from_fn_with_state` greift beim Upgrade-Request, ein 401 mit Body ist regulär
|
||||||
|
sendbar — und `tungstenite::Error::Http` trägt ihn vollständig zum Client.
|
||||||
|
|
||||||
|
### 2. Token-Ablauf: In-Band-Re-Auth über den offenen Socket
|
||||||
|
|
||||||
|
Der Client erneuert sein Access-Token wie bisher per REST und schiebt es als Frame über den
|
||||||
|
bestehenden Socket; der Server prüft und verlängert. Kein Reconnect, keine Handshake-Welle,
|
||||||
|
kein erneuter Manifest-Abruf — bei 15 Minuten Tokenlebensdauer und drei verbundenen Servern
|
||||||
|
wären das sonst zwölf Reconnects pro Stunde.
|
||||||
|
|
||||||
|
Der Socket ist für Voice-Signaling ohnehin bidirektional; dies ist damit der **zweite**
|
||||||
|
client→server-Frame-Typ, nicht der erste.
|
||||||
|
|
||||||
|
**Widerruf wirkt trotzdem:** schlägt die REST-Erneuerung fehl, weil das Refresh-Token
|
||||||
|
gelöscht wurde, kann der Client nicht verlängern, und der Server schließt bei Ablauf mit
|
||||||
|
`4001`. Die Wirkungsverzögerung ist damit auf die Restlaufzeit des Access-Tokens begrenzt,
|
||||||
|
also höchstens 15 Minuten — dieselbe Schranke wie bei einer Verbindungs-TTL.
|
||||||
|
|
||||||
|
Verworfen: eine serverseitige Widerrufsliste. Sie machte Widerruf sofort wirksam, kostet aber
|
||||||
|
geteilten Zustand über Instanzen hinweg plus eine Prüfung je Event — und gegen den Betreiber
|
||||||
|
als Angreifer wirkt sie per Definition gar nicht, weil er die Liste kontrolliert.
|
||||||
|
|
||||||
|
### 3. Autorisierung: Registry ist maßgeblich, REST prüft je Anfrage
|
||||||
|
|
||||||
|
Die Zustellung folgt der Registry aus [*Fan-out-Architektur*](08-fan-out-architektur.md). Die
|
||||||
|
**Sicherheitsgrenze liegt bei den REST-Endpunkten** — Manifest, Backfill und Historie prüfen
|
||||||
|
Mitgliedschaft bei jeder Anfrage.
|
||||||
|
|
||||||
|
Das trägt, weil der Event nur `{conversation, seq}` enthält und **keinen Inhalt**. Eine
|
||||||
|
Abweichung der Registry von der Datenbank leckt folglich nur Metadaten („Conversation X war
|
||||||
|
um 14:03 aktiv"), niemals Inhalte — und Metadaten gegenüber dem Server hat die Map
|
||||||
|
ausdrücklich als akzeptabel eingestuft.
|
||||||
|
|
||||||
|
Verworfen: eine Datenbankprüfung je Event (bei einem Channel mit fünfhundert Mitgliedern
|
||||||
|
fünfhundert Abfragen für eine Nachricht, zusätzlich zum Backfill, der ohnehin prüft) und
|
||||||
|
periodische Neuvalidierung (ein weiterer Mechanismus ohne nachgewiesenes Driftproblem —
|
||||||
|
bleibt als billiges Sicherheitsnetz in der Hinterhand).
|
||||||
|
|
||||||
|
### 4. Heartbeat: der Client pingt alle 15 Sekunden
|
||||||
|
|
||||||
|
Protokoll-Ping, kein Application-Frame — im WebView wäre das nicht möglich, in Rust schon.
|
||||||
|
Der Server antwortet (tungstenite automatisch) und führt `last_seen`; nach zwei ausbleibenden
|
||||||
|
Intervallen räumt er die Registry auf. Der Client erkennt eine tote Verbindung am
|
||||||
|
ausbleibenden Pong. **Eine Richtung bedient beide Bedürfnisse.**
|
||||||
|
|
||||||
|
Der Client treibt, weil er hinter NAT und Proxy sitzt und sein Intervall situativ anpassen
|
||||||
|
kann — im Hintergrund oder bei schlechtem Funk seltener, ohne dass der Server davon wissen
|
||||||
|
muss. Bei N verbundenen Servern kann er die Pings zeitlich verteilen statt sie zu bündeln.
|
||||||
|
|
||||||
|
**15 Sekunden**, um unter dem striktesten dokumentierten Proxy-Idle-Timeout der Recherche zu
|
||||||
|
bleiben (Azure Application Gateway, 20 s). Konfigurierbar — die meisten Aufstellungen sind
|
||||||
|
nginx mit 60 s. Die verbreiteten Cloudflare- und HAProxy-Werte ließen sich laut Recherche
|
||||||
|
nicht belegen und sind nicht Grundlage dieser Wahl.
|
||||||
|
|
||||||
|
### 5. Verbindungslimit: acht je Nutzer, die älteste weicht
|
||||||
|
|
||||||
|
Bei Überschreitung wird die **älteste** Verbindung mit `4002` getrennt, nicht die neue
|
||||||
|
abgewiesen. Das ist der wichtige Teil: ein Gerät mit hängengebliebener Verbindung könnte sich
|
||||||
|
sonst selbst aussperren — und genau das passiert auf Mobile regelmäßig, wenn das
|
||||||
|
Betriebssystem eine App abräumt, ohne dass der Socket sauber schließt.
|
||||||
|
|
||||||
|
Acht ist großzügig gegenüber dem legitimen Fall (Desktop, Handy, Tablet, Zweitrechner) und
|
||||||
|
trotzdem eine harte Obergrenze. Gezählt wird in der `user_id`-Registry, die es seit
|
||||||
|
Ticket 08 ohnehin gibt.
|
||||||
|
|
||||||
|
Ein Rate-Limit auf Handshakes wurde erwogen und **nicht** jetzt entschieden: es trifft ein
|
||||||
|
anderes Missbrauchsmuster, ist aber ohne Betriebserfahrung nicht kalibrierbar — ein
|
||||||
|
Reconnect-Sturm nach einem Server-Neustart sieht genauso aus wie ein Angriff. Bleibt in der
|
||||||
|
Fog-Notiz zum Rate-Limiting.
|
||||||
|
|
||||||
|
### 6. Close-Codes und Reconnect-Politik
|
||||||
|
|
||||||
|
Drei Handlungsklassen im Client:
|
||||||
|
|
||||||
|
| Code | Bedeutung | Klasse |
|
||||||
|
|---|---|---|
|
||||||
|
| `1012` | Server-Neustart | **sofort neu verbinden**, mit Jitter |
|
||||||
|
| `4001` | Token abgelaufen, keine gültige Re-Auth | Token erneuern, dann sofort neu verbinden; scheitert die Erneuerung → Anmeldung |
|
||||||
|
| `4002` | durch neuere Verbindung verdrängt | **nicht** neu verbinden |
|
||||||
|
| `4003` | Konto gesperrt oder gelöscht | **nicht** neu verbinden, zur Anmeldung |
|
||||||
|
| `1001`, `1006`, alles Übrige | Netzfehler, unbekannt | mit Backoff neu verbinden |
|
||||||
|
|
||||||
|
`4002` ist der Grund, warum es diese Tabelle überhaupt gibt: ohne ein „nicht neu verbinden"
|
||||||
|
verdrängt ein verdrängtes Gerät sofort das nächste, und bei mehr aktiven Geräten als erlaubt
|
||||||
|
entsteht ein Kreisverkehr.
|
||||||
|
|
||||||
|
Der Nutzen reicht über den Reconnect hinaus: der Client muss je Server einen aussagekräftigen
|
||||||
|
Zustand anzeigen, und „Anmeldung abgelaufen" von „Server nicht erreichbar" zu unterscheiden
|
||||||
|
geht nur so.
|
||||||
|
|
||||||
|
**Backoff (abgeleitet):** Full Jitter nach dem in der Recherche belegten Muster —
|
||||||
|
`warte = random(0, min(30 s, 0,5 s · 2^Versuch))`. Der Zufallsanteil ist nicht optional; ohne
|
||||||
|
ihn kehren nach einem Neustart alle Clients gleichzeitig zurück.
|
||||||
|
|
||||||
|
### 7. Graceful Shutdown selbst bauen (abgeleitet)
|
||||||
|
|
||||||
|
Vierfach belegt in der Recherche: `axum::serve(...).with_graceful_shutdown(...)` beendet
|
||||||
|
WebSocket-Verbindungen nicht und wartet nicht auf sie (axum-Issue #3003 offen seit
|
||||||
|
21.10.2024, PR #3831 offen seit 11.07.2026). Bausteine:
|
||||||
|
|
||||||
|
- Ein `CancellationToken` je Verbindung, abgeleitet vom globalen Shutdown-Token.
|
||||||
|
- Ein `TaskTracker`, um beim Herunterfahren auf die Verbindungstasks zu warten.
|
||||||
|
- Beim Shutdown Close-Frame mit `1012` senden — `WebSocket::close` gibt es seit axum 0.8
|
||||||
|
nicht mehr, der Frame geht explizit über `send`.
|
||||||
|
- `broadcast::Receiver::recv` und `CancellationToken::cancelled` sind dokumentiert cancel-safe;
|
||||||
|
für `SplitStream::next()` fehlt eine explizite Zusage, weshalb die axum-Beispiele
|
||||||
|
durchweg in zwei Tasks aufteilen statt über den Socket-Stream zu selektieren.
|
||||||
|
|
||||||
|
Sobald PR #3831 gemerged ist, kann das eigene Tracking entfallen — bis dahin ist es zwingend.
|
||||||
|
|
||||||
|
### Nebenbefund
|
||||||
|
|
||||||
|
`axum = "0.8.9"` ist heute **ohne das `ws`-Feature** eingebunden; es fehlt also die
|
||||||
|
Voraussetzung überhaupt. Und `Extension<Claims>` wird nirgends gelesen — die Middleware
|
||||||
|
authentifiziert, aber kein Handler nutzt die Identität. Beides gehört in die Umsetzung.
|
||||||
|
|
||||||
|
### Zu entscheiden
|
||||||
|
|
||||||
|
- **Handshake-Auth.** Token im Query-String ist bei diesem Bedrohungsmodell heikel
|
||||||
|
(Server-Logs, Proxy-Logs, Browser-History). Welcher Weg wird es? Falls die Verbindung
|
||||||
|
im Rust-Layer lebt (siehe *Wo lebt die WS-Verbindung im Client*), ist ein
|
||||||
|
`Authorization`-Header möglich — dann ist die Frage fast beantwortet.
|
||||||
|
- **Token-Ablauf bei laufender Verbindung.** Verbindungs-TTL mit erzwungenem Reconnect,
|
||||||
|
In-Band-Re-Auth, oder Revocation-Prüfung. Wichtig: eine Verbindung darf einen
|
||||||
|
**Token-Widerruf nicht überleben** — bei „Nutzersicherheit als Erfolgsfaktor" ist eine
|
||||||
|
Sitzung, die nach dem Logout weiterläuft, ein echter Fehler, kein Schönheitsfleck.
|
||||||
|
- **Autorisierung.** Der Nutzer darf nur Events für Conversations sehen, in denen er
|
||||||
|
Mitglied ist. Wird die Mitgliedschaft beim Verbindungsaufbau einmal geladen oder pro
|
||||||
|
Event geprüft? Was passiert, wenn er während der Verbindung aus einer Conversation
|
||||||
|
entfernt wird — oder hinzugefügt?
|
||||||
|
- **Heartbeat.** Protokoll-Ping/Pong oder Application-Level, mit welchen Intervallen.
|
||||||
|
Zielgruppe Firmen heißt: hinter Proxies, die Idle-Verbindungen abräumen.
|
||||||
|
- **Reconnect-Parameter.** Backoff-Kurve und Jitter, konkret. Was passiert beim
|
||||||
|
Server-Neustart, wenn alle Clients gleichzeitig zurückkommen?
|
||||||
|
- **Verbindungslimits.** Wieviele Verbindungen pro Nutzer sind erlaubt? Multi-Device ist
|
||||||
|
gefordert (Desktop + Mobile parallel), also mehr als eine — aber nicht unbegrenzt,
|
||||||
|
sonst ist es ein Vektor auf einem öffentlichen Server.
|
||||||
|
- **Sauberes Herunterfahren.** Wie werden offene Verbindungen beim Server-Shutdown
|
||||||
|
beendet, sodass Clients einen geordneten Reconnect machen statt in Timeouts zu laufen?
|
||||||
203
docs/realtime/issues/08-fan-out-architektur.md
Normal file
203
docs/realtime/issues/08-fan-out-architektur.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# Fan-out-Architektur
|
||||||
|
|
||||||
|
Type: grilling
|
||||||
|
Status: resolved
|
||||||
|
Blocked by: —
|
||||||
|
Parent: ../map.md
|
||||||
|
|
||||||
|
## Question
|
||||||
|
|
||||||
|
Wie kommt ein Änderungs-Event von „Nutzer A hat geschrieben" zu allen betroffenen offenen
|
||||||
|
Verbindungen — und wie sieht die Naht aus, hinter der später `LISTEN/NOTIFY` steckt?
|
||||||
|
|
||||||
|
Entschieden ist bereits: Start mit In-Process-Fan-out, aber hinter einem Trait; `NOTIFY`
|
||||||
|
als zweite Implementierung; **kein Redis** (harte Regel: eine Binary + Postgres).
|
||||||
|
|
||||||
|
### Zu entscheiden
|
||||||
|
|
||||||
|
- **Registry-Struktur.** `conversation_id → Menge von Sendern`, `user_id → Menge von
|
||||||
|
Verbindungen`, oder beides? Ein Nutzer kann in vielen Conversations sein und mehrere
|
||||||
|
Geräte haben — welche Indizierung hält beides aus, ohne bei jedem Event über alles zu
|
||||||
|
iterieren?
|
||||||
|
- **Kanaltyp.** `tokio::sync::broadcast` pro Conversation, `mpsc` pro Verbindung, oder
|
||||||
|
ein globaler Broadcast mit clientseitigem Filter. Bei `broadcast` ist zu klären, wie
|
||||||
|
`RecvError::Lagged` behandelt wird — es bedeutet hier „Resync", nicht „Fehler", und
|
||||||
|
das muss explizit im Design stehen (Council-Hinweis des Engineer).
|
||||||
|
- **Auf- und Abräumen.** Wann wird ein Eintrag entfernt? Verbindungsabbrüche sind der
|
||||||
|
Normalfall, nicht die Ausnahme — Leaks in der Registry sind der klassische Fehler.
|
||||||
|
- **Trait-Zuschnitt.** Wie schmal muss die Schnittstelle sein, damit die
|
||||||
|
`NOTIFY`-Implementierung sie wirklich erfüllen kann? `NOTIFY` ist *fire and forget*
|
||||||
|
ohne Zustellgarantie und ohne Rückkanal — ein Trait, das mehr verspricht als das,
|
||||||
|
ist eine Lüge, die beim Wechsel auffliegt.
|
||||||
|
- **`NOTIFY`-Pfad konkret genug, um ihn zu bewerten.** Ein Channel pro Conversation
|
||||||
|
(Postgres hat ein Limit an Channels pro Verbindung), oder ein Channel mit
|
||||||
|
Conversation-ID im Payload? Wer hält die `LISTEN`-Verbindung, und was passiert, wenn
|
||||||
|
sie abreißt?
|
||||||
|
- **Wo wird die `conversation_events`-Zeile geschrieben?** [*Monotoner Cursor*](02-monotoner-cursor.md)
|
||||||
|
hat die Tabelle festgelegt, diese Frage aber bewusst hierher verschoben, weil sie mit
|
||||||
|
`NOTIFY` zusammenfällt. Also: im Handler in derselben Transaktion, oder per
|
||||||
|
Datenbank-Trigger? Ein Trigger feuert garantiert bei jedem Schreibweg, ein
|
||||||
|
Handler-Aufruf ist einfacher zu verstehen. Bei Trigger + `NOTIFY` fällt die zweite
|
||||||
|
Implementierung fast von selbst ab — das ist ein Argument, das geprüft gehört.
|
||||||
|
### Faktenlage aus der Auth-Recherche (14.08.2026)
|
||||||
|
|
||||||
|
Aus [`../research/03-ws-auth-reconnect.md`](../research/03-ws-auth-reconnect.md) §6, belegt:
|
||||||
|
|
||||||
|
- **`tokio::sync::watch` passt auffällig gut zum Dumb Pipe.** Es „retains only the last sent
|
||||||
|
value" — was exakt die Semantik von „der Cursor der Conversation X ist jetzt N, hol dir den
|
||||||
|
Rest per REST" ist. **Kein Lag-Fehler möglich**, weil Zwischenwerte per Konstruktion
|
||||||
|
entbehrlich sind. Das ist ein ernstzunehmender Kandidat gegen `broadcast` und sollte hier
|
||||||
|
gegen es antreten, nicht stillschweigend übergangen werden.
|
||||||
|
- **`RecvError::Lagged(n)` präzise:** `n` = Anzahl übersprungener Nachrichten, der Receiver
|
||||||
|
bleibt **brauchbar**, sein Cursor springt auf die älteste noch vorhandene Nachricht.
|
||||||
|
⚠️ **„Lagged = Resync" ist nirgends als offizielles Muster dokumentiert** — gängige
|
||||||
|
Community-Praxis ohne Primärquelle. Belegbar sind nur die Bausteine: `resubscribe()`
|
||||||
|
(verwirft alles Alte), `Receiver::len()` als Frühwarnung *vor* dem Datenverlust, und
|
||||||
|
`mpsc::try_send` pro Verbindung, um die Drop-Entscheidung **pro Client** statt global zu
|
||||||
|
treffen.
|
||||||
|
- **`broadcast` speichert einmal und klont pro Receiver** ⇒ der Event-Typ sollte billig
|
||||||
|
klonbar sein (`Arc<…>`, `bytes::Bytes`). Kapazität wird auf die nächste Zweierpotenz
|
||||||
|
aufgerundet.
|
||||||
|
- **`async_broadcast` scheidet vermutlich aus:** es bietet echte Backpressure, womit ein
|
||||||
|
langsamer WS-Client den **gesamten Producer** bremst (Head-of-Line über alle Clients) — das
|
||||||
|
widerspricht der Map-Entscheidung „Best-effort + `?since`-Backfill" frontal.
|
||||||
|
**`flume` scheidet sicher aus:** MPMC heißt dort Work-Queue, jede Nachricht geht an genau
|
||||||
|
einen Receiver — kein Broadcast.
|
||||||
|
- **`PgListener` (sqlx)** reconnected selbst und re-subscribed alle Channels, belegt **aber
|
||||||
|
eine dedizierte Postgres-Connection außerhalb des `PgPool`** (heute `max_connections = 10`)
|
||||||
|
— beim Sizing einplanen. `NOTIFY` ist transaktional, feuert also erst bei COMMIT, was gut
|
||||||
|
zur DB-als-Queue-Semantik passt. **Neue Gefahr:** wird die Notify-Queue voll (Listener
|
||||||
|
hängen), schlagen Transaktionen **beim Commit fehl**. Und `NOTIFY` liefert keine Historie —
|
||||||
|
ein verpasstes Signal ist weg, also dieselbe Resync-Frage eine Ebene tiefer.
|
||||||
|
|
||||||
|
- **Reihenfolge.** Garantiert das Fan-out die Reihenfolge der Events pro Conversation?
|
||||||
|
Falls nein: ist das egal, weil der Cursor monoton ist und der Client ohnehin nachlädt?
|
||||||
|
(Vermutlich ja — aber das explizit festhalten.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Answer
|
||||||
|
|
||||||
|
Entschieden am 14.08.2026.
|
||||||
|
|
||||||
|
### Vorbemerkung, die das ganze Ticket entschärft
|
||||||
|
|
||||||
|
Weil der Event `{conversation, seq}` ist und der Backfill über den Cursor läuft, zählt **nur
|
||||||
|
der jeweils neueste `seq` je Conversation**. 5, dann 6, dann 7 zuzustellen ist gleichwertig
|
||||||
|
damit, nur 7 zuzustellen. Das Fan-out darf zusammenfassen und sogar verlieren, ohne dass
|
||||||
|
Daten verlorengehen — schlimmstenfalls ist ein Client kurz veraltet, und das Manifest beim
|
||||||
|
nächsten Reconnect fängt alles auf.
|
||||||
|
|
||||||
|
Damit löst sich die `RecvError::Lagged`-Sorge aus
|
||||||
|
[*Recherche: WS-Auth, Heartbeat und Reconnect*](03-research-ws-auth-reconnect.md) weitgehend
|
||||||
|
auf: Lag ist hier kein Datenverlust, sondern Latenz.
|
||||||
|
|
||||||
|
### 1. Registry: `conversation_id → Verbindungen`, ein Kanal je Verbindung
|
||||||
|
|
||||||
|
Eine `HashMap<ConversationId, Set<Verbindung>>` im Serverzustand. Beim Verbindungsaufbau
|
||||||
|
werden die Mitgliedschaften einmal geladen und die Verbindung unter jeder eingetragen, beim
|
||||||
|
Trennen entfernt. Der Publisher schlägt einmal nach und stellt gezielt zu.
|
||||||
|
|
||||||
|
Entscheidend: **jede Verbindung hat genau einen Empfänger**. Die Verbindungsschleife bleibt
|
||||||
|
ein `select!` mit zwei Zweigen, statt über vierzig Kanäle gleichzeitig warten zu müssen. Die
|
||||||
|
Mitgliedschaftsliste braucht die Verbindung ohnehin zur Autorisierung — die Registry ist also
|
||||||
|
kein zusätzlicher Zustand.
|
||||||
|
|
||||||
|
Verworfen: ein Kanal je Conversation (eine Verbindung müsste auf N Empfängern warten,
|
||||||
|
`SelectAll` plus dynamisches An- und Abmelden während des Wartens) und ein globaler Kanal mit
|
||||||
|
Filter (zehn Zeilen Code, aber jede Verbindung wird bei jedem Event serverweit geweckt —
|
||||||
|
bei tausend Verbindungen und hundert Events pro Sekunde hunderttausend Aufwachvorgänge, die
|
||||||
|
fast alle nichts tun).
|
||||||
|
|
||||||
|
### 2. Zwei Registries, weil Mitgliedschaftswechsel anders adressiert werden
|
||||||
|
|
||||||
|
Ein Beitritt kann nicht über die Conversation-Registry laufen: der Nutzer ist dort noch nicht
|
||||||
|
eingetragen, das ist ja gerade der Punkt. Es gibt daher eine zweite Karte
|
||||||
|
`user_id → Verbindungen`. Mitgliedschafts-Events werden **pro Nutzer** zugestellt; die
|
||||||
|
Instanz, die diese Verbindung hält, trägt sie daraufhin in die Conversation-Registry ein oder
|
||||||
|
aus.
|
||||||
|
|
||||||
|
Damit funktioniert es auch über Instanzgrenzen: die Mitgliedschaftsänderung reist als `NOTIFY`
|
||||||
|
mit der `user_id` im Payload, und jede Instanz aktualisiert nur ihre eigenen Verbindungen.
|
||||||
|
|
||||||
|
### 3. Puffer je Verbindung: Dirty-Set mit `Notify`
|
||||||
|
|
||||||
|
Je Verbindung eine `HashMap<ConversationId, seq>` hinter einem Mutex plus ein `Notify`. Der
|
||||||
|
Publisher trägt ein und weckt; die Schreibtask leert die Karte und schickt einen Event je
|
||||||
|
Conversation.
|
||||||
|
|
||||||
|
Das kodiert „nur der neueste zählt" direkt im Datentyp: perfektes Zusammenfassen, kein
|
||||||
|
Verlust, Speicher begrenzt durch die Anzahl der Conversations statt durch die Event-Rate.
|
||||||
|
**Kein Überlauf, also kein Überlauf-Fehlerpfad.**
|
||||||
|
|
||||||
|
⚠️ **Wichtiges Detail:** der Eintrag ist `max(vorhandener, neuer)`, kein blindes `insert`.
|
||||||
|
`NOTIFY` garantiert Reihenfolge nur je Listener-Verbindung; ein blindes Überschreiben würde
|
||||||
|
sonst im ungünstigen Fall einen neueren `seq` durch einen älteren ersetzen.
|
||||||
|
|
||||||
|
Verworfen: `mpsc` + `try_send`. Bei vollem Puffer verwirft es den **neuen** Event und behält
|
||||||
|
die alten — also ausgerechnet den einzigen, auf den es ankommt, und im Log nicht von
|
||||||
|
Normalbetrieb unterscheidbar. Die Variante „bei Überlauf trennen" wäre korrekt, macht die
|
||||||
|
Puffergröße aber zur Stellschraube zwischen „trennt zu früh" und „merkt Blockade zu spät",
|
||||||
|
mit Pendelrisiko bei langsamen Mobilclients.
|
||||||
|
|
||||||
|
### 4. Log-Zeile im Handler, `NOTIFY` per Trigger auf `conversation_events`
|
||||||
|
|
||||||
|
Die Fachlogik bleibt in Rust: der Handler zieht den `seq`
|
||||||
|
(`UPDATE conversations … RETURNING`, was laut [*Monotoner Cursor*](02-monotoner-cursor.md)
|
||||||
|
ohnehin in seiner Transaktion passieren muss) und schreibt die Log-Zeile mit passendem `kind`.
|
||||||
|
|
||||||
|
Auf `conversation_events` sitzt ein Trigger, der **nichts** tut außer `pg_notify` zu feuern —
|
||||||
|
drei Zeilen PL/pgSQL ohne Fachlogik. Damit ist der Benachrichtigungspfad unvergesslich: wer
|
||||||
|
immer eine Log-Zeile schreibt, löst das Signal aus, auch künftige Handler und manuelle
|
||||||
|
Eingriffe. Und weil `NOTIFY` transaktional ist, feuert es erst beim Commit — genau dann, wenn
|
||||||
|
die Zeile sichtbar wird.
|
||||||
|
|
||||||
|
Verworfen: alles im Handler (jeder künftige Schreibweg müsste daran denken — die Sorte
|
||||||
|
Vergessen, die sich als „manchmal aktualisiert sich der Client nicht" äußert) und alles per
|
||||||
|
Trigger auf den Fachtabellen (die Kernlogik des Realtime-Layers läge dann in PL/pgSQL,
|
||||||
|
verteilt über drei Trigger, schlecht testbar und schwer zu lesen).
|
||||||
|
|
||||||
|
### 5. Kein Trait — immer über `NOTIFY`, auch bei einer Instanz
|
||||||
|
|
||||||
|
**Das revidiert eine Randbedingung der Map.** Sie sah „In-Process hinter einem Trait,
|
||||||
|
`NOTIFY` als zweite Implementierung" vor. Das wurde formuliert, bevor feststand, dass der
|
||||||
|
Event 60 Byte groß ist und der Trigger `NOTIFY` ohnehin feuert.
|
||||||
|
|
||||||
|
Der Server hört sich per `PgListener` selbst zu und speist die Registry. **Ein** Pfad für
|
||||||
|
Ein- und Mehr-Instanz-Betrieb: kein Trait, keine zweite Implementierung, und die
|
||||||
|
Mehr-Instanz-Fähigkeit ist ab Tag eins da statt nur vorgesehen. Der getestete Pfad ist
|
||||||
|
automatisch der Produktionspfad — der eigentliche Gewinn, denn beim Trait wäre ausgerechnet
|
||||||
|
der `NOTIFY`-Pfad beim ersten Skalierungsschritt der ungetestete gewesen.
|
||||||
|
|
||||||
|
Ein einziger Channel mit der Conversation-ID im Payload, nicht einer je Conversation:
|
||||||
|
dynamisches `LISTEN`/`UNLISTEN` über alle Conversations wäre unbegrenzt, und 60 Byte liegen
|
||||||
|
weit unter der 8-kB-Grenze.
|
||||||
|
|
||||||
|
**Betriebliche Kosten, bewusst akzeptiert:**
|
||||||
|
|
||||||
|
- Jeder Event läuft durch Postgres — auf demselben Host im Sub-Millisekunden-Bereich.
|
||||||
|
- `PgListener` belegt eine **dedizierte Verbindung außerhalb des Pools** (heute
|
||||||
|
`max_connections = 10`) — beim Sizing einplanen.
|
||||||
|
- Hängt ein Listener und die Notify-Queue läuft voll, **scheitern Transaktionen beim
|
||||||
|
Commit**. Das braucht Überwachung und gehört in die Fog-Notiz zur Observability.
|
||||||
|
|
||||||
|
⚠️ **Lücke, die diese Entscheidung einführt:** `PgListener` reconnected laut sqlx-Doku selbst,
|
||||||
|
aber Events während der Unterbrechung sind **weg** — `NOTIFY` hat keine Historie. Regel
|
||||||
|
daher: **nach jedem Listener-Reconnect schickt der Server allen Verbindungen einen
|
||||||
|
Resync-Hinweis**, woraufhin die Clients das Manifest neu ziehen. Das ist billig, selten, und
|
||||||
|
schließt die Lücke vollständig.
|
||||||
|
|
||||||
|
### 6. Auf- und Abräumen
|
||||||
|
|
||||||
|
- **Verbindungsaufbau:** Mitgliedschaften laden, in beide Registries eintragen.
|
||||||
|
- **Verbindungsabbau:** aus beiden entfernen. Abbrüche sind der Normalfall, nicht die
|
||||||
|
Ausnahme — Leaks in der Registry sind hier der klassische Fehler.
|
||||||
|
- **Mitgliedschaftswechsel während der Verbindung:** über die `user_id`-Registry, siehe §2.
|
||||||
|
- Eine leere Conversation-Menge wird aus der Karte entfernt, damit sie nicht unbegrenzt wächst.
|
||||||
|
|
||||||
|
### 7. Reihenfolge
|
||||||
|
|
||||||
|
Das Fan-out garantiert **keine** Reihenfolge über Instanzen hinweg und braucht sie auch
|
||||||
|
nicht: der Cursor ist monoton, der Client lädt ohnehin den aktuellen Zustand, und das
|
||||||
|
Dirty-Set nimmt das Maximum. Ein zu früh oder zu spät zugestellter Event kann nichts
|
||||||
|
beschädigen — er kann nur überflüssig sein.
|
||||||
255
docs/realtime/issues/09-voice-signaling-protokoll.md
Normal file
255
docs/realtime/issues/09-voice-signaling-protokoll.md
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
# Voice-Signaling-Protokoll
|
||||||
|
|
||||||
|
Type: grilling
|
||||||
|
Status: resolved
|
||||||
|
Blocked by: —
|
||||||
|
Parent: ../map.md
|
||||||
|
|
||||||
|
## Question
|
||||||
|
|
||||||
|
Wie sieht das flüchtige Signaling-Format aus, mit dem Clients einen Voice-Room betreten,
|
||||||
|
sich gegenseitig finden und SDP-/ICE-Daten austauschen?
|
||||||
|
|
||||||
|
Das ist die eine Stelle, an der echte Nutzlast über den Socket geht — der Dumb Pipe
|
||||||
|
trägt es nicht, weil ein SDP-Angebot flüchtig ist und in keiner Tabelle liegt.
|
||||||
|
|
||||||
|
**Entblockt am 14.08.2026.** Was aus [*Voice-Topologie*](04-voice-topologie.md) feststeht und
|
||||||
|
hier nicht neu aufgemacht wird:
|
||||||
|
|
||||||
|
- **Mesh, harte Obergrenze sechs Teilnehmer**, serverseitig durchgesetzt — der Beitritt wird
|
||||||
|
abgelehnt, nicht nur in der UI verhindert. Kein SFU-Vorbau; ein SFU wäre ein bewusster
|
||||||
|
Protokollbruch in einem eigenen Effort.
|
||||||
|
- **Eine Datenschutz-Politik je Instanz** (streng: `iceTransportPolicy: "relay"` + Opus
|
||||||
|
`cbr=1`/`usedtx=0`; offen: Standard-ICE + Standard-Opus). Sie muss über das Signaling an den
|
||||||
|
Client übermittelt werden, damit er sie umsetzen **und anzeigen** kann.
|
||||||
|
- Im strengen Modus sind **TURN-Zugangsdaten zwingend** — ihre Übermittlung gehört damit in
|
||||||
|
dieses Ticket.
|
||||||
|
|
||||||
|
### Zu entscheiden
|
||||||
|
|
||||||
|
- **Room-Modell.** Ist ein Voice-Room an eine Conversation gebunden („Sprachkanal dieses
|
||||||
|
Channels"), oder eine eigenständige Entität mit eigener Mitgliedschaft? Das erste ist
|
||||||
|
einfacher und passt zum bestehenden Datenmodell; das zweite erlaubt Ad-hoc-Anrufe
|
||||||
|
zwischen Leuten, die keine gemeinsame Conversation haben.
|
||||||
|
- **Persistenz.** Ist der Room-Zustand rein im Speicher (weg beim Neustart), oder liegt
|
||||||
|
er in der DB? In-Memory ist einfacher und passt zur Flüchtigkeit — aber beim Neustart
|
||||||
|
fallen alle Gespräche, und bei Mehr-Instanz-Betrieb später ist In-Memory ein Problem.
|
||||||
|
- **Teilnehmerliste.** Das ist die **einzige** Presence-Form im Scope dieses Efforts
|
||||||
|
(allgemeine Chat-Presence liegt in der Fog). Wie erfährt ein Beitretender, wer schon
|
||||||
|
drin ist? Wie erfahren die anderen von ihm? Was passiert bei einem
|
||||||
|
Verbindungsabbruch — ab wann gilt jemand als weg?
|
||||||
|
- **Nachrichtentypen.** Minimal nötig: `join`, `leave`, `participants`, `offer`,
|
||||||
|
`answer`, `ice-candidate`. Braucht es mehr — `mute`, `speaking`, `renegotiate`?
|
||||||
|
- **Adressierung.** SDP und ICE-Kandidaten gehen an einen *bestimmten* Peer, nicht an
|
||||||
|
den Raum. Wie werden Peers identifiziert — User-ID reicht nicht, weil Multi-Device
|
||||||
|
gefordert ist. Also eine Verbindungs- oder Session-ID.
|
||||||
|
- **Wer bietet an?** Bei n Teilnehmern muss geregelt sein, wer bei einem Beitritt das
|
||||||
|
Angebot macht, sonst gibt es Glare (beide bieten gleichzeitig). Deterministische Regel
|
||||||
|
festlegen.
|
||||||
|
- **TURN-Credentials.** Wenn TURN konfiguriert ist: wie kommen zeitbeschränkte
|
||||||
|
Credentials zum Client? Gehört das in dieses Protokoll oder in einen REST-Endpunkt?
|
||||||
|
Und was ist das Verhalten, wenn **kein** TURN konfiguriert ist — der Nutzer muss früh
|
||||||
|
und ehrlich erfahren, dass die Verbindung scheitern kann, statt in einen Timeout zu laufen.
|
||||||
|
- **Transport.** Derselbe WebSocket wie der Dumb Pipe, oder ein zweiter Endpunkt? Ein
|
||||||
|
Socket spart Auth- und Reconnect-Verwaltung; zwei trennen die Belange sauberer. Hängt
|
||||||
|
auch an *Wo lebt die WS-Verbindung im Client* — falls Chat in Rust und Voice in JS
|
||||||
|
liegt, ist die Frage bereits beantwortet.
|
||||||
|
|
||||||
|
### Harte Randbedingung: DTLS-Fingerprint-Pinning
|
||||||
|
|
||||||
|
Aus [*Recherche: WebRTC im Tauri-WebView (Mobile)*](01-research-webrtc-tauri-mobile.md) §7.4,
|
||||||
|
und für dieses Projekt der wichtigste Sicherheitsbefund überhaupt:
|
||||||
|
|
||||||
|
**RFC 8827 benennt den Angriff wörtlich** — ein On-Path-Angreifer kann die DTLS-SRTP-Fingerprints
|
||||||
|
im Handshake austauschen und die Identität beider Endpunkte ersetzen. Der Signaling-Server
|
||||||
|
relayed die SDP, kann also `a=fingerprint:` tauschen und zwei separate DTLS-Sitzungen
|
||||||
|
terminieren: vollständiges MITM auf dem Audio, ohne dass irgendein WebRTC-Mechanismus anschlägt.
|
||||||
|
Bei einem Bedrohungsmodell, in dem der Betreiber der Angreifer ist, ist das kein Randfall,
|
||||||
|
sondern der Hauptfall.
|
||||||
|
|
||||||
|
**Der spezifizierte Gegenmechanismus existiert nicht.** W3C *Identity for WebRTC 1.0* steht
|
||||||
|
seit 2018 unverändert als Candidate Recommendation; `setIdentityProvider` / `peerIdentity`
|
||||||
|
sind in Chrome, Safari und Android WebView **nicht implementiert** — also in keiner der vier
|
||||||
|
Ziel-Engines.
|
||||||
|
|
||||||
|
Was das Protokoll deshalb leisten muss:
|
||||||
|
|
||||||
|
1. Fingerprint deterministisch vorab bestimmen (`RTCPeerConnection.generateCertificate()` +
|
||||||
|
`getFingerprints()`), statt ihn aus `localDescription.sdp` zu lesen.
|
||||||
|
2. Ihn **durch einen E2EE-authentifizierten Kanal** übertragen, signiert mit dem
|
||||||
|
Langzeit-Identity-Key — nicht im Klartext-SDP, das der Server relayed.
|
||||||
|
3. Beim Empfang gegen den Fingerprint im relayed SDP vergleichen. Mismatch ⇒ Call abbrechen
|
||||||
|
und als Sicherheitsereignis anzeigen, nicht still weiterlaufen lassen.
|
||||||
|
4. **Key Continuity** (RFC 8827): Fingerprints pro Gerät über Calls hinweg cachen, bei
|
||||||
|
Änderung warnen.
|
||||||
|
5. Optional eine **SAS** (Emoji-/Wortliste aus dem Hash beider Fingerprints, à la ZRTP)
|
||||||
|
für den paranoiden Pfad — verteidigt zusätzlich gegen einen kompromittierten Identity-Key.
|
||||||
|
|
||||||
|
Genau so löst es Matrix in MSC3401/Element Call: Call-SDP läuft über verschlüsselte
|
||||||
|
To-Device-Messages, der Homeserver sieht den Fingerprint nie im Klartext.
|
||||||
|
|
||||||
|
**Konsequenz für die Effort-Grenzen:** Punkt 2 braucht einen E2EE-Kanal, den es heute nicht
|
||||||
|
gibt — das volle E2EE-Design ist ein Folge-Effort. Dieses Ticket muss die **Naht** festlegen
|
||||||
|
(wo im Signaling-Fluss der signierte Fingerprint sitzt, wie ein Mismatch behandelt wird),
|
||||||
|
kann sie aber nicht einlösen. Sicheres Voice ist damit von der Fertigstellung des
|
||||||
|
E2EE-Efforts abhängig — das explizit festhalten, statt es implizit zu lassen.
|
||||||
|
|
||||||
|
### Randbedingung aus *Voice auf Linux*
|
||||||
|
|
||||||
|
Es gibt **zwei Medienpfade**: `RTCPeerConnection` im WebView auf Windows, Android und iOS,
|
||||||
|
und `livekit/rust-sdks` im Rust-Layer auf Linux. Das Signaling-Protokoll muss beide bedienen.
|
||||||
|
|
||||||
|
Konkret für das Fingerprint-Pinning oben: der signierte DTLS-Fingerprint muss auf **beiden**
|
||||||
|
Pfaden erzeugbar sein — im WebView über `RTCPeerConnection.generateCertificate()`, nativ über
|
||||||
|
die Zertifikats-API von libwebrtc. Die Naht darf also nicht an einer WebView-API hängen.
|
||||||
|
|
||||||
|
### Randbedingung
|
||||||
|
|
||||||
|
**Kein Audio.** Codecs, Echo-Cancellation, Audio-Pipeline und SFU-Betrieb sind out of
|
||||||
|
scope. Dieses Ticket endet bei „zwei Clients haben genug Information, um eine
|
||||||
|
`RTCPeerConnection` aufzubauen".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Answer
|
||||||
|
|
||||||
|
Entschieden am 14.08.2026.
|
||||||
|
|
||||||
|
### 1. Der Voice-Room ist eine eigenständige Entität mit zwei möglichen Eltern
|
||||||
|
|
||||||
|
**Discord-Modell:** ein Sprachkanal hängt am **Workspace** und ist per Default für alle
|
||||||
|
Workspace-Mitglieder sichtbar und betretbar. Zusätzlich kann ein Raum an einer **Conversation**
|
||||||
|
hängen — das ist der Direktanruf aus einer DM oder Gruppe heraus.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE voice_rooms (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE,
|
||||||
|
name TEXT,
|
||||||
|
created_by UUID NOT NULL REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CHECK ((workspace_id IS NULL) <> (conversation_id IS NULL))
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Genau eines der beiden Elternfelder ist gesetzt — dasselbe Muster, das `conversations` heute
|
||||||
|
schon benutzt (dort ist `workspace_id` für DMs null). **Eine Entität, ein Signaling-Protokoll,
|
||||||
|
eine Prüfstelle für die Autorisierung**, die nur danach verzweigt, welcher Elternteil gesetzt
|
||||||
|
ist.
|
||||||
|
|
||||||
|
**Lebenszyklus:** Workspace-Räume sind dauerhaft (ein Ort, den man betritt und verlässt).
|
||||||
|
Conversation-Räume entstehen mit dem Anruf und werden beim Auflegen abgeräumt.
|
||||||
|
|
||||||
|
⚠️ **Die Autorisierung muss durch genau eine Funktion laufen.** Später sollen Rollen
|
||||||
|
*Conversations und Voice-Rooms gleichermaßen* einschränken — es darf deshalb **kein
|
||||||
|
raumspezifisches Berechtigungsmodell** entstehen, sonst gibt es zwei Systeme, die auseinander
|
||||||
|
laufen. Heute prüft diese Funktion Workspace- bzw. Conversation-Mitgliedschaft; das
|
||||||
|
Rollenmodell setzt später an derselben Stelle an.
|
||||||
|
|
||||||
|
### 2. Raumzustand in Postgres, aufgeräumt über den bestehenden Heartbeat
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE voice_participants (
|
||||||
|
room_id UUID NOT NULL REFERENCES voice_rooms(id) ON DELETE CASCADE,
|
||||||
|
session_id UUID NOT NULL,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (room_id, session_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Über Instanzen hinweg konsistent per Konstruktion — nötig, weil das Fan-out seit
|
||||||
|
[*Fan-out-Architektur*](08-fan-out-architektur.md) immer über `NOTIFY` läuft und
|
||||||
|
Mehr-Instanz-Betrieb damit ab Tag eins möglich ist. Die Teilnehmerliste, die ein Beitretender
|
||||||
|
für seine Angebote braucht, ist eine simple Abfrage.
|
||||||
|
|
||||||
|
**Geisterzeilen** nach einem Instanz-Absturz fängt `last_seen` ab, das der bestehende
|
||||||
|
15-Sekunden-Heartbeat aus [*Verbindungsaufbau & Auth*](07-verbindungsaufbau-und-auth.md)
|
||||||
|
gratis mitführt: wer zwei Intervalle fehlt, wird entfernt. Bei sauberem Socket-Schluss geht
|
||||||
|
der Abgang sofort.
|
||||||
|
|
||||||
|
Verworfen: In-Memory je Instanz (`NOTIFY` hat keine Historie — nach einem Listener-Reconnect
|
||||||
|
fehlt einer Instanz die Sicht, und abgestürzte Instanzen hinterlassen unaufräumbare Geister)
|
||||||
|
und Raum-zu-Instanz-Bindung (bräuchte selbst wieder geteilten Zustand plus eine Umleitung, und
|
||||||
|
ein Instanz-Absturz nähme den ganzen Raum mit statt ihn nur auszudünnen).
|
||||||
|
|
||||||
|
### 3. Adressierung über `session_id`, nicht `user_id`
|
||||||
|
|
||||||
|
Multi-Device ist gefordert — derselbe Nutzer kann mit Desktop und Handy im selben Raum sein.
|
||||||
|
SDP und ICE-Kandidaten gehen deshalb an eine **Verbindung**, nicht an ein Konto. Jede
|
||||||
|
WebSocket-Verbindung bekommt beim Aufbau eine `session_id`; sie ist die Peer-Identität im
|
||||||
|
Signaling.
|
||||||
|
|
||||||
|
### 4. Der Beitretende bietet an
|
||||||
|
|
||||||
|
Wer beitritt, erhält die Teilnehmerliste und sendet n−1 Angebote; die Anwesenden antworten
|
||||||
|
nur. Eine Regel statt einer Aushandlung darüber, wer aushandelt — und der Beitretende ist der
|
||||||
|
Einzige, der in diesem Moment etwas Neues weiß.
|
||||||
|
|
||||||
|
Kollisionen können praktisch nicht entstehen, weil es reines Audio ohne mitten im Gespräch
|
||||||
|
wechselnde Spuren gibt. Der einzige verbleibende Fall ist ein ICE-Neustart: **wer neu startet,
|
||||||
|
bietet an.**
|
||||||
|
|
||||||
|
⚠️ Das ist eine **Konvention, kein Mechanismus**. Kämen später Bildschirmfreigabe oder Video
|
||||||
|
hinzu, muss sie durch Perfect Negotiation (höflich/unhöflich per `session_id`-Vergleich)
|
||||||
|
ersetzt werden. Als Notiz festgehalten, nicht heute gebaut.
|
||||||
|
|
||||||
|
### 5. Fingerprint-Pinning: Naht jetzt, Füllung durch den E2EE-Effort
|
||||||
|
|
||||||
|
Das Protokoll sieht das Feld für den signierten DTLS-Fingerprint und den Abgleich gegen das
|
||||||
|
relayte SDP **von Anfang an** vor. Solange kein Identitätsschlüssel existiert, bleibt es leer,
|
||||||
|
und der Client zeigt **dauerhaft und nicht wegklickbar** an, dass das Gespräch nicht
|
||||||
|
verifiziert ist.
|
||||||
|
|
||||||
|
Begründung gegen die verlockende Alternative (eine kleine Identitätsschicht nur für Voice):
|
||||||
|
der Identitätsschlüssel ist das Kernstück des E2EE-Efforts. Ihn separat für Voice zu erzeugen
|
||||||
|
hieße, dass Nutzer später zwei Identitäten und zwei Verifikationsrituale haben — oder dass
|
||||||
|
eine davon migriert werden muss, und das ist bei Schlüsseln der unangenehmste aller
|
||||||
|
Migrationsfälle.
|
||||||
|
|
||||||
|
**Bewusst in Kauf genommen und in der Map vermerkt:** bis der E2EE-Effort steht, ist Voice
|
||||||
|
gegen den Betreiber ungeschützt. Sichtbar, nicht stillschweigend.
|
||||||
|
|
||||||
|
Beide Medienpfade aus [*Voice auf Linux*](11-voice-auf-linux.md) müssen den Fingerprint
|
||||||
|
deterministisch vorab erzeugen können — im WebView über
|
||||||
|
`RTCPeerConnection.generateCertificate()`, nativ über die Zertifikats-API von libwebrtc. Die
|
||||||
|
Naht darf nicht an einer WebView-API hängen.
|
||||||
|
|
||||||
|
### 6. Nachrichtensatz (abgeleitet)
|
||||||
|
|
||||||
|
Client → Server: `room.join` · `room.leave` · `peer.signal` (trägt Offer, Answer oder
|
||||||
|
ICE-Kandidat, adressiert an eine `session_id`)
|
||||||
|
|
||||||
|
Server → Client: `room.joined` (Antwort auf `join`: Teilnehmerliste, Politik,
|
||||||
|
TURN-Zugangsdaten) · `room.participant_added` · `room.participant_removed` · `peer.signal`
|
||||||
|
(weitergereicht) · `room.rejected` (Raum voll, nicht berechtigt)
|
||||||
|
|
||||||
|
`mute` und `speaking` sind **nicht** enthalten: Stummschaltung ist lokal, und
|
||||||
|
Sprechaktivität würde die Information über den Server leiten, die
|
||||||
|
[*Voice-Topologie*](04-voice-topologie.md) im strengen Modus gerade per Opus-CBR versteckt.
|
||||||
|
Beides gehört, falls gewünscht, über die Peer-Verbindung statt über das Signaling.
|
||||||
|
|
||||||
|
### 7. Politik und TURN-Zugangsdaten in der Beitrittsantwort (abgeleitet)
|
||||||
|
|
||||||
|
`room.joined` trägt die Instanz-Politik aus [*Voice-Topologie*](04-voice-topologie.md)
|
||||||
|
(*streng* → `iceTransportPolicy: "relay"` und Opus `cbr=1`/`usedtx=0`; *offen* → Standard) —
|
||||||
|
der Client setzt sie um **und zeigt sie an**.
|
||||||
|
|
||||||
|
Im strengen Modus sind TURN-Zugangsdaten zwingend und reisen mit: **zeitbeschränkte
|
||||||
|
HMAC-Credentials** nach dem üblichen coturn-Muster, damit nichts Dauerhaftes im Client liegt.
|
||||||
|
Erneuerung erfolgt durch erneuten Beitritt, nicht durch einen eigenen Mechanismus.
|
||||||
|
|
||||||
|
### 8. Raumgröße serverseitig durchgesetzt
|
||||||
|
|
||||||
|
Die Obergrenze von sechs wird beim `room.join` geprüft und mit `room.rejected` abgelehnt —
|
||||||
|
**nicht** nur in der Oberfläche verhindert.
|
||||||
|
|
||||||
|
### Offene Kopplung zum E2EE-Effort
|
||||||
|
|
||||||
|
`voice_rooms.name` ist nutzererzeugter Inhalt und fiele damit unter die Blob-Regel aus
|
||||||
|
[*Dumb-Pipe-Event & Backfill*](06-dumb-pipe-event-und-backfill.md). Der Server braucht ihn
|
||||||
|
nicht — aber ein neues Workspace-Mitglied müsste die Raumliste lesen können, ohne vorher
|
||||||
|
Schlüsselmaterial zu haben. Das ist ein Schlüsselverwaltungsproblem und gehört dem
|
||||||
|
E2EE-Effort; hier nur festgehalten, damit es dort nicht übersehen wird.
|
||||||
121
docs/realtime/issues/10-client-integration.md
Normal file
121
docs/realtime/issues/10-client-integration.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# Client-Integration: `refreshKey` ersetzen
|
||||||
|
|
||||||
|
Type: prototype
|
||||||
|
Status: resolved
|
||||||
|
Blocked by: —
|
||||||
|
Parent: ../map.md
|
||||||
|
|
||||||
|
## Question
|
||||||
|
|
||||||
|
Wie sieht der Frontend-State aus, wenn Nachrichten gepusht statt nachgeladen werden —
|
||||||
|
und wie fühlt sich das bei N gleichzeitig verbundenen Servern an?
|
||||||
|
|
||||||
|
Ein Prototyp, kein Produktionscode. Ziel ist, an etwas Konkretem zu sehen, ob das
|
||||||
|
Zustandsmodell trägt, bevor die Spec es festschreibt.
|
||||||
|
|
||||||
|
### Ausgangslage
|
||||||
|
|
||||||
|
`ChatPage.tsx` hält heute `refreshKey` als Zähler und reicht ihn an `MessageList`
|
||||||
|
durch; `MessageInput` zählt nach dem Senden hoch. Das ist der Hack, dessen Verschwinden
|
||||||
|
den ganzen Effort motiviert.
|
||||||
|
|
||||||
|
**Zugeschnitten am 14.08.2026** durch [*Wo lebt die WS-Verbindung im Client*](05-wo-lebt-die-ws-verbindung.md):
|
||||||
|
die Verbindung und der Backfill liegen in Rust, das Frontend hat **keine Cursor-Logik** und
|
||||||
|
kein Debouncing. Es bekommt aufgelöste Entitäten per `emit` plus ein separates
|
||||||
|
`resync`-Signal. Das schrumpft dieses Ticket erheblich — und verschiebt eine Frage hierher:
|
||||||
|
**soll Rust für Conversations nachladen, die gar nicht sichtbar sind?** Das weiß nur das
|
||||||
|
Frontend, also muss es diese Information nach unten reichen.
|
||||||
|
|
||||||
|
### Zu klären
|
||||||
|
|
||||||
|
- **Zustandsform.** Wo leben Nachrichten — pro Conversation gecacht, oder nur die
|
||||||
|
gerade offene? Was passiert beim Wechsel zwischen Conversations? Braucht es überhaupt
|
||||||
|
eine State-Bibliothek, oder reicht React-State plus ein Cursor pro Conversation?
|
||||||
|
- **Ungelesen-Anzeige.** Der Dumb-Pipe-Event trifft auch für Conversations ein, die
|
||||||
|
nicht offen sind. Wird dann nachgeladen (kostet Requests) oder nur ein Punkt gesetzt
|
||||||
|
und erst beim Öffnen geladen? Zweiteres wirkt richtig — am Prototyp prüfen.
|
||||||
|
- **Optimistisches Senden.** Erscheint die eigene Nachricht sofort, und wie wird sie
|
||||||
|
mit der später gepushten Version zusammengeführt, ohne zu doppeln oder zu springen?
|
||||||
|
- **Multi-Server im UI.** N Server heißt N Conversation-Listen, N Verbindungszustände,
|
||||||
|
N Fehlerfälle. Wie sieht das aus, wenn ein Server nicht erreichbar ist, während die
|
||||||
|
anderen laufen? Das ist der Punkt, an dem das Multi-Server-Modell konkret wird.
|
||||||
|
- **Verbindungszustand sichtbar machen.** „Verbunden", „reconnecting", „offline" — was
|
||||||
|
sieht der Nutzer, und wo?
|
||||||
|
- **Koaleszenz.** Zehn Events in zwei Sekunden sollen einen Fetch auslösen, nicht zehn.
|
||||||
|
Wo sitzt das Debouncing, und mit welcher Verzögerung fühlt es sich noch sofort an?
|
||||||
|
|
||||||
|
### Erwartetes Ergebnis
|
||||||
|
|
||||||
|
Ein Wegwerf-Prototyp, der die Zustandsform greifbar macht, plus eine kurze Notiz, was
|
||||||
|
er über das Zustandsmodell gezeigt hat. Der Prototyp wird als Asset verlinkt, nicht in
|
||||||
|
die Antwort kopiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Answer
|
||||||
|
|
||||||
|
Entschieden am 14.08.2026.
|
||||||
|
|
||||||
|
**Asset:** `PROTOTYPE-frontend-zustandsmodell.html` im Repo `rustification-client`,
|
||||||
|
auf dem Wegwerf-Branch `prototype/frontend-zustandsmodell` (bewusst nicht auf `master`) — eine
|
||||||
|
eigenständige HTML-Datei, im Browser zu öffnen. Freies Ausprobieren plus sechs geführte
|
||||||
|
Durchläufe. Der Reducer im ersten `<script>`-Block ist der übernehmbare Teil; die Seite
|
||||||
|
drumherum ist Wegwerfcode.
|
||||||
|
|
||||||
|
Geprüft: die Datei läuft fehlerfrei (nur ein Favicon-404). Die beiden entscheidenden
|
||||||
|
Durchläufe wurden durchgeklickt, siehe unten.
|
||||||
|
|
||||||
|
### Was der Prototyp aufgedeckt hat
|
||||||
|
|
||||||
|
**1. Der Client muss die Nachrichten-ID vergeben.** Das war vorher keine Frage — es wurde
|
||||||
|
eine, sobald optimistisches Senden konkret wurde. Vergibt der Server die ID, hat der Client
|
||||||
|
beim Echo nichts, woran er seine eigene Nachricht wiedererkennt: sie steht dann zweimal da,
|
||||||
|
einmal mit `seq` und einmal ohne. Im Prototyp beide Fälle nachgestellt und bestätigt.
|
||||||
|
|
||||||
|
Folge für die Spec: `messages.id` wird vom Client geliefert, nicht per
|
||||||
|
`DEFAULT gen_random_uuid()` erzeugt. **Nebengewinn:** damit wird das Senden idempotent —
|
||||||
|
ein Wiederholungsversuch nach unklarem Netzfehler ist ein No-op statt eines Duplikats, was
|
||||||
|
gerade auf Mobile den Unterschied macht.
|
||||||
|
|
||||||
|
⚠️ Client-gelieferte IDs sind eine Eingabe an einer Vertrauensgrenze. Der Primärschlüssel
|
||||||
|
weist Kollisionen von selbst ab, aber der Handler muss das als **Konflikt** behandeln und
|
||||||
|
nicht als Serverfehler — und die ID auf UUID-Form prüfen, statt sie ungeprüft zu übernehmen.
|
||||||
|
Auf einem öffentlichen Server ist das keine Formalie.
|
||||||
|
|
||||||
|
**2. Eine Ungelesen-*Zahl* ist ohne Nachladen nicht ableitbar.** `seq` zählt **Events**,
|
||||||
|
nicht Nachrichten — Reactions, Bearbeitungen und später Read-Receipts erhöhen ihn mit. Aus
|
||||||
|
der Differenz zwischen altem Cursor und neuem `seq` lässt sich also kein „3 neue
|
||||||
|
Nachrichten" berechnen, nur „hier hat sich etwas getan". Das war im Entwurf implizit falsch
|
||||||
|
angenommen.
|
||||||
|
|
||||||
|
**Entscheidung daraus:** Rust lädt **für DMs** nach (Zahl möglich), für Channels und Gruppen
|
||||||
|
nicht (nur ein Punkt). Das ist dasselbe Discord-Verhalten wie beim Raummodell, die Regel
|
||||||
|
kostet nichts, weil `conversations.kind` sie schon hergibt, und sie begrenzt die Last dort,
|
||||||
|
wo sie entsteht: ein aktiver Channel feuert ständig, eine DM selten. Sobald es Erwähnungen
|
||||||
|
gibt, kommen die nach derselben Logik dazu.
|
||||||
|
|
||||||
|
### Was der Prototyp bestätigt hat
|
||||||
|
|
||||||
|
- **Bearbeitete Nachrichten springen nicht.** Die Sortierung hängt an `messages.seq`, nicht
|
||||||
|
am Änderungszeitpunkt — eine Bearbeitung bewegt die Nachricht nur im Cursor-Raum, nicht im
|
||||||
|
Verlauf.
|
||||||
|
- **Löschen erzeugt einen Grabstein, keine Lücke.**
|
||||||
|
- **Der Zustand ist pro Server, nicht global.** Ein ausgefallener Server lässt die anderen
|
||||||
|
unberührt; „verdrängt" (4002) ist ein Endzustand, aus dem bewusst nicht neu verbunden wird.
|
||||||
|
- **Das Frontend rechnet nichts aus.** Es setzt gelieferte Entitäten ein und ersetzt anhand
|
||||||
|
der ID. Keine Cursor-Logik, kein Debouncing — beides liegt seit
|
||||||
|
[*Wo lebt die WS-Verbindung im Client*](05-wo-lebt-die-ws-verbindung.md) in Rust.
|
||||||
|
|
||||||
|
### Zustandsform
|
||||||
|
|
||||||
|
Pro Server ein Verbindungszustand; darunter je Conversation `{ geladen, ungelesen,
|
||||||
|
nachrichten[] }`. `ungelesen` ist entweder eine Zahl (DMs) oder eine Markierung (Channels) —
|
||||||
|
die Zweiwertigkeit ist keine Nachlässigkeit, sondern der Befund aus Punkt 2. Kein
|
||||||
|
Zustandsbibliothek nötig: React-State reicht, weil es keinen abgeleiteten Zustand gibt.
|
||||||
|
|
||||||
|
### Offen geblieben
|
||||||
|
|
||||||
|
Der `resync`-Pfad verwirft im Prototyp den Zwischenspeicher der betroffenen Conversation und
|
||||||
|
lädt beim nächsten Öffnen neu. Ob das für die gerade **sichtbare** Conversation sichtbar
|
||||||
|
ruckelt (kurz leer, dann voll), lässt sich am Zustandsmodell nicht beantworten — das ist eine
|
||||||
|
Frage der Oberfläche und gehört in die Umsetzung.
|
||||||
165
docs/realtime/issues/11-voice-auf-linux.md
Normal file
165
docs/realtime/issues/11-voice-auf-linux.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# Voice auf Linux
|
||||||
|
|
||||||
|
Type: grilling
|
||||||
|
Status: resolved
|
||||||
|
Blocked by: —
|
||||||
|
Parent: ../map.md
|
||||||
|
|
||||||
|
## Question
|
||||||
|
|
||||||
|
Was ist die Zusage an Linux-Nutzer bezüglich Sprachanrufen — und wenn Voice dort laufen
|
||||||
|
soll, über welchen Medien-Stack?
|
||||||
|
|
||||||
|
Aufgeworfen von [*Recherche: WebRTC im Tauri-WebView (Mobile)*](01-research-webrtc-tauri-mobile.md).
|
||||||
|
Die erwartete schlechte Nachricht kam nicht von Mobile, sondern von Linux.
|
||||||
|
|
||||||
|
### Befundlage (belegt, siehe §5 der Recherche)
|
||||||
|
|
||||||
|
WebRTC ist in Tauris Linux-WebView (WebKitGTK 4.1) an **drei unabhängigen Gates** blockiert:
|
||||||
|
|
||||||
|
1. **Compile-Time.** `ENABLE_WEB_RTC` folgt `ENABLE_EXPERIMENTAL_FEATURES`, das auf `OFF`
|
||||||
|
steht. Debian/Ubuntu und Alpine setzen es belegbar nicht → `RTCPeerConnection` existiert
|
||||||
|
im JS-Global gar nicht. Arch/Fedora unverifiziert (Experiment E5 in der Recherche).
|
||||||
|
2. **Runtime-Setting.** `WebKitSettings:enable-webrtc` ist Default `FALSE` und wird von wry
|
||||||
|
nie gesetzt.
|
||||||
|
3. **Permission.** `permission-request` ist Default Deny; wry 0.55.1 verbindet das Signal
|
||||||
|
überhaupt nicht.
|
||||||
|
|
||||||
|
Gate 2 und 3 wären aus dem App-Code über `WebviewWindow::with_webview` lösbar (bzw. ab
|
||||||
|
Tauri 2.12 sauber über `on_permission_request`). **Gate 1 hängt am Distro-Paket des
|
||||||
|
Nutzers und ist von uns nicht lösbar.**
|
||||||
|
|
||||||
|
Das trifft ausgerechnet die Gruppe, die für dieses Projekt zentral ist: Selbsthoster sind
|
||||||
|
linux-lastig.
|
||||||
|
|
||||||
|
### Optionen, alle unattraktiv
|
||||||
|
|
||||||
|
1. **Voice auf Linux als nicht unterstützt deklarieren.** Chat läuft voll, Voice nicht.
|
||||||
|
Ehrlich und billig — und wahrscheinlich der Weg, der die Simplizitätsdoktrin am wenigsten
|
||||||
|
verletzt. Aber schmerzhaft für die Kernzielgruppe.
|
||||||
|
2. **Nativer Medien-Stack nur auf Linux.** `livekit/rust-sdks` (`libwebrtc`-Crate, echtes
|
||||||
|
libwebrtc mit NetEQ/AEC/Opus, laut README inkl. Linux) im Tauri-Rust-Layer. Bricht die
|
||||||
|
Architektur-Einheitlichkeit: Voice läge auf drei Plattformen im WebView und auf einer in
|
||||||
|
Rust. Zusätzlich ein großer vorkompilierter nativer Blob pro Plattform.
|
||||||
|
*(`webrtc-rs` ist hierfür **kein** Ersatz — es ist ein reiner Protokoll-Stack ohne
|
||||||
|
Geräte-Capture, ohne Codecs, ohne Jitter-Buffer, ohne Echo-Cancellation.)*
|
||||||
|
3. **Nativer Stack auf allen Plattformen.** Einheitlich, aber verwirft den kostenlosen
|
||||||
|
WebView-Pfad auf drei von vier Zielen und zieht den nativen Blob überallhin.
|
||||||
|
4. **Eigenes WebKitGTK bündeln.** Widerspricht der Simplizitätsdoktrin frontal.
|
||||||
|
5. **Auf Upstream warten.** Löst Gate 2 und 3, **nie Gate 1**. Also keine Lösung.
|
||||||
|
|
||||||
|
### Zu entscheiden
|
||||||
|
|
||||||
|
- Welche Option — und wie wird die Einschränkung dem Nutzer gegenüber **sichtbar** gemacht,
|
||||||
|
statt ihn in einen Timeout laufen zu lassen? (Feature-Detection `typeof RTCPeerConnection`
|
||||||
|
ist trivial und sollte in jedem Fall passieren.)
|
||||||
|
- Falls (2) oder (3): wo verläuft dann die Rust↔WebView-Naht? Bei nativem Audio ist der
|
||||||
|
WebView **nicht mehr der Medien-Endpunkt** — Capture und Wiedergabe liegen in Rust, und
|
||||||
|
die UI bekommt nur heruntergerechnete Metering-Werte über IPC, niemals rohes PCM.
|
||||||
|
Zurück-Injizieren von PCM in den WebView geht in Chromium, aber **nicht** in WebKitGTK.
|
||||||
|
Rückkopplung auf [*Wo lebt die WS-Verbindung im Client*](05-wo-lebt-die-ws-verbindung.md).
|
||||||
|
**Beantwortet (14.08.2026): Linux ist eine vollwertige Client-Plattform.** Damit ist der
|
||||||
|
billige Ausweg verbaut — Option 1 bedeutet, auf einer erklärten Zielplattform ein
|
||||||
|
Kernfeature nicht anzubieten. Sie bleibt formal wählbar, muss aber als bewusster
|
||||||
|
Produktverzicht begründet werden, nicht als technische Notwendigkeit.
|
||||||
|
|
||||||
|
Realistisch bleiben damit **(2)** nativer Stack nur auf Linux und **(3)** nativer Stack
|
||||||
|
überall. Die eigentliche Frage dieses Tickets ist die Abwägung zwischen diesen beiden:
|
||||||
|
Einheitlichkeit gegen Kostenminimierung.
|
||||||
|
|
||||||
|
### Kopplung
|
||||||
|
|
||||||
|
Wirkt auf [*Voice-Topologie*](04-voice-topologie.md) zurück: fällt hier ein nativer Pfad,
|
||||||
|
ändern sich dort die Optionen. Die beiden Tickets können in beliebiger Reihenfolge laufen,
|
||||||
|
aber das zweite muss das Ergebnis des ersten kennen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Answer
|
||||||
|
|
||||||
|
Entschieden am 14.08.2026.
|
||||||
|
|
||||||
|
### 0. Zusätzlich geprüft: Flatpak löst Gate 1 nicht
|
||||||
|
|
||||||
|
Die Recherche hatte Gate 1 als „hängt am Distro-Paket" eingestuft. Das gilt aber nur, solange
|
||||||
|
der Nutzer das Distro-Paket benutzt — ein Flatpak bringt WebKitGTK aus seinem Runtime mit.
|
||||||
|
Geprüft, Ergebnis negativ:
|
||||||
|
|
||||||
|
- Das **GNOME-Runtime baut WebKitGTK ohne den WebRTC-Flag**; die Antwort im Flathub-Forum auf
|
||||||
|
die Frage nach dem Patchen lautete sinngemäß „macht ein Issue bei gnome-build-meta auf"
|
||||||
|
([Flathub Discourse](https://discourse.flathub.org/t/patching-gnome-runtime/10682)).
|
||||||
|
- Das einzige dokumentierte funktionierende Rezept verlangt, **WebKitGTK selbst zu
|
||||||
|
kompilieren** (`-DENABLE_MEDIA_STREAM=ON -DENABLE_WEB_RTC=ON`) **und zusätzlich wry zu
|
||||||
|
patchen** — erprobt auf NixOS unter X11, mit GBM-Fehlern unter Wayland und nicht
|
||||||
|
aufgelisteten v4l2loopback-Geräten
|
||||||
|
([Tauri Discussion #8426](https://github.com/tauri-apps/tauri/discussions/8426)).
|
||||||
|
|
||||||
|
Für ein Entwicklungssystem machbar, für Endnutzer nicht. **Gate 1 hat heute keine
|
||||||
|
nutzerseitige Lösung.**
|
||||||
|
|
||||||
|
### 1. WebView auf drei Plattformen, nativer Stack nur auf Linux
|
||||||
|
|
||||||
|
Windows, Android und iOS nutzen den kostenlosen, funktionierenden WebView-Pfad. Linux bekommt
|
||||||
|
`livekit/rust-sdks` (`libwebrtc`-Crate) im Rust-Layer — echtes libwebrtc mit NetEQ, AEC/AGC/NS
|
||||||
|
und Opus, laut README inklusive Linux.
|
||||||
|
|
||||||
|
`webrtc-rs` ist **kein** Ersatz: reiner Protokoll-Stack ohne Geräte-Capture, ohne Codecs, ohne
|
||||||
|
Jitter-Buffer, ohne Echo-Cancellation.
|
||||||
|
|
||||||
|
Die Alternative „nativ überall" war knapp und hat ein starkes Argument — ein Pfad statt zwei,
|
||||||
|
eine Berechtigungslogik, eine Testmatrix, und die wry-Eigenheit (Mikrofonrechte werden auf iOS
|
||||||
|
bedingungslos an jeden Origin vergeben) wäre komplett aus dem Spiel. Sie verliert, weil sie
|
||||||
|
einen auf drei Plattformen funktionierenden Gratis-Pfad wegwirft. **Der akzeptierte Preis sind
|
||||||
|
zwei Medienpfade auf Dauer**, mit zwei Berechtigungsmodellen und zwei Fehlerbildern.
|
||||||
|
|
||||||
|
**Nützliche Nebenwirkung:** weil Linux den nativen Stack *unbedingt* nutzt und nicht nur dort,
|
||||||
|
wo die WebView-Variante versagt, spielt es keine Rolle mehr, ob eine Distribution WebKitGTK
|
||||||
|
mit WebRTC gebaut hat. **Experiment E5 der Recherche (Arch/Fedora prüfen) entfällt damit.**
|
||||||
|
Und weil es hier nur um Audio geht, ist der GBM-/Wayland-Fehler aus #8426 gegenstandslos — er
|
||||||
|
betrifft die Videoausgabe des WebViews.
|
||||||
|
|
||||||
|
### 2. Die Naht ist eine explizite Verzweigung im Frontend, ganz oben
|
||||||
|
|
||||||
|
Rust meldet dem Frontend eine Fähigkeit — sinngemäß `mediaOwner: "webview" | "native" | "none"`
|
||||||
|
— und das Voice-Modul verzweigt **einmal**, an seiner Spitze. Darunter zwei klar getrennte
|
||||||
|
Implementierungen: `RTCPeerConnection` im WebView, oder Tauri-Commands in den nativen Stack.
|
||||||
|
|
||||||
|
Das ist ehrlich gegenüber der Realität, dass sich die Fälle nicht nur mechanisch
|
||||||
|
unterscheiden, sondern auch in der Oberfläche: **auf Linux gibt es kein `<audio>`-Element**,
|
||||||
|
weil PCM sich in WebKitGTK nicht zurückinjizieren lässt (in Chromium ginge es). Der native
|
||||||
|
Pfad besitzt dort die Wiedergabe vollständig, und Pegelanzeigen kommen als
|
||||||
|
heruntergerechnete Werte über IPC — **niemals rohes PCM**.
|
||||||
|
|
||||||
|
Verworfen: ein `MediaEngine`-Trait in Rust. Es wäre die sauberere Fassade, verlangte aber eine
|
||||||
|
Steuerungsumkehr — die WebView-Implementierung müsste ihre eigentliche Arbeit per IPC nach
|
||||||
|
oben ans Frontend delegieren. Ebenfalls verworfen: eine Naht nur auf Signaling-Ebene, weil die
|
||||||
|
Duplizierung dann bis in die Oberfläche reicht und Verhaltensunterschiede versehentlich statt
|
||||||
|
absichtlich entstehen.
|
||||||
|
|
||||||
|
### 3. Gestaffelt: Voice erscheint zuerst auf den WebView-Plattformen
|
||||||
|
|
||||||
|
Linux zeigt Voice solange sauber als „noch nicht verfügbar" an — über die gemeldete Fähigkeit
|
||||||
|
`none`, **nicht** über eine WebView-Feature-Erkennung, weil die auf Linux dauerhaft negativ
|
||||||
|
bliebe und nichts über den nativen Pfad aussagt. Chat läuft dort voll.
|
||||||
|
|
||||||
|
Das Argument ist Risikoreihenfolge: der WebView-Pfad validiert das Signaling-Protokoll billig
|
||||||
|
auf drei Plattformen, und der native Stack steckt danach in ein bereits bewährtes Protokoll.
|
||||||
|
Den schweren, unerprobten Medien-Stack gegen ein gleichzeitig unerprobtes Protokoll zu bauen,
|
||||||
|
verdoppelt die Unbekannten in einem Schritt — und bei einem Fehler wüsste niemand, welche
|
||||||
|
Seite schuld ist.
|
||||||
|
|
||||||
|
**Bewusst in Kauf genommen:** die Zielgruppe ist linuxlastig, und „Voice, aber nicht bei dir"
|
||||||
|
ist keine gute erste Begegnung. Wer es einmal als „geht auf Linux nicht" abgespeichert hat,
|
||||||
|
prüft später nicht nach. Das ist der Preis dafür, das Feature überhaupt in Nutzerhände zu
|
||||||
|
bringen, bevor Monate in den nativen Stack fließen — und er ist erträglich, weil die Ansage
|
||||||
|
ehrlich und sichtbar ist statt stillschweigend.
|
||||||
|
|
||||||
|
### Folgen für andere Tickets
|
||||||
|
|
||||||
|
- [*Voice-Topologie*](04-voice-topologie.md): der native Stack liefert auf Linux echtes
|
||||||
|
libwebrtc, Mesh funktioniert dort also genauso. Die Topologie-Entscheidung wird durch dieses
|
||||||
|
Ticket **nicht** eingeschränkt.
|
||||||
|
- [*Voice-Signaling-Protokoll*](09-voice-signaling-protokoll.md): der signierte
|
||||||
|
DTLS-Fingerprint muss auf **beiden** Pfaden erzeugbar sein — im WebView über
|
||||||
|
`RTCPeerConnection.generateCertificate()`, nativ über die Zertifikats-API von libwebrtc. Die
|
||||||
|
Naht für das Pinning darf also nicht an einer WebView-API hängen.
|
||||||
325
docs/realtime/map.md
Normal file
325
docs/realtime/map.md
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
# Map: WebSocket-Realtime-Layer + Voice-Signaling-Fundament
|
||||||
|
|
||||||
|
**Status: abgeschlossen.** Alle elf Tickets sind aufgelöst; das Ziel — eine
|
||||||
|
implementierbare Spec — ist erreicht. Dieses Verzeichnis ist die Begründungsschicht
|
||||||
|
zum Realtime-Code: *warum* etwas so gebaut ist, samt der verworfenen Alternativen.
|
||||||
|
Offene Arbeit steht nicht hier, sondern als Issue im GitLab.
|
||||||
|
|
||||||
|
Der Effort umfasst beide Repos (`rustification-server` und `rustification-client`);
|
||||||
|
die Dokumente liegen hier, weil das Gewicht der Spec serverseitig ist.
|
||||||
|
|
||||||
|
Entstanden mit `/wayfinder`; Label dort: `wayfinder:map`.
|
||||||
|
|
||||||
|
## Destination
|
||||||
|
|
||||||
|
Eine implementierbare Spec für einen WebSocket-Realtime-Layer **neben** dem bestehenden
|
||||||
|
REST-API (Nachrichten-Push für alles Persistierte) plus das **Voice-Signaling-Fundament**
|
||||||
|
(Room join/leave, Teilnehmerliste, SDP/ICE-Relay — kein Audio), zugeschnitten für den
|
||||||
|
Tauri-Client auf Desktop und Mobile im Foreground.
|
||||||
|
|
||||||
|
Fertig, wenn jemand die Spec ohne Rückfragen implementieren kann und der spätere
|
||||||
|
E2EE-Effort sie nicht ungültig macht.
|
||||||
|
|
||||||
|
**Dieser Effort plant, er baut nicht.** Jedes Ticket beantwortet eine Entscheidung.
|
||||||
|
Die Umsetzung ist ein eigener Effort.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
### Produktkontext
|
||||||
|
|
||||||
|
Rustification ist als Alternative zu Matrix gedacht, mit zwei erklärten Erfolgsfaktoren:
|
||||||
|
|
||||||
|
1. **Deployment-Simplizität** — wie leicht, schnell und reproduzierbar ist ein Server
|
||||||
|
aufgesetzt und einsatzbereit?
|
||||||
|
2. **Nutzersicherheit** — Schutz vor Angriffen und Data-Leaks.
|
||||||
|
|
||||||
|
Zielbild: Firmen, die mit sensiblen Daten umgehen (eigene Instanz), und eine öffentliche
|
||||||
|
Discord-Alternative für Privatnutzer. Jeder Nutzer soll einen eigenen Server hosten können.
|
||||||
|
|
||||||
|
**Bedrohungsmodell: der Serverbetreiber ist ein Angreifer.** Das ist der Grund, warum
|
||||||
|
echtes E2EE gerechtfertigt ist und nicht bloß Verschlüsselung at rest.
|
||||||
|
|
||||||
|
### Codebases
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `rustification-server` | Rust, Axum 0.8, SQLx 0.9 / PostgreSQL 17, JWT + Argon2, Tokio. Migrationen embedded. |
|
||||||
|
| `rustification-client` | Tauri v2. Frontend React 18 + TS (Vite). Client-Backend Rust, ruft REST via `reqwest`. Ziele: Windows, Linux, Android, iOS. |
|
||||||
|
|
||||||
|
Stand heute: **Polling.** `MessageList.tsx:33` fährt ein `setInterval(load, 3000)`,
|
||||||
|
zusätzlich zählt `ChatPage.tsx` nach dem Senden einen `refreshKey` hoch. Beides wird
|
||||||
|
ersetzt. (Der Client-README beschreibt darüber hinaus Member-Panel und Settings-Seite,
|
||||||
|
die es im Code noch nicht gibt.)
|
||||||
|
|
||||||
|
Weitere Bestandsfakten aus den Recherchen: `axum = "0.8.9"` ist **ohne `ws`-Feature**
|
||||||
|
eingebunden, es gibt keinen Graceful Shutdown, und `Extension<Claims>` wird nirgends gelesen.
|
||||||
|
Auf Client-Seite existieren `src-tauri/gen/android` und `gen/apple` noch nicht — Mobile ist
|
||||||
|
nie initialisiert worden, es gibt also nichts zu migrieren. Und es gibt weder WebRTC- noch
|
||||||
|
WebSocket-Code: grüne Wiese.
|
||||||
|
|
||||||
|
### Feststehende Randbedingungen
|
||||||
|
|
||||||
|
Diese sind **entschieden** und werden von den Tickets nicht neu aufgemacht:
|
||||||
|
|
||||||
|
| Thema | Entscheidung |
|
||||||
|
|---|---|
|
||||||
|
| REST vs. WS | REST bleibt für Auth, CRUD und History. WS macht Push + Voice-Signaling. |
|
||||||
|
| Protokollform | **Dumb Pipe** `{conversation, cursor}` für alles Persistierte; eigenes flüchtiges Format nur für Voice-Signaling. Die Naht verläuft entlang *persistiert vs. flüchtig*. |
|
||||||
|
| Deployment | **Harte Regel: eine Binary + Postgres.** TURN ist optionale, extern konfigurierbare Abhängigkeit — ohne sie läuft Chat voll, Voice eingeschränkt. |
|
||||||
|
| Zustellung | Best-effort + `?since`-Backfill nach Reconnect. Keine Server-Queue, keine ACKs — die DB *ist* die Queue. |
|
||||||
|
| Fan-out | **Revidiert am 14.08.2026 durch *Fan-out-Architektur*:** kein Trait, keine zweite Implementierung — **immer** über `pg_notify`, auch bei einer Instanz. Ein Trigger auf `conversation_events` feuert, der Server hört sich per `PgListener` selbst zu. Ein Pfad für Ein- und Mehr-Instanz-Betrieb, der getestete Pfad ist der Produktionspfad. Kein Redis. *(Ursprünglich: In-Process hinter einem Trait, `NOTIFY` als zweite Impl — formuliert, bevor feststand, dass der Event 60 Byte groß ist und der Trigger ohnehin feuert.)* |
|
||||||
|
| Server-Modell | **Keine Föderation.** Der *Client* hält N Verbindungen zu N unabhängigen Servern; kein Server-zu-Server-Protokoll. |
|
||||||
|
| Krypto | Nachrichteninhalte sind im Protokoll undurchsichtige Blobs. Metadaten darf der Server sehen. Volles E2EE-Design ist ein Folge-Effort. |
|
||||||
|
| Mobile | Foreground ja. Background-Delivery und Push out of scope. |
|
||||||
|
| Bestandsdaten | Keine. Migrationen dürfen brechen. |
|
||||||
|
| Client-Plattformen | Windows, Linux, Android, iOS — **Linux ist vollwertige Client-Plattform**, nicht nur Server-Host (bestätigt 14.08.2026). Das verbaut den billigen Ausweg bei *Voice auf Linux*. |
|
||||||
|
| **Keine fremden Origins im WebView** | wry vergibt Mikrofonrechte ohne App-Beteiligung — auf iOS in 0.55.1 bedingungslos `Grant` für jeden Origin. Solange nur eigener gebündelter Code läuft, ist das harmlos; ein `<iframe>`, eine Link-Vorschau oder eine Einbettung fremder Inhalte bekäme sofort Mikrofonzugriff, ohne dass die App gefragt wird. Ab Tauri 2.12 (`on_permission_request`) kontrollierbar — bis dahin ist diese Regel der einzige Schutz. |
|
||||||
|
|
||||||
|
### Skills pro Session
|
||||||
|
|
||||||
|
Jede Session, die ein Ticket bearbeitet: `/grilling` und `/domain-modeling`.
|
||||||
|
Recherche-Tickets: `/research` als Subagent. Prototyp-Tickets: `/prototype`.
|
||||||
|
|
||||||
|
### Council-Verdikt (14.08.2026)
|
||||||
|
|
||||||
|
Position: *Zuschnitt grundsätzlich richtig, aber zu groß und an einer Stelle sachlich
|
||||||
|
falsch — Protokoll auf einen generischen Änderungs-Event reduzieren, Mobile-WebRTC-
|
||||||
|
Recherche vor alle Voice-Entscheidungen ziehen.* Konfidenz 71 %.
|
||||||
|
|
||||||
|
Drei kritische Risiken:
|
||||||
|
|
||||||
|
1. **Cursor-Lücke** — **aufgelöst am 14.08.2026**: Zähler pro Conversation unter Zeilen-Lock,
|
||||||
|
damit ist Commit-Reihenfolge gleich Cursor-Reihenfolge. Siehe *Monotoner Cursor*.
|
||||||
|
2. **Mobile-WebRTC-Unbekannte** — **aufgelöst am 14.08.2026, positiv**: Android, iOS und
|
||||||
|
Windows tragen WebRTC im WebView. An seine Stelle tritt ein neues Risiko, das die
|
||||||
|
Recherche gefunden hat: **Linux trägt es nicht**, und Linux ist die Heimplattform der
|
||||||
|
Zielgruppe Selbsthoster. → *Voice auf Linux*.
|
||||||
|
3. **Bedrohungsmodell-Lücke** — **aufgelöst**: der Betreiber ist Angreifer (siehe oben).
|
||||||
|
|
||||||
|
Korrektur am Verdikt: das Argument „Dumb Pipe ist E2EE-agnostisch, weil kein Inhalt
|
||||||
|
durch den Socket geht" ist **falsch** — unter E2EE ist der Inhalt ohnehin ein Blob, den
|
||||||
|
der Server nicht lesen kann. Der Dumb Pipe gewinnt bei Simplizität, `NOTIFY`-Tauglichkeit
|
||||||
|
und Cache-Konsistenz, nicht bei der Sicherheit.
|
||||||
|
|
||||||
|
Minderheitsvotum (🎨 Visionary): *„Matrix ist seit zehn Jahren exakt dieses Produkt —
|
||||||
|
ein Matrix-Client in Tauri bringt dich in einem Wochenende weiter."* Bewusst verworfen:
|
||||||
|
Föderation ist der Grund für Matrix' Betriebsaufwand, und Simplizität im Deployment ist
|
||||||
|
hier das Differenzierungsmerkmal.
|
||||||
|
|
||||||
|
## Decisions so far
|
||||||
|
|
||||||
|
<!-- eine Zeile pro geschlossenem Ticket: Gist + Link -->
|
||||||
|
|
||||||
|
- [Recherche: WebRTC im Tauri-WebView (Mobile)](issues/01-research-webrtc-tauri-mobile.md) —
|
||||||
|
**iOS, Android und Windows tragen WebRTC im WebView** (iOS ab 14.3 mit
|
||||||
|
`NSMicrophoneUsageDescription`; Android ab API 21 mit `RECORD_AUDIO` **und**
|
||||||
|
`MODIFY_AUDIO_SETTINGS`; Windows ohne Konfiguration). **Linux nicht** — drei unabhängige
|
||||||
|
Gates in WebKitGTK, eines davon distro-seitig unlösbar. Council-Risiko 2 positiv
|
||||||
|
aufgelöst. Zwei Befunde mit Folgen: der Signaling-Server kann DTLS-Fingerprints tauschen
|
||||||
|
(RFC 8827) und der spezifizierte Gegenmechanismus ist nirgends implementiert; wry vergibt
|
||||||
|
Mikrofonrechte ohne App-Beteiligung.
|
||||||
|
Details: [`research/01-webrtc-tauri-mobile.md`](research/01-webrtc-tauri-mobile.md)
|
||||||
|
- [Recherche: WS-Auth, Heartbeat und Reconnect](issues/03-research-ws-auth-reconnect.md) —
|
||||||
|
Faktenlage zu allen sechs Teilfragen, ohne Empfehlung. Vier Befunde mit Folgen: **(a)** der
|
||||||
|
`Authorization`-Header ist der einzige Kanal, der in den Proxy-Defaults nicht mitgeloggt wird
|
||||||
|
(Caddy redigiert ihn aktiv, `request.uri` nicht) — und `Sec-WebSocket-Protocol` wird sehr wohl
|
||||||
|
geloggt; **(b)** die Browser-API muss laut WHATWG alle Fehlerdetails verschlucken, im WebView
|
||||||
|
ist „Token abgelaufen" nicht von „Server down" unterscheidbar, im Rust-Layer schon;
|
||||||
|
**(c)** `axum::serve(...).with_graceful_shutdown(...)` beendet WS-Verbindungen nicht und
|
||||||
|
wartet nicht auf sie — eigenes Tracking ist zwingend; **(d)** `tokio::sync::watch` passt
|
||||||
|
semantisch besser zum Dumb Pipe als `broadcast`.
|
||||||
|
Details: [`research/03-ws-auth-reconnect.md`](research/03-ws-auth-reconnect.md)
|
||||||
|
- [Monotoner Cursor](issues/02-monotoner-cursor.md) — **ein Zähler `seq` pro Conversation,
|
||||||
|
vergeben per `UPDATE conversations SET seq = seq + 1 … RETURNING` in derselben Transaktion
|
||||||
|
wie die Änderung.** Der Zeilen-Lock erzwingt Commit-Reihenfolge == `seq`-Reihenfolge; die
|
||||||
|
Korrektheit folgt aus der Konstruktion. Die Änderungsachse ist eine neue Tabelle
|
||||||
|
`conversation_events` (die Log-Zeile *ist* der Dumb-Pipe-Event), nicht eine Spalte auf
|
||||||
|
`messages` — nur so werden Edits, Soft-Deletes und Reactions vom Backfill erfasst.
|
||||||
|
`?since=N` liefert **aufgelösten Zustand inkl. Tombstones**, nicht rohe Events: idempotent
|
||||||
|
und koaleszierend, und das Log bleibt reine Signalisierung. Keine Retention, dafür ein
|
||||||
|
Antwortlimit mit `resync`-Antwort — derselbe Pfad bedient auch Erstverbindung und
|
||||||
|
`RecvError::Lagged`. **Nebenbefund: `created_at` verliert seine Sortierrolle** — es ist
|
||||||
|
`NOW()`, also Transaktionsstart, und die heutige Sortierung in `list_messages` ist damit
|
||||||
|
schon jetzt falsch. Council-Risiko 1 aufgelöst.
|
||||||
|
- [Wo lebt die WS-Verbindung im Client](issues/05-wo-lebt-die-ws-verbindung.md) — **eigene
|
||||||
|
Implementierung im Tauri-Rust-Layer** (`tokio-tungstenite`), Pool im `AppState`, Zustellung
|
||||||
|
per `emit`. Entscheidend waren `Authorization: Bearer` (einziger Kanal, den die
|
||||||
|
Proxy-Defaults nicht loggen) und dass `tungstenite::Error::Http` die volle Antwort trägt,
|
||||||
|
während der WebView laut WHATWG nur `1006` sehen darf. `tauri-plugin-websocket` geprüft und
|
||||||
|
verworfen (dünne API, dokumentierter Nachrichtenverlust, von den Maintainern erklärtermaßen
|
||||||
|
nicht gepflegt). **Voice-Signaling läuft ebenfalls über diesen Socket**, nur die
|
||||||
|
`RTCPeerConnection` lebt im WebView — die im Ticket vermutete Spannung löst sich auf.
|
||||||
|
**Rust macht den Backfill selbst und emittiert aufgelöste Entitäten**, die Cursor-Buchhaltung
|
||||||
|
liegt also bei Verbindung und Token statt im Frontend. Alle konfigurierten Server sind im
|
||||||
|
Vordergrund verbunden. `BASE_URL` wird zur Server-Liste im `tauri-plugin-store`;
|
||||||
|
Geheimnisse kommen hinter ein `SecretStore`-Trait, dessen Implementierung der E2EE-Effort
|
||||||
|
wählt.
|
||||||
|
- [Dumb-Pipe-Event & Backfill-Endpunkt](issues/06-dumb-pipe-event-und-backfill.md) — Event ist
|
||||||
|
**minimal `{conversation, seq}`** (~60 Byte, JSON, keine Protokollversion); `kind` und
|
||||||
|
`target_id` bewusst weggelassen, weil additiv nachrüstbar. `conversation_events.kind` bleibt
|
||||||
|
damit serverintern. Aufholen nach Reconnect läuft über ein **Manifest** (`GET /sync` liefert
|
||||||
|
`{conversation_id, seq}` für alle Mitgliedschaften — eine Abfrage) plus gezielte Backfills:
|
||||||
|
bei 40 Conversations und 3 geänderten 1+3 Requests statt 40. **`resync` ist ein
|
||||||
|
unterschiedener Antworttyp bei 200**, kein 4xx — es ist der Normalfall bei jeder
|
||||||
|
Erstverbindung und darf nicht durch Fehlerpfade laufen. Limit 500 Entitäten, Tombstones
|
||||||
|
werden mitgeliefert. **Klartext-Grenze konkret gezogen** und damit bindend für den
|
||||||
|
E2EE-Effort: Blob werden `messages.content`, `attachments.filename`, `attachments.mime_type`
|
||||||
|
und `reactions.emoji` (deterministisch, damit der `UNIQUE`-Constraint greift); Routing-,
|
||||||
|
Ordnungs- und Autorisierungsfelder plus Zeitstempel bleiben lesbar. Drei Endpunkte:
|
||||||
|
`/sync`, `/conversations/{id}/changes`, `/conversations/{id}/messages` (seitenweise).
|
||||||
|
- [Fan-out-Architektur](issues/08-fan-out-architektur.md) — Registry
|
||||||
|
`conversation_id → Verbindungen` plus eine zweite `user_id → Verbindungen` für
|
||||||
|
Mitgliedschaftswechsel (ein Beitretender steht noch nicht in der ersten). Je Verbindung ein
|
||||||
|
**Dirty-Set** `HashMap<Conversation, seq>` + `Notify` statt eines Kanals — perfektes
|
||||||
|
Zusammenfassen, kein Überlauf, kein Überlauf-Fehlerpfad; Eintrag als `max()`, weil `NOTIFY`
|
||||||
|
Reihenfolge nur je Listener garantiert. Log-Zeile schreibt der Handler, `pg_notify` feuert
|
||||||
|
ein dreizeiliger Trigger auf `conversation_events` — damit ist der Signalpfad unvergesslich.
|
||||||
|
**Der Trait ist gestrichen** (siehe Randbedingung Fan-out oben). Zwei akzeptierte Kosten:
|
||||||
|
`PgListener` belegt eine dedizierte Verbindung außerhalb des Pools, und eine volle
|
||||||
|
Notify-Queue lässt Transaktionen beim Commit scheitern. Eine eingeführte Lücke ist
|
||||||
|
geschlossen: **nach jedem Listener-Reconnect geht ein Resync-Hinweis an alle Verbindungen**,
|
||||||
|
weil `NOTIFY` keine Historie hat.
|
||||||
|
**Tragende Einsicht des Tickets:** weil nur der neueste `seq` je Conversation zählt, darf
|
||||||
|
das Fan-out zusammenfassen und sogar verlieren — Lag ist Latenz, nicht Datenverlust.
|
||||||
|
- [Verbindungsaufbau & Auth über WS](issues/07-verbindungsaufbau-und-auth.md) —
|
||||||
|
`Authorization: Bearer` im Handshake. **In-Band-Re-Auth** statt Verbindungs-TTL: der Client
|
||||||
|
schiebt das erneuerte Token über den ohnehin bidirektionalen Socket, statt bei 15 Minuten
|
||||||
|
Tokenlebensdauer zwölfmal pro Stunde neu zu verbinden; Widerruf wirkt trotzdem innerhalb der
|
||||||
|
Restlaufzeit. **Autorisierung liegt bei den REST-Endpunkten**, nicht bei der Registry — der
|
||||||
|
Event trägt keinen Inhalt, Registry-Drift leckt also höchstens Metadaten.
|
||||||
|
**Client pingt alle 15 s** (Protokoll-Ping, in Rust verfügbar; 15 s wegen des striktesten
|
||||||
|
belegten Proxy-Idle-Timeouts von 20 s), Server führt `last_seen`. **Acht Verbindungen je
|
||||||
|
Nutzer, die älteste weicht** — nicht die neue abweisen, sonst sperrt ein hängengebliebener
|
||||||
|
Socket das Gerät aus. **Eigene Close-Codes, drei Handlungsklassen** (sofort / Backoff /
|
||||||
|
gar nicht), Full Jitter beim Backoff. Graceful Shutdown wird selbst gebaut
|
||||||
|
(`CancellationToken` + `TaskTracker`), weil axum WS-Verbindungen nicht abwartet.
|
||||||
|
- [Voice auf Linux](issues/11-voice-auf-linux.md) — **Flatpak zusätzlich geprüft und als Lösung
|
||||||
|
ausgeschlossen**: das GNOME-Runtime baut WebKitGTK ohne WebRTC-Flag, und das einzige
|
||||||
|
funktionierende Rezept verlangt, WebKitGTK selbst zu kompilieren *und* wry zu patchen.
|
||||||
|
Gate 1 hat keine nutzerseitige Lösung. Entscheidung: **WebView-Pfad auf Windows, Android und
|
||||||
|
iOS; nativer Stack (`livekit/rust-sdks`) nur auf Linux** — der Gratis-Pfad auf drei
|
||||||
|
Plattformen wiegt schwerer als die Einheitlichkeit; akzeptierter Preis sind zwei Medienpfade
|
||||||
|
auf Dauer. Die Naht ist **eine explizite Verzweigung im Frontend**, gesteuert über eine von
|
||||||
|
Rust gemeldete Fähigkeit (`webview` / `native` / `none`), kein Trait mit Steuerungsumkehr.
|
||||||
|
**Gestaffelt:** Voice erscheint zuerst auf den drei WebView-Plattformen, Linux zeigt es
|
||||||
|
ehrlich als noch nicht verfügbar — der WebView-Pfad validiert das Signaling-Protokoll billig,
|
||||||
|
bevor der schwere native Stack hineinsteckt. **Nebenwirkung: Experiment E5 der WebRTC-Recherche
|
||||||
|
(Arch/Fedora prüfen) entfällt**, weil Linux den nativen Stack unbedingt nutzt.
|
||||||
|
- [Voice-Topologie](issues/04-voice-topologie.md) — **Mesh, harte Obergrenze sechs
|
||||||
|
Teilnehmer**, serverseitig durchgesetzt; der Server fasst nie Audio an. **Kein spekulativer
|
||||||
|
SFU-Vorbau**: ein SFU-Protokoll ist client-zu-Server adressiert, ein Mesh-Protokoll
|
||||||
|
peer-zu-peer — eine „SFU-taugliche Naht" heute wäre Selbsttäuschung. Ein SFU wäre ein
|
||||||
|
bewusster Protokollbruch in einem eigenen Effort, nicht ausgeschlossen, aber auch nicht
|
||||||
|
vorbereitet. Dazu **eine Datenschutz-Politik je Instanz**, vom Server gesetzt und im
|
||||||
|
Signaling übermittelt: *streng* = `iceTransportPolicy: "relay"` **plus** Opus `cbr=1`/`usedtx=0`,
|
||||||
|
*offen* = Standard-ICE und Standard-Opus. Die Kopplung folgt der Bedrohung — beide Lecks
|
||||||
|
entstehen am selben Ort: über das Relay sieht der Betreiber Paketgrößen und damit
|
||||||
|
Sprechaktivität, direkt sieht er nichts, dafür lernt der Peer die IP. Der Client **zeigt den
|
||||||
|
Modus an**. TURN bleibt damit optional: ohne TURN läuft Voice im offenen Modus eingeschränkt,
|
||||||
|
im strengen gar nicht — und der Betreiber legt den Schalter selbst um.
|
||||||
|
- [Voice-Signaling-Protokoll](issues/09-voice-signaling-protokoll.md) — **Discord-Modell:**
|
||||||
|
`voice_rooms` ist eine eigenständige Entität mit *zwei möglichen Eltern* — `workspace_id`
|
||||||
|
(dauerhafter Sprachkanal, für alle Workspace-Mitglieder sichtbar und betretbar) **oder**
|
||||||
|
`conversation_id` (Direktanruf aus DM/Gruppe, endet beim Auflegen), per `CHECK` genau eines.
|
||||||
|
Das spiegelt das Muster, das `conversations` schon benutzt. **Autorisierung läuft durch genau
|
||||||
|
eine Funktion**, weil Rollen später Conversations *und* Räume gleichermaßen einschränken
|
||||||
|
sollen. Raumzustand in `voice_participants` in Postgres — nötig, weil Mehr-Instanz-Betrieb
|
||||||
|
seit Ticket 08 ab Tag eins möglich ist; Geisterzeilen fängt `last_seen` über den bestehenden
|
||||||
|
15-s-Heartbeat ab. Adressierung über **`session_id`, nicht `user_id`** (Multi-Device).
|
||||||
|
**Der Beitretende bietet an** — Konvention, kein Mechanismus; bei Video/Screenshare später
|
||||||
|
durch Perfect Negotiation zu ersetzen. **Fingerprint-Pinning: Naht jetzt, Füllung durch den
|
||||||
|
E2EE-Effort** — bis dahin zeigt der Client dauerhaft und nicht wegklickbar an, dass das
|
||||||
|
Gespräch unverifiziert ist. Eine eigene Voice-Identität wurde bewusst verworfen, weil Nutzer
|
||||||
|
sonst später zwei Identitäten und zwei Verifikationsrituale hätten. Politik und
|
||||||
|
zeitbeschränkte TURN-Credentials reisen in der Beitrittsantwort; Raumgröße wird serverseitig
|
||||||
|
durchgesetzt.
|
||||||
|
- [Client-Integration](issues/10-client-integration.md) *(Prototyp:
|
||||||
|
`rustification-client`, Branch `prototype/frontend-zustandsmodell`)* — **Der Client vergibt die
|
||||||
|
Nachrichten-ID**, nicht der Server. Der Prototyp hat gezeigt, dass optimistisches Senden
|
||||||
|
sonst nicht zusammenführbar ist: dieselbe Nachricht steht zweimal da, einmal mit `seq` und
|
||||||
|
einmal ohne. `messages.id` kommt damit vom Client statt aus `DEFAULT gen_random_uuid()` —
|
||||||
|
Nebengewinn: Senden wird idempotent, ein Wiederholungsversuch ist ein No-op.
|
||||||
|
**Zweiter Befund: eine Ungelesen-Zahl ist ohne Nachladen nicht ableitbar**, weil `seq`
|
||||||
|
Events zählt und nicht Nachrichten. Daraus die Regel: Rust lädt **für DMs** nach (Zahl),
|
||||||
|
für Channels und Gruppen nicht (nur ein Punkt) — dasselbe Discord-Verhalten wie beim
|
||||||
|
Raummodell, und `conversations.kind` gibt die Unterscheidung schon her. Zustandsform: je
|
||||||
|
Server ein Verbindungszustand, je Conversation `{geladen, ungelesen, nachrichten[]}`; keine
|
||||||
|
Zustandsbibliothek nötig, weil es keinen abgeleiteten Zustand gibt.
|
||||||
|
|
||||||
|
## Not yet specified
|
||||||
|
|
||||||
|
- **Presence, Typing-Indikator und Read-Receipts als Event-Typen.** Bewusst hinter das
|
||||||
|
erste lauffähige Push geschoben — es sind Goodies, nicht der Hauptfokus. Read-Receipts
|
||||||
|
brauchen zusätzlich ein Schema (`message_reads` existiert nicht). **Haben seit
|
||||||
|
*Monotoner Cursor* ein Zuhause:** sie werden `kind`-Werte in `conversation_events`, das
|
||||||
|
Log trägt sie ohne Umbau mit. Wird spezifizierbar, sobald
|
||||||
|
*Dumb-Pipe-Event & Backfill-Endpunkt* steht. **Ausnahme:** die Teilnehmerliste
|
||||||
|
eines Voice-Rooms ist nicht dasselbe wie Chat-Presence und bleibt im Scope von
|
||||||
|
*Voice-Signaling-Protokoll*.
|
||||||
|
- **Verhalten beim Mobile-Resume aus dem Hintergrund.** Was genau passiert, wenn die App
|
||||||
|
nach Minuten oder Stunden zurückkommt — Reconnect-Backoff, Cursor-Sprung, UI-Zustand.
|
||||||
|
Hängt an *Dumb-Pipe-Event & Backfill-Endpunkt* und *Verbindungsaufbau & Auth über WS*.
|
||||||
|
- **Rate-Limiting auf Handshakes.** *Verbindungsaufbau & Auth über WS* hat das Limit pro
|
||||||
|
Nutzer entschieden (acht, älteste weicht), ein Rate-Limit auf Verbindungsversuche aber
|
||||||
|
bewusst offengelassen: es trifft ein anderes Missbrauchsmuster und ist ohne Betriebserfahrung
|
||||||
|
nicht kalibrierbar — ein Reconnect-Sturm nach einem Neustart sieht aus wie ein Angriff.
|
||||||
|
Wird scharf, sobald es Zahlen aus dem Betrieb gibt.
|
||||||
|
- **Observability des WS-Layers.** Was misst man an offenen Verbindungen, Registry-Größe,
|
||||||
|
Zustellverzögerung? Seit *Fan-out-Architektur* gibt es zwei konkrete Pflichtmetriken statt
|
||||||
|
einer vagen Frage: die **Füllung der Postgres-Notify-Queue** (läuft sie voll, scheitern
|
||||||
|
Transaktionen beim Commit) und **Reconnects des `PgListener`** (jeder löst einen
|
||||||
|
Resync-Hinweis an alle Verbindungen aus). Der Rest ist noch nicht scharf.
|
||||||
|
- **Auth-UI bei N Servern.** N Server heißt N Refresh-Zyklen und N Ablaufzeitpunkte — wie
|
||||||
|
sieht die Oberfläche aus, wenn Server C eine Neuanmeldung braucht, während A und B laufen?
|
||||||
|
Die Speicherfrage ist durch *Wo lebt die WS-Verbindung im Client* geklärt (Liste im Store,
|
||||||
|
Geheimnisse hinter dem `SecretStore`-Trait); die UI-Seite hängt an
|
||||||
|
*Verbindungsaufbau & Auth über WS*.
|
||||||
|
- **Rollenmodell für Conversations und Voice-Rooms.** Erklärtes Ziel: Rollen sollen später
|
||||||
|
steuern, welche Conversations und welche Voice-Rooms für wen sichtbar und betretbar sind.
|
||||||
|
*Voice-Signaling-Protokoll* hat dafür die Naht gesetzt — Autorisierung läuft durch genau eine
|
||||||
|
Funktion, damit kein raumspezifisches Modell entsteht. Das Modell selbst ist noch nicht
|
||||||
|
scharf: `workspace_members.role` kennt heute nur `owner`/`admin`/`member`, ein
|
||||||
|
Berechtigungsbegriff fehlt völlig.
|
||||||
|
- **Mobile-Plattform-Setup vor der Implementierung.** `src-tauri/gen/android` und
|
||||||
|
`gen/apple` existieren noch nicht — Mobile ist nie initialisiert worden. Dazu die sieben
|
||||||
|
Experimente aus §8 der WebRTC-Recherche, allen voran E1 (`getUserMedia` auf einem
|
||||||
|
physischen iPhone, je einmal mit und ohne Info.plist-Key) und E2
|
||||||
|
(`MODIFY_AUDIO_SETTINGS` wirklich zwingend?). Blockiert die Spec-Arbeit **nicht**,
|
||||||
|
gehört aber vor die Implementierung — vermutlich als Task-Ticket im Umsetzungs-Effort.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- **Vollständiges E2EE-Design** — Schlüsselverwaltung, Multi-Device-Sync (Desktop + Mobile
|
||||||
|
parallel ist gefordert), Rotation, Gruppen-Mitgliederwechsel, Geräteverlust,
|
||||||
|
Attachment-Verschlüsselung. Eigener Folge-Effort in der Größenordnung dieses hier.
|
||||||
|
In *dieser* Map steckt davon nur, was das Protokoll heute festlegen muss.
|
||||||
|
**Neu bekannte Kopplung (14.08.2026):** *sicheres* Voice hängt am E2EE-Effort. Der
|
||||||
|
Signaling-Server kann DTLS-Fingerprints tauschen (RFC 8827), und der Gegenmechanismus
|
||||||
|
verlangt, den Fingerprint signiert durch einen E2EE-Kanal zu übertragen — den es heute
|
||||||
|
nicht gibt. *Voice-Signaling-Protokoll* legt die Naht fest, kann sie aber nicht einlösen.
|
||||||
|
Voice ist damit erst nach dem E2EE-Effort gegen den Betreiber geschützt.
|
||||||
|
**Zweite Kopplung:** `voice_rooms.name` ist nutzererzeugter Inhalt und fiele unter die
|
||||||
|
Blob-Regel — aber ein neues Workspace-Mitglied müsste die Raumliste lesen können, ohne
|
||||||
|
vorher Schlüsselmaterial zu haben. Ein Schlüsselverwaltungsproblem, das dort gelöst gehört.
|
||||||
|
- **Audio-Übertragung selbst** — WebRTC-Peers, Codecs, Echo-Cancellation, SFU-Betrieb.
|
||||||
|
Dieser Effort endet beim Signaling.
|
||||||
|
- **Push-Notifications / Background-Delivery auf Mobile** — FCM/APNs, Delivery-Queue,
|
||||||
|
Wakeup-Semantik. Orthogonale Infrastruktur.
|
||||||
|
- **Föderation (Server-zu-Server)** — bewusste Produktentscheidung: isolierte Instanzen
|
||||||
|
sind das Differenzierungsmerkmal gegenüber Matrix, nicht der Rückstand. Der Client
|
||||||
|
verbindet sich zu mehreren Servern; die Server reden nie miteinander.
|
||||||
|
- **Wahl der `SecretStore`-Implementierung** — *Wo lebt die WS-Verbindung im Client* hat die
|
||||||
|
Naht festgelegt (Tokens und Refresh-Tokens hinter einem Trait mit zwei Methoden), die
|
||||||
|
Implementierung aber bewusst an den E2EE-Effort abgegeben: der hat dasselbe Problem für
|
||||||
|
Geräteschlüssel, in verschärfter Form. OS-Keychain ist der naheliegende Kandidat, hat aber
|
||||||
|
auf Linux die schwächste Stelle (Secret Service braucht gnome-keyring oder KWallet).
|
||||||
|
- **Portable Schlüssel-Identität** (Schlüsselpaar statt Server-Account als Identität) —
|
||||||
|
reizvoll im E2EE-Kontext, aber eine Entscheidung des E2EE-Efforts. Lässt sich später
|
||||||
|
ergänzen, ohne den WS-Layer zu brechen.
|
||||||
|
- **Mehr-Instanz-Betrieb** — Sticky Sessions, Load-Balancer-Konfiguration, Deployment.
|
||||||
|
Im Scope ist nur die *Trait-Naht*, die den späteren Wechsel ermöglicht.
|
||||||
|
- **Serverseitige Suche** — unter E2EE ohnehin tot; braucht clientseitige Indizes.
|
||||||
|
- **Metadaten-Minimierung** (Sealed Sender, Padding) — widerspricht direkt der Entscheidung,
|
||||||
|
Read-Receipts und Presence überhaupt zu wollen.
|
||||||
|
- **TURN-Betrieb** (coturn-Konfiguration, Credential-Rotation) — optionale externe
|
||||||
|
Abhängigkeit, nicht Teil der Spec.
|
||||||
742
docs/realtime/research/01-webrtc-tauri-mobile.md
Normal file
742
docs/realtime/research/01-webrtc-tauri-mobile.md
Normal file
@@ -0,0 +1,742 @@
|
|||||||
|
# Recherche-Ergebnis: WebRTC im Tauri-WebView (Mobile + Desktop)
|
||||||
|
|
||||||
|
Ticket: [`../issues/01-research-webrtc-tauri-mobile.md`](../issues/01-research-webrtc-tauri-mobile.md)
|
||||||
|
Map: [`../map.md`](../map.md)
|
||||||
|
Stand: 14.08.2026
|
||||||
|
Methodik: Primärquellen (Apple/WebKit, AOSP, Chromium, Microsoft Learn, W3C/IETF, wry-/Tauri-Quellcode
|
||||||
|
auf den tatsächlich aufgelösten Versions-Tags). Blogposts nur als Hinweisgeber, jeweils mit Datum markiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Kernantwort
|
||||||
|
|
||||||
|
| Plattform | Antwort | Aufwand | Blockierender Punkt |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **iOS** (WKWebView) | **Ja** | 1 Datei | `NSMicrophoneUsageDescription` in `src-tauri/Info.ios.plist` |
|
||||||
|
| **Android** (System WebView) | **Ja** | 2 Manifest-Zeilen | `RECORD_AUDIO` **und** `MODIFY_AUDIO_SETTINGS` |
|
||||||
|
| **Windows** (WebView2) | **Ja** | keiner | — (funktioniert out of the box, mit WebView2-eigenem Prompt) |
|
||||||
|
| **Linux** (WebKitGTK 4.1) | **Nein** | hoch / teils unlösbar | Drei unabhängige Gates, siehe §5 |
|
||||||
|
|
||||||
|
**Fazit für die Map:** Die im Council-Verdikt als Risiko 2 geführte „Mobile-WebRTC-Unbekannte" ist
|
||||||
|
**aufgelöst — positiv**. Android und iOS tragen WebRTC im WebView. Das Voice-Signaling-Design ist für
|
||||||
|
die Mobile-Hälfte der Zielplattformen nicht wertlos. Ticket 04 (*Voice-Topologie*) ist damit entblockt.
|
||||||
|
|
||||||
|
**Die neue schlechte Nachricht liegt woanders:** Nicht Mobile ist der Problemfall, sondern **Linux**.
|
||||||
|
Dort ist WebRTC in WebKitGTK weder einkompiliert noch zur Laufzeit aktivierbar noch berechtigt.
|
||||||
|
Das ist keine Kleinigkeit für ein Projekt, dessen Zielgruppe Selbsthoster sind.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Ausgangslage im Repo (Versionsanker)
|
||||||
|
|
||||||
|
Diese Recherche gilt gegen die Versionen, die `rustification-client` heute tatsächlich auflöst:
|
||||||
|
|
||||||
|
| Komponente | Version | Herkunft |
|
||||||
|
|---|---|---|
|
||||||
|
| `@tauri-apps/cli` | **2.11.4** | `package-lock.json` |
|
||||||
|
| `tauri` (Rust) | `"2"` → aktuell **2.11.5** (01.07.2026) | `src-tauri/Cargo.toml`, crates.io |
|
||||||
|
| `tauri-runtime-wry` | **2.11.4** (30.06.2026) | crates.io |
|
||||||
|
| → `wry` | Anforderung `^0.55.0` → **0.55.1** (04.05.2026) | crates.io Dependency-Auflösung |
|
||||||
|
|
||||||
|
Das ist der wichtigste Kontext des ganzen Dokuments: **wry 0.56.0 (30.07.2026) hat die Permission-Lage
|
||||||
|
grundlegend geändert**, aber `tauri-runtime-wry 2.11.4` fordert `^0.55.0` und kann 0.56 nicht ziehen
|
||||||
|
(SemVer bei `0.x`: `>=0.55.0, <0.56.0`). Alles unten Beschriebene ist gegen **wry 0.55.1** verifiziert.
|
||||||
|
|
||||||
|
Weitere relevante Repo-Fakten:
|
||||||
|
- `src-tauri/gen/android` und `src-tauri/gen/apple` existieren **noch nicht** — Mobile ist nie
|
||||||
|
initialisiert worden. Es gibt also noch nichts zu migrieren.
|
||||||
|
- `app.security.csp` ist `null`. Tauri v2 hat **keine Default-CSP**
|
||||||
|
([v2.tauri.app/security/csp](https://v2.tauri.app/security/csp/): *„The CSP protection is only enabled
|
||||||
|
if set on the Tauri configuration file."*). Für WebRTC ist das irrelevant (siehe §7.5), aber es ist gut
|
||||||
|
zu wissen, dass hier nichts im Weg steht.
|
||||||
|
- Es existiert **kein** WebRTC- oder WebSocket-Code im Client. Grüne Wiese.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. iOS — **Ja**
|
||||||
|
|
||||||
|
### 3.1 Versionsuntergrenze
|
||||||
|
|
||||||
|
`getUserMedia` ist in `WKWebView` (nicht nur Safari) seit **iOS 14.3** verfügbar.
|
||||||
|
Primärquelle: WebKit-Blog, *MediaRecorder API*, Youenn Fablet, **23.11.2020**
|
||||||
|
([webkit.org/blog/11353](https://webkit.org/blog/11353/mediarecorder-api/)), wörtlich:
|
||||||
|
|
||||||
|
> „Speaking of Safari in latest iOS 14.3 beta and local capture, `navigator.mediaDevices.getUserMedia`
|
||||||
|
> can now be exposed to WKWebView applications. `navigator.mediaDevices.getUserMedia` is automatically
|
||||||
|
> exposed **if the embedding application is able to natively capture either audio or video**."
|
||||||
|
|
||||||
|
Der Satzteil „able to natively capture" ist die eigentliche Bedingung: Er bedeutet **die
|
||||||
|
Usage-Description-Keys sind in der Info.plist vorhanden**. Kein Entitlement, keine private API.
|
||||||
|
|
||||||
|
Tauris eigener Default `bundle.iOS.minimumSystemVersion` ist `"14.0"` — für WebRTC muss man das
|
||||||
|
faktisch auf **14.3** oder höher lesen. Praktisch relevanter Floor ist ohnehin **iOS 15.0**, weil
|
||||||
|
erst dort der `WKUIDelegate`-Callback existiert, den wry registriert (siehe 3.3).
|
||||||
|
|
||||||
|
### 3.2 Secure Context — passt, ohne Tricks
|
||||||
|
|
||||||
|
`getUserMedia` verlangt einen Secure Context. Tauri liefert das Frontend auf iOS (wie macOS und Linux)
|
||||||
|
unter dem Custom-Scheme **`tauri://localhost`** aus (`crates/tauri/src/manager/mod.rs`,
|
||||||
|
`tauri_protocol_url`). Ein Custom-Scheme allein ist *nicht* vertrauenswürdig — aber WebKit prüft den
|
||||||
|
**Host unabhängig vom Schema** (`Source/WebCore/page/SecurityOrigin.cpp`):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
if (LegacySchemeRegistry::shouldTreatURLSchemeAsSecure(protocol)) return true;
|
||||||
|
if (SecurityOrigin::isLocalHostOrLoopbackIPAddress(host)) return true;
|
||||||
|
```
|
||||||
|
|
||||||
|
Host ist `localhost` ⇒ potentially trustworthy ⇒ Secure Context. Der aus Blogposts bekannte
|
||||||
|
`_registerURLSchemeAsSecure`-Hack (private API) ist **nicht nötig**.
|
||||||
|
|
||||||
|
### 3.3 Was wry bereits erledigt — verifiziert im Quellcode
|
||||||
|
|
||||||
|
wry registriert einen `WKUIDelegate` und darin den Media-Capture-Callback.
|
||||||
|
Quelle: `src/wkwebview/class/wry_web_view_ui_delegate.rs` am Tag **`wry-v0.55.1`**:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[unsafe(method(webView:requestMediaCapturePermissionForOrigin:initiatedByFrame:type:decisionHandler:))]
|
||||||
|
fn request_media_capture_permission(
|
||||||
|
&self, _webview: &WryWebView, _origin: &WKSecurityOrigin, _frame: &WKFrameInfo,
|
||||||
|
_capture_type: WKMediaCaptureType,
|
||||||
|
decision_handler: &Block<dyn Fn(WKPermissionDecision)>,
|
||||||
|
) {
|
||||||
|
(*decision_handler).call((WKPermissionDecision::Grant,));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Drei Beobachtungen, alle wichtig:
|
||||||
|
|
||||||
|
1. **Kein `#[cfg(target_os = "macos")]`.** Die Methode wird auf iOS mitkompiliert. (In `dev`/0.56 steht
|
||||||
|
dort `#[cfg(not(macos_12_unavailable))]` — ein Flag, das der `build.rs` nur unter
|
||||||
|
`target_os = "macos"` setzt; auch dort bleibt iOS also drin.)
|
||||||
|
2. **In 0.55.1 wird bedingungslos `Grant` zurückgegeben.** Es gibt in dieser Version gar keinen
|
||||||
|
`permission_handler` — `WryWebViewUIDelegateIvars` hat das Feld schlicht nicht. Der WebKit-Prompt
|
||||||
|
entfällt damit; es bleibt genau **ein** Prompt, nämlich der des Betriebssystems (TCC).
|
||||||
|
3. Apple dokumentiert den Default ohne Delegate-Implementierung als `WKPermissionDecision.prompt` —
|
||||||
|
also **nicht** Deny ([Apple Developer](https://developer.apple.com/documentation/webkit/wkuidelegate/webview(_:requestmediacapturepermissionfor:initiatedbyframe:type:decisionhandler:)),
|
||||||
|
verfügbar ab iOS 15.0 / macOS 12.0). wry macht daraus ein Grant. Sicherheitsrelevant, siehe §7.6.
|
||||||
|
|
||||||
|
`allowsInlineMediaPlayback` setzt wry auf iOS hart auf `true` — relevant, sobald der Remote-Stream in
|
||||||
|
ein `<audio>`/`<video>`-Element gehängt wird.
|
||||||
|
|
||||||
|
### 3.4 Der eine nötige Konfigurationsschritt
|
||||||
|
|
||||||
|
`src-tauri/Info.ios.plist` (oder `src-tauri/Info.plist`, siehe unten) anlegen:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Rustification braucht Mikrofonzugriff für Sprachanrufe.</string>
|
||||||
|
```
|
||||||
|
|
||||||
|
Fehlt der Key, gilt die App als nicht „able to natively capture" ⇒ `navigator.mediaDevices` ist
|
||||||
|
**gar nicht erst definiert** (das klassische `navigator.mediaDevices is undefined`), und ein nativer
|
||||||
|
Capture-Versuch bringt iOS zum harten Absturz.
|
||||||
|
|
||||||
|
Tauri merged die Datei mit den generierten Werten. Merge-Reihenfolge auf iOS (später gewinnt), laut
|
||||||
|
offenem Issue [tauri#13068](https://github.com/tauri-apps/tauri/issues/13068) (24.03.2025, *Unify/Document
|
||||||
|
handling of Info.plist files*): XcodeGen-`project.yml` → `Info.plist` aus `TAURI_DIR` →
|
||||||
|
`Info.ios.plist` aus `TAURI_DIR` → generierte Version-Strings. Zusätzlich existiert der Config-Key
|
||||||
|
`bundle.iOS.infoPlist` („Path to an Info.plist file for merging with defaults").
|
||||||
|
|
||||||
|
**`Info.ios.plist` ist der robuste Weg**, weil er `gen/apple` wegwerfbar hält.
|
||||||
|
|
||||||
|
Belastbares Signal, dass genau dieser Pfad vorgesehen ist: Tauris eigenes API-Beispiel
|
||||||
|
[`examples/api/src-tauri/Info.plist`](https://github.com/tauri-apps/tauri/blob/dev/examples/api/src-tauri/Info.plist)
|
||||||
|
enthält wörtlich `NSMicrophoneUsageDescription` = *„Request microphone access for WebRTC"*.
|
||||||
|
|
||||||
|
### 3.5 Bekanntes offenes Issue
|
||||||
|
|
||||||
|
[tauri#12068 — *iOS Microphone Access*](https://github.com/tauri-apps/tauri/issues/12068), geöffnet
|
||||||
|
**28.12.2024**, **noch offen**, Label `needs triage`, keine Maintainer-Antwort. Symptom: „it failed to
|
||||||
|
access the mic nor enumerate media devices. The mic permission is shown randomly once."
|
||||||
|
|
||||||
|
Der Report enthält weder `tauri info` noch die iOS-Version noch die Info.plist. **Wahrscheinlichste
|
||||||
|
Erklärung ist der fehlende `NSMicrophoneUsageDescription`-Key** — das Symptombild passt exakt. Das ist
|
||||||
|
aber eine Hypothese, kein Befund. Siehe Experiment E1 in §8.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Android — **Ja**
|
||||||
|
|
||||||
|
### 4.1 Versionsuntergrenze
|
||||||
|
|
||||||
|
WebRTC ist im Android System WebView seit **Android 5.0 Lollipop / API 21 / WebView 37** vorhanden.
|
||||||
|
Primärquelle: [developer.android.com/about/versions/android-5.0](https://developer.android.com/about/versions/android-5.0):
|
||||||
|
|
||||||
|
> „Android 5.0 updates the `WebView` implementation to **Chromium M37** … This release brings support
|
||||||
|
> for the following open standards: WebAudio, WebGL, **WebRTC**."
|
||||||
|
> „This release introduces the `PermissionRequest` class … **Your app must have the appropriate Android
|
||||||
|
> permissions for these resources in order to grant the permissions to the WebView.**"
|
||||||
|
|
||||||
|
Tauris Default `bundle.android.minSdkVersion` ist **24** — deutlich darüber. wrys Runtime-Permission-Pfad
|
||||||
|
ist auf `SDK_INT >= M` (API 23) gegated. Praktisch irrelevant: Das System WebView ist seit Lollipop über
|
||||||
|
den Play Store aktualisierbar, jedes reale Gerät fährt ein aktuelles Chromium.
|
||||||
|
|
||||||
|
### 4.2 Der WebView-Gate: `onPermissionRequest`
|
||||||
|
|
||||||
|
Ohne Override **verweigert** der `WebChromeClient`. AOSP,
|
||||||
|
[`core/java/android/webkit/WebChromeClient.java`](https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/java/android/webkit/WebChromeClient.java):
|
||||||
|
|
||||||
|
```java
|
||||||
|
/** ... If this method isn't overridden, the permission is denied. */
|
||||||
|
public void onPermissionRequest(PermissionRequest request) {
|
||||||
|
request.deny();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**wry überschreibt es.** Verifiziert in `src/android/kotlin/RustWebChromeClient.kt` am Tag
|
||||||
|
**`wry-v0.55.1`**:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
override fun onPermissionRequest(request: PermissionRequest) {
|
||||||
|
val isRequestPermissionRequired = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
|
||||||
|
val permissionList: MutableList<String> = ArrayList()
|
||||||
|
if (listOf(*request.resources).contains("android.webkit.resource.VIDEO_CAPTURE")) {
|
||||||
|
permissionList.add(Manifest.permission.CAMERA)
|
||||||
|
}
|
||||||
|
if (listOf(*request.resources).contains("android.webkit.resource.AUDIO_CAPTURE")) {
|
||||||
|
permissionList.add(Manifest.permission.MODIFY_AUDIO_SETTINGS)
|
||||||
|
permissionList.add(Manifest.permission.RECORD_AUDIO)
|
||||||
|
}
|
||||||
|
if (permissionList.isNotEmpty() && isRequestPermissionRequired) {
|
||||||
|
... permissionLauncher.launch(permissions) // OS-Prompt
|
||||||
|
// Callback: alle granted -> request.grant(request.resources), sonst request.deny()
|
||||||
|
} else {
|
||||||
|
request.grant(request.resources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Die App muss also **nichts** abfangen. wry löst den Runtime-Prompt selbst aus und reicht das Ergebnis
|
||||||
|
an den WebView durch. Der Callback kollabiert das Ergebnis-Map mit „alle müssen `true` sein".
|
||||||
|
|
||||||
|
Diese Methode existiert bei wry seit **0.21.0 (05.09.2022, PR #685)** — sie ist in *jedem* Tauri-v2-Release
|
||||||
|
vorhanden.
|
||||||
|
|
||||||
|
### 4.3 Die zwei nötigen Manifest-Zeilen
|
||||||
|
|
||||||
|
Tauris generiertes Manifest enthält **nur `INTERNET`**. Verifiziert im Template
|
||||||
|
[`crates/tauri-cli/templates/mobile/android/app/src/main/AndroidManifest.xml`](https://github.com/tauri-apps/tauri/blob/dev/crates/tauri-cli/templates/mobile/android/app/src/main/AndroidManifest.xml):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
```
|
||||||
|
|
||||||
|
In `src-tauri/gen/android/app/src/main/AndroidManifest.xml` ergänzen:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
<uses-feature android:name="android.hardware.microphone" android:required="false" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**`MODIFY_AUDIO_SETTINGS` ist nicht optional**, obwohl Chromium selbst es nicht braucht. Begründung aus
|
||||||
|
dem Code oben: wry fordert beide Permissions in *einem* `RequestMultiplePermissions`-Aufruf an und
|
||||||
|
verlangt, dass *alle* zurückgemeldeten Werte `true` sind. Ein nicht im Manifest deklariertes Recht
|
||||||
|
liefert dort `false` — also `request.deny()` und `NotAllowedError`, **obwohl der Nutzer den Mikrofon-Dialog
|
||||||
|
mit „Zulassen" bestätigt hat**. `MODIFY_AUDIO_SETTINGS` hat Protection-Level `normal`, erzeugt also
|
||||||
|
keinen zusätzlichen Dialog; es muss nur deklariert sein.
|
||||||
|
|
||||||
|
Das ist die wahrscheinlichste Ursache von
|
||||||
|
[tauri#10846](https://github.com/tauri-apps/tauri/issues/10846) (31.08.2024, *Audio Permission Not
|
||||||
|
Working in Tauri Android App Despite Manifest Declaration*, `needs triage`) — dort wurde ausschließlich
|
||||||
|
`RECORD_AUDIO` deklariert. Deckt sich mit der Community-Diagnose in
|
||||||
|
[Discussion #12732](https://github.com/orgs/tauri-apps/discussions/12732) (Feb–Apr 2025, getestet gegen
|
||||||
|
Tauri 2.10.3): *„Critically, MODIFY_AUDIO_SETTINGS to prevent NotReadableError."*
|
||||||
|
**Status: durch Code-Lesung sehr gut gestützt, nicht empirisch verifiziert** — siehe Experiment E2.
|
||||||
|
|
||||||
|
Es gibt **keinen** `tauri.conf.json`-Key für Android-Manifest-Permissions und **kein** offizielles
|
||||||
|
Tauri-Plugin für generische Runtime-Permissions (die offizielle Plugin-Liste auf
|
||||||
|
[v2.tauri.app/plugin](https://v2.tauri.app/plugin/) führt 30 Plugins, keines davon Mikrofon oder
|
||||||
|
Permissions). Der dokumentierte Weg ist Hand-Editieren des generierten Manifests.
|
||||||
|
|
||||||
|
### 4.4 `gen/android` gehört ins Git
|
||||||
|
|
||||||
|
Das Template-`.gitignore` schließt ausschließlich Build-Artefakte aus (`build`, `.gradle`,
|
||||||
|
`local.properties`, `/.tauri`, `/tauri.settings.gradle`, `app/src/main/**/generated`,
|
||||||
|
`app/src/main/jniLibs/**/*.so`, `app/src/main/assets/tauri.conf.json`). `AndroidManifest.xml` und
|
||||||
|
`MainActivity.kt` sind getrackte Quelldateien ohne `THIS IS AN AUTOGENERATED FILE`-Header.
|
||||||
|
|
||||||
|
Maintainer FabianLars, [Discussion #8323](https://github.com/orgs/tauri-apps/discussions/8323) (01.12.2023):
|
||||||
|
|
||||||
|
> „Generally yes. it has .gitignore folders inside that ignores the build artifacts etc. The gen folder
|
||||||
|
> also contains the normal android studio / xcode projects which you can modify if you want which would
|
||||||
|
> require you to commit it."
|
||||||
|
|
||||||
|
`tauri android build`/`dev` überschreibt die Datei nicht; **`tauri android init` rendert die Templates
|
||||||
|
neu** und ist damit als destruktiv zu behandeln. Siehe Experiment E3.
|
||||||
|
|
||||||
|
### 4.5 Secure Context
|
||||||
|
|
||||||
|
Tauri liefert auf Android und Windows unter **`http://tauri.localhost`** aus.
|
||||||
|
Verifiziert in `crates/tauri-utils/src/config.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Sets whether the custom protocols should use `https://<scheme>.localhost` instead of the default
|
||||||
|
/// `http://<scheme>.localhost` on Windows and Android. Defaults to `false`.
|
||||||
|
pub use_https_scheme: bool,
|
||||||
|
```
|
||||||
|
und im `Default`-Impl: `use_https_scheme: false`.
|
||||||
|
|
||||||
|
Chromium behandelt `*.localhost` auch über plain http als vertrauenswürdig.
|
||||||
|
[`net/base/url_util.cc`](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/net/base/url_util.cc):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
bool IsNormalizedLocalhostTLD(std::string_view host) {
|
||||||
|
return base::EndsWith(host, ".localhost", base::CompareCase::INSENSITIVE_ASCII);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Deckt sich mit W3C [Secure Contexts](https://w3c.github.io/webappsec-secure-contexts/), Schritt 5:
|
||||||
|
*„origin's host ends with `.localhost` or `.localhost.`"*. Das System WebView ist Chromium und linkt
|
||||||
|
denselben `//services/network`-Code.
|
||||||
|
|
||||||
|
**Gegenstimme, die ich für falsch halte:** [wry#1709](https://github.com/tauri-apps/wry/issues/1709) /
|
||||||
|
[#1710](https://github.com/tauri-apps/wry/issues/1710) (01.04.2026) behaupten, Android WebView halte nur
|
||||||
|
exakt `http://localhost`, `https://` und `file://` für sicher. Beide Issues stammen von einem
|
||||||
|
Nicht-Maintainer, #1709 hat null Antworten, und die Behauptung wird ohne Chromium-Quelle aufgestellt —
|
||||||
|
im direkten Widerspruch zum Code oben. Die dort angeführten Belege (SharedWorker, Service Worker) haben
|
||||||
|
andere, bekannte Ursachen. Trotzdem: Experiment E4 kostet zehn Sekunden.
|
||||||
|
|
||||||
|
**Risikoloser Hedge:** `useHttpsScheme: true` unter `app.windows[]`. Nachteile laut Tauri-Doc-Kommentar:
|
||||||
|
kein Mixed Content mehr (`http://`-Fetches brechen), und **die IndexedDB-/Cookie-/localStorage-Partition
|
||||||
|
wandert**. Also vor dem ersten Release entscheiden, nicht danach.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Linux (WebKitGTK 4.1) — **Nein**
|
||||||
|
|
||||||
|
Tauri v2 verlangt auf Linux `webkit2gtk-4.1` (`libwebkit2gtk-4.1-dev` / `webkit2gtk-4.1` /
|
||||||
|
`webkit2gtk4.1-devel`, [v2.tauri.app/start/prerequisites](https://v2.tauri.app/start/prerequisites/)).
|
||||||
|
Dort scheitert WebRTC an **drei unabhängigen Gates**, die alle passiert werden müssen.
|
||||||
|
|
||||||
|
### Gate 1 — Compile-Time: `ENABLE_WEB_RTC` ist standardmäßig AUS
|
||||||
|
|
||||||
|
[`Source/cmake/OptionsGTK.cmake`](https://github.com/WebKit/WebKit/blob/main/Source/cmake/OptionsGTK.cmake):
|
||||||
|
|
||||||
|
```cmake
|
||||||
|
WEBKIT_OPTION_DEFAULT_PORT_VALUE(ENABLE_MEDIA_STREAM PRIVATE ON)
|
||||||
|
WEBKIT_OPTION_DEFAULT_PORT_VALUE(ENABLE_WEB_RTC PRIVATE ${ENABLE_EXPERIMENTAL_FEATURES})
|
||||||
|
```
|
||||||
|
|
||||||
|
`Source/cmake/WebKitFeatures.cmake`:
|
||||||
|
|
||||||
|
```cmake
|
||||||
|
option(ENABLE_EXPERIMENTAL_FEATURES "Enable experimental features" OFF)
|
||||||
|
```
|
||||||
|
|
||||||
|
⇒ `RTCPeerConnection` ist im JS-Global schlicht **nicht vorhanden**, sofern der Packager nicht explizit
|
||||||
|
`-DENABLE_WEB_RTC=ON` setzt. `ENABLE_MEDIA_STREAM` ist zwar an (also `getUserMedia` grundsätzlich da),
|
||||||
|
aber ohne `ENABLE_WEB_RTC` nützt das nichts.
|
||||||
|
|
||||||
|
Verifizierter Distro-Stand:
|
||||||
|
- **Debian/Ubuntu** `webkit2gtk` — `debian/rules` enthält weder `ENABLE_WEB_RTC` noch
|
||||||
|
`ENABLE_EXPERIMENTAL_FEATURES`. ⇒ **AUS.** ([sources.debian.org](https://sources.debian.org/src/webkit2gtk/latest/debian/rules/))
|
||||||
|
- **Alpine** `community/webkit2gtk-4.1` APKBUILD — dito. ⇒ **AUS.**
|
||||||
|
- **Arch / Fedora** — nicht verifiziert; beide Dist-Git-Hosts sind hinter einer Anubis-Bot-Schranke
|
||||||
|
(Experiment E5). Schwaches Indiz gegen WebRTC bei Arch: `libnice` taucht in den Dependencies nicht auf.
|
||||||
|
|
||||||
|
Korroborierend: [tauri#13143](https://github.com/tauri-apps/tauri/issues/13143) *„The current browser
|
||||||
|
does not support WebRTC"* auf Ubuntu 22.04 mit WebKit2GTK 2.48.0 — geschlossen als *not planned*, Label
|
||||||
|
`status: upstream` (05.04.2025).
|
||||||
|
|
||||||
|
Historie: Der GstWebRTC-Backend kam mit WebKitGTK **2.37.1** („Add initial implementation of WebRTC using
|
||||||
|
GstWebRTC if GStreamer 1.20 is available"). Es gibt **keinen** NEWS-Eintrag, der ein
|
||||||
|
Default-On dokumentiert.
|
||||||
|
|
||||||
|
### Gate 2 — Runtime-Setting: `enable-webrtc` ist standardmäßig FALSE
|
||||||
|
|
||||||
|
[`WebKitSettings:enable-webrtc`](https://webkitgtk.org/reference/webkit2gtk/stable/property.Settings.enable-webrtc.html):
|
||||||
|
|
||||||
|
> „Enable WebRTC support for loaded pages. Enabling this setting implies that
|
||||||
|
> `WebKitSettings:enable-media-stream` will be enabled as well."
|
||||||
|
> **Default value: FALSE. Available since: 2.38.**
|
||||||
|
|
||||||
|
wry setzt es **nie** — weder in 0.55.1 noch in `dev`. In `set_webview_settings`
|
||||||
|
(`src/webkitgtk/mod.rs`) stehen `set_enable_webgl`, `set_enable_webaudio`, `set_enable_page_cache` u. a.,
|
||||||
|
aber kein `set_enable_webrtc` und kein `set_enable_media_stream`. Tauri exponiert die WebKitSettings
|
||||||
|
auch nicht ([tauri#8989](https://github.com/tauri-apps/tauri/issues/8989), *[feat] expose control over
|
||||||
|
webkit settings*, offen).
|
||||||
|
|
||||||
|
### Gate 3 — Permission: Default ist DENY
|
||||||
|
|
||||||
|
`WebKitWebView`s Default-Class-Handler für `permission-request`
|
||||||
|
(`Source/WebKit/UIProcess/API/glib/WebKitWebView.cpp`) verweigert alles außer Pointer-Lock. Die Doku ist
|
||||||
|
explizit ([`WebKitUserMediaPermissionRequest`](https://webkitgtk.org/reference/webkit2gtk/stable/class.UserMediaPermissionRequest.html)):
|
||||||
|
|
||||||
|
> „When a `WebKitUserMediaPermissionRequest` is not handled by the user, **it is denied by default**."
|
||||||
|
|
||||||
|
wry **0.55.1** verbindet `connect_permission_request` **überhaupt nicht** — im ganzen
|
||||||
|
`src/webkitgtk/mod.rs` kommt der String nicht vor. Das erklärt
|
||||||
|
[tauri#12547](https://github.com/tauri-apps/tauri/issues/12547) („access denied, no dialog",
|
||||||
|
geschlossen *not planned*, 28.01.2025).
|
||||||
|
|
||||||
|
wry **0.56+** verbindet ihn — aber nur, wenn die App einen Handler übergibt:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Permission handler
|
||||||
|
if let Some(permission_handler) = attributes.permission_handler.take() {
|
||||||
|
webview.connect_permission_request(move |_webview, request| {
|
||||||
|
if let Some(media_request) = request.downcast_ref::<UserMediaPermissionRequest>() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Und docs.rs zu `PermissionResponse::Default` ist unmissverständlich: *„Windows / macOS / Android:
|
||||||
|
continue the platform or browser permission flow. **Linux: The default behavior is `Deny`.**"*
|
||||||
|
|
||||||
|
### Der Weg, der theoretisch bliebe
|
||||||
|
|
||||||
|
`WebviewWindow::with_webview` gibt auf Linux Zugriff auf die rohe `webkit2gtk::WebView`
|
||||||
|
([docs.rs/tauri](https://docs.rs/tauri/latest/tauri/webview/struct.WebviewWindow.html)). Damit ließen sich
|
||||||
|
Gate 2 (`settings.set_enable_webrtc(true)`, Binding-Feature `v2_38`) und Gate 3
|
||||||
|
(`connect_permission_request`) aus dem App-Code heraus schließen — ohne wry oder Tauri zu patchen.
|
||||||
|
|
||||||
|
**Gate 1 bleibt trotzdem.** Ist WebRTC nicht einkompiliert, hilft kein Setting. Und Gate 1 hängt am
|
||||||
|
Distro-Paket des Nutzers, nicht an unserem Code.
|
||||||
|
|
||||||
|
### Konsequenz für die Map
|
||||||
|
|
||||||
|
Die harte Deployment-Regel („eine Binary + Postgres") betrifft den *Server*. Für den *Client* auf Linux
|
||||||
|
gibt es keine analoge Zusage — aber die Zielgruppe „Selbsthoster" ist stark linux-lastig, und Linux ist
|
||||||
|
in der Map als Zielplattform gelistet. Realistische Optionen, alle unattraktiv:
|
||||||
|
|
||||||
|
1. **Voice auf Linux als nicht unterstützt deklarieren.** Chat läuft voll, Voice nicht. Ehrlich, billig,
|
||||||
|
schmerzhaft.
|
||||||
|
2. **Nativer Pfad nur auf Linux** (§6). Bricht die Architektur-Einheitlichkeit — Voice läge auf drei
|
||||||
|
Plattformen im WebView und auf einer in Rust.
|
||||||
|
3. **Eigenes WebKitGTK bündeln.** Widerspricht der Simplizitätsdoktrin des Projekts frontal.
|
||||||
|
4. **Warten/upstream drücken:** wry ≥ 0.56 + Tauri ≥ 2.12 (PR
|
||||||
|
[#14865](https://github.com/tauri-apps/tauri/pull/14865), gemergt **03.08.2026**, Milestone 2.12,
|
||||||
|
noch nicht released) lösen Gate 3 sauber. Gate 2 bräuchte zusätzlich `with_webview`-Code oder
|
||||||
|
tauri#8989. **Gate 1 löst niemand außer den Distros.**
|
||||||
|
|
||||||
|
Diese Entscheidung gehört als Ergebnis in Ticket 04 (*Voice-Topologie*), nicht hierher.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Windows (WebView2) — **Ja, ohne Konfiguration**
|
||||||
|
|
||||||
|
- **Engine:** Chromium. Microsoft dokumentiert `navigator.mediaDevices.getUserMedia()` explizit als in
|
||||||
|
WebView2 verfügbar, mit Secure-Context als einziger Einschränkung
|
||||||
|
([Working with local content](https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/working-with-local-content)).
|
||||||
|
- **Permission-Default ist PROMPT, nicht Deny.** `COREWEBVIEW2_PERMISSION_STATE_DEFAULT` —
|
||||||
|
*„Specifies that the default browser behavior is used, which normally prompt users for decision."*
|
||||||
|
`get_State` — *„The default value is `COREWEBVIEW2_PERMISSION_STATE_DEFAULT`."*
|
||||||
|
Permission-Kind: `COREWEBVIEW2_PERMISSION_KIND_MICROPHONE` — *„Indicates permission to capture audio."*
|
||||||
|
API vorhanden seit WebView2 **0.9.430**; jede GA-Runtime genügt.
|
||||||
|
- **wry 0.55.1** registriert zwar `add_PermissionRequested`, setzt aber **nur** für
|
||||||
|
`COREWEBVIEW2_PERMISSION_KIND_CLIPBOARD_READ` einen State (verifiziert in `src/webview2/mod.rs`).
|
||||||
|
Alles andere bleibt auf `STATE_DEFAULT` ⇒ **WebView2 zeigt seinen eigenen Prompt.**
|
||||||
|
- **Secure Context:** `http://tauri.localhost` — siehe §4.5. Kein
|
||||||
|
`--unsafely-treat-insecure-origin-as-secure` nötig.
|
||||||
|
|
||||||
|
**Eine Falle:** Die Entscheidung wird im WebView2-Profil **persistiert**. Wer einmal „Blockieren" klickt,
|
||||||
|
bekommt nie wieder einen Prompt, bis `%LOCALAPPDATA%\<bundleId>\EBWebView\Default\Preferences` gelöscht
|
||||||
|
wird ([tauri#5042](https://github.com/tauri-apps/tauri/issues/5042),
|
||||||
|
[#8979](https://github.com/tauri-apps/tauri/issues/8979)). Das muss die UI abfangen: `getUserMedia`, das
|
||||||
|
ohne sichtbaren Dialog sofort mit `NotAllowedError` zurückkommt, ist ein anderer Zustand als „Nutzer hat
|
||||||
|
gerade abgelehnt", und der Nutzer braucht eine Anleitung statt eines Timeouts.
|
||||||
|
|
||||||
|
Für MSIX/UWP-Pakete zusätzlich: `microphone`-Device-Capability im AppxManifest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Nativer Fallback und Sicherheits-Eigenheiten
|
||||||
|
|
||||||
|
### 7.1 `webrtc-rs` — reiner Protokoll-Stack, **keine** Audio-Erfassung
|
||||||
|
|
||||||
|
`webrtc` **0.20.2** (11.08.2026), 5,6 Mio. Downloads,
|
||||||
|
[github.com/webrtc-rs/webrtc](https://github.com/webrtc-rs/webrtc). README-Selbstbeschreibung:
|
||||||
|
*„Sans-I/O protocol core with complete WebRTC stack (95%+ W3C API compliance)"* — ICE, DTLS, SRTP, SCTP,
|
||||||
|
RTP, DataChannels, Simulcast.
|
||||||
|
|
||||||
|
**Nicht enthalten:** Geräte-Capture, Codecs, Jitter-Buffer/NetEQ, Echo-Cancellation. Der Maintainer selbst
|
||||||
|
(webrtc-rs#94) beschreibt das Ersetzen von Google-libwebrtc als „requires porting NetEQ, audio 3A
|
||||||
|
(AEC, AGC, NS), audio/video codec" — also ausdrücklich nicht getan.
|
||||||
|
|
||||||
|
Der vollständige Eigenbau bräuchte zusätzlich: `cpal` 0.18.1 (Capture), `opus`/`audiopus` (Encoder),
|
||||||
|
RTP-Packetisierung, einen selbstgeschriebenen Jitter-Buffer mit PLC — und für AEC/AGC/NS ausgerechnet
|
||||||
|
`webrtc-audio-processing`, das seinerseits ein C++-Binding an Googles APM ist. Man entkommt libwebrtc
|
||||||
|
also nicht, man baut nur den unangenehmen Teil selbst.
|
||||||
|
|
||||||
|
**Mobile:** keine dokumentierte Plattform-Matrix. Android wird real benutzt, mit bekannten Kanten bei
|
||||||
|
Netzwerk-Interfaces und Handover (webrtc-rs #874, #272, #78). iOS: #645 *„build a static lib for
|
||||||
|
ios/macos"* wurde als **not planned** geschlossen.
|
||||||
|
|
||||||
|
### 7.2 `livekit/rust-sdks` (`libwebrtc`-Crate) — die realistische native Option
|
||||||
|
|
||||||
|
`libwebrtc` **0.3.45** (10.08.2026), `livekit` 0.8.3 —
|
||||||
|
[github.com/livekit/rust-sdks](https://github.com/livekit/rust-sdks), *„LiveKit safe bindings to
|
||||||
|
libwebrtc"*. README listet **Windows, macOS, Linux, iOS, Android**, mit Mobile-Beispiel. Liefert echtes
|
||||||
|
libwebrtc: NetEQ, AEC/AGC/NS, Opus, Geräte-Capture, HW-Encoding.
|
||||||
|
|
||||||
|
Preis: ein großer vorkompilierter nativer Blob pro Plattform/Architektur plus LiveKits Build-Toolchain.
|
||||||
|
Das steht in offener Spannung zur Simplizitäts-Doktrin des Projekts.
|
||||||
|
|
||||||
|
### 7.3 Der eigentliche Aufwand liegt in der Rust↔WebView-Naht
|
||||||
|
|
||||||
|
Geht Audio nativ, ist der WebView **nicht mehr der Medien-Endpunkt**: Mikrofon-Capture in Rust,
|
||||||
|
Remote-Wiedergabe in Rust (kein `<audio>`), Signaling/State über Tauri-IPC, und für die UI (Pegelanzeige,
|
||||||
|
Speaking-Indikator) nur heruntergerechnete Metering-Werte über IPC — niemals rohes PCM. Zurück-Injizieren
|
||||||
|
von PCM in den WebView via WebAudio/`MediaStreamTrackGenerator` geht in Chromium, aber **nicht** in
|
||||||
|
WebKitGTK — auf Linux müsste der native Pfad die Wiedergabe also komplett besitzen.
|
||||||
|
|
||||||
|
Grobe Größenordnung: `livekit/libwebrtc` ≈ Tage bis zum funktionierenden Call mit brauchbarer AEC;
|
||||||
|
`webrtc-rs` + cpal + opus + APM ≈ Wochen, und man besitzt die Echo-Cancellation-Qualität auf Dauer selbst.
|
||||||
|
|
||||||
|
**Empfehlung:** Der native Pfad ist als *Fallback für Linux* diskutabel, nicht als Ersatz für den
|
||||||
|
WebView-Pfad. Für Mobile ist er unnötig — dort funktioniert der WebView.
|
||||||
|
|
||||||
|
### 7.4 Bedrohungsmodell: der Signaling-Server kann DTLS-Fingerprints tauschen
|
||||||
|
|
||||||
|
Das ist der wichtigste Sicherheitsbefund für ein Projekt, dessen Betreiber als Angreifer gilt.
|
||||||
|
|
||||||
|
**RFC 8827** *(WebRTC Security Architecture, Januar 2021)* benennt den Angriff wörtlich:
|
||||||
|
|
||||||
|
> „any on-path attacker can replace the DTLS-SRTP fingerprints in the handshake and thus substitute its
|
||||||
|
> own identity for either endpoint"
|
||||||
|
|
||||||
|
Der Server relayed die SDP. Er kann `a=fingerprint:` austauschen und zwei separate DTLS-Sitzungen
|
||||||
|
terminieren — vollständiges MITM auf dem Audio, ohne dass irgendein WebRTC-Mechanismus anschlägt.
|
||||||
|
|
||||||
|
**Der spezifizierte Gegenmechanismus ist tot.** W3C *Identity for WebRTC 1.0* steht seit **27.09.2018**
|
||||||
|
unverändert als Candidate Recommendation. MDN-Browser-Compat für `setIdentityProvider` / `peerIdentity` /
|
||||||
|
`getIdentityAssertion`: Chrome `false`, Safari `false`, **WebView Android `false`**, nur Firefox 40.
|
||||||
|
⇒ In *keiner* der vier Ziel-Engines verfügbar.
|
||||||
|
|
||||||
|
**Was stattdessen zu tun ist** — und was ins Signaling-Protokoll (Ticket 09) gehört:
|
||||||
|
|
||||||
|
1. Fingerprint deterministisch vorab bestimmen: `RTCPeerConnection.generateCertificate()` +
|
||||||
|
`RTCCertificate.getFingerprints()`, statt ihn erst aus `localDescription.sdp` zu lesen.
|
||||||
|
2. Den Fingerprint **durch den E2EE-Kanal der App** übertragen, signiert mit dem Langzeit-Identity-Key —
|
||||||
|
*nicht* im Klartext-SDP, das der Server relayed.
|
||||||
|
3. Beim Empfang gegen den Fingerprint im relayed SDP vergleichen. Mismatch ⇒ Call abbrechen und als
|
||||||
|
Sicherheitsereignis anzeigen, nicht still weiterlaufen lassen.
|
||||||
|
4. **Key Continuity** (RFC 8827): Fingerprints pro Gerät über Calls hinweg cachen und bei Änderung warnen.
|
||||||
|
5. Optional eine **SAS** (Emoji/Wortliste aus dem Hash beider Fingerprints, à la ZRTP/Signal) für den
|
||||||
|
paranoiden Pfad — die verteidigt zusätzlich gegen einen kompromittierten Identity-Key.
|
||||||
|
|
||||||
|
Genau das macht Matrix in MSC3401/Element Call: Call-SDP läuft über `m.room.encrypted` To-Device-Messages,
|
||||||
|
der Homeserver sieht den Fingerprint nie im Klartext. **Hier ist die eine Stelle, an der das
|
||||||
|
Voice-Signaling-Protokoll heute schon eine E2EE-Naht braucht** — auch wenn das volle E2EE-Design ein
|
||||||
|
Folge-Effort ist. Das gehört als Randbedingung in Ticket 09.
|
||||||
|
|
||||||
|
### 7.5 IP-Leaks, TURN-Metadaten, CSP
|
||||||
|
|
||||||
|
- **`iceTransportPolicy: "relay"`** ([W3C WebRTC 1.0](https://www.w3.org/TR/webrtc/)): *„The ICE Agent
|
||||||
|
uses only media relay candidates"*; Spec-Note: *„can be used to prevent the remote endpoint from
|
||||||
|
learning the user's IP addresses"*. Versteckt die IP **vor dem Peer**, nicht vor dem TURN-Server.
|
||||||
|
Da der Server die IP ohnehin aus der WebSocket-Verbindung kennt, ist `relay` nahezu gratis — es reduziert
|
||||||
|
strikt die Exposition gegenüber dem *Peer*. Relevant für Ticket 04.
|
||||||
|
- **mDNS-ICE-Kandidaten** (`.local`): IETF-Draft `draft-ietf-mmusic-mdns-ice-candidates` ist **abgelaufen**
|
||||||
|
(-03, Juni 2022, nie RFC) — trotzdem überall ausgeliefert: Chromium ab M74 (2019), WebKit ab
|
||||||
|
[bug 209050](https://bugs.webkit.org/show_bug.cgi?id=209050) (RESOLVED FIXED, März 2020). WebKit ist
|
||||||
|
strenger als Chromium: Host-Kandidaten werden bis zur Capture-Erlaubnis komplett unterdrückt
|
||||||
|
([webkit.org/blog/7763](https://webkit.org/blog/7763/a-closer-look-into-webrtc/), 03.07.2017).
|
||||||
|
Ob die `.local`-Namen im Android-WebView auch *auflösbar* sind (Multicast braucht dort einen
|
||||||
|
`MulticastLock`), ist ungeprüft — Experiment E6.
|
||||||
|
- **Was ein feindlicher TURN-Betreiber unter `relay` lernt:** nicht den Klartext (DTLS-SRTP bleibt
|
||||||
|
Ende-zu-Ende), aber beide öffentlichen IPs, exakt wer-mit-wem, Start/Ende/Dauer, und über Paketgrößen
|
||||||
|
eine **Voice-Activity-Detection**. Opus läuft per Default variabel-bitratig; das leckt Sprech-/Pausen-Muster.
|
||||||
|
**Gegenmaßnahme: `cbr=1` (und ggf. `usedtx=0`) in der Opus-`fmtp`-Zeile**, gegen Bandbreite eingetauscht.
|
||||||
|
Gehört als Notiz zu Ticket 04/09, auch wenn Codecs formal out of scope sind — es ist eine SDP-Frage.
|
||||||
|
- **CSP kann WebRTC nicht einschränken.** Der `webrtc`-Direktive existiert in
|
||||||
|
[CSP Level 3](https://www.w3.org/TR/CSP3/) (Working Draft, **13.08.2026**, §6.2.1: *„The `webrtc`
|
||||||
|
directive controls whether a global object may initiate WebRTC connections"*), ist aber **nirgends
|
||||||
|
implementiert**: Firefox Bugzilla 1783489 NEW/unassigned, Chromium 40188662 noch im „evaluate"-Stadium,
|
||||||
|
MDN-Doku-Issue als *not planned* geschlossen. `connect-src` greift bei `RTCPeerConnection` nicht — genau
|
||||||
|
deshalb brauchte es überhaupt eine eigene Direktive. Ein harter Kill-Switch geht nur JS-seitig
|
||||||
|
(Konstruktor vor dem App-Code entfernen).
|
||||||
|
|
||||||
|
### 7.6 WebView-spezifische Eigenheit: wry vergibt Mikrofonrechte ohne App-Beteiligung
|
||||||
|
|
||||||
|
Aus §3.3 und §4.2 folgt eine Eigenschaft, die man kennen sollte:
|
||||||
|
|
||||||
|
- **iOS/macOS (wry 0.55.1):** `WKPermissionDecision::Grant`, **bedingungslos**, für jeden Origin und
|
||||||
|
jeden Frame. Es gibt in dieser Version keinen Hook, um das zu verhindern.
|
||||||
|
- **Android (wry 0.55.1/0.56):** `RESOURCE_AUDIO_CAPTURE` steht in der Menge der bekannten, automatisch
|
||||||
|
weitergereichten Ressourcen.
|
||||||
|
|
||||||
|
Solange das Frontend ausschließlich gebündelter, eigener Code auf `tauri://localhost` bzw.
|
||||||
|
`http://tauri.localhost` ist, ist das Risiko gering — der feindliche Server liefert kein Skript aus.
|
||||||
|
Es wird sofort relevant, sobald irgendwann **remote Inhalte, ein `<iframe>`, eine Vorschau-Einbettung
|
||||||
|
oder ein `<webview>` für fremde Inhalte** dazukommt: Dann bekäme dieser Origin Mikrofonzugriff, ohne dass
|
||||||
|
die App gefragt würde. Für eine datenschutzorientierte App ist das eine Regel, die aufgeschrieben gehört:
|
||||||
|
**keine fremden Origins im selben WebView.**
|
||||||
|
|
||||||
|
Ab Tauri 2.12 (wry ≥ 0.56, PR #14865) lässt sich das über `on_permission_request` sauber kontrollieren.
|
||||||
|
Bis dahin ist die Architektur-Regel der einzige Schutz.
|
||||||
|
|
||||||
|
### 7.7 Insertable Streams / SFrame — falls je ein SFU kommt
|
||||||
|
|
||||||
|
[W3C WebRTC Encoded Transform](https://www.w3.org/TR/webrtc-encoded-transform/) (Working Draft, 25.06.2026),
|
||||||
|
inkl. SFrame nach RFC 9605. Support (MDN BCD):
|
||||||
|
|
||||||
|
| Engine | `RTCRtpScriptTransform` |
|
||||||
|
|---|---|
|
||||||
|
| Safari / WKWebView (iOS) | **15.4** |
|
||||||
|
| Chrome / Android WebView / WebView2 | **141** (~Okt. 2025) |
|
||||||
|
| Firefox | 117 |
|
||||||
|
| Chromium-Legacy `createEncodedStreams()` | 86 (nicht standardisiert, WebKit hat es nie) |
|
||||||
|
|
||||||
|
Wichtige Einschränkung für das Bedrohungsmodell: Der SFU behält auch mit E2EE Zugriff auf
|
||||||
|
RTP-Metadaten — *„synchronization sources, payload types, timestamps, and audio levels"*. Die
|
||||||
|
`audio-level`-Header-Extension ist ein direktes Voice-Activity-Orakel; sie müsste beim SFU-Einsatz
|
||||||
|
wegverhandelt werden.
|
||||||
|
|
||||||
|
Für die im Ticket 04 zur Debatte stehende **Mesh-Topologie ist das alles irrelevant** — dort ist
|
||||||
|
DTLS-SRTP schon Ende-zu-Ende. Es wird erst mit einem SFU nötig.
|
||||||
|
|
||||||
|
### 7.8 `enumerateDevices()`
|
||||||
|
|
||||||
|
Spec-konform sind `label`-Felder vor der Erlaubnis leer; `deviceId`/`groupId` sind aber pro Origin stabile
|
||||||
|
Hashes, und **Anzahl und Art der Geräte lecken vor der Erlaubnis**. In einer Tauri-App ist das
|
||||||
|
konstruktionsbedingt kein Angriffsvektor (das Frontend ist eigener Code). Die daraus folgende Regel ist
|
||||||
|
eine andere: **Geräte-Inventar niemals an den Server senden** — nicht in Telemetrie, nicht in Logs, nicht
|
||||||
|
in Crash-Reports. Und SDP ist selbst ein starker Fingerabdruck (Codec-Liste, Header-Extensions, `a=msid`,
|
||||||
|
in Chromium `a=ssrc cname`); wo möglich reduzieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Offen — und welches Experiment es entscheidet
|
||||||
|
|
||||||
|
Nichts davon blockiert die Spec-Arbeit; alles davon sollte vor der *Implementierung* geklärt sein.
|
||||||
|
|
||||||
|
**E1 — iOS: funktioniert `getUserMedia` real auf einem Gerät? (höchste Priorität)**
|
||||||
|
Ist die einzige Frage, die noch ein offenes Upstream-Issue hat (tauri#12068).
|
||||||
|
Minimale Tauri-v2-App zweimal bauen — einmal **mit**, einmal **ohne** `NSMicrophoneUsageDescription` —
|
||||||
|
auf einem **physischen** iPhone (iOS ≥ 15). In beiden Fällen loggen: `typeof navigator.mediaDevices`,
|
||||||
|
`window.isSecureContext`, `location.origin`, `await navigator.mediaDevices.enumerateDevices()`, dann
|
||||||
|
`getUserMedia({audio:true})` aus einem Click-Handler. Erwartung: `isSecureContext === true`,
|
||||||
|
`origin === "tauri://localhost"`, Erfolg nur in der Variante mit Key. Zusätzlich prüfen, ob der Key nach
|
||||||
|
dem Merge tatsächlich in `gen/apple/*/Info.plist` steht (tauri#13068 warnt vor stillem Überschreiben).
|
||||||
|
|
||||||
|
**E2 — Android: ist `MODIFY_AUDIO_SETTINGS` wirklich zwingend?**
|
||||||
|
Dieselbe App dreimal: nur `RECORD_AUDIO` / nur `MODIFY_AUDIO_SETTINGS` / beide. Erwartung nach
|
||||||
|
Code-Lesung: nur die dritte Variante liefert einen Stream; die erste bringt `NotAllowedError` *nachdem*
|
||||||
|
der Nutzer im Dialog zugestimmt hat. Entscheidet zugleich tauri#10846.
|
||||||
|
|
||||||
|
**E3 — Überlebt das hand-editierte `AndroidManifest.xml`?**
|
||||||
|
Auf einem Scratch-Checkout `tauri android init`, Manifest editieren, `tauri android build`, diffen;
|
||||||
|
dann `tauri android init` erneut und wieder diffen. Erwartung: Build lässt es in Ruhe, `init` rendert neu.
|
||||||
|
|
||||||
|
**E4 — Ist `http://tauri.localhost` im Android-WebView ein Secure Context?**
|
||||||
|
Über `chrome://inspect` in der laufenden App auswerten: `window.isSecureContext` und
|
||||||
|
`!!window.crypto.subtle`. Beide `true` ⇒ wry#1709 ist widerlegt und `useHttpsScheme` bleibt aus.
|
||||||
|
|
||||||
|
**E5 — Haben Arch und Fedora WebKitGTK mit `-DENABLE_WEB_RTC=ON` gebaut?**
|
||||||
|
Für Debian/Ubuntu und Alpine ist die Antwort belegt (nein). Für Arch/Fedora blockte die Bot-Schranke.
|
||||||
|
Distro-unabhängiger und definitiver Test auf einer beliebigen Maschine:
|
||||||
|
`nm -D --defined-only /usr/lib/*/libwebkit2gtk-4.1.so.0 | grep -i rtcpeerconnection`
|
||||||
|
— oder in einem beliebigen WebKitGTK-View `typeof RTCPeerConnection === 'function'`.
|
||||||
|
|
||||||
|
**E6 — Lösen sich mDNS-`.local`-ICE-Kandidaten im Android-WebView und in WKWebView auf?**
|
||||||
|
Zwei echte Geräte im selben LAN, `pc.onicecandidate` loggen, prüfen ob `typ host`-Kandidaten auf
|
||||||
|
`.local` enden **und** ob die Verbindung trotzdem zustande kommt.
|
||||||
|
|
||||||
|
**E7 — Wann kommt Tauri 2.12?**
|
||||||
|
Trägt PR #14865 (`on_permission_request`), gemergt 03.08.2026. crates.io `tauri` steht auf 2.11.5.
|
||||||
|
Relevant für Linux Gate 3 und für §7.6.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Konfigurationsschritte, kompakt
|
||||||
|
|
||||||
|
```
|
||||||
|
src-tauri/Info.ios.plist [NEU]
|
||||||
|
NSMicrophoneUsageDescription = "…"
|
||||||
|
|
||||||
|
src-tauri/gen/android/app/src/main/AndroidManifest.xml [EDITIEREN, committen]
|
||||||
|
+ android.permission.RECORD_AUDIO
|
||||||
|
+ android.permission.MODIFY_AUDIO_SETTINGS
|
||||||
|
+ uses-feature android.hardware.microphone (required=false)
|
||||||
|
|
||||||
|
src-tauri/tauri.conf.json [UNVERÄNDERT]
|
||||||
|
csp: null — passt, CSP betrifft WebRTC ohnehin nicht
|
||||||
|
useHttpsScheme — nicht setzen, außer E4 fällt negativ aus (dann VOR dem ersten Release)
|
||||||
|
bundle.iOS.minimumSystemVersion: mindestens "14.3", praktisch "15.0"
|
||||||
|
|
||||||
|
Windows [NICHTS ZU TUN]
|
||||||
|
Linux [NICHT LÖSBAR — siehe §5, Entscheidung in Ticket 04]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Quellen
|
||||||
|
|
||||||
|
**Apple / WebKit**
|
||||||
|
- WebKit-Blog, *MediaRecorder API*, 23.11.2020 — https://webkit.org/blog/11353/mediarecorder-api/
|
||||||
|
- WebKit-Blog, *A Closer Look Into WebRTC*, 03.07.2017 — https://webkit.org/blog/7763/a-closer-look-into-webrtc/
|
||||||
|
- Apple, `webView(_:requestMediaCapturePermissionFor:…)`, iOS 15.0+ — https://developer.apple.com/documentation/webkit/wkuidelegate/webview(_:requestmediacapturepermissionfor:initiatedbyframe:type:decisionhandler:)
|
||||||
|
- Apple, `NSMicrophoneUsageDescription` — https://developer.apple.com/documentation/BundleResources/Information-Property-List/NSMicrophoneUsageDescription
|
||||||
|
- WebKit-Quellcode, `Source/WebCore/page/SecurityOrigin.cpp` — https://github.com/WebKit/WebKit/blob/main/Source/WebCore/page/SecurityOrigin.cpp
|
||||||
|
- WebKit-Bug 209050, mDNS-Kandidaten in Safari, RESOLVED FIXED (März 2020) — https://bugs.webkit.org/show_bug.cgi?id=209050
|
||||||
|
|
||||||
|
**Android / Chromium**
|
||||||
|
- Android 5.0 Release Notes (WebView → Chromium M37, WebRTC, `PermissionRequest`) — https://developer.android.com/about/versions/android-5.0
|
||||||
|
- AOSP `WebChromeClient.java` (Default = `request.deny()`) — https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/java/android/webkit/WebChromeClient.java
|
||||||
|
- AOSP `PermissionRequest.java` — https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/java/android/webkit/PermissionRequest.java
|
||||||
|
- AOSP `core/res/AndroidManifest.xml` (Protection Levels) — https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/res/AndroidManifest.xml
|
||||||
|
- Chromium `net/base/url_util.cc` (`IsNormalizedLocalhostTLD`) — https://chromium.googlesource.com/chromium/src/+/refs/heads/main/net/base/url_util.cc
|
||||||
|
- Chromium `services/network/public/cpp/is_potentially_trustworthy.cc` — https://chromium.googlesource.com/chromium/src/+/refs/heads/main/services/network/public/cpp/is_potentially_trustworthy.cc
|
||||||
|
|
||||||
|
**Microsoft / WebView2**
|
||||||
|
- `ICoreWebView2PermissionRequestedEventArgs` (Default = `STATE_DEFAULT`; ab 0.9.430) — https://learn.microsoft.com/en-us/microsoft-edge/webview2/reference/win32/icorewebview2permissionrequestedeventargs
|
||||||
|
- WebView2 IDL (`COREWEBVIEW2_PERMISSION_KIND_MICROPHONE`, `…_STATE_DEFAULT`) — https://learn.microsoft.com/en-us/microsoft-edge/webview2/reference/win32/webview2-idl
|
||||||
|
- WebView2 *Working with local content* (getUserMedia / Secure Context) — https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/working-with-local-content
|
||||||
|
|
||||||
|
**WebKitGTK**
|
||||||
|
- `Source/cmake/OptionsGTK.cmake` (`ENABLE_WEB_RTC` = `${ENABLE_EXPERIMENTAL_FEATURES}`) — https://github.com/WebKit/WebKit/blob/main/Source/cmake/OptionsGTK.cmake
|
||||||
|
- `Source/cmake/WebKitFeatures.cmake` (`ENABLE_EXPERIMENTAL_FEATURES` = OFF) — https://github.com/WebKit/WebKit/blob/main/Source/cmake/WebKitFeatures.cmake
|
||||||
|
- `WebKitSettings:enable-webrtc` (Default FALSE, seit 2.38) — https://webkitgtk.org/reference/webkit2gtk/stable/property.Settings.enable-webrtc.html
|
||||||
|
- `WebKitSettings:enable-media-stream` (Default FALSE laut Doku, Port-Default ON, seit 2.4) — https://webkitgtk.org/reference/webkit2gtk/stable/property.Settings.enable-media-stream.html
|
||||||
|
- `WebKitUserMediaPermissionRequest` („denied by default") — https://webkitgtk.org/reference/webkit2gtk/stable/class.UserMediaPermissionRequest.html
|
||||||
|
- `WebKitWebView::permission-request` — https://webkitgtk.org/reference/webkit2gtk/stable/signal.WebView.permission-request.html
|
||||||
|
- WebKitGTK 2.52 Highlights (librice, Netzwerk-Sandbox), 18.03.2026 — https://webkitgtk.org/2026/03/18/webkitgtk-2.52-highlights.html
|
||||||
|
- Debian `webkit2gtk` `debian/rules` — https://sources.debian.org/src/webkit2gtk/latest/debian/rules/
|
||||||
|
- Alpine `community/webkit2gtk-4.1` APKBUILD — https://github.com/alpinelinux/aports/blob/master/community/webkit2gtk-4.1/APKBUILD
|
||||||
|
|
||||||
|
**Tauri / wry** (jeweils Tag `wry-v0.55.1`, sofern nicht anders vermerkt)
|
||||||
|
- `src/wkwebview/class/wry_web_view_ui_delegate.rs` — https://github.com/tauri-apps/wry/blob/wry-v0.55.1/src/wkwebview/class/wry_web_view_ui_delegate.rs
|
||||||
|
- `src/android/kotlin/RustWebChromeClient.kt` — https://github.com/tauri-apps/wry/blob/wry-v0.55.1/src/android/kotlin/RustWebChromeClient.kt
|
||||||
|
- `src/webview2/mod.rs` (nur Clipboard-Read) — https://github.com/tauri-apps/wry/blob/wry-v0.55.1/src/webview2/mod.rs
|
||||||
|
- `src/webkitgtk/mod.rs` (kein `connect_permission_request`) — https://github.com/tauri-apps/wry/blob/wry-v0.55.1/src/webkitgtk/mod.rs
|
||||||
|
- wry CHANGELOG (0.56.0, 30.07.2026: Permission-API; 0.56.1, 13.08.2026) — https://github.com/tauri-apps/wry/blob/dev/CHANGELOG.md
|
||||||
|
- wry PR #1654, *expanded permission handling API* — https://github.com/tauri-apps/wry/pull/1654
|
||||||
|
- `crates/tauri-utils/src/config.rs` (`use_https_scheme`, Default `false`) — https://github.com/tauri-apps/tauri/blob/dev/crates/tauri-utils/src/config.rs
|
||||||
|
- `crates/tauri/src/manager/mod.rs` (`tauri_protocol_url`) — https://github.com/tauri-apps/tauri/blob/dev/crates/tauri/src/manager/mod.rs
|
||||||
|
- AndroidManifest-Template (nur `INTERNET`) — https://github.com/tauri-apps/tauri/blob/dev/crates/tauri-cli/templates/mobile/android/app/src/main/AndroidManifest.xml
|
||||||
|
- `examples/api/src-tauri/Info.plist` (WebRTC-Usage-Strings) — https://github.com/tauri-apps/tauri/blob/dev/examples/api/src-tauri/Info.plist
|
||||||
|
- Tauri PR #14865, `on_permission_request`, gemergt 03.08.2026, Milestone 2.12 — https://github.com/tauri-apps/tauri/pull/14865
|
||||||
|
- `WebviewWindow::with_webview` — https://docs.rs/tauri/latest/tauri/webview/struct.WebviewWindow.html
|
||||||
|
- Tauri v2 Prerequisites (webkit2gtk-4.1) — https://v2.tauri.app/start/prerequisites/
|
||||||
|
- Tauri v2 CSP („only enabled if set") — https://v2.tauri.app/security/csp/
|
||||||
|
- Tauri v2 Capabilities / Permissions (ACL ≠ OS-Rechte) — https://v2.tauri.app/security/capabilities/ · https://v2.tauri.app/security/permissions/
|
||||||
|
|
||||||
|
**Tauri-Issues und -Discussions** (Hinweisgeber, mit Datum und Status)
|
||||||
|
- tauri#12068 *iOS Microphone Access* — offen, 28.12.2024 — https://github.com/tauri-apps/tauri/issues/12068
|
||||||
|
- tauri#10846 *Audio Permission Not Working on Android* — 31.08.2024 — https://github.com/tauri-apps/tauri/issues/10846
|
||||||
|
- tauri#12547 *Linux: access denied, no dialog* — not planned, 28.01.2025 — https://github.com/tauri-apps/tauri/issues/12547
|
||||||
|
- tauri#13143 *„browser does not support WebRTC"* (Ubuntu 22.04) — not planned/upstream, 05.04.2025 — https://github.com/tauri-apps/tauri/issues/13143
|
||||||
|
- tauri#13068 *Unify/Document handling of Info.plist files* — offen, 24.03.2025 — https://github.com/tauri-apps/tauri/issues/13068
|
||||||
|
- tauri#8989 *[feat] expose control over webkit settings* — offen — https://github.com/tauri-apps/tauri/issues/8989
|
||||||
|
- tauri#5042 / #8979 — WebView2 persistiert „Block" — https://github.com/tauri-apps/tauri/issues/5042
|
||||||
|
- wry#1195 *getUserMedia/getDisplayMedia prompt on macOS* — offen, 20.03.2024 — https://github.com/tauri-apps/wry/issues/1195
|
||||||
|
- wry#1709 / #1710 — Secure-Context-Behauptung, **unbelegt**, 01.04.2026 — https://github.com/tauri-apps/wry/issues/1709
|
||||||
|
- Discussion #8426 *Functional WebRTC in WebkitGTK on Linux!* — 28.01.2024 — https://github.com/tauri-apps/tauri/discussions/8426
|
||||||
|
- Discussion #12732 *Camera permissions on mobile (Android)* — Feb–Apr 2025 — https://github.com/orgs/tauri-apps/discussions/12732
|
||||||
|
- Discussion #8323 (FabianLars zu `gen/` im Git) — 01.12.2023 — https://github.com/orgs/tauri-apps/discussions/8323
|
||||||
|
|
||||||
|
**Standards**
|
||||||
|
- RFC 8827, *WebRTC Security Architecture*, Januar 2021 — https://www.rfc-editor.org/rfc/rfc8827.html
|
||||||
|
- W3C WebRTC 1.0 (`RTCIceTransportPolicy`) — https://www.w3.org/TR/webrtc/
|
||||||
|
- W3C Identity for WebRTC 1.0, CR 27.09.2018 (praktisch tot) — https://www.w3.org/TR/webrtc-identity/
|
||||||
|
- W3C WebRTC Encoded Transform, WD 25.06.2026 — https://www.w3.org/TR/webrtc-encoded-transform/
|
||||||
|
- W3C Secure Contexts — https://w3c.github.io/webappsec-secure-contexts/
|
||||||
|
- W3C CSP Level 3, WD 13.08.2026 (`webrtc`-Direktive, unimplementiert) — https://www.w3.org/TR/CSP3/
|
||||||
|
- IETF `draft-ietf-mmusic-mdns-ice-candidates` — abgelaufen, -03 (Juni 2022) — https://datatracker.ietf.org/doc/draft-ietf-mmusic-mdns-ice-candidates/
|
||||||
|
|
||||||
|
**Rust-Ökosystem**
|
||||||
|
- `webrtc` 0.20.2 (11.08.2026) — https://github.com/webrtc-rs/webrtc · https://crates.io/crates/webrtc
|
||||||
|
- `libwebrtc` 0.3.45 (10.08.2026) / `livekit` 0.8.3 — https://github.com/livekit/rust-sdks · https://crates.io/crates/libwebrtc
|
||||||
|
- `cpal` 0.18.1 (07.06.2026) — https://crates.io/crates/cpal
|
||||||
|
- `webrtc-audio-processing` 2.1.0 (13.05.2026) — https://crates.io/crates/webrtc-audio-processing
|
||||||
1492
docs/realtime/research/03-ws-auth-reconnect.md
Normal file
1492
docs/realtime/research/03-ws-auth-reconnect.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user