Buscar software en la red

Documentación de la herramienta de administración remota de software en PowerShell

Descripción General

Este script de PowerShell proporciona una interfaz gráfica avanzada para buscar software instalado en equipos del dominio. Permite consultar remotamente el registro, proveedores de paquetes y WMI para identificar aplicaciones instaladas, además de ofrecer opciones para instalación desatendida y desinstalación remota.

Video demostrativo

Puedes ver la demostración completa de la herramienta en Odysee directamente desde esta página o abriendo el video en una pestaña nueva.

Abrir video en Odysee

Funciones Principales

Búsqueda remota de software

Get-InstalledSoftwareRemote

Orquesta la búsqueda de software instalado en un equipo remoto utilizando registro, Get-Package y Win32_Product como último recurso.

Get-RemoteSoftwareFromRegistry / Package / WMI

Funciones auxiliares que extraen la información de software desde el registro, proveedores de paquetes y WMI.

Instalación y desinstalación remota

Invoke-RemoteInstall

Permite copiar un instalador al equipo remoto y ejecutarlo de forma silenciosa, verificando la instalación mediante el registro.

Invoke-RemoteUninstall

Realiza desinstalaciones en cascada usando QuietUninstallString, UninstallString y Win32_Product según disponibilidad.

Instrucciones de Uso

  1. Cargar los equipos del dominio mediante el botón correspondiente.
  2. Escribir el nombre o parte del nombre del software a buscar.
  3. Seleccionar uno o varios equipos de la lista.
  4. Pulsar en "Buscar en seleccionados" para iniciar la consulta remota.
  5. Utilizar las opciones de exportación a CSV o copia de filas según sea necesario.
  6. Opcionalmente, configurar un instalador y usar los botones de instalación o desinstalación remota.

Ejemplo de Uso

Escenario típico:

  • Buscar un software concreto (por ejemplo, "Chrome" o "Office") en un conjunto de servidores o estaciones de trabajo.
  • Ver en qué equipos está instalado y qué versión tienen.
  • Exportar los resultados a CSV para documentar el estado de la red.
  • Desplegar una nueva versión del software en equipos seleccionados mediante la instalación remota.

Script Completo

A continuación se presenta el script completo de PowerShell para la búsqueda y administración remota de software. Puedes copiar el código completo usando el botón "Copiar" de tu navegador o editor.


#requires -Version 5.1

Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing

[System.Windows.Forms.Application]::EnableVisualStyles()

function Write-BuscarSoftwareLog {
param(
[Parameter(Mandatory)][string]$Message
)

try {
$logDirectory = 'C:\Logs'
$logPath = Join-Path -Path $logDirectory -ChildPath 'BuscarSoftwareRed.log'

if (-not (Test-Path -Path $logDirectory)) {
New-Item -Path $logDirectory -ItemType Directory -Force | Out-Null
}

$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$entry = "[$timestamp] $Message"
Add-Content -Path $logPath -Value $entry -Encoding UTF8
}
catch {
# No romper la ejecución del módulo si falla el log
}
}

function Get-ImageFromBase64 {
param(
[Parameter(Mandatory)][string]$Base64
)

if ([string]::IsNullOrWhiteSpace($Base64)) {
return $null
}

try {
$bytes = [Convert]::FromBase64String($Base64)
$ms = New-Object System.IO.MemoryStream(, $bytes)
return [System.Drawing.Image]::FromStream($ms)
}
catch {
return $null
}
}

function Get-DomainComputers {
param(
[ValidateSet('All', 'Servers', 'Workstations')]
[string]$Type = 'All'
)

try {
Import-Module ActiveDirectory -ErrorAction Stop
}
catch {
[System.Windows.Forms.MessageBox]::Show("No se pudo importar el módulo ActiveDirectory.\nAsegúrate de tener RSAT/AD
instalado.", "Error", 'OK', 'Error') | Out-Null
return @()
}

try {
$ldapFilter = '(objectClass=computer)'

switch ($Type) {
'Servers' {
$ldapFilter = '(operatingSystem=*Server*)'
}
'Workstations' {
$ldapFilter = '(!(operatingSystem=*Server*))'
}
}

$computers = Get-ADComputer -LDAPFilter $ldapFilter -Properties OperatingSystem |
Select-Object Name, OperatingSystem

return $computers | Sort-Object Name
}
catch {
$errorMessage = $_.Exception.Message
$fullError = $_ | Out-String
Write-BuscarSoftwareLog -Message "Get-DomainComputers error. Type=$Type; Message=$errorMessage; Details=$fullError"
[System.Windows.Forms.MessageBox]::Show("Error al obtener equipos del dominio. Detalle: $errorMessage", "Error", 'OK',
'Error') | Out-Null
return @()
}
}

function Get-RemoteSoftwareFromRegistry {
param(
[Parameter(Mandatory)][string]$ComputerName,
[Parameter(Mandatory)][string]$SearchText
)

$scriptBlock = {
param($SearchTextInner)
$paths = @(
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
)

$apps = foreach ($path in $paths) {
Get-ItemProperty -Path $path -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName -and ($_.DisplayName -like "*" + $SearchTextInner + "*") } |
Select-Object DisplayName, DisplayVersion, Publisher, InstallDate
}

return $apps
}

try {
$sessionOptions = New-PSSessionOption -OperationTimeout 120000
$result = Invoke-Command -ComputerName $ComputerName -ScriptBlock $scriptBlock -ArgumentList $SearchText -SessionOption
$sessionOptions -ErrorAction Stop
foreach ($app in $result) {
[PSCustomObject]@{
ComputerName = $ComputerName
DisplayName = $app.DisplayName
DisplayVersion = $app.DisplayVersion
Publisher = $app.Publisher
InstallDate = $app.InstallDate
}
}
}
catch {
$errorMessage = $_.Exception.Message
$fullError = $_ | Out-String
Write-BuscarSoftwareLog -Message "Get-RemoteSoftwareFromRegistry error. Computer=$ComputerName; Message=$errorMessage;
Details=$fullError"
@()
}
}

