upstream_rs/services/integration/
icon_manager.rs1use 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 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 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 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}