codex-endpoint-switcher 1.3.2 → 1.5.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 +24 -2
- package/bin/codex-switcher.js +42 -0
- package/package.json +1 -1
- package/src/main/main.js +13 -0
- package/src/main/preload.js +6 -0
- package/src/main/update-service.js +188 -0
- package/src/renderer/index.html +21 -0
- package/src/renderer/renderer.js +172 -3
- package/src/renderer/styles.css +86 -13
- package/src/web/proxy-server.js +17 -3
- package/src/web/server.js +42 -1
package/README.md
CHANGED
|
@@ -49,6 +49,8 @@ codex-switcher start
|
|
|
49
49
|
codex-switcher status
|
|
50
50
|
codex-switcher restart
|
|
51
51
|
codex-switcher stop
|
|
52
|
+
codex-switcher check-update
|
|
53
|
+
codex-switcher self-update
|
|
52
54
|
codex-switcher sync-server
|
|
53
55
|
codex-switcher install-access
|
|
54
56
|
codex-switcher remove-access
|
|
@@ -61,6 +63,8 @@ codex-switcher remove-access
|
|
|
61
63
|
- `status`:查看当前服务状态
|
|
62
64
|
- `restart`:重启本地网页服务
|
|
63
65
|
- `stop`:关闭本地网页服务
|
|
66
|
+
- `check-update`:检查 npm 是否有新版本
|
|
67
|
+
- `self-update`:自动更新 npm 包并自动重启控制台
|
|
64
68
|
- `sync-server`:启动账号同步服务,服务端使用 SQLite 单文件数据库
|
|
65
69
|
- `install-access`:创建桌面快捷方式和开机启动项
|
|
66
70
|
- `remove-access`:移除桌面快捷方式和开机启动项
|
|
@@ -75,7 +79,7 @@ http://localhost:3186
|
|
|
75
79
|
|
|
76
80
|
## 账号配置同步
|
|
77
81
|
|
|
78
|
-
|
|
82
|
+
网页控制台现在支持“账号 + 密码”的同步方式。
|
|
79
83
|
|
|
80
84
|
### 1. 启动同步服务
|
|
81
85
|
|
|
@@ -115,7 +119,6 @@ codex-switcher sync-server
|
|
|
115
119
|
|
|
116
120
|
在客户端网页控制台里填写:
|
|
117
121
|
|
|
118
|
-
- 同步服务器地址
|
|
119
122
|
- 账号
|
|
120
123
|
- 密码
|
|
121
124
|
|
|
@@ -135,6 +138,25 @@ codex-switcher sync-server
|
|
|
135
138
|
- 拉取前会自动备份本机 `~/.codex/endpoint-presets.json` 和 `~/.codex/endpoint-switcher-state.json`
|
|
136
139
|
- 服务端数据库是 SQLite,部署只需要一个 Node 进程和一个 `.db` 文件
|
|
137
140
|
|
|
141
|
+
## 版本更新提示
|
|
142
|
+
|
|
143
|
+
- 控制台打开后会自动检查 npm 最新版本
|
|
144
|
+
- 如果你发布了新版本,其他用户打开控制台后会看到“发现新版本”的提示
|
|
145
|
+
- 用户可以直接点 `立即更新`,程序会自动关闭、更新并重新打开
|
|
146
|
+
- 也可以继续使用“复制升级命令”手动更新
|
|
147
|
+
|
|
148
|
+
也可以用 CLI 手动检查:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
codex-switcher check-update
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
或者直接自动更新:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
codex-switcher self-update
|
|
158
|
+
```
|
|
159
|
+
|
|
138
160
|
## 更新 npm 包
|
|
139
161
|
|
|
140
162
|
先进入项目目录:
|
package/bin/codex-switcher.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const path = require("node:path");
|
|
4
4
|
const { spawn } = require("node:child_process");
|
|
5
5
|
const { handleCommand } = require("../src/web/launcher");
|
|
6
|
+
const updateService = require("../src/main/update-service");
|
|
6
7
|
|
|
7
8
|
const projectRoot = path.resolve(__dirname, "..");
|
|
8
9
|
const installScriptPath = path.join(projectRoot, "install-web-access.ps1");
|
|
@@ -17,6 +18,8 @@ function printUsage() {
|
|
|
17
18
|
codex-switcher status
|
|
18
19
|
codex-switcher restart
|
|
19
20
|
codex-switcher stop
|
|
21
|
+
codex-switcher check-update
|
|
22
|
+
codex-switcher self-update
|
|
20
23
|
codex-switcher sync-server
|
|
21
24
|
codex-switcher install-access
|
|
22
25
|
codex-switcher remove-access
|
|
@@ -27,6 +30,8 @@ function printUsage() {
|
|
|
27
30
|
status 查看服务状态
|
|
28
31
|
restart 重启本地网页服务
|
|
29
32
|
stop 关闭本地网页服务
|
|
33
|
+
check-update 检查 npm 最新版本
|
|
34
|
+
self-update 自动更新到最新版本并重启控制台
|
|
30
35
|
sync-server 启动账号同步服务(SQLite)
|
|
31
36
|
install-access 创建桌面快捷方式与开机启动项
|
|
32
37
|
remove-access 删除桌面快捷方式与开机启动项
|
|
@@ -89,6 +94,43 @@ async function main() {
|
|
|
89
94
|
await handleCommand(command === "start" ? "ensure" : command);
|
|
90
95
|
return;
|
|
91
96
|
}
|
|
97
|
+
case "check-update": {
|
|
98
|
+
const status = await updateService.getUpdateStatus({ force: true });
|
|
99
|
+
if (status.hasUpdate) {
|
|
100
|
+
console.log(`发现新版本:${status.latestVersion}`);
|
|
101
|
+
console.log(`当前版本:${status.currentVersion}`);
|
|
102
|
+
console.log(`升级命令:${status.upgradeCommand}`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (status.lastError) {
|
|
107
|
+
console.log(`当前版本:${status.currentVersion}`);
|
|
108
|
+
console.log(`暂时无法检查更新:${status.lastError}`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
console.log(`当前已是最新版:${status.currentVersion}`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
case "self-update": {
|
|
116
|
+
const status = await updateService.getUpdateStatus({ force: true });
|
|
117
|
+
if (!status.hasUpdate) {
|
|
118
|
+
if (status.lastError) {
|
|
119
|
+
console.log(`当前版本:${status.currentVersion}`);
|
|
120
|
+
console.log(`暂时无法检查更新:${status.lastError}`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
console.log(`当前已是最新版:${status.currentVersion}`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const result = updateService.scheduleAutoUpdate({ reopen: true });
|
|
129
|
+
console.log(`已开始自动更新到 ${status.latestVersion}`);
|
|
130
|
+
console.log(`日志文件:${result.logPath}`);
|
|
131
|
+
await handleCommand("stop");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
92
134
|
case "sync-server": {
|
|
93
135
|
await runNodeScript(cloudSyncServerPath, ["--experimental-sqlite"]);
|
|
94
136
|
return;
|
package/package.json
CHANGED
package/src/main/main.js
CHANGED
|
@@ -2,6 +2,7 @@ const path = require("node:path");
|
|
|
2
2
|
const { app, BrowserWindow, ipcMain, shell } = require("electron");
|
|
3
3
|
const profileManager = require("./profile-manager");
|
|
4
4
|
const cloudSyncClient = require("./cloud-sync-client");
|
|
5
|
+
const updateService = require("./update-service");
|
|
5
6
|
|
|
6
7
|
function createWindow() {
|
|
7
8
|
const mainWindow = new BrowserWindow({
|
|
@@ -101,6 +102,18 @@ function registerHandlers() {
|
|
|
101
102
|
closing: true,
|
|
102
103
|
};
|
|
103
104
|
});
|
|
105
|
+
|
|
106
|
+
ipcMain.handle("app:updateStatus", async (_event, payload) => {
|
|
107
|
+
return updateService.getUpdateStatus(payload || {});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
ipcMain.handle("app:autoUpdate", async (_event, payload) => {
|
|
111
|
+
const result = updateService.scheduleAutoUpdate(payload || {});
|
|
112
|
+
setTimeout(() => {
|
|
113
|
+
app.quit();
|
|
114
|
+
}, 120);
|
|
115
|
+
return result;
|
|
116
|
+
});
|
|
104
117
|
}
|
|
105
118
|
|
|
106
119
|
app.whenReady().then(() => {
|
package/src/main/preload.js
CHANGED
|
@@ -55,4 +55,10 @@ contextBridge.exposeInMainWorld("codexDesktop", {
|
|
|
55
55
|
closeApp() {
|
|
56
56
|
return ipcRenderer.invoke("app:close");
|
|
57
57
|
},
|
|
58
|
+
getUpdateStatus(payload) {
|
|
59
|
+
return ipcRenderer.invoke("app:updateStatus", payload);
|
|
60
|
+
},
|
|
61
|
+
autoUpdate(payload) {
|
|
62
|
+
return ipcRenderer.invoke("app:autoUpdate", payload);
|
|
63
|
+
},
|
|
58
64
|
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
const os = require("node:os");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const { spawn } = require("node:child_process");
|
|
5
|
+
const packageInfo = require("../../package.json");
|
|
6
|
+
|
|
7
|
+
const REGISTRY_BASE_URL = "https://registry.npmjs.org";
|
|
8
|
+
const SUCCESS_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
9
|
+
const ERROR_CACHE_TTL_MS = 30 * 1000;
|
|
10
|
+
const MAX_RETRY_TIMES = 3;
|
|
11
|
+
const AUTO_UPDATE_WAIT_SECONDS = 3;
|
|
12
|
+
|
|
13
|
+
let cachedStatus = null;
|
|
14
|
+
|
|
15
|
+
function getAutoUpdateLogPath() {
|
|
16
|
+
const logDir = path.join(os.homedir(), ".codex");
|
|
17
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
18
|
+
return path.join(logDir, "codex-switcher-auto-update.log");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sleep(timeoutMs) {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
setTimeout(resolve, timeoutMs);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseVersion(version) {
|
|
28
|
+
return String(version || "")
|
|
29
|
+
.trim()
|
|
30
|
+
.split(".")
|
|
31
|
+
.map((part) => Number.parseInt(part, 10) || 0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function compareVersions(left, right) {
|
|
35
|
+
const leftParts = parseVersion(left);
|
|
36
|
+
const rightParts = parseVersion(right);
|
|
37
|
+
const length = Math.max(leftParts.length, rightParts.length);
|
|
38
|
+
|
|
39
|
+
for (let index = 0; index < length; index += 1) {
|
|
40
|
+
const leftValue = leftParts[index] || 0;
|
|
41
|
+
const rightValue = rightParts[index] || 0;
|
|
42
|
+
|
|
43
|
+
if (leftValue > rightValue) {
|
|
44
|
+
return 1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (leftValue < rightValue) {
|
|
48
|
+
return -1;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildStatus(payload = {}) {
|
|
56
|
+
const currentVersion = packageInfo.version;
|
|
57
|
+
const latestVersion = payload.latestVersion || currentVersion;
|
|
58
|
+
const hasUpdate = compareVersions(latestVersion, currentVersion) > 0;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
packageName: packageInfo.name,
|
|
62
|
+
currentVersion,
|
|
63
|
+
latestVersion,
|
|
64
|
+
hasUpdate,
|
|
65
|
+
checkedAt: new Date().toISOString(),
|
|
66
|
+
upgradeCommand: `npm install -g ${packageInfo.name}@latest`,
|
|
67
|
+
releaseUrl: `https://www.npmjs.com/package/${packageInfo.name}`,
|
|
68
|
+
lastError: payload.lastError || "",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function fetchLatestPackageVersion() {
|
|
73
|
+
let lastError = null;
|
|
74
|
+
|
|
75
|
+
for (let attempt = 1; attempt <= MAX_RETRY_TIMES; attempt += 1) {
|
|
76
|
+
try {
|
|
77
|
+
const response = await fetch(`${REGISTRY_BASE_URL}/${packageInfo.name}/latest`, {
|
|
78
|
+
headers: {
|
|
79
|
+
Accept: "application/json",
|
|
80
|
+
"User-Agent": `${packageInfo.name}/${packageInfo.version}`,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
throw new Error(`npm 仓库请求失败:${response.status}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const payload = await response.json();
|
|
89
|
+
const latestVersion = String(payload.version || "").trim();
|
|
90
|
+
if (!latestVersion) {
|
|
91
|
+
throw new Error("npm 仓库没有返回可用版本号。");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return latestVersion;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
lastError = error;
|
|
97
|
+
|
|
98
|
+
if (attempt < MAX_RETRY_TIMES) {
|
|
99
|
+
await sleep(300 * attempt);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function getUpdateStatus(options = {}) {
|
|
108
|
+
const force = Boolean(options.force);
|
|
109
|
+
|
|
110
|
+
if (
|
|
111
|
+
!force &&
|
|
112
|
+
cachedStatus &&
|
|
113
|
+
Date.now() - new Date(cachedStatus.checkedAt).getTime() <
|
|
114
|
+
(cachedStatus.lastError ? ERROR_CACHE_TTL_MS : SUCCESS_CACHE_TTL_MS)
|
|
115
|
+
) {
|
|
116
|
+
return cachedStatus;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const latestVersion = await fetchLatestPackageVersion();
|
|
121
|
+
cachedStatus = buildStatus({ latestVersion });
|
|
122
|
+
} catch (error) {
|
|
123
|
+
cachedStatus = buildStatus({
|
|
124
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return cachedStatus;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function scheduleAutoUpdate(options = {}) {
|
|
132
|
+
const reopen = options.reopen !== false;
|
|
133
|
+
const logPath = getAutoUpdateLogPath();
|
|
134
|
+
const installCommand = `npm install -g ${packageInfo.name}@latest`;
|
|
135
|
+
const restartCommand = reopen ? " && codex-switcher open" : "";
|
|
136
|
+
|
|
137
|
+
if (process.platform === "win32") {
|
|
138
|
+
const command =
|
|
139
|
+
`echo [%date% %time%] 开始自动更新 ${packageInfo.name} > "${logPath}" ` +
|
|
140
|
+
`&& timeout /t ${AUTO_UPDATE_WAIT_SECONDS} /nobreak >nul ` +
|
|
141
|
+
`&& ${installCommand} >> "${logPath}" 2>&1` +
|
|
142
|
+
`${restartCommand ? `${restartCommand} >> "${logPath}" 2>&1` : ""}`;
|
|
143
|
+
|
|
144
|
+
const child = spawn("cmd.exe", ["/d", "/s", "/c", command], {
|
|
145
|
+
cwd: os.homedir(),
|
|
146
|
+
detached: true,
|
|
147
|
+
stdio: "ignore",
|
|
148
|
+
windowsHide: true,
|
|
149
|
+
env: process.env,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
child.unref();
|
|
153
|
+
return {
|
|
154
|
+
scheduled: true,
|
|
155
|
+
packageName: packageInfo.name,
|
|
156
|
+
currentVersion: packageInfo.version,
|
|
157
|
+
logPath,
|
|
158
|
+
reopen,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const command =
|
|
163
|
+
`sleep ${AUTO_UPDATE_WAIT_SECONDS}; ` +
|
|
164
|
+
`echo "[auto-update] start ${packageInfo.name}" > "${logPath}"; ` +
|
|
165
|
+
`${installCommand} >> "${logPath}" 2>&1` +
|
|
166
|
+
(reopen ? ` && codex-switcher open >> "${logPath}" 2>&1` : "");
|
|
167
|
+
|
|
168
|
+
const child = spawn("sh", ["-lc", command], {
|
|
169
|
+
cwd: os.homedir(),
|
|
170
|
+
detached: true,
|
|
171
|
+
stdio: "ignore",
|
|
172
|
+
env: process.env,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
child.unref();
|
|
176
|
+
return {
|
|
177
|
+
scheduled: true,
|
|
178
|
+
packageName: packageInfo.name,
|
|
179
|
+
currentVersion: packageInfo.version,
|
|
180
|
+
logPath,
|
|
181
|
+
reopen,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = {
|
|
186
|
+
getUpdateStatus,
|
|
187
|
+
scheduleAutoUpdate,
|
|
188
|
+
};
|
package/src/renderer/index.html
CHANGED
|
@@ -109,6 +109,27 @@
|
|
|
109
109
|
<button id="openEndpointStoreButton" class="ghost-button">打开连接数据文件</button>
|
|
110
110
|
<button id="closeAppButton" class="danger-button">关闭程序</button>
|
|
111
111
|
</div>
|
|
112
|
+
<section class="update-strip" aria-label="版本更新状态">
|
|
113
|
+
<div class="update-copy">
|
|
114
|
+
<span class="metric-label">版本更新</span>
|
|
115
|
+
<strong id="updateSummary">正在检查版本...</strong>
|
|
116
|
+
<span id="updateDetail" class="update-detail">
|
|
117
|
+
当前版本 -,正在连接 npm 仓库。
|
|
118
|
+
</span>
|
|
119
|
+
<code id="updateCommand" class="update-command" hidden>
|
|
120
|
+
npm install -g codex-endpoint-switcher@latest
|
|
121
|
+
</code>
|
|
122
|
+
</div>
|
|
123
|
+
<div class="update-actions">
|
|
124
|
+
<button id="checkUpdateButton" type="button" class="ghost-button">检查更新</button>
|
|
125
|
+
<button id="autoUpdateButton" type="button" class="primary-button" hidden>
|
|
126
|
+
立即更新
|
|
127
|
+
</button>
|
|
128
|
+
<button id="copyUpdateCommandButton" type="button" class="secondary-button" hidden>
|
|
129
|
+
复制升级命令
|
|
130
|
+
</button>
|
|
131
|
+
</div>
|
|
132
|
+
</section>
|
|
112
133
|
<div class="current-grid">
|
|
113
134
|
<article class="metric-card">
|
|
114
135
|
<span class="metric-label">当前备注</span>
|
package/src/renderer/renderer.js
CHANGED
|
@@ -3,6 +3,7 @@ const state = {
|
|
|
3
3
|
endpoints: [],
|
|
4
4
|
paths: null,
|
|
5
5
|
cloud: null,
|
|
6
|
+
update: null,
|
|
6
7
|
};
|
|
7
8
|
|
|
8
9
|
function createWebBridge() {
|
|
@@ -105,6 +106,21 @@ function createWebBridge() {
|
|
|
105
106
|
method: "POST",
|
|
106
107
|
});
|
|
107
108
|
},
|
|
109
|
+
getUpdateStatus(options = {}) {
|
|
110
|
+
const searchParams = new URLSearchParams();
|
|
111
|
+
if (options.force) {
|
|
112
|
+
searchParams.set("force", "1");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const query = searchParams.toString();
|
|
116
|
+
return request(`/api/app/update-status${query ? `?${query}` : ""}`);
|
|
117
|
+
},
|
|
118
|
+
autoUpdate(payload = {}) {
|
|
119
|
+
return request("/api/app/auto-update", {
|
|
120
|
+
method: "POST",
|
|
121
|
+
body: JSON.stringify(payload),
|
|
122
|
+
});
|
|
123
|
+
},
|
|
108
124
|
};
|
|
109
125
|
}
|
|
110
126
|
|
|
@@ -286,6 +302,76 @@ function renderCloudStatus() {
|
|
|
286
302
|
$("#cloudPullReplaceButton").disabled = !cloud.loggedIn;
|
|
287
303
|
}
|
|
288
304
|
|
|
305
|
+
function renderUpdateStatus() {
|
|
306
|
+
const update = state.update;
|
|
307
|
+
if (!update) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const summary = $("#updateSummary");
|
|
312
|
+
const detail = $("#updateDetail");
|
|
313
|
+
const command = $("#updateCommand");
|
|
314
|
+
const copyButton = $("#copyUpdateCommandButton");
|
|
315
|
+
const autoUpdateButton = $("#autoUpdateButton");
|
|
316
|
+
|
|
317
|
+
if (!summary || !detail || !command || !copyButton || !autoUpdateButton) {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
command.textContent = update.upgradeCommand;
|
|
322
|
+
|
|
323
|
+
if (update.hasUpdate) {
|
|
324
|
+
summary.textContent = `发现新版本 ${update.latestVersion}`;
|
|
325
|
+
detail.textContent = `当前版本 ${update.currentVersion},可以直接升级到最新版本。`;
|
|
326
|
+
command.hidden = false;
|
|
327
|
+
copyButton.hidden = false;
|
|
328
|
+
autoUpdateButton.hidden = false;
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (update.lastError) {
|
|
333
|
+
summary.textContent = "暂时无法检查更新";
|
|
334
|
+
detail.textContent = `当前版本 ${update.currentVersion},原因:${update.lastError}`;
|
|
335
|
+
command.hidden = true;
|
|
336
|
+
copyButton.hidden = true;
|
|
337
|
+
autoUpdateButton.hidden = true;
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
summary.textContent = `当前已是最新版 ${update.currentVersion}`;
|
|
342
|
+
detail.textContent = "npm 包没有检测到更新,可以继续直接使用。";
|
|
343
|
+
command.hidden = true;
|
|
344
|
+
copyButton.hidden = true;
|
|
345
|
+
autoUpdateButton.hidden = true;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function refreshUpdateStatus(force = false) {
|
|
349
|
+
const summary = $("#updateSummary");
|
|
350
|
+
const detail = $("#updateDetail");
|
|
351
|
+
|
|
352
|
+
if (summary) {
|
|
353
|
+
summary.textContent = force ? "正在重新检查版本..." : "正在检查版本...";
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (detail) {
|
|
357
|
+
detail.textContent = "正在连接 npm 仓库,请稍候。";
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
state.update = await bridge.getUpdateStatus({ force });
|
|
362
|
+
renderUpdateStatus();
|
|
363
|
+
} catch (error) {
|
|
364
|
+
state.update = {
|
|
365
|
+
currentVersion: "-",
|
|
366
|
+
latestVersion: "-",
|
|
367
|
+
hasUpdate: false,
|
|
368
|
+
upgradeCommand: "npm install -g codex-endpoint-switcher@latest",
|
|
369
|
+
lastError: error.message,
|
|
370
|
+
};
|
|
371
|
+
renderUpdateStatus();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
289
375
|
function createEndpointCard(endpoint) {
|
|
290
376
|
const card = document.createElement("article");
|
|
291
377
|
card.className = `profile-card ${endpoint.isActive ? "active" : ""}`;
|
|
@@ -348,9 +434,18 @@ function createEndpointCard(endpoint) {
|
|
|
348
434
|
const meta = document.createElement("div");
|
|
349
435
|
meta.className = "profile-meta";
|
|
350
436
|
meta.innerHTML = `
|
|
351
|
-
<
|
|
352
|
-
|
|
353
|
-
|
|
437
|
+
<div class="profile-meta-item">
|
|
438
|
+
<span class="profile-meta-label">URL</span>
|
|
439
|
+
<strong class="profile-meta-value">${endpoint.url || "-"}</strong>
|
|
440
|
+
</div>
|
|
441
|
+
<div class="profile-meta-item">
|
|
442
|
+
<span class="profile-meta-label">Key</span>
|
|
443
|
+
<strong class="profile-meta-value">${endpoint.maskedKey || "-"}</strong>
|
|
444
|
+
</div>
|
|
445
|
+
<div class="profile-meta-item">
|
|
446
|
+
<span class="profile-meta-label">更新时间</span>
|
|
447
|
+
<strong class="profile-meta-value">${formatDateTime(endpoint.updatedAt)}</strong>
|
|
448
|
+
</div>
|
|
354
449
|
`;
|
|
355
450
|
|
|
356
451
|
card.append(top, meta);
|
|
@@ -392,6 +487,7 @@ async function bootstrapConsoleAccess() {
|
|
|
392
487
|
const cloud = await bridge.getCloudSyncStatus();
|
|
393
488
|
state.cloud = cloud;
|
|
394
489
|
renderCloudStatus();
|
|
490
|
+
void refreshUpdateStatus();
|
|
395
491
|
|
|
396
492
|
if (!cloud.loggedIn) {
|
|
397
493
|
applyAccessState(false);
|
|
@@ -408,6 +504,7 @@ async function bootstrapConsoleAccess() {
|
|
|
408
504
|
setStatus("已通过账号校验,连接控制台已解锁。", "success");
|
|
409
505
|
} catch (error) {
|
|
410
506
|
applyAccessState(false);
|
|
507
|
+
void refreshUpdateStatus();
|
|
411
508
|
setAuthStatus(`登录校验失败:${error.message}`, "error");
|
|
412
509
|
}
|
|
413
510
|
}
|
|
@@ -659,6 +756,73 @@ async function handleCloseApp() {
|
|
|
659
756
|
}
|
|
660
757
|
}
|
|
661
758
|
|
|
759
|
+
async function handleCopyUpdateCommand() {
|
|
760
|
+
const command = state.update?.upgradeCommand;
|
|
761
|
+
if (!command) {
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
try {
|
|
766
|
+
await navigator.clipboard.writeText(command);
|
|
767
|
+
setStatus("升级命令已复制。", "success");
|
|
768
|
+
} catch (error) {
|
|
769
|
+
setStatus(`复制失败:${error.message}`, "error");
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
async function handleAutoUpdate() {
|
|
774
|
+
const update = state.update;
|
|
775
|
+
if (!update?.hasUpdate) {
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const confirmed = window.confirm(
|
|
780
|
+
`确定现在自动更新到 ${update.latestVersion} 吗?程序会自动关闭,更新完成后重新打开。`,
|
|
781
|
+
);
|
|
782
|
+
if (!confirmed) {
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const summary = $("#updateSummary");
|
|
787
|
+
const detail = $("#updateDetail");
|
|
788
|
+
const checkButton = $("#checkUpdateButton");
|
|
789
|
+
const autoUpdateButton = $("#autoUpdateButton");
|
|
790
|
+
const copyButton = $("#copyUpdateCommandButton");
|
|
791
|
+
|
|
792
|
+
if (summary) {
|
|
793
|
+
summary.textContent = `正在自动更新到 ${update.latestVersion}...`;
|
|
794
|
+
}
|
|
795
|
+
if (detail) {
|
|
796
|
+
detail.textContent = "程序会先关闭,再自动安装最新 npm 包并重新打开控制台。";
|
|
797
|
+
}
|
|
798
|
+
if (checkButton) {
|
|
799
|
+
checkButton.disabled = true;
|
|
800
|
+
}
|
|
801
|
+
if (autoUpdateButton) {
|
|
802
|
+
autoUpdateButton.disabled = true;
|
|
803
|
+
}
|
|
804
|
+
if (copyButton) {
|
|
805
|
+
copyButton.disabled = true;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
try {
|
|
809
|
+
const result = await bridge.autoUpdate({ reopen: true });
|
|
810
|
+
setStatus(`已开始自动更新,日志:${result.logPath}`, "success");
|
|
811
|
+
} catch (error) {
|
|
812
|
+
if (checkButton) {
|
|
813
|
+
checkButton.disabled = false;
|
|
814
|
+
}
|
|
815
|
+
if (autoUpdateButton) {
|
|
816
|
+
autoUpdateButton.disabled = false;
|
|
817
|
+
}
|
|
818
|
+
if (copyButton) {
|
|
819
|
+
copyButton.disabled = false;
|
|
820
|
+
}
|
|
821
|
+
setStatus(`自动更新失败:${error.message}`, "error");
|
|
822
|
+
await refreshUpdateStatus(true);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
662
826
|
function bindEvents() {
|
|
663
827
|
$("#authForm").addEventListener("submit", (event) => {
|
|
664
828
|
event.preventDefault();
|
|
@@ -706,6 +870,11 @@ function bindEvents() {
|
|
|
706
870
|
handleOpenPath(state.paths.endpointStorePath, " 连接数据文件");
|
|
707
871
|
}
|
|
708
872
|
});
|
|
873
|
+
$("#checkUpdateButton").addEventListener("click", () => {
|
|
874
|
+
refreshUpdateStatus(true);
|
|
875
|
+
});
|
|
876
|
+
$("#autoUpdateButton").addEventListener("click", handleAutoUpdate);
|
|
877
|
+
$("#copyUpdateCommandButton").addEventListener("click", handleCopyUpdateCommand);
|
|
709
878
|
$("#closeAppButton").addEventListener("click", handleCloseApp);
|
|
710
879
|
window.addEventListener("keydown", (event) => {
|
|
711
880
|
if (event.key === "Escape" && !$("#endpointModal").hidden) {
|
package/src/renderer/styles.css
CHANGED
|
@@ -278,6 +278,57 @@ code {
|
|
|
278
278
|
margin-bottom: 6px;
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
.update-strip {
|
|
282
|
+
display: grid;
|
|
283
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
284
|
+
gap: 12px;
|
|
285
|
+
align-items: center;
|
|
286
|
+
margin-bottom: 6px;
|
|
287
|
+
padding: 10px 12px;
|
|
288
|
+
border-radius: var(--radius-md);
|
|
289
|
+
background: rgba(255, 255, 255, 0.56);
|
|
290
|
+
border: 1px solid rgba(47, 36, 30, 0.08);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.update-copy {
|
|
294
|
+
display: grid;
|
|
295
|
+
gap: 4px;
|
|
296
|
+
min-width: 0;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.update-copy strong {
|
|
300
|
+
display: block;
|
|
301
|
+
font-size: 0.92rem;
|
|
302
|
+
line-height: 1.25;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.update-detail {
|
|
306
|
+
color: var(--muted);
|
|
307
|
+
font-size: 0.8rem;
|
|
308
|
+
line-height: 1.35;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
.update-command {
|
|
312
|
+
display: inline-flex;
|
|
313
|
+
width: fit-content;
|
|
314
|
+
max-width: 100%;
|
|
315
|
+
overflow: hidden;
|
|
316
|
+
text-overflow: ellipsis;
|
|
317
|
+
white-space: nowrap;
|
|
318
|
+
padding: 0.2rem 0.46rem;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.update-command[hidden] {
|
|
322
|
+
display: none !important;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.update-actions {
|
|
326
|
+
display: flex;
|
|
327
|
+
flex-wrap: wrap;
|
|
328
|
+
justify-content: flex-end;
|
|
329
|
+
gap: 8px;
|
|
330
|
+
}
|
|
331
|
+
|
|
281
332
|
.metric-card {
|
|
282
333
|
padding: 14px;
|
|
283
334
|
border-radius: var(--radius-md);
|
|
@@ -552,9 +603,9 @@ input[readonly] {
|
|
|
552
603
|
}
|
|
553
604
|
|
|
554
605
|
.profiles-list {
|
|
555
|
-
display:
|
|
556
|
-
|
|
557
|
-
align-
|
|
606
|
+
display: flex;
|
|
607
|
+
flex-direction: column;
|
|
608
|
+
align-items: stretch;
|
|
558
609
|
flex: 1 1 auto;
|
|
559
610
|
gap: 14px;
|
|
560
611
|
min-height: 0;
|
|
@@ -593,8 +644,9 @@ input[readonly] {
|
|
|
593
644
|
display: grid;
|
|
594
645
|
grid-template-rows: auto auto;
|
|
595
646
|
gap: 14px;
|
|
596
|
-
min-height:
|
|
647
|
+
min-height: 176px;
|
|
597
648
|
padding: 18px;
|
|
649
|
+
box-sizing: border-box;
|
|
598
650
|
border-radius: var(--radius-md);
|
|
599
651
|
background: var(--panel-strong);
|
|
600
652
|
border: 1px solid rgba(47, 36, 30, 0.08);
|
|
@@ -649,22 +701,27 @@ input[readonly] {
|
|
|
649
701
|
|
|
650
702
|
.profile-meta {
|
|
651
703
|
display: grid;
|
|
652
|
-
grid-template-
|
|
653
|
-
gap:
|
|
704
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
705
|
+
gap: 10px 14px;
|
|
654
706
|
min-height: 0;
|
|
655
|
-
align-
|
|
707
|
+
align-items: start;
|
|
656
708
|
}
|
|
657
709
|
|
|
658
|
-
.profile-meta
|
|
710
|
+
.profile-meta-item {
|
|
659
711
|
display: grid;
|
|
660
712
|
gap: 4px;
|
|
713
|
+
min-width: 0;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
.profile-meta-label {
|
|
661
717
|
color: var(--muted);
|
|
662
718
|
font-size: 0.92rem;
|
|
663
|
-
min-width: 0;
|
|
664
719
|
}
|
|
665
720
|
|
|
666
|
-
.profile-meta
|
|
721
|
+
.profile-meta-value {
|
|
667
722
|
color: var(--text);
|
|
723
|
+
display: block;
|
|
724
|
+
min-width: 0;
|
|
668
725
|
white-space: nowrap;
|
|
669
726
|
overflow: hidden;
|
|
670
727
|
text-overflow: ellipsis;
|
|
@@ -906,6 +963,7 @@ input[readonly] {
|
|
|
906
963
|
}
|
|
907
964
|
|
|
908
965
|
.current-tools button,
|
|
966
|
+
.update-actions button,
|
|
909
967
|
.action-row button,
|
|
910
968
|
.account-actions-grid button,
|
|
911
969
|
.card-actions button {
|
|
@@ -923,6 +981,15 @@ input[readonly] {
|
|
|
923
981
|
gap: 8px;
|
|
924
982
|
}
|
|
925
983
|
|
|
984
|
+
.update-strip {
|
|
985
|
+
grid-template-columns: 1fr;
|
|
986
|
+
gap: 10px;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
.update-actions {
|
|
990
|
+
justify-content: flex-start;
|
|
991
|
+
}
|
|
992
|
+
|
|
926
993
|
.current-panel,
|
|
927
994
|
.account-panel,
|
|
928
995
|
.profiles-panel {
|
|
@@ -991,8 +1058,14 @@ input[readonly] {
|
|
|
991
1058
|
grid-template-columns: 1fr;
|
|
992
1059
|
}
|
|
993
1060
|
|
|
1061
|
+
.update-command {
|
|
1062
|
+
white-space: normal;
|
|
1063
|
+
overflow: visible;
|
|
1064
|
+
text-overflow: clip;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
994
1067
|
.profiles-list {
|
|
995
|
-
|
|
1068
|
+
gap: 12px;
|
|
996
1069
|
}
|
|
997
1070
|
|
|
998
1071
|
.profile-card {
|
|
@@ -1000,10 +1073,10 @@ input[readonly] {
|
|
|
1000
1073
|
}
|
|
1001
1074
|
|
|
1002
1075
|
.profile-meta {
|
|
1003
|
-
grid-template-
|
|
1076
|
+
grid-template-columns: 1fr;
|
|
1004
1077
|
}
|
|
1005
1078
|
|
|
1006
|
-
.profile-meta
|
|
1079
|
+
.profile-meta-value {
|
|
1007
1080
|
white-space: normal;
|
|
1008
1081
|
overflow: visible;
|
|
1009
1082
|
text-overflow: clip;
|
package/src/web/proxy-server.js
CHANGED
|
@@ -10,6 +10,9 @@ const httpsAgent = new https.Agent({
|
|
|
10
10
|
keepAlive: true,
|
|
11
11
|
});
|
|
12
12
|
|
|
13
|
+
const UPSTREAM_REQUEST_TIMEOUT_MS = 90000;
|
|
14
|
+
const WARM_UP_TIMEOUT_MS = 12000;
|
|
15
|
+
|
|
13
16
|
function sleep(ms) {
|
|
14
17
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
15
18
|
}
|
|
@@ -60,11 +63,13 @@ function sendUpstreamRequest({
|
|
|
60
63
|
agent: upstreamUrl.protocol === "https:" ? httpsAgent : httpAgent,
|
|
61
64
|
},
|
|
62
65
|
(upstreamResponse) => {
|
|
66
|
+
upstreamResponse.socket?.setNoDelay?.(true);
|
|
63
67
|
resolve(upstreamResponse);
|
|
64
68
|
},
|
|
65
69
|
);
|
|
66
70
|
|
|
67
|
-
upstreamRequest.
|
|
71
|
+
upstreamRequest.setNoDelay?.(true);
|
|
72
|
+
upstreamRequest.setTimeout(UPSTREAM_REQUEST_TIMEOUT_MS, () => {
|
|
68
73
|
upstreamRequest.destroy(new Error("上游请求超时。"));
|
|
69
74
|
});
|
|
70
75
|
|
|
@@ -135,7 +140,7 @@ async function warmUpCurrentTarget() {
|
|
|
135
140
|
},
|
|
136
141
|
);
|
|
137
142
|
|
|
138
|
-
warmRequest.setTimeout(
|
|
143
|
+
warmRequest.setTimeout(WARM_UP_TIMEOUT_MS, () => {
|
|
139
144
|
warmRequest.destroy(new Error("预热请求超时。"));
|
|
140
145
|
});
|
|
141
146
|
warmRequest.on("error", reject);
|
|
@@ -144,8 +149,11 @@ async function warmUpCurrentTarget() {
|
|
|
144
149
|
}
|
|
145
150
|
|
|
146
151
|
function createProxyServer() {
|
|
147
|
-
|
|
152
|
+
const server = http.createServer(async (req, res) => {
|
|
148
153
|
try {
|
|
154
|
+
req.socket?.setNoDelay?.(true);
|
|
155
|
+
res.socket?.setNoDelay?.(true);
|
|
156
|
+
|
|
149
157
|
if (req.url === "/__switcher/health") {
|
|
150
158
|
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
151
159
|
res.end(JSON.stringify({ ok: true, proxyBaseUrl: profileManager.proxyBaseUrl }));
|
|
@@ -189,6 +197,12 @@ function createProxyServer() {
|
|
|
189
197
|
);
|
|
190
198
|
}
|
|
191
199
|
});
|
|
200
|
+
|
|
201
|
+
server.keepAliveTimeout = 90_000;
|
|
202
|
+
server.headersTimeout = 95_000;
|
|
203
|
+
server.requestTimeout = 0;
|
|
204
|
+
|
|
205
|
+
return server;
|
|
192
206
|
}
|
|
193
207
|
|
|
194
208
|
module.exports = {
|
package/src/web/server.js
CHANGED
|
@@ -3,6 +3,7 @@ const { exec } = require("node:child_process");
|
|
|
3
3
|
const express = require("express");
|
|
4
4
|
const profileManager = require("../main/profile-manager");
|
|
5
5
|
const cloudSyncClient = require("../main/cloud-sync-client");
|
|
6
|
+
const updateService = require("../main/update-service");
|
|
6
7
|
const { createProxyServer, warmUpCurrentTarget } = require("./proxy-server");
|
|
7
8
|
|
|
8
9
|
function wrapAsync(handler) {
|
|
@@ -88,7 +89,23 @@ function createApp() {
|
|
|
88
89
|
"/api/endpoints/switch",
|
|
89
90
|
wrapAsync(async (req) => {
|
|
90
91
|
await ensureConsoleAccess();
|
|
91
|
-
|
|
92
|
+
const result = await profileManager.switchEndpoint(req.body.id);
|
|
93
|
+
if (result.switchedViaProxy) {
|
|
94
|
+
try {
|
|
95
|
+
await warmUpCurrentTarget();
|
|
96
|
+
return {
|
|
97
|
+
...result,
|
|
98
|
+
proxyWarmedUp: true,
|
|
99
|
+
};
|
|
100
|
+
} catch {
|
|
101
|
+
return {
|
|
102
|
+
...result,
|
|
103
|
+
proxyWarmedUp: false,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return result;
|
|
92
109
|
}),
|
|
93
110
|
);
|
|
94
111
|
|
|
@@ -179,6 +196,30 @@ function createApp() {
|
|
|
179
196
|
}),
|
|
180
197
|
);
|
|
181
198
|
|
|
199
|
+
app.get(
|
|
200
|
+
"/api/app/update-status",
|
|
201
|
+
wrapAsync(async (req) => {
|
|
202
|
+
return updateService.getUpdateStatus({
|
|
203
|
+
force: String(req.query.force || "").trim() === "1",
|
|
204
|
+
});
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
app.post(
|
|
209
|
+
"/api/app/auto-update",
|
|
210
|
+
wrapAsync(async (req) => {
|
|
211
|
+
const result = updateService.scheduleAutoUpdate({
|
|
212
|
+
reopen: req.body?.reopen !== false,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
setTimeout(() => {
|
|
216
|
+
process.emit("codex-switcher:shutdown");
|
|
217
|
+
}, 120);
|
|
218
|
+
|
|
219
|
+
return result;
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
|
|
182
223
|
app.post(
|
|
183
224
|
"/api/open-path",
|
|
184
225
|
wrapAsync(async (req) => {
|