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>
This commit is contained in:
132
streamer/windows/tasks.ts
Normal file
132
streamer/windows/tasks.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user