Rust · 11336 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 for (i, path) in paths.iter().enumerate() {
109 tracing::info!(" path[{}]: {:?}", i, path);
110 }
111
112 // Now remove from manager and check status
113 let request = self.request_manager.remove(&handle.as_ref()).await;
114
115 // Check if cancelled
116 if let Some(req) = &request {
117 if req.cancelled {
118 tracing::info!("Request was cancelled");
119 return (ResponseCode::Cancelled as u32, HashMap::new());
120 }
121 }
122
123 // If we got paths, it's a success
124 if paths.is_empty() {
125 tracing::info!("No paths selected, treating as cancelled");
126 (ResponseCode::Cancelled as u32, HashMap::new())
127 } else {
128 tracing::info!("Returning {} selected paths", paths.len());
129 let response = build_file_chooser_response(paths);
130 tracing::info!("Response: {:?}", response);
131 (ResponseCode::Success as u32, response)
132 }
133 }
134
135 /// Parse filter options from the portal format.
136 fn parse_filters(options: &HashMap<&str, Value<'_>>) -> Vec<String> {
137 // Portal filters are: a(sa(us)) - array of (name, array of (type, pattern))
138 // For now, we'll extract glob patterns
139 let mut result = Vec::new();
140
141 if let Some(Value::Array(filters_array)) = options.get("filters") {
142 for filter in filters_array.iter() {
143 // Each filter is (name, patterns_array)
144 if let Value::Structure(s) = filter {
145 let fields = s.fields();
146 if fields.len() >= 2 {
147 if let Value::Array(patterns) = &fields[1] {
148 for pattern in patterns.iter() {
149 // Each pattern is (type, pattern_string)
150 // type 0 = glob, type 1 = mime
151 if let Value::Structure(ps) = pattern {
152 let pfields = ps.fields();
153 if pfields.len() >= 2 {
154 if let (Value::U32(0), Value::Str(glob)) = (&pfields[0], &pfields[1]) {
155 result.push(glob.to_string());
156 }
157 }
158 }
159 }
160 }
161 }
162 }
163 }
164 }
165
166 result
167 }
168
169 /// Extract current folder from options.
170 fn parse_current_folder(options: &HashMap<&str, Value<'_>>) -> Option<String> {
171 if let Some(Value::Array(bytes)) = options.get("current_folder") {
172 // current_folder is a byte array (path as bytes)
173 let path_bytes: Vec<u8> = bytes.iter()
174 .filter_map(|v| {
175 if let Value::U8(b) = v {
176 Some(*b)
177 } else {
178 None
179 }
180 })
181 .collect();
182
183 // Remove trailing null if present
184 let path_bytes: Vec<u8> = path_bytes.into_iter()
185 .take_while(|&b| b != 0)
186 .collect();
187
188 String::from_utf8(path_bytes).ok()
189 } else {
190 None
191 }
192 }
193 }
194
195 #[interface(name = "org.freedesktop.impl.portal.FileChooser")]
196 impl FileChooser {
197 /// Open a file chooser dialog.
198 ///
199 /// Portal method for opening files.
200 async fn open_file(
201 &self,
202 #[zbus(object_server)] server: &zbus::ObjectServer,
203 handle: ObjectPath<'_>,
204 _app_id: &str,
205 _parent_window: &str,
206 title: &str,
207 options: HashMap<&str, Value<'_>>,
208 ) -> fdo::Result<(u32, HashMap<String, Value<'static>>)> {
209 tracing::info!("OpenFile request: handle={}, title={}", handle, title);
210
211 let handle_owned: OwnedObjectPath = handle.into();
212
213 // Parse options
214 let multiple = options.get("multiple")
215 .and_then(|v| if let Value::Bool(b) = v { Some(*b) } else { None })
216 .unwrap_or(false);
217
218 let directory = options.get("directory")
219 .and_then(|v| if let Value::Bool(b) = v { Some(*b) } else { None })
220 .unwrap_or(false);
221
222 let filters = Self::parse_filters(&options);
223 let current_folder = Self::parse_current_folder(&options);
224
225 tracing::debug!("Registering request object at {}", handle_owned);
226
227 // Register request object for cancellation
228 let request = Request::new(handle_owned.clone(), self.request_manager.clone());
229 server.at(handle_owned.as_ref(), request).await
230 .map_err(|e| fdo::Error::Failed(format!("Failed to register request: {}", e)))?;
231
232 tracing::debug!("Request object registered, spawning picker");
233
234 // Spawn picker and wait for result
235 let result = self.spawn_picker(
236 handle_owned.clone(),
237 title,
238 directory,
239 multiple,
240 filters,
241 current_folder,
242 ).await;
243
244 tracing::debug!("Picker returned: {:?}", result.0);
245
246 // Remove request object
247 let _ = server.remove::<Request, _>(&handle_owned).await;
248
249 tracing::info!("OpenFile returning response code {}", result.0);
250 Ok(result)
251 }
252
253 /// Save a file dialog.
254 ///
255 /// Portal method for saving files.
256 async fn save_file(
257 &self,
258 #[zbus(object_server)] server: &zbus::ObjectServer,
259 handle: ObjectPath<'_>,
260 _app_id: &str,
261 _parent_window: &str,
262 title: &str,
263 options: HashMap<&str, Value<'_>>,
264 ) -> fdo::Result<(u32, HashMap<String, Value<'static>>)> {
265 tracing::info!("SaveFile request: handle={}, title={}", handle, title);
266
267 // For now, save dialogs work like open dialogs but for directories
268 // A full implementation would show a save dialog with filename input
269 let handle_owned: OwnedObjectPath = handle.into();
270 let current_folder = Self::parse_current_folder(&options);
271
272 let request = Request::new(handle_owned.clone(), self.request_manager.clone());
273 server.at(handle_owned.as_ref(), request).await
274 .map_err(|e| fdo::Error::Failed(format!("Failed to register request: {}", e)))?;
275
276 // For save, we pick a directory and the caller handles the filename
277 let result = self.spawn_picker(
278 handle_owned.clone(),
279 title,
280 true, // directory mode for save location
281 false,
282 Vec::new(),
283 current_folder,
284 ).await;
285
286 let _ = server.remove::<Request, _>(&handle_owned).await;
287
288 Ok(result)
289 }
290
291 /// Save multiple files.
292 async fn save_files(
293 &self,
294 #[zbus(object_server)] server: &zbus::ObjectServer,
295 handle: ObjectPath<'_>,
296 _app_id: &str,
297 _parent_window: &str,
298 title: &str,
299 options: HashMap<&str, Value<'_>>,
300 ) -> fdo::Result<(u32, HashMap<String, Value<'static>>)> {
301 tracing::info!("SaveFiles request: handle={}, title={}", handle, title);
302
303 // SaveFiles picks a directory for saving multiple files
304 let handle_owned: OwnedObjectPath = handle.into();
305 let current_folder = Self::parse_current_folder(&options);
306
307 let request = Request::new(handle_owned.clone(), self.request_manager.clone());
308 server.at(handle_owned.as_ref(), request).await
309 .map_err(|e| fdo::Error::Failed(format!("Failed to register request: {}", e)))?;
310
311 let result = self.spawn_picker(
312 handle_owned.clone(),
313 title,
314 true,
315 false,
316 Vec::new(),
317 current_folder,
318 ).await;
319
320 let _ = server.remove::<Request, _>(&handle_owned).await;
321
322 Ok(result)
323 }
324 }
325