icebear icebear0828 Claude Opus 4.6 commited on
Commit
1b6fb15
·
unverified ·
1 Parent(s): 20b98cc

fix: Electron OAuth login + i18n layout shift + update modal (#53)

Browse files

* fix: Electron OAuth login + i18n toggle layout shift + update modal

1. OAuth login fix for Electron:
- Add focus/visibilitychange listeners to detect new accounts immediately
when user switches back from system browser (bypasses Chromium throttling)
- Track completed sessions server-side so code-relay returns success
instead of "Invalid or expired session" after callback server handled it

2. i18n layout stability:
- Push Star/Updates buttons from md to lg breakpoint to reduce crowding
- Add inline-grid overlay to Star, Check for Updates, Proxy Settings buttons

3. Update modal: extract from Header into standalone UpdateModal component
with auto-restart support (git mode spawns new process, frontend auto-refreshes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add polling guard + fix Star button inline-grid reference width

- Add re-entrancy guard to checkForNewAccount (both shared/ and web/)
to prevent concurrent fetches from rapid focus/visibility events
- Fix Star button invisible reference text "Star" → "Star on GitHub"
so inline-grid actually prevents layout shift between languages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

CHANGELOG.md CHANGED
@@ -6,6 +6,10 @@
6
 
7
  ## [Unreleased]
8
 
 
 
 
 
9
  ### Fixed
10
 
11
  - Docker 端口修复:锁定容器内 `PORT=8080`(`environment` 覆盖 `env_file`),HEALTHCHECK 固定检查 8080,`.env` 的 PORT 仅控制宿主机暴露端口,修复自定义 PORT 时健康检查失败和端口映射不匹配的问题 (#40)
 
6
 
7
  ## [Unreleased]
8
 
9
+ ### Added
10
+
11
+ - 更新弹窗 + 自动重启:点击"有可用更新"弹出 Modal 显示 changelog,一键更新后服务器自动重启、前端自动刷新,零人工干预(git 模式 spawn 新进程、Docker/Electron 显示对应操作指引)
12
+
13
  ### Fixed
14
 
15
  - Docker 端口修复:锁定容器内 `PORT=8080`(`environment` 覆盖 `env_file`),HEALTHCHECK 固定检查 8080,`.env` 的 PORT 仅控制宿主机暴露端口,修复自定义 PORT 时健康检查失败和端口映射不匹配的问题 (#40)
shared/hooks/use-accounts.ts CHANGED
@@ -54,25 +54,48 @@ export function useAccounts() {
54
  window.open(data.authUrl, "oauth_add", "width=600,height=700,scrollbars=yes");
55
  setAddVisible(true);
56
 
57
- // Poll for new account
58
  const prevResp = await fetch("/auth/accounts");
59
  const prevData = await prevResp.json();
60
  const prevCount = prevData.accounts?.length || 0;
61
 
62
- const pollTimer = setInterval(async () => {
 
 
 
63
  try {
64
  const r = await fetch("/auth/accounts");
65
  const d = await r.json();
66
  if ((d.accounts?.length || 0) > prevCount) {
67
- clearInterval(pollTimer);
68
  setAddVisible(false);
69
  setAddInfo("accountAdded");
70
  await loadAccounts();
71
  }
72
- } catch {}
73
- }, 2000);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
- setTimeout(() => clearInterval(pollTimer), 5 * 60 * 1000);
 
 
 
 
 
76
  } catch (err) {
77
  setAddError(err instanceof Error ? err.message : "failedStartLogin");
78
  }
 
54
  window.open(data.authUrl, "oauth_add", "width=600,height=700,scrollbars=yes");
55
  setAddVisible(true);
56
 
57
+ // Poll for new account + focus/visibility detection
58
  const prevResp = await fetch("/auth/accounts");
59
  const prevData = await prevResp.json();
60
  const prevCount = prevData.accounts?.length || 0;
61
 
62
+ let checking = false;
63
+ const checkForNewAccount = async () => {
64
+ if (checking) return;
65
+ checking = true;
66
  try {
67
  const r = await fetch("/auth/accounts");
68
  const d = await r.json();
69
  if ((d.accounts?.length || 0) > prevCount) {
70
+ cleanup();
71
  setAddVisible(false);
72
  setAddInfo("accountAdded");
73
  await loadAccounts();
74
  }
75
+ } catch {} finally {
76
+ checking = false;
77
+ }
78
+ };
79
+
80
+ // Focus event — check immediately when window regains focus
81
+ const onFocus = () => { checkForNewAccount(); };
82
+ window.addEventListener("focus", onFocus);
83
+
84
+ // Visibility change — check when tab becomes visible
85
+ const onVisible = () => {
86
+ if (document.visibilityState === "visible") checkForNewAccount();
87
+ };
88
+ document.addEventListener("visibilitychange", onVisible);
89
+
90
+ // Interval polling as fallback
91
+ const pollTimer = setInterval(checkForNewAccount, 2000);
92
 
93
+ const cleanup = () => {
94
+ clearInterval(pollTimer);
95
+ window.removeEventListener("focus", onFocus);
96
+ document.removeEventListener("visibilitychange", onVisible);
97
+ };
98
+ setTimeout(cleanup, 5 * 60 * 1000);
99
  } catch (err) {
100
  setAddError(err instanceof Error ? err.message : "failedStartLogin");
101
  }
shared/hooks/use-update-status.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useCallback } from "preact/hooks";
2
 
3
  export interface UpdateStatus {
4
  proxy: {
@@ -45,12 +45,64 @@ export interface CheckResult {
45
  codex_update_in_progress: boolean;
46
  }
47
 
 
 
 
48
  export function useUpdateStatus() {
49
  const [status, setStatus] = useState<UpdateStatus | null>(null);
50
  const [checking, setChecking] = useState(false);
51
  const [result, setResult] = useState<CheckResult | null>(null);
52
  const [error, setError] = useState<string | null>(null);
53
  const [applying, setApplying] = useState(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
  const load = useCallback(async () => {
56
  try {
@@ -88,18 +140,24 @@ export function useUpdateStatus() {
88
  setError(null);
89
  try {
90
  const resp = await fetch("/admin/apply-update", { method: "POST" });
91
- const data = await resp.json() as { started: boolean; error?: string };
92
  if (!resp.ok || !data.started) {
93
  setError(data.error ?? "Apply failed");
 
 
 
 
 
94
  } else {
 
 
95
  await load();
96
  }
97
  } catch (err) {
98
  setError(err instanceof Error ? err.message : "Network error");
99
- } finally {
100
  setApplying(false);
101
  }
102
- }, [load]);
103
 
104
- return { status, checking, result, error, checkForUpdate, applyUpdate, applying };
105
  }
 
1
+ import { useState, useEffect, useCallback, useRef } from "preact/hooks";
2
 
3
  export interface UpdateStatus {
4
  proxy: {
 
45
  codex_update_in_progress: boolean;
46
  }
47
 
48
+ const RESTART_POLL_INTERVAL = 2000;
49
+ const RESTART_TIMEOUT = 30000;
50
+
51
  export function useUpdateStatus() {
52
  const [status, setStatus] = useState<UpdateStatus | null>(null);
53
  const [checking, setChecking] = useState(false);
54
  const [result, setResult] = useState<CheckResult | null>(null);
55
  const [error, setError] = useState<string | null>(null);
56
  const [applying, setApplying] = useState(false);
57
+ const [restarting, setRestarting] = useState(false);
58
+ const [restartFailed, setRestartFailed] = useState(false);
59
+ const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
60
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
61
+
62
+ const clearPolling = useCallback(() => {
63
+ if (pollTimerRef.current) {
64
+ clearInterval(pollTimerRef.current);
65
+ pollTimerRef.current = null;
66
+ }
67
+ if (timeoutRef.current) {
68
+ clearTimeout(timeoutRef.current);
69
+ timeoutRef.current = null;
70
+ }
71
+ }, []);
72
+
73
+ // Cleanup on unmount
74
+ useEffect(() => clearPolling, [clearPolling]);
75
+
76
+ const startRestartPolling = useCallback(() => {
77
+ setRestarting(true);
78
+ setRestartFailed(false);
79
+ clearPolling();
80
+
81
+ // Wait a bit before first poll (server needs time to shut down)
82
+ const initialDelay = setTimeout(() => {
83
+ pollTimerRef.current = setInterval(async () => {
84
+ try {
85
+ const resp = await fetch("/health", { signal: AbortSignal.timeout(3000) });
86
+ if (resp.ok) {
87
+ clearPolling();
88
+ location.reload();
89
+ }
90
+ } catch {
91
+ // Server still down, keep polling
92
+ }
93
+ }, RESTART_POLL_INTERVAL);
94
+
95
+ // Timeout fallback
96
+ timeoutRef.current = setTimeout(() => {
97
+ clearPolling();
98
+ setRestarting(false);
99
+ setRestartFailed(true);
100
+ }, RESTART_TIMEOUT);
101
+ }, 2000);
102
+
103
+ // Store the initial delay timer for cleanup
104
+ timeoutRef.current = initialDelay;
105
+ }, [clearPolling]);
106
 
107
  const load = useCallback(async () => {
108
  try {
 
140
  setError(null);
141
  try {
142
  const resp = await fetch("/admin/apply-update", { method: "POST" });
143
+ const data = await resp.json() as { started: boolean; restarting?: boolean; error?: string };
144
  if (!resp.ok || !data.started) {
145
  setError(data.error ?? "Apply failed");
146
+ setApplying(false);
147
+ } else if (data.restarting) {
148
+ // Server will restart — start polling for it to come back
149
+ setApplying(false);
150
+ startRestartPolling();
151
  } else {
152
+ // Update succeeded but no auto-restart
153
+ setApplying(false);
154
  await load();
155
  }
156
  } catch (err) {
157
  setError(err instanceof Error ? err.message : "Network error");
 
158
  setApplying(false);
159
  }
160
+ }, [load, startRestartPolling]);
161
 
162
+ return { status, checking, result, error, checkForUpdate, applyUpdate, applying, restarting, restartFailed };
163
  }
shared/i18n/translations.ts CHANGED
@@ -149,6 +149,10 @@ export const translations = {
149
  newVersion: "New version",
150
  dockerUpdateCmd: "Run to update:",
151
  downloadUpdate: "Download",
 
 
 
 
152
  },
153
  zh: {
154
  serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
@@ -303,6 +307,10 @@ export const translations = {
303
  newVersion: "\u65b0\u7248\u672c",
304
  dockerUpdateCmd: "\u6267\u884c\u66f4\u65b0\u547d\u4ee4\uff1a",
305
  downloadUpdate: "\u4e0b\u8f7d",
 
 
 
 
306
  },
307
  } as const;
308
 
 
149
  newVersion: "New version",
150
  dockerUpdateCmd: "Run to update:",
151
  downloadUpdate: "Download",
152
+ updateTitle: "Update Available",
153
+ updateRestarting: "Restarting server...",
154
+ restartFailed: "Server not responding. Please restart manually.",
155
+ close: "Close",
156
  },
157
  zh: {
158
  serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
 
307
  newVersion: "\u65b0\u7248\u672c",
308
  dockerUpdateCmd: "\u6267\u884c\u66f4\u65b0\u547d\u4ee4\uff1a",
309
  downloadUpdate: "\u4e0b\u8f7d",
310
+ updateTitle: "\u6709\u53ef\u7528\u66f4\u65b0",
311
+ updateRestarting: "\u6b63\u5728\u91cd\u542f\u670d\u52a1...",
312
+ restartFailed: "\u670d\u52a1\u5668\u672a\u54cd\u5e94\uff0c\u8bf7\u624b\u52a8\u91cd\u542f\u3002",
313
+ close: "\u5173\u95ed",
314
  },
315
  } as const;
316
 
src/auth/oauth-pkce.ts CHANGED
@@ -47,6 +47,9 @@ const isCfResponse = (r: CurlFetchResponse) => isCloudflareChallengeResponse(r.s
47
  /** In-memory store for pending OAuth sessions, keyed by `state`. */
48
  const pendingSessions = new Map<string, PendingSession>();
49
 
 
 
 
50
  // Clean up expired sessions every 60 seconds
51
  const SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes
52
  setInterval(() => {
@@ -56,8 +59,23 @@ setInterval(() => {
56
  pendingSessions.delete(state);
57
  }
58
  }
 
 
 
 
 
59
  }, 60_000).unref();
60
 
 
 
 
 
 
 
 
 
 
 
61
  /**
62
  * Generate a PKCE code_verifier + code_challenge (S256).
63
  */
@@ -427,6 +445,7 @@ export function startOAuthFlow(
427
  startCallbackServer(port, (accessToken, refreshToken) => {
428
  const entryId = pool.addAccount(accessToken, refreshToken);
429
  scheduler.scheduleOne(entryId, accessToken);
 
430
  console.log(`[Auth] OAuth via callback server — account ${entryId} added`);
431
  });
432
  return { authUrl, state };
 
47
  /** In-memory store for pending OAuth sessions, keyed by `state`. */
48
  const pendingSessions = new Map<string, PendingSession>();
49
 
50
+ /** Track completed sessions so code-relay doesn't error after callback server already handled it. */
51
+ const completedSessions = new Map<string, number>();
52
+
53
  // Clean up expired sessions every 60 seconds
54
  const SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes
55
  setInterval(() => {
 
59
  pendingSessions.delete(state);
60
  }
61
  }
62
+ for (const [state, completedAt] of completedSessions) {
63
+ if (now - completedAt > SESSION_TTL_MS) {
64
+ completedSessions.delete(state);
65
+ }
66
+ }
67
  }, 60_000).unref();
68
 
69
+ /** Mark a session as successfully completed. */
70
+ export function markSessionCompleted(state: string): void {
71
+ completedSessions.set(state, Date.now());
72
+ }
73
+
74
+ /** Check if a session was already completed (callback server handled it). */
75
+ export function isSessionCompleted(state: string): boolean {
76
+ return completedSessions.has(state);
77
+ }
78
+
79
  /**
80
  * Generate a PKCE code_verifier + code_challenge (S256).
81
  */
 
445
  startCallbackServer(port, (accessToken, refreshToken) => {
446
  const entryId = pool.addAccount(accessToken, refreshToken);
447
  scheduler.scheduleOne(entryId, accessToken);
448
+ markSessionCompleted(state);
449
  console.log(`[Auth] OAuth via callback server — account ${entryId} added`);
450
  });
451
  return { authUrl, state };
src/index.ts CHANGED
@@ -1,3 +1,4 @@
 
1
  import { Hono } from "hono";
2
  import { serve } from "@hono/node-server";
3
  import { loadConfig, loadFingerprint, getConfig } from "./config.js";
@@ -19,7 +20,7 @@ import { ProxyPool } from "./proxy/proxy-pool.js";
19
  import { createProxyRoutes } from "./routes/proxies.js";
20
  import { createResponsesRoutes } from "./routes/responses.js";
21
  import { startUpdateChecker, stopUpdateChecker } from "./update-checker.js";
22
- import { startProxyUpdateChecker, stopProxyUpdateChecker } from "./self-update.js";
23
  import { initProxy } from "./tls/curl-binary.js";
24
  import { initTransport } from "./tls/transport.js";
25
  import { loadStaticModels } from "./models/model-store.js";
@@ -147,6 +148,22 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
147
  });
148
  };
149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  return { close, port };
151
  }
152
 
 
1
+ import { spawn } from "child_process";
2
  import { Hono } from "hono";
3
  import { serve } from "@hono/node-server";
4
  import { loadConfig, loadFingerprint, getConfig } from "./config.js";
 
20
  import { createProxyRoutes } from "./routes/proxies.js";
21
  import { createResponsesRoutes } from "./routes/responses.js";
22
  import { startUpdateChecker, stopUpdateChecker } from "./update-checker.js";
23
+ import { startProxyUpdateChecker, stopProxyUpdateChecker, setRestartHandler } from "./self-update.js";
24
  import { initProxy } from "./tls/curl-binary.js";
25
  import { initTransport } from "./tls/transport.js";
26
  import { loadStaticModels } from "./models/model-store.js";
 
148
  });
149
  };
