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, repoDir: string,
repoUrl: string, repoUrl: string,
steps: ModSyncResult["steps"], steps: ModSyncResult["steps"],
onProgress?: (step: InstallStep) => void,
) => { ) => {
const gitDir = path.join(repoDir, ".git"); const gitDir = path.join(repoDir, ".git");
if (await pathExists(gitDir)) { if (await pathExists(gitDir)) {
@ -738,6 +739,10 @@ const ensureModRepo = async (
fetchResult.error ?? fetchResult.output ?? "git fetch failed", fetchResult.error ?? fetchResult.output ?? "git fetch failed",
); );
} }
onProgress?.({
name: "git fetch",
result: fetchResult,
});
return; return;
} }
@ -757,12 +762,17 @@ const ensureModRepo = async (
cloneResult.error ?? cloneResult.output ?? "git clone failed", cloneResult.error ?? cloneResult.output ?? "git clone failed",
); );
} }
onProgress?.({
name: "git clone",
result: cloneResult,
});
}; };
const checkoutRepoTag = async ( const checkoutRepoTag = async (
repoDir: string, repoDir: string,
tag: string, tag: string,
steps: ModSyncResult["steps"], steps: ModSyncResult["steps"],
onProgress?: (step: InstallStep) => void,
) => { ) => {
const checkoutResult = await runCommand("git", ["checkout", "-f", tag], { const checkoutResult = await runCommand("git", ["checkout", "-f", tag], {
cwd: repoDir, cwd: repoDir,
@ -780,9 +790,17 @@ const checkoutRepoTag = async (
checkoutResult.error ?? checkoutResult.output ?? "git checkout failed", 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"], { const pullResult = await runCommand("git", ["lfs", "pull"], {
cwd: repoDir, cwd: repoDir,
timeoutMs: INSTALL_TIMEOUT_MS, timeoutMs: INSTALL_TIMEOUT_MS,
@ -799,13 +817,28 @@ const pullLfs = async (repoDir: string, steps: ModSyncResult["steps"]) => {
pullResult.error ?? pullResult.output ?? "git lfs pull failed", 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 plan = await readModSyncPlan(repoDir);
const preservePrefixes = plan?.preserve ?? DEFAULT_PRESERVE_PREFIXES; const preservePrefixes = plan?.preserve ?? DEFAULT_PRESERVE_PREFIXES;
const mapping = plan?.directories; 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) { if (mapping && mapping.length > 0) {
for (const entry of mapping) { for (const entry of mapping) {
const sourcePath = path.join(repoDir, entry.from); const sourcePath = path.join(repoDir, entry.from);
@ -815,29 +848,43 @@ const syncDirectories = async (repoDir: string, targetDir: string) => {
} }
await moveDir(sourcePath, destPath, preservePrefixes, targetDir); await moveDir(sourcePath, destPath, preservePrefixes, targetDir);
} }
return; } else {
} const sourceRoot = await resolveSourceRoot(repoDir, plan);
const entries = await fs.readdir(sourceRoot, { withFileTypes: true });
const sourceRoot = await resolveSourceRoot(repoDir, plan); for (const entry of entries) {
const entries = await fs.readdir(sourceRoot, { withFileTypes: true }); if (!entry.isDirectory()) {
for (const entry of entries) { continue;
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);
} }
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);
} }
// 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 (
targetDir: string; payload: {
tag: string; targetDir: string;
cleanRepo?: boolean; tag: string;
}): Promise<ModSyncResult> => { cleanRepo?: boolean;
},
onProgress?: (step: InstallStep) => void,
): Promise<ModSyncResult> => {
const steps: ModSyncResult["steps"] = []; const steps: ModSyncResult["steps"] = [];
try { try {
const targetDir = payload.targetDir?.trim(); const targetDir = payload.targetDir?.trim();
@ -846,10 +893,11 @@ const runModSync = async (payload: {
} }
const repoDir = getModRepoCacheDir(); const repoDir = getModRepoCacheDir();
await ensureModRepo(repoDir, APP_CONFIG.modRepoUrl, steps); await ensureModRepo(repoDir, APP_CONFIG.modRepoUrl, steps, onProgress);
await checkoutRepoTag(repoDir, payload.tag, steps); await checkoutRepoTag(repoDir, payload.tag, steps, onProgress);
await pullLfs(repoDir, steps); await pullLfs(repoDir, steps, onProgress);
await syncDirectories(repoDir, targetDir);
await syncDirectories(repoDir, targetDir, steps, onProgress);
if (payload.cleanRepo) { if (payload.cleanRepo) {
await fs.rm(repoDir, { recursive: true, force: true }); await fs.rm(repoDir, { recursive: true, force: true });
@ -1229,10 +1277,12 @@ app.whenReady().then(() => {
ipcMain.handle( ipcMain.handle(
"spt:runModSync", "spt:runModSync",
async ( async (
_event, event,
payload: { targetDir: string; tag: string; cleanRepo?: boolean }, 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 }) => resetProfile: (payload: { username: string }) =>
ipcRenderer.invoke("spt:resetProfile", payload), ipcRenderer.invoke("spt:resetProfile", payload),
resumeSession: () => ipcRenderer.invoke("spt:resumeSession"), 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 sessionResumeAttemptedRef = useRef(false);
const transitionTimeoutRef = useRef<number | undefined>(); const transitionTimeoutRef = useRef<number | undefined>();
const simulateToolsMissingRef = useRef(false); 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) => const asObject = (value: unknown) =>
value && typeof value === "object" ? (value as Record<string, unknown>) : undefined; value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
@ -871,18 +893,70 @@ const App = () => {
throw new Error("simulated_sync_fail"); throw new Error("simulated_sync_fail");
} }
const result = await window.sptLauncher.runModSync({ setSyncSteps([]);
targetDir, setSyncResult(undefined);
tag: targetTag
});
if (!result.ok) {
setModCheckMessage(result.error ?? "모드 동기화에 실패했습니다.");
return false;
}
await applyModSyncResult(result.tag ?? targetTag); // Subscribe to progress events
setModCheckMessage(undefined); const cleanup = window.sptLauncher.onModSyncProgress
return true; ? 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) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
const proceed = window.confirm( const proceed = window.confirm(
@ -891,9 +965,18 @@ const App = () => {
return proceed; return proceed;
} finally { } finally {
setSyncInProgress(false); 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 handleLaunch = async () => {
const status = modVersionStatus?.status; const status = modVersionStatus?.status;
if (status === "majorMinorMismatch") { if (status === "majorMinorMismatch") {
@ -1323,6 +1406,53 @@ const App = () => {
</section> </section>
</div> </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> </div>
); );
}; };

View File

@ -591,6 +591,19 @@ button.ghost:hover:not(:disabled) {
flex-direction: column; flex-direction: column;
gap: 20px; gap: 20px;
animation: scaleIn 0.2s ease-out; 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 { .modal-header {
@ -648,3 +661,92 @@ button.ghost:hover:not(:disabled) {
display: block; display: block;
margin-bottom: 4px; 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<{ clearSession: () => Promise<{
ok: boolean; ok: boolean;
}>; }>;
onModSyncProgress: (
callback: (
event: unknown,
step: { name: string; result?: { ok: boolean; message?: string } }
) => void
) => () => void;
}; };
} }