Compare commits
4 Commits
dc13526360
...
9f7244185d
| Author | SHA1 | Date |
|---|---|---|
|
|
9f7244185d | |
|
|
b493637b8d | |
|
|
099ba63e33 | |
|
|
7c649b76c4 |
|
|
@ -5,12 +5,13 @@
|
|||
"main": "dist/main/main.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "yarn build:main && concurrently -k \"vite\" \"wait-on http://localhost:5173 && cross-env NODE_OPTIONS= VITE_DEV_SERVER_URL=http://localhost:5173 electron .\"",
|
||||
"dev": "yarn build:main && concurrently -k \"vite\" \"wait-on http://localhost:5173 && cross-env NODE_OPTIONS= VITE_DEV_SERVER_URL=http://localhost:5173 VITE_DEV_UI=1 electron .\"",
|
||||
"build:renderer": "vite build",
|
||||
"build:main": "tsc -p tsconfig.main.json",
|
||||
"build": "yarn build:renderer && yarn build:main",
|
||||
"pack": "yarn build && electron-builder --dir",
|
||||
"dist": "yarn build && electron-builder"
|
||||
"dist": "yarn build && electron-builder",
|
||||
"mod:sync:sim": "yarn build:main && node dist/main/modSyncSim.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron": "^30.0.0",
|
||||
|
|
|
|||
558
src/main/main.ts
558
src/main/main.ts
|
|
@ -1,4 +1,4 @@
|
|||
import { app, BrowserWindow, ipcMain, net, session } from "electron";
|
||||
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";
|
||||
|
|
@ -45,6 +45,32 @@ type CommandRunResult = {
|
|||
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 InstallStep = {
|
||||
name: string;
|
||||
result: CommandRunResult;
|
||||
|
|
@ -161,10 +187,14 @@ const resolveCommandPath = async (command: string): Promise<CommandPathResult> =
|
|||
const runCommand = async (
|
||||
command: string,
|
||||
args: string[],
|
||||
timeoutMs: number
|
||||
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 }, (error, stdout, stderr) => {
|
||||
execFile(command, args, { timeout: timeoutMs, cwd }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
resolve({
|
||||
ok: false,
|
||||
|
|
@ -193,6 +223,448 @@ const getGitPaths = async () => {
|
|||
};
|
||||
};
|
||||
|
||||
const MOD_VERSION_FILE_NAME = "mod-version.json";
|
||||
|
||||
const getModVersionFilePath = () => {
|
||||
return path.join(app.getPath("userData"), MOD_VERSION_FILE_NAME);
|
||||
};
|
||||
|
||||
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: [] };
|
||||
|
|
@ -428,8 +900,13 @@ const registerProfile = async (username: string) => {
|
|||
|
||||
const createWindow = () => {
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1100,
|
||||
height: 720,
|
||||
width: 880,
|
||||
height: 576,
|
||||
minWidth: 880,
|
||||
minHeight: 576,
|
||||
maxWidth: 880,
|
||||
maxHeight: 576,
|
||||
resizable: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "../preload/preload.js"),
|
||||
contextIsolation: true,
|
||||
|
|
@ -485,6 +962,77 @@ app.whenReady().then(() => {
|
|||
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();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,347 @@
|
|||
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";
|
||||
|
||||
type SyncPlan = {
|
||||
sourceRoot?: string;
|
||||
directories?: Array<{ from: string; to: string }>;
|
||||
preserve?: string[];
|
||||
processNames?: string[];
|
||||
};
|
||||
|
||||
const DEFAULT_PROCESS_NAMES = [
|
||||
"EscapeFromTarkov.exe",
|
||||
"EscapeFromTarkov",
|
||||
"EFT.exe",
|
||||
"BattlEye",
|
||||
"BsgLauncher.exe"
|
||||
];
|
||||
|
||||
const DEFAULT_PRESERVE_PREFIXES = ["user/profiles", "user/settings", "user/launcher"];
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const hasFlag = (flag: string) => args.includes(flag);
|
||||
const getArgValue = (flag: string) => {
|
||||
const index = args.indexOf(flag);
|
||||
if (index < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return args[index + 1];
|
||||
};
|
||||
|
||||
const dryRun = hasFlag("--dry-run");
|
||||
const checkProcess = hasFlag("--check-process");
|
||||
const cleanRepo = !hasFlag("--no-clean");
|
||||
|
||||
const rootDir = path.resolve(getArgValue("--root") ?? process.cwd());
|
||||
const repoDir = path.resolve(getArgValue("--repo-dir") ?? path.join(rootDir, ".spt-mods-repo"));
|
||||
const targetDir = path.resolve(getArgValue("--target-dir") ?? path.join(rootDir, "mods"));
|
||||
const planPath = path.resolve(getArgValue("--plan") ?? path.join(rootDir, "mod-sync.plan.json"));
|
||||
const repoUrl = getArgValue("--repo-url") ?? APP_CONFIG.modRepoUrl;
|
||||
|
||||
const log = (message: string) => {
|
||||
console.info(`[mod-sync-sim] ${message}`);
|
||||
};
|
||||
|
||||
const runCommand = async (
|
||||
command: string,
|
||||
commandArgs: string[],
|
||||
options: { cwd?: string; timeoutMs?: number } = {}
|
||||
) => {
|
||||
return await new Promise<{ ok: boolean; output: string; error?: string }>((resolve) => {
|
||||
execFile(
|
||||
command,
|
||||
commandArgs,
|
||||
{ cwd: options.cwd, timeout: options.timeoutMs ?? 5 * 60 * 1000 },
|
||||
(error, stdout, stderr) => {
|
||||
const output = `${stdout ?? ""}${stderr ?? ""}`.trim();
|
||||
if (error) {
|
||||
resolve({ ok: false, output, error: error.message });
|
||||
return;
|
||||
}
|
||||
resolve({ ok: true, output });
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const pathExists = async (targetPath: string) => {
|
||||
try {
|
||||
await fs.stat(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const readPlan = async (): Promise<SyncPlan | null> => {
|
||||
if (!(await pathExists(planPath))) {
|
||||
return null;
|
||||
}
|
||||
const raw = await fs.readFile(planPath, "utf8");
|
||||
try {
|
||||
return JSON.parse(raw) as SyncPlan;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`mod-sync.plan.json parse failed: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const listProcessNames = async (): Promise<string[]> => {
|
||||
if (process.platform === "win32") {
|
||||
const result = await runCommand("tasklist", ["/FO", "CSV"]);
|
||||
if (!result.ok) {
|
||||
return [];
|
||||
}
|
||||
return result.output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.split(",")[0]?.replace(/"/g, "").trim())
|
||||
.filter((name) => Boolean(name));
|
||||
}
|
||||
|
||||
const result = await runCommand("ps", ["-A", "-o", "comm"]);
|
||||
if (!result.ok) {
|
||||
return [];
|
||||
}
|
||||
return result.output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((name) => Boolean(name));
|
||||
};
|
||||
|
||||
const checkTarkovProcess = async (plan: SyncPlan | null) => {
|
||||
if (!checkProcess) {
|
||||
log("개발 모드(dev mode)로 프로세스 체크를 스킵합니다.");
|
||||
return { ok: true, skipped: true };
|
||||
}
|
||||
|
||||
const processNames = (plan?.processNames ?? DEFAULT_PROCESS_NAMES).map((name) =>
|
||||
name.toLowerCase()
|
||||
);
|
||||
const running = (await listProcessNames()).map((name) => name.toLowerCase());
|
||||
const matches = processNames.filter((name) => running.includes(name));
|
||||
|
||||
if (matches.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
message: `타르코프 관련 프로세스가 실행 중입니다: ${matches.join(", ")}`
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, skipped: false };
|
||||
};
|
||||
|
||||
const ensureRepo = async () => {
|
||||
const gitDir = path.join(repoDir, ".git");
|
||||
if (await pathExists(gitDir)) {
|
||||
log("기존 모드 저장소(repo)를 사용합니다.");
|
||||
const fetchResult = await runCommand("git", ["fetch", "--tags", "--prune"], {
|
||||
cwd: repoDir
|
||||
});
|
||||
if (!fetchResult.ok) {
|
||||
throw new Error(`git fetch failed: ${fetchResult.error ?? fetchResult.output}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
await fs.mkdir(repoDir, { recursive: true });
|
||||
}
|
||||
|
||||
log(`모드 저장소(repo) 클론(clone): ${repoUrl}`);
|
||||
if (dryRun) {
|
||||
return;
|
||||
}
|
||||
const cloneResult = await runCommand("git", ["clone", repoUrl, repoDir]);
|
||||
if (!cloneResult.ok) {
|
||||
throw new Error(`git clone failed: ${cloneResult.error ?? cloneResult.output}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getRepoVersion = async () => {
|
||||
const tagResult = await runCommand("git", ["tag", "--list", "v*.*.*", "--sort=-v:refname"], {
|
||||
cwd: repoDir
|
||||
});
|
||||
if (!tagResult.ok) {
|
||||
throw new Error(`git tag list failed: ${tagResult.error ?? tagResult.output}`);
|
||||
}
|
||||
|
||||
const latestTag = tagResult.output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => Boolean(line));
|
||||
|
||||
if (!latestTag) {
|
||||
throw new Error("v*.*.* 태그(tag)를 찾지 못했습니다.");
|
||||
}
|
||||
|
||||
const commitResult = await runCommand("git", ["rev-parse", "HEAD"], { cwd: repoDir });
|
||||
if (!commitResult.ok) {
|
||||
throw new Error(`git rev-parse failed: ${commitResult.error ?? commitResult.output}`);
|
||||
}
|
||||
|
||||
return {
|
||||
tag: latestTag,
|
||||
commit: commitResult.output
|
||||
};
|
||||
};
|
||||
|
||||
const pullLfs = async () => {
|
||||
const result = await runCommand("git", ["lfs", "pull"], { cwd: repoDir });
|
||||
if (!result.ok) {
|
||||
throw new Error(`git lfs pull failed: ${result.error ?? result.output}`);
|
||||
}
|
||||
};
|
||||
|
||||
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)) {
|
||||
log(`보존(preserve) 경로 스킵: ${destRelative}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await copyDir(sourcePath, destPath, preservePrefixes, baseDest);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isSymbolicLink()) {
|
||||
log(`심볼릭 링크(symlink) 스킵: ${destRelative}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
log(`파일 복사(copy) 시뮬레이션: ${destRelative}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await fs.copyFile(sourcePath, destPath);
|
||||
}
|
||||
};
|
||||
|
||||
const moveDir = async (source: string, destination: string, preservePrefixes: string[]) => {
|
||||
const destExists = await pathExists(destination);
|
||||
if (!destExists && !dryRun) {
|
||||
await fs.mkdir(destination, { recursive: true });
|
||||
}
|
||||
|
||||
await copyDir(source, destination, preservePrefixes, targetDir);
|
||||
if (!dryRun) {
|
||||
await fs.rm(source, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
const resolveSourceRoot = async (plan: SyncPlan | 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 syncDirectories = async (plan: SyncPlan | null) => {
|
||||
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))) {
|
||||
log(`소스 경로 누락: ${sourcePath}`);
|
||||
continue;
|
||||
}
|
||||
log(`디렉토리 이동(move): ${entry.from} -> ${entry.to}`);
|
||||
await moveDir(sourcePath, destPath, preservePrefixes);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceRoot = await resolveSourceRoot(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);
|
||||
log(`디렉토리 이동(move): ${entry.name} -> ${path.relative(rootDir, destPath)}`);
|
||||
await moveDir(sourcePath, destPath, preservePrefixes);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanRepoDir = async () => {
|
||||
if (!cleanRepo) {
|
||||
return;
|
||||
}
|
||||
if (dryRun) {
|
||||
log(`저장소 삭제(clean) 시뮬레이션: ${repoDir}`);
|
||||
return;
|
||||
}
|
||||
await fs.rm(repoDir, { recursive: true, force: true });
|
||||
log("저장소 삭제(clean) 완료.");
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
log(`root=${rootDir}`);
|
||||
log(`repo=${repoDir}`);
|
||||
log(`target=${targetDir}`);
|
||||
log(`plan=${planPath}`);
|
||||
|
||||
const plan = await readPlan();
|
||||
|
||||
const processCheck = await checkTarkovProcess(plan);
|
||||
if (!processCheck.ok) {
|
||||
throw new Error(processCheck.message ?? "process check failed");
|
||||
}
|
||||
|
||||
await ensureRepo();
|
||||
const version = await getRepoVersion();
|
||||
log(`mod version(tag): ${version.tag}`);
|
||||
log(`mod version(commit): ${version.commit}`);
|
||||
|
||||
log("git lfs pull 시작...");
|
||||
await pullLfs();
|
||||
log("git lfs pull 완료.");
|
||||
|
||||
await syncDirectories(plan);
|
||||
await cleanRepoDir();
|
||||
|
||||
log("모드 동기화 시뮬레이션(simulation) 완료.");
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[mod-sync-sim] 실패(error): ${message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
|
@ -8,6 +8,15 @@ contextBridge.exposeInMainWorld("sptLauncher", {
|
|||
checkGitTools: () => ipcRenderer.invoke("spt:checkGitTools"),
|
||||
getGitPaths: () => ipcRenderer.invoke("spt:getGitPaths"),
|
||||
installGitTools: () => ipcRenderer.invoke("spt:installGitTools"),
|
||||
getModVersionStatus: () => ipcRenderer.invoke("spt:getModVersionStatus"),
|
||||
setLocalModVersion: (payload: { tag: string }) =>
|
||||
ipcRenderer.invoke("spt:setLocalModVersion", payload),
|
||||
runModSync: (payload: { targetDir: string; tag: string; cleanRepo?: boolean }) =>
|
||||
ipcRenderer.invoke("spt:runModSync", payload),
|
||||
getSptInstallInfo: () => ipcRenderer.invoke("spt:getSptInstallInfo"),
|
||||
setSptInstallPath: (payload: { path: string; allowMissing?: boolean }) =>
|
||||
ipcRenderer.invoke("spt:setSptInstallPath", payload),
|
||||
pickSptInstallPath: () => ipcRenderer.invoke("spt:pickSptInstallPath"),
|
||||
fetchProfile: (payload: { username: string }) =>
|
||||
ipcRenderer.invoke("spt:fetchProfile", payload),
|
||||
downloadProfile: (payload: { username: string }) =>
|
||||
|
|
|
|||
|
|
@ -24,11 +24,30 @@ type GitPathsCheck = {
|
|||
lfs: CommandPath;
|
||||
checkedAt: number;
|
||||
};
|
||||
type ModVersionStatus = {
|
||||
ok: boolean;
|
||||
checkedAt: number;
|
||||
currentTag?: string;
|
||||
latestTag?: string;
|
||||
latestMinorTags?: string[];
|
||||
status?: "upToDate" | "patchMismatch" | "majorMinorMismatch" | "unknown";
|
||||
error?: string;
|
||||
};
|
||||
type SptInstallInfo = {
|
||||
ok: boolean;
|
||||
checkedAt: number;
|
||||
path?: string;
|
||||
source?: "stored" | "auto";
|
||||
needsUserInput?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const HEALTHCHECK_INTERVAL_MS = APP_CONFIG.healthcheckIntervalMs;
|
||||
const HEALTHCHECK_ENDPOINT_FALLBACK = SERVER_HEALTHCHECK_URL;
|
||||
const MIN_SERVER_CHECK_SCREEN_MS = 1500;
|
||||
const IS_DEV = import.meta.env.DEV;
|
||||
const DEV_UI_ENABLED = IS_DEV && import.meta.env.VITE_DEV_UI === "1";
|
||||
const INSTALL_GUIDE_URL = APP_CONFIG.sptInstallGuideUrl;
|
||||
|
||||
const App = () => {
|
||||
const [screen, setScreen] = useState<Screen>("serverCheck");
|
||||
|
|
@ -43,6 +62,7 @@ const App = () => {
|
|||
const [serverRecoveryActive, setServerRecoveryActive] = useState(false);
|
||||
const [skipToolsCheck, setSkipToolsCheck] = useState(false);
|
||||
const [devProceedReady, setDevProceedReady] = useState(false);
|
||||
const [simulateToolsMissing, setSimulateToolsMissing] = useState(false);
|
||||
const [gitCheckInProgress, setGitCheckInProgress] = useState(false);
|
||||
const [gitCheckedAt, setGitCheckedAt] = useState<number | undefined>();
|
||||
const [gitCheckResult, setGitCheckResult] = useState<GitToolsCheck | undefined>();
|
||||
|
|
@ -65,10 +85,20 @@ const App = () => {
|
|||
const [profileActionInProgress, setProfileActionInProgress] = useState(false);
|
||||
const [syncInProgress, setSyncInProgress] = useState(false);
|
||||
const [simulateSyncFail, setSimulateSyncFail] = useState(false);
|
||||
const [modCheckInProgress, setModCheckInProgress] = useState(false);
|
||||
const [modVersionStatus, setModVersionStatus] = useState<ModVersionStatus | undefined>();
|
||||
const [modCheckMessage, setModCheckMessage] = useState<string | undefined>();
|
||||
const [sptInstallInfo, setSptInstallInfo] = useState<SptInstallInfo | undefined>();
|
||||
const [sptInstallMessage, setSptInstallMessage] = useState<string | undefined>();
|
||||
const [sptPathInput, setSptPathInput] = useState("");
|
||||
const [allowMissingSptPath, setAllowMissingSptPath] = useState(false);
|
||||
const healthCheckInFlightRef = useRef(false);
|
||||
const gitCheckInFlightRef = useRef(false);
|
||||
const gitCheckedRef = useRef(false);
|
||||
const modCheckInFlightRef = useRef(false);
|
||||
const modCheckedRef = useRef(false);
|
||||
const transitionTimeoutRef = useRef<number | undefined>();
|
||||
const simulateToolsMissingRef = useRef(false);
|
||||
|
||||
const runGitCheck = useCallback(async (force = false) => {
|
||||
if (force) {
|
||||
|
|
@ -83,6 +113,18 @@ const App = () => {
|
|||
setGitCheckInProgress(true);
|
||||
|
||||
try {
|
||||
if (IS_DEV && simulateToolsMissingRef.current) {
|
||||
const simulated = {
|
||||
git: { ok: false, command: "git", error: "simulated_missing" },
|
||||
lfs: { ok: false, command: "git lfs", error: "simulated_missing" },
|
||||
checkedAt: Date.now()
|
||||
};
|
||||
setGitCheckResult(simulated);
|
||||
setGitCheckedAt(simulated.checkedAt);
|
||||
gitCheckedRef.current = true;
|
||||
return simulated;
|
||||
}
|
||||
|
||||
if (!window.sptLauncher?.checkGitTools) {
|
||||
throw new Error("preload(preload) 미로딩: window.sptLauncher.checkGitTools가 없습니다.");
|
||||
}
|
||||
|
|
@ -109,6 +151,72 @@ const App = () => {
|
|||
}
|
||||
}, [gitCheckResult]);
|
||||
|
||||
const runModVersionCheck = useCallback(async (force = false) => {
|
||||
if (force) {
|
||||
modCheckedRef.current = false;
|
||||
}
|
||||
|
||||
if (modCheckInFlightRef.current || (!force && modCheckedRef.current)) {
|
||||
return modVersionStatus;
|
||||
}
|
||||
|
||||
modCheckInFlightRef.current = true;
|
||||
setModCheckInProgress(true);
|
||||
setModCheckMessage(undefined);
|
||||
|
||||
try {
|
||||
if (!window.sptLauncher?.getModVersionStatus) {
|
||||
throw new Error("preload(preload) 미로딩: window.sptLauncher.getModVersionStatus가 없습니다.");
|
||||
}
|
||||
|
||||
const result = await window.sptLauncher.getModVersionStatus();
|
||||
setModVersionStatus(result);
|
||||
modCheckedRef.current = true;
|
||||
if (!result.ok) {
|
||||
setModCheckMessage(result.error ?? "모드 버전 확인에 실패했습니다.");
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const fallback: ModVersionStatus = {
|
||||
ok: false,
|
||||
checkedAt: Date.now(),
|
||||
error: message
|
||||
};
|
||||
setModVersionStatus(fallback);
|
||||
setModCheckMessage(message);
|
||||
modCheckedRef.current = true;
|
||||
return fallback;
|
||||
} finally {
|
||||
modCheckInFlightRef.current = false;
|
||||
setModCheckInProgress(false);
|
||||
}
|
||||
}, [modVersionStatus]);
|
||||
|
||||
const runSptInstallCheck = useCallback(async () => {
|
||||
if (!window.sptLauncher?.getSptInstallInfo) {
|
||||
setSptInstallMessage("SPT 경로 확인 기능이 준비되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await window.sptLauncher.getSptInstallInfo();
|
||||
setSptInstallInfo(result);
|
||||
if (!result.ok) {
|
||||
if (result.error === "unsupported_platform") {
|
||||
setSptInstallMessage("Windows 환경에서만 SPT 경로 확인이 가능합니다.");
|
||||
} else if (result.error === "not_found") {
|
||||
setSptInstallMessage("SPT 설치 경로를 찾지 못했습니다.");
|
||||
} else {
|
||||
setSptInstallMessage(result.error ?? "SPT 경로 확인에 실패했습니다.");
|
||||
}
|
||||
} else {
|
||||
setSptInstallMessage(undefined);
|
||||
if (result.path) {
|
||||
setSptPathInput(result.path);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearTransitionTimeout = useCallback(() => {
|
||||
if (transitionTimeoutRef.current) {
|
||||
window.clearTimeout(transitionTimeoutRef.current);
|
||||
|
|
@ -275,14 +383,64 @@ const App = () => {
|
|||
return () => window.clearInterval(interval);
|
||||
}, [serverRecoveryActive, runHealthCheck]);
|
||||
|
||||
useEffect(() => {
|
||||
simulateToolsMissingRef.current = simulateToolsMissing;
|
||||
}, [simulateToolsMissing]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearTransitionTimeout();
|
||||
}, [clearTransitionTimeout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSession || screen !== "main") {
|
||||
return;
|
||||
}
|
||||
void runModVersionCheck();
|
||||
}, [hasSession, screen, runModVersionCheck]);
|
||||
|
||||
useEffect(() => {
|
||||
if (screen !== "main") {
|
||||
return;
|
||||
}
|
||||
void runSptInstallCheck();
|
||||
}, [screen, runSptInstallCheck]);
|
||||
|
||||
const serverStatusLabel = useMemo(() => {
|
||||
return serverHealthy ? "정상" : "불가";
|
||||
}, [serverHealthy]);
|
||||
|
||||
const modStatusLabel = useMemo(() => {
|
||||
if (modCheckInProgress) {
|
||||
return "확인 중";
|
||||
}
|
||||
if (!modVersionStatus?.ok) {
|
||||
return "확인 실패";
|
||||
}
|
||||
switch (modVersionStatus.status) {
|
||||
case "upToDate":
|
||||
return "최신";
|
||||
case "patchMismatch":
|
||||
return "패치 업데이트 필요";
|
||||
case "majorMinorMismatch":
|
||||
return "강제 동기화 필요";
|
||||
default:
|
||||
return "알 수 없음";
|
||||
}
|
||||
}, [modCheckInProgress, modVersionStatus]);
|
||||
|
||||
const modStatusClass = (() => {
|
||||
if (modCheckInProgress) {
|
||||
return "idle";
|
||||
}
|
||||
if (!modVersionStatus?.ok) {
|
||||
return "blocked";
|
||||
}
|
||||
if (modVersionStatus.status === "upToDate") {
|
||||
return "ok";
|
||||
}
|
||||
return "blocked";
|
||||
})();
|
||||
|
||||
const serverStatusLedClass = serverCheckInProgress ? "idle" : serverHealthy ? "ok" : "blocked";
|
||||
const gitStatusLedClass = (() => {
|
||||
if (gitCheckInProgress) {
|
||||
|
|
@ -475,6 +633,11 @@ const App = () => {
|
|||
setHasSession(false);
|
||||
setProfile(undefined);
|
||||
setScreen("login");
|
||||
setModVersionStatus(undefined);
|
||||
setModCheckMessage(undefined);
|
||||
modCheckedRef.current = false;
|
||||
setSptInstallInfo(undefined);
|
||||
setSptInstallMessage(undefined);
|
||||
};
|
||||
|
||||
const handleProfileDownload = async () => {
|
||||
|
|
@ -556,29 +719,105 @@ const App = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleLaunch = () => {
|
||||
const updateLocalModVersion = async (tag: string) => {
|
||||
if (!window.sptLauncher?.setLocalModVersion) {
|
||||
return false;
|
||||
}
|
||||
const result = await window.sptLauncher.setLocalModVersion({ tag });
|
||||
if (!result.ok) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const applyModSyncResult = async (tag?: string) => {
|
||||
if (!tag) {
|
||||
return;
|
||||
}
|
||||
const updated = await updateLocalModVersion(tag);
|
||||
if (!updated) {
|
||||
return;
|
||||
}
|
||||
setModVersionStatus((prev) => {
|
||||
if (!prev) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
currentTag: tag,
|
||||
status: prev.latestTag === tag ? "upToDate" : prev.status
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const performModSync = async (mode: "required" | "optional" | "force") => {
|
||||
if (syncInProgress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setSyncInProgress(true);
|
||||
const targetTag =
|
||||
mode === "force"
|
||||
? modVersionStatus?.currentTag ?? modVersionStatus?.latestTag
|
||||
: modVersionStatus?.latestTag;
|
||||
|
||||
try {
|
||||
const targetDir = sptInstallInfo?.path?.trim();
|
||||
if (!targetDir) {
|
||||
window.alert("SPT 경로(path)가 설정되지 않았습니다.");
|
||||
return false;
|
||||
}
|
||||
if (!targetTag) {
|
||||
setModCheckMessage("동기화할 태그(tag)를 찾지 못했습니다.");
|
||||
return false;
|
||||
}
|
||||
if (!window.sptLauncher?.runModSync) {
|
||||
window.alert("동기화 기능이 준비되지 않았습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (simulateSyncFail) {
|
||||
throw new Error("simulated_sync_fail");
|
||||
}
|
||||
|
||||
const result = await window.sptLauncher.runModSync({
|
||||
targetDir,
|
||||
tag: targetTag
|
||||
});
|
||||
if (!result.ok) {
|
||||
setModCheckMessage(result.error ?? "모드 동기화에 실패했습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
await applyModSyncResult(result.tag ?? targetTag);
|
||||
setModCheckMessage(undefined);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const proceed = window.confirm(
|
||||
`모드 동기화가 실패했습니다. 그래도 실행할까요? (${message})`
|
||||
);
|
||||
return proceed;
|
||||
} finally {
|
||||
setSyncInProgress(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLaunch = async () => {
|
||||
const status = modVersionStatus?.status;
|
||||
if (status === "majorMinorMismatch") {
|
||||
window.alert("모드 메이저/마이너 버전이 다릅니다. 동기화 후 실행됩니다.");
|
||||
const ok = await performModSync("required");
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (syncInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSyncInProgress(true);
|
||||
|
||||
window.setTimeout(() => {
|
||||
setSyncInProgress(false);
|
||||
|
||||
if (!simulateSyncFail) {
|
||||
window.alert("모드 동기화 성공. 게임을 실행합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const proceed = window.confirm(
|
||||
"모드 동기화가 실패했습니다. 그래도 실행할까요?"
|
||||
);
|
||||
|
||||
if (proceed) {
|
||||
window.alert("실패 상태로 게임을 실행합니다.");
|
||||
}
|
||||
}, 1000);
|
||||
window.alert("게임을 실행합니다.");
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -655,6 +894,23 @@ const App = () => {
|
|||
다음으로 이동
|
||||
</button>
|
||||
)}
|
||||
{IS_DEV && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={() => {
|
||||
setSimulateToolsMissing((prev) => {
|
||||
const next = !prev;
|
||||
simulateToolsMissingRef.current = next;
|
||||
return next;
|
||||
});
|
||||
gitCheckedRef.current = false;
|
||||
void runGitCheck(true);
|
||||
}}
|
||||
>
|
||||
{simulateToolsMissing ? "Git/LFS 시뮬레이션 해제" : "Git/LFS 미설치 시뮬레이션"}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
|
@ -755,14 +1011,20 @@ const App = () => {
|
|||
)}
|
||||
|
||||
{screen === "main" && (
|
||||
<main className="main-layout">
|
||||
<section className="card profile">
|
||||
<div>
|
||||
<h2>프로필 정보</h2>
|
||||
<p className="muted">아이디: {profile?.username ?? ""}</p>
|
||||
<p className="muted">닉네임: {profile?.nickname ?? ""}</p>
|
||||
<p className="muted">레벨: {typeof profile?.level === "number" ? profile.level : ""}</p>
|
||||
{profile?.side && <p className="muted">진영: {profile.side}</p>}
|
||||
<main className="main-layout compact">
|
||||
<section className="card top-bar">
|
||||
<div className="profile-summary">
|
||||
<div className="profile-title">
|
||||
<span className="label">프로필</span>
|
||||
<span className="profile-name">
|
||||
{profile?.nickname ?? profile?.username ?? "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="profile-meta">
|
||||
<span>아이디: {profile?.username ?? "-"}</span>
|
||||
<span>레벨: {typeof profile?.level === "number" ? profile.level : "-"}</span>
|
||||
{profile?.side && <span>진영: {profile.side}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-actions">
|
||||
<button type="button" onClick={handleProfileDownload} disabled={profileActionInProgress}>
|
||||
|
|
@ -776,59 +1038,195 @@ const App = () => {
|
|||
>
|
||||
프로필 리셋
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card action">
|
||||
<h2>게임 시작</h2>
|
||||
<p className="status ok">준비됨</p>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={simulateSyncFail}
|
||||
onChange={(event) => setSimulateSyncFail(event.target.checked)}
|
||||
/>
|
||||
모드 동기화 실패 시뮬레이션
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLaunch}
|
||||
disabled={syncInProgress}
|
||||
>
|
||||
{syncInProgress ? "동기화 중..." : "게임 시작"}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<footer className="footer">
|
||||
<div className="footer-left">
|
||||
<span className="label">서버 상태</span>
|
||||
<span className={`status ${serverHealthy ? "ok" : "blocked"}`}>
|
||||
{serverStatusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="footer-right">
|
||||
<button type="button" className="ghost" onClick={handleLogout}>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
)}
|
||||
{screen !== "serverCheck" && (
|
||||
<nav className="status-bar">
|
||||
<div className="status-item">
|
||||
<button type="button" className="status-button" onClick={handleManualHealthCheck}>
|
||||
서버 상태
|
||||
</button>
|
||||
<div className="status-item-right">
|
||||
<span className={`led ${serverStatusLedClass}`} aria-label="서버 상태" />
|
||||
</section>
|
||||
|
||||
<section className="card spt-runner">
|
||||
<div className="spt-info">
|
||||
<div className="section-head">
|
||||
<h2>SPT 실행 정보</h2>
|
||||
<span className={`status ${sptInstallInfo?.ok ? "ok" : "blocked"}`}>
|
||||
{sptInstallInfo?.ok ? "경로 확인됨" : "경로 필요"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="spt-row">
|
||||
<span className="label">설치 경로</span>
|
||||
<span className="value">{sptInstallInfo?.path ?? "-"}</span>
|
||||
<div className="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost icon-button"
|
||||
onClick={async () => {
|
||||
if (!window.sptLauncher?.pickSptInstallPath) {
|
||||
window.alert("경로 선택 기능이 준비되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
const result = await window.sptLauncher.pickSptInstallPath();
|
||||
if (!result.ok) {
|
||||
if (result.error === "invalid_spt_path") {
|
||||
setSptInstallMessage("SPT 폴더가 아닙니다. 다시 선택해주세요.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSptInstallMessage(undefined);
|
||||
await runSptInstallCheck();
|
||||
}}
|
||||
>
|
||||
📁
|
||||
</button>
|
||||
<button type="button" className="ghost" onClick={runSptInstallCheck}>
|
||||
재확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{sptInstallInfo?.source && (
|
||||
<div className="helper">
|
||||
확인 방식: {sptInstallInfo.source === "auto" ? "자동 탐지" : "저장됨"}
|
||||
</div>
|
||||
)}
|
||||
<div className="spt-row">
|
||||
<span className="label">모드 버전</span>
|
||||
<span className="value">
|
||||
{modVersionStatus?.currentTag ?? "-"} → {modVersionStatus?.latestTag ?? "-"}
|
||||
</span>
|
||||
<span className={`status ${modStatusClass}`}>{modStatusLabel}</span>
|
||||
</div>
|
||||
{modVersionStatus?.latestMinorTags && modVersionStatus.latestMinorTags.length > 0 && (
|
||||
<div className="helper">
|
||||
최신 패치 목록: {modVersionStatus.latestMinorTags.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{modVersionStatus?.status === "majorMinorMismatch" && (
|
||||
<div className="notice">
|
||||
<strong>강제 동기화 필요</strong>
|
||||
<div className="modal-help">
|
||||
메이저/마이너 버전이 다릅니다. 게임 시작 시 자동 동기화 후 실행됩니다.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{modVersionStatus?.status === "patchMismatch" && (
|
||||
<div className="notice">
|
||||
<strong>새 패치 안내</strong>
|
||||
<div className="modal-help">
|
||||
새 패치 버전이 있습니다. 동기화 버튼을 눌러 업데이트할 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sptInstallMessage && <div className="notice">{sptInstallMessage}</div>}
|
||||
{modCheckMessage && <div className="notice">{modCheckMessage}</div>}
|
||||
{DEV_UI_ENABLED && (
|
||||
<div className="notice">
|
||||
<strong>개발 모드 경로</strong>
|
||||
<div className="modal-help">
|
||||
설치되어 있지 않아도 임의의 경로로 동작을 테스트할 수 있습니다.
|
||||
</div>
|
||||
<div className="login-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="예: D:\\SPT"
|
||||
value={sptPathInput}
|
||||
onChange={(event) => setSptPathInput(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowMissingSptPath}
|
||||
onChange={(event) => setAllowMissingSptPath(event.target.checked)}
|
||||
/>
|
||||
경로 존재 체크 스킵
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={async () => {
|
||||
const value = sptPathInput.trim();
|
||||
if (!value) {
|
||||
window.alert("경로를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
const result = await window.sptLauncher?.setSptInstallPath({
|
||||
path: value,
|
||||
allowMissing: allowMissingSptPath
|
||||
});
|
||||
if (!result?.ok) {
|
||||
setSptInstallMessage(result?.error ?? "경로 저장에 실패했습니다.");
|
||||
return;
|
||||
}
|
||||
setSptInstallMessage(undefined);
|
||||
await runSptInstallCheck();
|
||||
}}
|
||||
>
|
||||
경로 저장
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="status-item">
|
||||
<button type="button" className="status-button" onClick={handleOpenToolsModal}>
|
||||
도구 상태
|
||||
</button>
|
||||
<div className="status-item-right">
|
||||
<div className="spt-actions compact-actions">
|
||||
<div className="section-head">
|
||||
<h3>실행</h3>
|
||||
<span className={`status ${syncInProgress ? "idle" : "ok"}`}>
|
||||
{syncInProgress ? "동기화 중" : "준비됨"}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" onClick={handleLaunch} disabled={syncInProgress}>
|
||||
{syncInProgress ? "동기화 중..." : "게임 시작"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => performModSync("optional")}
|
||||
disabled={syncInProgress || !modVersionStatus?.latestTag}
|
||||
>
|
||||
{syncInProgress ? "동기화 중..." : "동기화"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={() => performModSync("force")}
|
||||
disabled={syncInProgress}
|
||||
>
|
||||
강제 재동기화
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={() => runModVersionCheck(true)}
|
||||
disabled={modCheckInProgress}
|
||||
>
|
||||
{modCheckInProgress ? "확인 중..." : "버전 재확인"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={() => window.open(INSTALL_GUIDE_URL, "_blank")}
|
||||
>
|
||||
설치 가이드 열기
|
||||
</button>
|
||||
{DEV_UI_ENABLED && (
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={simulateSyncFail}
|
||||
onChange={(event) => setSimulateSyncFail(event.target.checked)}
|
||||
/>
|
||||
동기화 실패 시뮬레이션
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="footer compact-footer footer-right-only">
|
||||
<div className="footer-right">
|
||||
<div className="tool-led">
|
||||
<span className="tool-label">서버</span>
|
||||
<span className={`led ${serverStatusLedClass}`} aria-label="서버 상태" />
|
||||
</div>
|
||||
<button type="button" className="ghost" onClick={handleOpenToolsModal}>
|
||||
도구 상태
|
||||
</button>
|
||||
<div className="tool-led">
|
||||
<span className="tool-label">Git</span>
|
||||
<span className={`led ${gitLedClass}`} aria-label="Git 상태" />
|
||||
|
|
@ -838,8 +1236,8 @@ const App = () => {
|
|||
<span className={`led ${lfsLedClass}`} aria-label="Git LFS 상태" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</footer>
|
||||
</main>
|
||||
)}
|
||||
{screen !== "serverCheck" && toolsModalOpen && (
|
||||
<div className="modal-overlay" role="presentation" onClick={() => setToolsModalOpen(false)}>
|
||||
|
|
|
|||
|
|
@ -161,6 +161,93 @@ button:disabled {
|
|||
grid-template-columns: 1.2fr 1fr;
|
||||
}
|
||||
|
||||
.main-layout.compact {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.profile-summary {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.profile-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.profile-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: #a8b0c1;
|
||||
}
|
||||
|
||||
.spt-runner {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: 1.4fr 0.9fr;
|
||||
}
|
||||
|
||||
.spt-info {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.spt-actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.compact-actions button {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.spt-row {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.spt-row .value {
|
||||
font-size: 13px;
|
||||
color: #d5d9e6;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
padding: 6px 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.profile {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
|
|
@ -195,12 +282,28 @@ button:disabled {
|
|||
border: 1px solid #242a35;
|
||||
}
|
||||
|
||||
.compact-footer {
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.footer-right-only {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.footer-left {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer-right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #a8b0c1;
|
||||
font-size: 13px;
|
||||
|
|
|
|||
|
|
@ -69,6 +69,56 @@ interface Window {
|
|||
checkedAt: number;
|
||||
};
|
||||
}>;
|
||||
getModVersionStatus: () => Promise<{
|
||||
ok: boolean;
|
||||
checkedAt: number;
|
||||
currentTag?: string;
|
||||
latestTag?: string;
|
||||
latestMinorTags?: string[];
|
||||
status?: "upToDate" | "patchMismatch" | "majorMinorMismatch" | "unknown";
|
||||
error?: string;
|
||||
}>;
|
||||
setLocalModVersion: (payload: { tag: string }) => Promise<{
|
||||
ok: boolean;
|
||||
record?: {
|
||||
currentTag?: string;
|
||||
updatedAt?: number;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
runModSync: (payload: { targetDir: string; tag: string; cleanRepo?: boolean }) => Promise<{
|
||||
ok: boolean;
|
||||
tag?: string;
|
||||
error?: string;
|
||||
steps?: Array<{ name: string; ok: boolean; message?: string }>;
|
||||
}>;
|
||||
getSptInstallInfo: () => Promise<{
|
||||
ok: boolean;
|
||||
checkedAt: number;
|
||||
path?: string;
|
||||
source?: "stored" | "auto";
|
||||
needsUserInput?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
setSptInstallPath: (payload: { path: string; allowMissing?: boolean }) => Promise<{
|
||||
ok: boolean;
|
||||
record?: {
|
||||
path?: string;
|
||||
allowMissing?: boolean;
|
||||
updatedAt?: number;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
pickSptInstallPath: () => Promise<{
|
||||
ok: boolean;
|
||||
record?: {
|
||||
path?: string;
|
||||
allowMissing?: boolean;
|
||||
updatedAt?: number;
|
||||
};
|
||||
path?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
fetchProfile: (payload: { username: string }) => Promise<{
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ export const APP_CONFIG = {
|
|||
serverRequestTimeoutMs: 4000,
|
||||
commandTimeoutMs: 4000,
|
||||
installTimeoutMs: 10 * 60 * 1000,
|
||||
healthcheckIntervalMs: 10000
|
||||
healthcheckIntervalMs: 10000,
|
||||
modRepoUrl: "https://gitea.pandoli365.com/art/spt-mods",
|
||||
sptInstallGuideUrl: "https://wiki.sp-tarkov.com/Installation_Guide"
|
||||
} as const;
|
||||
|
||||
export const SERVER_HEALTHCHECK_URL = new URL(
|
||||
|
|
|
|||
Loading…
Reference in New Issue