RDCManager.psm1

using namespace System.Xml.Linq
using assembly System.Xml.Linq

function InitializeModule {
    Set-RdcConfiguration -Reset
}


function GetAdsiComputer {
    <#
    .SYNOPSIS
        Get an computer object using ADSI.
    .DESCRIPTION
        These basic ADSI commands allow the RdcMan document generator to be used without the MS AD module.
 
        Use of the internal commands is optional. If used, all filters must be written as LDAP filter.
    #>


    [CmdletBinding()]
    param (
        # A filter describing the computers units to find.
        [String]$Filter,

        # The search base for this search.
        [String]$SearchBase,

        # The search scope for the search operation.
        [System.DirectoryServices.SearchScope]$SearchScope,

        # Limit the number of results returned by a search. By default result set size is unlimited.
        [Int32]$ResultSetSize,

        # The server to use to execute the search.
        [String]$Server,

        # Credentials to use when connecting to the server.
        [PSCredential]$Credential
    )

    if ($Filter -eq '*' -or -not $Filter) {
        $psboundparameters['Filter'] = '(&(objectCategory=computer)(objectClass=computer))'
    } else {
        $psboundparameters['Filter'] = '(&(objectCategory=computer)(objectClass=computer){0})' -f $Filter
    }

    GetAdsiObject -Properties 'name', 'description', 'dnsHostName' @psboundparameters
}


function GetAdsiObject {
    <#
    .SYNOPSIS
        Get an arbitrary object using ADSI.
    .DESCRIPTION
        These basic ADSI commands allow the RdcMan document generator to be used without the MS AD module.
 
        Use of the internal commands is optional. If used, all filters must be written as LDAP filter.
    #>


    [CmdletBinding()]
    param (
        # A filter describing the computers units to find.
        [Parameter(Mandatory)]
        [String]$Filter,

        # A list of properties to retrieve
        [String[]]$Properties = 'distinguishedName',

        # The search base for this search.
        [String]$SearchBase,

        # The search scope for the search operation.
        [System.DirectoryServices.SearchScope]$SearchScope,

        # Limit the number of results returned by a search. By default result set size is unlimited.
        [Int32]$ResultSetSize,

        # The server to use to execute the search.
        [String]$Server,

        # Credentials to use when connecting to the server.
        [PSCredential]$Credential
    )

    $params = @{}
    if ($Server) { $params.Add('Server', $Server) }
    if ($Credential) { $params.Add('Credential', $Credential) }

    $params = @{}
    if ($Server) { $params.Add('Server', $Server) }
    if ($Credential) { $params.Add('Credential', $Credential) }

    if (-not $SearchBase) {
        $SearchBase = (GetAdsiRootDse @params).defaultNamingContext
    }
    $adsiSearchBase = NewDirectoryEntry -DistinguishedName $SearchBase @params

    $searcher = [ADSISearcher]@{
        Filter      = $Filter
        SearchRoot  = $adsiSearchBase
        SearchScope = $SearchScope
        PageSize    = 1000
    }
    $searcher.PropertiesToLoad.AddRange($Properties)

    if ($ResultSetSize) {
        $searcher.SizeLimit = $ResultSetSize
    }

    Write-Debug 'SEARCHER:'
    Write-Debug (' Filter : {0}' -f $Filter)
    Write-Debug (' SearchBase : {0}' -f $SearchBase)
    Write-Debug (' SearchScope: {0}' -f $SearchScope)

    foreach ($searchResult in $searcher.FindAll()) {
        $objectProperties = @{}
        foreach ($property in $Properties) {
            $objectProperties.Add($property, $searchResult.Properties[$property][0])
        }
        [PSCustomObject]$objectProperties
    }
}


