soar_core/package/
install.rs

1use std::{
2    env, fs,
3    path::{Path, PathBuf},
4    sync::{Arc, Mutex},
5    thread::sleep,
6    time::Duration,
7};
8
9use reqwest::StatusCode;
10use rusqlite::{params, prepare_and_bind, Connection};
11use soar_dl::{
12    downloader::{DownloadOptions, DownloadState, Downloader, OciDownloadOptions, OciDownloader},
13    error::DownloadError,
14    utils::FileMode,
15};
16
17use crate::{
18    config::get_config,
19    database::{
20        models::{InstalledPackage, Package},
21        packages::{FilterCondition, PackageQueryBuilder, ProvideStrategy},
22    },
23    error::{ErrorContext, SoarError},
24    utils::{calculate_checksum, desktop_dir, get_extract_dir, icons_dir, process_dir},
25    SoarResult,
26};
27
28pub struct PackageInstaller {
29    package: Package,
30    install_dir: PathBuf,
31    progress_callback: Option<Arc<dyn Fn(DownloadState) + Send + Sync>>,
32    db: Arc<Mutex<Connection>>,
33    with_pkg_id: bool,
34    globs: Vec<String>,
35}
36
37#[derive(Clone, Default)]
38pub struct InstallTarget {
39    pub package: Package,
40    pub existing_install: Option<InstalledPackage>,
41    pub with_pkg_id: bool,
42    pub profile: Option<String>,
43    pub portable: Option<String>,
44    pub portable_home: Option<String>,
45    pub portable_config: Option<String>,
46    pub portable_share: Option<String>,
47    pub portable_cache: Option<String>,
48}
49
50impl PackageInstaller {
51    pub async fn new<P: AsRef<Path>>(
52        target: &InstallTarget,
53        install_dir: P,
54        progress_callback: Option<Arc<dyn Fn(DownloadState) + Send + Sync>>,
55        db: Arc<Mutex<Connection>>,
56        with_pkg_id: bool,
57        globs: Vec<String>,
58    ) -> SoarResult<Self> {
59        let install_dir = install_dir.as_ref().to_path_buf();
60        let package = &target.package;
61        let profile = get_config().default_profile.clone();
62
63        if target.existing_install.is_none() {
64            let conn = db.lock()?;
65            let Package {
66                ref repo_name,
67                ref pkg,
68                ref pkg_id,
69                ref pkg_name,
70                ref pkg_type,
71                ref version,
72                ref ghcr_size,
73                ref size,
74                ..
75            } = package;
76            let installed_path = install_dir.to_string_lossy();
77            let size = ghcr_size.unwrap_or(size.unwrap_or(0));
78            let install_patterns = serde_json::to_string(&globs).unwrap();
79            let mut stmt = prepare_and_bind!(
80                conn,
81                "INSERT INTO packages (
82                    repo_name, pkg, pkg_id, pkg_name, pkg_type, version, size,
83                    installed_path, installed_date, with_pkg_id, profile, install_patterns
84                )
85                VALUES
86                (
87                    $repo_name, $pkg, $pkg_id, $pkg_name, $pkg_type, $version, $size,
88                    $installed_path, datetime(), $with_pkg_id, $profile, $install_patterns
89                )"
90            );
91            stmt.raw_execute()?;
92        }
93
94        Ok(Self {
95            package: package.clone(),
96            install_dir,
97            progress_callback,
98            db: db.clone(),
99            with_pkg_id,
100            globs,
101        })
102    }
103
104    pub async fn download_package(&self) -> SoarResult<Option<String>> {
105        let package = &self.package;
106        let output_path = self.install_dir.join(&package.pkg_name);
107
108        // fallback to download_url for repositories without ghcr
109        let (url, output_path) = if let Some(ref ghcr_pkg) = self.package.ghcr_pkg {
110            (ghcr_pkg, &self.install_dir)
111        } else {
112            (&self.package.download_url, &output_path.to_path_buf())
113        };
114
115        if self.package.ghcr_pkg.is_some() {
116            let progress_callback = &self.progress_callback.clone();
117            let options = OciDownloadOptions {
118                url: url.to_string(),
119                output_path: Some(output_path.to_string_lossy().to_string()),
120                progress_callback: self.progress_callback.clone(),
121                api: None,
122                concurrency: Some(get_config().ghcr_concurrency.unwrap_or(8)),
123                regexes: vec![],
124                exclude_keywords: vec![],
125                match_keywords: vec![],
126                exact_case: true,
127                globs: self.globs.clone(),
128                file_mode: FileMode::SkipExisting,
129            };
130            let mut downloader = OciDownloader::new(options);
131            let mut retries = 0;
132            loop {
133                if retries > 5 {
134                    if let Some(ref callback) = progress_callback {
135                        callback(DownloadState::Aborted);
136                    }
137                    break;
138                }
139                match downloader.download_oci().await {
140                    Ok(_) => break,
141                    Err(
142                        DownloadError::ResourceError {
143                            status: StatusCode::TOO_MANY_REQUESTS,
144                            ..
145                        }
146                        | DownloadError::ChunkError,
147                    ) => sleep(Duration::from_secs(5)),
148                    Err(err) => return Err(err)?,
149                };
150                retries += 1;
151                if retries > 1 {
152                    continue;
153                }
154                if let Some(ref callback) = progress_callback {
155                    callback(DownloadState::Error);
156                }
157            }
158
159            Ok(None)
160        } else {
161            let downloader = Downloader::default();
162            let extract_dir = get_extract_dir(&self.install_dir);
163            let options = DownloadOptions {
164                url: url.to_string(),
165                output_path: Some(output_path.to_string_lossy().to_string()),
166                progress_callback: self.progress_callback.clone(),
167                extract_archive: true,
168                file_mode: FileMode::SkipExisting,
169                extract_dir: Some(extract_dir.to_string_lossy().to_string()),
170                prompt: None,
171            };
172
173            let file_name = downloader.download(options).await?;
174
175            let checksum = if PathBuf::from(&file_name).exists() {
176                Some(calculate_checksum(&file_name)?)
177            } else {
178                None
179            };
180
181            let extract_path = PathBuf::from(&extract_dir);
182            if extract_path.exists() {
183                fs::remove_file(file_name).ok();
184
185                for entry in fs::read_dir(&extract_path)
186                    .with_context(|| format!("reading {} directory", extract_path.display()))?
187                {
188                    let entry = entry.with_context(|| {
189                        format!("reading entry from directory {}", extract_path.display())
190                    })?;
191                    let from = entry.path();
192                    let to = self.install_dir.join(entry.file_name());
193                    fs::rename(&from, &to).with_context(|| {
194                        format!("renaming {} to {}", from.display(), to.display())
195                    })?;
196                }
197
198                fs::remove_dir_all(&extract_path).ok();
199            }
200
201            Ok(checksum)
202        }
203    }
204
205    pub async fn record(
206        &self,
207        unlinked: bool,
208        portable: Option<&str>,
209        portable_home: Option<&str>,
210        portable_config: Option<&str>,
211        portable_share: Option<&str>,
212        portable_cache: Option<&str>,
213    ) -> SoarResult<()> {
214        let mut conn = self.db.lock()?;
215        let package = &self.package;
216        let Package {
217            repo_name,
218            pkg_name,
219            pkg_id,
220            version,
221            ghcr_size,
222            size,
223            bsum,
224            ..
225        } = package;
226        let provides = serde_json::to_string(&package.provides).unwrap();
227        let size = ghcr_size.unwrap_or(size.unwrap_or(0));
228
229        let with_pkg_id = self.with_pkg_id;
230        let tx = conn.transaction()?;
231
232        let record_id: u32 = {
233            tx.query_row(
234                r#"
235                UPDATE packages
236                SET
237                    version = ?,
238                    size = ?,
239                    installed_date = datetime(),
240                    is_installed = true,
241                    provides = ?,
242                    with_pkg_id = ?,
243                    checksum = ?
244                WHERE
245                    repo_name = ?
246                    AND pkg_name = ?
247                    AND pkg_id = ?
248                    AND pinned = false
249                    AND version = ?
250                RETURNING id
251                "#,
252                params![
253                    version,
254                    size,
255                    provides,
256                    with_pkg_id,
257                    bsum,
258                    repo_name,
259                    pkg_name,
260                    pkg_id,
261                    version,
262                ],
263                |row| row.get(0),
264            )
265            .unwrap_or_default()
266        };
267
268        if portable.is_some()
269            || portable_home.is_some()
270            || portable_config.is_some()
271            || portable_share.is_some()
272            || portable_cache.is_some()
273        {
274            let base_dir = env::current_dir()
275                .map_err(|_| SoarError::Custom("Error retrieving current directory".into()))?;
276
277            let [portable, portable_home, portable_config, portable_share, portable_cache] = [
278                portable,
279                portable_home,
280                portable_config,
281                portable_share,
282                portable_cache,
283            ]
284            .map(|opt| {
285                opt.map(|p| {
286                    if p.is_empty() {
287                        String::new()
288                    } else {
289                        let path = PathBuf::from(&p);
290                        let absolute = if path.is_absolute() {
291                            path
292                        } else {
293                            base_dir.join(path)
294                        };
295                        absolute.to_string_lossy().into_owned()
296                    }
297                })
298            });
299
300            // try to update existing record first
301            let mut stmt = prepare_and_bind!(
302                tx,
303                "UPDATE portable_package
304                SET
305                    portable_path = $portable,
306                    portable_home = $portable_home,
307                    portable_config = $portable_config,
308                    portable_share = $portable_share,
309                    portable_cache = $portable_cache
310                WHERE
311                    package_id = $record_id
312                "
313            );
314            let updated = stmt.raw_execute()?;
315
316            // if no record were updated, add a new record
317            if updated == 0 {
318                let mut stmt = prepare_and_bind!(
319                    tx,
320                    "INSERT INTO portable_package
321                (
322                    package_id, portable_path, portable_home, portable_config,
323                    portable_share, portable_cache
324                )
325                VALUES
326                (
327                     $record_id, $portable, $portable_home, $portable_config,
328                     $portable_share, $portable_cache
329                )
330                "
331                );
332                stmt.raw_execute()?;
333            }
334        }
335
336        if !unlinked {
337            let mut stmt = prepare_and_bind!(
338                tx,
339                "UPDATE packages
340                SET
341                    unlinked = true
342                WHERE
343                    pkg_name = $pkg_name
344                    AND (
345                        pkg_id != $pkg_id
346                        OR
347                        version != $version
348                    )"
349            );
350            stmt.raw_execute()?;
351        }
352
353        tx.commit()?;
354        drop(conn);
355
356        if !unlinked {
357            // FIXME: alternate package could be the same package but different version
358            // or different package but same version
359            //
360            // this makes assumption that the pkg_id and version both are different
361            let alternate_packages = PackageQueryBuilder::new(self.db.clone())
362                .where_and("pkg_name", FilterCondition::Eq(pkg_name.to_owned()))
363                .where_and("pkg_id", FilterCondition::Ne(pkg_id.to_owned()))
364                .where_and("version", FilterCondition::Ne(version.to_owned()))
365                .load_installed()?
366                .items;
367
368            for package in alternate_packages {
369                let installed_path = PathBuf::from(&package.installed_path);
370
371                let mut remove_action = |path: &Path| -> SoarResult<()> {
372                    if let Ok(real_path) = fs::read_link(path) {
373                        if real_path.parent() == Some(&installed_path) {
374                            fs::remove_file(path).with_context(|| {
375                                format!("removing desktop file {}", path.display())
376                            })?;
377                        }
378                    }
379                    Ok(())
380                };
381                process_dir(desktop_dir(), &mut remove_action)?;
382
383                let mut remove_action = |path: &Path| -> SoarResult<()> {
384                    if let Ok(real_path) = fs::read_link(path) {
385                        if real_path.parent() == Some(&installed_path) {
386                            fs::remove_file(path).with_context(|| {
387                                format!("removing icon file {}", path.display())
388                            })?;
389                        }
390                    }
391                    Ok(())
392                };
393                process_dir(icons_dir(), &mut remove_action)?;
394
395                if let Some(provides) = package.provides {
396                    for provide in provides {
397                        if let Some(ref target) = provide.target {
398                            let is_symlink = matches!(
399                                provide.strategy,
400                                Some(ProvideStrategy::KeepTargetOnly)
401                                    | Some(ProvideStrategy::KeepBoth)
402                            );
403                            if is_symlink {
404                                let target_name = get_config().get_bin_path()?.join(target);
405                                if target_name.is_symlink() || target_name.is_file() {
406                                    std::fs::remove_file(&target_name).with_context(|| {
407                                        format!("removing provide {}", target_name.display())
408                                    })?;
409                                }
410                            }
411                        }
412                    }
413                }
414            }
415        }
416
417        Ok(())
418    }
419}