OfflineLanguageInstaller.psm1


#region private functions
function Test-Administrator {  
    $user = [Security.Principal.WindowsIdentity]::GetCurrent()
    $res = (New-Object Security.Principal.WindowsPrincipal $user).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)  
    
    if (-not $res) {  
        Throw 'This script must be run as an administrator'  
    }
}

function Test-RequiredModules {
    if (-not (Get-Module -Name PSDscResources -ListAvailable | Where-Object { $_.Version -eq '2.12.0.0' })) {
        Throw 'The module PSDscResources version 2.12.0.0 was not installed. Please install the module and try again.'
    }

    if (-not (Get-Module -Name PSDesiredStateConfiguration -ListAvailable | Where-Object { $_.Version -eq '2.0.7' })) {
        Throw 'The module PSDesiredStateConfiguration version 2.0.7 was not installed. Please install the module and try again.'
    }
}

function Get-BasicLanguageCabFile {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string] $LanguageCode,

        [Parameter()]
        [string] $DiskImagePath = 'D:\'
    )

    $cabFiles = Get-ChildItem -Path "$DiskImagePath\LanguagesAndOptionalFeatures" -Recurse -Filter '*.cab'
    $basicCabFile = $cabFiles | Where-Object { $_.Name -match "Microsoft-Windows-LanguageFeatures-Basic-$LanguageCode" } 

    if (-not $basicCabFile) {
        Throw "No basic cab file found for $LanguageCode"
    }

    $files.Add($basicCabFile) | Out-Null
    return $cabFiles
}

function Get-AdditionalLanguageCabFiles {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string] $LanguageCode,

        [Parameter(Mandatory = $true)]
        [string[]] $CabFiles,

        [Parameter(Mandatory = $true)]
        [ValidateSet('TextToSpeech', 'HandWriting', 'OCR', 'Speech')]
        [string[]] $Features
    )

    foreach ($feature in $Features) {
        $pattern = "Microsoft-Windows-LanguageFeatures-$feature-$LanguageCode"
        Write-Verbose -Message "Searching for additional cab file using $pattern"
        $additionalCabFiles = $cabFiles | Where-Object { $_ -match $pattern }

        Write-Verbose $additionalCabFiles

        if (-not $additionalCabFiles) {
            Write-Warning "No additional cab file found for $LanguageCode and $feature"
            continue
        }

        Write-Verbose -Message "Found additional cab file $additionalCabFiles. Adding to collection..."
        $files.Add($additionalCabFiles) | Out-Null
    }
}

