Skip to main content

fotos_lib/ipc/
server.rs

1/// IPC server — runs inside the main Tauri app process.
2///
3/// Binds a Unix socket at `$XDG_RUNTIME_DIR/fotos-ipc.sock` (fallback:
4/// `/tmp/fotos-ipc.sock`) and accepts connections from `fotos-mcp`.
5///
6/// Protocol: each message is framed as a 4-byte big-endian u32 payload length
7/// followed by that many bytes of UTF-8 JSON.  Request: `{id, command, params}`.
8/// Response: `{id, ok}` on success or `{id, error: {code, message}}` on failure.
9use anyhow::Result;
10use base64::prelude::*;
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use std::io::Cursor;
14use std::path::PathBuf;
15use std::sync::Arc;
16use tauri::Manager;
17use tracing::{error, info, warn};
18use uuid::Uuid;
19
20#[cfg(unix)]
21use tokio::{
22    io::{AsyncReadExt, AsyncWriteExt},
23    net::{UnixListener, UnixStream},
24};
25
26// ─── wire types ──────────────────────────────────────────────────────────────
27
28#[derive(Deserialize)]
29struct IpcRequest {
30    id: String,
31    command: String,
32    params: Value,
33}
34
35#[derive(Serialize)]
36struct IpcResponse {
37    id: String,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    ok: Option<Value>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    error: Option<IpcError>,
42}
43
44#[derive(Serialize)]
45struct IpcError {
46    code: String,
47    message: String,
48}
49
50impl IpcResponse {
51    fn ok(id: String, value: Value) -> Self {
52        Self {
53            id,
54            ok: Some(value),
55            error: None,
56        }
57    }
58    fn err(id: String, code: &str, message: String) -> Self {
59        Self {
60            id,
61            ok: None,
62            error: Some(IpcError {
63                code: code.to_owned(),
64                message,
65            }),
66        }
67    }
68}
69
70// ─── socket path ─────────────────────────────────────────────────────────────
71
72pub fn socket_path() -> PathBuf {
73    let base = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_owned());
74    PathBuf::from(base).join("fotos-ipc.sock")
75}
76
77// ─── server entry point ──────────────────────────────────────────────────────
78
79/// Start the IPC server. This is a long-running async task; spawn it with
80/// `tauri::async_runtime::spawn`.
81pub async fn start_ipc_server(app: tauri::AppHandle) -> Result<()> {
82    #[cfg(not(unix))]
83    {
84        warn!("IPC server not yet supported on this platform — skipping");
85        return Ok(());
86    }
87
88    #[cfg(unix)]
89    {
90        let path = socket_path();
91        // Remove a stale socket from a previous run.
92        let _ = std::fs::remove_file(&path);
93
94        let listener = UnixListener::bind(&path)?;
95        info!("IPC server listening at {}", path.display());
96
97        loop {
98            match listener.accept().await {
99                Ok((stream, _addr)) => {
100                    let app = app.clone();
101                    tokio::spawn(async move {
102                        if let Err(e) = handle_connection(stream, app).await {
103                            warn!("IPC connection closed: {e}");
104                        }
105                    });
106                }
107                Err(e) => {
108                    error!("IPC accept error: {e}");
109                }
110            }
111        }
112    }
113}
114
115// ─── per-connection handler ───────────────────────────────────────────────────
116
117#[cfg(unix)]
118async fn handle_connection(mut stream: UnixStream, app: tauri::AppHandle) -> Result<()> {
119    loop {
120        // Read the 4-byte length prefix.
121        let mut len_buf = [0u8; 4];
122        match stream.read_exact(&mut len_buf).await {
123            Ok(_) => {}
124            // Clean EOF — client disconnected.
125            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
126            Err(e) => return Err(e.into()),
127        }
128
129        let body_len = u32::from_be_bytes(len_buf) as usize;
130        let mut body = vec![0u8; body_len];
131        stream.read_exact(&mut body).await?;
132
133        let response = match serde_json::from_slice::<IpcRequest>(&body) {
134            Ok(req) => {
135                let id = req.id.clone();
136                match dispatch(&app, &req.command, req.params).await {
137                    Ok(v) => IpcResponse::ok(id, v),
138                    Err(e) => IpcResponse::err(id, "command_error", e.to_string()),
139                }
140            }
141            Err(e) => {
142                // Malformed request — id unknown, use empty string.
143                warn!("IPC: malformed request: {e}");
144                IpcResponse::err(String::new(), "invalid_request", e.to_string())
145            }
146        };
147
148        let payload = serde_json::to_vec(&response)?;
149        let len = u32::try_from(payload.len())?.to_be_bytes();
150        stream.write_all(&len).await?;
151        stream.write_all(&payload).await?;
152    }
153    Ok(())
154}
155
156// ─── command dispatcher ───────────────────────────────────────────────────────
157
158async fn dispatch(app: &tauri::AppHandle, command: &str, params: Value) -> anyhow::Result<Value> {
159    match command {
160        "get_settings" => {
161            let settings = crate::commands::settings::get_settings(app.clone())
162                .map_err(|e| anyhow::anyhow!("{e}"))?;
163            Ok(serde_json::to_value(settings)?)
164        }
165
166        "take_screenshot" => {
167            let mode = params
168                .get("mode")
169                .and_then(Value::as_str)
170                .unwrap_or("fullscreen")
171                .to_owned();
172            let image = match mode.as_str() {
173                "fullscreen" => {
174                    if let Some(win) = app.get_webview_window("main") {
175                        let _ = win.hide();
176                    }
177                    tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
178                    let img = crate::capture::xcap_backend::capture_fullscreen()
179                        .await
180                        .map_err(|e| anyhow::anyhow!("Capture failed: {e}"))?;
181                    if let Some(win) = app.get_webview_window("main") {
182                        let _ = win.show();
183                    }
184                    img
185                }
186                "monitor" => {
187                    let idx = params
188                        .get("monitor_index")
189                        .and_then(Value::as_u64)
190                        .map(|v| v as u32)
191                        .ok_or_else(|| {
192                            anyhow::anyhow!("monitor_index required for mode 'monitor'")
193                        })?;
194                    if let Some(win) = app.get_webview_window("main") {
195                        let _ = win.hide();
196                    }
197                    tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
198                    let img = crate::capture::xcap_backend::capture_monitor(idx)
199                        .await
200                        .map_err(|e| anyhow::anyhow!("Monitor capture failed: {e}"))?;
201                    if let Some(win) = app.get_webview_window("main") {
202                        let _ = win.show();
203                    }
204                    img
205                }
206                "window" => {
207                    let title_sub = params
208                        .get("window_title")
209                        .and_then(Value::as_str)
210                        .ok_or_else(|| anyhow::anyhow!("window_title required for mode 'window'"))?
211                        .to_lowercase();
212                    let windows = crate::commands::capture::list_windows()
213                        .await
214                        .map_err(|e| anyhow::anyhow!("{e}"))?;
215                    let win = windows
216                        .into_iter()
217                        .find(|w| w.title.to_lowercase().contains(&title_sub))
218                        .ok_or_else(|| anyhow::anyhow!("No window matching title"))?;
219                    crate::capture::xcap_backend::capture_window(win.id)
220                        .await
221                        .map_err(|e| anyhow::anyhow!("Window capture failed: {e}"))?
222                }
223                other => anyhow::bail!("Unknown capture mode '{other}'"),
224            };
225            let image = Arc::new(image);
226            let (width, height) = (image.width(), image.height());
227            let id = Uuid::new_v4();
228            app.state::<crate::capture::ImageStore>()
229                .insert(id, Arc::clone(&image));
230            let mut png_data = Vec::new();
231            image
232                .write_to(&mut Cursor::new(&mut png_data), image::ImageFormat::Png)
233                .map_err(|e| anyhow::anyhow!("PNG encoding failed: {e}"))?;
234            Ok(serde_json::json!({
235                "id": id.to_string(),
236                "image_b64": BASE64_STANDARD.encode(&png_data),
237                "width": width,
238                "height": height,
239                "timestamp": chrono::Utc::now().to_rfc3339(),
240                "mode": mode,
241            }))
242        }
243
244        "ocr_screenshot" => {
245            let lang = params
246                .get("language")
247                .and_then(Value::as_str)
248                .unwrap_or("eng")
249                .to_owned();
250            let store = app.state::<crate::capture::ImageStore>();
251            let (image, screenshot_id) = match params.get("screenshot_id").and_then(Value::as_str) {
252                Some(id_str) => {
253                    let uuid = Uuid::parse_str(id_str).map_err(|e| anyhow::anyhow!("{e}"))?;
254                    let img = store
255                        .get(&uuid)
256                        .ok_or_else(|| anyhow::anyhow!("Screenshot not found: {id_str}"))?;
257                    (img, uuid)
258                }
259                None => {
260                    if let Some(win) = app.get_webview_window("main") {
261                        let _ = win.hide();
262                    }
263                    tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
264                    let img = crate::capture::xcap_backend::capture_fullscreen()
265                        .await
266                        .map_err(|e| anyhow::anyhow!("Capture failed: {e}"))?;
267                    if let Some(win) = app.get_webview_window("main") {
268                        let _ = win.show();
269                    }
270                    let img = Arc::new(img);
271                    let id = Uuid::new_v4();
272                    store.insert(id, Arc::clone(&img));
273                    (img, id)
274                }
275            };
276            let tessdata_path = crate::commands::ai::resolve_tessdata_path(app, &lang)
277                .map_err(|e| anyhow::anyhow!("{e}"))?;
278            let opts = crate::ai::ocr::OcrOptions {
279                lang,
280                tessdata_path,
281            };
282            let progress_app = app.clone();
283            let on_progress = move |current: u32, total: u32| {
284                let _ = tauri::Emitter::emit(
285                    &progress_app,
286                    "ocr:progress",
287                    serde_json::json!({"current": current, "total": total}),
288                );
289            };
290            let ocr = crate::ai::ocr::run_ocr(&image, &opts, Some(&on_progress))
291                .map_err(|e| anyhow::anyhow!("OCR failed: {e}"))?;
292            Ok(serde_json::json!({
293                "screenshot_id": screenshot_id.to_string(),
294                "text": ocr.full_text,
295                "regions": ocr.regions.into_iter().map(|r| serde_json::json!({
296                    "text": r.text, "x": r.x, "y": r.y, "w": r.w, "h": r.h,
297                    "confidence": r.confidence,
298                })).collect::<Vec<_>>(),
299            }))
300        }
301
302        "annotate_screenshot" => {
303            let id_str = params
304                .get("screenshot_id")
305                .and_then(Value::as_str)
306                .ok_or_else(|| anyhow::anyhow!("screenshot_id required"))?
307                .to_owned();
308            let annotations_val = inject_annotation_ids(
309                params
310                    .get("annotations")
311                    .cloned()
312                    .unwrap_or(Value::Array(vec![])),
313            );
314            let annotations: Vec<crate::commands::files::Annotation> =
315                serde_json::from_value(annotations_val)
316                    .map_err(|e| anyhow::anyhow!("Invalid annotations: {e}"))?;
317            let store = app.state::<crate::capture::ImageStore>();
318            let image_b64 =
319                crate::commands::files::composite_image(id_str, annotations, None, store)
320                    .map_err(|e| anyhow::anyhow!("{e}"))?;
321            Ok(serde_json::json!({ "image_b64": image_b64 }))
322        }
323
324        "analyze_screenshot" => {
325            use crate::ai::{compress, llm};
326            use tauri_plugin_store::StoreExt;
327
328            let prompt = params
329                .get("prompt")
330                .and_then(Value::as_str)
331                .map(str::to_owned);
332            let provider = params
333                .get("provider")
334                .and_then(Value::as_str)
335                .unwrap_or("claude")
336                .to_owned();
337
338            let store = app.state::<crate::capture::ImageStore>();
339            let (image, _id) = match params.get("screenshot_id").and_then(Value::as_str) {
340                Some(id_str) => {
341                    let uuid = Uuid::parse_str(id_str).map_err(|e| anyhow::anyhow!("{e}"))?;
342                    let img = store
343                        .get(&uuid)
344                        .ok_or_else(|| anyhow::anyhow!("Screenshot not found: {id_str}"))?;
345                    (img, uuid)
346                }
347                None => {
348                    if let Some(win) = app.get_webview_window("main") {
349                        let _ = win.hide();
350                    }
351                    tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
352                    let img = crate::capture::xcap_backend::capture_fullscreen()
353                        .await
354                        .map_err(|e| anyhow::anyhow!("Capture failed: {e}"))?;
355                    if let Some(win) = app.get_webview_window("main") {
356                        let _ = win.show();
357                    }
358                    let img = Arc::new(img);
359                    let id = Uuid::new_v4();
360                    store.insert(id, Arc::clone(&img));
361                    (img, id)
362                }
363            };
364
365            let prefs_store = app
366                .store("prefs.json")
367                .map_err(|e| anyhow::anyhow!("{e}"))?;
368            let ai: crate::commands::settings::AiSettings = prefs_store
369                .get("ai")
370                .and_then(|v| serde_json::from_value(v).ok())
371                .unwrap_or_default();
372
373            let image_b64 = compress::compress_for_llm(&image, ai.image_max_dim, ai.image_quality)
374                .map_err(|e| anyhow::anyhow!("Compression failed: {e}"))?;
375            let prompt_text = prompt.unwrap_or_else(|| "Describe this image.".to_owned());
376
377            let output = match provider.as_str() {
378                "claude" | "anthropic" => {
379                    let key = crate::credentials::get_api_key("anthropic")
380                        .map_err(|_| anyhow::anyhow!("No Anthropic API key configured"))?;
381                    llm::analyze(
382                        &image_b64,
383                        &prompt_text,
384                        &llm::LlmProvider::Claude {
385                            model: ai.claude_model,
386                        },
387                        &key,
388                    )
389                    .await?
390                }
391                "gemini" => {
392                    let key = crate::credentials::get_api_key("gemini")
393                        .map_err(|_| anyhow::anyhow!("No Gemini API key configured"))?;
394                    llm::analyze(
395                        &image_b64,
396                        &prompt_text,
397                        &llm::LlmProvider::Gemini {
398                            model: ai.gemini_model,
399                        },
400                        &key,
401                    )
402                    .await?
403                }
404                s if s.starts_with("endpoint:") => {
405                    let id = &s["endpoint:".len()..];
406                    let endpoint = ai
407                        .endpoints
408                        .iter()
409                        .find(|e| e.id == id)
410                        .ok_or_else(|| anyhow::anyhow!("Unknown endpoint '{id}'"))?;
411                    let api_key =
412                        crate::credentials::get_api_key(provider.as_str()).unwrap_or_default();
413                    crate::ai::openai_compat::analyze(
414                        &image_b64,
415                        &prompt_text,
416                        &endpoint.base_url,
417                        &endpoint.model,
418                        &api_key,
419                    )
420                    .await?
421                }
422                other => anyhow::bail!("Unknown provider '{other}'"),
423            };
424            Ok(serde_json::json!({
425                "provider": provider,
426                "model": output.model,
427                "response_text": output.response,
428                "tokens_used": output.tokens_used,
429                "latency_ms": output.latency_ms,
430            }))
431        }
432
433        "auto_redact_pii" => {
434            let id_str = params
435                .get("screenshot_id")
436                .and_then(Value::as_str)
437                .ok_or_else(|| anyhow::anyhow!("screenshot_id required"))?
438                .to_owned();
439            let store = app.state::<crate::capture::ImageStore>();
440            let uuid = Uuid::parse_str(&id_str).map_err(|e| anyhow::anyhow!("{e}"))?;
441            let image = store
442                .get(&uuid)
443                .ok_or_else(|| anyhow::anyhow!("Screenshot not found: {id_str}"))?;
444
445            let tessdata_path = crate::commands::ai::resolve_tessdata_path(app, "eng")
446                .map_err(|e| anyhow::anyhow!("{e}"))?;
447            let opts = crate::ai::ocr::OcrOptions {
448                lang: "eng".to_owned(),
449                tessdata_path,
450            };
451            let ocr = crate::ai::ocr::run_ocr(&image, &opts, None)
452                .map_err(|e| anyhow::anyhow!("OCR failed: {e}"))?;
453            let pii = crate::ai::pii::detect_pii(&ocr.regions)
454                .map_err(|e| anyhow::anyhow!("PII detection failed: {e}"))?;
455
456            let blur_annotations: Vec<crate::commands::files::Annotation> = pii
457                .iter()
458                .map(|m| crate::commands::files::Annotation {
459                    id: Uuid::new_v4().to_string(),
460                    annotation_type: "blur".to_owned(),
461                    x: m.x as f64,
462                    y: m.y as f64,
463                    width: Some(m.w as f64),
464                    height: Some(m.h as f64),
465                    stroke_color: None,
466                    fill_color: None,
467                    stroke_width: None,
468                    opacity: None,
469                    text: None,
470                    font_size: None,
471                    font_family: None,
472                    points: None,
473                    step_number: None,
474                    blur_radius: None,
475                    highlight_color: None,
476                    created_at: None,
477                    locked: None,
478                })
479                .collect();
480
481            let image_b64 =
482                crate::commands::files::composite_image(id_str, blur_annotations, None, store)
483                    .map_err(|e| anyhow::anyhow!("{e}"))?;
484
485            let detections: Vec<Value> = pii
486                .into_iter()
487                .map(|m| {
488                    serde_json::json!({
489                        "type": m.pii_type, "x": m.x, "y": m.y, "w": m.w, "h": m.h,
490                    })
491                })
492                .collect();
493            Ok(serde_json::json!({ "image_b64": image_b64, "detections": detections }))
494        }
495
496        "list_screenshots" => {
497            let limit = params.get("limit").and_then(Value::as_u64).unwrap_or(10) as usize;
498            let ids = app.state::<crate::capture::ImageStore>().ids();
499            let entries: Vec<Value> = ids
500                .into_iter()
501                .take(limit)
502                .map(|id| serde_json::json!({ "id": id.to_string() }))
503                .collect();
504            Ok(Value::Array(entries))
505        }
506
507        _ => Err(anyhow::anyhow!("unknown command: {command}")),
508    }
509}
510
511/// Inject a random `id` field into any annotation object that is missing one.
512fn inject_annotation_ids(mut val: Value) -> Value {
513    if let Some(arr) = val.as_array_mut() {
514        for item in arr.iter_mut() {
515            if let Some(obj) = item.as_object_mut() {
516                if !obj.contains_key("id") {
517                    obj.insert("id".to_owned(), Value::String(Uuid::new_v4().to_string()));
518                }
519            }
520        }
521    }
522    val
523}