@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,148 @@
1
+ /**
2
+ * ssh_command — 在「系统设置 → Linux 服务器」选中的服务器上远程执行 Shell 命令
3
+ *
4
+ * 参数(task.actionParams):
5
+ * serverId 设置里 Linux 服务器的 id
6
+ * command 要执行的 Shell 命令(支持多行,会整体交给远端 shell)
7
+ * timeoutMs 超时毫秒数(可选,默认 120s)
8
+ */
9
+ import { Client } from 'ssh2'
10
+ import type { LinuxServer } from '../../../shared/types.ts'
11
+ import type { ActionExecutor } from '../registry.ts'
12
+
13
+ const DEFAULT_TIMEOUT_MS = 120_000
14
+
15
+ interface SshOutcome {
16
+ code: number | null
17
+ stdout: string
18
+ stderr: string
19
+ error?: string
20
+ }
21
+
22
+ /** 通过 SSH 在远端执行一条命令,返回退出码与输出 */
23
+ function execRemote(
24
+ server: LinuxServer,
25
+ command: string,
26
+ timeoutMs: number,
27
+ onOutput?: (stream: 'stdout' | 'stderr', text: string) => void
28
+ ): Promise<SshOutcome> {
29
+ return new Promise((resolve) => {
30
+ let stdout = ''
31
+ let stderr = ''
32
+ let done = false
33
+
34
+ const conn = new Client()
35
+
36
+ const finish = (r: SshOutcome): void => {
37
+ if (done) return
38
+ done = true
39
+ clearTimeout(timer)
40
+ try {
41
+ conn.end()
42
+ } catch {
43
+ /* 连接已断开时忽略 */
44
+ }
45
+ resolve(r)
46
+ }
47
+
48
+ const timer = setTimeout(() => {
49
+ finish({
50
+ code: null,
51
+ stdout,
52
+ stderr,
53
+ error: `执行超时(超过 ${Math.round(timeoutMs / 1000)} 秒)`
54
+ })
55
+ }, timeoutMs)
56
+
57
+ conn
58
+ .on('ready', () => {
59
+ conn.exec(command, (err, stream) => {
60
+ if (err) {
61
+ finish({ code: null, stdout, stderr, error: `执行命令失败: ${err.message}` })
62
+ return
63
+ }
64
+ stream
65
+ .on('close', (code: number) => {
66
+ finish({ code, stdout, stderr })
67
+ })
68
+ .on('data', (chunk: Buffer) => {
69
+ const text = chunk.toString()
70
+ stdout += text
71
+ try {
72
+ onOutput?.('stdout', text)
73
+ } catch {
74
+ /* 回调异常不影响执行 */
75
+ }
76
+ })
77
+ stream.stderr.on('data', (chunk: Buffer) => {
78
+ const text = chunk.toString()
79
+ stderr += text
80
+ try {
81
+ onOutput?.('stderr', text)
82
+ } catch {
83
+ /* 回调异常不影响执行 */
84
+ }
85
+ })
86
+ })
87
+ })
88
+ .on('error', (err: Error) => {
89
+ finish({
90
+ code: null,
91
+ stdout,
92
+ stderr,
93
+ error: `SSH 连接失败: ${err.message}(请检查 IP/端口/用户名/密码)`
94
+ })
95
+ })
96
+ .connect({
97
+ host: server.host,
98
+ port: server.port || 22,
99
+ username: server.username,
100
+ password: server.password,
101
+ readyTimeout: Math.min(timeoutMs, 30_000)
102
+ })
103
+ })
104
+ }
105
+
106
+ export const sshCommandAction: ActionExecutor = async (task, ctx) => {
107
+ const p = task.config.actionParams ?? {}
108
+ const serverId = String(p['serverId'] ?? '')
109
+ const command = String(p['command'] ?? '').trim()
110
+
111
+ if (!command) {
112
+ return { success: false, error: '未填写要执行的命令' }
113
+ }
114
+
115
+ const servers = (ctx.config.get().servers ?? []) as LinuxServer[]
116
+ const server = servers.find((s) => s.id === serverId)
117
+ if (!server) {
118
+ return {
119
+ success: false,
120
+ error: `未找到服务器配置(serverId=${serverId || '空'})。请先在「系统设置 → Linux 服务器」中添加服务器并在本任务里选择它。`
121
+ }
122
+ }
123
+ if (!server.host || !server.username) {
124
+ return { success: false, error: `服务器「${server.name || serverId}」配置不完整(缺少 IP 或用户名)` }
125
+ }
126
+
127
+ const parsedTimeout = Number(p['timeoutMs'])
128
+ const timeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout > 0 ? parsedTimeout : DEFAULT_TIMEOUT_MS
129
+
130
+ ctx.log(`SSH ${server.username}@${server.host}:${server.port || 22}`)
131
+ const r = await execRemote(server, command, timeoutMs, (stream, text) =>
132
+ ctx.onOutput?.(stream, text)
133
+ )
134
+
135
+ if (r.error) {
136
+ return { success: false, error: r.error, stdout: r.stdout, stderr: r.stderr }
137
+ }
138
+
139
+ const ok = r.code === 0
140
+ ctx.log(`退出码 ${r.code ?? '未知'}`)
141
+ return {
142
+ success: ok,
143
+ exitCode: r.code,
144
+ stdout: r.stdout,
145
+ stderr: r.stderr,
146
+ error: ok ? undefined : `命令退出码 ${r.code}`
147
+ }
148
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * 任务链动作 — 触发其他任务执行(方案 §3.3.E: task_chain)
3
+ */
4
+ import type { ActionResult } from '../../../shared/types.ts'
5
+ import type { ActionExecutor } from '../registry.ts'
6
+
7
+ export const taskChainAction: ActionExecutor = async (task, ctx): Promise<ActionResult> => {
8
+ const raw = task.config.actionParams?.['taskIds']
9
+ let ids: string[] = []
10
+ if (Array.isArray(raw)) ids = raw.map(String)
11
+ else if (typeof raw === 'string' && raw.trim()) {
12
+ ids = raw.split(/[,,\s]+/).filter(Boolean)
13
+ }
14
+ if (!ids.length || !ctx.requestRun) {
15
+ return { success: false, error: '未配置要触发的任务(taskIds)' }
16
+ }
17
+
18
+ const results: string[] = []
19
+ for (const id of ids) {
20
+ const ok = ctx.requestRun(id, 'chain')
21
+ results.push(`${ok ? '✅' : '❌'} ${id}`)
22
+ }
23
+ const failed = results.filter((r) => r.startsWith('❌')).length
24
+ return {
25
+ success: failed === 0,
26
+ stdout: `已触发 ${results.length - failed}/${results.length} 个任务`,
27
+ stderr: failed > 0 ? results.filter((r) => r.startsWith('❌')).join('\n') : undefined,
28
+ error: failed > 0 ? `${failed} 个任务触发失败` : undefined
29
+ }
30
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * 音量控制动作 — macOS osascript(调节音量/静音切换)
3
+ */
4
+ import { execFile } from 'node:child_process'
5
+ import type { ActionResult, TaskRecord } from '../../../shared/types.ts'
6
+ import type { ActionExecutor } from '../registry.ts'
7
+
8
+ function runOsa(script: string): Promise<ActionResult> {
9
+ return new Promise((resolve) => {
10
+ execFile('osascript', ['-e', script], { timeout: 10_000 }, (err, stdout) => {
11
+ if (err) resolve({ success: false, error: String(err) })
12
+ else resolve({ success: true, stdout: stdout.trim() })
13
+ })
14
+ })
15
+ }
16
+
17
+ export const volumeControlAction: ActionExecutor = async (task: TaskRecord): Promise<ActionResult> => {
18
+ if (process.platform !== 'darwin') {
19
+ return { success: false, error: `音量控制暂不支持 ${process.platform}` }
20
+ }
21
+ const p = task.config.actionParams ?? {}
22
+
23
+ // 静音切换优先
24
+ if (typeof p['mute'] === 'boolean') {
25
+ return runOsa(p['mute'] ? 'set volume with mute' : 'set volume without mute')
26
+ }
27
+
28
+ const vol = Number(p['volume'])
29
+ if (!Number.isFinite(vol)) {
30
+ // 未指定参数 → 返回当前音量
31
+ return runOsa('output volume of (get volume settings)')
32
+ }
33
+ const clamped = Math.max(0, Math.min(100, Math.round(vol)))
34
+ return runOsa(`set volume output volume ${clamped}`)
35
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 注册全部内置动作
3
+ */
4
+ import { clearClipboardAction, lockScreenAction, openTrashAction, refreshDesktopAction } from './actions/builtin.ts'
5
+ import { clipboardWatchAction } from './actions/clipboard-watch.ts'
6
+ import { httpRequestAction } from './actions/http-request.ts'
7
+ import { jenkinsBuildAction } from './actions/jenkins-build.ts'
8
+ import { openAppAction } from './actions/open-app.ts'
9
+ import { pythonScriptAction } from './actions/python-script.ts'
10
+ import { screenshotAction } from './actions/screenshot.ts'
11
+ import { sendKeystrokeAction } from './actions/send-keystroke.ts'
12
+ import { showReminderAction } from './actions/show-reminder.ts'
13
+ import { sshCommandAction } from './actions/ssh-command.ts'
14
+ import { taskChainAction } from './actions/task-chain.ts'
15
+ import { volumeControlAction } from './actions/volume-control.ts'
16
+ import type { ActionRegistry } from './registry.ts'
17
+
18
+ /** 全部内置动作注册 */
19
+ export function registerBuiltinActions(registry: ActionRegistry): void {
20
+ registry.register('python_script', pythonScriptAction)
21
+ registry.register('show_reminder', showReminderAction)
22
+ registry.register('open_app', openAppAction)
23
+ registry.register('http_request', httpRequestAction)
24
+ // 远程执行:Linux 服务器(SSH)/ Jenkins
25
+ registry.register('ssh_command', sshCommandAction)
26
+ registry.register('jenkins_build', jenkinsBuildAction)
27
+ registry.register('lock_screen', lockScreenAction)
28
+ registry.register('open_recycle_bin', openTrashAction)
29
+ registry.register('refresh_desktop', refreshDesktopAction)
30
+ registry.register('clear_clipboard', clearClipboardAction)
31
+ // M5 新增动作
32
+ registry.register('volume_control', volumeControlAction)
33
+ registry.register('screenshot', screenshotAction)
34
+ registry.register('send_keystroke', sendKeystrokeAction)
35
+ registry.register('task_chain', taskChainAction)
36
+ registry.register('clipboard_watch', clipboardWatchAction)
37
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * 动作插件注册表 — 任务动作可插拔
3
+ * 参考 dsh "万物皆插件":新动作 = 注册一个 executor,不改核心代码
4
+ */
5
+ import type { ActionResult, Reminder, TaskRecord } from '../../shared/types.ts'
6
+ import type { ConfigStore } from '../services/config-store.ts'
7
+ import type { ScriptRunner } from '../services/script-runner.ts'
8
+
9
+ /** 动作可用的通知能力(结构化接口,便于测试替身) */
10
+ export interface SchedulerNotifier {
11
+ /** 系统原生通知 */
12
+ notify(title: string, body: string): void
13
+ /** 任务提醒(气泡/全屏闪烁) */
14
+ showReminder(reminder: Reminder): void
15
+ /** 微信推送(M4 接入) */
16
+ sendWechat(text: string): void
17
+ }
18
+
19
+ /** 动作执行上下文 */
20
+ export interface ActionContext {
21
+ scriptRunner: ScriptRunner
22
+ notifier: SchedulerNotifier
23
+ config: ConfigStore
24
+ /** 注册运行中脚本句柄(供任务停止功能终止) */
25
+ trackRunningScript?: (taskId: string, handle: { kill: () => void } | null) => void
26
+ /** 触发其他任务(task_chain 动作) */
27
+ requestRun?: (taskId: string, source?: string) => boolean
28
+ /** 武装剪贴板监听(clipboard_watch 动作) */
29
+ armClipboardWatch?: (rule: { pattern: string; targetTaskId: string; passValueAsArg?: boolean }) => void
30
+ /** 逐行日志(写入执行记录) */
31
+ log: (msg: string) => void
32
+ /** 实时输出(流式推送到管理台,不必等脚本结束) */
33
+ onOutput?: (stream: 'stdout' | 'stderr', text: string) => void
34
+ /**
35
+ * 子进程启动后回传 pid(由调度器落库到执行记录)。
36
+ *
37
+ * 用途:服务被强杀 / 重启后,子进程仍可能活着(Windows 上父进程退出不会带走它),
38
+ * 启动恢复时靠这个 pid 判断脚本是不是还在后台跑,而不是一律判成「执行被中断」。
39
+ */
40
+ onChildPid?: (pid: number) => void
41
+ }
42
+
43
+ export type ActionExecutor = (task: TaskRecord, ctx: ActionContext) => Promise<ActionResult>
44
+
45
+ export class ActionRegistry {
46
+ private actions = new Map<string, ActionExecutor>()
47
+
48
+ register(name: string, executor: ActionExecutor): void {
49
+ this.actions.set(name, executor)
50
+ }
51
+
52
+ get(name: string): ActionExecutor | null {
53
+ return this.actions.get(name) ?? null
54
+ }
55
+
56
+ has(name: string): boolean {
57
+ return this.actions.has(name)
58
+ }
59
+
60
+ list(): string[] {
61
+ return [...this.actions.keys()]
62
+ }
63
+
64
+ /** 执行动作;未注册的动作返回失败 */
65
+ async execute(task: TaskRecord, ctx: ActionContext): Promise<ActionResult> {
66
+ const executor = this.actions.get(task.config.action)
67
+ if (!executor) {
68
+ return { success: false, error: `未知任务动作: ${task.config.action}` }
69
+ }
70
+ return executor(task, ctx)
71
+ }
72
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * 剪贴板监听 — 捕获到指定格式内容后触发任务(方案 §3.3.E: clipboard_watch)
3
+ *
4
+ * 语义:任务执行「剪贴板监听」动作 = 武装一个一次性监视器;
5
+ * 轮询系统剪贴板,文本命中正则后以匹配值为参数触发目标任务并自动解除。
6
+ */
7
+ import { EventEmitter } from 'node:events'
8
+ import { readClipboardText } from '../utils/clipboard.ts'
9
+
10
+ export interface ClipboardWatchRule {
11
+ /** 匹配模式(正则字符串),如快递单号 SF\\d{13,} */
12
+ pattern: string
13
+ /** 命中后触发的任务 id */
14
+ targetTaskId: string
15
+ /** 以匹配值作为脚本参数传给目标任务(仅 python_script 生效) */
16
+ passValueAsArg?: boolean
17
+ }
18
+
19
+ export class ClipboardWatchRegistry extends EventEmitter {
20
+ private timer: ReturnType<typeof setInterval> | null = null
21
+ private lastValue = ''
22
+ private rules: ClipboardWatchRule[] = []
23
+ private readClipboard: () => string | Promise<string>
24
+ /** 读取中标记:剪贴板读取为异步命令,避免轮询重叠 */
25
+ private busy = false
26
+
27
+ constructor(readClipboard: () => string | Promise<string> = async () => readClipboardText()) {
28
+ super()
29
+ this.readClipboard = readClipboard
30
+ }
31
+
32
+ get armedCount(): number {
33
+ return this.rules.length
34
+ }
35
+
36
+ /** 武装一条监听规则(重复 pattern+target 会去重) */
37
+ arm(rule: ClipboardWatchRule): void {
38
+ try {
39
+ new RegExp(rule.pattern) // 校验合法性
40
+ } catch (err) {
41
+ throw new Error(`无效的正则表达式: ${rule.pattern} (${String(err)})`)
42
+ }
43
+ if (!this.rules.some((r) => r.pattern === rule.pattern && r.targetTaskId === rule.targetTaskId)) {
44
+ this.rules.push(rule)
45
+ }
46
+ this.start()
47
+ this.emit('armed', rule)
48
+ }
49
+
50
+ disarm(pattern?: string, targetTaskId?: string): number {
51
+ const before = this.rules.length
52
+ this.rules =
53
+ pattern || targetTaskId
54
+ ? this.rules.filter(
55
+ (r) => !((!pattern || r.pattern === pattern) && (!targetTaskId || r.targetTaskId === targetTaskId))
56
+ )
57
+ : []
58
+ if (!this.rules.length) this.stop()
59
+ return before - this.rules.length
60
+ }
61
+
62
+ list(): ClipboardWatchRule[] {
63
+ return [...this.rules]
64
+ }
65
+
66
+ private start(): void {
67
+ if (this.timer) return
68
+ void this.read().then((v) => {
69
+ this.lastValue = v
70
+ })
71
+ this.timer = setInterval(() => {
72
+ void this.tick()
73
+ }, 1500)
74
+ this.timer.unref?.()
75
+ }
76
+
77
+ private async read(): Promise<string> {
78
+ try {
79
+ return await this.readClipboard()
80
+ } catch {
81
+ return ''
82
+ }
83
+ }
84
+
85
+ stop(): void {
86
+ if (this.timer) clearInterval(this.timer)
87
+ this.timer = null
88
+ }
89
+
90
+ shutdown(): void {
91
+ this.rules = []
92
+ this.stop()
93
+ }
94
+
95
+ private async tick(): Promise<void> {
96
+ if (this.busy) return
97
+ this.busy = true
98
+ try {
99
+ await this.doTick()
100
+ } finally {
101
+ this.busy = false
102
+ }
103
+ }
104
+
105
+ private async doTick(): Promise<void> {
106
+ const value = await this.read()
107
+ if (!value || value === this.lastValue) return
108
+ this.lastValue = value
109
+
110
+ for (const rule of [...this.rules]) {
111
+ let matched: string | null = null
112
+ try {
113
+ const m = new RegExp(rule.pattern).exec(value)
114
+ matched = m ? (m[0] ?? null) : null
115
+ } catch {
116
+ continue
117
+ }
118
+ if (matched) {
119
+ // 先解除再触发,避免目标任务再次武装造成循环
120
+ this.rules = this.rules.filter((r) => r !== rule)
121
+ this.emit('match', { rule, value: matched })
122
+ }
123
+ }
124
+ if (!this.rules.length) this.stop()
125
+ }
126
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * 配置存储 — 轻量 JSON 持久化(对应旧版 ConfigStore + electron-store 职责)
3
+ * 文件: userData/config/settings.json
4
+ */
5
+ import { EventEmitter } from 'events'
6
+ import { existsSync, readFileSync, writeFileSync } from 'fs'
7
+ import { join } from 'path'
8
+ import type { Settings } from '../../shared/types.ts'
9
+ import { ensureDir, getConfigDir } from '../utils/paths.ts'
10
+
11
+ export const DEFAULT_SETTINGS: Settings = {
12
+ system: {
13
+ autoStart: false,
14
+ powerMode: 'balanced',
15
+ idleSleepMinutes: 3
16
+ },
17
+ audio: {
18
+ enabled: true,
19
+ volume: 70
20
+ },
21
+ server: {
22
+ host: '127.0.0.1',
23
+ port: 3210
24
+ },
25
+ wechat: {
26
+ notifyOnTaskComplete: true,
27
+ notifyOnTaskFailed: true,
28
+ notifyOnStartup: false,
29
+ autoConnect: true,
30
+ allowRemoteCommand: true
31
+ },
32
+ tasks: {
33
+ breakerThreshold: 3
34
+ },
35
+ scanner: {
36
+ enabled: false,
37
+ port: '',
38
+ baud: 9600,
39
+ notifyWechat: true,
40
+ notifyDesktop: true
41
+ },
42
+ servers: [],
43
+ jenkins: {
44
+ url: '',
45
+ username: '',
46
+ password: ''
47
+ }
48
+ }
49
+
50
+ function deepMerge<T>(base: T, patch: unknown): T {
51
+ if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
52
+ return (patch === undefined ? base : (patch as T))
53
+ }
54
+ const out: Record<string, unknown> = { ...(base as Record<string, unknown>) }
55
+ for (const [k, v] of Object.entries(patch as Record<string, unknown>)) {
56
+ const baseV = (base as Record<string, unknown>)[k]
57
+ out[k] = typeof v === 'object' && v !== null && !Array.isArray(v) && baseV
58
+ ? deepMerge(baseV, v)
59
+ : v
60
+ }
61
+ return out as T
62
+ }
63
+
64
+ export class ConfigStore extends EventEmitter {
65
+ private settings: Settings
66
+ private filePath: string
67
+
68
+ constructor(filePath?: string) {
69
+ super()
70
+ this.filePath = filePath ?? join(getConfigDir(), 'settings.json')
71
+ this.settings = this.load()
72
+ }
73
+
74
+ private load(): Settings {
75
+ try {
76
+ if (existsSync(this.filePath)) {
77
+ const raw = JSON.parse(readFileSync(this.filePath, 'utf-8')) as Partial<Settings>
78
+ return deepMerge(structuredClone(DEFAULT_SETTINGS), raw)
79
+ }
80
+ } catch (err) {
81
+ console.error('[config] 读取配置失败,使用默认值:', err)
82
+ }
83
+ // 首次初始化:写入默认配置
84
+ const defaults = structuredClone(DEFAULT_SETTINGS)
85
+ try {
86
+ ensureDir(getConfigDir())
87
+ writeFileSync(this.filePath, JSON.stringify(defaults, null, 2), 'utf-8')
88
+ } catch (err) {
89
+ console.error('[config] 初始化配置写入失败:', err)
90
+ }
91
+ return defaults
92
+ }
93
+
94
+ /** 获取全部设置 */
95
+ get(): Settings
96
+ /** 获取设置项(支持点路径,如 "window.x") */
97
+ get(key: string): unknown
98
+ get(key?: string): unknown {
99
+ if (key) {
100
+ const keys = key.split('.')
101
+ let cur: unknown = this.settings
102
+ for (const k of keys) {
103
+ if (cur && typeof cur === 'object' && k in (cur as Record<string, unknown>)) {
104
+ cur = (cur as Record<string, unknown>)[k]
105
+ } else {
106
+ return undefined
107
+ }
108
+ }
109
+ return structuredClone(cur)
110
+ }
111
+ return structuredClone(this.settings)
112
+ }
113
+
114
+ set(key: string, value: unknown): void {
115
+ const keys = key.split('.')
116
+ let cur: Record<string, unknown> = this.settings as unknown as Record<string, unknown>
117
+ for (let i = 0; i < keys.length - 1; i++) {
118
+ const k = keys[i]
119
+ if (!cur[k] || typeof cur[k] !== 'object') cur[k] = {}
120
+ cur = cur[k] as Record<string, unknown>
121
+ }
122
+ cur[keys[keys.length - 1]] = value
123
+ this.save()
124
+ this.emit('changed')
125
+ }
126
+
127
+ update(patch: Partial<Settings>): void {
128
+ this.settings = deepMerge(this.settings, patch)
129
+ this.save()
130
+ this.emit('changed')
131
+ }
132
+
133
+ /** 整体替换设置(快照恢复 / 配置包导入用) */
134
+ replaceAll(settings: Settings): void {
135
+ this.settings = deepMerge(structuredClone(DEFAULT_SETTINGS), settings)
136
+ this.save()
137
+ this.emit('changed')
138
+ }
139
+
140
+ save(): void {
141
+ try {
142
+ ensureDir(getConfigDir())
143
+ writeFileSync(this.filePath, JSON.stringify(this.settings, null, 2), 'utf-8')
144
+ } catch (err) {
145
+ console.error('[config] 保存配置失败:', err)
146
+ }
147
+ }
148
+ }
149
+
150
+ export function deepMergeConfig<T>(base: T, patch: unknown): T {
151
+ return deepMerge(base, patch)
152
+ }