Compare commits
2 Commits
062911a9d0
...
944c77f146
| Author | SHA1 | Date |
|---|---|---|
|
|
944c77f146 | |
|
|
46c8d9b29c |
298
src/main/main.ts
298
src/main/main.ts
|
|
@ -1,4 +1,6 @@
|
||||||
import { app, BrowserWindow, ipcMain, net, session } from "electron";
|
import { app, BrowserWindow, ipcMain, net, session } from "electron";
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import * as fs from "node:fs/promises";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
|
||||||
const isDev = Boolean(process.env.VITE_DEV_SERVER_URL);
|
const isDev = Boolean(process.env.VITE_DEV_SERVER_URL);
|
||||||
|
|
@ -17,6 +19,42 @@ const SERVER_BASE_URL = "https://pandoli365.com:5069/";
|
||||||
const SERVER_HEALTHCHECK_PATH = "/launcher/ping";
|
const SERVER_HEALTHCHECK_PATH = "/launcher/ping";
|
||||||
const SERVER_HEALTHCHECK_TIMEOUT_MS = 2000;
|
const SERVER_HEALTHCHECK_TIMEOUT_MS = 2000;
|
||||||
const SERVER_REQUEST_TIMEOUT_MS = 4000;
|
const SERVER_REQUEST_TIMEOUT_MS = 4000;
|
||||||
|
const COMMAND_TIMEOUT_MS = 4000;
|
||||||
|
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
type CommandCheckResult = {
|
||||||
|
ok: boolean;
|
||||||
|
command: string;
|
||||||
|
version?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CommandPathResult = {
|
||||||
|
ok: boolean;
|
||||||
|
command: string;
|
||||||
|
path?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CommandRunResult = {
|
||||||
|
ok: boolean;
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
output?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InstallStep = {
|
||||||
|
name: string;
|
||||||
|
result: CommandRunResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InstallGitToolsResult = {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
steps: InstallStep[];
|
||||||
|
check?: Awaited<ReturnType<typeof checkGitTools>>;
|
||||||
|
};
|
||||||
|
|
||||||
const checkServerHealth = async (): Promise<ServerHealthResult> => {
|
const checkServerHealth = async (): Promise<ServerHealthResult> => {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
|
@ -77,6 +115,153 @@ const checkServerHealth = async (): Promise<ServerHealthResult> => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const checkCommand = async (command: string, args: string[]): Promise<CommandCheckResult> => {
|
||||||
|
return await new Promise<CommandCheckResult>((resolve) => {
|
||||||
|
execFile(command, args, { timeout: COMMAND_TIMEOUT_MS }, (error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
resolve({ ok: false, command, error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = `${stdout ?? ""}${stderr ?? ""}`.trim();
|
||||||
|
resolve({ ok: true, command, version: output });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkGitTools = async () => {
|
||||||
|
const git = await checkCommand("git", ["--version"]);
|
||||||
|
const lfs = await checkCommand("git", ["lfs", "version"]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
git,
|
||||||
|
lfs,
|
||||||
|
checkedAt: Date.now()
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveCommandPath = async (command: string): Promise<CommandPathResult> => {
|
||||||
|
const locator = process.platform === "win32" ? "where" : "which";
|
||||||
|
|
||||||
|
return await new Promise<CommandPathResult>((resolve) => {
|
||||||
|
execFile(locator, [command], { timeout: COMMAND_TIMEOUT_MS }, (error, stdout) => {
|
||||||
|
if (error) {
|
||||||
|
resolve({ ok: false, command, error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = String(stdout ?? "").trim();
|
||||||
|
const firstLine = output.split(/\r?\n/).find((line) => line.trim().length > 0);
|
||||||
|
resolve({ ok: Boolean(firstLine), command, path: firstLine });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const runCommand = async (
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<CommandRunResult> => {
|
||||||
|
return await new Promise<CommandRunResult>((resolve) => {
|
||||||
|
execFile(command, args, { timeout: timeoutMs }, (error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
resolve({
|
||||||
|
ok: false,
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
error: error.message,
|
||||||
|
output: `${stdout ?? ""}${stderr ?? ""}`.trim()
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = `${stdout ?? ""}${stderr ?? ""}`.trim();
|
||||||
|
resolve({ ok: true, command, args, output });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGitPaths = async () => {
|
||||||
|
const git = await resolveCommandPath("git");
|
||||||
|
const lfs = await resolveCommandPath("git-lfs");
|
||||||
|
|
||||||
|
return {
|
||||||
|
git,
|
||||||
|
lfs,
|
||||||
|
checkedAt: Date.now()
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const installGitTools = async (): Promise<InstallGitToolsResult> => {
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
return { ok: false, error: "Windows에서만 자동 설치가 가능합니다.", steps: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const wingetPath = await resolveCommandPath("winget");
|
||||||
|
if (!wingetPath.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "winget이 설치되어 있지 않습니다.",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
name: "winget 확인",
|
||||||
|
result: {
|
||||||
|
ok: false,
|
||||||
|
command: "winget",
|
||||||
|
args: ["--version"],
|
||||||
|
error: wingetPath.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps: InstallStep[] = [];
|
||||||
|
|
||||||
|
const gitInstall = await runCommand(
|
||||||
|
"winget",
|
||||||
|
[
|
||||||
|
"install",
|
||||||
|
"--id",
|
||||||
|
"Git.Git",
|
||||||
|
"-e",
|
||||||
|
"--source",
|
||||||
|
"winget",
|
||||||
|
"--accept-source-agreements",
|
||||||
|
"--accept-package-agreements",
|
||||||
|
"--silent"
|
||||||
|
],
|
||||||
|
INSTALL_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
steps.push({ name: "Git 설치", result: gitInstall });
|
||||||
|
|
||||||
|
const lfsInstall = await runCommand(
|
||||||
|
"winget",
|
||||||
|
[
|
||||||
|
"install",
|
||||||
|
"--id",
|
||||||
|
"GitHub.GitLFS",
|
||||||
|
"-e",
|
||||||
|
"--source",
|
||||||
|
"winget",
|
||||||
|
"--accept-source-agreements",
|
||||||
|
"--accept-package-agreements",
|
||||||
|
"--silent"
|
||||||
|
],
|
||||||
|
INSTALL_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
steps.push({ name: "Git LFS 설치", result: lfsInstall });
|
||||||
|
|
||||||
|
const ok = steps.every((step) => step.result.ok);
|
||||||
|
const check = await checkGitTools();
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
steps,
|
||||||
|
check
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const postJson = async <T>(
|
const postJson = async <T>(
|
||||||
path: string,
|
path: string,
|
||||||
body: unknown,
|
body: unknown,
|
||||||
|
|
@ -218,6 +403,27 @@ const postText = async (path: string, body: unknown) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const requestSessionId = async (username: string) => {
|
||||||
|
const loginResult = await postText("/launcher/profile/login", { username });
|
||||||
|
if (!loginResult.ok) {
|
||||||
|
return { ok: false, error: loginResult.error ?? "login_failed", url: loginResult.url };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = loginResult.data?.trim();
|
||||||
|
if (!sessionId) {
|
||||||
|
return { ok: false, error: "empty_session", url: loginResult.url };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, sessionId, url: loginResult.url };
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerProfile = async (username: string) => {
|
||||||
|
return await postJson("/launcher/profile/register", {
|
||||||
|
username,
|
||||||
|
edition: "Standard"
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const createWindow = () => {
|
const createWindow = () => {
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
|
|
@ -266,20 +472,102 @@ app.whenReady().then(() => {
|
||||||
return await checkServerHealth();
|
return await checkServerHealth();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("spt:checkGitTools", async () => {
|
||||||
|
return await checkGitTools();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("spt:getGitPaths", async () => {
|
||||||
|
return await getGitPaths();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("spt:installGitTools", async () => {
|
||||||
|
return await installGitTools();
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle("spt:fetchProfile", async (_event, payload: { username: string }) => {
|
ipcMain.handle("spt:fetchProfile", async (_event, payload: { username: string }) => {
|
||||||
const username = payload.username.trim();
|
const username = payload.username.trim();
|
||||||
|
|
||||||
const loginResult = await postText("/launcher/profile/login", { username });
|
const fetchProfileInfo = async (): Promise<{
|
||||||
if (!loginResult.ok || !loginResult.data) {
|
ok: boolean;
|
||||||
return loginResult;
|
status?: number;
|
||||||
|
data?: unknown;
|
||||||
|
error?: string;
|
||||||
|
url: string;
|
||||||
|
}> => {
|
||||||
|
const sessionResult = await requestSessionId(username);
|
||||||
|
if (!sessionResult.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: sessionResult.error ?? "login_failed",
|
||||||
|
url: sessionResult.url
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = loginResult.data.trim();
|
|
||||||
return await postJson(
|
return await postJson(
|
||||||
"/launcher/profile/info",
|
"/launcher/profile/info",
|
||||||
{ username },
|
{ username },
|
||||||
{
|
{
|
||||||
Cookie: `PHPSESSID=${sessionId}`
|
Cookie: `PHPSESSID=${sessionResult.sessionId}`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const infoResult = await fetchProfileInfo();
|
||||||
|
if (infoResult.ok && infoResult.data) {
|
||||||
|
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 requestSessionId(username);
|
||||||
|
if (!sessionResult.ok) {
|
||||||
|
return sessionResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const infoResult = await postJson(
|
||||||
|
"/launcher/profile/info",
|
||||||
|
{ username },
|
||||||
|
{
|
||||||
|
Cookie: `PHPSESSID=${sessionResult.sessionId}`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!infoResult.ok || !infoResult.data) {
|
||||||
|
return infoResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 requestSessionId(username);
|
||||||
|
if (!sessionResult.ok) {
|
||||||
|
return sessionResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await postJson(
|
||||||
|
"/launcher/profile/change/wipe",
|
||||||
|
{ username },
|
||||||
|
{
|
||||||
|
Cookie: `PHPSESSID=${sessionResult.sessionId}`
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,13 @@ console.info("[spt-launcher] preload loaded");
|
||||||
contextBridge.exposeInMainWorld("sptLauncher", {
|
contextBridge.exposeInMainWorld("sptLauncher", {
|
||||||
appName: "SPT Launcher",
|
appName: "SPT Launcher",
|
||||||
checkServerHealth: () => ipcRenderer.invoke("spt:checkServerHealth"),
|
checkServerHealth: () => ipcRenderer.invoke("spt:checkServerHealth"),
|
||||||
|
checkGitTools: () => ipcRenderer.invoke("spt:checkGitTools"),
|
||||||
|
getGitPaths: () => ipcRenderer.invoke("spt:getGitPaths"),
|
||||||
|
installGitTools: () => ipcRenderer.invoke("spt:installGitTools"),
|
||||||
fetchProfile: (payload: { username: string }) =>
|
fetchProfile: (payload: { username: string }) =>
|
||||||
ipcRenderer.invoke("spt:fetchProfile", payload)
|
ipcRenderer.invoke("spt:fetchProfile", payload),
|
||||||
|
downloadProfile: (payload: { username: string }) =>
|
||||||
|
ipcRenderer.invoke("spt:downloadProfile", payload),
|
||||||
|
resetProfile: (payload: { username: string }) =>
|
||||||
|
ipcRenderer.invoke("spt:resetProfile", payload)
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,28 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
type Screen = "serverCheck" | "login" | "signup" | "resetPassword" | "main";
|
type Screen = "serverCheck" | "login" | "signup" | "resetPassword" | "main";
|
||||||
|
type CommandCheck = {
|
||||||
|
ok: boolean;
|
||||||
|
command: string;
|
||||||
|
version?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
type GitToolsCheck = {
|
||||||
|
git: CommandCheck;
|
||||||
|
lfs: CommandCheck;
|
||||||
|
checkedAt: number;
|
||||||
|
};
|
||||||
|
type CommandPath = {
|
||||||
|
ok: boolean;
|
||||||
|
command: string;
|
||||||
|
path?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
type GitPathsCheck = {
|
||||||
|
git: CommandPath;
|
||||||
|
lfs: CommandPath;
|
||||||
|
checkedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
const HEALTHCHECK_INTERVAL_MS = 10000;
|
const HEALTHCHECK_INTERVAL_MS = 10000;
|
||||||
const HEALTHCHECK_ENDPOINT_FALLBACK = "https://pandoli365.com:5069/launcher/ping";
|
const HEALTHCHECK_ENDPOINT_FALLBACK = "https://pandoli365.com:5069/launcher/ping";
|
||||||
|
|
@ -14,6 +36,14 @@ const App = () => {
|
||||||
const [serverCheckedUrl, setServerCheckedUrl] = useState<string | undefined>();
|
const [serverCheckedUrl, setServerCheckedUrl] = useState<string | undefined>();
|
||||||
const [serverCheckInProgress, setServerCheckInProgress] = useState(false);
|
const [serverCheckInProgress, setServerCheckInProgress] = useState(false);
|
||||||
const [serverCheckedAt, setServerCheckedAt] = useState<number | undefined>();
|
const [serverCheckedAt, setServerCheckedAt] = useState<number | undefined>();
|
||||||
|
const [gitCheckInProgress, setGitCheckInProgress] = useState(false);
|
||||||
|
const [gitCheckedAt, setGitCheckedAt] = useState<number | undefined>();
|
||||||
|
const [gitCheckResult, setGitCheckResult] = useState<GitToolsCheck | undefined>();
|
||||||
|
const [gitPathsLoading, setGitPathsLoading] = useState(false);
|
||||||
|
const [gitPathsResult, setGitPathsResult] = useState<GitPathsCheck | undefined>();
|
||||||
|
const [toolsModalOpen, setToolsModalOpen] = useState(false);
|
||||||
|
const [installInProgress, setInstallInProgress] = useState(false);
|
||||||
|
const [installMessage, setInstallMessage] = useState<string | undefined>();
|
||||||
const [hasSession, setHasSession] = useState(false);
|
const [hasSession, setHasSession] = useState(false);
|
||||||
const [loginId, setLoginId] = useState("");
|
const [loginId, setLoginId] = useState("");
|
||||||
const [loginPassword, setLoginPassword] = useState("");
|
const [loginPassword, setLoginPassword] = useState("");
|
||||||
|
|
@ -25,9 +55,48 @@ const App = () => {
|
||||||
side?: string;
|
side?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
} | undefined>();
|
} | undefined>();
|
||||||
|
const [profileActionInProgress, setProfileActionInProgress] = useState(false);
|
||||||
const [syncInProgress, setSyncInProgress] = useState(false);
|
const [syncInProgress, setSyncInProgress] = useState(false);
|
||||||
const [simulateSyncFail, setSimulateSyncFail] = useState(false);
|
const [simulateSyncFail, setSimulateSyncFail] = useState(false);
|
||||||
const healthCheckInFlightRef = useRef(false);
|
const healthCheckInFlightRef = useRef(false);
|
||||||
|
const gitCheckInFlightRef = useRef(false);
|
||||||
|
const gitCheckedRef = useRef(false);
|
||||||
|
|
||||||
|
const runGitCheck = useCallback(async (force = false) => {
|
||||||
|
if (force) {
|
||||||
|
gitCheckedRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gitCheckInFlightRef.current || (!force && gitCheckedRef.current)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gitCheckInFlightRef.current = true;
|
||||||
|
setGitCheckInProgress(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!window.sptLauncher?.checkGitTools) {
|
||||||
|
throw new Error("preload(preload) 미로딩: window.sptLauncher.checkGitTools가 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await window.sptLauncher.checkGitTools();
|
||||||
|
setGitCheckResult(result);
|
||||||
|
setGitCheckedAt(result.checkedAt ?? Date.now());
|
||||||
|
gitCheckedRef.current = true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setGitCheckResult({
|
||||||
|
git: { ok: false, command: "git", error: message },
|
||||||
|
lfs: { ok: false, command: "git lfs", error: message },
|
||||||
|
checkedAt: Date.now()
|
||||||
|
});
|
||||||
|
setGitCheckedAt(Date.now());
|
||||||
|
gitCheckedRef.current = true;
|
||||||
|
} finally {
|
||||||
|
gitCheckInFlightRef.current = false;
|
||||||
|
setGitCheckInProgress(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const runHealthCheck = useCallback(async () => {
|
const runHealthCheck = useCallback(async () => {
|
||||||
if (healthCheckInFlightRef.current) {
|
if (healthCheckInFlightRef.current) {
|
||||||
|
|
@ -52,6 +121,9 @@ const App = () => {
|
||||||
setServerCheckedAt(Date.now());
|
setServerCheckedAt(Date.now());
|
||||||
|
|
||||||
if (screen === "serverCheck" && result.ok) {
|
if (screen === "serverCheck" && result.ok) {
|
||||||
|
if (!gitCheckedRef.current) {
|
||||||
|
await runGitCheck();
|
||||||
|
}
|
||||||
setScreen(hasSession ? "main" : "login");
|
setScreen(hasSession ? "main" : "login");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -88,6 +160,91 @@ const App = () => {
|
||||||
return serverHealthy ? "정상" : "불가";
|
return serverHealthy ? "정상" : "불가";
|
||||||
}, [serverHealthy]);
|
}, [serverHealthy]);
|
||||||
|
|
||||||
|
const serverStatusLedClass = serverCheckInProgress ? "idle" : serverHealthy ? "ok" : "blocked";
|
||||||
|
const gitStatusLedClass = (() => {
|
||||||
|
if (gitCheckInProgress) {
|
||||||
|
return "idle";
|
||||||
|
}
|
||||||
|
if (!gitCheckResult) {
|
||||||
|
return "idle";
|
||||||
|
}
|
||||||
|
if (gitCheckResult.git.ok && gitCheckResult.lfs.ok) {
|
||||||
|
return "ok";
|
||||||
|
}
|
||||||
|
return "blocked";
|
||||||
|
})();
|
||||||
|
const gitLedClass = gitCheckInProgress
|
||||||
|
? "idle"
|
||||||
|
: !gitCheckResult
|
||||||
|
? "idle"
|
||||||
|
: gitCheckResult.git.ok
|
||||||
|
? "ok"
|
||||||
|
: "blocked";
|
||||||
|
const lfsLedClass = gitCheckInProgress
|
||||||
|
? "idle"
|
||||||
|
: !gitCheckResult
|
||||||
|
? "idle"
|
||||||
|
: gitCheckResult.lfs.ok
|
||||||
|
? "ok"
|
||||||
|
: "blocked";
|
||||||
|
|
||||||
|
const handleOpenToolsModal = () => {
|
||||||
|
setToolsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRecheckTools = async () => {
|
||||||
|
await runGitCheck(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstallTools = async () => {
|
||||||
|
if (!window.sptLauncher?.installGitTools) {
|
||||||
|
window.alert("자동 설치 기능이 준비되지 않았습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInstallInProgress(true);
|
||||||
|
setInstallMessage(undefined);
|
||||||
|
try {
|
||||||
|
const result = await window.sptLauncher.installGitTools();
|
||||||
|
if (!result.ok) {
|
||||||
|
setInstallMessage(result.error ?? "자동 설치에 실패했습니다.");
|
||||||
|
} else {
|
||||||
|
setInstallMessage("자동 설치가 완료되었습니다.");
|
||||||
|
}
|
||||||
|
if (result.check) {
|
||||||
|
setGitCheckResult(result.check);
|
||||||
|
setGitCheckedAt(result.check.checkedAt ?? Date.now());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setInstallMessage(message);
|
||||||
|
} finally {
|
||||||
|
setInstallInProgress(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFetchGitPaths = async () => {
|
||||||
|
if (!window.sptLauncher?.getGitPaths) {
|
||||||
|
window.alert("경로 확인 기능이 준비되지 않았습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGitPathsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await window.sptLauncher.getGitPaths();
|
||||||
|
setGitPathsResult(result);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setGitPathsResult({
|
||||||
|
git: { ok: false, command: "git", error: message },
|
||||||
|
lfs: { ok: false, command: "git-lfs", error: message },
|
||||||
|
checkedAt: Date.now()
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setGitPathsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const asObject = (value: unknown) =>
|
const asObject = (value: unknown) =>
|
||||||
value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
|
value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
|
||||||
const pickString = (...values: unknown[]) =>
|
const pickString = (...values: unknown[]) =>
|
||||||
|
|
@ -189,6 +346,69 @@ const App = () => {
|
||||||
setScreen("login");
|
setScreen("login");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleProfileDownload = async () => {
|
||||||
|
const username = profile?.username?.trim() ?? loginId.trim();
|
||||||
|
if (!username) {
|
||||||
|
window.alert("아이디를 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.sptLauncher?.downloadProfile) {
|
||||||
|
window.alert("프로필 다운로드 기능이 준비되지 않았습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProfileActionInProgress(true);
|
||||||
|
try {
|
||||||
|
const result = await window.sptLauncher.downloadProfile({ username });
|
||||||
|
if (!result.ok) {
|
||||||
|
window.alert(result.error ?? "프로필 다운로드에 실패했습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.alert(`프로필을 다운로드했습니다: ${result.filePath ?? "-"}`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
window.alert(message);
|
||||||
|
} finally {
|
||||||
|
setProfileActionInProgress(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProfileReset = async () => {
|
||||||
|
const username = profile?.username?.trim() ?? loginId.trim();
|
||||||
|
if (!username) {
|
||||||
|
window.alert("아이디를 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.sptLauncher?.resetProfile) {
|
||||||
|
window.alert("프로필 리셋 기능이 준비되지 않았습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = window.confirm("프로필을 리셋할까요?");
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProfileActionInProgress(true);
|
||||||
|
try {
|
||||||
|
const result = await window.sptLauncher.resetProfile({ username });
|
||||||
|
if (!result.ok) {
|
||||||
|
window.alert(result.error ?? "프로필 리셋에 실패했습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.alert("프로필 리셋 요청이 완료되었습니다.");
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
window.alert(message);
|
||||||
|
} finally {
|
||||||
|
setProfileActionInProgress(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleLaunch = () => {
|
const handleLaunch = () => {
|
||||||
if (syncInProgress) {
|
if (syncInProgress) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -232,7 +452,7 @@ const App = () => {
|
||||||
: "서버 연결이 불가합니다. 10초마다 자동으로 재시도합니다."}
|
: "서버 연결이 불가합니다. 10초마다 자동으로 재시도합니다."}
|
||||||
</p>
|
</p>
|
||||||
<p className="notice">
|
<p className="notice">
|
||||||
{serverCheckedUrl ? `Endpoint(endpoint): ${serverCheckedUrl}` : "Endpoint(endpoint): (unknown)"}
|
{serverCheckedUrl ? `엔드포인트: ${serverCheckedUrl}` : "엔드포인트: "}
|
||||||
{serverStatusCode ? ` · HTTP ${serverStatusCode}` : ""}
|
{serverStatusCode ? ` · HTTP ${serverStatusCode}` : ""}
|
||||||
{typeof serverLatencyMs === "number" ? ` · ${serverLatencyMs}ms` : ""}
|
{typeof serverLatencyMs === "number" ? ` · ${serverLatencyMs}ms` : ""}
|
||||||
{serverError ? ` · ${serverError}` : ""}
|
{serverError ? ` · ${serverError}` : ""}
|
||||||
|
|
@ -245,6 +465,19 @@ const App = () => {
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{serverHealthy && (
|
||||||
|
<div className="notice">
|
||||||
|
<div className="tool-led">
|
||||||
|
<span className="tool-label">Git</span>
|
||||||
|
<span className={`led ${gitLedClass}`} aria-label="Git 상태" />
|
||||||
|
</div>
|
||||||
|
<div className="tool-led">
|
||||||
|
<span className="tool-label">Git LFS</span>
|
||||||
|
<span className={`led ${lfsLedClass}`} aria-label="Git LFS 상태" />
|
||||||
|
</div>
|
||||||
|
{gitCheckedAt ? <div className="helper">{new Date(gitCheckedAt).toLocaleTimeString()}</div> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -349,14 +582,21 @@ const App = () => {
|
||||||
<section className="card profile">
|
<section className="card profile">
|
||||||
<div>
|
<div>
|
||||||
<h2>프로필 정보</h2>
|
<h2>프로필 정보</h2>
|
||||||
<p className="muted">아이디: {profile?.username ?? "-"}</p>
|
<p className="muted">아이디: {profile?.username ?? ""}</p>
|
||||||
<p className="muted">닉네임: {profile?.nickname ?? "미등록"}</p>
|
<p className="muted">닉네임: {profile?.nickname ?? ""}</p>
|
||||||
<p className="muted">레벨: {typeof profile?.level === "number" ? profile.level : "-"}</p>
|
<p className="muted">레벨: {typeof profile?.level === "number" ? profile.level : ""}</p>
|
||||||
{profile?.side && <p className="muted">진영: {profile.side}</p>}
|
{profile?.side && <p className="muted">진영: {profile.side}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="profile-actions">
|
<div className="profile-actions">
|
||||||
<button type="button">프로필 다운로드</button>
|
<button type="button" onClick={handleProfileDownload} disabled={profileActionInProgress}>
|
||||||
<button type="button" className="ghost">
|
{profileActionInProgress ? "처리 중..." : "프로필 다운로드"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost"
|
||||||
|
onClick={handleProfileReset}
|
||||||
|
disabled={profileActionInProgress}
|
||||||
|
>
|
||||||
프로필 리셋
|
프로필 리셋
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -397,6 +637,80 @@ const App = () => {
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
)}
|
)}
|
||||||
|
{screen !== "serverCheck" && (
|
||||||
|
<nav className="status-bar">
|
||||||
|
<div className="status-item">
|
||||||
|
<span className="label">서버 상태</span>
|
||||||
|
<div className="status-item-right">
|
||||||
|
<span className={`led ${serverStatusLedClass}`} aria-label="서버 상태" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="status-item">
|
||||||
|
<button type="button" className="status-button" onClick={handleOpenToolsModal}>
|
||||||
|
도구 상태
|
||||||
|
</button>
|
||||||
|
<div className="status-item-right">
|
||||||
|
<div className="tool-led">
|
||||||
|
<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 상태" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
{screen !== "serverCheck" && toolsModalOpen && (
|
||||||
|
<div className="modal-overlay" role="presentation" onClick={() => setToolsModalOpen(false)}>
|
||||||
|
<section
|
||||||
|
className="modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="도구 상태"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<header className="modal-header">
|
||||||
|
<h3>도구 상태</h3>
|
||||||
|
<button type="button" className="ghost" onClick={() => setToolsModalOpen(false)}>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="modal-row">
|
||||||
|
<span className="label">Git</span>
|
||||||
|
<span className={`led ${gitLedClass}`} aria-label="Git 상태" />
|
||||||
|
</div>
|
||||||
|
<div className="modal-row">
|
||||||
|
<span className="label">Git LFS</span>
|
||||||
|
<span className={`led ${lfsLedClass}`} aria-label="Git LFS 상태" />
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button type="button" onClick={handleRecheckTools} disabled={gitCheckInProgress}>
|
||||||
|
{gitCheckInProgress ? "점검 중..." : "재확인"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ghost" onClick={handleInstallTools} disabled={installInProgress}>
|
||||||
|
{installInProgress ? "설치 중..." : "자동 재설치"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ghost" onClick={handleFetchGitPaths} disabled={gitPathsLoading}>
|
||||||
|
{gitPathsLoading ? "확인 중..." : "경로 확인"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{installMessage && <div className="notice">{installMessage}</div>}
|
||||||
|
{gitPathsResult && (
|
||||||
|
<div className="notice">
|
||||||
|
<strong>경로 확인</strong>
|
||||||
|
<div className="modal-help">
|
||||||
|
<div>Git: {gitPathsResult.git.ok ? gitPathsResult.git.path : gitPathsResult.git.error}</div>
|
||||||
|
<div>Git LFS: {gitPathsResult.lfs.ok ? gitPathsResult.lfs.path : gitPathsResult.lfs.error}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -190,3 +190,149 @@ button:disabled {
|
||||||
.muted {
|
.muted {
|
||||||
color: #a8b0c1;
|
color: #a8b0c1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 20px;
|
||||||
|
right: 20px;
|
||||||
|
bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #141923;
|
||||||
|
border: 1px solid #242a35;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: #0f141d;
|
||||||
|
border: 1px solid #242a35;
|
||||||
|
border-radius: 8px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar .label {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar .status {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-button {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #a8b0c1;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-button:hover {
|
||||||
|
color: #c7d0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-led {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #a8b0c1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.led {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #2b2f3a;
|
||||||
|
box-shadow: 0 0 0 2px #0f1218;
|
||||||
|
}
|
||||||
|
|
||||||
|
.led.ok {
|
||||||
|
background: #44d38f;
|
||||||
|
box-shadow: 0 0 6px rgba(68, 211, 143, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.led.idle {
|
||||||
|
background: #9aa3b2;
|
||||||
|
box-shadow: 0 0 6px rgba(154, 163, 178, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.led.blocked {
|
||||||
|
background: #f07171;
|
||||||
|
box-shadow: 0 0 6px rgba(240, 113, 113, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(7, 10, 16, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: min(520px, 90vw);
|
||||||
|
background: #141923;
|
||||||
|
border: 1px solid #242a35;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-help {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #cbd2e0;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,65 @@ interface Window {
|
||||||
error?: string;
|
error?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
}>;
|
}>;
|
||||||
|
checkGitTools: () => Promise<{
|
||||||
|
git: {
|
||||||
|
ok: boolean;
|
||||||
|
command: string;
|
||||||
|
version?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
lfs: {
|
||||||
|
ok: boolean;
|
||||||
|
command: string;
|
||||||
|
version?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
checkedAt: number;
|
||||||
|
}>;
|
||||||
|
getGitPaths: () => Promise<{
|
||||||
|
git: {
|
||||||
|
ok: boolean;
|
||||||
|
command: string;
|
||||||
|
path?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
lfs: {
|
||||||
|
ok: boolean;
|
||||||
|
command: string;
|
||||||
|
path?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
checkedAt: number;
|
||||||
|
}>;
|
||||||
|
installGitTools: () => Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
steps: Array<{
|
||||||
|
name: string;
|
||||||
|
result: {
|
||||||
|
ok: boolean;
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
output?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
check?: {
|
||||||
|
git: {
|
||||||
|
ok: boolean;
|
||||||
|
command: string;
|
||||||
|
version?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
lfs: {
|
||||||
|
ok: boolean;
|
||||||
|
command: string;
|
||||||
|
version?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
checkedAt: number;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
fetchProfile: (payload: { username: string }) => Promise<{
|
fetchProfile: (payload: { username: string }) => Promise<{
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
status?: number;
|
status?: number;
|
||||||
|
|
@ -17,5 +76,19 @@ interface Window {
|
||||||
error?: string;
|
error?: string;
|
||||||
url: string;
|
url: string;
|
||||||
}>;
|
}>;
|
||||||
|
downloadProfile: (payload: { username: string }) => Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
status?: number;
|
||||||
|
url: string;
|
||||||
|
filePath?: string;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
resetProfile: (payload: { username: string }) => Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
status?: number;
|
||||||
|
data?: unknown;
|
||||||
|
error?: string;
|
||||||
|
url: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue