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:
이정수 2026-01-30 14:53:24 +09:00
parent b493637b8d
commit 9f7244185d
7 changed files with 533 additions and 101 deletions

View File

@ -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",

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";
@ -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();

View File

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

View File

@ -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,20 +1038,65 @@ 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> </div>
{modVersionStatus?.latestMinorTags && modVersionStatus.latestMinorTags.length > 0 && ( {modVersionStatus?.latestMinorTags && modVersionStatus.latestMinorTags.length > 0 && (
<div className="notice"> <div className="helper">
<strong> </strong> : {modVersionStatus.latestMinorTags.join(", ")}
<div className="modal-help">{modVersionStatus.latestMinorTags.join(", ")}</div>
</div> </div>
)} )}
{modVersionStatus?.status === "majorMinorMismatch" && ( {modVersionStatus?.status === "majorMinorMismatch" && (
@ -1017,8 +1115,66 @@ const App = () => {
</div> </div>
</div> </div>
)} )}
{sptInstallMessage && <div className="notice">{sptInstallMessage}</div>}
{modCheckMessage && <div className="notice">{modCheckMessage}</div>} {modCheckMessage && <div className="notice">{modCheckMessage}</div>}
<div className="modal-actions"> {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 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 <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>
</button>
{DEV_UI_ENABLED && (
<label className="checkbox"> <label className="checkbox">
<input <input
type="checkbox" type="checkbox"
checked={simulateSyncFail} checked={simulateSyncFail}
onChange={(event) => setSimulateSyncFail(event.target.checked)} onChange={(event) => setSimulateSyncFail(event.target.checked)}
/> />
</label> </label>
<button )}
type="button" </div>
onClick={handleLaunch}
disabled={syncInProgress}
>
{syncInProgress ? "동기화 중..." : "게임 시작"}
</button>
</section> </section>
<footer className="footer"> <footer className="footer compact-footer footer-right-only">
<div className="footer-left">
<span className="label"> </span>
<span className={`status ${serverHealthy ? "ok" : "blocked"}`}>
{serverStatusLabel}
</span>
</div>
<div className="footer-right"> <div className="footer-right">
<button type="button" className="ghost" onClick={handleLogout}> <div className="tool-led">
<span className="tool-label"></span>
</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="서버 상태" /> <span className={`led ${serverStatusLedClass}`} aria-label="서버 상태" />
</div> </div>
</div> <button type="button" className="ghost" onClick={handleOpenToolsModal}>
<div className="status-item">
<button type="button" className="status-button" onClick={handleOpenToolsModal}>
</button> </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)}>

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

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

View File

@ -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(