diff --git a/src/main/main.ts b/src/main/main.ts index dbe2925..09f28f6 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -22,6 +22,7 @@ const SERVER_HEALTHCHECK_TIMEOUT_MS = APP_CONFIG.serverHealthcheckTimeoutMs; const SERVER_REQUEST_TIMEOUT_MS = APP_CONFIG.serverRequestTimeoutMs; const COMMAND_TIMEOUT_MS = APP_CONFIG.commandTimeoutMs; const INSTALL_TIMEOUT_MS = APP_CONFIG.installTimeoutMs; +const SESSION_TTL_MS = APP_CONFIG.sessionTtlMs; type CommandCheckResult = { ok: boolean; @@ -71,6 +72,13 @@ type SptInstallInfo = { error?: string; }; +type SessionRecord = { + username: string; + sessionId: string; + expiresAt: number; + updatedAt: number; +}; + type InstallStep = { name: string; result: CommandRunResult; @@ -224,11 +232,63 @@ const getGitPaths = async () => { }; const MOD_VERSION_FILE_NAME = "mod-version.json"; +const SESSION_FILE_NAME = "session.json"; const getModVersionFilePath = () => { return path.join(app.getPath("userData"), MOD_VERSION_FILE_NAME); }; +const getSessionRecordPath = () => { + return path.join(app.getPath("userData"), SESSION_FILE_NAME); +}; + +const readSessionRecord = async (): Promise => { + const filePath = getSessionRecordPath(); + try { + const raw = await fs.readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as SessionRecord; + if ( + typeof parsed.username !== "string" || + typeof parsed.sessionId !== "string" || + typeof parsed.expiresAt !== "number" || + typeof parsed.updatedAt !== "number" + ) { + return null; + } + return parsed; + } catch { + return null; + } +}; + +const writeSessionRecord = async (payload: { username: string; sessionId: string }) => { + const filePath = getSessionRecordPath(); + const record: SessionRecord = { + username: payload.username, + sessionId: payload.sessionId, + updatedAt: Date.now(), + expiresAt: Date.now() + SESSION_TTL_MS + }; + await fs.writeFile(filePath, JSON.stringify(record, null, 2), "utf8"); + return record; +}; + +const refreshSessionRecord = async (payload: { username: string; sessionId: string }) => { + return await writeSessionRecord(payload); +}; + +const clearSessionRecord = async () => { + const filePath = getSessionRecordPath(); + await fs.rm(filePath, { force: true }); +}; + +const isSessionExpired = (record: SessionRecord | null) => { + if (!record) { + return true; + } + return record.expiresAt <= Date.now(); +}; + const readModVersionRecord = async () => { const filePath = getModVersionFilePath(); try { @@ -890,6 +950,28 @@ const requestSessionId = async (username: string) => { return { ok: true, sessionId, url: loginResult.url }; }; +const isSessionInvalidStatus = (status?: number) => status === 401 || status === 403; + +const getSessionIdForUser = async (username: string) => { + const cached = await readSessionRecord(); + if (cached && cached.username === username && !isSessionExpired(cached)) { + await refreshSessionRecord({ username: cached.username, sessionId: cached.sessionId }); + return { ok: true, sessionId: cached.sessionId, url: SERVER_BASE_URL, source: "cache" as const }; + } + + if (cached && cached.username !== username) { + await clearSessionRecord(); + } + + const loginResult = await requestSessionId(username); + if (!loginResult.ok) { + return loginResult; + } + + await writeSessionRecord({ username, sessionId: loginResult.sessionId }); + return { ok: true, sessionId: loginResult.sessionId, url: loginResult.url, source: "login" as const }; +}; + const registerProfile = async (username: string) => { return await postJson("/launcher/profile/register", { username, @@ -1035,6 +1117,7 @@ app.whenReady().then(() => { ipcMain.handle("spt:fetchProfile", async (_event, payload: { username: string }) => { const username = payload.username.trim(); + let activeSessionId: string | undefined; const fetchProfileInfo = async (): Promise<{ ok: boolean; @@ -1043,7 +1126,7 @@ app.whenReady().then(() => { error?: string; url: string; }> => { - const sessionResult = await requestSessionId(username); + const sessionResult = await getSessionIdForUser(username); if (!sessionResult.ok) { return { ok: false, @@ -1051,6 +1134,7 @@ app.whenReady().then(() => { url: sessionResult.url }; } + activeSessionId = sessionResult.sessionId; return await postJson( "/launcher/profile/info", @@ -1061,8 +1145,30 @@ app.whenReady().then(() => { ); }; - const infoResult = await fetchProfileInfo(); + 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 }); + } return infoResult; } @@ -1073,12 +1179,13 @@ app.whenReady().then(() => { ipcMain.handle("spt:downloadProfile", async (_event, payload: { username: string }) => { const username = payload.username.trim(); - const sessionResult = await requestSessionId(username); + const sessionResult = await getSessionIdForUser(username); if (!sessionResult.ok) { return sessionResult; } - const infoResult = await postJson( + let activeSessionId = sessionResult.sessionId; + let infoResult = await postJson( "/launcher/profile/info", { username }, { @@ -1086,10 +1193,28 @@ app.whenReady().then(() => { } ); + 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); @@ -1107,18 +1232,71 @@ app.whenReady().then(() => { ipcMain.handle("spt:resetProfile", async (_event, payload: { username: string }) => { const username = payload.username.trim(); - const sessionResult = await requestSessionId(username); + const sessionResult = await getSessionIdForUser(username); if (!sessionResult.ok) { return sessionResult; } - return await postJson( + 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: true }; }); // 개발/운영 모두에서 부팅 시 1회 즉시 체크(instant check) 로그를 남겨 diff --git a/src/preload/preload.ts b/src/preload/preload.ts index e5f40f2..25306d5 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -22,5 +22,7 @@ contextBridge.exposeInMainWorld("sptLauncher", { downloadProfile: (payload: { username: string }) => ipcRenderer.invoke("spt:downloadProfile", payload), resetProfile: (payload: { username: string }) => - ipcRenderer.invoke("spt:resetProfile", payload) + ipcRenderer.invoke("spt:resetProfile", payload), + resumeSession: () => ipcRenderer.invoke("spt:resumeSession"), + clearSession: () => ipcRenderer.invoke("spt:clearSession") }); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d8b17b8..bee50b2 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -97,9 +97,56 @@ const App = () => { const gitCheckedRef = useRef(false); const modCheckInFlightRef = useRef(false); const modCheckedRef = useRef(false); + const sessionResumeAttemptedRef = useRef(false); const transitionTimeoutRef = useRef(); const simulateToolsMissingRef = useRef(false); + 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; @@ -255,6 +302,7 @@ const App = () => { setProfile(undefined); setSkipToolsCheck(false); setDevProceedReady(false); + sessionResumeAttemptedRef.current = false; setScreen("serverCheck"); clearTransitionTimeout(); }, @@ -364,6 +412,50 @@ const App = () => { 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 () => { + const result = await window.sptLauncher.resumeSession(); + if (!result.ok || !result.data) { + return; + } + const resumedProfile = extractProfile(result.data, loginId.trim()); + setProfile(resumedProfile); + setHasSession(true); + if (resumedProfile.username) { + setLoginId(resumedProfile.username); + } + clearTransitionTimeout(); + setScreen("main"); + })(); + }, [ + screen, + serverHealthy, + hasSession, + serverCheckInProgress, + serverCheckedAt, + loginId, + clearTransitionTimeout, + extractProfile + ]); + useEffect(() => { if (screen !== "serverCheck") { return; @@ -526,52 +618,6 @@ const App = () => { } }; - 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 handleLogin = async () => { const trimmedId = loginId.trim(); if (!trimmedId) { @@ -630,6 +676,9 @@ const App = () => { }; const handleLogout = () => { + if (window.sptLauncher?.clearSession) { + void window.sptLauncher.clearSession(); + } setHasSession(false); setProfile(undefined); setScreen("login"); diff --git a/src/renderer/vite-env.d.ts b/src/renderer/vite-env.d.ts index a919b09..ebe6bad 100644 --- a/src/renderer/vite-env.d.ts +++ b/src/renderer/vite-env.d.ts @@ -140,6 +140,16 @@ interface Window { error?: string; url: string; }>; + resumeSession: () => Promise<{ + ok: boolean; + status?: number; + data?: unknown; + error?: string; + url: string; + }>; + clearSession: () => Promise<{ + ok: boolean; + }>; }; } diff --git a/src/shared/config.ts b/src/shared/config.ts index f1d6bf4..c7d5525 100644 --- a/src/shared/config.ts +++ b/src/shared/config.ts @@ -3,6 +3,7 @@ export const APP_CONFIG = { serverHealthcheckPath: "/launcher/ping", serverHealthcheckTimeoutMs: 2000, serverRequestTimeoutMs: 4000, + sessionTtlMs: 7 * 24 * 60 * 60 * 1000, commandTimeoutMs: 4000, installTimeoutMs: 10 * 60 * 1000, healthcheckIntervalMs: 10000,