codex-endpoint-switcher 1.2.0 → 1.3.1

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 CHANGED
@@ -48,6 +48,7 @@ codex-switcher open
48
48
  codex-switcher start
49
49
  codex-switcher status
50
50
  codex-switcher restart
51
+ codex-switcher stop
51
52
  codex-switcher sync-server
52
53
  codex-switcher install-access
53
54
  codex-switcher remove-access
@@ -59,6 +60,7 @@ codex-switcher remove-access
59
60
  - `start`:仅在后台启动本地网页服务
60
61
  - `status`:查看当前服务状态
61
62
  - `restart`:重启本地网页服务
63
+ - `stop`:关闭本地网页服务
62
64
  - `sync-server`:启动账号同步服务,服务端使用 SQLite 单文件数据库
63
65
  - `install-access`:创建桌面快捷方式和开机启动项
64
66
  - `remove-access`:移除桌面快捷方式和开机启动项
@@ -16,6 +16,7 @@ function printUsage() {
16
16
  codex-switcher start
17
17
  codex-switcher status
18
18
  codex-switcher restart
19
+ codex-switcher stop
19
20
  codex-switcher sync-server
20
21
  codex-switcher install-access
21
22
  codex-switcher remove-access
@@ -25,6 +26,7 @@ function printUsage() {
25
26
  start 仅在后台启动本地网页服务
26
27
  status 查看服务状态
27
28
  restart 重启本地网页服务
29
+ stop 关闭本地网页服务
28
30
  sync-server 启动账号同步服务(SQLite)
29
31
  install-access 创建桌面快捷方式与开机启动项
30
32
  remove-access 删除桌面快捷方式与开机启动项
@@ -82,7 +84,8 @@ async function main() {
82
84
  case "open":
83
85
  case "start":
84
86
  case "status":
85
- case "restart": {
87
+ case "restart":
88
+ case "stop": {
86
89
  await handleCommand(command === "start" ? "ensure" : command);
87
90
  return;
88
91
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-endpoint-switcher",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "用于切换 Codex URL 和 Key 的本地网页控制台与 npm CLI",
5
5
  "main": "src/main/main.js",
6
6
  "bin": {
@@ -58,13 +58,13 @@
58
58
  "x64"
59
59
  ]
60
60
  }
61
- ],
62
- "artifactName": "CodexProfileDesktop-${version}-portable.${ext}"
61
+ ]
63
62
  },
64
63
  "portable": {
65
64
  "artifactName": "CodexProfileDesktop-${version}-portable.${ext}"
66
65
  },
67
66
  "nsis": {
67
+ "artifactName": "CodexProfileDesktop-${version}-installer.${ext}",
68
68
  "oneClick": false,
69
69
  "allowToChangeInstallationDirectory": true
70
70
  }
@@ -33,7 +33,7 @@ function getDefaultServerUrl() {
33
33
  return normalizeServerUrl(
34
34
  process.env.CODEX_SYNC_SERVER_URL ||
35
35
  process.env.SYNC_SERVER_PUBLIC_URL ||
36
- "http://cd.xdo.icu:18245",
36
+ "https://codexqh.zhang-fy.top",
37
37
  );
38
38
  }
39
39
 
package/src/main/main.js CHANGED
@@ -91,6 +91,16 @@ function registerHandlers() {
91
91
  const openError = await shell.openPath(targetPath);
92
92
  return openError || "";
93
93
  });
94
+
95
+ ipcMain.handle("app:close", async () => {
96
+ setTimeout(() => {
97
+ app.quit();
98
+ }, 60);
99
+
100
+ return {
101
+ closing: true,
102
+ };
103
+ });
94
104
  }
95
105
 
