Add Git tools management functionality. Implemented IPC handlers for checking, fetching paths, and installing Git and Git LFS tools in the main process. Updated the renderer to support these actions with user feedback and integrated status indicators for tool availability.

This commit is contained in:
이정수 2026-01-29 17:51:28 +09:00
parent 46c8d9b29c
commit 944c77f146
5 changed files with 648 additions and 1 deletions

View File

@ -1,4 +1,5 @@
import { app, BrowserWindow, ipcMain, net, session } from "electron"; import { app, BrowserWindow, ipcMain, net, session } from "electron";
import { execFile } from "node:child_process";
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as path from "node:path"; 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_PATH = "/launcher/ping";
const SERVER_HEALTHCHECK_TIMEOUT_MS = 2000; const SERVER_HEALTHCHECK_TIMEOUT_MS = 2000;
const SERVER_REQUEST_TIMEOUT_MS = 4000; 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<ReturnType<typeof checkGitTools>>;
};
const checkServerHealth = async (): Promise<ServerHealthResult> => { const checkServerHealth = async (): Promise<ServerHealthResult> => {
const startedAt = Date.now(); const startedAt = Date.now();
@ -78,6 +115,153 @@ const checkServerHealth = async (): Promise<ServerHealthResult> => {
}); });
}; };
const checkCommand = async (command: string, args: string[]): Promise<CommandCheckResult> => {
return await new Promise<CommandCheckResult>((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<CommandPathResult> => {
const locator = process.platform === "win32" ? "where" : "which";
return await new Promise<CommandPathResult>((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<CommandRunResult> => {
return await new Promise<CommandRunResult>((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<InstallGitToolsResult> => {
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 <T>( const postJson = async <T>(
path: string, path: string,
body: unknown, body: unknown,
@ -288,6 +472,18 @@ app.whenReady().then(() => {
return await checkServerHealth(); 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 }) => { ipcMain.handle("spt:fetchProfile", async (_event, payload: { username: string }) => {
const username = payload.username.trim(); const username = payload.username.trim();

View File

@ -5,6 +5,9 @@ console.info("[spt-launcher] preload loaded");
contextBridge.exposeInMainWorld("sptLauncher", { contextBridge.exposeInMainWorld("sptLauncher", {
appName: "SPT Launcher", appName: "SPT Launcher",
checkServerHealth: () => ipcRenderer.invoke("spt:checkServerHealth"), checkServerHealth: () => ipcRenderer.invoke("spt:checkServerHealth"),
checkGitTools: () => ipcRenderer.invoke("spt:checkGitTools"),
getGitPaths: () => ipcRenderer.invoke("spt:getGitPaths"),
installGitTools: () => ipcRenderer.invoke("spt:installGitTools"),
fetchProfile: (payload: { username: string }) => fetchProfile: (payload: { username: string }) =>
ipcRenderer.invoke("spt:fetchProfile", payload), ipcRenderer.invoke("spt:fetchProfile", payload),
downloadProfile: (payload: { username: string }) => downloadProfile: (payload: { username: string }) =>

View File

@ -1,6 +1,28 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
type Screen = "serverCheck" | "login" | "signup" | "resetPassword" | "main"; 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_INTERVAL_MS = 10000;
const HEALTHCHECK_ENDPOINT_FALLBACK = "https://pandoli365.com:5069/launcher/ping"; const HEALTHCHECK_ENDPOINT_FALLBACK = "https://pandoli365.com:5069/launcher/ping";
@ -14,6 +36,14 @@ const App = () => {
const [serverCheckedUrl, setServerCheckedUrl] = useState<string | undefined>(); const [serverCheckedUrl, setServerCheckedUrl] = useState<string | undefined>();
const [serverCheckInProgress, setServerCheckInProgress] = useState(false); const [serverCheckInProgress, setServerCheckInProgress] = useState(false);
const [serverCheckedAt, setServerCheckedAt] = useState<number | undefined>(); const [serverCheckedAt, setServerCheckedAt] = useState<number | undefined>();
const [gitCheckInProgress, setGitCheckInProgress] = useState(false);
const [gitCheckedAt, setGitCheckedAt] = useState<number | undefined>();
const [gitCheckResult, setGitCheckResult] = useState<GitToolsCheck | undefined>();
const [gitPathsLoading, setGitPathsLoading] = useState(false);
const [gitPathsResult, setGitPathsResult] = useState<GitPathsCheck | undefined>();
const [toolsModalOpen, setToolsModalOpen] = useState(false);
const [installInProgress, setInstallInProgress] = useState(false);
const [installMessage, setInstallMessage] = useState<string | undefined>();
const [hasSession, setHasSession] = useState(false); const [hasSession, setHasSession] = useState(false);
const [loginId, setLoginId] = useState(""); const [loginId, setLoginId] = useState("");
const [loginPassword, setLoginPassword] = useState(""); const [loginPassword, setLoginPassword] = useState("");
@ -29,6 +59,44 @@ const App = () => {
const [syncInProgress, setSyncInProgress] = useState(false); const [syncInProgress, setSyncInProgress] = useState(false);
const [simulateSyncFail, setSimulateSyncFail] = useState(false); const [simulateSyncFail, setSimulateSyncFail] = useState(false);
const healthCheckInFlightRef = useRef(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 () => { const runHealthCheck = useCallback(async () => {
if (healthCheckInFlightRef.current) { if (healthCheckInFlightRef.current) {
@ -53,6 +121,9 @@ const App = () => {
setServerCheckedAt(Date.now()); setServerCheckedAt(Date.now());
if (screen === "serverCheck" && result.ok) { if (screen === "serverCheck" && result.ok) {
if (!gitCheckedRef.current) {
await runGitCheck();
}
setScreen(hasSession ? "main" : "login"); setScreen(hasSession ? "main" : "login");
} }
} catch (error) { } catch (error) {
@ -89,6 +160,91 @@ const App = () => {
return serverHealthy ? "정상" : "불가"; return serverHealthy ? "정상" : "불가";
}, [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) => const asObject = (value: unknown) =>
value && typeof value === "object" ? (value as Record<string, unknown>) : undefined; value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
const pickString = (...values: unknown[]) => const pickString = (...values: unknown[]) =>
@ -296,7 +452,7 @@ const App = () => {
: "서버 연결이 불가합니다. 10초마다 자동으로 재시도합니다."} : "서버 연결이 불가합니다. 10초마다 자동으로 재시도합니다."}
</p> </p>
<p className="notice"> <p className="notice">
{serverCheckedUrl ? `Endpoint(endpoint): ${serverCheckedUrl}` : "Endpoint(endpoint): "} {serverCheckedUrl ? `엔드포인트: ${serverCheckedUrl}` : "엔드포인트: "}
{serverStatusCode ? ` · HTTP ${serverStatusCode}` : ""} {serverStatusCode ? ` · HTTP ${serverStatusCode}` : ""}
{typeof serverLatencyMs === "number" ? ` · ${serverLatencyMs}ms` : ""} {typeof serverLatencyMs === "number" ? ` · ${serverLatencyMs}ms` : ""}
{serverError ? ` · ${serverError}` : ""} {serverError ? ` · ${serverError}` : ""}
@ -309,6 +465,19 @@ const App = () => {
</button> </button>
</> </>
)} )}
{serverHealthy && (
<div className="notice">
<div className="tool-led">
<span className="tool-label">Git</span>
<span className={`led ${gitLedClass}`} aria-label="Git 상태" />
</div>
<div className="tool-led">
<span className="tool-label">Git LFS</span>
<span className={`led ${lfsLedClass}`} aria-label="Git LFS 상태" />
</div>
{gitCheckedAt ? <div className="helper">{new Date(gitCheckedAt).toLocaleTimeString()}</div> : null}
</div>
)}
</section> </section>
)} )}
@ -468,6 +637,80 @@ const App = () => {
</footer> </footer>
</main> </main>
)} )}
{screen !== "serverCheck" && (
<nav className="status-bar">
<div className="status-item">
<span className="label"> </span>
<div className="status-item-right">
<span className={`led ${serverStatusLedClass}`} aria-label="서버 상태" />
</div>
</div>
<div className="status-item">
<button type="button" className="status-button" onClick={handleOpenToolsModal}>
</button>
<div className="status-item-right">
<div className="tool-led">
<span className="tool-label">Git</span>
<span className={`led ${gitLedClass}`} aria-label="Git 상태" />
</div>
<div className="tool-led">
<span className="tool-label">LFS</span>
<span className={`led ${lfsLedClass}`} aria-label="Git LFS 상태" />
</div>
</div>
</div>
</nav>
)}
{screen !== "serverCheck" && toolsModalOpen && (
<div className="modal-overlay" role="presentation" onClick={() => setToolsModalOpen(false)}>
<section
className="modal"
role="dialog"
aria-modal="true"
aria-label="도구 상태"
onClick={(event) => event.stopPropagation()}
>
<header className="modal-header">
<h3> </h3>
<button type="button" className="ghost" onClick={() => setToolsModalOpen(false)}>
</button>
</header>
<div className="modal-body">
<div className="modal-row">
<span className="label">Git</span>
<span className={`led ${gitLedClass}`} aria-label="Git 상태" />
</div>
<div className="modal-row">
<span className="label">Git LFS</span>
<span className={`led ${lfsLedClass}`} aria-label="Git LFS 상태" />
</div>
<div className="modal-actions">
<button type="button" onClick={handleRecheckTools} disabled={gitCheckInProgress}>
{gitCheckInProgress ? "점검 중..." : "재확인"}
</button>
<button type="button" className="ghost" onClick={handleInstallTools} disabled={installInProgress}>
{installInProgress ? "설치 중..." : "자동 재설치"}
</button>
<button type="button" className="ghost" onClick={handleFetchGitPaths} disabled={gitPathsLoading}>
{gitPathsLoading ? "확인 중..." : "경로 확인"}
</button>
</div>
{installMessage && <div className="notice">{installMessage}</div>}
{gitPathsResult && (
<div className="notice">
<strong> </strong>
<div className="modal-help">
<div>Git: {gitPathsResult.git.ok ? gitPathsResult.git.path : gitPathsResult.git.error}</div>
<div>Git LFS: {gitPathsResult.lfs.ok ? gitPathsResult.lfs.path : gitPathsResult.lfs.error}</div>
</div>
</div>
)}
</div>
</section>
</div>
)}
</div> </div>
); );
}; };

View File

@ -190,3 +190,149 @@ button:disabled {
.muted { .muted {
color: #a8b0c1; 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;
}

View File

@ -10,6 +10,65 @@ interface Window {
error?: string; error?: string;
url?: 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<{ fetchProfile: (payload: { username: string }) => Promise<{
ok: boolean; ok: boolean;
status?: number; status?: number;