//! 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 deploy; pub mod diarization; pub mod error; pub mod hardware; pub mod llm; pub mod mcp; pub mod media; 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::menu::{Menu, MenuItem}; use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}; 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, /// The single in-flight recording, if any. Guarded so start/stop/pause are atomic. pub session: Mutex>, } /// 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 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>>, /// 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>, 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>>, /// `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>, /// 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>>, /// A few seconds of raw mic-only audio, captured once early in the /// recording — used at `stop_recording` to voiceprint-match the mic /// against the diarized speaker clusters so the mic speaker can be /// auto-labeled "You" instead of a clustered "S1"/"S2". `None` when the /// mic is disabled (same conditions as `mic_capture`). pub mic_voice_sample: Option>, /// `audio.wav` channel layout for this recording (FR-SPK): `"split"` (mic /// enabled → stereo mic-left/loopback-right) or `"summed"` (mic off). Passed /// to `finalize_meeting` so playback/diarization/export can branch on it. pub audio_layout: &'static str, /// Live notes redesign: raw user-authored notes accumulated *during* the /// recording (freeform text + per-moment annotations) — see /// `models::ManualNotes`. Mutated by `update_live_notes`/`set_segment_note` /// and write-through persisted to `manual_notes.json` on every edit (crash /// safety, same spirit as T2.8 recovery); folded into the final `notes.md` /// at `stop_recording` via `notes::MarkdownNotes::merge`. pub manual_notes: Arc>, } /// 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); /// Show a blocking native error dialog and exit non-zero. Used for failures /// that happen before the Tauri app (and its window) exist, where a silent /// `panic=abort` fast-fail would otherwise close with no explanation. Exits /// cleanly (code 1) rather than aborting so the OS reports no crash. fn fatal_startup_error(body: &str) -> ! { tracing::error!("fatal startup error: {body}"); #[cfg(windows)] { use windows::core::PCWSTR; use windows::Win32::UI::WindowsAndMessaging::{ MessageBoxW, MB_ICONERROR, MB_OK, MB_SYSTEMMODAL, }; // Null-terminated UTF-16 for the Win32 wide API. let to_wide = |s: &str| { s.encode_utf16() .chain(std::iter::once(0)) .collect::>() }; let text = to_wide(body); let caption = to_wide("WhispAssist can't start"); // SAFETY: both buffers are null-terminated and outlive the call. unsafe { MessageBoxW( None, PCWSTR(text.as_ptr()), PCWSTR(caption.as_ptr()), MB_OK | MB_ICONERROR | MB_SYSTEMMODAL, ); } } #[cfg(not(windows))] eprintln!("WhispAssist can't start: {body}"); std::process::exit(1); } /// Build state, register commands/events, and run the app. pub fn run() { tracing_subscriber::fmt().with_env_filter("info").init(); let store: Arc = match tauri::async_runtime::block_on(storage::SqliteStore::connect()) { Ok(s) => Arc::new(s), // A migration checksum mismatch (an edited-after-shipping migration) // or a locked/corrupt file lands here. Tell the user instead of // fast-failing into a silent no-launch. Err(e) => fatal_startup_error(&format!( "WhispAssist couldn't open its database (wa.db):\n\n{e}\n\n\ Your recordings and notes are not deleted. This usually means the \ database is from a newer version of WhispAssist, or a data migration \ is incompatible. Reinstalling the matching version, or moving wa.db \ aside from %LOCALAPPDATA%\\WhispAssist, will let the app start." )), }; let store_for_setup = store.clone(); tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) // Opt-in launch-at-login (NFR-RES-4). The macOS launcher arg is required // by the signature but unused on Windows, where enable/disable writes a // per-user HKCU\...\Run entry (no admin). Off until the user (or an // enterprise deploy file) turns `auto_start` on. .plugin(tauri_plugin_autostart::init( tauri_plugin_autostart::MacosLauncher::LaunchAgent, None, )) // 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| { // Single tray icon (the `trayIcon` in tauri.conf.json was removed so // this is the only one). It carries a Show/Quit menu and, on // left-click, restores the window — the always-available way back // from "close to tray". let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?; let show_item = MenuItem::with_id(app, "show", "Show WhispAssist", true, None::<&str>)?; let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; let menu = Menu::with_items(app, &[&show_item, &quit_item])?; let tray = TrayIconBuilder::new() .icon(icon) .tooltip("WhispAssist — idle") .menu(&menu) .show_menu_on_left_click(false) .on_menu_event(|app, event| match event.id.as_ref() { "show" => show_main_window(app), "quit" => app.exit(0), _ => {} }) .on_tray_icon_event(|tray, event| { if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event { show_main_window(tray.app_handle()); } }) .build(app)?; app.manage(TrayHandle(tray)); // First-run enterprise deploy seeding (deploy.rs): if no settings.json // exists yet and an admin dropped a wa-defaults.ini, seed settings once // and optionally fetch the configured model in the background. One-shot // — guarded by the settings file's absence, so it never re-runs and adds // nothing to idle cost (NFR-RES-1). if !crate::paths::settings_path().exists() { if let Some((seeded, model_to_download)) = deploy::seed_settings_from_defaults() { match commands::save_settings(&seeded) { Ok(()) => { tracing::info!("seeded settings.json from wa-defaults.ini"); if let Some(id) = model_to_download { let app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { if let Err(e) = commands::download_model( app_handle, commands::DownloadModelArgs { kind: "whisper".into(), id, }, ) .await { tracing::warn!( "deploy auto-download of model failed: {e:?}" ); } }); } } Err(e) => { tracing::error!( "first-run deploy seeding failed to write settings: {e:?}" ) } } } } // Reconcile launch-at-login with the persisted preference (NFR-RES-4): // if the user opted in but the OS entry is missing (e.g. after a // reinstall or a deploy file that set auto_start), restore it. One-shot. { use tauri_plugin_autostart::ManagerExt; let manager = app.autolaunch(); if commands::load_settings().auto_start && !manager.is_enabled().unwrap_or(false) { if let Err(e) = manager.enable() { tracing::warn!("failed to restore auto-start entry: {e}"); } } } // 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, pst_settings.pst_import_range_days, ) .await { tracing::warn!("startup PST auto-sync failed: {e:?}"); } } } }); Ok(()) }) // Close to tray (keep running in background): when the setting is on, // the window X hides instead of quitting; the tray "Quit" is the real // exit. Off → default behavior (closing the window quits the app). .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { if commands::load_settings().close_to_tray { api.prevent_close(); let _ = window.hide(); } } }) .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::toggle_microphone_mute, commands::set_recording_retention, commands::acknowledge_recording_consent, commands::update_live_notes, commands::set_segment_note, 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::set_auto_start, commands::monitor_audio_level, commands::stress_test_hardware, 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::import_media, 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::import_meeting_bundle, 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::enhance_notes, commands::llm_setup_suggestions, commands::pull_ollama_model, commands::import_pst, commands::cleanup_calendar_events, commands::list_calendar_events, commands::get_calendar_event, commands::attach_meeting_to_event, commands::begin_graph_calendar_link, commands::import_graph_calendar, commands::disconnect_graph_calendar, 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, commands::list_unencrypted_meetings, commands::encrypt_meeting, ]) .run(tauri::generate_context!()) .expect("error while running WhispAssist"); } /// Restore the main window from the tray (show + unminimize + focus). Shared by /// the tray left-click and the "Show WhispAssist" menu item. fn show_main_window(app: &tauri::AppHandle) { if let Some(w) = app.get_webview_window("main") { let _ = w.show(); let _ = w.unminimize(); let _ = w.set_focus(); } } /// 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::() { 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 = 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); } }