96
106
  app.whenReady().then(() => {
@@ -52,4 +52,7 @@ contextBridge.exposeInMainWorld("codexDesktop", {
52
52
  openPath(targetPath) {
53
53
  return ipcRenderer.invoke("shell:openPath", targetPath);
54
54
  },
55
+ closeApp() {
56
+ return ipcRenderer.invoke("app:close");
57
+ },
55
58
  });
@@ -62,29 +62,6 @@
62
62
  </section>
63
63
 
64
64
  <div id="appShell" class="app-shell" hidden>
65
- <header class="workspace-bar panel">
66
- <div class="workspace-main">
67
- <div class="workspace-copy">
68
- <p class="eyebrow">CODEX ENDPOINT SWITCHER</p>
69
- <div class="workspace-title-row">
70
- <h1>连接控制台</h1>
71
- <div class="hero-badge workspace-badge" id="heroBadge">读取中</div>
72
- </div>
73
- <p class="workspace-text">
74
- 这里只管理 <code>备注</code>、<code>URL</code>、<code>Key</code>。切换时只更新
75
- Codex 当前使用的 <code>base_url</code> 和 <code>OPENAI_API_KEY</code>。
76
- </p>
77
- </div>
78
-
79
- <div class="workspace-actions">
80
- <button id="refreshButton" class="ghost-button">刷新状态</button>
81
- <button id="openCodexRootButton" class="ghost-button">打开 Codex 目录</button>
82
- <button id="openEndpointStoreButton" class="ghost-button">打开连接数据文件</button>
83
- </div>
84
- </div>
85
- <div id="statusBox" class="status-box info workspace-status">应用已启动,正在读取当前连接。</div>
86
- </header>
87
-
88
65
  <main class="content-grid">
89
66
  <div class="left-column">
90
67
  <section class="panel current-panel">
@@ -95,6 +72,18 @@
95
72
  </div>
96
73
  <span class="panel-tag" id="activeEndpointTag">未识别</span>
97
74
  </div>
75
+ <div class="current-summary">
76
+ <p class="current-copy">
77
+ 这里只管理 <code>备注</code>、<code>URL</code>、<code>Key</code>。切换时只更新
78
+ Codex 当前使用的 <code>base_url</code> 和 <code>OPENAI_API_KEY</code>。
79
+ </p>
80
+ </div>
81
+ <div class="current-tools">
82
+ <button id="refreshButton" class="ghost-button">刷新状态</button>
83
+ <button id="openCodexRootButton" class="ghost-button">打开 Codex 目录</button>
84
+ <button id="openEndpointStoreButton" class="ghost-button">打开连接数据文件</button>
85
+ <button id="closeAppButton" class="danger-button">关闭程序</button>
86
+ </div>
98
87
  <div class="current-grid">
99
88
  <article class="metric-card">
100
89
  <span class="metric-label">当前备注</span>
@@ -112,22 +101,6 @@
112
101
  <span class="metric-label">模型</span>
113
102
  <strong id="currentModel">-</strong>
114
103
  </article>
115
- <article class="metric-card metric-card-wide">
116
- <span class="metric-label">当前 URL</span>
117
- <strong id="currentBaseUrl">-</strong>
118
- </article>
119
- <article class="metric-card metric-card-wide">
120
- <span class="metric-label">热更新模式</span>
121
- <strong id="proxyModeStatus">未开启</strong>
122
- <p class="field-hint" id="proxyModeHint">
123
- 当前还是直连模式。开启后,后续同一 Codex 会话可在下一次请求时热更新到新 URL/Key。
124
- </p>
125
- <div class="action-row metric-actions">
126
- <button id="enableProxyModeButton" type="button" class="secondary-button">
127
- 开启热更新模式
128
- </button>
129
- </div>
130
- </article>
131
104
  </div>
132
105
  </section>
133
106
 
@@ -160,7 +133,7 @@
160
133
  <span class="metric-label">同步服务</span>
161
134
  <strong id="cloudServerStatus">自动连接</strong>
162
135
  </article>
163
- <article class="account-inline-card account-inline-card-wide">
136
+ <article class="account-inline-card">
164
137
  <span class="metric-label">同步记录</span>
165
138
  <strong id="cloudLastSyncStatus">还没有推送或拉取记录</strong>
166
139
  </article>
@@ -100,6 +100,11 @@ function createWebBridge() {
100
100
  body: JSON.stringify({ targetPath }),
101
101
  }).then(() => "");
102
102
  },
103
+ closeApp() {
104
+ return request("/api/app/close", {
105
+ method: "POST",
106
+ });
107
+ },
103
108
  };
104
109
  }
105
110
 
@@ -109,6 +114,15 @@ function $(selector) {
109
114
  return document.querySelector(selector);
110
115
  }
111
116
 
