Core/PortManager.ps1

# Core\PortManager.ps1
# Centralized port allocation and process tracking for Posh apps

$script:PortConfig = @{
    RangeStart = 49152
    RangeEnd   = 49251
    Registry   = @{}   # [int]Port -> @{ App; PID; Started }
}

# Load persisted registry on module import
$script:_portRegistryPath = Join-Path $script:PoshDE.AppDataPath "port-registry.json"
if (Test-Path $script:_portRegistryPath) {
    try {
        $saved = Get-Content $script:_portRegistryPath -Raw | ConvertFrom-Json
        foreach ($prop in $saved.PSObject.Properties) {
            $script:PortConfig.Registry[[int]$prop.Name] = @{
                App     = $prop.Value.App
                PID     = $prop.Value.PID
                Started = $prop.Value.Started
            }
        }
    } catch { }
}

# ── Internal helpers ─────────────────────────────────────────────────────────

function Save-PortRegistry {
    $toSave = @{}
    foreach ($key in $script:PortConfig.Registry.Keys) {
        $toSave[[string]$key] = $script:PortConfig.Registry[$key]
    }
    $toSave | ConvertTo-Json -Depth 3 | Set-Content $script:_portRegistryPath -Encoding UTF8
}

function Test-PortInUse {
    param([int]$Port)
    return ($null -ne (Get-NetTCPConnection -LocalPort $Port -ErrorAction SilentlyContinue))
}

function Get-ProcessByPort {
    param([int]$Port)
    $conn = Get-NetTCPConnection -LocalPort $Port -ErrorAction SilentlyContinue | Select-Object -First 1
    if ($conn) { return Get-Process -Id $conn.OwningProcess -ErrorAction SilentlyContinue }
    return $null
}

# ── Public functions ─────────────────────────────────────────────────────────

function Register-PoshPort {
    <#
    .SYNOPSIS
        Allocate a port from the PoshDE pool for a Posh application.
    .DESCRIPTION
        Returns a port number from the ephemeral range (49152-49251).
        If the app already has a registered port and the process is still alive,
        that port is returned. Otherwise a new one is allocated.
    .PARAMETER App
        Application name (e.g. "PoshConsole").
    .EXAMPLE
        $port = Register-PoshPort -App "PoshConsole"
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$App
    )

    # Reuse existing registration if process is alive
    foreach ($port in $script:PortConfig.Registry.Keys) {
        $entry = $script:PortConfig.Registry[$port]
        if ($entry.App -eq $App) {
            if ($entry.PID -gt 0) {
                $proc = Get-Process -Id $entry.PID -ErrorAction SilentlyContinue
                if ($proc -and -not $proc.HasExited) {
                    Write-Warning "$App already has port $port (PID $($entry.PID))"
                    return $port
                }
            }
            # Dead process — reclaim the slot
            $script:PortConfig.Registry[$port].PID     = 0
            $script:PortConfig.Registry[$port].Started = (Get-Date).ToString('o')
            Save-PortRegistry
            Write-Verbose "Reclaimed port $port for $App"
            return $port
        }
    }

    # Find a free port
    for ($port = $script:PortConfig.RangeStart; $port -le $script:PortConfig.RangeEnd; $port++) {
        if ($script:PortConfig.Registry.ContainsKey($port)) { continue }
        if (Test-PortInUse -Port $port)                      { continue }

        $script:PortConfig.Registry[$port] = @{
            App     = $App
            PID     = 0
            Started = (Get-Date).ToString('o')
        }
        Save-PortRegistry
        Write-Verbose "Assigned port $port to $App"
        return $port
    }

    Write-Error "No available ports in range $($script:PortConfig.RangeStart)-$($script:PortConfig.RangeEnd)"
    return $null
}

function Update-PoshPortPID {
    <#
    .SYNOPSIS
        Associate a process ID with a registered port.
    .DESCRIPTION
        Call this after spawning the WebView2 subprocess to record its PID.
    .EXAMPLE
        Update-PoshPortPID -Port 49152 -PID $proc.Id
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][int]$Port,
        [Parameter(Mandatory)][int]$PID
    )

    if ($script:PortConfig.Registry.ContainsKey($Port)) {
        $script:PortConfig.Registry[$Port].PID = $PID
        Save-PortRegistry
        Write-Verbose "Registered PID $PID on port $Port"
    } else {
        Write-Warning "Port $Port is not in the registry"
    }
}

function Unregister-PoshPort {
    <#
    .SYNOPSIS
        Release a port and optionally kill its associated process.
    .PARAMETER Port
        Port number to release.
    .PARAMETER App
        App name to release (alternative to -Port).
    .PARAMETER Kill
        Kill the associated process before releasing.
    .EXAMPLE
        Unregister-PoshPort -App "PoshConsole" -Kill
    #>

    [CmdletBinding()]
    param(
        [Parameter(ParameterSetName = 'ByPort')][int]$Port,
        [Parameter(ParameterSetName = 'ByApp')][string]$App,
        [switch]$Kill
    )

    if ($App) {
        $Port = 0
        foreach ($p in $script:PortConfig.Registry.Keys) {
            if ($script:PortConfig.Registry[$p].App -eq $App) { $Port = $p; break }
        }
        if ($Port -eq 0) {
            Write-Warning "App '$App' not found in port registry"
            return
        }
    }

    if (-not $script:PortConfig.Registry.ContainsKey($Port)) {
        Write-Warning "Port $Port is not registered"
        return
    }

    $entry   = $script:PortConfig.Registry[$Port]
    $appName = $entry.App
    $pid     = $entry.PID

    if ($Kill -and $pid -gt 0) {
        $proc = Get-Process -Id $pid -ErrorAction SilentlyContinue
        if ($proc -and -not $proc.HasExited) {
            $proc.Kill()
            $proc.WaitForExit(3000)
        }
        # Also kill anything else that grabbed the port
        $portProc = Get-ProcessByPort -Port $Port
        if ($portProc -and $portProc.Id -ne $pid) {
            $portProc.Kill()
            $portProc.WaitForExit(3000)
        }
    }

    $script:PortConfig.Registry.Remove($Port)
    Save-PortRegistry

    Write-Host " Released port $Port ($appName)" -ForegroundColor Yellow
}

