Files
esports/streamer/windows/tasks.ts
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

133 lines
4.8 KiB
TypeScript

/**
* The scheduled task that starts an instance inside the signed-in Windows
* desktop. Programs started straight from an SSH session run in a session
* with no desktop, where OBS, osu! and window capture cannot work; a task
* with LogonType Interactive runs in the user's own desktop session instead,
* and keeps running when the SSH connection closes.
*/
export const taskName = (instance: string) => `Vash Broadcast (${instance})`;
/** Runs a PowerShell script with values passed as environment variables (no quoting problems). */
export async function powershell(script: string, env: Record<string, string> = {}) {
if (process.platform !== 'win32') throw new Error('Scheduled tasks exist only on Windows');
// -EncodedCommand (UTF-16LE, base64) sidesteps Windows command-line quoting entirely.
const encoded = Buffer.from(
`$ProgressPreference = 'SilentlyContinue'\n${script}`,
'utf16le'
).toString('base64');
const child = Bun.spawn(
[
'powershell.exe',
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-EncodedCommand',
encoded
],
{ stdout: 'pipe', stderr: 'pipe', env: { ...process.env, ...env }, windowsHide: true }
);
const [stdout, stderr, code] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited
]);
if (code !== 0)
throw new Error(
stderr.trim().split(/\r?\n/).slice(0, 3).join(' ') || `PowerShell exited with ${code}`
);
return stdout.trim();
}
export async function registerTask(options: {
instance: string;
exe: string;
cwd: string;
autostart: boolean;
}) {
await powershell(
`$ErrorActionPreference = 'Stop'
$user = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
$action = New-ScheduledTaskAction -Execute $env:VASH_EXE -Argument $env:VASH_ARGS -WorkingDirectory $env:VASH_CWD
$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited
# Priority 4 is normal; the default (7) would slow OBS and osu! down.
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew -StartWhenAvailable -Priority 4
$task = @{ Action = $action; Principal = $principal; Settings = $settings; Description = 'Vash broadcast worker. Manage it with vash-broadcast.exe.' }
if ($env:VASH_AUTOSTART -eq '1') { $task.Trigger = New-ScheduledTaskTrigger -AtLogOn -User $user }
$definition = New-ScheduledTask @task
Register-ScheduledTask -TaskName $env:VASH_TASK -InputObject $definition -Force | Out-Null`,
{
VASH_TASK: taskName(options.instance),
VASH_EXE: options.exe,
VASH_ARGS: `task --instance ${options.instance}`,
VASH_CWD: options.cwd,
VASH_AUTOSTART: options.autostart ? '1' : '0'
}
);
}
export async function runTask(instance: string) {
await powershell(`Start-ScheduledTask -TaskName $env:VASH_TASK`, {
VASH_TASK: taskName(instance)
});
}
export async function endTask(instance: string) {
await powershell(`Stop-ScheduledTask -TaskName $env:VASH_TASK -ErrorAction SilentlyContinue`, {
VASH_TASK: taskName(instance)
}).catch(() => {});
}
export async function unregisterTask(instance: string) {
await powershell(
`Unregister-ScheduledTask -TaskName $env:VASH_TASK -Confirm:$false -ErrorAction SilentlyContinue`,
{ VASH_TASK: taskName(instance) }
).catch(() => {});
}
export interface TaskInfo {
exists: boolean;
state: string | null;
lastRunTime: string | null;
lastTaskResult: number | null;
autostart: boolean;
}
export async function taskInfo(instance: string): Promise<TaskInfo> {
const output = await powershell(
`$task = Get-ScheduledTask -TaskName $env:VASH_TASK -ErrorAction SilentlyContinue
if ($null -eq $task) { '{"exists":false}'; exit }
$info = $task | Get-ScheduledTaskInfo
@{ exists = $true; state = [string]$task.State; lastRunTime = if ($info.LastRunTime) { $info.LastRunTime.ToString('o') } else { $null }; lastTaskResult = [long]$info.LastTaskResult; autostart = @($task.Triggers).Count -gt 0 } | ConvertTo-Json -Compress`,
{ VASH_TASK: taskName(instance) }
);
const info = JSON.parse(output) as Partial<TaskInfo>;
return {
exists: !!info.exists,
state: info.state ?? null,
lastRunTime: info.lastRunTime ?? null,
lastTaskResult: info.lastTaskResult ?? null,
autostart: !!info.autostart
};
}
/** Who is signed in at the PC's own screen, if anyone. */
export async function consoleUser() {
const output = await powershell(`(Get-CimInstance Win32_ComputerSystem).UserName`).catch(
() => ''
);
return output || null;
}
export async function currentUser() {
return powershell(`[System.Security.Principal.WindowsIdentity]::GetCurrent().Name`);
}
export async function gpuNames() {
const output = await powershell(
`(Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name) -join "\`n"`
).catch(() => '');
return output.split(/\r?\n/).filter(Boolean);
}