Skip to main content

upstream_rs/application/operations/
install_operation.rs

1use crate::{
2    models::common::DesktopEntry,
3    models::upstream::Package,
4    providers::provider_manager::ProviderManager,
5    services::{
6        integration::{AppImageExtractor, DesktopManager, IconManager},
7        storage::package_storage::PackageStorage,
8    },
9    utils::static_paths::UpstreamPaths,
10};
11
12use crate::services::packaging::PackageInstaller;
13
14use anyhow::{Context, Result, anyhow};
15use console::style;
16use std::time::{Duration, Instant};
17
18const INSTALL_PROGRESS_UPDATE_INTERVAL: Duration = Duration::from_millis(100);
19
20macro_rules! message {
21    ($cb:expr, $($arg:tt)*) => {{
22        if let Some(cb) = $cb.as_mut() {
23            cb(&format!($($arg)*));
24        }
25    }};
26}
27
28pub struct InstallOperation<'a> {
29    installer: PackageInstaller<'a>,
30    package_storage: &'a mut PackageStorage,
31    provider_manager: &'a ProviderManager,
32    paths: &'a UpstreamPaths,
33}
34
35impl<'a> InstallOperation<'a> {
36    pub fn new(
37        provider_manager: &'a ProviderManager,
38        package_storage: &'a mut PackageStorage,
39        paths: &'a UpstreamPaths,
40    ) -> Result<Self> {
41        let installer = PackageInstaller::new(provider_manager, paths)?;
42        Ok(Self {
43            installer,
44            package_storage,
45            provider_manager,
46            paths,
47        })
48    }
49
50    pub async fn install_bulk<F, G, H>(
51        &mut self,
52        packages: Vec<Package>,
53        ignore_checksums: bool,
54        download_progress_callback: &mut Option<F>,
55        overall_progress_callback: &mut Option<G>,
56        message_callback: &mut Option<H>,
57    ) -> Result<()>
58    where
59        F: FnMut(u64, u64),
60        G: FnMut(u32, u32),
61        H: FnMut(&str),
62    {
63        let total = packages.len() as u32;
64        let mut completed = 0;
65        let mut failures = 0;
66
67        for package in packages {
68            let package_name = package.name.clone();
69            message!(message_callback, "Installing '{}' ...", package_name);
70
71            let use_icon = &package.icon_path.is_some();
72            let mut last_progress: Option<(u64, u64)> = None;
73            let mut last_emit: Option<Instant> = None;
74            let mut throttled_download_progress = download_progress_callback.as_mut().map(|cb| {
75                |downloaded: u64, total: u64| {
76                    last_progress = Some((downloaded, total));
77                    let should_emit = last_emit
78                        .map(|t| t.elapsed() >= INSTALL_PROGRESS_UPDATE_INTERVAL)
79                        .unwrap_or(true);
80                    if should_emit {
81                        cb(downloaded, total);
82                        last_emit = Some(Instant::now());
83                    }
84                }
85            });
86
87            match self
88                .install_single(
89                    package,
90                    &None,
91                    use_icon,
92                    ignore_checksums,
93                    &mut throttled_download_progress,
94                    message_callback,
95                )
96                .await
97                .context(format!("Failed to install package '{}'", package_name))
98            {
99                Ok(_) => {
100                    message!(message_callback, "{}", style("Package installed").green());
101                }
102                Err(e) => {
103                    message!(message_callback, "{} {}", style("Install failed:").red(), e);
104                    failures += 1;
105                }
106            }
107
108            if let (Some((downloaded, total)), Some(cb)) =
109                (last_progress, download_progress_callback.as_mut())
110            {
111                cb(downloaded, total);
112            }
113
114            completed += 1;
115            if let Some(cb) = overall_progress_callback.as_mut() {
116                cb(completed, total);
117            }
118        }
119
120        if failures > 0 {
121            message!(
122                message_callback,
123                "{} package(s) failed to install",
124                failures
125            );
126        }
127
128        Ok(())
129    }
130
131    pub async fn install_single<F, H>(
132        &mut self,
133        package: Package,
134        version: &Option<String>,
135        add_entry: &bool,
136        ignore_checksums: bool,
137        download_progress_callback: &mut Option<F>,
138        message_callback: &mut Option<H>,
139    ) -> Result<()>
140    where
141        F: FnMut(u64, u64),
142        H: FnMut(&str),
143    {
144        let package_name = package.name.clone();
145
146        let mut installed_package = self
147            .perform_install(
148                package,
149                version,
150                ignore_checksums,
151                download_progress_callback,
152                message_callback,
153            )
154            .await
155            .context(format!(
156                "Failed to perform installation for '{}'",
157                package_name
158            ))?;
159
160        if *add_entry {
161            let appimage_extractor =
162                AppImageExtractor::new().context("Failed to initialize appimage extractor")?;
163
164            let icon_manager = IconManager::new(self.paths, &appimage_extractor);
165            let desktop_manager = DesktopManager::new(self.paths, &appimage_extractor);
166            let install_path = installed_package.install_path.clone().ok_or_else(|| {
167                anyhow!(
168                    "Package '{}' has no install path after installation",
169                    installed_package.name
170                )
171            })?;
172
173            let icon_path = icon_manager
174                .add_icon(
175                    &installed_package.name,
176                    &install_path,
177                    &installed_package.filetype,
178                    message_callback,
179                )
180                .await
181                .context(format!(
182                    "Failed to add icon for '{}'",
183                    installed_package.name
184                ))?;
185
186            installed_package.icon_path = icon_path;
187
188            let desktop_entry = DesktopEntry::from_package(&installed_package);
189
190            let _ = desktop_manager
191                .create_entry(
192                    &install_path,
193                    &installed_package.filetype,
194                    desktop_entry,
195                    message_callback,
196                )
197                .await
198                .context(format!(
199                    "Failed to create desktop entry for '{}'",
200                    installed_package.name
201                ))?;
202        }
203
204        self.package_storage
205            .add_or_update_package(installed_package.clone())
206            .context(format!(
207                "Failed to save package '{}' to storage",
208                installed_package.name
209            ))?;
210
211        Ok(())
212    }
213
214    async fn perform_install<F, H>(
215        &self,
216        package: Package,
217        version: &Option<String>,
218        ignore_checksums: bool,
219        download_progress_callback: &mut Option<F>,
220        message_callback: &mut Option<H>,
221    ) -> Result<Package>
222    where
223        F: FnMut(u64, u64),
224        H: FnMut(&str),
225    {
226        if package.install_path.is_some() {
227            return Err(anyhow!("Package '{}' is already installed", package.name));
228        }
229
230        let release = if let Some(version_tag) = version {
231            // SPECIFIC VERSION
232            message!(
233                message_callback,
234                "Fetching release for version '{}' ...",
235                version_tag
236            );
237            self.provider_manager
238                .get_release_by_tag_for(
239                    &package.repo_slug,
240                    version_tag,
241                    &package.provider,
242                    package.base_url.as_deref(),
243                )
244                .await
245                .context(format!(
246                    "Failed to fetch release '{}' for '{}'. Verify the version tag exists",
247                    version_tag, package.repo_slug
248                ))?
249        } else {
250            // LATEST VERSION
251            message!(message_callback, "Fetching latest release ...");
252            self.provider_manager
253                .get_latest_release_for(
254                    &package.repo_slug,
255                    &package.provider,
256                    &package.channel,
257                    package.base_url.as_deref(),
258                )
259                .await
260                .context(format!(
261                    "Failed to fetch latest {} release for '{}'",
262                    package.channel, package.repo_slug
263                ))?
264        };
265
266        self.installer
267            .install_package_files(
268                package,
269                &release,
270                ignore_checksums,
271                download_progress_callback,
272                message_callback,
273            )
274            .await
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::InstallOperation;
281    use crate::models::common::enums::{Channel, Filetype, Provider};
282    use crate::models::upstream::Package;
283    use crate::providers::provider_manager::ProviderManager;
284    use crate::services::storage::package_storage::PackageStorage;
285    use crate::utils::static_paths::{
286        AppDirs, ConfigPaths, InstallPaths, IntegrationPaths, UpstreamPaths,
287    };
288    use std::path::{Path, PathBuf};
289    use std::time::{SystemTime, UNIX_EPOCH};
290    use std::{fs, io};
291
292    fn temp_root(name: &str) -> PathBuf {
293        let nanos = SystemTime::now()
294            .duration_since(UNIX_EPOCH)
295            .map(|d| d.as_nanos())
296            .unwrap_or(0);
297        std::env::temp_dir().join(format!("upstream-install-op-test-{name}-{nanos}"))
298    }
299
300    fn test_paths(root: &Path) -> UpstreamPaths {
301        let dirs = AppDirs {
302            user_dir: root.to_path_buf(),
303            config_dir: root.join("config"),
304            data_dir: root.join("data"),
305            metadata_dir: root.join("data/metadata"),
306        };
307
308        UpstreamPaths {
309            config: ConfigPaths {
310                config_file: dirs.config_dir.join("config.toml"),
311                packages_file: dirs.metadata_dir.join("packages.json"),
312                paths_file: dirs.metadata_dir.join("paths.sh"),
313            },
314            install: InstallPaths {
315                appimages_dir: dirs.data_dir.join("appimages"),
316                binaries_dir: dirs.data_dir.join("binaries"),
317                archives_dir: dirs.data_dir.join("archives"),
318            },
319            integration: IntegrationPaths {
320                symlinks_dir: dirs.data_dir.join("symlinks"),
321                xdg_applications_dir: dirs.user_dir.join(".local/share/applications"),
322                icons_dir: dirs.data_dir.join("icons"),
323            },
324            dirs,
325        }
326    }
327
328    fn cleanup(path: &Path) -> io::Result<()> {
329        fs::remove_dir_all(path)
330    }
331
332    #[tokio::test]
333    async fn perform_install_rejects_already_installed_package_before_network_calls() {
334        let root = temp_root("already-installed");
335        let paths = test_paths(&root);
336        fs::create_dir_all(paths.config.packages_file.parent().expect("parent"))
337            .expect("create metadata dir");
338        let mut storage = PackageStorage::new(&paths.config.packages_file).expect("storage");
339        let provider_manager = ProviderManager::new(None, None, None).expect("provider manager");
340        let op = InstallOperation::new(&provider_manager, &mut storage, &paths).expect("operation");
341
342        let mut package = Package::with_defaults(
343            "tool".to_string(),
344            "owner/tool".to_string(),
345            Filetype::Binary,
346            None,
347            None,
348            Channel::Stable,
349            Provider::Github,
350            None,
351        );
352        package.install_path = Some(paths.install.binaries_dir.join("tool"));
353        let mut dl: Option<fn(u64, u64)> = None;
354        let mut msg: Option<fn(&str)> = None;
355
356        let err = op
357            .perform_install(package, &None, false, &mut dl, &mut msg)
358            .await
359            .expect_err("already-installed guard");
360        assert!(err.to_string().contains("already installed"));
361
362        cleanup(&root).expect("cleanup");
363    }
364}