fix(shell): preserve Windows launcher paths and elevation arguments

This commit is contained in:
手瓜一十雪
2026-10-10 11:17:31 +08:00
parent f8786c65de
commit ff9a829e29
8 changed files with 210 additions and 23 deletions
@@ -0,0 +1,96 @@
$ErrorActionPreference = 'Stop'
$auditRoot = Join-Path ([IO.Path]::GetTempPath()) 'napcat-windows-launcher-tests'
$sourceRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../../../packages/napcat-shell-loader'))
New-Item -ItemType Directory -Path $auditRoot -Force | Out-Null
$compiledStub = Join-Path $auditRoot ('stub-' + [guid]::NewGuid().ToString('N') + '.exe')
& (Join-Path $env:WINDIR 'Microsoft.NET/Framework64/v4.0.30319/csc.exe') /nologo ("/out:" + $compiledStub) (Join-Path $PSScriptRoot 'windows-launcher-stub.cs')
if ($LASTEXITCODE -ne 0) { throw 'Failed to compile launcher fixture' }
$stubDirectory = Join-Path $auditRoot 'tools'
New-Item -ItemType Directory -Path $stubDirectory -Force | Out-Null
foreach ($stubName in @('reg.exe', 'net.exe', 'powershell.exe')) {
Copy-Item -LiteralPath $compiledStub -Destination (Join-Path $stubDirectory $stubName) -Force
}
$results = [Collections.Generic.List[object]]::new()
foreach ($launcher in @('launcher.bat', 'launcher-win10.bat', 'launcher-user.bat', 'launcher-win10-user.bat')) {
foreach ($pathKind in @('plain', 'space', 'special', 'unquoted', 'missing-registry', 'boot-error', 'elevation')) {
if ($pathKind -eq 'elevation' -and $launcher -like '*-user.bat') { continue }
$directoryName = switch ($pathKind) {
plain { 'plain' }
space { 'space path' }
special { "路径 & () ! # ' test" }
default { "路径 & () ! # ' $pathKind" }
}
$fixture = Join-Path $auditRoot ($launcher.Replace('.bat', '') + '\' + $directoryName)
New-Item -ItemType Directory -Path $fixture -Force | Out-Null
Copy-Item -LiteralPath (Join-Path $sourceRoot $launcher) -Destination $fixture -Force
Copy-Item -LiteralPath $compiledStub -Destination (Join-Path $fixture 'NapCatWinBootMain.exe') -Force
New-Item -ItemType File -Path (Join-Path $fixture 'QQ.exe') -Force | Out-Null
$output = Join-Path $fixture 'arguments.txt'
if (Test-Path -LiteralPath $output) { Remove-Item -LiteralPath $output }
$startInfo = [Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $env:ComSpec
$startInfo.Arguments = '/d /s /c ""' + (Join-Path $fixture $launcher) + '" -q "123456" "argument with spaces" "literal!value""'
$startInfo.WorkingDirectory = $auditRoot
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
$startInfo.RedirectStandardInput = $true
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
$startInfo.EnvironmentVariables['PATH'] = $stubDirectory + ';' + $env:PATH
$startInfo.EnvironmentVariables['NAPCAT_AUDIT_UNINSTALL'] = if ($pathKind -eq 'missing-registry') { '' } elseif ($pathKind -eq 'unquoted') { Join-Path $fixture 'Uninstall.exe' } else { '"' + (Join-Path $fixture 'Uninstall.exe') + '"' }
$startInfo.EnvironmentVariables['NAPCAT_AUDIT_NET_STATUS'] = if ($pathKind -eq 'elevation') { '1' } else { '0' }
$startInfo.EnvironmentVariables['NAPCAT_AUDIT_BOOT_STATUS'] = if ($pathKind -eq 'boot-error') { '31' } else { '0' }
$startInfo.EnvironmentVariables['NAPCAT_AUDIT_OUTPUT'] = $output
$process = [Diagnostics.Process]::Start($startInfo)
$stdout = $process.StandardOutput.ReadToEndAsync()
$stderr = $process.StandardError.ReadToEndAsync()
$process.StandardInput.WriteLine('')
$process.StandardInput.Close()
if (-not $process.WaitForExit(10000)) {
& taskkill.exe /PID $process.Id /T /F | Out-Null
throw "$launcher $pathKind timed out"
}
$expected = @((Join-Path $fixture 'QQ.exe'), (Join-Path $fixture 'NapCatWinBootHook.dll'), '-q', '123456', 'argument with spaces', 'literal!value')
if ($pathKind -eq 'missing-registry') { $expected = @() }
if ($pathKind -eq 'elevation') {
$terminal = if ($launcher -eq 'launcher.bat') { 'wt.exe' } else { 'powershell.exe' }
$expected = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $fixture 'launcher-elevate.ps1'), $terminal, (Join-Path $fixture $launcher), '-q', '123456', 'argument with spaces', 'literal!value')
}
$actual = @()
if (Test-Path -LiteralPath $output) {
$actual = @(Get-Content -LiteralPath $output -Encoding UTF8 | Select-Object -Skip 1 | ForEach-Object { [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($_)) })
}
$passed = $actual.Count -eq $expected.Count
for ($argumentIndex = 0; $argumentIndex -lt $expected.Count -and $passed; $argumentIndex++) {
$passed = $actual[$argumentIndex] -ceq $expected[$argumentIndex]
}
$expectedExit = if ($pathKind -eq 'missing-registry') { 1 } elseif ($pathKind -eq 'boot-error') { 31 } else { 0 }
$passed = $passed -and $process.ExitCode -eq $expectedExit
$result = [PSCustomObject]@{ launcher=$launcher; path=$pathKind; passed=$passed; exit=$process.ExitCode; actual=$actual; stdout=$stdout.Result; stderr=$stderr.Result }
$results.Add($result)
$result | Select-Object launcher,path,passed,exit | ConvertTo-Json -Compress
}
}
function Start-Process {
param([string]$FilePath, [string]$ArgumentList, [string]$Verb)
$global:napcatAuditElevationRequest = @{ file=$FilePath; arguments=$ArgumentList; verb=$Verb }
}
foreach ($terminal in @('wt.exe', 'powershell.exe')) {
$testScript = "C:\路径 & () ! # '\launcher.bat"
$testArguments = @('-q', '123456', 'argument with spaces', 'literal!value', 'literal$()', 'quote"value')
& (Join-Path $sourceRoot 'launcher-elevate.ps1') $terminal $testScript @testArguments
$encodedCommand = ($global:napcatAuditElevationRequest.arguments -split ' ')[-1]
$command = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encodedCommand))
$parseTokens = $null
$parseErrors = $null
[Management.Automation.Language.Parser]::ParseInput($command, [ref]$parseTokens, [ref]$parseErrors) | Out-Null
$match = [regex]::Match($command, "FromBase64String\('([A-Za-z0-9+/=]+)'\)")
$payload = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($match.Groups[1].Value)) | ConvertFrom-Json
$passed = $parseErrors.Count -eq 0 -and $payload.script -ceq $testScript -and $global:napcatAuditElevationRequest.verb -eq 'RunAs'
$passed = $passed -and ($payload.arguments | ConvertTo-Json -Compress) -ceq ($testArguments | ConvertTo-Json -Compress)
$result = [PSCustomObject]@{ launcher='launcher-elevate.ps1'; path=$terminal; passed=$passed; exit=0 }
$results.Add($result)
$result | ConvertTo-Json -Compress
}
$results | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $auditRoot 'results.json') -Encoding UTF8
if ($results.Where({ -not $_.passed }).Count) { exit 1 }
@@ -0,0 +1,24 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Text;
public class NapCatLauncherAudit {
public static int Main(string[] arguments) {
string name = Path.GetFileNameWithoutExtension(Process.GetCurrentProcess().MainModule.FileName);
if (name == "reg") {
string uninstall = Environment.GetEnvironmentVariable("NAPCAT_AUDIT_UNINSTALL");
if (String.IsNullOrEmpty(uninstall)) return 1;
Console.OutputEncoding = new UTF8Encoding(false);
Console.WriteLine(" UninstallString REG_SZ " + uninstall);
return 0;
}
if (name == "net") return Int32.Parse(Environment.GetEnvironmentVariable("NAPCAT_AUDIT_NET_STATUS"));
string output = Environment.GetEnvironmentVariable("NAPCAT_AUDIT_OUTPUT");
using (StreamWriter writer = new StreamWriter(output, false, new UTF8Encoding(false))) {
writer.WriteLine(name);
foreach (string argument in arguments) writer.WriteLine(Convert.ToBase64String(Encoding.UTF8.GetBytes(argument)));
}
return Int32.Parse(Environment.GetEnvironmentVariable("NAPCAT_AUDIT_BOOT_STATUS") ?? "0");
}
}
+32
View File
@@ -0,0 +1,32 @@
name: Windows launcher
on:
push:
paths:
- packages/napcat-shell-loader/launcher*
- .github/tests/windows-launcher/**
- .github/workflows/windows-launcher.yml
pull_request:
paths:
- packages/napcat-shell-loader/launcher*
- .github/tests/windows-launcher/**
- .github/workflows/windows-launcher.yml
workflow_dispatch:
permissions:
contents: read
jobs:
launchers:
runs-on: windows-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Check paths, registry, arguments and elevation
shell: pwsh
run: ./.github/tests/windows-launcher/validation.ps1
- uses: actions/upload-artifact@v4
if: always()
with:
name: windows-launcher-results
path: ${{ runner.temp }}/napcat-windows-launcher-tests/results.json
@@ -0,0 +1,20 @@
$ErrorActionPreference = 'Stop'
$terminal = $args[0]
$payload = @{
script = $args[1]
arguments = @($args | Select-Object -Skip 2)
} | ConvertTo-Json -Compress
$encodedPayload = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($payload))
$command = @"
`$payload = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$encodedPayload')) | ConvertFrom-Json
`$launchArguments = @(`$payload.arguments)
& `$payload.script @launchArguments
exit `$LASTEXITCODE
"@
$encodedCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command))
$commandArguments = "-NoProfile -ExecutionPolicy Bypass -EncodedCommand $encodedCommand"
if ($terminal -eq 'wt.exe') {
Start-Process -FilePath $terminal -ArgumentList "powershell.exe $commandArguments" -Verb RunAs
} else {
Start-Process -FilePath $terminal -ArgumentList $commandArguments -Verb RunAs
}
@@ -7,11 +7,14 @@ set "NAPCAT_LOAD_PATH=%cd%\loadNapCat.js"
set "NAPCAT_INJECT_PATH=%cd%\NapCatWinBootHook.dll"
set "NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe"
set "NAPCAT_MAIN_PATH=%cd%\napcat.mjs"
:loop_read
set "RetString="
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
set "RetString=%%~b"
goto :napcat_boot
)
echo QQ installation was not found in the registry.
pause
exit /b 1
:napcat_boot
for %%a in ("%RetString%") do (
@@ -23,9 +26,10 @@ set "QQPath=%pathWithoutUninstall%QQ.exe"
if not exist "%QQpath%" (
echo provided QQ path is invalid
pause
exit /b
exit /b 1
)
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %*
set "napcat_exit_code=%ERRORLEVEL%"
pause
exit /b %napcat_exit_code%
@@ -7,11 +7,14 @@ set "NAPCAT_LOAD_PATH=%cd%\loadNapCat.js"
set "NAPCAT_INJECT_PATH=%cd%\NapCatWinBootHook.dll"
set "NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe"
set "NAPCAT_MAIN_PATH=%cd%\napcat.mjs"
:loop_read
set "RetString="
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
set "RetString=%%~b"
goto :napcat_boot
)
echo QQ installation was not found in the registry.
pause
exit /b 1
:napcat_boot
for %%a in ("%RetString%") do (
@@ -23,11 +26,13 @@ set "QQPath=%pathWithoutUninstall%QQ.exe"
if not exist "%QQpath%" (
echo provided QQ path is invalid
pause
exit /b
exit /b 1
)
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %*
set "napcat_exit_code=%ERRORLEVEL%"
REM Optional: -q <QQ_NUMBER> for quick login, omit for QR code login
REM Example: "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" -q 123456
pause
exit /b %napcat_exit_code%
@@ -3,24 +3,26 @@ setlocal DisableDelayedExpansion
cd /d "%~dp0" || exit /b 1
chcp 65001 >nul
net session >nul 2>&1
if %ERRORLEVEL% == 0 (
echo Administrator mode detected.
) else (
echo Please run this script in administrator mode.
powershell -Command "Start-Process 'cmd.exe' -ArgumentList '/c cd /d \"%cd%\" && \"%~f0\" %*' -Verb runAs"
exit
)
if not errorlevel 1 goto :napcat_admin
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0launcher-elevate.ps1" powershell.exe "%~f0" %*
exit /b %ERRORLEVEL%
:napcat_admin
echo Administrator mode detected.
set "NAPCAT_PATCH_PACKAGE=%cd%\qqnt.json"
set "NAPCAT_LOAD_PATH=%cd%\loadNapCat.js"
set "NAPCAT_INJECT_PATH=%cd%\NapCatWinBootHook.dll"
set "NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe"
set "NAPCAT_MAIN_PATH=%cd%\napcat.mjs"
:loop_read
set "RetString="
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
set "RetString=%%~b"
goto :napcat_boot
)
echo QQ installation was not found in the registry.
pause
exit /b 1
:napcat_boot
for %%a in ("%RetString%") do (
@@ -32,9 +34,10 @@ set "QQPath=%pathWithoutUninstall%QQ.exe"
if not exist "%QQPath%" (
echo provided QQ path is invalid
pause
exit /b
exit /b 1
)
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %*
exit /b %ERRORLEVEL%
REM Optional: -q <QQ_NUMBER> for quick login, omit for QR code login
REM Example: "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" -q 123456
+12 -9
View File
@@ -3,24 +3,26 @@ setlocal DisableDelayedExpansion
cd /d "%~dp0" || exit /b 1
chcp 65001 >nul
net session >nul 2>&1
if %ERRORLEVEL% == 0 (
echo Administrator mode detected.
) else (
echo Please run this script in administrator mode.
powershell -Command "Start-Process 'wt.exe' -ArgumentList 'cmd /c cd /d \"%cd%\" && \"%~f0\" %*' -Verb runAs"
exit
)
if not errorlevel 1 goto :napcat_admin
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0launcher-elevate.ps1" wt.exe "%~f0" %*
exit /b %ERRORLEVEL%
:napcat_admin
echo Administrator mode detected.
set "NAPCAT_PATCH_PACKAGE=%cd%\qqnt.json"
set "NAPCAT_LOAD_PATH=%cd%\loadNapCat.js"
set "NAPCAT_INJECT_PATH=%cd%\NapCatWinBootHook.dll"
set "NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe"
set "NAPCAT_MAIN_PATH=%cd%\napcat.mjs"
:loop_read
set "RetString="
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
set "RetString=%%~b"
goto :napcat_boot
)
echo QQ installation was not found in the registry.
pause
exit /b 1
:napcat_boot
for %%a in ("%RetString%") do (
@@ -32,7 +34,8 @@ set "QQPath=%pathWithoutUninstall%QQ.exe"
if not exist "%QQPath%" (
echo provided QQ path is invalid
pause
exit /b
exit /b 1
)
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %*
exit /b %ERRORLEVEL%