@wanghaopeng1148/deskpet 2.0.0

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 (76) hide show
  1. package/README.md +394 -0
  2. package/bin/deskpet.mjs +142 -0
  3. package/dist/web/assets/DashboardView-BLXa7VrZ.css +1 -0
  4. package/dist/web/assets/DashboardView-CTyWcLtQ.js +60 -0
  5. package/dist/web/assets/SettingsView-7c3RiRrt.js +1 -0
  6. package/dist/web/assets/SettingsView-BkL0OpZC.css +1 -0
  7. package/dist/web/assets/TasksView-BTK1OTwU.css +1 -0
  8. package/dist/web/assets/TasksView-s1MaR5sZ.js +5 -0
  9. package/dist/web/assets/ToolsView-B8V-A1j_.js +178 -0
  10. package/dist/web/assets/ToolsView-DGLJATQ9.css +1 -0
  11. package/dist/web/assets/WeChatView-ChLBhPso.js +1 -0
  12. package/dist/web/assets/WeChatView-CvdhJ05E.css +1 -0
  13. package/dist/web/assets/browser-CjSdxGTc.js +8 -0
  14. package/dist/web/assets/dashboard-DJ_Miuzx.js +93 -0
  15. package/dist/web/assets/dashboard-Fbcagzwx.css +1 -0
  16. package/dist/web/dashboard/index.html +13 -0
  17. package/package.json +71 -0
  18. package/resources/icons/tray.png +0 -0
  19. package/resources/icons/tray@2x.png +0 -0
  20. package/resources/previews/busy.png +0 -0
  21. package/resources/previews/click.png +0 -0
  22. package/resources/previews/hover.png +0 -0
  23. package/resources/previews/idle.png +0 -0
  24. package/resources/previews/preview.png +0 -0
  25. package/resources/previews/sleep.png +0 -0
  26. package/resources/skins/default_cute/pet.json +7 -0
  27. package/resources/skins/default_cute/spritesheet.webp +0 -0
  28. package/resources/skins/default_cute/submission.json +29 -0
  29. package/server/db/database.ts +118 -0
  30. package/server/db/migrate-legacy.ts +121 -0
  31. package/server/db/task-repository.ts +585 -0
  32. package/server/http/http-server.ts +725 -0
  33. package/server/http/ws-hub.ts +66 -0
  34. package/server/main.ts +322 -0
  35. package/server/plugins/actions/builtin.ts +73 -0
  36. package/server/plugins/actions/clipboard-watch.ts +38 -0
  37. package/server/plugins/actions/http-request.ts +53 -0
  38. package/server/plugins/actions/jenkins-build.ts +209 -0
  39. package/server/plugins/actions/open-app.ts +40 -0
  40. package/server/plugins/actions/python-script.ts +187 -0
  41. package/server/plugins/actions/screenshot.ts +41 -0
  42. package/server/plugins/actions/send-keystroke.ts +103 -0
  43. package/server/plugins/actions/show-reminder.ts +16 -0
  44. package/server/plugins/actions/ssh-command.ts +148 -0
  45. package/server/plugins/actions/task-chain.ts +30 -0
  46. package/server/plugins/actions/volume-control.ts +35 -0
  47. package/server/plugins/index.ts +37 -0
  48. package/server/plugins/registry.ts +72 -0
  49. package/server/services/clipboard-watcher.ts +126 -0
  50. package/server/services/config-store.ts +152 -0
  51. package/server/services/idle-monitor.ts +146 -0
  52. package/server/services/notifier.ts +54 -0
  53. package/server/services/quick-actions-store.ts +54 -0
  54. package/server/services/remote-connector.ts +75 -0
  55. package/server/services/scanner-reader.ts +257 -0
  56. package/server/services/script-runner.ts +263 -0
  57. package/server/services/snapshot-service.ts +196 -0
  58. package/server/services/task-scheduler.ts +900 -0
  59. package/server/services/wechat-bot.ts +744 -0
  60. package/server/services/wechat-command-types.ts +15 -0
  61. package/server/services/wechat-commands.ts +367 -0
  62. package/server/suppress-warnings.ts +10 -0
  63. package/server/utils/asset-url.ts +27 -0
  64. package/server/utils/auto-start.ts +90 -0
  65. package/server/utils/clipboard.ts +50 -0
  66. package/server/utils/dashboard-url.ts +9 -0
  67. package/server/utils/instance-guard.ts +170 -0
  68. package/server/utils/native-notify.ts +68 -0
  69. package/server/utils/open.ts +38 -0
  70. package/server/utils/paths.ts +72 -0
  71. package/server/utils/python-interpreter.ts +154 -0
  72. package/shared/animation-engine.ts +422 -0
  73. package/shared/chain-condition.ts +60 -0
  74. package/shared/cron-weekly.ts +131 -0
  75. package/shared/py-task-params.ts +356 -0
  76. package/shared/types.ts +309 -0
