From f696cf404d65f35694e0437f9d3d747a50aaff5d Mon Sep 17 00:00:00 2001 From: GreenAppers Date: Sat, 23 Nov 2024 16:47:49 -0800 Subject: [PATCH] Track running games --- src/components/App.tsx | 4 +- src/components/Launcher.tsx | 344 +++++++++--------------------- src/components/NewGameInstall.tsx | 200 +++++++++++++++++ src/constants.ts | 13 ++ src/hooks/useMojang.tsx | 21 ++ src/main.ts | 4 +- src/preload.ts | 12 +- src/store.ts | 11 +- src/types.d.ts | 6 + src/utils/launcher.ts | 49 +++-- 10 files changed, 394 insertions(+), 270 deletions(-) create mode 100644 src/components/NewGameInstall.tsx create mode 100644 src/hooks/useMojang.tsx diff --git a/src/components/App.tsx b/src/components/App.tsx index 9da8063..006bdd6 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -114,7 +114,9 @@ export function App() { {selectedTab === 0 && } {selectedTab === 1 && } - {selectedTab === 2 && } + + + {selectedTab === 4 && } diff --git a/src/components/Launcher.tsx b/src/components/Launcher.tsx index d45a9f4..8130c0f 100644 --- a/src/components/Launcher.tsx +++ b/src/components/Launcher.tsx @@ -1,14 +1,10 @@ import { Box, Button, - Checkbox, Flex, - FormControl, - FormLabel, Heading, IconButton, Image, - Input, List, ListItem, Modal, @@ -18,234 +14,62 @@ import { ModalFooter, ModalHeader, ModalOverlay, - Select, Spacer, Spinner, Text, Tooltip, useToken, } from '@chakra-ui/react' -import { AddIcon, EditIcon } from '@chakra-ui/icons' -import { useQuery } from '@tanstack/react-query' -import axios from 'axios' -import React, { useEffect, useState } from 'react' +import { AddIcon, EditIcon, ViewIcon } from '@chakra-ui/icons' +import React, { useEffect, useRef, useState } from 'react' import { v4 as uuidv4 } from 'uuid' import { - findVersionManifest, GameInstall, getGameInstallIsHacked, getGameInstalModLoaderName, + LAUNCH_STATUS, ModLoaderName, - MojangVersionManifests, - mojangVersionManifests, - QUERY_KEYS, - setGameInstallModLoaderName, - toggleGameInstallModeUrl, + removeProperty, } from '../constants' -import { useCreateGameInstallMutation, useGameInstallsQuery } from '../hooks/useStore' -import { mods } from '../utils/mods' +import { useGameInstallsQuery } from '../hooks/useStore' +import { NewGameInstall } from './NewGameInstall' -const defaultVersion = '1.20.6' - -export const useMojangVersionManifestsQuery = () => - useQuery({ - queryKey: [QUERY_KEYS.useMojangVersionManifests], - queryFn: async () => { - const { data } = await axios.get( - 'https://launchermeta.mojang.com/mc/game/version_manifest.json' - ) - return mojangVersionManifests.parse(data) - }, - refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }) - -export function NewGameInstall(props: { - existingInstall?: GameInstall - isOpen: boolean - onClose: () => void -}) { - const [yellow100] = useToken('colors', ['yellow.100']) - const [newInstall, setNewInstall] = useState>( - props.existingInstall ?? { - name: '', - path: '', - versionManifest: undefined, - } - ) - - const updateNewInstallVersionManifest = (version: string) => { - const newVersionManifest = findVersionManifest( - versionManifests.data, - version - ) - if (!newVersionManifest) return - setNewInstall((prev) => ({ - ...prev, - name: - !prev.name || prev.name === prev.versionManifest?.id - ? newVersionManifest.id - : prev.name, - versionManifest: newVersionManifest, - })) - } - - const versionManifests = useMojangVersionManifestsQuery() - const createGameInstallMutation = useCreateGameInstallMutation({ - callback: props.onClose, - }) - - useEffect(() => { - if (!newInstall.versionManifest?.id && versionManifests.isSuccess) { - const defaultVersionManifest = findVersionManifest( - versionManifests.data, - defaultVersion - ) - updateNewInstallVersionManifest( - defaultVersionManifest - ? defaultVersion - : versionManifests.data.latest.release - ) - } - }, [ - newInstall.versionManifest?.id, - versionManifests.isSuccess, - versionManifests.data, - ]) - - return ( - - - - - {props.existingInstall ? 'Update' : 'New'} Install - - - {versionManifests.isError ? ( - - Error loading version manifests - {versionManifests.error.message} - - ) : !versionManifests.isSuccess || - createGameInstallMutation.isPending ? ( - - - - ) : ( - - - Version - - - - Name - - setNewInstall((prev) => ({ - ...prev, - name: event.target.value, - })) - } - /> - - - Mod loader - - - {newInstall.fabricLoaderVersion && ( - - {Object.entries( - mods[`${newInstall?.versionManifest?.id}-fabric`] || {} - ).map(([name, url]) => ( - - - setNewInstall((prev) => - toggleGameInstallModeUrl( - prev, - url, - !!e.target.checked - ) - ) - } - > - {name} - - - ))} - - )} - - )} - - {versionManifests.isSuccess && - !createGameInstallMutation.isPending && ( - - )} - - - - ) +interface RunningLaunch { + install: GameInstall + processId?: number + show: boolean } -export function LaunchGameInstall(props: { +export function LaunchedGame(props: { isOpen: boolean launchId: string install: GameInstall onClose: () => void + onFinished: () => void + onProcessId: (processId: number) => void }) { + const containerRef = useRef(null) + const textRef = useRef(null) const [purple100] = useToken('colors', ['purple.100']) + useEffect(() => { - if (!props.isOpen || !props.launchId) return + if (!props.launchId) return const handle = window.api.launchGameInstall( props.launchId, props.install, - (text) => { - console.log(props.launchId, text) + (message) => { + if (message.message) { + if (textRef.current) textRef.current.textContent += message.message + if (containerRef.current) + containerRef.current.scrollTop = containerRef.current.scrollHeight + } + if (message.processId) props.onProcessId(message.processId) + if (message.status === LAUNCH_STATUS.finished) props.onFinished() } ) return () => window.api.removeListener(handle) - }, [props.isOpen, props.launchId]) + }, [props.launchId]) return ( @@ -255,6 +79,8 @@ export function LaunchGameInstall(props: { bgImage="app-file://images/icons/obsidian.png" bgSize={64} border={`${purple100} 2px solid`} + minWidth="fit-content" + height="fit-content" > Launching {props.install.name} @@ -262,6 +88,12 @@ export function LaunchGameInstall(props: { +
+

+

+ )} + + +
+ ) +} diff --git a/src/constants.ts b/src/constants.ts index 2f09dc7..9e0c0fc 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -258,6 +258,10 @@ export const CHANNELS = { readGameLogs: 'read-game-logs', } +export const LAUNCH_STATUS = { + finished: 'finished', +} + export const LAUNCH_CHANNEL = (uuid: string) => `launch-game-install-${uuid}` export const findVersionManifest = ( @@ -325,3 +329,12 @@ export const updateVersionDetailsLibrary = ( } versionDetails.libraries.push(newLibrary) } + +export const removeProperty = >( + x: X, + key: string +) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [key]: _removed, ...rest } = x + return rest +} diff --git a/src/hooks/useMojang.tsx b/src/hooks/useMojang.tsx new file mode 100644 index 0000000..efa996a --- /dev/null +++ b/src/hooks/useMojang.tsx @@ -0,0 +1,21 @@ +import { useQuery } from '@tanstack/react-query' +import { + mojangVersionManifests, + MojangVersionManifests, + QUERY_KEYS, +} from '../constants' +import axios from 'axios' + +export const useMojangVersionManifestsQuery = () => + useQuery({ + queryKey: [QUERY_KEYS.useMojangVersionManifests], + queryFn: async () => { + const { data } = await axios.get( + 'https://launchermeta.mojang.com/mc/game/version_manifest.json' + ) + return mojangVersionManifests.parse(data) + }, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }) diff --git a/src/main.ts b/src/main.ts index acd6242..66833dc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -120,8 +120,8 @@ app.on('ready', () => { CHANNELS.launchGameInstall, async (_event, launchId: string, gameInstall: GameInstall) => { const channel = LAUNCH_CHANNEL(launchId) - launchInstall(launchId, gameInstall, authProvider, store, (update) => { - mainWindow?.webContents.send(channel, update) + launchInstall(launchId, gameInstall, authProvider, store, (message) => { + mainWindow?.webContents.send(channel, message) }).catch((error) => log.error('launchGameInstall error', error)) return true } diff --git a/src/preload.ts b/src/preload.ts index 3fd15ef..ead2fcc 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -4,7 +4,7 @@ import { contextBridge, ipcRenderer } from 'electron/renderer' import { CHANNELS, GameInstall, LAUNCH_CHANNEL } from './constants' -import type { GameLogLine } from './types' +import type { GameLogLine, LaunchStatusMessage } from './types' export type Listener = ( event: Electron.IpcRendererEvent, @@ -25,14 +25,15 @@ export const api = { launchGameInstall: ( launchId: string, gameInstall: GameInstall, - callback: (text: string) => void - ): ListenHandle<[string]> => { + callback: (message: LaunchStatusMessage) => void + ): ListenHandle<[LaunchStatusMessage]> => { ipcRenderer .invoke(CHANNELS.launchGameInstall, launchId, gameInstall) .catch((error) => console.log(`${CHANNELS.launchGameInstall} error`, error) ) - const listener: Listener<[string]> = (_event, text) => callback(text) + const listener: Listener<[LaunchStatusMessage]> = (_event, message) => + callback(message) const channel = LAUNCH_CHANNEL(launchId) ipcRenderer.on(channel, listener) return { channel, listener } @@ -59,7 +60,8 @@ export const api = { ipcRenderer .invoke(CHANNELS.readGameLogs, gameLogDirectories, beginDate, endDate) .catch((error) => console.log(`${CHANNELS.readGameLogs} error`, error)) - const listener: Listener<[GameLogLine[]]> = (_event, lines) => callback(lines) + const listener: Listener<[GameLogLine[]]> = (_event, lines) => + callback(lines) ipcRenderer.on(CHANNELS.readGameLogs, listener) return { channel: CHANNELS.readGameLogs, listener } }, diff --git a/src/store.ts b/src/store.ts index f12e49d..c9deb20 100644 --- a/src/store.ts +++ b/src/store.ts @@ -37,11 +37,20 @@ export const newStore = () => serialize: (value) => JSON.stringify( value, - (_, x) => (x instanceof RegExp ? x.toString().slice(1,-1) : x), + (_, x) => (x instanceof RegExp ? x.toString().slice(1, -1) : x), '\t' ), }) +export const getActiveGameAccount = (store: Store) => + store + .get(STORE_KEYS.gameAccounts) + .find( + (x) => + x.active && + new Date().getTime() < new Date(x.minecraftToken.NotAfter).getTime() + ) || null + export const updateGameAccount = ( store: Store, gameAccount: GameAccount diff --git a/src/types.d.ts b/src/types.d.ts index 47bd740..eb6c571 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -25,6 +25,12 @@ interface GameLogLine { source: string } +interface LaunchStatusMessage { + message?: string + processId?: number + status?: string +} + interface PieRaySample { chunk: Chunk direction?: ChunkDirection diff --git a/src/utils/launcher.ts b/src/utils/launcher.ts index 27c65b3..ad7204e 100644 --- a/src/utils/launcher.ts +++ b/src/utils/launcher.ts @@ -4,17 +4,20 @@ import { app } from 'electron' import fs from 'fs' import path from 'path' import pSettle from 'p-settle' +import readline from 'readline' import { v4 as uuidv4 } from 'uuid' import { fabricVersionDetails, GameInstall, + LAUNCH_STATUS, mojangVersionDetails, parseLibraryName, updateVersionDetailsLibrary, } from '../constants' import { AuthProvider } from '../msal/AuthProvider' -import { Store, StoreSchema } from '../store' +import { getActiveGameAccount, Store, StoreSchema } from '../store' +import type { LaunchStatusMessage } from '../types' import { loginToMinecraft } from './auth' import { checkFileExists, @@ -138,7 +141,6 @@ export async function updateInstall(install: GameInstall) { downloadIfMissing(url, path.join(librariesPath, jarPath)) ) } - // https://github.com/FabricMC/fabric/releases/download/0.107.0%2B1.21.3/fabric-api-0.107.0+1.21.3.jar for (const argument of fabricDetails.arguments.jvm) { versionDetails.arguments.jvm.push(argument) } @@ -165,7 +167,7 @@ export async function launchInstall( install: GameInstall, authProvider: AuthProvider, store: Store, - callback: (updated: string) => void + callback: (message: LaunchStatusMessage) => void ) { try { if (launchRunning[launchId]) { @@ -174,7 +176,7 @@ export async function launchInstall( } launchRunning[launchId] = install - callback('Updating install') + callback({ message: 'Updating install' }) const versionDetails = await updateInstall(install) const osArch = getOsArch() @@ -198,19 +200,24 @@ export async function launchInstall( classpath: '', } - callback('Authenticating') + callback({ message: 'Authenticating' }) try { - const microsoftAuth = await authProvider.login() - const gameAccount = await loginToMinecraft( - microsoftAuth?.accessToken, - store - ) + let gameAccount = getActiveGameAccount(store) + if (gameAccount) { + callback({ + message: `Found cached account: ${gameAccount.profile.name}`, + }) + } else { + const microsoftAuth = await authProvider.login() + gameAccount = await loginToMinecraft(microsoftAuth?.accessToken, store) + callback({ message: `Logged in as: ${gameAccount.profile.name}` }) + } template.auth_access_token = gameAccount.yggdrasilToken.access_token template.auth_player_name = gameAccount.profile.name template.auth_uuid = gameAccount.profile.id template.user_type = 'msa' } catch (error) { - callback('Error authenticating: ' + error.toString()) + callback({ message: 'Error authenticating: ' + error.toString() }) } const appendClasspath = (path: string) => @@ -236,26 +243,32 @@ export async function launchInstall( command ) command = filterBlankArguments(command) - callback('Launching install: ' + command.join(' ')) + callback({ message: 'Launching install: ' + command.join(' ') }) const child = spawn(command[0], command.slice(1), { cwd: install.path, }) - child.stdout.on('data', (data) => callback(data.toString())) - child.stderr.on('data', (data) => callback(data.toString())) + child.stdout.on('data', (data) => callback({ message: data.toString() })) + child.stderr.on('data', (data) => callback({ message: data.toString() })) child.on('error', (err: Error) => { throw new Error(`launch:${launchId} failed to start! ${err}`) }) child.on('exit', (code: number) => { if (code === 0) { - callback(`launch:${launchId} exited`) + callback({ + message: `launch:${launchId} exited`, + status: LAUNCH_STATUS.finished, + }) } else { - callback(`launch:${launchId} exited with code ${code}`) + callback({ + message: `launch:${launchId} exited with code ${code}`, + status: LAUNCH_STATUS.finished, + }) } }) - callback(`Complete: ${child.pid}`) + callback({ message: `Complete: ${child.pid}` }) } catch (error) { - callback('Error launching: ' + error.toString()) + callback({ message: 'Error launching: ' + error.toString() }) throw error } finally { delete launchRunning[launchId]