From fee3c1912b1dfcf6b88d72691296f413183f7856 Mon Sep 17 00:00:00 2001 From: art Date: Wed, 25 Feb 2026 16:39:33 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D(Authentication)=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8(Login)=20=EB=B0=8F=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85(Signup)=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=EC=9D=84=20=EA=B5=AC=ED=98=84=ED=95=98=EA=B3=A0=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20UI,=20API,=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=96=88?= =?UTF-8?q?=EC=8A=B5=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/auth-flow.md | 33 + src/main/main.ts | 929 ++++++++++--------- src/preload/preload.ts | 6 +- src/renderer/App.tsx | 557 +++++++----- src/renderer/App.tsx.orig | 1734 ++++++++++++++++++++++++++++++++++++ src/renderer/App.tsx.patch | 10 + src/renderer/App.tsx.rej | 8 + src/renderer/vite-env.d.ts | 16 +- src/shared/config.ts | 2 +- test-auth-deflate.js | 88 ++ test-auth.js | 45 + test-signup-login.js | 54 ++ 12 files changed, 2806 insertions(+), 676 deletions(-) create mode 100644 docs/auth-flow.md create mode 100644 src/renderer/App.tsx.orig create mode 100644 src/renderer/App.tsx.patch create mode 100644 src/renderer/App.tsx.rej create mode 100644 test-auth-deflate.js create mode 100644 test-auth.js create mode 100644 test-signup-login.js diff --git a/docs/auth-flow.md b/docs/auth-flow.md new file mode 100644 index 0000000..a5e7892 --- /dev/null +++ b/docs/auth-flow.md @@ -0,0 +1,33 @@ +# Personal Authentication Mod Flow + +This document details the modifications and features integrated within `spt-launcher` and `server-csharp` to support the custom Personal Authentication Mod. + +## What is Personal Authentication? +Unlike the vanilla SPT launcher which assumes 1 user = 1 machine, Personal Authentication enables multiple isolated accounts. Users register and log in to uniquely generated session IDs via `AuthRouter.cs` hooking into `LauncherCallbacks`. + +## The `server-csharp` Modifications: +The server's default JSON payload parsing was too strict regarding unmapped JSON structures (like adding a `password` field). + +We addressed this by updating the base interface in `SPTarkov.Server.Core`: +```csharp +namespace SPTarkov.Server.Core.Models.Eft.Launcher; + +public record LoginRequestData : IRequestData +{ + [JsonPropertyName("username")] + public string? Username { get; set; } + + [JsonPropertyName("password")] + public string? Password { get; set; } +} +``` +Now, `LoginRequestData` natively accepts passwords, allowing Harmony Patches on `LauncherCallbacks.Login` and `LauncherCallbacks.Register` to validate against the database gracefully. + +## Custom Token Session vs Default Random Session Strings +Vanilla SPT creates a random GUID session variable that is used loosely. +With SSO: +1. `spt-launcher` initiates an HTTP POST `/launcher/profile/login` with `username` and `password`. +2. The Database (`DatabaseManager.cs`) verifies credentials against PostgreSQL passwords (hashed). +3. Returns a specific `SessionID` mapped directly to that User ID. +4. The launcher preserves this ID in cookies and local cache storage. +5. Sub-requests (Start Client, Fetch Match Profiles) utilize this single, constant Session ID instead of performing a secondary manual profile scan discovery via `/launcher/profiles`. diff --git a/src/main/main.ts b/src/main/main.ts index f0ab318..caa00bf 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -125,6 +125,11 @@ const checkServerHealth = async (): Promise => { const request = net.request({ method: "GET", url, + headers: { + "Accept-Encoding": "identity", + requestcompressed: "0", + responsecompressed: "0", + } }); const timeout = setTimeout(() => { @@ -1336,8 +1341,10 @@ const postText = async (path: string, body: unknown) => { const requestSessionId = async ( username: string, + password?: string, ): Promise => { - const loginResult = await postText("/launcher/profile/login", { username }); + // 1. Login to get the session token + const loginResult = await postText("/launcher/profile/login", { username, password }); if (!loginResult.ok) { return { ok: false, @@ -1347,10 +1354,17 @@ const requestSessionId = async ( } const sessionId = loginResult.data?.trim(); - if (!sessionId) { - return { ok: false, error: "empty_session", url: loginResult.url }; + + // Check if session ID is empty or explicitly "FAILED" + if (!sessionId || sessionId === "FAILED" || sessionId === "") { + return { + ok: false, + error: sessionId === "FAILED" ? "login_failed_credentials" : "empty_session", + url: loginResult.url + }; } + // With PersonalAuthMod, the sessionId is the unique token we need. return { ok: true, sessionId, url: loginResult.url }; }; @@ -1359,6 +1373,7 @@ const isSessionInvalidStatus = (status?: number) => const getSessionIdForUser = async ( username: string, + password?: string, ): Promise => { const cached = await readSessionRecord(); if (cached && cached.username === username && !isSessionExpired(cached)) { @@ -1378,7 +1393,7 @@ const getSessionIdForUser = async ( await clearSessionRecord(); } - const loginResult = await requestSessionId(username); + const loginResult = await requestSessionId(username, password); if (!loginResult.ok) { return loginResult; } @@ -1392,9 +1407,10 @@ const getSessionIdForUser = async ( }; }; -const registerProfile = async (username: string) => { - return await postJson("/launcher/profile/register", { +const registerProfile = async (username: string, password?: string) => { + return await postText("/launcher/profile/register", { username, + password, edition: "Standard", }); }; @@ -1420,294 +1436,309 @@ const createWindow = () => { } }; -app.whenReady().then(() => { - const expectedServerUrl = new URL(SERVER_BASE_URL); - const expectedServerOrigin = expectedServerUrl.origin; - const expectedServerHost = expectedServerUrl.hostname; - // `electron.net` 요청은 self-signed TLS에서 막히는 경우가 많아서, - // 특정 호스트(host)만 인증서 검증을 우회(bypass)합니다. - session.defaultSession.setCertificateVerifyProc((request, callback) => { - if (request.hostname === expectedServerHost) { - callback(0); - return; - } +const gotTheLock = app.requestSingleInstanceLock(); - callback(-3); +if (!gotTheLock) { + console.log("[spt-launcher] Another instance is already running. Quitting..."); + app.quit(); +} else { + app.on("second-instance", (event, commandLine, workingDirectory) => { + // Someone tried to run a second instance, we should focus our window. + const windows = BrowserWindow.getAllWindows(); + if (windows.length > 0) { + const mainWindow = windows[0]; + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } }); - app.on( - "certificate-error", - (event, _webContents, url, _error, _certificate, callback) => { - if (url.startsWith(expectedServerOrigin)) { - event.preventDefault(); - callback(true); + app.whenReady().then(() => { + const expectedServerUrl = new URL(SERVER_BASE_URL); + const expectedServerOrigin = expectedServerUrl.origin; + const expectedServerHost = expectedServerUrl.hostname; + // `electron.net` 요청은 self-signed TLS에서 막히는 경우가 많아서, + // 특정 호스트(host)만 인증서 검증을 우회(bypass)합니다. + session.defaultSession.setCertificateVerifyProc((request, callback) => { + if (request.hostname === expectedServerHost) { + callback(0); return; } - callback(false); - }, - ); - - ipcMain.handle("spt:checkServerHealth", async () => { - return await checkServerHealth(); - }); - - ipcMain.handle("spt:checkGitTools", async () => { - return await checkGitTools(); - }); - - ipcMain.handle("spt:getGitPaths", async () => { - return await getGitPaths(); - }); - - ipcMain.handle("spt:installGitTools", async (event) => { - return await installGitTools((step) => { - event.sender.send("spt:gitInstallProgress", step); + callback(-3); }); - }); - ipcMain.handle("spt:launchGame", async (event) => { - const mainWindow = BrowserWindow.fromWebContents(event.sender); - const installInfo = await resolveSptInstall(); - - if (!installInfo.path) { - return { ok: false, error: "no_spt_path" }; - } - - // Double check running processes - const running = await checkGameRunning(); - if (running.clientRunning) { - return { ok: false, error: "already_running", details: running.processes }; - } - - // Double check mod version (optional but safer) - const modStatus = await getModVersionStatus(); - if (!modStatus.ok) { - return { ok: false, error: "mod_verification_failed" }; - } - - // Launch Server if not running (Local only) - const serverUrl = new URL(SERVER_BASE_URL); - const isLocal = serverUrl.hostname === "127.0.0.1" || serverUrl.hostname === "localhost"; - - if (isLocal && !running.serverRunning) { - let serverExePath: string | undefined; - for (const exe of SERVER_PROCESS_NAMES) { - // Check direct path or SPT subdirectory - const direct = path.join(installInfo.path, exe); - if (await pathExists(direct)) { - serverExePath = direct; - break; + app.on( + "certificate-error", + (event, _webContents, url, _error, _certificate, callback) => { + if (url.startsWith(expectedServerOrigin)) { + event.preventDefault(); + callback(true); + return; } - const nested = path.join(installInfo.path, "SPT", exe); - if (await pathExists(nested)) { - serverExePath = nested; - break; + + callback(false); + }, + ); + + ipcMain.handle("spt:checkServerHealth", async () => { + return await checkServerHealth(); + }); + + ipcMain.handle("spt:checkGitTools", async () => { + return await checkGitTools(); + }); + + ipcMain.handle("spt:getGitPaths", async () => { + return await getGitPaths(); + }); + + ipcMain.handle("spt:installGitTools", async (event) => { + return await installGitTools((step) => { + event.sender.send("spt:gitInstallProgress", step); + }); + }); + + ipcMain.handle("spt:launchGame", async (event) => { + const mainWindow = BrowserWindow.fromWebContents(event.sender); + const installInfo = await resolveSptInstall(); + + if (!installInfo.path) { + return { ok: false, error: "no_spt_path" }; + } + + // Double check running processes + const running = await checkGameRunning(); + if (running.clientRunning) { + return { ok: false, error: "already_running", details: running.processes }; + } + + // Double check mod version (optional but safer) + const modStatus = await getModVersionStatus(); + if (!modStatus.ok) { + return { ok: false, error: "mod_verification_failed" }; + } + + // Launch Server if not running (Local only) + const serverUrl = new URL(SERVER_BASE_URL); + const isLocal = serverUrl.hostname === "127.0.0.1" || serverUrl.hostname === "localhost"; + + if (isLocal && !running.serverRunning) { + let serverExePath: string | undefined; + for (const exe of SERVER_PROCESS_NAMES) { + // Check direct path or SPT subdirectory + const direct = path.join(installInfo.path, exe); + if (await pathExists(direct)) { + serverExePath = direct; + break; + } + const nested = path.join(installInfo.path, "SPT", exe); + if (await pathExists(nested)) { + serverExePath = nested; + break; + } + } + + if (serverExePath) { + console.log(`[spt-launcher] Starting Server: ${serverExePath}`); + const serverBat = stripExe(serverExePath) + ".bat"; // Sometimes users use bat, but exe should work if arguments aren't complex + + // Prefer EXE for now. + const child = require("child_process").spawn(serverExePath, [], { + cwd: path.dirname(serverExePath), + detached: true, + stdio: "ignore" + }); + child.unref(); + + // Wait for server health + let attempts = 0; + const maxAttempts = 30; // 30 seconds approx + while (attempts < maxAttempts) { + const health = await checkServerHealth(); + if (health.ok) { + break; + } + await new Promise(r => setTimeout(r, 1000)); + attempts++; + } } } - if (serverExePath) { - console.log(`[spt-launcher] Starting Server: ${serverExePath}`); - const serverBat = stripExe(serverExePath) + ".bat"; // Sometimes users use bat, but exe should work if arguments aren't complex + // Launch Client + try { + // Prioritize EFT executable + const candidates = [ + "EscapeFromTarkov.exe", + "EscapeFromTarkov_BE.exe", + "SPT/EscapeFromTarkov.exe", + "SPT/EscapeFromTarkov_BE.exe" + ]; - // Prefer EXE for now. - const child = require("child_process").spawn(serverExePath, [], { - cwd: path.dirname(serverExePath), + let executablePath: string | undefined; + for (const exe of candidates) { + const fullPath = path.join(installInfo.path, exe); + if (await pathExists(fullPath)) { + executablePath = fullPath; + break; + } + } + + if (!executablePath) { + return { ok: false, error: "executable_not_found" }; + } + + // Get Session ID for args + const session = await readSessionRecord(); + if (!session || isSessionExpired(session)) { + return { ok: false, error: "session_required" }; + } + + // Build Args + const configArg = JSON.stringify({ + BackendUrl: SERVER_BASE_URL, + Version: "live" + }); + const args = [`-token=${session.sessionId}`, `-config=${configArg}`]; + console.log(`[spt-launcher] Launching: ${executablePath} with args`, args); + + // Sanitize Environment + // Strip Electron/Node specifics to prevent BepInEx/Plugin conflicts + const sanitizedEnv = { ...process.env }; + for (const key of Object.keys(sanitizedEnv)) { + if (key.startsWith("ELECTRON_") || key.startsWith("NODE_") || key === "VITE_DEV_SERVER_URL") { + delete sanitizedEnv[key]; + } + } + + // Spawn detached + const child = require("child_process").spawn(executablePath, args, { + cwd: path.dirname(executablePath), detached: true, - stdio: "ignore" + stdio: "ignore", + env: sanitizedEnv }); child.unref(); - // Wait for server health - let attempts = 0; - const maxAttempts = 30; // 30 seconds approx - while (attempts < maxAttempts) { - const health = await checkServerHealth(); - if (health.ok) { - break; + mainWindow?.minimize(); + return { ok: true, executedPath: executablePath }; + } catch (e: any) { + console.error("Launch failed", e); + return { ok: false, error: e.message }; + } + }); + + function stripExe(filename: string) { + return filename.replace(/\.exe$/i, ""); + } + + ipcMain.handle("spt:checkGameRunning", async () => { + return await checkGameRunning(); + }); + + 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, (step) => { + event.sender.send("spt:modSyncProgress", step); + }); + }, + ); + + ipcMain.handle("spt:getSptInstallInfo", async () => { + return await resolveSptInstall(); + }); + + ipcMain.handle( + "spt:setSptInstallPath", + async (_event, payload: { path: string; allowMissing?: boolean }) => { + let rawPath = payload.path?.trim(); + if (!rawPath) { + return { ok: false, error: "empty_path" }; + } + + if (process.platform === "win32" && !payload.allowMissing) { + const resolved = await findSptInstallPath(rawPath); + if (!resolved) { + return { ok: false, error: "invalid_spt_path" }; } - await new Promise(r => setTimeout(r, 1000)); - attempts++; + rawPath = resolved; } - } - } - // Launch Client - try { - // Prioritize EFT executable - const candidates = [ - "EscapeFromTarkov.exe", - "EscapeFromTarkov_BE.exe", - "SPT/EscapeFromTarkov.exe", - "SPT/EscapeFromTarkov_BE.exe" - ]; + const record = await writeSptPathRecord({ + path: rawPath, + allowMissing: payload.allowMissing, + }); + return { ok: true, record }; + }, + ); - let executablePath: string | undefined; - for (const exe of candidates) { - const fullPath = path.join(installInfo.path, exe); - if (await pathExists(fullPath)) { - executablePath = fullPath; - break; - } + ipcMain.handle("spt:pickSptInstallPath", async () => { + if (process.platform !== "win32") { + return { ok: false, error: "unsupported_platform" }; } - if (!executablePath) { - return { ok: false, error: "executable_not_found" }; - } - - // Get Session ID for args - const session = await readSessionRecord(); - if (!session || isSessionExpired(session)) { - return { ok: false, error: "session_required" }; - } - - // Build Args - const configArg = JSON.stringify({ - BackendUrl: SERVER_BASE_URL, - Version: "live" + const result = await dialog.showOpenDialog({ + title: "SPT 설치 폴더 선택", + properties: ["openDirectory"], }); - const args = [`-token=${session.sessionId}`, `-config=${configArg}`]; - console.log(`[spt-launcher] Launching: ${executablePath} with args`, args); - // Sanitize Environment - // Strip Electron/Node specifics to prevent BepInEx/Plugin conflicts - const sanitizedEnv = { ...process.env }; - for (const key of Object.keys(sanitizedEnv)) { - if (key.startsWith("ELECTRON_") || key.startsWith("NODE_") || key === "VITE_DEV_SERVER_URL") { - delete sanitizedEnv[key]; - } + if (result.canceled || result.filePaths.length === 0) { + return { ok: false, error: "cancelled" }; } - // Spawn detached - const child = require("child_process").spawn(executablePath, args, { - cwd: path.dirname(executablePath), - detached: true, - stdio: "ignore", - env: sanitizedEnv - }); - child.unref(); - - mainWindow?.minimize(); - return { ok: true, executedPath: executablePath }; - } catch (e: any) { - console.error("Launch failed", e); - return { ok: false, error: e.message }; - } - }); - - function stripExe(filename: string) { - return filename.replace(/\.exe$/i, ""); - } - - ipcMain.handle("spt:checkGameRunning", async () => { - return await checkGameRunning(); - }); - - 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, (step) => { - event.sender.send("spt:modSyncProgress", step); - }); - }, - ); - - ipcMain.handle("spt:getSptInstallInfo", async () => { - return await resolveSptInstall(); - }); - - ipcMain.handle( - "spt:setSptInstallPath", - async (_event, payload: { path: string; allowMissing?: boolean }) => { - let rawPath = payload.path?.trim(); - if (!rawPath) { - return { ok: false, error: "empty_path" }; - } - - if (process.platform === "win32" && !payload.allowMissing) { - const resolved = await findSptInstallPath(rawPath); - if (!resolved) { - return { ok: false, error: "invalid_spt_path" }; - } - rawPath = resolved; + const selected = result.filePaths[0]; + const resolved = await findSptInstallPath(selected); + if (!resolved) { + return { ok: false, error: "invalid_spt_path", path: selected }; } const record = await writeSptPathRecord({ - path: rawPath, - allowMissing: payload.allowMissing, + path: resolved, + allowMissing: false, }); return { ok: true, record }; - }, - ); - - ipcMain.handle("spt:pickSptInstallPath", async () => { - if (process.platform !== "win32") { - return { ok: false, error: "unsupported_platform" }; - } - - const result = await dialog.showOpenDialog({ - title: "SPT 설치 폴더 선택", - properties: ["openDirectory"], }); - if (result.canceled || result.filePaths.length === 0) { - return { ok: false, error: "cancelled" }; - } + ipcMain.handle( + "spt:register", + async (_event, payload: { username: string; password?: string }) => { + const username = payload.username.trim(); + const password = payload.password?.trim(); + return await registerProfile(username, password); + } + ); - const selected = result.filePaths[0]; - const resolved = await findSptInstallPath(selected); - if (!resolved) { - return { ok: false, error: "invalid_spt_path", path: selected }; - } + ipcMain.handle( + "spt:login", + async (_event, payload: { username: string; password?: string }) => { + const username = payload.username.trim(); + const password = payload.password?.trim(); - const record = await writeSptPathRecord({ - path: resolved, - allowMissing: false, - }); - return { ok: true, record }; - }); - - ipcMain.handle( - "spt:fetchProfile", - async (_event, payload: { username: string }) => { - const username = payload.username.trim(); - let activeSessionId: string | undefined; - - const fetchProfileInfo = async (): Promise<{ - ok: boolean; - status?: number; - data?: unknown; - error?: string; - url: string; - }> => { - const sessionResult = await getSessionIdForUser(username); + const sessionResult = await getSessionIdForUser(username, password); if (!sessionResult.ok) { - return { - ok: false, - error: sessionResult.error ?? "login_failed", - url: sessionResult.url, - }; + return sessionResult; } - activeSessionId = sessionResult.sessionId; + + await writeSessionRecord({ username, sessionId: sessionResult.sessionId }); return await postJson( "/launcher/profile/info", @@ -1716,191 +1747,225 @@ app.whenReady().then(() => { Cookie: `PHPSESSID=${sessionResult.sessionId}`, }, ); - }; - - let infoResult = await fetchProfileInfo(); - if (!infoResult.ok && isSessionInvalidStatus(infoResult.status)) { - const relogin = await requestSessionId(username); - if (!relogin.ok) { - return { - ok: false, - error: relogin.error ?? "login_failed", - url: relogin.url, - }; - } - await writeSessionRecord({ username, sessionId: relogin.sessionId }); - activeSessionId = relogin.sessionId; - infoResult = await postJson( - "/launcher/profile/info", - { username }, - { - Cookie: `PHPSESSID=${relogin.sessionId}`, - }, - ); } - if (infoResult.ok && infoResult.data) { - if (activeSessionId) { - await refreshSessionRecord({ username, sessionId: activeSessionId }); + ); + + ipcMain.handle( + "spt:fetchProfile", + async (_event, payload: { username: string; password?: string }) => { + const username = payload.username.trim(); + const password = payload.password?.trim(); + let activeSessionId: string | undefined; + + const fetchProfileInfo = async (): Promise<{ + ok: boolean; + status?: number; + data?: unknown; + error?: string; + url: string; + }> => { + const sessionResult = await getSessionIdForUser(username, password); + if (!sessionResult.ok) { + return { + ok: false, + error: sessionResult.error ?? "login_failed", + url: sessionResult.url, + }; + } + activeSessionId = sessionResult.sessionId; + + return await postJson( + "/launcher/profile/info", + { username }, + { + Cookie: `PHPSESSID=${sessionResult.sessionId}`, + }, + ); + }; + + let infoResult = await fetchProfileInfo(); + if (!infoResult.ok && isSessionInvalidStatus(infoResult.status)) { + const relogin = await requestSessionId(username, password); + if (!relogin.ok) { + return { + ok: false, + error: relogin.error ?? "login_failed", + url: relogin.url, + }; + } + await writeSessionRecord({ username, sessionId: relogin.sessionId }); + activeSessionId = relogin.sessionId; + infoResult = await postJson( + "/launcher/profile/info", + { username }, + { + Cookie: `PHPSESSID=${relogin.sessionId}`, + }, + ); } + if (infoResult.ok && infoResult.data) { + if (activeSessionId) { + await refreshSessionRecord({ username, sessionId: activeSessionId }); + } + return infoResult; + } + return infoResult; - } - - await registerProfile(username); - return await fetchProfileInfo(); - }, - ); - - ipcMain.handle( - "spt:downloadProfile", - async (_event, payload: { username: string }) => { - const username = payload.username.trim(); - - const sessionResult = await getSessionIdForUser(username); - if (!sessionResult.ok) { - return sessionResult; - } - - let activeSessionId = sessionResult.sessionId; - let infoResult = await postJson( - "/launcher/profile/info", - { username }, - { - Cookie: `PHPSESSID=${sessionResult.sessionId}`, - }, - ); - - if (!infoResult.ok && isSessionInvalidStatus(infoResult.status)) { - const relogin = await requestSessionId(username); - if (!relogin.ok) { - return relogin; - } - await writeSessionRecord({ username, sessionId: relogin.sessionId }); - activeSessionId = relogin.sessionId; - infoResult = await postJson( - "/launcher/profile/info", - { username }, - { - Cookie: `PHPSESSID=${relogin.sessionId}`, - }, - ); - } - - if (!infoResult.ok || !infoResult.data) { - return infoResult; - } - - await refreshSessionRecord({ username, sessionId: activeSessionId }); - - const downloadsDir = app.getPath("downloads"); - const fileName = `spt-profile-${username}-${Date.now()}.json`; - const filePath = path.join(downloadsDir, fileName); - - await fs.writeFile( - filePath, - JSON.stringify(infoResult.data, null, 2), - "utf8", - ); - - return { - ok: true, - status: infoResult.status, - url: infoResult.url, - filePath, - }; - }, - ); - - ipcMain.handle( - "spt:resetProfile", - async (_event, payload: { username: string }) => { - const username = payload.username.trim(); - - const sessionResult = await getSessionIdForUser(username); - if (!sessionResult.ok) { - return sessionResult; - } - - let activeSessionId = sessionResult.sessionId; - let wipeResult = await postJson( - "/launcher/profile/change/wipe", - { username }, - { - Cookie: `PHPSESSID=${sessionResult.sessionId}`, - }, - ); - - if (!wipeResult.ok && isSessionInvalidStatus(wipeResult.status)) { - const relogin = await requestSessionId(username); - if (!relogin.ok) { - return relogin; - } - await writeSessionRecord({ username, sessionId: relogin.sessionId }); - activeSessionId = relogin.sessionId; - wipeResult = await postJson( - "/launcher/profile/change/wipe", - { username }, - { - Cookie: `PHPSESSID=${relogin.sessionId}`, - }, - ); - } - - if (wipeResult.ok) { - await refreshSessionRecord({ username, sessionId: activeSessionId }); - } - return wipeResult; - }, - ); - - ipcMain.handle("spt:resumeSession", async () => { - const record = await readSessionRecord(); - if (!record || isSessionExpired(record)) { - await clearSessionRecord(); - return { ok: false, error: "session_expired", url: SERVER_BASE_URL }; - } - - const infoResult = await postJson( - "/launcher/profile/info", - { username: record.username }, - { - Cookie: `PHPSESSID=${record.sessionId}`, }, ); - if (!infoResult.ok && isSessionInvalidStatus(infoResult.status)) { + ipcMain.handle( + "spt:downloadProfile", + async (_event, payload: { username: string }) => { + const username = payload.username.trim(); + + const sessionResult = await getSessionIdForUser(username); + if (!sessionResult.ok) { + return sessionResult; + } + + let activeSessionId = sessionResult.sessionId; + let infoResult = await postJson( + "/launcher/profile/info", + { username }, + { + Cookie: `PHPSESSID=${sessionResult.sessionId}`, + }, + ); + + if (!infoResult.ok && isSessionInvalidStatus(infoResult.status)) { + const relogin = await requestSessionId(username); + if (!relogin.ok) { + return relogin; + } + await writeSessionRecord({ username, sessionId: relogin.sessionId }); + activeSessionId = relogin.sessionId; + infoResult = await postJson( + "/launcher/profile/info", + { username }, + { + Cookie: `PHPSESSID=${relogin.sessionId}`, + }, + ); + } + + if (!infoResult.ok || !infoResult.data) { + return infoResult; + } + + await refreshSessionRecord({ username, sessionId: activeSessionId }); + + const downloadsDir = app.getPath("downloads"); + const fileName = `spt-profile-${username}-${Date.now()}.json`; + const filePath = path.join(downloadsDir, fileName); + + await fs.writeFile( + filePath, + JSON.stringify(infoResult.data, null, 2), + "utf8", + ); + + return { + ok: true, + status: infoResult.status, + url: infoResult.url, + filePath, + }; + }, + ); + + ipcMain.handle( + "spt:resetProfile", + async (_event, payload: { username: string }) => { + const username = payload.username.trim(); + + const sessionResult = await getSessionIdForUser(username); + if (!sessionResult.ok) { + return sessionResult; + } + + let activeSessionId = sessionResult.sessionId; + let wipeResult = await postJson( + "/launcher/profile/change/wipe", + { username }, + { + Cookie: `PHPSESSID=${sessionResult.sessionId}`, + }, + ); + + if (!wipeResult.ok && isSessionInvalidStatus(wipeResult.status)) { + const relogin = await requestSessionId(username); + if (!relogin.ok) { + return relogin; + } + await writeSessionRecord({ username, sessionId: relogin.sessionId }); + activeSessionId = relogin.sessionId; + wipeResult = await postJson( + "/launcher/profile/change/wipe", + { username }, + { + Cookie: `PHPSESSID=${relogin.sessionId}`, + }, + ); + } + + if (wipeResult.ok) { + await refreshSessionRecord({ username, sessionId: activeSessionId }); + } + return wipeResult; + }, + ); + + ipcMain.handle("spt:resumeSession", async () => { + const record = await readSessionRecord(); + if (!record || isSessionExpired(record)) { + await clearSessionRecord(); + return { ok: false, error: "session_expired", url: SERVER_BASE_URL }; + } + + const infoResult = await postJson( + "/launcher/profile/info", + { username: record.username }, + { + Cookie: `PHPSESSID=${record.sessionId}`, + }, + ); + + if (!infoResult.ok && isSessionInvalidStatus(infoResult.status)) { + await clearSessionRecord(); + return { ok: false, error: "session_expired", url: infoResult.url }; + } + + if (infoResult.ok) { + await refreshSessionRecord({ + username: record.username, + sessionId: record.sessionId, + }); + } + return infoResult; + }); + + ipcMain.handle("spt:clearSession", async () => { await clearSessionRecord(); - return { ok: false, error: "session_expired", url: infoResult.url }; - } + return { ok: true }; + }); - if (infoResult.ok) { - await refreshSessionRecord({ - username: record.username, - sessionId: record.sessionId, - }); - } - return infoResult; + // 개발/운영 모두에서 부팅 시 1회 즉시 체크(instant check) 로그를 남겨 + // 엔드포인트(endpoint) 확인이 가능하도록 합니다. + void checkServerHealth(); + + createWindow(); + + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); }); - ipcMain.handle("spt:clearSession", async () => { - await clearSessionRecord(); - return { ok: true }; - }); - - // 개발/운영 모두에서 부팅 시 1회 즉시 체크(instant check) 로그를 남겨 - // 엔드포인트(endpoint) 확인이 가능하도록 합니다. - void checkServerHealth(); - - createWindow(); - - app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); + app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); } }); -}); - -app.on("window-all-closed", () => { - if (process.platform !== "darwin") { - app.quit(); - } -}); +} diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 025928d..0289e83 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -19,7 +19,11 @@ contextBridge.exposeInMainWorld("sptLauncher", { setSptInstallPath: (payload: { path: string; allowMissing?: boolean }) => ipcRenderer.invoke("spt:setSptInstallPath", payload), pickSptInstallPath: () => ipcRenderer.invoke("spt:pickSptInstallPath"), - fetchProfile: (payload: { username: string }) => + login: (payload: { username: string; password?: string }) => + ipcRenderer.invoke("spt:login", payload), + register: (payload: { username: string; password?: string }) => + ipcRenderer.invoke("spt:register", payload), + fetchProfile: (payload: { username: string; password?: string }) => ipcRenderer.invoke("spt:fetchProfile", payload), downloadProfile: (payload: { username: string }) => ipcRenderer.invoke("spt:downloadProfile", payload), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index e8cfead..d9d6d94 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -74,6 +74,9 @@ const App = () => { const [hasSession, setHasSession] = useState(false); const [loginId, setLoginId] = useState(""); const [loginPassword, setLoginPassword] = useState(""); + const [signupId, setSignupId] = useState(""); + const [signupPassword, setSignupPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); const [profileLoading, setProfileLoading] = useState(false); const [profile, setProfile] = useState<{ username: string; @@ -169,8 +172,8 @@ const App = () => { value && typeof value === "object" ? (value as Record) : undefined; const pickString = (...values: unknown[]) => values.find((value) => typeof value === "string" && value.trim().length > 0) as - | string - | undefined; + | string + | undefined; const pickNumber = (...values: unknown[]) => { const candidate = values.find( (value) => typeof value === "number" || (typeof value === "string" && value.trim() !== "") @@ -496,18 +499,31 @@ const App = () => { sessionResumeAttemptedRef.current = true; void (async () => { - const result = await window.sptLauncher.resumeSession(); - if (!result.ok || !result.data) { - return; + try { + const result = await window.sptLauncher.resumeSession(); + if (!result.ok || !result.data) { + scheduleScreenTransition("login"); + return; + } + + const resumedProfile = extractProfile(result.data, loginId.trim()); + // Simple validation: profile must have an ID or Username to be considered valid + if (!resumedProfile.id && !resumedProfile.username) { + scheduleScreenTransition("login"); + return; + } + + setProfile(resumedProfile); + setHasSession(true); + if (resumedProfile.username) { + setLoginId(resumedProfile.username); + } + clearTransitionTimeout(); + setScreen("main"); + } catch (error) { + console.error("Session resume failed", error); + scheduleScreenTransition("login"); } - const resumedProfile = extractProfile(result.data, loginId.trim()); - setProfile(resumedProfile); - setHasSession(true); - if (resumedProfile.username) { - setLoginId(resumedProfile.username); - } - clearTransitionTimeout(); - setScreen("main"); })(); }, [ screen, @@ -815,28 +831,30 @@ const App = () => { const handleLogin = async () => { const trimmedId = loginId.trim(); + const trimmedPassword = loginPassword.trim(); if (!trimmedId) { window.alert("아이디를 입력해주세요."); return; } - if (!window.sptLauncher?.fetchProfile) { - window.alert("프로필 조회 기능이 준비되지 않았습니다."); + if (!window.sptLauncher?.login) { + window.alert("로그인 기능이 준비되지 않았습니다."); return; } setProfileLoading(true); try { - const result = await window.sptLauncher.fetchProfile({ - username: trimmedId + const result = await window.sptLauncher.login({ + username: trimmedId, + password: trimmedPassword }); if (!result.ok) { if (isTimeoutError(result.error)) { enterRecoveryMode("timeout"); return; } - window.alert(result.error ?? "프로필 조회에 실패했습니다."); + window.alert(result.error ?? "로그인에 실패했습니다."); return; } @@ -860,9 +878,51 @@ const App = () => { } }; - const handleSignup = () => { - window.alert("회원가입 요청이 접수되었습니다. 관리자 승인 후 이용 가능합니다."); - setScreen("login"); + const handleSignup = async () => { + const trimmedId = signupId.trim(); + const trimmedPassword = signupPassword.trim(); + const trimmedConfirm = confirmPassword.trim(); + + if (!trimmedId) { + window.alert("아이디를 입력해주세요."); + return; + } + if (!trimmedPassword) { + window.alert("비밀번호를 입력해주세요."); + return; + } + if (trimmedPassword !== trimmedConfirm) { + window.alert("비밀번호가 일치하지 않습니다."); + return; + } + + if (!window.sptLauncher?.register) { + window.alert("회원가입 기능이 준비되지 않았습니다."); + return; + } + + setProfileLoading(true); + try { + const result = await window.sptLauncher.register({ + username: trimmedId, + password: trimmedPassword + }); + + if (!result.ok) { + window.alert(result.error ?? "회원가입 요청에 실패했습니다."); + return; + } + + window.alert("회원가입이 완료되었습니다. 이제 로그인할 수 있습니다."); + setLoginId(trimmedId); + setLoginPassword(trimmedPassword); + setScreen("login"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + window.alert(message); + } finally { + setProfileLoading(false); + } }; const handlePasswordReset = () => { @@ -1037,42 +1097,42 @@ const App = () => { // 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; - const uiStep = { - name: s.name, - ok: s.result?.ok ?? false, - message: s.result?.ok ? undefined : s.result?.message ?? s.result?.error, - status: s.result?.ok ? "done" : "error" - }; - upsertSyncStep(uiStep); - }) - : () => {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const s = step as any; + const uiStep = { + name: s.name, + ok: s.result?.ok ?? false, + message: s.result?.ok ? undefined : s.result?.message ?? s.result?.error, + status: (s.result?.ok ? "done" : "error") as "done" | "error" + }; + upsertSyncStep(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.ok ? undefined : s.message, - status: s.ok ? "done" : "error" - })) - ); - } - setModCheckMessage(result.error ?? "모드 동기화에 실패했습니다."); - return false; + 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.ok ? undefined : s.message, + status: s.ok ? "done" : "error" + })) + ); + } + setModCheckMessage(result.error ?? "모드 동기화에 실패했습니다."); + return false; } setSyncResult({ ok: true }); @@ -1109,7 +1169,7 @@ const App = () => { const closeSyncModal = () => { if (syncInProgress) { - return; // Cannot close while running + return; // Cannot close while running } setSyncResult(undefined); setSyncSteps([]); @@ -1130,31 +1190,31 @@ const App = () => { } if (!window.sptLauncher?.launchGame) { - window.alert("게임 시작 기능이 준비되지 않았습니다."); - return; + window.alert("게임 시작 기능이 준비되지 않았습니다."); + return; } try { - const result = await window.sptLauncher.launchGame(); - if (!result.ok) { - if (result.error === "already_running") { - window.alert(`게임이 이미 실행 중입니다.\n(발견된 프로세스: ${(result.details ?? []).join(", ")})`); - } else if (result.error === "mod_verification_failed") { - window.alert("모드 버전 검증에 실패했습니다. 버전을 다시 확인해주세요."); - } else if (result.error === "no_spt_path") { - window.alert("SPT 설치 경로가 설정되지 않았습니다."); - } else if (result.error === "executable_not_found") { - window.alert("실행 파일(Launcher/Server)을 찾을 수 없습니다."); - } else { - window.alert(`게임 실행 실패: ${result.error}`); - } - return; + const result = await window.sptLauncher.launchGame(); + if (!result.ok) { + if (result.error === "already_running") { + window.alert(`게임이 이미 실행 중입니다.\n(발견된 프로세스: ${(result.details ?? []).join(", ")})`); + } else if (result.error === "mod_verification_failed") { + window.alert("모드 버전 검증에 실패했습니다. 버전을 다시 확인해주세요."); + } else if (result.error === "no_spt_path") { + window.alert("SPT 설치 경로가 설정되지 않았습니다."); + } else if (result.error === "executable_not_found") { + window.alert("실행 파일(Launcher/Server)을 찾을 수 없습니다."); + } else { + window.alert(`게임 실행 실패: ${result.error}`); } - - // Success - window.alert(`게임이 실행되었습니다.\n(${result.executedPath})`); + return; + } + + // Success + window.alert(`게임이 실행되었습니다.\n(${result.executedPath})`); } catch (e: any) { - window.alert(`게임 실행 중 오류가 발생했습니다: ${e.message}`); + window.alert(`게임 실행 중 오류가 발생했습니다: ${e.message}`); } }; @@ -1312,12 +1372,27 @@ const App = () => { 회원가입 후 서버 관리자 승인이 완료되어야 로그인 가능합니다.

