14 Commits

27 changed files with 3012 additions and 311 deletions

1
.cursorignore Normal file
View File

@ -0,0 +1 @@
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)

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

@ -36,9 +36,15 @@ const configuration: webpack.Configuration = {
}, },
optimization: { optimization: {
minimize: false,
minimizer: [ minimizer: [
new TerserPlugin({ new TerserPlugin({
parallel: true, parallel: true,
terserOptions: {
ecma: 2020,
keep_classnames: true,
keep_fnames: true,
},
}), }),
], ],
}, },

1
.gitignore vendored
View File

@ -27,3 +27,4 @@ npm-debug.log.*
*.css.d.ts *.css.d.ts
*.sass.d.ts *.sass.d.ts
*.scss.d.ts *.scss.d.ts
.env

18
assets/images/heart.svg Normal file
View File

@ -0,0 +1,18 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" width="8" height="4" fill="#FF2D0F"/>
<rect x="8" y="4" width="4" height="16" fill="#FF2D0F"/>
<rect x="4" y="8" width="4" height="8" fill="#FF2D0F"/>
<rect y="4" width="4" height="8" fill="#FF2D0F"/>
<rect x="24" y="4" width="4" height="8" fill="#FF2D0F"/>
<rect x="16" width="8" height="16" fill="#FF2D0F"/>
<rect x="16" y="16" width="4" height="4" fill="#FF2D0F"/>
<rect x="24" y="12" width="4" height="4" fill="#BD2211"/>
<rect x="20" y="16" width="4" height="4" fill="#BD2211"/>
<rect x="16" y="20" width="4" height="4" fill="#BD2211"/>
<rect x="12" y="24" width="4" height="4" fill="#BD2211"/>
<rect x="8" y="20" width="4" height="4" fill="#BD2211"/>
<rect x="4" y="16" width="4" height="4" fill="#BD2211"/>
<rect x="4" y="4" width="4" height="4" fill="#FFCAC8"/>
<rect y="12" width="4" height="4" fill="#BD2211"/>
<rect x="12" y="4" width="4" height="20" fill="#FF2D0F"/>
</svg>

After

Width:  |  Height:  |  Size: 994 B

