PowerShellで作る Dashboard アプリのエントリーポイント解説

本記事では、PowerShellで構築された Dashboardアプリのエントリーポイントスクリプト Main.ps1 を解説します。
このスクリプトは、アプリ全体を正しく起動させるための「玄関口」として機能します。

これを使って、前回作成した下記のツールをダッシュボードで可視化してみます。

2025/10/1

ブラウザ上のリンク先URLをポップアップ表示するPowerShellスクリプト

PowerShell と WPF を組み合わせて、マウスカーソル下のリンク先 URL をポップアップ表示するスクリプトを作成しました。対象ブラウザを限定したり、ブロックリストで危険なサイトを赤色表示するなど、実用性の高い機能を盛り込んでいます。 主な機能 事前準備 スクリプト本体 以下を .ps1 ファイルとして保存してください。例: Show-UrlPopup.ps1 動作イメージ これにより、リンク先を確認しながら安全にブラウジングできます。 使い方 ※ -sta は自動で付与されるので、通常の実行で ...

# Main.ps1 - RedDash entrypoint (Always relaunch STA, with logging & fallbacks)

# ====== Settings ======
$Global:RedDashRoot   = 'C:\myTool'
$Global:RuntimeDir    = Join-Path $Global:RedDashRoot 'runtime'
$Global:StaMarkerPath = Join-Path $Global:RuntimeDir 'relaunch.marker'
$Global:LogPath       = Join-Path $Global:RuntimeDir 'RedDash.log'
$Global:BootMode      = ''   # relaunch-external | runspace-fallback | sta-direct
# ======================

# ---- Logging helpers ----
function Write-Log {
  param([string]$msg,[string]$level='INFO',[ConsoleColor]$Color=[ConsoleColor]::Yellow)
  $line = "[RedDash][$level] $msg"
  try { $dir = Split-Path -Parent $Global:LogPath; if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null } } catch {}
  try { Add-Content -Path $Global:LogPath -Value ("{0:u} {1}" -f (Get-Date), $line) -Encoding UTF8 } catch {}
  try { Write-Host $line -ForegroundColor $Color } catch { Write-Output $line }
}
function Write-ErrLog { param([string]$msg) Write-Log $msg 'ERROR' ([ConsoleColor]::Red) }

