@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,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
|
+
}
|