<# .SYNOPSIS Instala FastDesk desde PowerShell, sin pasar por el navegador. .DESCRIPTION Descarga el instalador del canal indicado, VERIFICA su hash SHA-256 contra el manifiesto y lo instala en silencio para el usuario actual (sin pedir administrador). Es el mismo paquete que usa la autoactualizacion de la app, asi que las dos vias quedan en un solo canal coherente (ADR 0033). Uso tipico (una linea): irm https://app.clickserver.cl/instalar | iex Con opciones (descargar primero el script y luego ejecutarlo): .\instalar.ps1 -Silencioso .\instalar.ps1 -Origen D:\instaladores # sin internet .\instalar.ps1 -Desinstalar Que NO hace: no verifica la firma de la autoactualizacion (minisign) - PowerShell 5.1 no trae Ed25519- y el instalador aun no lleva firma de codigo de Windows (SmartScreen puede avisar si lo abres a mano). La integridad la da HTTPS + el hash del manifiesto: compara el hash que imprime este script con el que publica la pagina de descargas. .PARAMETER BaseUrl Carpeta HTTPS que contiene latest.json y el instalador. .PARAMETER Origen Carpeta local con latest.json y el instalador (pruebas o sin internet). .PARAMETER SHA256Esperado Hash que tu ya conoces (de la web); si se da, manda sobre el del manifiesto. .PARAMETER Directorio Carpeta de instalacion. Por defecto la del instalador (%LOCALAPPDATA%). .PARAMETER Silencioso No muestra la ventana del instalador. .PARAMETER Forzar Instala aunque ya exista otra instalacion de FastDesk. .PARAMETER Desinstalar Quita la instalacion por usuario hecha por este script. .PARAMETER ConfiarCertificado Solo equipos INTERNOS y como administrador: instala el certificado publico de la organizacion (autofirmado) como editor de confianza, para que Windows reconozca lo firmado con el. Exige -HuellaCertificado. .PARAMETER HuellaCertificado Huella SHA-1 que el administrador conoce por otra via (no por este servidor). Si el certificado descargado no coincide, no se instala nada. .PARAMETER CertificadoUrl De donde bajar el .cer (con -Origen se lee de esa carpeta). #> [CmdletBinding()] param( [string]$BaseUrl = 'https://app.clickserver.cl/actualizaciones/estable', [string]$Origen, [string]$SHA256Esperado, [string]$Directorio, [switch]$Silencioso, [switch]$Forzar, [switch]$Desinstalar, [switch]$ConfiarCertificado, [string]$HuellaCertificado, [string]$CertificadoUrl = 'https://app.clickserver.cl/firma-publica.cer' ) $ErrorActionPreference = 'Stop' $Nombre = 'FastDesk' try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch { } function Falla([string]$mensaje) { throw [Exception]::new($mensaje) } # Instalaciones de FastDesk que Windows conoce: por usuario (NSIS, la de este # script y la autoactualizacion) o por equipo (MSI de Program Files). function Buscar-Instalaciones { $claves = @( @{ Ambito = 'usuario'; Ruta = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall' }, @{ Ambito = 'equipo'; Ruta = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall' }, @{ Ambito = 'equipo'; Ruta = 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall' } ) foreach ($c in $claves) { if (-not (Test-Path $c.Ruta)) { continue } foreach ($k in Get-ChildItem $c.Ruta -ErrorAction SilentlyContinue) { $p = Get-ItemProperty $k.PSPath -ErrorAction SilentlyContinue if ($p.DisplayName -eq $Nombre) { [pscustomobject]@{ Ambito = $c.Ambito; Version = $p.DisplayVersion Ubicacion = $p.InstallLocation; Desinstalador = $p.UninstallString } } } } } try { if ($ConfiarCertificado) { # Equipos internos: el certificado es autofirmado, asi que cada equipo debe # decidir confiar en el UNA vez. La huella llega por un canal distinto al # del archivo (la da el administrador), para que un servidor comprometido # no pueda colar otro certificado. $huellaPedida = ($HuellaCertificado -replace '[^0-9A-Fa-f]', '').ToUpper() $rutaCer = Join-Path ([IO.Path]::GetTempPath()) 'firma-publica.cer' if ($Origen) { Copy-Item (Join-Path $Origen 'firma-publica.cer') $rutaCer -Force } else { if (-not $CertificadoUrl.StartsWith('https://')) { Falla 'CertificadoUrl debe empezar por https://' } Invoke-WebRequest -Uri $CertificadoUrl -OutFile $rutaCer -UseBasicParsing } $cer = New-Object Security.Cryptography.X509Certificates.X509Certificate2 $rutaCer Write-Host "Certificado: $($cer.Subject)" Write-Host "Huella SHA-1: $($cer.Thumbprint) Vence: $($cer.NotAfter.ToString('yyyy-MM-dd'))" if (-not $huellaPedida) { Remove-Item $rutaCer -Force -ErrorAction SilentlyContinue Falla 'Falta -HuellaCertificado. Comparala con la que te dio el administrador y vuelve a ejecutar con ella.' } if ($cer.Thumbprint -ne $huellaPedida) { Remove-Item $rutaCer -Force -ErrorAction SilentlyContinue Falla "La huella NO coincide (esperada $huellaPedida). No se instalo el certificado." } $esAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $esAdmin) { Falla 'Confiar en un certificado para todo el equipo exige PowerShell como administrador.' } foreach ($almacen in 'Root', 'TrustedPublisher') { Import-Certificate -FilePath $rutaCer -CertStoreLocation "Cert:LocalMachine$almacen" | Out-Null } Remove-Item $rutaCer -Force -ErrorAction SilentlyContinue Write-Host "[OK] Certificado de confianza instalado (Root y Editores de confianza)." -ForegroundColor Green Write-Host " Para quitarlo: Remove-Item Cert:LocalMachineRoot$($cer.Thumbprint), Cert:LocalMachineTrustedPublisher$($cer.Thumbprint)" return } if ($Desinstalar) { $propia = Buscar-Instalaciones | Where-Object { $_.Ambito -eq 'usuario' } | Select-Object -First 1 if (-not $propia) { Falla "No hay una instalacion de $Nombre por usuario que quitar." } $exe = ($propia.Desinstalador -replace '^"?([^"]+?)"?(\s.*)?$', '$1') Write-Host "Quitando $Nombre $($propia.Version) ($($propia.Ubicacion))..." $p = Start-Process -FilePath $exe -ArgumentList '/S' -Wait -PassThru if ($p.ExitCode -ne 0) { Falla "El desinstalador termino con codigo $($p.ExitCode)." } Write-Host "[OK] $Nombre desinstalado." -ForegroundColor Green return } # --- 1. Manifiesto ---------------------------------------------------------- if ($Origen) { $rutaManifiesto = Join-Path $Origen 'latest.json' if (-not (Test-Path $rutaManifiesto)) { Falla "No existe $rutaManifiesto" } $manifiesto = Get-Content -Raw -Encoding UTF8 $rutaManifiesto | ConvertFrom-Json } else { if (-not $BaseUrl.StartsWith('https://')) { Falla 'BaseUrl debe empezar por https://' } try { $manifiesto = Invoke-RestMethod -Uri ($BaseUrl.TrimEnd('/') + '/latest.json') -UseBasicParsing } catch { Falla "No se pudo leer el manifiesto en $BaseUrl (?hay internet?): $($_.Exception.Message)" } } $plataforma = $manifiesto.platforms.'windows-x86_64' if (-not $plataforma) { Falla 'El manifiesto no trae un instalador para Windows x64.' } $version = $manifiesto.version $hashManifiesto = $manifiesto.sha256 $hashEsperado = if ($SHA256Esperado) { $SHA256Esperado } else { $hashManifiesto } if (-not $hashEsperado) { Falla 'El manifiesto no trae hash SHA-256: no se instala nada sin poder verificarlo.' } # --- 2. ?Ya hay una instalacion? --------------------------------------------- $existentes = @(Buscar-Instalaciones) if ($existentes.Count -gt 0 -and -not $Forzar) { foreach ($e in $existentes) { Write-Host "- Ya hay $Nombre $($e.Version) instalado para el $($e.Ambito): $($e.Ubicacion)" -ForegroundColor Yellow } if ($existentes | Where-Object { $_.Ambito -eq 'equipo' }) { Falla "Hay una instalacion por equipo (MSI). Desinstalala primero (Configuracion > Aplicaciones) o usa -Forzar para tener las dos." } Write-Host " Para actualizar usa el aviso dentro de la app, o -Forzar para reinstalar." -ForegroundColor Yellow return } # --- 3. Obtener el instalador y verificar ---------------------------------------- $archivo = [IO.Path]::GetFileName(([Uri]$plataforma.url).LocalPath) $archivo = [Uri]::UnescapeDataString($archivo) if ($Origen) { $instalador = Join-Path $Origen $archivo if (-not (Test-Path $instalador)) { Falla "No existe $instalador" } } else { $instalador = Join-Path ([IO.Path]::GetTempPath()) $archivo Write-Host "Descargando $archivo ($version)..." try { Invoke-WebRequest -Uri $plataforma.url -OutFile $instalador -UseBasicParsing } catch { Falla "No se pudo descargar el instalador: $($_.Exception.Message)" } } $hashReal = (Get-FileHash -Algorithm SHA256 -Path $instalador).Hash Write-Host "SHA-256: $hashReal" if ($hashReal -ne $hashEsperado.Trim()) { if (-not $Origen) { Remove-Item $instalador -Force -ErrorAction SilentlyContinue } Falla "El hash NO coincide (esperado $hashEsperado). El archivo esta danado o fue alterado: no se instalo nada." } Write-Host '[OK] Hash verificado.' -ForegroundColor Green # --- 4. Instalar ------------------------------------------------------------- $opciones = @() if ($Silencioso) { $opciones += '/S' } if ($Directorio) { $opciones += "/D=$Directorio" } # /D debe ir al final y sin comillas Write-Host "Instalando $Nombre $version para el usuario actual..." $p = Start-Process -FilePath $instalador -ArgumentList $opciones -Wait -PassThru if (-not $Origen) { Remove-Item $instalador -Force -ErrorAction SilentlyContinue } if ($p.ExitCode -ne 0) { Falla "El instalador termino con codigo $($p.ExitCode)." } $nueva = Buscar-Instalaciones | Where-Object { $_.Ambito -eq 'usuario' } | Select-Object -First 1 if (-not $nueva) { Falla 'El instalador termino bien pero Windows no registra la instalacion.' } Write-Host "[OK] $Nombre $($nueva.Version) instalado en $($nueva.Ubicacion)" -ForegroundColor Green } catch { # Mensaje corto en vez del volcado de PowerShell; sin `exit`, que cerraria la # consola de quien ejecuto `irm ... | iex`. Write-Host "[X] $($_.Exception.Message)" -ForegroundColor Red $global:LASTEXITCODE = 1 # Ejecutado como archivo si devolvemos codigo de salida; via `iex` no hay # `$PSCommandPath` y se deja la consola abierta. if ($PSCommandPath) { exit 1 } }