745 lines
23 KiB
Rust
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"));
|
|
}
|
|
}
|