- - - + setSignupId(e.target.value)} + /> + setSignupPassword(e.target.value)} + /> + setConfirmPassword(e.target.value)} + />
- + - +
{/* Install Path Row */}
-
- 설치 경로 - - {sptInstallInfo?.ok ? "확인됨" : "확인 필요"} +
+ 설치 경로 + + {sptInstallInfo?.ok ? "확인됨" : "확인 필요"} + +
+
+ {sptInstallInfo?.path ?? "-"} + {sptInstallInfo?.source && ( + + ({sptInstallInfo.source === "auto" ? "자동" : "수동"}) -
-
- {sptInstallInfo?.path ?? "-"} - {sptInstallInfo?.source && ( - - ({sptInstallInfo.source === "auto" ? "자동" : "수동"}) - - )} -
-
- - -
+ )} +
+
+ + +
{/* Mod Version Row */}
-
- 모드 버전 - {modStatusLabel} -
-
- - {modVersionStatus?.currentTag ?? "-"} → {modVersionStatus?.latestTag ?? "-"} +
+ 모드 버전 + {modStatusLabel} +
+
+ + {modVersionStatus?.currentTag ?? "-"} → {modVersionStatus?.latestTag ?? "-"} + + {modVersionStatus?.latestMinorTags && modVersionStatus.latestMinorTags.length > 0 && ( + + (+{modVersionStatus.latestMinorTags.length} 패치) - {modVersionStatus?.latestMinorTags && modVersionStatus.latestMinorTags.length > 0 && ( - - (+{modVersionStatus.latestMinorTags.length} 패치) - - )} -
-
- + )} +
+
+ + +
-
- - {/* Hover menu logic handled by CSS usually, keeping simple for now */} -
-
+ {/* Hover menu logic handled by CSS usually, keeping simple for now */} +
+
@@ -1506,19 +1581,19 @@ const App = () => {
- 서버 - + 서버 +
- Git - + Git +
- LFS - + LFS +
@@ -1566,7 +1641,7 @@ const App = () => { )} - {installMessage &&
{installMessage}
} + {installMessage &&
{installMessage}
} {gitPathsResult && (
경로 확인 @@ -1583,73 +1658,73 @@ const App = () => { {(syncInProgress || syncResult) && (
-
e.stopPropagation()}> -
-

- {syncResult - ? (syncResult.ok ? (modalTitle.replace("중...", "완료")) : (syncResult.error ? `오류: ${syncResult.error}` : "오류 발생")) - : modalTitle} -

- {!syncInProgress && ( - - )} -
-
- {syncProgress?.progress && ( -
-
- 진행률(Progress) - {syncProgress.progress.percent}% -
-
-
-
- {syncProgress.stepName && ( -
- 현재 단계(Current Step): {getStepDisplayInfo(syncProgress.stepName).title} -
- )} -
- )} -
- {syncSteps.map((step, idx) => { - const info = getStepDisplayInfo(step.name); - const stepClass = - step.status === "running" ? "running" : step.ok ? "ok" : "fail"; - const stepIcon = - step.status === "running" ? "⏳" : step.ok ? "✅" : "❌"; - return ( -
- {idx + 1}. -
-
{info.title}
-
{info.command}
- {!step.ok && step.message && ( -
오류(Error): {step.message}
- )} -
- {stepIcon} -
- ); - })} - {syncInProgress &&
- - 작업 진행 중... -
} -
- {syncResult && ( -
- {syncResult.ok ? "모드 적용이 완료되었습니다." : (syncResult.error ? `실패: ${syncResult.error}` : "오류가 발생했습니다.")} -
- )} +
e.stopPropagation()}> +
+

+ {syncResult + ? (syncResult.ok ? (modalTitle.replace("중...", "완료")) : (syncResult.error ? `오류: ${syncResult.error}` : "오류 발생")) + : modalTitle} +

+ {!syncInProgress && ( + + )} +
+
+ {syncProgress?.progress && ( +
+
+ 진행률(Progress) + {syncProgress.progress.percent}% +
+
+
+
+ {syncProgress.stepName && ( +
+ 현재 단계(Current Step): {getStepDisplayInfo(syncProgress.stepName).title} +
+ )}
+ )} +
+ {syncSteps.map((step, idx) => { + const info = getStepDisplayInfo(step.name); + const stepClass = + step.status === "running" ? "running" : step.ok ? "ok" : "fail"; + const stepIcon = + step.status === "running" ? "⏳" : step.ok ? "✅" : "❌"; + return ( +
+ {idx + 1}. +
+
{info.title}
+
{info.command}
+ {!step.ok && step.message && ( +
오류(Error): {step.message}
+ )} +
+ {stepIcon} +
+ ); + })} + {syncInProgress &&
+ + 작업 진행 중... +
} +
+ {syncResult && ( +
+ {syncResult.ok ? "모드 적용이 완료되었습니다." : (syncResult.error ? `실패: ${syncResult.error}` : "오류가 발생했습니다.")} +
+ )} +
-
+
)}
diff --git a/src/renderer/App.tsx.orig b/src/renderer/App.tsx.orig new file mode 100644 index 0000000..d9d6d94 --- /dev/null +++ b/src/renderer/App.tsx.orig @@ -0,0 +1,1734 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { APP_CONFIG, SERVER_HEALTHCHECK_URL } from "../shared/config"; + +type Screen = "serverCheck" | "login" | "signup" | "resetPassword" | "main"; +type CommandCheck = { + ok: boolean; + command: string; + version?: string; + error?: string; +}; +type GitToolsCheck = { + git: CommandCheck; + lfs: CommandCheck; + checkedAt: number; +}; +type CommandPath = { + ok: boolean; + command: string; + path?: string; + error?: string; +}; +type GitPathsCheck = { + git: CommandPath; + lfs: CommandPath; + checkedAt: number; +}; +type ModVersionStatus = { + ok: boolean; + checkedAt: number; + currentTag?: string; + latestTag?: string; + latestMinorTags?: string[]; + status?: "upToDate" | "patchMismatch" | "majorMinorMismatch" | "unknown"; + error?: string; +}; +type SptInstallInfo = { + ok: boolean; + checkedAt: number; + path?: string; + source?: "stored" | "auto"; + needsUserInput?: boolean; + 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 DEV_UI_ENABLED = IS_DEV && import.meta.env.VITE_DEV_UI === "1"; + +const App = () => { + const [screen, setScreen] = useState("serverCheck"); + const [serverHealthy, setServerHealthy] = useState(true); + const [serverStatusCode, setServerStatusCode] = useState(); + const [serverLatencyMs, setServerLatencyMs] = useState(); + const [serverError, setServerError] = useState(); + const [serverCheckedUrl, setServerCheckedUrl] = useState(); + const [serverCheckInProgress, setServerCheckInProgress] = useState(false); + const [serverCheckedAt, setServerCheckedAt] = useState(); + const [serverCheckStartedAt, setServerCheckStartedAt] = useState(Date.now()); + const [serverRecoveryActive, setServerRecoveryActive] = useState(false); + const [skipToolsCheck, setSkipToolsCheck] = useState(false); + const [devProceedReady, setDevProceedReady] = useState(false); + const [simulateToolsMissing, setSimulateToolsMissing] = useState(false); + const [simulateInstallProgress, setSimulateInstallProgress] = useState(false); + const [gitCheckInProgress, setGitCheckInProgress] = useState(false); + const [gitCheckedAt, setGitCheckedAt] = useState(); + const [gitCheckResult, setGitCheckResult] = useState(); + const [gitPathsLoading, setGitPathsLoading] = useState(false); + const [gitPathsResult, setGitPathsResult] = useState(); + const [toolsModalOpen, setToolsModalOpen] = useState(false); + const [installInProgress, setInstallInProgress] = useState(false); + const [installMessage, setInstallMessage] = useState(); + const [hasSession, setHasSession] = useState(false); + const [loginId, setLoginId] = useState(""); + const [loginPassword, setLoginPassword] = useState(""); + const [signupId, setSignupId] = useState(""); + const [signupPassword, setSignupPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [profileLoading, setProfileLoading] = useState(false); + const [profile, setProfile] = useState<{ + username: string; + nickname?: string; + level?: number; + side?: string; + id?: string; + } | undefined>(); + const [profileActionInProgress, setProfileActionInProgress] = useState(false); + const [syncInProgress, setSyncInProgress] = useState(false); + const [simulateSyncFail, setSimulateSyncFail] = useState(false); + const [modCheckInProgress, setModCheckInProgress] = useState(false); + const [modVersionStatus, setModVersionStatus] = useState(); + const [modCheckMessage, setModCheckMessage] = useState(); + const [selectedModTag, setSelectedModTag] = useState(""); + const [sptInstallInfo, setSptInstallInfo] = useState(); + const [sptInstallMessage, setSptInstallMessage] = useState(); + const [sptPathInput, setSptPathInput] = useState(""); + const [allowMissingSptPath, setAllowMissingSptPath] = useState(false); + const healthCheckInFlightRef = useRef(false); + const gitCheckInFlightRef = useRef(false); + const gitCheckedRef = useRef(false); + const modCheckInFlightRef = useRef(false); + const modCheckedRef = useRef(false); + const sessionResumeAttemptedRef = useRef(false); + const transitionTimeoutRef = useRef(); + const simulateToolsMissingRef = useRef(false); + const devInstallTimerRef = useRef(); + const [modalTitle, setModalTitle] = useState("모드 적용 중..."); + const [syncSteps, setSyncSteps] = useState< + Array<{ + name: string; + ok: boolean; + message?: string; + status?: "running" | "done" | "error"; + progress?: { current: number; total: number; percent: number }; + }> + >([]); + 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..." }; + } + // Git Install Steps + if (rawName === "winget 확인") { + return { title: "패키지 관리자(winget) 확인", command: "checking winget..." }; + } + if (rawName === "Git 설치") { + return { title: "Git 설치 (시간이 걸릴 수 있습니다)", command: "winget install Git.Git" }; + } + if (rawName === "Git LFS 설치") { + return { title: "Git LFS 설치", command: "winget install GitHub.GitLFS" }; + } + return { title: rawName, command: "" }; + }; + + const upsertSyncStep = useCallback( + (nextStep: { + name: string; + ok: boolean; + message?: string; + status?: "running" | "done" | "error"; + progress?: { current: number; total: number; percent: number }; + }) => { + setSyncSteps((prev) => { + const index = prev.findIndex((step) => step.name === nextStep.name); + if (index < 0) { + return [...prev, nextStep]; + } + const updated = [...prev]; + updated[index] = { ...updated[index], ...nextStep }; + return updated; + }); + }, + [] + ); + + const asObject = (value: unknown) => + value && typeof value === "object" ? (value as Record) : undefined; + const pickString = (...values: unknown[]) => + values.find((value) => typeof value === "string" && value.trim().length > 0) as + | string + | undefined; + const pickNumber = (...values: unknown[]) => { + const candidate = values.find( + (value) => typeof value === "number" || (typeof value === "string" && value.trim() !== "") + ); + if (typeof candidate === "number") { + return candidate; + } + if (typeof candidate === "string") { + const parsed = Number(candidate); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; + }; + + const extractProfile = (data: unknown, fallbackUsername: string) => { + const root = asObject(data); + const profileCandidate = Array.isArray(root?.profiles) + ? root?.profiles?.[0] + : root?.profile ?? root?.data ?? root; + const profileObject = asObject(profileCandidate) ?? {}; + const info = asObject(profileObject.info ?? profileObject.Info) ?? profileObject; + + return { + username: pickString(info.username, info.Username, profileObject.username, fallbackUsername) ?? fallbackUsername, + nickname: pickString(info.nickname, info.Nickname, profileObject.nickname, profileObject.Nickname), + level: pickNumber( + info.level, + info.Level, + info.currlvl, + info.currLvl, + profileObject.level, + profileObject.Level, + profileObject.currlvl, + profileObject.currLvl + ), + side: pickString(info.side, info.Side, profileObject.side, profileObject.Side), + id: pickString(info.id, info._id, profileObject.id, profileObject._id, profileObject.profileId) + }; + }; + + const runGitCheck = useCallback(async (force = false) => { + if (force) { + gitCheckedRef.current = false; + } + + if (gitCheckInFlightRef.current || (!force && gitCheckedRef.current)) { + return gitCheckResult; + } + + gitCheckInFlightRef.current = true; + setGitCheckInProgress(true); + + try { + if (DEV_UI_ENABLED && simulateToolsMissingRef.current) { + const simulated = { + git: { ok: false, command: "git", error: "simulated_missing" }, + lfs: { ok: false, command: "git lfs", error: "simulated_missing" }, + checkedAt: Date.now() + }; + setGitCheckResult(simulated); + setGitCheckedAt(simulated.checkedAt); + gitCheckedRef.current = true; + return simulated; + } + + if (!window.sptLauncher?.checkGitTools) { + throw new Error("preload(preload) 미로딩: window.sptLauncher.checkGitTools가 없습니다."); + } + + const result = await window.sptLauncher.checkGitTools(); + setGitCheckResult(result); + setGitCheckedAt(result.checkedAt ?? Date.now()); + gitCheckedRef.current = true; + return result; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const fallback = { + git: { ok: false, command: "git", error: message }, + lfs: { ok: false, command: "git lfs", error: message }, + checkedAt: Date.now() + }; + setGitCheckResult(fallback); + setGitCheckedAt(Date.now()); + gitCheckedRef.current = true; + return fallback; + } finally { + gitCheckInFlightRef.current = false; + setGitCheckInProgress(false); + } + }, [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 runSptInstallCheck = useCallback(async () => { + if (!window.sptLauncher?.getSptInstallInfo) { + setSptInstallMessage("SPT 경로 확인 기능이 준비되지 않았습니다."); + return; + } + + const result = await window.sptLauncher.getSptInstallInfo(); + setSptInstallInfo(result); + if (!result.ok) { + if (result.error === "unsupported_platform") { + setSptInstallMessage("Windows 환경에서만 SPT 경로 확인이 가능합니다."); + } else if (result.error === "not_found") { + setSptInstallMessage("SPT 설치 경로를 찾지 못했습니다."); + } else { + setSptInstallMessage(result.error ?? "SPT 경로 확인에 실패했습니다."); + } + } else { + setSptInstallMessage(undefined); + if (result.path) { + setSptPathInput(result.path); + } + } + }, []); + + const clearTransitionTimeout = useCallback(() => { + if (transitionTimeoutRef.current) { + window.clearTimeout(transitionTimeoutRef.current); + transitionTimeoutRef.current = undefined; + } + }, []); + + const scheduleScreenTransition = useCallback( + (nextScreen: Screen) => { + clearTransitionTimeout(); + const elapsedMs = Date.now() - serverCheckStartedAt; + const remainingMs = Math.max(0, MIN_SERVER_CHECK_SCREEN_MS - elapsedMs); + + if (remainingMs > 0) { + transitionTimeoutRef.current = window.setTimeout(() => { + setScreen(nextScreen); + }, remainingMs); + return; + } + + setScreen(nextScreen); + }, + [clearTransitionTimeout, serverCheckStartedAt] + ); + + const enterRecoveryMode = useCallback( + (reason: string) => { + setServerRecoveryActive(true); + setServerHealthy(false); + setServerStatusCode(undefined); + setServerLatencyMs(undefined); + setServerError(reason); + setServerCheckedUrl(undefined); + setServerCheckedAt(Date.now()); + setHasSession(false); + setProfile(undefined); + setSkipToolsCheck(false); + setDevProceedReady(false); + sessionResumeAttemptedRef.current = false; + setScreen("serverCheck"); + clearTransitionTimeout(); + }, + [clearTransitionTimeout] + ); + + const isTimeoutError = (error?: string) => { + if (!error) { + return false; + } + return error.toLowerCase().includes("timeout"); + }; + + const runHealthCheck = useCallback( + async (trigger: "initial" | "manual" | "recovery" = "initial") => { + if (healthCheckInFlightRef.current) { + return; + } + + healthCheckInFlightRef.current = true; + setServerCheckInProgress(true); + setServerError(undefined); + let isHealthy = false; + let failureReason = "healthcheck_failed"; + + try { + if (!window.sptLauncher?.checkServerHealth) { + throw new Error("preload(preload) 미로딩: window.sptLauncher가 없습니다."); + } + + const result = await window.sptLauncher.checkServerHealth(); + isHealthy = result.ok; + if (!result.ok) { + failureReason = result.error ?? "healthcheck_failed"; + } + setServerHealthy(result.ok); + setServerStatusCode(result.status); + setServerLatencyMs(result.latencyMs); + setServerError(result.error); + setServerCheckedUrl(result.url ?? HEALTHCHECK_ENDPOINT_FALLBACK); + setServerCheckedAt(Date.now()); + + if (screen === "serverCheck" && result.ok) { + const gitResult = !gitCheckedRef.current ? await runGitCheck() : gitCheckResult; + const toolsReady = Boolean(gitResult?.git.ok && gitResult?.lfs.ok); + + if (serverRecoveryActive) { + setServerRecoveryActive(false); + if (DEV_UI_ENABLED) { + setDevProceedReady(true); + } else { + scheduleScreenTransition("login"); + } + return; + } + + if (!toolsReady && gitResult && !skipToolsCheck) { + return; + } + + if (DEV_UI_ENABLED) { + setDevProceedReady(true); + return; + } + + scheduleScreenTransition(hasSession ? "main" : "login"); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + failureReason = message; + setServerHealthy(false); + setServerStatusCode(undefined); + setServerLatencyMs(undefined); + setServerError(message); + setServerCheckedUrl(undefined); + setServerCheckedAt(Date.now()); + } finally { + healthCheckInFlightRef.current = false; + setServerCheckInProgress(false); + } + + if (trigger === "manual" && !isHealthy) { + enterRecoveryMode(failureReason); + } + }, + [ + screen, + hasSession, + serverRecoveryActive, + runGitCheck, + gitCheckResult, + scheduleScreenTransition, + enterRecoveryMode, + skipToolsCheck + ] + ); + + const handleManualHealthCheck = () => { + void runHealthCheck("manual"); + }; + + useEffect(() => { + if (screen !== "serverCheck") { + return; + } + + void runHealthCheck(serverRecoveryActive ? "recovery" : "initial"); + }, [screen, serverRecoveryActive, runHealthCheck]); + + useEffect(() => { + if ( + screen !== "serverCheck" || + !serverHealthy || + hasSession || + serverCheckInProgress || + !serverCheckedAt + ) { + return; + } + if (sessionResumeAttemptedRef.current) { + return; + } + if (!window.sptLauncher?.resumeSession) { + sessionResumeAttemptedRef.current = true; + return; + } + + sessionResumeAttemptedRef.current = true; + void (async () => { + try { + const result = await window.sptLauncher.resumeSession(); + if (!result.ok || !result.data) { + scheduleScreenTransition("login"); + return; + } + + const resumedProfile = extractProfile(result.data, loginId.trim()); + // Simple validation: profile must have an ID or Username to be considered valid + if (!resumedProfile.id && !resumedProfile.username) { + scheduleScreenTransition("login"); + return; + } + + setProfile(resumedProfile); + setHasSession(true); + if (resumedProfile.username) { + setLoginId(resumedProfile.username); + } + clearTransitionTimeout(); + setScreen("main"); + } catch (error) { + console.error("Session resume failed", error); + scheduleScreenTransition("login"); + } + })(); + }, [ + screen, + serverHealthy, + hasSession, + serverCheckInProgress, + serverCheckedAt, + loginId, + clearTransitionTimeout, + extractProfile + ]); + + useEffect(() => { + if (screen !== "serverCheck") { + return; + } + setServerCheckStartedAt(Date.now()); + setDevProceedReady(false); + }, [screen]); + + useEffect(() => { + if (!serverRecoveryActive) { + return; + } + const interval = window.setInterval(() => { + void runHealthCheck("recovery"); + }, HEALTHCHECK_INTERVAL_MS); + + return () => window.clearInterval(interval); + }, [serverRecoveryActive, runHealthCheck]); + + useEffect(() => { + simulateToolsMissingRef.current = simulateToolsMissing; + }, [simulateToolsMissing]); + + useEffect(() => { + return () => { + if (devInstallTimerRef.current) { + window.clearTimeout(devInstallTimerRef.current); + } + }; + }, []); + + useEffect(() => { + return () => clearTransitionTimeout(); + }, [clearTransitionTimeout]); + + useEffect(() => { + if (!hasSession || screen !== "main") { + return; + } + void runModVersionCheck(); + }, [hasSession, screen, runModVersionCheck]); + + useEffect(() => { + if (screen !== "main") { + return; + } + void runSptInstallCheck(); + }, [screen, runSptInstallCheck]); + + 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 modTagOptions = useMemo(() => { + const tags = new Set(); + if (modVersionStatus?.currentTag) { + tags.add(modVersionStatus.currentTag); + } + if (modVersionStatus?.latestTag) { + tags.add(modVersionStatus.latestTag); + } + modVersionStatus?.latestMinorTags?.forEach((tag) => tags.add(tag)); + return Array.from(tags); + }, [modVersionStatus]); + + const syncProgress = useMemo(() => { + for (let i = syncSteps.length - 1; i >= 0; i -= 1) { + if (syncSteps[i].progress) { + return { progress: syncSteps[i].progress, stepName: syncSteps[i].name }; + } + } + return undefined; + }, [syncSteps]); + + const startReady = Boolean(sptInstallInfo?.ok && modVersionStatus?.ok && !syncInProgress); + const startStatusLabel = syncInProgress + ? "동기화 중" + : startReady + ? "준비됨" + : !sptInstallInfo?.ok + ? "경로 필요" + : !modVersionStatus?.ok + ? "버전 확인 필요" + : "준비 안됨"; + const startStatusClass = syncInProgress ? "idle" : startReady ? "ok" : "blocked"; + + useEffect(() => { + if (modTagOptions.length === 0) { + if (selectedModTag) { + setSelectedModTag(""); + } + return; + } + const preferred = modVersionStatus?.latestTag ?? modTagOptions[0]; + if (!modTagOptions.includes(selectedModTag)) { + setSelectedModTag(preferred ?? ""); + } + }, [modTagOptions, modVersionStatus?.latestTag, selectedModTag]); + + const serverStatusLedClass = serverCheckInProgress ? "idle" : serverHealthy ? "ok" : "blocked"; + const gitStatusLedClass = (() => { + if (gitCheckInProgress) { + return "idle"; + } + if (!gitCheckResult) { + return "idle"; + } + if (gitCheckResult.git.ok && gitCheckResult.lfs.ok) { + return "ok"; + } + return "blocked"; + })(); + const gitLedClass = gitCheckInProgress + ? "idle" + : !gitCheckResult + ? "idle" + : gitCheckResult.git.ok + ? "ok" + : "blocked"; + const lfsLedClass = gitCheckInProgress + ? "idle" + : !gitCheckResult + ? "idle" + : gitCheckResult.lfs.ok + ? "ok" + : "blocked"; + + const handleOpenToolsModal = () => { + setToolsModalOpen(true); + }; + + const handleRecheckTools = async () => { + await runGitCheck(true); + }; + + const handleInstallTools = async () => { + if (!window.sptLauncher?.installGitTools) { + window.alert("자동 설치 기능이 준비되지 않았습니다."); + return; + } + + setInstallInProgress(true); + setInstallMessage(undefined); + + // Modal Setup + setSyncSteps([]); + setSyncResult(undefined); + setModalTitle("Git 도구 설치 중..."); + setSyncInProgress(true); + + const cleanup = window.sptLauncher.onGitInstallProgress((_e, step: any) => { + const normalized = { + name: step.name, + ok: Boolean(step.ok), + message: step.ok ? undefined : step.message, + status: step.status, + progress: step.progress + }; + upsertSyncStep(normalized); + }); + + try { + const result = await window.sptLauncher.installGitTools(); + if (!result.ok) { + setInstallMessage(result.error ?? "자동 설치에 실패했습니다."); + setSyncResult({ ok: false, error: result.error }); + } else { + setInstallMessage("자동 설치가 완료되었습니다."); + setSyncResult({ ok: true }); + } + if (result.check) { + setGitCheckResult(result.check); + setGitCheckedAt(result.check.checkedAt ?? Date.now()); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setInstallMessage(message); + setSyncResult({ ok: false, error: message }); + } finally { + cleanup(); + setInstallInProgress(false); + setSyncInProgress(false); // Show close button + } + }; + + const runDevInstallSimulation = useCallback(() => { + if (!DEV_UI_ENABLED) { + return; + } + if (devInstallTimerRef.current) { + window.clearTimeout(devInstallTimerRef.current); + } + setSimulateInstallProgress(true); + setInstallInProgress(true); + setInstallMessage(undefined); + setSyncSteps([]); + setSyncResult(undefined); + setModalTitle("Git 도구 설치 중..."); + setSyncInProgress(true); + + const steps = [ + { name: "winget 확인", percent: 33 }, + { name: "Git 설치", percent: 67 }, + { name: "Git LFS 설치", percent: 100 } + ]; + + let index = 0; + const tick = () => { + const current = steps[index]; + upsertSyncStep({ + name: current.name, + ok: true, + status: "running", + progress: { current: index + 1, total: steps.length, percent: current.percent } + }); + + devInstallTimerRef.current = window.setTimeout(() => { + upsertSyncStep({ + name: current.name, + ok: true, + status: "done", + progress: { current: index + 1, total: steps.length, percent: current.percent } + }); + index += 1; + if (index < steps.length) { + tick(); + return; + } + setInstallMessage("자동 설치가 완료되었습니다."); + setSyncResult({ ok: true }); + setInstallInProgress(false); + setSyncInProgress(false); + setSimulateInstallProgress(false); + }, 800); + }; + + tick(); + }, [upsertSyncStep]); + + const handleFetchGitPaths = async () => { + if (!window.sptLauncher?.getGitPaths) { + window.alert("경로 확인 기능이 준비되지 않았습니다."); + return; + } + + setGitPathsLoading(true); + try { + const result = await window.sptLauncher.getGitPaths(); + setGitPathsResult(result); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setGitPathsResult({ + git: { ok: false, command: "git", error: message }, + lfs: { ok: false, command: "git-lfs", error: message }, + checkedAt: Date.now() + }); + } finally { + setGitPathsLoading(false); + } + }; + + const handleLogin = async () => { + const trimmedId = loginId.trim(); + const trimmedPassword = loginPassword.trim(); + if (!trimmedId) { + window.alert("아이디를 입력해주세요."); + return; + } + + if (!window.sptLauncher?.login) { + window.alert("로그인 기능이 준비되지 않았습니다."); + return; + } + + setProfileLoading(true); + + try { + const result = await window.sptLauncher.login({ + username: trimmedId, + password: trimmedPassword + }); + if (!result.ok) { + if (isTimeoutError(result.error)) { + enterRecoveryMode("timeout"); + return; + } + window.alert(result.error ?? "로그인에 실패했습니다."); + return; + } + + if (!result.data) { + window.alert("프로필 응답이 비어있습니다."); + return; + } + + setProfile(extractProfile(result.data, trimmedId)); + setHasSession(true); + setScreen("main"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isTimeoutError(message)) { + enterRecoveryMode("timeout"); + return; + } + window.alert(message); + } finally { + setProfileLoading(false); + } + }; + + const handleSignup = async () => { + const trimmedId = signupId.trim(); + const trimmedPassword = signupPassword.trim(); + const trimmedConfirm = confirmPassword.trim(); + + if (!trimmedId) { + window.alert("아이디를 입력해주세요."); + return; + } + if (!trimmedPassword) { + window.alert("비밀번호를 입력해주세요."); + return; + } + if (trimmedPassword !== trimmedConfirm) { + window.alert("비밀번호가 일치하지 않습니다."); + return; + } + + if (!window.sptLauncher?.register) { + window.alert("회원가입 기능이 준비되지 않았습니다."); + return; + } + + setProfileLoading(true); + try { + const result = await window.sptLauncher.register({ + username: trimmedId, + password: trimmedPassword + }); + + if (!result.ok) { + window.alert(result.error ?? "회원가입 요청에 실패했습니다."); + return; + } + + window.alert("회원가입이 완료되었습니다. 이제 로그인할 수 있습니다."); + setLoginId(trimmedId); + setLoginPassword(trimmedPassword); + setScreen("login"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + window.alert(message); + } finally { + setProfileLoading(false); + } + }; + + const handlePasswordReset = () => { + window.alert("비밀번호 변경 요청이 접수되었습니다."); + setScreen("login"); + }; + + const handleLogout = () => { + if (window.sptLauncher?.clearSession) { + void window.sptLauncher.clearSession(); + } + setHasSession(false); + setProfile(undefined); + setScreen("login"); + setModVersionStatus(undefined); + setModCheckMessage(undefined); + modCheckedRef.current = false; + setSptInstallInfo(undefined); + setSptInstallMessage(undefined); + }; + + const handleProfileDownload = async () => { + const username = profile?.username?.trim() ?? loginId.trim(); + if (!username) { + window.alert("아이디를 입력해주세요."); + return; + } + + if (!window.sptLauncher?.downloadProfile) { + window.alert("프로필 다운로드 기능이 준비되지 않았습니다."); + return; + } + + setProfileActionInProgress(true); + try { + const result = await window.sptLauncher.downloadProfile({ username }); + if (!result.ok) { + if (isTimeoutError(result.error)) { + enterRecoveryMode("timeout"); + return; + } + window.alert(result.error ?? "프로필 다운로드에 실패했습니다."); + return; + } + + window.alert(`프로필을 다운로드했습니다: ${result.filePath ?? "-"}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isTimeoutError(message)) { + enterRecoveryMode("timeout"); + return; + } + window.alert(message); + } finally { + setProfileActionInProgress(false); + } + }; + + const handleProfileReset = async () => { + const username = profile?.username?.trim() ?? loginId.trim(); + if (!username) { + window.alert("아이디를 입력해주세요."); + return; + } + + if (!window.sptLauncher?.resetProfile) { + window.alert("프로필 리셋 기능이 준비되지 않았습니다."); + return; + } + + const confirmed = window.confirm("프로필을 리셋할까요?"); + if (!confirmed) { + return; + } + + setProfileActionInProgress(true); + try { + const result = await window.sptLauncher.resetProfile({ username }); + if (!result.ok) { + if (isTimeoutError(result.error)) { + enterRecoveryMode("timeout"); + return; + } + window.alert(result.error ?? "프로필 리셋에 실패했습니다."); + return; + } + + window.alert("프로필 리셋 요청이 완료되었습니다."); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isTimeoutError(message)) { + enterRecoveryMode("timeout"); + return; + } + window.alert(message); + } finally { + setProfileActionInProgress(false); + } + }; + + 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", + overrideTag?: string + ) => { + if (syncInProgress) { + return false; + } + + setSyncInProgress(true); + const trimmedOverride = overrideTag?.trim(); + const targetTag = + trimmedOverride || + (mode === "force" + ? modVersionStatus?.currentTag ?? modVersionStatus?.latestTag + : modVersionStatus?.latestTag); + + try { + const targetDir = sptInstallInfo?.path?.trim(); + if (!targetDir) { + 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"); + } + + setSyncSteps([]); + setSyncResult(undefined); + setModalTitle("모드 적용 중..."); + setSyncInProgress(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; + const uiStep = { + name: s.name, + ok: s.result?.ok ?? false, + message: s.result?.ok ? undefined : s.result?.message ?? s.result?.error, + status: (s.result?.ok ? "done" : "error") as "done" | "error" + }; + upsertSyncStep(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.ok ? undefined : s.message, + status: s.ok ? "done" : "error" + })) + ); + } + 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.ok ? undefined : s.message, + status: s.ok ? "done" : "error" + })) + ); + } + + 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( + `모드 동기화가 실패했습니다. 그래도 실행할까요? (${message})` + ); + 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") { + window.alert("모드 메이저/마이너 버전이 다릅니다. 동기화 후 실행됩니다."); + const ok = await performModSync("required"); + if (!ok) { + return; + } + } + + if (syncInProgress) { + return; + } + + if (!window.sptLauncher?.launchGame) { + window.alert("게임 시작 기능이 준비되지 않았습니다."); + return; + } + + try { + const result = await window.sptLauncher.launchGame(); + if (!result.ok) { + if (result.error === "already_running") { + window.alert(`게임이 이미 실행 중입니다.\n(발견된 프로세스: ${(result.details ?? []).join(", ")})`); + } else if (result.error === "mod_verification_failed") { + window.alert("모드 버전 검증에 실패했습니다. 버전을 다시 확인해주세요."); + } else if (result.error === "no_spt_path") { + window.alert("SPT 설치 경로가 설정되지 않았습니다."); + } else if (result.error === "executable_not_found") { + window.alert("실행 파일(Launcher/Server)을 찾을 수 없습니다."); + } else { + window.alert(`게임 실행 실패: ${result.error}`); + } + return; + } + + // Success + window.alert(`게임이 실행되었습니다.\n(${result.executedPath})`); + } catch (e: any) { + window.alert(`게임 실행 중 오류가 발생했습니다: ${e.message}`); + } + }; + + return ( +
+
+

SPT Launcher

+
+ + {screen === "serverCheck" && ( +
+

서버 연결 및 도구 상태 점검

+

+ {serverCheckInProgress ? "체크 중..." : serverHealthy ? "정상" : "불가"} +

+
+
+ 서버 + + {serverRecoveryActive && 복구 중} +
+
+ Git + +
+
+ Git LFS + +
+
+ {!serverCheckInProgress && !serverHealthy && ( + + )} + {serverHealthy && gitCheckResult && !(gitCheckResult.git.ok && gitCheckResult.lfs.ok) && ( +
+ 도구 설치 필요 +
+ Git/Git LFS 감지에 실패했습니다. 자동 설치(Windows) 또는 수동 설치 후 재확인해주세요. +
+
+ + + +
+
+ )} + {DEV_UI_ENABLED && + serverHealthy && + (skipToolsCheck || + Boolean(gitCheckResult?.git.ok && gitCheckResult?.lfs.ok)) && ( + + )} + {DEV_UI_ENABLED && ( + + )} + {DEV_UI_ENABLED && ( + + )} +
+ )} + + {screen === "login" && ( +
+

로그인

+

로그인 필요

+
+ setLoginId(event.target.value)} + /> + setLoginPassword(event.target.value)} + /> +
+ +
+ + +
+
+ )} + + {screen === "signup" && ( +
+

회원가입

+

관리자 승인 필요

+

+ 회원가입 후 서버 관리자 승인이 완료되어야 로그인 가능합니다. +

+
+ setSignupId(e.target.value)} + /> + setSignupPassword(e.target.value)} + /> + setConfirmPassword(e.target.value)} + /> +
+ + +
+ )} + + {screen === "resetPassword" && ( +
+

비밀번호 변경

+

본인 확인 필요

+
+ + + + +
+ + +
+ )} + + {screen === "main" && ( +
+
+
+
+ 프로필 + + {profile?.nickname ?? profile?.username ?? "-"} + +
+
+ 아이디: {profile?.username ?? "-"} + 레벨: {typeof profile?.level === "number" ? profile.level : "-"} + {profile?.side && 진영: {profile.side}} +
+
+
+ + + +
+
+ +
+
+

SPT 설정

+ +
+ +
+ {/* Install Path Row */} +
+
+ 설치 경로 + + {sptInstallInfo?.ok ? "확인됨" : "확인 필요"} + +
+
+ {sptInstallInfo?.path ?? "-"} + {sptInstallInfo?.source && ( + + ({sptInstallInfo.source === "auto" ? "자동" : "수동"}) + + )} +
+
+ + +
+
+ + {/* Mod Version Row */} +
+
+ 모드 버전 + {modStatusLabel} +
+
+ + {modVersionStatus?.currentTag ?? "-"} → {modVersionStatus?.latestTag ?? "-"} + + {modVersionStatus?.latestMinorTags && modVersionStatus.latestMinorTags.length > 0 && ( + + (+{modVersionStatus.latestMinorTags.length} 패치) + + )} +
+
+ + +
+ + {/* Hover menu logic handled by CSS usually, keeping simple for now */} +
+
+
+
+ + {/* Notices Area */} +
+ {modVersionStatus?.status === "majorMinorMismatch" && ( +
⚠️ 버전 불일치! 실행 시 자동 동기화됩니다.
+ )} + {modVersionStatus?.status === "patchMismatch" && ( +
ℹ️ 새 패치가 있습니다.
+ )} + {sptInstallMessage &&
{sptInstallMessage}
} +
+
+ +
+
+ 서버 + +
+
+ Git + +
+
+ LFS + +
+ +
+
+ )} + {screen !== "serverCheck" && toolsModalOpen && ( +
setToolsModalOpen(false)}> +
event.stopPropagation()} + > +
+

도구 상태

+ +
+
+
+ Git + +
+
+ Git LFS + +
+
+ + + +
+ {gitCheckResult && !(gitCheckResult.git.ok && gitCheckResult.lfs.ok) && ( +
+ 도구 설치 필요 +
+ Git/Git LFS가 감지되지 않았습니다. 자동 설치(Windows) 또는 수동 설치 후 재확인해주세요. +
+
+ )} + {installMessage &&
{installMessage}
} + {gitPathsResult && ( +
+ 경로 확인 +
+
Git: {gitPathsResult.git.ok ? gitPathsResult.git.path : gitPathsResult.git.error}
+
Git LFS: {gitPathsResult.lfs.ok ? gitPathsResult.lfs.path : gitPathsResult.lfs.error}
+
+
+ )} +
+
+
+ )} + + {(syncInProgress || syncResult) && ( +
+
e.stopPropagation()}> +
+

+ {syncResult + ? (syncResult.ok ? (modalTitle.replace("중...", "완료")) : (syncResult.error ? `오류: ${syncResult.error}` : "오류 발생")) + : modalTitle} +

+ {!syncInProgress && ( + + )} +
+
+ {syncProgress?.progress && ( +
+
+ 진행률(Progress) + {syncProgress.progress.percent}% +
+
+
+
+ {syncProgress.stepName && ( +
+ 현재 단계(Current Step): {getStepDisplayInfo(syncProgress.stepName).title} +
+ )} +
+ )} +
+ {syncSteps.map((step, idx) => { + const info = getStepDisplayInfo(step.name); + const stepClass = + step.status === "running" ? "running" : step.ok ? "ok" : "fail"; + const stepIcon = + step.status === "running" ? "⏳" : step.ok ? "✅" : "❌"; + return ( +
+ {idx + 1}. +
+
{info.title}
+
{info.command}
+ {!step.ok && step.message && ( +
오류(Error): {step.message}
+ )} +
+ {stepIcon} +
+ ); + })} + {syncInProgress &&
+ + 작업 진행 중... +
} +
+ {syncResult && ( +
+ {syncResult.ok ? "모드 적용이 완료되었습니다." : (syncResult.error ? `실패: ${syncResult.error}` : "오류가 발생했습니다.")} +
+ )} +
+ +
+
+ )} +
+ ); +}; + +export default App; diff --git a/src/renderer/App.tsx.patch b/src/renderer/App.tsx.patch new file mode 100644 index 0000000..160b058 --- /dev/null +++ b/src/renderer/App.tsx.patch @@ -0,0 +1,10 @@ +--- src/renderer/App.tsx ++++ src/renderer/App.tsx +@@ -49,6 +49,7 @@ + // MOCK For Browser testing since we hit CORS issues + if (window.location.protocol === 'http:') { + setServerStatus('ready'); ++ return; + } + + const result = await window.sptLauncher.checkServerVersion(); diff --git a/src/renderer/App.tsx.rej b/src/renderer/App.tsx.rej new file mode 100644 index 0000000..e43d142 --- /dev/null +++ b/src/renderer/App.tsx.rej @@ -0,0 +1,8 @@ +@@ -49,6 +49,7 @@ + // MOCK For Browser testing since we hit CORS issues + if (window.location.protocol === 'http:') { + setServerStatus('ready'); ++ return; + } + + const result = await window.sptLauncher.checkServerVersion(); diff --git a/src/renderer/vite-env.d.ts b/src/renderer/vite-env.d.ts index 7260a2e..ff4ca0d 100644 --- a/src/renderer/vite-env.d.ts +++ b/src/renderer/vite-env.d.ts @@ -121,7 +121,21 @@ interface Window { path?: string; error?: string; }>; - fetchProfile: (payload: { username: string }) => Promise<{ + login: (payload: { username: string; password?: string }) => Promise<{ + ok: boolean; + status?: number; + data?: unknown; + error?: string; + url: string; + }>; + register: (payload: { username: string; password?: string }) => Promise<{ + ok: boolean; + status?: number; + data?: unknown; + error?: string; + url: string; + }>; + fetchProfile: (payload: { username: string; password?: string }) => Promise<{ ok: boolean; status?: number; data?: unknown; diff --git a/src/shared/config.ts b/src/shared/config.ts index acb01b1..d528743 100644 --- a/src/shared/config.ts +++ b/src/shared/config.ts @@ -1,5 +1,5 @@ export const APP_CONFIG = { - serverBaseUrl: "https://pandoli365.com:5069", + serverBaseUrl: "https://127.0.0.1:6969", serverHealthcheckPath: "/launcher/ping", serverHealthcheckTimeoutMs: 2000, serverRequestTimeoutMs: 4000, diff --git a/test-auth-deflate.js b/test-auth-deflate.js new file mode 100644 index 0000000..a563807 --- /dev/null +++ b/test-auth-deflate.js @@ -0,0 +1,88 @@ +const https = require('https'); +const zlib = require('zlib'); + +const agent = new https.Agent({ rejectUnauthorized: false }); + +const makeRequest = (path, data, cookie) => { + return new Promise((resolve, reject) => { + const payload = JSON.stringify(data); + const deflated = zlib.deflateSync(payload); + + const headers = { + 'Content-Type': 'application/json', + 'Accept-Encoding': 'deflate', + 'Expect': '100-continue' + }; + if (cookie) { + headers['Cookie'] = cookie; + } + + const req = https.request({ + hostname: '127.0.0.1', + port: 6969, + path: path, + method: 'POST', + headers: headers, + agent + }, (res) => { + const chunks = []; + res.on('data', chunk => chunks.push(chunk)); + res.on('end', () => { + const buffer = Buffer.concat(chunks); + let body = ''; + if (buffer.length > 0) { + try { + body = zlib.inflateSync(buffer).toString(); + } catch (e) { + body = buffer.toString(); + } + } + resolve({ status: res.statusCode, body }); + }); + }); + + req.on('error', reject); + req.write(deflated); + req.end(); + }); +}; + +async function testFlow() { + const user = 'testuser' + Math.floor(Math.random() * 1000); + const pass = 'password123'; + + console.log(`Registering new user: ${user}`); + const regRes = await makeRequest('/launcher/profile/register', { + username: user, + password: pass, + edition: 'Standard' + }); + console.log("Registration Response:", regRes.status, regRes.body); + + if (regRes.status !== 200 || regRes.body === 'FAILED') { + console.log("Registration failed, stopping."); + return; + } + + console.log("\nLogging in with new user..."); + const loginRes = await makeRequest('/launcher/profile/login', { + username: user, + password: pass + }); + console.log("Login Response:", loginRes.status, loginRes.body); + + if (loginRes.status !== 200 || loginRes.body === 'FAILED') { + console.log("Login failed, stopping."); + return; + } + + const sessionId = loginRes.body; + + console.log("\nFetching profile info..."); + const infoRes = await makeRequest('/launcher/profile/info', { + username: user + }, `PHPSESSID=${sessionId}`); + console.log("Info Response:", infoRes.status, infoRes.body); +} + +testFlow().catch(console.error); diff --git a/test-auth.js b/test-auth.js new file mode 100644 index 0000000..d7fc989 --- /dev/null +++ b/test-auth.js @@ -0,0 +1,45 @@ +const http = require('http'); +const https = require('https'); +const zlib = require('zlib'); + +async function sendRequest(path, data) { + const payload = JSON.stringify(data); + + const options = { + hostname: '127.0.0.1', + port: 6969, + path: path, + method: 'POST', + rejectUnauthorized: false, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + 'Accept-Encoding': 'identity', + 'requestcompressed': '0', + 'responsecompressed': '0' + } + }; + + return new Promise((resolve, reject) => { + const req = https.request(options, (res) => { + let result = ''; + res.on('data', d => result += d); + res.on('end', () => resolve({ status: res.statusCode, body: result })); + }); + req.on('error', reject); + req.write(payload); // We pass uncompressed for now using requestcompressed: 0 + req.end(); + }); +} + +async function run() { + console.log("Registering..."); + const regRes = await sendRequest('/launcher/profile/register', { username: 'testuser3', password: 'testpassword3', edition: 'Standard' }); + console.log(regRes); + + console.log("Logging in..."); + const loginRes = await sendRequest('/launcher/profile/login', { username: 'testuser3', password: 'testpassword3' }); + console.log(loginRes); +} + +run(); diff --git a/test-signup-login.js b/test-signup-login.js new file mode 100644 index 0000000..8e4b83d --- /dev/null +++ b/test-signup-login.js @@ -0,0 +1,54 @@ +const https = require('https'); + +const agent = new https.Agent({ rejectUnauthorized: false }); + +const makeRequest = (path, data) => { + return new Promise((resolve, reject) => { + const payload = JSON.stringify(data); + const req = https.request({ + hostname: '127.0.0.1', + port: 6969, + path: path, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload) + }, + agent + }, (res) => { + let body = ''; + res.on('data', chunk => body += chunk); + res.on('end', () => resolve({ status: res.statusCode, body })); + }); + + req.on('error', reject); + req.write(payload); + req.end(); + }); +}; + +async function testFlow() { + console.log("Registering new user..."); + const regRes = await makeRequest('/launcher/profile/register', { + username: 'newuser123', + password: 'newpass123', + edition: 'Standard' + }); + console.log("Registration:", regRes); + + console.log("Logging in with new user..."); + const loginRes = await makeRequest('/launcher/profile/login', { + username: 'newuser123', + password: 'newpass123' + }); + console.log("Login:", loginRes); + const sessionId = loginRes.body; + + console.log("Fetching profile info..."); + const infoRes = await makeRequest('/launcher/profile/info', { + username: 'newuser123' + }); + console.log("Info:", infoRes); +} + +testFlow().catch(console.error);