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 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 let _ = window.hide();
56 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 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 #[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 {
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}