Install.ps1

<#
.SYNOPSIS
    Installs WinPkgScan for the current user, with Start menu and desktop
    shortcuts to the app. Also uninstalls it.
 
.DESCRIPTION
    Copies the WinPkgScan files to %LOCALAPPDATA%\Programs\WinPkgScan, adds
    a "WinPkgScan" shortcut to the Start menu and the desktop, and adds
    WinPkgScan to Settings > Apps so it can be uninstalled there.
    No administrator rights are needed.
 
    Run it again from a newer download to update. Your exclude list in the
    install folder is kept.
 
    -Uninstall removes the files, the shortcuts and the Settings entry. It
    only removes the WinPkgScan files: anything else in the folder is left.
    Your scan history, settings and exclude list in
    %LOCALAPPDATA%\WinPkgScan are kept unless you add -RemoveData.
 
.PARAMETER Uninstall
    Remove WinPkgScan instead of installing it.
 
.PARAMETER InstallPath
    Where to install. Default: %LOCALAPPDATA%\Programs\WinPkgScan
 
.PARAMETER NoDesktopShortcut
    Do not add a desktop shortcut. The Start menu shortcut is still added.
 
.PARAMETER NoShortcuts
    Do not add any shortcuts.
 
.PARAMETER NoUninstallEntry
    Do not add WinPkgScan to Settings > Apps.
 
.PARAMETER Launch
    Start the app when the install has finished.
 
