fix(desktop): launch Windows runner without a console

This commit is contained in:
winewill committed 2026-10-16 12:42:29 +08:00
1 parent 085f3c6a58
commit 9defbaf451
8 files changed
+124 -10

No files matched your search

+3
View File
@@ -581,6 +581,9 @@ jobs:
$null = [Management.Automation.Language.Parser]::ParseFile((Join-Path $env:GITHUB_WORKSPACE $path), [ref]$tokens, [ref]$parseErrors)
if ($parseErrors.Count) { throw ($parseErrors -join "`n") }
}
- name: Verify windowless Windows runner launcher
shell: pwsh
run: ./apps/desktop/tests/windows-runner-launcher.ps1
- name: Run Windows observational gates on built artifacts
id: observational
continue-on-error: true
@@ -0,0 +1,50 @@
// Compiled as a Windows GUI executable so Task Scheduler never allocates a console.
using System;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
internal static class WindowsRunnerLauncher
{
private static int Main(string[] args)
{
string root = AppDomain.CurrentDomain.BaseDirectory;
string log = Path.Combine(root, "launcher-" + Process.GetCurrentProcess().Id);
try
{
if (args.Length != 1 || !Path.IsPathRooted(args[0]))
throw new ArgumentException("Expected the absolute PowerShell executable path.");
using (var output = File.Create(log + ".out.log"))
using (var errors = File.Create(log + ".err.log"))
using (var child = new Process())
{
child.StartInfo = new ProcessStartInfo
{
FileName = args[0],
Arguments = "-NoProfile -NonInteractive -File \"" + Path.Combine(root, "run.ps1") + "\"",
WorkingDirectory = Directory.GetParent(root.TrimEnd(Path.DirectorySeparatorChar)).FullName,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
};
child.Start();
Task stdout = child.StandardOutput.BaseStream.CopyToAsync(output);
Task stderr = child.StandardError.BaseStream.CopyToAsync(errors);
child.WaitForExit();
Task.WaitAll(stdout, stderr);
return child.ExitCode;
}
}
catch (Exception error)
{
try { File.AppendAllText(log + ".err.log", error.ToString() + Environment.NewLine); }
catch (Exception)
{
// No console is available. Task Scheduler result 3 reports that diagnostics could not be written.
return 3;
}
return 2;
}
}
}
+6 -2
View File
@@ -37,12 +37,16 @@ if ($Action -eq 'install') {
} else {
$null = New-Item -ItemType Directory -Path $serviceDir
}
$compiler = Join-Path $env:SystemRoot 'Microsoft.NET/Framework64/v4.0.30319/csc.exe'
if (-not (Test-Path -LiteralPath $compiler)) { throw 'Install requires the Windows .NET Framework 4 compiler (Framework64/v4.0.30319/csc.exe).' }
$launcher = Join-Path $serviceDir 'launcher.exe'
& $compiler /nologo /target:winexe "/out:$launcher" (Join-Path $PSScriptRoot 'windows-runner-launcher.cs')
if ($LASTEXITCODE -ne 0) { throw 'Runner launcher compilation failed; no task was installed.' }
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'start-windows-packaging-runner.ps1') -Destination (Join-Path $serviceDir 'start.ps1')
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'run-windows-runner-task.ps1') -Destination (Join-Path $serviceDir 'run.ps1')
@{ RunnerRoot = $RunnerRoot; ConfigRoot = $ConfigRoot; TaskName = $taskName; RestartCount = $RestartCount } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $serviceDir 'config.json') -Encoding utf8
Set-Content -LiteralPath (Join-Path $serviceDir 'attempts.txt') -Value '0'
$arguments = '-NoProfile -NonInteractive -WindowStyle Hidden -File "' + (Join-Path $serviceDir 'run.ps1') + '"'
$execution = New-ScheduledTaskAction -Execute $pwsh -Argument $arguments -WorkingDirectory $RunnerRoot
$execution = New-ScheduledTaskAction -Execute $launcher -Argument ('"' + $pwsh + '"') -WorkingDirectory $RunnerRoot
$principal = New-ScheduledTaskPrincipal -UserId $sid -LogonType Interactive -RunLevel Limited
$trigger = @(
(New-ScheduledTaskTrigger -AtLogOn -User $sid),
@@ -0,0 +1,44 @@
# Windowless launcher acceptance without Task Scheduler, GitHub registration or signing credentials.
$ErrorActionPreference = 'Stop'
if (-not $IsWindows) { throw 'Requires PowerShell 7 on Windows.' }
$root = Join-Path ([IO.Path]::GetTempPath()) ('dsh-launcher-' + [guid]::NewGuid().ToString('N'))
$taskRoot = Join-Path $root 'task with spaces'
$null = New-Item -ItemType Directory -Path $taskRoot
$launcher = Join-Path $taskRoot 'launcher.exe'
$compiler = Join-Path $env:SystemRoot 'Microsoft.NET/Framework64/v4.0.30319/csc.exe'
& $compiler /nologo /target:winexe "/out:$launcher" (Join-Path $PSScriptRoot '../scripts/windows-runner-launcher.cs')
if ($LASTEXITCODE -ne 0) { throw 'Launcher compilation failed.' }
$image = [IO.File]::ReadAllBytes($launcher)
$peOffset = [BitConverter]::ToInt32($image, 0x3c)
if ([BitConverter]::ToUInt16($image, $peOffset + 24 + 68) -ne 2) { throw 'Launcher must use the Windows GUI subsystem.' }
@'
Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class ConsoleProbe { [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); }'
[Console]::WriteLine('console=' + [ConsoleProbe]::GetConsoleWindow().ToInt64())
[Console]::Error.WriteLine('launcher stderr probe')
exit 37
'@ | Set-Content -LiteralPath (Join-Path $taskRoot 'run.ps1')
function Invoke-Launcher([string]$Executable, [int]$ExpectedExit) {
$process = [Diagnostics.Process]::new()
$process.StartInfo = [Diagnostics.ProcessStartInfo]::new($launcher)
$process.StartInfo.UseShellExecute = $false
$process.StartInfo.CreateNoWindow = $true
$process.StartInfo.ArgumentList.Add($Executable)
try {
$null = $process.Start()
if (-not $process.WaitForExit(30000)) { throw 'Launcher did not exit within 30 seconds.' }
if ($process.ExitCode -ne $ExpectedExit) { throw "Expected exit $ExpectedExit, got $($process.ExitCode)." }
return (Join-Path $taskRoot ('launcher-' + $process.Id))
} finally {
if ($process.Id -and -not $process.HasExited) {
$process.Kill($true)
if (-not $process.WaitForExit(30000)) { throw 'Launcher teardown did not complete.' }
}
$process.Dispose()
}
}
$log = Invoke-Launcher (Join-Path $PSHOME 'pwsh.exe') 37
if ((Get-Content -LiteralPath ($log + '.out.log') -Raw).Trim() -ne 'console=0') { throw 'Child allocated a console or stdout was lost.' }
if ((Get-Content -LiteralPath ($log + '.err.log') -Raw).Trim() -ne 'launcher stderr probe') { throw 'Child stderr was lost.' }
$log = Invoke-Launcher (Join-Path $root 'missing.exe') 2
if (-not (Get-Content -LiteralPath ($log + '.err.log') -Raw).Contains('Win32Exception')) { throw 'Startup failure diagnostic was lost.' }
Write-Output "PASS: GUI executable, no child console, exit propagation, stdout/stderr and startup failure. Evidence: $root"
+11 -2
View File
@@ -7,11 +7,13 @@ $source = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../scripts'))
$runner = Join-Path $root 'runner with spaces'
$null = New-Item -ItemType Directory -Path $runner
Set-Content -LiteralPath (Join-Path $runner '.runner') -Value '{}'
foreach ($file in @('windows-runner-task.ps1','run-windows-runner-task.ps1')) { Copy-Item -LiteralPath (Join-Path $source $file) -Destination $root }
foreach ($file in @('windows-runner-task.ps1','run-windows-runner-task.ps1','windows-runner-launcher.cs')) { Copy-Item -LiteralPath (Join-Path $source $file) -Destination $root }
$manager = Join-Path $root 'windows-runner-task.ps1'
@'
param([string]$RunnerRoot, [string]$ConfigRoot, [switch]$CheckOnly)
if ($CheckOnly) { exit 0 }
Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class ConsoleProbe { [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); }'
Add-Content -LiteralPath (Join-Path $RunnerRoot 'consoles.txt') -Value ([ConsoleProbe]::GetConsoleWindow().ToInt64())
Add-Content -LiteralPath (Join-Path $RunnerRoot 'starts.txt') -Value $PID
if (-not (Test-Path -LiteralPath (Join-Path $RunnerRoot 'failed-once'))) {
Set-Content -LiteralPath (Join-Path $RunnerRoot 'failed-once') -Value 'yes'
@@ -42,11 +44,18 @@ try {
$installed = $true
$config = Get-Content -LiteralPath (Join-Path $runner 'dsh-task/config.json') -Raw | ConvertFrom-Json
$task = Get-ScheduledTask -TaskName $config.TaskName -TaskPath '\'
$launcher = Join-Path $runner 'dsh-task/launcher.exe'
if ($task.Actions.Execute -ne $launcher) { throw 'Task must start the windowless launcher.' }
$image = [IO.File]::ReadAllBytes($launcher)
$peOffset = [BitConverter]::ToInt32($image, 0x3c)
if ([BitConverter]::ToUInt16($image, $peOffset + 24 + 68) -ne 2) { throw 'Launcher must use the Windows GUI subsystem.' }
if ($task.State -ne 'Disabled' -or $task.Settings.ExecutionTimeLimit -ne 'PT0S' -or $task.Settings.MultipleInstances -ne 'IgnoreNew') { throw 'Unexpected task scheduling settings.' }
Invoke-Manager install -ExpectFailure
Invoke-Manager start
$starts = Join-Path $runner 'starts.txt'
Wait-Condition { (Test-Path -LiteralPath $starts) -and @(Get-Content -LiteralPath $starts).Count -ge 2 } 'automatic retry after unexpected clean exit' 110
$consoles = @(Get-Content -LiteralPath (Join-Path $runner 'consoles.txt'))
if ($consoles.Count -ne 2 -or @($consoles | Where-Object { $_ -ne '0' }).Count) { throw 'Initial or recovered runner allocated a console.' }
$childId = [int]@(Get-Content -LiteralPath $starts)[-1]
$child = Get-Process -Id $childId
Invoke-Manager start
@@ -83,7 +92,7 @@ try {
$installed = $false
Invoke-Manager status -ExpectFailure
if (-not (Test-Path -LiteralPath (Join-Path $runner '.runner'))) { throw 'Registration removed.' }
Write-Output "PASS: install, duplicate refusal, automatic retry, start, status, stop, child teardown, retry exhaustion, uninstall. Evidence: $root"
Write-Output "PASS: windowless initial launch and recovery, install, duplicate refusal, automatic retry, start, status, stop, child teardown, retry exhaustion, uninstall. Evidence: $root"
} catch { $primaryFailure = $_; throw } finally {
$cleanupErrors = @()
if ($installed) {
@@ -21,8 +21,8 @@
en: bb889e9514678d1c
zh: 48cbec1e907d6a2b
/windows-signing-and-runner-operations/machine-setup-and-runner-startup/supervised-startup:
en: d32f9f8ce9f5b861
zh: 80d9078a7763f85d
en: 3ed3e760cfa0c383
zh: 8b6ba9ddbefb6eb0
/windows-signing-and-runner-operations/signing-configuration:
en: 4b5548818375eb9a
zh: 8c975adbb1dca9c8
+4 -2
View File
@@ -36,13 +36,15 @@ Run `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Roo
### Supervised startup
The task installer requires the Windows .NET Framework 4 compiler at `%SystemRoot%/Microsoft.NET/Framework64/v4.0.30319/csc.exe`. It compiles a GUI executable into `runner/dsh-task/launcher.exe`; this executable starts PowerShell with no console and waits for it to exit. Startup output and errors go to `launcher-*.out.log` and `launcher-*.err.log`. Task result `2` indicates launcher failure; `3` means its error log could not be written. Normal runner output remains in `run-*.log`.
Run `pwsh -NoProfile -File apps/desktop/scripts/windows-runner-task.ps1 -Action install -RunnerRoot <registered-runner-directory> -ConfigRoot <machine-root>/packaging` as the runner's Windows user, using its installed PowerShell 7. The installer checks the environment and copies startup scripts into `runner/dsh-task`; registration, tools and secrets remain separate. It registers a disabled, limited-permission interactive Task Scheduler task without storing an account password. For `-Action start` or `-Action status`, pass only `-RunnerRoot`; configuration and retry options apply only to installation. The task starts after this user's desktop login, including after reboot, and stops on logout. It does not provide unattended boot or unlock a signing Token.
The task permits one instance and has no execution time limit. A repeating trigger checks every `-RestartMinutes` minutes (default `1`) and restarts an exited launcher, including an unexpected zero exit. The persisted budget permits the initial launch plus `-RestartCount` restarts (default `10`) between explicit starts. Every desktop login also consumes this budget; `status` shows the remaining launches. After the budget is exhausted, login alone cannot restart the runner. Starting an already running task does not reset the budget. Missing or invalid budget/configuration files disable automatic starts until repaired. GitHub's launcher owns runner updates; task recovery does not rerun a failed workflow or clear signing interlocks. After retries are exhausted, inspect `runner/dsh-task/run-*.log`, the runner's `_diag` logs and `status`, correct the cause, then run `start`. A running task alone does not prove connectivity: verify `Connected to GitHub` and `Listening for Jobs`, then verify assignment in Actions. Logs are retained for operator-managed cleanup.
Before maintenance, pause new workflow dispatch and wait for the runner to become idle. `-Action stop` refuses an active worker for this runner, disables future starts and stops its task process tree; the local check cannot prevent a concurrent GitHub assignment. `-Action uninstall` requires a stopped, disabled task and retains registration, configuration and logs. To update installed scripts, stop, uninstall and install again. When migrating a manually launched runner, stop its existing launcher only after it is idle, then start the task; never run both launchers together.
Before maintenance, pause new workflow dispatch and wait for the runner to become idle. `-Action stop` refuses an active worker for this runner, disables future starts and stops its task process tree; the local check cannot prevent a concurrent GitHub assignment. `-Action uninstall` requires a stopped, disabled task and retains registration, configuration and logs. Pulling repository changes does not update the installed copy. To upgrade it, record the configuration directory and retry settings, then run `stop`, `uninstall`, `install` with those settings, and `start` from the updated checkout. Confirm listening and job assignment again. When migrating a manually launched runner, stop its existing launcher only after it is idle, then start the task; never run both launchers together.
Operators can run `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1` on Windows to exercise installation, automatic recovery, duplicate-start prevention, stop, child-process teardown and uninstall against an isolated fake runner. This opt-in test creates a temporary task and retains diagnostic files; it does not contact GitHub or access signing hardware. Real-machine acceptance additionally checks an idle runner coming online, an Actions job being assigned, and login after an operator-approved reboot. Signed packaging and logout/reboot acceptance require separate operator verification.
Operators can run `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1` on Windows to exercise installation, console-free initial launch and automatic recovery, duplicate-start prevention, stop, child-process teardown and uninstall against an isolated fake runner. This opt-in test creates a temporary task and retains diagnostic files; it does not contact GitHub or access signing hardware. `pwsh -NoProfile -File apps/desktop/tests/windows-runner-launcher.ps1` checks the GUI executable, child console absence, exit status and diagnostic output without Task Scheduler; Windows CI runs it. Real-machine acceptance additionally checks an idle runner coming online without a visible Terminal window, an Actions job being assigned, and login after an operator-approved reboot. Signed packaging and logout/reboot acceptance require separate operator verification.
<a id="signing-configuration"></a>
+4 -2
View File
@@ -36,13 +36,15 @@ description: "配置和维护 Windows 打包 runner、签名凭据、EV Token
### 受监督启动
任务安装器需要 Windows .NET Framework 4 编译器,路径为 `%SystemRoot%/Microsoft.NET/Framework64/v4.0.30319/csc.exe`。它将 GUI 可执行文件编译到 `runner/dsh-task/launcher.exe`;该程序不分配控制台地启动 PowerShell,并等待其退出。启动输出和错误分别写入 `launcher-*.out.log` 和 `launcher-*.err.log`。任务结果 `2` 表示启动器失败,`3` 表示无法写入错误日志。正常 runner 输出仍保存在 `run-*.log`。
以 runner 的 Windows 用户运行 `pwsh -NoProfile -File apps/desktop/scripts/windows-runner-task.ps1 -Action install -RunnerRoot <registered-runner-directory> -ConfigRoot <machine-root>/packaging`,使用该机器安装的 PowerShell 7。安装器检查环境,并将启动脚本复制到 `runner/dsh-task`;注册、工具和凭据仍单独管理。它注册一个初始禁用、权限受限的交互式计划任务,不保存账户密码。执行 `-Action start` 或 `-Action status` 时只传 `-RunnerRoot`;配置目录及重试选项仅适用于安装。任务在该用户登录桌面后启动,重启后也需要登录,注销时停止。它不提供无人值守开机,也不解锁签名 Token。
任务只允许一个实例,且没有执行时限。周期触发器每隔 `-RestartMinutes` 分钟(默认 `1`)检查一次,并恢复已退出的启动器,包括意外的零退出码。持久预算允许首次启动及 `-RestartCount` 次重启(默认 `10`),直到再次显式启动。每次桌面登录也会消耗预算,`status` 显示剩余启动次数;预算耗尽后,仅登录不能重启 runner。对正在运行的任务执行 start 不会重置预算。预算或配置文件缺失、无效时,自动启动会禁用,需修复后恢复。GitHub 启动器负责 runner 更新;任务恢复不会重跑失败的 workflow,也不会清除签名互锁。重试耗尽后,检查 `runner/dsh-task/run-*.log`、runner 的 `_diag` 日志和 `status`,修复原因后运行 `start`。任务运行不等于连接成功:确认出现 `Connected to GitHub` 和 `Listening for Jobs`,再到 Actions 确认任务分配。日志保留,由操作者管理清理。
维护前暂停新 workflow 的触发,等待 runner 空闲。`-Action stop` 在该 runner 存在活跃 worker 时拒绝停止,否则禁用后续启动并停止任务进程树;本地检查无法阻止 GitHub 同时分配任务。`-Action uninstall` 要求任务已停止并禁用,保留注册、配置和日志。更新已安装脚本时,依次停止、卸载、重新安装。迁移手动启动的 runner 时,只在空闲后停止原启动器,再启动计划任务;不要同时运行两个启动器。
维护前暂停新 workflow 的触发,等待 runner 空闲。`-Action stop` 在该 runner 存在活跃 worker 时拒绝停止,否则禁用后续启动并停止任务进程树;本地检查无法阻止 GitHub 同时分配任务。`-Action uninstall` 要求任务已停止并禁用,保留注册、配置和日志。拉取仓库更新不会更新已安装的副本。升级时先记录配置目录和重试设置,再从更新后的 checkout 依次执行 `stop`、`uninstall`、携带原设置的 `install` 和 `start`,最后重新确认监听与 job 分配。迁移手动启动的 runner 时,只在空闲后停止原启动器,再启动计划任务;不要同时运行两个启动器。
操作者可在 Windows 上运行 `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1`,使用隔离的假 runner 验证安装、自动恢复、防止重复启动、停止、子进程退出和卸载。此显式运行的测试创建临时任务并保留诊断文件,不连接 GitHub 或访问签名硬件。真实机器验收还需检查空闲 runner 上线、Actions job 分配,以及经操作者批准的重启后登录。签名打包和注销/重启验收需要操作者单独验证。
操作者可在 Windows 上运行 `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1`,使用隔离的假 runner 验证安装、无控制台的首次启动与自动恢复、防止重复启动、停止、子进程退出和卸载。此显式运行的测试创建临时任务并保留诊断文件,不连接 GitHub 或访问签名硬件。`pwsh -NoProfile -File apps/desktop/tests/windows-runner-launcher.ps1` 无需计划任务,检查 GUI 可执行文件、子进程无控制台、退出状态及诊断输出;Windows CI 会执行该测试。真实机器验收还需检查空闲 runner 上线且不出现 Terminal 窗口、Actions job 分配,以及经操作者批准的重启后登录。签名打包和注销/重启验收需要操作者单独验证。
<a id="signing-configuration"></a>