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.
This commit is contained in:
parent
099ba63e33
commit
b493637b8d
383
src/main/main.ts
383
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<ModVersionStatus> => {
|
||||
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<ModSyncResult> => {
|
||||
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<InstallGitToolsResult> => {
|
||||
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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) =>
|
||||
|
|
|
|||
|
|
@ -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<Screen>("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<ModVersionStatus | undefined>();
|
||||
const [modCheckMessage, setModCheckMessage] = useState<string | undefined>();
|
||||
const healthCheckInFlightRef = useRef(false);
|
||||
const gitCheckInFlightRef = useRef(false);
|
||||
const gitCheckedRef = useRef(false);
|
||||
const modCheckInFlightRef = useRef(false);
|
||||
const modCheckedRef = useRef(false);
|
||||
const transitionTimeoutRef = useRef<number | undefined>();
|
||||
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 = () => {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card action">
|
||||
<h2>모드 버전</h2>
|
||||
<p className={`status ${modStatusClass}`}>{modStatusLabel}</p>
|
||||
<div className="notice">
|
||||
<div>현재 버전: {modVersionStatus?.currentTag ?? "-"}</div>
|
||||
<div>최신 버전: {modVersionStatus?.latestTag ?? "-"}</div>
|
||||
</div>
|
||||
{modVersionStatus?.latestMinorTags && modVersionStatus.latestMinorTags.length > 0 && (
|
||||
<div className="notice">
|
||||
<strong>최신 마이너 버전 패치 목록</strong>
|
||||
<div className="modal-help">{modVersionStatus.latestMinorTags.join(", ")}</div>
|
||||
</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>
|
||||
)}
|
||||
{modCheckMessage && <div className="notice">{modCheckMessage}</div>}
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => performModSync("optional")}
|
||||
disabled={syncInProgress || !modVersionStatus?.latestTag}
|
||||
>
|
||||
{syncInProgress ? "동기화 중..." : "동기화"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={() => performModSync("force")}
|
||||
disabled={syncInProgress}
|
||||
>
|
||||
강제 재동기화
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={() => runModVersionCheck(true)}
|
||||
disabled={modCheckInProgress}
|
||||
>
|
||||
{modCheckInProgress ? "확인 중..." : "버전 재확인"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card action">
|
||||
<h2>게임 시작</h2>
|
||||
<p className="status ok">준비됨</p>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue