diff --git a/README.md b/README.md index 16e21a6..4e5bf49 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,12 @@ To download the font from the NerdFonts repository and install it on the system, Install-NerdFont -Name 'FiraCode' -Scope AllUsers #Tab completion works on Scope too ``` +To install only a specific variant from the archive, use the `-Variant` parameter. `Mono` is useful for terminal and editor setups where you only want the monospace family. + +```powershell +Install-NerdFont -Name 'FiraCode' -Variant Mono +``` + ### Install all NerdFonts To install all NerdFonts on the system you can use the following command. @@ -54,6 +60,12 @@ This requires the shell to run in an elevated context (sudo or run as administra Install-NerdFont -All -Scope AllUsers ``` +You can combine `-All` with `-Variant` to limit what gets installed from each archive: + +```powershell +Install-NerdFont -All -Variant Mono +``` + ### Check if a NerdFont is installed The [Fonts](https://psmodule.io/Fonts) module is installed automatically as a dependency and provides the @@ -73,6 +85,23 @@ Get-Font -Name 'FiraCode*' -Scope AllUsers If the command returns results, the font is installed. If it returns nothing, the font is not installed in that scope. +When you run `Install-NerdFont` again without `-Force`, fonts that are already installed in the requested scope are skipped. Downloaded archives are also cached per Nerd Fonts release so retries and repeated installs do not need to fetch the same ZIP again. + +Cache locations: + +- Windows: `%LOCALAPPDATA%/PSModule/NerdFonts/cache` +- macOS and Linux: `$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' +} else { + Join-Path $HOME '.cache/PSModule/NerdFonts' +} +``` + ### Update an installed NerdFont Individual font files do not embed a NerdFonts release version, so there is no direct way to check whether an installed @@ -89,7 +118,7 @@ Install-NerdFont -Name 'FiraCode' -Force -Scope AllUsers ``` This re-downloads and installs the font version bundled with your installed NerdFonts module, overwriting any existing -files. To pick up newer font releases, update the NerdFonts module first (`Update-PSResource -Name NerdFonts` if you +files. `-Force` also bypasses the local archive cache so the font ZIP is fetched again before reinstalling. To pick up newer font releases, update the NerdFonts module first (`Update-PSResource -Name NerdFonts` if you installed via PSResourceGet, or `Update-Module -Name NerdFonts` if you installed via PowerShellGet). ### Uninstall a NerdFont diff --git a/scripts/Measure-InstallPerformance.ps1 b/scripts/Measure-InstallPerformance.ps1 new file mode 100644 index 0000000..cd57afd --- /dev/null +++ b/scripts/Measure-InstallPerformance.ps1 @@ -0,0 +1,152 @@ +<# + .SYNOPSIS + Measures Install-NerdFont performance across known scenarios. + + .DESCRIPTION + Runs a set of timed scenarios against the currently loaded NerdFonts module + and emits a structured result object per scenario. Each scenario uninstalls + the fonts it will measure before timing, so measurements are comparable + across iterations. + + .EXAMPLE + ./Measure-InstallPerformance.ps1 -Iteration 'baseline' -Subset 'Hack','FiraCode','JetBrainsMono' + + .NOTES + Per-iteration result JSON is appended to scripts/perf-results.jsonl + so a full report can be produced at the end of the improvement cycle. +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingWriteHost', '', + Justification = 'Console output for an interactive perf script.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', 'ResultsPath', + Justification = 'Used inside Measure-Scenario via closure on the enclosing scope.' +)] +[CmdletBinding()] +param( + # Free-form label for the iteration (module version, commit short SHA, etc). + [Parameter(Mandatory)] + [string] $Iteration, + + # Named fonts used for the small subset scenarios. Should be small/medium + # archives to keep iteration time bounded. + [Parameter()] + [string[]] $Subset = @('Hack', 'FiraCode', 'JetBrainsMono'), + + # When set, also runs a full Install-NerdFont -All measurement. Slow. + [Parameter()] + [switch] $IncludeAll, + + # File where per-scenario JSON lines are appended. + [Parameter()] + [string] $ResultsPath = (Join-Path $PSScriptRoot 'perf-results.jsonl') +) + +$ErrorActionPreference = 'Stop' + +function Invoke-Uninstall { + <# + .SYNOPSIS + Removes matching Nerd Font families for the current user. + #> + param([string[]]$Names) + foreach ($n in $Names) { + # Nerd Fonts archives expand to multiple family names that all start + # with the archive's base name (e.g. "Hack Nerd Font", "Hack Nerd Font Mono"). + $families = Get-Font -Scope CurrentUser | Where-Object { $_.Name -like "$n Nerd Font*" } + foreach ($f in $families) { + try { + Uninstall-Font -Name $f.Name -Scope CurrentUser -ErrorAction Stop + } catch { + Write-Verbose "Uninstall failed for $($f.Name): $_" + } + } + } +} + +function Invoke-UninstallAll { + <# + .SYNOPSIS + Removes all Nerd Fonts known to the current module. + #> + $names = (Get-NerdFont).Name + Invoke-Uninstall -Names $names +} + +function Measure-Scenario { + <# + .SYNOPSIS + Runs one setup/action performance scenario and records the result. + #> + param( + [string]$Name, + [scriptblock]$Setup, + [scriptblock]$Action + ) + Write-Host "[$Iteration] Setup : $Name" -ForegroundColor DarkGray + & $Setup | Out-Null + [System.GC]::Collect() + [System.GC]::WaitForPendingFinalizers() + Write-Host "[$Iteration] Measure : $Name" -ForegroundColor Cyan + $sw = [System.Diagnostics.Stopwatch]::StartNew() + try { + & $Action | Out-Null + $err = $null + } catch { + $err = $_.ToString() + } + $sw.Stop() + $result = [pscustomobject]@{ + Iteration = $Iteration + Scenario = $Name + DurationMs = [int]$sw.Elapsed.TotalMilliseconds + DurationS = [math]::Round($sw.Elapsed.TotalSeconds, 2) + Timestamp = (Get-Date).ToString('o') + Error = $err + Module = (Get-Module NerdFonts).Version.ToString() + } + Write-Host ("[$Iteration] Result : {0} -> {1}s" -f $Name, $result.DurationS) -ForegroundColor Green + $result | ConvertTo-Json -Compress | Add-Content -Path $ResultsPath + return $result +} + +$results = [System.Collections.Generic.List[object]]::new() + +# --- Scenario 1: single small/medium font --- +$single = @{ + Name = 'Single-Hack' + Setup = { Invoke-Uninstall -Names 'Hack' } + Action = { Install-NerdFont -Name 'Hack' -Scope CurrentUser -Force } +} +$results.Add((Measure-Scenario @single)) + +# --- Scenario 2: subset of named fonts --- +$subsetArgs = @{ + Name = "Subset-$($Subset -join '+')" + Setup = { Invoke-Uninstall -Names $Subset } + Action = { Install-NerdFont -Name $Subset -Scope CurrentUser -Force } +} +$results.Add((Measure-Scenario @subsetArgs)) + +# --- Scenario 3: re-install when already present (no-op path) --- +$noop = @{ + Name = 'Subset-AlreadyInstalled' + Setup = { } + Action = { Install-NerdFont -Name $Subset -Scope CurrentUser } +} +$results.Add((Measure-Scenario @noop)) + +# --- Scenario 4: full -All (only when explicitly requested) --- +if ($IncludeAll) { + $allArgs = @{ + Name = 'All' + Setup = { Invoke-UninstallAll } + Action = { Install-NerdFont -All -Scope CurrentUser -Force } + } + $results.Add((Measure-Scenario @allArgs)) +} + +Write-Host '' +Write-Host "Summary for iteration '$Iteration':" -ForegroundColor Yellow +$results | Format-Table Iteration, Scenario, DurationS, Module -AutoSize diff --git a/scripts/perf-results.jsonl b/scripts/perf-results.jsonl new file mode 100644 index 0000000..8067d48 --- /dev/null +++ b/scripts/perf-results.jsonl @@ -0,0 +1,32 @@ +{"Iteration":"baseline-1.0.32","Scenario":"Single-Hack","DurationMs":1300,"DurationS":1.3,"Timestamp":"2026-05-17T12:49:25.3421303+02:00","Error":null,"Module":"1.0.32"} +{"Iteration":"baseline-1.0.32","Scenario":"Subset-Hack+FiraCode+JetBrainsMono","DurationMs":6195,"DurationS":6.2,"Timestamp":"2026-05-17T12:49:31.7706509+02:00","Error":null,"Module":"1.0.32"} +{"Iteration":"baseline-1.0.32","Scenario":"Subset-AlreadyInstalled","DurationMs":2808,"DurationS":2.81,"Timestamp":"2026-05-17T12:49:34.6122341+02:00","Error":null,"Module":"1.0.32"} +{"Iteration":"1.0.33-pre001-progressbar","Scenario":"Single-Hack","DurationMs":675,"DurationS":0.67,"Timestamp":"2026-05-17T12:59:23.3818901+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre001-progressbar","Scenario":"Subset-Hack+FiraCode+JetBrainsMono","DurationMs":3583,"DurationS":3.58,"Timestamp":"2026-05-17T12:59:28.5904267+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre001-progressbar","Scenario":"Subset-AlreadyInstalled","DurationMs":2867,"DurationS":2.87,"Timestamp":"2026-05-17T12:59:31.4997320+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre002-dedup","Scenario":"Single-Hack","DurationMs":571,"DurationS":0.57,"Timestamp":"2026-05-17T13:09:00.7799184+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre002-dedup","Scenario":"Subset-Hack+FiraCode+JetBrainsMono","DurationMs":3519,"DurationS":3.52,"Timestamp":"2026-05-17T13:09:05.9546030+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre002-dedup","Scenario":"Subset-AlreadyInstalled","DurationMs":2835,"DurationS":2.83,"Timestamp":"2026-05-17T13:09:08.8327714+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre003-skipinstalled","Scenario":"Single-Hack","DurationMs":703,"DurationS":0.7,"Timestamp":"2026-05-17T13:22:50.2667008+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre003-skipinstalled","Scenario":"Subset-Hack+FiraCode+JetBrainsMono","DurationMs":3559,"DurationS":3.56,"Timestamp":"2026-05-17T13:22:56.1123334+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre003-skipinstalled","Scenario":"Subset-AlreadyInstalled","DurationMs":22,"DurationS":0.02,"Timestamp":"2026-05-17T13:22:56.1734595+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre004-zipfile","Scenario":"Single-Hack","DurationMs":598,"DurationS":0.6,"Timestamp":"2026-05-17T13:31:16.9883423+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre004-zipfile","Scenario":"Subset-Hack+FiraCode+JetBrainsMono","DurationMs":3440,"DurationS":3.44,"Timestamp":"2026-05-17T13:31:22.7322456+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre004-zipfile","Scenario":"Subset-AlreadyInstalled","DurationMs":16,"DurationS":0.02,"Timestamp":"2026-05-17T13:31:22.7860048+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre005-cache-cold","Scenario":"Single-Hack","DurationMs":446,"DurationS":0.45,"Timestamp":"2026-05-17T13:40:07.8376702+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre005-cache-cold","Scenario":"Subset-Hack+FiraCode+JetBrainsMono","DurationMs":3656,"DurationS":3.66,"Timestamp":"2026-05-17T13:40:13.8549945+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre005-cache-cold","Scenario":"Subset-AlreadyInstalled","DurationMs":17,"DurationS":0.02,"Timestamp":"2026-05-17T13:40:13.9101232+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre005-cache-warm","Scenario":"Single-Hack","DurationMs":403,"DurationS":0.4,"Timestamp":"2026-05-17T13:40:14.5276409+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre005-cache-warm","Scenario":"Subset-Hack+FiraCode+JetBrainsMono","DurationMs":3193,"DurationS":3.19,"Timestamp":"2026-05-17T13:40:19.2900547+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre005-cache-warm","Scenario":"Subset-AlreadyInstalled","DurationMs":11,"DurationS":0.01,"Timestamp":"2026-05-17T13:40:19.3360365+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre006-variant-all","Scenario":"Single-Hack","DurationMs":1171,"DurationS":1.17,"Timestamp":"2026-05-17T14:05:52.0905786+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre006-variant-all","Scenario":"Subset-Hack+FiraCode+JetBrainsMono","DurationMs":6096,"DurationS":6.1,"Timestamp":"2026-05-17T14:06:02.0498948+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre006-variant-all","Scenario":"Subset-AlreadyInstalled","DurationMs":35,"DurationS":0.04,"Timestamp":"2026-05-17T14:06:02.1490854+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre006-variant-mono","Scenario":"Subset-Mono","DurationMs":1620,"DurationS":1.62,"Timestamp":"2026-05-17T12:06:18.1677417Z","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre007-parallel","Scenario":"Single-Hack","DurationMs":615,"DurationS":0.61,"Timestamp":"2026-05-17T14:32:21.0280298+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre007-parallel","Scenario":"Subset-Hack+FiraCode+JetBrainsMono","DurationMs":3695,"DurationS":3.7,"Timestamp":"2026-05-17T14:32:24.9507727+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre007-parallel","Scenario":"Subset-AlreadyInstalled","DurationMs":16,"DurationS":0.02,"Timestamp":"2026-05-17T14:32:25.0140743+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre007-parallel-all","Scenario":"Single-Hack","DurationMs":466,"DurationS":0.47,"Timestamp":"2026-05-17T14:34:32.3565252+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre007-parallel-all","Scenario":"Subset-Hack+FiraCode+JetBrainsMono","DurationMs":3821,"DurationS":3.82,"Timestamp":"2026-05-17T14:34:36.3395668+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre007-parallel-all","Scenario":"Subset-AlreadyInstalled","DurationMs":15,"DurationS":0.02,"Timestamp":"2026-05-17T14:34:36.4202031+02:00","Error":null,"Module":"1.0.33"} +{"Iteration":"1.0.33-pre007-parallel-all","Scenario":"All","DurationMs":221809,"DurationS":221.81,"Timestamp":"2026-05-17T14:38:20.2949322+02:00","Error":null,"Module":"1.0.33"} diff --git a/src/functions/public/Install-NerdFont.ps1 b/src/functions/public/Install-NerdFont.ps1 index bece478..856531c 100644 --- a/src/functions/public/Install-NerdFont.ps1 +++ b/src/functions/public/Install-NerdFont.ps1 @@ -63,6 +63,11 @@ function Install-NerdFont { [ValidateSet('CurrentUser', 'AllUsers')] [string] $Scope = 'CurrentUser', + # Select which variant(s) to install from each archive. Default 'All' preserves current behavior. + [Parameter()] + [ValidateSet('All', 'Standard', 'Mono', 'Propo')] + [string] $Variant = 'All', + # Force will overwrite existing fonts [Parameter()] [switch] $Force @@ -76,7 +81,8 @@ Please run the command again with elevated rights (Run as Administrator) or prov '@ throw $errorMessage } - $nerdFontsToInstall = @() + $nerdFontsToInstall = [System.Collections.Generic.List[object]]::new() + $seenNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $guid = (New-Guid).Guid $tempPath = Join-Path -Path $HOME -ChildPath "NerdFonts-$guid" @@ -88,46 +94,237 @@ Please run the command again with elevated rights (Run as Administrator) or prov process { if ($All) { - $nerdFontsToInstall = $script:NerdFonts + foreach ($font in $script:NerdFonts) { + if ($seenNames.Add($font.Name)) { $nerdFontsToInstall.Add($font) } + } } else { foreach ($fontName in $Name) { - $nerdFontsToInstall += $script:NerdFonts | Where-Object { $_.Name -like $fontName } + foreach ($font in $script:NerdFonts) { + if ($font.Name -like $fontName -and $seenNames.Add($font.Name)) { + $nerdFontsToInstall.Add($font) + } + } } } + } + + 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' + } - Write-Verbose "[$Scope] - Installing [$($nerdFontsToInstall.count)] fonts" + $installedFamilies = $null + if (-not $Force) { + $installedNames = @(Get-Font -Scope $Scope -ErrorAction SilentlyContinue | ForEach-Object { $_.Name } | Where-Object { $_ }) + $installedFamilies = [System.Collections.Generic.HashSet[string]]::new( + [string[]]$installedNames, + [System.StringComparer]::OrdinalIgnoreCase + ) + } + $toProcess = [System.Collections.Generic.List[object]]::new() foreach ($nerdFont in $nerdFontsToInstall) { - $URL = $nerdFont.URL $fontName = $nerdFont.Name - $downloadFileName = Split-Path -Path $URL -Leaf - $downloadPath = Join-Path -Path $tempPath -ChildPath $downloadFileName + if (-not $Force -and $installedFamilies) { + $alreadyInstalled = $false + foreach ($family in $installedFamilies) { + if ($family -like "$fontName Nerd Font*") { $alreadyInstalled = $true; break } + } + if ($alreadyInstalled) { + Write-Verbose "[$fontName] - 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() + $readyToInstall = [System.Collections.Generic.List[object]]::new() + $throttle = 8 + + try { + foreach ($nerdFont in $toProcess) { + $URL = $nerdFont.URL + $fontName = $nerdFont.Name + $downloadFileName = Split-Path -Path $URL -Leaf + $downloadPath = Join-Path -Path $tempPath -ChildPath $downloadFileName - Write-Verbose "[$fontName] - Downloading to [$downloadPath]" - if ($PSCmdlet.ShouldProcess("[$fontName] to [$downloadPath]", 'Download')) { - Invoke-WebRequest -Uri $URL -OutFile $downloadPath -RetryIntervalSec 5 -MaximumRetryCount 5 + $cacheTag = if ($URL -match '/releases/download/([^/]+)/') { + $Matches[1] + } else { + 'unknown' + } + $cacheTagDir = Join-Path -Path $cacheRoot -ChildPath $cacheTag + $cachedFile = Join-Path -Path $cacheTagDir -ChildPath $downloadFileName + + 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 + } + $pending.Add($item) + $readyToInstall.Add($item) + } else { + $item = [pscustomobject]@{ + Name = $fontName + URL = $URL + DownloadPath = $downloadPath + CachedFile = $cachedFile + CacheTagDir = $cacheTagDir + FromCache = $false + } + $pending.Add($item) + } + } else { + Write-Verbose "[$fontName] - Queue download to [$downloadPath]" + $item = [pscustomobject]@{ + Name = $fontName + URL = $URL + DownloadPath = $downloadPath + CachedFile = $cachedFile + CacheTagDir = $cacheTagDir + FromCache = $false + } + $pending.Add($item) + } + } + + $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) { + try { + $bytes = $t.Task.GetAwaiter().GetResult() + [System.IO.File]::WriteAllBytes($t.Q.DownloadPath, $bytes) + $readyToInstall.Add($t.Q) + } catch { + Write-Error "[$($t.Q.Name)] - Download failed: $($_.Exception.Message)" + } + } } + } finally { + $httpClient.Dispose() + } + foreach ($p in $readyToInstall) { + $fontName = $p.Name + $downloadPath = $p.DownloadPath $extractPath = Join-Path -Path $tempPath -ChildPath $fontName Write-Verbose "[$fontName] - Extract to [$extractPath]" if ($PSCmdlet.ShouldProcess("[$fontName] to [$extractPath]", 'Extract')) { - Expand-Archive -Path $downloadPath -DestinationPath $extractPath -Force + if (-not (Test-Path -LiteralPath $extractPath)) { + $null = New-Item -ItemType Directory -Path $extractPath + } + [System.IO.Compression.ZipFile]::ExtractToDirectory($downloadPath, $extractPath, $true) + + if (-not $p.FromCache -and (Test-Path -LiteralPath $downloadPath)) { + try { + if (-not (Test-Path -LiteralPath $p.CacheTagDir)) { + $null = New-Item -ItemType Directory -Path $p.CacheTagDir -Force -ErrorAction Stop + } + $tempCachePath = "$($p.CachedFile).$PID.tmp" + Copy-Item -LiteralPath $downloadPath -Destination $tempCachePath -Force -ErrorAction Stop + Move-Item -LiteralPath $tempCachePath -Destination $p.CachedFile -Force -ErrorAction Stop + } catch { + Write-Warning "[$fontName] - Download succeeded but cache write failed: $($_.Exception.Message)" + if (Test-Path -LiteralPath $tempCachePath) { + Remove-Item -LiteralPath $tempCachePath -Force -ErrorAction SilentlyContinue + } + } + } + Remove-Item -Path $downloadPath -Force } + 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*' + } + } + } + $keepNames = [string[]]@($keep.FullName) + $keepSet = [System.Collections.Generic.HashSet[string]]::new( + $keepNames, + [System.StringComparer]::OrdinalIgnoreCase + ) + $removed = 0 + foreach ($f in $allFiles) { + if (-not $keepSet.Contains($f.FullName)) { + Remove-Item -LiteralPath $f.FullName -Force -ErrorAction SilentlyContinue + $removed++ + } + } + 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 -Path $extractPath -Force -Recurse } } - } - end { Write-Verbose "Remove folder [$tempPath]" } clean { - Remove-Item -Path $tempPath -Force + if ($tempPath -and (Test-Path -LiteralPath $tempPath)) { + Remove-Item -LiteralPath $tempPath -Force -Recurse -ErrorAction SilentlyContinue + } } } diff --git a/tests/NerdFonts.Tests.ps1 b/tests/NerdFonts.Tests.ps1 index 741aa40..c20e601 100644 --- a/tests/NerdFonts.Tests.ps1 +++ b/tests/NerdFonts.Tests.ps1 @@ -40,9 +40,340 @@ Describe 'Module' { Get-Font -Name 'Tinos*' | Should -Not -BeNullOrEmpty } - It 'Install-NerdFont - Installs all fonts' { - { Install-NerdFont -All -Verbose } | Should -Not -Throw - Get-Font -Name 'VictorMono*' | Should -Not -BeNullOrEmpty + It 'Install-NerdFont - Continues when one queued download fails' { + $originalFonts = InModuleScope NerdFonts { $script:NerdFonts } + $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 + + $testFonts = @( + [pscustomobject]@{ + Name = 'BrokenDownloadTest' + URL = 'https://github.com/ryanoasis/nerd-fonts/releases/download/v3.4.0/does-not-exist.zip' + }, + $goodFont + ) + InModuleScope NerdFonts -Parameters @{ fonts = $testFonts } { + param($fonts) + $script:NerdFonts = $fonts + } + + try { + Mock -ModuleName NerdFonts Install-Font {} + { Install-NerdFont -Name @('BrokenDownloadTest', 'Tinos') -Force -ErrorAction SilentlyContinue } | Should -Not -Throw + 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 = @( + [pscustomobject]@{ + Name = 'AlreadyInstalledTest' + URL = 'https://example.invalid/already-installed.zip' + } + ) + InModuleScope NerdFonts -Parameters @{ fonts = $testFonts } { + param($fonts) + $script:NerdFonts = $fonts + } + + try { + Mock -ModuleName NerdFonts Get-Font { + [pscustomobject]@{ Name = 'AlreadyInstalledTest Nerd Font' } + } + Mock -ModuleName NerdFonts Install-Font {} + + { Install-NerdFont -Name 'AlreadyInstalledTest' -ErrorAction Stop } | Should -Not -Throw + Should -Invoke -ModuleName NerdFonts Install-Font -Times 0 -Exactly + } finally { + InModuleScope NerdFonts -Parameters @{ fonts = $originalFonts } { + param($fonts) + $script:NerdFonts = $fonts + } + } + } + + It 'Install-NerdFont - Installs a font with -Variant Mono' { + $originalFonts = InModuleScope NerdFonts { $script:NerdFonts } + $loadedFonts = Get-Content -Path (Join-Path $PSScriptRoot '..\src\FontsData.json') | ConvertFrom-Json + $goodFont = $loadedFonts | Where-Object Name -EQ 'Hack' | Select-Object -First 1 + $testFonts = @($goodFont) + InModuleScope NerdFonts -Parameters @{ fonts = $testFonts } { + param($fonts) + $script:NerdFonts = $fonts + } + + try { + Mock -ModuleName NerdFonts Get-Font { @() } + $script:TestCapturedFiles = $null + Mock -ModuleName NerdFonts Install-Font {} -ParameterFilter { + $script:TestCapturedFiles = @( + Get-ChildItem -Path $Path -Recurse -File -Include '*.ttf', '*.otf' | + Select-Object -ExpandProperty Name + ) + $true + } + + { Install-NerdFont -Name 'Hack' -Variant Mono -Force -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*' } + } finally { + InModuleScope NerdFonts -Parameters @{ fonts = $originalFonts } { + param($fonts) + $script:NerdFonts = $fonts + } + } + } + + It 'Install-NerdFont - Installs a font with -Variant Standard' { + $originalFonts = InModuleScope NerdFonts { $script:NerdFonts } + $loadedFonts = Get-Content -Path (Join-Path $PSScriptRoot '..\src\FontsData.json') | ConvertFrom-Json + $goodFont = $loadedFonts | Where-Object Name -EQ 'Hack' | Select-Object -First 1 + $testFonts = @($goodFont) + InModuleScope NerdFonts -Parameters @{ fonts = $testFonts } { + param($fonts) + $script:NerdFonts = $fonts + } + + try { + Mock -ModuleName NerdFonts Get-Font { @() } + $script:TestCapturedFiles = $null + Mock -ModuleName NerdFonts Install-Font {} -ParameterFilter { + $script:TestCapturedFiles = @( + Get-ChildItem -Path $Path -Recurse -File -Include '*.ttf', '*.otf' | + Select-Object -ExpandProperty Name + ) + $true + } + + { Install-NerdFont -Name 'Hack' -Variant Standard -Force -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 '*NerdFont*' + $_ | Should -Not -BeLike '*NerdFontMono*' + $_ | Should -Not -BeLike '*NerdFontPropo*' + } + } finally { + InModuleScope NerdFonts -Parameters @{ fonts = $originalFonts } { + param($fonts) + $script:NerdFonts = $fonts + } + } + } + + It 'Install-NerdFont - Installs a font with -Variant Propo' { + $originalFonts = InModuleScope NerdFonts { $script:NerdFonts } + $loadedFonts = Get-Content -Path (Join-Path $PSScriptRoot '..\src\FontsData.json') | ConvertFrom-Json + $goodFont = $loadedFonts | Where-Object Name -EQ 'Hack' | Select-Object -First 1 + $testFonts = @($goodFont) + InModuleScope NerdFonts -Parameters @{ fonts = $testFonts } { + param($fonts) + $script:NerdFonts = $fonts + } + + try { + Mock -ModuleName NerdFonts Get-Font { @() } + $script:TestCapturedFiles = $null + Mock -ModuleName NerdFonts Install-Font {} -ParameterFilter { + $script:TestCapturedFiles = @( + Get-ChildItem -Path $Path -Recurse -File -Include '*.ttf', '*.otf' | + Select-Object -ExpandProperty Name + ) + $true + } + + { Install-NerdFont -Name 'Hack' -Variant Propo -Force -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 '*NerdFontPropo*' } + } finally { + InModuleScope NerdFonts -Parameters @{ fonts = $originalFonts } { + param($fonts) + $script:NerdFonts = $fonts + } + } + } + + It 'Install-NerdFont - Handles -All without downloading already installed fonts' { + $originalFonts = InModuleScope NerdFonts { $script:NerdFonts } + $testFonts = @( + [pscustomobject]@{ + Name = 'AllPathSmokeTest' + URL = 'https://example.invalid/all-path-smoke.zip' + } + ) + InModuleScope NerdFonts -Parameters @{ fonts = $testFonts } { + param($fonts) + $script:NerdFonts = $fonts + } + + try { + Mock -ModuleName NerdFonts Get-Font { + [pscustomobject]@{ Name = 'AllPathSmokeTest Nerd Font' } + } + Mock -ModuleName NerdFonts Install-Font {} + + { Install-NerdFont -All -Verbose -ErrorAction Stop } | Should -Not -Throw + Should -Invoke -ModuleName NerdFonts Install-Font -Times 0 -Exactly + } finally { + InModuleScope NerdFonts -Parameters @{ fonts = $originalFonts } { + param($fonts) + $script:NerdFonts = $fonts + } + } + } + + It 'Install-NerdFont - Throws when -Scope AllUsers without admin rights' { + Mock -ModuleName NerdFonts IsAdmin { $false } + { Install-NerdFont -Name 'Tinos' -Scope AllUsers -ErrorAction Stop } | Should -Throw '*Administrator*' + } + + It 'Install-NerdFont - Falls back to download when cache read fails' { + $originalFonts = InModuleScope NerdFonts { $script:NerdFonts } + $loadedFonts = Get-Content -Path (Join-Path $PSScriptRoot '..\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' + } + $cacheTag = if ($goodFont.URL -match '/releases/download/([^/]+)/') { $Matches[1] } else { 'unknown' } + $cacheTagDir = Join-Path $cacheRoot $cacheTag + $downloadFileName = Split-Path -Path $goodFont.URL -Leaf + $cachedFile = Join-Path $cacheTagDir $downloadFileName + + # Backup any existing real cache entry to restore after the test + $backupPath = "$cachedFile.test-bak" + $hadExistingCacheRoot = Test-Path -LiteralPath $cacheRoot + $hadExistingCache = Test-Path -LiteralPath $cachedFile + $hadExistingTagDir = Test-Path -LiteralPath $cacheTagDir + if ($hadExistingCache) { + Copy-Item -LiteralPath $cachedFile -Destination $backupPath -Force + } + + $testFonts = @($goodFont) + InModuleScope NerdFonts -Parameters @{ fonts = $testFonts } { + param($fonts) + $script:NerdFonts = $fonts + } + + try { + # Place a regular placeholder file so Test-Path returns true for cache-hit detection + if (-not (Test-Path -LiteralPath $cacheTagDir)) { + $null = New-Item -ItemType Directory -Path $cacheTagDir -Force + } + Set-Content -LiteralPath $cachedFile -Value 'placeholder' + + Mock -ModuleName NerdFonts Get-Font { @() } + Mock -ModuleName NerdFonts Install-Font {} + # Mock Copy-Item to throw only for the cache-read path, simulating + # a locked/unreadable cached file cross-platform. + Mock -ModuleName NerdFonts Copy-Item { + throw 'Simulated cache read failure' + } -ParameterFilter { $LiteralPath -and $LiteralPath -eq $cachedFile } + + # Should not throw — falls back to download + { Install-NerdFont -Name $fontName -Force:$false -ErrorAction Stop } | Should -Not -Throw + Should -Invoke -ModuleName NerdFonts Install-Font -Times 1 -Exactly + } finally { + # Restore original cache state so no user/CI state is mutated + if ($hadExistingCache) { + Move-Item -LiteralPath $backupPath -Destination $cachedFile -Force -ErrorAction SilentlyContinue + } else { + Remove-Item -LiteralPath $cachedFile -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $backupPath -Force -ErrorAction SilentlyContinue + } + if (-not $hadExistingTagDir -and (Test-Path -LiteralPath $cacheTagDir)) { + Remove-Item -LiteralPath $cacheTagDir -Recurse -Force -ErrorAction SilentlyContinue + } + if (-not $hadExistingCacheRoot -and (Test-Path -LiteralPath $cacheRoot)) { + Remove-Item -LiteralPath $cacheRoot -Recurse -Force -ErrorAction SilentlyContinue + } + InModuleScope NerdFonts -Parameters @{ fonts = $originalFonts } { + param($fonts) + $script:NerdFonts = $fonts + } + } + } + + 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' + } + $cacheTagDir = Join-Path -Path $cacheRoot -ChildPath 'test-dedup-v0' + $zipPath = Join-Path -Path $cacheTagDir -ChildPath 'DuplicateMonoTest.zip' + $hadExistingCacheRoot = Test-Path -LiteralPath $cacheRoot + + try { + if (-not (Test-Path -LiteralPath $cacheTagDir)) { + $null = New-Item -ItemType Directory -Path $cacheTagDir -Force + } + + $zipRoot = Join-Path -Path $TestDrive -ChildPath 'dup-zip' + $primaryDir = Join-Path -Path $zipRoot -ChildPath 'Primary' + $compatDir = Join-Path -Path $zipRoot -ChildPath 'Windows Compatible' + $null = New-Item -ItemType Directory -Path $primaryDir -Force + $null = New-Item -ItemType Directory -Path $compatDir -Force + + $fileName = 'DuplicateMonoTestNerdFontMono-Regular.ttf' + Set-Content -Path (Join-Path -Path $primaryDir -ChildPath $fileName) -Value 'primary' + Set-Content -Path (Join-Path -Path $compatDir -ChildPath $fileName) -Value 'compat' + + Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $zipPath) { + Remove-Item -LiteralPath $zipPath -Force + } + [System.IO.Compression.ZipFile]::CreateFromDirectory($zipRoot, $zipPath) + + $testFonts = @( + [pscustomobject]@{ + Name = $fontName + URL = 'https://github.com/ryanoasis/nerd-fonts/releases/download/test-dedup-v0/DuplicateMonoTest.zip' + } + ) + InModuleScope NerdFonts -Parameters @{ fonts = $testFonts } { + param($fonts) + $script:NerdFonts = $fonts + } + + Mock -ModuleName NerdFonts Get-Font { @() } + $script:TestCapturedFiles = $null + Mock -ModuleName NerdFonts Install-Font {} -ParameterFilter { + $script:TestCapturedFiles = @( + Get-ChildItem -Path $Path -Recurse -File -Include '*.ttf', '*.otf' | + Select-Object -ExpandProperty Name + ) + $true + } + + { Install-NerdFont -Name $fontName -Variant Mono -ErrorAction Stop } | Should -Not -Throw + Should -Invoke -ModuleName NerdFonts Install-Font -Times 1 -Exactly + $script:TestCapturedFiles.Count | Should -Be 1 + ($script:TestCapturedFiles | Select-Object -Unique).Count | Should -Be 1 + } finally { + if (Test-Path -LiteralPath $cacheTagDir) { + Remove-Item -LiteralPath $cacheTagDir -Recurse -Force -ErrorAction SilentlyContinue + } + if (-not $hadExistingCacheRoot -and (Test-Path -LiteralPath $cacheRoot)) { + Remove-Item -LiteralPath $cacheRoot -Recurse -Force -ErrorAction SilentlyContinue + } + InModuleScope NerdFonts -Parameters @{ fonts = $originalFonts } { + param($fonts) + $script:NerdFonts = $fonts + } + } } } }