4 Commits

Author SHA1 Message Date
b65b9538bb working version(without authorization) 2025-07-06 22:41:16 +05:00
4717132b05 Working version(some) 2025-07-06 22:13:09 +05:00
12f7ea8d1c refactoring, check auth 2025-07-05 19:48:05 +05:00
e21a51482a add: login page 2025-07-05 05:47:53 +05:00
19 changed files with 3982 additions and 212 deletions

View File

@ -1,7 +1,3 @@
/**
* Base webpack config used across other specific configs
*/
import webpack from 'webpack'; import webpack from 'webpack';
import TsconfigPathsPlugins from 'tsconfig-paths-webpack-plugin'; import TsconfigPathsPlugins from 'tsconfig-paths-webpack-plugin';
import webpackPaths from './webpack.paths'; import webpackPaths from './webpack.paths';
@ -20,7 +16,6 @@ const configuration: webpack.Configuration = {
use: { use: {
loader: 'ts-loader', loader: 'ts-loader',
options: { options: {
// Remove this line to enable type checking in webpack builds
transpileOnly: true, transpileOnly: true,
compilerOptions: { compilerOptions: {
module: 'nodenext', module: 'nodenext',
@ -34,18 +29,22 @@ const configuration: webpack.Configuration = {
output: { output: {
path: webpackPaths.srcPath, path: webpackPaths.srcPath,
// https://github.com/webpack/webpack/issues/1114
library: { type: 'commonjs2' }, library: { type: 'commonjs2' },
}, },
/**
* Determine the array of extensions that should be used to resolve modules.
*/
resolve: { resolve: {
extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'], extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'],
modules: [webpackPaths.srcPath, 'node_modules'], modules: [webpackPaths.srcPath, 'node_modules'],
// There is no need to add aliases here, the paths in tsconfig get mirrored
plugins: [new TsconfigPathsPlugins()], plugins: [new TsconfigPathsPlugins()],
// Новые настройки
extensionAlias: {
'.js': ['.js', '.mjs'],
},
fullySpecified: false,
alias: {
'undici/lib/core/util': 'undici/lib/core/util.js',
},
}, },
plugins: [new webpack.EnvironmentPlugin({ NODE_ENV: 'production' })], plugins: [new webpack.EnvironmentPlugin({ NODE_ENV: 'production' })],

View File

1745
assets/fonts/benzin-bold.svg Normal file

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

View File

Binary file not shown.

1131
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -102,17 +102,29 @@
}, },
"dependencies": { "dependencies": {
"@electron/notarize": "^3.0.0", "@electron/notarize": "^3.0.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/material": "^7.2.0",
"@xmcl/core": "^2.14.1",
"@xmcl/installer": "^6.1.0",
"electron-debug": "^4.1.0", "electron-debug": "^4.1.0",
"electron-log": "^5.3.2", "electron-log": "^5.3.2",
"electron-updater": "^6.3.9", "electron-updater": "^6.3.9",
"find-java-home": "^2.0.0",
"https-browserify": "^1.0.0",
"path-browserify": "^1.0.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.3.0" "react-router-dom": "^7.3.0",
"stream-browserify": "^3.0.0",
"undici": "^7.11.0",
"util": "^0.12.5"
}, },
"devDependencies": { "devDependencies": {
"@electron/rebuild": "^3.7.1", "@electron/rebuild": "^3.7.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
"@svgr/webpack": "^8.1.0", "@svgr/webpack": "^8.1.0",
"@swc/core": "^1.12.9",
"@teamsupercell/typings-for-css-modules-loader": "^2.5.2", "@teamsupercell/typings-for-css-modules-loader": "^2.5.2",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0", "@testing-library/react": "^16.2.0",

View File

@ -14,6 +14,121 @@ import { autoUpdater } from 'electron-updater';
import log from 'electron-log'; import log from 'electron-log';
import MenuBuilder from './menu'; import MenuBuilder from './menu';
import { resolveHtmlPath } from './util'; import { resolveHtmlPath } from './util';
import fs from 'fs';
import https from 'https';
import extract from 'extract-zip';
import { launch, Version, diagnose } from '@xmcl/core';
import { execSync } from 'child_process';
import {
installDependencies,
installFabric,
getFabricLoaders,
getVersionList,
install,
installTask,
installDependenciesTask,
} from '@xmcl/installer';
import { Task } from '@xmcl/task';
// import findJavaHome from 'find-java-home';
// Функция для поиска Java
async function findJava(): Promise<string> {
return new Promise((resolve, reject) => {
try {
// 1. Сначала проверяем переменную JAVA_HOME
if (process.env.JAVA_HOME) {
const javaPath = path.join(
process.env.JAVA_HOME,
'bin',
'java' + (process.platform === 'win32' ? '.exe' : ''),
);
if (fs.existsSync(javaPath)) {
return resolve(javaPath);
}
}
// 2. Проверяем стандартные пути установки в зависимости от платформы
const checkPaths: string[] = [];
if (process.platform === 'win32') {
// Windows
const programFiles = process.env['ProgramFiles'] || 'C:\\Program Files';
const programFilesX86 =
process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
// JDK пути
[
'Java',
'AdoptOpenJDK',
'Eclipse Adoptium',
'BellSoft',
'Zulu',
'Amazon Corretto',
].forEach((vendor) => {
checkPaths.push(path.join(programFiles, vendor));
checkPaths.push(path.join(programFilesX86, vendor));
});
} else if (process.platform === 'darwin') {
// macOS
checkPaths.push('/Library/Java/JavaVirtualMachines');
checkPaths.push('/System/Library/Java/JavaVirtualMachines');
checkPaths.push('/usr/libexec/java_home');
} else {
// Linux
checkPaths.push('/usr/lib/jvm');
checkPaths.push('/usr/java');
checkPaths.push('/opt/java');
}
// Проверяем каждый путь
for (const basePath of checkPaths) {
if (fs.existsSync(basePath)) {
try {
// Находим подпапки с JDK/JRE
const entries = fs.readdirSync(basePath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
// Проверяем наличие исполняемого файла java в bin
const javaPath = path.join(
basePath,
entry.name,
'bin',
'java' + (process.platform === 'win32' ? '.exe' : ''),
);
if (fs.existsSync(javaPath)) {
return resolve(javaPath);
}
}
}
} catch (err) {
console.error(`Ошибка при сканировании ${basePath}:`, err);
}
}
}
// 3. Пробуем найти java в PATH через команду which/where
try {
const command =
process.platform === 'win32' ? 'where java' : 'which java';
const javaPath = execSync(command).toString().trim();
if (javaPath && fs.existsSync(javaPath)) {
return resolve(javaPath);
}
} catch (err) {
console.error('Ошибка при поиске java через PATH:', err);
}
// Если Java не найдена, выдаем ошибку
reject(
new Error('Java не найдена. Установите Java и повторите попытку.'),
);
} catch (error) {
reject(error);
}
});
}
class AppUpdater { class AppUpdater {
constructor() { constructor() {
@ -43,6 +158,403 @@ if (isDebug) {
require('electron-debug').default(); require('electron-debug').default();
} }
// Minecraft
const COMFORT_DOWNLOAD_URL =
'https://github.com/DIKER0K/Comfort/releases/latest/download/Comfort.zip';
const GITHUB_API_RELEASE_URL =
'https://api.github.com/repos/DIKER0K/Comfort/releases/latest';
const COMFORT_VERSION_FILE = 'comfort_version.txt';
async function getLatestReleaseVersion(): Promise<string> {
try {
const response = await fetch(GITHUB_API_RELEASE_URL);
const data = await response.json();
return data.tag_name || '0.0.0';
} catch (error) {
console.error('Failed to fetch latest version:', error);
return '0.0.0';
}
}
// Модифицируем обработчик IPC
ipcMain.handle('download-and-extract', async (event) => {
try {
const appPath = path.dirname(app.getPath('exe'));
const minecraftDir = path.join(appPath, '.minecraft');
const versionsDir = path.join(minecraftDir, 'versions');
const versionFilePath = path.join(minecraftDir, COMFORT_VERSION_FILE);
// Получаем текущую и последнюю версии
const latestVersion = await getLatestReleaseVersion();
let currentVersion = '';
// Проверяем текущую версию, если файл существует
if (fs.existsSync(versionFilePath)) {
currentVersion = fs.readFileSync(versionFilePath, 'utf-8').trim();
}
// Проверяем, нужно ли обновление
if (currentVersion === latestVersion) {
return { success: true, updated: false, version: currentVersion };
}
const tempDir = path.join(appPath, 'temp');
// Создаем/очищаем временную директорию
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true });
}
fs.mkdirSync(tempDir, { recursive: true });
const zipPath = path.join(tempDir, 'Comfort.zip');
// Скачиваем файл
await downloadFile(COMFORT_DOWNLOAD_URL, zipPath, (progress) => {
event.sender.send('download-progress', progress);
});
// Проверяем архив
const fileStats = fs.statSync(zipPath);
if (fileStats.size < 1024) {
throw new Error('Downloaded file is too small, likely corrupted');
}
// Создаем папку versions если её нет
if (!fs.existsSync(versionsDir)) {
fs.mkdirSync(versionsDir, { recursive: true });
}
// Распаковываем архив напрямую в папку versions
await extract(zipPath, { dir: versionsDir });
fs.unlinkSync(zipPath);
// Сохраняем новую версию
fs.writeFileSync(versionFilePath, latestVersion);
// Удаляем временную директорию
fs.rmSync(tempDir, { recursive: true });
// После распаковки архива и перед запуском
const versionsContents = fs.readdirSync(versionsDir);
console.log('Доступные версии:', versionsContents);
return { success: true, updated: true, version: latestVersion };
} catch (error) {
console.error('Error in download-and-extract:', error);
throw error;
}
});
async function downloadFile(
url: string,
dest: string,
progressCallback: (progress: number) => void,
): Promise<void> {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(dest);
let downloadedSize = 0;
let totalSize = 0;
let redirectCount = 0;
const makeRequest = (requestUrl: string) => {
https
.get(requestUrl, (response) => {
// Обрабатываем редиректы
if (response.statusCode === 301 || response.statusCode === 302) {
if (redirectCount++ > 5) {
reject(new Error('Too many redirects'));
return;
}
makeRequest(response.headers.location!);
return;
}
if (response.statusCode !== 200) {
reject(new Error(`Server returned ${response.statusCode}`));
return;
}
totalSize = parseInt(response.headers['content-length'] || '0', 10);
response.on('data', (chunk) => {
downloadedSize += chunk.length;
const progress = Math.round((downloadedSize / totalSize) * 100);
progressCallback(progress);
});
response.pipe(file);
file.on('finish', () => {
file.close();
// Проверяем, что файл скачан полностью
if (downloadedSize !== totalSize && totalSize > 0) {
fs.unlink(dest, () => {
reject(new Error('File download incomplete'));
});
return;
}
resolve();
});
})
.on('error', (err) => {
fs.unlink(dest, () => reject(err));
});
};
makeRequest(url);
});
}
ipcMain.handle('launch-minecraft', async (event) => {
try {
const baseVersion = '1.21.4';
const appPath = path.dirname(app.getPath('exe'));
const minecraftDir = path.join(appPath, '.minecraft');
const versionsDir = path.join(minecraftDir, 'versions');
// Определяем версию для запуска
const versionsContents = fs.existsSync(versionsDir)
? fs.readdirSync(versionsDir)
: [];
console.log('Доступные версии:', versionsContents);
// Найти версию Comfort или версию с Fabric
let versionToLaunch = '';
if (versionsContents.includes('1.21.4-fabric0.16.14')) {
versionToLaunch = '1.21.4-fabric0.16.14';
} else if (versionsContents.includes('Comfort')) {
versionToLaunch = 'Comfort';
} else {
versionToLaunch = '1.21.4';
}
console.log('Запускаем версию:', versionToLaunch);
// Находим путь к Java
event.sender.send('installation-status', {
step: 'java',
message: 'Поиск Java...',
});
let javaPath;
try {
javaPath = await findJava();
} catch (error) {
console.warn('Ошибка при поиске Java:', error);
event.sender.send('installation-status', {
step: 'java-error',
message: 'Не удалось найти Java. Используем системную Java.',
});
javaPath = 'java'; // Пробуем использовать системную Java
}
// Далее пробуем установить Minecraft, но продолжаем даже при ошибках
let resolvedVersion;
try {
// 1. Получаем список версий и устанавливаем ванильный Minecraft
event.sender.send('installation-status', {
step: 'minecraft-list',
message: 'Получение списка версий Minecraft...',
});
const versionList = await getVersionList();
const minecraftVersion = versionList.versions.find(
(v) => v.id === baseVersion,
);
if (minecraftVersion) {
// Устанавливаем базовую версию Minecraft
event.sender.send('installation-status', {
step: 'minecraft-install',
message: `Установка Minecraft ${baseVersion}...`,
});
try {
const installMcTask = installTask(minecraftVersion, minecraftDir, {
skipRevalidate: true,
});
await installMcTask.startAndWait({
onStart(task) {
event.sender.send('installation-status', {
step: `minecraft-install.${task.path}`,
message: `Начало: ${task.name || task.path}`,
});
},
onUpdate(task) {
const percentage =
Math.round(
(installMcTask.progress / installMcTask.total) * 100,
) || 0;
event.sender.send('download-progress', percentage);
event.sender.send('installation-status', {
step: `minecraft-install.${task.path}`,
message: `Прогресс ${task.name || task.path}: ${percentage}% (${installMcTask.progress}/${installMcTask.total})`,
});
},
onFailed(task, error) {
console.warn(
`Ошибка при установке ${task.path}, продолжаем:`,
error,
);
event.sender.send('installation-status', {
step: `minecraft-install.${task.path}`,
message: `Ошибка: ${error.message}`,
});
},
onSucceed(task) {
event.sender.send('installation-status', {
step: `minecraft-install.${task.path}`,
message: `Завершено: ${task.name || task.path}`,
});
},
});
} catch (error) {
console.warn('Ошибка при установке Minecraft, продолжаем:', error);
}
// 2. Устанавливаем Fabric
try {
event.sender.send('installation-status', {
step: 'fabric-list',
message: 'Получение списка версий Fabric...',
});
const fabricVersions = await getFabricLoaders();
const fabricVersion = fabricVersions[0]; // Последняя версия
if (fabricVersion) {
event.sender.send('installation-status', {
step: 'fabric-install',
message: `Установка Fabric ${fabricVersion.version}...`,
});
await installFabric({
minecraftVersion: baseVersion,
version: fabricVersion.version,
minecraft: minecraftDir,
});
}
} catch (error) {
console.warn('Ошибка при установке Fabric, продолжаем:', error);
}
// 3. Подготовка версии и установка зависимостей
try {
// Используем идентификатор Fabric-версии
const fabricVersionId = `${baseVersion}-fabric0.16.14`;
event.sender.send('installation-status', {
step: 'version-parse',
message: 'Подготовка версии...',
});
resolvedVersion = await Version.parse(minecraftDir, fabricVersionId);
event.sender.send('installation-status', {
step: 'dependencies',
message: 'Установка библиотек и ресурсов...',
});
const depsTask = installDependenciesTask(resolvedVersion, {
assetsDownloadConcurrency: 4,
skipRevalidate: true,
prevalidSizeOnly: true,
checksumValidatorResolver: (checksum) => ({
validate: async () => {
/* void */
},
}),
});
try {
await depsTask.startAndWait({
onStart(task) {
event.sender.send('installation-status', {
step: `dependencies.${task.path}`,
message: `Начало: ${task.name || task.path}`,
});
},
onUpdate(task) {
const percentage =
Math.round((depsTask.progress / depsTask.total) * 100) || 0;
event.sender.send('download-progress', percentage);
event.sender.send('installation-status', {
step: `dependencies.${task.path}`,
message: `Установка ${task.name || task.path}: ${percentage}%`,
});
},
onFailed(task, error) {
console.warn(
`Ошибка при установке ${task.path}, продолжаем:`,
error,
);
event.sender.send('installation-status', {
step: `dependencies.${task.path}`,
message: `Ошибка: ${error.message}`,
});
},
onSucceed(task) {
event.sender.send('installation-status', {
step: `dependencies.${task.path}`,
message: `Завершено: ${task.name || task.path}`,
});
},
});
} catch (error) {
console.warn(
'Ошибка при загрузке ресурсов, продолжаем запуск:',
error,
);
}
} catch (error) {
console.warn('Ошибка при подготовке версии, продолжаем:', error);
}
}
} catch (error) {
console.warn('Произошла ошибка при подготовке Minecraft:', error);
}
// 5. Запускаем Minecraft - НЕЗАВИСИМО ОТ ПРЕДЫДУЩИХ ОШИБОК
event.sender.send('installation-status', {
step: 'launch',
message: 'Запуск игры...',
});
const comfortDir = path.join(versionsDir, 'Comfort');
const proc = await launch({
gamePath: comfortDir,
resourcePath: minecraftDir,
javaPath,
version: '1.21.4-fabric0.16.14',
extraJVMArgs: ['-Dlog4j2.formatMsgNoLookups=true', '-Xmx2G'], // Добавляем больше памяти
});
// Логирование
proc.stdout?.on('data', (data) => {
console.log(`Minecraft stdout: ${data}`);
});
proc.stderr?.on('data', (data) => {
console.error(`Minecraft stderr: ${data}`);
});
return { success: true, pid: proc.pid };
} catch (error) {
// Даже если произошла ошибка при запуске, возвращаем успех
// чтобы интерфейс считал, что запуск выполнен
console.error('Ошибка при запуске Minecraft:', error);
event.sender.send('installation-status', {
step: 'error',
message: `Ошибка запуска: ${error.message}`,
});
return { success: false, error: error.message };
}
});
const installExtensions = async () => { const installExtensions = async () => {
const installer = require('electron-devtools-installer'); const installer = require('electron-devtools-installer');
const forceDownload = !!process.env.UPGRADE_EXTENSIONS; const forceDownload = !!process.env.UPGRADE_EXTENSIONS;
@ -75,6 +587,7 @@ const createWindow = async () => {
height: 728, height: 728,
icon: getAssetPath('icon.png'), icon: getAssetPath('icon.png'),
webPreferences: { webPreferences: {
webSecurity: false,
preload: app.isPackaged preload: app.isPackaged
? path.join(__dirname, 'preload.js') ? path.join(__dirname, 'preload.js')
: path.join(__dirname, '../../.erb/dll/preload.js'), : path.join(__dirname, '../../.erb/dll/preload.js'),

View File

@ -1,8 +1,10 @@
// Disable no-unused-vars, broken for spread args
/* eslint no-unused-vars: off */
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'; import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
export type Channels = 'ipc-example'; export type Channels =
| 'ipc-example'
| 'download-progress'
| 'launch-minecraft'
| 'installation-status';
const electronHandler = { const electronHandler = {
ipcRenderer: { ipcRenderer: {
@ -21,6 +23,9 @@ const electronHandler = {
once(channel: Channels, func: (...args: unknown[]) => void) { once(channel: Channels, func: (...args: unknown[]) => void) {
ipcRenderer.once(channel, (_event, ...args) => func(...args)); ipcRenderer.once(channel, (_event, ...args) => func(...args));
}, },
invoke(channel: string, ...args: unknown[]) {
return ipcRenderer.invoke(channel, ...args);
},
}, },
}; };

View File

@ -1,62 +1,41 @@
/* @font-face {
* @NOTE: Prepend a `~` to css file paths that are in your node_modules font-family: 'Benzin-Bold';
* See https://github.com/webpack-contrib/sass-loader#imports src: url('../../assets/fonts/benzin-bold.eot'); /* IE 9 Compatibility Mode */
*/ src:
url('../../assets/fonts/benzin-bold.eot?#iefix') format('embedded-opentype'),
/* IE < 9 */ url('../../assets/fonts/benzin-bold.woff2') format('woff2'),
/* Super Modern Browsers */ url('../../assets/fonts/benzin-bold.woff')
format('woff'),
/* Firefox >= 3.6, any other modern browser */
url('../../assets/fonts/benzin-bold.ttf') format('truetype'),
/* Safari, Android, iOS */
url('../../assets/fonts/benzin-bold.svg#benzin-bold') format('svg'); /* Chrome < 4, Legacy iOS */
}
body { body {
position: relative; position: relative;
color: white; color: white;
height: 100vh; height: 100vh;
background: linear-gradient( background: linear-gradient(200.96deg, #000000, #3b4187);
200.96deg, font-family: 'Benzin-Bold' !important;
#fedc2a -29.09%,
#dd5789 51.77%,
#7a2c9e 129.35%
);
font-family: sans-serif;
overflow-y: hidden; overflow-y: hidden;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
button { p {
background-color: white; font-family: 'Benzin-Bold' !important;
padding: 10px 20px;
border-radius: 10px;
border: none;
appearance: none;
font-size: 1.3rem;
box-shadow: 0px 8px 28px -6px rgba(24, 39, 75, 0.12),
0px 18px 88px -4px rgba(24, 39, 75, 0.14);
transition: all ease-in 0.1s;
cursor: pointer;
opacity: 0.9;
} }
button:hover { h1 {
transform: scale(1.05); font-family: 'Benzin-Bold' !important;
opacity: 1;
} }
li { h2 {
list-style: none; font-family: 'Benzin-Bold' !important;
} }
a { h3 {
text-decoration: none; font-family: 'Benzin-Bold' !important;
height: fit-content;
width: fit-content;
margin: 10px;
}
a:hover {
opacity: 1;
text-decoration: none;
}
.Hello {
display: flex;
justify-content: center;
align-items: center;
margin: 20px 0;
} }

View File

@ -1,50 +1,78 @@
import { MemoryRouter as Router, Routes, Route } from 'react-router-dom'; import {
import icon from '../../assets/icon.svg'; MemoryRouter as Router,
Routes,
Route,
Navigate,
} from 'react-router-dom';
import Login from './pages/Login';
import LaunchPage from './pages/LaunchPage';
import { ReactNode, useEffect, useState } from 'react';
import './App.css'; import './App.css';
function Hello() { const AuthCheck = ({ children }: { children: ReactNode }) => {
return ( const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
<div>
<div className="Hello">
<img width="200" alt="icon" src={icon} />
</div>
<h1>electron-react-boilerplate</h1>
<div className="Hello">
<a
href="https://electron-react-boilerplate.js.org/"
target="_blank"
rel="noreferrer"
>
<button type="button">
<span role="img" aria-label="books">
📚
</span>
Read our docs
</button>
</a>
<a
href="https://github.com/sponsors/electron-react-boilerplate"
target="_blank"
rel="noreferrer"
>
<button type="button">
<span role="img" aria-label="folded hands">
🙏
</span>
Donate
</button>
</a>
</div>
</div>
);
}
export default function App() { useEffect(() => {
const checkAuth = async () => {
try {
const savedConfig = localStorage.getItem('launcher_config');
if (savedConfig) {
const config = JSON.parse(savedConfig);
if (config.accessToken) {
// Можно добавить дополнительную проверку токена
const isValid = await validateToken(config.accessToken);
setIsAuthenticated(isValid);
return;
}
}
setIsAuthenticated(false);
} catch (error) {
console.error('Ошибка проверки авторизации:', error);
setIsAuthenticated(false);
}
};
checkAuth();
}, []);
const validateToken = async (token: string) => {
try {
const response = await fetch('https://authserver.ely.by/auth/validate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ accessToken: token }),
});
return response.ok;
} catch (error) {
return false;
}
};
if (isAuthenticated === null) {
return <div>Loading...</div>;
}
return isAuthenticated ? children : <Navigate to="/login" replace />;
};
const App = () => {
return ( return (
<Router> <Router>
<Routes> <Routes>
<Route path="/" element={<Hello />} /> <Route path="/login" element={<Login />} />
<Route
path="/"
element={
<AuthCheck>
<LaunchPage />
</AuthCheck>
}
/>
</Routes> </Routes>
</Router> </Router>
); );
} };
export default App;

View File

@ -0,0 +1,39 @@
import { Box, Button, TextField, Typography } from '@mui/material';
interface AuthFormProps {
config: {
username: string;
password: string;
};
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onLogin: () => void;
}
const AuthForm = ({ config, handleInputChange, onLogin }: AuthFormProps) => {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1.5vw' }}>
<TextField
required
name="username"
label="Введите ник"
variant="outlined"
value={config.username}
onChange={handleInputChange}
/>
<TextField
required
type="password"
name="password"
label="Введите пароль"
variant="outlined"
value={config.password}
onChange={handleInputChange}
/>
<Button onClick={onLogin} variant="contained">
Войти
</Button>
</Box>
);
};
export default AuthForm;

View File

@ -0,0 +1,26 @@
import { Slider } from '@mui/material';
interface MemorySliderProps {
memory: number;
onChange: (e: Event, value: number | number[]) => void;
}
const MemorySlider = ({ memory, onChange }: MemorySliderProps) => {
return (
<Slider
name="memory"
aria-label="Memory"
defaultValue={4096}
valueLabelDisplay="auto"
shiftStep={1024}
step={1024}
marks
min={1024}
max={32628}
value={memory}
onChange={onChange}
/>
);
};
export default MemorySlider;

View File

@ -0,0 +1,177 @@
import { useState } from 'react';
const useAuth = () => {
const [status, setStatus] = useState('');
const validateSession = async (accessToken: string) => {
try {
const response = await fetch('https://authserver.ely.by/auth/validate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
accessToken: accessToken,
}),
});
return response.ok;
} catch (error) {
console.log(`Ошибка при проверке токена: ${error.message}`);
return false;
}
};
const refreshSession = async (accessToken: string, clientToken: string) => {
try {
const refreshData = {
accessToken: accessToken,
clientToken: clientToken,
};
const response = await fetch('https://authserver.ely.by/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(refreshData),
});
if (response.ok) {
const data = await response.json();
const newAccessToken = data.accessToken;
const profile = data.selectedProfile;
const uuid = profile.id;
const name = profile.name;
if (newAccessToken && uuid && name) {
return {
accessToken: newAccessToken,
uuid: uuid,
username: name,
clientToken: clientToken,
};
}
}
return null;
} catch (error) {
console.log(`Ошибка при обновлении сессии: ${error.message}`);
return null;
}
};
const authenticateWithElyBy = async (
username: string,
password: string,
saveConfig: Function,
) => {
try {
const clientToken = crypto.randomUUID();
const authData = {
username: username,
password: password,
clientToken: clientToken,
requestUser: true,
};
console.log(`Аутентификация пользователя ${username} на Ely.by...`);
const response = await fetch(
'https://authserver.ely.by/auth/authenticate',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(authData),
},
);
const responseData = await response.json();
if (response.ok) {
const accessToken = responseData.accessToken;
const profile = responseData.selectedProfile;
const uuid = profile.id;
const name = profile.name;
if (accessToken && uuid && name) {
saveConfig(
username,
4096, // default memory
accessToken,
clientToken,
'',
password,
);
console.log(`Аутентификация успешна: UUID=${uuid}, Username=${name}`);
return {
accessToken: accessToken,
uuid: uuid,
username: name,
clientToken: clientToken,
};
}
} else {
if (responseData.error === 'Account protected with two factor auth') {
const totpToken = prompt(
'Введите код двухфакторной аутентификации:',
'',
);
if (totpToken) {
authData.password = `${password}:${totpToken}`;
const totpResponse = await fetch(
'https://authserver.ely.by/auth/authenticate',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(authData),
},
);
if (totpResponse.ok) {
const totpData = await totpResponse.json();
const newAccessToken = totpData.accessToken;
const newProfile = totpData.selectedProfile;
const newUuid = newProfile.id;
const newName = newProfile.name;
if (newAccessToken && newUuid && newName) {
saveConfig(
username,
4096, // default memory
newAccessToken,
clientToken,
'',
password,
);
return {
accessToken: newAccessToken,
uuid: newUuid,
username: newName,
clientToken: clientToken,
};
}
}
}
}
throw new Error(responseData.error || 'Ошибка авторизации');
}
} catch (error) {
console.log(`Ошибка авторизации: ${error.message}`);
return null;
}
};
return {
status,
setStatus,
validateSession,
refreshSession,
authenticateWithElyBy,
};
};
export default useAuth;

View File

@ -0,0 +1,69 @@
import { useState, useEffect } from 'react';
const useConfig = () => {
const [config, setConfig] = useState({
username: '',
password: '',
memory: 4096,
comfortVersion: '',
accessToken: '',
clientToken: '',
});
const loadInitialConfig = () => {
try {
const savedConfig = localStorage.getItem('launcher_config');
if (savedConfig) {
return JSON.parse(savedConfig);
}
} catch (error) {
console.log('Ошибка загрузки конфигурации:', error);
}
return {
username: '',
password: '',
memory: 4096,
comfortVersion: '',
accessToken: '',
clientToken: '',
};
};
useEffect(() => {
const savedConfig = loadInitialConfig();
setConfig(savedConfig);
}, []);
const saveConfig = (
username: string,
memory: number,
accessToken = '',
clientToken = '',
comfortVersion = '',
password = '',
) => {
try {
const newConfig = {
username,
memory,
accessToken: accessToken || config.accessToken,
clientToken: clientToken || config.clientToken,
comfortVersion: comfortVersion || config.comfortVersion,
password: password || config.password,
};
setConfig(newConfig);
localStorage.setItem('launcher_config', JSON.stringify(newConfig));
} catch (error) {
console.log(`Ошибка при сохранении конфигурации: ${error.message}`);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setConfig((prev) => ({ ...prev, [name]: value }));
};
return { config, setConfig, saveConfig, handleInputChange };
};
export default useConfig;

View File

@ -0,0 +1,166 @@
import { Box, Typography, Button, Snackbar, Alert } from '@mui/material';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
declare global {
interface Window {
electron: {
ipcRenderer: {
invoke(channel: string, ...args: unknown[]): Promise<any>;
on(channel: string, func: (...args: unknown[]) => void): void;
removeAllListeners(channel: string): void;
};
};
}
}
const LaunchPage = () => {
const navigate = useNavigate();
const [isDownloading, setIsDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0);
const [installStatus, setInstallStatus] = useState('');
const [notification, setNotification] = useState<{
open: boolean;
message: string;
severity: 'success' | 'error' | 'info';
}>({ open: false, message: '', severity: 'info' });
const [installStep, setInstallStep] = useState('');
const [installMessage, setInstallMessage] = useState('');
useEffect(() => {
const savedConfig = localStorage.getItem('launcher_config');
if (!savedConfig || !JSON.parse(savedConfig).accessToken) {
navigate('/login');
}
window.electron.ipcRenderer.on('download-progress', (progress: any) => {
setDownloadProgress(progress as number);
});
// Добавляем слушатель для статуса установки
window.electron.ipcRenderer.on('installation-progress', (data: any) => {
setInstallStatus((data as { status: string }).status);
});
// Обновляем слушатель для статуса установки
window.electron.ipcRenderer.on('installation-status', (data: any) => {
const { step, message } = data as { step: string; message: string };
setInstallStep(step);
setInstallMessage(message);
});
return () => {
window.electron.ipcRenderer.removeAllListeners('download-progress');
window.electron.ipcRenderer.removeAllListeners('installation-progress');
window.electron.ipcRenderer.removeAllListeners('installation-status');
};
}, [navigate]);
const handleLogout = () => {
localStorage.removeItem('launcher_config');
navigate('/login');
};
const showNotification = (
message: string,
severity: 'success' | 'error' | 'info',
) => {
setNotification({ open: true, message, severity });
};
const handleCloseNotification = () => {
setNotification({ ...notification, open: false });
};
const handleLaunchMinecraft = async () => {
try {
setIsDownloading(true);
setDownloadProgress(0);
// Сначала проверяем и обновляем файлы
const downloadResult = await window.electron.ipcRenderer.invoke(
'download-and-extract',
);
if (downloadResult?.success) {
if (downloadResult.updated) {
showNotification(
`Сборка успешно обновлена до версии ${downloadResult.version}`,
'success',
);
} else {
showNotification(
`Установлена актуальная версия сборки ${downloadResult.version}`,
'info',
);
}
// Затем запускаем Minecraft
const launchResult =
await window.electron.ipcRenderer.invoke('launch-minecraft');
if (launchResult?.success) {
showNotification('Minecraft успешно запущен!', 'success');
}
}
} catch (error) {
console.error('Error:', error);
showNotification(`Ошибка: ${error.message}`, 'error');
} finally {
setIsDownloading(false);
}
};
return (
<Box sx={{ p: 3 }}>
<Typography variant="h4" sx={{ mb: 3 }}>
Добро пожаловать в лаунчер
</Typography>
{isDownloading ? (
<Box sx={{ mb: 3 }}>
<Typography>Загрузка и установка: {downloadProgress}%</Typography>
{installMessage && (
<Typography variant="body1" sx={{ mt: 1, color: 'white' }}>
{installMessage}
</Typography>
)}
{installStep && (
<Typography variant="body2" sx={{ color: 'white' }}>
Шаг: {installStep}
</Typography>
)}
</Box>
) : (
<Button
variant="contained"
color="primary"
sx={{ mb: 3 }}
onClick={handleLaunchMinecraft}
>
Запустить Minecraft
</Button>
)}
<Button onClick={handleLogout} variant="contained" color="error">
Выйти
</Button>
<Snackbar
open={notification.open}
autoHideDuration={6000}
onClose={handleCloseNotification}
>
<Alert
onClose={handleCloseNotification}
severity={notification.severity}
sx={{ width: '100%' }}
>
{notification.message}
</Alert>
</Snackbar>
</Box>
);
};
export default LaunchPage;

View File

@ -0,0 +1,104 @@
import { Box, Typography } from '@mui/material';
import useConfig from '../hooks/useConfig';
import useAuth from '../hooks/useAuth';
import AuthForm from '../components/Login/AuthForm';
import MemorySlider from '../components/Login/MemorySlider';
import { useNavigate } from 'react-router-dom';
const Login = () => {
const navigate = useNavigate();
const { config, setConfig, saveConfig, handleInputChange } = useConfig();
const { status, validateSession, refreshSession, authenticateWithElyBy } =
useAuth();
const authorization = async () => {
console.log('Начинаем процесс авторизации...');
if (!config.username.trim()) {
console.log('Ошибка: не указан никнейм');
alert('Введите никнейм!');
return;
}
try {
// Проверяем, есть ли сохранённый токен
if (config.accessToken && config.clientToken) {
console.log('Проверка валидности существующего токена...');
const isValid = await validateSession(config.accessToken);
if (!isValid) {
console.log('Токен недействителен, пытаемся обновить...');
const refreshedSession = await refreshSession(
config.accessToken,
config.clientToken,
);
if (!refreshedSession) {
console.log(
'Не удалось обновить токен, требуется новая авторизация',
);
const newSession = await authenticateWithElyBy(
config.username,
config.password,
saveConfig,
);
if (!newSession) {
console.log('Авторизация не удалась');
return;
}
}
} else {
console.log('Токен действителен');
}
} else {
console.log('Токен отсутствует, выполняем авторизацию...');
const session = await authenticateWithElyBy(
config.username,
config.password,
saveConfig,
);
if (!session) {
console.log('Авторизация не удалась');
return;
}
}
console.log('Авторизация успешно завершена');
navigate('/');
} catch (error) {
console.log(`ОШИБКА при авторизации: ${error.message}`);
}
};
return (
<Box>
<Box sx={{ display: 'flex' }}>
<Typography variant="h3">POPA</Typography>
<Typography variant="h3">-</Typography>
<Typography
variant="h3"
sx={{
background: '-webkit-linear-gradient(200.96deg, #88BCFF, #FD71FF)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
POPA
</Typography>
</Box>
<AuthForm
config={config}
handleInputChange={handleInputChange}
onLogin={authorization}
/>
<MemorySlider
memory={config.memory}
onChange={(e, value) => {
setConfig((prev) => ({ ...prev, memory: value as number }));
}}
/>
</Box>
);
};
export default Login;

View File

@ -1,5 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"useUnknownInCatchVariables": false,
"incremental": true, "incremental": true,
"target": "es2022", "target": "es2022",
"module": "node16", "module": "node16",