Skip to main content

upstream_rs/services/integration/
icon_manager.rs

1#[cfg(target_os = "linux")]
2use crate::services::integration::appimage_extractor::AppImageExtractor;
3use crate::{models::common::enums::Filetype, utils::static_paths::UpstreamPaths};
4use anyhow::{Result, anyhow};
5use std::fs;
6use std::path::{Path, PathBuf};
7
8macro_rules! message {
9    ($cb:expr, $($arg:tt)*) => {{
10        if let Some(cb) = $cb.as_mut() {
11            cb(&format!($($arg)*));
12        }
13    }};
14}
15
16pub struct IconManager<'a> {
17    paths: &'a UpstreamPaths,
18    #[cfg(target_os = "linux")]
19    extractor: &'a AppImageExtractor,
20}
21
22impl<'a> IconManager<'a> {
23    #[cfg(target_os = "linux")]
24    pub fn new(paths: &'a UpstreamPaths, extractor: &'a AppImageExtractor) -> Self {
25        Self { paths, extractor }
26    }
27
28    #[cfg(not(target_os = "linux"))]
29    pub fn new(paths: &'a UpstreamPaths) -> Self {
30        Self { paths }
31    }
32
33    pub async fn add_icon<H>(
34        &self,
35        name: &str,
36        path: &Path,
37        filetype: &Filetype,
38        message_callback: &mut Option<H>,
39    ) -> Result<Option<PathBuf>>
40    where
41        H: FnMut(&str),
42    {
43        let icon_path = match filetype {
44            Filetype::AppImage => {
45                #[cfg(target_os = "linux")]
46                {
47                    let squashfs_root =
48                        self.extractor.extract(name, path, message_callback).await?;
49                    Self::search_for_best_icon(&squashfs_root, name, message_callback)
50                        .or_else(|| Self::search_system_icons(name, message_callback))
51                }
52                #[cfg(not(target_os = "linux"))]
53                {
54                    anyhow::bail!("AppImage integration is only supported on Linux hosts");
55                }
56            }
57            Filetype::Archive => Self::search_for_best_icon(path, name, message_callback)
58                .or_else(|| Self::search_system_icons(name, message_callback)),
59            _ => Self::search_system_icons(name, message_callback),
60        };
61
62        let Some(icon_path) = icon_path else {
63            message!(
64                message_callback,
65                "No icon found; using empty Icon field in .desktop file"
66            );
67            return Ok(None);
68        };
69
70        self.copy_icon_to_output(&icon_path).map(Some)
71    }
72
73    fn copy_icon_to_output(&self, icon_path: &Path) -> Result<PathBuf> {
74        let filename = icon_path
75            .file_name()
76            .ok_or_else(|| anyhow!("Invalid icon path"))?;
77        let output_path = self.paths.integration.icons_dir.join(filename);
78        fs::copy(icon_path, &output_path)?;
79        Ok(output_path)
80    }
81
82    fn search_system_icons<H>(name: &str, message_callback: &mut Option<H>) -> Option<PathBuf>
83    where
84        H: FnMut(&str),
85    {
86        message!(message_callback, "Searching system icon themes …");
87
88        let home_dir = std::env::var("HOME").ok()?;
89        let icon_dirs = vec![
90            PathBuf::from(format!("{}/.local/share/icons", home_dir)),
91            PathBuf::from(format!("{}/.icons", home_dir)),
92            PathBuf::from("/usr/share/icons"),
93            PathBuf::from("/usr/local/share/icons"),
94            PathBuf::from("/usr/share/pixmaps"),
95            PathBuf::from("/usr/local/share/pixmaps"),
96        ];
97
98        let name_lower = name.to_lowercase();
99        // Also try hyphenated variant (e.g. "YouTube Music" -> "youtube-music")
100        let name_hyphen = name_lower.replace(' ', "-");
101        let name_variants: Vec<&str> = if name_hyphen != name_lower {
102            vec![&name_lower, &name_hyphen]
103        } else {
104            vec![&name_lower]
105        };
106        let extensions = [".svg", ".png", ".xpm", ".ico"];
107
108        // Strategy 1: exact matches (case-insensitive via lowercased variants)
109        for dir in &icon_dirs {
110            if !dir.exists() {
111                continue;
112            }
113            for variant in &name_variants {
114                for ext in extensions {
115                    let exact_match = dir.join(format!("{}{}", variant, ext));
116                    if exact_match.exists() {
117                        message!(
118                            message_callback,
119                            "Found system icon: {}",
120                            exact_match.display()
121                        );
122                        return Some(exact_match);
123                    }
124                }
125            }
126        }
127
128        // Strategy 2: themed subdirectories
129        message!(message_callback, "Scanning themed icon directories …");
130        let common_subdirs = [
131            "hicolor/48x48/apps",
132            "hicolor/scalable/apps",
133            "hicolor/256x256/apps",
134        ];
135        for dir in &icon_dirs {
136            for subdir in common_subdirs {
137                let theme_dir = dir.join(subdir);
138                if !theme_dir.exists() {
139                    continue;
140                }
141                for variant in &name_variants {
142                    for ext in extensions {
143                        let icon_path = theme_dir.join(format!("{}{}", variant, ext));
144                        if icon_path.exists() {
145                            message!(
146                                message_callback,
147                                "Found themed icon: {}",
148                                icon_path.display()
149                            );
150                            return Some(icon_path);
151                        }
152                    }
153                }
154            }
155        }
156
157        // Strategy 3: recursive glob — name-matched + generic fallbacks
158        message!(message_callback, "Falling back to recursive icon search …");
159        let mut all_candidates = Vec::new();
160
161        for dir in &icon_dirs {
162            if !dir.exists() {
163                continue;
164            }
165            // Name-matched globs (case-insensitive via lowercased variants)
166            for variant in &name_variants {
167                for ext in extensions {
168                    if let Ok(entries) =
169                        glob::glob(&format!("{}/**/*{}*{}", dir.display(), variant, ext))
170                    {
171                        all_candidates.extend(entries.flatten().take(50));
172                    }
173                }
174            }
175            // Generic fallbacks: anything ending in .svg or .ico
176            for ext in [".svg", ".ico"] {
177                if let Ok(entries) = glob::glob(&format!("{}/**/*{}", dir.display(), ext)) {
178                    all_candidates.extend(entries.flatten().take(20));
179                }
180            }
181            if all_candidates.len() >= 10 {
182                break;
183            }
184        }
185
186        if all_candidates.is_empty() {
187            message!(message_callback, "No system icons found");
188            return None;
189        }
190
191        all_candidates.dedup();
192        let best = all_candidates
193            .into_iter()
194            .max_by_key(|path| Self::score_icon(path, name));
195
196        if let Some(ref path) = best {
197            message!(
198                message_callback,
199                "Selected best system icon: {}",
200                path.display()
201            );
202        }
203        best
204    }
205
206    fn search_for_best_icon<H>(
207        dir: &Path,
208        name: &str,
209        message_callback: &mut Option<H>,
210    ) -> Option<PathBuf>
211    where
212        H: FnMut(&str),
213    {
214        message!(message_callback, "Searching extracted files for icons …");
215
216        let name_lower = name.to_lowercase();
217        // Also try hyphenated variant (e.g. "YouTube Music" -> "youtube-music")
218        let name_hyphen = name_lower.replace(' ', "-");
219        let name_variants: Vec<&str> = if name_hyphen != name_lower {
220            vec![&name_lower, &name_hyphen]
221        } else {
222            vec![&name_lower]
223        };
224
225        let mut all_candidates = Vec::new();
226
227        // Name-matched candidates (case-insensitive via lowercased variants)
228        for variant in &name_variants {
229            for ext in [".svg", ".png", ".xpm", ".ico"] {
230                let exact_match = dir.join(format!("{}{}", variant, ext));
231                if exact_match.exists() {
232                    all_candidates.push(exact_match);
233                }
234                if let Ok(entries) =
235                    glob::glob(&format!("{}/**/*{}*{}", dir.display(), variant, ext))
236                {
237                    all_candidates.extend(entries.flatten());
238                }
239            }
240        }
241
242        // Generic fallbacks: anything ending in .svg or .ico
243        for ext in [".svg", ".ico"] {
244            if let Ok(entries) = glob::glob(&format!("{}/**/*{}", dir.display(), ext)) {
245                all_candidates.extend(entries.flatten());
246            }
247        }
248
249        if all_candidates.is_empty() {
250            message!(message_callback, "No icons found in extracted files");
251            return None;
252        }
253
254        all_candidates.dedup();
255        let best = all_candidates
256            .into_iter()
257            .max_by_key(|path| Self::score_icon(path, name));
258
259        if let Some(ref path) = best {
260            message!(
261                message_callback,
262                "Selected extracted icon: {}",
263                path.display()
264            );
265        }
266        best
267    }
268
269    fn score_icon(path: &Path, app_name: &str) -> i32 {
270        let mut score = 0i32;
271
272        let path_str = path.to_string_lossy().to_lowercase();
273        let file_stem = path
274            .file_stem()
275            .and_then(|s| s.to_str())
276            .unwrap_or("")
277            .to_lowercase();
278
279        let name_lower = app_name.to_lowercase();
280        let name_hyphen = name_lower.replace(' ', "-");
281
282        // Format scores
283        if path_str.ends_with(".svg") {
284            score += 100;
285        } else if path_str.ends_with(".png") {
286            score += 70;
287        } else if path_str.ends_with(".ico") {
288            score += 50;
289        } else if path_str.ends_with(".xpm") {
290            score += 30;
291        }
292
293        // Name match (check both spaced and hyphenated variants)
294        if file_stem == name_lower || file_stem == name_hyphen {
295            score += 60;
296        } else if file_stem.contains(&name_lower) || file_stem.contains(&name_hyphen) {
297            score += 30;
298        }
299
300        // Generic "icon" filename bonus — rewards things like "icon.svg" or "app-icon.png"
301        // when no name match is found (fallback path)
302        if file_stem == "icon" {
303            score += 55; // near-name-match quality for a dedicated icon file
304        } else if file_stem.ends_with("-icon")
305            || file_stem.ends_with("_icon")
306            || file_stem.starts_with("icon-")
307            || file_stem.starts_with("icon_")
308        {
309            score += 40;
310        } else if file_stem.contains("icon") {
311            score += 20;
312        }
313
314        // Location bonuses
315        if path_str.contains("icons/")
316            || path_str.contains("pixmaps/")
317            || path_str.contains(".diricon")
318        {
319            score += 30;
320        }
321        if path_str.contains("/hicolor/") || path_str.contains("/theme/") {
322            score += 25;
323        }
324
325        // Penalise obviously wrong images
326        if file_stem.contains("screenshot")
327            || file_stem.contains("banner")
328            || file_stem.contains("splash")
329            || file_stem.contains("background")
330            || file_stem.contains("preview")
331        {
332            score -= 30;
333        }
334
335        // Prefer larger (but not huge) raster sizes
336        let size_indicators = ["16", "22", "24", "32", "48", "64", "128", "256", "512"];
337        for size in size_indicators {
338            if path_str.contains(&format!("{}x{}", size, size)) || file_stem.contains(size) {
339                score += 20;
340                break;
341            }
342        }
343
344        // File-size sanity
345        if let Ok(metadata) = fs::metadata(path) {
346            let size = metadata.len();
347            if size > 10_000_000 {
348                score -= 100;
349            } else if size > 1_000_000 {
350                score -= 50;
351            } else if (1024..=500_000).contains(&size) {
352                score += 10;
353            } else if size < 512 {
354                score -= 20;
355            }
356        }
357
358        score
359    }
360}