Public/Tray.ps1
|
# --------------------------------------------------------------------------- # System Tray - STA runspace tray icon with context menu and notifications # --------------------------------------------------------------------------- function Start-TTTray { <# .SYNOPSIS Start the TerminalTracker system tray icon. .DESCRIPTION Launches a WinForms NotifyIcon in a dedicated STA runspace that provides quick access to session management actions, recent CWD quick-launch, and balloon notifications for session events. The tray wraps the existing background monitor job and stops it on exit. .EXAMPLE Start-TTTray Starts the system tray icon with context menu and notifications. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '')] [CmdletBinding()] param() # Singleton guard - prevent duplicate tray icons if ($null -ne $script:TrayHandle -and -not $script:TrayHandle.IsCompleted) { Write-Warning 'TerminalTracker tray is already running.' return } $mutex = $null try { $mutex = [System.Threading.Mutex]::new($false, 'Global\TerminalTracker-Tray') } catch { Write-Warning "TerminalTracker: could not create tray mutex: $_" return } $acquired = $false try { $acquired = $mutex.WaitOne(0) } catch [System.Threading.AbandonedMutexException] { $acquired = $true } if (-not $acquired) { Write-Warning 'TerminalTracker tray is already running.' $mutex.Dispose() return } # Load WinForms assemblies try { Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop Add-Type -AssemblyName System.Drawing -ErrorAction Stop } catch { Write-Warning 'TerminalTracker: System.Windows.Forms could not be loaded. Tray is unavailable. Install PS7 via MSI or ensure .NET Desktop Runtime is present.' try { $mutex.ReleaseMutex() } catch { } $mutex.Dispose() return } # Start monitor job if not already running $existingJob = Get-Job -Name 'TerminalTracker-Monitor' -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Running' } if (-not $existingJob) { Start-TTMonitor -AsJob } # Synchronized hashtable for cross-runspace communication $script:TraySync = [Hashtable]::Synchronized(@{ Stop = $false Notification = $null PreviousSessionIds = @() PreviousSessions = @() }) # STA runspace creation $runspace = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace() $runspace.ApartmentState = 'STA' $runspace.ThreadOptions = 'ReuseThread' $runspace.Open() $runspace.SessionStateProxy.SetVariable('sync', $script:TraySync) $runspace.SessionStateProxy.SetVariable('modulePath', (Split-Path $PSScriptRoot -Parent)) $ps = [System.Management.Automation.PowerShell]::Create() $ps.Runspace = $runspace $ps.AddScript({ param($sync, $modulePath) Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing [System.Windows.Forms.Application]::EnableVisualStyles() Import-Module (Join-Path $modulePath 'TerminalTracker.psd1') -Force -DisableNameChecking # Hidden form as message pump host $form = [System.Windows.Forms.Form]::new() $form.ShowInTaskbar = $false $form.WindowState = 'Minimized' $form.Opacity = 0 # Tray icon $trayIcon = [System.Windows.Forms.NotifyIcon]::new() $trayIcon.Icon = [System.Drawing.SystemIcons]::Application $trayIcon.Visible = $true $trayIcon.Text = 'TerminalTracker - monitor running' # --- Context menu --- $menu = [System.Windows.Forms.ContextMenuStrip]::new() # Sessions submenu $sessionsMenu = [System.Windows.Forms.ToolStripMenuItem]::new('Sessions') $suspendAll = [System.Windows.Forms.ToolStripMenuItem]::new('Suspend All') $suspendAll.add_Click({ try { Suspend-TTSession -All -Force } catch { } }) $resumeAll = [System.Windows.Forms.ToolStripMenuItem]::new('Resume All') $resumeAll.add_Click({ try { Resume-TTSession -All } catch { } }) $scanNow = [System.Windows.Forms.ToolStripMenuItem]::new('Scan Now') $scanNow.add_Click({ try { Find-TTTerminal } catch { } }) $sessionsMenu.DropDownItems.Add($suspendAll) | Out-Null $sessionsMenu.DropDownItems.Add($resumeAll) | Out-Null $sessionsMenu.DropDownItems.Add($scanNow) | Out-Null # Quick Launch submenu $quickLaunch = [System.Windows.Forms.ToolStripMenuItem]::new('Quick Launch') # Populate Quick Launch on menu opening $menu.add_Opening({ $quickLaunch.DropDownItems.Clear() $cwds = @(& (Get-Module TerminalTracker) { Get-TTRecentCwds -Count 10 }) if ($cwds.Count -eq 0) { $empty = [System.Windows.Forms.ToolStripMenuItem]::new('(no recent directories)') $empty.Enabled = $false $quickLaunch.DropDownItems.Add($empty) | Out-Null } else { foreach ($cwd in $cwds) { # Path truncation: if > 50 chars, show "{drive}:\...{last two segments}" $displayPath = $cwd if ($cwd.Length -gt 50) { $parts = $cwd -split '[/\\]' if ($parts.Count -ge 3) { $displayPath = $parts[0] + '\...\' + $parts[-2] + '\' + $parts[-1] } } $pinnedCwd = $cwd $item = [System.Windows.Forms.ToolStripMenuItem]::new($displayPath) $item.Tag = $pinnedCwd $item.add_Click({ $dir = $this.Tag try { Open-TerminalAt -Directory $dir } catch { } }) $quickLaunch.DropDownItems.Add($item) | Out-Null } } }) $menu.Items.Add($sessionsMenu) | Out-Null $menu.Items.Add($quickLaunch) | Out-Null $menu.Items.Add([System.Windows.Forms.ToolStripSeparator]::new()) | Out-Null # Open Dashboard $dashboard = [System.Windows.Forms.ToolStripMenuItem]::new('Open Dashboard') $dashboard.add_Click({ $escapedPath = $modulePath -replace "'", "''" $cmd = "Import-Module '$escapedPath\TerminalTracker.psd1' -Force -DisableNameChecking; Show-TTDashboard -Interactive" $bytes = [System.Text.Encoding]::Unicode.GetBytes($cmd) $encoded = [Convert]::ToBase64String($bytes) try { Start-Process pwsh -ArgumentList '-NoProfile', '-NoExit', '-EncodedCommand', $encoded } catch { try { Start-Process powershell -ArgumentList '-NoProfile', '-NoExit', '-EncodedCommand', $encoded } catch { } } }) $menu.Items.Add($dashboard) | Out-Null $menu.Items.Add([System.Windows.Forms.ToolStripSeparator]::new()) | Out-Null # Exit $exitItem = [System.Windows.Forms.ToolStripMenuItem]::new('Exit') $exitItem.add_Click({ $sync.Stop = $true }) $menu.Items.Add($exitItem) | Out-Null $trayIcon.ContextMenuStrip = $menu # Double-click opens interactive dashboard $trayIcon.add_DoubleClick({ $escapedPath = $modulePath -replace "'", "''" $cmd = "Import-Module '$escapedPath\TerminalTracker.psd1' -Force -DisableNameChecking; Show-TTDashboard -Interactive" $bytes = [System.Text.Encoding]::Unicode.GetBytes($cmd) $encoded = [Convert]::ToBase64String($bytes) try { Start-Process pwsh -ArgumentList '-NoProfile', '-NoExit', '-EncodedCommand', $encoded } catch { try { Start-Process powershell -ArgumentList '-NoProfile', '-NoExit', '-EncodedCommand', $encoded } catch { } } }) # Initialize previous session state for notification detection try { $initialSessions = @(ConvertTo-FlatArray (Get-TTSession)) $sync.PreviousSessionIds = @($initialSessions | ForEach-Object { $_.Id }) $sync.PreviousSessions = @($initialSessions) } catch { } # Timer for polling stop signal, updating tooltip, and notifications $timer = [System.Windows.Forms.Timer]::new() $timer.Interval = 1000 $timer.add_Tick({ # Stop check if ($sync.Stop) { $timer.Stop() $trayIcon.Visible = $false $trayIcon.Dispose() $form.Close() return } # Update tooltip with active session count try { $currentSessions = @(ConvertTo-FlatArray (Get-TTSession)) $count = $currentSessions.Count $trayIcon.Text = "TerminalTracker - $count active session(s)" # Notification detection $currentIds = @($currentSessions | ForEach-Object { $_.Id }) $previousIds = @($sync.PreviousSessionIds) # Get notification config with fallback for existing installs $notifConfig = $null try { $cfg = Get-TTConfig $notifConfig = $cfg.Notifications } catch { } # New terminals foreach ($id in $currentIds) { if ($id -notin $previousIds) { $shouldNotify = if ($null -ne $notifConfig -and $null -ne $notifConfig.NewTerminal) { $notifConfig.NewTerminal } else { $true } if ($shouldNotify) { $sess = $currentSessions | Where-Object { $_.Id -eq $id } | Select-Object -First 1 if ($sess) { $shell = if ($sess.Shell) { $sess.Shell } else { $sess.ProcessName } $cwd = if ($sess.WorkingDirectory) { $sess.WorkingDirectory } else { '(unknown)' } $trayIcon.ShowBalloonTip(3000, 'New Terminal', "$shell opened at $cwd", [System.Windows.Forms.ToolTipIcon]::Info) } } } } # Dead sessions $previousSessions = @($sync.PreviousSessions) foreach ($id in $previousIds) { if ($id -notin $currentIds) { $shouldNotify = if ($null -ne $notifConfig -and $null -ne $notifConfig.SessionEnded) { $notifConfig.SessionEnded } else { $true } if ($shouldNotify) { $prevSess = $previousSessions | Where-Object { $_.Id -eq $id } | Select-Object -First 1 if ($prevSess) { $shell = if ($prevSess.Shell) { $prevSess.Shell } else { $prevSess.ProcessName } $cwd = if ($prevSess.WorkingDirectory) { $prevSess.WorkingDirectory } else { '(unknown)' } $trayIcon.ShowBalloonTip(3000, 'Session Ended', "$shell at $cwd has closed", [System.Windows.Forms.ToolTipIcon]::Info) } } } } # Update previous state $sync.PreviousSessionIds = $currentIds $sync.PreviousSessions = @($currentSessions) } catch { } # Queued notifications from sync hashtable if ($null -ne $sync.Notification) { try { $n = $sync.Notification $trayIcon.ShowBalloonTip(3000, $n.Title, $n.Text, [System.Windows.Forms.ToolTipIcon]::Info) } catch { } $sync.Notification = $null } }) $timer.Start() # FormClosing cleanup $form.add_FormClosing({ $trayIcon.Visible = $false $trayIcon.Dispose() }) [System.Windows.Forms.Application]::Run($form) }).AddArgument($script:TraySync).AddArgument((Split-Path $PSScriptRoot -Parent)) | Out-Null # Store references in script scope $script:TrayRunspace = $runspace $script:TrayPowerShell = $ps $script:TrayMutex = $mutex $script:TrayHandle = $ps.BeginInvoke() Write-Verbose 'TerminalTracker tray started.' } function Stop-TTTray { <# .SYNOPSIS Stop the TerminalTracker system tray icon. .DESCRIPTION Removes the tray icon, cleans up the STA runspace, releases the singleton mutex, and stops the background monitor job. .EXAMPLE Stop-TTTray Stops the tray icon and cleans up all resources. #> [CmdletBinding()] param() if ($null -eq $script:TraySync) { Write-Warning 'No tray instance is running in this session.' return } # Signal the tray runspace to stop $script:TraySync.Stop = $true # Wait up to 5 seconds for the runspace to finish $deadline = [datetime]::UtcNow.AddSeconds(5) while (-not $script:TrayHandle.IsCompleted -and [datetime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 100 } # Clean up PowerShell instance try { $script:TrayPowerShell.EndInvoke($script:TrayHandle) } catch { Write-Verbose "Tray EndInvoke error: $_" } try { $script:TrayPowerShell.Dispose() } catch { Write-Verbose "Tray PS dispose error: $_" } # Clean up runspace try { $script:TrayRunspace.Close() $script:TrayRunspace.Dispose() } catch { Write-Verbose "Tray runspace dispose error: $_" } # Release and dispose mutex try { $script:TrayMutex.ReleaseMutex() } catch { Write-Verbose "Tray mutex release error: $_" } try { $script:TrayMutex.Dispose() } catch { Write-Verbose "Tray mutex dispose error: $_" } # Stop the monitor job Stop-TTMonitor # Null out script-scoped references $script:TrayRunspace = $null $script:TrayPowerShell = $null $script:TrayMutex = $null $script:TrayHandle = $null $script:TraySync = $null Write-Verbose 'TerminalTracker tray stopped.' } function Get-TTTrayStatus { <# .SYNOPSIS Get the current status of the TerminalTracker tray icon. .DESCRIPTION Returns an object indicating whether the tray icon is running and providing access to the synchronized state hashtable. .EXAMPLE Get-TTTrayStatus Returns the tray running status. #> [CmdletBinding()] param() [PSCustomObject]@{ Running = ($null -ne $script:TrayHandle -and -not $script:TrayHandle.IsCompleted) Sync = $script:TraySync } } |