mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat: split settings into LLMs and API Keys tabs, fix keychain vault and Ollama UX
- Split settings window into 3 tabs: LLMs (Ollama/Groq/OpenRouter), API Keys (data feeds), and Debug & Logs - Add featureFilter option to RuntimeConfigPanel for rendering subsets - Consolidate keychain to single JSON vault entry (1 macOS prompt vs 20) - Add Ollama model discovery with /api/tags + /v1/models fallback - Strip <think> reasoning tokens from Ollama responses - Suppress thinking with think:false in Ollama request body - Parallel secret verification with 15s global timeout - Fix manual model input overlapping dropdown (CSS grid-area + hidden-input class) - Add loading spinners to settings tab panels - Suppress notification popups when settings window is open - Filter embed models from Ollama dropdown - Fix settings window black screen flash with inline dark background
This commit is contained in:
@@ -4,6 +4,7 @@ use std::fs::{self, File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::env;
|
||||
@@ -53,17 +54,77 @@ struct LocalApiState {
|
||||
token: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
/// In-memory cache for keychain secrets. Populated once at startup to avoid
|
||||
/// repeated macOS Keychain prompts (each `Entry::get_password()` triggers one).
|
||||
struct SecretsCache {
|
||||
secrets: Mutex<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl SecretsCache {
|
||||
fn load_from_keychain() -> Self {
|
||||
// Try consolidated vault first — single keychain prompt
|
||||
if let Ok(entry) = Entry::new(KEYRING_SERVICE, "secrets-vault") {
|
||||
if let Ok(json) = entry.get_password() {
|
||||
if let Ok(map) = serde_json::from_str::<HashMap<String, String>>(&json) {
|
||||
let secrets: HashMap<String, String> = map
|
||||
.into_iter()
|
||||
.filter(|(k, v)| {
|
||||
SUPPORTED_SECRET_KEYS.contains(&k.as_str()) && !v.trim().is_empty()
|
||||
})
|
||||
.map(|(k, v)| (k, v.trim().to_string()))
|
||||
.collect();
|
||||
return SecretsCache { secrets: Mutex::new(secrets) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migration: read individual keys (old format), consolidate into vault.
|
||||
// This triggers one keychain prompt per key — happens only once.
|
||||
let mut secrets = HashMap::new();
|
||||
for key in SUPPORTED_SECRET_KEYS.iter() {
|
||||
if let Ok(entry) = Entry::new(KEYRING_SERVICE, key) {
|
||||
if let Ok(value) = entry.get_password() {
|
||||
let trimmed = value.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
secrets.insert((*key).to_string(), trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write consolidated vault and clean up individual entries
|
||||
if !secrets.is_empty() {
|
||||
if let Ok(json) = serde_json::to_string(&secrets) {
|
||||
if let Ok(vault_entry) = Entry::new(KEYRING_SERVICE, "secrets-vault") {
|
||||
if vault_entry.set_password(&json).is_ok() {
|
||||
for key in SUPPORTED_SECRET_KEYS.iter() {
|
||||
if let Ok(entry) = Entry::new(KEYRING_SERVICE, key) {
|
||||
let _ = entry.delete_credential();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SecretsCache { secrets: Mutex::new(secrets) }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DesktopRuntimeInfo {
|
||||
os: String,
|
||||
arch: String,
|
||||
}
|
||||
|
||||
fn secret_entry(key: &str) -> Result<Entry, String> {
|
||||
if !SUPPORTED_SECRET_KEYS.contains(&key) {
|
||||
return Err(format!("Unsupported secret key: {key}"));
|
||||
}
|
||||
Entry::new(KEYRING_SERVICE, key).map_err(|e| format!("Keyring init failed: {e}"))
|
||||
fn save_vault(cache: &HashMap<String, String>) -> Result<(), String> {
|
||||
let json = serde_json::to_string(cache)
|
||||
.map_err(|e| format!("Failed to serialize vault: {e}"))?;
|
||||
let entry = Entry::new(KEYRING_SERVICE, "secrets-vault")
|
||||
.map_err(|e| format!("Keyring init failed: {e}"))?;
|
||||
entry.set_password(&json)
|
||||
.map_err(|e| format!("Failed to write vault: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_local_token() -> String {
|
||||
@@ -106,46 +167,49 @@ fn list_supported_secret_keys() -> Vec<String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_secret(key: String) -> Result<Option<String>, String> {
|
||||
let entry = secret_entry(&key)?;
|
||||
match entry.get_password() {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(err) => Err(format!("Failed to read keyring secret: {err}")),
|
||||
fn get_secret(key: String, cache: tauri::State<'_, SecretsCache>) -> Result<Option<String>, String> {
|
||||
if !SUPPORTED_SECRET_KEYS.contains(&key.as_str()) {
|
||||
return Err(format!("Unsupported secret key: {key}"));
|
||||
}
|
||||
let secrets = cache.secrets.lock().map_err(|_| "Lock poisoned".to_string())?;
|
||||
Ok(secrets.get(&key).cloned())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_all_secrets() -> std::collections::HashMap<String, String> {
|
||||
let mut result = std::collections::HashMap::new();
|
||||
for key in SUPPORTED_SECRET_KEYS.iter() {
|
||||
if let Ok(entry) = Entry::new(KEYRING_SERVICE, key) {
|
||||
if let Ok(value) = entry.get_password() {
|
||||
if !value.trim().is_empty() {
|
||||
result.insert((*key).to_string(), value.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
fn get_all_secrets(cache: tauri::State<'_, SecretsCache>) -> HashMap<String, String> {
|
||||
cache.secrets.lock().unwrap_or_else(|e| e.into_inner()).clone()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_secret(key: String, value: String) -> Result<(), String> {
|
||||
let entry = secret_entry(&key)?;
|
||||
entry
|
||||
.set_password(&value)
|
||||
.map_err(|e| format!("Failed to write keyring secret: {e}"))
|
||||
fn set_secret(key: String, value: String, cache: tauri::State<'_, SecretsCache>) -> Result<(), String> {
|
||||
if !SUPPORTED_SECRET_KEYS.contains(&key.as_str()) {
|
||||
return Err(format!("Unsupported secret key: {key}"));
|
||||
}
|
||||
let mut secrets = cache.secrets.lock().map_err(|_| "Lock poisoned".to_string())?;
|
||||
let trimmed = value.trim().to_string();
|
||||
// Build proposed state, persist first, then commit to cache
|
||||
let mut proposed = secrets.clone();
|
||||
if trimmed.is_empty() {
|
||||
proposed.remove(&key);
|
||||
} else {
|
||||
proposed.insert(key, trimmed);
|
||||
}
|
||||
save_vault(&proposed)?;
|
||||
*secrets = proposed;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_secret(key: String) -> Result<(), String> {
|
||||
let entry = secret_entry(&key)?;
|
||||
match entry.delete_credential() {
|
||||
Ok(_) => Ok(()),
|
||||
Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(err) => Err(format!("Failed to delete keyring secret: {err}")),
|
||||
fn delete_secret(key: String, cache: tauri::State<'_, SecretsCache>) -> Result<(), String> {
|
||||
if !SUPPORTED_SECRET_KEYS.contains(&key.as_str()) {
|
||||
return Err(format!("Unsupported secret key: {key}"));
|
||||
}
|
||||
let mut secrets = cache.secrets.lock().map_err(|_| "Lock poisoned".to_string())?;
|
||||
let mut proposed = secrets.clone();
|
||||
proposed.remove(&key);
|
||||
save_vault(&proposed)?;
|
||||
*secrets = proposed;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cache_file_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
@@ -360,7 +424,6 @@ fn open_settings_window(app: &AppHandle) -> Result<(), String> {
|
||||
.inner_size(980.0, 760.0)
|
||||
.min_inner_size(820.0, 620.0)
|
||||
.resizable(true)
|
||||
.visible(false)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create settings window: {e}"))?;
|
||||
|
||||
@@ -658,16 +721,13 @@ fn start_local_api(app: &AppHandle) -> Result<(), String> {
|
||||
cmd.current_dir(parent);
|
||||
}
|
||||
|
||||
// Pass keychain secrets to sidecar as env vars
|
||||
// Pass cached keychain secrets to sidecar as env vars (no keychain re-read)
|
||||
let mut secret_count = 0u32;
|
||||
for key in SUPPORTED_SECRET_KEYS.iter() {
|
||||
if let Ok(entry) = Entry::new(KEYRING_SERVICE, key) {
|
||||
if let Ok(value) = entry.get_password() {
|
||||
if !value.trim().is_empty() {
|
||||
cmd.env(key, value.trim());
|
||||
secret_count += 1;
|
||||
}
|
||||
}
|
||||
let secrets_cache = app.state::<SecretsCache>();
|
||||
if let Ok(secrets) = secrets_cache.secrets.lock() {
|
||||
for (key, value) in secrets.iter() {
|
||||
cmd.env(key, value);
|
||||
secret_count += 1;
|
||||
}
|
||||
}
|
||||
append_desktop_log(app, "INFO", &format!("injected {secret_count} keychain secrets into sidecar env"));
|
||||
@@ -696,6 +756,7 @@ fn main() {
|
||||
.menu(build_app_menu)
|
||||
.on_menu_event(handle_menu_event)
|
||||
.manage(LocalApiState::default())
|
||||
.manage(SecretsCache::load_from_keychain())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
list_supported_secret_keys,
|
||||
get_secret,
|
||||
|
||||
Reference in New Issue
Block a user