feat: introduce initial Electron-based SPT Launcher with server, Git, mod, and profile management capabilities.
This commit is contained in:
parent
a5a5211e87
commit
0d9584e530
104
src/main/main.ts
104
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<ModSyncResult> => {
|
||||
const runModSync = async (
|
||||
payload: {
|
||||
targetDir: string;
|
||||
tag: string;
|
||||
cleanRepo?: boolean;
|
||||
},
|
||||
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);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
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 = () => {
|
|||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,6 +150,12 @@ interface Window {
|
|||
clearSession: () => Promise<{
|
||||
ok: boolean;
|
||||
}>;
|
||||
onModSyncProgress: (
|
||||
callback: (
|
||||
event: unknown,
|
||||
step: { name: string; result?: { ok: boolean; message?: string } }
|
||||
) => void
|
||||
) => () => void;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue