PoshWallpaper.psm1
|
# PoshWallpaper - Multi-monitor animated wallpaper manager # Part of the PoshDE ecosystem $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 $script:ConfigDir = Join-Path $env:APPDATA "PoshDE\PoshWallpaper" $script:ConfigFile = Join-Path $script:ConfigDir "config.json" $script:UserWallpapersDir = Join-Path ([Environment]::GetFolderPath('MyDocuments')) "PoshWallpaper\Wallpapers" if (-not (Get-Module -ListAvailable -Name PoshDE)) { Write-Error "PoshWallpaper requires PoshDE. Install it first." return } Import-Module PoshDE -ErrorAction Stop # ============================================================================ # PS5.1 launcher — written to disk per-monitor, runs WebView2 fullscreen # ============================================================================ $script:LauncherScript = @' param($HtmlFile, $X, $Y, $Width, $Height, $DllPath, $MonitorIndex) Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing [System.Reflection.Assembly]::LoadFrom("$DllPath\Microsoft.Web.WebView2.Core.dll") | Out-Null [System.Reflection.Assembly]::LoadFrom("$DllPath\Microsoft.Web.WebView2.WinForms.dll") | Out-Null $userDataFolder = "$env:LOCALAPPDATA\PoshWallpaper\WebView2_Monitor$MonitorIndex" New-Item -Path $userDataFolder -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null Add-Type @" using System; using System.Runtime.InteropServices; public class WallpaperApi { [DllImport("user32.dll")] public static extern int GetWindowLong(IntPtr hwnd, int index); [DllImport("user32.dll")] public static extern int SetWindowLong(IntPtr hwnd, int index, int value); [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); public static readonly IntPtr HWND_BOTTOM = new IntPtr(1); public const int GWL_EXSTYLE = -20; public const int WS_EX_TOOLWINDOW = 0x80; public const int WS_EX_NOACTIVATE = 0x08000000; public const uint SWP_NOMOVE = 0x0002; public const uint SWP_NOSIZE = 0x0001; public const uint SWP_NOACTIVATE = 0x0010; } "@ $form = New-Object System.Windows.Forms.Form $form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::None $form.StartPosition = [System.Windows.Forms.FormStartPosition]::Manual $form.Location = New-Object System.Drawing.Point($X, $Y) $form.Size = New-Object System.Drawing.Size($Width, $Height) $form.BackColor = [System.Drawing.Color]::Black $form.ShowInTaskbar = $false $form.TopMost = $false $webView = New-Object Microsoft.Web.WebView2.WinForms.WebView2 $webView.Dock = [System.Windows.Forms.DockStyle]::Fill $webView.DefaultBackgroundColor = [System.Drawing.Color]::Black $webView.CreationProperties = New-Object Microsoft.Web.WebView2.WinForms.CoreWebView2CreationProperties $webView.CreationProperties.UserDataFolder = $userDataFolder $form.Controls.Add($webView) $form.Add_Load({ $hwnd = $form.Handle $exStyle = [WallpaperApi]::GetWindowLong($hwnd, [WallpaperApi]::GWL_EXSTYLE) [WallpaperApi]::SetWindowLong($hwnd, [WallpaperApi]::GWL_EXSTYLE, $exStyle -bor [WallpaperApi]::WS_EX_TOOLWINDOW -bor [WallpaperApi]::WS_EX_NOACTIVATE) [WallpaperApi]::SetWindowPos($hwnd, [WallpaperApi]::HWND_BOTTOM, 0, 0, 0, 0, ([WallpaperApi]::SWP_NOMOVE -bor [WallpaperApi]::SWP_NOSIZE -bor [WallpaperApi]::SWP_NOACTIVATE)) }) $webView.Add_CoreWebView2InitializationCompleted({ param($sender, $e) if ($e.IsSuccess) { $webView.CoreWebView2.Settings.IsStatusBarEnabled = $false $webView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = $false $webView.CoreWebView2.Settings.AreDevToolsEnabled = $false $fileUrl = "file:///" + $HtmlFile.Replace('\', '/') $webView.CoreWebView2.Navigate($fileUrl) } }) $form.Add_Shown({ $webView.EnsureCoreWebView2Async($null) }) [System.Windows.Forms.Application]::Run($form) '@ # ============================================================================ # Config helpers # ============================================================================ function Initialize-PoshWallpaperConfig { if (-not (Test-Path $script:ConfigDir)) { New-Item -Path $script:ConfigDir -ItemType Directory -Force | Out-Null } if (-not (Test-Path $script:UserWallpapersDir)) { New-Item -Path $script:UserWallpapersDir -ItemType Directory -Force | Out-Null } } function Import-PoshWallpaperConfig { Initialize-PoshWallpaperConfig $cfg = @{ Assignments = @{}; Slideshow = @{} } if (Test-Path $script:ConfigFile) { try { $json = Get-Content $script:ConfigFile -Raw | ConvertFrom-Json if ($json.Assignments) { $json.Assignments.PSObject.Properties | ForEach-Object { $cfg.Assignments[$_.Name] = $_.Value } } } catch { } } return $cfg } # ============================================================================ # Public: Get-PoshMonitors # ============================================================================ function Get-PoshMonitors { <# .SYNOPSIS List all connected monitors with size, position, and wallpaper status. .EXAMPLE Get-PoshMonitors #> [CmdletBinding()] param() Add-Type -AssemblyName System.Windows.Forms $i = 0 foreach ($screen in [System.Windows.Forms.Screen]::AllScreens) { $key = "$i" $running = $false $current = $null if ($script:SharedState) { if ($script:SharedState.WallpaperProcesses.ContainsKey($key)) { $p = $script:SharedState.WallpaperProcesses[$key] $running = ($p -and -not $p.HasExited) } $current = $script:SharedState.Config.Assignments[$key] } [PSCustomObject]@{ Index = $i DeviceName = $screen.DeviceName X = $screen.Bounds.X Y = $screen.Bounds.Y Width = $screen.Bounds.Width Height = $screen.Bounds.Height Primary = $screen.Primary Running = $running CurrentWallpaper = $current } $i++ } } # ============================================================================ # Public: Get-PoshWallpapers # ============================================================================ function Get-PoshWallpapers { <# .SYNOPSIS List all available wallpapers (built-in + user-added). .DESCRIPTION Built-in wallpapers live in the module's Wallpapers\ folder. Each wallpaper is a subfolder containing an HTML file with the same name, plus any assets it needs (images, JSON, etc.). Example: Wallpapers\MatrixRain\MatrixRain.html User wallpapers live in: $env:APPDATA\PoshDE\PoshWallpaper\Wallpapers\ Drop a folder there with a matching HTML file and it appears automatically. This folder survives module updates. .EXAMPLE Get-PoshWallpapers #> [CmdletBinding()] param() Initialize-PoshWallpaperConfig $seen = @{} $builtinDir = Join-Path $script:ModuleRoot "Wallpapers" foreach ($dir in @($builtinDir, $script:UserWallpapersDir)) { if (-not (Test-Path $dir)) { continue } foreach ($folder in (Get-ChildItem $dir -Directory -ErrorAction SilentlyContinue)) { if ($seen.ContainsKey($folder.Name)) { continue } $htmlFile = Join-Path $folder.FullName "$($folder.Name).html" if (-not (Test-Path $htmlFile)) { continue } $seen[$folder.Name] = $true $content = Get-Content $htmlFile -Raw -ErrorAction SilentlyContinue $description = if ($content -match '<!--\s*Description:\s*(.+?)\s*-->') { $Matches[1] } else { "Animated wallpaper" } $tags = if ($content -match '<!--\s*Tags:\s*(.+?)\s*-->') { $Matches[1] -split '\s*,\s*' } else { @() } $source = if ($dir -eq $builtinDir) { 'builtin' } else { 'user' } [PSCustomObject]@{ Name = $folder.Name Description = $description Tags = $tags Path = $htmlFile Source = $source } } } } # ============================================================================ # Public: Start-PoshWallpaper (CLI) # ============================================================================ function Start-PoshWallpaper { <# .SYNOPSIS Start a wallpaper on one or all monitors from the CLI. .PARAMETER Name Wallpaper name. .PARAMETER Monitor Monitor index (default 0). .PARAMETER AllMonitors Apply to every monitor. .EXAMPLE Start-PoshWallpaper -Name "MatrixRain" .EXAMPLE Start-PoshWallpaper -Name "SynthwaveGrid" -AllMonitors #> [CmdletBinding(DefaultParameterSetName='Single')] param( [Parameter(Mandatory)] [string]$Name, [Parameter(ParameterSetName='Single')] [int]$Monitor = 0, [Parameter(ParameterSetName='All')] [switch]$AllMonitors ) $dllPath = Get-WebView2DllPath if (-not $dllPath) { Write-Error "WebView2 not ready."; return } if (-not $script:SharedState) { $script:SharedState = [hashtable]::Synchronized(@{ Running = $false WallpaperProcesses = [hashtable]::Synchronized(@{}) Config = Import-PoshWallpaperConfig DllPath = $dllPath ModuleRoot = $script:ModuleRoot UserWallpapersDir = $script:UserWallpapersDir LauncherScript = $script:LauncherScript }) } Add-Type -AssemblyName System.Windows.Forms $targets = if ($AllMonitors) { 0..([System.Windows.Forms.Screen]::AllScreens.Count - 1) } else { @($Monitor) } foreach ($idx in $targets) { $key = "$idx" # Find wallpaper — look for Name\Name.html in built-in then user dir $htmlPath = $null $builtinDir = Join-Path $script:ModuleRoot "Wallpapers" foreach ($dir in @($builtinDir, $script:UserWallpapersDir)) { $c = Join-Path $dir "$Name\$Name.html" if (Test-Path $c) { $htmlPath = $c; break } } if (-not $htmlPath) { Write-Warning "Wallpaper not found: $Name"; continue } # Kill existing if ($script:SharedState.WallpaperProcesses.ContainsKey($key)) { $old = $script:SharedState.WallpaperProcesses[$key] if ($old -and -not $old.HasExited) { try { $old.Kill(); $old.WaitForExit(3000) } catch { } } $script:SharedState.WallpaperProcesses.Remove($key) } $b = [System.Windows.Forms.Screen]::AllScreens[$idx].Bounds $launcherPath = Join-Path $env:TEMP "PoshWallpaper_M$idx.ps1" $script:LauncherScript | Set-Content $launcherPath -Encoding UTF8 -Force $argStr = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden" + " -File `"$launcherPath`"" + " -HtmlFile `"$htmlPath`"" + " -X $($b.X) -Y $($b.Y) -Width $($b.Width) -Height $($b.Height)" + " -DllPath `"$dllPath`"" + " -MonitorIndex $idx" $proc = Start-Process -FilePath "powershell.exe" -ArgumentList $argStr -PassThru -WindowStyle Hidden if ($proc) { $script:SharedState.WallpaperProcesses[$key] = $proc $script:SharedState.Config.Assignments[$key] = $Name # Persist $toSave = @{ Assignments = $script:SharedState.Config.Assignments; Slideshow = @{} } try { $toSave | ConvertTo-Json -Depth 5 | Set-Content $script:ConfigFile -Encoding UTF8 } catch { } Write-Host " [+] Monitor $idx $Name (PID $($proc.Id))" -ForegroundColor Green } else { Write-Warning "Failed to launch wallpaper on monitor $idx" } } } # ============================================================================ # Public: Stop-PoshWallpaper (CLI) # ============================================================================ function Stop-PoshWallpaper { <# .SYNOPSIS Stop wallpaper on one or all monitors. .PARAMETER Monitor Monitor index. Omit to stop all. .EXAMPLE Stop-PoshWallpaper .EXAMPLE Stop-PoshWallpaper -Monitor 1 #> [CmdletBinding()] param([int]$Monitor = -1) if (-not $script:SharedState) { Write-Host "No wallpapers running." -ForegroundColor DarkGray; return } $keys = if ($Monitor -ge 0) { @("$Monitor") } else { @($script:SharedState.WallpaperProcesses.Keys) } foreach ($key in $keys) { if ($script:SharedState.WallpaperProcesses.ContainsKey($key)) { $p = $script:SharedState.WallpaperProcesses[$key] if ($p -and -not $p.HasExited) { try { $p.Kill(); $p.WaitForExit(3000) } catch { } } $script:SharedState.WallpaperProcesses.Remove($key) $script:SharedState.Config.Assignments.Remove($key) Write-Host " [-] Monitor $key stopped" -ForegroundColor Yellow } } $toSave = @{ Assignments = $script:SharedState.Config.Assignments; Slideshow = @{} } try { $toSave | ConvertTo-Json -Depth 5 | Set-Content $script:ConfigFile -Encoding UTF8 } catch { } } # ============================================================================ # Public: Restore-PoshWallpaperState # ============================================================================ function Restore-PoshWallpaperState { <# .SYNOPSIS Reapply all saved wallpaper assignments. .EXAMPLE Restore-PoshWallpaperState #> [CmdletBinding()] param() $cfg = Import-PoshWallpaperConfig if ($cfg.Assignments.Count -eq 0) { Write-Host " Nothing to restore." -ForegroundColor DarkGray; return } foreach ($key in $cfg.Assignments.Keys) { Start-PoshWallpaper -Name $cfg.Assignments[$key] -Monitor ([int]$key) } } # ============================================================================ # HTTP Server — background runspace, handles all API calls # Start-Process works fine in a runspace — no queue/watcher needed # ============================================================================ $script:ServerScript = { param( [int] $Port, [string] $PublicPath, [string] $PoshDEPath, [hashtable] $SharedState ) Import-Module $PoshDEPath -ErrorAction Stop 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-Text { param($Ctx, [string]$Text, [string]$ContentType = 'text/plain; charset=utf-8', [int]$Code = 200) $buf = [System.Text.Encoding]::UTF8.GetBytes($Text) $Ctx.Response.StatusCode = $Code $Ctx.Response.ContentType = $ContentType $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") $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 } function Get-WallpaperList { $seen = @{} $list = [System.Collections.Generic.List[object]]::new() $builtinDir = Join-Path $SharedState.ModuleRoot "Wallpapers" foreach ($dir in @($builtinDir, $SharedState.UserWallpapersDir)) { if (-not (Test-Path $dir)) { continue } foreach ($folder in (Get-ChildItem $dir -Directory -ErrorAction SilentlyContinue)) { if ($seen.ContainsKey($folder.Name)) { continue } $htmlFile = Join-Path $folder.FullName "$($folder.Name).html" if (-not (Test-Path $htmlFile)) { continue } $seen[$folder.Name] = $true $content = Get-Content $htmlFile -Raw -ErrorAction SilentlyContinue $description = if ($content -match '<!--\s*Description:\s*(.+?)\s*-->') { $Matches[1] } else { "Animated wallpaper" } $tags = if ($content -match '<!--\s*Tags:\s*(.+?)\s*-->') { @($Matches[1] -split '\s*,\s*') } else { @() } $source = if ($dir -eq $builtinDir) { 'builtin' } else { 'user' } $list.Add(@{ name=$folder.Name; description=$description; tags=$tags; path=$htmlFile; source=$source }) } } return @($list) } function Resolve-WallpaperPath { param([string]$Name) $builtinDir = Join-Path $SharedState.ModuleRoot "Wallpapers" foreach ($dir in @($builtinDir, $SharedState.UserWallpapersDir)) { $c = Join-Path $dir "$Name\$Name.html" if (Test-Path $c) { return $c } } return $null } function Get-MonitorList { Add-Type -AssemblyName System.Windows.Forms $list = [System.Collections.Generic.List[object]]::new() $i = 0 foreach ($s in [System.Windows.Forms.Screen]::AllScreens) { $key = "$i" $running = $false $current = $null if ($SharedState.WallpaperProcesses.ContainsKey($key)) { $p = $SharedState.WallpaperProcesses[$key] if ($p -and -not $p.HasExited) { $running = $true } } if ($SharedState.Config.Assignments.ContainsKey($key)) { $current = $SharedState.Config.Assignments[$key] } $sv = $null if ($SharedState.Config.Slideshow.ContainsKey($key)) { $v = $SharedState.Config.Slideshow[$key] $sv = @{ enabled=$v.Enabled; intervalMinutes=$v.IntervalMinutes; shuffle=$v.Shuffle } } $list.Add(@{ index=$i; deviceName=$s.DeviceName x=$s.Bounds.X; y=$s.Bounds.Y; width=$s.Bounds.Width; height=$s.Bounds.Height primary=$s.Primary; running=$running; currentWallpaper=$current; slideshow=$sv }) $i++ } return @($list) } # Apply a wallpaper directly — Start-Process works fine in a runspace function Invoke-Apply { param([int]$MonitorIndex, [string]$WallpaperName) $key = "$MonitorIndex" $htmlPath = Resolve-WallpaperPath -Name $WallpaperName if (-not $htmlPath) { return @{ success=$false; error="Wallpaper not found: $WallpaperName" } } # Kill existing on this monitor if ($SharedState.WallpaperProcesses.ContainsKey($key)) { $old = $SharedState.WallpaperProcesses[$key] if ($old -and -not $old.HasExited) { try { $old.Kill(); $old.WaitForExit(3000) } catch { } } $SharedState.WallpaperProcesses.Remove($key) } Add-Type -AssemblyName System.Windows.Forms $screens = [System.Windows.Forms.Screen]::AllScreens if ($MonitorIndex -ge $screens.Count) { return @{ success=$false; error="Monitor $MonitorIndex not found" } } $b = $screens[$MonitorIndex].Bounds $launcherPath = Join-Path $env:TEMP "PoshWallpaper_M$MonitorIndex.ps1" $SharedState.LauncherScript | Set-Content $launcherPath -Encoding UTF8 -Force # MUST be a single string — array breaks on negative X (e.g. -1920 parsed as a flag) $argStr = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden" + " -File `"$launcherPath`"" + " -HtmlFile `"$htmlPath`"" + " -X $($b.X) -Y $($b.Y) -Width $($b.Width) -Height $($b.Height)" + " -DllPath `"$($SharedState.DllPath)`"" + " -MonitorIndex $MonitorIndex" $proc = Start-Process -FilePath "powershell.exe" -ArgumentList $argStr -PassThru -WindowStyle Hidden if (-not $proc) { return @{ success=$false; error="Start-Process returned null" } } $SharedState.WallpaperProcesses[$key] = $proc $SharedState.Config.Assignments[$key] = $WallpaperName $toSave = @{ Assignments=$SharedState.Config.Assignments; Slideshow=@{} } foreach ($sk in $SharedState.Config.Slideshow.Keys) { $v = $SharedState.Config.Slideshow[$sk] $toSave.Slideshow[$sk] = @{ Enabled=$v.Enabled; IntervalMinutes=$v.IntervalMinutes; Shuffle=$v.Shuffle } } $cfgFile = Join-Path $env:APPDATA "PoshDE\PoshWallpaper\config.json" try { $toSave | ConvertTo-Json -Depth 5 | Set-Content $cfgFile -Encoding UTF8 } catch { } return @{ success=$true; pid=$proc.Id } } function Invoke-Stop { param([int]$MonitorIndex) $key = "$MonitorIndex" if ($SharedState.WallpaperProcesses.ContainsKey($key)) { $p = $SharedState.WallpaperProcesses[$key] if ($p -and -not $p.HasExited) { try { $p.Kill(); $p.WaitForExit(3000) } catch { } } $SharedState.WallpaperProcesses.Remove($key) } $SharedState.Config.Assignments.Remove($key) $toSave = @{ Assignments=$SharedState.Config.Assignments; Slideshow=@{} } foreach ($sk in $SharedState.Config.Slideshow.Keys) { $v = $SharedState.Config.Slideshow[$sk] $toSave.Slideshow[$sk] = @{ Enabled=$v.Enabled; IntervalMinutes=$v.IntervalMinutes; Shuffle=$v.Shuffle } } $cfgFile = Join-Path $env:APPDATA "PoshDE\PoshWallpaper\config.json" try { $toSave | ConvertTo-Json -Depth 5 | Set-Content $cfgFile -Encoding UTF8 } catch { } } function Invoke-SlideshowTick { $now = Get-Date foreach ($key in @($SharedState.Config.Slideshow.Keys)) { $sv = $SharedState.Config.Slideshow[$key] if (-not $sv.Enabled) { continue } if (($now - $sv.LastChanged).TotalMinutes -lt $sv.IntervalMinutes) { continue } $all = @(Get-WallpaperList) if ($all.Count -eq 0) { continue } $next = if ($sv.Shuffle) { ($all | Get-Random).name } else { $names = @($all | ForEach-Object { $_.name }) $idx = [array]::IndexOf($names, $SharedState.Config.Assignments[$key]) $names[($idx + 1) % $names.Count] } Invoke-Apply -MonitorIndex ([int]$key) -WallpaperName $next | Out-Null $sv.LastChanged = $now } } # ── 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 } } Invoke-SlideshowTick 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 } if ($urlPath -eq '/' -or $urlPath -eq '/index.html') { Send-File $ctx (Join-Path $SharedState.ModuleRoot 'index.html') 'text/html; charset=utf-8' } elseif ($urlPath -eq '/api/theme') { $t = Get-PoshTheme Send-Json $ctx @{ name=$t.Name; css=(Export-PoshThemeCSS); colors=$t.Colors } } elseif ($urlPath -eq '/api/monitors') { Send-Json $ctx @{ monitors = @(Get-MonitorList) } } elseif ($urlPath -eq '/api/wallpapers') { Send-Json $ctx @{ wallpapers = @(Get-WallpaperList) } } elseif ($urlPath -match '^/api/wallpaper/(.+)$') { $name = [Uri]::UnescapeDataString($Matches[1]) $path = Resolve-WallpaperPath -Name $name if ($path) { Send-Text $ctx (Get-Content $path -Raw -Encoding UTF8) 'text/html; charset=utf-8' } else { Send-Json $ctx @{ error="Not found: $name" } -Code 404 } } elseif ($urlPath -eq '/api/apply' -and $req.HttpMethod -eq 'POST') { $body = Read-JsonBody $req $name = [string]$body.wallpaperName $idx = [int]$body.monitorIndex if ($idx -eq -1) { Add-Type -AssemblyName System.Windows.Forms $count = [System.Windows.Forms.Screen]::AllScreens.Count $results = @() for ($m = 0; $m -lt $count; $m++) { $r = Invoke-Apply -MonitorIndex $m -WallpaperName $name $results += @{ monitor=$m; success=$r.success; error=$r.error } } Send-Json $ctx @{ results = $results } } else { $r = Invoke-Apply -MonitorIndex $idx -WallpaperName $name Send-Json $ctx @{ success=$r.success; monitor=$idx; wallpaper=$name; error=$r.error; pid=$r.pid } } } elseif ($urlPath -eq '/api/stop' -and $req.HttpMethod -eq 'POST') { $body = Read-JsonBody $req $idx = [int]$body.monitorIndex if ($idx -eq -1) { foreach ($k in @($SharedState.WallpaperProcesses.Keys)) { Invoke-Stop -MonitorIndex ([int]$k) } Send-Json $ctx @{ success=$true; stopped='all' } } else { Invoke-Stop -MonitorIndex $idx Send-Json $ctx @{ success=$true; monitor=$idx } } } elseif ($urlPath -eq '/api/slideshow' -and $req.HttpMethod -eq 'POST') { $body = Read-JsonBody $req $key = "$([int]$body.monitorIndex)" $sv = if ($SharedState.Config.Slideshow.ContainsKey($key)) { $SharedState.Config.Slideshow[$key] } else { @{ Enabled=$false; IntervalMinutes=15; Shuffle=$false; LastChanged=[datetime]::MinValue } } if ($null -ne $body.enabled) { $sv.Enabled = [bool]$body.enabled } if ($null -ne $body.intervalMinutes) { $sv.IntervalMinutes = [int]$body.intervalMinutes } if ($null -ne $body.shuffle) { $sv.Shuffle = [bool]$body.shuffle } $sv.LastChanged = [datetime]::MinValue $SharedState.Config.Slideshow[$key] = $sv Send-Json $ctx @{ success=$true } } else { 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() } } # ============================================================================ # Public: Open-PoshWallpaper # ============================================================================ function Open-PoshWallpaper { <# .SYNOPSIS Open the PoshWallpaper GUI manager. .PARAMETER NoWindow Start HTTP server only (no WebView2 window). .EXAMPLE Open-PoshWallpaper #> [CmdletBinding()] param([switch]$NoWindow) if ($script:SharedState -and $script:SharedState.Running) { Write-Host " PoshWallpaper already running on port $script:ActivePort" -ForegroundColor Yellow return } $dllPath = Get-WebView2DllPath if (-not $dllPath) { Write-Error "WebView2 not ready. Run Install-PoshDependencies."; return } $port = Register-PoshPort -App "PoshWallpaper" if (-not $port) { Write-Error "Could not allocate port."; return } $script:ActivePort = $port $script:SharedState = [hashtable]::Synchronized(@{ Running = $true Ready = $false Version = $script:Version WallpaperProcesses = [hashtable]::Synchronized(@{}) Config = Import-PoshWallpaperConfig DllPath = $dllPath ModuleRoot = $script:ModuleRoot UserWallpapersDir = $script:UserWallpapersDir LauncherScript = $script:LauncherScript }) $script:ServerRunspace = [runspacefactory]::CreateRunspace() $script:ServerRunspace.ApartmentState = 'STA' $script:ServerRunspace.ThreadOptions = 'ReuseThread' $script:ServerRunspace.Open() $script:ServerPowerShell = [PowerShell]::Create() $script:ServerPowerShell.Runspace = $script:ServerRunspace $script:ServerPowerShell.AddScript($script:ServerScript) | Out-Null $script:ServerPowerShell.AddParameter('Port', $port) | Out-Null $script:ServerPowerShell.AddParameter('PublicPath', $script:ModuleRoot) | Out-Null $script:ServerPowerShell.AddParameter('PoshDEPath', (Get-Module PoshDE).ModuleBase) | Out-Null $script:ServerPowerShell.AddParameter('SharedState', $script:SharedState) | Out-Null $script:ServerAsyncResult = $script:ServerPowerShell.BeginInvoke() $waited = 0 while (-not $script:SharedState.Ready -and $waited -lt 5000) { Start-Sleep -Milliseconds 100; $waited += 100 } Write-Host "" Write-Host " ╔══════════════════════════════════════╗" -ForegroundColor Magenta Write-Host " ║ 🖼 PoshWallpaper v$script:Version ║" -ForegroundColor Magenta Write-Host " ╚══════════════════════════════════════╝" -ForegroundColor Magenta Write-Host " [*] Port $port" -ForegroundColor Yellow Write-Host " [*] Theme $((Get-PoshTheme).Name)" -ForegroundColor Cyan Write-Host " [*] Monitors $(@(Get-PoshMonitors).Count) detected" -ForegroundColor Cyan Write-Host "" if (-not $NoWindow) { $script:WindowProcess = New-PoshWindow -AppName "PoshWallpaper" -Port $port -Width 1280 -Height 820 if ($script:WindowProcess) { Update-PoshPortPID -Port $port -PID $script:WindowProcess.Id Write-Host " [+] Window PID $($script:WindowProcess.Id)" -ForegroundColor Green } } else { Write-Host " [*] Server http://localhost:$port" -ForegroundColor Yellow } Write-Host "" Write-Host " Run Stop-PoshWallpaperManager to close." -ForegroundColor DarkGray Write-Host "" } # ============================================================================ # Public: Stop-PoshWallpaperManager # ============================================================================ function Stop-PoshWallpaperManager { <# .SYNOPSIS Close the GUI manager. Wallpapers keep running unless -StopWallpapers. .EXAMPLE Stop-PoshWallpaperManager .EXAMPLE Stop-PoshWallpaperManager -StopWallpapers #> [CmdletBinding()] param([switch]$StopWallpapers) Write-Host " Stopping PoshWallpaper manager..." -ForegroundColor Yellow if ($StopWallpapers -and $script:SharedState) { foreach ($k in @($script:SharedState.WallpaperProcesses.Keys)) { $p = $script:SharedState.WallpaperProcesses[$k] if ($p -and -not $p.HasExited) { try { $p.Kill() } catch { } } } } if ($script:SharedState) { $script:SharedState.Running = $false } if ($script:WindowProcess -and -not $script:WindowProcess.HasExited) { $script:WindowProcess.Kill(); $script:WindowProcess.WaitForExit(3000) } 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() } if ($script:ActivePort) { Unregister-PoshPort -App "PoshWallpaper" } $script:ActivePort = $null $script:WindowProcess = $null $script:ServerRunspace = $null $script:ServerPowerShell = $null $script:ServerAsyncResult = $null $script:SharedState = $null Write-Host " Done." -ForegroundColor Green } # ============================================================================ # Public: Get-PoshWallpaperStatus # ============================================================================ function Get-PoshWallpaperStatus { <# .SYNOPSIS Get current status of all wallpapers. .EXAMPLE Get-PoshWallpaperStatus #> [CmdletBinding()] param() [PSCustomObject]@{ ManagerRunning = ($script:SharedState -and $script:SharedState.Running) Port = $script:ActivePort Wallpapers = if ($script:SharedState) { $script:SharedState.WallpaperProcesses.Keys | ForEach-Object { $p = $script:SharedState.WallpaperProcesses[$_] [PSCustomObject]@{ Monitor = [int]$_ Wallpaper = $script:SharedState.Config.Assignments[$_] Running = ($p -and -not $p.HasExited) PID = if ($p) { $p.Id } else { $null } } } } else { @() } } } # ============================================================================ # Module init banner # ============================================================================ Write-Host "" Write-Host " PoshWallpaper v$script:Version" -ForegroundColor Magenta Write-Host " [+] PoshDE Connected" -ForegroundColor Green Write-Host " [*] Monitors $(@(Get-PoshMonitors).Count) detected" -ForegroundColor Cyan Write-Host " [*] Wallpapers $(@(Get-PoshWallpapers).Count) available" -ForegroundColor Cyan Write-Host " [*] User dir $script:UserWallpapersDir" -ForegroundColor DarkGray Write-Host "" # ============================================================================ # Exports # ============================================================================ Export-ModuleMember -Function @( 'Open-PoshWallpaper' 'Stop-PoshWallpaperManager' 'Start-PoshWallpaper' 'Stop-PoshWallpaper' 'Get-PoshWallpapers' 'Get-PoshMonitors' 'Get-PoshWallpaperStatus' 'Restore-PoshWallpaperState' ) |