652
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,7 @@
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/electron-react-boilerplate/electron-react-boilerplate.git" "url": "git+https://git.popa-popa.ru/DIKER/popa-launcher.git"
}, },
"license": "MIT", "license": "MIT",
"author": { "author": {
@ -42,14 +42,15 @@
"postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && npm run build:dll", "postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && npm run build:dll",
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx", "lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
"lint:fix": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix", "lint:fix": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix",
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never && npm run build:dll", "package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish always && npm run build:dll",
"rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app", "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
"prestart": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.main.dev.ts", "prestart": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.main.dev.ts",
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run prestart && npm run start:renderer", "start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run prestart && npm run start:renderer",
"start:main": "concurrently -k -P \"cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --watch --config ./.erb/configs/webpack.config.main.dev.ts\" \"electronmon . -- {@}\" --", "start:main": "concurrently -k -P \"cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --watch --config ./.erb/configs/webpack.config.main.dev.ts\" \"electronmon . -- {@}\" --",
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.preload.dev.ts", "start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts", "start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
"test": "jest" "test": "jest",
"publish-debug": "electron-builder build --publish always"
}, },
"browserslist": [ "browserslist": [
"extends browserslist-config-erb" "extends browserslist-config-erb"
@ -104,18 +105,30 @@
"@electron/notarize": "^3.0.0", "@electron/notarize": "^3.0.0",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.2.0",
"@mui/material": "^7.2.0", "@mui/material": "^7.2.0",
"@xmcl/core": "^2.14.1",
"@xmcl/installer": "^6.1.0",
"@xmcl/user": "^4.2.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",
"uuid": "^11.1.0"
}, },
"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",
@ -178,7 +191,7 @@
"webpack-merge": "^6.0.1" "webpack-merge": "^6.0.1"
}, },
"build": { "build": {
"productName": "ElectronReact", "productName": "popa-launcher",
"appId": "org.erb.ElectronReact", "appId": "org.erb.ElectronReact",
"asar": true, "asar": true,
"afterSign": ".erb/scripts/notarize.js", "afterSign": ".erb/scripts/notarize.js",
@ -237,9 +250,12 @@
"./assets/**" "./assets/**"
], ],
"publish": { "publish": {
"provider": "github", "provider": "generic",
"owner": "electron-react-boilerplate", "url": "https://git.popa-popa.ru/DIKER/popa-launcher/releases/download/v${version}",
"repo": "electron-react-boilerplate" "channel": "latest",
"requestHeaders": {
"Authorization": "token ${env.GH_TOKEN}"
}
} }
}, },
"collective": { "collective": {

View File

@ -1,12 +1,12 @@
{ {
"name": "electron-react-boilerplate", "name": "popa-launcher",
"version": "4.6.0", "version": "1.0.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "electron-react-boilerplate", "name": "popa-launcher",
"version": "4.6.0", "version": "1.0.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT" "license": "MIT"
} }

View File

@ -1,12 +1,12 @@
{ {
"name": "electron-react-boilerplate", "name": "popa-launcher",
"version": "4.6.0", "version": "1.0.0",
"description": "A foundation for scalable desktop apps", "description": "Popa Launcher",
"license": "MIT", "license": "MIT",
"author": { "author": {
"name": "Electron React Boilerplate Maintainers", "name": "DIKER",
"email": "electronreactboilerplate@gmail.com", "email": "diker0k@gmail.com",
"url": "https://github.com/electron-react-boilerplate" "url": "https://github.com/DIKER0K"
}, },
"main": "./dist/main/main.js", "main": "./dist/main/main.js",
"scripts": { "scripts": {

90
src/main/auth-service.ts Normal file
View File

@ -0,0 +1,90 @@
import { YggdrasilClient, YggrasilAuthentication } from '@xmcl/user';
import { v4 as uuidv4 } from 'uuid';
// Ely.by сервер
const ELY_BY_AUTH_SERVER = 'https://authserver.ely.by';
export class AuthService {
private client: YggdrasilClient;
constructor() {
this.client = new YggdrasilClient(ELY_BY_AUTH_SERVER);
}
async login(
username: string,
password: string,
): Promise<YggrasilAuthentication> {
try {
// Генерируем уникальный clientToken
const clientToken = uuidv4();
// Выполняем запрос напрямую к правильному URL
const response = await fetch(`${ELY_BY_AUTH_SERVER}/auth/authenticate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
password,
clientToken,
requestUser: true,
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Ошибка авторизации: ${response.status} ${errorText}`);
}
const auth = await response.json();
console.log(`Аутентификация успешна для ${auth.selectedProfile?.name}`);
return auth;
} catch (error) {
console.error('Ошибка при авторизации:', error);
throw error;
}
}
async validate(accessToken: string, clientToken: string): Promise<boolean> {
try {
const response = await fetch(`${ELY_BY_AUTH_SERVER}/auth/validate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ accessToken, clientToken }),
});
return response.ok;
} catch (error) {
console.error('Ошибка при валидации токена:', error);
return false;
}
}
async refresh(
accessToken: string,
clientToken: string,
): Promise<YggrasilAuthentication | null> {
try {
const response = await fetch(`${ELY_BY_AUTH_SERVER}/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ accessToken, clientToken }),
});
if (!response.ok) {
return null;
}
return await response.json();
} catch (error) {
console.error('Ошибка при обновлении токена:', error);
return null;
}
}
}

View File

@ -14,12 +14,47 @@ 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 {
initMinecraftHandlers,
initAuthHandlers,
initServerStatusHandler,
initPackConfigHandlers,
} from './minecraft-launcher';
class AppUpdater { class AppUpdater {
constructor() { constructor() {
log.transports.file.level = 'info'; log.transports.file.level = 'info';
autoUpdater.logger = log; autoUpdater.logger = log;
const server = 'https://git.popa-popa.ru/DIKER/popa-launcher';
// Для Gitea нужно указать конкретную структуру URL
// Обратите внимание на использование пути /download/
autoUpdater.setFeedURL({
provider: 'generic',
url: `${server}/releases/download/latest`, // Укажите конкретную версию
channel: 'latest',
});
// Проверка обновлений
autoUpdater.checkForUpdatesAndNotify(); autoUpdater.checkForUpdatesAndNotify();
// Периодическая проверка обновлений (каждый час)
setInterval(
() => {
autoUpdater.checkForUpdatesAndNotify();
},
60 * 60 * 1000,
);
// Обработчики событий обновления
autoUpdater.on('update-downloaded', () => {
log.info('Обновление загружено. Будет установлено при перезапуске.');
// Можно отправить событие в renderer для уведомления пользователя
if (mainWindow) {
mainWindow.webContents.send('update-available');
}
});
} }
} }
@ -43,6 +78,16 @@ if (isDebug) {
require('electron-debug').default(); require('electron-debug').default();
} }
ipcMain.handle('close-app', () => {
app.quit();
return true;
});
ipcMain.handle('minimize-app', () => {
mainWindow?.minimize();
return true;
});
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;
@ -72,7 +117,10 @@ const createWindow = async () => {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
show: false, show: false,
width: 1024, width: 1024,
height: 728, height: 850,
autoHideMenuBar: true,
resizable: false,
frame: false,
icon: getAssetPath('icon.png'), icon: getAssetPath('icon.png'),
webPreferences: { webPreferences: {
webSecurity: false, webSecurity: false,
@ -111,6 +159,11 @@ const createWindow = async () => {
// Remove this if your app does not use auto updates // Remove this if your app does not use auto updates
// eslint-disable-next-line // eslint-disable-next-line
new AppUpdater(); new AppUpdater();
initAuthHandlers();
initMinecraftHandlers();
initServerStatusHandler();
initPackConfigHandlers();
}; };
/** /**
@ -136,3 +189,7 @@ app
}); });
}) })
.catch(console.log); .catch(console.log);
ipcMain.handle('install-update', () => {
autoUpdater.quitAndInstall();
});

View File

@ -0,0 +1,981 @@
import path from 'path';
import { app, ipcMain } from 'electron';
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 { spawn } from 'child_process';
import { AuthService } from './auth-service';
// Константы
const AUTHLIB_INJECTOR_FILENAME = 'authlib-injector-1.2.5.jar';
const MCSTATUS_API_URL = 'https://api.mcstatus.io/v2/status/java/';
// Создаем экземпляр сервиса аутентификации
const authService = new AuthService();
// Модифицированная функция для получения последней версии релиза с произвольного URL
export async function getLatestReleaseVersion(apiUrl: string): Promise<string> {
try {
const response = await fetch(apiUrl);
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';
}
}
// Функция для загрузки файла
export 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);
});
}
// Добавим функцию для определения версии Java
export async function getJavaVersion(
javaPath: string,
): Promise<{ version: string; majorVersion: number }> {
return new Promise((resolve, reject) => {
const process = spawn(javaPath, ['-version']);
let output = '';
process.stderr.on('data', (data) => {
output += data.toString();
});
process.on('close', (code) => {
if (code === 0) {
// Извлекаем версию из вывода (например, "java version "1.8.0_291"")
const versionMatch = output.match(/version "([^"]+)"/);
if (versionMatch) {
const version = versionMatch[1];
// Определяем major версию
let majorVersion = 8; // По умолчанию предполагаем Java 8
if (version.startsWith('1.8')) {
majorVersion = 8;
} else if (version.match(/^(9|10|11|12|13|14|15|16)/)) {
majorVersion = parseInt(version.split('.')[0], 10);
} else if (version.match(/^1\.(9|10|11|12|13|14|15|16)/)) {
majorVersion = parseInt(version.split('.')[1], 10);
} else if (version.match(/^([0-9]+)/)) {
majorVersion = parseInt(version.match(/^([0-9]+)/)?.[1] || '0', 10);
}
resolve({ version, majorVersion });
} else {
reject(new Error('Unable to parse Java version'));
}
} else {
reject(new Error(`Java process exited with code ${code}`));
}
});
});
}
// Модифицируем функцию findJava, чтобы она находила все версии Java и определяла их версии
export async function findJavaVersions(): Promise<
Array<{ path: string; version: string; majorVersion: number }>
> {
const javaPaths: string[] = [];
const results: Array<{
path: string;
version: string;
majorVersion: number;
}> = [];
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)) {
javaPaths.push(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',
'Microsoft',
].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)) {
javaPaths.push(javaPath);
}
}
}
} catch (err) {
console.error(`Ошибка при сканировании ${basePath}:`, err);
}
}
}
// 3. Пробуем найти java в PATH через команду which/where
try {
const command =
process.platform === 'win32' ? 'where java' : 'which java';
const javaPathFromCmd = execSync(command).toString().trim().split('\n');
javaPathFromCmd.forEach((path) => {
if (path && fs.existsSync(path) && !javaPaths.includes(path)) {
javaPaths.push(path);
}
});
} catch (err) {
console.error('Ошибка при поиске java через PATH:', err);
}
// Получаем информацию о версиях найденных Java
for (const javaPath of javaPaths) {
try {
const versionInfo = await getJavaVersion(javaPath);
results.push({
path: javaPath,
version: versionInfo.version,
majorVersion: versionInfo.majorVersion,
});
} catch (err) {
console.error(`Ошибка при определении версии для ${javaPath}:`, err);
}
}
// Сортируем результаты по majorVersion (от меньшей к большей)
return results.sort((a, b) => a.majorVersion - b.majorVersion);
} catch (error) {
console.error('Ошибка при поиске версий Java:', error);
return results;
}
}
// Обновим функцию findJava для использования Java 8
export async function findJava(): Promise<string> {
try {
console.log('Поиск доступных версий Java...');
const javaVersions = await findJavaVersions();
if (javaVersions.length === 0) {
throw new Error('Java не найдена. Установите Java и повторите попытку.');
}
console.log('Найденные версии Java:');
javaVersions.forEach((java) => {
console.log(
`- Java ${java.majorVersion} (${java.version}) по пути: ${java.path}`,
);
});
// Предпочитаем Java 21 или 17 для совместимости с authlib-injector
const preferredVersions = [21, 17, 11];
for (const preferredVersion of preferredVersions) {
const preferred = javaVersions.find(
(java) => java.majorVersion === preferredVersion,
);
if (preferred) {
console.log(
`Выбрана предпочтительная версия Java ${preferredVersion}: ${preferred.path}`,
);
return preferred.path;
}
}
// Если не нашли предпочтительные версии, берем самую старую версию
console.log(`Выбрана доступная версия Java: ${javaVersions[0].path}`);
return javaVersions[0].path;
} catch (error) {
console.error('Ошибка при поиске Java:', error);
throw error;
}
}
// Добавим функцию для проверки/копирования authlib-injector (как в C#)
async function ensureAuthlibInjectorExists(appPath: string): Promise<string> {
const authlibPath = path.join(appPath, AUTHLIB_INJECTOR_FILENAME);
// Проверяем, существует ли файл
if (fs.existsSync(authlibPath)) {
console.log(
`Файл ${AUTHLIB_INJECTOR_FILENAME} уже существует: ${authlibPath}`,
);
return authlibPath;
}
// Ищем authlib в ресурсах приложения
const resourcePath = path.join(app.getAppPath(), AUTHLIB_INJECTOR_FILENAME);
if (fs.existsSync(resourcePath)) {
console.log(`Копирование ${AUTHLIB_INJECTOR_FILENAME} из ресурсов...`);
fs.copyFileSync(resourcePath, authlibPath);
console.log(`Файл успешно скопирован: ${authlibPath}`);
return authlibPath;
}
// Если не нашли локальный файл - скачиваем его
console.log(`Скачивание ${AUTHLIB_INJECTOR_FILENAME}...`);
await downloadFile(
`https://github.com/yushijinhun/authlib-injector/releases/download/v1.2.5/${AUTHLIB_INJECTOR_FILENAME}`,
authlibPath,
(progress) => {
console.log(`Прогресс скачивания: ${progress}%`);
},
);
return authlibPath;
}
// Инициализация IPC обработчиков
export function initMinecraftHandlers() {
// Обработчик для скачивания и распаковки
ipcMain.handle('download-and-extract', async (event, options) => {
try {
const {
downloadUrl = 'https://github.com/DIKER0K/Comfort/releases/latest/download/Comfort.zip',
apiReleaseUrl = 'https://api.github.com/repos/DIKER0K/Comfort/releases/latest',
versionFileName = 'comfort_version.txt',
packName = 'Comfort',
preserveFiles = [], // Новый параметр: список файлов/папок для сохранения
} = options || {};
const appPath = path.dirname(app.getPath('exe'));
const minecraftDir = path.join(appPath, '.minecraft');
const versionsDir = path.join(minecraftDir, 'versions');
const versionFilePath = path.join(minecraftDir, versionFileName);
// Получаем текущую и последнюю версии
const latestVersion = await getLatestReleaseVersion(apiReleaseUrl);
let currentVersion = '';
// Проверяем текущую версию, если файл существует
if (fs.existsSync(versionFilePath)) {
currentVersion = fs.readFileSync(versionFilePath, 'utf-8').trim();
}
// Проверяем, нужно ли обновление
if (currentVersion === latestVersion) {
return {
success: true,
updated: false,
version: currentVersion,
packName,
};
}
const tempDir = path.join(appPath, 'temp');
const packDir = path.join(versionsDir, packName); // Директория пакета
// Создаем/очищаем временную директорию
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true });
}
fs.mkdirSync(tempDir, { recursive: true });
const zipPath = path.join(tempDir, `${packName}.zip`);
// Скачиваем файл
await downloadFile(downloadUrl, 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 });
}
// Сохраняем файлы/папки, которые нужно оставить
const backupDir = path.join(tempDir, 'backup');
fs.mkdirSync(backupDir, { recursive: true });
// Проверка и бэкап указанных файлов/папок
for (const filePath of preserveFiles) {
const fullPath = path.join(packDir, filePath);
if (fs.existsSync(fullPath)) {
const backupPath = path.join(backupDir, filePath);
// Создаем необходимые директории для бэкапа
const backupDirPath = path.dirname(backupPath);
if (!fs.existsSync(backupDirPath)) {
fs.mkdirSync(backupDirPath, { recursive: true });
}
// Копируем файл или директорию
if (fs.lstatSync(fullPath).isDirectory()) {
fs.cpSync(fullPath, backupPath, { recursive: true });
console.log(`Директория ${filePath} сохранена во временный бэкап`);
} else {
fs.copyFileSync(fullPath, backupPath);
console.log(`Файл ${filePath} сохранен во временный бэкап`);
}
}
}
// Распаковываем архив напрямую в папку versions
await extract(zipPath, { dir: versionsDir });
fs.unlinkSync(zipPath);
// Восстанавливаем файлы/папки из бэкапа
for (const filePath of preserveFiles) {
const backupPath = path.join(backupDir, filePath);
if (fs.existsSync(backupPath)) {
const targetPath = path.join(packDir, filePath);
// Создаем необходимые директории для восстановления
const targetDirPath = path.dirname(targetPath);
if (!fs.existsSync(targetDirPath)) {
fs.mkdirSync(targetDirPath, { recursive: true });
}
// Копируем обратно файл или директорию
if (fs.lstatSync(backupPath).isDirectory()) {
fs.cpSync(backupPath, targetPath, { recursive: true });
console.log(`Директория ${filePath} восстановлена из бэкапа`);
} else {
fs.copyFileSync(backupPath, targetPath);
console.log(`Файл ${filePath} восстановлен из бэкапа`);
}
}
}
// Сохраняем новую версию
fs.writeFileSync(versionFilePath, latestVersion);
// Удаляем временную директорию
fs.rmSync(tempDir, { recursive: true });
return { success: true, updated: true, version: latestVersion, packName };
} catch (error) {
console.error('Error in download-and-extract:', error);
throw error;
}
});
// Обработчик для запуска Minecraft
ipcMain.handle('launch-minecraft', async (event, gameConfig) => {
try {
const {
accessToken,
uuid,
username,
memory = 4096,
baseVersion = '1.21.4',
fabricVersion = 'fabric0.16.14',
packName = 'Comfort', // Название основной сборки
versionToLaunchOverride = '', // Возможность переопределить версию для запуска
serverIp = 'popa-popa.ru',
serverPort, // Добавляем опциональный порт без значения по умолчанию
} = gameConfig || {};
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);
// Найти версию пакета, Fabric или базовую версию
let versionToLaunch = versionToLaunchOverride;
if (!versionToLaunch) {
if (
versionsContents.includes(`${baseVersion}-fabric${fabricVersion}`)
) {
versionToLaunch = `${baseVersion}-fabric${fabricVersion}`;
} else if (versionsContents.includes(packName)) {
versionToLaunch = packName;
} else {
versionToLaunch = baseVersion;
}
}
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';
}
// Далее пробуем установить 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...',
});
if (fabricVersion) {
event.sender.send('installation-status', {
step: 'fabric-install',
message: `Установка Fabric ${fabricVersion}...`,
});
await installFabric({
minecraftVersion: baseVersion,
version: fabricVersion, // Используйте напрямую, без .version
minecraft: minecraftDir,
});
}
} catch (error) {
console.warn('Ошибка при установке Fabric, продолжаем:', error);
}
// 3. Подготовка версии и установка зависимостей
try {
// Используем идентификатор Fabric-версии
const fabricVersionId = `${baseVersion}-fabric${fabricVersion}`;
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);
}
// Загрузка и проверка authlib-injector
const authlibPath = await ensureAuthlibInjectorExists(appPath);
event.sender.send('installation-status', {
step: 'authlib-injector',
message: 'authlib-injector готов',
});
// Запускаем Minecraft с authlib-injector для Ely.by
event.sender.send('installation-status', {
step: 'launch',
message: 'Запуск игры...',
});
// При запуске используем переданные параметры
const packDir = path.join(versionsDir, packName);
// При формировании конфигурации запуска создаем объект server только с нужными параметрами
const serverConfig: any = { ip: serverIp };
// Добавляем порт только если он был передан
if (serverPort) {
serverConfig.port = serverPort;
}
const proc = await launch({
gamePath: packDir,
resourcePath: minecraftDir,
javaPath,
version: versionToLaunch,
launcherName: 'popa-popa',
server: serverConfig, // Используем созданный объект конфигурации
extraJVMArgs: [
'-Dlog4j2.formatMsgNoLookups=true',
`-javaagent:${authlibPath}=ely.by`,
`-Xmx${memory}M`,
],
// Используем данные аутентификации Yggdrasil
accessToken,
gameProfile: {
id: uuid,
name: username,
},
});
// Логирование
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 };
}
});
// Добавьте в функцию initMinecraftHandlers или создайте новую
ipcMain.handle('get-pack-files', async (event, packName) => {
try {
const appPath = path.dirname(app.getPath('exe'));
const minecraftDir = path.join(appPath, '.minecraft');
const packDir = path.join(minecraftDir, 'versions', packName);
if (!fs.existsSync(packDir)) {
return { success: false, error: 'Директория сборки не найдена' };
}
// Функция для рекурсивного обхода директории
const scanDir: any = (dir: any, basePath: any = '') => {
const result = [];
const items = fs.readdirSync(dir);
for (const item of items) {
const itemPath = path.join(dir, item);
const relativePath = basePath ? path.join(basePath, item) : item;
const isDirectory = fs.statSync(itemPath).isDirectory();
result.push({
name: item,
path: relativePath,
isDirectory,
// Если это директория, рекурсивно сканируем ее
children: isDirectory ? scanDir(itemPath, relativePath) : [],
});
}
return result;
};
const files = scanDir(packDir);
return { success: true, files };
} catch (error) {
console.error('Ошибка при получении файлов сборки:', error);
return { success: false, error: error.message };
}
});
}
// Добавляем обработчики IPC для аутентификации
export function initAuthHandlers() {
// Аутентификация
ipcMain.handle('authenticate', async (event, { username, password }) => {
try {
const auth = await authService.login(username, password);
return {
accessToken: auth.accessToken,
clientToken: auth.clientToken,
selectedProfile: auth.selectedProfile,
};
} catch (error) {
console.error('Ошибка аутентификации:', error);
throw error;
}
});
// Валидация токена
ipcMain.handle('validate-token', async (event, accessToken) => {
try {
const clientToken = JSON.parse(
fs.readFileSync(
path.join(app.getPath('userData'), 'config.json'),
'utf8',
),
).clientToken;
const valid = await authService.validate(accessToken, clientToken);
return { valid };
} catch (error) {
console.error('Ошибка валидации токена:', error);
return { valid: false };
}
});
// Обновление токена
ipcMain.handle(
'refresh-token',
async (event, { accessToken, clientToken }) => {
try {
const auth = await authService.refresh(accessToken, clientToken);
if (!auth) return null;
return {
accessToken: auth.accessToken,
clientToken: auth.clientToken,
selectedProfile: auth.selectedProfile,
};
} catch (error) {
console.error('Ошибка обновления токена:', error);
return null;
}
},
);
}
// Функция для получения статуса сервера
export function initServerStatusHandler() {
ipcMain.handle('get-server-status', async (event, { host, port }) => {
try {
// Формируем адрес с портом, если указан
const serverAddress = port ? `${host}:${port}` : host;
// Делаем запрос к API mcstatus.io
const response = await fetch(`${MCSTATUS_API_URL}${serverAddress}`);
// Проверяем статус ответа
if (!response.ok) {
const errorText = await response.text();
throw new Error(`API вернул ошибку ${response.status}: ${errorText}`);
}
const data = await response.json();
if (data.online) {
return {
success: true,
online: data.players?.online || 0,
max: data.players?.max || 0,
version: data.version?.name_clean || 'Unknown',
icon: data.icon || null, // Возвращаем иконку
motd: data.motd?.clean || '', // Название сервера
};
} else {
return { success: false, error: 'Сервер не доступен' };
}
} catch (error) {
console.error('Ошибка при получении статуса сервера:', error);
return { success: false, error: error.message };
}
});
}
// Функция для работы с конфигурацией сборки
export function initPackConfigHandlers() {
// Файл конфигурации
const CONFIG_FILENAME = 'popa-launcher-config.json';
// Обработчик для сохранения настроек сборки
ipcMain.handle('save-pack-config', async (event, { packName, config }) => {
try {
const appPath = path.dirname(app.getPath('exe'));
const minecraftDir = path.join(appPath, '.minecraft');
const packDir = path.join(minecraftDir, 'versions', packName);
// Создаем папку для сборки, если она не существует
if (!fs.existsSync(packDir)) {
fs.mkdirSync(packDir, { recursive: true });
}
const configPath = path.join(packDir, CONFIG_FILENAME);
// Сохраняем конфигурацию в файл
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
// Добавляем файл конфигурации в список файлов, которые не удаляются
if (!config.preserveFiles.includes(CONFIG_FILENAME)) {
config.preserveFiles.push(CONFIG_FILENAME);
// Перезаписываем файл с обновленным списком
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
}
return { success: true };
} catch (error) {
console.error('Ошибка при сохранении настроек сборки:', error);
return { success: false, error: error.message };
}
});
// Обработчик для загрузки настроек сборки
ipcMain.handle('load-pack-config', async (event, { packName }) => {
try {
const appPath = path.dirname(app.getPath('exe'));
const minecraftDir = path.join(appPath, '.minecraft');
const packDir = path.join(minecraftDir, 'versions', packName);
const configPath = path.join(packDir, CONFIG_FILENAME);
// Проверяем существование файла конфигурации
if (!fs.existsSync(configPath)) {
// Если файла нет, возвращаем дефолтную конфигурацию
return {
success: true,
config: {
memory: 4096,
preserveFiles: [CONFIG_FILENAME], // По умолчанию сохраняем файл конфигурации
},
};
}
// Читаем и парсим конфигурацию
const configData = fs.readFileSync(configPath, 'utf8');
const config = JSON.parse(configData);
// Добавляем файл конфигурации в список сохраняемых файлов, если его там нет
if (!config.preserveFiles.includes(CONFIG_FILENAME)) {
config.preserveFiles.push(CONFIG_FILENAME);
}
return { success: true, config };
} catch (error) {
console.error('Ошибка при загрузке настроек сборки:', error);
return {
success: false,
error: error.message,
// Возвращаем дефолтную конфигурацию при ошибке
config: {
memory: 4096,
preserveFiles: [CONFIG_FILENAME],
},
};
}
});
}

View File

@ -1,8 +1,17 @@
// 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'
| 'get-server-status'
| 'close-app'
| 'minimize-app'
| 'save-pack-config'
| 'load-pack-config'
| 'update-available'
| 'install-update';
const electronHandler = { const electronHandler = {
ipcRenderer: { ipcRenderer: {
@ -21,6 +30,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: Channels, ...args: unknown[]): Promise<any> {
return ipcRenderer.invoke(channel, ...args);
},
}, },
}; };

View File

@ -16,7 +16,7 @@ body {
position: relative; position: relative;
color: white; color: white;
height: 100vh; height: 100vh;
background: linear-gradient(200.96deg, #000000, #3b4187); background: linear-gradient(242.94deg, #000000 39.07%, #3b4187 184.73%);
font-family: 'Benzin-Bold' !important; font-family: 'Benzin-Bold' !important;
overflow-y: hidden; overflow-y: hidden;
display: flex; display: flex;
@ -39,3 +39,11 @@ h2 {
h3 { h3 {
font-family: 'Benzin-Bold' !important; font-family: 'Benzin-Bold' !important;
} }
h4 {
font-family: 'Benzin-Bold' !important;
}
h5 {
font-family: 'Benzin-Bold' !important;
}

View File

@ -5,9 +5,26 @@ import {
Navigate, Navigate,
} from 'react-router-dom'; } from 'react-router-dom';
import Login from './pages/Login'; import Login from './pages/Login';
import Dashboard from './pages/Dashboard'; import LaunchPage from './pages/LaunchPage';
import { ReactNode, useEffect, useState } from 'react'; import { ReactNode, useEffect, useState } from 'react';
import './App.css'; import './App.css';
import TopBar from './components/TopBar';
import { Box } from '@mui/material';
import MinecraftBackround from './components/MinecraftBackround';
import { Notifier } from './components/Notifier';
// Переместите launchOptions сюда, вне компонентов
const launchOptions = {
downloadUrl:
'https://github.com/DIKER0K/Comfort/releases/latest/download/Comfort.zip',
apiReleaseUrl: 'https://api.github.com/repos/DIKER0K/Comfort/releases/latest',
versionFileName: 'comfort_version.txt',
packName: 'Comfort',
memory: 4096,
baseVersion: '1.21.4',
serverIp: 'popa-popa.ru',
fabricVersion: '0.16.14', // Уберите префикс "fabric"
};
const AuthCheck = ({ children }: { children: ReactNode }) => { const AuthCheck = ({ children }: { children: ReactNode }) => {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null); const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
@ -58,19 +75,39 @@ const AuthCheck = ({ children }: { children: ReactNode }) => {
}; };
const App = () => { const App = () => {
// Просто используйте window.open без useNavigate
const handleRegister = () => {
window.open('https://account.ely.by/register', '_blank');
};
return ( return (
<Router> <Router>
<Routes> <Box
<Route path="/login" element={<Login />} /> sx={{
<Route height: '100vh',
path="/" width: '100vw',
element={ position: 'relative',
<AuthCheck> display: 'flex',
<Dashboard /> flexDirection: 'column',
</AuthCheck> alignItems: 'center',
} justifyContent: 'center',
/> }}
</Routes> >
<MinecraftBackround />
<TopBar onRegister={handleRegister} />
<Notifier />
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<AuthCheck>
<LaunchPage launchOptions={launchOptions} />
</AuthCheck>
}
/>
</Routes>
</Box>
</Router> </Router>
); );
}; };

View File

@ -0,0 +1,205 @@
import { useState, useEffect } from 'react';
import {
Box,
Checkbox,
Typography,
List,
ListItem,
ListItemIcon,
ListItemText,
Collapse,
CircularProgress,
} from '@mui/material';
import FolderIcon from '@mui/icons-material/Folder';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
interface FileNode {
name: string;
path: string;
isDirectory: boolean;
children: FileNode[];
}
interface FilesSelectorProps {
packName: string;
onSelectionChange: (selectedFiles: string[]) => void;
initialSelected?: string[]; // Добавляем этот параметр
}
export default function FilesSelector({
packName,
onSelectionChange,
initialSelected = [], // Значение по умолчанию
}: FilesSelectorProps) {
const [files, setFiles] = useState<FileNode[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Используем initialSelected для начального состояния
const [selectedFiles, setSelectedFiles] = useState<string[]>(initialSelected);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set(),
);
useEffect(() => {
const fetchFiles = async () => {
try {
setLoading(true);
const result = await window.electron.ipcRenderer.invoke(
'get-pack-files',
packName,
);
if (result.success) {
setFiles(result.files);
} else {
setError(result.error);
}
} catch (err) {
setError('Ошибка при загрузке файлов');
} finally {
setLoading(false);
}
};
fetchFiles();
}, [packName]);
// Обработка выбора файла/папки
const handleToggle = (
path: string,
isDirectory: boolean,
children: FileNode[],
) => {
let newSelected = [...selectedFiles];
if (isDirectory) {
if (selectedFiles.includes(path)) {
// Если папка выбрана, убираем ее и все вложенные файлы
newSelected = newSelected.filter((p) => !p.startsWith(path));
} else {
// Если папка не выбрана, добавляем ее и все вложенные файлы
newSelected.push(path);
const addChildPaths = (nodes: FileNode[]) => {
for (const node of nodes) {
newSelected.push(node.path);
if (node.isDirectory) {
addChildPaths(node.children);
}
}
};
addChildPaths(children);
}
} else {
// Для обычного файла просто переключаем состояние
if (selectedFiles.includes(path)) {
newSelected = newSelected.filter((p) => p !== path);
} else {
newSelected.push(path);
}
}
setSelectedFiles(newSelected);
onSelectionChange(newSelected);
};
// Переключение раскрытия папки
const toggleFolder = (path: string) => {
const newExpanded = new Set(expandedFolders);
if (expandedFolders.has(path)) {
newExpanded.delete(path);
} else {
newExpanded.add(path);
}
setExpandedFolders(newExpanded);
};
// Рекурсивный компонент для отображения файлов и папок
const renderFileTree = (nodes: FileNode[]) => {
// Сортировка: сначала папки, потом файлы
const sortedNodes = [...nodes].sort((a, b) => {
// Если у элементов разные типы (папка/файл)
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1; // Папки идут первыми
}
// Если оба элемента одного типа, сортируем по алфавиту
return a.name.localeCompare(b.name);
});
return (
<List dense>
{sortedNodes.map((node) => (
<div key={node.path}>
<ListItem
sx={{
borderRadius: '3vw',
backgroundColor: '#FFFFFF1A',
marginBottom: '1vh',
}}
>
<ListItemIcon>
<Checkbox
edge="start"
checked={selectedFiles.includes(node.path)}
onChange={() =>
handleToggle(node.path, node.isDirectory, node.children)
}
tabIndex={-1}
sx={{ color: 'white' }}
/>
</ListItemIcon>
{node.isDirectory && (
<ListItemIcon onClick={() => toggleFolder(node.path)}>
{expandedFolders.has(node.path) ? (
<ExpandLessIcon sx={{ color: 'white' }} />
) : (
<ExpandMoreIcon sx={{ color: 'white' }} />
)}
</ListItemIcon>
)}
<ListItemIcon>
{node.isDirectory ? (
<FolderIcon sx={{ color: 'white' }} />
) : (
<InsertDriveFileIcon sx={{ color: 'white' }} />
)}
</ListItemIcon>
<ListItemText
primary={node.name}
sx={{ color: 'white', fontFamily: 'Benzin-Bold' }}
/>
</ListItem>
{node.isDirectory && node.children.length > 0 && (
<Collapse
in={expandedFolders.has(node.path)}
timeout="auto"
unmountOnExit
>
<Box sx={{ pl: 4 }}>{renderFileTree(node.children)}</Box>
</Collapse>
)}
</div>
))}
</List>
);
};
if (loading) {
return <CircularProgress />;
}
if (error) {
return <Typography color="error">{error}</Typography>;
}
return (
<Box sx={{ maxHeight: '300px', overflow: 'auto' }}>
{renderFileTree(files)}
</Box>
);
}

View File

@ -0,0 +1,110 @@
import { Box } from '@mui/material';
import heart from '../../../assets/images/heart.svg';
export default function MinecraftBackround() {
return (
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
opacity: 0.25,
overflow: 'hidden',
zIndex: -10,
}}
>
<Box
sx={{
position: 'absolute',
bottom: 0,
right: 0,
gap: '1vw',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
rotate: '-20deg',
paddingTop: '30vw',
}}
>
<img
src={heart}
draggable={false}
style={{
width: '20vw',
height: '20vw',
rotate: '-20deg',
userSelect: 'none',
}}
/>
<img
src={heart}
draggable={false}
style={{
width: '20vw',
height: '20vw',
paddingBottom: '5vw',
userSelect: 'none',
}}
/>
<img
src={heart}
draggable={false}
style={{
width: '20vw',
height: '20vw',
rotate: '20deg',
userSelect: 'none',
}}
/>
</Box>
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
gap: '1vw',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
rotate: '160deg',
paddingTop: '80vw',
}}
>
<img
src={heart}
draggable={false}
style={{
width: '20vw',
height: '20vw',
rotate: '-20deg',
userSelect: 'none',
}}
/>
<img
src={heart}
draggable={false}
style={{
width: '20vw',
height: '20vw',
paddingBottom: '5vw',
userSelect: 'none',
}}
/>
<img
src={heart}
draggable={false}
style={{
width: '20vw',
height: '20vw',
rotate: '20deg',
userSelect: 'none',
}}
/>
</Box>
</Box>
);
}

View File

@ -0,0 +1,63 @@
import { Alert, Box, Snackbar, Button } from '@mui/material';
import { useEffect, useState } from 'react';
export const Notifier = () => {
const [open, setOpen] = useState(false);
const [message, setMessage] = useState('');
const [severity, setSeverity] = useState<
'error' | 'warning' | 'info' | 'success'
>('info');
const [hasUpdateAvailable, setHasUpdateAvailable] = useState(false);
useEffect(() => {
// Слушаем событие о наличии обновления
window.electron.ipcRenderer.on('update-available', () => {
setMessage('Доступно новое обновление');
setSeverity('info');
setHasUpdateAvailable(true);
setOpen(true);
});
return () => {
// Отписываемся от события при размонтировании
window.electron.ipcRenderer.removeAllListeners('update-available');
};
}, []);
const handleClose = () => {
setOpen(false);
};
const handleUpdate = () => {
window.electron.ipcRenderer.invoke('install-update');
setOpen(false);
};
return (
<Box>
<Snackbar
open={open}
autoHideDuration={hasUpdateAvailable ? null : 6000}
onClose={handleClose}
>
<Alert
severity={severity}
action={
hasUpdateAvailable && (
<>
<Button color="primary" size="small" onClick={handleUpdate}>
Обновить сейчас
</Button>
<Button color="secondary" size="small" onClick={handleClose}>
Позже
</Button>
</>
)
}
>
{message}
</Alert>
</Snackbar>
</Box>
);
};

View File

@ -0,0 +1,121 @@
import { Box, Typography, CircularProgress, Avatar } from '@mui/material';
import { useEffect, useState } from 'react';
interface ServerStatusProps {
serverIp: string;
serverPort?: number;
refreshInterval?: number; // Интервал обновления в миллисекундах
}
const ServerStatus = ({
serverIp,
serverPort,
refreshInterval = 60000, // По умолчанию обновление раз в минуту
}: ServerStatusProps) => {
const [serverStatus, setServerStatus] = useState<{
online: number;
max: number;
loading: boolean;
error: string | null;
icon: string | null;
motd: string;
}>({
online: 0,
max: 0,
loading: true,
error: null,
icon: null,
motd: '',
});
useEffect(() => {
// Функция для получения статуса сервера
const fetchServerStatus = async () => {
try {
setServerStatus((prev) => ({ ...prev, loading: true, error: null }));
console.log('Отправляем запрос на сервер с параметрами:', {
host: serverIp,
port: serverPort || 25565,
});
// Проверяем, что serverIp имеет значение
if (!serverIp) {
throw new Error('Адрес сервера не указан');
}
const result = await window.electron.ipcRenderer.invoke(
'get-server-status',
{
host: serverIp,
port: serverPort || 25565,
},
);
if (result.success) {
setServerStatus({
online: result.online,
max: result.max,
loading: false,
error: null,
icon: result.icon,
motd: result.motd || serverIp,
});
} else {
setServerStatus({
online: 0,
max: 0,
loading: false,
error: result.error || 'Неизвестная ошибка',
icon: null,
motd: '',
});
}
} catch (error) {
console.error('Ошибка при получении статуса сервера:', error);
setServerStatus((prev) => ({
...prev,
loading: false,
error: 'Ошибка при получении статуса сервера',
icon: null,
}));
}
};
// Загрузка при первом рендере
fetchServerStatus();
// Периодическое обновление
const interval = setInterval(fetchServerStatus, refreshInterval);
return () => clearInterval(interval);
}, [serverIp, serverPort, refreshInterval]);
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{/* Отображаем иконку сервера или иконку по умолчанию */}
{serverStatus.icon ? (
<Avatar
src={serverStatus.icon}
alt={serverStatus.motd || 'Minecraft сервер'}
sx={{ width: '2em', height: '2em' }}
/>
) : (
<Avatar sx={{ width: '2em', height: '2em', bgcolor: 'primary.main' }}>
?
</Avatar>
)}
{serverStatus.loading ? (
<CircularProgress size={20} />
) : serverStatus.error ? (
<Typography color="error">Ошибка загрузки</Typography>
) : (
<Typography sx={{ fontWeight: 'bold' }}>
{serverStatus.online} / {serverStatus.max} игроков
</Typography>
)}
</Box>
);
};
export default ServerStatus;

