From 08e173e6c14a036dfb1ed91c5e466dfc42f449ac Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 8 Aug 2026 20:53:05 +0200 Subject: [PATCH 01/16] Harden font archive handling Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../private/Get-NerdFontCacheRoot.ps1 | 23 ++ .../private/Invoke-NerdFontDownload.ps1 | 125 +++++++++ src/functions/public/Install-NerdFont.ps1 | 237 ++++++++++-------- 3 files changed, 285 insertions(+), 100 deletions(-) create mode 100644 src/functions/private/Get-NerdFontCacheRoot.ps1 create mode 100644 src/functions/private/Invoke-NerdFontDownload.ps1 diff --git a/src/functions/private/Get-NerdFontCacheRoot.ps1 b/src/functions/private/Get-NerdFontCacheRoot.ps1 new file mode 100644 index 0000000..dbd0c72 --- /dev/null +++ b/src/functions/private/Get-NerdFontCacheRoot.ps1 @@ -0,0 +1,23 @@ +function Get-NerdFontCacheRoot { + <# + .SYNOPSIS + Gets the platform-standard archive cache location. + #> + [OutputType([string])] + [CmdletBinding()] + param() + + if ($IsWindows) { + return Join-Path -Path ([Environment]::GetFolderPath('LocalApplicationData')) -ChildPath 'PSModule/NerdFonts/cache' + } + + if ($IsMacOS) { + return Join-Path -Path $HOME -ChildPath 'Library/Caches/PSModule/NerdFonts' + } + + if (-not [string]::IsNullOrWhiteSpace($env:XDG_CACHE_HOME)) { + return Join-Path -Path $env:XDG_CACHE_HOME -ChildPath 'PSModule/NerdFonts' + } + + return Join-Path -Path $HOME -ChildPath '.cache/PSModule/NerdFonts' +} diff --git a/src/functions/private/Invoke-NerdFontDownload.ps1 b/src/functions/private/Invoke-NerdFontDownload.ps1 new file mode 100644 index 0000000..71bf232 --- /dev/null +++ b/src/functions/private/Invoke-NerdFontDownload.ps1 @@ -0,0 +1,125 @@ +if ($null -eq ('NerdFonts.ArchiveDownloader' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; + +namespace NerdFonts +{ + public static class ArchiveDownloader + { + public static async Task DownloadAsync( + HttpClient client, + Uri uri, + string destinationPath, + int maximumRetryCount, + TimeSpan retryInterval) + { + string temporaryPath = destinationPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + + try + { + for (int attempt = 0; ; attempt++) + { + try + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + + using (HttpResponseMessage response = await client.GetAsync( + uri, + HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false)) + { + if (!response.IsSuccessStatusCode) + { + if (IsTransient(response.StatusCode) && attempt < maximumRetryCount) + { + await Task.Delay(retryInterval).ConfigureAwait(false); + continue; + } + + response.EnsureSuccessStatusCode(); + } + + using (Stream source = await response.Content.ReadAsStreamAsync().ConfigureAwait(false)) + using (FileStream destination = new FileStream( + temporaryPath, + FileMode.Create, + FileAccess.Write, + FileShare.None, + 81920, + FileOptions.Asynchronous)) + { + await source.CopyToAsync(destination, 81920).ConfigureAwait(false); + await destination.FlushAsync().ConfigureAwait(false); + } + } + + File.Move(temporaryPath, destinationPath, true); + return; + } + catch (Exception exception) when ( + IsTransient(exception) && + attempt < maximumRetryCount) + { + await Task.Delay(retryInterval).ConfigureAwait(false); + } + } + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + + private static bool IsTransient(HttpStatusCode statusCode) + { + return statusCode == HttpStatusCode.RequestTimeout || + statusCode == (HttpStatusCode)429 || + (int)statusCode >= 500; + } + + private static bool IsTransient(Exception exception) + { + return exception is HttpRequestException || + exception is IOException || + exception is TaskCanceledException; + } + } +} +'@ -ErrorAction Stop +} + +function Invoke-NerdFontDownload { + <# + .SYNOPSIS + Downloads a font archive with bounded-memory retries. + #> + [OutputType([System.Threading.Tasks.Task])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Net.Http.HttpClient] $HttpClient, + + [Parameter(Mandatory)] + [uri] $Uri, + + [Parameter(Mandatory)] + [string] $DestinationPath + ) + + return [NerdFonts.ArchiveDownloader]::DownloadAsync( + $HttpClient, + $Uri, + $DestinationPath, + 5, + [TimeSpan]::FromSeconds(5) + ) +} diff --git a/src/functions/public/Install-NerdFont.ps1 b/src/functions/public/Install-NerdFont.ps1 index 53ed30a..0e72d66 100644 --- a/src/functions/public/Install-NerdFont.ps1 +++ b/src/functions/public/Install-NerdFont.ps1 @@ -96,10 +96,6 @@ Please run the command again with elevated rights (Run as Administrator) or prov $guid = (New-Guid).Guid $tempPath = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "NerdFonts-$guid" - if (-not (Test-Path -Path $tempPath -PathType Container)) { - Write-Verbose "Create folder [$tempPath]" - $null = New-Item -Path $tempPath -ItemType Directory - } } process { @@ -121,11 +117,7 @@ Please run the command again with elevated rights (Run as Administrator) or prov end { Write-Verbose "[$Scope] - Installing [$($nerdFontsToInstall.Count)] fonts" - $cacheRoot = if ($IsWindows) { - Join-Path -Path ([Environment]::GetFolderPath('LocalApplicationData')) -ChildPath 'PSModule/NerdFonts/cache' - } else { - Join-Path -Path $HOME -ChildPath '.cache/PSModule/NerdFonts' - } + $cacheRoot = Get-NerdFontCacheRoot $installedFamilies = $null if (-not $Force) { @@ -142,10 +134,35 @@ Please run the command again with elevated rights (Run as Administrator) or prov if (-not $Force -and $installedFamilies) { $alreadyInstalled = $false foreach ($family in $installedFamilies) { - if ($family -like "$fontName Nerd Font*") { $alreadyInstalled = $true; break } + $normalizedFamily = $family -replace '[\s_-]', '' + $normalizedFontName = $fontName -replace '[\s_-]', '' + if ($normalizedFamily -notlike "${normalizedFontName}NerdFont*") { + continue + } + + $matchesVariant = switch ($Variant) { + 'All' { + $true + } + 'Mono' { + $normalizedFamily -like '*NerdFontMono*' + } + 'Propo' { + $normalizedFamily -like '*NerdFontPropo*' + } + 'Standard' { + $normalizedFamily -notlike '*NerdFontMono*' -and + $normalizedFamily -notlike '*NerdFontPropo*' + } + } + + if ($matchesVariant) { + $alreadyInstalled = $true + break + } } if ($alreadyInstalled) { - Write-Verbose "[$fontName] - already installed, skipping" + Write-Verbose "[$fontName] - requested variant [$Variant] already installed, skipping" continue } } @@ -156,7 +173,7 @@ Please run the command again with elevated rights (Run as Administrator) or prov $httpClient = [System.Net.Http.HttpClient]::new() # Keep request lifetime unbounded for large archives on slower links. $httpClient.Timeout = [System.Threading.Timeout]::InfiniteTimeSpan - $pending = [System.Collections.Generic.List[object]]::new() + $pendingDownloads = [System.Collections.Generic.List[object]]::new() $readyToInstall = [System.Collections.Generic.List[object]]::new() $downloadErrors = [System.Collections.Generic.List[string]]::new() $throttle = 8 @@ -178,38 +195,42 @@ Please run the command again with elevated rights (Run as Administrator) or prov if ((Test-Path -LiteralPath $cachedFile) -and -not $Force) { Write-Verbose "[$fontName] - Cache hit at [$cachedFile]" - $cacheHitSuccess = $false - try { - Copy-Item -LiteralPath $cachedFile -Destination $downloadPath -Force -ErrorAction Stop - $cacheHitSuccess = $true - } catch { - Write-Warning "[$fontName] - Cache read failed, falling back to download: $($_.Exception.Message)" - } - if ($cacheHitSuccess) { - $item = [pscustomobject]@{ - Name = $fontName - URL = $URL - DownloadPath = $downloadPath - CachedFile = $cachedFile - CacheTagDir = $cacheTagDir - FromCache = $true + $cacheCopyTarget = "[$fontName] cache archive to [$downloadPath]" + if ($PSCmdlet.ShouldProcess($cacheCopyTarget, 'Copy cached archive')) { + if (-not (Test-Path -LiteralPath $tempPath)) { + Write-Verbose "Create folder [$tempPath]" + $null = New-Item -Path $tempPath -ItemType Directory -ErrorAction Stop } - $pending.Add($item) - $readyToInstall.Add($item) - } else { - $item = [pscustomobject]@{ - Name = $fontName - URL = $URL - DownloadPath = $downloadPath - CachedFile = $cachedFile - CacheTagDir = $cacheTagDir - FromCache = $false + + try { + Copy-Item -LiteralPath $cachedFile -Destination $downloadPath -Force -ErrorAction Stop + $cachedDownload = [pscustomobject]@{ + Name = $fontName + URL = $URL + DownloadPath = $downloadPath + CachedFile = $cachedFile + CacheTagDir = $cacheTagDir + FromCache = $true + } + $readyToInstall.Add($cachedDownload) + continue + } catch { + Write-Warning "[$fontName] - Cache read failed, falling back to download: $($_.Exception.Message)" } - $pending.Add($item) + } else { + continue } - } else { + + } + + if ($PSCmdlet.ShouldProcess("[$fontName] from [$URL]", 'Download archive')) { + if (-not (Test-Path -LiteralPath $tempPath)) { + Write-Verbose "Create folder [$tempPath]" + $null = New-Item -Path $tempPath -ItemType Directory -ErrorAction Stop + } + Write-Verbose "[$fontName] - Queue download to [$downloadPath]" - $item = [pscustomobject]@{ + $queuedDownload = [pscustomobject]@{ Name = $fontName URL = $URL DownloadPath = $downloadPath @@ -217,25 +238,32 @@ Please run the command again with elevated rights (Run as Administrator) or prov CacheTagDir = $cacheTagDir FromCache = $false } - $pending.Add($item) + $pendingDownloads.Add($queuedDownload) } } - $toDownload = @($pending | Where-Object { -not $_.FromCache }) + $toDownload = @($pendingDownloads) for ($i = 0; $i -lt $toDownload.Count; $i += $throttle) { $end = [Math]::Min($i + $throttle - 1, $toDownload.Count - 1) $chunk = $toDownload[$i..$end] - $tasks = @() - foreach ($q in $chunk) { - $tasks += [pscustomobject]@{ Q = $q; Task = $httpClient.GetByteArrayAsync($q.URL) } + $tasks = foreach ($queuedDownload in $chunk) { + $downloadParams = @{ + HttpClient = $httpClient + Uri = $queuedDownload.URL + DestinationPath = $queuedDownload.DownloadPath + } + [pscustomobject]@{ + QueuedDownload = $queuedDownload + Task = Invoke-NerdFontDownload @downloadParams + } } - foreach ($t in $tasks) { + + foreach ($task in $tasks) { try { - $bytes = $t.Task.GetAwaiter().GetResult() - [System.IO.File]::WriteAllBytes($t.Q.DownloadPath, $bytes) - $readyToInstall.Add($t.Q) + $task.Task.GetAwaiter().GetResult() + $readyToInstall.Add($task.QueuedDownload) } catch { - $downloadErrors.Add("[$($t.Q.Name)] - Download failed: $($_.Exception.Message)") + $downloadErrors.Add("[$($task.QueuedDownload.Name)] - Download failed: $($_.Exception.Message)") } } } @@ -249,10 +277,17 @@ Please run the command again with elevated rights (Run as Administrator) or prov $extractPath = Join-Path -Path $tempPath -ChildPath $fontName Write-Verbose "[$fontName] - Extract to [$extractPath]" if ($PSCmdlet.ShouldProcess("[$fontName] to [$extractPath]", 'Extract')) { - if (-not (Test-Path -LiteralPath $extractPath)) { - $null = New-Item -ItemType Directory -Path $extractPath + try { + if (-not (Test-Path -LiteralPath $extractPath)) { + $null = New-Item -ItemType Directory -Path $extractPath -ErrorAction Stop + } + [System.IO.Compression.ZipFile]::ExtractToDirectory($downloadPath, $extractPath, $true) + } catch { + Remove-Item -LiteralPath $extractPath -Force -Recurse -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $downloadPath -Force -ErrorAction SilentlyContinue + Write-Error "[$fontName] - Extract failed: $($_.Exception.Message)" + continue } - [System.IO.Compression.ZipFile]::ExtractToDirectory($downloadPath, $extractPath, $true) if (-not $p.FromCache -and (Test-Path -LiteralPath $downloadPath)) { $tempCachePath = $null @@ -272,62 +307,64 @@ Please run the command again with elevated rights (Run as Administrator) or prov } Remove-Item -LiteralPath $downloadPath -Force -ErrorAction SilentlyContinue - } - if ($Variant -ne 'All') { - $allFiles = Get-ChildItem -Path $extractPath -Recurse -File -Include '*.ttf', '*.otf' - $keep = switch ($Variant) { - 'Mono' { - $allFiles | Where-Object { $_.Name -like '*NerdFontMono*' } - } - 'Propo' { - $allFiles | Where-Object { $_.Name -like '*NerdFontPropo*' } + if ($Variant -ne 'All') { + $allFiles = Get-ChildItem -Path $extractPath -Recurse -File -Include '*.ttf', '*.otf' + $keep = switch ($Variant) { + 'Mono' { + $allFiles | Where-Object { $_.Name -like '*NerdFontMono*' } + } + 'Propo' { + $allFiles | Where-Object { $_.Name -like '*NerdFontPropo*' } + } + 'Standard' { + $allFiles | Where-Object { + $_.Name -like '*NerdFont*' -and + $_.Name -notlike '*NerdFontMono*' -and + $_.Name -notlike '*NerdFontPropo*' + } + } } - 'Standard' { - $allFiles | Where-Object { - $_.Name -like '*NerdFont*' -and - $_.Name -notlike '*NerdFontMono*' -and - $_.Name -notlike '*NerdFontPropo*' + $keepNames = [string[]]@($keep.FullName) + $keepSet = [System.Collections.Generic.HashSet[string]]::new( + $keepNames, + [System.StringComparer]::OrdinalIgnoreCase + ) + $removed = 0 + foreach ($file in $allFiles) { + if (-not $keepSet.Contains($file.FullName)) { + Remove-Item -LiteralPath $file.FullName -Force -ErrorAction SilentlyContinue + $removed++ } } + Write-Verbose "[$fontName] - Variant '$Variant': kept $($keep.Count), removed $removed" } - $keepNames = [string[]]@($keep.FullName) - $keepSet = [System.Collections.Generic.HashSet[string]]::new( - $keepNames, - [System.StringComparer]::OrdinalIgnoreCase + + # Nerd Fonts archives sometimes contain duplicate matching files in + # compatibility subfolders. Keep a single file per filename. + $remaining = @(Get-ChildItem -Path $extractPath -Recurse -File -Include '*.ttf', '*.otf') + $preferred = $remaining | Sort-Object -Property @( + @{ Expression = { if ($_.FullName -match '(?i)[\\/]Windows Compatible[\\/]') { 1 } else { 0 } } } + @{ Expression = { $_.FullName.Length } } ) - $removed = 0 - foreach ($f in $allFiles) { - if (-not $keepSet.Contains($f.FullName)) { - Remove-Item -LiteralPath $f.FullName -Force -ErrorAction SilentlyContinue - $removed++ + $seenFileNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $duplicateRemoved = 0 + foreach ($file in $preferred) { + if ($seenFileNames.Add($file.Name)) { + continue } + Remove-Item -LiteralPath $file.FullName -Force -ErrorAction SilentlyContinue + $duplicateRemoved++ + } + if ($duplicateRemoved -gt 0) { + Write-Verbose "[$fontName] - Deduplicated $duplicateRemoved file(s)" } - Write-Verbose "[$fontName] - Variant '$Variant': kept $($keep.Count), removed $removed" - } - - # Nerd Fonts archives sometimes contain duplicate matching files in - # compatibility subfolders. Keep a single file per filename. - $remaining = @(Get-ChildItem -Path $extractPath -Recurse -File -Include '*.ttf', '*.otf') - $preferred = $remaining | Sort-Object -Property @( - @{ Expression = { if ($_.FullName -match '(?i)[\\/]Windows Compatible[\\/]') { 1 } else { 0 } } } - @{ Expression = { $_.FullName.Length } } - ) - $seenFileNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - $duplicateRemoved = 0 - foreach ($file in $preferred) { - if ($seenFileNames.Add($file.Name)) { continue } - Remove-Item -LiteralPath $file.FullName -Force -ErrorAction SilentlyContinue - $duplicateRemoved++ - } - if ($duplicateRemoved -gt 0) { - Write-Verbose "[$fontName] - Deduplicated $duplicateRemoved file(s)" - } - Write-Verbose "[$fontName] - Install to [$Scope]" - if ($PSCmdlet.ShouldProcess("[$fontName] to [$Scope]", 'Install font')) { - Install-Font -Path $extractPath -Scope $Scope -Force:$Force - Remove-Item -LiteralPath $extractPath -Force -Recurse -ErrorAction SilentlyContinue + Write-Verbose "[$fontName] - Install to [$Scope]" + if ($PSCmdlet.ShouldProcess("[$fontName] to [$Scope]", 'Install font')) { + Install-Font -Path $extractPath -Scope $Scope -Force:$Force + Remove-Item -LiteralPath $extractPath -Force -Recurse -ErrorAction SilentlyContinue + } } } From 9d14ebaec70b67cfeac04b457b8c0f0aec7f5438 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 8 Aug 2026 20:58:30 +0200 Subject: [PATCH 02/16] Use native streaming download jobs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../private/Invoke-NerdFontDownload.ps1 | 125 ------------------ .../private/Start-NerdFontDownload.ps1 | 38 ++++++ src/functions/public/Install-NerdFont.ps1 | 18 +-- 3 files changed, 48 insertions(+), 133 deletions(-) delete mode 100644 src/functions/private/Invoke-NerdFontDownload.ps1 create mode 100644 src/functions/private/Start-NerdFontDownload.ps1 diff --git a/src/functions/private/Invoke-NerdFontDownload.ps1 b/src/functions/private/Invoke-NerdFontDownload.ps1 deleted file mode 100644 index 71bf232..0000000 --- a/src/functions/private/Invoke-NerdFontDownload.ps1 +++ /dev/null @@ -1,125 +0,0 @@ -if ($null -eq ('NerdFonts.ArchiveDownloader' -as [type])) { - Add-Type -TypeDefinition @' -using System; -using System.IO; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; - -namespace NerdFonts -{ - public static class ArchiveDownloader - { - public static async Task DownloadAsync( - HttpClient client, - Uri uri, - string destinationPath, - int maximumRetryCount, - TimeSpan retryInterval) - { - string temporaryPath = destinationPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; - - try - { - for (int attempt = 0; ; attempt++) - { - try - { - if (File.Exists(temporaryPath)) - { - File.Delete(temporaryPath); - } - - using (HttpResponseMessage response = await client.GetAsync( - uri, - HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false)) - { - if (!response.IsSuccessStatusCode) - { - if (IsTransient(response.StatusCode) && attempt < maximumRetryCount) - { - await Task.Delay(retryInterval).ConfigureAwait(false); - continue; - } - - response.EnsureSuccessStatusCode(); - } - - using (Stream source = await response.Content.ReadAsStreamAsync().ConfigureAwait(false)) - using (FileStream destination = new FileStream( - temporaryPath, - FileMode.Create, - FileAccess.Write, - FileShare.None, - 81920, - FileOptions.Asynchronous)) - { - await source.CopyToAsync(destination, 81920).ConfigureAwait(false); - await destination.FlushAsync().ConfigureAwait(false); - } - } - - File.Move(temporaryPath, destinationPath, true); - return; - } - catch (Exception exception) when ( - IsTransient(exception) && - attempt < maximumRetryCount) - { - await Task.Delay(retryInterval).ConfigureAwait(false); - } - } - } - finally - { - if (File.Exists(temporaryPath)) - { - File.Delete(temporaryPath); - } - } - } - - private static bool IsTransient(HttpStatusCode statusCode) - { - return statusCode == HttpStatusCode.RequestTimeout || - statusCode == (HttpStatusCode)429 || - (int)statusCode >= 500; - } - - private static bool IsTransient(Exception exception) - { - return exception is HttpRequestException || - exception is IOException || - exception is TaskCanceledException; - } - } -} -'@ -ErrorAction Stop -} - -function Invoke-NerdFontDownload { - <# - .SYNOPSIS - Downloads a font archive with bounded-memory retries. - #> - [OutputType([System.Threading.Tasks.Task])] - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [System.Net.Http.HttpClient] $HttpClient, - - [Parameter(Mandatory)] - [uri] $Uri, - - [Parameter(Mandatory)] - [string] $DestinationPath - ) - - return [NerdFonts.ArchiveDownloader]::DownloadAsync( - $HttpClient, - $Uri, - $DestinationPath, - 5, - [TimeSpan]::FromSeconds(5) - ) -} diff --git a/src/functions/private/Start-NerdFontDownload.ps1 b/src/functions/private/Start-NerdFontDownload.ps1 new file mode 100644 index 0000000..49af215 --- /dev/null +++ b/src/functions/private/Start-NerdFontDownload.ps1 @@ -0,0 +1,38 @@ +function Start-NerdFontDownload { + <# + .SYNOPSIS + Starts a streaming archive download with retries. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Install-NerdFont confirms the download operation before starting a job.' + )] + [OutputType([System.Management.Automation.Job])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [uri] $Uri, + + [Parameter(Mandatory)] + [string] $DestinationPath + ) + + $downloadScript = { + param( + [uri] $DownloadUri, + + [string] $DownloadPath + ) + + $downloadParams = @{ + Uri = $DownloadUri + OutFile = $DownloadPath + MaximumRetryCount = 5 + RetryIntervalSec = 5 + ErrorAction = 'Stop' + } + Invoke-WebRequest @downloadParams + } + + return Start-ThreadJob -ScriptBlock $downloadScript -ArgumentList $Uri, $DestinationPath +} diff --git a/src/functions/public/Install-NerdFont.ps1 b/src/functions/public/Install-NerdFont.ps1 index 0e72d66..d2c395e 100644 --- a/src/functions/public/Install-NerdFont.ps1 +++ b/src/functions/public/Install-NerdFont.ps1 @@ -169,13 +169,10 @@ Please run the command again with elevated rights (Run as Administrator) or prov $toProcess.Add($nerdFont) } - Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue - $httpClient = [System.Net.Http.HttpClient]::new() - # Keep request lifetime unbounded for large archives on slower links. - $httpClient.Timeout = [System.Threading.Timeout]::InfiniteTimeSpan $pendingDownloads = [System.Collections.Generic.List[object]]::new() $readyToInstall = [System.Collections.Generic.List[object]]::new() $downloadErrors = [System.Collections.Generic.List[string]]::new() + $activeDownloadJobs = [System.Collections.Generic.List[object]]::new() $throttle = 8 try { @@ -248,27 +245,32 @@ Please run the command again with elevated rights (Run as Administrator) or prov $chunk = $toDownload[$i..$end] $tasks = foreach ($queuedDownload in $chunk) { $downloadParams = @{ - HttpClient = $httpClient Uri = $queuedDownload.URL DestinationPath = $queuedDownload.DownloadPath } + $downloadJob = Start-NerdFontDownload @downloadParams + $activeDownloadJobs.Add($downloadJob) [pscustomobject]@{ QueuedDownload = $queuedDownload - Task = Invoke-NerdFontDownload @downloadParams + Job = $downloadJob } } foreach ($task in $tasks) { try { - $task.Task.GetAwaiter().GetResult() + Receive-Job -Job $task.Job -Wait -AutoRemoveJob -ErrorAction Stop | Out-Null $readyToInstall.Add($task.QueuedDownload) } catch { $downloadErrors.Add("[$($task.QueuedDownload.Name)] - Download failed: $($_.Exception.Message)") + } finally { + $null = $activeDownloadJobs.Remove($task.Job) } } } } finally { - $httpClient.Dispose() + foreach ($downloadJob in $activeDownloadJobs) { + Remove-Job -Job $downloadJob -Force -ErrorAction SilentlyContinue + } } foreach ($p in $readyToInstall) { From c70a1785f501a86a611ad1d4c6d063593d2b30ce Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 8 Aug 2026 20:58:33 +0200 Subject: [PATCH 03/16] Cover font installation edge cases Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/NerdFonts.Tests.ps1 | 262 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 250 insertions(+), 12 deletions(-) diff --git a/tests/NerdFonts.Tests.ps1 b/tests/NerdFonts.Tests.ps1 index 515e3c7..8b6a4ec 100644 --- a/tests/NerdFonts.Tests.ps1 +++ b/tests/NerdFonts.Tests.ps1 @@ -16,10 +16,39 @@ 'PSAvoidLongLines', '', Justification = 'Long test descriptions and skip switches' )] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Pester mock parameters mirror the invoked command signature.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSProvideCommentHelp', '', + Justification = 'Test-only archive helper is not part of the module interface.' +)] [CmdletBinding()] param() Describe 'Module' { + BeforeAll { + function script:New-TestFontArchive { + param( + [Parameter(Mandatory)] + [string] $ArchivePath, + + [Parameter(Mandatory)] + [string[]] $FileNames + ) + + $archiveRoot = Join-Path -Path $TestDrive -ChildPath ([Guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $archiveRoot -Force + foreach ($fileName in $FileNames) { + Set-Content -Path (Join-Path -Path $archiveRoot -ChildPath $fileName) -Value 'test-font' + } + + Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue + [System.IO.Compression.ZipFile]::CreateFromDirectory($archiveRoot, $ArchivePath) + } + } + Context 'Function: Get-NerdFont' { It 'Returns all fonts' { $fonts = Get-NerdFont @@ -69,6 +98,54 @@ Describe 'Module' { } } + It 'Install-NerdFont - Continues when one downloaded archive cannot be extracted' { + $originalFonts = InModuleScope NerdFonts { $script:NerdFonts } + $validArchivePath = Join-Path -Path $TestDrive -ChildPath 'valid-archive.zip' + New-TestFontArchive -ArchivePath $validArchivePath -FileNames 'ValidArchiveTestNerdFont-Regular.ttf' + $testFonts = @( + [pscustomobject]@{ + Name = 'BrokenArchiveTest' + URL = 'https://example.invalid/BrokenArchiveTest.zip' + }, + [pscustomobject]@{ + Name = 'ValidArchiveTest' + URL = 'https://example.invalid/ValidArchiveTest.zip' + } + ) + + InModuleScope NerdFonts -Parameters @{ fonts = $testFonts } { + param($fonts) + $script:NerdFonts = $fonts + } + + try { + Mock -ModuleName NerdFonts Get-Font { @() } + Mock -ModuleName NerdFonts Get-NerdFontCacheRoot { + Join-Path -Path $TestDrive -ChildPath 'cache' + } + Mock -ModuleName NerdFonts Start-NerdFontDownload { + param($Uri, $DestinationPath) + if ($Uri.AbsoluteUri -like '*BrokenArchiveTest.zip') { + Set-Content -Path $DestinationPath -Value 'invalid archive' + } else { + Copy-Item -LiteralPath $validArchivePath -Destination $DestinationPath -Force + } + Start-ThreadJob -ScriptBlock {} + } + Mock -ModuleName NerdFonts Install-Font {} + + { Install-NerdFont -Name @('BrokenArchiveTest', 'ValidArchiveTest') -Force -ErrorAction SilentlyContinue } | + Should -Not -Throw + Should -Invoke -ModuleName NerdFonts Start-NerdFontDownload -Times 2 -Exactly + Should -Invoke -ModuleName NerdFonts Install-Font -Times 1 -Exactly + } finally { + InModuleScope NerdFonts -Parameters @{ fonts = $originalFonts } { + param($fonts) + $script:NerdFonts = $fonts + } + } + } + It 'Install-NerdFont - Skips already installed fonts without downloading' { $originalFonts = InModuleScope NerdFonts { $script:NerdFonts } $testFonts = @( @@ -84,11 +161,176 @@ Describe 'Module' { try { Mock -ModuleName NerdFonts Get-Font { - [pscustomobject]@{ Name = 'AlreadyInstalledTest Nerd Font' } + [pscustomobject]@{ Name = 'AlreadyInstalledTestNerdFont-Regular' } } + Mock -ModuleName NerdFonts Start-NerdFontDownload {} Mock -ModuleName NerdFonts Install-Font {} { Install-NerdFont -Name 'AlreadyInstalledTest' -ErrorAction Stop } | Should -Not -Throw + Should -Invoke -ModuleName NerdFonts Start-NerdFontDownload -Times 0 -Exactly + Should -Invoke -ModuleName NerdFonts Install-Font -Times 0 -Exactly + } finally { + InModuleScope NerdFonts -Parameters @{ fonts = $originalFonts } { + param($fonts) + $script:NerdFonts = $fonts + } + } + } + + It 'Install-NerdFont - Downloads with -Force when the family is installed' { + $originalFonts = InModuleScope NerdFonts { $script:NerdFonts } + $fontName = 'ForceDownloadTest' + $testFonts = @( + [pscustomobject]@{ + Name = $fontName + URL = 'https://example.invalid/force-download.zip' + } + ) + $script:TestArchivePath = Join-Path -Path $TestDrive -ChildPath 'force-download.zip' + New-TestFontArchive -ArchivePath $script:TestArchivePath -FileNames 'ForceDownloadTestNerdFont-Regular.ttf' + + InModuleScope NerdFonts -Parameters @{ fonts = $testFonts } { + param($fonts) + $script:NerdFonts = $fonts + } + + try { + Mock -ModuleName NerdFonts Get-Font { + [pscustomobject]@{ Name = 'ForceDownloadTestNerdFont-Regular' } + } + Mock -ModuleName NerdFonts Get-NerdFontCacheRoot { + Join-Path -Path $TestDrive -ChildPath 'cache' + } + Mock -ModuleName NerdFonts Start-NerdFontDownload { + param($Uri, $DestinationPath) + Copy-Item -LiteralPath $script:TestArchivePath -Destination $DestinationPath -Force + Start-ThreadJob -ScriptBlock {} + } + Mock -ModuleName NerdFonts Install-Font {} + + { Install-NerdFont -Name $fontName -Force -ErrorAction Stop } | Should -Not -Throw + Should -Invoke -ModuleName NerdFonts Start-NerdFontDownload -Times 1 -Exactly + Should -Invoke -ModuleName NerdFonts Install-Font -Times 1 -Exactly + } finally { + InModuleScope NerdFonts -Parameters @{ fonts = $originalFonts } { + param($fonts) + $script:NerdFonts = $fonts + } + } + } + + It 'Install-NerdFont - Downloads when the requested variant is missing' { + $originalFonts = InModuleScope NerdFonts { $script:NerdFonts } + $fontName = 'VariantSkipTest' + $testFonts = @( + [pscustomobject]@{ + Name = $fontName + URL = 'https://example.invalid/variant-skip.zip' + } + ) + $script:TestArchivePath = Join-Path -Path $TestDrive -ChildPath 'variant-skip.zip' + New-TestFontArchive -ArchivePath $script:TestArchivePath -FileNames 'VariantSkipTestNerdFontMono-Regular.ttf' + + InModuleScope NerdFonts -Parameters @{ fonts = $testFonts } { + param($fonts) + $script:NerdFonts = $fonts + } + + try { + Mock -ModuleName NerdFonts Get-Font { + [pscustomobject]@{ Name = 'VariantSkipTestNerdFont-Regular' } + } + Mock -ModuleName NerdFonts Get-NerdFontCacheRoot { + Join-Path -Path $TestDrive -ChildPath 'cache' + } + Mock -ModuleName NerdFonts Start-NerdFontDownload { + param($Uri, $DestinationPath) + Copy-Item -LiteralPath $script:TestArchivePath -Destination $DestinationPath -Force + Start-ThreadJob -ScriptBlock {} + } + Mock -ModuleName NerdFonts Install-Font {} + + { Install-NerdFont -Name $fontName -Variant Mono -ErrorAction Stop } | Should -Not -Throw + Should -Invoke -ModuleName NerdFonts Start-NerdFontDownload -Times 1 -Exactly + Should -Invoke -ModuleName NerdFonts Install-Font -Times 1 -Exactly + } finally { + InModuleScope NerdFonts -Parameters @{ fonts = $originalFonts } { + param($fonts) + $script:NerdFonts = $fonts + } + } + } + + It 'Install-NerdFont - Downloads each overlapping name match once' { + $originalFonts = InModuleScope NerdFonts { $script:NerdFonts } + $fontName = 'OverlappingNameTest' + $testFonts = @( + [pscustomobject]@{ + Name = $fontName + URL = 'https://example.invalid/overlapping-name.zip' + } + ) + $script:TestArchivePath = Join-Path -Path $TestDrive -ChildPath 'overlapping-name.zip' + New-TestFontArchive -ArchivePath $script:TestArchivePath -FileNames 'OverlappingNameTestNerdFont-Regular.ttf' + + InModuleScope NerdFonts -Parameters @{ fonts = $testFonts } { + param($fonts) + $script:NerdFonts = $fonts + } + + try { + Mock -ModuleName NerdFonts Get-Font { @() } + Mock -ModuleName NerdFonts Get-NerdFontCacheRoot { + Join-Path -Path $TestDrive -ChildPath 'cache' + } + Mock -ModuleName NerdFonts Start-NerdFontDownload { + param($Uri, $DestinationPath) + Copy-Item -LiteralPath $script:TestArchivePath -Destination $DestinationPath -Force + Start-ThreadJob -ScriptBlock {} + } + Mock -ModuleName NerdFonts Install-Font {} + + { Install-NerdFont -Name "$fontName*", $fontName -ErrorAction Stop } | Should -Not -Throw + Should -Invoke -ModuleName NerdFonts Start-NerdFontDownload -Times 1 -Exactly + Should -Invoke -ModuleName NerdFonts Install-Font -Times 1 -Exactly + } finally { + InModuleScope NerdFonts -Parameters @{ fonts = $originalFonts } { + param($fonts) + $script:NerdFonts = $fonts + } + } + } + + It 'Install-NerdFont - Does not copy or download archives with -WhatIf' { + $originalFonts = InModuleScope NerdFonts { $script:NerdFonts } + $fontName = 'WhatIfCacheTest' + $testFonts = @( + [pscustomobject]@{ + Name = $fontName + URL = 'https://github.com/ryanoasis/nerd-fonts/releases/download/test-whatif/WhatIfCacheTest.zip' + } + ) + $cacheRoot = Join-Path -Path $TestDrive -ChildPath 'cache' + $cacheTagDir = Join-Path -Path $cacheRoot -ChildPath 'test-whatif' + $cachedFile = Join-Path -Path $cacheTagDir -ChildPath 'WhatIfCacheTest.zip' + $null = New-Item -ItemType Directory -Path $cacheTagDir -Force + Set-Content -Path $cachedFile -Value 'cached archive' + + InModuleScope NerdFonts -Parameters @{ fonts = $testFonts } { + param($fonts) + $script:NerdFonts = $fonts + } + + try { + Mock -ModuleName NerdFonts Get-Font { @() } + Mock -ModuleName NerdFonts Get-NerdFontCacheRoot { $cacheRoot } + Mock -ModuleName NerdFonts Copy-Item {} + Mock -ModuleName NerdFonts Start-NerdFontDownload {} + Mock -ModuleName NerdFonts Install-Font {} + + { Install-NerdFont -Name $fontName -WhatIf -ErrorAction Stop } | Should -Not -Throw + Should -Invoke -ModuleName NerdFonts Copy-Item -Times 0 -Exactly + Should -Invoke -ModuleName NerdFonts Start-NerdFontDownload -Times 0 -Exactly Should -Invoke -ModuleName NerdFonts Install-Font -Times 0 -Exactly } finally { InModuleScope NerdFonts -Parameters @{ fonts = $originalFonts } { @@ -122,7 +364,11 @@ Describe 'Module' { { Install-NerdFont -Name 'Hack' -Variant Mono -ErrorAction Stop } | Should -Not -Throw Should -Invoke -ModuleName NerdFonts Install-Font -Times 1 -Exactly $script:TestCapturedFiles | Should -Not -BeNullOrEmpty - $script:TestCapturedFiles | ForEach-Object { $_ | Should -BeLike '*NerdFontMono*' } + $script:TestCapturedFiles | ForEach-Object { + $_ | Should -BeLike '*NerdFontMono*' + $_ | Should -Not -BeLike '*NerdFontPropo*' + $_ | Should -Not -BeLike '*NerdFont-*' + } } finally { InModuleScope NerdFonts -Parameters @{ fonts = $originalFonts } { param($fonts) @@ -240,11 +486,7 @@ Describe 'Module' { $loadedFonts = Get-Content -Path (Join-Path -Path $PSScriptRoot -ChildPath '../src/FontsData.json') | ConvertFrom-Json $goodFont = $loadedFonts | Where-Object Name -EQ 'Tinos' | Select-Object -First 1 $fontName = $goodFont.Name - $cacheRoot = if ($IsWindows) { - Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'PSModule/NerdFonts/cache' - } else { - Join-Path $HOME '.cache/PSModule/NerdFonts' - } + $cacheRoot = InModuleScope NerdFonts { Get-NerdFontCacheRoot } $cacheTag = if ($goodFont.URL -match '/releases/download/([^/]+)/') { $Matches[1] } else { 'unknown' } $cacheTagDir = Join-Path $cacheRoot $cacheTag $downloadFileName = Split-Path -Path $goodFont.URL -Leaf @@ -318,11 +560,7 @@ Describe 'Module' { It 'Install-NerdFont - Deduplicates variant files from cached archives' { $originalFonts = InModuleScope NerdFonts { $script:NerdFonts } $fontName = 'DuplicateMonoTest' - $cacheRoot = if ($IsWindows) { - Join-Path -Path ([Environment]::GetFolderPath('LocalApplicationData')) -ChildPath 'PSModule/NerdFonts/cache' - } else { - Join-Path -Path $HOME -ChildPath '.cache/PSModule/NerdFonts' - } + $cacheRoot = InModuleScope NerdFonts { Get-NerdFontCacheRoot } $cacheTagDir = Join-Path -Path $cacheRoot -ChildPath 'test-dedup-v0' $zipPath = Join-Path -Path $cacheTagDir -ChildPath 'DuplicateMonoTest.zip' $hadExistingCacheRoot = Test-Path -LiteralPath $cacheRoot From 430685164d9adc214aa915fc95b37fc633909ccb Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 8 Aug 2026 20:58:36 +0200 Subject: [PATCH 04/16] Document cache paths and benchmark setup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .gitignore | 3 + README.md | 7 +- scripts/Measure-InstallPerformance.ps1 | 161 +++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 scripts/Measure-InstallPerformance.ps1 diff --git a/.gitignore b/.gitignore index 8af6555..8c4e840 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ outputs/* bin/ obj/ libs/ + +# Generated performance measurements +scripts/perf-results.jsonl diff --git a/README.md b/README.md index e39da46..2fdb04c 100644 --- a/README.md +++ b/README.md @@ -90,13 +90,18 @@ When you run `Install-NerdFont` again without `-Force`, fonts that are already i Cache locations: - Windows: `%LOCALAPPDATA%/PSModule/NerdFonts/cache` -- macOS and Linux: `$HOME/.cache/PSModule/NerdFonts` +- macOS: `$HOME/Library/Caches/PSModule/NerdFonts` +- Linux: `$XDG_CACHE_HOME/PSModule/NerdFonts` when `XDG_CACHE_HOME` is set, otherwise `$HOME/.cache/PSModule/NerdFonts` You can inspect the active cache path in PowerShell with: ```powershell if ($IsWindows) { Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'PSModule/NerdFonts/cache' +} elseif ($IsMacOS) { + Join-Path $HOME 'Library/Caches/PSModule/NerdFonts' +} elseif (-not [string]::IsNullOrWhiteSpace($env:XDG_CACHE_HOME)) { + Join-Path $env:XDG_CACHE_HOME 'PSModule/NerdFonts' } else { Join-Path $HOME '.cache/PSModule/NerdFonts' } diff --git a/scripts/Measure-InstallPerformance.ps1 b/scripts/Measure-InstallPerformance.ps1 new file mode 100644 index 0000000..4744c3b --- /dev/null +++ b/scripts/Measure-InstallPerformance.ps1 @@ -0,0 +1,161 @@ +<# + .SYNOPSIS + Measures Install-NerdFont performance across known scenarios. + + .DESCRIPTION + Runs timed installation scenarios and appends structured results to a JSON Lines file. + Each fresh-install scenario removes its target fonts during setup. The already-installed + scenario explicitly installs its subset before measuring the skip path. + + .EXAMPLE + ./Measure-InstallPerformance.ps1 -Iteration 'baseline' -Subset 'Hack', 'FiraCode', 'JetBrainsMono' +#> +[CmdletBinding()] +param( + # Free-form label for the iteration, such as a module version or commit SHA. + [Parameter(Mandatory)] + [string] $Iteration, + + # Named fonts used for the small subset scenarios. + [Parameter()] + [string[]] $Subset = @('Hack', 'FiraCode', 'JetBrainsMono'), + + # Runs a full Install-NerdFont -All measurement. + [Parameter()] + [switch] $IncludeAll, + + # File to receive one JSON result object per scenario. + [Parameter()] + [string] $ResultsPath = (Join-Path -Path $PSScriptRoot -ChildPath 'perf-results.jsonl') +) + +$ErrorActionPreference = 'Stop' + +function Remove-NerdFont { + <# + .SYNOPSIS + Removes the installed families associated with the supplied archive names. + #> + [OutputType([void])] + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory)] + [string[]] $Names + ) + + foreach ($name in $Names) { + $families = Get-Font -Scope CurrentUser | Where-Object { $_.Name -like "$name*" } + foreach ($family in $families) { + if ($PSCmdlet.ShouldProcess($family.Name, 'Uninstall font')) { + Uninstall-Font -Name $family.Name -Scope CurrentUser -ErrorAction Stop + } + } + } +} + +function Remove-AllNerdFont { + <# + .SYNOPSIS + Removes all installed Nerd Font families. + #> + [OutputType([void])] + [CmdletBinding(SupportsShouldProcess)] + param() + + $removeParams = @{ + Names = (Get-NerdFont).Name + WhatIf = $WhatIfPreference + Confirm = $false + } + Remove-NerdFont @removeParams +} + +function Measure-InstallScenario { + <# + .SYNOPSIS + Measures one setup and action pair. + #> + [OutputType([pscustomobject])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Name, + + [Parameter(Mandatory)] + [scriptblock] $Setup, + + [Parameter(Mandatory)] + [scriptblock] $Action, + + [Parameter(Mandatory)] + [string] $ResultsPath + ) + + Write-Verbose "[$Iteration] Setup : $Name" + & $Setup | Out-Null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + + Write-Verbose "[$Iteration] Measure : $Name" + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + $errorMessage = $null + try { + & $Action | Out-Null + } catch { + $errorMessage = $_.ToString() + } + $stopwatch.Stop() + + $result = [pscustomobject]@{ + Iteration = $Iteration + Scenario = $Name + DurationMs = [int] $stopwatch.Elapsed.TotalMilliseconds + DurationS = [Math]::Round($stopwatch.Elapsed.TotalSeconds, 2) + Timestamp = (Get-Date).ToString('o') + Error = $errorMessage + Module = (Get-Module -Name NerdFonts).Version.ToString() + } + + Write-Verbose "[$Iteration] Result : $Name -> $($result.DurationS)s" + $result | ConvertTo-Json -Compress | Add-Content -Path $ResultsPath + return $result +} + +$results = [System.Collections.Generic.List[object]]::new() + +$singleFontScenario = @{ + Name = 'Single-Hack' + Setup = { Remove-NerdFont -Names 'Hack' -Confirm:$false } + Action = { Install-NerdFont -Name 'Hack' -Scope CurrentUser -Force } + ResultsPath = $ResultsPath +} +$results.Add((Measure-InstallScenario @singleFontScenario)) + +$subsetScenario = @{ + Name = "Subset-$($Subset -join '+')" + Setup = { Remove-NerdFont -Names $Subset -Confirm:$false } + Action = { Install-NerdFont -Name $Subset -Scope CurrentUser -Force } + ResultsPath = $ResultsPath +} +$results.Add((Measure-InstallScenario @subsetScenario)) + +$alreadyInstalledScenario = @{ + Name = 'Subset-AlreadyInstalled' + Setup = { Install-NerdFont -Name $Subset -Scope CurrentUser -Force } + Action = { Install-NerdFont -Name $Subset -Scope CurrentUser } + ResultsPath = $ResultsPath +} +$results.Add((Measure-InstallScenario @alreadyInstalledScenario)) + +if ($IncludeAll) { + $allScenario = @{ + Name = 'All' + Setup = { Remove-AllNerdFont -Confirm:$false } + Action = { Install-NerdFont -All -Scope CurrentUser -Force } + ResultsPath = $ResultsPath + } + $results.Add((Measure-InstallScenario @allScenario)) +} + +Write-Verbose "Summary for iteration '$Iteration':" +$results | Format-Table Iteration, Scenario, DurationS, Module -AutoSize From 276fdb693fcdad746559c3d1331b6c64e7205b99 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 8 Aug 2026 21:01:17 +0200 Subject: [PATCH 05/16] Limit benchmark cleanup to Nerd Fonts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- scripts/Measure-InstallPerformance.ps1 | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/Measure-InstallPerformance.ps1 b/scripts/Measure-InstallPerformance.ps1 index 4744c3b..d525cff 100644 --- a/scripts/Measure-InstallPerformance.ps1 +++ b/scripts/Measure-InstallPerformance.ps1 @@ -44,7 +44,11 @@ function Remove-NerdFont { ) foreach ($name in $Names) { - $families = Get-Font -Scope CurrentUser | Where-Object { $_.Name -like "$name*" } + $normalizedName = $name -replace '[\s_-]', '' + $families = Get-Font -Scope CurrentUser | Where-Object { + $normalizedFamily = $_.Name -replace '[\s_-]', '' + $normalizedFamily -like "${normalizedName}NerdFont*" + } foreach ($family in $families) { if ($PSCmdlet.ShouldProcess($family.Name, 'Uninstall font')) { Uninstall-Font -Name $family.Name -Scope CurrentUser -ErrorAction Stop From ad74c8ab15a165b1bef9f30187e420e3bbe2e601 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 8 Aug 2026 21:07:06 +0200 Subject: [PATCH 06/16] Use direct null assignment for command output Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- scripts/Measure-InstallPerformance.ps1 | 4 ++-- src/functions/public/Install-NerdFont.ps1 | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/Measure-InstallPerformance.ps1 b/scripts/Measure-InstallPerformance.ps1 index d525cff..5228ad1 100644 --- a/scripts/Measure-InstallPerformance.ps1 +++ b/scripts/Measure-InstallPerformance.ps1 @@ -96,7 +96,7 @@ function Measure-InstallScenario { ) Write-Verbose "[$Iteration] Setup : $Name" - & $Setup | Out-Null + $null = & $Setup [GC]::Collect() [GC]::WaitForPendingFinalizers() @@ -104,7 +104,7 @@ function Measure-InstallScenario { $stopwatch = [Diagnostics.Stopwatch]::StartNew() $errorMessage = $null try { - & $Action | Out-Null + $null = & $Action } catch { $errorMessage = $_.ToString() } diff --git a/src/functions/public/Install-NerdFont.ps1 b/src/functions/public/Install-NerdFont.ps1 index d2c395e..26aafbc 100644 --- a/src/functions/public/Install-NerdFont.ps1 +++ b/src/functions/public/Install-NerdFont.ps1 @@ -258,7 +258,7 @@ Please run the command again with elevated rights (Run as Administrator) or prov foreach ($task in $tasks) { try { - Receive-Job -Job $task.Job -Wait -AutoRemoveJob -ErrorAction Stop | Out-Null + $null = Receive-Job -Job $task.Job -Wait -AutoRemoveJob -ErrorAction Stop $readyToInstall.Add($task.QueuedDownload) } catch { $downloadErrors.Add("[$($task.QueuedDownload.Name)] - Download failed: $($_.Exception.Message)") From f947b652f5648f53c8b0e74aa8c08bb2caa445c1 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 8 Aug 2026 21:20:22 +0200 Subject: [PATCH 07/16] Stream archives with .NET HttpClient Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../private/Start-NerdFontDownload.ps1 | 80 +++++++++++++++++-- 1 file changed, 73 insertions(+), 7 deletions(-) diff --git a/src/functions/private/Start-NerdFontDownload.ps1 b/src/functions/private/Start-NerdFontDownload.ps1 index 49af215..88127ea 100644 --- a/src/functions/private/Start-NerdFontDownload.ps1 +++ b/src/functions/private/Start-NerdFontDownload.ps1 @@ -24,14 +24,80 @@ function Start-NerdFontDownload { [string] $DownloadPath ) - $downloadParams = @{ - Uri = $DownloadUri - OutFile = $DownloadPath - MaximumRetryCount = 5 - RetryIntervalSec = 5 - ErrorAction = 'Stop' + $maximumRetryCount = 5 + $retryIntervalSeconds = 5 + $temporaryPath = "$DownloadPath.$PID.tmp" + $httpClient = [System.Net.Http.HttpClient]::new() + $httpClient.Timeout = [System.Threading.Timeout]::InfiniteTimeSpan + + try { + for ($attempt = 0; $attempt -le $maximumRetryCount; $attempt++) { + $response = $null + $source = $null + $destination = $null + try { + $responseHeadersOnly = [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead + $response = $httpClient.GetAsync($DownloadUri, $responseHeadersOnly).GetAwaiter().GetResult() + + if (-not $response.IsSuccessStatusCode) { + $statusCode = [int] $response.StatusCode + $isTransientStatus = $statusCode -eq 408 -or $statusCode -eq 429 -or $statusCode -ge 500 + if ($isTransientStatus -and $attempt -lt $maximumRetryCount) { + Start-Sleep -Seconds $retryIntervalSeconds + continue + } + + $errorMessage = "Download failed with HTTP status code [$statusCode]." + throw [InvalidOperationException]::new($errorMessage) + } + + $source = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() + $destination = [System.IO.FileStream]::new( + $temporaryPath, + [System.IO.FileMode]::Create, + [System.IO.FileAccess]::Write, + [System.IO.FileShare]::None, + 81920, + [System.IO.FileOptions]::Asynchronous + ) + $null = $source.CopyToAsync($destination).GetAwaiter().GetResult() + $null = $destination.FlushAsync().GetAwaiter().GetResult() + $destination.Dispose() + $destination = $null + $source.Dispose() + $source = $null + [System.IO.File]::Move($temporaryPath, $DownloadPath, $true) + return + } catch { + $isTransientException = @( + $_.Exception -is [System.Net.Http.HttpRequestException] + $_.Exception -is [System.IO.IOException] + $_.Exception -is [System.Threading.Tasks.TaskCanceledException] + ) -contains $true + if ($isTransientException -and $attempt -lt $maximumRetryCount) { + Start-Sleep -Seconds $retryIntervalSeconds + continue + } + + throw + } finally { + if ($destination) { + $destination.Dispose() + } + if ($source) { + $source.Dispose() + } + if ($response) { + $response.Dispose() + } + } + } + } finally { + $httpClient.Dispose() + if (Test-Path -LiteralPath $temporaryPath) { + Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue + } } - Invoke-WebRequest @downloadParams } return Start-ThreadJob -ScriptBlock $downloadScript -ArgumentList $Uri, $DestinationPath From 35e3cdf2c9d67ce452b2d859ccf6e6edbdd5c254 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 8 Aug 2026 21:26:15 +0200 Subject: [PATCH 08/16] Cover .NET archive downloads Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../private/Start-NerdFontDownload.ps1 | 10 ++++++- tests/NerdFonts.Tests.ps1 | 29 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/functions/private/Start-NerdFontDownload.ps1 b/src/functions/private/Start-NerdFontDownload.ps1 index 88127ea..46d8740 100644 --- a/src/functions/private/Start-NerdFontDownload.ps1 +++ b/src/functions/private/Start-NerdFontDownload.ps1 @@ -14,7 +14,10 @@ function Start-NerdFontDownload { [uri] $Uri, [Parameter(Mandatory)] - [string] $DestinationPath + [string] $DestinationPath, + + [Parameter()] + [switch] $Wait ) $downloadScript = { @@ -100,5 +103,10 @@ function Start-NerdFontDownload { } } + if ($Wait) { + $null = & $downloadScript $Uri $DestinationPath + return + } + return Start-ThreadJob -ScriptBlock $downloadScript -ArgumentList $Uri, $DestinationPath } diff --git a/tests/NerdFonts.Tests.ps1 b/tests/NerdFonts.Tests.ps1 index 8b6a4ec..469ebe1 100644 --- a/tests/NerdFonts.Tests.ps1 +++ b/tests/NerdFonts.Tests.ps1 @@ -98,6 +98,35 @@ Describe 'Module' { } } + It 'Start-NerdFontDownload - Streams a complete archive with .NET' { + $loadedFonts = Get-Content -Path (Join-Path -Path $PSScriptRoot -ChildPath '../src/FontsData.json') | ConvertFrom-Json + $goodFont = $loadedFonts | Where-Object Name -EQ 'Tinos' | Select-Object -First 1 + $downloadPath = Join-Path -Path $TestDrive -ChildPath 'Tinos.zip' + + InModuleScope NerdFonts -Parameters @{ url = $goodFont.URL; path = $downloadPath } { + param($url, $path) + Start-NerdFontDownload -Uri $url -DestinationPath $path -Wait + } + + Test-Path -LiteralPath $downloadPath -PathType Leaf | Should -BeTrue + $archive = [System.IO.Compression.ZipFile]::OpenRead($downloadPath) + $archive.Entries.Count | Should -BeGreaterThan 0 + $archive.Dispose() + } + + It 'Start-NerdFontDownload - Fails a missing archive without retrying' { + $downloadPath = Join-Path -Path $TestDrive -ChildPath 'missing.zip' + $missingUrl = 'https://github.com/ryanoasis/nerd-fonts/releases/download/v3.5.0/does-not-exist.zip' + + { + InModuleScope NerdFonts -Parameters @{ url = $missingUrl; path = $downloadPath } { + param($url, $path) + Start-NerdFontDownload -Uri $url -DestinationPath $path -Wait + } + } | Should -Throw '*HTTP status code [[]404[]]*' + Test-Path -LiteralPath $downloadPath | Should -BeFalse + } + It 'Install-NerdFont - Continues when one downloaded archive cannot be extracted' { $originalFonts = InModuleScope NerdFonts { $script:NerdFonts } $validArchivePath = Join-Path -Path $TestDrive -ChildPath 'valid-archive.zip' From 8e3cafaa8fe3076fd07f969a083bf067955f5ed9 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 8 Aug 2026 21:27:40 +0200 Subject: [PATCH 09/16] Expose .NET downloader coverage Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../private/Start-NerdFontDownload.ps1 | 187 ++++++++++-------- 1 file changed, 107 insertions(+), 80 deletions(-) diff --git a/src/functions/private/Start-NerdFontDownload.ps1 b/src/functions/private/Start-NerdFontDownload.ps1 index 46d8740..07b1665 100644 --- a/src/functions/private/Start-NerdFontDownload.ps1 +++ b/src/functions/private/Start-NerdFontDownload.ps1 @@ -1,3 +1,98 @@ +function Invoke-NerdFontDownload { + <# + .SYNOPSIS + Streams a font archive to disk with retries. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Install-NerdFont confirms the download operation before invoking this helper.' + )] + [OutputType([void])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [uri] $Uri, + + [Parameter(Mandatory)] + [string] $DestinationPath + ) + + $maximumRetryCount = 5 + $retryIntervalSeconds = 5 + $temporaryPath = "$DestinationPath.$PID.tmp" + $httpClient = [System.Net.Http.HttpClient]::new() + $httpClient.Timeout = [System.Threading.Timeout]::InfiniteTimeSpan + + try { + for ($attempt = 0; $attempt -le $maximumRetryCount; $attempt++) { + $response = $null + $source = $null + $destination = $null + try { + $responseHeadersOnly = [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead + $response = $httpClient.GetAsync($Uri, $responseHeadersOnly).GetAwaiter().GetResult() + + if (-not $response.IsSuccessStatusCode) { + $statusCode = [int] $response.StatusCode + $isTransientStatus = $statusCode -eq 408 -or $statusCode -eq 429 -or $statusCode -ge 500 + if ($isTransientStatus -and $attempt -lt $maximumRetryCount) { + Start-Sleep -Seconds $retryIntervalSeconds + continue + } + + $errorMessage = "Download failed with HTTP status code [$statusCode]." + throw [InvalidOperationException]::new($errorMessage) + } + + $source = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() + $destination = [System.IO.FileStream]::new( + $temporaryPath, + [System.IO.FileMode]::Create, + [System.IO.FileAccess]::Write, + [System.IO.FileShare]::None, + 81920, + [System.IO.FileOptions]::Asynchronous + ) + $null = $source.CopyToAsync($destination).GetAwaiter().GetResult() + $null = $destination.FlushAsync().GetAwaiter().GetResult() + $destination.Dispose() + $destination = $null + $source.Dispose() + $source = $null + [System.IO.File]::Move($temporaryPath, $DestinationPath, $true) + return + } catch { + $isTransientException = @( + $_.Exception -is [System.Net.Http.HttpRequestException] + $_.Exception -is [System.IO.IOException] + $_.Exception -is [System.Threading.Tasks.TaskCanceledException] + ) -contains $true + if ($isTransientException -and $attempt -lt $maximumRetryCount) { + Start-Sleep -Seconds $retryIntervalSeconds + continue + } + + throw + } finally { + if ($destination) { + $destination.Dispose() + } + if ($source) { + $source.Dispose() + } + if ($response) { + $response.Dispose() + } + } + } + } finally { + $httpClient.Dispose() + if (Test-Path -LiteralPath $temporaryPath) { + Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue + } + } +} + function Start-NerdFontDownload { <# .SYNOPSIS @@ -20,93 +115,25 @@ function Start-NerdFontDownload { [switch] $Wait ) + if ($Wait) { + Invoke-NerdFontDownload -Uri $Uri -DestinationPath $DestinationPath + return + } + + $downloadFunctionBody = ${function:Invoke-NerdFontDownload}.ToString() $downloadScript = { param( + [string] $FunctionBody, + [uri] $DownloadUri, [string] $DownloadPath ) - $maximumRetryCount = 5 - $retryIntervalSeconds = 5 - $temporaryPath = "$DownloadPath.$PID.tmp" - $httpClient = [System.Net.Http.HttpClient]::new() - $httpClient.Timeout = [System.Threading.Timeout]::InfiniteTimeSpan - - try { - for ($attempt = 0; $attempt -le $maximumRetryCount; $attempt++) { - $response = $null - $source = $null - $destination = $null - try { - $responseHeadersOnly = [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead - $response = $httpClient.GetAsync($DownloadUri, $responseHeadersOnly).GetAwaiter().GetResult() - - if (-not $response.IsSuccessStatusCode) { - $statusCode = [int] $response.StatusCode - $isTransientStatus = $statusCode -eq 408 -or $statusCode -eq 429 -or $statusCode -ge 500 - if ($isTransientStatus -and $attempt -lt $maximumRetryCount) { - Start-Sleep -Seconds $retryIntervalSeconds - continue - } - - $errorMessage = "Download failed with HTTP status code [$statusCode]." - throw [InvalidOperationException]::new($errorMessage) - } - - $source = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() - $destination = [System.IO.FileStream]::new( - $temporaryPath, - [System.IO.FileMode]::Create, - [System.IO.FileAccess]::Write, - [System.IO.FileShare]::None, - 81920, - [System.IO.FileOptions]::Asynchronous - ) - $null = $source.CopyToAsync($destination).GetAwaiter().GetResult() - $null = $destination.FlushAsync().GetAwaiter().GetResult() - $destination.Dispose() - $destination = $null - $source.Dispose() - $source = $null - [System.IO.File]::Move($temporaryPath, $DownloadPath, $true) - return - } catch { - $isTransientException = @( - $_.Exception -is [System.Net.Http.HttpRequestException] - $_.Exception -is [System.IO.IOException] - $_.Exception -is [System.Threading.Tasks.TaskCanceledException] - ) -contains $true - if ($isTransientException -and $attempt -lt $maximumRetryCount) { - Start-Sleep -Seconds $retryIntervalSeconds - continue - } - - throw - } finally { - if ($destination) { - $destination.Dispose() - } - if ($source) { - $source.Dispose() - } - if ($response) { - $response.Dispose() - } - } - } - } finally { - $httpClient.Dispose() - if (Test-Path -LiteralPath $temporaryPath) { - Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue - } - } - } - - if ($Wait) { - $null = & $downloadScript $Uri $DestinationPath - return + $functionDefinition = "function Invoke-NerdFontDownload {`n$FunctionBody`n}" + . ([scriptblock]::Create($functionDefinition)) + Invoke-NerdFontDownload -Uri $DownloadUri -DestinationPath $DownloadPath } - return Start-ThreadJob -ScriptBlock $downloadScript -ArgumentList $Uri, $DestinationPath + return Start-ThreadJob -ScriptBlock $downloadScript -ArgumentList $downloadFunctionBody, $Uri, $DestinationPath } From a00ed14c640107c1e063a572d5cdc235e8a26f08 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 8 Aug 2026 21:29:37 +0200 Subject: [PATCH 10/16] Retry stalled archive downloads Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../private/Start-NerdFontDownload.ps1 | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/functions/private/Start-NerdFontDownload.ps1 b/src/functions/private/Start-NerdFontDownload.ps1 index 07b1665..72bde3a 100644 --- a/src/functions/private/Start-NerdFontDownload.ps1 +++ b/src/functions/private/Start-NerdFontDownload.ps1 @@ -14,7 +14,11 @@ function Invoke-NerdFontDownload { [uri] $Uri, [Parameter(Mandatory)] - [string] $DestinationPath + [string] $DestinationPath, + + [Parameter()] + [ValidateRange(1, 3600)] + [int] $AttemptTimeoutSeconds = 900 ) $maximumRetryCount = 5 @@ -28,9 +32,17 @@ function Invoke-NerdFontDownload { $response = $null $source = $null $destination = $null + $cancellationTokenSource = [System.Threading.CancellationTokenSource]::new() try { + $attemptTimeout = [TimeSpan]::FromSeconds($AttemptTimeoutSeconds) + $cancellationTokenSource.CancelAfter($attemptTimeout) + $cancellationToken = $cancellationTokenSource.Token $responseHeadersOnly = [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead - $response = $httpClient.GetAsync($Uri, $responseHeadersOnly).GetAwaiter().GetResult() + $response = $httpClient.GetAsync( + $Uri, + $responseHeadersOnly, + $cancellationToken + ).GetAwaiter().GetResult() if (-not $response.IsSuccessStatusCode) { $statusCode = [int] $response.StatusCode @@ -44,7 +56,7 @@ function Invoke-NerdFontDownload { throw [InvalidOperationException]::new($errorMessage) } - $source = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() + $source = $response.Content.ReadAsStreamAsync($cancellationToken).GetAwaiter().GetResult() $destination = [System.IO.FileStream]::new( $temporaryPath, [System.IO.FileMode]::Create, @@ -53,8 +65,8 @@ function Invoke-NerdFontDownload { 81920, [System.IO.FileOptions]::Asynchronous ) - $null = $source.CopyToAsync($destination).GetAwaiter().GetResult() - $null = $destination.FlushAsync().GetAwaiter().GetResult() + $null = $source.CopyToAsync($destination, 81920, $cancellationToken).GetAwaiter().GetResult() + $null = $destination.FlushAsync($cancellationToken).GetAwaiter().GetResult() $destination.Dispose() $destination = $null $source.Dispose() @@ -65,7 +77,7 @@ function Invoke-NerdFontDownload { $isTransientException = @( $_.Exception -is [System.Net.Http.HttpRequestException] $_.Exception -is [System.IO.IOException] - $_.Exception -is [System.Threading.Tasks.TaskCanceledException] + $_.Exception -is [System.OperationCanceledException] ) -contains $true if ($isTransientException -and $attempt -lt $maximumRetryCount) { Start-Sleep -Seconds $retryIntervalSeconds @@ -83,6 +95,7 @@ function Invoke-NerdFontDownload { if ($response) { $response.Dispose() } + $cancellationTokenSource.Dispose() } } } finally { From 031ec6eb7dbc06f50d49269cba7e78b1bb5b1ce6 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 8 Aug 2026 21:34:11 +0200 Subject: [PATCH 11/16] Split .NET downloader helpers Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../private/Invoke-NerdFontDownload.ps1 | 107 +++++++++++++++++ .../private/Start-NerdFontDownload.ps1 | 108 ------------------ 2 files changed, 107 insertions(+), 108 deletions(-) create mode 100644 src/functions/private/Invoke-NerdFontDownload.ps1 diff --git a/src/functions/private/Invoke-NerdFontDownload.ps1 b/src/functions/private/Invoke-NerdFontDownload.ps1 new file mode 100644 index 0000000..07f0395 --- /dev/null +++ b/src/functions/private/Invoke-NerdFontDownload.ps1 @@ -0,0 +1,107 @@ +function Invoke-NerdFontDownload { + <# + .SYNOPSIS + Streams a font archive to disk with retries. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Install-NerdFont confirms the download operation before invoking this helper.' + )] + [OutputType([void])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [uri] $Uri, + + [Parameter(Mandatory)] + [string] $DestinationPath, + + [Parameter()] + [ValidateRange(1, 3600)] + [int] $AttemptTimeoutSeconds = 900 + ) + + $maximumRetryCount = 5 + $retryIntervalSeconds = 5 + $temporaryPath = "$DestinationPath.$PID.tmp" + $httpClient = [System.Net.Http.HttpClient]::new() + $httpClient.Timeout = [System.Threading.Timeout]::InfiniteTimeSpan + + try { + for ($attempt = 0; $attempt -le $maximumRetryCount; $attempt++) { + $response = $null + $source = $null + $destination = $null + $cancellationTokenSource = [System.Threading.CancellationTokenSource]::new() + try { + $attemptTimeout = [TimeSpan]::FromSeconds($AttemptTimeoutSeconds) + $cancellationTokenSource.CancelAfter($attemptTimeout) + $cancellationToken = $cancellationTokenSource.Token + $responseHeadersOnly = [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead + $response = $httpClient.GetAsync( + $Uri, + $responseHeadersOnly, + $cancellationToken + ).GetAwaiter().GetResult() + + if (-not $response.IsSuccessStatusCode) { + $statusCode = [int] $response.StatusCode + $isTransientStatus = $statusCode -eq 408 -or $statusCode -eq 429 -or $statusCode -ge 500 + if ($isTransientStatus -and $attempt -lt $maximumRetryCount) { + Start-Sleep -Seconds $retryIntervalSeconds + continue + } + + $errorMessage = "Download failed with HTTP status code [$statusCode]." + throw [InvalidOperationException]::new($errorMessage) + } + + $source = $response.Content.ReadAsStreamAsync($cancellationToken).GetAwaiter().GetResult() + $destination = [System.IO.FileStream]::new( + $temporaryPath, + [System.IO.FileMode]::Create, + [System.IO.FileAccess]::Write, + [System.IO.FileShare]::None, + 81920, + [System.IO.FileOptions]::Asynchronous + ) + $null = $source.CopyToAsync($destination, 81920, $cancellationToken).GetAwaiter().GetResult() + $null = $destination.FlushAsync($cancellationToken).GetAwaiter().GetResult() + $destination.Dispose() + $destination = $null + $source.Dispose() + $source = $null + [System.IO.File]::Move($temporaryPath, $DestinationPath, $true) + return + } catch { + $isTransientException = @( + $_.Exception -is [System.Net.Http.HttpRequestException] + $_.Exception -is [System.IO.IOException] + $_.Exception -is [System.OperationCanceledException] + ) -contains $true + if ($isTransientException -and $attempt -lt $maximumRetryCount) { + Start-Sleep -Seconds $retryIntervalSeconds + continue + } + + throw + } finally { + if ($destination) { + $destination.Dispose() + } + if ($source) { + $source.Dispose() + } + if ($response) { + $response.Dispose() + } + $cancellationTokenSource.Dispose() + } + } + } finally { + $httpClient.Dispose() + if (Test-Path -LiteralPath $temporaryPath) { + Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue + } + } +} diff --git a/src/functions/private/Start-NerdFontDownload.ps1 b/src/functions/private/Start-NerdFontDownload.ps1 index 72bde3a..c86b861 100644 --- a/src/functions/private/Start-NerdFontDownload.ps1 +++ b/src/functions/private/Start-NerdFontDownload.ps1 @@ -1,111 +1,3 @@ -function Invoke-NerdFontDownload { - <# - .SYNOPSIS - Streams a font archive to disk with retries. - #> - [Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseShouldProcessForStateChangingFunctions', '', - Justification = 'Install-NerdFont confirms the download operation before invoking this helper.' - )] - [OutputType([void])] - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [uri] $Uri, - - [Parameter(Mandatory)] - [string] $DestinationPath, - - [Parameter()] - [ValidateRange(1, 3600)] - [int] $AttemptTimeoutSeconds = 900 - ) - - $maximumRetryCount = 5 - $retryIntervalSeconds = 5 - $temporaryPath = "$DestinationPath.$PID.tmp" - $httpClient = [System.Net.Http.HttpClient]::new() - $httpClient.Timeout = [System.Threading.Timeout]::InfiniteTimeSpan - - try { - for ($attempt = 0; $attempt -le $maximumRetryCount; $attempt++) { - $response = $null - $source = $null - $destination = $null - $cancellationTokenSource = [System.Threading.CancellationTokenSource]::new() - try { - $attemptTimeout = [TimeSpan]::FromSeconds($AttemptTimeoutSeconds) - $cancellationTokenSource.CancelAfter($attemptTimeout) - $cancellationToken = $cancellationTokenSource.Token - $responseHeadersOnly = [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead - $response = $httpClient.GetAsync( - $Uri, - $responseHeadersOnly, - $cancellationToken - ).GetAwaiter().GetResult() - - if (-not $response.IsSuccessStatusCode) { - $statusCode = [int] $response.StatusCode - $isTransientStatus = $statusCode -eq 408 -or $statusCode -eq 429 -or $statusCode -ge 500 - if ($isTransientStatus -and $attempt -lt $maximumRetryCount) { - Start-Sleep -Seconds $retryIntervalSeconds - continue - } - - $errorMessage = "Download failed with HTTP status code [$statusCode]." - throw [InvalidOperationException]::new($errorMessage) - } - - $source = $response.Content.ReadAsStreamAsync($cancellationToken).GetAwaiter().GetResult() - $destination = [System.IO.FileStream]::new( - $temporaryPath, - [System.IO.FileMode]::Create, - [System.IO.FileAccess]::Write, - [System.IO.FileShare]::None, - 81920, - [System.IO.FileOptions]::Asynchronous - ) - $null = $source.CopyToAsync($destination, 81920, $cancellationToken).GetAwaiter().GetResult() - $null = $destination.FlushAsync($cancellationToken).GetAwaiter().GetResult() - $destination.Dispose() - $destination = $null - $source.Dispose() - $source = $null - [System.IO.File]::Move($temporaryPath, $DestinationPath, $true) - return - } catch { - $isTransientException = @( - $_.Exception -is [System.Net.Http.HttpRequestException] - $_.Exception -is [System.IO.IOException] - $_.Exception -is [System.OperationCanceledException] - ) -contains $true - if ($isTransientException -and $attempt -lt $maximumRetryCount) { - Start-Sleep -Seconds $retryIntervalSeconds - continue - } - - throw - } finally { - if ($destination) { - $destination.Dispose() - } - if ($source) { - $source.Dispose() - } - if ($response) { - $response.Dispose() - } - $cancellationTokenSource.Dispose() - } - } - } finally { - $httpClient.Dispose() - if (Test-Path -LiteralPath $temporaryPath) { - Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue - } - } -} - function Start-NerdFontDownload { <# .SYNOPSIS From a18c27a59abc24a3f99ca0887f15cb2a9fda668e Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 8 Aug 2026 22:14:24 +0200 Subject: [PATCH 12/16] Clean benchmark font aliases Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- scripts/Measure-InstallPerformance.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/Measure-InstallPerformance.ps1 b/scripts/Measure-InstallPerformance.ps1 index 5228ad1..cb2a4d8 100644 --- a/scripts/Measure-InstallPerformance.ps1 +++ b/scripts/Measure-InstallPerformance.ps1 @@ -47,7 +47,7 @@ function Remove-NerdFont { $normalizedName = $name -replace '[\s_-]', '' $families = Get-Font -Scope CurrentUser | Where-Object { $normalizedFamily = $_.Name -replace '[\s_-]', '' - $normalizedFamily -like "${normalizedName}NerdFont*" + $normalizedFamily -like "${normalizedName}*NerdFont*" } foreach ($family in $families) { if ($PSCmdlet.ShouldProcess($family.Name, 'Uninstall font')) { From bda3a33f41cc19d85bae7208b8bd3bf69ae2a1b0 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 8 Aug 2026 22:15:59 +0200 Subject: [PATCH 13/16] Avoid benchmark subset reinstalls Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- scripts/Measure-InstallPerformance.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/Measure-InstallPerformance.ps1 b/scripts/Measure-InstallPerformance.ps1 index cb2a4d8..ff17e0f 100644 --- a/scripts/Measure-InstallPerformance.ps1 +++ b/scripts/Measure-InstallPerformance.ps1 @@ -145,7 +145,7 @@ $results.Add((Measure-InstallScenario @subsetScenario)) $alreadyInstalledScenario = @{ Name = 'Subset-AlreadyInstalled' - Setup = { Install-NerdFont -Name $Subset -Scope CurrentUser -Force } + Setup = { Install-NerdFont -Name $Subset -Scope CurrentUser } Action = { Install-NerdFont -Name $Subset -Scope CurrentUser } ResultsPath = $ResultsPath } From 67f22e36ef981022128abfb71e3de46d68cc4762 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 9 Aug 2026 01:30:06 +0200 Subject: [PATCH 14/16] Pool font archive downloads by core Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../private/Invoke-NerdFontDownload.ps1 | 13 +++- .../New-NerdFontDownloadRunspacePool.ps1 | 34 +++++++++ .../private/New-NerdFontHttpClient.ps1 | 25 +++++++ .../private/Receive-NerdFontDownload.ps1 | 22 ++++++ .../private/Start-NerdFontDownload.ps1 | 42 ++++++----- src/functions/public/Install-NerdFont.ps1 | 72 +++++++++++++------ 6 files changed, 167 insertions(+), 41 deletions(-) create mode 100644 src/functions/private/New-NerdFontDownloadRunspacePool.ps1 create mode 100644 src/functions/private/New-NerdFontHttpClient.ps1 create mode 100644 src/functions/private/Receive-NerdFontDownload.ps1 diff --git a/src/functions/private/Invoke-NerdFontDownload.ps1 b/src/functions/private/Invoke-NerdFontDownload.ps1 index 07f0395..a2353db 100644 --- a/src/functions/private/Invoke-NerdFontDownload.ps1 +++ b/src/functions/private/Invoke-NerdFontDownload.ps1 @@ -16,6 +16,9 @@ function Invoke-NerdFontDownload { [Parameter(Mandatory)] [string] $DestinationPath, + [Parameter()] + [System.Net.Http.HttpClient] $HttpClient, + [Parameter()] [ValidateRange(1, 3600)] [int] $AttemptTimeoutSeconds = 900 @@ -24,8 +27,10 @@ function Invoke-NerdFontDownload { $maximumRetryCount = 5 $retryIntervalSeconds = 5 $temporaryPath = "$DestinationPath.$PID.tmp" - $httpClient = [System.Net.Http.HttpClient]::new() - $httpClient.Timeout = [System.Threading.Timeout]::InfiniteTimeSpan + $ownsHttpClient = $null -eq $HttpClient + if ($ownsHttpClient) { + $HttpClient = New-NerdFontHttpClient -MaximumConnections 1 + } try { for ($attempt = 0; $attempt -le $maximumRetryCount; $attempt++) { @@ -99,7 +104,9 @@ function Invoke-NerdFontDownload { } } } finally { - $httpClient.Dispose() + if ($ownsHttpClient) { + $HttpClient.Dispose() + } if (Test-Path -LiteralPath $temporaryPath) { Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue } diff --git a/src/functions/private/New-NerdFontDownloadRunspacePool.ps1 b/src/functions/private/New-NerdFontDownloadRunspacePool.ps1 new file mode 100644 index 0000000..2ed66fa --- /dev/null +++ b/src/functions/private/New-NerdFontDownloadRunspacePool.ps1 @@ -0,0 +1,34 @@ +function New-NerdFontDownloadRunspacePool { + <# + .SYNOPSIS + Creates a bounded pool for concurrent archive downloads. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Creating an in-memory runspace pool has no external side effects.' + )] + [OutputType([System.Management.Automation.Runspaces.RunspacePool])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateRange(1, [int]::MaxValue)] + [int] $MaximumRunspaces + ) + + $initialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault2() + $downloadDefinition = ${function:Invoke-NerdFontDownload}.ToString() + $downloadFunction = [System.Management.Automation.Runspaces.SessionStateFunctionEntry]::new( + 'Invoke-NerdFontDownload', + $downloadDefinition + ) + $initialSessionState.Commands.Add($downloadFunction) + + $runspacePool = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspacePool( + 1, + $MaximumRunspaces, + $initialSessionState, + $Host + ) + $runspacePool.Open() + return $runspacePool +} diff --git a/src/functions/private/New-NerdFontHttpClient.ps1 b/src/functions/private/New-NerdFontHttpClient.ps1 new file mode 100644 index 0000000..a6349b2 --- /dev/null +++ b/src/functions/private/New-NerdFontHttpClient.ps1 @@ -0,0 +1,25 @@ +function New-NerdFontHttpClient { + <# + .SYNOPSIS + Creates a reusable HTTP client for an installation operation. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Creating an in-memory HTTP client has no external side effects.' + )] + [OutputType([System.Net.Http.HttpClient])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateRange(1, [int]::MaxValue)] + [int] $MaximumConnections + ) + + $handler = [System.Net.Http.SocketsHttpHandler]::new() + $handler.MaxConnectionsPerServer = $MaximumConnections + $handler.PooledConnectionLifetime = [TimeSpan]::FromMinutes(15) + + $httpClient = [System.Net.Http.HttpClient]::new($handler, $true) + $httpClient.Timeout = [System.Threading.Timeout]::InfiniteTimeSpan + return $httpClient +} diff --git a/src/functions/private/Receive-NerdFontDownload.ps1 b/src/functions/private/Receive-NerdFontDownload.ps1 new file mode 100644 index 0000000..d811632 --- /dev/null +++ b/src/functions/private/Receive-NerdFontDownload.ps1 @@ -0,0 +1,22 @@ +function Receive-NerdFontDownload { + <# + .SYNOPSIS + Completes an asynchronous archive download and surfaces its errors. + #> + [OutputType([void])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [pscustomobject] $Operation + ) + + try { + $null = $Operation.PowerShell.EndInvoke($Operation.AsyncResult) + if ($Operation.PowerShell.HadErrors) { + $errorMessage = ($Operation.PowerShell.Streams.Error | ForEach-Object ToString) -join [Environment]::NewLine + throw $errorMessage + } + } finally { + $Operation.PowerShell.Dispose() + } +} diff --git a/src/functions/private/Start-NerdFontDownload.ps1 b/src/functions/private/Start-NerdFontDownload.ps1 index c86b861..b26563d 100644 --- a/src/functions/private/Start-NerdFontDownload.ps1 +++ b/src/functions/private/Start-NerdFontDownload.ps1 @@ -7,7 +7,7 @@ function Start-NerdFontDownload { 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Install-NerdFont confirms the download operation before starting a job.' )] - [OutputType([System.Management.Automation.Job])] + [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory)] @@ -16,29 +16,39 @@ function Start-NerdFontDownload { [Parameter(Mandatory)] [string] $DestinationPath, + [Parameter()] + [System.Net.Http.HttpClient] $HttpClient, + + [Parameter()] + [System.Management.Automation.Runspaces.RunspacePool] $RunspacePool, + [Parameter()] [switch] $Wait ) if ($Wait) { - Invoke-NerdFontDownload -Uri $Uri -DestinationPath $DestinationPath + Invoke-NerdFontDownload -Uri $Uri -DestinationPath $DestinationPath -HttpClient $HttpClient return } - $downloadFunctionBody = ${function:Invoke-NerdFontDownload}.ToString() - $downloadScript = { - param( - [string] $FunctionBody, - - [uri] $DownloadUri, - - [string] $DownloadPath - ) - - $functionDefinition = "function Invoke-NerdFontDownload {`n$FunctionBody`n}" - . ([scriptblock]::Create($functionDefinition)) - Invoke-NerdFontDownload -Uri $DownloadUri -DestinationPath $DownloadPath + if ($null -eq $HttpClient -or $null -eq $RunspacePool) { + throw 'HttpClient and RunspacePool are required for asynchronous downloads.' } - return Start-ThreadJob -ScriptBlock $downloadScript -ArgumentList $downloadFunctionBody, $Uri, $DestinationPath + $powerShell = [PowerShell]::Create() + $powerShell.RunspacePool = $RunspacePool + try { + $command = $powerShell.AddCommand('Invoke-NerdFontDownload') + $null = $command.AddParameter('Uri', $Uri) + $null = $command.AddParameter('DestinationPath', $DestinationPath) + $null = $command.AddParameter('HttpClient', $HttpClient) + $asyncResult = $powerShell.BeginInvoke() + return [pscustomobject]@{ + AsyncResult = $asyncResult + PowerShell = $powerShell + } + } catch { + $powerShell.Dispose() + throw + } } diff --git a/src/functions/public/Install-NerdFont.ps1 b/src/functions/public/Install-NerdFont.ps1 index 26aafbc..234a67e 100644 --- a/src/functions/public/Install-NerdFont.ps1 +++ b/src/functions/public/Install-NerdFont.ps1 @@ -172,8 +172,9 @@ Please run the command again with elevated rights (Run as Administrator) or prov $pendingDownloads = [System.Collections.Generic.List[object]]::new() $readyToInstall = [System.Collections.Generic.List[object]]::new() $downloadErrors = [System.Collections.Generic.List[string]]::new() - $activeDownloadJobs = [System.Collections.Generic.List[object]]::new() - $throttle = 8 + $processorCount = [System.Environment]::ProcessorCount + $httpClient = $null + $runspacePool = $null try { foreach ($nerdFont in $toProcess) { @@ -240,36 +241,63 @@ Please run the command again with elevated rights (Run as Administrator) or prov } $toDownload = @($pendingDownloads) - for ($i = 0; $i -lt $toDownload.Count; $i += $throttle) { - $end = [Math]::Min($i + $throttle - 1, $toDownload.Count - 1) - $chunk = $toDownload[$i..$end] - $tasks = foreach ($queuedDownload in $chunk) { + if ($toDownload.Count -gt 0) { + $httpClient = New-NerdFontHttpClient -MaximumConnections $processorCount + + if ($toDownload.Count -eq 1) { + $queuedDownload = $toDownload[0] $downloadParams = @{ + HttpClient = $httpClient Uri = $queuedDownload.URL DestinationPath = $queuedDownload.DownloadPath + Wait = $true } - $downloadJob = Start-NerdFontDownload @downloadParams - $activeDownloadJobs.Add($downloadJob) - [pscustomobject]@{ - QueuedDownload = $queuedDownload - Job = $downloadJob - } - } - - foreach ($task in $tasks) { try { - $null = Receive-Job -Job $task.Job -Wait -AutoRemoveJob -ErrorAction Stop - $readyToInstall.Add($task.QueuedDownload) + Start-NerdFontDownload @downloadParams + $readyToInstall.Add($queuedDownload) } catch { - $downloadErrors.Add("[$($task.QueuedDownload.Name)] - Download failed: $($_.Exception.Message)") - } finally { - $null = $activeDownloadJobs.Remove($task.Job) + $downloadErrors.Add("[$($queuedDownload.Name)] - Download failed: $($_.Exception.Message)") + } + } else { + $maximumRunspaces = [Math]::Min($processorCount, $toDownload.Count) + $runspacePool = New-NerdFontDownloadRunspacePool -MaximumRunspaces $maximumRunspaces + $downloadOperations = [System.Collections.Generic.List[object]]::new() + + foreach ($queuedDownload in $toDownload) { + $downloadParams = @{ + HttpClient = $httpClient + RunspacePool = $runspacePool + Uri = $queuedDownload.URL + DestinationPath = $queuedDownload.DownloadPath + } + try { + $downloadOperation = Start-NerdFontDownload @downloadParams + $downloadOperations.Add([pscustomobject]@{ + QueuedDownload = $queuedDownload + Operation = $downloadOperation + }) + } catch { + $downloadErrors.Add("[$($queuedDownload.Name)] - Download failed: $($_.Exception.Message)") + } + } + + foreach ($downloadOperation in $downloadOperations) { + try { + Receive-NerdFontDownload -Operation $downloadOperation.Operation + $readyToInstall.Add($downloadOperation.QueuedDownload) + } catch { + $downloadErrors.Add("[$($downloadOperation.QueuedDownload.Name)] - Download failed: $($_.Exception.Message)") + } } } } } finally { - foreach ($downloadJob in $activeDownloadJobs) { - Remove-Job -Job $downloadJob -Force -ErrorAction SilentlyContinue + if ($runspacePool) { + $runspacePool.Close() + $runspacePool.Dispose() + } + if ($httpClient) { + $httpClient.Dispose() } } From dbceb192d50a753c87619b0fd39d589312d5e32d Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 9 Aug 2026 01:30:08 +0200 Subject: [PATCH 15/16] Cover core-bounded download pools Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/NerdFonts.Tests.ps1 | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/NerdFonts.Tests.ps1 b/tests/NerdFonts.Tests.ps1 index 469ebe1..1ea106a 100644 --- a/tests/NerdFonts.Tests.ps1 +++ b/tests/NerdFonts.Tests.ps1 @@ -159,8 +159,9 @@ Describe 'Module' { } else { Copy-Item -LiteralPath $validArchivePath -Destination $DestinationPath -Force } - Start-ThreadJob -ScriptBlock {} + [pscustomobject]@{} } + Mock -ModuleName NerdFonts Receive-NerdFontDownload {} Mock -ModuleName NerdFonts Install-Font {} { Install-NerdFont -Name @('BrokenArchiveTest', 'ValidArchiveTest') -Force -ErrorAction SilentlyContinue } | @@ -175,6 +176,19 @@ Describe 'Module' { } } + It 'New-NerdFontDownloadRunspacePool - Limits runspaces to the processor count' { + InModuleScope NerdFonts { + $processorCount = [System.Environment]::ProcessorCount + $runspacePool = New-NerdFontDownloadRunspacePool -MaximumRunspaces $processorCount + try { + $runspacePool.GetMaxRunspaces() | Should -Be $processorCount + } finally { + $runspacePool.Close() + $runspacePool.Dispose() + } + } + } + It 'Install-NerdFont - Skips already installed fonts without downloading' { $originalFonts = InModuleScope NerdFonts { $script:NerdFonts } $testFonts = @( From ff8dfddff0b4d3b8acffbb02199b9ccfd91bce32 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 9 Aug 2026 01:48:05 +0200 Subject: [PATCH 16/16] Correct download helper output contract Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/functions/private/Start-NerdFontDownload.ps1 | 1 - 1 file changed, 1 deletion(-) diff --git a/src/functions/private/Start-NerdFontDownload.ps1 b/src/functions/private/Start-NerdFontDownload.ps1 index b26563d..85b6b38 100644 --- a/src/functions/private/Start-NerdFontDownload.ps1 +++ b/src/functions/private/Start-NerdFontDownload.ps1 @@ -7,7 +7,6 @@ function Start-NerdFontDownload { 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Install-NerdFont confirms the download operation before starting a job.' )] - [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory)]