import_pst_core takes &AppHandle/&dyn Store directly instead of the State<AppState> extractor, so the startup auto-sync pass (lib.rs) can run the same import logic without going through a Tauri command.
3163 lines
113 KiB
Rust
3163 lines
113 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, 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};
|
|
|
|
#[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>,
|
|
}
|
|
|
|
// ---- 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`).
|
|
|
|
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(),
|
|
low_overhead: false,
|
|
default_record: false,
|
|
consent_acknowledged: false,
|
|
sync_enabled: false,
|
|
retention_max_age_days: None,
|
|
retention_max_size_gb: None,
|
|
pst_last_path: None,
|
|
pst_auto_sync: false,
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
) -> 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) {
|
|
Ok(t) => 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) {
|
|
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)
|
|
.map(|t| (Box::new(t) as Box<dyn Transcriber>, BackendId::Cpu))
|
|
}
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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()
|
|
),
|
|
));
|
|
}
|
|
|
|
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,
|
|
})
|
|
.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);
|
|
let capture = WasapiCapture
|
|
.start(&wav_path, frame_tx, event_tx)
|
|
.map_err(|e| WaError::new("audio", e.to_string()))?;
|
|
|
|
// 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,
|
|
}),
|
|
);
|
|
}
|
|
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();
|
|
let app_for_worker = app.clone();
|
|
let meeting_id_for_worker = meeting_id.clone();
|
|
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) {
|
|
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:?}"),
|
|
}),
|
|
);
|
|
}
|
|
run_streaming_worker(transcriber.as_ref(), frame_rx, |segment| {
|
|
if let Ok(mut buf) = segments_for_worker.lock() {
|
|
buf.push(segment.clone());
|
|
}
|
|
let _ = app_for_worker.emit(
|
|
"transcript://segment",
|
|
serde_json::json!({ "meetingId": meeting_id_for_worker, "segment": segment }),
|
|
);
|
|
});
|
|
})
|
|
.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();
|
|
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
|
|
}
|
|
|
|
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 speakers = {
|
|
let mut segs = segments_for_diar.lock().unwrap_or_else(|e| e.into_inner());
|
|
diarizer.assign(&mut segs, &spans);
|
|
let names = names_for_diar.lock().unwrap_or_else(|e| e.into_inner());
|
|
speaker_infos_from_segments(&segs, &names)
|
|
};
|
|
let _ = app_for_diar.emit(
|
|
"diarization://updated",
|
|
serde_json::json!({ "meetingId": meeting_id_for_diar, "speakers": speakers }),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
*guard = Some(RecordingSession {
|
|
meeting_id: meeting_id.clone(),
|
|
capture,
|
|
retention: args.record,
|
|
wav_path,
|
|
started_at: std::time::Instant::now(),
|
|
transcription_worker,
|
|
segments,
|
|
active_backend,
|
|
model_id,
|
|
diarizer,
|
|
speaker_names,
|
|
});
|
|
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()))?;
|
|
// The capture thread has dropped its `FrameSink`; the transcription worker's
|
|
// `recv()` now 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();
|
|
|
|
// T4.1/4.2: one authoritative diarization pass over the now-complete
|
|
// recording (ADR-0005's "post-stop pass"), refining whatever the live
|
|
// provisional passes (T4.3) produced. Skipped if diarization models
|
|
// aren't installed — `speaker_infos_from_segments` then falls back to
|
|
// the single pre-diarization "S1" placeholder, same as before Phase 4.
|
|
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),
|
|
Ok(Err(e)) => tracing::warn!("final diarization pass failed: {e}"),
|
|
Err(e) => tracing::warn!("final diarization task failed: {e}"),
|
|
}
|
|
}
|
|
let 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());
|
|
|
|
// 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: None,
|
|
backend_used: Some(backend_used),
|
|
model_used: Some(session.model_id.clone()),
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
|
|
|
let template = template_id
|
|
.as_deref()
|
|
.and_then(crate::notes::note_template_by_id);
|
|
let notes_md =
|
|
crate::notes::MarkdownNotes.to_markdown(&segments, &speakers, 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.
|
|
if !session.retention {
|
|
let _ = std::fs::remove_file(&session.wav_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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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(())
|
|
}
|
|
|
|
#[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()))?;
|
|
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()))?;
|
|
drop(guard);
|
|
let _ = app.emit(
|
|
"recording://state",
|
|
serde_json::json!({ "meetingId": meeting_id, "state": "recording", "elapsedMs": 0 }),
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
// ---- Speakers (Phase 4) ----
|
|
|
|
/// Re-renders and persists `notes.md` from a finalized meeting's current
|
|
/// (post-rename/post-merge) segments+speakers, and tells the frontend what
|
|
/// changed (T4.5/4.6, FR-SPK-3/5). This is what keeps `export_meeting` — which
|
|
/// just copies the already-rendered `notes.md` — in sync with naming changes
|
|
/// made after the meeting ends; `transcript.json` itself is untouched.
|
|
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 template = meeting
|
|
.template_id
|
|
.as_deref()
|
|
.and_then(crate::notes::note_template_by_id);
|
|
let notes_md = crate::notes::MarkdownNotes.to_markdown(
|
|
&meeting.segments,
|
|
&meeting.speakers,
|
|
None,
|
|
template.as_ref(),
|
|
);
|
|
let _ = state.store.update_notes(meeting_id, ¬es_md).await;
|
|
let _ = app.emit(
|
|
"diarization://updated",
|
|
serde_json::json!({ "meetingId": meeting_id, "speakers": meeting.speakers }),
|
|
);
|
|
Ok(meeting.speakers)
|
|
}
|
|
|
|
/// 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<()> {
|
|
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);
|
|
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);
|
|
|
|
state
|
|
.store
|
|
.map_speaker_to_participant(&meeting_id, &label, &participant_id)
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
|
refresh_notes_and_notify(&app, &state, &meeting_id).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(),
|
|
},
|
|
}))
|
|
}
|
|
|
|
#[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)
|
|
}
|
|
|
|
/// 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 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.
|
|
#[tauri::command]
|
|
pub async fn reprocess_transcript(
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
meeting_id: MeetingId,
|
|
model: 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 settings = load_settings();
|
|
let backend = backend_for(&settings);
|
|
let segments = tauri::async_runtime::spawn_blocking({
|
|
let wav_path = wav_path.clone();
|
|
move || {
|
|
let (transcriber, _used) = load_transcriber(backend, &model_path)?;
|
|
transcriber.transcribe_file(&wav_path)
|
|
}
|
|
})
|
|
.await
|
|
.map_err(|e| WaError::new("transcription", e.to_string()))?
|
|
.map_err(|e| WaError::new("transcription", e.to_string()))?;
|
|
|
|
let meeting = state
|
|
.store
|
|
.get_meeting(&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: meeting.speakers.clone(),
|
|
duration_secs,
|
|
recorded: meeting.recorded,
|
|
language: meeting.language,
|
|
backend_used: Some(backend.as_str().to_string()),
|
|
model_used: Some(model),
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
|
|
|
let notes_md = crate::notes::MarkdownNotes.to_markdown(
|
|
&segments,
|
|
&meeting.speakers,
|
|
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(())
|
|
}
|
|
|
|
/// 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",
|
|
));
|
|
}
|
|
|
|
// 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 = tauri::async_runtime::spawn_blocking(move || {
|
|
WhisperTranscriber::load(&model_path, BackendId::Cpu)
|
|
.and_then(|transcriber| transcriber.transcribe_file(&wav_path))
|
|
})
|
|
.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: None,
|
|
backend_used: Some(BackendId::Cpu.as_str().to_string()),
|
|
model_used: Some(model_id),
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
|
|
|
let template = template_id
|
|
.as_deref()
|
|
.and_then(crate::notes::note_template_by_id);
|
|
let notes_md =
|
|
crate::notes::MarkdownNotes.to_markdown(&segments, &speakers, 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(
|
|
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()))
|
|
}
|
|
|
|
/// 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()))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn update_notes(
|
|
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()))
|
|
}
|
|
|
|
/// `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)
|
|
}
|
|
|
|
/// 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 is stored plaintext — copy as-is.
|
|
let audio_src = source_dir.join("audio.wav");
|
|
if audio_src.exists() {
|
|
std::fs::copy(&audio_src, dest_path.join("audio.wav"))
|
|
.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()))?;
|
|
}
|
|
other => {
|
|
return Err(WaError::new(
|
|
"export",
|
|
format!("unsupported export format: {other}"),
|
|
))
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ---- 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
|
|
})),
|
|
_ => 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)]
|
|
pub struct SetLlmProviderArgs {
|
|
pub provider: String, // "ollama" | "custom" | "off" (Phase 10a adds "anthropic" | "openai")
|
|
pub endpoint: Option<String>,
|
|
pub model: Option<String>,
|
|
}
|
|
|
|
/// Select/configure the LLM provider (T5.2, FR-LLM-1). Hosted providers
|
|
/// (`apiKey`, stored only in the OS credential store — never settings/DB)
|
|
/// land in Phase 10a.
|
|
#[tauri::command]
|
|
pub async fn set_llm_provider(args: SetLlmProviderArgs) -> WaResult<()> {
|
|
if !matches!(args.provider.as_str(), "ollama" | "custom" | "off") {
|
|
return Err(WaError::new(
|
|
"llm",
|
|
format!(
|
|
"provider '{}' is not available yet — hosted providers land in Phase 10a",
|
|
args.provider
|
|
),
|
|
));
|
|
}
|
|
let mut settings = load_settings();
|
|
settings.llm_provider = args.provider;
|
|
if let Some(endpoint) = args.endpoint {
|
|
settings.llm_endpoint = endpoint;
|
|
}
|
|
if let Some(model) = args.model {
|
|
settings.llm_model = 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);
|
|
}
|
|
|
|
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(
|
|
state: State<'_, AppState>,
|
|
meeting_id: MeetingId,
|
|
items: Vec<ActionItem>,
|
|
) -> WaResult<()> {
|
|
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),
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ---- 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>,
|
|
) -> WaResult<u32> {
|
|
let events = tauri::async_runtime::spawn_blocking(move || {
|
|
PstSource.import(CalImport { path, password })
|
|
})
|
|
.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>,
|
|
) -> WaResult<u32> {
|
|
import_pst_core(&app, state.store.as_ref(), path, password).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()))
|
|
}
|
|
|
|
/// 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()))
|
|
}
|
|
|
|
// ---- 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)
|
|
}
|
|
|
|
/// 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,
|
|
) -> 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();
|
|
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?;
|
|
}
|
|
let sent = rx
|
|
.try_iter()
|
|
.last()
|
|
.map(|(s, _)| s)
|
|
.unwrap_or(row.bytes_total.unwrap_or(0) as u64);
|
|
Ok(sent)
|
|
}
|
|
|
|
/// 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);
|
|
|
|
let outcome = match crate::sync::build_sync_target(&target) {
|
|
Ok(t) => upload_job(t.as_ref(), &job, target.encrypt_before_upload).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 10b, ADR-0011) ----
|
|
|
|
#[tauri::command]
|
|
pub async fn create_feature_brief(
|
|
_meeting_id: MeetingId,
|
|
_target_repo: Option<String>,
|
|
) -> WaResult<FeatureBrief> {
|
|
// T10.6: distill transcript → structured brief via the configured LlmProvider.
|
|
Err(not_implemented("create_feature_brief"))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn list_feature_briefs(
|
|
_meeting_id: Option<MeetingId>,
|
|
) -> WaResult<Vec<FeatureBriefInfo>> {
|
|
Err(not_implemented("list_feature_briefs"))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_feature_brief(_id: String) -> WaResult<FeatureBrief> {
|
|
Err(not_implemented("get_feature_brief"))
|
|
}
|
|
|
|
/// Scope control: include/exclude a brief from the MCP server (FR-MCP-3).
|
|
#[tauri::command]
|
|
pub async fn set_brief_exposed(_id: String, _exposed: bool) -> WaResult<()> {
|
|
Err(not_implemented("set_brief_exposed"))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn mcp_status() -> WaResult<serde_json::Value> {
|
|
Err(not_implemented("mcp_status"))
|
|
}
|
|
|
|
/// Enable/disable the loopback MCP server; returns endpoint + token on enable (FR-MCP-1/6).
|
|
#[tauri::command]
|
|
pub async fn set_mcp_enabled(
|
|
_enabled: bool,
|
|
_transport: Option<String>,
|
|
_port: Option<u16>,
|
|
) -> WaResult<serde_json::Value> {
|
|
Err(not_implemented("set_mcp_enabled"))
|
|
}
|
|
|
|
/// Set exposure scope (none|selected|all) and whether recordings may be served (FR-MCP-3).
|
|
#[tauri::command]
|
|
pub async fn set_mcp_scope(_expose: String, _expose_recordings: Option<bool>) -> WaResult<()> {
|
|
Err(not_implemented("set_mcp_scope"))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn mcp_access_log(_limit: Option<u32>) -> WaResult<Vec<McpAccessEntry>> {
|
|
Err(not_implemented("mcp_access_log"))
|
|
}
|
|
|
|
// ---- 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());
|
|
}
|
|
}
|
|
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::*;
|
|
|
|
/// 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 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());
|
|
}
|
|
|
|
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,
|
|
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,
|
|
}
|
|
}
|
|
|
|
#[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_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));
|
|
}
|
|
}
|