deploymentor.ps1

<#
    .Synopsis
    Deployment Helper and Administration Tool
     
    .Description
    Provides a GUI to trigger PowerShell snippets and software install scripts
 
    .Notes
    .NAME Deploymentor
    .AUTHOR Nabil Redmann <repo+deploymentor@bananaacid.de>
    .LICENSE MIT
#>

[CmdletBinding()]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingCmdletAliases', '', Scope = 'Function', Target = '*')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseApprovedVerbs', '', Scope = 'Function', Target = '*')]
param(
    [Parameter(Mandatory=$false, Position=0, HelpMessage="start with a specific profile")]
    [Alias("Profile")]
    $ProfileSelected = $null, # int or name string
    
    [Parameter(Mandatory=$false, Position=1)]
    [ValidateSet('none','actions','software','all')]
    [string]$AutoStart = 'none',

    [Parameter(Mandatory=$false, Position=2)]
    [string]$ConfigFile = "$PSScriptRoot\data\config.ps1",

    [Parameter(Mandatory=$false, Position=3)]
    [string]$Logs = "$PSScriptRoot\logs"
)

<# ------------------------------------------------------------------------------------------------ #>

# do NOT use -UseMinimalHeader -> we want to know the user it was started with, in case of errors
Start-Transcript -Path "$Logs\$(Get-Date -Format 'yyyy-MM-dd_HH-mm-ss').log" -Append -ErrorAction SilentlyContinue | Out-Null