# ---- Relaunch to STA (always) ----
function Invoke-RelaunchSTA {
  param([Parameter(Mandatory)][string]$ScriptPath,[string[]]$Args)
  try { $psExe = (Get-Process -Id $PID).Path } catch { $psExe = "$env:WINDIR\System32\WindowsPowerShell\v1.0\powershell.exe" }
  if (-not (Test-Path $psExe)) { $psExe = 'powershell.exe' }

  function Q([string]$s){ if ($null -eq $s) { return $null }; if ($s -match '\s|"'){'"' + ($s -replace '"','`"') + '"'} else {$s} }

  try {
    if (-not (Test-Path $Global:RuntimeDir)) { New-Item -ItemType Directory -Path $Global:RuntimeDir | Out-Null }
    'marker' | Set-Content -Path $Global:StaMarkerPath -Encoding UTF8
  } catch {}

  $argList = @('-NoProfile','-ExecutionPolicy','Bypass','-STA','-File', $ScriptPath) + $Args
  $argStr  = ($argList | ForEach-Object { Q $_ } | Where-Object { $_ -ne $null }) -join ' '

  Write-Log "Relaunching in STA: $psExe $argStr"
  try {
    $p = Start-Process -FilePath $psExe -ArgumentList $argStr -WorkingDirectory (Get-Location) -PassThru -WindowStyle Normal
    if ($p) {
      Write-Log "New process PID=$($p.Id). Exiting current..."
      Start-Sleep -Milliseconds 120
      exit
    }
  } catch {
    Write-ErrLog "Start-Process failed: $($_.Exception.Message). Using runspace fallback."
  }

  # Fallback: same-process STA runspace
  $Global:BootMode = 'runspace-fallback'
  $rs = [runspacefactory]::CreateRunspace()
  $rs.ApartmentState = 'STA'
  $rs.ThreadOptions  = 'ReuseThread'; $rs.Open()
  $ps = [powershell]::Create(); $ps.Runspace = $rs
  $self = $PSCommandPath; if (-not $self) { $self = $MyInvocation.MyCommand.Path }
  $code = @"
`$Global:RedDashRoot   = '$Global:RedDashRoot'
`$Global:RuntimeDir    = '$Global:RuntimeDir'
`$Global:StaMarkerPath = '$Global:StaMarkerPath'
`$Global:LogPath       = '$Global:LogPath'
`$Global:BootMode      = 'runspace-fallback'
. '$self'
"@
  $ps.AddScript($code) | Out-Null
  try { $null = $ps.Invoke() } finally { $ps.Dispose(); $rs.Dispose(); exit }
}

# ===== Bootstrap =====
# 1) 常に外部 STA リローンチ(マーカーで1回だけ)
$scriptPath = $PSCommandPath; if (-not $scriptPath) { $scriptPath = $MyInvocation.MyCommand.Path }
if (-not $scriptPath) { Write-ErrLog 'Cannot resolve script path.'; exit 1 }

if (-not (Test-Path $Global:StaMarkerPath)) {
  Invoke-RelaunchSTA -ScriptPath $scriptPath -Args $args
  return
}

# 2) ここに来た=外部再起動済
try { Remove-Item $Global:StaMarkerPath -ErrorAction SilentlyContinue } catch {}
$Global:BootMode = 'relaunch-external'

# ===== Main (STA 確定) =====
try {
  Add-Type -AssemblyName PresentationCore,PresentationFramework,WindowsBase
    # --- ensure WPF Application exists (STA 前提) ---
    function Ensure-WpfApp {
        $app = [System.Windows.Application]::Current
        if (-not $app) {
            $app = New-Object System.Windows.Application
            # 任意: ここでグローバル例外も握る
            try {
                $app.DispatcherUnhandledException += {
                    param($s,$e)
                    Write-Host "[RedDash][UI] " + $e.Exception.Message -ForegroundColor Red
                    $e.Handled = $true
                }
            } catch {}
        }
        return $app
    }

  # Global exception logging
  try {
    [AppDomain]::CurrentDomain.UnhandledException += {
      param($s,$e) $ex = $e.ExceptionObject; Write-ErrLog ("Unhandled: " + ($ex.Message))
    }
    if (-not [System.Windows.Application]::Current) { $null = New-Object System.Windows.Application }
    [System.Windows.Application]::Current.DispatcherUnhandledException += {
      param($s,$e) Write-ErrLog ("UI: " + $e.Exception.Message); $e.Handled = $true
    }
  } catch {}

  Set-Location $Global:RedDashRoot

  # Import modules
  . (Join-Path $Global:RedDashRoot 'Modules\Launcher.ps1')
  . (Join-Path $Global:RedDashRoot 'Modules\Dashboard.ps1')
  . (Join-Path $Global:RedDashRoot 'Modules\Redmine.Api.ps1')
  . (Join-Path $Global:RedDashRoot 'Modules\Redmine.ps1')
  . (Join-Path $Global:RedDashRoot 'Modules\Settings.ApiKey.ps1')

    # Styles
    $app = Ensure-WpfApp   # ★ここで必ず Application を作る

    $stylesPath  = Join-Path $Global:RedDashRoot 'Xaml\Styles.xaml'
    $stylesXaml  = Get-Content $stylesPath -Raw
    $stylesReader= [System.Xml.XmlReader]::Create((New-Object System.IO.StringReader $stylesXaml))
    $stylesDict  = [Windows.Markup.XamlReader]::Load($stylesReader)

    # Application.Resources へマージ
    $app.Resources.MergedDictionaries.Add($stylesDict) | Out-Null

  $stylesXaml   = Get-Content (Join-Path $Global:RedDashRoot 'Xaml\Styles.xaml') -Raw
  $stylesReader = [System.Xml.XmlReader]::Create((New-Object System.IO.StringReader $stylesXaml))
  $stylesDict   = [Windows.Markup.XamlReader]::Load($stylesReader)
  [System.Windows.Application]::Current.Resources.MergedDictionaries.Add($stylesDict) | Out-Null

  # MainWindow
  $mainXaml   = Get-Content (Join-Path $Global:RedDashRoot 'Xaml\MainWindow.xaml') -Raw
  $mainReader = [System.Xml.XmlReader]::Create((New-Object System.IO.StringReader $mainXaml))
  $window     = [Windows.Markup.XamlReader]::Load($mainReader)

  $modeLabel = switch ($Global:BootMode) {
    'relaunch-external' { 'STA (relaunch)' }
    'runspace-fallback' { 'STA (runspace)' }
    default             { 'STA' }
  }
  $window.Title = "RedDash - $modeLabel"

  # Helpers
  function FN($name) { $window.FindName($name) }
  $btnDashboard = FN "BtnDashboard"
  $btnLauncher  = FN "BtnLauncher"
  $btnRedmine   = FN "BtnRedmine"
  $contentArea  = FN "ContentArea"

  function Show-View($uiElement) {
    $contentArea.Children.Clear()
    $null = $contentArea.Children.Add($uiElement)
  }

  function Set-StatusError {
    param([Parameter(Mandatory)]$ErrRecord,[string]$Context='')
    $msgCore = if ($ErrRecord -is [System.Management.Automation.ErrorRecord]) { $ErrRecord.Exception.Message } else { [string]$ErrRecord }
    $line = if ($Context) { "[$Context] $msgCore" } else { $msgCore }
    if ($script:RedState -and $script:RedState.Status) { $script:RedState.Status.Text = "❌ $line" }
    Write-ErrLog $line
    try {
      if ($ErrRecord.InvocationInfo -and $ErrRecord.InvocationInfo.PositionMessage) { Write-Log ($ErrRecord.InvocationInfo.PositionMessage.TrimEnd()) 'TRACE' ([ConsoleColor]::DarkGray) }
      if ($ErrRecord.ScriptStackTrace) { Write-Log ($ErrRecord.ScriptStackTrace.TrimEnd()) 'TRACE' ([ConsoleColor]::DarkGray) }
    } catch {}
  }

  # Nav events
  $btnDashboard.Add_Click({
    $dash = New-DashboardView
    Show-View $dash
    Initialize-DashboardView -View $dash -HostWindow $window
  })
  $btnLauncher.Add_Click({
    $view = New-LauncherView
    Show-View $view
    Initialize-LauncherView -View $view -HostWindow $window
  })
  $btnRedmine.Add_Click({
    $view = New-RedmineView
    Show-View $view
    Initialize-RedmineView -View $view -HostWindow $window
  })

  # Default: Dashboard
  try {
    $dash = New-DashboardView
    Show-View $dash
    Initialize-DashboardView -View $dash -HostWindow $window
  } catch { Write-ErrLog "Dashboard init failed: $($_.Exception.Message)" }

  Write-Log ("BootstrapMode = {0} (thread={1}; runspace={2})" -f $Global:BootMode, [System.Threading.Thread]::CurrentThread.GetApartmentState(), $Host.Runspace.ApartmentState)

  $null = $window.ShowDialog()

} catch {
  Write-ErrLog "Fatal error: $($_.Exception.Message)"
  throw
}

スクリプトのポイント解説

1. STA強制再起動

  • PowerShellは通常MTAモードで動作しますが、WPFアプリは STA必須
  • Invoke-RelaunchSTA 関数で必ず -STA 付きで再起動する仕組みを用意。

2. ログ出力機能

  • Write-Log / Write-ErrLog により、実行経過をコンソールとログファイル両方に保存。
  • 実行トラブルの解析が容易。

3. WPF関連アセンブリのロード

  • PresentationCore, PresentationFramework, WindowsBase を追加ロード。
  • XAMLベースのUIを使用可能に。

4. モジュール読み込み

  • Launcher.ps1Dashboard.ps1 など、アプリ機能の中核モジュールをインポート。

Dashboard.ps1

# Modules/Dashboard.ps1
# PowerShell 5.x / WPF
# - コンソールでも確実表示
# - Start/Stop の見た目を即時置換 → PID検出/消滅で確定反映
# - XAML: x:Class を自動除去して読み込み
# - 例外は Write-DashError へ集約

Add-Type -AssemblyName PresentationCore,PresentationFramework,WindowsBase | Out-Null

# ===== 設定 =====
$script:DashXamlPath   = 'C:\myTool\Xaml\Dashboard.xaml'
$script:ToolScriptPath = 'C:\myTool\Tools\UrlInspector.ps1'
$script:ToolRunDir     = 'C:\myTool\runtime'
$script:ToolPidFile    = Join-Path $script:ToolRunDir 'UrlInspector.pid'
$script:ToolCmdMatch   = [Regex]::Escape($script:ToolScriptPath)
# =================

# ===== ログ =====
function Write-DashError {
  param($Err,$Ctx='')
  try {
    $msg = if ($Err -and ($Err -is [System.Management.Automation.ErrorRecord]) -and $Err.Exception) { $Err.Exception.Message }
           elseif ($Err) { [string]$Err } else { 'Unknown error' }
    if ($Ctx) { $msg = "[$Ctx] $msg" }
    Write-Error "❌ $msg"
    if ($Err -and $Err.InvocationInfo -and $Err.InvocationInfo.PositionMessage) { Write-Host ($Err.InvocationInfo.PositionMessage.TrimEnd()) }
    if ($Err -and $Err.ScriptStackTrace) { Write-Host ($Err.ScriptStackTrace.TrimEnd()) }
  } catch { Write-Error "❌ [$Ctx] $_" }
}

# ===== XAML 読込(x:Class 自動除去)=====
function Load-XamlFile([string]$path) {
  if (-not (Test-Path $path)) { throw "XAML が見つかりません: $path" }
  $xaml = Get-Content -LiteralPath $path -Raw
  $xaml = [regex]::Replace($xaml, '\s+x:Class="[^"]*"', '', 'IgnoreCase')
  $sr = New-Object System.IO.StringReader $xaml
  $xr = [System.Xml.XmlReader]::Create($sr)
  [Windows.Markup.XamlReader]::Load($xr)
}

# ===== ユーティリティ =====
function Ensure-ToolRuntime {
  if (!(Test-Path $script:ToolRunDir)) { New-Item -ItemType Directory -Path $script:ToolRunDir | Out-Null }
}

# プロセス検出(PID ファイル → CIM → WMI)
function Get-ToolProcess {
  if (Test-Path $script:ToolPidFile) {
    try {
      $pidText = (Get-Content $script:ToolPidFile -Raw).Trim()
      if ($pidText -and $pidText -match '^\d+$') {
        $p = Get-Process -Id [int]$pidText -ErrorAction SilentlyContinue
        if ($p) { return $p }
      }
    } catch {}
  }
  try {
    $procs = Get-CimInstance Win32_Process -Filter "Name='powershell.exe' OR Name='pwsh.exe'" -ErrorAction Stop
    foreach ($c in $procs) {
      if ($c.CommandLine -and ($c.CommandLine -match $script:ToolCmdMatch)) {
        try { return Get-Process -Id $c.ProcessId -ErrorAction SilentlyContinue } catch {}
      }
    }
  } catch {
    try {
      $procs = Get-WmiObject Win32_Process -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '^(powershell|pwsh)\.exe$' }
      foreach ($c in $procs) {
        if ($c.CommandLine -and ($c.CommandLine -match $script:ToolCmdMatch)) {
          try { return Get-Process -Id $c.ProcessId -ErrorAction SilentlyContinue } catch {}
        }
      }
    } catch {}
  }
  return $null
}

function Get-ToolState {
  $p = Get-ToolProcess
  if ($p) { [pscustomobject]@{ IsRunning=$true;  Pid=$p.Id; Started=$p.StartTime } }
  else    { [pscustomobject]@{ IsRunning=$false; Pid=$null; Started=$null     } }
}

function Start-Tool {
  Ensure-ToolRuntime
  $st = Get-ToolState
  if ($st.IsRunning) { return $st }

  $psExe = "$env:WINDIR\System32\WindowsPowerShell\v1.0\powershell.exe"
  if (-not (Test-Path $psExe)) { $psExe = 'powershell.exe' }
  if (-not (Test-Path $script:ToolScriptPath)) { throw "ツールが見つかりません:`n$($script:ToolScriptPath)" }

  $args = @('-NoProfile','-ExecutionPolicy','Bypass','-STA','-File', $script:ToolScriptPath)
  $proc = Start-Process -FilePath $psExe -ArgumentList $args -WindowStyle Hidden -PassThru
  try { $proc.WaitForInputIdle(2000) | Out-Null } catch {}
  try { $proc.Id.ToString() | Set-Content -Path $script:ToolPidFile -Encoding ASCII } catch {}

  for ($i=0; $i -lt 5; $i++) { Start-Sleep -Milliseconds 150; if (Get-ToolProcess) { break } }
  Get-ToolState
}

