From b493637b8d9b56679722edf8f20d258d9e31e10a Mon Sep 17 00:00:00 2001 From: art Date: Fri, 30 Jan 2026 11:24:48 +0900 Subject: [PATCH] Add mod version management functionality. Introduced IPC handlers for retrieving and setting local mod versions, along with a synchronization process for managing mod updates. Enhanced the UI to display mod version status and integrated checks for version mismatches, improving user feedback during mod synchronization. --- src/main/main.ts | 383 +++++++++++++++++++++++++++++++++++++ src/preload/preload.ts | 5 + src/renderer/App.tsx | 269 ++++++++++++++++++++++++-- src/renderer/vite-env.d.ts | 23 +++ 4 files changed, 661 insertions(+), 19 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index cea776f..b702831 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -45,6 +45,23 @@ type CommandRunResult = { error?: string; }; +type ModVersionStatus = { + ok: boolean; + checkedAt: number; + currentTag?: string; + latestTag?: string; + latestMinorTags?: string[]; + status?: "upToDate" | "patchMismatch" | "majorMinorMismatch" | "unknown"; + error?: string; +}; + +type ModSyncResult = { + ok: boolean; + tag?: string; + error?: string; + steps?: Array<{ name: string; ok: boolean; message?: string }>; +}; + type InstallStep = { name: string; result: CommandRunResult; @@ -193,6 +210,352 @@ const getGitPaths = async () => { }; }; +const MOD_VERSION_FILE_NAME = "mod-version.json"; + +const getModVersionFilePath = () => { + return path.join(app.getPath("userData"), MOD_VERSION_FILE_NAME); +}; + +const readModVersionRecord = async () => { + const filePath = getModVersionFilePath(); + try { + const raw = await fs.readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as { currentTag?: string; updatedAt?: number }; + return { + currentTag: typeof parsed.currentTag === "string" ? parsed.currentTag : undefined, + updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : undefined + }; + } catch { + return { currentTag: undefined, updatedAt: undefined }; + } +}; + +const writeModVersionRecord = async (tag: string) => { + const filePath = getModVersionFilePath(); + const payload = { + currentTag: tag, + updatedAt: Date.now() + }; + await fs.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8"); + return payload; +}; + +const listRemoteTags = async (repoUrl: string) => { + const result = await runCommand("git", ["ls-remote", "--tags", "--refs", repoUrl], { + timeoutMs: INSTALL_TIMEOUT_MS + }); + if (!result.ok) { + throw new Error(result.error ?? result.output ?? "git ls-remote failed"); + } + return result.output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); +}; + +const parseVersionTag = (tag: string) => { + const match = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag.trim()); + if (!match) { + return undefined; + } + return { + tag, + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]) + }; +}; + +const resolveLatestMinorTags = (tags: string[]) => { + const parsed = tags + .map((tagLine) => { + const refMatch = tagLine.match(/refs\/tags\/(v\d+\.\d+\.\d+)$/); + return parseVersionTag(refMatch ? refMatch[1] : tagLine); + }) + .filter((item): item is { tag: string; major: number; minor: number; patch: number } => Boolean(item)); + + if (parsed.length === 0) { + return { latestMinorTags: [], latestTag: undefined }; + } + + const maxMajor = Math.max(...parsed.map((item) => item.major)); + const majorCandidates = parsed.filter((item) => item.major === maxMajor); + const maxMinor = Math.max(...majorCandidates.map((item) => item.minor)); + const latestMinorTags = majorCandidates + .filter((item) => item.minor === maxMinor) + .sort((a, b) => a.patch - b.patch) + .map((item) => item.tag); + + return { + latestMinorTags, + latestTag: latestMinorTags[latestMinorTags.length - 1] + }; +}; + +const getModVersionStatus = async (): Promise => { + try { + const tags = await listRemoteTags(APP_CONFIG.modRepoUrl); + const { latestMinorTags, latestTag } = resolveLatestMinorTags(tags); + if (!latestTag) { + return { + ok: false, + checkedAt: Date.now(), + error: "v*.*.* 태그(tag)를 찾지 못했습니다." + }; + } + + const record = await readModVersionRecord(); + const currentTag = record.currentTag; + const latestParsed = parseVersionTag(latestTag); + const currentParsed = currentTag ? parseVersionTag(currentTag) : undefined; + + let status: ModVersionStatus["status"] = "unknown"; + if (currentParsed && latestParsed) { + if (currentParsed.major !== latestParsed.major || currentParsed.minor !== latestParsed.minor) { + status = "majorMinorMismatch"; + } else if (currentParsed.patch !== latestParsed.patch) { + status = "patchMismatch"; + } else { + status = "upToDate"; + } + } + + return { + ok: true, + checkedAt: Date.now(), + currentTag, + latestTag, + latestMinorTags, + status + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + ok: false, + checkedAt: Date.now(), + error: message + }; + } +}; + +const MOD_SYNC_PLAN_FILE_NAME = "mod-sync.plan.json"; +const MOD_REPO_CACHE_DIR_NAME = "mod-repo-cache"; +const DEFAULT_PRESERVE_PREFIXES = ["user/profiles", "user/settings", "user/launcher"]; + +const getModRepoCacheDir = () => { + return path.join(app.getPath("userData"), MOD_REPO_CACHE_DIR_NAME); +}; + +const readModSyncPlan = async (repoDir: string) => { + const planPath = path.join(repoDir, MOD_SYNC_PLAN_FILE_NAME); + try { + const raw = await fs.readFile(planPath, "utf8"); + const parsed = JSON.parse(raw) as { + sourceRoot?: string; + directories?: Array<{ from: string; to: string }>; + preserve?: string[]; + }; + return parsed; + } catch { + return null; + } +}; + +const pathExists = async (targetPath: string) => { + try { + await fs.stat(targetPath); + return true; + } catch { + return false; + } +}; + +const normalizePath = (value: string) => value.split(path.sep).join("/"); + +const shouldPreserve = (relativePath: string, preservePrefixes: string[]) => { + const normalized = normalizePath(relativePath); + return preservePrefixes.some((prefix) => normalized === prefix || normalized.startsWith(`${prefix}/`)); +}; + +const copyDir = async ( + source: string, + destination: string, + preservePrefixes: string[], + baseDest: string +) => { + const entries = await fs.readdir(source, { withFileTypes: true }); + await fs.mkdir(destination, { recursive: true }); + + for (const entry of entries) { + const sourcePath = path.join(source, entry.name); + const destPath = path.join(destination, entry.name); + const destRelative = normalizePath(path.relative(baseDest, destPath)); + + if (shouldPreserve(destRelative, preservePrefixes)) { + continue; + } + + if (entry.isDirectory()) { + await copyDir(sourcePath, destPath, preservePrefixes, baseDest); + continue; + } + + if (entry.isSymbolicLink()) { + continue; + } + + await fs.copyFile(sourcePath, destPath); + } +}; + +const moveDir = async ( + source: string, + destination: string, + preservePrefixes: string[], + baseDest: string +) => { + const destExists = await pathExists(destination); + if (!destExists) { + await fs.mkdir(destination, { recursive: true }); + } + + await copyDir(source, destination, preservePrefixes, baseDest); +}; + +const resolveSourceRoot = async (repoDir: string, plan: { sourceRoot?: string } | null) => { + const planRoot = plan?.sourceRoot ? path.join(repoDir, plan.sourceRoot) : null; + if (planRoot && (await pathExists(planRoot))) { + return planRoot; + } + + const modsRoot = path.join(repoDir, "mods"); + if (await pathExists(modsRoot)) { + return modsRoot; + } + + return repoDir; +}; + +const ensureModRepo = async (repoDir: string, repoUrl: string, steps: ModSyncResult["steps"]) => { + const gitDir = path.join(repoDir, ".git"); + if (await pathExists(gitDir)) { + const fetchResult = await runCommand("git", ["fetch", "--tags", "--prune"], { + cwd: repoDir, + timeoutMs: INSTALL_TIMEOUT_MS + }); + steps?.push({ + name: "git fetch", + ok: fetchResult.ok, + message: fetchResult.ok ? undefined : fetchResult.error ?? fetchResult.output + }); + if (!fetchResult.ok) { + throw new Error(fetchResult.error ?? fetchResult.output ?? "git fetch failed"); + } + return; + } + + await fs.mkdir(repoDir, { recursive: true }); + const cloneResult = await runCommand("git", ["clone", repoUrl, repoDir], { + timeoutMs: INSTALL_TIMEOUT_MS + }); + steps?.push({ + name: "git clone", + ok: cloneResult.ok, + message: cloneResult.ok ? undefined : cloneResult.error ?? cloneResult.output + }); + if (!cloneResult.ok) { + throw new Error(cloneResult.error ?? cloneResult.output ?? "git clone failed"); + } +}; + +const checkoutRepoTag = async (repoDir: string, tag: string, steps: ModSyncResult["steps"]) => { + const checkoutResult = await runCommand("git", ["checkout", "-f", tag], { + cwd: repoDir, + timeoutMs: INSTALL_TIMEOUT_MS + }); + steps?.push({ + name: `git checkout ${tag}`, + ok: checkoutResult.ok, + message: checkoutResult.ok ? undefined : checkoutResult.error ?? checkoutResult.output + }); + if (!checkoutResult.ok) { + throw new Error(checkoutResult.error ?? checkoutResult.output ?? "git checkout failed"); + } +}; + +const pullLfs = async (repoDir: string, steps: ModSyncResult["steps"]) => { + const pullResult = await runCommand("git", ["lfs", "pull"], { + cwd: repoDir, + timeoutMs: INSTALL_TIMEOUT_MS + }); + steps?.push({ + name: "git lfs pull", + ok: pullResult.ok, + message: pullResult.ok ? undefined : pullResult.error ?? pullResult.output + }); + if (!pullResult.ok) { + throw new Error(pullResult.error ?? pullResult.output ?? "git lfs pull failed"); + } +}; + +const syncDirectories = async (repoDir: string, targetDir: string) => { + const plan = await readModSyncPlan(repoDir); + const preservePrefixes = plan?.preserve ?? DEFAULT_PRESERVE_PREFIXES; + const mapping = plan?.directories; + + if (mapping && mapping.length > 0) { + for (const entry of mapping) { + const sourcePath = path.join(repoDir, entry.from); + const destPath = path.join(targetDir, entry.to); + if (!(await pathExists(sourcePath))) { + continue; + } + await moveDir(sourcePath, destPath, preservePrefixes, targetDir); + } + return; + } + + const sourceRoot = await resolveSourceRoot(repoDir, plan); + const entries = await fs.readdir(sourceRoot, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + if (entry.name.startsWith(".")) { + continue; + } + const sourcePath = path.join(sourceRoot, entry.name); + const destPath = path.join(targetDir, entry.name); + await moveDir(sourcePath, destPath, preservePrefixes, targetDir); + } +}; + +const runModSync = async (payload: { + targetDir: string; + tag: string; + cleanRepo?: boolean; +}): Promise => { + const steps: ModSyncResult["steps"] = []; + try { + const targetDir = payload.targetDir?.trim(); + if (!targetDir) { + return { ok: false, error: "missing_target_dir", steps }; + } + + const repoDir = getModRepoCacheDir(); + await ensureModRepo(repoDir, APP_CONFIG.modRepoUrl, steps); + await checkoutRepoTag(repoDir, payload.tag, steps); + await pullLfs(repoDir, steps); + await syncDirectories(repoDir, targetDir); + + if (payload.cleanRepo) { + await fs.rm(repoDir, { recursive: true, force: true }); + } + + return { ok: true, tag: payload.tag, steps }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, error: message, steps }; + } +}; + const installGitTools = async (): Promise => { if (process.platform !== "win32") { return { ok: false, error: "Windows에서만 자동 설치가 가능합니다.", steps: [] }; @@ -485,6 +848,26 @@ app.whenReady().then(() => { return await installGitTools(); }); + ipcMain.handle("spt:getModVersionStatus", async () => { + return await getModVersionStatus(); + }); + + ipcMain.handle("spt:setLocalModVersion", async (_event, payload: { tag: string }) => { + const tag = payload.tag.trim(); + if (!tag) { + return { ok: false, error: "empty_tag" }; + } + const record = await writeModVersionRecord(tag); + return { ok: true, record }; + }); + + ipcMain.handle( + "spt:runModSync", + async (_event, payload: { targetDir: string; tag: string; cleanRepo?: boolean }) => { + return await runModSync(payload); + } + ); + ipcMain.handle("spt:fetchProfile", async (_event, payload: { username: string }) => { const username = payload.username.trim(); diff --git a/src/preload/preload.ts b/src/preload/preload.ts index dea4d52..88f9144 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -8,6 +8,11 @@ contextBridge.exposeInMainWorld("sptLauncher", { checkGitTools: () => ipcRenderer.invoke("spt:checkGitTools"), getGitPaths: () => ipcRenderer.invoke("spt:getGitPaths"), installGitTools: () => ipcRenderer.invoke("spt:installGitTools"), + getModVersionStatus: () => ipcRenderer.invoke("spt:getModVersionStatus"), + setLocalModVersion: (payload: { tag: string }) => + ipcRenderer.invoke("spt:setLocalModVersion", payload), + runModSync: (payload: { targetDir: string; tag: string; cleanRepo?: boolean }) => + ipcRenderer.invoke("spt:runModSync", payload), 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 44488f6..6d5d306 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -24,11 +24,21 @@ type GitPathsCheck = { lfs: CommandPath; checkedAt: number; }; +type ModVersionStatus = { + ok: boolean; + checkedAt: number; + currentTag?: string; + latestTag?: string; + latestMinorTags?: string[]; + status?: "upToDate" | "patchMismatch" | "majorMinorMismatch" | "unknown"; + 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 App = () => { const [screen, setScreen] = useState("serverCheck"); @@ -66,9 +76,14 @@ const App = () => { const [profileActionInProgress, setProfileActionInProgress] = useState(false); const [syncInProgress, setSyncInProgress] = useState(false); const [simulateSyncFail, setSimulateSyncFail] = useState(false); + const [modCheckInProgress, setModCheckInProgress] = useState(false); + const [modVersionStatus, setModVersionStatus] = useState(); + const [modCheckMessage, setModCheckMessage] = useState(); const healthCheckInFlightRef = useRef(false); const gitCheckInFlightRef = useRef(false); const gitCheckedRef = useRef(false); + const modCheckInFlightRef = useRef(false); + const modCheckedRef = useRef(false); const transitionTimeoutRef = useRef(); const simulateToolsMissingRef = useRef(false); @@ -123,6 +138,48 @@ const App = () => { } }, [gitCheckResult]); + const runModVersionCheck = useCallback(async (force = false) => { + if (force) { + modCheckedRef.current = false; + } + + if (modCheckInFlightRef.current || (!force && modCheckedRef.current)) { + return modVersionStatus; + } + + modCheckInFlightRef.current = true; + setModCheckInProgress(true); + setModCheckMessage(undefined); + + try { + if (!window.sptLauncher?.getModVersionStatus) { + throw new Error("preload(preload) 미로딩: window.sptLauncher.getModVersionStatus가 없습니다."); + } + + const result = await window.sptLauncher.getModVersionStatus(); + setModVersionStatus(result); + modCheckedRef.current = true; + if (!result.ok) { + setModCheckMessage(result.error ?? "모드 버전 확인에 실패했습니다."); + } + return result; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const fallback: ModVersionStatus = { + ok: false, + checkedAt: Date.now(), + error: message + }; + setModVersionStatus(fallback); + setModCheckMessage(message); + modCheckedRef.current = true; + return fallback; + } finally { + modCheckInFlightRef.current = false; + setModCheckInProgress(false); + } + }, [modVersionStatus]); + const clearTransitionTimeout = useCallback(() => { if (transitionTimeoutRef.current) { window.clearTimeout(transitionTimeoutRef.current); @@ -297,10 +354,49 @@ const App = () => { return () => clearTransitionTimeout(); }, [clearTransitionTimeout]); + useEffect(() => { + if (!hasSession || screen !== "main") { + return; + } + void runModVersionCheck(); + }, [hasSession, screen, runModVersionCheck]); + const serverStatusLabel = useMemo(() => { return serverHealthy ? "정상" : "불가"; }, [serverHealthy]); + const modStatusLabel = useMemo(() => { + if (modCheckInProgress) { + return "확인 중"; + } + if (!modVersionStatus?.ok) { + return "확인 실패"; + } + switch (modVersionStatus.status) { + case "upToDate": + return "최신"; + case "patchMismatch": + return "패치 업데이트 필요"; + case "majorMinorMismatch": + return "강제 동기화 필요"; + default: + return "알 수 없음"; + } + }, [modCheckInProgress, modVersionStatus]); + + const modStatusClass = (() => { + if (modCheckInProgress) { + return "idle"; + } + if (!modVersionStatus?.ok) { + return "blocked"; + } + if (modVersionStatus.status === "upToDate") { + return "ok"; + } + return "blocked"; + })(); + const serverStatusLedClass = serverCheckInProgress ? "idle" : serverHealthy ? "ok" : "blocked"; const gitStatusLedClass = (() => { if (gitCheckInProgress) { @@ -493,6 +589,9 @@ const App = () => { setHasSession(false); setProfile(undefined); setScreen("login"); + setModVersionStatus(undefined); + setModCheckMessage(undefined); + modCheckedRef.current = false; }; const handleProfileDownload = async () => { @@ -574,29 +673,104 @@ const App = () => { } }; - const handleLaunch = () => { + const updateLocalModVersion = async (tag: string) => { + if (!window.sptLauncher?.setLocalModVersion) { + return false; + } + const result = await window.sptLauncher.setLocalModVersion({ tag }); + if (!result.ok) { + return false; + } + return true; + }; + + const applyModSyncResult = async (tag?: string) => { + if (!tag) { + return; + } + const updated = await updateLocalModVersion(tag); + if (!updated) { + return; + } + setModVersionStatus((prev) => { + if (!prev) { + return prev; + } + return { + ...prev, + currentTag: tag, + status: prev.latestTag === tag ? "upToDate" : prev.status + }; + }); + }; + + const performModSync = async (mode: "required" | "optional" | "force") => { + if (syncInProgress) { + return false; + } + + setSyncInProgress(true); + const targetTag = + mode === "force" + ? modVersionStatus?.currentTag ?? modVersionStatus?.latestTag + : modVersionStatus?.latestTag; + + try { + if (!MOD_SYNC_TARGET_DIR) { + window.alert("SPT 경로(path)가 설정되지 않았습니다."); + return false; + } + if (!targetTag) { + setModCheckMessage("동기화할 태그(tag)를 찾지 못했습니다."); + return false; + } + if (!window.sptLauncher?.runModSync) { + window.alert("동기화 기능이 준비되지 않았습니다."); + return false; + } + + if (simulateSyncFail) { + throw new Error("simulated_sync_fail"); + } + + const result = await window.sptLauncher.runModSync({ + targetDir: MOD_SYNC_TARGET_DIR, + tag: targetTag + }); + if (!result.ok) { + setModCheckMessage(result.error ?? "모드 동기화에 실패했습니다."); + return false; + } + + await applyModSyncResult(result.tag ?? targetTag); + setModCheckMessage(undefined); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const proceed = window.confirm( + `모드 동기화가 실패했습니다. 그래도 실행할까요? (${message})` + ); + return proceed; + } finally { + setSyncInProgress(false); + } + }; + + const handleLaunch = async () => { + const status = modVersionStatus?.status; + if (status === "majorMinorMismatch") { + window.alert("모드 메이저/마이너 버전이 다릅니다. 동기화 후 실행됩니다."); + const ok = await performModSync("required"); + if (!ok) { + return; + } + } + if (syncInProgress) { return; } - setSyncInProgress(true); - - window.setTimeout(() => { - setSyncInProgress(false); - - if (!simulateSyncFail) { - window.alert("모드 동기화 성공. 게임을 실행합니다."); - return; - } - - const proceed = window.confirm( - "모드 동기화가 실패했습니다. 그래도 실행할까요?" - ); - - if (proceed) { - window.alert("실패 상태로 게임을 실행합니다."); - } - }, 1000); + window.alert("게임을 실행합니다."); }; return ( @@ -814,6 +988,63 @@ const App = () => { +
+

모드 버전

+

{modStatusLabel}

+
+
현재 버전: {modVersionStatus?.currentTag ?? "-"}
+
최신 버전: {modVersionStatus?.latestTag ?? "-"}
+
+ {modVersionStatus?.latestMinorTags && modVersionStatus.latestMinorTags.length > 0 && ( +
+ 최신 마이너 버전 패치 목록 +
{modVersionStatus.latestMinorTags.join(", ")}
+
+ )} + {modVersionStatus?.status === "majorMinorMismatch" && ( +
+ 강제 동기화 필요 +
+ 메이저/마이너 버전이 다릅니다. 게임 시작 시 자동 동기화 후 실행됩니다. +
+
+ )} + {modVersionStatus?.status === "patchMismatch" && ( +
+ 새 패치 안내 +
+ 새 패치 버전이 있습니다. 동기화 버튼을 눌러 업데이트할 수 있습니다. +
+
+ )} + {modCheckMessage &&
{modCheckMessage}
} +
+ + + +
+
+

게임 시작

준비됨

diff --git a/src/renderer/vite-env.d.ts b/src/renderer/vite-env.d.ts index ab11615..cd866b2 100644 --- a/src/renderer/vite-env.d.ts +++ b/src/renderer/vite-env.d.ts @@ -69,6 +69,29 @@ interface Window { checkedAt: number; }; }>; + getModVersionStatus: () => Promise<{ + ok: boolean; + checkedAt: number; + currentTag?: string; + latestTag?: string; + latestMinorTags?: string[]; + status?: "upToDate" | "patchMismatch" | "majorMinorMismatch" | "unknown"; + error?: string; + }>; + setLocalModVersion: (payload: { tag: string }) => Promise<{ + ok: boolean; + record?: { + currentTag?: string; + updatedAt?: number; + }; + error?: string; + }>; + runModSync: (payload: { targetDir: string; tag: string; cleanRepo?: boolean }) => Promise<{ + ok: boolean; + tag?: string; + error?: string; + steps?: Array<{ name: string; ok: boolean; message?: string }>; + }>; fetchProfile: (payload: { username: string }) => Promise<{ ok: boolean; status?: number;