@ytchuan/flowlink 1.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 +176 -0
- package/app-ui/dist/assets/BaseCard-BquKu8iW.js +1 -0
- package/app-ui/dist/assets/BaseCard-vDGVmnfA.css +1 -0
- package/app-ui/dist/assets/ConsumptionView-Bv1BmzJZ.css +1 -0
- package/app-ui/dist/assets/ConsumptionView-CKpT-R7X.js +1 -0
- package/app-ui/dist/assets/HomeView-BXZYBUAv.js +1 -0
- package/app-ui/dist/assets/HomeView-C0c8S1Ss.css +1 -0
- package/app-ui/dist/assets/MetricCard-CIRjcKHO.css +1 -0
- package/app-ui/dist/assets/MetricCard-irffmzJu.js +1 -0
- package/app-ui/dist/assets/PackageView-C0KTgqoW.css +1 -0
- package/app-ui/dist/assets/PackageView-DzGcYqiI.js +1 -0
- package/app-ui/dist/assets/SettingsView-CKxlifru.js +2 -0
- package/app-ui/dist/assets/SettingsView-Co_5arPi.css +1 -0
- package/app-ui/dist/assets/StatusBadge-BeZad7-E.js +1 -0
- package/app-ui/dist/assets/StatusBadge-jZK8Ckfn.css +1 -0
- package/app-ui/dist/assets/index-C2wBeAgU.js +2269 -0
- package/app-ui/dist/assets/index-EXys6EPN.css +1 -0
- package/app-ui/dist/index.html +13 -0
- package/assets/icon.icns +0 -0
- package/assets/icon.ico +0 -0
- package/assets/logo256.png +0 -0
- package/assets/logo512.png +0 -0
- package/bin/flowlink.js +24 -0
- package/main.js +315 -0
- package/package.json +88 -0
- package/preload.js +103 -0
- package/resources/tray-icon.png +0 -0
- package/services/config-writer.js +415 -0
- package/services/device.js +104 -0
- package/services/scanner.js +254 -0
- package/services/scheduler.js +197 -0
- package/services/selfcheck.js +154 -0
- package/services/settings.js +58 -0
- package/services/tray.js +110 -0
- package/services/updater.js +26 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 客户端安装检测(WorkBuddy / CodeBuddy)
|
|
3
|
+
* 扫描本地已安装的 AI 客户端软件
|
|
4
|
+
* Windows 检测策略:精确路径 → 注册表卸载信息(支持 CodeBuddy CN 等变体)→ 配置目录兜底
|
|
5
|
+
*/
|
|
6
|
+
const fs = require('fs')
|
|
7
|
+
const path = require('path')
|
|
8
|
+
const os = require('os')
|
|
9
|
+
const { execSync } = require('child_process')
|
|
10
|
+
|
|
11
|
+
/** 检查路径是否存在 */
|
|
12
|
+
function checkPathExists(filePath) {
|
|
13
|
+
try {
|
|
14
|
+
return fs.existsSync(filePath)
|
|
15
|
+
} catch {
|
|
16
|
+
return false
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 在指定目录下查找名称匹配的子项(模糊匹配,不区分大小写)
|
|
22
|
+
* @param {string} dir - 搜索目录
|
|
23
|
+
* @param {string} keyword - 匹配关键词
|
|
24
|
+
* @returns {string|null} 匹配到的完整路径
|
|
25
|
+
*/
|
|
26
|
+
function findMatchingEntry(dir, keyword) {
|
|
27
|
+
try {
|
|
28
|
+
if (!fs.existsSync(dir)) return null
|
|
29
|
+
const entries = fs.readdirSync(dir)
|
|
30
|
+
const lowerKeyword = keyword.toLowerCase()
|
|
31
|
+
for (const entry of entries) {
|
|
32
|
+
if (entry.toLowerCase().includes(lowerKeyword)) {
|
|
33
|
+
return path.join(dir, entry)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
// 目录读取失败
|
|
38
|
+
}
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 在 VS Code / Cursor 扩展目录中查找 CodeBuddy
|
|
44
|
+
* 扩展目录名格式:publisher.extension-name-version
|
|
45
|
+
*/
|
|
46
|
+
function findCodeBuddyExtension() {
|
|
47
|
+
const home = os.homedir()
|
|
48
|
+
const extDirs = [
|
|
49
|
+
path.join(home, '.vscode', 'extensions'),
|
|
50
|
+
path.join(home, '.cursor', 'extensions'),
|
|
51
|
+
path.join(home, '.vscode-insiders', 'extensions'),
|
|
52
|
+
path.join(home, '.windsurf', 'extensions'),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
// CodeBuddy 扩展可能的关键词
|
|
56
|
+
const keywords = ['codebuddy', 'code-buddy', 'tencent-cloud-ai']
|
|
57
|
+
|
|
58
|
+
for (const extDir of extDirs) {
|
|
59
|
+
for (const kw of keywords) {
|
|
60
|
+
const found = findMatchingEntry(extDir, kw)
|
|
61
|
+
if (found) return found
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 在 JetBrains 插件目录中查找 CodeBuddy
|
|
69
|
+
* 各平台 JetBrains 配置目录:
|
|
70
|
+
* - macOS: ~/Library/Application Support/JetBrains
|
|
71
|
+
* - Windows: %APPDATA%/JetBrains
|
|
72
|
+
* - Linux: ~/.local/share/JetBrains
|
|
73
|
+
*/
|
|
74
|
+
function findCodeBuddyJetBrains() {
|
|
75
|
+
const home = os.homedir()
|
|
76
|
+
const jbBaseByPlatform = {
|
|
77
|
+
darwin: path.join(home, 'Library', 'Application Support', 'JetBrains'),
|
|
78
|
+
win32: path.join(home, 'AppData', 'Roaming', 'JetBrains'),
|
|
79
|
+
linux: path.join(home, '.local', 'share', 'JetBrains'),
|
|
80
|
+
}
|
|
81
|
+
const jbBase = jbBaseByPlatform[process.platform]
|
|
82
|
+
if (!jbBase) return null
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
if (!fs.existsSync(jbBase)) return null
|
|
86
|
+
const ideDirs = fs.readdirSync(jbBase)
|
|
87
|
+
for (const ideDir of ideDirs) {
|
|
88
|
+
const pluginsDir = path.join(jbBase, ideDir, 'plugins')
|
|
89
|
+
const found = findMatchingEntry(pluginsDir, 'codebuddy')
|
|
90
|
+
if (found) return found
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// 目录读取失败
|
|
94
|
+
}
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 在 Windows 注册表卸载信息中查找已安装程序
|
|
100
|
+
* 注意:必须用 `reg query ... /s` 查询全部值;若带 `/v DisplayName` 则只返回 DisplayName,拿不到 InstallLocation
|
|
101
|
+
* @param {RegExp} namePattern - DisplayName 匹配正则
|
|
102
|
+
* @returns {string|null} 匹配到的 InstallLocation(缺失时返回 DisplayName),未找到返回 null
|
|
103
|
+
*/
|
|
104
|
+
function findInWindowsUninstall(namePattern) {
|
|
105
|
+
if (process.platform !== 'win32') return null
|
|
106
|
+
|
|
107
|
+
const regPaths = [
|
|
108
|
+
'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
|
109
|
+
'HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
|
110
|
+
'HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
for (const regPath of regPaths) {
|
|
114
|
+
let output
|
|
115
|
+
try {
|
|
116
|
+
output = execSync(`reg query "${regPath}" /s 2>nul`, {
|
|
117
|
+
encoding: 'utf-8',
|
|
118
|
+
timeout: 8000,
|
|
119
|
+
})
|
|
120
|
+
} catch {
|
|
121
|
+
// 该注册表分支不存在或查询失败
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 每个子键块以 HKEY_ 开头
|
|
126
|
+
const blocks = output.split(/(?=HKEY_)/)
|
|
127
|
+
for (const block of blocks) {
|
|
128
|
+
const nameMatch = block.match(/DisplayName\s+REG_SZ\s+(.+)/)
|
|
129
|
+
if (!nameMatch) continue
|
|
130
|
+
const displayName = nameMatch[1].trim()
|
|
131
|
+
if (!namePattern.test(displayName)) continue
|
|
132
|
+
|
|
133
|
+
const locMatch = block.match(/InstallLocation\s+REG_SZ\s+(.+)/)
|
|
134
|
+
// 去除尾部反斜杠,避免拼接出双反斜杠路径
|
|
135
|
+
const installLocation = locMatch
|
|
136
|
+
? locMatch[1].trim().replace(/[\\/]+$/, '')
|
|
137
|
+
: ''
|
|
138
|
+
return installLocation || displayName
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 客户端定义 */
|
|
145
|
+
const CLIENT_TARGETS = [
|
|
146
|
+
{
|
|
147
|
+
key: 'workbuddy',
|
|
148
|
+
name: 'WorkBuddy',
|
|
149
|
+
description: '腾讯代码助手桌面版',
|
|
150
|
+
macOSPaths: [
|
|
151
|
+
'/Applications/WorkBuddy.app',
|
|
152
|
+
'/Applications/腾讯代码助手.app',
|
|
153
|
+
'/Applications/腾讯云AI代码助手.app',
|
|
154
|
+
path.join(os.homedir(), 'Applications/WorkBuddy.app'),
|
|
155
|
+
],
|
|
156
|
+
win32Paths: [
|
|
157
|
+
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'WorkBuddy'),
|
|
158
|
+
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'WorkBuddy'),
|
|
159
|
+
path.join(process.env.LOCALAPPDATA || '', 'WorkBuddy'),
|
|
160
|
+
// Electron 应用默认用户级安装位置
|
|
161
|
+
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'WorkBuddy'),
|
|
162
|
+
],
|
|
163
|
+
// Windows 注册表卸载信息中的 DisplayName 匹配规则
|
|
164
|
+
win32NamePattern: /^WorkBuddy/i,
|
|
165
|
+
// 配置目录兜底:客户端运行过至少一次即会创建
|
|
166
|
+
configDir: path.join(os.homedir(), '.workbuddy'),
|
|
167
|
+
configPath: path.join(os.homedir(), '.workbuddy', 'models.json'),
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
key: 'codebuddy',
|
|
171
|
+
name: 'CodeBuddy',
|
|
172
|
+
description: '腾讯云 AI IDE 插件',
|
|
173
|
+
macOSPaths: [
|
|
174
|
+
'/Applications/CodeBuddy.app',
|
|
175
|
+
path.join(os.homedir(), 'Applications/CodeBuddy.app'),
|
|
176
|
+
],
|
|
177
|
+
win32Paths: [
|
|
178
|
+
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'CodeBuddy'),
|
|
179
|
+
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'CodeBuddy'),
|
|
180
|
+
path.join(process.env.LOCALAPPDATA || '', 'CodeBuddy'),
|
|
181
|
+
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'CodeBuddy'),
|
|
182
|
+
// 中国区独立安装包,目录名带 CN 后缀(注册表 DisplayName 为 "CodeBuddy CN (User)")
|
|
183
|
+
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'CodeBuddy CN'),
|
|
184
|
+
path.join(process.env.LOCALAPPDATA || '', 'CodeBuddy CN'),
|
|
185
|
+
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'CodeBuddy CN'),
|
|
186
|
+
],
|
|
187
|
+
win32NamePattern: /^CodeBuddy/i,
|
|
188
|
+
configDir: path.join(os.homedir(), '.codebuddy'),
|
|
189
|
+
configPath: path.join(os.homedir(), '.codebuddy', 'models.json'),
|
|
190
|
+
},
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 扫描已安装的客户端
|
|
195
|
+
* @returns {Array<{ key: string, name: string, description: string, installed: boolean, configPath?: string }>}
|
|
196
|
+
*/
|
|
197
|
+
function scan() {
|
|
198
|
+
const platform = process.platform
|
|
199
|
+
const pathKey = platform === 'darwin' ? 'macOSPaths' : 'win32Paths'
|
|
200
|
+
|
|
201
|
+
return CLIENT_TARGETS.map((target) => {
|
|
202
|
+
// 1. 精确路径匹配
|
|
203
|
+
const paths = target[pathKey] || []
|
|
204
|
+
let installed = paths.some(checkPathExists)
|
|
205
|
+
let foundPath = paths.find(checkPathExists)
|
|
206
|
+
|
|
207
|
+
// 2. Windows 注册表卸载信息匹配(覆盖 CodeBuddy CN 等非默认目录安装)
|
|
208
|
+
if (!installed && platform === 'win32' && target.win32NamePattern) {
|
|
209
|
+
foundPath = findInWindowsUninstall(target.win32NamePattern)
|
|
210
|
+
if (foundPath) {
|
|
211
|
+
installed = true
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 3. CodeBuddy 特殊处理:在 IDE 扩展/插件目录中模糊匹配
|
|
216
|
+
if (!installed && target.key === 'codebuddy') {
|
|
217
|
+
// VS Code / Cursor 扩展
|
|
218
|
+
foundPath = findCodeBuddyExtension()
|
|
219
|
+
if (foundPath) {
|
|
220
|
+
installed = true
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// JetBrains 插件
|
|
224
|
+
if (!installed) {
|
|
225
|
+
foundPath = findCodeBuddyJetBrains()
|
|
226
|
+
if (foundPath) {
|
|
227
|
+
installed = true
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 4. 配置目录兜底:客户端已运行过至少一次会创建配置目录(如 ~/.codebuddy)
|
|
233
|
+
if (!installed && target.configDir && checkPathExists(target.configDir)) {
|
|
234
|
+
foundPath = target.configDir
|
|
235
|
+
installed = true
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
key: target.key,
|
|
240
|
+
name: target.name,
|
|
241
|
+
description: target.description,
|
|
242
|
+
installed,
|
|
243
|
+
configPath: installed ? foundPath : undefined,
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** 获取客户端配置文件路径 */
|
|
249
|
+
function getConfigPath(key) {
|
|
250
|
+
const target = CLIENT_TARGETS.find((t) => t.key === key)
|
|
251
|
+
return target ? target.configPath : null
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
module.exports = { scan, getConfigPath, CLIENT_TARGETS }
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地调度服务(端口 51173 代理,占用时依次回退 51172、51171)
|
|
3
|
+
* 启动/停止本地 HTTP 代理服务,将 AI 请求原样转发到后端网关
|
|
4
|
+
*/
|
|
5
|
+
const http = require('http')
|
|
6
|
+
const https = require('https')
|
|
7
|
+
const { URL } = require('url')
|
|
8
|
+
const { EventEmitter } = require('events')
|
|
9
|
+
|
|
10
|
+
/** 调度服务默认端口列表 */
|
|
11
|
+
const SCHEDULER_PORTS = [51173, 51172, 51171]
|
|
12
|
+
|
|
13
|
+
/** 默认后端网关地址(支持通过环境变量注入,便于打包时适配不同环境) */
|
|
14
|
+
const DEFAULT_GATEWAY_URL = 'http://localhost:9000'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 尝试在指定端口上启动 HTTP 服务
|
|
18
|
+
* @param {http.Server} server
|
|
19
|
+
* @param {number} port
|
|
20
|
+
* @returns {Promise<void>}
|
|
21
|
+
*/
|
|
22
|
+
function tryListen(server, port) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const onError = (err) => {
|
|
25
|
+
server.off('error', onError)
|
|
26
|
+
reject(err)
|
|
27
|
+
}
|
|
28
|
+
server.on('error', onError)
|
|
29
|
+
server.listen(port, '127.0.0.1', () => {
|
|
30
|
+
server.off('error', onError)
|
|
31
|
+
resolve()
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
class SchedulerService extends EventEmitter {
|
|
37
|
+
constructor() {
|
|
38
|
+
super()
|
|
39
|
+
this.server = null
|
|
40
|
+
this.running = false
|
|
41
|
+
this.port = SCHEDULER_PORTS[0]
|
|
42
|
+
this.gatewayUrl = DEFAULT_GATEWAY_URL
|
|
43
|
+
this.config = {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 启动调度服务
|
|
48
|
+
* @param {{ apiKey?: string, deviceId?: string, gatewayUrl?: string }} config
|
|
49
|
+
* @returns {{ success: boolean, message: string }}
|
|
50
|
+
*/
|
|
51
|
+
async start(config = {}) {
|
|
52
|
+
this.config = config
|
|
53
|
+
if (config.gatewayUrl) {
|
|
54
|
+
this.gatewayUrl = config.gatewayUrl // 远程获取的配置覆盖本地
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (this.running) {
|
|
58
|
+
return { success: true, message: '调度服务已在运行中' }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
this.server = http.createServer((req, res) => {
|
|
62
|
+
this.handleRequest(req, res)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
for (const port of SCHEDULER_PORTS) {
|
|
66
|
+
try {
|
|
67
|
+
await tryListen(this.server, port)
|
|
68
|
+
this.port = port
|
|
69
|
+
this.running = true
|
|
70
|
+
this.emit('status-changed', { running: true, port: this.port })
|
|
71
|
+
console.log(`[Scheduler] 已启动 http://127.0.0.1:${this.port} → ${this.gatewayUrl}`)
|
|
72
|
+
return { success: true, message: '调度服务已启动' }
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (err.code !== 'EADDRINUSE') {
|
|
75
|
+
return { success: false, message: `启动失败:${err.message}` }
|
|
76
|
+
}
|
|
77
|
+
console.warn(`[Scheduler] 端口 ${port} 已被占用,尝试下一个端口`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
this.server = null
|
|
82
|
+
return { success: false, message: '端口被占用' }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 停止调度服务
|
|
87
|
+
* @returns {{ success: boolean, message: string }}
|
|
88
|
+
*/
|
|
89
|
+
async stop() {
|
|
90
|
+
if (!this.running || !this.server) {
|
|
91
|
+
this.running = false
|
|
92
|
+
return { success: true, message: '调度服务已停止' }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
await new Promise((resolve) => {
|
|
97
|
+
this.server.close(() => resolve())
|
|
98
|
+
})
|
|
99
|
+
this.server = null
|
|
100
|
+
this.running = false
|
|
101
|
+
this.emit('status-changed', { running: false, port: this.port })
|
|
102
|
+
return { success: true, message: '调度服务已停止' }
|
|
103
|
+
} catch (err) {
|
|
104
|
+
return { success: false, message: `停止失败:${err.message}` }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 获取服务状态
|
|
110
|
+
* @returns {{ running: boolean, port: number }}
|
|
111
|
+
*/
|
|
112
|
+
getStatus() {
|
|
113
|
+
return { running: this.running, port: this.port }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 处理请求:健康检查本地响应,其余请求原样转发到后端网关
|
|
118
|
+
* 转发时透传请求头/请求体/响应流(支持 SSE 流式响应)
|
|
119
|
+
*/
|
|
120
|
+
handleRequest(req, res) {
|
|
121
|
+
// CORS 头
|
|
122
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
123
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
124
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
|
125
|
+
|
|
126
|
+
if (req.method === 'OPTIONS') {
|
|
127
|
+
res.writeHead(204)
|
|
128
|
+
res.end()
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 健康检查端点
|
|
133
|
+
if (req.url === '/health' && req.method === 'GET') {
|
|
134
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
135
|
+
res.end(JSON.stringify({ status: 'ok', running: true }))
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 代理转发到后端网关
|
|
140
|
+
let target
|
|
141
|
+
try {
|
|
142
|
+
target = new URL(this.gatewayUrl)
|
|
143
|
+
} catch {
|
|
144
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
145
|
+
res.end(JSON.stringify({ error: 'INVALID_GATEWAY', message: '后端网关地址无效' }))
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const isHttps = target.protocol === 'https:'
|
|
150
|
+
const client = isHttps ? https : http
|
|
151
|
+
|
|
152
|
+
// 透传请求头,重写 host
|
|
153
|
+
const headers = { ...req.headers }
|
|
154
|
+
headers.host = target.host
|
|
155
|
+
|
|
156
|
+
const proxyReq = client.request(
|
|
157
|
+
{
|
|
158
|
+
hostname: target.hostname,
|
|
159
|
+
port: target.port || (isHttps ? 443 : 80),
|
|
160
|
+
path: req.url,
|
|
161
|
+
method: req.method,
|
|
162
|
+
headers,
|
|
163
|
+
},
|
|
164
|
+
(proxyRes) => {
|
|
165
|
+
// 透传响应头与状态码(流式响应直接管道输出)
|
|
166
|
+
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers)
|
|
167
|
+
proxyRes.pipe(res)
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
proxyReq.on('error', (err) => {
|
|
172
|
+
console.error('[Scheduler] 转发失败:', err.message)
|
|
173
|
+
if (!res.headersSent) {
|
|
174
|
+
res.writeHead(502, { 'Content-Type': 'application/json' })
|
|
175
|
+
res.end(JSON.stringify({
|
|
176
|
+
error: 'GATEWAY_UNREACHABLE',
|
|
177
|
+
message: `无法连接后端网关 ${this.gatewayUrl}:${err.message}`,
|
|
178
|
+
}))
|
|
179
|
+
} else {
|
|
180
|
+
res.end()
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// 客户端中断时终止上游请求
|
|
185
|
+
req.on('aborted', () => {
|
|
186
|
+
proxyReq.destroy()
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// 透传请求体
|
|
190
|
+
req.pipe(proxyReq)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 单例
|
|
195
|
+
const schedulerService = new SchedulerService()
|
|
196
|
+
|
|
197
|
+
module.exports = schedulerService
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 环境自检
|
|
3
|
+
* 诊断服务连通性问题,检查配置完整性
|
|
4
|
+
*/
|
|
5
|
+
const http = require('http')
|
|
6
|
+
const net = require('net')
|
|
7
|
+
const scheduler = require('./scheduler')
|
|
8
|
+
const scanner = require('./scanner')
|
|
9
|
+
const settings = require('./settings')
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 检查端口连通性
|
|
13
|
+
* @param {number} port
|
|
14
|
+
* @param {string} host
|
|
15
|
+
* @returns {Promise<boolean>}
|
|
16
|
+
*/
|
|
17
|
+
function checkPort(port, host = '127.0.0.1') {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
const socket = new net.Socket()
|
|
20
|
+
socket.setTimeout(2000)
|
|
21
|
+
socket.on('connect', () => {
|
|
22
|
+
socket.destroy()
|
|
23
|
+
resolve(true)
|
|
24
|
+
})
|
|
25
|
+
socket.on('timeout', () => {
|
|
26
|
+
socket.destroy()
|
|
27
|
+
resolve(false)
|
|
28
|
+
})
|
|
29
|
+
socket.on('error', () => {
|
|
30
|
+
resolve(false)
|
|
31
|
+
})
|
|
32
|
+
socket.connect(port, host)
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 检查远程网关连通性
|
|
38
|
+
* @returns {Promise<boolean>}
|
|
39
|
+
*/
|
|
40
|
+
async function checkRemoteGateway(port) {
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
const req = http.get(
|
|
43
|
+
{hostname: '127.0.0.1', port, path: '/health', timeout: 3000},
|
|
44
|
+
(res) => {
|
|
45
|
+
resolve(res.statusCode === 200)
|
|
46
|
+
res.resume()
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
req.on('error', () => resolve(false))
|
|
50
|
+
req.on('timeout', () => {
|
|
51
|
+
req.destroy()
|
|
52
|
+
resolve(false)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 运行环境自检
|
|
59
|
+
* @param {{ deviceId: string, activated?: boolean, packageCount?: number, balance?: number, apiKey?: string }} context
|
|
60
|
+
* @returns {Promise<Array<{ item: string, status: 'pass'|'fail'|'warning', message: string }>>}
|
|
61
|
+
*/
|
|
62
|
+
async function runSelfCheck(context = {}) {
|
|
63
|
+
const results = []
|
|
64
|
+
|
|
65
|
+
// 1. 本地端口连通性
|
|
66
|
+
const schedulerStatus = scheduler.getStatus()
|
|
67
|
+
const port = schedulerStatus.port
|
|
68
|
+
const portAvailable = await checkPort(port)
|
|
69
|
+
results.push({
|
|
70
|
+
item: `本地端口连通性 (127.0.0.1:${port})`,
|
|
71
|
+
status: schedulerStatus.running && portAvailable ? 'pass' : 'warning',
|
|
72
|
+
message: schedulerStatus.running && portAvailable
|
|
73
|
+
? `端口 ${port} 可访问,调度服务运行中`
|
|
74
|
+
: `调度服务未运行,端口 ${port} 不可访问`,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// 2. 远程网关连通性(通过本地代理检查)
|
|
78
|
+
if (schedulerStatus.running) {
|
|
79
|
+
const gatewayOk = await checkRemoteGateway(port)
|
|
80
|
+
results.push({
|
|
81
|
+
item: '远程网关连通性',
|
|
82
|
+
status: gatewayOk ? 'pass' : 'warning',
|
|
83
|
+
message: gatewayOk ? '网关响应正常' : '网关响应异常,请检查网络',
|
|
84
|
+
})
|
|
85
|
+
} else {
|
|
86
|
+
results.push({
|
|
87
|
+
item: '远程网关连通性',
|
|
88
|
+
status: 'warning',
|
|
89
|
+
message: '调度服务未运行,跳过网关检查',
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 3. 设备标识有效性
|
|
94
|
+
const hasDeviceId = !!context.deviceId && context.deviceId.startsWith('ST-')
|
|
95
|
+
results.push({
|
|
96
|
+
item: '设备标识有效性',
|
|
97
|
+
status: hasDeviceId ? 'pass' : 'fail',
|
|
98
|
+
message: hasDeviceId
|
|
99
|
+
? `设备标识正常:${context.deviceId.slice(0, 16)}...`
|
|
100
|
+
: '设备标识缺失或格式错误',
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// 4. 积分余额检查(以真实账户数据为准)
|
|
104
|
+
const packageCount = Number(context.packageCount) || 0
|
|
105
|
+
const balance = Number(context.balance) || 0
|
|
106
|
+
const hasPackage = context.activated || packageCount > 0 || balance > 0
|
|
107
|
+
results.push({
|
|
108
|
+
item: '积分余额检查',
|
|
109
|
+
status: hasPackage ? 'pass' : 'fail',
|
|
110
|
+
message: hasPackage
|
|
111
|
+
? `已激活积分包,剩余 ${balance} 积分(共 ${packageCount} 个积分包)`
|
|
112
|
+
: '未激活积分包,请先激活',
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
// 5. 客户端配置完整性
|
|
116
|
+
const clients = scanner.scan()
|
|
117
|
+
const installedClients = clients.filter((c) => c.installed)
|
|
118
|
+
if (installedClients.length === 0) {
|
|
119
|
+
results.push({
|
|
120
|
+
item: '客户端配置完整性',
|
|
121
|
+
status: 'warning',
|
|
122
|
+
message: '未检测到已安装的客户端',
|
|
123
|
+
})
|
|
124
|
+
} else {
|
|
125
|
+
const fs = require('fs')
|
|
126
|
+
const configuredCount = installedClients.filter((c) => {
|
|
127
|
+
const configPath = scanner.getConfigPath(c.key)
|
|
128
|
+
return configPath && fs.existsSync(configPath)
|
|
129
|
+
}).length
|
|
130
|
+
|
|
131
|
+
results.push({
|
|
132
|
+
item: '客户端配置完整性',
|
|
133
|
+
status: configuredCount > 0 ? 'pass' : 'warning',
|
|
134
|
+
message:
|
|
135
|
+
configuredCount > 0
|
|
136
|
+
? `已配置 ${configuredCount}/${installedClients.length} 个客户端`
|
|
137
|
+
: `检测到 ${installedClients.length} 个客户端,但尚未配置`,
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 6. API Key 有效性(格式:SK-UUID)
|
|
142
|
+
const hasApiKey = !!context.apiKey && context.apiKey.startsWith('SK-')
|
|
143
|
+
results.push({
|
|
144
|
+
item: 'API Key 有效性',
|
|
145
|
+
status: hasApiKey ? 'pass' : 'warning',
|
|
146
|
+
message: hasApiKey
|
|
147
|
+
? 'API Key 格式正确'
|
|
148
|
+
: 'API Key 缺失或格式错误,请重新生成',
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
return results
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = {runSelfCheck}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 应用设置读写与持久化
|
|
3
|
+
* 存储到 userData/settings.json
|
|
4
|
+
*/
|
|
5
|
+
const { app } = require('electron')
|
|
6
|
+
const fs = require('fs')
|
|
7
|
+
const path = require('path')
|
|
8
|
+
|
|
9
|
+
/** 默认设置 */
|
|
10
|
+
const DEFAULT_SETTINGS = {
|
|
11
|
+
theme: 'light',
|
|
12
|
+
closeBehavior: 'quit',
|
|
13
|
+
autoStart: false,
|
|
14
|
+
autoSchedule: false,
|
|
15
|
+
backupBeforeConfig: true,
|
|
16
|
+
/** 后端网关地址(本地 51173 端点的转发目标) */
|
|
17
|
+
gatewayUrl: 'http://localhost:9000',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 设置文件路径 */
|
|
21
|
+
function getSettingsFilePath() {
|
|
22
|
+
return path.join(app.getPath('userData'), 'settings.json')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 读取设置
|
|
27
|
+
* @returns {typeof DEFAULT_SETTINGS}
|
|
28
|
+
*/
|
|
29
|
+
function getSettings() {
|
|
30
|
+
const filePath = getSettingsFilePath()
|
|
31
|
+
try {
|
|
32
|
+
if (fs.existsSync(filePath)) {
|
|
33
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
|
34
|
+
return { ...DEFAULT_SETTINGS, ...data }
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
// 文件损坏,返回默认值
|
|
38
|
+
}
|
|
39
|
+
return { ...DEFAULT_SETTINGS }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 保存设置(合并写入)
|
|
44
|
+
* @param {Partial<typeof DEFAULT_SETTINGS>} partial
|
|
45
|
+
*/
|
|
46
|
+
function saveSettings(partial) {
|
|
47
|
+
const current = getSettings()
|
|
48
|
+
const merged = { ...current, ...partial }
|
|
49
|
+
const filePath = getSettingsFilePath()
|
|
50
|
+
try {
|
|
51
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
52
|
+
fs.writeFileSync(filePath, JSON.stringify(merged, null, 2), 'utf-8')
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.error('保存设置失败:', err)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { getSettings, saveSettings, DEFAULT_SETTINGS }
|