VSCode-Updater.psm1

<#
    SPDX-License-Identifier: MIT
    Copyright (c) 2026 Leon McClatchey, Linktech Engineering LLC
 
    Package: VSCode-Updater
    Author: Leon McClatchey
    Company: Linktech Engineering LLC
    Created: 2026-04-16
    Modified: 2026-07-05
    File: VSCode-Updater.psm1
    Version: 3.1.0
    Description: Module root for VSCode-Updater. Loads public functions, wires private helpers,
                 and exposes deterministic update, rollback, symlink diagnostics, and safe-mode operations.
#>


# =====================================================================
# Load Public + Private Functions
# =====================================================================
# Load enum FIRST
. "$PSScriptRoot/Private/WatchdogExitCode.ps1"
. "$PSScriptRoot/Private/InstallerExceptions.ps1"

# Load all other private files
Get-ChildItem -Path "$PSScriptRoot/Private" -Filter *.ps1 |
    Where-Object { $_.Name -ne 'WatchdogExitCode.ps1' } |
    Sort-Object Name |
    ForEach-Object { . $_.FullName }

# Load public files LAST
Get-ChildItem -Path "$PSScriptRoot/Public" -Filter *.ps1 |
    Sort-Object Name |
    ForEach-Object { . $_.FullName }


# =====================================================================
# Module-Level Constants
# =====================================================================

Set-Variable -Name VSU_MaxRetries     -Value 5   -Scope Script -Option ReadOnly
Set-Variable -Name VSU_DetectTimeout  -Value 10  -Scope Script -Option ReadOnly
Set-Variable -Name VSU_DefaultIdle    -Value 900 -Scope Script -Option ReadOnly
Set-Variable -Name VSU_SafeInstallerMode -Value $true -Scope Script -Option ReadOnly

# Load module version from manifest
$script:ModuleVersion = (Import-PowerShellDataFile "$PSScriptRoot\VSCode-Updater.psd1").ModuleVersion

Write-VSCodeUpdaterLog "[INIT] VSCode-Updater module loaded — version $script:ModuleVersion"


# =====================================================================
# Update-VSCode (Public Entry Point)
# =====================================================================

