Files
esports/streamer/windows/desktop.ps1
Stan Runge 54444a77f0 feat: add native Windows broadcaster and live score modules
- streamer/windows: vash-broadcast.exe (setup/login/start/stop/status/doctor)
  with per-instance OBS, tosu and FFmpeg, a supervised worker, OCR room
  selection and OBS scene reconciliation; built and tested on Linux only
- streamer/relay.ts shared with Vash TV; Cloudflare Access proxy for dev/staging
- worker status validation and live score helpers for the site
- STREAMING_HANDOFF.md and streamer/ALPHA_TEST.md record what is unverified

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:02:25 +00:00

504 lines
21 KiB
PowerShell

# Vash broadcast desktop helper.
#
# The worker starts this in the signed-in desktop session and sends one JSON
# request per line on stdin; each gets one JSON line back on stdout:
# {"id":1,"cmd":"windows","under":"C:\\...\\osu"} -> {"id":1,"ok":true,"result":[...]}
#
# It needs Windows PowerShell 5.1 (powershell.exe, part of Windows): the
# Windows.Media.Ocr API used for reading osu!tourney's room list is not
# reachable from PowerShell 7. Coordinates are physical screen pixels.
$ErrorActionPreference = 'Stop'
# Explicit UTF-8 streams: [Console]::InputEncoding cannot be set when there is no console window.
$utf8 = New-Object System.Text.UTF8Encoding $false
$stdin = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), $utf8)
$stdout = New-Object System.IO.StreamWriter([Console]::OpenStandardOutput(), $utf8)
$stdout.AutoFlush = $true
Add-Type -AssemblyName System.Drawing
Add-Type -ReferencedAssemblies System.Drawing -TypeDefinition @'
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
public class VashWindow {
public long hwnd { get; set; }
public int pid { get; set; }
public string title { get; set; }
public string className { get; set; }
public string exe { get; set; }
public bool visible { get; set; }
public bool minimized { get; set; }
public int x { get; set; }
public int y { get; set; }
public int width { get; set; }
public int height { get; set; }
public int clientX { get; set; }
public int clientY { get; set; }
public int clientWidth { get; set; }
public int clientHeight { get; set; }
}
public class VashProcess {
public int pid { get; set; }
public string path { get; set; }
}
public static class VashDesktop {
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X, Y; }
[StructLayout(LayoutKind.Sequential)] struct MOUSEINPUT { public int dx; public int dy; public uint mouseData; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; }
[StructLayout(LayoutKind.Sequential)] struct KEYBDINPUT { public ushort wVk; public ushort wScan; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; }
[StructLayout(LayoutKind.Sequential)] struct HARDWAREINPUT { public uint uMsg; public ushort wParamL; public ushort wParamH; }
[StructLayout(LayoutKind.Explicit)] struct InputUnion {
[FieldOffset(0)] public MOUSEINPUT mi;
[FieldOffset(0)] public KEYBDINPUT ki;
[FieldOffset(0)] public HARDWAREINPUT hi;
}
[StructLayout(LayoutKind.Sequential)] struct INPUT { public uint type; public InputUnion U; }
[StructLayout(LayoutKind.Sequential)] struct LASTINPUTINFO { public uint cbSize; public uint dwTime; }
delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")] static extern bool EnumWindows(EnumWindowsProc cb, IntPtr lParam);
[DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetWindowText(IntPtr hWnd, StringBuilder sb, int max);
[DllImport("user32.dll")] static extern int GetWindowTextLength(IntPtr hWnd);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetClassName(IntPtr hWnd, StringBuilder sb, int max);
[DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll")] static extern bool IsWindow(IntPtr hWnd);
[DllImport("user32.dll")] static extern bool IsIconic(IntPtr hWnd);
[DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr hWnd, out RECT r);
[DllImport("user32.dll")] static extern bool GetClientRect(IntPtr hWnd, out RECT r);
[DllImport("user32.dll")] static extern bool ClientToScreen(IntPtr hWnd, ref POINT p);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint msg, IntPtr w, string l, uint flags, uint timeout, out IntPtr result);
[DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")] static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] static extern bool ShowWindow(IntPtr hWnd, int cmd);
[DllImport("user32.dll")] static extern bool BringWindowToTop(IntPtr hWnd);
[DllImport("user32.dll")] static extern bool AttachThreadInput(uint a, uint b, bool attach);
[DllImport("kernel32.dll")] static extern uint GetCurrentThreadId();
[DllImport("user32.dll")] static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] static extern uint SendInput(uint n, INPUT[] inputs, int size);
[DllImport("user32.dll")] static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int cx, int cy, uint flags);
[DllImport("user32.dll")] static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr w, IntPtr l);
[DllImport("user32.dll")] static extern IntPtr OpenInputDesktop(uint flags, bool inherit, uint access);
[DllImport("user32.dll")] static extern bool CloseDesktop(IntPtr h);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern bool GetUserObjectInformation(IntPtr h, int index, StringBuilder sb, int len, out int needed);
[DllImport("user32.dll")] static extern bool SetProcessDpiAwarenessContext(IntPtr value);
[DllImport("user32.dll")] static extern int GetSystemMetrics(int index);
[DllImport("user32.dll")] static extern bool GetLastInputInfo(ref LASTINPUTINFO info);
[DllImport("kernel32.dll")] static extern IntPtr OpenProcess(uint access, bool inherit, uint pid);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)] static extern bool QueryFullProcessImageName(IntPtr h, uint flags, StringBuilder sb, ref uint size);
[DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h);
[DllImport("kernel32.dll")] static extern uint WTSGetActiveConsoleSessionId();
[DllImport("kernel32.dll")] static extern bool ProcessIdToSessionId(uint pid, out uint session);
[DllImport("kernel32.dll")] static extern uint SetThreadExecutionState(uint flags);
/** Keeps the PC awake and the display on while this helper runs (it reverts when the helper exits). */
public static bool StayAwake(bool on) {
// ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED
uint flags = on ? 0x80000000u | 0x00000001u | 0x00000002u : 0x80000000u;
return SetThreadExecutionState(flags) != 0;
}
public static string Init() {
try {
// Per-monitor aware v2: every rectangle and click is in physical pixels.
if (SetProcessDpiAwarenessContext(new IntPtr(-4))) return "per-monitor-v2";
return "unchanged";
} catch (Exception e) {
return "unavailable: " + e.Message;
}
}
public static string ProcessPath(uint pid) {
IntPtr h = OpenProcess(0x1000, false, pid);
if (h == IntPtr.Zero) return null;
try {
var sb = new StringBuilder(1024);
uint size = (uint)sb.Capacity;
return QueryFullProcessImageName(h, 0, sb, ref size) ? sb.ToString() : null;
} finally {
CloseHandle(h);
}
}
static bool Under(string path, string dir) {
if (String.IsNullOrEmpty(dir)) return true;
if (path == null) return false;
string prefix = dir.TrimEnd('\\') + "\\";
return path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
}
public static VashWindow Describe(IntPtr h) {
uint pid;
GetWindowThreadProcessId(h, out pid);
var title = new StringBuilder(GetWindowTextLength(h) + 1);
GetWindowText(h, title, title.Capacity);
var cls = new StringBuilder(256);
GetClassName(h, cls, cls.Capacity);
RECT r, c;
GetWindowRect(h, out r);
GetClientRect(h, out c);
var origin = new POINT();
ClientToScreen(h, ref origin);
string path = ProcessPath(pid);
return new VashWindow {
hwnd = h.ToInt64(), pid = (int)pid, title = title.ToString(), className = cls.ToString(),
exe = path, visible = IsWindowVisible(h), minimized = IsIconic(h),
x = r.Left, y = r.Top, width = r.Right - r.Left, height = r.Bottom - r.Top,
clientX = origin.X, clientY = origin.Y, clientWidth = c.Right - c.Left, clientHeight = c.Bottom - c.Top
};
}
/** Top-level windows of processes whose executable is under dir (all when empty). */
public static VashWindow[] Windows(string dir) {
var handles = new List<IntPtr>();
EnumWindows((h, l) => { handles.Add(h); return true; }, IntPtr.Zero);
var result = new List<VashWindow>();
var paths = new Dictionary<uint, string>();
foreach (var h in handles) {
uint pid;
GetWindowThreadProcessId(h, out pid);
string path;
if (!paths.TryGetValue(pid, out path)) { path = ProcessPath(pid); paths[pid] = path; }
if (!Under(path, dir)) continue;
result.Add(Describe(h));
}
return result.ToArray();
}
public static VashWindow Window(long hwnd) {
var h = new IntPtr(hwnd);
return IsWindow(h) ? Describe(h) : null;
}
public static VashProcess[] Processes(string dir) {
var result = new List<VashProcess>();
foreach (var p in Process.GetProcesses()) {
try {
string path = ProcessPath((uint)p.Id);
if (path != null && Under(path, dir)) result.Add(new VashProcess { pid = p.Id, path = path });
} catch { }
finally { p.Dispose(); }
}
return result.ToArray();
}
public static int Kill(int[] pids) {
int killed = 0;
foreach (int pid in pids) {
try { using (var p = Process.GetProcessById(pid)) { p.Kill(); killed++; } } catch { }
}
return killed;
}
/** Asks every top-level window of a process to close, like clicking its X. */
public static int CloseProcessWindows(int pid) {
int posted = 0;
foreach (var w in Windows(null)) {
if (w.pid != pid) continue;
if (PostMessage(new IntPtr(w.hwnd), 0x0010, IntPtr.Zero, IntPtr.Zero)) posted++;
}
return posted;
}
/** "Default" when the desktop is unlocked; "Winlogon" (or null) when locked or on a secure screen. */
public static string InputDesktop() {
IntPtr d = OpenInputDesktop(0, false, 0x0001);
if (d == IntPtr.Zero) return null;
try {
var sb = new StringBuilder(256);
int needed;
return GetUserObjectInformation(d, 2, sb, sb.Capacity * 2, out needed) ? sb.ToString() : null;
} finally {
CloseDesktop(d);
}
}
public static Dictionary<string, object> Session() {
uint session;
ProcessIdToSessionId((uint)Process.GetCurrentProcess().Id, out session);
var info = new LASTINPUTINFO { cbSize = (uint)Marshal.SizeOf(typeof(LASTINPUTINFO)) };
GetLastInputInfo(ref info);
return new Dictionary<string, object> {
{ "sessionId", (int)session },
{ "consoleSessionId", (int)WTSGetActiveConsoleSessionId() },
{ "inputDesktop", InputDesktop() },
{ "screenWidth", GetSystemMetrics(0) },
{ "screenHeight", GetSystemMetrics(1) },
{ "idleMs", unchecked(Environment.TickCount - (int)info.dwTime) }
};
}
static void Key(ushort vk, bool up) {
var input = new INPUT { type = 1 };
input.U.ki.wVk = vk;
input.U.ki.dwFlags = up ? 0x0002u : 0u;
SendInput(1, new[] { input }, Marshal.SizeOf(typeof(INPUT)));
}
/** Brings a window to the front; Windows only allows this in some cases, so try the known ways. */
public static bool Focus(long hwnd) {
var h = new IntPtr(hwnd);
if (!IsWindow(h)) return false;
if (IsIconic(h)) ShowWindow(h, 9);
if (GetForegroundWindow() == h) return true;
IntPtr fg = GetForegroundWindow();
uint ignored;
uint fgThread = fg == IntPtr.Zero ? 0 : GetWindowThreadProcessId(fg, out ignored);
uint me = GetCurrentThreadId();
bool attached = fgThread != 0 && fgThread != me && AttachThreadInput(me, fgThread, true);
BringWindowToTop(h);
SetForegroundWindow(h);
if (attached) AttachThreadInput(me, fgThread, false);
Thread.Sleep(80);
if (GetForegroundWindow() == h) return true;
// The process that sent the last input may set the foreground window: tap Alt, then retry.
Key(0x12, false);
Key(0x12, true);
SetForegroundWindow(h);
Thread.Sleep(120);
return GetForegroundWindow() == h;
}
public static bool Move(long hwnd, int x, int y) {
// SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE
return SetWindowPos(new IntPtr(hwnd), IntPtr.Zero, x, y, 0, 0, 0x0001 | 0x0004 | 0x0010);
}
public static bool SetTitle(long hwnd, string title) {
IntPtr result;
// WM_SETTEXT with a timeout, so a busy window cannot hang the helper.
return SendMessageTimeout(new IntPtr(hwnd), 0x000C, IntPtr.Zero, title, 0x0002, 2000, out result) != IntPtr.Zero;
}
static ImageCodecInfo Codec(string mime) {
foreach (var codec in ImageCodecInfo.GetImageEncoders()) if (codec.MimeType == mime) return codec;
return null;
}
static void Save(Bitmap bmp, string path, string format, long quality) {
if (format == "jpeg") {
var parameters = new EncoderParameters(1);
parameters.Param[0] = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality, quality);
bmp.Save(path, Codec("image/jpeg"), parameters);
} else {
bmp.Save(path, ImageFormat.Png);
}
}
/**
* Copies a screen rectangle to an image file, optionally scaled (to at most maxWidth,
* or by scale) and inverted (dark text on light reads better for OCR). Returns
* the saved size. Fails while the desktop is locked.
*/
public static int[] Capture(int x, int y, int w, int h, string path, string format, int maxWidth, double scale, bool invert, long quality) {
using (var bmp = new Bitmap(w, h, PixelFormat.Format32bppArgb)) {
using (var g = Graphics.FromImage(bmp)) {
g.CopyFromScreen(x, y, 0, 0, new Size(w, h), CopyPixelOperation.SourceCopy);
}
double factor = scale > 0 ? scale : 1.0;
if (maxWidth > 0 && w * factor > maxWidth) factor = (double)maxWidth / w;
int ow = Math.Max(1, (int)Math.Round(w * factor));
int oh = Math.Max(1, (int)Math.Round(h * factor));
if (ow == w && oh == h && !invert) {
Save(bmp, path, format, quality);
return new[] { ow, oh };
}
using (var output = new Bitmap(ow, oh, PixelFormat.Format24bppRgb)) {
using (var g = Graphics.FromImage(output)) {
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
g.PixelOffsetMode = PixelOffsetMode.HighQuality;
var attributes = new ImageAttributes();
if (invert) {
attributes.SetColorMatrix(new ColorMatrix(new float[][] {
new float[] { -1, 0, 0, 0, 0 },
new float[] { 0, -1, 0, 0, 0 },
new float[] { 0, 0, -1, 0, 0 },
new float[] { 0, 0, 0, 1, 0 },
new float[] { 1, 1, 1, 0, 1 }
}));
}
g.DrawImage(bmp, new Rectangle(0, 0, ow, oh), 0, 0, w, h, GraphicsUnit.Pixel, attributes);
}
Save(output, path, format, quality);
}
return new[] { ow, oh };
}
}
public static bool Click(int x, int y, bool right) {
if (!SetCursorPos(x, y)) return false;
Thread.Sleep(60);
var down = new INPUT { type = 0 };
down.U.mi.dwFlags = right ? 0x0008u : 0x0002u;
var up = new INPUT { type = 0 };
up.U.mi.dwFlags = right ? 0x0010u : 0x0004u;
uint sent = SendInput(1, new[] { down }, Marshal.SizeOf(typeof(INPUT)));
Thread.Sleep(60);
sent += SendInput(1, new[] { up }, Marshal.SizeOf(typeof(INPUT)));
return sent == 2;
}
public static int TypeText(string text) {
int sent = 0;
foreach (char ch in text) {
var down = new INPUT { type = 1 };
down.U.ki.wScan = ch;
down.U.ki.dwFlags = 0x0004;
var up = new INPUT { type = 1 };
up.U.ki.wScan = ch;
up.U.ki.dwFlags = 0x0004 | 0x0002;
sent += (int)SendInput(1, new[] { down }, Marshal.SizeOf(typeof(INPUT)));
sent += (int)SendInput(1, new[] { up }, Marshal.SizeOf(typeof(INPUT)));
Thread.Sleep(15);
}
return sent / 2;
}
public static void PressKey(ushort vk) {
Key(vk, false);
Thread.Sleep(30);
Key(vk, true);
}
}
'@
$script:dpi = [VashDesktop]::Init()
# ── Windows OCR (Windows.Media.Ocr, part of Windows 10 and 11) ─────────────
$script:ocr = $null
function Initialize-Ocr {
if ($null -ne $script:ocr) { return }
Add-Type -AssemblyName System.Runtime.WindowsRuntime
$null = [Windows.Storage.StorageFile, Windows.Storage, ContentType = WindowsRuntime]
$null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType = WindowsRuntime]
$null = [Windows.Foundation.IAsyncOperation`1, Windows.Foundation, ContentType = WindowsRuntime]
$null = [Windows.Graphics.Imaging.SoftwareBitmap, Windows.Foundation, ContentType = WindowsRuntime]
$null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Foundation, ContentType = WindowsRuntime]
$null = [Windows.Storage.Streams.RandomAccessStream, Windows.Storage.Streams, ContentType = WindowsRuntime]
$script:asTask = [System.WindowsRuntimeSystemExtensions].GetMethods() |
Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1' } |
Select-Object -First 1
$engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages()
if ($null -eq $engine) {
try {
$null = [Windows.Globalization.Language, Windows.Globalization, ContentType = WindowsRuntime]
$engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromLanguage([Windows.Globalization.Language]::new('en-US'))
} catch { }
}
if ($null -eq $engine) {
throw 'Windows OCR has no language available. Add English under Settings > Time & language > Language & region.'
}
$script:ocr = $engine
}
function Await($operation, [Type]$type) {
$task = $script:asTask.MakeGenericMethod($type).Invoke($null, @($operation))
if (-not $task.Wait(20000)) { throw 'Windows OCR timed out' }
$task.Result
}
function Read-Text([string]$path) {
Initialize-Ocr
$file = Await ([Windows.Storage.StorageFile]::GetFileFromPathAsync($path)) ([Windows.Storage.StorageFile])
$stream = Await ($file.OpenAsync([Windows.Storage.FileAccessMode]::Read)) ([Windows.Storage.Streams.IRandomAccessStream])
try {
$decoder = Await ([Windows.Graphics.Imaging.BitmapDecoder]::CreateAsync($stream)) ([Windows.Graphics.Imaging.BitmapDecoder])
$bitmap = Await ($decoder.GetSoftwareBitmapAsync()) ([Windows.Graphics.Imaging.SoftwareBitmap])
$result = Await ($script:ocr.RecognizeAsync($bitmap)) ([Windows.Media.Ocr.OcrResult])
} finally {
$stream.Dispose()
}
$lines = New-Object System.Collections.ArrayList
foreach ($line in $result.Lines) {
$left = [double]::MaxValue; $top = [double]::MaxValue; $right = 0.0; $bottom = 0.0
foreach ($word in $line.Words) {
$r = $word.BoundingRect
$left = [Math]::Min($left, $r.X); $top = [Math]::Min($top, $r.Y)
$right = [Math]::Max($right, $r.X + $r.Width); $bottom = [Math]::Max($bottom, $r.Y + $r.Height)
}
if ($right -le 0) { continue }
$null = $lines.Add(@{ text = $line.Text; x = $left; y = $top; width = $right - $left; height = $bottom - $top })
}
return , $lines.ToArray()
}
# ── Request loop ──────────────────────────────────────────────────────────
function Reply($id, [bool]$ok, $value) {
$message = @{ id = $id; ok = $ok }
if ($ok) { $message.result = $value } else { $message.error = [string]$value }
$stdout.WriteLine((ConvertTo-Json -InputObject $message -Compress -Depth 6))
}
Reply 0 $true @{ ready = $true; dpi = $script:dpi; powershell = $PSVersionTable.PSVersion.ToString() }
while ($true) {
$line = $stdin.ReadLine()
if ($null -eq $line) { break }
if (-not $line.Trim()) { continue }
$id = $null
try {
$request = ConvertFrom-Json $line
$id = $request.id
$cmd = [string]$request.cmd
$result = $null
if ($cmd -eq 'ping') {
$result = @{ pong = $true }
} elseif ($cmd -eq 'awake') {
$result = [VashDesktop]::StayAwake([bool]$request.on)
} elseif ($cmd -eq 'session') {
$result = [VashDesktop]::Session()
} elseif ($cmd -eq 'windows') {
$result = [VashDesktop]::Windows([string]$request.under)
} elseif ($cmd -eq 'window') {
$result = [VashDesktop]::Window([long]$request.hwnd)
} elseif ($cmd -eq 'processes') {
$result = [VashDesktop]::Processes([string]$request.under)
} elseif ($cmd -eq 'kill') {
$result = [VashDesktop]::Kill([int[]]@($request.pids))
} elseif ($cmd -eq 'close') {
$result = [VashDesktop]::CloseProcessWindows([int]$request.pid)
} elseif ($cmd -eq 'focus') {
$result = [VashDesktop]::Focus([long]$request.hwnd)
} elseif ($cmd -eq 'move') {
$result = [VashDesktop]::Move([long]$request.hwnd, [int]$request.x, [int]$request.y)
} elseif ($cmd -eq 'title') {
$result = [VashDesktop]::SetTitle([long]$request.hwnd, [string]$request.title)
} elseif ($cmd -eq 'capture') {
$format = if ($request.format) { [string]$request.format } else { 'png' }
$quality = if ($request.quality) { [long]$request.quality } else { 80 }
$size = [VashDesktop]::Capture([int]$request.x, [int]$request.y, [int]$request.width, [int]$request.height,
[string]$request.path, $format, [int]$request.maxWidth, [double]$request.scale, [bool]$request.invert, $quality)
$result = @{ width = $size[0]; height = $size[1] }
} elseif ($cmd -eq 'ocr') {
$result = Read-Text ([string]$request.path)
} elseif ($cmd -eq 'ocrReady') {
Initialize-Ocr
$result = @{ language = $script:ocr.RecognizerLanguage.LanguageTag; maxDimension = [Windows.Media.Ocr.OcrEngine]::MaxImageDimension }
} elseif ($cmd -eq 'click') {
$result = [VashDesktop]::Click([int]$request.x, [int]$request.y, [bool]$request.right)
} elseif ($cmd -eq 'type') {
$result = [VashDesktop]::TypeText([string]$request.text)
} elseif ($cmd -eq 'key') {
[VashDesktop]::PressKey([uint16]$request.vk)
$result = $true
} else {
throw "Unknown command $cmd"
}
Reply $id $true $result
} catch {
Reply $id $false $_.Exception.Message
}
}