function GetAdsiOrganizationalUnit {
    <#
    .SYNOPSIS
        Get an organization unit object using ADSI.
    .DESCRIPTION
        These basic ADSI commands allow the RdcMan document generator to be used without the MS AD module.
 
        Use of the internal commands is optional. If used, all filters must be written as LDAP filter.
    #>


    [CmdletBinding(DefaultParameterSetName = 'UsingFilter')]
    param (
        # A filter describing the organizational units to find.
        [Parameter(ParameterSetName = 'UsingFilter')]
        [String]$Filter,

        # Use identity instead of a filter to locate the OU.
        [Parameter(ParameterSetName = 'ByIdentity')]
        [String]$Identity,

        # The search base for this search.
        [String]$SearchBase,

        # The search scope for the search operation.
        [System.DirectoryServices.SearchScope]$SearchScope,

        # The server to use to execute the search.
        [String]$Server,

        # Credentials to use when connecting to the server.
        [PSCredential]$Credential
    )

    if ($Identity) {
        $psboundparameters['Filter'] = '(&(objectClass=organizationalUnit)(distinguishedName={0}))' -f $Identity
        $psboundparameters['SearchScope'] = 'Subtree'
        $null = $psboundparameters.Remove('Identity')
    } elseif ($Filter -eq '*' -or -not $Filter) {
        $psboundparameters['Filter'] = '(objectClass=organizationalUnit)'
    } else {
        $psboundparameters['Filter'] = '(&(objectClass=organizationalUnit){0})' -f $Filter
    }

    GetAdsiObject -Properties 'name', 'description', 'distinguishedName' @psboundparameters
}


function GetAdsiRootDse {
    <#
    .SYNOPSIS
        Get a RootDSE node using ADSI.
    .DESCRIPTION
        These basic ADSI commands allow the RdcMan document generator to be used without the MS AD module.
 
        Use of the internal commands is optional. If used, all filters must be written as LDAP filter.
    #>


    [CmdletBinding()]
    param (
        # The server to use for the ADSI connection.
        [String]$Server,

        # Credentials to use when connecting to the server.
        [PSCredential]$Credential
    )

    $rootDSE = NewDirectoryEntry -DistinguishedName 'RootDSE' @psboundparameters
    $properties = @{}
    foreach ($property in $rootDSE.Properties.Keys) {
        $properties.Add($property, $rootDSE.Properties[$property])
    }
    [PSCustomObject]$properties
}


function NewDirectoryEntry {
    <#
    .SYNOPSIS
        Creates a System.DirectoryServices.DirectoryEntry object.
    .DESCRIPTION
        Creates a System.DirectoryServices.DirectoryEntry object.
    #>


    [CmdletBinding()]
    param (
        # The distinguished name to connect to.
        [String]$DistinguishedName,

        # The server used for the connection.
        [String]$Server,

        # Any credentials which should be used.
        [PSCredential]$Credential
    )

    if ($Server) {
        $Path = 'LDAP://{0}/{1}' -f $Server, $DistinguishedName
    } else {
        $Path = 'LDAP://{0}' -f $DistinguishedName
    }
    if ($Credential) {
        [ADSI]::new($Path, $Credential.Username, $Credential.GetNetworkCredential().Password)
    } else {
        [ADSI]::new($Path)
    }
}


function GetADComputer {
    <#
    .SYNOPSIS
        Use either the ActiveDirectory module or ADSI to find computer objects.
    .DESCRIPTION
        Use either the ActiveDirectory module or ADSI to find computer objects.
    #>


    [CmdletBinding(DefaultParameterSetName = 'UsingFilter')]
    param (
        # A filter to use for the search. If using the ActiveDirectory module this can either be an LDAP filter, or the specialised form used by the ActiveDirectory module.
        [Parameter(ParameterSetName = 'UsingFilter')]
        [String]$Filter,

        # When searching by name the names are assembled into a filter for each name using the OR operator.
        [Parameter(ParameterSetName = 'ByName')]
        [String[]]$Name,

        # A searchbase to use. If a search base is not set, the root of the current domain is used.
        [String]$SearchBase,

        # The search scope for the search operation.
        [System.DirectoryServices.SearchScope]$SearchScope,

        # Limit the number of results returned by a search. By default result set size is unlimited.
        [Int32]$ResultSetSize,

        # The server to use for the search.
        [String]$Server,

        # Credentials to use when connecting to the server.
        [PSCredential]$Credential,

        # The filter format to use.
        [String]$FilterFormat = (Get-RdcConfiguration -Name FilterFormat)
    )

    $null = $psboundparameters.Remove('FilterFormat')
    if ($pscmdlet.ParameterSetName -eq 'ByName') {
        $null = $psboundparameters.Remove('Name')

        $FilterFormat = 'LDAP'
        $nameFilters = foreach ($value in $Name) {
            '(name={0})' -f $value
        }
        $Filter = '(|{0})' -f (-join $nameFilters)
        $psboundparameters.Add('Filter', $Filter)
    }

    if (Get-RdcConfiguration -Name SearchMode -Eq ADModule) {
        if ($FilterFormat -eq 'LDAP') {
            $null = $psboundparameters.Remove('Filter')
            $psboundparameters.Add('LdapFilter', $Filter)
        }
        Get-ADComputer -Properties dnsHostName, displayName @psboundparameters
    } else {
        GetAdsiComputer @psboundparameters
    }
}


