Add profile fetching functionality and improve UI responsiveness. Implemented profile retrieval in the main process, updated the renderer to handle profile data, and enhanced styles for better layout and user experience.

This commit is contained in:
이정수 2026-01-28 15:40:09 +09:00
parent 9a88d44769
commit 7cfd513b73
5 changed files with 323 additions and 11 deletions

View File

@ -16,6 +16,7 @@ type ServerHealthResult = {
const SERVER_BASE_URL = "https://pandoli365.com:5069/";
const SERVER_HEALTHCHECK_PATH = "/launcher/ping";
const SERVER_HEALTHCHECK_TIMEOUT_MS = 2000;
const SERVER_REQUEST_TIMEOUT_MS = 4000;
const checkServerHealth = async (): Promise<ServerHealthResult> => {
const startedAt = Date.now();
@ -76,6 +77,151 @@ const checkServerHealth = async (): Promise<ServerHealthResult> => {
});
};
const postJson = async <T>(path: string, body: unknown) => {
const url = new URL(path, SERVER_BASE_URL).toString();
const payload = JSON.stringify(body);
return await new Promise<{
ok: boolean;
status?: number;
data?: T;
error?: string;
url: string;
}>((resolve) => {
const request = net.request({
method: "POST",
url,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"Accept-Encoding": "identity",
"Content-Encoding": "identity",
"Content-Length": Buffer.byteLength(payload).toString()
}
});
const timeout = setTimeout(() => {
request.abort();
resolve({
ok: false,
error: "timeout",
url
});
}, SERVER_REQUEST_TIMEOUT_MS);
request.on("response", (response) => {
let rawData = "";
response.on("data", (chunk) => {
rawData += chunk.toString("utf8");
});
response.on("end", () => {
clearTimeout(timeout);
const status = response.statusCode;
const ok = typeof status === "number" ? status >= 200 && status < 400 : false;
if (!rawData) {
resolve({ ok, status, url });
return;
}
try {
const parsed = JSON.parse(rawData) as T;
resolve({ ok, status, data: parsed, url });
} catch (error) {
const message = error instanceof Error ? error.message : "invalid_json";
resolve({ ok: false, status, error: message, url });
}
});
});
request.on("error", (error) => {
clearTimeout(timeout);
const message = error instanceof Error ? error.message : String(error);
resolve({
ok: false,
error: message,
url
});
});
request.write(payload);
request.end();
});
};
const getJson = async <T>(path: string) => {
const url = new URL(path, SERVER_BASE_URL).toString();
return await new Promise<{
ok: boolean;
status?: number;
data?: T;
error?: string;
url: string;
}>((resolve) => {
const request = net.request({
method: "GET",
url,
headers: {
Accept: "application/json",
"Accept-Encoding": "identity",
responsecompressed: "0"
}
});
const timeout = setTimeout(() => {
request.abort();
resolve({
ok: false,
error: "timeout",
url
});
}, SERVER_REQUEST_TIMEOUT_MS);
request.on("response", (response) => {
let rawData = "";
response.on("data", (chunk) => {
rawData += chunk.toString("utf8");
});
response.on("end", () => {
clearTimeout(timeout);
const status = response.statusCode;
const ok = typeof status === "number" ? status >= 200 && status < 400 : false;
if (!rawData) {
resolve({ ok, status, url });
return;
}
try {
const parsed = JSON.parse(rawData) as T;
resolve({ ok, status, data: parsed, url });
} catch (error) {
const message = error instanceof Error ? error.message : "invalid_json";
resolve({ ok: false, status, error: message, url });
}
});
});
request.on("error", (error) => {
clearTimeout(timeout);
const message = error instanceof Error ? error.message : String(error);
resolve({
ok: false,
error: message,
url
});
});
request.end();
});
};
const createWindow = () => {
const mainWindow = new BrowserWindow({
width: 1100,
@ -123,6 +269,10 @@ app.whenReady().then(() => {
return await checkServerHealth();
});
ipcMain.handle("spt:fetchProfiles", async () => {
return await getJson("/launcher/profiles");
});
// 개발/운영 모두에서 부팅 시 1회 즉시 체크(instant check) 로그를 남겨
// 엔드포인트(endpoint) 확인이 가능하도록 합니다.
void checkServerHealth();

View File

@ -4,5 +4,6 @@ console.info("[spt-launcher] preload loaded");
contextBridge.exposeInMainWorld("sptLauncher", {
appName: "SPT Launcher",
checkServerHealth: () => ipcRenderer.invoke("spt:checkServerHealth")
checkServerHealth: () => ipcRenderer.invoke("spt:checkServerHealth"),
fetchProfiles: () => ipcRenderer.invoke("spt:fetchProfiles")
});

View File

@ -15,6 +15,16 @@ const App = () => {
const [serverCheckInProgress, setServerCheckInProgress] = useState(false);
const [serverCheckedAt, setServerCheckedAt] = useState<number | undefined>();
const [hasSession, setHasSession] = useState(false);
const [loginId, setLoginId] = useState("");
const [loginPassword, setLoginPassword] = useState("");
const [profileLoading, setProfileLoading] = useState(false);
const [profile, setProfile] = useState<{
username: string;
nickname?: string;
level?: number;
side?: string;
id?: string;
} | undefined>();
const [syncInProgress, setSyncInProgress] = useState(false);
const [simulateSyncFail, setSimulateSyncFail] = useState(false);
const healthCheckInFlightRef = useRef(false);
@ -78,9 +88,120 @@ const App = () => {
return serverHealthy ? "정상" : "불가";
}, [serverHealthy]);
const handleLogin = () => {
setHasSession(true);
setScreen("main");
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 extractProfileList = (data: unknown) => {
if (Array.isArray(data)) {
return data;
}
const root = asObject(data);
if (Array.isArray(root?.profiles)) {
return root?.profiles;
}
if (Array.isArray(root?.data)) {
return root?.data;
}
return [];
};
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, profileObject.level, profileObject.Level),
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) {
window.alert("아이디를 입력해주세요.");
return;
}
if (!window.sptLauncher?.fetchProfiles) {
window.alert("프로필 목록 조회 기능이 준비되지 않았습니다.");
return;
}
setProfileLoading(true);
try {
const result = await window.sptLauncher.fetchProfiles();
if (!result.ok) {
window.alert(result.error ?? "프로필 목록 조회에 실패했습니다.");
return;
}
if (!result.data) {
window.alert("프로필 목록 응답이 비어있습니다.");
return;
}
const profiles = extractProfileList(result.data);
if (profiles.length === 0) {
window.alert("프로필 목록이 비어있습니다.");
return;
}
const normalizedId = trimmedId.toLowerCase();
const matched = profiles.find((profileEntry) => {
const profileObject = asObject(profileEntry) ?? {};
const info = asObject(profileObject.info ?? profileObject.Info) ?? profileObject;
const username = pickString(
info.username,
info.Username,
profileObject.username,
profileObject.Username,
info.nickname,
profileObject.nickname
);
return username?.toLowerCase() === normalizedId;
});
if (!matched) {
window.alert("입력한 아이디와 일치하는 프로필이 없습니다.");
return;
}
setProfile(extractProfile(matched, trimmedId));
setHasSession(true);
setScreen("main");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
window.alert(message);
} finally {
setProfileLoading(false);
}
};
const handleSignup = () => {
@ -95,6 +216,7 @@ const App = () => {
const handleLogout = () => {
setHasSession(false);
setProfile(undefined);
setScreen("login");
};
@ -162,11 +284,21 @@ const App = () => {
<h2></h2>
<p className="status idle"> </p>
<div className="login-form">
<input type="text" placeholder="아이디" />
<input type="password" placeholder="비밀번호" />
<input
type="text"
placeholder="아이디"
value={loginId}
onChange={(event) => setLoginId(event.target.value)}
/>
<input
type="password"
placeholder="비밀번호"
value={loginPassword}
onChange={(event) => setLoginPassword(event.target.value)}
/>
</div>
<button type="button" onClick={handleLogin}>
<button type="button" onClick={handleLogin} disabled={profileLoading}>
{profileLoading ? "프로필 조회 중..." : "로그인"}
</button>
<div className="link-row">
<button
@ -186,7 +318,16 @@ const App = () => {
<button
type="button"
className="ghost"
onClick={() => setHasSession(true)}
onClick={() => {
const fallbackId = loginId.trim() || "demo";
setProfile({
username: fallbackId,
nickname: "Demo",
level: 1
});
setHasSession(true);
setScreen("main");
}}
>
()
</button>
@ -239,8 +380,10 @@ const App = () => {
<section className="card profile">
<div>
<h2> </h2>
<p className="muted">닉네임: Pandoli</p>
<p className="muted">레벨: 45</p>
<p className="muted">: {profile?.username ?? "-"}</p>
<p className="muted">: {profile?.nickname ?? "미등록"}</p>
<p className="muted">: {typeof profile?.level === "number" ? profile.level : "-"}</p>
{profile?.side && <p className="muted">: {profile.side}</p>}
</div>
<div className="profile-actions">
<button type="button"> </button>

View File

@ -11,6 +11,9 @@ body {
.app {
padding: 20px;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app-header {
@ -98,6 +101,8 @@ button:disabled {
.hero {
max-width: 460px;
width: 100%;
margin: 0 auto;
}
.helper {
@ -125,6 +130,12 @@ button:disabled {
border-radius: 8px;
}
.notice.error {
color: #f2a7a7;
background: #2a1f24;
border-color: #4b2a34;
}
.main-layout {
display: grid;
gap: 12px;

View File

@ -10,5 +10,12 @@ interface Window {
error?: string;
url?: string;
}>;
fetchProfiles: () => Promise<{
ok: boolean;
status?: number;
data?: unknown;
error?: string;
url: string;
}>;
};
}