PoshConsole.psm1
|
# PoshConsole - PowerShell Terminal # Part of the PoshDE ecosystem # https://github.com/Jakoby/PoshConsole $script:ModuleRoot = $PSScriptRoot $script:Version = '1.0.0' $script:ActivePort = $null $script:WindowProcess = $null $script:ServerRunspace = $null $script:ServerPowerShell = $null $script:ServerAsyncResult = $null $script:SharedState = $null # ============================================================================ # Status # ============================================================================ function Get-PoshConsoleStatus { <# .SYNOPSIS Get the current status of PoshConsole. #> [CmdletBinding()] param() $running = $false if ($script:SharedState -and $script:SharedState.Ready -and $script:SharedState.Running) { $running = $true } [PSCustomObject]@{ Running = $running Port = $script:ActivePort WindowPID = if ($script:WindowProcess -and -not $script:WindowProcess.HasExited) { $script:WindowProcess.Id } else { $null } Version = $script:Version WorkingDir = if ($script:SharedState) { $script:SharedState.WorkingDirectory } else { $null } } } # ============================================================================ # Server script — runs in a background runspace # This keeps Start-PoshConsole non-blocking so the PS7 session stays usable # ============================================================================ $script:ServerScript = { param( [int] $Port, [string] $PublicPath, [string] $PoshDEPath, [hashtable] $SharedState, [string] $Version, [string] $BannersPath, [string] $CustomBannerDir ) # Import PoshDE into this runspace Import-Module $PoshDEPath -ErrorAction Stop # ── Persistent execution runspace ──────────────────────────────────────── # A single shared runspace for all user commands. # This means cd, $variables, imported modules etc. all persist between commands. # CreateDefault2() loads full type/format data so Out-String works for all types. $iss = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault2() $execRunspace = [runspacefactory]::CreateRunspace($iss) $execRunspace.Open() # Set initial working directory AND inherit PSModulePath from parent session $initPs = [PowerShell]::Create() $initPs.Runspace = $execRunspace $initPs.AddScript({ param($WorkingDir, $ModulePath) Set-Location -LiteralPath $WorkingDir $env:PSModulePath = $ModulePath }) | Out-Null $initPs.AddArgument($SharedState.InitialWorkingDir) | Out-Null $initPs.AddArgument($env:PSModulePath) | Out-Null $initPs.Invoke() | Out-Null $initPs.Dispose() $commandHistory = [System.Collections.Generic.List[string]]::new() $contentTypes = @{ '.html' = 'text/html; charset=utf-8' '.css' = 'text/css' '.js' = 'application/javascript' '.json' = 'application/json' '.png' = 'image/png' '.svg' = 'image/svg+xml' '.ico' = 'image/x-icon' '.woff2'= 'font/woff2' } # ── Response helpers ───────────────────────────────────────────────────── function Send-Json { param($Ctx, $Data, [int]$Code = 200) $json = $Data | ConvertTo-Json -Depth 10 -Compress $buffer = [System.Text.Encoding]::UTF8.GetBytes($json) $Ctx.Response.StatusCode = $Code $Ctx.Response.ContentType = 'application/json; charset=utf-8' $Ctx.Response.ContentLength64 = $buffer.Length $Ctx.Response.OutputStream.Write($buffer, 0, $buffer.Length) $Ctx.Response.Close() } function Read-JsonBody { param($Request) $reader = New-Object System.IO.StreamReader($Request.InputStream) $body = $reader.ReadToEnd() $reader.Close() return $body | ConvertFrom-Json } # ── Banner helpers ─────────────────────────────────────────────────────── function Get-BannerList { $banners = [System.Collections.Generic.List[object]]::new() # Built-in banners if (Test-Path $BannersPath) { foreach ($f in Get-ChildItem $BannersPath -Filter '*.banner' | Sort-Object Name) { try { $data = Get-Content $f.FullName -Raw | ConvertFrom-Json $banners.Add(@{ name = $data.name label = $data.label description = $data.description source = 'builtin' }) } catch { } } } # Custom banners if ($CustomBannerDir -and (Test-Path $CustomBannerDir)) { foreach ($f in Get-ChildItem $CustomBannerDir -Filter '*.banner' | Sort-Object Name) { try { $data = Get-Content $f.FullName -Raw | ConvertFrom-Json # Custom banner with same name overrides built-in in the list $existing = $banners | Where-Object { $_.name -eq $data.name } if ($existing) { $banners.Remove($existing) | Out-Null } $banners.Add(@{ name = $data.name label = $data.label description = $data.description source = 'custom' }) } catch { } } } return @($banners) } function Get-BannerContent { param([string]$Name) # Custom dir takes precedence over built-in $searchDirs = @() if ($CustomBannerDir -and (Test-Path $CustomBannerDir)) { $searchDirs += $CustomBannerDir } if (Test-Path $BannersPath) { $searchDirs += $BannersPath } foreach ($dir in $searchDirs) { $file = Join-Path $dir "$Name.banner" if (Test-Path $file) { try { return Get-Content $file -Raw | ConvertFrom-Json } catch { } } } return $null } function Get-WorkingDir { $ps = [PowerShell]::Create() $ps.Runspace = $execRunspace $ps.AddScript('$PWD.Path') | Out-Null $result = $ps.Invoke() $ps.Dispose() return $(if ($result -and $result[0]) { $result[0].ToString() } else { $SharedState.WorkingDirectory }) } # ── Start listener ─────────────────────────────────────────────────────── $listener = New-Object System.Net.HttpListener $listener.Prefixes.Add("http://localhost:$Port/") $listener.Prefixes.Add("http://127.0.0.1:$Port/") $listener.Start() $SharedState.Ready = $true try { while ($SharedState.Running) { # GetContextAsync + Wait(500ms) lets us check the Running flag # without blocking forever on an idle server $task = $listener.GetContextAsync() while (-not $task.Wait(500)) { if (-not $SharedState.Running) { break } } if (-not $SharedState.Running) { break } if ($task.IsFaulted -or $task.IsCanceled) { continue } $ctx = $task.Result $req = $ctx.Request $urlPath = $req.Url.LocalPath # CORS headers on every response $ctx.Response.Headers.Add('Access-Control-Allow-Origin', '*') $ctx.Response.Headers.Add('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') $ctx.Response.Headers.Add('Access-Control-Allow-Headers', 'Content-Type') try { # OPTIONS preflight if ($req.HttpMethod -eq 'OPTIONS') { $ctx.Response.StatusCode = 200 $ctx.Response.Close() continue } # ── /api/theme ──────────────────────────────────────────── if ($urlPath -eq '/api/theme' -and $req.HttpMethod -eq 'GET') { $theme = Get-PoshTheme Send-Json $ctx @{ name = $theme.name css = Export-PoshThemeCSS colors = $theme.colors } } # ── /api/banners (list) ────────────────────────────────── elseif ($urlPath -eq '/api/banners' -and $req.HttpMethod -eq 'GET') { Send-Json $ctx @{ banners = @(Get-BannerList) } } # ── /api/banner/{name} (content) ────────────────────────── elseif ($urlPath -match '^/api/banner/(.+)$' -and $req.HttpMethod -eq 'GET') { $bannerName = $Matches[1] $banner = Get-BannerContent -Name $bannerName if ($banner) { Send-Json $ctx @{ name = $banner.name html = $banner.html css = if ($banner.css) { $banner.css } else { '' } js = if ($banner.js) { $banner.js } else { '' } } } else { $buf = [System.Text.Encoding]::UTF8.GetBytes("{`"error`":`"Banner not found`"}") $ctx.Response.StatusCode = 404 $ctx.Response.ContentType = 'application/json' $ctx.Response.ContentLength64 = $buf.Length $ctx.Response.OutputStream.Write($buf, 0, $buf.Length) $ctx.Response.Close() } } # ── /api/execute ────────────────────────────────────────── elseif ($urlPath -eq '/api/execute' -and $req.HttpMethod -eq 'POST') { $body = Read-JsonBody $req $command = $body.command if ($command -and $command.Trim()) { $commandHistory.Add($command) } $result = @{ output = '' error = '' richOutput = $null workingDir = $SharedState.WorkingDirectory exitCode = 0 } $ps = $null $ps2 = $null try { # Step 1: run the command in the exec runspace, collect raw objects $ps = [PowerShell]::Create() $ps.Runspace = $execRunspace $ps.AddScript($command) | Out-Null $rawOutput = $ps.Invoke() $lines = [System.Collections.Generic.List[string]]::new() # Capture output streams from step 1 foreach ($info in $ps.Streams.Information) { $lines.Add($info.MessageData.ToString()) } foreach ($warn in $ps.Streams.Warning) { $lines.Add("WARNING: $($warn.Message)") } foreach ($dbg in $ps.Streams.Debug) { $lines.Add("DEBUG: $($dbg.Message)") } foreach ($err in $ps.Streams.Error) { $lines.Add("ERROR: $($err.Exception.Message)") $result.exitCode = 1 } # Step 2: format raw objects via Out-String — must run in exec runspace # where CreateDefault2() loaded the format/type XML data if ($rawOutput -and $rawOutput.Count -gt 0) { $ps2 = [PowerShell]::Create() $ps2.Runspace = $execRunspace $ps2.AddScript({ param($items) $items | Out-String -Width 500 }) | Out-Null $ps2.AddArgument($rawOutput) | Out-Null $formatted = $ps2.Invoke() $text = ($formatted -join "`n").TrimEnd() if ($text) { $lines.Add($text) } } $result.output = $lines -join "`n" } catch { $result.error = $_.Exception.Message $result.exitCode = 1 } finally { if ($ps) { $ps.Dispose() } if ($ps2) { $ps2.Dispose() } } # Sync working directory — isolated so it never contaminates the result try { $wd = Get-WorkingDir $SharedState.WorkingDirectory = $wd $result.workingDir = $wd } catch { $result.workingDir = $SharedState.WorkingDirectory } Send-Json $ctx $result } # ── /api/history ────────────────────────────────────────── elseif ($urlPath -eq '/api/history' -and $req.HttpMethod -eq 'GET') { Send-Json $ctx @{ history = @($commandHistory) count = $commandHistory.Count } } # ── /api/history/clear ──────────────────────────────────── elseif ($urlPath -eq '/api/history/clear' -and $req.HttpMethod -eq 'POST') { $commandHistory.Clear() Send-Json $ctx @{ success = $true } } # ── /api/completions ────────────────────────────────────── elseif ($urlPath -eq '/api/completions' -and $req.HttpMethod -eq 'POST') { $body = Read-JsonBody $req $completions = @() $replIndex = 0 $replLength = 0 try { $cr = [System.Management.Automation.CommandCompletion]::CompleteInput( $body.input, [int]$body.cursorPosition, $null ) $completions = @($cr.CompletionMatches | ForEach-Object { @{ text = $_.CompletionText; type = $_.ResultType.ToString(); tooltip = $_.ToolTip } }) $replIndex = $cr.ReplacementIndex $replLength = $cr.ReplacementLength } catch { } Send-Json $ctx @{ completions = $completions replacementIndex = $replIndex replacementLength = $replLength } } # ── /api/info ───────────────────────────────────────────── elseif ($urlPath -eq '/api/info' -and $req.HttpMethod -eq 'GET') { # Get PS version from the execution runspace (reflects what the user actually runs) $pvPs = [PowerShell]::Create() $pvPs.Runspace = $execRunspace $pvPs.AddScript('$PSVersionTable.PSVersion.ToString()') | Out-Null $psVer = $pvPs.Invoke() $pvPs.Dispose() Send-Json $ctx @{ version = $Version psVersion = if ($psVer -and $psVer[0]) { $psVer[0].ToString() } else { 'Unknown' } os = [System.Environment]::OSVersion.VersionString user = $env:USERNAME hostname = $env:COMPUTERNAME workingDir = $SharedState.WorkingDirectory } } # ── Static files ────────────────────────────────────────── else { if ($urlPath -eq '/') { $urlPath = '/index.html' } $filePath = Join-Path $PublicPath $urlPath.TrimStart('/') if (Test-Path $filePath -PathType Leaf) { $ext = [System.IO.Path]::GetExtension($filePath).ToLower() $contentType = if ($contentTypes[$ext]) { $contentTypes[$ext] } else { 'application/octet-stream' } $bytes = [System.IO.File]::ReadAllBytes($filePath) $ctx.Response.ContentType = $contentType $ctx.Response.ContentLength64 = $bytes.Length $ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length) $ctx.Response.Close() } else { $buf = [System.Text.Encoding]::UTF8.GetBytes('Not Found') $ctx.Response.StatusCode = 404 $ctx.Response.ContentLength64 = $buf.Length $ctx.Response.OutputStream.Write($buf, 0, $buf.Length) $ctx.Response.Close() } } } catch { # Last-resort error response try { $buf = [System.Text.Encoding]::UTF8.GetBytes("{`"error`":`"$($_.Exception.Message)`"}") $ctx.Response.StatusCode = 500 $ctx.Response.ContentType = 'application/json' $ctx.Response.ContentLength64 = $buf.Length $ctx.Response.OutputStream.Write($buf, 0, $buf.Length) $ctx.Response.Close() } catch { } } } } finally { $listener.Stop() $listener.Close() $execRunspace.Close() $execRunspace.Dispose() } } # ============================================================================ # Start # ============================================================================ function Start-PoshConsole { <# .SYNOPSIS Start PoshConsole — a WebView2 terminal powered by PoshDE. .DESCRIPTION Starts the HTTP server in a background runspace (non-blocking) and launches a WebView2 window via PoshDE's shared New-PoshWindow function. Your PS7 session stays free while PoshConsole runs. .PARAMETER NoWindow Start the server without opening the WebView2 window. Useful for headless use or debugging. .EXAMPLE Start-PoshConsole .EXAMPLE Start-PoshConsole -NoWindow #> [CmdletBinding()] param( [switch]$NoWindow ) # ── Preflight checks ───────────────────────────────────────────────────── if (-not (Get-Module -Name PoshDE)) { Write-Error "PoshDE is required. Run: Install-Module PoshDE" return } if (-not (Test-PoshDependencies)) { Write-Error "WebView2 is not installed. Run: Install-PoshDependencies" return } if ($script:SharedState -and $script:SharedState.Running) { Write-Warning "PoshConsole is already running on port $script:ActivePort" return } $publicPath = Join-Path $script:ModuleRoot "Public" if (-not (Test-Path $publicPath)) { Write-Error "Public folder not found at: $publicPath" return } # ── Port allocation ─────────────────────────────────────────────────────── $port = $null for ($attempt = 1; $attempt -le 5; $attempt++) { $candidate = Register-PoshPort -App "PoshConsole" if (-not $candidate) { Write-Error "PoshDE could not allocate a port." return } # Verify the port is actually bindable try { $test = New-Object System.Net.HttpListener $test.Prefixes.Add("http://localhost:$candidate/") $test.Start() $test.Stop() $test.Close() $port = $candidate break } catch { Unregister-PoshPort -Port $candidate Start-Sleep -Milliseconds 300 } finally { try { $test.Close() } catch {} } } if (-not $port) { Write-Error "Could not bind to any port after 5 attempts." return } $script:ActivePort = $port # ── Load PoshConsole config (custom banner dir etc.) ───────────────────── $script:ConfigPath = Join-Path $env:APPDATA "PoshDE\PoshConsole" if (-not (Test-Path $script:ConfigPath)) { New-Item -Path $script:ConfigPath -ItemType Directory -Force | Out-Null } $script:ConfigFile = Join-Path $script:ConfigPath "config.json" $script:ConsoleConfig = @{ CustomBannerDir = '' } if (Test-Path $script:ConfigFile) { try { $saved = Get-Content $script:ConfigFile -Raw | ConvertFrom-Json if ($saved.CustomBannerDir) { $script:ConsoleConfig.CustomBannerDir = $saved.CustomBannerDir } } catch { } } # ── Shared state (thread-safe hashtable) ───────────────────────────────── $script:SharedState = [hashtable]::Synchronized(@{ Running = $true Ready = $false WorkingDirectory = $PWD.Path InitialWorkingDir = $PWD.Path }) # ── Launch server runspace ──────────────────────────────────────────────── $poshDEPath = (Get-Module PoshDE).ModuleBase $script:ServerRunspace = [runspacefactory]::CreateRunspace() $script:ServerRunspace.Open() $script:ServerPowerShell = [PowerShell]::Create() $script:ServerPowerShell.Runspace = $script:ServerRunspace $script:ServerPowerShell.AddScript($script:ServerScript) | Out-Null $script:ServerPowerShell.AddArgument($port) | Out-Null $script:ServerPowerShell.AddArgument($publicPath) | Out-Null $script:ServerPowerShell.AddArgument($poshDEPath) | Out-Null $script:ServerPowerShell.AddArgument($script:SharedState) | Out-Null $script:ServerPowerShell.AddArgument($script:Version) | Out-Null $script:ServerPowerShell.AddArgument((Join-Path $script:ModuleRoot 'Banners')) | Out-Null $script:ServerPowerShell.AddArgument($script:ConsoleConfig.CustomBannerDir) | Out-Null $script:ServerAsyncResult = $script:ServerPowerShell.BeginInvoke() # Wait for the server to signal ready (up to 10s) $waited = 0 while (-not $script:SharedState.Ready -and $waited -lt 10000) { Start-Sleep -Milliseconds 100 $waited += 100 } if (-not $script:SharedState.Ready) { Write-Warning "Server did not signal ready within 10 seconds." } # ── Banner ──────────────────────────────────────────────────────────────── Write-Host "" Write-Host " ╔══════════════════════════════════════╗" -ForegroundColor Cyan Write-Host " ║ 💻 PoshConsole v$script:Version ║" -ForegroundColor Cyan Write-Host " ╚══════════════════════════════════════╝" -ForegroundColor Cyan Write-Host "" Write-Host " [*] Port " -NoNewline -ForegroundColor DarkGray Write-Host $port -ForegroundColor Yellow Write-Host " [*] Theme " -NoNewline -ForegroundColor DarkGray Write-Host (Get-PoshTheme).name -ForegroundColor Cyan Write-Host " [*] Directory " -NoNewline -ForegroundColor DarkGray Write-Host $PWD.Path -ForegroundColor DarkGray if ($script:ConsoleConfig.CustomBannerDir) { Write-Host " [*] Banners " -NoNewline -ForegroundColor DarkGray Write-Host $script:ConsoleConfig.CustomBannerDir -ForegroundColor DarkGray } Write-Host "" # ── Launch WebView2 window ──────────────────────────────────────────────── if (-not $NoWindow) { $script:WindowProcess = New-PoshWindow -AppName "PoshConsole" -Port $port -Width 1100 -Height 750 if ($script:WindowProcess) { Update-PoshPortPID -Port $port -PID $script:WindowProcess.Id Write-Host " [+] Window " -NoNewline -ForegroundColor Green Write-Host "PID $($script:WindowProcess.Id)" -ForegroundColor Cyan } else { Write-Warning "WebView2 window failed to launch." } } else { Write-Host " [*] Server " -NoNewline -ForegroundColor DarkGray Write-Host "http://localhost:$port" -ForegroundColor Yellow } Write-Host "" Write-Host " Run " -NoNewline -ForegroundColor DarkGray Write-Host "Stop-PoshConsole" -NoNewline -ForegroundColor Cyan Write-Host " to close." -ForegroundColor DarkGray Write-Host "" } # ============================================================================ # Stop # ============================================================================ function Stop-PoshConsole { <# .SYNOPSIS Stop PoshConsole and release all resources. #> [CmdletBinding()] param() Write-Host "" Write-Host " Stopping PoshConsole..." -ForegroundColor Yellow # Signal the server loop to exit if ($script:SharedState) { $script:SharedState.Running = $false } # Kill the WebView2 window if ($script:WindowProcess -and -not $script:WindowProcess.HasExited) { $script:WindowProcess.Kill() $script:WindowProcess.WaitForExit(3000) } # Wait for server runspace to finish, then clean up if ($script:ServerPowerShell -and $script:ServerAsyncResult) { try { $script:ServerPowerShell.EndInvoke($script:ServerAsyncResult) | Out-Null } catch { } $script:ServerPowerShell.Dispose() } if ($script:ServerRunspace) { $script:ServerRunspace.Close() $script:ServerRunspace.Dispose() } # Release the port if ($script:ActivePort) { Unregister-PoshPort -App "PoshConsole" } # Clear state $script:ActivePort = $null $script:WindowProcess = $null $script:ServerRunspace = $null $script:ServerPowerShell = $null $script:ServerAsyncResult = $null $script:SharedState = $null Write-Host " Done." -ForegroundColor Green Write-Host "" } # ============================================================================ # Banner directory configuration # ============================================================================ function Set-PoshConsoleBannerDir { <# .SYNOPSIS Set a custom directory for PoshConsole to load .banner files from. .DESCRIPTION The custom directory is stored outside the module folder so it survives module updates. Files in the custom directory take precedence over built-in banners with the same name. Pass no path (or -Clear) to remove the custom directory setting. .PARAMETER Path Full path to the directory containing your .banner files. .PARAMETER Clear Remove the custom banner directory setting. .EXAMPLE Set-PoshConsoleBannerDir -Path "C:\MyBanners" .EXAMPLE Set-PoshConsoleBannerDir -Clear #> [CmdletBinding()] param( [Parameter(ParameterSetName = 'Set')] [string]$Path, [Parameter(ParameterSetName = 'Clear')] [switch]$Clear ) $configDir = Join-Path $env:APPDATA "PoshDE\PoshConsole" $configFile = Join-Path $configDir "config.json" if (-not (Test-Path $configDir)) { New-Item -Path $configDir -ItemType Directory -Force | Out-Null } # Load existing config $config = @{ CustomBannerDir = '' } if (Test-Path $configFile) { try { $saved = Get-Content $configFile -Raw | ConvertFrom-Json if ($saved.CustomBannerDir) { $config.CustomBannerDir = $saved.CustomBannerDir } } catch { } } if ($Clear) { $config.CustomBannerDir = '' $config | ConvertTo-Json | Set-Content $configFile -Encoding UTF8 Write-Host " Custom banner directory cleared." -ForegroundColor Yellow Write-Host " Restart PoshConsole to apply." -ForegroundColor DarkGray return } if (-not $Path) { Write-Host " Current custom banner dir: " -NoNewline -ForegroundColor DarkGray if ($config.CustomBannerDir) { Write-Host $config.CustomBannerDir -ForegroundColor Cyan } else { Write-Host "(none)" -ForegroundColor DarkGray } return } if (-not (Test-Path $Path -PathType Container)) { Write-Error "Directory not found: $Path" return } $bannerCount = @(Get-ChildItem $Path -Filter '*.banner' -ErrorAction SilentlyContinue).Count $config.CustomBannerDir = $Path $config | ConvertTo-Json | Set-Content $configFile -Encoding UTF8 Write-Host " Custom banner dir set to: " -NoNewline -ForegroundColor DarkGray Write-Host $Path -ForegroundColor Cyan Write-Host " Found $bannerCount .banner file(s)." -ForegroundColor DarkGray Write-Host " Restart PoshConsole to apply." -ForegroundColor DarkGray } # ============================================================================ # Module init banner # ============================================================================ Write-Host "" Write-Host " PoshConsole v$script:Version" -ForegroundColor Cyan if (Get-Module -Name PoshDE) { Write-Host " [+] PoshDE " -NoNewline -ForegroundColor Green Write-Host "Connected" -ForegroundColor Cyan } else { Write-Host " [-] PoshDE " -NoNewline -ForegroundColor Red Write-Host "Not found — run: Install-Module PoshDE" -ForegroundColor Yellow } Write-Host "" # ============================================================================ # Exports # ============================================================================ Export-ModuleMember -Function @( 'Start-PoshConsole' 'Stop-PoshConsole' 'Get-PoshConsoleStatus' 'Set-PoshConsoleBannerDir' ) |