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

527 lines
24 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 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<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>>>,
/// 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<Arc<audio::VoiceSample>>,
/// `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<StdMutex<models::ManualNotes>>,
}
/// 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::<Vec<u16>>()
};
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<dyn storage::Store> =
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::<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);
}
}