diff --git a/docs/auth-flow.md b/docs/auth-flow.md
new file mode 100644
index 0000000..a5e7892
--- /dev/null
+++ b/docs/auth-flow.md
@@ -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`.
diff --git a/src/main/main.ts b/src/main/main.ts
index f0ab318..caa00bf 100644
--- a/src/main/main.ts
+++ b/src/main/main.ts
@@ -125,6 +125,11 @@ const checkServerHealth = async (): Promise => {
const request = net.request({
method: "GET",
url,
+ headers: {
+ "Accept-Encoding": "identity",
+ requestcompressed: "0",
+ responsecompressed: "0",
+ }
});
const timeout = setTimeout(() => {
@@ -1336,8 +1341,10 @@ const postText = async (path: string, body: unknown) => {
const requestSessionId = async (
username: string,
+ password?: string,
): Promise => {
- const loginResult = await postText("/launcher/profile/login", { username });
+ // 1. Login to get the session token
+ const loginResult = await postText("/launcher/profile/login", { username, password });
if (!loginResult.ok) {
return {
ok: false,
@@ -1347,10 +1354,17 @@ const requestSessionId = async (
}
const sessionId = loginResult.data?.trim();
- if (!sessionId) {
- return { ok: false, error: "empty_session", url: loginResult.url };
+
+ // Check if session ID is empty or explicitly "FAILED"
+ if (!sessionId || sessionId === "FAILED" || sessionId === "") {
+ return {
+ ok: false,
+ error: sessionId === "FAILED" ? "login_failed_credentials" : "empty_session",
+ url: loginResult.url
+ };
}
+ // With PersonalAuthMod, the sessionId is the unique token we need.
return { ok: true, sessionId, url: loginResult.url };
};
@@ -1359,6 +1373,7 @@ const isSessionInvalidStatus = (status?: number) =>
const getSessionIdForUser = async (
username: string,
+ password?: string,
): Promise => {
const cached = await readSessionRecord();
if (cached && cached.username === username && !isSessionExpired(cached)) {
@@ -1378,7 +1393,7 @@ const getSessionIdForUser = async (
await clearSessionRecord();
}
- const loginResult = await requestSessionId(username);
+ const loginResult = await requestSessionId(username, password);
if (!loginResult.ok) {
return loginResult;
}
@@ -1392,9 +1407,10 @@ const getSessionIdForUser = async (
};
};
-const registerProfile = async (username: string) => {
- return await postJson("/launcher/profile/register", {
+const registerProfile = async (username: string, password?: string) => {
+ return await postText("/launcher/profile/register", {
username,
+ password,
edition: "Standard",
});
};
@@ -1420,294 +1436,309 @@ const createWindow = () => {
}
};
-app.whenReady().then(() => {
- const expectedServerUrl = new URL(SERVER_BASE_URL);
- const expectedServerOrigin = expectedServerUrl.origin;
- const expectedServerHost = expectedServerUrl.hostname;
- // `electron.net` 요청은 self-signed TLS에서 막히는 경우가 많아서,
- // 특정 호스트(host)만 인증서 검증을 우회(bypass)합니다.
- session.defaultSession.setCertificateVerifyProc((request, callback) => {
- if (request.hostname === expectedServerHost) {
- callback(0);
- return;
- }
+const gotTheLock = app.requestSingleInstanceLock();
- callback(-3);
+if (!gotTheLock) {
+ console.log("[spt-launcher] Another instance is already running. Quitting...");
+ app.quit();
+} else {
+ app.on("second-instance", (event, commandLine, workingDirectory) => {
+ // Someone tried to run a second instance, we should focus our window.
+ const windows = BrowserWindow.getAllWindows();
+ if (windows.length > 0) {
+ const mainWindow = windows[0];
+ if (mainWindow.isMinimized()) mainWindow.restore();
+ mainWindow.focus();
+ }
});
- app.on(
- "certificate-error",
- (event, _webContents, url, _error, _certificate, callback) => {
- if (url.startsWith(expectedServerOrigin)) {
- event.preventDefault();
- callback(true);
+ app.whenReady().then(() => {
+ const expectedServerUrl = new URL(SERVER_BASE_URL);
+ const expectedServerOrigin = expectedServerUrl.origin;
+ const expectedServerHost = expectedServerUrl.hostname;
+ // `electron.net` 요청은 self-signed TLS에서 막히는 경우가 많아서,
+ // 특정 호스트(host)만 인증서 검증을 우회(bypass)합니다.
+ session.defaultSession.setCertificateVerifyProc((request, callback) => {
+ if (request.hostname === expectedServerHost) {
+ callback(0);
return;
}
- callback(false);
- },
- );
-
- ipcMain.handle("spt:checkServerHealth", async () => {
- return await checkServerHealth();
- });
-
- ipcMain.handle("spt:checkGitTools", async () => {
- return await checkGitTools();
- });
-
- ipcMain.handle("spt:getGitPaths", async () => {
- return await getGitPaths();
- });
-
- ipcMain.handle("spt:installGitTools", async (event) => {
- return await installGitTools((step) => {
- event.sender.send("spt:gitInstallProgress", step);
+ callback(-3);
});
- });
- ipcMain.handle("spt:launchGame", async (event) => {
- const mainWindow = BrowserWindow.fromWebContents(event.sender);
- const installInfo = await resolveSptInstall();
-
- if (!installInfo.path) {
- return { ok: false, error: "no_spt_path" };
- }
-
- // Double check running processes
- const running = await checkGameRunning();
- if (running.clientRunning) {
- return { ok: false, error: "already_running", details: running.processes };
- }
-
- // Double check mod version (optional but safer)
- const modStatus = await getModVersionStatus();
- if (!modStatus.ok) {
- return { ok: false, error: "mod_verification_failed" };
- }
-
- // Launch Server if not running (Local only)
- const serverUrl = new URL(SERVER_BASE_URL);
- const isLocal = serverUrl.hostname === "127.0.0.1" || serverUrl.hostname === "localhost";
-
- if (isLocal && !running.serverRunning) {
- let serverExePath: string | undefined;
- for (const exe of SERVER_PROCESS_NAMES) {
- // Check direct path or SPT subdirectory
- const direct = path.join(installInfo.path, exe);
- if (await pathExists(direct)) {
- serverExePath = direct;
- break;
+ app.on(
+ "certificate-error",
+ (event, _webContents, url, _error, _certificate, callback) => {
+ if (url.startsWith(expectedServerOrigin)) {
+ event.preventDefault();
+ callback(true);
+ return;
}
- const nested = path.join(installInfo.path, "SPT", exe);
- if (await pathExists(nested)) {
- serverExePath = nested;
- break;
+
+ callback(false);
+ },
+ );
+
+ ipcMain.handle("spt:checkServerHealth", async () => {
+ return await checkServerHealth();
+ });
+
+ ipcMain.handle("spt:checkGitTools", async () => {
+ return await checkGitTools();
+ });
+
+ ipcMain.handle("spt:getGitPaths", async () => {
+ return await getGitPaths();
+ });
+
+ ipcMain.handle("spt:installGitTools", async (event) => {
+ return await installGitTools((step) => {
+ event.sender.send("spt:gitInstallProgress", step);
+ });
+ });
+
+ ipcMain.handle("spt:launchGame", async (event) => {
+ const mainWindow = BrowserWindow.fromWebContents(event.sender);
+ const installInfo = await resolveSptInstall();
+
+ if (!installInfo.path) {
+ return { ok: false, error: "no_spt_path" };
+ }
+
+ // Double check running processes
+ const running = await checkGameRunning();
+ if (running.clientRunning) {
+ return { ok: false, error: "already_running", details: running.processes };
+ }
+
+ // Double check mod version (optional but safer)
+ const modStatus = await getModVersionStatus();
+ if (!modStatus.ok) {
+ return { ok: false, error: "mod_verification_failed" };
+ }
+
+ // Launch Server if not running (Local only)
+ const serverUrl = new URL(SERVER_BASE_URL);
+ const isLocal = serverUrl.hostname === "127.0.0.1" || serverUrl.hostname === "localhost";
+
+ if (isLocal && !running.serverRunning) {
+ let serverExePath: string | undefined;
+ for (const exe of SERVER_PROCESS_NAMES) {
+ // Check direct path or SPT subdirectory
+ const direct = path.join(installInfo.path, exe);
+ if (await pathExists(direct)) {
+ serverExePath = direct;
+ break;
+ }
+ const nested = path.join(installInfo.path, "SPT", exe);
+ if (await pathExists(nested)) {
+ serverExePath = nested;
+ break;
+ }
+ }
+
+ if (serverExePath) {
+ console.log(`[spt-launcher] Starting Server: ${serverExePath}`);
+ const serverBat = stripExe(serverExePath) + ".bat"; // Sometimes users use bat, but exe should work if arguments aren't complex
+
+ // Prefer EXE for now.
+ const child = require("child_process").spawn(serverExePath, [], {
+ cwd: path.dirname(serverExePath),
+ detached: true,
+ stdio: "ignore"
+ });
+ child.unref();
+
+ // Wait for server health
+ let attempts = 0;
+ const maxAttempts = 30; // 30 seconds approx
+ while (attempts < maxAttempts) {
+ const health = await checkServerHealth();
+ if (health.ok) {
+ break;
+ }
+ await new Promise(r => setTimeout(r, 1000));
+ attempts++;
+ }
}
}
- if (serverExePath) {
- console.log(`[spt-launcher] Starting Server: ${serverExePath}`);
- const serverBat = stripExe(serverExePath) + ".bat"; // Sometimes users use bat, but exe should work if arguments aren't complex
+ // Launch Client
+ try {
+ // Prioritize EFT executable
+ const candidates = [
+ "EscapeFromTarkov.exe",
+ "EscapeFromTarkov_BE.exe",
+ "SPT/EscapeFromTarkov.exe",
+ "SPT/EscapeFromTarkov_BE.exe"
+ ];
- // Prefer EXE for now.
- const child = require("child_process").spawn(serverExePath, [], {
- cwd: path.dirname(serverExePath),
+ let executablePath: string | undefined;
+ for (const exe of candidates) {
+ const fullPath = path.join(installInfo.path, exe);
+ if (await pathExists(fullPath)) {
+ executablePath = fullPath;
+ break;
+ }
+ }
+
+ if (!executablePath) {
+ return { ok: false, error: "executable_not_found" };
+ }
+
+ // Get Session ID for args
+ const session = await readSessionRecord();
+ if (!session || isSessionExpired(session)) {
+ return { ok: false, error: "session_required" };
+ }
+
+ // Build Args
+ const configArg = JSON.stringify({
+ BackendUrl: SERVER_BASE_URL,
+ Version: "live"
+ });
+ const args = [`-token=${session.sessionId}`, `-config=${configArg}`];
+ console.log(`[spt-launcher] Launching: ${executablePath} with args`, args);
+
+ // Sanitize Environment
+ // Strip Electron/Node specifics to prevent BepInEx/Plugin conflicts
+ const sanitizedEnv = { ...process.env };
+ for (const key of Object.keys(sanitizedEnv)) {
+ if (key.startsWith("ELECTRON_") || key.startsWith("NODE_") || key === "VITE_DEV_SERVER_URL") {
+ delete sanitizedEnv[key];
+ }
+ }
+
+ // Spawn detached
+ const child = require("child_process").spawn(executablePath, args, {
+ cwd: path.dirname(executablePath),
detached: true,
- stdio: "ignore"
+ stdio: "ignore",
+ env: sanitizedEnv
});
child.unref();
- // Wait for server health
- let attempts = 0;
- const maxAttempts = 30; // 30 seconds approx
- while (attempts < maxAttempts) {
- const health = await checkServerHealth();
- if (health.ok) {
- break;
+ mainWindow?.minimize();
+ return { ok: true, executedPath: executablePath };
+ } catch (e: any) {
+ console.error("Launch failed", e);
+ return { ok: false, error: e.message };
+ }
+ });
+
+ function stripExe(filename: string) {
+ return filename.replace(/\.exe$/i, "");
+ }
+
+ ipcMain.handle("spt:checkGameRunning", async () => {
+ return await checkGameRunning();
+ });
+
+ ipcMain.handle("spt:getModVersionStatus", async () => {
+ return await getModVersionStatus();
+ });
+
+ ipcMain.handle(
+ "spt:setLocalModVersion",
+ async (_event, payload: { tag: string }) => {
+ const tag = payload.tag.trim();
+ if (!tag) {
+ return { ok: false, error: "empty_tag" };
+ }
+ const record = await writeModVersionRecord(tag);
+ return { ok: true, record };
+ },
+ );
+
+ ipcMain.handle(
+ "spt:runModSync",
+ async (
+ event,
+ payload: { targetDir: string; tag: string; cleanRepo?: boolean },
+ ) => {
+ return await runModSync(payload, (step) => {
+ event.sender.send("spt:modSyncProgress", step);
+ });
+ },
+ );
+
+ ipcMain.handle("spt:getSptInstallInfo", async () => {
+ return await resolveSptInstall();
+ });
+
+ ipcMain.handle(
+ "spt:setSptInstallPath",
+ async (_event, payload: { path: string; allowMissing?: boolean }) => {
+ let rawPath = payload.path?.trim();
+ if (!rawPath) {
+ return { ok: false, error: "empty_path" };
+ }
+
+ if (process.platform === "win32" && !payload.allowMissing) {
+ const resolved = await findSptInstallPath(rawPath);
+ if (!resolved) {
+ return { ok: false, error: "invalid_spt_path" };
}
- await new Promise(r => setTimeout(r, 1000));
- attempts++;
+ rawPath = resolved;
}
- }
- }
- // Launch Client
- try {
- // Prioritize EFT executable
- const candidates = [
- "EscapeFromTarkov.exe",
- "EscapeFromTarkov_BE.exe",
- "SPT/EscapeFromTarkov.exe",
- "SPT/EscapeFromTarkov_BE.exe"
- ];
+ const record = await writeSptPathRecord({
+ path: rawPath,
+ allowMissing: payload.allowMissing,
+ });
+ return { ok: true, record };
+ },
+ );
- let executablePath: string | undefined;
- for (const exe of candidates) {
- const fullPath = path.join(installInfo.path, exe);
- if (await pathExists(fullPath)) {
- executablePath = fullPath;
- break;
- }
+ ipcMain.handle("spt:pickSptInstallPath", async () => {
+ if (process.platform !== "win32") {
+ return { ok: false, error: "unsupported_platform" };
}
- if (!executablePath) {
- return { ok: false, error: "executable_not_found" };
- }
-
- // Get Session ID for args
- const session = await readSessionRecord();
- if (!session || isSessionExpired(session)) {
- return { ok: false, error: "session_required" };
- }
-
- // Build Args
- const configArg = JSON.stringify({
- BackendUrl: SERVER_BASE_URL,
- Version: "live"
+ const result = await dialog.showOpenDialog({
+ title: "SPT 설치 폴더 선택",
+ properties: ["openDirectory"],
});
- const args = [`-token=${session.sessionId}`, `-config=${configArg}`];
- console.log(`[spt-launcher] Launching: ${executablePath} with args`, args);
- // Sanitize Environment
- // Strip Electron/Node specifics to prevent BepInEx/Plugin conflicts
- const sanitizedEnv = { ...process.env };
- for (const key of Object.keys(sanitizedEnv)) {
- if (key.startsWith("ELECTRON_") || key.startsWith("NODE_") || key === "VITE_DEV_SERVER_URL") {
- delete sanitizedEnv[key];
- }
+ if (result.canceled || result.filePaths.length === 0) {
+ return { ok: false, error: "cancelled" };
}
- // Spawn detached
- const child = require("child_process").spawn(executablePath, args, {
- cwd: path.dirname(executablePath),
- detached: true,
- stdio: "ignore",
- env: sanitizedEnv
- });
- child.unref();
-
- mainWindow?.minimize();
- return { ok: true, executedPath: executablePath };
- } catch (e: any) {
- console.error("Launch failed", e);
- return { ok: false, error: e.message };
- }
- });
-
- function stripExe(filename: string) {
- return filename.replace(/\.exe$/i, "");
- }
-
- ipcMain.handle("spt:checkGameRunning", async () => {
- return await checkGameRunning();
- });
-
- ipcMain.handle("spt:getModVersionStatus", async () => {
- return await getModVersionStatus();
- });
-
- ipcMain.handle(
- "spt:setLocalModVersion",
- async (_event, payload: { tag: string }) => {
- const tag = payload.tag.trim();
- if (!tag) {
- return { ok: false, error: "empty_tag" };
- }
- const record = await writeModVersionRecord(tag);
- return { ok: true, record };
- },
- );
-
- ipcMain.handle(
- "spt:runModSync",
- async (
- event,
- payload: { targetDir: string; tag: string; cleanRepo?: boolean },
- ) => {
- return await runModSync(payload, (step) => {
- event.sender.send("spt:modSyncProgress", step);
- });
- },
- );
-
- ipcMain.handle("spt:getSptInstallInfo", async () => {
- return await resolveSptInstall();
- });
-
- ipcMain.handle(
- "spt:setSptInstallPath",
- async (_event, payload: { path: string; allowMissing?: boolean }) => {
- let rawPath = payload.path?.trim();
- if (!rawPath) {
- return { ok: false, error: "empty_path" };
- }
-
- if (process.platform === "win32" && !payload.allowMissing) {
- const resolved = await findSptInstallPath(rawPath);
- if (!resolved) {
- return { ok: false, error: "invalid_spt_path" };
- }
- rawPath = resolved;
+ const selected = result.filePaths[0];
+ const resolved = await findSptInstallPath(selected);
+ if (!resolved) {
+ return { ok: false, error: "invalid_spt_path", path: selected };
}
const record = await writeSptPathRecord({
- path: rawPath,
- allowMissing: payload.allowMissing,
+ path: resolved,
+ allowMissing: false,
});
return { ok: true, record };
- },
- );
-
- ipcMain.handle("spt:pickSptInstallPath", async () => {
- if (process.platform !== "win32") {
- return { ok: false, error: "unsupported_platform" };
- }
-
- const result = await dialog.showOpenDialog({
- title: "SPT 설치 폴더 선택",
- properties: ["openDirectory"],
});
- if (result.canceled || result.filePaths.length === 0) {
- return { ok: false, error: "cancelled" };
- }
+ ipcMain.handle(
+ "spt:register",
+ async (_event, payload: { username: string; password?: string }) => {
+ const username = payload.username.trim();
+ const password = payload.password?.trim();
+ return await registerProfile(username, password);
+ }
+ );
- const selected = result.filePaths[0];
- const resolved = await findSptInstallPath(selected);
- if (!resolved) {
- return { ok: false, error: "invalid_spt_path", path: selected };
- }
+ ipcMain.handle(
+ "spt:login",
+ async (_event, payload: { username: string; password?: string }) => {
+ const username = payload.username.trim();
+ const password = payload.password?.trim();
- const record = await writeSptPathRecord({
- path: resolved,
- allowMissing: false,
- });
- return { ok: true, record };
- });
-
- ipcMain.handle(
- "spt:fetchProfile",
- async (_event, payload: { username: string }) => {
- const username = payload.username.trim();
- let activeSessionId: string | undefined;
-
- const fetchProfileInfo = async (): Promise<{
- ok: boolean;
- status?: number;
- data?: unknown;
- error?: string;
- url: string;
- }> => {
- const sessionResult = await getSessionIdForUser(username);
+ const sessionResult = await getSessionIdForUser(username, password);
if (!sessionResult.ok) {
- return {
- ok: false,
- error: sessionResult.error ?? "login_failed",
- url: sessionResult.url,
- };
+ return sessionResult;
}
- activeSessionId = sessionResult.sessionId;
+
+ await writeSessionRecord({ username, sessionId: sessionResult.sessionId });
return await postJson(
"/launcher/profile/info",
@@ -1716,191 +1747,225 @@ app.whenReady().then(() => {
Cookie: `PHPSESSID=${sessionResult.sessionId}`,
},
);
- };
-
- let infoResult = await fetchProfileInfo();
- if (!infoResult.ok && isSessionInvalidStatus(infoResult.status)) {
- const relogin = await requestSessionId(username);
- if (!relogin.ok) {
- return {
- ok: false,
- error: relogin.error ?? "login_failed",
- url: relogin.url,
- };
- }
- await writeSessionRecord({ username, sessionId: relogin.sessionId });
- activeSessionId = relogin.sessionId;
- infoResult = await postJson(
- "/launcher/profile/info",
- { username },
- {
- Cookie: `PHPSESSID=${relogin.sessionId}`,
- },
- );
}
- if (infoResult.ok && infoResult.data) {
- if (activeSessionId) {
- await refreshSessionRecord({ username, sessionId: activeSessionId });
+ );
+
+ ipcMain.handle(
+ "spt:fetchProfile",
+ async (_event, payload: { username: string; password?: string }) => {
+ const username = payload.username.trim();
+ const password = payload.password?.trim();
+ let activeSessionId: string | undefined;
+
+ const fetchProfileInfo = async (): Promise<{
+ ok: boolean;
+ status?: number;
+ data?: unknown;
+ error?: string;
+ url: string;
+ }> => {
+ const sessionResult = await getSessionIdForUser(username, password);
+ if (!sessionResult.ok) {
+ return {
+ ok: false,
+ error: sessionResult.error ?? "login_failed",
+ url: sessionResult.url,
+ };
+ }
+ activeSessionId = sessionResult.sessionId;
+
+ return await postJson(
+ "/launcher/profile/info",
+ { username },
+ {
+ Cookie: `PHPSESSID=${sessionResult.sessionId}`,
+ },
+ );
+ };
+
+ let infoResult = await fetchProfileInfo();
+ if (!infoResult.ok && isSessionInvalidStatus(infoResult.status)) {
+ const relogin = await requestSessionId(username, password);
+ if (!relogin.ok) {
+ return {
+ ok: false,
+ error: relogin.error ?? "login_failed",
+ url: relogin.url,
+ };
+ }
+ await writeSessionRecord({ username, sessionId: relogin.sessionId });
+ activeSessionId = relogin.sessionId;
+ infoResult = await postJson(
+ "/launcher/profile/info",
+ { username },
+ {
+ Cookie: `PHPSESSID=${relogin.sessionId}`,
+ },
+ );
}
+ if (infoResult.ok && infoResult.data) {
+ if (activeSessionId) {
+ await refreshSessionRecord({ username, sessionId: activeSessionId });
+ }
+ return infoResult;
+ }
+
return infoResult;
- }
-
- await registerProfile(username);
- return await fetchProfileInfo();
- },
- );
-
- ipcMain.handle(
- "spt:downloadProfile",
- async (_event, payload: { username: string }) => {
- const username = payload.username.trim();
-
- const sessionResult = await getSessionIdForUser(username);
- if (!sessionResult.ok) {
- return sessionResult;
- }
-
- let activeSessionId = sessionResult.sessionId;
- let infoResult = await postJson(
- "/launcher/profile/info",
- { username },
- {
- Cookie: `PHPSESSID=${sessionResult.sessionId}`,
- },
- );
-
- if (!infoResult.ok && isSessionInvalidStatus(infoResult.status)) {
- const relogin = await requestSessionId(username);
- if (!relogin.ok) {
- return relogin;
- }
- await writeSessionRecord({ username, sessionId: relogin.sessionId });
- activeSessionId = relogin.sessionId;
- infoResult = await postJson(
- "/launcher/profile/info",
- { username },
- {
- Cookie: `PHPSESSID=${relogin.sessionId}`,
- },
- );
- }
-
- if (!infoResult.ok || !infoResult.data) {
- return infoResult;
- }
-
- await refreshSessionRecord({ username, sessionId: activeSessionId });
-
- const downloadsDir = app.getPath("downloads");
- const fileName = `spt-profile-${username}-${Date.now()}.json`;
- const filePath = path.join(downloadsDir, fileName);
-
- await fs.writeFile(
- filePath,
- JSON.stringify(infoResult.data, null, 2),
- "utf8",
- );
-
- return {
- ok: true,
- status: infoResult.status,
- url: infoResult.url,
- filePath,
- };
- },
- );
-
- ipcMain.handle(
- "spt:resetProfile",
- async (_event, payload: { username: string }) => {
- const username = payload.username.trim();
-
- const sessionResult = await getSessionIdForUser(username);
- if (!sessionResult.ok) {
- return sessionResult;
- }
-
- let activeSessionId = sessionResult.sessionId;
- let wipeResult = await postJson(
- "/launcher/profile/change/wipe",
- { username },
- {
- Cookie: `PHPSESSID=${sessionResult.sessionId}`,
- },
- );
-
- if (!wipeResult.ok && isSessionInvalidStatus(wipeResult.status)) {
- const relogin = await requestSessionId(username);
- if (!relogin.ok) {
- return relogin;
- }
- await writeSessionRecord({ username, sessionId: relogin.sessionId });
- activeSessionId = relogin.sessionId;
- wipeResult = await postJson(
- "/launcher/profile/change/wipe",
- { username },
- {
- Cookie: `PHPSESSID=${relogin.sessionId}`,
- },
- );
- }
-
- if (wipeResult.ok) {
- await refreshSessionRecord({ username, sessionId: activeSessionId });
- }
- return wipeResult;
- },
- );
-
- ipcMain.handle("spt:resumeSession", async () => {
- const record = await readSessionRecord();
- if (!record || isSessionExpired(record)) {
- await clearSessionRecord();
- return { ok: false, error: "session_expired", url: SERVER_BASE_URL };
- }
-
- const infoResult = await postJson(
- "/launcher/profile/info",
- { username: record.username },
- {
- Cookie: `PHPSESSID=${record.sessionId}`,
},
);
- if (!infoResult.ok && isSessionInvalidStatus(infoResult.status)) {
+ ipcMain.handle(
+ "spt:downloadProfile",
+ async (_event, payload: { username: string }) => {
+ const username = payload.username.trim();
+
+ const sessionResult = await getSessionIdForUser(username);
+ if (!sessionResult.ok) {
+ return sessionResult;
+ }
+
+ let activeSessionId = sessionResult.sessionId;
+ let infoResult = await postJson(
+ "/launcher/profile/info",
+ { username },
+ {
+ Cookie: `PHPSESSID=${sessionResult.sessionId}`,
+ },
+ );
+
+ if (!infoResult.ok && isSessionInvalidStatus(infoResult.status)) {
+ const relogin = await requestSessionId(username);
+ if (!relogin.ok) {
+ return relogin;
+ }
+ await writeSessionRecord({ username, sessionId: relogin.sessionId });
+ activeSessionId = relogin.sessionId;
+ infoResult = await postJson(
+ "/launcher/profile/info",
+ { username },
+ {
+ Cookie: `PHPSESSID=${relogin.sessionId}`,
+ },
+ );
+ }
+
+ if (!infoResult.ok || !infoResult.data) {
+ return infoResult;
+ }
+
+ await refreshSessionRecord({ username, sessionId: activeSessionId });
+
+ const downloadsDir = app.getPath("downloads");
+ const fileName = `spt-profile-${username}-${Date.now()}.json`;
+ const filePath = path.join(downloadsDir, fileName);
+
+ await fs.writeFile(
+ filePath,
+ JSON.stringify(infoResult.data, null, 2),
+ "utf8",
+ );
+
+ return {
+ ok: true,
+ status: infoResult.status,
+ url: infoResult.url,
+ filePath,
+ };
+ },
+ );
+
+ ipcMain.handle(
+ "spt:resetProfile",
+ async (_event, payload: { username: string }) => {
+ const username = payload.username.trim();
+
+ const sessionResult = await getSessionIdForUser(username);
+ if (!sessionResult.ok) {
+ return sessionResult;
+ }
+
+ let activeSessionId = sessionResult.sessionId;
+ let wipeResult = await postJson(
+ "/launcher/profile/change/wipe",
+ { username },
+ {
+ Cookie: `PHPSESSID=${sessionResult.sessionId}`,
+ },
+ );
+
+ if (!wipeResult.ok && isSessionInvalidStatus(wipeResult.status)) {
+ const relogin = await requestSessionId(username);
+ if (!relogin.ok) {
+ return relogin;
+ }
+ await writeSessionRecord({ username, sessionId: relogin.sessionId });
+ activeSessionId = relogin.sessionId;
+ wipeResult = await postJson(
+ "/launcher/profile/change/wipe",
+ { username },
+ {
+ Cookie: `PHPSESSID=${relogin.sessionId}`,
+ },
+ );
+ }
+
+ if (wipeResult.ok) {
+ await refreshSessionRecord({ username, sessionId: activeSessionId });
+ }
+ return wipeResult;
+ },
+ );
+
+ ipcMain.handle("spt:resumeSession", async () => {
+ const record = await readSessionRecord();
+ if (!record || isSessionExpired(record)) {
+ await clearSessionRecord();
+ return { ok: false, error: "session_expired", url: SERVER_BASE_URL };
+ }
+
+ const infoResult = await postJson(
+ "/launcher/profile/info",
+ { username: record.username },
+ {
+ Cookie: `PHPSESSID=${record.sessionId}`,
+ },
+ );
+
+ if (!infoResult.ok && isSessionInvalidStatus(infoResult.status)) {
+ await clearSessionRecord();
+ return { ok: false, error: "session_expired", url: infoResult.url };
+ }
+
+ if (infoResult.ok) {
+ await refreshSessionRecord({
+ username: record.username,
+ sessionId: record.sessionId,
+ });
+ }
+ return infoResult;
+ });
+
+ ipcMain.handle("spt:clearSession", async () => {
await clearSessionRecord();
- return { ok: false, error: "session_expired", url: infoResult.url };
- }
+ return { ok: true };
+ });
- if (infoResult.ok) {
- await refreshSessionRecord({
- username: record.username,
- sessionId: record.sessionId,
- });
- }
- return infoResult;
+ // 개발/운영 모두에서 부팅 시 1회 즉시 체크(instant check) 로그를 남겨
+ // 엔드포인트(endpoint) 확인이 가능하도록 합니다.
+ void checkServerHealth();
+
+ createWindow();
+
+ app.on("activate", () => {
+ if (BrowserWindow.getAllWindows().length === 0) {
+ createWindow();
+ }
+ });
});
- ipcMain.handle("spt:clearSession", async () => {
- await clearSessionRecord();
- return { ok: true };
- });
-
- // 개발/운영 모두에서 부팅 시 1회 즉시 체크(instant check) 로그를 남겨
- // 엔드포인트(endpoint) 확인이 가능하도록 합니다.
- void checkServerHealth();
-
- createWindow();
-
- app.on("activate", () => {
- if (BrowserWindow.getAllWindows().length === 0) {
- createWindow();
+ app.on("window-all-closed", () => {
+ if (process.platform !== "darwin") {
+ app.quit();
}
});
-});
-
-app.on("window-all-closed", () => {
- if (process.platform !== "darwin") {
- app.quit();
- }
-});
+}
diff --git a/src/preload/preload.ts b/src/preload/preload.ts
index 025928d..0289e83 100644
--- a/src/preload/preload.ts
+++ b/src/preload/preload.ts
@@ -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),
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index e8cfead..d9d6d94 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -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) : 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 = () => {
회원가입 후 서버 관리자 승인이 완료되어야 로그인 가능합니다.
-
-
-
+ setSignupId(e.target.value)}
+ />
+ setSignupPassword(e.target.value)}
+ />
+ setConfirmPassword(e.target.value)}
+ />
-