function Get-RemoteSoftwareFromPackage {
param(
[Parameter(Mandatory)][string]$ComputerName,
[Parameter(Mandatory)][string]$SearchText
)

$scriptBlock = {
param($SearchTextInner)
try {
$packages = Get-Package -ErrorAction Stop |
Where-Object { $_.Name -and ($_.Name -like "*" + $SearchTextInner + "*") }

foreach ($pkg in $packages) {
[PSCustomObject]@{
DisplayName = $pkg.Name
DisplayVersion = $pkg.Version
Publisher = $pkg.ProviderName
InstallDate = $null
}
}
}
catch {
@()
}
}

try {
$result = Invoke-Command -ComputerName $ComputerName -ScriptBlock $scriptBlock -ArgumentList $SearchText -ErrorAction
Stop
foreach ($app in $result) {
[PSCustomObject]@{
ComputerName = $ComputerName
DisplayName = $app.DisplayName
DisplayVersion = $app.DisplayVersion
Publisher = $app.Publisher
InstallDate = $app.InstallDate
}
}
}
catch {
$errorMessage = $_.Exception.Message
$fullError = $_ | Out-String
Write-BuscarSoftwareLog -Message "Get-RemoteSoftwareFromPackage error. Computer=$ComputerName; Message=$errorMessage;
Details=$fullError"
@()
}
}

function Get-RemoteSoftwareFromWmi {
param(
[Parameter(Mandatory)][string]$ComputerName,
[Parameter(Mandatory)][string]$SearchText
)

try {
$instances = Get-CimInstance -ClassName Win32_Product -ComputerName $ComputerName -OperationTimeoutSec 120 -ErrorAction
Stop |
Where-Object { $_.Name -and ($_.Name -like "*" + $SearchText + "*") }

foreach ($inst in $instances) {
[PSCustomObject]@{
ComputerName = $ComputerName
DisplayName = $inst.Name
DisplayVersion = $inst.Version
Publisher = $inst.Vendor
InstallDate = $inst.InstallDate
}
}
}
catch {
$errorMessage = $_.Exception.Message
$fullError = $_ | Out-String
Write-BuscarSoftwareLog -Message "Get-RemoteSoftwareFromWmi error. Computer=$ComputerName; Message=$errorMessage;
Details=$fullError"
@()
}
}

function Get-InstalledSoftwareRemote {
param(
[Parameter(Mandatory)] [string]$ComputerName,
[Parameter(Mandatory)] [string]$SearchText,
[switch]$UseWin32Product = $true
)

# 0) Comprobar que el equipo responde a ICMP
if (-not (Test-ComputerOnline -ComputerName $ComputerName)) {
Write-BuscarSoftwareLog -Message "Equipo no accesible por ICMP (posible problema de DNS, apagado o firewall ICMP):
$ComputerName"
return @()
}

$results = @()

# 1) Registro (método principal)
$results += Get-RemoteSoftwareFromRegistry -ComputerName $ComputerName -SearchText $SearchText

# 2) Get-Package como fallback si no hubo resultados
if (-not $results -or $results.Count -eq 0) {
$results += Get-RemoteSoftwareFromPackage -ComputerName $ComputerName -SearchText $SearchText
}

# 3) WMI (Win32_Product) como último recurso automático si no hay resultados
if ($UseWin32Product -and (-not $results -or $results.Count -eq 0)) {
$results += Get-RemoteSoftwareFromWmi -ComputerName $ComputerName -SearchText $SearchText
}

if (-not $results -or $results.Count -eq 0) {
return @()
}

return $results
}

function Get-DefaultSilentArguments {
param(
[Parameter(Mandatory)][string]$InstallerPath
)

$ext = [System.IO.Path]::GetExtension($InstallerPath).ToLowerInvariant()

switch ($ext) {
'.msi' { '/i `"{0}`" /qn /norestart' -f $InstallerPath }
'.exe' { '/silent /norestart' }
default { '' }
}
}

