Update project plan for desktop client

Refine documentation regarding the desktop client, including its
architecture, tech stack, and development rationale. Emphasize the
choice of `eframe`/`egui` over alternatives like Tauri and Compose
Multiplatform, and explain the decision to use `ffmpeg` as a subprocess
for audio recording and graceful stopping. Clarify the rationale for
prioritizing the desktop client's development.
This commit is contained in:
2026-04-17 16:47:22 +02:00
parent d0f70e706e
commit 6fbb549ddf
+90 -8
View File
@@ -307,9 +307,25 @@ Wird nach Abschluss der Watch-App gebaut, andockt an dieselben `:core-*`-Module.
**Data Layer Sync (optional, später):** **Data Layer Sync (optional, später):**
Wenn Watch und Handy gekoppelt sind (klassisches Wear-OS-Pairing), *könnten* beide Geräte die heutige Fallliste via `DataClient` (Wear OS Data Layer API) replizieren — nur Marker-Dateien, keine Audios. Das ist eine reine UX-Verbesserung (beide Geräte sehen denselben Fall), nicht MVP-relevant und wird erst nach Handy-App-Grundfunktion evaluiert. Wenn Watch und Handy gekoppelt sind (klassisches Wear-OS-Pairing), *könnten* beide Geräte die heutige Fallliste via `DataClient` (Wear OS Data Layer API) replizieren — nur Marker-Dateien, keine Audios. Das ist eine reine UX-Verbesserung (beide Geräte sehen denselben Fall), nicht MVP-relevant und wird erst nach Handy-App-Grundfunktion evaluiert.
#### 1c. Weitere Plattformen (Linux / Windows / iOS) — optional, Ausblick #### 1c. Desktop-Clients (Linux / Windows) — erster nativer Client
Wegen der fixierten Invariante ist jeder weitere Client lediglich „ein weiterer HTTP-Client". Tech-Stack ist pro Plattform offen — denkbare Optionen: **Compose Multiplatform** (Code-Sharing über Android hinaus), **Tauri + Rust** (Desktop, kleine Binaries, teilt ggf. Logik mit dem Server), **nativ** (SwiftUI für iOS). Entscheidung erst nach Handy-App-Erfahrung. **Ist-Stand:** Der Linux-Desktop-Client ist gebaut und funktioniert (`client-desktop/`). Er ist der **erste echte native Client überhaupt** — wurde vor der Watch-App gebaut, weil der Nutzer noch keine Pixel-Watch-Hardware hat und ein realer Client für API-Ergonomie-Tests (Retry, Content-Types, Auth-Fehlerpfade) nötig war.
**Features (MVP):**
- Config-Panel beim Erststart (Server-URL + API-Key, TOML unter `~/.config/doctate/client.toml`)
- Ein-Klick-Aufnahme („● Neu") + Stop-Button mit Live-Timer
- Persistente Pending-Queue (`~/.local/share/doctate/pending/`, m4a + `{stem}.meta.json`-Sidecar)
- Uploader mit exponentieller Retry (2/4/8/16/32 s, Cap 60 s, unbegrenzt für transiente Fehler, sofortiger Abbruch bei 400/401/403/413)
- Startup-Recovery: Pending-Dir-Scan beim Launch → Crash-Residuen automatisch retried
- State-Machine: `NotConfigured``Idle``Recording``FinalizingRecording``Uploading``Idle` | `Error`
**Tech-Stack (Linux):** `eframe`/`egui` (Immediate-Mode-UI, natives Fenster, keine WebView) + `tokio` (Background-Runtime für Recorder + Uploader) + `reqwest` (Multipart-Upload, rustls) + `ffmpeg`-Subprozess (m4a/AAC direkt; Graceful-Stop via `SIGINT` über `libc::kill`).
**Windows-Desktop:** geplant „bald" — derselbe `client-desktop`-Crate mit `#[cfg(target_os = "windows")]`-Gates, vor allem für `audio_input_args` (dshow statt PulseAudio) und Graceful-Stop (`q`-Stdin als Best-Effort, weil `tokio::process::Child` keine einfache SIGINT-Entsprechung auf Windows bietet).
**iOS:** SwiftUI, Ausblick — nicht in Arbeit.
**Zurückgestellt (nicht MVP):** System-Tray-Icon (Design war fertig, dann `tray-icon`-Crate verworfen wegen System-Dep-Schwergewicht mit `libxdo`/`gtk3`/`libappindicator`; `ksni` als pure-Rust-dbus-Alternative für später dokumentiert), Autostart, Keyboard-Shortcuts, Fall-Liste, Oneliner-Polling.
--- ---
@@ -811,7 +827,8 @@ Wichtig: LLM-Antworten werden **nicht** geloggt (potenziell patientenbezogene Da
| Schicht | Technologie | | Schicht | Technologie |
|---|---| |---|---|
| Android-Clients (Watch primär, Handy geplant) | Kotlin, Multi-Modul-Gradle. UI: Compose for Wear OS (`:app-wear`) bzw. Jetpack Compose Material 3 (`:app-mobile`). Geteilte Core-Module (`:core-*`) sind UI-unabhängig und JVM-testbar. | | Android-Clients (Watch primär, Handy geplant) | Kotlin, Multi-Modul-Gradle. UI: Compose for Wear OS (`:app-wear`) bzw. Jetpack Compose Material 3 (`:app-mobile`). Geteilte Core-Module (`:core-*`) sind UI-unabhängig und JVM-testbar. |
| Weitere Clients (Linux/Windows/iOS, optional) | Stack offen — Kandidaten: Compose Multiplatform, Tauri + Rust, nativ (SwiftUI). Entscheidung nach Handy-App-Erfahrung. | | Desktop-Client (Linux gebaut, Windows geplant) | Rust, Cargo-Workspace-Member `client-desktop`. `eframe`/`egui` (Immediate-Mode-UI), `tokio` (async I/O), `reqwest` (Multipart-Upload), `ffmpeg`-Subprozess (m4a-Recording, SIGINT-Stop). Teilt `doctate-common`-Lib mit dem Server. |
| iOS-Gerät (optional, später) | SwiftUI oder Compose Multiplatform. Entscheidung nach Watch/Handy-Erfahrung. |
| Server | Rust (edition 2024), Axum 0.8, askama, reqwest, tracing, `strsim` + `spellbook` (Gazetteer); SSE für Live-Updates ist Phase-4-TODO | | Server | Rust (edition 2024), Axum 0.8, askama, reqwest, tracing, `strsim` + `spellbook` (Gazetteer); SSE für Live-Updates ist Phase-4-TODO |
| STT | faster-whisper (CTranslate2, large-v3, eigener Docker Container mit HTTP-API) | | STT | faster-whisper (CTranslate2, large-v3, eigener Docker Container mit HTTP-API) |
| Preprocessing | Ollama, Gemma 3 4B (keep_alive: 0 für VRAM-Freigabe nach Request) | | Preprocessing | Ollama, Gemma 3 4B (keep_alive: 0 für VRAM-Freigabe nach Request) |
@@ -874,6 +891,53 @@ wiremock = "0.6"
**Gazetteer-Stack separat begründet:** `strsim` liefert die Kernmetrik (Damerau-Levenshtein ≤ 2 über N-Gramme gegen kuratiertes Vocab). `spellbook` ist ein pure-Rust Hunspell-kompatibler Parser, der `.aff`-Affix-Regeln live anwendet — Tokens, die als gültige deutsche Alltagswörter erkannt werden (inkl. Flexionen wie *Kakteen* aus *Kaktus*), werden vom Rewrite ausgenommen (Dict-Veto). Siehe Abschnitt „Gazetteer" weiter oben. **Gazetteer-Stack separat begründet:** `strsim` liefert die Kernmetrik (Damerau-Levenshtein ≤ 2 über N-Gramme gegen kuratiertes Vocab). `spellbook` ist ein pure-Rust Hunspell-kompatibler Parser, der `.aff`-Affix-Regeln live anwendet — Tokens, die als gültige deutsche Alltagswörter erkannt werden (inkl. Flexionen wie *Kakteen* aus *Kaktus*), werden vom Rewrite ausgenommen (Dict-Veto). Siehe Abschnitt „Gazetteer" weiter oben.
### Cargo-Workspace-Layout
Das Repository ist ein **Cargo-Workspace** mit drei Rust-Crates:
```
doctate/
├── Cargo.toml (Workspace-Root, edition 2024, resolver 3)
├── server/ (doctate-server bin; Axum)
├── doctate-common/ (doctate-common lib; API-Typen + Konstanten)
├── client-desktop/ (doctate-desktop bin; eframe + ffmpeg)
└── watch/wearos/ (Kotlin, kein Rust-Member)
```
**`doctate-common`** ist die einzige Quelle der API-Wahrheit zwischen Server und Clients:
- `ack::AckResponse`, `ack::AckStatus` — ACK-Typen für `POST /api/upload`
- `timestamp::now_rfc3339()`, `filename_stem_to_recorded_at()`, `recorded_at_to_filename_stem()` — bijektive Umwandlung UTC-RFC3339 ↔ Filesystem-safe-Name (Colon ↔ Hyphen im Zeit-Teil)
- `constants::API_KEY_HEADER` (= `"X-API-Key"`), `UPLOAD_PATH`, `FIELD_CASE_ID`, `FIELD_RECORDED_AT`, `FIELD_AUDIO`, `CONTENT_TYPE_AUDIO_MP4`
Deps der Shared-Lib sind minimal: nur `serde`, `uuid`, `time`**kein** `tokio`, `reqwest`, `axum`. Dadurch bleibt sie runtime-agnostisch und kompiliert auch in WASM-Kontexten, falls je ein Browser-Client dazukommt.
### Rust Crates (Client-Desktop)
```toml
[dependencies]
doctate-common = { path = "../doctate-common" }
eframe = "0.28" # egui + winit
reqwest = { workspace = true }
tokio = { workspace = true }
serde, serde_json, toml, uuid, tracing, tracing-subscriber # workspace-geerbt
directories = "5" # OS-konforme Config-/Data-Pfade
thiserror = "1"
which = "6" # ffmpeg-PATH-Check
[target.'cfg(unix)'.dependencies]
libc = "0.2" # SIGINT via libc::kill
[dev-dependencies]
tempfile = "3"
tokio = { workspace = true, features = ["test-util"] } # für start_paused in Retry-Tests
wiremock = "0.6"
```
**Bewusst NICHT verwendet:**
- `ffmpeg-next`/`libav-sys`: Build-Komplexität (system-ffmpeg-Headers) + GPL/LGPL-Lizenzfragen. Subprozess-Aufruf hat keine Linkage-Probleme.
- `cpal` + `fdk-aac-sys` + `mp4`: wäre ~500 LOC Reimplementierung von ffmpeg für marginalen Benefit.
- `tray-icon`: zieht `libxdo`/`gtk3`/`libappindicator` als System-Deps. Für MVP zu viel Friktion; `ksni` als Pure-Rust-dbus-Alternative dokumentiert für später.
--- ---
## Hardware ## Hardware
@@ -883,7 +947,9 @@ wiremock = "0.6"
| Pixel Watch 2 oder 3 (LTE empfohlen) | Primäres Erfassungsgerät (Aufnahme + Upload + Oneliner-Polling) | | Pixel Watch 2 oder 3 (LTE empfohlen) | Primäres Erfassungsgerät (Aufnahme + Upload + Oneliner-Polling) |
| Bluetooth-Headset (optional) | Bessere Aufnahmequalität | | Bluetooth-Headset (optional) | Bessere Aufnahmequalität |
| Android Phone | Sekundäres Erfassungsgerät (eigene App, geplant — Erfassung + Review) **und** Wear OS Companion. Für LTE-lose Watches bleibt der Wear-OS-Network-Proxy als Framework-Mechanismus erhalten — kein eigener Code auf der Companion-Ebene nötig. | | Android Phone | Sekundäres Erfassungsgerät (eigene App, geplant — Erfassung + Review) **und** Wear OS Companion. Für LTE-lose Watches bleibt der Wear-OS-Network-Proxy als Framework-Mechanismus erhalten — kein eigener Code auf der Companion-Ebene nötig. |
| Linux-/Windows-Desktop, iOS-Gerät (optional, später) | Weitere Client-Plattformen — sprechen dieselbe Server-API wie alle anderen Clients | | Linux-Desktop (Arzt-PC, CachyOS/Arch) | Erster nativer Client — Aufnahme + Upload. Tray-los, Fenster bleibt sichtbar. |
| Windows-Desktop | Geplant „bald"; gleicher Crate via `#[cfg(target_os = "windows")]`-Gates |
| iOS-Gerät (optional, später) | Weitere Client-Plattform — spricht dieselbe Server-API wie alle anderen |
| Unraid Server | nginx reverse proxy, TLS-Terminierung | | Unraid Server | nginx reverse proxy, TLS-Terminierung |
| Ubuntu Server (RTX 3060, 12 GB VRAM) | Docker: Axum (kein GPU), faster-whisper (GPU), Ollama (GPU) | | Ubuntu Server (RTX 3060, 12 GB VRAM) | Docker: Axum (kein GPU), faster-whisper (GPU), Ollama (GPU) |
@@ -1021,10 +1087,16 @@ wiremock = "0.6"
- [ ] Tombstone-Strategie für „gestern gelöscht" (TTL ~25 h, um Auferstehung zu vermeiden) - [ ] Tombstone-Strategie für „gestern gelöscht" (TTL ~25 h, um Auferstehung zu vermeiden)
- [ ] Test: Watch + Handy offline → beide nehmen auf → bei erneuter Bluetooth-Verbindung gleicht sich die Liste ab - [ ] Test: Watch + Handy offline → beide nehmen auf → bei erneuter Bluetooth-Verbindung gleicht sich die Liste ab
#### 5e — Weitere Plattformen (Ausblick, nicht MVP) #### 5e — Desktop-Clients (in Arbeit, nativer Rust-Stack)
- [ ] Tech-Stack-Entscheidung für Linux-/Windows-Desktop (Compose Multiplatform vs. Tauri vs. nativ) - [x] Tech-Stack entschieden: `eframe`/`egui` + `tokio` + `reqwest` + `ffmpeg`-Subprozess (nicht Tauri/Compose)
- [ ] iOS-App (SwiftUI oder Compose Multiplatform) - [x] Cargo-Workspace + `doctate-common`-Shared-Lib
- [ ] Invariante gilt weiterhin: jeder neue Client = weiterer HTTP-Client, kein Server-Code-Ausbau nötig - [x] Linux-Desktop: Config, Recorder (SIGINT-Stop), Uploader mit Retry + persistenter Queue, State-Machine, UI mit Fehlerpfad
- [x] Integration-Test für Recorder (`lavfi`-Silence, `#[ignore]`, verifiziert SIGINT-Graceful-Stop + `ftyp`-Magic)
- [ ] Windows-Desktop: `dshow`-Device-Enumeration; ffmpeg-Installationspfad (winget vs. Bundle)
- [ ] Tray-Icon (zurückgestellt; später via `ksni`, weil `tray-icon` zu viele System-Deps mitzieht)
- [ ] Oneliner-Polling + Fall-Liste im Client (post-MVP)
- [ ] iOS-App (SwiftUI, Ausblick)
- [x] Invariante gewahrt: jeder Client = weiterer HTTP-Client, kein Server-Code-Ausbau nötig
### Phase 6 — Integration & Testing ### Phase 6 — Integration & Testing
- [ ] End-to-End Test (Watch → Server → Webinterface) - [ ] End-to-End Test (Watch → Server → Webinterface)
@@ -1142,3 +1214,13 @@ Alle Einträge beziehen sich auf den Ist-Stand im Repository. Die ursprüngliche
| Admin-Log vs. Arzt-UI | Nur Arzt-UI geplant | Zusätzlich frühes Admin-Log unter `GET /web/` (flache Liste aller Fälle) | Gebaut, bevor Session/States/Fall-Detail existierten, um die Pipeline während Entwicklung inspizieren zu können. Soll später hinter `role = "admin"` geschützt werden. | | Admin-Log vs. Arzt-UI | Nur Arzt-UI geplant | Zusätzlich frühes Admin-Log unter `GET /web/` (flache Liste aller Fälle) | Gebaut, bevor Session/States/Fall-Detail existierten, um die Pipeline während Entwicklung inspizieren zu können. Soll später hinter `role = "admin"` geschützt werden. |
| Test-Client für Watch-Flow | Erst ab Phase 5 mit Hardware | `scripts/dictate.sh` ab Phase 2/3 als Stand-in (ffmpeg + curl + c/n/r/q-Loop) | End-to-End-Tests ohne Pixel-Watch-Hardware. | | Test-Client für Watch-Flow | Erst ab Phase 5 mit Hardware | `scripts/dictate.sh` ab Phase 2/3 als Stand-in (ffmpeg + curl + c/n/r/q-Loop) | End-to-End-Tests ohne Pixel-Watch-Hardware. |
| Hotwords (Whisper) | Nicht vorgesehen | Per-User-Feld `[user.whisper].hotwords` **im Code**, aber nicht als Feature angeboten | Regress-Lauf über 10 Fixtures zeigt **keinen** Vorteil (ohne 12/257 Wortfehler, mit 14/257). Hotwords schluckten Funktionswörter. Leitung bleibt durchverdrahtet, bewerben wir aber nicht — re-evaluieren bei konkretem Bedarf. | | Hotwords (Whisper) | Nicht vorgesehen | Per-User-Feld `[user.whisper].hotwords` **im Code**, aber nicht als Feature angeboten | Regress-Lauf über 10 Fixtures zeigt **keinen** Vorteil (ohne 12/257 Wortfehler, mit 14/257). Hotwords schluckten Funktionswörter. Leitung bleibt durchverdrahtet, bewerben wir aber nicht — re-evaluieren bei konkretem Bedarf. |
### Client-Architektur
| Änderung | Original | Aktuell | Grund |
|---|---|---|---|
| Repository-Layout | `server/` standalone + `watch/wearos/` separat | **Cargo-Workspace** mit `server/`, `doctate-common/` (shared lib), `client-desktop/` (erster nativer Client) | Mit zweitem Rust-Consumer rechtfertigt sich eine geteilte Lib. API-Typen + Konstanten + Timestamp-Helper zentral → keine Wire-Format-Drift zwischen Server und Clients. |
| Reihenfolge der Clients | Pixel Watch zuerst, dann Handy, Desktop später (Phase 5e „Ausblick") | **Linux-Desktop zuerst** (vor Watch), Windows nächster, Watch sobald Hardware verfügbar | Nutzer hat noch keine Pixel-Watch-Hardware. Desktop-Client dient zusätzlich als API-Ergonomie-Test-Fahrzeug (Retry, Content-Types, Auth-Fehlerpfade, die Wiremock-Tests nicht zeigen) und als Arzt-Workflow-Tool am PC (nebenher diktieren beim Arztbrief-Tippen). |
| Desktop-Tech-Stack | Offen — Kandidaten Tauri / Compose Multiplatform / nativ | `eframe`/`egui` (Immediate-Mode-UI) + `tokio` + `reqwest` + `ffmpeg`-Subprozess | Kleinere Binaries als Tauri (keine WebView, kein WebKitGTK auf Linux), keine Android-Verpflichtung wie Compose, pure Rust-Toolchain. ffmpeg-Subprozess statt `libav`-FFI: Lizenz (GPL vs. LGPL), Build-Komplexität, plattformübergreifende Konsistenz. |
| Desktop-Audio-Stop | nicht spezifiziert | **Unix:** `SIGINT` via `libc::kill`. **Windows:** `q`-Stdin-Write als Best-Effort. | ffmpeg's `term_init()` prüft `isatty(stdin)`; bei Pipe-stdin wird Keyboard-Polling deaktiviert, `q` landet in der Pipe, wird aber nie gelesen. SIGINT triggert ffmpeg's Signal-Handler unabhängig vom TTY-Status. Exit-Code 255 bei SIGINT-Exit ist normal — wir prüfen Datei-Existenz + Nicht-Leer statt Exit-Code. |
| Desktop-Tray-Icon | Geplant als Schritt 4f des Client-Aufbaus | Zurückgestellt, kein Tray im MVP. Close-Button beendet App. | `tray-icon`-Crate zieht drei System-Libs (`libxdo`, `gtk3`, `libappindicator`) für triviale Funktion. `ksni` (pure-Rust via dbus/zbus) als saubere Linux-Alternative für später dokumentiert. Client läuft ohne Tray sinnvoll. |