#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use std::{ fs, path::{Path, PathBuf}, sync::Mutex, }; use serde::Deserialize; use serde_json::{json, Map, Value}; use tauri::{AppHandle, Emitter, Manager, State, WindowEvent}; use tauri_plugin_dialog::DialogExt; use tauri_plugin_opener::OpenerExt; const DEFAULT_BACKEND_API_URL: &str = "http://127.0.0.1:8000"; const DEFAULT_OLLAMA_API_URL: &str = "http://127.0.0.1:11434"; const DEFAULT_EMBED_MODEL: &str = "nomic-embed-text:latest"; const DEFAULT_TRANSCRIPTION_MODEL: &str = "base"; const BGE_EMBED_MODEL: &str = "bge-m3:latest"; const DEFAULT_UI_SCALE: f64 = 1.0; const MIN_UI_SCALE: f64 = 0.7; const MAX_UI_SCALE: f64 = 1.3; const SETTINGS_FILE_NAME: &str = "settings.json"; const CHANGELOG_PAGE_SIZE: u64 = 50; type SettingsMap = Map; struct SettingsStore { path: PathBuf, settings: Mutex, } #[derive(Debug, Deserialize)] struct PickPathsOptions { title: Option, filters: Option>, multiple: Option, } #[derive(Debug, Deserialize)] struct DialogFilter { name: Option, extensions: Option>, } fn default_settings() -> SettingsMap { let mut settings = SettingsMap::new(); settings.insert("backendApiUrl".into(), json!(DEFAULT_BACKEND_API_URL)); settings.insert("ollamaApiUrl".into(), json!(DEFAULT_OLLAMA_API_URL)); settings.insert("chatModel".into(), json!("llama3")); settings.insert("visionModel".into(), json!("")); settings.insert("embedModel".into(), json!(DEFAULT_EMBED_MODEL)); settings.insert("rerankModel".into(), json!(DEFAULT_EMBED_MODEL)); settings.insert( "transcriptionModel".into(), json!(DEFAULT_TRANSCRIPTION_MODEL), ); settings.insert("colorScheme".into(), json!("Default")); settings.insert("uiScale".into(), json!(DEFAULT_UI_SCALE)); settings.insert("openDevToolsOnStartup".into(), json!(false)); settings.insert("audioInputEnabled".into(), json!(true)); settings.insert("audioInputDeviceId".into(), json!("")); settings.insert("audioInputLanguage".into(), json!("")); settings } fn value_to_trimmed_string(value: Option<&Value>) -> String { match value { Some(Value::String(text)) => text.trim().to_string(), Some(Value::Number(number)) => number.to_string(), Some(Value::Bool(value)) => value.to_string(), _ => String::new(), } } fn value_to_number(value: Option<&Value>) -> Option { match value { Some(Value::Number(number)) => number.as_f64(), Some(Value::String(text)) => text.trim().parse::().ok(), _ => None, } } fn normalize_ui_scale(value: Option<&Value>) -> f64 { let scale = value_to_number(value).unwrap_or(DEFAULT_UI_SCALE); let clamped = scale.clamp(MIN_UI_SCALE, MAX_UI_SCALE); (clamped * 100.0).round() / 100.0 } fn normalize_boolean_setting(value: Option<&Value>) -> bool { match value { Some(Value::String(text)) => match text.trim().to_lowercase().as_str() { "true" | "1" | "yes" | "on" => true, "false" | "0" | "no" | "off" | "" => false, _ => false, }, Some(Value::Bool(value)) => *value, _ => false, } } fn normalize_model_name(value: Option<&Value>, fallback: &str) -> String { let trimmed = value_to_trimmed_string(value); if trimmed.is_empty() { fallback.to_string() } else { trimmed } } fn normalize_embed_model(value: Option<&Value>) -> String { let trimmed = value_to_trimmed_string(value); if trimmed.is_empty() { return DEFAULT_EMBED_MODEL.to_string(); } match trimmed.to_lowercase().as_str() { "bge" | "bge-m3" | BGE_EMBED_MODEL => BGE_EMBED_MODEL.to_string(), "nomic" | "nomic-embed-text" | DEFAULT_EMBED_MODEL => DEFAULT_EMBED_MODEL.to_string(), _ => trimmed, } } fn looks_like_ollama_url(value: &str) -> bool { tauri::Url::parse(value) .map(|parsed| { parsed.port() == Some(11434) || parsed .path() .trim_end_matches('/') .eq_ignore_ascii_case("/api") }) .unwrap_or(false) } fn migrate_settings(source: Option) -> (SettingsMap, bool) { let source = source.unwrap_or_default(); let mut next = default_settings(); let mut migrated = false; for (key, value) in source.iter() { next.insert(key.clone(), value.clone()); } if !source.contains_key("backendApiUrl") { if let Some(Value::String(ollama_api_url)) = source.get("ollamaApiUrl") { if looks_like_ollama_url(ollama_api_url) { next.insert("backendApiUrl".into(), json!(DEFAULT_BACKEND_API_URL)); next.insert("ollamaApiUrl".into(), json!(ollama_api_url.trim())); } else { next.insert("backendApiUrl".into(), json!(ollama_api_url.trim())); next.insert("ollamaApiUrl".into(), json!(DEFAULT_OLLAMA_API_URL)); } migrated = true; } } if !source.contains_key("openDevToolsOnStartup") { migrated = true; } if !source.contains_key("rerankModel") { let embed_model = next .get("embedModel") .cloned() .unwrap_or_else(|| json!(DEFAULT_EMBED_MODEL)); next.insert("rerankModel".into(), embed_model); migrated = true; } if !source.contains_key("visionModel") { let chat_model = value_to_trimmed_string(next.get("chatModel")); next.insert("visionModel".into(), json!(chat_model)); migrated = true; } if !source.contains_key("transcriptionModel") { next.insert( "transcriptionModel".into(), json!(DEFAULT_TRANSCRIPTION_MODEL), ); migrated = true; } normalize_settings(&mut next); (next, migrated) } fn normalize_settings(settings: &mut SettingsMap) { let backend_api_url = value_to_trimmed_string(settings.get("backendApiUrl")); let ollama_api_url = value_to_trimmed_string(settings.get("ollamaApiUrl")); let chat_model = normalize_model_name(settings.get("chatModel"), ""); let vision_model = normalize_model_name(settings.get("visionModel"), ""); let embed_model = normalize_embed_model(settings.get("embedModel")); let rerank_model = normalize_embed_model(settings.get("rerankModel")); let transcription_model = normalize_model_name( settings.get("transcriptionModel"), DEFAULT_TRANSCRIPTION_MODEL, ); let ui_scale = normalize_ui_scale(settings.get("uiScale")); let open_devtools_on_startup = normalize_boolean_setting(settings.get("openDevToolsOnStartup")); let audio_input_enabled = normalize_boolean_setting(settings.get("audioInputEnabled")); let audio_input_device_id = value_to_trimmed_string(settings.get("audioInputDeviceId")); let audio_input_language = value_to_trimmed_string(settings.get("audioInputLanguage")).to_lowercase(); settings.insert("backendApiUrl".into(), json!(backend_api_url)); settings.insert("ollamaApiUrl".into(), json!(ollama_api_url)); settings.insert("chatModel".into(), json!(chat_model)); settings.insert("visionModel".into(), json!(vision_model)); settings.insert("embedModel".into(), json!(embed_model)); settings.insert("rerankModel".into(), json!(rerank_model)); settings.insert("transcriptionModel".into(), json!(transcription_model)); settings.insert("uiScale".into(), json!(ui_scale)); settings.insert( "openDevToolsOnStartup".into(), json!(open_devtools_on_startup), ); settings.insert("audioInputEnabled".into(), json!(audio_input_enabled)); settings.insert("audioInputDeviceId".into(), json!(audio_input_device_id)); settings.insert("audioInputLanguage".into(), json!(audio_input_language)); } fn explicit_settings_file_path() -> Option { if let Ok(path) = std::env::var("HEIMGEIST_SETTINGS_FILE") { let trimmed = path.trim(); if !trimmed.is_empty() { return Some(PathBuf::from(trimmed)); } } None } fn tauri_settings_file_path(app: &AppHandle) -> Result { app.path() .app_config_dir() .map(|dir| dir.join(SETTINGS_FILE_NAME)) .map_err(|error| format!("Could not resolve Tauri settings path: {error}")) } fn settings_file_path(app: &AppHandle) -> Result { explicit_settings_file_path() .map(Ok) .unwrap_or_else(|| tauri_settings_file_path(app)) } fn paths_equal(first: &Path, second: &Path) -> bool { match (first.canonicalize(), second.canonicalize()) { (Ok(first), Ok(second)) => first == second, _ => first == second, } } fn push_unique_candidate(candidates: &mut Vec, candidate: PathBuf, tauri_path: &Path) { if paths_equal(&candidate, tauri_path) || candidates .iter() .any(|existing| paths_equal(existing, &candidate)) { return; } candidates.push(candidate); } fn electron_settings_candidates(app: &AppHandle, tauri_path: &Path) -> Vec { let mut candidates = Vec::new(); if let Ok(config_dir) = app.path().config_dir() { let product_name = app.config().product_name.as_deref().unwrap_or("Heimgeist"); for app_dir in [product_name, "Heimgeist", "heimgeist"] { push_unique_candidate( &mut candidates, config_dir.join(app_dir).join(SETTINGS_FILE_NAME), tauri_path, ); } } candidates } fn import_electron_settings_if_available( app: &AppHandle, tauri_path: &Path, ) -> Option { for candidate in electron_settings_candidates(app, tauri_path) { match read_settings_file(&candidate) { Ok(Some(settings)) => { println!( "Imported Electron settings for first Tauri run from {}", candidate.display() ); return Some(settings); } Ok(None) => {} Err(error) => { eprintln!( "Skipping Electron settings import from {}: {error}", candidate.display() ); } } } None } fn read_settings_file(path: &PathBuf) -> Result, String> { if !path.exists() { return Ok(None); } let data = fs::read_to_string(path) .map_err(|error| format!("Could not read settings from {}: {error}", path.display()))?; let value: Value = serde_json::from_str(&data) .map_err(|error| format!("Could not parse settings from {}: {error}", path.display()))?; match value { Value::Object(settings) => Ok(Some(settings)), _ => Ok(None), } } fn write_settings_file(path: &PathBuf, settings: &SettingsMap) -> Result<(), String> { if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|error| { format!( "Could not create settings directory {}: {error}", parent.display() ) })?; } let data = serde_json::to_string_pretty(settings) .map_err(|error| format!("Could not serialize settings: {error}"))?; fs::write(path, format!("{data}\n")) .map_err(|error| format!("Could not write settings to {}: {error}", path.display())) } fn load_settings_store(app: &AppHandle) -> Result { let path = settings_file_path(app)?; let raw_settings = read_settings_file(&path)?; let (settings, should_write) = match raw_settings { Some(raw_settings) => { let (settings, migrated) = migrate_settings(Some(raw_settings)); (settings, migrated) } None => { let imported_settings = if explicit_settings_file_path().is_none() { import_electron_settings_if_available(app, &path) } else { None }; let (settings, _migrated) = migrate_settings(imported_settings); (settings, true) } }; if should_write { write_settings_file(&path, &settings)?; } Ok(SettingsStore { path, settings: Mutex::new(settings), }) } fn unavailable_update_status(message: &str) -> Value { json!({ "state": "unavailable", "message": message, "checkedAt": null, "localCommit": null, "remoteCommit": null, "branch": null, "restartScheduled": false }) } fn normalize_changelog_page(page: Option) -> u64 { page.filter(|value| *value > 0).unwrap_or(1) } #[tauri::command] fn get_settings(store: State<'_, SettingsStore>) -> Result { let settings = store .settings .lock() .map_err(|_| "Settings store is unavailable.".to_string())?; Ok(Value::Object(settings.clone())) } #[tauri::command] fn set_setting(key: String, value: Value, store: State<'_, SettingsStore>) -> Result { let mut settings = store .settings .lock() .map_err(|_| "Settings store is unavailable.".to_string())?; settings.insert(key, value); normalize_settings(&mut settings); write_settings_file(&store.path, &settings)?; Ok(true) } #[tauri::command] fn update_settings(settings: SettingsMap, store: State<'_, SettingsStore>) -> Result { let mut current_settings = store .settings .lock() .map_err(|_| "Settings store is unavailable.".to_string())?; current_settings.extend(settings); normalize_settings(&mut current_settings); write_settings_file(&store.path, ¤t_settings)?; Ok(true) } #[tauri::command] fn get_update_status() -> Value { unavailable_update_status("Tauri update checks are not implemented in this migration phase.") } #[tauri::command] fn check_for_updates() -> Value { unavailable_update_status("Tauri update checks are not implemented in this migration phase.") } #[tauri::command] fn get_changelog_page(page: Option) -> Value { json!({ "page": normalize_changelog_page(page), "pageSize": CHANGELOG_PAGE_SIZE, "hasMore": false, "entries": [] }) } #[tauri::command] async fn pick_paths( app: AppHandle, options: Option, ) -> Result, String> { let options = options.unwrap_or(PickPathsOptions { title: None, filters: None, multiple: None, }); let mut dialog = app.dialog().file(); if let Some(title) = options .title .as_deref() .map(str::trim) .filter(|title| !title.is_empty()) { dialog = dialog.set_title(title); } for filter in options.filters.unwrap_or_default() { let name = filter .name .as_deref() .map(str::trim) .filter(|name| !name.is_empty()) .unwrap_or("Files") .to_string(); let extensions: Vec = filter .extensions .unwrap_or_default() .into_iter() .map(|extension| extension.trim().trim_start_matches('.').to_string()) .filter(|extension| !extension.is_empty()) .collect(); if !extensions.is_empty() { let extension_refs: Vec<&str> = extensions.iter().map(String::as_str).collect(); dialog = dialog.add_filter(name, &extension_refs); } } let paths = if options.multiple.unwrap_or(true) { dialog.blocking_pick_files().unwrap_or_default() } else { dialog.blocking_pick_file().into_iter().collect() }; paths .into_iter() .map(|path| { path.into_path() .map(|path| path.to_string_lossy().into_owned()) .map_err(|error| format!("Could not resolve selected file path: {error}")) }) .collect() } #[tauri::command] fn open_path(app: AppHandle, file_path: String) -> Result { let file_path = file_path.trim(); if file_path.is_empty() { return Ok(false); } match app.opener().open_path(file_path, None::<&str>) { Ok(_) => Ok(true), Err(error) => { eprintln!("Failed to open path {file_path}: {error}"); Ok(false) } } } fn is_allowed_external_url(url: &str) -> bool { tauri::Url::parse(url) .map(|parsed| matches!(parsed.scheme(), "http" | "https" | "mailto" | "tel")) .unwrap_or(false) } #[tauri::command] fn open_external_link(app: AppHandle, url: String) -> Result { let url = url.trim(); if url.is_empty() || !is_allowed_external_url(url) { return Ok(false); } match app.opener().open_url(url, None::<&str>) { Ok(_) => Ok(true), Err(error) => { eprintln!("Failed to open URL {url}: {error}"); Ok(false) } } } fn main() { tauri::Builder::default() .menu(|handle| tauri::menu::Menu::default(handle)) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) .setup(|app| { let store = load_settings_store(app.handle()).map_err(std::io::Error::other)?; app.manage(store); Ok(()) }) .on_window_event(|window, event| { if matches!(event, WindowEvent::Focused(true)) { let _ = window.emit("window-focused", ()); } }) .invoke_handler(tauri::generate_handler![ get_settings, set_setting, update_settings, get_update_status, check_for_updates, get_changelog_page, pick_paths, open_path, open_external_link ]) .run(tauri::generate_context!()) .expect("failed to run Heimgeist Tauri migration spike"); }