feat: introduce initial Electron-based SPT Launcher with server, Git, mod, and profile management capabilities.

This commit is contained in:
이정수 2026-01-31 03:36:03 +09:00
parent a5a5211e87
commit 0d9584e530
5 changed files with 335 additions and 39 deletions

View File

@ -715,6 +715,7 @@ const ensureModRepo = async (
repoDir: string,
repoUrl: string,
steps: ModSyncResult["steps"],
onProgress?: (step: InstallStep) => void,
) => {
const gitDir = path.join(repoDir, ".git");
if (await pathExists(gitDir)) {
@ -738,6 +739,10 @@ const ensureModRepo = async (
fetchResult.error ?? fetchResult.output ?? "git fetch failed",
);
}
onProgress?.({
name: "git fetch",
result: fetchResult,
});
return;
}
@ -757,12 +762,17 @@ const ensureModRepo = async (
cloneResult.error ?? cloneResult.output ?? "git clone failed",
);
}
onProgress?.({
name: "git clone",
result: cloneResult,
});
};
const checkoutRepoTag = async (
repoDir: string,
tag: string,
steps: ModSyncResult["steps"],
onProgress?: (step: InstallStep) => void,
) => {
const checkoutResult = await runCommand("git", ["checkout", "-f", tag], {
cwd: repoDir,
@ -780,9 +790,17 @@ const checkoutRepoTag = async (
checkoutResult.error ?? checkoutResult.output ?? "git checkout failed",
);
}
onProgress?.({
name: `git checkout ${tag}`,
result: checkoutResult,
});
};
const pullLfs = async (repoDir: string, steps: ModSyncResult["steps"]) => {
const pullLfs = async (
repoDir: string,
steps: ModSyncResult["steps"],
onProgress?: (step: InstallStep) => void,
) => {
const pullResult = await runCommand("git", ["lfs", "pull"], {
cwd: repoDir,
timeoutMs: INSTALL_TIMEOUT_MS,
@ -799,13 +817,28 @@ const pullLfs = async (repoDir: string, steps: ModSyncResult["steps"]) => {
pullResult.error ?? pullResult.output ?? "git lfs pull failed",
);
}
onProgress?.({
name: "git lfs pull",
result: pullResult,
});
};
const syncDirectories = async (repoDir: string, targetDir: string) => {
const syncDirectories = async (
repoDir: string,
targetDir: string,
steps: ModSyncResult["steps"],
onProgress?: (step: InstallStep) => void,
) => {
const plan = await readModSyncPlan(repoDir);
const preservePrefixes = plan?.preserve ?? DEFAULT_PRESERVE_PREFIXES;
const mapping = plan?.directories;
// Initial progress event for starting file copy
onProgress?.({
name: "copying files",
result: { ok: true, command: "copy", args: [] }, // Intermediate state
});
if (mapping && mapping.length > 0) {
for (const entry of mapping) {
const sourcePath = path.join(repoDir, entry.from);
@ -815,9 +848,7 @@ const syncDirectories = async (repoDir: string, targetDir: string) => {
}
await moveDir(sourcePath, destPath, preservePrefixes, targetDir);
}
return;
}
} else {
const sourceRoot = await resolveSourceRoot(repoDir, plan);
const entries = await fs.readdir(sourceRoot, { withFileTypes: true });
for (const entry of entries) {
@ -831,13 +862,29 @@ const syncDirectories = async (repoDir: string, targetDir: string) => {
const destPath = path.join(targetDir, entry.name);
await moveDir(sourcePath, destPath, preservePrefixes, targetDir);
}
}
// Final progress event for completion
const result = { ok: true, command: "copy", args: [] };
steps?.push({
name: "copying files",
ok: true,
message: undefined
});
onProgress?.({
name: "copying files",
result: { ok: true, command: "copy", args: [] },
});
};
const runModSync = async (payload: {
const runModSync = async (
payload: {
targetDir: string;
tag: string;
cleanRepo?: boolean;
}): Promise<ModSyncResult> => {
},
onProgress?: (step: InstallStep) => void,
): Promise<ModSyncResult> => {
const steps: ModSyncResult["steps"] = [];
try {
const targetDir = payload.targetDir?.trim();
@ -846,10 +893,11 @@ const runModSync = async (payload: {
}
const repoDir = getModRepoCacheDir();
await ensureModRepo(repoDir, APP_CONFIG.modRepoUrl, steps);
await checkoutRepoTag(repoDir, payload.tag, steps);
await pullLfs(repoDir, steps);
await syncDirectories(repoDir, targetDir);
await ensureModRepo(repoDir, APP_CONFIG.modRepoUrl, steps, onProgress);
await checkoutRepoTag(repoDir, payload.tag, steps, onProgress);
await pullLfs(repoDir, steps, onProgress);
await syncDirectories(repoDir, targetDir, steps, onProgress);
if (payload.cleanRepo) {
await fs.rm(repoDir, { recursive: true, force: true });
@ -1229,10 +1277,12 @@ app.whenReady().then(() => {
ipcMain.handle(
"spt:runModSync",
async (
_event,
event,
payload: { targetDir: string; tag: string; cleanRepo?: boolean },
) => {
return await runModSync(payload);
return await runModSync(payload, (step) => {
event.sender.send("spt:modSyncProgress", step);
});
},
);

View File

@ -24,5 +24,13 @@ contextBridge.exposeInMainWorld("sptLauncher", {
resetProfile: (payload: { username: string }) =>
ipcRenderer.invoke("spt:resetProfile", payload),
resumeSession: () => ipcRenderer.invoke("spt:resumeSession"),
clearSession: () => ipcRenderer.invoke("spt:clearSession")
clearSession: () => ipcRenderer.invoke("spt:clearSession"),
onModSyncProgress: (callback: (event: unknown, step: unknown) => void) => {
const subscription = (_event: unknown, step: unknown) =>
callback(_event, step);
ipcRenderer.on("spt:modSyncProgress", subscription);
return () => {
ipcRenderer.removeListener("spt:modSyncProgress", subscription);
};
},
});

View File

@ -100,6 +100,28 @@ const App = () => {
const sessionResumeAttemptedRef = useRef(false);
const transitionTimeoutRef = useRef<number | undefined>();
const simulateToolsMissingRef = useRef(false);
const [syncSteps, setSyncSteps] = useState<Array<{ name: string; ok: boolean; message?: string }>>([]);
const [syncResult, setSyncResult] = useState<{ ok: boolean; error?: string } | undefined>();
const getStepDisplayInfo = (rawName: string) => {
if (rawName.startsWith("git fetch")) {
return { title: "저장소 정보 업데이트", command: rawName };
}
if (rawName.startsWith("git clone")) {
return { title: "모드 저장소 다운로드", command: rawName };
}
if (rawName.startsWith("git checkout")) {
const tag = rawName.replace("git checkout", "").trim();
return { title: `버전 ${tag} 설정`, command: rawName };
}
if (rawName.startsWith("git lfs pull")) {
return { title: "대용량 파일(LFS) 다운로드", command: rawName };
}
if (rawName === "copying files") {
return { title: "모드 파일 적용", command: "Copying files to Game Directory..." };
}
return { title: rawName, command: "" };
};
const asObject = (value: unknown) =>
value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
@ -871,18 +893,70 @@ const App = () => {
throw new Error("simulated_sync_fail");
}
setSyncSteps([]);
setSyncResult(undefined);
// Subscribe to progress events
const cleanup = window.sptLauncher.onModSyncProgress
? window.sptLauncher.onModSyncProgress((_event, step) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const s = step as any;
setSyncSteps((prev) => {
// Update existing step if name matches, or add new
// Ideally steps come in order. Let's just append or update last?
// The event sends "completed" steps usually?
// My main implementation sends: { name, result: { ok... } }
// I'll map it to simplified UI step
const uiStep = {
name: s.name,
ok: s.result?.ok ?? false,
message: s.result?.message ?? s.result?.error
};
return [...prev, uiStep];
});
})
: () => {};
try {
const result = await window.sptLauncher.runModSync({
targetDir,
tag: targetTag
});
cleanup(); // Unsubscribe
if (!result.ok) {
setSyncResult({ ok: false, error: result.error ?? "모드 동기화에 실패했습니다." });
// If steps are returned in result (history), use them?
// The real-time listener should have captured them, but let's ensure we have full list
if (result.steps) {
setSyncSteps(result.steps.map(s => ({
name: s.name,
ok: s.ok,
message: s.message
})));
}
setModCheckMessage(result.error ?? "모드 동기화에 실패했습니다.");
return false;
}
setSyncResult({ ok: true });
// Ensure steps from result are shown (more reliable final state)
if (result.steps) {
setSyncSteps(result.steps.map(s => ({
name: s.name,
ok: s.ok,
message: s.message
})));
}
await applyModSyncResult(result.tag ?? targetTag);
setModCheckMessage(undefined);
return true;
} catch (err) {
cleanup();
throw err;
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const proceed = window.confirm(
@ -891,9 +965,18 @@ const App = () => {
return proceed;
} finally {
setSyncInProgress(false);
// Removed setSyncResult(undefined) here to keep modal open until user closes
}
};
const closeSyncModal = () => {
if (syncInProgress) {
return; // Cannot close while running
}
setSyncResult(undefined);
setSyncSteps([]);
};
const handleLaunch = async () => {
const status = modVersionStatus?.status;
if (status === "majorMinorMismatch") {
@ -1323,6 +1406,53 @@ const App = () => {
</section>
</div>
)}
{(syncInProgress || syncResult) && (
<div className="modal-overlay" role="presentation">
<section className="modal" onClick={(e) => e.stopPropagation()}>
<header className="modal-header">
<h3>
{syncResult
? (syncResult.ok ? "모드 적용 완료" : "오류 발생")
: "모드 적용 중..."}
</h3>
{!syncInProgress && (
<button type="button" className="ghost" onClick={closeSyncModal}>
</button>
)}
</header>
<div className="modal-body">
<div className="sync-steps">
{syncSteps.map((step, idx) => {
const info = getStepDisplayInfo(step.name);
return (
<div key={idx} className={`sync-step ${step.ok ? "ok" : "fail"}`}>
<span className="step-number">{idx + 1}.</span>
<div className="step-content">
<div className="step-title">{info.title}</div>
<div className="step-command">{info.command}</div>
{step.message && <div className="step-msg">Error: {step.message}</div>}
</div>
<span className="step-icon">{step.ok ? "✅" : "❌"}</span>
</div>
);
})}
{syncInProgress && <div className="spinner-row">
<span className="spinner"></span>
<span> ...</span>
</div>}
</div>
{syncResult && (
<div className={`notice ${syncResult.ok ? "info" : "error"}`}>
{syncResult.ok ? "모드 적용이 완료되었습니다." : `실패: ${syncResult.error}`}
</div>
)}
</div>
</section>
</div>
)}
</div>
);
};

View File

@ -591,6 +591,19 @@ button.ghost:hover:not(:disabled) {
flex-direction: column;
gap: 20px;
animation: scaleIn 0.2s ease-out;
max-height: 85vh;
overflow: hidden; /* Header stays, body scrolls */
}
.modal-body {
display: flex;
flex-direction: column;
gap: 20px;
overflow-y: auto;
min-height: 0;
flex: 1;
padding-right: 4px;
padding-bottom: 20px; /* Ensure bottom content is visible */
}
.modal-header {
@ -648,3 +661,92 @@ button.ghost:hover:not(:disabled) {
display: block;
margin-bottom: 4px;
}
/* Sync Modal Steps */
.sync-steps {
display: flex;
flex-direction: column;
gap: 8px;
background: rgba(0, 0, 0, 0.2);
border-radius: var(--radius-sm);
padding: 8px;
}
.sync-step {
display: flex;
align-items: flex-start; /* Align to top for multi-line */
gap: 12px;
font-size: 0.9rem;
padding: 10px;
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.03);
}
.sync-step.ok {
border-left: 3px solid var(--status-ok);
background: rgba(16, 185, 129, 0.05);
}
.sync-step.fail {
border-left: 3px solid var(--status-error);
background: rgba(239, 68, 68, 0.05);
}
.step-number {
font-family: monospace;
font-size: 0.9rem;
color: var(--text-muted);
opacity: 0.7;
padding-top: 2px;
}
.step-content {
display: flex;
flex-direction: column;
flex: 1;
gap: 2px;
}
.step-title {
font-weight: 500;
color: var(--text-main);
font-size: 0.95rem;
}
.step-command {
font-family: monospace;
font-size: 0.75rem;
color: var(--text-muted);
opacity: 0.6;
}
.step-msg {
font-size: 0.8rem;
color: var(--status-error);
margin-top: 4px;
}
.step-icon {
font-size: 1rem;
padding-top: 2px;
}
.spinner-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
font-weight: 500;
color: var(--text-muted);
justify-content: center;
}
.spinner {
animation: spin 1s linear infinite;
display: inline-block;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

View File

@ -150,6 +150,12 @@ interface Window {
clearSession: () => Promise<{
ok: boolean;
}>;
onModSyncProgress: (
callback: (
event: unknown,
step: { name: string; result?: { ok: boolean; message?: string } }
) => void
) => () => void;
};
}