150
 
151
+ // Register restart handler for self-update auto-restart
152
+ setRestartHandler(() => {
153
+ close().then(() => {
154
+ const child = spawn(process.argv[0], process.argv.slice(1), {
155
+ detached: true,
156
+ stdio: "ignore",
157
+ cwd: process.cwd(),
158
+ windowsHide: true,
159
+ });
160
+ child.unref();
161
+ process.exit(0);
162
+ }).catch(() => {
163
+ process.exit(1);
164
+ });
165
+ });
166
+
167
  return { close, port };
168
  }
169
 
src/routes/auth.ts CHANGED
@@ -10,6 +10,8 @@ import {
10
  requestDeviceCode,
11
  pollDeviceToken,
12
  importCliAuth,
 
 
13
  } from "../auth/oauth-pkce.js";
14
 
15
  export function createAuthRoutes(
@@ -80,6 +82,10 @@ export function createAuthRoutes(
80
 
81
  const session = consumeSession(state);
82
  if (!session) {
 
 
 
 
83
  return c.json({ error: "Invalid or expired session. Please try again." }, 400);
84
  }
85
 
@@ -87,6 +93,7 @@ export function createAuthRoutes(
87
  const tokens = await exchangeCode(code, session.codeVerifier, session.redirectUri);
88
  const entryId = pool.addAccount(tokens.access_token, tokens.refresh_token);
89
  scheduler.scheduleOne(entryId, tokens.access_token);
 
90
 
91
  console.log(`[Auth] OAuth via code-relay — account ${entryId} added`);
92
  return c.json({ success: true });
@@ -115,6 +122,12 @@ export function createAuthRoutes(
115
 
116
  const session = consumeSession(state);
117
  if (!session) {
 
 
 
 
 
 
118
  return c.html(errorPage("Invalid or expired OAuth session. Please try again."), 400);
119
  }
120
 
@@ -122,6 +135,7 @@ export function createAuthRoutes(
122
  const tokens = await exchangeCode(code, session.codeVerifier, session.redirectUri);
123
  const entryId = pool.addAccount(tokens.access_token, tokens.refresh_token);
124
  scheduler.scheduleOne(entryId, tokens.access_token);
 
125
 
126
  console.log(`[Auth] OAuth login completed — account ${entryId} added`);
127
 
 
10
  requestDeviceCode,
11
  pollDeviceToken,
12
  importCliAuth,
13
+ markSessionCompleted,
14
+ isSessionCompleted,
15
  } from "../auth/oauth-pkce.js";
16
 
17
  export function createAuthRoutes(
 
82
 
83
  const session = consumeSession(state);
84
  if (!session) {
85
+ // Session already consumed by callback server — treat as success
86
+ if (isSessionCompleted(state)) {
87
+ return c.json({ success: true });
88
+ }
89
  return c.json({ error: "Invalid or expired session. Please try again." }, 400);
90
  }
91
 
 
93
  const tokens = await exchangeCode(code, session.codeVerifier, session.redirectUri);
94
  const entryId = pool.addAccount(tokens.access_token, tokens.refresh_token);
95
  scheduler.scheduleOne(entryId, tokens.access_token);
96
+ markSessionCompleted(state);
97
 
98
  console.log(`[Auth] OAuth via code-relay — account ${entryId} added`);
99
  return c.json({ success: true });
 
122
 
123
  const session = consumeSession(state);
124
  if (!session) {
125
+ // Session already consumed by callback server — redirect home
126
+ if (isSessionCompleted(state)) {
127
+ const config = getConfig();
128
+ const host = c.req.header("host") || `localhost:${config.server.port}`;
129
+ return c.redirect(`http://${host}/`);
130
+ }
131
  return c.html(errorPage("Invalid or expired OAuth session. Please try again."), 400);
132
  }
133
 
 
135
  const tokens = await exchangeCode(code, session.codeVerifier, session.redirectUri);
136
  const entryId = pool.addAccount(tokens.access_token, tokens.refresh_token);
137
  scheduler.scheduleOne(entryId, tokens.access_token);
138
+ markSessionCompleted(state);
139
 
140
  console.log(`[Auth] OAuth login completed — account ${entryId} added`);
141
 
src/self-update.ts CHANGED
@@ -11,6 +11,27 @@ import { resolve } from "path";
11
  import { promisify } from "util";
12
  import { getRootDir, isEmbedded } from "./paths.js";
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  const execFileAsync = promisify(execFile);
15
 
16
  const GITHUB_REPO = "icebear0828/codex-proxy";
@@ -233,7 +254,7 @@ export async function checkProxySelfUpdate(): Promise<ProxySelfUpdateResult> {
233
  * Apply proxy self-update: git pull + npm install + npm run build.
234
  * Only works in git (CLI) mode.
235
  */
236
- export async function applyProxySelfUpdate(): Promise<{ started: boolean; error?: string }> {
237
  if (_proxyUpdateInProgress) {
238
  return { started: false, error: "Update already in progress" };
239
  }
@@ -251,9 +272,15 @@ export async function applyProxySelfUpdate(): Promise<{ started: boolean; error?
251
  console.log("[SelfUpdate] Building...");
252
  await execFileAsync("npm", ["run", "build"], { cwd, timeout: 120000, shell: true });
253
 
254
- console.log("[SelfUpdate] Update complete. Server restart required.");
255
  _proxyUpdateInProgress = false;
256
- return { started: true };
 
 
 
 
 
 
257
  } catch (err) {
258
  _proxyUpdateInProgress = false;
259
  const msg = err instanceof Error ? err.message : String(err);
 
11
  import { promisify } from "util";
12
  import { getRootDir, isEmbedded } from "./paths.js";
13
 
14
+ // ── Restart handler ──────────────────────────────────────────────────
15
+ let _onRestart: (() => void) | null = null;
16
+
17
+ /** Register a handler that will be called to restart the server process. */
18
+ export function setRestartHandler(handler: () => void): void {
19
+ _onRestart = handler;
20
+ }
21
+
22
+ /** Schedule a server restart after a short delay (allows HTTP response to flush). */
23
+ export function scheduleRestart(): void {
24
+ if (!_onRestart) {
25
+ console.warn("[SelfUpdate] No restart handler registered, manual restart required.");
26
+ return;
27
+ }
28
+ const handler = _onRestart;
29
+ setTimeout(() => {
30
+ console.log("[SelfUpdate] Restarting server...");
31
+ handler();
32
+ }, 500);
33
+ }
34
+
35
  const execFileAsync = promisify(execFile);
36
 
37
  const GITHUB_REPO = "icebear0828/codex-proxy";
 
254
  * Apply proxy self-update: git pull + npm install + npm run build.
255
  * Only works in git (CLI) mode.
256
  */
257
+ export async function applyProxySelfUpdate(): Promise<{ started: boolean; restarting?: boolean; error?: string }> {
258
  if (_proxyUpdateInProgress) {
259
  return { started: false, error: "Update already in progress" };
260
  }
 
272
  console.log("[SelfUpdate] Building...");
273
  await execFileAsync("npm", ["run", "build"], { cwd, timeout: 120000, shell: true });
274
 
275
+ console.log("[SelfUpdate] Update complete. Scheduling restart...");
276
  _proxyUpdateInProgress = false;
277
+
278
+ const willRestart = _onRestart !== null;
279
+ if (willRestart) {
280
+ scheduleRestart();
281
+ }
282
+
283
+ return { started: true, restarting: willRestart };
284
  } catch (err) {
285
  _proxyUpdateInProgress = false;
286
  const msg = err instanceof Error ? err.message : String(err);
web/src/App.tsx CHANGED
@@ -1,7 +1,8 @@
1
- import { useState, useEffect } from "preact/hooks";
2
  import { I18nProvider } from "../../shared/i18n/context";
3
  import { ThemeProvider } from "../../shared/theme/context";
4
  import { Header } from "./components/Header";
 
5
  import { AccountList } from "./components/AccountList";
6
  import { AddAccount } from "./components/AddAccount";
7
  import { ProxyPool } from "./components/ProxyPool";
@@ -51,18 +52,16 @@ function useUpdateMessage() {
51
  color = "text-red-500";
52
  }
53
 
54
- const proxyUpdate = update.status?.proxy.update_available
 
55
  ? {
56
- mode: update.status.proxy.mode,
57
- updateAvailable: true as const,
58
- commits: update.status.proxy.commits,
59
- release: update.status.proxy.release,
60
- onApply: update.applyUpdate,
61
- applying: update.applying,
62
  }
63
  : null;
64
 
65
- return { ...update, msg, color, proxyUpdate };
66
  }
67
 
68
  function Dashboard() {
@@ -70,6 +69,16 @@ function Dashboard() {
70
  const proxies = useProxies();
71
  const status = useStatus(accounts.list.length);
72
  const update = useUpdateMessage();
 
 
 
 
 
 
 
 
 
 
73
 
74
  const handleProxyChange = async (accountId: string, proxyId: string) => {
75
  accounts.patchLocal(accountId, { proxyId });
@@ -81,12 +90,13 @@ function Dashboard() {
81
  <Header
82
  onAddAccount={accounts.startAdd}
83
  onCheckUpdate={update.checkForUpdate}
 
84
  checking={update.checking}
85
  updateStatusMsg={update.msg}
86
  updateStatusColor={update.color}
87
  version={update.status?.proxy.version ?? null}
88
  commit={update.status?.proxy.commit ?? null}
89
- proxyUpdate={update.proxyUpdate}
90
  />
91
  <main class="flex-grow px-4 md:px-8 lg:px-40 py-8 flex justify-center">
92
  <div class="flex flex-col w-full max-w-[960px] gap-6">
@@ -135,6 +145,19 @@ function Dashboard() {
135
  </div>
136
  </main>
137
  <Footer updateStatus={update.status} />
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  </>
139
  );
140
  }
@@ -176,7 +199,7 @@ function ProxySettingsPage() {
176
  version={update.status?.proxy.version ?? null}
177
  commit={update.status?.proxy.commit ?? null}
178
  isProxySettings
179
- proxyUpdate={update.proxyUpdate}
180
  />
181
  <ProxySettings />
182
  </>
 
1
+ import { useState, useEffect, useRef } from "preact/hooks";
2
  import { I18nProvider } from "../../shared/i18n/context";
3
  import { ThemeProvider } from "../../shared/theme/context";
4
  import { Header } from "./components/Header";
5
+ import { UpdateModal } from "./components/UpdateModal";
6
  import { AccountList } from "./components/AccountList";
7
  import { AddAccount } from "./components/AddAccount";
8
  import { ProxyPool } from "./components/ProxyPool";
 
52
  color = "text-red-500";
53
  }
54
 
55
+ const hasUpdate = update.status?.proxy.update_available ?? false;
56
+ const proxyUpdateInfo = hasUpdate
57
  ? {
58
+ mode: update.status!.proxy.mode,
59
+ commits: update.status!.proxy.commits,
60
+ release: update.status!.proxy.release,
 
 
 
61
  }
62
  : null;
63
 
64
+ return { ...update, msg, color, hasUpdate, proxyUpdateInfo };
65
  }
66
 
67
  function Dashboard() {
 
69
  const proxies = useProxies();
70
  const status = useStatus(accounts.list.length);
71
  const update = useUpdateMessage();
72
+ const [showModal, setShowModal] = useState(false);
73
+ const prevUpdateAvailable = useRef(false);
74
+
75
+ // Auto-open modal when update becomes available after a check
76
+ useEffect(() => {
77
+ if (update.hasUpdate && !prevUpdateAvailable.current) {
78
+ setShowModal(true);
79
+ }
80
+ prevUpdateAvailable.current = update.hasUpdate;
81
+ }, [update.hasUpdate]);
82
 
83
  const handleProxyChange = async (accountId: string, proxyId: string) => {
84
  accounts.patchLocal(accountId, { proxyId });
 
90
  <Header
91
  onAddAccount={accounts.startAdd}
92
  onCheckUpdate={update.checkForUpdate}
93
+ onOpenUpdateModal={() => setShowModal(true)}
94
  checking={update.checking}
95
  updateStatusMsg={update.msg}
96
  updateStatusColor={update.color}
97
  version={update.status?.proxy.version ?? null}
98
  commit={update.status?.proxy.commit ?? null}
99
+ hasUpdate={update.hasUpdate}
100
  />
101
  <main class="flex-grow px-4 md:px-8 lg:px-40 py-8 flex justify-center">
102
  <div class="flex flex-col w-full max-w-[960px] gap-6">
 
145
  </div>
146
  </main>
147
  <Footer updateStatus={update.status} />
148
+ {update.proxyUpdateInfo && (
149
+ <UpdateModal
150
+ open={showModal}
151
+ onClose={() => setShowModal(false)}
152
+ mode={update.proxyUpdateInfo.mode}
153
+ commits={update.proxyUpdateInfo.commits}
154
+ release={update.proxyUpdateInfo.release}
155
+ onApply={update.applyUpdate}
156
+ applying={update.applying}
157
+ restarting={update.restarting}
158
+ restartFailed={update.restartFailed}
159
+ />
160
+ )}
161
  </>
162
  );
163
  }
 
199
  version={update.status?.proxy.version ?? null}
200
  commit={update.status?.proxy.commit ?? null}
201
  isProxySettings
202
+ hasUpdate={update.hasUpdate}
203
  />
204
  <ProxySettings />
205
  </>
web/src/components/Header.tsx CHANGED
@@ -1,4 +1,3 @@
1
- import { useState } from "preact/hooks";
2
  import { useI18n } from "../../../shared/i18n/context";
3
  import { useTheme } from "../../../shared/theme/context";
4
 
@@ -14,31 +13,22 @@ const SVG_SUN = (
14
  </svg>
15
  );
16
 
17
- export interface ProxyUpdateInfo {
18
- mode: "git" | "docker" | "electron";
19
- updateAvailable: boolean;
20
- commits: { hash: string; message: string }[];
21
- release: { version: string; body: string; url: string } | null;
22
- onApply: () => void;
23
- applying: boolean;
24
- }
25
-
26
  interface HeaderProps {
27
  onAddAccount: () => void;
28
  onCheckUpdate: () => void;
 
29
  checking: boolean;
30
  updateStatusMsg: string | null;
31
  updateStatusColor: string;
32
  version: string | null;
33
  commit?: string | null;
34
  isProxySettings?: boolean;
35
- proxyUpdate?: ProxyUpdateInfo | null;
36
  }
37
 
38
- export function Header({ onAddAccount, onCheckUpdate, checking, updateStatusMsg, updateStatusColor, version, commit, isProxySettings, proxyUpdate }: HeaderProps) {
39
  const { lang, toggleLang, t } = useI18n();
40
  const { isDark, toggle: toggleTheme } = useTheme();
41
- const [showChangelog, setShowChangelog] = useState(false);
42
 
43
  return (
44
  <header class="sticky top-0 z-50 w-full bg-white dark:bg-card-dark border-b border-gray-200 dark:border-border-dark shadow-sm transition-colors">
@@ -90,31 +80,35 @@ export function Header({ onAddAccount, onCheckUpdate, checking, updateStatusMsg,
90
  href="https://github.com/icebear0828/codex-proxy"
91
  target="_blank"
92
  rel="noopener noreferrer"
93
- class="hidden md:flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-amber-50 border border-amber-200 text-amber-700 dark:bg-amber-900/20 dark:border-amber-700/30 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-900/40 transition-colors"
94
  >
95
  <svg class="size-3.5" viewBox="0 0 24 24" fill="currentColor">
96
  <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
97
  </svg>
98
- <span class="text-xs font-semibold">{t("starOnGithub")}</span>
 
 
 
99
  </a>
100
  {/* Check for Updates */}
101
  <button
102
  onClick={onCheckUpdate}
103
  disabled={checking}
104
- class="hidden md:flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-gray-200 dark:border-border-dark text-slate-600 dark:text-text-dim hover:bg-slate-50 dark:hover:bg-border-dark disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
105
  >
106
  <svg class={`size-3.5 ${checking ? "animate-spin" : ""}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
107
  <path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.992 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182M20.985 4.356v4.992" />
108
  </svg>
109
- <span class="text-xs font-semibold">
110
- {checking ? t("checkingUpdates") : t("checkForUpdates")}
 
111
  </span>
112
  </button>
113
  {/* Update status message */}
114
  {updateStatusMsg && !checking && (
115
  <button
116
- onClick={onCheckUpdate}
117
- class={`hidden md:inline text-xs font-medium ${updateStatusColor} hover:underline`}
118
  >
119
  {updateStatusMsg}
120
  </button>
@@ -123,7 +117,7 @@ export function Header({ onAddAccount, onCheckUpdate, checking, updateStatusMsg,
123
  <button
124
  onClick={toggleLang}
125
  class="p-2 rounded-lg text-slate-500 dark:text-text-dim hover:bg-slate-100 dark:hover:bg-border-dark transition-colors"
126
- title="\u4e2d/EN"
127
  >
128
  <span class="text-xs font-bold inline-flex items-center justify-center w-5">{lang === "en" ? "EN" : "\u4e2d"}</span>
129
  </button>
@@ -145,7 +139,10 @@ export function Header({ onAddAccount, onCheckUpdate, checking, updateStatusMsg,
145
  <svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
146
  <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75" />
147
  </svg>
148
- <span class="text-xs font-semibold">{t("proxySettings")}</span>
 
 
 
149
  </a>
150
  <button
151
  onClick={onAddAccount}
@@ -164,76 +161,6 @@ export function Header({ onAddAccount, onCheckUpdate, checking, updateStatusMsg,
164
  </div>
165
  </div>
166
  </div>
167
- {/* Expandable update panel */}
168
- {proxyUpdate && proxyUpdate.updateAvailable && (
169
- <div class="border-t border-amber-200 dark:border-amber-700/30 bg-amber-50/80 dark:bg-amber-900/10">
170
- <div class="px-4 md:px-8 lg:px-40 flex justify-center">
171
- <div class="w-full max-w-[960px] py-2">
172
- <div class="flex items-center justify-between">
173
- <span class="text-xs font-medium text-amber-700 dark:text-amber-400">
174
- {proxyUpdate.mode === "git"
175
- ? `${proxyUpdate.commits.length} ${t("commits")} ${t("proxyBehind")}`
176
- : `${t("newVersion")} v${proxyUpdate.release?.version}`}
177
- </span>
178
- <button
179
- onClick={() => setShowChangelog(!showChangelog)}
180
- class="text-xs font-medium text-amber-600 dark:text-amber-400 hover:underline"
181
- >
182
- {showChangelog ? t("hideChanges") : t("viewChanges")}
183
- </button>
184
- </div>
185
- {showChangelog && (
186
- <div class="mt-2">
187
- {proxyUpdate.mode === "git" ? (
188
- <>
189
- <ul class="space-y-0.5 text-xs text-slate-600 dark:text-text-dim max-h-48 overflow-y-auto">
190
- {proxyUpdate.commits.map((c) => (
191
- <li key={c.hash} class="flex gap-2">
192
- <code class="text-primary/70 shrink-0">{c.hash}</code>
193
- <span>{c.message}</span>
194
- </li>
195
- ))}
196
- </ul>
197
- <button
198
- onClick={proxyUpdate.onApply}
199
- disabled={proxyUpdate.applying}
200
- class="mt-2 px-3 py-1.5 text-xs font-semibold bg-primary text-white rounded-md hover:bg-primary-hover disabled:opacity-50 transition-colors"
201
- >
202
- {proxyUpdate.applying ? t("applyingUpdate") : t("updateNow")}
203
- </button>
204
- </>
205
- ) : (
206
- <>
207
- {proxyUpdate.release && (
208
- <pre class="text-xs text-slate-600 dark:text-text-dim whitespace-pre-wrap max-h-48 overflow-y-auto mb-2">
209
- {proxyUpdate.release.body}
210
- </pre>
211
- )}
212
- {proxyUpdate.mode === "docker" ? (
213
- <div class="flex items-center gap-2">
214
- <span class="text-xs text-slate-500 dark:text-text-dim">{t("dockerUpdateCmd")}</span>
215
- <code class="text-xs bg-slate-100 dark:bg-bg-dark px-2 py-0.5 rounded font-mono select-all">
216
- docker compose up -d --build
217
- </code>
218
- </div>
219
- ) : (
220
- <a
221
- href={proxyUpdate.release?.url}
222
- target="_blank"
223
- rel="noopener noreferrer"
224
- class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-primary text-white rounded-md hover:bg-primary-hover transition-colors"
225
- >
226
- {t("downloadUpdate")}
227
- </a>
228
- )}
229
- </>
230
- )}
231
- </div>
232
- )}
233
- </div>
234
- </div>
235
- </div>
236
- )}
237
  </header>
238
  );
239
  }
 
 
1
  import { useI18n } from "../../../shared/i18n/context";
2
  import { useTheme } from "../../../shared/theme/context";
3
 
 
13
  </svg>
14
  );
15
 
 
 
 
 
 
 
 
 
 
16
  interface HeaderProps {
17
  onAddAccount: () => void;
18
  onCheckUpdate: () => void;
19
+ onOpenUpdateModal?: () => void;
20
  checking: boolean;
21
  updateStatusMsg: string | null;
22
  updateStatusColor: string;
23
  version: string | null;
24
  commit?: string | null;
25
  isProxySettings?: boolean;
26
+ hasUpdate?: boolean;
27
  }
28
 
29
+ export function Header({ onAddAccount, onCheckUpdate, onOpenUpdateModal, checking, updateStatusMsg, updateStatusColor, version, commit, isProxySettings, hasUpdate }: HeaderProps) {
30
  const { lang, toggleLang, t } = useI18n();
31
  const { isDark, toggle: toggleTheme } = useTheme();
 
32
 
33
  return (
34
  <header class="sticky top-0 z-50 w-full bg-white dark:bg-card-dark border-b border-gray-200 dark:border-border-dark shadow-sm transition-colors">
 
80
  href="https://github.com/icebear0828/codex-proxy"
81
  target="_blank"
82
  rel="noopener noreferrer"
83
+ class="hidden lg:flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-amber-50 border border-amber-200 text-amber-700 dark:bg-amber-900/20 dark:border-amber-700/30 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-900/40 transition-colors"
84
  >
85
  <svg class="size-3.5" viewBox="0 0 24 24" fill="currentColor">
86
  <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
87
  </svg>
88
+ <span class="text-xs font-semibold inline-grid">
89
+ <span class="invisible col-start-1 row-start-1">Star on GitHub</span>
90
+ <span class="col-start-1 row-start-1">{t("starOnGithub")}</span>
91
+ </span>
92
  </a>
93
  {/* Check for Updates */}
94
  <button
95
  onClick={onCheckUpdate}
96
  disabled={checking}
97
+ class="hidden lg:flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-gray-200 dark:border-border-dark text-slate-600 dark:text-text-dim hover:bg-slate-50 dark:hover:bg-border-dark disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
98
  >
99
  <svg class={`size-3.5 ${checking ? "animate-spin" : ""}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
100
  <path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.992 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182M20.985 4.356v4.992" />
101
  </svg>
102
+ <span class="text-xs font-semibold inline-grid">
103
+ <span class="invisible col-start-1 row-start-1">Check for Updates</span>
104
+ <span class="col-start-1 row-start-1">{checking ? t("checkingUpdates") : t("checkForUpdates")}</span>
105
  </span>
106
  </button>
107
  {/* Update status message */}
108
  {updateStatusMsg && !checking && (
109
  <button
110
+ onClick={hasUpdate && onOpenUpdateModal ? onOpenUpdateModal : onCheckUpdate}
111
+ class={`hidden lg:inline text-xs font-medium ${updateStatusColor} hover:underline`}
112
  >
113
  {updateStatusMsg}
114
  </button>
 
117
  <button
118
  onClick={toggleLang}
119
  class="p-2 rounded-lg text-slate-500 dark:text-text-dim hover:bg-slate-100 dark:hover:bg-border-dark transition-colors"
120
+ title={"\u4e2d/EN"}
121
  >
122
  <span class="text-xs font-bold inline-flex items-center justify-center w-5">{lang === "en" ? "EN" : "\u4e2d"}</span>
123
  </button>
 
139
  <svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
140
  <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75" />
141
  </svg>
142
+ <span class="text-xs font-semibold inline-grid">
143
+ <span class="invisible col-start-1 row-start-1">Proxy Assignment</span>
144
+ <span class="col-start-1 row-start-1">{t("proxySettings")}</span>
145
+ </span>
146
  </a>
147
  <button
148
  onClick={onAddAccount}
 
161
  </div>
162
  </div>
163
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  </header>
165
  );
166
  }
web/src/components/UpdateModal.tsx ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from "preact/hooks";
2
+ import { useI18n } from "../../../shared/i18n/context";
3
+
4
+ interface UpdateModalProps {
5
+ open: boolean;
6
+ onClose: () => void;
7
+ mode: "git" | "docker" | "electron";
8
+ commits: { hash: string; message: string }[];
9
+ release: { version: string; body: string; url: string } | null;
10
+ onApply: () => void;
11
+ applying: boolean;
12
+ restarting: boolean;
13
+ restartFailed: boolean;
14
+ }
15
+
16
+ export function UpdateModal({
17
+ open,
18
+ onClose,
19
+ mode,
20
+ commits,
21
+ release,
22
+ onApply,
23
+ applying,
24
+ restarting,
25
+ restartFailed,
26
+ }: UpdateModalProps) {
27
+ const { t } = useI18n();
28
+ const dialogRef = useRef<HTMLDialogElement>(null);
29
+
30
+ useEffect(() => {
31
+ const dialog = dialogRef.current;
32
+ if (!dialog) return;
33
+ if (open && !dialog.open) {
34
+ dialog.showModal();
35
+ } else if (!open && dialog.open) {
36
+ dialog.close();
37
+ }
38
+ }, [open]);
39
+
40
+ // Close on backdrop click
41
+ const handleBackdropClick = (e: MouseEvent) => {
42
+ if (e.target === dialogRef.current && !restarting && !applying) {
43
+ onClose();
44
+ }
45
+ };
46
+
47
+ // Close on Escape
48
+ const handleCancel = (e: Event) => {
49
+ if (restarting || applying) {
50
+ e.preventDefault();
51
+ }
52
+ };
53
+
54
+ if (!open) return null;
55
+
56
+ return (
57
+ <dialog
58
+ ref={dialogRef}
59
+ onClick={handleBackdropClick}
60
+ onCancel={handleCancel}
61
+ class="backdrop:bg-black/50 bg-transparent p-0 m-auto max-w-lg w-full open:flex"
62
+ >
63
+ <div class="w-full bg-white dark:bg-card-dark rounded-xl shadow-2xl border border-gray-200 dark:border-border-dark overflow-hidden">
64
+ {/* Header */}
65
+ <div class="px-5 py-4 border-b border-gray-200 dark:border-border-dark flex items-center justify-between">
66
+ <h2 class="text-base font-bold text-slate-800 dark:text-text-main">
67
+ {t("updateTitle")}
68
+ </h2>
69
+ {!restarting && !applying && (
70
+ <button
71
+ onClick={onClose}
72
+ class="p-1 rounded-md text-slate-400 hover:text-slate-600 dark:text-text-dim dark:hover:text-text-main hover:bg-slate-100 dark:hover:bg-border-dark transition-colors"
73
+ >
74
+ <svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
75
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
76
+ </svg>
77
+ </button>
78
+ )}
79
+ </div>
80
+
81
+ {/* Body */}
82
+ <div class="px-5 py-4">
83
+ {restarting ? (
84
+ <div class="flex flex-col items-center gap-3 py-6">
85
+ <svg class="size-8 animate-spin text-primary" viewBox="0 0 24 24" fill="none">
86
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
87
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
88
+ </svg>
89
+ <span class="text-sm font-medium text-slate-600 dark:text-text-dim">
90
+ {t("updateRestarting")}
91
+ </span>
92
+ </div>
93
+ ) : restartFailed ? (
94
+ <div class="flex flex-col items-center gap-3 py-6">
95
+ <svg class="size-8 text-red-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
96
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
97
+ </svg>
98
+ <span class="text-sm font-medium text-red-600 dark:text-red-400">
99
+ {t("restartFailed")}
100
+ </span>
101
+ </div>
102
+ ) : mode === "git" ? (
103
+ <ul class="space-y-1 text-sm text-slate-600 dark:text-text-dim max-h-64 overflow-y-auto">
104
+ {commits.map((c) => (
105
+ <li key={c.hash} class="flex gap-2 py-0.5">
106
+ <code class="text-primary/70 text-xs shrink-0 pt-0.5">{c.hash}</code>
107
+ <span class="text-xs">{c.message}</span>
108
+ </li>
109
+ ))}
110
+ </ul>
111
+ ) : (
112
+ <>
113
+ {release && (
114
+ <pre class="text-xs text-slate-600 dark:text-text-dim whitespace-pre-wrap max-h-64 overflow-y-auto leading-relaxed">
115
+ {release.body}
116
+ </pre>
117
+ )}
118
+ </>
119
+ )}
120
+ </div>
121
+
122
+ {/* Footer */}
123
+ {!restarting && !restartFailed && (
124
+ <div class="px-5 py-3 border-t border-gray-200 dark:border-border-dark flex items-center justify-end gap-2">
125
+ <button
126
+ onClick={onClose}
127
+ disabled={applying}
128
+ class="px-4 py-2 text-xs font-semibold text-slate-600 dark:text-text-dim hover:bg-slate-100 dark:hover:bg-border-dark rounded-lg transition-colors disabled:opacity-50"
129
+ >
130
+ {t("cancelBtn")}
131
+ </button>
132
+ {mode === "git" ? (
133
+ <button
134
+ onClick={onApply}
135
+ disabled={applying}
136
+ class="px-4 py-2 text-xs font-semibold bg-primary text-white rounded-lg hover:bg-primary-hover disabled:opacity-50 transition-colors flex items-center gap-1.5"
137
+ >
138
+ {applying && (
139
+ <svg class="size-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
140
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
141
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
142
+ </svg>
143
+ )}
144
+ {applying ? t("applyingUpdate") : t("updateNow")}
145
+ </button>
146
+ ) : mode === "docker" ? (
147
+ <button
148
+ onClick={() => { navigator.clipboard.writeText("docker compose up -d --build"); }}
149
+ class="px-4 py-2 text-xs font-semibold bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors"
150
+ >
151
+ {t("copy")} docker compose up -d --build
152
+ </button>
153
+ ) : (
154
+ <a
155
+ href={release?.url}
156
+ target="_blank"
157
+ rel="noopener noreferrer"
158
+ class="px-4 py-2 text-xs font-semibold bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors inline-flex"
159
+ >
160
+ {t("downloadUpdate")}
161
+ </a>
162
+ )}
163
+ </div>
164
+ )}
165
+
166
+ {/* Close button for restartFailed state */}
167
+ {restartFailed && (
168
+ <div class="px-5 py-3 border-t border-gray-200 dark:border-border-dark flex justify-end">
169
+ <button
170
+ onClick={onClose}
171
+ class="px-4 py-2 text-xs font-semibold text-slate-600 dark:text-text-dim hover:bg-slate-100 dark:hover:bg-border-dark rounded-lg transition-colors"
172
+ >
173
+ {t("close")}
174
+ </button>
175
+ </div>
176
+ )}
177
+ </div>
178
+ </dialog>
179
+ );
180
+ }
web/src/hooks/use-accounts.ts CHANGED
@@ -78,25 +78,48 @@ export function useAccounts() {
78
  window.open(data.authUrl, "oauth_add", "width=600,height=700,scrollbars=yes");
79
  setAddVisible(true);
80
 
81
- // Poll for new account
82
  const prevResp = await fetch("/auth/accounts");
83
  const prevData = await prevResp.json();
84
  const prevCount = prevData.accounts?.length || 0;
85
 
86
- const pollTimer = setInterval(async () => {
 
 
 
87
  try {
88
  const r = await fetch("/auth/accounts");
89
  const d = await r.json();
90
  if ((d.accounts?.length || 0) > prevCount) {
91
- clearInterval(pollTimer);
92
  setAddVisible(false);
93
  setAddInfo("accountAdded");
94
  await loadAccounts();
95
  }
96
- } catch {}
97
- }, 2000);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
 
99
- setTimeout(() => clearInterval(pollTimer), 5 * 60 * 1000);
 
 
 
 
 
100
  } catch (err) {
101
  setAddError(err instanceof Error ? err.message : "failedStartLogin");
102
  }
 
78
  window.open(data.authUrl, "oauth_add", "width=600,height=700,scrollbars=yes");
79
  setAddVisible(true);
80
 
81
+ // Poll for new account + focus/visibility detection
82
  const prevResp = await fetch("/auth/accounts");
83
  const prevData = await prevResp.json();
84
  const prevCount = prevData.accounts?.length || 0;
85
 
86
+ let checking = false;
87
+ const checkForNewAccount = async () => {
88
+ if (checking) return;
89
+ checking = true;
90
  try {
91
  const r = await fetch("/auth/accounts");
92
  const d = await r.json();
93
  if ((d.accounts?.length || 0) > prevCount) {
94
+ cleanup();
95
  setAddVisible(false);
96
  setAddInfo("accountAdded");
97
  await loadAccounts();
98
  }
99
+ } catch {} finally {
100
+ checking = false;
101
+ }
102
+ };
103
+
104
+ // Focus event — check immediately when window regains focus
105
+ const onFocus = () => { checkForNewAccount(); };
106
+ window.addEventListener("focus", onFocus);
107
+
108
+ // Visibility change — check when tab becomes visible
109
+ const onVisible = () => {
110
+ if (document.visibilityState === "visible") checkForNewAccount();
111
+ };
112
+ document.addEventListener("visibilitychange", onVisible);
113
+
114
+ // Interval polling as fallback
115
+ const pollTimer = setInterval(checkForNewAccount, 2000);
116
 
117
+ const cleanup = () => {
118
+ clearInterval(pollTimer);
119
+ window.removeEventListener("focus", onFocus);
120
+ document.removeEventListener("visibilitychange", onVisible);
121
+ };
122
+ setTimeout(cleanup, 5 * 60 * 1000);
123
  } catch (err) {
124
  setAddError(err instanceof Error ? err.message : "failedStartLogin");
125
  }