Files
tolaria/src-tauri/src/lib.rs
2026-05-24 12:28:29 +02:00

745 lines
23 KiB
Rust

pub mod ai_agents;
pub mod ai_models;
mod app_icon;
pub mod app_updater;
pub mod claude_cli;
mod claude_invocation;
mod cli_agent_runtime;
pub mod codex_cli;
mod commands;
pub mod frontmatter;
pub mod gemini_cli;
mod gemini_config;
mod gemini_discovery;
pub mod git;
pub mod kiro_cli;
mod kiro_discovery;
#[cfg(any(test, all(desktop, target_os = "linux")))]
mod linux_appimage;
pub mod mcp;
#[cfg(desktop)]
pub mod menu;
pub mod opencode_cli;
mod opencode_config;
mod opencode_discovery;
mod opencode_events;
pub mod pi_cli;
mod pi_config;
mod pi_discovery;
mod pi_events;
pub mod search;
pub mod settings;
pub mod telemetry;
pub mod vault;
pub mod vault_list;
pub mod vault_watcher;
#[cfg(desktop)]
mod window_state;
use std::ffi::OsStr;
use std::process::Command;
#[cfg(desktop)]
use std::path::{Path, PathBuf};
#[cfg(desktop)]
use std::process::Child;
#[cfg(desktop)]
use std::sync::Mutex;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;
pub(crate) fn hidden_command(program: impl AsRef<OsStr>) -> Command {
let mut command = Command::new(program);
suppress_windows_console(&mut command);
command
}
#[cfg(windows)]
fn suppress_windows_console(command: &mut Command) {
use std::os::windows::process::CommandExt;
command.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
fn suppress_windows_console(_command: &mut Command) {}
#[cfg(desktop)]
struct WsBridgeChild(Mutex<Option<Child>>);
#[cfg(desktop)]
struct AllowedAssetScopeRoots(Mutex<Vec<PathBuf>>);
#[cfg(desktop)]
fn log_startup_result(label: &str, result: Result<usize, String>) {
match result {
Ok(n) if n > 0 => log::info!("{}: {} files", label, n),
Err(e) => log::warn!("{}: {}", label, e),
_ => {}
}
}
#[cfg(desktop)]
fn selected_mcp_bridge_vault_paths(vault_list: &vault_list::VaultList) -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(active_vault) = vault_list
.active_vault
.as_deref()
.map(str::trim)
.filter(|path| !path.is_empty())
{
push_unique_mcp_bridge_vault_path(&mut paths, active_vault);
}
for vault in &vault_list.vaults {
if vault.mounted == Some(false) {
continue;
}
push_unique_mcp_bridge_vault_path(&mut paths, &vault.path);
}
paths
}
#[cfg(desktop)]
fn push_unique_mcp_bridge_vault_path(paths: &mut Vec<PathBuf>, path: &str) {
let trimmed = path.trim();
if trimmed.is_empty() {
return;
}
let path = PathBuf::from(trimmed);
if paths.iter().any(|existing| existing == &path) {
return;
}
paths.push(path);
}
#[cfg(desktop)]
fn validate_mcp_bridge_vault_path(vault_path: &Path) -> Result<PathBuf, String> {
let resolved = std::fs::canonicalize(vault_path).map_err(|e| {
format!(
"MCP bridge vault is not available: {} ({e})",
vault_path.display()
)
})?;
if !resolved.is_dir() {
return Err(format!(
"MCP bridge vault is not available: {} is not a directory",
vault_path.display()
));
}
Ok(resolved)
}
#[cfg(desktop)]
fn stop_ws_bridge_child(active_child: &mut Option<Child>) {
if let Some(mut child) = active_child.take() {
let _ = child.kill();
let _ = child.wait();
log::info!("ws-bridge child process stopped");
}
}
#[cfg(desktop)]
pub(crate) fn sync_ws_bridge_for_vault(
app_handle: &tauri::AppHandle,
vault_path: Option<&Path>,
active_vault_paths: &[PathBuf],
) -> Result<&'static str, String> {
use tauri::Manager;
let state: tauri::State<'_, WsBridgeChild> = app_handle.state();
let mut active_child = state
.0
.lock()
.map_err(|_| "Failed to lock ws-bridge state".to_string())?;
let Some(vault_path) = vault_path else {
stop_ws_bridge_child(&mut active_child);
return Ok("stopped");
};
let resolved_vault_path = match validate_mcp_bridge_vault_path(vault_path) {
Ok(path) => path,
Err(e) => {
stop_ws_bridge_child(&mut active_child);
return Err(e);
}
};
stop_ws_bridge_child(&mut active_child);
let resolved_active_vault_paths = active_vault_paths
.iter()
.filter_map(|path| validate_mcp_bridge_vault_path(path).ok())
.collect::<Vec<_>>();
let child =
mcp::spawn_ws_bridge_with_paths(&resolved_vault_path, &resolved_active_vault_paths)?;
*active_child = Some(child);
Ok("started")
}
fn spawn_background_task<F>(thread_name: &'static str, task: F)
where
F: FnOnce() + Send + 'static,
{
if let Err(e) = std::thread::Builder::new()
.name(thread_name.into())
.spawn(task)
{
log::warn!("Failed to start {thread_name}: {e}");
}
}
/// Run startup housekeeping on the legacy default vault (migrate legacy frontmatter, seed configs).
#[cfg(desktop)]
fn run_startup_tasks_for_vault(vault_path: &Path) {
let vp_str = vault_path.to_str().unwrap_or_default();
log_startup_result(
"Migrated is_a to type on startup",
vault::migrate_is_a_to_type(vp_str),
);
// Migrate legacy config/agents.md -> root AGENTS.md (one-time, idempotent)
vault::migrate_agents_md(vp_str);
// Seed AGENTS.md and starter type definitions at vault root if missing
vault::seed_config_files(vp_str);
}
#[cfg(desktop)]
fn spawn_startup_tasks_for_vault_with<F>(vault_path: PathBuf, task: F) -> bool
where
F: FnOnce(PathBuf) + Send + 'static,
{
if !vault_path.is_dir() {
return false;
}
spawn_background_task("tolaria-startup-tasks", move || task(vault_path));
true
}
#[cfg(desktop)]
fn spawn_startup_tasks() {
let Some(vault_path) = dirs::home_dir().map(|h| h.join("Laputa")) else {
return;
};
spawn_startup_tasks_for_vault_with(vault_path, |path| run_startup_tasks_for_vault(&path));
}
#[cfg(desktop)]
fn sync_ws_bridge_for_selected_vault(app_handle: &tauri::AppHandle) {
let vault_paths = match vault_list::load_vault_list() {
Ok(vault_list) => selected_mcp_bridge_vault_paths(&vault_list),
Err(e) => {
log::warn!("Failed to load active vault for ws-bridge startup: {}", e);
Vec::new()
}
};
let Some(vault_path) = vault_paths.first() else {
log::info!("ws-bridge not started: no active vault selected");
return;
};
if let Err(e) = sync_ws_bridge_for_vault(app_handle, Some(vault_path), &vault_paths) {
log::warn!("Failed to start ws-bridge: {}", e);
}
}
#[cfg(desktop)]
fn spawn_initial_ws_bridge_sync(app: &tauri::App) {
let app_handle = app.handle().clone();
spawn_background_task("tolaria-ws-bridge-startup", move || {
#[cfg(all(desktop, target_os = "linux"))]
if linux_appimage::is_running() {
let app_version = app_handle.package_info().version.to_string();
if let Err(e) = mcp::extract_mcp_server_to_stable_dir(&app_version) {
log::warn!("Failed to extract MCP server to stable path: {e}");
}
}
sync_ws_bridge_for_selected_vault(&app_handle);
});
}
fn setup_common_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
app.handle().plugin(tauri_plugin_dialog::init())?;
Ok(())
}
#[cfg(desktop)]
fn setup_desktop_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
setup_macos_webview_shortcut_prevention(app)?;
app.handle()
.plugin(tauri_plugin_updater::Builder::new().build())?;
app.handle().plugin(tauri_plugin_process::init())?;
app.handle().plugin(tauri_plugin_opener::init())?;
if should_use_native_desktop_menu(std::env::consts::OS) {
menu::setup_menu(app)?;
}
setup_custom_window_chrome(app)?;
window_state::restore_main_window_state(app);
show_debug_main_window(app);
Ok(())
}
#[cfg(debug_assertions)]
fn show_debug_main_window(app: &mut tauri::App) {
use tauri::Manager;
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.center();
let _ = window.set_focus();
}
}
#[cfg(not(debug_assertions))]
fn show_debug_main_window(_app: &mut tauri::App) {}
fn should_use_native_desktop_menu(target_os: &str) -> bool {
target_os == "macos"
}
#[cfg(all(desktop, any(target_os = "linux", target_os = "windows")))]
fn setup_custom_window_chrome(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
use tauri::Manager;
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_decorations(false);
}
Ok(())
}
#[cfg(not(all(desktop, any(target_os = "linux", target_os = "windows"))))]
fn setup_custom_window_chrome(_app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
#[cfg(any(test, all(desktop, target_os = "macos")))]
const MACOS_WEBVIEW_RESERVED_COMMAND_KEYS: &[&str] = &["O", "F"];
#[cfg(any(test, all(desktop, target_os = "macos")))]
const MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS: &[&str] = &["L"];
#[cfg(all(desktop, target_os = "macos"))]
fn setup_macos_webview_shortcut_prevention(
app: &mut tauri::App,
) -> Result<(), Box<dyn std::error::Error>> {
use tauri_plugin_prevent_default::ModifierKey::{MetaKey, ShiftKey};
use tauri_plugin_prevent_default::{Flags, KeyboardShortcut};
let mut builder = tauri_plugin_prevent_default::Builder::new().with_flags(Flags::empty());
// WKWebView can swallow some browser-reserved chords before our shared
// renderer shortcut handler sees them. Keep this list narrow and verify
// every addition with native QA.
for key in MACOS_WEBVIEW_RESERVED_COMMAND_KEYS {
builder = builder.shortcut(KeyboardShortcut::with_modifiers(key, &[MetaKey]));
}
for key in MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS {
builder = builder.shortcut(KeyboardShortcut::with_modifiers(key, &[MetaKey, ShiftKey]));
}
app.handle().plugin(builder.build())?;
Ok(())
}
#[cfg(not(all(desktop, target_os = "macos")))]
fn setup_macos_webview_shortcut_prevention(
_app: &mut tauri::App,
) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
fn setup_app(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
setup_common_plugins(app)?;
#[cfg(desktop)]
setup_desktop_plugins(app)?;
if telemetry::init_sentry_from_settings() {
log::info!("Sentry initialized (crash reporting enabled)");
}
#[cfg(desktop)]
{
spawn_startup_tasks();
spawn_initial_ws_bridge_sync(app);
}
Ok(())
}
#[cfg(desktop)]
fn vault_asset_scope_roots(vault_path: &Path) -> Result<Vec<PathBuf>, String> {
let canonical_vault_path = std::fs::canonicalize(vault_path).map_err(|e| {
format!(
"Failed to resolve asset scope for {}: {e}",
vault_path.display()
)
})?;
let mut roots = vec![canonical_vault_path.clone()];
let requested_vault_path = vault_path.to_path_buf();
if requested_vault_path != canonical_vault_path {
roots.push(requested_vault_path);
}
Ok(roots)
}
#[cfg(desktop)]
fn missing_asset_scope_roots(
allowed_roots: &[PathBuf],
requested_roots: &[PathBuf],
) -> Vec<PathBuf> {
requested_roots
.iter()
.filter(|root| !allowed_roots.contains(root))
.cloned()
.collect()
}
#[cfg(desktop)]
pub(crate) fn sync_vault_asset_scope(
app_handle: &tauri::AppHandle,
vault_path: &Path,
) -> Result<(), String> {
use tauri::Manager;
let requested_roots = vault_asset_scope_roots(vault_path)?;
let scope = app_handle.asset_protocol_scope();
let state: tauri::State<'_, AllowedAssetScopeRoots> = app_handle.state();
let mut allowed_roots = state
.0
.lock()
.map_err(|_| "Failed to lock asset scope state".to_string())?;
let roots_to_allow = missing_asset_scope_roots(&allowed_roots, &requested_roots);
for root in &roots_to_allow {
scope
.allow_directory(root, true)
.map_err(|e| format!("Failed to allow asset access for {}: {e}", root.display()))?;
}
allowed_roots.extend(roots_to_allow);
Ok(())
}
macro_rules! app_invoke_handler {
() => {
tauri::generate_handler![
commands::list_vault,
commands::list_vault_folders,
commands::get_note_content,
commands::validate_note_content,
commands::create_note_content,
commands::save_note_content,
commands::update_frontmatter,
commands::delete_frontmatter_property,
commands::rename_note,
commands::rename_note_filename,
commands::move_note_to_folder,
commands::move_note_to_workspace,
commands::auto_rename_untitled,
commands::detect_renames,
commands::update_wikilinks_for_renames,
commands::get_file_history,
commands::get_modified_files,
commands::get_file_diff,
commands::get_file_diff_at_commit,
commands::get_vault_pulse,
commands::git_commit,
commands::get_build_number,
commands::get_last_commit_info,
commands::git_pull,
commands::git_push,
commands::git_remote_status,
commands::git_add_remote,
commands::get_conflict_files,
commands::get_conflict_mode,
commands::git_resolve_conflict,
commands::git_commit_conflict_resolution,
commands::git_discard_file,
commands::is_git_repo,
commands::init_git_repo,
commands::check_claude_cli,
commands::get_ai_agents_status,
commands::get_agent_docs_path,
commands::get_vault_ai_guidance_status,
commands::restore_vault_ai_guidance,
commands::stream_claude_chat,
commands::stream_ai_agent,
commands::stream_ai_model,
commands::save_ai_model_provider_api_key,
commands::delete_ai_model_provider_api_key,
commands::test_ai_model_provider,
commands::reload_vault,
commands::reload_vault_entry,
commands::sync_vault_asset_scope_for_window,
commands::open_vault_file_external,
commands::sync_note_title,
commands::save_image,
commands::copy_image_to_vault,
commands::delete_note,
commands::batch_delete_notes,
commands::batch_delete_notes_async,
commands::migrate_is_a_to_type,
commands::create_vault_folder,
commands::rename_vault_folder,
commands::delete_vault_folder,
commands::batch_archive_notes,
commands::get_settings,
commands::check_for_app_update,
commands::update_menu_state,
commands::update_app_icon,
commands::trigger_menu_command,
commands::update_current_window_min_size,
commands::perform_current_window_titlebar_double_click,
commands::save_settings,
commands::download_and_install_app_update,
commands::load_vault_list,
commands::save_vault_list,
commands::git_clone::clone_git_repo,
commands::search_vault,
commands::create_empty_vault,
commands::create_getting_started_vault,
commands::check_vault_exists,
commands::get_default_vault_path,
commands::register_mcp_tools,
commands::remove_mcp_tools,
commands::check_mcp_status,
commands::get_mcp_config_snippet,
commands::copy_text_to_clipboard,
commands::read_text_from_clipboard,
commands::sync_mcp_bridge_vault,
commands::get_process_memory_snapshot,
commands::repair_vault,
commands::reinit_telemetry,
commands::should_use_external_media_preview,
commands::list_views,
commands::save_view_cmd,
commands::delete_view_cmd,
vault_watcher::start_vault_watcher,
vault_watcher::stop_vault_watcher
]
};
}
fn with_invoke_handler(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
builder.invoke_handler(app_invoke_handler!())
}
#[cfg(desktop)]
fn handle_run_event(app_handle: &tauri::AppHandle, event: &tauri::RunEvent) {
use tauri::Manager;
window_state::handle_run_event(app_handle, event);
if let tauri::RunEvent::Exit = event {
let state: tauri::State<'_, WsBridgeChild> = app_handle.state();
let mut guard = state.0.lock().unwrap();
stop_ws_bridge_child(&mut guard);
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
#[cfg(all(desktop, target_os = "linux"))]
linux_appimage::apply_startup_env_overrides();
let builder = tauri::Builder::default();
#[cfg(desktop)]
let builder = builder
.manage(WsBridgeChild(Mutex::new(None)))
.manage(AllowedAssetScopeRoots(Mutex::new(Vec::new())))
.manage(window_state::MainWindowFrameState::default())
.manage(vault_watcher::VaultWatcherState::new());
with_invoke_handler(builder)
.setup(setup_app)
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
#[cfg(desktop)]
handle_run_event(app_handle, &event);
});
}
#[cfg(test)]
mod tests {
use super::should_use_native_desktop_menu;
use super::MACOS_WEBVIEW_RESERVED_COMMAND_KEYS;
use super::MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS;
#[cfg(desktop)]
use super::{
missing_asset_scope_roots, selected_mcp_bridge_vault_paths,
spawn_startup_tasks_for_vault_with, validate_mcp_bridge_vault_path,
};
#[cfg(desktop)]
use crate::vault_list::{VaultEntry, VaultList};
#[cfg(desktop)]
use std::path::PathBuf;
#[cfg(all(desktop, unix))]
use super::vault_asset_scope_roots;
#[test]
fn macos_webview_shortcut_prevention_includes_ai_panel_shortcut() {
assert_eq!(MACOS_WEBVIEW_RESERVED_COMMAND_KEYS, ["O", "F"]);
assert_eq!(MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS, ["L"]);
}
#[cfg(desktop)]
#[test]
fn selected_mcp_bridge_vault_paths_puts_persisted_active_vault_first() {
let list = VaultList {
vaults: vec![
VaultEntry {
label: "Secondary".to_string(),
path: "/tmp/Secondary Vault".to_string(),
mounted: Some(true),
..VaultEntry::default()
},
VaultEntry {
label: "Hidden".to_string(),
path: "/tmp/Hidden Vault".to_string(),
mounted: Some(false),
..VaultEntry::default()
},
VaultEntry {
label: "Selected".to_string(),
path: "/tmp/Selected Vault".to_string(),
mounted: Some(true),
..VaultEntry::default()
},
],
active_vault: Some("/tmp/Selected Vault".to_string()),
default_workspace_path: None,
hidden_defaults: Vec::new(),
};
assert_eq!(
selected_mcp_bridge_vault_paths(&list),
vec![
PathBuf::from("/tmp/Selected Vault"),
PathBuf::from("/tmp/Secondary Vault"),
]
);
}
#[cfg(desktop)]
#[test]
fn selected_mcp_bridge_vault_paths_ignores_blank_active_vault() {
let list = VaultList {
vaults: Vec::new(),
active_vault: Some(" ".to_string()),
default_workspace_path: None,
hidden_defaults: Vec::new(),
};
assert!(selected_mcp_bridge_vault_paths(&list).is_empty());
}
#[cfg(desktop)]
#[test]
fn startup_tasks_skip_missing_legacy_vault() {
let missing_vault = tempfile::tempdir().unwrap().path().join("missing");
let called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let called_from_task = called.clone();
let spawned = spawn_startup_tasks_for_vault_with(missing_vault, move |_| {
called_from_task.store(true, std::sync::atomic::Ordering::SeqCst);
});
assert!(!spawned);
assert!(!called.load(std::sync::atomic::Ordering::SeqCst));
}
#[cfg(desktop)]
#[test]
fn startup_tasks_run_in_background() {
let dir = tempfile::tempdir().unwrap();
let (entered_tx, entered_rx) = std::sync::mpsc::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel();
let spawned = spawn_startup_tasks_for_vault_with(dir.path().to_path_buf(), move |_| {
entered_tx.send(()).unwrap();
release_rx
.recv_timeout(std::time::Duration::from_secs(1))
.unwrap();
});
assert!(spawned);
entered_rx
.recv_timeout(std::time::Duration::from_secs(1))
.unwrap();
release_tx.send(()).unwrap();
}
#[cfg(desktop)]
#[test]
fn validate_mcp_bridge_vault_path_requires_existing_directory() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("Vault With Spaces");
std::fs::create_dir(&vault).unwrap();
let resolved = validate_mcp_bridge_vault_path(&vault).unwrap();
assert_eq!(resolved, vault.canonicalize().unwrap());
let missing = dir.path().join("Missing Vault");
let err = validate_mcp_bridge_vault_path(&missing).unwrap_err();
assert!(err.contains("MCP bridge vault is not available"));
}
#[cfg(all(desktop, unix))]
#[test]
fn vault_asset_scope_roots_include_requested_symlink_path() {
let dir = tempfile::tempdir().unwrap();
let canonical_vault = dir.path().join("Getting Started");
let symlinked_vault = dir.path().join("Symlinked Getting Started");
std::fs::create_dir(&canonical_vault).unwrap();
std::os::unix::fs::symlink(&canonical_vault, &symlinked_vault).unwrap();
let roots = vault_asset_scope_roots(&symlinked_vault).unwrap();
assert_eq!(roots[0], canonical_vault.canonicalize().unwrap());
assert!(roots.contains(&symlinked_vault));
}
#[cfg(desktop)]
#[test]
fn missing_asset_scope_roots_keeps_previously_allowed_vaults() {
let vault_a = PathBuf::from("/vault-a");
let vault_b = PathBuf::from("/vault-b");
let allowed_roots = vec![vault_a.clone()];
assert_eq!(
missing_asset_scope_roots(&allowed_roots, std::slice::from_ref(&vault_b)),
vec![vault_b]
);
assert!(
missing_asset_scope_roots(&allowed_roots, std::slice::from_ref(&vault_a)).is_empty()
);
}
#[test]
fn native_desktop_menu_is_macos_only() {
assert!(should_use_native_desktop_menu("macos"));
assert!(!should_use_native_desktop_menu("windows"));
assert!(!should_use_native_desktop_menu("linux"));
}
}