305 lines
7.7 KiB
TypeScript
305 lines
7.7 KiB
TypeScript
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<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 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 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();
|
|
}
|
|
});
|