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 |