Spaces:
Paused
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 +4 -0
- shared/hooks/use-accounts.ts +29 -6
- shared/hooks/use-update-status.ts +63 -5
- shared/i18n/translations.ts +8 -0
- src/auth/oauth-pkce.ts +19 -0
- src/index.ts +18 -1
- src/routes/auth.ts +14 -0
- src/self-update.ts +30 -3
- web/src/App.tsx +34 -11
- web/src/components/Header.tsx +19 -92
- web/src/components/UpdateModal.tsx +180 -0
- web/src/hooks/use-accounts.ts +29 -6
|
@@ -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)
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
| 63 |
try {
|
| 64 |
const r = await fetch("/auth/accounts");
|
| 65 |
const d = await r.json();
|
| 66 |
if ((d.accounts?.length || 0) > prevCount) {
|
| 67 |
-
|
| 68 |
setAddVisible(false);
|
| 69 |
setAddInfo("accountAdded");
|
| 70 |
await loadAccounts();
|
| 71 |
}
|
| 72 |
-
} catch {}
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|
|
@@ -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 |
}
|
|
@@ -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 |
|
|
@@ -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 };
|
|
@@ -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 |
|
|
@@ -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 |
|
|
@@ -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.
|
| 255 |
_proxyUpdateInProgress = false;
|
| 256 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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);
|
|
@@ -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
|
|
|
|
| 55 |
? {
|
| 56 |
-
mode: update.status.proxy.mode,
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
release: update.status.proxy.release,
|
| 60 |
-
onApply: update.applyUpdate,
|
| 61 |
-
applying: update.applying,
|
| 62 |
}
|
| 63 |
: null;
|
| 64 |
|
| 65 |
-
return { ...update, msg, color,
|
| 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 |
-
|
| 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 |
-
|
| 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 |
</>
|
|
@@ -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 |
-
|
| 36 |
}
|
| 37 |
|
| 38 |
-
export function Header({ onAddAccount, onCheckUpdate, checking, updateStatusMsg, updateStatusColor, version, commit, isProxySettings,
|
| 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
|
| 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">
|
|
|
|
|
|
|
|
|
|
| 99 |
</a>
|
| 100 |
{/* Check for Updates */}
|
| 101 |
<button
|
| 102 |
onClick={onCheckUpdate}
|
| 103 |
disabled={checking}
|
| 104 |
-
class="hidden
|
| 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 |
-
|
|
|
|
| 111 |
</span>
|
| 112 |
</button>
|
| 113 |
{/* Update status message */}
|
| 114 |
{updateStatusMsg && !checking && (
|
| 115 |
<button
|
| 116 |
-
onClick={onCheckUpdate}
|
| 117 |
-
class={`hidden
|
| 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">
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
| 87 |
try {
|
| 88 |
const r = await fetch("/auth/accounts");
|
| 89 |
const d = await r.json();
|
| 90 |
if ((d.accounts?.length || 0) > prevCount) {
|
| 91 |
-
|
| 92 |
setAddVisible(false);
|
| 93 |
setAddInfo("accountAdded");
|
| 94 |
await loadAccounts();
|
| 95 |
}
|
| 96 |
-
} catch {}
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|