ai-zero-token 2.0.3 → 2.0.4

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.
@@ -1,18 +1,28 @@
1
1
  #!/usr/bin/env node
2
- import { app as electronApp, BrowserWindow, dialog, shell } from "electron";
2
+ import { app as electronApp, BrowserWindow, Menu, Tray, clipboard, dialog, nativeImage, screen, shell } from "electron";
3
+ import { execFile } from "node:child_process";
3
4
  import { readFileSync } from "node:fs";
4
5
  import path from "node:path";
6
+ import { promisify } from "node:util";
5
7
  import { fileURLToPath } from "node:url";
6
8
  import { startServer } from "../server/index.js";
7
9
  let gatewayServer = null;
8
10
  let mainWindow = null;
11
+ let tray = null;
12
+ let accountPanelWindow = null;
9
13
  let isQuitting = false;
10
14
  let isRestarting = false;
15
+ let isAccountPanelBusy = false;
11
16
  let currentGatewayUrl = null;
12
17
  let currentAdminUrl = null;
13
18
  const desktopDir = path.dirname(fileURLToPath(import.meta.url));
14
19
  const appIconPath = path.resolve(desktopDir, "../../build/icon.png");
20
+ const trayIconPath = path.resolve(desktopDir, "../../build/tray-icon-template.png");
15
21
  const startupPageUrl = buildStartupPageUrl("\u6B63\u5728\u542F\u52A8\u672C\u5730\u7F51\u5173");
22
+ const accountPanelWidth = 420;
23
+ const accountPanelHeight = 640;
24
+ const execFileAsync = promisify(execFile);
25
+ const codexAppPath = "/Applications/Codex.app";
16
26
  electronApp.setName("AI Zero Token");
