Compare commits

..

No commits in common. "18dcf789c88e636bfcb04da5eb5801325cdbd078" and "9f7244185d74b4f09eeeff1005e096366177f840" have entirely different histories.

5 changed files with 75 additions and 301 deletions

View File

@ -22,7 +22,6 @@ 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;
@ -72,13 +71,6 @@ 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;
@ -232,63 +224,11 @@ 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 {
@ -950,28 +890,6 @@ 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,
@ -1117,7 +1035,6 @@ 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;
@ -1126,7 +1043,7 @@ app.whenReady().then(() => {
error?: string; error?: string;
url: string; url: string;
}> => { }> => {
const sessionResult = await getSessionIdForUser(username); const sessionResult = await requestSessionId(username);
if (!sessionResult.ok) { if (!sessionResult.ok) {
return { return {
ok: false, ok: false,
@ -1134,7 +1051,6 @@ app.whenReady().then(() => {
url: sessionResult.url url: sessionResult.url
}; };
} }
activeSessionId = sessionResult.sessionId;
return await postJson( return await postJson(
"/launcher/profile/info", "/launcher/profile/info",
@ -1145,30 +1061,8 @@ app.whenReady().then(() => {
); );
}; };
let infoResult = await fetchProfileInfo(); const 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;
} }
@ -1179,13 +1073,12 @@ 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 getSessionIdForUser(username); const sessionResult = await requestSessionId(username);
if (!sessionResult.ok) { if (!sessionResult.ok) {
return sessionResult; return sessionResult;
} }
let activeSessionId = sessionResult.sessionId; const infoResult = await postJson(
let infoResult = await postJson(
"/launcher/profile/info", "/launcher/profile/info",
{ username }, { username },
{ {
@ -1193,28 +1086,10 @@ 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);
@ -1232,71 +1107,18 @@ 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 getSessionIdForUser(username); const sessionResult = await requestSessionId(username);
if (!sessionResult.ok) { if (!sessionResult.ok) {
return sessionResult; return sessionResult;
} }
let activeSessionId = sessionResult.sessionId; return await postJson(
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,7 +22,5 @@ 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,56 +97,9 @@ 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;
@ -160,7 +113,7 @@ const App = () => {
setGitCheckInProgress(true); setGitCheckInProgress(true);
try { try {
if (DEV_UI_ENABLED && simulateToolsMissingRef.current) { if (IS_DEV && 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" },
@ -302,7 +255,6 @@ const App = () => {
setProfile(undefined); setProfile(undefined);
setSkipToolsCheck(false); setSkipToolsCheck(false);
setDevProceedReady(false); setDevProceedReady(false);
sessionResumeAttemptedRef.current = false;
setScreen("serverCheck"); setScreen("serverCheck");
clearTransitionTimeout(); clearTransitionTimeout();
}, },
@ -351,7 +303,7 @@ const App = () => {
if (serverRecoveryActive) { if (serverRecoveryActive) {
setServerRecoveryActive(false); setServerRecoveryActive(false);
if (DEV_UI_ENABLED) { if (IS_DEV) {
setDevProceedReady(true); setDevProceedReady(true);
} else { } else {
scheduleScreenTransition("login"); scheduleScreenTransition("login");
@ -363,7 +315,7 @@ const App = () => {
return; return;
} }
if (DEV_UI_ENABLED) { if (IS_DEV) {
setDevProceedReady(true); setDevProceedReady(true);
return; return;
} }
@ -412,50 +364,6 @@ 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;
@ -618,6 +526,52 @@ 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) {
@ -676,9 +630,6 @@ 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");
@ -919,9 +870,7 @@ const App = () => {
className="ghost" className="ghost"
onClick={() => { onClick={() => {
setSkipToolsCheck(true); setSkipToolsCheck(true);
scheduleScreenTransition( scheduleScreenTransition(IS_DEV ? "login" : hasSession ? "main" : "login");
DEV_UI_ENABLED ? "login" : hasSession ? "main" : "login"
);
}} }}
> >
@ -929,7 +878,7 @@ const App = () => {
</div> </div>
</div> </div>
)} )}
{DEV_UI_ENABLED && {IS_DEV &&
serverHealthy && serverHealthy &&
(skipToolsCheck || (skipToolsCheck ||
Boolean(gitCheckResult?.git.ok && gitCheckResult?.lfs.ok)) && ( Boolean(gitCheckResult?.git.ok && gitCheckResult?.lfs.ok)) && (
@ -945,7 +894,7 @@ const App = () => {
</button> </button>
)} )}
{DEV_UI_ENABLED && ( {IS_DEV && (
<button <button
type="button" type="button"
className="ghost" className="ghost"
@ -1001,6 +950,22 @@ 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,16 +140,6 @@ 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,7 +3,6 @@ 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,