1use std::cell::RefCell;
25use std::fs;
26use std::path::{Path, PathBuf};
27
28use crate::catalog;
29use crate::core::network::installer_filename;
30use crate::core::paths::install_root_from_package_dir;
31use crate::core::temp_workspace;
32use crate::database;
33use crate::engines;
34use crate::models::catalog::CatalogInstaller;
35use crate::models::domains::shared::DeploymentKind;
36use crate::operations::shims;
37use tracing::warn;
38
39pub use crate::core::cancel;
40pub use crate::models::catalog::CatalogPackage;
41use crate::models::domains::command_resolution::{ResolverResult, resolve_command_exposure};
42use crate::models::domains::install::EngineInstallReceipt;
43pub use crate::models::domains::install::{InstallFailureClass, InstallOutcome, InstallResult};
44pub use crate::models::domains::package::PackageRef;
45use crate::models::domains::shared::HashAlgorithm;
46pub use types::InstallError;
47pub type Result<T> = types::Result<T>;
48
49pub mod download;
50pub mod flow;
51pub mod plan;
52mod sevenz;
53pub mod state;
54pub mod types;
55
56fn ensure_install_dirs(install_root: &Path) -> std::io::Result<()> {
57 fs::create_dir_all(install_root)
58}
59
60pub trait InstallObserver {
67 fn choose_package(&mut self, query: &str, matches: &[CatalogPackage]) -> anyhow::Result<usize>;
72
73 fn on_start(&mut self, _total_bytes: Option<u64>) {}
78
79 fn on_progress(&mut self, _downloaded_bytes: u64) {}
81
82 fn on_install_start(&mut self, _message: &str) {}
84
85 fn on_install_complete(&mut self) {}
90
91 fn confirm_runtime_bootstrap(
93 &mut self,
94 runtime_name: &str,
95 target_dir: &Path,
96 ) -> anyhow::Result<bool> {
97 let _ = (runtime_name, target_dir);
98 Ok(false)
99 }
100}
101
102#[derive(Debug, Clone)]
103pub(crate) struct ResolvedInstallTarget {
104 pub package: CatalogPackage,
105 pub installer: CatalogInstaller,
106 pub command_resolution: ResolverResult,
107 pub resolved_commands: Option<Vec<String>>,
108 pub resolved_commands_json: Option<String>,
109 pub manifest_engine: crate::engines::EngineKind,
110 pub manifest_deployment_kind: DeploymentKind,
111 pub install_dir: PathBuf,
112 pub install_root: PathBuf,
113 pub temp_root: PathBuf,
114 pub download_path: PathBuf,
115 pub package_version: String,
116 pub runtime_bootstrap_required: bool,
117}
118
119pub fn run<O: InstallObserver>(
140 ctx: &crate::AppContext,
141 package_ref: PackageRef,
142 ignore_checksum_security: bool,
143 observer: &mut O,
144) -> Result<InstallOutcome> {
145 let observer = RefCell::new(observer);
146 let target = resolve_install_target(ctx, package_ref, |query, matches| {
147 observer.borrow_mut().choose_package(query, matches)
148 })?;
149
150 let _runtime_root_guard = sevenz::runtime_root_env_guard(&ctx.paths.root);
151 let mut conn = database::get_conn()?;
152 state::prepare_install_target_with_commands(
153 &conn,
154 &target.package.name,
155 &target.install_dir,
156 target.resolved_commands_json.as_deref(),
157 )?;
158
159 {
160 let mut observer = observer.borrow_mut();
161 sevenz::ensure_runtime(
162 &ctx.paths.root,
163 &target.installer.url,
164 |runtime_name, target_dir| observer.confirm_runtime_bootstrap(runtime_name, target_dir),
165 )?;
166 }
167
168 ensure_install_dirs(&target.install_root)?;
169 fs::create_dir_all(&target.temp_root)?;
170
171 let _temp_root_guard = TempRootGuard::new(target.temp_root.clone());
172 state::mark_installing(
173 &conn,
174 target.package.name.clone(),
175 target.package_version.clone(),
176 target.installer.kind,
177 target.manifest_deployment_kind,
178 target.manifest_engine,
179 &target.install_dir,
180 )?;
181
182 let client = download::build_client()?;
183
184 let (engine_receipt, legacy_checksum_algorithms) =
185 match (|| -> anyhow::Result<(EngineInstallReceipt, Vec<HashAlgorithm>)> {
186 let legacy_checksum_algorithms = download::download_installer(
187 &client,
188 &target.installer,
189 &target.download_path,
190 ignore_checksum_security,
191 |total_bytes| observer.borrow_mut().on_start(total_bytes),
192 |downloaded_bytes| observer.borrow_mut().on_progress(downloaded_bytes),
193 )?;
194
195 let resolved_kind =
196 engines::probe_installer_from_download(&target.installer, &target.download_path)?;
197 let mut resolved_installer = target.installer.clone();
198 resolved_installer.kind = resolved_kind;
199
200 let engine = engines::resolve_engine_for_installer(&resolved_installer)?;
201 let deployment_kind = engines::resolve_deployment_kind(&resolved_installer);
202
203 if resolved_kind != target.installer.kind
204 || engine != target.manifest_engine
205 || deployment_kind != target.manifest_deployment_kind
206 {
207 state::update_installing_identity(
208 &conn,
209 &target.package.name,
210 resolved_kind,
211 deployment_kind,
212 engine,
213 )?;
214 }
215
216 observer
217 .borrow_mut()
218 .on_install_start(&format!("Installing {}...", target.package.name));
219 let _install_phase_guard = InstallPhaseGuard::new(&observer);
220
221 let engine_receipt = flow::execute_engine_install(
222 engine,
223 &resolved_installer,
224 &target.download_path,
225 &target.install_dir,
226 &target.package.name,
227 )?;
228
229 Ok((engine_receipt, legacy_checksum_algorithms))
230 })() {
231 Ok(result) => result,
232 Err(err) => {
233 let install_error: InstallError = err.into();
234
235 match install_error.failure_class() {
236 InstallFailureClass::Cancelled => {
237 flow::rollback_cancelled_install(
238 &conn,
239 &target.package.name,
240 &target.install_dir,
241 );
242 }
243 _ => {
244 flow::rollback_failed_install(
245 &conn,
246 &target.package.name,
247 &target.install_dir,
248 );
249 }
250 }
251
252 return Err(install_error);
253 }
254 };
255
256 if cancel::is_cancelled() {
257 flow::rollback_cancelled_install(&conn, &target.package.name, &target.install_dir);
258 return Err(cancel::CancellationError.into());
259 }
260
261 if let Err(err) = database::commit_install_with_commands(
262 &mut conn,
263 &target.package.name,
264 &engine_receipt,
265 target.resolved_commands_json.as_deref(),
266 ) {
267 let _ = state::mark_failed(&conn, &target.package.name);
268 if let Some(conflict) = err.downcast_ref::<database::CommandRegistryConflictError>() {
269 return Err(InstallError::CommandClaimedWhileInProgress {
270 command: conflict.command_name.clone(),
271 });
272 }
273 return Err(err.into());
274 }
275
276 if let Err(err) = write_install_journal(
277 &ctx.paths,
278 &conn,
279 &target.package.name,
280 &target.command_resolution,
281 target.resolved_commands.as_deref(),
282 target.package.bin.as_deref(),
283 target.package.env_add_path.as_deref(),
284 ) {
285 warn!(
286 package = %target.package.name,
287 error = %err,
288 "failed to write install journal"
289 );
290 }
291
292 if let Err(err) = shims::publish_package_shims(
293 &ctx.paths.shims,
294 &target.package.name,
295 target.package.bin.as_deref(),
296 ) {
297 warn!(
298 package = %target.package.name,
299 error = %err,
300 "failed to publish package shims"
301 );
302 }
303
304 let install_result = InstallResult {
305 name: target.package.name,
306 version: target.package_version,
307 install_dir: engine_receipt.install_dir.clone(),
308 };
309
310 Ok(InstallOutcome {
311 result: install_result,
312 legacy_checksum_algorithms,
313 })
314}
315
316pub(crate) fn resolve_install_target(
317 ctx: &crate::AppContext,
318 package_ref: PackageRef,
319 mut choose_package: impl FnMut(&str, &[CatalogPackage]) -> anyhow::Result<usize>,
320) -> Result<ResolvedInstallTarget> {
321 let catalog_conn = database::get_catalog_conn()?;
322 let package =
323 catalog::resolve_catalog_package_ref(&catalog_conn, &package_ref, |query, matches| {
324 choose_package(query, matches)
325 })?;
326 let selection_context = crate::catalog::SelectionContext::new(
327 crate::windows::host::host_profile(),
328 crate::windows::host::is_elevated(),
329 );
330 let installer = types::select_installer(
331 &database::get_installers(&catalog_conn, &package.id)?,
332 selection_context,
333 )?;
334 let command_resolution = resolve_command_exposure(&package, &installer)
335 .map_err(|source| InstallError::Unexpected(anyhow::Error::new(source)))?;
336 let resolved_commands = match &command_resolution {
337 ResolverResult::Resolved { commands, .. } => Some(commands.clone()),
338 ResolverResult::Unresolved { .. } => None,
339 };
340 let resolved_commands_json = resolved_commands.as_ref().map(|commands| {
341 serde_json::to_string(commands).expect("resolved commands should serialize")
342 });
343 let manifest_engine = engines::resolve_engine_for_installer(&installer)?;
344 let manifest_deployment_kind = engines::resolve_deployment_kind(&installer);
345 let package_version = package.version.to_string();
346 let install_dir = ctx.paths.package_install_dir(&package.name);
347 let install_root = install_root_from_package_dir(&install_dir);
348 let temp_root = temp_workspace::build_temp_root(&package.name, &package_version);
349 let download_path = temp_root.join(installer_filename(&installer.url));
350 let runtime_bootstrap_required =
351 sevenz::runtime_bootstrap_required(&ctx.paths.root, &installer.url);
352
353 Ok(ResolvedInstallTarget {
354 package,
355 installer,
356 command_resolution,
357 resolved_commands,
358 resolved_commands_json,
359 manifest_engine,
360 manifest_deployment_kind,
361 install_dir,
362 install_root,
363 temp_root,
364 download_path,
365 package_version,
366 runtime_bootstrap_required,
367 })
368}
369
370struct TempRootGuard {
371 path: PathBuf,
372}
373
374impl TempRootGuard {
375 fn new(path: PathBuf) -> Self {
376 Self { path }
377 }
378}
379
380struct InstallPhaseGuard<'a, O: InstallObserver> {
381 observer: &'a RefCell<&'a mut O>,
382}
383
384impl<'a, O: InstallObserver> InstallPhaseGuard<'a, O> {
385 fn new(observer: &'a RefCell<&'a mut O>) -> Self {
386 Self { observer }
387 }
388}
389
390impl<O: InstallObserver> Drop for InstallPhaseGuard<'_, O> {
391 fn drop(&mut self) {
392 self.observer.borrow_mut().on_install_complete();
393 }
394}
395
396#[cfg(test)]
397#[allow(clippy::items_after_test_module)]
398mod tests {
399 use super::write_install_journal;
400 use crate::database;
401 use crate::database::package_journal_key;
402 use crate::models::domains::command_resolution::{
403 CommandSource, Confidence, ResolverResult, VersionScope,
404 };
405 use crate::models::domains::install::InstallerType;
406 use crate::models::domains::installed::{InstalledPackage, PackageStatus};
407 use anyhow::Result;
408 use std::fs;
409 use std::path::Path;
410 use winbrew_testing::{init_database, reset_install_state, test_root};
411
412 fn sample_package(name: &str, kind: InstallerType, install_dir: &Path) -> InstalledPackage {
413 InstalledPackage {
414 name: name.to_string(),
415 version: "1.0.0".to_string(),
416 kind,
417 deployment_kind: kind.deployment_kind(),
418 engine_kind: kind.into(),
419 engine_metadata: None,
420 install_dir: install_dir.to_string_lossy().into_owned(),
421 dependencies: Vec::new(),
422 status: PackageStatus::Ok,
423 installed_at: "2026-04-05T00:00:00Z".to_string(),
424 }
425 }
426
427 #[test]
428 fn write_install_journal_normalizes_single_string_bin_metadata() -> Result<()> {
429 let test_root = test_root();
430 let root = test_root.path();
431 let config = init_database(root)?;
432 reset_install_state(root)?;
433 let conn = database::get_conn()?;
434
435 let install_dir = root.join("packages").join("Contoso.Journal");
436 fs::create_dir_all(&install_dir)?;
437
438 let package = sample_package("Contoso.Journal", InstallerType::Portable, &install_dir);
439 database::insert_package(&conn, &package)?;
440
441 let paths = config.resolved_paths();
442 let command_resolution = ResolverResult::Resolved {
443 commands: vec!["contoso".to_string()],
444 confidence: Confidence::High,
445 sources: vec![CommandSource::PackageLevel],
446 version_scope: VersionScope::Specific(package.version.clone()),
447 catalog_fingerprint: "sha256:dummy".to_string(),
448 };
449 let commands = vec!["contoso".to_string()];
450
451 write_install_journal(
452 &paths,
453 &conn,
454 &package.name,
455 &command_resolution,
456 Some(commands.as_slice()),
457 Some(r#""bin/tool.exe""#),
458 Some(r#""env/add""#),
459 )?;
460
461 let journal_key = package_journal_key(&package.name, &package.version);
462 let journal_path = paths.package_journal_file(&journal_key);
463 let committed = database::JournalReader::read_committed_package(&journal_path)?;
464
465 assert_eq!(committed.commands, Some(vec!["contoso".to_string()]));
466 assert_eq!(committed.bin, Some(vec!["bin\\tool.exe".to_string()]));
467 assert_eq!(committed.env_add_path, vec![r"env\add".to_string()]);
468
469 Ok(())
470 }
471}
472
473impl Drop for TempRootGuard {
474 fn drop(&mut self) {
475 flow::cleanup_temp_root(&self.path);
476 }
477}
478
479fn write_install_journal(
480 paths: &crate::core::paths::ResolvedPaths,
481 conn: &crate::database::DbConnection,
482 package_name: &str,
483 command_resolution: &ResolverResult,
484 commands: Option<&[String]>,
485 bin: Option<&str>,
486 env_add_path: Option<&str>,
487) -> anyhow::Result<()> {
488 let committed_package = database::get_package(conn, package_name)?.ok_or_else(|| {
489 anyhow::anyhow!("package '{package_name}' was not found after a successful install commit")
490 })?;
491
492 let journal_key = database::package_journal_key(
493 committed_package.name.as_str(),
494 committed_package.version.as_str(),
495 );
496
497 fs::create_dir_all(paths.package_journal_dir(&journal_key))?;
498
499 let mut writer = database::JournalWriter::open_for_package_in(
500 paths,
501 committed_package.name.as_str(),
502 committed_package.version.as_str(),
503 )?;
504
505 let (bin, bin_bindings) = match bin {
506 Some(raw_bin) => match shims::parse_journal_shim_bindings(Some(raw_bin)) {
507 Ok(bin_bindings) => {
508 let bin = shims::target_paths_from_journal_bindings(&bin_bindings);
509 let bin = if bin.is_empty() { None } else { Some(bin) };
510 let bin_bindings = if bin_bindings.is_empty() {
511 None
512 } else {
513 Some(bin_bindings)
514 };
515 (bin, bin_bindings)
516 }
517 Err(err) => {
518 warn!(
519 package = %package_name,
520 error = %err,
521 "failed to normalize install bin metadata into journal"
522 );
523 (None, None)
524 }
525 },
526 None => (None, None),
527 };
528
529 let env_add_path = match env_add_path {
533 Some(raw_env_add_path) => match shims::parse_target_paths(Some(raw_env_add_path)) {
534 Ok(env_add_path) => Some(env_add_path),
535 Err(err) => {
536 warn!(
537 package = %package_name,
538 error = %err,
539 "failed to normalize install env_add_path metadata into journal"
540 );
541 None
542 }
543 },
544 None => None,
545 };
546
547 writer.append(&database::JournalEntry::Metadata {
548 package_id: committed_package.name.clone(),
549 version: committed_package.version.clone(),
550 engine: committed_package.engine_kind.as_str().to_string(),
551 deployment_kind: committed_package.deployment_kind,
552 install_dir: committed_package.install_dir.clone(),
553 dependencies: committed_package.dependencies.clone(),
554 commands: commands.map(|commands| commands.to_vec()),
555 bin,
556 bin_bindings,
557 env_add_path: env_add_path.unwrap_or_default(),
558 command_resolution: Some(command_resolution.clone()),
559 engine_metadata: committed_package.engine_metadata.clone(),
560 })?;
561 writer.append(&database::JournalEntry::Commit {
562 installed_at: committed_package.installed_at.clone(),
563 })?;
564 writer.flush()?;
565
566 Ok(())
567}