free_launch/
desktop_item.rs

1use egui::Image;
2use freedesktop_desktop_entry::DesktopEntry;
3use serde_yaml::to_string;
4use std::fs::OpenOptions;
5use std::io::Write;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8use tracing::{error, info};
9
10use crate::free_launch;
11use crate::launch_entry::{LaunchAction, LaunchId, Launchable};
12
13#[derive(Debug, Clone)]
14pub struct DesktopItem {
15    pub name: String,
16    pub exec: String,
17    pub icon: Option<String>,
18    pub comment: Option<String>,
19    pub selected: bool,
20    pub desktop_file_path: PathBuf,
21}
22
23impl DesktopItem {
24    // TODO take a DesktopEntry directly
25    pub(crate) fn from_desktop_file(path: &Path) -> Option<Self> {
26        let desktop_entry = DesktopEntry::from_path(path, Some(&free_launch::LOCALES)).ok()?;
27
28        // Check that this desktop entry is valid
29        // More info here: https://specifications.freedesktop.org/desktop-entry-spec/latest/recognized-keys.html
30
31        // Hidden: Hidden should have been called Deleted. It means the user deleted (at their level) something that was present (at an upper level, e.g. in the system dirs). It's strictly equivalent to the .desktop file not existing at all, as far as that user is concerned.
32        if desktop_entry.hidden() {
33            return None;
34        }
35
36        // NoDisplay: NoDisplay means "this application exists, but don't display it in the menus". This can be useful to e.g. associate this application with MIME types, so that it gets launched from a file manager (or other apps), without having a menu entry for it (there are tons of good reasons for this, including e.g. the netscape -remote, or kfmclient openURL kind of stuff).
37        if desktop_entry.no_display() {
38            return None;
39        }
40
41        // Exec: Program to execute, possibly with arguments.
42        // might want to skip items where this is blank or "false"
43
44        let name = desktop_entry.name(&free_launch::LOCALES)?.to_string();
45        let exec = desktop_entry.exec()?.to_string();
46        // TODO use the IconCache exclusively
47        let icon = desktop_entry.icon().map(|i| to_string(i).ok()).flatten();
48        let comment = desktop_entry
49            .comment(&free_launch::LOCALES)
50            .map(|s| s.to_string());
51
52        Some(DesktopItem {
53            name,
54            exec,
55            icon,
56            comment,
57            selected: false,
58            desktop_file_path: path.to_path_buf(),
59        })
60    }
61
62    pub(crate) fn name(&self) -> &str {
63        &self.name
64    }
65
66    // TODO consider logging via the tracing crate
67    fn log_directory_open_error(&self, directory: &Path, error: &std::io::Error) {
68        let log_path = "/var/log/free-launch.log";
69        let timestamp = std::time::SystemTime::now()
70            .duration_since(std::time::UNIX_EPOCH)
71            .unwrap_or_default()
72            .as_secs();
73
74        let log_entry = format!(
75            "[{}] ERROR: Failed to open directory '{}' for item '{}': {}\n",
76            timestamp,
77            directory.display(),
78            self.name,
79            error
80        );
81
82        // Try to write to the log file, fall back to stderr if that fails
83        match OpenOptions::new().create(true).append(true).open(log_path) {
84            Ok(mut file) => {
85                if let Err(write_err) = file.write_all(log_entry.as_bytes()) {
86                    error!(
87                        "Failed to write to log file {}: {}: {}",
88                        log_path,
89                        write_err,
90                        log_entry.trim()
91                    );
92                }
93            }
94            Err(open_err) => {
95                error!(
96                    "Failed to open log file {}: {}: {}",
97                    log_path,
98                    open_err,
99                    log_entry.trim()
100                );
101            }
102        }
103    }
104
105    // TODO consider logging via the tracing crate
106    fn log_launch_error(&self, program: &str, args: &[&str], error: &std::io::Error) {
107        let log_path = "/var/log/free-launch.log";
108        let timestamp = std::time::SystemTime::now()
109            .duration_since(std::time::UNIX_EPOCH)
110            .unwrap_or_default()
111            .as_secs();
112
113        let log_entry = format!(
114            "[{}] ERROR: Failed to launch '{}' with args {:?} from item '{}': {}\n",
115            timestamp, program, args, self.name, error
116        );
117
118        // Try to write to the log file, fall back to stderr if that fails
119        match OpenOptions::new().create(true).append(true).open(log_path) {
120            Ok(mut file) => {
121                if let Err(write_err) = file.write_all(log_entry.as_bytes()) {
122                    error!(
123                        "Failed to write to log file {}: {}: {}",
124                        log_path,
125                        write_err,
126                        log_entry.trim()
127                    );
128                }
129            }
130            Err(open_err) => {
131                error!(
132                    "Failed to open log file {}: {}: {}",
133                    log_path,
134                    open_err,
135                    log_entry.trim()
136                );
137            }
138        }
139    }
140
141    fn exec_name(&self) -> Option<&str> {
142        self.exec
143            .split_whitespace()
144            .next()
145            .and_then(|e| e.rsplit('/').next())
146    }
147
148    fn path_name(&self) -> Option<&str> {
149        self.desktop_file_path
150            .iter()
151            .last()
152            .and_then(|f| f.to_str())
153    }
154}
155
156impl LaunchId for DesktopItem {
157    /// Returns an ID for correlating Invocations and DesktopItems that combines "desktop", name, and the pathname of the desktop file
158    fn id(&self) -> String {
159        // Extract first part of exec using simple whitespace splitting
160        let exec_name = self.path_name().unwrap_or("NONE");
161
162        format!("desktop-{}-{}", self.name, exec_name)
163    }
164
165    fn file_path(&self) -> &Path {
166        &self.desktop_file_path
167    }
168
169    fn icon_name(&self) -> Option<&str> {
170        self.icon.as_deref()
171    }
172
173    fn comment(&self) -> Option<&str> {
174        self.comment.as_deref()
175    }
176
177    fn debug_ui(&self, ui: &mut egui::Ui, count: usize) {
178        ui.label(format!("  [{}] ID: {}", count, self.id()));
179        ui.label(format!("      Name: {}", self.name));
180        ui.label(format!("      Exec: {}", self.exec));
181        ui.label(format!("      Path: {}", self.file_path().display()));
182        if let Some(comment) = self.comment() {
183            ui.label(format!("      Comment: {}", comment));
184        }
185        ui.label(format!("      Icon: {:?}", self.icon_name()));
186    }
187}
188
189impl Launchable for DesktopItem {}
190
191impl LaunchAction for DesktopItem {
192    fn launch(&self) {
193        // check for field codes: https://specifications.freedesktop.org/desktop-entry-spec/latest/exec-variables.html
194
195        // NOTE: A real implementation would need to handle desktop file field codes.
196        // For a simple case, we will just remove any common field codes.
197        // TODO properly handle field codes
198        let sanitized = self
199            .exec
200            .replace("%u", "")
201            .replace("%U", "")
202            .replace("%f", "")
203            .replace("%F", "")
204            .trim()
205            .to_string();
206
207        // Split the command string into the program and its arguments.
208        // This simple splitting won't work if there are quoted spaces or escaped characters.
209        // Consider using a shell-like parser (for example, the [`shell-words`](https://crates.io/crates/shell-words) crate) if needed.
210        info!("Launching item: {}", sanitized);
211        // TODO use proper shell splitting
212        let mut parts = sanitized.split_whitespace();
213        if let Some(program) = parts.next() {
214            let args: Vec<&str> = parts.collect();
215
216            // Launch the process.
217            // You might want to use .spawn() as above to run it concurrently, or .output() if you need to wait for it to finish.
218            match Command::new(program).args(&args).spawn() {
219                Ok(_) => {
220                    // Command launched successfully
221                }
222                Err(e) => {
223                    // Log the error to the standard NixOS log path
224                    self.log_launch_error(program, &args, &e);
225                }
226            }
227            // TODO ensure that we wait for the command or properly detach it if we are running in daemon mode
228        };
229    }
230
231    fn open_directory(&self) {
232        if let Some(parent_dir) = &self.desktop_file_path.parent() {
233            // Try to open the directory with the default file manager
234            let result = Command::new("xdg-open").arg(parent_dir).spawn();
235
236            if let Err(e) = result {
237                self.log_directory_open_error(parent_dir, &e);
238            }
239        }
240    }
241}