1use 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#[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
70pub 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
77pub 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 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#[cfg(unix)]
118async fn handle_connection(mut stream: UnixStream, app: tauri::AppHandle) -> Result<()> {
119 loop {
120 let mut len_buf = [0u8; 4];
122 match stream.read_exact(&mut len_buf).await {
123 Ok(_) => {}
124 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 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
156async 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
511fn 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}