Compare commits

...

2 Commits

5 changed files with 301 additions and 75 deletions

View File

@ -22,6 +22,7 @@ const SERVER_HEALTHCHECK_TIMEOUT_MS = APP_CONFIG.serverHealthcheckTimeoutMs;
const SERVER_REQUEST_TIMEOUT_MS = APP_CONFIG.serverRequestTimeoutMs; const SERVER_REQUEST_TIMEOUT_MS = APP_CONFIG.serverRequestTimeoutMs;
const COMMAND_TIMEOUT_MS = APP_CONFIG.commandTimeoutMs; const COMMAND_TIMEOUT_MS = APP_CONFIG.commandTimeoutMs;
const INSTALL_TIMEOUT_MS = APP_CONFIG.installTimeoutMs; const INSTALL_TIMEOUT_MS = APP_CONFIG.installTimeoutMs;
const SESSION_TTL_MS = APP_CONFIG.sessionTtlMs;
type CommandCheckResult = { type CommandCheckResult = {
ok: boolean; ok: boolean;
@ -71,6 +72,13 @@ type SptInstallInfo = {
error?: string; error?: string;
}; };
type SessionRecord = {
username: string;
sessionId: string;
expiresAt: number;
updatedAt: number;
};
type InstallStep = { type InstallStep = {
name: string; name: string;
result: CommandRunResult; result: CommandRunResult;
@ -224,11 +232,63 @@ const getGitPaths = async () => {
}; };
const MOD_VERSION_FILE_NAME = "mod-version.json"; const MOD_VERSION_FILE_NAME = "mod-version.json";
const SESSION_FILE_NAME = "session.json";
const getModVersionFilePath = () => { const getModVersionFilePath = () => {
return path.join(app.getPath("userData"), MOD_VERSION_FILE_NAME); 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 readModVersionRecord = async () => {
const filePath = getModVersionFilePath(); const filePath = getModVersionFilePath();
try { try {
@ -890,6 +950,28 @@ const requestSessionId = async (username: string) => {
return { ok: true, sessionId, url: loginResult.url }; 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) => { const registerProfile = async (username: string) => {
return await postJson("/launcher/profile/register", { return await postJson("/launcher/profile/register", {
username, username,
@ -1035,6 +1117,7 @@ app.whenReady().then(() => {
ipcMain.handle("spt:fetchProfile", async (_event, payload: { username: string }) => { ipcMain.handle("spt:fetchProfile", async (_event, payload: { username: string }) => {
const username = payload.username.trim(); const username = payload.username.trim();
let activeSessionId: string | undefined;
const fetchProfileInfo = async (): Promise<{ const fetchProfileInfo = async (): Promise<{
ok: boolean; ok: boolean;
@ -1043,7 +1126,7 @@ app.whenReady().then(() => {
error?: string; error?: string;
url: string; url: string;
}> => { }> => {
const sessionResult = await requestSessionId(username); const sessionResult = await getSessionIdForUser(username);
if (!sessionResult.ok) { if (!sessionResult.ok) {
return { return {
ok: false, ok: false,
@ -1051,6 +1134,7 @@ app.whenReady().then(() => {
url: sessionResult.url url: sessionResult.url
}; };
} }
activeSessionId = sessionResult.sessionId;
return await postJson( return await postJson(
"/launcher/profile/info", "/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 (infoResult.ok && infoResult.data) {
if (activeSessionId) {
await refreshSessionRecord({ username, sessionId: activeSessionId });
}
return infoResult; return infoResult;
} }
@ -1073,12 +1179,13 @@ app.whenReady().then(() => {
ipcMain.handle("spt:downloadProfile", async (_event, payload: { username: string }) => { ipcMain.handle("spt:downloadProfile", async (_event, payload: { username: string }) => {
const username = payload.username.trim(); const username = payload.username.trim();
const sessionResult = await requestSessionId(username); const sessionResult = await getSessionIdForUser(username);
if (!sessionResult.ok) { if (!sessionResult.ok) {
return sessionResult; return sessionResult;
} }
const infoResult = await postJson( let activeSessionId = sessionResult.sessionId;
let infoResult = await postJson(
"/launcher/profile/info", "/launcher/profile/info",
{ username }, { 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) { if (!infoResult.ok || !infoResult.data) {
return infoResult; return infoResult;
} }
await refreshSessionRecord({ username, sessionId: activeSessionId });
const downloadsDir = app.getPath("downloads"); const downloadsDir = app.getPath("downloads");
const fileName = `spt-profile-${username}-${Date.now()}.json`; const fileName = `spt-profile-${username}-${Date.now()}.json`;
const filePath = path.join(downloadsDir, fileName); const filePath = path.join(downloadsDir, fileName);
@ -1107,18 +1232,71 @@ app.whenReady().then(() => {
ipcMain.handle("spt:resetProfile", async (_event, payload: { username: string }) => { ipcMain.handle("spt:resetProfile", async (_event, payload: { username: string }) => {
const username = payload.username.trim(); const username = payload.username.trim();
const sessionResult = await requestSessionId(username); const sessionResult = await getSessionIdForUser(username);
if (!sessionResult.ok) { if (!sessionResult.ok) {
return sessionResult; return sessionResult;
} }
return await postJson( let activeSessionId = sessionResult.sessionId;
let wipeResult = await postJson(
"/launcher/profile/change/wipe", "/launcher/profile/change/wipe",
{ username }, { username },
{ {
Cookie: `PHPSESSID=${sessionResult.sessionId}` 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) 로그를 남겨 // 개발/운영 모두에서 부팅 시 1회 즉시 체크(instant check) 로그를 남겨

View File

@ -22,5 +22,7 @@ contextBridge.exposeInMainWorld("sptLauncher", {
downloadProfile: (payload: { username: string }) => downloadProfile: (payload: { username: string }) =>
ipcRenderer.invoke("spt:downloadProfile", payload), ipcRenderer.invoke("spt:downloadProfile", payload),
resetProfile: (payload: { username: string }) => resetProfile: (payload: { username: string }) =>
ipcRenderer.invoke("spt:resetProfile", payload) ipcRenderer.invoke("spt:resetProfile", payload),
resumeSession: () => ipcRenderer.invoke("spt:resumeSession"),
clearSession: () => ipcRenderer.invoke("spt:clearSession")
}); });

View File

@ -97,9 +97,56 @@ const App = () => {
const gitCheckedRef = useRef(false); const gitCheckedRef = useRef(false);
const modCheckInFlightRef = useRef(false); const modCheckInFlightRef = useRef(false);
const modCheckedRef = useRef(false); const modCheckedRef = useRef(false);
const sessionResumeAttemptedRef = useRef(false);
const transitionTimeoutRef = useRef<number | undefined>(); const transitionTimeoutRef = useRef<number | undefined>();
const simulateToolsMissingRef = useRef(false); 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) => { const runGitCheck = useCallback(async (force = false) => {
if (force) { if (force) {
gitCheckedRef.current = false; gitCheckedRef.current = false;
@ -113,7 +160,7 @@ const App = () => {
setGitCheckInProgress(true); setGitCheckInProgress(true);
try { try {
if (IS_DEV && simulateToolsMissingRef.current) { if (DEV_UI_ENABLED && simulateToolsMissingRef.current) {
const simulated = { const simulated = {
git: { ok: false, command: "git", error: "simulated_missing" }, git: { ok: false, command: "git", error: "simulated_missing" },
lfs: { ok: false, command: "git lfs", error: "simulated_missing" }, lfs: { ok: false, command: "git lfs", error: "simulated_missing" },
@ -255,6 +302,7 @@ const App = () => {
setProfile(undefined); setProfile(undefined);
setSkipToolsCheck(false); setSkipToolsCheck(false);
setDevProceedReady(false); setDevProceedReady(false);
sessionResumeAttemptedRef.current = false;
setScreen("serverCheck"); setScreen("serverCheck");
clearTransitionTimeout(); clearTransitionTimeout();
}, },
@ -303,7 +351,7 @@ const App = () => {
if (serverRecoveryActive) { if (serverRecoveryActive) {
setServerRecoveryActive(false); setServerRecoveryActive(false);
if (IS_DEV) { if (DEV_UI_ENABLED) {
setDevProceedReady(true); setDevProceedReady(true);
} else { } else {
scheduleScreenTransition("login"); scheduleScreenTransition("login");
@ -315,7 +363,7 @@ const App = () => {
return; return;
} }
if (IS_DEV) { if (DEV_UI_ENABLED) {
setDevProceedReady(true); setDevProceedReady(true);
return; return;
} }
@ -364,6 +412,50 @@ const App = () => {
void runHealthCheck(serverRecoveryActive ? "recovery" : "initial"); void runHealthCheck(serverRecoveryActive ? "recovery" : "initial");
}, [screen, serverRecoveryActive, runHealthCheck]); }, [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(() => { useEffect(() => {
if (screen !== "serverCheck") { if (screen !== "serverCheck") {
return; 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 handleLogin = async () => {
const trimmedId = loginId.trim(); const trimmedId = loginId.trim();
if (!trimmedId) { if (!trimmedId) {
@ -630,6 +676,9 @@ const App = () => {
}; };
const handleLogout = () => { const handleLogout = () => {
if (window.sptLauncher?.clearSession) {
void window.sptLauncher.clearSession();
}
setHasSession(false); setHasSession(false);
setProfile(undefined); setProfile(undefined);
setScreen("login"); setScreen("login");
@ -870,7 +919,9 @@ const App = () => {
className="ghost" className="ghost"
onClick={() => { onClick={() => {
setSkipToolsCheck(true); setSkipToolsCheck(true);
scheduleScreenTransition(IS_DEV ? "login" : hasSession ? "main" : "login"); scheduleScreenTransition(
DEV_UI_ENABLED ? "login" : hasSession ? "main" : "login"
);
}} }}
> >
@ -878,7 +929,7 @@ const App = () => {
</div> </div>
</div> </div>
)} )}
{IS_DEV && {DEV_UI_ENABLED &&
serverHealthy && serverHealthy &&
(skipToolsCheck || (skipToolsCheck ||
Boolean(gitCheckResult?.git.ok && gitCheckResult?.lfs.ok)) && ( Boolean(gitCheckResult?.git.ok && gitCheckResult?.lfs.ok)) && (
@ -894,7 +945,7 @@ const App = () => {
</button> </button>
)} )}
{IS_DEV && ( {DEV_UI_ENABLED && (
<button <button
type="button" type="button"
className="ghost" className="ghost"
@ -950,22 +1001,6 @@ const App = () => {
> >
</button> </button>
<button
type="button"
className="ghost"
onClick={() => {
const fallbackId = loginId.trim() || "demo";
setProfile({
username: fallbackId,
nickname: "Demo",
level: 1
});
setHasSession(true);
setScreen("main");
}}
>
()
</button>
</div> </div>
</section> </section>
)} )}

View File

@ -140,6 +140,16 @@ interface Window {
error?: string; error?: string;
url: string; url: string;
}>; }>;
resumeSession: () => Promise<{
ok: boolean;
status?: number;
data?: unknown;
error?: string;
url: string;
}>;
clearSession: () => Promise<{
ok: boolean;
}>;
}; };
} }

View File

@ -3,6 +3,7 @@ export const APP_CONFIG = {
serverHealthcheckPath: "/launcher/ping", serverHealthcheckPath: "/launcher/ping",
serverHealthcheckTimeoutMs: 2000, serverHealthcheckTimeoutMs: 2000,
serverRequestTimeoutMs: 4000, serverRequestTimeoutMs: 4000,
sessionTtlMs: 7 * 24 * 60 * 60 * 1000,
commandTimeoutMs: 4000, commandTimeoutMs: 4000,
installTimeoutMs: 10 * 60 * 1000, installTimeoutMs: 10 * 60 * 1000,
healthcheckIntervalMs: 10000, healthcheckIntervalMs: 10000,