feat: SPT Launcher Electron 애플리케이션의 초기 구현으로, 서버 헬스 체크, 명령어 실행, 세션 관리 등 핵심 기능들을 포함합니다.

This commit is contained in:
이정수 2026-01-31 17:59:40 +09:00
parent 2328cb9b5f
commit 1573df675b
5 changed files with 346 additions and 24 deletions

View File

@ -24,6 +24,18 @@ const COMMAND_TIMEOUT_MS = APP_CONFIG.commandTimeoutMs;
const INSTALL_TIMEOUT_MS = APP_CONFIG.installTimeoutMs;
const SESSION_TTL_MS = APP_CONFIG.sessionTtlMs;
const CLIENT_PROCESS_NAMES = [
"EscapeFromTarkov.exe",
"EscapeFromTarkov_BE.exe",
];
const SERVER_PROCESS_NAMES = [
"Aki.Server.exe",
"SPT.Server.exe"
];
const GAME_PROCESS_NAMES = [...CLIENT_PROCESS_NAMES, ...SERVER_PROCESS_NAMES];
type CommandCheckResult = {
ok: boolean;
command: string;
@ -89,7 +101,9 @@ type SessionLookupResult =
type InstallStep = {
name: string;
result: CommandRunResult;
ok: boolean;
message?: string;
result?: CommandRunResult;
};
type InstallGitToolsResult = {
@ -191,6 +205,45 @@ const checkGitTools = async () => {
};
};
const checkGameRunning = async (): Promise<{ running: boolean; serverRunning: boolean; clientRunning: boolean; processes: string[] }> => {
if (process.platform !== "win32") {
return { running: false, serverRunning: false, clientRunning: false, processes: [] };
}
return await new Promise((resolve) => {
execFile("tasklist", ["/FO", "CSV", "/NH"], (error, stdout) => {
if (error) {
console.warn("tasklist failed", error);
resolve({ running: false, serverRunning: false, clientRunning: false, processes: [] });
return;
}
const output = stdout.toString().toLowerCase();
const runningProcesses: string[] = [];
let serverRunning = false;
let clientRunning = false;
for (const procName of GAME_PROCESS_NAMES) {
if (output.includes(procName.toLowerCase())) {
runningProcesses.push(procName);
if (SERVER_PROCESS_NAMES.includes(procName)) {
serverRunning = true;
} else if (CLIENT_PROCESS_NAMES.includes(procName)) {
clientRunning = true;
}
}
}
resolve({
running: runningProcesses.length > 0,
serverRunning,
clientRunning,
processes: runningProcesses
});
});
});
};
const resolveCommandPath = async (
command: string,
): Promise<CommandPathResult> => {
@ -741,6 +794,8 @@ const ensureModRepo = async (
}
onProgress?.({
name: "git fetch",
ok: fetchResult.ok,
message: fetchResult.error,
result: fetchResult,
});
return;
@ -764,6 +819,8 @@ const ensureModRepo = async (
}
onProgress?.({
name: "git clone",
ok: cloneResult.ok,
message: cloneResult.error,
result: cloneResult,
});
};
@ -792,6 +849,8 @@ const checkoutRepoTag = async (
}
onProgress?.({
name: `git checkout ${tag}`,
ok: checkoutResult.ok,
message: checkoutResult.error,
result: checkoutResult,
});
};
@ -819,6 +878,8 @@ const pullLfs = async (
}
onProgress?.({
name: "git lfs pull",
ok: pullResult.ok,
message: pullResult.error,
result: pullResult,
});
};
@ -836,6 +897,7 @@ const syncDirectories = async (
// Initial progress event for starting file copy
onProgress?.({
name: "copying files",
ok: true,
result: { ok: true, command: "copy", args: [] }, // Intermediate state
});
@ -873,6 +935,7 @@ const syncDirectories = async (
});
onProgress?.({
name: "copying files",
ok: true,
result: { ok: true, command: "copy", args: [] },
});
};
@ -910,7 +973,9 @@ const runModSync = async (
}
};
const installGitTools = async (): Promise<InstallGitToolsResult> => {
const installGitTools = async (
onProgress?: (step: InstallStep) => void,
): Promise<InstallGitToolsResult> => {
if (process.platform !== "win32") {
return {
ok: false,
@ -919,26 +984,49 @@ const installGitTools = async (): Promise<InstallGitToolsResult> => {
};
}
const steps: InstallStep[] = [];
const wingetPath = await resolveCommandPath("winget");
if (!wingetPath.ok) {
const errorStep = {
name: "winget 확인",
ok: false,
message: wingetPath.error,
result: {
ok: false,
command: "winget",
args: ["--version"],
error: wingetPath.error,
},
};
steps.push(errorStep);
onProgress?.(errorStep);
return {
ok: false,
error: "winget이 설치되어 있지 않습니다.",
steps: [
{
name: "winget 확인",
result: {
ok: false,
command: "winget",
args: ["--version"],
error: wingetPath.error,
},
},
],
steps,
};
}
const steps: InstallStep[] = [];
// Winget check success
/*
const wingetStep = {
name: "winget 확인",
result: { ok: true, command: "winget", args: ["--version"] }
};
steps.push(wingetStep);
onProgress?.(wingetStep);
*/
// skipping separate success step for winget to match previous behavior purely?
// User asked for "same template", so seeing "Winget Check... OK" is good.
// I will add it for clarity.
const wingetStep = {
name: "winget 확인",
ok: true,
result: { ok: true, command: "winget", args: ["--version"] }
};
steps.push(wingetStep);
onProgress?.(wingetStep);
const gitInstall = await runCommand(
"winget",
@ -952,10 +1040,21 @@ const installGitTools = async (): Promise<InstallGitToolsResult> => {
"--accept-source-agreements",
"--accept-package-agreements",
"--silent",
"--disable-interactivity" // Ensure no blocking prompts
],
INSTALL_TIMEOUT_MS,
);
steps.push({ name: "Git 설치", result: gitInstall });
steps.push({
name: "Git 설치",
ok: gitInstall.ok,
message: gitInstall.output || gitInstall.error, // Prioritize output
result: gitInstall
});
onProgress?.({
name: "Git 설치",
ok: gitInstall.ok,
message: gitInstall.output || gitInstall.error // Prioritize output
});
const lfsInstall = await runCommand(
"winget",
@ -969,12 +1068,23 @@ const installGitTools = async (): Promise<InstallGitToolsResult> => {
"--accept-source-agreements",
"--accept-package-agreements",
"--silent",
"--disable-interactivity"
],
INSTALL_TIMEOUT_MS,
);
steps.push({ name: "Git LFS 설치", result: lfsInstall });
steps.push({
name: "Git LFS 설치",
ok: lfsInstall.ok,
message: lfsInstall.output || lfsInstall.error, // Prioritize output
result: lfsInstall
});
onProgress?.({
name: "Git LFS 설치",
ok: lfsInstall.ok,
message: lfsInstall.output || lfsInstall.error // Prioritize output
});
const ok = steps.every((step) => step.result.ok);
const ok = steps.every((step) => step.ok);
const check = await checkGitTools();
return {
@ -1254,8 +1364,147 @@ app.whenReady().then(() => {
return await getGitPaths();
});
ipcMain.handle("spt:installGitTools", async () => {
return await installGitTools();
ipcMain.handle("spt:installGitTools", async (event) => {
return await installGitTools((step) => {
event.sender.send("spt:gitInstallProgress", step);
});
});
ipcMain.handle("spt:launchGame", async (event) => {
const mainWindow = BrowserWindow.fromWebContents(event.sender);
const installInfo = await resolveSptInstall();
if (!installInfo.path) {
return { ok: false, error: "no_spt_path" };
}
// Double check running processes
const running = await checkGameRunning();
if (running.clientRunning) {
return { ok: false, error: "already_running", details: running.processes };
}
// Double check mod version (optional but safer)
const modStatus = await getModVersionStatus();
if (!modStatus.ok) {
return { ok: false, error: "mod_verification_failed" };
}
// Launch Server if not running (Local only)
const serverUrl = new URL(SERVER_BASE_URL);
const isLocal = serverUrl.hostname === "127.0.0.1" || serverUrl.hostname === "localhost";
if (isLocal && !running.serverRunning) {
let serverExePath: string | undefined;
for (const exe of SERVER_PROCESS_NAMES) {
// Check direct path or SPT subdirectory
const direct = path.join(installInfo.path, exe);
if (await pathExists(direct)) {
serverExePath = direct;
break;
}
const nested = path.join(installInfo.path, "SPT", exe);
if (await pathExists(nested)) {
serverExePath = nested;
break;
}
}
if (serverExePath) {
console.log(`[spt-launcher] Starting Server: ${serverExePath}`);
const serverBat = stripExe(serverExePath) + ".bat"; // Sometimes users use bat, but exe should work if arguments aren't complex
// Prefer EXE for now.
const child = require("child_process").spawn(serverExePath, [], {
cwd: path.dirname(serverExePath),
detached: true,
stdio: "ignore"
});
child.unref();
// Wait for server health
let attempts = 0;
const maxAttempts = 30; // 30 seconds approx
while(attempts < maxAttempts) {
const health = await checkServerHealth();
if (health.ok) {
break;
}
await new Promise(r => setTimeout(r, 1000));
attempts++;
}
}
}
// Launch Client
try {
// Prioritize EFT executable
const candidates = [
"EscapeFromTarkov.exe",
"EscapeFromTarkov_BE.exe",
"SPT/EscapeFromTarkov.exe",
"SPT/EscapeFromTarkov_BE.exe"
];
let executablePath: string | undefined;
for (const exe of candidates) {
const fullPath = path.join(installInfo.path, exe);
if (await pathExists(fullPath)) {
executablePath = fullPath;
break;
}
}
if (!executablePath) {
return { ok: false, error: "executable_not_found" };
}
// Get Session ID for args
const session = await readSessionRecord();
if (!session || isSessionExpired(session)) {
return { ok: false, error: "session_required" };
}
// Build Args
const configArg = JSON.stringify({
BackendUrl: SERVER_BASE_URL,
Version: "live"
});
const args = [`-token=${session.sessionId}`, `-config=${configArg}`];
console.log(`[spt-launcher] Launching: ${executablePath} with args`, args);
// Sanitize Environment
// Strip Electron/Node specifics to prevent BepInEx/Plugin conflicts
const sanitizedEnv = { ...process.env };
for (const key of Object.keys(sanitizedEnv)) {
if (key.startsWith("ELECTRON_") || key.startsWith("NODE_") || key === "VITE_DEV_SERVER_URL") {
delete sanitizedEnv[key];
}
}
// Spawn detached
const child = require("child_process").spawn(executablePath, args, {
cwd: path.dirname(executablePath),
detached: true,
stdio: "ignore",
env: sanitizedEnv
});
child.unref();
mainWindow?.minimize();
return { ok: true, executedPath: executablePath };
} catch (e: any) {
console.error("Launch failed", e);
return { ok: false, error: e.message };
}
});
function stripExe(filename: string) {
return filename.replace(/\.exe$/i, "");
}
ipcMain.handle("spt:checkGameRunning", async () => {
return await checkGameRunning();
});
ipcMain.handle("spt:getModVersionStatus", async () => {

View File

@ -5,6 +5,8 @@ console.info("[spt-launcher] preload loaded");
contextBridge.exposeInMainWorld("sptLauncher", {
appName: "SPT Launcher",
checkServerHealth: () => ipcRenderer.invoke("spt:checkServerHealth"),
launchGame: () => ipcRenderer.invoke("spt:launchGame"),
checkGameRunning: () => ipcRenderer.invoke("spt:checkGameRunning"),
checkGitTools: () => ipcRenderer.invoke("spt:checkGitTools"),
getGitPaths: () => ipcRenderer.invoke("spt:getGitPaths"),
installGitTools: () => ipcRenderer.invoke("spt:installGitTools"),
@ -33,4 +35,12 @@ contextBridge.exposeInMainWorld("sptLauncher", {
ipcRenderer.removeListener("spt:modSyncProgress", subscription);
};
},
onGitInstallProgress: (callback: (event: unknown, step: unknown) => void) => {
const subscription = (_event: unknown, step: unknown) =>
callback(_event, step);
ipcRenderer.on("spt:gitInstallProgress", subscription);
return () => {
ipcRenderer.removeListener("spt:gitInstallProgress", subscription);
};
},
});

View File

@ -100,6 +100,7 @@ const App = () => {
const sessionResumeAttemptedRef = useRef(false);
const transitionTimeoutRef = useRef<number | undefined>();
const simulateToolsMissingRef = useRef(false);
const [modalTitle, setModalTitle] = useState("모드 적용 중...");
const [syncSteps, setSyncSteps] = useState<Array<{ name: string; ok: boolean; message?: string }>>([]);
const [syncResult, setSyncResult] = useState<{ ok: boolean; error?: string } | undefined>();
@ -120,6 +121,16 @@ const App = () => {
if (rawName === "copying files") {
return { title: "모드 파일 적용", command: "Copying files to Game Directory..." };
}
// Git Install Steps
if (rawName === "winget 확인") {
return { title: "패키지 관리자(winget) 확인", command: "checking winget..." };
}
if (rawName === "Git 설치") {
return { title: "Git 설치 (시간이 걸릴 수 있습니다)", command: "winget install Git.Git" };
}
if (rawName === "Git LFS 설치") {
return { title: "Git LFS 설치", command: "winget install GitHub.GitLFS" };
}
return { title: rawName, command: "" };
};
@ -636,12 +647,25 @@ const App = () => {
setInstallInProgress(true);
setInstallMessage(undefined);
// Modal Setup
setSyncSteps([]);
setSyncResult(undefined);
setModalTitle("Git 도구 설치 중...");
setSyncInProgress(true);
const cleanup = window.sptLauncher.onGitInstallProgress((_e, step: any) => {
setSyncSteps((prev) => [...prev, step]);
});
try {
const result = await window.sptLauncher.installGitTools();
if (!result.ok) {
setInstallMessage(result.error ?? "자동 설치에 실패했습니다.");
setSyncResult({ ok: false, error: result.error });
} else {
setInstallMessage("자동 설치가 완료되었습니다.");
setSyncResult({ ok: true });
}
if (result.check) {
setGitCheckResult(result.check);
@ -650,8 +674,11 @@ const App = () => {
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setInstallMessage(message);
setSyncResult({ ok: false, error: message });
} finally {
cleanup();
setInstallInProgress(false);
setSyncInProgress(false); // Show close button
}
};
@ -895,6 +922,8 @@ const App = () => {
setSyncSteps([]);
setSyncResult(undefined);
setModalTitle("모드 적용 중...");
setSyncInProgress(true);
// Subscribe to progress events
const cleanup = window.sptLauncher.onModSyncProgress
@ -991,7 +1020,33 @@ const App = () => {
return;
}
window.alert("게임을 실행합니다.");
if (!window.sptLauncher?.launchGame) {
window.alert("게임 시작 기능이 준비되지 않았습니다.");
return;
}
try {
const result = await window.sptLauncher.launchGame();
if (!result.ok) {
if (result.error === "already_running") {
window.alert(`게임이 이미 실행 중입니다.\n(발견된 프로세스: ${(result.details ?? []).join(", ")})`);
} else if (result.error === "mod_verification_failed") {
window.alert("모드 버전 검증에 실패했습니다. 버전을 다시 확인해주세요.");
} else if (result.error === "no_spt_path") {
window.alert("SPT 설치 경로가 설정되지 않았습니다.");
} else if (result.error === "executable_not_found") {
window.alert("실행 파일(Launcher/Server)을 찾을 수 없습니다.");
} else {
window.alert(`게임 실행 실패: ${result.error}`);
}
return;
}
// Success
window.alert(`게임이 실행되었습니다.\n(${result.executedPath})`);
} catch (e: any) {
window.alert(`게임 실행 중 오류가 발생했습니다: ${e.message}`);
}
};
return (
@ -1413,8 +1468,8 @@ const App = () => {
<header className="modal-header">
<h3>
{syncResult
? (syncResult.ok ? "모드 적용 완료" : "오류 발생")
: "모드 적용 중..."}
? (syncResult.ok ? (modalTitle.replace("중...", "완료")) : (syncResult.error ? `오류: ${syncResult.error}` : "오류 발생"))
: modalTitle}
</h3>
{!syncInProgress && (
<button type="button" className="ghost" onClick={closeSyncModal}>
@ -1445,7 +1500,7 @@ const App = () => {
</div>
{syncResult && (
<div className={`notice ${syncResult.ok ? "info" : "error"}`}>
{syncResult.ok ? "모드 적용이 완료되었습니다." : `실패: ${syncResult.error}`}
{syncResult.ok ? "모드 적용이 완료되었습니다." : (syncResult.error ? `실패: ${syncResult.error}` : "오류가 발생했습니다.")}
</div>
)}
</div>

View File

@ -10,6 +10,8 @@ interface Window {
error?: string;
url?: string;
}>;
launchGame: () => Promise<{ ok: boolean; error?: string; details?: string[]; executedPath?: string }>;
checkGameRunning: () => Promise<{ running: boolean; processes: string[] }>;
checkGitTools: () => Promise<{
git: {
ok: boolean;
@ -156,6 +158,12 @@ interface Window {
step: { name: string; result?: { ok: boolean; message?: string } }
) => void
) => () => void;
onGitInstallProgress: (
callback: (
event: unknown,
step: { name: string; result?: { ok: boolean; message?: string } }
) => void
) => () => void;
};
}

View File

@ -1,5 +1,5 @@
export const APP_CONFIG = {
serverBaseUrl: "https://pandoli365.com:5069/",
serverBaseUrl: "https://pandoli365.com:5069",
serverHealthcheckPath: "/launcher/ping",
serverHealthcheckTimeoutMs: 2000,
serverRequestTimeoutMs: 4000,