function Stop-Tool {
  $p = Get-ToolProcess
  if ($p) {
    try {
      Stop-Process -Id $p.Id -ErrorAction SilentlyContinue
      Start-Sleep -Milliseconds 150
      if (Get-Process -Id $p.Id -ErrorAction SilentlyContinue) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue }
    } catch {}
  }
  try { Remove-Item -Path $script:ToolPidFile -ErrorAction SilentlyContinue } catch {}
  for ($i=0; $i -lt 5; $i++) { Start-Sleep -Milliseconds 120; if (-not (Get-ToolProcess)) { break } }
  Get-ToolState
}

# sender から親 UserControl/Window を遡って取得
function Get-RootViewFromSender { param($Sender)
  $d = $Sender -as [System.Windows.DependencyObject]
  while ($d -and -not ($d -is [System.Windows.Controls.UserControl] -or $d -is [System.Windows.Window])) {
    try { $d = [System.Windows.Media.VisualTreeHelper]::GetParent($d) } catch { break }
  }
  $d
}

# UI を固めない DoEvents 相当
function Do-Events {
  $frame = New-Object Windows.Threading.DispatcherFrame
  [Windows.Threading.Dispatcher]::CurrentDispatcher.BeginInvoke(
    [Windows.Threading.DispatcherPriority]::Background,
    [System.Windows.Threading.DispatcherOperationCallback]{ param($f) $f.Continue = $false; $null },
    $frame
  ) | Out-Null
  [Windows.Threading.Dispatcher]::PushFrame($frame)
}

# PID 検出/消滅 待ち
function Wait-ToolUp   { param([int]$TimeoutMs=7000,[int]$IntervalMs=120) $sw=[Diagnostics.Stopwatch]::StartNew(); while($sw.ElapsedMilliseconds -lt $TimeoutMs){ if(Get-ToolProcess){return $true}; Start-Sleep -Milliseconds $IntervalMs; Do-Events }; $false }
function Wait-ToolDown { param([int]$TimeoutMs=7000,[int]$IntervalMs=120) $sw=[Diagnostics.Stopwatch]::StartNew(); while($sw.ElapsedMilliseconds -lt $TimeoutMs){ if(-not(Get-ToolProcess)){return $true}; Start-Sleep -Milliseconds $IntervalMs; Do-Events }; $false }

# ===== カード・データ =====
function New-ToolControlCard {
  $st = Get-ToolState
  $accent     = if ($st.IsRunning) { '#34C759' } else { '#6B7280' }
  $statusText = if ($st.IsRunning) { "実行中 (PID $($st.Pid))" } else { "停止中" }
  $valueText  = if ($st.IsRunning) { 'ON' } else { 'OFF' }

  [pscustomobject]@{
    IsControlCard = $true
    IsRunning     = $st.IsRunning
    Title         = 'URLウォッチャー'
    Badge         = 'CTRL'
    Accent        = $accent
    StatusText    = $statusText
    Value         = $valueText
    Description   = 'リンク先URLをポップアップ表示'
    Items         = @(
      "スクリプト: $($script:ToolScriptPath)",
      "ブロックリスト: C:\myTool\AccessDeniedURL\URL_LIST.txt",
      "検出: powershell.exe のコマンドライン一致"
    )
  }
}

function Get-StaticCards {
  @(
    [pscustomobject]@{
      Title='Open Issues'; Value=(Get-Random -Minimum 12 -Maximum 30)
      Description='全プロジェクト / 直近30日'; Badge='LIVE'; Accent='#3A6EE8'
      Items=@('API: タイムアウト発生','UI: 入力検証のズレ','Auth: 監査ログ項目追加')
    },
    [pscustomobject]@{
      Title='My Tasks Due Soon'; Value=(Get-Random -Minimum 1 -Maximum 5)
      Description='私の期限接近タスク'; Badge=''; Accent='#34C759'
      Items=@('レビュー','設計メモ整理','ダッシュボード調整')
    }
  )
}

# ===== ItemsControl 更新 =====
function Ensure-DashCards {
  param([Parameter(Mandatory)] $View)

  if (-not ($View.Tag -is [hashtable])) { $View.Tag = @{} }
  $cards = $null
  if ($View.Tag.ContainsKey('Cards')) { $cards = $View.Tag['Cards'] }

  # 常に ObservableCollection[object] を保証
  if (-not $cards -or ($cards -isnot [System.Collections.ObjectModel.ObservableCollection[object]])) {
    $cards = New-Object 'System.Collections.ObjectModel.ObservableCollection[object]'
    $View.Tag['Cards'] = $cards
  }
  return $cards
}

function Bind-DashCards {
  param([Parameter(Mandatory)] $View)

  $rep = $View.FindName('CardRepeater'); if (-not $rep) { return $null }
  $cards = Ensure-DashCards -View $View

  # ItemsSource が他の型になっていたら必ず差し替える
  try {
    if (-not $rep.ItemsSource -or ($rep.ItemsSource -ne $cards)) {
      $rep.ItemsSource = $cards
    } else {
      $rep.Items.Refresh()
    }
  } catch {
    # 何かで固まっていた場合も再バインド
    $rep.ItemsSource = $cards
  }
  return $cards
}

