forked from boos/BoosAPI
feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as a monorepo subdirectory under desktop/. Source only — node_modules, dist, Cargo target and the upstream .git history are excluded. desktop/ keeps its own independent Cargo workspace (desktop/src-tauri with crates/*); it is not part of the backend boosapi crate, so a `cargo check` at the metapi root still compiles only the backend. Build the desktop app from desktop/src-tauri per desktop/README.md. Note: app icons are still placeholder assets pending the final BoosAPI logo. The updater signing keys live outside the repo and are not included.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
use crate::AppState;
|
||||
use boosapi_core::types::*;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_artifacts(
|
||||
state: State<'_, AppState>,
|
||||
conversation_id: String,
|
||||
) -> Result<Vec<Artifact>, String> {
|
||||
boosapi_core::repo::artifact::list_artifacts(&state.sea_db, &conversation_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_artifact(
|
||||
state: State<'_, AppState>,
|
||||
input: CreateArtifactInput,
|
||||
) -> Result<Artifact, String> {
|
||||
boosapi_core::repo::artifact::create_artifact(&state.sea_db, &input)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_artifact(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
input: UpdateArtifactInput,
|
||||
) -> Result<Artifact, String> {
|
||||
boosapi_core::repo::artifact::update_artifact(&state.sea_db, &id, &input)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_artifact(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||
boosapi_core::repo::artifact::delete_artifact(&state.sea_db, &id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
use crate::AppState;
|
||||
use boosapi_core::repo::backup;
|
||||
use boosapi_core::repo::settings::get_settings;
|
||||
use boosapi_core::types::*;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_backups(state: State<'_, AppState>) -> Result<Vec<BackupManifest>, String> {
|
||||
backup::list_backups(&state.sea_db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_backup(
|
||||
state: State<'_, AppState>,
|
||||
format: String,
|
||||
) -> Result<BackupManifest, String> {
|
||||
let settings = get_settings(&state.sea_db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let decoded_backup_dir = boosapi_core::path_vars::decode_path_opt(&settings.backup_dir);
|
||||
let backup_dir = backup::resolve_backup_dir(decoded_backup_dir.as_deref(), &state.app_data_dir);
|
||||
backup::create_backup(&state.sea_db, &format, &backup_dir)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restore_backup(
|
||||
app: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
backup_id: String,
|
||||
) -> Result<(), String> {
|
||||
let manifest = backup::get_backup(&state.sea_db, &backup_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if manifest.version != "sqlite" {
|
||||
return Err("Only SQLite backups can be restored directly".to_string());
|
||||
}
|
||||
|
||||
let backup_path = manifest.file_path.ok_or("Backup file path not available")?;
|
||||
let db_path = state
|
||||
.db_path
|
||||
.strip_prefix("sqlite:")
|
||||
.unwrap_or(&state.db_path);
|
||||
backup::restore_sqlite_backup(&backup_path, db_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Remove stale WAL/SHM files so SQLite doesn't replay an incompatible journal on restart
|
||||
let _ = std::fs::remove_file(format!("{}-wal", db_path));
|
||||
let _ = std::fs::remove_file(format!("{}-shm", db_path));
|
||||
|
||||
// Auto-restart to pick up the restored database
|
||||
app.restart();
|
||||
|
||||
#[allow(unreachable_code)]
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_backup(state: State<'_, AppState>, backup_id: String) -> Result<(), String> {
|
||||
backup::delete_backup(&state.sea_db, &backup_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn batch_delete_backups(
|
||||
state: State<'_, AppState>,
|
||||
backup_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
backup::batch_delete_backups(&state.sea_db, &backup_ids)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_backup_settings(state: State<'_, AppState>) -> Result<AutoBackupSettings, String> {
|
||||
let settings = get_settings(&state.sea_db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let decoded_backup_dir = boosapi_core::path_vars::decode_path_opt(&settings.backup_dir);
|
||||
let default_dir = backup::resolve_backup_dir(None, &state.app_data_dir);
|
||||
Ok(AutoBackupSettings {
|
||||
enabled: settings.auto_backup_enabled,
|
||||
interval_hours: settings.auto_backup_interval_hours,
|
||||
max_count: settings.auto_backup_max_count,
|
||||
backup_dir: Some(
|
||||
decoded_backup_dir.unwrap_or_else(|| default_dir.to_string_lossy().to_string()),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_backup_settings(
|
||||
state: State<'_, AppState>,
|
||||
backup_settings: AutoBackupSettings,
|
||||
) -> Result<(), String> {
|
||||
let mut settings = get_settings(&state.sea_db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
settings.auto_backup_enabled = backup_settings.enabled;
|
||||
settings.auto_backup_interval_hours = backup_settings.interval_hours;
|
||||
settings.auto_backup_max_count = backup_settings.max_count;
|
||||
settings.backup_dir = boosapi_core::path_vars::encode_path_opt(&backup_settings.backup_dir);
|
||||
|
||||
boosapi_core::repo::settings::save_settings(&state.sea_db, &settings)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Restart scheduler with new settings
|
||||
restart_auto_backup(
|
||||
&state.auto_backup_handle,
|
||||
&state.sea_db,
|
||||
&state.app_data_dir,
|
||||
&backup_settings,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start or restart the auto-backup scheduler
|
||||
async fn restart_auto_backup(
|
||||
handle: &Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
|
||||
db: &DatabaseConnection,
|
||||
app_data_dir: &PathBuf,
|
||||
settings: &AutoBackupSettings,
|
||||
) {
|
||||
let mut guard = handle.lock().await;
|
||||
|
||||
// Stop existing scheduler
|
||||
if let Some(h) = guard.take() {
|
||||
h.abort();
|
||||
}
|
||||
|
||||
if !settings.enabled || settings.interval_hours == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let db = db.clone();
|
||||
let app_dir = app_data_dir.clone();
|
||||
let interval_hours = settings.interval_hours;
|
||||
let max_count = settings.max_count;
|
||||
let interval_secs = interval_hours as u64 * 3600;
|
||||
|
||||
// Calculate initial delay: catch up if overdue
|
||||
let initial_delay_secs = match backup::list_backups(&db).await {
|
||||
Ok(backups) if !backups.is_empty() => {
|
||||
let last_ts = &backups[0].created_at;
|
||||
if let Ok(last_time) =
|
||||
chrono::NaiveDateTime::parse_from_str(last_ts, "%Y-%m-%d %H:%M:%S")
|
||||
{
|
||||
let elapsed = chrono::Utc::now()
|
||||
.naive_utc()
|
||||
.signed_duration_since(last_time)
|
||||
.num_seconds()
|
||||
.max(0) as u64;
|
||||
if elapsed >= interval_secs {
|
||||
0
|
||||
} else {
|
||||
interval_secs - elapsed
|
||||
}
|
||||
} else {
|
||||
interval_secs
|
||||
}
|
||||
}
|
||||
_ => interval_secs,
|
||||
};
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
let interval = std::time::Duration::from_secs(interval_secs);
|
||||
// Initial wait (may be shorter if overdue)
|
||||
tokio::time::sleep(std::time::Duration::from_secs(initial_delay_secs)).await;
|
||||
loop {
|
||||
// Read current settings to get backup_dir
|
||||
let backup_dir = match get_settings(&db).await {
|
||||
Ok(s) => {
|
||||
let decoded = boosapi_core::path_vars::decode_path_opt(&s.backup_dir);
|
||||
backup::resolve_backup_dir(decoded.as_deref(), &app_dir)
|
||||
}
|
||||
Err(_) => backup::resolve_backup_dir(None, &app_dir),
|
||||
};
|
||||
|
||||
// Create auto backup (SQLite format for speed)
|
||||
if let Err(e) = backup::create_backup(&db, "sqlite", &backup_dir).await {
|
||||
tracing::warn!("Auto-backup failed: {}", e);
|
||||
} else {
|
||||
tracing::info!("Auto-backup created successfully");
|
||||
// Cleanup old backups
|
||||
if let Err(e) = backup::cleanup_old_backups(&db, max_count).await {
|
||||
tracing::warn!("Auto-backup cleanup failed: {}", e);
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
});
|
||||
|
||||
*guard = Some(task);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use crate::AppState;
|
||||
use boosapi_core::types::*;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_branches(
|
||||
state: State<'_, AppState>,
|
||||
conversation_id: String,
|
||||
) -> Result<Vec<ConversationBranch>, String> {
|
||||
boosapi_core::repo::conversation_branch::list_branches(&state.sea_db, &conversation_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fork_conversation(
|
||||
state: State<'_, AppState>,
|
||||
conversation_id: String,
|
||||
message_id: String,
|
||||
) -> Result<ConversationBranch, String> {
|
||||
boosapi_core::repo::conversation_branch::create_branch(
|
||||
&state.sea_db,
|
||||
&conversation_id,
|
||||
&message_id,
|
||||
"Branch",
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn compare_branches(
|
||||
_state: State<'_, AppState>,
|
||||
branch_a: String,
|
||||
branch_b: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
Ok(serde_json::json!({
|
||||
"branch_a": branch_a,
|
||||
"branch_b": branch_b,
|
||||
"differences": []
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_workspace_snapshot(
|
||||
_state: State<'_, AppState>,
|
||||
conversation_id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
Ok(serde_json::json!({
|
||||
"conversation_id": conversation_id,
|
||||
"context_sources": [],
|
||||
"active_tools": [],
|
||||
"knowledge_bindings": [],
|
||||
"memory_policy": null,
|
||||
"search_policy": null,
|
||||
"artifacts": [],
|
||||
"branches": []
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_workspace_snapshot(
|
||||
_state: State<'_, AppState>,
|
||||
_conversation_id: String,
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
use crate::AppState;
|
||||
use boosapi_core::repo::chatgpt_import::{ChatGptImportResult, ChatGptImportSummary};
|
||||
use std::path::PathBuf;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn scan_chatgpt_import(
|
||||
state: State<'_, AppState>,
|
||||
path: String,
|
||||
) -> Result<ChatGptImportSummary, String> {
|
||||
boosapi_core::repo::chatgpt_import::scan_chatgpt_import_from_path(
|
||||
&state.sea_db,
|
||||
&PathBuf::from(path),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn import_chatgpt_export(
|
||||
state: State<'_, AppState>,
|
||||
path: String,
|
||||
) -> Result<ChatGptImportResult, String> {
|
||||
boosapi_core::repo::chatgpt_import::import_chatgpt_export_from_path(
|
||||
&state.sea_db,
|
||||
&PathBuf::from(path),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
use crate::AppState;
|
||||
use boosapi_core::repo::cherry_import::{
|
||||
CherryStudioImportOptions, CherryStudioImportResult, CherryStudioImportSummary,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn scan_cherry_studio_import(
|
||||
state: State<'_, AppState>,
|
||||
path: String,
|
||||
) -> Result<CherryStudioImportSummary, String> {
|
||||
boosapi_core::repo::cherry_import::scan_cherry_studio_import_from_path(
|
||||
&state.sea_db,
|
||||
&PathBuf::from(path),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn import_cherry_studio_backup(
|
||||
state: State<'_, AppState>,
|
||||
path: String,
|
||||
options: CherryStudioImportOptions,
|
||||
) -> Result<CherryStudioImportResult, String> {
|
||||
boosapi_core::repo::cherry_import::import_cherry_studio_backup_from_path(
|
||||
&state.sea_db,
|
||||
&state.master_key,
|
||||
&PathBuf::from(path),
|
||||
options,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use crate::AppState;
|
||||
use boosapi_core::types::*;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_context_sources(
|
||||
state: State<'_, AppState>,
|
||||
conversation_id: String,
|
||||
) -> Result<Vec<ContextSource>, String> {
|
||||
boosapi_core::repo::context_source::list_context_sources(&state.sea_db, &conversation_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_context_source(
|
||||
state: State<'_, AppState>,
|
||||
input: CreateContextSourceInput,
|
||||
) -> Result<ContextSource, String> {
|
||||
boosapi_core::repo::context_source::add_context_source(&state.sea_db, &input)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_context_source(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||
boosapi_core::repo::context_source::remove_context_source(&state.sea_db, &id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn toggle_context_source(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<ContextSource, String> {
|
||||
boosapi_core::repo::context_source::toggle_context_source(&state.sea_db, &id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use crate::AppState;
|
||||
use boosapi_core::types::*;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_conversation_categories(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<ConversationCategory>, String> {
|
||||
boosapi_core::repo::conversation_category::list_conversation_categories(&state.sea_db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_conversation_category(
|
||||
state: State<'_, AppState>,
|
||||
input: CreateConversationCategoryInput,
|
||||
) -> Result<ConversationCategory, String> {
|
||||
boosapi_core::repo::conversation_category::create_conversation_category(&state.sea_db, input)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_conversation_category(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
input: UpdateConversationCategoryInput,
|
||||
) -> Result<ConversationCategory, String> {
|
||||
boosapi_core::repo::conversation_category::update_conversation_category(&state.sea_db, &id, input)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_conversation_category(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
boosapi_core::repo::conversation_category::delete_conversation_category(&state.sea_db, &id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reorder_conversation_categories(
|
||||
state: State<'_, AppState>,
|
||||
category_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
boosapi_core::repo::conversation_category::reorder_conversation_categories(
|
||||
&state.sea_db,
|
||||
&category_ids,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_conversation_category_collapsed(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
collapsed: bool,
|
||||
) -> Result<(), String> {
|
||||
boosapi_core::repo::conversation_category::set_conversation_category_collapsed(
|
||||
&state.sea_db,
|
||||
&id,
|
||||
collapsed,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,176 @@
|
||||
use crate::AppState;
|
||||
use std::sync::atomic::Ordering;
|
||||
use tauri::Manager;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn minimize_window(window: tauri::Window) -> Result<(), String> {
|
||||
crate::window_lifecycle::minimize_main_window(window)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn toggle_maximize_window(window: tauri::Window) -> Result<(), String> {
|
||||
if window.is_maximized().map_err(|e| e.to_string())? {
|
||||
window.unmaximize().map_err(|e| e.to_string())
|
||||
} else {
|
||||
window.maximize().map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_always_on_top(window: tauri::Window, enabled: bool) -> Result<(), String> {
|
||||
window.set_always_on_top(enabled).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_close_to_tray(app: tauri::AppHandle, enabled: bool) -> Result<(), String> {
|
||||
let state = app.state::<AppState>();
|
||||
state.close_to_tray.store(enabled, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_release_webview_on_tray(
|
||||
app: tauri::AppHandle,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
let state = app.state::<AppState>();
|
||||
state
|
||||
.release_webview_on_tray
|
||||
.store(enabled, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn apply_startup_settings(
|
||||
window: tauri::Window,
|
||||
app: tauri::AppHandle,
|
||||
always_on_top: bool,
|
||||
close_to_tray: bool,
|
||||
release_webview_on_tray: bool,
|
||||
) -> Result<(), String> {
|
||||
window
|
||||
.set_always_on_top(always_on_top)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let state = app.state::<AppState>();
|
||||
state.close_to_tray.store(close_to_tray, Ordering::Relaxed);
|
||||
state
|
||||
.release_webview_on_tray
|
||||
.store(release_webview_on_tray, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn force_quit(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let state = app.state::<AppState>();
|
||||
state.is_quitting.store(true, Ordering::Relaxed);
|
||||
app.exit(0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_desktop_capabilities() -> Result<serde_json::Value, String> {
|
||||
Ok(serde_json::json!([
|
||||
{ "key": "tray", "supported": true },
|
||||
{ "key": "global_shortcut", "supported": true },
|
||||
{ "key": "protocol_handler", "supported": true },
|
||||
{ "key": "mini_window", "supported": true },
|
||||
{ "key": "notification", "supported": true },
|
||||
{
|
||||
"key": "devtools_context_menu",
|
||||
"supported": crate::startup_diagnostics::devtools_context_menu_enabled()
|
||||
}
|
||||
]))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_desktop_notification(_title: String, _body: String) -> Result<(), String> {
|
||||
// Placeholder — real notification via tauri notification plugin
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_window_state() -> Result<serde_json::Value, String> {
|
||||
Ok(serde_json::json!({
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"maximized": false,
|
||||
"visible": true
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_devtools(webview_window: tauri::WebviewWindow) -> Result<(), String> {
|
||||
tracing::info!(
|
||||
window = webview_window.label(),
|
||||
devtools_context_menu_enabled = crate::startup_diagnostics::devtools_context_menu_enabled(),
|
||||
"Opening BoosApi WebView devtools"
|
||||
);
|
||||
webview_window.open_devtools();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn write_diagnostic_log(level: String, message: String) -> Result<(), String> {
|
||||
let message = truncate_diagnostic_message(&message);
|
||||
match level.trim().to_ascii_lowercase().as_str() {
|
||||
"trace" => tracing::trace!(target: "boosapi_frontend", "{}", message),
|
||||
"debug" => tracing::debug!(target: "boosapi_frontend", "{}", message),
|
||||
"warn" | "warning" => tracing::warn!(target: "boosapi_frontend", "{}", message),
|
||||
"error" => tracing::error!(target: "boosapi_frontend", "{}", message),
|
||||
_ => tracing::info!(target: "boosapi_frontend", "{}", message),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn truncate_diagnostic_message(message: &str) -> String {
|
||||
const MAX_DIAGNOSTIC_MESSAGE_LEN: usize = 8 * 1024;
|
||||
let mut result = String::with_capacity(message.len().min(MAX_DIAGNOSTIC_MESSAGE_LEN));
|
||||
for ch in message.chars() {
|
||||
if result.len() + ch.len_utf8() > MAX_DIAGNOSTIC_MESSAGE_LEN {
|
||||
result.push_str("...<truncated>");
|
||||
break;
|
||||
}
|
||||
result.push(ch);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn test_proxy(
|
||||
proxy_type: String,
|
||||
proxy_address: String,
|
||||
proxy_port: u16,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
use std::time::Instant;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
let addr = format!("{}:{}", proxy_address, proxy_port);
|
||||
let start = Instant::now();
|
||||
|
||||
match timeout(Duration::from_secs(5), TcpStream::connect(&addr)).await {
|
||||
Ok(Ok(_stream)) => {
|
||||
let latency = start.elapsed().as_millis();
|
||||
// If it's an HTTP proxy, try a minimal HTTP CONNECT to verify
|
||||
if proxy_type == "http" {
|
||||
Ok(serde_json::json!({ "ok": true, "latency_ms": latency }))
|
||||
} else {
|
||||
Ok(serde_json::json!({ "ok": true, "latency_ms": latency }))
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => Ok(serde_json::json!({ "ok": false, "error": e.to_string() })),
|
||||
Err(_) => Ok(serde_json::json!({ "ok": false, "error": "Connection timed out (5s)" })),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_system_fonts() -> Result<Vec<String>, String> {
|
||||
tokio::task::spawn_blocking(|| {
|
||||
let source = font_kit::source::SystemSource::new();
|
||||
let mut families = source.all_families().map_err(|e| e.to_string())?;
|
||||
families.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
|
||||
Ok(families)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
fn file_cleanup_lock() -> &'static tokio::sync::Mutex<()> {
|
||||
static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
|
||||
}
|
||||
|
||||
pub async fn delete_attachment_reference(
|
||||
db: &DatabaseConnection,
|
||||
file_store: &boosapi_core::file_store::FileStore,
|
||||
record_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let _guard = file_cleanup_lock().lock().await;
|
||||
|
||||
let file = boosapi_core::repo::stored_file::get_stored_file(db, record_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
boosapi_core::repo::stored_file::delete_stored_file(db, record_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let remaining_refs =
|
||||
boosapi_core::repo::stored_file::count_stored_files_with_storage_path(db, &file.storage_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if remaining_refs == 0 {
|
||||
file_store.delete_file(&file.storage_path).map_err(|e| {
|
||||
format!(
|
||||
"Removed file record but failed to delete backing file {}: {}",
|
||||
file.storage_path, e
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
use crate::AppState;
|
||||
use boosapi_core::repo::stored_file::StoredFile;
|
||||
use serde::Serialize;
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
use tauri::State;
|
||||
|
||||
const MAX_REMOTE_IMAGE_BYTES: u64 = 25 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteImageResponse {
|
||||
pub data: String,
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
fn validate_remote_image_url(url: &str) -> Result<reqwest::Url, String> {
|
||||
let parsed = reqwest::Url::parse(url).map_err(|e| format!("Invalid image URL: {e}"))?;
|
||||
match parsed.scheme() {
|
||||
"http" | "https" => {}
|
||||
scheme => return Err(format!("Unsupported image URL scheme: {scheme}")),
|
||||
}
|
||||
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| "Image URL must include a host".to_string())?;
|
||||
if host.eq_ignore_ascii_case("localhost") {
|
||||
return Err("Localhost image URLs are not supported".to_string());
|
||||
}
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
match ip {
|
||||
IpAddr::V4(ip) if ip.is_loopback() || ip.is_private() || ip.is_link_local() => {
|
||||
return Err("Private network image URLs are not supported".to_string());
|
||||
}
|
||||
IpAddr::V6(ip) if ip.is_loopback() || ip.is_unique_local() || ip.is_unspecified() => {
|
||||
return Err("Private network image URLs are not supported".to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn normalize_image_mime(raw: Option<&reqwest::header::HeaderValue>) -> Result<String, String> {
|
||||
let raw = raw
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.ok_or_else(|| "Remote image response is missing Content-Type".to_string())?;
|
||||
let mime = raw
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if !mime.starts_with("image/") {
|
||||
return Err(format!("Remote URL did not return an image: {mime}"));
|
||||
}
|
||||
Ok(mime)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn upload_file(
|
||||
state: State<'_, AppState>,
|
||||
data: String,
|
||||
file_name: String,
|
||||
mime_type: String,
|
||||
conversation_id: Option<String>,
|
||||
) -> Result<StoredFile, String> {
|
||||
use base64::Engine;
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(&data)
|
||||
.map_err(|e| format!("Invalid base64: {}", e))?;
|
||||
|
||||
boosapi_core::storage_paths::ensure_documents_dirs()
|
||||
.map_err(|e| format!("Failed to ensure documents dirs: {}", e))?;
|
||||
let file_store = boosapi_core::file_store::FileStore::new();
|
||||
|
||||
let saved = file_store
|
||||
.save_file(&bytes, &file_name, &mime_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let id = boosapi_core::utils::gen_id();
|
||||
let stored = boosapi_core::repo::stored_file::create_stored_file(
|
||||
&state.sea_db,
|
||||
&id,
|
||||
&saved.hash,
|
||||
&file_name,
|
||||
&mime_type,
|
||||
saved.size_bytes,
|
||||
&saved.storage_path,
|
||||
conversation_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(stored)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_file(state: State<'_, AppState>, file_id: String) -> Result<String, String> {
|
||||
use base64::Engine;
|
||||
let file = boosapi_core::repo::stored_file::get_stored_file(&state.sea_db, &file_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let file_store = boosapi_core::file_store::FileStore::new();
|
||||
|
||||
let data = file_store
|
||||
.read_file(&file.storage_path)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(&data))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fetch_remote_image(url: String) -> Result<RemoteImageResponse, String> {
|
||||
use base64::Engine;
|
||||
use reqwest::header::{CONTENT_TYPE, USER_AGENT};
|
||||
|
||||
let parsed = validate_remote_image_url(&url)?;
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.redirect(reqwest::redirect::Policy::limited(5))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create image download client: {e}"))?;
|
||||
|
||||
let response = client
|
||||
.get(parsed)
|
||||
.header(USER_AGENT, "BoosApi/remote-image-fetch")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download image: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to download image: HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(len) = response.content_length() {
|
||||
if len > MAX_REMOTE_IMAGE_BYTES {
|
||||
return Err(format!("Remote image is too large: {len} bytes"));
|
||||
}
|
||||
}
|
||||
|
||||
let mime_type = normalize_image_mime(response.headers().get(CONTENT_TYPE))?;
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read remote image: {e}"))?;
|
||||
if bytes.len() as u64 > MAX_REMOTE_IMAGE_BYTES {
|
||||
return Err(format!("Remote image is too large: {} bytes", bytes.len()));
|
||||
}
|
||||
|
||||
Ok(RemoteImageResponse {
|
||||
data: base64::engine::general_purpose::STANDARD.encode(&bytes),
|
||||
mime_type,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_files(
|
||||
state: State<'_, AppState>,
|
||||
conversation_id: String,
|
||||
) -> Result<Vec<StoredFile>, String> {
|
||||
boosapi_core::repo::stored_file::list_stored_files_by_conversation(
|
||||
&state.sea_db,
|
||||
&conversation_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_file(state: State<'_, AppState>, file_id: String) -> Result<(), String> {
|
||||
let file_store = boosapi_core::file_store::FileStore::new();
|
||||
super::file_cleanup::delete_attachment_reference(&state.sea_db, &file_store, &file_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validate_remote_image_url_accepts_http_and_https() {
|
||||
assert!(validate_remote_image_url("https://example.com/image.png").is_ok());
|
||||
assert!(validate_remote_image_url("http://example.com/image.png").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_remote_image_url_rejects_local_or_non_http_sources() {
|
||||
assert!(validate_remote_image_url("data:image/png;base64,aGVsbG8=").is_err());
|
||||
assert!(validate_remote_image_url("file:///tmp/image.png").is_err());
|
||||
assert!(validate_remote_image_url("http://localhost/image.png").is_err());
|
||||
assert!(validate_remote_image_url("http://127.0.0.1/image.png").is_err());
|
||||
assert!(validate_remote_image_url("http://192.168.1.1/image.png").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_image_mime_requires_image_content_type() {
|
||||
let png = reqwest::header::HeaderValue::from_static("image/png; charset=utf-8");
|
||||
assert_eq!(normalize_image_mime(Some(&png)).unwrap(), "image/png");
|
||||
|
||||
let json = reqwest::header::HeaderValue::from_static("application/json");
|
||||
assert!(normalize_image_mime(Some(&json)).is_err());
|
||||
assert!(normalize_image_mime(None).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,929 @@
|
||||
use boosapi_core::repo::stored_file::StoredFile;
|
||||
use boosapi_core::types::BackupManifest;
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tauri::State;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
// ── Shared row type sent to the frontend ─────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FilesPageEntry {
|
||||
/// Stable namespaced id: `"attachment::<record_id>"` or `"backup_manifest::<record_id>"`
|
||||
pub id: String,
|
||||
/// `"attachment"` | `"backup_manifest"`
|
||||
pub source_kind: String,
|
||||
/// `"images"` | `"files"` | `"backups"`
|
||||
pub category: String,
|
||||
pub display_name: String,
|
||||
pub path: String,
|
||||
/// Relative storage path under documents root (e.g. `images/abc123_photo.jpg`).
|
||||
/// Used by frontend to load base64 preview via `read_attachment_preview`.
|
||||
pub storage_path: Option<String>,
|
||||
pub size_bytes: i64,
|
||||
pub created_at: String,
|
||||
/// `true` when the backing file no longer exists on disk
|
||||
pub missing: bool,
|
||||
/// `file://`-prefixed URI suitable for use as an `<img src>`, populated for
|
||||
/// image entries whose backing file exists on disk; `null` for all other entries.
|
||||
pub preview_url: Option<String>,
|
||||
}
|
||||
|
||||
// ── Pure helpers (unit-testable, no AppState / DB) ────────────────────────────
|
||||
|
||||
/// Returns `true` when the file at `path` does not exist on disk.
|
||||
pub fn check_file_missing(path: &str) -> bool {
|
||||
!Path::new(path).exists()
|
||||
}
|
||||
|
||||
fn resolve_storage_path(storage_path: &str) -> String {
|
||||
let storage_path = Path::new(storage_path);
|
||||
if storage_path.is_absolute() {
|
||||
return storage_path.to_string_lossy().to_string();
|
||||
}
|
||||
|
||||
boosapi_core::storage_paths::resolve_documents_path(&storage_path.to_string_lossy())
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Build image entries from stored files (mime_type starts with "image/").
|
||||
/// Missing rows are included and flagged rather than filtered out.
|
||||
pub fn build_image_entries(files: &[StoredFile]) -> Vec<FilesPageEntry> {
|
||||
files
|
||||
.iter()
|
||||
.filter(|f| f.mime_type.starts_with("image/"))
|
||||
.map(|f| {
|
||||
let resolved_path = resolve_storage_path(&f.storage_path);
|
||||
let missing = check_file_missing(&resolved_path);
|
||||
let preview_url = if missing {
|
||||
None
|
||||
} else {
|
||||
Some(format!("file://{}", resolved_path))
|
||||
};
|
||||
FilesPageEntry {
|
||||
id: format!("attachment::{}", f.id),
|
||||
source_kind: "attachment".to_string(),
|
||||
category: "images".to_string(),
|
||||
display_name: f.original_name.clone(),
|
||||
path: resolved_path,
|
||||
storage_path: Some(f.storage_path.clone()),
|
||||
size_bytes: f.size_bytes,
|
||||
created_at: f.created_at.clone(),
|
||||
missing,
|
||||
preview_url,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build non-image file entries from stored files.
|
||||
/// Missing rows are included and flagged rather than filtered out.
|
||||
pub fn build_file_entries(files: &[StoredFile]) -> Vec<FilesPageEntry> {
|
||||
files
|
||||
.iter()
|
||||
.filter(|f| !f.mime_type.starts_with("image/"))
|
||||
.map(|f| {
|
||||
let resolved_path = resolve_storage_path(&f.storage_path);
|
||||
FilesPageEntry {
|
||||
id: format!("attachment::{}", f.id),
|
||||
source_kind: "attachment".to_string(),
|
||||
category: "files".to_string(),
|
||||
display_name: f.original_name.clone(),
|
||||
path: resolved_path.clone(),
|
||||
storage_path: Some(f.storage_path.clone()),
|
||||
size_bytes: f.size_bytes,
|
||||
created_at: f.created_at.clone(),
|
||||
missing: check_file_missing(&resolved_path),
|
||||
preview_url: None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build backup entries from backup manifests.
|
||||
/// A manifest whose `file_path` is `None` or points to a missing file is flagged.
|
||||
pub fn build_backup_entries(manifests: &[BackupManifest]) -> Vec<FilesPageEntry> {
|
||||
manifests
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let path = m.file_path.clone().unwrap_or_default();
|
||||
let missing = path.is_empty() || check_file_missing(&path);
|
||||
FilesPageEntry {
|
||||
id: format!("backup_manifest::{}", m.id),
|
||||
source_kind: "backup_manifest".to_string(),
|
||||
category: "backups".to_string(),
|
||||
display_name: format!("backup-{}.{}", m.created_at, m.version),
|
||||
path,
|
||||
storage_path: None,
|
||||
size_bytes: m.file_size,
|
||||
created_at: m.created_at.clone(),
|
||||
missing,
|
||||
preview_url: None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Filter entries by display name (case-insensitive substring match).
|
||||
pub fn apply_search_filter(
|
||||
entries: Vec<FilesPageEntry>,
|
||||
search: Option<&str>,
|
||||
) -> Vec<FilesPageEntry> {
|
||||
match search {
|
||||
None | Some("") => entries,
|
||||
Some(q) => {
|
||||
let q = q.to_lowercase();
|
||||
entries
|
||||
.into_iter()
|
||||
.filter(|e| e.display_name.to_lowercase().contains(&q))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort entries by the given key (`"name"`, `"size"`, or `"date"` / default newest-first).
|
||||
pub fn apply_sort(mut entries: Vec<FilesPageEntry>, sort_key: Option<&str>) -> Vec<FilesPageEntry> {
|
||||
match sort_key {
|
||||
Some("name") => entries.sort_by(|a, b| a.display_name.cmp(&b.display_name)),
|
||||
Some("size") => entries.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes)),
|
||||
_ => entries.sort_by(|a, b| b.created_at.cmp(&a.created_at)),
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
/// Parse a namespaced entry id like `"attachment::abc"` → `("attachment", "abc")`.
|
||||
/// Returns an error for malformed ids or an empty record part.
|
||||
pub fn parse_entry_id(entry_id: &str) -> Result<(&str, &str), String> {
|
||||
let sep = entry_id
|
||||
.find("::")
|
||||
.ok_or_else(|| format!("Invalid entry_id (missing '::'): {}", entry_id))?;
|
||||
let kind = &entry_id[..sep];
|
||||
let record_id = &entry_id[sep + 2..];
|
||||
if record_id.is_empty() {
|
||||
return Err(format!("Invalid entry_id (empty record id): {}", entry_id));
|
||||
}
|
||||
Ok((kind, record_id))
|
||||
}
|
||||
|
||||
/// Validate that `path` is non-empty and exists on disk.
|
||||
/// Returns a descriptive error that surfaces to the caller; never swallows it.
|
||||
pub fn validate_path_for_open(path: &str) -> Result<(), String> {
|
||||
if path.is_empty() {
|
||||
return Err("Path is empty".to_string());
|
||||
}
|
||||
if !Path::new(path).exists() {
|
||||
return Err(format!("File not found: {}", path));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Map a file extension to a MIME type for common attachment types.
|
||||
fn mime_from_extension(path: &str) -> &'static str {
|
||||
match Path::new(path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_ascii_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("png") => "image/png",
|
||||
Some("jpg" | "jpeg") => "image/jpeg",
|
||||
Some("gif") => "image/gif",
|
||||
Some("webp") => "image/webp",
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("bmp") => "image/bmp",
|
||||
Some("ico") => "image/x-icon",
|
||||
Some("pdf") => "application/pdf",
|
||||
Some("json") => "application/json",
|
||||
Some("xml") => "application/xml",
|
||||
Some("txt" | "md" | "csv" | "log") => "text/plain",
|
||||
Some("html" | "htm") => "text/html",
|
||||
Some("css") => "text/css",
|
||||
Some("js") => "application/javascript",
|
||||
Some("zip") => "application/zip",
|
||||
Some("mp3") => "audio/mpeg",
|
||||
Some("mp4") => "video/mp4",
|
||||
Some("wav") => "audio/wav",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tauri command wrappers ────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_attachment_exists(file_path: String) -> Result<bool, String> {
|
||||
if file_path.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let abs = resolve_storage_path(&file_path);
|
||||
Ok(Path::new(&abs).exists())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn read_attachment_preview(file_path: String) -> Result<String, String> {
|
||||
if file_path.is_empty() {
|
||||
return Err("file_path is empty".to_string());
|
||||
}
|
||||
let abs = resolve_storage_path(&file_path);
|
||||
let bytes = std::fs::read(&abs).map_err(|e| format!("Failed to read file: {e}"))?;
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
let mime = mime_from_extension(&abs);
|
||||
Ok(format!("data:{mime};base64,{b64}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_avatar_file(data: String, mime_type: String) -> Result<String, String> {
|
||||
use boosapi_core::file_store::FileStore;
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(&data)
|
||||
.map_err(|e| format!("Invalid base64: {e}"))?;
|
||||
let store = FileStore::new();
|
||||
let saved = store
|
||||
.save_file(&bytes, "avatar", &mime_type)
|
||||
.map_err(|e| format!("Failed to save avatar: {e}"))?;
|
||||
Ok(saved.storage_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn resolve_attachment_path(file_path: String) -> Result<String, String> {
|
||||
if file_path.is_empty() {
|
||||
return Err("file_path is empty".to_string());
|
||||
}
|
||||
Ok(resolve_storage_path(&file_path))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reveal_attachment_file(
|
||||
app: tauri::AppHandle,
|
||||
file_path: String,
|
||||
) -> Result<(), String> {
|
||||
if file_path.is_empty() {
|
||||
return Err("file_path is empty".to_string());
|
||||
}
|
||||
let abs = resolve_storage_path(&file_path);
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
let path = Path::new(&abs);
|
||||
if path.exists() {
|
||||
app.opener()
|
||||
.reveal_item_in_dir(&abs)
|
||||
.map_err(|e| e.to_string())
|
||||
} else if let Some(parent) = path.parent().filter(|p| p.exists()) {
|
||||
app.opener()
|
||||
.reveal_item_in_dir(parent.to_string_lossy().as_ref())
|
||||
.map_err(|e| e.to_string())
|
||||
} else {
|
||||
Err("File and parent directory do not exist".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure validation logic for `open_attachment_file`, extracted for unit tests.
|
||||
fn open_attachment_file_validate(file_path: &str) -> Result<String, String> {
|
||||
if file_path.is_empty() {
|
||||
return Err("file_path is empty".to_string());
|
||||
}
|
||||
let abs = resolve_storage_path(file_path);
|
||||
validate_path_for_open(&abs)?;
|
||||
Ok(abs)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_attachment_file(app: tauri::AppHandle, file_path: String) -> Result<(), String> {
|
||||
let abs = open_attachment_file_validate(&file_path)?;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
app.opener()
|
||||
.open_path(&abs, None::<&str>)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_files_page_entries(
|
||||
state: State<'_, AppState>,
|
||||
category: String,
|
||||
search: Option<String>,
|
||||
sort_key: Option<String>,
|
||||
) -> Result<Vec<FilesPageEntry>, String> {
|
||||
let entries = match category.as_str() {
|
||||
"images" | "files" => {
|
||||
let all_files = boosapi_core::repo::stored_file::list_all_stored_files(&state.sea_db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if category == "images" {
|
||||
build_image_entries(&all_files)
|
||||
} else {
|
||||
build_file_entries(&all_files)
|
||||
}
|
||||
}
|
||||
"backups" => {
|
||||
let manifests = boosapi_core::repo::backup::list_backups(&state.sea_db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
build_backup_entries(&manifests)
|
||||
}
|
||||
_ => return Err(format!("Unknown category: {}", category)),
|
||||
};
|
||||
|
||||
let entries = apply_search_filter(entries, search.as_deref());
|
||||
let entries = apply_sort(entries, sort_key.as_deref());
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_files_page_entry(app: tauri::AppHandle, path: String) -> Result<(), String> {
|
||||
validate_path_for_open(&path)?;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
app.opener()
|
||||
.open_path(&path, None::<&str>)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reveal_files_page_entry(app: tauri::AppHandle, path: String) -> Result<(), String> {
|
||||
validate_path_for_open(&path)?;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
app.opener()
|
||||
.reveal_item_in_dir(&path)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cleanup_missing_files_page_entry(
|
||||
state: State<'_, AppState>,
|
||||
entry_id: String,
|
||||
) -> Result<(), String> {
|
||||
let (source_kind, record_id) = parse_entry_id(&entry_id)?;
|
||||
match source_kind {
|
||||
"attachment" => {
|
||||
let file_store = boosapi_core::file_store::FileStore::new();
|
||||
super::file_cleanup::delete_attachment_reference(&state.sea_db, &file_store, record_id)
|
||||
.await
|
||||
}
|
||||
"backup_manifest" => boosapi_core::repo::backup::delete_backup(&state.sea_db, record_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string()),
|
||||
_ => Err(format!("Unknown source_kind: {}", source_kind)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inline unit tests ─────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::commands::file_cleanup;
|
||||
|
||||
fn make_stored_file(id: &str, name: &str, mime: &str, path: &str) -> StoredFile {
|
||||
StoredFile {
|
||||
id: id.to_string(),
|
||||
hash: "deadbeef".to_string(),
|
||||
original_name: name.to_string(),
|
||||
mime_type: mime.to_string(),
|
||||
size_bytes: 1024,
|
||||
storage_path: path.to_string(),
|
||||
conversation_id: None,
|
||||
created_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_backup_manifest(id: &str, path: Option<&str>) -> BackupManifest {
|
||||
BackupManifest {
|
||||
id: id.to_string(),
|
||||
version: "sqlite".to_string(),
|
||||
created_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
encrypted: false,
|
||||
checksum: "abc123".to_string(),
|
||||
object_counts_json: "{}".to_string(),
|
||||
source_app_version: "1.0.0".to_string(),
|
||||
file_path: path.map(|s| s.to_string()),
|
||||
file_size: 2048,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_temp_app_data_dir() -> std::path::PathBuf {
|
||||
let unique = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!(
|
||||
"boosapi-files-page-tests-{}-{}",
|
||||
std::process::id(),
|
||||
unique
|
||||
))
|
||||
}
|
||||
|
||||
// ── category loaders ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_images_includes_image_mime_types() {
|
||||
let files = vec![
|
||||
make_stored_file("1", "photo.jpg", "image/jpeg", "/tmp/test/a.jpg"),
|
||||
make_stored_file("2", "doc.pdf", "application/pdf", "/tmp/test/b.pdf"),
|
||||
make_stored_file("3", "photo.png", "image/png", "/tmp/test/c.png"),
|
||||
];
|
||||
let entries = build_image_entries(&files);
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert!(entries.iter().all(|e| e.category == "images"));
|
||||
assert!(entries.iter().all(|e| e.source_kind == "attachment"));
|
||||
assert!(entries.iter().all(|e| e.id.starts_with("attachment::")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_files_returns_non_image_attachments() {
|
||||
let files = vec![
|
||||
make_stored_file("1", "photo.jpg", "image/jpeg", "/tmp/test/a.jpg"),
|
||||
make_stored_file("2", "doc.pdf", "application/pdf", "/tmp/test/b.pdf"),
|
||||
make_stored_file("3", "data.csv", "text/csv", "/tmp/test/c.csv"),
|
||||
];
|
||||
let entries = build_file_entries(&files);
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert!(entries.iter().all(|e| e.category == "files"));
|
||||
let names: Vec<&str> = entries.iter().map(|e| e.display_name.as_str()).collect();
|
||||
assert!(names.contains(&"doc.pdf"));
|
||||
assert!(names.contains(&"data.csv"));
|
||||
assert!(!names.contains(&"photo.jpg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backups_reuses_backup_manifest_data() {
|
||||
let manifests = vec![
|
||||
make_backup_manifest("bk1", Some("/tmp/backup1.db")),
|
||||
make_backup_manifest("bk2", Some("/tmp/backup2.db")),
|
||||
];
|
||||
let entries = build_backup_entries(&manifests);
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert!(entries.iter().all(|e| e.category == "backups"));
|
||||
assert!(entries.iter().all(|e| e.source_kind == "backup_manifest"));
|
||||
assert!(entries
|
||||
.iter()
|
||||
.all(|e| e.id.starts_with("backup_manifest::")));
|
||||
assert!(entries.iter().any(|e| e.id == "backup_manifest::bk1"));
|
||||
assert!(entries.iter().any(|e| e.id == "backup_manifest::bk2"));
|
||||
}
|
||||
|
||||
// ── missing detection ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_missing_file_rows_are_flagged_not_filtered() {
|
||||
let files = vec![
|
||||
make_stored_file("1", "photo.jpg", "image/jpeg", "/nonexistent/path/a.jpg"),
|
||||
make_stored_file("2", "photo.png", "image/png", "/nonexistent/path/b.png"),
|
||||
];
|
||||
let entries = build_image_entries(&files);
|
||||
// Both rows must be present even though the backing files are gone
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert!(
|
||||
entries.iter().all(|e| e.missing),
|
||||
"all missing-path rows must be flagged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_existing_file_rows_are_not_flagged_missing() {
|
||||
// current_exe() always exists on the test runner machine
|
||||
let existing = std::env::current_exe()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let files = vec![make_stored_file(
|
||||
"1",
|
||||
"app.bin",
|
||||
"application/octet-stream",
|
||||
&existing,
|
||||
)];
|
||||
let entries = build_file_entries(&files);
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(
|
||||
!entries[0].missing,
|
||||
"existing file must not be flagged missing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backup_manifest_missing_file_flagged() {
|
||||
let manifests = vec![
|
||||
make_backup_manifest("bk1", Some("/nonexistent/backup.db")),
|
||||
make_backup_manifest("bk2", None), // no path at all
|
||||
];
|
||||
let entries = build_backup_entries(&manifests);
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert!(entries.iter().all(|e| e.missing));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stored_file_paths_resolve_under_documents_root() {
|
||||
let files = vec![make_stored_file(
|
||||
"1",
|
||||
"photo.jpg",
|
||||
"image/jpeg",
|
||||
"images/abc123_photo.jpg",
|
||||
)];
|
||||
let entries = build_image_entries(&files);
|
||||
|
||||
let expected = boosapi_core::storage_paths::documents_root()
|
||||
.join("images/abc123_photo.jpg")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
assert_eq!(entries[0].path, expected);
|
||||
assert_eq!(
|
||||
entries[0].preview_url.as_deref(),
|
||||
None,
|
||||
"missing files should not expose preview urls"
|
||||
);
|
||||
}
|
||||
|
||||
// ── open / reveal path validation ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_open_returns_error_for_unavailable_path() {
|
||||
let result = validate_path_for_open("/absolutely/does/not/exist/file.txt");
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err();
|
||||
assert!(
|
||||
msg.to_lowercase().contains("not found") || msg.to_lowercase().contains("empty"),
|
||||
"error message should describe the problem, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_returns_error_for_empty_path() {
|
||||
let result = validate_path_for_open("");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_succeeds_for_existing_path() {
|
||||
let existing = std::env::current_exe()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
assert!(validate_path_for_open(&existing).is_ok());
|
||||
}
|
||||
|
||||
// ── open_attachment_file validation ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_open_attachment_rejects_empty_path() {
|
||||
// open_attachment_file's first guard: empty string → Err
|
||||
assert_eq!(
|
||||
open_attachment_file_validate(""),
|
||||
Err("file_path is empty".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_attachment_rejects_missing_file() {
|
||||
// After resolve, validate_path_for_open should reject a non-existent path
|
||||
let result = open_attachment_file_validate("nonexistent/dir/fake.pdf");
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err();
|
||||
assert!(
|
||||
msg.to_lowercase().contains("not found"),
|
||||
"should report file not found, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_attachment_accepts_existing_file() {
|
||||
let existing = std::env::current_exe()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
// Absolute paths pass through resolve_storage_path unchanged
|
||||
assert!(open_attachment_file_validate(&existing).is_ok());
|
||||
}
|
||||
|
||||
// ── cleanup: entry-id parsing ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_parse_attachment_id() {
|
||||
let (kind, id) = parse_entry_id("attachment::abc123").unwrap();
|
||||
assert_eq!(kind, "attachment");
|
||||
assert_eq!(id, "abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_parse_backup_manifest_id() {
|
||||
let (kind, id) = parse_entry_id("backup_manifest::xyz789").unwrap();
|
||||
assert_eq!(kind, "backup_manifest");
|
||||
assert_eq!(id, "xyz789");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_parse_invalid_id_no_separator() {
|
||||
assert!(parse_entry_id("invalid_no_separator").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_parse_invalid_id_empty_record() {
|
||||
assert!(parse_entry_id("attachment::").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_attachment_cleanup_removes_disk_file_and_db_record() {
|
||||
let db = boosapi_core::db::create_test_pool().await.unwrap().conn;
|
||||
let app_data_dir = make_temp_app_data_dir();
|
||||
std::fs::create_dir_all(&app_data_dir).unwrap();
|
||||
|
||||
let file_store = boosapi_core::file_store::FileStore::with_root(app_data_dir.clone());
|
||||
let saved = file_store
|
||||
.save_file(b"hello world", "photo.png", "image/png")
|
||||
.unwrap();
|
||||
let physical_path = app_data_dir.join(&saved.storage_path);
|
||||
assert!(
|
||||
physical_path.exists(),
|
||||
"test fixture file must exist before cleanup"
|
||||
);
|
||||
|
||||
boosapi_core::repo::stored_file::create_stored_file(
|
||||
&db,
|
||||
"file-1",
|
||||
&saved.hash,
|
||||
"photo.png",
|
||||
"image/png",
|
||||
saved.size_bytes,
|
||||
&saved.storage_path,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let cleanup_result =
|
||||
file_cleanup::delete_attachment_reference(&db, &file_store, "file-1").await;
|
||||
assert!(
|
||||
cleanup_result.is_ok(),
|
||||
"attachment cleanup should succeed, got: {cleanup_result:?}"
|
||||
);
|
||||
assert!(
|
||||
!physical_path.exists(),
|
||||
"attachment cleanup must remove the backing file from disk"
|
||||
);
|
||||
assert!(
|
||||
boosapi_core::repo::stored_file::get_stored_file(&db, "file-1")
|
||||
.await
|
||||
.is_err(),
|
||||
"attachment cleanup must also remove the stored-file record"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&app_data_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_attachment_cleanup_preserves_shared_file_until_last_reference() {
|
||||
let db = boosapi_core::db::create_test_pool().await.unwrap().conn;
|
||||
let app_data_dir = make_temp_app_data_dir();
|
||||
std::fs::create_dir_all(&app_data_dir).unwrap();
|
||||
|
||||
let file_store = boosapi_core::file_store::FileStore::with_root(app_data_dir.clone());
|
||||
let saved = file_store
|
||||
.save_file(b"same-bytes", "shared.png", "image/png")
|
||||
.unwrap();
|
||||
let physical_path = app_data_dir.join(&saved.storage_path);
|
||||
assert!(
|
||||
physical_path.exists(),
|
||||
"shared fixture file must exist before cleanup"
|
||||
);
|
||||
|
||||
for file_id in ["file-1", "file-2"] {
|
||||
boosapi_core::repo::stored_file::create_stored_file(
|
||||
&db,
|
||||
file_id,
|
||||
&saved.hash,
|
||||
"shared.png",
|
||||
"image/png",
|
||||
saved.size_bytes,
|
||||
&saved.storage_path,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let cleanup_result =
|
||||
file_cleanup::delete_attachment_reference(&db, &file_store, "file-1").await;
|
||||
assert!(
|
||||
cleanup_result.is_ok(),
|
||||
"attachment cleanup should succeed when duplicate records share a storage path, got: {cleanup_result:?}"
|
||||
);
|
||||
assert!(
|
||||
physical_path.exists(),
|
||||
"cleanup must keep the shared backing file while another record still references it"
|
||||
);
|
||||
assert!(
|
||||
boosapi_core::repo::stored_file::get_stored_file(&db, "file-1")
|
||||
.await
|
||||
.is_err(),
|
||||
"cleanup must remove the targeted record"
|
||||
);
|
||||
assert!(
|
||||
boosapi_core::repo::stored_file::get_stored_file(&db, "file-2")
|
||||
.await
|
||||
.is_ok(),
|
||||
"cleanup must preserve other records that still share the same storage path"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&app_data_dir);
|
||||
}
|
||||
|
||||
// ── search / sort ────────────────────────────────────────────────────────
|
||||
|
||||
fn sample_entries() -> Vec<FilesPageEntry> {
|
||||
vec![
|
||||
FilesPageEntry {
|
||||
id: "attachment::1".to_string(),
|
||||
source_kind: "attachment".to_string(),
|
||||
category: "files".to_string(),
|
||||
display_name: "Important Document.pdf".to_string(),
|
||||
path: "/tmp/a.pdf".to_string(),
|
||||
storage_path: None,
|
||||
size_bytes: 500,
|
||||
created_at: "2024-01-03".to_string(),
|
||||
missing: false,
|
||||
preview_url: None,
|
||||
},
|
||||
FilesPageEntry {
|
||||
id: "attachment::2".to_string(),
|
||||
source_kind: "attachment".to_string(),
|
||||
category: "files".to_string(),
|
||||
display_name: "photo.jpg".to_string(),
|
||||
path: "/tmp/b.jpg".to_string(),
|
||||
storage_path: None,
|
||||
size_bytes: 200,
|
||||
created_at: "2024-01-01".to_string(),
|
||||
missing: false,
|
||||
preview_url: None,
|
||||
},
|
||||
FilesPageEntry {
|
||||
id: "attachment::3".to_string(),
|
||||
source_kind: "attachment".to_string(),
|
||||
category: "files".to_string(),
|
||||
display_name: "archive.zip".to_string(),
|
||||
path: "/tmp/c.zip".to_string(),
|
||||
storage_path: None,
|
||||
size_bytes: 9000,
|
||||
created_at: "2024-01-02".to_string(),
|
||||
missing: false,
|
||||
preview_url: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_filter_case_insensitive() {
|
||||
let filtered = apply_search_filter(sample_entries(), Some("important"));
|
||||
assert_eq!(filtered.len(), 1);
|
||||
assert_eq!(filtered[0].display_name, "Important Document.pdf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_filter_none_returns_all() {
|
||||
let all = apply_search_filter(sample_entries(), None);
|
||||
assert_eq!(all.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_filter_empty_string_returns_all() {
|
||||
let all = apply_search_filter(sample_entries(), Some(""));
|
||||
assert_eq!(all.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_by_name() {
|
||||
let sorted = apply_sort(sample_entries(), Some("name"));
|
||||
let names: Vec<&str> = sorted.iter().map(|e| e.display_name.as_str()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
["Important Document.pdf", "archive.zip", "photo.jpg"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_by_size_descending() {
|
||||
let sorted = apply_sort(sample_entries(), Some("size"));
|
||||
assert_eq!(sorted[0].display_name, "archive.zip");
|
||||
assert_eq!(sorted[2].display_name, "photo.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_default_newest_first() {
|
||||
let sorted = apply_sort(sample_entries(), None);
|
||||
assert_eq!(sorted[0].display_name, "Important Document.pdf"); // 2024-01-03
|
||||
assert_eq!(sorted[2].display_name, "photo.jpg"); // 2024-01-01
|
||||
}
|
||||
|
||||
// ── preview_url ──────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_image_entry_has_preview_url_when_file_exists() {
|
||||
let existing = std::env::current_exe()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let files = vec![make_stored_file("1", "photo.jpg", "image/jpeg", &existing)];
|
||||
let entries = build_image_entries(&files);
|
||||
assert_eq!(entries.len(), 1);
|
||||
let preview = entries[0]
|
||||
.preview_url
|
||||
.as_deref()
|
||||
.expect("preview_url must be Some for an existing image");
|
||||
assert!(
|
||||
preview.starts_with("file://"),
|
||||
"preview_url must be a file:// URI, got: {preview}"
|
||||
);
|
||||
assert!(
|
||||
preview.contains(&existing),
|
||||
"preview_url must embed the storage path"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_entry_has_no_preview_url_when_file_missing() {
|
||||
let files = vec![make_stored_file(
|
||||
"1",
|
||||
"photo.jpg",
|
||||
"image/jpeg",
|
||||
"/nonexistent/photo.jpg",
|
||||
)];
|
||||
let entries = build_image_entries(&files);
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(
|
||||
entries[0].preview_url.is_none(),
|
||||
"missing image must not have a preview_url"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_image_entry_has_no_preview_url() {
|
||||
let files = vec![make_stored_file(
|
||||
"1",
|
||||
"doc.pdf",
|
||||
"application/pdf",
|
||||
"/tmp/doc.pdf",
|
||||
)];
|
||||
let entries = build_file_entries(&files);
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(
|
||||
entries[0].preview_url.is_none(),
|
||||
"non-image entries must not have a preview_url"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backup_entry_has_no_preview_url() {
|
||||
let manifests = vec![make_backup_manifest("bk1", Some("/tmp/backup.db"))];
|
||||
let entries = build_backup_entries(&manifests);
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(
|
||||
entries[0].preview_url.is_none(),
|
||||
"backup entries must not have a preview_url"
|
||||
);
|
||||
}
|
||||
|
||||
// ── save_avatar_file tests ──────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_avatar_file_returns_relative_path() {
|
||||
// 1x1 red PNG pixel
|
||||
let png_bytes: &[u8] = &[
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
|
||||
0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00,
|
||||
0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08,
|
||||
0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC,
|
||||
0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
|
||||
];
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(png_bytes);
|
||||
|
||||
// Use a temp dir so we don't pollute the real ~/Documents/boosapi
|
||||
let tmp = make_temp_app_data_dir();
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
|
||||
// Save via FileStore directly (mirrors command logic without the Tauri runtime)
|
||||
let store = boosapi_core::file_store::FileStore::with_root(tmp.clone());
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(&b64)
|
||||
.unwrap();
|
||||
let saved = store.save_file(&decoded, "avatar", "image/png").unwrap();
|
||||
|
||||
assert!(
|
||||
saved.storage_path.starts_with("images/"),
|
||||
"avatar should be stored under images/, got: {}",
|
||||
saved.storage_path
|
||||
);
|
||||
assert!(
|
||||
saved.storage_path.contains("avatar"),
|
||||
"storage path should contain 'avatar', got: {}",
|
||||
saved.storage_path
|
||||
);
|
||||
assert!(tmp.join(&saved.storage_path).exists());
|
||||
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_avatar_file_rejects_invalid_base64() {
|
||||
let result = base64::engine::general_purpose::STANDARD.decode("not-valid-base64!!!");
|
||||
assert!(result.is_err(), "decoding garbage base64 should fail");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
use crate::AppState;
|
||||
use boosapi_core::repo::kelivo_import::{
|
||||
ThirdPartyImportOptions, ThirdPartyImportResult, ThirdPartyImportSummary,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn scan_kelivo_import(
|
||||
state: State<'_, AppState>,
|
||||
path: String,
|
||||
) -> Result<ThirdPartyImportSummary, String> {
|
||||
boosapi_core::repo::kelivo_import::scan_kelivo_import_from_path(
|
||||
&state.sea_db,
|
||||
&PathBuf::from(path),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn import_kelivo_backup(
|
||||
state: State<'_, AppState>,
|
||||
path: String,
|
||||
options: ThirdPartyImportOptions,
|
||||
) -> Result<ThirdPartyImportResult, String> {
|
||||
boosapi_core::repo::kelivo_import::import_kelivo_backup_from_path(
|
||||
&state.sea_db,
|
||||
&state.master_key,
|
||||
&PathBuf::from(path),
|
||||
options,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
use crate::knowledge_index_scheduler::KnowledgeIndexScheduleOptions;
|
||||
use crate::AppState;
|
||||
use boosapi_core::types::*;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
fn knowledge_index_schedule_options(kb: &KnowledgeBase) -> KnowledgeIndexScheduleOptions {
|
||||
KnowledgeIndexScheduleOptions {
|
||||
index_concurrency: kb.index_concurrency,
|
||||
index_interval_ms: kb.index_interval_ms,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_knowledge_bases(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<KnowledgeBase>, String> {
|
||||
boosapi_core::repo::knowledge::list_knowledge_bases(&state.sea_db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_knowledge_base(
|
||||
state: State<'_, AppState>,
|
||||
input: CreateKnowledgeBaseInput,
|
||||
) -> Result<KnowledgeBase, String> {
|
||||
boosapi_core::repo::knowledge::create_knowledge_base(&state.sea_db, input)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_knowledge_base(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
input: UpdateKnowledgeBaseInput,
|
||||
) -> Result<KnowledgeBase, String> {
|
||||
boosapi_core::repo::knowledge::update_knowledge_base(&state.sea_db, &id, input)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_knowledge_base(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||
boosapi_core::repo::knowledge::delete_knowledge_base(&state.sea_db, &id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reorder_knowledge_bases(
|
||||
state: State<'_, AppState>,
|
||||
base_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
boosapi_core::repo::knowledge::reorder_knowledge_bases(&state.sea_db, &base_ids)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_knowledge_documents(
|
||||
state: State<'_, AppState>,
|
||||
base_id: String,
|
||||
) -> Result<Vec<KnowledgeDocument>, String> {
|
||||
boosapi_core::repo::knowledge::list_documents(&state.sea_db, &base_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_knowledge_document(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
base_id: String,
|
||||
title: String,
|
||||
source_path: String,
|
||||
mime_type: String,
|
||||
) -> Result<KnowledgeDocument, String> {
|
||||
let doc = boosapi_core::repo::knowledge::add_document(
|
||||
&state.sea_db,
|
||||
&base_id,
|
||||
&title,
|
||||
&source_path,
|
||||
&mime_type,
|
||||
None, // doc_type defaults to "file"
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Spawn async indexing task
|
||||
let kb = boosapi_core::repo::knowledge::get_knowledge_base(&state.sea_db, &base_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(ref embedding_provider) = kb.embedding_provider {
|
||||
let db = state.sea_db.clone();
|
||||
let master_key = state.master_key;
|
||||
let vector_store = state.vector_store.clone();
|
||||
let doc_id = doc.id.clone();
|
||||
let src_path = source_path.clone();
|
||||
let mime = mime_type.clone();
|
||||
let ep = embedding_provider.clone();
|
||||
let dimensions = crate::indexing::configured_embedding_dimensions(kb.embedding_dimensions);
|
||||
let chunk_sz = kb.chunk_size;
|
||||
let chunk_ov = kb.chunk_overlap;
|
||||
let kb_id = base_id.clone();
|
||||
let scheduler = state.knowledge_index_scheduler.clone();
|
||||
let schedule_options = knowledge_index_schedule_options(&kb);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result = scheduler
|
||||
.run(&kb_id, schedule_options, || async {
|
||||
crate::indexing::index_knowledge_document(
|
||||
&db,
|
||||
&master_key,
|
||||
&vector_store,
|
||||
&kb_id,
|
||||
&doc_id,
|
||||
&src_path,
|
||||
&mime,
|
||||
&ep,
|
||||
dimensions,
|
||||
chunk_sz,
|
||||
chunk_ov,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await;
|
||||
|
||||
if let Err(e) = &result {
|
||||
let err_msg = e.to_string();
|
||||
tracing::error!("Indexing failed for doc {}: {}", doc_id, err_msg);
|
||||
let _ = boosapi_core::repo::knowledge::update_document_status_with_error(
|
||||
&db,
|
||||
&doc_id,
|
||||
"failed",
|
||||
Some(&err_msg),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Emit event to notify frontend
|
||||
let _ = app.emit(
|
||||
"knowledge-document-indexed",
|
||||
serde_json::json!({
|
||||
"documentId": doc_id,
|
||||
"success": result.is_ok(),
|
||||
"error": result.err().map(|e| e.to_string()),
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_knowledge_document(
|
||||
state: State<'_, AppState>,
|
||||
base_id: String,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
// Delete vector embeddings for this document
|
||||
let collection_id = format!("kb_{}", base_id);
|
||||
let _ = state
|
||||
.vector_store
|
||||
.delete_document_embeddings(&collection_id, &id)
|
||||
.await;
|
||||
|
||||
boosapi_core::repo::knowledge::delete_document(&state.sea_db, &id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn search_knowledge_base(
|
||||
state: State<'_, AppState>,
|
||||
base_id: String,
|
||||
query: String,
|
||||
top_k: Option<usize>,
|
||||
) -> Result<Vec<boosapi_core::vector_store::VectorSearchResult>, String> {
|
||||
crate::indexing::search_knowledge(
|
||||
&state.sea_db,
|
||||
&state.master_key,
|
||||
&state.vector_store,
|
||||
&base_id,
|
||||
&query,
|
||||
top_k.unwrap_or(5),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn rebuild_knowledge_index(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
base_id: String,
|
||||
) -> Result<(), String> {
|
||||
let kb = boosapi_core::repo::knowledge::get_knowledge_base(&state.sea_db, &base_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let embedding_provider = kb
|
||||
.embedding_provider
|
||||
.clone()
|
||||
.ok_or("No embedding provider configured")?;
|
||||
|
||||
let schedule_options = knowledge_index_schedule_options(&kb);
|
||||
let dimensions = crate::indexing::configured_embedding_dimensions(kb.embedding_dimensions);
|
||||
|
||||
let collection_id = format!("kb_{}", base_id);
|
||||
|
||||
// Get all documents
|
||||
let docs = boosapi_core::repo::knowledge::list_documents(&state.sea_db, &base_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if docs.is_empty() {
|
||||
let _ = app.emit(
|
||||
"knowledge-rebuild-complete",
|
||||
serde_json::json!({ "baseId": base_id }),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Reset all document statuses to "indexing"
|
||||
for doc in &docs {
|
||||
let _ =
|
||||
boosapi_core::repo::knowledge::update_document_status(&state.sea_db, &doc.id, "indexing")
|
||||
.await;
|
||||
}
|
||||
|
||||
// Clear only embeddings (vec0), keep _meta intact
|
||||
let _ = state.vector_store.clear_embeddings(&collection_id).await;
|
||||
|
||||
let db = state.sea_db.clone();
|
||||
let master_key = state.master_key;
|
||||
let vector_store = state.vector_store.clone();
|
||||
let ep = embedding_provider.clone();
|
||||
let scheduler = state.knowledge_index_scheduler.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Process each document individually so status updates per-doc
|
||||
for doc in &docs {
|
||||
let result = scheduler
|
||||
.run(&base_id, schedule_options, || async {
|
||||
let chunks = vector_store
|
||||
.list_document_chunks_raw(&collection_id, &doc.id)
|
||||
.await?;
|
||||
|
||||
if chunks.is_empty() {
|
||||
return Ok::<(), boosapi_core::error::BoosApiError>(());
|
||||
}
|
||||
|
||||
let texts: Vec<String> = chunks
|
||||
.iter()
|
||||
.map(|(_, _, content)| content.clone())
|
||||
.collect();
|
||||
let rowids: Vec<i64> = chunks.iter().map(|(rid, _, _)| *rid).collect();
|
||||
|
||||
let embed_response = crate::indexing::generate_embeddings(
|
||||
&db,
|
||||
&master_key,
|
||||
&ep,
|
||||
texts,
|
||||
dimensions,
|
||||
)
|
||||
.await?;
|
||||
let entries: Vec<(i64, Vec<f32>)> = rowids
|
||||
.into_iter()
|
||||
.zip(embed_response.embeddings.into_iter())
|
||||
.collect();
|
||||
|
||||
vector_store
|
||||
.upsert_document_embeddings(&collection_id, entries)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let _ = boosapi_core::repo::knowledge::update_document_status_with_error(
|
||||
&db, &doc.id, "ready", None,
|
||||
)
|
||||
.await;
|
||||
let _ = app.emit(
|
||||
"knowledge-document-indexed",
|
||||
serde_json::json!({
|
||||
"documentId": doc.id,
|
||||
"success": true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
let err_msg = e.to_string();
|
||||
tracing::error!("Failed to rebuild doc {}: {}", doc.id, err_msg);
|
||||
let _ = boosapi_core::repo::knowledge::update_document_status_with_error(
|
||||
&db,
|
||||
&doc.id,
|
||||
"failed",
|
||||
Some(&err_msg),
|
||||
)
|
||||
.await;
|
||||
let _ = app.emit(
|
||||
"knowledge-document-indexed",
|
||||
serde_json::json!({
|
||||
"documentId": doc.id,
|
||||
"success": false,
|
||||
"error": err_msg,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = app.emit(
|
||||
"knowledge-rebuild-complete",
|
||||
serde_json::json!({ "baseId": base_id }),
|
||||
);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clear_knowledge_index(
|
||||
state: State<'_, AppState>,
|
||||
base_id: String,
|
||||
) -> Result<(), String> {
|
||||
let collection_id = format!("kb_{}", base_id);
|
||||
// Only clear embeddings (vec0), keep chunk metadata (_meta) intact
|
||||
state
|
||||
.vector_store
|
||||
.clear_embeddings(&collection_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Reset all documents to "pending"
|
||||
let docs = boosapi_core::repo::knowledge::list_documents(&state.sea_db, &base_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
for doc in docs {
|
||||
let _ =
|
||||
boosapi_core::repo::knowledge::update_document_status(&state.sea_db, &doc.id, "pending")
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_knowledge_document_chunks(
|
||||
state: State<'_, AppState>,
|
||||
base_id: String,
|
||||
document_id: String,
|
||||
) -> Result<Vec<boosapi_core::vector_store::VectorSearchResult>, String> {
|
||||
let collection_id = format!("kb_{}", base_id);
|
||||
state
|
||||
.vector_store
|
||||
.list_document_chunks(&collection_id, &document_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_knowledge_chunk(
|
||||
state: State<'_, AppState>,
|
||||
base_id: String,
|
||||
chunk_id: String,
|
||||
) -> Result<(), String> {
|
||||
let collection_id = format!("kb_{}", base_id);
|
||||
state
|
||||
.vector_store
|
||||
.delete_chunk(&collection_id, &chunk_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_knowledge_chunk(
|
||||
state: State<'_, AppState>,
|
||||
base_id: String,
|
||||
chunk_id: String,
|
||||
content: String,
|
||||
) -> Result<(), String> {
|
||||
let collection_id = format!("kb_{}", base_id);
|
||||
state
|
||||
.vector_store
|
||||
.update_chunk_content(&collection_id, &chunk_id, &content)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_knowledge_chunk(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
base_id: String,
|
||||
document_id: String,
|
||||
content: String,
|
||||
) -> Result<String, String> {
|
||||
let kb = boosapi_core::repo::knowledge::get_knowledge_base(&state.sea_db, &base_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let embedding_provider = kb
|
||||
.embedding_provider
|
||||
.ok_or_else(|| "No embedding provider configured".to_string())?;
|
||||
let dimensions = crate::indexing::configured_embedding_dimensions(kb.embedding_dimensions);
|
||||
|
||||
let collection_id = format!("kb_{}", base_id);
|
||||
let db = state.sea_db.clone();
|
||||
let master_key = state.master_key;
|
||||
let vector_store = state.vector_store.clone();
|
||||
let doc_id = document_id.clone();
|
||||
let chunk_content = content.clone();
|
||||
|
||||
let chunk_id_result = tokio::spawn(async move {
|
||||
let embed_response = crate::indexing::generate_embeddings(
|
||||
&db,
|
||||
&master_key,
|
||||
&embedding_provider,
|
||||
vec![chunk_content.clone()],
|
||||
dimensions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let embedding = embed_response
|
||||
.embeddings
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| {
|
||||
boosapi_core::error::BoosApiError::Provider("No embedding returned".to_string())
|
||||
})?;
|
||||
|
||||
let chunk_id = vector_store
|
||||
.add_single_chunk(&collection_id, &doc_id, &chunk_content, &embedding)
|
||||
.await?;
|
||||
|
||||
let _ = app.emit(
|
||||
"knowledge-chunk-added",
|
||||
serde_json::json!({
|
||||
"baseId": base_id,
|
||||
"documentId": doc_id,
|
||||
"chunkId": chunk_id,
|
||||
}),
|
||||
);
|
||||
|
||||
Ok::<String, boosapi_core::error::BoosApiError>(chunk_id)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(chunk_id_result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reindex_knowledge_chunk(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
base_id: String,
|
||||
chunk_id: String,
|
||||
) -> Result<(), String> {
|
||||
let kb = boosapi_core::repo::knowledge::get_knowledge_base(&state.sea_db, &base_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let embedding_provider = kb
|
||||
.embedding_provider
|
||||
.ok_or_else(|| "No embedding provider configured".to_string())?;
|
||||
let dimensions = crate::indexing::configured_embedding_dimensions(kb.embedding_dimensions);
|
||||
|
||||
let collection_id = format!("kb_{}", base_id);
|
||||
|
||||
let chunk_content = {
|
||||
use sea_orm::{ConnectionTrait, DbBackend, Statement};
|
||||
let name = format!("vec_kb_{}", base_id.replace('-', "_"));
|
||||
let row = state
|
||||
.sea_db
|
||||
.query_one(Statement::from_sql_and_values(
|
||||
DbBackend::Sqlite,
|
||||
&format!("SELECT content FROM {name}_meta WHERE id = $1"),
|
||||
vec![chunk_id.clone().into()],
|
||||
))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("Chunk {} not found", chunk_id))?;
|
||||
row.try_get::<String>("", "content")
|
||||
.map_err(|e| e.to_string())?
|
||||
};
|
||||
|
||||
// Embed the single chunk
|
||||
let db = state.sea_db.clone();
|
||||
let master_key = state.master_key;
|
||||
let vector_store = state.vector_store.clone();
|
||||
let cid = chunk_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result = async {
|
||||
let embed_response = crate::indexing::generate_embeddings(
|
||||
&db,
|
||||
&master_key,
|
||||
&embedding_provider,
|
||||
vec![chunk_content],
|
||||
dimensions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(embedding) = embed_response.embeddings.into_iter().next() {
|
||||
vector_store
|
||||
.update_chunk_embedding(&collection_id, &cid, &embedding)
|
||||
.await?;
|
||||
}
|
||||
Ok::<_, boosapi_core::error::BoosApiError>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
let _ = app.emit(
|
||||
"knowledge-chunk-reindexed",
|
||||
serde_json::json!({
|
||||
"chunkId": cid,
|
||||
"success": result.is_ok(),
|
||||
"error": result.err().map(|e| e.to_string()),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rebuild the index for a single document (re-embed its chunks only).
|
||||
#[tauri::command]
|
||||
pub async fn rebuild_knowledge_document(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
base_id: String,
|
||||
document_id: String,
|
||||
) -> Result<(), String> {
|
||||
let kb = boosapi_core::repo::knowledge::get_knowledge_base(&state.sea_db, &base_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let embedding_provider = kb
|
||||
.embedding_provider
|
||||
.clone()
|
||||
.ok_or("No embedding provider configured")?;
|
||||
|
||||
let schedule_options = knowledge_index_schedule_options(&kb);
|
||||
let dimensions = crate::indexing::configured_embedding_dimensions(kb.embedding_dimensions);
|
||||
|
||||
let collection_id = format!("kb_{}", base_id);
|
||||
|
||||
let chunks = state
|
||||
.vector_store
|
||||
.list_document_chunks_raw(&collection_id, &document_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if chunks.is_empty() {
|
||||
let _ = app.emit(
|
||||
"knowledge-document-indexed",
|
||||
serde_json::json!({ "documentId": document_id, "success": true }),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Set document status to "indexing"
|
||||
let _ = boosapi_core::repo::knowledge::update_document_status(
|
||||
&state.sea_db,
|
||||
&document_id,
|
||||
"indexing",
|
||||
)
|
||||
.await;
|
||||
|
||||
let db = state.sea_db.clone();
|
||||
let master_key = state.master_key;
|
||||
let vector_store = state.vector_store.clone();
|
||||
let ep = embedding_provider.clone();
|
||||
let doc_id = document_id.clone();
|
||||
let scheduler = state.knowledge_index_scheduler.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result = scheduler
|
||||
.run(&base_id, schedule_options, || async {
|
||||
let texts: Vec<String> = chunks
|
||||
.iter()
|
||||
.map(|(_, _, content)| content.clone())
|
||||
.collect();
|
||||
let rowids: Vec<i64> = chunks.iter().map(|(rid, _, _)| *rid).collect();
|
||||
|
||||
let embed_response =
|
||||
crate::indexing::generate_embeddings(&db, &master_key, &ep, texts, dimensions)
|
||||
.await?;
|
||||
let entries: Vec<(i64, Vec<f32>)> = rowids
|
||||
.into_iter()
|
||||
.zip(embed_response.embeddings.into_iter())
|
||||
.collect();
|
||||
|
||||
vector_store
|
||||
.upsert_document_embeddings(&collection_id, entries)
|
||||
.await?;
|
||||
|
||||
Ok::<(), boosapi_core::error::BoosApiError>(())
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let _ = boosapi_core::repo::knowledge::update_document_status_with_error(
|
||||
&db, &doc_id, "ready", None,
|
||||
)
|
||||
.await;
|
||||
let _ = app.emit(
|
||||
"knowledge-document-indexed",
|
||||
serde_json::json!({
|
||||
"documentId": doc_id,
|
||||
"success": true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
let err_msg = e.to_string();
|
||||
tracing::error!("Failed to embed doc {}: {}", doc_id, err_msg);
|
||||
let _ = boosapi_core::repo::knowledge::update_document_status_with_error(
|
||||
&db,
|
||||
&doc_id,
|
||||
"failed",
|
||||
Some(&err_msg),
|
||||
)
|
||||
.await;
|
||||
let _ = app.emit(
|
||||
"knowledge-document-indexed",
|
||||
serde_json::json!({
|
||||
"documentId": doc_id,
|
||||
"success": false,
|
||||
"error": err_msg,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
use crate::AppState;
|
||||
use boosapi_core::types::*;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_mcp_servers(state: State<'_, AppState>) -> Result<Vec<McpServer>, String> {
|
||||
boosapi_core::repo::mcp_server::list_mcp_servers(&state.sea_db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_mcp_server(
|
||||
state: State<'_, AppState>,
|
||||
input: CreateMcpServerInput,
|
||||
) -> Result<McpServer, String> {
|
||||
boosapi_core::repo::mcp_server::create_mcp_server(&state.sea_db, input)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_mcp_server(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
input: UpdateMcpServerInput,
|
||||
) -> Result<McpServer, String> {
|
||||
boosapi_core::repo::mcp_server::update_mcp_server(&state.sea_db, &id, input)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_mcp_server(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||
boosapi_core::repo::mcp_server::delete_mcp_server(&state.sea_db, &id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn test_mcp_server(
|
||||
_state: State<'_, AppState>,
|
||||
_id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
// Mock implementation — return success with capabilities
|
||||
Ok(serde_json::json!({"ok": true, "capabilities": ["tools"]}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_mcp_tools(
|
||||
state: State<'_, AppState>,
|
||||
server_id: String,
|
||||
) -> Result<Vec<ToolDescriptor>, String> {
|
||||
boosapi_core::repo::mcp_server::list_tools_for_server(&state.sea_db, &server_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn discover_mcp_tools(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<Vec<ToolDescriptor>, String> {
|
||||
let server = boosapi_core::repo::mcp_server::get_mcp_server(&state.sea_db, &id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if server.source == "builtin" {
|
||||
return boosapi_core::repo::mcp_server::list_tools_for_server(&state.sea_db, &id)
|
||||
.await
|
||||
.map_err(|e| e.to_string());
|
||||
}
|
||||
|
||||
let timeout_secs = server.discover_timeout_secs.unwrap_or(30) as u64;
|
||||
let timeout_duration = std::time::Duration::from_secs(timeout_secs);
|
||||
|
||||
let tools = match server.transport.as_str() {
|
||||
"stdio" => {
|
||||
let command = server
|
||||
.command
|
||||
.as_deref()
|
||||
.ok_or_else(|| "stdio server has no command configured".to_string())?;
|
||||
let args: Vec<String> = server
|
||||
.args_json
|
||||
.as_ref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default();
|
||||
let env: std::collections::HashMap<String, String> = server
|
||||
.env_json
|
||||
.as_ref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default();
|
||||
tokio::time::timeout(
|
||||
timeout_duration,
|
||||
boosapi_core::mcp_client::discover_tools_stdio(command, &args, &env),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| format!("Tool discovery timed out after {}s", timeout_secs))?
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
"http" => {
|
||||
let endpoint = server
|
||||
.endpoint
|
||||
.as_deref()
|
||||
.ok_or_else(|| "HTTP server has no endpoint configured".to_string())?;
|
||||
tokio::time::timeout(
|
||||
timeout_duration,
|
||||
boosapi_core::mcp_client::discover_tools_http(
|
||||
endpoint,
|
||||
server.headers_json.as_deref(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| format!("Tool discovery timed out after {}s", timeout_secs))?
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
"sse" => {
|
||||
let endpoint = server
|
||||
.endpoint
|
||||
.as_deref()
|
||||
.ok_or_else(|| "SSE server has no endpoint configured".to_string())?;
|
||||
tokio::time::timeout(
|
||||
timeout_duration,
|
||||
boosapi_core::mcp_client::discover_tools_sse(
|
||||
endpoint,
|
||||
server.headers_json.as_deref(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| format!("Tool discovery timed out after {}s", timeout_secs))?
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
other => return Err(format!("Unsupported transport: {}", other)),
|
||||
};
|
||||
|
||||
boosapi_core::repo::mcp_server::save_tool_descriptors(&state.sea_db, &id, tools)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_tool_executions(
|
||||
state: State<'_, AppState>,
|
||||
conversation_id: String,
|
||||
) -> Result<Vec<ToolExecution>, String> {
|
||||
boosapi_core::repo::tool_execution::list_tool_executions(&state.sea_db, &conversation_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
use crate::AppState;
|
||||
use boosapi_core::types::*;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_memory_namespaces(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<MemoryNamespace>, String> {
|
||||
boosapi_core::repo::memory::list_namespaces(&state.sea_db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_memory_namespace(
|
||||
state: State<'_, AppState>,
|
||||
input: CreateMemoryNamespaceInput,
|
||||
) -> Result<MemoryNamespace, String> {
|
||||
boosapi_core::repo::memory::create_namespace(&state.sea_db, input)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_memory_namespace(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||
boosapi_core::repo::memory::delete_namespace(&state.sea_db, &id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_memory_namespace(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
input: UpdateMemoryNamespaceInput,
|
||||
) -> Result<MemoryNamespace, String> {
|
||||
boosapi_core::repo::memory::update_namespace(&state.sea_db, &id, input)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_memory_items(
|
||||
state: State<'_, AppState>,
|
||||
namespace_id: String,
|
||||
) -> Result<Vec<MemoryItem>, String> {
|
||||
boosapi_core::repo::memory::list_items(&state.sea_db, &namespace_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_memory_item(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
input: CreateMemoryItemInput,
|
||||
) -> Result<MemoryItem, String> {
|
||||
let item = boosapi_core::repo::memory::add_item(&state.sea_db, input)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Spawn async embedding task if namespace has an embedding provider
|
||||
let ns = boosapi_core::repo::memory::get_namespace(&state.sea_db, &item.namespace_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(ref embedding_provider) = ns.embedding_provider {
|
||||
// Set status to indexing
|
||||
let _ = boosapi_core::repo::memory::update_item_index_status(
|
||||
&state.sea_db,
|
||||
&item.id,
|
||||
"indexing",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let db = state.sea_db.clone();
|
||||
let master_key = state.master_key;
|
||||
let vector_store = state.vector_store.clone();
|
||||
let item_id = item.id.clone();
|
||||
let content = item.content.clone();
|
||||
let ep = embedding_provider.clone();
|
||||
let ns_id = item.namespace_id.clone();
|
||||
let dims = ns.embedding_dimensions.map(|v| v as usize);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result = crate::indexing::index_memory_item(
|
||||
&db,
|
||||
&master_key,
|
||||
&vector_store,
|
||||
&ns_id,
|
||||
&item_id,
|
||||
&content,
|
||||
&ep,
|
||||
dims,
|
||||
)
|
||||
.await;
|
||||
|
||||
let (status, err_msg) = match &result {
|
||||
Ok(_) => ("ready", None),
|
||||
Err(e) => {
|
||||
tracing::error!("Memory embedding failed for item {}: {}", item_id, e);
|
||||
("failed", Some(e.to_string()))
|
||||
}
|
||||
};
|
||||
let _ = boosapi_core::repo::memory::update_item_index_status(
|
||||
&db,
|
||||
&item_id,
|
||||
status,
|
||||
err_msg.as_deref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let _ = app.emit(
|
||||
"memory-item-indexed",
|
||||
serde_json::json!({
|
||||
"itemId": item_id,
|
||||
"success": result.is_ok(),
|
||||
"status": status,
|
||||
"error": err_msg,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// Return item with "indexing" status
|
||||
return Ok(MemoryItem {
|
||||
index_status: "indexing".to_string(),
|
||||
..item
|
||||
});
|
||||
} else {
|
||||
// No embedding provider — mark as skipped
|
||||
let _ = boosapi_core::repo::memory::update_item_index_status(
|
||||
&state.sea_db,
|
||||
&item.id,
|
||||
"skipped",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
return Ok(MemoryItem {
|
||||
index_status: "skipped".to_string(),
|
||||
..item
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_memory_item(
|
||||
state: State<'_, AppState>,
|
||||
namespace_id: String,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
// Delete vector embedding for this item
|
||||
let collection_id = format!("mem_{}", namespace_id);
|
||||
let _ = state
|
||||
.vector_store
|
||||
.delete_document_embeddings(&collection_id, &id)
|
||||
.await;
|
||||
|
||||
boosapi_core::repo::memory::delete_item(&state.sea_db, &id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_memory_item(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
namespace_id: String,
|
||||
id: String,
|
||||
input: UpdateMemoryItemInput,
|
||||
) -> Result<MemoryItem, String> {
|
||||
let content_changed = input.content.is_some();
|
||||
let item = boosapi_core::repo::memory::update_item(&state.sea_db, &id, input)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Re-index if content changed and namespace has embedding provider
|
||||
if content_changed {
|
||||
let ns = boosapi_core::repo::memory::get_namespace(&state.sea_db, &namespace_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(ref embedding_provider) = ns.embedding_provider {
|
||||
// Set status to indexing
|
||||
let _ = boosapi_core::repo::memory::update_item_index_status(
|
||||
&state.sea_db,
|
||||
&id,
|
||||
"indexing",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let db = state.sea_db.clone();
|
||||
let master_key = state.master_key;
|
||||
let vector_store = state.vector_store.clone();
|
||||
let item_id = item.id.clone();
|
||||
let content = item.content.clone();
|
||||
let ep = embedding_provider.clone();
|
||||
let ns_id = namespace_id.clone();
|
||||
let dims = ns.embedding_dimensions.map(|v| v as usize);
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Delete old embedding first
|
||||
let collection_id = format!("mem_{}", ns_id);
|
||||
let _ = vector_store
|
||||
.delete_document_embeddings(&collection_id, &item_id)
|
||||
.await;
|
||||
|
||||
let result = crate::indexing::index_memory_item(
|
||||
&db,
|
||||
&master_key,
|
||||
&vector_store,
|
||||
&ns_id,
|
||||
&item_id,
|
||||
&content,
|
||||
&ep,
|
||||
dims,
|
||||
)
|
||||
.await;
|
||||
|
||||
let (status, err_msg) = match &result {
|
||||
Ok(_) => ("ready", None),
|
||||
Err(e) => {
|
||||
tracing::error!("Memory re-embedding failed for item {}: {}", item_id, e);
|
||||
("failed", Some(e.to_string()))
|
||||
}
|
||||
};
|
||||
let _ = boosapi_core::repo::memory::update_item_index_status(
|
||||
&db,
|
||||
&item_id,
|
||||
status,
|
||||
err_msg.as_deref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let _ = app.emit(
|
||||
"memory-item-indexed",
|
||||
serde_json::json!({
|
||||
"itemId": item_id,
|
||||
"success": result.is_ok(),
|
||||
"status": status,
|
||||
"error": err_msg,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return Ok(MemoryItem {
|
||||
index_status: "indexing".to_string(),
|
||||
..item
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn search_memory(
|
||||
state: State<'_, AppState>,
|
||||
namespace_id: String,
|
||||
query: String,
|
||||
top_k: Option<usize>,
|
||||
) -> Result<Vec<boosapi_core::vector_store::VectorSearchResult>, String> {
|
||||
crate::indexing::search_memory(
|
||||
&state.sea_db,
|
||||
&state.master_key,
|
||||
&state.vector_store,
|
||||
&namespace_id,
|
||||
&query,
|
||||
top_k.unwrap_or(5),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn rebuild_memory_index(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
namespace_id: String,
|
||||
) -> Result<(), String> {
|
||||
let ns = boosapi_core::repo::memory::get_namespace(&state.sea_db, &namespace_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let embedding_provider = ns
|
||||
.embedding_provider
|
||||
.ok_or("No embedding provider configured")?;
|
||||
|
||||
// Clear existing collection
|
||||
let collection_id = format!("mem_{}", namespace_id);
|
||||
let _ = state.vector_store.delete_collection(&collection_id).await;
|
||||
|
||||
// Get all items and re-index
|
||||
let items = boosapi_core::repo::memory::list_items(&state.sea_db, &namespace_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Set all items to indexing status
|
||||
for item in &items {
|
||||
let _ = boosapi_core::repo::memory::update_item_index_status(
|
||||
&state.sea_db,
|
||||
&item.id,
|
||||
"indexing",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let db = state.sea_db.clone();
|
||||
let master_key = state.master_key;
|
||||
let vector_store = state.vector_store.clone();
|
||||
let ep = embedding_provider.clone();
|
||||
let dims = ns.embedding_dimensions.map(|v| v as usize);
|
||||
|
||||
tokio::spawn(async move {
|
||||
for item in items {
|
||||
let result = crate::indexing::index_memory_item(
|
||||
&db,
|
||||
&master_key,
|
||||
&vector_store,
|
||||
&namespace_id,
|
||||
&item.id,
|
||||
&item.content,
|
||||
&ep,
|
||||
dims,
|
||||
)
|
||||
.await;
|
||||
|
||||
let (status, err_msg) = match &result {
|
||||
Ok(_) => ("ready", None),
|
||||
Err(e) => {
|
||||
tracing::error!("Memory re-indexing failed for item {}: {}", item.id, e);
|
||||
("failed", Some(e.to_string()))
|
||||
}
|
||||
};
|
||||
let _ = boosapi_core::repo::memory::update_item_index_status(
|
||||
&db,
|
||||
&item.id,
|
||||
status,
|
||||
err_msg.as_deref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Emit per-item event for real-time progress
|
||||
let _ = app.emit(
|
||||
"memory-item-indexed",
|
||||
serde_json::json!({
|
||||
"itemId": item.id,
|
||||
"success": result.is_ok(),
|
||||
"status": status,
|
||||
"error": err_msg,
|
||||
"isRebuild": true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let _ = app.emit(
|
||||
"memory-rebuild-complete",
|
||||
serde_json::json!({ "namespaceId": namespace_id }),
|
||||
);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clear_memory_index(
|
||||
state: State<'_, AppState>,
|
||||
namespace_id: String,
|
||||
) -> Result<(), String> {
|
||||
let collection_id = format!("mem_{}", namespace_id);
|
||||
state
|
||||
.vector_store
|
||||
.delete_collection(&collection_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Reset all items to "pending"
|
||||
let items = boosapi_core::repo::memory::list_items(&state.sea_db, &namespace_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
for item in items {
|
||||
let _ = boosapi_core::repo::memory::update_item_index_status(
|
||||
&state.sea_db,
|
||||
&item.id,
|
||||
"pending",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reindex_memory_item(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
namespace_id: String,
|
||||
item_id: String,
|
||||
) -> Result<(), String> {
|
||||
let ns = boosapi_core::repo::memory::get_namespace(&state.sea_db, &namespace_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let embedding_provider = ns
|
||||
.embedding_provider
|
||||
.ok_or("No embedding provider configured")?;
|
||||
|
||||
let items = boosapi_core::repo::memory::list_items(&state.sea_db, &namespace_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let item = items
|
||||
.into_iter()
|
||||
.find(|i| i.id == item_id)
|
||||
.ok_or("Item not found")?;
|
||||
|
||||
let _ = boosapi_core::repo::memory::update_item_index_status(
|
||||
&state.sea_db,
|
||||
&item_id,
|
||||
"indexing",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let db = state.sea_db.clone();
|
||||
let master_key = state.master_key;
|
||||
let vector_store = state.vector_store.clone();
|
||||
let ep = embedding_provider.clone();
|
||||
let ns_id = namespace_id.clone();
|
||||
let iid = item_id.clone();
|
||||
let content = item.content.clone();
|
||||
let dims = ns.embedding_dimensions.map(|v| v as usize);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let collection_id = format!("mem_{}", ns_id);
|
||||
let _ = vector_store
|
||||
.delete_document_embeddings(&collection_id, &iid)
|
||||
.await;
|
||||
|
||||
let result = crate::indexing::index_memory_item(
|
||||
&db,
|
||||
&master_key,
|
||||
&vector_store,
|
||||
&ns_id,
|
||||
&iid,
|
||||
&content,
|
||||
&ep,
|
||||
dims,
|
||||
)
|
||||
.await;
|
||||
|
||||
let (status, err_msg) = match &result {
|
||||
Ok(_) => ("ready", None),
|
||||
Err(e) => {
|
||||
tracing::error!("Memory reindex failed for item {}: {}", iid, e);
|
||||
("failed", Some(e.to_string()))
|
||||
}
|
||||
};
|
||||
let _ = boosapi_core::repo::memory::update_item_index_status(
|
||||
&db,
|
||||
&iid,
|
||||
status,
|
||||
err_msg.as_deref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let _ = app.emit(
|
||||
"memory-item-indexed",
|
||||
serde_json::json!({
|
||||
"itemId": iid,
|
||||
"success": result.is_ok(),
|
||||
"status": status,
|
||||
"error": err_msg,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reorder_memory_namespaces(
|
||||
state: State<'_, AppState>,
|
||||
namespace_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
boosapi_core::repo::memory::reorder_namespaces(&state.sea_db, &namespace_ids)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
use crate::AppState;
|
||||
use boosapi_core::types::*;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_messages(
|
||||
state: State<'_, AppState>,
|
||||
conversation_id: String,
|
||||
) -> Result<Vec<Message>, String> {
|
||||
boosapi_core::repo::message::list_messages(&state.sea_db, &conversation_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_messages_page(
|
||||
state: State<'_, AppState>,
|
||||
conversation_id: String,
|
||||
limit: Option<u64>,
|
||||
before_message_id: Option<String>,
|
||||
) -> Result<MessagePage, String> {
|
||||
boosapi_core::repo::message::list_messages_page(
|
||||
&state.sea_db,
|
||||
&conversation_id,
|
||||
limit.unwrap_or(10),
|
||||
before_message_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_message(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||
boosapi_core::repo::message::delete_message(&state.sea_db, &id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_message_content(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
content: String,
|
||||
) -> Result<Message, String> {
|
||||
boosapi_core::repo::message::update_message_content(&state.sea_db, &id, &content)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clear_conversation_messages(
|
||||
state: State<'_, AppState>,
|
||||
conversation_id: String,
|
||||
) -> Result<u64, String> {
|
||||
let rows =
|
||||
boosapi_core::repo::message::clear_conversation_messages(&state.sea_db, &conversation_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Also clear the agent session's SDK context so the agent doesn't retain old history
|
||||
let _ = boosapi_core::repo::agent_session::clear_sdk_context_by_conversation_id(
|
||||
&state.sea_db,
|
||||
&conversation_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn export_conversation(
|
||||
state: State<'_, AppState>,
|
||||
conversation_id: String,
|
||||
format: String,
|
||||
) -> Result<String, String> {
|
||||
let conversation =
|
||||
boosapi_core::repo::conversation::get_conversation(&state.sea_db, &conversation_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let messages = boosapi_core::repo::message::list_messages(&state.sea_db, &conversation_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
match format.as_str() {
|
||||
"json" => serde_json::to_string_pretty(&serde_json::json!({
|
||||
"conversation": conversation,
|
||||
"messages": messages,
|
||||
}))
|
||||
.map_err(|e| e.to_string()),
|
||||
"markdown" => {
|
||||
let mut md = format!("# {}\n\n", conversation.title);
|
||||
for msg in &messages {
|
||||
let role = match msg.role {
|
||||
MessageRole::System => "System",
|
||||
MessageRole::User => "User",
|
||||
MessageRole::Assistant => "Assistant",
|
||||
MessageRole::Tool => "Tool",
|
||||
};
|
||||
md.push_str(&format!("## {}\n\n{}\n\n", role, msg.content));
|
||||
}
|
||||
Ok(md)
|
||||
}
|
||||
_ => Err(format!("Unsupported export format: {}", format)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_conversation_stats(
|
||||
state: State<'_, AppState>,
|
||||
conversation_id: String,
|
||||
) -> Result<ConversationStats, String> {
|
||||
boosapi_core::repo::message::get_conversation_stats(&state.sea_db, &conversation_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
pub mod agent;
|
||||
pub mod artifacts;
|
||||
pub mod backup;
|
||||
pub mod branches;
|
||||
pub mod chatgpt_import;
|
||||
pub mod cherry_import;
|
||||
pub mod context_sources;
|
||||
pub mod conversation_categories;
|
||||
pub mod conversations;
|
||||
pub mod desktop;
|
||||
pub mod drawing;
|
||||
pub(crate) mod file_cleanup;
|
||||
pub mod files;
|
||||
pub mod files_page;
|
||||
pub mod gateway;
|
||||
pub mod kelivo_import;
|
||||
pub mod knowledge;
|
||||
pub mod mcp;
|
||||
pub mod memory;
|
||||
pub mod messages;
|
||||
pub mod providers;
|
||||
pub mod s3;
|
||||
pub mod search;
|
||||
pub mod settings;
|
||||
pub mod skills;
|
||||
pub mod storage;
|
||||
pub mod webdav;
|
||||
@@ -0,0 +1,466 @@
|
||||
use crate::AppState;
|
||||
use boosapi_core::repo::provider_import::{ProviderImportBatchResult, ProviderImportCandidate};
|
||||
use boosapi_core::types::*;
|
||||
use std::time::Instant;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_providers(state: State<'_, AppState>) -> Result<Vec<ProviderConfig>, String> {
|
||||
boosapi_core::repo::provider::list_providers_merged(&state.sea_db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_provider(
|
||||
state: State<'_, AppState>,
|
||||
input: CreateProviderInput,
|
||||
) -> Result<ProviderConfig, String> {
|
||||
boosapi_core::repo::provider::create_provider(&state.sea_db, input)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn import_provider_from_deep_link(
|
||||
state: State<'_, AppState>,
|
||||
input: DeepLinkProviderImportInput,
|
||||
) -> Result<DeepLinkProviderImportResult, String> {
|
||||
boosapi_core::repo::provider::import_provider_from_deep_link(
|
||||
&state.sea_db,
|
||||
&state.master_key,
|
||||
input,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn scan_cc_switch_provider_imports(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<ProviderImportCandidate>, String> {
|
||||
boosapi_core::repo::provider_import::scan_cc_switch_provider_imports(
|
||||
&state.sea_db,
|
||||
&state.master_key,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn import_cc_switch_provider_configs(
|
||||
state: State<'_, AppState>,
|
||||
candidate_ids: Vec<String>,
|
||||
) -> Result<ProviderImportBatchResult, String> {
|
||||
boosapi_core::repo::provider_import::import_cc_switch_provider_configs(
|
||||
&state.sea_db,
|
||||
&state.master_key,
|
||||
candidate_ids,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_provider(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
input: UpdateProviderInput,
|
||||
) -> Result<ProviderConfig, String> {
|
||||
let real_id = boosapi_core::repo::provider::resolve_provider_id(&state.sea_db, &id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
boosapi_core::repo::provider::update_provider(&state.sea_db, &real_id, input)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_provider(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||
// Virtual built-in providers have no DB row — deletion is a no-op (they'll reappear)
|
||||
if id.starts_with("builtin_") {
|
||||
return Ok(());
|
||||
}
|
||||
boosapi_core::repo::provider::delete_provider(&state.sea_db, &id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn toggle_provider(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
let real_id = boosapi_core::repo::provider::resolve_provider_id(&state.sea_db, &id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
boosapi_core::repo::provider::toggle_provider(&state.sea_db, &real_id, enabled)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_provider_key(
|
||||
state: State<'_, AppState>,
|
||||
provider_id: String,
|
||||
raw_key: String,
|
||||
) -> Result<ProviderKey, String> {
|
||||
let real_id = boosapi_core::repo::provider::resolve_provider_id(&state.sea_db, &provider_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let encrypted =
|
||||
boosapi_core::crypto::encrypt_key(&raw_key, &state.master_key).map_err(|e| e.to_string())?;
|
||||
let prefix = if raw_key.len() >= 8 {
|
||||
format!("{}...", &raw_key[..8])
|
||||
} else {
|
||||
raw_key.clone()
|
||||
};
|
||||
boosapi_core::repo::provider::add_provider_key(&state.sea_db, &real_id, &encrypted, &prefix)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_provider_key(
|
||||
state: State<'_, AppState>,
|
||||
key_id: String,
|
||||
raw_key: String,
|
||||
) -> Result<ProviderKey, String> {
|
||||
let encrypted =
|
||||
boosapi_core::crypto::encrypt_key(&raw_key, &state.master_key).map_err(|e| e.to_string())?;
|
||||
let prefix = if raw_key.len() >= 8 {
|
||||
format!("{}...", &raw_key[..8])
|
||||
} else {
|
||||
raw_key.clone()
|
||||
};
|
||||
boosapi_core::repo::provider::update_provider_key(&state.sea_db, &key_id, &encrypted, &prefix)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_provider_key(state: State<'_, AppState>, key_id: String) -> Result<(), String> {
|
||||
boosapi_core::repo::provider::delete_provider_key(&state.sea_db, &key_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn toggle_provider_key(
|
||||
state: State<'_, AppState>,
|
||||
key_id: String,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
boosapi_core::repo::provider::toggle_provider_key(&state.sea_db, &key_id, enabled)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_decrypted_provider_key(
|
||||
state: State<'_, AppState>,
|
||||
key_id: String,
|
||||
) -> Result<String, String> {
|
||||
let key_row = boosapi_core::repo::provider::get_provider_key(&state.sea_db, &key_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
boosapi_core::crypto::decrypt_key(&key_row.key_encrypted, &state.master_key)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn validate_provider_key(
|
||||
state: State<'_, AppState>,
|
||||
key_id: String,
|
||||
) -> Result<bool, String> {
|
||||
let key_row = boosapi_core::repo::provider::get_provider_key(&state.sea_db, &key_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let decrypted = boosapi_core::crypto::decrypt_key(&key_row.key_encrypted, &state.master_key)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let provider = boosapi_core::repo::provider::get_provider(&state.sea_db, &key_row.provider_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Use the registry to validate by listing models
|
||||
let registry = boosapi_providers::registry::ProviderRegistry::create_default();
|
||||
let provider_type_str = match provider.provider_type {
|
||||
ProviderType::OpenAI => "openai",
|
||||
ProviderType::OpenAIResponses => "openai_responses",
|
||||
ProviderType::DeepSeek => "deepseek",
|
||||
ProviderType::XAI => "xai",
|
||||
ProviderType::GLM => "glm",
|
||||
ProviderType::SiliconFlow => "siliconflow",
|
||||
ProviderType::Anthropic => "anthropic",
|
||||
ProviderType::Gemini => "gemini",
|
||||
ProviderType::Jina => "jina",
|
||||
ProviderType::Cohere => "cohere",
|
||||
ProviderType::Voyage => "voyage",
|
||||
ProviderType::Custom => "custom",
|
||||
};
|
||||
let adapter = registry
|
||||
.get(provider_type_str)
|
||||
.ok_or_else(|| format!("No adapter for provider type: {}", provider_type_str))?;
|
||||
let global_settings = boosapi_core::repo::settings::get_settings(&state.sea_db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let resolved_proxy =
|
||||
boosapi_core::types::ProviderProxyConfig::resolve(&provider.proxy_config, &global_settings);
|
||||
let ctx = boosapi_providers::ProviderRequestContext {
|
||||
api_key: decrypted,
|
||||
key_id: key_id.clone(),
|
||||
provider_id: provider.id.clone(),
|
||||
base_url: Some(boosapi_providers::resolve_base_url_for_type(
|
||||
&provider.api_host,
|
||||
&provider.provider_type,
|
||||
)),
|
||||
api_path: provider.api_path.clone(),
|
||||
proxy_config: resolved_proxy,
|
||||
custom_headers: provider
|
||||
.custom_headers
|
||||
.as_ref()
|
||||
.and_then(|s| serde_json::from_str(s).ok()),
|
||||
};
|
||||
let valid = match adapter.validate_key(&ctx).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("Key validation failed for key {}: {}", key_id, e);
|
||||
// Update as invalid, then return the error
|
||||
let _ =
|
||||
boosapi_core::repo::provider::update_key_validation(&state.sea_db, &key_id, false)
|
||||
.await;
|
||||
return Err(e.to_string());
|
||||
}
|
||||
};
|
||||
// Update validation timestamp
|
||||
boosapi_core::repo::provider::update_key_validation(&state.sea_db, &key_id, valid)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(valid)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_models(
|
||||
state: State<'_, AppState>,
|
||||
provider_id: String,
|
||||
models: Vec<Model>,
|
||||
) -> Result<(), String> {
|
||||
let real_id = boosapi_core::repo::provider::resolve_provider_id(&state.sea_db, &provider_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
boosapi_core::repo::provider::save_models_from_user_selection(&state.sea_db, &real_id, &models)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn toggle_model(
|
||||
state: State<'_, AppState>,
|
||||
provider_id: String,
|
||||
model_id: String,
|
||||
enabled: bool,
|
||||
) -> Result<Model, String> {
|
||||
let real_id = boosapi_core::repo::provider::resolve_provider_id(&state.sea_db, &provider_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
boosapi_core::repo::provider::toggle_model(&state.sea_db, &real_id, &model_id, enabled)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_model_params(
|
||||
state: State<'_, AppState>,
|
||||
provider_id: String,
|
||||
model_id: String,
|
||||
overrides: ModelParamOverrides,
|
||||
) -> Result<Model, String> {
|
||||
let real_id = boosapi_core::repo::provider::resolve_provider_id(&state.sea_db, &provider_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
boosapi_core::repo::provider::update_model_params(&state.sea_db, &real_id, &model_id, overrides)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fetch_remote_models(
|
||||
state: State<'_, AppState>,
|
||||
provider_id: String,
|
||||
) -> Result<Vec<Model>, String> {
|
||||
let real_id = boosapi_core::repo::provider::resolve_provider_id(&state.sea_db, &provider_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let provider = boosapi_core::repo::provider::get_provider(&state.sea_db, &real_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Get an enabled key for the provider
|
||||
let key_row = boosapi_core::repo::provider::get_active_key(&state.sea_db, &real_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let decrypted = boosapi_core::crypto::decrypt_key(&key_row.key_encrypted, &state.master_key)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let registry = boosapi_providers::registry::ProviderRegistry::create_default();
|
||||
let provider_type_str = match provider.provider_type {
|
||||
ProviderType::OpenAI => "openai",
|
||||
ProviderType::OpenAIResponses => "openai_responses",
|
||||
ProviderType::DeepSeek => "deepseek",
|
||||
ProviderType::XAI => "xai",
|
||||
ProviderType::GLM => "glm",
|
||||
ProviderType::SiliconFlow => "siliconflow",
|
||||
ProviderType::Anthropic => "anthropic",
|
||||
ProviderType::Gemini => "gemini",
|
||||
ProviderType::Jina => "jina",
|
||||
ProviderType::Cohere => "cohere",
|
||||
ProviderType::Voyage => "voyage",
|
||||
ProviderType::Custom => "custom",
|
||||
};
|
||||
let adapter = registry
|
||||
.get(provider_type_str)
|
||||
.ok_or_else(|| format!("No adapter for provider type: {}", provider_type_str))?;
|
||||
let global_settings = boosapi_core::repo::settings::get_settings(&state.sea_db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let resolved_proxy =
|
||||
boosapi_core::types::ProviderProxyConfig::resolve(&provider.proxy_config, &global_settings);
|
||||
let ctx = boosapi_providers::ProviderRequestContext {
|
||||
api_key: decrypted,
|
||||
key_id: key_row.id.clone(),
|
||||
provider_id: provider.id.clone(),
|
||||
base_url: Some(boosapi_providers::resolve_base_url_for_type(
|
||||
&provider.api_host,
|
||||
&provider.provider_type,
|
||||
)),
|
||||
api_path: provider.api_path.clone(),
|
||||
proxy_config: resolved_proxy,
|
||||
custom_headers: provider
|
||||
.custom_headers
|
||||
.as_ref()
|
||||
.and_then(|s| serde_json::from_str(s).ok()),
|
||||
};
|
||||
adapter.list_models(&ctx).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Test a single model's availability by sending the minimal native request.
|
||||
/// Returns latency in milliseconds on success.
|
||||
#[tauri::command]
|
||||
pub async fn test_model(
|
||||
state: State<'_, AppState>,
|
||||
provider_id: String,
|
||||
model_id: String,
|
||||
) -> Result<u64, String> {
|
||||
let real_id = boosapi_core::repo::provider::resolve_provider_id(&state.sea_db, &provider_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let provider = boosapi_core::repo::provider::get_provider(&state.sea_db, &real_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let key_row = boosapi_core::repo::provider::get_active_key(&state.sea_db, &real_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let decrypted = boosapi_core::crypto::decrypt_key(&key_row.key_encrypted, &state.master_key)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let registry = boosapi_providers::registry::ProviderRegistry::create_default();
|
||||
let provider_type_str = match provider.provider_type {
|
||||
ProviderType::OpenAI => "openai",
|
||||
ProviderType::OpenAIResponses => "openai_responses",
|
||||
ProviderType::DeepSeek => "deepseek",
|
||||
ProviderType::XAI => "xai",
|
||||
ProviderType::GLM => "glm",
|
||||
ProviderType::SiliconFlow => "siliconflow",
|
||||
ProviderType::Anthropic => "anthropic",
|
||||
ProviderType::Gemini => "gemini",
|
||||
ProviderType::Jina => "jina",
|
||||
ProviderType::Cohere => "cohere",
|
||||
ProviderType::Voyage => "voyage",
|
||||
ProviderType::Custom => "custom",
|
||||
};
|
||||
let adapter = registry
|
||||
.get(provider_type_str)
|
||||
.ok_or_else(|| format!("No adapter for provider type: {}", provider_type_str))?;
|
||||
let global_settings = boosapi_core::repo::settings::get_settings(&state.sea_db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let resolved_proxy =
|
||||
boosapi_core::types::ProviderProxyConfig::resolve(&provider.proxy_config, &global_settings);
|
||||
let ctx = boosapi_providers::ProviderRequestContext {
|
||||
api_key: decrypted,
|
||||
key_id: key_row.id.clone(),
|
||||
provider_id: provider.id.clone(),
|
||||
base_url: Some(boosapi_providers::resolve_base_url_for_type(
|
||||
&provider.api_host,
|
||||
&provider.provider_type,
|
||||
)),
|
||||
api_path: provider.api_path.clone(),
|
||||
proxy_config: resolved_proxy,
|
||||
custom_headers: provider
|
||||
.custom_headers
|
||||
.as_ref()
|
||||
.and_then(|s| serde_json::from_str(s).ok()),
|
||||
};
|
||||
let model_type = provider
|
||||
.models
|
||||
.iter()
|
||||
.find(|model| model.model_id == model_id)
|
||||
.map(|model| &model.model_type);
|
||||
let start = Instant::now();
|
||||
if model_type.is_some_and(|model_type| *model_type == ModelType::Rerank) {
|
||||
adapter
|
||||
.rerank(
|
||||
&ctx,
|
||||
RerankRequest {
|
||||
model: model_id,
|
||||
query: "test".into(),
|
||||
documents: vec!["test".into()],
|
||||
top_n: 1,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
let request = ChatRequest {
|
||||
model: model_id,
|
||||
messages: vec![ChatMessage {
|
||||
role: "user".into(),
|
||||
content: ChatContent::Text("hi".into()),
|
||||
reasoning_content: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
}],
|
||||
stream: false,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
max_tokens: Some(1),
|
||||
tools: None,
|
||||
thinking_budget: None,
|
||||
thinking_level: None,
|
||||
reasoning_profile: None,
|
||||
use_max_completion_tokens: None,
|
||||
thinking_param_style: None,
|
||||
extra_body: None,
|
||||
};
|
||||
adapter
|
||||
.chat(&ctx, request)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(start.elapsed().as_millis() as u64)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reorder_providers(
|
||||
state: State<'_, AppState>,
|
||||
provider_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
// Materialize any virtual built-in providers so sort_order can be persisted
|
||||
let mut real_ids = Vec::with_capacity(provider_ids.len());
|
||||
for id in &provider_ids {
|
||||
let real_id = boosapi_core::repo::provider::resolve_provider_id(&state.sea_db, id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
real_ids.push(real_id);
|
||||
}
|
||||
boosapi_core::repo::provider::reorder_providers(&state.sea_db, &real_ids)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
use crate::AppState;
|
||||
use boosapi_core::crypto::{decrypt_key, encrypt_key};
|
||||
use boosapi_core::repo::{backup, settings as settings_repo};
|
||||
use boosapi_core::s3_backup::{S3BackupClient, S3Config, S3FileInfo};
|
||||
use boosapi_core::webdav;
|
||||
use sea_orm::{ConnectionTrait, DatabaseConnection, EntityTrait, PaginatorTrait, Statement};
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::State;
|
||||
|
||||
const S3_ACCESS_KEY_ID_SETTING: &str = "s3_access_key_id_encrypted";
|
||||
const S3_SECRET_ACCESS_KEY_SETTING: &str = "s3_secret_access_key_encrypted";
|
||||
const S3_SESSION_TOKEN_SETTING: &str = "s3_session_token_encrypted";
|
||||
const S3_LAST_SYNC_TIME_SETTING: &str = "s3_last_sync_time";
|
||||
const S3_LAST_SYNC_STATUS_SETTING: &str = "s3_last_sync_status";
|
||||
|
||||
#[derive(Default)]
|
||||
struct TempPathCleanup {
|
||||
files: Vec<PathBuf>,
|
||||
dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl TempPathCleanup {
|
||||
fn track_file<P: Into<PathBuf>>(&mut self, path: P) {
|
||||
self.files.push(path.into());
|
||||
}
|
||||
|
||||
fn track_dir<P: Into<PathBuf>>(&mut self, path: P) {
|
||||
self.dirs.push(path.into());
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempPathCleanup {
|
||||
fn drop(&mut self) {
|
||||
for path in &self.files {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
for path in &self.dirs {
|
||||
let _ = std::fs::remove_dir_all(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_s3_config(state: State<'_, AppState>) -> Result<S3Config, String> {
|
||||
get_s3_config_from_db(&state.sea_db, &state.master_key).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_s3_config(state: State<'_, AppState>, config: S3Config) -> Result<(), String> {
|
||||
save_s3_config_to_db(&state.sea_db, &state.master_key, config).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn s3_check_connection(config: S3Config) -> Result<bool, String> {
|
||||
let client = S3BackupClient::new(config)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
client.check_connection().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn s3_backup(state: State<'_, AppState>) -> Result<String, String> {
|
||||
do_s3_backup_impl(&state.sea_db, &state.master_key, &state.app_data_dir).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn s3_list_backups(state: State<'_, AppState>) -> Result<Vec<S3FileInfo>, String> {
|
||||
let config = get_s3_config_from_db(&state.sea_db, &state.master_key).await?;
|
||||
if config.bucket.trim().is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let client = S3BackupClient::new(config)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
client.list_files().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn s3_restore(
|
||||
app: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
file_name: String,
|
||||
) -> Result<(), String> {
|
||||
let config = get_s3_config_from_db(&state.sea_db, &state.master_key).await?;
|
||||
let settings = settings_repo::get_settings(&state.sea_db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let decoded_backup_dir = boosapi_core::path_vars::decode_path_opt(&settings.backup_dir);
|
||||
let backup_dir = backup::resolve_backup_dir(decoded_backup_dir.as_deref(), &state.app_data_dir);
|
||||
backup::ensure_backup_dir(&backup_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut cleanup = TempPathCleanup::default();
|
||||
|
||||
let zip_path = backup_dir.join(&file_name);
|
||||
cleanup.track_file(&zip_path);
|
||||
let client = S3BackupClient::new(config)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
client
|
||||
.download_file(&file_name, &zip_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let temp_dir = backup_dir.join("_s3_restore_temp");
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
cleanup.track_dir(&temp_dir);
|
||||
let contents = webdav::extract_backup_zip(&zip_path, &temp_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(expected) = contents
|
||||
.metadata
|
||||
.get("db_checksum")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
let ok =
|
||||
webdav::verify_db_checksum(&contents.db_path, expected).map_err(|e| e.to_string())?;
|
||||
if !ok {
|
||||
return Err("Backup checksum verification failed — file may be corrupted".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let db_path = state
|
||||
.db_path
|
||||
.strip_prefix("sqlite:")
|
||||
.unwrap_or(&state.db_path);
|
||||
let safety_backup = backup_dir.join("_pre_s3_restore_safety.db");
|
||||
let _ = std::fs::copy(db_path, &safety_backup);
|
||||
let master_key_dest = state.app_data_dir.join("master.key");
|
||||
let safety_key_backup = temp_dir.join("_pre_s3_restore_safety.key");
|
||||
let _ = std::fs::copy(&master_key_dest, &safety_key_backup);
|
||||
cleanup.track_file(&safety_key_backup);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
let _ = std::fs::set_permissions(&safety_key_backup, perms);
|
||||
}
|
||||
|
||||
if let Some(ref key_path) = contents.master_key_path {
|
||||
std::fs::copy(key_path, &master_key_dest)
|
||||
.map_err(|e| format!("Failed to restore master.key: {}", e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
let _ = std::fs::set_permissions(&master_key_dest, perms);
|
||||
}
|
||||
}
|
||||
|
||||
backup::restore_sqlite_backup(contents.db_path.to_str().unwrap_or(""), db_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let _ = std::fs::remove_file(format!("{}-wal", db_path));
|
||||
let _ = std::fs::remove_file(format!("{}-shm", db_path));
|
||||
|
||||
if contents.has_documents {
|
||||
let docs_source = temp_dir.join("documents");
|
||||
let docs_target = webdav::documents_sync_root();
|
||||
if docs_source.exists() {
|
||||
copy_directory(&docs_source, &docs_target)
|
||||
.map_err(|e| format!("Failed to restore documents: {}", e))?;
|
||||
}
|
||||
}
|
||||
|
||||
if contents.has_workspace {
|
||||
let ws_source = temp_dir.join("workspace");
|
||||
let ws_target = state.app_data_dir.join("workspace");
|
||||
if ws_source.exists() {
|
||||
copy_directory(&ws_source, &ws_target)
|
||||
.map_err(|e| format!("Failed to restore workspace: {}", e))?;
|
||||
}
|
||||
}
|
||||
|
||||
app.restart();
|
||||
|
||||
#[allow(unreachable_code)]
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn s3_delete_backup(state: State<'_, AppState>, file_name: String) -> Result<(), String> {
|
||||
let config = get_s3_config_from_db(&state.sea_db, &state.master_key).await?;
|
||||
let client = S3BackupClient::new(config)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
client
|
||||
.delete_file(&file_name)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_s3_sync_status(state: State<'_, AppState>) -> Result<serde_json::Value, String> {
|
||||
let last_time = settings_repo::get_setting(&state.sea_db, S3_LAST_SYNC_TIME_SETTING)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let last_status = settings_repo::get_setting(&state.sea_db, S3_LAST_SYNC_STATUS_SETTING)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"lastSyncTime": last_time,
|
||||
"lastSyncStatus": last_status,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restart_s3_sync(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let settings = settings_repo::get_settings(&state.sea_db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut guard = state.s3_sync_handle.lock().await;
|
||||
if let Some(h) = guard.take() {
|
||||
h.abort();
|
||||
}
|
||||
|
||||
if !settings.s3_sync_enabled || settings.s3_sync_interval_minutes == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let db = state.sea_db.clone();
|
||||
let master_key = state.master_key;
|
||||
let app_data_dir = state.app_data_dir.clone();
|
||||
let interval_minutes = settings.s3_sync_interval_minutes;
|
||||
let task = spawn_s3_sync_task(
|
||||
db,
|
||||
master_key,
|
||||
app_data_dir,
|
||||
interval_minutes,
|
||||
interval_minutes as u64 * 60,
|
||||
);
|
||||
|
||||
*guard = Some(task);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn get_s3_config_from_db(
|
||||
db: &DatabaseConnection,
|
||||
master_key: &[u8; 32],
|
||||
) -> Result<S3Config, String> {
|
||||
let settings = settings_repo::get_settings(db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(S3Config {
|
||||
bucket: settings.s3_bucket.unwrap_or_default(),
|
||||
region: settings
|
||||
.s3_region
|
||||
.unwrap_or_else(|| "us-east-1".to_string()),
|
||||
prefix: settings.s3_prefix.unwrap_or_else(|| "boosapi/".to_string()),
|
||||
endpoint_url: settings.s3_endpoint,
|
||||
force_path_style: settings.s3_force_path_style,
|
||||
use_default_credentials: settings.s3_use_default_credentials,
|
||||
access_key_id: decrypt_setting(db, master_key, S3_ACCESS_KEY_ID_SETTING).await?,
|
||||
secret_access_key: decrypt_setting(db, master_key, S3_SECRET_ACCESS_KEY_SETTING).await?,
|
||||
session_token: Some(decrypt_setting(db, master_key, S3_SESSION_TOKEN_SETTING).await?)
|
||||
.filter(|v| !v.is_empty()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn save_s3_config_to_db(
|
||||
db: &DatabaseConnection,
|
||||
master_key: &[u8; 32],
|
||||
config: S3Config,
|
||||
) -> Result<(), String> {
|
||||
let mut settings = settings_repo::get_settings(db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
settings.s3_bucket = Some(config.bucket);
|
||||
settings.s3_region = Some(config.region);
|
||||
settings.s3_endpoint = config.endpoint_url;
|
||||
settings.s3_prefix = Some(config.prefix);
|
||||
settings.s3_force_path_style = config.force_path_style;
|
||||
settings.s3_use_default_credentials = config.use_default_credentials;
|
||||
|
||||
settings_repo::save_settings(db, &settings)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
save_encrypted_setting(
|
||||
db,
|
||||
master_key,
|
||||
S3_ACCESS_KEY_ID_SETTING,
|
||||
if config.use_default_credentials {
|
||||
""
|
||||
} else {
|
||||
&config.access_key_id
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
save_encrypted_setting(
|
||||
db,
|
||||
master_key,
|
||||
S3_SECRET_ACCESS_KEY_SETTING,
|
||||
if config.use_default_credentials {
|
||||
""
|
||||
} else {
|
||||
&config.secret_access_key
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
save_encrypted_setting(
|
||||
db,
|
||||
master_key,
|
||||
S3_SESSION_TOKEN_SETTING,
|
||||
if config.use_default_credentials {
|
||||
""
|
||||
} else {
|
||||
config.session_token.as_deref().unwrap_or("")
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn decrypt_setting(
|
||||
db: &DatabaseConnection,
|
||||
master_key: &[u8; 32],
|
||||
key: &str,
|
||||
) -> Result<String, String> {
|
||||
let encrypted = settings_repo::get_setting(db, key)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(match encrypted {
|
||||
Some(enc) if !enc.is_empty() => decrypt_key(&enc, master_key).unwrap_or_default(),
|
||||
_ => String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn save_encrypted_setting(
|
||||
db: &DatabaseConnection,
|
||||
master_key: &[u8; 32],
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> Result<(), String> {
|
||||
if value.trim().is_empty() {
|
||||
settings_repo::set_setting(db, key, "")
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
} else {
|
||||
let encrypted = encrypt_key(value, master_key).map_err(|e| e.to_string())?;
|
||||
settings_repo::set_setting(db, key, &encrypted)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn do_s3_backup_impl(
|
||||
db: &DatabaseConnection,
|
||||
master_key: &[u8; 32],
|
||||
app_data_dir: &Path,
|
||||
) -> Result<String, String> {
|
||||
let result = do_s3_backup_once(db, master_key, app_data_dir).await;
|
||||
record_s3_sync_status(db, if result.is_ok() { "success" } else { "failed" }).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn do_s3_backup_once(
|
||||
db: &DatabaseConnection,
|
||||
master_key: &[u8; 32],
|
||||
app_data_dir: &Path,
|
||||
) -> Result<String, String> {
|
||||
let config = get_s3_config_from_db(db, master_key).await?;
|
||||
if config.bucket.trim().is_empty() {
|
||||
return Err("S3 is not configured".to_string());
|
||||
}
|
||||
|
||||
let settings = settings_repo::get_settings(db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let decoded_backup_dir = boosapi_core::path_vars::decode_path_opt(&settings.backup_dir);
|
||||
let backup_dir = backup::resolve_backup_dir(decoded_backup_dir.as_deref(), app_data_dir);
|
||||
backup::ensure_backup_dir(&backup_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let temp_id = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
let temp_db_path = backup_dir.join(format!("_s3_temp_{}.db", temp_id));
|
||||
let _ = std::fs::remove_file(&temp_db_path);
|
||||
let mut cleanup = TempPathCleanup::default();
|
||||
cleanup.track_file(&temp_db_path);
|
||||
|
||||
let db_str = temp_db_path.to_string_lossy().to_string();
|
||||
db.execute(Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Sqlite,
|
||||
format!("VACUUM INTO '{}'", db_str.replace('\'', "''")),
|
||||
))
|
||||
.await
|
||||
.map_err(|e| format!("VACUUM INTO failed: {}", e))?;
|
||||
|
||||
let object_counts = count_objects_json(db).await;
|
||||
let documents_dir = if settings.s3_include_documents {
|
||||
let docs_root = webdav::documents_sync_root();
|
||||
docs_root.exists().then_some(docs_root)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let workspace_root = app_data_dir.join("workspace");
|
||||
let workspace_dir = workspace_root.exists().then_some(workspace_root);
|
||||
|
||||
let master_key_path = app_data_dir.join("master.key");
|
||||
let zip_filename = webdav::generate_backup_filename();
|
||||
let zip_path = backup_dir.join(&zip_filename);
|
||||
cleanup.track_file(&zip_path);
|
||||
webdav::create_backup_zip(
|
||||
&temp_db_path,
|
||||
documents_dir.as_deref(),
|
||||
workspace_dir.as_deref(),
|
||||
Some(&master_key_path),
|
||||
&zip_path,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
&object_counts,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let client = S3BackupClient::new(config)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
client
|
||||
.upload_file(&zip_filename, &zip_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let _ = std::fs::remove_file(&temp_db_path);
|
||||
let _ = std::fs::remove_file(&zip_path);
|
||||
|
||||
if settings.s3_max_remote_backups > 0 {
|
||||
cleanup_remote_backups(&client, settings.s3_max_remote_backups).await;
|
||||
}
|
||||
|
||||
Ok(zip_filename)
|
||||
}
|
||||
|
||||
async fn count_objects_json(db: &DatabaseConnection) -> String {
|
||||
use boosapi_core::entity::*;
|
||||
|
||||
let conv_count = conversations::Entity::find().count(db).await.unwrap_or(0);
|
||||
let msg_count = messages::Entity::find().count(db).await.unwrap_or(0);
|
||||
let provider_count = providers::Entity::find().count(db).await.unwrap_or(0);
|
||||
|
||||
serde_json::json!({
|
||||
"conversations": conv_count,
|
||||
"messages": msg_count,
|
||||
"providers": provider_count,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn cleanup_remote_backups(client: &S3BackupClient, max_per_host: u32) {
|
||||
if let Ok(files) = client.list_files().await {
|
||||
for file_name in backup_file_names_to_delete(files, max_per_host) {
|
||||
if let Err(e) = client.delete_file(&file_name).await {
|
||||
tracing::warn!("Failed to clean up old S3 backup {}: {}", file_name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn backup_file_names_to_delete(files: Vec<S3FileInfo>, max_per_host: u32) -> Vec<String> {
|
||||
if max_per_host == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut by_host: std::collections::HashMap<String, Vec<S3FileInfo>> =
|
||||
std::collections::HashMap::new();
|
||||
for file in files {
|
||||
by_host.entry(file.hostname.clone()).or_default().push(file);
|
||||
}
|
||||
|
||||
let mut to_delete = Vec::new();
|
||||
for (_, mut host_files) in by_host {
|
||||
host_files.sort_by(|a, b| b.file_name.cmp(&a.file_name));
|
||||
if host_files.len() > max_per_host as usize {
|
||||
to_delete.extend(
|
||||
host_files
|
||||
.split_off(max_per_host as usize)
|
||||
.into_iter()
|
||||
.map(|f| f.file_name),
|
||||
);
|
||||
}
|
||||
}
|
||||
to_delete.sort();
|
||||
to_delete
|
||||
}
|
||||
|
||||
fn copy_directory(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dst)?;
|
||||
for entry in std::fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let target = dst.join(entry.file_name());
|
||||
if entry.file_type()?.is_dir() {
|
||||
copy_directory(&entry.path(), &target)?;
|
||||
} else {
|
||||
std::fs::copy(entry.path(), &target)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn record_s3_sync_status(db: &DatabaseConnection, status: &str) {
|
||||
let timestamp = webdav::sync_status_timestamp();
|
||||
let _ = settings_repo::set_setting(db, S3_LAST_SYNC_TIME_SETTING, ×tamp).await;
|
||||
let _ = settings_repo::set_setting(db, S3_LAST_SYNC_STATUS_SETTING, status).await;
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_s3_sync_task(
|
||||
db: DatabaseConnection,
|
||||
master_key: [u8; 32],
|
||||
app_data_dir: PathBuf,
|
||||
interval_minutes: u32,
|
||||
initial_delay_secs: u64,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let interval = std::time::Duration::from_secs(interval_minutes as u64 * 60);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(initial_delay_secs)).await;
|
||||
loop {
|
||||
match do_s3_backup_impl(&db, &master_key, &app_data_dir).await {
|
||||
Ok(name) => tracing::info!("S3 auto-sync completed: {}", name),
|
||||
Err(e) => tracing::warn!("S3 auto-sync failed: {}", e),
|
||||
}
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn remote_cleanup_keeps_newest_backups_per_host() {
|
||||
let files = vec![
|
||||
file("boosapi-backup-20260513_010203.alpha.zip", "alpha"),
|
||||
file("boosapi-backup-20260515_010203.alpha.zip", "alpha"),
|
||||
file("boosapi-backup-20260514_010203.alpha.zip", "alpha"),
|
||||
file("boosapi-backup-20260512_010203.beta.zip", "beta"),
|
||||
];
|
||||
|
||||
let to_delete = backup_file_names_to_delete(files, 2);
|
||||
|
||||
assert_eq!(
|
||||
to_delete,
|
||||
vec!["boosapi-backup-20260513_010203.alpha.zip".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_s3_config_round_trips_encrypted_credentials() {
|
||||
let db = boosapi_core::db::create_test_pool().await.unwrap().conn;
|
||||
let master_key = [7; 32];
|
||||
let config = S3Config {
|
||||
bucket: "bucket-a".to_string(),
|
||||
region: "us-west-2".to_string(),
|
||||
prefix: "boosapi/backups".to_string(),
|
||||
endpoint_url: Some("https://s3.example.com".to_string()),
|
||||
force_path_style: true,
|
||||
use_default_credentials: false,
|
||||
access_key_id: "access".to_string(),
|
||||
secret_access_key: "secret".to_string(),
|
||||
session_token: Some("token".to_string()),
|
||||
};
|
||||
|
||||
save_s3_config_to_db(&db, &master_key, config.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let loaded = get_s3_config_from_db(&db, &master_key).await.unwrap();
|
||||
|
||||
assert_eq!(loaded.bucket, config.bucket);
|
||||
assert_eq!(loaded.region, config.region);
|
||||
assert_eq!(loaded.prefix, config.prefix);
|
||||
assert_eq!(loaded.endpoint_url, config.endpoint_url);
|
||||
assert!(loaded.force_path_style);
|
||||
assert_eq!(loaded.access_key_id, "access");
|
||||
assert_eq!(loaded.secret_access_key, "secret");
|
||||
assert_eq!(loaded.session_token.as_deref(), Some("token"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_credential_config_clears_stored_secret_values() {
|
||||
let db = boosapi_core::db::create_test_pool().await.unwrap().conn;
|
||||
let master_key = [9; 32];
|
||||
let explicit = S3Config {
|
||||
bucket: "bucket-a".to_string(),
|
||||
region: "us-west-2".to_string(),
|
||||
prefix: "boosapi/backups".to_string(),
|
||||
use_default_credentials: false,
|
||||
access_key_id: "access".to_string(),
|
||||
secret_access_key: "secret".to_string(),
|
||||
session_token: Some("token".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
save_s3_config_to_db(&db, &master_key, explicit)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let default_credentials = S3Config {
|
||||
bucket: "bucket-a".to_string(),
|
||||
region: "us-west-2".to_string(),
|
||||
prefix: "boosapi/backups".to_string(),
|
||||
use_default_credentials: true,
|
||||
..Default::default()
|
||||
};
|
||||
save_s3_config_to_db(&db, &master_key, default_credentials)
|
||||
.await
|
||||
.unwrap();
|
||||
let loaded = get_s3_config_from_db(&db, &master_key).await.unwrap();
|
||||
|
||||
assert!(loaded.use_default_credentials);
|
||||
assert!(loaded.access_key_id.is_empty());
|
||||
assert!(loaded.secret_access_key.is_empty());
|
||||
assert_eq!(loaded.session_token, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_cleanup_removes_tracked_safety_key_files() {
|
||||
let temp_root = std::env::temp_dir().join(format!(
|
||||
"boosapi-s3-restore-cleanup-{}",
|
||||
boosapi_core::utils::gen_id()
|
||||
));
|
||||
std::fs::create_dir_all(&temp_root).expect("create temp root");
|
||||
let safety_key = temp_root.join("_pre_s3_restore_safety.key");
|
||||
std::fs::write(&safety_key, b"secret").expect("write safety key");
|
||||
|
||||
{
|
||||
let mut cleanup = TempPathCleanup::default();
|
||||
cleanup.track_file(&safety_key);
|
||||
}
|
||||
|
||||
assert!(
|
||||
!safety_key.exists(),
|
||||
"restore cleanup must delete the plaintext safety key backup"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&temp_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temp_path_cleanup_removes_backup_temp_files_on_drop() {
|
||||
let temp_root = std::env::temp_dir().join(format!(
|
||||
"boosapi-s3-backup-cleanup-{}",
|
||||
boosapi_core::utils::gen_id()
|
||||
));
|
||||
std::fs::create_dir_all(&temp_root).expect("create temp root");
|
||||
let temp_db = temp_root.join("_s3_temp_123.db");
|
||||
let temp_zip = temp_root.join("boosapi-backup-20260519_010203.host.zip");
|
||||
std::fs::write(&temp_db, b"db").expect("write temp db");
|
||||
std::fs::write(&temp_zip, b"zip").expect("write temp zip");
|
||||
|
||||
{
|
||||
let mut cleanup = TempPathCleanup::default();
|
||||
cleanup.track_file(&temp_db);
|
||||
cleanup.track_file(&temp_zip);
|
||||
}
|
||||
|
||||
assert!(
|
||||
!temp_db.exists(),
|
||||
"backup cleanup must delete the temporary database copy"
|
||||
);
|
||||
assert!(
|
||||
!temp_zip.exists(),
|
||||
"backup cleanup must delete the generated temporary ZIP"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&temp_root);
|
||||
}
|
||||
|
||||
fn file(file_name: &str, hostname: &str) -> S3FileInfo {
|
||||
S3FileInfo {
|
||||
file_name: file_name.to_string(),
|
||||
size: 1,
|
||||
last_modified: String::new(),
|
||||
hostname: hostname.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use crate::AppState;
|
||||
use boosapi_core::entity::search_providers;
|
||||
use boosapi_core::search::{self, SearchResponse, TestResult};
|
||||
use boosapi_core::types::*;
|
||||
use sea_orm::EntityTrait;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_search_providers(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<SearchProvider>, String> {
|
||||
boosapi_core::repo::search_provider::list_search_providers(&state.sea_db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_search_provider(
|
||||
state: State<'_, AppState>,
|
||||
mut input: CreateSearchProviderInput,
|
||||
) -> Result<SearchProvider, String> {
|
||||
if let Some(ref raw_key) = input.api_key {
|
||||
if !raw_key.is_empty() {
|
||||
let encrypted = boosapi_core::crypto::encrypt_key(raw_key, &state.master_key)
|
||||
.map_err(|e| e.to_string())?;
|
||||
input.api_key = Some(encrypted);
|
||||
}
|
||||
}
|
||||
boosapi_core::repo::search_provider::create_search_provider(&state.sea_db, input)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_search_provider(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
mut input: CreateSearchProviderInput,
|
||||
) -> Result<SearchProvider, String> {
|
||||
if let Some(ref raw_key) = input.api_key {
|
||||
if !raw_key.is_empty() {
|
||||
let encrypted = boosapi_core::crypto::encrypt_key(raw_key, &state.master_key)
|
||||
.map_err(|e| e.to_string())?;
|
||||
input.api_key = Some(encrypted);
|
||||
}
|
||||
}
|
||||
boosapi_core::repo::search_provider::update_search_provider(&state.sea_db, &id, input)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_search_provider(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||
boosapi_core::repo::search_provider::delete_search_provider(&state.sea_db, &id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Fetch the raw DB model and decrypt its API key
|
||||
async fn get_provider_with_key(
|
||||
state: &AppState,
|
||||
id: &str,
|
||||
) -> Result<(search_providers::Model, String), String> {
|
||||
let model = search_providers::Entity::find_by_id(id)
|
||||
.one(&state.sea_db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("SearchProvider {} not found", id))?;
|
||||
|
||||
let api_key = match &model.api_key_ref {
|
||||
Some(encrypted) if !encrypted.is_empty() => {
|
||||
boosapi_core::crypto::decrypt_key(encrypted, &state.master_key)
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
Ok((model, api_key))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn test_search_provider(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<TestResult, String> {
|
||||
let (model, api_key) = get_provider_with_key(&state, &id).await?;
|
||||
|
||||
Ok(search::test_provider(
|
||||
&model.provider_type,
|
||||
model.endpoint.as_deref(),
|
||||
&api_key,
|
||||
model.timeout_ms,
|
||||
)
|
||||
.await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn execute_search(
|
||||
state: State<'_, AppState>,
|
||||
provider_id: String,
|
||||
query: String,
|
||||
max_results: Option<i32>,
|
||||
) -> Result<SearchResponse, String> {
|
||||
let (model, api_key) = get_provider_with_key(&state, &provider_id).await?;
|
||||
|
||||
search::execute_search(
|
||||
&model.provider_type,
|
||||
model.endpoint.as_deref(),
|
||||
&api_key,
|
||||
&query,
|
||||
max_results.unwrap_or(model.result_limit),
|
||||
model.timeout_ms,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use crate::AppState;
|
||||
use boosapi_core::types::*;
|
||||
use std::sync::atomic::Ordering;
|
||||
use tauri::AppHandle;
|
||||
use tauri::Manager;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, String> {
|
||||
let mut settings = boosapi_core::repo::settings::get_settings(&state.sea_db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
settings.backup_dir = boosapi_core::path_vars::decode_path_opt(&settings.backup_dir);
|
||||
settings.gateway_ssl_cert_path =
|
||||
boosapi_core::path_vars::decode_path_opt(&settings.gateway_ssl_cert_path);
|
||||
settings.gateway_ssl_key_path =
|
||||
boosapi_core::path_vars::decode_path_opt(&settings.gateway_ssl_key_path);
|
||||
settings.agent_workspace_root =
|
||||
boosapi_core::path_vars::decode_path_opt(&settings.agent_workspace_root);
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_settings(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
mut settings: AppSettings,
|
||||
) -> Result<(), String> {
|
||||
settings.backup_dir = boosapi_core::path_vars::encode_path_opt(&settings.backup_dir);
|
||||
settings.gateway_ssl_cert_path =
|
||||
boosapi_core::path_vars::encode_path_opt(&settings.gateway_ssl_cert_path);
|
||||
settings.gateway_ssl_key_path =
|
||||
boosapi_core::path_vars::encode_path_opt(&settings.gateway_ssl_key_path);
|
||||
settings.agent_workspace_root =
|
||||
boosapi_core::path_vars::encode_path_opt(&settings.agent_workspace_root);
|
||||
boosapi_core::repo::settings::save_settings(&state.sea_db, &settings)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let app_state = app.state::<AppState>();
|
||||
app_state
|
||||
.close_to_tray
|
||||
.store(settings.minimize_to_tray, Ordering::Relaxed);
|
||||
app_state
|
||||
.release_webview_on_tray
|
||||
.store(settings.release_webview_on_tray, Ordering::Relaxed);
|
||||
|
||||
crate::tray::sync_tray_language(&app, &settings.language).map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
use crate::paths::boosapi_home;
|
||||
use crate::AppState;
|
||||
use boosapi_core::types::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::State;
|
||||
|
||||
fn home_dir() -> PathBuf {
|
||||
dirs::home_dir().expect("Could not determine home directory")
|
||||
}
|
||||
|
||||
fn skills_dir() -> PathBuf {
|
||||
boosapi_home().join("skills")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_skills(state: State<'_, AppState>) -> Result<Vec<SkillInfo>, String> {
|
||||
let home = home_dir();
|
||||
let skills = open_agent_sdk::skills::load_all_global(&home);
|
||||
let disabled = boosapi_core::repo::skill::get_disabled_skills(&state.sea_db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let result: Vec<SkillInfo> = skills
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let enabled = !disabled.contains(&s.name);
|
||||
SkillInfo {
|
||||
name: s.name.clone(),
|
||||
description: s.metadata.description.clone().unwrap_or_default(),
|
||||
author: s
|
||||
.metadata
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("author"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
version: s
|
||||
.metadata
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("version"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
source: s.source.as_str().to_string(),
|
||||
source_path: s.path.to_string_lossy().to_string(),
|
||||
enabled,
|
||||
has_update: false,
|
||||
user_invocable: s.metadata.user_invocable,
|
||||
argument_hint: s.metadata.argument_hint.clone(),
|
||||
when_to_use: s.metadata.when_to_use.clone(),
|
||||
group: s.group.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_skill(state: State<'_, AppState>, name: String) -> Result<SkillDetail, String> {
|
||||
let home = home_dir();
|
||||
let skills = open_agent_sdk::skills::load_all_global(&home);
|
||||
let skill = skills
|
||||
.into_iter()
|
||||
.find(|s| s.name == name)
|
||||
.ok_or_else(|| format!("Skill '{}' not found", name))?;
|
||||
|
||||
let disabled = boosapi_core::repo::skill::get_disabled_skills(&state.sea_db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let skill_dir = skill.path.parent().unwrap_or(Path::new(""));
|
||||
|
||||
// List files in skill directory
|
||||
let files = std::fs::read_dir(skill_dir)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.flatten()
|
||||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Read manifest if exists
|
||||
let manifest_path = skill_dir.join("skill-manifest.json");
|
||||
let manifest = std::fs::read_to_string(&manifest_path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<SkillManifest>(&s).ok());
|
||||
|
||||
let info = SkillInfo {
|
||||
name: skill.name.clone(),
|
||||
description: skill.metadata.description.clone().unwrap_or_default(),
|
||||
author: skill
|
||||
.metadata
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("author"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
version: skill
|
||||
.metadata
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("version"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
source: skill.source.as_str().to_string(),
|
||||
source_path: skill.path.to_string_lossy().to_string(),
|
||||
enabled: !disabled.contains(&skill.name),
|
||||
has_update: false,
|
||||
user_invocable: skill.metadata.user_invocable,
|
||||
argument_hint: skill.metadata.argument_hint.clone(),
|
||||
when_to_use: skill.metadata.when_to_use.clone(),
|
||||
group: skill.group.clone(),
|
||||
};
|
||||
|
||||
Ok(SkillDetail {
|
||||
info,
|
||||
content: skill.content.clone(),
|
||||
files,
|
||||
manifest,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn toggle_skill(
|
||||
state: State<'_, AppState>,
|
||||
name: String,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
boosapi_core::repo::skill::set_skill_enabled(&state.sea_db, &name, enabled)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_skill(source: String, target: Option<String>) -> Result<String, String> {
|
||||
let target_dir = match target.as_deref() {
|
||||
Some("claude") => home_dir().join(".claude").join("skills"),
|
||||
Some("agents") => home_dir().join(".agents").join("skills"),
|
||||
_ => skills_dir(),
|
||||
};
|
||||
std::fs::create_dir_all(&target_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
if source.starts_with('/') || source.starts_with('.') {
|
||||
install_from_local(&source, &target_dir).await
|
||||
} else {
|
||||
let (owner, repo) = parse_github_source(&source)?;
|
||||
install_from_github(&owner, &repo, &target_dir).await
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_github_source(source: &str) -> Result<(String, String), String> {
|
||||
let clean = source.trim_end_matches('/').trim_end_matches(".git");
|
||||
|
||||
if clean.contains("github.com") {
|
||||
let parts: Vec<&str> = clean.split('/').collect();
|
||||
let len = parts.len();
|
||||
if len >= 2 {
|
||||
return Ok((parts[len - 2].to_string(), parts[len - 1].to_string()));
|
||||
}
|
||||
return Err(format!("Invalid GitHub URL: {}", source));
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = source.split('/').collect();
|
||||
if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
|
||||
Ok((parts[0].to_string(), parts[1].to_string()))
|
||||
} else {
|
||||
Err(format!(
|
||||
"Invalid source format '{}'. Expected 'owner/repo', GitHub URL, or local path.",
|
||||
source
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async fn install_from_github(owner: &str, repo: &str, target_dir: &Path) -> Result<String, String> {
|
||||
let url = format!("https://api.github.com/repos/{}/{}/zipball", owner, repo);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("User-Agent", "BoosAPI Desktop")
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download skill: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"GitHub API returned status {}: {}",
|
||||
response.status(),
|
||||
response.text().await.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
|
||||
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
|
||||
|
||||
let temp_dir = tempfile::tempdir().map_err(|e| e.to_string())?;
|
||||
let cursor = std::io::Cursor::new(&bytes);
|
||||
let mut archive =
|
||||
zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip: {}", e))?;
|
||||
|
||||
// GitHub zipball has a top-level directory like "owner-repo-hash/"
|
||||
let top_dir = archive
|
||||
.file_names()
|
||||
.next()
|
||||
.and_then(|n| n.split('/').next())
|
||||
.map(String::from)
|
||||
.ok_or("Empty archive")?;
|
||||
|
||||
archive
|
||||
.extract(temp_dir.path())
|
||||
.map_err(|e| format!("Failed to extract: {}", e))?;
|
||||
|
||||
let extracted = temp_dir.path().join(&top_dir);
|
||||
let skill_target = target_dir.join(repo);
|
||||
|
||||
if skill_target.exists() {
|
||||
std::fs::remove_dir_all(&skill_target).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
copy_dir_recursive(&extracted, &skill_target)?;
|
||||
|
||||
let manifest = serde_json::json!({
|
||||
"source_kind": "github",
|
||||
"source_ref": format!("{}/{}", owner, repo),
|
||||
"branch": "main",
|
||||
"commit": top_dir.split('-').last().unwrap_or("unknown"),
|
||||
"installed_at": chrono::Utc::now().to_rfc3339(),
|
||||
"installed_via": "marketplace"
|
||||
});
|
||||
let manifest_path = skill_target.join("skill-manifest.json");
|
||||
std::fs::write(
|
||||
&manifest_path,
|
||||
serde_json::to_string_pretty(&manifest).unwrap(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(repo.to_string())
|
||||
}
|
||||
|
||||
async fn install_from_local(source: &str, target_dir: &Path) -> Result<String, String> {
|
||||
let source_path = PathBuf::from(source);
|
||||
if !source_path.exists() {
|
||||
return Err(format!("Source path does not exist: {}", source));
|
||||
}
|
||||
if !source_path.is_dir() {
|
||||
return Err(format!("Source path is not a directory: {}", source));
|
||||
}
|
||||
|
||||
let name = source_path
|
||||
.file_name()
|
||||
.ok_or("Invalid source directory name")?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let skill_target = target_dir.join(&name);
|
||||
if skill_target.exists() {
|
||||
std::fs::remove_dir_all(&skill_target).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
copy_dir_recursive(&source_path, &skill_target)?;
|
||||
|
||||
let manifest = serde_json::json!({
|
||||
"source_kind": "local",
|
||||
"source_ref": source,
|
||||
"installed_at": chrono::Utc::now().to_rfc3339(),
|
||||
"installed_via": "local"
|
||||
});
|
||||
let manifest_path = skill_target.join("skill-manifest.json");
|
||||
std::fs::write(
|
||||
&manifest_path,
|
||||
serde_json::to_string_pretty(&manifest).unwrap(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> {
|
||||
std::fs::create_dir_all(dst).map_err(|e| e.to_string())?;
|
||||
for entry in std::fs::read_dir(src).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let ty = entry.file_type().map_err(|e| e.to_string())?;
|
||||
let dst_path = dst.join(entry.file_name());
|
||||
if ty.is_dir() {
|
||||
copy_dir_recursive(&entry.path(), &dst_path)?;
|
||||
} else {
|
||||
std::fs::copy(entry.path(), &dst_path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn uninstall_skill(name: String) -> Result<(), String> {
|
||||
let skill_dir = skills_dir().join(&name);
|
||||
if !skill_dir.exists() {
|
||||
return Err(format!("Skill '{}' not found in ~/.boosapi/skills/", name));
|
||||
}
|
||||
std::fs::remove_dir_all(&skill_dir).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn uninstall_skill_group(group: String) -> Result<(), String> {
|
||||
// Search all skill roots for a directory matching the group name
|
||||
let home = home_dir();
|
||||
let search_dirs = [
|
||||
home.join(".boosapi").join("skills"),
|
||||
home.join(".claude").join("skills"),
|
||||
home.join(".agents").join("skills"),
|
||||
];
|
||||
|
||||
for parent in &search_dirs {
|
||||
let group_dir = parent.join(&group);
|
||||
if group_dir.exists() && group_dir.is_dir() {
|
||||
std::fs::remove_dir_all(&group_dir).map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("Skill group '{}' not found", group))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_skills_dir() -> Result<(), String> {
|
||||
let dir = skills_dir();
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
open::that(&dir).map_err(|e| format!("Failed to open directory: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_skill_dir(path: String) -> Result<(), String> {
|
||||
let p = std::path::Path::new(&path);
|
||||
let dir = if p.is_dir() {
|
||||
p.to_path_buf()
|
||||
} else {
|
||||
p.parent()
|
||||
.map(|d| d.to_path_buf())
|
||||
.unwrap_or_else(|| p.to_path_buf())
|
||||
};
|
||||
if dir.exists() {
|
||||
open::that(&dir).map_err(|e| format!("Failed to open directory: {}", e))
|
||||
} else {
|
||||
Err(format!("Directory does not exist: {}", dir.display()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect `source_ref` values from `skill-manifest.json` files across all
|
||||
/// three global skill directories so marketplace results can be marked as
|
||||
/// installed regardless of the directory name.
|
||||
fn installed_source_refs() -> std::collections::HashSet<String> {
|
||||
let home = home_dir();
|
||||
let dirs = [
|
||||
home.join(".boosapi").join("skills"),
|
||||
home.join(".claude").join("skills"),
|
||||
home.join(".agents").join("skills"),
|
||||
];
|
||||
|
||||
let mut refs = std::collections::HashSet::new();
|
||||
for dir in &dirs {
|
||||
collect_source_refs(dir, &mut refs, /* depth */ 0);
|
||||
}
|
||||
refs
|
||||
}
|
||||
|
||||
fn collect_source_refs(dir: &Path, refs: &mut std::collections::HashSet<String>, depth: u32) {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let manifest = path.join("skill-manifest.json");
|
||||
if manifest.exists() {
|
||||
if let Some(sr) = read_source_ref(&manifest) {
|
||||
refs.insert(sr);
|
||||
}
|
||||
}
|
||||
// Recurse one level for group containers (dirs without SKILL.md but
|
||||
// with subdirs that have skill-manifest.json).
|
||||
if depth == 0 {
|
||||
collect_source_refs(&path, refs, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_source_ref(manifest: &Path) -> Option<String> {
|
||||
let text = std::fs::read_to_string(manifest).ok()?;
|
||||
let val: serde_json::Value = serde_json::from_str(&text).ok()?;
|
||||
let sr = val["source_ref"].as_str()?;
|
||||
let normalized = sr.trim().trim_end_matches('/').to_lowercase();
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn search_marketplace(
|
||||
query: String,
|
||||
source: Option<String>,
|
||||
) -> Result<Vec<MarketplaceSkill>, String> {
|
||||
let installed_refs = installed_source_refs();
|
||||
|
||||
match source.as_deref().unwrap_or("skills.sh") {
|
||||
"github" => {
|
||||
let url = format!(
|
||||
"https://api.github.com/search/repositories?q={}+topic:agent-skill&sort=stars&per_page=20",
|
||||
urlencoding::encode(&query)
|
||||
);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("User-Agent", "BoosAPI Desktop")
|
||||
.header("Accept", "application/vnd.github.v3+json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Search failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("GitHub API error: {}", response.status()));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = response.json().await.map_err(|e| e.to_string())?;
|
||||
let items = body["items"].as_array().cloned().unwrap_or_default();
|
||||
|
||||
let results: Vec<MarketplaceSkill> = items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let skill_name = item["name"].as_str().unwrap_or("").to_string();
|
||||
let repo = item["full_name"].as_str().unwrap_or("").to_string();
|
||||
let installed =
|
||||
installed_refs.contains(&repo.trim().trim_end_matches('/').to_lowercase());
|
||||
MarketplaceSkill {
|
||||
name: skill_name,
|
||||
description: item["description"].as_str().unwrap_or("").to_string(),
|
||||
repo,
|
||||
stars: item["stargazers_count"].as_i64().unwrap_or(0),
|
||||
installs: 0,
|
||||
installed,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
_ => {
|
||||
let url = format!(
|
||||
"https://skills.sh/api/search?q={}",
|
||||
urlencoding::encode(&query)
|
||||
);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("User-Agent", "BoosAPI Desktop")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Search failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("skills.sh API error: {}", response.status()));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = response.json().await.map_err(|e| e.to_string())?;
|
||||
let items = body["skills"].as_array().cloned().unwrap_or_default();
|
||||
|
||||
let results: Vec<MarketplaceSkill> = items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let skill_name = item["name"].as_str().unwrap_or("").to_string();
|
||||
let repo = item["source"].as_str().unwrap_or("").to_string();
|
||||
let installed =
|
||||
installed_refs.contains(&repo.trim().trim_end_matches('/').to_lowercase());
|
||||
MarketplaceSkill {
|
||||
name: skill_name,
|
||||
description: String::new(),
|
||||
repo,
|
||||
stars: 0,
|
||||
installs: item["installs"].as_i64().unwrap_or(0),
|
||||
installed,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_skill_updates() -> Result<Vec<SkillUpdateInfo>, String> {
|
||||
let skills_path = skills_dir();
|
||||
let mut updates = Vec::new();
|
||||
|
||||
let entries = match std::fs::read_dir(&skills_path) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Ok(updates),
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let manifest_path = entry.path().join("skill-manifest.json");
|
||||
if !manifest_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let manifest_str = match std::fs::read_to_string(&manifest_path) {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let manifest: serde_json::Value = match serde_json::from_str(&manifest_str) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if manifest["source_kind"].as_str() != Some("github") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let source_ref = manifest["source_ref"].as_str().unwrap_or("").to_string();
|
||||
let current_commit = manifest["commit"].as_str().unwrap_or("").to_string();
|
||||
|
||||
if source_ref.is_empty() || current_commit.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = source_ref.split('/').collect();
|
||||
if parts.len() != 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let url = format!(
|
||||
"https://api.github.com/repos/{}/{}/commits?per_page=1",
|
||||
parts[0], parts[1]
|
||||
);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("User-Agent", "BoosAPI Desktop")
|
||||
.header("Accept", "application/vnd.github.v3+json")
|
||||
.send()
|
||||
.await;
|
||||
|
||||
if let Ok(resp) = response {
|
||||
if resp.status().is_success() {
|
||||
if let Ok(body) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(commits) = body.as_array() {
|
||||
if let Some(latest) = commits.first() {
|
||||
let latest_sha = latest["sha"].as_str().unwrap_or("").to_string();
|
||||
let short_latest = &latest_sha[..7.min(latest_sha.len())];
|
||||
if !current_commit.is_empty()
|
||||
&& !latest_sha.starts_with(¤t_commit)
|
||||
&& current_commit != short_latest
|
||||
{
|
||||
updates.push(SkillUpdateInfo {
|
||||
name: entry.file_name().to_string_lossy().to_string(),
|
||||
current_commit: current_commit.clone(),
|
||||
latest_commit: short_latest.to_string(),
|
||||
source_ref: source_ref.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(updates)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
use crate::AppState;
|
||||
use boosapi_core::storage_inventory::{self, StorageInventory};
|
||||
use boosapi_core::storage_paths;
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_storage_inventory() -> Result<StorageInventory, String> {
|
||||
Ok(storage_inventory::scan_storage())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_storage_directory(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let root = storage_paths::documents_root();
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
app.opener()
|
||||
.reveal_item_in_dir(&root)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// -- Change documents root --
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ChangeDocumentsRootResult {
|
||||
pub files_moved: usize,
|
||||
pub files_failed: usize,
|
||||
}
|
||||
|
||||
/// Validate a candidate documents root directory.
|
||||
/// Returns (is_empty, exists, writable).
|
||||
#[tauri::command]
|
||||
pub async fn validate_documents_root(path: String) -> Result<ValidateResult, String> {
|
||||
let target = PathBuf::from(&path);
|
||||
|
||||
if !target.is_absolute() {
|
||||
return Err("路径必须是绝对路径".into());
|
||||
}
|
||||
|
||||
let exists = target.exists();
|
||||
|
||||
// Create if missing (to test writability), then remove if we created it.
|
||||
let created_now = if !exists {
|
||||
std::fs::create_dir_all(&target).map_err(|e| format!("无法创建目录: {e}"))?;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// Test writability
|
||||
let probe = target.join(".boosapi_write_probe");
|
||||
let writable = std::fs::write(&probe, b"ok").is_ok();
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
|
||||
// Check emptiness
|
||||
let is_empty = match std::fs::read_dir(&target) {
|
||||
Ok(mut entries) => entries.next().is_none(),
|
||||
Err(_) => true,
|
||||
};
|
||||
|
||||
// Clean up if we created the dir
|
||||
if created_now && is_empty {
|
||||
let _ = std::fs::remove_dir(&target);
|
||||
}
|
||||
|
||||
Ok(ValidateResult {
|
||||
exists,
|
||||
is_empty,
|
||||
writable,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ValidateResult {
|
||||
pub exists: bool,
|
||||
pub is_empty: bool,
|
||||
pub writable: bool,
|
||||
}
|
||||
|
||||
/// Change the documents root to `new_path`.
|
||||
/// If `migrate` is true, copies all files from the current root.
|
||||
#[tauri::command]
|
||||
pub async fn change_documents_root(
|
||||
state: State<'_, AppState>,
|
||||
new_path: String,
|
||||
migrate: bool,
|
||||
) -> Result<ChangeDocumentsRootResult, String> {
|
||||
let new_root = PathBuf::from(&new_path);
|
||||
let old_root = storage_paths::documents_root();
|
||||
|
||||
if new_root == old_root {
|
||||
return Err("新目录与当前目录相同".into());
|
||||
}
|
||||
|
||||
if !new_root.is_absolute() {
|
||||
return Err("路径必须是绝对路径".into());
|
||||
}
|
||||
|
||||
// Ensure the target directory and subdirs exist
|
||||
for sub in &["images", "files", "backups"] {
|
||||
std::fs::create_dir_all(new_root.join(sub))
|
||||
.map_err(|e| format!("无法创建目录 {sub}: {e}"))?;
|
||||
}
|
||||
|
||||
let mut result = ChangeDocumentsRootResult {
|
||||
files_moved: 0,
|
||||
files_failed: 0,
|
||||
};
|
||||
|
||||
// Migrate files if requested — move (rename or copy+delete)
|
||||
if migrate {
|
||||
for sub in &["images", "files", "backups"] {
|
||||
let src_dir = old_root.join(sub);
|
||||
let dst_dir = new_root.join(sub);
|
||||
if !src_dir.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entries) = std::fs::read_dir(&src_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let meta = match entry.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(_) => {
|
||||
result.files_failed += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
let src = entry.path();
|
||||
let dst = dst_dir.join(entry.file_name());
|
||||
if dst.exists() {
|
||||
// Already at destination — delete source and count as moved
|
||||
let _ = std::fs::remove_file(&src);
|
||||
result.files_moved += 1;
|
||||
continue;
|
||||
}
|
||||
// Try rename first (atomic, same filesystem)
|
||||
if std::fs::rename(&src, &dst).is_ok() {
|
||||
result.files_moved += 1;
|
||||
continue;
|
||||
}
|
||||
// Cross-filesystem: copy then delete source
|
||||
match std::fs::copy(&src, &dst) {
|
||||
Ok(_) => {
|
||||
let _ = std::fs::remove_file(&src);
|
||||
result.files_moved += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
src = %src.display(),
|
||||
dst = %dst.display(),
|
||||
error = %e,
|
||||
"failed to move file during documents root change"
|
||||
);
|
||||
result.files_failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the setting
|
||||
let db = &state.sea_db;
|
||||
let mut settings = boosapi_core::repo::settings::get_settings(db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
settings.documents_root_override = Some(new_path);
|
||||
boosapi_core::repo::settings::save_settings(db, &settings)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Update the in-process global so subsequent calls see the new root
|
||||
storage_paths::set_documents_root(new_root);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Reset documents root back to the platform default.
|
||||
#[tauri::command]
|
||||
pub async fn reset_documents_root(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let db = &state.sea_db;
|
||||
let mut settings = boosapi_core::repo::settings::get_settings(db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
settings.documents_root_override = None;
|
||||
boosapi_core::repo::settings::save_settings(db, &settings)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
storage_paths::clear_documents_root_override();
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
use crate::AppState;
|
||||
use boosapi_core::crypto::{decrypt_key, encrypt_key};
|
||||
use boosapi_core::repo::{backup, settings as settings_repo};
|
||||
use boosapi_core::webdav::{self, WebDavClient, WebDavConfig, WebDavFileInfo};
|
||||
use sea_orm::{ConnectionTrait, DatabaseConnection, EntityTrait, PaginatorTrait, Statement};
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tauri::State;
|
||||
|
||||
#[derive(Default)]
|
||||
struct RestoreCleanup {
|
||||
files: Vec<PathBuf>,
|
||||
dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl RestoreCleanup {
|
||||
fn track_file<P: Into<PathBuf>>(&mut self, path: P) {
|
||||
self.files.push(path.into());
|
||||
}
|
||||
|
||||
fn track_dir<P: Into<PathBuf>>(&mut self, path: P) {
|
||||
self.dirs.push(path.into());
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RestoreCleanup {
|
||||
fn drop(&mut self) {
|
||||
for path in &self.files {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
for path in &self.dirs {
|
||||
let _ = std::fs::remove_dir_all(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get WebDAV configuration (password decrypted).
|
||||
#[tauri::command]
|
||||
pub async fn get_webdav_config(state: State<'_, AppState>) -> Result<WebDavConfig, String> {
|
||||
get_webdav_config_from_db(&state.sea_db, &state.master_key).await
|
||||
}
|
||||
|
||||
/// Save WebDAV configuration (password encrypted).
|
||||
#[tauri::command]
|
||||
pub async fn save_webdav_config(
|
||||
state: State<'_, AppState>,
|
||||
config: WebDavConfig,
|
||||
) -> Result<(), String> {
|
||||
let mut settings = settings_repo::get_settings(&state.sea_db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
settings.webdav_host = Some(config.host);
|
||||
settings.webdav_username = Some(config.username);
|
||||
settings.webdav_path = Some(config.path);
|
||||
settings.webdav_accept_invalid_certs = config.accept_invalid_certs;
|
||||
|
||||
settings_repo::save_settings(&state.sea_db, &settings)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Encrypt and store password separately
|
||||
if !config.password.is_empty() {
|
||||
let encrypted =
|
||||
encrypt_key(&config.password, &state.master_key).map_err(|e| e.to_string())?;
|
||||
settings_repo::set_setting(&state.sea_db, "webdav_password_encrypted", &encrypted)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
settings_repo::set_setting(&state.sea_db, "webdav_password_encrypted", "")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test WebDAV connection without requiring saved config.
|
||||
#[tauri::command]
|
||||
pub async fn webdav_check_connection(config: WebDavConfig) -> Result<bool, String> {
|
||||
let client = WebDavClient::new(config).map_err(|e| e.to_string())?;
|
||||
client.check_connection().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Create a backup and upload it to WebDAV.
|
||||
#[tauri::command]
|
||||
pub async fn webdav_backup(state: State<'_, AppState>) -> Result<String, String> {
|
||||
do_webdav_backup_impl(&state.sea_db, &state.master_key, &state.app_data_dir).await
|
||||
}
|
||||
|
||||
/// List remote backups on WebDAV server.
|
||||
#[tauri::command]
|
||||
pub async fn webdav_list_backups(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<WebDavFileInfo>, String> {
|
||||
let config = get_webdav_config_from_db(&state.sea_db, &state.master_key).await?;
|
||||
if config.host.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let client = WebDavClient::new(config).map_err(|e| e.to_string())?;
|
||||
client.list_files().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Restore from a remote WebDAV backup.
|
||||
#[tauri::command]
|
||||
pub async fn webdav_restore(
|
||||
app: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
file_name: String,
|
||||
) -> Result<(), String> {
|
||||
let config = get_webdav_config_from_db(&state.sea_db, &state.master_key).await?;
|
||||
let settings = settings_repo::get_settings(&state.sea_db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let decoded_backup_dir = boosapi_core::path_vars::decode_path_opt(&settings.backup_dir);
|
||||
let backup_dir = backup::resolve_backup_dir(decoded_backup_dir.as_deref(), &state.app_data_dir);
|
||||
backup::ensure_backup_dir(&backup_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut cleanup = RestoreCleanup::default();
|
||||
|
||||
// 1. Download ZIP
|
||||
let zip_path = backup_dir.join(&file_name);
|
||||
cleanup.track_file(&zip_path);
|
||||
let client = WebDavClient::new(config).map_err(|e| e.to_string())?;
|
||||
client
|
||||
.download_file(&file_name, &zip_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 2. Extract to temp directory
|
||||
let temp_dir = backup_dir.join("_webdav_restore_temp");
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
cleanup.track_dir(&temp_dir);
|
||||
let contents = webdav::extract_backup_zip(&zip_path, &temp_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
// 3. Verify checksum
|
||||
if let Some(expected) = contents
|
||||
.metadata
|
||||
.get("db_checksum")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
let ok =
|
||||
webdav::verify_db_checksum(&contents.db_path, expected).map_err(|e| e.to_string())?;
|
||||
if !ok {
|
||||
return Err("Backup checksum verification failed — file may be corrupted".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Create a safety backup of current database and master.key
|
||||
let db_path = state
|
||||
.db_path
|
||||
.strip_prefix("sqlite:")
|
||||
.unwrap_or(&state.db_path);
|
||||
let safety_backup = backup_dir.join("_pre_webdav_restore_safety.db");
|
||||
let _ = std::fs::copy(db_path, &safety_backup);
|
||||
let master_key_dest = state.app_data_dir.join("master.key");
|
||||
let safety_key_backup = temp_dir.join("_pre_webdav_restore_safety.key");
|
||||
let _ = std::fs::copy(&master_key_dest, &safety_key_backup);
|
||||
cleanup.track_file(&safety_key_backup);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
let _ = std::fs::set_permissions(&safety_key_backup, perms);
|
||||
}
|
||||
|
||||
// 5. Restore master.key if present in backup (required for decrypting API keys)
|
||||
if let Some(ref key_path) = contents.master_key_path {
|
||||
std::fs::copy(key_path, &master_key_dest)
|
||||
.map_err(|e| format!("Failed to restore master.key: {}", e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
let _ = std::fs::set_permissions(&master_key_dest, perms);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Restore database — also remove stale WAL/SHM files so SQLite
|
||||
// doesn't try to replay a journal that belongs to the old database.
|
||||
backup::restore_sqlite_backup(contents.db_path.to_str().unwrap_or(""), db_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let _ = std::fs::remove_file(format!("{}-wal", db_path));
|
||||
let _ = std::fs::remove_file(format!("{}-shm", db_path));
|
||||
|
||||
// 7. Restore documents if present
|
||||
if contents.has_documents {
|
||||
let docs_source = temp_dir.join("documents");
|
||||
let docs_target = webdav::documents_sync_root();
|
||||
if docs_source.exists() {
|
||||
copy_directory(&docs_source, &docs_target)
|
||||
.map_err(|e| format!("Failed to restore documents: {}", e))?;
|
||||
}
|
||||
}
|
||||
|
||||
// 7b. Restore workspace if present
|
||||
if contents.has_workspace {
|
||||
let ws_source = temp_dir.join("workspace");
|
||||
let ws_target = state.app_data_dir.join("workspace");
|
||||
if ws_source.exists() {
|
||||
copy_directory(&ws_source, &ws_target)
|
||||
.map_err(|e| format!("Failed to restore workspace: {}", e))?;
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Auto-restart to pick up the restored database
|
||||
app.restart();
|
||||
|
||||
#[allow(unreachable_code)]
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a remote backup file.
|
||||
#[tauri::command]
|
||||
pub async fn webdav_delete_backup(
|
||||
state: State<'_, AppState>,
|
||||
file_name: String,
|
||||
) -> Result<(), String> {
|
||||
let config = get_webdav_config_from_db(&state.sea_db, &state.master_key).await?;
|
||||
let client = WebDavClient::new(config).map_err(|e| e.to_string())?;
|
||||
client
|
||||
.delete_file(&file_name)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Get WebDAV sync status (last sync time and result).
|
||||
#[tauri::command]
|
||||
pub async fn get_webdav_sync_status(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let last_time = settings_repo::get_setting(&state.sea_db, "webdav_last_sync_time")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let last_status = settings_repo::get_setting(&state.sea_db, "webdav_last_sync_status")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"lastSyncTime": last_time,
|
||||
"lastSyncStatus": last_status,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Restart the WebDAV auto-sync scheduler based on current settings.
|
||||
#[tauri::command]
|
||||
pub async fn restart_webdav_sync(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let settings = settings_repo::get_settings(&state.sea_db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut guard = state.webdav_sync_handle.lock().await;
|
||||
|
||||
// Stop existing scheduler
|
||||
if let Some(h) = guard.take() {
|
||||
h.abort();
|
||||
}
|
||||
|
||||
if !settings.webdav_sync_enabled || settings.webdav_sync_interval_minutes == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let db = state.sea_db.clone();
|
||||
let master_key = state.master_key;
|
||||
let app_data_dir = state.app_data_dir.clone();
|
||||
let interval_minutes = settings.webdav_sync_interval_minutes;
|
||||
let task = spawn_webdav_sync_task(
|
||||
db,
|
||||
master_key,
|
||||
app_data_dir,
|
||||
interval_minutes,
|
||||
interval_minutes as u64 * 60,
|
||||
);
|
||||
|
||||
*guard = Some(task);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// === Internal Helpers ===
|
||||
|
||||
pub(crate) async fn get_webdav_config_from_db(
|
||||
db: &DatabaseConnection,
|
||||
master_key: &[u8; 32],
|
||||
) -> Result<WebDavConfig, String> {
|
||||
let settings = settings_repo::get_settings(db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let encrypted_pw = settings_repo::get_setting(db, "webdav_password_encrypted")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let password = match encrypted_pw {
|
||||
Some(enc) if !enc.is_empty() => decrypt_key(&enc, master_key).unwrap_or_default(),
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
Ok(WebDavConfig {
|
||||
host: settings.webdav_host.unwrap_or_default(),
|
||||
username: settings.webdav_username.unwrap_or_default(),
|
||||
password,
|
||||
path: settings
|
||||
.webdav_path
|
||||
.unwrap_or_else(|| "/boosapi/".to_string()),
|
||||
accept_invalid_certs: settings.webdav_accept_invalid_certs,
|
||||
})
|
||||
}
|
||||
|
||||
/// Core backup-and-upload logic, shared by the command and the auto-sync scheduler.
|
||||
pub(crate) async fn do_webdav_backup_impl(
|
||||
db: &DatabaseConnection,
|
||||
master_key: &[u8; 32],
|
||||
app_data_dir: &Path,
|
||||
) -> Result<String, String> {
|
||||
let result = do_webdav_backup_once(db, master_key, app_data_dir).await;
|
||||
record_webdav_sync_status(db, if result.is_ok() { "success" } else { "failed" }).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn do_webdav_backup_once(
|
||||
db: &DatabaseConnection,
|
||||
master_key: &[u8; 32],
|
||||
app_data_dir: &Path,
|
||||
) -> Result<String, String> {
|
||||
// 1. Load config
|
||||
let config = get_webdav_config_from_db(db, master_key).await?;
|
||||
if config.host.is_empty() {
|
||||
return Err("WebDAV is not configured".to_string());
|
||||
}
|
||||
|
||||
let settings = settings_repo::get_settings(db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 2. Create local SQLite snapshot via VACUUM INTO
|
||||
let decoded_backup_dir = boosapi_core::path_vars::decode_path_opt(&settings.backup_dir);
|
||||
let backup_dir = backup::resolve_backup_dir(decoded_backup_dir.as_deref(), app_data_dir);
|
||||
backup::ensure_backup_dir(&backup_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let temp_id = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
let temp_db_path = backup_dir.join(format!("_webdav_temp_{}.db", temp_id));
|
||||
let _ = std::fs::remove_file(&temp_db_path);
|
||||
|
||||
let db_str = temp_db_path.to_string_lossy().to_string();
|
||||
db.execute(Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Sqlite,
|
||||
format!("VACUUM INTO '{}'", db_str.replace('\'', "''")),
|
||||
))
|
||||
.await
|
||||
.map_err(|e| format!("VACUUM INTO failed: {}", e))?;
|
||||
|
||||
// 3. Object counts for metadata
|
||||
let object_counts = count_objects_json(db).await;
|
||||
|
||||
// 4. Documents directory (optional)
|
||||
let include_docs = settings.webdav_include_documents;
|
||||
let documents_dir = if include_docs {
|
||||
let docs_root = webdav::documents_sync_root();
|
||||
if docs_root.exists() {
|
||||
Some(docs_root)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 4b. Workspace directory (always included if present)
|
||||
let workspace_root = app_data_dir.join("workspace");
|
||||
let workspace_dir = if workspace_root.exists() {
|
||||
Some(workspace_root)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 5. Create ZIP (includes master.key for cross-device restore)
|
||||
let master_key_path = app_data_dir.join("master.key");
|
||||
let zip_filename = webdav::generate_backup_filename();
|
||||
let zip_path = backup_dir.join(&zip_filename);
|
||||
webdav::create_backup_zip(
|
||||
&temp_db_path,
|
||||
documents_dir.as_deref(),
|
||||
workspace_dir.as_deref(),
|
||||
Some(&master_key_path),
|
||||
&zip_path,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
&object_counts,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 6. Upload
|
||||
let client = WebDavClient::new(config).map_err(|e| e.to_string())?;
|
||||
client
|
||||
.upload_file(&zip_filename, &zip_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 7. Cleanup temp files
|
||||
let _ = std::fs::remove_file(&temp_db_path);
|
||||
let _ = std::fs::remove_file(&zip_path);
|
||||
|
||||
// 8. Cleanup old remote backups
|
||||
let max_backups = settings.webdav_max_remote_backups;
|
||||
if max_backups > 0 {
|
||||
cleanup_remote_backups(&client, max_backups).await;
|
||||
}
|
||||
|
||||
Ok(zip_filename)
|
||||
}
|
||||
|
||||
async fn count_objects_json(db: &DatabaseConnection) -> String {
|
||||
use boosapi_core::entity::*;
|
||||
|
||||
let conv_count = conversations::Entity::find().count(db).await.unwrap_or(0);
|
||||
let msg_count = messages::Entity::find().count(db).await.unwrap_or(0);
|
||||
let provider_count = providers::Entity::find().count(db).await.unwrap_or(0);
|
||||
|
||||
serde_json::json!({
|
||||
"conversations": conv_count,
|
||||
"messages": msg_count,
|
||||
"providers": provider_count,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn cleanup_remote_backups(client: &WebDavClient, max_per_host: u32) {
|
||||
if let Ok(files) = client.list_files().await {
|
||||
let mut by_host: std::collections::HashMap<String, Vec<WebDavFileInfo>> =
|
||||
std::collections::HashMap::new();
|
||||
for f in files {
|
||||
by_host.entry(f.hostname.clone()).or_default().push(f);
|
||||
}
|
||||
|
||||
for (_, mut host_files) in by_host {
|
||||
if host_files.len() > max_per_host as usize {
|
||||
let to_delete = host_files.split_off(max_per_host as usize);
|
||||
for f in to_delete {
|
||||
if let Err(e) = client.delete_file(&f.file_name).await {
|
||||
tracing::warn!(
|
||||
"Failed to clean up old WebDAV backup {}: {}",
|
||||
f.file_name,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_directory(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dst)?;
|
||||
for entry in std::fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let target = dst.join(entry.file_name());
|
||||
if entry.file_type()?.is_dir() {
|
||||
copy_directory(&entry.path(), &target)?;
|
||||
} else {
|
||||
std::fs::copy(entry.path(), &target)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn record_webdav_sync_status(db: &DatabaseConnection, status: &str) {
|
||||
let timestamp = webdav::sync_status_timestamp();
|
||||
let _ = settings_repo::set_setting(db, "webdav_last_sync_time", ×tamp).await;
|
||||
let _ = settings_repo::set_setting(db, "webdav_last_sync_status", status).await;
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_webdav_sync_task(
|
||||
db: DatabaseConnection,
|
||||
master_key: [u8; 32],
|
||||
app_data_dir: std::path::PathBuf,
|
||||
interval_minutes: u32,
|
||||
initial_delay_secs: u64,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let interval = std::time::Duration::from_secs(interval_minutes as u64 * 60);
|
||||
// Initial wait (may be shorter if overdue)
|
||||
tokio::time::sleep(std::time::Duration::from_secs(initial_delay_secs)).await;
|
||||
loop {
|
||||
match do_webdav_backup_impl(&db, &master_key, &app_data_dir).await {
|
||||
Ok(name) => tracing::info!("WebDAV auto-sync completed: {}", name),
|
||||
Err(e) => tracing::warn!("WebDAV auto-sync failed: {}", e),
|
||||
}
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::RestoreCleanup;
|
||||
|
||||
#[test]
|
||||
fn restore_cleanup_removes_tracked_safety_key_files() {
|
||||
let temp_root = std::env::temp_dir().join(format!(
|
||||
"boosapi-webdav-restore-cleanup-{}",
|
||||
boosapi_core::utils::gen_id()
|
||||
));
|
||||
std::fs::create_dir_all(&temp_root).expect("create temp root");
|
||||
let safety_key = temp_root.join("_pre_webdav_restore_safety.key");
|
||||
std::fs::write(&safety_key, b"secret").expect("write safety key");
|
||||
|
||||
{
|
||||
let mut cleanup = RestoreCleanup::default();
|
||||
cleanup.track_file(&safety_key);
|
||||
}
|
||||
|
||||
assert!(
|
||||
!safety_key.exists(),
|
||||
"restore cleanup must delete the plaintext safety key backup"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&temp_root);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn restore_cleanup_keeps_safety_key_backup_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let temp_root = std::env::temp_dir().join(format!(
|
||||
"boosapi-webdav-restore-perms-{}",
|
||||
boosapi_core::utils::gen_id()
|
||||
));
|
||||
std::fs::create_dir_all(&temp_root).expect("create temp root");
|
||||
let safety_key = temp_root.join("_pre_webdav_restore_safety.key");
|
||||
std::fs::write(&safety_key, b"secret").expect("write safety key");
|
||||
std::fs::set_permissions(&safety_key, std::fs::Permissions::from_mode(0o600))
|
||||
.expect("set permissions");
|
||||
|
||||
let mode = std::fs::metadata(&safety_key)
|
||||
.expect("metadata")
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777;
|
||||
|
||||
assert_eq!(
|
||||
mode, 0o600,
|
||||
"safety key backups must be owner-readable only"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&temp_root);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
//! Context manager for conversation history compression.
|
||||
//!
|
||||
//! Two modes:
|
||||
//! - **Sliding window** (compression OFF): trims oldest messages to fit the token budget.
|
||||
//! - **Compression** (manual or auto): all messages are compressed into an LLM summary,
|
||||
//! a `<!-- context-compressed -->` marker is inserted, and subsequent sends use
|
||||
//! the summary + only messages after the marker.
|
||||
|
||||
use boosapi_core::token_counter;
|
||||
use boosapi_core::types::{ChatContent, ChatMessage};
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Fraction of context window that triggers auto-compression (70%).
|
||||
const THRESHOLD_RATIO: f64 = 0.70;
|
||||
|
||||
/// Content string for the compression marker message.
|
||||
pub const COMPRESSION_MARKER: &str = "<!-- context-compressed -->";
|
||||
|
||||
/// Estimate the token count of a single `ChatMessage`.
|
||||
pub fn message_tokens(msg: &ChatMessage) -> usize {
|
||||
let text = match &msg.content {
|
||||
ChatContent::Text(s) => s.as_str(),
|
||||
ChatContent::Multipart(parts) => {
|
||||
return token_counter::estimate_tokens(
|
||||
&parts
|
||||
.iter()
|
||||
.filter_map(|p| p.text.as_deref())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
) + parts.iter().filter(|p| p.image_url.is_some()).count() * 85
|
||||
+ 4;
|
||||
}
|
||||
};
|
||||
token_counter::estimate_message_tokens(&msg.role, text)
|
||||
}
|
||||
|
||||
/// Check whether the current context exceeds the auto-compression threshold.
|
||||
///
|
||||
/// Returns `true` if total tokens (system + history) > model_context_window * 0.70.
|
||||
///
|
||||
/// When `model_context_window` is `None` (model has no configured limit), always
|
||||
/// returns `false` — we never auto-compress without a known budget.
|
||||
pub fn should_auto_compress(
|
||||
system_messages: &[ChatMessage],
|
||||
history_messages: &[ChatMessage],
|
||||
model_context_window: Option<u32>,
|
||||
) -> bool {
|
||||
let context_window = match model_context_window {
|
||||
Some(v) => v as usize,
|
||||
None => return false,
|
||||
};
|
||||
let threshold = (context_window as f64 * THRESHOLD_RATIO) as usize;
|
||||
|
||||
let total: usize = system_messages
|
||||
.iter()
|
||||
.chain(history_messages.iter())
|
||||
.map(|m| message_tokens(m))
|
||||
.sum();
|
||||
|
||||
total > threshold
|
||||
}
|
||||
|
||||
/// Build the final context for LLM from system messages + optional summary + history.
|
||||
///
|
||||
/// If a summary exists, it is prepended as a system message.
|
||||
/// Sliding window is applied only when `model_context_window` is `Some`.
|
||||
/// When the model has no configured limit, all history messages are included.
|
||||
pub fn build_context(
|
||||
system_messages: &[ChatMessage],
|
||||
history_messages: &[ChatMessage],
|
||||
existing_summary: Option<&str>,
|
||||
model_context_window: Option<u32>,
|
||||
) -> Vec<ChatMessage> {
|
||||
let mut out = system_messages.to_vec();
|
||||
|
||||
// Insert summary as a system message if present
|
||||
if let Some(summary_text) = existing_summary {
|
||||
out.push(ChatMessage {
|
||||
role: "system".to_string(),
|
||||
content: ChatContent::Text(format!(
|
||||
"[对话历史摘要 / Conversation History Summary]\n{}",
|
||||
summary_text
|
||||
)),
|
||||
reasoning_content: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
}
|
||||
|
||||
match model_context_window {
|
||||
Some(ctx_window) => {
|
||||
let budget = (ctx_window as f64 * THRESHOLD_RATIO) as usize;
|
||||
let system_tokens: usize = out.iter().map(|m| message_tokens(m)).sum();
|
||||
let available = budget.saturating_sub(system_tokens);
|
||||
let trimmed = sliding_window(history_messages, available);
|
||||
out.extend(trimmed);
|
||||
}
|
||||
None => {
|
||||
// No known context limit — include all history messages
|
||||
out.extend(history_messages.iter().cloned());
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Sliding window: keep as many recent messages as fit within `budget` tokens.
|
||||
/// Always includes at least the last message to prevent the current user input
|
||||
/// from being silently dropped.
|
||||
fn sliding_window(history: &[ChatMessage], budget: usize) -> Vec<ChatMessage> {
|
||||
if history.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut total = 0usize;
|
||||
let mut start_idx = history.len();
|
||||
let mut end_idx = history.len();
|
||||
|
||||
while end_idx > 0 {
|
||||
let group_start = message_group_start(history, end_idx - 1);
|
||||
let group_tokens: usize = history[group_start..end_idx]
|
||||
.iter()
|
||||
.map(message_tokens)
|
||||
.sum();
|
||||
if total + group_tokens > budget {
|
||||
break;
|
||||
}
|
||||
total += group_tokens;
|
||||
start_idx = group_start;
|
||||
end_idx = group_start;
|
||||
}
|
||||
|
||||
// Always include at least the last message
|
||||
if start_idx == history.len() {
|
||||
start_idx = message_group_start(history, history.len() - 1);
|
||||
}
|
||||
|
||||
history[start_idx..].to_vec()
|
||||
}
|
||||
|
||||
fn message_group_start(history: &[ChatMessage], index: usize) -> usize {
|
||||
let message = &history[index];
|
||||
if message.role != "tool" {
|
||||
return index;
|
||||
}
|
||||
|
||||
let Some(tool_call_id) = message.tool_call_id.as_deref() else {
|
||||
return index;
|
||||
};
|
||||
if tool_call_id.trim().is_empty() {
|
||||
return index;
|
||||
}
|
||||
|
||||
for candidate_index in (0..index).rev() {
|
||||
let candidate = &history[candidate_index];
|
||||
if candidate.role != "assistant" {
|
||||
continue;
|
||||
}
|
||||
let tool_call_ids = candidate
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.map(|tool_calls| {
|
||||
tool_calls
|
||||
.iter()
|
||||
.map(|tool_call| tool_call.id.as_str())
|
||||
.collect::<HashSet<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if tool_call_ids.contains(tool_call_id) {
|
||||
return candidate_index;
|
||||
}
|
||||
}
|
||||
|
||||
index
|
||||
}
|
||||
|
||||
/// Messages that need to be summarized (passed to LLM).
|
||||
pub struct SummarizationRequest {
|
||||
/// Existing summary to merge with, if any.
|
||||
pub existing_summary: Option<String>,
|
||||
/// Messages to incorporate into the summary.
|
||||
pub messages_to_compress: Vec<ChatMessage>,
|
||||
}
|
||||
|
||||
/// Build the LLM prompt for generating a conversation summary.
|
||||
pub fn build_summary_prompt(request: &SummarizationRequest) -> Vec<ChatMessage> {
|
||||
let mut messages = Vec::new();
|
||||
|
||||
let instruction = if request.existing_summary.is_some() {
|
||||
"你是一个对话摘要助手。请将以下新增对话内容合并到已有摘要中。\n\n\
|
||||
要求:\n\
|
||||
1. 保留所有用户明确表达的需求、偏好和决策\n\
|
||||
2. 保留关键技术细节(代码片段、配置、错误信息等)\n\
|
||||
3. 保留待办事项和未解决的问题\n\
|
||||
4. 用简洁的要点形式组织\n\
|
||||
5. 如果有冲突信息,以最新的为准\n\
|
||||
6. 保持摘要简洁,不超过 500 字"
|
||||
} else {
|
||||
"你是一个对话摘要助手。请将以下对话历史压缩为简洁摘要。\n\n\
|
||||
要求:\n\
|
||||
1. 保留所有用户明确表达的需求、偏好和决策\n\
|
||||
2. 保留关键技术细节(代码片段、配置、错误信息等)\n\
|
||||
3. 保留待办事项和未解决的问题\n\
|
||||
4. 用简洁的要点形式组织\n\
|
||||
5. 保持摘要简洁,不超过 500 字"
|
||||
};
|
||||
|
||||
messages.push(ChatMessage {
|
||||
role: "system".to_string(),
|
||||
content: ChatContent::Text(instruction.to_string()),
|
||||
reasoning_content: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
|
||||
if let Some(ref summary) = request.existing_summary {
|
||||
messages.push(ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: ChatContent::Text(format!("已有摘要:\n{}", summary)),
|
||||
reasoning_content: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
}
|
||||
|
||||
let conversation_text: Vec<String> = request
|
||||
.messages_to_compress
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let content_text = match &m.content {
|
||||
ChatContent::Text(s) => s.clone(),
|
||||
ChatContent::Multipart(parts) => parts
|
||||
.iter()
|
||||
.filter_map(|p| p.text.as_deref())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
};
|
||||
let truncated = if content_text.len() > 2000 {
|
||||
format!("{}...[已截断]", &content_text[..2000])
|
||||
} else {
|
||||
content_text
|
||||
};
|
||||
format!("{}: {}", m.role, truncated)
|
||||
})
|
||||
.collect();
|
||||
|
||||
messages.push(ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: ChatContent::Text(format!(
|
||||
"{}对话内容:\n{}",
|
||||
if request.existing_summary.is_some() {
|
||||
"新增"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
conversation_text.join("\n")
|
||||
)),
|
||||
reasoning_content: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
|
||||
messages
|
||||
}
|
||||
|
||||
/// Build summary prompt with a custom system instruction (from settings).
|
||||
pub fn build_summary_prompt_with_custom(
|
||||
request: &SummarizationRequest,
|
||||
custom_prompt: &str,
|
||||
) -> Vec<ChatMessage> {
|
||||
let mut messages = Vec::new();
|
||||
|
||||
messages.push(ChatMessage {
|
||||
role: "system".to_string(),
|
||||
content: ChatContent::Text(custom_prompt.to_string()),
|
||||
reasoning_content: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
|
||||
if let Some(ref summary) = request.existing_summary {
|
||||
messages.push(ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: ChatContent::Text(format!("已有摘要:\n{}", summary)),
|
||||
reasoning_content: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
}
|
||||
|
||||
let conversation_text: Vec<String> = request
|
||||
.messages_to_compress
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let content_text = match &m.content {
|
||||
ChatContent::Text(s) => s.clone(),
|
||||
ChatContent::Multipart(parts) => parts
|
||||
.iter()
|
||||
.filter_map(|p| p.text.as_deref())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
};
|
||||
let truncated = if content_text.len() > 2000 {
|
||||
format!("{}...[已截断]", &content_text[..2000])
|
||||
} else {
|
||||
content_text
|
||||
};
|
||||
format!("{}: {}", m.role, truncated)
|
||||
})
|
||||
.collect();
|
||||
|
||||
messages.push(ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: ChatContent::Text(format!(
|
||||
"{}对话内容:\n{}",
|
||||
if request.existing_summary.is_some() {
|
||||
"新增"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
conversation_text.join("\n")
|
||||
)),
|
||||
reasoning_content: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
|
||||
messages
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use boosapi_core::types::{ToolCall, ToolCallFunction};
|
||||
|
||||
fn text_message(role: &str, content: &str) -> ChatMessage {
|
||||
ChatMessage {
|
||||
role: role.to_string(),
|
||||
content: ChatContent::Text(content.to_string()),
|
||||
reasoning_content: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sliding_window_does_not_keep_orphan_tool_result_without_assistant_call() {
|
||||
let mut assistant = text_message("assistant", "");
|
||||
assistant.tool_calls = Some(vec![ToolCall {
|
||||
id: "call-1".into(),
|
||||
call_type: "function".into(),
|
||||
function: ToolCallFunction {
|
||||
name: "read_file".into(),
|
||||
arguments: "{}".into(),
|
||||
},
|
||||
}]);
|
||||
assistant.reasoning_content = Some("need file".into());
|
||||
|
||||
let mut tool = text_message("tool", "small tool result");
|
||||
tool.tool_call_id = Some("call-1".into());
|
||||
|
||||
let history = vec![
|
||||
text_message("user", &"old ".repeat(500)),
|
||||
assistant,
|
||||
tool,
|
||||
text_message("user", "next"),
|
||||
];
|
||||
let tool_tokens = message_tokens(&history[2]);
|
||||
let current_user_tokens = message_tokens(&history[3]);
|
||||
let budget = tool_tokens + current_user_tokens + 1;
|
||||
|
||||
let trimmed = sliding_window(&history, budget);
|
||||
|
||||
assert_eq!(trimmed.len(), 1);
|
||||
assert_eq!(trimmed[0].role, "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deepseek_v4_flash_budget_does_not_auto_compress_below_threshold() {
|
||||
let history = vec![text_message("user", &"token ".repeat(100_000))];
|
||||
|
||||
assert!(!should_auto_compress(&[], &history, Some(1_000_000)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
use std::env;
|
||||
use std::ffi::OsString;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
|
||||
pub const LOG_FILE_ENV: &str = "BOOSAPI_LOG_FILE";
|
||||
const LINUX_AUTO_WINDOW_ENV: &str = "BOOSAPI_LINUX_AUTO_WINDOW";
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SharedLogFile {
|
||||
file: Arc<Mutex<File>>,
|
||||
}
|
||||
|
||||
struct SharedLogFileWriter {
|
||||
file: Arc<Mutex<File>>,
|
||||
}
|
||||
|
||||
impl SharedLogFile {
|
||||
fn new(file: File) -> Self {
|
||||
Self {
|
||||
file: Arc::new(Mutex::new(file)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for SharedLogFileWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
let mut file = self
|
||||
.file
|
||||
.lock()
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::Other, "log file lock poisoned"))?;
|
||||
let written = file.write(buf)?;
|
||||
file.flush()?;
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
let mut file = self
|
||||
.file
|
||||
.lock()
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::Other, "log file lock poisoned"))?;
|
||||
file.flush()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'writer> MakeWriter<'writer> for SharedLogFile {
|
||||
type Writer = SharedLogFileWriter;
|
||||
|
||||
fn make_writer(&'writer self) -> Self::Writer {
|
||||
SharedLogFileWriter {
|
||||
file: Arc::clone(&self.file),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_tracing() {
|
||||
let log_path = log_file_path_from_value(env::var_os(LOG_FILE_ENV));
|
||||
|
||||
if let Some(path) = log_path {
|
||||
match open_log_file(&path) {
|
||||
Ok(file) => {
|
||||
if let Err(err) = tracing_subscriber::fmt()
|
||||
.with_env_filter(env_filter())
|
||||
.with_writer(SharedLogFile::new(file))
|
||||
.try_init()
|
||||
{
|
||||
eprintln!("failed to initialize BoosApi file logging: {err}");
|
||||
return;
|
||||
}
|
||||
tracing::info!(
|
||||
log_file = %path.display(),
|
||||
"BoosApi diagnostic file logging enabled"
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"failed to open BoosApi diagnostic log file '{}': {err}",
|
||||
path.display()
|
||||
);
|
||||
init_stderr_tracing();
|
||||
tracing::warn!(
|
||||
log_file = %path.display(),
|
||||
error = %err,
|
||||
"Falling back to stderr logging because diagnostic log file could not be opened"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init_stderr_tracing();
|
||||
}
|
||||
|
||||
pub fn log_process_startup() {
|
||||
tracing::info!(
|
||||
package_name = env!("CARGO_PKG_NAME"),
|
||||
crate_version = env!("CARGO_PKG_VERSION"),
|
||||
os = env::consts::OS,
|
||||
arch = env::consts::ARCH,
|
||||
rust_log = %env_value("RUST_LOG"),
|
||||
boosapi_log_file = %env_value(LOG_FILE_ENV),
|
||||
xdg_session_type = %env_value("XDG_SESSION_TYPE"),
|
||||
wayland_display = %env_value("WAYLAND_DISPLAY"),
|
||||
display = %env_value("DISPLAY"),
|
||||
gdk_backend = %env_value("GDK_BACKEND"),
|
||||
xdg_current_desktop = %env_value("XDG_CURRENT_DESKTOP"),
|
||||
desktop_session = %env_value("DESKTOP_SESSION"),
|
||||
webkit_disable_dmabuf_renderer = %env_value("WEBKIT_DISABLE_DMABUF_RENDERER"),
|
||||
webkit_disable_compositing_mode = %env_value("WEBKIT_DISABLE_COMPOSITING_MODE"),
|
||||
boosapi_linux_auto_window = %env_value(LINUX_AUTO_WINDOW_ENV),
|
||||
boosapi_linux_any_thread = %env_value(crate::startup_diagnostics::LINUX_ANY_THREAD_ENV),
|
||||
boosapi_linux_minimal_plugins = %env_value(crate::startup_diagnostics::LINUX_MINIMAL_PLUGINS_ENV),
|
||||
boosapi_enable_devtools = %env_value(crate::startup_diagnostics::ENABLE_DEVTOOLS_ENV),
|
||||
"BoosApi process startup diagnostics"
|
||||
);
|
||||
}
|
||||
|
||||
pub fn install_panic_hook() {
|
||||
std::panic::set_hook(Box::new(|panic_info| {
|
||||
let location = panic_info
|
||||
.location()
|
||||
.map(|location| {
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
location.file(),
|
||||
location.line(),
|
||||
location.column()
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| "<unknown>".to_string());
|
||||
let payload = panic_payload(panic_info.payload());
|
||||
|
||||
tracing::error!(
|
||||
location = %location,
|
||||
payload = %payload,
|
||||
"BoosApi process panicked"
|
||||
);
|
||||
eprintln!("BoosApi process panicked at {location}: {payload}");
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn show_linux_startup_error_dialog(message: &str) {
|
||||
if spawn_linux_dialog(
|
||||
"zenity",
|
||||
&["--error", "--title", "BoosAPI Desktop", "--text", message],
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if spawn_linux_dialog("kdialog", &["--title", "BoosAPI Desktop", "--error", message]) {
|
||||
return;
|
||||
}
|
||||
|
||||
tracing::warn!("No Linux native dialog command available for startup error");
|
||||
}
|
||||
|
||||
fn init_stderr_tracing() {
|
||||
if let Err(err) = tracing_subscriber::fmt()
|
||||
.with_env_filter(env_filter())
|
||||
.try_init()
|
||||
{
|
||||
eprintln!("failed to initialize BoosApi stderr logging: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn env_filter() -> tracing_subscriber::EnvFilter {
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"))
|
||||
}
|
||||
|
||||
fn env_value(key: &str) -> String {
|
||||
env::var(key).unwrap_or_else(|_| "<unset>".to_string())
|
||||
}
|
||||
|
||||
fn panic_payload(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
if let Some(message) = payload.downcast_ref::<&str>() {
|
||||
(*message).to_string()
|
||||
} else if let Some(message) = payload.downcast_ref::<String>() {
|
||||
message.clone()
|
||||
} else {
|
||||
"<non-string panic payload>".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn spawn_linux_dialog(command: &str, args: &[&str]) -> bool {
|
||||
std::process::Command::new(command)
|
||||
.args(args)
|
||||
.spawn()
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn log_file_path_from_value(value: Option<OsString>) -> Option<PathBuf> {
|
||||
let value = value?;
|
||||
if value.to_string_lossy().trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(PathBuf::from(value))
|
||||
}
|
||||
|
||||
fn open_log_file(path: &Path) -> io::Result<File> {
|
||||
if let Some(parent) = path
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
{
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
OpenOptions::new().create(true).append(true).open(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{log_file_path_from_value, open_log_file};
|
||||
use std::ffi::OsString;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn ignores_missing_or_blank_log_file_env() {
|
||||
assert_eq!(log_file_path_from_value(None), None);
|
||||
assert_eq!(log_file_path_from_value(Some(OsString::from(" "))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_non_blank_log_file_path() {
|
||||
assert_eq!(
|
||||
log_file_path_from_value(Some(OsString::from("/tmp/boosapi.log"))),
|
||||
Some(std::path::PathBuf::from("/tmp/boosapi.log"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opens_log_file_and_creates_parent_directories() {
|
||||
let temp_dir = tempfile::tempdir().expect("tempdir");
|
||||
let log_path = temp_dir.path().join("nested").join("boosapi.log");
|
||||
|
||||
{
|
||||
let mut file = open_log_file(&log_path).expect("open log file");
|
||||
writeln!(file, "first").expect("write first log line");
|
||||
}
|
||||
{
|
||||
let mut file = open_log_file(&log_path).expect("reopen log file");
|
||||
writeln!(file, "second").expect("write second log line");
|
||||
}
|
||||
|
||||
let contents = std::fs::read_to_string(log_path).expect("read log file");
|
||||
assert!(contents.contains("first"));
|
||||
assert!(contents.contains("second"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
//! Indexing pipeline for knowledge base documents and memory items.
|
||||
//!
|
||||
//! Provides functions to:
|
||||
//! - Parse an `embedding_provider` string ("providerId::modelId")
|
||||
//! - Build a `ProviderRequestContext` for embedding API calls
|
||||
//! - Generate embeddings via provider adapters
|
||||
//! - Index knowledge base documents and memory items via the unified RAG layer
|
||||
//! - Search knowledge base / memory vectors via the unified RAG layer
|
||||
//! - Collect RAG context for conversation injection
|
||||
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
use boosapi_core::error::{BoosApiError, Result};
|
||||
use boosapi_core::rag::{self, ChunkStrategy, KnowledgeRAG, MemoryRAG};
|
||||
use boosapi_core::types::*;
|
||||
use boosapi_core::vector_store::{VectorSearchResult, VectorStore};
|
||||
|
||||
use boosapi_providers::{
|
||||
registry::ProviderRegistry, resolve_base_url_for_type, ProviderAdapter, ProviderRequestContext,
|
||||
};
|
||||
|
||||
// ── AsyncEmbedFn implementation ──────────────────────────────────────────────
|
||||
|
||||
/// Concrete implementation of `AsyncEmbedFn` that uses provider adapters.
|
||||
#[derive(Clone)]
|
||||
pub struct ProviderEmbedFn;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ProviderRerankFn;
|
||||
|
||||
pub(crate) fn configured_embedding_dimensions(dimensions: Option<i32>) -> Option<usize> {
|
||||
dimensions
|
||||
.and_then(|value| usize::try_from(value).ok())
|
||||
.filter(|value| *value > 0)
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl rag::AsyncEmbedFn for ProviderEmbedFn {
|
||||
async fn generate(
|
||||
&self,
|
||||
db: &DatabaseConnection,
|
||||
master_key: &[u8; 32],
|
||||
embedding_provider: &str,
|
||||
texts: Vec<String>,
|
||||
dimensions: Option<usize>,
|
||||
) -> Result<EmbedResponse> {
|
||||
generate_embeddings(db, master_key, embedding_provider, texts, dimensions).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl rag::AsyncRerankFn for ProviderRerankFn {
|
||||
async fn rerank(
|
||||
&self,
|
||||
db: &DatabaseConnection,
|
||||
master_key: &[u8; 32],
|
||||
rerank_provider: &str,
|
||||
query: &str,
|
||||
results: &[VectorSearchResult],
|
||||
top_n: usize,
|
||||
) -> Result<Vec<VectorSearchResult>> {
|
||||
rerank_search_results(db, master_key, rerank_provider, query, results, top_n).await
|
||||
}
|
||||
}
|
||||
|
||||
// ── Low-level embedding utilities ────────────────────────────────────────────
|
||||
|
||||
/// Parse an embedding_provider string like "providerId::modelId" into (provider_id, model_id).
|
||||
pub fn parse_embedding_provider(embedding_provider: &str) -> Result<(String, String)> {
|
||||
let parts: Vec<&str> = embedding_provider.splitn(2, "::").collect();
|
||||
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
|
||||
return Err(BoosApiError::Provider(format!(
|
||||
"Invalid embedding_provider format '{}'. Expected 'providerId::modelId'",
|
||||
embedding_provider
|
||||
)));
|
||||
}
|
||||
Ok((parts[0].to_string(), parts[1].to_string()))
|
||||
}
|
||||
|
||||
/// Resolve the provider type string used for registry lookup.
|
||||
fn provider_type_to_registry_key(pt: &ProviderType) -> &'static str {
|
||||
match pt {
|
||||
ProviderType::OpenAI => "openai",
|
||||
ProviderType::OpenAIResponses => "openai_responses",
|
||||
ProviderType::DeepSeek => "deepseek",
|
||||
ProviderType::XAI => "xai",
|
||||
ProviderType::GLM => "glm",
|
||||
ProviderType::SiliconFlow => "siliconflow",
|
||||
ProviderType::Anthropic => "anthropic",
|
||||
ProviderType::Gemini => "gemini",
|
||||
ProviderType::Jina => "jina",
|
||||
ProviderType::Cohere => "cohere",
|
||||
ProviderType::Voyage => "voyage",
|
||||
ProviderType::Custom => "custom",
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a ProviderRequestContext for an embedding provider.
|
||||
pub async fn build_embed_context(
|
||||
db: &DatabaseConnection,
|
||||
master_key: &[u8; 32],
|
||||
provider_id: &str,
|
||||
) -> Result<(ProviderRequestContext, ProviderConfig)> {
|
||||
let provider = boosapi_core::repo::provider::get_provider(db, provider_id).await?;
|
||||
let key_row = boosapi_core::repo::provider::get_active_key(db, provider_id).await?;
|
||||
let decrypted_key = boosapi_core::crypto::decrypt_key(&key_row.key_encrypted, master_key)?;
|
||||
|
||||
let global_settings = boosapi_core::repo::settings::get_settings(db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let resolved_proxy = ProviderProxyConfig::resolve(&provider.proxy_config, &global_settings);
|
||||
|
||||
let ctx = ProviderRequestContext {
|
||||
api_key: decrypted_key,
|
||||
key_id: key_row.id.clone(),
|
||||
provider_id: provider.id.clone(),
|
||||
base_url: Some(resolve_base_url_for_type(
|
||||
&provider.api_host,
|
||||
&provider.provider_type,
|
||||
)),
|
||||
api_path: None,
|
||||
proxy_config: resolved_proxy,
|
||||
custom_headers: provider
|
||||
.custom_headers
|
||||
.as_ref()
|
||||
.and_then(|s| serde_json::from_str(s).ok()),
|
||||
};
|
||||
|
||||
Ok((ctx, provider))
|
||||
}
|
||||
|
||||
/// Generate embeddings for a list of texts using the specified provider.
|
||||
pub async fn generate_embeddings(
|
||||
db: &DatabaseConnection,
|
||||
master_key: &[u8; 32],
|
||||
embedding_provider: &str,
|
||||
texts: Vec<String>,
|
||||
dimensions: Option<usize>,
|
||||
) -> Result<EmbedResponse> {
|
||||
let (provider_id, model_id) = parse_embedding_provider(embedding_provider)?;
|
||||
let (ctx, provider_config) = build_embed_context(db, master_key, &provider_id).await?;
|
||||
|
||||
let registry = ProviderRegistry::create_default();
|
||||
let registry_key = provider_type_to_registry_key(&provider_config.provider_type);
|
||||
let adapter: &dyn ProviderAdapter = registry.get(registry_key).ok_or_else(|| {
|
||||
BoosApiError::Provider(format!("Unsupported provider type: {}", registry_key))
|
||||
})?;
|
||||
|
||||
let request = EmbedRequest {
|
||||
model: model_id,
|
||||
input: texts,
|
||||
dimensions,
|
||||
};
|
||||
|
||||
adapter.embed(&ctx, request).await
|
||||
}
|
||||
|
||||
/// Rerank existing vector search results using a configured rerank provider.
|
||||
pub async fn generate_rerank(
|
||||
db: &DatabaseConnection,
|
||||
master_key: &[u8; 32],
|
||||
rerank_provider: &str,
|
||||
query: &str,
|
||||
documents: Vec<String>,
|
||||
top_n: usize,
|
||||
) -> Result<RerankResponse> {
|
||||
let (provider_id, model_id) = parse_embedding_provider(rerank_provider)?;
|
||||
let (ctx, provider_config) = build_embed_context(db, master_key, &provider_id).await?;
|
||||
|
||||
let registry = ProviderRegistry::create_default();
|
||||
let registry_key = provider_type_to_registry_key(&provider_config.provider_type);
|
||||
let adapter: &dyn ProviderAdapter = registry.get(registry_key).ok_or_else(|| {
|
||||
BoosApiError::Provider(format!("Unsupported provider type: {}", registry_key))
|
||||
})?;
|
||||
|
||||
let request = RerankRequest {
|
||||
model: model_id,
|
||||
query: query.to_string(),
|
||||
documents,
|
||||
top_n,
|
||||
};
|
||||
|
||||
adapter.rerank(&ctx, request).await
|
||||
}
|
||||
|
||||
pub async fn rerank_search_results(
|
||||
db: &DatabaseConnection,
|
||||
master_key: &[u8; 32],
|
||||
rerank_provider: &str,
|
||||
query: &str,
|
||||
results: &[VectorSearchResult],
|
||||
top_n: usize,
|
||||
) -> Result<Vec<VectorSearchResult>> {
|
||||
if results.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let top_n = top_n.max(1).min(results.len());
|
||||
let documents: Vec<String> = results.iter().map(|r| r.content.clone()).collect();
|
||||
let response =
|
||||
generate_rerank(db, master_key, rerank_provider, query, documents, top_n).await?;
|
||||
|
||||
Ok(apply_rerank_response(results, response, top_n))
|
||||
}
|
||||
|
||||
fn apply_rerank_response(
|
||||
results: &[VectorSearchResult],
|
||||
response: RerankResponse,
|
||||
top_n: usize,
|
||||
) -> Vec<VectorSearchResult> {
|
||||
let mut ranked = Vec::new();
|
||||
let mut used = std::collections::HashSet::new();
|
||||
let mut response_results = response.results;
|
||||
response_results.sort_by(|a, b| {
|
||||
b.relevance_score
|
||||
.partial_cmp(&a.relevance_score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
for item in response_results {
|
||||
if ranked.len() >= top_n {
|
||||
break;
|
||||
}
|
||||
if item.index >= results.len() || !used.insert(item.index) {
|
||||
continue;
|
||||
}
|
||||
let mut result = results[item.index].clone();
|
||||
result.rerank_score = Some(item.relevance_score);
|
||||
ranked.push(result);
|
||||
}
|
||||
|
||||
for (idx, result) in results.iter().enumerate() {
|
||||
if ranked.len() >= top_n {
|
||||
break;
|
||||
}
|
||||
if used.insert(idx) {
|
||||
ranked.push(result.clone());
|
||||
}
|
||||
}
|
||||
|
||||
ranked
|
||||
}
|
||||
|
||||
// ── Document / item indexing (delegates to rag::index) ───────────────────────
|
||||
|
||||
/// Index a single knowledge base document: parse → chunk → embed → store.
|
||||
///
|
||||
/// Updates document status to "indexing" then "ready" or "failed".
|
||||
pub async fn index_knowledge_document(
|
||||
db: &DatabaseConnection,
|
||||
master_key: &[u8; 32],
|
||||
vector_store: &VectorStore,
|
||||
knowledge_base_id: &str,
|
||||
document_id: &str,
|
||||
source_path: &str,
|
||||
mime_type: &str,
|
||||
embedding_provider: &str,
|
||||
dimensions: Option<usize>,
|
||||
chunk_size: Option<i32>,
|
||||
chunk_overlap: Option<i32>,
|
||||
) -> Result<()> {
|
||||
boosapi_core::repo::knowledge::update_document_status(db, document_id, "indexing").await?;
|
||||
|
||||
let strategy = ChunkStrategy::ParseAndChunk {
|
||||
source_path: source_path.to_string(),
|
||||
mime_type: mime_type.to_string(),
|
||||
chunk_size: chunk_size
|
||||
.map(|v| v as usize)
|
||||
.unwrap_or(boosapi_core::text_chunker::DEFAULT_CHUNK_SIZE),
|
||||
overlap: chunk_overlap
|
||||
.map(|v| v as usize)
|
||||
.unwrap_or(boosapi_core::text_chunker::DEFAULT_OVERLAP),
|
||||
};
|
||||
|
||||
let chunks = rag::prepare_chunks(document_id, &strategy)?;
|
||||
|
||||
if chunks.is_empty() {
|
||||
boosapi_core::repo::knowledge::update_document_status(db, document_id, "ready").await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let chunk_texts: Vec<String> = chunks.iter().map(|(_, text, _)| text.clone()).collect();
|
||||
let embed_response =
|
||||
generate_embeddings(db, master_key, embedding_provider, chunk_texts, dimensions).await?;
|
||||
|
||||
rag::index(
|
||||
vector_store,
|
||||
"kb",
|
||||
knowledge_base_id,
|
||||
document_id,
|
||||
"",
|
||||
embed_response.embeddings,
|
||||
chunks,
|
||||
)
|
||||
.await?;
|
||||
|
||||
boosapi_core::repo::knowledge::update_document_status(db, document_id, "ready").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Index a single memory item: embed content → store in vector DB.
|
||||
pub async fn index_memory_item(
|
||||
db: &DatabaseConnection,
|
||||
master_key: &[u8; 32],
|
||||
vector_store: &VectorStore,
|
||||
namespace_id: &str,
|
||||
item_id: &str,
|
||||
content: &str,
|
||||
embedding_provider: &str,
|
||||
dimensions: Option<usize>,
|
||||
) -> Result<()> {
|
||||
let chunks = rag::prepare_direct_chunk(item_id, content);
|
||||
|
||||
if chunks.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let chunk_texts: Vec<String> = chunks.iter().map(|(_, text, _)| text.clone()).collect();
|
||||
let embed_response =
|
||||
generate_embeddings(db, master_key, embedding_provider, chunk_texts, dimensions).await?;
|
||||
|
||||
rag::index(
|
||||
vector_store,
|
||||
"mem",
|
||||
namespace_id,
|
||||
item_id,
|
||||
content,
|
||||
embed_response.embeddings,
|
||||
chunks,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// ── Search (delegates to rag::search) ────────────────────────────────────────
|
||||
|
||||
/// Search knowledge base vectors for relevant content.
|
||||
pub async fn search_knowledge(
|
||||
db: &DatabaseConnection,
|
||||
master_key: &[u8; 32],
|
||||
vector_store: &VectorStore,
|
||||
knowledge_base_id: &str,
|
||||
query: &str,
|
||||
top_k: usize,
|
||||
) -> Result<Vec<VectorSearchResult>> {
|
||||
let kb = boosapi_core::repo::knowledge::get_knowledge_base(db, knowledge_base_id).await?;
|
||||
let dimensions = configured_embedding_dimensions(kb.embedding_dimensions);
|
||||
let final_top_k = kb
|
||||
.retrieval_top_k
|
||||
.filter(|v| *v > 0)
|
||||
.map(|v| v as usize)
|
||||
.unwrap_or(top_k)
|
||||
.max(1);
|
||||
let rerank_provider = kb
|
||||
.rerank_provider
|
||||
.filter(|provider| !provider.trim().is_empty());
|
||||
let source_top_k = if rerank_provider.is_some() {
|
||||
let min_candidates = final_top_k.min(100) as i32;
|
||||
kb.rerank_candidate_k
|
||||
.unwrap_or(20)
|
||||
.clamp(min_candidates, 100) as usize
|
||||
} else {
|
||||
final_top_k
|
||||
};
|
||||
|
||||
let raw_results = rag::search(
|
||||
&KnowledgeRAG,
|
||||
db,
|
||||
master_key,
|
||||
vector_store,
|
||||
knowledge_base_id,
|
||||
query,
|
||||
source_top_k,
|
||||
dimensions,
|
||||
ProviderEmbedFn,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut results: Vec<_> = if let Some(threshold) = kb.retrieval_threshold.filter(|v| *v > 0.0) {
|
||||
raw_results
|
||||
.into_iter()
|
||||
.filter(|r| rag::passes_retrieval_threshold(r.score, threshold))
|
||||
.collect()
|
||||
} else {
|
||||
raw_results
|
||||
};
|
||||
|
||||
if let Some(rerank_provider) = rerank_provider {
|
||||
results = rerank_search_results(
|
||||
db,
|
||||
master_key,
|
||||
&rerank_provider,
|
||||
query,
|
||||
&results,
|
||||
final_top_k,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
results.truncate(final_top_k);
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Search memory namespace vectors for relevant content.
|
||||
pub async fn search_memory(
|
||||
db: &DatabaseConnection,
|
||||
master_key: &[u8; 32],
|
||||
vector_store: &VectorStore,
|
||||
namespace_id: &str,
|
||||
query: &str,
|
||||
top_k: usize,
|
||||
) -> Result<Vec<VectorSearchResult>> {
|
||||
// Look up namespace settings for dimensions
|
||||
let dims = boosapi_core::repo::memory::get_namespace(db, namespace_id)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|ns| ns.embedding_dimensions.map(|v| v as usize));
|
||||
rag::search(
|
||||
&MemoryRAG,
|
||||
db,
|
||||
master_key,
|
||||
vector_store,
|
||||
namespace_id,
|
||||
query,
|
||||
top_k,
|
||||
dims,
|
||||
ProviderEmbedFn,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// ── Context collection (delegates to rag::collect_rag_context) ───────────────
|
||||
|
||||
/// Collect RAG context from all enabled sources for a conversation query.
|
||||
///
|
||||
/// Returns a `RagContextResult` with formatted context parts and structured results.
|
||||
pub async fn collect_rag_context(
|
||||
db: &DatabaseConnection,
|
||||
master_key: &[u8; 32],
|
||||
vector_store: &VectorStore,
|
||||
kb_ids: &[String],
|
||||
mem_ids: &[String],
|
||||
query: &str,
|
||||
top_k: usize,
|
||||
) -> RagContextResult {
|
||||
rag::collect_rag_context(
|
||||
db,
|
||||
master_key,
|
||||
vector_store,
|
||||
kb_ids,
|
||||
mem_ids,
|
||||
query,
|
||||
top_k,
|
||||
ProviderEmbedFn,
|
||||
ProviderRerankFn,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn vector_result(id: &str, score: f32) -> VectorSearchResult {
|
||||
VectorSearchResult {
|
||||
id: id.to_string(),
|
||||
document_id: format!("doc-{id}"),
|
||||
chunk_index: 0,
|
||||
content: format!("content {id}"),
|
||||
score,
|
||||
rerank_score: None,
|
||||
has_embedding: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_rerank_response_reorders_by_provider_indexes_and_truncates() {
|
||||
let results = vec![
|
||||
vector_result("a", 0.1),
|
||||
vector_result("b", 0.2),
|
||||
vector_result("c", 0.3),
|
||||
];
|
||||
|
||||
let ranked = apply_rerank_response(
|
||||
&results,
|
||||
RerankResponse {
|
||||
results: vec![
|
||||
RerankResult {
|
||||
index: 0,
|
||||
relevance_score: 0.82,
|
||||
},
|
||||
RerankResult {
|
||||
index: 2,
|
||||
relevance_score: 0.91,
|
||||
},
|
||||
],
|
||||
},
|
||||
2,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
ranked.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
|
||||
vec!["c", "a"]
|
||||
);
|
||||
assert_eq!(ranked[0].score, 0.3);
|
||||
assert_eq!(ranked[0].rerank_score, Some(0.91));
|
||||
assert_eq!(ranked[1].rerank_score, Some(0.82));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_embedding_dimensions_uses_positive_values_only() {
|
||||
assert_eq!(configured_embedding_dimensions(Some(3072)), Some(3072));
|
||||
assert_eq!(configured_embedding_dimensions(Some(0)), None);
|
||||
assert_eq!(configured_embedding_dimensions(Some(-1)), None);
|
||||
assert_eq!(configured_embedding_dimensions(None), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
future::Future,
|
||||
sync::{Arc, Mutex, MutexGuard},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
const DEFAULT_INDEX_CONCURRENCY: usize = 1;
|
||||
const MAX_INDEX_CONCURRENCY: usize = 10;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct KnowledgeIndexScheduleOptions {
|
||||
pub index_concurrency: Option<i32>,
|
||||
pub index_interval_ms: Option<i32>,
|
||||
}
|
||||
|
||||
pub fn normalize_index_concurrency(value: Option<i32>) -> usize {
|
||||
value.unwrap_or(DEFAULT_INDEX_CONCURRENCY as i32).clamp(
|
||||
DEFAULT_INDEX_CONCURRENCY as i32,
|
||||
MAX_INDEX_CONCURRENCY as i32,
|
||||
) as usize
|
||||
}
|
||||
|
||||
pub fn normalize_index_interval_ms(value: Option<i32>) -> u64 {
|
||||
value.unwrap_or(0).max(0) as u64
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct KnowledgeIndexScheduler {
|
||||
bases: Mutex<HashMap<String, Arc<BaseIndexState>>>,
|
||||
}
|
||||
|
||||
impl KnowledgeIndexScheduler {
|
||||
pub async fn run<F, Fut, T>(
|
||||
&self,
|
||||
base_id: &str,
|
||||
options: KnowledgeIndexScheduleOptions,
|
||||
task: F,
|
||||
) -> T
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = T>,
|
||||
{
|
||||
let state = self.state_for(base_id);
|
||||
let _permit = state.acquire(options).await;
|
||||
task().await
|
||||
}
|
||||
|
||||
fn state_for(&self, base_id: &str) -> Arc<BaseIndexState> {
|
||||
let mut bases = lock_or_recover(&self.bases);
|
||||
bases
|
||||
.entry(base_id.to_string())
|
||||
.or_insert_with(|| Arc::new(BaseIndexState::default()))
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BaseIndexState {
|
||||
inner: Mutex<BaseIndexInner>,
|
||||
notify: Notify,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BaseIndexInner {
|
||||
running: usize,
|
||||
next_start_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl BaseIndexState {
|
||||
async fn acquire(self: Arc<Self>, options: KnowledgeIndexScheduleOptions) -> IndexTaskPermit {
|
||||
let concurrency = normalize_index_concurrency(options.index_concurrency);
|
||||
let interval =
|
||||
Duration::from_millis(normalize_index_interval_ms(options.index_interval_ms));
|
||||
|
||||
loop {
|
||||
let wait = {
|
||||
let mut inner = lock_or_recover(&self.inner);
|
||||
if inner.running < concurrency {
|
||||
let now = Instant::now();
|
||||
if let Some(next_start_at) = inner.next_start_at {
|
||||
if next_start_at > now {
|
||||
Some(next_start_at.duration_since(now))
|
||||
} else {
|
||||
inner.running += 1;
|
||||
inner.next_start_at = Some(now + interval);
|
||||
return IndexTaskPermit {
|
||||
state: self.clone(),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
inner.running += 1;
|
||||
inner.next_start_at = Some(now + interval);
|
||||
return IndexTaskPermit {
|
||||
state: self.clone(),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(wait) = wait {
|
||||
tokio::time::sleep(wait).await;
|
||||
} else {
|
||||
self.notify.notified().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct IndexTaskPermit {
|
||||
state: Arc<BaseIndexState>,
|
||||
}
|
||||
|
||||
impl Drop for IndexTaskPermit {
|
||||
fn drop(&mut self) {
|
||||
let mut inner = lock_or_recover(&self.state.inner);
|
||||
inner.running = inner.running.saturating_sub(1);
|
||||
drop(inner);
|
||||
self.state.notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
fn lock_or_recover<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||
mutex
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
@@ -0,0 +1,988 @@
|
||||
use boosapi_core::db;
|
||||
use chrono;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct StreamCancelEntry {
|
||||
pub conversation_id: String,
|
||||
pub flag: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
pub sea_db: DatabaseConnection,
|
||||
pub master_key: [u8; 32],
|
||||
pub gateway: Arc<Mutex<Option<boosapi_gateway::server::GatewayServer>>>,
|
||||
pub close_to_tray: Arc<AtomicBool>,
|
||||
pub release_webview_on_tray: Arc<AtomicBool>,
|
||||
pub main_window_released_to_tray: Arc<AtomicBool>,
|
||||
pub main_window_restoring: Arc<AtomicBool>,
|
||||
pub is_quitting: Arc<AtomicBool>,
|
||||
pub app_data_dir: PathBuf,
|
||||
pub db_path: String,
|
||||
pub auto_backup_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
|
||||
pub webdav_sync_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
|
||||
pub s3_sync_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
|
||||
pub vector_store: Arc<boosapi_core::vector_store::VectorStore>,
|
||||
pub knowledge_index_scheduler: Arc<knowledge_index_scheduler::KnowledgeIndexScheduler>,
|
||||
pub stream_cancel_flags: Arc<Mutex<std::collections::HashMap<String, StreamCancelEntry>>>,
|
||||
pub agent_cancel_tokens:
|
||||
Arc<Mutex<std::collections::HashMap<String, open_agent_sdk::CancellationToken>>>,
|
||||
pub agent_permission_senders:
|
||||
Arc<Mutex<std::collections::HashMap<String, tokio::sync::oneshot::Sender<String>>>>,
|
||||
pub agent_ask_senders:
|
||||
Arc<Mutex<std::collections::HashMap<String, tokio::sync::oneshot::Sender<String>>>>,
|
||||
pub agent_always_allowed:
|
||||
Arc<Mutex<std::collections::HashMap<String, std::collections::HashSet<String>>>>,
|
||||
}
|
||||
|
||||
mod commands;
|
||||
mod context_manager;
|
||||
mod diagnostics;
|
||||
mod indexing;
|
||||
pub mod knowledge_index_scheduler;
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
mod linux_webkit;
|
||||
mod paths;
|
||||
mod startup_diagnostics;
|
||||
mod tray;
|
||||
mod window_lifecycle;
|
||||
mod window_state;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows_utils;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
diagnostics::init_tracing();
|
||||
diagnostics::install_panic_hook();
|
||||
diagnostics::log_process_startup();
|
||||
startup_diagnostics::log_startup_env_switches();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
linux_webkit::apply_startup_workarounds();
|
||||
|
||||
#[allow(unused_mut)]
|
||||
let mut builder = {
|
||||
tracing::info!("Creating Tauri application builder");
|
||||
let builder = tauri::Builder::default();
|
||||
tracing::info!("Created Tauri application builder");
|
||||
builder
|
||||
};
|
||||
|
||||
let minimal_plugins = {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
startup_diagnostics::linux_minimal_plugins_enabled()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if minimal_plugins {
|
||||
tracing::warn!(
|
||||
env = startup_diagnostics::LINUX_MINIMAL_PLUGINS_ENV,
|
||||
"Skipping nonessential Tauri plugins for Linux startup diagnostics"
|
||||
);
|
||||
} else {
|
||||
builder = startup_diagnostics::register_plugin(
|
||||
builder,
|
||||
"single-instance",
|
||||
tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
tracing::info!("BoosApi single-instance callback reached");
|
||||
window_lifecycle::restore_main_window(app);
|
||||
}),
|
||||
);
|
||||
builder = builder.plugin(startup_diagnostics::diagnostic_marker_plugin(
|
||||
"boosapi_diag_after_single_instance",
|
||||
));
|
||||
|
||||
builder = startup_diagnostics::register_plugin(
|
||||
builder,
|
||||
"deep-link",
|
||||
tauri_plugin_deep_link::init(),
|
||||
);
|
||||
builder = builder.plugin(startup_diagnostics::diagnostic_marker_plugin(
|
||||
"boosapi_diag_after_deep_link",
|
||||
));
|
||||
|
||||
builder =
|
||||
startup_diagnostics::register_plugin(builder, "opener", tauri_plugin_opener::init());
|
||||
builder = builder.plugin(startup_diagnostics::diagnostic_marker_plugin(
|
||||
"boosapi_diag_after_opener",
|
||||
));
|
||||
|
||||
builder =
|
||||
startup_diagnostics::register_plugin(builder, "dialog", tauri_plugin_dialog::init());
|
||||
builder = builder.plugin(startup_diagnostics::diagnostic_marker_plugin(
|
||||
"boosapi_diag_after_dialog",
|
||||
));
|
||||
|
||||
builder = startup_diagnostics::register_plugin(builder, "fs", tauri_plugin_fs::init());
|
||||
builder = builder.plugin(startup_diagnostics::diagnostic_marker_plugin(
|
||||
"boosapi_diag_after_fs",
|
||||
));
|
||||
|
||||
builder = startup_diagnostics::register_plugin(
|
||||
builder,
|
||||
"autostart",
|
||||
tauri_plugin_autostart::init(tauri_plugin_autostart::MacosLauncher::LaunchAgent, None),
|
||||
);
|
||||
builder = builder.plugin(startup_diagnostics::diagnostic_marker_plugin(
|
||||
"boosapi_diag_after_autostart",
|
||||
));
|
||||
|
||||
builder = startup_diagnostics::register_plugin(
|
||||
builder,
|
||||
"global-shortcut",
|
||||
tauri_plugin_global_shortcut::Builder::new().build(),
|
||||
);
|
||||
builder = builder.plugin(startup_diagnostics::diagnostic_marker_plugin(
|
||||
"boosapi_diag_after_global_shortcut",
|
||||
));
|
||||
|
||||
builder =
|
||||
startup_diagnostics::register_plugin(builder, "process", tauri_plugin_process::init());
|
||||
builder = builder.plugin(startup_diagnostics::diagnostic_marker_plugin(
|
||||
"boosapi_diag_after_process",
|
||||
));
|
||||
|
||||
builder = startup_diagnostics::register_plugin(
|
||||
builder,
|
||||
"clipboard-manager",
|
||||
tauri_plugin_clipboard_manager::init(),
|
||||
);
|
||||
builder = builder.plugin(startup_diagnostics::diagnostic_marker_plugin(
|
||||
"boosapi_diag_after_clipboard_manager",
|
||||
));
|
||||
|
||||
builder = startup_diagnostics::register_plugin(
|
||||
builder,
|
||||
"updater",
|
||||
tauri_plugin_updater::Builder::new().build(),
|
||||
);
|
||||
builder = builder.plugin(startup_diagnostics::diagnostic_marker_plugin(
|
||||
"boosapi_diag_after_updater",
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
if !minimal_plugins {
|
||||
builder = startup_diagnostics::register_plugin(
|
||||
builder,
|
||||
"mcp-bridge",
|
||||
tauri_plugin_mcp_bridge::init(),
|
||||
);
|
||||
builder = builder.plugin(startup_diagnostics::diagnostic_marker_plugin(
|
||||
"boosapi_diag_after_mcp_bridge",
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if startup_diagnostics::linux_any_thread_enabled() {
|
||||
tracing::warn!(
|
||||
env = startup_diagnostics::LINUX_ANY_THREAD_ENV,
|
||||
"Using Tauri any_thread runtime path for Linux startup diagnostics"
|
||||
);
|
||||
builder = builder.any_thread();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let context = {
|
||||
tracing::info!("Generating Tauri context");
|
||||
let mut context = tauri::generate_context!();
|
||||
tracing::info!("Generated Tauri context");
|
||||
linux_webkit::configure_startup_window_creation(&mut context);
|
||||
tracing::info!("Configured Linux startup window creation");
|
||||
context
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let context = {
|
||||
tracing::info!("Generating Tauri context");
|
||||
let context = tauri::generate_context!();
|
||||
tracing::info!("Generated Tauri context");
|
||||
context
|
||||
};
|
||||
|
||||
let startup_phase = startup_diagnostics::StartupPhase::new("attaching invoke handler");
|
||||
let watchdog = startup_diagnostics::start_linux_startup_watchdog(startup_phase.clone());
|
||||
|
||||
tracing::info!("Attaching Tauri invoke handler");
|
||||
startup_phase.set("attaching invoke handler");
|
||||
let builder = builder.invoke_handler(tauri::generate_handler![
|
||||
// providers
|
||||
commands::providers::list_providers,
|
||||
commands::providers::create_provider,
|
||||
commands::providers::import_provider_from_deep_link,
|
||||
commands::providers::scan_cc_switch_provider_imports,
|
||||
commands::providers::import_cc_switch_provider_configs,
|
||||
commands::providers::update_provider,
|
||||
commands::providers::delete_provider,
|
||||
commands::providers::toggle_provider,
|
||||
commands::providers::add_provider_key,
|
||||
commands::providers::update_provider_key,
|
||||
commands::providers::delete_provider_key,
|
||||
commands::providers::toggle_provider_key,
|
||||
commands::providers::get_decrypted_provider_key,
|
||||
commands::providers::validate_provider_key,
|
||||
commands::providers::save_models,
|
||||
commands::providers::toggle_model,
|
||||
commands::providers::update_model_params,
|
||||
commands::providers::fetch_remote_models,
|
||||
commands::providers::test_model,
|
||||
commands::providers::reorder_providers,
|
||||
// drawing
|
||||
commands::drawing::list_drawing_generations,
|
||||
commands::drawing::upload_drawing_reference,
|
||||
commands::drawing::generate_drawing_images,
|
||||
commands::drawing::edit_drawing_image,
|
||||
commands::drawing::edit_drawing_image_with_mask,
|
||||
commands::drawing::delete_drawing_generation,
|
||||
// conversations
|
||||
commands::conversations::list_conversations,
|
||||
commands::conversations::create_conversation,
|
||||
commands::conversations::update_conversation,
|
||||
commands::conversations::delete_conversation,
|
||||
commands::conversations::branch_conversation,
|
||||
commands::conversations::search_conversations,
|
||||
commands::conversations::send_message,
|
||||
commands::conversations::toggle_pin_conversation,
|
||||
commands::conversations::toggle_archive_conversation,
|
||||
commands::conversations::list_archived_conversations,
|
||||
commands::conversations::regenerate_message,
|
||||
commands::conversations::regenerate_with_model,
|
||||
commands::conversations::cancel_stream,
|
||||
commands::conversations::list_message_versions,
|
||||
commands::conversations::switch_message_version,
|
||||
commands::conversations::delete_message_group,
|
||||
commands::conversations::send_system_message,
|
||||
commands::conversations::compress_context,
|
||||
commands::conversations::get_compression_summary,
|
||||
commands::conversations::get_context_usage,
|
||||
commands::conversations::delete_compression,
|
||||
commands::conversations::regenerate_conversation_title,
|
||||
// conversation categories
|
||||
commands::conversation_categories::list_conversation_categories,
|
||||
commands::conversation_categories::create_conversation_category,
|
||||
commands::conversation_categories::update_conversation_category,
|
||||
commands::conversation_categories::delete_conversation_category,
|
||||
commands::conversation_categories::reorder_conversation_categories,
|
||||
commands::conversation_categories::set_conversation_category_collapsed,
|
||||
// settings
|
||||
commands::settings::get_settings,
|
||||
commands::settings::save_settings,
|
||||
// gateway
|
||||
commands::gateway::list_gateway_keys,
|
||||
commands::gateway::create_gateway_key,
|
||||
commands::gateway::delete_gateway_key,
|
||||
commands::gateway::toggle_gateway_key,
|
||||
commands::gateway::decrypt_gateway_key,
|
||||
commands::gateway::get_gateway_metrics,
|
||||
commands::gateway::start_gateway,
|
||||
commands::gateway::stop_gateway,
|
||||
commands::gateway::get_gateway_status,
|
||||
commands::gateway::get_gateway_usage_by_key,
|
||||
commands::gateway::get_gateway_usage_by_provider,
|
||||
commands::gateway::get_gateway_usage_by_day,
|
||||
commands::gateway::get_connected_programs,
|
||||
commands::gateway::get_gateway_diagnostics,
|
||||
commands::gateway::get_program_policies,
|
||||
commands::gateway::save_program_policy,
|
||||
commands::gateway::delete_program_policy,
|
||||
commands::gateway::list_gateway_templates,
|
||||
commands::gateway::copy_gateway_template,
|
||||
commands::gateway::list_gateway_request_logs,
|
||||
commands::gateway::clear_gateway_request_logs,
|
||||
commands::gateway::get_all_cli_tool_statuses,
|
||||
commands::gateway::connect_cli_tool,
|
||||
commands::gateway::disconnect_cli_tool,
|
||||
commands::gateway::get_codex_session_visibility_status,
|
||||
commands::gateway::repair_codex_session_visibility,
|
||||
commands::gateway::generate_self_signed_cert,
|
||||
// messages
|
||||
commands::messages::list_messages,
|
||||
commands::messages::list_messages_page,
|
||||
commands::messages::delete_message,
|
||||
commands::messages::update_message_content,
|
||||
commands::messages::clear_conversation_messages,
|
||||
commands::messages::export_conversation,
|
||||
commands::messages::get_conversation_stats,
|
||||
// artifacts
|
||||
commands::artifacts::list_artifacts,
|
||||
commands::artifacts::create_artifact,
|
||||
commands::artifacts::update_artifact,
|
||||
commands::artifacts::delete_artifact,
|
||||
// context sources
|
||||
commands::context_sources::list_context_sources,
|
||||
commands::context_sources::add_context_source,
|
||||
commands::context_sources::remove_context_source,
|
||||
commands::context_sources::toggle_context_source,
|
||||
// branches & workspace
|
||||
commands::branches::list_branches,
|
||||
commands::branches::fork_conversation,
|
||||
commands::branches::compare_branches,
|
||||
commands::branches::get_workspace_snapshot,
|
||||
commands::branches::update_workspace_snapshot,
|
||||
// search providers
|
||||
commands::search::list_search_providers,
|
||||
commands::search::create_search_provider,
|
||||
commands::search::update_search_provider,
|
||||
commands::search::delete_search_provider,
|
||||
commands::search::test_search_provider,
|
||||
commands::search::execute_search,
|
||||
commands::conversations::generate_search_query,
|
||||
// mcp servers
|
||||
commands::mcp::list_mcp_servers,
|
||||
commands::mcp::create_mcp_server,
|
||||
commands::mcp::update_mcp_server,
|
||||
commands::mcp::delete_mcp_server,
|
||||
commands::mcp::test_mcp_server,
|
||||
commands::mcp::list_mcp_tools,
|
||||
commands::mcp::discover_mcp_tools,
|
||||
commands::mcp::list_tool_executions,
|
||||
// knowledge
|
||||
commands::knowledge::list_knowledge_bases,
|
||||
commands::knowledge::create_knowledge_base,
|
||||
commands::knowledge::update_knowledge_base,
|
||||
commands::knowledge::delete_knowledge_base,
|
||||
commands::knowledge::reorder_knowledge_bases,
|
||||
commands::knowledge::list_knowledge_documents,
|
||||
commands::knowledge::add_knowledge_document,
|
||||
commands::knowledge::delete_knowledge_document,
|
||||
commands::knowledge::search_knowledge_base,
|
||||
commands::knowledge::rebuild_knowledge_index,
|
||||
commands::knowledge::clear_knowledge_index,
|
||||
commands::knowledge::list_knowledge_document_chunks,
|
||||
commands::knowledge::delete_knowledge_chunk,
|
||||
commands::knowledge::update_knowledge_chunk,
|
||||
commands::knowledge::reindex_knowledge_chunk,
|
||||
commands::knowledge::rebuild_knowledge_document,
|
||||
commands::knowledge::add_knowledge_chunk,
|
||||
// memory
|
||||
commands::memory::list_memory_namespaces,
|
||||
commands::memory::create_memory_namespace,
|
||||
commands::memory::delete_memory_namespace,
|
||||
commands::memory::update_memory_namespace,
|
||||
commands::memory::list_memory_items,
|
||||
commands::memory::add_memory_item,
|
||||
commands::memory::delete_memory_item,
|
||||
commands::memory::update_memory_item,
|
||||
commands::memory::search_memory,
|
||||
commands::memory::rebuild_memory_index,
|
||||
commands::memory::clear_memory_index,
|
||||
commands::memory::reindex_memory_item,
|
||||
commands::memory::reorder_memory_namespaces,
|
||||
// backup
|
||||
commands::backup::list_backups,
|
||||
commands::backup::create_backup,
|
||||
commands::backup::restore_backup,
|
||||
commands::backup::delete_backup,
|
||||
commands::backup::batch_delete_backups,
|
||||
commands::backup::get_backup_settings,
|
||||
commands::backup::update_backup_settings,
|
||||
commands::cherry_import::scan_cherry_studio_import,
|
||||
commands::cherry_import::import_cherry_studio_backup,
|
||||
commands::chatgpt_import::scan_chatgpt_import,
|
||||
commands::chatgpt_import::import_chatgpt_export,
|
||||
commands::kelivo_import::scan_kelivo_import,
|
||||
commands::kelivo_import::import_kelivo_backup,
|
||||
// webdav
|
||||
commands::webdav::get_webdav_config,
|
||||
commands::webdav::save_webdav_config,
|
||||
commands::webdav::webdav_check_connection,
|
||||
commands::webdav::webdav_backup,
|
||||
commands::webdav::webdav_list_backups,
|
||||
commands::webdav::webdav_restore,
|
||||
commands::webdav::webdav_delete_backup,
|
||||
commands::webdav::get_webdav_sync_status,
|
||||
commands::webdav::restart_webdav_sync,
|
||||
// s3
|
||||
commands::s3::get_s3_config,
|
||||
commands::s3::save_s3_config,
|
||||
commands::s3::s3_check_connection,
|
||||
commands::s3::s3_backup,
|
||||
commands::s3::s3_list_backups,
|
||||
commands::s3::s3_restore,
|
||||
commands::s3::s3_delete_backup,
|
||||
commands::s3::get_s3_sync_status,
|
||||
commands::s3::restart_s3_sync,
|
||||
// desktop
|
||||
commands::desktop::get_desktop_capabilities,
|
||||
commands::desktop::send_desktop_notification,
|
||||
commands::desktop::get_window_state,
|
||||
commands::desktop::set_always_on_top,
|
||||
commands::desktop::set_close_to_tray,
|
||||
commands::desktop::set_release_webview_on_tray,
|
||||
commands::desktop::force_quit,
|
||||
commands::desktop::apply_startup_settings,
|
||||
commands::desktop::test_proxy,
|
||||
commands::desktop::open_devtools,
|
||||
commands::desktop::write_diagnostic_log,
|
||||
commands::desktop::list_system_fonts,
|
||||
commands::desktop::minimize_window,
|
||||
commands::desktop::toggle_maximize_window,
|
||||
// files
|
||||
commands::files::upload_file,
|
||||
commands::files::download_file,
|
||||
commands::files::fetch_remote_image,
|
||||
commands::files::list_files,
|
||||
commands::files::delete_file,
|
||||
// files page
|
||||
commands::files_page::list_files_page_entries,
|
||||
commands::files_page::open_files_page_entry,
|
||||
commands::files_page::reveal_files_page_entry,
|
||||
commands::files_page::cleanup_missing_files_page_entry,
|
||||
commands::files_page::check_attachment_exists,
|
||||
commands::files_page::resolve_attachment_path,
|
||||
commands::files_page::read_attachment_preview,
|
||||
commands::files_page::reveal_attachment_file,
|
||||
commands::files_page::save_avatar_file,
|
||||
commands::files_page::open_attachment_file,
|
||||
// storage
|
||||
commands::storage::get_storage_inventory,
|
||||
commands::storage::open_storage_directory,
|
||||
commands::storage::validate_documents_root,
|
||||
commands::storage::change_documents_root,
|
||||
commands::storage::reset_documents_root,
|
||||
// agent
|
||||
commands::agent::agent_query,
|
||||
commands::agent::agent_cancel,
|
||||
commands::agent::agent_update_session,
|
||||
commands::agent::agent_get_session,
|
||||
commands::agent::agent_ensure_workspace,
|
||||
commands::agent::agent_approve,
|
||||
commands::agent::agent_respond_ask,
|
||||
commands::agent::agent_backup_and_clear_sdk_context,
|
||||
commands::agent::agent_restore_sdk_context_from_backup,
|
||||
// skills
|
||||
commands::skills::list_skills,
|
||||
commands::skills::get_skill,
|
||||
commands::skills::toggle_skill,
|
||||
commands::skills::install_skill,
|
||||
commands::skills::uninstall_skill,
|
||||
commands::skills::uninstall_skill_group,
|
||||
commands::skills::open_skills_dir,
|
||||
commands::skills::open_skill_dir,
|
||||
commands::skills::search_marketplace,
|
||||
commands::skills::check_skill_updates,
|
||||
]);
|
||||
tracing::info!("Attached Tauri invoke handler");
|
||||
|
||||
tracing::info!("Attaching Tauri setup handler");
|
||||
startup_phase.set("attaching setup handler");
|
||||
let builder = builder.setup(|app| {
|
||||
tracing::info!("BoosApi setup closure entered");
|
||||
|
||||
// Force overlay (auto-hide) scrollbar style on macOS.
|
||||
// Apps linked against older SDKs (e.g. macOS 15 CI builds) may
|
||||
// fall back to classic native scrollbars, ignoring CSS
|
||||
// ::-webkit-scrollbar styling. Setting this user default before
|
||||
// the WebView is created ensures consistent thin overlay
|
||||
// scrollbars regardless of which SDK the binary was linked with.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use objc2::msg_send;
|
||||
use objc2::rc::Retained;
|
||||
use objc2::runtime::{AnyClass, AnyObject};
|
||||
|
||||
unsafe {
|
||||
let defaults_cls = AnyClass::get(c"NSUserDefaults").unwrap();
|
||||
let defaults: Retained<AnyObject> =
|
||||
msg_send![defaults_cls, standardUserDefaults];
|
||||
|
||||
let str_cls = AnyClass::get(c"NSString").unwrap();
|
||||
let key: Retained<AnyObject> =
|
||||
msg_send![str_cls, stringWithUTF8String: c"AppleShowScrollBars".as_ptr()];
|
||||
let value: Retained<AnyObject> =
|
||||
msg_send![str_cls, stringWithUTF8String: c"WhenScrolling".as_ptr()];
|
||||
|
||||
let _: () = msg_send![&*defaults, setObject: &*value, forKey: &*key];
|
||||
}
|
||||
}
|
||||
|
||||
// Canonical BoosApi home directory (~/.boosapi/ on macOS/Linux,
|
||||
// %USERPROFILE%\.boosapi\ on Windows).
|
||||
let app_dir = paths::boosapi_home();
|
||||
std::fs::create_dir_all(&app_dir).expect("failed to create BoosApi home dir");
|
||||
tracing::info!(
|
||||
app_dir = %app_dir.display(),
|
||||
version = %app.package_info().version,
|
||||
"BoosApi setup started"
|
||||
);
|
||||
|
||||
// Ensure ~/Documents/boosapi/{images,files,backups}/ exist
|
||||
boosapi_core::storage_paths::ensure_documents_dirs()
|
||||
.expect("failed to create documents storage dirs");
|
||||
tracing::info!("BoosApi documents directories ensured");
|
||||
|
||||
let db_path = format!("sqlite:{}/boosapi.db", app_dir.display());
|
||||
tracing::info!(db_path = %db_path, "BoosApi database path resolved");
|
||||
|
||||
// Load or generate master key BEFORE opening the database.
|
||||
// db::create_pool uses SQLite create mode, which would create boosapi.db
|
||||
// on first launch — causing the safety guard below to misfire if it ran
|
||||
// after the pool is opened.
|
||||
let key_path = app_dir.join("master.key");
|
||||
let master_key = if key_path.exists() {
|
||||
let mut bytes = std::fs::read(&key_path).expect("failed to read master key");
|
||||
if bytes.len() != 32 {
|
||||
panic!(
|
||||
"master.key is corrupted: expected 32 bytes, got {}. Delete the file to regenerate.",
|
||||
bytes.len()
|
||||
);
|
||||
}
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&bytes);
|
||||
// Securely clear the temporary buffer
|
||||
bytes.iter_mut().for_each(|b| *b = 0);
|
||||
tracing::info!("BoosApi master key loaded");
|
||||
key
|
||||
} else {
|
||||
// Safety guard: refuse to generate a new key when an existing database is
|
||||
// present. A fresh key would make every byte of encrypted data in the DB
|
||||
// permanently unrecoverable.
|
||||
// Note: we check for the DB file *before* create_pool so that a genuine
|
||||
// fresh install (no db, no key) can proceed normally.
|
||||
let db_file = app_dir.join("boosapi.db");
|
||||
if db_file.exists() {
|
||||
panic!(
|
||||
"FATAL: boosapi.db exists at '{}' but master.key is missing from '{}'.\n\
|
||||
Generating a new master key would render all encrypted database \
|
||||
contents permanently unrecoverable.\n\n\
|
||||
Options:\n\
|
||||
• Restore master.key from a backup and restart.\n\
|
||||
• Remove boosapi.db (and boosapi.db-shm / boosapi.db-wal if present) \
|
||||
to start fresh — ALL DATA WILL BE LOST.",
|
||||
db_file.display(),
|
||||
key_path.display()
|
||||
);
|
||||
}
|
||||
let key = boosapi_core::crypto::generate_master_key();
|
||||
std::fs::write(&key_path, &key).expect("failed to write master key");
|
||||
// Restrict file permissions to owner-only (Unix)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
std::fs::set_permissions(&key_path, perms)
|
||||
.expect("failed to set master.key permissions");
|
||||
}
|
||||
tracing::info!("BoosApi master key generated");
|
||||
key
|
||||
};
|
||||
|
||||
// Register sqlite-vec extension before any DB connections
|
||||
boosapi_core::vector_store::register_sqlite_vec_extension();
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let db_handle = match rt.block_on(db::create_pool(&db_path)) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
let msg = format!(
|
||||
"数据库初始化失败: {}\n\n\
|
||||
如果您从新版本回退到旧版本,数据库结构可能不兼容。\n\
|
||||
请使用最新版本的 BoosApi。",
|
||||
e
|
||||
);
|
||||
tracing::error!("{}", msg);
|
||||
// Show native dialog so user sees the error
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let escaped = msg.replace('\"', "\\\"").replace('\n', "\\n");
|
||||
let _ = std::process::Command::new("osascript")
|
||||
.args(["-e", &format!(
|
||||
"display dialog \"{}\" with title \"BoosApi\" buttons {{\"OK\"}} default button \"OK\" with icon stop",
|
||||
escaped
|
||||
)])
|
||||
.output();
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows_utils::show_error_dialog("BoosAPI Desktop", &msg);
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
tracing::info!("BoosApi database pool initialized");
|
||||
|
||||
// Initialize vector store (shares the sea-orm SQLite connection)
|
||||
let vector_store =
|
||||
boosapi_core::vector_store::VectorStore::new(db_handle.conn.clone());
|
||||
|
||||
// Migrate any hardcoded absolute paths in settings to dynamic variables
|
||||
rt.block_on(boosapi_core::path_vars::migrate_hardcoded_paths(&db_handle.conn));
|
||||
|
||||
let app_settings = rt
|
||||
.block_on(boosapi_core::repo::settings::get_settings(&db_handle.conn))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Apply custom documents root (if configured) before anything
|
||||
// that reads documents_root().
|
||||
boosapi_core::storage_paths::init_documents_root(
|
||||
app_settings.documents_root_override.as_ref().map(PathBuf::from),
|
||||
);
|
||||
|
||||
// Re-ensure documents dirs under the (possibly custom) root
|
||||
boosapi_core::storage_paths::ensure_documents_dirs()
|
||||
.expect("failed to create documents storage dirs (custom root)");
|
||||
|
||||
let tray_language = app_settings.language.clone();
|
||||
|
||||
app.manage(AppState {
|
||||
sea_db: db_handle.conn,
|
||||
master_key,
|
||||
gateway: Arc::new(Mutex::new(None)),
|
||||
close_to_tray: Arc::new(AtomicBool::new(app_settings.minimize_to_tray)),
|
||||
release_webview_on_tray: Arc::new(AtomicBool::new(app_settings.release_webview_on_tray)),
|
||||
main_window_released_to_tray: Arc::new(AtomicBool::new(false)),
|
||||
main_window_restoring: Arc::new(AtomicBool::new(false)),
|
||||
is_quitting: Arc::new(AtomicBool::new(false)),
|
||||
app_data_dir: app_dir.clone(),
|
||||
db_path: db_path,
|
||||
auto_backup_handle: Arc::new(Mutex::new(None)),
|
||||
webdav_sync_handle: Arc::new(Mutex::new(None)),
|
||||
s3_sync_handle: Arc::new(Mutex::new(None)),
|
||||
vector_store: Arc::new(vector_store),
|
||||
knowledge_index_scheduler: Arc::new(
|
||||
knowledge_index_scheduler::KnowledgeIndexScheduler::default(),
|
||||
),
|
||||
stream_cancel_flags: Arc::new(Mutex::new(std::collections::HashMap::new())),
|
||||
agent_cancel_tokens: Arc::new(Mutex::new(std::collections::HashMap::new())),
|
||||
agent_permission_senders: Arc::new(Mutex::new(std::collections::HashMap::new())),
|
||||
agent_ask_senders: Arc::new(Mutex::new(std::collections::HashMap::new())),
|
||||
agent_always_allowed: Arc::new(Mutex::new(std::collections::HashMap::new())),
|
||||
});
|
||||
|
||||
// Reset any agent sessions that were running when app crashed/closed
|
||||
{
|
||||
let sea_db = app.state::<AppState>().sea_db.clone();
|
||||
let _ = rt.block_on(boosapi_core::repo::agent_session::reset_running_sessions(&sea_db));
|
||||
match rt.block_on(boosapi_core::repo::message::mark_stale_partial_assistant_messages_failed(&sea_db)) {
|
||||
Ok(count) if count > 0 => {
|
||||
tracing::info!(
|
||||
count,
|
||||
"Marked stale partial assistant messages as failed"
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"Failed to recover stale partial assistant messages"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = window_lifecycle::ensure_main_window_for_setup(app.handle()) {
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
"Failed to ensure BoosApi main window during setup"
|
||||
);
|
||||
#[cfg(target_os = "linux")]
|
||||
diagnostics::show_linux_startup_error_dialog(&format!(
|
||||
"BoosApi 主窗口创建失败:{}",
|
||||
err
|
||||
));
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::Other, err).into());
|
||||
}
|
||||
|
||||
// Initialize auto-backup scheduler if enabled
|
||||
{
|
||||
let state = app.state::<AppState>();
|
||||
let db = state.sea_db.clone();
|
||||
let app_data = app_dir.clone();
|
||||
let handle = state.auto_backup_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Ok(settings) = boosapi_core::repo::settings::get_settings(&db).await {
|
||||
if settings.auto_backup_enabled && settings.auto_backup_interval_hours > 0 {
|
||||
let backup_dir_setting = boosapi_core::path_vars::decode_path_opt(&settings.backup_dir);
|
||||
let interval = settings.auto_backup_interval_hours;
|
||||
let max_count = settings.auto_backup_max_count;
|
||||
let interval_secs = interval as u64 * 3600;
|
||||
let db2 = db.clone();
|
||||
let app_dir2 = app_data.clone();
|
||||
|
||||
// Calculate initial delay: catch up if overdue
|
||||
let initial_delay_secs = match boosapi_core::repo::backup::list_backups(&db).await {
|
||||
Ok(backups) if !backups.is_empty() => {
|
||||
let last_ts = &backups[0].created_at;
|
||||
if let Ok(last_time) = chrono::NaiveDateTime::parse_from_str(last_ts, "%Y-%m-%d %H:%M:%S") {
|
||||
let elapsed = chrono::Utc::now()
|
||||
.naive_utc()
|
||||
.signed_duration_since(last_time)
|
||||
.num_seconds()
|
||||
.max(0) as u64;
|
||||
if elapsed >= interval_secs { 0 } else { interval_secs - elapsed }
|
||||
} else {
|
||||
interval_secs
|
||||
}
|
||||
}
|
||||
_ => interval_secs,
|
||||
};
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
let dur = std::time::Duration::from_secs(interval_secs);
|
||||
// Initial wait (may be shorter if overdue)
|
||||
tokio::time::sleep(std::time::Duration::from_secs(initial_delay_secs)).await;
|
||||
loop {
|
||||
let backup_dir = boosapi_core::repo::backup::resolve_backup_dir(
|
||||
backup_dir_setting.as_deref(),
|
||||
&app_dir2,
|
||||
);
|
||||
if let Err(e) = boosapi_core::repo::backup::create_backup(
|
||||
&db2, "sqlite", &backup_dir,
|
||||
).await {
|
||||
tracing::warn!("Auto-backup failed: {}", e);
|
||||
} else {
|
||||
tracing::info!("Auto-backup created");
|
||||
let _ = boosapi_core::repo::backup::cleanup_old_backups(
|
||||
&db2, max_count,
|
||||
).await;
|
||||
}
|
||||
tokio::time::sleep(dur).await;
|
||||
}
|
||||
});
|
||||
*handle.lock().await = Some(task);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize WebDAV sync scheduler if enabled
|
||||
{
|
||||
let state = app.state::<AppState>();
|
||||
let db = state.sea_db.clone();
|
||||
let master_key = state.master_key;
|
||||
let app_data_dir = app_dir.clone();
|
||||
let handle = state.webdav_sync_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Ok(settings) = boosapi_core::repo::settings::get_settings(&db).await {
|
||||
if settings.webdav_sync_enabled && settings.webdav_sync_interval_minutes > 0 {
|
||||
let db2 = db.clone();
|
||||
let dir2 = app_data_dir.clone();
|
||||
let interval = settings.webdav_sync_interval_minutes;
|
||||
let interval_secs = interval as u64 * 60;
|
||||
|
||||
// Calculate initial delay: catch up if overdue
|
||||
let initial_delay_secs = match boosapi_core::repo::settings::get_setting(&db, "webdav_last_sync_time").await {
|
||||
Ok(Some(ts)) => {
|
||||
if let Ok(last_time) = chrono::DateTime::parse_from_rfc3339(&ts) {
|
||||
let elapsed = chrono::Utc::now()
|
||||
.signed_duration_since(last_time)
|
||||
.num_seconds()
|
||||
.max(0) as u64;
|
||||
if elapsed >= interval_secs { 0 } else { interval_secs - elapsed }
|
||||
} else {
|
||||
interval_secs
|
||||
}
|
||||
}
|
||||
_ => interval_secs,
|
||||
};
|
||||
|
||||
let task = commands::webdav::spawn_webdav_sync_task(
|
||||
db2, master_key, dir2, interval, initial_delay_secs,
|
||||
);
|
||||
*handle.lock().await = Some(task);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize S3 sync scheduler if enabled
|
||||
{
|
||||
let state = app.state::<AppState>();
|
||||
let db = state.sea_db.clone();
|
||||
let master_key = state.master_key;
|
||||
let app_data_dir = app_dir.clone();
|
||||
let handle = state.s3_sync_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Ok(settings) = boosapi_core::repo::settings::get_settings(&db).await {
|
||||
if settings.s3_sync_enabled && settings.s3_sync_interval_minutes > 0 {
|
||||
let db2 = db.clone();
|
||||
let dir2 = app_data_dir.clone();
|
||||
let interval = settings.s3_sync_interval_minutes;
|
||||
let interval_secs = interval as u64 * 60;
|
||||
|
||||
let initial_delay_secs = match boosapi_core::repo::settings::get_setting(&db, "s3_last_sync_time").await {
|
||||
Ok(Some(ts)) => {
|
||||
if let Ok(last_time) = chrono::DateTime::parse_from_rfc3339(&ts) {
|
||||
let elapsed = chrono::Utc::now()
|
||||
.signed_duration_since(last_time)
|
||||
.num_seconds()
|
||||
.max(0) as u64;
|
||||
if elapsed >= interval_secs { 0 } else { interval_secs - elapsed }
|
||||
} else {
|
||||
interval_secs
|
||||
}
|
||||
}
|
||||
_ => interval_secs,
|
||||
};
|
||||
|
||||
let task = commands::s3::spawn_s3_sync_task(
|
||||
db2, master_key, dir2, interval, initial_delay_secs,
|
||||
);
|
||||
*handle.lock().await = Some(task);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize system tray
|
||||
let handle = app.handle();
|
||||
if let Err(e) = tray::create_tray(handle, &tray_language) {
|
||||
tracing::warn!("Failed to create system tray: {}", e);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
tracing::info!("Attached Tauri setup handler");
|
||||
|
||||
tracing::info!("Attaching Tauri window event handler");
|
||||
startup_phase.set("attaching window event handler");
|
||||
let builder = builder.on_window_event(|window, event| {
|
||||
if window.label() == "main" {
|
||||
match event {
|
||||
tauri::WindowEvent::Resized(_) | tauri::WindowEvent::Moved(_) => {
|
||||
let app = window.app_handle();
|
||||
let state = app.state::<AppState>();
|
||||
let maximized = window.is_maximized().unwrap_or(false);
|
||||
let fullscreen = window.is_fullscreen().unwrap_or(false);
|
||||
let scale_factor = window.scale_factor().unwrap_or(1.0);
|
||||
|
||||
// Load previous state to preserve non-maximized geometry
|
||||
let prev = window_state::load_window_state(&state.app_data_dir);
|
||||
|
||||
if maximized || fullscreen {
|
||||
// Only flip flags; keep the last normal geometry
|
||||
if let Some(mut prev) = prev {
|
||||
prev.maximized = maximized;
|
||||
prev.fullscreen = fullscreen;
|
||||
let _ = window_state::save_window_state(&state.app_data_dir, prev);
|
||||
}
|
||||
} else if let (Ok(size), Ok(pos)) =
|
||||
(window.inner_size(), window.outer_position())
|
||||
{
|
||||
let logical_w = size.width as f64 / scale_factor;
|
||||
let logical_h = size.height as f64 / scale_factor;
|
||||
let logical_x = pos.x as f64 / scale_factor;
|
||||
let logical_y = pos.y as f64 / scale_factor;
|
||||
let _ = window_state::save_window_state(
|
||||
&state.app_data_dir,
|
||||
window_state::PersistedWindowState {
|
||||
width: logical_w,
|
||||
height: logical_h,
|
||||
maximized: false,
|
||||
fullscreen: false,
|
||||
x: Some(logical_x),
|
||||
y: Some(logical_y),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||
let app = window.app_handle();
|
||||
let state = app.state::<AppState>();
|
||||
if state.close_to_tray.load(Ordering::Relaxed) {
|
||||
let _ = window_lifecycle::release_main_window_to_tray(window);
|
||||
api.prevent_close();
|
||||
} else {
|
||||
// Ask frontend for confirmation before quitting
|
||||
api.prevent_close();
|
||||
let _ = app.emit("app-close-requested", ());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
tracing::info!("Attached Tauri window event handler");
|
||||
|
||||
tracing::info!("Building Tauri application");
|
||||
startup_phase.set("inside builder.build(context)");
|
||||
let build_result = builder.build(context);
|
||||
startup_phase.set("builder.build(context) returned");
|
||||
watchdog.stop();
|
||||
|
||||
let app = match build_result {
|
||||
Ok(app) => {
|
||||
tracing::info!("Tauri application build returned successfully");
|
||||
app
|
||||
}
|
||||
Err(e) => {
|
||||
let error_msg = e.to_string();
|
||||
let error_chain = startup_diagnostics::format_error_chain(&e);
|
||||
let backtrace = std::backtrace::Backtrace::force_capture();
|
||||
tracing::error!(
|
||||
error = %error_msg,
|
||||
error_chain = %error_chain,
|
||||
backtrace = %backtrace,
|
||||
"Failed to build Tauri application"
|
||||
);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let lower = error_msg.to_lowercase();
|
||||
if lower.contains("webview2") || lower.contains("webview") || lower.contains("edge")
|
||||
{
|
||||
let user_ok = windows_utils::show_warning_ok_cancel(
|
||||
"BoosAPI Desktop",
|
||||
"未检测到 Microsoft Edge WebView2 Runtime,BoosApi 无法启动。\n\n\
|
||||
点击「确定」打开下载页面进行安装,安装完成后重新启动 BoosApi。",
|
||||
);
|
||||
if user_ok {
|
||||
let _ = std::process::Command::new("cmd")
|
||||
.args(["/c", "start", "https://developer.microsoft.com/en-us/microsoft-edge/webview2/?form=MA13LH#download"])
|
||||
.spawn();
|
||||
}
|
||||
} else {
|
||||
windows_utils::show_error_dialog(
|
||||
"BoosAPI Desktop",
|
||||
&format!("应用启动失败:{}", error_msg),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
diagnostics::show_linux_startup_error_dialog(&format!(
|
||||
"BoosApi 启动失败:{}\n\n请使用 BOOSAPI_LOG_FILE 和 RUST_LOG=debug 启动后回传日志。",
|
||||
error_chain
|
||||
));
|
||||
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!("Starting Tauri application event loop");
|
||||
app.run(|app, event| {
|
||||
if let tauri::RunEvent::ExitRequested { api, .. } = &event {
|
||||
let state = app.state::<AppState>();
|
||||
if state.main_window_released_to_tray.load(Ordering::Relaxed)
|
||||
&& !state.is_quitting.load(Ordering::Relaxed)
|
||||
{
|
||||
api.prevent_exit();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if let tauri::RunEvent::Reopen {
|
||||
has_visible_windows,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
if !has_visible_windows {
|
||||
window_lifecycle::restore_main_window(app);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::env;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
const OPT_OUT_ENV: &str = "BOOSAPI_DISABLE_LINUX_WEBKIT_WORKAROUNDS";
|
||||
#[cfg(target_os = "linux")]
|
||||
const DMABUF_ENV: &str = "WEBKIT_DISABLE_DMABUF_RENDERER";
|
||||
#[cfg(target_os = "linux")]
|
||||
const COMPOSITING_ENV: &str = "WEBKIT_DISABLE_COMPOSITING_MODE";
|
||||
#[cfg(target_os = "linux")]
|
||||
const XDG_SESSION_TYPE_ENV: &str = "XDG_SESSION_TYPE";
|
||||
#[cfg(target_os = "linux")]
|
||||
const AUTO_WINDOW_ENV: &str = "BOOSAPI_LINUX_AUTO_WINDOW";
|
||||
#[cfg(target_os = "linux")]
|
||||
const MAIN_WINDOW_LABEL: &str = "main";
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum WorkaroundDecision {
|
||||
DisableDmabufRenderer,
|
||||
Skip(SkipReason),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum SkipReason {
|
||||
OptedOut,
|
||||
UserConfiguredDmabuf,
|
||||
UserConfiguredCompositing,
|
||||
NotWayland,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn apply_startup_workarounds() {
|
||||
let opt_out = env::var(OPT_OUT_ENV).ok();
|
||||
let user_configured_dmabuf = env::var_os(DMABUF_ENV).is_some();
|
||||
let user_configured_compositing = env::var_os(COMPOSITING_ENV).is_some();
|
||||
let session_type = env::var(XDG_SESSION_TYPE_ENV).ok();
|
||||
tracing::info!(
|
||||
opt_out = opt_out.as_deref().unwrap_or("<unset>"),
|
||||
user_configured_dmabuf,
|
||||
user_configured_compositing,
|
||||
session_type = session_type.as_deref().unwrap_or("<unset>"),
|
||||
"Evaluating Linux WebKitGTK startup workaround"
|
||||
);
|
||||
|
||||
let decision = decide_workaround(
|
||||
opt_out.as_deref(),
|
||||
user_configured_dmabuf,
|
||||
user_configured_compositing,
|
||||
session_type.as_deref(),
|
||||
);
|
||||
|
||||
match decision {
|
||||
WorkaroundDecision::DisableDmabufRenderer => {
|
||||
// This runs at process startup, before Tauri/WebKit initializes or
|
||||
// application threads are spawned.
|
||||
unsafe {
|
||||
env::set_var(DMABUF_ENV, "1");
|
||||
}
|
||||
tracing::info!("Enabled Linux WebKitGTK Wayland workaround: {DMABUF_ENV}=1");
|
||||
}
|
||||
WorkaroundDecision::Skip(reason) => {
|
||||
tracing::debug!("Skipped Linux WebKitGTK startup workaround: {reason:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn configure_startup_window_creation<R: tauri::Runtime>(context: &mut tauri::Context<R>) {
|
||||
let auto_window = should_use_tauri_auto_window_from_env();
|
||||
let window_count = context.config().app.windows.len();
|
||||
|
||||
if auto_window {
|
||||
tracing::info!(
|
||||
auto_window_env = %env_value(AUTO_WINDOW_ENV),
|
||||
window_count,
|
||||
"Using Tauri automatic window creation for Linux diagnostics"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut disabled_labels = Vec::new();
|
||||
for window in &mut context.config_mut().app.windows {
|
||||
if window.label == MAIN_WINDOW_LABEL {
|
||||
window.create = false;
|
||||
disabled_labels.push(window.label.clone());
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
auto_window_env = %env_value(AUTO_WINDOW_ENV),
|
||||
window_count,
|
||||
disabled_labels = ?disabled_labels,
|
||||
"Disabled Tauri automatic main window creation for Linux diagnostics"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn should_create_main_window_in_setup() -> bool {
|
||||
!should_use_tauri_auto_window_from_env()
|
||||
}
|
||||
|
||||
fn decide_workaround(
|
||||
opt_out: Option<&str>,
|
||||
user_configured_dmabuf: bool,
|
||||
user_configured_compositing: bool,
|
||||
session_type: Option<&str>,
|
||||
) -> WorkaroundDecision {
|
||||
if opt_out.map(is_truthy).unwrap_or(false) {
|
||||
return WorkaroundDecision::Skip(SkipReason::OptedOut);
|
||||
}
|
||||
|
||||
if user_configured_dmabuf {
|
||||
return WorkaroundDecision::Skip(SkipReason::UserConfiguredDmabuf);
|
||||
}
|
||||
|
||||
if user_configured_compositing {
|
||||
return WorkaroundDecision::Skip(SkipReason::UserConfiguredCompositing);
|
||||
}
|
||||
|
||||
if !session_type
|
||||
.map(|value| value.eq_ignore_ascii_case("wayland"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return WorkaroundDecision::Skip(SkipReason::NotWayland);
|
||||
}
|
||||
|
||||
WorkaroundDecision::DisableDmabufRenderer
|
||||
}
|
||||
|
||||
fn is_truthy(value: &str) -> bool {
|
||||
matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
}
|
||||
|
||||
fn should_use_tauri_auto_window(value: Option<&str>) -> bool {
|
||||
value.map(is_truthy).unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn should_use_tauri_auto_window_from_env() -> bool {
|
||||
should_use_tauri_auto_window(env::var(AUTO_WINDOW_ENV).ok().as_deref())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn env_value(key: &str) -> String {
|
||||
env::var(key).unwrap_or_else(|_| "<unset>".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{decide_workaround, should_use_tauri_auto_window, SkipReason, WorkaroundDecision};
|
||||
|
||||
#[test]
|
||||
fn enables_dmabuf_workaround_for_wayland_by_default() {
|
||||
assert_eq!(
|
||||
decide_workaround(None, false, false, Some("wayland")),
|
||||
WorkaroundDecision::DisableDmabufRenderer
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_existing_user_webkit_configuration() {
|
||||
assert_eq!(
|
||||
decide_workaround(None, true, false, Some("wayland")),
|
||||
WorkaroundDecision::Skip(SkipReason::UserConfiguredDmabuf)
|
||||
);
|
||||
assert_eq!(
|
||||
decide_workaround(None, false, true, Some("wayland")),
|
||||
WorkaroundDecision::Skip(SkipReason::UserConfiguredCompositing)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_explicit_opt_out() {
|
||||
assert_eq!(
|
||||
decide_workaround(Some("1"), false, false, Some("wayland")),
|
||||
WorkaroundDecision::Skip(SkipReason::OptedOut)
|
||||
);
|
||||
assert_eq!(
|
||||
decide_workaround(Some("true"), false, false, Some("wayland")),
|
||||
WorkaroundDecision::Skip(SkipReason::OptedOut)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_non_wayland_sessions() {
|
||||
assert_eq!(
|
||||
decide_workaround(None, false, false, Some("x11")),
|
||||
WorkaroundDecision::Skip(SkipReason::NotWayland)
|
||||
);
|
||||
assert_eq!(
|
||||
decide_workaround(None, false, false, None),
|
||||
WorkaroundDecision::Skip(SkipReason::NotWayland)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tauri_auto_window_is_opt_in() {
|
||||
assert!(!should_use_tauri_auto_window(None));
|
||||
assert!(!should_use_tauri_auto_window(Some("")));
|
||||
assert!(!should_use_tauri_auto_window(Some("0")));
|
||||
assert!(should_use_tauri_auto_window(Some("1")));
|
||||
assert!(should_use_tauri_auto_window(Some("true")));
|
||||
assert!(should_use_tauri_auto_window(Some("yes")));
|
||||
assert!(should_use_tauri_auto_window(Some("on")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
boosapi_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Returns the canonical BoosApi home directory and ensures it exists.
|
||||
///
|
||||
/// - macOS / Linux: `~/.boosapi/`
|
||||
/// - Windows: `%USERPROFILE%\.boosapi\`
|
||||
///
|
||||
/// Panics if the home directory cannot be determined.
|
||||
pub fn boosapi_home() -> PathBuf {
|
||||
#[cfg(not(windows))]
|
||||
let home = std::env::var("HOME").expect("HOME env var not set");
|
||||
#[cfg(windows)]
|
||||
let home = std::env::var("USERPROFILE").expect("USERPROFILE env var not set");
|
||||
|
||||
PathBuf::from(home).join(".boosapi")
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
use std::env;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const ENABLE_DEVTOOLS_ENV: &str = "BOOSAPI_ENABLE_DEVTOOLS";
|
||||
pub const LINUX_ANY_THREAD_ENV: &str = "BOOSAPI_LINUX_ANY_THREAD";
|
||||
pub const LINUX_MINIMAL_PLUGINS_ENV: &str = "BOOSAPI_LINUX_MINIMAL_PLUGINS";
|
||||
const TEST_BUILD_ENV: &str = "BOOSAPI_TEST_BUILD";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct StartupPhase {
|
||||
inner: Arc<Mutex<String>>,
|
||||
}
|
||||
|
||||
pub struct StartupWatchdog {
|
||||
stop: Arc<AtomicBool>,
|
||||
handle: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl StartupPhase {
|
||||
pub fn new(initial: impl Into<String>) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(initial.into())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set(&self, value: impl Into<String>) {
|
||||
match self.inner.lock() {
|
||||
Ok(mut phase) => *phase = value.into(),
|
||||
Err(_) => tracing::warn!("BoosApi startup phase lock poisoned"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(any(target_os = "linux", test)), allow(dead_code))]
|
||||
pub fn get(&self) -> String {
|
||||
self.inner
|
||||
.lock()
|
||||
.map(|phase| phase.clone())
|
||||
.unwrap_or_else(|_| "<phase lock poisoned>".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl StartupWatchdog {
|
||||
fn noop() -> Self {
|
||||
Self {
|
||||
stop: Arc::new(AtomicBool::new(true)),
|
||||
handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop(mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
if let Some(handle) = self.handle.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StartupWatchdog {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn devtools_context_menu_enabled() -> bool {
|
||||
devtools_context_menu_enabled_from_values(
|
||||
env::var(ENABLE_DEVTOOLS_ENV).ok().as_deref(),
|
||||
option_env!("BOOSAPI_TEST_BUILD"),
|
||||
cfg!(debug_assertions),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn linux_any_thread_enabled() -> bool {
|
||||
env_truthy(LINUX_ANY_THREAD_ENV)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn linux_minimal_plugins_enabled() -> bool {
|
||||
env_truthy(LINUX_MINIMAL_PLUGINS_ENV)
|
||||
}
|
||||
|
||||
pub fn log_startup_env_switches() {
|
||||
tracing::info!(
|
||||
boosapi_enable_devtools = %env_value(ENABLE_DEVTOOLS_ENV),
|
||||
boosapi_linux_any_thread = %env_value(LINUX_ANY_THREAD_ENV),
|
||||
boosapi_linux_minimal_plugins = %env_value(LINUX_MINIMAL_PLUGINS_ENV),
|
||||
boosapi_test_build_runtime = %env_value(TEST_BUILD_ENV),
|
||||
boosapi_test_build = option_env!("BOOSAPI_TEST_BUILD").unwrap_or("<unset>"),
|
||||
"BoosApi startup diagnostic switches"
|
||||
);
|
||||
}
|
||||
|
||||
pub fn register_plugin<R, P>(
|
||||
builder: tauri::Builder<R>,
|
||||
plugin_name: &'static str,
|
||||
plugin: P,
|
||||
) -> tauri::Builder<R>
|
||||
where
|
||||
R: tauri::Runtime,
|
||||
P: tauri::plugin::Plugin<R> + 'static,
|
||||
{
|
||||
tracing::info!(plugin = plugin_name, "Registering Tauri plugin");
|
||||
let builder = builder.plugin(plugin);
|
||||
tracing::info!(plugin = plugin_name, "Registered Tauri plugin");
|
||||
builder
|
||||
}
|
||||
|
||||
pub fn diagnostic_marker_plugin<R: tauri::Runtime>(
|
||||
marker_name: &'static str,
|
||||
) -> tauri::plugin::TauriPlugin<R> {
|
||||
tauri::plugin::Builder::new(marker_name)
|
||||
.setup(move |_app, _api| {
|
||||
tracing::info!(
|
||||
plugin_marker = marker_name,
|
||||
"BoosApi diagnostic plugin setup reached"
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn start_linux_startup_watchdog(phase: StartupPhase) -> StartupWatchdog {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let thread_stop = Arc::clone(&stop);
|
||||
let handle = thread::spawn(move || {
|
||||
let started = Instant::now();
|
||||
let mut next_heartbeat = Duration::from_secs(2);
|
||||
tracing::info!("BoosApi Linux startup watchdog started");
|
||||
while !thread_stop.load(Ordering::Relaxed) {
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
if thread_stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
if started.elapsed() < next_heartbeat {
|
||||
continue;
|
||||
}
|
||||
tracing::warn!(
|
||||
elapsed_secs = started.elapsed().as_secs(),
|
||||
startup_phase = %phase.get(),
|
||||
xdg_session_type = %env_value("XDG_SESSION_TYPE"),
|
||||
wayland_display = %env_value("WAYLAND_DISPLAY"),
|
||||
display = %env_value("DISPLAY"),
|
||||
gdk_backend = %env_value("GDK_BACKEND"),
|
||||
webkit_disable_dmabuf_renderer = %env_value("WEBKIT_DISABLE_DMABUF_RENDERER"),
|
||||
webkit_disable_compositing_mode = %env_value("WEBKIT_DISABLE_COMPOSITING_MODE"),
|
||||
boosapi_linux_auto_window = %env_value("BOOSAPI_LINUX_AUTO_WINDOW"),
|
||||
boosapi_linux_any_thread = %env_value(LINUX_ANY_THREAD_ENV),
|
||||
boosapi_linux_minimal_plugins = %env_value(LINUX_MINIMAL_PLUGINS_ENV),
|
||||
"BoosApi Linux startup watchdog heartbeat"
|
||||
);
|
||||
next_heartbeat += Duration::from_secs(2);
|
||||
}
|
||||
tracing::info!(
|
||||
elapsed_secs = started.elapsed().as_secs(),
|
||||
"BoosApi Linux startup watchdog stopped"
|
||||
);
|
||||
});
|
||||
|
||||
StartupWatchdog {
|
||||
stop,
|
||||
handle: Some(handle),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn start_linux_startup_watchdog(_phase: StartupPhase) -> StartupWatchdog {
|
||||
StartupWatchdog::noop()
|
||||
}
|
||||
|
||||
pub fn format_error_chain(error: &(dyn std::error::Error + 'static)) -> String {
|
||||
let mut chain = vec![error.to_string()];
|
||||
let mut source = error.source();
|
||||
while let Some(err) = source {
|
||||
chain.push(err.to_string());
|
||||
source = err.source();
|
||||
}
|
||||
chain.join(" | caused by: ")
|
||||
}
|
||||
|
||||
fn devtools_context_menu_enabled_from_values(
|
||||
runtime_env: Option<&str>,
|
||||
test_build_env: Option<&str>,
|
||||
debug_assertions: bool,
|
||||
) -> bool {
|
||||
debug_assertions
|
||||
|| runtime_env.map(is_truthy).unwrap_or(false)
|
||||
|| test_build_env.map(is_truthy).unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn env_truthy(key: &str) -> bool {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.as_deref()
|
||||
.map(is_truthy)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn env_value(key: &str) -> String {
|
||||
env::var(key).unwrap_or_else(|_| "<unset>".to_string())
|
||||
}
|
||||
|
||||
fn is_truthy(value: &str) -> bool {
|
||||
matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
devtools_context_menu_enabled_from_values, format_error_chain, is_truthy, StartupPhase,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn parses_truthy_env_values() {
|
||||
assert!(is_truthy("1"));
|
||||
assert!(is_truthy("true"));
|
||||
assert!(is_truthy(" YES "));
|
||||
assert!(is_truthy("on"));
|
||||
assert!(!is_truthy("0"));
|
||||
assert!(!is_truthy(""));
|
||||
assert!(!is_truthy("false"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn devtools_context_menu_is_enabled_by_debug_runtime_or_test_build() {
|
||||
assert!(devtools_context_menu_enabled_from_values(None, None, true));
|
||||
assert!(devtools_context_menu_enabled_from_values(
|
||||
Some("1"),
|
||||
None,
|
||||
false
|
||||
));
|
||||
assert!(devtools_context_menu_enabled_from_values(
|
||||
None,
|
||||
Some("true"),
|
||||
false
|
||||
));
|
||||
assert!(!devtools_context_menu_enabled_from_values(
|
||||
None, None, false
|
||||
));
|
||||
assert!(!devtools_context_menu_enabled_from_values(
|
||||
Some("0"),
|
||||
Some("false"),
|
||||
false
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_phase_can_be_updated() {
|
||||
let phase = StartupPhase::new("initial");
|
||||
assert_eq!(phase.get(), "initial");
|
||||
|
||||
phase.set("builder.build");
|
||||
assert_eq!(phase.get(), "builder.build");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_error_chains() {
|
||||
let error = std::io::Error::new(std::io::ErrorKind::Other, "outer");
|
||||
|
||||
assert_eq!(format_error_chain(&error), "outer");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use tauri::{
|
||||
image::Image,
|
||||
menu::{Menu, MenuItem},
|
||||
tray::TrayIconBuilder,
|
||||
AppHandle, Manager,
|
||||
};
|
||||
|
||||
const TRAY_ID: &str = "boosapi-tray";
|
||||
|
||||
fn tray_labels(language: &str) -> (&'static str, &'static str) {
|
||||
let lang = language.to_ascii_lowercase();
|
||||
if lang == "en" || lang.starts_with("en-") {
|
||||
("Show", "Quit")
|
||||
} else if lang == "zh-tw" {
|
||||
("顯示主視窗", "退出 BoosApi")
|
||||
} else if lang == "ja" {
|
||||
("メインウィンドウを表示", "BoosApi を終了")
|
||||
} else if lang == "ko" {
|
||||
("메인 창 표시", "BoosApi 종료")
|
||||
} else if lang == "fr" {
|
||||
("Afficher", "Quitter BoosApi")
|
||||
} else if lang == "de" {
|
||||
("Anzeigen", "BoosApi beenden")
|
||||
} else if lang == "es" {
|
||||
("Mostrar", "Salir de BoosApi")
|
||||
} else if lang == "ru" {
|
||||
("Показать", "Выйти из BoosApi")
|
||||
} else if lang == "hi" {
|
||||
("दिखाएं", "BoosApi छोड़ें")
|
||||
} else if lang == "ar" {
|
||||
("عرض", "إنهاء BoosApi")
|
||||
} else {
|
||||
("显示主窗口", "退出 BoosApi")
|
||||
}
|
||||
}
|
||||
|
||||
fn build_menu(
|
||||
app: &AppHandle,
|
||||
language: &str,
|
||||
) -> Result<Menu<tauri::Wry>, Box<dyn std::error::Error>> {
|
||||
let (show_label, quit_label) = tray_labels(language);
|
||||
let show = MenuItem::with_id(app, "show", show_label, true, None::<&str>)?;
|
||||
let quit = MenuItem::with_id(app, "quit", quit_label, true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&show, &quit])?;
|
||||
Ok(menu)
|
||||
}
|
||||
|
||||
pub fn create_tray(app: &AppHandle, language: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let menu = build_menu(app, language)?;
|
||||
let icon = Image::from_path("icons/icon.png").unwrap_or_else(|_| {
|
||||
Image::from_bytes(include_bytes!("../icons/32x32.png"))
|
||||
.expect("failed to load fallback tray icon")
|
||||
});
|
||||
|
||||
TrayIconBuilder::with_id(TRAY_ID)
|
||||
.icon(icon)
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.tooltip("BoosAPI Desktop")
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"show" => {
|
||||
crate::window_lifecycle::restore_main_window(app);
|
||||
}
|
||||
"quit" => {
|
||||
let state = app.state::<crate::AppState>();
|
||||
state
|
||||
.is_quitting
|
||||
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let tauri::tray::TrayIconEvent::Click {
|
||||
button: tauri::tray::MouseButton::Left,
|
||||
button_state: tauri::tray::MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
let app = tray.app_handle();
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
if w.is_visible().unwrap_or(false) {
|
||||
let _ = crate::window_lifecycle::release_webview_window_to_tray(&w);
|
||||
} else {
|
||||
crate::window_lifecycle::restore_main_window(app);
|
||||
}
|
||||
} else {
|
||||
crate::window_lifecycle::restore_main_window(app);
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn sync_tray_language(
|
||||
app: &AppHandle,
|
||||
language: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let menu = build_menu(app, language)?;
|
||||
if let Some(tray) = app.tray_by_id(TRAY_ID) {
|
||||
tray.set_menu(Some(menu))?;
|
||||
} else {
|
||||
create_tray(app, language)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
use crate::{window_state, AppState};
|
||||
use std::sync::atomic::Ordering;
|
||||
use tauri::{LogicalPosition, LogicalSize, Manager, Position, Size, WebviewWindow};
|
||||
|
||||
const MAIN_WINDOW_LABEL: &str = "main";
|
||||
|
||||
pub fn configure_main_window(app: &tauri::AppHandle, main_window: &WebviewWindow) {
|
||||
// On Windows, hide native decorations so the custom TitleBar is
|
||||
// the only title bar. macOS keeps its Overlay style (traffic lights).
|
||||
// After removing decorations, re-enable minimize/maximize capabilities
|
||||
// since set_decorations(false) strips the WS_MINIMIZEBOX/WS_MAXIMIZEBOX styles.
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = main_window.set_decorations(false);
|
||||
let _ = main_window.set_minimizable(true);
|
||||
let _ = main_window.set_maximizable(true);
|
||||
}
|
||||
|
||||
let state = app.state::<AppState>();
|
||||
if let Some(saved_state) = window_state::load_window_state(&state.app_data_dir) {
|
||||
let restored_state = if let Ok(Some(monitor)) = main_window.current_monitor() {
|
||||
let monitor_size = monitor
|
||||
.size()
|
||||
.to_logical::<f64>(main_window.scale_factor().unwrap_or(1.0));
|
||||
window_state::clamp_window_state_to_monitor(
|
||||
saved_state,
|
||||
monitor_size.width,
|
||||
monitor_size.height,
|
||||
)
|
||||
} else {
|
||||
saved_state
|
||||
};
|
||||
|
||||
let _ = main_window.set_size(Size::Logical(LogicalSize::new(
|
||||
restored_state.width,
|
||||
restored_state.height,
|
||||
)));
|
||||
|
||||
if let (Some(x), Some(y)) = (restored_state.x, restored_state.y) {
|
||||
let _ = main_window.set_position(Position::Logical(LogicalPosition::new(x, y)));
|
||||
} else {
|
||||
let _ = main_window.center();
|
||||
}
|
||||
|
||||
if restored_state.fullscreen {
|
||||
let _ = main_window.set_fullscreen(true);
|
||||
} else if restored_state.maximized {
|
||||
let _ = main_window.maximize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_main_window_for_setup(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
if let Some(main_window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
tracing::info!("BoosApi main window found during setup");
|
||||
configure_main_window(app, &main_window);
|
||||
tracing::info!("BoosApi main window configured");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::warn!("BoosApi main window was not found during setup");
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if crate::linux_webkit::should_create_main_window_in_setup() {
|
||||
tracing::info!("Creating BoosApi main window manually during Linux setup");
|
||||
let main_window = create_main_window_from_config(app)?;
|
||||
tracing::info!("BoosApi main window manually created during Linux setup");
|
||||
configure_main_window(app, &main_window);
|
||||
tracing::info!("BoosApi main window configured");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn release_main_window_to_tray(window: &tauri::Window) -> Result<(), String> {
|
||||
let app = window.app_handle();
|
||||
if should_release_webview(&app) {
|
||||
mark_main_window_released(&app, true);
|
||||
if let Err(err) = window.destroy() {
|
||||
mark_main_window_released(&app, false);
|
||||
return Err(err.to_string());
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
window.hide().map_err(|err| err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn release_webview_window_to_tray(window: &WebviewWindow) -> Result<(), String> {
|
||||
let app = window.app_handle();
|
||||
if should_release_webview(&app) {
|
||||
mark_main_window_released(&app, true);
|
||||
if let Err(err) = window.destroy() {
|
||||
mark_main_window_released(&app, false);
|
||||
return Err(err.to_string());
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
window.hide().map_err(|err| err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn minimize_main_window(window: tauri::Window) -> Result<(), String> {
|
||||
let app = window.app_handle();
|
||||
if should_release_webview(&app) {
|
||||
release_main_window_to_tray(&window)
|
||||
} else {
|
||||
window.minimize().map_err(|err| err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn restore_main_window(app: &tauri::AppHandle) {
|
||||
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
let state = app.state::<AppState>();
|
||||
state
|
||||
.main_window_released_to_tray
|
||||
.store(false, Ordering::Relaxed);
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
return;
|
||||
}
|
||||
|
||||
let state = app.state::<AppState>();
|
||||
if state.main_window_restoring.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let app = app.clone();
|
||||
std::thread::spawn(move || {
|
||||
let result = restore_main_window_inner(&app);
|
||||
let state = app.state::<AppState>();
|
||||
if result.is_ok() {
|
||||
state
|
||||
.main_window_released_to_tray
|
||||
.store(false, Ordering::Relaxed);
|
||||
} else if let Err(err) = result {
|
||||
tracing::warn!("Failed to restore main window: {}", err);
|
||||
}
|
||||
state.main_window_restoring.store(false, Ordering::Relaxed);
|
||||
});
|
||||
}
|
||||
|
||||
fn restore_main_window_inner(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
let window = create_main_window_from_config(app)?;
|
||||
configure_main_window(app, &window);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_main_window_from_config(app: &tauri::AppHandle) -> Result<WebviewWindow, String> {
|
||||
let config = app
|
||||
.config()
|
||||
.app
|
||||
.windows
|
||||
.iter()
|
||||
.find(|config| config.label == MAIN_WINDOW_LABEL)
|
||||
.ok_or_else(|| "main window config not found".to_string())?;
|
||||
|
||||
tracing::info!(
|
||||
label = %config.label,
|
||||
create = config.create,
|
||||
visible = config.visible,
|
||||
"Preparing BoosApi main window builder from config"
|
||||
);
|
||||
let builder =
|
||||
tauri::WebviewWindowBuilder::from_config(app, config).map_err(|err| err.to_string())?;
|
||||
tracing::info!("BoosApi main window builder created from config");
|
||||
let window = builder.build().map_err(|err| err.to_string())?;
|
||||
tracing::info!("BoosApi main window WebView build completed");
|
||||
Ok(window)
|
||||
}
|
||||
|
||||
fn should_release_webview(app: &tauri::AppHandle) -> bool {
|
||||
let state = app.state::<AppState>();
|
||||
should_release_webview_for_settings(
|
||||
state.close_to_tray.load(Ordering::Relaxed),
|
||||
state.release_webview_on_tray.load(Ordering::Relaxed),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn should_release_webview_for_settings(
|
||||
close_to_tray: bool,
|
||||
release_webview_on_tray: bool,
|
||||
) -> bool {
|
||||
close_to_tray && release_webview_on_tray
|
||||
}
|
||||
|
||||
fn mark_main_window_released(app: &tauri::AppHandle, released: bool) {
|
||||
let state = app.state::<AppState>();
|
||||
state
|
||||
.main_window_released_to_tray
|
||||
.store(released, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::should_release_webview_for_settings;
|
||||
|
||||
#[test]
|
||||
fn releases_webview_only_when_close_to_tray_and_release_setting_are_enabled() {
|
||||
assert!(should_release_webview_for_settings(true, true));
|
||||
assert!(!should_release_webview_for_settings(true, false));
|
||||
assert!(!should_release_webview_for_settings(false, true));
|
||||
assert!(!should_release_webview_for_settings(false, false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PersistedWindowState {
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
#[serde(default)]
|
||||
pub maximized: bool,
|
||||
#[serde(default)]
|
||||
pub fullscreen: bool,
|
||||
#[serde(default)]
|
||||
pub x: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub y: Option<f64>,
|
||||
}
|
||||
|
||||
pub fn window_state_path(boosapi_home: &Path) -> PathBuf {
|
||||
boosapi_home.join("window-state.json")
|
||||
}
|
||||
|
||||
pub fn load_window_state(boosapi_home: &Path) -> Option<PersistedWindowState> {
|
||||
let path = window_state_path(boosapi_home);
|
||||
let json = std::fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str(&json).ok()
|
||||
}
|
||||
|
||||
pub fn save_window_state(boosapi_home: &Path, state: PersistedWindowState) -> io::Result<()> {
|
||||
std::fs::create_dir_all(boosapi_home)?;
|
||||
let json = serde_json::to_vec_pretty(&state)
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::Other, error))?;
|
||||
std::fs::write(window_state_path(boosapi_home), json)
|
||||
}
|
||||
|
||||
pub fn clamp_window_state_to_monitor(
|
||||
state: PersistedWindowState,
|
||||
monitor_width: f64,
|
||||
monitor_height: f64,
|
||||
) -> PersistedWindowState {
|
||||
let max_width = monitor_width * 0.9;
|
||||
let max_height = monitor_height * 0.9;
|
||||
PersistedWindowState {
|
||||
width: state.width.clamp(640.0, max_width),
|
||||
height: state.height.clamp(480.0, max_height),
|
||||
maximized: state.maximized,
|
||||
fullscreen: state.fullscreen,
|
||||
x: state.x.map(|v| v.clamp(0.0, monitor_width - 100.0)),
|
||||
y: state.y.map(|v| v.clamp(0.0, monitor_height - 100.0)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
clamp_window_state_to_monitor, load_window_state, save_window_state, PersistedWindowState,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn round_trips_window_state_in_boosapi_home() {
|
||||
let test_dir = std::env::temp_dir().join(format!(
|
||||
"boosapi-window-state-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("system time before unix epoch")
|
||||
.as_nanos()
|
||||
));
|
||||
|
||||
std::fs::create_dir_all(&test_dir).expect("failed to create temp dir");
|
||||
let state = PersistedWindowState {
|
||||
width: 1440.0,
|
||||
height: 960.0,
|
||||
maximized: true,
|
||||
fullscreen: false,
|
||||
x: Some(100.0),
|
||||
y: Some(50.0),
|
||||
};
|
||||
|
||||
save_window_state(&test_dir, state).expect("failed to save window state");
|
||||
|
||||
let restored = load_window_state(&test_dir).expect("failed to load saved window state");
|
||||
assert_eq!(restored, state);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&test_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_legacy_state_without_new_fields() {
|
||||
let test_dir = std::env::temp_dir().join(format!(
|
||||
"boosapi-window-state-legacy-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("system time before unix epoch")
|
||||
.as_nanos()
|
||||
));
|
||||
|
||||
std::fs::create_dir_all(&test_dir).expect("failed to create temp dir");
|
||||
let legacy_json = r#"{"width":1440.0,"height":960.0}"#;
|
||||
std::fs::write(test_dir.join("window-state.json"), legacy_json)
|
||||
.expect("failed to write legacy json");
|
||||
|
||||
let restored = load_window_state(&test_dir).expect("failed to load legacy state");
|
||||
assert_eq!(restored.width, 1440.0);
|
||||
assert_eq!(restored.height, 960.0);
|
||||
assert!(!restored.maximized);
|
||||
assert!(!restored.fullscreen);
|
||||
assert!(restored.x.is_none());
|
||||
assert!(restored.y.is_none());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&test_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamps_oversized_window_state_to_visible_monitor_bounds() {
|
||||
let clamped = clamp_window_state_to_monitor(
|
||||
PersistedWindowState {
|
||||
width: 2200.0,
|
||||
height: 1600.0,
|
||||
maximized: false,
|
||||
fullscreen: false,
|
||||
x: Some(2000.0),
|
||||
y: Some(1500.0),
|
||||
},
|
||||
1512.0,
|
||||
982.0,
|
||||
);
|
||||
|
||||
assert!((clamped.width - 1360.8).abs() < f64::EPSILON);
|
||||
assert!((clamped.height - 883.8).abs() < 1e-9);
|
||||
// x clamped to monitor_width - 100
|
||||
assert!((clamped.x.unwrap() - 1412.0).abs() < f64::EPSILON);
|
||||
// y clamped to monitor_height - 100
|
||||
assert!((clamped.y.unwrap() - 882.0).abs() < f64::EPSILON);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Windows-specific utilities: native error dialogs for fatal startup failures.
|
||||
|
||||
use windows_sys::Win32::Foundation::HWND;
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||
MessageBoxW, IDOK, MB_ICONERROR, MB_ICONWARNING, MB_OK, MB_OKCANCEL,
|
||||
};
|
||||
|
||||
/// Encode a Rust string as a null-terminated UTF-16 vector for Win32 APIs.
|
||||
fn to_wide(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
/// Show a native Windows MessageBox with an error icon.
|
||||
pub fn show_error_dialog(title: &str, message: &str) {
|
||||
let wide_title = to_wide(title);
|
||||
let wide_msg = to_wide(message);
|
||||
unsafe {
|
||||
MessageBoxW(
|
||||
0 as HWND,
|
||||
wide_msg.as_ptr(),
|
||||
wide_title.as_ptr(),
|
||||
MB_OK | MB_ICONERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a native Windows MessageBox with a warning icon and OK/Cancel buttons.
|
||||
/// Returns `true` if the user clicked OK.
|
||||
pub fn show_warning_ok_cancel(title: &str, message: &str) -> bool {
|
||||
let wide_title = to_wide(title);
|
||||
let wide_msg = to_wide(message);
|
||||
let result = unsafe {
|
||||
MessageBoxW(
|
||||
0 as HWND,
|
||||
wide_msg.as_ptr(),
|
||||
wide_title.as_ptr(),
|
||||
MB_OKCANCEL | MB_ICONWARNING,
|
||||
)
|
||||
};
|
||||
result == IDOK
|
||||
}
|
||||
Reference in New Issue
Block a user