function GetADObject {
    <#
    .SYNOPSIS
        Use either the ActiveDirectory module or ADSI to find arbitrary objects.
    .DESCRIPTION
        Use either the ActiveDirectory module or ADSI to find arbitrary objects.
    #>


    [CmdletBinding()]
    param (
        # A filter to use for the search. If using the ActiveDirectory module this can either be an LDAP filter, or the specialised form used by the ActiveDirectory module.
        [String]$Filter,

        # A searchbase to use. If a search base is not set, the root of the current domain is used.
        [String]$SearchBase,

        # The search scope for the search operation.
        [System.DirectoryServices.SearchScope]$SearchScope,

        # The server to use for the search.
        [String]$Server,

        # Credentials to use when connecting to the server.
        [PSCredential]$Credential
    )

    $null = $psboundparameters.Remove('FilterFormat')
    if ($pscmdlet.ParameterSetName -eq 'ByName') {
        $null = $psboundparameters.Remove('Name')

        $FilterFormat = 'LDAP'
        $nameFilters = foreach ($value in $Name) {
            '(name={0})' -f $value
        }
        $Filter = '(|{0})' -f (-join $nameFilters)
        $psboundparameters.Add('Filter', $Filter)
    }

    if (Get-RdcConfiguration -Name SearchMode -Eq ADModule) {
        if ($FilterFormat -eq 'LDAP') {
            $null = $psboundparameters.Remove('Filter')
            $psboundparameters.Add('LdapFilter', $Filter)
        }
        Get-ADComputer @psboundparameters
    } else {
        GetAdsiObject @psboundparameters
    }
}


function GetADOrganizationalUnit {
    <#
    .SYNOPSIS
        Use either the ActiveDirectory module or ADSI to find organizational unit objects.
    .DESCRIPTION
        Use either the ActiveDirectory module or ADSI to find organizational unit objects.
    #>


    [CmdletBinding()]
    param (
        [Parameter(ParameterSetName = 'ByName')]
        [String]$Name,

        # A filter to use for the search. If using the ActiveDirectory module this can either be an LDAP filter, or the specialised form used by the ActiveDirectory module.
        [Parameter(ParameterSetName = 'UsingFilter')]
        [String]$Filter,

        # Use identity instead of a filter to locate the OU.
        [Parameter(ParameterSetName = 'ByIdentity')]
        [String]$Identity,

        # A searchbase to use. If a search base is not set, the root of the current domain is used.
        [String]$SearchBase,

        # The search scope for the search operation.
        [System.DirectoryServices.SearchScope]$SearchScope,

        # The server to use for the search.
        [String]$Server,

        # Credentials to use when connecting to the server.
        [PSCredential]$Credential,

        # The filter format to use.
        [String]$FilterFormat = (Get-RdcConfiguration -Name FilterFormat)
    )

    if ($pscmdlet.ParameterSetName -eq 'ByName') {
        $null = $psboundparameters.Remove('Name')

        $FilterFormat = 'LDAP'
        $Filter = '(name={0})' -f $Name
        $psboundparameters.Add('Filter', $Filter)
    }

    if (-not $SearchBase) {
        $null = $psboundparameters.Remove('SearchBase')
    }

    if (Get-RdcConfiguration -Name SearchMode -Eq ADModule) {
        if ($FilterFormat -eq 'LDAP') {
            $null = $psboundparameters.Remove('Filter')
            $psboundparameters.Add('LdapFilter', $Filter)
        }
        Get-ADOrganizationalUnit @psboundparameters
    } else {
        GetAdsiOrganizationalUnit @psboundparameters
    }
}


