本記事では、PowerShellで構築された Dashboardアプリのエントリーポイントスクリプト Main.ps1 を解説します。
このスクリプトは、アプリ全体を正しく起動させるための「玄関口」として機能します。
これを使って、前回作成した下記のツールをダッシュボードで可視化してみます。
ブラウザ上のリンク先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.ps1やDashboard.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モードの保証とログ出力機能は、実運用において非常に重要なポイントです。
