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:
parent
9a88d44769
commit
7cfd513b73
150
src/main/main.ts
150
src/main/main.ts
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -10,5 +10,12 @@ interface Window {
|
|||
error?: string;
|
||||
url?: string;
|
||||
}>;
|
||||
fetchProfiles: () => Promise<{
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
url: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue