upstream_rs/services/integration/
icon_manager.rs1#[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 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 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 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 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 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 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 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 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 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 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 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 if file_stem == "icon" {
303 score += 55; } 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 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 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 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 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}