# we need a variant, that works with transcript - but we ignore the error pipeline. Overwrite the one from XAMLgui.
Function Write-ErrorClean { param( [Parameter(Mandatory=$false, Position=0, ValueFromPipeline=$true)] [String] $Message, [Parameter(Mandatory=$false, Position=1)] [String] $ForegroundColor = $Host.PrivateData.ErrorForegroundColor <# ='Red'#> ); Write-Host "ERROR: $Message" -ForegroundColor $ForegroundColor; }

# save current path
$callingFromPWD = $PWD

# get version from psd1
$PSD1 = if (Get-Command Import-PowerShellDataFile -ErrorAction SilentlyContinue ) { Import-PowerShellDataFile "$PSScriptRoot\Deploymentor.psd1" -ErrorAction SilentlyContinue } else { & { Invoke-Expression (Get-Content -Path "$PSScriptRoot\Deploymentor.psd1" -Raw) } }
$VERSION = If ($PSD1) { 'v' + $PSD1.ModuleVersion } Else { '' }
Write-Host "Deploymentor $VERSION" -ForegroundColor Yellow

# make sure, we are in the right dir
# Set-Location $PSScriptRoot
Write-Host "Working dir: $PWD"


# --- config loading -------------------------------


# config requirements
# always load as base config
$ConfigFileBase = (Get-Item "$PSScriptRoot\data\config.ps1","$PSScriptRoot\config.ps1" -ErrorAction SilentlyContinue | Select-Object -First 1)
. $ConfigFileBase

# set in case it is not set in the -ConfigFile parameter
if (-not $dir) { $dir = @{} }
$psmodulesDir = if (-not $dir["psmodules"]) { "$PSScriptRoot\ps-modules" } else { if ([System.IO.Path]::IsPathRooted($dir["psmodules"])) { $dir["psmodules"] } else { Join-Path (Split-Path $ConfigFileBase) $dir["psmodules"] } }

# just "examples" is a special case
if ($ConfigFile -eq "examples") { $ConfigFile = "$PSScriptRoot\examples\data\config.ps1" }

# can be configured by the user (if its the same default one ... nothing happens)
if ($ConfigFile) {
    $configFile = $(if ([System.IO.Path]::IsPathRooted($configFile)) { $configFile } else { Join-Path $callingFromPWD $configFile })

    if (Test-Path $ConfigFile) {
        $ConfigFile = Resolve-Path $ConfigFile
        . $ConfigFile 
    }
    elseif ($ConfigFile -and -not (Test-Path $ConfigFile)) {
        Write-Host "Config file not found: $ConfigFile" -ForegroundColor Red
        Stop-Transcript
        Exit 99
    }
}
else {
    # make sure, it is always set - needed for $dir resolution
    $ConfigFile = $ConfigFileBase
}

Write-Host "Config File: $ConfigFile"


# --- early config handling -------------------------------


If (!$showConsole) { Hide-Console }


# --- Dirs based on config handling -------------------------------


# ensure ps-modules dir exists
$psmodulesDir = if (-not $dir["psmodules"]) { "$PSScriptRoot\ps-modules" } else { if ([System.IO.Path]::IsPathRooted($dir["psmodules"])) { $dir["psmodules"] } else { Join-Path (Split-Path $ConfigFile) $dir["psmodules"] } }
if (!(Test-Path $psmodulesDir)) { mkdir $psmodulesDir | Out-Null }
$dir["psmodules"] = $psmodulesDir # force update, in case there was a fallback because of a missing config
# ensure cache dir exists
$cacheDir = if ([System.IO.Path]::IsPathRooted($dir["cache"])) { $dir["cache"] } else { Join-Path (Split-Path $ConfigFile) $dir["cache"] }
if (!(Test-Path $cacheDir)) { mkdir $cacheDir | Out-Null }

#resolve all paths, relative to the config.ps1
$dirAbs = @{}
$dir.Keys |% { $dirAbs[$_] = Resolve-Path $(if ([System.IO.Path]::IsPathRooted($dir["$_"])) { $dir["$_"] } else { Join-Path (Split-Path $ConfigFile) $dir["$_"] }) -ErrorAction Stop }
$dir = $dirAbs

Write-Debug ("Config Dirs Resolved:" + ($dir | Format-Table | Out-String))


# --- XAMLgui import -------------------------------


# To be able to use a local module, because deploying the app on an USB stick
# will need it and might not have internet access
Function Find-LocalModulePath { param( [Parameter(Mandatory=$true, Position=0)] [String] $Name, [String] $Path = ".\ps-modules" ) return ls "$Path\$Name" -ErrorAction SilentlyContinue | select -Last 1 |% FullName }
Function Import-LocalModule { param( [Parameter(Mandatory=$true, Position=0)] [String] $Name, [String] $Path = ".\ps-modules", [Boolean] $Download = $True ) if (-not (Find-LocalModulePath $Name -Path $Path) -and $Download) { Save-Module -Name $Name -Path $Path } $fullPath = Find-LocalModulePath $Name -Path $Path; if (-not $fullPath) { Write-Error "Unable to find $Name module, could not download. Aborting."; Exit 99 } Write-Host "Importing $Name module from $fullPath"; Import-Module (Join-Path $fullPath $Name); }
Function Get-LocalModule { param( [Parameter(Mandatory=$true, Position=0)] [String] $Name, [String] $Path = ".\ps-modules", [Boolean] $Download = $True ) if (-not (Find-LocalModulePath $Name -Path $Path) -and $Download) { Save-Module -Name $Name -Path $Path } $fullPath = Find-LocalModulePath $Name -Path $Path; if (-not $fullPath) { Write-Error "Unable to find $Name module, could not download. Aborting."; Exit 99 } return $fullPath; }

## DO NOT USE THIS
## We use a local version of the XAMLgui module, because deploying the app on an USB stick
## will need it and might not have internet access
###Import-LocalModule XAMLgui

#! Dev - use Dev version of XAMLgui
if (Test-Path -Path "$PSScriptRoot\..\XAMLgui\XAMLgui") { ls "$PSScriptRoot\..\XAMLgui\XAMLgui\*.ps1" |% { . $_.FullName } }
#! import the module functions into the current session
#! we do not import, because the handlers are not picked up -- TODO: FIX this.
else { ls "$(Get-LocalModule XAMLgui -Path $dir.psmodules)\*.ps1" |% { . $_.FullName } }

Write-Debug "XAMLgui Version $(Get-XAMLguiVersion)"

# --- Context preperation -------------------------------


# usefull for actions/software
$ctxBase =  @{
    dir = $dir          # == config.ps1 > $dir
    activeProfile = @{  # data of the currently selected profile
        File = $null    # current profile filename path
        Title = $null   # the displayed title
        Data = $null    # the profile files returned settings content
        Index = 0       # the index of the currently selected profile
    }
    deploymentor = @{           # data of the currently running deploymentor
        Root = $PSScriptRoot    # installation root folder
        File = $PSCommandPath   # installation filename path
        Version = $VERSION      # deploymentor version, if available
    }
    item = $null        # @{ Folder = $dir.software; FilePath; FileName; }
    doCancel = $false   # setting this will abort any actions/software in queue
    lastExecResults = @{} # may contain content returned from an action / software
}
$script:ctx = New-ClonedObject $ctxBase
$script:contextFNs = "" # set in Load-ContextFns, will pass helper functions to actions/apps
$script:contextFNsFile = ""

#globals
$script:profilesAvailable = @()
$Elements = $null
$MainWindow = $null

Function Load-ContextFns {
    # get functions into context (Actions, Software)
    $script:contextFNs = @(
        Get-FnAsString Find-LocalModulePath
        Get-FnAsString Import-LocalModule   # requires Find-LocalModulePath
        # Get-FnAsString Get-LocalModule # gets added by Import-LocalModule within context
        Get-FnAsString ConvertTo-NiceXml    # good to have
        "Import-LocalModule XAMLgui -Path `"$($dir.psmodules)`""  # requires Import-LocalModule to be available
        "Set-LocalModulePathBase -Path `"$($dir.psmodules)`""     # provide the Deploymentor module path to search in for modules / or the one configured
        'Enable-VisualStyles'
        Get-FnAsString Write-ErrorClean     # we make sure, we have an error handler that is able to be cought by transcript
    ) -join "`n"

    $script:contextFNsFile = Join-Path $dir.cache 'contextFNs.ps1'
    $script:contextFNs | Out-File $script:contextFNsFile -Force
}

# --- UI Event-Handlers -------------------------------
# handler - have to be available before loading form

function Deploymentor.MainWindow.DoInstallActions_Click($Sender1, $EventArgs1) {
    $tasksActions = ($Elements.lvActions.items |? IsSelected -eq $true).Count
    if (($sender1 -ne 'AutoStart') -and  (Show-MessageBox "Are you sure you want to run all selected actions ($tasksActions)?" -Title "Really sure?"  -Buttons OkCancel -Type Warning) -ne "Ok") { return }

    Reset-lastExecResults
    Reset-Progressbars
    
    Enable-Ui -Enable $false
    Invoke-Actions
    Enable-Ui -Enable $true
}

function Deploymentor.MainWindow.DoInstallSoftware_Click($Sender1, $EventArgs1) { 
    $tasksSoftware = ($Elements.lvSoftware.items |? IsSelected -eq $true).Count
    if (($sender1 -ne 'AutoStart') -and (Show-MessageBox "Are you sure you want to run all selected software ($tasksSoftware)?" -Title "Really sure?"  -Buttons OkCancel -Type Warning) -ne "Ok") { return }

    Reset-lastExecResults
    Reset-Progressbars
    
    Enable-Ui -Enable $false
    Invoke-Software
    Enable-Ui -Enable $true
}

function Deploymentor.MainWindow.doInstallAll_Click($Sender1, $EventArgs1) {
    $tasksActions = ($Elements.lvActions.items |? IsSelected -eq $true).Count
    $tasksSoftware = ($Elements.lvSoftware.items |? IsSelected -eq $true).Count
    if (($sender1 -ne 'AutoStart') -and (Show-MessageBox "Are you sure you want to run all selected actions ($tasksActions) and all selected software ($tasksSoftware)?" -Title "Really sure?"  -Buttons OkCancel -Type Warning) -ne "Ok") { return }

    Reset-lastExecResults
    Reset-Progressbars
    
    Enable-Ui $false
    
    Write-Host "Total tasks: $($tasksActions + $tasksSoftware)" -ForegroundColor Green
    
    $af = $ctx.activeProfile.Data.actionsFirst -ne $false    
    $counter = 0

    If ($af) {
        Invoke-Actions -Counter ([ref]$counter)
        Invoke-Software -Counter ([ref]$counter)
    }
    else {
        Invoke-Software -Counter ([ref]$counter)
        Invoke-Actions -Counter ([ref]$counter)
    }
    
    Enable-Ui $true
    
    Write-Host "Installing done." -ForegroundColor Green
}

function Deploymentor.MainWindow.BtnCancel_Click($Sender1, $EventArgs1) {
    Write-Warning "Canceling execution of tasks ..."
    
    $ctx.doCancel = $true
}

function Deploymentor.MainWindow.CbProfileSelect_SelectionChanged($Sender1, $EventArgs1) {
    $s = [System.Windows.Controls.Combobox]$sender1
    if ($s.SelectedIndex -gt 0) {
        Write-Host "Profile selected: ", $s.SelectedIndex, " -> ", $s.SelectedValue -ForegroundColor Green
    }
    else {
        Write-Host "Profile selected: none" -ForegroundColor Green
    }

    Load-Profile -Index $s.SelectedIndex -Title $s.SelectedValue
    Reset-Progressbars
}

function Deploymentor.MainWindow.DoSoftwareDir_Click($Sender1, $EventArgs1) {
    Start $dir.software
}

function Deploymentor.MainWindow.DoActionsDir_Click($Sender1, $EventArgs1) {
    Start $dir.actions
}

function Deploymentor.MainWindow.DoOpenConfig_Click($Sender1, $EventArgs1) {
    Start-Process "explorer.exe" "/select,`"$configFile`""
}

function Deploymentor.MainWindow.LbCopy_MouseDown($Sender1, $EventArgs1) {
    Start "mailto:repo+deploymentor@bananaacid.de"
}

function Deploymentor.MainWindow.LvTools_SelectionChanged($Sender1, $EventArgs1) {
    # prevent errors
    If (!$ctx.toolsLoaded) { return }
    
    $listbox = [System.Windows.Controls.ListBox]$Sender1
    
    # if it was deselected
    If(!$listbox.SelectedItem) { return }

    # get the selected item
    $item = ($EventArgs1.AddedItems | Select-Object -First 1)

    # Pretend to be a button -> Deselect
    $listbox.SelectedItem = $null
    
    Invoke-Tool $item
}

$script:lvSoftwareLastClicked = $null
function Deploymentor.MainWindow.lvSoftware_PreviewMouseRightButtonDown($Sender1, $EventArgs1) {
    $e = [System.Windows.Input.MouseButtonEventArgs]$EventArgs1
    $s = [System.Windows.Controls.ListView]$Sender1
    $script:lvSoftwareLastClicked = ([System.Windows.FrameworkElement]$e.OriginalSource).DataContext
    
    $e.Handled = $true;  # block right click

    if ($null -ne $script:lvSoftwareLastClicked) {
        $s.ContextMenu.PlacementTarget = $s;
        $s.ContextMenu.IsOpen = $true;
    }
}

function Deploymentor.MainWindow.lvSoftware_ContextMenu_Click($Sender1, $EventArgs1) {
    $item = $script:lvSoftwareLastClicked
    
    if ($null -ne $item) {
        Write-Host "Started `"$($item.Title)`"" -ForegroundColor Green
        
        Start-AwaitJob $item.Data.installFn -ArgumentList $($ctx) -Dir $item.FilePath -InitBlock $script:contextFNs
    }
}

function Deploymentor.MainWindow.cbActonsFirst_Checked($Sender1, $EventArgs1) {
    $cb = [System.Windows.Controls.CheckBox]$Sender1
    
    if ($cb.IsChecked) {
        # $Elements.software."Grid.Column" = 0
        # $Elements.actions."Grid.Column" = 2
        [System.Windows.Controls.Grid]::SetColumn($Elements.software, 2)
        [System.Windows.Controls.Grid]::SetColumn($Elements.actions, 0)
    }
    else {
        # $Elements.software."Grid.Column" = 2
        # $Elements.actions."Grid.Column" = 0
        [System.Windows.Controls.Grid]::SetColumn($Elements.software, 0)
        [System.Windows.Controls.Grid]::SetColumn($Elements.actions, 2)
    }
}

$didRunAutoStart = $false
function Deploymentor.MainWindow.Window_ContentRendered($Sender1, $EventArgs1) {
    if ($didRunAutoStart) { return }
    $didRunAutoStart = $true

    Handle-AutoStart
}


# load main gui

# copy if it exists (we do not need the window dir when deploying this)
Copy-Item $PSScriptRoot\window\MainWindow.xaml "$($dir.data)\MainWindow.xaml" -Force -ErrorAction SilentlyContinue # try or don't care
$Elements,$MainWindow = . New-Window "$($dir.data)\MainWindow.xaml" # -Debug # for debugging handlers and see error messages


# --- Main functions: Load ------------------------------------------


# Preselect by param if available
Function Load-ProfileByParam {
    Param( $profileName )
    
    # if no profile name is given or -1 or ""
    if ($null -eq $profileName -or "" -eq $profileName -or -1 -eq $profileName) {
        # force 0 to trigger a select on the item -> this triggers a change event and is cought as a profile change -> Load-Profile -> Load-Actions, ...
        # otherwise it is -1 and will not trigger the event / load the profile
        $profileName = 0
    } 
    
    # ref to the combobox
    $cb = [System.Windows.Controls.ComboBox]$Elements.CbProfileSelect
    
    # Try to parse the string to an integer
    if ([int]::TryParse($profileName, [ref]$null)) {
        if ($profileName -ge $script:profilesAvailable.Count) {
            if ($profileName -ne 0) {
                Write-ErrorClean "Profile not found for index: $profileName"
            }
            $profileName = 0
        }
        $cb.SelectedIndex = [int]$profileName # this will trigger a change event -> Profile load
    }
    else {
        # find the correct filename -> is the profile name
        $profileNameNew = $cb.Items |? { $_ -ilike $profileName } | select -first 1
        if ($null -eq $profileNameNew) { 
            Write-ErrorClean "Profile not found matching: $profileName"
            $cb.SelectedIndex = 0
        }
        else {
            $cb.SelectedItem = $profileNameNew # this will trigger a change event -> Profile load
        }
    } 
}

# data handler
<#
1. load profile files
2. add to combobox CbProfileSelect
 
3. preselected: none -> all options / software
#>

Function Load-ProfileList {
    $script:profilesAvailable = ls "$($dir.profiles)\*.ps1" -File |? { $_.Name -notlike "_.*" -and $_.Name -notlike ".*" }
    $cb = [System.Windows.Controls.Combobox]$Elements.CbProfileSelect
    $cb.Items.Clear()
    $cb.AddText("")

    if ($script:profilesAvailable.Count -eq 0) {
        Write-ErrorClean "No profiles found in: $($dir.profiles)"
        $script:profilesAvailable = @()
        return
    }

    Foreach ($file in $script:profilesAvailable) {
        Write-Debug "Found profile: $file"
        
        $data = & $file
        $title = ($file | % {$_.BaseName})
        
        If ($data.title) {
            $title = $data.title
        }
        
        $cb.AddText( $title )
    }
}

Function Load-Profile {
    Param( $Index, $Title )
    
    # fresh ctx
    $script:ctx = New-ClonedObject $ctxBase
    
    # $var[0 -1] wraps around !!! ... result is not null
    # combobox idx 0 == "ALL" (empty item), profile arr starts in combobox at idx 1
    $realIndex = $Index - 1
    if ($realIndex -ge 0) {
        $currentProfile = $script:profilesAvailable[$realIndex]
    }
    else {
        $currentProfile = $null
    }

    $settings = if ($currentProfile) { & $currentProfile } else { @{} }
    
    # store current profile to be used by any script
    $ctx.activeProfile = @{
        File = $currentProfile
        Title = $title # optional $settings.Title was already handled by loading the list and passed in $Title
        Data = $Settings
        Index = $realIndex
    }

    Set-ActionsBeforeSoftware ($null -eq $Settings.actionsFirst -or $Settings.actionsFirst -eq $true)
    # check: $ctx.activeProfile.Data.actionsFirst -ne $false
    
    If ($Index -ne 0 -and $null -ne $Settings.software) {
        $filter = $Settings.software
        
        Load-Software $filter
    }
    else {
        Load-Software
    }
    
    If ($Index -ne 0 -and $null -ne $Settings.actions) {
        $filter = $Settings.actions
        
        Load-Actions $filter
    }
    else {
        Load-Actions
    }

    If ($Index -ne 0 -and $null -ne $Settings.tools) {
        $filter = $Settings.Tools
        
        Load-Tools $filter
    }
    else {
        Load-Tools
    }
}

Function Load-Software {
    Param( $Filter )
    
    # load defaults
    If ($null -eq $filter) {
        $filter = ls "$($dir.software)\*" -Directory |? { $_.Name -notlike "_.*" -and $_.Name -notlike ".*" }
    }

    # add software
    $Elements.lvSoftware.Items.Clear();
    Foreach ($itemAorB in $filter) {
        $isSelected = $null

        # is string or object? We only want file items - this item is only the case for items provided by a profile
        # @{Name="FolderName"; isSelected=$true}
        if ($null -ne $itemAorB.isSelected -and $null -ne $itemAorB.Name) {
            # the item is an action item config: @{Name="...filename.ps1..."; isSelected=$true} object
            $item = gi "$($dir.software)\$($itemAorB.Name)" -ErrorAction SilentlyContinue
            $isSelected = $itemAorB.isSelected -eq $true
        }
        elseif ($itemAorB.FullName) { # by the !$filter check, they are files
            # the item is a file
            $item = $itemAorB
        }
        elseif ($itemAorB -is [string]) {
            # the item is a filename
            $item = gi "$($dir.software)\$itemAorB" -ErrorAction SilentlyContinue
        }
        else {
            Write-ErrorClean "Unknown software item type:"
            $itemAorB | Format-List | Out-String | Write-ErrorClean
            continue
        }
        
        if ($null -eq $item) {
            # gi * might not actually found a file
            Write-ErrorClean "Software folder does not exist: '$($dir.software)\$itemAorB'"
            continue
        }

        
        $data = $NULL
        $deploymentFilePath = $NULL
        
        ForEach ($typeKey in $softwareInstallers.Keys) {
            $typeValue = $softwareInstallers[$typeKey]
            $deploymentFilePath = Join-Path -Path $item.FullName -ChildPath $typeKey
            
            If (Test-Path $deploymentFilePath) {
                # load data
                $deploymentFilePath = $deploymentFilePath | gi
                
                Switch ($typeValue) {
                    # is parsed for additional info, special Deploymentor file: must return Description, installFn
                    "dpx" {
                        $data = & $deploymentFilePath
                        if (-not $data.ctxType) {
                            $data.ctxType = "native"
                        }
                    }
                    "ps" {
                        $data = @{
                            Description = "Running as Powershell Script"
                            installFn = [scriptblock]::Create("param(`$ctx) ; & '$deploymentFilePath' `$ctx")
                        }
                    }
                    "exec" {
                        $data = @{
                            Description = "Running an Executable"
                            installFn = [scriptblock]::Create("param(`$ctx) ; Start '$deploymentFilePath' -WorkingDirectory '$($deploymentFilePath.DirectoryName)' -ArgumentList `$ctx")
                        }
                    }
                    "wsh" {
                        $data = @{
                            Description = "Running through Windows Scripting Host ($($deploymentFilePath.Extension))"
                            installFn = [scriptblock]::Create("param(`$ctx) ; cd '$($deploymentFilePath.DirectoryName)' ; & cscript.exe '$deploymentFilePath' `$ctx")
                        }
                    }
                    "bash" {
                        $data = @{
                            Description = "Running a Script through Bash"
                            #! TODO: check if this works ... '\' and '/', in windows with bash and on linux with bash
                            installFn = [scriptblock]::Create("param(`$ctx) ; & bash -c `". '$deploymentFilePath' `$ctx `"") 
                        }
                    }
                    default {
                        Write-ErrorClean "Unknown deployment file type: $typeValue `n - for $($deploymentFilePath.FullName)"
                    }
                }
                break
            }
        }
        
        if (-not $data) {
            Write-Warning "Software installer not found in folder: `"$($dir.software)\$($item.Name)\`""
            continue
        }
        
        # if config
        $iconFile = Resolve-Path "$($dir.data)\default-app.png"
        $title = $item.Name
        
        If ($data.title) {
            $title = $data.title
        }
        
        # cache icon
        $iconFileProvider = Join-Path -Path $item.FullName -ChildPath $data.icon # resolve makes it $Null if it does not exist, -ErrorAction does not work with -Resolve
        if (Test-Path $iconFileProvider) { $iconFileProvider = Resolve-Path $iconFileProvider} else { $iconFileProvider = $null }
        
        If ($Null -eq $iconFileProvider) { Write-ErrorClean ("Icon providing file does not exist (but was set in deployment file): " + (Join-Path -Path $item.FullName -ChildPath $data.icon).replace('\.\','\')) }
        If ($Null -ne $data.icon -and $Null -ne $iconFileProvider) {
            $iconPath = Join-Path -Path $dir.cache -ChildPath ($data.icon -replace '.\\','' -replace '\\','_')
            $iconFile = "$iconPath.bmp"
            
            If (!(Test-Path $iconFile)) { 
                [System.Drawing.Icon]::ExtractAssociatedIcon( $iconFileProvider ).ToBitmap().Save($iconFile)
                #Write-Host "writing file"
            }
            
            $iconFile = Resolve-Path $iconFile
        }
        
        # if it was NOT defined by the profile, check the file itself
        If ($null -eq $isSelected) {
            $isSelected = $data.isSelected -eq $true
        }
        
        $id = Resolve-Path $deploymentFilePath.FullName -Relative
        
        # add item
        <# $pos = #> $Elements.lvSoftware.Items.Add([PSCustomObject]@{Id=$id; Title=$title; Description=$data.description; Folder="software"; FilePath=$(Resolve-Path $item.FullName); FileName=$deploymentFilePath.Name; IsSelected=[bool]$isSelected; Icon=[string]$iconFile; Data=$data}) | Out-Null
    }
}

Function Load-Actions {
    Param( $Filter )
    
    # load defaults
    If ($null -eq $filter) {
        $filter = ls "$($dir.actions)\*.ps1" |? { $_.Name -notlike "_.*" -and $_.Name -notlike ".*" }
    }
    
    # add actions
    $Elements.lvActions.Items.Clear();
    Foreach ($itemAorB in $filter) {
        $isSelected = $null
        
        # is string or object? We only want file items
        If ($null -ne $itemAorB.isSelected -and $null -ne $itemAorB.Name) {
            # the item is an action item config: @{Name="...filename.ps1..."; isSelected=$true} object
            $item = gi "$($dir.actions)\$($itemAorB.Name)" -ErrorAction SilentlyContinue
            $isSelected = $itemAorB.isSelected -eq $true
        }
        elseif ($itemAorB.FullName) { # by the !$filter check, they are files
            # the item is a file object
            $item = $itemAorB
        }
        elseif ($itemAorB -is [string]) {
            # the item is a filename
            $item = gi "$($dir.actions)\$itemAorB" -ErrorAction SilentlyContinue
        }
        else {
            Write-ErrorClean "Unknown action item type:"
            $itemAorB | Format-List | Out-String | Write-ErrorClean
            continue
        }
        
        if ($null -eq $item) {
            # gi * might not actually found a file
            Write-ErrorClean "Action file does not exist: '$($dir.actions)\$itemAorB'"
            continue
        }
        
        
        # load data from file item
        $data = & $item.FullName
        
        $descriptionVisibility = "Visible"
        if (!$data.description) {
            $descriptionVisibility = "Collapsed"
        }
        
        if ($null -ne $data.hasValue) {
            # use hasValue/Value properties
            
            $textBoxVisibility = "Visible"
            if ($data.hasValue -ne $true) {
                $textBoxVisibility = "Collapsed"
            }
            
            $textBoxVisibility2 = "Visible"
            if ($data.hasValue2 -ne $true) {
                $textBoxVisibility2 = "Collapsed"
            }
        }
        elseif ($data.installFn -and $data.installFn.Ast) {
            # get params from installFn
            
            $paramBlock = $data.installFn.Ast.ParamBlock
            $params = $paramBlock.Parameters
            
            $textBoxVisibility = "Collapsed"
            $textBoxVisibility2 = "Collapsed"
            # first ist $ctx
            if ($params.Count -ge 2) {
                $textBoxVisibility = "Visible"
                if ($null -ne $params[1].Defaultvalue.value) {
                    $data.value = $params[1].Defaultvalue.value.toString()
                }
            }
            if ($params.Count -ge 3) {
                $textBoxVisibility2 = "Visible"
                if ($null -ne $params[2].Defaultvalue.value) {
                    $data.value2 = $params[2].Defaultvalue.value.toString()
                }
            }
        }
        
        $title = $item.BaseName
        If ($null -ne $data.title) {
            $title = $data.title
        }
        If ($null -ne $data.title2) {
            $title2 = $data.title2
        }
        if ($textBoxVisibility2 -eq "Visible" -and ($null -eq $data.title2) -and $title -match "`n") {
            $title, $title2 = $title -split "`n"
        }
        
        # if it was NOT defined by the profile, check the file itself
        If ($null -eq $isSelected) {
            $isSelected = $data.isSelected -eq $true
        }
        
        $id = Resolve-Path $item.FullName -Relative
        
        # add item
        $itemData = [PSCustomObject]@{Id=$id; Title=$title; Title2=$title2; Description=$data.description; Folder="actions"; FilePath=$dir.actions; FileName=$item.Name; IsSelected=[bool]$isSelected; DescriptionVisibility=$descriptionVisibility; TextBoxVisibility=$textBoxVisibility; Value=$data.value; TextBoxVisibility2=$textBoxVisibility2; Value2=$data.value2; Data=$data}
        <# $pos = #> $Elements.lvActions.Items.Add($itemData) | Out-Null
    }
}

Function Load-Tools {
    Param( $filter )
    
    $Elements.lvTools.Items.clear()
    
    if ($null -eq $filter) {
        $filter = ls "$($dir.tools)\*" |? {$_.Name -notlike "_.*" -and $_.Name -notlike ".*" }
    }
    
    $current, $avail = Get-PowershellInterpreter

    Foreach ($itemAorB in $filter) {
        # is string or object? We only want file items
        If ($null -ne $itemAorB.isSelected -and $null -ne $itemAorB.Name) {
            # the item is an tools item config: @{Name="...filename.ps1..."; isSelected=$true} object
            $item = gi "$($dir.tools)\$($itemAorB.Name)" -ErrorAction SilentlyContinue
        }
        elseif ($itemAorB.FullName) { # by the !$filter check, they are files
            # the item is a file object
            $item = $itemAorB
        }
        elseif ($itemAorB -is [string]) {
            # the item is a filename
            $item = gi "$($dir.tools)\$itemAorB" -ErrorAction SilentlyContinue
        }
        else {
            Write-ErrorClean "Unknown tools item type:"
            $itemAorB | Format-List | Out-String | Write-ErrorClean
            continue
        }
        
        if ($null -eq $item) {
            # gi * might not actually found a file
            Write-ErrorClean "Tools file does not exist: '$($dir.tools)\$itemAorB'"
            continue
        }



        $ext = $item.Extension

        if ($item.Name.EndsWith(".x.ps1", [System.StringComparison]::OrdinalIgnoreCase)) {
            $ext = ".x.ps1"
        }

        switch ($ext) {
            ".ps1" { # get its own console window and own session
                $data = @{
                    type = "default"
                    execFn = [scriptblock]::Create(@"
                    param(`$ctx)
                    start $current -Verb RunAs " -ExecutionPolicy ByPass -Command ```"cd '$($item.DirectoryName)\' ; . '$($script:contextFNsFile)' ; & '$($item.FullName)' `$ctx ; Read-Host 'Press Enter to close ...' ```" "
"@

                    )
                }
            }
            ".x.ps1" { # run in local session - and get ctx
                $data = @{
                    type = "psx"
                    execFn = [scriptblock]::Create("param(`$ctx) ; & '$($item.FullName)' `$ctx") # is filename
                }
            }
            default {
                $data = @{
                    type = "default"
                    execFn = [scriptblock]::Create("param(`$ctx) ; Start '$item' -WorkingDirectory '$($item.DirectoryName)' ")
                }
            }
        }
        
        $iconFile = Resolve-Path "$($dir.data)\default-app.png"  #fallback
        
        # cache icon
        try {
            $iconPath = Join-Path -Path $dir.cache -ChildPath ($item.Name -replace "\\","_")
            $iconFile = "$iconPath.bmp"
            
            If (!(Test-Path $iconFile)) { 
                [System.Drawing.Icon]::ExtractAssociatedIcon( $item.FullName ).ToBitmap().Save($iconFile)
                #Write-Host "writing file"
            }
            
            $iconFile = Resolve-Path $iconFile
        }
        catch {}
        
        $title = $item.Name
        If ($toolsExtHide -contains $item.Extension) {
            $title = $item.BaseName
        }
        
        $id = Resolve-Path $item.FullName -Relative
        
        <# $pos = #> $Elements.lvTools.Items.Add([PSCustomObject]@{Id=$id; Title=$title; Icon=[string]$iconFile; Folder="tools"; FilePath=$dir.tools; FileName=$item.Name; Data=$data}) | Out-Null
    }
    
    $ctx.toolsLoaded = $true
}


# --- Main functions: Invokes --------------------------


Function Invoke-Actions {
    Param( $filter, [ref]$Counter )
    
    #! WARNING: this is not the same as $Elements.lvActions.SelectedItems - SelectedItems does not reliably contain all selected items - only if they have been selected by clicking (otherwise its random)
    $selectedItems = $Elements.lvActions.items |? IsSelected -eq $true

    Write-Host "Actions ($($selectedItems.Count))" -ForegroundColor Green
    
    # do actions, Value and Value2 are bound by the list item back into the data object, even if it did not have it in the first place
    $i = 0
    Foreach ($itemData in $selectedItems) { # only provides the data object
        If ($ctx.doCancel) {Enable-Ui $true; return}
        If ($global:DoCancel) {Enable-Ui $true; $global:DoCancel = $false; return}
        
        $i++
        If ($Counter) { $Counter.Value++ }

        Write-Host "Action #$i/$($selectedItems.Count) -> $($ItemData.FileName)(`"$($itemData.Value)`", `"$($itemData.Value2)`")" -ForegroundColor Green
        
        $ctx.item = @{
            CtxId = $itemData.Id;
            Folder=$itemData.Folder;
            FilePath=$itemData.FilePath;
            FileName=$itemData.FileName;
        }
        
        $ctxRet = Start-AwaitJob $itemData.Data.installFn -ArgumentList $ctx,$itemData.Value,$itemData.Value2 -Dir $itemData.FilePath -InitBlock $script:contextFNs
        $script:ctx.lastExecResults[$itemData.Id] = $ctXRet
        
        $Elements.pbActions.Value = $i
    }

    if ($selectedItems.Count) {
        Invoke-BalloonTip $( $selectedItems | Join-String -Property 'FileName' -Separator ', ' )   "Actions done #$i/$($selectedItems.Count)"
    }
}

Function Invoke-Software {
    Param( $filter, [ref]$Counter )

    #! WARNING: this is not the same as $Elements.lvSoftware.SelectedItems - SelectedItems does not reliably contain all selected items - only if they have been selected by clicking (otherwise its random)
    $selectedItems = $Elements.lvSoftware.items |? IsSelected -eq $true

    Write-Host "Apps ($($selectedItems.Count))" -ForegroundColor Green
    
    # do apps
    $i = 0
    Foreach ($itemData in $selectedItems) {
        If ($ctx.doCancel) {Enable-Ui $true; return}
        If ($global:DoCancel) {Enable-Ui $true; $global:DoCancel = $false; return}
        
        $i++
        If ($Counter) { $Counter.Value++ }
        
        Write-Host "Installing #$i/$($selectedItems.Count) -> $($itemData.FileName)" -ForegroundColor Green
        
        $ctx.item = @{
            CtxId = $itemData.Id;
            Folder=$itemData.Folder;
            FilePath=$itemData.FilePath;
            FileName=$itemData.FileName;
        }

        $ctxType = $itemData.Data.ctxType
        if (!$ctxType) {
            $ctxType = Get-CtxTypeByFilename $itemData.FileName
        }
        $ctxConverted = Convert-Ctx $ctx -Type $ctxType
        
        # Unblocking the UI !
        $ctxRet = Start-AwaitJob $itemData.Data.installFn -ArgumentList $($ctxConverted) -Dir $itemData.FilePath -InitBlock $script:contextFNs
        $script:ctx.lastExecResults[$itemData.Id] = $ctXRet
        
        Invoke-BalloonTip $itemData.FileName "App done #$i/$($selectedItems.Count)"
        
        $Elements.pbSoftware.Value = $i
    }
}

Function Invoke-Tool {
    Param( $itemData )
    
    Write-Host "Starting Tool $($itemData.Id) - $($itemData.Type)"

    try {
        $ctx.item = @{
            CtxId = $itemData.Id;
            Folder=$itemData.Folder;
            FilePath=$itemData.FilePath;
            FileName=$itemData.FileName;
        }

        $ctxType = $itemData.Data.CtxType
        if (!$ctxType) {
            $ctxType = Get-CtxTypeByFilename $itemData.FileName
        }
        $ctxConverted = Convert-Ctx $ctx -Type $ctxType

        #execute command
        if ($itemData.Data.type -eq "psx") {
            $ctxRet = Start-AwaitJob $itemData.Data.execFn -ArgumentList $($ctxConverted) -Dir $itemData.FilePath -InitBlock $script:contextFNs
            $script:ctx.lastExecResults[$itemData.Id] = $ctXRet
        }
        else {
            # context info for all
            & $itemData.data.execFn $ctxConverted
        }
    }
    catch {
        Write-ErrorClean ("Error executing tool: " + $itemData.Id)
        Write-Error $_
    }
}


# --- Helpers --------------------------


Function Handle-AutoStart {
    $AutoStart = $AutoStart.ToLower()

    if ($AutoStart -and ($AutoStart -ne 'none')) {
        Write-Host "AutoStart: $AutoStart"
    }

    switch ($AutoStart) {
        'actions' { 
            Deploymentor.MainWindow.DoInstallActions_Click('AutoStart')
        }
        'software' {
            Deploymentor.MainWindow.DoInstallSoftware_Click('AutoStart')
        }
        'all' {
            Deploymentor.MainWindow.doInstallAll_Click('AutoStart')
        }
        'none' {}
        Default {}
    }
}

Function Set-ActionsBeforeSoftware {
    param (
        $actionsFirst = $true
    )

    $Elements.cbActonsFirst.IsChecked = $actionsFirst -eq $true
}


Function Prepare-Window {
    $MainWindow.title = "Deploymentor $VERSION"
}

Function Enable-Ui {
    Param( [bool]$Enable )
    
    $ctx.doCancel = $false   # always reset
    
    If ($Enable) {
        $Elements.tabControl.IsEnabled = $true
        $Elements.overlayCancel.Visibility = "Collapsed"
    }
    else {
        $Elements.tabControl.IsEnabled = $false
        $Elements.overlayCancel.Visibility = "Visible"
    }
}

Function Reset-Progressbars {
    $selectedItems = $Elements.lvSoftware.items |? IsSelected -eq $true
    $Elements.pbSoftware.Maximum = $selectedItems.Count
    $Elements.pbSoftware.Value = 0

    $selectedItems = $Elements.lvActions.items |? IsSelected -eq $true
    $Elements.pbActions.Maximum = $selectedItems.Count
    $Elements.pbActions.Value = 0
}

Function Reset-lastExecResults {
    $script:ctx.lastExecResults = @{}
}


Function Get-CtxTypeByFilename {
    param( $filename ) # allow filename strings and file objects

    $filenameStr = $filename;
    if ($filename.Name) {
        $filenameStr = $filename.Name
    }

    $resultExt = ""
    $resultCtxType = ""
    Foreach ($ext in $contextFormat.Keys) {
        if ($filenameStr.EndsWith($ext, [System.StringComparison]::OrdinalIgnoreCase)) {
            # in case there is a longer extension, use that one (support .x.ps1 for example)
            if ($ext.length -gt $resultExt.length) {
                $resultCtxType = $contextFormat[$ext]
                $resultExt = $ext
            }
        }
    }

    return $resultCtxType
}

Function Get-CtxTypeByExt {
    param($ext)

    return $contextFormat[$ext]
}

<# convert PS Objects to nice XML, specialized for $ctx (the lastExecResults part) #>
Function ConvertTo-NiceXml {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        $InputObject,

        [Parameter()]
        [string]$RootName = "Objects",

        [Parameter()]
        [boolean]$Indent = $true
    )

    process {
        $settings = New-Object System.Xml.XmlWriterSettings
        $settings.Indent = $Indent
        $settings.IndentChars = " "
        # This ensures that even if strings have leading/trailing spaces, they are preserved
        $settings.NewLineHandling = [System.Xml.NewLineHandling]::None 

        $stringBuilder = New-Object System.Text.StringBuilder
        $writer = [System.Xml.XmlWriter]::Create($stringBuilder, $settings)
        $writer.WriteStartDocument()

        function Write-Node {
            param($Name, $Value, $ParentName)

            # --- SPECIAL CASE: lastExecResults ---
            # Instead of <.\path\script.ps1 />, we create <result item=".\path\script.ps1">Value</result>
            if ($ParentName -eq "lastExecResults") {
                $writer.WriteStartElement("result")
                $writer.WriteAttributeString("item", $Name)
                if ($null -ne $Value) { $writer.WriteString([string]$Value) }
                $writer.WriteEndElement()
                return
            }

            # --- GENERAL CASE: Sanitize Key for Tag Name ---
            $CleanName = $Name -replace '[^a-zA-Z0-9_\-]', '_'
            if ($CleanName -match '^\d') { $CleanName = "v_$CleanName" }
            if ([string]::IsNullOrWhiteSpace($CleanName)) { $CleanName = "Item" }

            if ($Value -is [System.Collections.IDictionary]) {
                $writer.WriteStartElement($CleanName)
                foreach ($key in $Value.Keys) {
                    Write-Node -Name $key -Value $Value[$key] -ParentName $CleanName
                }
                $writer.WriteEndElement()
            }
            elseif ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) {
                $writer.WriteStartElement($CleanName)
                foreach ($item in $Value) {
                    Write-Node -Name "Item" -Value $item -ParentName $CleanName
                }
                $writer.WriteEndElement()
            }
            else {
                # Simple Leaf Node
                $writer.WriteStartElement($CleanName)
                if ($null -ne $Value) { 
                    # WriteString handles XML escaping (like < > &) automatically
                    $writer.WriteString([string]$Value) 
                }
                $writer.WriteEndElement()
            }
        }

        # Start recursion
        Write-Node -Name $RootName -Value $InputObject -ParentName ""

        $writer.WriteEndDocument()
        $writer.Flush()
        $writer.Close()

        return $stringBuilder.ToString()
    }
}