function Replace-ToolCard {
  param($View, $newCard)

  if (-not $View) { return }

  # 必ず ObservableCollection を取得
  $cards = Ensure-DashCards -View $View

  # 位置を特定
  $idx = -1
  for ($i=0; $i -lt $cards.Count; $i++) {
    try { if ($cards[$i].IsControlCard) { $idx = $i; break } } catch {}
  }

  try {
    if ($cards -is [System.Collections.ObjectModel.ObservableCollection[object]]) {
      # 安全:インデクサー代入で置換(RemoveAt/Insert 不要)
      if ($idx -ge 0) { $cards[$idx] = $newCard }
      else { $cards.Add($newCard) | Out-Null }
    } else {
      # 想定外の型(固定サイズなど)は再構築して再バインド
      $newOc = New-Object 'System.Collections.ObjectModel.ObservableCollection[object]'
      foreach ($x in @($cards)) { $newOc.Add($x) | Out-Null }
      if ($idx -ge 0) {
        # 元に同種カードがあったなら同じ位置を差し替え
        if ($idx -lt $newOc.Count) { $newOc[$idx] = $newCard }
        else { $newOc.Add($newCard) | Out-Null }
      } else {
        $newOc.Add($newCard) | Out-Null
      }
      $View.Tag['Cards'] = $newOc
      $rep = $View.FindName('CardRepeater')
      if ($rep) { $rep.ItemsSource = $newOc }
    }

    # 明示的に再描画
    $rep2 = $View.FindName('CardRepeater')
    if ($rep2) {
      if ($rep2.Dispatcher.CheckAccess()) { $rep2.Items.Refresh(); $rep2.UpdateLayout() }
      else { $rep2.Dispatcher.Invoke([Action]{ $rep2.Items.Refresh(); $rep2.UpdateLayout() }) }
    }
  } catch {
    Write-DashError $_ 'Replace-ToolCard'
  }
}

function Refresh-CardRepeater { param([Parameter(Mandatory)] $View)
  try {
    $rep = $View.FindName('CardRepeater'); if (-not $rep) { return }
    if ($rep.Dispatcher.CheckAccess()) { $rep.Items.Refresh(); $rep.UpdateLayout() }
    else { $rep.Dispatcher.Invoke([Action]{ $rep.Items.Refresh(); $rep.UpdateLayout() }) }
  } catch {}
}

function Reset-DashCards {
  param([Parameter(Mandatory)] $View,[Parameter(Mandatory)][object[]] $items)
  $cards = Ensure-DashCards -View $View
  try { $cards.Clear() } catch {
    $cards = New-Object 'System.Collections.ObjectModel.ObservableCollection[object]'
    $View.Tag['Cards'] = $cards
  }
  foreach ($i in @($items)) { $cards.Add($i) | Out-Null }
  $null = Bind-DashCards -View $View
  Refresh-CardRepeater -View $View
}

function Refresh-ToolCard { param($View)
  if (-not $View) { return }
  Replace-ToolCard -View $View -newCard (New-ToolControlCard)
  Refresh-CardRepeater -View $View
}

# ===== View 生成/初期化 =====
function New-DashboardView { Load-XamlFile $script:DashXamlPath }

function Initialize-DashboardView {
  param([Parameter(Mandatory)] $View,[Parameter(Mandatory)] $HostWindow)

  if (-not ($View.Tag -is [hashtable])) { $View.Tag = @{} }
  if ($View.Tag.DashInit) { return }
  $View.Tag.DashInit = $true

  $View.Add_Loaded( (
    {
      param($s,$e)
      try {
        $v = if ($s) { [System.Windows.FrameworkElement]$s } else { $View }
        if (-not ($v.Tag -is [hashtable])) { $v.Tag = @{} }

        # 初回カード
        $null = Bind-DashCards -View $v
        $cardsInit = @(); $cardsInit += Get-StaticCards; $cardsInit += New-ToolControlCard
        Reset-DashCards -View $v -items $cardsInit
        $v.UpdateLayout()

        # Routed ボタン(Start/Stop/更新)
        $rep = $v.FindName('CardRepeater')
        if ($rep) {
          $handler = [System.Windows.RoutedEventHandler](
            {
              param($sender,$ev)
              $btn  = $ev.OriginalSource -as [System.Windows.Controls.Button]; if (-not $btn) { return }
              $root = Get-RootViewFromSender -Sender $btn; if (-not $root) { $root = $v }

              switch ($btn.Name) {
                'ToolStartBtn' {
                  try {
                    # 即時プレースホルダ
                    $tmp = New-ToolControlCard
                    $tmp.IsRunning = $true; $tmp.StatusText = '起動中...'; $tmp.Value='ON'; $tmp.Accent='#3A6EE8'
                    Replace-ToolCard -View $root -newCard $tmp
                    $null = Bind-DashCards -View $root
                    Refresh-CardRepeater -View $root

                    $null = Start-Tool
                    if (Wait-ToolUp -TimeoutMs 7000 -IntervalMs 120) { Refresh-ToolCard -View $root }
                    else {
                      $tmp2 = New-ToolControlCard
                      $tmp2.IsRunning=$false; $tmp2.StatusText='起動に失敗(PID未検出)'; $tmp2.Value='OFF'; $tmp2.Accent='#6B7280'
                      Replace-ToolCard -View $root -newCard $tmp2
                      $null = Bind-DashCards -View $root
                      Write-DashError 'ツールのPID検出に失敗しました(7秒タイムアウト)' 'Start-Tool'
                    }
                  } catch { Write-DashError $_ 'Start-Tool' }
                }
                'ToolStopBtn' {
                  try {
                    $tmp = New-ToolControlCard
                    $tmp.IsRunning=$false; $tmp.StatusText='停止中...'; $tmp.Value='OFF'; $tmp.Accent='#3A6EE8'
                    Replace-ToolCard -View $root -newCard $tmp
                    $null = Bind-DashCards -View $root
                    Refresh-CardRepeater -View $root

                    $null = Stop-Tool
                    if (Wait-ToolDown -TimeoutMs 7000 -IntervalMs 120) { Refresh-ToolCard -View $root }
                    else { Refresh-ToolCard -View $root; Write-DashError 'ツール停止を確認できません(7秒タイムアウト)' 'Stop-Tool' }
                  } catch { Write-DashError $_ 'Stop-Tool' }
                }
                'ToolRefreshBtn' {
                  try { Refresh-ToolCard -View $root } catch { Write-DashError $_ 'Refresh-ToolCard' }
                }
                default { }
              }
            }
          ).GetNewClosure()
          $rep.AddHandler([System.Windows.Controls.Button]::ClickEvent, $handler)
        }

        # ヘッダーの「更新」
        $btn = $v.FindName('DashRefreshBtn')
        if ($btn) {
          $btn.Add_Click( ({
            param($sender,$ee)
            try {
              $root = Get-RootViewFromSender -Sender $sender; if (-not $root) { $root = $v }
              $cards = @(); $cards += Get-StaticCards; $cards += New-ToolControlCard
              Reset-DashCards -View $root -items $cards
            } catch { Write-DashError $_ 'Dash-ManualRefresh' }
          }).GetNewClosure() )
        }

        # 1秒ポーリング
        $t = New-Object System.Windows.Threading.DispatcherTimer
        $t.Interval = [TimeSpan]::FromSeconds(1)
        $t.Add_Tick( ({
          param($sd,$ee)
          try { Refresh-ToolCard -View $v } catch { Write-DashError $_ 'Dash-Tick' }
        }).GetNewClosure() )
        $t.Start()
        $v.Tag['DashTimer'] = $t

        # Unloaded
        $v.Add_Unloaded( ({
          param($ss,$ee)
          try {
            if ($v.Tag -is [hashtable] -and $v.Tag['DashTimer']) { $v.Tag['DashTimer'].Stop() }
          } catch { Write-DashError $_ 'Dash-Unloaded' }
        }).GetNewClosure() )

      } catch { Write-DashError $_ 'Initialize-DashboardView' }
    }
  ).GetNewClosure() )
}

