MyProfile.psm1

# References:
# 1. Below are the list of predefined vars that can be used:
# - $PSScriptRoot [System defined] The folder path for current scipt file, NOT the caller script to call this function

. "$PSScriptRoot\Common.ps1"

if (-not ("ddrr.PowerShell.MyProfileModule.MyProfile" -as [Type]))
{
    Add-Type @"
        namespace ddrr.PowerShell.MyProfileModule
        {
            public class MyProfile
            {
                public string Name { get; set; }
                public int LoadOrder { get; set; }
                public string Path { get; set; }
                public bool Installed { get; set; }
                public bool AutoDetected { get; set; }
            }
        }
"@

}

###############################################################################
# MyProfile private functions
###############################################################################

function SetupMyProfileModule
{
    # Setup PSProfile.ps1, it will support the auto loading for PSProfile.ps1 for all of MyProfiles.
    InstallPsProfile -CodeId "34418a32-466b-456e-bc36-e8f6f71f465d" -ScriptBlock {
# The entrance of the PSProfile for MyProfile
if (-NOT [string]::IsNullOrEmpty($Env:MyProfileModuleDevPath)) { 
    Write-Host "MyProfile module's DevMode is enabled: '$($Env:MyProfileModuleDevPath)'" -ForegroundColor Magenta
    Import-Module $Env:MyProfileModuleDevPath -Verbose
}
if (Get-Command Invoke-MyPSProfile -ErrorAction Ignore) { . Invoke-MyPSProfile }
    }

    # Setup environment vars: MyProfileBinPath, MyProfilePSModulePath
    Add-EnvironmentListVariableValues -Name 'Path' -ValuesToAppend "%$MyProfileBinPathVarName%" -Target User

    $currentUserPsModulesPath = Join-Path $([Environment]::GetFolderPath("MyDocuments")) "WindowsPowerShell\Modules"
    Add-EnvironmentListVariableValues -Name 'PSModulePath' -ValuesToAppend @($currentUserPsModulesPath,"%$MyProfilePSModulesPathVarName%") -Target User

    # Setup SysProfile.ps1
    #$sysRunRegPath = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run"
    #$sysProfileTrigger = "PowerShell -Executionpolicy Unrestricted -WindowStyle Hidden -File `"$MyProfileModuleSysProfilePath`""
    #New-ItemProperty -Path $sysRunRegPath -Name "MyProfile" -Value $sysProfileTrigger -PropertyType String -Force | Out-Null
    Copy-Item $MyProfileModuleMyProfileShortcutPath -Destination $([Environment]::GetFolderPath("Startup")) -Force
}

<#
.SYNOPSIS
Check whether the specified path is a valid MyProfile.
#>

function Test-MyProfile
{
    param(
        [Parameter(Position=0)]
        [string] $MyProfilePath
    )

    # Check the path exists or not
    if ([string]::IsNullOrWhiteSpace($MyProfilePath)) { return $false }
    if (-NOT (Test-Path $MyProfilePath)) { return $false }

    # Check the manifest
    $manifestFilePath = Join-Path $MyProfilePath $MyProfileRelativeManifestPath
    if (-NOT(Test-Path $manifestFilePath)) { return $false }

    return $true
}

<#
.SYNOPSIS
Get the list of paths for my profile that has been installed.
#>

function Get-InstalledMyProfilePaths
{
    return Get-EnvironmentListVariable -Name $MyProfileInstalledListVarName
}

<#
.SYNOPSIS
Get the list of auto detected my profile paths.
#>

function Get-AutoDetectedMyProfilePaths
{
    $ret = @()

    # Default 1: SynologyDrive
    $ret += "$($env:USERPROFILE)\SynologyDrive\MyProfile"

    # Default 2: OneDrive - Personal
    $ret += "$($env:USERPROFILE)\OneDrive\MyProfile"

    # Default 3: OneDrive - <Business>
    Get-ChildItem -Path $env:USERPROFILE -Filter "OneDrive - *" | % {
        $ret += "$($env:USERPROFILE)\$($_.Name)\MyProfile"
    }

    return $ret
}

function Get-MyProfileManifest
{
    param(
        [Parameter(Position=0)]
        [string] $MyProfilePath
    )

    $manifestFilePath = Join-Path $MyProfilePath $MyProfileRelativeManifestPath
    return Import-PowerShellDataFile $manifestFilePath
}

