diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 978f917..f49402f 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -27,6 +27,8 @@ type GitPathsCheck = { const HEALTHCHECK_INTERVAL_MS = APP_CONFIG.healthcheckIntervalMs; const HEALTHCHECK_ENDPOINT_FALLBACK = SERVER_HEALTHCHECK_URL; +const MIN_SERVER_CHECK_SCREEN_MS = 1500; +const IS_DEV = import.meta.env.DEV; const App = () => { const [screen, setScreen] = useState("serverCheck"); @@ -37,6 +39,10 @@ const App = () => { const [serverCheckedUrl, setServerCheckedUrl] = useState(); const [serverCheckInProgress, setServerCheckInProgress] = useState(false); const [serverCheckedAt, setServerCheckedAt] = useState(); + const [serverCheckStartedAt, setServerCheckStartedAt] = useState(Date.now()); + const [serverRecoveryActive, setServerRecoveryActive] = useState(false); + const [skipToolsCheck, setSkipToolsCheck] = useState(false); + const [devProceedReady, setDevProceedReady] = useState(false); const [gitCheckInProgress, setGitCheckInProgress] = useState(false); const [gitCheckedAt, setGitCheckedAt] = useState(); const [gitCheckResult, setGitCheckResult] = useState(); @@ -62,6 +68,7 @@ const App = () => { const healthCheckInFlightRef = useRef(false); const gitCheckInFlightRef = useRef(false); const gitCheckedRef = useRef(false); + const transitionTimeoutRef = useRef(); const runGitCheck = useCallback(async (force = false) => { if (force) { @@ -69,7 +76,7 @@ const App = () => { } if (gitCheckInFlightRef.current || (!force && gitCheckedRef.current)) { - return; + return gitCheckResult; } gitCheckInFlightRef.current = true; @@ -84,78 +91,193 @@ const App = () => { setGitCheckResult(result); setGitCheckedAt(result.checkedAt ?? Date.now()); gitCheckedRef.current = true; + return result; } catch (error) { const message = error instanceof Error ? error.message : String(error); - setGitCheckResult({ + const fallback = { git: { ok: false, command: "git", error: message }, lfs: { ok: false, command: "git lfs", error: message }, checkedAt: Date.now() - }); + }; + setGitCheckResult(fallback); setGitCheckedAt(Date.now()); gitCheckedRef.current = true; + return fallback; } finally { gitCheckInFlightRef.current = false; setGitCheckInProgress(false); } + }, [gitCheckResult]); + + const clearTransitionTimeout = useCallback(() => { + if (transitionTimeoutRef.current) { + window.clearTimeout(transitionTimeoutRef.current); + transitionTimeoutRef.current = undefined; + } }, []); - const runHealthCheck = useCallback(async () => { - if (healthCheckInFlightRef.current) { - return; - } + const scheduleScreenTransition = useCallback( + (nextScreen: Screen) => { + clearTransitionTimeout(); + const elapsedMs = Date.now() - serverCheckStartedAt; + const remainingMs = Math.max(0, MIN_SERVER_CHECK_SCREEN_MS - elapsedMs); - healthCheckInFlightRef.current = true; - setServerCheckInProgress(true); - setServerError(undefined); - - try { - if (!window.sptLauncher?.checkServerHealth) { - throw new Error("preload(preload) 미로딩: window.sptLauncher가 없습니다."); + if (remainingMs > 0) { + transitionTimeoutRef.current = window.setTimeout(() => { + setScreen(nextScreen); + }, remainingMs); + return; } - const result = await window.sptLauncher.checkServerHealth(); - setServerHealthy(result.ok); - setServerStatusCode(result.status); - setServerLatencyMs(result.latencyMs); - setServerError(result.error); - setServerCheckedUrl(result.url ?? HEALTHCHECK_ENDPOINT_FALLBACK); - setServerCheckedAt(Date.now()); + setScreen(nextScreen); + }, + [clearTransitionTimeout, serverCheckStartedAt] + ); - if (screen === "serverCheck" && result.ok) { - if (!gitCheckedRef.current) { - await runGitCheck(); - } - setScreen(hasSession ? "main" : "login"); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const enterRecoveryMode = useCallback( + (reason: string) => { + setServerRecoveryActive(true); setServerHealthy(false); setServerStatusCode(undefined); setServerLatencyMs(undefined); - setServerError(message); + setServerError(reason); setServerCheckedUrl(undefined); setServerCheckedAt(Date.now()); - } finally { - healthCheckInFlightRef.current = false; - setServerCheckInProgress(false); + setHasSession(false); + setProfile(undefined); + setSkipToolsCheck(false); + setDevProceedReady(false); + setScreen("serverCheck"); + clearTransitionTimeout(); + }, + [clearTransitionTimeout] + ); + + const isTimeoutError = (error?: string) => { + if (!error) { + return false; } - }, [screen, hasSession]); + return error.toLowerCase().includes("timeout"); + }; + + const runHealthCheck = useCallback( + async (trigger: "initial" | "manual" | "recovery" = "initial") => { + if (healthCheckInFlightRef.current) { + return; + } + + healthCheckInFlightRef.current = true; + setServerCheckInProgress(true); + setServerError(undefined); + let isHealthy = false; + let failureReason = "healthcheck_failed"; + + try { + if (!window.sptLauncher?.checkServerHealth) { + throw new Error("preload(preload) 미로딩: window.sptLauncher가 없습니다."); + } + + const result = await window.sptLauncher.checkServerHealth(); + isHealthy = result.ok; + if (!result.ok) { + failureReason = result.error ?? "healthcheck_failed"; + } + setServerHealthy(result.ok); + setServerStatusCode(result.status); + setServerLatencyMs(result.latencyMs); + setServerError(result.error); + setServerCheckedUrl(result.url ?? HEALTHCHECK_ENDPOINT_FALLBACK); + setServerCheckedAt(Date.now()); + + if (screen === "serverCheck" && result.ok) { + const gitResult = !gitCheckedRef.current ? await runGitCheck() : gitCheckResult; + const toolsReady = Boolean(gitResult?.git.ok && gitResult?.lfs.ok); + + if (serverRecoveryActive) { + setServerRecoveryActive(false); + if (IS_DEV) { + setDevProceedReady(true); + } else { + scheduleScreenTransition("login"); + } + return; + } + + if (!toolsReady && gitResult && !skipToolsCheck) { + return; + } + + if (IS_DEV) { + setDevProceedReady(true); + return; + } + + scheduleScreenTransition(hasSession ? "main" : "login"); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + failureReason = message; + setServerHealthy(false); + setServerStatusCode(undefined); + setServerLatencyMs(undefined); + setServerError(message); + setServerCheckedUrl(undefined); + setServerCheckedAt(Date.now()); + } finally { + healthCheckInFlightRef.current = false; + setServerCheckInProgress(false); + } + + if (trigger === "manual" && !isHealthy) { + enterRecoveryMode(failureReason); + } + }, + [ + screen, + hasSession, + serverRecoveryActive, + runGitCheck, + gitCheckResult, + scheduleScreenTransition, + enterRecoveryMode, + skipToolsCheck + ] + ); + + const handleManualHealthCheck = () => { + void runHealthCheck("manual"); + }; useEffect(() => { if (screen !== "serverCheck") { return; } - void runHealthCheck(); - }, [screen, runHealthCheck]); + void runHealthCheck(serverRecoveryActive ? "recovery" : "initial"); + }, [screen, serverRecoveryActive, runHealthCheck]); useEffect(() => { + if (screen !== "serverCheck") { + return; + } + setServerCheckStartedAt(Date.now()); + setDevProceedReady(false); + }, [screen]); + + useEffect(() => { + if (!serverRecoveryActive) { + return; + } const interval = window.setInterval(() => { - void runHealthCheck(); + void runHealthCheck("recovery"); }, HEALTHCHECK_INTERVAL_MS); return () => window.clearInterval(interval); - }, [runHealthCheck]); + }, [serverRecoveryActive, runHealthCheck]); + + useEffect(() => { + return () => clearTransitionTimeout(); + }, [clearTransitionTimeout]); const serverStatusLabel = useMemo(() => { return serverHealthy ? "정상" : "불가"; @@ -311,6 +433,10 @@ const App = () => { username: trimmedId }); if (!result.ok) { + if (isTimeoutError(result.error)) { + enterRecoveryMode("timeout"); + return; + } window.alert(result.error ?? "프로필 조회에 실패했습니다."); return; } @@ -325,6 +451,10 @@ const App = () => { setScreen("main"); } catch (error) { const message = error instanceof Error ? error.message : String(error); + if (isTimeoutError(message)) { + enterRecoveryMode("timeout"); + return; + } window.alert(message); } finally { setProfileLoading(false); @@ -363,6 +493,10 @@ const App = () => { try { const result = await window.sptLauncher.downloadProfile({ username }); if (!result.ok) { + if (isTimeoutError(result.error)) { + enterRecoveryMode("timeout"); + return; + } window.alert(result.error ?? "프로필 다운로드에 실패했습니다."); return; } @@ -370,6 +504,10 @@ const App = () => { window.alert(`프로필을 다운로드했습니다: ${result.filePath ?? "-"}`); } catch (error) { const message = error instanceof Error ? error.message : String(error); + if (isTimeoutError(message)) { + enterRecoveryMode("timeout"); + return; + } window.alert(message); } finally { setProfileActionInProgress(false); @@ -397,6 +535,10 @@ const App = () => { try { const result = await window.sptLauncher.resetProfile({ username }); if (!result.ok) { + if (isTimeoutError(result.error)) { + enterRecoveryMode("timeout"); + return; + } window.alert(result.error ?? "프로필 리셋에 실패했습니다."); return; } @@ -404,6 +546,10 @@ const App = () => { window.alert("프로필 리셋 요청이 완료되었습니다."); } catch (error) { const message = error instanceof Error ? error.message : String(error); + if (isTimeoutError(message)) { + enterRecoveryMode("timeout"); + return; + } window.alert(message); } finally { setProfileActionInProgress(false); @@ -442,43 +588,73 @@ const App = () => { {screen === "serverCheck" && ( -
-

서버 상태 확인 중

+
+

서버 연결 및 도구 상태 점검

{serverCheckInProgress ? "체크 중..." : serverHealthy ? "정상" : "불가"}

-

- {serverHealthy - ? "서버 상태 확인 완료. 로그인 화면으로 이동합니다." - : "서버 연결이 불가합니다. 10초마다 자동으로 재시도합니다."} -

-

- {serverCheckedUrl ? `엔드포인트: ${serverCheckedUrl}` : "엔드포인트: "} - {serverStatusCode ? ` · HTTP ${serverStatusCode}` : ""} - {typeof serverLatencyMs === "number" ? ` · ${serverLatencyMs}ms` : ""} - {serverError ? ` · ${serverError}` : ""} - {serverCheckedAt ? ` · ${new Date(serverCheckedAt).toLocaleTimeString()}` : ""} -

+
+
+ 서버 + + {serverRecoveryActive && 복구 중} +
+
+ Git + +
+
+ Git LFS + +
+
{!serverCheckInProgress && !serverHealthy && ( - <> - - + )} - {serverHealthy && ( + {serverHealthy && gitCheckResult && !(gitCheckResult.git.ok && gitCheckResult.lfs.ok) && (
-
- Git - + 도구 설치 필요 +
+ Git/Git LFS 감지에 실패했습니다. 자동 설치(Windows) 또는 수동 설치 후 재확인해주세요.
-
- Git LFS - +
+ + +
- {gitCheckedAt ?
{new Date(gitCheckedAt).toLocaleTimeString()}
: null}
)} + {IS_DEV && + serverHealthy && + (skipToolsCheck || + Boolean(gitCheckResult?.git.ok && gitCheckResult?.lfs.ok)) && ( + + )}
)} @@ -641,7 +817,9 @@ const App = () => { {screen !== "serverCheck" && (