Compare commits

..

2 Commits

5 changed files with 841 additions and 13 deletions

View File

@ -1,4 +1,6 @@
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";
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_TIMEOUT_MS = 2000;
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 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>(
path: string,
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 mainWindow = new BrowserWindow({
@ -266,20 +472,102 @@ app.whenReady().then(() => {
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 }) => {
const username = payload.username.trim();
const loginResult = await postText("/launcher/profile/login", { username });
if (!loginResult.ok || !loginResult.data) {
return loginResult;
const fetchProfileInfo = async (): Promise<{
ok: boolean;
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
};
}
return await postJson(
"/launcher/profile/info",
{ username },
{
Cookie: `PHPSESSID=${sessionResult.sessionId}`
}
);
};
const infoResult = await fetchProfileInfo();
if (infoResult.ok && infoResult.data) {
return infoResult;
}
const sessionId = loginResult.data.trim();
return await postJson(
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=${sessionId}`
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}`
}
);
});

View File

@ -5,6 +5,13 @@ console.info("[spt-launcher] preload loaded");
contextBridge.exposeInMainWorld("sptLauncher", {
appName: "SPT Launcher",
checkServerHealth: () => ipcRenderer.invoke("spt:checkServerHealth"),
checkGitTools: () => ipcRenderer.invoke("spt:checkGitTools"),
getGitPaths: () => ipcRenderer.invoke("spt:getGitPaths"),
installGitTools: () => ipcRenderer.invoke("spt:installGitTools"),
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)
});

View File

@ -1,6 +1,28 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
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_ENDPOINT_FALLBACK = "https://pandoli365.com:5069/launcher/ping";
@ -14,6 +36,14 @@ const App = () => {
const [serverCheckedUrl, setServerCheckedUrl] = useState<string | undefined>();
const [serverCheckInProgress, setServerCheckInProgress] = useState(false);
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 [loginId, setLoginId] = useState("");
const [loginPassword, setLoginPassword] = useState("");
@ -25,9 +55,48 @@ const App = () => {
side?: string;
id?: string;
} | undefined>();
const [profileActionInProgress, setProfileActionInProgress] = useState(false);
const [syncInProgress, setSyncInProgress] = useState(false);
const [simulateSyncFail, setSimulateSyncFail] = useState(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 () => {
if (healthCheckInFlightRef.current) {
@ -52,6 +121,9 @@ const App = () => {
setServerCheckedAt(Date.now());
if (screen === "serverCheck" && result.ok) {
if (!gitCheckedRef.current) {
await runGitCheck();
}
setScreen(hasSession ? "main" : "login");
}
} catch (error) {
@ -88,6 +160,91 @@ const App = () => {
return 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) =>
value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
const pickString = (...values: unknown[]) =>
@ -189,6 +346,69 @@ const App = () => {
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 = () => {
if (syncInProgress) {
return;
@ -232,7 +452,7 @@ const App = () => {
: "서버 연결이 불가합니다. 10초마다 자동으로 재시도합니다."}
</p>
<p className="notice">
{serverCheckedUrl ? `Endpoint(endpoint): ${serverCheckedUrl}` : "Endpoint(endpoint): (unknown)"}
{serverCheckedUrl ? `엔드포인트: ${serverCheckedUrl}` : "엔드포인트: "}
{serverStatusCode ? ` · HTTP ${serverStatusCode}` : ""}
{typeof serverLatencyMs === "number" ? ` · ${serverLatencyMs}ms` : ""}
{serverError ? ` · ${serverError}` : ""}
@ -245,6 +465,19 @@ const App = () => {
</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>
)}
@ -349,14 +582,21 @@ const App = () => {
<section className="card profile">
<div>
<h2> </h2>
<p className="muted">: {profile?.username ?? "-"}</p>
<p className="muted">: {profile?.nickname ?? "미등록"}</p>
<p className="muted">: {typeof profile?.level === "number" ? profile.level : "-"}</p>
<p className="muted">: {profile?.username ?? ""}</p>
<p className="muted">: {profile?.nickname ?? ""}</p>
<p className="muted">: {typeof profile?.level === "number" ? profile.level : ""}</p>
{profile?.side && <p className="muted">: {profile.side}</p>}
</div>
<div className="profile-actions">
<button type="button"> </button>
<button type="button" className="ghost">
<button type="button" onClick={handleProfileDownload} disabled={profileActionInProgress}>
{profileActionInProgress ? "처리 중..." : "프로필 다운로드"}
</button>
<button
type="button"
className="ghost"
onClick={handleProfileReset}
disabled={profileActionInProgress}
>
</button>
</div>
@ -397,6 +637,80 @@ const App = () => {
</footer>
</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>
);
};

View File

@ -190,3 +190,149 @@ button:disabled {
.muted {
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;
}

View File

@ -10,6 +10,65 @@ interface Window {
error?: 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<{
ok: boolean;
status?: number;
@ -17,5 +76,19 @@ interface Window {
error?: 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;
}>;
};
}