From 7cfd513b7341c98edc1be5bc6f77d742b1a04ad0 Mon Sep 17 00:00:00 2001 From: art Date: Wed, 28 Jan 2026 15:40:09 +0900 Subject: [PATCH] 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. --- src/main/main.ts | 150 ++++++++++++++++++++++++++++++++++ src/preload/preload.ts | 3 +- src/renderer/App.tsx | 163 ++++++++++++++++++++++++++++++++++--- src/renderer/styles.css | 11 +++ src/renderer/vite-env.d.ts | 7 ++ 5 files changed, 323 insertions(+), 11 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 43564f3..8eddc65 100644 --- a/src/main/main.ts +++ b/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 => { const startedAt = Date.now(); @@ -76,6 +77,151 @@ const checkServerHealth = async (): Promise => { }); }; +const postJson = async (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 (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(); diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 9afc7bf..aa99685 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -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") }); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3ecd04d..75df382 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -15,6 +15,16 @@ const App = () => { const [serverCheckInProgress, setServerCheckInProgress] = useState(false); const [serverCheckedAt, setServerCheckedAt] = useState(); 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) : 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 = () => {

로그인

로그인 필요

- - + setLoginId(event.target.value)} + /> + setLoginPassword(event.target.value)} + />
-
@@ -239,8 +380,10 @@ const App = () => {

프로필 정보

-

닉네임: Pandoli

-

레벨: 45

+

아이디: {profile?.username ?? "-"}

+

닉네임: {profile?.nickname ?? "미등록"}

+

레벨: {typeof profile?.level === "number" ? profile.level : "-"}

+ {profile?.side &&

진영: {profile.side}

}
diff --git a/src/renderer/styles.css b/src/renderer/styles.css index dee2781..4610836 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -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; diff --git a/src/renderer/vite-env.d.ts b/src/renderer/vite-env.d.ts index e0a4e9d..1a2c342 100644 --- a/src/renderer/vite-env.d.ts +++ b/src/renderer/vite-env.d.ts @@ -10,5 +10,12 @@ interface Window { error?: string; url?: string; }>; + fetchProfiles: () => Promise<{ + ok: boolean; + status?: number; + data?: unknown; + error?: string; + url: string; + }>; }; }