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