@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,15 @@
1
+ /**
2
+ * 微信指令路由依赖的最小调度器接口(便于测试注入替身)
3
+ */
4
+ import type { TaskConfig, TaskDto } from '../../shared/types.ts'
5
+
6
+ export type { TaskDto }
7
+
8
+ export interface TaskSchedulerLike {
9
+ listTasks(): TaskDto[]
10
+ isStarted: boolean
11
+ requestRun(id: string, source?: 'manual' | 'trigger' | 'chain' | 'retry'): boolean
12
+ /** 带参执行:临时覆盖 scriptArgs,执行完自动恢复 */
13
+ runTaskWithArgs(id: string, args: string[]): boolean
14
+ createTask(config: TaskConfig): TaskDto
15
+ }
@@ -0,0 +1,367 @@
1
+ /**
2
+ * 微信指令路由 — 把微信收到的文本翻译成本地操作(对齐旧版 wechat_commands.py)
3
+ *
4
+ * 8 类指令: 帮助 / 状态 / 任务列表 / 创建任务 / 执行 / 带参执行 / 参数 / 提醒
5
+ * 仅关键字匹配,不接任何大模型;未识别返回提示。
6
+ */
7
+ import * as os from 'node:os'
8
+ import type { TaskDto, TaskSchedulerLike } from './wechat-command-types.ts'
9
+
10
+ export const HELP_TEXT = [
11
+ '🐾 桌面宠物 · 微信指令',
12
+ '✅创建任务 <名称> <日期> <时间>',
13
+ '  创建定时提醒任务',
14
+ '  如: 创建任务 党会 2026-09-20 14:00',
15
+ '✅任务列表 查看全部任务',
16
+ '✅执行 <名称|序号> -- <参数...>',
17
+ '  用自定义参数执行任务(临时覆盖,不改原配置)',
18
+ '✅带参执行 <名称|序号> -- <参数...> 同上(显式语义)',
19
+ '✅参数 <名称|序号> 查看任务运行参数',
20
+ '✅提醒 <内容> 在电脑上弹出全屏提醒',
21
+ '✅状态 查看桌宠与任务运行状态',
22
+ '✅帮助 显示本说明',
23
+ '任务执行完成后会自动推送结果到这里'
24
+ ].join('\n')
25
+
26
+ const STATUS_ICON: Record<string, string> = {
27
+ running: '🔄',
28
+ completed: '✅',
29
+ failed: '❌',
30
+ disabled: '⏸',
31
+ idle: '⚪',
32
+ pending: '⏳',
33
+ cancelled: '🚫'
34
+ }
35
+
36
+ const TYPE_NAME: Record<string, string> = { manual: '手动', scheduled: '定时', scene: '场景' }
37
+
38
+ function fmtTime(iso: string | null): string {
39
+ if (!iso) return '-'
40
+ const d = new Date(iso)
41
+ const p = (n: number): string => String(n).padStart(2, '0')
42
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
43
+ }
44
+
45
+ export interface CommandDeps {
46
+ scheduler: TaskSchedulerLike
47
+ /** 弹出桌面全屏提醒 */
48
+ reminder: (text: string) => void
49
+ }
50
+
51
+ export class WechatCommandRouter {
52
+ private deps: CommandDeps
53
+
54
+ constructor(deps: CommandDeps) {
55
+ this.deps = deps
56
+ }
57
+
58
+ /** 处理一条微信消息;返回回复文本,null 表示不回复 */
59
+ handle(text: string): string | null {
60
+ const t = (text ?? '').trim()
61
+ if (!t) return null
62
+
63
+ if (['帮助', 'help', '?', '?', '菜单'].includes(t.toLowerCase()) || t === '帮助') {
64
+ return HELP_TEXT
65
+ }
66
+ if (['状态', 'status', '运行状态'].includes(t)) {
67
+ return this.status()
68
+ }
69
+ if (['任务列表', '任务', 'tasks', '列表'].includes(t)) {
70
+ return this.listTasks()
71
+ }
72
+ for (const prefix of ['创建任务', '新建任务', '添加任务']) {
73
+ if (t.startsWith(prefix)) return this.createTask(t.slice(prefix.length).trim())
74
+ }
75
+ for (const prefix of ['执行', '运行', 'run']) {
76
+ if (t.startsWith(prefix)) return this.execute(t.slice(prefix.length).trim(), false)
77
+ }
78
+ for (const prefix of ['带参执行', '带参运行']) {
79
+ if (t.startsWith(prefix)) return this.execute(t.slice(prefix.length).trim(), true)
80
+ }
81
+ if (t.startsWith('参数')) return this.showParams(t.slice(2).trim())
82
+ if (t.toLowerCase().startsWith('params')) return this.showParams(t.slice(6).trim())
83
+ if (t.startsWith('提醒')) return this.remind(t.slice(2).trim())
84
+
85
+ return '❓ 未识别的指令,发送「帮助」查看可用指令'
86
+ }
87
+
88
+ // ── 状态 ────────────────────────────────────────────────
89
+
90
+ private status(): string {
91
+ const sched = this.deps.scheduler
92
+ if (!sched) return '🟢 桌面宠物在线\n(任务调度器未接入)'
93
+ const tasks = sched.listTasks()
94
+ const running = tasks.filter((t) => t.status === 'running').length
95
+ const enabled = tasks.filter((t) => t.config.enabled).length
96
+ return (
97
+ '🟢 桌面宠物在线\n' +
98
+ `时间:${fmtTime(new Date().toISOString())}\n` +
99
+ `主机:${os.hostname()}\n` +
100
+ `系统:${os.type()} ${os.release()}\n` +
101
+ `任务:共 ${tasks.length} 个 / 已启用 ${enabled} 个 / 运行中 ${running} 个\n` +
102
+ `调度器:${sched.isStarted ? '运行中' : '已停止'}`
103
+ )
104
+ }
105
+
106
+ // ── 任务列表 ────────────────────────────────────────────
107
+
108
+ private listTasks(): string {
109
+ const records = this.deps.scheduler?.listTasks() ?? []
110
+ if (!records.length) {
111
+ return '📋 当前没有配置任何任务\n请在管理台「任务管理」中添加'
112
+ }
113
+ const lines = ['📋 任务列表']
114
+ records.forEach((rec, i) => {
115
+ const icon = STATUS_ICON[rec.status] ?? '⚪'
116
+ const flag = rec.config.enabled ? '' : '(已禁用)'
117
+ lines.push(`${i + 1}. ${icon} ${rec.config.name}${flag}`)
118
+ })
119
+ lines.push('发送「执行 1」或「执行 任务名」立即运行')
120
+ return lines.join('\n')
121
+ }
122
+
123
+ // ── 任务解析:序号/精确/模糊 ────────────────────────────
124
+
125
+ private resolveTask(target: string): { record: TaskDto | null; error: string | null } {
126
+ const records = this.deps.scheduler?.listTasks() ?? []
127
+ if (!records.length) return { record: null, error: '❌ 当前没有配置任何任务' }
128
+
129
+ if (!target) {
130
+ return {
131
+ record: null,
132
+ error: '用法:执行/参数 <任务名或序号>\n发送「任务列表」查看可用任务'
133
+ }
134
+ }
135
+
136
+ let record: TaskDto | null = null
137
+
138
+ // 1) 按序号
139
+ if (/^\d+$/.test(target)) {
140
+ const idx = parseInt(target, 10)
141
+ if (idx >= 1 && idx <= records.length) record = records[idx - 1]
142
+ else return { record: null, error: `❌ 序号超出范围(1-${records.length})` }
143
+ }
144
+
145
+ // 2) 名称精确匹配
146
+ if (!record) {
147
+ record = records.find((r) => r.config.name === target) ?? null
148
+ }
149
+
150
+ // 3) 名称模糊匹配
151
+ if (!record) {
152
+ const hits = records.filter((r) => r.config.name.includes(target))
153
+ if (hits.length === 1) record = hits[0]
154
+ else if (hits.length > 1) {
155
+ const names = hits
156
+ .slice(0, 5)
157
+ .map((r) => r.config.name)
158
+ .join('、')
159
+ return { record: null, error: `❓ 匹配到多个任务:${names}\n请发送完整任务名或序号` }
160
+ }
161
+ }
162
+
163
+ if (!record) {
164
+ return { record: null, error: `❌ 未找到任务「${target}」\n发送「任务列表」查看可用任务` }
165
+ }
166
+ return { record, error: null }
167
+ }
168
+
169
+ // ── 执行 / 带参执行 ─────────────────────────────────────
170
+
171
+ private execute(target: string, forceArgs: boolean): string {
172
+ const sched = this.deps.scheduler
173
+ if (!sched) return '❌ 任务调度器未接入,无法执行任务'
174
+
175
+ // 解析 "任务名 -- arg1 arg2"
176
+ let taskTarget = target
177
+ let scriptArgs: string[] = []
178
+ if (target.includes(' -- ')) {
179
+ const [head, tail] = target.split(' -- ', 2)
180
+ taskTarget = head.trim()
181
+ scriptArgs = tail.trim() ? tail.trim().split(/\s+/) : []
182
+ }
183
+ if (forceArgs && !scriptArgs) {
184
+ return (
185
+ '用法:带参执行 <任务名|序号> -- <参数1> <参数2> ...\n' +
186
+ '例如:带参执行 备份 -- /data /backup\n' +
187
+ '参数会临时覆盖原配置,执行完自动恢复'
188
+ )
189
+ }
190
+
191
+ const { record, error } = this.resolveTask(taskTarget)
192
+ if (!record) return error as string
193
+
194
+ if (!record.config.enabled) {
195
+ return `⏸ 任务「${record.config.name}」已被禁用,请先在管理台启用`
196
+ }
197
+ if (record.status === 'running') {
198
+ return `🔄 任务「${record.config.name}」正在执行中,请稍候`
199
+ }
200
+
201
+ if (scriptArgs.length > 0) {
202
+ if (record.config.action !== 'python_script') {
203
+ return `⚠️ 任务「${record.config.name}」不是脚本任务\n只有 Python 脚本任务支持自定义参数`
204
+ }
205
+ const ok = sched.runTaskWithArgs(record.id, scriptArgs)
206
+ if (!ok) return `❌ 任务「${record.config.name}」启动失败`
207
+ return (
208
+ `🔔 已开始带参执行任务「${record.config.name}」\n` +
209
+ `临时参数:${scriptArgs.join(' ')}\n` +
210
+ `(执行完自动恢复原配置参数)\n完成后会自动推送结果`
211
+ )
212
+ }
213
+
214
+ const ok = sched.requestRun(record.id)
215
+ if (!ok) return `❌ 任务「${record.config.name}」启动失败`
216
+ return `🔔 已开始执行任务「${record.config.name}」\n完成后会自动推送结果`
217
+ }
218
+
219
+ // ── 查看参数 ────────────────────────────────────────────
220
+
221
+ private showParams(target: string): string {
222
+ const { record, error } = this.resolveTask(target)
223
+ if (!record) return error as string
224
+
225
+ const cfg = record.config
226
+ const lines = ['🔧 任务运行参数', `【${cfg.name}】`]
227
+ lines.push(
228
+ `类型:${TYPE_NAME[cfg.type] ?? cfg.type}|状态:${record.status}|${cfg.enabled ? '启用' : '已禁用'}`
229
+ )
230
+ lines.push(`动作:${cfg.action || '(无)'}`)
231
+
232
+ if (cfg.action === 'python_script') {
233
+ lines.push(`脚本:${cfg.scriptPath || '(未设置)'}`)
234
+ if (cfg.interpreter) lines.push(`解释器:${cfg.interpreter}`)
235
+ if (cfg.scriptArgs?.length) lines.push(`脚本参数:${cfg.scriptArgs.join(' ')}`)
236
+ if (cfg.scriptWorkDir) lines.push(`工作目录:${cfg.scriptWorkDir}`)
237
+ lines.push(`超时:${cfg.scriptTimeout}s`)
238
+ }
239
+
240
+ const tr = cfg.trigger
241
+ if (cfg.type === 'scheduled') {
242
+ let trig = '定时(未配置)'
243
+ if (tr.type === 'cron' && tr.cron) trig = `定时 (cron: ${tr.cron})`
244
+ else if (tr.type === 'once' && tr.datetime) trig = `定时 (${tr.datetime})`
245
+ else if (tr.type === 'delay' && tr.delaySeconds) trig = `延时 (${tr.delaySeconds}s)`
246
+ else if (tr.type === 'interval' && tr.intervalMinutes) trig = `间隔 (${tr.intervalMinutes} 分钟)`
247
+ lines.push(`触发:${trig}`)
248
+ } else if (cfg.type === 'scene') {
249
+ const sceneMap: Record<string, string> = { idle: '闲置', startup: '开机', network: '联网' }
250
+ lines.push(`触发:场景(${sceneMap[tr.type] ?? tr.type})`)
251
+ }
252
+
253
+ if (cfg.actionParams && Object.keys(cfg.actionParams).length > 0) {
254
+ lines.push('运行参数:')
255
+ for (const [k, v] of Object.entries(cfg.actionParams)) {
256
+ lines.push(` ${k}:${String(v)}`)
257
+ }
258
+ } else {
259
+ lines.push('运行参数:(无)')
260
+ }
261
+
262
+ lines.push(`重试:${cfg.retryCount} 次`)
263
+ lines.push(`累计执行:${record.executionCount} 次`)
264
+ if (record.lastRunAt) lines.push(`最后运行:${fmtTime(record.lastRunAt)}`)
265
+ return lines.join('\n')
266
+ }
267
+
268
+ // ── 提醒 ────────────────────────────────────────────────
269
+
270
+ private remind(content: string): string {
271
+ if (!content) return '用法:提醒 <提醒内容>\n例如:提醒 该开会了'
272
+ try {
273
+ this.deps.reminder(content)
274
+ } catch (err) {
275
+ return `❌ 提醒弹出失败:${String(err)}`
276
+ }
277
+ return `✅ 已在电脑上弹出提醒:${content}`
278
+ }
279
+
280
+ // ── 创建任务(定时提醒,oneTime) ───────────────────────
281
+
282
+ private createTask(content: string): string {
283
+ const sched = this.deps.scheduler
284
+ if (!sched) return '❌ 任务调度器未接入,无法创建任务'
285
+
286
+ if (!content) {
287
+ return (
288
+ '用法:创建任务 <任务名> <日期> <时间>\n' +
289
+ '例如:创建任务 党会 2026-09-20 14:00\n' +
290
+ '任务到时间会在电脑上弹出桌面提醒'
291
+ )
292
+ }
293
+
294
+ const dateMatch = /\d{4}[-/]\d{1,2}[-/]\d{1,2}/.exec(content)
295
+ const timeMatch = /\d{1,2}[::]\d{2}/.exec(content)
296
+ if (!dateMatch) {
297
+ return (
298
+ '❌ 未识别到日期\n格式:创建任务 <任务名> YYYY-MM-DD HH:MM\n例如:创建任务 党会 2026-09-20 14:00'
299
+ )
300
+ }
301
+
302
+ // 规范化日期
303
+ const dateParts = dateMatch[0].replace(/\//g, '-').split('-')
304
+ const dateStr = `${dateParts[0]}-${String(Number(dateParts[1])).padStart(2, '0')}-${String(
305
+ Number(dateParts[2])
306
+ ).padStart(2, '0')}`
307
+
308
+ // 规范化时间(缺省 09:00)
309
+ let timeStr = '09:00'
310
+ if (timeMatch) {
311
+ const tp = timeMatch[0].replace(':', ':').split(':')
312
+ timeStr = `${String(Number(tp[0])).padStart(2, '0')}:${tp[1]}`
313
+ }
314
+ const datetimeStr = `${dateStr} ${timeStr}`
315
+
316
+ // 提取任务名:剔除日期与时间片段
317
+ let name = content
318
+ if (timeMatch) name = name.replace(timeMatch[0], ' ')
319
+ name = name.replace(dateMatch[0], ' ')
320
+ name = name.split(/\s+/).filter(Boolean).join(' ')
321
+ if (!name) return '❌ 任务名不能为空\n例如:创建任务 党会 2026-09-20 14:00'
322
+
323
+ // 校验未来时间
324
+ const runTime = new Date(`${datetimeStr.replace(' ', 'T')}:00`)
325
+ if (Number.isNaN(runTime.getTime())) {
326
+ return `❌ 日期时间格式错误: ${datetimeStr}`
327
+ }
328
+ if (runTime.getTime() <= Date.now()) {
329
+ return `❌ 时间已过去: ${datetimeStr}\n请指定未来的时间`
330
+ }
331
+
332
+ const dto = sched.createTask({
333
+ name,
334
+ type: 'scheduled',
335
+ trigger: {
336
+ type: 'once',
337
+ datetime: datetimeStr,
338
+ cron: null,
339
+ delaySeconds: null,
340
+ intervalMinutes: null,
341
+ idleMinutes: null,
342
+ maxExecutions: -1,
343
+ holidayCheck: false
344
+ },
345
+ action: 'show_reminder',
346
+ actionParams: { text: `提醒:${name}` },
347
+ scriptPath: '',
348
+ interpreter: null,
349
+ scriptArgs: [],
350
+ scriptTimeout: 120,
351
+ scriptWorkDir: null,
352
+ retryCount: 0,
353
+ enabled: true,
354
+ notifyChannels: [],
355
+ chainNext: null,
356
+ chainCondition: null,
357
+ oneTime: true
358
+ })
359
+
360
+ return (
361
+ `✅ 定时提醒已创建\n` +
362
+ `任务:${dto.config.name}\n` +
363
+ `时间:${datetimeStr}\n` +
364
+ `到点将在电脑上弹出全屏提醒,执行后自动删除`
365
+ )
366
+ }
367
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * 进程级警告开关 —— 必须在 main.ts 的所有其他导入之前引入(ESM 导入按顺序求值)。
3
+ * node:sqlite 在 Node 22(Electron 37 内置)中为实验性 API,在内置模块编译期
4
+ * (compileForInternalLoader)同步发出 ExperimentalWarning,该时机早于 'warning'
5
+ * 事件分派,内部调用也不经过 process.emitWarning 属性查找,故事件监听与包装均无法
6
+ * 阻止默认打印。Node 在 emitExperimentalWarning 内提供了 process.noProcessWarnings
7
+ * 开关(等价 --no-warnings 对实验警告的效果):在加载 node:sqlite 前置为 true 即可
8
+ * 静默实验警告;DeprecationWarning 等其他类型警告不受影响。
9
+ */
10
+ ;(process as NodeJS.Process & { noProcessWarnings?: boolean }).noProcessWarnings = true
@@ -0,0 +1,27 @@
1
+ /**
2
+ * 皮肤资源 URL — 取代原 deskpet:// 自定义协议
3
+ *
4
+ * 皮肤精灵图等本地文件通过 HTTP 接口 /api/assets?path=<绝对路径> 分发,
5
+ * 服务端做目录白名单校验,避免任意文件读取。
6
+ */
7
+ import { existsSync } from 'node:fs'
8
+ import { normalize, resolve, sep } from 'node:path'
9
+ import { getResourcesRoot, getUserSkinsDir } from './paths.ts'
10
+
11
+ export const ASSET_ENDPOINT = '/api/assets'
12
+
13
+ /**
14
+ * 校验并解析 /api/assets 的 path 参数
15
+ * 仅允许访问用户皮肤目录与内置资源目录内的已存在文件,越界返回 null
16
+ */
17
+ export function resolveAssetRequest(raw: unknown): string | null {
18
+ if (typeof raw !== 'string' || !raw.trim()) return null
19
+ const abs = normalize(resolve(raw.trim()))
20
+ for (const root of [getUserSkinsDir(), getResourcesRoot()]) {
21
+ const r = normalize(resolve(root))
22
+ if (abs === r || abs.startsWith(r + sep)) {
23
+ return existsSync(abs) ? abs : null
24
+ }
25
+ }
26
+ return null
27
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * 开机自启 — 取代 Electron app.setLoginItemSettings
3
+ * Windows: HKCU\...\Run 注册表项
4
+ * macOS : ~/Library/LaunchAgents plist
5
+ * Linux : 暂不支持
6
+ *
7
+ * 仅在用户在设置中开启时写入;任何异常都只记录日志,不影响服务启动。
8
+ */
9
+ import { execFile } from 'node:child_process'
10
+ import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'
11
+ import { homedir } from 'node:os'
12
+ import { join } from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
14
+
15
+ const APP_ID = 'DeskPet'
16
+
17
+ /** 当前入口脚本绝对路径(供开机启动调用) */
18
+ function entryScript(): string {
19
+ return fileURLToPath(import.meta.url).replace(/utils[\\/]auto-start\.ts$/, 'main.ts')
20
+ }
21
+
22
+ /** 建议的启动命令:node <入口> */
23
+ function startCommand(): string {
24
+ return `"${process.execPath}" "${entryScript()}"`
25
+ }
26
+
27
+ export async function setAutoStart(on: boolean): Promise<boolean> {
28
+ try {
29
+ if (process.platform === 'win32') {
30
+ const args = on
31
+ ? ['add', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run', '/v', APP_ID, '/t', 'REG_SZ', '/d', startCommand(), '/f']
32
+ : ['delete', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run', '/v', APP_ID, '/f']
33
+ await new Promise<void>((resolve) => {
34
+ execFile('reg', args, { windowsHide: true }, () => resolve())
35
+ })
36
+ return true
37
+ }
38
+
39
+ if (process.platform === 'darwin') {
40
+ const dir = join(homedir(), 'Library', 'LaunchAgents')
41
+ const plist = join(dir, `${APP_ID}.plist`)
42
+ if (!on) {
43
+ if (existsSync(plist)) unlinkSync(plist)
44
+ return true
45
+ }
46
+ mkdirSync(dir, { recursive: true })
47
+ const cmd = startCommand().replace(/"/g, '')
48
+ const parts = cmd.split(' ')
49
+ const programArgs = parts.slice(1).map((p) => ` <string>${p}</string>`).join('\n')
50
+ writeFileSync(
51
+ plist,
52
+ `<?xml version="1.0" encoding="UTF-8"?>
53
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
54
+ <plist version="1.0">
55
+ <dict>
56
+ <key>Label</key>
57
+ <string>${APP_ID}</string>
58
+ <key>ProgramArguments</key>
59
+ <array>
60
+ <string>${parts[0]}</string>
61
+ ${programArgs}
62
+ </array>
63
+ <key>RunAtLoad</key>
64
+ <true/>
65
+ </dict>
66
+ </plist>
67
+ `,
68
+ 'utf-8'
69
+ )
70
+ return true
71
+ }
72
+ } catch (err) {
73
+ console.error('[autostart] 设置失败:', err)
74
+ return false
75
+ }
76
+ console.warn(`[autostart] 暂不支持的平台: ${process.platform}`)
77
+ return false
78
+ }
79
+
80
+ /** 查询当前是否已设置自启(尽力而为,失败返回 false) */
81
+ export function autoStartEnabled(): boolean {
82
+ try {
83
+ if (process.platform === 'darwin') {
84
+ return existsSync(join(homedir(), 'Library', 'LaunchAgents', `${APP_ID}.plist`))
85
+ }
86
+ } catch {
87
+ /* ignore */
88
+ }
89
+ return false
90
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * 剪贴板读写 — 取代 Electron clipboard 模块
3
+ * 通过系统命令实现,失败时静默返回空串(不阻塞任务链路)
4
+ */
5
+ import { execFile } from 'node:child_process'
6
+
7
+ function run(cmd: string, args: string[], input?: string): Promise<string> {
8
+ return new Promise((resolvePromise) => {
9
+ const child = execFile(
10
+ cmd,
11
+ args,
12
+ { timeout: 3000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 },
13
+ (err, stdout) => {
14
+ resolvePromise(err ? '' : String(stdout ?? ''))
15
+ }
16
+ )
17
+ if (input !== undefined) child.stdin?.end(input)
18
+ })
19
+ }
20
+
21
+ /** 读取剪贴板文本;不支持的平台返回空串 */
22
+ export async function readClipboardText(): Promise<string> {
23
+ try {
24
+ if (process.platform === 'darwin') return await run('pbpaste', [])
25
+ if (process.platform === 'win32') {
26
+ return await run('powershell', [
27
+ '-NoProfile',
28
+ '-Sta',
29
+ '-Command',
30
+ 'Get-Clipboard -Raw'
31
+ ])
32
+ }
33
+ if (process.platform === 'linux') return await run('xclip', ['-selection', 'clipboard', '-o'])
34
+ } catch {
35
+ /* ignore */
36
+ }
37
+ return ''
38
+ }
39
+
40
+ /** 清空剪贴板 */
41
+ export async function clearClipboard(): Promise<void> {
42
+ try {
43
+ if (process.platform === 'darwin') await run('pbcopy', [], '')
44
+ else if (process.platform === 'win32') {
45
+ await run('powershell', ['-NoProfile', '-Command', 'Set-Clipboard -Value $null'])
46
+ } else if (process.platform === 'linux') await run('xclip', ['-selection', 'clipboard'], '')
47
+ } catch {
48
+ /* ignore */
49
+ }
50
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * 管理台 WebUI 地址工具
3
+ */
4
+
5
+ /** 管理台访问地址(0.0.0.0 监听时本机访问用 127.0.0.1) */
6
+ export function dashboardUrl(host: string, port: number): string {
7
+ const h = host === '0.0.0.0' || host === '' ? '127.0.0.1' : host
8
+ return `http://${h}:${port}/`
9
+ }