upstream_rs/services/integration/
desktop_manager.rs1#[cfg(target_os = "linux")]
2use crate::services::integration::appimage_extractor::AppImageExtractor;
3use crate::{
4 models::common::{DesktopEntry, enums::Filetype},
5 utils::static_paths::UpstreamPaths,
6};
7#[cfg(windows)]
8use anyhow::Context;
9use anyhow::{Result, anyhow};
10use std::{
11 fs,
12 path::{Path, PathBuf},
13};
14
15#[cfg(windows)]
16use std::process::Command;
17
18#[cfg(any(target_os = "linux", target_os = "macos"))]
19macro_rules! message {
20 ($cb:expr, $($arg:tt)*) => {{
21 if let Some(cb) = $cb.as_mut() {
22 cb(&format!($($arg)*));
23 }
24 }};
25}
26
27pub struct DesktopManager<'a> {
28 paths: &'a UpstreamPaths,
29 #[cfg(target_os = "linux")]
30 extractor: &'a AppImageExtractor,
31}
32
33impl<'a> DesktopManager<'a> {
34 #[cfg(target_os = "linux")]
35 pub fn new(paths: &'a UpstreamPaths, extractor: &'a AppImageExtractor) -> Self {
36 Self { paths, extractor }
37 }
38
39 #[cfg(not(target_os = "linux"))]
40 pub fn new(paths: &'a UpstreamPaths) -> Self {
41 Self { paths }
42 }
43
44 pub async fn create_entry<H>(
45 &self,
46 install_path: &Path,
47 filetype: &Filetype,
48 entry: DesktopEntry,
49 message_callback: &mut Option<H>,
50 ) -> Result<PathBuf>
51 where
52 H: FnMut(&str),
53 {
54 #[cfg(target_os = "linux")]
55 {
56 return self
57 .create_unix_desktop_entry(install_path, filetype, entry, message_callback)
58 .await;
59 }
60
61 #[cfg(target_os = "macos")]
62 {
63 let name = entry
64 .name
65 .as_deref()
66 .ok_or_else(|| anyhow!("Desktop entry name is required"))?;
67 let _ = (&entry.icon, &entry.comment, &entry.categories);
68 let exec_path = entry
69 .exec
70 .as_deref()
71 .map(Path::new)
72 .ok_or_else(|| anyhow!("Desktop entry exec path is required"))?;
73
74 return self.create_macos_launcher(
75 name,
76 install_path,
77 exec_path,
78 filetype,
79 message_callback,
80 );
81 }
82
83 #[cfg(windows)]
84 {
85 let name = entry
86 .name
87 .as_deref()
88 .ok_or_else(|| anyhow!("Desktop entry name is required"))?;
89 let _ = (install_path, filetype, message_callback);
90 let exec_path = entry
91 .exec
92 .as_deref()
93 .map(Path::new)
94 .ok_or_else(|| anyhow!("Desktop entry exec path is required"))?;
95 let icon_path = entry
96 .icon
97 .as_deref()
98 .filter(|icon| !icon.is_empty())
99 .map(Path::new);
100
101 return self.create_windows_shortcut(name, exec_path, icon_path);
102 }
103 }
104
105 pub fn remove_entry(paths: &UpstreamPaths, name: &str) -> Result<()> {
106 #[cfg(target_os = "linux")]
107 {
108 let path = paths
109 .integration
110 .xdg_applications_dir
111 .join(format!("{}.desktop", name));
112 if path.exists() {
113 fs::remove_file(&path)?;
114 }
115 Ok(())
116 }
117
118 #[cfg(target_os = "macos")]
119 {
120 let path = Self::macos_launcher_path(paths, name);
121 if path.exists() {
122 let metadata = fs::symlink_metadata(&path)?;
123 if metadata.file_type().is_symlink() {
124 fs::remove_file(&path)?;
125 }
126 }
127 return Ok(());
128 }
129
130 #[cfg(windows)]
131 {
132 let path = Self::windows_shortcut_path(paths, name);
133 if path.exists() {
134 fs::remove_file(&path)?;
135 }
136 return Ok(());
137 }
138 }
139
140 #[cfg(target_os = "linux")]
145 async fn create_unix_desktop_entry<H>(
146 &self,
147 install_path: &Path,
148 filetype: &Filetype,
149 mut entry: DesktopEntry,
150 message_callback: &mut Option<H>,
151 ) -> Result<PathBuf>
152 where
153 H: FnMut(&str),
154 {
155 let name = entry
156 .name
157 .as_deref()
158 .ok_or_else(|| anyhow!("Desktop entry name is required"))?
159 .to_string();
160
161 entry = if *filetype == Filetype::AppImage {
162 let squashfs_root = self
163 .extractor
164 .extract(&name, install_path, message_callback)
165 .await?;
166 self.find_and_parse_desktop_file(&squashfs_root, &name, message_callback)
167 .unwrap_or_default()
168 .merge(entry)
169 .ensure_name(&name)
170 } else {
171 entry.ensure_name(&name)
172 };
173
174 entry.terminal = false;
175 self.write_unix_entry(&name, &entry)
176 }
177
178 #[cfg(target_os = "linux")]
179 fn write_unix_entry(&self, name: &str, entry: &DesktopEntry) -> Result<PathBuf> {
180 let out_path = self
181 .paths
182 .integration
183 .xdg_applications_dir
184 .join(format!("{}.desktop", name));
185 fs::write(&out_path, entry.to_desktop_file())?;
186 Ok(out_path)
187 }
188
189 #[cfg(target_os = "linux")]
192 fn find_and_parse_desktop_file<H>(
193 &self,
194 squashfs_root: &Path,
195 name: &str,
196 message_callback: &mut Option<H>,
197 ) -> Option<DesktopEntry>
198 where
199 H: FnMut(&str),
200 {
201 message!(message_callback, "Searching for embedded .desktop file ...");
202
203 let candidates = [
204 squashfs_root.join(format!("{}.desktop", name)),
205 squashfs_root.join(format!("usr/share/applications/{}.desktop", name)),
206 ];
207
208 for path in &candidates {
209 if path.exists() {
210 message!(message_callback, "Found .desktop file: {}", path.display());
211 return Self::parse_desktop_file(path);
212 }
213 }
214
215 let pattern = format!("{}/**/*.desktop", squashfs_root.display());
216 if let Ok(entries) = glob::glob(&pattern) {
217 let mut found: Vec<PathBuf> = entries.flatten().collect();
218 found.sort_by_key(|p| {
219 let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or("");
220 if stem.eq_ignore_ascii_case(name) {
221 0
222 } else {
223 1
224 }
225 });
226
227 if let Some(path) = found.first() {
228 message!(message_callback, "Found .desktop file: {}", path.display());
229 return Self::parse_desktop_file(path);
230 }
231 }
232
233 message!(message_callback, "No .desktop file found in AppImage");
234 None
235 }
236
237 #[cfg(target_os = "linux")]
239 fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
240 let content = fs::read_to_string(path).ok()?;
241 let mut entry = DesktopEntry::default();
242 let mut in_desktop_entry = false;
243
244 for line in content.lines() {
245 let trimmed = line.trim();
246 if trimmed.starts_with('[') {
247 in_desktop_entry = trimmed.eq_ignore_ascii_case("[Desktop Entry]");
248 continue;
249 }
250
251 if !in_desktop_entry
252 || trimmed.is_empty()
253 || trimmed.starts_with('#')
254 || trimmed.starts_with(';')
255 || !trimmed.contains('=')
256 {
257 continue;
258 }
259 let Some((key, value)) = trimmed.split_once('=') else {
260 continue;
261 };
262 let key = key.trim().trim_start_matches('\u{feff}');
263 let value = value.trim().to_string();
264 entry.set_field(key, value);
265 }
266
267 Some(entry)
268 }
269
270 #[cfg(target_os = "macos")]
271 fn macos_launcher_path(paths: &UpstreamPaths, name: &str) -> PathBuf {
272 let apps_dir = dirs::home_dir()
273 .unwrap_or_else(|| paths.dirs.user_dir.clone())
274 .join("Applications");
275 apps_dir.join(format!("{name}.app"))
276 }
277
278 #[cfg(target_os = "macos")]
280 fn find_app_bundle_path(
281 install_path: &Path,
282 exec_path: &Path,
283 filetype: &Filetype,
284 ) -> Option<PathBuf> {
285 use std::ffi::OsStr;
286
287 if matches!(filetype, Filetype::MacApp)
288 && install_path.extension() == Some(OsStr::new("app"))
289 {
290 return Some(install_path.to_path_buf());
291 }
292
293 if install_path.extension() == Some(OsStr::new("app")) {
294 return Some(install_path.to_path_buf());
295 }
296
297 for candidate in [exec_path, install_path] {
298 if let Some(bundle) = candidate
299 .ancestors()
300 .find(|ancestor| ancestor.extension() == Some(OsStr::new("app")))
301 {
302 return Some(bundle.to_path_buf());
303 }
304 }
305
306 None
307 }
308
309 #[cfg(target_os = "macos")]
310 fn create_macos_launcher<H>(
311 &self,
312 name: &str,
313 install_path: &Path,
314 exec_path: &Path,
315 filetype: &Filetype,
316 message_callback: &mut Option<H>,
317 ) -> Result<PathBuf>
318 where
319 H: FnMut(&str),
320 {
321 let app_bundle = Self::find_app_bundle_path(install_path, exec_path, filetype)
322 .ok_or_else(|| anyhow!("Could not locate a .app bundle for '{}'", name))?;
323
324 if !app_bundle.exists() || !app_bundle.is_dir() {
325 return Err(anyhow!(
326 "Resolved .app bundle '{}' does not exist or is not a directory",
327 app_bundle.display()
328 ));
329 }
330
331 let launcher_path = Self::macos_launcher_path(self.paths, name);
332 if let Some(parent) = launcher_path.parent() {
333 fs::create_dir_all(parent)?;
334 }
335
336 if launcher_path.exists() {
337 let metadata = fs::symlink_metadata(&launcher_path)?;
338 if metadata.file_type().is_symlink() {
339 fs::remove_file(&launcher_path)?;
340 } else {
341 return Err(anyhow!(
342 "Refusing to overwrite non-symlink at '{}'",
343 launcher_path.display()
344 ));
345 }
346 }
347
348 std::os::unix::fs::symlink(&app_bundle, &launcher_path)?;
349 message!(
350 message_callback,
351 "Created macOS launcher: {} -> {}",
352 launcher_path.display(),
353 app_bundle.display()
354 );
355
356 Ok(launcher_path)
357 }
358
359 #[cfg(windows)]
360 fn windows_shortcut_path(paths: &UpstreamPaths, name: &str) -> PathBuf {
361 let shortcut_dir =
362 dirs::desktop_dir().unwrap_or_else(|| paths.dirs.data_dir.join("shortcuts"));
363 shortcut_dir.join(format!("{}.lnk", name))
364 }
365
366 #[cfg(windows)]
367 fn ps_quote(value: &str) -> String {
368 value.replace('\'', "''")
369 }
370
371 #[cfg(windows)]
372 fn create_windows_shortcut(
373 &self,
374 name: &str,
375 exec_path: &Path,
376 icon_path: Option<&Path>,
377 ) -> Result<PathBuf> {
378 let shortcut_path = Self::windows_shortcut_path(self.paths, name);
379 if let Some(parent) = shortcut_path.parent() {
380 fs::create_dir_all(parent).context("Failed to create shortcut directory")?;
381 }
382
383 let target = Self::ps_quote(&exec_path.display().to_string());
384 let shortcut = Self::ps_quote(&shortcut_path.display().to_string());
385 let working_dir = exec_path
386 .parent()
387 .map(|p| Self::ps_quote(&p.display().to_string()))
388 .unwrap_or_default();
389
390 let mut script = vec![
391 "$WshShell = New-Object -ComObject WScript.Shell".to_string(),
392 format!("$Shortcut = $WshShell.CreateShortcut('{}')", shortcut),
393 format!("$Shortcut.TargetPath = '{}'", target),
394 ];
395
396 if !working_dir.is_empty() {
397 script.push(format!("$Shortcut.WorkingDirectory = '{}'", working_dir));
398 }
399
400 if let Some(icon) = icon_path {
401 let icon_value = Self::ps_quote(&icon.display().to_string());
402 script.push(format!("$Shortcut.IconLocation = '{},0'", icon_value));
403 }
404
405 script.push("$Shortcut.Save()".to_string());
406
407 let status = Command::new("powershell")
408 .args([
409 "-NoProfile",
410 "-NonInteractive",
411 "-ExecutionPolicy",
412 "Bypass",
413 "-Command",
414 &script.join("; "),
415 ])
416 .status()
417 .context("Failed to execute PowerShell for shortcut creation")?;
418
419 if !status.success() {
420 anyhow::bail!(
421 "Failed to create Windows shortcut '{}' (PowerShell exit status: {})",
422 shortcut_path.display(),
423 status
424 );
425 }
426
427 Ok(shortcut_path)
428 }
429}
430
431#[cfg(all(test, target_os = "linux"))]
432mod tests {
433 use super::DesktopManager;
434 use crate::models::common::DesktopEntry;
435 use std::path::{Path, PathBuf};
436 use std::time::{SystemTime, UNIX_EPOCH};
437 use std::{fs, io};
438
439 fn temp_root(name: &str) -> PathBuf {
440 let nanos = SystemTime::now()
441 .duration_since(UNIX_EPOCH)
442 .map(|d| d.as_nanos())
443 .unwrap_or(0);
444 std::env::temp_dir().join(format!("upstream-desktop-manager-test-{name}-{nanos}"))
445 }
446
447 fn cleanup(path: &Path) -> io::Result<()> {
448 fs::remove_dir_all(path)
449 }
450
451 fn fixture_path(relative: &str) -> PathBuf {
452 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
453 .join("tests")
454 .join("fixtures")
455 .join(relative)
456 }
457
458 #[cfg(target_os = "linux")]
459 #[test]
460 fn parse_desktop_file_reads_valid_fixture() {
461 let desktop_file = fixture_path("integration/desktop/tool-valid.desktop");
462
463 let entry = DesktopManager::parse_desktop_file(&desktop_file).expect("parse desktop file");
464
465 assert_eq!(entry.name.as_deref(), Some("Tool"));
466 assert_eq!(entry.exec.as_deref(), Some("/usr/bin/tool"));
467 assert_eq!(entry.icon.as_deref(), Some("tool"));
468 }
469
470 #[test]
471 fn parse_desktop_file_preserves_localized_and_extra_fields() {
472 let root = temp_root("parse");
473 fs::create_dir_all(&root).expect("create temp root");
474 let desktop_file = root.join("app.desktop");
475
476 fs::write(
477 &desktop_file,
478 r#"
479 Name=ignored-outside-section
480 [Desktop Entry]
481 Name=KDE Connect
482 Name[fr]=KDEConnect
483 GenericName=Device Synchronization
484 Comment=Make all your devices one
485 Exec=kdeconnect-app
486 Icon=kdeconnect
487 Type=Application
488 Terminal=false
489 Categories=Qt;KDE;Network
490 X-AppImage-Name=KDE_Connect
491
492 [Desktop Action New]
493 Name=ignored-action
494 "#,
495 )
496 .expect("write desktop file");
497
498 let entry = DesktopManager::parse_desktop_file(&desktop_file).expect("parse desktop file");
499
500 assert_eq!(entry.name.as_deref(), Some("KDE Connect"));
501 assert_eq!(entry.comment.as_deref(), Some("Make all your devices one"));
502 assert_eq!(entry.exec.as_deref(), Some("kdeconnect-app"));
503 assert_eq!(entry.icon.as_deref(), Some("kdeconnect"));
504 assert_eq!(entry.categories.as_deref(), Some("Qt;KDE;Network"));
505 assert!(!entry.terminal);
506
507 assert_eq!(
508 entry.extras.get("Name[fr]").map(String::as_str),
509 Some("KDEConnect")
510 );
511 assert_eq!(
512 entry.extras.get("GenericName").map(String::as_str),
513 Some("Device Synchronization")
514 );
515 assert_eq!(
516 entry.extras.get("X-AppImage-Name").map(String::as_str),
517 Some("KDE_Connect")
518 );
519
520 cleanup(&root).expect("cleanup");
521 }
522
523 #[test]
524 fn ensure_name_prefers_localized_then_fallback() {
525 let mut localized_only = DesktopEntry::default();
526 localized_only.set_field("Name[en_GB]", "Localized App".to_string());
527
528 let localized_resolved = localized_only.ensure_name("fallback-name");
529 assert_eq!(localized_resolved.name.as_deref(), Some("Localized App"));
530
531 let fallback_resolved = DesktopEntry::default().ensure_name("fallback-name");
532 assert_eq!(fallback_resolved.name.as_deref(), Some("fallback-name"));
533 }
534
535 #[test]
536 fn serialize_preserves_extras_and_sanitize_overrides_exec_icon_terminal() {
537 let mut entry = DesktopEntry::default();
538 entry.set_field("Name[en_GB]", "Localized App".to_string());
539 entry.set_field("X-AppImage-Version", "25.12.2-1".to_string());
540 entry.set_field("Exec", "embedded-exec".to_string());
541 entry.set_field("Icon", "embedded-icon".to_string());
542 entry.set_field("Terminal", "true".to_string());
543
544 let rendered = entry
545 .ensure_name("fallback-name")
546 .sanitize(Path::new("/tmp/upstream-bin"), None)
547 .to_desktop_file();
548
549 assert!(rendered.contains("Name=Localized App\n"));
550 assert!(rendered.contains("Exec=/tmp/upstream-bin\n"));
551 assert!(rendered.contains("Icon=\n"));
552 assert!(rendered.contains("Terminal=false\n"));
553 assert!(rendered.contains("Name[en_GB]=Localized App\n"));
554 assert!(rendered.contains("X-AppImage-Version=25.12.2-1\n"));
555 }
556}