117
+ function setText(selector, value) {
118
+ const element = $(selector);
119
+ if (!element) {
120
+ return;
121
+ }
122
+
123
+ element.textContent = value;
124
+ }
125
+
112
126
  function formatDateTime(value) {
113
127
  if (!value) {
114
128
  return "-";
@@ -126,23 +140,18 @@ function formatDateTime(value) {
126
140
  }
127
141
 
128
142
  function setStatus(message, type = "info") {
129
- const statusBox = $("#statusBox");
130
- if (!statusBox) {
131
- return;
132
- }
133
-
134
- statusBox.textContent = message;
135
- statusBox.className = `status-box ${type}`;
143
+ void message;
144
+ void type;
136
145
  }
137
146
 
138
147
  function setAuthStatus(message, type = "info") {
139
- const statusBox = $("#authStatusBox");
140
- if (!statusBox) {
148
+ const authStatusElement = $("#authStatusBox");
149
+ if (!authStatusElement) {
141
150
  return;
142
151
  }
143
152
 
144
- statusBox.textContent = message;
145
- statusBox.className = `status-box ${type} auth-status`;
153
+ authStatusElement.textContent = message;
154
+ authStatusElement.className = `status-box ${type} auth-status`;
146
155
  }
147
156
 
148
157
  function syncFieldValue(selector, value) {
@@ -228,25 +237,11 @@ function renderCurrent() {
228
237
  return;
229
238
  }
230
239
 
231
- $("#activeEndpointTag").textContent = current.currentNote || "未识别";
232
- $("#currentNote").textContent = current.currentNote || "未识别";
233
- $("#currentProvider").textContent = current.provider || "-";
234
- $("#currentBaseUrl").textContent = current.currentUrl || "-";
235
- $("#currentKeyMasked").textContent = current.currentKeyMasked || "-";
236
- $("#currentModel").textContent = current.model || "-";
237
- $("#proxyModeStatus").textContent = current.proxyModeEnabled
238
- ? `已开启,固定代理:${current.proxyBaseUrl}`
239
- : "未开启";
240
- $("#proxyModeHint").textContent = current.proxyModeEnabled
241
- ? "当前 Codex 只要已经接入本地代理,后续同一会话内切换连接会在下一次请求时热更新。"
242
- : "当前还是直连模式。开启后需要把已打开的旧 Codex 会话重开一次;之后同一会话即可热更新。";
243
- $("#enableProxyModeButton").disabled = current.proxyModeEnabled;
244
- $("#enableProxyModeButton").textContent = current.proxyModeEnabled
245
- ? "热更新模式已开启"
246
- : "开启热更新模式";
247
- $("#heroBadge").textContent = current.currentNote
248
- ? `${current.proxyModeEnabled ? "热更新中" : "当前连接"}:${current.currentNote}`
249
- : "当前未匹配到已保存连接";
240
+ setText("#activeEndpointTag", current.currentNote || "未识别");
241
+ setText("#currentNote", current.currentNote || "未识别");
242
+ setText("#currentProvider", current.provider || "-");
243
+ setText("#currentKeyMasked", current.currentKeyMasked || "-");
244
+ setText("#currentModel", current.model || "-");
250
245
  }
251
246
 
252
247
  function renderCloudStatus() {
@@ -258,22 +253,12 @@ function renderCloudStatus() {
258
253
  syncFieldValue("#authUsername", cloud.username);
259
254
 
260
255
  const accountName = cloud.remoteUser || cloud.username || "-";
261
- const serverText = !cloud.serverUrl
262
- ? "未配置"
263
- : cloud.loggedIn
264
- ? "已自动连接"
265
- : cloud.lastError
266
- ? "连接异常"
267
- : "等待登录";
268
- const sessionText = !cloud.serverUrl
269
- ? "未配置服务器"
270
- : cloud.loggedIn
271
- ? cloud.tokenExpiresAt
272
- ? `已登录 · 到期 ${formatDateTime(cloud.tokenExpiresAt)}`
273
- : "已登录"
274
- : cloud.hasToken
275
- ? "登录态失效,请重新登录"
276
- : "未登录";
256
+ const serverText = cloud.loggedIn ? "已连接" : cloud.lastError ? "连接异常" : "等待登录";
257
+ const sessionText = cloud.loggedIn
258
+ ? "已登录"
259
+ : cloud.hasToken
260
+ ? "登录态失效,请重新登录"
261
+ : "未登录";
277
262
 
278
263
  const syncStatusParts = [];
279
264
  if (cloud.lastPushAt) {
@@ -483,29 +468,6 @@ async function handleSwitchEndpoint(id, note) {
483
468
  }
484
469
  }
485
470
 
486
- async function handleEnableProxyMode() {
487
- try {
488
- setStatus("正在切到热更新代理模式 ...", "info");
489
- const result = await bridge.enableProxyMode();
490
- await refreshDashboard(false);
491
- if (result.oneTimeRestartRequired) {
492
- setStatus(
493
- "热更新模式已开启。请把当前已经打开着的旧 Codex 会话重开一次;之后同一会话内即可热更新。",
494
- "success",
495
- );
496
- } else {
497
- setStatus("热更新模式已开启。当前会话后续请求可直接热更新到新连接。", "success");
498
- }
499
- } catch (error) {
500
- if (isUnauthorizedError(error)) {
501
- await handleAccessRevoked(error.message);
502
- return;
503
- }
504
-
505
- setStatus(`开启热更新模式失败:${error.message}`, "error");
506
- }
507
- }
508
-
509
471
  function getAuthFormPayload() {
510
472
  return {
511
473
  username: $("#authUsername").value.trim(),
@@ -684,6 +646,19 @@ async function handleOpenPath(targetPath, label) {
684
646
  }
685
647
  }
686
648
 
649
+ async function handleCloseApp() {
650
+ const confirmed = window.confirm("确定要关闭当前程序吗?");
651
+ if (!confirmed) {
652
+ return;
653
+ }
654
+
655
+ try {
656
+ await bridge.closeApp();
657
+ } catch {
658
+ // 服务关闭过程会中断当前页面请求,这里不再额外提示。
659
+ }
660
+ }
661
+
687
662
  function bindEvents() {
688
663
  $("#authForm").addEventListener("submit", (event) => {
689
664
  event.preventDefault();
@@ -712,7 +687,6 @@ function bindEvents() {
712
687
  resetForm();
713
688
  setStatus("已重置表单,可作为新连接保存。", "info");
714
689
  });
715
- $("#enableProxyModeButton").addEventListener("click", handleEnableProxyMode);
716
690
  $("#cloudLogoutButton").addEventListener("click", handleCloudLogout);
717
691
  $("#openSwitchAccountButton").addEventListener("click", handleCloudLogout);
718
692
  $("#cloudPushButton").addEventListener("click", handleCloudPush);
@@ -732,6 +706,7 @@ function bindEvents() {
732
706
  handleOpenPath(state.paths.endpointStorePath, " 连接数据文件");
733
707
  }
734
708
  });
709
+ $("#closeAppButton").addEventListener("click", handleCloseApp);
735
710
  window.addEventListener("keydown", (event) => {
736
711
  if (event.key === "Escape" && !$("#endpointModal").hidden) {
737
712
  closeEndpointModal();
@@ -61,13 +61,7 @@ code {
61
61
 
62
62
  .app-shell {
63
63
  height: 100%;
64
- display: grid;
65
- grid-template-rows: auto minmax(0, 1fr);
66
- gap: 16px;
67
- }
68
-
69
- .mobile-section-nav {
70
- display: none;
64
+ display: block;
71
65
  }
72
66
 
73
67
  .app-shell[hidden],
@@ -75,7 +69,6 @@ code {
75
69
  display: none;
76
70
  }
77
71
 
78
- .workspace-bar,
79
72
  .panel {
80
73
  backdrop-filter: blur(18px);
81
74
  -webkit-backdrop-filter: blur(18px);
@@ -93,7 +86,6 @@ code {
93
86
  font-weight: 700;
94
87
  }
95
88
 
96
- .workspace-copy h1,
97
89
  .panel-header h2,
98
90
  .profile-heading h3,
99
91
  .modal-header h2 {
@@ -143,67 +135,6 @@ code {
143
135
  margin-top: 2px;
144
136
  }
145
137
 
146
- .workspace-bar {
147
- display: grid;
148
- gap: 14px;
149
- padding: 18px 20px;
150
- border-radius: var(--radius-xl);
151
- background:
152
- linear-gradient(135deg, rgba(255, 252, 248, 0.94), rgba(247, 238, 230, 0.9)),
153
- var(--panel);
154
- }
155
-
156
- .workspace-main {
157
- display: grid;
158
- grid-template-columns: minmax(0, 1fr) auto;
159
- gap: 18px;
160
- align-items: start;
161
- }
162
-
163
- .workspace-copy h1 {
164
- font-size: clamp(1.5rem, 2.2vw, 2.2rem);
165
- line-height: 1.05;
166
- }
167
-
168
- .workspace-title-row {
169
- display: flex;
170
- align-items: center;
171
- justify-content: space-between;
172
- gap: 12px;
173
- }
174
-
175
- .workspace-text {
176
- max-width: 760px;
177
- margin: 10px 0 0;
178
- color: var(--muted);
179
- font-size: 0.94rem;
180
- line-height: 1.55;
181
- }
182
-
183
- .workspace-actions {
184
- display: flex;
185
- flex-wrap: wrap;
186
- justify-content: flex-end;
187
- gap: 10px;
188
- }
189
-
190
- .hero-badge {
191
- display: inline-flex;
192
- align-items: center;
193
- padding: 12px 18px;
194
- border-radius: 999px;
195
- background: rgba(255, 255, 255, 0.82);
196
- border: 1px solid rgba(47, 36, 30, 0.08);
197
- color: var(--text);
198
- font-weight: 700;
199
- }
200
-
201
- .workspace-badge {
202
- flex: 0 0 auto;
203
- padding: 10px 14px;
204
- white-space: nowrap;
205
- }
206
-
207
138
  .content-grid {
208
139
  display: grid;
209
140
  grid-template-columns: 0.84fr 1.16fr;
@@ -216,7 +147,7 @@ code {
216
147
  .left-column {
217
148
  min-height: 0;
218
149
  display: grid;
219
- grid-template-rows: minmax(0, 0.9fr) minmax(0, 1.1fr);
150
+ grid-template-rows: auto minmax(0, 1fr);
220
151
  gap: 20px;
221
152
  overflow: hidden;
222
153
  }
@@ -259,30 +190,28 @@ code {
259
190
  font-weight: 700;
260
191
  }
261
192
 
262
- .mobile-section-button {
263
- height: 40px;
264
- padding: 0 14px;
265
- border: 1px solid rgba(47, 36, 30, 0.1);
266
- border-radius: 999px;
267
- background: rgba(255, 255, 255, 0.68);
268
- color: var(--text);
269
- cursor: pointer;
270
- transition:
271
- background 0.18s ease,
272
- color 0.18s ease,
273
- box-shadow 0.18s ease;
193
+ .current-grid {
194
+ display: grid;
195
+ grid-template-columns: repeat(4, minmax(0, 1fr));
196
+ gap: 6px;
274
197
  }
275
198
 
276
- .mobile-section-button.is-active {
277
- background: linear-gradient(135deg, #d86135, #b1431d);
278
- color: #fff;
279
- box-shadow: 0 12px 28px rgba(179, 67, 29, 0.24);
199
+ .current-summary {
200
+ margin-bottom: 6px;
280
201
  }
281
202
 
282
- .current-grid {
203
+ .current-copy {
204
+ margin: 0;
205
+ color: var(--muted);
206
+ font-size: 0.84rem;
207
+ line-height: 1.4;
208
+ }
209
+
210
+ .current-tools {
283
211
  display: grid;
284
- grid-template-columns: repeat(4, minmax(0, 1fr));
212
+ grid-template-columns: repeat(3, minmax(0, 1fr));
285
213
  gap: 8px;
214
+ margin-bottom: 6px;
286
215
  }
287
216
 
288
217
  .metric-card {
@@ -300,30 +229,32 @@ code {
300
229
  }
301
230
 
302
231
  .current-panel .panel-header {
303
- margin-bottom: 8px;
232
+ margin-bottom: 6px;
233
+ }
234
+
235
+ .current-panel {
236
+ align-self: start;
237
+ display: grid;
238
+ align-content: start;
304
239
  }
305
240
 
306
241
  .current-panel .metric-card {
307
- padding: 6px 9px;
242
+ padding: 5px 8px;
308
243
  }
309
244
 
310
245
  .current-panel .metric-card strong {
311
- margin-top: 3px;
312
- font-size: 0.9rem;
246
+ margin-top: 2px;
247
+ font-size: 0.88rem;
313
248
  }
314
249
 
315
250
  .current-panel .metric-label {
316
- font-size: 0.78rem;
251
+ font-size: 0.76rem;
317
252
  }
318
253
 
319
254
  .current-panel .secondary-button {
320
255
  height: 32px;
321
256
  }
322
257
 
323
- .metric-card-wide {
324
- grid-column: 1 / -1;
325
- }
326
-
327
258
  .metric-label {
328
259
  color: var(--muted);
329
260
  font-size: 0.88rem;
@@ -452,7 +383,7 @@ input[readonly] {
452
383
 
453
384
  .account-inline-grid {
454
385
  display: grid;
455
- grid-template-columns: 180px minmax(0, 1fr);
386
+ grid-template-columns: repeat(2, minmax(0, 1fr));
456
387
  gap: 8px;
457
388
  }
458
389
 
@@ -558,16 +489,20 @@ input[readonly] {
558
489
 
559
490
  .profiles-list {
560
491
  display: grid;
492
+ grid-auto-rows: minmax(190px, auto);
493
+ align-content: start;
561
494
  flex: 1 1 auto;
562
495
  gap: 14px;
563
496
  min-height: 0;
564
497
  overflow: auto;
565
498
  padding-right: 6px;
499
+ padding-bottom: 6px;
566
500
  }
567
501
 
568
502
  .profiles-panel {
569
503
  display: flex;
570
504
  flex-direction: column;
505
+ min-height: 0;
571
506
  overflow: hidden;
572
507
  }
573
508
 
@@ -592,11 +527,14 @@ input[readonly] {
592
527
 
593
528
  .profile-card {
594
529
  display: grid;
530
+ grid-template-rows: auto auto;
595
531
  gap: 14px;
532
+ min-height: 100%;
596
533
  padding: 18px;
597
534
  border-radius: var(--radius-md);
598
535
  background: var(--panel-strong);
599
536
  border: 1px solid rgba(47, 36, 30, 0.08);
537
+ overflow: visible;
600
538
  }
601
539
 
602
540
  .profile-card.active {
@@ -621,6 +559,7 @@ input[readonly] {
621
559
  .profile-heading {
622
560
  display: grid;
623
561
  gap: 8px;
562
+ min-width: 0;
624
563
  }
625
564
 
626
565
  .profile-badges {
@@ -646,7 +585,10 @@ input[readonly] {
646
585
 
647
586
  .profile-meta {
648
587
  display: grid;
588
+ grid-template-rows: repeat(3, auto);
649
589
  gap: 8px;
590
+ min-height: 0;
591
+ align-content: start;
650
592
  }
651
593
 
652
594
  .profile-meta span {
@@ -654,10 +596,14 @@ input[readonly] {
654
596
  gap: 4px;
655
597
  color: var(--muted);
656
598
  font-size: 0.92rem;
599
+ min-width: 0;
657
600
  }
658
601
 
659
602
  .profile-meta strong {
660
603
  color: var(--text);
604
+ white-space: nowrap;
605
+ overflow: hidden;
606
+ text-overflow: ellipsis;
661
607
  }
662
608
 
663
609
  .profile-path {
@@ -700,12 +646,6 @@ input[readonly] {
700
646
  color: var(--error);
701
647
  }
702
648
 
703
- .workspace-status {
704
- min-height: 0;
705
- padding: 12px 14px;
706
- background: rgba(255, 255, 255, 0.72);
707
- }
708
-
709
649
  .account-panel {
710
650
  display: grid;
711
651
  gap: 7px;
@@ -787,10 +727,6 @@ input[readonly] {
787
727
  margin-top: 0;
788
728
  }
789
729
 
790
- #proxyModeHint {
791
- display: none;
792
- }
793
-
794
730
  @media (max-width: 1160px) {
795
731
  body {
796
732
  overflow: auto;
@@ -806,7 +742,6 @@ input[readonly] {
806
742
  height: auto;
807
743
  }
808
744
 
809
- .workspace-main,
810
745
  .content-grid {
811
746
  grid-template-columns: 1fr;
812
747
  }
@@ -816,12 +751,6 @@ input[readonly] {
816
751
  overflow: visible;
817
752
  }
818
753
 
819
- #proxyModeHint {
820
- display: block;
821
- }
822
-
823
- .workspace-title-row,
824
- .workspace-actions,
825
754
  .panel-header-actions {
826
755
  justify-content: flex-start;
827
756
  }
@@ -869,28 +798,21 @@ input[readonly] {
869
798
  }
870
799
 
871
800
  .auth-form-card,
872
- .workspace-bar,
873
801
  .panel {
874
802
  border-radius: 20px;
875
803
  padding: 14px;
876
804
  }
877
805
 
878
- .workspace-title-row {
879
- flex-direction: column;
880
- align-items: flex-start;
881
- }
882
-
883
- .workspace-text,
884
806
  .account-hint {
885
807
  font-size: 0.82rem;
886
808
  line-height: 1.4;
887
809
  }
888
810
 
889
- .workspace-actions {
890
- width: 100%;
811
+ .current-tools {
812
+ grid-template-columns: repeat(2, minmax(0, 1fr));
891
813
  }
892
814
 
893
- .workspace-actions button,
815
+ .current-tools button,
894
816
  .action-row button,
895
817
  .account-actions-grid button,
896
818
  .card-actions button {
@@ -902,6 +824,12 @@ input[readonly] {
902
824
  gap: 12px;
903
825
  }
904
826
 
827
+ .current-tools {
828
+ display: grid;
829
+ grid-template-columns: repeat(2, minmax(0, 1fr));
830
+ gap: 8px;
831
+ }
832
+
905
833
  .current-panel,
906
834
  .account-panel,
907
835
  .profiles-panel {
@@ -941,6 +869,7 @@ input[readonly] {
941
869
  .profile-card {
942
870
  gap: 10px;
943
871
  padding: 14px;
872
+ min-height: 0;
944
873
  }
945
874
 
946
875
  .profile-topline {
@@ -964,6 +893,30 @@ input[readonly] {
964
893
  }
965
894
  }
966
895
 
896
+ @media (max-width: 560px) {
897
+ .current-tools {
898
+ grid-template-columns: 1fr;
899
+ }
900
+
901
+ .profiles-list {
902
+ grid-auto-rows: auto;
903
+ }
904
+
905
+ .profile-card {
906
+ height: auto;
907
+ }
908
+
909
+ .profile-meta {
910
+ grid-template-rows: none;
911
+ }
912
+
913
+ .profile-meta strong {
914
+ white-space: normal;
915
+ overflow: visible;
916
+ text-overflow: clip;
917
+ }
918
+ }
919
+
967
920
  @media (max-height: 920px) and (min-width: 1161px) {
968
921
  .page-shell {
969
922
  padding: 12px;
@@ -974,18 +927,10 @@ input[readonly] {
974
927
  padding: 16px;
975
928
  }
976
929
 
977
- .workspace-bar {
978
- padding: 16px 18px;
979
- }
980
-
981
930
  .panel {
982
931
  padding: 16px;
983
932
  }
984
933
 
985
- .workspace-copy h1 {
986
- font-size: clamp(1.35rem, 1.8vw, 1.9rem);
987
- }
988
-
989
934
  .metric-card {
990
935
  padding: 12px;
991
936
  }
@@ -9,6 +9,7 @@ const serverEntryPath = path.join(__dirname, "server.js");
9
9
  const port = Number(process.env.PORT || 3186);
10
10
  const proxyPort = 3187;
11
11
  const healthUrl = `http://127.0.0.1:${port}/api/health`;
12
+ const closeUrl = `http://127.0.0.1:${port}/api/app/close`;
12
13
  const proxyHealthUrl = `http://127.0.0.1:${proxyPort}/__switcher/health`;
13
14
  const consoleUrl = `http://localhost:${port}`;
14
15
 
@@ -41,6 +42,44 @@ function requestJson(url, timeoutMs = 1200) {
41
42
  });
42
43
  }
43
44
 
45
+ function postJson(url, timeoutMs = 2000) {
46
+ return new Promise((resolve, reject) => {
47
+ const target = new URL(url);
48
+ const request = http.request(
49
+ {
50
+ hostname: target.hostname,
51
+ port: target.port,
52
+ path: target.pathname,
53
+ method: "POST",
54
+ headers: {
55
+ "Content-Type": "application/json",
56
+ },
57
+ },
58
+ (response) => {
59
+ let data = "";
60
+ response.setEncoding("utf8");
61
+ response.on("data", (chunk) => {
62
+ data += chunk;
63
+ });
64
+ response.on("end", () => {
65
+ try {
66
+ resolve(JSON.parse(data));
67
+ } catch (error) {
68
+ reject(error);
69
+ }
70
+ });
71
+ },
72
+ );
73
+
74
+ request.setTimeout(timeoutMs, () => {
75
+ request.destroy(new Error("请求超时"));
76
+ });
77
+
78
+ request.on("error", reject);
79
+ request.end("{}");
80
+ });
81
+ }
82
+
44
83
  /**
45
84
  * 检查本地网页服务是否已经可用。
46
85
  */
@@ -183,6 +222,65 @@ async function restartServer() {
183
222
  return ensureServerRunning();
184
223
  }
185
224
 
225
+ function killListeningPid(targetPort) {
226
+ return new Promise((resolve, reject) => {
227
+ exec(`cmd /c netstat -ano | findstr :${targetPort}`, (error, stdout) => {
228
+ if (error) {
229
+ resolve(false);
230
+ return;
231
+ }
232
+
233
+ const lines = stdout
234
+ .split(/\r?\n/)
235
+ .map((line) => line.trim())
236
+ .filter(Boolean);
237
+
238
+ const pids = [...new Set(lines
239
+ .map((line) => line.split(/\s+/))
240
+ .filter((parts) => parts[3] === "LISTENING")
241
+ .map((parts) => Number(parts[4]))
242
+ .filter((pid) => Number.isFinite(pid)))];
243
+
244
+ if (!pids.length) {
245
+ resolve(false);
246
+ return;
247
+ }
248
+
249
+ exec(`cmd /c taskkill ${pids.map((pid) => `/PID ${pid}`).join(" ")} /F`, (killError) => {
250
+ if (killError) {
251
+ reject(killError);
252
+ return;
253
+ }
254
+
255
+ resolve(true);
256
+ });
257
+ });
258
+ });
259
+ }
260
+
261
+ async function stopServer() {
262
+ if (await checkServerHealth()) {
263
+ try {
264
+ await postJson(closeUrl);
265
+ } catch {
266
+ // 服务正在退出时,可能来不及返回完整响应,这里继续走兜底检查。
267
+ }
268
+
269
+ await sleep(1000);
270
+ }
271
+
272
+ const webKilled = await killListeningPid(port);
273
+ const proxyKilled = await killListeningPid(proxyPort);
274
+
275
+ return {
276
+ stopped: true,
277
+ webKilled,
278
+ proxyKilled,
279
+ port,
280
+ proxyPort,
281
+ };
282
+ }
283
+
186
284
  async function handleCommand(command) {
187
285
  switch (command) {
188
286
  case "ensure":
@@ -227,8 +325,13 @@ async function handleCommand(command) {
227
325
  console.log(JSON.stringify({ ...result, restarted: true }, null, 2));
228
326
  return;
229
327
  }
328
+ case "stop": {
329
+ const result = await stopServer();
330
+ console.log(JSON.stringify(result, null, 2));
331
+ return;
332
+ }
230
333
  default:
231
- throw new Error("不支持的命令。可用命令:ensure、open、status、restart");
334
+ throw new Error("不支持的命令。可用命令:ensure、open、status、restart、stop");
232
335
  }
233
336
  }
234
337
 
@@ -245,6 +348,7 @@ module.exports = {
245
348
  ensureServerRunning,
246
349
  handleCommand,
247
350
  restartServer,
351
+ stopServer,
248
352
  openBrowser,
249
353
  runtimeDir,
250
354
  consoleUrl,
package/src/web/server.js CHANGED
@@ -166,6 +166,19 @@ function createApp() {
166
166
  }),
167
167
  );
168
168
 
169
+ app.post(
170
+ "/api/app/close",
171
+ wrapAsync(async () => {
172
+ setTimeout(() => {
173
+ process.emit("codex-switcher:shutdown");
174
+ }, 80);
175
+
176
+ return {
177
+ closing: true,
178
+ };
179
+ }),
180
+ );
181
+
169
182
  app.post(
170
183
  "/api/open-path",
171
184
  wrapAsync(async (req) => {
@@ -214,7 +227,7 @@ function startServer(options = {}) {
214
227
 
215
228
  proxyServer.listen(proxyPort);
216
229
 
217
- return {
230
+ const controller = {
218
231
  webServer,
219
232
  proxyServer,
220
233
  close(callback) {
@@ -243,6 +256,15 @@ function startServer(options = {}) {
243
256
  proxyServer.close((error) => done(error));
244
257
  },
245
258
  };
259
+
260
+ process.removeAllListeners("codex-switcher:shutdown");
261
+ process.on("codex-switcher:shutdown", () => {
262
+ controller.close(() => {
263
+ process.exit(0);
264
+ });
265
+ });
266
+
267
+ return controller;
246
268
  }
247
269
 
248
270
  if (require.main === module) {