Terminal/SRxVSCode.psm1

<#
    SRxVSCode.psm1 (Terminal subsystem - Iteration 10: VS Code provisioning)
    ------------------------------------------------------------------------
    Called once when the Monaco editor surface is first activated:
      - VS Code installed (user OR machine scope) -> DO NOTHING.
      - Not installed -> install it in CURRENT-USER scope, without admin
        rights, in a HIDDEN, NON-INTERACTIVE child powershell.exe so the
        Explorer UI thread is never blocked:
            Install-PackageProvider NuGet (CurrentUser, only if missing)
            Install-Script -Name Install-VSCode -Scope CurrentUser -Force
            Install-VSCode.ps1 -BuildEdition Stable-User
        NOTE: Install-VSCode.ps1 (v1.4.x) defaults to -BuildEdition
        'Stable-System' (machine-wide, needs admin). 'Stable-User' is passed
        explicitly to get the per-user installer
        (%LOCALAPPDATA%\Programs\Microsoft VS Code). The script also installs
        the ms-vscode.PowerShell extension.
      - The child is guarded by a per-user named mutex, so several Explorer
        instances can never run two installs at once. Output is transcribed
        to %LOCALAPPDATA%\SRx\Logs\Install-VSCode-<timestamp>.log.
    The caller polls the returned process (HasExited / ExitCode) from a UI
    Timer (see SRxTerminalBoxControl GOTCHA #4).
    ------------------------------------------------------------------------
#>


$script:SRxVSCodeInstallStarted = $false

function Test-SRxVSCodeInstalled {
    <#
        .SYNOPSIS
        Detects a VS Code installation (user or machine scope).
        .PARAMETER CandidatePaths
        Overrides the Code.exe probe list (tests).
        .PARAMETER SkipRegistry
        Skip the Uninstall-key scan (tests).
        .PARAMETER SkipCommand
        Skip the 'code' command lookup on PATH (tests).
        .OUTPUTS
        [pscustomobject] Installed, Path, Scope ('User'|'Machine'|'Unknown'), Source
    #>

    [CmdletBinding()]
    param(
        [string[]]$CandidatePaths = $null,
        [switch]$SkipRegistry,
        [switch]$SkipCommand
    )

    if ($null -eq $CandidatePaths) {
        $CandidatePaths = @()
        if ($env:LOCALAPPDATA)           { $CandidatePaths += (Join-Path $env:LOCALAPPDATA 'Programs\Microsoft VS Code\Code.exe') }
        if ($env:ProgramFiles)           { $CandidatePaths += (Join-Path $env:ProgramFiles 'Microsoft VS Code\Code.exe') }
        if (${env:ProgramFiles(x86)})    { $CandidatePaths += (Join-Path ${env:ProgramFiles(x86)} 'Microsoft VS Code\Code.exe') }
    }

    foreach ($p in $CandidatePaths) {
        if ([string]::IsNullOrEmpty($p)) { continue }
        if (Test-Path -LiteralPath $p -PathType Leaf) {
            $scope = 'Machine'
            if ($env:LOCALAPPDATA -and $p.StartsWith($env:LOCALAPPDATA, [System.StringComparison]::OrdinalIgnoreCase)) { $scope = 'User' }
            return [pscustomobject]@{ Installed = $true; Path = $p; Scope = $scope; Source = 'File' }
        }
    }

    if (-not $SkipRegistry) {
        $keys = @(
            @{ Root = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall';             Scope = 'User' },
            @{ Root = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall';             Scope = 'Machine' },
            @{ Root = 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'; Scope = 'Machine' }
        )
        foreach ($k in $keys) {
            try {
                if (-not (Test-Path $k.Root)) { continue }
                $hit = Get-ChildItem -Path $k.Root -ErrorAction SilentlyContinue | ForEach-Object {
                    try { Get-ItemProperty -Path $_.PSPath -ErrorAction Stop } catch { $null }
                } | Where-Object {
                    $_ -and $_.PSObject.Properties['DisplayName'] -and ([string]$_.DisplayName -like 'Microsoft Visual Studio Code*') -and ([string]$_.DisplayName -notlike '*Insiders*')
                } | Select-Object -First 1
                if ($hit) {
                    $exe = $null
                    if ($hit.PSObject.Properties['InstallLocation'] -and $hit.InstallLocation) {
                        $cand = Join-Path ([string]$hit.InstallLocation) 'Code.exe'
                        if (Test-Path -LiteralPath $cand) { $exe = $cand }
                    }
                    # an orphaned uninstall entry without Code.exe does not count
                    if ($exe) {
                        return [pscustomobject]@{ Installed = $true; Path = $exe; Scope = $k.Scope; Source = 'Registry' }
                    }
                }
            } catch { }
        }
    }

    if (-not $SkipCommand) {
        $cmd = Get-Command -Name 'code.cmd', 'code' -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
        if ($cmd) {
            return [pscustomobject]@{ Installed = $true; Path = $cmd.Source; Scope = 'Unknown'; Source = 'Command' }
        }
    }

    return [pscustomobject]@{ Installed = $false; Path = $null; Scope = $null; Source = $null }
}

$script:SRxVSCodeInstallerScript = @'
param(
    [Parameter(Mandatory)][string]$LogPath,
    [string]$BuildEdition = 'Stable-User',
    [string]$AdditionalExtensions = '',
    [switch]$LaunchWhenDone
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
try { Start-Transcript -Path $LogPath -Append | Out-Null } catch { }
$mutex = $null
$owned = $false
$code = 1
try {
    $mutex = New-Object System.Threading.Mutex -ArgumentList $false, 'Local\SRxVSCodeUserInstall'
    try { $owned = $mutex.WaitOne(0) }
    catch {
        # a previous installer crashed while holding the mutex -> we own it now
        $inner = $_.Exception
        while ($inner.InnerException) { $inner = $inner.InnerException }
        if ($inner -is [System.Threading.AbandonedMutexException]) { $owned = $true } else { throw }
    }
    if (-not $owned) {
        Write-Output 'Another VS Code installation is already in progress - nothing to do.'
        $code = 3
    }
    else {
        [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
        try { [System.Net.WebRequest]::DefaultWebProxy.Credentials = [System.Net.CredentialCache]::DefaultNetworkCredentials } catch { }
 
        $nuget = Get-PackageProvider -ListAvailable -Name NuGet -ErrorAction SilentlyContinue |
                 Where-Object { $_.Version -ge [version]'2.8.5.201' } | Select-Object -First 1
        if (-not $nuget) {
            Write-Output 'Installing NuGet package provider (CurrentUser)...'
            Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Scope CurrentUser -Force | Out-Null
        }
 
        $installed = Get-InstalledScript -Name Install-VSCode -ErrorAction SilentlyContinue
        if (-not $installed) {
            Write-Output 'Install-Script -Name Install-VSCode -Scope CurrentUser -Force'
            Install-Script -Name Install-VSCode -Scope CurrentUser -Force -Confirm:$false
            $installed = Get-InstalledScript -Name Install-VSCode -ErrorAction Stop
        }
        $scriptPath = Join-Path $installed.InstalledLocation 'Install-VSCode.ps1'
        if (-not (Test-Path -LiteralPath $scriptPath)) { throw "Install-VSCode.ps1 not found at '$scriptPath'." }
 
        $splat = @{ BuildEdition = $BuildEdition }
        if ($AdditionalExtensions) { $splat['AdditionalExtensions'] = @($AdditionalExtensions -split ',' | Where-Object { $_ }) }
        if ($LaunchWhenDone) { $splat['LaunchWhenDone'] = $true }
        Write-Output "Running $scriptPath -BuildEdition $BuildEdition"
        & $scriptPath @splat
        $code = 0
    }
}
catch {
    Write-Output "FAILED: $($_.Exception.Message)"
    $code = 1
}
finally {
    if ($mutex) {
        if ($owned) { try { $mutex.ReleaseMutex() } catch { } }
        $mutex.Dispose()
    }
    try { Stop-Transcript | Out-Null } catch { }
}
exit $code
'@


function Get-SRxVSCodeInstallCommand {
    <#
        .SYNOPSIS
        Writes the installer script to %LOCALAPPDATA%\SRx\VSCode and returns
        the powershell.exe argument string that runs it (no side effects
        other than writing that script file).
    #>

    [CmdletBinding()]
    param(
        [string]$BuildEdition = 'Stable-User',
        [string[]]$AdditionalExtensions = @(),
        [switch]$LaunchWhenDone,
        [string]$WorkFolder = $(Join-Path $(if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } else { $env:TEMP }) 'SRx')
    )
    $scriptDir = Join-Path $WorkFolder 'VSCode'
    $logDir    = Join-Path $WorkFolder 'Logs'
    foreach ($d in @($scriptDir, $logDir)) { if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null } }

    $scriptFile = Join-Path $scriptDir 'Invoke-SRxVSCodeInstall.ps1'
    $utf8Bom = New-Object System.Text.UTF8Encoding -ArgumentList $true
    [System.IO.File]::WriteAllText($scriptFile, $script:SRxVSCodeInstallerScript, $utf8Bom)

    $logFile = Join-Path $logDir ('Install-VSCode-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '.log')
    $argLine = '-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "{0}" -LogPath "{1}" -BuildEdition {2}' -f $scriptFile, $logFile, $BuildEdition
    $ext = @($AdditionalExtensions | Where-Object { $_ })
    if ($ext.Count -gt 0) { $argLine += (' -AdditionalExtensions "{0}"' -f ($ext -join ',')) }
    if ($LaunchWhenDone)  { $argLine += ' -LaunchWhenDone' }

    return [pscustomobject]@{
        FilePath   = Join-Path $PSHOME 'powershell.exe'
        Arguments  = $argLine
        ScriptFile = $scriptFile
        LogPath    = $logFile
    }
}

function Start-SRxVSCodeUserInstall {
    <#
        .SYNOPSIS
        If VS Code is missing, starts the hidden current-user install and
        returns a handle object; if VS Code is present (or an install was
        already started in this session) returns $null - i.e. DOES NOTHING.
        .PARAMETER Detection
        Pre-computed Test-SRxVSCodeInstalled result (tests).
        .PARAMETER DryRun
        Build the command but do not launch it (tests). Returned object has
        Process = $null and DryRun = $true.
        .PARAMETER WorkFolder
        Where the installer script and log go (default %LOCALAPPDATA%\SRx).
        .OUTPUTS
        [pscustomobject] Process, LogPath, Command, DryRun
    #>

    [CmdletBinding()]
    param(
        [object]$Detection = $null,
        [string]$BuildEdition = 'Stable-User',
        [string[]]$AdditionalExtensions = @(),
        [switch]$LaunchWhenDone,
        [switch]$DryRun,
        [switch]$Force,
        [string]$WorkFolder = $null
    )
    if ($null -eq $Detection) { $Detection = Test-SRxVSCodeInstalled }
    if ($Detection.Installed) { return $null }
    if ($script:SRxVSCodeInstallStarted -and -not $Force -and -not $DryRun) { return $null }

    $cmdArgs = @{ BuildEdition = $BuildEdition; AdditionalExtensions = $AdditionalExtensions; LaunchWhenDone = [bool]$LaunchWhenDone }
    if ($WorkFolder) { $cmdArgs['WorkFolder'] = $WorkFolder }
    $cmd = Get-SRxVSCodeInstallCommand @cmdArgs
    if ($DryRun) {
        return [pscustomobject]@{ Process = $null; LogPath = $cmd.LogPath; Command = $cmd; DryRun = $true }
    }

    $proc = Start-Process -FilePath $cmd.FilePath -ArgumentList $cmd.Arguments -WindowStyle Hidden -PassThru
    # Windows PowerShell quirk: touch .Handle now or ExitCode may be $null later
    try { $null = $proc.Handle } catch { }
    $script:SRxVSCodeInstallStarted = $true
    return [pscustomobject]@{ Process = $proc; LogPath = $cmd.LogPath; Command = $cmd; DryRun = $false }
}

function Reset-SRxVSCodeInstallState {
    <# .SYNOPSIS Clears the once-per-session guard (tests). #>
    [CmdletBinding()]
    param()
    $script:SRxVSCodeInstallStarted = $false
}

Export-ModuleMember -Function Test-SRxVSCodeInstalled, Get-SRxVSCodeInstallCommand, Start-SRxVSCodeUserInstall, Reset-SRxVSCodeInstallState