Add session management functionality. Introduced IPC handlers for resuming and clearing user sessions, along with session record handling in the main process. Updated the renderer to support session resumption on app startup and clear session on logout, enhancing user experience and session continuity.
This commit is contained in:
parent
03a3f85974
commit
18dcf789c8
190
src/main/main.ts
190
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<SessionRecord | null> => {
|
||||
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) 로그를 남겨
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<number | undefined>();
|
||||
const simulateToolsMissingRef = useRef(false);
|
||||
|
||||
const asObject = (value: unknown) =>
|
||||
value && typeof value === "object" ? (value as Record<string, unknown>) : 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<string, unknown>) : 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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue