import { app, BrowserWindow, dialog, ipcMain, net, session } from "electron"; import { execFile } from "node:child_process"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import { APP_CONFIG } from "../shared/config"; const isDev = Boolean(process.env.VITE_DEV_SERVER_URL); type ServerHealthResult = { ok: boolean; status?: number; latencyMs?: number; error?: string; url?: string; }; // SPT 서버는 기본적으로 self-signed TLS(자체서명 인증서)를 쓰는 경우가 많아서 // 런처에서는 HTTPS + 인증서 예외 처리(certificate exception)를 고려합니다. const SERVER_BASE_URL = APP_CONFIG.serverBaseUrl; const SERVER_HEALTHCHECK_PATH = APP_CONFIG.serverHealthcheckPath; const SERVER_HEALTHCHECK_TIMEOUT_MS = APP_CONFIG.serverHealthcheckTimeoutMs; const SERVER_REQUEST_TIMEOUT_MS = APP_CONFIG.serverRequestTimeoutMs; 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; 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 ModVersionStatus = { ok: boolean; checkedAt: number; currentTag?: string; latestTag?: string; latestMinorTags?: string[]; status?: "upToDate" | "patchMismatch" | "majorMinorMismatch" | "unknown"; error?: string; }; type ModSyncResult = { ok: boolean; tag?: string; error?: string; steps?: Array<{ name: string; ok: boolean; message?: string }>; }; type SptInstallInfo = { ok: boolean; checkedAt: number; path?: string; source?: "stored" | "auto"; needsUserInput?: boolean; error?: string; }; type SessionRecord = { username: string; sessionId: string; expiresAt: number; updatedAt: number; }; type SessionRequestResult = | { ok: true; sessionId: string; url: string } | { ok: false; error: string; url: string }; type SessionLookupResult = | { ok: true; sessionId: string; url: string; source: "cache" | "login" } | { ok: false; error: string; url: string }; type InstallStep = { name: string; ok: boolean; message?: string; result?: CommandRunResult; status?: "running" | "done" | "error"; progress?: { current: number; total: number; percent: number }; }; type InstallGitToolsResult = { ok: boolean; error?: string; steps: InstallStep[]; check?: Awaited>; }; const checkServerHealth = async (): Promise => { const startedAt = Date.now(); const url = new URL(SERVER_HEALTHCHECK_PATH, SERVER_BASE_URL).toString(); console.info(`[spt-launcher] healthcheck -> GET ${url}`); return await new Promise((resolve) => { const request = net.request({ method: "GET", url, }); const timeout = setTimeout(() => { request.abort(); resolve({ ok: false, latencyMs: Date.now() - startedAt, error: "timeout", url, }); }, SERVER_HEALTHCHECK_TIMEOUT_MS); request.on("response", (response) => { // data를 소비(consumption)하지 않으면 연결이 정리되지 않을 수 있어 drain 처리 response.on("data", () => undefined); response.on("end", () => undefined); clearTimeout(timeout); const status = response.statusCode; console.info( `[spt-launcher] healthcheck <- ${status} (${Date.now() - startedAt}ms)`, ); resolve({ ok: typeof status === "number" ? status >= 200 && status < 400 : false, status, latencyMs: Date.now() - startedAt, url, }); }); request.on("error", (error) => { clearTimeout(timeout); const message = error instanceof Error ? error.message : String(error); console.warn( `[spt-launcher] healthcheck !! ${message} (${Date.now() - startedAt}ms)`, ); resolve({ ok: false, latencyMs: Date.now() - startedAt, error: message, url, }); }); request.end(); }); }; 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 refreshWindowsPath = async () => { if (process.platform !== "win32") { return; } const result = await runCommand( "powershell", [ "-NoProfile", "-Command", "[Environment]::GetEnvironmentVariable('Path','Machine');[Environment]::GetEnvironmentVariable('Path','User')", ], COMMAND_TIMEOUT_MS, ); if (!result.ok || !result.output) { return; } const merged = result.output .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .join(";"); const combined = [merged, process.env.PATH].filter(Boolean).join(";"); const unique = Array.from(new Set(combined.split(";").filter(Boolean))).join(";"); process.env.PATH = unique; process.env.Path = unique; }; const checkGitTools = async () => { await refreshWindowsPath(); const git = await checkCommand("git", ["--version"]); const lfs = await checkCommand("git", ["lfs", "version"]); return { git, lfs, checkedAt: Date.now(), }; }; 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 => { 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[], timeoutMsOrOptions: number | { timeoutMs?: number; cwd?: string }, ): Promise => { const timeoutMs = typeof timeoutMsOrOptions === "number" ? timeoutMsOrOptions : timeoutMsOrOptions.timeoutMs; const cwd = typeof timeoutMsOrOptions === "number" ? undefined : timeoutMsOrOptions.cwd; return await new Promise((resolve) => { execFile( command, args, { timeout: timeoutMs, cwd }, (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 () => { await refreshWindowsPath(); const git = await resolveCommandPath("git"); const lfs = await resolveCommandPath("git-lfs"); return { git, lfs, checkedAt: Date.now(), }; }; const MOD_VERSION_FILE_NAME = "mod-version.json"; const SESSION_FILE_NAME = "session.json"; const getModVersionFilePath = () => { return path.join(app.getPath("userData"), MOD_VERSION_FILE_NAME); }; const getSessionRecordPath = () => { return path.join(app.getPath("userData"), SESSION_FILE_NAME); }; const readSessionRecord = async (): Promise => { const filePath = getSessionRecordPath(); try { const raw = await fs.readFile(filePath, "utf8"); const parsed = JSON.parse(raw) as SessionRecord; if ( typeof parsed.username !== "string" || typeof parsed.sessionId !== "string" || typeof parsed.expiresAt !== "number" || typeof parsed.updatedAt !== "number" ) { return null; } return parsed; } catch { return null; } }; const writeSessionRecord = async (payload: { username: string; sessionId: string; }) => { const filePath = getSessionRecordPath(); const record: SessionRecord = { username: payload.username, sessionId: payload.sessionId, updatedAt: Date.now(), expiresAt: Date.now() + SESSION_TTL_MS, }; await fs.writeFile(filePath, JSON.stringify(record, null, 2), "utf8"); return record; }; const refreshSessionRecord = async (payload: { username: string; sessionId: string; }) => { return await writeSessionRecord(payload); }; const clearSessionRecord = async () => { const filePath = getSessionRecordPath(); await fs.rm(filePath, { force: true }); }; const isSessionExpired = (record: SessionRecord | null) => { if (!record) { return true; } return record.expiresAt <= Date.now(); }; const readModVersionRecord = async () => { const filePath = getModVersionFilePath(); try { const raw = await fs.readFile(filePath, "utf8"); const parsed = JSON.parse(raw) as { currentTag?: string; updatedAt?: number; }; return { currentTag: typeof parsed.currentTag === "string" ? parsed.currentTag : undefined, updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : undefined, }; } catch { return { currentTag: undefined, updatedAt: undefined }; } }; const writeModVersionRecord = async (tag: string) => { const filePath = getModVersionFilePath(); const payload = { currentTag: tag, updatedAt: Date.now(), }; await fs.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8"); return payload; }; const listRemoteTags = async (repoUrl: string) => { const result = await runCommand( "git", ["ls-remote", "--tags", "--refs", repoUrl], { timeoutMs: INSTALL_TIMEOUT_MS, }, ); if (!result.ok) { throw new Error(result.error ?? result.output ?? "git ls-remote failed"); } const output = result.output ?? ""; return output .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); }; const parseVersionTag = (tag: string) => { const match = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag.trim()); if (!match) { return undefined; } return { tag, major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), }; }; const resolveLatestMinorTags = (tags: string[]) => { const parsed = tags .map((tagLine) => { const refMatch = tagLine.match(/refs\/tags\/(v\d+\.\d+\.\d+)$/); return parseVersionTag(refMatch ? refMatch[1] : tagLine); }) .filter( ( item, ): item is { tag: string; major: number; minor: number; patch: number } => Boolean(item), ); if (parsed.length === 0) { return { latestMinorTags: [], latestTag: undefined }; } const maxMajor = Math.max(...parsed.map((item) => item.major)); const majorCandidates = parsed.filter((item) => item.major === maxMajor); const maxMinor = Math.max(...majorCandidates.map((item) => item.minor)); const latestMinorTags = majorCandidates .filter((item) => item.minor === maxMinor) .sort((a, b) => a.patch - b.patch) .map((item) => item.tag); return { latestMinorTags, latestTag: latestMinorTags[latestMinorTags.length - 1], }; }; const getModVersionStatus = async (): Promise => { try { const tags = await listRemoteTags(APP_CONFIG.modRepoUrl); const { latestMinorTags, latestTag } = resolveLatestMinorTags(tags); if (!latestTag) { return { ok: false, checkedAt: Date.now(), error: "v*.*.* 태그(tag)를 찾지 못했습니다.", }; } const record = await readModVersionRecord(); const currentTag = record.currentTag; const latestParsed = parseVersionTag(latestTag); const currentParsed = currentTag ? parseVersionTag(currentTag) : undefined; let status: ModVersionStatus["status"] = "unknown"; if (currentParsed && latestParsed) { if ( currentParsed.major !== latestParsed.major || currentParsed.minor !== latestParsed.minor ) { status = "majorMinorMismatch"; } else if (currentParsed.patch !== latestParsed.patch) { status = "patchMismatch"; } else { status = "upToDate"; } } return { ok: true, checkedAt: Date.now(), currentTag, latestTag, latestMinorTags, status, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { ok: false, checkedAt: Date.now(), error: message, }; } }; const MOD_SYNC_PLAN_FILE_NAME = "mod-sync.plan.json"; const MOD_REPO_CACHE_DIR_NAME = "mod-repo-cache"; const DEFAULT_PRESERVE_PREFIXES = [ "user/profiles", "user/settings", "user/launcher", ]; const SPT_PATH_FILE_NAME = "spt-path.json"; const DEFAULT_SPT_PATHS = [ "C:\\Games\\SPT", "C:\\SPT", "D:\\Games\\SPT", "D:\\SPT", "E:\\Games\\SPT", "E:\\SPT", ]; const getModRepoCacheDir = () => { return path.join(app.getPath("userData"), MOD_REPO_CACHE_DIR_NAME); }; const getSptPathRecordPath = () => { return path.join(app.getPath("userData"), SPT_PATH_FILE_NAME); }; const readModSyncPlan = async (repoDir: string) => { const planPath = path.join(repoDir, MOD_SYNC_PLAN_FILE_NAME); try { const raw = await fs.readFile(planPath, "utf8"); const parsed = JSON.parse(raw) as { sourceRoot?: string; directories?: Array<{ from: string; to: string }>; preserve?: string[]; }; return parsed; } catch { return null; } }; const pathExists = async (targetPath: string) => { try { await fs.stat(targetPath); return true; } catch { return false; } }; const readSptPathRecord = async () => { const filePath = getSptPathRecordPath(); try { const raw = await fs.readFile(filePath, "utf8"); const parsed = JSON.parse(raw) as { path?: string; allowMissing?: boolean; updatedAt?: number; }; return { path: typeof parsed.path === "string" ? parsed.path : undefined, allowMissing: Boolean(parsed.allowMissing), updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : undefined, }; } catch { return { path: undefined, allowMissing: false, updatedAt: undefined }; } }; const writeSptPathRecord = async (payload: { path: string; allowMissing?: boolean; }) => { const filePath = getSptPathRecordPath(); const record = { path: payload.path, allowMissing: Boolean(payload.allowMissing), updatedAt: Date.now(), }; await fs.writeFile(filePath, JSON.stringify(record, null, 2), "utf8"); return record; }; const isValidSptInstall = async (installPath: string) => { const hasClient = await pathExists(path.join(installPath, "EscapeFromTarkov.exe")); if (!hasClient) { return false; } const hasServerOrLauncher = (await pathExists(path.join(installPath, "SPT.Server.exe"))) || (await pathExists(path.join(installPath, "SPT.Launcher.exe"))) || (await pathExists(path.join(installPath, "SPT.Server"))) || (await pathExists(path.join(installPath, "SPT.Launcher"))) || (await pathExists(path.join(installPath, "Aki.Server.exe"))) || (await pathExists(path.join(installPath, "Aki.Launcher.exe"))) || (await pathExists(path.join(installPath, "SPT", "SPT.Server.exe"))) || (await pathExists(path.join(installPath, "SPT", "SPT.Launcher.exe"))); return hasServerOrLauncher; }; const findSptInstallPath = async ( searchPath: string, ): Promise => { if (await isValidSptInstall(searchPath)) { return searchPath; } const nestedPath = path.join(searchPath, "SPT"); if (await isValidSptInstall(nestedPath)) { return nestedPath; } return null; }; const resolveSptInstall = async (): Promise => { const isDevUi = process.env.VITE_DEV_UI === "1"; if (process.platform !== "win32" && !isDevUi) { return { ok: false, checkedAt: Date.now(), error: "unsupported_platform", needsUserInput: false, }; } const record = await readSptPathRecord(); if (record.path) { if (record.allowMissing) { return { ok: true, checkedAt: Date.now(), path: record.path, source: "stored", }; } const resolved = await findSptInstallPath(record.path); if (resolved) { return { ok: true, checkedAt: Date.now(), path: resolved, source: "stored", }; } } for (const candidate of DEFAULT_SPT_PATHS) { const found = await findSptInstallPath(candidate); if (found) { await writeSptPathRecord({ path: found, allowMissing: false }); return { ok: true, checkedAt: Date.now(), path: found, source: "auto", }; } } return { ok: false, checkedAt: Date.now(), error: "not_found", needsUserInput: true, }; }; const normalizePath = (value: string) => value.split(path.sep).join("/"); const shouldPreserve = (relativePath: string, preservePrefixes: string[]) => { const normalized = normalizePath(relativePath); return preservePrefixes.some( (prefix) => normalized === prefix || normalized.startsWith(`${prefix}/`), ); }; const copyDir = async ( source: string, destination: string, preservePrefixes: string[], baseDest: string, ) => { const entries = await fs.readdir(source, { withFileTypes: true }); await fs.mkdir(destination, { recursive: true }); for (const entry of entries) { const sourcePath = path.join(source, entry.name); const destPath = path.join(destination, entry.name); const destRelative = normalizePath(path.relative(baseDest, destPath)); if (shouldPreserve(destRelative, preservePrefixes)) { continue; } if (entry.isDirectory()) { await copyDir(sourcePath, destPath, preservePrefixes, baseDest); continue; } if (entry.isSymbolicLink()) { continue; } await fs.copyFile(sourcePath, destPath); } }; const moveDir = async ( source: string, destination: string, preservePrefixes: string[], baseDest: string, ) => { const destExists = await pathExists(destination); if (!destExists) { await fs.mkdir(destination, { recursive: true }); } await copyDir(source, destination, preservePrefixes, baseDest); }; const resolveSourceRoot = async ( repoDir: string, plan: { sourceRoot?: string } | null, ) => { const planRoot = plan?.sourceRoot ? path.join(repoDir, plan.sourceRoot) : null; if (planRoot && (await pathExists(planRoot))) { return planRoot; } const modsRoot = path.join(repoDir, "mods"); if (await pathExists(modsRoot)) { return modsRoot; } return repoDir; }; const ensureModRepo = async ( repoDir: string, repoUrl: string, steps: ModSyncResult["steps"], onProgress?: (step: InstallStep) => void, ) => { const gitDir = path.join(repoDir, ".git"); if (await pathExists(gitDir)) { const fetchResult = await runCommand( "git", ["fetch", "--tags", "--prune"], { cwd: repoDir, timeoutMs: INSTALL_TIMEOUT_MS, }, ); steps?.push({ name: "git fetch", ok: fetchResult.ok, message: fetchResult.ok ? undefined : (fetchResult.error ?? fetchResult.output), }); if (!fetchResult.ok) { throw new Error( fetchResult.error ?? fetchResult.output ?? "git fetch failed", ); } onProgress?.({ name: "git fetch", ok: fetchResult.ok, message: fetchResult.error, result: fetchResult, }); return; } await fs.mkdir(repoDir, { recursive: true }); const cloneResult = await runCommand("git", ["clone", repoUrl, repoDir], { timeoutMs: INSTALL_TIMEOUT_MS, }); steps?.push({ name: "git clone", ok: cloneResult.ok, message: cloneResult.ok ? undefined : (cloneResult.error ?? cloneResult.output), }); if (!cloneResult.ok) { throw new Error( cloneResult.error ?? cloneResult.output ?? "git clone failed", ); } onProgress?.({ name: "git clone", ok: cloneResult.ok, message: cloneResult.error, result: cloneResult, }); }; const checkoutRepoTag = async ( repoDir: string, tag: string, steps: ModSyncResult["steps"], onProgress?: (step: InstallStep) => void, ) => { const checkoutResult = await runCommand("git", ["checkout", "-f", tag], { cwd: repoDir, timeoutMs: INSTALL_TIMEOUT_MS, }); steps?.push({ name: `git checkout ${tag}`, ok: checkoutResult.ok, message: checkoutResult.ok ? undefined : (checkoutResult.error ?? checkoutResult.output), }); if (!checkoutResult.ok) { throw new Error( checkoutResult.error ?? checkoutResult.output ?? "git checkout failed", ); } onProgress?.({ name: `git checkout ${tag}`, ok: checkoutResult.ok, message: checkoutResult.error, result: checkoutResult, }); }; const pullLfs = async ( repoDir: string, steps: ModSyncResult["steps"], onProgress?: (step: InstallStep) => void, ) => { const pullResult = await runCommand("git", ["lfs", "pull"], { cwd: repoDir, timeoutMs: INSTALL_TIMEOUT_MS, }); steps?.push({ name: "git lfs pull", ok: pullResult.ok, message: pullResult.ok ? undefined : (pullResult.error ?? pullResult.output), }); if (!pullResult.ok) { throw new Error( pullResult.error ?? pullResult.output ?? "git lfs pull failed", ); } onProgress?.({ name: "git lfs pull", ok: pullResult.ok, message: pullResult.error, result: pullResult, }); }; const syncDirectories = async ( repoDir: string, targetDir: string, steps: ModSyncResult["steps"], onProgress?: (step: InstallStep) => void, ) => { const plan = await readModSyncPlan(repoDir); const preservePrefixes = plan?.preserve ?? DEFAULT_PRESERVE_PREFIXES; const mapping = plan?.directories; // Initial progress event for starting file copy onProgress?.({ name: "copying files", ok: true, result: { ok: true, command: "copy", args: [] }, // Intermediate state }); if (mapping && mapping.length > 0) { for (const entry of mapping) { const sourcePath = path.join(repoDir, entry.from); const destPath = path.join(targetDir, entry.to); if (!(await pathExists(sourcePath))) { continue; } await moveDir(sourcePath, destPath, preservePrefixes, targetDir); } } else { const sourceRoot = await resolveSourceRoot(repoDir, plan); const entries = await fs.readdir(sourceRoot, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) { continue; } if (entry.name.startsWith(".")) { continue; } const sourcePath = path.join(sourceRoot, entry.name); const destPath = path.join(targetDir, entry.name); await moveDir(sourcePath, destPath, preservePrefixes, targetDir); } } // Final progress event for completion const result = { ok: true, command: "copy", args: [] }; steps?.push({ name: "copying files", ok: true, message: undefined }); onProgress?.({ name: "copying files", ok: true, result: { ok: true, command: "copy", args: [] }, }); }; const runModSync = async ( payload: { targetDir: string; tag: string; cleanRepo?: boolean; }, onProgress?: (step: InstallStep) => void, ): Promise => { const steps: ModSyncResult["steps"] = []; try { const targetDir = payload.targetDir?.trim(); if (!targetDir) { return { ok: false, error: "missing_target_dir", steps }; } const repoDir = getModRepoCacheDir(); await ensureModRepo(repoDir, APP_CONFIG.modRepoUrl, steps, onProgress); await checkoutRepoTag(repoDir, payload.tag, steps, onProgress); await pullLfs(repoDir, steps, onProgress); await syncDirectories(repoDir, targetDir, steps, onProgress); if (payload.cleanRepo) { await fs.rm(repoDir, { recursive: true, force: true }); } return { ok: true, tag: payload.tag, steps }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { ok: false, error: message, steps }; } }; const installGitTools = async ( onProgress?: (step: InstallStep) => void, ): Promise => { const isDevUi = process.env.VITE_DEV_UI === "1"; if (process.platform !== "win32") { if (!isDevUi) { return { ok: false, error: "Windows에서만 자동 설치가 가능합니다.", steps: [], }; } // Mac/Linux Dev UI Simulation const steps: InstallStep[] = []; const totalSteps = 3; const emitProgress = ( name: string, current: number, status: InstallStep["status"], ) => { const percent = Math.round((current / totalSteps) * 100); onProgress?.({ name, ok: status !== "error", status, progress: { current, total: totalSteps, percent }, result: { ok: true, command: "simulated", args: [] } }); }; emitProgress("winget 확인", 1, "running"); await new Promise(r => setTimeout(r, 500)); const wingetStep: InstallStep = { name: "winget 확인", ok: true, status: "done", progress: { current: 1, total: totalSteps, percent: 33 }, result: { ok: true, command: "simulated", args: [] } }; steps.push(wingetStep); onProgress?.(wingetStep); emitProgress("Git 설치", 2, "running"); await new Promise(r => setTimeout(r, 800)); const gitInstallStep: InstallStep = { name: "Git 설치", ok: true, status: "done", progress: { current: 2, total: totalSteps, percent: 67 }, result: { ok: true, command: "simulated", args: [] } }; steps.push(gitInstallStep); onProgress?.(gitInstallStep); emitProgress("Git LFS 설치", 3, "running"); await new Promise(r => setTimeout(r, 800)); const lfsInstallStep: InstallStep = { name: "Git LFS 설치", ok: true, status: "done", progress: { current: 3, total: totalSteps, percent: 100 }, result: { ok: true, command: "simulated", args: [] } }; steps.push(lfsInstallStep); onProgress?.(lfsInstallStep); return { ok: true, steps, check: { git: { ok: true, command: "git", version: "simulated" }, lfs: { ok: true, command: "git", lfs: "simulated" } as any, checkedAt: Date.now() } }; } const steps: InstallStep[] = []; const totalSteps = 3; const emitProgress = ( name: string, current: number, status: InstallStep["status"], message?: string, result?: CommandRunResult, ) => { const percent = Math.round((current / totalSteps) * 100); const step: InstallStep = { name, ok: status !== "error", message, status, progress: { current, total: totalSteps, percent }, result, }; // Update or add to steps array const existingIdx = steps.findIndex(s => s.name === name); if (existingIdx >= 0) { steps[existingIdx] = step; } else { steps.push(step); } onProgress?.(step); return step; }; // 1. Winget Check emitProgress("winget 확인", 1, "running"); const wingetPath = await resolveCommandPath("winget"); if (!wingetPath.ok) { emitProgress("winget 확인", 1, "error", wingetPath.error, { ok: false, command: "winget", args: ["--version"], error: wingetPath.error, }); return { ok: false, error: "winget이 설치되어 있지 않습니다.", steps, }; } emitProgress("winget 확인", 1, "done", undefined, { ok: true, command: "winget", args: ["--version"] }); // 2. Git Install emitProgress("Git 설치", 2, "running"); const gitInstall = await runCommand( "winget", [ "install", "--id", "Git.Git", "-e", "--source", "winget", "--accept-source-agreements", "--accept-package-agreements", "--silent", "--disable-interactivity" ], INSTALL_TIMEOUT_MS, ); emitProgress("Git 설치", 2, gitInstall.ok ? "done" : "error", gitInstall.ok ? undefined : (gitInstall.error ?? gitInstall.output), gitInstall); if (!gitInstall.ok) { return { ok: false, error: "Git 설치에 실패했습니다.", steps }; } // 3. Git LFS Install emitProgress("Git LFS 설치", 3, "running"); const lfsInstall = await runCommand( "winget", [ "install", "--id", "GitHub.GitLFS", "-e", "--source", "winget", "--accept-source-agreements", "--accept-package-agreements", "--silent", "--disable-interactivity" ], INSTALL_TIMEOUT_MS, ); emitProgress("Git LFS 설치", 3, lfsInstall.ok ? "done" : "error", lfsInstall.ok ? undefined : (lfsInstall.error ?? lfsInstall.output), lfsInstall); if (!lfsInstall.ok) { return { ok: false, error: "Git LFS 설치에 실패했습니다.", steps }; } const ok = steps.every((step) => step.ok); await refreshWindowsPath(); const check = await checkGitTools(); return { ok, steps, check, }; }; const postJson = async ( path: string, body: unknown, extraHeaders: Record = {}, ) => { const url = new URL(path, SERVER_BASE_URL).toString(); const payload = JSON.stringify(body); return await new Promise<{ ok: boolean; status?: number; data?: T; error?: string; url: string; }>((resolve) => { const request = net.request({ method: "POST", url, headers: { "Content-Type": "application/json", Accept: "application/json", "Accept-Encoding": "identity", requestcompressed: "0", responsecompressed: "0", ...extraHeaders, }, }); const timeout = setTimeout(() => { request.abort(); resolve({ ok: false, error: "timeout", url, }); }, SERVER_REQUEST_TIMEOUT_MS); request.on("response", (response) => { let rawData = ""; response.on("data", (chunk) => { rawData += chunk.toString("utf8"); }); response.on("end", () => { clearTimeout(timeout); const status = response.statusCode; const ok = typeof status === "number" ? status >= 200 && status < 400 : false; if (!rawData) { resolve({ ok, status, url }); return; } try { const parsed = JSON.parse(rawData) as T; resolve({ ok, status, data: parsed, url }); } catch (error) { const message = error instanceof Error ? error.message : "invalid_json"; resolve({ ok: false, status, error: message, url }); } }); }); request.on("error", (error) => { clearTimeout(timeout); const message = error instanceof Error ? error.message : String(error); resolve({ ok: false, error: message, url, }); }); request.write(payload); request.end(); }); }; const postText = async (path: string, body: unknown) => { const url = new URL(path, SERVER_BASE_URL).toString(); const payload = JSON.stringify(body); return await new Promise<{ ok: boolean; status?: number; data?: string; error?: string; url: string; }>((resolve) => { const request = net.request({ method: "POST", url, headers: { "Content-Type": "application/json", Accept: "text/plain", "Accept-Encoding": "identity", requestcompressed: "0", responsecompressed: "0", }, }); const timeout = setTimeout(() => { request.abort(); resolve({ ok: false, error: "timeout", url, }); }, SERVER_REQUEST_TIMEOUT_MS); request.on("response", (response) => { let rawData = ""; response.on("data", (chunk) => { rawData += chunk.toString("utf8"); }); response.on("end", () => { clearTimeout(timeout); const status = response.statusCode; const ok = typeof status === "number" ? status >= 200 && status < 400 : false; resolve({ ok, status, data: rawData, url }); }); }); request.on("error", (error) => { clearTimeout(timeout); const message = error instanceof Error ? error.message : String(error); resolve({ ok: false, error: message, url, }); }); request.write(payload); request.end(); }); }; const requestSessionId = async ( username: string, ): Promise => { const loginResult = await postText("/launcher/profile/login", { username }); if (!loginResult.ok) { return { ok: false, error: loginResult.error ?? "login_failed", url: loginResult.url, }; } const sessionId = loginResult.data?.trim(); if (!sessionId) { return { ok: false, error: "empty_session", url: loginResult.url }; } return { ok: true, sessionId, url: loginResult.url }; }; const isSessionInvalidStatus = (status?: number) => status === 401 || status === 403; const getSessionIdForUser = async ( username: string, ): Promise => { const cached = await readSessionRecord(); if (cached && cached.username === username && !isSessionExpired(cached)) { await refreshSessionRecord({ username: cached.username, sessionId: cached.sessionId, }); return { ok: true, sessionId: cached.sessionId, url: SERVER_BASE_URL, source: "cache" as const, }; } if (cached && cached.username !== username) { await clearSessionRecord(); } const loginResult = await requestSessionId(username); if (!loginResult.ok) { return loginResult; } await writeSessionRecord({ username, sessionId: loginResult.sessionId }); return { ok: true, sessionId: loginResult.sessionId, url: loginResult.url, source: "login" as const, }; }; const registerProfile = async (username: string) => { return await postJson("/launcher/profile/register", { username, edition: "Standard", }); }; const createWindow = () => { const mainWindow = new BrowserWindow({ width: 880, height: 576, minWidth: 880, minHeight: 576, resizable: true, webPreferences: { preload: path.join(__dirname, "../preload/preload.js"), contextIsolation: true, nodeIntegration: false, }, }); if (isDev) { mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL as string); } else { mainWindow.loadFile(path.join(__dirname, "../renderer/index.html")); } }; app.whenReady().then(() => { const expectedServerUrl = new URL(SERVER_BASE_URL); const expectedServerOrigin = expectedServerUrl.origin; const expectedServerHost = expectedServerUrl.hostname; // `electron.net` 요청은 self-signed TLS에서 막히는 경우가 많아서, // 특정 호스트(host)만 인증서 검증을 우회(bypass)합니다. session.defaultSession.setCertificateVerifyProc((request, callback) => { if (request.hostname === expectedServerHost) { callback(0); return; } callback(-3); }); app.on( "certificate-error", (event, _webContents, url, _error, _certificate, callback) => { if (url.startsWith(expectedServerOrigin)) { event.preventDefault(); callback(true); return; } callback(false); }, ); ipcMain.handle("spt:checkServerHealth", async () => { return await checkServerHealth(); }); ipcMain.handle("spt:checkGitTools", async () => { return await checkGitTools(); }); ipcMain.handle("spt:getGitPaths", async () => { return await getGitPaths(); }); 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 () => { return await getModVersionStatus(); }); ipcMain.handle( "spt:setLocalModVersion", async (_event, payload: { tag: string }) => { const tag = payload.tag.trim(); if (!tag) { return { ok: false, error: "empty_tag" }; } const record = await writeModVersionRecord(tag); return { ok: true, record }; }, ); ipcMain.handle( "spt:runModSync", async ( event, payload: { targetDir: string; tag: string; cleanRepo?: boolean }, ) => { return await runModSync(payload, (step) => { event.sender.send("spt:modSyncProgress", step); }); }, ); ipcMain.handle("spt:getSptInstallInfo", async () => { return await resolveSptInstall(); }); ipcMain.handle( "spt:setSptInstallPath", async (_event, payload: { path: string; allowMissing?: boolean }) => { let rawPath = payload.path?.trim(); if (!rawPath) { return { ok: false, error: "empty_path" }; } if (process.platform === "win32" && !payload.allowMissing) { const resolved = await findSptInstallPath(rawPath); if (!resolved) { return { ok: false, error: "invalid_spt_path" }; } rawPath = resolved; } const record = await writeSptPathRecord({ path: rawPath, allowMissing: payload.allowMissing, }); return { ok: true, record }; }, ); ipcMain.handle("spt:pickSptInstallPath", async () => { if (process.platform !== "win32") { return { ok: false, error: "unsupported_platform" }; } const result = await dialog.showOpenDialog({ title: "SPT 설치 폴더 선택", properties: ["openDirectory"], }); if (result.canceled || result.filePaths.length === 0) { return { ok: false, error: "cancelled" }; } const selected = result.filePaths[0]; const resolved = await findSptInstallPath(selected); if (!resolved) { return { ok: false, error: "invalid_spt_path", path: selected }; } const record = await writeSptPathRecord({ path: resolved, allowMissing: false, }); return { ok: true, record }; }); ipcMain.handle( "spt:fetchProfile", async (_event, payload: { username: string }) => { const username = payload.username.trim(); let activeSessionId: string | undefined; const fetchProfileInfo = async (): Promise<{ ok: boolean; status?: number; data?: unknown; error?: string; url: string; }> => { const sessionResult = await getSessionIdForUser(username); if (!sessionResult.ok) { return { ok: false, error: sessionResult.error ?? "login_failed", url: sessionResult.url, }; } activeSessionId = sessionResult.sessionId; return await postJson( "/launcher/profile/info", { username }, { Cookie: `PHPSESSID=${sessionResult.sessionId}`, }, ); }; let infoResult = await fetchProfileInfo(); if (!infoResult.ok && isSessionInvalidStatus(infoResult.status)) { const relogin = await requestSessionId(username); if (!relogin.ok) { return { ok: false, error: relogin.error ?? "login_failed", url: relogin.url, }; } await writeSessionRecord({ username, sessionId: relogin.sessionId }); activeSessionId = relogin.sessionId; infoResult = await postJson( "/launcher/profile/info", { username }, { Cookie: `PHPSESSID=${relogin.sessionId}`, }, ); } if (infoResult.ok && infoResult.data) { if (activeSessionId) { await refreshSessionRecord({ username, sessionId: activeSessionId }); } return infoResult; } await registerProfile(username); return await fetchProfileInfo(); }, ); ipcMain.handle( "spt:downloadProfile", async (_event, payload: { username: string }) => { const username = payload.username.trim(); const sessionResult = await getSessionIdForUser(username); if (!sessionResult.ok) { return sessionResult; } let activeSessionId = sessionResult.sessionId; let infoResult = await postJson( "/launcher/profile/info", { username }, { Cookie: `PHPSESSID=${sessionResult.sessionId}`, }, ); if (!infoResult.ok && isSessionInvalidStatus(infoResult.status)) { const relogin = await requestSessionId(username); if (!relogin.ok) { return relogin; } await writeSessionRecord({ username, sessionId: relogin.sessionId }); activeSessionId = relogin.sessionId; infoResult = await postJson( "/launcher/profile/info", { username }, { Cookie: `PHPSESSID=${relogin.sessionId}`, }, ); } if (!infoResult.ok || !infoResult.data) { return infoResult; } await refreshSessionRecord({ username, sessionId: activeSessionId }); const downloadsDir = app.getPath("downloads"); const fileName = `spt-profile-${username}-${Date.now()}.json`; const filePath = path.join(downloadsDir, fileName); await fs.writeFile( filePath, JSON.stringify(infoResult.data, null, 2), "utf8", ); return { ok: true, status: infoResult.status, url: infoResult.url, filePath, }; }, ); ipcMain.handle( "spt:resetProfile", async (_event, payload: { username: string }) => { const username = payload.username.trim(); const sessionResult = await getSessionIdForUser(username); if (!sessionResult.ok) { return sessionResult; } let activeSessionId = sessionResult.sessionId; let wipeResult = await postJson( "/launcher/profile/change/wipe", { username }, { Cookie: `PHPSESSID=${sessionResult.sessionId}`, }, ); if (!wipeResult.ok && isSessionInvalidStatus(wipeResult.status)) { const relogin = await requestSessionId(username); if (!relogin.ok) { return relogin; } await writeSessionRecord({ username, sessionId: relogin.sessionId }); activeSessionId = relogin.sessionId; wipeResult = await postJson( "/launcher/profile/change/wipe", { username }, { Cookie: `PHPSESSID=${relogin.sessionId}`, }, ); } if (wipeResult.ok) { await refreshSessionRecord({ username, sessionId: activeSessionId }); } return wipeResult; }, ); ipcMain.handle("spt:resumeSession", async () => { const record = await readSessionRecord(); if (!record || isSessionExpired(record)) { await clearSessionRecord(); return { ok: false, error: "session_expired", url: SERVER_BASE_URL }; } const infoResult = await postJson( "/launcher/profile/info", { username: record.username }, { Cookie: `PHPSESSID=${record.sessionId}`, }, ); if (!infoResult.ok && isSessionInvalidStatus(infoResult.status)) { await clearSessionRecord(); return { ok: false, error: "session_expired", url: infoResult.url }; } if (infoResult.ok) { await refreshSessionRecord({ username: record.username, sessionId: record.sessionId, }); } return infoResult; }); ipcMain.handle("spt:clearSession", async () => { await clearSessionRecord(); return { ok: true }; }); // 개발/운영 모두에서 부팅 시 1회 즉시 체크(instant check) 로그를 남겨 // 엔드포인트(endpoint) 확인이 가능하도록 합니다. void checkServerHealth(); createWindow(); app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } }); }); app.on("window-all-closed", () => { if (process.platform !== "darwin") { app.quit(); } });