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>; }; 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 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 => { 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 () => { 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 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 => { if (await isValidSptInstall(searchPath)) { return searchPath; } const nestedPath = path.join(searchPath, "SPT"); if (await isValidSptInstall(nestedPath)) { return nestedPath; } return null; }; const resolveSptInstall = async (): Promise => { 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 => { 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 => { 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 ( 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 () => { 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(); } });