diff --git a/launcher/electron/electron.ts b/launcher/electron/electron.ts index 5d6d748..c9f69a1 100644 --- a/launcher/electron/electron.ts +++ b/launcher/electron/electron.ts @@ -4,7 +4,7 @@ import * as os from 'os' import * as fs from 'fs' import * as msmc from 'msmc' import { Client } from 'minecraft-launcher-core' -import { Profile } from '../src/types' +import type { StartArgs } from '../src/types' import { createLogger } from './logger' import decompress from 'decompress' import { urlJoin } from './url-join' @@ -53,8 +53,11 @@ const logger = createLogger(LOG_FILE) const defaultConfig = { rootDir, ram: 4, - servers: ['http://redover.fr:40069', 'http://localhost:40069'], - selectedServer: 0, + servers: [ + 'http://redover.fr:40069', + 'https://mclauncher.kensa.fr', + 'http://localhost:40069' + ], cdnServer: '', closeLauncher: true } @@ -97,7 +100,7 @@ async function createWindow() { ) } } else { - logger.warn('login info file is corrupted, deleting') + logger.warning('login info file is corrupted, deleting') fs.rmSync(loginInfoPath) } } @@ -110,7 +113,7 @@ async function createWindow() { config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) // checking if config is missing field if (Object.keys(config).length !== Object.keys(defaultConfig).length) { - logger.warn( + logger.warning( 'config seems to be missing some fields, resetting to default config' ) config = { ...defaultConfig } @@ -202,7 +205,7 @@ ipcMain.handle('is-up-to-date', (event, arg) => { ipcMain.on('get-config', (event, arg) => { logger.debug('get-config') - event.returnValue = JSON.stringify(config) + event.returnValue = config }) ipcMain.on('set-config', (event, arg) => { @@ -240,7 +243,7 @@ ipcMain.on('prompt-file', (event, args) => { ipcMain.on('get-selected-profile', (event, args) => { logger.debug('get-selected-profile') if (!fs.existsSync(path.join(configFolder, 'selectedProfile.json'))) { - event.returnValue = JSON.stringify(0) + event.returnValue = ['', 0] } else { event.returnValue = JSON.parse( fs.readFileSync( @@ -259,7 +262,33 @@ ipcMain.on('set-selected-profile', (event, args) => { ) }) -ipcMain.handle('start-game', async (event, args: Profile) => { +ipcMain.on('get-local-profiles', (event, args) => { + logger.debug('get-local-profiles') + if (!fs.existsSync(path.join(configFolder, 'localProfiles.json'))) { + event.returnValue = [] + } else { + event.returnValue = JSON.parse( + fs.readFileSync( + path.join(configFolder, 'localProfiles.json'), + 'utf-8' + ) + ) + } +}) + +ipcMain.on('set-local-profiles', (event, args) => { + logger.debug('set-local-profiles') + fs.writeFileSync( + path.join(configFolder, 'localProfiles.json'), + JSON.stringify(args, null, 4) + ) +}) + +ipcMain.on('get-current-task', event => { + event.returnValue = currentTask +}) + +ipcMain.handle('start-game', async (_, args: StartArgs) => { logger.debug('start-game (async)') logger.info('Starting Game ...') return new Promise(async (resolve, reject) => { @@ -280,169 +309,197 @@ ipcMain.handle('start-game', async (event, args: Profile) => { progress: 0 } - // cdn check - const primaryServer = config.servers[config.selectedServer] - let downloadServer = primaryServer + let gameStarted = false - if (!(await checkServer(primaryServer))) { - // checking if server is accessible - gameStarting = false - reject( - "server is not accessible, either your config is wrong or you don't have an internet connection" - ) - } + launcher.on('data', e => { + if (!gameStarted) { + gameStarted = true + if (config.closeLauncher) setTimeout(app.quit, 5000) + gameStarting = false + resolve() + } + // sometimes multiple lines arrive at once + for (const s of e.trim().split('\n')) logger.game(s.trim()) + }) - if (config.cdnServer) { - logger.info('CDN detected in config, testing if it is working') - if (await checkServer(config.cdnServer)) { - logger.info('CDN working, setting it as download server') - downloadServer = config.cdnServer + launcher.on('progress', progress => { + console.log(progress) + const { + type, + task: current, + total + } = progress as { type: string; task: number; total: number } + + if (['assets', 'natives'].includes(type)) { + currentTask = { + title: `Downloading ${type}`, + progress: (current / total) * 100 + } } else { - logger.info( - 'CDN server appear to be inaccessible, using primary server as download server' - ) + currentTask = { + title: 'Starting Game', + progress: (current / total) * 100 + } + } + }) + + try { + console.log(args) + if (args.server === 'local') { + await launchGameLocal(args) + } else if (args.server !== '') { + await launchGameRemote(args) + } else { + reject('invalid start args') } + } catch (err) { + logger.warning(err) + gameStarting = false + reject(err) } + }) +}) - logger.info('Checking if java is installed') - const MCVersionNumber = parseInt(args.version.mc.split('.')[1]) - const javaVersion = MCVersionNumber >= 17 ? '17' : '8' - const javaFolder = path.join(config.rootDir, 'java') - const javaExecutable = path.join( - javaFolder, - javaVersion, - 'bin', - platform === 'win32' ? 'java.exe' : 'java' - ) - if (!fs.existsSync(javaExecutable)) { - logger.info('Java not installed, installing it...') +async function launchGameRemote(args: StartArgs) { + if (!config) return + if (!loginInfo) return + const profile = args.profile + const primaryServer = args.server + let downloadServer = primaryServer + + // cdn check + if (!(await checkServer(primaryServer))) { + // checking if server is accessible + gameStarting = false + throw "server is not accessible, either your config is wrong or you don't have an internet connection" + } - const zipPath = path.join(javaFolder, 'binaries.tar.gz') - const zipUrl = urlJoin( - downloadServer, - '/static/java', - `${platform}-${javaVersion}.tar.gz` + if (config.cdnServer) { + logger.info('CDN detected in config, testing if it is working') + if (await checkServer(config.cdnServer)) { + logger.info('CDN working, setting it as download server') + downloadServer = config.cdnServer + } else { + logger.info( + 'CDN server appear to be inaccessible, using primary server as download server' ) - await download(zipUrl, zipPath) - await decompress(zipPath, path.join(javaFolder, javaVersion), { - strip: 1 - }) - fs.rmSync(zipPath) - logger.info('Java installed') } + } - let forgeArgs - if (args.version.forge) { - logger.info('Forge detected, downloading forge installer') - const forgePath = path.join( - config.rootDir, - 'forgeInstallers', - args.version.forge + logger.info('Checking if java is installed') + const MCVersionNumber = parseInt(profile.version.mc.split('.')[1]) + const javaVersion = MCVersionNumber >= 17 ? '17' : '8' + const javaExecutable = path.join( + config.rootDir, + 'java', + javaVersion, + 'bin', + platform === 'win32' ? 'java.exe' : 'java' + ) + await installJava(primaryServer, javaVersion) + + let forgeArgs + if (profile.version.forge) { + logger.info('Forge detected, downloading forge installer') + const forgePath = path.join( + config.rootDir, + 'forgeInstallers', + profile.version.forge + ) + if (!fs.existsSync(forgePath)) { + const forgeURL = urlJoin( + downloadServer, + '/static/forges/', + profile.version.forge ) - if (!fs.existsSync(forgePath)) { - const forgeURL = urlJoin( - downloadServer, - '/static/forges/', - args.version.forge - ) - logger.info(`downloading ${forgeURL} to ${forgePath}`) - await download(forgeURL, forgePath) - logger.info(`${args.version.forge} downloaded`) - } - forgeArgs = forgePath + logger.info(`downloading ${forgeURL} to ${forgePath}`) + await download(forgeURL, forgePath) + logger.info(`${profile.version.forge} downloaded`) } - if (args.gameFolder) { - logger.info('A forced game folder is detected, downloading it...') - const localPath = path.join( - config.rootDir, - 'profiles', - args.gameFolder - ) + forgeArgs = forgePath + } + if (profile.gameFolder) { + logger.info('A forced game folder is detected, downloading it...') + const localPath = path.join( + config.rootDir, + 'profiles', + profile.gameFolder + ) - checkExist(localPath) + checkExist(localPath) - const hashTree = await JSONFetch(urlJoin(primaryServer, 'hashes')) - const remoteTree = hashTree['gameFolders'][args.gameFolder] - const fileCount: number = ( - await JSONFetch( - urlJoin(primaryServer, 'fileCount', args.gameFolder) - ) - ).count - - logger.info('Remote tree fetched') - const localTree = await folderTree(localPath) - logger.info('Local tree created') - function getFolders(tree: any) { - return Object.keys(tree).filter( - key => typeof tree[key] !== 'string' - ) - } - const remoteFolders = getFolders(remoteTree) - const localFolders = getFolders(localTree) - - logger.info('Starting update procedure') - // creates all the folder at the root that does not exists - for (const folder of remoteFolders) { - if (!localFolders.includes(folder)) { - fs.mkdirSync(path.join(localPath, folder)) - localTree[folder] = {} - } + const hashTree = await JSONFetch(urlJoin(primaryServer, 'hashes')) + const remoteTree = hashTree['gameFolders'][profile.gameFolder] + const fileCount: number = ( + await JSONFetch( + urlJoin(primaryServer, 'fileCount', profile.gameFolder) + ) + ).count + + logger.info('Remote tree fetched') + const localTree = await folderTree(localPath) + logger.info('Local tree created') + function getFolders(tree: any) { + return Object.keys(tree).filter( + key => typeof tree[key] !== 'string' + ) + } + const remoteFolders = getFolders(remoteTree) + const localFolders = getFolders(localTree) + + logger.info('Starting update procedure') + // creates all the folder at the root that does not exists + for (const folder of remoteFolders) { + if (!localFolders.includes(folder)) { + fs.mkdirSync(path.join(localPath, folder)) + localTree[folder] = {} } + } - let count = 0 - for (const folder of remoteFolders) { - //start recursive function which will download all files for all the folders - await downloadFolder( - remoteTree[folder], - localTree[folder], - args.gameFolder, - localPath, - [folder] + let count = 0 + for (const folder of remoteFolders) { + //start recursive function which will download all files for all the folders + await downloadFolder( + remoteTree[folder], + localTree[folder], + profile.gameFolder, + localPath, + [folder] + ) + } + logger.info('Update finished') + /** + * + * @param remoteFolder object representing the remote folder to download (must not be the root of gameFolder, it should be the folder to download) + * @param localFolder object representing the same folder but locally (I.E current state of the folder) + * @param gameFolder name of the remote folder on the server + * @param folderPath path to the local folder + * @param pathA path to sub-folder to download (ex: ['folder1','test'] will download "gameFolder/folder1/test") (used the recreate path on disk) + */ + async function downloadFolder( + remoteFolder, + localFolder, + gameFolder: string, + folderPath: string, + pathA: string[] = [] + ) { + for (const element of Object.keys(remoteFolder)) { + const localPath = path.join(...pathA, element) + const filepath = path.join(folderPath, localPath) // = absolute path to file + const fileUrl = urlJoin( + downloadServer, + '/static/gameFolders', + gameFolder, + ...pathA, + element ) - } - logger.info('Update finished') - /** - * - * @param remoteFolder object representing the remote folder to download (must not be the root of gameFolder, it should be the folder to download) - * @param localFolder object representing the same folder but locally (I.E current state of the folder) - * @param gameFolder name of the remote folder on the server - * @param folderPath path to the local folder - * @param pathA path to sub-folder to download (ex: ['folder1','test'] will download "gameFolder/folder1/test") (used the recreate path on disk) - */ - async function downloadFolder( - remoteFolder, - localFolder, - gameFolder: string, - folderPath: string, - pathA: string[] = [] - ) { - for (const element of Object.keys(remoteFolder)) { - const localPath = path.join(...pathA, element) - const filepath = path.join(folderPath, localPath) // = absolute path to file - const fileUrl = urlJoin( - downloadServer, - '/static/gameFolders', - gameFolder, - ...pathA, - element - ) - if (typeof remoteFolder[element] === 'string') { - // Element is a file - if (localFolder[element] !== undefined) { - if ( - (await getHash(filepath)) !== - remoteFolder[element] - ) { - logger.info('Updating file "%s"', localPath) - await download(fileUrl, filepath) - count++ - currentTask = { - title: 'Updating Mods', - progress: (count / fileCount) * 100 - } - } - } else { - logger.info('Downloading file "%s"', localPath) + if (typeof remoteFolder[element] === 'string') { + // Element is a file + if (localFolder[element] !== undefined) { + if ( + (await getHash(filepath)) !== remoteFolder[element] + ) { + logger.info('Updating file "%s"', localPath) await download(fileUrl, filepath) count++ currentTask = { @@ -451,115 +508,189 @@ ipcMain.handle('start-game', async (event, args: Profile) => { } } } else { - // Element is a folder - if (!localFolder[element]) { - fs.mkdirSync(filepath) - localFolder[element] = {} + logger.info('Downloading file "%s"', localPath) + await download(fileUrl, filepath) + count++ + currentTask = { + title: 'Updating Mods', + progress: (count / fileCount) * 100 } - await downloadFolder( - remoteFolder[element], - localFolder[element], - gameFolder, - folderPath, - pathA.concat(element) - ) } - } - const onlyLocalFile = Object.keys(localFolder) - .filter(key => typeof localFolder[key] === 'string') - .filter(key => !Object.keys(remoteFolder).includes(key)) - for (const file of onlyLocalFile) { - fs.rmSync(path.join(folderPath, ...pathA, file), { - recursive: true - }) + } else { + // Element is a folder + if (!localFolder[element]) { + fs.mkdirSync(filepath) + localFolder[element] = {} + } + await downloadFolder( + remoteFolder[element], + localFolder[element], + gameFolder, + folderPath, + pathA.concat(element) + ) } } - } else { - logger.info( - 'No forced game folder detected, creating an empty one...' - ) - args.gameFolder = args.name - .replace(/[^a-zA-Z0-9]/g, '_') - .toLowerCase() + const onlyLocalFile = Object.keys(localFolder) + .filter(key => typeof localFolder[key] === 'string') + .filter(key => !Object.keys(remoteFolder).includes(key)) + for (const file of onlyLocalFile) { + fs.rmSync(path.join(folderPath, ...pathA, file), { + recursive: true + }) + } } + } else { + logger.info('No forced game folder detected, creating an empty one...') + profile.gameFolder = profile.name + .replace(/[^a-zA-Z0-9]/g, '_') + .toLowerCase() + } - const gameFolder = path.join( - config.rootDir, - 'profiles', - args.gameFolder - ) + const gameFolder = path.join(config.rootDir, 'profiles', profile.gameFolder) - const additionalFileFolder = path.join( - config.rootDir, - 'additionalFiles', - args.gameFolder - ) - checkExist(additionalFileFolder) - // Copy added mods - const additionalFiles = fs.readdirSync(additionalFileFolder) - if (additionalFiles.length > 0) { - checkExist(gameFolder) - copyFolder(additionalFileFolder, gameFolder) + const additionalFileFolder = path.join( + config.rootDir, + 'additionalFiles', + profile.gameFolder + ) + checkExist(additionalFileFolder) + // Copy added mods + const additionalFiles = fs.readdirSync(additionalFileFolder) + if (additionalFiles.length > 0) { + checkExist(gameFolder) + copyFolder(additionalFileFolder, gameFolder) + } + + const opts = { + clientPackage: null, + authorization: msmc.getMCLC().getAuth(loginInfo), + root: gameFolder, + version: { + number: profile.version.mc, + type: 'release' + }, + forge: forgeArgs, + memory: { + max: config.ram + 'G', + min: config.ram + 'G' + }, + javaPath: javaExecutable, + customArgs: ['-Djava.net.preferIPv6Stack=true'], + overrides: { + detached: config.jrePath !== '', + assetRoot: path.join(config.rootDir, 'assets'), + libraryRoot: path.join(config.rootDir, 'libraries') } + } + launcher.launch(opts as any) +} - const opts = { - clientPackage: null, - authorization: msmc.getMCLC().getAuth(loginInfo), - root: gameFolder, - version: { - number: args.version.mc, - type: 'release' - }, - forge: forgeArgs, - memory: { - max: config.ram + 'G', - min: config.ram + 'G' - }, - javaPath: javaExecutable, - customArgs: ['-Djava.net.preferIPv6Stack=true'], - overrides: { - detached: config.jrePath !== '', - assetRoot: path.join(config.rootDir, 'assets'), - libraryRoot: path.join(config.rootDir, 'libraries') +async function launchGameLocal(args: StartArgs) { + if (!config) return + if (!loginInfo) return + const profile = args.profile + + logger.info('Checking if java is installed') + const MCVersionNumber = parseInt(profile.version.mc.split('.')[1]) + const javaVersion = MCVersionNumber >= 17 ? '17' : '8' + const javaFolder = path.join(config.rootDir, 'java') + const javaExecutable = path.join( + javaFolder, + javaVersion, + 'bin', + platform === 'win32' ? 'java.exe' : 'java' + ) + if (!fs.existsSync(javaExecutable)) { + logger.info('Java not installed, trying to install it from server list') + let installed = false + for (const server of config.servers) { + try { + await installJava(server, javaVersion) + installed = true + break + } catch { + logger.info('Failed to install java from %s', server) } } - launcher.launch(opts as any) + if (!installed) { + logger.info('Failed to install java from any server') + throw 'Failed to install java from any server' + } + } - let gameStarted = false - launcher.on('data', e => { - if (!gameStarted) { - gameStarted = true - if (config.closeLauncher) setTimeout(app.quit, 5000) - gameStarting = false - resolve() - } - // sometimes multiple lines arrive at once - for (const s of e.trim().split('\n')) logger.game(s.trim()) - }) + let forgeArgs + if (profile.version.forge) { + logger.info('Forge detected, checking if forge installer is present') + const forgePath = path.join( + config.rootDir, + 'forgeInstallers', + profile.version.forge + ) + if (!fs.existsSync(forgePath)) { + throw ( + "Forge installer isn't found, please add it to the location : " + + forgePath + ) + } + forgeArgs = forgePath + } - launcher.on('progress', progress => { - console.log(progress) - const { - type, - task: current, - total - } = progress as { type: string; task: number; total: number } + profile.gameFolder = profile.gameFolder + ? profile.gameFolder + : profile.name.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase() + + const gameFolder = path.join(config.rootDir, 'profiles', profile.gameFolder) + + const opts = { + clientPackage: null, + authorization: msmc.getMCLC().getAuth(loginInfo), + root: gameFolder, + version: { + number: profile.version.mc, + type: 'release' + }, + forge: forgeArgs, + memory: { + max: config.ram + 'G', + min: config.ram + 'G' + }, + javaPath: javaExecutable, + customArgs: ['-Djava.net.preferIPv6Stack=true'], + overrides: { + detached: config.jrePath !== '', + assetRoot: path.join(config.rootDir, 'assets'), + libraryRoot: path.join(config.rootDir, 'libraries') + } + } + launcher.launch(opts as any) +} - if (['assets', 'natives'].includes(type)) { - currentTask = { - title: `Downloading ${type}`, - progress: (current / total) * 100 - } - } else { - currentTask = { - title: 'Starting Game', - progress: (current / total) * 100 - } - } +async function installJava(server: string, version: string) { + const javaFolder = path.join(config.rootDir, 'java') + const javaExecutable = path.join( + javaFolder, + version, + 'bin', + platform === 'win32' ? 'java.exe' : 'java' + ) + if (!fs.existsSync(javaExecutable)) { + logger.info('Java not installed, installing it...') + + const zipPath = path.join(javaFolder, 'binaries.tar.gz') + const zipUrl = urlJoin( + server, + '/static/java', + `${platform}-${version}.tar.gz` + ) + if (!(await checkServer(zipUrl))) { + throw 'java version not found on server' + } + await download(zipUrl, zipPath) + await decompress(zipPath, path.join(javaFolder, version), { + strip: 1 }) - }) -}) - -ipcMain.on('get-current-task', event => { - event.returnValue = currentTask -}) + fs.rmSync(zipPath) + logger.info('Java installed') + } +} diff --git a/launcher/electron/utils.ts b/launcher/electron/utils.ts index 357b8ec..5ef0979 100644 --- a/launcher/electron/utils.ts +++ b/launcher/electron/utils.ts @@ -1,6 +1,7 @@ import * as fs from 'fs' import * as path from 'path' -import * as https from 'https' +import { get as httpGet } from 'http' +import { get as httpsGet } from 'https' import { createHash } from 'crypto' import fetch from 'electron-fetch' @@ -19,15 +20,14 @@ export function download(address: string, filepath: string) { fs.writeFileSync(filepath, '') } const file = fs.createWriteStream(filepath) - https - .get(address, res => { - res.pipe(file) - file.on('finish', () => { - file.close() - resolve() - }) + const get = address.startsWith('https') ? httpsGet : httpGet + get(address, res => { + res.pipe(file) + file.on('finish', () => { + file.close() + resolve() }) - .on('error', err => reject(err)) + }).on('error', err => reject(err)) }) } diff --git a/launcher/package.json b/launcher/package.json index f0451c0..2a19506 100644 --- a/launcher/package.json +++ b/launcher/package.json @@ -3,7 +3,7 @@ "description": "A Minecraft launcher with auto-update feature to facilitate playing modded minecraft", "author": "Kensa", "private": true, - "version": "2.5.0", + "version": "3.0.0", "main": "dist-electron/electron.js", "scripts": { "dev": "vite", diff --git a/launcher/src/App.tsx b/launcher/src/App.tsx index 14947b9..f43e18e 100644 --- a/launcher/src/App.tsx +++ b/launcher/src/App.tsx @@ -3,10 +3,15 @@ import { Modal } from 'react-bootstrap' import Home from './pages/Home' import Settings from './pages/Settings' +import ServerManager from './pages/ServerManager' +import ProfileManager from './pages/ProfileManager' export default function App() { const [overlay, setOverlay] = useState(undefined) const [settingsShown, setSettingsShown] = useState(false) + const [serverManagerShown, setServerManagerShown] = useState(false) + const [profileManagerShown, setProfileManagerShown] = + useState(false) return (
@@ -20,7 +25,38 @@ export default function App() { - setSettingsShown(false)} /> + setSettingsShown(false)} + showServerManager={() => setServerManagerShown(true)} + showProfileManager={() => setProfileManagerShown(true)} + /> + + + + setServerManagerShown(false)} + > + + + Server Manager + + + + + + + setProfileManagerShown(false)} + > + + + Local Profile Manager + + + +
diff --git a/launcher/src/components/HomeHeader.tsx b/launcher/src/components/HomeHeader.tsx index d3a7340..1e13ea3 100644 --- a/launcher/src/components/HomeHeader.tsx +++ b/launcher/src/components/HomeHeader.tsx @@ -1,12 +1,11 @@ import { SlidersHorizontal } from 'lucide-react' import { Button } from 'react-bootstrap' -import ProfilePicker, { ProfilePickerProps } from './ProfilePicker' +import ProfilePicker from './ProfilePicker' import UserElement from './UserElement' export interface HomeHeaderProps { style?: React.CSSProperties className?: string - profileProps: ProfilePickerProps setOverlay: (overlay: JSX.Element | undefined) => void setSettingsShown: (show: boolean) => void } @@ -14,7 +13,6 @@ export interface HomeHeaderProps { export default function HomeHeader({ style, className, - profileProps, setOverlay, setSettingsShown }: HomeHeaderProps) { @@ -27,7 +25,7 @@ export default function HomeHeader({ style={style} > - + + + + ) +} + +interface ProfileComponentProps { + profile: Profile + deleteProfile: () => void + edit: () => void +} +function ProfileComponent({ + profile, + deleteProfile, + edit +}: ProfileComponentProps) { + const versionString = profile.version.forge + ? `forge-${profile.version.mc}` + : `${profile.version.mc}` + + return ( + + {profile.name} + {versionString} + + + + + + ) +} + +interface ProfileEditProps { + profile?: Profile + hide: () => void +} + +function ProfileEdit({ profile, hide }: ProfileEditProps) { + const { localProfiles, setLocalProfiles } = useLocalProfiles() + + const [name, setName] = useState(profile?.name ?? '') + const [version, setVersion] = useState(profile?.version.mc ?? '') + const [forge, setForge] = useState(profile?.version.forge ?? '') + const [gameFolder, setGameFolder] = useState( + profile?.gameFolder ?? '' + ) + + const save = (event: React.FormEvent) => { + event.preventDefault() + event.stopPropagation() + const newProfile: Profile = { + name, + version: { + mc: version, + forge: forge && forge !== '' ? forge : undefined + }, + gameFolder: gameFolder && gameFolder !== '' ? gameFolder : undefined + } + + const newProfiles = localProfiles.filter(p => p !== profile) + newProfiles.push(newProfile) + setLocalProfiles(newProfiles) + hide() + } + + return ( +
+ + Name + setName(target.value)} + placeholder='name of the profile' + /> + + + Version + setVersion(target.value)} + placeholder='minecraft version of the profile' + /> + + + Forge Version + setForge(target.value)} + placeholder='forge installer name of the profile (optional)' + /> + + + Game Folder + setGameFolder(target.value)} + placeholder='game folder of the profile (optional)' + /> + + + + +
+ ) +} diff --git a/launcher/src/pages/ServerManager.tsx b/launcher/src/pages/ServerManager.tsx new file mode 100644 index 0000000..d4826d6 --- /dev/null +++ b/launcher/src/pages/ServerManager.tsx @@ -0,0 +1,71 @@ +import React, { useState } from 'react' +import { Button, Form, Table } from 'react-bootstrap' +import { useServers } from '../stores/config' + +export default function ServerManager() { + const [newServer, setNewServer] = useState('') + const { servers, setServers } = useServers() + return ( + + + + + + + + + + + + {servers.map(server => ( + { + setServers(servers.filter(s => s !== server)) + }} + /> + ))} + +
AddressAction
+ + setNewServer(target.value) + } + type='text' + /> + + +
+ ) +} + +interface ServerComponentProps { + server: string + deleteServer: () => void +} + +function ServerComponent({ server, deleteServer }: ServerComponentProps) { + return ( + + {server} + + + + + ) +} diff --git a/launcher/src/pages/Settings.tsx b/launcher/src/pages/Settings.tsx index 63d813c..3b7529b 100644 --- a/launcher/src/pages/Settings.tsx +++ b/launcher/src/pages/Settings.tsx @@ -2,22 +2,26 @@ import { ipcRenderer } from 'electron' import { FileSearch, FolderSearch } from 'lucide-react' import React, { useState } from 'react' import { Button, Form } from 'react-bootstrap' -import configStore from '../stores/config' +import { useConfig } from '../stores/config' interface SettingsProps { hide: () => void + showServerManager: () => void + showProfileManager: () => void } type SettingValue = string | number | boolean type Setter = (s: SettingValue) => void -export default function Settings({ hide }: SettingsProps) { - const config = configStore(store => ({ ...store })) +export default function Settings({ + hide, + showServerManager, + showProfileManager +}: SettingsProps) { + const config = useConfig() const [rootDir, setRootDir] = useState(config.rootDir) const [ram, setRam] = useState(config.ram) - const [servers, setServers] = useState(config.servers) - const [selectedServer, setSelectedServer] = useState(config.selectedServer) const [cdnServer, setCdnServer] = useState(config.cdnServer) const [closeLauncher, setCloseLauncher] = useState(config.closeLauncher) @@ -29,8 +33,6 @@ export default function Settings({ hide }: SettingsProps) { config.setRam(ram) config.setCdnServer(cdnServer) config.setCloseLauncher(closeLauncher) - config.setServers(servers) - config.setSelectedServer(selectedServer) setValidated(true) hide() @@ -60,19 +62,6 @@ export default function Settings({ hide }: SettingsProps) { min={1} max={14} /> - - setServers([...servers, server as string]) - } - /> -
-
+
+ + +
+
+ +
- ) } diff --git a/launcher/src/stores/auth.ts b/launcher/src/stores/auth.ts index b368bef..8eadeed 100644 --- a/launcher/src/stores/auth.ts +++ b/launcher/src/stores/auth.ts @@ -8,7 +8,7 @@ interface authStore { logout: () => void } -export default create(set => { +const store = create(set => { const loginInfo = JSON.parse(ipcRenderer.sendSync('msmc-result')) const profile = loginInfo ? loginInfo.profile : undefined @@ -37,3 +37,6 @@ export default create(set => { } } }) + +export const useAuth = store +export const useIsConnected = () => store(state => state.connected) diff --git a/launcher/src/stores/config.ts b/launcher/src/stores/config.ts index 47d2658..689047f 100644 --- a/launcher/src/stores/config.ts +++ b/launcher/src/stores/config.ts @@ -5,28 +5,23 @@ interface configStore { rootDir: string ram: number servers: string[] - selectedServer: number - server: string cdnServer: string closeLauncher: boolean setRootDir: (dir: string) => void setRam: (ram: number) => void setServers: (servers: string[]) => void - setSelectedServer: (selectedServer: number) => void setCdnServer: (cdnServer: string) => void setCloseLauncher: (closeLauncher: boolean) => void resetConfig: () => void } -export default create((set, get) => { - const config = JSON.parse(ipcRenderer.sendSync('get-config')) +const store = create(set => { + const config = ipcRenderer.sendSync('get-config') return { rootDir: config.rootDir, ram: config.ram, servers: config.servers, - selectedServer: config.selectedServer, - server: config.servers[config.selectedServer], cdnServer: config.cdnServer, closeLauncher: config.closeLauncher, setRootDir: (rootDir: string) => { @@ -38,13 +33,9 @@ export default create((set, get) => { ipcRenderer.send('set-config', JSON.stringify({ ram })) }, setServers: (servers: string[]) => { - set({ servers, server: servers[get().selectedServer] }) + set({ servers }) ipcRenderer.send('set-config', JSON.stringify({ servers })) }, - setSelectedServer: (selectedServer: number) => { - set({ selectedServer, server: get().servers[selectedServer] }) - ipcRenderer.send('set-config', JSON.stringify({ selectedServer })) - }, setCdnServer: (cdnServer: string) => { if (cdnServer.endsWith('/')) cdnServer = cdnServer.slice(0, -1) set({ cdnServer }) @@ -61,3 +52,7 @@ export default create((set, get) => { } } }) + +export const useConfig = store +export const useServers = () => + store(state => ({ servers: state.servers, setServers: state.setServers })) diff --git a/launcher/src/stores/profiles.ts b/launcher/src/stores/profiles.ts new file mode 100644 index 0000000..29a225e --- /dev/null +++ b/launcher/src/stores/profiles.ts @@ -0,0 +1,119 @@ +import { create } from 'zustand' +import { ipcRenderer } from 'electron' + +import { useConfig } from './config' +import { Profile } from '../types' +import { useEffect, useState } from 'react' + +interface profileStore { + remoteProfiles: Record + localProfiles: Profile[] + fetching: boolean + fetchRemoteProfiles: () => void + setLocalProfiles: (profiles: Profile[]) => void + + selectedProfile: [string, number] + setSelectedProfile: (profile: [string, number]) => void +} + +const useStore = create(set => { + useConfig.subscribe((config, prev) => { + if (config.servers !== prev.servers) { + fetchRemoteProfiles() + } + }) + + const fetchRemoteProfiles = () => { + const servers = useConfig.getState().servers + const profiles: Record = {} + set({ fetching: true }) + Promise.all( + servers.map(server => + fetch(server + '/profiles') + .then(res => res.json()) + .then(data => [server, data]) + .catch(err => { + console.log('unable to fetch profiles from ' + server) + return [server, []] + }) + ) + ).then(responses => { + for (const response of responses) { + if (!response) continue + const [server, data] = response + profiles[server] = data + } + set({ remoteProfiles: profiles, fetching: false }) + }) + } + + fetchRemoteProfiles() + + return { + remoteProfiles: {}, + localProfiles: ipcRenderer.sendSync('get-local-profiles'), + fetchRemoteProfiles, + fetching: true, + setLocalProfiles: (profiles: Profile[]) => { + set({ localProfiles: profiles }) + ipcRenderer.send('set-local-profiles', profiles) + }, + + selectedProfile: ipcRenderer.sendSync('get-selected-profile'), + setSelectedProfile: (profile: [string, number]) => { + set({ selectedProfile: profile }) + ipcRenderer.send('set-selected-profile', profile) + } + } +}) +export default useStore + +export const useProfiles = () => { + const { localProfiles, remoteProfiles } = useStore(state => ({ + localProfiles: state.localProfiles, + remoteProfiles: state.remoteProfiles + })) + + const [profiles, setProfiles] = useState>({}) + + useEffect(() => { + setProfiles({ ...remoteProfiles, local: localProfiles }) + }, [localProfiles, remoteProfiles]) + + return profiles +} + +export const useLocalProfiles = () => + useStore(state => ({ + localProfiles: state.localProfiles, + setLocalProfiles: state.setLocalProfiles + })) + +export const useSelectedProfile = () => { + const { selectedProfile, setSelectedProfile, fetching } = useStore( + state => ({ + selectedProfile: state.selectedProfile, + setSelectedProfile: state.setSelectedProfile, + fetching: state.fetching + }) + ) + + const profiles = useProfiles() + const servers = useConfig(state => state.servers) + + useEffect(() => { + if (Object.keys(profiles).length === 0 || fetching) return + if ( + !profiles[selectedProfile[0]] || + selectedProfile[1] >= + Object.keys(profiles[selectedProfile[0]]).length + ) { + ipcRenderer.send('set-selected-profile', [0, 0]) + setSelectedProfile([servers[0], 0]) + } + }, [profiles, servers]) + + return { selectedProfile, setSelectedProfile } +} + +export const useIsFetching = () => useStore(state => state.fetching) diff --git a/launcher/src/style/style.scss b/launcher/src/style/style.scss index 5577a70..c0ca7da 100644 --- a/launcher/src/style/style.scss +++ b/launcher/src/style/style.scss @@ -69,6 +69,10 @@ body, --bs-dropdown-bg: rgba(0, 0, 0, 0.05); --bs-dropdown-link-hover-bg: rgba(255, 255, 255, 0.15); + + z-index: 99999; + overflow-y: auto; + max-height: 50vh; } } @@ -94,3 +98,21 @@ h6 { background-position: center; background-repeat: no-repeat; } + +*::-webkit-scrollbar { + background-color: transparent; + width: 15px; +} +*::-webkit-scrollbar-track { + background-color: transparent; +} +*::-webkit-scrollbar-thumb { + border: 4px solid transparent; + border-radius: 20px; + background-color: rgba(255, 255, 255, 0.5); + background-clip: content-box; +} + +.flex-grow { + flex-grow: 1; +} diff --git a/launcher/src/types.ts b/launcher/src/types.ts index 4e4c522..924e321 100644 --- a/launcher/src/types.ts +++ b/launcher/src/types.ts @@ -11,3 +11,8 @@ export interface Task { title: string progress: number } + +export type StartArgs = { + server: string + profile: Profile +}