Rust · 11124 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 /// Spawn garfield in picker mode and collect results.
27 async fn spawn_picker(
28 &self,
29 handle: OwnedObjectPath,
30 title: &str,
31 directory_mode: bool,
32 multiple: bool,
33 filters: Vec<String>,
34 current_folder: Option<String>,
35 ) -> (u32, HashMap<String, Value<'static>>) {
36 // Use full path to ensure we get the right garfield binary
37 // (user may have old version in ~/.cargo/bin before /usr/local/bin in PATH)
38 let garfield_path = if std::path::Path::new("/usr/local/bin/garfield").exists() {
39 "/usr/local/bin/garfield"
40 } else {
41 "garfield" // Fall back to PATH lookup
42 };
43
44 let mut cmd = Command::new(garfield_path);
45 cmd.arg("--picker");
46
47 if !title.is_empty() {
48 cmd.arg("--title").arg(title);
49 }
50
51 if directory_mode {
52 cmd.arg("--directory");
53 }
54
55 if multiple {
56 cmd.arg("--multiple");
57 }
58
59 if !filters.is_empty() {
60 cmd.arg("--filter").arg(filters.join(";"));
61 }
62
63 if let Some(folder) = current_folder {
64 cmd.arg(&folder);
65 }
66
67 cmd.stdout(Stdio::piped());
68 cmd.stderr(Stdio::inherit()); // Let garfield stderr through for debugging
69
70 tracing::info!("Spawning garfield picker: {:?}", cmd);
71
72 let mut child = match cmd.spawn() {
73 Ok(c) => c,
74 Err(e) => {
75 tracing::error!("Failed to spawn garfield: {}", e);
76 return (ResponseCode::Error as u32, HashMap::new());
77 }
78 };
79
80 tracing::info!("garfield spawned with PID {:?}", child.id());
81
82 // Get stdout handle before adding to manager (we need ownership)
83 let stdout = match child.stdout.take() {
84 Some(s) => s,
85 None => {
86 tracing::error!("Failed to get stdout from garfield");
87 return (ResponseCode::Error as u32, HashMap::new());
88 }
89 };
90
91 // Track the request for cancellation (child still runs, just stdout detached)
92 self.request_manager.add(handle.clone(), child).await;
93
94 // Wait for the child to exit by reading stdout until EOF
95 tracing::info!("Waiting for garfield to complete...");
96 let reader = BufReader::new(stdout);
97 let mut lines = reader.lines();
98 let mut paths = Vec::new();
99
100 while let Ok(Some(line)) = lines.next_line().await {
101 if !line.is_empty() {
102 tracing::debug!("garfield output: {}", line);
103 paths.push(line);
104 }
105 }
106
107 tracing::info!("garfield stdout closed, got {} paths", paths.len());
108
109 // Now remove from manager and check status
110 let request = self.request_manager.remove(&handle.as_ref()).await;
111
112 // Check if cancelled
113 if let Some(req) = &request {
114 if req.cancelled {
115 tracing::info!("Request was cancelled");
116 return (ResponseCode::Cancelled as u32, HashMap::new());
117 }
118 }
119
120 // If we got paths, it's a success
121 if paths.is_empty() {
122 tracing::info!("No paths selected, treating as cancelled");
123 (ResponseCode::Cancelled as u32, HashMap::new())
124 } else {
125 tracing::info!("Returning {} selected paths", paths.len());
126 (ResponseCode::Success as u32, build_file_chooser_response(paths))
127 }
128 }
129
130 /// Parse filter options from the portal format.
131 fn parse_filters(options: &HashMap<&str, Value<'_>>) -> Vec<String> {
132 // Portal filters are: a(sa(us)) - array of (name, array of (type, pattern))
133 // For now, we'll extract glob patterns
134 let mut result = Vec::new();
135
136 if let Some(Value::Array(filters_array)) = options.get("filters") {
137 for filter in filters_array.iter() {
138 // Each filter is (name, patterns_array)
139 if let Value::Structure(s) = filter {
140 let fields = s.fields();
141 if fields.len() >= 2 {
142 if let Value::Array(patterns) = &fields[1] {
143 for pattern in patterns.iter() {
144 // Each pattern is (type, pattern_string)
145 // type 0 = glob, type 1 = mime
146 if let Value::Structure(ps) = pattern {
147 let pfields = ps.fields();
148 if pfields.len() >= 2 {
149 if let (Value::U32(0), Value::Str(glob)) = (&pfields[0], &pfields[1]) {
150 result.push(glob.to_string());
151 }
152 }
153 }
154 }
155 }
156 }
157 }
158 }
159 }
160
161 result
162 }
163
164 /// Extract current folder from options.
165 fn parse_current_folder(options: &HashMap<&str, Value<'_>>) -> Option<String> {
166 if let Some(Value::Array(bytes)) = options.get("current_folder") {
167 // current_folder is a byte array (path as bytes)
168 let path_bytes: Vec<u8> = bytes.iter()
169 .filter_map(|v| {
170 if let Value::U8(b) = v {
171 Some(*b)
172 } else {
173 None
174 }
175 })
176 .collect();
177
178 // Remove trailing null if present
179 let path_bytes: Vec<u8> = path_bytes.into_iter()
180 .take_while(|&b| b != 0)
181 .collect();
182
183 String::from_utf8(path_bytes).ok()
184 } else {
185 None
186 }
187 }
188 }
189
190 #[interface(name = "org.freedesktop.impl.portal.FileChooser")]
191 impl FileChooser {
192 /// Open a file chooser dialog.
193 ///
194 /// Portal method for opening files.
195 async fn open_file(
196 &self,
197 #[zbus(object_server)] server: &zbus::ObjectServer,
198 handle: ObjectPath<'_>,
199 _app_id: &str,
200 _parent_window: &str,
201 title: &str,
202 options: HashMap<&str, Value<'_>>,
203 ) -> fdo::Result<(u32, HashMap<String, Value<'static>>)> {
204 tracing::info!("OpenFile request: handle={}, title={}", handle, title);
205
206 let handle_owned: OwnedObjectPath = handle.into();
207
208 // Parse options
209 let multiple = options.get("multiple")
210 .and_then(|v| if let Value::Bool(b) = v { Some(*b) } else { None })
211 .unwrap_or(false);
212
213 let directory = options.get("directory")
214 .and_then(|v| if let Value::Bool(b) = v { Some(*b) } else { None })
215 .unwrap_or(false);
216
217 let filters = Self::parse_filters(&options);
218 let current_folder = Self::parse_current_folder(&options);
219
220 tracing::debug!("Registering request object at {}", handle_owned);
221
222 // Register request object for cancellation
223 let request = Request::new(handle_owned.clone(), self.request_manager.clone());
224 server.at(handle_owned.as_ref(), request).await
225 .map_err(|e| fdo::Error::Failed(format!("Failed to register request: {}", e)))?;
226
227 tracing::debug!("Request object registered, spawning picker");
228
229 // Spawn picker and wait for result
230 let result = self.spawn_picker(
231 handle_owned.clone(),
232 title,
233 directory,
234 multiple,
235 filters,
236 current_folder,
237 ).await;
238
239 tracing::debug!("Picker returned: {:?}", result.0);
240
241 // Remove request object
242 let _ = server.remove::<Request, _>(&handle_owned).await;
243
244 tracing::info!("OpenFile returning response code {}", result.0);
245 Ok(result)
246 }
247
248 /// Save a file dialog.
249 ///
250 /// Portal method for saving files.
251 async fn save_file(
252 &self,
253 #[zbus(object_server)] server: &zbus::ObjectServer,
254 handle: ObjectPath<'_>,
255 _app_id: &str,
256 _parent_window: &str,
257 title: &str,
258 options: HashMap<&str, Value<'_>>,
259 ) -> fdo::Result<(u32, HashMap<String, Value<'static>>)> {
260 tracing::info!("SaveFile request: handle={}, title={}", handle, title);
261
262 // For now, save dialogs work like open dialogs but for directories
263 // A full implementation would show a save dialog with filename input
264 let handle_owned: OwnedObjectPath = handle.into();
265 let current_folder = Self::parse_current_folder(&options);
266
267 let request = Request::new(handle_owned.clone(), self.request_manager.clone());
268 server.at(handle_owned.as_ref(), request).await
269 .map_err(|e| fdo::Error::Failed(format!("Failed to register request: {}", e)))?;
270
271 // For save, we pick a directory and the caller handles the filename
272 let result = self.spawn_picker(
273 handle_owned.clone(),
274 title,
275 true, // directory mode for save location
276 false,
277 Vec::new(),
278 current_folder,
279 ).await;
280
281 let _ = server.remove::<Request, _>(&handle_owned).await;
282
283 Ok(result)
284 }
285
286 /// Save multiple files.
287 async fn save_files(
288 &self,
289 #[zbus(object_server)] server: &zbus::ObjectServer,
290 handle: ObjectPath<'_>,
291 _app_id: &str,
292 _parent_window: &str,
293 title: &str,
294 options: HashMap<&str, Value<'_>>,
295 ) -> fdo::Result<(u32, HashMap<String, Value<'static>>)> {
296 tracing::info!("SaveFiles request: handle={}, title={}", handle, title);
297
298 // SaveFiles picks a directory for saving multiple files
299 let handle_owned: OwnedObjectPath = handle.into();
300 let current_folder = Self::parse_current_folder(&options);
301
302 let request = Request::new(handle_owned.clone(), self.request_manager.clone());
303 server.at(handle_owned.as_ref(), request).await
304 .map_err(|e| fdo::Error::Failed(format!("Failed to register request: {}", e)))?;
305
306 let result = self.spawn_picker(
307 handle_owned.clone(),
308 title,
309 true,
310 false,
311 Vec::new(),
312 current_folder,
313 ).await;
314
315 let _ = server.remove::<Request, _>(&handle_owned).await;
316
317 Ok(result)
318 }
319 }
320