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