### Exit Codes: # -1 = Sandbox is not enabled # 0 = Success # 1 = Error fetching GitHub release # 2 = Unable to kill a running process # 3 = WinGet is not installed # 4 = Manifest validation error ### [CmdletBinding()] param( # Manifest [Parameter(Position = 0, HelpMessage = 'The Manifest to install in the Sandbox.')] [ValidateScript({ if (-not (Test-Path -Path $_)) { throw "$_ does not exist" } return $true })] [String] $Manifest, # Script [Parameter(Position = 1, HelpMessage = 'The script to run in the Sandbox.')] [ScriptBlock] $Script, # MapFolder [Parameter(HelpMessage = 'The folder to map in the Sandbox.')] [ValidateScript({ if (-not (Test-Path -Path $_ -PathType Container)) { throw "$_ is not a folder." } if ($_ -match '[''"<>&]') { throw "MapFolder path contains forbidden characters (' `" < > &): $_" } return $true })] [String] $MapFolder = $pwd, # WinGetVersion [Parameter(HelpMessage = 'The version of WinGet to use')] [string] $WinGetVersion, # WinGetOptions [Parameter(HelpMessage = 'Additional options for WinGet')] [ValidateScript({ # Parse allowed flags only from flag definition lines in winget install --help # These lines start with whitespace followed by a dash (e.g., " -h,--silent ...") if (-not (Get-Command 'winget.exe' -ErrorAction SilentlyContinue)) { Write-Warning 'WinGet is not installed on the host. Skipping WinGetOptions validation.' return $true } $helpLines = winget install --help 2>&1 | Where-Object { $_ -match '^\s+-' } $allowedFlags = @() foreach ($line in $helpLines) { # Extract the flag portion before the description (everything up to two consecutive spaces) $flagPart = ($line.Trim() -split '\s{2,}')[0] $parsedTokens = $flagPart -split ',' | ForEach-Object { $_.Trim() } Write-Debug "WinGetOptions: Parsed line '$($line.Trim())' -> flags: $($parsedTokens -join ', ')" $allowedFlags += $parsedTokens } $allowedFlags = $allowedFlags | Where-Object { $_ } | Select-Object -Unique if (-not $allowedFlags) { throw 'Could not parse winget install flags from --help output. Ensure winget is installed.' } Write-Debug "WinGetOptions: Allow-list ($($allowedFlags.Count) flags): $($allowedFlags -join ', ')" $tokens = $_ -split '\s+' foreach ($token in $tokens) { if ($token.StartsWith('-')) { $isAllowed = $token -in $allowedFlags Write-Debug "WinGetOptions: Checking token '$token' -> $(if ($isAllowed) { 'ALLOWED' } else { 'BLOCKED' })" if (-not $isAllowed) { throw "WinGetOptions contains disallowed flag: $token. Allowed flags: $($allowedFlags -join ', ')" } } else { Write-Debug "WinGetOptions: Skipping value token '$token'" } } return $true })] [string] $WinGetOptions, # Switches [switch] $SkipManifestValidation, [switch] $Prerelease, [switch] $EnableExperimentalFeatures, [switch] $Clean ) enum DependencySources { InRelease Legacy } # Script Behaviors $ProgressPreference = 'SilentlyContinue' $ErrorActionPreference = 'Stop' # This gets overridden most places, but is set explicitly here to help catch errors if ($PSBoundParameters.Keys -notcontains 'InformationAction') { $InformationPreference = 'Continue' } # If the user didn't explicitly set an InformationAction, Override their preference if ($PSBoundParameters.Keys -contains 'WarningAction') { $script:OnMappedFolderWarning = $PSBoundParameters.WarningAction } else { $script:OnMappedFolderWarning = 'Inquire' } $script:UseNuGetForMicrosoftUIXaml = $false $script:ScriptName = 'SandboxTest' $script:AppInstallerPFN = 'Microsoft.DesktopAppInstaller_8wekyb3d8bbwe' $script:DependenciesBaseName = 'DesktopAppInstaller_Dependencies' $script:ReleasesApiUrl = 'https://api.github.com/repos/microsoft/winget-cli/releases?per_page=100' $script:DependencySource = [DependencySources]::InRelease $script:UsePowerShellModuleForInstall = $false $script:CachedTokenExpiration = 30 # Days # File Names $script:AppInstallerMsixFileName = "$script:AppInstallerPFN.msixbundle" # This should exactly match the name of the file in the CLI GitHub Release $script:DependenciesZipFileName = "$script:DependenciesBaseName.zip" # This should exactly match the name of the file in the CLI GitHub Release # Download Urls $script:VcLibsDownloadUrl = 'https://aka.ms/Microsoft.VCLibs.x64.14.00.Desktop.appx' $script:UiLibsDownloadUrl_v2_7 = 'https://github.com/microsoft/microsoft-ui-xaml/releases/download/v2.7.3/Microsoft.UI.Xaml.2.7.x64.appx' $script:UiLibsDownloadUrl_v2_8 = 'https://github.com/microsoft/microsoft-ui-xaml/releases/download/v2.8.6/Microsoft.UI.Xaml.2.8.x64.appx' $script:UiLibsDownloadUrl_NuGet = 'https://globalcdn.nuget.org/packages/microsoft.ui.xaml.2.8.6.nupkg?packageVersion=2.8.6' # Expected Hashes $script:VcLibsHash = 'B56A9101F706F9D95F815F5B7FA6EFBAC972E86573D378B96A07CFF5540C5961' $script:UiLibsHash_v2_7 = '8CE30D92ABEC6522BEB2544E7B716983F5CBA50751B580D89A36048BF4D90316' $script:UiLibsHash_v2_8 = '249D2AFB41CC009494841372BD6DD2DF46F87386D535DDF8D9F32C97226D2E46' $script:UiLibsHash_NuGet = '6B62BD3C277F55518C3738121B77585AC5E171C154936EC58D87268BBAE91736' # File Paths $script:AppInstallerDataFolder = Join-Path -Path (Join-Path -Path $env:LOCALAPPDATA -ChildPath 'Packages') -ChildPath $script:AppInstallerPFN $script:TokenValidationCache = Join-Path -Path $script:AppInstallerDataFolder -ChildPath 'TokenValidationCache' $script:DependenciesCacheFolder = Join-Path -Path $script:AppInstallerDataFolder -ChildPath "$script:ScriptName.Dependencies" $script:TestDataFolder = Join-Path -Path $script:AppInstallerDataFolder -ChildPath $script:ScriptName $script:PrimaryMappedFolder = (Resolve-Path -Path $MapFolder).Path $script:ConfigurationFile = Join-Path -Path $script:TestDataFolder -ChildPath "$script:ScriptName.wsb" # Sandbox Settings $script:SandboxDesktopFolder = 'C:\Users\WDAGUtilityAccount\Desktop' $script:SandboxWorkingDirectory = Join-Path -Path $script:SandboxDesktopFolder -ChildPath $($script:PrimaryMappedFolder | Split-Path -Leaf) $script:SandboxTestDataFolder = Join-Path -Path $script:SandboxDesktopFolder -ChildPath $($script:TestDataFolder | Split-Path -Leaf) $script:SandboxBootstrapFile = Join-Path -Path $script:SandboxTestDataFolder -ChildPath "$script:ScriptName.ps1" $script:HostGeoID = (Get-WinHomeLocation).GeoID # Misc [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 # Ensure the System.Net.Http assembly is loaded Add-Type -AssemblyName System.Net.Http $script:HttpClient = New-Object System.Net.Http.HttpClient $script:CleanupPaths = @() # Removed the `-GitHubToken`parameter, always use environment variable # It is possible that the environment variable may not exist, in which case this may be null $script:GitHubToken = $env:WINGET_PKGS_GITHUB_TOKEN # The experimental features get updated later based on a switch that is set $script:SandboxWinGetSettings = @{ '$schema' = 'https://aka.ms/winget-settings.schema.json' logging = @{ level = 'verbose' } experimentalFeatures = @{ fonts = $false } } #### # Description: Cleans up resources used by the script and then exits # Inputs: Exit code # Outputs: None #### function Invoke-CleanExit { param ( [Parameter(Mandatory = $true)] [int] $ExitCode ) Invoke-FileCleanup -FilePaths $script:CleanupPaths $script:HttpClient.Dispose() Write-Debug "Exiting ($ExitCode)" exit $ExitCode } #### # Description: Ensures that a folder is present. Creates it if it does not exist # Inputs: Path to folder # Outputs: Boolean. True if path exists or was created; False if otherwise #### function Initialize-Folder { param ( [Parameter(Mandatory = $true)] [String] $FolderPath ) $FolderPath = [System.Io.Path]::GetFullPath($FolderPath) # Normalize the path just in case the separation characters weren't quite right, or dot notation was used if (Test-Path -Path $FolderPath -PathType Container) { return $true } # The path exists and is a folder if (Test-Path -Path $FolderPath) { return $false } # The path exists but was not a folder Write-Debug "Initializing folder at $FolderPath" $directorySeparator = [System.IO.Path]::DirectorySeparatorChar # Build the path up one part at a time. This is safer than using the `-Force` parameter on New-Item to create the directory foreach ($pathPart in $FolderPath.Split($directorySeparator)) { $builtPath += $pathPart + $directorySeparator if (!(Test-Path -Path $builtPath)) { New-Item -Path $builtPath -ItemType Directory | Out-Null } } # Make sure that the path was actually created return Test-Path -Path $FolderPath } #### # Description: Gets the details for a specific WinGet CLI release # Inputs: Nullable GitHub API Token # Outputs: Nullable Object containing GitHub release details #### function Get-Release { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'The standard workflow that users use with other applications requires the use of plaintext GitHub Access Tokens')] param ( [Parameter()] [AllowEmptyString()] [String] $GitHubToken ) # Build up the API request parameters here so the authentication can be added if the user's token is valid $requestParameters = @{ Uri = $script:ReleasesApiUrl } if (Test-GithubToken -Token $GitHubToken) { # The validation function will return True only if the provided token is valid Write-Verbose 'Adding Bearer Token Authentication to Releases API Request' $requestParameters.Add('Authentication', 'Bearer') $requestParameters.Add('Token', $(ConvertTo-SecureString $GitHubToken -AsPlainText)) } else { # No token was provided or the token has expired # If an invalid token was provided, an exception will have been thrown before this code is reached Write-Warning @" A valid GitHub token was not provided. You may encounter API rate limits. Please consider adding your token using the `WINGET_PKGS_GITHUB_TOKEN` environment variable. "@ } $releasesAPIResponse = Invoke-RestMethod @requestParameters if (!$script:Prerelease) { $releasesAPIResponse = $releasesAPIResponse.Where({ !$_.prerelease }) } if (![String]::IsNullOrWhiteSpace($script:WinGetVersion)) { $releasesAPIResponse = @($releasesAPIResponse.Where({ $_.tag_name -match $('^v?' + [regex]::escape($script:WinGetVersion)) })) } if ($releasesAPIResponse.Count -lt 1) { return $null } return $releasesAPIResponse | Sort-Object -Property published_at -Descending | Select-Object -First 1 } #### # Description: Gets the content of a file from a URI # Inputs: Remote URI # Outputs: File Contents #### function Get-RemoteContent { param ( [Parameter(Mandatory = $true)] [AllowEmptyString()] [String] $URL, [String] $OutputPath = '', [switch] $Raw ) Write-Debug "Attempting to fetch content from $URL" # Check if the URL is valid before trying to download # If the URL is null, return a status code of 400 if ([String]::IsNullOrWhiteSpace($URL)) { $response = @{ StatusCode = 400 } } else { $response = Invoke-WebRequest -Uri $URL -Method Head -ErrorAction SilentlyContinue } if ($response.StatusCode -ne 200) { Write-Debug "Fetching remote content from $URL returned status code $($response.StatusCode)" return $null } # If a path was specified, store it at that path; Otherwise use the temp folder if ($OutputPath) { $localFile = [System.IO.FileInfo]::new($OutputPath) } else { $localFile = New-TemporaryFile } Write-Debug "Remote content will be stored at $($localFile.FullName)" # Mark the file for cleanup when the script ends if the raw data was requested if ($Raw) { $script:CleanupPaths += @($localFile.FullName) } try { $downloadTask = $script:HttpClient.GetByteArrayAsync($URL) [System.IO.File]::WriteAllBytes($localfile.FullName, $downloadTask.Result) } catch { # If the download fails, write a zero-byte file anyways $null | Out-File $localFile.FullName } # If the raw content was requested, return the content, otherwise, return the FileInfo object if ($Raw) { return Get-Content -Path $localFile.FullName } else { return $localFile } } #### # Description: Removes files and folders from the file system # Inputs: List of paths to remove # Outputs: None #### function Invoke-FileCleanup { param ( [Parameter(Mandatory = $true)] [AllowEmptyString()] [AllowEmptyCollection()] [String[]] $FilePaths ) if (!$FilePaths) { return } foreach ($path in $FilePaths) { Write-Debug "Removing $path" if (Test-Path $path) { Remove-Item -Path $path -Recurse } else { Write-Warning "Could not remove $path as it does not exist" } } } #### # Description: Stops a process and waits for it to terminate # Inputs: ProcessName, TimeoutSeconds # Outputs: None #### function Stop-NamedProcess { [CmdletBinding(SupportsShouldProcess)] param ( [Parameter(Mandatory = $true)] [String] $ProcessName, [int] $TimeoutMilliseconds = 30000 # Default to 30 seconds ) $process = Get-Process -Name $ProcessName -ErrorAction SilentlyContinue if (!$process) { return } # Process was not running # Stop The Process Write-Information "--> Stopping $ProcessName" if ($PSCmdlet.ShouldProcess($process)) { $process | Stop-Process -WhatIf:$WhatIfPreference } $elapsedTime = 0 $waitMilliseconds = 500 $processStillRunning = $true # Wait for the process to terminate do { $processStillRunning = Get-Process -Name $processName -ErrorAction SilentlyContinue if ($processStillRunning) { Write-Debug "$ProcessName is still running after $($elapsedTime/1000) seconds" Start-Sleep -Milliseconds $waitMilliseconds # Wait before checking again $elapsedTime += $waitMilliseconds } } while ($processStillRunning -and $elapsedTime -lt $TimeoutMilliseconds) if ($processStillRunning) { Write-Error -Category OperationTimeout "Unable to terminate running process: $ProcessName" -ErrorAction Continue Invoke-CleanExit -ExitCode 2 } } #### # Description: Ensures that a file has the expected checksum # Inputs: Expected Checksum, Path to file, Hashing algorithm # Outputs: Boolean #### function Test-FileChecksum { param ( [Parameter(Mandatory = $true)] [String] $ExpectedChecksum, [Parameter(Mandatory = $true)] [String] $Path, [Parameter()] [String] $Algorithm = 'SHA256' ) # Get the hash of the file that is currently at the expected location for the dependency; This can be $null $currentHash = Get-FileHash -Path $Path -Algorithm $Algorithm -ErrorAction SilentlyContinue return ($currentHash -and $currentHash.Hash -eq $ExpectedChecksum) } #### # Description: Checks that a provided GitHub token is valid # Inputs: Token # Outputs: Boolean # Notes: # This function hashes the provided GitHub token. If the provided token is valid, a file is added to the token cache with # the name of the hashed token and the token expiration date. To avoid making unnecessary calls to the GitHub APIs, this # function checks the token cache for the existence of the file. If the file is older than 30 days, it is removed and the # token is re-checked. If the file has content, the date is checked to see if the token is expired. This can't catch every # edge case, but it should catch a majority of the use cases. #### function Test-GithubToken { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'The standard workflow that users use with other applications requires the use of plaintext GitHub Access Tokens')] param ( [Parameter(Mandatory = $true)] [AllowEmptyString()] [String] $Token ) # If the token is empty, there is no way that it can be valid if ([string]::IsNullOrWhiteSpace($Token)) { return $false } Write-Verbose 'Hashing GitHub Token' $_memoryStream = [System.IO.MemoryStream]::new() $_streamWriter = [System.IO.StreamWriter]::new($_memoryStream) $_streamWriter.Write($Token) $_streamWriter.Flush() $_memoryStream.Position = 0 $tokenHash = Get-FileHash -InputStream $_memoryStream | Select-Object -ExpandProperty Hash # Dispose of the reader and writer for hashing the token to ensure they cannot be accessed outside of the intended scope Write-Debug 'Disposing of hashing components' $_streamWriter.DisposeAsync() 1> $null $_memoryStream.DisposeAsync() 1> $null # Check for the cached token file Initialize-Folder -FolderPath $script:TokenValidationCache | Out-Null $cachedToken = Get-ChildItem -Path $script:TokenValidationCache -Filter $tokenHash -ErrorAction SilentlyContinue if ($cachedToken) { Write-Verbose 'Token was found in the cache' # Check the age of the cached file $cachedTokenAge = (Get-Date) - $cachedToken.LastWriteTime | Select-Object -ExpandProperty TotalDays $cachedTokenAge = [Math]::Round($cachedTokenAge, 2) # We don't need all the precision the system provides Write-Debug "Token has been in the cache for $cachedTokenAge days" $cacheIsExpired = $cachedTokenAge -ge $script:CachedTokenExpiration $cachedTokenContent = (Get-Content $cachedToken -Raw).Trim() # Ensure any trailing whitespace is ignored $cachedTokenIsEmpty = [string]::IsNullOrWhiteSpace($cachedTokenContent) # It is possible for a token to be both empty and expired. Since these are debug and verbose messages, showing both doesn't hurt if ($cachedTokenIsEmpty) { Write-Verbose 'Cached token had no content. It will be re-validated' } if ($cacheIsExpired) { Write-Verbose "Cached token is older than $script:CachedTokenExpiration days. It will be re-validated" } if (!$cacheIsExpired -and !$cachedTokenIsEmpty) { # Check the content of the cached file in case the actual token expiration is known Write-Verbose 'Attempting to fetch token expiration from cache' # Since Github adds ` UTC` at the end, it needs to be stripped off. Trim is safe here since the last character should always be a digit or AM/PM $cachedExpirationForParsing = $cachedTokenContent.TrimEnd(' UTC') $cachedExpirationDate = [System.DateTime]::MinValue # Pipe to Out-Null so that it doesn't get captured in the return output [System.DateTime]::TryParse($cachedExpirationForParsing, [ref]$cachedExpirationDate) | Out-Null $tokenExpirationDays = $cachedExpirationDate - (Get-Date) | Select-Object -ExpandProperty TotalDays $tokenExpirationDays = [Math]::Round($tokenExpirationDays, 2) # We don't need all the precision the system provides if ($cachedExpirationForParsing -eq [System.DateTime]::MaxValue.ToLongDateString().Trim()) { Write-Verbose 'The cached token contained content. It is set to never expire' return $true } if ($tokenExpirationDays -gt 0) { Write-Verbose "The cached token contained content. It should expire in $tokenExpirationDays days" return $true } # If the parsing failed, the expiration should still be at the minimum value elseif ($cachedExpirationDate -eq [System.DateTime]::MinValue) { Write-Verbose 'The cached token contained content, but it could not be parsed as a date. It will be re-validated' Invoke-FileCleanup -FilePaths $cachedToken.FullName # Do not return anything, since the token will need to be re-validated } else { Write-Verbose "The cached token contained content, but the token expired $([Math]::Abs($tokenExpirationDays)) days ago" # Leave the cached token so that it doesn't throw script exceptions in the future # Invoke-FileCleanup -FilePaths $cachedToken.FullName return $false } } else { # Either the token was empty, or the cached token is expired. Remove the cached token so that re-validation # of the token will update the date the token was cached if it is still valid Invoke-FileCleanup -FilePaths $cachedToken.FullName } } else { Write-Verbose 'Token was not found in the cache' } # To get here either the token was not in the cache or it needs to be re-validated $requestParameters = @{ Uri = 'https://api.github.com/rate_limit' Authentication = 'Bearer' Token = $(ConvertTo-SecureString "$Token" -AsPlainText) } Write-Verbose "Checking Token against $($requestParameters.Uri)" $apiResponse = Invoke-WebRequest @requestParameters # This will return an exception if the token is not valid; It is intentionally not caught # The headers can sometimes be a single string, or an array of strings. Cast them into an array anyways just for safety $rateLimit = @($apiResponse.Headers['X-RateLimit-Limit']) $tokenExpiration = @($apiResponse.Headers['github-authentication-token-expiration']) # This could be null if the token is set to never expire. Write-Debug "API responded with Rate Limit ($rateLimit) and Expiration ($tokenExpiration)" if (!$rateLimit) { return $false } # Something went horribly wrong, and the rate limit isn't known. Assume the token is not valid if ([int]$rateLimit[0] -le 60) { # Authenticated users typically have a limit that is much higher than 60 return $false } Write-Verbose 'Token validated successfully. Adding to cache' # Trim off any non-digit characters from the end # Strip off the array wrapper since it is no longer needed $tokenExpiration = $tokenExpiration[0] -replace '[^0-9]+$', '' # If the token doesn't expire, write a special value to the file if (!$tokenExpiration -or [string]::IsNullOrWhiteSpace($tokenExpiration)) { Write-Debug 'Token expiration was empty, setting it to maximum' $tokenExpiration = [System.DateTime]::MaxValue } # Try parsing the value to a datetime before storing it if ([DateTime]::TryParse($tokenExpiration, [ref]$tokenExpiration)) { Write-Debug "Token expiration successfully parsed as DateTime ($tokenExpiration)" } else { # TryParse Failed Write-Warning 'Could not parse expiration date as a DateTime object. It will be set to the minimum value' $tokenExpiration = [System.DateTime]::MinValue } # Explicitly convert to a string here to avoid implicit casting $tokenExpiration = $tokenExpiration.ToString() # Write the value to the cache New-Item -ItemType File -Path $script:TokenValidationCache -Name $tokenHash -Value $tokenExpiration | Out-Null Write-Debug "Token <$tokenHash> added to cache with content <$tokenExpiration>" return $true } #### Start of main script #### # Check if Windows Sandbox is enabled if (-not (Get-Command 'WindowsSandbox' -ErrorAction SilentlyContinue)) { Write-Error -ErrorAction Continue -Category NotInstalled -Message @' Windows Sandbox does not seem to be available. Check the following URL for prerequisites and further details: https://docs.microsoft.com/windows/security/threat-protection/windows-sandbox/windows-sandbox-overview You can run the following command in an elevated PowerShell for enabling Windows Sandbox: $ Enable-WindowsOptionalFeature -Online -FeatureName 'Containers-DisposableClientVM' '@ Invoke-CleanExit -ExitCode -1 } # Validate the provided manifest if (!$SkipManifestValidation -and ![String]::IsNullOrWhiteSpace($Manifest)) { # Check that WinGet is Installed if (!(Get-Command 'winget.exe' -ErrorAction SilentlyContinue)) { Write-Error -Category NotInstalled 'WinGet is not installed. Manifest cannot be validated' -ErrorAction Continue Invoke-CleanExit -ExitCode 3 } Write-Information '--> Validating Manifest' $validateCommandOutput = & { # Store current output encoding setting $prevOutEnc = [Console]::OutputEncoding # Set [Console]::OutputEncoding to UTF-8 since winget uses UTF-8 for output [Console]::OutputEncoding = $OutputEncoding = [System.Text.Utf8Encoding]::new() winget.exe validate $Manifest # Reset the encoding to the previous values [Console]::OutputEncoding = $prevOutEnc } switch ($LASTEXITCODE) { '-1978335191' { # Skip the first line and the empty last line $validateCommandOutput | Select-Object -Skip 1 -SkipLast 1 | ForEach-Object { Write-Information $_ } Write-Error -Category ParserError 'Manifest validation failed' -ErrorAction Continue Invoke-CleanExit -ExitCode 4 } '-1978335192' { # Skip the first line and the empty last line $validateCommandOutput | Select-Object -Skip 1 -SkipLast 1 | ForEach-Object { Write-Information $_ } Write-Warning 'Manifest validation succeeded with warnings' Start-Sleep -Seconds 5 # Allow the user 5 seconds to read the warnings before moving on } default { # Avoid writing a raw object array when `winget validate` returns multiple lines $validateSuccessOutput = @($validateCommandOutput).ForEach({ $_.ToString().Trim() }).Where({ -not [String]::IsNullOrWhiteSpace($_) }) if ($validateSuccessOutput) { Write-Information ($validateSuccessOutput -join [Environment]::NewLine) } } } } # Get the details for the version of WinGet that was requested Write-Verbose "Fetching release details from $script:ReleasesApiUrl; Filters: {Prerelease=$script:Prerelease; Version~=$script:WinGetVersion}" $script:WinGetReleaseDetails = Get-Release -GitHubToken $script:GitHubToken if (!$script:WinGetReleaseDetails) { Write-Error -Category ObjectNotFound 'No WinGet releases found matching criteria' -ErrorAction Continue Invoke-CleanExit -ExitCode 1 } if (!$script:WinGetReleaseDetails.assets) { Write-Error -Category ResourceUnavailable 'Could not fetch WinGet CLI release assets' -ErrorAction Continue Invoke-CleanExit -ExitCode 1 } Write-Verbose 'Parsing Release Information' # Parse the needed URLs out of the release. It is entirely possible that these could end up being $null $script:AppInstallerMsixShaDownloadUrl = $script:WinGetReleaseDetails.assets.Where({ $_.name -eq "$script:AppInstallerPFN.txt" }).browser_download_url $script:AppInstallerMsixDownloadUrl = $script:WinGetReleaseDetails.assets.Where({ $_.name -eq $script:AppInstallerMsixFileName }).browser_download_url $script:DependenciesShaDownloadUrl = $script:WinGetReleaseDetails.assets.Where({ $_.name -eq "$script:DependenciesBaseName.txt" }).browser_download_url $script:DependenciesZipDownloadUrl = $script:WinGetReleaseDetails.assets.Where({ $_.name -eq $script:DependenciesZipFileName }).browser_download_url Write-Debug @" AppInstallerMsixShaDownloadUrl = $script:AppInstallerMsixShaDownloadUrl AppInstallerMsixDownloadUrl = $script:AppInstallerMsixDownloadUrl DependenciesShaDownloadUrl = $script:DependenciesShaDownloadUrl DependenciesZipDownloadUrl = $script:DependenciesZipDownloadUrl "@ # Parse out the version $script:AppInstallerReleaseTag = $script:WinGetReleaseDetails.tag_name $script:AppInstallerParsedVersion = [System.Version]($script:AppInstallerReleaseTag -replace '(^v)|(-preview$)') Write-Debug "Using Release version $script:AppinstallerReleaseTag ($script:AppInstallerParsedVersion)" # Get the hashes for the files that change with each release version Write-Verbose 'Fetching file hash information' $script:AppInstallerMsixHash = Get-RemoteContent -URL $script:AppInstallerMsixShaDownloadUrl -Raw $script:DependenciesZipHash = Get-RemoteContent -URL $script:DependenciesShaDownloadUrl -Raw Write-Debug @" AppInstallerMsixHash = $script:AppInstallerMsixHash DependenciesZipHash = $script:DependenciesZipHash "@ # Set the folder for the files that change with each release version $script:AppInstallerReleaseAssetsFolder = Join-Path -Path (Join-Path -Path $script:AppInstallerDataFolder -ChildPath 'bin') -ChildPath $script:AppInstallerReleaseTag # Build the dependency information Write-Verbose 'Building Dependency List' $script:AppInstallerDependencies = @() if ($script:AppInstallerParsedVersion -ge [System.Version]'1.9.25180') { # As of WinGet 1.9.25180, VCLibs no longer publishes to the public URL and must be downloaded from the WinGet release # Add the Zip file from the release to the dependencies Write-Debug "Adding $script:DependenciesZipFileName to dependency list" $script:AppInstallerDependencies += @{ DownloadUrl = $script:DependenciesZipDownloadUrl Checksum = $script:DependenciesZipHash Algorithm = 'SHA256' SaveTo = (Join-Path -Path $script:AppInstallerReleaseAssetsFolder -ChildPath $script:DependenciesZipFileName) } } else { $script:DependencySource = [DependencySources]::Legacy # Add the VCLibs to the dependencies Write-Debug 'Adding VCLibs UWP to dependency list' $script:AppInstallerDependencies += @{ DownloadUrl = $script:VcLibsDownloadUrl Checksum = $script:VcLibsHash Algorithm = 'SHA256' SaveTo = (Join-Path -Path $script:DependenciesCacheFolder -ChildPath 'Microsoft.VCLibs.Desktop.x64.appx') } if ($script:UseNuGetForMicrosoftUIXaml) { # Add the NuGet file to the dependencies Write-Debug 'Adding Microsoft.UI.Xaml (NuGet) to dependency list' $script:AppInstallerDependencies += @{ DownloadUrl = $script:UiLibsDownloadUrl_NuGet Checksum = $script:UiLibsHash_NuGet Algorithm = 'SHA256' SaveTo = (Join-Path -Path $script:DependenciesCacheFolder -ChildPath 'Microsoft.UI.Xaml.zip') } } # As of WinGet 1.7.10514 (https://github.com/microsoft/winget-cli/pull/4218), the dependency on uiLibsUwP was bumped from version 2.7.3 to version 2.8.6 elseif ($script:AppInstallerParsedVersion -lt [System.Version]'1.7.10514') { # Add Xaml 2.7 to the dependencies Write-Debug 'Adding Microsoft.UI.Xaml (v2.7) to dependency list' $script:AppInstallerDependencies += @{ DownloadUrl = $script:UiLibsDownloadUrl_v2_7 Checksum = $script:UiLibsHash_v2_7 Algorithm = 'SHA256' SaveTo = (Join-Path -Path $script:DependenciesCacheFolder -ChildPath 'Microsoft.UI.Xaml.2.7.x64.appx') } } else { # Add Xaml 2.8 to the dependencies Write-Debug 'Adding Microsoft.UI.Xaml (v2.8) to dependency list' $script:AppInstallerDependencies += @{ DownloadUrl = $script:UiLibsDownloadUrl_v2_8 Checksum = $script:UiLibsHash_v2_8 Algorithm = 'SHA256' SaveTo = (Join-Path -Path $script:DependenciesCacheFolder -ChildPath 'Microsoft.UI.Xaml.2.8.x64.appx') } } } # Add WinGet as a dependency for itself # This seems weird, but it's the easiest way to ensure that it is downloaded and has the right hash Write-Debug "Adding $script:AppInstallerMsixFileName ($script:AppInstallerReleaseTag) to dependency list" $script:AppInstallerDependencies += @{ DownloadUrl = $script:AppInstallerMsixDownloadUrl Checksum = $script:AppInstallerMsixHash Algorithm = 'SHA256' SaveTo = (Join-Path -Path $script:AppInstallerReleaseAssetsFolder -ChildPath $script:AppInstallerMsixFileName) } # If the PowerShell Module will be used, destroy the dependency list that was just created # This is cleaner than adding if statements everywhere to try and handle this flag. # Since the time it takes to build the dependency tree is minimal, don't worry about performance yet if ($script:UsePowerShellModuleForInstall) { $script:AppInstallerDependencies = @() } # Process the dependency list Write-Information '--> Checking Dependencies' foreach ($dependency in $script:AppInstallerDependencies) { # On a clean install, remove the existing files if ($Clean) { Invoke-FileCleanup -FilePaths $dependency.SaveTo } # If the hash doesn't match, the dependency needs to be re-downloaded # If the file doesn't exist on the system, the hashes will not match since $null != '' Write-Verbose "Checking the hash of $($dependency.SaveTo)" if (!(Test-FileChecksum -ExpectedChecksum $dependency.Checksum -Path $dependency.SaveTo -Algorithm $dependency.Algorithm)) { if (!(Initialize-Folder $($dependency.SaveTo | Split-Path))) { throw "Could not create folder for caching $($dependency.DownloadUrl)" } # The folder needs to be present, otherwise the WebClient request will fail Write-Information " - Downloading $($dependency.DownloadUrl)" Get-RemoteContent -URL $dependency.DownloadUrl -OutputPath $dependency.SaveTo -ErrorAction SilentlyContinue | Out-Null } # If the hash didn't match, remove the item so the sandbox can fall-back to using the PowerShell module if (!(Test-FileChecksum -ExpectedChecksum $dependency.Checksum -Path $dependency.SaveTo -Algorithm $dependency.Algorithm)) { $script:UsePowerShellModuleForInstall = $true Write-Debug "Hashes did not match; Expected $($dependency.Checksum), Received $((Get-FileHash $dependency.SaveTo -Algorithm $dependency.Algorithm -ErrorAction Continue).Hash)" Remove-Item $dependency.SaveTo -Force | Out-Null # Continue on these errors because the PowerShell module will be used instead Write-Error -Category SecurityError 'Dependency hash does not match the downloaded file' -ErrorAction Continue Write-Error -Category SecurityError 'Please open an issue referencing this error at https://bit.ly/WinGet-SandboxTest-Needs-Update' -ErrorAction Continue break # Skip processing further dependencies, since the PowerShell Module will be used } } # Kill the active running sandbox, if it exists, otherwise the test data folder can't be removed if (Get-Command wsb -ErrorAction SilentlyContinue) { $sandboxInstance = @(wsb list) | Select-Object -First 1 if ($sandboxInstance) { wsb stop --id $sandboxInstance } else { Write-Verbose 'No running instance of Sandbox' } } else { Write-Debug 'wsb command was not found - terminaing any sessions by process name' Stop-NamedProcess -ProcessName 'WindowsSandboxClient' Stop-NamedProcess -ProcessName 'WindowsSandboxRemoteSession' } Start-Sleep -Milliseconds 5000 # Wait for the lock on the file to be released # Remove the test data folder if it exists. We will rebuild it with new test data Write-Verbose 'Cleaning up previous test data' Invoke-FileCleanup -FilePaths $script:TestDataFolder # Create the paths if they don't exist if (!(Initialize-Folder $script:TestDataFolder)) { throw 'Could not create folder for mapping files into the sandbox' } if (!(Initialize-Folder $script:DependenciesCacheFolder)) { throw 'Could not create folder for caching dependencies' } # Set Experimental Features to be Enabled, If requested if ($EnableExperimentalFeatures) { Write-Debug 'Setting Experimental Features to Enabled' $experimentalFeatures = @($script:SandboxWinGetSettings.experimentalFeatures.Keys) foreach ($feature in $experimentalFeatures) { $script:SandboxWinGetSettings.experimentalFeatures[$feature] = $true } } # Copy Files to the TestDataFolder that will be mapped into sandbox Write-Verbose "Copying assets into $script:TestDataFolder" if ($Manifest) { Copy-Item -Path $Manifest -Destination $script:TestDataFolder -Recurse -ErrorAction SilentlyContinue } $script:SandboxWinGetSettings | ConvertTo-Json | Out-File -FilePath (Join-Path -Path $script:TestDataFolder -ChildPath 'settings.json') -Encoding ascii foreach ($dependency in $script:AppInstallerDependencies) { Copy-Item -Path $dependency.SaveTo -Destination $script:TestDataFolder -ErrorAction SilentlyContinue } # Create a script file from the script parameter if (-not [String]::IsNullOrWhiteSpace($Script)) { Write-Verbose "Creating script file from 'Script' argument" $Script | Out-File -Path (Join-Path $script:TestDataFolder -ChildPath 'BoundParameterScript.ps1') } # Create the bootstrapping script Write-Verbose 'Creating the script for bootstrapping the sandbox' @" function Update-EnvironmentVariables { foreach(`$level in "Machine","User") { [Environment]::GetEnvironmentVariables(`$level).GetEnumerator() | % { # For Path variables, append the new values, if they're not already in there if(`$_.Name -match '^Path$') { `$_.Value = (`$((Get-Content "Env:`$(`$_.Name)") + ";`$(`$_.Value)") -split ';' | Select -unique) -join ';' } `$_ } | Set-Content -Path { "Env:`$(`$_.Name)" } } } function Get-ARPTable { `$registry_paths = @('HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*','HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKCU:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*') return Get-ItemProperty `$registry_paths -ErrorAction SilentlyContinue | Where-Object { `$_.DisplayName -and (-not `$_.SystemComponent -or `$_.SystemComponent -ne 1 ) } | Select-Object DisplayName, DisplayVersion, Publisher, @{N='ProductCode'; E={`$_.PSChildName}}, @{N='Scope'; E={if(`$_.PSDrive.Name -eq 'HKCU') {'User'} else {'Machine'}}} } Push-Location $($script:SandboxTestDataFolder) Write-Host @' --> Installing WinGet '@ `$ProgressPreference = 'SilentlyContinue' try { if ($([int]$script:UsePowerShellModuleForInstall)) { throw } # Using exceptions for control logic is generally not preferred, but is done here to keep things clean and readable Get-ChildItem -Filter '*.zip' | Expand-Archive Get-ChildItem -Recurse -Filter '*.appx' | Where-Object {`$_.FullName -match 'x64'} | Add-AppxPackage -ErrorAction Stop # This path is set explicitly instead of using Get-ChildItem as an error prevention measure Add-AppxPackage './$($script:AppInstallerPFN).msixbundle' -ErrorAction Stop } catch { Write-Host -ForegroundColor Red 'Could not install from cached packages. Falling back to Repair-WinGetPackageManager cmdlet' try { Install-PackageProvider -Name NuGet -Force | Out-Null Install-Module -Name Microsoft.WinGet.Client -Force -Repository PSGallery | Out-Null } catch { throw "Microsoft.Winget.Client was not installed successfully" } finally { # Check to be sure it actually installed if (-not(Get-Module -ListAvailable -Name Microsoft.Winget.Client)) { throw "Microsoft.Winget.Client was not found. Check that the Windows Package Manager PowerShell module was installed correctly." } } Repair-WinGetPackageManager -Version $($script:AppInstallerReleaseTag) } Write-Host @' --> Disabling safety warning when running installers '@ New-Item -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Policies\Associations' | Out-Null New-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Policies\Associations' -Name 'ModRiskFileTypes' -Type 'String' -Value '.bat;.exe;.reg;.vbs;.chm;.msi;.js;.cmd' | Out-Null Write-Host @' --> Fixing slow MSI package installers '@ reg add "HKLM\SYSTEM\CurrentControlSet\Control\CI\Policy" /v "VerifiedAndReputablePolicyState" /t REG_DWORD /d 0 /f | Out-Null # See: https://github.com/microsoft/Windows-Sandbox/issues/68#issuecomment-2754867968 CiTool.exe --refresh --json | Out-Null # Refreshes policy. Use json output param or else it will prompt for confirmation, even with Out-Null Write-Host @' --> Disabling background services '@ # Disable Search Indexing to prevent performance issues during installation of packages with many files Stop-Service WSearch Set-Service WSearch -StartupType Disabled # Disable Superfetch since we don't need to optimize disk access patterns in a disposable environment Stop-Service SysMain Set-Service SysMain -StartupType Disabled # Disable Windows Update since it can't be used in the sandbox Stop-Service wuauserv Set-Service wuauserv -StartupType Disabled # Disable Scheduled Tasks schtasks /Change /TN "\Microsoft\Windows\DiskCleanup\SilentCleanup" /Disable | Out-Null schtasks /Change /TN "\Microsoft\Windows\Defrag\ScheduledDefrag" /Disable | Out-Null # Disable Telemetry since it isn't needed in a disposable environment Stop-Service DiagTrack Set-Service DiagTrack -StartupType Disabled # Disable the Print Spooler Stop-Service Spooler Set-Service Spooler -StartupType Disabled # Disable Edge Background Processes reg add "HKLM\Software\Policies\Microsoft\Edge" /v BackgroundModeEnabled /t REG_DWORD /d 0 /f | Out-Null Write-Host @' --> Configuring Winget '@ winget settings --Enable LocalManifestFiles winget settings --Enable LocalArchiveMalwareScanOverride Get-ChildItem -Filter 'settings.json' | Copy-Item -Destination C:\Users\WDAGUtilityAccount\AppData\Local\Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState\settings.json Set-WinHomeLocation -GeoID $($script:HostGeoID) Write-Host @' Tip: you can type 'Update-EnvironmentVariables' to update your environment variables, such as after installing a new software. '@ `$manifestFolder = (Get-ChildItem `$pwd -Directory).Where({Get-ChildItem `$_ -Filter '*.yaml'}).FullName | Select-Object -First 1 if (`$manifestFolder) { Write-Host @" --> Installing the Manifest `$(`$manifestFolder | Split-Path -Leaf) `"@ `$originalARP = Get-ARPTable winget install -m `$manifestFolder --accept-package-agreements --verbose-logs --ignore-local-archive-malware-scan --dependency-source winget $WinGetOptions Write-Host @' --> Refreshing environment variables '@ Update-EnvironmentVariables Write-Host @' --> Comparing ARP Entries '@ (Compare-Object (Get-ARPTable) `$originalARP -Property DisplayName,DisplayVersion,Publisher,ProductCode,Scope)| Select-Object -Property * -ExcludeProperty SideIndicator | Format-Table } `$BoundParameterScript = Get-ChildItem -Filter 'BoundParameterScript.ps1' if (`$BoundParameterScript) { Write-Host @' --> Running the following script: { `$(Get-Content -Path `$BoundParameterScript.FullName) } '@ & `$BoundParameterScript.FullName } Pop-Location "@ | Out-File -FilePath $(Join-Path -Path $script:TestDataFolder -ChildPath "$script:ScriptName.ps1") # Create the WSB file # Although this could be done using the native XML processor, it's easier to just write the content directly as a string Write-Verbose 'Creating WSB file for launching the sandbox' $escapedTestDataFolder = [System.Security.SecurityElement]::Escape($script:TestDataFolder) $escapedPrimaryMappedFolder = [System.Security.SecurityElement]::Escape($script:PrimaryMappedFolder) $escapedSandboxWorkingDirectory = [System.Security.SecurityElement]::Escape($script:SandboxWorkingDirectory) $escapedSandboxBootstrapFile = [System.Security.SecurityElement]::Escape($script:SandboxBootstrapFile) @" Enable $($escapedTestDataFolder) $($escapedPrimaryMappedFolder) PowerShell Start-Process PowerShell -WindowStyle Maximized -WorkingDirectory '$($escapedSandboxWorkingDirectory)' -ArgumentList '-ExecutionPolicy Bypass -NoExit -NoLogo -File $($escapedSandboxBootstrapFile)' Disable Disable Disable "@ | Out-File -FilePath $script:ConfigurationFile if ($script:PrimaryMappedFolder -notmatch 'winget-pkgs') { Write-Warning @" The mapped folder does not appear to be within the winget-pkgs repository path. This will give read-and-write access to $($script:PrimaryMappedFolder) within the sandbox "@ -WarningAction $script:OnMappedFolderWarning } Write-Information @" --> Starting Windows Sandbox, and: - Mounting the following directories: - $($script:TestDataFolder) as read-and-write - $($script:PrimaryMappedFolder) as read-and-write - Installing WinGet - Configuring Winget "@ if (-not [String]::IsNullOrWhiteSpace($Manifest)) { Write-Information @" - Installing the Manifest $(Split-Path $Manifest -Leaf) - Refreshing environment variables - Comparing ARP Entries "@ } if (-not [String]::IsNullOrWhiteSpace($Script)) { Write-Information @" - Running the following script: { $Script } "@ } Write-Verbose "Invoking the sandbox using $script:ConfigurationFile" WindowsSandbox $script:ConfigurationFile Invoke-CleanExit -ExitCode 0