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#[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 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
152fn 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 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 let openai_id = "openai".to_string();
191 let ollama_id = "ollama-local".to_string();
192
193 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 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 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
270fn 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 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 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 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}