function Get-RdcConfiguration {
    <#
    .SYNOPSIS
        Get the configuration for the document generator.
    .DESCRIPTION
        Get the configuration for the document generator.
    #>


    [CmdletBinding()]
    param (
        # Get a specific configuration value by name.
        [String]$Name,

        # Get a configuration value and test whether or not it is equal to the specified value.
        [Object]$Eq
    )

    if ($Name -and $psboundparameters.ContainsKey('Eq')) {
        $script:Configuration.$Name -eq $Eq
    } elseif ($Name) {
        $Script:configuration.$Name
    } else {
        $Script:configuration
    }
}


function Set-RdcConfiguration {
    <#
    .SYNOPSIS
        Set the configuration for the document generator.
    .DESCRIPTION
        Sets the configuration used by the document generator.
    #>


    [CmdletBinding()]
    param (
        # Set the search mode used when building content from AD.
        #
        # The following values may be set:
        #
        # - ADModule: Uses the MS ActiveDirectory module.
        # - ADSI: Uses the ADSI search commands in this module.
        #
        # The default search mode is ADModule if the ActiveDirectory module is available on the computer. Otherwise the search mode defaults to ADSI.
        #
        # If the ActiveDirectory module is made available using implicit remoting this option must be set.
        [Parameter(ParameterSetName = 'Update')]
        [ValidateSet('ADModule', 'ADSI')]
        [String]$SearchMode,

        # The format used for filters. By default LDAP format is used when the search mode is ADSI. The ActiveDirectory format is used if the module is used.
        [Parameter(ParameterSetName = 'Update')]
        [ValidateSet('ADModule', 'LDAP')]
        [String]$FilterFormat,

        # Reset the configuration to the default.
        [Parameter(ParameterSetName = 'Reset')]
        [Switch]$Reset
    )

    if ($pscmdlet.ParameterSetName -eq 'Reset') {
        [Boolean]$isADModulePresent = Get-Module ActiveDirectory -ListAvailable

        $Script:configuration = [PSCustomObject]@{
            SearchMode   = ('ADSI', 'ADModule')[$isADModulePresent]
            FilterFormat = ('LDAP', 'ADModule')[$isADModulePresent]
        }
    } else {
        if ($SearchMode -and -not $FilterFormat) {
            if ($SearchMode -eq 'ADSI') {
                $psboundparameters['FilterFormat'] = 'LDAP'
            } else {
                $psboundparameters['FilterFormat'] = 'ADModule'
            }
        }

        foreach ($parameterName in $psboundparameters.Keys) {
            if ($Script:configuration.PSObject.Properties.Item($parameterName)) {
                $Script:configuration.$parameterName = $psboundparameters[$parameterName]
            }
        }
    }
}


function ADConfiguration {
    <#
    .SYNOPSIS
        Set the AD any AD configuration which should be used when searching Active Directory.
    .DESCRIPTION
        The ADConfiguration element provides default values for AD search operations in child scopes.
 
        The ADConfiguration element is expected to be used in RdcDocument or RdcGroup elements.
    #>


    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateScript(
            {
                if ($_.ContainsKey('Credential') -and $_['Credential'] -isnot [PSCredential]) {
                    throw 'The credential key was present, but the value is not a credential object.'
                }
                foreach ($key in $_.Keys) {
                    if ($key -notin 'Server', 'Credential') {
                        throw ('Invalid key in the ADConfigurastion hashtable. Valid keys are Server and Credential')
                    }
                }
                $true
            }
        )]
        [Hashtable]$ADConfiguration
    )

    try {
        # Get the value of the parentNode variable from the parent scope(s)
        $parentNode = Get-Variable currentNode -ValueOnly -ErrorAction Stop
    } catch {
        throw ('{0} must be nested in RdcDocument or RdcGroup: {1}' -f $myinvocation.InvocationName, $_.Exception.Message)
    }

    foreach ($key in $ADConfiguration.Keys) {
        New-Variable -Name ('RdcAD{0}' -f $key) -Value $ADConfiguration[$key] -Scope 1 -Force
    }
}


function RdcADComputer {
    <#
    .SYNOPSIS
        Creates a set of computers under a document or group.
    .DESCRIPTION
        RdcADComputer is used to create computer objects based on a search of Active Directory.
    #>


    [CmdletBinding(DefaultParameterSetName = 'UsingFilter')]
    param (
        # The filter which will be used to find computers.
        [Parameter(Position = 1, ParameterSetName = 'UsingFilter')]
        [String]$Filter = '*',

        # When searching by name the names are assembled into a filter for each name using the OR operator.
        [Parameter(ParameterSetName = 'ByName')]
        [String[]]$Name,

        # The search base. By default the search is performed from the root of the current domain.
        [String]$SearchBase,

        # The server to use for this operation.
        [String]$Server = (Get-Variable RdcADServer -ValueOnly -ErrorAction SilentlyContinue),

        # Credentials to use when connecting to active directory.
        [PSCredential]$Credential = (Get-Variable RdcADCredential -ValueOnly -ErrorAction SilentlyContinue),

        # If recurse is set computer objects from child OUs will be added to the parent group.
        [Switch]$Recurse
    )

    if (-not $psboundparameters.ContainsKey('SearchBase')) {
        if ($candidateDN = Get-Variable parentDN -ValueOnly -ErrorAction SilentlyContinue) {
            $SearchBase = $candidateDN
        }
    }

    $params = @{
        SearchBase  = $SearchBase
        SearchScope = ('OneLevel', 'Subtree')[$Recurse.ToBool()]
    }
    if ($Name) {
        $params.Add('Name', $Name)
    } else {
        $params.Add('Filter', $Filter)
    }
    if ($Server) {
        $params.Add('Server', $Server)
    }
    if ($Credential) {
        $params.Add('Credential', $Credential)
    }

    GetADComputer @params |
        Sort-Object Name |
        RdcComputer
}


function RdcADGroup {
    <#
    .SYNOPSIS
        Create a group node derived from the content of an organisational unit.
    .DESCRIPTION
        Create a group node derived from the content of an organisational unit.
    #>


    [CmdletBinding(DefaultParameterSetName = 'UsingFilter')]
    param (
        # The identity of a single OU.
        [Parameter(Mandatory, Position = 1, ParameterSetName = 'ByName')]
        [String]$Name,

        # A filter for OU objects.
        [Parameter(ParameterSetName = 'UsingFilter')]
        [String]$Filter = '*',

        # The identity of a single OU.
        [Parameter(Mandatory, ParameterSetName = 'ByIdentity')]
        [String]$Identity,

        # A filter to apply when evaluating descendent computer objects.
        [String]$ComputerFilter = '*',

        # The search base to use when using a filter.
        [Parameter(ParameterSetName = 'UsingFilter')]
        [String]$SearchBase,

        # The server to use for this operation.
        [String]$Server = (Get-Variable RdcADServer -ValueOnly -ErrorAction SilentlyContinue),

        # Credentials to use when connecting to active directory.
        [PSCredential]$Credential = (Get-Variable RdcADCredential -ValueOnly -ErrorAction SilentlyContinue),

        # If Recurse is set, groups will be created in the RDC document reprsenting each child organisational unit.
        #
        # Organizational units are only included as groups if the oganizational unit contains computer accounts or other organizational units.
        [Switch]$Recurse
    )

    if (-not $psboundparameters.ContainsKey('SearchBase')) {
        if ($candidateDN = Get-Variable parentDN -ValueOnly -ErrorAction SilentlyContinue) {
            $SearchBase = $candidateDN
        }
    }

    if ($pscmdlet.ParameterSetName -eq 'ByIdentity') {
        $params = @{
            Identity = $Identity
        }
    } elseif ($pscmdlet.ParameterSetName -eq 'ByName') {
        $params = @{
            Name        = $Name
            SearchBase  = $SearchBase
            SearchScope = 'Subtree'
        }
    } else {
        $params = @{
            Filter      = $Filter
            SearchBase  = $SearchBase
            SearchScope = 'OneLevel'
        }
    }

    $serverAndCredential = @{}
    if ($Server) {
        $serverAndCredential.Add('Server', $Server)
    }
    if ($Credential) {
        $serverAndCredential.Add('Credential', $Credential)
    }

    GetADOrganizationalUnit @params @serverAndCredential | ForEach-Object {
        # Determine if the OU has child objects. If so, allow it to be included.
        Write-Debug 'Searching for child computer objects'
        Write-Debug (' SearchBase: {0}' -f $_.DistinguishedName)

        $params = @{
            Filter        = '*'
            SearchBase    = $_.DistinguishedName
            SearchScope   = 'Subtree'
            ResultSetSize = 1
        }
        if (GetADComputer @params @serverAndCredential) {
            Write-Verbose ('Creating group {0}' -f $_.Name)

            $parentDN = $_.DistinguishedName
            if ($Recurse) {
                RdcGroup $_.Name {
                    RdcADGroup -Recurse -ComputerFilter $ComputerFilter @serverAndCredential
                    RdcADComputer -Filter $ComputerFilter @serverAndCredential
                }
            } else {
                RdcGroup $_.Name {
                    RdcADComputer -Filter $ComputerFilter @serverAndCredential -Recurse
                }
            }
        }
    }
}