function Mount-Iso ($IsoPath) {
    Write-Verbose -Message 'Retrieving current volumes'
    $Volumes = (Get-Volume).Where({ $_.DriveLetter }).DriveLetter
    Write-Verbose -Message "Mounting $IsoPath"
    Mount-DiskImage -ImagePath $IsoPath | Out-Null
    Write-Verbose 'Determining drive letter for ISO'
    $ISO = (Compare-Object -ReferenceObject $Volumes -DifferenceObject (Get-Volume).Where({ $_.DriveLetter }).DriveLetter).InputObject.ToString().Insert(1, ':\')
    return $Iso
}

function Invoke-Dsc {
    [CmdletBinding()]
    param (
        [Parameter()]
        [hashtable]$Property
    )

    $functionInput = @{
        Name       = 'WindowsPackageCab'
        ModuleName = 'PSDscResources'
        Method     = 'Test'
        Property   = $Property
    }

    $testResult = Invoke-DscResource @functionInput

    if ($testResult.InDesiredState) {
        Write-Verbose -Message 'The package is already installed'
        return
    }

    $functionInput.Method = 'Set'

    Write-Verbose -Message 'Invoking DSC resource with the following properties:'
    Write-Verbose -Message ($Property | ConvertTo-Json | Out-String)
    Invoke-DscResource @functionInput
}
#endregion private functions

#region public functions
function Install-LanguageFromIso {
    <#
    .SYNOPSIS
        Install languages on Windows 11 from an ISO file leveraging DISM/DSC
     
    .DESCRIPTION
        The function Install-LanguageFromIso installs language packs on Windows 11 from an ISO file. The function does the following:
         
        * Mounts the ISO file
        * Retrieves the basic language cab file
        * Optionally additional cab files for features like TextToSpeech, Handwriting, OCR, and Speech.
         
        The cab files are copied to a temporary directory and installed using the DSC resource WindowsPackageCab.
     
    .PARAMETER IsoPath
        The path to the ISO file containing the language cab files
     
    .PARAMETER LanguageCode
        The language code of the language to install e.g. en-us
     
    .PARAMETER Features
        The features to install. The following features are supported: TextToSpeech, Handwriting, OCR, and Speech
     
    .PARAMETER AddToUserLanguageList
        Switch to add the language to the user language list
     
    .EXAMPLE
        PS C:\> Install-LanguageFromIso -IsoPath 'C:\ISOs\26100.1.240331-1435.ge_release_amd64fre_CLIENT_LOF_PACKAGES_OEM.iso' -LanguageCode 'en-us' -Features 'TextToSpeech', 'Handwriting'
 
        This example installs the English language pack with the features TextToSpeech and Handwriting from the specified ISO file.
 
    .EXAMPLE
        PS C:\> Install-LanguageFromIso -IsoPath 'C:\ISOs\26100.1.240331-1435.ge_release_amd64fre_CLIENT_LOF_PACKAGES_OEM.iso' -LanguageCode 'en-us'
 
        This example installs the English language pack without any additional features from the specified ISO file.
 
    .EXAMPLE
        PS C:\> Install-LanguageFromIso -IsoPath 'C:\ISOs\26100.1.240331-1435.ge_release_amd64fre_CLIENT_LOF_PACKAGES_OEM.iso' -LanguageCode 'en-GB' -AddToUserLanguageList
 
        This example installs the English (United Kingdom) language pack and adds it to the user language list.
     
    .NOTES
        Author: Gijs Reijn
        Version: 1.0.0
        Date: 2025-01-07
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateScript({
                if (-Not ($_ | Test-Path -PathType Leaf) ) { throw 'The Path argument must be a file. Folder paths are not allowed.' }
                if ($_ -notmatch '\.iso$') { throw 'The file specified in the path argument must be type .iso' }
                return $true
            })]
        [System.IO.FileInfo]
        $IsoPath,

        [Parameter(Mandatory = $true)]
        [string] $LanguageCode,

        [Parameter()]
        [ValidateSet('TextToSpeech', 'Handwriting', 'OCR', 'Speech')]
        [string[]] $Features,

        [Parameter()]
        [switch] $AddToUserLanguageList
    )

    begin {
        Write-Verbose -Message ('Starting {0}' -f $MyInvocation.MyCommand.Name)

        Test-Administrator
        Test-RequiredModules 

        $global:files = [System.Collections.ArrayList]@()
    }

    process {
        # Mount the ISO
        $diskImagePath = Mount-Iso -IsoPath $IsoPath

        # Get the basic language cab file
        $cabFiles = Get-BasicLanguageCabFile -LanguageCode $LanguageCode -DiskImagePath $diskImagePath

        # Get additional language cab files if features are specified
        if ($Features) {
            Get-AdditionalLanguageCabFiles -LanguageCode $LanguageCode -CabFiles $cabFiles -Features $Features
        }

        # Copy the cab files to the destination path
        $destinationPath = Join-Path $env:TEMP -ChildPath 'LanguageCabFiles'
        New-Item -ItemType Directory -Path $destinationPath -Force | Out-Null

        $sourcePaths = foreach ($file in $files) {
            Write-Verbose -Message "Copying $file to $destinationPath"
            Copy-Item -Path $file -Destination $destinationPath -PassThru -Force
        }

        # Dismount the ISO
        Write-Verbose -Message 'Dismounting ISO'
        Dismount-DiskImage -ImagePath $IsoPath -ErrorAction SilentlyContinue | Out-Null

        # Call the DSC resource to install the cab files
        foreach ($sourcePath in $sourcePaths) {
            $property = @{
                Name       = $sourcePath.Name
                Ensure     = 'Present'
                SourcePath = $sourcePath.FullName
                LogPath    = (Join-Path -Path $env:TEMP -ChildPath 'WindowsPackageCab.log')
            }

            if ($PSCmdlet.ShouldProcess($sourcePath, 'Install')) {
                Invoke-Dsc -Property $property
            }
        }

        if ($AddToUserLanguageList.IsPresent) {
            $currentList = Get-WinUserLanguageList
            Write-Verbose -Message "Adding $LanguageCode to the user language list"
            $currentList.Add($LanguageCode)

            # Set the user language list
            Set-WinUserLanguageList -LanguageList $currentList -Force
        }
    }
    
    end {
        Write-Verbose -Message ('Ending {0}' -f $MyInvocation.MyCommand)
    }
}
#endregion public functions