function Get-PoshPortRegistry {
    <#
    .SYNOPSIS
        Show all registered ports with live process status.
    .EXAMPLE
        Get-PoshPortRegistry
    #>

    [CmdletBinding()]
    param()

    if ($script:PortConfig.Registry.Count -eq 0) {
        Write-Host " No ports registered." -ForegroundColor DarkGray
        return
    }

    foreach ($port in ($script:PortConfig.Registry.Keys | Sort-Object)) {
        $entry = $script:PortConfig.Registry[$port]

        $status      = 'Unknown'
        $statusColor = 'DarkGray'

        if ($entry.PID -eq 0) {
            $status      = 'Starting'
            $statusColor = 'Yellow'
        } else {
            $proc = Get-Process -Id $entry.PID -ErrorAction SilentlyContinue
            if ($proc -and -not $proc.HasExited) {
                $status      = 'Running'
                $statusColor = 'Green'
            } elseif (Test-PortInUse -Port $port) {
                $status      = 'Orphaned'
                $statusColor = 'Red'
            } else {
                $status      = 'Dead'
                $statusColor = 'Red'
            }
        }

        $started = try { ([datetime]$entry.Started).ToString('h:mm:ss tt') } catch { 'Unknown' }

        [PSCustomObject]@{
            Port    = $port
            App     = $entry.App
            PID     = if ($entry.PID -gt 0) { $entry.PID } else { '-' }
            Status  = $status
            Started = $started
        }
    }
}

function Clear-PoshOrphanedPorts {
    <#
    .SYNOPSIS
        Remove registry entries whose processes are no longer running.
    .PARAMETER Force
        Also kill any unregistered process found using a port in the PoshDE range.
    .EXAMPLE
        Clear-PoshOrphanedPorts
    #>

    [CmdletBinding()]
    param([switch]$Force)

    Write-Host ""
    Write-Host " Cleaning orphaned ports..." -ForegroundColor Cyan
    Write-Host ""

    $toRemove = @()
    $killed   = 0

    foreach ($port in @($script:PortConfig.Registry.Keys)) {
        $entry = $script:PortConfig.Registry[$port]
        if ($entry.PID -gt 0) {
            $proc = Get-Process -Id $entry.PID -ErrorAction SilentlyContinue
            if (-not $proc -or $proc.HasExited) {
                Write-Host " [!] Port $port ($($entry.App)) — PID $($entry.PID) is dead" -ForegroundColor Yellow
                $toRemove += $port
                $portProc  = Get-ProcessByPort -Port $port
                if ($portProc) {
                    Write-Host " Killing orphan $($portProc.Id) ($($portProc.ProcessName))" -ForegroundColor Red
                    $portProc.Kill(); $portProc.WaitForExit(3000)
                    $killed++
                }
            }
        }
    }

    foreach ($port in $toRemove) { $script:PortConfig.Registry.Remove($port) }

    if ($Force) {
        for ($port = $script:PortConfig.RangeStart; $port -le $script:PortConfig.RangeEnd; $port++) {
            if (-not $script:PortConfig.Registry.ContainsKey($port)) {
                $proc = Get-ProcessByPort -Port $port
                if ($proc) {
                    Write-Host " [!] Port $port — unregistered process $($proc.Id) ($($proc.ProcessName))" -ForegroundColor Yellow
                    $proc.Kill(); $proc.WaitForExit(3000)
                    $killed++
                }
            }
        }
    }

    if ($toRemove.Count -gt 0 -or $killed -gt 0) {
        Save-PortRegistry
        Write-Host ""
        Write-Host " Removed: $($toRemove.Count) entries Killed: $killed processes" -ForegroundColor Green
    } else {
        Write-Host " No orphaned ports found." -ForegroundColor Green
    }
    Write-Host ""
}

function Stop-PoshApp {
    <#
    .SYNOPSIS
        Stop a named Posh application and release its port.
    .EXAMPLE
        Stop-PoshApp -Name PoshConsole
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Name
    )
    Unregister-PoshPort -App $Name -Kill
}

function Stop-AllPoshApps {
    <#
    .SYNOPSIS
        Stop every running Posh application.
    #>

    [CmdletBinding()]
    param()

    Write-Host ""
    Write-Host " Stopping all Posh apps..." -ForegroundColor Cyan

    foreach ($port in @($script:PortConfig.Registry.Keys)) {
        Unregister-PoshPort -Port $port -Kill
    }

    Write-Host " Done." -ForegroundColor Green
    Write-Host ""
}

# ── Startup orphan notification ──────────────────────────────────────────────
$_orphans = 0
foreach ($port in @($script:PortConfig.Registry.Keys)) {
    $e = $script:PortConfig.Registry[$port]
    if ($e.PID -gt 0) {
        $p = Get-Process -Id $e.PID -ErrorAction SilentlyContinue
        if (-not $p -or $p.HasExited) { $_orphans++ }
    }
}
if ($_orphans -gt 0) {
    Write-Host " [!] $($_orphans) orphaned port(s) detected — run " -NoNewline -ForegroundColor Yellow
    Write-Host "Clear-PoshOrphanedPorts" -ForegroundColor Cyan
}

Write-Verbose "PortManager loaded"