function Invoke-RemoteInstall {
param(
[Parameter(Mandatory)][string]$ComputerName,
[Parameter(Mandatory)][string]$InstallerPath,
[string]$SilentArgs,
[string]$CustomCommand,
[string]$ExpectedDisplayName
)

if (-not (Test-ComputerOnline -ComputerName $ComputerName)) {
Write-BuscarSoftwareLog -Message "Instalacion omitida. Equipo sin respuesta ICMP: $ComputerName"
return $false
}

if (-not (Test-Path -Path $InstallerPath)) {
Write-BuscarSoftwareLog -Message "Ruta de instalador no valida: $InstallerPath"
return $false
}

$fileName = [System.IO.Path]::GetFileName($InstallerPath)
$ext = [System.IO.Path]::GetExtension($InstallerPath).ToLowerInvariant()

if ([string]::IsNullOrWhiteSpace($SilentArgs)) {
$SilentArgs = Get-DefaultSilentArguments -InstallerPath $InstallerPath
}

$session = $null
try {
$session = New-PSSession -ComputerName $ComputerName -ErrorAction Stop

$remoteFolder = 'C:\Temp\SoftwareDeploy'

Invoke-Command -Session $session -ScriptBlock {
param($folder)
if (-not (Test-Path -Path $folder)) {
New-Item -Path $folder -ItemType Directory -Force | Out-Null
}
} -ArgumentList $remoteFolder -ErrorAction Stop

$uniqueName = ([guid]::NewGuid().ToString('N').Substring(0, 8) + '_' + $fileName)
$remotePath = Join-Path -Path $remoteFolder -ChildPath $uniqueName

Copy-Item -Path $InstallerPath -Destination $remotePath -ToSession $session -Force

$commandLine = $null

if (-not [string]::IsNullOrWhiteSpace($CustomCommand)) {
$commandLine = $CustomCommand.Replace('{InstallerPath}', '"{0}"' -f $remotePath)
}
else {
if ($ext -eq '.msi') {
if (-not $SilentArgs) { $SilentArgs = '/qn /norestart' }
$commandLine = "msiexec.exe /i `"$remotePath`" $SilentArgs"
}
else {
$commandLine = '"' + $remotePath + '" ' + $SilentArgs
}
}

$scriptBlock = {
param($cmd, $expectedName)

Start-Process -FilePath 'cmd.exe' -ArgumentList "/c $cmd" -WindowStyle Hidden -Wait -ErrorAction Stop

$installedOk = $true

if ($expectedName -and -not [string]::IsNullOrWhiteSpace($expectedName)) {
$paths = @(
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
)

$found = $false
foreach ($p in $paths) {
$item = Get-ItemProperty -Path $p -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName -and ($_.DisplayName -like "*" + $expectedName + "*") } |
Select-Object -First 1 DisplayName

if ($item) {
$found = $true
break
}
}

if (-not $found) {
$installedOk = $false
}
}

return $installedOk
}

$remoteResult = Invoke-Command -Session $session -ScriptBlock $scriptBlock -ArgumentList $commandLine,
$ExpectedDisplayName -ErrorAction Stop

if ($remoteResult) {
Write-BuscarSoftwareLog -Message "Instalacion verificada via PSSession en $ComputerName. Comando: $commandLine"
return $true
}
else {
Write-BuscarSoftwareLog -Message "Instalador se ejecuto pero no se encontro el software esperado en el registro en
$ComputerName. ExpectedDisplayName: $ExpectedDisplayName"
return $false
}
}
catch {
$msg = $_.Exception.Message
Write-BuscarSoftwareLog -Message "Error en instalacion remota para $ComputerName. Detalle: $msg"
return $false
}
finally {
if ($session) {
try { Remove-PSSession -Session $session -ErrorAction SilentlyContinue } catch { }
}
}
}

function Invoke-RemoteUninstall {
param(
[Parameter(Mandatory)][string]$ComputerName,
[Parameter(Mandatory)][string]$DisplayName
)

if (-not (Test-ComputerOnline -ComputerName $ComputerName)) {
Write-BuscarSoftwareLog -Message "Desinstalacion omitida. Equipo sin respuesta ICMP: $ComputerName"
return $false
}

$methods = @('RegistryQuiet', 'RegistryNormal', 'Win32Product')

foreach ($method in $methods) {
try {
switch ($method) {
'RegistryQuiet' {
$scriptBlock = {
param($name)
$paths = @(
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
)

foreach ($path in $paths) {
Get-ItemProperty -Path $path -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName -and ($_.DisplayName -like "*" + $name + "*") -and $_.QuietUninstallString } |
Select-Object -First 1 @{ Name = 'Command'; Expression = { $_.QuietUninstallString } }
}
}

$cmdObj = Invoke-Command -ComputerName $ComputerName -ScriptBlock $scriptBlock -ArgumentList $DisplayName -ErrorAction
Stop
if ($cmdObj -and $cmdObj.Command) {
$cmd = [string]$cmdObj.Command
$sb = {
param($uCmd)
Start-Process -FilePath 'cmd.exe' -ArgumentList "/c $uCmd" -WindowStyle Hidden -Wait -ErrorAction Stop
}
Invoke-Command -ComputerName $ComputerName -ScriptBlock $sb -ArgumentList $cmd -ErrorAction Stop
Write-BuscarSoftwareLog -Message "Desinstalacion exitosa (QuietUninstallString) en $ComputerName. DisplayName:
$DisplayName Comando: $cmd"
return $true
}
}
'RegistryNormal' {
$scriptBlock2 = {
param($name)
$paths = @(
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
)

foreach ($path in $paths) {
Get-ItemProperty -Path $path -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName -and ($_.DisplayName -like "*" + $name + "*") -and $_.UninstallString } |
Select-Object -First 1 DisplayName, UninstallString
}
}

$info = Invoke-Command -ComputerName $ComputerName -ScriptBlock $scriptBlock2 -ArgumentList $DisplayName -ErrorAction
Stop
if ($info -and $info.UninstallString) {
$u = [string]$info.UninstallString

if ($u -match 'msiexec(.+?)/I\s*\{') {
$u = $u -replace '/I', '/x'
if ($u -notmatch '/qn') {
$u += ' /qn /norestart'
}
}

$sb2 = {
param($uCmd)
Start-Process -FilePath 'cmd.exe' -ArgumentList "/c $uCmd" -WindowStyle Hidden -Wait -ErrorAction Stop
}
Invoke-Command -ComputerName $ComputerName -ScriptBlock $sb2 -ArgumentList $u -ErrorAction Stop
Write-BuscarSoftwareLog -Message "Desinstalacion iniciada (UninstallString) en $ComputerName. DisplayName: $DisplayName
Comando: $u"
return $true
}
}
'Win32Product' {
$sb3 = {
param($name)
$product = Get-CimInstance -ClassName Win32_Product -ErrorAction Stop |
Where-Object { $_.Name -and ($_.Name -like "*" + $name + "*") } |
Select-Object -First 1

if ($product) {
$result = $product.Uninstall()
return $result.ReturnValue
}
else {
return $null
}
}

$rv = Invoke-Command -ComputerName $ComputerName -ScriptBlock $sb3 -ArgumentList $DisplayName -ErrorAction Stop
if ($rv -eq 0) {
Write-BuscarSoftwareLog -Message "Desinstalacion exitosa via Win32_Product en $ComputerName. DisplayName: $DisplayName"
return $true
}
}
}
}
catch {
$msg = $_.Exception.Message
Write-BuscarSoftwareLog -Message "Metodo de desinstalacion '$method' fallo para $ComputerName. DisplayName: $DisplayName
Detalle: $msg"
}
}

Write-BuscarSoftwareLog -Message "Todos los metodos de desinstalacion fallaron para $ComputerName. DisplayName:
$DisplayName"
return $false
}

# Crear formulario
$form = New-Object System.Windows.Forms.Form
$form.Text = 'Buscador de software en la red'
$form.Size = New-Object System.Drawing.Size(900, 780)
$form.StartPosition = 'CenterScreen'
$form.TopMost = $false
$(
# Estilo general del formulario
$form.BackColor = [System.Drawing.Color]::FromArgb(245, 247, 250)
$form.Font = New-Object System.Drawing.Font('Segoe UI', 9)
$form.FormBorderStyle = 'FixedDialog'
$form.MaximizeBox = $false
)

# Etiqueta y textbox de búsqueda
$lblSearch = New-Object System.Windows.Forms.Label
$lblSearch.Text = 'Nombre (o parte) del software:'
$lblSearch.AutoSize = $true
$lblSearch.Location = New-Object System.Drawing.Point(10, 90)
$lblSearch.ForeColor = [System.Drawing.Color]::FromArgb(45, 52, 63)

$txtSearch = New-Object System.Windows.Forms.TextBox
$txtSearch.Location = New-Object System.Drawing.Point(190, 90)
$txtSearch.Width = 300
$txtSearch.BorderStyle = 'FixedSingle'

# Filtro de tipo de equipo
$lblType = New-Object System.Windows.Forms.Label
$lblType.Text = 'Tipo de equipo:'
$lblType.AutoSize = $true
$lblType.Location = New-Object System.Drawing.Point(10, 60)
$lblType.ForeColor = [System.Drawing.Color]::FromArgb(45, 52, 63)

$cmbType = New-Object System.Windows.Forms.ComboBox
$cmbType.Location = New-Object System.Drawing.Point(100, 55)
$cmbType.Width = 150
$cmbType.DropDownStyle = 'DropDownList'
$cmbType.FlatStyle = 'Flat'
[void]$cmbType.Items.Add('Todos')
[void]$cmbType.Items.Add('Servidores')
[void]$cmbType.Items.Add('Workstations')
$cmbType.SelectedIndex = 0

# Checkbox para uso de Win32_Product
$chkUseWin32Product = New-Object System.Windows.Forms.CheckBox
$chkUseWin32Product.Text = 'Usar Win32_Product (lento)'
$chkUseWin32Product.AutoSize = $true
$chkUseWin32Product.Location = New-Object System.Drawing.Point(500, 90)
$chkUseWin32Product.Checked = $true
$chkUseWin32Product.ForeColor = [System.Drawing.Color]::FromArgb(80, 80, 80)

# Controles para instalacion desatendida
$lblInstaller = New-Object System.Windows.Forms.Label
$lblInstaller.Text = 'Instalador:'
$lblInstaller.AutoSize = $true
$lblInstaller.Location = New-Object System.Drawing.Point(60, 600)
$lblInstaller.ForeColor = [System.Drawing.Color]::FromArgb(45, 52, 63)

$txtInstallerPath = New-Object System.Windows.Forms.TextBox
$txtInstallerPath.Location = New-Object System.Drawing.Point(125, 600)
$txtInstallerPath.Width = 360
$txtInstallerPath.BorderStyle = 'FixedSingle'

$btnBrowseInstaller = New-Object System.Windows.Forms.Button
$btnBrowseInstaller.Text = '...'
$btnBrowseInstaller.Location = New-Object System.Drawing.Point(488, 600)
$btnBrowseInstaller.Width = 40
$btnBrowseInstaller.FlatStyle = 'Flat'
$btnBrowseInstaller.BackColor = [System.Drawing.Color]::FromArgb(127, 140, 141)
$btnBrowseInstaller.ForeColor = [System.Drawing.Color]::White

$lblInstallArgs = New-Object System.Windows.Forms.Label
$lblInstallArgs.Text = 'Parametros silenciosos (opcional):'
$lblInstallArgs.AutoSize = $true
$lblInstallArgs.Location = New-Object System.Drawing.Point(60, 630)
$lblInstallArgs.ForeColor = [System.Drawing.Color]::FromArgb(45, 52, 63)

$txtInstallArgs = New-Object System.Windows.Forms.TextBox
$txtInstallArgs.Location = New-Object System.Drawing.Point(250, 630)
$txtInstallArgs.Width = 280
$txtInstallArgs.BorderStyle = 'FixedSingle'

$lblExpectedName = New-Object System.Windows.Forms.Label
$lblExpectedName.Text = 'Nombre esperado (registro):'
$lblExpectedName.AutoSize = $true
$lblExpectedName.Location = New-Object System.Drawing.Point(60, 660)
$lblExpectedName.ForeColor = [System.Drawing.Color]::FromArgb(45, 52, 63)

$txtExpectedName = New-Object System.Windows.Forms.TextBox
$txtExpectedName.Location = New-Object System.Drawing.Point(250, 658)
$txtExpectedName.Width = 280
$txtExpectedName.BorderStyle = 'FixedSingle'

# Botón cargar equipos
$btnLoadComputers = New-Object System.Windows.Forms.Button
$btnLoadComputers.Text = 'Cargar equipos'
$btnLoadComputers.Location = New-Object System.Drawing.Point(270, 55)
$btnLoadComputers.Width = 150
$btnLoadComputers.FlatStyle = 'Flat'
$btnLoadComputers.BackColor = [System.Drawing.Color]::FromArgb(52, 152, 219)
$btnLoadComputers.ForeColor = [System.Drawing.Color]::White

# Lista de equipos
$lblComputers = New-Object System.Windows.Forms.Label
$lblComputers.Text = 'Equipos del dominio:'
$lblComputers.AutoSize = $true
$lblComputers.Location = New-Object System.Drawing.Point(10, 130)
$lblComputers.ForeColor = [System.Drawing.Color]::FromArgb(45, 52, 63)

$lstComputers = New-Object System.Windows.Forms.ListBox
$lstComputers.Location = New-Object System.Drawing.Point(10, 160)
$lstComputers.Size = New-Object System.Drawing.Size(250, 400)
$lstComputers.SelectionMode = 'MultiExtended'
$lstComputers.BorderStyle = 'FixedSingle'

# Botón buscar
$btnSearch = New-Object System.Windows.Forms.Button
$btnSearch.Text = 'Buscar en seleccionados'
$btnSearch.Location = New-Object System.Drawing.Point(680, 90)
$btnSearch.Width = 180
$btnSearch.FlatStyle = 'Flat'
$btnSearch.BackColor = [System.Drawing.Color]::FromArgb(39, 174, 96)
$btnSearch.ForeColor = [System.Drawing.Color]::White

# Botón exportar a CSV
$btnExportCsv = New-Object System.Windows.Forms.Button
$btnExportCsv.Text = 'Exportar a CSV'
$btnExportCsv.Location = New-Object System.Drawing.Point(600, 570)
$btnExportCsv.Width = 130
$btnExportCsv.FlatStyle = 'Flat'
$btnExportCsv.BackColor = [System.Drawing.Color]::FromArgb(52, 73, 94)
$btnExportCsv.ForeColor = [System.Drawing.Color]::White

# Botón copiar fila
$btnCopySelected = New-Object System.Windows.Forms.Button
$btnCopySelected.Text = 'Copiar selección'
$btnCopySelected.Location = New-Object System.Drawing.Point(740, 570)
$btnCopySelected.Width = 130
$btnCopySelected.FlatStyle = 'Flat'
$btnCopySelected.BackColor = [System.Drawing.Color]::FromArgb(127, 140, 141)
$btnCopySelected.ForeColor = [System.Drawing.Color]::White

$btnInstall = New-Object System.Windows.Forms.Button
$btnInstall.Text = 'Instalar en seleccionados'
$btnInstall.Location = New-Object System.Drawing.Point(600, 600)
$btnInstall.Width = 190
$btnInstall.Height = 60
$btnInstall.FlatStyle = 'Flat'
$btnInstall.BackColor = [System.Drawing.Color]::FromArgb(231, 76, 60)
$btnInstall.ForeColor = [System.Drawing.Color]::White
$pictureLogo.Image = $logoImage
}

# DataGridView para resultados
$grid = New-Object System.Windows.Forms.DataGridView
$grid.Location = New-Object System.Drawing.Point(270, 160)
$grid.Size = New-Object System.Drawing.Size(600, 400)
$grid.ReadOnly = $true
$grid.AllowUserToAddRows = $false
$grid.AllowUserToDeleteRows = $false
$grid.AutoSizeColumnsMode = 'Fill'
$grid.BorderStyle = 'FixedSingle'
$grid.BackgroundColor = [System.Drawing.Color]::White
$grid.EnableHeadersVisualStyles = $false

$headerStyle = New-Object System.Windows.Forms.DataGridViewCellStyle
$headerStyle.BackColor = [System.Drawing.Color]::FromArgb(52, 73, 94)
$headerStyle.ForeColor = [System.Drawing.Color]::White
$headerStyle.Font = New-Object System.Drawing.Font('Segoe UI', 9, [System.Drawing.FontStyle]::Bold)
$grid.ColumnHeadersDefaultCellStyle = $headerStyle

$rowStyle = New-Object System.Windows.Forms.DataGridViewCellStyle
$rowStyle.BackColor = [System.Drawing.Color]::White
$rowStyle.SelectionBackColor = [System.Drawing.Color]::FromArgb(52, 152, 219)
$rowStyle.SelectionForeColor = [System.Drawing.Color]::White
$grid.DefaultCellStyle = $rowStyle

$altRowStyle = New-Object System.Windows.Forms.DataGridViewCellStyle
$altRowStyle.BackColor = [System.Drawing.Color]::FromArgb(245, 247, 250)
$grid.AlternatingRowsDefaultCellStyle = $altRowStyle

# Barra de progreso para carga/busqueda
$progressBar = New-Object System.Windows.Forms.ProgressBar
$progressBar.Location = New-Object System.Drawing.Point(60, 685)
$progressBar.Size = New-Object System.Drawing.Size(500, 15)
$progressBar.Style = 'Continuous'
$progressBar.Minimum = 0
$progressBar.Maximum = 100
$progressBar.Value = 0
$progressBar.Visible = $false

# Barra de progreso para instalacion/desinstalacion (parte inferior)
$progressBarInstall = New-Object System.Windows.Forms.ProgressBar
$progressBarInstall.Location = New-Object System.Drawing.Point(60, 685)
$progressBarInstall.Size = New-Object System.Drawing.Size(470, 15)
$progressBarInstall.Style = 'Continuous'
$progressBarInstall.Minimum = 0
$progressBarInstall.Maximum = 100
$progressBarInstall.Value = 0
$progressBarInstall.Visible = $false

# Label de estado
$lblStatus = New-Object System.Windows.Forms.Label
$lblStatus.Text = 'Listo.'
$lblStatus.AutoSize = $true
$lblStatus.Location = New-Object System.Drawing.Point(10, 560)
$lblStatus.ForeColor = [System.Drawing.Color]::FromArgb(90, 98, 110)

# Agregar controles al formulario
$form.Controls.AddRange(@(
$lblSearch,
$txtSearch,
$lblType,
$cmbType,
$btnLoadComputers,
$lblComputers,
$lstComputers,
$btnSearch,
$btnExportCsv,
$btnCopySelected,
$btnInstall,
$btnUninstall,
$pictureLogo,
$chkUseWin32Product,
$lblInstaller,
$txtInstallerPath,
$btnBrowseInstaller,
$lblInstallArgs,
$txtInstallArgs,
$lblExpectedName,
$txtExpectedName,
$grid,
$lblStatus,
$progressBar,
$progressBarInstall
))

# Evento: Cargar equipos
$btnLoadComputers.Add_Click({
$lblStatus.Text = 'Cargando equipos del dominio...'
$form.Refresh()

$btnLoadComputers.Enabled = $false
$btnSearch.Enabled = $false
$lstComputers.Enabled = $false

$progressBar.Style = 'Marquee'
$progressBar.MarqueeAnimationSpeed = 30
$progressBar.Visible = $true

switch ($cmbType.SelectedItem) {
'Servidores' { $type = 'Servers' }
'Workstations' { $type = 'Workstations' }
default { $type = 'All' }
}

$lstComputers.Items.Clear()
$computers = Get-DomainComputers -Type $type

if (-not $computers -or $computers.Count -eq 0) {
$lblStatus.Text = 'No se encontraron equipos o hubo un error.'
}
else {
foreach ($c in $computers) {
[void]$lstComputers.Items.Add($c.Name)
}

$lblStatus.Text = "Equipos cargados: $($computers.Count)"
}

$progressBar.Visible = $false
$progressBar.Style = 'Continuous'
$progressBar.Value = 0

$btnLoadComputers.Enabled = $true
$btnSearch.Enabled = $true
$lstComputers.Enabled = $true
})

# Convertir lista de resultados a DataTable para el grid
function ConvertTo-DataTable {
param([Parameter(Mandatory, ValueFromPipeline)][PSObject]$InputObject)
begin {
$dt = New-Object System.Data.DataTable
$first = $true
}
process {
$obj = $_
if ($first) {
foreach ($prop in $obj.PSObject.Properties.Name) {
[void]$dt.Columns.Add($prop)
}
$first = $false
}
$row = $dt.NewRow()
foreach ($prop in $obj.PSObject.Properties.Name) {
$row[$prop] = $obj.$prop
}
[void]$dt.Rows.Add($row)
}
end {
return $dt
}
}

function Test-ComputerOnline {
param(
[Parameter(Mandatory)][string]$ComputerName
)

try {
return Test-Connection -ComputerName $ComputerName -Count 1 -Quiet -ErrorAction SilentlyContinue
}
catch {
return $false
}
}

# Evento: Buscar software en equipos seleccionados
$btnSearch.Add_Click({
$searchText = $txtSearch.Text.Trim()
if ([string]::IsNullOrWhiteSpace($searchText)) {
[System.Windows.Forms.MessageBox]::Show('Escribe el nombre (o parte) del software a buscar.', 'Aviso', 'OK',
'Information') | Out-Null
return
}

if ($lstComputers.SelectedItems.Count -eq 0) {
[System.Windows.Forms.MessageBox]::Show('Selecciona al menos un equipo de la lista.', 'Aviso', 'OK', 'Information') |
Out-Null
return
}

$selectedCount = $lstComputers.SelectedItems.Count
if ($selectedCount -gt 20) {
$answer = [System.Windows.Forms.MessageBox]::Show(
"Has seleccionado $selectedCount equipos. La búsqueda puede tardar varios minutos. ¿Deseas continuar?",
'Confirmación',
[System.Windows.Forms.MessageBoxButtons]::YesNo,
[System.Windows.Forms.MessageBoxIcon]::Question
)

if ($answer -ne [System.Windows.Forms.DialogResult]::Yes) {
return
}
}

$lblStatus.Text = 'Buscando en equipos seleccionados...'
$form.Refresh()

$btnSearch.Enabled = $false
$btnLoadComputers.Enabled = $false
$lstComputers.Enabled = $false

$progressBar.Style = 'Continuous'
$progressBar.Minimum = 0
$progressBar.Maximum = $lstComputers.SelectedItems.Count
$progressBar.Value = 0
$progressBar.Visible = $true

$useWin32 = $chkUseWin32Product.Checked

$modulePath = Join-Path -Path $PSScriptRoot -ChildPath 'BuscarSoftwareRedModule.psm1'
if (-not (Test-Path $modulePath)) {
[System.Windows.Forms.MessageBox]::Show("No se encontró el módulo de lógica: $modulePath", 'Error', 'OK', 'Error') |
Out-Null
$btnSearch.Enabled = $true
$btnLoadComputers.Enabled = $true
$lstComputers.Enabled = $true
return
}

$jobs = @()
$offlineComputers = @()

foreach ($item in $lstComputers.SelectedItems) {
$computerName = [string]$item

if (-not (Test-ComputerOnline -ComputerName $computerName)) {
Write-BuscarSoftwareLog -Message "Equipo no accesible por ICMP (posible problema de DNS, apagado o firewall ICMP):
$computerName"
$offlineComputers += $computerName
continue
}

$lblStatus.Text = "Creando tarea para $computerName..."
$form.Refresh()

$job = Start-Job -ScriptBlock {
param($ComputerNameInner, $SearchTextInner, $UseWin32Inner, $ModulePathInner)

Import-Module $ModulePathInner -ErrorAction Stop

if ($UseWin32Inner) {
Get-InstalledSoftwareRemote -ComputerName $ComputerNameInner -SearchText $SearchTextInner -UseWin32Product
}
else {
Get-InstalledSoftwareRemote -ComputerName $ComputerNameInner -SearchText $SearchTextInner -UseWin32Product:$false
}
} -ArgumentList $computerName, $searchText, $useWin32, $modulePath

$jobs += $job
}

$results = @()

foreach ($job in $jobs) {
$lblStatus.Text = "Esperando resultados de tareas en segundo plano..."
$form.Refresh()

$null = Wait-Job -Job $job -Timeout 300

$res = Receive-Job -Job $job -ErrorAction SilentlyContinue
if ($res) {
$results += $res
}

Remove-Job -Job $job -Force -ErrorAction SilentlyContinue | Out-Null

if ($progressBar.Value -lt $progressBar.Maximum) {
$progressBar.Value += 1
}
}

if ($results.Count -eq 0) {
$lblStatus.Text = 'Búsqueda finalizada. No se encontraron coincidencias.'
$grid.DataSource = $null
}
else {
$lblStatus.Text = "Búsqueda finalizada. Resultados: $($results.Count)"

# Construir DataTable explícito con normalización de InstallDate
$dt = New-Object System.Data.DataTable
[void]$dt.Columns.Add('ComputerName')
[void]$dt.Columns.Add('DisplayName')
[void]$dt.Columns.Add('DisplayVersion')
[void]$dt.Columns.Add('Publisher')
[void]$dt.Columns.Add('InstallDate')

foreach ($r in $results) {
$installDateText = ''

if ($null -ne $r.InstallDate -and $r.InstallDate -ne '') {
try {
$dtParsed = [datetime]$r.InstallDate
$installDateText = $dtParsed.ToString('yyyy-MM-dd')
}
catch {
$installDateText = [string]$r.InstallDate
}
}

$row = $dt.NewRow()
$row['ComputerName'] = [string]$r.ComputerName
$row['DisplayName'] = [string]$r.DisplayName
$row['DisplayVersion'] = [string]$r.DisplayVersion
$row['Publisher'] = [string]$r.Publisher
$row['InstallDate'] = $installDateText
[void]$dt.Rows.Add($row)
}

$grid.DataSource = $null
$grid.DataSource = $dt
}

$progressBar.Visible = $false
$progressBar.Value = 0

if ($offlineComputers.Count -gt 0) {
$mensajeOffline = "Los siguientes equipos no están accesibles (offline o sin respuesta ICMP):`r`n`r`n" +
($offlineComputers -join "`r`n")
[System.Windows.Forms.MessageBox]::Show($mensajeOffline, 'Equipos no accesibles', 'OK', 'Warning') | Out-Null
}

$btnSearch.Enabled = $true
$btnLoadComputers.Enabled = $true
$lstComputers.Enabled = $true
})

function Copy-CurrentGridRowToClipboard {
if (-not $grid.CurrentRow) { return }

$values = @()
foreach ($cell in $grid.CurrentRow.Cells) {
$values += [string]$cell.Value
}

$text = [string]::Join("`t", $values)
[System.Windows.Forms.Clipboard]::SetText($text)
}

$btnExportCsv.Add_Click({
if (-not $grid.DataSource -or -not $grid.Rows.Count) {
[System.Windows.Forms.MessageBox]::Show('No hay resultados para exportar.', 'Aviso', 'OK', 'Information') | Out-Null
return
}

$dialog = New-Object System.Windows.Forms.SaveFileDialog
$dialog.Filter = 'CSV (*.csv)|*.csv|Todos los archivos (*.*)|*.*'
$dialog.Title = 'Guardar resultados como CSV'
$dialog.FileName = 'Resultados_BuscarSoftware.csv'

if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
$path = $dialog.FileName
$dataTable = [System.Data.DataTable]$grid.DataSource
$dataTable | Export-Csv -Path $path -NoTypeInformation -Encoding UTF8
[System.Windows.Forms.MessageBox]::Show("Resultados exportados a: $path", 'Información', 'OK', 'Information') | Out-Null
}
})

$btnCopySelected.Add_Click({
if (-not $grid.CurrentRow) {
[System.Windows.Forms.MessageBox]::Show('No hay ninguna fila seleccionada.', 'Aviso', 'OK', 'Information') | Out-Null
return
}

Copy-CurrentGridRowToClipboard
[System.Windows.Forms.MessageBox]::Show('Datos de la fila copiados al portapapeles.', 'Información', 'OK',
'Information') | Out-Null
})

$btnBrowseInstaller.Add_Click({
$dialog = New-Object System.Windows.Forms.OpenFileDialog
$dialog.Filter = 'Instaladores (*.exe;*.msi)|*.exe;*.msi|Todos los archivos (*.*)|*.*'
$dialog.Title = 'Seleccionar instalador'

if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
$txtInstallerPath.Text = $dialog.FileName
}
})

$btnInstall.Add_Click({
if ($lstComputers.SelectedItems.Count -eq 0) {
[System.Windows.Forms.MessageBox]::Show('Selecciona al menos un equipo para instalar el software.', 'Aviso', 'OK',
'Information') | Out-Null
return
}

$installerPath = $txtInstallerPath.Text.Trim()
if ([string]::IsNullOrWhiteSpace($installerPath)) {
[System.Windows.Forms.MessageBox]::Show('Selecciona o escribe la ruta del instalador.', 'Aviso', 'OK', 'Information') |
Out-Null
return
}

$silentArgs = $txtInstallArgs.Text.Trim()

$customCommand = $null

$expectedDisplayName = $txtExpectedName.Text.Trim()
if ([string]::IsNullOrWhiteSpace($expectedDisplayName)) {
$expectedDisplayName = $txtSearch.Text.Trim()
}
$txtExpectedName.Text = $expectedDisplayName

$msg = "Se intentara instalar el software de forma desatendida en los equipos seleccionados.\n\n" +
"Instalador: $installerPath\n" +
"Parametros: $silentArgs\n\n" +
"Nota: Se probaran varios metodos (Invoke-Command, copia remota, WMI)."

$answer = [System.Windows.Forms.MessageBox]::Show($msg, 'Confirmar instalación',
[System.Windows.Forms.MessageBoxButtons]::YesNo, [System.Windows.Forms.MessageBoxIcon]::Question)
if ($answer -ne [System.Windows.Forms.DialogResult]::Yes) { return }

$btnInstall.Enabled = $false
$btnSearch.Enabled = $false
$btnLoadComputers.Enabled = $false
$lstComputers.Enabled = $false

$lblStatus.Text = 'Iniciando instalación desatendida en equipos seleccionados...'
$form.Refresh()

$progressBar.Style = 'Continuous'
$progressBar.Minimum = 0
$progressBar.Maximum = $lstComputers.SelectedItems.Count
$progressBar.Value = 0
$progressBar.Visible = $true

$ok = 0
$fail = 0

foreach ($item in $lstComputers.SelectedItems) {
$computerName = [string]$item
$lblStatus.Text = "Instalando en $computerName..."
$form.Refresh()

$result = Invoke-RemoteInstall -ComputerName $computerName -InstallerPath $installerPath -SilentArgs $silentArgs
-CustomCommand $customCommand -ExpectedDisplayName $expectedDisplayName
if ($result) { $ok++ } else { $fail++ }

if ($progressBarInstall.Value -lt $progressBarInstall.Maximum) {
$progressBarInstall.Value += 1
}
}

$progressBarInstall.Visible = $false
$progressBarInstall.Value = 0

$lblStatus.Text = "Instalacion finalizada. Exitosas: $ok. Fallidas: $fail."

$btnInstall.Enabled = $true
$btnSearch.Enabled = $true
$btnLoadComputers.Enabled = $true
$lstComputers.Enabled = $true

[System.Windows.Forms.MessageBox]::Show("Instalacion finalizada. Exitosas: $ok. Fallidas: $fail.", 'Resultado de
instalación', 'OK', 'Information') | Out-Null
})

$btnUninstall.Add_Click({
if (-not $grid.DataSource -or -not $grid.Rows.Count) {
[System.Windows.Forms.MessageBox]::Show('No hay resultados en la tabla de software para desinstalar.', 'Aviso', 'OK',
'Information') | Out-Null
return
}

if ($grid.SelectedRows.Count -eq 0) {
[System.Windows.Forms.MessageBox]::Show('Selecciona al menos una fila en la tabla de resultados para desinstalar.',
'Aviso', 'OK', 'Information') | Out-Null
return
}

$count = $grid.SelectedRows.Count
$msg = "Se intentara desinstalar de forma desatendida el software de las filas seleccionadas.\n\n" +
"Filas seleccionadas: $count\n\n" +
"Se usaran metodos en cascada (QuietUninstallString, UninstallString, Win32_Product)."

$answer = [System.Windows.Forms.MessageBox]::Show($msg, 'Confirmar desinstalación',
[System.Windows.Forms.MessageBoxButtons]::YesNo, [System.Windows.Forms.MessageBoxIcon]::Warning)
if ($answer -ne [System.Windows.Forms.DialogResult]::Yes) { return }

$btnUninstall.Enabled = $false
$btnInstall.Enabled = $false
$btnSearch.Enabled = $false
$btnLoadComputers.Enabled = $false
$lstComputers.Enabled = $false

$lblStatus.Text = 'Iniciando desinstalación en equipos seleccionados...'
$form.Refresh()

$progressBarInstall.Style = 'Continuous'
$progressBarInstall.Minimum = 0
$progressBarInstall.Maximum = $grid.SelectedRows.Count
$progressBarInstall.Value = 0
$progressBarInstall.Visible = $true

$ok = 0
$fail = 0

foreach ($row in $grid.SelectedRows) {
$computerName = [string]$row.Cells['ComputerName'].Value
$displayName = [string]$row.Cells['DisplayName'].Value

if ([string]::IsNullOrWhiteSpace($computerName) -or [string]::IsNullOrWhiteSpace($displayName)) {
continue
}

$lblStatus.Text = "Desinstalando '$displayName' en $computerName..."
$form.Refresh()

$result = Invoke-RemoteUninstall -ComputerName $computerName -DisplayName $displayName
if ($result) { $ok++ } else { $fail++ }

if ($progressBar.Value -lt $progressBar.Maximum) {
$progressBar.Value += 1
}
}

$progressBar.Visible = $false
$progressBar.Value = 0

$lblStatus.Text = "Desinstalacion finalizada. Exitosas: $ok. Fallidas: $fail."

$btnUninstall.Enabled = $true
$btnInstall.Enabled = $true
$btnSearch.Enabled = $true
$btnLoadComputers.Enabled = $true
$lstComputers.Enabled = $true

[System.Windows.Forms.MessageBox]::Show("Desinstalacion finalizada. Exitosas: $ok. Fallidas: $fail.", 'Resultado de
desinstalación', 'OK', 'Information') | Out-Null
})

$grid.Add_CellDoubleClick({
param($sender, $e)
if ($e.RowIndex -ge 0) {
Copy-CurrentGridRowToClipboard
}
})

[void]$form.ShowDialog()

Módulo BuscarSoftwareRedModule.psm1

Este módulo contiene las funciones reutilizables para obtener equipos del dominio y consultar software instalado remotamente. Aquí se muestra su contenido completo.


#requires -Version 5.1

function Get-DomainComputers {
    param(
        [ValidateSet('All', 'Servers', 'Workstations')]
        [string]$Type = 'All'
    )

    try {
        Import-Module ActiveDirectory -ErrorAction Stop
    }
    catch {
        throw "No se pudo importar el módulo ActiveDirectory. Asegúrate de tener RSAT/AD instalado. Detalle: $_"
    }

    try {
        $ldapFilter = '(objectClass=computer)'

        switch ($Type) {
            'Servers' {
                $ldapFilter = '(operatingSystem=*Server*)'
            }
            'Workstations' {
                $ldapFilter = '(!(operatingSystem=*Server*))'
            }
        }

        $computers = Get-ADComputer -LDAPFilter $ldapFilter -Properties OperatingSystem |
        Select-Object Name, OperatingSystem

        return $computers | Sort-Object Name
    }
    catch {
        throw "Error al obtener equipos del dominio: $_"
    }
}

function Get-RemoteSoftwareFromRegistry {
    param(
        [Parameter(Mandatory)][string]$ComputerName,
        [Parameter(Mandatory)][string]$SearchText
    )

    $scriptBlock = {
        param($SearchTextInner)
        $paths = @(
            'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
            'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
        )

        $apps = foreach ($path in $paths) {
            Get-ItemProperty -Path $path -ErrorAction SilentlyContinue |
            Where-Object { $_.DisplayName -and ($_.DisplayName -like "*" + $SearchTextInner + "*") } |
            Select-Object DisplayName, DisplayVersion, Publisher, InstallDate
        }

        return $apps
    }

    try {
        $sessionOptions = New-PSSessionOption -OperationTimeout 120000
        $result = Invoke-Command -ComputerName $ComputerName -ScriptBlock $scriptBlock -ArgumentList $SearchText -SessionOption $sessionOptions -ErrorAction Stop
        foreach ($app in $result) {
            [PSCustomObject]@{
                ComputerName   = $ComputerName
                DisplayName    = $app.DisplayName
                DisplayVersion = $app.DisplayVersion
                Publisher      = $app.Publisher
                InstallDate    = $app.InstallDate
            }
        }
    }
    catch {
        @()
    }
}

function Get-RemoteSoftwareFromPackage {
    param(
        [Parameter(Mandatory)][string]$ComputerName,
        [Parameter(Mandatory)][string]$SearchText
    )

    $scriptBlock = {
        param($SearchTextInner)
        try {
            $packages = Get-Package -ErrorAction Stop |
            Where-Object { $_.Name -and ($_.Name -like "*" + $SearchTextInner + "*") }

            foreach ($pkg in $packages) {
                [PSCustomObject]@{
                    DisplayName    = $pkg.Name
                    DisplayVersion = $pkg.Version
                    Publisher      = $pkg.ProviderName
                    InstallDate    = $null
                }
            }
        }
        catch {
            @()
        }
    }

    try {
        $sessionOptions = New-PSSessionOption -OperationTimeout 120000
        $result = Invoke-Command -ComputerName $ComputerName -ScriptBlock $scriptBlock -ArgumentList $SearchText -SessionOption $sessionOptions -ErrorAction Stop
        foreach ($app in $result) {
            [PSCustomObject]@{
                ComputerName   = $ComputerName
                DisplayName    = $app.DisplayName
                DisplayVersion = $app.DisplayVersion
                Publisher      = $app.Publisher
                InstallDate    = $app.InstallDate
            }
        }
    }
    catch {
        @()
    }
}

function Get-RemoteSoftwareFromWmi {
    param(
        [Parameter(Mandatory)][string]$ComputerName,
        [Parameter(Mandatory)][string]$SearchText
    )

    try {
        $instances = Get-CimInstance -ClassName Win32_Product -ComputerName $ComputerName -OperationTimeoutSec 120 -ErrorAction Stop |
        Where-Object { $_.Name -and ($_.Name -like "*" + $SearchText + "*") }

        foreach ($inst in $instances) {
            [PSCustomObject]@{
                ComputerName   = $ComputerName
                DisplayName    = $inst.Name
                DisplayVersion = $inst.Version
                Publisher      = $inst.Vendor
                InstallDate    = $inst.InstallDate
            }
        }
    }
    catch {
        @()
    }
}

function Get-InstalledSoftwareRemote {
    param(
        [Parameter(Mandatory)] [string]$ComputerName,
        [Parameter(Mandatory)] [string]$SearchText,
        [switch]$UseWin32Product = $true
    )

    if (-not (Test-ComputerOnline -ComputerName $ComputerName)) {
        return @()
    }

    $results = @()

    # 1) Registro (método principal)
    $results += Get-RemoteSoftwareFromRegistry -ComputerName $ComputerName -SearchText $SearchText

    # 2) Get-Package como fallback si no hubo resultados
    if (-not $results -or $results.Count -eq 0) {
        $results += Get-RemoteSoftwareFromPackage -ComputerName $ComputerName -SearchText $SearchText
    }

    # 3) WMI (Win32_Product) como último recurso automático si no hay resultados
    if ($UseWin32Product -and (-not $results -or $results.Count -eq 0)) {
        $results += Get-RemoteSoftwareFromWmi -ComputerName $ComputerName -SearchText $SearchText
    }

    if (-not $results -or $results.Count -eq 0) {
        return @()
    }

    return $results
}

function Test-ComputerOnline {
    param(
        [Parameter(Mandatory)][string]$ComputerName
    )

    try {
        # ICMP + resolución DNS; -Quiet devuelve True/False
        return Test-Connection -ComputerName $ComputerName -Count 1 -Quiet -ErrorAction SilentlyContinue
    }
    catch {
        return $false
    }
}