use crate::{
models::common::{DesktopEntry, enums::Filetype},
services::integration::appimage_extractor::AppImageExtractor,
utils::static_paths::UpstreamPaths,
};
#[cfg(windows)]
use anyhow::Context;
use anyhow::Result;
use std::{
fs,
path::{Path, PathBuf},
};
#[cfg(windows)]
use std::process::Command;
macro_rules! message {
($cb:expr, $($arg:tt)*) => {{
if let Some(cb) = $cb.as_mut() {
cb(&format!($($arg)*));
}
}};
}
pub struct DesktopManager<'a> {
paths: &'a UpstreamPaths,
#[cfg(unix)]
extractor: &'a AppImageExtractor,
}
impl<'a> DesktopManager<'a> {
pub fn new(paths: &'a UpstreamPaths, extractor: &'a AppImageExtractor) -> Self {
Self {
paths,
#[cfg(unix)]
extractor,
}
}
pub async fn create_entry<H>(
&self,
name: &str,
install_path: &Path,
exec_path: &Path,
icon_path: Option<&Path>,
filetype: &Filetype,
comment: Option<&str>,
categories: Option<&str>,
message_callback: &mut Option<H>,
) -> Result<PathBuf>
where
H: FnMut(&str),
{
#[cfg(unix)]
{
return self
.create_unix_desktop_entry(
name,
install_path,
exec_path,
icon_path,
filetype,
comment,
categories,
message_callback,
)
.await;
}
#[cfg(windows)]
{
let _ = (
install_path,
filetype,
comment,
categories,
message_callback,
);
return self.create_windows_shortcut(name, exec_path, icon_path);
}
}
pub fn remove_entry(paths: &UpstreamPaths, name: &str) -> Result<()> {
#[cfg(unix)]
{
let path = paths
.integration
.xdg_applications_dir
.join(format!("{}.desktop", name));
if path.exists() {
fs::remove_file(&path)?;
}
return Ok(());
}
#[cfg(windows)]
{
let path = Self::windows_shortcut_path(paths, name);
if path.exists() {
fs::remove_file(&path)?;
}
return Ok(());
}
}
#[cfg(unix)]
async fn create_unix_desktop_entry<H>(
&self,
name: &str,
install_path: &Path,
exec_path: &Path,
icon_path: Option<&Path>,
filetype: &Filetype,
comment: Option<&str>,
categories: Option<&str>,
message_callback: &mut Option<H>,
) -> Result<PathBuf>
where
H: FnMut(&str),
{
let fallback_entry = DesktopEntry {
comment: comment.map(String::from),
categories: categories.map(String::from),
..DesktopEntry::default()
};
let entry = if *filetype == Filetype::AppImage {
let squashfs_root = self
.extractor
.extract(name, install_path, message_callback)
.await?;
fallback_entry
.merge(
self.find_and_parse_desktop_file(&squashfs_root, name, message_callback)
.unwrap_or_default(),
)
.ensure_name(name)
} else {
fallback_entry.ensure_name(name)
};
let entry = entry.sanitize(exec_path, icon_path);
self.write_unix_entry(name, &entry)
}
#[cfg(unix)]
fn write_unix_entry(&self, name: &str, entry: &DesktopEntry) -> Result<PathBuf> {
let out_path = self
.paths
.integration
.xdg_applications_dir
.join(format!("{}.desktop", name));
fs::write(&out_path, entry.to_desktop_file())?;
Ok(out_path)
}
#[cfg(unix)]
fn find_and_parse_desktop_file<H>(
&self,
squashfs_root: &Path,
name: &str,
message_callback: &mut Option<H>,
) -> Option<DesktopEntry>
where
H: FnMut(&str),
{
message!(message_callback, "Searching for embedded .desktop file ...");
let candidates = [
squashfs_root.join(format!("{}.desktop", name)),
squashfs_root.join(format!("usr/share/applications/{}.desktop", name)),
];
for path in &candidates {
if path.exists() {
message!(message_callback, "Found .desktop file: {}", path.display());
return Self::parse_desktop_file(path);
}
}
let pattern = format!("{}/**/*.desktop", squashfs_root.display());
if let Ok(entries) = glob::glob(&pattern) {
let mut found: Vec<PathBuf> = entries.flatten().collect();
found.sort_by_key(|p| {
let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or("");
if stem.eq_ignore_ascii_case(name) {
0
} else {
1
}
});
if let Some(path) = found.first() {
message!(message_callback, "Found .desktop file: {}", path.display());
return Self::parse_desktop_file(path);
}
}
message!(message_callback, "No .desktop file found in AppImage");
None
}
#[cfg(unix)]
fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
let content = fs::read_to_string(path).ok()?;
let mut entry = DesktopEntry::default();
let mut in_desktop_entry = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
in_desktop_entry = trimmed.eq_ignore_ascii_case("[Desktop Entry]");
continue;
}
if !in_desktop_entry
|| trimmed.is_empty()
|| trimmed.starts_with('#')
|| trimmed.starts_with(';')
|| !trimmed.contains('=')
{
continue;
}
let Some((key, value)) = trimmed.split_once('=') else {
continue;
};
let key = key.trim().trim_start_matches('\u{feff}');
let value = value.trim().to_string();
entry.set_field(key, value);
}
Some(entry)
}
#[cfg(windows)]
fn windows_shortcut_path(paths: &UpstreamPaths, name: &str) -> PathBuf {
let shortcut_dir =
dirs::desktop_dir().unwrap_or_else(|| paths.dirs.data_dir.join("shortcuts"));
shortcut_dir.join(format!("{}.lnk", name))
}
#[cfg(windows)]
fn ps_quote(value: &str) -> String {
value.replace('\'', "''")
}
#[cfg(windows)]
fn create_windows_shortcut(
&self,
name: &str,
exec_path: &Path,
icon_path: Option<&Path>,
) -> Result<PathBuf> {
let shortcut_path = Self::windows_shortcut_path(self.paths, name);
if let Some(parent) = shortcut_path.parent() {
fs::create_dir_all(parent).context("Failed to create shortcut directory")?;
}
let target = Self::ps_quote(&exec_path.display().to_string());
let shortcut = Self::ps_quote(&shortcut_path.display().to_string());
let working_dir = exec_path
.parent()
.map(|p| Self::ps_quote(&p.display().to_string()))
.unwrap_or_default();
let mut script = vec![
"$WshShell = New-Object -ComObject WScript.Shell".to_string(),
format!("$Shortcut = $WshShell.CreateShortcut('{}')", shortcut),
format!("$Shortcut.TargetPath = '{}'", target),
];
if !working_dir.is_empty() {
script.push(format!("$Shortcut.WorkingDirectory = '{}'", working_dir));
}
if let Some(icon) = icon_path {
let icon_value = Self::ps_quote(&icon.display().to_string());
script.push(format!("$Shortcut.IconLocation = '{},0'", icon_value));
}
script.push("$Shortcut.Save()".to_string());
let status = Command::new("powershell")
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
&script.join("; "),
])
.status()
.context("Failed to execute PowerShell for shortcut creation")?;
if !status.success() {
anyhow::bail!(
"Failed to create Windows shortcut '{}' (PowerShell exit status: {})",
shortcut_path.display(),
status
);
}
Ok(shortcut_path)
}
}
#[cfg(all(test, unix))]
#[path = "../../../tests/services/integration/desktop_manager.rs"]
mod tests;