错误输出通道改造: GBK 回退解码与 RPA_ERROR 标记防截断

This commit is contained in:
yu 2026-11-03 17:43:46 +08:00
parent e1f162aa82
commit f390db9c94

View File

@ -82,10 +82,26 @@ function encoded(command: string): string {
function decodeCliXml(detail: string): string {
const lines = [...detail.matchAll(/<S S="Error">(.*?)<\/S>/gs)].map((m) => m[1]);
if (lines.length === 0) return detail;
// 只留「+ 类别/错误消息」首行, 去掉位置堆栈噪声; _xHHHH_ 是 CLIXML 转义
// _xHHHH_ 是 CLIXML 转义
const decoded = lines.join("\n").replace(/_x([0-9A-Fa-f]{4})_/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
const first = decoded.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("所在位置") && !l.startsWith("+") && !l.startsWith("~~") && !l.includes("CategoryInfo") && !l.includes("FullyQualifiedErrorId") && !l.startsWith("<Objs") && !l.startsWith("#<"));
return first[0] || decoded.split("\n")[0].trim() || detail;
const parts = decoded.split("\n").map((l) => l.trim());
const position = parts.filter((l) => l.startsWith("所在位置")).slice(0, 1);
const message = parts.filter(
(l) => l && !l.startsWith("所在位置") && !l.startsWith("+") && !l.startsWith("~~") && !l.includes("CategoryInfo") && !l.includes("FullyQualifiedErrorId") && !l.startsWith("<Objs") && !l.startsWith("#<")
);
const main = message[0] || parts[0] || detail;
return position.length ? `${main}(${position[0]})` : main;
}
/** 管道字节流解码:优先 UTF-8;出现替换符说明是系统代码页(GBK)写入的(如解析类错误不走 UTF-8 前缀),回退 GB18030 */
function decodePipe(buf: Buffer): string {
const utf8 = buf.toString("utf8");
if (!utf8.includes("\uFFFD")) return utf8;
try {
return new TextDecoder("gb18030").decode(buf);
} catch {
return utf8;
}
}
export async function powershell(command: string, timeout = 30000): Promise<string> {
@ -95,12 +111,22 @@ export async function powershell(command: string, timeout = 30000): Promise<stri
const result = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded(utf8 + command)], {
windowsHide: true,
timeout,
maxBuffer: 4 * 1024 * 1024
});
return String(result.stdout || "").trim();
maxBuffer: 4 * 1024 * 1024,
encoding: "buffer"
} as never);
return decodePipe(result.stdout as unknown as Buffer).trim();
} catch (error) {
const raw = error instanceof Error ? error.message : String(error);
const detail = decodeCliXml(raw);
const err = error as Error & { stdout?: unknown; stderr?: unknown };
// 脚本主动失败(RpaFail)把完整错误写在 stdout 的 RPA_ERROR:: 标记后,不经 CLIXML 渲染,无截断
const stdoutText = Buffer.isBuffer(err.stdout) ? decodePipe(err.stdout) : String(err.stdout ?? "");
const marked = stdoutText.match(/RPA_ERROR::([\s\S]*)/);
if (marked) {
const detail = marked[1].trim();
throw new RuntimeError("desktop_command_failed", `桌面操作失败:${detail}`, { detail });
}
const stderrBuf = Buffer.isBuffer(err.stderr) ? err.stderr : Buffer.from(String(err.stderr ?? ""), "utf8");
const stderrText = stderrBuf.length > 0 ? decodePipe(stderrBuf) : err.message;
const detail = decodeCliXml(stderrText);
throw new RuntimeError("desktop_command_failed", `桌面操作失败:${detail}`, { detail });
}
}
@ -179,11 +205,13 @@ const mouseRoll: CapabilityHandler = async (_ctx, node, args) => {
};
/** 把匹配标题的窗口带到前台;多招式依次兜底(应用点击「运行」后自身是前台,
* Windows 前台锁定策略会拒绝普通 SetForegroundWindow,最小化再恢复可绕过) */
* Windows 前台锁定策略会拒绝普通 SetForegroundWindow,最小化再恢复可绕过)。
* 失败用 RPA_ERROR 标记写 stdout 后 exit 1:不走 throw/CLIXML,避免控制台宽度截断和编码歧义 */
function activateWindowScript(selector: string): string {
const safeTitle = selector.replace(/'/g, "''");
return `$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1
if (-not $p) { $vis = (Get-Process | Where-Object { $_.MainWindowTitle } | Select-Object -First 12 | ForEach-Object { '『' + $_.MainWindowTitle + '』' }) -join ' '; throw ('找不到窗口:${safeTitle}。当前可见窗口标题: ' + $vis) }
return `function RpaFail($m) { [Console]::Out.WriteLine('RPA_ERROR::' + $m); exit 1 }
$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1
if (-not $p) { $vis = (Get-Process | Where-Object { $_.MainWindowTitle } | Select-Object -First 12 | ForEach-Object { '『' + $_.MainWindowTitle + '』' }) -join ' '; RpaFail ('找不到窗口:${safeTitle}。当前可见窗口标题: ' + $vis) }
Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd); [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h); [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); [DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr pid); [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool attach); [DllImport("user32.dll")] public static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab); [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();' -Name RpaFocus2 -Namespace WinRpaFocus2 | Out-Null
$h = $p.MainWindowHandle
if ([WinRpaFocus2.RpaFocus2]::IsIconic($h)) { [void][WinRpaFocus2.RpaFocus2]::ShowWindow($h, 9); Start-Sleep -Milliseconds 150 }
@ -210,7 +238,7 @@ if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) {
[void][WinRpaFocus2.RpaFocus2]::ShowWindow($h, 9)
Start-Sleep -Milliseconds 300
}
if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) { throw '无法把窗口带到前台,请确认窗口未被关闭或全屏占用' }`;
if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) { RpaFail '无法把窗口带到前台,请确认窗口未被关闭或全屏占用' }`;
}
const setActiveWindow: CapabilityHandler = async (_ctx, node, args) => {