@@ -0,0 +1,66 @@
1
+ /**
2
+ * WebSocket 推送中心 — 实时事件(任务状态/日志/皮肤变更)广播给所有 WebUI 客户端
3
+ */
4
+ import { WebSocketServer, type WebSocket } from 'ws'
5
+ import type { Server } from 'node:http'
6
+
7
+ export interface WsMessage {
8
+ type: string
9
+ payload: unknown
10
+ ts: number
11
+ }
12
+
13
+ export class WsHub {
14
+ private wss: WebSocketServer | null = null
15
+ private clients = new Set<WebSocket>()
16
+
17
+ /** 挂载到 HTTP server(路径 /ws) */
18
+ attach(server: Server, path = '/ws'): void {
19
+ this.wss = new WebSocketServer({ server, path })
20
+ this.wss.on('connection', (ws) => {
21
+ this.clients.add(ws)
22
+ ws.send(JSON.stringify({ type: 'hello', payload: { ok: true }, ts: Date.now() }))
23
+ ws.on('close', () => this.clients.delete(ws))
24
+ ws.on('error', () => this.clients.delete(ws))
25
+ // 心跳保活
26
+ const ping = setInterval(() => {
27
+ if (ws.readyState === ws.OPEN) ws.ping()
28
+ else clearInterval(ping)
29
+ }, 30000)
30
+ ws.on('close', () => clearInterval(ping))
31
+ })
32
+ }
33
+
34
+ /** 广播事件给全部客户端 */
35
+ broadcast(type: string, payload: unknown): void {
36
+ if (!this.wss || this.clients.size === 0) return
37
+ const msg: WsMessage = { type, payload, ts: Date.now() }
38
+ const data = JSON.stringify(msg)
39
+ for (const ws of this.clients) {
40
+ if (ws.readyState === ws.OPEN) {
41
+ try {
42
+ ws.send(data)
43
+ } catch {
44
+ /* 忽略单个客户端失败 */
45
+ }
46
+ }
47
+ }
48
+ }
49
+
50
+ get clientCount(): number {
51
+ return this.clients.size
52
+ }
53
+
54
+ close(): void {
55
+ for (const ws of this.clients) {
56
+ try {
57
+ ws.close()
58
+ } catch {
59
+ /* ignore */
60
+ }
61
+ }
62
+ this.clients.clear()
63
+ this.wss?.close()
64
+ this.wss = null
65
+ }
66
+ }
package/server/main.ts ADDED
@@ -0,0 +1,322 @@
1
+ /**
2
+ * DeskPet 服务入口 — 纯 Node 常驻进程(无 Electron)
3
+ *
4
+ * 职责: 任务系统(SQLite + 动作插件 + 调度器)、微信 Bot、扫码枪、
5
+ * 配置快照、管理台 HTTP/WebSocket 服务。前端用浏览器访问即可。
6
+ *
7
+ * 启动: npm start(内部 node --experimental-strip-types server/main.ts)
8
+ */
9
+ import './suppress-warnings.ts'
10
+ import { join } from 'node:path'
11
+ import type { Server } from 'node:http'
12
+ import { dashboardUrl } from './utils/dashboard-url.ts'
13
+ import { openDatabase } from './db/database.ts'
14
+ import { migrateLegacyTasksFile, type MigrateResult } from './db/migrate-legacy.ts'
15
+ import { TaskRepository } from './db/task-repository.ts'
16
+ import { registerBuiltinActions } from './plugins/index.ts'
17
+ import { ActionRegistry } from './plugins/registry.ts'
18
+ import { createApp } from './http/http-server.ts'
19
+ import { WsHub } from './http/ws-hub.ts'
20
+ import { ClipboardWatchRegistry } from './services/clipboard-watcher.ts'
21
+ import { ConfigStore } from './services/config-store.ts'
22
+ import { IdleMonitor, NetworkMonitor } from './services/idle-monitor.ts'
23
+ import { Notifier } from './services/notifier.ts'
24
+ import { QuickActionsStore } from './services/quick-actions-store.ts'
25
+ import { ScannerReader } from './services/scanner-reader.ts'
26
+ import { ScriptRunner } from './services/script-runner.ts'
27
+ import { SnapshotService, type SnapshotSource } from './services/snapshot-service.ts'
28
+ import { TaskScheduler } from './services/task-scheduler.ts'
29
+ import { WechatBot } from './services/wechat-bot.ts'
30
+ import { WechatCommandRouter } from './services/wechat-commands.ts'
31
+ import { setAutoStart } from './utils/auto-start.ts'
32
+ import { acquireInstanceLock, startHeartbeat } from './utils/instance-guard.ts'
33
+ import { getDataRoot, getConfigDir } from './utils/paths.ts'
34
+
35
+ async function main(): Promise<void> {
36
+ // ── 基础服务 ──────────────────────────────────────────
37
+ const config = new ConfigStore()
38
+
39
+ // ── 任务系统(SQLite + 动作插件 + 调度器) ─────────────
40
+ const db = openDatabase()
41
+ const repo = new TaskRepository(db)
42
+ // 启动时清理历史:每个任务只保留最近 N 条执行记录(默认 10)
43
+ try {
44
+ const pruned = repo.pruneExecutionsToLimit()
45
+ if (pruned > 0) console.log(`[deskpet] 已清理 ${pruned} 条过期执行记录`)
46
+ } catch (err) {
47
+ console.warn(`[deskpet] 清理执行记录失败: ${String(err)}`)
48
+ }
49
+ const scriptRunner = new ScriptRunner()
50
+ const notifier = new Notifier()
51
+ const registry = new ActionRegistry()
52
+ registerBuiltinActions(registry)
53
+
54
+ // ── 微信 Bot ──────────────────────────────────────────
55
+ const wechatCredsPath = join(getConfigDir(), 'wechat.json')
56
+ const clipboardWatch = new ClipboardWatchRegistry()
57
+ let wsHubSafe: WsHub | null = null
58
+
59
+ const scheduler = new TaskScheduler(repo, registry, {
60
+ scriptRunner,
61
+ notifier,
62
+ config,
63
+ trackRunningScript: (taskId, handle) => scheduler.trackRunningScript(taskId, handle),
64
+ requestRun: (taskId, source): boolean =>
65
+ scheduler.requestRun(taskId, (source ?? 'manual') as 'manual'),
66
+ armClipboardWatch: (rule) => {
67
+ clipboardWatch.arm(rule)
68
+ },
69
+ log: () => {
70
+ /* 日志经执行记录落库;此处可扩展文件日志 */
71
+ }
72
+ })
73
+
74
+ const wechatBot = new WechatBot(wechatCredsPath, () => config.get().wechat)
75
+ const commandRouter = new WechatCommandRouter({
76
+ scheduler,
77
+ reminder: (text) => notifier.showReminder({ text })
78
+ })
79
+ wechatBot.setCommandHandler((text) => commandRouter.handle(text))
80
+ notifier.setWechatSender((text) => {
81
+ void wechatBot.sendText(text)
82
+ })
83
+ wechatBot.on('message', ({ text }) => repo.insertWechatMessage('in', text))
84
+ wechatBot.on('status', (info) => wsHubSafe?.broadcast('wechat-status', info))
85
+ wechatBot.on('disconnected', (reason) => {
86
+ console.warn(`[wechat] ${reason}`)
87
+ notifier.notify('微信连接断开', reason)
88
+ })
89
+ // 若已保存过凭据,启动时自动恢复会话(无需重新扫码)
90
+ if (wechatBot.connected) {
91
+ wechatBot.resume()
92
+ }
93
+
94
+ // 剪贴板监听:命中后以匹配值触发目标任务
95
+ clipboardWatch.on('match', ({ rule, value }) => {
96
+ console.log(`[clipboard-watch] 命中 /${rule.pattern}/: ${value}`)
97
+ if (rule.passValueAsArg) {
98
+ scheduler.runTaskWithArgs(rule.targetTaskId, [value])
99
+ } else {
100
+ scheduler.requestRun(rule.targetTaskId, 'trigger')
101
+ }
102
+ })
103
+
104
+ // 扫码枪
105
+ const scanner = new ScannerReader(() => config.get().scanner)
106
+ scanner.on('scan', (value: string) => {
107
+ console.log(`[scanner] 扫码内容: ${value}`)
108
+ wsHubSafe?.broadcast('scanner-scan', { value })
109
+ const sc = config.get().scanner
110
+ const now = new Date().toLocaleString('zh-CN', { hour12: false })
111
+ if (sc.notifyWechat && wechatBot.connected) {
112
+ void wechatBot.sendText(`📱 扫码内容\n值:${value}\n时间:${now}`)
113
+ }
114
+ if (sc.notifyDesktop) {
115
+ notifier.notify('扫码枪', value)
116
+ }
117
+ })
118
+ scanner.on('status', (level: string, msg: string) => {
119
+ console.log(`[scanner:${level}] ${msg}`)
120
+ wsHubSafe?.broadcast('scanner-status', { level, message: msg })
121
+ })
122
+
123
+ // 快捷指令存储
124
+ const quickActionsStore = new QuickActionsStore(join(getConfigDir(), 'quick-actions.json'))
125
+
126
+ // 配置快照:配置变更自动快照,保留 20 份
127
+ const snapshotSource: SnapshotSource = {
128
+ getSettings: () => config.get(),
129
+ listTasks: () => scheduler.listTasks().map((t) => ({ id: t.id, config: t.config })),
130
+ getQuickActions: () => quickActionsStore.load(),
131
+ apply: ({ settings: s, tasks, quickActions }) => {
132
+ config.replaceAll(s)
133
+ repo.replaceAll(tasks ?? [])
134
+ if (quickActions) quickActionsStore.save(quickActions as never[])
135
+ }
136
+ }
137
+
138
+ // 旧版 tasks.json 迁移(仅当任务表为空)
139
+ const legacyPath = join(getConfigDir(), 'tasks.json')
140
+ const migrated: MigrateResult = migrateLegacyTasksFile(legacyPath, repo)
141
+ if (migrated.imported > 0) {
142
+ console.log(`[deskpet] 已从旧版导入 ${migrated.imported} 个任务`)
143
+ }
144
+
145
+ scheduler.load()
146
+ // 注意:scheduler.start() 刻意推迟到 listen() 成功之后,见下方调用点的注释
147
+
148
+ // 自动快照:任务/设置/皮肤变更去抖 3 秒
149
+ let snapshotTimer: ReturnType<typeof setTimeout> | null = null
150
+ const snapshots = new SnapshotService(db, snapshotSource)
151
+ const scheduleAutoSnapshot = (): void => {
152
+ if (snapshotTimer) clearTimeout(snapshotTimer)
153
+ snapshotTimer = setTimeout(() => {
154
+ try {
155
+ snapshots.snapshot('auto')
156
+ } catch {
157
+ /* ignore */
158
+ }
159
+ }, 3000)
160
+ snapshotTimer.unref?.()
161
+ }
162
+
163
+ // ── 管理台 WebUI 服务(Express + WebSocket) ───────────
164
+ const wsHub = new WsHub()
165
+ wsHubSafe = wsHub
166
+ notifier.setSink(wsHub)
167
+
168
+ const expressApp = createApp({
169
+ scheduler,
170
+ repo,
171
+ config,
172
+ notifier,
173
+ registry,
174
+ wsHub,
175
+ wechat: wechatBot,
176
+ scanner,
177
+ snapshots,
178
+ quickActions: quickActionsStore,
179
+ clipboardWatch
180
+ })
181
+
182
+ // 监听地址允许用环境变量临时覆盖。
183
+ // 为什么需要:dev-all.mjs 会读 DESKPET_PORT 去等端口,而后端原先只认 settings.json,
184
+ // 两边取值来源不一致 —— 一旦设了环境变量,就成了「后端其实起来了,启动脚本却一直等不到」。
185
+ // 顺带也让同机并行跑第二个实例(调试用)成为可能。
186
+ const envPort = Number(process.env.DESKPET_PORT)
187
+ const serverHost = process.env.DESKPET_HOST?.trim() || (config.get('server.host') as string)
188
+ const serverPort =
189
+ Number.isInteger(envPort) && envPort > 0 ? envPort : (config.get('server.port') as number)
190
+
191
+ const httpServer: Server = await listen(expressApp, serverPort, serverHost)
192
+ wsHub.attach(httpServer)
193
+
194
+ const dashUrl = dashboardUrl(serverHost, serverPort)
195
+ console.log(`[deskpet] 管理台: ${dashUrl}`)
196
+ console.log(`[deskpet] 数据目录: ${getDataRoot()}`)
197
+
198
+ // ── 事件接线 ──────────────────────────────────────────
199
+ scheduler.on('status', (evt) => wsHub.broadcast('task-status', evt))
200
+ scheduler.on('execution', (info) => wsHub.broadcast('execution', info))
201
+ scheduler.on('busy', (on) => wsHub.broadcast('busy', on))
202
+ scheduler.on('queue', (state) => wsHub.broadcast('queue-changed', state))
203
+ // 运行中脚本的实时 stdout/stderr(管理台可边跑边看)
204
+ scheduler.on('output', (ev) => wsHub.broadcast('task-output', ev))
205
+ scheduler.on('changed', () => {
206
+ wsHub.broadcast('tasks-changed', { ts: Date.now() })
207
+ scheduleAutoSnapshot()
208
+ })
209
+ config.on('changed', scheduleAutoSnapshot)
210
+
211
+ // ── 启动调度器(必须晚于 listen 成功)─────────────────────
212
+ // start() 内部会调用 recoverInterrupted():把库里残留在 running / pending 的任务
213
+ // 复位,并给未结束的执行记录收尾。这一步是「有罪推定」——它假定
214
+ // 「库里凡是 running 的任务,都是上个进程挂掉时留下的」。
215
+ //
216
+ // 但本进程完全可能只是因为 3210 端口被占用而没抢到实例(重复启动、--watch 抖动),
217
+ // 而真正在跑任务的其实是另一个进程。此时若提前执行 recoverInterrupted(),
218
+ // 就会把对方正在执行的任务误判成「服务重启,执行被中断」——
219
+ // 用户看到的现象是「刚点运行不到 1 秒就报中断」,且任务日志一个字节都没产生。
220
+ //
221
+ // 因此必须等 listen() 成功之后再启动调度器。
222
+ //
223
+ // 但光靠端口还不够:Windows 上 libuv 默认给监听套接字设 SO_REUSEADDR,
224
+ // 允许多个进程同时绑定同一端口且都 listen 成功,EADDRINUSE 基本不会触发。
225
+ // 所以这里再用心跳文件判一次「是否已有实例在跑」,有的话只跳过恢复动作、
226
+ // 不中断对方(见 utils/instance-guard.ts)。
227
+ // 单实例判定改用**原子锁**(O_EXCL 一步完成「检查 + 创建」),
228
+ // 取代原先心跳那种「读 → 判 → 写」三步:后者在两个实例几乎同时启动时,
229
+ // 双方都可能读到「没有心跳 / 心跳已过期」,于是双双认定「我是唯一实例」,
230
+ // 双双执行 recoverInterrupted(),把对方正在跑的任务掐成「服务重启,执行被中断」。
231
+ const instanceLock = acquireInstanceLock()
232
+ if (!instanceLock.acquired) {
233
+ console.warn(
234
+ '[deskpet] 检测到已有 DeskPet 实例在运行:本次跳过「残留任务恢复」,' +
235
+ '不会中断对方正在执行的任务'
236
+ )
237
+ }
238
+ startHeartbeat()
239
+ scheduler.start({ recover: instanceLock.acquired })
240
+
241
+ // 开机自启:启动时应用 + 跟随设置变化
242
+ let lastAutoStart = config.get().system.autoStart
243
+ void setAutoStart(lastAutoStart)
244
+ config.on('changed', () => {
245
+ const next = config.get().system.autoStart
246
+ if (next !== lastAutoStart) {
247
+ lastAutoStart = next
248
+ void setAutoStart(next)
249
+ }
250
+ })
251
+
252
+ // 扫码枪:启用则随应用自动连接 + 跟随"自动开启"开关变化
253
+ if (config.get().scanner.enabled) {
254
+ void scanner.start()
255
+ }
256
+ let lastScannerEnabled = config.get().scanner.enabled
257
+ config.on('changed', () => {
258
+ const next = config.get().scanner.enabled
259
+ if (next !== lastScannerEnabled) {
260
+ lastScannerEnabled = next
261
+ if (next) void scanner.start()
262
+ else scanner.stop()
263
+ }
264
+ })
265
+
266
+ // ── 场景触发:闲置 / 联网 ─────────────────────────────
267
+ const idleMonitor = new IdleMonitor((minutes) => scheduler.notifyIdle(minutes))
268
+ idleMonitor.start()
269
+ const networkMonitor = new NetworkMonitor(() => scheduler.notifyNetwork())
270
+ networkMonitor.start()
271
+
272
+ // ── 优雅退出 ─────────────────────────────────────────
273
+ const shutdown = (): void => {
274
+ // 用 shutdown() 而非 stop():前者会把运行中/排队中的任务收尾落库,
275
+ // 否则退出时留下的 running 状态会被下次启动误判成「服务重启中断」
276
+ scheduler.shutdown()
277
+ idleMonitor.stop()
278
+ networkMonitor.stop()
279
+ wsHub.close()
280
+ httpServer.close()
281
+ // killAll 现在会真正终止子进程树(旧实现只清状态,Windows 上会留下孤儿脚本)
282
+ scriptRunner.killAll()
283
+ wechatBot.shutdown()
284
+ scanner.shutdown()
285
+ clipboardWatch.shutdown()
286
+ instanceLock.release()
287
+ console.log('[deskpet] 已退出')
288
+ process.exit(0)
289
+ }
290
+
291
+ process.on('SIGINT', shutdown)
292
+ process.on('SIGTERM', shutdown)
293
+
294
+ console.log('[deskpet] 服务就绪')
295
+ }
296
+
297
+ /** 监听端口;端口被占用时给出提示并退出(取代单实例锁) */
298
+ function listen(
299
+ app: ReturnType<typeof createApp>,
300
+ port: number,
301
+ host: string
302
+ ): Promise<Server> {
303
+ return new Promise((resolve, reject) => {
304
+ const server = app.listen(port, host, () => resolve(server))
305
+ server.on('error', (err: NodeJS.ErrnoException) => {
306
+ if (err.code === 'EADDRINUSE') {
307
+ reject(
308
+ new Error(
309
+ `端口 ${port} 已被占用,DeskPet 可能已在运行(访问 ${dashboardUrl(host, port)})`
310
+ )
311
+ )
312
+ } else {
313
+ reject(err)
314
+ }
315
+ })
316
+ })
317
+ }
318
+
319
+ main().catch((err) => {
320
+ console.error('[deskpet] 启动失败:', err)
321
+ process.exit(1)
322
+ })
@@ -0,0 +1,73 @@
1
+ /**
2
+ * 系统内置动作 — 锁屏 / 打开回收站 / 刷新桌面 / 清剪贴板
3
+ * 无 Electron 依赖,全部走系统命令;未适配的平台返回失败
4
+ */
5
+ import { exec } from 'node:child_process'
6
+ import { homedir } from 'node:os'
7
+ import { join } from 'node:path'
8
+ import { clearClipboard } from '../../utils/clipboard.ts'
9
+ import { openPath } from '../../utils/open.ts'
10
+ import type { ActionExecutor } from '../registry.ts'
11
+
12
+ function runCmd(cmd: string): Promise<{ ok: boolean; error?: string }> {
13
+ return new Promise((resolve) => {
14
+ exec(cmd, { timeout: 10000 }, (err) => {
15
+ resolve(err ? { ok: false, error: err.message } : { ok: true })
16
+ })
17
+ })
18
+ }
19
+
20
+ /** 锁屏(macOS: Ctrl+Cmd+Q;Windows: rundll32 锁定工作站) */
21
+ export const lockScreenAction: ActionExecutor = async (_task, ctx) => {
22
+ const cmd =
23
+ process.platform === 'darwin'
24
+ ? 'osascript -e \'tell application "System Events" to keystroke "q" using {control down, command down}\''
25
+ : process.platform === 'win32'
26
+ ? 'rundll32.exe user32.dll,LockWorkStation'
27
+ : ''
28
+ if (!cmd) return { success: false, error: `暂不支持的平台: ${process.platform}` }
29
+ const r = await runCmd(cmd)
30
+ if (r.ok) {
31
+ ctx.log('已锁定屏幕')
32
+ return { success: true }
33
+ }
34
+ return { success: false, error: r.error }
35
+ }
36
+
37
+ /** 打开回收站 */
38
+ export const openTrashAction: ActionExecutor = async (_task, ctx) => {
39
+ const target =
40
+ process.platform === 'darwin'
41
+ ? join(homedir(), '.Trash')
42
+ : process.platform === 'win32'
43
+ ? 'shell:RecycleBinFolder'
44
+ : 'trash:///'
45
+ const err = await openPath(target)
46
+ if (err) return { success: false, error: err }
47
+ ctx.log('已打开回收站')
48
+ return { success: true }
49
+ }
50
+
51
+ /** 刷新桌面(macOS: 重启 Finder;Windows: 重启资源管理器) */
52
+ export const refreshDesktopAction: ActionExecutor = async (_task, ctx) => {
53
+ const cmd =
54
+ process.platform === 'darwin'
55
+ ? 'killall Finder'
56
+ : process.platform === 'win32'
57
+ ? 'taskkill /F /IM explorer.exe & start explorer.exe'
58
+ : ''
59
+ if (!cmd) return { success: false, error: `暂不支持的平台: ${process.platform}` }
60
+ const r = await runCmd(cmd)
61
+ if (r.ok) {
62
+ ctx.log('已刷新桌面')
63
+ return { success: true }
64
+ }
65
+ return { success: false, error: r.error }
66
+ }
67
+
68
+ /** 清空剪贴板 */
69
+ export const clearClipboardAction: ActionExecutor = async (_task, ctx) => {
70
+ await clearClipboard()
71
+ ctx.log('已清空剪贴板')
72
+ return { success: true }
73
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * 剪贴板监听动作 — 武装一次性监视器,命中正则后触发目标任务
3
+ *
4
+ * 参数: { pattern: "SF\\d{13,}", targetTaskId: "task_xxx", passValueAsArg?: true }
5
+ */
6
+ import type { ActionResult } from '../../../shared/types.ts'
7
+ import type { ActionExecutor } from '../registry.ts'
8
+
9
+ export interface WatchArms {
10
+ arm: (rule: { pattern: string; targetTaskId: string; passValueAsArg?: boolean }) => void
11
+ }
12
+
13
+ /** 注册表由 main 注入到 ActionContext.requestClipboardWatch */
14
+ export const clipboardWatchAction: ActionExecutor = async (task, ctx): Promise<ActionResult> => {
15
+ const p = task.config.actionParams ?? {}
16
+ const pattern = String(p['pattern'] ?? '').trim()
17
+ const targetTaskId = String(p['targetTaskId'] ?? '').trim()
18
+ if (!pattern || !targetTaskId) {
19
+ return {
20
+ success: false,
21
+ error: '需要参数 pattern(正则)与 targetTaskId(命中后执行的任务)'
22
+ }
23
+ }
24
+ const arm = ctx.armClipboardWatch
25
+ if (!arm) {
26
+ return { success: false, error: '剪贴板监视器未接入' }
27
+ }
28
+ try {
29
+ arm({ pattern, targetTaskId, passValueAsArg: p['passValueAsArg'] !== false })
30
+ } catch (err) {
31
+ return { success: false, error: String(err) }
32
+ }
33
+ ctx.log(`已武装剪贴板监听: /${pattern}/ → ${targetTaskId}`)
34
+ return {
35
+ success: true,
36
+ stdout: `正在监听剪贴板 /${pattern}/,命中后执行任务 ${targetTaskId}`
37
+ }
38
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * http_request 动作 — 定时请求 API(GET/POST + header + body)
3
+ * action_params:
4
+ * - url: 必填
5
+ * - method: GET/POST/PUT/DELETE...
6
+ * - headers: Record<string, string>
7
+ * - body: string 或对象
8
+ * - timeoutMs: 默认 15000
9
+ * - successCodes: 视为成功的状态码数组,默认 [200..299]
10
+ */
11
+ import type { ActionExecutor } from '../registry.ts'
12
+
13
+ export const httpRequestAction: ActionExecutor = async (task, ctx) => {
14
+ const p = task.config.actionParams
15
+ const url = p.url as string | undefined
16
+ if (!url) return { success: false, error: '未指定 url' }
17
+
18
+ const method = String(p.method ?? 'GET').toUpperCase()
19
+ const headers = (p.headers as Record<string, string> | undefined) ?? {}
20
+ const body = p.body
21
+ const timeoutMs = typeof p.timeoutMs === 'number' ? p.timeoutMs : 15000
22
+ const successCodes = (p.successCodes as number[] | undefined) ?? []
23
+
24
+ const controller = new AbortController()
25
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
26
+ try {
27
+ const resp = await fetch(url, {
28
+ method,
29
+ headers: { 'User-Agent': 'DeskPet/2.0', ...headers },
30
+ body:
31
+ body === undefined || body === null
32
+ ? undefined
33
+ : typeof body === 'string'
34
+ ? body
35
+ : JSON.stringify(body),
36
+ signal: controller.signal
37
+ })
38
+ const ok = successCodes.length > 0
39
+ ? successCodes.includes(resp.status)
40
+ : resp.status >= 200 && resp.status < 300
41
+ const text = (await resp.text()).slice(0, 2000)
42
+ ctx.log(`HTTP ${method} ${url} → ${resp.status}`)
43
+ return {
44
+ success: ok,
45
+ stdout: text,
46
+ error: ok ? undefined : `HTTP ${resp.status}`
47
+ }
48
+ } catch (err) {
49
+ return { success: false, error: `请求失败: ${String(err)}` }
50
+ } finally {
51
+ clearTimeout(timer)
52
+ }
53
+ }