param( [string]$Arg1 = "", [string]$Arg2 = "" ) # Set console encoding to UTF-8 [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 $code = @" using System; using System.Drawing; using System.Drawing.Imaging; using System.Runtime.InteropServices; using System.IO; public class ScreenCapture { [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; } [DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr hWnd, IntPtr hdcBlt, int nFlags); [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); [DllImport("user32.dll")] public static extern uint MapVirtualKey(uint uCode, uint uMapType); [DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc enumProc, IntPtr lParam); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd); [DllImport("user32.dll", CharSet = CharSet.Auto)] public static extern int GetWindowTextLength(IntPtr hWnd); [DllImport("user32.dll", CharSet = CharSet.Auto)] public static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder lpString, int nMaxCount); [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); public static System.Collections.Generic.List GetProcessWindows(uint processId) { System.Collections.Generic.List windows = new System.Collections.Generic.List(); EnumWindows(delegate(IntPtr hWnd, IntPtr lParam) { uint id; GetWindowThreadProcessId(hWnd, out id); if (id == processId && IsWindowVisible(hWnd) && GetWindowTextLength(hWnd) > 0) { windows.Add(hWnd); } return true; }, IntPtr.Zero); return windows; } public static string GetWindowTextStr(IntPtr hWnd) { int length = GetWindowTextLength(hWnd); if (length == 0) return ""; System.Text.StringBuilder builder = new System.Text.StringBuilder(length + 1); GetWindowText(hWnd, builder, builder.Capacity); return builder.ToString(); } public const int SW_RESTORE = 9; public const int SW_SHOWNOACTIVATE = 4; public const byte VK_LEFT = 0x25; public const byte VK_RIGHT = 0x27; public const uint KEYEVENTF_KEYUP = 0x0002; public const uint WM_KEYDOWN = 0x0100; public const uint WM_KEYUP = 0x0101; public static readonly IntPtr HWND_NOTOPMOST = new IntPtr(-2); public const uint SWP_NOSIZE = 0x0001; public const uint SWP_NOMOVE = 0x0002; public const uint SWP_NOACTIVATE = 0x0010; public static void SendKeyPress(byte key) { keybd_event(key, 0, 0, UIntPtr.Zero); System.Threading.Thread.Sleep(50); keybd_event(key, 0, KEYEVENTF_KEYUP, UIntPtr.Zero); } private static IntPtr MakeKeyLParam(byte key, bool isKeyUp) { uint scanCode = MapVirtualKey(key, 0); uint lParam = 1 | (scanCode << 16); if (isKeyUp) { lParam |= (1u << 30); lParam |= (1u << 31); } return (IntPtr)unchecked((int)lParam); } public static void SendKeyToWindow(IntPtr hWnd, byte key) { PostMessage(hWnd, WM_KEYDOWN, (IntPtr)key, MakeKeyLParam(key, false)); System.Threading.Thread.Sleep(50); PostMessage(hWnd, WM_KEYUP, (IntPtr)key, MakeKeyLParam(key, true)); } public static void SetNotTopMost(IntPtr hWnd) { SetWindowPos(hWnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); } public static string CaptureWindow(IntPtr hWnd) { // 如果窗口最小化了,则先将其恢复,但不激活(不抢占焦点) if (IsIconic(hWnd)) { ShowWindow(hWnd, SW_SHOWNOACTIVATE); // 稍微等待一下让窗口恢复 System.Threading.Thread.Sleep(100); } RECT rc; GetWindowRect(hWnd, out rc); int width = rc.Right - rc.Left; int height = rc.Bottom - rc.Top; if(width <= 0 || height <= 0) return null; Bitmap bmp = new Bitmap(width, height, PixelFormat.Format32bppArgb); Graphics gfxBmp = Graphics.FromImage(bmp); IntPtr hdcBitmap = gfxBmp.GetHdc(); PrintWindow(hWnd, hdcBitmap, 2); gfxBmp.ReleaseHdc(hdcBitmap); gfxBmp.Dispose(); using (MemoryStream ms = new MemoryStream()) { // Compress heavily for faster network EncoderParameters encoderParameters = new EncoderParameters(1); encoderParameters.Param[0] = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality, 50L); ImageCodecInfo jpegCodec = GetEncoder(ImageFormat.Jpeg); bmp.Save(ms, jpegCodec, encoderParameters); byte[] byteImage = ms.ToArray(); bmp.Dispose(); return Convert.ToBase64String(byteImage); } } private static ImageCodecInfo GetEncoder(ImageFormat format) { ImageCodecInfo[] codecs = ImageCodecInfo.GetImageDecoders(); foreach (ImageCodecInfo codec in codecs) { if (codec.FormatID == format.Guid) { return codec; } } return null; } } "@ try { Add-Type -TypeDefinition $code -ReferencedAssemblies System.Drawing, System.Windows.Forms -ErrorAction SilentlyContinue } catch { Write-Host "Warning: Could not compile ScreenCapture class" -ForegroundColor Yellow } # --- Configuration Logic --- $port = 3001 $currentDir = (Get-Location).Path $root = "" # Parse arguments logic similar to the requested CMD # Logic: If Arg1 is numeric, it's the port. Otherwise it might be the path (or ignored based on CMD logic). # If Arg2 is present, it's the path. if ($Arg1 -match "^\d+$") { $port = [int]$Arg1 } elseif ($Arg1 -ne "") { # If first arg is not a number, assume it is the root path (enhancement over original CMD) $root = $Arg1 } if ($Arg2 -ne "") { $root = $Arg2 } # Auto-detect root if not specified if ($root -eq "") { if (Test-Path (Join-Path $currentDir "dist")) { $root = Join-Path $currentDir "dist" } elseif (Test-Path (Join-Path $currentDir "build")) { $root = Join-Path $currentDir "build" } else { # Fallback to current directory if neither exists (though validation will fail later) $root = Join-Path $currentDir "dist" } } else { # Resolve relative path if (-not [System.IO.Path]::IsPathRooted($root)) { $root = Join-Path $currentDir $root } } # --- Data Directory & Class Config --- $baseDir = Split-Path $root -Parent if ([string]::IsNullOrEmpty($baseDir) -or -not (Test-Path $baseDir)) { $baseDir = $currentDir } $dataDir = Join-Path $baseDir "data" if (-not (Test-Path $dataDir)) { New-Item -ItemType Directory -Path $dataDir -Force | Out-Null } $classConfigFile = Join-Path $dataDir "classworks-config-basic.json" $className = "未设置" if (Test-Path $classConfigFile) { try { $rawConfig = Get-Content -Path $classConfigFile -Raw -Encoding UTF8 $classObj = $rawConfig | ConvertFrom-Json if ($classObj.classNumber) { $className = $classObj.classNumber } } catch { # Ignore } } # --- UI Display --- Write-Host "========================================" -ForegroundColor White Write-Host " 宝一外班级作业看板" -ForegroundColor Yellow Write-Host "========================================" -ForegroundColor White Write-Host "" Write-Host "关于:" -ForegroundColor White Write-Host "ClassHomeworkBoard 班级作业看板由高一七班(原12班)李昊睿制作。在宝一外提供技术支持。" -ForegroundColor Gray Write-Host "---" -ForegroundColor DarkGray Write-Host "主机名称 : $env:COMPUTERNAME" -ForegroundColor Gray Write-Host "当前用户 : $env:USERNAME" -ForegroundColor Gray Write-Host "班级名称 : $className" -ForegroundColor Cyan Write-Host "运行目录 : $root" -ForegroundColor Yellow Write-Host "监听端口 : $port" -ForegroundColor Green Write-Host "---" -ForegroundColor DarkGray Write-Host "" # --- Validation --- if (-not (Test-Path $root)) { Write-Host "[错误] 目录不存在: $root" -ForegroundColor Red Write-Host "请先运行: npm run build" -ForegroundColor Red exit 1 } # --- Port Detection --- function Test-Port($p) { $tcp = New-Object System.Net.Sockets.TcpClient try { $tcp.Connect("localhost", $p) $tcp.Close() return $true } catch { return $false } } while (Test-Port $port) { Write-Host "[信息] 端口 $port 被占用,尝试端口 $($port + 1)..." -ForegroundColor Yellow $port++ } # --- Browser Detection --- function Find-Edge { # 1. Common C: paths $paths = @( "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", "C:\Program Files\Microsoft\Edge\Application\msedge.exe", "D:\CHWB-CI\Microsoft\Edge\Application\msedge.exe", "$env:LOCALAPPDATA\Microsoft\Edge\Application\msedge.exe" ) foreach ($path in $paths) { if (Test-Path $path) { return $path } } # 2. Registry try { $regPath = Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\msedge.exe" -ErrorAction SilentlyContinue if ($regPath -and (Test-Path $regPath.'(default)')) { return $regPath.'(default)' } } catch {} # 3. Search D: drive (User request) Write-Host "正在D盘查找 Microsoft Edge (msedge.exe),这可能需要一点时间..." -ForegroundColor Gray try { # Search D: drive for msedge.exe $edge = Get-ChildItem -Path "D:\" -Filter "msedge.exe" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 if ($edge) { return $edge.FullName } } catch {} return $null } # --- Server Start --- $url = "http://localhost:$port/" $listener = New-Object System.Net.HttpListener $listener.Prefixes.Add($url) try { $listener.Start() Write-Host "[√] 启动成功 $url" -ForegroundColor Green Write-Host "正在自动打开浏览器..." -ForegroundColor DarkGray Write-Host "" } catch { Write-Host "[错误] 启动服务器失败: $($_.Exception.Message)" -ForegroundColor Red exit 1 } try { $edgePath = Find-Edge if ($edgePath) { Write-Host "使用 Edge 浏览器打开: $edgePath" -ForegroundColor Gray Start-Process $edgePath $url -ErrorAction Stop } else { Write-Host "未找到 Edge,使用默认浏览器打开..." -ForegroundColor Gray Start-Process $url -ErrorAction Stop } } catch { Write-Host "[警告] 无法自动打开浏览器,请手动访问上述地址。" -ForegroundColor Yellow } # --- MIME Types --- $mimeTypes = @{ ".html" = "text/html; charset=utf-8" ".htm" = "text/html; charset=utf-8" ".css" = "text/css; charset=utf-8" ".js" = "application/javascript; charset=utf-8" ".mjs" = "application/javascript; charset=utf-8" ".json" = "application/json; charset=utf-8" ".png" = "image/png" ".jpg" = "image/jpeg" ".jpeg" = "image/jpeg" ".gif" = "image/gif" ".svg" = "image/svg+xml" ".ico" = "image/x-icon" ".woff" = "font/woff" ".woff2" = "font/woff2" ".ttf" = "font/ttf" ".eot" = "application/vnd.ms-fontobject" ".otf" = "font/otf" ".wasm" = "application/wasm" ".map" = "application/json" ".txt" = "text/plain; charset=utf-8" ".xml" = "text/xml; charset=utf-8" ".pdf" = "application/pdf" ".mp3" = "audio/mpeg" ".mp4" = "video/mp4" ".webm" = "video/webm" ".webp" = "image/webp" } # --- Request Loop --- while ($listener.IsListening) { try { $context = $listener.GetContext() $request = $context.Request $response = $context.Response $path = $request.Url.LocalPath # URL Decode $path = [System.Net.WebUtility]::UrlDecode($path) if ($path.StartsWith("/api/ppt/check")) { $response.AddHeader("Access-Control-Allow-Origin", "*") $response.AddHeader("Access-Control-Allow-Methods", "GET, OPTIONS") $response.AddHeader("Access-Control-Allow-Headers", "Content-Type") $response.AddHeader("Cache-Control", "no-store") if ($request.HttpMethod -eq "OPTIONS") { $response.StatusCode = 204 $response.Close() continue } try { $ppts = Get-Process -Name "wpp", "POWERPNT", "wps" -ErrorAction SilentlyContinue if ($ppts) { $pptList = @() $seenHwnds = @{} foreach ($p in $ppts) { $procName = $p.ProcessName $hwnds = [ScreenCapture]::GetProcessWindows($p.Id) foreach ($hwnd in $hwnds) { $hwndStr = $hwnd.ToString() if ($seenHwnds.ContainsKey($hwndStr)) { continue } $title = [ScreenCapture]::GetWindowTextStr($hwnd) # Skip system/irrelevant windows if ($title -eq "DI" -or $title -eq "Default IME" -or $title -eq "MSCTFIME UI" -or $title -eq "OleMainThreadWndName") { continue } if ($procName -eq "POWERPNT" -and $title -notmatch "PowerPoint") { continue } if ($procName -eq "wps") { if ($title -match "WPS\s*文字" -or $title -match "WPS\s*表格") { continue } if ($title -notmatch "WPS\s*演示" -and $title -notmatch "Presentation" -and $title -notmatch "PPT" -and $title -notmatch "幻灯片" -and $title -notmatch "Slide Show") { continue } } $seenHwnds[$hwndStr] = $true if ([ScreenCapture]::IsIconic($hwnd)) { [ScreenCapture]::ShowWindow($hwnd, [ScreenCapture]::SW_SHOWNOACTIVATE) Start-Sleep -Milliseconds 100 } $b64 = [ScreenCapture]::CaptureWindow($hwnd) $isSlideShow = ($title -match "幻灯片放映" -or $title -match "Slide Show") $pptInfo = @{ title = $title; slideShow = $isSlideShow } if (-not [string]::IsNullOrEmpty($b64)) { $pptInfo.image = "data:image/jpeg;base64,$b64" } $pptList += $pptInfo } } $respObj = @{ success = $true; hasPpt = ($pptList.Length -gt 0); ppts = $pptList } } else { $respObj = @{ success = $true; hasPpt = $false; ppts = @() } } $respJson = $respObj | ConvertTo-Json -Compress -Depth 5 $bytes = [System.Text.Encoding]::UTF8.GetBytes($respJson) $response.StatusCode = 200 $response.ContentType = "application/json; charset=utf-8" $response.ContentLength64 = $bytes.Length $response.OutputStream.Write($bytes, 0, $bytes.Length) $response.Close() } catch { $respObj = @{ success = $false; error = $_.Exception.Message } $respJson = $respObj | ConvertTo-Json -Compress $bytes = [System.Text.Encoding]::UTF8.GetBytes($respJson) $response.StatusCode = 500 $response.ContentType = "application/json; charset=utf-8" $response.ContentLength64 = $bytes.Length $response.OutputStream.Write($bytes, 0, $bytes.Length) $response.Close() } continue } if ($path.StartsWith("/api/ppt/control")) { $response.AddHeader("Access-Control-Allow-Origin", "*") $response.AddHeader("Access-Control-Allow-Methods", "POST, OPTIONS") $response.AddHeader("Access-Control-Allow-Headers", "Content-Type") $response.AddHeader("Cache-Control", "no-store") if ($request.HttpMethod -eq "OPTIONS") { $response.StatusCode = 204 $response.Close() continue } try { $action = $request.QueryString["action"] $ppts = Get-Process -Name "wpp", "POWERPNT", "wps" -ErrorAction SilentlyContinue if ($ppts) { $targetProc = $null $hwnd = [IntPtr]::Zero $procName = "" foreach ($p in $ppts) { $procNameCandidate = $p.ProcessName $hwnds = [ScreenCapture]::GetProcessWindows($p.Id) foreach ($h in $hwnds) { $t = [ScreenCapture]::GetWindowTextStr($h) if ($t -eq "DI" -or $t -eq "Default IME" -or $t -eq "MSCTFIME UI" -or $t -eq "OleMainThreadWndName") { continue } if ($procNameCandidate -eq "POWERPNT" -and $t -notmatch "PowerPoint") { continue } if ($procNameCandidate -eq "wps") { if ($t -match "WPS\s*文字" -or $t -match "WPS\s*表格") { continue } if ($t -notmatch "WPS\s*演示" -and $t -notmatch "Presentation" -and $t -notmatch "PPT" -and $t -notmatch "幻灯片" -and $t -notmatch "Slide Show") { continue } } $targetProc = $p $hwnd = $h $procName = $procNameCandidate break } if ($targetProc -and $hwnd -ne [IntPtr]::Zero) { break } } if (-not $targetProc -or $hwnd -eq [IntPtr]::Zero) { $respObj = @{ success = $false; error = "No PPT window found" } $respJson = $respObj | ConvertTo-Json -Compress $bytes = [System.Text.Encoding]::UTF8.GetBytes($respJson) $response.StatusCode = 200 $response.ContentType = "application/json; charset=utf-8" $response.ContentLength64 = $bytes.Length $response.OutputStream.Write($bytes, 0, $bytes.Length) $response.Close() continue } $handled = $false if ([ScreenCapture]::IsIconic($hwnd)) { [ScreenCapture]::ShowWindow($hwnd, [ScreenCapture]::SW_SHOWNOACTIVATE) Start-Sleep -Milliseconds 100 } # 优先使用 COM 自动化(不需要把窗口切到前台) try { Add-Type -AssemblyName System.Runtime.InteropServices -ErrorAction SilentlyContinue | Out-Null } catch {} $app = $null $progIds = @() if ($procName -eq "POWERPNT") { $progIds = @("PowerPoint.Application") } else { # WPS 兼容:常见 ProgID 尝试 $progIds = @("KWPP.Application", "WPP.Application", "PowerPoint.Application") } foreach ($progId in $progIds) { try { $app = [Runtime.InteropServices.Marshal]::GetActiveObject($progId) if ($app) { break } } catch {} } if ($app) { try { $dir = if ($action -eq "prev") { "prev" } else { "next" } $sswCount = 0 try { $sswCount = $app.SlideShowWindows.Count } catch { $sswCount = 0 } if ($sswCount -gt 0) { $view = $app.SlideShowWindows.Item(1).View if ($action -eq "prev") { $view.Previous() $handled = $true } elseif ($action -eq "next") { $view.Next() $handled = $true } elseif ($action -eq "slideshow_end") { $view.Exit() $handled = $true } } else { if ($action -eq "slideshow_start") { try { $pres = $null try { $pres = $app.ActivePresentation } catch { $pres = $null } if ($pres) { try { $pres.SlideShowSettings.ShowType = 2 } catch {} $pres.SlideShowSettings.Run() | Out-Null Start-Sleep -Milliseconds 200 try { $sswCount2 = 0 try { $sswCount2 = $app.SlideShowWindows.Count } catch { $sswCount2 = 0 } if ($sswCount2 -gt 0) { $sswHwnd = [IntPtr]$app.SlideShowWindows.Item(1).HWND if ($sswHwnd -ne [IntPtr]::Zero) { [ScreenCapture]::SetNotTopMost($sswHwnd) } } } catch {} $handled = $true } } catch {} } else { $pres = $null try { $pres = $app.ActivePresentation } catch { $pres = $null } if ($pres) { $total = 0 try { $total = $pres.Slides.Count } catch { $total = 0 } $idx = 0 try { $idx = $app.ActiveWindow.View.Slide.SlideIndex } catch { $idx = 0 } if ($idx -gt 0 -and $total -gt 0) { $target = if ($action -eq "prev") { [Math]::Max(1, $idx - 1) } else { [Math]::Min($total, $idx + 1) } try { $app.ActiveWindow.View.GotoSlide($target) } catch {} $handled = $true } } } } } catch {} } if (-not $handled) { if ($action -eq "slideshow_start") { [ScreenCapture]::SendKeyToWindow($hwnd, 0x74) Start-Sleep -Milliseconds 300 try { $ssHwnd = [IntPtr]::Zero $hwnds2 = [ScreenCapture]::GetProcessWindows($targetProc.Id) foreach ($h2 in $hwnds2) { $t2 = [ScreenCapture]::GetWindowTextStr($h2) if ($t2 -match "幻灯片放映" -or $t2 -match "Slide Show") { $ssHwnd = $h2 break } } if ($ssHwnd -ne [IntPtr]::Zero) { [ScreenCapture]::SetNotTopMost($ssHwnd) } } catch {} $handled = $true } elseif ($action -eq "slideshow_end") { [ScreenCapture]::SendKeyToWindow($hwnd, 0x1B) $handled = $true } elseif ($action -eq "prev") { [ScreenCapture]::SendKeyToWindow($hwnd, [ScreenCapture]::VK_LEFT) $handled = $true } elseif ($action -eq "next") { [ScreenCapture]::SendKeyToWindow($hwnd, [ScreenCapture]::VK_RIGHT) $handled = $true } } $respObj = @{ success = $handled } } else { $respObj = @{ success = $false; error = "No PPT process found" } } $respJson = $respObj | ConvertTo-Json -Compress $bytes = [System.Text.Encoding]::UTF8.GetBytes($respJson) $response.StatusCode = 200 $response.ContentType = "application/json; charset=utf-8" $response.ContentLength64 = $bytes.Length $response.OutputStream.Write($bytes, 0, $bytes.Length) $response.Close() } catch { $respObj = @{ success = $false; error = $_.Exception.Message } $respJson = $respObj | ConvertTo-Json -Compress $bytes = [System.Text.Encoding]::UTF8.GetBytes($respJson) $response.StatusCode = 500 $response.ContentType = "application/json; charset=utf-8" $response.ContentLength64 = $bytes.Length $response.OutputStream.Write($bytes, 0, $bytes.Length) $response.Close() } continue } if ($path.StartsWith("/api/local-file")) { # Log API Request $time = Get-Date -Format "HH:mm:ss" Write-Host "[$time] " -NoNewline -ForegroundColor DarkGray Write-Host "API " -NoNewline -ForegroundColor Cyan Write-Host "- $path" -ForegroundColor Gray $baseDir = Split-Path $root -Parent if ([string]::IsNullOrEmpty($baseDir) -or -not (Test-Path $baseDir)) { $baseDir = $currentDir } $dataDir = Join-Path $baseDir "data" if (-not (Test-Path $dataDir)) { New-Item -ItemType Directory -Path $dataDir -Force | Out-Null } $response.AddHeader("Access-Control-Allow-Origin", "*") $response.AddHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS") $response.AddHeader("Access-Control-Allow-Headers", "Content-Type") $response.AddHeader("Cache-Control", "no-store") if ($request.HttpMethod -eq "OPTIONS") { $response.StatusCode = 204 $response.Close() continue } $subPath = $path.Substring("/api/local-file".Length) try { if ($subPath -eq "/save" -and $request.HttpMethod -eq "POST") { $ms = New-Object System.IO.MemoryStream $request.InputStream.CopyTo($ms) $bodyBytes = $ms.ToArray() $ms.Dispose() $body = [System.Text.Encoding]::UTF8.GetString($bodyBytes) $body = $body.Trim([char]65279).Trim([char]0).Trim() $payload = $body | ConvertFrom-Json $key = $payload.key $data = $payload.data if ([string]::IsNullOrEmpty($key)) { throw "Missing key" } $safeKey = ($key -replace '[^a-zA-Z0-9_-]', '_') $filePath = Join-Path $dataDir ($safeKey + ".json") # Fix: Use ConvertTo-Json with sufficient depth and prevent UTF-16 issues # PowerShell 5.1 ConvertTo-Json defaults to depth 2, which breaks nested objects $jsonToWrite = $data | ConvertTo-Json -Depth 100 -Compress # Force UTF-8 encoding (no BOM) [System.IO.File]::WriteAllText($filePath, $jsonToWrite, [System.Text.Encoding]::UTF8) $respObj = @{ success = $true } $respJson = $respObj | ConvertTo-Json -Compress $bytes = [System.Text.Encoding]::UTF8.GetBytes($respJson) $response.StatusCode = 200 $response.ContentType = "application/json; charset=utf-8" $response.ContentLength64 = $bytes.Length $response.OutputStream.Write($bytes, 0, $bytes.Length) $response.Close() continue } if ($subPath -eq "/load" -and $request.HttpMethod -eq "GET") { $key = $request.QueryString["key"] if ([string]::IsNullOrEmpty($key)) { throw "Missing key" } $safeKey = ($key -replace '[^a-zA-Z0-9_-]', '_') $filePath = Join-Path $dataDir ($safeKey + ".json") if (-not (Test-Path $filePath -PathType Leaf)) { $respObj = @{ success = $false; error = "Not found" } $respJson = $respObj | ConvertTo-Json -Compress $bytes = [System.Text.Encoding]::UTF8.GetBytes($respJson) $response.StatusCode = 404 $response.ContentType = "application/json; charset=utf-8" $response.ContentLength64 = $bytes.Length $response.OutputStream.Write($bytes, 0, $bytes.Length) $response.Close() continue } $raw = Get-Content -Path $filePath -Raw -Encoding UTF8 $dataObj = $raw | ConvertFrom-Json $respObj = @{ success = $true; data = $dataObj } $respJson = $respObj | ConvertTo-Json -Depth 100 -Compress $bytes = [System.Text.Encoding]::UTF8.GetBytes($respJson) $response.StatusCode = 200 $response.ContentType = "application/json; charset=utf-8" $response.ContentLength64 = $bytes.Length $response.OutputStream.Write($bytes, 0, $bytes.Length) $response.Close() continue } if ($subPath -eq "/keys" -and $request.HttpMethod -eq "GET") { $files = Get-ChildItem -Path $dataDir -Filter "*.json" -File -ErrorAction SilentlyContinue $keysList = @() if ($files) { foreach ($f in $files) { $keysList += '"' + $f.BaseName + '"' } } $keysJson = "[" + ($keysList -join ",") + "]" # Manually construct JSON to avoid PowerShell array flattening issues $respJson = '{"success":true,"keys":' + $keysJson + '}' $bytes = [System.Text.Encoding]::UTF8.GetBytes($respJson) $response.StatusCode = 200 $response.ContentType = "application/json; charset=utf-8" $response.ContentLength64 = $bytes.Length $response.OutputStream.Write($bytes, 0, $bytes.Length) $response.Close() continue } $respObj = @{ success = $false; error = "Not found" } $respJson = $respObj | ConvertTo-Json -Compress $bytes = [System.Text.Encoding]::UTF8.GetBytes($respJson) $response.StatusCode = 404 $response.ContentType = "application/json; charset=utf-8" $response.ContentLength64 = $bytes.Length $response.OutputStream.Write($bytes, 0, $bytes.Length) $response.Close() continue } catch { Write-Host "API Error: $($_.Exception.Message)" -ForegroundColor Red $respObj = @{ success = $false; error = $_.Exception.Message } if ([string]::IsNullOrEmpty($respObj.error)) { $respObj.error = "$_" } $respJson = $respObj | ConvertTo-Json -Compress $bytes = [System.Text.Encoding]::UTF8.GetBytes($respJson) $response.StatusCode = 500 $response.ContentType = "application/json; charset=utf-8" $response.ContentLength64 = $bytes.Length $response.OutputStream.Write($bytes, 0, $bytes.Length) $response.Close() continue } } # Default to index.html if ($path -eq "/" -or $path -eq "") { $path = "/index.html" } # Security Check $relativePath = $path.TrimStart("/").Replace("/", "\") $filePath = Join-Path $root $relativePath if (-not $filePath.StartsWith($root)) { $response.StatusCode = 403 $response.Close() continue } # SPA Fallback if (-not (Test-Path $filePath -PathType Leaf)) { $extension = [System.IO.Path]::GetExtension($filePath) if ([string]::IsNullOrEmpty($extension)) { $filePath = Join-Path $root "index.html" } } # Serve File if (Test-Path $filePath -PathType Leaf) { try { $time = Get-Date -Format "HH:mm:ss" # Log success (Gray time, Green status, Gray path) Write-Host "[$time] " -NoNewline -ForegroundColor DarkGray Write-Host "200 OK " -NoNewline -ForegroundColor Green Write-Host "- $path" -ForegroundColor Gray $content = [System.IO.File]::ReadAllBytes($filePath) $ext = [System.IO.Path]::GetExtension($filePath).ToLower() $contentType = "application/octet-stream" if ($mimeTypes.ContainsKey($ext)) { $contentType = $mimeTypes[$ext] } $response.ContentType = $contentType $response.ContentLength64 = $content.Length $response.StatusCode = 200 # Add Cache-Control headers to prevent caching issues $servedFileName = [System.IO.Path]::GetFileName($filePath).ToLower() if ($servedFileName -eq "sw.js" -or $servedFileName -eq "index.html") { $response.AddHeader("Cache-Control", "no-cache, no-store, must-revalidate") $response.AddHeader("Pragma", "no-cache") $response.AddHeader("Expires", "0") } else { $response.AddHeader("Cache-Control", "public, max-age=3600") } $response.OutputStream.Write($content, 0, $content.Length) } catch { $response.StatusCode = 500 Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red } } else { # Log 404 (Gray time, Yellow status, Gray path) $time = Get-Date -Format "HH:mm:ss" Write-Host "[$time] " -NoNewline -ForegroundColor DarkGray Write-Host "404 Not Found " -NoNewline -ForegroundColor Yellow Write-Host "- $path" -ForegroundColor Gray $response.StatusCode = 404 $msg = [System.Text.Encoding]::UTF8.GetBytes("404 Not Found") $response.ContentType = "text/plain; charset=utf-8" $response.ContentLength64 = $msg.Length $response.OutputStream.Write($msg, 0, $msg.Length) } $response.Close() } catch { # Ignore loop errors } }