Rust · 16320 bytes Raw Blame History
1 //! FileChooser portal interface implementation.
2 //!
3 //! Implements org.freedesktop.impl.portal.FileChooser by spawning
4 //! garfield in picker mode.
5
6 use crate::request::{build_file_chooser_response, Request, RequestManager, ResponseCode};
7 use std::collections::HashMap;
8 use std::process::Stdio;
9 use tokio::io::{AsyncBufReadExt, BufReader};
10 use tokio::process::Command;
11 use zbus::zvariant::{ObjectPath, OwnedObjectPath, Value};
12 use zbus::{fdo, interface};
13
14 /// FileChooser portal backend.
15 pub struct FileChooser {
16 request_manager: RequestManager,
17 }
18
19 impl FileChooser {
20 pub fn new() -> Self {
21 Self {
22 request_manager: RequestManager::new(),
23 }
24 }
25
26 /// Parse X11 window ID from portal parent_window string.
27 /// Format is "x11:<xid>" where xid is the window ID in hex.
28 fn parse_parent_window(parent_window: &str) -> Option<u32> {
29 if parent_window.starts_with("x11:") {
30 let hex_str = &parent_window[4..];
31 u32::from_str_radix(hex_str, 16).ok()
32 } else {
33 None
34 }
35 }
36
37 /// Spawn garfield in picker mode and collect results.
38 async fn spawn_picker(
39 &self,
40 handle: OwnedObjectPath,
41 title: &str,
42 directory_mode: bool,
43 multiple: bool,
44 filters: Vec<String>,
45 current_folder: Option<String>,
46 parent_window: Option<u32>,
47 ) -> (u32, HashMap<String, Value<'static>>) {
48 // Find garfield binary - prefer ~/.cargo/bin (development), then /usr/local/bin, then PATH
49 let home = std::env::var("HOME").unwrap_or_default();
50 let cargo_path = format!("{}/.cargo/bin/garfield", home);
51 let garfield_path = if std::path::Path::new(&cargo_path).exists() {
52 cargo_path
53 } else if std::path::Path::new("/usr/local/bin/garfield").exists() {
54 "/usr/local/bin/garfield".to_string()
55 } else {
56 "garfield".to_string() // Fall back to PATH lookup
57 };
58
59 let mut cmd = Command::new(garfield_path);
60 cmd.arg("--picker");
61
62 if !title.is_empty() {
63 cmd.arg("--title").arg(title);
64 }
65
66 if directory_mode {
67 cmd.arg("--directory");
68 }
69
70 if multiple {
71 cmd.arg("--multiple");
72 }
73
74 if !filters.is_empty() {
75 cmd.arg("--filter").arg(filters.join(";"));
76 }
77
78 if let Some(parent) = parent_window {
79 cmd.arg("--parent-window").arg(parent.to_string());
80 }
81
82 if let Some(folder) = current_folder {
83 cmd.arg(&folder);
84 }
85
86 cmd.stdout(Stdio::piped());
87 cmd.stderr(Stdio::inherit()); // Let garfield stderr through for debugging
88
89 tracing::info!("Spawning garfield picker: {:?}", cmd);
90
91 let mut child = match cmd.spawn() {
92 Ok(c) => c,
93 Err(e) => {
94 tracing::error!("Failed to spawn garfield: {}", e);
95 return (ResponseCode::Error as u32, HashMap::new());
96 }
97 };
98
99 tracing::info!("garfield spawned with PID {:?}", child.id());
100
101 // Get stdout handle before adding to manager (we need ownership)
102 let stdout = match child.stdout.take() {
103 Some(s) => s,
104 None => {
105 tracing::error!("Failed to get stdout from garfield");
106 return (ResponseCode::Error as u32, HashMap::new());
107 }
108 };
109
110 // Track the request for cancellation (child still runs, just stdout detached)
111 self.request_manager.add(handle.clone(), child).await;
112
113 // Wait for the child to exit by reading stdout until EOF
114 tracing::info!("Waiting for garfield to complete...");
115 let reader = BufReader::new(stdout);
116 let mut lines = reader.lines();
117 let mut paths = Vec::new();
118
119 while let Ok(Some(line)) = lines.next_line().await {
120 if !line.is_empty() {
121 tracing::debug!("garfield output: {}", line);
122 paths.push(line);
123 }
124 }
125
126 tracing::info!("garfield stdout closed, got {} paths", paths.len());
127 for (i, path) in paths.iter().enumerate() {
128 tracing::info!(" path[{}]: {:?}", i, path);
129 }
130
131 // Now remove from manager and check status
132 let request = self.request_manager.remove(&handle.as_ref()).await;
133
134 // Check if cancelled
135 if let Some(req) = &request {
136 if req.cancelled {
137 tracing::info!("Request was cancelled");
138 return (ResponseCode::Cancelled as u32, HashMap::new());
139 }
140 }
141
142 // If we got paths, it's a success
143 if paths.is_empty() {
144 tracing::info!("No paths selected, treating as cancelled");
145 (ResponseCode::Cancelled as u32, HashMap::new())
146 } else {
147 tracing::info!("Returning {} selected paths", paths.len());
148 let response = build_file_chooser_response(paths);
149 tracing::info!("Response: {:?}", response);
150 (ResponseCode::Success as u32, response)
151 }
152 }
153
154 /// Parse filter options from the portal format.
155 fn parse_filters(options: &HashMap<&str, Value<'_>>) -> Vec<String> {
156 // Portal filters are: a(sa(us)) - array of (name, array of (type, pattern))
157 // For now, we'll extract glob patterns
158 let mut result = Vec::new();
159
160 if let Some(Value::Array(filters_array)) = options.get("filters") {
161 for filter in filters_array.iter() {
162 // Each filter is (name, patterns_array)
163 if let Value::Structure(s) = filter {
164 let fields = s.fields();
165 if fields.len() >= 2 {
166 if let Value::Array(patterns) = &fields[1] {
167 for pattern in patterns.iter() {
168 // Each pattern is (type, pattern_string)
169 // type 0 = glob, type 1 = mime
170 if let Value::Structure(ps) = pattern {
171 let pfields = ps.fields();
172 if pfields.len() >= 2 {
173 if let (Value::U32(0), Value::Str(glob)) = (&pfields[0], &pfields[1]) {
174 result.push(glob.to_string());
175 }
176 }
177 }
178 }
179 }
180 }
181 }
182 }
183 }
184
185 result
186 }
187
188 /// Extract current folder from options.
189 fn parse_current_folder(options: &HashMap<&str, Value<'_>>) -> Option<String> {
190 if let Some(Value::Array(bytes)) = options.get("current_folder") {
191 // current_folder is a byte array (path as bytes)
192 let path_bytes: Vec<u8> = bytes.iter()
193 .filter_map(|v| {
194 if let Value::U8(b) = v {
195 Some(*b)
196 } else {
197 None
198 }
199 })
200 .collect();
201
202 // Remove trailing null if present
203 let path_bytes: Vec<u8> = path_bytes.into_iter()
204 .take_while(|&b| b != 0)
205 .collect();
206
207 String::from_utf8(path_bytes).ok()
208 } else {
209 None
210 }
211 }
212
213 /// Extract current_name (suggested filename) from options for SaveFile.
214 fn parse_current_name(options: &HashMap<&str, Value<'_>>) -> Option<String> {
215 if let Some(Value::Str(s)) = options.get("current_name") {
216 Some(s.to_string())
217 } else {
218 None
219 }
220 }
221
222 /// Spawn garfield in save mode.
223 async fn spawn_save_picker(
224 &self,
225 handle: OwnedObjectPath,
226 title: &str,
227 suggested_filename: Option<String>,
228 current_folder: Option<String>,
229 parent_window: Option<u32>,
230 ) -> (u32, HashMap<String, Value<'static>>) {
231 // Find garfield binary - prefer ~/.cargo/bin (development), then /usr/local/bin, then PATH
232 let home = std::env::var("HOME").unwrap_or_default();
233 let cargo_path = format!("{}/.cargo/bin/garfield", home);
234 let garfield_path = if std::path::Path::new(&cargo_path).exists() {
235 cargo_path
236 } else if std::path::Path::new("/usr/local/bin/garfield").exists() {
237 "/usr/local/bin/garfield".to_string()
238 } else {
239 "garfield".to_string()
240 };
241
242 let mut cmd = Command::new(&garfield_path);
243 cmd.arg("--picker");
244 cmd.arg("--save");
245
246 if let Some(filename) = suggested_filename {
247 cmd.arg("--save-filename").arg(&filename);
248 }
249
250 if !title.is_empty() {
251 cmd.arg("--title").arg(title);
252 }
253
254 if let Some(parent) = parent_window {
255 cmd.arg("--parent-window").arg(parent.to_string());
256 }
257
258 if let Some(folder) = current_folder {
259 cmd.arg(&folder);
260 }
261
262 cmd.stdout(Stdio::piped());
263 cmd.stderr(Stdio::inherit());
264
265 tracing::info!("Spawning garfield save picker: {:?}", cmd);
266
267 let mut child = match cmd.spawn() {
268 Ok(c) => c,
269 Err(e) => {
270 tracing::error!("Failed to spawn garfield: {}", e);
271 return (ResponseCode::Error as u32, HashMap::new());
272 }
273 };
274
275 tracing::info!("garfield save picker spawned with PID {:?}", child.id());
276
277 let stdout = match child.stdout.take() {
278 Some(s) => s,
279 None => {
280 tracing::error!("Failed to get stdout from garfield");
281 return (ResponseCode::Error as u32, HashMap::new());
282 }
283 };
284
285 self.request_manager.add(handle.clone(), child).await;
286
287 tracing::info!("Waiting for garfield save picker to complete...");
288 let reader = BufReader::new(stdout);
289 let mut lines = reader.lines();
290 let mut paths = Vec::new();
291
292 while let Ok(Some(line)) = lines.next_line().await {
293 if !line.is_empty() {
294 tracing::debug!("garfield output: {}", line);
295 paths.push(line);
296 }
297 }
298
299 tracing::info!("garfield save picker completed, got {} paths", paths.len());
300 for (i, path) in paths.iter().enumerate() {
301 tracing::info!(" path[{}]: {:?}", i, path);
302 }
303
304 let request = self.request_manager.remove(&handle.as_ref()).await;
305
306 if let Some(req) = &request {
307 if req.cancelled {
308 tracing::info!("Request was cancelled");
309 return (ResponseCode::Cancelled as u32, HashMap::new());
310 }
311 }
312
313 if paths.is_empty() {
314 tracing::info!("No path selected, treating as cancelled");
315 (ResponseCode::Cancelled as u32, HashMap::new())
316 } else {
317 tracing::info!("Returning save path: {:?}", paths[0]);
318 let response = build_file_chooser_response(paths);
319 tracing::info!("Response: {:?}", response);
320 (ResponseCode::Success as u32, response)
321 }
322 }
323 }
324
325 #[interface(name = "org.freedesktop.impl.portal.FileChooser")]
326 impl FileChooser {
327 /// Open a file chooser dialog.
328 ///
329 /// Portal method for opening files.
330 async fn open_file(
331 &self,
332 #[zbus(object_server)] server: &zbus::ObjectServer,
333 handle: ObjectPath<'_>,
334 _app_id: &str,
335 parent_window: &str,
336 title: &str,
337 options: HashMap<&str, Value<'_>>,
338 ) -> fdo::Result<(u32, HashMap<String, Value<'static>>)> {
339 tracing::info!("OpenFile request: handle={}, title={}, parent_window={}", handle, title, parent_window);
340
341 let handle_owned: OwnedObjectPath = handle.into();
342 let parent_window_id = Self::parse_parent_window(parent_window);
343
344 // Parse options
345 let multiple = options.get("multiple")
346 .and_then(|v| if let Value::Bool(b) = v { Some(*b) } else { None })
347 .unwrap_or(false);
348
349 let directory = options.get("directory")
350 .and_then(|v| if let Value::Bool(b) = v { Some(*b) } else { None })
351 .unwrap_or(false);
352
353 let filters = Self::parse_filters(&options);
354 let current_folder = Self::parse_current_folder(&options);
355
356 tracing::debug!("Registering request object at {}", handle_owned);
357
358 // Register request object for cancellation
359 let request = Request::new(handle_owned.clone(), self.request_manager.clone());
360 server.at(handle_owned.as_ref(), request).await
361 .map_err(|e| fdo::Error::Failed(format!("Failed to register request: {}", e)))?;
362
363 tracing::debug!("Request object registered, spawning picker");
364
365 // Spawn picker and wait for result
366 let result = self.spawn_picker(
367 handle_owned.clone(),
368 title,
369 directory,
370 multiple,
371 filters,
372 current_folder,
373 parent_window_id,
374 ).await;
375
376 tracing::debug!("Picker returned: {:?}", result.0);
377
378 // Remove request object
379 let _ = server.remove::<Request, _>(&handle_owned).await;
380
381 tracing::info!("OpenFile returning response code {}", result.0);
382 Ok(result)
383 }
384
385 /// Save a file dialog.
386 ///
387 /// Portal method for saving files.
388 async fn save_file(
389 &self,
390 #[zbus(object_server)] server: &zbus::ObjectServer,
391 handle: ObjectPath<'_>,
392 _app_id: &str,
393 parent_window: &str,
394 title: &str,
395 options: HashMap<&str, Value<'_>>,
396 ) -> fdo::Result<(u32, HashMap<String, Value<'static>>)> {
397 tracing::info!("SaveFile request: handle={}, title={}, parent_window={}", handle, title, parent_window);
398 tracing::debug!("SaveFile options: {:?}", options);
399
400 let handle_owned: OwnedObjectPath = handle.into();
401 let parent_window_id = Self::parse_parent_window(parent_window);
402 let current_folder = Self::parse_current_folder(&options);
403 let suggested_filename = Self::parse_current_name(&options);
404
405 tracing::info!("SaveFile: folder={:?}, filename={:?}, parent={:?}", current_folder, suggested_filename, parent_window_id);
406
407 let request = Request::new(handle_owned.clone(), self.request_manager.clone());
408 server.at(handle_owned.as_ref(), request).await
409 .map_err(|e| fdo::Error::Failed(format!("Failed to register request: {}", e)))?;
410
411 // Spawn picker in save mode with suggested filename
412 let result = self.spawn_save_picker(
413 handle_owned.clone(),
414 title,
415 suggested_filename,
416 current_folder,
417 parent_window_id,
418 ).await;
419
420 let _ = server.remove::<Request, _>(&handle_owned).await;
421
422 Ok(result)
423 }
424
425 /// Save multiple files.
426 async fn save_files(
427 &self,
428 #[zbus(object_server)] server: &zbus::ObjectServer,
429 handle: ObjectPath<'_>,
430 _app_id: &str,
431 parent_window: &str,
432 title: &str,
433 options: HashMap<&str, Value<'_>>,
434 ) -> fdo::Result<(u32, HashMap<String, Value<'static>>)> {
435 tracing::info!("SaveFiles request: handle={}, title={}, parent_window={}", handle, title, parent_window);
436
437 // SaveFiles picks a directory for saving multiple files
438 let handle_owned: OwnedObjectPath = handle.into();
439 let parent_window_id = Self::parse_parent_window(parent_window);
440 let current_folder = Self::parse_current_folder(&options);
441
442 let request = Request::new(handle_owned.clone(), self.request_manager.clone());
443 server.at(handle_owned.as_ref(), request).await
444 .map_err(|e| fdo::Error::Failed(format!("Failed to register request: {}", e)))?;
445
446 let result = self.spawn_picker(
447 handle_owned.clone(),
448 title,
449 true,
450 false,
451 Vec::new(),
452 current_folder,
453 parent_window_id,
454 ).await;
455
456 let _ = server.remove::<Request, _>(&handle_owned).await;
457
458 Ok(result)
459 }
460 }
461