spt-launcher/src/main/main.ts

1907 lines
50 KiB
TypeScript

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<ReturnType<typeof checkGitTools>>;
};
const checkServerHealth = async (): Promise<ServerHealthResult> => {
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<ServerHealthResult>((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<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 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<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[],
timeoutMsOrOptions: number | { timeoutMs?: number; cwd?: string },
): Promise<CommandRunResult> => {
const timeoutMs =
typeof timeoutMsOrOptions === "number"
? timeoutMsOrOptions
: timeoutMsOrOptions.timeoutMs;
const cwd =
typeof timeoutMsOrOptions === "number" ? undefined : timeoutMsOrOptions.cwd;
return await new Promise<CommandRunResult>((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<SessionRecord | null> => {
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<ModVersionStatus> => {
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<string | null> => {
if (await isValidSptInstall(searchPath)) {
return searchPath;
}
const nestedPath = path.join(searchPath, "SPT");
if (await isValidSptInstall(nestedPath)) {
return nestedPath;
}
return null;
};
const resolveSptInstall = async (): Promise<SptInstallInfo> => {
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<ModSyncResult> => {
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<InstallGitToolsResult> => {
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 <T>(
path: string,
body: unknown,
extraHeaders: Record<string, string> = {},
) => {
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<SessionRequestResult> => {
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<SessionLookupResult> => {
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();
}
});