diff --git a/resources/catalog-overrides.json b/resources/catalog-overrides.json new file mode 100644 index 0000000..a4e9259 --- /dev/null +++ b/resources/catalog-overrides.json @@ -0,0 +1,68 @@ +{ + "Basic_JudgeDataType": { + "description": "按所选目标类型判断数据,返回布尔值 true 或 false。数值判断区分数字与数字文本。", + "fields": { + "into_type": { + "defaultValue": "number", + "description": "选择要判断的目标类型。历史默认值 1 兼容数值类型。", + "options": [ + { "label": "数值", "value": "number" }, + { "label": "整数", "value": "int" }, + { "label": "空值", "value": "null" }, + { "label": "字典", "value": "dict" }, + { "label": "数组", "value": "list" }, + { "label": "文本", "value": "string" }, + { "label": "布尔", "value": "bool" } + ] + } + } + }, + "Basic_DataTypeConversion": { + "description": "将数据转换为所选的数值、整数、布尔或文本类型;无法转换时给出参数错误。", + "fields": { + "into_type": { + "defaultValue": "number", + "description": "选择目标类型。历史默认值 1 兼容数值;其他数字别名 2=整数、3=布尔、4=文本。", + "options": [ + { "label": "数值", "value": "number" }, + { "label": "整数", "value": "int" }, + { "label": "布尔", "value": "bool" }, + { "label": "文本", "value": "string" } + ] + } + } + }, + "App_KillV1": { + "fields": { + "close_type": { + "options": [ + { "label": "按进程 PID", "value": "id" }, + { "label": "按进程名称", "value": "name" } + ] + } + } + }, + "System_ExecuteCommandV1": { + "fields": { + "command_type": { + "label": "命令类型", + "options": [ + { "label": "DOS 命令", "value": "1" }, + { "label": "BAT 文件", "value": "2" }, + { "label": "PowerShell 命令", "value": "3" } + ] + } + } + }, + "System_ScreenSaverShowV1": { + "description": "开启 Windows 已配置的系统屏保。当前组件不支持自定义屏保文字、透明度或解锁密码;登录保护遵循 Windows 系统设置。", + "messageTemplate": "开启 Windows 系统屏保", + "removeFields": ["text", "effect", "status", "set_password", "password"] + }, + "ScreenControl_ScreenUnlock": { + "name": "唤醒屏幕(非登录解锁)", + "description": "仅尝试唤醒屏幕,不支持通过账号密码自动完成 Windows 登录解锁。锁屏后仍需本人手动登录。", + "messageTemplate": "尝试唤醒屏幕;Windows 登录锁屏需人工解锁", + "removeFields": ["username", "pwd"] + } +} diff --git a/resources/catalog.json b/resources/catalog.json index 02d1313..c87626f 100644 --- a/resources/catalog.json +++ b/resources/catalog.json @@ -10,7 +10,7 @@ "vendor": 997, "native": 16, "triggers": 3, - "fields": 4300 + "fields": 4293 }, "groups": [ { @@ -1633,7 +1633,17 @@ "placeholder": "", "description": "通过进程PID、进程名称来终止进程", "advanced": false, - "valueTypes": [] + "valueTypes": [], + "options": [ + { + "label": "按进程 PID", + "value": "id" + }, + { + "label": "按进程名称", + "value": "name" + } + ] }, { "key": "pid", @@ -3565,7 +3575,7 @@ "base_command" ], "categorySignature": "judgment|base_command", - "description": "判断指定数据是否是数值、空值、字典、类型和数组,并返回true & false后保存。", + "description": "按所选目标类型判断数据,返回布尔值 true 或 false。数值判断区分数字与数字文本。", "messageTemplate": "判断数据 $1 的类型, 然后把结果保存到 $0", "baseType": "Basic_JudgeDataType", "functionName": "Basic.JudgeDataType", @@ -3622,11 +3632,41 @@ "type": "string", "displayType": "dropDownSelect", "required": false, - "defaultValue": "1", + "defaultValue": "number", "placeholder": "", - "description": "选择待判断目标数据的数据类型", + "description": "选择要判断的目标类型。历史默认值 1 兼容数值类型。", "advanced": false, - "valueTypes": [] + "valueTypes": [], + "options": [ + { + "label": "数值", + "value": "number" + }, + { + "label": "整数", + "value": "int" + }, + { + "label": "空值", + "value": "null" + }, + { + "label": "字典", + "value": "dict" + }, + { + "label": "数组", + "value": "list" + }, + { + "label": "文本", + "value": "string" + }, + { + "label": "布尔", + "value": "bool" + } + ] } ], "output": { @@ -3658,7 +3698,7 @@ "data_type_conversion" ], "categorySignature": "data_type_conversion", - "description": "将指定的数据转为需要的类型(布尔、整数、数值、文字)后保存。", + "description": "将数据转换为所选的数值、整数、布尔或文本类型;无法转换时给出参数错误。", "messageTemplate": "将待转换数据 $1 转换为 $2, 然后把结果保存到 $0", "baseType": "Basic_DataTypeConversion", "functionName": "Basic.DataTypeConversion", @@ -3714,11 +3754,29 @@ "type": "string", "displayType": "dropDownSelect", "required": false, - "defaultValue": "1", + "defaultValue": "number", "placeholder": "", - "description": "Python 的基本数据类型选择。", + "description": "选择目标类型。历史默认值 1 兼容数值;其他数字别名 2=整数、3=布尔、4=文本。", "advanced": false, - "valueTypes": [] + "valueTypes": [], + "options": [ + { + "label": "数值", + "value": "number" + }, + { + "label": "整数", + "value": "int" + }, + { + "label": "布尔", + "value": "bool" + }, + { + "label": "文本", + "value": "string" + } + ] } ], "output": { @@ -92484,15 +92542,15 @@ }, { "id": "ScreenControl_ScreenUnlock", - "name": "屏幕解锁", + "name": "唤醒屏幕(非登录解锁)", "kind": "vendor", "group": "app", "categories": [ "ScreenControl" ], "categorySignature": "ScreenControl", - "description": "通过用户名,密码进行屏幕解锁。", - "messageTemplate": "通过用户名 $1 ,密码 $2进行屏幕解锁, 然后把结果保存到 $0", + "description": "仅尝试唤醒屏幕,不支持通过账号密码自动完成 Windows 登录解锁。锁屏后仍需本人手动登录。", + "messageTemplate": "尝试唤醒屏幕;Windows 登录锁屏需人工解锁", "baseType": "ScreenControl_ScreenUnlock", "functionName": "ScreenControl.ScreenUnlock", "functionCalls": [ @@ -92514,39 +92572,7 @@ "platformTag": null, "icon": "app", "risk": "high", - "fields": [ - { - "key": "username", - "label": "用户名", - "widget": "text", - "type": "string", - "displayType": "text-expression", - "required": true, - "defaultValue": "", - "placeholder": "请输入屏幕解锁需要的用户名", - "description": "", - "advanced": false, - "valueTypes": [ - "rpa_str" - ] - }, - { - "key": "pwd", - "label": "密码", - "widget": "secret", - "type": "string", - "displayType": "encryption", - "required": true, - "defaultValue": "", - "placeholder": "请输入屏幕解锁需要的密码", - "description": "", - "advanced": false, - "valueTypes": [ - "rpa_password", - "rpa_str" - ] - } - ], + "fields": [], "output": { "label": "输出到变量", "key": "var_name", @@ -96317,7 +96343,7 @@ "fields": [ { "key": "command_type", - "label": "command_type", + "label": "命令类型", "widget": "radio", "type": "string", "displayType": "buttonGroup", @@ -96326,7 +96352,21 @@ "placeholder": "", "description": "", "advanced": false, - "valueTypes": [] + "valueTypes": [], + "options": [ + { + "label": "DOS 命令", + "value": "1" + }, + { + "label": "BAT 文件", + "value": "2" + }, + { + "label": "PowerShell 命令", + "value": "3" + } + ] }, { "key": "dos_command", @@ -96800,8 +96840,8 @@ "ScreenSaver" ], "categorySignature": "ScreenSaver", - "description": "开启屏幕保护,防止隐私数据被偷窥。", - "messageTemplate": "屏保文字为 $0", + "description": "开启 Windows 已配置的系统屏保。当前组件不支持自定义屏保文字、透明度或解锁密码;登录保护遵循 Windows 系统设置。", + "messageTemplate": "开启 Windows 系统屏保", "baseType": "System_ScreenSaverShow", "functionName": "System.ScreenSaverShowV1", "functionCalls": [ @@ -96826,77 +96866,7 @@ "platformTag": null, "icon": "app", "risk": "high", - "fields": [ - { - "key": "text", - "label": "屏保文字", - "widget": "text", - "type": "string", - "displayType": "text-expression", - "required": false, - "defaultValue": "", - "placeholder": "请输入屏保文字", - "description": "展示在屏保界面上的文字。", - "advanced": false, - "valueTypes": [ - "rpa_str" - ] - }, - { - "key": "effect", - "label": "屏幕透明度", - "widget": "radio", - "type": "string", - "displayType": "radioGroup", - "required": false, - "defaultValue": "2", - "placeholder": "", - "description": "设置屏保的模糊、透明效果。", - "advanced": false, - "valueTypes": [] - }, - { - "key": "status", - "label": "流程暂停或结束时", - "widget": "radio", - "type": "string", - "displayType": "radioGroup", - "required": false, - "defaultValue": "1", - "placeholder": "", - "description": "运行结束时选择自动关闭屏保,还是手动输入密码关闭屏保。", - "advanced": false, - "valueTypes": [] - }, - { - "key": "set_password", - "label": "设置密码解锁", - "widget": "radio", - "type": "string", - "displayType": "radioGroup", - "required": false, - "defaultValue": "0", - "placeholder": "", - "description": "可设置解锁密码为系统密码或自定义密码,密码字符上限20", - "advanced": false, - "valueTypes": [] - }, - { - "key": "password", - "label": "password", - "widget": "text", - "type": "string", - "displayType": "text-expression", - "required": true, - "defaultValue": "", - "placeholder": "请输入自定义密码", - "description": "", - "advanced": false, - "valueTypes": [ - "rpa_str" - ] - } - ], + "fields": [], "output": { "label": "", "key": "", diff --git a/scripts/build-catalog.mjs b/scripts/build-catalog.mjs index 83de19a..03e7150 100644 --- a/scripts/build-catalog.mjs +++ b/scripts/build-catalog.mjs @@ -12,6 +12,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { applyCatalogOverrides } from "./catalog-overrides.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, ".."); @@ -336,7 +337,7 @@ function main() { }; fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true }); - fs.writeFileSync(OUT_FILE, JSON.stringify(catalog), "utf8"); + fs.writeFileSync(OUT_FILE, JSON.stringify(applyCatalogOverrides(catalog)), "utf8"); const size = fs.statSync(OUT_FILE).size; console.log(`catalog: ${counts.total} components (${counts.vendor} vendor + ${counts.native} native + ${counts.triggers} triggers)`); console.log(`fields: ${counts.fields}; groups: ${catalog.groups.map((g) => `${g.id}:${g.count}`).join(",")}`); diff --git a/scripts/catalog-overrides.mjs b/scripts/catalog-overrides.mjs new file mode 100644 index 0000000..d02b73b --- /dev/null +++ b/scripts/catalog-overrides.mjs @@ -0,0 +1,17 @@ +import fs from "node:fs"; + +const overrides = JSON.parse(fs.readFileSync(new URL("../resources/catalog-overrides.json", import.meta.url), "utf8")); + +/** 把本项目已实现的参数契约覆盖到派生目录,避免重新生成时恢复旧的错误元数据。 */ +export function applyCatalogOverrides(catalog) { + for (const component of catalog.components) { + const override = overrides[component.id]; + if (!override) continue; + const { fields = {}, removeFields = [], ...metadata } = override; + Object.assign(component, metadata); + component.fields = component.fields.filter(field => !removeFields.includes(field.key)) + .map(field => ({ ...field, ...fields[field.key] })); + } + catalog.counts.fields = catalog.components.reduce((count, component) => count + component.fields.length, 0); + return catalog; +} diff --git a/src/main/capabilities/app-system-regression.test.ts b/src/main/capabilities/app-system-regression.test.ts new file mode 100644 index 0000000..919cbb4 --- /dev/null +++ b/src/main/capabilities/app-system-regression.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { CatalogComponent, FlowNode } from "../../shared/types"; +import type { RunContext } from "../engine/types"; +import { getHandler } from "../engine/registry"; + +const mocks = vi.hoisted(() => ({ powershell: vi.fn(), execFile: vi.fn() })); +vi.mock("./desktop", () => ({ powershell: mocks.powershell, psString: (value: unknown) => `'${String(value ?? "").replace(/'/g, "''")}'` })); +vi.mock("node:child_process", () => ({ execFile: mocks.execFile })); +import "./data"; +import "./control"; +import "./system-extended"; +import "./windows-system"; + +function run(id: string, args: Record = {}, signal = new AbortController().signal) { + const node: FlowNode = { id: "regression", componentId: id, values: args, options: {}, position: { x: 0, y: 0 } }; + return getHandler(id)!({ signal, log: vi.fn(), vars: {} } as unknown as RunContext, node, args, {} as CatalogComponent); +} + +afterEach(() => { vi.useRealTimers(); vi.resetAllMocks(); }); + +describe("应用与系统组件缺陷回归", () => { + it("没有匹配进程时返回 [] / null,忽略空行和非法 PID", async () => { + mocks.powershell.mockResolvedValue(""); + await expect(run("App_GetPids", { pattern: "missing.exe" })).resolves.toEqual([]); + await expect(run("App_GetPid", { pattern: "missing.exe" })).resolves.toBeNull(); + mocks.powershell.mockResolvedValue("\r\n0\r\n123\r\nabc\r\n456\r\n"); + await expect(run("App_GetPids", { pattern: "fixture.exe" })).resolves.toEqual([123, 456]); + }); + + it("毫秒等待读取 mileSecond,计时和取消均正确", async () => { + vi.useFakeTimers(); + const waiting = Promise.resolve(run("Basic_Delay", { mileSecond: "250" })); + let done = false; + void waiting.then(() => { done = true; }); + await vi.advanceTimersByTimeAsync(249); + expect(done).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(done).toBe(true); + const controller = new AbortController(); + const cancelled = Promise.resolve(run("Basic_Delay", { mileSecond: "3000" }, controller.signal)); + const rejection = expect(cancelled).rejects.toMatchObject({ code: "cancelled" }); + controller.abort(); + await rejection; + }); + + it.each([ + [42, "number", true], ["42", "number", false], [42, "1", true], + [[], "list", true], [{ key: 1 }, "dict", true], [null, "null", true], + [false, "bool", true], ["中文", "string", true], [3.5, "int", false] + ])("按目标类型判断 %j → %s", (val, into_type, expected) => { + expect(run("Basic_JudgeDataType", { val, into_type })).toBe(expected); + }); + + it.each([ + ["42.5", "number", 42.5], ["42", "1", 42], ["-3.9", "int", -3], + ["false", "bool", false], ["true", "bool", true], [42, "string", "42"] + ])("执行类型转换 %j → %s", (val, into_type, expected) => { + expect(run("Basic_DataTypeConversion", { val, into_type })).toBe(expected); + }); + + it("非法数值或未知目标类型给出定位错误,避免静默返回原值", () => { + expect(() => run("Basic_DataTypeConversion", { val: "abc", into_type: "int" })).toThrow(); + expect(() => run("Basic_DataTypeConversion", { val: 1, into_type: "unknown" })).toThrow(); + expect(() => run("Basic_JudgeDataType", { val: 1, into_type: "unknown" })).toThrow(); + }); + + it("关闭组件按所选模式执行,非法 PID 不降级为按名称批量关闭", async () => { + mocks.powershell.mockResolvedValue(""); + await expect(run("App_KillV1", { close_type: "id", pid: "invalid", p_name: "fixture.exe" })).rejects.toMatchObject({ code: "validation_error" }); + expect(mocks.powershell).not.toHaveBeenCalled(); + await run("App_KillV1", { close_type: "name", pid: 123, p_name: "fixture.exe", kill_all: "0" }); + expect(mocks.powershell.mock.calls[0][0]).toContain("SessionId"); + expect(mocks.powershell.mock.calls[0][0]).not.toContain("Stop-Process -Id 123"); + }); + + it("唤醒不声称已通过 Windows 登录解锁", async () => { + mocks.execFile.mockImplementation((_file, _args, _opts, callback) => callback(null, { stdout: "", stderr: "" })); + await expect(run("ScreenControl_ScreenUnlock")).resolves.toMatchObject({ awakened: true, loginUnlockSupported: false }); + }); + + it("屏保自定义密码不受支持时,在触发桌面操作前明确报错", async () => { + await expect(run("System_ScreenSaverShowV1", { set_password: "1", password: "" })).rejects.toMatchObject({ code: "validation_error" }); + expect(mocks.execFile).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main/capabilities/capabilities.test.ts b/src/main/capabilities/capabilities.test.ts index aefc38b..44aafcf 100644 --- a/src/main/capabilities/capabilities.test.ts +++ b/src/main/capabilities/capabilities.test.ts @@ -81,6 +81,7 @@ describe("capability registration smoke", () => { const execute = getHandler("System_ExecuteCommand"); expect(execute).toBeTruthy(); await expect(execute!(context, node("System_ExecuteCommand"), { action: "Write-Output smoke" }, component)).resolves.toContain("smoke"); + await expect(execute!(context, node("System_ExecuteCommand"), { action: "Write-Output '中文输出测试'" }, component)).resolves.toBe("中文输出测试"); }); it("finds an exact PNG template inside a larger screenshot", async () => { diff --git a/src/main/capabilities/control.ts b/src/main/capabilities/control.ts index 7f69df0..baae0cc 100644 --- a/src/main/capabilities/control.ts +++ b/src/main/capabilities/control.ts @@ -30,12 +30,21 @@ register("Basic_Delay_V2", async (ctx, node, args) => { return seconds; }); -register(["Basic_Delay_V1", "Basic_Delay"], async (ctx, node, args) => { +register("Basic_Delay_V1", async (ctx, node, args) => { const seconds = toNumber(args.second ?? args.time ?? 1, 1); await sleep(Math.max(0, seconds) * 1000, ctx.signal); return seconds; }); +register("Basic_Delay", async (ctx, node, args) => { + const milliseconds = Number(args.mileSecond ?? 1000); + if (!Number.isFinite(milliseconds) || milliseconds < 0 || milliseconds > 2_147_483_647) { + throw new RuntimeError("validation_error", "延时时间必须是 0~2147483647 范围内的毫秒数", { nodeId: node.id, componentId: node.componentId }); + } + await sleep(milliseconds, ctx.signal); + return milliseconds / 1000; +}); + register("Basic_DebugOutput", (ctx, node, args) => { const message = String(args.msg ?? args.message ?? ""); ctx.log(message || "(空)", "info", node.id); diff --git a/src/main/capabilities/data.ts b/src/main/capabilities/data.ts index a4307fd..1e14914 100644 --- a/src/main/capabilities/data.ts +++ b/src/main/capabilities/data.ts @@ -498,12 +498,30 @@ register("Basic_Type", (_ctx, _node, args) => { if (Array.isArray(value)) return "list"; return typeof value; }); -register("Basic_JudgeDataType", (_ctx, _node, args) => { +// 原始目录缺少选项映射。新界面使用语义值;数字别名兼容此前默认的 "1"。 +const JUDGE_TYPE_ALIASES: Record = { "1": "number", "2": "null", "3": "dict", "4": "string", "5": "list", "6": "bool", "7": "int" }; +const CONVERT_TYPE_ALIASES: Record = { "1": "number", "2": "int", "3": "bool", "4": "string" }; + +function targetType(raw: unknown, aliases: Record, supported: string[], nodeId: string, componentId: string): string { + const text = String(raw ?? "number").trim().toLowerCase(); + const synonyms: Record = { float: "number", boolean: "bool", str: "string", text: "string", array: "list", dictionary: "dict", object: "dict", empty: "null" }; + const type = aliases[text] || synonyms[text] || text; + if (!supported.includes(type)) throw new RuntimeError("validation_error", "目标数据类型无效,请重新选择类型", { nodeId, componentId }); + return type; +} + +register("Basic_JudgeDataType", (_ctx, node, args) => { const value = first(args, ["val"]); - if (value === null || value === undefined) return "null"; - if (Array.isArray(value)) return "list"; - if (typeof value === "object") return "dict"; - return typeof value; + const type = targetType(args.into_type, JUDGE_TYPE_ALIASES, ["number", "null", "dict", "string", "list", "bool", "int"], node.id, node.componentId); + switch (type) { + case "number": return typeof value === "number" && Number.isFinite(value); + case "int": return typeof value === "number" && Number.isInteger(value); + case "null": return value === null || value === undefined || value === ""; + case "dict": return Boolean(value) && typeof value === "object" && !Array.isArray(value); + case "string": return typeof value === "string"; + case "list": return Array.isArray(value); + case "bool": return typeof value === "boolean"; + } }); register("Basic_Clone", (_ctx, _node, args) => structuredClone(first(args, ["val"]))); register("Basic_RandomInt", (_ctx, _node, args) => { @@ -512,4 +530,23 @@ register("Basic_RandomInt", (_ctx, _node, args) => { return Math.floor(min + Math.random() * (max - min + 1)); }); register("Basic_Rnd", () => Math.random()); -register("Basic_DataTypeConversion", (_ctx, _node, args) => first(args, ["val"])); +register("Basic_DataTypeConversion", (_ctx, node, args) => { + const value = first(args, ["val"]); + const type = targetType(args.into_type, CONVERT_TYPE_ALIASES, ["number", "int", "bool", "string"], node.id, node.componentId); + if (type === "string") return str(value); + if (type === "bool") { + if (value == null || value === "") return false; + if (typeof value === "boolean") return value; + if (typeof value === "number" && Number.isFinite(value)) return value !== 0; + const text = String(value).trim().toLowerCase(); + if (["true", "1", "是", "开启"].includes(text)) return true; + if (["false", "0", "否", "关闭"].includes(text)) return false; + throw new RuntimeError("validation_error", "输入值无法转换为布尔类型", { nodeId: node.id, componentId: node.componentId }); + } + if ((typeof value !== "number" && typeof value !== "string") || (typeof value === "string" && !value.trim()) || !Number.isFinite(Number(value))) { + throw new RuntimeError("validation_error", "输入值不是有效数字", { nodeId: node.id, componentId: node.componentId }); + } + const result = type === "int" ? Math.trunc(Number(value)) : Number(value); + if (type === "int" && !Number.isSafeInteger(result)) throw new RuntimeError("validation_error", "转换后的整数超出安全范围", { nodeId: node.id, componentId: node.componentId }); + return result; +}); diff --git a/src/main/capabilities/desktop.ts b/src/main/capabilities/desktop.ts index 794d6cd..0379e63 100644 --- a/src/main/capabilities/desktop.ts +++ b/src/main/capabilities/desktop.ts @@ -45,7 +45,9 @@ function encoded(command: string): string { export async function powershell(command: string, timeout = 30000): Promise { try { - const result = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded(command)], { + // Windows PowerShell 的默认输出编码依机器代码页变化;Node 按 UTF-8 解码管道。 + const utf8 = "$ProgressPreference='SilentlyContinue'; [Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false); $OutputEncoding=[Console]::OutputEncoding; "; + const result = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded(utf8 + command)], { windowsHide: true, timeout, maxBuffer: 4 * 1024 * 1024 diff --git a/src/main/capabilities/system-extended.ts b/src/main/capabilities/system-extended.ts index e6675e4..3beffc8 100644 --- a/src/main/capabilities/system-extended.ts +++ b/src/main/capabilities/system-extended.ts @@ -75,21 +75,30 @@ register("System_GetSystemPath", (_ctx, _node, args) => { const getPids: CapabilityHandler = async (_ctx, node, args) => { const pattern = processPattern(required(args, ["pattern", "p_name", "name"], "进程名称", node.id, node.componentId)); const output = await powershell(`Get-Process | Where-Object { $_.ProcessName -like ${psString(pattern)} } | Select-Object -ExpandProperty Id`); - return output.split(/\r?\n/).map(Number).filter(Number.isFinite); + return output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean) + .map(Number).filter((pid) => Number.isSafeInteger(pid) && pid > 0); }; register("App_GetPids", getPids); register("App_GetPid", async (ctx, node, args, component) => (await getPids(ctx, node, args, component) as number[])[0] ?? null); register(["App_Kill", "App_KillV1"], async (_ctx, node, args) => { - const pid = Number(value(args, ["pid"])); - const name = value(args, ["p_name", "pattern"]); - if (Number.isFinite(pid) && pid > 0) { + const mode = node.componentId === "App_Kill" ? "id" : value(args, ["close_type"], "id"); + if (mode === "id") { + const rawPid = args.pid; + const pid = typeof rawPid === "number" || typeof rawPid === "string" ? Number(rawPid) : NaN; + if (!Number.isSafeInteger(pid) || pid <= 0) { + throw new RuntimeError("validation_error", "进程 PID 必须是正整数", { nodeId: node.id, componentId: node.componentId }); + } if (pid === process.pid) throw new RuntimeError("validation_error", "拒绝关闭 RPA Studio 自身进程", { nodeId: node.id, componentId: node.componentId }); await powershell(`Stop-Process -Id ${pid} -Force -ErrorAction Stop`); return pid; } - const pattern = processPattern(required({ name }, ["name"], "进程名称", node.id, node.componentId)); - await powershell(`Get-Process | Where-Object { $_.ProcessName -like ${psString(pattern)} } | Stop-Process -Force -ErrorAction Stop`); + if (mode !== "name") throw new RuntimeError("validation_error", "请选择按 PID 或按进程名称关闭", { nodeId: node.id, componentId: node.componentId }); + const pattern = processPattern(required(args, ["p_name", "pattern"], "进程名称", node.id, node.componentId)); + if (pattern === "*") throw new RuntimeError("validation_error", "关闭应用程序必须指定具体的进程名称", { nodeId: node.id, componentId: node.componentId }); + const allSessions = [true, 1, "1", "true", "True"].includes(args.kill_all as never); + const session = allSessions ? "" : " -and $_.SessionId -eq [System.Diagnostics.Process]::GetCurrentProcess().SessionId"; + await powershell(`Get-Process | Where-Object { $_.ProcessName -like ${psString(pattern)} -and $_.Id -ne ${process.pid}${session} } | Stop-Process -Force -ErrorAction Stop`); return pattern; }); diff --git a/src/main/capabilities/utility-extended.test.ts b/src/main/capabilities/utility-extended.test.ts index 31feeab..59b63df 100644 --- a/src/main/capabilities/utility-extended.test.ts +++ b/src/main/capabilities/utility-extended.test.ts @@ -79,6 +79,20 @@ describe("utility extended capabilities", () => { await expect(run("System_ExecuteCommandV1", args)).resolves.toContain("utility-ok"); }); + it("executes BAT files whose path contains spaces and Chinese characters", async () => { + if (process.platform !== "win32") return; + const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-utility-中文 空格-")); + directories.push(directory); + const file = path.join(directory, "测试 脚本.bat"); + await writeFile(file, "@echo RPA-BAT-PATH-OK\r\n", "ascii"); + await expect(run("System_ExecuteCommandV1", { command_type: "2", bat_file: file, get_ret: "1" })).resolves.toBe("RPA-BAT-PATH-OK"); + }); + + it("keeps PowerShell command output in UTF-8", async () => { + if (process.platform !== "win32") return; + await expect(run("System_ExecuteCommandV1", { command_type: "3", ps_command: "Write-Output '中文输出测试'", get_ret: "1" })).resolves.toBe("中文输出测试"); + }); + it("compares local text files with structured line changes", async () => { const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-utility-")); directories.push(directory); diff --git a/src/main/capabilities/utility-extended.ts b/src/main/capabilities/utility-extended.ts index b4d48a4..57c121d 100644 --- a/src/main/capabilities/utility-extended.ts +++ b/src/main/capabilities/utility-extended.ts @@ -252,6 +252,7 @@ register("Basic_GetProcessInfo", (ctx) => ({ register("System_ExecuteCommandV1", async (_ctx, node, args) => { const mode = String(args.command_type || "1"); + if (!["1", "2", "3"].includes(mode)) throw new RuntimeError("validation_error", "请选择 DOS、BAT 或 PowerShell 命令类型", { nodeId: node.id, componentId: node.componentId }); const cwd = String(args.cwd || "").trim() || undefined; const timeout = Math.max(1_000, Number(args.timeoutMs || 120_000)); let executable: string; @@ -264,7 +265,9 @@ register("System_ExecuteCommandV1", async (_ctx, node, args) => { commandArgs = ["/d", "/s", "/c", `call "${file}"`]; } else if (mode === "3") { executable = "powershell.exe"; - commandArgs = ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", requiredText(args.ps_command, "PowerShell命令", node.id, node.componentId)]; + const utf8 = "$ProgressPreference='SilentlyContinue'; [Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false); $OutputEncoding=[Console]::OutputEncoding; "; + const script = utf8 + requiredText(args.ps_command, "PowerShell命令", node.id, node.componentId); + commandArgs = ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")]; } else { executable = process.env.ComSpec || "cmd.exe"; commandArgs = ["/d", "/s", "/c", requiredText(args.dos_command, "DOS命令", node.id, node.componentId)]; @@ -276,7 +279,8 @@ register("System_ExecuteCommandV1", async (_ctx, node, args) => { executable = "/bin/sh"; commandArgs = ["-lc", command]; } - const result = await execFileAsync(executable, commandArgs, { cwd, timeout, windowsHide: true, maxBuffer: 10 * 1024 * 1024 }); + // cmd.exe 自己解析 /c 后的命令;不能让 Node 再把 BAT 路径引号转成反斜杠引号。 + const result = await execFileAsync(executable, commandArgs, { cwd, timeout, windowsHide: true, windowsVerbatimArguments: process.platform === "win32" && mode !== "3", maxBuffer: 10 * 1024 * 1024 }); return bool(args.get_ret, true) ? String(result.stdout).trimEnd() : true; }); diff --git a/src/main/capabilities/windows-system.ts b/src/main/capabilities/windows-system.ts index d611206..85e1a02 100644 --- a/src/main/capabilities/windows-system.ts +++ b/src/main/capabilities/windows-system.ts @@ -22,13 +22,16 @@ register("ScreenControl_Screenlock", async () => { register("ScreenControl_ScreenUnlock", async () => { // 仅唤醒屏幕/解除屏保;系统锁屏需人工输入密码,无法通用自动解锁 await ps("Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern void mouse_event(uint f, uint dx, uint dy, uint d, UIntPtr e);' -Name W32U -Namespace WinRpa | Out-Null; [WinRpa.W32U]::mouse_event(0x0001, 1, 1, 0, [UIntPtr]::Zero); Start-Sleep -Milliseconds 200; [WinRpa.W32U]::mouse_event(0x0001, 0xFFFFFFFF, 0, 0, [UIntPtr]::Zero)"); - return { awakened: true, note: "已唤醒屏幕;锁屏需人工输入密码" }; + return { awakened: true, loginUnlockSupported: false, note: "仅尝试唤醒屏幕;Windows 登录锁屏仍需本人手动解锁" }; }); const SC_SCREENSAVE = 0xF140; const WM_SYSCOMMAND = 0x0112; -register(["System_ScreenSaverShow", "System_ScreenSaverShowV1"], async () => { - await ps("Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern IntPtr FindWindow(string cls, string win); [DllImport(\"user32.dll\")] public static extern IntPtr SendMessage(IntPtr h, uint m, IntPtr w, IntPtr l);' -Name W32S -Namespace WinRpa | Out-Null; [WinRpa.W32S]::SendMessage([IntPtr]0xFFFF, [UIntPtr]0x0112, [IntPtr]0xF140, [IntPtr]::Zero) | Out-Null"); +register(["System_ScreenSaverShow", "System_ScreenSaverShowV1"], async (_ctx, node, args) => { + if ([true, 1, "1", "true", "True"].includes(args.set_password as never)) { + throw new RuntimeError("validation_error", "系统屏保不支持设置自定义解锁密码,请关闭该选项并使用 Windows 原有登录保护", { nodeId: node.id, componentId: node.componentId }); + } + await ps("Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern IntPtr SendMessage(IntPtr h, uint m, IntPtr w, IntPtr l);' -Name W32S -Namespace WinRpa | Out-Null; [WinRpa.W32S]::SendMessage([IntPtr]0xFFFF, [uint32]0x0112, [IntPtr]0xF140, [IntPtr]::Zero) | Out-Null"); return { screensaver: true }; }); diff --git a/src/renderer/src/panels/Properties.tsx b/src/renderer/src/panels/Properties.tsx index c89f9d0..28ccd88 100644 --- a/src/renderer/src/panels/Properties.tsx +++ b/src/renderer/src/panels/Properties.tsx @@ -17,6 +17,7 @@ import type { Catalog, CatalogField, FlowNode, FlowVariable } from "@shared/type import { useEditorStore } from "../store/editorStore"; import { groupIcon } from "../components/icons"; import { containerSlots } from "../canvas/ContainerNode"; +import { fieldIsActive } from "../validation/flowValidation"; type Tab = "props" | "vars" | "run"; @@ -248,8 +249,9 @@ function PropertyTab({ onToast: (message: string, kind?: "ok" | "error") => void; }) { const [advancedOpen, setAdvancedOpen] = useState(false); - const basic = component.fields.filter((field) => !field.advanced && field.widget !== "hidden"); - const advanced = component.fields.filter((field) => field.advanced && field.widget !== "hidden"); + const active = component.fields.filter((field) => field.widget !== "hidden" && fieldIsActive(component.id, field.key, node.values)); + const basic = active.filter((field) => !field.advanced); + const advanced = active.filter((field) => field.advanced); const slots = component.slots.length > 0 ? containerSlots(component) : []; return ( diff --git a/src/renderer/src/validation/flowValidation.test.ts b/src/renderer/src/validation/flowValidation.test.ts index a33d0cf..ba6d5b9 100644 --- a/src/renderer/src/validation/flowValidation.test.ts +++ b/src/renderer/src/validation/flowValidation.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { Catalog, FlowDefinition } from "@shared/types"; -import { validateFlow } from "./flowValidation"; +import { fieldIsActive, validateFlow } from "./flowValidation"; const catalog = { schemaVersion: 1, generatedAt: "", counts: { total: 2, vendor: 1, native: 0, triggers: 1 }, groups: [], @@ -20,6 +20,24 @@ function flow(): FlowDefinition { } describe("flow validation", () => { + it("关闭程序按模式仅显示并校验 PID 或进程名称", () => { + expect(fieldIsActive("App_KillV1", "pid", { close_type: "id" })).toBe(true); + expect(fieldIsActive("App_KillV1", "p_name", { close_type: "id" })).toBe(false); + expect(fieldIsActive("App_KillV1", "pid", { close_type: "name" })).toBe(false); + expect(fieldIsActive("App_KillV1", "p_name", { close_type: "name" })).toBe(true); + }); + + it.each(["1", "2", "3"])("命令模式 %s 仅显示并校验当前命令输入", (mode) => { + const fields = ["dos_command", "bat_file", "ps_command"]; + const active = fields.filter(key => fieldIsActive("System_ExecuteCommandV1", key, { command_type: mode })); + expect(active).toEqual([fields[Number(mode) - 1]]); + }); + + it("旧屏保元数据不再要求未启用的密码", () => { + expect(fieldIsActive("System_ScreenSaverShowV1", "password", { set_password: "0" })).toBe(false); + expect(fieldIsActive("System_ScreenSaverShowV1", "password", { set_password: "1" })).toBe(true); + }); + it("reports pending components, required fields, and disconnected nodes", () => { const issues = validateFlow(flow(), catalog); expect(issues).toEqual(expect.arrayContaining([ diff --git a/src/renderer/src/validation/flowValidation.ts b/src/renderer/src/validation/flowValidation.ts index add8a85..37a3c83 100644 --- a/src/renderer/src/validation/flowValidation.ts +++ b/src/renderer/src/validation/flowValidation.ts @@ -15,7 +15,25 @@ function truthy(value: unknown): boolean { return [true, 1, "1", "true", "True", "是", "开启"].includes(value as never); } +/** 属性显示与必填校验使用相同的模式条件,切换模式时保留未激活字段的旧值。 */ +export function fieldIsActive(componentId: string, key: string, values: Record): boolean { + if (componentId === "App_KillV1") { + const mode = String(values.close_type ?? "id"); + if (key === "pid") return mode === "id"; + if (key === "p_name" || key === "kill_all") return mode === "name"; + } + if (componentId === "System_ExecuteCommandV1") { + const mode = String(values.command_type ?? "1"); + if (["dos_command", "bat_file", "ps_command"].includes(key)) { + return key === ({ "1": "dos_command", "2": "bat_file", "3": "ps_command" } as Record)[mode]; + } + } + if (componentId === "System_ScreenSaverShowV1" && key === "password") return truthy(values.set_password); + return true; +} + function requiredInContext(componentId: string, key: string, values: Record): boolean { + if (!fieldIsActive(componentId, key, values)) return false; if (key === "designated_obj" && !truthy(values.designated_info)) return false; if (key === "cvSelector" && String(values.choose ?? "0") !== "1") return false; if (componentId === "Image_Screenshot" && ["image_path", "outImagePath", "outImageName"].includes(key)) return false;