PoshDE.psm1
|
# PoshDE - PowerShell Desktop Environment # https://github.com/Jakoby/PoshDE $script:ModuleRoot = $PSScriptRoot $script:Version = '1.0.0' # ============================================================================ # Global State # ============================================================================ $script:PoshDE = @{ CurrentTheme = 'samurai' WebView2Ready = $false WebView2DllPath = $null AppDataPath = Join-Path $env:APPDATA "PoshDE" } # Ensure app data directory exists if (-not (Test-Path $script:PoshDE.AppDataPath)) { New-Item -Path $script:PoshDE.AppDataPath -ItemType Directory -Force | Out-Null } # Load persisted config $configPath = Join-Path $script:PoshDE.AppDataPath "config.json" if (Test-Path $configPath) { try { $config = Get-Content $configPath -Raw | ConvertFrom-Json if ($config.CurrentTheme) { $script:PoshDE.CurrentTheme = $config.CurrentTheme } } catch { } } # ============================================================================ # Load Core Components # ============================================================================ . "$script:ModuleRoot\Core\DependencyManager.ps1" . "$script:ModuleRoot\Core\ThemeEngine.ps1" . "$script:ModuleRoot\Core\PortManager.ps1" . "$script:ModuleRoot\Core\ModuleTracker.ps1" . "$script:ModuleRoot\Core\WindowManager.ps1" # Check WebView2 on load $script:PoshDE.WebView2Ready = Test-WebView2Installed # ============================================================================ # Banner # ============================================================================ function Show-PoshDEBanner { Write-Host "" Write-Host " PoshDE v$script:Version" -ForegroundColor Magenta Write-Host " PowerShell Desktop Environment" -ForegroundColor DarkGray Write-Host " ─────────────────────────────────────" -ForegroundColor DarkGray if ($script:PoshDE.WebView2Ready) { Write-Host " [+] WebView2 " -NoNewline -ForegroundColor Green Write-Host "Ready" -ForegroundColor Cyan } else { Write-Host " [-] WebView2 " -NoNewline -ForegroundColor Red Write-Host "Not installed — run " -NoNewline -ForegroundColor Yellow Write-Host "Install-PoshDependencies" -ForegroundColor Cyan } Write-Host " [*] Theme " -NoNewline -ForegroundColor DarkGray Write-Host $script:PoshDE.CurrentTheme -ForegroundColor Cyan $modules = Get-PoshModules $installedCnt = @($modules | Where-Object Installed).Count Write-Host " [*] Modules " -NoNewline -ForegroundColor DarkGray Write-Host "$installedCnt / $($modules.Count) installed" -ForegroundColor Cyan $activePorts = @($script:PortConfig.Registry.Keys) if ($activePorts.Count -gt 0) { $running = 0 foreach ($p in $activePorts) { $e = $script:PortConfig.Registry[$p] if ($e.PID -gt 0) { $proc = Get-Process -Id $e.PID -ErrorAction SilentlyContinue if ($proc -and -not $proc.HasExited) { $running++ } } } Write-Host " [*] Apps " -NoNewline -ForegroundColor DarkGray Write-Host "$running running" -ForegroundColor Cyan } Write-Host "" } Show-PoshDEBanner # ============================================================================ # GUI — Open-PoshDE / Stop-PoshDE # ============================================================================ $script:PoshDE_ActivePort = $null $script:PoshDE_WindowProcess = $null $script:PoshDE_ServerRunspace = $null $script:PoshDE_ServerPowerShell = $null $script:PoshDE_ServerAsyncResult = $null $script:PoshDE_SharedState = $null $script:PoshDE_ServerScript = { param( [int] $Port, [string] $PoshDEPath, [hashtable] $SharedState ) Import-Module $PoshDEPath -ErrorAction Stop # ── Response helpers ───────────────────────────────────────────────────── function Send-Json { param($Ctx, $Data, [int]$Code = 200) $json = $Data | ConvertTo-Json -Depth 10 -Compress $buf = [System.Text.Encoding]::UTF8.GetBytes($json) $Ctx.Response.StatusCode = $Code $Ctx.Response.ContentType = 'application/json; charset=utf-8' $Ctx.Response.ContentLength64 = $buf.Length $Ctx.Response.OutputStream.Write($buf, 0, $buf.Length) $Ctx.Response.Close() } function Send-File { param($Ctx, [string]$Path, [string]$ContentType) if (Test-Path $Path) { $bytes = [System.IO.File]::ReadAllBytes($Path) $Ctx.Response.StatusCode = 200 $Ctx.Response.ContentType = $ContentType $Ctx.Response.ContentLength64 = $bytes.Length $Ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length) } else { $buf = [System.Text.Encoding]::UTF8.GetBytes("Not Found: $Path") $Ctx.Response.StatusCode = 404 $Ctx.Response.ContentLength64 = $buf.Length $Ctx.Response.OutputStream.Write($buf, 0, $buf.Length) } $Ctx.Response.Close() } function Read-JsonBody { param($Req) $reader = New-Object System.IO.StreamReader($Req.InputStream) $body = $reader.ReadToEnd(); $reader.Close() return $body | ConvertFrom-Json } # ── Data helpers ───────────────────────────────────────────────────────── function Get-ModuleList { $list = [System.Collections.Generic.List[object]]::new() foreach ($mod in $SharedState.KnownModules) { $found = Get-Module -ListAvailable -Name $mod.Name -ErrorAction SilentlyContinue $installed = $null -ne $found $version = if ($installed) { ($found | Sort-Object Version -Descending | Select-Object -First 1).Version.ToString() } else { $null } $list.Add(@{ name = $mod.Name description = $mod.Description repo = $mod.Repo installed = $installed version = $version }) } return @($list) } function Get-PortList { $path = $SharedState.PortRegistryPath if (-not (Test-Path $path)) { return @() } try { $json = Get-Content $path -Raw | ConvertFrom-Json } catch { return @() } $list = [System.Collections.Generic.List[object]]::new() foreach ($prop in $json.PSObject.Properties) { $portNum = [int]$prop.Name $entry = $prop.Value $status = 'unknown' if ($entry.PID -gt 0) { $proc = Get-Process -Id $entry.PID -ErrorAction SilentlyContinue $status = if ($proc -and -not $proc.HasExited) { 'running' } else { 'dead' } } else { $status = 'starting' } $started = try { ([datetime]$entry.Started).ToString('h:mm tt') } catch { '—' } $list.Add(@{ port = $portNum app = $entry.App pid = $entry.PID status = $status started = $started }) } return @($list | Sort-Object { $_.port }) } function Get-ThemeList { $names = @('samurai','cyberpunk','matrix','midnight','hacker','default') $list = [System.Collections.Generic.List[object]]::new() foreach ($name in $names) { $t = Get-PoshTheme -Name $name $list.Add(@{ name = $name description = $t.description primary = $t.colors.primary secondary = $t.colors.secondary accent = $t.colors.accent bgDark = $t.colors.bgDark bgMedium = $t.colors.bgMedium }) } return @($list) } # Map of module name -> launch scriptblock $LaunchMap = @{ 'PoshWallpaper' = { Open-PoshWallpaper } 'PoshConsole' = { Start-PoshConsole } 'PoshPresenter' = { Start-PoshDemo } } function Invoke-LaunchApp { param([string]$ModuleName) $openCmd = $LaunchMap[$ModuleName] if (-not $openCmd) { return @{ success=$false; error="No launch command defined for $ModuleName" } } # Check if already running via port registry $path = $SharedState.PortRegistryPath if (Test-Path $path) { try { $json = Get-Content $path -Raw | ConvertFrom-Json foreach ($prop in $json.PSObject.Properties) { if ($prop.Value.App -eq $ModuleName) { $pid = $prop.Value.PID if ($pid -gt 0) { $proc = Get-Process -Id $pid -ErrorAction SilentlyContinue if ($proc -and -not $proc.HasExited) { return @{ success=$false; error="$ModuleName is already running" } } } } } } catch { } } try { Import-Module $ModuleName -ErrorAction Stop & $openCmd return @{ success=$true; module=$ModuleName } } catch { return @{ success=$false; error=$_.Exception.Message } } } function Stop-AppByPort { param([int]$TargetPort) $path = $SharedState.PortRegistryPath if (-not (Test-Path $path)) { return @{ success=$false; error='Registry not found' } } try { $json = Get-Content $path -Raw | ConvertFrom-Json } catch { return @{ success=$false; error='Could not read registry' } } $entry = $json.PSObject.Properties | Where-Object { [int]$_.Name -eq $TargetPort } | Select-Object -First 1 if (-not $entry) { return @{ success=$false; error="Port $TargetPort not in registry" } } if ($entry.Value.PID -gt 0) { $proc = Get-Process -Id $entry.Value.PID -ErrorAction SilentlyContinue if ($proc -and -not $proc.HasExited) { try { $proc.Kill(); $proc.WaitForExit(3000) } catch { } } } # Rewrite registry without this entry $newReg = @{} foreach ($p in $json.PSObject.Properties) { if ([int]$p.Name -ne $TargetPort) { $newReg[$p.Name] = $p.Value } } try { $newReg | ConvertTo-Json -Depth 5 | Set-Content $path -Encoding UTF8 } catch { } return @{ success=$true } } # ── HTTP 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) { $task = $listener.GetContextAsync() while (-not $task.Wait(500)) { if (-not $SharedState.Running) { break } } if (-not $SharedState.Running) { break } if (-not $task.IsCompleted -or $task.IsFaulted -or $task.IsCanceled) { continue } $ctx = $task.Result $req = $ctx.Request $urlPath = $req.Url.LocalPath $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 { if ($req.HttpMethod -eq 'OPTIONS') { $ctx.Response.StatusCode = 200; $ctx.Response.Close(); continue } switch -Regex ($urlPath) { '^/$|^/index\.html$' { Send-File $ctx (Join-Path $SharedState.ModuleRoot 'index.html') 'text/html; charset=utf-8' } '^/api/theme$' { $t = Get-PoshTheme Send-Json $ctx @{ name=$t.name; css=(Export-PoshThemeCSS); colors=$t.colors } } '^/api/themes$' { Send-Json $ctx @{ themes = @(Get-ThemeList) } } '^/api/modules$' { Send-Json $ctx @{ modules = @(Get-ModuleList) } } '^/api/ports$' { Send-Json $ctx @{ ports = @(Get-PortList) } } '^/api/theme/set$' { if ($req.HttpMethod -ne 'POST') { Send-Json $ctx @{ error='POST required' } -Code 405; break } $body = Read-JsonBody $req $name = [string]$body.name Set-PoshTheme -Name $name Send-Json $ctx @{ success=$true; theme=$name } } '^/api/app/stop$' { if ($req.HttpMethod -ne 'POST') { Send-Json $ctx @{ error='POST required' } -Code 405; break } $body = Read-JsonBody $req $result = Stop-AppByPort -TargetPort ([int]$body.port) Send-Json $ctx $result } '^/api/app/launch$' { if ($req.HttpMethod -ne 'POST') { Send-Json $ctx @{ error='POST required' } -Code 405; break } $body = Read-JsonBody $req $result = Invoke-LaunchApp -ModuleName ([string]$body.module) Send-Json $ctx $result } default { Send-Json $ctx @{ error="Not found: $urlPath" } -Code 404 } } } catch { $msg = $_.Exception.Message try { $buf = [System.Text.Encoding]::UTF8.GetBytes("{`"error`":`"$msg`"}") $ctx.Response.StatusCode = 500 $ctx.Response.ContentType = 'application/json' $ctx.Response.ContentLength64 = $buf.Length $ctx.Response.OutputStream.Write($buf, 0, $buf.Length) } catch { } try { $ctx.Response.Close() } catch { } } } } finally { $listener.Stop() $listener.Close() } } function Open-PoshDE { <# .SYNOPSIS Open the PoshDE dashboard GUI. .DESCRIPTION Launches a WebView2 window showing module status, running apps, theme switcher, and port registry. .PARAMETER NoWindow Start the HTTP server only without opening a window. .EXAMPLE Open-PoshDE #> [CmdletBinding()] param([switch]$NoWindow) if ($script:PoshDE_SharedState -and $script:PoshDE_SharedState.Running) { Write-Host " PoshDE GUI already running on port $script:PoshDE_ActivePort" -ForegroundColor Yellow return } $dllPath = Get-WebView2DllPath if (-not $dllPath) { Write-Error "WebView2 not ready. Run Install-PoshDependencies."; return } $port = Register-PoshPort -App 'PoshDE' if (-not $port) { Write-Error "Could not allocate port."; return } $script:PoshDE_ActivePort = $port $script:PoshDE_SharedState = [hashtable]::Synchronized(@{ Running = $true Ready = $false ModuleRoot = $script:ModuleRoot PortRegistryPath = Join-Path $script:PoshDE.AppDataPath 'port-registry.json' ConfigPath = Join-Path $script:PoshDE.AppDataPath 'config.json' KnownModules = $script:PoshKnownModules }) $script:PoshDE_ServerRunspace = [runspacefactory]::CreateRunspace() $script:PoshDE_ServerRunspace.ApartmentState = 'STA' $script:PoshDE_ServerRunspace.ThreadOptions = 'ReuseThread' $script:PoshDE_ServerRunspace.Open() $script:PoshDE_ServerPowerShell = [PowerShell]::Create() $script:PoshDE_ServerPowerShell.Runspace = $script:PoshDE_ServerRunspace $script:PoshDE_ServerPowerShell.AddScript($script:PoshDE_ServerScript) | Out-Null $script:PoshDE_ServerPowerShell.AddParameter('Port', $port) | Out-Null $script:PoshDE_ServerPowerShell.AddParameter('PoshDEPath', (Get-Module PoshDE).ModuleBase) | Out-Null $script:PoshDE_ServerPowerShell.AddParameter('SharedState', $script:PoshDE_SharedState) | Out-Null $script:PoshDE_ServerAsyncResult = $script:PoshDE_ServerPowerShell.BeginInvoke() $waited = 0 while (-not $script:PoshDE_SharedState.Ready -and $waited -lt 5000) { Start-Sleep -Milliseconds 100; $waited += 100 } Write-Host "" Write-Host " ╔══════════════════════════════════════╗" -ForegroundColor Magenta Write-Host " ║ 🔱 PoshDE v$script:Version ║" -ForegroundColor Magenta Write-Host " ╚══════════════════════════════════════╝" -ForegroundColor Magenta Write-Host " [*] Port $port" -ForegroundColor Yellow Write-Host " [*] Theme $($script:PoshDE.CurrentTheme)" -ForegroundColor Cyan Write-Host "" if (-not $NoWindow) { $script:PoshDE_WindowProcess = New-PoshWindow -AppName 'PoshDE' -Port $port -Width 1100 -Height 740 if ($script:PoshDE_WindowProcess) { Update-PoshPortPID -Port $port -PID $script:PoshDE_WindowProcess.Id Write-Host " [+] Window PID $($script:PoshDE_WindowProcess.Id)" -ForegroundColor Green } } else { Write-Host " [*] Server http://localhost:$port" -ForegroundColor Yellow } Write-Host "" Write-Host " Run Stop-PoshDE to close." -ForegroundColor DarkGray Write-Host "" } function Stop-PoshDE { <# .SYNOPSIS Close the PoshDE dashboard GUI. .EXAMPLE Stop-PoshDE #> [CmdletBinding()] param() Write-Host " Stopping PoshDE GUI..." -ForegroundColor Yellow if ($script:PoshDE_SharedState) { $script:PoshDE_SharedState.Running = $false } if ($script:PoshDE_WindowProcess -and -not $script:PoshDE_WindowProcess.HasExited) { $script:PoshDE_WindowProcess.Kill() $script:PoshDE_WindowProcess.WaitForExit(3000) } if ($script:PoshDE_ServerPowerShell -and $script:PoshDE_ServerAsyncResult) { try { $script:PoshDE_ServerPowerShell.EndInvoke($script:PoshDE_ServerAsyncResult) | Out-Null } catch { } $script:PoshDE_ServerPowerShell.Dispose() } if ($script:PoshDE_ServerRunspace) { $script:PoshDE_ServerRunspace.Close() $script:PoshDE_ServerRunspace.Dispose() } if ($script:PoshDE_ActivePort) { Unregister-PoshPort -App 'PoshDE' } $script:PoshDE_ActivePort = $null $script:PoshDE_WindowProcess = $null $script:PoshDE_ServerRunspace = $null $script:PoshDE_ServerPowerShell = $null $script:PoshDE_ServerAsyncResult = $null $script:PoshDE_SharedState = $null Write-Host " Done." -ForegroundColor Green } # ============================================================================ # Exports # ============================================================================ Export-ModuleMember -Function @( # Dependencies 'Install-PoshDependencies' 'Test-PoshDependencies' 'Get-PoshDependencyStatus' 'Get-WebView2DllPath' # Themes 'Get-PoshTheme' 'Set-PoshTheme' 'Get-PoshThemes' 'Export-PoshThemeCSS' # Port Management 'Register-PoshPort' 'Unregister-PoshPort' 'Update-PoshPortPID' 'Get-PoshPortRegistry' 'Clear-PoshOrphanedPorts' 'Stop-PoshApp' 'Stop-AllPoshApps' # Module Tracking 'Get-PoshModules' # Window Management 'New-PoshWindow' # GUI 'Open-PoshDE' 'Stop-PoshDE' ) |