# Main から呼びやすいように公開
Set-Variable -Name NewDashboardView        -Value ${function:New-DashboardView}        -Scope Global
Set-Variable -Name InitializeDashboardView -Value ${function:Initialize-DashboardView} -Scope Global
Set-Item function:\NewDashboardView        -Value ${function:New-DashboardView}
Set-Item function:\InitializeDashboardView -Value ${function:Initialize-DashboardView}

Dashboard.xaml

<UserControl xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
  <UserControl.Resources>
    <!-- Palette -->
    <SolidColorBrush x:Key="BgWindow"   Color="#0F172A"/>
    <SolidColorBrush x:Key="BgCard"     Color="#111827"/>
    <SolidColorBrush x:Key="BorderCard" Color="#1F2937"/>
    <SolidColorBrush x:Key="FgText"     Color="#E5E7EB"/>
    <SolidColorBrush x:Key="FgSub"      Color="#9CA3AF"/>
    <SolidColorBrush x:Key="BtnGhost"   Color="#374151"/>
    <SolidColorBrush x:Key="BtnPrimary" Color="#2563EB"/>

    <!-- Buttons -->
    <Style x:Key="PrimaryButton" TargetType="Button">
      <Setter Property="Background" Value="{StaticResource BtnPrimary}"/>
      <Setter Property="Foreground" Value="White"/>
      <Setter Property="Padding" Value="10,6"/>
      <Setter Property="Margin" Value="0,0,6,0"/>
      <Setter Property="BorderThickness" Value="0"/>
      <Setter Property="FontWeight" Value="SemiBold"/>
      <Setter Property="Cursor" Value="Hand"/>
    </Style>
    <Style x:Key="GhostButton" TargetType="Button" BasedOn="{StaticResource PrimaryButton}">
      <Setter Property="Background" Value="{StaticResource BtnGhost}"/>
    </Style>

    <!-- Card -->
    <Style x:Key="CardBorderStyle" TargetType="Border">
      <Setter Property="Background" Value="{StaticResource BgCard}"/>
      <Setter Property="CornerRadius" Value="14"/>
      <Setter Property="BorderBrush" Value="{StaticResource BorderCard}"/>
      <Setter Property="BorderThickness" Value="1"/>
      <Setter Property="Padding" Value="14"/>
      <Setter Property="Margin" Value="10"/>
      <Setter Property="Width" Value="320"/>
      <Setter Property="Effect">
        <Setter.Value>
          <DropShadowEffect ShadowDepth="0" BlurRadius="8" Opacity="0.3"/>
        </Setter.Value>
      </Setter>
    </Style>

    <!-- カードテンプレート -->
    <DataTemplate x:Key="CardTemplate">
      <Border Style="{StaticResource CardBorderStyle}">
        <Grid>
          <Grid.RowDefinitions>
            <RowDefinition Height="4"/>    <!-- Accent -->
            <RowDefinition Height="Auto"/> <!-- Title line -->
            <RowDefinition Height="Auto"/> <!-- Action row -->
            <RowDefinition Height="Auto"/> <!-- Metric -->
            <RowDefinition Height="*"/>    <!-- Items -->
          </Grid.RowDefinitions>

          <!-- Accent -->
          <Border Grid.Row="0" Margin="-14,-14,-14,10" CornerRadius="14,14,0,0">
            <Border.Background>
              <SolidColorBrush Color="{Binding Accent}"/>
            </Border.Background>
          </Border>

          <!-- Title + badge -->
          <StackPanel Grid.Row="1" Orientation="Horizontal">
            <TextBlock Text="{Binding Title}" Foreground="{StaticResource FgText}" FontWeight="SemiBold" FontSize="15"/>
            <Border Background="#1E3A8A" Padding="6,2" CornerRadius="8" Margin="8,0,0,0">
              <Border.Style>
                <Style TargetType="Border">
                  <Setter Property="Visibility" Value="Visible"/>
                  <Style.Triggers>
                    <DataTrigger Binding="{Binding Badge}" Value="">
                      <Setter Property="Visibility" Value="Collapsed"/>
                    </DataTrigger>
                    <DataTrigger Binding="{Binding Badge}" Value="{x:Null}">
                      <Setter Property="Visibility" Value="Collapsed"/>
                    </DataTrigger>
                  </Style.Triggers>
                </Style>
              </Border.Style>
              <TextBlock Text="{Binding Badge}" Foreground="White" FontSize="11"/>
            </Border>
          </StackPanel>

          <!-- Action Row(制御カードのみ) -->
          <StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,8,0,6">
            <StackPanel.Style>
              <Style TargetType="StackPanel">
                <Setter Property="Visibility" Value="Collapsed"/>
                <Style.Triggers>
                  <DataTrigger Binding="{Binding IsControlCard}" Value="True">
                    <Setter Property="Visibility" Value="Visible"/>
                  </DataTrigger>
                </Style.Triggers>
              </Style>
            </StackPanel.Style>

            <!-- status dot -->
            <Ellipse Width="10" Height="10" VerticalAlignment="Center" Margin="0,0,6,0">
              <Ellipse.Style>
                <Style TargetType="Ellipse">
                  <Setter Property="Fill" Value="#6B7280"/>
                  <Style.Triggers>
                    <DataTrigger Binding="{Binding IsRunning}" Value="True">
                      <Setter Property="Fill" Value="#34C759"/>
                    </DataTrigger>
                  </Style.Triggers>
                </Style>
              </Ellipse.Style>
            </Ellipse>
            <TextBlock Text="{Binding StatusText}" Foreground="{StaticResource FgSub}" VerticalAlignment="Center"/>

            <StackPanel Orientation="Horizontal" Margin="8,0,0,0">
              <!-- ★ ここを修正:Style属性を外し、Button.Styleだけに統一 -->
              <Button x:Name="ToolStartBtn" Content="開始">
                <Button.Style>
                  <Style TargetType="Button" BasedOn="{StaticResource PrimaryButton}">
                    <Setter Property="Visibility" Value="Visible"/>
                    <Style.Triggers>
                      <DataTrigger Binding="{Binding IsRunning}" Value="True">
                        <Setter Property="Visibility" Value="Collapsed"/>
                      </DataTrigger>
                    </Style.Triggers>
                  </Style>
                </Button.Style>
              </Button>

              <Button x:Name="ToolStopBtn" Content="停止">
                <Button.Style>
                  <Style TargetType="Button" BasedOn="{StaticResource GhostButton}">
                    <Setter Property="Visibility" Value="Collapsed"/>
                    <Style.Triggers>
                      <DataTrigger Binding="{Binding IsRunning}" Value="True">
                        <Setter Property="Visibility" Value="Visible"/>
                      </DataTrigger>
                    </Style.Triggers>
                  </Style>
                </Button.Style>
              </Button>

              <!-- Refresh はトリガなしなので属性のままでOK(重複指定なし) -->
              <Button x:Name="ToolRefreshBtn" Content="更新" Style="{StaticResource GhostButton}"/>
            </StackPanel>
          </StackPanel>

          <!-- Metric -->
          <StackPanel Grid.Row="3" Margin="0,0,0,6">
            <TextBlock Text="{Binding Value}" Foreground="{StaticResource FgText}" FontSize="26" FontWeight="Bold"/>
            <TextBlock Text="{Binding Description}" Foreground="{StaticResource FgSub}" FontSize="12"/>
          </StackPanel>

          <!-- List -->
          <ItemsControl Grid.Row="4" ItemsSource="{Binding Items}">
            <ItemsControl.ItemTemplate>
              <DataTemplate>
                <StackPanel Orientation="Horizontal" Margin="0,2,0,2">
                  <TextBlock Text="•" Foreground="{StaticResource FgSub}" Margin="0,0,6,0"/>
                  <TextBlock Text="{Binding}" Foreground="{StaticResource FgText}" TextTrimming="CharacterEllipsis"/>
                </StackPanel>
              </DataTemplate>
            </ItemsControl.ItemTemplate>
          </ItemsControl>
        </Grid>
      </Border>
    </DataTemplate>
  </UserControl.Resources>

  <Grid Background="{StaticResource BgWindow}">
    <Grid.RowDefinitions>
      <RowDefinition Height="Auto"/>
      <RowDefinition Height="*"/>
    </Grid.RowDefinitions>

    <!-- Header -->
    <DockPanel Grid.Row="0" Margin="16,12,16,8">
      <TextBlock Text="ダッシュボード" FontSize="18" FontWeight="SemiBold" Foreground="{StaticResource FgText}" DockPanel.Dock="Left"/>
      <Button x:Name="DashRefreshBtn" Content="更新" Style="{StaticResource PrimaryButton}" DockPanel.Dock="Right"/>
    </DockPanel>

    <!-- Cards -->
    <ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
      <ItemsControl x:Name="CardRepeater" ItemTemplate="{StaticResource CardTemplate}">
        <ItemsControl.ItemsPanel>
          <ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
        </ItemsControl.ItemsPanel>
        <ItemsControl.Margin>16,0,16,16</ItemsControl.Margin>
      </ItemsControl>
    </ScrollViewer>
  </Grid>
