import { app, BrowserWindow, ipcMain, net, session } from "electron"; import * as path from "node:path"; 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 = "https://pandoli365.com:5069/"; const SERVER_HEALTHCHECK_PATH = "/launcher/ping"; const SERVER_HEALTHCHECK_TIMEOUT_MS = 2000; const SERVER_REQUEST_TIMEOUT_MS = 4000; 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 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 createWindow = () => { const mainWindow = new BrowserWindow({ width: 1100, height: 720, 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:fetchProfile", async (_event, payload: { username: string }) => { const username = payload.username.trim(); const loginResult = await postText("/launcher/profile/login", { username }); if (!loginResult.ok || !loginResult.data) { return loginResult; } const sessionId = loginResult.data.trim(); return await postJson( "/launcher/profile/info", { username }, { Cookie: `PHPSESSID=${sessionId}` } ); }); // 개발/운영 모두에서 부팅 시 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(); } });