Scripts/Install-Font.ps1

#Requires -Version 4.0

[CmdletBinding(SupportsShouldProcess)]
Param(
    [ValidateNotNullOrEmpty()]
    [String]$Path,

    [ValidateSet('System', 'User')]
    [String]$Scope='System',

    [ValidateSet('Manual', 'Shell')]
    [String]$Method='Manual'
)

Function Get-InstalledFonts {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')]
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory)]
        [ValidateSet('System', 'User')]
        [String]$Scope
    )

    switch ($Scope) {
        'System' { $FontsPath = [Environment]::GetFolderPath('Fonts') }
        'User' { $FontsPath = Join-Path -Path ([Environment]::GetFolderPath('LocalApplicationData')) -ChildPath 'Microsoft\Windows\Fonts' }
    }

    Write-Debug -Message ('Enumerating installed {0} fonts ...' -f $Scope.ToLower())
    [IO.FileInfo[]]$Fonts = @()
    try {
        $Fonts += Get-ChildItem -Path $FontsPath -ErrorAction Stop | Where-Object Extension -in $ValidExts
    } catch {
        $Message = 'Unable to enumerate installed {0} fonts.' -f $Scope.ToLower()
        if ($Scope -eq 'User') {
            Write-Warning -Message $Message
        } else {
            throw $Message
        }
    }

    return $Fonts
}

Function Install-FontManual {
    [CmdletBinding(SupportsShouldProcess)]
    Param(
        [Parameter(Mandatory)]
        [IO.FileInfo[]]$Fonts,

        [Parameter(Mandatory)]
        [ValidateSet('System', 'User')]
        [String]$Scope
    )

    switch ($Scope) {
        'System' {
            $FontsFolder = [Environment]::GetFolderPath('Fonts')
            $FontsRegKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts'
        }
        'User' {
            $FontsFolder = Join-Path -Path ([Environment]::GetFolderPath('LocalApplicationData')) -ChildPath 'Microsoft\Windows\Fonts'
            $FontsRegKey = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts'
        }
    }

    if ($Scope -eq 'User') {
        $null = New-Item -Path $FontsFolder -ItemType Directory -ErrorAction Ignore
        $null = New-Item -Path $FontsRegKey -ErrorAction Ignore
    }

    try {
        Add-Type -AssemblyName PresentationCore -ErrorAction Stop
    } catch {
        throw $_
    }

    foreach ($Font in $Fonts) {
        $FontInstallName = $Font.Name
        $FontInstallPath = Join-Path -Path $FontsFolder -ChildPath $FontInstallName

        # Matches the convention used by the Explorer shell
        if (Test-Path -Path $FontInstallPath) {
            $FontNameSuffix = -1
            do {
                $FontNameSuffix++
                $FontInstallName = '{0}_{1}{2}' -f $Font.BaseName, $FontNameSuffix, $Font.Extension
                $FontInstallPath = Join-Path -Path $FontsFolder -ChildPath $FontInstallName
            } while (Test-Path -Path $FontInstallPath)
        }
        Write-Debug -Message ('[{0}] Font install path: {1}' -f $Font.Name, $FontInstallPath)

        $FontUri = New-Object -TypeName Uri -ArgumentList $Font.FullName
        try {
            $GlyphTypeface = New-Object -TypeName Windows.Media.GlyphTypeface -ArgumentList $FontUri
        } catch {
            Write-Error -Message ('Unable to import font: {0}' -f $Font)
            continue
        }

        $FontNameCulture = 'en-US'
        if ($GlyphTypeface.Win32FamilyNames.ContainsKey($FontNameCulture) -and $GlyphTypeface.Win32FaceNames.ContainsKey($FontNameCulture)) {
            $FontFamilyName = $GlyphTypeface.Win32FamilyNames[$FontNameCulture]
            $FontFaceName = $GlyphTypeface.Win32FaceNames[$FontNameCulture]
        } else {
            Write-Error -Message ('Unable to determine font name culture: {0}' -f $Font)
            continue
        }

        # Matches the convention used by the Explorer shell
        if ($FontFaceName -eq 'Regular') {
            $FontRegistryName = '{0} (TrueType)' -f $FontFamilyName
        } else {
            $FontRegistryName = '{0} {1} (TrueType)' -f $FontFamilyName, $FontFaceName
        }
        Write-Debug -Message ('[{0}] Font registry name: {1}' -f $Font.Name, $FontRegistryName)

        try {
            $FontsRegItem = Get-Item -Path $FontsRegKey -ErrorAction Stop
        } catch {
            throw ('Unable to access {0} fonts registry key.' -f $Scope.ToLower())
        }

        if ($FontsRegItem.Property.Contains($FontRegistryName)) {
            Write-Error -Message ('Font registry name already exists: {0}' -f $FontRegistryName)
            continue
        }

        Write-Verbose -Message ('Installing font manually: {0}' -f $Font.Name)
        Copy-Item -Path $Font.FullName -Destination $FontInstallPath
        $null = New-ItemProperty -Path $FontsRegKey -Name $FontRegistryName -PropertyType String -Value $FontInstallName
    }
}