</UserControl>

Launcher.ps1

# Modules/Launcher.ps1
# PowerShell 5.1 / WPF
# - apps.json を読み込み、カテゴリ/検索でフィルタしながらタイル表示
# - クリックで起動(http/https も可、runAs/args 対応)

function New-LauncherView {
@"
<Grid xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
      xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
  <Grid.RowDefinitions>
    <RowDefinition Height="Auto"/>
    <RowDefinition Height="*"/>
  </Grid.RowDefinitions>

  <!-- ヘッダー(カテゴリ / 検索 / 更新) -->
  <Border Background="#1E232C" Padding="12" Grid.Row="0">
    <Grid>
      <Grid.ColumnDefinitions>
        <ColumnDefinition Width="220"/>
        <ColumnDefinition Width="*"/>
        <ColumnDefinition Width="Auto"/>
      </Grid.ColumnDefinitions>

      <!-- ComboBox をダーク化。Popup(ドロップダウン)の背景/文字色も上書き -->
      <ComboBox x:Name="CbCategory" Height="28" Margin="0,0,8,0"
                Background="#2A303B" Foreground="White" BorderBrush="#445"
                Padding="6,2">
        <ComboBox.Resources>
          <!-- Popup 本体の背景/文字色 -->
          <SolidColorBrush x:Key="{x:Static SystemColors.WindowBrushKey}"       Color="#2A303B"/>
          <SolidColorBrush x:Key="{x:Static SystemColors.ControlBrushKey}"      Color="#2A303B"/>
          <SolidColorBrush x:Key="{x:Static SystemColors.ControlTextBrushKey}"  Color="#FFFFFF"/>
          <!-- ホバー/選択色(任意) -->
          <Style TargetType="ComboBoxItem">
            <Setter Property="Background" Value="#2A303B"/>
            <Setter Property="Foreground" Value="White"/>
            <Setter Property="Padding"    Value="8,4"/>
            <Style.Triggers>
              <Trigger Property="IsMouseOver" Value="True">
                <Setter Property="Background" Value="#334054"/>
              </Trigger>
              <Trigger Property="IsSelected" Value="True">
                <Setter Property="Background" Value="#3C8DFF"/>
              </Trigger>
            </Style.Triggers>
          </Style>
        </ComboBox.Resources>
      </ComboBox>

      <TextBox x:Name="TbSearch" Grid.Column="1" Height="28" Padding="8" Margin="0,0,8,0"
               Background="#2A303B" Foreground="White" BorderBrush="#445"
               ToolTip="アプリ名で検索"/>

      <Button  x:Name="BtnReload" Grid.Column="2" Content="更新" Height="28" Padding="12,0"
               Background="#3C8DFF" Foreground="White" BorderBrush="#2E6AD0"/>
    </Grid>
  </Border>

  <!-- タイル一覧 -->
  <ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Background="#2B2F36">
    <WrapPanel x:Name="AppPanel" Margin="12"/>
  </ScrollViewer>
</Grid>
"@ | ForEach-Object {
    $sr = New-Object System.IO.StringReader $_
    $xr = [System.Xml.XmlReader]::Create($sr)
    [Windows.Markup.XamlReader]::Load($xr)
  }
}

#================ ヘルパ ===================
function Get-LauncherCfgPath {
  # Modules/ の親フォルダにある apps.json を見る
  $root = $pwd
  Join-Path $root '\apps.json'
}
function Read-LauncherConfig {
  # 固定パスに変更
  $path = 'C:\myTool\apps.json'

  $default = @'
{
  "apps": [
    { "name": "メモ帳", "path": "notepad.exe", "icon": "", "args": "", "runAs": false, "category": "ツール" },
    { "name": "電卓",   "path": "calc.exe",    "icon": "", "args": "", "runAs": false, "category": "ツール" },
    { "name": "VS Code","path": "code.exe",    "icon": "", "args": "", "runAs": false, "category": "開発"  },
    { "name": "GitHub", "path": "https://github.com", "icon": "", "args": "", "runAs": false, "category": "Web" }
  ]
}
'@

  try {
    $cfg = if (Test-Path $path) { (Get-Content $path -Raw) | ConvertFrom-Json } else { $default | ConvertFrom-Json }

    # 互換: categories 形式をフラット化
    if ($cfg.PSObject.Properties.Name -contains 'categories') {
      $flat = @()
      foreach($cat in $cfg.categories){
        foreach($a in $cat.apps){
          $flat += [pscustomobject]@{
            name=$a.name; path=$a.path; icon=$a.icon; args=$a.args; runAs=$a.runAs; category=$cat.name
          }
        }
      }
      return [pscustomobject]@{ apps = $flat }
    }
    return $cfg
  } catch {
    $default | ConvertFrom-Json
  }
}

