soar_core/package/
install.rs

1use std::{
2    env, fs,
3    io::Write,
4    path::{Path, PathBuf},
5    thread::sleep,
6    time::Duration,
7};
8
9use chrono::Utc;
10use serde_json::json;
11use soar_config::config::get_config;
12use soar_db::{
13    models::types::ProvideStrategy,
14    repository::core::{CoreRepository, InstalledPackageWithPortable, NewInstalledPackage},
15};
16use soar_dl::{
17    download::Download,
18    error::DownloadError,
19    filter::Filter,
20    oci::OciDownload,
21    types::{OverwriteMode, Progress},
22};
23use soar_utils::{
24    error::FileSystemResult,
25    fs::{safe_remove, walk_dir},
26    hash::calculate_checksum,
27    path::{desktop_dir, icons_dir},
28};
29use tracing::{debug, trace, warn};
30
31use crate::{
32    constants::INSTALL_MARKER_FILE,
33    database::{connection::DieselDatabase, models::Package},
34    error::{ErrorContext, SoarError},
35    utils::get_extract_dir,
36    SoarResult,
37};
38
39/// Marker content to verify partial install matches current package
40#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
41pub struct InstallMarker {
42    pub pkg_id: String,
43    pub version: String,
44    pub bsum: Option<String>,
45}
46
47impl InstallMarker {
48    pub fn read_from_dir(install_dir: &Path) -> Option<Self> {
49        let marker_path = install_dir.join(INSTALL_MARKER_FILE);
50        let content = fs::read_to_string(&marker_path).ok()?;
51        serde_json::from_str(&content).ok()
52    }
53
54    pub fn matches_package(&self, package: &Package) -> bool {
55        self.pkg_id == package.pkg_id
56            && self.version == package.version
57            && self.bsum == package.bsum
58    }
59}
60
61pub struct PackageInstaller {
62    package: Package,
63    install_dir: PathBuf,
64    progress_callback: Option<std::sync::Arc<dyn Fn(Progress) + Send + Sync>>,
65    db: DieselDatabase,
66    with_pkg_id: bool,
67    globs: Vec<String>,
68}
69
70#[derive(Clone, Default)]
71pub struct InstallTarget {
72    pub package: Package,
73    pub existing_install: Option<crate::database::models::InstalledPackage>,
74    pub with_pkg_id: bool,
75    pub pinned: bool,
76    pub profile: Option<String>,
77    pub portable: Option<String>,
78    pub portable_home: Option<String>,
79    pub portable_config: Option<String>,
80    pub portable_share: Option<String>,
81    pub portable_cache: Option<String>,
82}
83
84impl PackageInstaller {
85    pub async fn new<P: AsRef<Path>>(
86        target: &InstallTarget,
87        install_dir: P,
88        progress_callback: Option<std::sync::Arc<dyn Fn(Progress) + Send + Sync>>,
89        db: DieselDatabase,
90        with_pkg_id: bool,
91        globs: Vec<String>,
92    ) -> SoarResult<Self> {
93        let install_dir = install_dir.as_ref().to_path_buf();
94        let package = &target.package;
95        trace!(
96            pkg_name = package.pkg_name,
97            pkg_id = package.pkg_id,
98            install_dir = %install_dir.display(),
99            "creating package installer"
100        );
101        let profile = get_config().default_profile.clone();
102
103        let needs_new_record = match &target.existing_install {
104            None => true,
105            Some(existing) => existing.version != package.version,
106        };
107
108        if needs_new_record {
109            trace!(
110                "inserting new package record for version {}",
111                package.version
112            );
113            let repo_name = &package.repo_name;
114            let pkg_id = &package.pkg_id;
115            let pkg_name = &package.pkg_name;
116            let pkg_type = package.pkg_type.as_deref();
117            let version = &package.version;
118            let size = package.ghcr_size.unwrap_or(package.size.unwrap_or(0)) as i64;
119            let installed_path = install_dir.to_string_lossy();
120            let installed_date = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
121
122            let new_package = NewInstalledPackage {
123                repo_name,
124                pkg_id,
125                pkg_name,
126                pkg_type,
127                version,
128                size,
129                checksum: None,
130                installed_path: &installed_path,
131                installed_date: &installed_date,
132                profile: &profile,
133                pinned: target.pinned,
134                is_installed: false,
135                with_pkg_id,
136                detached: false,
137                unlinked: false,
138                provides: None,
139                install_patterns: Some(json!(globs)),
140            };
141
142            db.with_conn(|conn| CoreRepository::insert(conn, &new_package))?;
143        }
144
145        Ok(Self {
146            package: package.clone(),
147            install_dir,
148            progress_callback,
149            db,
150            with_pkg_id,
151            globs,
152        })
153    }
154
155    fn write_marker(&self) -> SoarResult<()> {
156        fs::create_dir_all(&self.install_dir).with_context(|| {
157            format!("creating install directory {}", self.install_dir.display())
158        })?;
159
160        let marker = InstallMarker {
161            pkg_id: self.package.pkg_id.clone(),
162            version: self.package.version.clone(),
163            bsum: self.package.bsum.clone(),
164        };
165
166        let marker_path = self.install_dir.join(INSTALL_MARKER_FILE);
167        let mut file = fs::File::create(&marker_path)
168            .with_context(|| format!("creating marker file {}", marker_path.display()))?;
169        let content = serde_json::to_string(&marker)
170            .map_err(|e| SoarError::Custom(format!("Failed to serialize marker: {e}")))?;
171        file.write_all(content.as_bytes())
172            .with_context(|| format!("writing marker file {}", marker_path.display()))?;
173
174        Ok(())
175    }
176
177    fn remove_marker(&self) -> SoarResult<()> {
178        let marker_path = self.install_dir.join(INSTALL_MARKER_FILE);
179        if marker_path.exists() {
180            fs::remove_file(&marker_path)
181                .with_context(|| format!("removing marker file {}", marker_path.display()))?;
182        }
183        Ok(())
184    }
185
186    pub async fn download_package(&self) -> SoarResult<Option<String>> {
187        debug!(
188            pkg_name = self.package.pkg_name,
189            pkg_id = self.package.pkg_id,
190            "starting package download"
191        );
192        self.write_marker()?;
193
194        let package = &self.package;
195        let output_path = self.install_dir.join(&package.pkg_name);
196
197        // fallback to download_url for repositories without ghcr
198        let (url, output_path) = if let Some(ref ghcr_pkg) = self.package.ghcr_pkg {
199            debug!("source: {} (OCI)", ghcr_pkg);
200            (ghcr_pkg, &self.install_dir)
201        } else {
202            debug!("source: {}", self.package.download_url);
203            (&self.package.download_url, &output_path.to_path_buf())
204        };
205
206        if self.package.ghcr_pkg.is_some() {
207            trace!(url = url.as_str(), "using OCI/GHCR download");
208            let mut dl = OciDownload::new(url.as_str())
209                .output(output_path.to_string_lossy())
210                .parallel(get_config().ghcr_concurrency.unwrap_or(8))
211                .overwrite(OverwriteMode::Skip);
212
213            if let Some(ref cb) = self.progress_callback {
214                let cb = cb.clone();
215                dl = dl.progress(move |p| {
216                    cb(p);
217                });
218            }
219
220            if !self.globs.is_empty() {
221                dl = dl.filter(Filter {
222                    globs: self.globs.clone(),
223                    ..Default::default()
224                });
225            }
226
227            let mut retries = 0;
228            let mut last_error: Option<DownloadError> = None;
229            loop {
230                if retries > 5 {
231                    if let Some(ref callback) = self.progress_callback {
232                        callback(Progress::Aborted);
233                    }
234                    // Return error after max retries
235                    return Err(last_error.unwrap_or_else(|| {
236                        DownloadError::Multiple {
237                            errors: vec!["Download failed after 5 retries".into()],
238                        }
239                    }))?;
240                }
241                match dl.clone().execute() {
242                    Ok(_) => {
243                        debug!("OCI download completed successfully");
244                        break;
245                    }
246                    Err(err) => {
247                        if matches!(
248                            err,
249                            DownloadError::HttpError {
250                                status: 429,
251                                ..
252                            } | DownloadError::Network(_)
253                        ) {
254                            warn!(retry = retries, "download failed, retrying after delay");
255                            sleep(Duration::from_secs(5));
256                            retries += 1;
257                            if retries > 1 {
258                                if let Some(ref callback) = self.progress_callback {
259                                    callback(Progress::Error);
260                                }
261                            }
262                            last_error = Some(err);
263                        } else {
264                            return Err(err)?;
265                        }
266                    }
267                }
268            }
269
270            Ok(None)
271        } else {
272            trace!(url = url.as_str(), "using direct download");
273            let extract_dir = get_extract_dir(&self.install_dir);
274
275            // Only extract if it's an archive type
276            let should_extract = self
277                .package
278                .pkg_type
279                .as_deref()
280                .is_some_and(|t| t == "archive");
281
282            let mut dl = Download::new(url.as_str())
283                .output(output_path.to_string_lossy())
284                .overwrite(OverwriteMode::Skip)
285                .extract(should_extract)
286                .extract_to(&extract_dir);
287
288            if let Some(ref cb) = self.progress_callback {
289                let cb = cb.clone();
290                dl = dl.progress(move |p| {
291                    cb(p);
292                });
293            }
294
295            let file_path = dl.execute()?;
296
297            let checksum = if PathBuf::from(&file_path).exists() {
298                Some(calculate_checksum(&file_path)?)
299            } else {
300                None
301            };
302
303            let extract_path = PathBuf::from(&extract_dir);
304            if extract_path.exists() {
305                fs::remove_file(file_path).ok();
306
307                for entry in fs::read_dir(&extract_path)
308                    .with_context(|| format!("reading {} directory", extract_path.display()))?
309                {
310                    let entry = entry.with_context(|| {
311                        format!("reading entry from directory {}", extract_path.display())
312                    })?;
313                    let from = entry.path();
314                    let to = self.install_dir.join(entry.file_name());
315                    fs::rename(&from, &to).with_context(|| {
316                        format!("renaming {} to {}", from.display(), to.display())
317                    })?;
318                }
319
320                fs::remove_dir_all(&extract_path).ok();
321            }
322
323            Ok(checksum)
324        }
325    }
326
327    pub async fn record(
328        &self,
329        unlinked: bool,
330        portable: Option<&str>,
331        portable_home: Option<&str>,
332        portable_config: Option<&str>,
333        portable_share: Option<&str>,
334        portable_cache: Option<&str>,
335    ) -> SoarResult<()> {
336        debug!(
337            pkg_name = self.package.pkg_name,
338            pkg_id = self.package.pkg_id,
339            unlinked = unlinked,
340            "recording installation"
341        );
342        let package = &self.package;
343        let repo_name = &package.repo_name;
344        let pkg_name = &package.pkg_name;
345        let pkg_id = &package.pkg_id;
346        let version = &package.version;
347        let size = package.ghcr_size.unwrap_or(package.size.unwrap_or(0)) as i64;
348        let checksum = package.bsum.as_deref();
349        let provides = package.provides.clone();
350
351        let with_pkg_id = self.with_pkg_id;
352        let installed_date = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
353
354        let record_id: Option<i32> = self.db.with_conn(|conn| {
355            CoreRepository::record_installation(
356                conn,
357                repo_name,
358                pkg_name,
359                pkg_id,
360                version,
361                size,
362                provides,
363                with_pkg_id,
364                checksum,
365                &installed_date,
366            )
367        })?;
368
369        let record_id = record_id.ok_or_else(|| {
370            SoarError::Custom(format!(
371                "Failed to record installation for {}#{}: package not found in database",
372                pkg_name, pkg_id
373            ))
374        })?;
375
376        if portable.is_some()
377            || portable_home.is_some()
378            || portable_config.is_some()
379            || portable_share.is_some()
380            || portable_cache.is_some()
381        {
382            let base_dir = env::current_dir()
383                .map_err(|_| SoarError::Custom("Error retrieving current directory".into()))?;
384
385            let resolve_path = |opt: Option<&str>| -> Option<String> {
386                opt.map(|p| {
387                    if p.is_empty() {
388                        String::new()
389                    } else {
390                        let path = PathBuf::from(p);
391                        let absolute = if path.is_absolute() {
392                            path
393                        } else {
394                            base_dir.join(path)
395                        };
396                        absolute.to_string_lossy().into_owned()
397                    }
398                })
399            };
400
401            let portable_path = resolve_path(portable);
402            let portable_home = resolve_path(portable_home);
403            let portable_config = resolve_path(portable_config);
404            let portable_share = resolve_path(portable_share);
405            let portable_cache = resolve_path(portable_cache);
406
407            self.db.with_conn(|conn| {
408                CoreRepository::upsert_portable(
409                    conn,
410                    record_id,
411                    portable_path.as_deref(),
412                    portable_home.as_deref(),
413                    portable_config.as_deref(),
414                    portable_share.as_deref(),
415                    portable_cache.as_deref(),
416                )
417            })?;
418        }
419
420        if !unlinked {
421            self.db
422                .with_conn(|conn| CoreRepository::unlink_others(conn, pkg_name, pkg_id, version))?;
423
424            let alternate_packages: Vec<InstalledPackageWithPortable> =
425                self.db.with_conn(|conn| {
426                    CoreRepository::find_alternates(conn, pkg_name, pkg_id, version)
427                })?;
428
429            for alt_pkg in alternate_packages {
430                let installed_path = PathBuf::from(&alt_pkg.installed_path);
431
432                let mut remove_action = |path: &Path| -> FileSystemResult<()> {
433                    if let Ok(real_path) = fs::read_link(path) {
434                        if real_path.parent() == Some(&installed_path) {
435                            safe_remove(path)?;
436                        }
437                    }
438                    Ok(())
439                };
440                walk_dir(desktop_dir(), &mut remove_action)?;
441
442                let mut remove_action = |path: &Path| -> FileSystemResult<()> {
443                    if let Ok(real_path) = fs::read_link(path) {
444                        if real_path.parent() == Some(&installed_path) {
445                            safe_remove(path)?;
446                        }
447                    }
448                    Ok(())
449                };
450                walk_dir(icons_dir(), &mut remove_action)?;
451
452                if let Some(ref provides) = alt_pkg.provides {
453                    for provide in provides {
454                        if let Some(ref target) = provide.target {
455                            let is_symlink = matches!(
456                                provide.strategy,
457                                Some(ProvideStrategy::KeepTargetOnly)
458                                    | Some(ProvideStrategy::KeepBoth)
459                            );
460                            if is_symlink {
461                                let target_name = get_config().get_bin_path()?.join(target);
462                                if target_name.is_symlink() || target_name.is_file() {
463                                    std::fs::remove_file(&target_name).with_context(|| {
464                                        format!("removing provide {}", target_name.display())
465                                    })?;
466                                }
467                            }
468                        }
469                    }
470                }
471            }
472        }
473
474        self.remove_marker()?;
475
476        Ok(())
477    }
478}