Compare commits

...

4 Commits

8 changed files with 1543 additions and 85 deletions

View File

@ -5,12 +5,13 @@
"main": "dist/main/main.js", "main": "dist/main/main.js",
"private": true, "private": true,
"scripts": { "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:renderer": "vite build",
"build:main": "tsc -p tsconfig.main.json", "build:main": "tsc -p tsconfig.main.json",
"build": "yarn build:renderer && yarn build:main", "build": "yarn build:renderer && yarn build:main",
"pack": "yarn build && electron-builder --dir", "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": { "dependencies": {
"electron": "^30.0.0", "electron": "^30.0.0",

View File

@ -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 { execFile } from "node:child_process";
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as path from "node:path"; import * as path from "node:path";
@ -45,6 +45,32 @@ type CommandRunResult = {
error?: 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 InstallStep = { type InstallStep = {
name: string; name: string;
result: CommandRunResult; result: CommandRunResult;
@ -161,10 +187,14 @@ const resolveCommandPath = async (command: string): Promise<CommandPathResult> =
const runCommand = async ( const runCommand = async (
command: string, command: string,
args: string[], args: string[],
timeoutMs: number timeoutMsOrOptions: number | { timeoutMs?: number; cwd?: string }
): Promise<CommandRunResult> => { ): Promise<CommandRunResult> => {
const timeoutMs =
typeof timeoutMsOrOptions === "number" ? timeoutMsOrOptions : timeoutMsOrOptions.timeoutMs;
const cwd =
typeof timeoutMsOrOptions === "number" ? undefined : timeoutMsOrOptions.cwd;
return await new Promise<CommandRunResult>((resolve) => { 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) { if (error) {
resolve({ resolve({
ok: false, 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> => { const installGitTools = async (): Promise<InstallGitToolsResult> => {
if (process.platform !== "win32") { if (process.platform !== "win32") {
return { ok: false, error: "Windows에서만 자동 설치가 가능합니다.", steps: [] }; return { ok: false, error: "Windows에서만 자동 설치가 가능합니다.", steps: [] };
@ -428,8 +900,13 @@ const registerProfile = async (username: string) => {
const createWindow = () => { const createWindow = () => {
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
width: 1100, width: 880,
height: 720, height: 576,
minWidth: 880,
minHeight: 576,
maxWidth: 880,
maxHeight: 576,
resizable: false,
webPreferences: { webPreferences: {
preload: path.join(__dirname, "../preload/preload.js"), preload: path.join(__dirname, "../preload/preload.js"),
contextIsolation: true, contextIsolation: true,
@ -485,6 +962,77 @@ app.whenReady().then(() => {
return await installGitTools(); 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 }) => { ipcMain.handle("spt:fetchProfile", async (_event, payload: { username: string }) => {
const username = payload.username.trim(); const username = payload.username.trim();

347
src/main/modSyncSim.ts Normal file
View File

@ -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;
});

View File

@ -8,6 +8,15 @@ contextBridge.exposeInMainWorld("sptLauncher", {
checkGitTools: () => ipcRenderer.invoke("spt:checkGitTools"), checkGitTools: () => ipcRenderer.invoke("spt:checkGitTools"),
getGitPaths: () => ipcRenderer.invoke("spt:getGitPaths"), getGitPaths: () => ipcRenderer.invoke("spt:getGitPaths"),
installGitTools: () => ipcRenderer.invoke("spt:installGitTools"), 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 }) => fetchProfile: (payload: { username: string }) =>
ipcRenderer.invoke("spt:fetchProfile", payload), ipcRenderer.invoke("spt:fetchProfile", payload),
downloadProfile: (payload: { username: string }) => downloadProfile: (payload: { username: string }) =>

View File

@ -24,11 +24,30 @@ type GitPathsCheck = {
lfs: CommandPath; lfs: CommandPath;
checkedAt: number; 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_INTERVAL_MS = APP_CONFIG.healthcheckIntervalMs;
const HEALTHCHECK_ENDPOINT_FALLBACK = SERVER_HEALTHCHECK_URL; const HEALTHCHECK_ENDPOINT_FALLBACK = SERVER_HEALTHCHECK_URL;
const MIN_SERVER_CHECK_SCREEN_MS = 1500; const MIN_SERVER_CHECK_SCREEN_MS = 1500;
const IS_DEV = import.meta.env.DEV; 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 App = () => {
const [screen, setScreen] = useState<Screen>("serverCheck"); const [screen, setScreen] = useState<Screen>("serverCheck");
@ -43,6 +62,7 @@ const App = () => {
const [serverRecoveryActive, setServerRecoveryActive] = useState(false); const [serverRecoveryActive, setServerRecoveryActive] = useState(false);
const [skipToolsCheck, setSkipToolsCheck] = useState(false); const [skipToolsCheck, setSkipToolsCheck] = useState(false);
const [devProceedReady, setDevProceedReady] = useState(false); const [devProceedReady, setDevProceedReady] = useState(false);
const [simulateToolsMissing, setSimulateToolsMissing] = useState(false);
const [gitCheckInProgress, setGitCheckInProgress] = useState(false); const [gitCheckInProgress, setGitCheckInProgress] = useState(false);
const [gitCheckedAt, setGitCheckedAt] = useState<number | undefined>(); const [gitCheckedAt, setGitCheckedAt] = useState<number | undefined>();
const [gitCheckResult, setGitCheckResult] = useState<GitToolsCheck | undefined>(); const [gitCheckResult, setGitCheckResult] = useState<GitToolsCheck | undefined>();
@ -65,10 +85,20 @@ const App = () => {
const [profileActionInProgress, setProfileActionInProgress] = useState(false); const [profileActionInProgress, setProfileActionInProgress] = useState(false);
const [syncInProgress, setSyncInProgress] = useState(false); const [syncInProgress, setSyncInProgress] = useState(false);
const [simulateSyncFail, setSimulateSyncFail] = 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 healthCheckInFlightRef = useRef(false);
const gitCheckInFlightRef = useRef(false); const gitCheckInFlightRef = useRef(false);
const gitCheckedRef = useRef(false); const gitCheckedRef = useRef(false);
const modCheckInFlightRef = useRef(false);
const modCheckedRef = useRef(false);
const transitionTimeoutRef = useRef<number | undefined>(); const transitionTimeoutRef = useRef<number | undefined>();
const simulateToolsMissingRef = useRef(false);
const runGitCheck = useCallback(async (force = false) => { const runGitCheck = useCallback(async (force = false) => {
if (force) { if (force) {
@ -83,6 +113,18 @@ const App = () => {
setGitCheckInProgress(true); setGitCheckInProgress(true);
try { 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) { if (!window.sptLauncher?.checkGitTools) {
throw new Error("preload(preload) 미로딩: window.sptLauncher.checkGitTools가 없습니다."); throw new Error("preload(preload) 미로딩: window.sptLauncher.checkGitTools가 없습니다.");
} }
@ -109,6 +151,72 @@ const App = () => {
} }
}, [gitCheckResult]); }, [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(() => { const clearTransitionTimeout = useCallback(() => {
if (transitionTimeoutRef.current) { if (transitionTimeoutRef.current) {
window.clearTimeout(transitionTimeoutRef.current); window.clearTimeout(transitionTimeoutRef.current);
@ -275,14 +383,64 @@ const App = () => {
return () => window.clearInterval(interval); return () => window.clearInterval(interval);
}, [serverRecoveryActive, runHealthCheck]); }, [serverRecoveryActive, runHealthCheck]);
useEffect(() => {
simulateToolsMissingRef.current = simulateToolsMissing;
}, [simulateToolsMissing]);
useEffect(() => { useEffect(() => {
return () => clearTransitionTimeout(); return () => clearTransitionTimeout();
}, [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(() => { const serverStatusLabel = useMemo(() => {
return serverHealthy ? "정상" : "불가"; return serverHealthy ? "정상" : "불가";
}, [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 serverStatusLedClass = serverCheckInProgress ? "idle" : serverHealthy ? "ok" : "blocked";
const gitStatusLedClass = (() => { const gitStatusLedClass = (() => {
if (gitCheckInProgress) { if (gitCheckInProgress) {
@ -475,6 +633,11 @@ const App = () => {
setHasSession(false); setHasSession(false);
setProfile(undefined); setProfile(undefined);
setScreen("login"); setScreen("login");
setModVersionStatus(undefined);
setModCheckMessage(undefined);
modCheckedRef.current = false;
setSptInstallInfo(undefined);
setSptInstallMessage(undefined);
}; };
const handleProfileDownload = async () => { 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) { if (syncInProgress) {
return; return;
} }
setSyncInProgress(true); window.alert("게임을 실행합니다.");
window.setTimeout(() => {
setSyncInProgress(false);
if (!simulateSyncFail) {
window.alert("모드 동기화 성공. 게임을 실행합니다.");
return;
}
const proceed = window.confirm(
"모드 동기화가 실패했습니다. 그래도 실행할까요?"
);
if (proceed) {
window.alert("실패 상태로 게임을 실행합니다.");
}
}, 1000);
}; };
return ( return (
@ -655,6 +894,23 @@ const App = () => {
</button> </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> </section>
)} )}
@ -755,14 +1011,20 @@ const App = () => {
)} )}
{screen === "main" && ( {screen === "main" && (
<main className="main-layout"> <main className="main-layout compact">
<section className="card profile"> <section className="card top-bar">
<div> <div className="profile-summary">
<h2> </h2> <div className="profile-title">
<p className="muted">: {profile?.username ?? ""}</p> <span className="label"></span>
<p className="muted">: {profile?.nickname ?? ""}</p> <span className="profile-name">
<p className="muted">: {typeof profile?.level === "number" ? profile.level : ""}</p> {profile?.nickname ?? profile?.username ?? "-"}
{profile?.side && <p className="muted">: {profile.side}</p>} </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>
<div className="profile-actions"> <div className="profile-actions">
<button type="button" onClick={handleProfileDownload} disabled={profileActionInProgress}> <button type="button" onClick={handleProfileDownload} disabled={profileActionInProgress}>
@ -776,59 +1038,195 @@ const App = () => {
> >
</button> </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 type="button" className="ghost" onClick={handleLogout}>
</button> </button>
</div> </div>
</footer> </section>
</main>
)} <section className="card spt-runner">
{screen !== "serverCheck" && ( <div className="spt-info">
<nav className="status-bar"> <div className="section-head">
<div className="status-item"> <h2>SPT </h2>
<button type="button" className="status-button" onClick={handleManualHealthCheck}> <span className={`status ${sptInstallInfo?.ok ? "ok" : "blocked"}`}>
{sptInstallInfo?.ok ? "경로 확인됨" : "경로 필요"}
</button> </span>
<div className="status-item-right"> </div>
<span className={`led ${serverStatusLedClass}`} aria-label="서버 상태" /> <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> <div className="spt-actions compact-actions">
<div className="status-item"> <div className="section-head">
<button type="button" className="status-button" onClick={handleOpenToolsModal}> <h3></h3>
<span className={`status ${syncInProgress ? "idle" : "ok"}`}>
</button> {syncInProgress ? "동기화 중" : "준비됨"}
<div className="status-item-right"> </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"> <div className="tool-led">
<span className="tool-label">Git</span> <span className="tool-label">Git</span>
<span className={`led ${gitLedClass}`} aria-label="Git 상태" /> <span className={`led ${gitLedClass}`} aria-label="Git 상태" />
@ -838,8 +1236,8 @@ const App = () => {
<span className={`led ${lfsLedClass}`} aria-label="Git LFS 상태" /> <span className={`led ${lfsLedClass}`} aria-label="Git LFS 상태" />
</div> </div>
</div> </div>
</div> </footer>
</nav> </main>
)} )}
{screen !== "serverCheck" && toolsModalOpen && ( {screen !== "serverCheck" && toolsModalOpen && (
<div className="modal-overlay" role="presentation" onClick={() => setToolsModalOpen(false)}> <div className="modal-overlay" role="presentation" onClick={() => setToolsModalOpen(false)}>

View File

@ -161,6 +161,93 @@ button:disabled {
grid-template-columns: 1.2fr 1fr; 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 { .profile {
display: grid; display: grid;
gap: 12px; gap: 12px;
@ -195,12 +282,28 @@ button:disabled {
border: 1px solid #242a35; border: 1px solid #242a35;
} }
.compact-footer {
gap: 12px;
flex-wrap: wrap;
}
.footer-right-only {
justify-content: flex-end;
}
.footer-left { .footer-left {
display: flex; display: flex;
gap: 12px; gap: 12px;
align-items: center; align-items: center;
} }
.footer-right {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.label { .label {
color: #a8b0c1; color: #a8b0c1;
font-size: 13px; font-size: 13px;

View File

@ -69,6 +69,56 @@ interface Window {
checkedAt: number; 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<{ fetchProfile: (payload: { username: string }) => Promise<{
ok: boolean; ok: boolean;
status?: number; status?: number;

View File

@ -5,7 +5,9 @@ export const APP_CONFIG = {
serverRequestTimeoutMs: 4000, serverRequestTimeoutMs: 4000,
commandTimeoutMs: 4000, commandTimeoutMs: 4000,
installTimeoutMs: 10 * 60 * 1000, 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; } as const;
export const SERVER_HEALTHCHECK_URL = new URL( export const SERVER_HEALTHCHECK_URL = new URL(