View File

@ -0,0 +1,109 @@
import { Box, Button, Typography } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import MinimizeIcon from '@mui/icons-material/Minimize';
import { useLocation } 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;
};
};
}
}
// Определяем пропсы
interface TopBarProps {
onRegister?: () => void; // Опционально, если нужен обработчик регистрации
}
export default function TopBar({ onRegister }: TopBarProps) {
// Получаем текущий путь
const location = useLocation();
const isLoginPage = location.pathname === '/login';
return (
<Box
sx={{
display: 'flex',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '50px',
zIndex: 1000,
width: '100%',
WebkitAppRegion: 'drag',
overflow: 'hidden',
justifyContent: 'flex-end', // Всё содержимое справа
}}
>
{/* Правая часть со всеми кнопками */}
<Box
sx={{
display: 'flex',
WebkitAppRegion: 'no-drag',
gap: '2vw',
padding: '1em',
alignItems: 'center',
}}
>
{/* Кнопка регистрации, если на странице логина */}
{isLoginPage && (
<Button
variant="outlined"
color="primary"
onClick={() => onRegister && onRegister()}
sx={{
width: '10em',
height: '3em',
borderRadius: '1.5vw',
color: 'white',
backgroundImage: 'linear-gradient(to right, #7BB8FF, #FFB7ED)',
border: 'unset',
'&:hover': {
backgroundImage: 'linear-gradient(to right, #6AA8EE, #EEA7DD)',
},
boxShadow: '0.5em 0.5em 0.5em 0px #00000040 inset',
}}
>
Регистрация
</Button>
)}
{/* Кнопки управления окном */}
<Button
onClick={() => {
window.electron.ipcRenderer.invoke('minimize-app');
}}
sx={{
minWidth: 'unset',
minHeight: 'unset',
width: '3em',
height: '3em',
borderRadius: '50%',
}}
>
<MinimizeIcon sx={{ color: 'white' }} />
</Button>
<Button
onClick={() => {
window.electron.ipcRenderer.invoke('close-app');
}}
sx={{
minWidth: 'unset',
minHeight: 'unset',
width: '3em',
height: '3em',
borderRadius: '50%',
}}
>
<CloseIcon sx={{ color: 'white' }} />
</Button>
</Box>
</Box>
);
}

