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; + }>; }; }