1506 lines
38 KiB
TypeScript
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();
|
|
}
|
|
});
|