function RdcComputer {
    <#
    .SYNOPSIS
        Create a computer in the RDCMan document.
    .DESCRIPTION
        Create a computer in the RDCMan document.
    #>


    [CmdletBinding(DefaultParameterSetName = 'FromPipeline')]
    param (
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'FromPipeline')]
        [String]$Name,

        [Parameter(Position = 2, ValueFromPipelineByPropertyName, ParameterSetName = 'FromPipeline')]
        [String]$DnsHostName,

        [Parameter(Position = 3, ValueFromPipelineByPropertyName, ParameterSetName = 'FromPipeline')]
        [Alias('IPv4Address')]
        [String]$Comment,

        [Parameter(Mandatory, Position = 1, ParameterSetName = 'FromHashtable')]
        [ValidateScript(
            {
                if (-not $_.ContainsKey('Name')) {
                    throw 'The Name key must be present'
                }
                foreach ($key in $_.Keys) {
                    if ($key -notin 'Name', 'DnsHostName', 'Comment') {
                        throw ('Invalid key in Properties hashtable. Valid keys are Name, DnsHostName, and Comment')
                    }
                }
                $true
            }
        )]
        [Hashtable]$Properties
    )

    begin {
        try {
            # Get the value of the parentNode variable from the parent scope(s)
            $parentNode = Get-Variable currentNode -ValueOnly -ErrorAction Stop
        } catch {
            throw ('{0} must be nested in RdcDocument or RdcGroup: {1}' -f $myinvocation.InvocationName, $_.Exception.Message)
        }
    }

    process {
        if ($Properties) {
            $Name = $Properties.Name
            $DnsHostName = $Properties.DnsHostName
            $Comment = $Properties.Comment
        }
        if (-not $DnsHostName) {
            $DnsHostName = $Name
        }

        $xElement = [System.Xml.Linq.XElement]('
            <server>
                <properties>
                    <displayname>{0}</displayname>
                    <name>{1}</name>
                    <comment>{2}</comment>
                </properties>
            </server>'
 -f $Name, $DnsHostName, $Comment)

        if ($parentNode -is [System.Xml.Linq.XDocument]) {
            $parentNode.Element('Rdc').Element('connected').AddBeforeSelf($xElement)
        } else {
            $parentNode.Element('properties').AddAfterSelf($xElement)
        }
    }
}


function RdcConfiguration {
    <#
    .SYNOPSIS
 
    .DESCRIPTION
        RdcConfiguration allows the generator behaviours to be defined using a node in the document.
    .EXAMPLE
        RdcDocument name {
            RdcConfiguration @{
                SearchMode = 'ADSI'
            }
        }
    #>


    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [Hashtable]$Configuration
    )

    Set-RdcConfiguration @Configuration
}


function RdcDocument {
    <#
    .SYNOPSIS
        Declare an RDCMan document.
    .DESCRIPTION
        An RDC Document defines the basic document content and is the starting point for creating groups and computer elements.
    #>


    [CmdletBinding()]
    param (
        # The path to a file to save content.
        [Parameter(Mandatory, Position = 1)]
        [Alias('FileName', 'FullName')]
        [String]$Path,

        # A script block defining the content of the document.
        [Parameter(Mandatory, Position = 2)]
        [ScriptBlock]$Children
    )

    $xDocument = $currentNode = [System.Xml.Linq.XDocument]::Parse('
        <?xml version="1.0" encoding="utf-8"?>
        <Rdc programVersion="2.7" schemaVersion="3">
            <file>
                <credentialsProfiles />
                <properties>
                    <name>{0}</name>
                </properties>
            </file>
            <connected />
            <favorites />
            <recentlyUsed />
        </Rdc>'
.Trim() -f ([System.IO.FileInfo]$Path).BaseName)

    if ($Children) {
        & $Children
    }

    if ($Path -notmatch '\.rdg$') {
        $Path = '{0}.rdg' -f $Path
    }
    $Path = $pscmdlet.GetUnresolvedProviderPathFromPSPath($Path)
    $xDocument.Save($Path)
}


