@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,209 @@
1
+ /**
2
+ * jenkins_build — 触发 Jenkins Job 构建并等待结果
3
+ *
4
+ * 凭据读取自「系统设置 → Jenkins」;CSRF crumb 自动获取(未启用 CSRF 时跳过)。
5
+ *
6
+ * 参数(task.actionParams):
7
+ * jobName Job 名称,支持 "文件夹/Job名"
8
+ * params 构建参数(可选):JSON 对象,或每行一条 key=value
9
+ * timeoutMs 等待构建完成的最长时间(可选,默认 10 分钟)
10
+ */
11
+ import type { ActionExecutor } from '../registry.ts'
12
+
13
+ const DEFAULT_TIMEOUT_MS = 600_000
14
+ const POLL_INTERVAL_MS = 3_000
15
+
16
+ interface JenkinsSettings {
17
+ url: string
18
+ username: string
19
+ password: string
20
+ }
21
+
22
+ function normalizeBase(raw: string): string {
23
+ return String(raw ?? '').trim().replace(/\/+$/, '')
24
+ }
25
+
26
+ function basicAuth(user: string, pass: string): string {
27
+ return 'Basic ' + Buffer.from(`${user}:${pass}`, 'utf-8').toString('base64')
28
+ }
29
+
30
+ /** "folder/my job" → "job/folder/job/my%20job" */
31
+ function jobPath(job: string): string {
32
+ const segs = String(job ?? '')
33
+ .split('/')
34
+ .map((s) => encodeURIComponent(s.trim()))
35
+ .filter(Boolean)
36
+ return segs.map((s) => `job/${s}`).join('/')
37
+ }
38
+
39
+ /** 构建参数:JSON 对象 / 每行 key=value / 空 */
40
+ function parseParams(raw: unknown): Record<string, string> {
41
+ if (!raw) return {}
42
+ if (typeof raw === 'object' && !Array.isArray(raw)) {
43
+ const out: Record<string, string> = {}
44
+ for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
45
+ out[k] = typeof v === 'object' ? JSON.stringify(v) : String(v)
46
+ }
47
+ return out
48
+ }
49
+ const text = String(raw).trim()
50
+ if (!text) return {}
51
+ if (text.startsWith('{')) {
52
+ const obj = JSON.parse(text) as Record<string, unknown>
53
+ const out: Record<string, string> = {}
54
+ for (const [k, v] of Object.entries(obj)) {
55
+ out[k] = typeof v === 'object' ? JSON.stringify(v) : String(v)
56
+ }
57
+ return out
58
+ }
59
+ const out: Record<string, string> = {}
60
+ for (const line of text.split('\n')) {
61
+ const t = line.trim()
62
+ if (!t) continue
63
+ const i = t.indexOf('=')
64
+ if (i <= 0) throw new Error(`构建参数每行应为 key=value,收到:${t}`)
65
+ out[t.slice(0, i).trim()] = t.slice(i + 1).trim()
66
+ }
67
+ return out
68
+ }
69
+
70
+ /** 获取 CSRF crumb;未启用 CSRF 时返回空 */
71
+ async function fetchCrumb(base: string, headers: Record<string, string>): Promise<Record<string, string>> {
72
+ try {
73
+ const r = await fetch(`${base}/crumbIssuer/api/json`, { headers })
74
+ if (!r.ok) return {}
75
+ const j = (await r.json()) as { crumbRequestField?: string; crumb?: string }
76
+ if (j.crumbRequestField && j.crumb) return { [j.crumbRequestField]: j.crumb }
77
+ return {}
78
+ } catch {
79
+ return {}
80
+ }
81
+ }
82
+
83
+ function sleep(ms: number): Promise<void> {
84
+ return new Promise((r) => setTimeout(r, ms))
85
+ }
86
+
87
+ /** 轮询直到返回非 null 或超时(返回 null 表示超时) */
88
+ async function pollUntil<T>(
89
+ fn: () => Promise<T | null>,
90
+ timeoutMs: number
91
+ ): Promise<T | null> {
92
+ const start = Date.now()
93
+ for (;;) {
94
+ const v = await fn()
95
+ if (v !== null && v !== undefined) return v
96
+ if (Date.now() - start >= timeoutMs) return null
97
+ await sleep(POLL_INTERVAL_MS)
98
+ }
99
+ }
100
+
101
+ export const jenkinsBuildAction: ActionExecutor = async (task, ctx) => {
102
+ const p = task.config.actionParams ?? {}
103
+ const job = String(p['jobName'] ?? '').trim()
104
+ if (!job) return { success: false, error: '未填写 Jenkins Job 名称' }
105
+
106
+ const j = (ctx.config.get().jenkins ?? {}) as Partial<JenkinsSettings>
107
+ const base = normalizeBase(j.url ?? '')
108
+ if (!base) {
109
+ return { success: false, error: '未配置 Jenkins 地址,请先到「系统设置 → Jenkins」填写 URL 与账号' }
110
+ }
111
+
112
+ let params: Record<string, string>
113
+ try {
114
+ params = parseParams(p['params'])
115
+ } catch (err) {
116
+ return { success: false, error: `构建参数解析失败: ${(err as Error).message}` }
117
+ }
118
+
119
+ const parsedTimeout = Number(p['timeoutMs'])
120
+ const timeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout > 0 ? parsedTimeout : DEFAULT_TIMEOUT_MS
121
+
122
+ const headers: Record<string, string> = { Authorization: basicAuth(j.username ?? '', j.password ?? '') }
123
+ Object.assign(headers, await fetchCrumb(base, headers))
124
+
125
+ const hasParams = Object.keys(params).length > 0
126
+ const endpoint = hasParams ? 'buildWithParameters' : 'build'
127
+ const qs = hasParams ? `?${new URLSearchParams(params).toString()}` : ''
128
+ const triggerUrl = `${base}/${jobPath(job)}/${endpoint}${qs}`
129
+
130
+ ctx.log(`触发 Jenkins 构建:${job}`)
131
+ let res: Response
132
+ try {
133
+ res = await fetch(triggerUrl, { method: 'POST', headers })
134
+ } catch (err) {
135
+ return { success: false, error: `请求 Jenkins 失败: ${(err as Error).message}(请检查地址与网络)` }
136
+ }
137
+
138
+ if (res.status === 401 || res.status === 403) {
139
+ return {
140
+ success: false,
141
+ error: `Jenkins 认证失败(HTTP ${res.status}),请检查用户名与密码/API Token`
142
+ }
143
+ }
144
+ if (!res.ok && res.status !== 201) {
145
+ const detail = (await res.text().catch(() => '')).slice(0, 200)
146
+ return { success: false, error: `触发构建失败:HTTP ${res.status} ${detail}` }
147
+ }
148
+
149
+ const location = res.headers.get('location') ?? ''
150
+ if (!location) {
151
+ return { success: false, error: 'Jenkins 未返回队列地址(响应缺少 Location 头)' }
152
+ }
153
+
154
+ // 1) 等排队任务落到具体构建号
155
+ const queueUrl = location.endsWith('/') ? `${location}api/json` : `${location}/api/json`
156
+ const buildNumber = await pollUntil(async () => {
157
+ try {
158
+ const r = await fetch(queueUrl, { headers })
159
+ if (!r.ok) return null
160
+ const q = (await r.json()) as { executable?: { number?: number } }
161
+ return q.executable?.number ?? null
162
+ } catch {
163
+ return null
164
+ }
165
+ }, Math.min(timeoutMs, 120_000))
166
+
167
+ if (buildNumber == null) {
168
+ return { success: false, error: '等待 Jenkins 开始构建超时(任务可能一直在排队)' }
169
+ }
170
+
171
+ // 2) 等构建结束拿到结果
172
+ const buildApi = `${base}/${jobPath(job)}/${buildNumber}/api/json`
173
+ const consoleUrl = `${base}/${jobPath(job)}/${buildNumber}/console`
174
+ const result = await pollUntil(async () => {
175
+ try {
176
+ const r = await fetch(buildApi, { headers })
177
+ if (!r.ok) return null
178
+ const b = (await r.json()) as { building?: boolean; result?: string | null }
179
+ if (b.building) return null
180
+ return b.result ?? null
181
+ } catch {
182
+ return null
183
+ }
184
+ }, timeoutMs)
185
+
186
+ if (result == null) {
187
+ return {
188
+ success: false,
189
+ error: `等待构建结果超时(>${Math.round(timeoutMs / 1000)}s)`,
190
+ stdout: `Job: ${job}\n构建号: #${buildNumber}\n控制台: ${consoleUrl}`
191
+ }
192
+ }
193
+
194
+ const ok = result === 'SUCCESS'
195
+ const summary = [
196
+ `Job: ${job}`,
197
+ `构建号: #${buildNumber}`,
198
+ `结果: ${result}`,
199
+ `控制台: ${consoleUrl}`
200
+ ].join('\n')
201
+
202
+ ctx.log(`构建 #${buildNumber} 结果 ${result}`)
203
+ return {
204
+ success: ok,
205
+ exitCode: ok ? 0 : 1,
206
+ stdout: summary,
207
+ error: ok ? undefined : `Jenkins 构建结果:${result}(#${buildNumber})`
208
+ }
209
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * open_app 动作 — 打开本地程序 / 文件 / 文件夹 / URL
3
+ * action_params:
4
+ * - url: 打开 URL(系统默认浏览器)
5
+ * - target: 文件/文件夹路径(系统默认程序)
6
+ * - app: 应用名或可执行文件路径
7
+ */
8
+ import { openApp, openPath, openUrl } from '../../utils/open.ts'
9
+ import type { ActionExecutor } from '../registry.ts'
10
+
11
+ export const openAppAction: ActionExecutor = async (task, ctx) => {
12
+ const p = task.config.actionParams
13
+ const url = p.url as string | undefined
14
+ const target = p.target as string | undefined
15
+ const app = p.app as string | undefined
16
+
17
+ try {
18
+ if (url) {
19
+ const err = await openUrl(String(url))
20
+ if (err) return { success: false, error: `打开失败: ${err}` }
21
+ ctx.log(`已打开 URL: ${url}`)
22
+ return { success: true }
23
+ }
24
+ if (target) {
25
+ const err = await openPath(String(target))
26
+ if (err) return { success: false, error: `打开失败: ${err}` }
27
+ ctx.log(`已打开: ${target}`)
28
+ return { success: true }
29
+ }
30
+ if (app) {
31
+ const err = await openApp(String(app))
32
+ if (err) return { success: false, error: `打开失败: ${err}` }
33
+ ctx.log(`已打开应用: ${app}`)
34
+ return { success: true }
35
+ }
36
+ return { success: false, error: '未指定 url/target/app 任一参数' }
37
+ } catch (err) {
38
+ return { success: false, error: `打开失败: ${String(err)}` }
39
+ }
40
+ }
@@ -0,0 +1,187 @@
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 type { LinuxServer } from '../../../shared/types.ts'
19
+ import { getDataRoot } from '../../utils/paths.ts'
20
+ import { resolvePythonInterpreter } from '../../utils/python-interpreter.ts'
21
+ import type { ActionExecutor } from '../registry.ts'
22
+
23
+ const TEMP_DIR_NAME = 'deskpet-scripts'
24
+
25
+ export const pythonScriptAction: ActionExecutor = async (task, ctx) => {
26
+ const c = task.config
27
+ const params = c.actionParams ?? {}
28
+ const scriptContent = typeof params['scriptContent'] === 'string' ? params['scriptContent'] : ''
29
+ const scriptName =
30
+ typeof params['scriptName'] === 'string' && params['scriptName'] ? params['scriptName'] : 'script.py'
31
+
32
+ const settings = ctx.config.get()
33
+ const servers = (settings.servers ?? []) as LinuxServer[]
34
+ const selected =
35
+ servers.find((s) => s.id === String(params['serverId'] ?? '')) ?? null
36
+
37
+ const dir = join(tmpdir(), TEMP_DIR_NAME)
38
+ const tempFiles: string[] = []
39
+ const stamp = Date.now()
40
+
41
+ try {
42
+ mkdirSync(dir, { recursive: true })
43
+ } catch (err) {
44
+ return { success: false, error: `创建临时目录失败: ${String(err)}` }
45
+ }
46
+
47
+ // ── 注入配置(Jenkins / 服务器),供脚本读取 ─────────────
48
+ let configPath = ''
49
+ try {
50
+ const injected = {
51
+ jenkins: settings.jenkins ?? { url: '', username: '', password: '' },
52
+ servers,
53
+ server: selected,
54
+ task: { id: task.id, name: c.name, args: c.scriptArgs ?? [] },
55
+ params
56
+ }
57
+ configPath = join(dir, `${task.id}-${stamp}.config.json`)
58
+ writeFileSync(configPath, JSON.stringify(injected, null, 2), 'utf-8')
59
+ tempFiles.push(configPath)
60
+ } catch (err) {
61
+ return { success: false, error: `写入注入配置失败: ${String(err)}` }
62
+ }
63
+
64
+ // ── 脚本来源 ────────────────────────────────────────────
65
+ let scriptPath = c.scriptPath || String(params['script_path'] ?? '')
66
+ if (scriptContent.trim()) {
67
+ try {
68
+ const p = join(dir, `${task.id}-${stamp}.py`)
69
+ writeFileSync(p, scriptContent, 'utf-8')
70
+ scriptPath = p
71
+ tempFiles.push(p)
72
+ } catch (err) {
73
+ cleanup(tempFiles)
74
+ return { success: false, error: `写入临时脚本失败: ${String(err)}` }
75
+ }
76
+ }
77
+
78
+ if (!scriptPath) {
79
+ cleanup(tempFiles)
80
+ return { success: false, error: '任务未导入 Python 脚本,请编辑任务上传 .py 文件' }
81
+ }
82
+
83
+ // 脚本日志目录(持久化,便于排查)
84
+ const logDir = join(getDataRoot(), 'logs')
85
+ try {
86
+ mkdirSync(logDir, { recursive: true })
87
+ } catch {
88
+ /* 建目录失败时交给脚本自行回退 */
89
+ }
90
+
91
+ const env: Record<string, string> = {
92
+ // Windows 下 Python 默认用 GBK 输出,会导致中文日志乱码 → 强制 UTF-8
93
+ PYTHONIOENCODING: 'utf-8',
94
+ PYTHONUTF8: '1',
95
+ // 关键:关闭 Python 的输出缓冲。否则 stdout 为管道时是「块缓冲」,
96
+ // print() 的内容要等缓冲区满或进程结束才会到达父进程 —— 管理台就看不到实时日志。
97
+ // 等价于 python -u;配合 script-runner 的 onOutput 即可边跑边看。
98
+ PYTHONUNBUFFERED: '1',
99
+ DESKPET_CONFIG: configPath,
100
+ DESKPET_LOG_DIR: logDir,
101
+ DESKPET_JENKINS_URL: settings.jenkins?.url ?? '',
102
+ DESKPET_JENKINS_USER: settings.jenkins?.username ?? '',
103
+ DESKPET_JENKINS_PASS: settings.jenkins?.password ?? '',
104
+ DESKPET_TASK_ID: task.id,
105
+ DESKPET_TASK_NAME: c.name ?? '',
106
+ DESKPET_PARAMS: JSON.stringify(params)
107
+ }
108
+ if (selected) {
109
+ env['DESKPET_SERVER_NAME'] = selected.name ?? ''
110
+ env['DESKPET_SERVER_HOST'] = selected.host ?? ''
111
+ env['DESKPET_SERVER_PORT'] = String(selected.port ?? 22)
112
+ env['DESKPET_SERVER_USER'] = selected.username ?? ''
113
+ env['DESKPET_SERVER_PASS'] = selected.password ?? ''
114
+ }
115
+
116
+ ctx.log(`开始执行脚本: ${scriptName}`)
117
+
118
+ // ── 解释器解析 ──────────────────────────────────────────
119
+ // Windows 上 `python3` 往往是 Microsoft Store 的别名存根,非交互执行直接返回 9009,
120
+ // 这里统一解析成真实可执行文件的绝对路径(跳过存根),避免「exit_code=9009、无任何输出」。
121
+ const resolution = resolvePythonInterpreter(c.interpreter)
122
+ if (!resolution.ok) {
123
+ cleanup(tempFiles)
124
+ return { success: false, error: resolution.error }
125
+ }
126
+ ctx.log(`解释器: ${resolution.interpreter.command}`)
127
+
128
+ try {
129
+ const handle = ctx.scriptRunner.start({
130
+ scriptPath,
131
+ interpreter: resolution.interpreter.command,
132
+ args: c.scriptArgs,
133
+ timeout: c.scriptTimeout,
134
+ workDir: c.scriptWorkDir,
135
+ env,
136
+ // 实时把子进程输出推给管理台(调度器注入的回调)
137
+ onOutput: (stream, text) => ctx.onOutput?.(stream, text)
138
+ })
139
+ // 注册句柄供任务停止
140
+ try {
141
+ ctx.trackRunningScript?.(task.id, handle)
142
+ } catch {
143
+ /* 调度器未注入时忽略 */
144
+ }
145
+ // 登记子进程 pid:服务中途被强杀时,靠它判断脚本是否还在后台跑
146
+ if (handle.pid) {
147
+ try {
148
+ ctx.onChildPid?.(handle.pid)
149
+ } catch {
150
+ /* 调度器未注入时忽略 */
151
+ }
152
+ }
153
+ const result = await handle.result
154
+ try {
155
+ ctx.trackRunningScript?.(task.id, null)
156
+ } catch {
157
+ /* ignore */
158
+ }
159
+ if (result.timedOut) {
160
+ ctx.log(`执行超时 (${c.scriptTimeout}s)`)
161
+ } else if (result.success) {
162
+ ctx.log(`执行成功, 退出码 ${result.exitCode}, 耗时 ${(result.elapsedMs / 1000).toFixed(1)}s`)
163
+ } else {
164
+ ctx.log(`执行失败: ${result.errorMessage}`)
165
+ }
166
+ return {
167
+ success: result.success,
168
+ exitCode: result.exitCode,
169
+ stdout: result.stdout,
170
+ stderr: result.stderr,
171
+ error: result.errorMessage || undefined,
172
+ timedOut: result.timedOut
173
+ }
174
+ } finally {
175
+ cleanup(tempFiles)
176
+ }
177
+ }
178
+
179
+ function cleanup(files: string[]): void {
180
+ for (const f of files) {
181
+ try {
182
+ rmSync(f, { force: true })
183
+ } catch {
184
+ /* 清理失败可忽略 */
185
+ }
186
+ }
187
+ }
@@ -0,0 +1,41 @@
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
+ import type { ActionResult, TaskRecord } from '../../../shared/types.ts'
9
+ import type { ActionExecutor } from '../registry.ts'
10
+
11
+ export const screenshotAction: ActionExecutor = async (task: TaskRecord) => {
12
+ const p = task.config.actionParams ?? {}
13
+ if (process.platform !== 'darwin') {
14
+ return { success: false, error: `截图动作暂不支持 ${process.platform}` }
15
+ }
16
+
17
+ // 目标路径:参数指定目录或默认 ~/Downloads
18
+ const dir = (typeof p['dir'] === 'string' && p['dir']) || join(homedir(), 'Downloads')
19
+ try {
20
+ mkdirSync(dir, { recursive: true })
21
+ } catch {
22
+ /* 已存在 */
23
+ }
24
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
25
+ const filePath =
26
+ (typeof p['file'] === 'string' && p['file']) || join(dir, `deskpet-screenshot-${ts}.png`)
27
+
28
+ return new Promise<ActionResult>((resolve) => {
29
+ // -x 不播放快门声;-m 只截主屏(可选)
30
+ const args = ['-x']
31
+ if (p['mainDisplayOnly'] === true) args.push('-m')
32
+ args.push(filePath)
33
+ execFile('screencapture', args, { timeout: 15_000 }, (err) => {
34
+ if (err) {
35
+ resolve({ success: false, error: String(err) })
36
+ } else {
37
+ resolve({ success: true, stdout: filePath })
38
+ }
39
+ })
40
+ })
41
+ }
@@ -0,0 +1,103 @@
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
+ import type { ActionResult, TaskRecord } from '../../../shared/types.ts'
8
+ import type { ActionExecutor } from '../registry.ts'
9
+
10
+ const MODIFIER_MAP: Record<string, string> = {
11
+ cmd: 'command down',
12
+ command: 'command down',
13
+ ctrl: 'control down',
14
+ control: 'control down',
15
+ alt: 'option down',
16
+ option: 'option down',
17
+ shift: 'shift down',
18
+ fn: 'fn down'
19
+ }
20
+
21
+ /** 组合键 → osascript keystroke 脚本;非法返回 null */
22
+ export function buildKeystrokeScript(combo: string): string | null {
23
+ const parts = combo
24
+ .split(/[+-]/)
25
+ .map((s) => s.trim().toLowerCase())
26
+ .filter(Boolean)
27
+ if (!parts.length) return null
28
+
29
+ const modifiers: string[] = []
30
+ let key = ''
31
+ for (const part of parts) {
32
+ if (MODIFIER_MAP[part]) {
33
+ modifiers.push(MODIFIER_MAP[part])
34
+ } else {
35
+ key = part
36
+ }
37
+ }
38
+ if (!key) return null
39
+
40
+ // 特殊键映射
41
+ const special: Record<string, string> = {
42
+ return: 'return',
43
+ enter: 'return',
44
+ tab: 'tab',
45
+ space: 'space',
46
+ delete: 'delete',
47
+ escape: 'escape',
48
+ esc: 'escape',
49
+ up: 'up arrow',
50
+ down: 'down arrow',
51
+ left: 'left arrow',
52
+ right: 'right arrow',
53
+ home: 'home',
54
+ end: 'end',
55
+ pageup: 'page up',
56
+ pagedown: 'page down'
57
+ }
58
+
59
+ if (special[key]) {
60
+ const using = modifiers.length ? ` using {${modifiers.join(', ')}}` : ''
61
+ return `tell application "System Events" to key code ${KEY_CODES[special[key]] ?? `"${special[key]}"`}${using}`
62
+ }
63
+ if (key.length !== 1) return null
64
+ const using = modifiers.length ? ` using {${modifiers.join(', ')}}` : ''
65
+ return `tell application "System Events" to keystroke "${key}"${using}`
66
+ }
67
+
68
+ // System Events 特殊键的 key code(部分键只能用 key code 表达)
69
+ const KEY_CODES: Record<string, number> = {
70
+ return: 36,
71
+ tab: 48,
72
+ space: 49,
73
+ delete: 51,
74
+ escape: 53,
75
+ home: 115,
76
+ end: 119,
77
+ pageup: 116,
78
+ 'page down': 121,
79
+ 'up arrow': 126,
80
+ 'down arrow': 125,
81
+ 'left arrow': 123,
82
+ 'right arrow': 124
83
+ }
84
+
85
+ export const sendKeystrokeAction: ActionExecutor = async (task: TaskRecord): Promise<ActionResult> => {
86
+ if (process.platform !== 'darwin') {
87
+ return { success: false, error: `按键模拟暂不支持 ${process.platform}` }
88
+ }
89
+ const combo = String(task.config.actionParams?.['keys'] ?? '').trim()
90
+ if (!combo) {
91
+ return { success: false, error: '未指定按键序列(actionParams.keys,如 cmd+q)' }
92
+ }
93
+ const script = buildKeystrokeScript(combo)
94
+ if (!script) {
95
+ return { success: false, error: `无法解析按键组合: ${combo}` }
96
+ }
97
+ return new Promise<ActionResult>((resolve) => {
98
+ execFile('osascript', ['-e', script], { timeout: 10_000 }, (err, stdout) => {
99
+ if (err) resolve({ success: false, error: String(err) })
100
+ else resolve({ success: true, stdout: `已发送按键: ${combo}\n${stdout.trim()}` })
101
+ })
102
+ })
103
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * show_reminder 动作 — 任务提醒
3
+ *
4
+ * 旧版支持「全屏闪烁 / 气泡」两种桌面级提醒,依赖桌面宠物窗口;
5
+ * 该窗口已移除,故这里只发送提醒文本(管理台通知 + 系统原生通知)。
6
+ * 历史任务 actionParams 里的 mode / flash / color 会被忽略(无害残留)。
7
+ */
8
+ import type { ActionExecutor } from '../registry.ts'
9
+
10
+ export const showReminderAction: ActionExecutor = async (task, ctx) => {
11
+ const p = task.config.actionParams
12
+ const text = String(p.text ?? task.config.name ?? '提醒')
13
+ ctx.notifier.showReminder({ text })
14
+ ctx.log(`提醒触发: ${text}`)
15
+ return { success: true }
16
+ }