.PARAMETER RemoveData
    With -Uninstall: also delete %LOCALAPPDATA%\WinPkgScan (scan history,
    settings, your exclude list and the app's scan results).
 
.PARAMETER StartMenuPath
    Folder for the Start menu shortcut. Default: your Start menu Programs
    folder.
 
.PARAMETER DesktopPath
    Folder for the desktop shortcut. Default: your desktop.
 
.PARAMETER UninstallKey
    Registry key for the Settings > Apps entry.
 
.PARAMETER NoPause
    Never pause at the end. The script only pauses when it opened its own
    window (Install.bat or Settings > Apps), not when run from an open
    PowerShell prompt.
 
.EXAMPLE
    .\Install.ps1
    Installs WinPkgScan with Start menu and desktop shortcuts.
 
.EXAMPLE
    .\Install.ps1 -Uninstall
    Removes WinPkgScan, and keeps your history and settings.
 
.NOTES
    Version: 1.8.0
#>


[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
param(
    [switch]$Uninstall,

    [string]$InstallPath,

    [switch]$NoDesktopShortcut,

    [switch]$NoShortcuts,

    [switch]$NoUninstallEntry,

    [switch]$Launch,

    [switch]$RemoveData,

    [string]$StartMenuPath,

    [string]$DesktopPath,

    [string]$UninstallKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\WinPkgScan',

    [switch]$NoPause
)

$ErrorActionPreference = 'Stop'
$ScriptVersion = "1.8.0"

# Files that make up WinPkgScan. The first four are required.
$RequiredFiles = @(
    'Find-OrphanedAppxFolders.ps1', 'Remove-OrphanedAppxFolders.ps1',
    'Restore-OrphanedAppxFolders.ps1', 'WinPkgScan-App.ps1'
)
$OptionalFiles = @(
    'WinPkgScan.bat', 'WinPkgScan-App.bat', 'Run-Scan.bat', 'Run-Scan-Unattended.bat', 'Run-Delete.bat',
    'WinPkgScan.exclude.txt', 'WinPkgScan.ico', 'README.md', 'LICENSE', 'Install.ps1', 'Install.bat'
)
$MarkerName   = 'WinPkgScan.install'
$ShortcutName = 'WinPkgScan.lnk'

# Pause at the end only when this script opened its own window, so the
# window does not close before you can read it.
$PauseAtEnd = $false
if (-not $NoPause.IsPresent) {
    try {
        $PauseAtEnd = (-not [Console]::IsInputRedirected) -and
            ([Environment]::CommandLine.IndexOf([System.IO.Path]::GetFileName($PSCommandPath), [StringComparison]::OrdinalIgnoreCase) -ge 0)
    }
    catch {
        $PauseAtEnd = $false
    }
}

function Exit-Script {
    param([int]$Code = 0)
    if ($PauseAtEnd) {
        Write-Host ""
        Read-Host "Press Enter to exit" | Out-Null
    }
    exit $Code
}

function Get-DefaultInstallPath {
    return Join-Path (Join-Path $env:LOCALAPPDATA 'Programs') 'WinPkgScan'
}

function Get-FullPath {
    param([string]$Path)
    return [System.IO.Path]::GetFullPath($Path).TrimEnd('\', '/')
}

function Test-SamePath {
    param([string]$A, [string]$B)
    return [string]::Equals((Get-FullPath $A), (Get-FullPath $B), [StringComparison]::OrdinalIgnoreCase)
}

function Get-AppLaunchArgument {
    # powershell.exe arguments that start the app without a console window.
    param([string]$Folder)
    return '-NoProfile -ExecutionPolicy Bypass -STA -WindowStyle Hidden -File "' + (Join-Path $Folder 'WinPkgScan-App.ps1') + '"'
}

function Get-WindowsPowerShellPath {
    $path = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
    if ($env:SystemRoot -and (Test-Path -LiteralPath $path)) { return $path }
    return 'powershell.exe'
}

function Get-ShortcutPath {
    # Where the shortcuts go: Start menu always, desktop unless turned off.
    param([string]$StartMenu, [string]$Desktop, [bool]$NoDesktop, [bool]$NoLinks)
    $folders = @()
    if ($NoLinks) { return $folders }
    if (-not $StartMenu) { $StartMenu = [Environment]::GetFolderPath('Programs') }
    if ($StartMenu) { $folders += Join-Path $StartMenu $ShortcutName }
    if (-not $NoDesktop) {
        if (-not $Desktop) { $Desktop = [Environment]::GetFolderPath('Desktop') }
        if ($Desktop) { $folders += Join-Path $Desktop $ShortcutName }
    }
    return $folders
}

function Add-AppShortcut {
    [CmdletBinding(SupportsShouldProcess = $true)]
    param([string]$Path, [string]$Folder)
    if (-not $PSCmdlet.ShouldProcess($Path, 'Add shortcut')) { return }
    $dir = Split-Path -Path $Path -Parent
    if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
    $shell = New-Object -ComObject WScript.Shell
    $link = $shell.CreateShortcut($Path)
    $link.TargetPath       = Get-WindowsPowerShellPath
    $link.Arguments        = Get-AppLaunchArgument -Folder $Folder
    $link.WorkingDirectory = $Folder
    $link.Description      = 'Find and safely remove leftover app folders'
    $link.WindowStyle      = 7
    $icon = Join-Path $Folder 'WinPkgScan.ico'
    if (Test-Path -LiteralPath $icon) { $link.IconLocation = "$icon,0" }
    $link.Save()
}

function Test-OurShortcut {
    # True if the shortcut starts the app in this install folder.
    param([string]$Path, [string]$Folder)
    try {
        $shell = New-Object -ComObject WScript.Shell
        $args0 = $shell.CreateShortcut($Path).Arguments
        return ($args0.IndexOf((Join-Path $Folder 'WinPkgScan-App.ps1'), [StringComparison]::OrdinalIgnoreCase) -ge 0)
    }
    catch {
        return $false
    }
}

function Get-FolderSizeKB {
    param([string]$Folder)
    $bytes = 0
    foreach ($f in @(Get-ChildItem -LiteralPath $Folder -File -ErrorAction SilentlyContinue)) { $bytes += $f.Length }
    return [int][math]::Ceiling($bytes / 1KB)
}

function Register-UninstallEntry {
    [CmdletBinding(SupportsShouldProcess = $true)]
    param([string]$Folder)
    if (-not $PSCmdlet.ShouldProcess('Settings > Apps', 'Add WinPkgScan')) { return }
    if (-not (Test-Path -LiteralPath $UninstallKey)) { New-Item -Path $UninstallKey -Force | Out-Null }
    $uninstall = '"' + (Get-WindowsPowerShellPath) + '" -NoProfile -ExecutionPolicy Bypass -File "' +
        (Join-Path $Folder 'Install.ps1') + '" -Uninstall'
    $values = [ordered]@{
        DisplayName     = 'WinPkgScan'
        DisplayVersion  = $ScriptVersion
        Publisher       = 'Louis Yvan Alcayde'
        InstallLocation = $Folder
        UninstallString = $uninstall
        URLInfoAbout    = 'https://github.com/leaurix/WinPkgScan'
        HelpLink        = 'https://github.com/leaurix/WinPkgScan#readme'
    }
    foreach ($key in $values.Keys) {
        New-ItemProperty -Path $UninstallKey -Name $key -Value $values[$key] -PropertyType String -Force | Out-Null
    }
    $icon = Join-Path $Folder 'WinPkgScan.ico'
    if (Test-Path -LiteralPath $icon) {
        New-ItemProperty -Path $UninstallKey -Name 'DisplayIcon' -Value $icon -PropertyType String -Force | Out-Null
    }
    foreach ($flag in 'NoModify', 'NoRepair') {
        New-ItemProperty -Path $UninstallKey -Name $flag -Value 1 -PropertyType DWord -Force | Out-Null
    }
    New-ItemProperty -Path $UninstallKey -Name 'EstimatedSize' -Value (Get-FolderSizeKB $Folder) -PropertyType DWord -Force | Out-Null
}

function Install-WinPkgScanFile {
    param([string]$Source, [string]$Target)
    if (-not (Test-Path -LiteralPath $Target)) { New-Item -ItemType Directory -Path $Target -Force | Out-Null }
    $copied = 0
    foreach ($name in @($RequiredFiles) + @($OptionalFiles)) {
        $from = Join-Path $Source $name
        $to   = Join-Path $Target $name
        if (-not (Test-Path -LiteralPath $from -PathType Leaf)) { continue }
        if ($name -eq 'WinPkgScan.exclude.txt' -and (Test-Path -LiteralPath $to)) {
            Write-Host " Kept your exclude list: $to"
            continue
        }
        Copy-Item -LiteralPath $from -Destination $to -Force
        $copied++
    }
    Write-InstallMarker -Target $Target
    return $copied
}

function Write-InstallMarker {
    # Uninstall only removes files from a folder with this marker.
    [CmdletBinding(SupportsShouldProcess = $true)]
    param([string]$Target)
    $marker = Join-Path $Target $MarkerName
    if ($PSCmdlet.ShouldProcess($marker, 'Write install marker')) {
        Set-Content -LiteralPath $marker -Encoding ASCII -Value @(
            'WinPkgScan install folder. Install.ps1 -Uninstall only removes folders with this file.'
            "Version: $ScriptVersion"
            "Installed: $((Get-Date).ToString('yyyy-MM-dd HH:mm'))"
        )
    }
}

function Uninstall-WinPkgScanFile {
    # Removes the WinPkgScan files, then the folder if nothing else is left.
    param([string]$Target)
    $removed = 0
    foreach ($name in @($RequiredFiles) + @($OptionalFiles) + @($MarkerName)) {
        $path = Join-Path $Target $name
        if (Test-Path -LiteralPath $path -PathType Leaf) {
            Remove-Item -LiteralPath $path -Force
            $removed++
        }
    }
    if (@(Get-ChildItem -LiteralPath $Target -Force -ErrorAction SilentlyContinue).Count -eq 0) {
        Remove-Item -LiteralPath $Target -Force
    }
    return $removed
}

# ------------------------------------------------------------
# Main
# ------------------------------------------------------------

if (-not $InstallPath) { $InstallPath = Get-DefaultInstallPath }
$InstallPath = Get-FullPath $InstallPath
$Source = $PSScriptRoot
$Marker = Join-Path $InstallPath $MarkerName
$Shortcuts = @(Get-ShortcutPath -StartMenu $StartMenuPath -Desktop $DesktopPath -NoDesktop $NoDesktopShortcut.IsPresent -NoLinks $NoShortcuts.IsPresent)

Write-Host ""
Write-Host "WinPkgScan $ScriptVersion $(if ($Uninstall) { 'uninstall' } else { 'install' })" -ForegroundColor Cyan
Write-Host "Folder: $InstallPath"
Write-Host ""

try {
    if ($Uninstall) {
        # Never delete from a folder this script did not install into.
        if ((Test-Path -LiteralPath $InstallPath) -and -not (Test-Path -LiteralPath $Marker)) {
            Write-Host "ERROR: $InstallPath is not a WinPkgScan install folder (no $MarkerName file). Nothing was removed." -ForegroundColor Red
            Exit-Script 1
        }
        # The folder cannot be removed while it is the current folder.
        if ((Get-Location).ProviderPath -and (Get-FullPath (Get-Location).ProviderPath).StartsWith($InstallPath, [StringComparison]::OrdinalIgnoreCase)) {
            Set-Location -LiteralPath ([System.IO.Path]::GetTempPath())
        }

        foreach ($link in $Shortcuts) {
            if ((Test-Path -LiteralPath $link) -and (Test-OurShortcut -Path $link -Folder $InstallPath)) {
                Remove-Item -LiteralPath $link -Force
                Write-Host " Removed shortcut: $link"
            }
        }
        if (-not $NoUninstallEntry -and (Test-Path -LiteralPath $UninstallKey)) {
            Remove-Item -LiteralPath $UninstallKey -Recurse -Force
            Write-Host " Removed WinPkgScan from Settings > Apps"
        }
        if (Test-Path -LiteralPath $InstallPath) {
            $count = Uninstall-WinPkgScanFile -Target $InstallPath
            Write-Host " Removed $count file$(if ($count -ne 1) { 's' })"
            if (-not $WhatIfPreference -and (Test-Path -LiteralPath $InstallPath)) {
                Write-Host " Kept the folder $InstallPath`: it has other files in it." -ForegroundColor Yellow
            }
        }
        else {
            Write-Host " WinPkgScan files were not found in $InstallPath."
        }

        $data = if ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA 'WinPkgScan' } else { $null }
        if ($RemoveData) {
            if ($data -and (Test-Path -LiteralPath $data)) {
                Remove-Item -LiteralPath $data -Recurse -Force
                Write-Host " Removed your history and settings: $data"
            }
        }
        elseif ($data -and (Test-Path -LiteralPath $data)) {
            Write-Host " Kept your history and settings: $data"
        }

        Write-Host ""
        if ($WhatIfPreference) { Write-Host "Preview only: nothing was changed." -ForegroundColor Green }
        else { Write-Host "WinPkgScan was uninstalled." -ForegroundColor Green }
        Exit-Script 0
    }

    # ---- Install ----
    $missing = @($RequiredFiles | Where-Object { -not (Test-Path -LiteralPath (Join-Path $Source $_) -PathType Leaf) })
    if ($missing.Count -gt 0) {
        Write-Host "ERROR: Missing $($missing -join ', ') next to Install.ps1. Run it from the extracted WinPkgScan folder." -ForegroundColor Red
        Exit-Script 1
    }
    $samePlace = Test-SamePath $Source $InstallPath
    if (-not $samePlace -and (Test-Path -LiteralPath $InstallPath) -and -not (Test-Path -LiteralPath $Marker) -and
        @(Get-ChildItem -LiteralPath $InstallPath -Force -ErrorAction SilentlyContinue).Count -gt 0) {
        Write-Host "ERROR: $InstallPath already has other files in it. Choose an empty folder with -InstallPath." -ForegroundColor Red
        Exit-Script 1
    }

    if ($samePlace) {
        Write-InstallMarker -Target $InstallPath
        Write-Host " Already in the install folder: files not copied."
    }
    else {
        $copied = Install-WinPkgScanFile -Source $Source -Target $InstallPath
        Write-Host " Copied $copied file$(if ($copied -ne 1) { 's' })"
    }

    foreach ($link in $Shortcuts) {
        Add-AppShortcut -Path $link -Folder $InstallPath
        Write-Host " Shortcut: $link"
    }
    if (-not $NoUninstallEntry) {
        Register-UninstallEntry -Folder $InstallPath
        Write-Host " Added to Settings > Apps, where you can uninstall it"
    }

    Write-Host ""
    if ($WhatIfPreference) {
        Write-Host "Preview only: nothing was changed." -ForegroundColor Green
        Exit-Script 0
    }
    Write-Host "WinPkgScan $ScriptVersion is installed." -ForegroundColor Green
    if (-not $NoShortcuts) { Write-Host "Start it from the Start menu: WinPkgScan." }
    Write-Host "To uninstall: Settings > Apps, or run Install.ps1 -Uninstall."

    if ($Launch) {
        Start-Process -FilePath (Get-WindowsPowerShellPath) -ArgumentList (Get-AppLaunchArgument -Folder $InstallPath) `
            -WorkingDirectory $InstallPath -WindowStyle Hidden
    }
    Exit-Script 0
}
catch {
    Write-Host ""
    Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
    Exit-Script 1
}