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;