はじめに
今回紹介するのは、Windows PowerShell 5.x 環境でも動く WPF GUI ダッシュボードです。
XAML で画面を定義し、PowerShell から XamlReader
を使ってロード。データはすべて **JSON ファイル(cards.json)**で管理します。
また、サービスの稼働監視・起動停止に加えて、アプリケーションランチャーもタブで統合しました。.psm1
(PowerShell モジュール)にすると「再利用・分離・管理」が段違いでやりやすくなるため、psm1でモジュール作成しています。
ディレクトリ構成
MyGuiApp/
├─ Main.ps1 # エントリーポイント
├─ MainWindow.xaml # WPF 画面定義
├─ cards.json # ダッシュボードデータ
└─ modules/ # 機能ごとのモジュール群
├─ UiHelpers.psm1
├─ JsonData.psm1
├─ Navigation.psm1
├─ ServiceMonitor.psm1
├─ AppLauncher.psm1
├─ AppIcons.psm1
└─ AppTimers.psm1
役割
- Main.ps1
- STA 再起動処理(ISE でも動くように対応)
- 各モジュールの読み込み
- JSON ロード & DataContext バインド
- UI 初期化とタイマー起動
- cards.json
- KPIカード、進捗バー、タスク、リンク、ニュース、サービス、アプリ情報を定義
- GUI の内容はすべて JSON から駆動される
- modules
UiHelpers.psm1
:XAML 読込 / DataContext バインド / 要素探索JsonData.psm1
:JSON のロード・リロード管理Navigation.psm1
:ハイパーリンククリック処理ServiceMonitor.psm1
:サービス監視・起動停止(PID 管理対応)AppLauncher.psm1
:アプリ起動・フォルダオープン処理AppIcons.psm1
:アプリのアイコン解決(exe 埋込 / 画像ファイル / URL favicon)AppTimers.psm1
:DispatcherTimer ラッパー
ソースコード(全文)と解説
1. Main.ps1(エントリ/起動制御/モジュール読込/タイマー)
目的
- ISEでも確実にSTAで起動
- 各モジュールの堅牢な Import(診断ログ付き)
- JSON をロードして DataContext にバインド
- 各UI(リンク、サービス監視、ランチャー、アイコン)初期化
- 2秒ごとの状態更新タイマー
本文
# ===== Main.ps1 (モジュール分割版: ISE対応 / JSON駆動 / サービス監視) ===== param([switch]$InProcess) # ★起点フォルダ(あなたの環境に合わせてOK) $ScriptDir = 'C:\myTool\MyGuiApp' Set-Location $ScriptDir # 実行ファイルのパス(ISEやStart-Process再起動時に使用) $MainScript = Join-Path $ScriptDir 'Main.ps1' if (-not $PSCommandPath) { $PSCommandPath = $MainScript } # --- ISEから実行されたら既定で別プロセス(-InProcess指定時は同一プロセスで続行) --- if ($psISE -and -not $InProcess) { $argsList = @( '-sta','-NoProfile','-ExecutionPolicy','Bypass', '-File', $PSCommandPath, '-InProcess:$false' ) Start-Process -FilePath (Get-Command powershell).Source -ArgumentList $argsList | Out-Null Write-Host "✅ 別プロセス(STA)でダッシュボードを起動しました。ISE はそのまま操作できます。" return } # --- STA でなければSTAで自動再起動 --- if ([Threading.Thread]::CurrentThread.ApartmentState -ne 'STA') { $argsList = @('-sta','-NoProfile','-ExecutionPolicy','Bypass','-File', $PSCommandPath) if ($InProcess) { $argsList += '-InProcess' } Start-Process -FilePath (Get-Command powershell).Source -ArgumentList $argsList | Out-Null return } # --- 必須アセンブリ --- Add-Type -AssemblyName PresentationCore,PresentationFramework,WindowsBase # === モジュールの確実なロード(診断つき) === $modules = Join-Path $ScriptDir 'modules' $moduleFiles = @( 'UiHelpers.psm1', 'JsonData.psm1', 'Navigation.psm1', 'ServiceMonitor.psm1', 'AppTimers.psm1', 'AppLauncher.psm1', 'AppIcons.psm1' ) foreach ($mf in $moduleFiles) { $path = Join-Path $modules $mf if (-not (Test-Path $path)) { throw "モジュールが見つかりません: $path" } try { $modName = [System.IO.Path]::GetFileNameWithoutExtension($mf) Remove-Module $modName -ErrorAction SilentlyContinue Import-Module $path -Force -ErrorAction Stop -Verbose } catch { Write-Error "Import失敗: $path`n$($_.Exception.Message)" throw } } # 依存関数の存在チェック(ここで止まったらモジュール内Export名の問題) $required = @( 'Load-Xaml', 'Set-AppDataPath','Get-AppData','Reload-AppData', 'Bind-DataContext', 'Register-HyperlinkHandler', 'Initialize-ServiceMonitor','Attach-ServiceButtons', 'Resolve-PidFile','Start-MyService','Stop-MyService','Test-ServiceRunning','Update-ServiceStatusUI', 'New-UiTimer','Start-UiTimer','Stop-UiTimer' ) foreach ($fn in $required) { if (-not (Get-Command -Name $fn -ErrorAction SilentlyContinue)) { throw "関数が見つかりません: $fn(Export-ModuleMember名 or Importに問題)" } } # --- XAMLロード & データバインド --- $window = Load-Xaml (Join-Path $ScriptDir 'MainWindow.xaml') if (-not $window) { throw "Window の生成に失敗しました(MainWindow.xaml)" } Set-AppDataPath (Join-Path $ScriptDir 'cards.json') $data = Get-AppData $null Bind-DataContext -Window $window -Data $data Resolve-AppIcons -Window $window # --- ハイパーリンクの遷移ハンドラ --- Register-HyperlinkHandler -Window $window # --- サービス監視UI 初期化 & ボタン配線 --- Initialize-ServiceMonitor -Window $window Attach-ServiceButtons -Window $window # --- アプリランチャー 初期化 & ボタン配線 --- Initialize-AppLauncher -Window $window Attach-AppLauncherButtons -Window $window # --- 更新ボタン(JSON再読込) --- $btn = $window.FindName('BtnRefresh') if ($btn) { $btn.Add_Click({ try { $fresh = Reload-AppData Bind-DataContext -Window $window -Data $fresh Resolve-AppIcons -Window $window [System.Windows.MessageBox]::Show("JSONを再読込しました。") Update-ServiceStatusUI -Window $window } catch { [System.Windows.MessageBox]::Show("JSON読込エラー: $($_.Exception.Message)") } }) } # --- 状態ポーリング(2秒毎) --- $t = New-UiTimer -IntervalSec 2 -OnTick { Update-ServiceStatusUI -Window $window Update-AppLauncherStatus -Window $window } Start-UiTimer $t # --- 表示 --- $null = $window.ShowDialog()
ポイント
- ISEでもズレずに STA 実行へ再起動。
- モジュール Import は Remove-Module → Import -Verbose → 関数存在チェックで堅牢化。
- JSON 再読込(更新ボタン)時に アイコン再解決も忘れず実施。
2. MainWindow.xaml(UI定義:タブ+カード)
目的
- ダッシュボードとランチャーを TabControl で切換え
- 既存の KPI/進捗/ニュース/サービス監視を1枚目、ランチャーを2枚目に配置
- 画像の null 判定は
{x:Null}
を使用(PS5/WPFで安全)
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="{Binding header.title, FallbackValue=ダッシュボード}" Height="720" Width="1200" WindowStartupLocation="CenterScreen" Background="#1E1E1E"> <TabControl Background="#1E1E1E"> <TabItem Header="ダッシュボード"> <Grid Margin="24"> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="12"/> <RowDefinition Height="Auto"/> <RowDefinition Height="12"/> <RowDefinition Height="Auto"/> <RowDefinition Height="12"/> <RowDefinition Height="Auto"/> <RowDefinition Height="24"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <DockPanel Grid.Row="0"> <StackPanel Orientation="Vertical" DockPanel.Dock="Left"> <TextBlock Text="{Binding header.title}" FontSize="28" FontWeight="Bold" Foreground="White"/> <TextBlock Margin="0,4,0,0" Foreground="LightGray"> <Run Text="最終更新: "/> <Run Text="{Binding header.lastUpdated}"/> </TextBlock> </StackPanel> <Button x:Name="BtnRefresh" DockPanel.Dock="Right" Margin="8,0,0,0" Padding="6,2" FontSize="12" MinWidth="60" Height="24" Background="#333333" Foreground="White" BorderBrush="#555" BorderThickness="1">更新</Button> </DockPanel> <Border Grid.Row="2" BorderBrush="#444" BorderThickness="1" CornerRadius="8" Padding="16" Background="#2D2D30"> <StackPanel> <TextBlock Text="プロセス監視" Foreground="White" FontSize="18" FontWeight="Bold"/> <ItemsControl x:Name="SvcList" ItemsSource="{Binding services}" Margin="0,8,0,0"> <ItemsControl.ItemsPanel> <ItemsPanelTemplate> <WrapPanel/> </ItemsPanelTemplate> </ItemsControl.ItemsPanel> <ItemsControl.ItemTemplate> <DataTemplate> <Border BorderBrush="#444" BorderThickness="1" CornerRadius="8" Padding="12" Margin="6" Background="#1F1F1F"> <StackPanel Width="280"> <TextBlock Text="{Binding display}" Foreground="White" FontWeight="Bold"/> <TextBlock x:Name="StatusText" Text="判定中..." Foreground="LightGray" Margin="0,4,0,0"/> <StackPanel Orientation="Horizontal" Margin="0,8,0,0"> <Button x:Name="StartBtn" Content="起動" Tag="{Binding id}" Padding="8,4" Margin="0,0,6,0" Background="#0E639C" Foreground="White" BorderBrush="#0E639C"/> <Button x:Name="StopBtn" Content="停止" Tag="{Binding id}" Padding="8,4" Background="#7A3E3E" Foreground="White" BorderBrush="#7A3E3E"/> </StackPanel> </StackPanel> </Border> </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl> </StackPanel> </Border> <ItemsControl Grid.Row="4" ItemsSource="{Binding kpiCards}"> <ItemsControl.ItemsPanel> <ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate> </ItemsControl.ItemsPanel> <ItemsControl.ItemTemplate> <DataTemplate> <Border BorderBrush="#444" BorderThickness="1" CornerRadius="8" Padding="12" Margin="6" Background="#2D2D30"> <StackPanel> <TextBlock Text="{Binding title}" Foreground="LightGray"/> <StackPanel Orientation="Horizontal" Margin="0,6,0,0"> <TextBlock Text="{Binding value}" FontSize="24" FontWeight="Bold" Foreground="White"/> <TextBlock Text="{Binding unit}" Margin="6,6,0,0" Foreground="LightGray"/> </StackPanel> <TextBlock Text="{Binding delta}" Foreground="LightGray"/> </StackPanel> </Border> </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl> <Border Grid.Row="6" BorderBrush="#444" BorderThickness="1" CornerRadius="8" Padding="12" Background="#2D2D30"> <StackPanel> <TextBlock Text="{Binding progress.label}" Foreground="LightGray"/> <ProgressBar Minimum="0" Maximum="100" Value="{Binding progress.value}" Height="12" Margin="0,12,0,0" Background="#333" Foreground="#0E639C"/> </StackPanel> </Border> <Grid Grid.Row="8"> <Grid.ColumnDefinitions> <ColumnDefinition Width="*"/> <ColumnDefinition Width="24"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <Border Grid.Column="0" BorderBrush="#444" BorderThickness="1" CornerRadius="8" Padding="12" Background="#2D2D30"> <ItemsControl ItemsSource="{Binding tasks}"> <ItemsControl.ItemTemplate> <DataTemplate> <StackPanel Margin="0,0,0,8"> <TextBlock Text="{Binding title}" FontWeight="Bold" Foreground="White"/> <TextBlock Text="{Binding state}" Foreground="LightGray"/> <TextBlock Text="{Binding due}" Foreground="LightGray"/> </StackPanel> </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl> </Border> <StackPanel Grid.Column="2"> <Border BorderBrush="#444" BorderThickness="1" CornerRadius="8" Padding="12" Margin="0,0,0,12" Background="#2D2D30"> <ItemsControl ItemsSource="{Binding links}"> <ItemsControl.ItemTemplate> <DataTemplate> <TextBlock Margin="0,0,0,6"> <Hyperlink NavigateUri="{Binding url}" Foreground="#3794FF"> <Run Text="{Binding label}"/> </Hyperlink> </TextBlock> </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl> </Border> <Border BorderBrush="#444" BorderThickness="1" CornerRadius="8" Padding="12" Background="#2D2D30"> <ItemsControl ItemsSource="{Binding news}"> <ItemsControl.ItemTemplate> <DataTemplate> <TextBlock Text="{Binding text}" Margin="0,0,0,6" Foreground="White"/> </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl> </Border> </StackPanel> </Grid> </Grid> </TabItem> <!-- ランチャー --> <TabItem Header="ランチャー"> <ScrollViewer VerticalScrollBarVisibility="Auto"> <Grid Margin="24"> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="12"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <DockPanel Grid.Row="0"> <TextBlock Text="アプリ起動ランチャー" FontSize="22" FontWeight="Bold" Foreground="White" /> <StackPanel Orientation="Horizontal" DockPanel.Dock="Right"> <TextBlock Text="ダブルクリックでも起動できます" Foreground="LightGray" VerticalAlignment="Center"/> </StackPanel> </DockPanel> <ItemsControl x:Name="AppList" Grid.Row="2" ItemsSource="{Binding apps}"> <ItemsControl.ItemsPanel> <ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate> </ItemsControl.ItemsPanel> <ItemsControl.ItemTemplate> <DataTemplate> <Border BorderBrush="#444" BorderThickness="1" CornerRadius="8" Padding="12" Margin="6" Background="#2D2D30"> <StackPanel Width="300"> <StackPanel Orientation="Horizontal" VerticalAlignment="Center"> <Image Width="20" Height="20" Source="{Binding iconImage}" Margin="0,0,6,0"> <Image.Style> <Style TargetType="{x:Type Image}"> <Setter Property="Visibility" Value="Visible"/> <Style.Triggers> <DataTrigger Binding="{Binding iconImage}" Value="{x:Null}"> <Setter Property="Visibility" Value="Collapsed"/> </DataTrigger> </Style.Triggers> </Style> </Image.Style> </Image> <TextBlock Text="{Binding display}" FontWeight="Bold" Foreground="White" VerticalAlignment="Center"/> </StackPanel> <TextBlock x:Name="AppStatusText" Text="未起動" Foreground="LightGray" Margin="0,4,0,0"/> <TextBlock Text="{Binding note}" Foreground="LightGray" TextWrapping="Wrap" Margin="0,4,0,0"/> <StackPanel Orientation="Horizontal" Margin="0,8,0,0"> <Button x:Name="AppRunBtn" Content="起動" Tag="{Binding id}" Padding="8,4" Margin="0,0,6,0" Background="#0E639C" Foreground="White" BorderBrush="#0E639C"/> <Button x:Name="AppOpenDirBtn" Content="フォルダ" Tag="{Binding id}" Padding="8,4" Background="#3A3D41" Foreground="White" BorderBrush="#3A3D41"/> </StackPanel> </StackPanel> </Border> </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl> </Grid> </ScrollViewer> </TabItem> </TabControl> </Window>
3. cards.json(表示データ/サービス・アプリ定義)
目的
- GUI の表示内容を JSON だけで差し替えられるようにする
- サービスは scriptPath(PS1)+ workingDir + pidFile で管理
- アプリは exe/URL どちらも OK、アイコンは
@exe
/画像/URL に対応
{ "header": { "title": "ダッシュボード", "lastUpdated": "2025-10-03T06:00:00+09:00" }, "kpiCards": [ { "title": "本日のジョブ", "value": 12, "unit": "件", "delta": "+2", "accent": "primary" }, { "title": "エラー", "value": 1, "unit": "件", "delta": "-3", "accent": "alert" }, { "title": "稼働率", "value": 98.5, "unit": "%", "delta": "+0.4", "accent": "ok" } ], "progress": { "label": "全体進捗", "value": 35 }, "tasks": [ { "title": "DBバックアップ", "state": "進行中", "due": "2025-10-05", "assignee": "ユーザーA" }, { "title": "API キー更新", "state": "未着手", "due": "2025-10-07", "assignee": "チーム" } ], "links": [ { "label": "Redmine", "url": "https://example.com/redmine" }, { "label": "API ドキュメント", "url": "https://example.com/docs" } ], "news": [ { "text": "新機能: レポートのエクスポートを追加" }, { "text": "注意: API キーの更新期限が近づいています" } ], "services": [ { "id": "urlInspector", "display": "URL Inspector", "processName": "powershell", "exePath": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", "scriptPath": "UrlInspector.ps1", "workingDir": "C:\\myTool\\MyGuiApp\\program\\UrlInspector", "pidFile": "C:\\myTool\\MyGuiApp\\run\\urlinspector.pid" } ], "apps": [ { "id": "notepad", "display": "メモ帳", "exePath": "C:\\Windows\\System32\\notepad.exe", "args": "", "workingDir": "C:\\Windows\\System32\\", "runAsAdmin": false, "note": "簡易テキスト編集", "icon": "@exe" }, { "id": "youtube", "display": "Youtube", "exePath": "https://www.youtube.com/", "args": "", "workingDir": "", "runAsAdmin": false, "note": "Youtube" } ] }
4. modules/UiHelpers.psm1(XAMLロード/データバインド/要素探索)
解説
Load-Xaml
:XAML を読み込み、例外時は読みやすいメッセージに整形Bind-DataContext
:Window に JSON を紐付けFind-ChildByName
:テンプレート展開後に子要素を名前で探索(サービス/ランチャーで利用)
# modules/UiHelpers.psm1 Set-StrictMode -Version Latest function Load-Xaml { param( [string]$Path ) try { [xml]$xaml = Get-Content -LiteralPath $Path -Raw $reader = New-Object System.Xml.XmlNodeReader $xaml [System.Windows.Markup.XamlReader]::Load($reader) } catch { $msg = if ($_.Exception.InnerException) { $_.Exception.InnerException.Message } else { $_.Exception.Message } throw "XAML 読込エラー: $Path`n$msg" } } function Bind-DataContext { param( [System.Windows.Window]$Window, [Parameter(Mandatory)]$Data ) $Window.DataContext = $Data } function Find-ChildByName { param( [System.Windows.DependencyObject]$Parent, [string]$Name ) if (-not $Parent) { return $null } $fe = $Parent -as [System.Windows.FrameworkElement] if ($fe) { $fe.ApplyTemplate() | Out-Null } $count = [System.Windows.Media.VisualTreeHelper]::GetChildrenCount($Parent) for ($i = 0; $i -lt $count; $i++) { $child = [System.Windows.Media.VisualTreeHelper]::GetChild($Parent, $i) $feChild = $child -as [System.Windows.FrameworkElement] if ($feChild) { if ($feChild.Name -eq $Name) { return $feChild } $found = Find-ChildByName -Parent $child -Name $Name if ($found) { return $found } } else { $found = Find-ChildByName -Parent $child -Name $Name if ($found) { return $found } } } return $null } Export-ModuleMember -Function Load-Xaml, Bind-DataContext, Find-ChildByName
5. modules/JsonData.psm1(JSON ロード&リロード)
解説
$script:AppDataPath
を保持- 初回ロード・再ロードを共通化
- 読み込みエラーは上位で拾ってダイアログ表示
# グローバルで保持する JSON データファイルパス $script:AppDataPath = $null function Set-AppDataPath { param( [string]$Path ) $script:AppDataPath = $Path } function Get-AppData { param( [string]$Path ) # パスが渡された場合は上書き if ($Path) { $script:AppDataPath = $Path } if (-not $script:AppDataPath) { throw "AppDataPath が未設定です。" } Get-Content -LiteralPath $script:AppDataPath -Raw | ConvertFrom-Json } function Reload-AppData { if (-not $script:AppDataPath) { throw "AppDataPath が未設定です。" } Get-Content -LiteralPath $script:AppDataPath -Raw | ConvertFrom-Json } Export-ModuleMember -Function Set-AppDataPath, Get-AppData, Reload-AppData
6. modules/Navigation.psm1(ハイパーリンク遷移)
解説
- XAML 内の
Hyperlink
のRequestNavigate
を一括ハンドリング - エラー時は MessageBox で通知
function Register-HyperlinkHandler { param( [System.Windows.Window]$Window ) $Window.AddHandler( [System.Windows.Documents.Hyperlink]::RequestNavigateEvent, [System.Windows.Navigation.RequestNavigateEventHandler]{ param($s, $e) try { Start-Process $e.Uri.AbsoluteUri $e.Handled = $true } catch { [System.Windows.MessageBox]::Show("リンクを開けませんでした: $($_.Exception.Message)") } } ) } Export-ModuleMember -Function Register-HyperlinkHandler
7. modules/ServiceMonitor.psm1(サービス監視/起動・停止・PID 管理)
解説
- PS5.x対応:
Start-Process
のパラメータセット競合を避け、ProcessStartInfo で統一 scriptPath
があるサービスは 自動で wrapper.ps1 を生成して起動 → 親ホストを常駐- これにより 「起動直後に即終了」でも稼働安定
- PID は pidFile に保存/復旧
Test-ServiceRunning
は pidFile優先 → 失踪時はコマンドライン再特定- UI は
SvcList
のStatusText
を更新(緑/赤)
# 自分(ダッシュボード)を除外するための情報 $script:SelfPid = $PID try { $script:SelfCmdLine = (Get-CimInstance Win32_Process -Filter "ProcessId=$SelfPid").CommandLine } catch { $script:SelfCmdLine = "" } function Resolve-PidFile { param([object]$svc) if ($svc.pidFile) { return $svc.pidFile } $base = Join-Path $env:ProgramData 'MyGuiApp\pids' if (-not (Test-Path $base)) { New-Item -ItemType Directory -Path $base -Force | Out-Null } Join-Path $base ("{0}.pid" -f $svc.id) } function Get-ServiceDef { param( [System.Windows.Window]$Window, [string]$Id ) $Window.DataContext.services | Where-Object { $_.id -eq $Id } | Select-Object -First 1 } function New-WrapperPath { param([object]$svc) $base = Join-Path $env:ProgramData 'MyGuiApp\wrappers' if (-not (Test-Path $base)) { New-Item -ItemType Directory -Path $base -Force | Out-Null } Join-Path $base ("{0}-wrapper.ps1" -f $svc.id) } function Write-WrapperScript { param([object]$svc) $wrapper = New-WrapperPath -svc $svc $work = if ($svc.workingDir) { $svc.workingDir } else { Split-Path -LiteralPath $svc.scriptPath -Parent } $log = Join-Path $work ("{0}.launch.log" -f $svc.id) # 文字列は PowerShell 5.x でも安全な単純置換のみ使用。出力リダイレクトは Out-File に統一。 $content = @" # AUTO-GENERATED WRAPPER (PowerShell 5.x safe) try { Set-Location '$work' "`$(Get-Date -Format o) START" | Out-File -Append '$log' -Encoding UTF8 if (Test-Path '$($svc.scriptPath)') { . '$($svc.scriptPath)' | Out-Null } else { "`$(Get-Date -Format o) MISSING: $($svc.scriptPath)" | Out-File -Append '$log' -Encoding UTF8 } } catch { ("`$(Get-Date -Format o) ERROR: " + `$_) | Out-File -Append '$log' -Encoding UTF8 } # 常駐(ダッシュボードの監視用) while (\$true) { Start-Sleep -Seconds 3600 } "@ Set-Content -LiteralPath $wrapper -Value $content -Encoding UTF8 return $wrapper } function Start-MyService { param( [System.Windows.Window]$Window, [string]$Id ) $svc = Get-ServiceDef -Window $Window -Id $Id if (-not $svc) { return } $pidFile = Resolve-PidFile $svc if (Test-Path $pidFile) { $svcPid = [int](Get-Content -LiteralPath $pidFile -Raw -ErrorAction SilentlyContinue) if ($svcPid) { $p = Get-Process -Id $svcPid -ErrorAction SilentlyContinue if ($p) { return } } } try { # --- PowerShell 実行ファイルの解決(PS5.x 安定) --- $pwshExe = $null if ($svc.exePath -and (Test-Path $svc.exePath)) { $pwshExe = $svc.exePath } else { $cmd = Get-Command powershell -ErrorAction SilentlyContinue if ($cmd -and $cmd.Source) { $pwshExe = $cmd.Source } if (-not $pwshExe) { $pwshExe = 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' } } # --- 必ず wrapper 経由で起動(args は一切使わない) --- if (-not $svc.scriptPath) { throw "cards.json の services[].scriptPath が未設定です。scriptPath を指定してください。" } $wrapper = Write-WrapperScript -svc $svc $workDir = Split-Path -LiteralPath $wrapper -Parent # 引数は 1 本の文字列(PS5.x 安定) $argLine = "-NoProfile -ExecutionPolicy Bypass -File `"$wrapper`"" write-host ($argLine) # Start-Process は使わず PSI のみ $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = $pwshExe $psi.Arguments = $argLine $psi.WorkingDirectory = $workDir $psi.UseShellExecute = $true #$psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden $proc = [System.Diagnostics.Process]::Start($psi) if ($proc) { Start-Sleep -Milliseconds 200 $proc.Refresh() Set-Content -LiteralPath $pidFile -Value $proc.Id -Encoding ASCII } } catch { $msg = "起動に失敗: $($_.Exception.Message)`nexe: $pwshExe" try { if ($argLine) { $msg += "`nargs: $argLine" } } catch {} [System.Windows.MessageBox]::Show($_) } } function Test-ServiceRunning { param([object]$svc) if (-not $svc) { return $false } $pidFile = Resolve-PidFile $svc # 1) pidfile 優先 if (Test-Path $pidFile) { $svcPid = [int](Get-Content -LiteralPath $pidFile -Raw -ErrorAction SilentlyContinue) if ($svcPid) { $p = Get-Process -Id $svcPid -ErrorAction SilentlyContinue if ($p) { return $true } else { Remove-Item -LiteralPath $pidFile -ErrorAction SilentlyContinue } } } # 2) ラッパー or scriptPath で再特定 try { $name = if ($svc.processName) { $svc.processName } else { 'powershell' } $plist = @(Get-Process -Name $name -ErrorAction SilentlyContinue) if (-not $plist) { return $false } $wrapper = if ($svc.scriptPath) { New-WrapperPath -svc $svc } else { $null } foreach ($proc in $plist) { if ($proc.Id -eq $script:SelfPid) { continue } $cmd = (Get-CimInstance Win32_Process -Filter "ProcessId=$($proc.Id)").CommandLine if (-not $cmd) { continue } $exeMatch = $true if ($svc.exePath -and (Test-Path $svc.exePath)) { try { $exeMatch = ($proc.MainModule.FileName -eq $svc.exePath) } catch { $exeMatch = $true } } if (-not $exeMatch) { continue } # ラッパーがあればラッパーで照合、なければscriptPathで照合 if ($wrapper -and (Test-Path $wrapper)) { if ($cmd -like "*$wrapper*") { Set-Content -LiteralPath $pidFile -Value $proc.Id -Encoding ASCII return $true } } elseif ($svc.scriptPath) { if ($cmd -like "*$($svc.scriptPath)*") { Set-Content -LiteralPath $pidFile -Value $proc.Id -Encoding ASCII return $true } } else { return $true } } return $false } catch { return $false } } function Stop-MyService { param( [System.Windows.Window]$Window, [string]$Id ) $svc = Get-ServiceDef -Window $Window -Id $Id if (-not $svc) { return } $pidFile = Resolve-PidFile $svc $killed = $false # 1) pidfile 優先 if (Test-Path $pidFile) { $svcPid = [int](Get-Content -LiteralPath $pidFile -Raw -ErrorAction SilentlyContinue) if ($svcPid) { $p = Get-Process -Id $svcPid -ErrorAction SilentlyContinue if ($p) { try { $p.CloseMainWindow() | Out-Null Start-Sleep -Milliseconds 300 if (-not $p.HasExited) { $p.Kill() } $killed = $true } catch { } } } Remove-Item -LiteralPath $pidFile -ErrorAction SilentlyContinue } if ($killed) { return } # 2) 予備:ラッパー or scriptPath 照合で停止(自分は除外) try { $name = if ($svc.processName) { $svc.processName } else { 'powershell' } $plist = @(Get-Process -Name $name -ErrorAction SilentlyContinue) $wrapper = if ($svc.scriptPath) { New-WrapperPath -svc $svc } else { $null } foreach ($proc in $plist) { if ($proc.Id -eq $script:SelfPid) { continue } $cmd = (Get-CimInstance Win32_Process -Filter "ProcessId=$($proc.Id)").CommandLine if (-not $cmd) { continue } $exeMatch = $true if ($svc.exePath -and (Test-Path $svc.exePath)) { try { $exeMatch = ($proc.MainModule.FileName -eq $svc.exePath) } catch { $exeMatch = $true } } if (-not $exeMatch) { continue } $match = $false if ($wrapper -and (Test-Path $wrapper) -and ($cmd -like "*$wrapper*")) { $match = $true } elseif ($svc.scriptPath -and ($cmd -like "*$($svc.scriptPath)*")) { $match = $true } if ($match) { try { $proc.CloseMainWindow() | Out-Null Start-Sleep -Milliseconds 300 if (-not $proc.HasExited) { $proc.Kill() } $killed = $true } catch { } } } } catch { } } function Update-ServiceStatusUI { param([System.Windows.Window]$Window) $svcList = $Window.FindName('SvcList') if (-not $svcList) { return } $gen = $svcList.ItemContainerGenerator for ($i=0; $i -lt $svcList.Items.Count; $i++) { $container = $gen.ContainerFromIndex($i) -as [System.Windows.FrameworkElement] if (-not $container) { continue } $container.ApplyTemplate() | Out-Null $tb = $null if (Get-Command -Name Find-ChildByName -ErrorAction SilentlyContinue) { $tb = Find-ChildByName -Parent $container -Name 'StatusText' } if (-not $tb) { continue } $item = $svcList.Items[$i] $running = Test-ServiceRunning $item $tb.Text = if ($running) { '稼働中' } else { '停止中' } $tb.Foreground = if ($running) { New-Object System.Windows.Media.SolidColorBrush ([System.Windows.Media.Color]::FromRgb(0,200,0)) } else { New-Object System.Windows.Media.SolidColorBrush ([System.Windows.Media.Color]::FromRgb(192,80,80)) } } } function Initialize-ServiceMonitor { param([System.Windows.Window]$Window) $svcList = $Window.FindName('SvcList') if ($svcList) { $gen = $svcList.ItemContainerGenerator $gen.Add_StatusChanged({ if ($gen.Status -eq [System.Windows.Controls.Primitives.GeneratorStatus]::ContainersGenerated) { Update-ServiceStatusUI -Window $Window } }) } } function Attach-ServiceButtons { param([System.Windows.Window]$Window) $Window.AddHandler([System.Windows.Controls.Button]::ClickEvent, [System.Windows.RoutedEventHandler]{ param($s,$e) $btn = $e.OriginalSource -as [System.Windows.Controls.Button] if (-not $btn -or -not $btn.Tag) { return } $id = [string]$btn.Tag switch ($btn.Name) { 'StartBtn' { Start-MyService -Window $Window -Id $id; Start-Sleep -Milliseconds 250; Update-ServiceStatusUI -Window $Window } 'StopBtn' { Stop-MyService -Window $Window -Id $id; Start-Sleep -Milliseconds 250; Update-ServiceStatusUI -Window $Window } } } ) } Export-ModuleMember -Function Initialize-ServiceMonitor, Attach-ServiceButtons, Resolve-PidFile, Start-MyService, Stop-MyService, Test-ServiceRunning, Update-ServiceStatusUI
8. modules/AppLauncher.psm1(アプリ起動/フォルダオープン/状態表示)
解説
- URL(http/https)は既定ブラウザ、exe は ProcessStartInfo で安全に起動
- 「フォルダ」ボタンは
workingDir
→ exePath の親 → exePath がフォルダ、の優先順位 - 起動状態は プロセス名+exePath一致で概算判定
# modules/AppLauncher.psm1 function Get-AppDef { param([System.Windows.Window]$Window, [string]$Id) $Window.DataContext.apps | Where-Object { $_.id -eq $Id } | Select-Object -First 1 } function Test-AppRunning { param($app) if (-not $app) { return $false } try { $name = if ($app.processName) { $app.processName } else { [System.IO.Path]::GetFileNameWithoutExtension($app.exePath) } $plist = @(Get-Process -Name $name -ErrorAction SilentlyContinue) if (-not $plist) { return $false } foreach ($p in $plist) { # exePath が指定されていれば一致確認(権限で取れない環境は許容) $exeMatch = $true if ($app.exePath -and (Test-Path $app.exePath)) { try { $exeMatch = ($p.MainModule.FileName -eq $app.exePath) } catch { $exeMatch = $true } } if (-not $exeMatch) { continue } return $true } return $false } catch { return $false } } function Start-App { param([System.Windows.Window]$Window, [string]$Id) $app = Get-AppDef -Window $Window -Id $Id if (-not $app) { return } if (-not $app.exePath) { [System.Windows.MessageBox]::Show("exePath が未設定です: $($app.display)") return } try { # URLなら既定ブラウザで開く if ($app.exePath -match '^(http|https)://') { Start-Process -FilePath $app.exePath return } # 通常の実行ファイル $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = $app.exePath if ($app.args) { $psi.Arguments = [string]$app.args } if ($app.workingDir) { $psi.WorkingDirectory = $app.workingDir } $psi.UseShellExecute = $true if ($app.runAsAdmin) { $psi.Verb = 'runas' } $psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Normal [void][System.Diagnostics.Process]::Start($psi) } catch { [System.Windows.MessageBox]::Show("起動に失敗: $($_.Exception.Message)") } } function Get-AppFolder { param($app) if ($app.workingDir -and (Test-Path -LiteralPath $app.workingDir -PathType Container)) { return $app.workingDir } if ($app.exePath -and ($app.exePath -notmatch '^(http|https)://') -and (Test-Path -LiteralPath $app.exePath -PathType Leaf)) { return (Split-Path -LiteralPath $app.exePath -Parent) } # 3) exePath がフォルダを指していたらそのまま if ($app.exePath -and (Test-Path -LiteralPath $app.exePath -PathType Container)) { return $app.exePath } return $null } function Open-AppFolder { param([System.Windows.Window]$Window, [string]$Id) $app = Get-AppDef -Window $Window -Id $Id if (-not $app) { return } # URL if ($app.exePath -match '^(http|https)://') { [System.Windows.MessageBox]::Show("URL のアプリにはフォルダがありません。") return } $folder = Get-AppFolder -app $app if (-not $folder) { [System.Windows.MessageBox]::Show("開くフォルダを特定できませんでした。apps[].workingDir か exePath を確認してください。") return } try { # フォルダを開く Start-Process -FilePath explorer.exe -ArgumentList ('"{0}"' -f $folder) } catch { [System.Windows.MessageBox]::Show("フォルダを開けませんでした: $($_.Exception.Message)`nPath: $folder") } } function Update-AppLauncherStatus { param([System.Windows.Window]$Window) $list = $Window.FindName('AppList') if (-not $list) { return } $gen = $list.ItemContainerGenerator for ($i=0; $i -lt $list.Items.Count; $i++) { $container = $gen.ContainerFromIndex($i) -as [System.Windows.FrameworkElement] if (-not $container) { continue } $container.ApplyTemplate() | Out-Null $tb = $null if (Get-Command -Name Find-ChildByName -ErrorAction SilentlyContinue) { $tb = Find-ChildByName -Parent $container -Name 'AppStatusText' } if (-not $tb) { continue } $item = $list.Items[$i] $running = Test-AppRunning $item $tb.Text = if ($running) { '起動中' } else { '未起動' } $tb.Foreground = if ($running) { New-Object System.Windows.Media.SolidColorBrush ([System.Windows.Media.Colors]::LightGreen) } else { New-Object System.Windows.Media.SolidColorBrush ([System.Windows.Media.Colors]::LightGray) } } } function Initialize-AppLauncher { param([System.Windows.Window]$Window) $list = $Window.FindName('AppList') if ($list) { $gen = $list.ItemContainerGenerator $gen.Add_StatusChanged({ if ($gen.Status -eq [System.Windows.Controls.Primitives.GeneratorStatus]::ContainersGenerated) { Update-AppLauncherStatus -Window $Window } }) # ダブルクリックでも起動 $list.AddHandler([System.Windows.Controls.Control]::MouseDoubleClickEvent, [System.Windows.Input.MouseButtonEventHandler]{ param($s,$e) $fe = $e.OriginalSource -as [System.Windows.FrameworkElement] if (-not $fe) { return } $container = [System.Windows.Controls.ItemsControl]::ContainerFromElement($list,$fe) if (-not $container) { return } $item = $container.Content if ($item -and $item.id) { Start-App -Window $Window -Id $item.id Start-Sleep -Milliseconds 200 Update-AppLauncherStatus -Window $Window } } ) } } function Attach-AppLauncherButtons { param([System.Windows.Window]$Window) $Window.AddHandler([System.Windows.Controls.Button]::ClickEvent, [System.Windows.RoutedEventHandler]{ param($s,$e) $btn = $e.OriginalSource -as [System.Windows.Controls.Button] if (-not $btn -or -not $btn.Tag) { return } $id = [string]$btn.Tag switch ($btn.Name) { 'AppRunBtn' { Start-App -Window $Window -Id $id; Start-Sleep -Milliseconds 200; Update-AppLauncherStatus -Window $Window } 'AppOpenDirBtn' { Open-AppFolder -Window $Window -Id $id } } }) } Export-ModuleMember -Function Initialize-AppLauncher, Attach-AppLauncherButtons, Update-AppLauncherStatus
9. modules/AppIcons.psm1(アイコン解決:@exe / 画像 / URL)
解説
- PowerShell 5.x 互換(
?.
などは不使用) - 画像は ローカルパス/http(s) URL の両対応
- exe の埋込アイコン抽出は
System.Drawing.Icon
→Imaging.CreateBitmapSourceFromHIcon
# modules/AppIcons.psm1 (PowerShell 5.x 対応) # 画像ファイルから BitmapImage をつくる function New-BitmapImageFromPath { param([string]$Path) if (-not $Path -or -not (Test-Path $Path)) { return $null } try { $uri = New-Object System.Uri($Path, [System.UriKind]::Absolute) $bi = New-Object System.Windows.Media.Imaging.BitmapImage $bi.BeginInit() $bi.CacheOption = [System.Windows.Media.Imaging.BitmapCacheOption]::OnLoad $bi.CreateOptions = [System.Windows.Media.Imaging.BitmapCreateOptions]::IgnoreColorProfile $bi.UriSource = $uri $bi.EndInit() $bi.Freeze() return $bi } catch { return $null } } # exe の埋込アイコンを ImageSource に変換 function New-ImageSourceFromExe { param([string]$ExePath) if (-not $ExePath -or -not (Test-Path $ExePath)) { return $null } try { Add-Type -AssemblyName System.Drawing -ErrorAction SilentlyContinue $ico = [System.Drawing.Icon]::ExtractAssociatedIcon($ExePath) if (-not $ico) { return $null } $src = [System.Windows.Interop.Imaging]::CreateBitmapSourceFromHIcon( $ico.Handle, (New-Object System.Windows.Int32Rect 0,0,$ico.Width,$ico.Height), [System.Windows.Media.Imaging.BitmapSizeOptions]::FromEmptyOptions() ) $src.Freeze() return $src } catch { return $null } } function Resolve-AppIcons { param([System.Windows.Window]$Window) $apps = $Window.DataContext.apps if (-not $apps) { return } foreach ($app in $apps) { $img = $null $iconProp = $app.PSObject.Properties['icon'] $iconSpec = $null if ($iconProp) { $iconSpec = [string]$iconProp.Value } if ($iconSpec) { if ($iconSpec -eq '@exe') { $img = if ($app.exePath) { New-ImageSourceFromExe -ExePath $app.exePath } else { $null } } else { # ファイルパス(相対なら workingDir から解決) $path = $iconSpec if (-not (Test-Path $path)) { if ($app.workingDir) { $cand = Join-Path $app.workingDir $iconSpec if (Test-Path $cand) { $path = $cand } } elseif ($app.exePath) { $cand = Join-Path (Split-Path $app.exePath -Parent) $iconSpec if (Test-Path $cand) { $path = $cand } } } $img = New-BitmapImageFromPath -Path $path } } else { # 指定がなければ exe のアイコンを試す $img = if ($app.exePath) { New-ImageSourceFromExe -ExePath $app.exePath } else { $null } } # UI から参照するプロパティを追加/更新 Add-Member -InputObject $app -NotePropertyName iconImage -NotePropertyValue $img -Force } } Export-ModuleMember -Function Resolve-AppIcons, New-BitmapImageFromPath, New-ImageSourceFromExe
10. modules/AppTimers.psm1(UI タイマー)
解説
DispatcherTimer
をラップ- 2秒ごとにサービス/ランチャーの状態更新
function New-UiTimer { param( [int]$IntervalSec = 2, [ScriptBlock]$OnTick ) $t = New-Object System.Windows.Threading.DispatcherTimer $t.Interval = [TimeSpan]::FromSeconds($IntervalSec) if ($OnTick) { $t.Add_Tick($OnTick) } return $t } function Start-UiTimer { param( [Parameter(Mandatory)]$Timer ) $Timer.Start() } function Stop-UiTimer { param( [Parameter(Mandatory)]$Timer ) $Timer.Stop() } Export-ModuleMember -Function New-UiTimer, Start-UiTimer, Stop-UiTimer
よくあるハマりどころと対策
XamlParseException: StaticExtension
→{x:Static x:Null}
は使わず{x:Null}
で判定。- PS5.x で
?.
構文エラー
→ 使わない($obj.PSObject.Properties[...]
を素直に分岐)。 - Start-Process の ParamSet 競合
→ ProcessStartInfo に一本化し、引数は1本の文字列で渡す。 - 起動直後に停止と判定される
→ServiceMonitor
は wrapper 常駐で安定化。pidFile を併用。 - 相対パス
scriptPath
が動かない
→ wrapper 内でSet-Location workingDir
→. 'script.ps1'
にしているのでOK。
まとめ
- JSON を書き換えるだけでダッシュボード&ランチャーを運用可能。
- タブを増やしてログビューア、ジョブ管理、API連携(Redmine/Notion/Jira等)も簡単に拡張できます。