338 lines
15 KiB
Rust
338 lines
15 KiB
Rust
//! WhispAssist core library.
|
|
//!
|
|
//! Architecture: a thin Tauri shell over independent service modules, each
|
|
//! exposing a trait (see `docs/04-api-contracts.md`). Callers depend on the
|
|
//! trait, not the concrete engine, so engines are swappable (NFR-MNT-1/2).
|
|
|
|
pub mod agent;
|
|
pub mod audio;
|
|
pub mod briefs;
|
|
pub mod calendar;
|
|
pub mod commands;
|
|
pub mod diarization;
|
|
pub mod error;
|
|
pub mod hardware;
|
|
pub mod llm;
|
|
pub mod mcp;
|
|
pub mod models;
|
|
pub mod notes;
|
|
pub mod paths;
|
|
pub mod reminders;
|
|
pub mod storage;
|
|
pub mod sync;
|
|
pub mod transcription;
|
|
pub mod vault;
|
|
|
|
use std::path::PathBuf;
|
|
use std::sync::{Arc, Mutex as StdMutex};
|
|
use std::thread::JoinHandle;
|
|
use tauri::tray::TrayIcon;
|
|
use tauri::Manager;
|
|
use tokio::sync::Mutex;
|
|
|
|
/// Shared application state handed to every Tauri command via `tauri::State`.
|
|
///
|
|
/// `hardware`/`llm`/`sync`/`mcp` service handles join this struct once their
|
|
/// concrete implementations exist (Phase 3/5/9/10) — several don't have one to
|
|
/// construct yet, so wiring them in early would just be unused plumbing.
|
|
pub struct AppState {
|
|
pub store: Arc<dyn storage::Store>,
|
|
/// The single in-flight recording, if any. Guarded so start/stop/pause are atomic.
|
|
pub session: Mutex<Option<RecordingSession>>,
|
|
}
|
|
|
|
/// Tracks the currently-recording meeting and its worker handles.
|
|
pub struct RecordingSession {
|
|
pub meeting_id: models::MeetingId,
|
|
pub capture: audio::CaptureHandle,
|
|
/// The user's microphone capture (FR-CAP-7), mixed into the transcript
|
|
/// stream. `None` when the mic is disabled in Settings or failed to open —
|
|
/// the meeting proceeds on loopback alone either way.
|
|
pub mic_capture: Option<audio::CaptureHandle>,
|
|
/// Audio retention for this meeting (ADR-0009); toggle-able mid-meeting.
|
|
pub retention: bool,
|
|
pub wav_path: PathBuf,
|
|
pub started_at: std::time::Instant,
|
|
pub transcription_worker: JoinHandle<()>,
|
|
/// Segments produced so far, appended by the same closure that emits
|
|
/// `transcript://segment` — lets `stop_recording` persist the full
|
|
/// transcript without a second round-trip through the event channel.
|
|
pub segments: Arc<StdMutex<Vec<models::TranscriptSegment>>>,
|
|
/// Backend actually in use, updated in place if the worker falls back
|
|
/// (T3.5); model id used for this session (T3.9's "low overhead" preset
|
|
/// can pick a different model than `Settings.whisper_model`).
|
|
pub active_backend: Arc<StdMutex<models::BackendId>>,
|
|
pub model_id: String,
|
|
/// Resolved transcription language (T8.7, FR-TRX-4): `None` = auto.
|
|
/// Set by the transcription worker once the engine loads (to the
|
|
/// request resolved against model capability, e.g. forced "en" for an
|
|
/// English-only model) and updated after every decode to whatever was
|
|
/// actually used/detected — `stop_recording` reads the final value to
|
|
/// persist on the meeting record.
|
|
pub language: Arc<StdMutex<Option<String>>>,
|
|
/// `None` when diarization models aren't installed yet (T4.7) — live
|
|
/// provisional turns and the final post-stop pass are both skipped, same
|
|
/// graceful-degradation treatment as a missing hardware backend (T4.3).
|
|
pub diarizer: Option<Arc<dyn diarization::Diarizer>>,
|
|
/// label ("S1"…) -> user-given display name, settable mid-recording
|
|
/// (T4.4, FR-SPK-2). Never rewritten onto segments (FR-SPK-5); resolved
|
|
/// at render/finalize time instead.
|
|
pub speaker_names: Arc<StdMutex<std::collections::HashMap<String, String>>>,
|
|
}
|
|
|
|
/// Wraps the tray icon so it can be looked up from commands to update its
|
|
/// tooltip on recording state changes (FR-CAP-4).
|
|
pub struct TrayHandle(pub TrayIcon);
|
|
|
|
/// Build state, register commands/events, and run the app.
|
|
pub fn run() {
|
|
tracing_subscriber::fmt().with_env_filter("info").init();
|
|
|
|
let store: Arc<dyn storage::Store> = Arc::new(
|
|
tauri::async_runtime::block_on(storage::SqliteStore::connect())
|
|
.expect("failed to initialize storage (wa.db)"),
|
|
);
|
|
let store_for_setup = store.clone();
|
|
|
|
tauri::Builder::default()
|
|
.plugin(tauri_plugin_dialog::init())
|
|
// In-memory streaming of recordings for the player (FR-REC-5): decrypts
|
|
// on the fly so no plaintext audio is ever written to disk.
|
|
.register_uri_scheme_protocol("waaudio", |_ctx, request| {
|
|
commands::serve_recording(&request)
|
|
})
|
|
.manage(AppState {
|
|
store,
|
|
session: Mutex::new(None),
|
|
})
|
|
.setup(move |app| {
|
|
let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?;
|
|
let tray = tauri::tray::TrayIconBuilder::new()
|
|
.icon(icon)
|
|
.tooltip("WhispAssist — idle")
|
|
.build(app)?;
|
|
app.manage(TrayHandle(tray));
|
|
|
|
// Reminders (Phase 8, T8.6, FR-CAL-5) are Windows-scheduled toasts, not
|
|
// an app-side timer — Windows itself is what's "polling", so this stays
|
|
// within NFR-RES-1. init() just registers the AppUserModelID.
|
|
reminders::init();
|
|
|
|
// NPU package (T3.4 step 2): if an NPU is present but its ONNX model
|
|
// isn't installed yet, fetch it in the background so it's ready
|
|
// without blocking startup. The heavy OpenVINO runtime is staged
|
|
// separately (download_npu_package). Nothing here repeats on a timer
|
|
// (NFR-RES-1) — it runs once and stops when the model is present.
|
|
#[cfg(feature = "npu")]
|
|
if crate::hardware::npu_hardware_present()
|
|
&& !crate::transcription::onnx_models::is_installed(
|
|
crate::transcription::onnx_models::DEFAULT_ONNX_MODEL,
|
|
)
|
|
{
|
|
let app_handle = app.handle().clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
if let Err(e) = commands::download_npu_model(app_handle).await {
|
|
tracing::warn!("background NPU model download failed: {e:?}");
|
|
}
|
|
});
|
|
}
|
|
|
|
// Startup recovery + retention + reminder-reconcile pass (FR-REL-1,
|
|
// FR-STORE-2, FR-CAL-5). Spawned so it never blocks the window from
|
|
// showing (NFR-PERF-4); nothing here repeats on a timer (NFR-RES-1).
|
|
// Sweep away any plaintext playback temp files left by the previous
|
|
// file-based player (T8.8) — playback is now in-memory only.
|
|
commands::cleanup_playback_temp();
|
|
|
|
let store = store_for_setup;
|
|
let startup_app = app.handle().clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
match store.recover_scan().await {
|
|
Ok(recovered) if !recovered.is_empty() => {
|
|
tracing::info!("recovered {} interrupted meeting(s)", recovered.len());
|
|
}
|
|
Ok(_) => {}
|
|
Err(e) => tracing::error!("startup recovery scan failed: {e}"),
|
|
}
|
|
let settings = commands::load_settings();
|
|
let policy = storage::Retention {
|
|
max_age_days: settings.retention_max_age_days,
|
|
max_size_gb: settings.retention_max_size_gb,
|
|
};
|
|
if let Err(e) = store.enforce_retention(policy).await {
|
|
tracing::error!("startup retention enforcement failed: {e}");
|
|
}
|
|
// Re-schedules anything Windows' own toast schedule lost track of
|
|
// (uninstall/reinstall, a changed AppUserModelID, …) — schedule()
|
|
// itself is idempotent (cancels any existing entry for the same
|
|
// action item id first), so this is safe to run every startup.
|
|
match store.list_pending_reminders().await {
|
|
Ok(pending) => {
|
|
for item in &pending {
|
|
if let (Some(id), Some(due_at)) = (&item.id, item.due_at) {
|
|
reminders::schedule(id, &item.text, item.owner.as_deref(), due_at);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => tracing::error!("startup reminder reconcile failed: {e}"),
|
|
}
|
|
|
|
// Resume any sync jobs left pending/failed from a previous run
|
|
// (T9.5). One-shot on startup — no idle timer (NFR-RES-1).
|
|
if commands::load_settings().sync_enabled {
|
|
commands::pump_sync(&startup_app, store.as_ref()).await;
|
|
}
|
|
|
|
// Re-import the last .pst path if the user opted into auto-sync
|
|
// (T6.2). One-shot on startup, same as the sync-job resume above
|
|
// — no idle timer (NFR-RES-1). Re-import is dedup'd by
|
|
// (source, raw_uid), so this just catches up on new/changed events.
|
|
let pst_settings = commands::load_settings();
|
|
if pst_settings.pst_auto_sync {
|
|
if let Some(path) = pst_settings.pst_last_path {
|
|
if let Err(e) =
|
|
commands::import_pst_core(&startup_app, store.as_ref(), path, None)
|
|
.await
|
|
{
|
|
tracing::warn!("startup PST auto-sync failed: {e:?}");
|
|
}
|
|
}
|
|
}
|
|
});
|
|
Ok(())
|
|
})
|
|
.invoke_handler(tauri::generate_handler![
|
|
commands::start_recording,
|
|
commands::stop_recording,
|
|
commands::cancel_recording,
|
|
commands::recording_playback_path,
|
|
commands::pause_recording,
|
|
commands::resume_recording,
|
|
commands::set_recording_retention,
|
|
commands::acknowledge_recording_consent,
|
|
commands::resume_transcription,
|
|
commands::app_info,
|
|
commands::open_url,
|
|
commands::hardware_status,
|
|
commands::list_audio_devices,
|
|
commands::list_input_devices,
|
|
commands::set_preferred_backend,
|
|
commands::list_models,
|
|
commands::list_whisper_languages,
|
|
commands::download_npu_package,
|
|
commands::download_directml_package,
|
|
commands::list_diarization_models,
|
|
commands::download_model,
|
|
commands::remove_model,
|
|
commands::reprocess_transcript,
|
|
commands::list_meetings,
|
|
commands::search,
|
|
commands::set_tags,
|
|
commands::list_tags,
|
|
commands::list_note_templates,
|
|
commands::get_meeting,
|
|
commands::delete_meeting,
|
|
commands::update_notes,
|
|
commands::export_meeting,
|
|
commands::bulk_export_meetings,
|
|
commands::rename_speaker,
|
|
commands::merge_speakers,
|
|
commands::map_speaker_to_participant,
|
|
commands::llm_status,
|
|
commands::set_llm_provider,
|
|
commands::generate_summary,
|
|
commands::confirm_action_items,
|
|
commands::generate_tags,
|
|
commands::llm_setup_suggestions,
|
|
commands::pull_ollama_model,
|
|
commands::import_pst,
|
|
commands::list_calendar_events,
|
|
commands::get_calendar_event,
|
|
commands::attach_meeting_to_event,
|
|
commands::rename_meeting,
|
|
commands::list_sync_targets,
|
|
commands::add_sync_target,
|
|
commands::update_sync_target,
|
|
commands::remove_sync_target,
|
|
commands::test_sync_target,
|
|
commands::set_sync_enabled,
|
|
commands::begin_oauth_link,
|
|
commands::sync_meeting,
|
|
commands::sync_status,
|
|
commands::retry_sync_job,
|
|
commands::create_feature_brief,
|
|
commands::list_feature_briefs,
|
|
commands::get_feature_brief,
|
|
commands::set_brief_exposed,
|
|
commands::mcp_status,
|
|
commands::set_mcp_enabled,
|
|
commands::set_mcp_scope,
|
|
commands::mcp_access_log,
|
|
commands::run_agent,
|
|
commands::create_issue_from_brief,
|
|
commands::get_settings,
|
|
commands::update_settings,
|
|
commands::privacy_self_check,
|
|
commands::vault_status,
|
|
commands::enable_vault,
|
|
commands::unlock_vault,
|
|
commands::lock_vault,
|
|
commands::change_vault_password,
|
|
])
|
|
.run(tauri::generate_context!())
|
|
.expect("error while running WhispAssist");
|
|
}
|
|
|
|
/// Used by `commands.rs` to keep the tray tooltip honest about capture state (FR-CAP-4).
|
|
pub(crate) fn update_tray_tooltip(app: &tauri::AppHandle, text: &str) {
|
|
if let Some(tray) = app.try_state::<TrayHandle>() {
|
|
let _ = tray.0.set_tooltip(Some(text));
|
|
}
|
|
}
|
|
|
|
/// Entry point for `whispassist.exe --mcp-stdio` (FR-MCP-6): serves one MCP
|
|
/// session over this process's own stdin/stdout instead of showing a window,
|
|
/// against the same `wa.db` the GUI instance uses. There is no Tauri
|
|
/// `AppHandle` in this mode, so `mcp://access` events have nowhere to go —
|
|
/// the `mcp_access_log` DB row is still written regardless (FR-MCP-5).
|
|
pub fn run_mcp_stdio() {
|
|
#[cfg(feature = "mcp")]
|
|
{
|
|
// stderr, not stdout: stdout is the MCP JSON-RPC channel.
|
|
let _ = tracing_subscriber::fmt()
|
|
.with_env_filter("info")
|
|
.with_writer(std::io::stderr)
|
|
.try_init();
|
|
let rt = match tokio::runtime::Builder::new_multi_thread()
|
|
.enable_all()
|
|
.build()
|
|
{
|
|
Ok(rt) => rt,
|
|
Err(e) => {
|
|
eprintln!("whispassist --mcp-stdio: failed to start a runtime: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
rt.block_on(async {
|
|
let store: std::sync::Arc<dyn storage::Store> =
|
|
match storage::SqliteStore::connect().await {
|
|
Ok(s) => std::sync::Arc::new(s),
|
|
Err(e) => {
|
|
eprintln!("whispassist --mcp-stdio: failed to open wa.db: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
let handler = mcp::handler::WaMcpHandler::new(store, None);
|
|
if let Err(e) = mcp::stdio_transport::serve_once(handler).await {
|
|
eprintln!("whispassist --mcp-stdio: session ended with an error: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
});
|
|
}
|
|
#[cfg(not(feature = "mcp"))]
|
|
{
|
|
eprintln!("this build was compiled without MCP support (the `mcp` cargo feature is off)");
|
|
std::process::exit(1);
|
|
}
|
|
}
|