soar_core/package/formats/
common.rs

1use std::{
2    env,
3    ffi::OsStr,
4    fs::{self, File},
5    io::{BufReader, BufWriter, Write},
6    path::{Path, PathBuf},
7};
8
9use image::{imageops::FilterType, DynamicImage, GenericImageView};
10use regex::Regex;
11use soar_dl::downloader::{DownloadOptions, Downloader};
12use soar_dl::utils::FileMode;
13
14use crate::{
15    config::get_config,
16    constants::PNG_MAGIC_BYTES,
17    database::models::{Package, PackageExt},
18    error::{ErrorContext, SoarError},
19    utils::{calc_magic_bytes, create_symlink, home_data_path, process_dir},
20    SoarResult,
21};
22
23use super::{
24    appimage::integrate_appimage, get_file_type, wrappe::setup_wrappe_portable_dir, PackageFormat,
25};
26
27const SUPPORTED_DIMENSIONS: &[(u32, u32)] = &[
28    (16, 16),
29    (24, 24),
30    (32, 32),
31    (48, 48),
32    (64, 64),
33    (72, 72),
34    (80, 80),
35    (96, 96),
36    (128, 128),
37    (192, 192),
38    (256, 256),
39    (512, 512),
40];
41
42fn find_nearest_supported_dimension(width: u32, height: u32) -> (u32, u32) {
43    SUPPORTED_DIMENSIONS
44        .iter()
45        .min_by_key(|&&(w, h)| {
46            let width_diff = (w as i32 - width as i32).abs();
47            let height_diff = (h as i32 - height as i32).abs();
48            width_diff + height_diff
49        })
50        .cloned()
51        .unwrap_or((width, height))
52}
53
54fn normalize_image(image: DynamicImage) -> DynamicImage {
55    let (width, height) = image.dimensions();
56    let (new_width, new_height) = find_nearest_supported_dimension(width, height);
57
58    if (width, height) != (new_width, new_height) {
59        image.resize(new_width, new_height, FilterType::Lanczos3)
60    } else {
61        image
62    }
63}
64
65pub fn symlink_icon<P: AsRef<Path>>(real_path: P) -> SoarResult<PathBuf> {
66    let real_path = real_path.as_ref();
67    let icon_name = real_path.file_stem().unwrap();
68    let ext = real_path.extension();
69
70    let (w, h) = if ext == Some(OsStr::new("svg")) {
71        (128, 128)
72    } else {
73        let image = image::open(real_path)?;
74        let (orig_w, orig_h) = image.dimensions();
75
76        let normalized_image = normalize_image(image);
77        let (w, h) = normalized_image.dimensions();
78
79        if (w, h) != (orig_w, orig_h) {
80            normalized_image.save(real_path)?;
81        }
82
83        (w, h)
84    };
85
86    let final_path = PathBuf::from(format!(
87        "{}/icons/hicolor/{w}x{h}/apps/{}-soar.{}",
88        home_data_path(),
89        icon_name.to_string_lossy(),
90        ext.unwrap_or_default().to_string_lossy()
91    ));
92
93    create_symlink(real_path, &final_path)?;
94    Ok(final_path)
95}
96
97pub fn symlink_desktop<P: AsRef<Path>, T: PackageExt>(
98    real_path: P,
99    package: &T,
100) -> SoarResult<PathBuf> {
101    let pkg_name = package.pkg_name();
102    let real_path = real_path.as_ref();
103    let content = fs::read_to_string(real_path)
104        .with_context(|| format!("reading content of desktop file: {}", real_path.display()))?;
105    let file_name = real_path.file_stem().unwrap();
106
107    let final_content = {
108        let re = Regex::new(r"(?m)^(Icon|Exec|TryExec)=(.*)").unwrap();
109
110        re.replace_all(&content, |caps: &regex::Captures| match &caps[1] {
111            "Icon" => format!("Icon={}-soar", file_name.to_string_lossy()),
112            "Exec" | "TryExec" => {
113                let value = &caps[0];
114                let bin_path = get_config().get_bin_path().unwrap();
115                let new_value = format!("{}/{}", &bin_path.display(), pkg_name);
116
117                if value.contains("{{pkg_path}}") {
118                    value.replace("{{pkg_path}}", &new_value)
119                } else {
120                    format!("{}={}", &caps[1], new_value)
121                }
122            }
123            _ => unreachable!(),
124        })
125        .to_string()
126    };
127
128    let mut writer = BufWriter::new(
129        File::create(real_path)
130            .with_context(|| format!("creating desktop file {}", real_path.display()))?,
131    );
132    writer
133        .write_all(final_content.as_bytes())
134        .with_context(|| format!("writing desktop file to {}", real_path.display()))?;
135
136    let final_path = PathBuf::from(format!(
137        "{}/applications/{}-soar.desktop",
138        home_data_path(),
139        file_name.to_string_lossy()
140    ));
141
142    create_symlink(real_path, &final_path)?;
143    Ok(final_path)
144}
145
146pub async fn integrate_remote<P: AsRef<Path>>(
147    package_path: P,
148    package: &Package,
149) -> SoarResult<()> {
150    let package_path = package_path.as_ref();
151    let icon_url = &package.icon;
152    let desktop_url = &package.desktop;
153
154    let mut icon_output_path = package_path.join(".DirIcon");
155    let desktop_output_path = package_path.join(format!("{}.desktop", package.pkg_name));
156
157    let downloader = Downloader::default();
158
159    if let Some(icon_url) = icon_url {
160        let options = DownloadOptions {
161            url: icon_url.clone(),
162            output_path: Some(icon_output_path.to_string_lossy().to_string()),
163            progress_callback: None,
164            extract_archive: false,
165            extract_dir: None,
166            file_mode: FileMode::SkipExisting,
167            prompt: None,
168        };
169        downloader.download(options).await?;
170
171        let ext = if calc_magic_bytes(icon_output_path, 8)? == PNG_MAGIC_BYTES {
172            "png"
173        } else {
174            "svg"
175        };
176        icon_output_path = package_path.join(format!("{}.{}", package.pkg_name, ext));
177    }
178
179    if let Some(desktop_url) = desktop_url {
180        let options = DownloadOptions {
181            url: desktop_url.clone(),
182            output_path: Some(desktop_output_path.to_string_lossy().to_string()),
183            progress_callback: None,
184            extract_archive: false,
185            extract_dir: None,
186            file_mode: FileMode::SkipExisting,
187            prompt: None,
188        };
189        downloader.download(options).await?;
190    } else {
191        let content = create_default_desktop_entry(&package.pkg_name, "Utility");
192        fs::write(&desktop_output_path, &content).with_context(|| {
193            format!("writing to desktop file {}", desktop_output_path.display())
194        })?;
195    }
196
197    symlink_icon(&icon_output_path)?;
198    symlink_desktop(&desktop_output_path, package)?;
199
200    Ok(())
201}
202
203pub fn create_portable_link<P: AsRef<Path>>(
204    portable_path: P,
205    real_path: P,
206    pkg_name: &str,
207    extension: &str,
208) -> SoarResult<()> {
209    let base_dir = env::current_dir()
210        .map_err(|_| SoarError::Custom("Error retrieving current directory".into()))?;
211    let portable_path = portable_path.as_ref();
212    let portable_path = if portable_path.is_absolute() {
213        portable_path
214    } else {
215        &base_dir.join(portable_path)
216    };
217    let portable_path = portable_path.join(pkg_name).with_extension(extension);
218
219    fs::create_dir_all(&portable_path)
220        .with_context(|| format!("creating directory {}", portable_path.display()))?;
221    create_symlink(&portable_path, &real_path.as_ref().to_path_buf())?;
222    Ok(())
223}
224
225pub fn setup_portable_dir<P: AsRef<Path>, T: PackageExt>(
226    bin_path: P,
227    package: &T,
228    portable: Option<&str>,
229    portable_home: Option<&str>,
230    portable_config: Option<&str>,
231    portable_share: Option<&str>,
232    portable_cache: Option<&str>,
233) -> SoarResult<()> {
234    let portable_dir_base = get_config().get_portable_dirs()?.join(format!(
235        "{}-{}",
236        package.pkg_name(),
237        package.pkg_id()
238    ));
239    let bin_path = bin_path.as_ref();
240
241    let pkg_name = package.pkg_name();
242    let pkg_config = bin_path.with_extension("config");
243    let pkg_home = bin_path.with_extension("home");
244    let pkg_share = bin_path.with_extension("share");
245    let pkg_cache = bin_path.with_extension("cache");
246
247    let (portable_home, portable_config, portable_share, portable_cache) =
248        if let Some(portable) = portable {
249            (
250                Some(portable),
251                Some(portable),
252                Some(portable),
253                Some(portable),
254            )
255        } else {
256            (
257                portable_home,
258                portable_config,
259                portable_share,
260                portable_cache,
261            )
262        };
263
264    for (opt, target, kind) in [
265        (portable_home, &pkg_home, "home"),
266        (portable_config, &pkg_config, "config"),
267        (portable_share, &pkg_share, "share"),
268        (portable_cache, &pkg_cache, "cache"),
269    ] {
270        if let Some(val) = opt {
271            let base = if val.is_empty() {
272                &portable_dir_base
273            } else {
274                Path::new(val)
275            };
276            create_portable_link(base, target, pkg_name, kind)?;
277        }
278    }
279
280    Ok(())
281}
282
283fn create_default_desktop_entry(name: &str, categories: &str) -> Vec<u8> {
284    format!(
285        "[Desktop Entry]\n\
286        Type=Application\n\
287        Name={name}\n\
288        Icon={name}\n\
289        Exec={name}\n\
290        Categories={categories};\n",
291    )
292    .as_bytes()
293    .to_vec()
294}
295
296pub async fn integrate_package<P: AsRef<Path>, T: PackageExt>(
297    install_dir: P,
298    package: &T,
299    portable: Option<&str>,
300    portable_home: Option<&str>,
301    portable_config: Option<&str>,
302    portable_share: Option<&str>,
303    portable_cache: Option<&str>,
304) -> SoarResult<()> {
305    let install_dir = install_dir.as_ref();
306    let pkg_name = package.pkg_name();
307    let bin_path = install_dir.join(pkg_name);
308
309    let mut has_desktop = false;
310    let mut has_icon = false;
311    let mut symlink_action = |path: &Path| -> SoarResult<()> {
312        let ext = path.extension();
313        if ext == Some(OsStr::new("desktop")) {
314            has_desktop = true;
315            symlink_desktop(path, package)?;
316        }
317        Ok(())
318    };
319    process_dir(install_dir, &mut symlink_action)?;
320
321    let mut symlink_action = |path: &Path| -> SoarResult<()> {
322        let ext = path.extension();
323        if ext == Some(OsStr::new("png")) || ext == Some(OsStr::new("svg")) {
324            has_icon = true;
325            symlink_icon(path)?;
326        }
327        Ok(())
328    };
329    process_dir(install_dir, &mut symlink_action)?;
330
331    let mut reader = BufReader::new(
332        File::open(&bin_path).with_context(|| format!("opening {}", bin_path.display()))?,
333    );
334    let file_type = get_file_type(&mut reader)?;
335
336    match file_type {
337        PackageFormat::AppImage | PackageFormat::RunImage => {
338            if matches!(file_type, PackageFormat::AppImage) {
339                let _ = integrate_appimage(install_dir, &bin_path, package, has_icon, has_desktop)
340                    .await;
341            }
342            setup_portable_dir(
343                bin_path,
344                package,
345                portable,
346                portable_home,
347                portable_config,
348                portable_share,
349                portable_cache,
350            )?;
351        }
352        PackageFormat::FlatImage => {
353            setup_portable_dir(
354                format!("{}/.{}", bin_path.parent().unwrap().display(), pkg_name),
355                package,
356                None,
357                None,
358                portable_config,
359                None,
360                None,
361            )?;
362        }
363        PackageFormat::Wrappe => {
364            setup_wrappe_portable_dir(&bin_path, pkg_name, portable)?;
365        }
366        _ => {}
367    }
368
369    Ok(())
370}