#Requires -Version 5.1 <# .SYNOPSIS Silent VLC desktop updater for SYSTEM / N-central / NinjaOne. Version 1.0.0. .DESCRIPTION Inventories both HKLM registry views, local user hives (including unloaded profiles), Store/Appx registrations, standard VLC paths and AdditionalPaths. Updates registered machine-wide desktop VLC. If outdated copies exist, they are consolidated to one current native x86/x64 MSI installation. It downloads and validates the replacement BEFORE removing anything. No VLC present = no install. Per-user, Store, portable, broken registrations, custom unsafe paths, ARM64, and newer/prerelease versions are reported, not silently declared updated. No Win32_Product, winget dependency, reboot, password or interactive prompts. By default running VLC blocks changes. -CloseRunningVlc stops it (playback ends). Logs/transcript, before/after inventory, summary JSON and verbose MSI logs: %ProgramData%\Smilar\VlcUpdater\ (SYSTEM/Administrators only). Official stable release is discovered each run; only reviewed VLC 3.x is applied. A later major release fails for review instead of silently using an older release. Network/proxy and certificate validation must work as SYSTEM. TLS 1.2 required. Exit 0=current/updated/absent or successful detection; 2=unresolved copies; 1618=busy/running VLC; 3010=restart required, incomplete; 1460=installer timeout; 1=other error. Native MSI failures are propagated. Configure RMM NOT to reboot. Allow 90 minutes for multi-install cleanup. Pilot before fleet deployment. An unfinished removal/install leaves a protected pending.json marker. Review and recover from its retained installer/logs before clearing it for a fresh run. No exhaustive whole-disk portable-file search; AdditionalPaths are exact folders. .EXAMPLE .\Update-VlcMediaPlayer.ps1 -DetectOnly .EXAMPLE .\Update-VlcMediaPlayer.ps1 -CloseRunningVlc #> [CmdletBinding()] param([switch]$DetectOnly, [switch]$CloseRunningVlc, [string[]]$AdditionalPaths = @()) $ErrorActionPreference = 'Stop' $script:Findings = [Collections.Generic.List[object]]::new() $script:Problems = [Collections.Generic.List[string]]::new() $script:RunPath = $null function Log([string]$Message) { Write-Host ('[{0}] {1}' -f (Get-Date -Format o), $Message) } function Problem([string]$Message) { $script:Problems.Add($Message); Log "UNRESOLVED: $Message" } function Parse-Version([string]$Text) { if ($Text -match '^\s*(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?\s*$') { $revision = if ($Matches[4]) { [int]$Matches[4] } else { 0 } return [version]::new([int]$Matches[1],[int]$Matches[2],[int]$Matches[3],$revision) } return $null } function Latest-Release([string]$Html) { $versions = @([regex]::Matches($Html, 'href="(\d+\.\d+\.\d+(?:\.\d+)?)/"') | ForEach-Object { $_.Groups[1].Value } | Sort-Object { Parse-Version $_ } -Descending -Unique) if (-not $versions.Count) { throw 'No stable numeric release found in official release index.' } return $versions[0] } function Assert-NoReparse([string]$Path) { $item = Get-Item -LiteralPath $Path -Force while ($null -ne $item) { if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "Reparse path rejected: $Path" } $item = if ($item -is [IO.DirectoryInfo]) { $item.Parent } else { $item.Directory } } } function Init-Log { $root = Join-Path $env:ProgramData 'Smilar\VlcUpdater' $null = New-Item -ItemType Directory -Path $root -Force Assert-NoReparse $root $acl = [Security.AccessControl.DirectorySecurity]::new() $acl.SetAccessRuleProtection($true,$false) foreach ($sid in @('S-1-5-18','S-1-5-32-544')) { $rule = [Security.AccessControl.FileSystemAccessRule]::new( [Security.Principal.SecurityIdentifier]::new($sid),'FullControl','ContainerInherit,ObjectInherit','None','Allow') $acl.AddAccessRule($rule) } $acl.SetOwner([Security.Principal.SecurityIdentifier]::new('S-1-5-18')) Set-Acl -LiteralPath $root -AclObject $acl $script:PendingPath = Join-Path $root 'pending.json' $script:RunPath = Join-Path $root ((Get-Date -Format 'yyyyMMdd-HHmmss')+'-'+[guid]::NewGuid().ToString('N')) $null = New-Item -ItemType Directory $script:RunPath $null = Start-Transcript -Path (Join-Path $script:RunPath 'transcript.log') Log "Logs: $script:RunPath" } function Add-Record([string]$Scope,[string]$Kind,[string]$Key,[string]$Name,[string]$Version,[string]$Path,[string]$Uninstall,[string]$Publisher) { $actual = $null if ($Path -and (Test-Path -LiteralPath (Join-Path $Path 'vlc.exe'))) { $v = [Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $Path 'vlc.exe')) $actual = '{0}.{1}.{2}.{3}' -f $v.FileMajorPart,$v.FileMinorPart,$v.FileBuildPart,$v.FilePrivatePart } $script:Findings.Add([pscustomobject]@{Scope=$Scope;Kind=$Kind;Key=$Key;Name=$Name;Version=$Version;FileVersion=$actual;Path=$Path;Uninstall=$Uninstall;Publisher=$Publisher}) } function Read-Uninstall($Base,[string]$Prefix,[string]$Scope) { $key = $Base.OpenSubKey($Prefix) if ($null -eq $key) { return } try { foreach ($name in $key.GetSubKeyNames()) { $entry=$key.OpenSubKey($name) if ($null -eq $entry) { continue } try { $display=[string]$entry.GetValue('DisplayName') if ($display -notmatch '^VLC(?:\s+media\s+player)?(?:\s|$)') { continue } $path=[string]$entry.GetValue('InstallLocation') $uninstall=[string]$entry.GetValue('UninstallString') if (-not $path) { $icon=[string]$entry.GetValue('DisplayIcon') if ($icon -match '^"?([A-Za-z]:\\.*?\\vlc\.exe)"?(?:,\d+)?$') { $path=Split-Path $Matches[1] } elseif ($uninstall -match '^"?([A-Za-z]:\\.*?\\uninstall\.exe)"?\s*$') { $path=Split-Path $Matches[1] } } $kind=if ($entry.GetValue('WindowsInstaller') -eq 1) {'MSI'} else {'NSIS'} Add-Record $Scope $kind $name $display ([string]$entry.GetValue('DisplayVersion')) $path $uninstall ([string]$entry.GetValue('Publisher')) } finally { $entry.Dispose() } } } finally { $key.Dispose() } } function Inventory { $script:Findings.Clear() $arp='SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' $views=@([Microsoft.Win32.RegistryView]::Registry32) if ([Environment]::Is64BitOperatingSystem) { $views += [Microsoft.Win32.RegistryView]::Registry64 } foreach ($view in $views) { $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey('LocalMachine',$view) try { Read-Uninstall $base $arp 'Machine' } finally { $base.Dispose() } } $paths=[Collections.Generic.List[string]]::new() foreach ($root in @($env:ProgramW6432,$env:ProgramFiles,${env:ProgramFiles(x86)})) { if ($root) { $paths.Add((Join-Path $root 'VideoLAN\VLC')) } } $profiles=[Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList') try { foreach ($sid in $profiles.GetSubKeyNames()) { if ($sid -notmatch '^S-1-(5-21|12-1)-') { continue } $profile=$profiles.OpenSubKey($sid) try { $homePath=[Environment]::ExpandEnvironmentVariables([string]$profile.GetValue('ProfileImagePath')) } finally { $profile.Dispose() } if (-not $homePath -or -not (Test-Path -LiteralPath $homePath)) { Problem "Profile unavailable: $sid"; continue } foreach ($relative in @('AppData\Local\Programs\VideoLAN\VLC','AppData\Local\VideoLAN\VLC','AppData\Roaming\VideoLAN\VLC')) { $paths.Add((Join-Path $homePath $relative)) } $loaded=[Microsoft.Win32.Registry]::Users.OpenSubKey($sid) $mount=$sid; $mounted=$false if ($null -ne $loaded) { $loaded.Dispose() } else { $hive=Join-Path $homePath 'NTUSER.DAT' if (-not (Test-Path -LiteralPath $hive)) { Problem "User hive unavailable: $sid"; continue } $mount='SmilarVlc_'+[guid]::NewGuid().ToString('N') & $script:RegExe load "HKU\$mount" $hive 2>&1 | Out-Host if ($LASTEXITCODE -ne 0) { Problem "Could not inspect unloaded user hive: $sid"; continue } $mounted=$true } try { foreach ($suffix in @($arp,'SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall')) { Read-Uninstall ([Microsoft.Win32.Registry]::Users) "$mount\$suffix" "User:$sid" } } finally { if ($mounted) { & $script:RegExe unload "HKU\$mount" 2>&1 | Out-Host if ($LASTEXITCODE -ne 0) { Problem "Unable to unload temporary hive HKU\$mount" } } } } } finally { $profiles.Dispose() } foreach ($path in @($paths.ToArray()+$AdditionalPaths | Sort-Object -Unique)) { if ($path -and (Test-Path -LiteralPath (Join-Path $path 'vlc.exe')) -and -not @($script:Findings | Where-Object { $_.Path.TrimEnd('\') -eq $path.TrimEnd('\') }).Count) { Add-Record 'Unregistered' 'PortableOrUnregistered' '' 'VLC media player' '' $path '' '' } } if (Get-Command Get-AppxPackage -ErrorAction SilentlyContinue) { try { foreach ($app in @(Get-AppxPackage -AllUsers -ErrorAction Stop | Where-Object { $_.Name -match 'VideoLAN.*VLC|^VLC$' })) { Add-Record 'Store' 'Appx' $app.PackageFullName $app.Name "$($app.Version)" $app.InstallLocation '' $app.Publisher } } catch { Problem 'Store inventory failed; desktop inventory alone is not complete.' } } else { Problem 'Appx inventory command unavailable; Store inventory not verified.' } return @($script:Findings.ToArray() | Sort-Object Scope,Kind,Key,Path -Unique) } function Assert-MachineRecord($Record) { if ($Record.Scope -ne 'Machine' -or $Record.Publisher -notmatch 'VideoLAN') { throw 'Not a recognised machine-wide VideoLAN installation.' } if (-not $Record.Path -or $Record.Path -match '["\r\n]' -or $Record.Path -notmatch '^[A-Za-z]:\\') { throw 'Invalid installation path.' } $full=[IO.Path]::GetFullPath($Record.Path).TrimEnd('\') $roots=@($env:ProgramW6432,$env:ProgramFiles,${env:ProgramFiles(x86)}) | Where-Object { $_ } if (-not @($roots | Where-Object { $full.StartsWith($_.TrimEnd('\')+'\',[StringComparison]::OrdinalIgnoreCase) }).Count) { throw 'Custom path outside Program Files requires separate review.' } Assert-NoReparse $full if (-not (Test-Path -LiteralPath (Join-Path $full 'vlc.exe'))) { throw 'Registered VLC executable is missing.' } if ($Record.Kind -eq 'MSI') { if ($Record.Key -notmatch '^\{[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}\}$') { throw 'Invalid MSI product code.' } } else { $uninstaller=Join-Path $full 'uninstall.exe' if ($Record.Uninstall.Trim('"') -ine $uninstaller -or -not (Test-Path -LiteralPath $uninstaller)) { throw 'Unexpected NSIS uninstall command; will not execute registry command text.' } Assert-NoReparse $uninstaller # Never run an uninstaller writable by ordinary users as SYSTEM. $writeMask=[Security.AccessControl.FileSystemRights]::Write -bor [Security.AccessControl.FileSystemRights]::Delete -bor [Security.AccessControl.FileSystemRights]::ChangePermissions -bor [Security.AccessControl.FileSystemRights]::TakeOwnership foreach ($check in @($full,$uninstaller)) { foreach ($ace in (Get-Acl -LiteralPath $check).Access) { if ($ace.AccessControlType -eq 'Allow' -and ($ace.FileSystemRights -band $writeMask)) { $sid=$ace.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value if ($sid -notin @('S-1-5-18','S-1-5-32-544','S-1-3-0') -and $sid -notlike 'S-1-5-80-*') { throw 'Uninstaller path grants write access beyond trusted system principals.' } } } } } } function Download-Official([string]$Uri,[string]$Destination) { if ($Uri -notmatch '^https://download\.videolan\.org/pub/videolan/vlc/') { throw 'Unexpected download origin.' } Log "DOWNLOAD $Uri" Invoke-WebRequest -UseBasicParsing -Uri $Uri -OutFile $Destination -TimeoutSec 600 -MaximumRedirection 0 } function Validate-Package([string]$File,[string]$HashText) { $match=[regex]::Match($HashText,'(?im)^([a-f0-9]{64})\s+\*?'+[regex]::Escape([IO.Path]::GetFileName($File))+'\s*$') if (-not $match.Success) { throw 'Malformed or mismatched official SHA-256 manifest.' } $hash=(Get-FileHash -LiteralPath $File -Algorithm SHA256).Hash if ($hash -ine $match.Groups[1].Value) { throw 'Installer SHA-256 mismatch.' } $signature=Get-AuthenticodeSignature -LiteralPath $File if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch '(?i)(?:CN|O)=VideoLAN(?:\s|,|$)') { throw 'Installer must have a valid VideoLAN Authenticode signature.' } Log "VALIDATED SHA256=$hash; signer=$($signature.SignerCertificate.Subject)" } function Run-Installer([string]$File,[string]$Arguments,[string]$Label) { Log "START $Label; file=$File; arguments=$Arguments" for ($attempt=1;$attempt -le 3;$attempt++) { $start=[Diagnostics.ProcessStartInfo]::new() $start.FileName=$File; $start.Arguments=$Arguments; $start.UseShellExecute=$false; $start.CreateNoWindow=$true $process=[Diagnostics.Process]::Start($start) try { if (-not $process.WaitForExit(900000)) { Log 'TIMEOUT: Installer may still be running; do not retry blindly.'; return 1460 } $code=$process.ExitCode } finally { $process.Dispose() } Log "END $Label; exit=$code; attempt=$attempt" if ($code -ne 1618 -or $attempt -eq 3) { return $code } Start-Sleep -Seconds 30 } } function Save-Inventory($Records,[string]$Name) { ConvertTo-Json -InputObject @($Records) -Depth 5 | Set-Content -LiteralPath (Join-Path $script:RunPath $Name) -Encoding UTF8 foreach ($r in $Records) { Log ('FOUND scope={0}; type={1}; registered={2}; file={3}; path={4}' -f $r.Scope,$r.Kind,$r.Version,$r.FileVersion,$r.Path) } } function Update-Vlc { $before=@(Inventory) Save-Inventory $before 'inventory-before.json' if ($DetectOnly) { Log 'RESULT=DETECTED; inventory only, no downloads or installers.'; if ($script:Problems.Count) { return 2 }; return 0 } if ($script:PendingPath -and (Test-Path -LiteralPath $script:PendingPath)) { throw 'A previous upgrade is unfinished. Review pending.json and its installer/log directory before recovery; do not remove the marker while an installer is running.' } if (-not $before.Count) { Log 'RESULT=NOT_INSTALLED'; if ($script:Problems.Count) { return 2 }; return 0 } $index=(Invoke-WebRequest -UseBasicParsing -Uri 'https://download.videolan.org/pub/videolan/vlc/' -TimeoutSec 60 -MaximumRedirection 0).Content $release=Latest-Release $index $target=Parse-Version $release $script:Target=$release Log "LATEST_STABLE=$release (official release directory)" if ($target.Major -ne 3) { throw 'New VLC major release needs installer review. No older release silently substituted.' } $machine=@($before | Where-Object Scope -eq 'Machine') foreach ($r in @($before | Where-Object Scope -ne 'Machine')) { Problem "Requires its own deployment method: $($r.Scope) / $($r.Kind) / $($r.Path)" } if (-not $machine.Count) { return 2 } $outdated=$false; $blocked=$false foreach ($r in $machine) { try { Assert-MachineRecord $r } catch { Problem "Cannot safely update $($r.Path): $($_.Exception.Message)"; $blocked=$true; continue } $registered=Parse-Version $r.Version; $actual=Parse-Version $r.FileVersion if ($null -eq $registered -or $null -eq $actual -or $registered -gt $target -or $actual -gt $target) { Problem "Unknown/newer version: $($r.Path); no downgrade attempted."; $blocked=$true; continue } if ($registered -lt $target -or $actual -lt $target) { $outdated=$true } } if ($blocked) { return 2 } if (-not $outdated) { Log 'RESULT=CURRENT'; if ($script:Problems.Count) { return 2 }; return 0 } $running=@(Get-Process -Name vlc -ErrorAction SilentlyContinue) if ($running.Count -and -not $CloseRunningVlc) { Log 'RESULT=IN_USE; rerun with -CloseRunningVlc or after VLC closes.'; return 1618 } $arch=if ([Environment]::Is64BitOperatingSystem) {'win64'} else {'win32'} $name="vlc-$release-$arch.msi" $package=Join-Path $script:RunPath $name $url="https://download.videolan.org/pub/videolan/vlc/$release/$arch/$name" Download-Official "$url.sha256" "$package.sha256" Download-Official $url $package Validate-Package $package (Get-Content -Raw -LiteralPath "$package.sha256") # Start closing only after the replacement is downloaded and validated. if ($CloseRunningVlc) { foreach ($process in @(Get-Process -Name vlc -ErrorAction SilentlyContinue)) { Log "STOP VLC pid=$($process.Id); playback will end." Stop-Process -Id $process.Id -Force -ErrorAction Stop } } if (@(Get-Process -Name vlc -ErrorAction SilentlyContinue).Count) { return 1618 } Log "PLAN: consolidate $($machine.Count) registered desktop copies to $release $arch." [pscustomobject]@{Target=$release;Package=$package;LogDirectory=$script:RunPath;Started=(Get-Date -Format o);Original=$machine} | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $script:PendingPath -Encoding UTF8 $i=0 foreach ($r in $machine) { $i++ # Refresh and revalidate immediately before using local removal data. Assert-MachineRecord $r if ($r.Kind -eq 'MSI') { $log=Join-Path $script:RunPath "uninstall-$i-msi.log" $code=Run-Installer $script:MsiExe ('/x {0} /qn /norestart REBOOT=ReallySuppress /L*v "{1}"' -f $r.Key,$log) "Uninstall MSI $($r.Key)" } else { $code=Run-Installer (Join-Path $r.Path 'uninstall.exe') ('/S _?={0}' -f $r.Path.TrimEnd('\')) "Uninstall NSIS $($r.Path)" } if ($code -in @(3010,1641)) { Log 'RESULT=REBOOT_REQUIRED; no reboot requested; cleanup incomplete.'; return 3010 } if ($code -notin @(0,1605,1614)) { return $code } } # Re-inventory, rather than assuming removal succeeded from exit codes. $removed=@(Inventory) Save-Inventory $removed 'inventory-after-removal.json' if (@($removed | Where-Object Scope -eq 'Machine').Count) { throw 'Old machine registrations remain. Replacement not started.' } foreach ($r in $machine) { if (Test-Path -LiteralPath (Join-Path $r.Path 'vlc.exe')) { throw "Old VLC binary remains: $($r.Path). No forced deletion attempted." } } $installLog=Join-Path $script:RunPath 'install-msi.log' $code=Run-Installer $script:MsiExe ('/i "{0}" /qn /norestart REBOOT=ReallySuppress /L*v "{1}"' -f $package,$installLog) "Install VLC $release $arch" if ($code -in @(3010,1641)) { Log 'RESULT=REBOOT_REQUIRED; no reboot requested; verify after planned restart.'; return 3010 } if ($code -ne 0) { return $code } $after=@(Inventory) Save-Inventory $after 'inventory-after.json' $desktop=@($after | Where-Object Scope -eq 'Machine') if (-not $desktop.Count) { throw 'No desktop registration found after installation.' } foreach ($r in $desktop) { if ((Parse-Version $r.Version) -ne $target -or (Parse-Version $r.FileVersion) -ne $target) { throw "Version verification failed: $($r.Path)" } } Remove-Item -LiteralPath $script:PendingPath -Force if (@($after | Where-Object Scope -ne 'Machine').Count -or $script:Problems.Count) { Log 'RESULT=PARTIAL; desktop updated, unresolved inventory remains.'; return 2 } Remove-Item -LiteralPath $package -Force Log 'RESULT=UPDATED; all discovered registered desktop copies are at the target release.' return 0 } $exitCode=1; $mutex=$null; $locked=$false; $transcript=$false try { if (-not [Security.Principal.WindowsIdentity]::GetCurrent().IsSystem) { throw 'Run this script as LocalSystem / SYSTEM.' } if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64') { throw 'ARM64 Windows requires a separate deployment.' } if ([Environment]::OSVersion.Version.Major -lt 10) { throw 'This updater targets Windows 10/11 and Server 2016 or later.' } if ([Environment]::Is64BitOperatingSystem -and -not [Environment]::Is64BitProcess) { throw 'Select 64-bit PowerShell for complete inventory on 64-bit Windows.' } $script:MsiExe=Join-Path $env:WINDIR 'System32\msiexec.exe' $script:RegExe=Join-Path $env:WINDIR 'System32\reg.exe' $mutex=[Threading.Mutex]::new($false,'Global\SmilarVlcUpdater') try { $locked=$mutex.WaitOne(0) } catch [Threading.AbandonedMutexException] { $locked=$true } if (-not $locked) { $exitCode=1618; throw 'Another updater instance is running.' } Init-Log; $transcript=$true [Net.ServicePointManager]::SecurityProtocol=[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 $exitCode=Update-Vlc } catch { Log "RESULT=ERROR; $($_.Exception.Message)" } finally { if ($script:RunPath) { try { $final=@(Inventory) Save-Inventory $final 'inventory-final.json' if ($exitCode -eq 0 -and $script:Problems.Count) { $exitCode=2 } [pscustomobject]@{ExitCode=$exitCode;Target=$script:Target;Time=(Get-Date -Format o);Unresolved=@($script:Problems.ToArray());Inventory=$final} | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $script:RunPath 'summary.json') -Encoding UTF8 Log "EXIT_CODE=$exitCode; LOG_DIRECTORY=$script:RunPath" } catch { Log "Final inventory/logging failed: $($_.Exception.Message)"; if ($exitCode -eq 0) { $exitCode=1 } } } if ($transcript) { $null=Stop-Transcript } if ($locked) { $mutex.ReleaseMutex() } if ($null -ne $mutex) { $mutex.Dispose() } } exit $exitCode