はじめに
今回紹介するのは、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等)も簡単に拡張できます。