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

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);
}
}