Function Convert-Ctx {
    Param( $ctx, [Alias("Type")][string]$ctxType )
    
    switch ($ctxType) {
        "native" { $ctxConverted = $ctx }
        "nativefile" { $ctxConverted = New-TemporaryFile; $ctx | Export-Clixml -Path $ctxConverted; $ctxConverted = "'" + $ctxConverted + "'" }
        "json" { $ctxConverted = "'" + ($ctx | ConvertTo-Json -Compress).replace("'","``'") + "'" }
        "jsonfile" { $ctxConverted = New-TemporaryFile; $ctx | ConvertTo-Json | Out-File $ctxConverted; $ctxConverted = "'" + $ctxConverted + "'" }
        #"xml" { $ctxConverted = "'" + ($ctx | ConvertTo-Xml -NoTypeInformation -As String).replace("'","``'").replace("`r","").replace("`n","") + "'" }
        "xml" { $ctxConverted = "'" + ($ctx | ConvertTo-NiceXml -RootName "Context" -Indent $false).replace("'","``'").replace("`r","").replace("`n","") + "'" }
        # "xmlfile" { $ctxConverted = New-TemporaryFile; $ctx | ConvertTo-Xml -NoTypeInformation -As String | Out-File $ctxConverted; $ctxConverted = "'" + $ctxConverted + "'" }
        "xmlfile" { $ctxConverted = New-TemporaryFile; $ctx | ConvertTo-NiceXml -RootName "Context" -Indent $true | Out-File $ctxConverted; $ctxConverted = "'" + $ctxConverted + "'" }
        default { $ctxConverted = New-TemporaryFile; $ctx | Format-List | Out-File $ctxConverted }
    }
    
    return $ctxConverted
}


