Skip to main content

fotos_lib/commands/
settings.rs

1use reqwest::header;
2use serde::{Deserialize, Serialize};
3use tauri_plugin_store::StoreExt;
4
5const STORE_PATH: &str = "prefs.json";
6const SCHEMA_VERSION: u64 = 2;
7
8#[derive(Serialize, Deserialize)]
9#[serde(rename_all = "camelCase")]
10pub struct CaptureSettings {
11    pub default_mode: String,
12    pub include_mouse_cursor: bool,
13    pub delay_ms: u32,
14    pub save_directory: String,
15    pub default_format: String,
16    pub jpeg_quality: u8,
17    pub copy_to_clipboard_after_capture: bool,
18}
19
20impl Default for CaptureSettings {
21    fn default() -> Self {
22        Self {
23            default_mode: "region".to_string(),
24            save_directory: "~/Pictures/Fotos".to_string(),
25            default_format: "png".to_string(),
26            jpeg_quality: 90,
27            copy_to_clipboard_after_capture: true,
28            include_mouse_cursor: false,
29            delay_ms: 0,
30        }
31    }
32}
33
34#[derive(Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub struct AnnotationSettings {
37    pub default_stroke_color: String,
38    pub default_stroke_width: f64,
39    pub default_font_size: f64,
40    pub default_font_family: String,
41    pub step_number_color: String,
42    pub step_number_size: f64,
43    pub blur_radius: f64,
44}
45
46impl Default for AnnotationSettings {
47    fn default() -> Self {
48        Self {
49            default_stroke_color: "#FF0000".to_string(),
50            default_stroke_width: 2.0,
51            default_font_size: 16.0,
52            default_font_family: "sans-serif".to_string(),
53            step_number_color: "#FF0000".to_string(),
54            step_number_size: 24.0,
55            blur_radius: 10.0,
56        }
57    }
58}
59
60/// A user-defined OpenAI-compatible LLM endpoint.
61#[derive(Serialize, Deserialize, Clone)]
62#[serde(rename_all = "camelCase")]
63pub struct LlmEndpoint {
64    pub id: String,
65    pub name: String,
66    pub base_url: String,
67    pub model: String,
68}
69
70fn default_endpoints() -> Vec<LlmEndpoint> {
71    vec![
72        LlmEndpoint {
73            id: "openai".to_string(),
74            name: "OpenAI".to_string(),
75            base_url: "https://api.openai.com/v1".to_string(),
76            model: "gpt-4o".to_string(),
77        },
78        LlmEndpoint {
79            id: "ollama-local".to_string(),
80            name: "Ollama (local)".to_string(),
81            base_url: "http://localhost:11434/v1".to_string(),
82            model: "llava:7b".to_string(),
83        },
84    ]
85}
86
87#[derive(Serialize, Deserialize)]
88#[serde(rename_all = "camelCase")]
89pub struct AiSettings {
90    pub ocr_language: String,
91    pub default_llm_provider: String,
92    /// User-defined OpenAI-compatible endpoints (replaces fixed openai/ollama fields).
93    pub endpoints: Vec<LlmEndpoint>,
94    pub claude_model: String,
95    pub gemini_model: String,
96    pub image_max_dim: u32,
97    pub image_quality: u8,
98}
99
100impl Default for AiSettings {
101    fn default() -> Self {
102        Self {
103            ocr_language: "eng".to_string(),
104            default_llm_provider: "claude".to_string(),
105            endpoints: default_endpoints(),
106            claude_model: "claude-sonnet-4-20250514".to_string(),
107            gemini_model: "gemini-2.0-flash".to_string(),
108            image_max_dim: 2048,
109            image_quality: 85,
110        }
111    }
112}
113
114#[derive(Serialize, Deserialize)]
115#[serde(rename_all = "camelCase")]
116pub struct UiSettings {
117    pub theme: String,
118    pub show_ai_panel: bool,
119    pub show_status_bar: bool,
120    pub smooth_zoom: bool,
121}
122
123impl Default for UiSettings {
124    fn default() -> Self {
125        Self {
126            theme: "system".to_string(),
127            show_ai_panel: true,
128            show_status_bar: true,
129            smooth_zoom: true,
130        }
131    }
132}
133
134#[derive(Default, Serialize, Deserialize)]
135pub struct Settings {
136    pub capture: CaptureSettings,
137    pub annotation: AnnotationSettings,
138    pub ai: AiSettings,
139    pub ui: UiSettings,
140}
141
142fn load_section<T: serde::de::DeserializeOwned + Default>(
143    store: &tauri_plugin_store::Store<tauri::Wry>,
144    key: &str,
145) -> T {
146    store
147        .get(key)
148        .and_then(|v| serde_json::from_value(v).ok())
149        .unwrap_or_default()
150}
151
152/// Migrate settings from v1 to v2 schema.
153///
154/// v1 → v2: removes `ollamaUrl`, `ollamaModel`, `openaiModel` from `ai`;
155/// adds `endpoints: Vec<LlmEndpoint>`. Migrates `defaultLlmProvider` if it
156/// was `"openai"` or `"ollama"`. Best-effort migrates the OpenAI keychain entry.
157fn migrate_v1_to_v2(store: &tauri_plugin_store::Store<tauri::Wry>) {
158    let old_ai = match store.get("ai") {
159        Some(v) => v,
160        None => {
161            // No ai section; just bump the version.
162            store.set("_schemaVersion", serde_json::json!(SCHEMA_VERSION));
163            let _ = store.save();
164            return;
165        }
166    };
167
168    let ollama_url = old_ai
169        .get("ollamaUrl")
170        .and_then(|v| v.as_str())
171        .unwrap_or("http://localhost:11434")
172        .to_string();
173    let ollama_model = old_ai
174        .get("ollamaModel")
175        .and_then(|v| v.as_str())
176        .unwrap_or("llava:7b")
177        .to_string();
178    let openai_model = old_ai
179        .get("openaiModel")
180        .and_then(|v| v.as_str())
181        .unwrap_or("gpt-4o")
182        .to_string();
183    let default_provider = old_ai
184        .get("defaultLlmProvider")
185        .and_then(|v| v.as_str())
186        .unwrap_or("claude")
187        .to_string();
188
189    // Use stable well-known IDs for the migrated endpoints.
190    let openai_id = "openai".to_string();
191    let ollama_id = "ollama-local".to_string();
192
193    // Normalise the Ollama URL: ensure it ends with /v1.
194    let ollama_base_url = if ollama_url.trim_end_matches('/').ends_with("/v1") {
195        ollama_url.trim_end_matches('/').to_string()
196    } else {
197        format!("{}/v1", ollama_url.trim_end_matches('/'))
198    };
199
200    let openai_endpoint = LlmEndpoint {
201        id: openai_id.clone(),
202        name: "OpenAI".to_string(),
203        base_url: "https://api.openai.com/v1".to_string(),
204        model: openai_model,
205    };
206    let ollama_endpoint = LlmEndpoint {
207        id: ollama_id.clone(),
208        name: "Ollama (local)".to_string(),
209        base_url: ollama_base_url,
210        model: ollama_model,
211    };
212
213    let new_default_provider = match default_provider.as_str() {
214        "openai" => format!("endpoint:{openai_id}"),
215        "ollama" => format!("endpoint:{ollama_id}"),
216        other => other.to_string(),
217    };
218
219    // Preserve all other ai fields across the migration.
220    let claude_model = old_ai
221        .get("claudeModel")
222        .and_then(|v| v.as_str())
223        .unwrap_or("claude-sonnet-4-20250514")
224        .to_string();
225    let gemini_model = old_ai
226        .get("geminiModel")
227        .and_then(|v| v.as_str())
228        .unwrap_or("gemini-2.0-flash")
229        .to_string();
230    let ocr_language = old_ai
231        .get("ocrLanguage")
232        .and_then(|v| v.as_str())
233        .unwrap_or("eng")
234        .to_string();
235    let image_max_dim = old_ai
236        .get("imageMaxDim")
237        .and_then(|v| v.as_u64())
238        .unwrap_or(2048) as u32;
239    let image_quality = old_ai
240        .get("imageQuality")
241        .and_then(|v| v.as_u64())
242        .unwrap_or(85) as u8;
243
244    let new_ai = AiSettings {
245        ocr_language,
246        default_llm_provider: new_default_provider,
247        endpoints: vec![openai_endpoint, ollama_endpoint],
248        claude_model,
249        gemini_model,
250        image_max_dim,
251        image_quality,
252    };
253
254    if let Ok(v) = serde_json::to_value(&new_ai) {
255        store.set("ai", v);
256    }
257
258    // Best-effort: migrate OpenAI keychain entry to the new endpoint account.
259    if let Ok(key) = crate::credentials::get_api_key("openai") {
260        if !key.is_empty() {
261            let _ = crate::credentials::store_api_key(&format!("endpoint:{openai_id}"), &key);
262            let _ = crate::credentials::delete_api_key("openai");
263        }
264    }
265
266    store.set("_schemaVersion", serde_json::json!(SCHEMA_VERSION));
267    let _ = store.save();
268}
269
270/// Run any pending schema migrations before loading settings.
271fn migrate_if_needed(store: &tauri_plugin_store::Store<tauri::Wry>) {
272    let version = store
273        .get("_schemaVersion")
274        .and_then(|v| v.as_u64())
275        .unwrap_or(0);
276
277    if version < 2 {
278        migrate_v1_to_v2(store);
279    }
280}
281
282#[tauri::command]
283pub fn get_settings(app: tauri::AppHandle) -> Result<Settings, String> {
284    let store = app
285        .store(STORE_PATH)
286        .map_err(|e| format!("Store error: {e}"))?;
287    migrate_if_needed(&store);
288    Ok(Settings {
289        capture: load_section(&store, "capture"),
290        annotation: load_section(&store, "annotation"),
291        ai: load_section(&store, "ai"),
292        ui: load_section(&store, "ui"),
293    })
294}
295
296#[tauri::command]
297pub fn set_settings(app: tauri::AppHandle, settings: Settings) -> Result<(), String> {
298    let store = app
299        .store(STORE_PATH)
300        .map_err(|e| format!("Store error: {e}"))?;
301    store.set(
302        "capture",
303        serde_json::to_value(&settings.capture).map_err(|e| e.to_string())?,
304    );
305    store.set(
306        "annotation",
307        serde_json::to_value(&settings.annotation).map_err(|e| e.to_string())?,
308    );
309    store.set(
310        "ai",
311        serde_json::to_value(&settings.ai).map_err(|e| e.to_string())?,
312    );
313    store.set(
314        "ui",
315        serde_json::to_value(&settings.ui).map_err(|e| e.to_string())?,
316    );
317    store.set("_schemaVersion", serde_json::json!(SCHEMA_VERSION));
318    store.save().map_err(|e| format!("Save error: {e}"))?;
319    Ok(())
320}
321
322#[tauri::command]
323pub fn set_api_key(provider: String, key: String) -> Result<(), String> {
324    crate::credentials::store_api_key(&provider, &key)
325        .map_err(|e| format!("Failed to store API key: {e}"))
326}
327
328#[tauri::command]
329pub fn get_api_key(provider: String) -> Result<String, String> {
330    match crate::credentials::get_api_key(&provider) {
331        Ok(key) => {
332            // Return masked form: 8 bullets + last 4 chars (or all bullets if short)
333            let masked = if key.len() > 4 {
334                format!(
335                    "\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}{}",
336                    &key[key.len() - 4..]
337                )
338            } else {
339                "\u{2022}".repeat(key.len())
340            };
341            Ok(masked)
342        }
343        // No entry = not an error, just no key set
344        Err(_) => Ok(String::new()),
345    }
346}
347
348#[tauri::command]
349pub fn delete_api_key(provider: String) -> Result<(), String> {
350    match crate::credentials::delete_api_key(&provider) {
351        Ok(_) => Ok(()),
352        Err(e) => {
353            // Treat missing entry as success
354            if matches!(
355                e.downcast_ref::<keyring::Error>(),
356                Some(keyring::Error::NoEntry)
357            ) {
358                Ok(())
359            } else {
360                Err(format!("Failed to delete API key: {e}"))
361            }
362        }
363    }
364}
365
366#[tauri::command]
367pub async fn test_api_key(app: tauri::AppHandle, provider: String) -> Result<(), String> {
368    let client = reqwest::Client::builder()
369        .timeout(std::time::Duration::from_secs(10))
370        .build()
371        .map_err(|e| format!("HTTP client error: {e}"))?;
372
373    match provider.as_str() {
374        "anthropic" => {
375            let key = crate::credentials::get_api_key(&provider)
376                .map_err(|_| "No API key configured for 'anthropic'".to_string())?;
377            if key.is_empty() {
378                return Err("No API key configured for 'anthropic'".to_string());
379            }
380            let status = client
381                .get("https://api.anthropic.com/v1/models")
382                .header("x-api-key", &key)
383                .header("anthropic-version", "2023-06-01")
384                .send()
385                .await
386                .map_err(|e| format!("Request failed: {e}"))?
387                .status();
388            check_key_status(status)
389        }
390        "gemini" => {
391            let key = crate::credentials::get_api_key(&provider)
392                .map_err(|_| "No API key configured for 'gemini'".to_string())?;
393            if key.is_empty() {
394                return Err("No API key configured for 'gemini'".to_string());
395            }
396            let url = format!("https://generativelanguage.googleapis.com/v1/models?key={key}");
397            let status = client
398                .get(&url)
399                .send()
400                .await
401                .map_err(|e| format!("Request failed: {e}"))?
402                .status();
403            check_key_status(status)
404        }
405        s if s.starts_with("endpoint:") => {
406            let id = s["endpoint:".len()..].to_string();
407            let store = app
408                .store(STORE_PATH)
409                .map_err(|e| format!("Store error: {e}"))?;
410            let ai_settings: AiSettings = store
411                .get("ai")
412                .and_then(|v| serde_json::from_value(v).ok())
413                .unwrap_or_default();
414            let endpoint = ai_settings
415                .endpoints
416                .iter()
417                .find(|e| e.id == id)
418                .ok_or_else(|| format!("Unknown endpoint '{id}'"))?;
419
420            let api_key = crate::credentials::get_api_key(&provider).unwrap_or_default();
421            let base = endpoint.base_url.trim_end_matches('/');
422            let url = format!("{base}/models");
423
424            let mut req = client.get(&url);
425            if !api_key.is_empty() {
426                req = req.header(header::AUTHORIZATION, format!("Bearer {api_key}"));
427            }
428            let status = req
429                .send()
430                .await
431                .map_err(|e| format!("Request failed: {e}"))?
432                .status();
433            if status.is_success() {
434                Ok(())
435            } else {
436                Err(format!("API returned status {status}"))
437            }
438        }
439        other => Err(format!("Unknown provider '{other}'")),
440    }
441}
442
443fn check_key_status(status: reqwest::StatusCode) -> Result<(), String> {
444    if status.is_success() {
445        Ok(())
446    } else if status.as_u16() == 401 || status.as_u16() == 403 {
447        Err(format!("Authentication failed ({status}): invalid API key"))
448    } else {
449        Err(format!("API returned unexpected status {status}"))
450    }
451}