6051 lines
229 KiB
Rust
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, ¬es_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, ¬es_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, ¬es_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, ¬es_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, ¬es)
|
|
.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());
|
|
}
|
|
}
|