Skip to main content

upstream_rs/services/integration/
icon_manager.rs

1use crate::{
2    models::common::enums::Filetype, services::integration::appimage_extractor::AppImageExtractor,
3    utils::static_paths::UpstreamPaths,
4};
5use anyhow::{Result, anyhow};
6use std::fs;
7use std::path::{Path, PathBuf};
8
9macro_rules! message {
10    ($cb:expr, $($arg:tt)*) => {{
11        if let Some(cb) = $cb.as_mut() {
12            cb(&format!($($arg)*));
13        }
14    }};
15}
16
17pub struct IconManager<'a> {
18    paths: &'a UpstreamPaths,
19    extractor: &'a AppImageExtractor,
20}
21
22impl<'a> IconManager<'a> {
23    pub fn new(paths: &'a UpstreamPaths, extractor: &'a AppImageExtractor) -> Self {
24        Self { paths, extractor }
25    }
26
27    pub async fn add_icon<H>(
28        &self,
29        name: &str,
30        path: &Path,
31        filetype: &Filetype,
32        message_callback: &mut Option<H>,
33    ) -> Result<Option<PathBuf>>
34    where
35        H: FnMut(&str),
36    {
37        let icon_path = match filetype {
38            Filetype::AppImage => {
39                let squashfs_root = self.extractor.extract(name, path, message_callback).await?;
40                Self::search_for_best_icon(&squashfs_root, name, message_callback)
41                    .or_else(|| Self::search_system_icons(name, message_callback))
42            }
43            Filetype::Archive => Self::search_for_best_icon(path, name, message_callback)
44                .or_else(|| Self::search_system_icons(name, message_callback)),
45            _ => Self::search_system_icons(name, message_callback),
46        };
47
48        let Some(icon_path) = icon_path else {
49            message!(
50                message_callback,
51                "No icon found; using empty Icon field in .desktop file"
52            );
53            return Ok(None);
54        };
55
56        self.copy_icon_to_output(&icon_path).map(Some)
57    }
58
59    fn copy_icon_to_output(&self, icon_path: &Path) -> Result<PathBuf> {
60        let filename = icon_path
61            .file_name()
62            .ok_or_else(|| anyhow!("Invalid icon path"))?;
63        let output_path = self.paths.integration.icons_dir.join(filename);
64        fs::copy(icon_path, &output_path)?;
65        Ok(output_path)
66    }
67
68    fn search_system_icons<H>(name: &str, message_callback: &mut Option<H>) -> Option<PathBuf>
69    where
70        H: FnMut(&str),
71    {
72        message!(message_callback, "Searching system icon themes …");
73        let home_dir = std::env::var("HOME").ok()?;
74        let icon_dirs = vec![
75            PathBuf::from(format!("{}/.local/share/icons", home_dir)),
76            PathBuf::from(format!("{}/.icons", home_dir)),
77            PathBuf::from("/usr/share/icons"),
78            PathBuf::from("/usr/local/share/icons"),
79            PathBuf::from("/usr/share/pixmaps"),
80            PathBuf::from("/usr/local/share/pixmaps"),
81        ];
82        let name_lower = name.to_lowercase();
83        let extensions = [".svg", ".png", ".xpm", ".ico"];
84
85        // Strategy 1: exact matches
86        for dir in &icon_dirs {
87            if !dir.exists() {
88                continue;
89            }
90            for ext in extensions {
91                let exact_match = dir.join(format!("{}{}", name, ext));
92                if exact_match.exists() {
93                    message!(
94                        message_callback,
95                        "Found system icon: {}",
96                        exact_match.display()
97                    );
98                    return Some(exact_match);
99                }
100            }
101        }
102
103        // Strategy 2: themed subdirectories
104        message!(message_callback, "Scanning themed icon directories …");
105        let common_subdirs = [
106            "hicolor/48x48/apps",
107            "hicolor/scalable/apps",
108            "hicolor/256x256/apps",
109        ];
110        for dir in &icon_dirs {
111            for subdir in common_subdirs {
112                let theme_dir = dir.join(subdir);
113                if !theme_dir.exists() {
114                    continue;
115                }
116                for ext in extensions {
117                    let icon_path = theme_dir.join(format!("{}{}", name, ext));
118                    if icon_path.exists() {
119                        message!(
120                            message_callback,
121                            "Found themed icon: {}",
122                            icon_path.display()
123                        );
124                        return Some(icon_path);
125                    }
126                }
127            }
128        }
129
130        // Strategy 3: recursive glob
131        message!(message_callback, "Falling back to recursive icon search …");
132        let mut all_candidates = Vec::new();
133        for dir in icon_dirs {
134            if !dir.exists() {
135                continue;
136            }
137            for ext in extensions {
138                if let Ok(entries) =
139                    glob::glob(&format!("{}/**/*{}*{}", dir.display(), name_lower, ext))
140                {
141                    all_candidates.extend(entries.flatten().take(50));
142                    if all_candidates.len() >= 10 {
143                        break;
144                    }
145                }
146            }
147            if all_candidates.len() >= 10 {
148                break;
149            }
150        }
151
152        if all_candidates.is_empty() {
153            message!(message_callback, "No system icons found");
154            return None;
155        }
156
157        let best = all_candidates
158            .into_iter()
159            .max_by_key(|path| Self::score_icon(path, name));
160
161        if let Some(ref path) = best {
162            message!(
163                message_callback,
164                "Selected best system icon: {}",
165                path.display()
166            );
167        }
168        best
169    }
170
171    fn search_for_best_icon<H>(
172        dir: &Path,
173        name: &str,
174        message_callback: &mut Option<H>,
175    ) -> Option<PathBuf>
176    where
177        H: FnMut(&str),
178    {
179        message!(message_callback, "Searching extracted files for icons …");
180        let mut all_candidates = Vec::new();
181
182        for ext in [".svg", ".png", ".xpm", ".ico"] {
183            let exact_match = dir.join(format!("{}{}", name, ext));
184            if exact_match.exists() {
185                all_candidates.push(exact_match);
186            }
187            if let Ok(entries) = glob::glob(&format!("{}/**/*{}*{}", dir.display(), name, ext)) {
188                all_candidates.extend(entries.flatten());
189            }
190        }
191
192        if all_candidates.is_empty() {
193            message!(message_callback, "No icons found in extracted files");
194            return None;
195        }
196
197        let best = all_candidates
198            .into_iter()
199            .max_by_key(|path| Self::score_icon(path, name));
200
201        if let Some(ref path) = best {
202            message!(
203                message_callback,
204                "Selected extracted icon: {}",
205                path.display()
206            );
207        }
208        best
209    }
210
211    fn score_icon(path: &Path, app_name: &str) -> i32 {
212        let mut score = 0;
213        let path_str = path.to_string_lossy().to_lowercase();
214        let file_stem = path
215            .file_stem()
216            .and_then(|s| s.to_str())
217            .unwrap_or("")
218            .to_lowercase();
219
220        if path_str.ends_with(".svg") {
221            score += 100;
222        } else if path_str.ends_with(".png") {
223            score += 70;
224        } else if path_str.ends_with(".ico") {
225            score += 50;
226        } else if path_str.ends_with(".xpm") {
227            score += 30;
228        }
229
230        if file_stem == app_name.to_lowercase() {
231            score += 60;
232        }
233        if file_stem.contains("icon") {
234            score += 50;
235        }
236        if path_str.contains("icons/")
237            || path_str.contains("pixmaps/")
238            || path_str.contains(".diricon")
239        {
240            score += 30;
241        }
242        if file_stem.contains("screenshot")
243            || file_stem.contains("banner")
244            || file_stem.contains("splash")
245            || file_stem.contains("background")
246            || file_stem.contains("preview")
247        {
248            score -= 30;
249        }
250
251        let size_indicators = ["16", "22", "24", "32", "48", "64", "128", "256", "512"];
252        for size in size_indicators {
253            if file_stem.contains(size) {
254                score += 20;
255                break;
256            }
257        }
258
259        if path_str.contains("/hicolor/") || path_str.contains("/theme/") {
260            score += 25;
261        }
262
263        if let Ok(metadata) = fs::metadata(path) {
264            let size = metadata.len();
265            if size > 10_000_000 {
266                score -= 100;
267            } else if size > 1_000_000 {
268                score -= 50;
269            } else if (1024..=500_000).contains(&size) {
270                score += 10;
271            } else if size < 512 {
272                score -= 20;
273            }
274        }
275
276        score
277    }
278}