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:
+90
-8
@@ -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. |
|
||||||
|
|||||||
Reference in New Issue
Block a user