winbrew_engines\windows/
msi.rs1use anyhow::{Context, Result, bail};
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5use tracing::{debug, warn};
6
7use crate::models::install::engine::{
8 EngineInstallReceipt, EngineKind, EngineMetadata, InstallScope,
9};
10use crate::models::install::installed::InstalledPackage;
11use crate::models::msi_inventory::records::MsiInventorySnapshot;
12use crate::windows_dep::installed::read_uninstall_registry_value;
13use crate::windows_dep::packages::msi_scan_inventory;
14
15const MSI_INSTALL_EXIT_CODES: &[i32] = &[0, 1641, 3010];
16const INSTALL_LOCATION_VALUE: &str = "InstallLocation";
17
18pub(crate) fn install(
25 download_path: &Path,
26 install_dir: &Path,
27 package_name: &str,
28) -> Result<EngineInstallReceipt> {
29 let snapshot = msi_scan_inventory(
30 download_path,
31 install_dir,
32 package_name,
33 InstallScope::Installed,
34 )
35 .with_context(|| format!("failed to scan MSI inventory for {}", package_name))?;
36
37 fs::create_dir_all(install_dir)
38 .with_context(|| format!("failed to create {}", install_dir.display()))?;
39
40 let status = Command::new("msiexec")
41 .arg("/i")
42 .arg(download_path)
43 .arg(format!(r"TARGETDIR={}", install_dir.display()))
44 .arg(format!(r"INSTALLDIR={}", install_dir.display()))
45 .arg("/qn")
46 .arg("/norestart")
47 .status()
48 .context("failed to launch msiexec for MSI install")?;
49
50 let exit_code = status
51 .code()
52 .ok_or_else(|| anyhow::anyhow!("msiexec terminated without an exit code"))?;
53
54 if !MSI_INSTALL_EXIT_CODES.contains(&exit_code) {
55 bail!(
56 "msiexec failed for {} with exit code {}",
57 package_name,
58 exit_code
59 );
60 }
61
62 let install_dir = resolve_install_dir(&snapshot, install_dir, package_name);
63
64 let snapshot_receipt = snapshot.receipt.clone();
65 let registry_keys = collect_registry_keys(&snapshot);
66 let shortcuts = collect_shortcuts(&snapshot);
67
68 let engine_metadata = Some(EngineMetadata::Msi {
69 product_code: snapshot_receipt.product_code,
70 upgrade_code: snapshot_receipt.upgrade_code,
71 scope: snapshot_receipt.scope,
72 registry_keys,
73 shortcuts,
74 });
75
76 let mut receipt = EngineInstallReceipt::new(
77 EngineKind::Msi,
78 install_dir.to_string_lossy().into_owned(),
79 engine_metadata,
80 );
81 receipt.msi_inventory_snapshot = Some(snapshot);
82
83 Ok(receipt)
84}
85
86pub(crate) fn remove(package: &InstalledPackage) -> Result<()> {
88 let product_code = match package.engine_metadata.as_ref() {
89 Some(EngineMetadata::Msi { product_code, .. }) => product_code.as_str(),
90 _ => bail!("missing MSI receipt metadata for '{}'", package.name),
91 };
92
93 let status = Command::new("msiexec")
94 .arg("/x")
95 .arg(product_code)
96 .arg("/qn")
97 .arg("/norestart")
98 .status()
99 .context("failed to launch msiexec for MSI removal")?;
100
101 let exit_code = status
102 .code()
103 .ok_or_else(|| anyhow::anyhow!("msiexec terminated without an exit code"))?;
104
105 if !MSI_INSTALL_EXIT_CODES.contains(&exit_code) {
106 bail!(
107 "msiexec removal failed for {} with exit code {}",
108 package.name,
109 exit_code
110 );
111 }
112
113 Ok(())
114}
115
116fn collect_registry_keys(snapshot: &MsiInventorySnapshot) -> Vec<String> {
117 let mut registry_keys = snapshot
118 .registry_entries
119 .iter()
120 .map(|entry| format!(r"{}\{}", entry.hive, entry.key_path))
121 .collect::<Vec<_>>();
122
123 registry_keys.sort_unstable();
124 registry_keys.dedup();
125
126 registry_keys
127}
128
129fn collect_shortcuts(snapshot: &MsiInventorySnapshot) -> Vec<String> {
130 let mut shortcuts = snapshot
131 .shortcuts
132 .iter()
133 .map(|shortcut| shortcut.path.clone())
134 .collect::<Vec<_>>();
135
136 shortcuts.sort_unstable();
137 shortcuts.dedup();
138
139 shortcuts
140}
141
142fn resolve_install_dir(
143 snapshot: &MsiInventorySnapshot,
144 requested_install_dir: &Path,
145 package_name: &str,
146) -> PathBuf {
147 match read_uninstall_registry_value(&snapshot.receipt.product_code, INSTALL_LOCATION_VALUE) {
148 Some(install_location) => {
149 let actual_install_dir = PathBuf::from(&install_location);
150
151 if !same_install_dir(&actual_install_dir, requested_install_dir) {
152 warn!(
153 package = package_name,
154 product_code = %snapshot.receipt.product_code,
155 requested_install_dir = %requested_install_dir.display(),
156 registry_install_location = %install_location,
157 "MSI InstallLocation differs from the path used to build the scan snapshot"
158 );
159
160 debug!(
161 package = package_name,
162 product_code = %snapshot.receipt.product_code,
163 file_count = snapshot.files.len(),
164 registry_entry_count = snapshot.registry_entries.len(),
165 shortcut_count = snapshot.shortcuts.len(),
166 component_count = snapshot.components.len(),
167 "MSI inventory details for InstallLocation mismatch"
168 );
169 }
170
171 actual_install_dir
172 }
173 None => {
174 warn!(
175 package = package_name,
176 product_code = %snapshot.receipt.product_code,
177 requested_install_dir = %requested_install_dir.display(),
178 file_count = snapshot.files.len(),
179 "MSI InstallLocation was not published; using the requested install directory"
180 );
181
182 debug!(
183 package = package_name,
184 product_code = %snapshot.receipt.product_code,
185 registry_entry_count = snapshot.registry_entries.len(),
186 shortcut_count = snapshot.shortcuts.len(),
187 component_count = snapshot.components.len(),
188 "MSI inventory details for missing InstallLocation"
189 );
190
191 requested_install_dir.to_path_buf()
192 }
193 }
194}
195
196fn same_install_dir(left: &Path, right: &Path) -> bool {
197 match (fs::canonicalize(left), fs::canonicalize(right)) {
198 (Ok(left), Ok(right)) => left == right,
199 _ => normalize_install_dir_text(left) == normalize_install_dir_text(right),
200 }
201}
202
203fn normalize_install_dir_text(path: &Path) -> String {
204 path.to_string_lossy()
205 .replace('/', "\\")
206 .trim_end_matches('\\')
207 .to_ascii_lowercase()
208}