function Install-MyToolbox
{
    [CmdletBinding()]
    param(
        [Parameter(Position=0)]
        [string] $App,
        [string] $MyProfile,
        [switch] $Force
    )


}

function Install-MyChocoApp
{
    [CmdletBinding()]
    param(
        [Parameter(Position=0)]
        [string] $App,
        [switch] $Force
    )

    #
    # Step 1: Install the Choco if not yet
    #
    if (!(Test-Path "$ENV:ChocolateyInstall\bin\choco.exe")){
        Set-ExecutionPolicy Bypass -Scope Process -Force
        [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
        Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))

        #Set-ExecutionPolicy Bypass -Scope Process -Force
        #Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))
        #$ENV:Path = "$ENV:ALLUSERSPROFILE\chocolatey\bin;$ENV:Path"
    }
}

###############################################################################
# MyProfile public functions
###############################################################################
function Set-MyProfileModuleDevPath
{
    [CmdletBinding()]
    param(
        [Parameter(Position=0)]
        [string] $Path = ''
    )

    # Enable the "DevMode" if the path is a valid MyProfile module path.
    # In "Dev Mode", the specified MyProfile module will be imported when PowerShell start, but not import the installed MyProfile moudle.
    # In this way, it will be much easier to test and debug the new code of MyProfile module. So call it as "DevMode".
    if ([string]::IsNullOrEmpty($Path))
    {
        Write-ColorHost "Will <Green>disable</Green> the DevMode for MyProfile module." -Type Highlight
    }
    elseif(Test-Path $Path)
    {
        Write-ColorHost "Will <Green>enable</Green> the DevMode for MyProfile module to path '<Green>$Path</Green>'" -Type Highlight
    }
    else 
    {
        Write-ColorHost "The specified path '<Green>$Path</Green>' does not exists. Do nothing." -Type Error
        return
    }

    Set-EnvironmentVariable -Name $MyProfileModuleDevPathVarName -Target User -Value $Path
}

function New-MyProfile
{
    [CmdletBinding()]
    param(
        [Parameter(Position=0)]
        [string] $Path = ''
    )

    if ([string]::IsNullOrWhiteSpace($Path)) { $Path = '.'}

    if (Test-Path $Path)
    {
        $Path = Resolve-Path $Path
        if ((Get-ChildItem $Path).Count -gt 0)
        {
            Write-ColorHost "The path for a new MyProfile must be a new path or an empty folder. Fail to create new MyProfile at '$Path'." -Type Error
            return
        }
    }
    else 
    {
        New-Item -Path $Path -ItemType Directory | Out-Null
    }

    Copy-Item -Path "$MyProfileModuleTemplateRootPath\*" -Destination $Path
    Write-ColorHost "A new MyProfile has been created at '<Green>$Path</Green>'. You can update the <Green>$MyProfileRelativeManifestPath</Green> file to setup the details." -Type Highlight
}

<#
.SYNOPSIS
Get specified profile(s).
 
.DESCRIPTION
If nothing is specifed in parameter "Identity", return all visible profiles (auto detectable profiles + installed profiles)
If profile name is specified in parameter "Identity", find the profile match with the name from all visible profiles (auto detectable profiles + installed profiles)
If profile path is specified in parameter "Identity", try to directly get the profile in that path
 
.PARAMETER Identity
The profile name (support wildcard), or the profile path. Optional.
#>

