PowerShell + WPF で作る「JSON 駆動ダッシュボード & アプリランチャー」

はじめに

今回紹介するのは、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 内の HyperlinkRequestNavigate を一括ハンドリング
  • エラー時は 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-ServiceRunningpidFile優先 → 失踪時はコマンドライン再特定
  • UI は SvcListStatusText を更新(緑/赤)
# 自分(ダッシュボード)を除外するための情報
$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.IconImaging.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等)も簡単に拡張できます。

スポンサーリンク

-IT関連
-,