refactor: rename to common/, clients/desktop, clients/wearos

Move three directories into their target topology:
- doctate-common/ -> common/
- client-desktop/ -> clients/desktop/
- watch/wearos/   -> clients/wearos/

Update doctate-common path-dependency in 3 Cargo.toml files
(server, clients/desktop, experiments) and the workspace
members list in the root Cargo.toml. Crate names unchanged
(doctate-server, doctate-common, doctate-desktop).

Workspace still in single-Cargo.lock form; isolation into
standalone workspaces follows in stage 3. All 508 tests pass,
experiments standalone-workspace also resolves the new path.
This commit is contained in:
2026-05-02 11:55:38 +02:00
parent 39cc666ca6
commit 0c66fc6010
124 changed files with 4 additions and 4 deletions
+942
View File
@@ -0,0 +1,942 @@
//! The egui application: config panel, record/stop controls, case list.
//!
//! State transitions are driven by:
//! - user clicks (new/continue/stop/open/save-config/dismiss-error)
//! - recorder events (ffmpeg finalizing → RecorderEvent::Finished)
//! - upload events (terminal failure → AppState::Error)
//!
//! Connectivity and upload-progress indicators live in the footer; they
//! never block the main state machine. See
//! `berpr-fe-den-plan-lively-journal.md` for the design rationale.
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::case_store::{CaseMarker, CaseStore};
use crate::case_update::{PutOnelinerError, put_oneliner};
use crate::footer_status::{FooterStatus, pick_footer_status};
use crate::server_sync::{ServerSync, SyncConfig, WorkerSnapshot, WorkerState};
use crate::snapshot_cache::SnapshotCache;
use crate::startup::{DEFAULT_MARKER_RETENTION, DEFAULT_ORPHAN_AUDIO_RETENTION, run_startup_cleanup};
use crate::upload::{PendingUpload, UploadEvent, write_sidecar};
use doctate_common::join_url;
use doctate_common::oneliners::OnelinerState;
use doctate_common::timestamp::{extract_hhmm, now_rfc3339, recorded_at_to_filename_stem};
use eframe::egui;
use tokio::runtime::Runtime;
use tokio::sync::{mpsc, watch};
use tracing::{error, info, warn};
use uuid::Uuid;
use crate::config::Config;
use crate::recorder::{Recorder, RecorderEvent};
use crate::state::AppState;
pub struct DoctateApp {
runtime: Arc<Runtime>,
config: Option<Config>,
pending_dir: PathBuf,
state: AppState,
server_url_input: String,
api_key_input: String,
http_client: Arc<reqwest::Client>,
case_store: Arc<CaseStore>,
snapshot_rx: watch::Receiver<Arc<Vec<CaseMarker>>>,
snapshot_cache: Arc<SnapshotCache>,
recorder: Option<Recorder>,
recorder_events: Option<mpsc::UnboundedReceiver<RecorderEvent>>,
server_sync: Option<ServerSync>,
worker_rx: Option<watch::Receiver<WorkerSnapshot>>,
upload_events: Option<mpsc::UnboundedReceiver<UploadEvent>>,
/// Context for an in-flight ffmpeg flush: we left `AppState::Recording`
/// for `AppState::Idle` on Stop (so the UI stays responsive), but we
/// still need `case_id` + `recorded_at` when the recorder emits
/// `Finished`. `None` when no flush is in progress.
finalizing: Option<RecordingContext>,
/// Active inline tap-to-edit on a oneliner row. `None` means
/// every row renders in read mode.
oneliner_edit: Option<OnelinerEditState>,
/// Channel for results coming back from background `put_oneliner`
/// spawns. The receiver is drained at the start of every frame.
save_result_tx: mpsc::UnboundedSender<SaveResult>,
save_result_rx: mpsc::UnboundedReceiver<SaveResult>,
/// Toast banner shown until the user dismisses it; populated when
/// a `put_oneliner` returns an error.
last_save_error: Option<String>,
}
/// Enough recording identity to finalize a pending flush or emit an
/// upload event once the recorder signals `Finished`.
#[derive(Debug, Clone)]
struct RecordingContext {
case_id: Uuid,
recorded_at: String,
}
/// Per-case oneliner edit buffer. Only one case is in edit mode at a
/// time; clicking another case (or pressing Esc) clears this. The
/// `focus_pending` flag drives a one-shot `request_focus()` on the
/// first render after the edit starts, so the user lands in the
/// TextEdit without a second click.
struct OnelinerEditState {
case_id: Uuid,
draft: String,
focus_pending: bool,
}
/// Result of a background `put_oneliner` request, posted back to the
/// UI thread via mpsc so the next `update()` frame can surface
/// success/failure to the user.
struct SaveResult {
case_id: Uuid,
outcome: Result<doctate_common::oneliners::OnelinerState, PutOnelinerError>,
}
enum UiAction {
SaveConfig,
StartNew,
Continue(Uuid),
OpenWeb(Uuid),
Stop,
DismissError,
StartOnelinerEdit { case_id: Uuid, current_text: String },
CancelOnelinerEdit,
SaveOneliner { case_id: Uuid, text: String },
DismissSaveError,
}
impl DoctateApp {
pub fn new(
runtime: Arc<Runtime>,
pending_dir: PathBuf,
cases_dir: PathBuf,
snapshot_cache_path: PathBuf,
) -> Self {
let config = match crate::config_path::load() {
Ok(Some(c)) => Some(c),
Ok(None) => None,
Err(e) => {
warn!(error = %e, "config load failed");
None
}
};
let server_url_input = config
.as_ref()
.map(|c| c.server_url.clone())
.unwrap_or_default();
let api_key_input = config
.as_ref()
.map(|c| c.api_key.clone())
.unwrap_or_default();
let (store, snapshot_rx) = runtime
.block_on(async { CaseStore::open(cases_dir).await })
.expect("open case store");
let case_store = Arc::new(store);
let snapshot_cache = Arc::new(SnapshotCache::new(snapshot_cache_path));
let http_client = Arc::new(reqwest::Client::new());
// Startup cleanup — one-shot, before the worker spawns.
{
let store = case_store.clone();
let pending = pending_dir.clone();
runtime.block_on(async move {
run_startup_cleanup(
&store,
&pending,
DEFAULT_MARKER_RETENTION,
DEFAULT_ORPHAN_AUDIO_RETENTION,
)
.await;
});
}
let (state, server_sync, worker_rx, upload_events) = if let Some(cfg) = &config {
let (sync, events, rx) = spawn_worker(
&runtime,
cfg.clone(),
pending_dir.clone(),
http_client.clone(),
case_store.clone(),
snapshot_cache.clone(),
);
(AppState::Idle, Some(sync), Some(rx), Some(events))
} else {
(AppState::NotConfigured, None, None, None)
};
let (save_result_tx, save_result_rx) = mpsc::unbounded_channel();
Self {
runtime,
config,
pending_dir,
state,
server_url_input,
api_key_input,
http_client,
case_store,
snapshot_rx,
snapshot_cache,
recorder: None,
recorder_events: None,
server_sync,
worker_rx,
upload_events,
finalizing: None,
oneliner_edit: None,
save_result_tx,
save_result_rx,
last_save_error: None,
}
}
fn on_save_config(&mut self) {
let cfg = Config {
server_url: self.server_url_input.trim().to_string(),
api_key: self.api_key_input.trim().to_string(),
oneliner_poll_interval_seconds: self
.config
.as_ref()
.and_then(|c| c.oneliner_poll_interval_seconds),
};
if let Err(e) = crate::config_path::save(&cfg) {
error!(error = %e, "config save failed");
self.state = AppState::Error {
message: format!("Speichern: {e}"),
};
return;
}
info!("config saved");
let store = self.case_store.clone();
let cache = self.snapshot_cache.clone();
self.runtime.block_on(async move {
if let Err(e) = store.clear_all().await {
warn!(error = %e, "case store clear failed on config change");
}
if let Err(e) = cache.clear().await {
warn!(error = %e, "snapshot cache clear failed on config change");
}
});
// Drop old worker explicitly so its abort fires before new spawn.
self.server_sync = None;
self.worker_rx = None;
self.upload_events = None;
let (sync, events, rx) = spawn_worker(
&self.runtime,
cfg.clone(),
self.pending_dir.clone(),
self.http_client.clone(),
self.case_store.clone(),
self.snapshot_cache.clone(),
);
self.server_sync = Some(sync);
self.worker_rx = Some(rx);
self.upload_events = Some(events);
self.config = Some(cfg);
self.state = AppState::Idle;
self.finalizing = None;
}
fn on_start_new(&mut self) {
self.start_recording_for(Uuid::new_v4(), /* is_continue */ false);
}
fn on_continue(&mut self, case_id: Uuid) {
self.start_recording_for(case_id, /* is_continue */ true);
}
fn start_recording_for(&mut self, case_id: Uuid, is_continue: bool) {
// Two-part guard: user-visible state AND no lingering recorder
// from a previous stop that is still flushing.
if !matches!(self.state, AppState::Idle) || self.recorder.is_some() {
return;
}
if let Err(e) = std::fs::create_dir_all(&self.pending_dir) {
self.state = AppState::Error {
message: format!("Pending-Verzeichnis: {e}"),
};
return;
}
let recorded_at = now_rfc3339();
let stem = format!("{}_{}", case_id, recorded_at_to_filename_stem(&recorded_at));
let output_path = self.pending_dir.join(format!("{stem}.m4a"));
// Persist the case marker before recording so the UI list
// reflects the new case immediately.
let store = self.case_store.clone();
let now = time::OffsetDateTime::now_utc();
self.runtime.block_on(async move {
let res = if is_continue {
store.mark_activity(case_id, now).await.map(|_| ())
} else {
store.create_local(case_id, now).await.map(|_| ())
};
if let Err(e) = res {
warn!(error = %e, "case store write failed");
}
});
let _guard = self.runtime.enter();
match Recorder::start(output_path) {
Ok((recorder, events)) => {
self.recorder = Some(recorder);
self.recorder_events = Some(events);
self.state = AppState::Recording {
case_id,
recorded_at,
started_at: Instant::now(),
};
}
Err(e) => {
self.state = AppState::Error {
message: format!("Aufnahme starten: {e}"),
};
}
}
}
fn on_stop(&mut self) {
let AppState::Recording {
case_id,
recorded_at,
..
} = &self.state
else {
return;
};
let case_id = *case_id;
let recorded_at = recorded_at.clone();
if let Some(rec) = self.recorder.take() {
rec.stop();
}
// UI is ready for the next recording immediately — the finalizing
// ffmpeg flush shows up as a footer spinner, not a blocking state.
self.finalizing = Some(RecordingContext {
case_id,
recorded_at,
});
self.state = AppState::Idle;
}
fn on_open_web(&self, case_id: Uuid) {
let Some(cfg) = &self.config else {
return;
};
let server_url = cfg.server_url.clone();
let api_key = cfg.api_key.clone();
let http = self.http_client.clone();
let return_to = format!("/web/cases/{case_id}");
let fallback_url = join_url(&server_url, &return_to);
// Fire-and-forget on the runtime. Blocking the egui UI thread on
// an HTTP round-trip would freeze the window for ~100500ms.
self.runtime.spawn(async move {
let url =
match crate::magic_link::build_magic_url(&http, &server_url, &api_key, &return_to)
.await
{
Ok(u) => u,
Err(e) => {
warn!(error = %e, "magic-link request failed; falling back to plain URL");
fallback_url
}
};
if let Err(e) = webbrowser::open(&url) {
warn!(url = %url, error = %e, "open browser failed");
}
});
}
fn drain_recorder_events(&mut self) {
let Some(rx) = self.recorder_events.as_mut() else {
return;
};
let mut events = Vec::new();
while let Ok(event) = rx.try_recv() {
events.push(event);
}
for event in events {
match event {
RecorderEvent::Started => {}
RecorderEvent::Finished { output } => {
self.handle_recording_finished(output);
}
RecorderEvent::Failed { error: err } => {
error!(error = %err, "recorder failed");
self.finalizing = None;
self.state = AppState::Error {
message: format!("Aufnahme: {err}"),
};
self.recorder_events = None;
self.recorder = None;
}
}
}
}
fn handle_recording_finished(&mut self, output: PathBuf) {
// Prefer the explicit finalizing context (Stop-flow). Fall back
// to AppState::Recording (recorder terminated before Stop — rare
// but possible).
let ctx = if let Some(ctx) = self.finalizing.take() {
ctx
} else if let AppState::Recording {
case_id,
recorded_at,
..
} = &self.state
{
let ctx = RecordingContext {
case_id: *case_id,
recorded_at: recorded_at.clone(),
};
self.state = AppState::Idle;
ctx
} else {
warn!(state = ?self.state, "recorder finished in unexpected state");
self.recorder_events = None;
self.recorder = None;
return;
};
let RecordingContext {
case_id,
recorded_at,
} = ctx;
let store = self.case_store.clone();
let now = time::OffsetDateTime::now_utc();
self.runtime.block_on(async move {
if let Err(e) = store.mark_activity(case_id, now).await {
warn!(error = %e, "mark_activity on recording finished failed");
}
});
let upload = PendingUpload {
case_id,
recorded_at,
file: output,
};
if let Err(e) = write_sidecar(&upload) {
self.state = AppState::Error {
message: format!("Sidecar schreiben: {e}"),
};
self.recorder_events = None;
self.recorder = None;
return;
}
// Kick the worker so it picks up the fresh file without waiting
// for the next poll-tick.
if let Some(sync) = &self.server_sync {
sync.notify();
} else {
warn!("no server_sync active — upload stays on disk until next launch");
}
self.recorder_events = None;
self.recorder = None;
}
fn drain_upload_events(&mut self) {
let Some(rx) = self.upload_events.as_mut() else {
return;
};
let mut events = Vec::new();
while let Ok(event) = rx.try_recv() {
events.push(event);
}
for event in events {
match event {
UploadEvent::Started(case_id) => {
info!(case_id = %case_id, "upload attempt started");
}
UploadEvent::Succeeded { case_id, status } => {
info!(case_id = %case_id, status = ?status, "upload succeeded");
}
UploadEvent::Failed {
case_id,
reason,
will_retry,
} => {
warn!(case_id = %case_id, reason = %reason, will_retry, "upload failed");
if !will_retry {
// Terminal = operator action needed. Block UI.
self.state = AppState::Error { message: reason };
}
}
}
}
}
fn render_controls(&mut self, ui: &mut egui::Ui) -> Option<UiAction> {
match &self.state {
AppState::NotConfigured => {
ui.label("Server URL:");
ui.add(egui::TextEdit::singleline(&mut self.server_url_input).desired_width(280.0));
ui.add_space(4.0);
ui.label("API Key:");
ui.add(
egui::TextEdit::singleline(&mut self.api_key_input)
.desired_width(280.0)
.password(true),
);
ui.add_space(8.0);
if ui.button("Speichern").clicked() {
return Some(UiAction::SaveConfig);
}
None
}
AppState::Idle => {
if ui.button("● Neu").clicked() {
return Some(UiAction::StartNew);
}
None
}
AppState::Recording { started_at, .. } => {
let elapsed = started_at.elapsed();
ui.label(format!(
"● REC {:02}:{:02}",
elapsed.as_secs() / 60,
elapsed.as_secs() % 60
));
ui.add_space(4.0);
if ui.button("■ Stop").clicked() {
return Some(UiAction::Stop);
}
None
}
AppState::Error { message } => {
ui.colored_label(egui::Color32::RED, message);
ui.add_space(8.0);
if ui.button("OK").clicked() {
return Some(UiAction::DismissError);
}
None
}
}
}
fn render_case_list(&mut self, ui: &mut egui::Ui) -> Option<UiAction> {
let snapshot = self.snapshot_rx.borrow().clone();
// Snapshot is already server-filtered — the server's `window_hours`
// per user (users.toml) is the single source of truth.
let visible: Vec<&CaseMarker> = snapshot.iter().collect();
if visible.is_empty() {
ui.add_space(8.0);
ui.colored_label(egui::Color32::GRAY, "Noch keine Fälle heute.");
return None;
}
// Pending-upload lookup for the badge. Cheap clone (Arc) from the
// worker's last published snapshot; empty set if no worker yet.
let pending_ids = self
.worker_rx
.as_ref()
.map(|rx| rx.borrow().pending_case_ids.clone())
.unwrap_or_default();
let mut action = None;
// Ready-for-new means the user-intent state is Idle AND no ffmpeg
// instance is still flushing. Upload-queue depth does NOT gate.
let idle = matches!(self.state, AppState::Idle) && self.recorder.is_none();
egui::ScrollArea::vertical().show(ui, |ui| {
for marker in visible {
ui.separator();
ui.add_space(4.0);
let time_str = extract_hhmm(&marker.last_activity_at);
let has_pending = pending_ids.contains(&marker.case_id);
let in_edit_mode = self
.oneliner_edit
.as_ref()
.is_some_and(|e| e.case_id == marker.case_id);
if in_edit_mode {
// Inline edit: TextEdit + ✓/✗. Enter saves, Esc cancels.
let edit = self.oneliner_edit.as_mut().unwrap();
ui.horizontal(|ui| {
ui.label(format!("{time_str} ·"));
let response = ui
.add(egui::TextEdit::singleline(&mut edit.draft).desired_width(280.0));
if edit.focus_pending {
response.request_focus();
edit.focus_pending = false;
}
let enter_pressed =
response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
let save_clicked = ui.button("").clicked();
let cancel_clicked = ui.button("").clicked();
let esc_pressed = ui.input(|i| i.key_pressed(egui::Key::Escape));
if save_clicked || enter_pressed {
action = Some(UiAction::SaveOneliner {
case_id: marker.case_id,
text: edit.draft.clone(),
});
} else if cancel_clicked || esc_pressed {
action = Some(UiAction::CancelOnelinerEdit);
}
});
} else {
// Read mode: clickable label, plus a green pencil
// for Manual variant so the doctor sees their
// override is the source of truth here.
let oneliner_text = match &marker.oneliner {
Some(
OnelinerState::Ready { text, .. } | OnelinerState::Manual { text, .. },
) => text.clone(),
Some(OnelinerState::Empty { .. }) => "".to_owned(),
Some(OnelinerState::Error { .. }) => "".to_owned(),
None => "".to_owned(),
};
let line = if has_pending {
format!("{time_str} · {oneliner_text}")
} else {
format!("{time_str} · {oneliner_text}")
};
ui.horizontal(|ui| {
let label = if has_pending {
egui::Label::new(
egui::RichText::new(line)
.color(egui::Color32::from_rgb(200, 140, 0)),
)
.sense(egui::Sense::click())
} else {
egui::Label::new(line).sense(egui::Sense::click())
};
let resp = ui.add(label);
if matches!(marker.oneliner, Some(OnelinerState::Manual { .. })) {
// Person-glyph marks doctor-authored titles.
// Replaces an earlier pencil icon: the pencil
// suggested an edit affordance, but on this row
// the marker is informational only — clicking
// the title text already opens the editor.
ui.colored_label(egui::Color32::GRAY, "👤")
.on_hover_text("manuell bearbeitet");
}
if resp.clicked() {
// Pre-fill the editor with the visible text
// (Ready/Manual). For Empty/Error/None start
// with an empty buffer so the doctor types
// from scratch.
let current = match &marker.oneliner {
Some(
OnelinerState::Ready { text, .. }
| OnelinerState::Manual { text, .. },
) => text.clone(),
_ => String::new(),
};
action = Some(UiAction::StartOnelinerEdit {
case_id: marker.case_id,
current_text: current,
});
}
});
}
ui.horizontal(|ui| {
if ui
.add_enabled(idle, egui::Button::new("▶ Fortsetzen"))
.clicked()
{
action = Some(UiAction::Continue(marker.case_id));
}
if ui.button("🌐 Öffnen").clicked() {
action = Some(UiAction::OpenWeb(marker.case_id));
}
});
ui.add_space(4.0);
}
});
action
}
fn render_footer_status(&self, ui: &mut egui::Ui) {
let Some(worker_rx) = &self.worker_rx else {
ui.colored_label(egui::Color32::GRAY, "");
return;
};
let snap = worker_rx.borrow().clone();
let last_success_age = snap.last_success.map(|i| i.elapsed());
let last_failure_age = snap.last_failure.map(|i| i.elapsed());
let threshold = self
.config
.as_ref()
.map(|c| std::cmp::max(3 * c.poll_interval(), Duration::from_secs(30)))
.unwrap_or(Duration::from_secs(30));
let status = pick_footer_status(
self.finalizing.is_some(),
snap.state,
snap.queue_len,
last_success_age,
last_failure_age,
threshold,
);
match status {
FooterStatus::Finalizing => {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Speichere Aufnahme…");
});
}
FooterStatus::Uploading { count } => {
ui.horizontal(|ui| {
ui.spinner();
if count <= 1 {
ui.label("Uploading…");
} else {
ui.label(format!("Uploading ({count} offen)…"));
}
});
}
FooterStatus::Synchronizing => {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Synchronisiere…");
});
}
FooterStatus::NeverConnected => {
ui.colored_label(egui::Color32::GRAY, "⚪ noch keine Verbindung");
}
FooterStatus::Offline => {
ui.colored_label(egui::Color32::DARK_RED, "🔴 Offline");
}
FooterStatus::Online => {
ui.colored_label(egui::Color32::DARK_GREEN, "🟢 Online");
}
}
}
fn handle_action(&mut self, action: UiAction) {
match action {
UiAction::SaveConfig => self.on_save_config(),
UiAction::StartNew => self.on_start_new(),
UiAction::Continue(id) => self.on_continue(id),
UiAction::OpenWeb(id) => self.on_open_web(id),
UiAction::Stop => self.on_stop(),
UiAction::DismissError => self.state = AppState::Idle,
UiAction::StartOnelinerEdit {
case_id,
current_text,
} => {
self.oneliner_edit = Some(OnelinerEditState {
case_id,
draft: current_text,
focus_pending: true,
});
}
UiAction::CancelOnelinerEdit => {
self.oneliner_edit = None;
}
UiAction::SaveOneliner { case_id, text } => {
self.on_save_oneliner(case_id, text);
}
UiAction::DismissSaveError => {
self.last_save_error = None;
}
}
}
/// Optimistic write + background PUT. Local state is updated
/// immediately so the new text appears on the next frame; the
/// server round-trip runs in a spawned task and reports back via
/// `save_result_tx`.
fn on_save_oneliner(&mut self, case_id: Uuid, text: String) {
let trimmed = text.trim().to_owned();
if trimmed.is_empty() {
// Empty payload would be rejected by the server (400) and
// is also a no-op on the local cache. Treat as cancel.
self.oneliner_edit = None;
return;
}
let local_state = OnelinerState::Manual {
text: trimmed.clone(),
set_at: now_rfc3339(),
};
let store = self.case_store.clone();
let local_clone = local_state.clone();
// Block briefly on the local write — single tokio::fs::write +
// map insert, microseconds in practice. Keeps the UI strictly
// monotonic: by the next render frame the new text is visible.
self.runtime.block_on(async move {
if let Err(e) = store.set_oneliner_local(case_id, local_clone).await {
warn!(case_id = %case_id, error = %e, "local set_oneliner failed");
}
});
self.oneliner_edit = None;
// Background PUT. On failure the next drain_save_results
// surfaces the error; on success no further work is needed
// because the local state is already correct.
let Some(cfg) = self.config.clone() else {
warn!("save_oneliner without config — should not happen");
return;
};
let http = self.http_client.clone();
let tx = self.save_result_tx.clone();
self.runtime.spawn(async move {
let outcome =
put_oneliner(&http, &cfg.server_url, &cfg.api_key, case_id, &trimmed).await;
let _ = tx.send(SaveResult { case_id, outcome });
});
}
fn drain_save_results(&mut self) {
while let Ok(result) = self.save_result_rx.try_recv() {
match result.outcome {
Ok(_) => {
info!(case_id = %result.case_id, "oneliner save succeeded");
}
Err(e) => {
error!(case_id = %result.case_id, error = ?e, "oneliner save failed");
self.last_save_error = Some(format!(
"Oneliner für Fall {} konnte nicht gespeichert werden: {}",
short_case_id(&result.case_id),
user_error_message(&e),
));
}
}
}
}
fn worker_is_active(&self) -> bool {
let Some(rx) = &self.worker_rx else {
return false;
};
let snap = rx.borrow();
snap.state != WorkerState::Idle || snap.queue_len > 0
}
}
fn spawn_worker(
runtime: &Runtime,
config: Config,
pending_dir: PathBuf,
http: Arc<reqwest::Client>,
case_store: Arc<CaseStore>,
snapshot_cache: Arc<SnapshotCache>,
) -> (
ServerSync,
mpsc::UnboundedReceiver<UploadEvent>,
watch::Receiver<WorkerSnapshot>,
) {
let _guard = runtime.enter();
let poll_interval = config.poll_interval();
let sync_config = SyncConfig {
server_url: config.server_url,
api_key: config.api_key,
poll_interval,
};
let (sync, events_rx) =
ServerSync::spawn(http, sync_config, case_store, snapshot_cache, pending_dir);
let snapshot_rx = sync.snapshot();
(sync, events_rx, snapshot_rx)
}
/// Short prefix of a `Uuid` for human-readable error messages.
fn short_case_id(id: &Uuid) -> String {
id.to_string().chars().take(8).collect()
}
/// Map a `PutOnelinerError` to a German user-facing one-liner.
fn user_error_message(e: &PutOnelinerError) -> String {
match e {
PutOnelinerError::Terminal { status, .. } => {
format!("Server-Fehler {status} — bitte Konfiguration prüfen.")
}
PutOnelinerError::Transient(_) => "Netzwerk-Fehler — bitte erneut versuchen.".to_string(),
PutOnelinerError::Parse(_) => "Antwort vom Server unverständlich.".to_string(),
}
}
impl eframe::App for DoctateApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.drain_recorder_events();
self.drain_upload_events();
self.drain_save_results();
let mut pending_action: Option<UiAction> = None;
// Toast banner for the last save failure. Sits above the
// header so the user can still navigate the rest of the app.
if let Some(msg) = self.last_save_error.clone() {
egui::TopBottomPanel::top("save-error-banner").show(ctx, |ui| {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.colored_label(egui::Color32::DARK_RED, "");
ui.label(msg);
if ui.button("").clicked() {
pending_action = Some(UiAction::DismissSaveError);
}
});
ui.add_space(4.0);
});
}
egui::TopBottomPanel::top("header").show(ctx, |ui| {
ui.add_space(4.0);
ui.vertical_centered(|ui| {
ui.heading("doctate");
ui.label(self.state.label());
});
ui.add_space(4.0);
});
egui::TopBottomPanel::bottom("footer").show(ctx, |ui| {
ui.add_space(4.0);
ui.vertical_centered(|ui| {
self.render_footer_status(ui);
});
ui.add_space(4.0);
});
egui::CentralPanel::default().show(ctx, |ui| {
ui.vertical_centered(|ui| {
ui.add_space(8.0);
if let Some(a) = self.render_controls(ui) {
pending_action = Some(a);
}
ui.add_space(8.0);
});
if !matches!(self.state, AppState::NotConfigured)
&& let Some(a) = self.render_case_list(ui)
{
pending_action = Some(a);
}
});
if let Some(action) = pending_action {
self.handle_action(action);
}
// Repaint cadence: fast while visibly active (spinner, REC-timer),
// slow (1 Hz) in the quiet steady state.
let active_spinner = self.finalizing.is_some() || self.worker_is_active();
let interval = match self.state {
AppState::Recording { .. } => Duration::from_millis(250),
_ if active_spinner => Duration::from_millis(100),
_ => Duration::from_secs(1),
};
ctx.request_repaint_after(interval);
}
}
File diff suppressed because it is too large Load Diff
+220
View File
@@ -0,0 +1,220 @@
//! HTTP client for `PUT /api/cases/{case_id}/oneliner` — send the
//! doctor's manual oneliner override to the server.
//!
//! Returns the persisted [`OnelinerState`] on success (always
//! `Manual { text, set_at }` if the server obeyed the contract).
//! Errors are split into terminal (operator must fix — wrong API key,
//! unknown case, validation refused) and transient (network blip /
//! 5xx, retry sensible) so the caller can surface the right message.
use doctate_common::API_KEY_HEADER;
use doctate_common::join_url;
use doctate_common::oneliners::{
ONELINER_OVERRIDE_PATH_TEMPLATE, OnelinerOverrideRequest, OnelinerState,
};
use reqwest::StatusCode;
use thiserror::Error;
use uuid::Uuid;
#[derive(Debug, Error)]
pub enum PutOnelinerError {
/// Server rejected the request for a reason the user must address:
/// 401 (bad API key), 403 (no permission), 404 (unknown case),
/// 400/422 (validation failure). Surfacing the body lets the UI
/// show the server-side message.
#[error("HTTP {status}: {body}")]
Terminal { status: u16, body: String },
/// Network error or 408/429/5xx — the caller may retry after a
/// backoff. The string is the most useful diagnostic available.
#[error("transient: {0}")]
Transient(String),
/// Server replied 200 but the body did not parse as
/// [`OnelinerState`]. Treated as terminal because there is no
/// obvious retry strategy: a body-shape mismatch is not a hiccup.
#[error("parse response: {0}")]
Parse(String),
}
/// Fire a `PUT /api/cases/{case_id}/oneliner` request and return the
/// persisted state on success. The server sets `set_at` on the
/// returned variant — clients should not pre-populate it.
pub async fn put_oneliner(
http: &reqwest::Client,
server_url: &str,
api_key: &str,
case_id: Uuid,
text: &str,
) -> Result<OnelinerState, PutOnelinerError> {
let path = ONELINER_OVERRIDE_PATH_TEMPLATE.replace("{case_id}", &case_id.to_string());
let url = join_url(server_url, &path);
let body = OnelinerOverrideRequest {
text: text.to_owned(),
};
let resp = match http
.put(&url)
.header(API_KEY_HEADER, api_key)
.json(&body)
.send()
.await
{
Ok(r) => r,
Err(e) => return Err(PutOnelinerError::Transient(format!("network: {e}"))),
};
let status = resp.status();
if status == StatusCode::OK {
match resp.json::<OnelinerState>().await {
Ok(s) => Ok(s),
Err(e) => Err(PutOnelinerError::Parse(format!("{e}"))),
}
} else if status.as_u16() == 408 || status.as_u16() == 429 || status.is_server_error() {
let body = resp.text().await.unwrap_or_default();
Err(PutOnelinerError::Transient(format!(
"HTTP {status}: {body}"
)))
} else {
let body = resp.text().await.unwrap_or_default();
Err(PutOnelinerError::Terminal {
status: status.as_u16(),
body,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use wiremock::matchers::{header, method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
const TEST_API_KEY: &str = "test-key";
async fn mock_server_with(status: u16, body: serde_json::Value) -> MockServer {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path_regex(r"^/api/cases/[0-9a-fA-F-]+/oneliner$"))
.and(header(API_KEY_HEADER, TEST_API_KEY))
.respond_with(ResponseTemplate::new(status).set_body_json(body))
.mount(&server)
.await;
server
}
#[tokio::test]
async fn put_oneliner_happy_path_returns_manual_state() {
let server = mock_server_with(
200,
json!({
"kind": "manual",
"text": "55 J., Knieschmerz",
"set_at": "2026-04-26T12:00:00Z"
}),
)
.await;
let http = reqwest::Client::new();
let case_id = Uuid::new_v4();
let result = put_oneliner(
&http,
&server.uri(),
TEST_API_KEY,
case_id,
"55 J., Knieschmerz",
)
.await
.expect("ok");
match result {
OnelinerState::Manual { text, set_at } => {
assert_eq!(text, "55 J., Knieschmerz");
assert_eq!(set_at, "2026-04-26T12:00:00Z");
}
other => panic!("expected Manual, got {other:?}"),
}
}
#[tokio::test]
async fn put_oneliner_401_is_terminal() {
let server = mock_server_with(401, json!({"error": "Unauthorized"})).await;
let http = reqwest::Client::new();
let err = put_oneliner(&http, &server.uri(), TEST_API_KEY, Uuid::new_v4(), "x")
.await
.expect_err("must err");
match err {
PutOnelinerError::Terminal { status, .. } => assert_eq!(status, 401),
other => panic!("expected Terminal, got {other:?}"),
}
}
#[tokio::test]
async fn put_oneliner_400_is_terminal() {
let server = mock_server_with(400, json!({"error": "empty text"})).await;
let http = reqwest::Client::new();
let err = put_oneliner(&http, &server.uri(), TEST_API_KEY, Uuid::new_v4(), "x")
.await
.expect_err("must err");
assert!(matches!(
err,
PutOnelinerError::Terminal { status: 400, .. }
));
}
#[tokio::test]
async fn put_oneliner_404_is_terminal() {
let server = mock_server_with(404, json!({"error": "case not found"})).await;
let http = reqwest::Client::new();
let err = put_oneliner(&http, &server.uri(), TEST_API_KEY, Uuid::new_v4(), "x")
.await
.expect_err("must err");
assert!(matches!(
err,
PutOnelinerError::Terminal { status: 404, .. }
));
}
#[tokio::test]
async fn put_oneliner_503_is_transient() {
let server = mock_server_with(503, json!({"error": "service unavailable"})).await;
let http = reqwest::Client::new();
let err = put_oneliner(&http, &server.uri(), TEST_API_KEY, Uuid::new_v4(), "x")
.await
.expect_err("must err");
assert!(matches!(err, PutOnelinerError::Transient(_)));
}
#[tokio::test]
async fn put_oneliner_429_is_transient() {
let server = mock_server_with(429, json!({"error": "too many requests"})).await;
let http = reqwest::Client::new();
let err = put_oneliner(&http, &server.uri(), TEST_API_KEY, Uuid::new_v4(), "x")
.await
.expect_err("must err");
assert!(matches!(err, PutOnelinerError::Transient(_)));
}
#[tokio::test]
async fn put_oneliner_network_error_is_transient() {
// Point at a URL nothing is listening on.
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(200))
.build()
.unwrap();
let err = put_oneliner(
&http,
"http://127.0.0.1:1", // reserved port
TEST_API_KEY,
Uuid::new_v4(),
"x",
)
.await
.expect_err("must err");
assert!(matches!(err, PutOnelinerError::Transient(_)));
}
}
+180
View File
@@ -0,0 +1,180 @@
//! Shared client configuration: server URL, API key, polling cadence,
//! oneliner window. Every native client (desktop, watch, phone) reads
//! the same schema — only the on-disk path differs per platform.
//!
//! Path resolution is deliberately **not** part of this module. Callers
//! provide a concrete `&Path` to `load_from` / `save_to`; the
//! per-platform path helper lives in each client crate.
use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Fallback polling cadence for `/api/oneliners` in seconds. Chosen to
/// feel real-time without hammering the server.
pub const DEFAULT_POLL_INTERVAL_SECONDS: u64 = 5;
/// Client configuration loaded from a TOML file at a platform-specific
/// path. All fields except the two durations are mandatory.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Config {
/// Base URL of the doctate server, e.g. `https://doctate.example.org`.
pub server_url: String,
/// Per-user API key used in the `X-API-Key` header.
pub api_key: String,
/// Polling cadence for `/api/oneliners`. Omit in TOML to use the
/// built-in default.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oneliner_poll_interval_seconds: Option<u64>,
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("cannot determine OS config directory (is $HOME set?)")]
NoConfigDir,
#[error("I/O error at {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("parse error at {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("serialize error: {0}")]
Serialize(#[from] toml::ser::Error),
}
impl Config {
/// Load from `path`. Returns `Ok(None)` if the file does not exist
/// (first-run case); `Ok(Some(_))` on success; `Err` on I/O or parse
/// errors — caller should surface these to the user, not swallow.
pub fn load_from(path: &Path) -> Result<Option<Self>, ConfigError> {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(ConfigError::Io {
path: path.into(),
source: e,
});
}
};
let cfg: Config = toml::from_str(&text).map_err(|e| ConfigError::Parse {
path: path.into(),
source: e,
})?;
Ok(Some(cfg))
}
/// Save to `path`, creating parent directories as needed.
pub fn save_to(&self, path: &Path) -> Result<(), ConfigError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| ConfigError::Io {
path: parent.into(),
source: e,
})?;
}
let text = toml::to_string_pretty(self)?;
std::fs::write(path, text).map_err(|e| ConfigError::Io {
path: path.into(),
source: e,
})?;
Ok(())
}
/// Polling cadence as a [`Duration`], substituting the default if
/// the field was omitted.
pub fn poll_interval(&self) -> Duration {
Duration::from_secs(
self.oneliner_poll_interval_seconds
.unwrap_or(DEFAULT_POLL_INTERVAL_SECONDS),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn sample() -> Config {
Config {
server_url: "https://doctate.example.org".into(),
api_key: "sk-test-12345".into(),
oneliner_poll_interval_seconds: None,
}
}
#[test]
fn roundtrip_through_file() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("client.toml");
let original = sample();
original.save_to(&path).unwrap();
let loaded = Config::load_from(&path).unwrap();
assert_eq!(loaded, Some(original));
}
#[test]
fn load_missing_file_returns_none() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("nonexistent.toml");
assert!(Config::load_from(&path).unwrap().is_none());
}
#[test]
fn save_creates_parent_dirs() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("deep/nested/client.toml");
sample().save_to(&path).unwrap();
assert!(path.exists());
}
#[test]
fn load_invalid_toml_returns_parse_error() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("bad.toml");
std::fs::write(&path, "this is not [valid toml").unwrap();
let err = Config::load_from(&path).unwrap_err();
assert!(matches!(err, ConfigError::Parse { .. }));
}
#[test]
fn defaults_when_fields_omitted() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("minimal.toml");
std::fs::write(&path, "server_url = \"http://x\"\napi_key = \"y\"\n").unwrap();
let cfg = Config::load_from(&path).unwrap().unwrap();
assert_eq!(cfg.poll_interval(), Duration::from_secs(5));
}
#[test]
fn explicit_values_override_defaults() {
let cfg = Config {
server_url: "http://x".into(),
api_key: "y".into(),
oneliner_poll_interval_seconds: Some(10),
};
assert_eq!(cfg.poll_interval(), Duration::from_secs(10));
}
#[test]
fn roundtrip_preserves_optional_fields() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("client.toml");
let original = Config {
server_url: "http://x".into(),
api_key: "y".into(),
oneliner_poll_interval_seconds: Some(3),
};
original.save_to(&path).unwrap();
let loaded = Config::load_from(&path).unwrap().unwrap();
assert_eq!(loaded.oneliner_poll_interval_seconds, Some(3));
}
}
+48
View File
@@ -0,0 +1,48 @@
//! Desktop-side config helpers. The `Config` struct itself lives in
//! the sibling `config` module (TOML schema, platform-agnostic). This
//! module only provides the per-platform default path via the
//! `directories` crate, plus two convenience functions for load/save
//! against that default path.
use std::path::PathBuf;
use directories::ProjectDirs;
pub use crate::config::{Config, ConfigError};
const CONFIG_FILENAME: &str = "client.toml";
/// Default config path, following OS conventions:
/// Linux: `~/.config/doctate/client.toml`
/// Windows: `%APPDATA%\doctate\client.toml`
/// macOS: `~/Library/Application Support/doctate/client.toml`
pub fn default_config_path() -> Result<PathBuf, ConfigError> {
ProjectDirs::from("", "", "doctate")
.map(|dirs| dirs.config_dir().join(CONFIG_FILENAME))
.ok_or(ConfigError::NoConfigDir)
}
/// Convenience: load from `default_config_path()`.
pub fn load() -> Result<Option<Config>, ConfigError> {
Config::load_from(&default_config_path()?)
}
/// Convenience: save to `default_config_path()`.
pub fn save(cfg: &Config) -> Result<(), ConfigError> {
cfg.save_to(&default_config_path()?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_path_ends_with_client_toml() {
// Sanity check on the ProjectDirs wiring — can't assert the
// exact path (varies per host), but the filename is deterministic.
let path = default_config_path().expect("config path available in test env");
assert_eq!(
path.file_name().and_then(|s| s.to_str()),
Some("client.toml")
);
}
}
+227
View File
@@ -0,0 +1,227 @@
//! Footer status derivation — a pure function that every client (desktop,
//! watch, phone) uses to decide which one-line indicator to show in its
//! status bar. No UI framework dependency.
//!
//! The truth-table is deliberately tied to the **worker state**, not the
//! queue depth: if the worker is sitting in `sleep(backoff)` after a
//! transient failure, the queue still has items but the worker is `Idle`
//! → we show `Offline` / `Online` based on `last_success_age` vs.
//! `last_failure_age`, not a misleading "Uploading…".
use std::time::Duration;
use crate::server_sync::WorkerState;
/// Derived observable for the client's status line.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FooterStatus {
/// ffmpeg (or platform-equivalent) is flushing the last recording.
Finalizing,
/// Worker is uploading. `count` = pending-dir depth.
Uploading { count: usize },
/// Worker is polling `/api/oneliners`.
Synchronizing,
/// Never got a successful request yet, and never saw a failure either —
/// typical cold start with no network attempt so far.
NeverConnected,
/// Last event on record was a failure, or `last_success` is older
/// than the offline threshold.
Offline,
/// `last_success` is fresh enough, and no failure has overtaken it.
Online,
}
/// Decide which footer status to show. Pure function — no side effects,
/// fully table-testable.
///
/// Decision rules for the `Idle` branch:
/// - A failure younger than the last success (or a failure with no
/// prior success at all) wins → `Offline`.
/// - Otherwise fall back to success-age vs. threshold (classic staleness).
///
/// `last_*_age` arguments are `Option<Duration>` = "seconds since that
/// event, or `None` if it never happened". Smaller Duration = more recent.
pub fn pick_footer_status(
finalizing: bool,
worker_state: WorkerState,
queue_len: usize,
last_success_age: Option<Duration>,
last_failure_age: Option<Duration>,
offline_threshold: Duration,
) -> FooterStatus {
if finalizing {
return FooterStatus::Finalizing;
}
match worker_state {
WorkerState::Uploading => FooterStatus::Uploading {
count: queue_len.max(1),
},
WorkerState::Polling => FooterStatus::Synchronizing,
WorkerState::Idle => {
let failure_more_recent = match (last_success_age, last_failure_age) {
(Some(s), Some(f)) => f < s, // smaller age = younger
(None, Some(_)) => true, // only a failure on record
_ => false,
};
if failure_more_recent {
return FooterStatus::Offline;
}
match last_success_age {
None => FooterStatus::NeverConnected,
Some(age) if age > offline_threshold => FooterStatus::Offline,
Some(_) => FooterStatus::Online,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const THRESHOLD: Duration = Duration::from_secs(30);
/// Convenience: most tests don't care about the failure field.
fn status(
finalizing: bool,
worker_state: WorkerState,
queue_len: usize,
last_success_age: Option<Duration>,
) -> FooterStatus {
pick_footer_status(
finalizing,
worker_state,
queue_len,
last_success_age,
None,
THRESHOLD,
)
}
#[test]
fn finalizing_wins_over_everything() {
let s = pick_footer_status(
true,
WorkerState::Uploading,
5,
Some(Duration::from_millis(1)),
Some(Duration::from_millis(1)),
THRESHOLD,
);
assert_eq!(s, FooterStatus::Finalizing);
}
#[test]
fn uploading_when_queue_non_empty() {
let s = status(
false,
WorkerState::Uploading,
3,
Some(Duration::from_millis(1)),
);
assert_eq!(s, FooterStatus::Uploading { count: 3 });
}
#[test]
fn uploading_reports_at_least_one_even_if_scan_raced() {
let s = status(
false,
WorkerState::Uploading,
0,
Some(Duration::from_millis(1)),
);
assert_eq!(s, FooterStatus::Uploading { count: 1 });
}
#[test]
fn synchronizing_when_polling_and_no_queue() {
let s = status(
false,
WorkerState::Polling,
0,
Some(Duration::from_millis(1)),
);
assert_eq!(s, FooterStatus::Synchronizing);
}
#[test]
fn never_connected_without_last_success() {
let s = status(false, WorkerState::Idle, 0, None);
assert_eq!(s, FooterStatus::NeverConnected);
}
#[test]
fn offline_when_last_success_is_stale() {
let s = status(false, WorkerState::Idle, 0, Some(Duration::from_secs(120)));
assert_eq!(s, FooterStatus::Offline);
}
#[test]
fn online_when_last_success_is_fresh() {
let s = status(false, WorkerState::Idle, 0, Some(Duration::from_secs(5)));
assert_eq!(s, FooterStatus::Online);
}
#[test]
fn offline_threshold_is_inclusive_of_equal_age_as_online() {
let s = status(false, WorkerState::Idle, 0, Some(THRESHOLD));
assert_eq!(s, FooterStatus::Online);
}
#[test]
fn offline_while_queue_has_items_but_worker_backing_off() {
let s = status(false, WorkerState::Idle, 3, Some(Duration::from_secs(120)));
assert_eq!(s, FooterStatus::Offline);
}
#[test]
fn online_while_queue_has_items_but_worker_idle_and_recent_success() {
let s = status(false, WorkerState::Idle, 3, Some(Duration::from_secs(2)));
assert_eq!(s, FooterStatus::Online);
}
#[test]
fn never_connected_while_queue_has_items_and_no_success_ever() {
let s = status(false, WorkerState::Idle, 3, None);
assert_eq!(s, FooterStatus::NeverConnected);
}
#[test]
fn failure_younger_than_success_forces_offline() {
let s = pick_footer_status(
false,
WorkerState::Idle,
1,
Some(Duration::from_secs(10)),
Some(Duration::from_secs(1)),
THRESHOLD,
);
assert_eq!(s, FooterStatus::Offline);
}
#[test]
fn success_younger_than_failure_wins_back_online() {
let s = pick_footer_status(
false,
WorkerState::Idle,
0,
Some(Duration::from_secs(1)),
Some(Duration::from_secs(10)),
THRESHOLD,
);
assert_eq!(s, FooterStatus::Online);
}
#[test]
fn only_failure_no_success_is_offline() {
let s = pick_footer_status(
false,
WorkerState::Idle,
0,
None,
Some(Duration::from_secs(5)),
THRESHOLD,
);
assert_eq!(s, FooterStatus::Offline);
}
}
+21
View File
@@ -0,0 +1,21 @@
//! Desktop dictation client — Linux + Windows.
//!
//! Modules listed here are intentionally `pub` so that the Wear-OS
//! mirror (Kotlin) can reference canonical Rust paths via doc anchors
//! like `doctate-desktop::pending_cleanup::cleanup_orphan_audio`.
pub mod app;
pub mod case_store;
pub mod case_update;
pub mod config;
pub mod config_path;
pub mod footer_status;
pub mod magic_link;
pub mod paths;
pub mod pending_cleanup;
pub mod recorder;
pub mod server_sync;
pub mod snapshot_cache;
pub mod startup;
pub mod state;
pub mod upload;
+121
View File
@@ -0,0 +1,121 @@
//! Client-side helper: ask the server for a one-time magic-link token and
//! build the URL the system browser should open.
//!
//! Kept separate from `app.rs` so the network call can be unit-tested
//! against a wiremock server without involving `webbrowser::open` (which
//! would launch a real browser during tests).
//!
//! # Behaviour on error
//!
//! Caller decides. The desktop app's `on_open_web` falls back to the
//! plain case URL if this fails — the doctor lands on the login page and
//! signs in manually rather than seeing a dead button.
use doctate_common::{API_KEY_HEADER, join_url};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Serialize)]
struct CreateRequest<'a> {
return_to: &'a str,
}
#[derive(Deserialize)]
struct CreateResponse {
token: String,
}
#[derive(Debug, Error)]
pub enum MagicLinkError {
#[error("http error: {0}")]
Http(#[from] reqwest::Error),
#[error("server returned status {0}")]
Status(reqwest::StatusCode),
}
/// Request a magic-link token and build the full URL to open. The
/// `return_to` path is what the browser lands on **after** the token
/// is consumed; must start with `/web/` (server enforces this too).
pub async fn build_magic_url(
http: &reqwest::Client,
server_url: &str,
api_key: &str,
return_to: &str,
) -> Result<String, MagicLinkError> {
let resp = http
.post(join_url(server_url, "/api/auth/magic-link"))
.header(API_KEY_HEADER, api_key)
.json(&CreateRequest { return_to })
.send()
.await?;
if !resp.status().is_success() {
return Err(MagicLinkError::Status(resp.status()));
}
let parsed: CreateResponse = resp.json().await?;
Ok(join_url(
server_url,
&format!("/web/magic?token={}", parsed.token),
))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn returns_full_url_with_token() {
let server = MockServer::start().await;
let token = "tok-1234567890";
Mock::given(method("POST"))
.and(path("/api/auth/magic-link"))
.and(header(API_KEY_HEADER, "test-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "token": token })))
.mount(&server)
.await;
let http = reqwest::Client::new();
let url = build_magic_url(&http, &server.uri(), "test-key", "/web/cases/abc")
.await
.expect("magic url");
assert_eq!(url, format!("{}/web/magic?token={token}", server.uri()));
}
#[tokio::test]
async fn propagates_server_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/auth/magic-link"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let http = reqwest::Client::new();
let err = build_magic_url(&http, &server.uri(), "wrong-key", "/web/cases/abc")
.await
.expect_err("should error");
assert!(matches!(err, MagicLinkError::Status(s) if s.as_u16() == 401));
}
#[tokio::test]
async fn trims_trailing_slash_in_server_url() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/auth/magic-link"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "token": "abc" })))
.mount(&server)
.await;
let http = reqwest::Client::new();
let url_with_slash = format!("{}/", server.uri());
let url = build_magic_url(&http, &url_with_slash, "k", "/web/cases/abc")
.await
.expect("magic url");
assert!(!url.contains("//web/"), "double slash leaked: {url}");
}
}
+112
View File
@@ -0,0 +1,112 @@
use std::sync::Arc;
use eframe::egui;
use tracing::info;
use tracing_subscriber::EnvFilter;
use std::fs::{File, OpenOptions, TryLockError};
use doctate_desktop::app::DoctateApp;
use doctate_desktop::paths::{cases_dir, lock_file_path, pending_dir, snapshot_cache_path};
/// Unwrap a startup `Result` or print `context: {err}` to stderr and
/// `exit(1)`. Used for unrecoverable bootstrap failures before the
/// egui event loop runs — there is no UI to show an error in.
fn or_die<T, E: std::fmt::Display>(result: Result<T, E>, context: &str) -> T {
match result {
Ok(v) => v,
Err(e) => {
eprintln!("{context}: {e}");
std::process::exit(1);
}
}
}
fn main() -> eframe::Result {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("doctate_desktop=info")),
)
.init();
info!("starting doctate-desktop");
let runtime = Arc::new(
tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.expect("build tokio runtime"),
);
let pending = or_die(pending_dir(), "cannot determine pending directory");
// Single-instance guard. The lock is held by the `_lock_file` binding
// for the entire `main` lifetime — OS releases it automatically on
// process exit or crash, so a stale lock file after a crash is
// harmless (the next launch re-acquires).
let _lock_file = acquire_single_instance_lock();
let cases = or_die(cases_dir(), "cannot determine cases directory");
let cache_path = or_die(
snapshot_cache_path(),
"cannot determine snapshot cache path",
);
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([400.0, 560.0])
.with_min_inner_size([320.0, 400.0])
.with_resizable(true),
..Default::default()
};
eframe::run_native(
"doctate",
options,
Box::new(move |_cc| {
let app = DoctateApp::new(runtime, pending, cases, cache_path);
Ok(Box::new(app) as Box<dyn eframe::App>)
}),
)
}
/// Open and exclusively lock a per-user `client.lock`. Returns the
/// handle; dropping it (process exit) releases the lock. If another
/// instance already holds it, prints a friendly message and exits.
fn acquire_single_instance_lock() -> File {
let path = or_die(lock_file_path(), "cannot determine lock file path");
if let Some(parent) = path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
eprintln!("cannot create data directory {}: {e}", parent.display());
std::process::exit(1);
}
let file = match OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&path)
{
Ok(f) => f,
Err(e) => {
eprintln!("cannot open lock file {}: {e}", path.display());
std::process::exit(1);
}
};
match file.try_lock() {
Ok(()) => file,
Err(TryLockError::WouldBlock) => {
eprintln!(
"doctate läuft bereits (Lock hält auf {}). Nur eine Instanz pro User zulässig.",
path.display()
);
std::process::exit(1);
}
Err(TryLockError::Error(e)) => {
eprintln!("lock acquire failed on {}: {e}", path.display());
std::process::exit(1);
}
}
}
+65
View File
@@ -0,0 +1,65 @@
//! OS-conventional paths for client-side storage.
use std::path::PathBuf;
use directories::ProjectDirs;
use thiserror::Error;
/// Directory name for pending (not-yet-uploaded) recordings inside the
/// data-local root.
const PENDING_DIR_NAME: &str = "pending";
/// Directory name for per-case marker files inside the data-local root.
const CASES_DIR_NAME: &str = "cases";
/// File name for the oneliners snapshot cache inside the data-local root.
const SNAPSHOT_CACHE_FILENAME: &str = "oneliners_cache.json";
/// File name for the single-instance advisory lock inside the data-local
/// root.
const LOCK_FILENAME: &str = "client.lock";
#[derive(Debug, Error)]
pub enum PathError {
#[error("cannot determine OS data directory (is $HOME set?)")]
NoDataDir,
}
/// Resolve `{data_local_dir}/{rel}` under the doctate project scope, or
/// error if the OS won't give us a data directory at all. Every public
/// path helper here delegates to this so the `ProjectDirs::from(…)`
/// triple stays in one place.
fn project_path(rel: &str) -> Result<PathBuf, PathError> {
ProjectDirs::from("", "", "doctate")
.map(|dirs| dirs.data_local_dir().join(rel))
.ok_or(PathError::NoDataDir)
}
/// Directory where recordings wait for upload. Persists across app
/// restarts and crashes.
/// Linux: `~/.local/share/doctate/pending/`
/// Windows: `%LOCALAPPDATA%\doctate\pending\`
/// macOS: `~/Library/Application Support/doctate/pending/`
pub fn pending_dir() -> Result<PathBuf, PathError> {
project_path(PENDING_DIR_NAME)
}
/// Directory holding one JSON marker per known case (locally created
/// and/or server-synced). Persists across app restarts.
/// Linux: `~/.local/share/doctate/cases/`
pub fn cases_dir() -> Result<PathBuf, PathError> {
project_path(CASES_DIR_NAME)
}
/// File path for the last-successful `/api/oneliners` response. Feeds
/// the UI at cold start before the first live poll completes.
pub fn snapshot_cache_path() -> Result<PathBuf, PathError> {
project_path(SNAPSHOT_CACHE_FILENAME)
}
/// File path for the single-instance advisory lock. Held by the running
/// process for its entire lifetime; a second launch attempting to lock
/// the same file gets `WouldBlock` and exits cleanly.
pub fn lock_file_path() -> Result<PathBuf, PathError> {
project_path(LOCK_FILENAME)
}
+219
View File
@@ -0,0 +1,219 @@
//! Orphan reaping for the client's pending-upload directory.
//!
//! The uploader happy-path deletes `.m4a` + `.meta.json` pairs after
//! ACK. A crash between recorder-stop and sidecar-write, or a crash
//! between ACK and the two `remove_file` calls, can leave **orphans**
//! behind:
//!
//! - `*.m4a` without `*.meta.json` (or vice versa): the uploader's
//! `scan_pending_dir` silently skips these on next launch, so they
//! would accumulate indefinitely without explicit cleanup.
//! - `*.json.tmp` / `*.m4a.tmp` leftovers from interrupted atomic
//! writes: pure garbage, always safe to remove.
//!
//! Orphan audio is sensitive — clients must not hoard patient
//! recordings that never made it to the server. This module reaps them
//! on a caller-supplied age threshold. The caller passes an `age_cutoff:
//! SystemTime`; everything whose mtime is strictly older is deleted.
//! `.tmp` leftovers bypass the cutoff (they are never legitimate work
//! in progress at app startup — the lock file guarantees exclusivity).
use std::path::Path;
use std::time::SystemTime;
use tracing::{info, warn};
/// Reap orphan artefacts in `pending_dir`. Returns the number of files
/// deleted. Missing directory is treated as "nothing to do".
pub async fn cleanup_orphan_audio(pending_dir: &Path, age_cutoff: SystemTime) -> usize {
let Ok(mut entries) = tokio::fs::read_dir(pending_dir).await else {
return 0;
};
// First pass: collect filenames so we can answer "does the sibling
// exist?" without doing N separate stat calls.
let mut names: Vec<String> = Vec::new();
while let Ok(Some(e)) = entries.next_entry().await {
if let Some(n) = e.file_name().to_str() {
names.push(n.to_owned());
}
}
let has_name = |n: &str| names.iter().any(|x| x == n);
let mut deleted = 0usize;
for name in &names {
let path = pending_dir.join(name);
// Atomic-write leftovers: always delete, no age check. A running
// process would hold the single-instance lock, so `.tmp` at
// startup is by definition stale.
if name.ends_with(".m4a.tmp") || name.ends_with(".meta.json.tmp") {
if let Err(e) = tokio::fs::remove_file(&path).await {
warn!(path = %path.display(), error = %e, "failed to remove tmp leftover");
} else {
info!(path = %path.display(), "tmp leftover removed");
deleted += 1;
}
continue;
}
// Candidate: orphan `.m4a` — only delete if older than cutoff.
if let Some(stem) = name.strip_suffix(".m4a") {
let sibling = format!("{stem}.meta.json");
if has_name(&sibling) {
continue; // paired, leave for the uploader
}
if mtime_older_than(&path, age_cutoff).await {
if let Err(e) = tokio::fs::remove_file(&path).await {
warn!(path = %path.display(), error = %e, "failed to remove orphan m4a");
} else {
info!(path = %path.display(), "orphan m4a removed (no sidecar)");
deleted += 1;
}
}
continue;
}
// Candidate: orphan sidecar — only delete if older than cutoff.
if let Some(stem) = name.strip_suffix(".meta.json") {
let sibling = format!("{stem}.m4a");
if has_name(&sibling) {
continue;
}
if mtime_older_than(&path, age_cutoff).await {
if let Err(e) = tokio::fs::remove_file(&path).await {
warn!(path = %path.display(), error = %e, "failed to remove orphan sidecar");
} else {
info!(path = %path.display(), "orphan sidecar removed (no m4a)");
deleted += 1;
}
}
continue;
}
}
deleted
}
async fn mtime_older_than(path: &Path, cutoff: SystemTime) -> bool {
match tokio::fs::metadata(path).await {
Ok(meta) => match meta.modified() {
Ok(mtime) => mtime < cutoff,
Err(_) => false,
},
Err(_) => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use tempfile::TempDir;
async fn touch_with_mtime(path: &Path, mtime: SystemTime) {
tokio::fs::write(path, b"x").await.unwrap();
filetime::set_file_mtime(path, filetime::FileTime::from_system_time(mtime)).unwrap();
}
fn now() -> SystemTime {
SystemTime::now()
}
#[tokio::test]
async fn missing_directory_returns_zero() {
let tmp = TempDir::new().unwrap();
let n = cleanup_orphan_audio(&tmp.path().join("nope"), now()).await;
assert_eq!(n, 0);
}
#[tokio::test]
async fn paired_files_are_preserved() {
let tmp = TempDir::new().unwrap();
let stem = "uuid_2026-04-18T08-00-00Z";
touch_with_mtime(&tmp.path().join(format!("{stem}.m4a")), now()).await;
touch_with_mtime(&tmp.path().join(format!("{stem}.meta.json")), now()).await;
// Cutoff in the future → any orphan would be deleted — but
// these are paired, must survive.
let cutoff = now() + Duration::from_secs(3600);
let n = cleanup_orphan_audio(tmp.path(), cutoff).await;
assert_eq!(n, 0);
assert!(tmp.path().join(format!("{stem}.m4a")).exists());
assert!(tmp.path().join(format!("{stem}.meta.json")).exists());
}
#[tokio::test]
async fn young_orphan_m4a_is_preserved() {
let tmp = TempDir::new().unwrap();
let m4a = tmp.path().join("uuid_young.m4a");
touch_with_mtime(&m4a, now()).await;
// cutoff = 1h ago → file (mtime=now) is younger → keep
let cutoff = now() - Duration::from_secs(3600);
assert_eq!(cleanup_orphan_audio(tmp.path(), cutoff).await, 0);
assert!(m4a.exists());
}
#[tokio::test]
async fn old_orphan_m4a_is_deleted() {
let tmp = TempDir::new().unwrap();
let m4a = tmp.path().join("uuid_old.m4a");
touch_with_mtime(&m4a, now() - Duration::from_secs(48 * 3600)).await;
let cutoff = now() - Duration::from_secs(24 * 3600);
let n = cleanup_orphan_audio(tmp.path(), cutoff).await;
assert_eq!(n, 1);
assert!(!m4a.exists(), "old orphan m4a must be gone");
}
#[tokio::test]
async fn old_orphan_sidecar_is_deleted() {
let tmp = TempDir::new().unwrap();
let sc = tmp.path().join("uuid_orphan.meta.json");
touch_with_mtime(&sc, now() - Duration::from_secs(48 * 3600)).await;
let cutoff = now() - Duration::from_secs(24 * 3600);
let n = cleanup_orphan_audio(tmp.path(), cutoff).await;
assert_eq!(n, 1);
assert!(!sc.exists());
}
#[tokio::test]
async fn tmp_leftovers_always_removed_regardless_of_age() {
let tmp = TempDir::new().unwrap();
let young_tmp = tmp.path().join("fresh.meta.json.tmp");
let m4a_tmp = tmp.path().join("interrupted.m4a.tmp");
touch_with_mtime(&young_tmp, now()).await;
touch_with_mtime(&m4a_tmp, now()).await;
// Cutoff in the far past → age check would preserve everything.
// But tmp leftovers bypass age check.
let n = cleanup_orphan_audio(tmp.path(), now() - Duration::from_secs(10 * 86400)).await;
assert_eq!(n, 2);
assert!(!young_tmp.exists());
assert!(!m4a_tmp.exists());
}
#[tokio::test]
async fn mix_of_pairs_orphans_and_tmp() {
let tmp = TempDir::new().unwrap();
let keep_stem = "uuid_keep_2026-04-18";
let orphan_stem = "uuid_orphan_old";
// Paired (keep)
touch_with_mtime(&tmp.path().join(format!("{keep_stem}.m4a")), now()).await;
touch_with_mtime(&tmp.path().join(format!("{keep_stem}.meta.json")), now()).await;
// Old orphan m4a (delete)
touch_with_mtime(
&tmp.path().join(format!("{orphan_stem}.m4a")),
now() - Duration::from_secs(48 * 3600),
)
.await;
// tmp leftover (delete)
touch_with_mtime(&tmp.path().join("crash.m4a.tmp"), now()).await;
let cutoff = now() - Duration::from_secs(24 * 3600);
let n = cleanup_orphan_audio(tmp.path(), cutoff).await;
assert_eq!(n, 2, "orphan + tmp must be removed, pair must stay");
assert!(tmp.path().join(format!("{keep_stem}.m4a")).exists());
assert!(tmp.path().join(format!("{keep_stem}.meta.json")).exists());
assert!(!tmp.path().join(format!("{orphan_stem}.m4a")).exists());
assert!(!tmp.path().join("crash.m4a.tmp").exists());
}
}
+316
View File
@@ -0,0 +1,316 @@
//! Audio recorder via an ffmpeg subprocess. Produces m4a/AAC files on disk.
//!
//! Portability note: this module is intentionally UI-agnostic and carries no
//! `egui`/`eframe` deps so it can be lifted into a shared client-core crate
//! once a second Rust client (Windows variant, etc.) arrives.
//!
//! Call `Recorder::start` inside a Tokio runtime context (entered with
//! `Runtime::enter()` or via `#[tokio::main]`).
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use thiserror::Error;
use tokio::process::{Child, Command};
use tokio::sync::{mpsc, oneshot};
use tokio::time::timeout;
use tracing::{info, warn};
/// Max time to wait for ffmpeg to finalize the MOOV atom after stop; past
/// this we hard-kill and emit `Failed`.
const STOP_GRACEFUL_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug)]
pub enum RecorderEvent {
/// ffmpeg spawned successfully; audio capture has begun.
Started,
/// ffmpeg exited cleanly; the m4a file is ready.
Finished { output: PathBuf },
/// ffmpeg or I/O failed; output file may be missing or corrupt.
Failed { error: String },
}
#[derive(Debug, Error)]
pub enum RecorderError {
#[error("ffmpeg not found in PATH")]
FfmpegMissing,
#[error("spawn ffmpeg failed: {0}")]
Spawn(#[from] std::io::Error),
}
/// Handle to a running recorder. Dropping it does NOT stop the recorder —
/// call `stop()` explicitly (consumes self).
pub struct Recorder {
stop_tx: oneshot::Sender<()>,
}
impl Recorder {
/// Spawn a recorder task that captures the system default audio input
/// to `output_path`. Returns a handle + event receiver. The task
/// continues until `stop()` is called, then finalizes the m4a.
pub fn start(
output_path: PathBuf,
) -> Result<(Self, mpsc::UnboundedReceiver<RecorderEvent>), RecorderError> {
let input = audio_input_args();
Self::spawn(output_path, &input)
}
/// Test-only entry point: spawn with custom input args (e.g. `lavfi`
/// test sources instead of a real audio device). Lets integration
/// tests exercise the full lifecycle without depending on hardware.
#[cfg(test)]
fn start_with_input_args(
output_path: PathBuf,
input_args: &[&str],
) -> Result<(Self, mpsc::UnboundedReceiver<RecorderEvent>), RecorderError> {
Self::spawn(output_path, input_args)
}
fn spawn(
output_path: PathBuf,
input_args: &[&str],
) -> Result<(Self, mpsc::UnboundedReceiver<RecorderEvent>), RecorderError> {
if which::which("ffmpeg").is_err() {
return Err(RecorderError::FfmpegMissing);
}
let (events_tx, events_rx) = mpsc::unbounded_channel();
let (stop_tx, stop_rx) = oneshot::channel();
let mut cmd = Command::new("ffmpeg");
cmd.args(ffmpeg_args(&output_path, input_args))
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null());
let child = cmd.spawn()?;
tokio::spawn(run_recorder_task(child, output_path, stop_rx, events_tx));
Ok((Self { stop_tx }, events_rx))
}
/// Request the recorder to finalize and exit. Consumes the handle —
/// no double-stop. Completion arrives as `Finished` (or `Failed`) on
/// the event receiver.
pub fn stop(self) {
let _ = self.stop_tx.send(());
}
}
async fn run_recorder_task(
mut child: Child,
output_path: PathBuf,
stop_rx: oneshot::Receiver<()>,
events_tx: mpsc::UnboundedSender<RecorderEvent>,
) {
let _ = events_tx.send(RecorderEvent::Started);
tokio::select! {
exit_result = child.wait() => {
let event = match exit_result {
Ok(status) if status.success() => RecorderEvent::Finished { output: output_path },
Ok(status) => RecorderEvent::Failed {
error: format!("ffmpeg exited unexpectedly: {status}"),
},
Err(e) => RecorderEvent::Failed {
error: format!("wait ffmpeg: {e}"),
},
};
let _ = events_tx.send(event);
}
_ = stop_rx => {
finalize(&mut child, output_path, &events_tx).await;
}
}
}
/// Graceful stop. On Unix, ffmpeg does NOT poll stdin for keys when
/// stdin is a pipe (its `term_init()` calls `isatty()` and disables
/// `stdin_interaction` for non-TTY), so writing `q` is silently
/// ignored. SIGINT is honored regardless of TTY status — ffmpeg's
/// signal handler sets a flag, the main loop breaks out between
/// frames, MOOV atom is written, `exit(0)` fires.
///
/// On Windows there is no equivalent via `tokio::process` without
/// launching the child in its own process group; we fall back to
/// writing `q` to stdin and rely on the timeout for corrupt-output
/// detection. Proper Windows graceful stop is future work.
async fn finalize(
child: &mut Child,
output_path: PathBuf,
events_tx: &mpsc::UnboundedSender<RecorderEvent>,
) {
#[cfg(unix)]
{
if let Some(pid) = child.id() {
match send_sigint(pid) {
Ok(()) => info!(pid, "SIGINT sent to ffmpeg"),
Err(e) => warn!(pid, error = %e, "SIGINT failed"),
}
}
}
#[cfg(windows)]
{
use tokio::io::AsyncWriteExt;
if let Some(mut stdin) = child.stdin.take()
&& let Err(e) = stdin.write_all(b"q").await
{
warn!(error = %e, "failed to write stop to ffmpeg stdin");
}
}
// SIGINT causes ffmpeg to exit with a non-zero status (often 255)
// even when the MOOV atom was written cleanly. Don't second-guess
// the exit code — verify the output file was produced and is
// non-empty. Real corruption will surface server-side at decode.
let event = match timeout(STOP_GRACEFUL_TIMEOUT, child.wait()).await {
Ok(Ok(status)) => match tokio::fs::metadata(&output_path).await {
Ok(meta) if meta.len() > 0 => RecorderEvent::Finished {
output: output_path,
},
Ok(_) => RecorderEvent::Failed {
error: format!("ffmpeg exit {status} and empty output"),
},
Err(e) => RecorderEvent::Failed {
error: format!("ffmpeg exit {status} and no output: {e}"),
},
},
Ok(Err(e)) => RecorderEvent::Failed {
error: format!("wait ffmpeg: {e}"),
},
Err(_elapsed) => {
warn!("ffmpeg did not exit within timeout; killing");
let _ = child.kill().await;
RecorderEvent::Failed {
error: "ffmpeg stop timeout — output may be corrupt".into(),
}
}
};
let _ = events_tx.send(event);
}
/// Send SIGINT to a child PID. Used only on Unix.
#[cfg(unix)]
fn send_sigint(pid: u32) -> std::io::Result<()> {
// SAFETY: libc::kill accepts any pid_t; SIGINT is a well-known
// signal. If the child already exited we get ESRCH, which we
// convert to io::Error for the caller to log.
let result = unsafe { libc::kill(pid as i32, libc::SIGINT) };
if result == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
fn ffmpeg_args(output: &Path, input_args: &[&str]) -> Vec<String> {
let mut args: Vec<String> = vec!["-loglevel".into(), "error".into()];
for s in input_args {
args.push((*s).to_string());
}
args.extend([
"-c:a".into(),
"aac".into(),
"-b:a".into(),
"96k".into(),
"-ar".into(),
"48000".into(),
"-ac".into(),
"1".into(),
"-movflags".into(),
"+faststart".into(),
"-y".into(),
output.to_string_lossy().into_owned(),
]);
args
}
#[cfg(target_os = "linux")]
fn audio_input_args() -> Vec<&'static str> {
// PulseAudio interface (also works under PipeWire via pipewire-pulse).
vec!["-f", "pulse", "-i", "default"]
}
#[cfg(target_os = "windows")]
fn audio_input_args() -> Vec<&'static str> {
// TODO(windows-client): enumerate dshow devices via
// `ffmpeg -list_devices true -f dshow -i dummy` and cache the first
// non-loopback capture device. "audio=default" below is a placeholder;
// most Windows systems do not have a device literally named "default",
// so the Windows client must resolve a real name before shipping.
vec!["-f", "dshow", "-i", "audio=default"]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ffmpeg_args_contain_output_path_and_encoder() {
let path = Path::new("/tmp/test.m4a");
let input = audio_input_args();
let args = ffmpeg_args(path, &input);
assert!(args.contains(&"/tmp/test.m4a".to_string()));
assert!(args.contains(&"-y".to_string()));
assert!(args.contains(&"aac".to_string()));
assert!(args.contains(&"+faststart".to_string()));
}
#[test]
fn ffmpeg_args_have_exactly_one_input() {
let input = audio_input_args();
let args = ffmpeg_args(Path::new("/tmp/x.m4a"), &input);
let input_flags = args.iter().filter(|s| *s == "-i").count();
assert_eq!(input_flags, 1);
}
/// End-to-end: spawn ffmpeg with a lavfi silence source, stop via
/// `Recorder::stop()`, verify the m4a is finalized (exists, has
/// non-trivial size, starts with a valid MP4 `ftyp` atom).
///
/// `#[ignore]` because it requires ffmpeg in PATH and takes ~1s.
/// Run with: `cargo test -p doctate-desktop -- --ignored`.
#[tokio::test]
#[ignore = "requires ffmpeg in PATH"]
async fn records_lavfi_source_and_stops_cleanly() {
let tmp = tempfile::TempDir::new().unwrap();
let output = tmp.path().join("lavfi.m4a");
let (recorder, mut events) = Recorder::start_with_input_args(
output.clone(),
&["-f", "lavfi", "-i", "anullsrc=r=48000:cl=mono"],
)
.expect("start recorder");
match events.recv().await {
Some(RecorderEvent::Started) => {}
other => panic!("expected Started, got {other:?}"),
}
tokio::time::sleep(Duration::from_millis(500)).await;
recorder.stop();
let event = tokio::time::timeout(Duration::from_secs(10), events.recv())
.await
.expect("recorder event timed out")
.expect("event channel closed");
let output_file = match event {
RecorderEvent::Finished { output } => output,
other => panic!("expected Finished, got {other:?}"),
};
assert!(output_file.exists(), "output file should exist");
let bytes = std::fs::read(&output_file).unwrap();
assert!(
bytes.len() > 500,
"output should be > 500 bytes, got {}",
bytes.len()
);
// MP4/m4a: "ftyp" box magic at offset 4.
assert_eq!(&bytes[4..8], b"ftyp", "should start with MP4 ftyp atom");
}
}
File diff suppressed because it is too large Load Diff
+176
View File
@@ -0,0 +1,176 @@
//! On-disk cache of the last successful `/api/oneliners` response.
//!
//! Survives app restarts so the UI has data before the first poll
//! completes. A single JSON file under a caller-chosen path. Atomic
//! writes via temp-file + rename. Read errors on malformed content are
//! treated the same as "no cache" — the next poll will rebuild it.
use std::path::{Path, PathBuf};
use doctate_common::oneliners::OnelinersResponse;
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedSnapshot {
/// Last ETag we handed the server in `If-None-Match` — raw header
/// value, including the `W/"..."` wrapper.
pub etag: String,
/// RFC3339 UTC; when the client received the response. Feeds the
/// staleness indicator on cold start.
pub fetched_at: String,
pub response: OnelinersResponse,
}
#[derive(Clone)]
pub struct SnapshotCache {
path: PathBuf,
}
impl SnapshotCache {
pub fn new(path: PathBuf) -> Self {
Self { path }
}
pub fn path(&self) -> &Path {
&self.path
}
/// Read and deserialize. `None` on missing file, unreadable file, or
/// parse error — the caller treats all three as "cold start".
pub async fn load(&self) -> Option<CachedSnapshot> {
let bytes = match tokio::fs::read(&self.path).await {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
Err(e) => {
warn!(path = %self.path.display(), error = %e, "snapshot cache read failed");
return None;
}
};
match serde_json::from_slice::<CachedSnapshot>(&bytes) {
Ok(c) => Some(c),
Err(e) => {
warn!(path = %self.path.display(), error = %e, "snapshot cache malformed — ignoring");
None
}
}
}
/// Persist atomically: write to `{path}.tmp`, then rename.
pub async fn store(&self, cached: &CachedSnapshot) -> std::io::Result<()> {
if let Some(parent) = self.path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let tmp = self.path.with_extension(tmp_extension(&self.path));
let bytes = serde_json::to_vec(cached)
.map_err(|e| std::io::Error::other(format!("serialize cached snapshot: {e}")))?;
tokio::fs::write(&tmp, bytes).await?;
tokio::fs::rename(&tmp, &self.path).await?;
debug!(path = %self.path.display(), "snapshot cache written");
Ok(())
}
/// Delete the cache file. Idempotent: missing file is success.
pub async fn clear(&self) -> std::io::Result<()> {
match tokio::fs::remove_file(&self.path).await {
Ok(_) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
}
/// Build a `{original_extension}.tmp`-style extension that the rename
/// target never accidentally matches. For `foo.json` → `json.tmp`.
fn tmp_extension(path: &Path) -> String {
match path.extension().and_then(|s| s.to_str()) {
Some(ext) => format!("{ext}.tmp"),
None => "tmp".to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use doctate_common::oneliners::{OnelinerEntry, OnelinerState};
use tempfile::TempDir;
fn sample() -> CachedSnapshot {
CachedSnapshot {
etag: "W/\"1729512345-72\"".into(),
fetched_at: "2026-04-18T10:45:00Z".into(),
response: OnelinersResponse {
as_of: "2026-04-18T10:45:00Z".into(),
window_hours: 72,
oneliners: vec![OnelinerEntry {
case_id: "550e8400-e29b-41d4-a716-446655440000".into(),
oneliner: Some(OnelinerState::Ready {
text: "Kniegelenk".into(),
generated_at: "2026-04-18T10:30:00Z".into(),
}),
created_at: "2026-04-18T10:00:00Z".into(),
last_recording_at: Some("2026-04-18T10:30:00Z".into()),
updated_at: Some("2026-04-18T10:30:00Z".into()),
}],
},
}
}
#[tokio::test]
async fn store_and_load_roundtrip() {
let tmp = TempDir::new().unwrap();
let cache = SnapshotCache::new(tmp.path().join("cache.json"));
let s = sample();
cache.store(&s).await.unwrap();
let loaded = cache.load().await.expect("cache present");
assert_eq!(loaded.etag, s.etag);
assert_eq!(loaded.response.oneliners.len(), 1);
let text = match &loaded.response.oneliners[0].oneliner {
Some(OnelinerState::Ready { text, .. }) => Some(text.as_str()),
_ => None,
};
assert_eq!(text, Some("Kniegelenk"));
}
#[tokio::test]
async fn load_returns_none_when_missing() {
let tmp = TempDir::new().unwrap();
let cache = SnapshotCache::new(tmp.path().join("missing.json"));
assert!(cache.load().await.is_none());
}
#[tokio::test]
async fn load_returns_none_on_garbage() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("garbage.json");
tokio::fs::write(&path, b"this is not json").await.unwrap();
let cache = SnapshotCache::new(path);
assert!(cache.load().await.is_none());
}
#[tokio::test]
async fn clear_is_idempotent() {
let tmp = TempDir::new().unwrap();
let cache = SnapshotCache::new(tmp.path().join("cache.json"));
cache.clear().await.unwrap();
cache.store(&sample()).await.unwrap();
assert!(cache.load().await.is_some());
cache.clear().await.unwrap();
assert!(cache.load().await.is_none());
// Second clear on already-empty is still Ok.
cache.clear().await.unwrap();
}
#[tokio::test]
async fn store_creates_parent_dirs() {
let tmp = TempDir::new().unwrap();
let deep = tmp.path().join("a/b/c/cache.json");
let cache = SnapshotCache::new(deep.clone());
cache.store(&sample()).await.unwrap();
assert!(deep.exists());
}
}
+150
View File
@@ -0,0 +1,150 @@
//! One-shot client startup routines shared across all native clients.
//!
//! Today this is just the data-minimization sweep: ephemeral devices
//! (desktop, watch, phone) are "sophisticated microphones" — the client
//! must not hoard patient-sensitive audio or metadata past the
//! dictation-day horizon.
//!
//! Two retention horizons exist:
//! - **Audio** (sensitive): 24 h by default. Orphan m4a/sidecar files
//! that never reached the server get deleted.
//! - **Markers** (non-sensitive: UUIDs + oneliners): 72 h by default.
//! Long enough to span a weekend; short enough that a stolen device
//! can't reconstruct a long case history.
use std::path::Path;
use std::time::{Duration, SystemTime};
use time::OffsetDateTime;
use tracing::{info, warn};
use crate::case_store::CaseStore;
use crate::pending_cleanup::cleanup_orphan_audio;
/// Default retention for case-markers on disk. Confirmed-by-server
/// markers older than this are pruned; unsynced markers are kept
/// forever (they guard pending uploads).
pub const DEFAULT_MARKER_RETENTION: Duration = Duration::from_secs(72 * 3600);
/// Default retention for orphan audio files in the pending directory.
/// Orphan = an `.m4a` without its matching sidecar, or vice versa,
/// older than this horizon.
pub const DEFAULT_ORPHAN_AUDIO_RETENTION: Duration = Duration::from_secs(24 * 3600);
/// Run the one-shot startup cleanup. Intended to be called once at app
/// launch, before the server-sync worker spawns.
///
/// Logs each outcome via `tracing`; no error bubbles up because both
/// sweeps are best-effort (a failing sweep shouldn't prevent the app
/// from starting).
pub async fn run_startup_cleanup(
store: &CaseStore,
pending_dir: &Path,
marker_retention: Duration,
orphan_audio_retention: Duration,
) {
let marker_cutoff =
OffsetDateTime::now_utc() - time::Duration::seconds(marker_retention.as_secs() as i64);
match store.cleanup_stale(marker_cutoff).await {
Ok(n) if n > 0 => info!(count = n, "startup cleanup: stale markers removed"),
Ok(_) => {}
Err(e) => warn!(error = %e, "startup cleanup: marker sweep failed"),
}
let audio_cutoff = SystemTime::now() - orphan_audio_retention;
let n = cleanup_orphan_audio(pending_dir, audio_cutoff).await;
if n > 0 {
info!(count = n, "startup cleanup: orphan audio removed");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::upload::{PendingUpload, write_sidecar};
use filetime::{FileTime, set_file_mtime};
use tempfile::TempDir;
use uuid::Uuid;
/// Integration-style test: seed a stale synced marker + an orphan
/// audio file, call the helper with tight retention horizons, watch
/// both go away.
#[tokio::test]
async fn sweeps_both_stale_markers_and_orphan_audio() {
let tmp = TempDir::new().unwrap();
let cases_dir = tmp.path().join("cases");
let pending_dir = tmp.path().join("pending");
std::fs::create_dir_all(&pending_dir).unwrap();
// Seed a stale synced marker by opening the store, creating it,
// flipping the synced flag via merge, then reopening to re-scan.
{
let (store, _rx) = CaseStore::open(cases_dir.clone()).await.unwrap();
let old_id = Uuid::new_v4();
let then = OffsetDateTime::parse(
"2020-01-01T00:00:00Z",
&time::format_description::well_known::Rfc3339,
)
.unwrap();
store.create_local(old_id, then).await.unwrap();
store.mark_synced(old_id).await.unwrap();
}
// Seed an orphan audio file, pre-dated to be older than 24h.
let orphan = pending_dir.join("orphan.m4a");
std::fs::write(&orphan, b"dummy").unwrap();
let two_days_ago = SystemTime::now() - Duration::from_secs(48 * 3600);
set_file_mtime(&orphan, FileTime::from_system_time(two_days_ago)).unwrap();
// Also seed a fresh, *paired* upload — must survive both sweeps.
let fresh_id = Uuid::new_v4();
let fresh_stem = format!("{fresh_id}_2026-04-18T10-00-00Z");
let fresh_m4a = pending_dir.join(format!("{fresh_stem}.m4a"));
let upload = PendingUpload {
case_id: fresh_id,
recorded_at: "2026-04-18T10:00:00Z".into(),
file: fresh_m4a.clone(),
};
std::fs::write(&fresh_m4a, b"fresh audio").unwrap();
write_sidecar(&upload).unwrap();
// Reopen store to load all on-disk markers.
let (store, _rx) = CaseStore::open(cases_dir).await.unwrap();
run_startup_cleanup(
&store,
&pending_dir,
Duration::from_secs(3600), // markers older than 1h: nuke
Duration::from_secs(3600), // orphans older than 1h: nuke
)
.await;
// Stale marker gone.
assert!(
store.list().await.is_empty(),
"old synced marker must be swept"
);
// Orphan gone.
assert!(!orphan.exists(), "old orphan audio must be swept");
// Fresh paired upload survived.
assert!(fresh_m4a.exists(), "fresh paired m4a must remain");
}
#[tokio::test]
async fn no_op_on_clean_directories() {
let tmp = TempDir::new().unwrap();
let cases_dir = tmp.path().join("cases");
let pending_dir = tmp.path().join("pending");
std::fs::create_dir_all(&pending_dir).unwrap();
let (store, _rx) = CaseStore::open(cases_dir).await.unwrap();
// Should simply return without error and without logs at >= warn.
run_startup_cleanup(
&store,
&pending_dir,
DEFAULT_MARKER_RETENTION,
DEFAULT_ORPHAN_AUDIO_RETENTION,
)
.await;
}
}
+38
View File
@@ -0,0 +1,38 @@
//! UI-visible application state.
//!
//! The state machine here only reflects **user intent** — "ready to
//! record", "recording in progress", "config error, please fix".
//! Background activity (upload queue, poll cycle, finalize flush) lives
//! in a separate observable layer — see
//! `doctate_client_core::footer_status`.
use std::time::Instant;
use uuid::Uuid;
#[derive(Debug)]
pub enum AppState {
/// No valid config on disk — show settings panel.
NotConfigured,
/// Ready for a new recording. No ephemeral context.
Idle,
/// ffmpeg is actively capturing audio.
Recording {
case_id: Uuid,
recorded_at: String,
started_at: Instant,
},
/// Terminal error; user must dismiss or fix config.
Error { message: String },
}
impl AppState {
pub fn label(&self) -> &'static str {
match self {
Self::NotConfigured => "Konfiguration fehlt",
Self::Idle => "Bereit",
Self::Recording { .. } => "Aufnahme läuft",
Self::Error { .. } => "Fehler",
}
}
}
+181
View File
@@ -0,0 +1,181 @@
//! Upload I/O building blocks: `PendingUpload` DTO, atomic sidecar write,
//! pending-dir scan.
//!
//! No network logic here — the actual `POST /api/upload` lives in the
//! `server_sync` worker. This module is only about persistence on the
//! client's disk queue:
//!
//! - `{stem}.m4a` — the recorded audio (written by the recorder)
//! - `{stem}.meta.json` — the sidecar with `case_id` + `recorded_at`
//!
//! A pair of both is a ready-to-upload work item; singletons are orphans
//! that `pending_cleanup` reaps.
use std::path::{Path, PathBuf};
use doctate_common::ack::AckStatus;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::warn;
use uuid::Uuid;
/// A recording waiting to be uploaded. The `file` path is derived from the
/// sidecar's on-disk location, not from the JSON content itself — so the
/// sidecar stays valid if the pending directory is moved/renamed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingUpload {
pub case_id: Uuid,
pub recorded_at: String,
#[serde(skip, default)]
pub file: PathBuf,
}
/// Lifecycle events for a single upload attempt. Emitted by the worker,
/// consumed by the UI.
#[derive(Debug)]
pub enum UploadEvent {
Started(Uuid),
Succeeded {
case_id: Uuid,
status: AckStatus,
},
Failed {
case_id: Uuid,
reason: String,
will_retry: bool,
},
}
#[derive(Debug, Error)]
pub enum UploadIoError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("serialize sidecar: {0}")]
Serialize(#[from] serde_json::Error),
}
/// Sidecar path next to a `.m4a` file: same directory, same stem, `.meta.json`.
pub fn sidecar_path_for(m4a: &Path) -> PathBuf {
let stem = m4a
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let dir = m4a.parent().unwrap_or_else(|| Path::new("."));
dir.join(format!("{stem}.meta.json"))
}
/// Persist a pending upload's metadata next to its m4a. Atomic: writes a
/// `*.tmp` sibling and renames it into place. A crash mid-write cannot
/// leave a zero-byte or partial sidecar on disk — `scan_pending_dir`
/// would otherwise skip the pair as an unreadable orphan.
pub fn write_sidecar(upload: &PendingUpload) -> Result<(), UploadIoError> {
let sidecar = sidecar_path_for(&upload.file);
if let Some(parent) = sidecar.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(upload)?;
let tmp = sidecar.with_extension("json.tmp");
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, &sidecar)?;
Ok(())
}
/// Scan a pending directory for `.m4a` files with matching sidecars.
/// Orphans (no sidecar, unreadable sidecar) are logged and skipped — one
/// broken pair should not stop recovery of the rest.
pub fn scan_pending_dir(pending_dir: &Path) -> Vec<PendingUpload> {
let entries = match std::fs::read_dir(pending_dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
Err(e) => {
warn!(dir = ?pending_dir, error = %e, "scan pending dir failed");
return Vec::new();
}
};
let mut out = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("m4a") {
continue;
}
match read_pending(&path) {
Ok(upload) => out.push(upload),
Err(e) => warn!(path = ?path, error = %e, "skipping pending entry"),
}
}
out
}
fn read_pending(m4a: &Path) -> Result<PendingUpload, UploadIoError> {
let sidecar = sidecar_path_for(m4a);
let json = std::fs::read_to_string(&sidecar)?;
let mut upload: PendingUpload = serde_json::from_str(&json)?;
upload.file = m4a.to_path_buf();
Ok(upload)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn sample(dir: &Path, stem: &str) -> PendingUpload {
let file = dir.join(format!("{stem}.m4a"));
std::fs::write(&file, b"dummy audio").unwrap();
PendingUpload {
case_id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
recorded_at: "2026-04-15T10:32:00Z".into(),
file,
}
}
#[test]
fn sidecar_roundtrip_via_scan() {
let tmp = TempDir::new().unwrap();
let upload = sample(tmp.path(), "case_a");
write_sidecar(&upload).unwrap();
let found = scan_pending_dir(tmp.path());
assert_eq!(found.len(), 1);
assert_eq!(found[0].case_id, upload.case_id);
assert_eq!(found[0].recorded_at, upload.recorded_at);
assert_eq!(found[0].file, upload.file);
}
#[test]
fn scan_ignores_m4a_without_sidecar() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("orphan.m4a"), b"x").unwrap();
let found = scan_pending_dir(tmp.path());
assert!(found.is_empty());
}
#[test]
fn scan_returns_empty_for_missing_dir() {
let tmp = TempDir::new().unwrap();
let missing = tmp.path().join("does-not-exist");
assert!(scan_pending_dir(&missing).is_empty());
}
#[test]
fn write_sidecar_leaves_no_tmp_artefact() {
let tmp = TempDir::new().unwrap();
let upload = sample(tmp.path(), "case_b");
write_sidecar(&upload).unwrap();
let sidecar = sidecar_path_for(&upload.file);
assert!(sidecar.exists(), "sidecar must be at final path");
let tmp_path = sidecar.with_extension("json.tmp");
assert!(!tmp_path.exists(), "tmp artefact must not remain");
}
#[test]
fn scan_returns_pairs_in_arbitrary_order_but_all_present() {
let tmp = TempDir::new().unwrap();
for stem in &["case_1", "case_2", "case_3"] {
let upload = sample(tmp.path(), stem);
write_sidecar(&upload).unwrap();
}
let found = scan_pending_dir(tmp.path());
assert_eq!(found.len(), 3);
}
}