function Get-MyProfile
{
    [CmdletBinding()]
    param(
        [Parameter(Position=0)]
        [string] $Identity
    )

    [array]$installedPathList = Get-InstalledMyProfilePaths
    [array]$autoDetectedPathList = Get-AutoDetectedMyProfilePaths

    $isPathMode = $false
    if ((-NOT [string]::IsNullOrWhiteSpace($Identity)) -AND (Test-Path $Identity))
    {
        [array]$profilePathList = @($Identity)
        $isPathMode = $true
    }
    else
    {
        [array]$profilePathList = $autoDetectedPathList + $installedPathList
    }

    [array]$profiles = $profilePathList | ? { Test-MyProfile $_ } | % { (Resolve-Path $_).Path.TrimEnd('\') } | Sort-Object -Unique | % {
        $curManifest = Get-MyProfileManifest $_
        if ($isPathMode -OR ([string]::IsNullOrWhiteSpace($Identity) -OR ($curManifest.Name -like $Identity)))
        {
            $curProfile = New-Object ddrr.PowerShell.MyProfileModule.MyProfile
            $curProfile.Name = if ([string]::IsNullOrWhiteSpace($curManifest.Name)){ $_ } else { $curManifest.Name }
            $curProfile.LoadOrder = $curManifest.LoadOrder
            $curProfile.Path = $_
            $curProfile.Installed = $installedPathList -contains $_
            $curProfile.AutoDetected = $autoDetectedPathList -contains $_
            $curProfile
        }
    }

    return $profiles | Sort-Object LoadOrder,Path
}

<#
.SYNOPSIS
Install specified profile(s).
 
.DESCRIPTION
If nothing is specifed in parameter "Identity", install all visible profiles (auto detectable profiles + installed profiles)
If profile name is specified in parameter "Identity", install the profile match with the name from all visible profiles (auto detectable profiles + installed profiles)
If profile path is specified in parameter "Identity", install the profile in that path
 
.PARAMETER Identity
The profile name (support wildcard), or the profile path. Optional.
#>

function Install-MyProfile
{
    [CmdletBinding()]
    param(
        [Parameter(Position=0)]
        [string] $Identity
    )

    # Make sure this module has been setup (hooked to system).
    SetupMyProfileModule

    # Build the profile list
    [array]$profileList = Get-MyProfile
    [bool]$isPathMode = $false
    if ((-NOT [string]::IsNullOrWhiteSpace($Identity)) -AND (Test-Path $Identity))
    {
        
        $pathProfile = Get-MyProfile $Identity | Select-Object -First 1
        if ($null -eq $pathProfile)
        {
            # Directly return as the specified profile does not exists
            Write-ColorHost "Skip to install the invalid profile `"$Identity`"" -Type Warning
            return
        }

        $isPathMode = $true
        # Update to the normalized path
        $Identity = $pathProfile.Path

        # Only adjust the profileList when pathProfile is a new profile to avoid duplicate profile in the list.
        if (-NOT $pathProfile.Installed)
        {
            $profileList = $profileList + $pathProfile | ? { $_.Installed -OR $_ -eq $pathProfile } | Sort-Object LoadOrder,Path
        }
    }
    else 
    {
        $profileList = $profileList | ? { [string]::IsNullOrWhiteSpace($Identity) -OR $_.Name -like $Identity }
    }

    $profilePaths = @()
    $binPaths = @()
    $psModulesPaths = @()

    $profileList | % {
        if (-NOT $_.Installed)
        {
            #
            # Only does these things for the profiles that specified to install.
            #
            Write-ColorHost "Installing $($_.Name)" -Type Keynote

            # Invoke PSModules for current MyProfile after it is installed.
            # Note: Although the PSProfile is also invoked here, but the behavior may not match with run them on PS start as there may have dependency between PSProfiles.
            Invoke-MyProfile $_.Path -PSModules -PSProfile -PSCmdlets

            # Install Toolbox
        }

        #
        # Do below things for all installed/installing profiles
        #

        # Register MyProfile path. This will support PowerShell Profile, System Profile, Cron
        $profilePaths += $_.Path

        # Register Bin path
        $binPaths += Join-Path $_.Path $MyProfileRelativeBinPath

        # Register PowerShell Modules
        $psModulesPaths += Join-Path $_.Path $MyProfileRelativePSMoudlesPath
    }

    # Save paths to environment variables
    Set-EnvironmentListVariable -Name $MyProfileInstalledListVarName -ListValue $profilePaths -Target User
    Set-EnvironmentListVariable -Name $MyProfileBinPathVarName -ListValue $binPaths -Target User
    Set-EnvironmentListVariable -Name $MyProfilePSModulesPathVarName -ListValue $psModulesPaths -Target User

    # Update related environment variables
    Set-EnvironmentVariable -Name "PSModulePath" -Target Process -Value ([Environment]::ExpandEnvironmentVariables($ENV:PSModulePath))
    Set-EnvironmentVariable -Name "Path" -Target Process -Value ([Environment]::ExpandEnvironmentVariables($ENV:Path))
}

function Uninstall-MyProfile
{
    [CmdletBinding()]
    param(
        [Parameter(Position=0)]
        [string] $Identity
    )

    $profilePaths = @()
    $binPaths = @()
    $psModulesPaths = @()

    Get-MyProfile | ? { $_.Installed } | % {
        if ([string]::IsNullOrWhiteSpace($Identity) -OR ($_.Name -like $Identity) -OR ($_.Path -eq $Identity))
        {
            #
            # Only does these things for the profiles that specified to install.
            #
            Write-ColorHost "Uninstalling $($_.Name)" -Type Keynote

            # Unimport PSModules for current MyProfile after it is uninstalled.

            # Uninstall Toolbox
        }
        else 
        {
            #
            # Do below things for all rest installed profiles
            #

            # Register MyProfile path. This will support PowerShell Profile, System Profile, Cron
            $profilePaths += $_.Path

            # Register Bin path
            $binPaths += Join-Path $_.Path $MyProfileRelativeBinPath

            # Register PowerShell Modules
            $psModulesPaths += Join-Path $_.Path $MyProfileRelativePSMoudlesPath
        }
    }

    # Save paths to environment variables
    Set-EnvironmentListVariable -Name $MyProfileInstalledListVarName -ListValue $profilePaths -Target User
    Set-EnvironmentListVariable -Name $MyProfileBinPathVarName -ListValue $binPaths -Target User
    Set-EnvironmentListVariable -Name $MyProfilePSModulesPathVarName -ListValue $psModulesPaths -Target User

    # Update related environment variables
    Set-EnvironmentVariable -Name "PSModulePath" -Target Process -Value ([Environment]::ExpandEnvironmentVariables($ENV:PSModulePath))
    Set-EnvironmentVariable -Name "Path" -Target Process -Value ([Environment]::ExpandEnvironmentVariables($ENV:Path))
}

function Invoke-MyProfile
{
    [CmdletBinding()]
    param(
        [Parameter(Position=0)]
        [string] $Identity,
        [switch] $SysProfile,
        [switch] $PSProfile,
        [switch] $PSModules,
        [switch] $PSCmdlets
    )

    if ((-NOT $SysProfile) -AND (-NOT $PSProfile) -AND (-NOT $PSModules) -AND (-NOT $PSCmdlets)) {
        $SysProfile = $true
        $PSProfile = $true
        $PSModules = $true
        $PSCmdlets = $true
    }

    $sysProfiles = @()
    # Note, only allow to invoke Installed MyProfile.
    Get-MyProfile $Identity | ? { $_.Installed } | % {
        if ($PSCmdlets) {
            $cmdletsModuleName = "MyCmdlets_$($_.Name)"
            $cmdletsPath = Join-Path $_.Path $MyProfileRelativeCmdletsPath
            $cmdletModule = New-Module -Name $cmdletsModuleName -ArgumentList @($cmdletsPath) -ScriptBlock {
                Get-ChildItem -Path $args[0] -Filter "*-*.ps1" | ? { -NOT ($_.Name -like "Template*") } | % {
                    $fileName = $_.Name.Trim()
                    $cmdletName = $fileName.Substring(0, $fileName.Length - 4)
                    $cmdletScript = $_.FullName
                    Set-Alias -Name $cmdletName -Value $cmdletScript
                    Export-ModuleMember -Alias $cmdletName
                    Write-ColorHost "Export alias <Green>$cmdletName</Green> for $cmdletScript" -Type Verbose
                }
            }

            Import-Module $cmdletModule -Scope Global
        }
        
        if ($PSProfile) {
            $curPSProfilePath = Join-Path $_.Path $MyProfileRelativePSProfilePath
            # Note, use operator '.' here to invoke all MyPrfile's PowerShell profiles in the same scope of the MyProfile's PSProfile.
            # So, these PowerShell profiles can impact each other, and the MyProfile load order is important.
            . InvokeScriptInMyProfileModuleCallerScope $curPSProfilePath
        }

        if ($PSModules) { Import-MyModule -MyProfileName $_.Path }

        # For SysProfile, it should not impact current PS scope, so they will not be invoked via operator '.'
        # But similar to PSProfiles, all SysProfiles should share the same scope. So they will be not invoked here. Instead, they will be invoked together later.
        # This is also consistent with the normal behavior when system startup: SysProfiles are invoked after all PSProfiles are invoked.
        if ($SysProfile) { $sysProfiles += Join-Path $_.Path $MyProfileRelativeSysProfilePath }
    }

    # NOT use operator '.' here to avoid all MyProfile's system profiles impact current PS scope.
    # But these SysProfiles will share the same scope, so the MyProfile load order is important.
    if ($sysProfiles.Count -gt 0){ InvokeScriptInMyProfileModuleCallerScope $sysProfiles }
}

<#
.SYNOPSIS
The entrance of PS profile.
Note: the signature should NOT be changed. Otherwise, there will be upgrade issue.
#>

function Invoke-MyPSProfile
{
    try {
        # Try to update MyProfile module if it is run as administrator
        
        $lastUpdateTimeUtc = [datetime]::FromFileTimeUtc($(Get-EnvironmentVariable -Name $MyProfileModuleLastUpdateFileTimeUtcVarName -Target Process))
        if ($lastUpdateTimeUtc.AddDays(1) -lt [datetime]::UtcNow) {
            # Update module needs to run as admin.
            if (([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator"))
            {
                Write-ColorHost "Trying to update <Green>MyProfile</Green> module" -Type Highlight
                Update-Module MyProfile
                Set-EnvironmentVariable -Name $MyProfileModuleLastUpdateFileTimeUtcVarName -Value $([datetime]::UtcNow.ToFileTimeUtc()) -Target User
            }
            else 
            {
                Write-ColorHost "Trying to update <Green>MyProfile</Green> module in a new PowerShell window which run as Administrator" -Type Highlight
                Start-Process powershell -Verb runas "Write-Host '$([datetime]::Now) MyProfile updated.' -ForegroundColor Green"
            }
        }

        # For cmdlets script based module, PowerShell cannot auto detect the updates of cmdlets.
        # Manually import them when PowerShell start to make sure the latest cmdlets are available.
        # Use dot operator here to prompt the inner scope.
        . Invoke-MyProfile -PSProfile -PSCmdlets
    }
    catch {
        Write-Host "Failed to run MyPSProfile with error:" -ForegroundColor Red
        Write-Host $_
    }
}

<#
.SYNOPSIS
The entrance of Sys profile.
Note: the signature should NOT be changed. Otherwise, there will be upgrade issue.
#>

function Invoke-MySysProfile
{
    Invoke-MyProfile -SysProfile
}

function Import-MyModule
{
    [CmdletBinding()]
    param(
        [Parameter(Position=0)]
        [string] $Name,
        [string] $MyProfileName,
        [switch] $Force
    )

    if ((-NOT [string]::IsNullOrWhiteSpace($Name)) -AND (Test-Path $Name))
    {
        # Note: When $Name is a path to a module, will not check whether the corresponding MyProfile has been installed or not, as the MyProfile may not have been installed.
        try {
            Import-Module $Name -Global -Force:$Force -ErrorAction Stop
            if (-NOT [string]::IsNullOrWhiteSpace($MyProfileName)) { Write-ColorHost "[$MyProfileName] " -NoNewLine -Type Highlight }
            Write-ColorHost "Imported my module '$Name'" -Type Highlight
        }
        catch {
            Write-ColorHost "Failed to import my module '$Name'" -Type Error
            Write-Host $_
        }
    }
    else
    {
        Get-MyProfile | ? { $_.Installed } | ? { [string]::IsNullOrWhiteSpace($MyProfileName) -OR (($_.Name -like $MyProfileName) -OR ($_.Path -eq $MyProfileName)) } | % {
            $curMyProfile = $_
            $psModulesPath = Join-Path $_.Path $MyProfileRelativePSMoudlesPath
            if (Test-Path $psModulesPath -PathType Container)
            {
                Get-ChildItem $psModulesPath -Directory | ? { -NOT($_.Name -like "Template*") } | ? { [string]::IsNullOrWhiteSpace($Name) -OR ($_.Name -like $Name) } | % {
                    Import-MyModule -Name (Join-Path $psModulesPath $_.Name) -MyProfileName $curMyProfile.Name -Force:$Force
                }
            }
        }
    }
}


###############################################################################
# Alias
###############################################################################
Set-Alias -Name Start-MySysProfile -Value $MyProfileModuleSysProfilePath