# api: ps
# title: GUI handling, WPF + WinForms
# description: WinForm and WPF interaction, GUI and thread handling
# depends: menu, clipboard, sys:presentationframework, sys:system.windows.forms, sys:system.drawing
# doc: http://fossil.include-once.org/clickycoloury/wiki/W
# version: 1.2.1
# license: MITL
# type: functions
# category: ui
# config:
# { name: cfg.threading, type: bool, value: 1, description: Enable threading/runspaces for GUI/WPF version }
# { name: cfg.autoclear, type: int, value: 300, description: Clear output box after N seconds. }
# { name: cfg.noheader, type: bool, value: 0, description: Disable script info header in output. }
# { name: cfg.theme, type: select, select: "dark|bunti|beta", value: dark, description: XAML theme file. }
# status: stable
# priority: core
#
# Handles $menu list in WPF window. Creates widgets and menu entries
# for plugin list / $menu meta data.
# ยท `type: inline` is the implied default, renders output in TextBlock
# ยท `type: cli` or `window` plugins are run in a separate window
# ยท `hidden: 1` tools only show up in menus
# ยท `keycode:` is used for shortcuts; the CLI `key:` regex ignored
# ยท `type: init-gui` plugins are run once during GUI construction
# ยท Whereas `type: init` execute in the main/script RunSpace
#
# The responsive UI is achieved through:
# ยท A new runspace for the GUI and a trivial message queue in $GUI.tasks.
# ยท Main loop simply holds the window open, then executes $GUI.tasks events.
# ยท That event queue contains the verbatim entries from $menu (mostly).
# ยท Pipes them through `Run-GuiTask` (Should ideally be identical to the
# one in menu.psm1, but needs a few customizations here.)
#
# All widget interactions are confined to the WPF runspace/thread.
# ยท WPF interaction through `$GUI` would often hang both processes.
# ยท Thus `Out-Gui` manages output, and a few helpers for title, var fetching.
# ยท Toolbar actions run entirely in the GUI thread, thus can freeze UI a bit.
#
# Scripts/tools should work identically as for the CLI version:
# ยท Aliases for `Write-Host` and `Read-Host` should make it transparent.
# ยท However simple console output (typically to the stdout stream) will
# have to be pipe-captured.
# ยท Order of pipe/stream output and Write-Host calls do not intermix still.
#
#-- register libs
Add-Type -AN PresentationCore, PresentationFramework, WindowsBase
Add-Type -AN System.Drawing, System.Windows.Forms, Microsoft.VisualBasic
[System.Windows.Forms.Application]::EnableVisualStyles()
[System.Windows.Forms.Application]::SetCompatibleTextRenderingDefault($true)
#-- init a few local vars
# - $GUI, $last_output etc. get created in Start-Win
# - New-GuiThread inherits $menu,$cfg,$plugins from global
$ModuleDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$BaseDir = Split-Path -Parent $ModuleDir
#-- standard UI input widgets and handler names
$cfg.standard_fields = @{
machine = "Computer"
username = "Username"
#bulkcsv = "BulkCSV"
}
$cfg.standard_aliases = @{
username = "^(?i)[$]?(AD[-\s]?)?(User|Account)(?:[-\s]?Name)?\s*[=:?]*\s*$"
machine = "^(?i)[$]?(Computer|Machine|PC|Host)([-\s]?Name)?\s*[=:?]*\s*$"
#bulkcsv = "^(?i)[$]?(Bulk|bulk.?csv|bulkfn|list|CSV)" # obsolete
}
$cfg.standard_actions = @{ # Add-ButtonHooks
Cpy = 'Set-Clipboard $GUI.{0}.Text'
Clr = '$GUI.{0}.Text = ""; $GUI.{0}.Background = "White"'
"" = '$GUI.{0}.Text = (Get-Clipboard).trim()'
}
#-- WPF/WinForms widget wrapper
function W {
<#
.SYNOPSIS
Convenient wrapper to create WPF or WinForms widgets.
.DESCRIPTION
Allows to instantiate and modify WPF/XAML or WinForms UI widgets.
The $prop hash unifies property assignments and widget method calls.
Notably this implementation is a bit slow, due to this abstraction and
probing different object trees.
.PARAMERER type
Widget type (e.g. "Button" or "Label") or existing $w widget object.
.PARAMETER prop
HashTable listing widget attributes or methods to call:
@{Color="Red"; Text="Hello World"; Add=$other_widget}
Property+method names depend on whether WPF or WinForms widgets are used.
.PARAMETER base
Defaults to "Controls" for WPF widgets, but can be set to "Forms" for WF.
.OUTPUTS
Returns the instantiated Widget.
.EXAMPLE
Create a button:
$w_btn = W Button @{Content="Text"; Border=2; add_Click=$cb}
Add child widgets per list:
$w_grd = W Grid @{Add=$w1, $w2, $w3}
Or chain creation of multiple widgets:
$w_all = W StackPanel {Spacing=5; Add=(
(W Button @{Content="OK"}),
(W Label @{Content="Really?"})
)}
The nesting gets confusing for WPF, but often simplifies WinForm structures.
.NOTES
The shortcut method `WF` creates WinForms widgets, whereas `WD` is
for TextBlock/document inlines.
#>
[CmdletBinding()]
Param($type = "Button", $prop = @{}, $Base="Controls")
#-- new object
if ($type.getType().Name -eq "String") {
$w = New-Object System.Windows.$Base.$type
}
else {
$w = $type
}
#@bug on FOOTERM01 w/ PS 3.0
if (($PSVersionTable.PSVersion.Major -eq 3) -and ($w -is [System.Windows.Thickness])) { return $w; }
#-- apply options+methods
$prop.keys | % {
$key = $_
$val = $prop[$_]
if ($pt = ($w | Get-Member -Force -Name $key)) { $pt = $pt.MemberType }
#-- properties
if ($pt -eq "Property") {
if (($Base -eq "Forms") -and (@("Size" , "ItemSize", "Location") -contains $key)) {
$val = New-Object System.Drawing.Size($val[0], $val[1])
}
$w.$key = $val
}
#-- check for methods in widget and common subcontainers
else {
try {
ForEach ($obj in @($w, $w.Children, $w.Child, $w.Container, $w.Controls)) {
if ($obj.psobject -and $obj.psobject.methods.match($key) -and $obj.$key) {
([array]$val) | ? { $obj.$key.Invoke } | % { $obj.$key.Invoke($_) } | Out-Null
break
}
}
}
catch [ArgumentException] { # Augment failures with useful context.
Write-Error "Failed calling '$key' on '$obj' with argument '$val'`r`n"
#Write-Error $PSItem.Exception.StackTrace
}
}
}
return $w
}
#-- WinForms version
function WF {
Param($type, $prop, $add=@{}, $click=$null)
if ($add) { $prop.add = $add }
if ($click) { $prop.add_click = $click }
W -Base Forms -Type $type -Prop $prop
}
#-- Document "widgets"
function WD {
Param($type, $prop=@{})
W -Base Documents -Type $type -Prop $prop
}
#-- WPF main window
function WPF-Window {
<#
.SYNOPSIS
Loads XAML file `wpf.xaml` from same directory as this module.
.DESCRIPTION
Also populates $GUI.$_ for a select few global widget names.
.NOTES
Workaround img\ cache dir not enabled.
#>
Param($main="kbt5.xaml", $theme=$cfg.theme)
#-- ImgDir
$ImgDir = $BaseDir
#if (Test-Path "$($env:APPDATA)\multitool\img") { $ImgDir = "$($env:APPDATA)/multitool" }
#-- load + patch
$xaml = (Get-Content "$BaseDir/modules/$main" | Out-String)
$xaml = $xaml -replace "e:/","$ImgDir/"
if (Test-Path ($fn = "modules/theme.$theme.xaml")) { $xaml = $xaml -replace "modules/theme.\w+.xaml", $fn }
$w = [Windows.Markup.XamlReader]::Load((New-Object System.Xml.XmlNodeReader ([xml]$xaml)))
#-- save aliases int $GUI (sync hash across all threads/runspaces)
$GUI.styles = $w.Resources
$shortcuts = "Window,Menu,Ribbon,Grid_ALL,Output,Cancel," + ($cfg.standard_fields.Keys -join ",")
$shortcuts.split(",") | % { $GUI.$_ = $w.findName($_) } | Out-Null
#--- return
return $w
}
#-- create new runspace
function New-GuiThread {
<#
.SYNOPSIS
Creates a new Runspace for the GUI interface.
.DESCRIPTION
Copies functions over into new Runspace/Pipeline and attaches shared $GUI
hashtable for data exchange.
.EXAMPLE
New-GuiThread {code} -Funcs "copy1,copy2" -Vars "menu,GUI" -Modules "WPF"
.NOTES
Does not work with PS 5.0 yet (presumably scoping or syntax issue with func duplication).
#>
Param(
$code = {},
$funcs = "", #"WPF-Window,W,WF,WD" no longer used, now reloading modules
$modules = "",
$vars = "menu",
$level = "",
[switch]$invoke=$true
)
#-- create, add functions and code
$shell = [PowerShell]::Create()
$shell.AddScript($code) | Out-Null
#-- separate thread
$shell.Runspace = [runspacefactory]::CreateRunspace()
$shell.Runspace.ApartmentState = "STA"
$shell.Runspace.ThreadOptions = "ReuseThread"
$shell.Runspace.Open() | out-null
#-- add vars, modules
$shell.Runspace.SessionStateProxy.SetVariable("GUI", $GUI) | out-null
$shell.Runspace.SessionStateProxy.SetVariable("vars", @{}) | out-null
$shell.Runspace.SessionStateProxy.SetVariable("ParentHost", $Host) | out-null
$vars.split(",") | ? { $_ } | % { $shell.Runspace.SessionStateProxy.SetVariable($_, (get-variable $_).value) } | Out-Null
#-- start
if ($invoke) {
$handle = $shell.BeginInvoke()
}
return $shell
}
#-- Look up icon basename path variations
function Get-IconPath {
<#
.SYNOPSIS
Looks up alternative filenames for icons.
.DESCRIPTION9
Scans for PNGs (filename from PMD .icon or .img field) in $BaseDir/img/
.PARAMETER basenames
Can be a list of icon names "user", "icon.user", "user2.png" etc.
#>
[CmdletBinding()]
Param([Parameter(ValueFromRemainingArguments=$true)]$basenames)
ForEach ($fn in ($basenames | ? { $_ -ne $null })) {
ForEach ($png in "icon.$fn.png", "icon.$fn", "$fn.png", "$fn") {
if (Test-Path ($path = "$BaseDir/img/$png")) {
return $path
}
}
}
}
#-- Add tool buttons to main window
function Add-GuiMenu {
<#
.SYNOPSIS
Adds menu entries and button blocks for each plugin from $menu
.DESCRIPTION
Iterators over the $menu list
- skips "hidden:" or "nomenu:" entries, or "type:init*" plugins
- adds a MenuItem and callback
- uses the PMD .category to find the right menu or grid/notebook tab
- looks up plugin or category icons (WPF requires unique instances each?!)
- calls Create-GuiParamFields for extra input variables
Also handles shortcut icons section.
.NOTES
This is what causes the slow startup! (perhaps due to `W` being too convenient)
#>
Param($menu)
$icon_default = W Image # empty image
$GRID = $GUI.Grid_ALL # now a TreeView
$TV = @{}
$cb_TV = { $GUI.Grid_ALL.Items | % { $_.IsExpanded = ($_ -eq $this) } }
$steps = 500 / $menu.count;
ForEach ($e in $menu) {
Write-Splash "Add-GuiMenu ($($e.title))" $steps
#-- prepare params
$e.hidden = ($e.hidden -match "1|yes|hide|hidden|true")
if ($e.type -eq "init") { continue; }
$CAT = $e.category.toUpper();
#-- output category header
if (($e.category -ne $category) -and (-not $e.hidden)) {
$category = $e.category
if (-not $TV[$category]) {
$TV[$category] = (W TreeViewItem @{Header="$category"; FontWeight="Bold"; FontSize=16; Add_MouseUp=$cb_TV})
$GRID.Items.Add($TV[$category]);
}
}
#-- callback (= now just appends to event queue)
$cb = { $GUI.tasks += $e }.getnewclosure()
#-- action block/button
if (-not $e.hidden) {
$label = (W StackPanel @{Orientation="Horizontal"; Padding=0; Margin=0; Height=18; Add=@(
(W Image @{Source=(Get-IconPath $e.img $e.icon $category $e.x_category); Width=16; Height=16;}),
(W Label @{Content=$e.title; Padding=0; Margin="2,0"; Height=18; FontSize=12; FontWeight="Normal"})
)})
$btn = (W Button @{Content=$label; Add_Click=$cb; BorderWidth=1; Padding=0; Height=22; Margin="0,1,0,2"; ToolTip=$e.doc})
$TV[$category].Items.Add($btn)
}
#-- add menu entry
if (($e.type -notmatch "^init") -and ($e.category) -and (!$e.nomenu)) {
$m = $GUI.w.findName("Menu_$($CAT)")
# new Extras > submenu if not found
if (-not $m) {
$m = (W MenuItem @{Name=$CAT; Header=$e.category})
$GUI.w.findName("Menu_EXTRAS").Items.Add($m)
$GUI.w.registerName("Menu_$CAT", $m)
}
# add
$ICON = W Image @{Source=(Get-IconPath $e.img $e.icon $e.category); Height=20; Width=20}
$m.Items.Add((W MenuItem @{Header=$e.title; InputGestureText=$e.keycode; Icon=$ICON; ToolTip=(W ToolTip @{Content=$e.description}); Add_Click=$cb}))
}
}
#-- and a shortcut - have their own custom sorting from `#shortcut: 123`
if ($m = $GUI.w.findName("Shortcuts")) {
ForEach ($e in ($menu | ? { $_.shortcut -match "\d+|true|yes" } | Sort-Object @{expression={$_.shortcut.trim()}} )) {
$cb = { $GUI.tasks += $e }.getnewclosure()
$ICON = W Image @{Source=(Get-IconPath $e.img $e.icon $e.category); Height=22; Width=22}
$BTN = W Button @{Style=$GUI.styles.ActionButton; ToolTip=(W ToolTip @{Content=$e.title}); Add_click=$cb; Height=22; Width=22; Content=$ICON}
$m.Children.Add($BTN)
}
}
}
#-- FlowDocument textrange (for clipboard functions)
function Get-OutputTextRange {
New-Object System.Windows.Documents.TextRange -ArgumentList @($GUI.output.ContentStart,$GUI.Output.ContentEnd)
}
#-- attach callbacks for main UI buttons
function Add-ButtonHooks {
<#
.SYNOPSIS
Prepares callbacks for buttons/toolbar in main window.
.DESCRIPTION
Such as the computer and username fields, or the clipboard functionality.
.NOTES
Ping and username lookup can freeze the UI, as they run in the WPF runspace already.
$GUI.machine and $GUI.username are shared with the main thread.
As is the clipboards $GUI.html shadow content.
#>
#-- default clipboard actions (Clear+Copy) for primary GUI input boxes
ForEach ($key in $cfg.standard_fields.Keys) {
ForEach ($suffix in $cfg.standard_actions.Keys) {
if ($btn = $GUI.w.findName("Btn" + $cfg.standard_fields[$key] + $suffix)) {
$btn.add_Click([scriptblock]::create($cfg.standard_actions[$suffix] -f $key))
}
}
}
#-- computer name
$GUI.w.findName("BtnComputer").add_Click({
if ($m = (Get-Clipboard).trim()) {
$GUI.machine.Text = $m
$col = if (Test-Connection $m -Count 1 -Quiet -TTL 10 -ErrorAction SilentlyContinue) {"#5599ff99"} else {"#55ff7755"}
$GUI.machine.Items.Insert(0, (W ComboBoxItem @{Content=$m; Background=$col}))
$GUI.machine.Background = "$col"
}
})
$GUI.w.findName("BtnComputerPng").add_Click({ })
$GUI.w.findName("BtnComputerUsr").add_Click({ if ($u = Get-MachineCurrentUser $GUI.machine.Text) { $GUI.username.Text = $u } })
#-- user name
$GUI.w.findName("BtnUsername").add_Click({
$u = (Get-Clipboard).trim()
if ($u -match "\w+[@, ]+\w+") { $u = (AD-Search $u -only 1) }
$GUI.username.Text = "$u"
})
$GUI.username.add_DropDownOpened({
if (($u = $GUI.username.Text).length -gt 2) {
$GUI.username.Items.Clear()
AD-Search $u | % { $GUI.username.Items.Add($_) }
}
})
$GUI.username.add_DropDownClosed({
$GUI.username.Text = $GUI.username.Text -replace "\s*\|.+$", ""
})
$GUI.w.findName("BtnUsernameCom").add_Click({ })
#-- other fields
#
#-- clipboard tools
$GUI.w.findName("BtnClipText").add_Click({
Set-Clipboard ((Get-OutputTextRange).Text)
})
$GUI.w.findName("BtnClipHtml").add_Click({
# use $html when available
if ($GUI.html) {
Set-ClipboardHtml $GUI.html
}
elseif (Test-Path function:Convert-FlowdocumentToHtml) {
Set-ClipboardHtml (Convert-FlowdocumentToHtml $GUI.Output.Blocks)
}
else { # use RTF for now (no XAML-to-HTML converter yet)
$ms = New-Object System.IO.MemoryStream
$null = (Get-OutputTextRange).Save($ms, "Rich Text Format")
$str = [System.Text.Encoding]::ASCII.GetString($ms.getBuffer())
[System.Windows.Forms.Clipboard]::setData("Rich Text Format", $str)
}
})
$GUI.w.findName("BtnClipFree").add_Click({
$GUI.prev_output = (Get-OutputTextRange).Text # retain only text version
$GUI.html = ""
$GUI.output.Blocks.Clear()
})
$GUI.w.findName("BtnClipSwap").add_Click({
$GUI.output.blocks.clear()
Out-Gui ($GUI.prev_output)
})
#-- Cancel event
$GUI.Cancel.add_Click({
$ParentHost.Runspace.Events.GenerateEvent("Gui.Cancel", $this, $null, $null)
})
#-- window closing
$GUI.w.add_Closed({
$GUI.closed = $true
$GUI.w = $null
$ParentHost.Runspace.Events.GenerateEvent("Gui.Exit", $this, $null, $null)
})
#-- unicode symbols
if ($ls = $GUI.w.findName("UnicodeClip")) {
$cb = {Set-Clipboard $this.Content}
ForEach ($btn in $ls.Children) {
$btn.Add_click($cb)
}
}
}
#-- User lookup (simple `finduser` variant)
function AD-Search {
<#
.SYNOPSIS
Simple AD username search
.DESCRIPTION
Gets invoked whenever somethings is pasted into `username` input field.
Defaults to search for "Last, First" names and spit out "UserName123".
Also may scan for phone numbers or email addresses (used in UI for dropdown).
.NOTES
Defaults to [adsisearched], but may use Get-ADUser when available. By default
the ActiveDirectory module is not loaded again into the WPF/UI Runspace.
May require customization if AD displayname does not follow "First, Last" scheme.
#>
Param(
$s = "",
$only = 0
)
$s = ($s -replace "%","*")
#-- AD search
if (Test-Path function:Get-ADUser) {
$filter = switch -regex ($s) {
"^[*?\d]+$" { "telephoneNumber -like '*$s'" }
"^\w+, \w+" { "displayname -like '$s*'" }
"\w+@\w+" { "mail -like '$s'" }
default { "displayname -like '$s*' -or samaccountname -like '$s*'" }
}
#-- find
$u = Get-ADUser -Filter $filter -Properties samaccountname,displayname,telephonenumber
}
#-- crude cache search
elseif ($cache) {
return ((Get-Content "data\adsearch.txt") -match $s)
}
#-- ADSI query
else {
$s = $s -replace '([;\\"#+=<>])', '\$1'
$filter = switch -regex ($s) {
"^[*?\d]+$" { "telephonenumber=*$s" }
"^\w+\\?, \w+" { "displayname=$s" }
".+\w+@\w+" { "mail=$s" }
default { "|(displayname=$s*)(samaccountname=$s*)" }
}
$adsi = [adsisearcher]"(&(objectClass=user)(objectCategory=person)(!userAccountControl:1.2.840.113556.1.4.803:=2)($filter))"
[void]$adsi.PropertiesToLoad.AddRange(("telephonenumber","displayname","samaccountname","mail","*","+"))
$u = ($adsi.findAll() | % { $_.properties })
}
#-- result
if ($only) {
if ($u -is "array") { $u = $u[0] }
return [string]$u.samaccountname
}
else { return @($u | % { "{0} | {1} | {2}" -f [string]$_.samaccountname, [string]$_.displayname, [string]$_.telephonenumber }) }
}
#-- get current user
function Get-MachineCurrentUser($m) {
<#
.SYNOPSIS
Get current user from remote machine name.
.DESCRIPTION
Runs a quick WMI query. Returns user name (domain prefix stripped).
.NOTES
Alternatively now falls back on Explorer.exe process scan.
#>
if (!$m) {
}
elseif (($w = gwmi win32_computersystem -computername $m) -and ($u = $w.username)) {
return $u -replace "^\w+[\\\\]",""
}
elseif ($W = GWMI Win32_Process -ComputerName $m -filter "name='Explorer.exe'") {
$r = ($w | % { $_.getOwner().user } | ? { $_ -notmatch "^SYSTEM$" })
if ($r -is [array]) { $r = $r[0] }
return $r
}
}
##########################################################################################
######################## shared between main and GUI runspace ########################
##########################################################################################
#-- Startup splash screen (WinForms)
# ยท runs in main thread, no callbacks or extra background handling
# ยท keeps its own reference once initialized by first call
function Write-Splash {
Param($msg, $progress=25, $TITLE="โถ ClickyColoury", $VENDOR="PWSH")
$null = &{
if (!$Splash) {
$bold = New-Object System.Drawing.Font -ArgumentList "Verdana",13,"Bold","Pixel"
$impc = New-Object System.Drawing.Font -ArgumentList "Lucida Sans",32,"Bold","Pixel"
$bg = [System.Drawing.Image]::FromFile("$BaseDir/img/splash.jpeg")
$v = $PSVersionTable.PSVersion
$global:Splash = @{
psv1 = (WF Label @{Text=$VENDOR; Location=5,5; Size=50,16; Font=$BOLD; ForeColor="Gray"; BackColor="Transparent"})
psv2 = (WF Label @{Text="$($v.Major).$($v.Minor)"; Location=50,5; Size=40,16; Font=$BOLD; ForeColor="Orange"; BackColor="Transparent"})
title = (WF Label @{Text=$TITLE; Location=5,25; Size=280,55; AutoSize=0; TextAlign="MiddleCenter"; Font=$IMPC; ForeColor="#ffffff"; BackColor="Transparent"})
prg = (WF ProgressBar @{Location=10,80; Size=280,30; Value=1; Step=25; Maximum=1000; BackColor="#111133"; ForeColor="#ddbb22"})
msg = (WF Label @{Location=15,120; Size=270,20; Text="..."; ForeColor="#eeffdd"; BackColor="Transparent"})
}
$Splash.win = (WF Form @{Text="Clicky"; Size=300,150; StartPosition="CenterScreen"; Opacity=0.95; BackgroundImage=$bg; FormBorderStyle="None"; OnLoad={$this.close()}} -Add $Splash.Values)
$Splash.win.show()
}
$Splash.prg.PerformStep()
$Splash.msg.Text = $msg
$Splash.prg.Step = $progress # current step is used for next redraw
$Splash.win.Refresh()
if ($progress -ge 1000) { $Splash.win.close(); $Splash = @{} }
}
}
#-- GUI output
# ยท Primarily appends Paragraphs to `output` FlowDocument
# ยท Is called from main thread (and the only allowed interaction method),
# itself uses w.Dispatcher.Invoke to execute codeblock in GUI runspace
function Out-Gui {
<#
.SYNOPSIS
Fills the $GUI.output TextBlock from calls to Write-Host (aliased to Out-Gui).
.DESCRIPTION
Mainly converts any objects (HashTables/PSObjects) to strings, then appends them to main output pane.
Since this is called from the main script Runspace, uses the WPF dispatcher to execute the appending
in the WPF runspace (factually WPF does it in another separate thread).
.PARAM str
Input string or object
.PARAM f
Foreground color
.PARAM b
Background color
.PARAM N
NoNewLine
.PARAM bold, s
Not CLI/Write-Host compatible. Should not be used unless the plugin/script was meant for GUI usage only.
.NOTES
Ultimately this should handle error/exception highlighting and creating OUtGrid-like display
for HashTables and Objects. Not implemented yet, as it doesn't even run on PS 5.0 yet.
Also should be rewritten to Begin{} Process{} End{} and split up to handle proper piping.
.NOTES
Getting this to work in Powershell 5.0 was quite an act. Apparently you can't create
new Document widgets in the Dispatcher thread anymore. It's again related to object/variable
scopes. Using [action][scriptblock]::create() did severe the $str/etc. variable binding.
Thus we're now creating XAML flowdocument inlines before passing it on. Much slower, but
gives more flexibility later on.
#>
[CmdletBinding()]
Param(
[Parameter(ValueFromPipeline=$true)]$str = $null,
[alias("Foreground")]$f = $null,
[alias("Background")]$b = $null,
[alias("Bld")]$bold = $null,
[alias("Strikethrough")]$S = $null,
[alias("Underline")]$U = $null,
[alias("NoNewLine")][switch]$N = $false,
$style = "",
$xaml = $null, # Alternatively to string/object input
$cols = "^(#([a-f0-9]{3,4}){1,2}|Transparent|liceBlue|AntiqueWhite|Aqua|Aquamarine|Azure|Beige|Bisque|Black|BlanchedAlmond|Blue|BlueViolet|Brown|BurlyWood|CadetBlue|Chartreuse|Chocolate|Coral|CornflowerBlue|Cornsilk|Crimson|Cyan|DarkBlue|DarkCyan|DarkGoldenrod|DarkGray|DarkGreen|DarkKhaki|DarkMagenta|DarkOliveGreen|DarkOrange|DarkOrchid|DarkRed|DarkSalmon|DarkSeaGreen|DarkSlateBlue|DarkSlateGray|DarkTurquoise|DarkViolet|DeepPink|DeepSkyBlue|DimGray|DodgerBlue|Firebrick|FloralWhite|ForestGreen|Fuchsia|Gainsboro|GhostWhite|Gold|Goldenrod|Gray|Green|GreenYellow|Honeydew|HotPink|IndianRed|Indigo|Ivory|Khaki|Lavender|LavenderBlush|LawnGreen|LemonChiffon|LightBlue|LightCoral|LightCyan|LightGoldenrodYellow|LightGreen|LightGray|LightPink|LightSalmon|LightSeaGreen|LightSkyBlue|LightSlateGray|LightSteelBlue|LightYellow|Lime|LimeGreen|Linen|Magenta|Maroon|MediumAquamarine|MediumBlue|MediumOrchid|MediumPurple|MediumSeaGreen|MediumSlateBlue|MediumSpringGreen|MediumTurquoise|MediumVioletRed|MidnightBlue|MintCream|MistyRose|Moccasin|NavajoWhite|Navy|OldLace|Olive|OliveDrab|Orange|OrangeRed|Orchid|PaleGoldenrod|PaleGreen|PaleTurquoise|PaleVioletRed|PapayaWhip|PeachPuff|Peru|Pink|Plum|PowderBlue|Purple|Red|RosyBrown|RoyalBlue|SaddleBrown|Salmon|SandyBrown|SeaGreen|SeaShell|Sienna|Silver|SkyBlue|SlateBlue|SlateGray|Snow|SpringGreen|SteelBlue|Tan|Teal|Thistle|Tomato|Turquoise|Violet|Wheat|White|WhiteSmoke|Yellow|YellowGreen)$"
)
BEGIN {
#--PS5dbg
TRAP { $_ | Out-String >> ./data/log.errors.wpf.txt }
#-- prepare output, check types
try { $type = $str.GetType().Name } catch { $str = ""; $type = "unknown" }
#-- finalized output
if ($xaml) {
# leave as-is
}
#-- assume string
else {
$style = ""
if ($f -and $f -match $cols) { $style += " Foreground='$f'" }
if ($b -and $b -match $cols) { $style += " Background='$b'" }
if ($bold) { $style += " FontWeight='Bold'" }
if ($S) { $style += " TextDecorations='Strikethrough'" }
if ($U) { $style += " TextDecorations='Underline'" }
}
}
PROCESS {
#-- convert current pipeline object to string
if ($str) {
try {
$str = ($str | Out-String -Width 120) -replace "\r?\n$",""
}
catch {
$str = "โ"
}
$xaml = @"
<Paragraph
xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation'
xml:space='preserve' Margin="0,1" $style
><![CDATA[$str]]></Paragraph>
"@
}
elseif (-not $xaml) {return}
#-- inject via dispatcher
Dispatch-GUI ([action]{
try {
#-- append as XAML
$w = [Windows.Markup.XamlReader]::Parse($xaml)
[void]$GUI.output.Blocks.Add($w)
}
catch {
$ParentHost.UI.Write("ERR_OUT_GUI_ACTION: $($_|FL|Out-String)`r`n")
#$GUI.output.Blocks.AddText(($_|FL|Out-String))
}
})
$xaml = $null
}
END {
Dispatch-GUI ([action]{ [void]$GUI.output.Parent.Parent.ScrollToEnd() })
}
}
#-- invoke [action] callback via main window dispatcher
# PowerShell >= 3.0 does need the parameters swapped
# @src: https://gallery.technet.microsoft.com/Script-New-ProgressBar-329abbfd
#
function Dispatch-Gui($action, $args=@()) {
if ($PSVersionTable.PSVersion.Major -eq 2) {
[void]$GUI.w.Dispatcher.Invoke("Normal", $action)
}
else {
[void]$GUI.w.Dispatcher.Invoke($action, "Normal") #, $args)
}
}
#-- just -Title update
function Set-GuiTitle($title) {
Dispatch-Gui ([action]{ $GUI.w.title = $title })
}
#-- clear Window output pane
# ยท if last output over 5 minutes ago (`-clear:$true` is set by caller)
function Clear-GuiOutput {
$GUI.html = ""
Dispatch-Gui ([action]{
$GUI.prev_output = (Get-OutputTextRange).Text
$GUI.output.blocks.clear()
})
}
#-- Read standard input fields
# ยท this is a workaround, because accessing $GUI.machine.text
# directly from parent runspace would hang up WPF
# ยท for some reason also needs its hashtable recreated
function Get-GuiStandardVars {
Dispatch-Gui ([action]{
$GUI.vars = @{
machine = $GUI.machine.Text
username = $GUI.username.Text
}
})
}
##########################################################################################
######################## everything below runs in main thread ########################
##########################################################################################
#-- build input widget blocks
# ยท called for entries in $meta.vars[] (each an hash with .name/.type/.value/...)
# ยท only returns an input widget (checkbox, textbox, combobox, button) each
# ยท default .value is used unless it's a standard var, or was previously set
function New-GuiVarInput {
Param($PARAM, $CURRENT, $SELECT_FN = "./data/combobox.{0}.txt")
$NAME = $PARAM.name
$ALIAS= Get-VarsAlias $NAME
# value preference: (1) explicit alias โ `{value:$varname}` or expression `{value:$(...)}`
if (($PARAM.VALUE -match '^\$(\w+)$') -and $GUI.vars.containsKey($matches[1])) { $VALUE = $GUI.vars[$matches[1]] }
elseif ($PARAM.VALUE -match '^\$\((\w.+)\)$') { $VALUE = Invoke-Expression ($matches[1] -replace '\$(username|machine)','$GUI.vars["$1"]') }
elseif ($VALUE = $GUI.vars[$ALIAS]) {} # (2) use standard vars
elseif ($VALUE = $CURRENT[$NAME]) {} # (3) previously entered data
else { $VALUE = $PARAM.value } # (4) literal default โ `{value:"..."}`
# widget/type variants
switch -regex ($PARAM.type) {
"bool" {
return W CheckBox @{Margin="10,0"; IsChecked=($VALUE -notmatch "^$|0|false|no")}
}
"text|csv|long" {
return W TextBox @{ Height=120; Width=280; Text=$VALUE+"`r`n"; AcceptsReturn=1 }
}
"select|combo" {
if ($PARAM.select) { $ls = $PARAM.select.split("[,;|]") } # normal `select: alternative|list|syntax`
elseif (Test-Path ($SELECT_FN -f $NAME)) { $ls = Get-Content ($SELECT_FN -f $NAME) } # read combobox.name.txt else
else { $ls = @("Null") }
if (!$VALUE) { $VALUE = $ls[0] } # default value = first entry in select list
return W ComboBox @{Height=22; Width=260; IsEditable=$true; Text=$VALUE; ItemsSource=$ls }
}
"btn|button|action" {
return W Button @{Content="โถ "+$PARAM.description; Width=125; Height=26; HorizontalAlignment="Left"; Background="#cc77ff77"; Add_Click=[scriptblock]::create(
'$results._proceed = $results["{0}"] = 1; $w.Close()' -f $NAME # scriptblock workaround, because $w lives in parent function scope, but $NAME only here
)}
}
"file|choose" {
return W Button @{Content="File โ "+$PARAM.description; Background="#eeffee77"; Add_Click={
$f = WF OpenFileDialog @{CheckFileExists=$false; Filter="Text (*.txt)|*.txt|CSV (*.csv)|*.csv|JSON (*.json)|*.json|XML (*.xml)|*.xml|Powershell (*.ps1)|*.ps1|All Files (*.*)|*.*"; SupportMultiDottedExtensions=$true; Title="Script '$NAME' file"}
if ($f.ShowDialog() -and $f.FileName) { $this.Content = $f.FileName }
}}
}
"str|int|^$|.*" {
return W TextBox @{Height=20; Width=270; Text=$VALUE}
}
}
}
#-- Variable list input
# ยท shows an input box popup for $meta.vars[], waits for input, then returns variable hash
# ยท reuses current $GUI.vars, default {value:...} data, or previous values
# ยท gets run only when extra input fields are requested
# ยท also utilized by `Read-Host` alias `Ask-Gui` for single input
function Read-GuiExtraParams {
Param(
$meta = @{
title="INPUT"; description="EXAMPLE"; doc="META"; version="0.0";
vars=@( @{name="mail"; type="string"; description="email addr"; value="user@local"} )
},
$height = 450, $CURRENT_STORE = $GUI.vars_previous,
$vars = @{}, $widget_blocks = @(), $results = @{_proceed=0}
)
if (!$meta.vars) { return }
#-- create input widgets, wrap in label and description
$widget_blocks = $meta.vars | % {
$vars[$_.name] = New-GuiVarInput $_ $CURRENT_STORE
W Label @{Content=$_.name; FontWeight="Bold"}
$vars[$_.name]
W TextBlock @{Text=$_.description; TextWrapping="WrapWithOverflow"; FontSize=9; Margin="0,0,0,5"}
}
#-- window
$w = (W (New-Object System.Windows.Window) @{
Title=$meta.title;
Width=345; Height=$height; Top=350; Left=600; TopMost=$true;
Background="#eef7f7ff";
Content=(
W StackPanel @{Width=350;Add=@( #AddChild ?
(W ScrollViewer @{
Height=($height-90); Padding="15,5"; HorizontalAlignment="Left";
Content=(
W StackPanel @{
Add=@(
(W Expander @{Expanded=$false; Header=$meta.description;
Content=(W Label @{Content=" $($meta.api) // $($meta.type) // $($meta.category) // $($meta.version)`r`n$($meta.doc)"})
})
) + $widget_blocks
}
)
}),
(W DockPanel @{Height=50; Margin="35,5";
Add=@(
(W Button @{Content="Cancel"; Width=70; Height=30; Dock="Right"; BackGround="Red";
Add_Click={ $results._proceed = 0; $w.close() }
}),
(W Button @{Content="โถ Run"; Width=150; Height=30; BackGround="Green";
Add_Click={ $results._proceed = 1; $w.close() }
})
)
})
)}#=stackpanel
)
})#=window
[void]$w.ShowDialog()
#-- fetch vars
if ($results._proceed) {
ForEach ($NAME in $vars.Keys) {
$results[$NAME] = switch -regex ($meta.vars | ? { $_.name -eq $NAME } | % { $_.type}) {
"bool" { [int32]($vars[$NAME].IsChecked); break }
"but|btn" { $results[$NAME]; break } # already set in button scriptblock
"file|cho" { $vars[$NAME].Content; break }
"str|.*" { $vars[$NAME].Text; break }
}
}
#$results|FL|Out-String|Write-Host
$results.getEnumerator() | % { $CURRENT_STORE[$_.Name] = $_.Value }
return $results
}
}
#-- User input
# ยท this is aliased over `Read-Host`
# โ so scripts can be used unaltered in standalone/CLI or GUI mode
# ยท for standard field names just returns the GUI $machine/$username input
# ยท else shows a VB input box window
function Ask-Gui {
<#
.SYNOPSIS
Alias over `Read-Host`
.DESCRIPTION
Captures Read-Host requests, returns either custom or standard $GUI.vars[] entry.
.PARAM str
Textual input query. Usually just the variable name '$computer' or '$user'.
Recognizes common aliases like 'Machine' 'PC' 'Hostname' 'AD-Account' or 'AD-User-Name'
.NOTES
Scripts should preferrably query for input in their Param() section once,
and list custom fields per plugin meta #param: list. NEW: per #vars: block.
#>
param($name, $title="Input", [switch]$N=$false)
$alias = Get-VarsAlias $name
if ($GUI.vars.containsKey($alias)) {
return $GUI.vars[$alias]
}
else {
#-- undefined/non-Param() input request / stray Read-Host calls
$results = Read-GuiExtraParams @{
title="Input"; description="Read-Host"; doc="stray Read-Host call from script";
api="clicky"; type="io"; category="extra"; version="1.1";
vars=@(@{name=$name; type="str"; description="$name"; value=""})
} -Height 200
if (!$results) { throw "โ Cancelled at Read-Host request." }
return $results[$name]
}
}
#-- Parameter/varname aliasing
function Get-VarsAlias {
<#
.SYNOPSIS
Aliases like "Computer" for $machine variable
.DESCRIPTION
Allows flexible title matching for standard/internal/default variables.
Which permits scripts to list arguments by different monikers. For instance
Read-Host '$computer-name:' would still match $GUI.vars."machine"
#>
Param($name)
ForEach ($key in $cfg.standard_aliases.keys) {
if ($name -match $cfg.standard_aliases[$key]) { return $key }
}
return $name;
}
#-- Converts vars: name list from $e.vars[]{} to quoted cmdline "arg" "strings"
function Convert-VarsToCmdArgs {
<#
.SYNOPSIS
Crafts a list of cmd/Invoke-quoted strings from params list
.DESCRIPTION
Is used for type:window and type:cli scripts/plugins. Those get executed
in a separate Powershell process, thus need input variables as exec arguments.
.NOTES
Aliases any "standard" $GUI.var names back onto requested aliases/order.
#>
Param(
$meta_params = @(), # meta.vars[] list of dicts
$vals = @{machine=""; username=""}, # from $GUI.vars[]
$out = ""
)
# default list
if (!$meta_params) {
$meta_params = @( @{name="machine"}, @{name="username"} )
}
ForEach ($key in ($meta_params | % { $_.name })) {
# aliases
$key = Get-VarsAlias $key
# quote + append
$out += ' "'+($vals[$key] -replace '(["^])','^$1')+'"'
#$out += $vals[$key]
}
return $out
}
#-- fetch all script input vars - ribbon fields + custom vars:
function Get-ScriptVars($e) {
#-- machine, user
$null = Get-GuiStandardVars
#-- convert obsolete param: scheme to vars: list
if ($e.param -and -not $e.vars) {
$e.vars = ($e.param.trim() -split "\s*[,;]\s*") | % {
if (Test-Path "data/combobox.$_.txt") { $t = "select" } else { $t = "str" }
@{name=$_; description="..."; type="$t"; value=$GUI.vars[$_]}
}
}
#-- input dialog, if vars: lists non-standard fields
if ($e.vars -and ($e.vars | % { Get-VarsAlias ($_.name) } | ? { $GUI.vars.keys -notcontains $_ })) {
if ($add = Read-GuiExtraParams $e) {
$add.getEnumerator() | % { $GUI.vars[$_.Name] = $_.Value }
}
else {
return $false;
}
}
return $GUI.vars
}
#-- print header/title
function Out-GuiHeader($e) {
Set-GuiTitle "โฑ $($cfg.main.title) โ $($e.title)"
if ($e.noheader -or $cfg.noheader) { return; }
#$Host.UI.Write("HDR`r`n")
$XAML = @"
<Paragraph xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation'
Foreground="#ff9988dd" Background="#ff102070" Margin="0,3"
><Image Source='$(Get-IconPath $e.icon $e.img $e.category)' Width='16' Height='16'
/><Bold><![CDATA[$($e.title)]]></Bold><Span Foreground="#a98d"><![CDATA[ - $($e.description)]]></Span
></Paragraph>
"@
# inject any $placeholders from $GUI.vars
$XAML = [regex]::replace($XAML, "\`$(\w+)", {$GUI.vars[$Args[0].Groups[1].Value]})
Out-Gui -xaml $XAML
}
#-- wrapper around Process-MenuTask
function Run-GuiTask {
<#
.SYNOPSIS
Executes the given $e event queue entry returned from GUI (menu or button callback).
.DESCRIPTION
Checks the $menu.type and handles output start, script execution, and result collection.
- Just prints .title and .description first
- Then collects GUI variables ($machine, $username and extra script fields)
- Runs `type:inline` plugins in main thread.
- But `type:window` in separate window/CLI process.
- Captures and outputs any errors
- Then returns to Start-Win loop
.PARAM e
The script/tool entry as returned from $GUI.tasks (just one of the $menu entries really).
Said queue gets filled by any of the menu or toolblock buttons in the main window.
.NOTES
Ideally this should just wrap the CLI Process-MenuTask function (but too divergent now).
The new | Out-Gui piping should capture literal stream results, BUT still does not mix
with direct Write-Host calls in order. (Probably needs Runspace.Streams event listener)
#>
[CmdletBinding()]
param($e, $clear=$false) # one of the $menu entries{}
#-- check last output time (>= 5 minutes per default)
if ($last_output -and (([double]::parse((Get-Date -u %s)) - $last_output) -ge $cfg.autoclear)) {
Clear-GuiOutput
}
#-- get vars (fetch input from "Ribbon"-fields: machine, username, etc)
if (!(Get-ScriptVars $e)) { Write-Host -f Red "Cancelled."; return; }
$GUI.vars.GetEnumerator() | % { Set-Variable $_.name $_.value -Scope Global }
#-- print header
Out-GuiHeader $e
#-- plugins
TRAP { $_ | out-gui -b red }
$plugins.before | % { Invoke-Expression ($_.ToString()) }
#-- Run $menu entry rules (command=, func=, or fn=)
#$Host.UI.Write("INVOKE`r`n")
try {
#-- Internal commands
if ($e.command) {
[void]((Invoke-Expression $e.command) | Out-String | Out-Gui -f Yellow)
}
elseif ($e.func) {
[void]((Invoke-Expression "$e.func $machine $username") | Out-String | Out-Gui -f Yellow)
}
#-- Start script
elseif ($e.fn) {
if ($e.type -match "window|cli") { # in separate window
$cmd_params = Convert-VarsToCmdArgs ($e.vars) ($GUI.vars)
Start-Process powershell.exe -Argumentlist "-STA -ExecutionPolicy ByPass -File ""$($e.fn)"" $cmd_params"
#Start-Process powershell.exe -ArgumentList (@("-STA", "-ExecutionPolicy", "ByPass", "-File", $e.fn)+@($cmd_params))
}
else { # dot-source all "inline" type: plugins
. $e.fn | Out-String -Width 120 | Out-Gui
}
}
#-- No handler
else {
Out-Gui -f Red "No Run-GuiTask handler for:"
[void](@($e) | Out-String | Out-Gui)
}
}
#-- Error cleanup
catch {
Out-Gui -f Yellow $_
}
if ($Error) {
$Error | % { $_ | Out-Gui -f Red } | Out-Null
[void]($Error.Clear())
}
#-- Run plugins / cleanup
$plugins.after | % { Invoke-Expression ($_.ToString()) }
$global:last_output = [double]::parse((Get-Date -u %s))
Set-GuiTitle "โฑ $($cfg.main.title)"
}
#-- verify runspace and window are still active
function Test-GuiRunning($shell, $GUI) {
return ($shell.Runspace.RunspaceStateinfo.State -eq "Opened") -and ($GUI.w) -and (-not $GUI.closed)
}
#-- Initialize Window, Buttons, Subshell+GUI thread
function Start-Win {
<#
.SYNOPSIS
Starts the WPF window and Runspace, then waits for GUI events
.DESCRIPTION
Sets up GUI environment from $menu entries, then polls the shared $GUI. variables
.NOTES
Perhaps the least interesting function and self-explanatory.
#>
Param($menu)
#-- no threading / manual
# $GUI.w = $w = WPF-Window; $w.showdialog(); Write-Host "Ran WPF-Window without thread"
#-- start GUI in separate thread/runspace
Write-Splash "New-GuiThread.." 40
$GUI.shell = New-GuiThread -Code {
#-- error catching
TRAP { $ParentHost.UI.Write("GUI_THREAD_ERR: $($_|FL|Out-String)`r`n"); }
function Write-Host {
[Cmdletbinding()]
Param([parameter(ValueFromPipeline=$True)]$str)
PROCESS { $ParentHost.UI.Write("$str`r`n"); }
}
#-- modules
Import-Module -DisableNameChecking .\modules\guimenu.psm1
Write-Splash "Loading modules anew.." 30
Import-Module -DisableNameChecking .\modules\menu.psm1
Import-Module -DisableNameChecking .\modules\clipboard.psm1
#-- setup things
#-- build gui
Write-Splash "Create WPF-Window.." 40
$GUI.w = WPF-Window
Write-Splash "Add-ButtonHooks.." 10
Add-ButtonHooks
Write-Splash "Add-GuiMenu.." 15
Add-GuiMenu $menu
#$GUI.w.Add_Loaded({Add-GuiMenu $menu}) # "speed" mode (late init would be faster, but halts window draw, might make sense with timer pulsing)
#-- execute `type:init` files right away
$menu | ? { $_.fn -and ($_.type -eq "init-gui") } | % {
Write-Splash "Init-Gui plugins.. $($_.title)" 25
. $_.fn
}
Write-Splash "Go." 1000
$GUI.w.ShowDialog()
} -Vars "menu,BaseDir,cfg,plugins,Splash"
#-- wait for window to be visible
while (!($GUI.w.IsInitialized)) {
Write-Splash "Event loop preparations.." 1
Start-Sleep -milliseconds 275
Write-Host 'wait on $GUI.w.isInitialized'
if ($Debug -and $shell.streams.error) {
$shell.streams | FL | Out-String | Write-Host -b Red
}
}
#-- other initializations
$GUI.tasks = @()
$GUI.vars = @{}
$GUI.vars_previous = @{}
$global:last_output = 1.5
#-- Alias console functions
# `echo` is already alias to `Write-Output`
Set-Alias -Name Write-Host -Value Out-Gui -Scope Global
Set-Alias -Name Read-Host -Value Ask-Gui -Scope Global
#-- basic error catching
TRAP { $_ | Out-Gui -f Red; $Error.Clear() }
#-- events
Register-EngineEvent "Gui.Exit" -Action {exit;}
Register-EngineEvent "Gui.Cancel" -Action {$Host.UI.Write("Main.Cancel()");}
#-- main loop / trivial message queue
# test for window state, pause a few seconds, then resolves $tasks[]
while (Test-GuiRunning $GUI.shell $GUI) {
if ($GUI.tasks) {
$GUI.tasks | % { Run-GuiTask $_ }
$GUI.tasks = @()
}
Start-Sleep -milliseconds 175
$Host.UI.Write((Get-Event))
}
}