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(