function New-BitmapImage([string]$path) {
  try {
    if (-not (Test-Path $path)) { return $null }
    $bi = New-Object Windows.Media.Imaging.BitmapImage
    $bi.BeginInit()
    $bi.CacheOption = [Windows.Media.Imaging.BitmapCacheOption]::OnLoad
    $bi.UriSource = New-Object System.Uri((Resolve-Path $path).Path)
    $bi.EndInit()
    $bi.Freeze()
    return $bi
  } catch { return $null }
}
function Test-AppPath($path) {
  if ([string]::IsNullOrWhiteSpace($path)) { return $false }
  if ($path -match '^(http|https)://') { return $true }
  try { $cmd = Get-Command $path -ErrorAction Stop; if ($cmd.Source) { return $true } } catch { }
  Test-Path $path
}

function New-AppTile([hashtable]$app, $hostWindow) {
  $btn  = New-Object Windows.Controls.Button
  # タイルの見た目:Styles.xaml に TileButton があれば使う。無ければデフォルト枠で組む
  $tileStyle = $hostWindow.TryFindResource('TileButton')
  if ($tileStyle) { $btn.Style = $tileStyle }
  else {
    $btn.Width=220; $btn.Height=160; $btn.Margin='8'
    $btn.BorderThickness=1; $btn.Padding=6
  }
  $btn.Content = $app.name
  $btn.Tag     = $app
  $btn.ToolTip = if ($app.path) { $app.path } else { $app.name }
  $btn.IsEnabled = (Test-AppPath $app.path)

  # テンプレートの画像差し替え(TileButton がある場合)
  $btn.Add_Loaded({
    try {
      $templ = $this.Template
      if ($templ -eq $null) { return }
      $img = $templ.FindName('TileImage', $this)
      if ($img -eq $null) { return }
      $iconPath = $null
      if ($this.Tag.PSObject.Properties.Name -contains 'icon') { $iconPath = $this.Tag.icon }
      if ($iconPath -and (Test-Path $iconPath) -and ($iconPath -match '\.(png|ico)$')) {
        $src = New-BitmapImage $iconPath
        if ($src) { $img.Source = $src }
      } else {
        # 画像が無い場合は MDL2 のフォールバック文字
        $label = New-Object Windows.Controls.TextBlock
        $label.Text = "{}"; $label.FontSize = 26
        $label.VerticalAlignment='Center'; $label.HorizontalAlignment='Left'; $label.Margin='4,0,0,0'
        $img.Parent.Children.Remove($img) | Out-Null
        $img.Parent.Children.Insert(0, $label) | Out-Null
      }
    } catch {}
  })

  # クリックで起動
  $btn.Add_Click({
    $a = [hashtable]$this.Tag
    try {
      if ($a.path -match '^(http|https)://') {
        Start-Process $a.path
      } else {
        if ($a.runAs -eq $true) {
          $psi = New-Object System.Diagnostics.ProcessStartInfo
          $psi.FileName = $a.path
          if ($a.args) { $psi.Arguments = $a.args }
          $psi.Verb = 'runas'; $psi.UseShellExecute = $true
          [System.Diagnostics.Process]::Start($psi) | Out-Null
        } else {
          if ($a.args) { Start-Process -FilePath $a.path -ArgumentList $a.args }
          else         { Start-Process -FilePath $a.path }
        }
      }
    } catch {
      [System.Windows.MessageBox]::Show("起動に失敗しました。`n$($_.Exception.Message)","起動エラー") | Out-Null
    }
  })
  $btn
}

#================ 初期化(Main.ps1 から:Show-View (New-LauncherView) 呼び出し後に実行) ==================
# ビュー生成後にコントロールを初期化する関数を公開(Main.ps1 から呼んでもらう)
function Initialize-LauncherView {
  param(
    [Parameter(Mandatory=$true)]$View,
    [Parameter(Mandatory=$true)]$HostWindow
  )
  $cbCategory = $View.FindName('CbCategory')
  $tbSearch   = $View.FindName('TbSearch')
  $btnReload  = $View.FindName('BtnReload')
  $appPanel   = $View.FindName('AppPanel')

  # 状態を保持
  $script:LauncherState = @{
    HostWindow = $HostWindow
    View       = $View
    Config     = $null
    Cb         = $cbCategory
    Tb         = $tbSearch
    Panel      = $appPanel
  }

  # ---- スクリプトスコープに scriptblock を置く(イベントから呼べるように) ----
  $script:RebuildCategories = {
    $s = $script:LauncherState
    $s.Cb.Items.Clear()
    $s.Cb.Items.Add('(すべて)') | Out-Null
    $cats = @()
    if ($s.Config -and $s.Config.apps) {
      $cats = $s.Config.apps |
        Where-Object { $_.PSObject.Properties.Name -contains 'category' -and $_.category } |
        Select-Object -ExpandProperty category -Unique | Sort-Object
    }
    foreach($c in $cats){ $s.Cb.Items.Add($c) | Out-Null }
    $s.Cb.SelectedIndex = 0
  }

  $script:RefreshApps = {
    $s = $script:LauncherState
    $s.Panel.Children.Clear()
    if (-not $s.Config) { return }

    $apps = $s.Config.apps
    if ($s.Cb.SelectedIndex -gt 0) {
      $sel = [string]$s.Cb.SelectedItem
      $apps = $apps | Where-Object { $_.category -eq $sel }
    }
    if ($s.Tb.Text) {
      $q = [Regex]::Escape($s.Tb.Text)
      $apps = $apps | Where-Object { $_.name -match $q }
    }

    foreach($a in $apps) {
      $ht = @{
        name = $a.name; path = $a.path; icon = $a.icon; args = $a.args; runAs = $a.runAs
      }
      $btn = New-AppTile $ht $s.HostWindow
      $s.Panel.Children.Add($btn) | Out-Null
    }
  }

  $script:LoadConfig = {
    $script:LauncherState.Config = Read-LauncherConfig   # C:\myTool\apps.json を読む関数
    & $script:RebuildCategories
    & $script:RefreshApps
  }

  # ---- イベント:& で scriptblock を呼び出す ----
  $cbCategory.Add_SelectionChanged({ & $script:RefreshApps })
  $tbSearch.Add_TextChanged({ & $script:RefreshApps })
  $btnReload.Add_Click({ & $script:LoadConfig })

  # 初回ロード
  & $script:LoadConfig
}

# Main.ps1 から使えるエイリアスを用意
Set-Variable -Name NewLauncherView            -Value ${function:New-LauncherView}         -Scope Global
Set-Variable -Name InitializeLauncherView     -Value ${function:Initialize-LauncherView}  -Scope Global

5. スタイルリソースの適用

  • Styles.xaml をロードし、WPFアプリのUIスタイルを統一。

