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