Skip to main content

upstream_rs/services/integration/
desktop_manager.rs

1#[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    /// Build and write a Linux desktop entry.
141    ///
142    /// For AppImages, this attempts to merge metadata from an embedded
143    /// `.desktop` file before applying explicit entry overrides.
144    #[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    /// Search common and fallback locations in extracted AppImage contents for
190    /// the most relevant `.desktop` file.
191    #[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    /// Parse a `.desktop` file and extract only the `[Desktop Entry]` section.
238    #[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    /// Resolve the `.app` bundle corresponding to an installed macOS package.
279    #[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}