powershell.exe -file vs. -command

I am working on a PowerShell script that creates a GUI, using classes from Windows.Forms. The script runs fine in the PowerShell ISE, but when I select "Run with PowerShell" from the context menu in File Explorer, it fails and doesn't find the functions defined in the script.

Digging a bit further into the problem, I found that the "Run with PowerShell" invocation more or less executes a command line like

powershell.exe -Command "script.ps1"

while running the script within the ISE is equivalent to

powershell.exe -File "script.ps1"

Running these two commands from a cmd window exactly reproduce what I observed: The -File version works well and the -Command version fails.

I am including a stripped-down version of the script. It creates a GUI with the buttons "Save configuration" and "Exit". The "Exit" button does what you expect, and "Save configuration" calls the functions Get-Configuration-Data-from-GUI and Save-Configuration (which both do nothing). When you launch the script in the ISE or via the -File parameter, all is well, but when you try "Run with PowerShell" or the -Command parameter, you see these errors when you click "Save configuration":

Get-Configuration-Data-from-GUI : The term 'Get-Configuration-Data-from-GUI' is not recognized as the name of a cmdlet, function, script file, or operable program. Check the spelling of the name, or if a path was included, verify that the path is correct and try again.
At C:\Projects\Internal\Tools\VersionInventoryTool\trunk\source\VersionInventory.ps1:54 char:9
...
Save-Configuration : The term 'Save-Configuration' is not recognized as the name of a cmdlet, function, script file, or operable program. Check the spelling of the name, or if a path was included, verify that the path is correct and try again.
At C:\Projects\Internal\Tools\VersionInventoryTool\trunk\source\VersionInventory.ps1:55 char:9
...

This is what's left of my script after removing all unnecessary parts; the error is reported from the script block that is the argument to $save_button.Add_Click:

using namespace System.Windows.Forms
using namespace System.Drawing

Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing

function Save-Configuration()
{
}

function Get-Configuration-Data-from-GUI($gui)
{
}

function New-Element
{
    param(
        [parameter()] [string] $class,
        [parameter()] [int] $x,
        [parameter()] [int] $y,
        [parameter()] [int] $width,
        [parameter()] [int] $height,
        [parameter()] [string] $text,
        [parameter(Mandatory = $false)] [Form] $parent
    )

    $ret = New-Object $class
    if ($width -gt 0)
    {
        $ret.Width = $width
    }
    if ($height -gt 0)
    {
        $ret.Height = $height
    }
    $ret.Location  = New-Object System.Drawing.Point($x, $y)
    $ret.Text = $text
    if ($parent -ne $null)
    {
        $parent.Controls.Add($ret)
    }
    return $ret
}

function Create-GUI()
{
    $main_form = New-Element Form -x 100 -y 100 -width 600 -height 500 -text "My tool"
    $border = 10
    $width = $main_form.ClientRectangle.Width - 2 * $border

    $save_button = New-Element Button -text "Save configuration" -x $border -width 120 -parent $main_form
    $save_button.Top = $main_form.ClientRectangle.Bottom - $border - $save_button.Height
    $save_button.Add_Click({
        Get-Configuration-Data-from-GUI $main_form
        Save-Configuration
    }.GetNewClosure())

    $exit_button = New-Element Button -text Exit -y $save_button.Top -parent $main_form
    $exit_button.Left = $main_form.ClientRectangle.Right - $border - $exit_button.Width
    $exit_button.DialogResult = [DialogResult]::OK

    return $main_form
}

$gui = Create-Gui
$gui.ShowDialog()

Hoping for insights...

Greetings Hans


Solution 1:

After Create-GUI() is done, the scope for {...} in button.Add_Click({...$main_form...}.GetNewClosure()) is kinda left dangling which is why it can't get back to $main_form; you've over compensated for this by adding the .GetNewClosure() which more or less creates a whole new scope and copies $main_form into it. However this new scope is tenuously attached to the scope that Create-GUI() lives in, and like you've seen depending on how Create-GUI() is sourced into the runspace (& aka -Command vs . aka -File), then {}.GetNewClosure() may or may not have affinity to Create-GUI()'s parent runspace.

To get around this I've dropped the .GetNewClosure() to keep {...} in the runspace it looks like it should live in, and moved the .ShowDialogue() into Create-GUI() so that $main_form doesn't go out of scope.