17
27
  function createBrowserUrl(host, port) {
18
28
  if (host === "0.0.0.0" || host === "::") {
@@ -53,11 +63,17 @@ function resolvePreferredGatewayParams() {
53
63
  function isAllowedAppUrl(targetUrl) {
54
64
  return Boolean(currentAdminUrl && isGatewayUrl(targetUrl, currentAdminUrl) || currentGatewayUrl && isGatewayUrl(targetUrl, currentGatewayUrl));
55
65
  }
66
+ function updateDesktopUrls(server) {
67
+ const gatewayUrl = createBrowserUrl(server.host, server.port);
68
+ currentGatewayUrl = gatewayUrl;
69
+ currentAdminUrl = resolveAdminUrl(gatewayUrl);
70
+ }
56
71
  async function restartGateway() {
57
72
  if (isRestarting) {
58
73
  return;
59
74
  }
60
75
  isRestarting = true;
76
+ void renderAccountPanel("\u6B63\u5728\u91CD\u542F\u672C\u5730\u7F51\u5173...");
61
77
  try {
62
78
  if (mainWindow && !mainWindow.isDestroyed()) {
63
79
  await mainWindow.loadURL(buildStartupPageUrl("\u6B63\u5728\u91CD\u542F\u672C\u5730\u7F51\u5173"));
@@ -66,13 +82,11 @@ async function restartGateway() {
66
82
  console.error("[desktop:gateway:restart-close]", error);
67
83
  });
68
84
  const server = await ensureGatewayServer();
69
- const gatewayUrl = createBrowserUrl(server.host, server.port);
70
- const adminUrl = resolveAdminUrl(gatewayUrl);
71
- currentGatewayUrl = gatewayUrl;
72
- currentAdminUrl = adminUrl;
85
+ updateDesktopUrls(server);
73
86
  if (mainWindow && !mainWindow.isDestroyed()) {
74
- await mainWindow.loadURL(adminUrl);
87
+ await mainWindow.loadURL(currentAdminUrl ?? createBrowserUrl(server.host, server.port));
75
88
  }
89
+ void renderAccountPanel();
76
90
  } catch (error) {
77
91
  const message = error instanceof Error ? error.message : String(error);
78
92
  console.error("[desktop:gateway:restart]", error);
@@ -80,10 +94,61 @@ async function restartGateway() {
80
94
  await mainWindow.loadURL(buildStartupPageUrl(`\u7F51\u5173\u91CD\u542F\u5931\u8D25\uFF1A${message}`)).catch(() => void 0);
81
95
  }
82
96
  dialog.showErrorBox("AI Zero Token \u7F51\u5173\u91CD\u542F\u5931\u8D25", message);
97
+ void renderAccountPanel(`\u7F51\u5173\u91CD\u542F\u5931\u8D25\uFF1A${message}`);
83
98
  } finally {
84
99
  isRestarting = false;
85
100
  }
86
101
  }
102
+ async function restartCodexApp() {
103
+ if (process.platform !== "darwin") {
104
+ throw new Error("\u5F53\u524D\u4EC5\u652F\u6301\u5728 macOS \u4E0A\u91CD\u542F Codex\u3002");
105
+ }
106
+ await execFileAsync("osascript", ["-e", 'tell application "Codex" to quit']).catch(() => void 0);
107
+ const gracefullyExited = await waitForCodexMainProcess(false, 6e3);
108
+ if (!gracefullyExited) {
109
+ await execFileAsync("pkill", ["-TERM", "-x", "Codex"]).catch(() => void 0);
110
+ await waitForCodexMainProcess(false, 3e3);
111
+ }
112
+ await execFileAsync("open", [codexAppPath]).catch(async () => {
113
+ await execFileAsync("open", ["-a", "Codex"]);
114
+ });
115
+ const started = await waitForCodexMainProcess(true, 8e3);
116
+ if (!started) {
117
+ throw new Error("Codex \u5DF2\u9000\u51FA\uFF0C\u4F46\u672A\u80FD\u786E\u8BA4\u91CD\u65B0\u542F\u52A8\u3002");
118
+ }
119
+ }
120
+ async function isCodexMainProcessRunning() {
121
+ try {
122
+ await execFileAsync("pgrep", ["-x", "Codex"]);
123
+ return true;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+ async function waitForCodexMainProcess(expectedRunning, timeoutMs) {
129
+ const deadline = Date.now() + timeoutMs;
130
+ while (Date.now() < deadline) {
131
+ if (await isCodexMainProcessRunning() === expectedRunning) {
132
+ return true;
133
+ }
134
+ await new Promise((resolve) => setTimeout(resolve, 250));
135
+ }
136
+ return false;
137
+ }
138
+ async function confirmRestartCodexApp() {
139
+ const options = {
140
+ type: "question",
141
+ buttons: ["\u91CD\u542F Codex", "\u7A0D\u540E\u624B\u52A8\u91CD\u542F"],
142
+ defaultId: 0,
143
+ cancelId: 1,
144
+ title: "\u91CD\u542F Codex \u4EE5\u751F\u6548",
145
+ message: "Codex \u8D26\u53F7\u5DF2\u5207\u6362\uFF0C\u662F\u5426\u73B0\u5728\u91CD\u542F Codex \u5BA2\u6237\u7AEF\uFF1F",
146
+ detail: "Codex \u901A\u5E38\u5728\u542F\u52A8\u65F6\u8BFB\u53D6\u672C\u673A auth.json\u3002\u91CD\u542F\u540E\u65B0\u8D26\u53F7\u4F1A\u7ACB\u5373\u751F\u6548\u3002"
147
+ };
148
+ const parent = accountPanelWindow ?? mainWindow;
149
+ const result = parent ? await dialog.showMessageBox(parent, options) : await dialog.showMessageBox(options);
150
+ return result.response === 0;
151
+ }
87
152
  function buildStartupPageUrl(subtitle) {
88
153
  const iconUrl = `data:image/png;base64,${readFileSync(appIconPath).toString("base64")}`;
89
154
  const html = `<!doctype html>
@@ -162,18 +227,554 @@ function buildStartupPageUrl(subtitle) {
162
227
  function escapeHtml(value) {
163
228
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
164
229
  }
230
+ function normalizeLabel(value, fallback) {
231
+ return value?.trim() || fallback;
232
+ }
233
+ function maskAccountLabel(value) {
234
+ const [name, domain] = value.split("@");
235
+ if (!domain) {
236
+ return value.length <= 10 ? value : `${value.slice(0, 6)}...${value.slice(-4)}`;
237
+ }
238
+ const maskedName = name.length <= 4 ? `${name.slice(0, 1)}***` : `${name.slice(0, 3)}***${name.slice(-2)}`;
239
+ return `${maskedName}@${domain}`;
240
+ }
241
+ function profileLabel(profile) {
242
+ if (!profile) {
243
+ return "\u672A\u9009\u62E9";
244
+ }
245
+ return normalizeLabel(profile.email, profile.accountId);
246
+ }
247
+ function maskedProfileLabel(profile) {
248
+ return maskAccountLabel(profileLabel(profile));
249
+ }
250
+ function codexLabel(config) {
251
+ if (!config?.codex.accountId && !config?.codex.email) {
252
+ return "\u672A\u5E94\u7528";
253
+ }
254
+ const codexProfile = config.profiles.find((profile) => profile.accountId === config.codex.accountId);
255
+ return maskedProfileLabel(codexProfile) || maskAccountLabel(normalizeLabel(config.codex.email, config.codex.accountId ?? "\u672A\u77E5\u8D26\u53F7"));
256
+ }
257
+ function primaryUsage(profile) {
258
+ return Math.max(0, Math.min(100, profile.quota?.primaryUsedPercent ?? 0));
259
+ }
260
+ function secondaryUsage(profile) {
261
+ return Math.max(0, Math.min(100, profile.quota?.secondaryUsedPercent ?? 0));
262
+ }
263
+ function isQuotaExhausted(profile) {
264
+ return primaryUsage(profile) >= 100 || secondaryUsage(profile) >= 100;
265
+ }
266
+ function isProfileInvalid(profile) {
267
+ return Boolean(
268
+ profile.authStatus?.state === "token_invalidated" || profile.authStatus?.state === "auth_error" || profile.expiresAt && profile.expiresAt <= Date.now()
269
+ );
270
+ }
271
+ function profileHealth(profile) {
272
+ if (profile.authStatus?.state === "token_invalidated") return { key: "invalid", label: "\u767B\u5F55\u5931\u6548" };
273
+ if (profile.authStatus?.state === "auth_error") return { key: "invalid", label: "\u8BA4\u8BC1\u5F02\u5E38" };
274
+ if (profile.expiresAt && profile.expiresAt <= Date.now()) return { key: "expired", label: "\u5DF2\u8FC7\u671F" };
275
+ if (isQuotaExhausted(profile)) return { key: "exhausted", label: "\u989D\u5EA6\u8017\u5C3D" };
276
+ if (primaryUsage(profile) >= 75 || secondaryUsage(profile) >= 75) return { key: "warning", label: "\u504F\u4F4E" };
277
+ return { key: "healthy", label: "\u5065\u5EB7" };
278
+ }
279
+ function timestampToMillis(value) {
280
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
281
+ return void 0;
282
+ }
283
+ return value < 1e12 ? value * 1e3 : value;
284
+ }
285
+ function compactDateTime(value) {
286
+ if (!value) {
287
+ return "-";
288
+ }
289
+ return new Intl.DateTimeFormat("zh-CN", {
290
+ month: "2-digit",
291
+ day: "2-digit",
292
+ hour: "2-digit",
293
+ minute: "2-digit"
294
+ }).format(new Date(value));
295
+ }
296
+ function resetWindowLabel(profile, slot) {
297
+ const minutes = slot === "primary" ? profile.quota?.primaryWindowMinutes : profile.quota?.secondaryWindowMinutes;
298
+ if (!minutes) {
299
+ return slot === "primary" ? "\u4E3B\u989D\u5EA6" : "\u5468\u989D\u5EA6";
300
+ }
301
+ if (minutes < 60) return `${minutes} \u5206\u949F\u91CD\u7F6E`;
302
+ if (minutes < 60 * 24) return `${Math.round(minutes / 60)} \u5C0F\u65F6\u91CD\u7F6E`;
303
+ return `${Math.round(minutes / 60 / 24)} \u5929\u91CD\u7F6E`;
304
+ }
305
+ function resetTimeLabel(profile, slot) {
306
+ const direct = slot === "primary" ? profile.quota?.primaryResetAt : profile.quota?.secondaryResetAt;
307
+ const after = slot === "primary" ? profile.quota?.primaryResetAfterSeconds : profile.quota?.secondaryResetAfterSeconds;
308
+ const directMillis = timestampToMillis(direct);
309
+ if (directMillis) {
310
+ return compactDateTime(directMillis);
311
+ }
312
+ if (typeof after === "number" && after > 0) {
313
+ const capturedAt = timestampToMillis(profile.quota?.capturedAt) || Date.now();
314
+ return compactDateTime(capturedAt + after * 1e3);
315
+ }
316
+ return "-";
317
+ }
318
+ function accountPanelProfiles(config, tab) {
319
+ const codexAccountId = config.codex.accountId;
320
+ const profiles = [...config.profiles].sort((left, right) => {
321
+ const leftCurrent = Number(left.isActive || left.accountId === codexAccountId);
322
+ const rightCurrent = Number(right.isActive || right.accountId === codexAccountId);
323
+ if (leftCurrent !== rightCurrent) return rightCurrent - leftCurrent;
324
+ const leftInvalid = Number(isProfileInvalid(left) || isQuotaExhausted(left));
325
+ const rightInvalid = Number(isProfileInvalid(right) || isQuotaExhausted(right));
326
+ if (leftInvalid !== rightInvalid) return leftInvalid - rightInvalid;
327
+ return primaryUsage(left) - primaryUsage(right);
328
+ });
329
+ if (tab === "all") {
330
+ return profiles;
331
+ }
332
+ if (tab === "recent") {
333
+ return profiles.slice(0, 12);
334
+ }
335
+ return profiles.filter((profile) => !isProfileInvalid(profile) && !isQuotaExhausted(profile)).slice(0, 12);
336
+ }
337
+ function createTrayIcon() {
338
+ if (process.platform !== "darwin") {
339
+ return nativeImage.createFromPath(appIconPath).resize({ width: 18, height: 18 });
340
+ }
341
+ const icon = nativeImage.createFromPath(trayIconPath).resize({ width: 18, height: 18 });
342
+ icon.setTemplateImage(true);
343
+ return icon;
344
+ }
345
+ function ensureTray() {
346
+ if (tray) {
347
+ return;
348
+ }
349
+ tray = new Tray(createTrayIcon());
350
+ tray.setToolTip("AI Zero Token");
351
+ tray.on("click", () => {
352
+ void toggleAccountPanel();
353
+ });
354
+ tray.on("right-click", () => {
355
+ tray?.popUpContextMenu(Menu.buildFromTemplate([
356
+ { label: "\u6253\u5F00\u5FEB\u901F\u5207\u6362", click: () => void showAccountPanel() },
357
+ { label: "\u6253\u5F00\u63A7\u5236\u53F0", click: () => focusMainWindow() },
358
+ { type: "separator" },
359
+ { label: "\u9000\u51FA", role: "quit" }
360
+ ]));
361
+ });
362
+ }
363
+ function getAccountPanelPosition() {
364
+ const trayBounds = tray?.getBounds();
365
+ const display = trayBounds ? screen.getDisplayNearestPoint({ x: trayBounds.x, y: trayBounds.y }) : screen.getPrimaryDisplay();
366
+ const workArea = display.workArea;
367
+ const x = Math.min(
368
+ Math.max(Math.round((trayBounds?.x ?? workArea.x + workArea.width - accountPanelWidth) + (trayBounds?.width ?? 0) / 2 - accountPanelWidth / 2), workArea.x + 12),
369
+ workArea.x + workArea.width - accountPanelWidth - 12
370
+ );
371
+ const y = process.platform === "darwin" ? Math.max((trayBounds?.y ?? workArea.y) + (trayBounds?.height ?? 0) + 8, workArea.y + 12) : Math.min((trayBounds?.y ?? workArea.y) - accountPanelHeight - 8, workArea.y + workArea.height - accountPanelHeight - 12);
372
+ return { x, y: Math.max(y, workArea.y + 12) };
373
+ }
374
+ function ensureAccountPanelWindow() {
375
+ if (accountPanelWindow && !accountPanelWindow.isDestroyed()) {
376
+ return accountPanelWindow;
377
+ }
378
+ accountPanelWindow = new BrowserWindow({
379
+ width: accountPanelWidth,
380
+ height: accountPanelHeight,
381
+ show: false,
382
+ frame: false,
383
+ resizable: false,
384
+ movable: false,
385
+ fullscreenable: false,
386
+ skipTaskbar: true,
387
+ alwaysOnTop: true,
388
+ backgroundColor: "#00000000",
389
+ transparent: process.platform === "darwin",
390
+ webPreferences: {
391
+ contextIsolation: true,
392
+ nodeIntegration: false,
393
+ sandbox: true
394
+ }
395
+ });
396
+ accountPanelWindow.webContents.setWindowOpenHandler(({ url }) => {
397
+ void handleAccountPanelUrl(url);
398
+ return { action: "deny" };
399
+ });
400
+ accountPanelWindow.webContents.on("will-navigate", (event, url) => {
401
+ if (url.startsWith("data:")) {
402
+ return;
403
+ }
404
+ event.preventDefault();
405
+ void handleAccountPanelUrl(url);
406
+ });
407
+ accountPanelWindow.on("blur", () => {
408
+ if (!isAccountPanelBusy) {
409
+ accountPanelWindow?.hide();
410
+ }
411
+ });
412
+ accountPanelWindow.on("closed", () => {
413
+ accountPanelWindow = null;
414
+ });
415
+ return accountPanelWindow;
416
+ }
417
+ async function showAccountPanel() {
418
+ ensureTray();
419
+ const panel = ensureAccountPanelWindow();
420
+ panel.setBounds({ ...getAccountPanelPosition(), width: accountPanelWidth, height: accountPanelHeight });
421
+ await renderAccountPanel();
422
+ panel.show();
423
+ panel.focus();
424
+ }
425
+ async function toggleAccountPanel() {
426
+ if (accountPanelWindow?.isVisible()) {
427
+ accountPanelWindow.hide();
428
+ return;
429
+ }
430
+ await showAccountPanel();
431
+ }
432
+ async function fetchAdminConfig() {
433
+ const server = await ensureGatewayServer();
434
+ updateDesktopUrls(server);
435
+ const response = await fetch(`${currentGatewayUrl}/_gateway/admin/config`);
436
+ if (!response.ok) {
437
+ throw new Error(`HTTP ${response.status}`);
438
+ }
439
+ return await response.json();
440
+ }
441
+ async function postGatewayAction(pathname, body) {
442
+ const server = await ensureGatewayServer();
443
+ updateDesktopUrls(server);
444
+ const response = await fetch(`${currentGatewayUrl}${pathname}`, {
445
+ method: "POST",
446
+ headers: body ? { "Content-Type": "application/json" } : void 0,
447
+ body: body ? JSON.stringify(body) : void 0
448
+ });
449
+ const data = await response.json();
450
+ if (!response.ok) {
451
+ const message = "error" in data ? data.error?.message : void 0;
452
+ throw new Error(message || `HTTP ${response.status}`);
453
+ }
454
+ return "config" in data ? data.config : data;
455
+ }
456
+ async function applyAccountPanelProfile(profileId, action) {
457
+ if (isAccountPanelBusy) {
458
+ return;
459
+ }
460
+ isAccountPanelBusy = true;
461
+ await renderAccountPanel("\u6B63\u5728\u5E94\u7528\u8D26\u53F7...");
462
+ try {
463
+ if (action === "gateway" || action === "both") {
464
+ await postGatewayAction("/_gateway/admin/profiles/activate", { profileId });
465
+ }
466
+ if (action === "codex" || action === "both") {
467
+ await postGatewayAction("/_gateway/admin/codex/apply", { profileId });
468
+ }
469
+ await renderAccountPanel(action === "both" ? "\u5DF2\u540C\u65F6\u5E94\u7528\u5230\u7F51\u5173\u548C Codex\u3002" : action === "gateway" ? "\u5DF2\u5E94\u7528\u5230\u7F51\u5173\u3002" : "\u5DF2\u5E94\u7528\u5230\u672C\u673A Codex\u3002");
470
+ if (action === "codex" || action === "both") {
471
+ const shouldRestart = await confirmRestartCodexApp();
472
+ if (shouldRestart) {
473
+ await renderAccountPanel("\u6B63\u5728\u91CD\u542F Codex...");
474
+ await restartCodexApp();
475
+ await renderAccountPanel("Codex \u5DF2\u91CD\u542F\u3002");
476
+ } else {
477
+ await renderAccountPanel("\u5DF2\u5E94\u7528\u5230 Codex\uFF0C\u91CD\u542F\u5BA2\u6237\u7AEF\u540E\u751F\u6548\u3002");
478
+ }
479
+ }
480
+ } catch (error) {
481
+ const message = error instanceof Error ? error.message : String(error);
482
+ await renderAccountPanel(message);
483
+ } finally {
484
+ isAccountPanelBusy = false;
485
+ }
486
+ }
487
+ async function handleAccountPanelUrl(url) {
488
+ if (url === "azt-action://refresh") {
489
+ isAccountPanelBusy = true;
490
+ await renderAccountPanel("\u6B63\u5728\u540C\u6B65\u989D\u5EA6...");
491
+ try {
492
+ await postGatewayAction("/_gateway/admin/runtime-refresh", { staleOnly: false });
493
+ await renderAccountPanel("\u989D\u5EA6\u5DF2\u540C\u6B65\u3002");
494
+ } catch (error) {
495
+ const message = error instanceof Error ? error.message : String(error);
496
+ await renderAccountPanel(`\u540C\u6B65\u5931\u8D25\uFF1A${message}`);
497
+ } finally {
498
+ isAccountPanelBusy = false;
499
+ }
500
+ return;
501
+ }
502
+ if (url === "azt-action://open-console") {
503
+ accountPanelWindow?.hide();
504
+ focusMainWindow();
505
+ return;
506
+ }
507
+ if (url === "azt-action://copy-base-url") {
508
+ const config = await fetchAdminConfig();
509
+ clipboard.writeText(config.baseUrl);
510
+ await renderAccountPanel("Base URL \u5DF2\u590D\u5236\u3002");
511
+ return;
512
+ }
513
+ if (url === "azt-action://restart-gateway") {
514
+ await restartGateway();
515
+ return;
516
+ }
517
+ if (!url.startsWith("azt-action://apply")) {
518
+ return;
519
+ }
520
+ const parsed = new URL(url);
521
+ const profileId = parsed.searchParams.get("profileId");
522
+ const action = parsed.searchParams.get("target");
523
+ if (!profileId || action !== "gateway" && action !== "codex" && action !== "both") {
524
+ return;
525
+ }
526
+ await applyAccountPanelProfile(profileId, action);
527
+ }
528
+ async function renderAccountPanel(message = "") {
529
+ if (!accountPanelWindow || accountPanelWindow.isDestroyed()) {
530
+ return;
531
+ }
532
+ try {
533
+ const config = await fetchAdminConfig();
534
+ await accountPanelWindow.loadURL(buildAccountPanelPage(config, message));
535
+ } catch (error) {
536
+ const detail = error instanceof Error ? error.message : String(error);
537
+ await accountPanelWindow.loadURL(buildAccountPanelPage(null, message || `\u72B6\u6001\u8BFB\u53D6\u5931\u8D25\uFF1A${detail}`));
538
+ }
539
+ }
540
+ function buildAccountPanelPage(config, message) {
541
+ const iconUrl = `data:image/png;base64,${readFileSync(appIconPath).toString("base64")}`;
542
+ const activeLabel = maskedProfileLabel(config?.profile);
543
+ const activeCodexLabel = codexLabel(config);
544
+ const totalCount = config?.profiles.length ?? 0;
545
+ const usableCount = config?.profiles.filter((profile) => !isProfileInvalid(profile) && !isQuotaExhausted(profile)).length ?? 0;
546
+ const problemCount = Math.max(0, totalCount - usableCount);
547
+ const profiles = config ? accountPanelProfiles(config, "all") : [];
548
+ const codexAccountId = config?.codex.accountId;
549
+ const boot = JSON.stringify({
550
+ message,
551
+ profiles: profiles.map((profile) => {
552
+ const health = profileHealth(profile);
553
+ const quotaRemaining = Math.max(0, Math.round(100 - primaryUsage(profile)));
554
+ const isGateway = profile.isActive;
555
+ const isCodex = codexAccountId === profile.accountId;
556
+ return {
557
+ profileId: profile.profileId,
558
+ accountId: profile.accountId,
559
+ label: maskedProfileLabel(profile),
560
+ searchLabel: profileLabel(profile),
561
+ plan: profile.quota?.planType ?? "unknown",
562
+ quotaRemaining,
563
+ meta: [profile.quota?.planType ?? "unknown", `${quotaRemaining}%`, isGateway ? "\u7F51\u5173" : "", isCodex ? "Codex" : ""].filter(Boolean).join(" \xB7 "),
564
+ initial: profileLabel(profile).slice(0, 1).toUpperCase(),
565
+ primaryResetLabel: `${resetWindowLabel(profile, "primary")} ${resetTimeLabel(profile, "primary")}`,
566
+ secondaryResetLabel: `${resetWindowLabel(profile, "secondary")} ${resetTimeLabel(profile, "secondary")}`,
567
+ healthKey: health.key,
568
+ healthLabel: health.label,
569
+ isGateway,
570
+ isCodex,
571
+ isUsable: !isProfileInvalid(profile) && !isQuotaExhausted(profile)
572
+ };
573
+ })
574
+ }).replaceAll("</", "<\\/");
575
+ const html = `<!doctype html>
576
+ <html lang="zh-CN">
577
+ <head>
578
+ <meta charset="UTF-8" />
579
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
580
+ <style>
581
+ :root { color-scheme: light; font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "Microsoft YaHei", Arial, sans-serif; }
582
+ * { box-sizing: border-box; }
583
+ html, body { width: 100%; height: 100%; margin: 0; background: transparent; color: #101828; overflow: hidden; }
584
+ body { padding: 7px; }
585
+ a { color: inherit; text-decoration: none; }
586
+ .panel { height: 100%; border: 1px solid rgba(208, 213, 221, 0.7); border-radius: 18px; background: rgba(252, 252, 253, 0.97); box-shadow: 0 10px 24px rgba(16, 24, 40, 0.08); overflow: hidden; }
587
+ .inner { height: 100%; padding: 12px; display: grid; grid-template-rows: auto auto auto auto 1fr auto auto auto; gap: 8px; }
588
+ .hero { position: relative; display: flex; align-items: center; gap: 8px; min-height: 46px; padding: 9px 10px; border: 1px solid #e6f0f4; border-radius: 13px; background: linear-gradient(135deg, rgba(255,255,255,0.98), rgba(240,249,255,0.88)); color: #101828; overflow: hidden; }
589
+ .hero::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; background: linear-gradient(90deg, #14b8a6, #3b82f6, #f59e0b); }
590
+ .mark { position: relative; width: 26px; height: 26px; border-radius: 7px; background: #fff; box-shadow: 0 1px 4px rgba(16, 24, 40, 0.08); }
591
+ .hero-title { position: relative; font-size: 13px; font-weight: 800; line-height: 1.1; }
592
+ .hero-sub { position: relative; margin-top: 2px; color: #667085; font-size: 10px; font-weight: 650; }
593
+ .run { position: relative; margin-left: auto; display: inline-flex; align-items: center; gap: 5px; padding: 5px 9px; border-radius: 999px; background: #dcfce7; color: #15803d; font-size: 10.5px; font-weight: 800; }
594
+ .dot { width: 6px; height: 6px; border-radius: 999px; background: #22c55e; }
595
+ .current { display: grid; gap: 5px; padding: 8px 10px; border: 1px solid #e2e8f0; border-radius: 12px; background: linear-gradient(180deg, #fff, #f8fafc); }
596
+ .status-card { min-width: 0; display: grid; grid-template-columns: 48px 1fr auto; align-items: center; gap: 8px; padding: 0; border: 0; border-radius: 0; background: transparent; }
597
+ .label { color: #667085; font-size: 10.5px; font-weight: 800; }
598
+ .value { margin-top: 0; overflow: hidden; color: #101828; font-size: 12px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; }
599
+ .subvalue { margin-top: 0; overflow: hidden; color: #667085; font-size: 10px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
600
+ .search { height: 36px; display: flex; align-items: center; gap: 8px; padding: 0 10px; border: 1px solid #cbd5e1; border-radius: 12px; background: #fff; }
601
+ .search svg { flex: 0 0 auto; color: #0ea5e9; }
602
+ .search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: #101828; font: inherit; font-size: 12px; font-weight: 700; }
603
+ .search input::placeholder { color: #667085; }
604
+ .kbd { padding: 3px 8px; border: 1px solid #d0d5dd; border-radius: 7px; background: #f8fafc; color: #667085; font-size: 10px; font-weight: 800; }
605
+ .tabs { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 3px; padding: 3px; border-radius: 11px; background: #f2f4f7; }
606
+ .tab { height: 26px; border: 0; display: grid; place-items: center; border-radius: 8px; color: #667085; font-size: 10.5px; font-weight: 800; cursor: pointer; }
607
+ .tab.active { background: #e0f2fe; color: #0369a1; box-shadow: inset 0 0 0 1px #bae6fd; }
608
+ .section-title { display: none; }
609
+ .list { min-height: 0; display: grid; align-content: start; gap: 7px; overflow-y: auto; padding-right: 4px; }
610
+ .list::-webkit-scrollbar { width: 5px; }
611
+ .list::-webkit-scrollbar-thumb { border-radius: 999px; background: #98a2b3; }
612
+ .account { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-rows: auto auto auto; row-gap: 7px; padding: 10px 10px 9px; border: 1px solid #e5e7eb; border-radius: 13px; background: linear-gradient(180deg, #ffffff, #fbfdff); box-shadow: inset 3px 0 0 var(--accent, #3b82f6); }
613
+ .account-head { min-width: 0; display: flex; align-items: center; gap: 7px; padding-right: 72px; }
614
+ .account-title { overflow: hidden; color: #111827; font-size: 12px; font-weight: 850; text-overflow: ellipsis; white-space: nowrap; }
615
+ .plan-chip { flex: 0 0 auto; padding: 2px 6px; border-radius: 999px; background: color-mix(in srgb, var(--accent, #3b82f6) 10%, white); color: var(--accent, #2563eb); font-size: 9px; font-weight: 850; text-transform: lowercase; }
616
+ .status-row { grid-column: 1 / 3; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 8px; }
617
+ .quota-text { color: #475467; font-size: 10px; font-weight: 850; }
618
+ .quota { min-width: 92px; height: 5px; border-radius: 999px; background: #eef2f6; overflow: hidden; }
619
+ .quota span { display: block; height: 100%; border-radius: inherit; background: var(--accent, #3b82f6); }
620
+ .health { width: max-content; min-width: 40px; padding: 3px 8px; border-radius: 999px; text-align: center; font-size: 9.5px; font-weight: 850; }
621
+ .health.healthy { background: #dcfce7; color: #15803d; }
622
+ .health.warning, .health.exhausted { background: #fef3c7; color: #b45309; }
623
+ .health.invalid, .health.expired { background: #fee2e2; color: #b42318; }
624
+ .badges { position: absolute; top: 9px; right: 10px; display: flex; justify-content: flex-end; gap: 4px; }
625
+ .corner-badge { height: 18px; min-width: 34px; display: grid; place-items: center; padding: 0 7px; border-radius: 999px; font-size: 9px; font-weight: 850; }
626
+ .corner-badge.api { background: #dcfce7; color: #15803d; }
627
+ .corner-badge.codex { background: #e0e7ff; color: #3730a3; }
628
+ .account-foot { grid-column: 1 / 3; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
629
+ .reset-times { min-width: 0; display: flex; align-items: center; gap: 6px; overflow: hidden; color: #667085; font-size: 9.6px; font-weight: 750; white-space: nowrap; }
630
+ .reset-pill { padding: 3px 6px; border-radius: 8px; background: #f8fafc; }
631
+ .actions { flex: 0 0 auto; display: flex; gap: 4px; justify-content: flex-end; }
632
+ .btn { height: 21px; min-width: 42px; display: grid; place-items: center; padding: 0 8px; border-radius: 999px; background: #f2f4f7; color: #667085; font-size: 9.5px; font-weight: 850; }
633
+ .btn.gateway { background: #ecfdf3; color: #15803d; }
634
+ .btn.codex { background: #eef4ff; color: #3538cd; }
635
+ .btn.disabled { pointer-events: none; opacity: 0.48; }
636
+ .summary, .problems { display: flex; align-items: center; justify-content: space-between; height: 28px; padding: 0 10px; border: 1px solid #eaecf0; border-radius: 10px; background: #f8fafc; color: #344054; font-size: 10.5px; font-weight: 800; }
637
+ .problems { background: #fff; }
638
+ .message { min-height: 14px; color: #667085; font-size: 10px; font-weight: 800; }
639
+ .footer { display: grid; grid-template-columns: 1fr 1.12fr 1fr 1fr; gap: 6px; padding-top: 1px; border-top: 1px solid #eaecf0; }
640
+ .footer a { height: 32px; display: grid; place-items: center; border: 1px solid #d0d5dd; border-radius: 10px; background: #fff; color: #344054; font-size: 10.5px; font-weight: 800; }
641
+ .footer a.primary { border-color: #0ea5e9; background: #0ea5e9; color: #fff; }
642
+ .empty { padding: 24px; border: 1px dashed #d0d5dd; border-radius: 13px; color: #667085; text-align: center; font-size: 12px; font-weight: 700; }
643
+ </style>
644
+ </head>
645
+ <body>
646
+ <div class="panel">
647
+ <div class="inner">
648
+ <header class="hero">
649
+ <img class="mark" src="${iconUrl}" alt="" />
650
+ <div>
651
+ <div class="hero-title">AI Zero Token</div>
652
+ <div class="hero-sub">\u5FEB\u901F\u5207\u6362\u8D26\u53F7</div>
653
+ </div>
654
+ <div class="run"><span class="dot"></span>${config?.status.loggedIn ? "\u8FD0\u884C\u4E2D" : "\u5F85\u767B\u5F55"}</div>
655
+ </header>
656
+
657
+ <section class="current">
658
+ <div class="status-card">
659
+ <div class="label">\u7F51\u5173</div>
660
+ <div class="value">${escapeHtml(activeLabel)}</div>
661
+ <div class="subvalue">${escapeHtml(config?.baseUrl ? "API" : "\u542F\u52A8\u4E2D")}</div>
662
+ </div>
663
+ <div class="status-card">
664
+ <div class="label">Codex</div>
665
+ <div class="value">${escapeHtml(activeCodexLabel)}</div>
666
+ <div class="subvalue">${escapeHtml(config?.codex.exists ? "\u5DF2\u5E94\u7528" : "\u672A\u5E94\u7528")}</div>
667
+ </div>
668
+ </section>
669
+
670
+ <label class="search">
671
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/><path d="M16.5 16.5L21 21" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
672
+ <input id="query" type="search" placeholder="\u641C\u7D22\u90AE\u7BB1\u3001\u6807\u7B7E\u3001\u5957\u9910..." autofocus />
673
+ <span class="kbd">\u2318 K</span>
674
+ </label>
675
+
676
+ <nav class="tabs">
677
+ <button class="tab active" type="button" data-tab="recommended">\u63A8\u8350\u53EF\u7528</button>
678
+ <button class="tab" type="button" data-tab="recent">\u6700\u8FD1\u4F7F\u7528</button>
679
+ <button class="tab" type="button" data-tab="all">\u5168\u90E8 ${totalCount}</button>
680
+ </nav>
681
+
682
+ <div class="section-title" id="sectionTitle">\u63A8\u8350\u53EF\u7528 \xB7 \u6309\u5065\u5EB7\u548C\u6700\u8FD1\u4F7F\u7528\u6392\u5E8F</div>
683
+ <main class="list" id="list"></main>
684
+ <div class="summary"><span><span id="shownCount">0</span> / ${usableCount} \u53EF\u7528</span><span>\u603B\u6570 ${totalCount}</span></div>
685
+ <div class="problems"><span>${problemCount} \u4E2A\u9700\u5904\u7406</span><span>\u5DF2\u6298\u53E0</span></div>
686
+ <div class="message">${escapeHtml(message)}</div>
687
+ <footer class="footer">
688
+ <a class="primary" href="azt-action://refresh">\u5237\u65B0\u72B6\u6001</a>
689
+ <a href="azt-action://open-console">\u6253\u5F00\u63A7\u5236\u53F0</a>
690
+ <a href="azt-action://copy-base-url">\u590D\u5236 URL</a>
691
+ <a href="azt-action://restart-gateway">\u91CD\u542F\u7F51\u5173</a>
692
+ </footer>
693
+ </div>
694
+ </div>
695
+ <script>
696
+ const boot = ${boot};
697
+ let tab = "recommended";
698
+ const list = document.getElementById("list");
699
+ const query = document.getElementById("query");
700
+ const shownCount = document.getElementById("shownCount");
701
+ const sectionTitle = document.getElementById("sectionTitle");
702
+ const tabs = Array.from(document.querySelectorAll(".tab"));
703
+ const html = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
704
+ const actionUrl = (profileId, target) => "azt-action://apply?target=" + encodeURIComponent(target) + "&profileId=" + encodeURIComponent(profileId);
705
+ const accent = (item) => item.healthKey === "healthy" ? "#16a34a" : item.healthKey === "warning" ? "#f59e0b" : item.healthKey === "exhausted" ? "#ef4444" : item.healthKey === "invalid" || item.healthKey === "expired" ? "#dc2626" : "#3b82f6";
706
+ function baseRows() {
707
+ const rows = boot.profiles.slice();
708
+ if (tab === "recommended") return rows.filter((item) => item.isUsable).slice(0, 12);
709
+ if (tab === "recent") return rows.slice(0, 12);
710
+ return rows;
711
+ }
712
+ function render() {
713
+ const q = query.value.trim().toLowerCase();
714
+ let rows = baseRows().filter((item) => !q || [item.searchLabel, item.accountId, item.meta, item.healthLabel].join(" ").toLowerCase().includes(q));
715
+ if (q) rows = boot.profiles.filter((item) => [item.searchLabel, item.accountId, item.meta, item.healthLabel].join(" ").toLowerCase().includes(q));
716
+ sectionTitle.textContent = q ? "\u641C\u7D22\u7ED3\u679C" : tab === "recommended" ? "\u63A8\u8350\u53EF\u7528 \xB7 \u6309\u5065\u5EB7\u548C\u6700\u8FD1\u4F7F\u7528\u6392\u5E8F" : tab === "recent" ? "\u6700\u8FD1\u4F7F\u7528" : "\u5168\u90E8\u8D26\u53F7";
717
+ shownCount.textContent = String(rows.length);
718
+ list.innerHTML = rows.length ? rows.map((item) => {
719
+ const disabled = item.isUsable ? "" : " disabled";
720
+ return \`
721
+ <article class="account" style="--accent:\${accent(item)}">
722
+ <div class="account-head">
723
+ <div class="account-title">\${html(item.label)}</div>
724
+ <span class="plan-chip">\${html(item.plan)}</span>
725
+ </div>
726
+ <div class="badges">\${item.isGateway ? '<span class="corner-badge api">API</span>' : ''}\${item.isCodex ? '<span class="corner-badge codex">Codex</span>' : ''}</div>
727
+ <div class="status-row">
728
+ <span class="quota-text">\${html(item.quotaRemaining)}%</span>
729
+ <span class="quota"><span style="width:\${Math.max(0, Math.min(100, item.quotaRemaining))}%"></span></span>
730
+ <span class="health \${html(item.healthKey)}">\${html(item.healthLabel)}</span>
731
+ </div>
732
+ <div class="account-foot">
733
+ <div class="reset-times">
734
+ <span class="reset-pill">\${html(item.primaryResetLabel)}</span>
735
+ <span class="reset-pill">\${html(item.secondaryResetLabel)}</span>
736
+ </div>
737
+ <div class="actions">
738
+ <a class="btn gateway\${item.isGateway ? " disabled" : disabled}" href="\${actionUrl(item.profileId, "gateway")}">\u7F51\u5173</a>
739
+ <a class="btn codex\${item.isCodex ? " disabled" : disabled}" href="\${actionUrl(item.profileId, "codex")}">Codex</a>
740
+ </div>
741
+ </div>
742
+ </article>\`;
743
+ }).join("") : '<div class="empty">\u6CA1\u6709\u5339\u914D\u7684\u8D26\u53F7</div>';
744
+ }
745
+ tabs.forEach((button) => {
746
+ button.addEventListener("click", () => {
747
+ tab = button.dataset.tab;
748
+ tabs.forEach((item) => item.classList.toggle("active", item === button));
749
+ render();
750
+ });
751
+ });
752
+ query.addEventListener("input", render);
753
+ document.addEventListener("keydown", (event) => {
754
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
755
+ event.preventDefault();
756
+ query.focus();
757
+ }
758
+ });
759
+ render();
760
+ </script>
761
+ </body>
762
+ </html>`;
763
+ return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
764
+ }
165
765
  async function ensureGatewayServer() {
166
766
  if (gatewayServer) {
167
767
  return gatewayServer;
168
768
  }
169
769
  gatewayServer = await startServer({
170
770
  ...resolvePreferredGatewayParams(),
171
- onRestart: restartGateway
771
+ onRestart: restartGateway,
772
+ onRestartCodex: restartCodexApp
172
773
  });
173
- const adminUrl = createBrowserUrl(gatewayServer.host, gatewayServer.port);
774
+ updateDesktopUrls(gatewayServer);
174
775
  console.log("AI Zero Token desktop gateway started.");
175
- console.log(`admin: ${adminUrl}`);
176
- console.log(`apiBase: ${adminUrl}/v1`);
776
+ console.log(`admin: ${currentGatewayUrl}`);
777
+ console.log(`apiBase: ${currentGatewayUrl}/v1`);
177
778
  console.log(`listen: http://${gatewayServer.host}:${gatewayServer.port}`);
178
779
  return gatewayServer;
179
780
  }
@@ -220,11 +821,8 @@ async function createMainWindow() {
220
821
  if (!mainWindow) {
221
822
  return;
222
823
  }
223
- const gatewayUrl = createBrowserUrl(server.host, server.port);
224
- const adminUrl = resolveAdminUrl(gatewayUrl);
225
- currentGatewayUrl = gatewayUrl;
226
- currentAdminUrl = adminUrl;
227
- await mainWindow.loadURL(adminUrl);
824
+ updateDesktopUrls(server);
825
+ await mainWindow.loadURL(currentAdminUrl ?? createBrowserUrl(server.host, server.port));
228
826
  }
229
827
  function focusMainWindow() {
230
828
  if (!mainWindow) {
@@ -242,6 +840,8 @@ async function closeGatewayServer() {
242
840
  }
243
841
  const server = gatewayServer;
244
842
  gatewayServer = null;
843
+ currentGatewayUrl = null;
844
+ currentAdminUrl = null;
245
845
  await server.app.close();
246
846
  }
247
847
  function handleStartupError(error) {
@@ -260,6 +860,7 @@ if (!hasSingleInstanceLock) {
260
860
  focusMainWindow();
261
861
  });
262
862
  electronApp.whenReady().then(() => {
863
+ ensureTray();
263
864
  if (process.platform === "darwin") {
264
865
  electronApp.dock?.setIcon(appIconPath);
265
866
  }