@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.
Files changed (54) hide show
  1. package/README.md +70 -16
  2. package/bin/deskpet.mjs +70 -2
  3. package/dist/node/server/db/database.js +113 -0
  4. package/dist/node/server/db/migrate-legacy.js +88 -0
  5. package/dist/node/server/db/task-repository.js +374 -0
  6. package/dist/node/server/http/http-server.js +493 -0
  7. package/dist/node/server/http/ws-hub.js +59 -0
  8. package/dist/node/server/main.js +291 -0
  9. package/dist/node/server/plugins/actions/builtin.js +67 -0
  10. package/dist/node/server/plugins/actions/clipboard-watch.js +27 -0
  11. package/dist/node/server/plugins/actions/http-request.js +41 -0
  12. package/dist/node/server/plugins/actions/jenkins-build.js +183 -0
  13. package/dist/node/server/plugins/actions/open-app.js +41 -0
  14. package/dist/node/server/plugins/actions/python-script.js +180 -0
  15. package/dist/node/server/plugins/actions/screenshot.js +38 -0
  16. package/dist/node/server/plugins/actions/send-keystroke.js +100 -0
  17. package/dist/node/server/plugins/actions/show-reminder.js +7 -0
  18. package/dist/node/server/plugins/actions/ssh-command.js +123 -0
  19. package/dist/node/server/plugins/actions/task-chain.js +24 -0
  20. package/dist/node/server/plugins/actions/volume-control.js +31 -0
  21. package/dist/node/server/plugins/index.js +35 -0
  22. package/dist/node/server/plugins/registry.js +23 -0
  23. package/dist/node/server/services/clipboard-watcher.js +112 -0
  24. package/dist/node/server/services/config-store.js +141 -0
  25. package/dist/node/server/services/idle-monitor.js +131 -0
  26. package/dist/node/server/services/notifier.js +36 -0
  27. package/dist/node/server/services/quick-actions-store.js +52 -0
  28. package/dist/node/server/services/remote-connector.js +67 -0
  29. package/dist/node/server/services/scanner-reader.js +217 -0
  30. package/dist/node/server/services/script-runner.js +228 -0
  31. package/dist/node/server/services/snapshot-service.js +135 -0
  32. package/dist/node/server/services/task-scheduler.js +813 -0
  33. package/dist/node/server/services/wechat-bot.js +635 -0
  34. package/dist/node/server/services/wechat-command-types.js +1 -0
  35. package/dist/node/server/services/wechat-commands.js +330 -0
  36. package/dist/node/server/suppress-warnings.js +12 -0
  37. package/dist/node/server/utils/asset-url.js +26 -0
  38. package/dist/node/server/utils/auto-start.js +186 -0
  39. package/dist/node/server/utils/clipboard.js +50 -0
  40. package/dist/node/server/utils/dashboard-url.js +8 -0
  41. package/dist/node/server/utils/instance-guard.js +165 -0
  42. package/dist/node/server/utils/native-notify.js +53 -0
  43. package/dist/node/server/utils/open.js +37 -0
  44. package/dist/node/server/utils/paths.js +95 -0
  45. package/dist/node/server/utils/python-interpreter.js +129 -0
  46. package/dist/node/shared/animation-engine.js +349 -0
  47. package/dist/node/shared/chain-condition.js +39 -0
  48. package/dist/node/shared/cron-weekly.js +124 -0
  49. package/dist/node/shared/py-task-params.js +335 -0
  50. package/dist/node/shared/types.js +69 -0
  51. package/package.json +6 -2
  52. package/server/http/http-server.ts +4 -1
  53. package/server/utils/auto-start.ts +158 -49
  54. package/server/utils/paths.ts +28 -1