function Update-VSCode {
    [OutputType([Int32])]
    [CmdletBinding(SupportsShouldProcess = $true)]
    param()

    $null = Write-VSCodeUpdaterLog "[UPDATE] Starting VS Code update"
    Clear-SetupBootstrapper | Out-Null
    Clear-VSCodeHelpers | Out-Null
    Clear-InnoSetupWorkers | Out-Null

    if (-not $PSCmdlet.ShouldProcess("VS Code installation", "Update")) {
        $null = Write-VSCodeUpdaterLog "[UPDATE] ShouldProcess declined"
        return [WatchdogExitCode]::UpdateException
    }

    $finalResult = $null
    $installerUrl = "https://update.code.visualstudio.com/latest/win32-x64-user/stable"
    $cacheDir = Join-Path $PSScriptRoot "..\Cache"
    $cachedInstaller = Join-Path $cacheDir "VSCodeUserSetup.exe"

    $cachedInstaller = Normalize-Scalar(Get-Installer -Url $installerUrl -CachePath $cachedInstaller -DownloadMode "Normal")

    if (-not $cachedInstaller) {
        $null = Write-VSCodeUpdaterLog "[UPDATE] Installer acquisition failed — invoking ZIP fallback"
        Invoke-ZipFallback -Reason [WatchdogExitCode]::InstallerFailed | Out-Null
        return [WatchdogExitCode]::InstallerFailed
    }

    try {
        # =====================================================================
        # Installer Retry Loop
        # =====================================================================

        $maxAttempts = 3
        $attempt = 0
        $installerFailed = $false
        $fallbackReason = $null

        while ($attempt -lt $maxAttempts) {
            $attempt++
            $null = Write-VSCodeUpdaterLog "[ATTEMPT] Installer attempt $attempt of $maxAttempts"

            $result = Invoke-InstallerWrapper -InstallerPath $cachedInstaller -IdleTimeout $script:VSU_DefaultIdle

            Clear-VSCodeHelpers | Out-Null
            Clear-InnoSetupWorkers | Out-Null

            # ---------------------------------------------------------
            # NEW: ServicingBlocked pre-flight failure
            # ---------------------------------------------------------
            if ($result -eq [WatchdogExitCode]::ServicingBlocked) {
                $null = Write-VSCodeUpdaterLog "[MAIN] Servicing blocked — skipping installer attempts"
                $installerFailed = $true
                $fallbackReason = $result   # store reason for summary
                break                       # DO NOT return — allow summary to run
            }

            if ($result -eq [WatchdogExitCode]::Success) {
                $null = Write-VSCodeUpdaterLog "[SUCCESS] Installer completed successfully on attempt $attempt"
                break
            }

            if ($result -in @(
                [WatchdogExitCode]::IdleStalled,
                [WatchdogExitCode]::ActiveStalled,
                [WatchdogExitCode]::FSStalled
            )) {
                $null = Write-VSCodeUpdaterLog "[STALL] Installer stalled on attempt $attempt"
                $installerFailed = $true
                $fallbackReason = $result
                break
            }

            if ($attempt -ge $maxAttempts) {
                $null = Write-VSCodeUpdaterLog "[STALL] Installer stalled after $attempt attempts"
                $installerFailed = $true
                $fallbackReason = [WatchdogExitCode]::InstallerFailed
                break
            }

            $null = Write-VSCodeUpdaterLog "[RETRY] Cleaning processes and artifacts before retry"
            Clear-SetupBootstrapper | Out-Null
            Clear-VSCodeHelpers | Out-Null
            Clear-InnoSetupWorkers | Out-Null
        }

        # =====================================================================
        # Installer Failure → ZIP Fallback
        # =====================================================================

        if ($installerFailed) {

            #
            # v3.1 — Print diagnostic summary from watchdog
            #
            if ($script:WatchdogDiagnostics) {

                $null = Write-VSCodeUpdaterLog "[SUMMARY] Installer failed after $attempt attempts"

                if ($script:WatchdogDiagnostics.StallReason) {
                    Write-VSCodeUpdaterLog "[SUMMARY] Stall type: $($script:WatchdogDiagnostics.StallReason)"
                }

                if ($script:WatchdogDiagnostics.LastModules) {
                    $mods = ($script:WatchdogDiagnostics.LastModules | Select-Object -Last 5) -join ', '
                    $null = Write-VSCodeUpdaterLog "[SUMMARY] Last modules: $mods"
                }

                # Phase timeline
                $timeline = $script:WatchdogDiagnostics.PhaseTimeline
                if ($timeline) {
                    $null = Write-VSCodeUpdaterLog "[SUMMARY] Timeline:"
                    foreach ($phase in $timeline.Keys) {
                        $ts = if ($timeline[$phase]) { $timeline[$phase].ToString("HH:mm:ss") } else { "never reached" }
                        $null = Write-VSCodeUpdaterLog "[SUMMARY] $phase`: $ts"
                    }
                }

                # Extraction / payload / finalization flags
                if (-not $script:WatchdogDiagnostics.ExtractionStarted) {
                    $null = Write-VSCodeUpdaterLog "[SUMMARY] Extraction never started"
                }
                if (-not $script:WatchdogDiagnostics.PayloadLoaded) {
                    $null = Write-VSCodeUpdaterLog "[SUMMARY] Payload never loaded"
                }
                if (-not $script:WatchdogDiagnostics.FinalizationReached) {
                    $null = Write-VSCodeUpdaterLog "[SUMMARY] Finalization never reached"
                }

                # Pending reboot
                if ($script:WatchdogDiagnostics.PendingReboot) {
                    $null = Write-VSCodeUpdaterLog "[DIAGNOSTIC] Pending reboot detected — Windows servicing may be blocked"
                }

                # Component store corruption
                if ($script:WatchdogDiagnostics.ComponentStoreCorrupt) {
                    $null = Write-VSCodeUpdaterLog "[DIAGNOSTIC] Component store corruption detected — DISM checkhealth failed"
                }
            }

            #
            # Now trigger fallback — unless servicing is blocked
            #
            if ($fallbackReason -eq [WatchdogExitCode]::ServicingBlocked) {
                $null = Write-VSCodeUpdaterLog "[FALLBACK] Servicing blocked — fallback skipped"
                return [WatchdogExitCode]::ServicingBlocked
            }

            $null = Write-VSCodeUpdaterLog "[FALLBACK] Installer failed — invoking ZIP fallback"
            Invoke-ZipFallback -Reason $fallbackReason | Out-Null

            $null = Write-VSCodeUpdaterLog "[CHECK] Running post-fallback health validation"

            if (-not (Test-VSCodeInstall)) {
                $null = Write-VSCodeUpdaterLog "[CHECK] Fallback health check failed"
                $finalResult = [WatchdogExitCode]::FallbackFailed
            }
            else {
                $null = Write-VSCodeUpdaterLog "[CHECK] Fallback health check passed"
                $finalResult = [WatchdogExitCode]::Success
            }

            return $finalResult
        }

        # =====================================================================
        # Post‑Installer Health Check
        # =====================================================================

        $null = Write-VSCodeUpdaterLog "[CHECK] Running post-install health validation"

        $codeExe = Find-UserVSCode
        if (-not $codeExe) {
            $null = Write-VSCodeUpdaterLog "[CHECK] No user-space VS Code installation detected — aborting instead of ZIP fallback"
            return [WatchdogExitCode]::MissingCodeExe
        }

        if (-not (Test-Path $codeExe)) {
            $null = Write-VSCodeUpdaterLog "[CHECK] Code.exe missing — invoking ZIP fallback"
            Invoke-ZipFallback -Reason [WatchdogExitCode]::MissingCodeExe | Out-Null

            $null = Write-VSCodeUpdaterLog "[CHECK] Running post-fallback health validation"

            if (-not (Test-VSCodeInstall)) {
                $null = Write-VSCodeUpdaterLog "[CHECK] Fallback health check failed"
                $finalResult = [WatchdogExitCode]::MissingCodeExe
            }
            else {
                $null = Write-VSCodeUpdaterLog "[CHECK] Fallback health check passed"
                $finalResult = [WatchdogExitCode]::Success
            }

            return $finalResult
        }

        try {
            $proc = Start-Process $codeExe -PassThru -ErrorAction Stop

            $elapsed = 0
            $maxWait = 5
            $interval = 250

            while ($elapsed -lt $maxWait) {
                Start-Sleep -Milliseconds $interval
                $elapsed += ($interval / 1000)

                if ($proc.HasExited) {
                    $null = Write-VSCodeUpdaterLog "[CHECK] VS Code exited immediately — invoking ZIP fallback"
                    Invoke-ZipFallback -Reason [WatchdogExitCode]::LaunchFailed | Out-Null

                    $null = Write-VSCodeUpdaterLog "[CHECK] Running post-fallback health validation"

                    if (-not (Test-VSCodeInstall)) {
                        $null = Write-VSCodeUpdaterLog "[CHECK] Fallback health check failed"
                        $finalResult = [WatchdogExitCode]::LaunchFailed
                    }
                    else {
                        $null = Write-VSCodeUpdaterLog "[CHECK] Fallback health check passed"
                        $finalResult = [WatchdogExitCode]::Success
                    }

                    return $finalResult
                }
            }

            $proc | Stop-Process -Force | Out-Null
        }
        catch {
            $null = Write-VSCodeUpdaterLog "[CHECK] Exception launching VS Code: $($_.Exception.Message)"
            Invoke-ZipFallback -Reason [WatchdogExitCode]::LaunchException | Out-Null

            $null = Write-VSCodeUpdaterLog "[CHECK] Running post-fallback health validation"

            if (-not (Test-VSCodeInstall)) {
                $null = Write-VSCodeUpdaterLog "[CHECK] Fallback health check failed"
                $finalResult = [WatchdogExitCode]::LaunchException
            }
            else {
                $null = Write-VSCodeUpdaterLog "[CHECK] Fallback health check passed"
                $finalResult = [WatchdogExitCode]::Success
            }

            return $finalResult
        }

        $null = Write-VSCodeUpdaterLog "[UPDATE] VS Code installation passed health check"
        $finalResult = [WatchdogExitCode]::Success
    }
    catch {
        $null = Write-VSCodeUpdaterLog "[UPDATE] Exception in Update-VSCode: $($_.Exception.Message)"
        Invoke-ZipFallback -Reason [WatchdogExitCode]::UpdateException | Out-Null

        $null = Write-VSCodeUpdaterLog "[CHECK] Running post-fallback health validation"

        if (-not (Test-VSCodeInstall)) {
            $null = Write-VSCodeUpdaterLog "[CHECK] Fallback health check failed"
            $finalResult = [WatchdogExitCode]::UpdateException
        }
        else {
            $null = Write-VSCodeUpdaterLog "[CHECK] Fallback health check passed"
            $finalResult = [WatchdogExitCode]::Success
        }
    }

    return $finalResult
}

# =====================================================================
# Export Public Functions
# =====================================================================

Export-ModuleMember -Function @(
    'Update-VSCode'
    'Get-VSCodeVersions'
    'Switch-VSCodeVersion'
    'Invoke-VSCodeRollback'
    'Test-VSCodeSymlink'
    'Start-VSCodeSafeMode'
    'Get-VSCodeDashboard'
    'Invoke-ZipFallback'
    'Get-VSCodeSymlinkInfo'
    'Get-VSCodeLastResult'
    'Set-VSCodeSafeMode'
    'Start-VSCodeRepair'
    'Set-VSCodeSafeInstallerMode'
)
$PSDefaultParameterValues['*:OutVariable'] = $null