function RdcGroup {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline, Position = 1)]
        [String]$Name,

        [Parameter(Mandatory, Position = 2)]
        [ScriptBlock]$Children
    )

    try {
        # Get the value of the parentNode variable from the parent scope(s)
        $parentNode = Get-Variable currentNode -ValueOnly -ErrorAction Stop
    } catch {
        throw ('{0} must be nested in RdcDocument or RdcGroup: {1}' -f $myinvocation.InvocationName, $_.Exception.Message)
    }

    $xElement = $currentNode = [System.Xml.Linq.XElement]::new('group',
        [System.Xml.Linq.XElement]::new('properties',
            [System.Xml.Linq.XElement]::new('name', $Name)
        )
    )

    if ($parentNode -is [System.Xml.Linq.XDocument]) {
        $parentNode.Element('Rdc').Element('file').Add($xElement)
    } else {
        $parentNode.Add($xElement)
    }

    if ($Children) {
        & $Children
    }
}


function RdcLogonCredential {
    <#
    .SYNOPSIS
        Creates a node to save credentials in the parent group or document.
    .DESCRIPTION
        Creates a node to save credentials in the parent group or document.
    #>


    [CmdletBinding(DefaultParameterSetName = 'FromHashtable')]
    param (
        [Parameter(Position = 1, ParameterSetName = 'FromHashtable')]
        [ValidateScript(
            {
                if ($_.Contains('Password') -and $_['Password'] -isnot [SecureString]) {
                    throw 'Passwords must be stored as a secure string'
                }
                foreach ($key in $_.Keys) {
                    if ($key -notin 'Username', 'Password', 'Domain') {
                        throw ('Invalid key in the RdcLogonCredentials hashtable. Valid keys are UserName, Password, and Domain')
                    }
                }
                $true
            }
        )]
        [Hashtable]$CredentialHash,

        [Parameter(ParameterSetName = 'FromCredential')]
        [PSCredential]$Credential,

        [Switch]$SavePassword
    )

    try {
        # Get the value of the parentNode variable from the parent scope(s)
        $parentNode = Get-Variable currentNode -ValueOnly -ErrorAction Stop
    } catch {
        throw ('{0} must be nested in RdcDocument or RdcGroup: {1}' -f $myinvocation.InvocationName, $_.Exception.Message)
    }

    if ($Credential) {
        if ($Credential.Username.Contains('\')) {
            $domainName, $username = $Credential.UserName -split '\\', 2
        } else {
            $domainName = ''
            $userName = $Credential.UserName
        }
        $secureString = $Credential.Password
    } else {
        $domainName = $CredentialHash['Domain']
        $userName = $CredentialHash['UserName']
        $secureString = $CredentialHash['Password']
    }

    if ($secureString.Length -gt 0) {
        $encryptedHexString = $secureString | ConvertFrom-SecureString
        $bytes = for ($i = 0; $i -lt $encryptedHexString.Length; $i += 2) {
            [Convert]::ToByte(
                ('{0}{1}' -f $encryptedHexString[$i], $encryptedHexString[$i + 1]),
                16
            )
        }
        $encryptedPassword = [Convert]::ToBase64String($bytes)
    } else {
        $encryptedPassword = ''
    }

    # V2: BigInteger variation

    # Add-Type -AssemblyName System.Numerics
    # $bytes = [System.Numerics.BigInteger]::Parse(
    # ($secureString | ConvertFrom-SecureString),
    # 'HexNumber'
    # ).ToByteArray()
    # [Array]::Reverse($bytes)

    # $encryptedString = [Convert]::ToBase64String($bytes)

    # [RdcMan.Encryption]::DecryptString($encryptedString, [RdcMan.EncryptionSettings]::new())

    # V3: Decrypt and reencrypt

    # $encryptedString = [Convert]::ToBase64String(
    # [System.Security.Cryptography.ProtectedData]::Protect(
    # [System.Text.Encoding]::Unicode.GetBytes(
    # $Credential.GetNetworkCredential().Password
    # ),
    # $null,
    # 'CurrentUser'
    # )
    # )

    $xElement = [System.Xml.Linq.XElement]('
        <logonCredentials inherit="None">
            <profileName scope="Local">Custom</profileName>
            <userName>{0}</userName>
            <password>{1}</password>
            <domain>{2}</domain>
        </logonCredentials>'
 -f $username, $encryptedPassword, $domainName)

    if ($parentNode.NodeType -eq 'Document') {
        $propertiesElement = $parentNode.FirstNode.Element('file').Element('properties')
    }
    else {
        $propertiesElement = $parentNode.Element('properties')
    }
    $propertiesElement.AddAfterSelf($xElement)
}


function RdcRemoteDesktopSetting {
    <#
    .SYNOPSIS
        Creates a node to configure remote desktop settings in the parent group or document.
    .DESCRIPTION
        Creates a node to configure remote desktop settings in the parent group or document.
    #>


    [CmdletBinding(DefaultParameterSetName = 'FromHashtable')]
    param (
        # Remote Destkop Settings configuration.
        #
        # Remote destkop settings allows the following to be defined:
        #
        # - Size - A value in the form Horizontal x Vertical.
        # - SameSizeAsClientArea - True or False. Make the remote desktop area fill the client window pane.
        # - FullScreen - True or False. Make the remote desktop full screen.
        # - ColorDepth - By default 24. ColorDepth can be set to 8, 15, 16, 24, or 32.
        [Parameter(Position = 1, ParameterSetName = 'FromHashtable')]
        [ValidateScript(
            {
                foreach ($key in $_.Keys) {
                    if ($key -notin 'Size', 'SameSizeAsClientArea', 'FullScreen', 'ColorDepth') {
                        throw ('Invalid key in the RdcLogonCredentials hashtable. Valid keys are Size, SameSizeAsClientArea, FullScreen, and ColorDepth')
                    }
                }
                $true
            }
        )]
        [Hashtable]$SettingsHash
    )

    try {
        # Get the value of the parentNode variable from the parent scope(s)
        $parentNode = Get-Variable currentNode -ValueOnly -ErrorAction Stop
    } catch {
        throw ('{0} must be nested in RdcDocument or RdcGroup: {1}' -f $myinvocation.InvocationName, $_.Exception.Message)
    }

    $settings = @{
        Size                 = $null
        SameSizeAsClientArea = $false
        FullScreen           = $true
        ColorDepth           = 24
    }
    foreach ($setting in $SettingsHash.Keys) {
        $settings[$setting] = $settingsHash[$setting]
    }
    if ($SettingsHash.Contains('SameSizeAsClientArea') -and $SettingsHash['SameSizeAsClientArea']) {
        $settings['FullScreen'] = $false
    }

    if ($settings['ColorDepth'] -notin 8, 15, 16, 24, 32) {
        throw 'Invalid color depth. Valid values are 8, 15, 16, 24, and 32.'
    }
    if ($settings['Size'] -and $settings['Size'] -notmatch '^\d+ *x *\d+$') {
        throw 'Invalid desktop size. Sizes must be specified in the format "Horizontal x Vertical"'
    } elseif ($settings['Size'] -match '^(\d+) *x *(\d+)$') {
        # Ensure Size is formatted exactly as RdcMan expects it to be.
        $settings['Size'] = '{0} x {1}' -f $matches[1], $matches[2]
    }

    $xElement = [System.Xml.Linq.XElement]('
        <remoteDesktop inherit="None">
            <sameSizeAsClientArea>{0}</sameSizeAsClientArea>
            <fullScreen>{1}</fullScreen>
            <colorDepth>{2}</colorDepth>
        </remoteDesktop>'
 -f $settings['SameSizeAsClientArea'], $settings['FullScreen'], $settings['ColorDepth'])

    if (-not $settings['FullScreen'] -and $settings['Size']) {
        $null = $xElement.Element('remoteDestkop').AddFirst(
            [System.Xml.Linq.XElement]('<size>{0}</size>' -f $settings['Size'])
        )
    }

    if ($parentNode -is [System.Xml.Linq.XDocument]) {
        $parentNode.Element('Rdc').Element('file').Add($xElement)
    } else {
        $parentNode.Add($xElement)
    }
}


InitializeModule