@@ -46,7 +46,7 @@ enum DependencySources {
46
46
47
47
# Script Behaviors
48
48
$ProgressPreference = ' SilentlyContinue'
49
- $ErrorActionPreference = ' Stop' # This gets overriden most places, but is set explicitly here to help catch errors
49
+ $ErrorActionPreference = ' Stop' # This gets overridden most places, but is set explicitly here to help catch errors
50
50
if ($PSBoundParameters.Keys -notcontains ' InformationAction' ) { $InformationPreference = ' Continue' } # If the user didn't explicitly set an InformationAction, Override their preference
51
51
$script :OnMappedFolderWarning = ($PSBoundParameters.Keys -contains ' WarningAction' ) ? $PSBoundParameters.WarningAction : ' Inquire'
52
52
$script :UseNuGetForMicrosoftUIXaml = $false
@@ -56,6 +56,7 @@ $script:DependenciesBaseName = 'DesktopAppInstaller_Dependencies'
56
56
$script :ReleasesApiUrl = ' https://api.github.com/repos/microsoft/winget-cli/releases?per_page=100'
57
57
$script :DependencySource = [DependencySources ]::InRelease
58
58
$script :UsePowerShellModuleForInstall = $false
59
+ $script :CachedTokenExpiration = 30 # Days
59
60
60
61
# File Names
61
62
$script :AppInstallerMsixFileName = " $script :AppInstallerPFN .msixbundle" # This should exactly match the name of the file in the CLI GitHub Release
@@ -75,6 +76,7 @@ $script:UiLibsHash_NuGet = '6B62BD3C277F55518C3738121B77585AC5E171C154936EC58D87
75
76
76
77
# File Paths
77
78
$script :AppInstallerDataFolder = Join-Path - Path $env: LOCALAPPDATA - ChildPath ' Packages' - AdditionalChildPath $script :AppInstallerPFN
79
+ $script :TokenValidationCache = Join-Path - Path $script :AppInstallerDataFolder - ChildPath ' TokenValidationCache'
78
80
$script :DependenciesCacheFolder = Join-Path - Path $script :AppInstallerDataFolder - ChildPath " $script :ScriptName .Dependencies"
79
81
$script :TestDataFolder = Join-Path - Path $script :AppInstallerDataFolder - ChildPath $script :ScriptName
80
82
$script :PrimaryMappedFolder = (Resolve-Path - Path $MapFolder ).Path
@@ -92,6 +94,10 @@ $script:HostGeoID = (Get-WinHomeLocation).GeoID
92
94
$script :HttpClient = New-Object System.Net.Http.HttpClient
93
95
$script :CleanupPaths = @ ()
94
96
97
+ # Removed the `-GitHubToken`parameter, always use environment variable
98
+ # It is possible that the environment variable may not exist, in which case this may be null
99
+ $script :GitHubToken = $env: WINGET_PKGS_GITHUB_TOKEN
100
+
95
101
# The experimental features get updated later based on a switch that is set
96
102
$script :SandboxWinGetSettings = @ {
97
103
' $schema' = ' https://aka.ms/winget-settings.schema.json'
@@ -147,11 +153,40 @@ function Initialize-Folder {
147
153
148
154
# ###
149
155
# Description: Gets the details for a specific WinGet CLI release
150
- # Inputs: None
156
+ # Inputs: Nullable GitHub API Token
151
157
# Outputs: Nullable Object containing GitHub release details
152
158
# ###
153
159
function Get-Release {
154
- $releasesAPIResponse = Invoke-RestMethod $script :ReleasesApiUrl
160
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute (' PSAvoidUsingConvertToSecureStringWithPlainText' , ' ' ,
161
+ Justification= ' The standard workflow that users use with other applications requires the use of plaintext GitHub Access Tokens' )]
162
+
163
+ param (
164
+ [Parameter ()]
165
+ [AllowEmptyString ()]
166
+ [String ] $GitHubToken
167
+ )
168
+
169
+ # Build up the API request parameters here so the authentication can be added if the user's token is valid
170
+ $requestParameters = @ {
171
+ Uri = $script :ReleasesApiUrl
172
+ }
173
+
174
+ if (Test-GithubToken - Token $GitHubToken ) {
175
+ # The validation function will return True only if the provided token is valid
176
+ Write-Verbose ' Adding Bearer Token Authentication to Releases API Request'
177
+ $requestParameters.Add (' Authentication' , ' Bearer' )
178
+ $requestParameters.Add (' Token' , $ (ConvertTo-SecureString $GitHubToken - AsPlainText))
179
+ }
180
+ else {
181
+ # No token was provided or the token has expired
182
+ # If an invalid token was provided, an exception will have been thrown before this code is reached
183
+ Write-Warning @"
184
+ A valid GitHub token was not provided. You may encounter API rate limits.
185
+ Please consider adding your token using the `WINGET_PKGS_GITHUB_TOKEN` environment variable.
186
+ "@
187
+ }
188
+
189
+ $releasesAPIResponse = Invoke-RestMethod @requestParameters
155
190
if (! $script :Prerelease ) {
156
191
$releasesAPIResponse = $releasesAPIResponse.Where ({ ! $_.prerelease })
157
192
}
@@ -274,6 +309,152 @@ function Test-FileChecksum {
274
309
return ($currentHash -and $currentHash.Hash -eq $ExpectedChecksum )
275
310
}
276
311
312
+ # ###
313
+ # Description: Checks that a provided GitHub token is valid
314
+ # Inputs: Token
315
+ # Outputs: Boolean
316
+ # Notes:
317
+ # This function hashes the provided GitHub token. If the provided token is valid, a file is added to the token cache with
318
+ # the name of the hashed token and the token expiration date. To avoid making unnecessary calls to the GitHub APIs, this
319
+ # function checks the token cache for the existence of the file. If the file is older than 30 days, it is removed and the
320
+ # 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
321
+ # edge case, but it should catch a majority of the use cases.
322
+ # ###
323
+ function Test-GithubToken {
324
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute (' PSAvoidUsingConvertToSecureStringWithPlainText' , ' ' ,
325
+ Justification= ' The standard workflow that users use with other applications requires the use of plaintext GitHub Access Tokens' )]
326
+
327
+ param (
328
+ [Parameter (Mandatory = $true )]
329
+ [AllowEmptyString ()]
330
+ [String ] $Token
331
+ )
332
+
333
+ # If the token is empty, there is no way that it can be valid
334
+ if ([string ]::IsNullOrWhiteSpace($Token )) { return $false }
335
+
336
+ Write-Verbose ' Hashing GitHub Token'
337
+ $_memoryStream = [System.IO.MemoryStream ]::new()
338
+ $_streamWriter = [System.IO.StreamWriter ]::new($_memoryStream )
339
+ $_streamWriter.Write ($Token )
340
+ $_streamWriter.Flush ()
341
+ $_memoryStream.Position = 0
342
+
343
+ $tokenHash = Get-FileHash - InputStream $_memoryStream | Select-Object - ExpandProperty Hash
344
+
345
+ # Dispose of the reader and writer for hashing the token to ensure they cannot be accessed outside of the intended scope
346
+ Write-Debug ' Disposing of hashing components'
347
+ $_streamWriter.DisposeAsync () 1> $null
348
+ $_memoryStream.DisposeAsync () 1> $null
349
+
350
+ # Check for the cached token file
351
+ Initialize-Folder - FolderPath $script :TokenValidationCache | Out-Null
352
+ $cachedToken = Get-ChildItem - Path $script :TokenValidationCache - Filter $tokenHash - ErrorAction SilentlyContinue
353
+
354
+ if ($cachedToken ) {
355
+ Write-Verbose ' Token was found in the cache'
356
+ # Check the age of the cached file
357
+ $cachedTokenAge = (Get-Date ) - $cachedToken.LastWriteTime | Select-Object - ExpandProperty TotalDays
358
+ $cachedTokenAge = [Math ]::Round($cachedTokenAge , 2 ) # We don't need all the precision the system provides
359
+ Write-Debug " Token has been in the cache for $cachedTokenAge days"
360
+ $cacheIsExpired = $cachedTokenAge -ge $script :CachedTokenExpiration
361
+ $cachedTokenContent = (Get-Content $cachedToken - Raw).Trim() # Ensure any trailing whitespace is ignored
362
+ $cachedTokenIsEmpty = [string ]::IsNullOrWhiteSpace($cachedTokenContent )
363
+
364
+ # It is possible for a token to be both empty and expired. Since these are debug and verbose messages, showing both doesn't hurt
365
+ if ($cachedTokenIsEmpty ) { Write-Verbose ' Cached token had no content. It will be re-validated' }
366
+ if ($cacheIsExpired ) { Write-Verbose " Cached token is older than $script :CachedTokenExpiration days. It will be re-validated" }
367
+
368
+ if (! $cacheIsExpired -and ! $cachedTokenIsEmpty ) {
369
+ # Check the content of the cached file in case the actual token expiration is known
370
+ Write-Verbose ' Attempting to fetch token expiration from cache'
371
+ # 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
372
+ $cachedExpirationForParsing = $cachedTokenContent.TrimEnd (' UTC' )
373
+ $cachedExpirationDate = [System.DateTime ]::MinValue
374
+ # Pipe to Out-Null so that it doesn't get captured in the return output
375
+ [System.DateTime ]::TryParse($cachedExpirationForParsing , [ref ]$cachedExpirationDate ) | Out-Null
376
+
377
+ $tokenExpirationDays = $cachedExpirationDate - (Get-Date ) | Select-Object - ExpandProperty TotalDays
378
+ $tokenExpirationDays = [Math ]::Round($tokenExpirationDays , 2 ) # We don't need all the precision the system provides
379
+
380
+ if ($cachedExpirationForParsing -eq [System.DateTime ]::MaxValue.ToLongDateString().Trim()) {
381
+ Write-Verbose " The cached token contained content. It is set to never expire"
382
+ return $true
383
+ }
384
+
385
+ if ($tokenExpirationDays -gt 0 ) {
386
+ Write-Verbose " The cached token contained content. It should expire in $tokenExpirationDays days"
387
+ return $true
388
+ }
389
+ # If the parsing failed, the expiration should still be at the minimum value
390
+ elseif ($cachedExpirationDate -eq [System.DateTime ]::MinValue) {
391
+ Write-Verbose ' The cached token contained content, but it could not be parsed as a date. It will be re-validated'
392
+ Invoke-FileCleanup - FilePaths $cachedToken.FullName
393
+ # Do not return anything, since the token will need to be re-validated
394
+ }
395
+ else {
396
+ Write-Verbose " The cached token contained content, but the token expired $ ( [Math ]::Abs($tokenExpirationDays )) days ago"
397
+ # Leave the cached token so that it doesn't throw script exceptions in the future
398
+ # Invoke-FileCleanup -FilePaths $cachedToken.FullName
399
+ return $false
400
+ }
401
+ }
402
+ else {
403
+ # Either the token was empty, or the cached token is expired. Remove the cached token so that re-validation
404
+ # of the token will update the date the token was cached if it is still valid
405
+ Invoke-FileCleanup - FilePaths $cachedToken.FullName
406
+ }
407
+ }
408
+ else {
409
+ Write-Verbose ' Token was not found in the cache'
410
+ }
411
+
412
+ # To get here either the token was not in the cache or it needs to be re-validated
413
+
414
+ $requestParameters = @ {
415
+ Uri = ' https://api.github.com/rate_limit'
416
+ Authentication = ' Bearer'
417
+ Token = $ (ConvertTo-SecureString " $Token " - AsPlainText)
418
+ }
419
+
420
+ Write-Verbose " Checking Token against $ ( $requestParameters.Uri ) "
421
+ $apiResponse = Invoke-WebRequest @requestParameters # This will return an exception if the token is not valid; It is intentionally not caught
422
+ # The headers can sometimes be a single string, or an array of strings. Cast them into an array anyways just for safety
423
+ $rateLimit = @ ($apiResponse.Headers [' X-RateLimit-Limit' ])
424
+ $tokenExpiration = @ ($apiResponse.Headers [' github-authentication-token-expiration' ]) # This could be null if the token is set to never expire.
425
+ Write-Debug " API responded with Rate Limit ($rateLimit ) and Expiration ($tokenExpiration )"
426
+
427
+ if (! $rateLimit ) { return $false } # Something went horribly wrong, and the rate limit isn't known. Assume the token is not valid
428
+ if ([int ]$rateLimit [0 ] -le 60 ) {
429
+ # Authenticated users typically have a limit that is much higher than 60
430
+ return $false
431
+ }
432
+
433
+ Write-Verbose ' Token validated successfully. Adding to cache'
434
+ # Trim off any non-digit characters from the end
435
+ # Strip off the array wrapper since it is no longer needed
436
+ $tokenExpiration = $tokenExpiration [0 ] -replace ' [^0-9]+$' , ' '
437
+ # If the token doesn't expire, write a special value to the file
438
+ if (! $tokenExpiration -or [string ]::IsNullOrWhiteSpace($tokenExpiration )) {
439
+ Write-Debug " Token expiration was empty, setting it to maximum"
440
+ $tokenExpiration = [System.DateTime ]::MaxValue
441
+ }
442
+ # Try parsing the value to a datetime before storing it
443
+ if ([DateTime ]::TryParse($tokenExpiration , [ref ]$tokenExpiration )) {
444
+ Write-Debug " Token expiration successfully parsed as DateTime ($tokenExpiration )"
445
+ } else {
446
+ # TryParse Failed
447
+ Write-Warning " Could not parse expiration date as a DateTime object. It will be set to the minimum value"
448
+ $tokenExpiration = [System.DateTime ]::MinValue
449
+ }
450
+ # Explicitly convert to a string here to avoid implicit casting
451
+ $tokenExpiration = $tokenExpiration.ToString ()
452
+ # Write the value to the cache
453
+ New-Item - ItemType File - Path $script :TokenValidationCache - Name $tokenHash - Value $tokenExpiration | Out-Null
454
+ Write-Debug " Token <$tokenHash > added to cache with content <$tokenExpiration >"
455
+ return $true
456
+ }
457
+
277
458
# ### Start of main script ####
278
459
279
460
# Check if Windows Sandbox is enabled
@@ -292,7 +473,7 @@ $ Enable-WindowsOptionalFeature -Online -FeatureName 'Containers-DisposableClien
292
473
if (! $SkipManifestValidation -and ! [String ]::IsNullOrWhiteSpace($Manifest )) {
293
474
# Check that WinGet is Installed
294
475
if (! (Get-Command ' winget.exe' - ErrorAction SilentlyContinue)) {
295
- Write-Error - Category NotInstalled " WinGet is not installed. Manifest cannot be validated" - ErrorAction Continue
476
+ Write-Error - Category NotInstalled ' WinGet is not installed. Manifest cannot be validated' - ErrorAction Continue
296
477
Invoke-CleanExit - ExitCode 3
297
478
}
298
479
Write-Information " --> Validating Manifest"
@@ -316,7 +497,7 @@ if (!$SkipManifestValidation -and ![String]::IsNullOrWhiteSpace($Manifest)) {
316
497
}
317
498
' -1978335192' {
318
499
($validateCommandOutput | Select-Object - Skip 1 - SkipLast 1 ) | Write-Information # Skip the first line and the empty last line
319
- Write-Warning " Manifest validation succeeded with warnings"
500
+ Write-Warning ' Manifest validation succeeded with warnings'
320
501
Start-Sleep - Seconds 5 # Allow the user 5 seconds to read the warnings before moving on
321
502
}
322
503
Default {
@@ -327,7 +508,7 @@ if (!$SkipManifestValidation -and ![String]::IsNullOrWhiteSpace($Manifest)) {
327
508
328
509
# Get the details for the version of WinGet that was requested
329
510
Write-Verbose " Fetching release details from $script :ReleasesApiUrl ; Filters: {Prerelease=$script :Prerelease ; Version~=$script :WinGetVersion }"
330
- $script :WinGetReleaseDetails = Get-Release
511
+ $script :WinGetReleaseDetails = Get-Release - GitHubToken $ script :GitHubToken
331
512
if (! $script :WinGetReleaseDetails ) {
332
513
Write-Error - Category ObjectNotFound ' No WinGet releases found matching criteria' - ErrorAction Continue
333
514
Invoke-CleanExit - ExitCode 1
@@ -337,7 +518,7 @@ if (!$script:WinGetReleaseDetails.assets) {
337
518
Invoke-CleanExit - ExitCode 1
338
519
}
339
520
340
- Write-Verbose " Parsing Release Information"
521
+ Write-Verbose ' Parsing Release Information'
341
522
# Parse the needed URLs out of the release. It is entirely possible that these could end up being $null
342
523
$script :AppInstallerMsixShaDownloadUrl = $script :WinGetReleaseDetails.assets.Where ({ $_.name -eq " $script :AppInstallerPFN .txt" }).browser_download_url
343
524
$script :AppInstallerMsixDownloadUrl = $script :WinGetReleaseDetails.assets.Where ({ $_.name -eq $script :AppInstallerMsixFileName }).browser_download_url
@@ -357,7 +538,7 @@ $script:AppInstallerParsedVersion = [System.Version]($script:AppInstallerRelease
357
538
Write-Debug " Using Release version $script :AppinstallerReleaseTag ($script :AppInstallerParsedVersion )"
358
539
359
540
# Get the hashes for the files that change with each release version
360
- Write-Verbose " Fetching file hash information"
541
+ Write-Verbose ' Fetching file hash information'
361
542
$script :AppInstallerMsixHash = Get-RemoteContent - URL $script :AppInstallerMsixShaDownloadUrl - Raw
362
543
$script :DependenciesZipHash = Get-RemoteContent - URL $script :DependenciesShaDownloadUrl - Raw
363
544
Write-Debug @"
@@ -370,7 +551,7 @@ Write-Debug @"
370
551
$script :AppInstallerReleaseAssetsFolder = Join-Path $script :AppInstallerDataFolder - ChildPath ' bin' - AdditionalChildPath $script :AppInstallerReleaseTag
371
552
372
553
# Build the dependency information
373
- Write-Verbose " Building Dependency List"
554
+ Write-Verbose ' Building Dependency List'
374
555
$script :AppInstallerDependencies = @ ()
375
556
if ($script :AppInstallerParsedVersion -ge [System.Version ]' 1.9.25180' ) {
376
557
# As of WinGet 1.9.25180, VCLibs no longer publishes to the public URL and must be downloaded from the WinGet release
@@ -386,7 +567,7 @@ if ($script:AppInstallerParsedVersion -ge [System.Version]'1.9.25180') {
386
567
else {
387
568
$script :DependencySource = [DependencySources ]::Legacy
388
569
# Add the VCLibs to the dependencies
389
- Write-Debug " Adding VCLibs UWP to dependency list"
570
+ Write-Debug ' Adding VCLibs UWP to dependency list'
390
571
$script :AppInstallerDependencies += @ {
391
572
DownloadUrl = $script :VcLibsDownloadUrl
392
573
Checksum = $script :VcLibsHash
@@ -395,7 +576,7 @@ else {
395
576
}
396
577
if ($script :UseNuGetForMicrosoftUIXaml ) {
397
578
# Add the NuGet file to the dependencies
398
- Write-Debug " Adding Microsoft.UI.Xaml (NuGet) to dependency list"
579
+ Write-Debug ' Adding Microsoft.UI.Xaml (NuGet) to dependency list'
399
580
$script :AppInstallerDependencies += @ {
400
581
DownloadUrl = $script :UiLibsDownloadUrl_NuGet
401
582
Checksum = $script :UiLibsHash_NuGet
@@ -406,7 +587,7 @@ else {
406
587
# 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
407
588
elseif ($script :AppInstallerParsedVersion -lt [System.Version ]' 1.7.10514' ) {
408
589
# Add Xaml 2.7 to the dependencies
409
- Write-Debug " Adding Microsoft.UI.Xaml (v2.7) to dependency list"
590
+ Write-Debug ' Adding Microsoft.UI.Xaml (v2.7) to dependency list'
410
591
$script :AppInstallerDependencies += @ {
411
592
DownloadUrl = $script :UiLibsDownloadUrl_v2_7
412
593
Checksum = $script :UiLibsHash_v2_7
@@ -416,7 +597,7 @@ else {
416
597
}
417
598
else {
418
599
# Add Xaml 2.8 to the dependencies
419
- Write-Debug " Adding Microsoft.UI.Xaml (v2.8) to dependency list"
600
+ Write-Debug ' Adding Microsoft.UI.Xaml (v2.8) to dependency list'
420
601
$script :AppInstallerDependencies += @ {
421
602
DownloadUrl = $script :UiLibsDownloadUrl_v2_8
422
603
Checksum = $script :UiLibsHash_v2_8
@@ -444,7 +625,7 @@ if ($script:UsePowerShellModuleForInstall) {
444
625
}
445
626
446
627
# Process the dependency list
447
- Write-Information " --> Checking Dependencies"
628
+ Write-Information ' --> Checking Dependencies'
448
629
foreach ($dependency in $script :AppInstallerDependencies ) {
449
630
# On a clean install, remove the existing files
450
631
if ($Clean ) { Invoke-FileCleanup - FilePaths $dependency.SaveTo }
@@ -476,7 +657,7 @@ Stop-NamedProcess -ProcessName 'WindowsSandboxRemoteSession'
476
657
Start-Sleep - Milliseconds 5000 # Wait for the lock on the file to be released
477
658
478
659
# Remove the test data folder if it exists. We will rebuild it with new test data
479
- Write-Verbose " Cleaning up previous test data"
660
+ Write-Verbose ' Cleaning up previous test data'
480
661
Invoke-FileCleanup - FilePaths $script :TestDataFolder
481
662
482
663
# Create the paths if they don't exist
@@ -485,7 +666,7 @@ if (!(Initialize-Folder $script:DependenciesCacheFolder)) { throw 'Could not cre
485
666
486
667
# Set Experimental Features to be Enabled, If requested
487
668
if ($EnableExperimentalFeatures ) {
488
- Write-Debug " Setting Experimental Features to Enabled"
669
+ Write-Debug ' Setting Experimental Features to Enabled'
489
670
$experimentalFeatures = @ ($script :SandboxWinGetSettings.experimentalFeatures.Keys )
490
671
foreach ($feature in $experimentalFeatures ) {
491
672
$script :SandboxWinGetSettings.experimentalFeatures [$feature ] = $true
@@ -505,7 +686,7 @@ if (-Not [String]::IsNullOrWhiteSpace($Script)) {
505
686
}
506
687
507
688
# Create the bootstrapping script
508
- Write-Verbose " Creating the script for bootstrapping the sandbox"
689
+ Write-Verbose ' Creating the script for bootstrapping the sandbox'
509
690
@"
510
691
function Update-EnvironmentVariables {
511
692
foreach(`$ level in "Machine","User") {
@@ -613,7 +794,7 @@ Pop-Location
613
794
614
795
# Create the WSB file
615
796
# Although this could be done using the native XML processor, it's easier to just write the content directly as a string
616
- Write-Verbose " Creating WSB file for launching the sandbox"
797
+ Write-Verbose ' Creating WSB file for launching the sandbox'
617
798
@"
618
799
<Configuration>
619
800
<Networking>Enable</Networking>
0 commit comments