diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a370575..5b1d18c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -10,5 +10,8 @@ rust-version = "1.77" tauri-build = { version = "2", features = [] } [dependencies] +serde = { version = "1", features = ["derive"] } serde_json = "1" tauri = { version = "2", features = [] } +tauri-plugin-dialog = "2" +tauri-plugin-opener = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 0392774..d3e2a08 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,5 +2,11 @@ "identifier": "default", "description": "Default capability for the Heimgeist Tauri migration spike.", "windows": ["main"], - "permissions": ["core:default"] + "permissions": [ + "core:default", + "dialog:allow-open", + "opener:allow-default-urls", + "opener:allow-open-path", + "opener:allow-open-url" + ] } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index c6aa44e..efb4ebf 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,28 +1,273 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use std::{collections::HashMap, sync::Mutex}; +use std::{ + fs, + path::PathBuf, + sync::Mutex, +}; -use serde_json::{json, Value}; -use tauri::State; +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; -type SettingsStore = Mutex>; +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; -fn default_settings() -> HashMap { - HashMap::from([ - ("backendApiUrl".into(), json!("http://127.0.0.1:8000")), - ("ollamaApiUrl".into(), json!("http://127.0.0.1:11434")), - ("chatModel".into(), json!("llama3")), - ("visionModel".into(), json!("")), - ("embedModel".into(), json!("nomic-embed-text:latest")), - ("rerankModel".into(), json!("nomic-embed-text:latest")), - ("transcriptionModel".into(), json!("base")), - ("colorScheme".into(), json!("Default")), - ("uiScale".into(), json!(1)), - ("openDevToolsOnStartup".into(), json!(false)), - ("audioInputEnabled".into(), json!(true)), - ("audioInputDeviceId".into(), json!("")), - ("audioInputLanguage".into(), json!("")), - ]) +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 settings_file_path(app: &AppHandle) -> Result { + if let Ok(path) = std::env::var("HEIMGEIST_SETTINGS_FILE") { + let trimmed = path.trim(); + if !trimmed.is_empty() { + return Ok(PathBuf::from(trimmed)); + } + } + + app.path() + .app_config_dir() + .map(|dir| dir.join(SETTINGS_FILE_NAME)) + .map_err(|error| format!("Could not resolve Tauri settings path: {error}")) +} + +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 should_write = raw_settings.is_none(); + let (settings, migrated) = migrate_settings(raw_settings); + + if should_write || migrated { + write_settings_file(&path, &settings)?; + } + + Ok(SettingsStore { + path, + settings: Mutex::new(settings), + }) } fn unavailable_update_status(message: &str) -> Value { @@ -37,77 +282,173 @@ fn unavailable_update_status(message: &str) -> Value { }) } +fn normalize_changelog_page(page: Option) -> u64 { + page.filter(|value| *value > 0).unwrap_or(1) +} + #[tauri::command] -fn get_settings(settings: State<'_, SettingsStore>) -> Result { - let settings = settings +fn get_settings(store: State<'_, SettingsStore>) -> Result { + let settings = store + .settings .lock() .map_err(|_| "Settings store is unavailable.".to_string())?; - Ok(json!(settings.clone())) + Ok(Value::Object(settings.clone())) } #[tauri::command] fn set_setting( key: String, value: Value, - settings: State<'_, SettingsStore>, + store: State<'_, SettingsStore>, ) -> Result { - let mut settings = settings + 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: HashMap, + settings: SettingsMap, store: State<'_, SettingsStore>, ) -> Result { - let mut store = store + let mut current_settings = store + .settings .lock() .map_err(|_| "Settings store is unavailable.".to_string())?; - store.extend(settings); + 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 spike.") + 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 spike.") + unavailable_update_status("Tauri update checks are not implemented in this migration phase.") } #[tauri::command] fn get_changelog_page(page: Option) -> Value { json!({ - "page": page.unwrap_or(1), - "pageSize": 50, + "page": normalize_changelog_page(page), + "pageSize": CHANGELOG_PAGE_SIZE, "hasMore": false, "entries": [] }) } #[tauri::command] -fn pick_paths(_options: Option) -> Vec { - Vec::new() +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(_file_path: String) -> bool { - false +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(_url: String) -> bool { - false +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() - .manage(Mutex::new(default_settings())) + .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, diff --git a/src/desktop/desktopApi.js b/src/desktop/desktopApi.js index 9623057..774378b 100644 --- a/src/desktop/desktopApi.js +++ b/src/desktop/desktopApi.js @@ -27,6 +27,8 @@ const DEFAULT_UPDATE_STATUS = Object.freeze({ const hasWindow = () => typeof window !== 'undefined' const getElectronApi = () => (hasWindow() ? window.electronAPI : undefined) const getTauriCore = () => (hasWindow() ? window.__TAURI__?.core : undefined) +const getTauriEvent = () => (hasWindow() ? window.__TAURI__?.event : undefined) +const tauriWindowFocusUnlisteners = new WeakMap() function callElectronApi(methodName, ...args) { const method = getElectronApi()?.[methodName] @@ -77,6 +79,32 @@ function getExternalUrl(eventOrUrl) { return eventOrUrl?.currentTarget?.href || eventOrUrl?.target?.href || '' } +function listenForTauriWindowFocus(callback) { + const listen = getTauriEvent()?.listen + if (typeof listen !== 'function') { + return undefined + } + + const unlistenPromise = listen('window-focused', () => callback()) + tauriWindowFocusUnlisteners.set(callback, unlistenPromise) + return unlistenPromise +} + +function unlistenForTauriWindowFocus(callback) { + const unlistenPromise = tauriWindowFocusUnlisteners.get(callback) + if (!unlistenPromise) { + return undefined + } + + tauriWindowFocusUnlisteners.delete(callback) + Promise.resolve(unlistenPromise).then((unlisten) => { + if (typeof unlisten === 'function') { + unlisten() + } + }) + return unlistenPromise +} + const desktopApi = { getSettings: () => resultOrFallback( callElectronApi('getSettings') ?? callTauriCommand('get_settings'), @@ -120,8 +148,8 @@ const desktopApi = { const url = getExternalUrl(eventOrUrl) return resultOrFallback(callTauriCommand('open_external_link', { url }), false) }, - onWindowFocus: (callback) => callElectronApi('onWindowFocus', callback), - offWindowFocus: (callback) => callElectronApi('offWindowFocus', callback), + onWindowFocus: (callback) => callElectronApi('onWindowFocus', callback) ?? listenForTauriWindowFocus(callback), + offWindowFocus: (callback) => callElectronApi('offWindowFocus', callback) ?? unlistenForTauriWindowFocus(callback), } export default desktopApi