// エディションとモデル名(ビルド時に feature で切り替え)
#[cfg(feature = "community")]
const EDITION: &str = "community";
#[cfg(feature = "community")]
const MODEL_NAME: &str = "LSA (Local Semantic Analysis)";
#[cfg(feature = "pro")]
const EDITION: &str = "pro";
#[cfg(feature = "pro")]
const MODEL_NAME: &str = "sentence-bert-base-ja (Pro)";
#[tauri::command]
#[allow(dead_code)]
fn get_model_name() -> String {
MODEL_NAME.to_string()
}
/// アプリデータディレクトリの settings.json を読み、再インストール後も設定を継承する
#[tauri::command]
fn get_app_settings(app: tauri::AppHandle) -> Result<serde_json::Value, String> {
let path = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("settings.json");
let default = serde_json::json!({
"min_score": 0.3,
"limit": 5,
"run_on_login": false,
"monitor_paths": [],
"watch_extensions": ["txt", "md", "json", "html", "css", "js", "mjs", "ts", "rs"]
});
if !path.exists() {
log::info!("get_app_settings: no file at {:?}, returning default", path);
return Ok(default);
}
let s = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
log::info!("get_app_settings: read from {:?}, content len={}", path, s.len());
let loaded: serde_json::Value = serde_json::from_str(&s).map_err(|e| e.to_string())?;
let def = default.as_object().unwrap();
let empty: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
let obj = loaded.as_object().unwrap_or(&empty);
let run_on_login = obj
.get("run_on_login")
.or(def.get("run_on_login"))
.and_then(|v| v.as_bool().or_else(|| v.as_i64().map(|n| n != 0)))
.unwrap_or(false);
let monitor_paths = obj.get("monitor_paths")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_else(|| vec![]);
let watch_extensions = obj.get("watch_extensions")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_else(|| def.get("watch_extensions").and_then(|v| v.as_array()).cloned().unwrap_or_else(|| vec![]));
let merged = serde_json::json!({
"min_score": obj.get("min_score").or(def.get("min_score")).unwrap_or(&serde_json::json!(0.3)),
"limit": obj.get("limit").or(def.get("limit")).unwrap_or(&serde_json::json!(5)),
"run_on_login": run_on_login,
"monitor_paths": monitor_paths,
"watch_extensions": watch_extensions
});
log::info!("get_app_settings: returning run_on_login={}", run_on_login);
Ok(merged)
}
/// アプリデータディレクトリの settings.json に保存
#[tauri::command]
fn set_app_settings(app: tauri::AppHandle, settings: serde_json::Value) -> Result<(), String> {
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join("settings.json");
let to_write = if settings.get("min_score").is_some() || settings.get("limit").is_some() || settings.get("run_on_login").is_some() || settings.get("monitor_paths").is_some() || settings.get("watch_extensions").is_some() {
settings.clone()
} else if let Some(inner) = settings.get("settings").and_then(|v| v.as_object()) {
serde_json::to_value(inner).unwrap_or(settings)
} else {
settings
};
let s = serde_json::to_string_pretty(&to_write).map_err(|e| e.to_string())?;
std::fs::write(&path, &s).map_err(|e| e.to_string())?;
log::info!("set_app_settings: wrote to {:?}, run_on_login={:?}", path, to_write.get("run_on_login"));
Ok(())
}
// Read last N lines from the log file for UI consumption
#[tauri::command]
fn read_logs(limit: Option<usize>) -> Result<String, String> {
let log_dir = if cfg!(debug_assertions) {
std::env::current_dir()
.map_err(|e| e.to_string())?
.join("logs")
} else {
dirs::data_dir()
.unwrap_or_else(|| std::env::current_dir().unwrap())
.join("com.telosdb.app")
.join("logs")
};
let log_file = log_dir.join("telos.log");
if !log_file.exists() {
return Ok(String::new());
}
let s = std::fs::read_to_string(&log_file).map_err(|e| e.to_string())?;
let lines: Vec<&str> = s.lines().collect();
let n = limit.unwrap_or(200);
let start = if lines.len() > n { lines.len() - n } else { 0 };
Ok(lines[start..].join("\n"))
}
pub mod db;
pub mod dev_static;
pub mod utils;
pub mod mcp;
use std::sync::atomic::{AtomicU64, Ordering};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::Manager;
use tauri::path::BaseDirectory;
use tauri::menu::{Menu, MenuItem};
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
#[allow(dead_code)]
struct AppState {
db_pool: sqlx::SqlitePool,
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
None,
))
.plugin({
let log_dir = if cfg!(debug_assertions) {
std::env::current_dir().unwrap().join("logs")
} else {
dirs::data_dir()
.unwrap_or_else(|| std::env::current_dir().unwrap())
.join("com.telosdb.app")
.join("logs")
};
std::fs::create_dir_all(&log_dir).ok();
// Prune old telos logs, keep latest 7 files
if let Ok(entries) = std::fs::read_dir(&log_dir) {
let mut logs: Vec<_> = entries
.filter_map(|e| e.ok())
.filter_map(|de| {
let p = de.path();
if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
if name.starts_with("telos") && name.ends_with(".log") {
return Some(p);
}
}
None
})
.collect();
logs.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
while logs.len() > 7 {
let old = logs.remove(0);
let _ = std::fs::remove_file(old);
}
}
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.filter(|metadata| {
if metadata.target().starts_with("reqwest")
|| metadata.target().starts_with("hyper")
|| metadata.target().starts_with("h2")
|| metadata.target().starts_with("tracing")
|| metadata.target().starts_with("html5ever")
|| metadata.target().starts_with("selectors")
{
metadata.level() <= log::Level::Warn
} else {
true
}
})
.targets([
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout),
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir {
file_name: Some("telos.log".to_string()),
}),
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview),
])
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll)
.max_file_size(10 * 1024 * 1024)
.build()
})
.invoke_handler(tauri::generate_handler![
get_model_name,
read_logs,
get_app_settings,
set_app_settings,
])
.setup(move |app| {
let boot_start = std::time::Instant::now();
// Resolve paths
let app_data_dir = app
.path()
.app_data_dir()
.expect("failed to get app data dir");
let db_path = app_data_dir.join("telos.db");
let resource_dir = app.path().resource_dir().unwrap_or_default();
let exe_dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|p| p.to_path_buf()));
log::info!("[BOOT] Edition: {}", EDITION);
log::info!("[BOOT] Phase 1: Paths resolved ({}ms)", boot_start.elapsed().as_millis());
log::info!(" resource_dir: {:?}, exe_dir: {:?}", resource_dir, exe_dir);
// vec0.dll: バンドル時は resources、開発時は exe 同階層。見つかったら app_data にコピーしてそこから読む
let mut vec0_path: PathBuf = app
.path()
.resolve("vec0.dll", BaseDirectory::Resource)
.unwrap_or_else(|_| app.path().resource_dir().unwrap_or_default().join("vec0.dll"));
if !vec0_path.exists() {
if let Some(ref exe_dir) = exe_dir {
for candidate in [
exe_dir.join("vec0.dll"),
exe_dir.join("resources").join("vec0.dll"),
resource_dir.join("vec0.dll"),
resource_dir.parent().unwrap_or(exe_dir).join("vec0.dll"),
] {
if candidate.exists() {
vec0_path = candidate;
break;
}
}
}
}
// 見つかった dll を app_data_dir にコピーしてから使う(インストール先のパス差を吸収)
let vec0_in_app_data = app_data_dir.join("vec0.dll");
if vec0_path.exists() {
let _ = std::fs::create_dir_all(&app_data_dir);
if vec0_in_app_data != vec0_path {
if let Err(e) = std::fs::copy(&vec0_path, &vec0_in_app_data) {
log::warn!("Failed to copy vec0.dll to app data: {}", e);
} else {
log::info!(" Copied vec0.dll to app_data");
}
}
if vec0_in_app_data.exists() {
vec0_path = vec0_in_app_data;
}
}
log::info!("[BOOT] Phase 2: vec0.dll ready at {:?} ({}ms)", vec0_path, boot_start.elapsed().as_millis());
// Tray Menu
let quit_i = MenuItem::with_id(app, "quit", "終了", true, None::<&str>)?;
let show_i = MenuItem::with_id(app, "show", "表示", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show_i, &quit_i])?;
let _tray = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(move |app, event| {
match event.id.as_ref() {
"quit" => {
app.exit(0);
}
"show" => {
if let Some(window) = app.get_webview_window("main") {
window.show().unwrap();
window.set_focus().unwrap();
}
}
_ => {}
}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: tauri::tray::MouseButton::Left,
..
} = event
{
// トレイクリックが二重発火することがあるためデバウンス(一瞬開いてすぐ閉じるのを防ぐ)
const DEBOUNCE_MS: u64 = 400;
static LAST_TRAY_CLICK_MS: AtomicU64 = AtomicU64::new(0);
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let prev = LAST_TRAY_CLICK_MS.swap(now_ms, Ordering::Relaxed);
if prev != 0 && now_ms.saturating_sub(prev) < DEBOUNCE_MS {
return;
}
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
window.hide().unwrap();
} else {
window.show().unwrap();
window.set_focus().unwrap();
}
}
}
})
.build(app)?;
log::info!("[BOOT] Phase 3: Tray icon ready ({}ms)", boot_start.elapsed().as_millis());
if !vec0_path.exists() {
let msg = format!(
"vec0.dll が見つかりません。\n試したパス:\n- {:?}\n- exe と同じフォルダ\n- exe の resources フォルダ\n\nTelosDB を再インストールしてください。",
vec0_path
);
log::error!("{}", msg);
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, msg).into());
}
log::info!("[BOOT] Phase 4: Initializing database at {:?} ...", db_path);
let pool = tauri::async_runtime::block_on(async {
#[cfg(feature = "community")]
let dimension = 50;
#[cfg(feature = "pro")]
let dimension = crate::utils::embedding_pro::ProEmbeddingModel::embed_dim();
match db::initialize_database(&db_path, &vec0_path, dimension).await {
Ok(pool) => {
log::info!("[BOOT] Phase 4: Database ready ({}ms)", boot_start.elapsed().as_millis());
pool
}
Err(e) => {
log::error!("Database initialization failed: {}", e);
panic!("DB Init Error: {}", e);
}
}
});
app.manage(AppState {
db_pool: pool.clone(),
});
if let Some(router) = dev_static::dev_static_router(exe_dir.as_deref()) {
let bind_addr = "127.0.0.1:8474";
if let Ok(listener) = tauri::async_runtime::block_on(tokio::net::TcpListener::bind(bind_addr)) {
log::info!("[BOOT] Dev static server listening on {}", listener.local_addr().unwrap());
tauri::async_runtime::spawn(async move {
let _ = axum::serve(listener, router).await;
});
} else {
log::warn!("[BOOT] Dev static server (8474) bind failed");
}
} else {
log::info!("[BOOT] Dev static: frontend dir not resolved (exe_dir={:?}), skipping 8474", exe_dir);
}
log::info!("[BOOT] Phase 5: Starting MCP server on 127.0.0.1:3001 ...");
#[cfg(feature = "pro")]
let embedding_model_dir = {
let env_path = std::env::var_os("TELOS_EMBEDDING_MODEL_DIR").map(std::path::PathBuf::from);
// インストール後は Tauri の resolve でバンドル先を正しく解決する(resource_dir だけだと NSIS 配置先とずれる場合がある)
let resolved_resource = app
.path()
.resolve("embedding_model/model_quantized.onnx", BaseDirectory::Resource)
.ok()
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
.filter(|p| p.join("model_quantized.onnx").exists());
let resource_path = resource_dir.join("embedding_model");
let dev_path = exe_dir.as_ref().and_then(|exe| {
exe.parent().and_then(|p| p.parent()).map(|root| root.join("embedding_model"))
});
let resource_path_log = resource_path.clone();
let dev_path_log = dev_path.clone();
let chosen = env_path
.filter(|p| p.join("model_quantized.onnx").exists())
.or(resolved_resource)
.or_else(|| if resource_path.join("model_quantized.onnx").exists() { Some(resource_path) } else { None })
.or_else(|| dev_path.filter(|p| p.join("model_quantized.onnx").exists()));
if let Some(ref p) = chosen {
log::info!("[BOOT] Pro: embedding_model dir = {:?}", p);
} else {
log::warn!(
"[BOOT] Pro: embedding_model not found (model_quantized.onnx required). Tried: TELOS_EMBEDDING_MODEL_DIR, resolve(Resource), {:?}, {:?}. Set TELOS_EMBEDDING_MODEL_DIR or place embedding_model in exe parent.",
resource_path_log,
dev_path_log
);
}
chosen
};
tauri::async_runtime::spawn({
let pool = pool.clone();
#[cfg(feature = "pro")]
let embedding_dir = embedding_model_dir;
async move {
#[cfg(feature = "community")]
mcp::run_server(3001, app_data_dir.clone(), pool, MODEL_NAME.to_string(), EDITION.to_string()).await;
#[cfg(feature = "pro")]
mcp::run_server(3001, app_data_dir.clone(), pool, MODEL_NAME.to_string(), EDITION.to_string(), embedding_dir).await;
}
});
if std::env::var_os("TELOS_HEADLESS").map(|v| v == "1").unwrap_or(false) {
if let Some(w) = app.get_webview_window("main") {
let _ = w.hide();
log::info!("[BOOT] Headless mode: main window hidden.");
}
}
log::info!("[BOOT] Setup complete ({}ms). Window ready; MCP and LSA/HNSW continue in background.", boot_start.elapsed().as_millis());
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
// Prevent window from closing, just hide it
api.prevent_close();
window.hide().unwrap();
}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}