# --- Titlebar Darkmode --------------------------


Function Set-DarkModeTitlebar {
    param (
        [System.Windows.Window]$Window,
        [bool]$IsDark
    )

    Add-Type -AssemblyName System.Runtime.InteropServices
    Add-Type -Namespace Deploymentor -Name DwmApi -MemberDefinition '
        [DllImport("dwmapi.dll", PreserveSig = true)]
        public static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int attrValue, int attrSize);
    '


    # 20 is the code for DWMWA_USE_IMMERSIVE_DARK_MODE on modern Windows
    $DWMWA_USE_IMMERSIVE_DARK_MODE = 20
    
    # Get standard window handle
    $interopHelper = New-Object System.Windows.Interop.WindowInteropHelper($Window)
    $hwnd = $interopHelper.Handle
    
    if ($hwnd -ne [IntPtr]::Zero) {
        $trueValue = if ($IsDark) { 1 } else { 0 }
        [Deploymentor.DwmApi]::DwmSetWindowAttribute($hwnd, $DWMWA_USE_IMMERSIVE_DARK_MODE, [ref]$trueValue, 4) | Out-Null
    }
}

# Window initialized
$MainWindow.Add_SourceInitialized({
    $Elements.tglDarkMode.IsChecked = ($darkMode -eq $true)
})

$Elements.tglDarkMode.Add_Checked({
    Set-DarkModeTitlebar -Window $MainWindow -IsDark $true
})
$Elements.tglDarkMode.Add_Unchecked({
    Set-DarkModeTitlebar -Window $MainWindow -IsDark $false
})



# --- INIT --------------------------------------------------------------------------------------



# Set window title
Prepare-Window
# In case I forgot to hide the cancel banner in Visual Studio again ...
Enable-Ui $true
# Reset for use
Reset-Progressbars

# Initial load
Load-ContextFns
Load-ProfileList
Load-ProfileByParam $ProfileSelected # or without :)

Write-Host "Waiting for main window to close."
$MainWindow | Show-Window

# Return to previous location
#Set-Location $callingFromPWD

# stop logging
Stop-Transcript -ErrorAction SilentlyContinue