using namespace System.Windows.Forms
using namespace System.Drawing

Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing

function Save-Configuration{
   param($cfg)
   Write-Host ('Mocked: Save-Configuration({0})' -f $cfg) -ForegroundColor Magenta
}

function Get-Configuration-Data-from-GUI{
   param($gui)
   Write-Host ('Mocked: Get-Configuration-Data-from-GUI({0})' -f $gui.Text) -ForegroundColor Magenta
   return $gui.Text
}

function New-Element{
   param(
      [parameter()] [string] $class,
      [parameter()] [int] $x,
      [parameter()] [int] $y,
      [parameter()] [int] $width,
      [parameter()] [int] $height,
      [parameter()] [string] $text,
      [parameter(Mandatory = $false)] [Form] $parent
   )

   $ret = New-Object $class
   if($width -gt 0){
      $ret.Width = $width
   }
   if($height -gt 0){
      $ret.Height = $height
   }
   $ret.Location  = New-Object System.Drawing.Point($x, $y)
   $ret.Text = $text
   if($parent -ne $null){
      $parent.Controls.Add($ret)
   }
   return $ret
}

function Show-GUI(){
   $gui_data = [ordered]@{
      cfg = ''
      res = $null
   }
   
   $main_form = New-Element Form -x 100 -y 100 -width 600 -height 500 -text "My tool"
   $border = 10
   $width = $main_form.ClientRectangle.Width - 2 * $border

   $save_button = New-Element Button -text "Save configuration" -x $border -width 120 -parent $main_form
   $save_button.Top = $main_form.ClientRectangle.Bottom - $border - $save_button.Height
   $save_button.Add_Click({
      $gui_data.cfg = Get-Configuration-Data-from-GUI $main_form
      Save-Configuration $gui_data.cfg
   })

   $exit_button = New-Element Button -text Exit -y $save_button.Top -parent $main_form
   $exit_button.Left = $main_form.ClientRectangle.Right - $border - $exit_button.Width
   $exit_button.DialogResult = [DialogResult]::OK

   $gui_data.res = $main_form.ShowDialog() 
   return $gui_data
}

Show-GUI
pause

You could even take it a step further by either eliminating Save-Configuration and Get-Configuration-Data-from-GUI, or moving them into the same scope as button.Add_Click({...}). Which would then allow these two functions to access the form, controls, and data directly instead of needing to pass arguments.

using namespace System.Windows.Forms
using namespace System.Drawing

Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing

function Save-Configuration{
   Write-Host ('Mocked: Save-Configuration({0})' -f $gui_data.cfg) -ForegroundColor Magenta
}

function Get-Configuration-Data-from-GUI{
   Write-Host ('Mocked: Get-Configuration-Data-from-GUI({0})' -f $main_form.Text) -ForegroundColor Magenta
   $gui_data.cfg = $main_form.Text
}

function New-Element{
   param(
      [parameter()] [string] $class,
      [parameter()] [int] $x,
      [parameter()] [int] $y,
      [parameter()] [int] $width,
      [parameter()] [int] $height,
      [parameter()] [string] $text,
      [parameter(Mandatory = $false)] [Form] $parent
   )

   $ret = New-Object $class
   if($width -gt 0){
      $ret.Width = $width
   }
   if($height -gt 0){
      $ret.Height = $height
   }
   $ret.Location  = New-Object System.Drawing.Point($x, $y)
   $ret.Text = $text
   if($parent -ne $null){
      $parent.Controls.Add($ret)
   }
   return $ret
}

$gui_data = [ordered]@{
   cfg = ''
   res = $null
}

$main_form = New-Element Form -x 100 -y 100 -width 600 -height 500 -text "My tool"
$border = 10
$width = $main_form.ClientRectangle.Width - 2 * $border

$save_button = New-Element Button -text "Save configuration" -x $border -width 120 -parent $main_form
$save_button.Top = $main_form.ClientRectangle.Bottom - $border - $save_button.Height
$save_button.Add_Click({
   Get-Configuration-Data-from-GUI
   Save-Configuration
})

$exit_button = New-Element Button -text Exit -y $save_button.Top -parent $main_form
$exit_button.Left = $main_form.ClientRectangle.Right - $border - $exit_button.Width
$exit_button.DialogResult = [DialogResult]::OK

$gui_data.res = $main_form.ShowDialog() 
return $gui_data