diff --git a/src/main/main.ts b/src/main/main.ts index cf41481..d46df2b 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,4 +1,5 @@ import { app, BrowserWindow, ipcMain, net, session } from "electron"; +import { execFile } from "node:child_process"; import * as fs from "node:fs/promises"; import * as path from "node:path"; @@ -18,6 +19,42 @@ const SERVER_BASE_URL = "https://pandoli365.com:5069/"; const SERVER_HEALTHCHECK_PATH = "/launcher/ping"; const SERVER_HEALTHCHECK_TIMEOUT_MS = 2000; const SERVER_REQUEST_TIMEOUT_MS = 4000; +const COMMAND_TIMEOUT_MS = 4000; +const INSTALL_TIMEOUT_MS = 10 * 60 * 1000; + +type CommandCheckResult = { + ok: boolean; + command: string; + version?: string; + error?: string; +}; + +type CommandPathResult = { + ok: boolean; + command: string; + path?: string; + error?: string; +}; + +type CommandRunResult = { + ok: boolean; + command: string; + args: string[]; + output?: string; + error?: string; +}; + +type InstallStep = { + name: string; + result: CommandRunResult; +}; + +type InstallGitToolsResult = { + ok: boolean; + error?: string; + steps: InstallStep[]; + check?: Awaited>; +}; const checkServerHealth = async (): Promise => { const startedAt = Date.now(); @@ -78,6 +115,153 @@ const checkServerHealth = async (): Promise => { }); }; +const checkCommand = async (command: string, args: string[]): Promise => { + return await new Promise((resolve) => { + execFile(command, args, { timeout: COMMAND_TIMEOUT_MS }, (error, stdout, stderr) => { + if (error) { + resolve({ ok: false, command, error: error.message }); + return; + } + + const output = `${stdout ?? ""}${stderr ?? ""}`.trim(); + resolve({ ok: true, command, version: output }); + }); + }); +}; + +const checkGitTools = async () => { + const git = await checkCommand("git", ["--version"]); + const lfs = await checkCommand("git", ["lfs", "version"]); + + return { + git, + lfs, + checkedAt: Date.now() + }; +}; + +const resolveCommandPath = async (command: string): Promise => { + const locator = process.platform === "win32" ? "where" : "which"; + + return await new Promise((resolve) => { + execFile(locator, [command], { timeout: COMMAND_TIMEOUT_MS }, (error, stdout) => { + if (error) { + resolve({ ok: false, command, error: error.message }); + return; + } + + const output = String(stdout ?? "").trim(); + const firstLine = output.split(/\r?\n/).find((line) => line.trim().length > 0); + resolve({ ok: Boolean(firstLine), command, path: firstLine }); + }); + }); +}; + +const runCommand = async ( + command: string, + args: string[], + timeoutMs: number +): Promise => { + return await new Promise((resolve) => { + execFile(command, args, { timeout: timeoutMs }, (error, stdout, stderr) => { + if (error) { + resolve({ + ok: false, + command, + args, + error: error.message, + output: `${stdout ?? ""}${stderr ?? ""}`.trim() + }); + return; + } + + const output = `${stdout ?? ""}${stderr ?? ""}`.trim(); + resolve({ ok: true, command, args, output }); + }); + }); +}; + +const getGitPaths = async () => { + const git = await resolveCommandPath("git"); + const lfs = await resolveCommandPath("git-lfs"); + + return { + git, + lfs, + checkedAt: Date.now() + }; +}; + +const installGitTools = async (): Promise => { + if (process.platform !== "win32") { + return { ok: false, error: "Windows에서만 자동 설치가 가능합니다.", steps: [] }; + } + + const wingetPath = await resolveCommandPath("winget"); + if (!wingetPath.ok) { + return { + ok: false, + error: "winget이 설치되어 있지 않습니다.", + steps: [ + { + name: "winget 확인", + result: { + ok: false, + command: "winget", + args: ["--version"], + error: wingetPath.error + } + } + ] + }; + } + + const steps: InstallStep[] = []; + + const gitInstall = await runCommand( + "winget", + [ + "install", + "--id", + "Git.Git", + "-e", + "--source", + "winget", + "--accept-source-agreements", + "--accept-package-agreements", + "--silent" + ], + INSTALL_TIMEOUT_MS + ); + steps.push({ name: "Git 설치", result: gitInstall }); + + const lfsInstall = await runCommand( + "winget", + [ + "install", + "--id", + "GitHub.GitLFS", + "-e", + "--source", + "winget", + "--accept-source-agreements", + "--accept-package-agreements", + "--silent" + ], + INSTALL_TIMEOUT_MS + ); + steps.push({ name: "Git LFS 설치", result: lfsInstall }); + + const ok = steps.every((step) => step.result.ok); + const check = await checkGitTools(); + + return { + ok, + steps, + check + }; +}; + const postJson = async ( path: string, body: unknown, @@ -288,6 +472,18 @@ app.whenReady().then(() => { return await checkServerHealth(); }); + ipcMain.handle("spt:checkGitTools", async () => { + return await checkGitTools(); + }); + + ipcMain.handle("spt:getGitPaths", async () => { + return await getGitPaths(); + }); + + ipcMain.handle("spt:installGitTools", async () => { + return await installGitTools(); + }); + ipcMain.handle("spt:fetchProfile", async (_event, payload: { username: string }) => { const username = payload.username.trim(); diff --git a/src/preload/preload.ts b/src/preload/preload.ts index f59fb1b..dea4d52 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -5,6 +5,9 @@ console.info("[spt-launcher] preload loaded"); contextBridge.exposeInMainWorld("sptLauncher", { appName: "SPT Launcher", checkServerHealth: () => ipcRenderer.invoke("spt:checkServerHealth"), + checkGitTools: () => ipcRenderer.invoke("spt:checkGitTools"), + getGitPaths: () => ipcRenderer.invoke("spt:getGitPaths"), + installGitTools: () => ipcRenderer.invoke("spt:installGitTools"), fetchProfile: (payload: { username: string }) => ipcRenderer.invoke("spt:fetchProfile", payload), downloadProfile: (payload: { username: string }) => diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 5ae942d..5173185 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,6 +1,28 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; type Screen = "serverCheck" | "login" | "signup" | "resetPassword" | "main"; +type CommandCheck = { + ok: boolean; + command: string; + version?: string; + error?: string; +}; +type GitToolsCheck = { + git: CommandCheck; + lfs: CommandCheck; + checkedAt: number; +}; +type CommandPath = { + ok: boolean; + command: string; + path?: string; + error?: string; +}; +type GitPathsCheck = { + git: CommandPath; + lfs: CommandPath; + checkedAt: number; +}; const HEALTHCHECK_INTERVAL_MS = 10000; const HEALTHCHECK_ENDPOINT_FALLBACK = "https://pandoli365.com:5069/launcher/ping"; @@ -14,6 +36,14 @@ const App = () => { const [serverCheckedUrl, setServerCheckedUrl] = useState(); const [serverCheckInProgress, setServerCheckInProgress] = useState(false); const [serverCheckedAt, setServerCheckedAt] = useState(); + const [gitCheckInProgress, setGitCheckInProgress] = useState(false); + const [gitCheckedAt, setGitCheckedAt] = useState(); + const [gitCheckResult, setGitCheckResult] = useState(); + const [gitPathsLoading, setGitPathsLoading] = useState(false); + const [gitPathsResult, setGitPathsResult] = useState(); + const [toolsModalOpen, setToolsModalOpen] = useState(false); + const [installInProgress, setInstallInProgress] = useState(false); + const [installMessage, setInstallMessage] = useState(); const [hasSession, setHasSession] = useState(false); const [loginId, setLoginId] = useState(""); const [loginPassword, setLoginPassword] = useState(""); @@ -29,6 +59,44 @@ const App = () => { const [syncInProgress, setSyncInProgress] = useState(false); const [simulateSyncFail, setSimulateSyncFail] = useState(false); const healthCheckInFlightRef = useRef(false); + const gitCheckInFlightRef = useRef(false); + const gitCheckedRef = useRef(false); + + const runGitCheck = useCallback(async (force = false) => { + if (force) { + gitCheckedRef.current = false; + } + + if (gitCheckInFlightRef.current || (!force && gitCheckedRef.current)) { + return; + } + + gitCheckInFlightRef.current = true; + setGitCheckInProgress(true); + + try { + if (!window.sptLauncher?.checkGitTools) { + throw new Error("preload(preload) 미로딩: window.sptLauncher.checkGitTools가 없습니다."); + } + + const result = await window.sptLauncher.checkGitTools(); + setGitCheckResult(result); + setGitCheckedAt(result.checkedAt ?? Date.now()); + gitCheckedRef.current = true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setGitCheckResult({ + git: { ok: false, command: "git", error: message }, + lfs: { ok: false, command: "git lfs", error: message }, + checkedAt: Date.now() + }); + setGitCheckedAt(Date.now()); + gitCheckedRef.current = true; + } finally { + gitCheckInFlightRef.current = false; + setGitCheckInProgress(false); + } + }, []); const runHealthCheck = useCallback(async () => { if (healthCheckInFlightRef.current) { @@ -53,6 +121,9 @@ const App = () => { setServerCheckedAt(Date.now()); if (screen === "serverCheck" && result.ok) { + if (!gitCheckedRef.current) { + await runGitCheck(); + } setScreen(hasSession ? "main" : "login"); } } catch (error) { @@ -89,6 +160,91 @@ const App = () => { return serverHealthy ? "정상" : "불가"; }, [serverHealthy]); + const serverStatusLedClass = serverCheckInProgress ? "idle" : serverHealthy ? "ok" : "blocked"; + const gitStatusLedClass = (() => { + if (gitCheckInProgress) { + return "idle"; + } + if (!gitCheckResult) { + return "idle"; + } + if (gitCheckResult.git.ok && gitCheckResult.lfs.ok) { + return "ok"; + } + return "blocked"; + })(); + const gitLedClass = gitCheckInProgress + ? "idle" + : !gitCheckResult + ? "idle" + : gitCheckResult.git.ok + ? "ok" + : "blocked"; + const lfsLedClass = gitCheckInProgress + ? "idle" + : !gitCheckResult + ? "idle" + : gitCheckResult.lfs.ok + ? "ok" + : "blocked"; + + const handleOpenToolsModal = () => { + setToolsModalOpen(true); + }; + + const handleRecheckTools = async () => { + await runGitCheck(true); + }; + + const handleInstallTools = async () => { + if (!window.sptLauncher?.installGitTools) { + window.alert("자동 설치 기능이 준비되지 않았습니다."); + return; + } + + setInstallInProgress(true); + setInstallMessage(undefined); + try { + const result = await window.sptLauncher.installGitTools(); + if (!result.ok) { + setInstallMessage(result.error ?? "자동 설치에 실패했습니다."); + } else { + setInstallMessage("자동 설치가 완료되었습니다."); + } + if (result.check) { + setGitCheckResult(result.check); + setGitCheckedAt(result.check.checkedAt ?? Date.now()); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setInstallMessage(message); + } finally { + setInstallInProgress(false); + } + }; + + const handleFetchGitPaths = async () => { + if (!window.sptLauncher?.getGitPaths) { + window.alert("경로 확인 기능이 준비되지 않았습니다."); + return; + } + + setGitPathsLoading(true); + try { + const result = await window.sptLauncher.getGitPaths(); + setGitPathsResult(result); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setGitPathsResult({ + git: { ok: false, command: "git", error: message }, + lfs: { ok: false, command: "git-lfs", error: message }, + checkedAt: Date.now() + }); + } finally { + setGitPathsLoading(false); + } + }; + const asObject = (value: unknown) => value && typeof value === "object" ? (value as Record) : undefined; const pickString = (...values: unknown[]) => @@ -296,7 +452,7 @@ const App = () => { : "서버 연결이 불가합니다. 10초마다 자동으로 재시도합니다."}

- {serverCheckedUrl ? `Endpoint(endpoint): ${serverCheckedUrl}` : "Endpoint(endpoint): "} + {serverCheckedUrl ? `엔드포인트: ${serverCheckedUrl}` : "엔드포인트: "} {serverStatusCode ? ` · HTTP ${serverStatusCode}` : ""} {typeof serverLatencyMs === "number" ? ` · ${serverLatencyMs}ms` : ""} {serverError ? ` · ${serverError}` : ""} @@ -309,6 +465,19 @@ const App = () => { )} + {serverHealthy && ( +

+
+ Git + +
+
+ Git LFS + +
+ {gitCheckedAt ?
{new Date(gitCheckedAt).toLocaleTimeString()}
: null} +
+ )} )} @@ -468,6 +637,80 @@ const App = () => { )} + {screen !== "serverCheck" && ( + + )} + {screen !== "serverCheck" && toolsModalOpen && ( +
setToolsModalOpen(false)}> +
event.stopPropagation()} + > +
+

도구 상태

+ +
+
+
+ Git + +
+
+ Git LFS + +
+
+ + + +
+ {installMessage &&
{installMessage}
} + {gitPathsResult && ( +
+ 경로 확인 +
+
Git: {gitPathsResult.git.ok ? gitPathsResult.git.path : gitPathsResult.git.error}
+
Git LFS: {gitPathsResult.lfs.ok ? gitPathsResult.lfs.path : gitPathsResult.lfs.error}
+
+
+ )} +
+
+
+ )} ); }; diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 4610836..a8bf4ea 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -190,3 +190,149 @@ button:disabled { .muted { color: #a8b0c1; } + +.status-bar { + position: fixed; + left: 20px; + right: 20px; + bottom: 16px; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + padding: 6px 8px; + background: #141923; + border: 1px solid #242a35; + border-radius: 10px; +} + +.status-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 4px 8px; + background: #0f141d; + border: 1px solid #242a35; + border-radius: 8px; + flex: 0 0 auto; +} + +.status-item-right { + display: flex; + gap: 8px; + align-items: center; +} + +.status-bar .label { + font-size: 12px; +} + +.status-bar .status { + margin-bottom: 0; + padding: 2px 8px; + font-size: 11px; +} + +.status-button { + background: transparent; + border: none; + color: #a8b0c1; + padding: 0; + font-size: 12px; + cursor: pointer; +} + +.status-button:hover { + color: #c7d0e0; +} + +.tool-led { + display: flex; + align-items: center; + gap: 6px; +} + +.tool-label { + font-size: 11px; + color: #a8b0c1; +} + +.led { + width: 8px; + height: 8px; + border-radius: 999px; + background: #2b2f3a; + box-shadow: 0 0 0 2px #0f1218; +} + +.led.ok { + background: #44d38f; + box-shadow: 0 0 6px rgba(68, 211, 143, 0.8); +} + +.led.idle { + background: #9aa3b2; + box-shadow: 0 0 6px rgba(154, 163, 178, 0.6); +} + +.led.blocked { + background: #f07171; + box-shadow: 0 0 6px rgba(240, 113, 113, 0.7); +} + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(7, 10, 16, 0.7); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.modal { + width: min(520px, 90vw); + background: #141923; + border: 1px solid #242a35; + border-radius: 12px; + padding: 16px; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.modal-header h3 { + margin: 0; + font-size: 16px; +} + +.modal-body { + display: grid; + gap: 10px; +} + +.modal-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.modal-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.modal-help { + display: grid; + gap: 4px; + margin-top: 6px; + font-size: 12px; + color: #cbd2e0; +} diff --git a/src/renderer/vite-env.d.ts b/src/renderer/vite-env.d.ts index df7f4a8..2e3d5a1 100644 --- a/src/renderer/vite-env.d.ts +++ b/src/renderer/vite-env.d.ts @@ -10,6 +10,65 @@ interface Window { error?: string; url?: string; }>; + checkGitTools: () => Promise<{ + git: { + ok: boolean; + command: string; + version?: string; + error?: string; + }; + lfs: { + ok: boolean; + command: string; + version?: string; + error?: string; + }; + checkedAt: number; + }>; + getGitPaths: () => Promise<{ + git: { + ok: boolean; + command: string; + path?: string; + error?: string; + }; + lfs: { + ok: boolean; + command: string; + path?: string; + error?: string; + }; + checkedAt: number; + }>; + installGitTools: () => Promise<{ + ok: boolean; + error?: string; + steps: Array<{ + name: string; + result: { + ok: boolean; + command: string; + args: string[]; + output?: string; + error?: string; + }; + }>; + check?: { + git: { + ok: boolean; + command: string; + version?: string; + error?: string; + }; + lfs: { + ok: boolean; + command: string; + version?: string; + error?: string; + }; + checkedAt: number; + }; + }>; fetchProfile: (payload: { username: string }) => Promise<{ ok: boolean; status?: number;