本記事では、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モードの保証とログ出力機能は、実運用において非常に重要なポイントです。