free_launch/
desktop_item.rs

1use egui::Image;
2use freedesktop_desktop_entry::DesktopEntry;
3use icon::{IconFile, Icons};
4use std::fs::OpenOptions;
5use std::io::Write;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8use std::sync::LazyLock;
9
10use crate::free_launch;
11use crate::launch_entry::{LaunchAction, LaunchId, Launchable};
12
13// Struct used to find and cache icons
14static ICONS: LazyLock<Icons> = LazyLock::new(|| Icons::new());
15const ICON_SIZE: u32 = 64;
16const ICON_SCALE: u32 = 1;
17const ICON_THEME: &str = "Adwaita";
18
19#[derive(Debug, Clone)]
20pub struct DesktopItem {
21    pub name: String,
22    pub exec: String,
23    pub icon_file: Option<IconFile>,
24    pub comment: Option<String>,
25    pub selected: bool,
26    pub desktop_file_path: PathBuf,
27}
28
29impl DesktopItem {
30    pub(crate) fn from_desktop_file(path: &Path) -> Option<Self> {
31        let desktop_entry = DesktopEntry::from_path(path, Some(&free_launch::LOCALES)).ok()?;
32
33        let name = desktop_entry.name(&free_launch::LOCALES)?.to_string();
34        let exec = desktop_entry.exec()?.to_string();
35        let icon = desktop_entry
36            .icon()
37            .map(|s| ICONS.find_icon(s, ICON_SIZE, ICON_SCALE, ICON_THEME))
38            .flatten();
39        let comment = desktop_entry
40            .comment(&free_launch::LOCALES)
41            .map(|s| s.to_string());
42
43        Some(DesktopItem {
44            name,
45            exec,
46            icon_file: icon,
47            comment,
48            selected: false,
49            desktop_file_path: path.to_path_buf(),
50        })
51    }
52
53    pub(crate) fn name(&self) -> &str {
54        &self.name
55    }
56
57    fn log_directory_open_error(&self, directory: &Path, error: &std::io::Error) {
58        let log_path = "/var/log/free-launch.log";
59        let timestamp = std::time::SystemTime::now()
60            .duration_since(std::time::UNIX_EPOCH)
61            .unwrap_or_default()
62            .as_secs();
63
64        let log_entry = format!(
65            "[{}] ERROR: Failed to open directory '{}' for item '{}': {}\n",
66            timestamp,
67            directory.display(),
68            self.name,
69            error
70        );
71
72        // Try to write to the log file, fall back to stderr if that fails
73        match OpenOptions::new().create(true).append(true).open(log_path) {
74            Ok(mut file) => {
75                if let Err(write_err) = file.write_all(log_entry.as_bytes()) {
76                    eprintln!("Failed to write to log file {}: {}", log_path, write_err);
77                    eprintln!("{}", log_entry.trim());
78                }
79            }
80            Err(open_err) => {
81                eprintln!("Failed to open log file {}: {}", log_path, open_err);
82                eprintln!("{}", log_entry.trim());
83            }
84        }
85    }
86
87    fn log_launch_error(&self, program: &str, args: &[&str], error: &std::io::Error) {
88        let log_path = "/var/log/free-launch.log";
89        let timestamp = std::time::SystemTime::now()
90            .duration_since(std::time::UNIX_EPOCH)
91            .unwrap_or_default()
92            .as_secs();
93
94        let log_entry = format!(
95            "[{}] ERROR: Failed to launch '{}' with args {:?} from item '{}': {}\n",
96            timestamp, program, args, self.name, error
97        );
98
99        // Try to write to the log file, fall back to stderr if that fails
100        match OpenOptions::new().create(true).append(true).open(log_path) {
101            Ok(mut file) => {
102                if let Err(write_err) = file.write_all(log_entry.as_bytes()) {
103                    eprintln!("Failed to write to log file {}: {}", log_path, write_err);
104                    eprintln!("{}", log_entry.trim());
105                }
106            }
107            Err(open_err) => {
108                eprintln!("Failed to open log file {}: {}", log_path, open_err);
109                eprintln!("{}", log_entry.trim());
110            }
111        }
112    }
113}
114
115impl LaunchId for DesktopItem {
116    fn id(&self) -> &str {
117        // Create a static string that combines "desktop", name, and first part of exec
118        // For now, we'll use a simple approach that may not be the most efficient
119        // but works for the trait requirement
120
121        // Extract first part of exec using simple whitespace splitting
122        let first_exec_part = self.exec.split_whitespace().next().unwrap_or("");
123
124        // Since we need to return &str but need to construct the string,
125        // we'll use a Box::leak approach to create a static string
126        // This is not ideal for memory usage but satisfies the trait requirement
127        let id_string = format!("desktop-{}-{}", self.name, first_exec_part);
128        Box::leak(id_string.into_boxed_str())
129    }
130
131    fn file_path(&self) -> &Path {
132        &self.desktop_file_path
133    }
134
135    fn icon_file(&self) -> Option<&IconFile> {
136        self.icon_file.as_ref()
137    }
138
139    fn comment(&self) -> Option<&str> {
140        self.comment.as_deref()
141    }
142
143    fn icon(&self) -> Option<Image> {
144        match &self.icon_file {
145            Some(file) => match file {
146                IconFile { path, file_type: _ } => Some(
147                    // TODO might want to replace lossy with returning None on error
148                    Image::from_uri(format!("file://{}", path.to_string_lossy())),
149                ),
150                // TODO handle icon types other than PNG
151            },
152            None => None,
153        }
154    }
155}
156
157impl Launchable for DesktopItem {}
158
159impl LaunchAction for DesktopItem {
160    fn launch(&self) {
161        // check for field codes: https://specifications.freedesktop.org/desktop-entry-spec/latest/exec-variables.html
162
163        // NOTE: A real implementation would need to handle desktop file field codes.
164        // For a simple case, we will just remove any common field codes.
165        // TODO properly handle field codes
166        let sanitized = self
167            .exec
168            .replace("%u", "")
169            .replace("%U", "")
170            .replace("%f", "")
171            .replace("%F", "")
172            .trim()
173            .to_string();
174
175        // Split the command string into the program and its arguments.
176        // This simple splitting won't work if there are quoted spaces or escaped characters.
177        // Consider using a shell-like parser (for example, the [`shell-words`](https://crates.io/crates/shell-words) crate) if needed.
178        // TODO use proper shell splitting
179        let mut parts = sanitized.split_whitespace();
180        if let Some(program) = parts.next() {
181            let args: Vec<&str> = parts.collect();
182
183            // Launch the process.
184            // You might want to use .spawn() as above to run it concurrently, or .output() if you need to wait for it to finish.
185            match Command::new(program).args(&args).spawn() {
186                Ok(_) => {
187                    // Command launched successfully
188                }
189                Err(e) => {
190                    // Log the error to the standard NixOS log path
191                    self.log_launch_error(program, &args, &e);
192                }
193            }
194            // TODO ensure that we wait for the command or properly detach it if we are running in daemon mode
195        };
196    }
197
198    fn open_directory(&self) {
199        if let Some(parent_dir) = &self.desktop_file_path.parent() {
200            // Try to open the directory with the default file manager
201            let result = Command::new("xdg-open").arg(parent_dir).spawn();
202
203            if let Err(e) = result {
204                self.log_directory_open_error(parent_dir, &e);
205            }
206        }
207    }
208}