From 9f7244185d74b4f09eeeff1005e096366177f840 Mon Sep 17 00:00:00 2001 From: art Date: Fri, 30 Jan 2026 14:53:24 +0900 Subject: [PATCH] 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. --- package.json | 2 +- src/main/main.ts | 177 ++++++++++++++++++++- src/preload/preload.ts | 4 + src/renderer/App.tsx | 318 ++++++++++++++++++++++++++----------- src/renderer/styles.css | 103 ++++++++++++ src/renderer/vite-env.d.ts | 27 ++++ src/shared/config.ts | 3 +- 7 files changed, 533 insertions(+), 101 deletions(-) diff --git a/package.json b/package.json index 2721662..dc74edc 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "dist/main/main.js", "private": true, "scripts": { - "dev": "yarn build:main && concurrently -k \"vite\" \"wait-on http://localhost:5173 && cross-env NODE_OPTIONS= VITE_DEV_SERVER_URL=http://localhost:5173 electron .\"", + "dev": "yarn build:main && concurrently -k \"vite\" \"wait-on http://localhost:5173 && cross-env NODE_OPTIONS= VITE_DEV_SERVER_URL=http://localhost:5173 VITE_DEV_UI=1 electron .\"", "build:renderer": "vite build", "build:main": "tsc -p tsconfig.main.json", "build": "yarn build:renderer && yarn build:main", diff --git a/src/main/main.ts b/src/main/main.ts index b702831..dbe2925 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, ipcMain, net, session } from "electron"; +import { app, BrowserWindow, dialog, ipcMain, net, session } from "electron"; import { execFile } from "node:child_process"; import * as fs from "node:fs/promises"; import * as path from "node:path"; @@ -62,6 +62,15 @@ type ModSyncResult = { steps?: Array<{ name: string; ok: boolean; message?: string }>; }; +type SptInstallInfo = { + ok: boolean; + checkedAt: number; + path?: string; + source?: "stored" | "auto"; + needsUserInput?: boolean; + error?: string; +}; + type InstallStep = { name: string; result: CommandRunResult; @@ -178,10 +187,14 @@ const resolveCommandPath = async (command: string): Promise = const runCommand = async ( command: string, args: string[], - timeoutMs: number + timeoutMsOrOptions: number | { timeoutMs?: number; cwd?: string } ): Promise => { + const timeoutMs = + typeof timeoutMsOrOptions === "number" ? timeoutMsOrOptions : timeoutMsOrOptions.timeoutMs; + const cwd = + typeof timeoutMsOrOptions === "number" ? undefined : timeoutMsOrOptions.cwd; return await new Promise((resolve) => { - execFile(command, args, { timeout: timeoutMs }, (error, stdout, stderr) => { + execFile(command, args, { timeout: timeoutMs, cwd }, (error, stdout, stderr) => { if (error) { resolve({ ok: false, @@ -247,7 +260,8 @@ const listRemoteTags = async (repoUrl: string) => { if (!result.ok) { 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) => { @@ -338,11 +352,25 @@ const getModVersionStatus = async (): Promise => { 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 { @@ -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 => { + 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[]) => { @@ -791,8 +900,13 @@ const registerProfile = async (username: string) => { const createWindow = () => { const mainWindow = new BrowserWindow({ - width: 1100, - height: 720, + width: 880, + height: 576, + minWidth: 880, + minHeight: 576, + maxWidth: 880, + maxHeight: 576, + resizable: false, webPreferences: { preload: path.join(__dirname, "../preload/preload.js"), contextIsolation: true, @@ -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 }) => { const username = payload.username.trim(); diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 88f9144..e5f40f2 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -13,6 +13,10 @@ contextBridge.exposeInMainWorld("sptLauncher", { ipcRenderer.invoke("spt:setLocalModVersion", payload), runModSync: (payload: { targetDir: string; tag: string; cleanRepo?: boolean }) => ipcRenderer.invoke("spt:runModSync", payload), + getSptInstallInfo: () => ipcRenderer.invoke("spt:getSptInstallInfo"), + setSptInstallPath: (payload: { path: string; allowMissing?: boolean }) => + ipcRenderer.invoke("spt:setSptInstallPath", payload), + pickSptInstallPath: () => ipcRenderer.invoke("spt:pickSptInstallPath"), fetchProfile: (payload: { username: string }) => ipcRenderer.invoke("spt:fetchProfile", payload), downloadProfile: (payload: { username: string }) => diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 6d5d306..d189a65 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -33,12 +33,21 @@ type ModVersionStatus = { status?: "upToDate" | "patchMismatch" | "majorMinorMismatch" | "unknown"; error?: string; }; +type SptInstallInfo = { + ok: boolean; + checkedAt: number; + path?: string; + source?: "stored" | "auto"; + needsUserInput?: boolean; + error?: string; +}; const HEALTHCHECK_INTERVAL_MS = APP_CONFIG.healthcheckIntervalMs; const HEALTHCHECK_ENDPOINT_FALLBACK = SERVER_HEALTHCHECK_URL; const MIN_SERVER_CHECK_SCREEN_MS = 1500; const IS_DEV = import.meta.env.DEV; -const 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 [screen, setScreen] = useState("serverCheck"); @@ -79,6 +88,10 @@ const App = () => { const [modCheckInProgress, setModCheckInProgress] = useState(false); const [modVersionStatus, setModVersionStatus] = useState(); const [modCheckMessage, setModCheckMessage] = useState(); + const [sptInstallInfo, setSptInstallInfo] = useState(); + const [sptInstallMessage, setSptInstallMessage] = useState(); + const [sptPathInput, setSptPathInput] = useState(""); + const [allowMissingSptPath, setAllowMissingSptPath] = useState(false); const healthCheckInFlightRef = useRef(false); const gitCheckInFlightRef = useRef(false); const gitCheckedRef = useRef(false); @@ -180,6 +193,30 @@ const App = () => { } }, [modVersionStatus]); + const runSptInstallCheck = useCallback(async () => { + if (!window.sptLauncher?.getSptInstallInfo) { + setSptInstallMessage("SPT 경로 확인 기능이 준비되지 않았습니다."); + return; + } + + const result = await window.sptLauncher.getSptInstallInfo(); + setSptInstallInfo(result); + if (!result.ok) { + if (result.error === "unsupported_platform") { + setSptInstallMessage("Windows 환경에서만 SPT 경로 확인이 가능합니다."); + } else if (result.error === "not_found") { + setSptInstallMessage("SPT 설치 경로를 찾지 못했습니다."); + } else { + setSptInstallMessage(result.error ?? "SPT 경로 확인에 실패했습니다."); + } + } else { + setSptInstallMessage(undefined); + if (result.path) { + setSptPathInput(result.path); + } + } + }, []); + const clearTransitionTimeout = useCallback(() => { if (transitionTimeoutRef.current) { window.clearTimeout(transitionTimeoutRef.current); @@ -361,6 +398,13 @@ const App = () => { void runModVersionCheck(); }, [hasSession, screen, runModVersionCheck]); + useEffect(() => { + if (screen !== "main") { + return; + } + void runSptInstallCheck(); + }, [screen, runSptInstallCheck]); + const serverStatusLabel = useMemo(() => { return serverHealthy ? "정상" : "불가"; }, [serverHealthy]); @@ -592,6 +636,8 @@ const App = () => { setModVersionStatus(undefined); setModCheckMessage(undefined); modCheckedRef.current = false; + setSptInstallInfo(undefined); + setSptInstallMessage(undefined); }; const handleProfileDownload = async () => { @@ -716,7 +762,8 @@ const App = () => { : modVersionStatus?.latestTag; try { - if (!MOD_SYNC_TARGET_DIR) { + const targetDir = sptInstallInfo?.path?.trim(); + if (!targetDir) { window.alert("SPT 경로(path)가 설정되지 않았습니다."); return false; } @@ -734,7 +781,7 @@ const App = () => { } const result = await window.sptLauncher.runModSync({ - targetDir: MOD_SYNC_TARGET_DIR, + targetDir, tag: targetTag }); if (!result.ok) { @@ -964,14 +1011,20 @@ const App = () => { )} {screen === "main" && ( -
-
-
-

프로필 정보

-

아이디: {profile?.username ?? ""}

-

닉네임: {profile?.nickname ?? ""}

-

레벨: {typeof profile?.level === "number" ? profile.level : ""}

- {profile?.side &&

진영: {profile.side}

} +
+
+
+
+ 프로필 + + {profile?.nickname ?? profile?.username ?? "-"} + +
+
+ 아이디: {profile?.username ?? "-"} + 레벨: {typeof profile?.level === "number" ? profile.level : "-"} + {profile?.side && 진영: {profile.side}} +
+
-
-

모드 버전

-

{modStatusLabel}

-
-
현재 버전: {modVersionStatus?.currentTag ?? "-"}
-
최신 버전: {modVersionStatus?.latestTag ?? "-"}
+
+
+
+

SPT 실행 정보

+ + {sptInstallInfo?.ok ? "경로 확인됨" : "경로 필요"} + +
+
+ 설치 경로 + {sptInstallInfo?.path ?? "-"} +
+ + +
+
+ {sptInstallInfo?.source && ( +
+ 확인 방식: {sptInstallInfo.source === "auto" ? "자동 탐지" : "저장됨"} +
+ )} +
+ 모드 버전 + + {modVersionStatus?.currentTag ?? "-"} → {modVersionStatus?.latestTag ?? "-"} + + {modStatusLabel} +
+ {modVersionStatus?.latestMinorTags && modVersionStatus.latestMinorTags.length > 0 && ( +
+ 최신 패치 목록: {modVersionStatus.latestMinorTags.join(", ")} +
+ )} + {modVersionStatus?.status === "majorMinorMismatch" && ( +
+ 강제 동기화 필요 +
+ 메이저/마이너 버전이 다릅니다. 게임 시작 시 자동 동기화 후 실행됩니다. +
+
+ )} + {modVersionStatus?.status === "patchMismatch" && ( +
+ 새 패치 안내 +
+ 새 패치 버전이 있습니다. 동기화 버튼을 눌러 업데이트할 수 있습니다. +
+
+ )} + {sptInstallMessage &&
{sptInstallMessage}
} + {modCheckMessage &&
{modCheckMessage}
} + {DEV_UI_ENABLED && ( +
+ 개발 모드 경로 +
+ 설치되어 있지 않아도 임의의 경로로 동작을 테스트할 수 있습니다. +
+
+ setSptPathInput(event.target.value)} + /> +
+ + +
+ )}
- {modVersionStatus?.latestMinorTags && modVersionStatus.latestMinorTags.length > 0 && ( -
- 최신 마이너 버전 패치 목록 -
{modVersionStatus.latestMinorTags.join(", ")}
+
+
+

실행

+ + {syncInProgress ? "동기화 중" : "준비됨"} +
- )} - {modVersionStatus?.status === "majorMinorMismatch" && ( -
- 강제 동기화 필요 -
- 메이저/마이너 버전이 다릅니다. 게임 시작 시 자동 동기화 후 실행됩니다. -
-
- )} - {modVersionStatus?.status === "patchMismatch" && ( -
- 새 패치 안내 -
- 새 패치 버전이 있습니다. 동기화 버튼을 눌러 업데이트할 수 있습니다. -
-
- )} - {modCheckMessage &&
{modCheckMessage}
} -
+ -
-
- -
-

게임 시작

-

준비됨

- - -
- -
-
- 서버 상태 - - {serverStatusLabel} - -
-
- + {DEV_UI_ENABLED && ( + + )}
-
-
- )} - {screen !== "serverCheck" && ( -
+ +
+
+
+ 서버 + +
+
Git @@ -1104,8 +1236,8 @@ const App = () => {
- - +
+
)} {screen !== "serverCheck" && toolsModalOpen && (
setToolsModalOpen(false)}> diff --git a/src/renderer/styles.css b/src/renderer/styles.css index c24e9ad..a102482 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -161,6 +161,93 @@ button:disabled { grid-template-columns: 1.2fr 1fr; } +.main-layout.compact { + grid-template-columns: 1fr; +} + +.top-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.profile-summary { + display: grid; + gap: 6px; +} + +.profile-title { + display: flex; + align-items: center; + gap: 8px; +} + +.profile-name { + font-weight: 600; +} + +.profile-meta { + display: flex; + flex-wrap: wrap; + gap: 10px; + font-size: 13px; + color: #a8b0c1; +} + +.spt-runner { + display: grid; + gap: 12px; + grid-template-columns: 1.4fr 0.9fr; +} + +.spt-info { + display: grid; + gap: 10px; +} + +.spt-actions { + display: grid; + gap: 10px; + align-content: start; +} + +.compact-actions button { + padding: 6px 10px; + font-size: 12px; +} + +.section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.spt-row { + display: grid; + grid-template-columns: 120px 1fr auto; + align-items: center; + gap: 8px; +} + +.spt-row .value { + font-size: 13px; + color: #d5d9e6; + word-break: break-all; +} + +.row-actions { + display: flex; + gap: 6px; + align-items: center; +} + +.icon-button { + padding: 6px 8px; + font-size: 14px; +} + .profile { display: grid; gap: 12px; @@ -195,12 +282,28 @@ button:disabled { border: 1px solid #242a35; } +.compact-footer { + gap: 12px; + flex-wrap: wrap; +} + +.footer-right-only { + justify-content: flex-end; +} + .footer-left { display: flex; gap: 12px; align-items: center; } +.footer-right { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} + .label { color: #a8b0c1; font-size: 13px; diff --git a/src/renderer/vite-env.d.ts b/src/renderer/vite-env.d.ts index cd866b2..a919b09 100644 --- a/src/renderer/vite-env.d.ts +++ b/src/renderer/vite-env.d.ts @@ -92,6 +92,33 @@ interface Window { error?: string; steps?: Array<{ name: string; ok: boolean; message?: string }>; }>; + getSptInstallInfo: () => Promise<{ + ok: boolean; + checkedAt: number; + path?: string; + source?: "stored" | "auto"; + needsUserInput?: boolean; + error?: string; + }>; + setSptInstallPath: (payload: { path: string; allowMissing?: boolean }) => Promise<{ + ok: boolean; + record?: { + path?: string; + allowMissing?: boolean; + updatedAt?: number; + }; + error?: string; + }>; + pickSptInstallPath: () => Promise<{ + ok: boolean; + record?: { + path?: string; + allowMissing?: boolean; + updatedAt?: number; + }; + path?: string; + error?: string; + }>; fetchProfile: (payload: { username: string }) => Promise<{ ok: boolean; status?: number; diff --git a/src/shared/config.ts b/src/shared/config.ts index 17550c3..f1d6bf4 100644 --- a/src/shared/config.ts +++ b/src/shared/config.ts @@ -6,7 +6,8 @@ export const APP_CONFIG = { commandTimeoutMs: 4000, installTimeoutMs: 10 * 60 * 1000, 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; export const SERVER_HEALTHCHECK_URL = new URL(