@wanghaopeng1148/deskpet 2.0.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -16
- package/bin/deskpet.mjs +70 -2
- package/dist/node/server/db/database.js +113 -0
- package/dist/node/server/db/migrate-legacy.js +88 -0
- package/dist/node/server/db/task-repository.js +374 -0
- package/dist/node/server/http/http-server.js +493 -0
- package/dist/node/server/http/ws-hub.js +59 -0
- package/dist/node/server/main.js +291 -0
- package/dist/node/server/plugins/actions/builtin.js +67 -0
- package/dist/node/server/plugins/actions/clipboard-watch.js +27 -0
- package/dist/node/server/plugins/actions/http-request.js +41 -0
- package/dist/node/server/plugins/actions/jenkins-build.js +183 -0
- package/dist/node/server/plugins/actions/open-app.js +41 -0
- package/dist/node/server/plugins/actions/python-script.js +180 -0
- package/dist/node/server/plugins/actions/screenshot.js +38 -0
- package/dist/node/server/plugins/actions/send-keystroke.js +100 -0
- package/dist/node/server/plugins/actions/show-reminder.js +7 -0
- package/dist/node/server/plugins/actions/ssh-command.js +123 -0
- package/dist/node/server/plugins/actions/task-chain.js +24 -0
- package/dist/node/server/plugins/actions/volume-control.js +31 -0
- package/dist/node/server/plugins/index.js +35 -0
- package/dist/node/server/plugins/registry.js +23 -0
- package/dist/node/server/services/clipboard-watcher.js +112 -0
- package/dist/node/server/services/config-store.js +141 -0
- package/dist/node/server/services/idle-monitor.js +131 -0
- package/dist/node/server/services/notifier.js +36 -0
- package/dist/node/server/services/quick-actions-store.js +52 -0
- package/dist/node/server/services/remote-connector.js +67 -0
- package/dist/node/server/services/scanner-reader.js +217 -0
- package/dist/node/server/services/script-runner.js +228 -0
- package/dist/node/server/services/snapshot-service.js +135 -0
- package/dist/node/server/services/task-scheduler.js +813 -0
- package/dist/node/server/services/wechat-bot.js +635 -0
- package/dist/node/server/services/wechat-command-types.js +1 -0
- package/dist/node/server/services/wechat-commands.js +330 -0
- package/dist/node/server/suppress-warnings.js +12 -0
- package/dist/node/server/utils/asset-url.js +26 -0
- package/dist/node/server/utils/auto-start.js +186 -0
- package/dist/node/server/utils/clipboard.js +50 -0
- package/dist/node/server/utils/dashboard-url.js +8 -0
- package/dist/node/server/utils/instance-guard.js +165 -0
- package/dist/node/server/utils/native-notify.js +53 -0
- package/dist/node/server/utils/open.js +37 -0
- package/dist/node/server/utils/paths.js +95 -0
- package/dist/node/server/utils/python-interpreter.js +129 -0
- package/dist/node/shared/animation-engine.js +349 -0
- package/dist/node/shared/chain-condition.js +39 -0
- package/dist/node/shared/cron-weekly.js +124 -0
- package/dist/node/shared/py-task-params.js +335 -0
- package/dist/node/shared/types.js +69 -0
- package/package.json +6 -2
- package/server/http/http-server.ts +4 -1
- package/server/utils/auto-start.ts +158 -49
- package/server/utils/paths.ts +28 -1
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* python_script 动作 — 执行 Python 脚本
|
|
3
|
+
*
|
|
4
|
+
* 1) 脚本来源:优先「任务内导入」的脚本(actionParams.scriptContent,运行时写入临时文件);
|
|
5
|
+
* 兼容旧任务:无导入内容时回退 config.scriptPath 指向的本地脚本。
|
|
6
|
+
* 2) 配置注入:把「系统设置」里的 Jenkins / Linux 服务器配置写入临时 JSON,
|
|
7
|
+
* 并通过环境变量传给脚本,脚本无需再硬编码地址与密码:
|
|
8
|
+
* DESKPET_CONFIG 注入配置文件的绝对路径(JSON)
|
|
9
|
+
* DESKPET_LOG_DIR 脚本日志目录(~/.deskpet/logs)
|
|
10
|
+
* DESKPET_JENKINS_URL / _USER / _PASS
|
|
11
|
+
* DESKPET_SERVER_HOST / _PORT / _USER / _PASS / _NAME(任务选了服务器时才有)
|
|
12
|
+
* DESKPET_TASK_ID / DESKPET_TASK_NAME / DESKPET_PARAMS(actionParams JSON)
|
|
13
|
+
* 执行结束(含失败)会清理所有临时文件。
|
|
14
|
+
*/
|
|
15
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { getDataRoot } from "../../utils/paths.js";
|
|
19
|
+
import { resolvePythonInterpreter } from "../../utils/python-interpreter.js";
|
|
20
|
+
const TEMP_DIR_NAME = 'deskpet-scripts';
|
|
21
|
+
export const pythonScriptAction = async (task, ctx) => {
|
|
22
|
+
const c = task.config;
|
|
23
|
+
const params = c.actionParams ?? {};
|
|
24
|
+
const scriptContent = typeof params['scriptContent'] === 'string' ? params['scriptContent'] : '';
|
|
25
|
+
const scriptName = typeof params['scriptName'] === 'string' && params['scriptName'] ? params['scriptName'] : 'script.py';
|
|
26
|
+
const settings = ctx.config.get();
|
|
27
|
+
const servers = (settings.servers ?? []);
|
|
28
|
+
const selected = servers.find((s) => s.id === String(params['serverId'] ?? '')) ?? null;
|
|
29
|
+
const dir = join(tmpdir(), TEMP_DIR_NAME);
|
|
30
|
+
const tempFiles = [];
|
|
31
|
+
const stamp = Date.now();
|
|
32
|
+
try {
|
|
33
|
+
mkdirSync(dir, { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
return { success: false, error: `创建临时目录失败: ${String(err)}` };
|
|
37
|
+
}
|
|
38
|
+
// ── 注入配置(Jenkins / 服务器),供脚本读取 ─────────────
|
|
39
|
+
let configPath = '';
|
|
40
|
+
try {
|
|
41
|
+
const injected = {
|
|
42
|
+
jenkins: settings.jenkins ?? { url: '', username: '', password: '' },
|
|
43
|
+
servers,
|
|
44
|
+
server: selected,
|
|
45
|
+
task: { id: task.id, name: c.name, args: c.scriptArgs ?? [] },
|
|
46
|
+
params
|
|
47
|
+
};
|
|
48
|
+
configPath = join(dir, `${task.id}-${stamp}.config.json`);
|
|
49
|
+
writeFileSync(configPath, JSON.stringify(injected, null, 2), 'utf-8');
|
|
50
|
+
tempFiles.push(configPath);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
return { success: false, error: `写入注入配置失败: ${String(err)}` };
|
|
54
|
+
}
|
|
55
|
+
// ── 脚本来源 ────────────────────────────────────────────
|
|
56
|
+
let scriptPath = c.scriptPath || String(params['script_path'] ?? '');
|
|
57
|
+
if (scriptContent.trim()) {
|
|
58
|
+
try {
|
|
59
|
+
const p = join(dir, `${task.id}-${stamp}.py`);
|
|
60
|
+
writeFileSync(p, scriptContent, 'utf-8');
|
|
61
|
+
scriptPath = p;
|
|
62
|
+
tempFiles.push(p);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
cleanup(tempFiles);
|
|
66
|
+
return { success: false, error: `写入临时脚本失败: ${String(err)}` };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!scriptPath) {
|
|
70
|
+
cleanup(tempFiles);
|
|
71
|
+
return { success: false, error: '任务未导入 Python 脚本,请编辑任务上传 .py 文件' };
|
|
72
|
+
}
|
|
73
|
+
// 脚本日志目录(持久化,便于排查)
|
|
74
|
+
const logDir = join(getDataRoot(), 'logs');
|
|
75
|
+
try {
|
|
76
|
+
mkdirSync(logDir, { recursive: true });
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
/* 建目录失败时交给脚本自行回退 */
|
|
80
|
+
}
|
|
81
|
+
const env = {
|
|
82
|
+
// Windows 下 Python 默认用 GBK 输出,会导致中文日志乱码 → 强制 UTF-8
|
|
83
|
+
PYTHONIOENCODING: 'utf-8',
|
|
84
|
+
PYTHONUTF8: '1',
|
|
85
|
+
// 关键:关闭 Python 的输出缓冲。否则 stdout 为管道时是「块缓冲」,
|
|
86
|
+
// print() 的内容要等缓冲区满或进程结束才会到达父进程 —— 管理台就看不到实时日志。
|
|
87
|
+
// 等价于 python -u;配合 script-runner 的 onOutput 即可边跑边看。
|
|
88
|
+
PYTHONUNBUFFERED: '1',
|
|
89
|
+
DESKPET_CONFIG: configPath,
|
|
90
|
+
DESKPET_LOG_DIR: logDir,
|
|
91
|
+
DESKPET_JENKINS_URL: settings.jenkins?.url ?? '',
|
|
92
|
+
DESKPET_JENKINS_USER: settings.jenkins?.username ?? '',
|
|
93
|
+
DESKPET_JENKINS_PASS: settings.jenkins?.password ?? '',
|
|
94
|
+
DESKPET_TASK_ID: task.id,
|
|
95
|
+
DESKPET_TASK_NAME: c.name ?? '',
|
|
96
|
+
DESKPET_PARAMS: JSON.stringify(params)
|
|
97
|
+
};
|
|
98
|
+
if (selected) {
|
|
99
|
+
env['DESKPET_SERVER_NAME'] = selected.name ?? '';
|
|
100
|
+
env['DESKPET_SERVER_HOST'] = selected.host ?? '';
|
|
101
|
+
env['DESKPET_SERVER_PORT'] = String(selected.port ?? 22);
|
|
102
|
+
env['DESKPET_SERVER_USER'] = selected.username ?? '';
|
|
103
|
+
env['DESKPET_SERVER_PASS'] = selected.password ?? '';
|
|
104
|
+
}
|
|
105
|
+
ctx.log(`开始执行脚本: ${scriptName}`);
|
|
106
|
+
// ── 解释器解析 ──────────────────────────────────────────
|
|
107
|
+
// Windows 上 `python3` 往往是 Microsoft Store 的别名存根,非交互执行直接返回 9009,
|
|
108
|
+
// 这里统一解析成真实可执行文件的绝对路径(跳过存根),避免「exit_code=9009、无任何输出」。
|
|
109
|
+
const resolution = resolvePythonInterpreter(c.interpreter);
|
|
110
|
+
if (!resolution.ok) {
|
|
111
|
+
cleanup(tempFiles);
|
|
112
|
+
return { success: false, error: resolution.error };
|
|
113
|
+
}
|
|
114
|
+
ctx.log(`解释器: ${resolution.interpreter.command}`);
|
|
115
|
+
try {
|
|
116
|
+
const handle = ctx.scriptRunner.start({
|
|
117
|
+
scriptPath,
|
|
118
|
+
interpreter: resolution.interpreter.command,
|
|
119
|
+
args: c.scriptArgs,
|
|
120
|
+
timeout: c.scriptTimeout,
|
|
121
|
+
workDir: c.scriptWorkDir,
|
|
122
|
+
env,
|
|
123
|
+
// 实时把子进程输出推给管理台(调度器注入的回调)
|
|
124
|
+
onOutput: (stream, text) => ctx.onOutput?.(stream, text)
|
|
125
|
+
});
|
|
126
|
+
// 注册句柄供任务停止
|
|
127
|
+
try {
|
|
128
|
+
ctx.trackRunningScript?.(task.id, handle);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
/* 调度器未注入时忽略 */
|
|
132
|
+
}
|
|
133
|
+
// 登记子进程 pid:服务中途被强杀时,靠它判断脚本是否还在后台跑
|
|
134
|
+
if (handle.pid) {
|
|
135
|
+
try {
|
|
136
|
+
ctx.onChildPid?.(handle.pid);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
/* 调度器未注入时忽略 */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const result = await handle.result;
|
|
143
|
+
try {
|
|
144
|
+
ctx.trackRunningScript?.(task.id, null);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
/* ignore */
|
|
148
|
+
}
|
|
149
|
+
if (result.timedOut) {
|
|
150
|
+
ctx.log(`执行超时 (${c.scriptTimeout}s)`);
|
|
151
|
+
}
|
|
152
|
+
else if (result.success) {
|
|
153
|
+
ctx.log(`执行成功, 退出码 ${result.exitCode}, 耗时 ${(result.elapsedMs / 1000).toFixed(1)}s`);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
ctx.log(`执行失败: ${result.errorMessage}`);
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
success: result.success,
|
|
160
|
+
exitCode: result.exitCode,
|
|
161
|
+
stdout: result.stdout,
|
|
162
|
+
stderr: result.stderr,
|
|
163
|
+
error: result.errorMessage || undefined,
|
|
164
|
+
timedOut: result.timedOut
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
cleanup(tempFiles);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
function cleanup(files) {
|
|
172
|
+
for (const f of files) {
|
|
173
|
+
try {
|
|
174
|
+
rmSync(f, { force: true });
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
/* 清理失败可忽略 */
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 截图动作 — macOS screencapture(全屏截图保存到指定目录)
|
|
3
|
+
*/
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { mkdirSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
export const screenshotAction = async (task) => {
|
|
9
|
+
const p = task.config.actionParams ?? {};
|
|
10
|
+
if (process.platform !== 'darwin') {
|
|
11
|
+
return { success: false, error: `截图动作暂不支持 ${process.platform}` };
|
|
12
|
+
}
|
|
13
|
+
// 目标路径:参数指定目录或默认 ~/Downloads
|
|
14
|
+
const dir = (typeof p['dir'] === 'string' && p['dir']) || join(homedir(), 'Downloads');
|
|
15
|
+
try {
|
|
16
|
+
mkdirSync(dir, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
/* 已存在 */
|
|
20
|
+
}
|
|
21
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
22
|
+
const filePath = (typeof p['file'] === 'string' && p['file']) || join(dir, `deskpet-screenshot-${ts}.png`);
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
// -x 不播放快门声;-m 只截主屏(可选)
|
|
25
|
+
const args = ['-x'];
|
|
26
|
+
if (p['mainDisplayOnly'] === true)
|
|
27
|
+
args.push('-m');
|
|
28
|
+
args.push(filePath);
|
|
29
|
+
execFile('screencapture', args, { timeout: 15_000 }, (err) => {
|
|
30
|
+
if (err) {
|
|
31
|
+
resolve({ success: false, error: String(err) });
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
resolve({ success: true, stdout: filePath });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 按键序列动作 — macOS osascript System Events(模拟快捷键)
|
|
3
|
+
*
|
|
4
|
+
* 参数: keys: "cmd+shift+4" / "ctrl+c" / "return" / "cmd+alt+esc"
|
|
5
|
+
*/
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
const MODIFIER_MAP = {
|
|
8
|
+
cmd: 'command down',
|
|
9
|
+
command: 'command down',
|
|
10
|
+
ctrl: 'control down',
|
|
11
|
+
control: 'control down',
|
|
12
|
+
alt: 'option down',
|
|
13
|
+
option: 'option down',
|
|
14
|
+
shift: 'shift down',
|
|
15
|
+
fn: 'fn down'
|
|
16
|
+
};
|
|
17
|
+
/** 组合键 → osascript keystroke 脚本;非法返回 null */
|
|
18
|
+
export function buildKeystrokeScript(combo) {
|
|
19
|
+
const parts = combo
|
|
20
|
+
.split(/[+-]/)
|
|
21
|
+
.map((s) => s.trim().toLowerCase())
|
|
22
|
+
.filter(Boolean);
|
|
23
|
+
if (!parts.length)
|
|
24
|
+
return null;
|
|
25
|
+
const modifiers = [];
|
|
26
|
+
let key = '';
|
|
27
|
+
for (const part of parts) {
|
|
28
|
+
if (MODIFIER_MAP[part]) {
|
|
29
|
+
modifiers.push(MODIFIER_MAP[part]);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
key = part;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (!key)
|
|
36
|
+
return null;
|
|
37
|
+
// 特殊键映射
|
|
38
|
+
const special = {
|
|
39
|
+
return: 'return',
|
|
40
|
+
enter: 'return',
|
|
41
|
+
tab: 'tab',
|
|
42
|
+
space: 'space',
|
|
43
|
+
delete: 'delete',
|
|
44
|
+
escape: 'escape',
|
|
45
|
+
esc: 'escape',
|
|
46
|
+
up: 'up arrow',
|
|
47
|
+
down: 'down arrow',
|
|
48
|
+
left: 'left arrow',
|
|
49
|
+
right: 'right arrow',
|
|
50
|
+
home: 'home',
|
|
51
|
+
end: 'end',
|
|
52
|
+
pageup: 'page up',
|
|
53
|
+
pagedown: 'page down'
|
|
54
|
+
};
|
|
55
|
+
if (special[key]) {
|
|
56
|
+
const using = modifiers.length ? ` using {${modifiers.join(', ')}}` : '';
|
|
57
|
+
return `tell application "System Events" to key code ${KEY_CODES[special[key]] ?? `"${special[key]}"`}${using}`;
|
|
58
|
+
}
|
|
59
|
+
if (key.length !== 1)
|
|
60
|
+
return null;
|
|
61
|
+
const using = modifiers.length ? ` using {${modifiers.join(', ')}}` : '';
|
|
62
|
+
return `tell application "System Events" to keystroke "${key}"${using}`;
|
|
63
|
+
}
|
|
64
|
+
// System Events 特殊键的 key code(部分键只能用 key code 表达)
|
|
65
|
+
const KEY_CODES = {
|
|
66
|
+
return: 36,
|
|
67
|
+
tab: 48,
|
|
68
|
+
space: 49,
|
|
69
|
+
delete: 51,
|
|
70
|
+
escape: 53,
|
|
71
|
+
home: 115,
|
|
72
|
+
end: 119,
|
|
73
|
+
pageup: 116,
|
|
74
|
+
'page down': 121,
|
|
75
|
+
'up arrow': 126,
|
|
76
|
+
'down arrow': 125,
|
|
77
|
+
'left arrow': 123,
|
|
78
|
+
'right arrow': 124
|
|
79
|
+
};
|
|
80
|
+
export const sendKeystrokeAction = async (task) => {
|
|
81
|
+
if (process.platform !== 'darwin') {
|
|
82
|
+
return { success: false, error: `按键模拟暂不支持 ${process.platform}` };
|
|
83
|
+
}
|
|
84
|
+
const combo = String(task.config.actionParams?.['keys'] ?? '').trim();
|
|
85
|
+
if (!combo) {
|
|
86
|
+
return { success: false, error: '未指定按键序列(actionParams.keys,如 cmd+q)' };
|
|
87
|
+
}
|
|
88
|
+
const script = buildKeystrokeScript(combo);
|
|
89
|
+
if (!script) {
|
|
90
|
+
return { success: false, error: `无法解析按键组合: ${combo}` };
|
|
91
|
+
}
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
execFile('osascript', ['-e', script], { timeout: 10_000 }, (err, stdout) => {
|
|
94
|
+
if (err)
|
|
95
|
+
resolve({ success: false, error: String(err) });
|
|
96
|
+
else
|
|
97
|
+
resolve({ success: true, stdout: `已发送按键: ${combo}\n${stdout.trim()}` });
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ssh_command — 在「系统设置 → Linux 服务器」选中的服务器上远程执行 Shell 命令
|
|
3
|
+
*
|
|
4
|
+
* 参数(task.actionParams):
|
|
5
|
+
* serverId 设置里 Linux 服务器的 id
|
|
6
|
+
* command 要执行的 Shell 命令(支持多行,会整体交给远端 shell)
|
|
7
|
+
* timeoutMs 超时毫秒数(可选,默认 120s)
|
|
8
|
+
*/
|
|
9
|
+
import { Client } from 'ssh2';
|
|
10
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
11
|
+
/** 通过 SSH 在远端执行一条命令,返回退出码与输出 */
|
|
12
|
+
function execRemote(server, command, timeoutMs, onOutput) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
let stdout = '';
|
|
15
|
+
let stderr = '';
|
|
16
|
+
let done = false;
|
|
17
|
+
const conn = new Client();
|
|
18
|
+
const finish = (r) => {
|
|
19
|
+
if (done)
|
|
20
|
+
return;
|
|
21
|
+
done = true;
|
|
22
|
+
clearTimeout(timer);
|
|
23
|
+
try {
|
|
24
|
+
conn.end();
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
/* 连接已断开时忽略 */
|
|
28
|
+
}
|
|
29
|
+
resolve(r);
|
|
30
|
+
};
|
|
31
|
+
const timer = setTimeout(() => {
|
|
32
|
+
finish({
|
|
33
|
+
code: null,
|
|
34
|
+
stdout,
|
|
35
|
+
stderr,
|
|
36
|
+
error: `执行超时(超过 ${Math.round(timeoutMs / 1000)} 秒)`
|
|
37
|
+
});
|
|
38
|
+
}, timeoutMs);
|
|
39
|
+
conn
|
|
40
|
+
.on('ready', () => {
|
|
41
|
+
conn.exec(command, (err, stream) => {
|
|
42
|
+
if (err) {
|
|
43
|
+
finish({ code: null, stdout, stderr, error: `执行命令失败: ${err.message}` });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
stream
|
|
47
|
+
.on('close', (code) => {
|
|
48
|
+
finish({ code, stdout, stderr });
|
|
49
|
+
})
|
|
50
|
+
.on('data', (chunk) => {
|
|
51
|
+
const text = chunk.toString();
|
|
52
|
+
stdout += text;
|
|
53
|
+
try {
|
|
54
|
+
onOutput?.('stdout', text);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
/* 回调异常不影响执行 */
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
stream.stderr.on('data', (chunk) => {
|
|
61
|
+
const text = chunk.toString();
|
|
62
|
+
stderr += text;
|
|
63
|
+
try {
|
|
64
|
+
onOutput?.('stderr', text);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
/* 回调异常不影响执行 */
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
})
|
|
72
|
+
.on('error', (err) => {
|
|
73
|
+
finish({
|
|
74
|
+
code: null,
|
|
75
|
+
stdout,
|
|
76
|
+
stderr,
|
|
77
|
+
error: `SSH 连接失败: ${err.message}(请检查 IP/端口/用户名/密码)`
|
|
78
|
+
});
|
|
79
|
+
})
|
|
80
|
+
.connect({
|
|
81
|
+
host: server.host,
|
|
82
|
+
port: server.port || 22,
|
|
83
|
+
username: server.username,
|
|
84
|
+
password: server.password,
|
|
85
|
+
readyTimeout: Math.min(timeoutMs, 30_000)
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
export const sshCommandAction = async (task, ctx) => {
|
|
90
|
+
const p = task.config.actionParams ?? {};
|
|
91
|
+
const serverId = String(p['serverId'] ?? '');
|
|
92
|
+
const command = String(p['command'] ?? '').trim();
|
|
93
|
+
if (!command) {
|
|
94
|
+
return { success: false, error: '未填写要执行的命令' };
|
|
95
|
+
}
|
|
96
|
+
const servers = (ctx.config.get().servers ?? []);
|
|
97
|
+
const server = servers.find((s) => s.id === serverId);
|
|
98
|
+
if (!server) {
|
|
99
|
+
return {
|
|
100
|
+
success: false,
|
|
101
|
+
error: `未找到服务器配置(serverId=${serverId || '空'})。请先在「系统设置 → Linux 服务器」中添加服务器并在本任务里选择它。`
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (!server.host || !server.username) {
|
|
105
|
+
return { success: false, error: `服务器「${server.name || serverId}」配置不完整(缺少 IP 或用户名)` };
|
|
106
|
+
}
|
|
107
|
+
const parsedTimeout = Number(p['timeoutMs']);
|
|
108
|
+
const timeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout > 0 ? parsedTimeout : DEFAULT_TIMEOUT_MS;
|
|
109
|
+
ctx.log(`SSH ${server.username}@${server.host}:${server.port || 22}`);
|
|
110
|
+
const r = await execRemote(server, command, timeoutMs, (stream, text) => ctx.onOutput?.(stream, text));
|
|
111
|
+
if (r.error) {
|
|
112
|
+
return { success: false, error: r.error, stdout: r.stdout, stderr: r.stderr };
|
|
113
|
+
}
|
|
114
|
+
const ok = r.code === 0;
|
|
115
|
+
ctx.log(`退出码 ${r.code ?? '未知'}`);
|
|
116
|
+
return {
|
|
117
|
+
success: ok,
|
|
118
|
+
exitCode: r.code,
|
|
119
|
+
stdout: r.stdout,
|
|
120
|
+
stderr: r.stderr,
|
|
121
|
+
error: ok ? undefined : `命令退出码 ${r.code}`
|
|
122
|
+
};
|
|
123
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const taskChainAction = async (task, ctx) => {
|
|
2
|
+
const raw = task.config.actionParams?.['taskIds'];
|
|
3
|
+
let ids = [];
|
|
4
|
+
if (Array.isArray(raw))
|
|
5
|
+
ids = raw.map(String);
|
|
6
|
+
else if (typeof raw === 'string' && raw.trim()) {
|
|
7
|
+
ids = raw.split(/[,,\s]+/).filter(Boolean);
|
|
8
|
+
}
|
|
9
|
+
if (!ids.length || !ctx.requestRun) {
|
|
10
|
+
return { success: false, error: '未配置要触发的任务(taskIds)' };
|
|
11
|
+
}
|
|
12
|
+
const results = [];
|
|
13
|
+
for (const id of ids) {
|
|
14
|
+
const ok = ctx.requestRun(id, 'chain');
|
|
15
|
+
results.push(`${ok ? '✅' : '❌'} ${id}`);
|
|
16
|
+
}
|
|
17
|
+
const failed = results.filter((r) => r.startsWith('❌')).length;
|
|
18
|
+
return {
|
|
19
|
+
success: failed === 0,
|
|
20
|
+
stdout: `已触发 ${results.length - failed}/${results.length} 个任务`,
|
|
21
|
+
stderr: failed > 0 ? results.filter((r) => r.startsWith('❌')).join('\n') : undefined,
|
|
22
|
+
error: failed > 0 ? `${failed} 个任务触发失败` : undefined
|
|
23
|
+
};
|
|
24
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 音量控制动作 — macOS osascript(调节音量/静音切换)
|
|
3
|
+
*/
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
function runOsa(script) {
|
|
6
|
+
return new Promise((resolve) => {
|
|
7
|
+
execFile('osascript', ['-e', script], { timeout: 10_000 }, (err, stdout) => {
|
|
8
|
+
if (err)
|
|
9
|
+
resolve({ success: false, error: String(err) });
|
|
10
|
+
else
|
|
11
|
+
resolve({ success: true, stdout: stdout.trim() });
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export const volumeControlAction = async (task) => {
|
|
16
|
+
if (process.platform !== 'darwin') {
|
|
17
|
+
return { success: false, error: `音量控制暂不支持 ${process.platform}` };
|
|
18
|
+
}
|
|
19
|
+
const p = task.config.actionParams ?? {};
|
|
20
|
+
// 静音切换优先
|
|
21
|
+
if (typeof p['mute'] === 'boolean') {
|
|
22
|
+
return runOsa(p['mute'] ? 'set volume with mute' : 'set volume without mute');
|
|
23
|
+
}
|
|
24
|
+
const vol = Number(p['volume']);
|
|
25
|
+
if (!Number.isFinite(vol)) {
|
|
26
|
+
// 未指定参数 → 返回当前音量
|
|
27
|
+
return runOsa('output volume of (get volume settings)');
|
|
28
|
+
}
|
|
29
|
+
const clamped = Math.max(0, Math.min(100, Math.round(vol)));
|
|
30
|
+
return runOsa(`set volume output volume ${clamped}`);
|
|
31
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 注册全部内置动作
|
|
3
|
+
*/
|
|
4
|
+
import { clearClipboardAction, lockScreenAction, openTrashAction, refreshDesktopAction } from "./actions/builtin.js";
|
|
5
|
+
import { clipboardWatchAction } from "./actions/clipboard-watch.js";
|
|
6
|
+
import { httpRequestAction } from "./actions/http-request.js";
|
|
7
|
+
import { jenkinsBuildAction } from "./actions/jenkins-build.js";
|
|
8
|
+
import { openAppAction } from "./actions/open-app.js";
|
|
9
|
+
import { pythonScriptAction } from "./actions/python-script.js";
|
|
10
|
+
import { screenshotAction } from "./actions/screenshot.js";
|
|
11
|
+
import { sendKeystrokeAction } from "./actions/send-keystroke.js";
|
|
12
|
+
import { showReminderAction } from "./actions/show-reminder.js";
|
|
13
|
+
import { sshCommandAction } from "./actions/ssh-command.js";
|
|
14
|
+
import { taskChainAction } from "./actions/task-chain.js";
|
|
15
|
+
import { volumeControlAction } from "./actions/volume-control.js";
|
|
16
|
+
/** 全部内置动作注册 */
|
|
17
|
+
export function registerBuiltinActions(registry) {
|
|
18
|
+
registry.register('python_script', pythonScriptAction);
|
|
19
|
+
registry.register('show_reminder', showReminderAction);
|
|
20
|
+
registry.register('open_app', openAppAction);
|
|
21
|
+
registry.register('http_request', httpRequestAction);
|
|
22
|
+
// 远程执行:Linux 服务器(SSH)/ Jenkins
|
|
23
|
+
registry.register('ssh_command', sshCommandAction);
|
|
24
|
+
registry.register('jenkins_build', jenkinsBuildAction);
|
|
25
|
+
registry.register('lock_screen', lockScreenAction);
|
|
26
|
+
registry.register('open_recycle_bin', openTrashAction);
|
|
27
|
+
registry.register('refresh_desktop', refreshDesktopAction);
|
|
28
|
+
registry.register('clear_clipboard', clearClipboardAction);
|
|
29
|
+
// M5 新增动作
|
|
30
|
+
registry.register('volume_control', volumeControlAction);
|
|
31
|
+
registry.register('screenshot', screenshotAction);
|
|
32
|
+
registry.register('send_keystroke', sendKeystrokeAction);
|
|
33
|
+
registry.register('task_chain', taskChainAction);
|
|
34
|
+
registry.register('clipboard_watch', clipboardWatchAction);
|
|
35
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export class ActionRegistry {
|
|
2
|
+
actions = new Map();
|
|
3
|
+
register(name, executor) {
|
|
4
|
+
this.actions.set(name, executor);
|
|
5
|
+
}
|
|
6
|
+
get(name) {
|
|
7
|
+
return this.actions.get(name) ?? null;
|
|
8
|
+
}
|
|
9
|
+
has(name) {
|
|
10
|
+
return this.actions.has(name);
|
|
11
|
+
}
|
|
12
|
+
list() {
|
|
13
|
+
return [...this.actions.keys()];
|
|
14
|
+
}
|
|
15
|
+
/** 执行动作;未注册的动作返回失败 */
|
|
16
|
+
async execute(task, ctx) {
|
|
17
|
+
const executor = this.actions.get(task.config.action);
|
|
18
|
+
if (!executor) {
|
|
19
|
+
return { success: false, error: `未知任务动作: ${task.config.action}` };
|
|
20
|
+
}
|
|
21
|
+
return executor(task, ctx);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 剪贴板监听 — 捕获到指定格式内容后触发任务(方案 §3.3.E: clipboard_watch)
|
|
3
|
+
*
|
|
4
|
+
* 语义:任务执行「剪贴板监听」动作 = 武装一个一次性监视器;
|
|
5
|
+
* 轮询系统剪贴板,文本命中正则后以匹配值为参数触发目标任务并自动解除。
|
|
6
|
+
*/
|
|
7
|
+
import { EventEmitter } from 'node:events';
|
|
8
|
+
import { readClipboardText } from "../utils/clipboard.js";
|
|
9
|
+
export class ClipboardWatchRegistry extends EventEmitter {
|
|
10
|
+
timer = null;
|
|
11
|
+
lastValue = '';
|
|
12
|
+
rules = [];
|
|
13
|
+
readClipboard;
|
|
14
|
+
/** 读取中标记:剪贴板读取为异步命令,避免轮询重叠 */
|
|
15
|
+
busy = false;
|
|
16
|
+
constructor(readClipboard = async () => readClipboardText()) {
|
|
17
|
+
super();
|
|
18
|
+
this.readClipboard = readClipboard;
|
|
19
|
+
}
|
|
20
|
+
get armedCount() {
|
|
21
|
+
return this.rules.length;
|
|
22
|
+
}
|
|
23
|
+
/** 武装一条监听规则(重复 pattern+target 会去重) */
|
|
24
|
+
arm(rule) {
|
|
25
|
+
try {
|
|
26
|
+
new RegExp(rule.pattern); // 校验合法性
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
throw new Error(`无效的正则表达式: ${rule.pattern} (${String(err)})`);
|
|
30
|
+
}
|
|
31
|
+
if (!this.rules.some((r) => r.pattern === rule.pattern && r.targetTaskId === rule.targetTaskId)) {
|
|
32
|
+
this.rules.push(rule);
|
|
33
|
+
}
|
|
34
|
+
this.start();
|
|
35
|
+
this.emit('armed', rule);
|
|
36
|
+
}
|
|
37
|
+
disarm(pattern, targetTaskId) {
|
|
38
|
+
const before = this.rules.length;
|
|
39
|
+
this.rules =
|
|
40
|
+
pattern || targetTaskId
|
|
41
|
+
? this.rules.filter((r) => !((!pattern || r.pattern === pattern) && (!targetTaskId || r.targetTaskId === targetTaskId)))
|
|
42
|
+
: [];
|
|
43
|
+
if (!this.rules.length)
|
|
44
|
+
this.stop();
|
|
45
|
+
return before - this.rules.length;
|
|
46
|
+
}
|
|
47
|
+
list() {
|
|
48
|
+
return [...this.rules];
|
|
49
|
+
}
|
|
50
|
+
start() {
|
|
51
|
+
if (this.timer)
|
|
52
|
+
return;
|
|
53
|
+
void this.read().then((v) => {
|
|
54
|
+
this.lastValue = v;
|
|
55
|
+
});
|
|
56
|
+
this.timer = setInterval(() => {
|
|
57
|
+
void this.tick();
|
|
58
|
+
}, 1500);
|
|
59
|
+
this.timer.unref?.();
|
|
60
|
+
}
|
|
61
|
+
async read() {
|
|
62
|
+
try {
|
|
63
|
+
return await this.readClipboard();
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return '';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
stop() {
|
|
70
|
+
if (this.timer)
|
|
71
|
+
clearInterval(this.timer);
|
|
72
|
+
this.timer = null;
|
|
73
|
+
}
|
|
74
|
+
shutdown() {
|
|
75
|
+
this.rules = [];
|
|
76
|
+
this.stop();
|
|
77
|
+
}
|
|
78
|
+
async tick() {
|
|
79
|
+
if (this.busy)
|
|
80
|
+
return;
|
|
81
|
+
this.busy = true;
|
|
82
|
+
try {
|
|
83
|
+
await this.doTick();
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
this.busy = false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async doTick() {
|
|
90
|
+
const value = await this.read();
|
|
91
|
+
if (!value || value === this.lastValue)
|
|
92
|
+
return;
|
|
93
|
+
this.lastValue = value;
|
|
94
|
+
for (const rule of [...this.rules]) {
|
|
95
|
+
let matched = null;
|
|
96
|
+
try {
|
|
97
|
+
const m = new RegExp(rule.pattern).exec(value);
|
|
98
|
+
matched = m ? (m[0] ?? null) : null;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (matched) {
|
|
104
|
+
// 先解除再触发,避免目标任务再次武装造成循环
|
|
105
|
+
this.rules = this.rules.filter((r) => r !== rule);
|
|
106
|
+
this.emit('match', { rule, value: matched });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (!this.rules.length)
|
|
110
|
+
this.stop();
|
|
111
|
+
}
|
|
112
|
+
}
|