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

246 lines
10 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 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,
/// 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,
/// `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())
.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).
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;
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::start_recording,
commands::stop_recording,
commands::pause_recording,
commands::resume_recording,
commands::set_recording_retention,
commands::acknowledge_recording_consent,
commands::resume_transcription,
commands::hardware_status,
commands::set_preferred_backend,
commands::list_models,
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::llm_setup_suggestions,
commands::pull_ollama_model,
commands::import_pst,
commands::list_calendar_events,
commands::get_calendar_event,
commands::attach_meeting_to_event,
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));
}
}