Compare commits
2 Commits
3241d07ed1
...
feat/realt
| Author | SHA1 | Date | |
|---|---|---|---|
| 55a2af4756 | |||
| 099315a538 |
173
Cargo.lock
generated
173
Cargo.lock
generated
@@ -57,6 +57,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum-core",
|
"axum-core",
|
||||||
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"form_urlencoded",
|
"form_urlencoded",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -75,8 +76,10 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_path_to_error",
|
"serde_path_to_error",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
|
"sha1 0.10.7",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-tungstenite",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -297,6 +300,12 @@ dependencies = [
|
|||||||
"cmov",
|
"cmov",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
@@ -469,6 +478,17 @@ version = "0.3.34"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
|
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-macro"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-sink"
|
name = "futures-sink"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
@@ -489,6 +509,7 @@ checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
|
"futures-macro",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -519,6 +540,18 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi 5.3.0",
|
||||||
|
"wasip2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -527,7 +560,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi 6.0.0",
|
||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1044,6 +1077,15 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ppv-lite86"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.107"
|
version = "1.0.107"
|
||||||
@@ -1062,12 +1104,28 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "5.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "r-efi"
|
name = "r-efi"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha",
|
||||||
|
"rand_core 0.9.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
@@ -1079,6 +1137,16 @@ dependencies = [
|
|||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core 0.9.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
@@ -1088,6 +1156,15 @@ dependencies = [
|
|||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -1125,12 +1202,14 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
|
"futures-util",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1252,6 +1331,17 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.2.17",
|
||||||
|
"digest 0.10.7",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha1"
|
name = "sha1"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
@@ -1457,7 +1547,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"serde",
|
"serde",
|
||||||
"sha1",
|
"sha1 0.11.0",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
@@ -1489,7 +1579,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"md-5",
|
"md-5",
|
||||||
"memchr",
|
"memchr",
|
||||||
"rand",
|
"rand 0.10.2",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
@@ -1704,6 +1794,32 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-tungstenite"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"log",
|
||||||
|
"tokio",
|
||||||
|
"tungstenite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-util"
|
||||||
|
version = "0.7.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"futures-util",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -1764,6 +1880,22 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tungstenite"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"data-encoding",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
"rand 0.9.5",
|
||||||
|
"sha1 0.10.7",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.1"
|
version = "1.20.1"
|
||||||
@@ -1851,6 +1983,15 @@ version = "0.11.1+wasi-snapshot-preview1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasip2"
|
||||||
|
version = "1.0.4+wasi-0.2.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.127"
|
version = "0.2.127"
|
||||||
@@ -2052,6 +2193,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen"
|
||||||
|
version = "0.57.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
version = "0.6.3"
|
version = "0.6.3"
|
||||||
@@ -2081,6 +2228,26 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy"
|
||||||
|
version = "0.8.56"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy-derive"
|
||||||
|
version = "0.8.56"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerofrom"
|
name = "zerofrom"
|
||||||
version = "0.1.8"
|
version = "0.1.8"
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = "0.8.9"
|
axum = { version = "0.8.9", features = ["ws"] }
|
||||||
|
tokio-util = { version = "0.7", features = ["rt"] }
|
||||||
|
futures-util = "0.3"
|
||||||
dotenv = "0.15.0"
|
dotenv = "0.15.0"
|
||||||
serde_json = "1.0.151"
|
serde_json = "1.0.151"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|||||||
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
7
migrations/20260814120000_realtime_cursor.down.sql
Normal file
7
migrations/20260814120000_realtime_cursor.down.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
DROP TRIGGER IF EXISTS conversation_events_notify ON conversation_events;
|
||||||
|
DROP FUNCTION IF EXISTS notify_conversation_event();
|
||||||
|
DROP TABLE IF EXISTS conversation_events;
|
||||||
|
DROP INDEX IF EXISTS messages_conversation_seq_idx;
|
||||||
|
ALTER TABLE messages ALTER COLUMN seq DROP NOT NULL;
|
||||||
|
ALTER TABLE messages DROP COLUMN IF EXISTS seq;
|
||||||
|
ALTER TABLE conversations DROP COLUMN IF EXISTS seq;
|
||||||
69
migrations/20260814120000_realtime_cursor.up.sql
Normal file
69
migrations/20260814120000_realtime_cursor.up.sql
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
-- Realtime-Grundlage: monotoner Cursor je Conversation + Änderungs-Log.
|
||||||
|
--
|
||||||
|
-- Warum ein Zähler auf `conversations` und nicht BIGSERIAL auf `messages`:
|
||||||
|
-- Eine Sequenz vergibt die Nummer beim INSERT, sichtbar wird die Zeile aber erst
|
||||||
|
-- beim COMMIT. Diese beiden Reihenfolgen können auseinanderlaufen — Transaktion A
|
||||||
|
-- holt 100, B holt 101 und committet zuerst; ein Leser bei `since = 101` verliert
|
||||||
|
-- Nachricht 100 für immer. Der Zähler hier wird per UPDATE gezogen und nimmt damit
|
||||||
|
-- den Zeilen-Lock der Conversation: der zweite Schreiber blockiert bis zum COMMIT
|
||||||
|
-- des ersten, also ist Commit-Reihenfolge == seq-Reihenfolge.
|
||||||
|
|
||||||
|
ALTER TABLE conversations ADD COLUMN seq BIGINT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Das Änderungs-Log. Eine Zeile je Mutation — diese Zeile *ist* der Event, der
|
||||||
|
-- über den WebSocket geht. Deckt auch ab, was keine eigene Nachrichtenzeile hat
|
||||||
|
-- (Reactions, Attachments, später Presence und Read-Receipts).
|
||||||
|
CREATE TABLE conversation_events (
|
||||||
|
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||||
|
seq BIGINT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
target_id UUID NOT NULL,
|
||||||
|
at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (conversation_id, seq)
|
||||||
|
);
|
||||||
|
-- Der Primärschlüssel bedient bereits `WHERE conversation_id = $1 AND seq > $2
|
||||||
|
-- ORDER BY seq` — der Backfill braucht keinen weiteren Index.
|
||||||
|
|
||||||
|
-- Unveränderlicher Sortierschlüssel einer Nachricht: die seq ihres
|
||||||
|
-- Erzeugungs-Events. Damit verliert `created_at` seine Sortierrolle — es ist NOW(),
|
||||||
|
-- also der Transaktionsstart, weder eindeutig noch commit-geordnet.
|
||||||
|
ALTER TABLE messages ADD COLUMN seq BIGINT;
|
||||||
|
|
||||||
|
-- Bestand nachziehen: nach created_at durchnummerieren, dann den Conversation-
|
||||||
|
-- Zähler auf das Maximum setzen.
|
||||||
|
WITH nummeriert AS (
|
||||||
|
SELECT id, row_number() OVER (PARTITION BY conversation_id ORDER BY created_at, id) AS n
|
||||||
|
FROM messages
|
||||||
|
)
|
||||||
|
UPDATE messages m
|
||||||
|
SET seq = nummeriert.n
|
||||||
|
FROM nummeriert
|
||||||
|
WHERE m.id = nummeriert.id;
|
||||||
|
|
||||||
|
UPDATE conversations c
|
||||||
|
SET seq = COALESCE((SELECT max(m.seq) FROM messages m WHERE m.conversation_id = c.id), 0);
|
||||||
|
|
||||||
|
ALTER TABLE messages ALTER COLUMN seq SET NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX messages_conversation_seq_idx ON messages(conversation_id, seq);
|
||||||
|
|
||||||
|
-- Für Bestandsnachrichten werden bewusst KEINE Log-Zeilen nachgetragen: ein Client
|
||||||
|
-- ohne Cursor bekommt `resync` und lädt über den Historien-Endpunkt, nie über das
|
||||||
|
-- Log. Das Log braucht also keine Vergangenheit, nur Zukunft.
|
||||||
|
|
||||||
|
-- Der Signalpfad. Der Trigger enthält keine Fachlogik — er feuert nur. Damit kann
|
||||||
|
-- kein künftiger Schreibweg das Benachrichtigen vergessen, und weil NOTIFY
|
||||||
|
-- transaktional ist, feuert er erst beim COMMIT, also genau dann, wenn die Zeile
|
||||||
|
-- auch sichtbar wird.
|
||||||
|
CREATE FUNCTION notify_conversation_event() RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM pg_notify(
|
||||||
|
'conversation_changed',
|
||||||
|
json_build_object('conversation', NEW.conversation_id, 'seq', NEW.seq)::text
|
||||||
|
);
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER conversation_events_notify
|
||||||
|
AFTER INSERT ON conversation_events
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION notify_conversation_event();
|
||||||
@@ -1,26 +1,74 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
Json,
|
Extension, Json,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use crate::{model::Message, schema::MessageSchema, AppState};
|
|
||||||
use super::db_err;
|
use super::db_err;
|
||||||
|
use crate::realtime::{self, MESSAGE_CREATED, MESSAGE_DELETED, MESSAGE_EDITED};
|
||||||
|
use crate::{model::Claims, model::Message, schema::MessageSchema, AppState};
|
||||||
|
|
||||||
|
// TODO: create/update/delete still trust `user_id` and `conversation_id` from
|
||||||
|
// the request body and check no membership. Pre-existing, but the realtime
|
||||||
|
// design leans on these endpoints being the security boundary — close this
|
||||||
|
// before any instance is exposed.
|
||||||
|
|
||||||
|
// Lock ordering: every path that touches both a conversation and one of its
|
||||||
|
// messages takes the conversation row lock FIRST (that is what `record_event`
|
||||||
|
// does). Doing it the other way round in one handler and this way round in
|
||||||
|
// another is how you get deadlocks under concurrent edits.
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct HistoryQuery {
|
||||||
|
/// Exclusive upper bound, for scrolling back. Omit for the newest page.
|
||||||
|
pub before: Option<i64>,
|
||||||
|
pub limit: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub struct History {
|
||||||
|
/// The conversation's current seq — what a client adopts as its cursor
|
||||||
|
/// after a resync.
|
||||||
|
pub cursor: i64,
|
||||||
|
pub messages: Vec<Message>,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_messages(
|
pub async fn list_messages(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
|
Extension(claims): Extension<Claims>,
|
||||||
Path(conversation_id): Path<Uuid>,
|
Path(conversation_id): Path<Uuid>,
|
||||||
|
Query(q): Query<HistoryQuery>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
let messages = sqlx::query_as::<_, Message>(
|
let user = super::sync::user_id(&claims)?;
|
||||||
"SELECT * FROM messages WHERE conversation_id = $1 AND deleted_at IS NULL ORDER BY created_at ASC",
|
super::sync::ensure_member(&state.db, conversation_id, user).await?;
|
||||||
|
|
||||||
|
let limit = q.limit.unwrap_or(50).clamp(1, 200);
|
||||||
|
let cursor: i64 = sqlx::query_scalar("SELECT seq FROM conversations WHERE id = $1")
|
||||||
|
.bind(conversation_id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
// Ordered by seq, not created_at: created_at is NOW(), i.e. transaction
|
||||||
|
// start, so under concurrent senders its order can contradict commit order.
|
||||||
|
// Newest page first, then reversed, so `before` walks backwards.
|
||||||
|
let mut messages = sqlx::query_as::<_, Message>(
|
||||||
|
"SELECT * FROM messages
|
||||||
|
WHERE conversation_id = $1 AND deleted_at IS NULL AND ($2::BIGINT IS NULL OR seq < $2)
|
||||||
|
ORDER BY seq DESC LIMIT $3",
|
||||||
)
|
)
|
||||||
.bind(conversation_id)
|
.bind(conversation_id)
|
||||||
|
.bind(q.before)
|
||||||
|
.bind(limit)
|
||||||
.fetch_all(&state.db)
|
.fetch_all(&state.db)
|
||||||
.await
|
.await
|
||||||
.map_err(db_err)?;
|
.map_err(db_err)?;
|
||||||
Ok(Json(messages))
|
messages.reverse();
|
||||||
|
|
||||||
|
Ok(Json(History { cursor, messages }))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_message(
|
pub async fn get_message(
|
||||||
@@ -40,50 +88,121 @@ pub async fn create_message(
|
|||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(body): Json<MessageSchema>,
|
Json(body): Json<MessageSchema>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
let message = sqlx::query_as::<_, Message>(
|
let mut tx = state.db.begin().await.map_err(db_err)?;
|
||||||
"INSERT INTO messages (conversation_id, user_id, content) VALUES ($1, $2, $3) RETURNING *",
|
|
||||||
|
let seq = realtime::record_event(&mut tx, body.conversation_id, MESSAGE_CREATED, body.id)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
// The id comes from the client, so a retry after an unclear network error
|
||||||
|
// arrives as the same insert. DO NOTHING turns that into a no-op rather than
|
||||||
|
// a duplicate or a 500.
|
||||||
|
let inserted = sqlx::query_as::<_, Message>(
|
||||||
|
"INSERT INTO messages (id, conversation_id, user_id, content, seq)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
RETURNING *",
|
||||||
)
|
)
|
||||||
|
.bind(body.id)
|
||||||
.bind(body.conversation_id)
|
.bind(body.conversation_id)
|
||||||
.bind(body.user_id)
|
.bind(body.user_id)
|
||||||
.bind(&body.content)
|
.bind(&body.content)
|
||||||
|
.bind(seq)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
match inserted {
|
||||||
|
Some(message) => {
|
||||||
|
tx.commit().await.map_err(db_err)?;
|
||||||
|
Ok((StatusCode::CREATED, Json(message)))
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Roll back so the sequence bump and the log row disappear with it —
|
||||||
|
// a duplicate send must not advance anybody's cursor.
|
||||||
|
tx.rollback().await.map_err(db_err)?;
|
||||||
|
let existing = sqlx::query_as::<_, Message>("SELECT * FROM messages WHERE id = $1")
|
||||||
|
.bind(body.id)
|
||||||
.fetch_one(&state.db)
|
.fetch_one(&state.db)
|
||||||
.await
|
.await
|
||||||
.map_err(db_err)?;
|
.map_err(db_err)?;
|
||||||
Ok((StatusCode::CREATED, Json(message)))
|
Ok((StatusCode::OK, Json(existing)))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_message(
|
pub async fn update_message(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(body): Json<MessageSchema>,
|
Json(body): Json<UpdateMessageSchema>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
sqlx::query_as::<_, Message>(
|
let mut tx = state.db.begin().await.map_err(db_err)?;
|
||||||
|
|
||||||
|
// Read the parent first so the conversation lock is taken before the message
|
||||||
|
// row — see the lock-ordering note at the top.
|
||||||
|
let conversation_id: Option<Uuid> =
|
||||||
|
sqlx::query_scalar("SELECT conversation_id FROM messages WHERE id = $1 AND deleted_at IS NULL")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
let Some(conversation_id) = conversation_id else {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "Message not found".into()));
|
||||||
|
};
|
||||||
|
|
||||||
|
realtime::record_event(&mut tx, conversation_id, MESSAGE_EDITED, id)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
// `seq` deliberately stays as it was: an edit moves the message in cursor
|
||||||
|
// space, not in the conversation.
|
||||||
|
let message = sqlx::query_as::<_, Message>(
|
||||||
"UPDATE messages SET content = $1, updated_at = NOW()
|
"UPDATE messages SET content = $1, updated_at = NOW()
|
||||||
WHERE id = $2 AND deleted_at IS NULL RETURNING *",
|
WHERE id = $2 AND deleted_at IS NULL RETURNING *",
|
||||||
)
|
)
|
||||||
.bind(&body.content)
|
.bind(&body.content)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_optional(&state.db)
|
.fetch_one(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(db_err)?
|
.map_err(db_err)?;
|
||||||
.map(|m| Json(m).into_response())
|
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Message not found".into()))
|
tx.commit().await.map_err(db_err)?;
|
||||||
|
Ok(Json(message))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_message(
|
pub async fn delete_message(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
let res = sqlx::query(
|
let mut tx = state.db.begin().await.map_err(db_err)?;
|
||||||
"UPDATE messages SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL",
|
|
||||||
)
|
let conversation_id: Option<Uuid> =
|
||||||
|
sqlx::query_scalar("SELECT conversation_id FROM messages WHERE id = $1 AND deleted_at IS NULL")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&state.db)
|
.fetch_optional(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(db_err)?;
|
.map_err(db_err)?;
|
||||||
if res.rows_affected() > 0 {
|
|
||||||
Ok(StatusCode::NO_CONTENT.into_response())
|
let Some(conversation_id) = conversation_id else {
|
||||||
} else {
|
return Err((StatusCode::NOT_FOUND, "Message not found".into()));
|
||||||
Err((StatusCode::NOT_FOUND, "Message not found".into()))
|
};
|
||||||
}
|
|
||||||
|
realtime::record_event(&mut tx, conversation_id, MESSAGE_DELETED, id)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
sqlx::query("UPDATE messages SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
tx.commit().await.map_err(db_err)?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct UpdateMessageSchema {
|
||||||
|
pub content: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ pub mod conversation;
|
|||||||
pub mod conversation_member;
|
pub mod conversation_member;
|
||||||
pub mod message;
|
pub mod message;
|
||||||
pub mod reaction;
|
pub mod reaction;
|
||||||
|
pub mod sync;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
pub mod workspace_invitation;
|
pub mod workspace_invitation;
|
||||||
pub mod workspace_member;
|
pub mod workspace_member;
|
||||||
|
pub mod ws;
|
||||||
|
|
||||||
pub(crate) fn db_err(e: sqlx::Error) -> (StatusCode, String) {
|
pub(crate) fn db_err(e: sqlx::Error) -> (StatusCode, String) {
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||||
|
|||||||
150
src/handlers/sync.rs
Normal file
150
src/handlers/sync.rs
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
//! Manifest and backfill — the two endpoints a client uses to catch up.
|
||||||
|
//!
|
||||||
|
//! The WebSocket only ever says "conversation X is now at seq N". Everything
|
||||||
|
//! with content is pulled through here, and authorization lives here too: the
|
||||||
|
//! event carries no content, so the realtime registry is a delivery index, not a
|
||||||
|
//! security boundary. These handlers are the boundary.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
|
Extension, Json,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::db_err;
|
||||||
|
use crate::{model::Claims, model::Message, AppState};
|
||||||
|
|
||||||
|
/// Ticket 06: above this many entities a delta is not worth building — the
|
||||||
|
/// client is told to resync instead. Tunable without a protocol change.
|
||||||
|
const DELTA_LIMIT: i64 = 500;
|
||||||
|
|
||||||
|
pub(crate) fn user_id(claims: &Claims) -> Result<Uuid, (StatusCode, String)> {
|
||||||
|
Uuid::parse_str(&claims.sub)
|
||||||
|
.map_err(|_| (StatusCode::UNAUTHORIZED, "Malformed subject in token".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn ensure_member(
|
||||||
|
db: &sqlx::PgPool,
|
||||||
|
conversation_id: Uuid,
|
||||||
|
user: Uuid,
|
||||||
|
) -> Result<(), (StatusCode, String)> {
|
||||||
|
let member: Option<Uuid> = sqlx::query_scalar(
|
||||||
|
"SELECT user_id FROM conversation_members WHERE conversation_id = $1 AND user_id = $2",
|
||||||
|
)
|
||||||
|
.bind(conversation_id)
|
||||||
|
.bind(user)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
// 404 rather than 403: whether a conversation exists is itself information.
|
||||||
|
member
|
||||||
|
.map(|_| ())
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, "Conversation not found".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, sqlx::FromRow)]
|
||||||
|
pub struct ManifestEntry {
|
||||||
|
pub conversation_id: Uuid,
|
||||||
|
pub seq: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One cheap query that answers "what has moved since I was last here" for every
|
||||||
|
/// conversation at once. Without it a client with forty conversations would fire
|
||||||
|
/// forty backfills after every reconnect — and reconnects are routine on mobile.
|
||||||
|
pub async fn sync(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Extension(claims): Extension<Claims>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
|
let user = user_id(&claims)?;
|
||||||
|
|
||||||
|
let entries = sqlx::query_as::<_, ManifestEntry>(
|
||||||
|
"SELECT c.id AS conversation_id, c.seq
|
||||||
|
FROM conversations c
|
||||||
|
JOIN conversation_members m ON m.conversation_id = c.id
|
||||||
|
WHERE m.user_id = $1 AND c.deleted_at IS NULL
|
||||||
|
ORDER BY c.id",
|
||||||
|
)
|
||||||
|
.bind(user)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
Ok(Json(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ChangesQuery {
|
||||||
|
pub since: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `resync` is a normal outcome, not an error — it happens on every first
|
||||||
|
/// connection. Sending it as 4xx would run it through `error_for_status()`
|
||||||
|
/// reflexes, fill error logs, and let reconnect logic mistake it for a network
|
||||||
|
/// failure. Hence one 200 with a discriminated body.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum Changes {
|
||||||
|
Delta {
|
||||||
|
cursor: i64,
|
||||||
|
/// Includes tombstones — a client that never sees the soft-deleted row
|
||||||
|
/// never learns about the deletion.
|
||||||
|
messages: Vec<Message>,
|
||||||
|
},
|
||||||
|
Resync {
|
||||||
|
cursor: i64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn changes(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Extension(claims): Extension<Claims>,
|
||||||
|
Path(conversation_id): Path<Uuid>,
|
||||||
|
Query(q): Query<ChangesQuery>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
|
let user = user_id(&claims)?;
|
||||||
|
ensure_member(&state.db, conversation_id, user).await?;
|
||||||
|
|
||||||
|
let cursor: i64 = sqlx::query_scalar("SELECT seq FROM conversations WHERE id = $1")
|
||||||
|
.bind(conversation_id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
// One over the limit, so a full page is distinguishable from an overflow.
|
||||||
|
let targets: Vec<Uuid> = sqlx::query_scalar(
|
||||||
|
"SELECT DISTINCT target_id FROM conversation_events
|
||||||
|
WHERE conversation_id = $1 AND seq > $2
|
||||||
|
ORDER BY target_id
|
||||||
|
LIMIT $3",
|
||||||
|
)
|
||||||
|
.bind(conversation_id)
|
||||||
|
.bind(q.since)
|
||||||
|
.bind(DELTA_LIMIT + 1)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
if targets.len() as i64 > DELTA_LIMIT {
|
||||||
|
return Ok(Json(Changes::Resync { cursor }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolved current state, not the events themselves: idempotent, and ten
|
||||||
|
// edits of one message collapse into one row instead of ten.
|
||||||
|
// TODO: reactions and attachments once they emit events — today only
|
||||||
|
// message_* kinds exist, so resolving messages covers every target.
|
||||||
|
let messages = sqlx::query_as::<_, Message>(
|
||||||
|
"SELECT * FROM messages WHERE conversation_id = $1 AND id = ANY($2) ORDER BY seq",
|
||||||
|
)
|
||||||
|
.bind(conversation_id)
|
||||||
|
.bind(&targets)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
Ok(Json(Changes::Delta { cursor, messages }))
|
||||||
|
}
|
||||||
204
src/handlers/ws.rs
Normal file
204
src/handlers/ws.rs
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
//! The realtime socket.
|
||||||
|
//!
|
||||||
|
//! Carries only `{conversation, seq}` downstream — no content ever crosses it,
|
||||||
|
//! which is what lets the REST handlers stay the single authorization boundary.
|
||||||
|
//!
|
||||||
|
//! Auth rides in on the normal `Authorization: Bearer` header, because the
|
||||||
|
//! client owns this connection from Rust rather than from a WebView. That also
|
||||||
|
//! means real HTTP status codes reach the client on a rejected handshake; a
|
||||||
|
//! browser would only ever see close code 1006.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicI64, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade},
|
||||||
|
extract::State,
|
||||||
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
|
Extension,
|
||||||
|
};
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::db_err;
|
||||||
|
use crate::hub::{close, Connection, MAX_CONNECTIONS_PER_USER};
|
||||||
|
use crate::{model::Claims, AppState};
|
||||||
|
|
||||||
|
/// The client pings; we only answer and remember. One direction serves both
|
||||||
|
/// sides: the client notices a dead link from the missing pong, we notice it
|
||||||
|
/// from the missing ping. 15s keeps us under the strictest documented proxy idle
|
||||||
|
/// timeout (Azure Application Gateway, 20s).
|
||||||
|
const HEARTBEAT: Duration = Duration::from_secs(15);
|
||||||
|
const HEARTBEAT_GRACE: i64 = 2;
|
||||||
|
/// How often we check the two deadlines. Cheap, so it can be coarse.
|
||||||
|
const TICK: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
enum Inbound {
|
||||||
|
/// The client refreshed its access token over REST and hands it in here, so
|
||||||
|
/// the session survives without a reconnect. At 15 minutes of token life and
|
||||||
|
/// several connected servers, forced reconnects would otherwise be constant.
|
||||||
|
Reauth { token: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handler(
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Extension(claims): Extension<Claims>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
|
let user_id = super::sync::user_id(&claims)?;
|
||||||
|
|
||||||
|
let conversations: Vec<Uuid> =
|
||||||
|
sqlx::query_scalar("SELECT conversation_id FROM conversation_members WHERE user_id = $1")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
|
||||||
|
// Displace the oldest rather than refuse the newest: on mobile the OS kills
|
||||||
|
// apps without closing sockets, so refusing would let a dead connection lock
|
||||||
|
// the user out of their own account.
|
||||||
|
if state.hub.connection_count(user_id) >= MAX_CONNECTIONS_PER_USER {
|
||||||
|
if let Some(oldest) = state.hub.oldest_session(user_id) {
|
||||||
|
state.hub.displace(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ws.on_upgrade(move |socket| {
|
||||||
|
run(socket, state, user_id, claims.exp as i64, conversations)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(
|
||||||
|
socket: WebSocket,
|
||||||
|
state: Arc<AppState>,
|
||||||
|
user_id: Uuid,
|
||||||
|
token_exp: i64,
|
||||||
|
conversations: Vec<Uuid>,
|
||||||
|
) {
|
||||||
|
let session = Uuid::new_v4();
|
||||||
|
let connection = state.hub.register(session, user_id, &conversations);
|
||||||
|
|
||||||
|
let (mut sink, stream) = socket.split();
|
||||||
|
let deadline = Arc::new(AtomicI64::new(token_exp));
|
||||||
|
let last_seen = Arc::new(AtomicI64::new(now()));
|
||||||
|
|
||||||
|
let reader = tokio::spawn(read_loop(
|
||||||
|
stream,
|
||||||
|
connection.clone(),
|
||||||
|
state.clone(),
|
||||||
|
user_id,
|
||||||
|
deadline.clone(),
|
||||||
|
last_seen.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut ticker = tokio::time::interval(TICK);
|
||||||
|
let close_code = loop {
|
||||||
|
tokio::select! {
|
||||||
|
outbound = connection.take() => {
|
||||||
|
let mut failed = false;
|
||||||
|
for message in outbound {
|
||||||
|
let encoded = match serde_json::to_string(&message) {
|
||||||
|
Ok(encoded) => encoded,
|
||||||
|
Err(e) => { eprintln!("ws: could not encode {message:?}: {e}"); continue }
|
||||||
|
};
|
||||||
|
if sink.send(Message::Text(encoded.into())).await.is_err() {
|
||||||
|
failed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if failed { break close::RESTART }
|
||||||
|
}
|
||||||
|
_ = ticker.tick() => {
|
||||||
|
if now() >= deadline.load(Ordering::SeqCst) {
|
||||||
|
break close::TOKEN_EXPIRED;
|
||||||
|
}
|
||||||
|
if now() - last_seen.load(Ordering::SeqCst)
|
||||||
|
> HEARTBEAT.as_secs() as i64 * HEARTBEAT_GRACE
|
||||||
|
{
|
||||||
|
break close::RESTART;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = connection.cancel.cancelled() => break connection.close_code(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// axum 0.8 dropped `WebSocket::close`, so the frame goes out by hand.
|
||||||
|
let _ = sink
|
||||||
|
.send(Message::Close(Some(CloseFrame {
|
||||||
|
code: close_code,
|
||||||
|
reason: reason_for(close_code).into(),
|
||||||
|
})))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
connection.cancel.cancel();
|
||||||
|
reader.abort();
|
||||||
|
state.hub.unregister(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_loop(
|
||||||
|
mut stream: futures_util::stream::SplitStream<WebSocket>,
|
||||||
|
connection: Arc<Connection>,
|
||||||
|
state: Arc<AppState>,
|
||||||
|
user_id: Uuid,
|
||||||
|
deadline: Arc<AtomicI64>,
|
||||||
|
last_seen: Arc<AtomicI64>,
|
||||||
|
) {
|
||||||
|
while let Some(received) = stream.next().await {
|
||||||
|
let Ok(message) = received else { break };
|
||||||
|
last_seen.store(now(), Ordering::SeqCst);
|
||||||
|
|
||||||
|
match message {
|
||||||
|
// axum answers Ping itself; seeing it here is what keeps last_seen
|
||||||
|
// fresh.
|
||||||
|
Message::Ping(_) | Message::Pong(_) => {}
|
||||||
|
Message::Close(_) => break,
|
||||||
|
Message::Text(text) => match serde_json::from_str::<Inbound>(&text) {
|
||||||
|
Ok(Inbound::Reauth { token }) => {
|
||||||
|
match verify(&state, &token, user_id) {
|
||||||
|
Some(exp) => deadline.store(exp, Ordering::SeqCst),
|
||||||
|
None => {
|
||||||
|
connection.close_with(close::UNAUTHORIZED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("ws: undecodable frame: {e}"),
|
||||||
|
},
|
||||||
|
Message::Binary(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connection.close_with(close::RESTART);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A refreshed token must belong to the same user — otherwise a session could be
|
||||||
|
/// walked over to another account without reconnecting.
|
||||||
|
fn verify(state: &AppState, token: &str, expected: Uuid) -> Option<i64> {
|
||||||
|
let data = jsonwebtoken::decode::<Claims>(
|
||||||
|
token,
|
||||||
|
&jsonwebtoken::DecodingKey::from_secret(state.jwt_secret.as_bytes()),
|
||||||
|
&jsonwebtoken::Validation::default(),
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
(Uuid::parse_str(&data.claims.sub).ok()? == expected).then_some(data.claims.exp as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reason_for(code: u16) -> &'static str {
|
||||||
|
match code {
|
||||||
|
close::TOKEN_EXPIRED => "token expired",
|
||||||
|
close::DISPLACED => "displaced by a newer connection",
|
||||||
|
close::UNAUTHORIZED => "not authorized",
|
||||||
|
_ => "server going away",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
325
src/hub.rs
Normal file
325
src/hub.rs
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
//! Fan-out: getting "conversation X moved to seq N" to the right sockets.
|
||||||
|
//!
|
||||||
|
//! Two things make this simpler than it looks.
|
||||||
|
//!
|
||||||
|
//! First, only the *newest* seq per conversation matters. Delivering 5, then 6,
|
||||||
|
//! then 7 is equivalent to delivering only 7, because the client fetches current
|
||||||
|
//! state by cursor either way. So the per-connection buffer is a map, not a
|
||||||
|
//! queue: a newer value overwrites an older one, there is no overflow, and
|
||||||
|
//! therefore no overflow error path.
|
||||||
|
//!
|
||||||
|
//! Second, the event carries no content — only an id and a number. Delivery is
|
||||||
|
//! an index, not a security boundary; the REST handlers do the authorization.
|
||||||
|
//!
|
||||||
|
//! Delivery goes through Postgres `NOTIFY` even with a single instance. One path
|
||||||
|
//! instead of two means the tested path is the production path, and running more
|
||||||
|
//! than one instance needs no second implementation.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU64, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use sqlx::postgres::PgListener;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tokio::sync::Notify;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub const CHANNEL: &str = "conversation_changed";
|
||||||
|
|
||||||
|
/// Ticket 07: generous against the legitimate case — desktop, phone, tablet,
|
||||||
|
/// second machine — and still a hard ceiling.
|
||||||
|
pub const MAX_CONNECTIONS_PER_USER: usize = 8;
|
||||||
|
|
||||||
|
pub type SessionId = Uuid;
|
||||||
|
|
||||||
|
/// What the server sends down a socket. Deliberately tiny: about 60 bytes, well
|
||||||
|
/// under the 8 kB `NOTIFY` payload limit that shaped this protocol.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum Outbound {
|
||||||
|
Changed { conversation: Uuid, seq: i64 },
|
||||||
|
/// "Re-pull the manifest." Sent when the listener reconnected and events in
|
||||||
|
/// the gap are simply gone — NOTIFY has no history.
|
||||||
|
Resync,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close codes. The client maps these onto three behaviours: reconnect now,
|
||||||
|
/// reconnect with backoff, or do not reconnect at all. `DISPLACED` is why the
|
||||||
|
/// table exists — without an explicit "stay down", a displaced device reconnects
|
||||||
|
/// and displaces the next one, and two devices push each other around in circles.
|
||||||
|
pub mod close {
|
||||||
|
/// Server going down. Reconnect immediately, with jitter.
|
||||||
|
pub const RESTART: u16 = 1012;
|
||||||
|
/// No valid token any more. Refresh, then reconnect; if the refresh fails,
|
||||||
|
/// send the user to sign-in.
|
||||||
|
pub const TOKEN_EXPIRED: u16 = 4001;
|
||||||
|
/// A newer connection took this slot. Do not reconnect.
|
||||||
|
pub const DISPLACED: u16 = 4002;
|
||||||
|
/// Account gone or not permitted. Do not reconnect.
|
||||||
|
pub const UNAUTHORIZED: u16 = 4003;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Connection {
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub cancel: CancellationToken,
|
||||||
|
/// Monotonic registration order, so "displace the oldest" means the oldest
|
||||||
|
/// and not merely some deterministic pick.
|
||||||
|
registered: u64,
|
||||||
|
close_code: AtomicU16,
|
||||||
|
/// conversation -> highest seq not yet written to the socket.
|
||||||
|
dirty: Mutex<HashMap<Uuid, i64>>,
|
||||||
|
resync: AtomicBool,
|
||||||
|
signal: Notify,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Connection {
|
||||||
|
pub fn close_code(&self) -> u16 {
|
||||||
|
self.close_code.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close_with(&self, code: u16) {
|
||||||
|
self.close_code.store(code, Ordering::SeqCst);
|
||||||
|
self.cancel.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blocks until there is something to send, then hands over everything at
|
||||||
|
/// once — coalescing falls out of the map for free.
|
||||||
|
pub async fn take(&self) -> Vec<Outbound> {
|
||||||
|
loop {
|
||||||
|
{
|
||||||
|
let mut dirty = self.dirty.lock().unwrap();
|
||||||
|
let resync = self.resync.swap(false, Ordering::SeqCst);
|
||||||
|
if resync || !dirty.is_empty() {
|
||||||
|
let mut out = Vec::with_capacity(dirty.len() + 1);
|
||||||
|
if resync {
|
||||||
|
// Ordered first: the client throws its cache away, so
|
||||||
|
// anything queued behind it would be redundant anyway.
|
||||||
|
out.push(Outbound::Resync);
|
||||||
|
}
|
||||||
|
for (conversation, seq) in dirty.drain() {
|
||||||
|
out.push(Outbound::Changed { conversation, seq });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.signal.notified().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark(&self, conversation: Uuid, seq: i64) {
|
||||||
|
let mut dirty = self.dirty.lock().unwrap();
|
||||||
|
// max, not insert: NOTIFY guarantees ordering only per listener, so a
|
||||||
|
// blind overwrite could replace a newer seq with an older one.
|
||||||
|
dirty
|
||||||
|
.entry(conversation)
|
||||||
|
.and_modify(|held| *held = (*held).max(seq))
|
||||||
|
.or_insert(seq);
|
||||||
|
drop(dirty);
|
||||||
|
self.signal.notify_one();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_resync(&self) {
|
||||||
|
self.resync.store(true, Ordering::SeqCst);
|
||||||
|
self.signal.notify_one();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Hub {
|
||||||
|
next_registration: AtomicU64,
|
||||||
|
connections: RwLock<HashMap<SessionId, Arc<Connection>>>,
|
||||||
|
by_conversation: RwLock<HashMap<Uuid, HashSet<SessionId>>>,
|
||||||
|
/// Needed because a *join* cannot be routed by conversation — the joining
|
||||||
|
/// user is not in that set yet, which is the whole point of the join.
|
||||||
|
by_user: RwLock<HashMap<Uuid, HashSet<SessionId>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Hub {
|
||||||
|
pub fn connection_count(&self, user_id: Uuid) -> usize {
|
||||||
|
self.by_user
|
||||||
|
.read()
|
||||||
|
.unwrap()
|
||||||
|
.get(&user_id)
|
||||||
|
.map_or(0, |s| s.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The oldest session of this user, for the connection limit. Displacing the
|
||||||
|
/// oldest rather than refusing the newest matters on mobile, where the OS
|
||||||
|
/// kills apps without closing sockets — otherwise a stale connection locks
|
||||||
|
/// the user out of their own account.
|
||||||
|
pub fn oldest_session(&self, user_id: Uuid) -> Option<SessionId> {
|
||||||
|
let by_user = self.by_user.read().unwrap();
|
||||||
|
let sessions = by_user.get(&user_id)?;
|
||||||
|
let connections = self.connections.read().unwrap();
|
||||||
|
sessions
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| connections.get(s).map(|c| (c.registered, *s)))
|
||||||
|
.min()
|
||||||
|
.map(|(_, session)| session)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register(
|
||||||
|
&self,
|
||||||
|
session: SessionId,
|
||||||
|
user_id: Uuid,
|
||||||
|
conversations: &[Uuid],
|
||||||
|
) -> Arc<Connection> {
|
||||||
|
let connection = Arc::new(Connection {
|
||||||
|
user_id,
|
||||||
|
cancel: CancellationToken::new(),
|
||||||
|
registered: self.next_registration.fetch_add(1, Ordering::SeqCst),
|
||||||
|
close_code: AtomicU16::new(close::RESTART),
|
||||||
|
dirty: Mutex::new(HashMap::new()),
|
||||||
|
resync: AtomicBool::new(false),
|
||||||
|
signal: Notify::new(),
|
||||||
|
});
|
||||||
|
|
||||||
|
self.connections
|
||||||
|
.write()
|
||||||
|
.unwrap()
|
||||||
|
.insert(session, connection.clone());
|
||||||
|
self.by_user
|
||||||
|
.write()
|
||||||
|
.unwrap()
|
||||||
|
.entry(user_id)
|
||||||
|
.or_default()
|
||||||
|
.insert(session);
|
||||||
|
|
||||||
|
let mut by_conversation = self.by_conversation.write().unwrap();
|
||||||
|
for conversation in conversations {
|
||||||
|
by_conversation
|
||||||
|
.entry(*conversation)
|
||||||
|
.or_default()
|
||||||
|
.insert(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
connection
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dropped connections are the normal case, not the exception — leaking
|
||||||
|
/// registry entries is the classic bug here.
|
||||||
|
pub fn unregister(&self, session: SessionId) {
|
||||||
|
let removed = self.connections.write().unwrap().remove(&session);
|
||||||
|
let Some(connection) = removed else { return };
|
||||||
|
|
||||||
|
let mut by_user = self.by_user.write().unwrap();
|
||||||
|
if let Some(sessions) = by_user.get_mut(&connection.user_id) {
|
||||||
|
sessions.remove(&session);
|
||||||
|
if sessions.is_empty() {
|
||||||
|
by_user.remove(&connection.user_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(by_user);
|
||||||
|
|
||||||
|
let mut by_conversation = self.by_conversation.write().unwrap();
|
||||||
|
by_conversation.retain(|_, sessions| {
|
||||||
|
sessions.remove(&session);
|
||||||
|
!sessions.is_empty()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn displace(&self, session: SessionId) {
|
||||||
|
if let Some(connection) = self.connections.read().unwrap().get(&session) {
|
||||||
|
connection.close_with(close::DISPLACED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close every socket with 1012 so clients reconnect promptly instead of
|
||||||
|
/// waiting for a timeout. Needed because axum's graceful shutdown does not
|
||||||
|
/// wait for — or close — upgraded connections (axum#3003, PR #3831 open).
|
||||||
|
pub fn close_all(&self) {
|
||||||
|
for connection in self.connections.read().unwrap().values() {
|
||||||
|
connection.close_with(close::RESTART);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish(&self, conversation: Uuid, seq: i64) {
|
||||||
|
let by_conversation = self.by_conversation.read().unwrap();
|
||||||
|
let Some(sessions) = by_conversation.get(&conversation) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let connections = self.connections.read().unwrap();
|
||||||
|
for session in sessions {
|
||||||
|
if let Some(connection) = connections.get(session) {
|
||||||
|
connection.mark(conversation, seq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resync_everyone(&self) {
|
||||||
|
for connection in self.connections.read().unwrap().values() {
|
||||||
|
connection.mark_resync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Notification {
|
||||||
|
conversation: Uuid,
|
||||||
|
seq: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Listens to Postgres and feeds the hub. Runs for the life of the process.
|
||||||
|
///
|
||||||
|
/// `try_recv` returning `Ok(None)` means sqlx transparently re-established the
|
||||||
|
/// connection — and notifications sent during that gap are gone for good, since
|
||||||
|
/// NOTIFY keeps no history. Every client is therefore told to resync, which is
|
||||||
|
/// cheap and rare, and closes the one hole this design would otherwise have.
|
||||||
|
pub async fn run_listener(pool: PgPool, hub: Arc<Hub>, cancel: CancellationToken) {
|
||||||
|
loop {
|
||||||
|
let mut listener = match PgListener::connect_with(&pool).await {
|
||||||
|
Ok(listener) => listener,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("listener: could not connect: {e}");
|
||||||
|
if wait_or_cancel(&cancel).await {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = listener.listen(CHANNEL).await {
|
||||||
|
eprintln!("listener: LISTEN failed: {e}");
|
||||||
|
if wait_or_cancel(&cancel).await {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = cancel.cancelled() => return,
|
||||||
|
received = listener.try_recv() => match received {
|
||||||
|
Ok(Some(notification)) => {
|
||||||
|
match serde_json::from_str::<Notification>(notification.payload()) {
|
||||||
|
Ok(n) => hub.publish(n.conversation, n.seq),
|
||||||
|
Err(e) => eprintln!("listener: undecodable payload: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
eprintln!("listener: reconnected — asking every client to resync");
|
||||||
|
hub.resync_everyone();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("listener: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if wait_or_cancel(&cancel).await {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if shutdown was requested while waiting.
|
||||||
|
async fn wait_or_cancel(cancel: &CancellationToken) -> bool {
|
||||||
|
tokio::select! {
|
||||||
|
_ = cancel.cancelled() => true,
|
||||||
|
_ = tokio::time::sleep(std::time::Duration::from_secs(2)) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/main.rs
38
src/main.rs
@@ -3,8 +3,10 @@ use std::sync::Arc;
|
|||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
||||||
mod handlers;
|
mod handlers;
|
||||||
|
mod hub;
|
||||||
mod middleware;
|
mod middleware;
|
||||||
mod model;
|
mod model;
|
||||||
|
mod realtime;
|
||||||
mod route;
|
mod route;
|
||||||
mod schema;
|
mod schema;
|
||||||
|
|
||||||
@@ -14,6 +16,7 @@ use sqlx::{PgPool, postgres::PgPoolOptions};
|
|||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: PgPool,
|
pub db: PgPool,
|
||||||
pub jwt_secret: String,
|
pub jwt_secret: String,
|
||||||
|
pub hub: Arc<hub::Hub>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -42,12 +45,41 @@ async fn main() {
|
|||||||
let port: String = std::env::var("API_PORT").expect("API_PORT must be set!");
|
let port: String = std::env::var("API_PORT").expect("API_PORT must be set!");
|
||||||
let addr: String = format!("{}:{}", ip_addr, port);
|
let addr: String = format!("{}:{}", ip_addr, port);
|
||||||
|
|
||||||
let state = Arc::new(AppState { db: pool, jwt_secret });
|
let state = Arc::new(AppState {
|
||||||
|
db: pool.clone(),
|
||||||
|
jwt_secret,
|
||||||
|
hub: Arc::new(hub::Hub::default()),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fan-out always goes through Postgres NOTIFY, even with a single instance:
|
||||||
|
// one path means the tested path is the production path, and running more
|
||||||
|
// than one instance later needs no second implementation.
|
||||||
|
let shutdown = tokio_util::sync::CancellationToken::new();
|
||||||
|
tokio::spawn(hub::run_listener(
|
||||||
|
pool,
|
||||||
|
state.hub.clone(),
|
||||||
|
shutdown.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
let app: Router = Router::new()
|
let app: Router = Router::new()
|
||||||
.nest("/api/v1", route::router(state.clone()))
|
.nest("/api/v1", route::router(state.clone()))
|
||||||
.with_state(state);
|
.with_state(state.clone());
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||||
println!("Server started successfully at {}", addr);
|
println!("Server started successfully at {}", addr);
|
||||||
axum::serve(listener, app).await.unwrap();
|
|
||||||
|
// axum's graceful shutdown neither closes nor waits for upgraded
|
||||||
|
// connections (axum#3003; fix PR #3831 still open), so the sockets are
|
||||||
|
// closed here by hand before the runtime goes away.
|
||||||
|
let closing = state.clone();
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.with_graceful_shutdown(async move {
|
||||||
|
let _ = tokio::signal::ctrl_c().await;
|
||||||
|
println!("Shutting down — closing realtime connections");
|
||||||
|
closing.hub.close_all();
|
||||||
|
shutdown.cancel();
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,10 @@ pub struct Message {
|
|||||||
pub conversation_id: Uuid,
|
pub conversation_id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
|
/// Immutable sort key within the conversation. Ordering follows this, never
|
||||||
|
/// `created_at` — that is NOW(), i.e. transaction start, so it is neither
|
||||||
|
/// unique nor commit-ordered.
|
||||||
|
pub seq: i64,
|
||||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
pub deleted_at: Option<chrono::DateTime<chrono::Utc>>,
|
pub deleted_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
|||||||
235
src/realtime.rs
Normal file
235
src/realtime.rs
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
//! Cursor allocation and the change log.
|
||||||
|
//!
|
||||||
|
//! Everything that mutates a conversation goes through `record_event`. It bumps
|
||||||
|
//! the conversation's counter and writes the log row in one step, so you cannot
|
||||||
|
//! get a sequence number without also emitting the event that goes with it.
|
||||||
|
//!
|
||||||
|
//! The `UPDATE ... RETURNING` is what makes the cursor safe: it takes the row
|
||||||
|
//! lock on the conversation, so a second writer blocks until the first commits.
|
||||||
|
//! Commit order therefore equals seq order, and a reader polling `seq > n` can
|
||||||
|
//! never skip a row. A plain BIGSERIAL does not have this property — it hands out
|
||||||
|
//! numbers at INSERT time, but rows become visible at COMMIT time, and those two
|
||||||
|
//! orders can diverge.
|
||||||
|
//!
|
||||||
|
//! Must be called inside the same transaction as the mutation it describes.
|
||||||
|
|
||||||
|
use sqlx::PgConnection;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub const MESSAGE_CREATED: &str = "message_created";
|
||||||
|
pub const MESSAGE_EDITED: &str = "message_edited";
|
||||||
|
pub const MESSAGE_DELETED: &str = "message_deleted";
|
||||||
|
|
||||||
|
pub async fn record_event(
|
||||||
|
tx: &mut PgConnection,
|
||||||
|
conversation_id: Uuid,
|
||||||
|
kind: &str,
|
||||||
|
target_id: Uuid,
|
||||||
|
) -> Result<i64, sqlx::Error> {
|
||||||
|
let seq: i64 =
|
||||||
|
sqlx::query_scalar("UPDATE conversations SET seq = seq + 1 WHERE id = $1 RETURNING seq")
|
||||||
|
.bind(conversation_id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// The INSERT fires the pg_notify trigger — but only on COMMIT, since NOTIFY
|
||||||
|
// is transactional. Listeners therefore never see an event before the row.
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO conversation_events (conversation_id, seq, kind, target_id)
|
||||||
|
VALUES ($1, $2, $3, $4)",
|
||||||
|
)
|
||||||
|
.bind(conversation_id)
|
||||||
|
.bind(seq)
|
||||||
|
.bind(kind)
|
||||||
|
.bind(target_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
//! Guards the cursor against the gap described above. Needs a running
|
||||||
|
//! Postgres with the migrations applied:
|
||||||
|
//!
|
||||||
|
//! docker compose up -d db && cargo test realtime
|
||||||
|
//!
|
||||||
|
//! These tests deliberately panic instead of skipping when DATABASE_URL is
|
||||||
|
//! missing — a correctness test that quietly passes without running is worse
|
||||||
|
//! than no test at all.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use sqlx::{postgres::PgPoolOptions, PgPool};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
async fn pool() -> PgPool {
|
||||||
|
dotenv::dotenv().ok();
|
||||||
|
let url = std::env::var("DATABASE_URL")
|
||||||
|
.expect("DATABASE_URL must be set — these tests need a real Postgres");
|
||||||
|
PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&url)
|
||||||
|
.await
|
||||||
|
.expect("could not connect to Postgres")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fresh conversation with an owner, so the tests do not interfere.
|
||||||
|
async fn fixture(pool: &PgPool) -> Uuid {
|
||||||
|
let user: Uuid = sqlx::query_scalar(
|
||||||
|
"INSERT INTO users (email, password_hash, username, display_name)
|
||||||
|
VALUES ($1, 'x', $2, 'Probe') RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(format!("probe-{}@example.invalid", Uuid::new_v4()))
|
||||||
|
.bind(format!("probe-{}", Uuid::new_v4()))
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
sqlx::query_scalar(
|
||||||
|
"INSERT INTO conversations (kind, name, created_by)
|
||||||
|
VALUES ('channel', 'cursor probe', $1) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(user)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything visible after `since`, the way the backfill endpoint reads it.
|
||||||
|
async fn read_since(pool: &PgPool, conversation: Uuid, since: i64) -> Vec<i64> {
|
||||||
|
sqlx::query_scalar(
|
||||||
|
"SELECT seq FROM conversation_events
|
||||||
|
WHERE conversation_id = $1 AND seq > $2 ORDER BY seq",
|
||||||
|
)
|
||||||
|
.bind(conversation)
|
||||||
|
.bind(since)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two senders overlap: A starts first and commits last. A reader polling
|
||||||
|
/// with a cursor in between must still end up seeing both.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_senders_leave_no_gap() {
|
||||||
|
let pool = pool().await;
|
||||||
|
let conversation = fixture(&pool).await;
|
||||||
|
|
||||||
|
let mut a = pool.begin().await.unwrap();
|
||||||
|
let a_seq = record_event(&mut a, conversation, MESSAGE_CREATED, Uuid::new_v4())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// B runs concurrently and will block on the conversation row lock until
|
||||||
|
// A commits. That blocking IS the mechanism under test.
|
||||||
|
let pool_b = pool.clone();
|
||||||
|
let b = tokio::spawn(async move {
|
||||||
|
let mut tx = pool_b.begin().await.unwrap();
|
||||||
|
let seq = record_event(&mut tx, conversation, MESSAGE_CREATED, Uuid::new_v4())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
seq
|
||||||
|
});
|
||||||
|
|
||||||
|
// Give B time to actually reach the lock.
|
||||||
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
|
||||||
|
// Nothing is committed yet, so a reader sees nothing — in particular it
|
||||||
|
// cannot advance its cursor past A.
|
||||||
|
assert!(
|
||||||
|
read_since(&pool, conversation, 0).await.is_empty(),
|
||||||
|
"reader saw an uncommitted event"
|
||||||
|
);
|
||||||
|
|
||||||
|
a.commit().await.unwrap();
|
||||||
|
let b_seq = b.await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(a_seq, 1, "A committed first, so it must hold the lower seq");
|
||||||
|
assert_eq!(b_seq, 2);
|
||||||
|
|
||||||
|
// Read incrementally, exactly like a client following its cursor.
|
||||||
|
let mut cursor = 0i64;
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
loop {
|
||||||
|
let batch = read_since(&pool, conversation, cursor).await;
|
||||||
|
if batch.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cursor = *batch.last().unwrap();
|
||||||
|
seen.extend(batch);
|
||||||
|
}
|
||||||
|
assert_eq!(seen, vec![1, 2], "a message was skipped by the cursor");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same interleaving against a plain BIGSERIAL — the obvious design.
|
||||||
|
/// This one MUST show the gap; if it ever stops failing to deliver a row,
|
||||||
|
/// the test above is no longer proving anything.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn naive_sequence_does_lose_a_row() {
|
||||||
|
let pool = pool().await;
|
||||||
|
let probe = Uuid::new_v4();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS naive_cursor_probe (
|
||||||
|
seq BIGSERIAL PRIMARY KEY, probe UUID NOT NULL)",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut a = pool.begin().await.unwrap();
|
||||||
|
let a_seq: i64 =
|
||||||
|
sqlx::query_scalar("INSERT INTO naive_cursor_probe (probe) VALUES ($1) RETURNING seq")
|
||||||
|
.bind(probe)
|
||||||
|
.fetch_one(&mut *a)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// No lock is taken, so B sails past A and gets the higher number.
|
||||||
|
let mut b = pool.begin().await.unwrap();
|
||||||
|
let b_seq: i64 =
|
||||||
|
sqlx::query_scalar("INSERT INTO naive_cursor_probe (probe) VALUES ($1) RETURNING seq")
|
||||||
|
.bind(probe)
|
||||||
|
.fetch_one(&mut *b)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
b.commit().await.unwrap();
|
||||||
|
|
||||||
|
assert!(a_seq < b_seq);
|
||||||
|
|
||||||
|
// The reader polls while A is still open, sees only B, and advances its
|
||||||
|
// cursor past A's number.
|
||||||
|
let read = |since: i64| {
|
||||||
|
let pool = pool.clone();
|
||||||
|
async move {
|
||||||
|
sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT seq FROM naive_cursor_probe WHERE probe = $1 AND seq > $2 ORDER BY seq",
|
||||||
|
)
|
||||||
|
.bind(probe)
|
||||||
|
.bind(since)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let first = read(0).await;
|
||||||
|
assert_eq!(first, vec![b_seq]);
|
||||||
|
let cursor = *first.last().unwrap();
|
||||||
|
|
||||||
|
a.commit().await.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
read(cursor).await.is_empty(),
|
||||||
|
"expected the naive design to lose A — it did not, so this test no longer \
|
||||||
|
demonstrates the failure it exists to demonstrate"
|
||||||
|
);
|
||||||
|
|
||||||
|
sqlx::query("DROP TABLE naive_cursor_probe")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/route.rs
11
src/route.rs
@@ -1,6 +1,6 @@
|
|||||||
use crate::handlers::{
|
use crate::handlers::{
|
||||||
attachment, auth, auth_token, conversation, conversation_member, message, reaction, user,
|
attachment, auth, auth_token, conversation, conversation_member, message, reaction, sync, user,
|
||||||
workspace, workspace_invitation, workspace_member,
|
workspace, workspace_invitation, workspace_member, ws,
|
||||||
};
|
};
|
||||||
use crate::middleware::require_auth;
|
use crate::middleware::require_auth;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
@@ -81,6 +81,13 @@ pub fn router(state: Arc<AppState>) -> Router<Arc<AppState>> {
|
|||||||
"/conversations/{conversation_id}/members/{user_id}",
|
"/conversations/{conversation_id}/members/{user_id}",
|
||||||
delete(conversation_member::remove_member),
|
delete(conversation_member::remove_member),
|
||||||
)
|
)
|
||||||
|
// Realtime. The socket carries only {conversation, seq}; everything with
|
||||||
|
// content is pulled through the two endpoints below.
|
||||||
|
.route("/ws", get(ws::handler))
|
||||||
|
// Catch-up: one cheap manifest for every conversation, then targeted
|
||||||
|
// backfills for the few that actually moved.
|
||||||
|
.route("/sync", get(sync::sync))
|
||||||
|
.route("/conversations/{id}/changes", get(sync::changes))
|
||||||
// Messages
|
// Messages
|
||||||
.route("/conversations/{id}/messages", get(message::list_messages))
|
.route("/conversations/{id}/messages", get(message::list_messages))
|
||||||
.route("/messages", post(message::create_message))
|
.route("/messages", post(message::create_message))
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ pub struct ConversationMemberSchema {
|
|||||||
|
|
||||||
#[derive(Deserialize, Serialize, Debug)]
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
pub struct MessageSchema {
|
pub struct MessageSchema {
|
||||||
|
/// Supplied by the client, not the server. Without it the client cannot
|
||||||
|
/// reconcile its optimistically rendered message with the one that comes
|
||||||
|
/// back over the realtime channel — it would show up twice. Bonus: sending
|
||||||
|
/// becomes idempotent, so a retry after an unclear network error is a no-op
|
||||||
|
/// instead of a duplicate.
|
||||||
|
pub id: Uuid,
|
||||||
pub conversation_id: Uuid,
|
pub conversation_id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
|
|||||||
Reference in New Issue
Block a user