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 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 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}