diff --git a/src/main/main.ts b/src/main/main.ts index c6501b2..9458011 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -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,29 +848,43 @@ const syncDirectories = async (repoDir: string, targetDir: string) => { } 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; + } else { + 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); } - 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: { - targetDir: string; - tag: string; - cleanRepo?: boolean; -}): Promise => { +const runModSync = async ( + payload: { + targetDir: string; + tag: string; + cleanRepo?: boolean; + }, + onProgress?: (step: InstallStep) => void, +): Promise => { 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); + }); }, ); diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 25306d5..7d2637b 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -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); + }; + }, }); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 391eb8f..905e992 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -100,6 +100,28 @@ const App = () => { const sessionResumeAttemptedRef = useRef(false); const transitionTimeoutRef = useRef(); const simulateToolsMissingRef = useRef(false); + const [syncSteps, setSyncSteps] = useState>([]); + 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) : undefined; @@ -871,18 +893,70 @@ const App = () => { throw new Error("simulated_sync_fail"); } - const result = await window.sptLauncher.runModSync({ - targetDir, - tag: targetTag - }); - if (!result.ok) { - setModCheckMessage(result.error ?? "모드 동기화에 실패했습니다."); - return false; - } + setSyncSteps([]); + setSyncResult(undefined); - await applyModSyncResult(result.tag ?? targetTag); - setModCheckMessage(undefined); - return true; + // 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 = () => { )} + + {(syncInProgress || syncResult) && ( +
+
e.stopPropagation()}> +
+

+ {syncResult + ? (syncResult.ok ? "모드 적용 완료" : "오류 발생") + : "모드 적용 중..."} +

+ {!syncInProgress && ( + + )} +
+
+
+ {syncSteps.map((step, idx) => { + const info = getStepDisplayInfo(step.name); + return ( +
+ {idx + 1}. +
+
{info.title}
+
{info.command}
+ {step.message &&
Error: {step.message}
} +
+ {step.ok ? "✅" : "❌"} +
+ ); + })} + {syncInProgress &&
+ + 작업 진행 중... +
} +
+ {syncResult && ( +
+ {syncResult.ok ? "모드 적용이 완료되었습니다." : `실패: ${syncResult.error}`} +
+ )} +
+ +
+
+ )} ); }; diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 286ff9b..ba42557 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -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); } +} diff --git a/src/renderer/vite-env.d.ts b/src/renderer/vite-env.d.ts index ebe6bad..1826183 100644 --- a/src/renderer/vite-env.d.ts +++ b/src/renderer/vite-env.d.ts @@ -150,6 +150,12 @@ interface Window { clearSession: () => Promise<{ ok: boolean; }>; + onModSyncProgress: ( + callback: ( + event: unknown, + step: { name: string; result?: { ok: boolean; message?: string } } + ) => void + ) => () => void; }; }