1328 lines
36 KiB
TypeScript
1328 lines
36 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 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 || (await isValidSptInstall(record.path))) {
|
|
return {
|
|
ok: true,
|
|
checkedAt: Date.now(),
|
|
path: record.path,
|
|
source: "stored"
|
|
};
|
|
}
|
|
}
|
|
|
|
for (const candidate of DEFAULT_SPT_PATHS) {
|
|
if (await isValidSptInstall(candidate)) {
|
|
await writeSptPathRecord({ path: candidate, allowMissing: false });
|
|
return {
|
|
ok: true,
|
|
checkedAt: Date.now(),
|
|
path: candidate,
|
|
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,
|
|
maxWidth: 880,
|
|
maxHeight: 576,
|
|
resizable: false,
|
|
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 }) => {
|
|
const rawPath = payload.path?.trim();
|
|
if (!rawPath) {
|
|
return { ok: false, error: "empty_path" };
|
|
}
|
|
|
|
if (process.platform === "win32" && !payload.allowMissing) {
|
|
const valid = await isValidSptInstall(rawPath);
|
|
if (!valid) {
|
|
return { ok: false, error: "invalid_spt_path" };
|
|
}
|
|
}
|
|
|
|
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 valid = await isValidSptInstall(selected);
|
|
if (!valid) {
|
|
return { ok: false, error: "invalid_spt_path", path: selected };
|
|
}
|
|
|
|
const record = await writeSptPathRecord({ path: selected, 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();
|
|
}
|
|
});
|