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
    }
}