View File

@ -0,0 +1,22 @@
import { Typography } from '@mui/material';
import { Box } from '@mui/material';
export default function PopaPopa() {
return (
<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>
);
}

View File

@ -1,177 +1,94 @@
import { useState } from 'react'; import { useState } from 'react';
const useAuth = () => { interface AuthSession {
const [status, setStatus] = useState(''); accessToken: string;
clientToken: string;
selectedProfile: {
id: string;
name: string;
};
}
const validateSession = async (accessToken: string) => { export default function useAuth() {
const [status, setStatus] = useState('idle');
// Проверка валидности токена
const validateSession = async (accessToken: string): Promise<boolean> => {
try { try {
const response = await fetch('https://authserver.ely.by/auth/validate', { setStatus('validating');
method: 'POST', const response = await window.electron.ipcRenderer.invoke(
headers: { 'validate-token',
'Content-Type': 'application/json', accessToken,
}, );
body: JSON.stringify({ setStatus('idle');
accessToken: accessToken, return response.valid;
}),
});
return response.ok;
} catch (error) { } catch (error) {
console.log(`Ошибка при проверке токена: ${error.message}`); console.error('Ошибка при валидации токена:', error);
setStatus('error');
return false; return false;
} }
}; };
const refreshSession = async (accessToken: string, clientToken: string) => { // Обновление токена
const refreshSession = async (
accessToken: string,
clientToken: string,
): Promise<AuthSession | null> => {
try { try {
const refreshData = { setStatus('refreshing');
accessToken: accessToken, const response = await window.electron.ipcRenderer.invoke(
clientToken: clientToken, 'refresh-token',
}; { accessToken, clientToken },
);
const response = await fetch('https://authserver.ely.by/auth/refresh', { setStatus('idle');
method: 'POST', return response;
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) { } catch (error) {
console.log(`Ошибка при обновлении сессии: ${error.message}`); console.error('Ошибка при обновлении токена:', error);
setStatus('error');
return null; return null;
} }
}; };
// Аутентификация в Ely.by
const authenticateWithElyBy = async ( const authenticateWithElyBy = async (
username: string, username: string,
password: string, password: string,
saveConfig: Function, saveConfigFunc: Function,
) => { ): Promise<AuthSession | null> => {
try { try {
const clientToken = crypto.randomUUID(); setStatus('authenticating');
const authData = { const response = await window.electron.ipcRenderer.invoke(
username: username, 'authenticate',
password: password, { username, 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 && response.accessToken) {
if (response.ok) { // Правильно сохраняем данные в конфигурации
const accessToken = responseData.accessToken; saveConfigFunc({
const profile = responseData.selectedProfile; username: response.selectedProfile.name, // Имя игрока как строка
const uuid = profile.id; uuid: response.selectedProfile.id,
const name = profile.name; accessToken: response.accessToken,
clientToken: response.clientToken,
memory: 4096, // Сохраняем значение по умолчанию или из предыдущей конфигурации
});
if (accessToken && uuid && name) { setStatus('authenticated');
saveConfig( return response;
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 || 'Ошибка авторизации');
} }
setStatus('error');
return null;
} catch (error) { } catch (error) {
console.log(`Ошибка авторизации: ${error.message}`); console.error('Ошибка при аутентификации:', error);
setStatus('error');
return null; return null;
} }
}; };
return { return {
status, status,
setStatus,
validateSession, validateSession,
refreshSession, refreshSession,
authenticateWithElyBy, authenticateWithElyBy,
}; };
}; }
export default useAuth;

View File

@ -1,7 +1,18 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
// Добавляем определение типа Config
interface Config {
username: string;
password: string;
memory: number;
comfortVersion: string;
accessToken: string;
clientToken: string;
uuid?: string; // Добавляем uuid, который используется для авторизации
}
const useConfig = () => { const useConfig = () => {
const [config, setConfig] = useState({ const [config, setConfig] = useState<Config>({
username: '', username: '',
password: '', password: '',
memory: 4096, memory: 4096,
@ -22,7 +33,6 @@ const useConfig = () => {
return { return {
username: '', username: '',
password: '', password: '',
memory: 4096,
comfortVersion: '', comfortVersion: '',
accessToken: '', accessToken: '',
clientToken: '', clientToken: '',
@ -34,28 +44,10 @@ const useConfig = () => {
setConfig(savedConfig); setConfig(savedConfig);
}, []); }, []);
const saveConfig = ( const saveConfig = (newConfig: Partial<Config>) => {
username: string, const updatedConfig = { ...config, ...newConfig };
memory: number, setConfig(updatedConfig);
accessToken = '', localStorage.setItem('launcher_config', JSON.stringify(updatedConfig));
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 handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {

View File

@ -6,7 +6,7 @@
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="script-src 'self' 'unsafe-inline'" content="script-src 'self' 'unsafe-inline'"
/> />
<title>Hello Electron React!</title> <title>popa-launcher</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -1,31 +0,0 @@
import { Box, Typography, Button } from '@mui/material';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
const Dashboard = () => {
const navigate = useNavigate();
useEffect(() => {
// Проверяем авторизацию при монтировании компонента
const savedConfig = localStorage.getItem('launcher_config');
if (!savedConfig || !JSON.parse(savedConfig).accessToken) {
navigate('/login');
}
}, [navigate]);
const handleLogout = () => {
localStorage.removeItem('launcher_config');
navigate('/login');
};
return (
<Box>
<Typography variant="h4">Добро пожаловать в лаунчер</Typography>
<Button onClick={handleLogout} variant="contained" color="error">
Выйти
</Button>
</Box>
);
};
export default Dashboard;

View File

@ -0,0 +1,431 @@
import {
Box,
Typography,
Button,
Snackbar,
Alert,
LinearProgress,
Modal,
} from '@mui/material';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import ServerStatus from '../components/ServerStatus/ServerStatus';
import PopaPopa from '../components/popa-popa';
import SettingsIcon from '@mui/icons-material/Settings';
import React from 'react';
import MemorySlider from '../components/Login/MemorySlider';
import FilesSelector from '../components/FilesSelector';
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;
};
};
}
}
// Определяем тип для props
interface LaunchPageProps {
launchOptions: {
downloadUrl: string;
apiReleaseUrl: string;
versionFileName: string;
packName: string;
memory: number;
baseVersion: string;
serverIp: string;
fabricVersion: string;
};
}
const LaunchPage = ({ launchOptions }: LaunchPageProps) => {
const navigate = useNavigate();
// Начальное состояние должно быть пустым или с минимальными значениями
const [config, setConfig] = useState<{
memory: number;
preserveFiles: string[];
}>({
memory: 0,
preserveFiles: [],
});
const [isDownloading, setIsDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0);
const [buffer, setBuffer] = useState(10);
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('');
const [open, setOpen] = React.useState(false);
const handleOpen = () => setOpen(true);
const handleClose = () => setOpen(false);
useEffect(() => {
const savedConfig = localStorage.getItem('launcher_config');
if (!savedConfig || !JSON.parse(savedConfig).accessToken) {
navigate('/login');
}
const progressListener = (...args: unknown[]) => {
const progress = args[0] as number;
setDownloadProgress(progress);
setBuffer(Math.min(progress + 10, 100));
};
const statusListener = (...args: unknown[]) => {
const status = args[0] as { step: string; message: string };
setInstallStep(status.step);
setInstallMessage(status.message);
};
window.electron.ipcRenderer.on('download-progress', progressListener);
window.electron.ipcRenderer.on('installation-status', statusListener);
return () => {
// Удаляем только конкретных слушателей, а не всех
// Это безопаснее, чем removeAllListeners
const cleanup = window.electron.ipcRenderer.on;
if (typeof cleanup === 'function') {
cleanup('download-progress', progressListener);
cleanup('installation-status', statusListener);
}
// Удаляем использование removeAllListeners
};
}, [navigate]);
useEffect(() => {
// Загрузка конфигурации сборки при монтировании
const loadPackConfig = async () => {
try {
const result = await window.electron.ipcRenderer.invoke(
'load-pack-config',
{
packName: launchOptions.packName,
},
);
if (result.success && result.config) {
// Полностью заменяем config значениями из файла
setConfig(result.config);
}
} catch (error) {
console.error('Ошибка при загрузке настроек:', error);
}
};
loadPackConfig();
}, [launchOptions.packName]);
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);
setBuffer(10);
// Загружаем настройки сборки
const result = await window.electron.ipcRenderer.invoke(
'load-pack-config',
{
packName: launchOptions.packName,
},
);
// Используйте уже существующий state вместо локальной переменной
if (result.success && result.config) {
setConfig(result.config); // Обновляем state
}
const savedConfig = JSON.parse(
localStorage.getItem('launcher_config') || '{}',
);
// Опции для скачивания сборки
const packOptions = {
downloadUrl: launchOptions.downloadUrl,
apiReleaseUrl: launchOptions.apiReleaseUrl,
versionFileName: launchOptions.versionFileName,
packName: launchOptions.packName,
preserveFiles: config.preserveFiles,
};
// Передаем опции для скачивания
const downloadResult = await window.electron.ipcRenderer.invoke(
'download-and-extract',
packOptions,
);
if (downloadResult?.success) {
let needsSecondAttempt = false;
if (downloadResult.updated) {
showNotification(
`Сборка ${downloadResult.packName} успешно обновлена до версии ${downloadResult.version}`,
'success',
);
needsSecondAttempt = true;
} else {
showNotification(
`Установлена актуальная версия сборки ${downloadResult.packName} (${downloadResult.version})`,
'info',
);
}
// Опции для запуска
const options = {
accessToken: savedConfig.accessToken,
uuid: savedConfig.uuid,
username: savedConfig.username,
memory: config.memory, // Используем state
baseVersion: launchOptions.baseVersion,
packName: launchOptions.packName,
serverIp: launchOptions.serverIp,
fabricVersion: launchOptions.fabricVersion,
};
const launchResult = await window.electron.ipcRenderer.invoke(
'launch-minecraft',
options,
);
if (needsSecondAttempt) {
showNotification(
'Завершаем настройку компонентов, повторный запуск...',
'info',
);
await new Promise((resolve) => setTimeout(resolve, 2000));
const secondAttempt = await window.electron.ipcRenderer.invoke(
'launch-minecraft',
options,
);
showNotification('Minecraft успешно запущен!', 'success');
} else if (launchResult?.success) {
showNotification('Minecraft успешно запущен!', 'success');
}
}
} catch (error) {
console.error('Error:', error);
showNotification(`Ошибка: ${error.message}`, 'error');
} finally {
setIsDownloading(false);
}
};
// Функция для сохранения настроек
const savePackConfig = async () => {
try {
const configToSave = {
memory: config.memory,
preserveFiles: config.preserveFiles || [],
};
await window.electron.ipcRenderer.invoke('save-pack-config', {
packName: launchOptions.packName,
config: configToSave,
});
// Обновляем launchOptions
launchOptions.memory = config.memory;
showNotification('Настройки сохранены', 'success');
} catch (error) {
console.error('Ошибка при сохранении настроек:', error);
showNotification('Ошибка сохранения настроек', 'error');
}
};
return (
<Box sx={{ gap: '1vh', display: 'flex', flexDirection: 'column' }}>
<PopaPopa />
<Typography variant="h4">Игровой сервер</Typography>
<Typography variant="h4">долбаёбов в Minecraft</Typography>
<Box>
<Typography variant="body1" sx={{ color: '#FFFFFF61' }}>
СЕРВЕР ГДЕ ВСЕМ НА ВАС ПОХУЙ
</Typography>
<Typography variant="body1" sx={{ color: '#FFFFFF61' }}>
СЕРВЕР ГДЕ РАЗРЕШИНЫ ОДНОПОЛЫЕ БРАКИ
</Typography>
<Typography variant="body1" sx={{ color: '#FFFFFF61' }}>
СЕРВЕР ГДЕ ВСЕ ДОЛБАЕБЫ
</Typography>
<Typography variant="body1" sx={{ color: '#FFFFFF61' }}>
СЕРВЕР ГДЕ НА СПАВНЕ БУДЕТ ХУЙ (ВОЗМОЖНО)
</Typography>
<Typography variant="body1" sx={{ color: '#FFFFFF61' }}>
СЕРВЕР ЗА КОТОРЫЙ ВЫ ПРОДАДИТЕ МАТЬ
</Typography>
<Typography variant="body1" sx={{ color: '#FFFFFF61' }}>
ТЫ МОЖЕШЬ КУПИТЬ АДМИНКУ И ПОЛУЧИТЬ ПИЗДЫ
</Typography>
</Box>
<Box>
<ServerStatus
serverIp={launchOptions.serverIp}
refreshInterval={30000}
/>
</Box>
{isDownloading ? (
<Box sx={{ mb: 3, width: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box sx={{ width: '100%', mr: 1 }}>
<LinearProgress
variant="buffer"
value={downloadProgress}
valueBuffer={buffer}
/>
</Box>
<Box sx={{ minWidth: 35 }}>
<Typography
variant="body2"
sx={{ color: 'white' }}
>{`${Math.round(downloadProgress)}%`}</Typography>
</Box>
</Box>
</Box>
) : (
<Box
sx={{
display: 'flex',
gap: '1vw',
width: '100%', // родитель занимает всю ширину
}}
>
{/* Первая кнопка — растягивается на всё доступное пространство */}
<Button
variant="contained"
color="primary"
onClick={handleLaunchMinecraft}
sx={{
flexGrow: 1, // занимает всё свободное место
width: 'auto', // ширина подстраивается
borderRadius: '3vw',
fontFamily: 'Benzin-Bold',
background: 'linear-gradient(90deg, #3B96FF 0%, #FFB7ED 100%)',
}}
>
Запустить Minecraft
</Button>
{/* Вторая кнопка — квадратная, фиксированного размера (ширина = высоте) */}
<Button
variant="contained"
sx={{
flexShrink: 0, // не сжимается
aspectRatio: '1', // ширина = высоте
backgroundColor: 'grey',
borderRadius: '3vw',
minHeight: 'unset',
minWidth: 'unset',
height: '100%', // занимает полную высоту родителя
}}
onClick={handleOpen}
>
<SettingsIcon />
</Button>
</Box>
)}
<Snackbar
open={notification.open}
autoHideDuration={6000}
onClose={handleCloseNotification}
>
<Alert
onClose={handleCloseNotification}
severity={notification.severity}
sx={{ width: '100%' }}
>
{notification.message}
</Alert>
</Snackbar>
<Modal
open={open}
onClose={handleClose}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 400,
background:
'linear-gradient(-242.94deg, #000000 39.07%, #3b4187 184.73%)',
border: '2px solid #000',
boxShadow: 24,
p: 4,
borderRadius: '3vw',
gap: '1vh',
display: 'flex',
flexDirection: 'column',
}}
>
<Typography id="modal-modal-title" variant="body1" component="h2">
Файлы и папки, которые будут сохранены после переустановки сборки
</Typography>
<FilesSelector
packName={launchOptions.packName}
initialSelected={config.preserveFiles} // Передаем текущие выбранные файлы
onSelectionChange={(selected) => {
setConfig((prev) => ({ ...prev, preserveFiles: selected }));
}}
/>
<Typography variant="body1" sx={{ color: 'white' }}>
Оперативная память выделенная для Minecraft
</Typography>
<MemorySlider
memory={config.memory}
onChange={(e, value) => {
setConfig((prev) => ({ ...prev, memory: value as number }));
}}
/>
<Button
variant="contained"
color="success"
onClick={() => {
savePackConfig();
handleClose();
}}
sx={{
borderRadius: '3vw',
fontFamily: 'Benzin-Bold',
}}
>
Сохранить
</Button>
</Box>
</Modal>
</Box>
);
};
export default LaunchPage;

View File

@ -1,9 +1,10 @@
import { Box, Typography } from '@mui/material'; import { Box, Typography } from '@mui/material';
import useConfig from '../hooks/useConfig';
import useAuth from '../hooks/useAuth'; import useAuth from '../hooks/useAuth';
import AuthForm from '../components/Login/AuthForm'; import AuthForm from '../components/Login/AuthForm';
import MemorySlider from '../components/Login/MemorySlider'; import MemorySlider from '../components/Login/MemorySlider';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import PopaPopa from '../components/popa-popa';
import useConfig from '../hooks/useConfig';
const Login = () => { const Login = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -72,20 +73,7 @@ const Login = () => {
return ( return (
<Box> <Box>
<Box sx={{ display: 'flex' }}> <PopaPopa />
<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 <AuthForm
config={config} config={config}
handleInputChange={handleInputChange} handleInputChange={handleInputChange}