Styles.xaml

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">

  <!-- パレット -->
  <SolidColorBrush x:Key="BaseBg"  Color="#1E232C"/>
  <SolidColorBrush x:Key="PanelBg" Color="#232A34"/>
  <SolidColorBrush x:Key="FieldBg" Color="#2A303B"/>
  <SolidColorBrush x:Key="Accent"  Color="#3C8DFF"/>
  <SolidColorBrush x:Key="TextFg"  Color="#FFFFFFFF"/>

  <!-- 文字色デフォルト -->
  <Style TargetType="TextBlock">
    <Setter Property="Foreground" Value="{StaticResource TextFg}"/>
  </Style>

  <!-- 画面全体の地を暗く -->
  <Style TargetType="Grid">
    <Setter Property="Background" Value="{StaticResource PanelBg}"/>
  </Style>
  <Style TargetType="Border">
    <Setter Property="Background" Value="{StaticResource PanelBg}"/>
    <Setter Property="BorderBrush" Value="#445"/>
  </Style>
  <Style TargetType="ScrollViewer">
    <Setter Property="Background" Value="{StaticResource PanelBg}"/>
  </Style>

  <!-- 入力 -->
  <Style TargetType="TextBox">
    <Setter Property="Background"  Value="{StaticResource FieldBg}"/>
    <Setter Property="Foreground"  Value="{StaticResource TextFg}"/>
    <Setter Property="BorderBrush" Value="#445"/>
    <Setter Property="Padding"     Value="6,4"/>
    <Setter Property="Margin"      Value="2"/>
  </Style>
  <Style TargetType="PasswordBox">
    <Setter Property="Background"  Value="{StaticResource FieldBg}"/>
    <Setter Property="Foreground"  Value="{StaticResource TextFg}"/>
    <Setter Property="BorderBrush" Value="#445"/>
    <Setter Property="Padding"     Value="6,4"/>
    <Setter Property="Margin"      Value="2"/>
  </Style>
  <Style TargetType="ComboBox">
    <Setter Property="Background"  Value="{StaticResource FieldBg}"/>
    <Setter Property="Foreground"  Value="{StaticResource TextFg}"/>
    <Setter Property="BorderBrush" Value="#445"/>
    <Setter Property="Padding"     Value="6,2"/>
    <Setter Property="Margin"      Value="2"/>
  </Style>

  <!-- ボタン -->
  <Style TargetType="Button">
    <Setter Property="Background"  Value="{StaticResource Accent}"/>
    <Setter Property="Foreground"  Value="{StaticResource TextFg}"/>
    <Setter Property="BorderBrush" Value="#2E6AD0"/>
    <Setter Property="Padding"     Value="12,6"/>
    <Setter Property="Margin"      Value="4"/>
    <Setter Property="Cursor"      Value="Hand"/>
    <Style.Triggers>
      <Trigger Property="IsMouseOver" Value="True">
        <Setter Property="Background" Value="#559DFF"/>
      </Trigger>
      <Trigger Property="IsEnabled" Value="False">
        <Setter Property="Background" Value="#394456"/>
        <Setter Property="Foreground" Value="#90FFFFFF"/>
        <Setter Property="BorderBrush" Value="#3A3F48"/>
      </Trigger>
    </Style.Triggers>
  </Style>

  <!-- セカンダリボタン -->
  <Style x:Key="SubtleButton" TargetType="Button">
    <Setter Property="Background"  Value="{StaticResource FieldBg}"/>
    <Setter Property="Foreground"  Value="{StaticResource TextFg}"/>
    <Setter Property="BorderBrush" Value="#445"/>
    <Setter Property="Padding"     Value="12,6"/>
    <Setter Property="Margin"      Value="4"/>
    <Setter Property="Cursor"      Value="Hand"/>
    <Style.Triggers>
      <Trigger Property="IsMouseOver" Value="True">
        <Setter Property="Background" Value="#334054"/>
      </Trigger>
    </Style.Triggers>
  </Style>

  <!-- TabControl/TabItem:テンプレートで完全ダーク化 -->
  <Style TargetType="TabControl">
    <Setter Property="Background" Value="{StaticResource PanelBg}"/>
    <Setter Property="BorderBrush" Value="#445"/>
    <Setter Property="Template">
      <Setter.Value>
        <ControlTemplate TargetType="TabControl">
          <DockPanel>
            <TabPanel x:Name="HeaderPanel"
                      Background="{StaticResource PanelBg}"
                      IsItemsHost="True"
                      Margin="0,0,0,6"
                      DockPanel.Dock="Top"/>
            <Border Background="{StaticResource PanelBg}" BorderBrush="#445" BorderThickness="1" CornerRadius="8">
              <ContentPresenter x:Name="PART_SelectedContentHost" Margin="8"/>
            </Border>
          </DockPanel>
        </ControlTemplate>
      </Setter.Value>
    </Setter>
  </Style>

  <Style TargetType="TabItem">
    <Setter Property="Foreground" Value="White"/>
    <Setter Property="Template">
      <Setter.Value>
        <ControlTemplate TargetType="TabItem">
          <Border x:Name="Bd" Background="#2A303B" BorderBrush="#445" CornerRadius="6" Padding="10,6" Margin="2,2,2,0">
            <ContentPresenter ContentSource="Header" RecognizesAccessKey="True"/>
          </Border>
          <ControlTemplate.Triggers>
            <Trigger Property="IsSelected" Value="True">
              <Setter TargetName="Bd" Property="Background" Value="#1E232C"/>
              <Setter TargetName="Bd" Property="BorderBrush" Value="#3C8DFF"/>
            </Trigger>
            <Trigger Property="IsMouseOver" Value="True">
              <Setter TargetName="Bd" Property="Background" Value="#334054"/>
            </Trigger>
            <Trigger Property="IsEnabled" Value="False">
              <Setter Property="Foreground" Value="#77FFFFFF"/>
            </Trigger>
          </ControlTemplate.Triggers>
        </ControlTemplate>
      </Setter.Value>
    </Setter>
  </Style>

  <!-- DataGrid -->
  <Style TargetType="DataGrid">
    <Setter Property="Background" Value="{StaticResource PanelBg}"/>
    <Setter Property="Foreground" Value="{StaticResource TextFg}"/>
    <Setter Property="GridLinesVisibility" Value="None"/>
    <Setter Property="BorderBrush" Value="#445"/>
    <Setter Property="HorizontalGridLinesBrush" Value="#3A3F48"/>
    <Setter Property="VerticalGridLinesBrush"   Value="#3A3F48"/>
  </Style>
  <Style TargetType="DataGridColumnHeader">
    <Setter Property="Background" Value="#1E232C"/>
    <Setter Property="Foreground" Value="#E0E0E0"/>
    <Setter Property="BorderBrush" Value="#445"/>
  </Style>
</ResourceDictionary>

MainWindows.xaml

<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="RedDash" Height="600" Width="1000" WindowStartupLocation="CenterScreen">
  <DockPanel>
    <!-- 左メニュー -->
    <StackPanel DockPanel.Dock="Left" Background="#222" Width="180">
      <Button Content="ダッシュボード" x:Name="BtnDashboard" />
      <Button Content="Launcher"       x:Name="BtnLauncher" />
      <Button Content="Redmine"        x:Name="BtnRedmine" />
    </StackPanel>

    <!-- コンテンツ領域 -->
    <Grid x:Name="ContentArea" Background="#333">
      <TextBlock Text="ようこそ RedDash!"
                 FontSize="24"
                 VerticalAlignment="Center"
                 HorizontalAlignment="Center"/>
    </Grid>
  </DockPanel>
</Window>

まとめ

この Main.ps1 は、単なる起動スクリプトではなく 「WPFアプリをPowerShellで安全に起動するためのブートストラップ」 として設計されています。
特にSTAモードの保証とログ出力機能は、実運用において非常に重要なポイントです。

スポンサーリンク

-IT関連
-,