Files
WhispAssist/src-tauri/src/commands.rs
T

6051 lines
229 KiB
Rust

//! Tauri command handlers — the frontend's only entry into the core.
//! Contract: `docs/04-api-contracts.md`. Commands return promptly; long work
//! is spawned and reported via events ("recording://*", "transcript://*", …).
//!
//! Phases 1-5 commands are real implementations; everything for a
//! not-yet-reached phase returns `Err(not_implemented(...))` rather than
//! panicking — `panic = "abort"` in the release profile means any panic
//! reachable from a command handler kills the whole app, not just that call.
use crate::audio::{AudioCapture, WasapiCapture};
use crate::calendar::{CalImport, CalendarSource, PstSource};
use crate::diarization::{Diarizer, SherpaDiarizer};
use crate::error::WaResult;
use crate::hardware::{HardwareDetector, WinHardwareDetector};
use crate::models::*;
use crate::notes::NotesRenderer;
use crate::paths::{
diarization_embedding_model_file, diarization_segmentation_model_file, manual_notes_file,
meeting_dir, settings_path, wa_root, whisper_model_file,
};
use crate::storage::{FinalizeMeeting, Meeting, NewMeeting, SummaryFile, SyncTargetRow};
use crate::transcription::{
models as model_catalog, run_streaming_worker, Transcriber, WhisperTranscriber,
};
use crate::{error::WaError, AppState, RecordingSession};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex as StdMutex};
use tauri::{AppHandle, Emitter, Manager, State};
/// ~8s of 16kHz mono mic audio — enough for a stable speaker-embedding
/// voiceprint (see `diarization::voiceprint`) without holding minutes of raw
/// audio in memory for the whole meeting.
const MIC_VOICEPRINT_SAMPLES: usize = 16_000 * 8;
#[derive(Deserialize)]
pub struct StartRecordingArgs {
pub meeting_title: Option<String>,
pub calendar_event_id: Option<String>,
/// Retain audio as .wav? Defaults to false (ADR-0009). Controls retention, not capture.
#[serde(default)]
pub record: bool,
/// Note-template id (Phase 8, T8.1, FR-NOTE-5) — see `notes::built_in_note_templates`.
pub template_id: Option<String>,
/// Per-meeting transcription language override (T8.7, FR-TRX-4): `None`
/// falls back to `Settings.whisper_language`; `None`/`"auto"` (either
/// way) requests auto-detection. Only takes effect with a multilingual
/// model — see `transcription::resolve_language`.
#[serde(default)]
pub language: Option<String>,
}
// ---- File-backed settings (consent/default-retention/storage policy) ----
// `settings.json` per `docs/03-data-model.md`; meeting rows/files go through
// `AppState.store` (Phase 2, `storage::SqliteStore`).
pub(crate) fn default_settings() -> Settings {
Settings {
theme: "system".into(),
storage_root: wa_root().display().to_string(),
llm_provider: "ollama".into(),
llm_endpoint: "http://localhost:11434".into(),
llm_model: "llama3".into(),
llm_advanced: serde_json::Value::Null,
preferred_backend: "auto".into(),
whisper_model: crate::paths::DEFAULT_WHISPER_MODEL.to_string(),
whisper_language: None, // auto-detect by default (T8.7, FR-TRX-4)
low_overhead: false,
default_record: false,
consent_acknowledged: false,
hosted_ai_acknowledged: false,
sync_enabled: false,
retention_max_age_days: None,
retention_max_size_gb: None,
pst_last_path: None,
pst_auto_sync: false,
pst_import_range_days: None,
auto_record_calendar: false,
graph_calendar_enabled: false,
graph_calendar_credential_ref: None,
audio_output_device: None,
microphone_enabled: true,
audio_input_device: None,
mcp_enabled: false,
mcp_transport: "http".into(),
mcp_port: 4849,
mcp_expose: "none".into(),
mcp_expose_recordings: false,
auto_start: false,
close_to_tray: true,
}
}
pub(crate) fn load_settings() -> Settings {
std::fs::read_to_string(settings_path())
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_else(default_settings)
}
pub(crate) fn save_settings(settings: &Settings) -> Result<(), WaError> {
let path = settings_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| WaError::new("settings", e.to_string()))?;
}
let json = serde_json::to_string_pretty(settings)
.map_err(|e| WaError::new("settings", e.to_string()))?;
std::fs::write(path, json).map_err(|e| WaError::new("settings", e.to_string()))
}
/// Resolves which model file a recording should use: the "low overhead" preset
/// (T3.9) always takes the smallest catalog model over whatever is configured,
/// since it's optimizing for overhead, not accuracy.
fn model_id_for(settings: &Settings) -> String {
if settings.low_overhead {
model_catalog::smallest_id().to_string()
} else {
settings.whisper_model.clone()
}
}
/// The model id to *report* for a session on `backend`: the ONNX engine
/// (NPU/DirectML) ignores the configured ggml model and always runs its own
/// ONNX artifacts, so recording the ggml id would be a lie — e.g. a meeting
/// shown as "medium.en-q5_0 · npu" actually transcribed with ONNX base.en.
/// Mirrors the exact routing condition in `load_transcriber`; if the engine
/// fails to load at runtime the worker falls back to whisper.cpp and the
/// backend is corrected via `hardware://changed`, but this reported model
/// isn't — acceptable for that rare failure path.
fn effective_model_id(backend: BackendId, requested: &str) -> String {
#[cfg(feature = "npu")]
{
use crate::hardware::{resolve_accel, AccelPath};
use crate::transcription::onnx_models;
if matches!(
resolve_accel(backend),
AccelPath::OnnxOpenVino | AccelPath::OnnxDirectML
) && onnx_models::is_installed(onnx_models::DEFAULT_ONNX_MODEL)
{
return format!("{} (onnx)", onnx_models::DEFAULT_ONNX_MODEL);
}
}
#[cfg(not(feature = "npu"))]
let _ = backend;
requested.to_string()
}
/// Picks the backend to transcribe with: "low overhead" always forces CPU;
/// otherwise resolve the user's preferred backend (or auto-detect) against
/// what's actually available (T3.2, T3.5).
fn backend_for(settings: &Settings) -> BackendId {
if settings.low_overhead {
return BackendId::Cpu;
}
let preferred = match settings.preferred_backend.as_str() {
"npu" => Some(BackendId::Npu),
"nvidia" => Some(BackendId::Nvidia),
"amd" => Some(BackendId::Amd),
"intel" => Some(BackendId::Intel),
"cpu" => Some(BackendId::Cpu),
_ => None, // "auto"
};
WinHardwareDetector.best(preferred).id
}
/// Builds the transcriber for `backend`: the NPU backend goes to the
/// ONNX/OpenVINO engine (T3.4), everything else to whisper.cpp. Falls through to
/// whisper.cpp on CPU if the chosen engine can't load, and returns the backend
/// *actually* used so callers can update their "active backend" state (T3.5,
/// FR-HW-4). One place so streaming and batch dispatch identically.
#[cfg(feature = "cpu-transcription")]
fn load_transcriber(
backend: BackendId,
whisper_model: &Path,
language: Option<&str>,
) -> Result<(Box<dyn Transcriber>, BackendId), crate::transcription::TrxError> {
use crate::hardware::{resolve_accel, AccelPath};
// Resolve how this backend is actually served in *this* build (CUDA/Vulkan
// baked in? NPU/DirectML runtime present?) rather than assuming a GPU
// backend has a working accel path just because the hardware exists.
let path = resolve_accel(backend);
// ONNX engine — NPU (OpenVINO EP) or AMD/Intel GPU (DirectML EP). Same model
// artifacts either way; the EP is chosen inside OnnxTranscriber::load from
// `backend`, and the resolved `backend` is reported so the UI shows the real
// engine.
#[cfg(feature = "npu")]
if matches!(path, AccelPath::OnnxOpenVino | AccelPath::OnnxDirectML) {
use crate::transcription::{onnx_models, OnnxTranscriber};
if onnx_models::is_installed(onnx_models::DEFAULT_ONNX_MODEL) {
let dir = onnx_models::model_dir(onnx_models::DEFAULT_ONNX_MODEL);
match OnnxTranscriber::load(&dir, backend, language) {
Ok(t) => {
// The definitive "is the accelerator actually engaged" line
// (visible with `npm run tauri dev`): encoder EP from the
// accel path, decoder EP from wherever its session landed
// (OpenVINO/GPU on NPU systems, CPU otherwise/on fallback).
tracing::info!(
"transcription engine: ONNX {} (encoder EP: {}, decoder EP: {})",
onnx_models::DEFAULT_ONNX_MODEL,
match path {
AccelPath::OnnxOpenVino => "OpenVINO/NPU",
_ => "DirectML/GPU",
},
t.decoder_ep()
);
return Ok((Box::new(t), backend));
}
Err(e) => tracing::warn!("ONNX engine load failed ({e}); falling back to CPU"),
}
} else {
tracing::warn!(
"ONNX backend selected but model not installed; falling back to whisper.cpp"
);
}
}
// whisper.cpp path: only ask for GPU offload when the resolver picked a
// whisper GPU backend that's compiled in — otherwise a bogus `use_gpu` for a
// vendor with no accel path just no-ops. Anything else decodes on the CPU.
let whisper_backend = match path {
AccelPath::WhisperCuda | AccelPath::WhisperVulkan => backend,
_ => BackendId::Cpu,
};
match WhisperTranscriber::load(whisper_model, whisper_backend, language) {
Ok(t) => Ok((Box::new(t), whisper_backend)),
Err(e) if whisper_backend != BackendId::Cpu => {
tracing::warn!("backend {whisper_backend:?} failed to load ({e}); falling back to CPU");
WhisperTranscriber::load(whisper_model, BackendId::Cpu, language)
.map(|t| (Box::new(t) as Box<dyn Transcriber>, BackendId::Cpu))
}
Err(e) => Err(e),
}
}
/// Normalizes a requested language string the same way whisper.cpp itself
/// treats it: an empty string or the literal `"auto"` both mean "no explicit
/// language" — matching `FullParams::set_language`'s own `None`/`Some("auto")`
/// equivalence (T8.7, FR-TRX-4) — so callers can pass either spelling through
/// from settings/args without duplicating this check everywhere.
fn normalize_language(language: Option<&str>) -> Option<&str> {
match language {
Some(l) if !l.is_empty() && !l.eq_ignore_ascii_case("auto") => Some(l),
_ => None,
}
}
fn now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// A command whose feature isn't built yet (roadmap phase not reached). This
/// is deliberately a returned `Err`, never a `todo!()`/`unimplemented!()`:
/// the release profile sets `panic = "abort"`, so a panic anywhere a command
/// handler can reach would kill the whole app the instant any frontend code
/// path calls it — not just fail that one call (found the hard way: an
/// unconditional `listSyncTargets()` on startup was crashing every launch).
fn not_implemented(feature: &str) -> WaError {
WaError::new("not_implemented", format!("{feature} isn't built yet"))
}
/// Builds a `Diarizer` if both diarization models are installed — a fixed
/// pair of well-known filenames, downloadable/removable via
/// `diarization::models` and `list_diarization_models`/`download_model`/
/// `remove_model` (T4.7). Returns `None` rather than erring — diarization is
/// a provisional/refinement layer that a recording never depends on, same
/// treatment as a missing hardware backend.
fn diarizer_from_installed_models() -> Option<SherpaDiarizer> {
let seg_model = diarization_segmentation_model_file();
let emb_model = diarization_embedding_model_file();
if !seg_model.exists() || !emb_model.exists() {
return None;
}
match SherpaDiarizer::new(&seg_model, &emb_model) {
Ok(d) => Some(d),
Err(e) => {
tracing::warn!("diarization models present but failed to load: {e}");
None
}
}
}
// RETIRED (2026-07-14): the Phase 3 masked-mono attribution (`MicActivity`
// timeline + `mic_activity.json` + `mask_ranges` + `phase3_attribute`).
// Why it existed: to give per-stream "You" vs "Speaker" attribution when
// `audio.wav` was a summed mono mix — record a live mic-speech timeline, then
// diarize the mix with the mic's ranges masked out so clustering saw only the
// far side. Why retired: it was fragile on reprocess (re-aligning a sidecar
// timeline against a mono mix; collapsed to a single speaker), and the
// dual-channel split layout below makes the mic/far-side separation intrinsic
// to the file instead. Commits: 185489f / fd9311c / 0b5a85f (add), and this
// change (remove). Revisit there if a summed-only capture path is ever needed.
/// Split attribution (FR-SPK, supersedes `phase3_attribute`): diarize the far
/// side (right/loopback channel) into `Speaker N`, take "You" straight from
/// left-channel (mic) voice activity, attribute per channel
/// (`diarization::assign_split`) + name. Shared by
/// `stop_recording` and `reprocess_transcript`; both read it back from the file
/// so they agree. `None` if the far-side pass fails (caller falls back).
async fn attribute_split(
diarizer: Arc<dyn Diarizer>,
wav_path: PathBuf,
segments: &mut [TranscriptSegment],
) -> Option<HashMap<String, String>> {
let diarizer_for_task = diarizer.clone();
let result = tauri::async_runtime::spawn_blocking(move || {
// Right channel = loopback/far side; diarize it alone (mic never in it).
// Its VAD is the "was the far side talking at all" evidence assign_split
// weighs against the mic channel.
let far = crate::audio::read_wav_channel_16k(&wav_path, 1).map_err(|e| e.to_string())?;
let far_vad = crate::audio::vad_spans(&far);
let far_spans = diarizer_for_task
.diarize_samples(far)
.map_err(|e| e.to_string())?;
// Left channel = mic; its voice activity is "You".
let mic = crate::audio::read_wav_channel_16k(&wav_path, 0).map_err(|e| e.to_string())?;
Ok::<_, String>((far_spans, far_vad, crate::audio::vad_spans(&mic)))
})
.await;
let (far_spans, far_vad, you_spans) = match result {
Ok(Ok(v)) => v,
Ok(Err(e)) => {
tracing::warn!("split attribution failed: {e}");
return None;
}
Err(e) => {
tracing::warn!("split attribution task failed: {e}");
return None;
}
};
crate::diarization::assign_split(segments, &you_spans, &far_vad, &far_spans);
// Merged span timeline, only for first-appearance naming order below.
let mut spans: Vec<SpeakerSpan> = you_spans
.iter()
.map(|&(start_ms, end_ms)| SpeakerSpan {
start_ms,
end_ms,
speaker: "You".to_string(),
})
.collect();
spans.extend(far_spans);
spans.sort_by_key(|s| s.start_ms);
// Uniform naming: "You" -> "You", far speakers -> "Speaker 2", "Speaker 3"….
let labels = crate::diarization::voiceprint::first_appearance_order(&spans);
Some(crate::diarization::voiceprint::build_name_map(
&labels, "You",
))
}
/// The distinct speakers seen in `segments` so far, in first-appearance order,
/// with any display names applied (T4.3/T4.4, FR-SPK-2/5). Falls back to the
/// single pre-diarization "S1" placeholder if no segments exist yet.
fn speaker_infos_from_segments(
segments: &[TranscriptSegment],
names: &HashMap<String, String>,
) -> Vec<SpeakerInfo> {
let mut seen = std::collections::HashSet::new();
let mut out: Vec<SpeakerInfo> = segments
.iter()
.filter(|s| seen.insert(s.speaker.clone()))
.map(|s| SpeakerInfo {
label: s.speaker.clone(),
display_name: names.get(&s.speaker).cloned(),
participant_id: None,
})
.collect();
if out.is_empty() {
out.push(SpeakerInfo {
label: "S1".to_string(),
display_name: names.get("S1").cloned(),
participant_id: None,
});
}
out
}
// ---- Recording lifecycle (Phase 1) ----
#[tauri::command]
pub async fn start_recording(
app: AppHandle,
state: State<'_, AppState>,
args: StartRecordingArgs,
) -> WaResult<MeetingId> {
let mut guard = state.session.lock().await;
if guard.is_some() {
return Err(WaError::new(
"recording",
"a meeting is already in progress",
));
}
if args.record && !load_settings().consent_acknowledged {
return Err(WaError::new(
"consent",
"recording consent has not been acknowledged yet",
));
}
let settings = load_settings();
let model_id = model_id_for(&settings);
let backend = backend_for(&settings);
let model_path = whisper_model_file(&model_id);
if !model_path.exists() {
return Err(WaError::new(
"transcription",
format!(
"whisper model '{model_id}' not found at {}; download it from Settings first",
model_path.display()
),
));
}
// T8.7/FR-TRX-4: a per-meeting override wins over the Settings default;
// both spellings of "no explicit language" collapse to `None` here.
let language: Option<String> = normalize_language(
args.language
.as_deref()
.or(settings.whisper_language.as_deref()),
)
.map(|s| s.to_string());
let meeting_id = state
.store
.create_meeting(NewMeeting {
title: args
.meeting_title
.unwrap_or_else(|| "Untitled meeting".to_string()),
calendar_event_id: args.calendar_event_id,
template_id: args.template_id,
language: language.clone(),
})
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let wav_path = meeting_dir(&meeting_id).join("audio.wav");
// Bounded so a slow/stalled transcription worker can never back up the capture thread.
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(8);
// Level updates + device-change notices (FR-CAP-5/6); low-volume, forwarded to events below.
let (event_tx, event_rx) = std::sync::mpsc::sync_channel::<crate::audio::CaptureEvent>(8);
// When the mic is enabled (FR-CAP-7), capture it alongside loopback and let
// the mixer sum both into `frame_tx`; with it off, loopback feeds the
// transcription worker directly, exactly as before (no mixer overhead). A
// microphone that fails to open must not sink the meeting: we log and fall
// back to loopback-only (the mixer forwards loopback alone once its sink
// drops).
let (capture, mic_capture, mic_voice_sample) = if settings.microphone_enabled {
// The mixer sums both streams for the live transcript; the bridge carries
// the mic into the loopback thread. The loopback writes stereo split
// (mic-left/loopback-right) so the two sides stay separate (FR-SPK).
let bridge = crate::audio::MicBridge::shared();
// A few seconds of raw mic audio for the summed-recording voiceprint
// fallback (imports / mic-off diarization).
let voice_sample = crate::audio::VoiceSample::new(MIC_VOICEPRINT_SAMPLES);
let (loop_sink, mic_sink) = crate::audio::spawn_mixer(frame_tx);
let capture = WasapiCapture
.start_loopback_recording(
&wav_path,
settings.audio_output_device.as_deref(),
loop_sink,
event_tx.clone(),
bridge.clone(),
)
.map_err(|e| WaError::new("audio", e.to_string()))?;
let mic = WasapiCapture
.start_microphone_recording(
settings.audio_input_device.as_deref(),
mic_sink,
event_tx,
bridge,
Some(voice_sample.clone()),
)
.map_err(|e| tracing::warn!("microphone capture unavailable: {e}"))
.ok();
let voice_sample = mic.is_some().then_some(voice_sample);
(capture, mic, voice_sample)
} else {
let capture = WasapiCapture
.start(
&wav_path,
settings.audio_output_device.as_deref(),
frame_tx,
event_tx,
)
.map_err(|e| WaError::new("audio", e.to_string()))?;
(capture, None, None)
};
// Fire-and-forget: exits on its own once `event_tx` drops at capture stop;
// nothing downstream needs to join it.
let app_for_capture_events = app.clone();
let meeting_id_for_capture_events = meeting_id.clone();
std::thread::Builder::new()
.name("wa-capture-events".into())
.spawn(move || {
for event in event_rx {
match event {
crate::audio::CaptureEvent::Level(level) => {
let _ = app_for_capture_events.emit(
"recording://level",
serde_json::json!({
"meetingId": meeting_id_for_capture_events,
"rms": level.rms,
"peak": level.peak,
"mic": level.mic,
}),
);
}
crate::audio::CaptureEvent::DeviceChanged { recovered, message } => {
let _ = app_for_capture_events.emit(
"recording://device",
serde_json::json!({
"meetingId": meeting_id_for_capture_events,
"recovered": recovered,
"message": message,
}),
);
}
}
}
})
.map_err(|e| WaError::new("audio", e.to_string()))?;
let segments: Arc<StdMutex<Vec<TranscriptSegment>>> = Arc::new(StdMutex::new(Vec::new()));
let segments_for_worker = segments.clone();
let active_backend: Arc<StdMutex<BackendId>> = Arc::new(StdMutex::new(backend));
let active_backend_for_worker = active_backend.clone();
// T8.7/FR-TRX-4: starts at the requested value, then the worker refines
// it — first to what the loaded engine actually resolved (e.g. forced
// "en" for an English-only model), then to whatever was last
// used/detected once streaming stops. `stop_recording` reads the final
// value after joining this thread.
let language_state: Arc<StdMutex<Option<String>>> = Arc::new(StdMutex::new(language.clone()));
let language_state_for_worker = language_state.clone();
let language_for_worker = language.clone();
let app_for_worker = app.clone();
let meeting_id_for_worker = meeting_id.clone();
// Live-transcript cadence; the low-overhead preset trades a touch of
// immediacy for half the decode load (see StreamTuning).
let stream_tuning = crate::transcription::StreamTuning::new(settings.low_overhead);
let transcription_worker = std::thread::Builder::new()
.name("wa-transcription".into())
.spawn(move || {
// Dispatch to the NPU engine or whisper.cpp, with graceful CPU
// fall-through (T3.4/T3.5, FR-HW-4): a GPU/NPU load failure (driver
// issue, OOM, missing model) drops to CPU rather than losing the
// meeting's transcript entirely.
let (transcriber, used) =
match load_transcriber(backend, &model_path, language_for_worker.as_deref()) {
Ok(pair) => pair,
Err(e) => {
tracing::error!("failed to load any transcriber: {e}");
return;
}
};
if used != backend {
if let Ok(mut b) = active_backend_for_worker.lock() {
*b = used;
}
let _ = app_for_worker.emit(
"hardware://changed",
serde_json::json!({
"active": used.as_str(),
"reason": format!("{backend:?} unavailable; using {used:?}"),
}),
);
}
if let Ok(mut lang) = language_state_for_worker.lock() {
*lang = transcriber.effective_language();
}
run_streaming_worker(transcriber.as_ref(), frame_rx, stream_tuning, |segment| {
// Only committed lines are the real transcript; interim updates
// are live-UI only (emitted below). Storing interims would push
// a duplicate row per refresh into the buffer stop_recording and
// the live-diarization preview both read. A committed line's id
// is stable, so replace-in-place guards against any re-commit.
if !segment.interim {
if let Ok(mut buf) = segments_for_worker.lock() {
match buf.iter_mut().find(|s| s.id == segment.id) {
Some(existing) => *existing = segment.clone(),
None => buf.push(segment.clone()),
}
}
}
let _ = app_for_worker.emit(
"transcript://segment",
serde_json::json!({ "meetingId": meeting_id_for_worker, "segment": segment }),
);
});
// Prefer whatever the engine actually used/detected on its last
// decode over the load-time resolution above — meaningful for
// "auto" mode, where the real answer only exists after decoding.
if let Some(detected) = transcriber.detected_language() {
if let Ok(mut lang) = language_state_for_worker.lock() {
*lang = Some(detected);
}
}
})
.map_err(|e| WaError::new("transcription", e.to_string()))?;
// T4.3: cheap/provisional live diarization, skipped entirely (None) when
// the diarization models aren't installed yet (T4.7) — never blocks
// recording, same graceful-degradation treatment as a missing backend.
// Loading the ONNX models is blocking I/O, so it runs off this async task.
let diarizer: Option<Arc<dyn Diarizer>> =
tauri::async_runtime::spawn_blocking(diarizer_from_installed_models)
.await
.ok()
.flatten()
.map(|d| Arc::new(d) as Arc<dyn Diarizer>);
let speaker_names: Arc<StdMutex<HashMap<String, String>>> =
Arc::new(StdMutex::new(HashMap::new()));
if let Some(diarizer) = diarizer.clone() {
let app_for_diar = app.clone();
let meeting_id_for_diar = meeting_id.clone();
let wav_path_for_diar = wav_path.clone();
let segments_for_diar = segments.clone();
let names_for_diar = speaker_names.clone();
// Same condition as `audio_layout` below: mic on → split stereo file.
let split_layout = settings.microphone_enabled;
tauri::async_runtime::spawn(async move {
// ponytail: reprocesses the whole recording-so-far each tick
// rather than incremental/windowed segmentation — sherpa-onnx's
// offline Diarizer has no streaming primitive to build on, and
// this is provisional preview only (the accurate pass runs once
// at stop). Fine at meeting length and a 15s cadence; revisit
// with real streaming segmentation if long meetings make it heavy.
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(15));
ticker.tick().await; // interval's first tick fires immediately; skip it
loop {
ticker.tick().await;
let still_active = app_for_diar
.state::<AppState>()
.session
.lock()
.await
.as_ref()
.is_some_and(|s| s.meeting_id == meeting_id_for_diar);
if !still_active {
break; // recording stopped (or a new one started) — nothing left to do
}
// Split recording: the exact same channel-based attribution as
// the post-stop pass — mic (left) is always "You", the far side
// (right) is diarized alone. The old whole-mix pass + voiceprint
// cosine match never reliably showed "You" live (clusters over
// the summed mix reshuffle every tick and the match often missed
// its threshold), so "You" only appeared after stop.
let (speakers, changed) = if split_layout {
let mut snapshot = segments_for_diar
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
if attribute_split(diarizer.clone(), wav_path_for_diar.clone(), &mut snapshot)
.await
.map(|auto_names| {
let mut names =
names_for_diar.lock().unwrap_or_else(|e| e.into_inner());
// Never overwrite a name already set (user rename or a
// prior pass) — same guard as the post-stop pass.
for (label, name) in auto_names {
names.entry(label).or_insert(name);
}
})
.is_none()
{
continue; // pass failed (already logged); retry next tick
}
let relabeled: HashMap<u64, String> =
snapshot.into_iter().map(|s| (s.id, s.speaker)).collect();
let mut segs = segments_for_diar.lock().unwrap_or_else(|e| e.into_inner());
// Snapshot prior labels so only segments whose speaker
// actually changed this pass get re-emitted (ids are stable;
// the frontend replaces by id). Segments committed while the
// pass ran keep their placeholder until the next tick.
let before: HashMap<u64, String> =
segs.iter().map(|s| (s.id, s.speaker.clone())).collect();
for seg in segs.iter_mut() {
if let Some(speaker) = relabeled.get(&seg.id) {
seg.speaker = speaker.clone();
}
}
let names = names_for_diar.lock().unwrap_or_else(|e| e.into_inner());
let changed: Vec<TranscriptSegment> = segs
.iter()
.filter(|s| before.get(&s.id) != Some(&s.speaker))
.cloned()
.collect();
(speaker_infos_from_segments(&segs, &names), changed)
} else {
// Summed recording (mic off): whole-signal pass. No mic
// channel → no live "You" (the mic voice sample doesn't
// exist either), matching the post-stop behavior.
let diarizer_for_pass = diarizer.clone();
let wav_path = wav_path_for_diar.clone();
let spans = tauri::async_runtime::spawn_blocking(move || {
diarizer_for_pass.diarize(&wav_path)
})
.await;
let spans = match spans {
Ok(Ok(spans)) => spans,
Ok(Err(e)) => {
tracing::warn!("live diarization pass failed: {e}");
continue;
}
Err(e) => {
tracing::warn!("live diarization task failed: {e}");
continue;
}
};
let mut segs = segments_for_diar.lock().unwrap_or_else(|e| e.into_inner());
let before: HashMap<u64, String> =
segs.iter().map(|s| (s.id, s.speaker.clone())).collect();
diarizer.assign(&mut segs, &spans);
let names = names_for_diar.lock().unwrap_or_else(|e| e.into_inner());
let changed: Vec<TranscriptSegment> = segs
.iter()
.filter(|s| before.get(&s.id) != Some(&s.speaker))
.cloned()
.collect();
(speaker_infos_from_segments(&segs, &names), changed)
};
let _ = app_for_diar.emit(
"diarization://updated",
serde_json::json!({ "meetingId": meeting_id_for_diar, "speakers": speakers }),
);
// Re-emit relabeled committed segments so the live transcript
// reflects the refined speakers without a new event type
// (docs/04-api-contracts.md: transcript://segment is replace-by-id).
for segment in &changed {
let _ = app_for_diar.emit(
"transcript://segment",
serde_json::json!({ "meetingId": meeting_id_for_diar, "segment": segment }),
);
}
}
});
}
*guard = Some(RecordingSession {
meeting_id: meeting_id.clone(),
capture,
mic_capture,
retention: args.record,
wav_path,
started_at: std::time::Instant::now(),
transcription_worker,
segments,
active_backend,
// Report the model the routed engine will actually run (the ONNX
// engine ignores the configured ggml model), so the meeting's
// "transcribed with … · npu" line is truthful (T3.4/T3.5).
model_id: effective_model_id(backend, &model_id),
language: language_state,
diarizer,
speaker_names,
mic_voice_sample,
// Mic on → the loopback writer records split (mic-left/loopback-right);
// mic off → the summed loopback-only file (FR-SPK).
audio_layout: if settings.microphone_enabled {
"split"
} else {
"summed"
},
manual_notes: Arc::new(StdMutex::new(ManualNotes::default())),
});
drop(guard);
crate::update_tray_tooltip(&app, "WhispAssist — recording");
let _ = app.emit(
"recording://state",
serde_json::json!({ "meetingId": meeting_id, "state": "recording", "elapsedMs": 0 }),
);
Ok(meeting_id)
}
#[tauri::command]
pub async fn stop_recording(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
) -> WaResult<()> {
let mut guard = state.session.lock().await;
let session = match guard.take() {
Some(s) if s.meeting_id == meeting_id => s,
Some(s) => {
*guard = Some(s);
return Err(WaError::new(
"recording",
"meeting_id does not match the active recording",
));
}
None => {
return Err(WaError::new(
"recording",
"no meeting is currently recording",
))
}
};
drop(guard);
let summary = WasapiCapture
.stop(session.capture)
.map_err(|e| WaError::new("audio", e.to_string()))?;
// Stop the mic too (FR-CAP-7) so the mixer sees both sinks drop and closes
// `frame_tx`; ignore its summary/errors — the loopback WAV is the recording.
if let Some(mic) = session.mic_capture {
let _ = WasapiCapture.stop(mic);
}
// Both capture threads have now dropped their `FrameSink`s; the transcription
// worker's `recv()` returns `Err` and the worker exits on its own — join to
// guarantee it has fully drained the audio before we act on retention.
let _ = session.transcription_worker.join();
let mut segments = session
.segments
.lock()
.map(|g| g.clone())
.unwrap_or_default();
let segment_count = segments.len();
// Speaker attribution over the now-complete recording.
// FR-SPK: a split recording keeps the mic and far side on separate channels,
// so diarize the far side (right channel) alone → "Speaker N" and take "You"
// from left-channel voice activity. Falls back to the whole-signal pass
// (ADR-0005) + voiceprint "You" for summed recordings (mic off) or if the
// split pass fails.
let mut speaker_names: HashMap<String, String> = HashMap::new();
let mut attributed = false;
if session.audio_layout == "split" {
if let Some(diarizer) = session.diarizer.clone() {
if let Some(mut names) =
attribute_split(diarizer, session.wav_path.clone(), &mut segments).await
{
// Renames made *during* the recording are user-authored — they
// win over the automatic "You"/"Speaker N" defaults instead of
// being silently dropped at finalize.
if let Ok(user_names) = session.speaker_names.lock() {
for (label, name) in user_names.iter() {
names.insert(label.clone(), name.clone());
}
}
speaker_names = names;
attributed = true;
}
}
}
if !attributed {
// Fallback: one whole-signal pass, then voiceprint-match the mic against
// the clusters to auto-label "You" (never overriding a user-set name).
let mut final_spans: Option<Vec<SpeakerSpan>> = None;
if let Some(diarizer) = session.diarizer.clone() {
let diarizer_for_task = diarizer.clone();
let wav_path = session.wav_path.clone();
match tauri::async_runtime::spawn_blocking(move || diarizer_for_task.diarize(&wav_path))
.await
{
Ok(Ok(spans)) => {
diarizer.assign(&mut segments, &spans);
final_spans = Some(spans);
}
Ok(Err(e)) => tracing::warn!("final diarization pass failed: {e}"),
Err(e) => tracing::warn!("final diarization task failed: {e}"),
}
}
if let (Some(voice_sample), Some(spans)) = (&session.mic_voice_sample, &final_spans) {
let mic_samples = voice_sample.samples();
match crate::diarization::voiceprint::match_mic_speaker(
&diarization_embedding_model_file(),
&mic_samples,
&session.wav_path,
spans,
) {
Ok(auto_names) => {
for (label, name) in auto_names {
let already_named = session
.speaker_names
.lock()
.map(|g| g.contains_key(&label))
.unwrap_or(true); // poisoned lock: don't guess, skip
if already_named {
continue;
}
if let Err(e) = state.store.rename_speaker(&meeting_id, &label, &name).await
{
tracing::warn!("failed to persist auto speaker name: {e}");
continue;
}
if let Ok(mut names) = session.speaker_names.lock() {
names.insert(label, name);
}
}
}
Err(e) => tracing::warn!("mic voiceprint match failed: {e}"),
}
}
speaker_names = session
.speaker_names
.lock()
.map(|g| g.clone())
.unwrap_or_default();
}
let speakers = speaker_infos_from_segments(&segments, &speaker_names);
let backend_used = session
.active_backend
.lock()
.map(|b| b.as_str().to_string())
.unwrap_or_else(|_| BackendId::Cpu.as_str().to_string());
// T8.7/FR-TRX-4: whatever the transcription worker last resolved —
// explicit request, English-only forcing, or auto-detected result.
let language = session.language.lock().ok().and_then(|g| g.clone());
// T2.10: persist transcript.json (via finalize_meeting) and notes.md
// *before* touching the working WAV, so a crash here still leaves a
// recoverable, regenerable meeting.
let template_id = state
.store
.finalize_meeting(
&meeting_id,
FinalizeMeeting {
segments: segments.clone(),
speakers: speakers.clone(),
duration_secs: (summary.duration_ms / 1000) as i64,
recorded: session.retention,
language,
backend_used: Some(backend_used),
model_used: Some(session.model_id.clone()),
audio_layout: Some(session.audio_layout.to_string()),
},
)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let template = template_id
.as_deref()
.and_then(crate::notes::note_template_by_id);
// Granola-style redesign: fold in whatever was captured live (freeform
// notes typed during the meeting + per-moment annotations) instead of
// generating notes.md from the transcript alone.
let manual_notes = session
.manual_notes
.lock()
.map(|g| g.clone())
.unwrap_or_default();
let notes_md = crate::notes::MarkdownNotes.merge(
&segments,
&speakers,
&manual_notes,
None,
template.as_ref(),
);
let _ = state.store.update_notes(&meeting_id, &notes_md).await;
let _ = app.emit(
"transcript://finalized",
serde_json::json!({ "meetingId": meeting_id, "segmentCount": segment_count }),
);
// ADR-0009: delete the working WAV only after the transcript is finalized
// above, and only when retention is off.
let voiceprint_path = meeting_dir(&meeting_id).join("voiceprint.wav");
if !session.retention {
let _ = std::fs::remove_file(&session.wav_path);
// voiceprint.wav lives and dies with audio.wav (ADR-0009); never written
// when retention is off, but remove defensively regardless.
let _ = std::fs::remove_file(&voiceprint_path);
} else if crate::vault::is_unlocked() {
// Seal the retained recording at rest when the vault is unlocked (T8.8).
// Runs before the sync enqueue below, so any uploaded copy is ciphertext.
if let Ok(raw) = std::fs::read(&session.wav_path) {
if let Ok(sealed) = crate::vault::seal(&raw) {
let _ = std::fs::write(&session.wav_path, sealed);
}
}
}
// FR-SPK: persist the mic voiceprint alongside a retained recording so
// reprocess_transcript can re-identify "You" after it re-clusters. Retained
// audio of the user's own voice, so it's gated on the same ADR-0009
// consent/retention as audio.wav and sealed at rest the same way.
if session.retention {
if let Some(voice_sample) = &session.mic_voice_sample {
let samples = voice_sample.samples();
if let Err(e) = crate::audio::write_wav_mono_16k(&voiceprint_path, &samples) {
tracing::warn!("failed to persist voiceprint.wav: {e}");
} else if crate::vault::is_unlocked() {
if let Ok(raw) = std::fs::read(&voiceprint_path) {
if let Ok(sealed) = crate::vault::seal(&raw) {
let _ = std::fs::write(&voiceprint_path, sealed);
}
}
}
}
}
// Sync-on-finalize (T9.5, FR-SYNC-5): enqueue configured artifacts for
// finalize-trigger targets and pump in the background so stop returns
// promptly. Runs after the WAV-retention decision above, so a deleted
// working recording is simply skipped (its file is gone).
if load_settings().sync_enabled {
let store = state.store.clone();
let app_sync = app.clone();
let mid = meeting_id.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = enqueue_meeting_sync(store.as_ref(), &mid, None, true).await {
tracing::warn!("sync enqueue on finalize failed: {e:?}");
}
pump_sync(&app_sync, store.as_ref()).await;
});
}
crate::update_tray_tooltip(&app, "WhispAssist — idle");
let _ = app.emit(
"recording://state",
serde_json::json!({ "meetingId": meeting_id, "state": "stopped", "elapsedMs": summary.duration_ms }),
);
Ok(())
}
/// Abandon the in-progress recording: stop capture, drop the transcript, and
/// delete the meeting row + its working files entirely — for a recording that
/// was started by mistake. Unlike `stop_recording`, nothing is finalized,
/// transcribed further, diarized, retained, or synced.
#[tauri::command]
pub async fn cancel_recording(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
) -> WaResult<()> {
let mut guard = state.session.lock().await;
let session = match guard.take() {
Some(s) if s.meeting_id == meeting_id => s,
Some(s) => {
*guard = Some(s);
return Err(WaError::new(
"recording",
"meeting_id does not match the active recording",
));
}
None => {
return Err(WaError::new(
"recording",
"no meeting is currently recording",
))
}
};
drop(guard);
// Stop both captures so their frame sinks drop and the transcription worker
// exits; join it so nothing is still touching the files we're about to delete.
let _ = WasapiCapture.stop(session.capture);
if let Some(mic) = session.mic_capture {
let _ = WasapiCapture.stop(mic);
}
let _ = session.transcription_worker.join();
// Remove the DB row + the whole meeting folder (working audio.wav included).
state
.store
.delete_meeting(&meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
crate::update_tray_tooltip(&app, "WhispAssist — idle");
let _ = app.emit(
"recording://state",
serde_json::json!({ "meetingId": meeting_id, "state": "cancelled", "elapsedMs": 0 }),
);
Ok(())
}
#[tauri::command]
pub async fn pause_recording(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
) -> WaResult<()> {
let guard = state.session.lock().await;
let session = guard
.as_ref()
.filter(|s| s.meeting_id == meeting_id)
.ok_or_else(|| WaError::new("recording", "no matching active recording"))?;
WasapiCapture
.pause(&session.capture)
.map_err(|e| WaError::new("audio", e.to_string()))?;
if let Some(mic) = session.mic_capture.as_ref() {
WasapiCapture
.pause(mic)
.map_err(|e| WaError::new("audio", e.to_string()))?;
}
drop(guard);
let _ = app.emit(
"recording://state",
serde_json::json!({ "meetingId": meeting_id, "state": "paused", "elapsedMs": 0 }),
);
Ok(())
}
#[tauri::command]
pub async fn resume_recording(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
) -> WaResult<()> {
let guard = state.session.lock().await;
let session = guard
.as_ref()
.filter(|s| s.meeting_id == meeting_id)
.ok_or_else(|| WaError::new("recording", "no matching active recording"))?;
WasapiCapture
.resume(&session.capture)
.map_err(|e| WaError::new("audio", e.to_string()))?;
if let Some(mic) = session.mic_capture.as_ref() {
WasapiCapture
.resume(mic)
.map_err(|e| WaError::new("audio", e.to_string()))?;
}
drop(guard);
let _ = app.emit(
"recording://state",
serde_json::json!({ "meetingId": meeting_id, "state": "recording", "elapsedMs": 0 }),
);
Ok(())
}
/// Toggle the microphone mute state for the active recording (FR-CAP-7): the
/// mic channel goes silent (recording + live transcript + meter) while loopback
/// keeps capturing. Bound to the "M" key in the UI. Returns the new muted state.
/// Errors if this meeting was started with the mic off (nothing to mute).
#[tauri::command]
pub async fn toggle_microphone_mute(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
) -> WaResult<bool> {
let guard = state.session.lock().await;
let session = guard
.as_ref()
.filter(|s| s.meeting_id == meeting_id)
.ok_or_else(|| WaError::new("recording", "no matching active recording"))?;
let mic = session
.mic_capture
.as_ref()
.ok_or_else(|| WaError::new("audio", "the microphone is off for this meeting"))?;
let muted = !mic.is_muted();
mic.set_muted(muted);
drop(guard);
let _ = app.emit(
"recording://mic",
serde_json::json!({ "meetingId": meeting_id, "muted": muted }),
);
crate::update_tray_tooltip(
&app,
if muted {
"WhispAssist — recording (mic muted)"
} else {
"WhispAssist — recording"
},
);
Ok(muted)
}
/// Toggle audio retention mid-meeting (ADR-0009, FR-REC-1).
#[tauri::command]
pub async fn set_recording_retention(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
record: bool,
) -> WaResult<()> {
if record && !load_settings().consent_acknowledged {
return Err(WaError::new(
"consent",
"recording consent has not been acknowledged yet",
));
}
let mut guard = state.session.lock().await;
let session = guard
.as_mut()
.filter(|s| s.meeting_id == meeting_id)
.ok_or_else(|| WaError::new("recording", "no matching active recording"))?;
session.retention = record;
drop(guard);
let _ = app.emit(
"recording://retention",
serde_json::json!({ "meetingId": meeting_id, "record": record }),
);
Ok(())
}
/// Record the one-time recording-consent acknowledgment (FR-REC-2).
#[tauri::command]
pub async fn acknowledge_recording_consent() -> WaResult<()> {
let mut settings = load_settings();
settings.consent_acknowledged = true;
save_settings(&settings)
}
// ---- Live notes: Granola-style redesign (freeform + per-moment, during recording) ----
/// Best-effort write-through of `manual_notes.json` — crash safety for what
/// the user typed live, same spirit as T2.8's recover-scan. Never fails the
/// calling command on a disk error; the in-memory copy (what `stop_recording`
/// reads) is already updated by the time this runs.
fn persist_manual_notes(meeting_id: &MeetingId, manual: &ManualNotes) {
match serde_json::to_vec_pretty(manual) {
Ok(bytes) => {
if let Err(e) = std::fs::write(manual_notes_file(meeting_id), bytes) {
tracing::warn!("failed to persist manual notes for {meeting_id}: {e}");
}
}
Err(e) => tracing::warn!("failed to serialize manual notes for {meeting_id}: {e}"),
}
}
/// Reads `manual_notes.json` for a finalize path with no live
/// `RecordingSession` to read it from in-memory — crash recovery
/// (`resume_transcription`, T2.8) and post-finalize batch re-transcription
/// (`reprocess_transcript`, T3.8) both re-render `notes.md` from scratch, and
/// must not silently drop whatever manual notes were captured during the
/// original recording. Defaults to empty if the file is missing (a meeting
/// with the mic-notes feature never used, or nothing typed) or unreadable.
fn load_manual_notes(meeting_id: &MeetingId) -> ManualNotes {
std::fs::read(manual_notes_file(meeting_id))
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default()
}
/// Update the freeform notes typed live during an in-progress recording —
/// the "Notes pane, open and typable while recording" feature. Live-session
/// only: once a meeting is finalized, `notes.md` is the single editable
/// document and `update_notes` is the command for it.
#[tauri::command]
pub async fn update_live_notes(
state: State<'_, AppState>,
meeting_id: MeetingId,
markdown: String,
) -> WaResult<()> {
let guard = state.session.lock().await;
let session = guard
.as_ref()
.filter(|s| s.meeting_id == meeting_id)
.ok_or_else(|| WaError::new("recording", "no matching active recording"))?;
let manual = {
let mut manual = session
.manual_notes
.lock()
.unwrap_or_else(|e| e.into_inner());
manual.freeform_md = markdown;
manual.clone()
};
drop(guard);
persist_manual_notes(&meeting_id, &manual);
Ok(())
}
/// Attach (or clear, with `text: ""`) a note to a specific moment in an
/// in-progress recording — the "click a transcript line, add a note to it"
/// feature. Anchored by timestamp rather than segment id: a later batch
/// re-transcription (T3.8) can renumber segments, but never moves the moment
/// in time the note pointed at. Live-session only, same reasoning as
/// `update_live_notes`.
#[tauri::command]
pub async fn set_segment_note(
state: State<'_, AppState>,
meeting_id: MeetingId,
anchor_ms: u64,
text: String,
) -> WaResult<()> {
let guard = state.session.lock().await;
let session = guard
.as_ref()
.filter(|s| s.meeting_id == meeting_id)
.ok_or_else(|| WaError::new("recording", "no matching active recording"))?;
let manual = {
let mut manual = session
.manual_notes
.lock()
.unwrap_or_else(|e| e.into_inner());
upsert_segment_note(&mut manual.segment_notes, anchor_ms, text, now_unix());
manual.clone()
};
drop(guard);
persist_manual_notes(&meeting_id, &manual);
Ok(())
}
/// Update the note at `anchor_ms` in place if one already exists (matches
/// a re-click on an already-annotated segment), else append a new one.
fn upsert_segment_note(notes: &mut Vec<SegmentNote>, anchor_ms: u64, text: String, now: i64) {
match notes.iter_mut().find(|n| n.anchor_ms == anchor_ms) {
Some(existing) => {
existing.text = text;
existing.updated_at = now;
}
None => notes.push(SegmentNote {
anchor_ms,
text,
created_at: now,
updated_at: now,
}),
}
}
// ---- Speakers (Phase 4) ----
/// Tells the frontend a finalized meeting's speaker names/mapping changed
/// (T4.5/4.6, FR-SPK-3/5), e.g. after a rename or merge.
///
/// Bug fix: this used to also re-render and overwrite `notes.md` from the
/// current segments+speakers on every call — which, once the notes redesign
/// made `notes.md` the user's actual freely-edited document (manual notes +
/// transcript merged at finalize, see `MarkdownNotes::merge`), would have
/// silently destroyed whatever the user had written. `notes.md` is only ever
/// generated once, at finalize; a later rename updates the `speakers` table
/// and live UI display, and simply doesn't retroactively rewrite text
/// already baked into notes.md — same as any other manual edit isn't
/// retroactively touched either.
async fn refresh_notes_and_notify(
app: &AppHandle,
state: &State<'_, AppState>,
meeting_id: &MeetingId,
) -> WaResult<Vec<SpeakerInfo>> {
let meeting = state
.store
.get_meeting(meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let _ = app.emit(
"diarization://updated",
serde_json::json!({ "meetingId": meeting_id, "speakers": meeting.speakers }),
);
Ok(meeting.speakers)
}
/// `notes.md` bakes display names into its `**Name:**` dialogue tags at
/// finalize, so a post-meeting rename must rewrite them or the Notes pane
/// (and every export) keeps the old name forever. Targeted tag replace, not
/// a regenerate, so the user's own edits to notes.md are preserved.
async fn rename_speaker_in_notes(
state: &State<'_, AppState>,
meeting_id: &MeetingId,
old_name: &str,
new_name: &str,
) -> WaResult<()> {
if old_name == new_name || new_name.is_empty() {
return Ok(());
}
let meeting = state
.store
.get_meeting(meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let old_tag = format!("**{old_name}:**");
if meeting.notes_markdown.contains(&old_tag) {
let updated = meeting
.notes_markdown
.replace(&old_tag, &format!("**{new_name}:**"));
state
.store
.update_notes(meeting_id, &updated)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
}
Ok(())
}
/// A speaker's current name as notes.md renders it: display name, else the
/// raw label. `None` if the meeting/speaker can't be read (nothing to rewrite).
async fn current_speaker_name(
state: &State<'_, AppState>,
meeting_id: &MeetingId,
label: &str,
) -> Option<String> {
let meeting = state.store.get_meeting(meeting_id).await.ok()?;
let speaker = meeting.speakers.iter().find(|s| s.label == label)?;
Some(
speaker
.display_name
.clone()
.unwrap_or_else(|| label.to_string()),
)
}
/// Name a speaker; applies to that speaker's past & future segments (T4.4,
/// FR-SPK-2). Segments only ever carry the internal label ("S1"…) — never
/// rewritten — so persisting the label→name mapping here is enough to cover
/// both past and future segments once names are resolved at render time
/// (FR-SPK-5). Works whether the meeting is still recording or already
/// finalized.
#[tauri::command]
pub async fn rename_speaker(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
label: String,
name: String,
) -> WaResult<()> {
// Resolve the name notes.md currently shows *before* the rename lands, so
// the finalized-meeting branch below can rewrite its dialogue tags.
let old_name = current_speaker_name(&state, &meeting_id, &label).await;
state
.store
.rename_speaker(&meeting_id, &label, &name)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let guard = state.session.lock().await;
match guard.as_ref().filter(|s| s.meeting_id == meeting_id) {
Some(session) => {
// Still recording: transcript.json/notes.md don't exist on disk
// yet, so there's nothing to re-render — just refresh the live
// in-memory view (finalize builds notes.md from this same map
// at stop, T4.3/4.4).
let names = {
let mut names = session
.speaker_names
.lock()
.unwrap_or_else(|e| e.into_inner());
names.insert(label, name);
names.clone()
};
let segments = session
.segments
.lock()
.map(|g| g.clone())
.unwrap_or_default();
drop(guard);
let speakers = speaker_infos_from_segments(&segments, &names);
let _ = app.emit(
"diarization://updated",
serde_json::json!({ "meetingId": meeting_id, "speakers": speakers }),
);
}
None => {
drop(guard);
if let Some(old_name) = old_name {
rename_speaker_in_notes(&state, &meeting_id, &old_name, &name).await?;
}
refresh_notes_and_notify(&app, &state, &meeting_id).await?;
}
}
Ok(())
}
/// Fold over-split speakers into one canonical label (T4.5, FR-SPK-3) — e.g.
/// diarization split one person into "S2" and "S3"; merging them shows one
/// name and one grouped paragraph in notes/export. Post-meeting only: while
/// still recording, the live provisional pass (T4.3) re-clusters from scratch
/// every tick, so a label merged now could mean something else by the next
/// tick.
#[tauri::command]
pub async fn merge_speakers(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
from: Vec<String>,
into: String,
) -> WaResult<()> {
let guard = state.session.lock().await;
if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) {
return Err(WaError::new(
"recording",
"cannot merge speakers while this meeting is still recording — wait until it's stopped",
));
}
drop(guard);
state
.store
.merge_speakers(&meeting_id, &from, &into)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
refresh_notes_and_notify(&app, &state, &meeting_id).await?;
Ok(())
}
/// Name a speaker AND link them to a known `Participant` (T6.5/T6.6,
/// FR-SPK-4) — e.g. picked from the linked calendar event's attendee
/// dropdown rather than typed free-text. The shared `participant_id` is what
/// gives naming continuity across meetings the same person attends. Post-
/// meeting only, same reasoning as `merge_speakers`: the live provisional
/// diarization pass re-clusters from scratch every tick, so a label mapped
/// now could mean someone else by the next tick.
#[tauri::command]
pub async fn map_speaker_to_participant(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
label: String,
participant_id: String,
) -> WaResult<()> {
let guard = state.session.lock().await;
if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) {
return Err(WaError::new(
"recording",
"cannot map a speaker to a participant while this meeting is still recording — wait until it's stopped",
));
}
drop(guard);
let old_name = current_speaker_name(&state, &meeting_id, &label).await;
state
.store
.map_speaker_to_participant(&meeting_id, &label, &participant_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let speakers = refresh_notes_and_notify(&app, &state, &meeting_id).await?;
// Rewrite notes.md's baked-in dialogue tags to the participant's name,
// same as a free-text rename (the Notes pane must follow the Speakers pane).
let new_name = speakers
.iter()
.find(|s| s.label == label)
.and_then(|s| s.display_name.clone());
if let (Some(old_name), Some(new_name)) = (old_name, new_name) {
rename_speaker_in_notes(&state, &meeting_id, &old_name, &new_name).await?;
}
Ok(())
}
// ---- App metadata (About page) ----
/// Version + build commit for the About page. Version comes from Cargo; the
/// short commit hash is baked in at build time by `build.rs` (`WA_GIT_HASH`).
#[tauri::command]
pub async fn app_info() -> WaResult<serde_json::Value> {
Ok(serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
"commit": env!("WA_GIT_HASH"),
}))
}
/// Opens an http(s) URL in the user's default browser (About page source link).
/// Windows-only (WA is Windows-native, ADR-0001). The scheme is validated so
/// this can't be coerced into launching a local path or program, and `explorer`
/// receives the URL as a single argv (no shell), so there's no injection surface.
#[tauri::command]
pub async fn open_url(url: String) -> WaResult<()> {
if !(url.starts_with("https://") || url.starts_with("http://")) {
return Err(WaError::new("app", "only http(s) URLs may be opened"));
}
#[cfg(windows)]
{
std::process::Command::new("explorer")
.arg(&url)
.spawn()
.map_err(|e| WaError::new("app", e.to_string()))?;
Ok(())
}
#[cfg(not(windows))]
{
let _ = url;
Err(WaError::new("app", "unsupported platform"))
}
}
// ---- Hardware + models (Phase 3) ----
#[tauri::command]
pub async fn hardware_status() -> WaResult<serde_json::Value> {
let settings = load_settings();
let backends = WinHardwareDetector.detect();
let active = backend_for(&settings);
let model_id = model_id_for(&settings);
Ok(serde_json::json!({
"backends": backends,
"active": active,
"modelSize": model_id,
// ponytail: no real-time factor measurement harness yet (needs a
// timed sample transcription); 1.0 stands in until T3.6 wires one up.
"estRtf": 1.0,
// NPU package state (T3.4 step 2) — drives the Settings ▸ Hardware
// download indicator: chip detected, its runtime staged, model fetched.
"npu": {
"present": crate::hardware::npu_hardware_present(),
"runtimeReady": crate::paths::npu_runtime_ready(),
"modelInstalled": npu_model_installed(),
},
// DirectML GPU package (P2): shares the ONNX model with the NPU path;
// only the runtime differs. `applicable` gates the Settings ▸ Hardware
// card so it only shows when DirectML would actually help this build.
"directml": {
"applicable": crate::hardware::directml_would_help(),
"runtimeReady": crate::paths::directml_runtime_ready(),
"modelInstalled": npu_model_installed(),
},
}))
}
/// Lists active render (playback) devices for the "Audio Devices" picker
/// (Settings ▸ Hardware) — loopback-only, matching the app's only capture
/// path (FR-CAP-1). Runs off the async runtime thread since device
/// enumeration is a blocking COM call.
#[tauri::command]
pub async fn list_audio_devices() -> WaResult<Vec<crate::audio::AudioDeviceInfo>> {
tauri::async_runtime::spawn_blocking(crate::audio::list_render_devices)
.await
.map_err(|e| WaError::new("audio", e.to_string()))?
.map_err(|e| WaError::new("audio", e.to_string()))
}
/// Enumerate capture (microphone) devices for the Settings "Microphone" picker
/// (FR-CAP-7). Blocking COM enumeration, so it runs off the async runtime thread.
#[tauri::command]
pub async fn list_input_devices() -> WaResult<Vec<crate::audio::AudioDeviceInfo>> {
tauri::async_runtime::spawn_blocking(crate::audio::list_capture_devices)
.await
.map_err(|e| WaError::new("audio", e.to_string()))?
.map_err(|e| WaError::new("audio", e.to_string()))
}
#[derive(Deserialize)]
pub struct SetPreferredBackendArgs {
pub backend: String, // "auto"|"npu"|"nvidia"|"amd"|"intel"|"cpu"
}
#[tauri::command]
pub async fn set_preferred_backend(args: SetPreferredBackendArgs) -> WaResult<()> {
let mut settings = load_settings();
settings.preferred_backend = args.backend;
save_settings(&settings)
}
/// Enable/disable launch-at-login (NFR-RES-4). Writes a per-user `HKCU\...\Run`
/// entry via `tauri-plugin-autostart` (no admin) and persists the choice so the
/// startup reconcile in `lib.rs` keeps the OS entry in sync after a reinstall.
/// Opt-in only: nothing calls this unless the user toggles it (or an enterprise
/// deploy file set `auto_start=true`).
#[tauri::command]
pub async fn set_auto_start(app: AppHandle, enabled: bool) -> WaResult<()> {
use tauri_plugin_autostart::ManagerExt;
let manager = app.autolaunch();
let res = if enabled {
manager.enable()
} else {
manager.disable()
};
res.map_err(|e| WaError::new("autostart", e.to_string()))?;
let mut settings = load_settings();
settings.auto_start = enabled;
save_settings(&settings)
}
/// Live level meter for a device, without starting a recording (FR-CAP-5): open
/// the selected mic (`"input"`) or the render device in loopback (`"loopback"`)
/// for a few seconds and stream `device://level` events so Settings ▸ Hardware
/// can show whether audio is coming through. Reuses the normal capture path;
/// discards frames (no transcription, no retained audio). One capture at a time,
/// so it refuses while a recording is active.
/// ponytail: transient monitor handle; ceiling = one monitor at a time.
#[tauri::command]
pub async fn monitor_audio_level(
app: AppHandle,
state: State<'_, AppState>,
kind: String,
device_id: Option<String>,
duration_ms: Option<u64>,
) -> WaResult<()> {
use crate::audio::{AudioCapture, CaptureEvent, WasapiCapture};
if state.session.lock().await.is_some() {
return Err(WaError::new(
"audio",
"stop the current recording before testing a device",
));
}
let duration =
std::time::Duration::from_millis(duration_ms.unwrap_or(6000).clamp(1000, 20_000));
// Bounded channels so a slow consumer can't stall capture; frames are dropped.
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(8);
let (event_tx, event_rx) = std::sync::mpsc::sync_channel::<CaptureEvent>(64);
std::thread::spawn(move || frame_rx.into_iter().for_each(drop));
let app_ev = app.clone();
let kind_ev = kind.clone();
std::thread::spawn(move || {
for event in event_rx {
if let CaptureEvent::Level(level) = event {
let _ = app_ev.emit(
"device://level",
serde_json::json!({ "kind": kind_ev, "rms": level.rms, "peak": level.peak }),
);
}
}
});
// `start` (loopback) needs a WAV path; write to a temp file and delete it after.
let tmp_wav = std::env::temp_dir().join(format!("wa-monitor-{}.wav", now_unix()));
let handle = match kind.as_str() {
"input" => WasapiCapture.start_microphone(device_id.as_deref(), frame_tx, event_tx),
"loopback" => WasapiCapture.start(&tmp_wav, device_id.as_deref(), frame_tx, event_tx),
_ => return Err(WaError::new("audio", "kind must be 'input' or 'loopback'")),
}
.map_err(|e| WaError::new("audio", e.to_string()))?;
tauri::async_runtime::spawn_blocking(move || {
std::thread::sleep(duration);
let _ = WasapiCapture.stop(handle);
})
.await
.map_err(|e| WaError::new("audio", e.to_string()))?;
if kind == "loopback" {
let _ = std::fs::remove_file(&tmp_wav);
}
let _ = app.emit(
"device://level",
serde_json::json!({ "kind": kind, "done": true }),
);
Ok(())
}
/// One row of the quick hardware stress test.
#[derive(Debug, Clone, serde::Serialize)]
pub struct StressResult {
pub backend: String,
pub model: String,
pub rtf: f64,
pub realtime: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct StressRecommendation {
pub backend: String,
pub model: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct StressTestResult {
pub results: Vec<StressResult>,
pub recommended: Option<StressRecommendation>,
}
/// Real-time recommendation: among rows that keep up with live speech
/// (`rtf < 1`), prefer the **largest** model (most accurate), breaking ties by
/// the **lowest** rtf (most headroom). `sizes` maps model id → size_mb (an
/// accuracy proxy). `None` when nothing runs in real time.
fn pick_realtime_recommendation(
results: &[StressResult],
sizes: &HashMap<String, u32>,
) -> Option<StressRecommendation> {
results
.iter()
.filter(|r| r.realtime)
.max_by(|a, b| {
let sa = sizes.get(&a.model).copied().unwrap_or(0);
let sb = sizes.get(&b.model).copied().unwrap_or(0);
sa.cmp(&sb).then(
b.rtf
.partial_cmp(&a.rtf)
.unwrap_or(std::cmp::Ordering::Equal),
)
})
.map(|r| StressRecommendation {
backend: r.backend.clone(),
model: r.model.clone(),
})
}
/// Quick hardware stress test (FR-HW): benchmark each available backend against
/// the installed whisper models on a fixed sample, measure real-time factor
/// (elapsed / audio seconds), and recommend the most accurate model that still
/// keeps up with live speech. Heavy (loads + runs each model) but explicit and
/// progress-reported; runs off the async thread.
/// ponytail: benchmarks installed models only, capped to 3 sizes.
#[tauri::command]
pub async fn stress_test_hardware(
app: AppHandle,
state: State<'_, AppState>,
) -> WaResult<StressTestResult> {
if state.session.lock().await.is_some() {
return Err(WaError::new(
"hardware",
"stop the current recording before running the stress test",
));
}
// Fixed ~10s 16kHz mono sample. RTF timing is ~content-independent, so a
// synthetic quiet tone is enough — no bundled speech clip needed.
const SAMPLE_SECS: f64 = 10.0;
let n = (16_000.0 * SAMPLE_SECS) as usize;
let samples: Vec<f32> = (0..n).map(|i| (i as f32 * 0.05).sin() * 0.1).collect();
let wav = std::env::temp_dir().join("wa-stress-sample.wav");
crate::audio::write_wav_mono_16k(&wav, &samples)
.map_err(|e| WaError::new("hardware", e.to_string()))?;
let backends: Vec<BackendId> = WinHardwareDetector
.detect()
.into_iter()
.filter(|b| b.available)
.map(|b| b.id)
.collect();
// Installed models, smallest → largest, capped to bound runtime.
let mut models: Vec<(String, u32)> = model_catalog::list("")
.into_iter()
.filter(|m| m.installed)
.map(|m| (m.id, m.size_mb))
.collect();
models.sort_by_key(|(_, sz)| *sz);
models.truncate(3);
let sizes: HashMap<String, u32> = models.iter().cloned().collect();
if backends.is_empty() || models.is_empty() {
let _ = std::fs::remove_file(&wav);
return Err(WaError::new(
"hardware",
"no installed whisper model to benchmark — download one in Settings first",
));
}
let wav_for_task = wav.clone();
let app_for_task = app.clone();
let results = tauri::async_runtime::spawn_blocking(move || {
let mut out: Vec<StressResult> = Vec::new();
for backend in &backends {
for (model_id, _size) in &models {
let path = whisper_model_file(model_id);
if !path.exists() {
continue;
}
let _ = app_for_task.emit(
"stress://progress",
serde_json::json!({ "backend": backend.as_str(), "model": model_id }),
);
let start = std::time::Instant::now();
let ok = match load_transcriber(*backend, &path, None) {
Ok((t, _used)) => t.transcribe_file(&wav_for_task).is_ok(),
Err(e) => {
tracing::warn!("stress test load failed ({backend:?}/{model_id}): {e}");
false
}
};
if !ok {
continue;
}
let rtf = start.elapsed().as_secs_f64() / SAMPLE_SECS;
out.push(StressResult {
backend: backend.as_str().to_string(),
model: model_id.clone(),
rtf,
realtime: rtf < 1.0,
});
}
}
out
})
.await
.map_err(|e| WaError::new("hardware", e.to_string()))?;
let _ = std::fs::remove_file(&wav);
let recommended = pick_realtime_recommendation(&results, &sizes);
Ok(StressTestResult {
results,
recommended,
})
}
/// True when the NPU ONNX Whisper model is downloaded (false on non-NPU builds).
fn npu_model_installed() -> bool {
#[cfg(feature = "npu")]
{
use crate::transcription::onnx_models;
onnx_models::is_installed(onnx_models::DEFAULT_ONNX_MODEL)
}
#[cfg(not(feature = "npu"))]
{
false
}
}
/// Downloads the Whisper ONNX model for the NPU engine, emitting `npu://download`
/// progress. Idempotent — skips artifacts already on disk. Also used by the
/// startup background fetch (see `lib.rs`).
#[cfg(feature = "npu")]
pub async fn download_npu_model(app: AppHandle) -> WaResult<()> {
use crate::transcription::onnx_models;
let app2 = app.clone();
onnx_models::download(
onnx_models::DEFAULT_ONNX_MODEL,
move |idx, received, total| {
let _ = app2.emit(
"npu://download",
serde_json::json!({ "stage": "model", "file": idx, "received": received, "total": total }),
);
},
)
.await
.map_err(|e| WaError::new("npu", e.to_string()))
}
/// Hosted OpenVINO runtime bundle (ORT 1.24.1 + OpenVINO 2025.4.1 DLLs, 7z).
/// Served from the repo's `runtime/` dir via gitea's `media` path (resolves the
/// Git LFS object, unlike `raw` which returns the pointer); overridable at
/// runtime via `WA_NPU_RUNTIME_URL`. Keep the SHA-256 in step with the file.
#[cfg(feature = "npu")]
const NPU_RUNTIME_URL: &str =
"https://git.dou.bet/iamdoubz/WhispAssist/media/branch/main/runtime/openvino.7z";
/// SHA-256 of the runtime bundle; empty string disables the integrity check.
#[cfg(feature = "npu")]
const NPU_RUNTIME_SHA256: &str = "ca0be9fc52c78ee623b152f790450b3d4020c5a7ebe99d27736455b308782191";
/// Hosted DirectML runtime bundle (ORT 1.24.1 DirectML-EP DLLs, 7z), same repo
/// `media` (LFS-resolving) path as the OpenVINO one; overridable via
/// `WA_DIRECTML_RUNTIME_URL`.
#[cfg(feature = "npu")]
const DIRECTML_RUNTIME_URL: &str =
"https://git.dou.bet/iamdoubz/WhispAssist/media/branch/main/runtime/directml.7z";
/// SHA-256 of the DirectML runtime bundle; empty string disables the check.
#[cfg(feature = "npu")]
const DIRECTML_RUNTIME_SHA256: &str =
"34369222fcc1be2e72a957b868b1976a90150ba704a06c9e8992c34ee368926b";
/// Stages the ONNX Runtime + OpenVINO DLLs into the app's NPU runtime dir by
/// downloading the hosted bundle and unzipping it (T3.4). `WA_NPU_RUNTIME_SRC`
/// (';'-separated dirs) is honored as a dev/offline override that copies local
/// DLLs instead of downloading.
#[cfg(feature = "npu")]
async fn stage_npu_runtime(app: &AppHandle) -> WaResult<()> {
if crate::paths::npu_runtime_ready() {
return Ok(());
}
let dir = crate::paths::npu_runtime_dir();
std::fs::create_dir_all(&dir).map_err(|e| WaError::new("npu", e.to_string()))?;
// Dev/offline override: copy DLLs from local dirs instead of downloading.
if let Some(src) = std::env::var_os("WA_NPU_RUNTIME_SRC") {
return stage_npu_runtime_from_local(app, &dir, &src);
}
let url = std::env::var("WA_NPU_RUNTIME_URL").unwrap_or_else(|_| NPU_RUNTIME_URL.to_string());
download_and_extract_runtime(
app,
&dir,
&url,
NPU_RUNTIME_SHA256,
crate::paths::npu_runtime_ready,
)
.await
}
/// Stages the DirectML ONNX Runtime into the app's DirectML runtime dir (P2) by
/// downloading the hosted 7z bundle and unpacking it. Overridable via
/// `WA_DIRECTML_RUNTIME_URL`; dev testing can also just set `ORT_DYLIB_PATH`.
#[cfg(feature = "npu")]
async fn stage_directml_runtime(app: &AppHandle) -> WaResult<()> {
if crate::paths::directml_runtime_ready() {
return Ok(());
}
let dir = crate::paths::directml_runtime_dir();
std::fs::create_dir_all(&dir).map_err(|e| WaError::new("directml", e.to_string()))?;
let url = std::env::var("WA_DIRECTML_RUNTIME_URL")
.unwrap_or_else(|_| DIRECTML_RUNTIME_URL.to_string());
download_and_extract_runtime(
app,
&dir,
&url,
DIRECTML_RUNTIME_SHA256,
crate::paths::directml_runtime_ready,
)
.await
}
#[cfg(feature = "npu")]
fn stage_npu_runtime_from_local(
app: &AppHandle,
dir: &Path,
src: &std::ffi::OsStr,
) -> WaResult<()> {
let mut copied = 0u32;
for d in std::env::split_paths(src) {
let Ok(entries) = std::fs::read_dir(&d) else {
continue;
};
for entry in entries.flatten() {
let p = entry.path();
if p.extension().and_then(|s| s.to_str()) == Some("dll") {
if let Some(name) = p.file_name() {
if std::fs::copy(&p, dir.join(name)).is_ok() {
copied += 1;
}
}
}
}
let _ = app.emit(
"npu://download",
serde_json::json!({ "stage": "runtime", "copied": copied }),
);
}
if !crate::paths::npu_runtime_ready() {
return Err(WaError::new(
"npu",
format!("staged {copied} dll(s) but onnxruntime.dll not among them"),
));
}
Ok(())
}
/// Downloads a runtime bundle (streaming progress + SHA-256 check) and unzips
/// its DLLs flat into `dir`. `sha256` empty disables the integrity check;
/// `ready` is the on-disk readiness predicate for the target runtime (NPU or
/// DirectML), so this one function serves both.
// ponytail: progress event + error domain stay "npu"/"npu://download" even on
// the DirectML path — cosmetic only, no frontend consumer for a directml
// channel yet. Split them out when the Settings UI grows a DirectML indicator.
#[cfg(feature = "npu")]
async fn download_and_extract_runtime(
app: &AppHandle,
dir: &Path,
url: &str,
sha256: &str,
ready: fn() -> bool,
) -> WaResult<()> {
use futures_util::StreamExt;
use sha2::{Digest, Sha256};
let resp = reqwest::get(url)
.await
.map_err(|e| WaError::new("npu", e.to_string()))?;
if !resp.status().is_success() {
return Err(WaError::new(
"npu",
format!("runtime download failed: HTTP {}", resp.status()),
));
}
let total = resp.content_length();
let tmp = dir.join("runtime.7z.part");
let mut file = std::fs::File::create(&tmp).map_err(|e| WaError::new("npu", e.to_string()))?;
let mut hasher = Sha256::new();
let mut received = 0u64;
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| WaError::new("npu", e.to_string()))?;
std::io::Write::write_all(&mut file, &chunk)
.map_err(|e| WaError::new("npu", e.to_string()))?;
hasher.update(&chunk);
received += chunk.len() as u64;
let _ = app.emit(
"npu://download",
serde_json::json!({ "stage": "runtime", "received": received, "total": total }),
);
}
drop(file);
let digest = format!("{:x}", hasher.finalize());
if !sha256.is_empty() && digest != sha256 {
let _ = std::fs::remove_file(&tmp);
return Err(WaError::new("npu", "runtime bundle checksum mismatch"));
}
// Un-7z off the async runtime (CPU/IO-bound).
let tmp_for_unzip = tmp.clone();
let dir_for_unzip = dir.to_path_buf();
tokio::task::spawn_blocking(move || extract_7z_flat(&tmp_for_unzip, &dir_for_unzip))
.await
.map_err(|e| WaError::new("npu", e.to_string()))?
.map_err(|e| WaError::new("npu", e))?;
let _ = std::fs::remove_file(&tmp);
if !ready() {
return Err(WaError::new(
"npu",
"runtime bundle extracted but onnxruntime.dll is missing",
));
}
Ok(())
}
/// Extract every file entry of a zip into `dir`, flattening paths to just the
/// file name (which also strips the archive's top folder — the bundles nest DLLs
/// under `directml/` or `openvino/` — and prevents path traversal).
#[cfg(feature = "npu")]
fn extract_7z_flat(archive_path: &Path, dir: &Path) -> Result<(), String> {
sevenz_rust2::decompress_file_with_extract_fn(archive_path, dir, |entry, reader, _dest| {
if entry.is_directory() {
return Ok(true);
}
// Flatten to just the file name, dropping any folder prefix.
let Some(name) = Path::new(entry.name()).file_name().and_then(|n| n.to_str()) else {
return Ok(true);
};
let mut out = std::fs::File::create(dir.join(name)).map_err(sevenz_rust2::Error::io)?;
std::io::copy(reader, &mut out).map_err(sevenz_rust2::Error::io)?;
Ok(true)
})
.map_err(|e| e.to_string())
}
/// Downloads everything the NPU engine needs (ONNX model + OpenVINO runtime) for
/// the Settings ▸ Hardware "download NPU package" action (T3.4 step 2).
#[tauri::command]
pub async fn download_npu_package(app: AppHandle) -> WaResult<()> {
#[cfg(feature = "npu")]
{
download_npu_model(app.clone()).await?;
stage_npu_runtime(&app).await?;
let _ = app.emit(
"npu://download",
serde_json::json!({ "stage": "done", "ready": crate::paths::npu_runtime_ready() }),
);
Ok(())
}
#[cfg(not(feature = "npu"))]
{
let _ = app;
Err(WaError::new("npu", "this build has no NPU support"))
}
}
/// Downloads everything the DirectML GPU engine needs — the same Whisper ONNX
/// model as the NPU path, plus the DirectML runtime (P2). The non-Vulkan GPU
/// path for AMD/Intel.
#[tauri::command]
pub async fn download_directml_package(app: AppHandle) -> WaResult<()> {
#[cfg(feature = "npu")]
{
download_npu_model(app.clone()).await?; // identical ONNX artifacts
stage_directml_runtime(&app).await?;
let _ = app.emit(
"directml://download",
serde_json::json!({ "stage": "done", "ready": crate::paths::directml_runtime_ready() }),
);
Ok(())
}
#[cfg(not(feature = "npu"))]
{
let _ = app;
Err(WaError::new(
"directml",
"this build has no ONNX/DirectML support",
))
}
}
#[tauri::command]
pub async fn list_models() -> WaResult<Vec<ModelInfo>> {
let settings = load_settings();
Ok(model_catalog::list(&model_id_for(&settings)))
}
/// The Settings language dropdown's contents (T8.7, FR-TRX-4) — every
/// whisper.cpp-recognized ISO-639-1 code. "Auto-detect" isn't in this list;
/// the frontend prepends it (maps to omitting `language` at recording start).
#[tauri::command]
pub async fn list_whisper_languages() -> WaResult<Vec<LanguageOption>> {
Ok(crate::transcription::languages::list())
}
/// The fixed segmentation+embedding pair (T4.7, FR-MODEL-1) — a separate
/// command rather than folding into `list_models` because they're a fixed
/// installable pair, not an interchangeable-size catalog like whisper's.
#[tauri::command]
pub async fn list_diarization_models() -> WaResult<Vec<ModelInfo>> {
Ok(crate::diarization::models::list())
}
#[derive(Deserialize)]
pub struct DownloadModelArgs {
pub kind: String, // "whisper" | "diar-seg" | "diar-emb"
pub id: String,
}
#[tauri::command]
pub async fn download_model(app: AppHandle, args: DownloadModelArgs) -> WaResult<()> {
let id = args.id;
let id_for_progress = id.clone();
let on_progress = move |received: u64, total: Option<u64>| {
let _ = app.emit(
"model://progress",
serde_json::json!({ "id": id_for_progress, "receivedBytes": received, "totalBytes": total }),
);
};
match args.kind.as_str() {
"whisper" => model_catalog::download(&id, on_progress)
.await
.map_err(|e| WaError::new("model", e.to_string())),
"diar-seg" | "diar-emb" => crate::diarization::models::download(&id, on_progress)
.await
.map_err(|e| WaError::new("model", e.to_string())),
other => Err(WaError::new(
"model",
format!("unknown model kind '{other}'"),
)),
}
}
#[tauri::command]
pub async fn remove_model(id: String) -> WaResult<()> {
// No `kind` in this command's contract — disambiguate by catalog
// membership instead; whisper/diarization ids never collide.
if crate::diarization::models::list()
.iter()
.any(|m| m.id == id)
{
return crate::diarization::models::remove(&id)
.map_err(|e| WaError::new("model", e.to_string()));
}
let settings = load_settings();
model_catalog::remove(&id, &model_id_for(&settings))
.map_err(|e| WaError::new("model", e.to_string()))
}
/// Batch re-transcribe a finished meeting with a different (typically larger)
/// model (T3.8, FR-TRX-3). Only works if the meeting's audio was retained.
///
/// Re-diarizes from scratch: the fresh transcription is re-clustered and, if a
/// `voiceprint.wav` was retained, the mic cluster is re-labeled "You" (FR-SPK).
/// The meeting's stored speaker names are **discarded** — they key to the
/// original run's labels, which no longer exist after re-clustering, so any
/// names the user typed on the first pass are intentionally lost here.
#[tauri::command]
pub async fn reprocess_transcript(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
model: String,
// T8.7/FR-TRX-4: `None` reuses whatever language the meeting was
// already recorded/reprocessed with, so re-transcribing with a bigger
// model doesn't silently drop a prior language selection.
language: Option<String>,
) -> WaResult<()> {
let model_path = whisper_model_file(&model);
if !model_path.exists() {
return Err(WaError::new(
"model",
format!("model '{model}' is not installed"),
));
}
let wav_path = meeting_dir(&meeting_id).join("audio.wav");
if !wav_path.exists() {
return Err(WaError::new(
"transcription",
"no retained audio.wav to reprocess — enable recording retention for this meeting",
));
}
let meeting = state
.store
.get_meeting(&meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let settings = load_settings();
let backend = backend_for(&settings);
let requested_language =
normalize_language(language.as_deref().or(meeting.language.as_deref()))
.map(|s| s.to_string());
let (mut segments, resolved_language) = tauri::async_runtime::spawn_blocking({
let wav_path = wav_path.clone();
move || {
let (transcriber, _used) =
load_transcriber(backend, &model_path, requested_language.as_deref())?;
let segments = transcriber.transcribe_file(&wav_path)?;
let resolved = transcriber
.detected_language()
.or_else(|| transcriber.effective_language());
Ok::<_, crate::transcription::TrxError>((segments, resolved))
}
})
.await
.map_err(|e| WaError::new("transcription", e.to_string()))?
.map_err(|e| WaError::new("transcription", e.to_string()))?;
// FR-SPK: re-diarize the fresh transcript. The meeting's stored labels key
// to the original clustering and are meaningless now — rebuild from the
// audio. Missing models degrade to the single "S1" placeholder, same as
// live/import.
let diarizer: Option<Arc<dyn Diarizer>> =
tauri::async_runtime::spawn_blocking(diarizer_from_installed_models)
.await
.ok()
.flatten()
.map(|d| Arc::new(d) as Arc<dyn Diarizer>);
// Rebuild the name map fresh (stale labels are discarded either way).
// Prefer the Phase 3 mic timeline when it was persisted (mic = "You",
// diarize the far side masked); otherwise fall back to the whole-signal pass
// + voiceprint match (imports, pre-Phase-3 recordings). No models → raw "S1".
let mut speaker_names = HashMap::new();
let is_split = meeting.audio_layout.as_deref() == Some("split");
match diarizer {
Some(diarizer) if is_split => {
// Split recording: diarize the far side (R channel), "You" from the
// mic (L) channel — recomputed from the file, matching stop's result.
if let Some(names) = attribute_split(diarizer, wav_path.clone(), &mut segments).await {
speaker_names = names;
}
}
Some(diarizer) => {
// Summed recording: whole-signal pass + voiceprint "You" match.
let mut spans: Option<Vec<SpeakerSpan>> = None;
let diarizer_for_task = diarizer.clone();
let wp = wav_path.clone();
match tauri::async_runtime::spawn_blocking(move || diarizer_for_task.diarize(&wp)).await
{
Ok(Ok(s)) => {
diarizer.assign(&mut segments, &s);
spans = Some(s);
}
Ok(Err(e)) => tracing::warn!("reprocess diarization pass failed: {e}"),
Err(e) => tracing::warn!("reprocess diarization task failed: {e}"),
}
let voiceprint_path = meeting_dir(&meeting_id).join("voiceprint.wav");
if let (Some(spans), true) = (&spans, voiceprint_path.exists()) {
match crate::audio::read_wav_mono_16k(&voiceprint_path) {
Ok(mic_samples) => match crate::diarization::voiceprint::match_mic_speaker(
&diarization_embedding_model_file(),
&mic_samples,
&wav_path,
spans,
) {
Ok(names) => speaker_names = names,
Err(e) => tracing::warn!("reprocess voiceprint match failed: {e}"),
},
Err(e) => tracing::warn!("failed to read voiceprint.wav: {e}"),
}
}
}
None => {} // no diarization models: raw "S1" labels
}
let speakers = speaker_infos_from_segments(&segments, &speaker_names);
// Drop the previous run's speaker rows before re-inserting the fresh set:
// the old labels key to the discarded clustering, so leaving them stranded
// in the DB makes the Participants pane show ghosts (e.g. 83 old labels
// when the new transcript has 5). finalize_meeting below re-upserts
// `speakers`. (FR-SPK)
state
.store
.clear_speakers(&meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let duration_secs = segments
.last()
.map(|s| (s.end_ms / 1000) as i64)
.unwrap_or(meeting.duration_secs.unwrap_or(0));
let template = meeting
.template_id
.as_deref()
.and_then(crate::notes::note_template_by_id);
state
.store
.finalize_meeting(
&meeting_id,
FinalizeMeeting {
segments: segments.clone(),
speakers: speakers.clone(),
duration_secs,
recorded: meeting.recorded,
language: resolved_language,
backend_used: Some(backend.as_str().to_string()),
model_used: Some(effective_model_id(backend, &model)),
audio_layout: None, // reprocess preserves the recorded layout
},
)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
// Bug fix: re-transcribing must not silently drop manual notes the user
// typed live during the original recording (see `load_manual_notes`).
let manual_notes = load_manual_notes(&meeting_id);
let notes_md = crate::notes::MarkdownNotes.merge(
&segments,
&speakers,
&manual_notes,
None,
template.as_ref(),
);
let _ = state.store.update_notes(&meeting_id, &notes_md).await;
spawn_auto_sync(&app, state.store.clone(), meeting_id.clone());
let _ = app.emit(
"transcript://finalized",
serde_json::json!({ "meetingId": meeting_id, "segmentCount": segments.len() }),
);
Ok(())
}
/// Filename stem of a local path, for a sensible default meeting title; `None`
/// for a URL (yt-dlp's real title isn't fetched — the user can rename).
fn default_title_from_source(source: &str) -> Option<String> {
if crate::media::is_url(source) {
return None;
}
std::path::Path::new(source)
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.filter(|s| !s.trim().is_empty())
}
/// Emit one `import://progress` tick for the Domino's-tracker UI. `state` is
/// `active` (this phase is running), `done` (finished; `elapsed_ms` set), or
/// `error` (`message` set). `phase` is one of prepare/transcribe/diarize/finalize.
fn emit_import_progress(
app: &AppHandle,
meeting_id: &str,
phase: &str,
state: &str,
elapsed_ms: Option<u128>,
message: Option<&str>,
) {
let _ = app.emit(
"import://progress",
serde_json::json!({
"meetingId": meeting_id,
"phase": phase,
"state": state,
"elapsedMs": elapsed_ms.map(|m| m as u64),
"error": message,
}),
);
}
/// Manually add a meeting from an existing recording: a local audio/video file
/// or a URL (YouTube/streaming page, or a direct media URL). Creates the meeting
/// row (status `transcribing`) and returns its id **immediately**; the heavy
/// transcode → transcribe → diarize → finalize work runs detached so the UI is
/// never blocked (a 25-min video can take 10+ min). Progress streams via
/// `import://progress` and completion via `transcript://finalized`. `model`
/// overrides the Settings whisper model for this one import (so the user picks
/// and can see what it was transcribed with); omit to use the Settings default.
#[tauri::command]
pub async fn import_media(
app: AppHandle,
state: State<'_, AppState>,
source: String,
title: Option<String>,
model: Option<String>,
) -> WaResult<MeetingId> {
let settings = load_settings();
let model_id = model
.filter(|m| !m.trim().is_empty())
.unwrap_or_else(|| model_id_for(&settings));
let backend = backend_for(&settings);
let model_path = whisper_model_file(&model_id);
if !model_path.exists() {
return Err(WaError::new(
"transcription",
format!(
"whisper model '{model_id}' not found at {}; download it from Settings first",
model_path.display()
),
));
}
let language: Option<String> =
normalize_language(settings.whisper_language.as_deref()).map(|s| s.to_string());
let title = title
.filter(|t| !t.trim().is_empty())
.or_else(|| default_title_from_source(&source))
.unwrap_or_else(|| "Imported meeting".to_string());
let meeting_id = state
.store
.create_meeting(NewMeeting {
title,
calendar_event_id: None,
template_id: None,
language: language.clone(),
})
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
// `create_meeting` starts rows as `recording`; mark this one `transcribing`
// so the meetings-list badge reads as an import in flight, not a live mic.
let _ = state
.store
.set_meeting_status(&meeting_id, "transcribing")
.await;
// Run the pipeline detached and return now — the dialog closes and the
// meeting appears in the list with a live tracker fed by the events below.
let store = state.store.clone();
let dir = meeting_dir(&meeting_id);
let wav_path = dir.join("audio.wav");
let mid = meeting_id.clone();
tauri::async_runtime::spawn(run_import_pipeline(
app, store, mid, source, wav_path, dir, backend, model_id, model_path, language,
));
Ok(meeting_id)
}
/// The detached body of `import_media`: transcode, transcribe, diarize, finalize,
/// emitting an `import://progress` tick at the start and end of each phase. On
/// the first failure it marks the meeting `error` (kept in the list, not
/// deleted, so the user sees the failed import) and stops.
#[allow(clippy::too_many_arguments)]
async fn run_import_pipeline(
app: AppHandle,
store: Arc<dyn crate::storage::Store>,
meeting_id: MeetingId,
source: String,
wav_path: std::path::PathBuf,
dir: std::path::PathBuf,
backend: BackendId,
model_id: String,
model_path: std::path::PathBuf,
language: Option<String>,
) {
use std::time::Instant;
// Phase 1: prepare — transcode (and, for URLs, yt-dlp download) to audio.wav.
emit_import_progress(&app, &meeting_id, "prepare", "active", None, None);
let t = Instant::now();
let transcode = tauri::async_runtime::spawn_blocking({
let source = source.clone();
let wav_path = wav_path.clone();
let dir = dir.clone();
move || {
let work = dir.join("import-tmp");
std::fs::create_dir_all(&work)?;
let r = crate::media::import_to_wav(&source, &wav_path, &work);
let _ = std::fs::remove_dir_all(&work);
r
}
})
.await;
match transcode {
Ok(Ok(())) => emit_import_progress(
&app,
&meeting_id,
"prepare",
"done",
Some(t.elapsed().as_millis()),
None,
),
Ok(Err(e)) => {
return fail_import(&app, &store, &meeting_id, "prepare", &e.to_string()).await
}
Err(e) => return fail_import(&app, &store, &meeting_id, "prepare", &e.to_string()).await,
}
// Phase 2: transcribe (same batch path as reprocess_transcript).
emit_import_progress(&app, &meeting_id, "transcribe", "active", None, None);
let t = Instant::now();
let transcribed = tauri::async_runtime::spawn_blocking({
let wav_path = wav_path.clone();
let model_path = model_path.clone();
let requested_language = language.clone();
move || {
let (transcriber, _used) =
load_transcriber(backend, &model_path, requested_language.as_deref())?;
let segments = transcriber.transcribe_file(&wav_path)?;
let resolved = transcriber
.detected_language()
.or_else(|| transcriber.effective_language());
Ok::<_, crate::transcription::TrxError>((segments, resolved))
}
})
.await;
let (mut segments, resolved_language) = match transcribed {
Ok(Ok(v)) => {
emit_import_progress(
&app,
&meeting_id,
"transcribe",
"done",
Some(t.elapsed().as_millis()),
None,
);
v
}
Ok(Err(e)) => {
return fail_import(&app, &store, &meeting_id, "transcribe", &e.to_string()).await
}
Err(e) => {
return fail_import(&app, &store, &meeting_id, "transcribe", &e.to_string()).await
}
};
// Phase 3: diarize — one pass if the models are installed, else every line
// stays the single "S1" placeholder (same as stop_recording).
emit_import_progress(&app, &meeting_id, "diarize", "active", None, None);
let t = Instant::now();
let diarizer: Option<Arc<dyn Diarizer>> =
tauri::async_runtime::spawn_blocking(diarizer_from_installed_models)
.await
.ok()
.flatten()
.map(|d| Arc::new(d) as Arc<dyn Diarizer>);
if let Some(diarizer) = diarizer {
let diarizer_for_task = diarizer.clone();
let wp = wav_path.clone();
match tauri::async_runtime::spawn_blocking(move || diarizer_for_task.diarize(&wp)).await {
Ok(Ok(spans)) => diarizer.assign(&mut segments, &spans),
Ok(Err(e)) => tracing::warn!("import diarization pass failed: {e}"),
Err(e) => tracing::warn!("import diarization task failed: {e}"),
}
}
emit_import_progress(
&app,
&meeting_id,
"diarize",
"done",
Some(t.elapsed().as_millis()),
None,
);
// Phase 4: finalize — persist segments, notes, seal audio.
emit_import_progress(&app, &meeting_id, "finalize", "active", None, None);
let t = Instant::now();
let speakers = speaker_infos_from_segments(&segments, &HashMap::new());
let duration_secs = segments
.last()
.map(|s| (s.end_ms / 1000) as i64)
.unwrap_or(0);
if let Err(e) = store
.finalize_meeting(
&meeting_id,
FinalizeMeeting {
segments: segments.clone(),
speakers: speakers.clone(),
duration_secs,
recorded: true, // the imported WAV is the recording — keep it
language: resolved_language,
backend_used: Some(backend.as_str().to_string()),
model_used: Some(effective_model_id(backend, &model_id)),
audio_layout: Some("summed".to_string()), // single-source import
},
)
.await
{
return fail_import(&app, &store, &meeting_id, "finalize", &e.to_string()).await;
}
let notes_md = crate::notes::MarkdownNotes.merge(
&segments,
&speakers,
&crate::models::ManualNotes::default(),
None,
None,
);
let _ = store.update_notes(&meeting_id, &notes_md).await;
// Seal the retained recording at rest when the vault is unlocked (T8.8),
// matching stop_recording so imports aren't left as plaintext outliers.
if crate::vault::is_unlocked() {
if let Ok(raw) = std::fs::read(&wav_path) {
if let Ok(sealed) = crate::vault::seal(&raw) {
let _ = std::fs::write(&wav_path, sealed);
}
}
}
emit_import_progress(
&app,
&meeting_id,
"finalize",
"done",
Some(t.elapsed().as_millis()),
None,
);
let _ = app.emit(
"transcript://finalized",
serde_json::json!({ "meetingId": meeting_id, "segmentCount": segments.len() }),
);
}
/// Mark a failed background import `error` (kept in the list) and emit the error
/// tick for the phase that failed.
async fn fail_import(
app: &AppHandle,
store: &Arc<dyn crate::storage::Store>,
meeting_id: &str,
phase: &str,
message: &str,
) {
let _ = store
.set_meeting_status(&meeting_id.to_string(), "error")
.await;
emit_import_progress(app, meeting_id, phase, "error", None, Some(message));
}
/// Re-run transcription from a `recovering` meeting's working `audio.wav`
/// (T2.8, FR-REL-1). CPU-bound, so it runs on a blocking task rather than
/// tying up an async worker.
#[tauri::command]
pub async fn resume_transcription(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
) -> WaResult<()> {
let settings = load_settings();
let model_id = model_id_for(&settings);
let model_path = whisper_model_file(&model_id);
if !model_path.exists() {
return Err(WaError::new(
"transcription",
format!(
"whisper model '{model_id}' not found at {}; download it from Settings first",
model_path.display()
),
));
}
let wav_path = meeting_dir(&meeting_id).join("audio.wav");
if !wav_path.exists() {
return Err(WaError::new(
"recovery",
"no working audio.wav to recover from",
));
}
// T8.7/FR-TRX-4: `create_meeting` persisted the originally requested
// language immediately (not just at finalize), so a crash-recovered
// meeting can still honor it here rather than silently reverting to
// auto-detect.
let requested_language = state
.store
.get_meeting(&meeting_id)
.await
.ok()
.and_then(|m| m.language);
// Note: deliberately doesn't emit `recording://state` — that event drives
// the main header's single live-session Record/Stop toggle, and reusing
// it here would make the header show a phantom "Stop" button for a
// recovery run that isn't a `RecordingSession` at all. The "recovering"
// badge in the meetings list is this operation's own progress signal.
let (segments, resolved_language) = tauri::async_runtime::spawn_blocking(move || {
let transcriber =
WhisperTranscriber::load(&model_path, BackendId::Cpu, requested_language.as_deref())?;
let segments = transcriber.transcribe_file(&wav_path)?;
let resolved = transcriber
.detected_language()
.or_else(|| transcriber.effective_language());
Ok::<_, crate::transcription::TrxError>((segments, resolved))
})
.await
.map_err(|e| WaError::new("transcription", e.to_string()))?
.map_err(|e| WaError::new("transcription", e.to_string()))?;
let speakers = vec![SpeakerInfo {
label: "S1".to_string(),
display_name: None,
participant_id: None,
}];
let duration_secs = segments
.last()
.map(|s| (s.end_ms / 1000) as i64)
.unwrap_or(0);
let template_id = state
.store
.finalize_meeting(
&meeting_id,
FinalizeMeeting {
segments: segments.clone(),
speakers: speakers.clone(),
duration_secs,
// The working WAV surviving a crash is the only signal we have
// left about intent; keep it rather than silently discard it.
recorded: true,
language: resolved_language,
backend_used: Some(BackendId::Cpu.as_str().to_string()),
model_used: Some(model_id),
audio_layout: None, // recovery preserves the recorded layout
},
)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let template = template_id
.as_deref()
.and_then(crate::notes::note_template_by_id);
// Crash recovery: no RecordingSession survives a crash, so read whatever
// manual notes were write-through persisted to disk before it happened.
let manual_notes = load_manual_notes(&meeting_id);
let notes_md = crate::notes::MarkdownNotes.merge(
&segments,
&speakers,
&manual_notes,
None,
template.as_ref(),
);
let _ = state.store.update_notes(&meeting_id, &notes_md).await;
let _ = app.emit(
"transcript://finalized",
serde_json::json!({ "meetingId": meeting_id, "segmentCount": segments.len() }),
);
Ok(())
}
// ---- Meetings / storage (Phase 2) ----
#[tauri::command]
pub async fn list_meetings(
state: State<'_, AppState>,
query: Option<String>,
tag: Option<String>,
participant_id: Option<String>,
from: Option<i64>,
to: Option<i64>,
) -> WaResult<Vec<MeetingListItem>> {
state
.store
.list_meetings(crate::storage::MeetingFilter {
query,
tag,
participant_id,
from,
to,
})
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
/// Replaces a meeting's complete tag set (Phase 8, FR-SEARCH-2).
#[tauri::command]
pub async fn set_tags(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
tags: Vec<String>,
) -> WaResult<()> {
state
.store
.set_tags(&meeting_id, &tags)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
spawn_auto_sync(&app, state.store.clone(), meeting_id);
Ok(())
}
/// All known tag names, sorted, for filter/autocomplete UI (Phase 8, FR-SEARCH-2).
#[tauri::command]
pub async fn list_tags(state: State<'_, AppState>) -> WaResult<Vec<String>> {
state
.store
.list_tags()
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
/// Full-text search across transcripts + notes (Phase 8, FR-SEARCH-1) —
/// distinct from `list_meetings`' `query`, which only substring-matches the
/// title.
#[tauri::command]
pub async fn search(state: State<'_, AppState>, query: String) -> WaResult<Vec<SearchHit>> {
state
.store
.search(&query)
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
/// Built-in note templates for a picker at recording-start time (Phase 8,
/// T8.1, FR-NOTE-5).
#[tauri::command]
pub async fn list_note_templates() -> WaResult<Vec<crate::notes::NoteTemplate>> {
Ok(crate::notes::built_in_note_templates())
}
#[tauri::command]
pub async fn get_meeting(state: State<'_, AppState>, meeting_id: MeetingId) -> WaResult<Meeting> {
state
.store
.get_meeting(&meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
#[tauri::command]
pub async fn delete_meeting(state: State<'_, AppState>, meeting_id: MeetingId) -> WaResult<()> {
let guard = state.session.lock().await;
if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) {
return Err(WaError::new(
"recording",
"cannot delete a meeting that is currently recording",
));
}
drop(guard);
state
.store
.delete_meeting(&meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
/// Return the `waaudio://` URL the in-app player loads for a meeting's recording
/// (FR-REC-5). The bytes are decrypted in memory on demand by `serve_recording`
/// — nothing plaintext is ever written to disk. Prechecks that a recording
/// exists and (if sealed) the vault is unlocked, so the UI can show a clear
/// error before playback; also clears any stale plaintext `audio.play.wav` left
/// by the previous file-based player.
#[tauri::command]
pub async fn recording_playback_path(meeting_id: MeetingId) -> WaResult<String> {
let id = meeting_id.clone();
tauri::async_runtime::spawn_blocking(move || {
let dir = meeting_dir(&id);
let wav = dir.join("audio.wav");
if !wav.exists() {
return Err(WaError::new(
"recording",
"no saved recording for this meeting",
));
}
// Cheap sealed check — read only the magic prefix, not the whole file.
let mut head = [0u8; 8];
let sealed = std::fs::File::open(&wav)
.and_then(|mut f| {
use std::io::Read;
let n = f.read(&mut head)?;
Ok(n)
})
.map(|n| crate::vault::is_sealed(&head[..n]))
.unwrap_or(false);
if sealed && !crate::vault::is_unlocked() {
return Err(WaError::new(
"recording",
"unlock the vault to play this recording",
));
}
// Drop any plaintext temp the old file-based player left behind.
let _ = std::fs::remove_file(dir.join("audio.play.wav"));
Ok(())
})
.await
.map_err(|e| WaError::new("recording", e.to_string()))??;
Ok(format!("http://waaudio.localhost/{meeting_id}"))
}
/// Custom-scheme handler backing the `waaudio://` URL: reads the meeting's
/// `audio.wav`, decrypts it in memory if sealed (T8.8), and streams the PCM to
/// the `<audio>` element with byte-range support for seeking. The plaintext
/// never touches the filesystem, so playback can't undermine encryption at rest.
/// Fold a WAV's channels to their per-frame average, written back across
/// `out_channels` identical output channels (FR-SPK). Playback uses
/// `out_channels = 1` so a split (mic-left/loopback-right) recording plays both
/// sources in both ears; bundle export uses `2` (dual-mono) so the shared file
/// looks like a normal stereo file. Returns `None` when there's nothing to do
/// (already mono → mono) or the WAV is unparseable (caller keeps the original).
/// ponytail: re-decodes the whole file each call; cache if it ever gets heavy.
fn fold_wav(wav: &[u8], out_channels: u16) -> Option<Vec<u8>> {
let mut reader = hound::WavReader::new(std::io::Cursor::new(wav)).ok()?;
let spec = reader.spec();
let ch = spec.channels as usize;
if ch as u16 == out_channels && ch <= 1 {
return None; // already mono and mono requested — nothing to do
}
let out_spec = hound::WavSpec {
channels: out_channels,
..spec
};
let mut out = std::io::Cursor::new(Vec::new());
{
let mut writer = hound::WavWriter::new(&mut out, out_spec).ok()?;
match (spec.sample_format, spec.bits_per_sample) {
(hound::SampleFormat::Int, 16) => {
let s: Vec<i16> = reader.samples::<i16>().map_while(Result::ok).collect();
for frame in s.chunks(ch) {
let avg =
(frame.iter().map(|&x| x as i32).sum::<i32>() / frame.len() as i32) as i16;
for _ in 0..out_channels {
writer.write_sample(avg).ok()?;
}
}
}
(hound::SampleFormat::Float, 32) => {
let s: Vec<f32> = reader.samples::<f32>().map_while(Result::ok).collect();
for frame in s.chunks(ch) {
let avg = frame.iter().sum::<f32>() / frame.len() as f32;
for _ in 0..out_channels {
writer.write_sample(avg).ok()?;
}
}
}
_ => return None,
}
writer.finalize().ok()?;
}
Some(out.into_inner())
}
pub(crate) fn serve_recording(
request: &tauri::http::Request<Vec<u8>>,
) -> tauri::http::Response<Vec<u8>> {
use tauri::http::{header, Response, StatusCode};
let fail = |code: StatusCode| {
Response::builder()
.status(code)
.body(Vec::new())
.expect("static error response")
};
// Guard the id against path traversal before joining it into a path.
let id = request.uri().path().trim_start_matches('/').to_string();
if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return fail(StatusCode::BAD_REQUEST);
}
let raw = match std::fs::read(meeting_dir(&id).join("audio.wav")) {
Ok(b) => b,
Err(_) => return fail(StatusCode::NOT_FOUND),
};
let plain = match crate::vault::open(&raw) {
Ok(p) => p,
Err(_) => return fail(StatusCode::FORBIDDEN), // sealed + vault locked
};
// Fold stereo → mono so a split (mic-left/loopback-right) recording plays
// both sources in both ears (FR-SPK); mono/unparseable passes through.
let plain = fold_wav(&plain, 1).unwrap_or(plain);
let total = plain.len();
let base = || {
Response::builder()
.header(header::CONTENT_TYPE, "audio/wav")
.header(header::ACCEPT_RANGES, "bytes")
};
// Honour a single `Range` request so the player can seek.
if let Some(range) = request
.headers()
.get(header::RANGE)
.and_then(|v| v.to_str().ok())
{
if let Some((start, end)) = parse_byte_range(range, total) {
return base()
.status(StatusCode::PARTIAL_CONTENT)
.header(
header::CONTENT_RANGE,
format!("bytes {start}-{end}/{total}"),
)
.header(header::CONTENT_LENGTH, (end - start + 1).to_string())
.body(plain[start..=end].to_vec())
.unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR));
}
}
base()
.status(StatusCode::OK)
.header(header::CONTENT_LENGTH, total.to_string())
.body(plain)
.unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR))
}
/// Parse a single `bytes=start-end` range against `total`, returning inclusive
/// clamped indices. Supports open-ended (`start-`) and suffix (`-n`) forms; only
/// the common single-range case is handled (enough for an `<audio>` element).
fn parse_byte_range(header: &str, total: usize) -> Option<(usize, usize)> {
if total == 0 {
return None;
}
let (s, e) = header.strip_prefix("bytes=")?.split_once('-')?;
if s.is_empty() {
let n: usize = e.parse().ok()?;
return Some((total.saturating_sub(n), total - 1));
}
let start: usize = s.parse().ok()?;
let end = if e.is_empty() {
total - 1
} else {
e.parse::<usize>().ok()?.min(total - 1)
};
(start <= end && start < total).then_some((start, end))
}
/// Remove any leftover plaintext `audio.play.wav` files from the previous
/// file-based player, so no decrypted audio lingers on disk (T8.8).
pub(crate) fn cleanup_playback_temp() {
let Ok(entries) = std::fs::read_dir(crate::paths::meetings_dir()) else {
return;
};
for entry in entries.flatten() {
let play = entry.path().join("audio.play.wav");
if play.exists() {
let _ = std::fs::remove_file(play);
}
}
}
#[tauri::command]
pub async fn update_notes(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
markdown: String,
) -> WaResult<()> {
state
.store
.update_notes(&meeting_id, &markdown)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
spawn_auto_sync(&app, state.store.clone(), meeting_id);
Ok(())
}
/// `format` ∈ `md | pdf | docx | bundle`. `dest` is a file path for
/// `md`/`pdf`/`docx`, a destination folder for `bundle` (the frontend gets
/// it from a native Save/choose-folder dialog).
#[tauri::command]
pub async fn export_meeting(
state: State<'_, AppState>,
meeting_id: MeetingId,
dest: String,
format: String,
) -> WaResult<String> {
let dest_path = PathBuf::from(&dest);
export_meeting_to(&state, &meeting_id, &dest_path, &format).await?;
Ok(dest)
}
/// Bulk export (Phase 8, T8.5, FR-STORE-4) — every meeting matching the
/// tag/date filter, one file (or bundle folder) per meeting under `dest_dir`.
/// Reuses the same filter shape as `list_meetings`; a meeting that fails to
/// export (e.g. a "recovering" meeting with no notes yet) is skipped rather
/// than aborting the whole batch. Returns the count actually exported.
#[tauri::command]
pub async fn bulk_export_meetings(
state: State<'_, AppState>,
dest_dir: String,
format: String,
tag: Option<String>,
from: Option<i64>,
to: Option<i64>,
) -> WaResult<u32> {
let items = state
.store
.list_meetings(crate::storage::MeetingFilter {
tag,
from,
to,
..Default::default()
})
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let dir = PathBuf::from(&dest_dir);
std::fs::create_dir_all(&dir).map_err(|e| WaError::new("export", e.to_string()))?;
let mut count = 0u32;
for item in items {
let stem = bulk_export_stem(&item);
let dest_path = if format == "bundle" {
dir.join(stem)
} else {
dir.join(format!("{stem}.{format}"))
};
match export_meeting_to(&state, &item.id, &dest_path, &format).await {
Ok(()) => count += 1,
Err(e) => tracing::warn!("bulk export skipped meeting {}: {}", item.id, e.message),
}
}
Ok(count)
}
/// Import meeting bundle(s) exported with `format: "bundle"` (FR-STORE-4) — the
/// other half of moving recordings between computers. `dir` is either a single
/// bundle folder (contains `meeting.json`) or a parent folder of bundles (from
/// a bulk export); every bundle found is reconstructed under a fresh meeting id
/// so re-importing never collides with existing meetings. Returns the count
/// imported.
#[tauri::command]
pub async fn import_meeting_bundle(state: State<'_, AppState>, dir: String) -> WaResult<u32> {
let root = PathBuf::from(&dir);
let mut bundles: Vec<PathBuf> = Vec::new();
if root.join("meeting.json").exists() {
bundles.push(root.clone());
} else {
let entries =
std::fs::read_dir(&root).map_err(|e| WaError::new("import", e.to_string()))?;
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() && p.join("meeting.json").exists() {
bundles.push(p);
}
}
}
if bundles.is_empty() {
return Err(WaError::new(
"import",
"no meeting.json found — pick a folder exported with \"Bundle\"",
));
}
bundles.sort();
let mut count = 0u32;
for b in &bundles {
match import_one_bundle(&state, b).await {
Ok(_) => count += 1,
Err(e) => tracing::warn!("bundle import skipped {}: {}", b.display(), e.message),
}
}
Ok(count)
}
/// Reconstruct one exported bundle folder into a new meeting. Order matters:
/// create → finalize (writes transcript.json/speakers/status) → restore dates →
/// copy audio/summary → notes → tags → action items.
async fn import_one_bundle(state: &State<'_, AppState>, dir: &Path) -> WaResult<MeetingId> {
let manifest = std::fs::read_to_string(dir.join("meeting.json"))
.map_err(|e| WaError::new("import", e.to_string()))?;
let bundle: crate::models::MeetingBundle = serde_json::from_str(&manifest)
.map_err(|e| WaError::new("import", format!("bad meeting.json: {e}")))?;
// Segments live in transcript.json (plaintext in an export); pull just the
// array rather than depending on storage's private TranscriptFile shape.
let segments: Vec<crate::models::TranscriptSegment> =
std::fs::read_to_string(dir.join("transcript.json"))
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| serde_json::from_value(v.get("segments")?.clone()).ok())
.unwrap_or_default();
let id = state
.store
.create_meeting(NewMeeting {
title: bundle.title.clone(),
calendar_event_id: None,
template_id: bundle.template_id.clone(),
language: bundle.language.clone(),
})
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
state
.store
.finalize_meeting(
&id,
FinalizeMeeting {
segments,
speakers: bundle.speakers.clone(),
duration_secs: bundle.duration_secs.unwrap_or(0),
recorded: bundle.recorded,
language: bundle.language.clone(),
backend_used: bundle.backend_used.clone(),
model_used: bundle.model_used.clone(),
audio_layout: bundle.audio_layout.clone(),
},
)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
state
.store
.set_meeting_times(&id, bundle.started_at, bundle.ended_at)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let dest_dir = meeting_dir(&id);
// Audio: copy byte-for-byte, mirroring the export's copy-as-is. ponytail:
// if the source machine sealed audio at rest under a different vault key,
// it won't decrypt here — cross-machine vault key transfer is out of scope;
// upgrade path is decrypting audio into the bundle on export.
let audio_src = dir.join("audio.wav");
if audio_src.exists() {
std::fs::copy(&audio_src, dest_dir.join("audio.wav"))
.map_err(|e| WaError::new("import", e.to_string()))?;
}
// notes.md
if let Ok(notes) = std::fs::read_to_string(dir.join("notes.md")) {
state
.store
.update_notes(&id, &notes)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
}
// summary.json — reseal at rest when the vault is unlocked (T8.8), same as
// generate_summary's write path.
let summary_src = dir.join("summary.json");
if summary_src.exists() {
if let Ok(raw) = std::fs::read(&summary_src) {
if let Ok(sealed) = crate::vault::seal(&raw) {
let _ = std::fs::write(dest_dir.join("summary.json"), sealed);
}
}
}
if !bundle.tags.is_empty() {
state
.store
.set_tags(&id, &bundle.tags)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
}
if !bundle.action_items.is_empty() {
// Drop source ids so each inserts fresh under the new meeting id.
let items: Vec<ActionItem> = bundle
.action_items
.iter()
.map(|i| ActionItem {
id: None,
..i.clone()
})
.collect();
state
.store
.save_action_items(&id, &items)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
}
Ok(id)
}
/// A filesystem-safe, collision-resistant filename stem: sanitized title +
/// an id prefix, since meeting titles are very often duplicates ("Untitled
/// meeting") and would otherwise silently overwrite each other in a batch.
fn bulk_export_stem(item: &MeetingListItem) -> String {
let safe_title: String = item
.title
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c
} else {
'_'
}
})
.collect();
let safe_title = safe_title.trim_matches('_');
let id_prefix = &item.id[..item.id.len().min(8)];
format!("{safe_title}_{id_prefix}")
}
async fn export_meeting_to(
state: &State<'_, AppState>,
meeting_id: &MeetingId,
dest_path: &Path,
format: &str,
) -> WaResult<()> {
let meeting = state
.store
.get_meeting(meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
match format {
"md" => {
crate::notes::MarkdownNotes
.export(
&meeting.notes_markdown,
dest_path,
crate::notes::ExportFormat::Md,
)
.map_err(|e| WaError::new("export", e.to_string()))?;
}
"pdf" => {
crate::notes::MarkdownNotes
.export(
&meeting.notes_markdown,
dest_path,
crate::notes::ExportFormat::Pdf,
)
.map_err(|e| WaError::new("export", e.to_string()))?;
}
"docx" => {
crate::notes::MarkdownNotes
.export(
&meeting.notes_markdown,
dest_path,
crate::notes::ExportFormat::Docx,
)
.map_err(|e| WaError::new("export", e.to_string()))?;
}
"bundle" => {
std::fs::create_dir_all(dest_path)
.map_err(|e| WaError::new("export", e.to_string()))?;
let source_dir = meeting_dir(meeting_id);
// Audio: decrypt (T8.8) and fold to dual-mono so the shared file is a
// normal stereo file, not a mic-left/loopback-right split (FR-SPK).
// The manifest records this exported copy as `audio_layout: summed`.
let audio_src = source_dir.join("audio.wav");
if audio_src.exists() {
let raw =
std::fs::read(&audio_src).map_err(|e| WaError::new("export", e.to_string()))?;
let plain =
crate::vault::open(&raw).map_err(|e| WaError::new("export", e.to_string()))?;
let out = fold_wav(&plain, 2).unwrap_or_else(|| plain.to_vec());
std::fs::write(dest_path.join("audio.wav"), out)
.map_err(|e| WaError::new("export", e.to_string()))?;
}
// transcript.json may be vault-sealed; export the decrypted content
// so the bundle is usable (T8.8).
let transcript_src = source_dir.join("transcript.json");
if transcript_src.exists() {
let bytes = std::fs::read(&transcript_src)
.map_err(|e| WaError::new("export", e.to_string()))?;
let plain =
crate::vault::open(&bytes).map_err(|e| WaError::new("vault", e.to_string()))?;
std::fs::write(dest_path.join("transcript.json"), plain)
.map_err(|e| WaError::new("export", e.to_string()))?;
}
std::fs::write(dest_path.join("notes.md"), &meeting.notes_markdown)
.map_err(|e| WaError::new("export", e.to_string()))?;
// summary.json may be vault-sealed; export the decrypted content so
// the bundle is portable (T8.8), same as transcript.json above.
let summary_src = source_dir.join("summary.json");
if summary_src.exists() {
let bytes = std::fs::read(&summary_src)
.map_err(|e| WaError::new("export", e.to_string()))?;
let plain =
crate::vault::open(&bytes).map_err(|e| WaError::new("vault", e.to_string()))?;
std::fs::write(dest_path.join("summary.json"), plain)
.map_err(|e| WaError::new("export", e.to_string()))?;
}
// meeting.json — the portable manifest that makes a bundle
// re-importable on another machine (FR-STORE-4). meeting.action_items
// is table-backed (Feature 3), so confirmed items travel too.
let bundle = crate::models::MeetingBundle {
schema: 1,
title: meeting.title.clone(),
started_at: meeting.started_at,
ended_at: meeting.ended_at,
duration_secs: meeting.duration_secs,
language: meeting.language.clone(),
backend_used: meeting.backend_used.clone(),
model_used: meeting.model_used.clone(),
recorded: meeting.recorded,
template_id: meeting.template_id.clone(),
tags: meeting.tags.clone(),
speakers: meeting.speakers.clone(),
action_items: meeting.action_items.clone(),
// Export downmixes a split recording to dual-mono below, so the
// shared copy is a normal "summed" file (FR-STORE-4).
audio_layout: Some("summed".to_string()),
};
let manifest = serde_json::to_string_pretty(&bundle)
.map_err(|e| WaError::new("export", e.to_string()))?;
std::fs::write(dest_path.join("meeting.json"), manifest)
.map_err(|e| WaError::new("export", e.to_string()))?;
}
"obsidian" => {
// One self-contained vault note — no audio (FR-STORE-4). Everything
// is already on `meeting`, so this is pure formatting.
std::fs::write(dest_path, build_obsidian_note(&meeting))
.map_err(|e| WaError::new("export", e.to_string()))?;
}
other => {
return Err(WaError::new(
"export",
format!("unsupported export format: {other}"),
))
}
}
Ok(())
}
/// Render a meeting as a single portable Obsidian note (FR-STORE-4): YAML
/// frontmatter + notes + summary + decisions + action items + a timestamped
/// transcript, deliberately without audio. All fields come off the
/// already-loaded `Meeting`, so this does no I/O of its own.
fn build_obsidian_note(meeting: &crate::storage::Meeting) -> String {
use std::fmt::Write as _;
// Resolve a segment/participant speaker label to its display name, mirroring
// the frontend's `speakerName`.
let name_of = |label: &str| -> String {
meeting
.speakers
.iter()
.find(|sp| sp.label == label)
.and_then(|sp| sp.display_name.clone())
.unwrap_or_else(|| label.to_string())
};
let mut md = String::new();
md.push_str("---\n");
let _ = writeln!(md, "title: {}", yaml_str(&meeting.title));
let _ = writeln!(md, "date: {}", fmt_date(meeting.started_at));
if let Some(secs) = meeting.duration_secs {
let _ = writeln!(md, "duration: {}", fmt_duration(secs));
}
let participants: Vec<String> = meeting.speakers.iter().map(|s| name_of(&s.label)).collect();
if !participants.is_empty() {
let joined = participants
.iter()
.map(|p| yaml_str(p))
.collect::<Vec<_>>()
.join(", ");
let _ = writeln!(md, "participants: [{joined}]");
}
if !meeting.tags.is_empty() {
let joined = meeting
.tags
.iter()
.map(|t| yaml_str(t))
.collect::<Vec<_>>()
.join(", ");
let _ = writeln!(md, "tags: [{joined}]");
}
md.push_str("source: WhispAssist\n---\n\n");
if !meeting.notes_markdown.trim().is_empty() {
let _ = write!(md, "## Notes\n\n{}\n\n", meeting.notes_markdown.trim());
}
if let Some(sum) = &meeting.summary {
if !sum.summary_md.trim().is_empty() {
let _ = write!(md, "## Summary\n\n{}\n\n", sum.summary_md.trim());
}
if !sum.decisions.is_empty() {
md.push_str("## Decisions\n\n");
for d in &sum.decisions {
let _ = writeln!(md, "- {d}");
}
md.push('\n');
}
}
if !meeting.action_items.is_empty() {
md.push_str("## Action items\n\n");
for a in &meeting.action_items {
let check = if a.confirmed { "x" } else { " " };
let owner = a
.owner
.as_deref()
.map(|o| format!(" — {o}"))
.unwrap_or_default();
let due = a
.due_at
.map(|d| format!(" (due {})", fmt_date(d)))
.unwrap_or_default();
let _ = writeln!(md, "- [{check}] {}{owner}{due}", a.text);
}
md.push('\n');
}
if !meeting.segments.is_empty() {
md.push_str("## Transcript\n\n");
for seg in &meeting.segments {
let _ = writeln!(
md,
"**{}** {}: {}",
fmt_ts(seg.start_ms),
name_of(&seg.speaker),
seg.text.trim()
);
}
md.push('\n');
}
md
}
/// Quote a value for a YAML frontmatter scalar — always double-quoted and
/// escaped, so titles/tags containing `:`/`"`/`#` can't break the block.
fn yaml_str(s: &str) -> String {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
/// Unix seconds → `YYYY-MM-DD` in local time (matches how the UI shows dates).
fn fmt_date(unix: i64) -> String {
use chrono::TimeZone as _;
chrono::Local
.timestamp_opt(unix, 0)
.single()
.map(|dt| dt.format("%Y-%m-%d").to_string())
.unwrap_or_default()
}
/// Seconds → a compact `1h 5m` / `32m` / `48s` duration.
fn fmt_duration(secs: i64) -> String {
let secs = secs.max(0);
let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60);
if h > 0 {
format!("{h}h {m}m")
} else if m > 0 {
format!("{m}m")
} else {
format!("{s}s")
}
}
/// Milliseconds → `m:ss` (or `h:mm:ss` past an hour), matching the transcript
/// timestamp prefix shown in the UI.
fn fmt_ts(ms: u64) -> String {
let total = ms / 1000;
let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60);
if h > 0 {
format!("{h}:{m:02}:{s:02}")
} else {
format!("{m}:{s:02}")
}
}
// ---- LLM (Phase 5) ----
/// Builds the configured `LlmProvider`, or `None` if LLM integration is off
/// (default — the zero-egress state, FR-LLM-1).
fn llm_provider_from_settings(settings: &Settings) -> Option<Box<dyn crate::llm::LlmProvider>> {
match settings.llm_provider.as_str() {
"ollama" => Some(Box::new(crate::llm::OllamaProvider {
endpoint: settings.llm_endpoint.clone(),
model: settings.llm_model.clone(),
advanced: settings.llm_advanced.clone(),
})),
"custom" => Some(Box::new(crate::llm::OpenAiCompatProvider {
endpoint: settings.llm_endpoint.clone(),
model: settings.llm_model.clone(),
credential_ref: None, // local, unauthenticated custom endpoint (ADR-0007); Phase 10a sets this for hosted gateways
})),
// Hosted (ADR-0011, M3.1/M3.2): the key is never read from settings —
// only `set_llm_provider` writes it, straight to the OS credential
// store via `ANTHROPIC_CREDENTIAL_REF`. Selecting "anthropic" here is
// itself the "configured" signal that puts api.anthropic.com on the
// egress allowlist (see `privacy_self_check_json` below, which reuses
// this same `is_local()` check unconditionally for every provider —
// no anthropic-specific branch needed there).
"anthropic" => Some(Box::new(crate::llm::AnthropicProvider {
model: settings.llm_model.clone(),
credential_ref: crate::llm::ANTHROPIC_CREDENTIAL_REF.to_string(),
endpoint: settings.llm_endpoint.clone(),
})),
_ => None, // "off"
}
}
#[tauri::command]
pub async fn llm_status() -> WaResult<serde_json::Value> {
let settings = load_settings();
let Some(provider) = llm_provider_from_settings(&settings) else {
return Ok(serde_json::json!({
"provider": "off", "reachable": false, "isLocal": true, "models": Vec::<String>::new(),
}));
};
let status = provider.status().await;
Ok(serde_json::json!({
"provider": status.provider,
"reachable": status.reachable,
"isLocal": status.is_local,
"models": status.models,
}))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetLlmProviderArgs {
pub provider: String, // "ollama" | "custom" | "anthropic" | "off" ("openai" not yet wired — M3 scope is Anthropic)
pub endpoint: Option<String>,
pub model: Option<String>,
/// Hosted providers only (Anthropic, M3.2). Consumed here to write the
/// OS credential store and then dropped — never assigned into `Settings`,
/// so it structurally cannot reach settings.json/wa.db (ADR-0011,
/// FR-SEC-1). Optional on every call so re-saving the model/endpoint
/// doesn't force re-entering a key that's already stored.
pub api_key: Option<String>,
}
/// Pure settings mutation for `set_llm_provider` — everything except the
/// credential-store write, so it's unit-testable without touching a real
/// keyring and so the "the API key never reaches `Settings`" guarantee is
/// checkable structurally (this function's signature has no key parameter
/// at all; see `set_llm_provider_never_writes_the_api_key_into_settings`).
/// Anthropic's endpoint is fixed, not user-configurable (ADR-0011) — any
/// `endpoint` argument is ignored for that provider so a stale endpoint left
/// over from a previous "custom"/"ollama" selection can't leak through.
/// Symmetrically, switching *away* from Anthropic without supplying a new
/// endpoint resets to Ollama's own local default rather than silently
/// keeping its fixed hosted URL around — otherwise a quick per-use provider
/// switch (SummaryPanel, M3.3) that only sends `provider` could leave
/// `llm_endpoint` pointed at a third party for a provider that has no
/// business talking to it.
fn apply_llm_provider_args(
settings: &mut Settings,
provider: &str,
endpoint: Option<String>,
model: Option<String>,
) {
settings.llm_provider = provider.to_string();
if provider == "anthropic" {
settings.llm_endpoint = "https://api.anthropic.com".to_string();
} else if let Some(endpoint) = endpoint {
settings.llm_endpoint = endpoint;
} else if settings.llm_endpoint == "https://api.anthropic.com" {
settings.llm_endpoint = "http://localhost:11434".to_string();
}
if let Some(model) = model {
settings.llm_model = model;
}
}
/// Select/configure the LLM provider (T5.2/T10.2, FR-LLM-1, ADR-0011).
/// Hosted providers (`apiKey`) are stored only in the OS credential store —
/// never settings/DB (FR-SEC-1); selecting "anthropic" here is itself what
/// puts `api.anthropic.com` on the settings-derived egress allowlist (see
/// `privacy_self_check_json`), so it must never happen implicitly.
#[tauri::command]
pub async fn set_llm_provider(args: SetLlmProviderArgs) -> WaResult<()> {
if !matches!(
args.provider.as_str(),
"ollama" | "custom" | "anthropic" | "off"
) {
return Err(WaError::new(
"llm",
format!("provider '{}' is not available yet", args.provider),
));
}
if args.provider == "anthropic" {
match args.api_key.as_deref().map(str::trim) {
Some(key) if !key.is_empty() => {
crate::llm::credentials::set(crate::llm::ANTHROPIC_CREDENTIAL_REF, key)
.map_err(|e| WaError::new("llm", e.to_string()))?;
}
// No new key supplied — fine only if one was already stored from
// a previous call (e.g. the user is just changing the model).
_ if crate::llm::credentials::get(crate::llm::ANTHROPIC_CREDENTIAL_REF).is_err() => {
return Err(WaError::new(
"llm",
"an Anthropic API key is required".to_string(),
));
}
_ => {}
}
}
let mut settings = load_settings();
apply_llm_provider_args(&mut settings, &args.provider, args.endpoint, args.model);
save_settings(&settings)
}
/// Detect a missing/unreachable provider and offer a hardware-aware model
/// suggestion (T5.7, FR-LLM-5). Read-only and on-demand — no background
/// polling (NFR-RES-1); the frontend calls this when Settings' LLM section
/// is open or `llm_status` comes back unreachable.
#[tauri::command]
pub async fn llm_setup_suggestions() -> WaResult<serde_json::Value> {
let best_backend = WinHardwareDetector.best(None);
let suggestion = crate::llm::suggest_ollama_model(best_backend.vram_mb);
Ok(serde_json::json!({
"ollamaInstalled": crate::llm::ollama_installed(),
"installUrl": "https://ollama.com/download/windows",
"suggestedModel": {
"id": suggestion.id,
"label": suggestion.label,
"approxSizeGb": suggestion.approx_size_gb,
},
}))
}
/// Guided model download via Ollama's `/api/pull` (T5.7, ADR-0007); streams
/// progress as `model://progress`, the same shape the whisper/diarization
/// catalogs use.
#[tauri::command]
pub async fn pull_ollama_model(app: AppHandle, model: String) -> WaResult<()> {
let endpoint = load_settings().llm_endpoint;
let model_for_progress = model.clone();
crate::llm::pull_model(&endpoint, &model, move |received, total| {
let _ = app.emit(
"model://progress",
serde_json::json!({ "id": model_for_progress, "receivedBytes": received, "totalBytes": total }),
);
})
.await
.map_err(|e| WaError::new("llm", e.to_string()))
}
/// Built-in LLM-prompt biases for common meeting shapes (T5.4) — distinct
/// from `notes::NoteTemplate` (Phase 8, T8.1/FR-NOTE-5), which structures
/// notes.md itself. Both use the same meeting-type identifiers
/// ("standup"/"retro"/"one-on-one") since they describe the same kinds of
/// meetings, but are two independent lookups. An unrecognized id is ignored
/// rather than erroring, so a stale/removed template id never blocks a summary.
fn summary_prompt_bias(id: &str) -> Option<&'static str> {
match id {
"standup" => Some(
"This is a daily standup. Focus the summary on what each person did, what's \
blocking them, and what's planned next.",
),
"retro" => Some(
"This is a retrospective. Focus the summary on what went well, what didn't, and \
concrete process changes to try.",
),
"one-on-one" => Some(
"This is a 1:1. Focus the summary on the individual's updates, concerns, and \
agreed next steps.",
),
_ => None,
}
}
/// Assembles the transcript + metadata (+ optional template) prompt (T5.4,
/// FR-LLM-2). Reuses `notes_markdown` (already speaker-tagged, kept in sync
/// by every rename/merge) as the transcript rather than re-rendering it.
fn build_prompt(meeting: &Meeting, template_id: Option<&str>) -> crate::llm::Prompt {
let participants: Vec<String> = meeting
.speakers
.iter()
.map(|s| s.display_name.clone().unwrap_or_else(|| s.label.clone()))
.collect();
let metadata = format!(
"Meeting: {}\nDuration: {} min\nParticipants: {}",
meeting.title,
meeting.duration_secs.unwrap_or(0) / 60,
if participants.is_empty() {
"unknown".to_string()
} else {
participants.join(", ")
},
);
crate::llm::Prompt {
transcript: meeting.notes_markdown.clone(),
metadata,
template: template_id
.and_then(summary_prompt_bias)
.map(str::to_string),
}
}
/// Generate a summary/decisions/action items for a finished meeting
/// (T5.4-5.6, FR-LLM-2/3/4). Streams tokens live via `llm://token`, persists
/// `summary.json`, and emits `llm://done` with the full result. Drafted
/// action items are NOT written to the `action_items` table here — the user
/// reviews/edits them first; `confirm_action_items` is what actually creates
/// rows (FR-LLM-3).
#[tauri::command]
pub async fn generate_summary(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
template_id: Option<String>,
) -> WaResult<()> {
let guard = state.session.lock().await;
if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) {
return Err(WaError::new(
"llm",
"cannot generate a summary while this meeting is still recording — wait until it's stopped",
));
}
drop(guard);
let settings = load_settings();
let provider = llm_provider_from_settings(&settings).ok_or_else(|| {
WaError::new(
"llm",
"no LLM provider is configured — enable one in Settings first",
)
})?;
let meeting = state
.store
.get_meeting(&meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let prompt = build_prompt(&meeting, template_id.as_deref());
// `LlmProvider::summarize` streams tokens over a std (blocking) mpsc
// channel; drain it on its own thread so tokens reach the frontend as
// they arrive rather than batched after the whole reply completes.
let (tx, rx) = std::sync::mpsc::channel::<String>();
let app_for_tokens = app.clone();
let meeting_id_for_tokens = meeting_id.clone();
let forward = std::thread::spawn(move || {
while let Ok(text) = rx.recv() {
let _ = app_for_tokens.emit(
"llm://token",
serde_json::json!({ "meetingId": meeting_id_for_tokens, "text": text }),
);
}
});
let result = provider.summarize(prompt, tx).await;
let _ = forward.join(); // rx errs (and the thread exits) once summarize() drops its Sender
let summary = result.map_err(|e| WaError::new("llm", e.to_string()))?;
let summary_file = SummaryFile {
schema: 1,
generated_at: now_unix(),
provider: settings.llm_provider,
model: settings.llm_model,
summary_md: summary.summary_md,
decisions: summary.decisions,
action_items: summary.action_items,
};
let json = serde_json::to_string_pretty(&summary_file)
.map_err(|e| WaError::new("llm", e.to_string()))?;
// Seal at rest when the vault is unlocked (T8.8, FR-SEC-3); passthrough otherwise.
if let Ok(sealed) = crate::vault::seal(json.as_bytes()) {
let _ = std::fs::write(meeting_dir(&meeting_id).join("summary.json"), sealed);
}
// Summary lives only in summary.json (not a store write), so the search
// index needs an explicit nudge to pick it up (FR-SEARCH-1).
if let Err(e) = state.store.reindex_fts(&meeting_id).await {
tracing::warn!("failed to reindex search after summary generation: {e}");
}
spawn_auto_sync(&app, state.store.clone(), meeting_id.clone());
let _ = app.emit(
"llm://done",
serde_json::json!({ "meetingId": meeting_id, "summary": summary_file }),
);
Ok(())
}
/// Persist reviewed/edited action items as confirmed tasks (T5.6, FR-LLM-3),
/// then schedule or cancel each one's local reminder to match (Phase 8,
/// T8.6, FR-CAL-5).
#[tauri::command]
pub async fn confirm_action_items(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
items: Vec<ActionItem>,
) -> WaResult<()> {
// Cancel reminders for items the user deleted (present in the table but no
// longer in the incoming list) — save_action_items drops the rows, but the
// scheduled OS reminder is separate state (T8.6, FR-CAL-5).
let existing = state
.store
.list_action_items(&meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let keep: std::collections::HashSet<&str> =
items.iter().filter_map(|i| i.id.as_deref()).collect();
for old in &existing {
if let Some(id) = &old.id {
if !keep.contains(id.as_str()) {
crate::reminders::cancel(id);
}
}
}
let saved = state
.store
.save_action_items(&meeting_id, &items)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
for item in &saved {
let Some(id) = &item.id else { continue };
match (item.reminder_set, item.due_at) {
(true, Some(due_at)) => {
crate::reminders::schedule(id, &item.text, item.owner.as_deref(), due_at)
}
_ => crate::reminders::cancel(id),
}
}
spawn_auto_sync(&app, state.store.clone(), meeting_id);
Ok(())
}
/// Suggests 1-8 short topical tags from the transcript (T8.3, FR-SEARCH-2) —
/// same LLM path/prompt assembly as generate_summary, just a much shorter
/// non-streamed reply. Suggestions are NOT saved automatically; the caller
/// reviews/merges them and still calls set_tags to persist, same as a
/// manually typed tag.
#[tauri::command]
pub async fn generate_tags(
state: State<'_, AppState>,
meeting_id: MeetingId,
) -> WaResult<Vec<String>> {
let guard = state.session.lock().await;
if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) {
return Err(WaError::new(
"llm",
"cannot generate tags while this meeting is still recording — wait until it's stopped",
));
}
drop(guard);
let settings = load_settings();
let provider = llm_provider_from_settings(&settings).ok_or_else(|| {
WaError::new(
"llm",
"no LLM provider is configured — enable one in Settings first",
)
})?;
let meeting = state
.store
.get_meeting(&meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let prompt = build_prompt(&meeting, None);
let text = format!("{}\n\n{}", prompt.metadata, prompt.transcript);
provider
.suggest_tags(&text)
.await
.map_err(|e| WaError::new("llm", e.to_string()))
}
/// AI-enhance the user's rough notes into structured Markdown grounded in the
/// transcript (Granola-style). Reuses the configured `LlmProvider` (local by
/// default → no new egress). The caller passes the live editor buffer so we
/// enhance exactly what the user sees, not a possibly-stale saved copy; we
/// return the enhanced Markdown without persisting it — the UI decides to keep
/// or undo it. Refuses a still-recording meeting; errors clearly with no
/// provider configured. Invents nothing beyond the notes + transcript.
#[tauri::command]
pub async fn enhance_notes(
state: State<'_, AppState>,
meeting_id: MeetingId,
notes: String,
) -> WaResult<String> {
let guard = state.session.lock().await;
if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) {
return Err(WaError::new(
"llm",
"cannot enhance notes while this meeting is still recording — wait until it's stopped",
));
}
drop(guard);
let settings = load_settings();
let provider = llm_provider_from_settings(&settings).ok_or_else(|| {
WaError::new(
"llm",
"no LLM provider is configured — enable one in Settings first",
)
})?;
let meeting = state
.store
.get_meeting(&meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let prompt = build_prompt(&meeting, None);
let system = "You expand a user's rough meeting notes into clear, well-structured Markdown. \
Use ONLY facts stated in the transcript and the user's own notes — never invent details, names, \
numbers, or decisions. Preserve the user's intent and any structure they started. Output Markdown \
only, with no preamble or commentary.";
let notes = notes.trim();
let user = format!(
"My rough notes:\n{}\n\nTranscript:\n{}",
if notes.is_empty() {
"(none yet)"
} else {
notes
},
prompt.transcript
);
provider
.complete(system, &user)
.await
.map(|s| s.trim().to_string())
.map_err(|e| WaError::new("llm", e.to_string()))
}
// ---- Calendar / .pst (Phase 6) ----
/// Import events + attendees from a `.pst` (T6.1/T6.2, FR-CAL-1). The
/// `readpst` subprocess + file parsing is CPU/IO-bound and blocking, so it
/// runs off the async runtime thread. `readpst` doesn't expose granular
/// progress, so `pst://progress` is a start/done signal rather than
/// per-item — real per-item progress would mean reimplementing readpst's
/// internals, not worth it for a one-shot import.
///
/// Split from the `#[tauri::command]` wrapper so the startup auto-sync pass
/// (T6.2, `pst_auto_sync`) can call the same logic without a `State` extractor.
pub(crate) async fn import_pst_core(
app: &AppHandle,
store: &dyn crate::storage::Store,
path: String,
password: Option<String>,
range_days: Option<u32>,
) -> WaResult<u32> {
let from = range_days.map(|d| now_unix() - (d as i64) * 86_400);
let events = tauri::async_runtime::spawn_blocking(move || {
PstSource.import(CalImport {
path,
password,
from,
to: None,
})
})
.await
.map_err(|e| WaError::new("calendar", e.to_string()))?
.map_err(|e| WaError::new("calendar", e.to_string()))?;
let total = events.len() as u32;
let _ = app.emit(
"pst://progress",
serde_json::json!({ "processed": 0, "total": total }),
);
let imported = store
.import_calendar_events(events)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let _ = app.emit(
"pst://progress",
serde_json::json!({ "processed": imported, "total": total }),
);
Ok(imported)
}
#[tauri::command]
pub async fn import_pst(
app: AppHandle,
state: State<'_, AppState>,
path: String,
password: Option<String>,
range_days: Option<u32>,
) -> WaResult<u32> {
import_pst_core(&app, state.store.as_ref(), path, password, range_days).await
}
/// Browse imported calendar events (T6.3, FR-CAL-2).
#[tauri::command]
pub async fn list_calendar_events(
state: State<'_, AppState>,
from: Option<i64>,
to: Option<i64>,
) -> WaResult<Vec<CalendarEvent>> {
state
.store
.list_calendar_events(from, to)
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
/// A single event with its attendees (T6.3/T6.4, FR-CAL-1/3) — backs the
/// pre-meeting context panel and the speaker-naming attendee dropdown (T6.5).
#[tauri::command]
pub async fn get_calendar_event(
state: State<'_, AppState>,
event_id: String,
) -> WaResult<crate::storage::CalendarEventDetail> {
state
.store
.get_calendar_event(&event_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
/// Prune imported calendar events (bug fix: unbounded PST history could
/// grow to tens of thousands of rows). `older_than_days: None` deletes
/// every unlinked event ("Delete all"); `Some(n)` only those starting more
/// than `n` days ago. An event attached to a recorded meeting is always
/// kept regardless of the choice.
#[tauri::command]
pub async fn cleanup_calendar_events(
state: State<'_, AppState>,
older_than_days: Option<u32>,
) -> WaResult<crate::storage::CalendarCleanupResult> {
let cutoff = older_than_days.map(|d| now_unix() - (d as i64) * 86_400);
let (deleted, protected) = state
.store
.cleanup_calendar_events(cutoff)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
Ok(crate::storage::CalendarCleanupResult { deleted, protected })
}
/// Link a meeting (current or historical) to a calendar event (T6.3/T6.6,
/// FR-CAL-2/4).
#[tauri::command]
pub async fn attach_meeting_to_event(
state: State<'_, AppState>,
meeting_id: MeetingId,
event_id: String,
) -> WaResult<()> {
state
.store
.attach_meeting_to_event(&meeting_id, &event_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
// ---- Microsoft Graph calendar source (M4.4, T8.9, FR-CAL-6) ----
/// Begin OAuth 2.0 PKCE linking for the optional MS Graph calendar source.
/// Explicit consent per FR-CAL-6: the user both clicks "Connect" here and
/// approves Microsoft's own consent screen. On success, stores the token set
/// in the OS credential store and flips `graph_calendar_enabled`; emits
/// `calendar://linked`. Mirrors `begin_oauth_link`'s PKCE flow (`sync::oauth`)
/// but a calendar link isn't a `SyncTarget` — no row is created in
/// `sync_targets`, so it can never be picked up by the upload pump or listed
/// as a sync destination.
#[tauri::command]
pub async fn begin_graph_calendar_link(app: AppHandle) -> WaResult<serde_json::Value> {
use crate::sync::oauth;
let provider = oauth::provider_for("graph-calendar")
.ok_or_else(|| WaError::new("calendar", "unknown OAuth provider 'graph-calendar'"))?;
let client_id = provider
.client_id()
.map_err(|e| WaError::new("calendar", e.to_string()))?;
let pkce = oauth::pkce_pair();
let csrf_state = uuid::Uuid::new_v4().to_string();
let loopback =
oauth::LoopbackRedirect::bind().map_err(|e| WaError::new("calendar", e.to_string()))?;
let redirect_uri = loopback.redirect_uri.clone();
let auth_url = oauth::build_auth_url(
&provider,
&client_id,
&redirect_uri,
&pkce.challenge,
&csrf_state,
)
.map_err(|e| WaError::new("calendar", e.to_string()))?;
let verifier = pkce.verifier;
tauri::async_runtime::spawn(async move {
let fail = |msg: String| {
let _ = app.emit(
"calendar://linked",
serde_json::json!({ "ok": false, "error": msg }),
);
};
let code =
match tauri::async_runtime::spawn_blocking(move || loopback.wait_for_code(&csrf_state))
.await
{
Ok(Ok(code)) => code,
_ => return fail("authorization was cancelled or failed".into()),
};
let tokens = match oauth::exchange_code(
&provider,
&client_id,
&code,
&verifier,
&redirect_uri,
)
.await
{
Ok(t) => t,
Err(e) => return fail(e.to_string()),
};
let credential_ref = "wa-calendar-graph".to_string();
let Ok(json) = serde_json::to_string(&tokens) else {
return fail("could not serialize tokens".into());
};
if let Err(e) = crate::sync::credentials::set(&credential_ref, &json) {
return fail(e.to_string());
}
let mut settings = load_settings();
settings.graph_calendar_enabled = true;
settings.graph_calendar_credential_ref = Some(credential_ref);
if let Err(e) = save_settings(&settings) {
return fail(e.message);
}
let _ = app.emit("calendar://linked", serde_json::json!({ "ok": true }));
});
Ok(serde_json::json!({ "authUrl": auth_url }))
}
/// Import events from the linked MS Graph calendar for `[from, to]` (unix
/// seconds; defaults to roughly the last month through the next quarter —
/// see `GraphSource::import`). Split from the `#[tauri::command]` wrapper for
/// the same reason as `import_pst_core`. Persisted the same way as PST
/// events — dedup'd by `(source, raw_uid)` — so re-running this just catches
/// up on new/changed events.
pub(crate) async fn import_graph_calendar_core(
app: &AppHandle,
store: &dyn crate::storage::Store,
credential_ref: String,
from: Option<i64>,
to: Option<i64>,
) -> WaResult<u32> {
let events = tauri::async_runtime::spawn_blocking(move || {
crate::calendar::GraphSource { credential_ref }.import(CalImport {
path: String::new(), // unused by GraphSource
password: None,
from,
to,
})
})
.await
.map_err(|e| WaError::new("calendar", e.to_string()))?
.map_err(|e| WaError::new("calendar", e.to_string()))?;
let total = events.len() as u32;
let imported = store
.import_calendar_events(events)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let _ = app.emit(
"calendar://progress",
serde_json::json!({ "processed": imported, "total": total }),
);
Ok(imported)
}
#[tauri::command]
pub async fn import_graph_calendar(
app: AppHandle,
state: State<'_, AppState>,
from: Option<i64>,
to: Option<i64>,
) -> WaResult<u32> {
let settings = load_settings();
let credential_ref = settings
.graph_calendar_enabled
.then_some(settings.graph_calendar_credential_ref)
.flatten()
.ok_or_else(|| {
WaError::new(
"calendar",
"Microsoft calendar isn't connected — link it in Settings first",
)
})?;
import_graph_calendar_core(&app, state.store.as_ref(), credential_ref, from, to).await
}
/// Unlink the MS Graph calendar source: best-effort credential cleanup, then
/// clears the settings flags. Imported `calendar_events` rows (source "graph")
/// are left as historical data, same as PST events are never deleted on
/// disconnect.
#[tauri::command]
pub async fn disconnect_graph_calendar() -> WaResult<()> {
let mut settings = load_settings();
if let Some(cred) = settings.graph_calendar_credential_ref.take() {
let _ = crate::sync::credentials::delete(&cred);
}
settings.graph_calendar_enabled = false;
save_settings(&settings)
}
/// Manually rename a meeting — recordings default to "Untitled meeting" with
/// no prior way to change that from the UI.
#[tauri::command]
pub async fn rename_meeting(
state: State<'_, AppState>,
meeting_id: MeetingId,
title: String,
) -> WaResult<()> {
let title = title.trim();
if title.is_empty() {
return Err(WaError::new("storage", "title cannot be empty"));
}
state
.store
.rename_meeting(&meeting_id, title)
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
// ---- Sync / upload (Phase 9, ADR-0010) ----
/// Config payload for add/update (snake_case, matching the TS `SyncTargetConfig`
/// and the other serde structs). The `secret` (app password) is write-only — it
/// goes to the OS store, never back to the UI (FR-SYNC-6).
#[derive(Deserialize, Default)]
pub struct SyncTargetConfigInput {
pub id: Option<String>,
pub name: Option<String>,
pub kind: Option<String>, // webdav (default) | onedrive | dropbox | box
pub provider_hint: Option<String>,
pub base_url: Option<String>,
pub remote_base_path: Option<String>,
pub username: Option<String>,
pub secret: Option<String>,
pub enabled: Option<bool>,
pub upload_transcript: Option<bool>,
pub upload_notes: Option<bool>,
pub upload_summary: Option<bool>,
pub upload_recording: Option<bool>,
pub trigger_on_finalize: Option<bool>,
pub allow_plaintext_lan: Option<bool>,
pub encrypt_before_upload: Option<bool>,
}
#[tauri::command]
pub async fn list_sync_targets(state: State<'_, AppState>) -> WaResult<Vec<SyncTargetInfo>> {
// Never returns secrets (FR-SYNC-6) — row_to_info drops credential_ref.
let rows = state
.store
.list_sync_targets()
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
Ok(rows.iter().map(crate::sync::row_to_info).collect())
}
#[tauri::command]
pub async fn add_sync_target(
state: State<'_, AppState>,
config: SyncTargetConfigInput,
) -> WaResult<SyncTargetInfo> {
let id = uuid::Uuid::new_v4().to_string();
let credential_ref = format!("wa-sync-{id}");
// Secret goes to the OS credential store keyed by credential_ref, never the DB.
if let Some(secret) = config.secret.as_deref().filter(|s| !s.is_empty()) {
crate::sync::credentials::set(&credential_ref, secret)
.map_err(|e| WaError::new("sync", e.to_string()))?;
}
let row = SyncTargetRow {
id,
name: config.name.unwrap_or_else(|| "WebDAV target".to_string()),
kind: config.kind.unwrap_or_else(|| "webdav".to_string()),
provider_hint: config.provider_hint,
base_url: config.base_url,
remote_base_path: config
.remote_base_path
.unwrap_or_else(|| "/WhispAssist".to_string()),
username: config.username,
credential_ref,
enabled: config.enabled.unwrap_or(false),
upload_transcript: config.upload_transcript.unwrap_or(true),
upload_notes: config.upload_notes.unwrap_or(true),
upload_summary: config.upload_summary.unwrap_or(true),
upload_recording: config.upload_recording.unwrap_or(false),
trigger_on_finalize: config.trigger_on_finalize.unwrap_or(true),
allow_plaintext_lan: config.allow_plaintext_lan.unwrap_or(false),
encrypt_before_upload: config.encrypt_before_upload.unwrap_or(false),
created_at: now_unix(),
};
state
.store
.add_sync_target(row.clone())
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
Ok(crate::sync::row_to_info(&row))
}
#[tauri::command]
pub async fn update_sync_target(
state: State<'_, AppState>,
config: SyncTargetConfigInput,
) -> WaResult<SyncTargetInfo> {
let id = config
.id
.clone()
.ok_or_else(|| WaError::new("sync", "update requires a target id"))?;
let mut row = state
.store
.get_sync_target(&id)
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
// A provided secret rotates the credential; credential_ref is preserved.
if let Some(secret) = config.secret.as_deref().filter(|s| !s.is_empty()) {
crate::sync::credentials::set(&row.credential_ref, secret)
.map_err(|e| WaError::new("sync", e.to_string()))?;
}
if let Some(v) = config.name {
row.name = v;
}
if let Some(v) = config.provider_hint {
row.provider_hint = Some(v);
}
if let Some(v) = config.base_url {
row.base_url = Some(v);
}
if let Some(v) = config.remote_base_path {
row.remote_base_path = v;
}
if let Some(v) = config.username {
row.username = Some(v);
}
if let Some(v) = config.enabled {
row.enabled = v;
}
if let Some(v) = config.upload_transcript {
row.upload_transcript = v;
}
if let Some(v) = config.upload_notes {
row.upload_notes = v;
}
if let Some(v) = config.upload_summary {
row.upload_summary = v;
}
if let Some(v) = config.upload_recording {
row.upload_recording = v;
}
if let Some(v) = config.trigger_on_finalize {
row.trigger_on_finalize = v;
}
if let Some(v) = config.allow_plaintext_lan {
row.allow_plaintext_lan = v;
}
if let Some(v) = config.encrypt_before_upload {
row.encrypt_before_upload = v;
}
state
.store
.update_sync_target(row.clone())
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
Ok(crate::sync::row_to_info(&row))
}
#[tauri::command]
pub async fn remove_sync_target(state: State<'_, AppState>, id: String) -> WaResult<()> {
// Best-effort secret cleanup first, then the row (cascades its jobs).
if let Ok(row) = state.store.get_sync_target(&id).await {
let _ = crate::sync::credentials::delete(&row.credential_ref);
}
state
.store
.remove_sync_target(&id)
.await
.map_err(|e| WaError::new("sync", e.to_string()))
}
fn oauth_display_name(kind: &str) -> String {
match kind {
"onedrive" => "OneDrive",
"dropbox" => "Dropbox",
"box" => "Box",
other => other,
}
.to_string()
}
/// Begin OAuth 2.0 Authorization Code + PKCE linking for a secondary target
/// (loopback redirect). Returns the `authUrl` for the frontend to open; the flow
/// finishes in the background and emits `sync://linked` (T9.9, FR-SYNC-9).
#[tauri::command]
pub async fn begin_oauth_link(
app: AppHandle,
state: State<'_, AppState>,
kind: String,
) -> WaResult<serde_json::Value> {
use crate::sync::oauth;
let provider = oauth::provider_for(&kind)
.ok_or_else(|| WaError::new("sync", format!("unknown OAuth provider '{kind}'")))?;
// Requires an app registration; clear error until a client id is configured.
let client_id = provider
.client_id()
.map_err(|e| WaError::new("sync", e.to_string()))?;
let pkce = oauth::pkce_pair();
let csrf_state = uuid::Uuid::new_v4().to_string();
let loopback =
oauth::LoopbackRedirect::bind().map_err(|e| WaError::new("sync", e.to_string()))?;
let redirect_uri = loopback.redirect_uri.clone();
let auth_url = oauth::build_auth_url(
&provider,
&client_id,
&redirect_uri,
&pkce.challenge,
&csrf_state,
)
.map_err(|e| WaError::new("sync", e.to_string()))?;
// Finish the handshake off-thread: wait for the redirect, exchange the code,
// store tokens, and create the target. The frontend opens `authUrl` and
// listens for `sync://linked`.
let store = state.store.clone();
let app_bg = app.clone();
let verifier = pkce.verifier;
tauri::async_runtime::spawn(async move {
let fail = |msg: String| {
let _ = app_bg.emit(
"sync://linked",
serde_json::json!({ "ok": false, "kind": kind, "error": msg }),
);
};
// Blocking single-shot accept of the browser redirect.
let code =
match tauri::async_runtime::spawn_blocking(move || loopback.wait_for_code(&csrf_state))
.await
{
Ok(Ok(code)) => code,
_ => return fail("authorization was cancelled or failed".into()),
};
let tokens = match oauth::exchange_code(
&provider,
&client_id,
&code,
&verifier,
&redirect_uri,
)
.await
{
Ok(t) => t,
Err(e) => return fail(e.to_string()),
};
let id = uuid::Uuid::new_v4().to_string();
let credential_ref = format!("wa-sync-{id}");
let Ok(json) = serde_json::to_string(&tokens) else {
return fail("could not serialize tokens".into());
};
if let Err(e) = crate::sync::credentials::set(&credential_ref, &json) {
return fail(e.to_string());
}
let row = SyncTargetRow {
id,
name: oauth_display_name(&kind),
kind: kind.clone(),
provider_hint: None,
base_url: None,
remote_base_path: "/WhispAssist".to_string(),
username: None,
credential_ref,
enabled: true,
upload_transcript: true,
upload_notes: true,
upload_summary: true,
upload_recording: false,
trigger_on_finalize: true,
allow_plaintext_lan: false,
encrypt_before_upload: false,
created_at: now_unix(),
};
if let Err(e) = store.add_sync_target(row).await {
return fail(e.to_string());
}
let _ = app_bg.emit(
"sync://linked",
serde_json::json!({ "ok": true, "kind": kind.clone() }),
);
});
Ok(serde_json::json!({ "authUrl": auth_url }))
}
#[tauri::command]
pub async fn retry_sync_job(
app: AppHandle,
state: State<'_, AppState>,
job_id: String,
) -> WaResult<()> {
let mut job = state
.store
.get_sync_job(&job_id)
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
// Clear the backoff so the pump picks it up immediately.
job.status = "pending".to_string();
job.next_attempt_at = None;
job.updated_at = now_unix();
state
.store
.update_sync_job(job)
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
pump_sync(&app, state.store.as_ref()).await;
Ok(())
}
/// Provider-specific setup tips surfaced on a failed/attempted test (T9.4).
fn provider_setup_hints(hint: Option<&str>) -> Vec<&'static str> {
match hint {
Some("seafile") => {
vec!["Enable SeafDAV on the server; it's off by default and may need LOCK disabled."]
}
Some("synology") => {
vec!["Install & enable the WebDAV Server package; prefer the HTTPS port."]
}
Some("nextcloud") | Some("owncloud") => {
vec!["Use an app password (Settings → Security), not your account password."]
}
_ => vec![],
}
}
/// Reachability + auth test for a target — either an existing one (by `id`) or an
/// unsaved config carrying an inline `secret` (T9.4, FR-SYNC-4).
#[tauri::command]
pub async fn test_sync_target(
state: State<'_, AppState>,
config: SyncTargetConfigInput,
) -> WaResult<serde_json::Value> {
use crate::sync::SyncTarget;
let hints = provider_setup_hints(config.provider_hint.as_deref());
let (target, temp_ref): (Box<dyn SyncTarget>, Option<String>) =
if let Some(id) = config.id.clone() {
let row = state
.store
.get_sync_target(&id)
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
if row.kind == "webdav" {
// Editing an existing webdav target: test the values on screen but
// reuse the STORED password (via its credential_ref) unless the user
// typed a new one — so "Test" works after a URL fix without having to
// re-enter the password.
let (credential_ref, temp_ref) =
if let Some(secret) = config.secret.as_deref().filter(|s| !s.is_empty()) {
let temp_ref = format!("wa-sync-test-{}", uuid::Uuid::new_v4());
crate::sync::credentials::set(&temp_ref, secret)
.map_err(|e| WaError::new("sync", e.to_string()))?;
(temp_ref.clone(), Some(temp_ref))
} else {
(row.credential_ref.clone(), None)
};
let target = crate::sync::WebDavTarget {
base_url: config
.base_url
.clone()
.or(row.base_url.clone())
.unwrap_or_default(),
provider_hint: config.provider_hint.clone().or(row.provider_hint.clone()),
remote_base_path: config
.remote_base_path
.clone()
.unwrap_or(row.remote_base_path.clone()),
username: config
.username
.clone()
.or(row.username.clone())
.unwrap_or_default(),
credential_ref,
third_party: false,
allow_plaintext_lan: config
.allow_plaintext_lan
.unwrap_or(row.allow_plaintext_lan),
};
(Box::new(target), temp_ref)
} else {
// Non-webdav (OAuth) target — dispatch to its concrete impl as-is.
let target = crate::sync::build_sync_target(&row)
.map_err(|e| WaError::new("sync", e.to_string()))?;
(target, None)
}
} else {
// Unsaved WebDAV target: stash the secret under a temp credential_ref so
// the same resolve-at-use path works, then clean it up after the test.
let base_url = config
.base_url
.clone()
.ok_or_else(|| WaError::new("sync", "test requires a base_url"))?;
let temp_ref = format!("wa-sync-test-{}", uuid::Uuid::new_v4());
if let Some(secret) = config.secret.as_deref().filter(|s| !s.is_empty()) {
crate::sync::credentials::set(&temp_ref, secret)
.map_err(|e| WaError::new("sync", e.to_string()))?;
}
let target = crate::sync::WebDavTarget {
base_url,
provider_hint: config.provider_hint.clone(),
remote_base_path: config
.remote_base_path
.clone()
.unwrap_or_else(|| "/WhispAssist".to_string()),
username: config.username.clone().unwrap_or_default(),
credential_ref: temp_ref.clone(),
third_party: config.kind.as_deref().unwrap_or("webdav") != "webdav",
allow_plaintext_lan: config.allow_plaintext_lan.unwrap_or(false),
};
(Box::new(target), Some(temp_ref))
};
let result = target.test().await;
if let Some(temp_ref) = temp_ref {
let _ = crate::sync::credentials::delete(&temp_ref);
}
match result {
Ok(()) => Ok(serde_json::json!({ "ok": true, "message": "Connected", "hints": hints })),
Err(e) => Ok(serde_json::json!({ "ok": false, "message": e.to_string(), "hints": hints })),
}
}
#[tauri::command]
pub async fn set_sync_enabled(enabled: bool) -> WaResult<()> {
let mut settings = load_settings();
settings.sync_enabled = enabled;
save_settings(&settings)
}
// ---- At-rest encryption vault (Phase 8, T8.8, FR-SEC-3) ----
#[tauri::command]
pub async fn vault_status() -> WaResult<serde_json::Value> {
Ok(serde_json::json!({
"enabled": crate::vault::is_enabled(),
"unlocked": crate::vault::is_unlocked(),
}))
}
#[derive(Deserialize)]
pub struct VaultPasswordArgs {
pub password: String,
}
/// Turn on the vault: derive a key from the password and start sealing artifacts
/// at rest. Leaves the vault unlocked for the session.
#[tauri::command]
pub async fn enable_vault(args: VaultPasswordArgs) -> WaResult<()> {
crate::vault::enable(&args.password).map_err(|e| WaError::new("vault", e.to_string()))
}
#[tauri::command]
pub async fn unlock_vault(args: VaultPasswordArgs) -> WaResult<()> {
crate::vault::unlock(&args.password).map_err(|e| WaError::new("vault", e.to_string()))
}
#[tauri::command]
pub async fn lock_vault() -> WaResult<()> {
crate::vault::lock();
Ok(())
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChangeVaultPasswordArgs {
pub old_password: String,
pub new_password: String,
}
#[tauri::command]
pub async fn change_vault_password(args: ChangeVaultPasswordArgs) -> WaResult<()> {
crate::vault::change_password(&args.old_password, &args.new_password)
.map_err(|e| WaError::new("vault", e.to_string()))
}
// ---- Sync durable queue (T9.5) ----
/// Give up auto-retrying after this many failures; the job stays `failed` with
/// no `next_attempt_at` until the user hits "retry".
const SYNC_MAX_ATTEMPTS: i64 = 6;
/// Exponential backoff: 30s, 60s, 120s … capped at 1h.
fn sync_backoff_secs(attempts: i64) -> i64 {
(30_i64.saturating_mul(1_i64 << attempts.min(7))).min(3600)
}
fn file_sha256(path: &Path) -> Option<String> {
use sha2::{Digest, Sha256};
let bytes = std::fs::read(path).ok()?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
Some(format!("{:x}", hasher.finalize()))
}
/// Which (artifact, filename) pairs this target is configured to upload.
fn artifact_plan(target: &SyncTargetRow) -> Vec<(&'static str, &'static str)> {
let mut plan = Vec::new();
if target.upload_transcript {
plan.push(("transcript", "transcript.json"));
}
if target.upload_notes {
plan.push(("notes", "notes.md"));
}
if target.upload_summary {
plan.push(("summary", "summary.json"));
}
if target.upload_recording {
plan.push(("recording", "audio.wav"));
}
plan
}
fn job_to_info(row: &crate::storage::SyncJobRow) -> SyncJobInfo {
SyncJobInfo {
id: row.id.clone(),
target_id: row.target_id.clone(),
meeting_id: row.meeting_id.clone(),
artifact: row.artifact.clone(),
status: row.status.clone(),
attempts: row.attempts as u32,
bytes_sent: row.bytes_sent as u64,
bytes_total: row.bytes_total.map(|b| b as u64),
last_error: row.last_error.clone(),
}
}
fn emit_sync_job(app: &AppHandle, row: &crate::storage::SyncJobRow) {
// camelCase event shape per docs/04-api-contracts.md (`onSyncJob`).
let _ = app.emit(
"sync://job",
serde_json::json!({
"jobId": row.id,
"meetingId": row.meeting_id,
"targetId": row.target_id,
"artifact": row.artifact,
"status": row.status,
"bytesSent": row.bytes_sent,
"bytesTotal": row.bytes_total,
"attempts": row.attempts,
"error": row.last_error,
}),
);
}
/// Enqueue a meeting's configured artifacts for one or all enabled targets
/// (T9.5). `finalize_only` limits to targets with `trigger_on_finalize`. Files
/// that don't exist are skipped; unchanged files are deduped by SHA-256 in the
/// storage upsert. Returns the number of jobs (re)queued.
pub(crate) async fn enqueue_meeting_sync(
store: &dyn crate::storage::Store,
meeting_id: &MeetingId,
target_id: Option<&str>,
finalize_only: bool,
) -> Result<u32, WaError> {
let targets = store
.list_sync_targets()
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
let mut queued = 0u32;
for target in targets.iter().filter(|t| t.enabled) {
if target_id.is_some_and(|id| id != target.id) {
continue;
}
if finalize_only && !target.trigger_on_finalize {
continue;
}
let base = target.remote_base_path.trim_end_matches('/');
for (artifact, filename) in artifact_plan(target) {
let local = meeting_dir(meeting_id).join(filename);
if !local.exists() {
continue;
}
let bytes_total = std::fs::metadata(&local).ok().map(|m| m.len() as i64);
let job = crate::storage::SyncJobRow {
id: uuid::Uuid::new_v4().to_string(),
target_id: target.id.clone(),
meeting_id: meeting_id.clone(),
artifact: artifact.to_string(),
local_path: local.to_string_lossy().into_owned(),
remote_path: format!("{base}/{meeting_id}/{filename}"),
sha256: file_sha256(&local),
status: "pending".to_string(),
attempts: 0,
last_error: None,
next_attempt_at: None,
bytes_total,
bytes_sent: 0,
updated_at: now_unix(),
};
if store
.upsert_sync_job(job)
.await
.map_err(|e| WaError::new("sync", e.to_string()))?
{
queued += 1;
}
}
}
Ok(queued)
}
/// Re-sync a meeting's artifacts after an edit (notes / summary / transcript /
/// tags / action items), mirroring sync-on-finalize (T9.5, FR-SYNC-5). A no-op
/// unless sync is enabled; only finalize-trigger targets are touched and
/// unchanged files are deduped by SHA-256, so a save that didn't alter an
/// uploaded artifact queues (and uploads) nothing. Fire-and-forget so the edit
/// command returns immediately.
///
/// ponytail: fires on every (debounced) save; if WebDAV PUT volume from live
/// note-typing ever matters, coarsen the debounce or trigger on blur instead.
pub(crate) fn spawn_auto_sync(
app: &AppHandle,
store: std::sync::Arc<dyn crate::storage::Store>,
meeting_id: MeetingId,
) {
if !load_settings().sync_enabled {
return;
}
let app = app.clone();
tauri::async_runtime::spawn(async move {
match enqueue_meeting_sync(store.as_ref(), &meeting_id, None, true).await {
Ok(0) => {} // nothing changed
Ok(_) => pump_sync(&app, store.as_ref()).await, // upload the changes
Err(e) => tracing::warn!("auto-sync enqueue failed: {e:?}"),
}
});
}
/// Upload one job's file: ensure the remote dir, PUT, report bytes sent.
async fn upload_job(
target: &dyn crate::sync::SyncTarget,
row: &crate::storage::SyncJobRow,
encrypt: bool,
mut on_progress: impl FnMut(u64, u64) + Send + 'static,
) -> Result<u64, crate::sync::SyncError> {
use crate::sync::SyncError;
let remote_dir = row
.remote_path
.rsplit_once('/')
.map(|(dir, _)| dir)
.unwrap_or("");
target.ensure_dir(remote_dir).await?;
let (tx, rx) = std::sync::mpsc::channel();
// Forward live upload progress off the async task (the sink is a blocking
// std channel): throttled so a large recording emits a steady stream of
// `sync://job` updates without flooding the UI. Ends when `put` drops `tx`.
let fallback_total = row.bytes_total.unwrap_or(0) as u64;
let drain = std::thread::spawn(move || {
let mut last = 0u64;
let mut last_emit = std::time::Instant::now() - std::time::Duration::from_millis(500);
while let Ok((sent, total)) = rx.recv() {
last = sent;
if last_emit.elapsed() >= std::time::Duration::from_millis(250) {
on_progress(sent, total);
last_emit = std::time::Instant::now();
}
}
last
});
if encrypt {
// Client-side encryption before upload (T9.12, FR-SYNC-10): seal to a
// temp file (idempotent if already sealed at rest) so the destination
// only ever holds ciphertext. Requires the vault to be unlocked.
let bytes = tokio::fs::read(&row.local_path)
.await
.map_err(|e| SyncError::Upload(e.to_string()))?;
let sealed =
crate::vault::ensure_sealed(&bytes).map_err(|e| SyncError::Upload(e.to_string()))?;
let tmp = std::env::temp_dir().join(format!("wa-enc-{}.bin", row.id));
tokio::fs::write(&tmp, &sealed)
.await
.map_err(|e| SyncError::Upload(e.to_string()))?;
let result = target.put(&tmp, &row.remote_path, tx).await;
let _ = tokio::fs::remove_file(&tmp).await;
result?;
} else {
target
.put(Path::new(&row.local_path), &row.remote_path, tx)
.await?;
}
// `tx` is dropped now that `put` returned, so the drain thread finishes and
// hands back the final byte count it observed.
let sent = drain.join().unwrap_or(fallback_total);
Ok(if sent > 0 { sent } else { fallback_total })
}
/// Drive all due jobs once: upload each, emit `sync://job` on every transition,
/// apply exponential backoff on failure (T9.5). Called on finalize, on startup,
/// and on manual upload/retry — never on an idle timer (NFR-RES-1).
pub(crate) async fn pump_sync(app: &AppHandle, store: &dyn crate::storage::Store) {
let due = match store.claim_due_sync_jobs(now_unix()).await {
Ok(jobs) => jobs,
Err(e) => {
tracing::warn!("sync pump: claim failed: {e}");
return;
}
};
for mut job in due {
let target = match store.get_sync_target(&job.target_id).await {
Ok(t) => t,
Err(_) => continue, // target removed under us — its jobs cascaded away
};
job.status = "uploading".to_string();
job.updated_at = now_unix();
let _ = store.update_sync_job(job.clone()).await;
emit_sync_job(app, &job);
// Emit a live `sync://job` (status still "uploading") as bytes go out, so
// the UI shows a moving per-item progress bar (FR-SYNC-11).
let app_prog = app.clone();
let mut prog_row = job.clone();
let on_progress = move |sent: u64, _total: u64| {
prog_row.bytes_sent = sent as i64;
emit_sync_job(&app_prog, &prog_row);
};
let outcome = match crate::sync::build_sync_target(&target) {
Ok(t) => upload_job(t.as_ref(), &job, target.encrypt_before_upload, on_progress).await,
Err(e) => Err(e),
};
match outcome {
Ok(sent) => {
job.status = "done".to_string();
job.bytes_sent = sent as i64;
job.last_error = None;
job.next_attempt_at = None;
}
Err(e) => {
job.attempts += 1;
job.last_error = Some(e.to_string());
job.status = "failed".to_string();
job.next_attempt_at = (job.attempts < SYNC_MAX_ATTEMPTS)
.then(|| now_unix() + sync_backoff_secs(job.attempts));
}
}
job.updated_at = now_unix();
let _ = store.update_sync_job(job.clone()).await;
emit_sync_job(app, &job);
}
}
/// Manual "Upload now": (re)enqueue the meeting's artifacts and pump immediately.
/// Streams progress via `sync://job` events.
#[tauri::command]
pub async fn sync_meeting(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
target_id: Option<String>,
) -> WaResult<()> {
enqueue_meeting_sync(
state.store.as_ref(),
&meeting_id,
target_id.as_deref(),
false,
)
.await?;
pump_sync(&app, state.store.as_ref()).await;
Ok(())
}
#[tauri::command]
pub async fn sync_status(
state: State<'_, AppState>,
meeting_id: Option<MeetingId>,
) -> WaResult<Vec<SyncJobInfo>> {
let rows = state
.store
.list_sync_jobs(meeting_id.as_ref())
.await
.map_err(|e| WaError::new("sync", e.to_string()))?;
Ok(rows.iter().map(job_to_info).collect())
}
// ---- Feature briefs + MCP server (Phase 10, ADR-0011) ----
/// Distills a finished meeting into an agent-ready `FeatureBrief` (M1.4,
/// FR-MCP-4, T10.6) via the configured `LlmProvider` — no new egress. Writes
/// the sealed `briefs/<id>.json` + the `feature_briefs` index row only after
/// a successful distill; an LLM failure (off/unreachable) leaves nothing on
/// disk or in the DB.
#[tauri::command]
pub async fn create_feature_brief(
state: State<'_, AppState>,
meeting_id: MeetingId,
target_repo: Option<String>,
) -> WaResult<FeatureBrief> {
let guard = state.session.lock().await;
if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) {
return Err(WaError::new(
"briefs",
"cannot create a feature brief while this meeting is still recording — wait until it's stopped",
));
}
drop(guard);
create_feature_brief_core(
&state.store,
&meeting_id,
target_repo.as_deref(),
&load_settings(),
)
.await
}
/// Core of `create_feature_brief`, factored out of the `State`-taking command
/// so it's callable directly from tests with an in-memory `Store` (T10.6
/// tests, `06-test-strategy.md` P10) without needing a live Tauri app.
async fn create_feature_brief_core(
store: &std::sync::Arc<dyn crate::storage::Store>,
meeting_id: &MeetingId,
target_repo: Option<&str>,
settings: &Settings,
) -> WaResult<FeatureBrief> {
use crate::briefs::FeatureBriefBuilder;
let provider = llm_provider_from_settings(settings).ok_or_else(|| {
WaError::new(
"llm",
"no LLM provider is configured — enable one in Settings first",
)
})?;
let builder = crate::briefs::LlmFeatureBriefBuilder {
store: store.clone(),
llm: provider,
};
// Nothing is written until this succeeds — an LLM failure (off/
// unreachable) returns here with disk/DB untouched.
let brief = builder
.build(meeting_id, target_repo)
.await
.map_err(|e| WaError::new("briefs", e.to_string()))?;
let meeting_title = store
.get_meeting(meeting_id)
.await
.map(|m| m.title)
.map_err(|e| WaError::new("storage", e.to_string()))?;
let generated_at = now_unix();
let brief_file = crate::storage::BriefFile {
schema: 1,
id: brief.id.clone(),
meeting_id: meeting_id.clone(),
generated_at,
provider: settings.llm_provider.clone(),
model: settings.llm_model.clone(),
title: brief.title.clone(),
problem: brief.problem.clone(),
desired_outcome: brief.desired_outcome.clone(),
acceptance_criteria: brief.acceptance_criteria.clone(),
target_repo: brief.target_repo.clone(),
context_excerpts: brief.context_excerpts.clone(),
source: crate::storage::BriefSource {
meeting_title,
at: generated_at,
},
};
let json = serde_json::to_string_pretty(&brief_file)
.map_err(|e| WaError::new("briefs", e.to_string()))?;
let sealed =
crate::vault::seal(json.as_bytes()).map_err(|e| WaError::new("vault", e.to_string()))?;
let briefs_dir = meeting_dir(meeting_id).join("briefs");
std::fs::create_dir_all(&briefs_dir).map_err(|e| WaError::new("briefs", e.to_string()))?;
let rel_path = format!("briefs/{}.json", brief.id);
let abs_path = briefs_dir.join(format!("{}.json", brief.id));
std::fs::write(&abs_path, sealed).map_err(|e| WaError::new("briefs", e.to_string()))?;
let row = crate::storage::FeatureBriefRow {
id: brief.id.clone(),
meeting_id: meeting_id.clone(),
title: brief.title.clone(),
target_repo: brief.target_repo.clone(),
path: rel_path,
exposed: false,
created_at: generated_at,
};
if let Err(e) = store.insert_feature_brief(row).await {
// The distill + file write already succeeded; don't leave an orphan
// file with no DB index behind if the row insert itself fails.
let _ = std::fs::remove_file(&abs_path);
return Err(WaError::new("storage", e.to_string()));
}
Ok(brief)
}
/// Lists briefs newest-first, optionally scoped to one meeting (M1.4).
#[tauri::command]
pub async fn list_feature_briefs(
state: State<'_, AppState>,
meeting_id: Option<MeetingId>,
) -> WaResult<Vec<FeatureBriefInfo>> {
state
.store
.list_feature_briefs(meeting_id.as_ref())
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
/// Resolves the index row to its sealed `briefs/<id>.json` file and returns
/// the IPC subset (M1.4) — the file is the source of truth, the row is the
/// index (`docs/03-data-model.md`).
#[tauri::command]
pub async fn get_feature_brief(state: State<'_, AppState>, id: String) -> WaResult<FeatureBrief> {
get_feature_brief_core(&state.store, &id).await
}
/// Core of `get_feature_brief`, factored out of the `State`-taking command so
/// the MCP `get_feature_brief` tool (`mcp::handler`, which only holds an
/// `Arc<dyn Store>`, not a live Tauri `State`) can call the same logic.
pub(crate) async fn get_feature_brief_core(
store: &std::sync::Arc<dyn crate::storage::Store>,
id: &str,
) -> WaResult<FeatureBrief> {
let row = store
.get_feature_brief_row(id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let abs_path = meeting_dir(&row.meeting_id).join(&row.path);
let bytes = std::fs::read(&abs_path).map_err(|e| WaError::new("storage", e.to_string()))?;
let opened = crate::vault::open(&bytes).map_err(|e| WaError::new("vault", e.to_string()))?;
let brief_file: crate::storage::BriefFile =
serde_json::from_slice(&opened).map_err(|e| WaError::new("briefs", e.to_string()))?;
Ok(FeatureBrief {
id: brief_file.id,
meeting_id: brief_file.meeting_id,
title: brief_file.title,
problem: brief_file.problem,
desired_outcome: brief_file.desired_outcome,
acceptance_criteria: brief_file.acceptance_criteria,
target_repo: brief_file.target_repo,
context_excerpts: brief_file.context_excerpts,
})
}
/// Scope control: include/exclude a brief from the MCP server (FR-MCP-3).
#[tauri::command]
pub async fn set_brief_exposed(
state: State<'_, AppState>,
id: String,
exposed: bool,
) -> WaResult<()> {
state
.store
.set_brief_exposed(&id, exposed)
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
#[tauri::command]
pub async fn mcp_status(state: State<'_, AppState>, app: AppHandle) -> WaResult<serde_json::Value> {
#[cfg(feature = "mcp")]
{
let settings = load_settings();
let server = crate::mcp::server::instance(state.store.clone(), app);
let running = server.is_running().await;
let transport = crate::mcp::McpTransport::parse(&settings.mcp_transport);
let endpoint = if running {
mcp_endpoint_for(transport, settings.mcp_port)
} else {
String::new()
};
Ok(serde_json::json!({
"enabled": running,
"transport": transport.as_str(),
"endpoint": endpoint,
"tokenSet": crate::mcp::token::is_set(),
"exposeScope": settings.mcp_expose,
}))
}
#[cfg(not(feature = "mcp"))]
{
let _ = (state, app);
Err(not_implemented("mcp_status"))
}
}
/// Builds the endpoint string surfaced by `mcp_status`/`set_mcp_enabled` —
/// shared so both agree on the shape (`http://127.0.0.1:<port>/mcp` for
/// Streamable HTTP, or the `--mcp-stdio` command line the agent should spawn).
#[cfg(feature = "mcp")]
fn mcp_endpoint_for(transport: crate::mcp::McpTransport, port: u16) -> String {
match transport {
crate::mcp::McpTransport::Http => format!("http://127.0.0.1:{port}/mcp"),
crate::mcp::McpTransport::Stdio => {
let exe = std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(str::to_string))
.unwrap_or_else(|| "whispassist.exe".to_string());
format!("{exe} --mcp-stdio")
}
}
}
/// Enable/disable the loopback MCP server; returns endpoint + token on enable (FR-MCP-1/6).
/// The token is minted fresh on every enable and lives only in the OS
/// credential store (`mcp::token`) — this command's return value is the one
/// time it's ever surfaced, exactly like a newly-created password.
#[tauri::command]
pub async fn set_mcp_enabled(
state: State<'_, AppState>,
app: AppHandle,
enabled: bool,
transport: Option<String>,
port: Option<u16>,
) -> WaResult<serde_json::Value> {
#[cfg(feature = "mcp")]
{
use crate::mcp::McpServer;
let mut settings = load_settings();
if let Some(t) = &transport {
settings.mcp_transport = t.clone();
}
if let Some(p) = port {
settings.mcp_port = p;
}
settings.mcp_enabled = enabled;
save_settings(&settings)?;
let server = crate::mcp::server::instance(state.store.clone(), app);
if enabled {
let cfg = crate::mcp::McpConfig {
transport: crate::mcp::McpTransport::parse(&settings.mcp_transport),
port: settings.mcp_port,
expose: crate::mcp::ExposeScope::parse(&settings.mcp_expose),
expose_recordings: settings.mcp_expose_recordings,
};
let handle = server
.start(cfg)
.await
.map_err(|e| WaError::new("mcp", e.to_string()))?;
Ok(serde_json::json!({ "endpoint": handle.endpoint, "token": handle.token }))
} else {
server
.stop(crate::mcp::McpHandle {
endpoint: String::new(),
token: String::new(),
})
.await
.map_err(|e| WaError::new("mcp", e.to_string()))?;
Ok(serde_json::json!({ "endpoint": "", "token": "" }))
}
}
#[cfg(not(feature = "mcp"))]
{
let _ = (state, app, enabled, transport, port);
Err(not_implemented("set_mcp_enabled"))
}
}
/// Set exposure scope (none|selected|all) and whether recordings may be served
/// (FR-MCP-3). Pure settings I/O — every MCP tool handler reads this live
/// (`mcp::handler::WaMcpHandler::current_scope`), so a change here takes
/// effect immediately without restarting the server.
#[tauri::command]
pub async fn set_mcp_scope(expose: String, expose_recordings: Option<bool>) -> WaResult<()> {
let mut settings = load_settings();
settings.mcp_expose = expose;
if let Some(r) = expose_recordings {
settings.mcp_expose_recordings = r;
}
save_settings(&settings)
}
/// Audit trail (FR-MCP-5) — every tool read, allowed or denied.
#[tauri::command]
pub async fn mcp_access_log(
state: State<'_, AppState>,
limit: Option<u32>,
) -> WaResult<Vec<McpAccessEntry>> {
state
.store
.list_mcp_access_log(limit)
.await
.map_err(|e| WaError::new("mcp", e.to_string()))
}
// ---- Agent push / task-tracker handoff (Phase 10c, later) ----
#[tauri::command]
pub async fn run_agent(
_brief_id: String,
_tool: String,
_repo_path: String,
) -> WaResult<serde_json::Value> {
Err(not_implemented("run_agent"))
}
#[tauri::command]
pub async fn create_issue_from_brief(
_brief_id: String,
_tracker: String,
_assign_copilot: Option<bool>,
) -> WaResult<serde_json::Value> {
Err(not_implemented("create_issue_from_brief"))
}
// ---- Settings + privacy (Phase 2/7) ----
#[tauri::command]
pub async fn get_settings() -> WaResult<Settings> {
Ok(load_settings())
}
#[tauri::command]
pub async fn update_settings(patch: serde_json::Value) -> WaResult<Settings> {
let mut current = serde_json::to_value(load_settings())
.map_err(|e| WaError::new("settings", e.to_string()))?;
if let (Some(base), Some(patch)) = (current.as_object_mut(), patch.as_object()) {
for (key, value) in patch {
base.insert(key.clone(), value.clone());
}
}
let merged: Settings =
serde_json::from_value(current).map_err(|e| WaError::new("settings", e.to_string()))?;
save_settings(&merged)?;
Ok(merged)
}
/// Pure assembly of the `privacy_self_check` response (shape fixed by
/// docs/04-api-contracts.md) from already-loaded settings + sync targets, so
/// it's unit-testable without touching the settings file or a DB.
fn privacy_self_check_json(
settings: &Settings,
sync_targets: Vec<SyncTargetInfo>,
) -> serde_json::Value {
let provider = llm_provider_from_settings(settings);
let (llm_endpoint, llm_is_local) = match &provider {
Some(p) => (settings.llm_endpoint.clone(), p.is_local()),
None => (String::new(), true), // off: no endpoint, trivially no egress
};
let mut allowlisted_hosts: Vec<String> = Vec::new();
if !llm_is_local {
if let Some(host) = reqwest::Url::parse(&llm_endpoint)
.ok()
.and_then(|u| u.host_str().map(str::to_string))
{
allowlisted_hosts.push(host);
}
}
for target in sync_targets.iter().filter(|t| t.enabled) {
if let Some(host) = &target.host {
allowlisted_hosts.push(host.clone());
}
}
// MS Graph calendar source (M4.4, FR-CAL-6) — the only egress it ever
// needs, and only when the user has explicitly linked it.
if settings.graph_calendar_enabled {
allowlisted_hosts.push("graph.microsoft.com".to_string());
}
allowlisted_hosts.sort();
allowlisted_hosts.dedup();
let sync_targets_json: Vec<serde_json::Value> = sync_targets
.iter()
.map(|t| {
serde_json::json!({
"name": t.name,
"host": t.host.clone().unwrap_or_default(),
"thirdParty": t.third_party,
"tls": t.base_url.as_deref().is_some_and(|u| u.starts_with("https://")),
})
})
.collect();
serde_json::json!({
"llmEndpoint": llm_endpoint,
"llmIsLocal": llm_is_local,
"syncEnabled": settings.sync_enabled,
"syncTargets": sync_targets_json,
"allowlistedHosts": allowlisted_hosts,
})
}
/// Reports current egress + LLM endpoint so the UI can prove local-only handling (FR-SEC-2).
/// Enabled sync targets' hosts are folded into the allowlist (T9.8); a DB error is
/// swallowed to an empty list rather than failing the whole self-check.
#[tauri::command]
pub async fn privacy_self_check(state: State<'_, AppState>) -> WaResult<serde_json::Value> {
let settings = load_settings();
let sync_targets: Vec<SyncTargetInfo> = if settings.sync_enabled {
state
.store
.list_sync_targets()
.await
.map(|rows| rows.iter().map(crate::sync::row_to_info).collect())
.unwrap_or_default()
} else {
Vec::new()
};
Ok(privacy_self_check_json(&settings, sync_targets))
}
#[cfg(test)]
mod tests {
use super::*;
fn row(backend: &str, model: &str, rtf: f64) -> StressResult {
StressResult {
backend: backend.into(),
model: model.into(),
rtf,
realtime: rtf < 1.0,
}
}
#[test]
fn recommendation_picks_largest_realtime_model() {
let sizes = HashMap::from([
("tiny".to_string(), 32),
("base".to_string(), 60),
("small".to_string(), 190),
]);
let results = vec![
row("cpu", "tiny", 0.4),
row("cpu", "base", 0.9),
row("cpu", "small", 1.4), // too slow — excluded
row("vulkan", "small", 0.6),
];
let rec = pick_realtime_recommendation(&results, &sizes).unwrap();
// small is the largest model that still runs in real time (on vulkan).
assert_eq!(rec.model, "small");
assert_eq!(rec.backend, "vulkan");
}
#[test]
fn recommendation_breaks_size_ties_by_lowest_rtf() {
let sizes = HashMap::from([("base".to_string(), 60)]);
let results = vec![row("cpu", "base", 0.8), row("vulkan", "base", 0.3)];
let rec = pick_realtime_recommendation(&results, &sizes).unwrap();
assert_eq!(rec.backend, "vulkan"); // same model, more headroom
}
#[test]
fn recommendation_is_none_when_nothing_is_realtime() {
let sizes = HashMap::from([("small".to_string(), 190)]);
let results = vec![row("cpu", "small", 1.2)];
assert!(pick_realtime_recommendation(&results, &sizes).is_none());
}
/// Extracts the real hosted DirectML bundle (LZMA2+BCJ 7z) and checks the
/// DLL lands flat. Skips if the binary isn't present (e.g. a lean checkout).
#[cfg(feature = "npu")]
#[test]
fn extract_7z_flat_unpacks_the_directml_bundle() {
let archive = std::path::Path::new("../runtime/directml.7z");
if !archive.exists() {
eprintln!("skip: {} not present", archive.display());
return;
}
let dir = std::env::temp_dir().join(format!("wa-7z-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
extract_7z_flat(archive, &dir).expect("7z extract");
let dll = dir.join("onnxruntime.dll");
assert!(dll.exists(), "onnxruntime.dll missing after extract");
assert_eq!(
std::fs::metadata(&dll).unwrap().len(),
17_253_408,
"unexpected DLL size — BCJ/LZMA2 decode may be wrong"
);
// Flattened: the archive's `directml/` prefix must be stripped.
assert!(
!dir.join("directml").exists(),
"folder prefix not flattened"
);
let _ = std::fs::remove_dir_all(&dir);
}
fn segment(speaker: &str) -> TranscriptSegment {
TranscriptSegment {
id: 0,
start_ms: 0,
end_ms: 1000,
speaker: speaker.to_string(),
text: String::new(),
confidence: None,
interim: false,
}
}
#[test]
fn sync_backoff_grows_then_caps_at_one_hour() {
assert_eq!(sync_backoff_secs(0), 30);
assert_eq!(sync_backoff_secs(1), 60);
assert_eq!(sync_backoff_secs(2), 120);
// Monotonic non-decreasing and never above the 1h cap.
let mut prev = 0;
for a in 0..12 {
let b = sync_backoff_secs(a);
assert!(b >= prev && b <= 3600);
prev = b;
}
}
#[test]
fn fold_wav_downmixes_and_dual_monos() {
// 2ch 16-bit WAV, one frame [1000, 3000] → average 2000.
let spec = hound::WavSpec {
channels: 2,
sample_rate: 16_000,
bits_per_sample: 16,
sample_format: hound::SampleFormat::Int,
};
let mut buf = std::io::Cursor::new(Vec::new());
{
let mut w = hound::WavWriter::new(&mut buf, spec).unwrap();
w.write_sample(1000i16).unwrap();
w.write_sample(3000i16).unwrap();
w.finalize().unwrap();
}
let wav = buf.into_inner();
let mono = fold_wav(&wav, 1).unwrap();
let mut r = hound::WavReader::new(std::io::Cursor::new(mono)).unwrap();
assert_eq!(r.spec().channels, 1);
assert_eq!(
r.samples::<i16>().map(|x| x.unwrap()).collect::<Vec<_>>(),
vec![2000]
);
let dual = fold_wav(&wav, 2).unwrap();
let mut r2 = hound::WavReader::new(std::io::Cursor::new(dual)).unwrap();
assert_eq!(r2.spec().channels, 2);
assert_eq!(
r2.samples::<i16>().map(|x| x.unwrap()).collect::<Vec<_>>(),
vec![2000, 2000] // dual-mono: both channels the mixed sample
);
}
#[test]
fn speaker_infos_lists_distinct_speakers_in_first_appearance_order() {
let segments = vec![segment("S2"), segment("S1"), segment("S2")];
let infos = speaker_infos_from_segments(&segments, &HashMap::new());
let labels: Vec<&str> = infos.iter().map(|s| s.label.as_str()).collect();
assert_eq!(labels, vec!["S2", "S1"]);
}
#[test]
fn speaker_infos_applies_display_names_by_label() {
let segments = vec![segment("S1"), segment("S2")];
let mut names = HashMap::new();
names.insert("S1".to_string(), "Alice".to_string());
let infos = speaker_infos_from_segments(&segments, &names);
assert_eq!(infos[0].display_name.as_deref(), Some("Alice"));
assert_eq!(infos[1].display_name, None); // S2 was never named
}
#[test]
fn speaker_infos_falls_back_to_s1_placeholder_when_no_segments_yet() {
let infos = speaker_infos_from_segments(&[], &HashMap::new());
assert_eq!(infos.len(), 1);
assert_eq!(infos[0].label, "S1");
assert!(infos[0].display_name.is_none());
}
#[test]
fn diarizer_is_none_when_models_are_not_installed() {
// This test environment never has the fixed-path diarization models
// installed (T4.7 will add real download/selection) — confirms the
// graceful-degradation path a recording never blocks on (T4.3).
assert!(diarizer_from_installed_models().is_none());
}
// ---- Live notes redesign: upsert_segment_note / load_manual_notes ----
#[test]
fn upsert_segment_note_appends_a_new_anchor() {
let mut notes = Vec::new();
upsert_segment_note(&mut notes, 1_000, "first".to_string(), 100);
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].anchor_ms, 1_000);
assert_eq!(notes[0].text, "first");
assert_eq!(notes[0].created_at, 100);
assert_eq!(notes[0].updated_at, 100);
}
#[test]
fn upsert_segment_note_edits_the_existing_anchor_in_place_without_resetting_created_at() {
let mut notes = vec![SegmentNote {
anchor_ms: 1_000,
text: "first".to_string(),
created_at: 100,
updated_at: 100,
}];
upsert_segment_note(&mut notes, 1_000, "edited".to_string(), 200);
assert_eq!(
notes.len(),
1,
"re-clicking the same segment must not duplicate it"
);
assert_eq!(notes[0].text, "edited");
assert_eq!(notes[0].created_at, 100);
assert_eq!(notes[0].updated_at, 200);
}
#[test]
fn upsert_segment_note_with_empty_text_clears_rather_than_removes() {
// Kept (not deleted) so `updated_at` still reflects the clear, and
// `transcript_with_notes` already skips blank-text notes when
// rendering (see notes/mod.rs).
let mut notes = Vec::new();
upsert_segment_note(&mut notes, 1_000, String::new(), 100);
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].text, "");
}
#[test]
fn load_manual_notes_defaults_when_the_file_does_not_exist() {
let manual = load_manual_notes(&"no-such-meeting-id".to_string());
assert_eq!(manual.freeform_md, "");
assert!(manual.segment_notes.is_empty());
}
fn meeting_fixture() -> Meeting {
Meeting {
id: "m1".to_string(),
title: "Sprint planning".to_string(),
started_at: 0,
ended_at: None,
duration_secs: Some(1830), // 30.5 min
status: MeetingStatus::Ready,
recorded: false,
language: None,
backend_used: None,
model_used: None,
audio_layout: None,
segments: Vec::new(),
speakers: vec![
SpeakerInfo {
label: "S1".to_string(),
display_name: Some("Alice".to_string()),
participant_id: None,
},
SpeakerInfo {
label: "S2".to_string(),
display_name: None,
participant_id: None,
},
],
notes_markdown: "**Alice:** Let's plan the sprint.".to_string(),
summary: None,
calendar_event_id: None,
tags: Vec::new(),
template_id: None,
action_items: Vec::new(),
}
}
#[test]
fn build_prompt_includes_title_duration_and_named_or_labeled_speakers() {
let prompt = build_prompt(&meeting_fixture(), None);
assert!(prompt.metadata.contains("Sprint planning"));
assert!(prompt.metadata.contains("30 min"));
assert!(prompt.metadata.contains("Alice"));
assert!(prompt.metadata.contains("S2")); // unnamed speaker falls back to its label
assert_eq!(prompt.transcript, "**Alice:** Let's plan the sprint.");
assert!(prompt.template.is_none());
}
#[test]
fn build_prompt_resolves_a_known_template_id_and_ignores_an_unknown_one() {
let known = build_prompt(&meeting_fixture(), Some("standup"));
assert!(known.template.unwrap().to_lowercase().contains("standup"));
let unknown = build_prompt(&meeting_fixture(), Some("no-such-template"));
assert!(unknown.template.is_none());
}
fn sync_target_fixture(
name: &str,
enabled: bool,
host: &str,
third_party: bool,
tls: bool,
) -> SyncTargetInfo {
SyncTargetInfo {
id: name.to_string(),
name: name.to_string(),
kind: SyncKind::WebDav,
provider_hint: Some("nextcloud".to_string()),
base_url: Some(format!("{}://{}", if tls { "https" } else { "http" }, host)),
remote_base_path: "/WhispAssist".to_string(),
username: Some("alice".to_string()),
enabled,
third_party,
host: Some(host.to_string()),
upload_transcript: true,
upload_notes: true,
upload_summary: true,
upload_recording: false,
trigger_on_finalize: true,
allow_plaintext_lan: false,
encrypt_before_upload: false,
}
}
#[test]
fn privacy_self_check_default_settings_is_fully_local_with_empty_allowlist() {
let result = privacy_self_check_json(&default_settings(), Vec::new());
assert_eq!(result["llmIsLocal"], true);
assert_eq!(result["llmEndpoint"], "http://localhost:11434");
assert_eq!(result["syncEnabled"], false);
assert_eq!(result["syncTargets"], serde_json::json!([]));
assert_eq!(result["allowlistedHosts"], serde_json::json!([]));
}
#[test]
fn privacy_self_check_remote_llm_endpoint_is_not_local_and_joins_the_allowlist() {
let mut settings = default_settings();
settings.llm_provider = "custom".to_string();
settings.llm_endpoint = "https://api.example.com".to_string();
let result = privacy_self_check_json(&settings, Vec::new());
assert_eq!(result["llmIsLocal"], false);
assert_eq!(
result["allowlistedHosts"],
serde_json::json!(["api.example.com"])
);
}
#[test]
fn privacy_self_check_anthropic_host_joins_the_allowlist_only_once_selected() {
// Off by default (ADR-0011) — merely *knowing about* Anthropic (the
// provider code exists) must not put its host on the allowlist.
let result = privacy_self_check_json(&default_settings(), Vec::new());
assert_eq!(result["allowlistedHosts"], serde_json::json!([]));
// Only once the user has actually selected it (mirrors what
// set_llm_provider/apply_llm_provider_args writes) does the same
// generic llm_provider_from_settings + is_local() path used for every
// other provider pick it up — no anthropic-specific allowlist code.
let mut settings = default_settings();
settings.llm_provider = "anthropic".to_string();
settings.llm_endpoint = "https://api.anthropic.com".to_string();
let result = privacy_self_check_json(&settings, Vec::new());
assert_eq!(result["llmIsLocal"], false);
assert_eq!(
result["allowlistedHosts"],
serde_json::json!(["api.anthropic.com"])
);
}
#[test]
fn privacy_self_check_graph_calendar_host_joins_the_allowlist_only_when_linked() {
// Off by default — the OAuth provider existing in code must not put
// Graph's host on the allowlist by itself (M4.4, FR-CAL-6).
let result = privacy_self_check_json(&default_settings(), Vec::new());
assert_eq!(result["allowlistedHosts"], serde_json::json!([]));
let mut settings = default_settings();
settings.graph_calendar_enabled = true;
let result = privacy_self_check_json(&settings, Vec::new());
assert_eq!(
result["allowlistedHosts"],
serde_json::json!(["graph.microsoft.com"])
);
}
#[test]
fn set_llm_provider_never_writes_the_api_key_into_settings() {
// apply_llm_provider_args has no key parameter at all — this proves
// structurally (not just by inspection) that a supplied apiKey can
// never end up in the Settings struct that gets serialized to
// settings.json (FR-SEC-1, ADR-0011). The real key only ever reaches
// crate::llm::credentials::set, called separately in
// set_llm_provider before this function runs.
let mut settings = default_settings();
apply_llm_provider_args(
&mut settings,
"anthropic",
Some("https://attacker-controlled.example/ignored".to_string()),
Some("claude-3-5-sonnet-latest".to_string()),
);
assert_eq!(settings.llm_provider, "anthropic");
// The fixed hosted endpoint wins over any caller-supplied one.
assert_eq!(settings.llm_endpoint, "https://api.anthropic.com");
assert_eq!(settings.llm_model, "claude-3-5-sonnet-latest");
let json = serde_json::to_string(&settings).expect("settings must serialize");
assert!(!json.to_lowercase().contains("api_key"));
assert!(!json.to_lowercase().contains("apikey"));
assert!(!json.contains("sk-ant-"));
}
#[test]
fn apply_llm_provider_args_keeps_the_existing_endpoint_for_non_anthropic_providers() {
let mut settings = default_settings();
settings.llm_endpoint = "http://192.168.0.42:11434".to_string();
apply_llm_provider_args(&mut settings, "ollama", None, Some("llama3.1".to_string()));
assert_eq!(settings.llm_provider, "ollama");
assert_eq!(settings.llm_endpoint, "http://192.168.0.42:11434");
assert_eq!(settings.llm_model, "llama3.1");
}
#[test]
fn apply_llm_provider_args_resets_endpoint_when_switching_away_from_anthropics_fixed_url() {
// A per-use provider quick-switch (SummaryPanel, M3.3) only sends
// `provider`, relying on whatever endpoint/model were last saved —
// if that was Anthropic's fixed URL, switching to ollama must not
// silently keep pointing at a third party.
let mut settings = default_settings();
settings.llm_provider = "anthropic".to_string();
settings.llm_endpoint = "https://api.anthropic.com".to_string();
apply_llm_provider_args(&mut settings, "ollama", None, None);
assert_eq!(settings.llm_provider, "ollama");
assert_eq!(settings.llm_endpoint, "http://localhost:11434");
}
#[test]
fn privacy_self_check_only_enabled_sync_targets_join_the_allowlist() {
let mut settings = default_settings();
settings.sync_enabled = true;
let targets = vec![
sync_target_fixture("Nextcloud", true, "cloud.example.org", false, true),
sync_target_fixture(
"Old Disabled Target",
false,
"stale.example.org",
false,
true,
),
];
let result = privacy_self_check_json(&settings, targets);
assert_eq!(
result["allowlistedHosts"],
serde_json::json!(["cloud.example.org"])
);
assert_eq!(result["syncTargets"].as_array().unwrap().len(), 2);
assert_eq!(result["syncTargets"][0]["thirdParty"], false);
assert_eq!(result["syncTargets"][0]["tls"], true);
}
fn list_item(id: &str, title: &str) -> MeetingListItem {
MeetingListItem {
id: id.to_string(),
title: title.to_string(),
started_at: 0,
duration_secs: None,
status: MeetingStatus::Ready,
tags: Vec::new(),
}
}
#[test]
fn bulk_export_stem_sanitizes_unsafe_filename_characters() {
let item = list_item("abcdef12-0000-0000-0000-000000000000", "Q3: Sales / Ops?");
// ':',' ' -> "__"; ' ','/',' ' -> "___"; trailing '?' trimmed with the
// underscore it became, since trim_matches only strips leading/trailing.
assert_eq!(bulk_export_stem(&item), "Q3__Sales___Ops_abcdef12");
}
#[test]
fn bulk_export_stem_disambiguates_duplicate_titles_by_id() {
// Meeting titles are very often duplicates ("Untitled meeting") — the
// id prefix is what keeps a bulk export from overwriting files.
let a = list_item("11111111-aaaa", "Untitled meeting");
let b = list_item("22222222-bbbb", "Untitled meeting");
assert_ne!(bulk_export_stem(&a), bulk_export_stem(&b));
}
// ---- create_feature_brief_core (T10.6, M1.6 command-level test) ----
// `06-test-strategy.md` P10: LLM off/unreachable -> Err, nothing written
// to disk/DB. Exercised directly (not through the `#[tauri::command]`
// wrapper) with an in-memory store so it needs no live Tauri app.
#[tokio::test]
async fn create_feature_brief_core_errs_and_writes_nothing_when_llm_is_off() {
let store: std::sync::Arc<dyn crate::storage::Store> = std::sync::Arc::new(
crate::storage::SqliteStore::connect_in_memory()
.await
.unwrap(),
);
let mut settings = default_settings();
settings.llm_provider = "off".to_string();
let result =
create_feature_brief_core(&store, &"nonexistent".to_string(), None, &settings).await;
assert!(result.is_err());
assert!(store.list_feature_briefs(None).await.unwrap().is_empty());
}
#[tokio::test]
async fn create_feature_brief_core_errs_and_writes_nothing_when_llm_is_unreachable() {
let store: std::sync::Arc<dyn crate::storage::Store> = std::sync::Arc::new(
crate::storage::SqliteStore::connect_in_memory()
.await
.unwrap(),
);
let meeting_id = store
.create_meeting(NewMeeting {
title: "Test meeting".to_string(),
calendar_event_id: None,
template_id: None,
language: None,
})
.await
.unwrap();
store
.finalize_meeting(
&meeting_id,
FinalizeMeeting {
segments: Vec::new(),
speakers: Vec::new(),
duration_secs: 60,
recorded: false,
language: None,
backend_used: None,
model_used: None,
audio_layout: None,
},
)
.await
.unwrap();
let mut settings = default_settings();
settings.llm_provider = "ollama".to_string();
// Nothing listens here (loopback, not a real egress) — the LLM call
// fails fast with a connection error, same shape as "Ollama isn't
// running".
settings.llm_endpoint = "http://127.0.0.1:1".to_string();
let result = create_feature_brief_core(&store, &meeting_id, None, &settings).await;
assert!(result.is_err());
assert!(store
.list_feature_briefs(Some(&meeting_id))
.await
.unwrap()
.is_empty());
}
}