Function Install-FontShell {
    [CmdletBinding(SupportsShouldProcess)]
    Param(
        [Parameter(Mandatory)]
        [IO.FileInfo[]]$Fonts
    )

    # ShellSpecialFolderConstants enumeration
    # https://docs.microsoft.com/en-us/windows/desktop/api/Shldisp/ne-shldisp-shellspecialfolderconstants
    $ssfFONTS = 20

    # _SHFILEOPSTRUCTA structure
    # https://docs.microsoft.com/en-us/windows/desktop/api/shellapi/ns-shellapi-_shfileopstructa
    $FOF_SILENT = 4
    $FOF_NOCONFIRMATION = 16
    $FOF_NOERRORUI = 1024
    $FOF_NOCOPYSECURITYATTRIBS = 2048

    $ShellApp = New-Object -ComObject Shell.Application
    $FontsFolder = $ShellApp.NameSpace($ssfFONTS)
    $CopyOptions = $FOF_SILENT + $FOF_NOCONFIRMATION + $FOF_NOERRORUI + $FOF_NOCOPYSECURITYATTRIBS

    foreach ($Font in $Fonts) {
        if ($PSCmdlet.ShouldProcess($Font.Name, 'Install font via shell')) {
            Write-Verbose -Message ('Installing font via shell: {0}' -f $Font.Name)
            $FontsFolder.CopyHere($Font.FullName, $CopyOptions)
        }
    }
}

Function Test-IsAdministrator {
    [CmdletBinding()]
    [OutputType([Boolean])]
    Param()

    $User = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
    if ($User.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
        return $true
    }
    return $false
}

Function Test-PerUserFontsSupported {
    [CmdletBinding()]
    [OutputType([Boolean])]
    Param()

    # Windows 10 1809 introduced support for installing fonts per-user without Administrator
    # privileges. The corresponding release build number is 17763 (we ignore Insider builds).
    $BuildNumber = [Int](Get-CimInstance -ClassName Win32_OperatingSystem -Verbose:$false).BuildNumber
    if ($BuildNumber -ge 17763) {
        Write-Debug -Message ('Installing fonts per-user is supported (Windows build: {0}).' -f $BuildNumber)
        return $true
    }

    Write-Debug -Message ('Installing fonts per-user is unsupported (Windows build: {0}).' -f $BuildNumber)
    return $false
}

# Supported font extensions
$script:ValidExts = @('.otf', '.ttf')

# Cache per-user fonts support
$script:PerUserFontsSupported = Test-PerUserFontsSupported

# Validate the install scope and method
if ($Scope -eq 'System') {
    if ($Method -eq 'Shell' -and $PerUserFontsSupported) {
        throw 'Installing fonts system-wide using the Shell API is unsupported on Windows 10 1809 or newer.'
    } elseif (!(Test-IsAdministrator)) {
        throw 'Administrator privileges are required to install fonts system-wide.'
    }
} else {
    if (!$PerUserFontsSupported) {
        throw 'Installing fonts per-user requires Windows 10 1809 or newer.'
    }
}

# Use script location if no path provided
if (!$PSBoundParameters.ContainsKey('Path')) {
    $Path = $PSScriptRoot
}

# Validate the source font path
try {
    $SourceFontPath = Get-Item -Path $Path -ErrorAction Stop
} catch {
    throw ('Provided path is invalid: {0}' -f $Path)
}

# Enumerate fonts to be installed
$SourceFonts = @()
if ($SourceFontPath -is [IO.DirectoryInfo]) {
    $SourceFonts += Get-ChildItem -Path $SourceFontPath | Where-Object -Property Extension -in $ValidExts

    if (!$SourceFonts) {
        throw ('Unable to locate any fonts in provided directory: {0}' -f $SourceFontPath)
    }
} elseif ($SourceFontPath -is [IO.FileInfo]) {
    if ($SourceFontPath.Extension -notin $ValidExts) {
        throw ('Provided file does not appear to be a valid font: {0}' -f $SourceFontPath)
    }

    $SourceFonts += $SourceFontPath
} else {
    throw ('Expected directory or file but received: {0}' -f $SourceFontPath.GetType().Name)
}

# Retrieve already installed fonts
$InstalledFonts = Get-InstalledFonts -Scope $Scope

# Calculate the hash of each installed font
foreach ($Font in $InstalledFonts) {
    $FileHash = Get-FileHash -Path $Font.FullName
    Add-Member -InputObject $Font -MemberType NoteProperty -Name FileHash -Value $FileHash.Hash
}

# Check for already installed fonts
$InstallFonts = @()
foreach ($Font in $SourceFonts) {
    $FileHash = Get-FileHash -Path $Font.FullName

    if ($FileHash.Hash -notin $InstalledFonts.FileHash) {
        $InstallFonts += $Font
    } else {
        Write-Verbose -Message ('Font is already installed: {0}' -f $Font.Name)
    }
}

# Install fonts using selected method
if ($InstallFonts) {
    switch ($Method) {
        'Manual' { Install-FontManual -Fonts $InstallFonts -Scope $Scope }
        'Shell' { Install-FontShell -Fonts $InstallFonts }
    }
}