Skip to content

About

Asynchronous Powershell GUI with WPF in a MVVM pattern

Resources

Stars

0 stars

Watchers

0 watching

Forks

 
 

Repository files navigation

PowerShell WPF

課題:

  1. PowerShell 5.1と.NET Frameworkを使用してGUIを作成すること。
  2. Add-Typeを通じてカスタムC#クラスを書かないこと。
  3. Windows 10/11にネイティブに付属するリソースのみに制限すること。

結果:

非同期PowerShell UI! ViewModelとCommandBindingsによってサポートされています。テスト不可能なスクリプトブロックにすべてを書く必要はもうありません。代わりに、ネイティブのPowerShellクラスメソッドを呼び出すだけです!

SampleGUI.ps1 を右クリックしてPowerShellで実行するか、ドットソースするか、VSCodeでデバッガーを実行してサンプルを確認してください。

Sample.Video.mp4

XAMLカスタム名前空間

XAMLに xmlns:local="clr-namespace:;assembly=PowerShell Class Assembly" を追加することで、ローカルのPowerShellクラスを使用できます。これにより、C#に近い機能が可能になります。以下は XamlReader によって解析されたときにPartialWindowクラスを作成します。

<local:PartialWindow
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:;assembly=PowerShell Class Assembly">
    <StackPanel>
        <TextBlock Text="XAMLからのカスタムWPFオブジェクト!" />
    </StackPanel>
</local:PartialWindow>
class PartialWindow : System.Windows.Window {
    PartialWindow() {
        Write-Verbose 'PartialWindowが作成されました!' -Verbose
    }
}

PowerShellとタスク並列ライブラリ

スクリプトブロックを実行するランスペースがないため、[System.Threading.Tasks.Task]::Run([action]$Scriptblock) を呼び出すことはできません。しかし、Factory.FromAsync()を使用し、ContinueWithを連鎖させることができます。これにより、永続的なスリープループを持つランスペースを専用にすることなく、ランスペースを自動的にクリーンアップできます。

class DelegateClass {
    DelegateClass() {}

    $MagicDelegate = $this.CreateDelegate($this.AutoMagicallyCallEndInvoke, $this)

    [Delegate]CreateDelegate([System.Management.Automation.PSMethod]$Method, $Target) {
        $ReflectionMethod = $Target.GetType().GetMethod($Method.Name)
        $ParameterTypes = [System.Linq.Enumerable]::Select($ReflectionMethod.GetParameters(), [func[object,object]]{$args[0].parametertype})
        $ConcatMethodTypes = $ParameterTypes + $ReflectionMethod.ReturnType
        $DelegateType = [System.Linq.Expressions.Expression]::GetDelegateType($ConcatMethodTypes)
        $Delegate = [delegate]::CreateDelegate($DelegateType, $Target, $ReflectionMethod.Name)
        return $Delegate
    }

    [object]AutoMagicallyCallEndInvoke([System.Threading.Tasks.Task]$Task, [object]$Powershell) {
        $Powershell.Dispose()
        return "$($Task.Result) ContinueWith"
    }
}

$Class = [DelegateClass]::new()
$Powershell = [powershell]::Create()

# PSMethodのEndInvokeをデリゲートに変換
$EndInvokeDelegate = $Class.CreateDelegate($Powershell.EndInvoke, $Powershell)

$Scriptblock = {'タスク結果!'}
$null = $Powershell.AddScript($Scriptblock)
$Handle = $Powershell.BeginInvoke()

$TaskFactory = [System.Threading.Tasks.TaskFactory]::new([System.Threading.Tasks.TaskScheduler]::Default)
$Task = $TaskFactory.FromAsync($Handle, $EndInvokeDelegate)
$ContinueWithTask = $Task.ContinueWith($Class.MagicDelegate, $Powershell)
$Task.Result
$ContinueWithTask.Result

BeginInvokeが終了する前に$Task.Resultや$Taskを呼び出すと、コンソール/スレッドが停止します。フリーズせずに$Task.Statusや$Task.IsCompletedでステータスを確認できます。

[System.Threading.Tasks.Task]::Run($Class.CreateDelegate(Class.Method))を呼び出すことはできますが、現在のランスペースで実行されます。

並行性

Pwsh 7には[NoRunspaceAffinity()]属性がありますが、PowerShell 5.1にはありません。こちらの親切な方が方法を提供してくれています。ランスペースでクラスを定義し、すぐに(Get-Runspace -Id x).Close()を呼び出せば、同じ結果を得られるでしょう。

