Implement SPT installation path management and UI enhancements. Added IPC handlers for retrieving and setting the SPT installation path, including validation checks and user prompts. Updated the renderer to display SPT installation status and allow users to select the installation directory, improving overall user experience and feedback.
This commit is contained in:
parent
b493637b8d
commit
9f7244185d
|
|
@ -5,7 +5,7 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
177
src/main/main.ts
177
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 { 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";
|
||||||
|
|
@ -62,6 +62,15 @@ type ModSyncResult = {
|
||||||
steps?: Array<{ name: string; ok: boolean; message?: 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;
|
||||||
|
|
@ -178,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,
|
||||||
|
|
@ -247,7 +260,8 @@ const listRemoteTags = async (repoUrl: string) => {
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
throw new Error(result.error ?? result.output ?? "git ls-remote failed");
|
throw new Error(result.error ?? result.output ?? "git ls-remote failed");
|
||||||
}
|
}
|
||||||
return result.output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
const output = result.output ?? "";
|
||||||
|
return output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseVersionTag = (tag: string) => {
|
const parseVersionTag = (tag: string) => {
|
||||||
|
|
@ -338,11 +352,25 @@ const getModVersionStatus = async (): Promise<ModVersionStatus> => {
|
||||||
const MOD_SYNC_PLAN_FILE_NAME = "mod-sync.plan.json";
|
const MOD_SYNC_PLAN_FILE_NAME = "mod-sync.plan.json";
|
||||||
const MOD_REPO_CACHE_DIR_NAME = "mod-repo-cache";
|
const MOD_REPO_CACHE_DIR_NAME = "mod-repo-cache";
|
||||||
const DEFAULT_PRESERVE_PREFIXES = ["user/profiles", "user/settings", "user/launcher"];
|
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 = () => {
|
const getModRepoCacheDir = () => {
|
||||||
return path.join(app.getPath("userData"), MOD_REPO_CACHE_DIR_NAME);
|
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 readModSyncPlan = async (repoDir: string) => {
|
||||||
const planPath = path.join(repoDir, MOD_SYNC_PLAN_FILE_NAME);
|
const planPath = path.join(repoDir, MOD_SYNC_PLAN_FILE_NAME);
|
||||||
try {
|
try {
|
||||||
|
|
@ -367,6 +395,87 @@ const pathExists = async (targetPath: string) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 normalizePath = (value: string) => value.split(path.sep).join("/");
|
||||||
|
|
||||||
const shouldPreserve = (relativePath: string, preservePrefixes: string[]) => {
|
const shouldPreserve = (relativePath: string, preservePrefixes: string[]) => {
|
||||||
|
|
@ -791,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,
|
||||||
|
|
@ -868,6 +982,57 @@ app.whenReady().then(() => {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ contextBridge.exposeInMainWorld("sptLauncher", {
|
||||||
ipcRenderer.invoke("spt:setLocalModVersion", payload),
|
ipcRenderer.invoke("spt:setLocalModVersion", payload),
|
||||||
runModSync: (payload: { targetDir: string; tag: string; cleanRepo?: boolean }) =>
|
runModSync: (payload: { targetDir: string; tag: string; cleanRepo?: boolean }) =>
|
||||||
ipcRenderer.invoke("spt:runModSync", payload),
|
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 }) =>
|
||||||
|
|
|
||||||
|
|
@ -33,12 +33,21 @@ type ModVersionStatus = {
|
||||||
status?: "upToDate" | "patchMismatch" | "majorMinorMismatch" | "unknown";
|
status?: "upToDate" | "patchMismatch" | "majorMinorMismatch" | "unknown";
|
||||||
error?: string;
|
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 MOD_SYNC_TARGET_DIR = import.meta.env.VITE_SPT_ROOT ?? "";
|
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");
|
||||||
|
|
@ -79,6 +88,10 @@ const App = () => {
|
||||||
const [modCheckInProgress, setModCheckInProgress] = useState(false);
|
const [modCheckInProgress, setModCheckInProgress] = useState(false);
|
||||||
const [modVersionStatus, setModVersionStatus] = useState<ModVersionStatus | undefined>();
|
const [modVersionStatus, setModVersionStatus] = useState<ModVersionStatus | undefined>();
|
||||||
const [modCheckMessage, setModCheckMessage] = useState<string | 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);
|
||||||
|
|
@ -180,6 +193,30 @@ const App = () => {
|
||||||
}
|
}
|
||||||
}, [modVersionStatus]);
|
}, [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);
|
||||||
|
|
@ -361,6 +398,13 @@ const App = () => {
|
||||||
void runModVersionCheck();
|
void runModVersionCheck();
|
||||||
}, [hasSession, screen, 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]);
|
||||||
|
|
@ -592,6 +636,8 @@ const App = () => {
|
||||||
setModVersionStatus(undefined);
|
setModVersionStatus(undefined);
|
||||||
setModCheckMessage(undefined);
|
setModCheckMessage(undefined);
|
||||||
modCheckedRef.current = false;
|
modCheckedRef.current = false;
|
||||||
|
setSptInstallInfo(undefined);
|
||||||
|
setSptInstallMessage(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProfileDownload = async () => {
|
const handleProfileDownload = async () => {
|
||||||
|
|
@ -716,7 +762,8 @@ const App = () => {
|
||||||
: modVersionStatus?.latestTag;
|
: modVersionStatus?.latestTag;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!MOD_SYNC_TARGET_DIR) {
|
const targetDir = sptInstallInfo?.path?.trim();
|
||||||
|
if (!targetDir) {
|
||||||
window.alert("SPT 경로(path)가 설정되지 않았습니다.");
|
window.alert("SPT 경로(path)가 설정되지 않았습니다.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -734,7 +781,7 @@ const App = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await window.sptLauncher.runModSync({
|
const result = await window.sptLauncher.runModSync({
|
||||||
targetDir: MOD_SYNC_TARGET_DIR,
|
targetDir,
|
||||||
tag: targetTag
|
tag: targetTag
|
||||||
});
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
|
|
@ -964,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}>
|
||||||
|
|
@ -985,40 +1038,143 @@ const App = () => {
|
||||||
>
|
>
|
||||||
프로필 리셋
|
프로필 리셋
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="ghost" onClick={handleLogout}>
|
||||||
|
로그아웃
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card action">
|
<section className="card spt-runner">
|
||||||
<h2>모드 버전</h2>
|
<div className="spt-info">
|
||||||
<p className={`status ${modStatusClass}`}>{modStatusLabel}</p>
|
<div className="section-head">
|
||||||
<div className="notice">
|
<h2>SPT 실행 정보</h2>
|
||||||
<div>현재 버전: {modVersionStatus?.currentTag ?? "-"}</div>
|
<span className={`status ${sptInstallInfo?.ok ? "ok" : "blocked"}`}>
|
||||||
<div>최신 버전: {modVersionStatus?.latestTag ?? "-"}</div>
|
{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>
|
||||||
{modVersionStatus?.latestMinorTags && modVersionStatus.latestMinorTags.length > 0 && (
|
<div className="spt-actions compact-actions">
|
||||||
<div className="notice">
|
<div className="section-head">
|
||||||
<strong>최신 마이너 버전 패치 목록</strong>
|
<h3>실행</h3>
|
||||||
<div className="modal-help">{modVersionStatus.latestMinorTags.join(", ")}</div>
|
<span className={`status ${syncInProgress ? "idle" : "ok"}`}>
|
||||||
|
{syncInProgress ? "동기화 중" : "준비됨"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<button type="button" onClick={handleLaunch} disabled={syncInProgress}>
|
||||||
{modVersionStatus?.status === "majorMinorMismatch" && (
|
{syncInProgress ? "동기화 중..." : "게임 시작"}
|
||||||
<div className="notice">
|
</button>
|
||||||
<strong>강제 동기화 필요</strong>
|
|
||||||
<div className="modal-help">
|
|
||||||
메이저/마이너 버전이 다릅니다. 게임 시작 시 자동 동기화 후 실행됩니다.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{modVersionStatus?.status === "patchMismatch" && (
|
|
||||||
<div className="notice">
|
|
||||||
<strong>새 패치 안내</strong>
|
|
||||||
<div className="modal-help">
|
|
||||||
새 패치 버전이 있습니다. 동기화 버튼을 눌러 업데이트할 수 있습니다.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{modCheckMessage && <div className="notice">{modCheckMessage}</div>}
|
|
||||||
<div className="modal-actions">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => performModSync("optional")}
|
onClick={() => performModSync("optional")}
|
||||||
|
|
@ -1042,59 +1198,35 @@ const App = () => {
|
||||||
>
|
>
|
||||||
{modCheckInProgress ? "확인 중..." : "버전 재확인"}
|
{modCheckInProgress ? "확인 중..." : "버전 재확인"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button
|
||||||
</section>
|
type="button"
|
||||||
|
className="ghost"
|
||||||
<section className="card action">
|
onClick={() => window.open(INSTALL_GUIDE_URL, "_blank")}
|
||||||
<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>
|
</button>
|
||||||
|
{DEV_UI_ENABLED && (
|
||||||
|
<label className="checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={simulateSyncFail}
|
||||||
|
onChange={(event) => setSimulateSyncFail(event.target.checked)}
|
||||||
|
/>
|
||||||
|
동기화 실패 시뮬레이션
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</section>
|
||||||
</main>
|
|
||||||
)}
|
<footer className="footer compact-footer footer-right-only">
|
||||||
{screen !== "serverCheck" && (
|
<div className="footer-right">
|
||||||
<nav className="status-bar">
|
<div className="tool-led">
|
||||||
<div className="status-item">
|
<span className="tool-label">서버</span>
|
||||||
<button type="button" className="status-button" onClick={handleManualHealthCheck}>
|
<span className={`led ${serverStatusLedClass}`} aria-label="서버 상태" />
|
||||||
서버 상태
|
</div>
|
||||||
</button>
|
<button type="button" className="ghost" onClick={handleOpenToolsModal}>
|
||||||
<div className="status-item-right">
|
도구 상태
|
||||||
<span className={`led ${serverStatusLedClass}`} aria-label="서버 상태" />
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="status-item">
|
|
||||||
<button type="button" className="status-button" onClick={handleOpenToolsModal}>
|
|
||||||
도구 상태
|
|
||||||
</button>
|
|
||||||
<div className="status-item-right">
|
|
||||||
<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 상태" />
|
||||||
|
|
@ -1104,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)}>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,33 @@ interface Window {
|
||||||
error?: string;
|
error?: string;
|
||||||
steps?: Array<{ name: string; ok: boolean; message?: 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;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ export const APP_CONFIG = {
|
||||||
commandTimeoutMs: 4000,
|
commandTimeoutMs: 4000,
|
||||||
installTimeoutMs: 10 * 60 * 1000,
|
installTimeoutMs: 10 * 60 * 1000,
|
||||||
healthcheckIntervalMs: 10000,
|
healthcheckIntervalMs: 10000,
|
||||||
modRepoUrl: "https://gitea.pandoli365.com/art/spt-mods"
|
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(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue