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:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
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())
}
+206
View File
@@ -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
+176
View File
@@ -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(())
}
+209
View File
@@ -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())
}
+649
View File
@@ -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(())
}
+149
View File
@@ -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())
}
+493
View File
@@ -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())
}
+115
View File
@@ -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())
}
+27
View File
@@ -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;
+466
View File
@@ -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())
}
+678
View File
@@ -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, &timestamp).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(),
}
}
}
+116
View File
@@ -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())
}
+578
View File
@@ -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(&current_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)
}
+194
View File
@@ -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(())
}
+548
View File
@@ -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", &timestamp).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);
}
}
+384
View File
@@ -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)));
}
}
+253
View File
@@ -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"));
}
}
+516
View File
@@ -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())
}
+988
View File
@@ -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);
}
}
});
}
+209
View File
@@ -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")));
}
}
+6
View File
@@ -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()
}
+16
View File
@@ -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");
}
}
+108
View File
@@ -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(())
}
+205
View File
@@ -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));
}
}
+134
View File
@@ -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);
}
}
+41
View File
@@ -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
}