ネイティブINotifyPropertyChanged実装を持つViewModel

PowerShellクラスはINotifyPropertyChangedを実装できます。PowerShellクラスにはゲッターとセッターがありませんが、PSCustomObjectを継承することでそれを模倣できます。これにより、メンバーは$ViewModel.psobject.Propertyの背後に隠れます。そして、コンストラクタでAdd-Memberを介して$ViewModel.ScriptPropertyで表示されるプロパティのゲッターとセッターを設定できます。ボーナスとして、コンソールでは$ViewModel.psobject.Propertyを介してのみ表示されるプロパティでも、XAMLで"{Binding Property}"を使用できます。

class ViewModelBase : PSCustomObject, System.ComponentModel.INotifyPropertyChanged {
    [ComponentModel.PropertyChangedEventHandler]$PropertyChanged

	add_PropertyChanged([System.ComponentModel.PropertyChangedEventHandler]$handler) {
            $this.psobject.PropertyChanged = [Delegate]::Combine($this.psobject.PropertyChanged, $handler)
	}

	remove_PropertyChanged([System.ComponentModel.PropertyChangedEventHandler]$handler) {
	    $this.psobject.PropertyChanged = [Delegate]::Remove($this.psobject.PropertyChanged, $handler)
	}

	RaisePropertyChanged([string]$propname) {
	    if ($this.psobject.PropertyChanged) {
            	$evargs = [System.ComponentModel.PropertyChangedEventArgs]::new($propname)
            	$this.psobject.PropertyChanged.Invoke($this, $evargs)
	    }
	}
}

class MyViewModel : ViewModelBase {
    $SharedResource
    MyViewModel() {
        $this | Add-Member -Name SharedResource -MemberType ScriptProperty -Value {
	    return $this.psobject.SharedResource
	} -SecondValue {
		param($value)
		$this.psobject.SharedResource = $value
		$this.psobject.RaisePropertyChanged('SharedResource')
            	Write-Verbose "SharedResourceが$valueに設定されました" -Verbose
	}
    }
}

コマンド

最後に、コマンドバインディングについて。"コードビハインド"でハンドラーを設定できます。

$Window.FindName('Button').add_Click({$Class.Method()})

しかし、WPFにここまで深く入り込んでいるので、独自のDelegateCommandクラスを実装することもできます。これは相互作用を処理し、メソッドを非同期で実行する責任も持つことができます。これにより、ViewModelのメソッドに対してのみテストを実行する必要があります。ViewModelは単に機能します。

class DelegateCommand : ViewModelBase, System.Windows.Input.ICommand  {
    [System.EventHandler]$InternalCanExecuteChanged

    add_CanExecuteChanged([EventHandler]$value) {
        $this.psobject.InternalCanExecuteChanged = [Delegate]::Combine($this.psobject.InternalCanExecuteChanged, $value)
    }

    remove_CanExecuteChanged([EventHandler]$value) {
        $this.psobject.InternalCanExecuteChanged = [Delegate]::Remove($this.psobject.InternalCanExecuteChanged, $value)
    }

    [bool]CanExecute([object]$CommandParameter) {
        if ($this.psobject.CanExecuteAction) { return $this.psobject.CanExecuteAction.Invoke() }
        return $true
    }

    [void]Execute([object]$CommandParameter) {
        if ($this.psobject.Action) {
            $this.psobject.Action.Invoke()
        } else {
            $this.psobject.ActionObject.Invoke()
        }
    }

    DelegateCommand([Action]$Action) {
        $this.psobject.Action = $Action
    }

    DelegateCommand([Action[object]]$Action) {
        $this.psobject.ActionObject = $Action
    }

    [void]RaiseCanExecuteChanged() {
        $eCanExecuteChanged = $this.psobject.InternalCanExecuteChanged
        if ($eCanExecuteChanged) {
            if ($this.psobject.CanExecuteAction) {
                $eCanExecuteChanged.Invoke($this, [System.EventArgs]::Empty)
            }
        }
    }

    $Action
    $ActionObject
    $CanExecuteAction
}

New-WPFObject

[System.Windows.Markup.XamlReader]のラッパーです。[System.Windows.Markup.ParserContext]を使用して、WPFオブジェクトが自身のXAML内で完全パスを提供せずにリソースディクショナリ.xamlファイルなどの他のファイルを指すためのURIを追加できます。これにより、XAML内で相対パスが可能になります。

About

Asynchronous Powershell GUI with WPF in a MVVM pattern

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages