feat: 사용자 인증(Authentication)을 위한 로그인(Login) 및 회원가입(Signup) 기능을 구현하고 관련 UI, API, 테스트 코드를 추가했습니다.
This commit is contained in:
parent
ea4eefb525
commit
fee3c1912b
|
|
@ -0,0 +1,33 @@
|
|||
# Personal Authentication Mod Flow
|
||||
|
||||
This document details the modifications and features integrated within `spt-launcher` and `server-csharp` to support the custom Personal Authentication Mod.
|
||||
|
||||
## What is Personal Authentication?
|
||||
Unlike the vanilla SPT launcher which assumes 1 user = 1 machine, Personal Authentication enables multiple isolated accounts. Users register and log in to uniquely generated session IDs via `AuthRouter.cs` hooking into `LauncherCallbacks`.
|
||||
|
||||
## The `server-csharp` Modifications:
|
||||
The server's default JSON payload parsing was too strict regarding unmapped JSON structures (like adding a `password` field).
|
||||
|
||||
We addressed this by updating the base interface in `SPTarkov.Server.Core`:
|
||||
```csharp
|
||||
namespace SPTarkov.Server.Core.Models.Eft.Launcher;
|
||||
|
||||
public record LoginRequestData : IRequestData
|
||||
{
|
||||
[JsonPropertyName("username")]
|
||||
public string? Username { get; set; }
|
||||
|
||||
[JsonPropertyName("password")]
|
||||
public string? Password { get; set; }
|
||||
}
|
||||
```
|
||||
Now, `LoginRequestData` natively accepts passwords, allowing Harmony Patches on `LauncherCallbacks.Login` and `LauncherCallbacks.Register` to validate against the database gracefully.
|
||||
|
||||
## Custom Token Session vs Default Random Session Strings
|
||||
Vanilla SPT creates a random GUID session variable that is used loosely.
|
||||
With SSO:
|
||||
1. `spt-launcher` initiates an HTTP POST `/launcher/profile/login` with `username` and `password`.
|
||||
2. The Database (`DatabaseManager.cs`) verifies credentials against PostgreSQL passwords (hashed).
|
||||
3. Returns a specific `SessionID` mapped directly to that User ID.
|
||||
4. The launcher preserves this ID in cookies and local cache storage.
|
||||
5. Sub-requests (Start Client, Fetch Match Profiles) utilize this single, constant Session ID instead of performing a secondary manual profile scan discovery via `/launcher/profiles`.
|
||||
929
src/main/main.ts
929
src/main/main.ts
File diff suppressed because it is too large
Load Diff
|
|
@ -19,7 +19,11 @@ contextBridge.exposeInMainWorld("sptLauncher", {
|
|||
setSptInstallPath: (payload: { path: string; allowMissing?: boolean }) =>
|
||||
ipcRenderer.invoke("spt:setSptInstallPath", payload),
|
||||
pickSptInstallPath: () => ipcRenderer.invoke("spt:pickSptInstallPath"),
|
||||
fetchProfile: (payload: { username: string }) =>
|
||||
login: (payload: { username: string; password?: string }) =>
|
||||
ipcRenderer.invoke("spt:login", payload),
|
||||
register: (payload: { username: string; password?: string }) =>
|
||||
ipcRenderer.invoke("spt:register", payload),
|
||||
fetchProfile: (payload: { username: string; password?: string }) =>
|
||||
ipcRenderer.invoke("spt:fetchProfile", payload),
|
||||
downloadProfile: (payload: { username: string }) =>
|
||||
ipcRenderer.invoke("spt:downloadProfile", payload),
|
||||
|
|
|
|||
|
|
@ -74,6 +74,9 @@ const App = () => {
|
|||
const [hasSession, setHasSession] = useState(false);
|
||||
const [loginId, setLoginId] = useState("");
|
||||
const [loginPassword, setLoginPassword] = useState("");
|
||||
const [signupId, setSignupId] = useState("");
|
||||
const [signupPassword, setSignupPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [profileLoading, setProfileLoading] = useState(false);
|
||||
const [profile, setProfile] = useState<{
|
||||
username: string;
|
||||
|
|
@ -169,8 +172,8 @@ const App = () => {
|
|||
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;
|
||||
| string
|
||||
| undefined;
|
||||
const pickNumber = (...values: unknown[]) => {
|
||||
const candidate = values.find(
|
||||
(value) => typeof value === "number" || (typeof value === "string" && value.trim() !== "")
|
||||
|
|
@ -496,18 +499,31 @@ const App = () => {
|
|||
|
||||
sessionResumeAttemptedRef.current = true;
|
||||
void (async () => {
|
||||
const result = await window.sptLauncher.resumeSession();
|
||||
if (!result.ok || !result.data) {
|
||||
return;
|
||||
try {
|
||||
const result = await window.sptLauncher.resumeSession();
|
||||
if (!result.ok || !result.data) {
|
||||
scheduleScreenTransition("login");
|
||||
return;
|
||||
}
|
||||
|
||||
const resumedProfile = extractProfile(result.data, loginId.trim());
|
||||
// Simple validation: profile must have an ID or Username to be considered valid
|
||||
if (!resumedProfile.id && !resumedProfile.username) {
|
||||
scheduleScreenTransition("login");
|
||||
return;
|
||||
}
|
||||
|
||||
setProfile(resumedProfile);
|
||||
setHasSession(true);
|
||||
if (resumedProfile.username) {
|
||||
setLoginId(resumedProfile.username);
|
||||
}
|
||||
clearTransitionTimeout();
|
||||
setScreen("main");
|
||||
} catch (error) {
|
||||
console.error("Session resume failed", error);
|
||||
scheduleScreenTransition("login");
|
||||
}
|
||||
const resumedProfile = extractProfile(result.data, loginId.trim());
|
||||
setProfile(resumedProfile);
|
||||
setHasSession(true);
|
||||
if (resumedProfile.username) {
|
||||
setLoginId(resumedProfile.username);
|
||||
}
|
||||
clearTransitionTimeout();
|
||||
setScreen("main");
|
||||
})();
|
||||
}, [
|
||||
screen,
|
||||
|
|
@ -815,28 +831,30 @@ const App = () => {
|
|||
|
||||
const handleLogin = async () => {
|
||||
const trimmedId = loginId.trim();
|
||||
const trimmedPassword = loginPassword.trim();
|
||||
if (!trimmedId) {
|
||||
window.alert("아이디를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.sptLauncher?.fetchProfile) {
|
||||
window.alert("프로필 조회 기능이 준비되지 않았습니다.");
|
||||
if (!window.sptLauncher?.login) {
|
||||
window.alert("로그인 기능이 준비되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
setProfileLoading(true);
|
||||
|
||||
try {
|
||||
const result = await window.sptLauncher.fetchProfile({
|
||||
username: trimmedId
|
||||
const result = await window.sptLauncher.login({
|
||||
username: trimmedId,
|
||||
password: trimmedPassword
|
||||
});
|
||||
if (!result.ok) {
|
||||
if (isTimeoutError(result.error)) {
|
||||
enterRecoveryMode("timeout");
|
||||
return;
|
||||
}
|
||||
window.alert(result.error ?? "프로필 조회에 실패했습니다.");
|
||||
window.alert(result.error ?? "로그인에 실패했습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -860,9 +878,51 @@ const App = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleSignup = () => {
|
||||
window.alert("회원가입 요청이 접수되었습니다. 관리자 승인 후 이용 가능합니다.");
|
||||
setScreen("login");
|
||||
const handleSignup = async () => {
|
||||
const trimmedId = signupId.trim();
|
||||
const trimmedPassword = signupPassword.trim();
|
||||
const trimmedConfirm = confirmPassword.trim();
|
||||
|
||||
if (!trimmedId) {
|
||||
window.alert("아이디를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!trimmedPassword) {
|
||||
window.alert("비밀번호를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (trimmedPassword !== trimmedConfirm) {
|
||||
window.alert("비밀번호가 일치하지 않습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.sptLauncher?.register) {
|
||||
window.alert("회원가입 기능이 준비되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
setProfileLoading(true);
|
||||
try {
|
||||
const result = await window.sptLauncher.register({
|
||||
username: trimmedId,
|
||||
password: trimmedPassword
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
window.alert(result.error ?? "회원가입 요청에 실패했습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
window.alert("회원가입이 완료되었습니다. 이제 로그인할 수 있습니다.");
|
||||
setLoginId(trimmedId);
|
||||
setLoginPassword(trimmedPassword);
|
||||
setScreen("login");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
window.alert(message);
|
||||
} finally {
|
||||
setProfileLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordReset = () => {
|
||||
|
|
@ -1037,42 +1097,42 @@ const App = () => {
|
|||
// Subscribe to progress events
|
||||
const cleanup = window.sptLauncher.onModSyncProgress
|
||||
? window.sptLauncher.onModSyncProgress((_event, step) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const s = step as any;
|
||||
const uiStep = {
|
||||
name: s.name,
|
||||
ok: s.result?.ok ?? false,
|
||||
message: s.result?.ok ? undefined : s.result?.message ?? s.result?.error,
|
||||
status: s.result?.ok ? "done" : "error"
|
||||
};
|
||||
upsertSyncStep(uiStep);
|
||||
})
|
||||
: () => {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const s = step as any;
|
||||
const uiStep = {
|
||||
name: s.name,
|
||||
ok: s.result?.ok ?? false,
|
||||
message: s.result?.ok ? undefined : s.result?.message ?? s.result?.error,
|
||||
status: (s.result?.ok ? "done" : "error") as "done" | "error"
|
||||
};
|
||||
upsertSyncStep(uiStep);
|
||||
})
|
||||
: () => { };
|
||||
|
||||
try {
|
||||
const result = await window.sptLauncher.runModSync({
|
||||
targetDir,
|
||||
tag: targetTag
|
||||
});
|
||||
|
||||
|
||||
cleanup(); // Unsubscribe
|
||||
|
||||
if (!result.ok) {
|
||||
setSyncResult({ ok: false, error: result.error ?? "모드 동기화에 실패했습니다." });
|
||||
// If steps are returned in result (history), use them?
|
||||
// The real-time listener should have captured them, but let's ensure we have full list
|
||||
if (result.steps) {
|
||||
setSyncSteps(
|
||||
result.steps.map((s) => ({
|
||||
name: s.name,
|
||||
ok: s.ok,
|
||||
message: s.ok ? undefined : s.message,
|
||||
status: s.ok ? "done" : "error"
|
||||
}))
|
||||
);
|
||||
}
|
||||
setModCheckMessage(result.error ?? "모드 동기화에 실패했습니다.");
|
||||
return false;
|
||||
setSyncResult({ ok: false, error: result.error ?? "모드 동기화에 실패했습니다." });
|
||||
// If steps are returned in result (history), use them?
|
||||
// The real-time listener should have captured them, but let's ensure we have full list
|
||||
if (result.steps) {
|
||||
setSyncSteps(
|
||||
result.steps.map((s) => ({
|
||||
name: s.name,
|
||||
ok: s.ok,
|
||||
message: s.ok ? undefined : s.message,
|
||||
status: s.ok ? "done" : "error"
|
||||
}))
|
||||
);
|
||||
}
|
||||
setModCheckMessage(result.error ?? "모드 동기화에 실패했습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
setSyncResult({ ok: true });
|
||||
|
|
@ -1109,7 +1169,7 @@ const App = () => {
|
|||
|
||||
const closeSyncModal = () => {
|
||||
if (syncInProgress) {
|
||||
return; // Cannot close while running
|
||||
return; // Cannot close while running
|
||||
}
|
||||
setSyncResult(undefined);
|
||||
setSyncSteps([]);
|
||||
|
|
@ -1130,31 +1190,31 @@ const App = () => {
|
|||
}
|
||||
|
||||
if (!window.sptLauncher?.launchGame) {
|
||||
window.alert("게임 시작 기능이 준비되지 않았습니다.");
|
||||
return;
|
||||
window.alert("게임 시작 기능이 준비되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.sptLauncher.launchGame();
|
||||
if (!result.ok) {
|
||||
if (result.error === "already_running") {
|
||||
window.alert(`게임이 이미 실행 중입니다.\n(발견된 프로세스: ${(result.details ?? []).join(", ")})`);
|
||||
} else if (result.error === "mod_verification_failed") {
|
||||
window.alert("모드 버전 검증에 실패했습니다. 버전을 다시 확인해주세요.");
|
||||
} else if (result.error === "no_spt_path") {
|
||||
window.alert("SPT 설치 경로가 설정되지 않았습니다.");
|
||||
} else if (result.error === "executable_not_found") {
|
||||
window.alert("실행 파일(Launcher/Server)을 찾을 수 없습니다.");
|
||||
} else {
|
||||
window.alert(`게임 실행 실패: ${result.error}`);
|
||||
}
|
||||
return;
|
||||
const result = await window.sptLauncher.launchGame();
|
||||
if (!result.ok) {
|
||||
if (result.error === "already_running") {
|
||||
window.alert(`게임이 이미 실행 중입니다.\n(발견된 프로세스: ${(result.details ?? []).join(", ")})`);
|
||||
} else if (result.error === "mod_verification_failed") {
|
||||
window.alert("모드 버전 검증에 실패했습니다. 버전을 다시 확인해주세요.");
|
||||
} else if (result.error === "no_spt_path") {
|
||||
window.alert("SPT 설치 경로가 설정되지 않았습니다.");
|
||||
} else if (result.error === "executable_not_found") {
|
||||
window.alert("실행 파일(Launcher/Server)을 찾을 수 없습니다.");
|
||||
} else {
|
||||
window.alert(`게임 실행 실패: ${result.error}`);
|
||||
}
|
||||
|
||||
// Success
|
||||
window.alert(`게임이 실행되었습니다.\n(${result.executedPath})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Success
|
||||
window.alert(`게임이 실행되었습니다.\n(${result.executedPath})`);
|
||||
} catch (e: any) {
|
||||
window.alert(`게임 실행 중 오류가 발생했습니다: ${e.message}`);
|
||||
window.alert(`게임 실행 중 오류가 발생했습니다: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1312,12 +1372,27 @@ const App = () => {
|
|||
회원가입 후 서버 관리자 승인이 완료되어야 로그인 가능합니다.
|
||||
</p>
|
||||
<div className="login-form">
|
||||
<input type="text" placeholder="아이디" />
|
||||
<input type="password" placeholder="비밀번호" />
|
||||
<input type="password" placeholder="비밀번호 확인" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="아이디"
|
||||
value={signupId}
|
||||
onChange={(e) => setSignupId(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="비밀번호"
|
||||
value={signupPassword}
|
||||
onChange={(e) => setSignupPassword(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="비밀번호 확인"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button type="button" onClick={handleSignup}>
|
||||
회원가입 요청
|
||||
<button type="button" onClick={handleSignup} disabled={profileLoading}>
|
||||
{profileLoading ? "가입 요청 중..." : "회원가입 요청"}
|
||||
</button>
|
||||
<button type="button" className="ghost" onClick={() => setScreen("login")}>
|
||||
로그인으로 돌아가기
|
||||
|
|
@ -1381,114 +1456,114 @@ const App = () => {
|
|||
<section className="card spt-runner-card">
|
||||
<div className="section-head">
|
||||
<h2>SPT 설정</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="launch-button-header"
|
||||
onClick={handleLaunch}
|
||||
disabled={!startReady}
|
||||
>
|
||||
<span className="launch-icon">🚀</span>
|
||||
<div className="launch-info">
|
||||
<span className="launch-text">{syncInProgress ? "동기화 중..." : "게임 시작"}</span>
|
||||
{/* <span className="launch-sub">Server & Launcher</span> */}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="launch-button-header"
|
||||
onClick={handleLaunch}
|
||||
disabled={!startReady}
|
||||
>
|
||||
<span className="launch-icon">🚀</span>
|
||||
<div className="launch-info">
|
||||
<span className="launch-text">{syncInProgress ? "동기화 중..." : "게임 시작"}</span>
|
||||
{/* <span className="launch-sub">Server & Launcher</span> */}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="spt-rows-container">
|
||||
{/* Install Path Row */}
|
||||
<div className="spt-row">
|
||||
<div className="row-header">
|
||||
<span className="label">설치 경로</span>
|
||||
<span className={`status ${sptInstallInfo?.ok ? "ok" : "blocked"}`}>
|
||||
{sptInstallInfo?.ok ? "확인됨" : "확인 필요"}
|
||||
<div className="row-header">
|
||||
<span className="label">설치 경로</span>
|
||||
<span className={`status ${sptInstallInfo?.ok ? "ok" : "blocked"}`}>
|
||||
{sptInstallInfo?.ok ? "확인됨" : "확인 필요"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="row-body">
|
||||
<span className="value" title={sptInstallInfo?.path}>{sptInstallInfo?.path ?? "-"}</span>
|
||||
{sptInstallInfo?.source && (
|
||||
<span className="sub-helper">
|
||||
({sptInstallInfo.source === "auto" ? "자동" : "수동"})
|
||||
</span>
|
||||
</div>
|
||||
<div className="row-body">
|
||||
<span className="value" title={sptInstallInfo?.path}>{sptInstallInfo?.path ?? "-"}</span>
|
||||
{sptInstallInfo?.source && (
|
||||
<span className="sub-helper">
|
||||
({sptInstallInfo.source === "auto" ? "자동" : "수동"})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="row-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost icon-button"
|
||||
onClick={async () => {
|
||||
if (!window.sptLauncher?.pickSptInstallPath) {
|
||||
window.alert("준비 안됨");
|
||||
return;
|
||||
}
|
||||
const result = await window.sptLauncher.pickSptInstallPath();
|
||||
if (!result.ok && result.error === "invalid_spt_path") {
|
||||
setSptInstallMessage("유효하지 않은 SPT 폴더입니다.");
|
||||
return;
|
||||
}
|
||||
setSptInstallMessage(undefined);
|
||||
await runSptInstallCheck();
|
||||
}}
|
||||
title="경로 변경"
|
||||
>
|
||||
📂
|
||||
</button>
|
||||
<button type="button" className="ghost" onClick={runSptInstallCheck}>
|
||||
재확인
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="row-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost icon-button"
|
||||
onClick={async () => {
|
||||
if (!window.sptLauncher?.pickSptInstallPath) {
|
||||
window.alert("준비 안됨");
|
||||
return;
|
||||
}
|
||||
const result = await window.sptLauncher.pickSptInstallPath();
|
||||
if (!result.ok && result.error === "invalid_spt_path") {
|
||||
setSptInstallMessage("유효하지 않은 SPT 폴더입니다.");
|
||||
return;
|
||||
}
|
||||
setSptInstallMessage(undefined);
|
||||
await runSptInstallCheck();
|
||||
}}
|
||||
title="경로 변경"
|
||||
>
|
||||
📂
|
||||
</button>
|
||||
<button type="button" className="ghost" onClick={runSptInstallCheck}>
|
||||
재확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mod Version Row */}
|
||||
<div className="spt-row">
|
||||
<div className="row-header">
|
||||
<span className="label">모드 버전</span>
|
||||
<span className={`status ${modStatusClass}`}>{modStatusLabel}</span>
|
||||
</div>
|
||||
<div className="row-body">
|
||||
<span className="value">
|
||||
{modVersionStatus?.currentTag ?? "-"} → {modVersionStatus?.latestTag ?? "-"}
|
||||
<div className="row-header">
|
||||
<span className="label">모드 버전</span>
|
||||
<span className={`status ${modStatusClass}`}>{modStatusLabel}</span>
|
||||
</div>
|
||||
<div className="row-body">
|
||||
<span className="value">
|
||||
{modVersionStatus?.currentTag ?? "-"} → {modVersionStatus?.latestTag ?? "-"}
|
||||
</span>
|
||||
{modVersionStatus?.latestMinorTags && modVersionStatus.latestMinorTags.length > 0 && (
|
||||
<span className="sub-helper">
|
||||
(+{modVersionStatus.latestMinorTags.length} 패치)
|
||||
</span>
|
||||
{modVersionStatus?.latestMinorTags && modVersionStatus.latestMinorTags.length > 0 && (
|
||||
<span className="sub-helper">
|
||||
(+{modVersionStatus.latestMinorTags.length} 패치)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="row-controls">
|
||||
<select
|
||||
value={selectedModTag}
|
||||
onChange={(event) => setSelectedModTag(event.target.value)}
|
||||
disabled={modTagOptions.length === 0 || syncInProgress}
|
||||
className="version-select"
|
||||
>
|
||||
<option value="">선택...</option>
|
||||
{modTagOptions.map((tag) => (
|
||||
<option key={tag} value={tag}>{tag}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="row-controls">
|
||||
<select
|
||||
value={selectedModTag}
|
||||
onChange={(event) => setSelectedModTag(event.target.value)}
|
||||
disabled={modTagOptions.length === 0 || syncInProgress}
|
||||
className="version-select"
|
||||
>
|
||||
<option value="">선택...</option>
|
||||
{modTagOptions.map((tag) => (
|
||||
<option key={tag} value={tag}>{tag}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={() => performModSync("optional", selectedModTag)}
|
||||
disabled={syncInProgress || !selectedModTag}
|
||||
title="선택 버전 적용"
|
||||
>
|
||||
적용
|
||||
</button>
|
||||
<div className="sync-menu">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={() => performModSync("optional", selectedModTag)}
|
||||
disabled={syncInProgress || !selectedModTag}
|
||||
title="선택 버전 적용"
|
||||
className="ghost icon-button"
|
||||
onClick={() => performModSync("optional")}
|
||||
disabled={syncInProgress || !modVersionStatus?.latestTag}
|
||||
title="최신 동기화"
|
||||
>
|
||||
적용
|
||||
⟳
|
||||
</button>
|
||||
<div className="sync-menu">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost icon-button"
|
||||
onClick={() => performModSync("optional")}
|
||||
disabled={syncInProgress || !modVersionStatus?.latestTag}
|
||||
title="최신 동기화"
|
||||
>
|
||||
⟳
|
||||
</button>
|
||||
{/* Hover menu logic handled by CSS usually, keeping simple for now */}
|
||||
</div>
|
||||
</div>
|
||||
{/* Hover menu logic handled by CSS usually, keeping simple for now */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1506,19 +1581,19 @@ const App = () => {
|
|||
|
||||
<footer className="footer">
|
||||
<div className="tool-led">
|
||||
<span className="tool-label">서버</span>
|
||||
<span className={`led ${serverStatusLedClass}`} aria-label="서버 상태" />
|
||||
<span className="tool-label">서버</span>
|
||||
<span className={`led ${serverStatusLedClass}`} aria-label="서버 상태" />
|
||||
</div>
|
||||
<div className="tool-led">
|
||||
<span className="tool-label">Git</span>
|
||||
<span className={`led ${gitLedClass}`} aria-label="Git 상태" />
|
||||
<span className="tool-label">Git</span>
|
||||
<span className={`led ${gitLedClass}`} aria-label="Git 상태" />
|
||||
</div>
|
||||
<div className="tool-led">
|
||||
<span className="tool-label">LFS</span>
|
||||
<span className={`led ${lfsLedClass}`} aria-label="Git LFS 상태" />
|
||||
<span className="tool-label">LFS</span>
|
||||
<span className={`led ${lfsLedClass}`} aria-label="Git LFS 상태" />
|
||||
</div>
|
||||
<button type="button" className="ghost status-button" onClick={handleOpenToolsModal}>
|
||||
상세 보기
|
||||
상세 보기
|
||||
</button>
|
||||
</footer>
|
||||
</main>
|
||||
|
|
@ -1566,7 +1641,7 @@ const App = () => {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{installMessage && <div className="notice">{installMessage}</div>}
|
||||
{installMessage && <div className="notice">{installMessage}</div>}
|
||||
{gitPathsResult && (
|
||||
<div className="notice">
|
||||
<strong>경로 확인</strong>
|
||||
|
|
@ -1583,73 +1658,73 @@ const App = () => {
|
|||
|
||||
{(syncInProgress || syncResult) && (
|
||||
<div className="modal-overlay" role="presentation">
|
||||
<section className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="modal-header">
|
||||
<h3>
|
||||
{syncResult
|
||||
? (syncResult.ok ? (modalTitle.replace("중...", "완료")) : (syncResult.error ? `오류: ${syncResult.error}` : "오류 발생"))
|
||||
: modalTitle}
|
||||
</h3>
|
||||
{!syncInProgress && (
|
||||
<button type="button" className="ghost" onClick={closeSyncModal}>
|
||||
닫기
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
<div className="modal-body">
|
||||
{syncProgress?.progress && (
|
||||
<div className="sync-progress">
|
||||
<div className="sync-progress-header">
|
||||
<span>진행률(Progress)</span>
|
||||
<span>{syncProgress.progress.percent}%</span>
|
||||
</div>
|
||||
<div className="sync-progress-bar">
|
||||
<div
|
||||
className="sync-progress-fill"
|
||||
style={{ width: `${syncProgress.progress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{syncProgress.stepName && (
|
||||
<div className="sync-progress-step">
|
||||
현재 단계(Current Step): {getStepDisplayInfo(syncProgress.stepName).title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="sync-steps">
|
||||
{syncSteps.map((step, idx) => {
|
||||
const info = getStepDisplayInfo(step.name);
|
||||
const stepClass =
|
||||
step.status === "running" ? "running" : step.ok ? "ok" : "fail";
|
||||
const stepIcon =
|
||||
step.status === "running" ? "⏳" : step.ok ? "✅" : "❌";
|
||||
return (
|
||||
<div key={idx} className={`sync-step ${stepClass}`}>
|
||||
<span className="step-number">{idx + 1}.</span>
|
||||
<div className="step-content">
|
||||
<div className="step-title">{info.title}</div>
|
||||
<div className="step-command">{info.command}</div>
|
||||
{!step.ok && step.message && (
|
||||
<div className="step-msg">오류(Error): {step.message}</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="step-icon">{stepIcon}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{syncInProgress && <div className="spinner-row">
|
||||
<span className="spinner">⏳</span>
|
||||
<span>작업 진행 중...</span>
|
||||
</div>}
|
||||
</div>
|
||||
{syncResult && (
|
||||
<div className={`notice ${syncResult.ok ? "info" : "error"}`}>
|
||||
{syncResult.ok ? "모드 적용이 완료되었습니다." : (syncResult.error ? `실패: ${syncResult.error}` : "오류가 발생했습니다.")}
|
||||
</div>
|
||||
)}
|
||||
<section className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="modal-header">
|
||||
<h3>
|
||||
{syncResult
|
||||
? (syncResult.ok ? (modalTitle.replace("중...", "완료")) : (syncResult.error ? `오류: ${syncResult.error}` : "오류 발생"))
|
||||
: modalTitle}
|
||||
</h3>
|
||||
{!syncInProgress && (
|
||||
<button type="button" className="ghost" onClick={closeSyncModal}>
|
||||
닫기
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
<div className="modal-body">
|
||||
{syncProgress?.progress && (
|
||||
<div className="sync-progress">
|
||||
<div className="sync-progress-header">
|
||||
<span>진행률(Progress)</span>
|
||||
<span>{syncProgress.progress.percent}%</span>
|
||||
</div>
|
||||
<div className="sync-progress-bar">
|
||||
<div
|
||||
className="sync-progress-fill"
|
||||
style={{ width: `${syncProgress.progress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{syncProgress.stepName && (
|
||||
<div className="sync-progress-step">
|
||||
현재 단계(Current Step): {getStepDisplayInfo(syncProgress.stepName).title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="sync-steps">
|
||||
{syncSteps.map((step, idx) => {
|
||||
const info = getStepDisplayInfo(step.name);
|
||||
const stepClass =
|
||||
step.status === "running" ? "running" : step.ok ? "ok" : "fail";
|
||||
const stepIcon =
|
||||
step.status === "running" ? "⏳" : step.ok ? "✅" : "❌";
|
||||
return (
|
||||
<div key={idx} className={`sync-step ${stepClass}`}>
|
||||
<span className="step-number">{idx + 1}.</span>
|
||||
<div className="step-content">
|
||||
<div className="step-title">{info.title}</div>
|
||||
<div className="step-command">{info.command}</div>
|
||||
{!step.ok && step.message && (
|
||||
<div className="step-msg">오류(Error): {step.message}</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="step-icon">{stepIcon}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{syncInProgress && <div className="spinner-row">
|
||||
<span className="spinner">⏳</span>
|
||||
<span>작업 진행 중...</span>
|
||||
</div>}
|
||||
</div>
|
||||
{syncResult && (
|
||||
<div className={`notice ${syncResult.ok ? "info" : "error"}`}>
|
||||
{syncResult.ok ? "모드 적용이 완료되었습니다." : (syncResult.error ? `실패: ${syncResult.error}` : "오류가 발생했습니다.")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,10 @@
|
|||
--- src/renderer/App.tsx
|
||||
+++ src/renderer/App.tsx
|
||||
@@ -49,6 +49,7 @@
|
||||
// MOCK For Browser testing since we hit CORS issues
|
||||
if (window.location.protocol === 'http:') {
|
||||
setServerStatus('ready');
|
||||
+ return;
|
||||
}
|
||||
|
||||
const result = await window.sptLauncher.checkServerVersion();
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
@@ -49,6 +49,7 @@
|
||||
// MOCK For Browser testing since we hit CORS issues
|
||||
if (window.location.protocol === 'http:') {
|
||||
setServerStatus('ready');
|
||||
+ return;
|
||||
}
|
||||
|
||||
const result = await window.sptLauncher.checkServerVersion();
|
||||
|
|
@ -121,7 +121,21 @@ interface Window {
|
|||
path?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
fetchProfile: (payload: { username: string }) => Promise<{
|
||||
login: (payload: { username: string; password?: string }) => Promise<{
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
url: string;
|
||||
}>;
|
||||
register: (payload: { username: string; password?: string }) => Promise<{
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
url: string;
|
||||
}>;
|
||||
fetchProfile: (payload: { username: string; password?: string }) => Promise<{
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
data?: unknown;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export const APP_CONFIG = {
|
||||
serverBaseUrl: "https://pandoli365.com:5069",
|
||||
serverBaseUrl: "https://127.0.0.1:6969",
|
||||
serverHealthcheckPath: "/launcher/ping",
|
||||
serverHealthcheckTimeoutMs: 2000,
|
||||
serverRequestTimeoutMs: 4000,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
const https = require('https');
|
||||
const zlib = require('zlib');
|
||||
|
||||
const agent = new https.Agent({ rejectUnauthorized: false });
|
||||
|
||||
const makeRequest = (path, data, cookie) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = JSON.stringify(data);
|
||||
const deflated = zlib.deflateSync(payload);
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept-Encoding': 'deflate',
|
||||
'Expect': '100-continue'
|
||||
};
|
||||
if (cookie) {
|
||||
headers['Cookie'] = cookie;
|
||||
}
|
||||
|
||||
const req = https.request({
|
||||
hostname: '127.0.0.1',
|
||||
port: 6969,
|
||||
path: path,
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
agent
|
||||
}, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', chunk => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
let body = '';
|
||||
if (buffer.length > 0) {
|
||||
try {
|
||||
body = zlib.inflateSync(buffer).toString();
|
||||
} catch (e) {
|
||||
body = buffer.toString();
|
||||
}
|
||||
}
|
||||
resolve({ status: res.statusCode, body });
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.write(deflated);
|
||||
req.end();
|
||||
});
|
||||
};
|
||||
|
||||
async function testFlow() {
|
||||
const user = 'testuser' + Math.floor(Math.random() * 1000);
|
||||
const pass = 'password123';
|
||||
|
||||
console.log(`Registering new user: ${user}`);
|
||||
const regRes = await makeRequest('/launcher/profile/register', {
|
||||
username: user,
|
||||
password: pass,
|
||||
edition: 'Standard'
|
||||
});
|
||||
console.log("Registration Response:", regRes.status, regRes.body);
|
||||
|
||||
if (regRes.status !== 200 || regRes.body === 'FAILED') {
|
||||
console.log("Registration failed, stopping.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("\nLogging in with new user...");
|
||||
const loginRes = await makeRequest('/launcher/profile/login', {
|
||||
username: user,
|
||||
password: pass
|
||||
});
|
||||
console.log("Login Response:", loginRes.status, loginRes.body);
|
||||
|
||||
if (loginRes.status !== 200 || loginRes.body === 'FAILED') {
|
||||
console.log("Login failed, stopping.");
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = loginRes.body;
|
||||
|
||||
console.log("\nFetching profile info...");
|
||||
const infoRes = await makeRequest('/launcher/profile/info', {
|
||||
username: user
|
||||
}, `PHPSESSID=${sessionId}`);
|
||||
console.log("Info Response:", infoRes.status, infoRes.body);
|
||||
}
|
||||
|
||||
testFlow().catch(console.error);
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
const http = require('http');
|
||||
const https = require('https');
|
||||
const zlib = require('zlib');
|
||||
|
||||
async function sendRequest(path, data) {
|
||||
const payload = JSON.stringify(data);
|
||||
|
||||
const options = {
|
||||
hostname: '127.0.0.1',
|
||||
port: 6969,
|
||||
path: path,
|
||||
method: 'POST',
|
||||
rejectUnauthorized: false,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
'Accept-Encoding': 'identity',
|
||||
'requestcompressed': '0',
|
||||
'responsecompressed': '0'
|
||||
}
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.request(options, (res) => {
|
||||
let result = '';
|
||||
res.on('data', d => result += d);
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: result }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(payload); // We pass uncompressed for now using requestcompressed: 0
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log("Registering...");
|
||||
const regRes = await sendRequest('/launcher/profile/register', { username: 'testuser3', password: 'testpassword3', edition: 'Standard' });
|
||||
console.log(regRes);
|
||||
|
||||
console.log("Logging in...");
|
||||
const loginRes = await sendRequest('/launcher/profile/login', { username: 'testuser3', password: 'testpassword3' });
|
||||
console.log(loginRes);
|
||||
}
|
||||
|
||||
run();
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
const https = require('https');
|
||||
|
||||
const agent = new https.Agent({ rejectUnauthorized: false });
|
||||
|
||||
const makeRequest = (path, data) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = JSON.stringify(data);
|
||||
const req = https.request({
|
||||
hostname: '127.0.0.1',
|
||||
port: 6969,
|
||||
path: path,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload)
|
||||
},
|
||||
agent
|
||||
}, (res) => {
|
||||
let body = '';
|
||||
res.on('data', chunk => body += chunk);
|
||||
res.on('end', () => resolve({ status: res.statusCode, body }));
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
};
|
||||
|
||||
async function testFlow() {
|
||||
console.log("Registering new user...");
|
||||
const regRes = await makeRequest('/launcher/profile/register', {
|
||||
username: 'newuser123',
|
||||
password: 'newpass123',
|
||||
edition: 'Standard'
|
||||
});
|
||||
console.log("Registration:", regRes);
|
||||
|
||||
console.log("Logging in with new user...");
|
||||
const loginRes = await makeRequest('/launcher/profile/login', {
|
||||
username: 'newuser123',
|
||||
password: 'newpass123'
|
||||
});
|
||||
console.log("Login:", loginRes);
|
||||
const sessionId = loginRes.body;
|
||||
|
||||
console.log("Fetching profile info...");
|
||||
const infoRes = await makeRequest('/launcher/profile/info', {
|
||||
username: 'newuser123'
|
||||
});
|
||||
console.log("Info:", infoRes);
|
||||
}
|
||||
|
||||
testFlow().catch(console.error);
|
||||
Loading…
Reference in New Issue