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..ff17e0f --- /dev/null +++ b/scripts/Measure-InstallPerformance.ps1 @@ -0,0 +1,165 @@ +<# + .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) { + $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 + } + } + } +} + +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" + $null = & $Setup + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + + Write-Verbose "[$Iteration] Measure : $Name" + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + $errorMessage = $null + try { + $null = & $Action + } 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 } + 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 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..a2353db --- /dev/null +++ b/src/functions/private/Invoke-NerdFontDownload.ps1 @@ -0,0 +1,114 @@ +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()] + [System.Net.Http.HttpClient] $HttpClient, + + [Parameter()] + [ValidateRange(1, 3600)] + [int] $AttemptTimeoutSeconds = 900 + ) + + $maximumRetryCount = 5 + $retryIntervalSeconds = 5 + $temporaryPath = "$DestinationPath.$PID.tmp" + $ownsHttpClient = $null -eq $HttpClient + if ($ownsHttpClient) { + $HttpClient = New-NerdFontHttpClient -MaximumConnections 1 + } + + 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 { + 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 new file mode 100644 index 0000000..85b6b38 --- /dev/null +++ b/src/functions/private/Start-NerdFontDownload.ps1 @@ -0,0 +1,53 @@ +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.' + )] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [uri] $Uri, + + [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 -HttpClient $HttpClient + return + } + + if ($null -eq $HttpClient -or $null -eq $RunspacePool) { + throw 'HttpClient and RunspacePool are required for asynchronous downloads.' + } + + $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 53ed30a..234a67e 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,24 +134,47 @@ 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 } } $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 - $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 + $processorCount = [System.Environment]::ProcessorCount + $httpClient = $null + $runspacePool = $null try { foreach ($nerdFont in $toProcess) { @@ -178,38 +193,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,30 +236,69 @@ 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 }) - 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) } - } - foreach ($t in $tasks) { + $toDownload = @($pendingDownloads) + 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 + } try { - $bytes = $t.Task.GetAwaiter().GetResult() - [System.IO.File]::WriteAllBytes($t.Q.DownloadPath, $bytes) - $readyToInstall.Add($t.Q) + Start-NerdFontDownload @downloadParams + $readyToInstall.Add($queuedDownload) } catch { - $downloadErrors.Add("[$($t.Q.Name)] - Download failed: $($_.Exception.Message)") + $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 { - $httpClient.Dispose() + if ($runspacePool) { + $runspacePool.Close() + $runspacePool.Dispose() + } + if ($httpClient) { + $httpClient.Dispose() + } } foreach ($p in $readyToInstall) { @@ -249,10 +307,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 +337,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 + } } } diff --git a/tests/NerdFonts.Tests.ps1 b/tests/NerdFonts.Tests.ps1 index 515e3c7..1ea106a 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,97 @@ 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' + 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 + } + [pscustomobject]@{} + } + Mock -ModuleName NerdFonts Receive-NerdFontDownload {} + 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 '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 = @( @@ -84,11 +204,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 +407,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 +529,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 +603,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