@@ -0,0 +1,228 @@
1
+ /**
2
+ * 脚本运行器 — Python 脚本执行封装(child_process.spawn)
3
+ * 对齐旧版 script_runner.py + process_manager.py 语义
4
+ * 支持启动后手动中止(任务停止功能)
5
+ */
6
+ import { spawn } from 'node:child_process';
7
+ import { existsSync } from 'node:fs';
8
+ /** stdout/stderr 截断上限(方案文档: 保留最后 10KB) */
9
+ export const OUTPUT_LIMIT = 10 * 1024;
10
+ /**
11
+ * 终止整个进程树。
12
+ *
13
+ * 为什么不能只 `child.kill()`:脚本自己还会派生进程(ssh、kubectl 之类),
14
+ * 只杀直接子进程会留下残余;Windows 上更彻底 —— 父进程被强杀时子进程根本不会跟着死。
15
+ *
16
+ * 两步走的原因:`taskkill` 能按 PPID 回收整棵树,但它是**外部命令**,
17
+ * 在受管控的机器上可能被策略禁用,所以后面必须再补一次原生 `process.kill()` 兜底 ——
18
+ * 否则一旦 taskkill 不可用,就变成「以为杀了其实没杀」。
19
+ */
20
+ export function killProcessTree(pid) {
21
+ if (process.platform === 'win32') {
22
+ try {
23
+ spawn('taskkill', ['/pid', String(pid), '/T', '/F'], {
24
+ windowsHide: true,
25
+ stdio: 'ignore'
26
+ });
27
+ }
28
+ catch {
29
+ /* taskkill 不可用时靠下面的原生调用兜底 */
30
+ }
31
+ }
32
+ try {
33
+ // 原生强制终止:不依赖任何外部命令,保证主进程一定被干掉
34
+ process.kill(pid, 'SIGKILL');
35
+ }
36
+ catch {
37
+ /* 进程可能已经退出,忽略 */
38
+ }
39
+ }
40
+ export class ScriptRunner {
41
+ /**
42
+ * 运行中的脚本:key → 子进程。
43
+ *
44
+ * 用 Map 而不是 Set 保存**进程引用**,是为了让 killAll() 能真正终止它们 ——
45
+ * 旧实现只 clear() 了集合,注释里「子进程随主进程退出」在 Windows 上并不成立,
46
+ * 于是每次重启都会留下一批继续在后台跑的 python 孤儿进程。
47
+ */
48
+ active = new Map();
49
+ /** 当前运行中的脚本数 */
50
+ get runningCount() {
51
+ return this.active.size;
52
+ }
53
+ /**
54
+ * 启动脚本执行(可中止)
55
+ */
56
+ start(config) {
57
+ const interpreter = config.interpreter ?? (process.platform === 'win32' ? 'python' : 'python3');
58
+ const args = [...(config.interpreterArgs ?? []), config.scriptPath, ...(config.args ?? [])];
59
+ const timeoutS = clampInt(config.timeout ?? 120, 10, 1800, 120);
60
+ const startedAt = Date.now();
61
+ const key = `${interpreter}|${config.scriptPath}|${startedAt}`;
62
+ // 先占位:spawn 之前也保证 runningCount / killAll 能看到这次执行,
63
+ // 真正拿到子进程引用后再补上(见下方 spawn 成功处)。
64
+ this.active.set(key, null);
65
+ let child = null;
66
+ let timedOut = false;
67
+ let killed = false;
68
+ let settled = false;
69
+ let timer = null;
70
+ const finish = (result) => {
71
+ if (settled)
72
+ return;
73
+ settled = true;
74
+ if (timer)
75
+ clearTimeout(timer);
76
+ this.active.delete(key);
77
+ resolve(result);
78
+ };
79
+ let resolve;
80
+ const result = new Promise((res) => {
81
+ resolve = res;
82
+ });
83
+ // 脚本文件不存在则直接失败
84
+ if (!existsSync(config.scriptPath)) {
85
+ finish({
86
+ success: false,
87
+ exitCode: null,
88
+ stdout: '',
89
+ stderr: '',
90
+ elapsedMs: 0,
91
+ timedOut: false,
92
+ errorMessage: `脚本文件不存在: ${config.scriptPath}`
93
+ });
94
+ return { result, kill: () => { }, settled: () => settled, pid: null };
95
+ }
96
+ let stdout = '';
97
+ let stderr = '';
98
+ try {
99
+ child = spawn(interpreter, args, {
100
+ cwd: config.workDir ?? undefined,
101
+ env: config.env ? { ...process.env, ...config.env } : process.env,
102
+ stdio: ['ignore', 'pipe', 'pipe'],
103
+ windowsHide: true
104
+ });
105
+ }
106
+ catch (err) {
107
+ finish({
108
+ success: false,
109
+ exitCode: null,
110
+ stdout: '',
111
+ stderr: '',
112
+ elapsedMs: Date.now() - startedAt,
113
+ timedOut: false,
114
+ errorMessage: `启动失败: ${String(err)}`
115
+ });
116
+ return { result, kill: () => { }, settled: () => settled, pid: null };
117
+ }
118
+ // spawn 成功:补上进程引用,这样 killAll() / kill() 才能真的终止它
119
+ this.active.set(key, child);
120
+ timer = setTimeout(() => {
121
+ timedOut = true;
122
+ // 超时同样要杀整棵树,否则脚本派生的 ssh / kubectl 会留成孤儿
123
+ if (child?.pid)
124
+ killProcessTree(child.pid);
125
+ }, timeoutS * 1000);
126
+ child.stdout?.on('data', (chunk) => {
127
+ const text = chunk.toString();
128
+ stdout = appendLimited(stdout, text, OUTPUT_LIMIT);
129
+ try {
130
+ config.onOutput?.('stdout', text);
131
+ }
132
+ catch {
133
+ /* 回调异常不影响执行 */
134
+ }
135
+ });
136
+ child.stderr?.on('data', (chunk) => {
137
+ const text = chunk.toString();
138
+ stderr = appendLimited(stderr, text, OUTPUT_LIMIT);
139
+ try {
140
+ config.onOutput?.('stderr', text);
141
+ }
142
+ catch {
143
+ /* 回调异常不影响执行 */
144
+ }
145
+ });
146
+ child.on('error', (err) => {
147
+ finish({
148
+ success: false,
149
+ exitCode: null,
150
+ stdout,
151
+ stderr,
152
+ elapsedMs: Date.now() - startedAt,
153
+ timedOut,
154
+ errorMessage: `进程错误: ${err.message}`
155
+ });
156
+ });
157
+ child.on('close', (code, signal) => {
158
+ const elapsedMs = Date.now() - startedAt;
159
+ let errorMessage = '';
160
+ if (timedOut) {
161
+ errorMessage = `脚本执行超时 (>${timeoutS}s)`;
162
+ }
163
+ else if (killed) {
164
+ errorMessage = '脚本已被手动终止';
165
+ }
166
+ else if (code !== 0) {
167
+ errorMessage = `脚本异常退出 (exit_code=${code ?? signal ?? '?'}), stderr: ${stderr.slice(0, 200)}`;
168
+ // Windows 特有:解释器/命令不存在(含落到了 Microsoft Store 的 python 别名存根)
169
+ if (code === 9009) {
170
+ errorMessage +=
171
+ ' —— 解释器不可用:系统未找到该命令。请到任务「解释器」填写可用的解释器(如 python)或完整路径';
172
+ }
173
+ else if (code === -4058) {
174
+ errorMessage += ' —— 解释器路径不存在,请检查任务「解释器」配置';
175
+ }
176
+ }
177
+ finish({
178
+ success: !timedOut && !killed && code === 0,
179
+ exitCode: code,
180
+ stdout,
181
+ stderr,
182
+ elapsedMs,
183
+ timedOut,
184
+ errorMessage
185
+ });
186
+ });
187
+ return {
188
+ result,
189
+ pid: child?.pid ?? null,
190
+ kill: () => {
191
+ killed = true;
192
+ if (child?.pid)
193
+ killProcessTree(child.pid);
194
+ },
195
+ settled: () => settled
196
+ };
197
+ }
198
+ /** 便捷执行(一次,无重试) */
199
+ execute(config) {
200
+ return this.start(config).result;
201
+ }
202
+ /**
203
+ * 中止所有运行中脚本(应用退出时)。
204
+ *
205
+ * 必须**真的**终止进程树。旧实现只清空了集合,注释里那句
206
+ * 「子进程随主进程退出」在 Windows 上并不成立 —— 父进程退出后 python 会继续跑,
207
+ * 于是每次重启都留下一批孤儿脚本(对部署类脚本尤其危险:用户以为失败了,
208
+ * 脚本其实还在改远端状态)。
209
+ */
210
+ killAll() {
211
+ for (const child of this.active.values()) {
212
+ if (child?.pid)
213
+ killProcessTree(child.pid);
214
+ }
215
+ this.active.clear();
216
+ }
217
+ }
218
+ function appendLimited(current, addition, limit) {
219
+ const next = current + addition;
220
+ if (next.length <= limit)
221
+ return next;
222
+ return next.slice(-limit);
223
+ }
224
+ function clampInt(v, min, max, fallback) {
225
+ if (!Number.isFinite(v))
226
+ return fallback;
227
+ return Math.max(min, Math.min(max, Math.round(v)));
228
+ }
@@ -0,0 +1,135 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ const AUTO_KEEP = 20;
4
+ const PACK_MAGIC = 'DESKPET-PACK';
5
+ export class SnapshotService {
6
+ db;
7
+ source;
8
+ constructor(db, source) {
9
+ this.db = db;
10
+ this.source = source;
11
+ }
12
+ /** 创建快照;auto=true 时只保留最近 20 份自动快照 */
13
+ snapshot(reason = 'manual') {
14
+ const data = JSON.stringify({
15
+ settings: this.source.getSettings(),
16
+ tasks: this.source.listTasks(),
17
+ quickActions: this.source.getQuickActions()
18
+ });
19
+ const result = this.db
20
+ .prepare('INSERT INTO snapshots (created_at, reason, data) VALUES (?, ?, ?)')
21
+ .run(new Date().toISOString(), reason, data);
22
+ const id = Number(result.lastInsertRowid);
23
+ if (reason === 'auto') {
24
+ this.pruneAuto(AUTO_KEEP);
25
+ }
26
+ return id;
27
+ }
28
+ pruneAuto(keep) {
29
+ this.db
30
+ .prepare(`DELETE FROM snapshots WHERE reason = 'auto' AND id NOT IN (
31
+ SELECT id FROM snapshots WHERE reason = 'auto' ORDER BY id DESC LIMIT ?
32
+ )`)
33
+ .run(keep);
34
+ }
35
+ list(limit = 50) {
36
+ const rows = this.db
37
+ .prepare('SELECT id, created_at, reason, data FROM snapshots ORDER BY id DESC LIMIT ?')
38
+ .all(limit);
39
+ return rows.map((r) => ({
40
+ id: Number(r.id),
41
+ createdAt: r.created_at,
42
+ reason: r.reason ?? 'manual',
43
+ taskCount: safeTaskCount(r.data)
44
+ }));
45
+ }
46
+ get(id) {
47
+ const row = this.db.prepare('SELECT * FROM snapshots WHERE id = ?').get(id);
48
+ if (!row)
49
+ return null;
50
+ return {
51
+ meta: {
52
+ id: Number(row.id),
53
+ createdAt: row.created_at,
54
+ reason: row.reason ?? 'manual',
55
+ taskCount: safeTaskCount(row.data)
56
+ },
57
+ data: JSON.parse(row.data)
58
+ };
59
+ }
60
+ remove(id) {
61
+ const r = this.db.prepare('DELETE FROM snapshots WHERE id = ?').run(id);
62
+ return Number(r.changes) > 0;
63
+ }
64
+ /** 恢复快照 */
65
+ restore(id) {
66
+ const snap = this.get(id);
67
+ if (!snap)
68
+ return false;
69
+ // 恢复前先做一份当前状态的手动快照兜底
70
+ this.snapshot('manual');
71
+ this.source.apply({
72
+ settings: snap.data.settings,
73
+ tasks: snap.data.tasks ?? [],
74
+ quickActions: snap.data.quickActions ?? null
75
+ });
76
+ return true;
77
+ }
78
+ /** 导出 .deskpet 配置包到指定目录,返回文件路径 */
79
+ exportPack(dir) {
80
+ if (!existsSync(dir))
81
+ mkdirSync(dir, { recursive: true });
82
+ const pack = {
83
+ magic: PACK_MAGIC,
84
+ version: 1,
85
+ exportedAt: new Date().toISOString(),
86
+ settings: this.source.getSettings(),
87
+ tasks: this.source.listTasks(),
88
+ quickActions: this.source.getQuickActions()
89
+ };
90
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
91
+ const filePath = join(dir, `deskpet-backup-${ts}.deskpet`);
92
+ writeFileSync(filePath, JSON.stringify(pack, null, 2), 'utf-8');
93
+ return filePath;
94
+ }
95
+ /** 导出为内存对象(HTTP 下载用) */
96
+ exportPackData() {
97
+ return {
98
+ magic: PACK_MAGIC,
99
+ version: 1,
100
+ exportedAt: new Date().toISOString(),
101
+ settings: this.source.getSettings(),
102
+ tasks: this.source.listTasks(),
103
+ quickActions: this.source.getQuickActions()
104
+ };
105
+ }
106
+ /** 导入配置包(JSON 字符串),成功返回任务数量 */
107
+ importPack(jsonText) {
108
+ let pack;
109
+ try {
110
+ pack = JSON.parse(jsonText);
111
+ }
112
+ catch (err) {
113
+ return { ok: false, message: `解析失败: ${String(err)}` };
114
+ }
115
+ if (pack.magic !== PACK_MAGIC) {
116
+ return { ok: false, message: '不是有效的 .deskpet 配置包' };
117
+ }
118
+ this.snapshot('manual'); // 导入前兜底
119
+ this.source.apply({
120
+ settings: pack.settings,
121
+ tasks: pack.tasks ?? [],
122
+ quickActions: pack.quickActions ?? null
123
+ });
124
+ return { ok: true, message: '导入完成', taskCount: (pack.tasks ?? []).length };
125
+ }
126
+ }
127
+ function safeTaskCount(dataJson) {
128
+ try {
129
+ const d = JSON.parse(dataJson);
130
+ return Array.isArray(d.tasks) ? d.tasks.length : 0;
131
+ }
132
+ catch {
133
+ return 0;
134
+ }
135
+ }