use crate::{
error::{AppInfoError, FileIconError},
rgba_buffer_len, AppInfo, AppInfoReport, AppInfoWarning, Icon, ListOptions, Result,
};
use scopeguard::defer;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use windows::{
core::{HSTRING, PWSTR},
Win32::{
Foundation::{
ERROR_FILE_NOT_FOUND, ERROR_NO_MORE_ITEMS, RPC_E_CHANGED_MODE, SIZE, WIN32_ERROR,
},
Graphics::{
Gdi::DeleteObject,
Imaging::{
CLSID_WICImagingFactory, GUID_WICPixelFormat32bppRGBA, IWICImagingFactory,
WICBitmapDitherTypeNone, WICBitmapInterpolationModeFant,
WICBitmapPaletteTypeCustom, WICBitmapUseAlpha, WICRect,
},
},
System::{
Com::{CoCreateInstance, CoInitialize, CoUninitialize, CLSCTX_ALL},
Registry::{
RegCloseKey, RegEnumKeyExW, RegOpenKeyExW, RegQueryValueExW, HKEY,
HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_32KEY, KEY_WOW64_64KEY,
REG_DWORD, REG_EXPAND_SZ, REG_SAM_FLAGS, REG_SZ, REG_VALUE_TYPE,
},
},
UI::Shell::{
IShellItemImageFactory, SHCreateItemFromParsingName, SIIGBF_ICONONLY, SIIGBF_SCALEUP,
},
},
};
const UNINSTALL_KEY: &str = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall";
pub(crate) fn get_installed_apps(options: ListOptions) -> Result<AppInfoReport> {
let mut report = AppInfoReport::default();
let mut seen = HashSet::new();
let mut views = vec![(KEY_READ | KEY_WOW64_32KEY, "32-bit")];
#[cfg(target_pointer_width = "64")]
views.push((KEY_READ | KEY_WOW64_64KEY, "64-bit"));
for (root, root_name) in [
(HKEY_LOCAL_MACHINE, "HKEY_LOCAL_MACHINE"),
(HKEY_CURRENT_USER, "HKEY_CURRENT_USER"),
] {
for &(access, view_name) in &views {
scan_registry_key(
root,
root_name,
view_name,
access,
options,
&mut seen,
&mut report,
)?;
}
}
report.apps.sort_by(|left, right| {
left.name
.to_lowercase()
.cmp(&right.name.to_lowercase())
.then_with(|| left.path.cmp(&right.path))
});
Ok(report)
}
#[allow(clippy::too_many_arguments)]
fn scan_registry_key(
root: HKEY,
root_name: &str,
view_name: &str,
access: REG_SAM_FLAGS,
options: ListOptions,
seen: &mut HashSet<String>,
report: &mut AppInfoReport,
) -> Result<()> {
let mut key = HKEY::default();
let key_name = HSTRING::from(UNINSTALL_KEY);
let open_result = unsafe { RegOpenKeyExW(root, &key_name, 0, access, &mut key) };
if open_result == ERROR_FILE_NOT_FOUND {
return Ok(());
}
if open_result.is_err() {
return record_registry_problem(
options,
report,
format!("opening {root_name} {view_name} uninstall key"),
open_result,
);
}
defer!(unsafe {
let _ = RegCloseKey(key);
});
let mut index = 0u32;
loop {
let mut subkey_name = [0u16; 256];
let mut subkey_name_len = subkey_name.len() as u32;
let enum_result = unsafe {
RegEnumKeyExW(
key,
index,
PWSTR(subkey_name.as_mut_ptr()),
&mut subkey_name_len,
None,
PWSTR::null(),
None,
None,
)
};
if enum_result == ERROR_NO_MORE_ITEMS {
break;
}
if enum_result.is_err() {
record_registry_problem(
options,
report,
format!("enumerating {root_name} {view_name} uninstall key at index {index}"),
enum_result,
)?;
break;
}
let subkey = String::from_utf16_lossy(&subkey_name[..subkey_name_len as usize]);
match parse_registry_app(root, access, &subkey, options) {
Ok(Some((app, warning))) => {
let identity = app_identity(&app);
if seen.insert(identity) {
report.apps.push(app);
if let Some(warning) = warning {
report.warnings.push(warning);
}
}
}
Ok(None) => {}
Err(error) if options.is_strict() => return Err(error),
Err(error) => report.warnings.push(AppInfoWarning {
path: None,
message: format!("{root_name} {view_name} {subkey}: {error}"),
}),
}
index += 1;
}
Ok(())
}
fn record_registry_problem(
options: ListOptions,
report: &mut AppInfoReport,
action: String,
code: WIN32_ERROR,
) -> Result<()> {
let error = AppInfoError::RegistryError(format!("{action}: Windows error {}", code.0));
if options.is_strict() {
return Err(error);
}
report.warnings.push(AppInfoWarning {
path: None,
message: error.to_string(),
});
Ok(())
}
fn parse_registry_app(
root: HKEY,
access: REG_SAM_FLAGS,
subkey: &str,
options: ListOptions,
) -> Result<Option<(AppInfo, Option<AppInfoWarning>)>> {
let full_key_path = format!("{UNINSTALL_KEY}\\{subkey}");
let mut key = HKEY::default();
let key_name = HSTRING::from(&full_key_path);
let result = unsafe { RegOpenKeyExW(root, &key_name, 0, access, &mut key) };
if result.is_err() {
return Err(AppInfoError::RegistryError(format!(
"failed to open registry key {full_key_path}: Windows error {}",
result.0
)));
}
defer!(unsafe {
let _ = RegCloseKey(key);
});
if read_registry_u32(key, "SystemComponent").unwrap_or_default() == 1
|| read_registry_string(key, "ParentKeyName").is_ok()
{
return Ok(None);
}
let display_name = read_registry_string(key, "DisplayName")?;
if display_name.trim().is_empty() {
return Ok(None);
}
let version = read_registry_string(key, "DisplayVersion").ok();
let publisher = read_registry_string(key, "Publisher").ok();
let install_location = read_registry_string(key, "InstallLocation").ok();
let install_date = read_registry_string(key, "InstallDate").ok();
let display_icon = read_registry_string(key, "DisplayIcon").ok();
let display_icon_path = display_icon.as_deref().and_then(parse_display_icon);
let install_path = install_location
.as_deref()
.map(expand_windows_environment)
.map(PathBuf::from)
.filter(|path| path.exists());
let executable = display_icon_path.filter(|path| path.exists()).or_else(|| {
install_path
.as_deref()
.and_then(|path| find_main_executable(path, &display_name))
});
let app_path = executable.clone().or(install_path).unwrap_or_default();
let identifier = is_product_code(subkey).then(|| subkey.to_string());
let (icon, warning) =
if let (Some(icon_size), Some(icon_path)) = (options.icon_size(), executable.as_deref()) {
match get_file_icon(icon_path, icon_size.get()) {
Ok(icon) => (Some(icon), None),
Err(error) if options.is_strict() => return Err(error),
Err(error) => (
None,
Some(AppInfoWarning {
path: Some(icon_path.to_path_buf()),
message: error.to_string(),
}),
),
}
} else {
(None, None)
};
Ok(Some((
AppInfo {
name: display_name,
version,
path: app_path,
icon,
identifier,
publisher,
install_date,
},
warning,
)))
}
fn app_identity(app: &AppInfo) -> String {
if let Some(identifier) = &app.identifier {
return format!("id:{}", identifier.to_lowercase());
}
if !app.path.as_os_str().is_empty() {
return format!("path:{}", app.path.to_string_lossy().to_lowercase());
}
format!(
"name:{}|version:{}",
app.name.to_lowercase(),
app.version.as_deref().unwrap_or_default().to_lowercase()
)
}
fn is_product_code(value: &str) -> bool {
let bytes = value.as_bytes();
bytes.len() == 38
&& bytes.first() == Some(&b'{')
&& bytes.last() == Some(&b'}')
&& [9, 14, 19, 24].iter().all(|index| bytes[*index] == b'-')
&& bytes[1..37]
.iter()
.enumerate()
.all(|(index, byte)| [8, 13, 18, 23].contains(&index) || byte.is_ascii_hexdigit())
}
fn read_registry_string(key: HKEY, value_name: &str) -> Result<String> {
let value_name = HSTRING::from(value_name);
let mut data_type = REG_VALUE_TYPE(0);
let mut data_size = 0u32;
let size_result = unsafe {
RegQueryValueExW(
key,
&value_name,
None,
Some(&mut data_type),
None,
Some(&mut data_size),
)
};
if size_result.is_err() || data_size == 0 {
return Err(AppInfoError::RegistryError(format!(
"failed to read registry value {value_name:?}: Windows error {}",
size_result.0
)));
}
if data_type != REG_SZ && data_type != REG_EXPAND_SZ {
return Err(AppInfoError::RegistryError(format!(
"registry value {value_name:?} is not a string"
)));
}
if data_size % 2 != 0 {
return Err(AppInfoError::RegistryError(format!(
"registry value {value_name:?} has an invalid UTF-16 byte length"
)));
}
let mut buffer = vec![0u16; data_size.div_ceil(2) as usize];
let read_result = unsafe {
RegQueryValueExW(
key,
&value_name,
None,
Some(&mut data_type),
Some(buffer.as_mut_ptr().cast::<u8>()),
Some(&mut data_size),
)
};
if read_result.is_err() {
return Err(AppInfoError::RegistryError(format!(
"failed to read registry value {value_name:?}: Windows error {}",
read_result.0
)));
}
let used_units = (data_size / 2) as usize;
let end = buffer[..used_units]
.iter()
.position(|&unit| unit == 0)
.unwrap_or(used_units);
let value = String::from_utf16(&buffer[..end]).map_err(|error| {
AppInfoError::RegistryError(format!("invalid UTF-16 registry string: {error}"))
})?;
Ok(if data_type == REG_EXPAND_SZ {
expand_windows_environment(&value)
} else {
value
})
}
fn read_registry_u32(key: HKEY, value_name: &str) -> Result<u32> {
let value_name = HSTRING::from(value_name);
let mut data_type = REG_VALUE_TYPE(0);
let mut data_size = std::mem::size_of::<u32>() as u32;
let mut value = 0u32;
let result = unsafe {
RegQueryValueExW(
key,
&value_name,
None,
Some(&mut data_type),
Some((&mut value as *mut u32).cast::<u8>()),
Some(&mut data_size),
)
};
if result.is_err() || data_type != REG_DWORD || data_size != 4 {
return Err(AppInfoError::RegistryError(format!(
"failed to read DWORD registry value {value_name:?}"
)));
}
Ok(value)
}
fn parse_display_icon(value: &str) -> Option<PathBuf> {
let expanded = expand_windows_environment(value.trim());
let trimmed = expanded.trim();
if trimmed.is_empty() || trimmed.starts_with("@{") {
return None;
}
let path = if let Some(rest) = trimmed.strip_prefix('"') {
rest.rfind('"').map(|end| &rest[..end]).unwrap_or(rest)
} else if let Some((candidate, index)) = trimmed.rsplit_once(',') {
if index.trim().parse::<i32>().is_ok() {
candidate.trim()
} else {
trimmed
}
} else {
trimmed
};
(!path.is_empty()).then(|| PathBuf::from(path))
}
fn expand_windows_environment(value: &str) -> String {
let mut output = String::with_capacity(value.len());
let mut remaining = value;
while let Some(start) = remaining.find('%') {
output.push_str(&remaining[..start]);
let after_start = &remaining[start + 1..];
let Some(end) = after_start.find('%') else {
output.push_str(&remaining[start..]);
return output;
};
let variable = &after_start[..end];
if let Some(replacement) = std::env::var_os(variable) {
output.push_str(&replacement.to_string_lossy());
} else {
output.push('%');
output.push_str(variable);
output.push('%');
}
remaining = &after_start[end + 1..];
}
output.push_str(remaining);
output
}
fn find_main_executable(install_path: &Path, app_name: &str) -> Option<PathBuf> {
if install_path.is_file() {
return install_path
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("exe"))
.then(|| install_path.to_path_buf());
}
if !install_path.is_dir() {
return None;
}
let mut executables = Vec::new();
collect_executables(install_path, 0, &mut executables);
let normalized_app_name = normalize_executable_name(app_name);
executables.sort_by(|left, right| {
executable_score(right, &normalized_app_name)
.cmp(&executable_score(left, &normalized_app_name))
.then_with(|| left.cmp(right))
});
executables.into_iter().next()
}
fn collect_executables(directory: &Path, depth: u8, output: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(directory) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_file()
&& path
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("exe"))
{
output.push(path);
} else if depth < 1 && file_type.is_dir() && !file_type.is_symlink() {
collect_executables(&path, depth + 1, output);
}
}
}
fn executable_score(path: &Path, normalized_app_name: &str) -> i32 {
let name = path
.file_stem()
.map(|name| normalize_executable_name(&name.to_string_lossy()))
.unwrap_or_default();
let mut score = 0;
if name == normalized_app_name {
score += 100;
} else if !normalized_app_name.is_empty() && name.contains(normalized_app_name) {
score += 50;
}
if [
"unins",
"uninstall",
"remove",
"setup",
"update",
"helper",
"crash",
]
.iter()
.any(|pattern| name.contains(pattern))
{
score -= 100;
}
score
}
fn normalize_executable_name(value: &str) -> String {
value
.chars()
.filter(|character| character.is_alphanumeric())
.flat_map(char::to_lowercase)
.collect()
}
pub(crate) fn get_file_icon(path: &Path, size: u16) -> Result<Icon> {
struct ComToken;
impl Drop for ComToken {
fn drop(&mut self) {
unsafe { CoUninitialize() };
}
}
let com_result = unsafe { CoInitialize(None) };
let _com_token = if com_result.is_ok() {
Some(ComToken)
} else if com_result == RPC_E_CHANGED_MODE {
None
} else {
return Err(FileIconError::Failed(format!(
"COM initialization failed with HRESULT {com_result:?}"
))
.into());
};
let path_string = HSTRING::from(path.to_string_lossy().as_ref());
let image_factory: IShellItemImageFactory =
unsafe { SHCreateItemFromParsingName(&path_string, None) }
.map_err(|error| icon_failure("creating shell item", error))?;
let bitmap = unsafe {
image_factory.GetImage(
SIZE {
cx: size.into(),
cy: size.into(),
},
SIIGBF_ICONONLY | SIIGBF_SCALEUP,
)
}
.map_err(|error| icon_failure("extracting shell icon", error))?;
defer!(unsafe {
let _ = DeleteObject(bitmap);
});
let imaging_factory: IWICImagingFactory =
unsafe { CoCreateInstance(&CLSID_WICImagingFactory, None, CLSCTX_ALL) }
.map_err(|error| icon_failure("creating WIC factory", error))?;
let source =
unsafe { imaging_factory.CreateBitmapFromHBITMAP(bitmap, None, WICBitmapUseAlpha) }
.map_err(|error| icon_failure("creating WIC bitmap", error))?;
let scaler = unsafe { imaging_factory.CreateBitmapScaler() }
.map_err(|error| icon_failure("creating WIC scaler", error))?;
unsafe {
scaler.Initialize(
&source,
size.into(),
size.into(),
WICBitmapInterpolationModeFant,
)
}
.map_err(|error| icon_failure("scaling icon", error))?;
let converter = unsafe { imaging_factory.CreateFormatConverter() }
.map_err(|error| icon_failure("creating WIC format converter", error))?;
unsafe {
converter.Initialize(
&scaler,
&GUID_WICPixelFormat32bppRGBA,
WICBitmapDitherTypeNone,
None,
0.0,
WICBitmapPaletteTypeCustom,
)
}
.map_err(|error| icon_failure("converting icon to RGBA", error))?;
let mut pixels = vec![0u8; rgba_buffer_len(size.into(), size.into())?];
let rectangle = WICRect {
X: 0,
Y: 0,
Width: size.into(),
Height: size.into(),
};
unsafe { converter.CopyPixels(&rectangle, u32::from(size) * 4, &mut pixels) }
.map_err(|error| icon_failure("copying RGBA pixels", error))?;
Icon::from_rgba(size.into(), size.into(), pixels)
}
fn icon_failure(context: &str, error: windows::core::Error) -> AppInfoError {
FileIconError::Failed(format!("{context}: {error}")).into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_display_icon_variants() {
assert_eq!(
parse_display_icon(r#""C:\Program Files\Example, Inc\app.exe",-1"#),
Some(PathBuf::from(r"C:\Program Files\Example, Inc\app.exe"))
);
assert_eq!(
parse_display_icon(r"C:\Tools\app.exe,0"),
Some(PathBuf::from(r"C:\Tools\app.exe"))
);
assert_eq!(
parse_display_icon(r"C:\Tools\with,comma\app.exe"),
Some(PathBuf::from(r"C:\Tools\with,comma\app.exe"))
);
}
#[test]
fn recognizes_product_codes() {
assert!(is_product_code("{01234567-89AB-CDEF-0123-456789ABCDEF}"));
assert!(!is_product_code("ExampleApp"));
}
#[test]
fn ranks_primary_executables() {
let preferred = Path::new(r"C:\App\Example App.exe");
let helper = Path::new(r"C:\App\ExampleUpdater.exe");
assert!(executable_score(preferred, "exampleapp") > executable_score(helper, "exampleapp"));
}
}