codex-endpoint-switcher 1.6.1 → 1.6.3
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 +4 -1
- package/package.json +1 -1
- package/src/main/auto-update-runner.js +81 -18
- package/src/main/cloud-sync-client.js +195 -4
- package/src/main/profile-manager.js +51 -11
- package/src/main/update-service.js +74 -2
- package/src/renderer/renderer.js +8 -2
- package/src/web/cloud-sync-server.js +197 -46
- package/src/web/launcher.js +129 -18
package/README.md
CHANGED
|
@@ -68,6 +68,7 @@ codex-switcher remove-access
|
|
|
68
68
|
- `sync-server`:启动账号同步服务,服务端使用 SQLite 单文件数据库
|
|
69
69
|
- `install-access`:创建桌面快捷方式和开机启动项
|
|
70
70
|
- `remove-access`:移除桌面快捷方式和开机启动项
|
|
71
|
+
- 启动器现在会优先记录并复用自己的 PID 文件,关闭和重启比之前更稳
|
|
71
72
|
|
|
72
73
|
## 网页地址
|
|
73
74
|
|
|
@@ -142,6 +143,8 @@ codex-switcher sync-server
|
|
|
142
143
|
|
|
143
144
|
- 拉取前会自动备份本机 `~/.codex/endpoint-presets.json` 和 `~/.codex/endpoint-switcher-state.json`
|
|
144
145
|
- 服务端数据库是 SQLite,部署只需要一个 Node 进程和一个 `.db` 文件
|
|
146
|
+
- `/api/auth/register` 和 `/api/auth/login` 已内置限流与失败冻结,默认会在连续错误后短时封禁
|
|
147
|
+
- Windows 客户端会优先用 DPAPI 加密本地同步令牌,不再把登录 token 明文写进 `~/.codex/cloud-sync-config.json`
|
|
145
148
|
|
|
146
149
|
## 版本更新提示
|
|
147
150
|
|
|
@@ -149,7 +152,7 @@ codex-switcher sync-server
|
|
|
149
152
|
- 如果你发布了新版本,其他用户打开控制台后会看到“发现新版本”的提示
|
|
150
153
|
- 用户可以直接点 `立即更新`,程序会自动关闭、更新并重新打开
|
|
151
154
|
- 也可以继续使用“复制升级命令”手动更新
|
|
152
|
-
-
|
|
155
|
+
- 自动更新成功记录只会提示一次,失败记录会保留,方便排查问题
|
|
153
156
|
|
|
154
157
|
也可以用 CLI 手动检查:
|
|
155
158
|
|
package/package.json
CHANGED
|
@@ -3,6 +3,8 @@ const { spawn } = require("node:child_process");
|
|
|
3
3
|
|
|
4
4
|
const WAIT_PARENT_EXIT_TIMEOUT_MS = 20000;
|
|
5
5
|
const WAIT_PARENT_POLL_MS = 400;
|
|
6
|
+
const INSTALL_RETRY_TIMES = 3;
|
|
7
|
+
const INSTALL_RETRY_BASE_DELAY_MS = 1500;
|
|
6
8
|
|
|
7
9
|
function sleep(timeoutMs) {
|
|
8
10
|
return new Promise((resolve) => {
|
|
@@ -38,9 +40,10 @@ function runProcess(command, args, options = {}) {
|
|
|
38
40
|
stderr += chunk.toString("utf8");
|
|
39
41
|
});
|
|
40
42
|
|
|
41
|
-
child.on("exit", (code) => {
|
|
43
|
+
child.on("exit", (code, signal) => {
|
|
42
44
|
resolve({
|
|
43
|
-
code:
|
|
45
|
+
code: normalizeExitCode(code),
|
|
46
|
+
signal: signal || "",
|
|
44
47
|
stdout,
|
|
45
48
|
stderr,
|
|
46
49
|
});
|
|
@@ -56,6 +59,78 @@ function runProcess(command, args, options = {}) {
|
|
|
56
59
|
});
|
|
57
60
|
}
|
|
58
61
|
|
|
62
|
+
function normalizeExitCode(code) {
|
|
63
|
+
const safeCode = Number(code || 0);
|
|
64
|
+
if (!Number.isFinite(safeCode)) {
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (safeCode > 0x7fffffff) {
|
|
69
|
+
return safeCode - 0x100000000;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return safeCode;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isBusyInstallFailure(result) {
|
|
76
|
+
const combined = `${result.stdout || ""}\n${result.stderr || ""}`.toUpperCase();
|
|
77
|
+
return (
|
|
78
|
+
result.code === -4082 ||
|
|
79
|
+
combined.includes("EBUSY") ||
|
|
80
|
+
combined.includes("RESOURCE BUSY OR LOCKED") ||
|
|
81
|
+
combined.includes("EPERM") ||
|
|
82
|
+
combined.includes("OPERATION NOT PERMITTED")
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function installLatestPackage(payload) {
|
|
87
|
+
let lastResult = null;
|
|
88
|
+
|
|
89
|
+
for (let attempt = 1; attempt <= INSTALL_RETRY_TIMES; attempt += 1) {
|
|
90
|
+
if (attempt > 1) {
|
|
91
|
+
await appendLog(
|
|
92
|
+
payload.logPath,
|
|
93
|
+
`[${new Date().toISOString()}] 检测到文件占用,开始第 ${attempt} 次重试安装。\n`,
|
|
94
|
+
);
|
|
95
|
+
await sleep(INSTALL_RETRY_BASE_DELAY_MS * attempt);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const result = await runProcess(
|
|
99
|
+
payload.nodePath,
|
|
100
|
+
[payload.npmCliPath, "install", "-g", `${payload.packageName}@latest`],
|
|
101
|
+
{
|
|
102
|
+
cwd: process.cwd(),
|
|
103
|
+
env: process.env,
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
if (result.stdout) {
|
|
108
|
+
await appendLog(payload.logPath, result.stdout);
|
|
109
|
+
}
|
|
110
|
+
if (result.stderr) {
|
|
111
|
+
await appendLog(payload.logPath, result.stderr);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
lastResult = result;
|
|
115
|
+
if (result.code === 0) {
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!isBusyInstallFailure(result) || attempt >= INSTALL_RETRY_TIMES) {
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
lastResult || {
|
|
126
|
+
code: 1,
|
|
127
|
+
signal: "",
|
|
128
|
+
stdout: "",
|
|
129
|
+
stderr: "自动更新安装没有返回结果。",
|
|
130
|
+
}
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
59
134
|
function isProcessAlive(pid) {
|
|
60
135
|
try {
|
|
61
136
|
process.kill(pid, 0);
|
|
@@ -145,25 +220,13 @@ async function main() {
|
|
|
145
220
|
await sleep(Number(payload.waitSeconds || 3) * 1000);
|
|
146
221
|
await waitForProcessExit(Number(payload.parentPid || 0), payload.logPath);
|
|
147
222
|
|
|
148
|
-
const installResult = await
|
|
149
|
-
payload.nodePath,
|
|
150
|
-
[payload.npmCliPath, "install", "-g", `${payload.packageName}@latest`],
|
|
151
|
-
{
|
|
152
|
-
cwd: process.cwd(),
|
|
153
|
-
env: process.env,
|
|
154
|
-
},
|
|
155
|
-
);
|
|
156
|
-
|
|
157
|
-
if (installResult.stdout) {
|
|
158
|
-
await appendLog(payload.logPath, installResult.stdout);
|
|
159
|
-
}
|
|
160
|
-
if (installResult.stderr) {
|
|
161
|
-
await appendLog(payload.logPath, installResult.stderr);
|
|
162
|
-
}
|
|
223
|
+
const installResult = await installLatestPackage(payload);
|
|
163
224
|
|
|
164
225
|
if (installResult.code !== 0) {
|
|
165
226
|
const finishedAt = new Date().toISOString();
|
|
166
|
-
const errorMessage =
|
|
227
|
+
const errorMessage = isBusyInstallFailure(installResult)
|
|
228
|
+
? `npm 安装失败:文件仍被占用,请稍后重试或先手动关闭占用进程。退出码:${installResult.code}`
|
|
229
|
+
: `npm 安装失败,退出码:${installResult.code}`;
|
|
167
230
|
await appendLog(payload.logPath, `[${finishedAt}] ${errorMessage}\n`);
|
|
168
231
|
await writeState(payload.statePath, {
|
|
169
232
|
status: "failed",
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
const fs = require("node:fs/promises");
|
|
2
2
|
const path = require("node:path");
|
|
3
3
|
const os = require("node:os");
|
|
4
|
+
const crypto = require("node:crypto");
|
|
5
|
+
const { execFile } = require("node:child_process");
|
|
6
|
+
const { promisify } = require("node:util");
|
|
4
7
|
const profileManager = require("./profile-manager");
|
|
5
8
|
|
|
6
9
|
const codexRoot = path.join(os.homedir(), ".codex");
|
|
7
10
|
const cloudSyncConfigPath = path.join(codexRoot, "cloud-sync-config.json");
|
|
11
|
+
const cloudSyncKeyPath = path.join(codexRoot, "cloud-sync.key");
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
8
13
|
|
|
9
14
|
function normalizeServerUrl(value) {
|
|
10
15
|
const raw = String(value || "").trim();
|
|
@@ -80,6 +85,8 @@ async function ensureStorage() {
|
|
|
80
85
|
serverUrl: getDefaultServerUrl(),
|
|
81
86
|
username: "",
|
|
82
87
|
authToken: "",
|
|
88
|
+
authTokenProtection: "",
|
|
89
|
+
authTokenProtected: "",
|
|
83
90
|
tokenExpiresAt: "",
|
|
84
91
|
lastPushAt: "",
|
|
85
92
|
lastPullAt: "",
|
|
@@ -92,15 +99,177 @@ async function ensureStorage() {
|
|
|
92
99
|
}
|
|
93
100
|
}
|
|
94
101
|
|
|
102
|
+
async function runPowerShell(command, env = {}) {
|
|
103
|
+
const result = await execFileAsync(
|
|
104
|
+
"powershell.exe",
|
|
105
|
+
[
|
|
106
|
+
"-NoProfile",
|
|
107
|
+
"-NonInteractive",
|
|
108
|
+
"-ExecutionPolicy",
|
|
109
|
+
"Bypass",
|
|
110
|
+
"-Command",
|
|
111
|
+
command,
|
|
112
|
+
],
|
|
113
|
+
{
|
|
114
|
+
windowsHide: true,
|
|
115
|
+
env: {
|
|
116
|
+
...process.env,
|
|
117
|
+
...env,
|
|
118
|
+
},
|
|
119
|
+
maxBuffer: 1024 * 1024,
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
return String(result.stdout || "").trim();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function protectWithWindowsDpapi(plainText) {
|
|
127
|
+
const payload = Buffer.from(String(plainText || ""), "utf8").toString("base64");
|
|
128
|
+
return runPowerShell(
|
|
129
|
+
[
|
|
130
|
+
"Add-Type -AssemblyName System.Security",
|
|
131
|
+
"$plainBytes = [Convert]::FromBase64String($env:CODEX_SWITCHER_PLAIN_BASE64)",
|
|
132
|
+
"$cipherBytes = [System.Security.Cryptography.ProtectedData]::Protect($plainBytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)",
|
|
133
|
+
"[Convert]::ToBase64String($cipherBytes)",
|
|
134
|
+
].join("; "),
|
|
135
|
+
{
|
|
136
|
+
CODEX_SWITCHER_PLAIN_BASE64: payload,
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function unprotectWithWindowsDpapi(cipherText) {
|
|
142
|
+
return runPowerShell(
|
|
143
|
+
[
|
|
144
|
+
"Add-Type -AssemblyName System.Security",
|
|
145
|
+
"$cipherBytes = [Convert]::FromBase64String($env:CODEX_SWITCHER_CIPHER_BASE64)",
|
|
146
|
+
"$plainBytes = [System.Security.Cryptography.ProtectedData]::Unprotect($cipherBytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)",
|
|
147
|
+
"[System.Text.Encoding]::UTF8.GetString($plainBytes)",
|
|
148
|
+
].join("; "),
|
|
149
|
+
{
|
|
150
|
+
CODEX_SWITCHER_CIPHER_BASE64: String(cipherText || "").trim(),
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function ensureLocalAesKey() {
|
|
156
|
+
await ensureStorage();
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const stored = await fs.readFile(cloudSyncKeyPath, "utf8");
|
|
160
|
+
const normalized = stored.trim();
|
|
161
|
+
if (normalized) {
|
|
162
|
+
return Buffer.from(normalized, "base64");
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
// 首次写入时继续生成本地密钥。
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const key = crypto.randomBytes(32);
|
|
169
|
+
await fs.writeFile(cloudSyncKeyPath, `${key.toString("base64")}\n`, "utf8");
|
|
170
|
+
return key;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function protectWithLocalAes(plainText) {
|
|
174
|
+
const key = await ensureLocalAesKey();
|
|
175
|
+
const iv = crypto.randomBytes(12);
|
|
176
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
177
|
+
const encrypted = Buffer.concat([cipher.update(String(plainText || ""), "utf8"), cipher.final()]);
|
|
178
|
+
const tag = cipher.getAuthTag();
|
|
179
|
+
return Buffer.concat([iv, tag, encrypted]).toString("base64");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function unprotectWithLocalAes(cipherText) {
|
|
183
|
+
const key = await ensureLocalAesKey();
|
|
184
|
+
const payload = Buffer.from(String(cipherText || "").trim(), "base64");
|
|
185
|
+
if (payload.length < 29) {
|
|
186
|
+
throw new Error("本地同步令牌数据不完整。");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const iv = payload.subarray(0, 12);
|
|
190
|
+
const tag = payload.subarray(12, 28);
|
|
191
|
+
const encrypted = payload.subarray(28);
|
|
192
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
|
|
193
|
+
decipher.setAuthTag(tag);
|
|
194
|
+
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function protectToken(plainText) {
|
|
198
|
+
const value = String(plainText || "").trim();
|
|
199
|
+
if (!value) {
|
|
200
|
+
return {
|
|
201
|
+
protection: "",
|
|
202
|
+
protectedValue: "",
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (process.platform === "win32") {
|
|
207
|
+
try {
|
|
208
|
+
return {
|
|
209
|
+
protection: "dpapi",
|
|
210
|
+
protectedValue: await protectWithWindowsDpapi(value),
|
|
211
|
+
};
|
|
212
|
+
} catch {
|
|
213
|
+
// Windows DPAPI 不可用时回退到本地 AES,避免登录态明文落盘。
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
protection: "aes-256-gcm",
|
|
219
|
+
protectedValue: await protectWithLocalAes(value),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function unprotectToken(config) {
|
|
224
|
+
const protection = String(config.authTokenProtection || "").trim();
|
|
225
|
+
const protectedValue = String(config.authTokenProtected || "").trim();
|
|
226
|
+
const legacyToken = String(config.authToken || "").trim();
|
|
227
|
+
|
|
228
|
+
if (protectedValue) {
|
|
229
|
+
if (protection === "dpapi") {
|
|
230
|
+
return {
|
|
231
|
+
authToken: await unprotectWithWindowsDpapi(protectedValue),
|
|
232
|
+
hasStoredToken: true,
|
|
233
|
+
tokenError: "",
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (protection === "aes-256-gcm") {
|
|
238
|
+
return {
|
|
239
|
+
authToken: await unprotectWithLocalAes(protectedValue),
|
|
240
|
+
hasStoredToken: true,
|
|
241
|
+
tokenError: "",
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
authToken: "",
|
|
247
|
+
hasStoredToken: true,
|
|
248
|
+
tokenError: "本地同步令牌保护方式无法识别,请重新登录。",
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
authToken: legacyToken,
|
|
254
|
+
hasStoredToken: Boolean(legacyToken),
|
|
255
|
+
tokenError: "",
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
95
259
|
async function readConfig() {
|
|
96
260
|
await ensureStorage();
|
|
97
261
|
const content = await fs.readFile(cloudSyncConfigPath, "utf8");
|
|
98
262
|
const config = JSON.parse(content);
|
|
263
|
+
const tokenInfo = await unprotectToken(config);
|
|
99
264
|
|
|
100
265
|
return {
|
|
101
266
|
serverUrl: String(config.serverUrl || getDefaultServerUrl()).trim(),
|
|
102
267
|
username: String(config.username || "").trim(),
|
|
103
|
-
authToken: String(
|
|
268
|
+
authToken: String(tokenInfo.authToken || "").trim(),
|
|
269
|
+
hasStoredToken: Boolean(tokenInfo.hasStoredToken),
|
|
270
|
+
authTokenProtection: String(config.authTokenProtection || "").trim(),
|
|
271
|
+
authTokenProtected: String(config.authTokenProtected || "").trim(),
|
|
272
|
+
tokenError: String(tokenInfo.tokenError || "").trim(),
|
|
104
273
|
tokenExpiresAt: String(config.tokenExpiresAt || "").trim(),
|
|
105
274
|
lastPushAt: String(config.lastPushAt || "").trim(),
|
|
106
275
|
lastPullAt: String(config.lastPullAt || "").trim(),
|
|
@@ -109,7 +278,25 @@ async function readConfig() {
|
|
|
109
278
|
|
|
110
279
|
async function writeConfig(config) {
|
|
111
280
|
await ensureStorage();
|
|
112
|
-
await
|
|
281
|
+
const tokenPayload = await protectToken(config.authToken);
|
|
282
|
+
await fs.writeFile(
|
|
283
|
+
cloudSyncConfigPath,
|
|
284
|
+
`${JSON.stringify(
|
|
285
|
+
{
|
|
286
|
+
serverUrl: String(config.serverUrl || getDefaultServerUrl()).trim(),
|
|
287
|
+
username: String(config.username || "").trim(),
|
|
288
|
+
authToken: "",
|
|
289
|
+
authTokenProtection: tokenPayload.protection,
|
|
290
|
+
authTokenProtected: tokenPayload.protectedValue,
|
|
291
|
+
tokenExpiresAt: String(config.tokenExpiresAt || "").trim(),
|
|
292
|
+
lastPushAt: String(config.lastPushAt || "").trim(),
|
|
293
|
+
lastPullAt: String(config.lastPullAt || "").trim(),
|
|
294
|
+
},
|
|
295
|
+
null,
|
|
296
|
+
2,
|
|
297
|
+
)}\n`,
|
|
298
|
+
"utf8",
|
|
299
|
+
);
|
|
113
300
|
}
|
|
114
301
|
|
|
115
302
|
async function requestJson(serverUrl, pathname, options = {}) {
|
|
@@ -150,14 +337,14 @@ async function getCloudSyncStatus() {
|
|
|
150
337
|
configPath: cloudSyncConfigPath,
|
|
151
338
|
serverUrl: config.serverUrl,
|
|
152
339
|
username: config.username,
|
|
153
|
-
hasToken: Boolean(config.
|
|
340
|
+
hasToken: Boolean(config.hasStoredToken),
|
|
154
341
|
loggedIn: false,
|
|
155
342
|
tokenExpiresAt: config.tokenExpiresAt,
|
|
156
343
|
lastPushAt: config.lastPushAt,
|
|
157
344
|
lastPullAt: config.lastPullAt,
|
|
158
345
|
remoteUser: "",
|
|
159
346
|
remoteProfileUpdatedAt: "",
|
|
160
|
-
lastError: "",
|
|
347
|
+
lastError: config.tokenError || "",
|
|
161
348
|
};
|
|
162
349
|
|
|
163
350
|
if (!config.serverUrl || !config.authToken) {
|
|
@@ -252,6 +439,10 @@ async function logoutAccount() {
|
|
|
252
439
|
|
|
253
440
|
async function requireLoggedInConfig() {
|
|
254
441
|
const config = await readConfig();
|
|
442
|
+
if (config.tokenError) {
|
|
443
|
+
throw new Error(config.tokenError);
|
|
444
|
+
}
|
|
445
|
+
|
|
255
446
|
if (!config.authToken) {
|
|
256
447
|
throw new Error("当前还没有登录同步账号。");
|
|
257
448
|
}
|
|
@@ -559,23 +559,54 @@ async function updateEndpoint(id, payload) {
|
|
|
559
559
|
}
|
|
560
560
|
|
|
561
561
|
const endpoint = validateEndpointPayload(payload);
|
|
562
|
-
const endpoints = await
|
|
562
|
+
const [endpoints, currentState, state] = await Promise.all([
|
|
563
|
+
readEndpointStore(),
|
|
564
|
+
readCurrentConfigAndAuth(),
|
|
565
|
+
readEndpointState(),
|
|
566
|
+
]);
|
|
563
567
|
const index = endpoints.findIndex((item) => item.id === safeId);
|
|
564
568
|
|
|
565
569
|
if (index === -1) {
|
|
566
570
|
throw new Error("未找到要更新的端点。");
|
|
567
571
|
}
|
|
568
572
|
|
|
569
|
-
endpoints[index]
|
|
570
|
-
|
|
573
|
+
const previousEndpoint = endpoints[index];
|
|
574
|
+
const nextEndpoint = {
|
|
575
|
+
...previousEndpoint,
|
|
571
576
|
note: endpoint.note,
|
|
572
577
|
url: endpoint.url,
|
|
573
578
|
key: endpoint.key,
|
|
574
579
|
updatedAt: new Date().toISOString(),
|
|
575
580
|
};
|
|
581
|
+
endpoints[index] = nextEndpoint;
|
|
582
|
+
|
|
583
|
+
const shouldSyncDirectConfig = Boolean(
|
|
584
|
+
!currentState.proxyModeEnabled && state.activeEndpointId === safeId && currentState.provider,
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
let backupPaths = {};
|
|
588
|
+
if (shouldSyncDirectConfig) {
|
|
589
|
+
const nextConfigContent = updateProviderBaseUrl(
|
|
590
|
+
currentState.configContent,
|
|
591
|
+
currentState.provider,
|
|
592
|
+
nextEndpoint.url,
|
|
593
|
+
);
|
|
594
|
+
const nextAuthConfig = {
|
|
595
|
+
...currentState.authConfig,
|
|
596
|
+
OPENAI_API_KEY: nextEndpoint.key,
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
backupPaths = await backupCurrentState();
|
|
600
|
+
await writeTextFile(mainConfigPath, nextConfigContent);
|
|
601
|
+
await writeJsonFile(authConfigPath, nextAuthConfig);
|
|
602
|
+
}
|
|
576
603
|
|
|
577
604
|
await writeEndpointStore(endpoints);
|
|
578
|
-
return
|
|
605
|
+
return {
|
|
606
|
+
...backupPaths,
|
|
607
|
+
syncedCurrentConfig: shouldSyncDirectConfig,
|
|
608
|
+
...buildEndpointResponse(nextEndpoint, ""),
|
|
609
|
+
};
|
|
579
610
|
}
|
|
580
611
|
|
|
581
612
|
async function deleteEndpoint(id) {
|
|
@@ -592,19 +623,28 @@ async function deleteEndpoint(id) {
|
|
|
592
623
|
}
|
|
593
624
|
|
|
594
625
|
const [removed] = endpoints.splice(index, 1);
|
|
595
|
-
await writeEndpointStore(endpoints);
|
|
596
|
-
|
|
597
626
|
const state = await readEndpointState();
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
627
|
+
const deletingActiveEndpoint = state.activeEndpointId === removed.id;
|
|
628
|
+
|
|
629
|
+
if (state.proxyModeEnabled && deletingActiveEndpoint && endpoints.length === 0) {
|
|
630
|
+
throw new Error("当前正在使用这条热更新连接,至少保留一条连接后再删除。");
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
let nextActiveEndpointId = state.activeEndpointId;
|
|
634
|
+
if (deletingActiveEndpoint) {
|
|
635
|
+
nextActiveEndpointId = endpoints[0]?.id || "";
|
|
603
636
|
}
|
|
604
637
|
|
|
638
|
+
await writeEndpointStore(endpoints);
|
|
639
|
+
await writeEndpointState({
|
|
640
|
+
...state,
|
|
641
|
+
activeEndpointId: nextActiveEndpointId,
|
|
642
|
+
});
|
|
643
|
+
|
|
605
644
|
return {
|
|
606
645
|
id: removed.id,
|
|
607
646
|
note: removed.note,
|
|
647
|
+
nextActiveEndpointId,
|
|
608
648
|
};
|
|
609
649
|
}
|
|
610
650
|
|
|
@@ -26,6 +26,36 @@ function getAutoUpdateStatePath() {
|
|
|
26
26
|
return path.join(stateDir, "codex-switcher-auto-update.json");
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
function getAutoUpdateRuntimeDir() {
|
|
30
|
+
const runtimeDir = path.join(os.homedir(), ".codex", "runtime");
|
|
31
|
+
fs.mkdirSync(runtimeDir, { recursive: true });
|
|
32
|
+
return runtimeDir;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function cleanupStagedAutoUpdateRunners(runtimeDir) {
|
|
36
|
+
const staleBeforeMs = Date.now() - 24 * 60 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
for (const entry of fs.readdirSync(runtimeDir, { withFileTypes: true })) {
|
|
39
|
+
if (
|
|
40
|
+
!entry.isFile() ||
|
|
41
|
+
!entry.name.startsWith("codex-switcher-auto-update-runner-") ||
|
|
42
|
+
!entry.name.endsWith(".js")
|
|
43
|
+
) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const fullPath = path.join(runtimeDir, entry.name);
|
|
48
|
+
try {
|
|
49
|
+
const stats = fs.statSync(fullPath);
|
|
50
|
+
if (stats.mtimeMs < staleBeforeMs) {
|
|
51
|
+
fs.unlinkSync(fullPath);
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// 文件被占用或刚被其他进程删除时忽略。
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
29
59
|
function readAutoUpdateState() {
|
|
30
60
|
try {
|
|
31
61
|
const content = fs.readFileSync(getAutoUpdateStatePath(), "utf8");
|
|
@@ -44,6 +74,35 @@ function writeAutoUpdateState(payload) {
|
|
|
44
74
|
);
|
|
45
75
|
}
|
|
46
76
|
|
|
77
|
+
function clearAutoUpdateState() {
|
|
78
|
+
try {
|
|
79
|
+
fs.unlinkSync(getAutoUpdateStatePath());
|
|
80
|
+
} catch {
|
|
81
|
+
// 状态文件不存在时忽略。
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function consumeAutoUpdateState() {
|
|
86
|
+
const state = readAutoUpdateState();
|
|
87
|
+
if (!state) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (state.status === "succeeded") {
|
|
92
|
+
if (state.reportedAt) {
|
|
93
|
+
clearAutoUpdateState();
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
writeAutoUpdateState({
|
|
98
|
+
...state,
|
|
99
|
+
reportedAt: new Date().toISOString(),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return state;
|
|
104
|
+
}
|
|
105
|
+
|
|
47
106
|
function resolveRuntimePaths() {
|
|
48
107
|
const nodePath = process.execPath;
|
|
49
108
|
const nodeDir = path.dirname(nodePath);
|
|
@@ -71,6 +130,17 @@ function resolveRuntimePaths() {
|
|
|
71
130
|
};
|
|
72
131
|
}
|
|
73
132
|
|
|
133
|
+
function stageAutoUpdateRunner() {
|
|
134
|
+
const runtimeDir = getAutoUpdateRuntimeDir();
|
|
135
|
+
cleanupStagedAutoUpdateRunners(runtimeDir);
|
|
136
|
+
const stagedRunnerPath = path.join(
|
|
137
|
+
runtimeDir,
|
|
138
|
+
`codex-switcher-auto-update-runner-${Date.now()}.js`,
|
|
139
|
+
);
|
|
140
|
+
fs.copyFileSync(runnerEntryPath, stagedRunnerPath);
|
|
141
|
+
return stagedRunnerPath;
|
|
142
|
+
}
|
|
143
|
+
|
|
74
144
|
function sleep(timeoutMs) {
|
|
75
145
|
return new Promise((resolve) => {
|
|
76
146
|
setTimeout(resolve, timeoutMs);
|
|
@@ -109,7 +179,7 @@ function buildStatus(payload = {}) {
|
|
|
109
179
|
const currentVersion = packageInfo.version;
|
|
110
180
|
const latestVersion = payload.latestVersion || currentVersion;
|
|
111
181
|
const hasUpdate = compareVersions(latestVersion, currentVersion) > 0;
|
|
112
|
-
const autoUpdateState = payload.autoUpdateState ||
|
|
182
|
+
const autoUpdateState = payload.autoUpdateState || consumeAutoUpdateState();
|
|
113
183
|
|
|
114
184
|
return {
|
|
115
185
|
packageName: packageInfo.name,
|
|
@@ -188,6 +258,7 @@ function scheduleAutoUpdate(options = {}) {
|
|
|
188
258
|
const logPath = getAutoUpdateLogPath();
|
|
189
259
|
const statePath = getAutoUpdateStatePath();
|
|
190
260
|
const runtimePaths = resolveRuntimePaths();
|
|
261
|
+
const stagedRunnerPath = stageAutoUpdateRunner();
|
|
191
262
|
|
|
192
263
|
writeAutoUpdateState({
|
|
193
264
|
status: "scheduled",
|
|
@@ -217,7 +288,7 @@ function scheduleAutoUpdate(options = {}) {
|
|
|
217
288
|
"utf8",
|
|
218
289
|
).toString("base64url");
|
|
219
290
|
|
|
220
|
-
const child = spawn(process.execPath, [
|
|
291
|
+
const child = spawn(process.execPath, [stagedRunnerPath, payload], {
|
|
221
292
|
cwd: os.homedir(),
|
|
222
293
|
detached: true,
|
|
223
294
|
stdio: "ignore",
|
|
@@ -237,6 +308,7 @@ function scheduleAutoUpdate(options = {}) {
|
|
|
237
308
|
}
|
|
238
309
|
|
|
239
310
|
module.exports = {
|
|
311
|
+
clearAutoUpdateState,
|
|
240
312
|
getUpdateStatus,
|
|
241
313
|
scheduleAutoUpdate,
|
|
242
314
|
};
|
package/src/renderer/renderer.js
CHANGED
|
@@ -724,15 +724,21 @@ async function handleSubmitEndpoint(event) {
|
|
|
724
724
|
}
|
|
725
725
|
|
|
726
726
|
try {
|
|
727
|
+
let result;
|
|
727
728
|
if (id) {
|
|
728
|
-
await bridge.updateEndpoint({ id, note, url, key });
|
|
729
|
+
result = await bridge.updateEndpoint({ id, note, url, key });
|
|
729
730
|
} else {
|
|
730
|
-
await bridge.createEndpoint({ note, url, key });
|
|
731
|
+
result = await bridge.createEndpoint({ note, url, key });
|
|
731
732
|
}
|
|
732
733
|
|
|
733
734
|
await refreshDashboard(false);
|
|
734
735
|
closeEndpointModal();
|
|
735
736
|
resetForm();
|
|
737
|
+
if (id && result?.syncedCurrentConfig) {
|
|
738
|
+
setStatus(`已更新连接:${note},并同步到当前 Codex 配置。`, "success");
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
|
|
736
742
|
setStatus(id ? `已更新连接:${note}` : `已新增连接:${note}`, "success");
|
|
737
743
|
} catch (error) {
|
|
738
744
|
if (isUnauthorizedError(error)) {
|
|
@@ -10,6 +10,12 @@ const defaultDbPath = path.join(defaultRoot, "cloud-sync.db");
|
|
|
10
10
|
const defaultHost = process.env.SYNC_SERVER_HOST || "127.0.0.1";
|
|
11
11
|
const defaultPort = Number(process.env.SYNC_SERVER_PORT || 3190);
|
|
12
12
|
const sessionTtlDays = Number(process.env.SYNC_SERVER_SESSION_DAYS || 30);
|
|
13
|
+
const authWindowMs = Number(process.env.SYNC_SERVER_AUTH_WINDOW_MS || 10 * 60 * 1000);
|
|
14
|
+
const authBlockMs = Number(process.env.SYNC_SERVER_AUTH_BLOCK_MS || 15 * 60 * 1000);
|
|
15
|
+
const authMaxAttemptsPerIp = Number(process.env.SYNC_SERVER_AUTH_IP_ATTEMPTS || 18);
|
|
16
|
+
const authMaxAttemptsPerAccount = Number(process.env.SYNC_SERVER_AUTH_ACCOUNT_ATTEMPTS || 10);
|
|
17
|
+
const authMaxFailuresPerIp = Number(process.env.SYNC_SERVER_AUTH_IP_FAILURES || 10);
|
|
18
|
+
const authMaxFailuresPerAccount = Number(process.env.SYNC_SERVER_AUTH_ACCOUNT_FAILURES || 5);
|
|
13
19
|
|
|
14
20
|
function wrapAsync(handler) {
|
|
15
21
|
return async (req, res, next) => {
|
|
@@ -30,6 +36,132 @@ function addDaysToNow(days) {
|
|
|
30
36
|
return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
|
|
31
37
|
}
|
|
32
38
|
|
|
39
|
+
function getClientIp(req) {
|
|
40
|
+
const forwardedFor = String(req.headers["x-forwarded-for"] || "").trim();
|
|
41
|
+
const firstForwarded = forwardedFor.split(",")[0]?.trim();
|
|
42
|
+
const rawIp = firstForwarded || req.socket?.remoteAddress || "unknown";
|
|
43
|
+
return rawIp.replace(/^::ffff:/, "");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function toAccountKey(username) {
|
|
47
|
+
return String(username || "").trim().toLowerCase() || "__anonymous__";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatBlockMessage(scopeLabel, blockedUntil) {
|
|
51
|
+
const seconds = Math.max(1, Math.ceil((blockedUntil - Date.now()) / 1000));
|
|
52
|
+
return `${scopeLabel}尝试过于频繁,请在 ${seconds} 秒后重试。`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function createAttemptBucket() {
|
|
56
|
+
return {
|
|
57
|
+
attempts: [],
|
|
58
|
+
failures: [],
|
|
59
|
+
blockedUntil: 0,
|
|
60
|
+
updatedAt: 0,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function pruneBucket(bucket, now) {
|
|
65
|
+
bucket.attempts = bucket.attempts.filter((value) => now - value < authWindowMs);
|
|
66
|
+
bucket.failures = bucket.failures.filter((value) => now - value < authWindowMs);
|
|
67
|
+
bucket.updatedAt = now;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function markBlocked(bucket, now) {
|
|
71
|
+
bucket.blockedUntil = Math.max(bucket.blockedUntil || 0, now + authBlockMs);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function cleanupAttemptMap(store, now) {
|
|
75
|
+
for (const [key, bucket] of store.entries()) {
|
|
76
|
+
pruneBucket(bucket, now);
|
|
77
|
+
if (
|
|
78
|
+
(!bucket.attempts.length && !bucket.failures.length && bucket.blockedUntil <= now) ||
|
|
79
|
+
now - bucket.updatedAt > authWindowMs * 3
|
|
80
|
+
) {
|
|
81
|
+
store.delete(key);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function createAuthRateGuard() {
|
|
87
|
+
const ipBuckets = new Map();
|
|
88
|
+
const accountBuckets = new Map();
|
|
89
|
+
|
|
90
|
+
function getBucket(store, key, now) {
|
|
91
|
+
if (!store.has(key)) {
|
|
92
|
+
store.set(key, createAttemptBucket());
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const bucket = store.get(key);
|
|
96
|
+
pruneBucket(bucket, now);
|
|
97
|
+
return bucket;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function assertBucketAllowed(bucket, scopeLabel, maxAttempts, now) {
|
|
101
|
+
if (bucket.blockedUntil > now) {
|
|
102
|
+
const error = new Error(formatBlockMessage(scopeLabel, bucket.blockedUntil));
|
|
103
|
+
error.statusCode = 429;
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (bucket.attempts.length >= maxAttempts) {
|
|
108
|
+
markBlocked(bucket, now);
|
|
109
|
+
const error = new Error(formatBlockMessage(scopeLabel, bucket.blockedUntil));
|
|
110
|
+
error.statusCode = 429;
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function recordAttempt(bucket, now) {
|
|
116
|
+
bucket.attempts.push(now);
|
|
117
|
+
bucket.updatedAt = now;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function recordFailure(bucket, maxFailures, now) {
|
|
121
|
+
bucket.failures.push(now);
|
|
122
|
+
bucket.updatedAt = now;
|
|
123
|
+
if (bucket.failures.length >= maxFailures) {
|
|
124
|
+
markBlocked(bucket, now);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function recordSuccess(bucket, now) {
|
|
129
|
+
bucket.failures = [];
|
|
130
|
+
bucket.blockedUntil = 0;
|
|
131
|
+
bucket.updatedAt = now;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
assertAllowed(req, username) {
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
const ipKey = getClientIp(req);
|
|
138
|
+
const accountKey = toAccountKey(username);
|
|
139
|
+
const ipBucket = getBucket(ipBuckets, ipKey, now);
|
|
140
|
+
const accountBucket = getBucket(accountBuckets, accountKey, now);
|
|
141
|
+
|
|
142
|
+
assertBucketAllowed(ipBucket, "当前 IP ", authMaxAttemptsPerIp, now);
|
|
143
|
+
assertBucketAllowed(accountBucket, "当前账号", authMaxAttemptsPerAccount, now);
|
|
144
|
+
|
|
145
|
+
recordAttempt(ipBucket, now);
|
|
146
|
+
recordAttempt(accountBucket, now);
|
|
147
|
+
cleanupAttemptMap(ipBuckets, now);
|
|
148
|
+
cleanupAttemptMap(accountBuckets, now);
|
|
149
|
+
},
|
|
150
|
+
recordFailure(req, username) {
|
|
151
|
+
const now = Date.now();
|
|
152
|
+
const ipBucket = getBucket(ipBuckets, getClientIp(req), now);
|
|
153
|
+
const accountBucket = getBucket(accountBuckets, toAccountKey(username), now);
|
|
154
|
+
recordFailure(ipBucket, authMaxFailuresPerIp, now);
|
|
155
|
+
recordFailure(accountBucket, authMaxFailuresPerAccount, now);
|
|
156
|
+
},
|
|
157
|
+
recordSuccess(req, username) {
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
recordSuccess(getBucket(ipBuckets, getClientIp(req), now), now);
|
|
160
|
+
recordSuccess(getBucket(accountBuckets, toAccountKey(username), now), now);
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
33
165
|
function normalizeUsername(value) {
|
|
34
166
|
const username = String(value || "").trim();
|
|
35
167
|
if (!username) {
|
|
@@ -220,6 +352,7 @@ function createCloudSyncApp(options = {}) {
|
|
|
220
352
|
const host = options.host || defaultHost;
|
|
221
353
|
const port = Number(options.port || defaultPort);
|
|
222
354
|
const db = createDatabase(dbPath);
|
|
355
|
+
const authRateGuard = createAuthRateGuard();
|
|
223
356
|
const authRequired = buildAuthMiddleware(db);
|
|
224
357
|
const app = express();
|
|
225
358
|
|
|
@@ -240,62 +373,80 @@ function createCloudSyncApp(options = {}) {
|
|
|
240
373
|
app.post(
|
|
241
374
|
"/api/auth/register",
|
|
242
375
|
wrapAsync(async (req) => {
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const
|
|
249
|
-
|
|
376
|
+
const usernameCandidate = String(req.body.username || "").trim();
|
|
377
|
+
authRateGuard.assertAllowed(req, usernameCandidate);
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
const username = normalizeUsername(usernameCandidate);
|
|
381
|
+
const password = normalizePassword(req.body.password);
|
|
382
|
+
const existing = getUserByUsername(db, username);
|
|
383
|
+
|
|
384
|
+
if (existing) {
|
|
385
|
+
const error = new Error("这个账号已经存在,请直接登录。");
|
|
386
|
+
error.statusCode = 409;
|
|
387
|
+
throw error;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const salt = randomBytes(16).toString("base64url");
|
|
391
|
+
const createdAt = nowIso();
|
|
392
|
+
const inserted = db
|
|
393
|
+
.prepare(
|
|
394
|
+
`
|
|
395
|
+
INSERT INTO users (username, password_salt, password_hash, created_at, updated_at)
|
|
396
|
+
VALUES (?, ?, ?, ?, ?)
|
|
397
|
+
`,
|
|
398
|
+
)
|
|
399
|
+
.run(username, salt, hashPassword(password, salt), createdAt, createdAt);
|
|
400
|
+
const userId = Number(inserted.lastInsertRowid);
|
|
401
|
+
const session = createSession(db, userId);
|
|
402
|
+
authRateGuard.recordSuccess(req, username);
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
token: session.token,
|
|
406
|
+
expiresAt: session.expiresAt,
|
|
407
|
+
user: {
|
|
408
|
+
id: userId,
|
|
409
|
+
username,
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
} catch (error) {
|
|
413
|
+
authRateGuard.recordFailure(req, usernameCandidate);
|
|
250
414
|
throw error;
|
|
251
415
|
}
|
|
252
|
-
|
|
253
|
-
const salt = randomBytes(16).toString("base64url");
|
|
254
|
-
const createdAt = nowIso();
|
|
255
|
-
const inserted = db
|
|
256
|
-
.prepare(
|
|
257
|
-
`
|
|
258
|
-
INSERT INTO users (username, password_salt, password_hash, created_at, updated_at)
|
|
259
|
-
VALUES (?, ?, ?, ?, ?)
|
|
260
|
-
`,
|
|
261
|
-
)
|
|
262
|
-
.run(username, salt, hashPassword(password, salt), createdAt, createdAt);
|
|
263
|
-
const userId = Number(inserted.lastInsertRowid);
|
|
264
|
-
const session = createSession(db, userId);
|
|
265
|
-
|
|
266
|
-
return {
|
|
267
|
-
token: session.token,
|
|
268
|
-
expiresAt: session.expiresAt,
|
|
269
|
-
user: {
|
|
270
|
-
id: userId,
|
|
271
|
-
username,
|
|
272
|
-
},
|
|
273
|
-
};
|
|
274
416
|
}),
|
|
275
417
|
);
|
|
276
418
|
|
|
277
419
|
app.post(
|
|
278
420
|
"/api/auth/login",
|
|
279
421
|
wrapAsync(async (req) => {
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
const
|
|
286
|
-
|
|
422
|
+
const usernameCandidate = String(req.body.username || "").trim();
|
|
423
|
+
authRateGuard.assertAllowed(req, usernameCandidate);
|
|
424
|
+
|
|
425
|
+
try {
|
|
426
|
+
const username = normalizeUsername(usernameCandidate);
|
|
427
|
+
const password = normalizePassword(req.body.password);
|
|
428
|
+
const user = getUserByUsername(db, username);
|
|
429
|
+
|
|
430
|
+
if (!user || !verifyPassword(password, user.passwordSalt, user.passwordHash)) {
|
|
431
|
+
const error = new Error("账号或密码不正确。");
|
|
432
|
+
error.statusCode = 401;
|
|
433
|
+
throw error;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const session = createSession(db, user.id);
|
|
437
|
+
authRateGuard.recordSuccess(req, username);
|
|
438
|
+
return {
|
|
439
|
+
token: session.token,
|
|
440
|
+
expiresAt: session.expiresAt,
|
|
441
|
+
user: {
|
|
442
|
+
id: user.id,
|
|
443
|
+
username: user.username,
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
} catch (error) {
|
|
447
|
+
authRateGuard.recordFailure(req, usernameCandidate);
|
|
287
448
|
throw error;
|
|
288
449
|
}
|
|
289
|
-
|
|
290
|
-
const session = createSession(db, user.id);
|
|
291
|
-
return {
|
|
292
|
-
token: session.token,
|
|
293
|
-
expiresAt: session.expiresAt,
|
|
294
|
-
user: {
|
|
295
|
-
id: user.id,
|
|
296
|
-
username: user.username,
|
|
297
|
-
},
|
|
298
|
-
};
|
|
299
450
|
}),
|
|
300
451
|
);
|
|
301
452
|
|
package/src/web/launcher.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const fs = require("node:fs");
|
|
2
2
|
const path = require("node:path");
|
|
3
3
|
const http = require("node:http");
|
|
4
|
-
const { exec, spawn } = require("node:child_process");
|
|
4
|
+
const { exec, execFile, spawn } = require("node:child_process");
|
|
5
5
|
|
|
6
6
|
const projectRoot = path.resolve(__dirname, "../..");
|
|
7
7
|
const runtimeDir = path.join(projectRoot, "runtime");
|
|
@@ -12,11 +12,44 @@ const healthUrl = `http://127.0.0.1:${port}/api/health`;
|
|
|
12
12
|
const closeUrl = `http://127.0.0.1:${port}/api/app/close`;
|
|
13
13
|
const proxyHealthUrl = `http://127.0.0.1:${proxyPort}/__switcher/health`;
|
|
14
14
|
const consoleUrl = `http://localhost:${port}`;
|
|
15
|
+
const pidFilePath = path.join(runtimeDir, "web-server.pid.json");
|
|
15
16
|
|
|
16
17
|
function ensureRuntimeDir() {
|
|
17
18
|
fs.mkdirSync(runtimeDir, { recursive: true });
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
function readPidFile() {
|
|
22
|
+
try {
|
|
23
|
+
const content = fs.readFileSync(pidFilePath, "utf8");
|
|
24
|
+
const parsed = JSON.parse(content);
|
|
25
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writePidFile(payload) {
|
|
32
|
+
ensureRuntimeDir();
|
|
33
|
+
fs.writeFileSync(pidFilePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function clearPidFile() {
|
|
37
|
+
try {
|
|
38
|
+
fs.unlinkSync(pidFilePath);
|
|
39
|
+
} catch {
|
|
40
|
+
// 文件不存在时忽略。
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isProcessAlive(pid) {
|
|
45
|
+
try {
|
|
46
|
+
process.kill(pid, 0);
|
|
47
|
+
return true;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
20
53
|
function requestJson(url, timeoutMs = 1200) {
|
|
21
54
|
return new Promise((resolve, reject) => {
|
|
22
55
|
const request = http.get(url, (response) => {
|
|
@@ -133,11 +166,98 @@ function spawnServerInBackground() {
|
|
|
133
166
|
});
|
|
134
167
|
|
|
135
168
|
child.unref();
|
|
169
|
+
writePidFile({
|
|
170
|
+
pid: child.pid,
|
|
171
|
+
createdAt: new Date().toISOString(),
|
|
172
|
+
nodePath: process.execPath,
|
|
173
|
+
serverEntryPath,
|
|
174
|
+
port,
|
|
175
|
+
proxyPort,
|
|
176
|
+
});
|
|
136
177
|
return child.pid;
|
|
137
178
|
}
|
|
138
179
|
|
|
180
|
+
function inspectProcessCommandLine(pid) {
|
|
181
|
+
return new Promise((resolve) => {
|
|
182
|
+
execFile(
|
|
183
|
+
"powershell.exe",
|
|
184
|
+
[
|
|
185
|
+
"-NoProfile",
|
|
186
|
+
"-NonInteractive",
|
|
187
|
+
"-ExecutionPolicy",
|
|
188
|
+
"Bypass",
|
|
189
|
+
"-Command",
|
|
190
|
+
`& { $p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($null -eq $p) { '' } else { $p.CommandLine } }`,
|
|
191
|
+
],
|
|
192
|
+
{
|
|
193
|
+
windowsHide: true,
|
|
194
|
+
},
|
|
195
|
+
(error, stdout) => {
|
|
196
|
+
if (error) {
|
|
197
|
+
resolve("");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
resolve(String(stdout || "").trim());
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function isManagedServerPid(pid) {
|
|
208
|
+
if (!Number.isFinite(pid) || pid <= 0 || !isProcessAlive(pid)) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const commandLine = await inspectProcessCommandLine(pid);
|
|
213
|
+
if (!commandLine) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return commandLine.includes(serverEntryPath);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function stopTrackedServerProcess() {
|
|
221
|
+
const pidInfo = readPidFile();
|
|
222
|
+
const pid = Number(pidInfo?.pid || 0);
|
|
223
|
+
if (!(await isManagedServerPid(pid))) {
|
|
224
|
+
clearPidFile();
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
await new Promise((resolve, reject) => {
|
|
229
|
+
exec(`cmd /c taskkill /PID ${pid} /T /F`, (error) => {
|
|
230
|
+
if (error) {
|
|
231
|
+
reject(error);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
resolve();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
clearPidFile();
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
139
243
|
async function ensureServerRunning() {
|
|
140
244
|
if (await checkServerHealth()) {
|
|
245
|
+
const pidInfo = readPidFile();
|
|
246
|
+
const trackedPid = Number(pidInfo?.pid || 0);
|
|
247
|
+
if (!(await isManagedServerPid(trackedPid))) {
|
|
248
|
+
const listeningPid = await getListeningPid();
|
|
249
|
+
if (await isManagedServerPid(listeningPid)) {
|
|
250
|
+
writePidFile({
|
|
251
|
+
pid: listeningPid,
|
|
252
|
+
createdAt: pidInfo?.createdAt || new Date().toISOString(),
|
|
253
|
+
nodePath: process.execPath,
|
|
254
|
+
serverEntryPath,
|
|
255
|
+
port,
|
|
256
|
+
proxyPort,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
141
261
|
return {
|
|
142
262
|
started: false,
|
|
143
263
|
running: true,
|
|
@@ -150,6 +270,7 @@ async function ensureServerRunning() {
|
|
|
150
270
|
const ready = await waitForServer();
|
|
151
271
|
|
|
152
272
|
if (!ready) {
|
|
273
|
+
clearPidFile();
|
|
153
274
|
throw new Error("本地网页服务启动失败,请检查 runtime 日志。");
|
|
154
275
|
}
|
|
155
276
|
|
|
@@ -204,21 +325,8 @@ function getListeningPid() {
|
|
|
204
325
|
}
|
|
205
326
|
|
|
206
327
|
async function restartServer() {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
await new Promise((resolve, reject) => {
|
|
210
|
-
exec(`cmd /c taskkill /PID ${pid} /F`, (error) => {
|
|
211
|
-
if (error) {
|
|
212
|
-
reject(error);
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
resolve();
|
|
217
|
-
});
|
|
218
|
-
});
|
|
219
|
-
await sleep(800);
|
|
220
|
-
}
|
|
221
|
-
|
|
328
|
+
await stopServer();
|
|
329
|
+
await sleep(600);
|
|
222
330
|
return ensureServerRunning();
|
|
223
331
|
}
|
|
224
332
|
|
|
@@ -269,11 +377,14 @@ async function stopServer() {
|
|
|
269
377
|
await sleep(1000);
|
|
270
378
|
}
|
|
271
379
|
|
|
272
|
-
const
|
|
273
|
-
const
|
|
380
|
+
const trackedKilled = await stopTrackedServerProcess().catch(() => false);
|
|
381
|
+
const webKilled = trackedKilled ? true : await killListeningPid(port);
|
|
382
|
+
const proxyKilled = trackedKilled ? true : await killListeningPid(proxyPort);
|
|
383
|
+
clearPidFile();
|
|
274
384
|
|
|
275
385
|
return {
|
|
276
386
|
stopped: true,
|
|
387
|
+
trackedKilled,
|
|
277
388
|
webKilled,
|
|
278
389
|
proxyKilled,
|
|
279
390
|
port,
|