Skip to main content

fotos_lib/
lib.rs

1pub mod ai;
2pub mod capture;
3pub mod commands;
4pub mod credentials;
5#[cfg(target_os = "linux")]
6mod dbus;
7pub mod ipc;
8
9use base64::prelude::*;
10use std::io::Cursor;
11use std::sync::{
12    atomic::{AtomicBool, Ordering},
13    Arc,
14};
15use tauri::{Emitter, Manager};
16use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
17use uuid::Uuid;
18
19fn init_logging() {
20    use tracing_subscriber::{fmt, EnvFilter};
21    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
22    fmt()
23        .with_env_filter(filter)
24        .with_writer(std::io::stderr)
25        .init();
26}
27
28async fn do_capture_and_emit(
29    app: &tauri::AppHandle,
30    event_name: &'static str,
31    is_capturing: Arc<AtomicBool>,
32) {
33    // Guard: ignore if a capture is already in flight.
34    if is_capturing
35        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
36        .is_err()
37    {
38        tracing::debug!("do_capture_and_emit: capture already in flight, ignoring");
39        return;
40    }
41
42    tracing::info!("do_capture_and_emit: starting capture for event '{event_name}'");
43
44    let window = match app.get_webview_window("main") {
45        Some(w) => w,
46        None => {
47            tracing::error!("do_capture_and_emit: main window not found");
48            is_capturing.store(false, Ordering::SeqCst);
49            return;
50        }
51    };
52
53    // In Flatpak the portal captures the screen including our window, so hide
54    // first regardless of backend so the app doesn't appear in the shot.
55    let _ = window.hide();
56    // On X11 ~150ms is enough; on Wayland the compositor needs a full frame.
57    tokio::time::sleep(std::time::Duration::from_millis(300)).await;
58
59    let image_store = app.state::<capture::ImageStore>();
60    let result: Result<commands::capture::ScreenshotResponse, String> = async {
61        #[cfg(target_os = "linux")]
62        let in_flatpak = std::env::var("FLATPAK_ID").is_ok();
63        #[cfg(target_os = "linux")]
64        tracing::info!("do_capture_and_emit: in_flatpak={in_flatpak}");
65
66        #[cfg(target_os = "linux")]
67        let image = if in_flatpak {
68            tracing::info!("do_capture_and_emit: using portal backend");
69            capture::portal::capture_via_portal()
70                .await
71                .map_err(|e| e.to_string())?
72        } else {
73            tracing::info!("do_capture_and_emit: using xcap backend");
74            capture::xcap_backend::capture_fullscreen()
75                .await
76                .map_err(|e| e.to_string())?
77        };
78        #[cfg(not(target_os = "linux"))]
79        let image = {
80            tracing::info!("do_capture_and_emit: using xcap backend");
81            capture::xcap_backend::capture_fullscreen()
82                .await
83                .map_err(|e| e.to_string())?
84        };
85        let image = Arc::new(image);
86        let id = Uuid::new_v4();
87        image_store.insert(id, Arc::clone(&image));
88        let mut png = Vec::new();
89        image
90            .write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png)
91            .map_err(|e| e.to_string())?;
92        Ok(commands::capture::ScreenshotResponse {
93            id: id.to_string(),
94            width: image.width(),
95            height: image.height(),
96            data_url: format!("data:image/png;base64,{}", BASE64_STANDARD.encode(&png)),
97        })
98    }
99    .await;
100
101    let _ = window.show();
102    let _ = window.set_focus();
103    is_capturing.store(false, Ordering::SeqCst);
104
105    match result {
106        Ok(ref payload) => {
107            tracing::info!(
108                "do_capture_and_emit: captured {}x{}, emitting '{event_name}'",
109                payload.width,
110                payload.height
111            );
112            let _ = app.emit(event_name, payload);
113        }
114        Err(ref e) => {
115            tracing::error!("do_capture_and_emit: capture failed: {e}");
116            let _ = app.emit(event_name, serde_json::json!({ "error": e }));
117        }
118    }
119}
120
121pub fn run() {
122    init_logging();
123    tracing::info!(
124        "Fotos starting (FLATPAK_ID={:?})",
125        std::env::var("FLATPAK_ID").ok()
126    );
127
128    let image_store = capture::ImageStore::new();
129
130    tauri::Builder::default()
131        .manage(image_store)
132        .plugin(tauri_plugin_shell::init())
133        .plugin(tauri_plugin_dialog::init())
134        .plugin(tauri_plugin_fs::init())
135        .plugin(tauri_plugin_clipboard_manager::init())
136        .plugin(tauri_plugin_global_shortcut::Builder::new().build())
137        .plugin(tauri_plugin_os::init())
138        .plugin(tauri_plugin_store::Builder::default().build())
139        .setup(|app| {
140            let handle = app.handle().clone();
141            let is_capturing = Arc::new(AtomicBool::new(false));
142
143            // Start the IPC server so fotos-mcp can connect.
144            let ipc_handle = handle.clone();
145            tauri::async_runtime::spawn(async move {
146                if let Err(e) = ipc::server::start_ipc_server(ipc_handle).await {
147                    tracing::error!("IPC server exited: {e}");
148                }
149            });
150
151            // Start the D-Bus service for GNOME Shell integration (Linux only).
152            #[cfg(target_os = "linux")]
153            {
154                let dbus_handle = handle.clone();
155                let dbus_capturing = is_capturing.clone();
156                tauri::async_runtime::spawn(async move {
157                    if let Err(e) = dbus::start_service(dbus_handle, dbus_capturing).await {
158                        tracing::warn!("D-Bus service failed to start: {e}");
159                    }
160                });
161            }
162
163            // Restore saved window position and size.
164            {
165                use tauri_plugin_store::StoreExt;
166                if let (Ok(store), Some(win)) =
167                    (app.store("prefs.json"), app.get_webview_window("main"))
168                {
169                    if let Some(pos) = store.get("window_pos") {
170                        if let (Some(x), Some(y)) = (
171                            pos.get("x").and_then(|v| v.as_i64()),
172                            pos.get("y").and_then(|v| v.as_i64()),
173                        ) {
174                            let _ =
175                                win.set_position(tauri::PhysicalPosition::new(x as i32, y as i32));
176                        }
177                    }
178                    if let Some(size) = store.get("window_size") {
179                        if let (Some(w), Some(h)) = (
180                            size.get("width").and_then(|v| v.as_u64()),
181                            size.get("height").and_then(|v| v.as_u64()),
182                        ) {
183                            let _ = win.set_size(tauri::PhysicalSize::new(w as u32, h as u32));
184                        }
185                    }
186                }
187            }
188
189            let r1 = app.global_shortcut().on_shortcut("ctrl+shift+s", {
190                let handle = handle.clone();
191                let is_capturing = is_capturing.clone();
192                move |_app, _shortcut, event| {
193                    if event.state != ShortcutState::Pressed {
194                        return;
195                    }
196                    let handle = handle.clone();
197                    let is_capturing = is_capturing.clone();
198                    tauri::async_runtime::spawn(async move {
199                        do_capture_and_emit(&handle, "global-capture-region", is_capturing).await;
200                    });
201                }
202            });
203            if let Err(e) = r1 {
204                eprintln!("Warning: could not register Ctrl+Shift+S global shortcut: {e}");
205            }
206
207            let r2 = app.global_shortcut().on_shortcut("ctrl+shift+a", {
208                let handle = handle.clone();
209                let is_capturing = is_capturing.clone();
210                move |_app, _shortcut, event| {
211                    if event.state != ShortcutState::Pressed {
212                        return;
213                    }
214                    let handle = handle.clone();
215                    let is_capturing = is_capturing.clone();
216                    tauri::async_runtime::spawn(async move {
217                        do_capture_and_emit(&handle, "global-capture-fullscreen", is_capturing)
218                            .await;
219                    });
220                }
221            });
222            if let Err(e) = r2 {
223                eprintln!("Warning: could not register Ctrl+Shift+A global shortcut: {e}");
224            }
225
226            Ok(())
227        })
228        .invoke_handler(tauri::generate_handler![
229            commands::ping,
230            commands::capture::take_screenshot,
231            commands::capture::crop_image,
232            commands::capture::list_monitors,
233            commands::capture::list_windows,
234            commands::ai::run_ocr,
235            commands::ai::auto_blur_pii,
236            commands::ai::analyze_llm,
237            commands::ai::tessdata_available,
238            commands::ai::download_tessdata,
239            commands::files::save_image,
240            commands::files::composite_image,
241            commands::files::copy_to_clipboard,
242            commands::files::export_annotations,
243            commands::files::import_annotations,
244            commands::settings::get_settings,
245            commands::settings::set_settings,
246            commands::settings::set_api_key,
247            commands::settings::get_api_key,
248            commands::settings::delete_api_key,
249            commands::settings::test_api_key,
250        ])
251        .on_window_event(|window, event| match event {
252            tauri::WindowEvent::CloseRequested { .. } => {
253                use tauri_plugin_store::StoreExt;
254                if let Ok(store) = window.app_handle().store("prefs.json") {
255                    if let (Ok(pos), Ok(size)) = (window.outer_position(), window.outer_size()) {
256                        store.set("window_pos", serde_json::json!({"x": pos.x, "y": pos.y}));
257                        store.set(
258                            "window_size",
259                            serde_json::json!({"width": size.width, "height": size.height}),
260                        );
261                        let _ = store.save();
262                    }
263                }
264            }
265            tauri::WindowEvent::Destroyed => {
266                let _ = std::fs::remove_file(ipc::server::socket_path());
267            }
268            _ => {}
269        })
270        .run(tauri::generate_context!())
271        .expect("error while running Fotos");
272}