spt-launcher/src/main/main.ts

1506 lines
38 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;
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;
result: CommandRunResult;
};
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 checkGitTools = async () => {
const git = await checkCommand("git", ["--version"]);
const lfs = await checkCommand("git", ["lfs", "version"]);
return {
git,
lfs,
checkedAt: Date.now(),
};
};
const resolveCommandPath = async (
command: string,
): Promise<CommandPathResult> => {
const locator = process.platform === "win32" ? "where" : "which";
return await new Promise<CommandPathResult>((resolve) => {
execFile(
locator,
[command],
{ timeout: COMMAND_TIMEOUT_MS },
(error, stdout) => {
if (error) {
resolve({ ok: false, command, error: error.message });
return;
}
const output = String(stdout ?? "").trim();
const firstLine = output
.split(/\r?\n/)
.find((line) => line.trim().length > 0);
resolve({ ok: Boolean(firstLine), command, path: firstLine });
},
);
});
};
const runCommand = async (
command: string,
args: string[],
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 () => {
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 serverExe = path.join(installPath, "SPT.Server.exe");
const launcherExe = path.join(installPath, "SPT.Launcher.exe");
const serverNoExt = path.join(installPath, "SPT.Server");
const launcherNoExt = path.join(installPath, "SPT.Launcher");
return (
(await pathExists(serverExe)) ||
(await pathExists(launcherExe)) ||
(await pathExists(serverNoExt)) ||
(await pathExists(launcherNoExt))
);
};
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> => {
if (process.platform !== "win32") {
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"],
) => {
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",
);
}
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",
);
}
};
const checkoutRepoTag = async (
repoDir: string,
tag: string,
steps: ModSyncResult["steps"],
) => {
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",
);
}
};
const pullLfs = async (repoDir: string, steps: ModSyncResult["steps"]) => {
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",
);
}
};
const syncDirectories = async (repoDir: string, targetDir: string) => {
const plan = await readModSyncPlan(repoDir);
const preservePrefixes = plan?.preserve ?? DEFAULT_PRESERVE_PREFIXES;
const mapping = plan?.directories;
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);
}
return;
}
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);
}
};
const runModSync = async (payload: {
targetDir: string;
tag: string;
cleanRepo?: boolean;
}): 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);
await checkoutRepoTag(repoDir, payload.tag, steps);
await pullLfs(repoDir, steps);
await syncDirectories(repoDir, targetDir);
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 (): Promise<InstallGitToolsResult> => {
if (process.platform !== "win32") {
return {
ok: false,
error: "Windows에서만 자동 설치가 가능합니다.",
steps: [],
};
}
const wingetPath = await resolveCommandPath("winget");
if (!wingetPath.ok) {
return {
ok: false,
error: "winget이 설치되어 있지 않습니다.",
steps: [
{
name: "winget 확인",
result: {
ok: false,
command: "winget",
args: ["--version"],
error: wingetPath.error,
},
},
],
};
}
const steps: InstallStep[] = [];
const gitInstall = await runCommand(
"winget",
[
"install",
"--id",
"Git.Git",
"-e",
"--source",
"winget",
"--accept-source-agreements",
"--accept-package-agreements",
"--silent",
],
INSTALL_TIMEOUT_MS,
);
steps.push({ name: "Git 설치", result: gitInstall });
const lfsInstall = await runCommand(
"winget",
[
"install",
"--id",
"GitHub.GitLFS",
"-e",
"--source",
"winget",
"--accept-source-agreements",
"--accept-package-agreements",
"--silent",
],
INSTALL_TIMEOUT_MS,
);
steps.push({ name: "Git LFS 설치", result: lfsInstall });
const ok = steps.every((step) => step.result.ok);
const check = await checkGitTools();
return {
ok,
steps,
check,
};
};
const postJson = async <T>(
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 () => {
return await installGitTools();
});
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);
},
);
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();
}
});