PoshPresenter.psm1
|
#Requires -Version 7.0 <# .SYNOPSIS PoshPresenter - PowerShell presentation engine with per-slide HTML architecture. .DESCRIPTION Each slide is its own HTML file. The shell loads them in an iframe and handles navigation, timing, pace tracking, and speaker notes. USAGE: # Point at your presentation folder Start-PoshPresenter -Path "C:\MyTalks\ZeroTrustWorld" # Or point directly at the JSON file Start-PoshPresenter -Path "C:\MyTalks\ZeroTrustWorld\presentation.json" # Scaffold a new presentation New-PoshPresentation -Path "C:\MyTalks\NewTalk" -Title "My Talk" PRESENTATION FOLDER STRUCTURE: MyTalk\ presentation.json <- your slide list, notes, durations slides\ 01-intro.html 02-demo.html assets\ <- images, fonts, etc. presentation.json lives in YOUR folder and is never touched by module updates. Modes: standalone - Default, no sync presenter - Speaker notes, timer, pace tracker, broadcasts position attendee - Follows presenter sync Portable mode works without PoshDE installed. #> # ── Module State ── $script:Listener = $null $script:RunLoop = $null $script:SyncState = @{ currentSlide = 0; isPresenter = $false } $script:PublicRoot = Join-Path $PSScriptRoot 'Public' # ────────────────────────────────────────────────────────────────────────────── # Start-PoshPresenter # ────────────────────────────────────────────────────────────────────────────── function Start-PoshPresenter { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path, [switch]$Presenter, [switch]$Portable, [int]$Port = 0, [switch]$NoWindow, [string]$Title ) # ── Resolve presentation directory and config file ── $resolved = Resolve-PresentationPath -Path $Path $presDir = $resolved.Directory $presJson = $resolved.JsonFile # ── Load and validate presentation.json ── $config = Get-Content -Raw $presJson | ConvertFrom-Json if (-not $config.slides -or $config.slides.Count -eq 0) { throw "presentation.json has no slides defined: $presJson" } Write-Host "" Write-Host "PoshPresenter" -ForegroundColor Magenta -NoNewline Write-Host " | $($config.title)" -ForegroundColor White Write-Host " Slides : $($config.slides.Count)" -ForegroundColor DarkGray Write-Host " Duration : $($config.duration) min" -ForegroundColor DarkGray Write-Host " Folder : $presDir" -ForegroundColor DarkGray # ── Resolve port ── if ($Port -eq 0) { if (-not $Portable -and (Get-Command Register-PoshPort -ErrorAction SilentlyContinue)) { $Port = Register-PoshPort -App 'PoshPresenter' Write-Host " Port : $Port (PoshDE)" -ForegroundColor DarkGray } else { $Port = Find-FreePort Write-Host " Port : $Port (auto)" -ForegroundColor DarkGray } } # ── Resolve theme ── $themeCSS = $null if (-not $Portable -and (Get-Command Export-PoshThemeCSS -ErrorAction SilentlyContinue)) { try { $themeCSS = Export-PoshThemeCSS } catch {} } # ── MIME types ── $mimeTypes = @{ '.html' = 'text/html' '.css' = 'text/css' '.js' = 'application/javascript' '.json' = 'application/json' '.png' = 'image/png' '.jpg' = 'image/jpeg' '.jpeg' = 'image/jpeg' '.gif' = 'image/gif' '.svg' = 'image/svg+xml' '.ico' = 'image/x-icon' '.webp' = 'image/webp' '.mp4' = 'video/mp4' '.woff' = 'font/woff' '.woff2'= 'font/woff2' '.ttf' = 'font/ttf' } # ── Sync state ── $script:SyncState = @{ currentSlide = 0; isPresenter = [bool]$Presenter } # ── Start HTTP listener ── $prefix = "http://localhost:$Port/" $script:Listener = New-Object System.Net.HttpListener $script:Listener.Prefixes.Add($prefix) try { $script:Listener.Start() } catch { Write-Warning "Port $Port unavailable. Trying another..." $Port = Find-FreePort $prefix = "http://localhost:$Port/" $script:Listener = New-Object System.Net.HttpListener $script:Listener.Prefixes.Add($prefix) $script:Listener.Start() } $mode = if ($Presenter) { 'presenter' } else { 'standalone' } $appUrl = "http://localhost:$Port/?mode=$mode" Write-Host " URL : " -ForegroundColor DarkGray -NoNewline Write-Host $appUrl -ForegroundColor Cyan Write-Host "" # ── Serialize presentation config for the API ── # Read fresh from disk so runtime edits are picked up on reload $configJson = Get-Content -Raw $presJson # ── Request handler (runs in background runspace) ── $script:RunLoop = [PowerShell]::Create().AddScript({ param($Listener, $PublicRoot, $PresDir, $ConfigJson, $SyncState, $ThemeCSS, $MimeTypes) while ($Listener.IsListening) { try { $ctx = $Listener.GetContext() $req = $ctx.Request $res = $ctx.Response $path = $req.Url.AbsolutePath # CORS / cache headers $res.Headers.Add("Access-Control-Allow-Origin", "*") $res.Headers.Add("Access-Control-Allow-Methods", "GET, POST, OPTIONS") $res.Headers.Add("Access-Control-Allow-Headers", "Content-Type") $res.Headers.Add("Cache-Control", "no-cache") if ($req.HttpMethod -eq 'OPTIONS') { $res.StatusCode = 204; $res.Close(); continue } # ── /api/presentation ── # Returns the presentation.json — no file writing, just reads what was loaded if ($path -eq '/api/presentation') { $bytes = [System.Text.Encoding]::UTF8.GetBytes($ConfigJson) $res.ContentType = 'application/json' $res.OutputStream.Write($bytes, 0, $bytes.Length) $res.Close(); continue } # ── /api/theme ── if ($path -eq '/api/theme') { $obj = @{ css = if ($ThemeCSS) { $ThemeCSS } else { $null } } $bytes = [System.Text.Encoding]::UTF8.GetBytes(($obj | ConvertTo-Json)) $res.ContentType = 'application/json' $res.OutputStream.Write($bytes, 0, $bytes.Length) $res.Close(); continue } # ── /api/sync GET ── if ($path -eq '/api/sync' -and $req.HttpMethod -eq 'GET') { $bytes = [System.Text.Encoding]::UTF8.GetBytes(($SyncState | ConvertTo-Json)) $res.ContentType = 'application/json' $res.OutputStream.Write($bytes, 0, $bytes.Length) $res.Close(); continue } # ── /api/sync POST ── if ($path -eq '/api/sync' -and $req.HttpMethod -eq 'POST') { $reader = New-Object System.IO.StreamReader($req.InputStream) $body = $reader.ReadToEnd() | ConvertFrom-Json $reader.Close() if ($null -ne $body.currentSlide) { $SyncState['currentSlide'] = [int]$body.currentSlide } $bytes = [System.Text.Encoding]::UTF8.GetBytes('{"ok":true}') $res.ContentType = 'application/json' $res.OutputStream.Write($bytes, 0, $bytes.Length) $res.Close(); continue } # ── /api/execute POST ── if ($path -eq '/api/execute' -and $req.HttpMethod -eq 'POST') { $reader = New-Object System.IO.StreamReader($req.InputStream) $body = $reader.ReadToEnd() | ConvertFrom-Json $reader.Close() $result = @{ output = ''; error = ''; success = $true } try { $ps = [PowerShell]::Create() $ps.AddScript($body.command) | Out-Null $output = $ps.Invoke() $result.output = ($output | Out-String).TrimEnd() if ($ps.Streams.Error.Count -gt 0) { $result.error = ($ps.Streams.Error | Out-String).TrimEnd() $result.success = $false } if ($ps.Streams.Warning.Count -gt 0) { $result.output += "`n" + ($ps.Streams.Warning | Out-String).TrimEnd() } $ps.Dispose() } catch { $result.error = $_.Exception.Message $result.success = $false } $bytes = [System.Text.Encoding]::UTF8.GetBytes(($result | ConvertTo-Json -Depth 3)) $res.ContentType = 'application/json' $res.OutputStream.Write($bytes, 0, $bytes.Length) $res.Close(); continue } # ── Static file routing ── # Default root → shell.html (the presentation UI) if ($path -eq '/') { $path = '/index.html' } # File resolution order: # 1. Module Public folder (shell.html, shell.js, shell.css, slide-base.*) # 2. Presentation folder (slides/, assets/, any user files) $filePath = Join-Path $PublicRoot $path.TrimStart('/') if (-not (Test-Path $filePath -PathType Leaf)) { $filePath = Join-Path $PresDir $path.TrimStart('/') } if (Test-Path $filePath -PathType Leaf) { $ext = [System.IO.Path]::GetExtension($filePath).ToLower() $res.ContentType = if ($MimeTypes.ContainsKey($ext)) { $MimeTypes[$ext] } else { 'application/octet-stream' } $fileBytes = [System.IO.File]::ReadAllBytes($filePath) $res.OutputStream.Write($fileBytes, 0, $fileBytes.Length) } else { $res.StatusCode = 404 $msg = [System.Text.Encoding]::UTF8.GetBytes("404 Not Found: $path") $res.OutputStream.Write($msg, 0, $msg.Length) } $res.Close() } catch [System.Net.HttpListenerException] { break } catch { try { $res.StatusCode = 500; $res.Close() } catch {} } } }).AddArgument($script:Listener ).AddArgument($script:PublicRoot ).AddArgument($presDir ).AddArgument($configJson ).AddArgument($script:SyncState ).AddArgument($themeCSS ).AddArgument($mimeTypes) $script:RunLoop.BeginInvoke() | Out-Null Write-Host " Server: " -ForegroundColor DarkGray -NoNewline Write-Host "running" -ForegroundColor Green # ── Launch window ── if (-not $NoWindow) { $windowTitle = if ($Title) { $Title } elseif ($config.title) { $config.title } else { 'PoshPresenter' } Start-PoshPresenterWindow -Url $appUrl -Title $windowTitle -Portable:$Portable } [PSCustomObject]@{ Port = $Port Url = $appUrl Mode = $mode Slides = $config.slides.Count Duration = $config.duration Folder = $presDir } } # ────────────────────────────────────────────────────────────────────────────── # Stop-PoshPresenter # ────────────────────────────────────────────────────────────────────────────── function Stop-PoshPresenter { [CmdletBinding()] param() if ($script:Listener -and $script:Listener.IsListening) { $script:Listener.Stop() $script:Listener.Close() Write-Host "PoshPresenter stopped." -ForegroundColor Yellow } if ($script:RunLoop) { $script:RunLoop.Stop() $script:RunLoop.Dispose() $script:RunLoop = $null } if (Get-Command Unregister-PoshPort -ErrorAction SilentlyContinue) { try { Unregister-PoshPort -App 'PoshPresenter' } catch {} } } # ────────────────────────────────────────────────────────────────────────────── # New-PoshPresentation # Scaffolds a fresh presentation folder so users are instantly ready to go. # ────────────────────────────────────────────────────────────────────────────── function New-PoshPresentation { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path, [string]$Title = 'My Presentation', [string]$Author = $env:USERNAME, [int]$Duration = 30 ) # Expand ~ etc. $Path = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) if (Test-Path $Path) { # Allow pointing at an existing empty folder $existing = Get-ChildItem $Path -ErrorAction SilentlyContinue if ($existing) { throw "Folder already exists and is not empty: $Path`nDelete it first or choose a new path." } } # Create folder structure New-Item -ItemType Directory -Path (Join-Path $Path 'slides') -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $Path 'assets') -Force | Out-Null # ── presentation.json ── $config = [ordered]@{ title = $Title author = $Author duration = $Duration slides = @( [ordered]@{ file = 'slides/01-title.html' title = 'Title' section = 'Introduction' duration = 1 notes = 'Opening slide. Introduce yourself and the topic.' } [ordered]@{ file = 'slides/02-demo.html' title = 'Demo' section = 'Demo' duration = 5 notes = 'Live demo goes here.' } ) } $config | ConvertTo-Json -Depth 10 | Set-Content (Join-Path $Path 'presentation.json') -Encoding UTF8 # ── Starter slide 1: title ── $titleSlide = @' <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="stylesheet" href="/slide-base.css"> <script src="/slide-base.js"></script> <style> body { display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #0a0a0a; font-family: 'Courier New', monospace; } .center { text-align: center; } h1 { font-size: 3rem; color: #00ff9f; margin: 0 0 1rem; } p { color: #888; font-size: 1.2rem; } </style> </head> <body> <div class="center"> <h1>SLIDE_TITLE_PLACEHOLDER</h1> <p>Edit slides/01-title.html to customize this slide.</p> <p>See <a href="https://powershellforhackers.com" style="color:#00ff9f">powershellforhackers.com</a> for examples.</p> </div> </body> </html> '@ $titleSlide = $titleSlide -replace 'SLIDE_TITLE_PLACEHOLDER', $Title $titleSlide | Set-Content (Join-Path $Path 'slides/01-title.html') -Encoding UTF8 # ── Starter slide 2: live code demo ── $demoSlide = @' <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="stylesheet" href="/slide-base.css"> <script src="/slide-base.js"></script> <style> body { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #0a0a0a; font-family: 'Courier New', monospace; color: #e0e0e0; } h2 { color: #00ff9f; margin-bottom: 1.5rem; } pre { background: #111; border: 1px solid #333; border-radius: 6px; padding: 1rem 1.5rem; font-size: 0.95rem; color: #00ff9f; margin-bottom: 1rem; } </style> </head> <body> <h2>Live Code Execution Demo</h2> <!-- posh-run lets you run PowerShell inline. Remove the data-attendee-hide attribute if you want attendees to also see the Run button. --> <pre>Get-ComputerInfo | Select-Object WindowsProductName, OsArchitecture</pre> <div class="posh-run" data-code="Get-ComputerInfo | Select-Object WindowsProductName, OsArchitecture | Format-List" data-label="▶ Run on presenter machine"> </div> <p style="color:#555; margin-top:2rem; font-size:0.85rem"> Edit slides/02-demo.html to customize this slide. </p> </body> </html> '@ $demoSlide | Set-Content (Join-Path $Path 'slides/02-demo.html') -Encoding UTF8 # ── README ── $readme = @" # $Title Created with PoshPresenter. ## Quick Start ``````powershell Import-Module PoshPresenter Start-PoshPresenter -Path "$Path" # Presenter mode (speaker notes + timer) Start-PoshPresenter -Path "$Path" -Presenter `````` ## Folder Structure $Title\ presentation.json <- slide list, notes, durations slides\ <- your HTML slides (one file per slide) assets\ <- images, fonts, videos ## Adding Slides 1. Create a new HTML file in slides\ (e.g. slides\03-attack.html) 2. Add it to presentation.json: ``````json { "file": "slides/03-attack.html", "title": "The Attack", "section": "Demo", "duration": 3, "notes": "Speaker notes go here." } `````` ## Slide Helpers Each slide can include: - /slide-base.css <- dark theme, code blocks, utility classes - /slide-base.js <- Posh.run() for live code execution These are served from the PoshPresenter module automatically. ## Resources - powershellforhackers.com - github.com/jakoby "@ $readme | Set-Content (Join-Path $Path 'README.md') -Encoding UTF8 # ── Done ── Write-Host "" Write-Host "PoshPresenter" -ForegroundColor Magenta -NoNewline Write-Host " | New presentation scaffolded" -ForegroundColor Green Write-Host " Folder : $Path" -ForegroundColor DarkGray Write-Host " Config : presentation.json" -ForegroundColor DarkGray Write-Host " Slides : $($config.slides.Count) starter slides" -ForegroundColor DarkGray Write-Host "" Write-Host " Next:" -ForegroundColor White Write-Host " Start-PoshPresenter -Path `"$Path`"" -ForegroundColor Cyan Write-Host " Start-PoshPresenter -Path `"$Path`" -Presenter" -ForegroundColor Cyan Write-Host "" [PSCustomObject]@{ Path = $Path Config = Join-Path $Path 'presentation.json' Slides = Join-Path $Path 'slides' Assets = Join-Path $Path 'assets' } } # ────────────────────────────────────────────────────────────────────────────── # Internal helpers # ────────────────────────────────────────────────────────────────────────────── function Resolve-PresentationPath { param([string]$Path) $Path = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) if (-not (Test-Path $Path)) { throw "Path not found: $Path" } # If they passed a .json file directly if ((Get-Item $Path).PSIsContainer -eq $false) { if ($Path -notlike '*.json') { throw "File must be a .json presentation config: $Path" } return [PSCustomObject]@{ Directory = Split-Path $Path -Parent JsonFile = $Path } } # It's a directory — look for presentation.json $jsonFile = Join-Path $Path 'presentation.json' if (-not (Test-Path $jsonFile)) { # Friendly error with hint throw @" No presentation.json found in: $Path Create one with: New-PoshPresentation -Path "$Path" -Title "My Talk" Or point at an existing presentation.json file directly: Start-PoshPresenter -Path "C:\MyTalks\MyTalk\presentation.json" "@ } return [PSCustomObject]@{ Directory = $Path JsonFile = $jsonFile } } function Start-PoshPresenterWindow { param([string]$Url, [string]$Title, [switch]$Portable) # Option 1: PoshDE WebView2 via PS5.1 subprocess if (-not $Portable -and (Get-Command Get-WebView2DllPath -ErrorAction SilentlyContinue)) { $dllPath = Get-WebView2DllPath if ($dllPath -and (Test-Path $dllPath)) { Write-Host " Window : PoshDE WebView2" -ForegroundColor DarkGray $escapedDll = $dllPath -replace "'", "''" $escapedUrl = $Url -replace "'", "''" $escapedTitle = $Title -replace "'", "''" $userDataFolder = Join-Path $env:LOCALAPPDATA 'PoshPresenter\WebView2' $ps51 = @" try { Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing Add-Type -Path '$escapedDll\Microsoft.Web.WebView2.Core.dll' Add-Type -Path '$escapedDll\Microsoft.Web.WebView2.WinForms.dll' [System.Windows.Forms.Application]::EnableVisualStyles() `$userDataFolder = '$($userDataFolder -replace "'", "''")' if (-not (Test-Path `$userDataFolder)) { New-Item -Path `$userDataFolder -ItemType Directory -Force | Out-Null } `$form = New-Object System.Windows.Forms.Form `$form.Text = '$escapedTitle — PoshPresenter' `$form.Width = 1280; `$form.Height = 800 `$form.StartPosition = 'CenterScreen' `$form.BackColor = [System.Drawing.Color]::FromArgb(10,10,10) `$wv = New-Object Microsoft.Web.WebView2.WinForms.WebView2 `$wv.Dock = 'Fill' `$wv.DefaultBackgroundColor = [System.Drawing.Color]::FromArgb(10,10,10) `$creationProps = New-Object Microsoft.Web.WebView2.WinForms.CoreWebView2CreationProperties `$creationProps.UserDataFolder = `$userDataFolder `$wv.CreationProperties = `$creationProps `$form.Controls.Add(`$wv) `$wv.Add_CoreWebView2InitializationCompleted({ param(`$sender, `$e) if (`$e.IsSuccess) { `$wv.CoreWebView2.Navigate('$escapedUrl') `$wv.CoreWebView2.add_ContainsFullScreenElementChanged({ if (`$wv.CoreWebView2.ContainsFullScreenElement) { `$form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::None `$form.WindowState = [System.Windows.Forms.FormWindowState]::Maximized } else { `$form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::Sizable `$form.WindowState = [System.Windows.Forms.FormWindowState]::Normal } }) } }) `$form.Add_Shown({ `$wv.EnsureCoreWebView2Async(`$null) }) `$form.Add_FormClosing({ try { `$wv.Dispose() } catch {} }) [System.Windows.Forms.Application]::Run(`$form) } catch { [System.Windows.Forms.MessageBox]::Show(`$_.Exception.Message, 'PoshPresenter Error') } "@ $tempScript = Join-Path $env:TEMP "PoshPresenter_$(Get-Random).ps1" $ps51 | Set-Content -Path $tempScript -Encoding UTF8 Start-Process powershell.exe -ArgumentList @( '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$tempScript`"" ) return } } # Option 2: PoshWebView.exe bundled with the module $webviewExe = Join-Path $PSScriptRoot 'PoshWebView.exe' if (Test-Path $webviewExe) { Write-Host " Window : PoshWebView.exe" -ForegroundColor DarkGray Start-Process -FilePath $webviewExe -ArgumentList $Url, $Title, '1280', '800' return } # Option 3: Browser fallback (least preferred) Write-Host " Window : Default browser (install PoshDE for WebView2)" -ForegroundColor Yellow Start-Process $Url } function Find-FreePort { $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) $listener.Start() $port = $listener.LocalEndpoint.Port $listener.Stop() return $port } # ────────────────────────────────────────────────────────────────────────────── # Start-PoshDemo # Launches the built-in interactive demo that teaches users how to build slides. # ────────────────────────────────────────────────────────────────────────────── function Start-PoshDemo { [CmdletBinding()] param( [switch]$Presenter, [switch]$Portable, [switch]$NoWindow ) $demoPath = Join-Path $PSScriptRoot 'Demo' if (-not (Test-Path $demoPath)) { throw "Demo folder not found at: $demoPath`nThis may indicate a corrupted module installation." } Write-Host "" Write-Host "PoshPresenter" -ForegroundColor Magenta -NoNewline Write-Host " | Launching built-in demo..." -ForegroundColor DarkGray Start-PoshPresenter -Path $demoPath -Presenter:$Presenter -Portable:$Portable -NoWindow:$NoWindow } # ── Exports ── Export-ModuleMember -Function Start-PoshPresenter, Stop-PoshPresenter, New-PoshPresentation, Start-PoshDemo |