feat: Better ClusterGame page + Added multi-cluster process support

This commit is contained in:
LynithDev 2024-08-15 11:03:04 +02:00
parent ca66cf5684
commit 08528ec7c1
19 changed files with 455 additions and 175 deletions

View file

@ -5,6 +5,7 @@ use std::str::FromStr;
use onelauncher::cluster::content::logger;
use onelauncher::cluster::{self};
use onelauncher::data::{Loader, PackageData};
use onelauncher::processor::DetailedProcess;
use onelauncher::store::{Cluster, ClusterPath};
use onelauncher::State;
use serde::{Deserialize, Serialize};
@ -132,21 +133,12 @@ pub async fn remove_cluster(uuid: Uuid) -> Result<(), String> {
#[specta::specta]
#[tauri::command]
pub async fn run_cluster(uuid: Uuid) -> Result<(Uuid, u32), String> {
pub async fn run_cluster(uuid: Uuid) -> Result<DetailedProcess, String> {
let path = ClusterPath::find_by_uuid(uuid).await?;
let c_lock = cluster::run_default(&path).await?;
let child = &*c_lock.read().await;
let p_uuid = c_lock.read().await.uuid;
let p_pid = c_lock
.read()
.await
.current_child
.read()
.await
.id()
.unwrap_or(0);
Ok((p_uuid, p_pid))
Ok(DetailedProcess::from_processor_child(child).await)
}
#[specta::specta]

View file

@ -47,6 +47,10 @@ macro_rules! collect_commands {
kill_process,
is_cluster_running,
get_pid_by_uuid,
get_user_by_process,
get_process_started_at,
get_processes_detailed_by_path,
get_process_detailed_by_id,
// Settings
get_settings,
set_settings,

View file

@ -1,4 +1,5 @@
use onelauncher::processor;
use chrono::{DateTime, Utc};
use onelauncher::processor::{self, DetailedProcess};
use onelauncher::store::{Cluster, ClusterPath};
use uuid::Uuid;
@ -20,6 +21,30 @@ pub async fn get_processes_by_path(path: ClusterPath) -> Result<Vec<Uuid>, Strin
Ok(processor::get_uuids_by_cluster_path(path).await?)
}
#[specta::specta]
#[tauri::command]
pub async fn get_processes_detailed_by_path(path: ClusterPath) -> Result<Vec<DetailedProcess>, String> {
Ok(processor::get_processes_detailed_by_path(path).await?)
}
#[specta::specta]
#[tauri::command]
pub async fn get_process_detailed_by_id(uuid: Uuid) -> Result<DetailedProcess, String> {
Ok(processor::get_process_detailed_by_id(uuid).await?)
}
#[specta::specta]
#[tauri::command]
pub async fn get_user_by_process(uuid: Uuid) -> Result<Option<Uuid>, String> {
Ok(processor::get_user_by_process(uuid).await?)
}
#[specta::specta]
#[tauri::command]
pub async fn get_process_started_at(uuid: Uuid) -> Result<DateTime<Utc>, String> {
Ok(processor::get_process_started_at(uuid).await?)
}
#[specta::specta]
#[tauri::command]
pub async fn get_pid_by_uuid(uuid: Uuid) -> Result<u32, String> {

View file

@ -26,8 +26,10 @@ function Sidebar(props: SidebarProps) {
navigate(url);
}
function isActive(link: string) {
return location.pathname === `${props.base}${link}` || `${location.pathname}/` === `${props.base}${link}`;
function isActive(link: string, params: URLSearchParams | undefined) {
const pathnameMatch = location.pathname === `${props.base}${link}` || `${location.pathname}/` === `${props.base}${link}`;
const paramsMatch = params ? location.search.endsWith(params.toString()) : true;
return pathnameMatch && paramsMatch;
}
return (
@ -47,7 +49,7 @@ function Sidebar(props: SidebarProps) {
<a
onClick={() => goto(link[2], link[3])}
class={
`px-3 py-1 rounded-md text-md hover:bg-component-bg-hover active:bg-component-bg-pressed ${isActive(link[2]) ? 'bg-component-bg' : ''}`
`px-3 py-1 rounded-md text-md hover:bg-component-bg-hover active:bg-component-bg-pressed ${isActive(link[2], link[3]) ? 'bg-component-bg' : ''}`
}
>
<span class="flex flex-row items-center gap-x-3 *:w-5">

View file

@ -10,7 +10,7 @@ function FormattedLog(props: FormattedLogProps) {
const lines = createMemo(() => props.log.split('\n'));
return (
<div class="mt-2 h-full flex flex-1 overflow-auto rounded-md bg-component-bg p-2 font-medium font-mono">
<div class="h-full flex flex-1 overflow-auto rounded-md bg-component-bg p-2 font-medium font-mono">
<OverlayScrollbarsComponent class="relative h-full flex-1">
<code class={styles.log}>
<For each={lines()}>

View file

@ -164,14 +164,48 @@ Modal.Simple = function (props: ModalSimpleProps) {
);
};
type ModalErrorProps = MakeOptional<ModalSimpleProps, 'title'> & {
message?: string | undefined;
};
Modal.Error = function (props: ModalErrorProps) {
const [split, rest] = splitProps(props, ['message', 'title', 'buttons', 'children']);
return (
<Modal.Simple
{...rest}
title={split.title || 'Error'}
buttons={split.buttons || [
<Button
buttonStyle="secondary"
children="Close"
onClick={props.hide}
/>,
]}
>
<div class="max-w-84 flex flex-col items-center gap-y-2">
{split.children || (
<span>Something went wrong.</span>
)}
{split.message && (
<code class="rounded-md bg-component-bg p-2 text-left">
{split.message}
</code>
)}
</div>
</Modal.Simple>
);
};
type ModalDeleteProps = MakeOptional<ModalSimpleProps, 'title'> & {
onCancel?: () => void;
onDelete?: () => void;
timeLeft?: number;
deleteBtnText?: string;
};
Modal.Delete = function (props: ModalDeleteProps) {
const [split, rest] = splitProps(props, ['title', 'buttons', 'onCancel', 'onDelete', 'timeLeft', 'children']);
const [split, rest] = splitProps(props, ['title', 'buttons', 'onCancel', 'onDelete', 'timeLeft', 'children', 'deleteBtnText']);
const [timeLeft, setTimeLeft] = createSignal(1);
const [intervalId, setIntervalId] = createSignal<NodeJS.Timeout | undefined>(undefined);
@ -235,7 +269,7 @@ Modal.Delete = function (props: ModalDeleteProps) {
/>,
<Button
buttonStyle="danger"
children={`Delete${timeLeft() > 0 ? ` (${timeLeft()}s)` : ''}`}
children={(props.deleteBtnText || 'Delete $1').replace('$1', timeLeft() > 0 ? `(${timeLeft()}s)` : '')}
disabled={timeLeft() > 0}
onClick={onDelete}
/>,

View file

@ -18,19 +18,15 @@ export default createClusterStep({
});
function ClusterGameSetup(props: ClusterStepProps) {
// const check = () => {
// const hasName = (props.controller().partialCluster().name?.length ?? 0) > 0;
// const hasVersion = (props.controller().partialCluster().mc_version?.length ?? 0) > 0;
// const hasLoader = (props.controller().partialCluster().mod_loader?.length ?? 0) > 0;
const check = () => {
const hasName = (props.controller.partialCluster().name?.length ?? 0) > 0;
const hasVersion = (props.controller.partialCluster().mc_version?.length ?? 0) > 0;
const hasLoader = (props.controller.partialCluster().mod_loader?.length ?? 0) > 0;
// props.setCanGoForward(hasName && hasVersion && hasLoader);
// };
props.setCanGoForward(hasName && hasVersion && hasLoader);
};
// createEffect(check);
// createEffect(on(() => props.isVisible(), (curr: boolean) => {
// if (curr)
// check();
// }));
createEffect(check);
const setName = (name: string) => props.controller.updatePartialCluster('name', name);
const setVersion = (version: string) => props.controller.updatePartialCluster('mc_version', version);
@ -63,7 +59,7 @@ function ClusterGameSetup(props: ClusterStepProps) {
<div class="h-4 w-4">
<LoaderIcon loader={loader} />
</div>
{loader}
<span class="capitalize">{loader}</span>
</div>
</Dropdown.Row>
)}

View file

@ -1,7 +1,10 @@
import { type Context, type ParentProps, type ResourceReturn, Show, createContext, useContext } from 'solid-js';
import useCommand from './useCommand';
import { type Accessor, type Context, type ParentProps, type ResourceReturn, Show, createContext, createSignal, useContext } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import useCommand, { tryResult } from './useCommand';
import type { Cluster } from '~bindings';
import { bridge } from '~imports';
import ClusterGame from '~ui/pages/cluster/ClusterGame';
import Modal, { createModal } from '~ui/components/overlay/Modal';
const ClusterContext = createContext() as Context<ResourceReturn<Cluster>>;
@ -35,4 +38,32 @@ export function useClusterContext() {
return context;
}
export function useLaunchCluster(uuid: string | Accessor<string | undefined> | (() => string | undefined) | undefined) {
const navigate = useNavigate();
const [error, setError] = createSignal<string | undefined>(undefined);
const getUuid = () => typeof uuid === 'function' ? uuid() : uuid;
const modal = createModal(props => (
<Modal.Error
{...props}
message={error()}
/>
));
return () => {
const uuid = getUuid();
if (uuid === undefined)
return;
tryResult(bridge.commands.runCluster, uuid).then((details) => {
navigate(`/clusters/game?${ClusterGame.buildUrl(uuid, details).toString()}`);
}).catch((err) => {
setError(err);
modal.show();
});
};
}
export default useClusterContext;

View file

@ -18,6 +18,7 @@ import useCommand from '~ui/hooks/useCommand';
import { bridge } from '~imports';
import { secondsToWords, upperFirst } from '~utils';
import { useClusterCreator } from '~ui/components/overlay/cluster/ClusterCreationModal';
import { useLaunchCluster } from '~ui/hooks/useCluster';
type GroupedClusters = Record<string, Cluster[]>;
@ -183,6 +184,7 @@ function Banner(props: BannerProps) {
* If there are no clusters, display a generic background with the button action creating a new cluster.
*/
const [cluster, setCluster] = createSignal<Cluster>();
const launch = useLaunchCluster(() => cluster()?.uuid);
const navigate = useNavigate();
createEffect(() => {
@ -259,7 +261,7 @@ function Banner(props: BannerProps) {
buttonStyle="primary"
iconLeft={<PlayIcon />}
children={`Launch ${cluster()!.meta.mc_version}`}
onClick={() => ClusterRoot.launch(navigate, cluster()!.uuid)}
onClick={launch}
/>
<Button
buttonStyle="iconSecondary"
@ -291,6 +293,8 @@ function ClusterCard(props: Cluster) {
navigate(`/clusters/?id=${props.uuid}`);
}
const launch = useLaunchCluster(() => props.uuid);
return (
<>
<div
@ -328,7 +332,7 @@ function ClusterCard(props: Cluster) {
onClick={(e) => {
e.preventDefault();
e.stopImmediatePropagation();
ClusterRoot.launch(navigate, props.uuid);
launch();
}}
>
<PlayIcon class="h-4! w-4!" />

View file

@ -1,17 +1,23 @@
import { type Params, useNavigate, useSearchParams } from '@solidjs/router';
import { createEffect, createSignal, onCleanup, onMount } from 'solid-js';
import { createSignal, onCleanup, onMount } from 'solid-js';
import type { UnlistenFn } from '@tauri-apps/api/event';
import { SlashOctagonIcon } from '@untitled-theme/icons-solid';
import ClusterRoot from './ClusterRoot';
import { bridge } from '~imports';
import Sidebar from '~ui/components/Sidebar';
import useClusterContext from '~ui/hooks/useCluster';
import { TimeAgo } from '~ui/components/DynamicTime';
import Tooltip from '~ui/components/base/Tooltip';
import useCommand from '~ui/hooks/useCommand';
import FormattedLog from '~ui/components/content/FormattedLog';
import Button from '~ui/components/base/Button';
import Modal, { createModal } from '~ui/components/overlay/Modal';
import type { DetailedProcess } from '~bindings';
interface ClusterGameParams extends Params {
process_uuid: string;
started_at: string;
pid: string;
user: string;
};
function ClusterGame() {
@ -20,22 +26,6 @@ function ClusterGame() {
const navigate = useNavigate();
const [unlisten, setUnlisten] = createSignal<UnlistenFn>();
const [startedAt, setStartedAt] = createSignal<number>();
const [pid] = useCommand(bridge.commands.getPidByUuid, params.process_uuid || '');
createEffect(() => {
if (params.process_uuid === undefined)
throw new Error('process_uuid is required');
const c = cluster();
if (c === undefined)
throw new Error('Cluster not found');
if (typeof c.meta.played_at !== 'string')
setStartedAt(Date.now());
else
setStartedAt(new Date(c.meta.played_at).getTime());
});
onMount(async () => {
const unlisten = await bridge.events.processPayload.listen(({ payload }) => {
@ -50,39 +40,77 @@ function ClusterGame() {
unlisten()?.();
});
const startedAtFormatted = (startedAt: number) => {
const date = new Date(startedAt);
const startedAtFormatted = () => {
const date = new Date(params.started_at!);
return date.toLocaleString();
};
const killModal = createModal(props => (
<Modal.Delete
{...props}
title="Kill Game"
children="Are you sure you want to kill the game?"
onDelete={() => killProcess(true)}
deleteBtnText="Kill $1"
/>
));
function killProcess(force: boolean = false) {
if (params.process_uuid !== undefined) {
if (force === true) {
bridge.commands.killProcess(params.process_uuid);
return;
}
killModal.show();
}
}
return (
<Sidebar.Page>
<h1>Game Running</h1>
<div class="flex flex-col gap-y-2">
<div class="flex flex-row">
<Tooltip text={startedAtFormatted(startedAt() || 0)}>
Started:
<div class="flex flex-1 flex-col gap-y-4">
<div class="flex flex-col gap-y-2">
<div class="flex flex-row">
<Tooltip text={startedAtFormatted()}>
Started:
{' '}
<strong>
<TimeAgo timestamp={new Date(params.started_at!).getTime()} />
</strong>
</Tooltip>
</div>
<p>
PID:
{' '}
<strong>
<TimeAgo timestamp={startedAt() || 0} />
</strong>
</Tooltip>
<strong>{params.pid || 0}</strong>
</p>
</div>
<p>
PID:
{' '}
<strong>{pid() || 0}</strong>
</p>
</div>
<FormattedLog log="aaa" />
<FormattedLog log="aaa" />
<div class="flex flex-row items-center justify-end gap-x-2">
<Button
buttonStyle="danger"
children="Kill"
iconLeft={<SlashOctagonIcon />}
onClick={() => killProcess(false)}
/>
</div>
</div>
</Sidebar.Page>
);
}
ClusterGame.buildUrl = function (uuid: string, process_uuid: string): URLSearchParams {
return new URLSearchParams({ id: uuid, process_uuid });
ClusterGame.buildUrl = function (cluster_id: string, detailed: DetailedProcess): URLSearchParams {
return new URLSearchParams({
id: cluster_id,
process_uuid: detailed.uuid,
started_at: detailed.started_at,
pid: detailed.pid.toString(),
...(detailed.user ? { user: detailed.user } : {}),
});
};
export default ClusterGame;

View file

@ -7,12 +7,11 @@ import { join } from 'pathe';
import ClusterCover from '../../components/game/ClusterCover';
import LoaderIcon from '../../components/game/LoaderIcon';
import Button from '../../components/base/Button';
import ClusterRoot from './ClusterRoot';
import ScrollableContainer from '~ui/components/ScrollableContainer';
import Sidebar from '~ui/components/Sidebar';
import type { Cluster } from '~bindings';
import { secondsToWords, upperFirst } from '~utils';
import useClusterContext from '~ui/hooks/useCluster';
import useClusterContext, { useLaunchCluster } from '~ui/hooks/useCluster';
import { bridge } from '~imports';
import TextField from '~ui/components/base/TextField';
import SettingsRow from '~ui/components/SettingsRow';
@ -233,11 +232,7 @@ interface BannerProps {
}
function Banner(props: BannerProps) {
const navigate = useNavigate();
async function launch() {
ClusterRoot.launch(navigate, props.cluster.uuid);
}
const launch = useLaunchCluster(() => props.cluster.uuid);
async function launchFilePicker() {
const selected = await dialog.open({

View file

@ -1,5 +1,5 @@
import { type Navigator, Route, useIsRouting, useSearchParams } from '@solidjs/router';
import { type ParentProps, createEffect, createResource, createSignal, onCleanup, onMount } from 'solid-js';
import { type ParentProps, Show, createEffect, createResource, createSignal, onCleanup, onMount } from 'solid-js';
import { EyeIcon, File06Icon, Globe04Icon, Image03Icon, PackagePlusIcon, Settings04Icon } from '@untitled-theme/icons-solid';
import type { UnlistenFn } from '@tauri-apps/api/event';
import Sidebar from '../../components/Sidebar';
@ -27,7 +27,18 @@ function ClusterRoutes() {
<Route path="/screenshots" component={ClusterScreenshots} />
<Route path="/worlds" component={ClusterWorlds} />
<Route path="/settings" component={ClusterSettings} />
<Route path="/game" component={ClusterGame} />
<Route
path="/game"
component={() => {
const [searchParams] = useSearchParams();
return (
<Show keyed when={searchParams.process_uuid}>
<ClusterGame />
</Show>
);
}}
/>
</>
);
}
@ -58,12 +69,6 @@ ClusterRoot.open = function (navigate: Navigator, uuid: string) {
navigate(`/clusters/?id=${uuid}`);
};
ClusterRoot.launch = function (navigate: Navigator, uuid: string) {
tryResult(bridge.commands.runCluster, uuid).then(([process_uuid]) => {
navigate(`/clusters/game?${ClusterGame.buildUrl(uuid, process_uuid).toString()}`);
});
};
function ClusterSidebar() {
const [cluster, { refetch: refetchCluster }] = useClusterContext();
const [listener, setListener] = createSignal<UnlistenFn>();
@ -72,7 +77,7 @@ function ClusterSidebar() {
if (!c || typeof c.path !== 'string')
return [];
return tryResult(bridge.commands.getProcessesByPath, c.path);
return tryResult(bridge.commands.getProcessesDetailedByPath, c.path);
});
const isRouting = useIsRouting();
@ -106,13 +111,12 @@ function ClusterSidebar() {
[<File06Icon />, 'Logs', '/logs'],
[<Settings04Icon />, 'Game Settings', '/settings'],
],
// TODO: Support multiple running clusters and add used account's avatar as the icon
...(runningProcesses() && runningProcesses()!.length > 0
? {
Running: runningProcesses()!.map((process_uuid, index) => {
const icon = <PlayerHead uuid={null} />; // : <Settings01Icon />;
Running: runningProcesses()!.map((details, index) => {
const icon = <PlayerHead uuid={details.user} />; ;
return [icon, `Process #${index + 1}`, '/game', ClusterGame.buildUrl(cluster()!.uuid, process_uuid)];
return [icon, `Process #${index + 1}`, '/game', ClusterGame.buildUrl(cluster()!.uuid, details)];
}),
}
: {}),

View file

@ -69,10 +69,35 @@ function PageSettings(cluster: Accessor<Cluster>) {
return (
<>
<GameSettings {...{ fullscreen, memory, resolution }} />
<LauncherSettings {...{ hideOnLaunch: undefined }} />
<ProcessSettings {...{ preCommand, wrapperCommand, postCommand }} />
<JvmSettings {...{ javaArgs, envVars }} />
<GameSettings
{...{
fullscreen,
memory,
resolution,
}}
/>
<LauncherSettings
{...{
hideOnLaunch: undefined,
allowParallelClusters: undefined,
}}
/>
<ProcessSettings
{...{
preCommand,
wrapperCommand,
postCommand,
}}
/>
<JvmSettings
{...{
javaArgs,
envVars,
}}
/>
</>
);
}

View file

@ -1,4 +1,4 @@
import { CpuChip01Icon, Database01Icon, EyeIcon, FilePlus02Icon, FileX02Icon, LayoutTopIcon, Maximize01Icon, ParagraphWrapIcon, VariableIcon, XIcon } from '@untitled-theme/icons-solid';
import { ActivityIcon, CpuChip01Icon, Database01Icon, EyeIcon, FilePlus02Icon, FileX02Icon, LayoutTopIcon, Maximize01Icon, ParagraphWrapIcon, VariableIcon, XIcon } from '@untitled-theme/icons-solid';
import { type Accessor, type Setter, Show, createSignal, onMount, splitProps, untrack } from 'solid-js';
import type { Memory, Resolution } from '~bindings';
import TextField from '~ui/components/base/TextField';
@ -192,12 +192,17 @@ export function GameSettings(props: {
export function LauncherSettings(props: {
hideOnLaunch: CreateSetting<boolean> | undefined;
allowParallelClusters: CreateSetting<boolean> | undefined;
}) {
const shouldShowHeader = () => Object.values(props).some(setting => setting !== undefined);
return (
<>
<Show when={props.hideOnLaunch !== undefined}>
<Show when={shouldShowHeader()}>
<BaseSettingsRow.Header>Launcher</BaseSettingsRow.Header>
</Show>
<Show when={props.hideOnLaunch !== undefined}>
<SettingsRow
title="Hide On Launch"
description="Hide the launcher whenever you start a game."
@ -211,6 +216,21 @@ export function LauncherSettings(props: {
/>
</SettingsRow>
</Show>
<Show when={props.allowParallelClusters !== undefined}>
<SettingsRow
title="Allow Parallel Clusters"
description="Allow running the same cluster with the same account."
icon={<ActivityIcon />}
isGlobal={props.allowParallelClusters!.isGlobal}
reset={props.allowParallelClusters!.resetToFallback}
>
<Toggle
checked={props.allowParallelClusters!.get}
onChecked={props.allowParallelClusters!.set}
/>
</SettingsRow>
</Show>
</>
);
}
@ -335,6 +355,7 @@ function PageSettings() {
// Launcher
const hideOnLaunch = createSetting(settings().hide_on_launch ?? false);
const allowParallelClusters = createSetting(settings().allow_parallel_running_clusters ?? false);
// Process
const preCommand = createSetting(settings().init_hooks.pre ?? '');
@ -353,6 +374,7 @@ function PageSettings() {
// Launcher
hide_on_launch: hideOnLaunch.get(),
allow_parallel_running_clusters: allowParallelClusters.get(),
// Process
init_hooks: {
@ -368,10 +390,35 @@ function PageSettings() {
return (
<>
<GameSettings {...{ fullscreen, memory, resolution }} />
<LauncherSettings {...{ hideOnLaunch }} />
<ProcessSettings {...{ preCommand, wrapperCommand, postCommand }} />
<JvmSettings {...{ javaArgs, envVars }} />
<GameSettings
{...{
fullscreen,
memory,
resolution,
}}
/>
<LauncherSettings
{...{
hideOnLaunch,
allowParallelClusters,
}}
/>
<ProcessSettings
{...{
preCommand,
wrapperCommand,
postCommand,
}}
/>
<JvmSettings
{...{
javaArgs,
envVars,
}}
/>
</>
);
}

View file

@ -2,14 +2,98 @@
pub use crate::store::{Cluster, InitHooks, JavaOptions, Memory, Resolution, Settings, State};
use crate::store::{ClusterPath, ProcessorChild};
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[derive(specta::Type, serde::Serialize)]
pub struct DetailedProcess {
pub uuid: Uuid,
pub user: Option<Uuid>,
pub started_at: DateTime<Utc>,
pub pid: u32,
}
impl DetailedProcess {
pub async fn from_processor_child(process: &ProcessorChild) -> Self {
let pid = process.current_child.read().await.id().unwrap_or(0);
Self {
uuid: process.uuid,
user: process.user,
started_at: process.started_at,
pid,
}
}
}
/// get detailed processes by a [`ClusterPath`].
#[tracing::instrument]
pub async fn get_process_detailed_by_id(uuid: Uuid) -> crate::Result<DetailedProcess> {
let state = State::get().await?;
let processor = state.processor.read().await;
let child = processor.get(uuid).ok_or(anyhow::anyhow!("process not found"))?;
let child = child.read().await;
let pid = child.current_child.read().await.id().ok_or(anyhow::anyhow!("process not found"))?;
Ok(DetailedProcess {
uuid,
user: child.user,
started_at: child.started_at,
pid,
})
}
/// get detailed processes by a [`ClusterPath`].
#[tracing::instrument]
pub async fn get_processes_detailed_by_path(path: ClusterPath) -> crate::Result<Vec<DetailedProcess>> {
let state = State::get().await?;
let processor = state.processor.read().await;
let uuids = processor.running_cluster(path).await?;
let mut processes = Vec::new();
for uuid in uuids {
let child = processor.get(uuid).ok_or(anyhow::anyhow!("process not found"))?;
let child = child.read().await;
let pid = child.current_child.read().await.id().ok_or(anyhow::anyhow!("process not found"))?;
processes.push(DetailedProcess {
uuid,
user: child.user,
started_at: child.started_at,
pid,
});
}
Ok(processes)
}
/// check whether or not a process has completed by its [`Uuid`].
#[tracing::instrument]
pub async fn uuid_is_finished(uuid: Uuid) -> crate::Result<bool> {
Ok(uuid_exit_status(uuid).await?.is_some())
}
/// get the user from a processor by its [`Uuid`].
#[tracing::instrument]
pub async fn get_user_by_process(uuid: Uuid) -> crate::Result<Option<Uuid>> {
let state = State::get().await?;
let processor = state.processor.read().await;
if let Some(processor) = processor.get(uuid) {
Ok(processor.read().await.user)
} else {
Err(anyhow::anyhow!("process not found").into())
}
}
/// get the user from a processor by its [`Uuid`].
#[tracing::instrument]
pub async fn get_process_started_at(uuid: Uuid) -> crate::Result<DateTime<Utc>> {
let state = State::get().await?;
let processor = state.processor.read().await;
if let Some(processor) = processor.get(uuid) {
Ok(processor.read().await.started_at)
} else {
Err(anyhow::anyhow!("process not found").into())
}
}
/// check the exit status of a process by its [`Uuid`].
#[tracing::instrument]
pub async fn uuid_exit_status(uuid: Uuid) -> crate::Result<Option<i32>> {

View file

@ -4,6 +4,7 @@ use crate::api::proxy::{
};
use crate::proxy::OfflinePayload;
use crate::store::{ClusterPath, IngressProcessType, IngressProcessor};
use tauri::Emitter;
use uuid::Uuid;
#[cfg(feature = "cli")]
@ -15,9 +16,6 @@ use tauri_specta::Event;
#[cfg(feature = "tauri")]
use crate::api::proxy::{ClusterPayload, IngressPayload, MessagePayload, ProcessPayload};
#[cfg(feature = "tauri")]
use tauri::Manager;
#[onelauncher_macros::memory]
pub async fn init_ingress(
ingress_type: IngressType,
@ -152,12 +150,6 @@ pub async fn send_ingress(
},
)
.map_err(ProxyError::from)?;
// let _ = IngressPayload {
// fraction: display_conv,
// message: message.unwrap_or(&ingress.message).to_string(),
// event: ingress.ingress_type.clone(),
// ingress_uuid: ingress.ingress_uuid,
// }.emit(proxy_state.app.app_handle());
ingress.last_sent = display;
}
@ -169,10 +161,15 @@ pub async fn send_message(message: &str) -> crate::Result<()> {
#[cfg(feature = "tauri")]
{
let proxy_state = crate::ProxyState::get().await?;
MessagePayload {
message: message.to_string(),
}
.emit(proxy_state.app.app_handle())?;
proxy_state
.app
.emit(
MessagePayload::NAME,
MessagePayload {
message: message.to_string(),
},
)
.map_err(ProxyError::from)?;
}
tracing::warn!("{}", message);
@ -183,11 +180,15 @@ pub async fn send_offline(offline: bool) -> crate::Result<()> {
#[cfg(feature = "tauri")]
{
let proxy_state = crate::ProxyState::get().await?;
// proxy_state
// .app
// .emit("offline", offline)
// .map_err(ProxyError::from)?;
OfflinePayload { offline }.emit(proxy_state.app.app_handle())?;
proxy_state
.app
.emit(
OfflinePayload::NAME,
OfflinePayload {
offline,
},
)
.map_err(ProxyError::from)?;
}
Ok(())
@ -198,11 +199,13 @@ pub async fn send_internet(internet: InternetPayload) -> crate::Result<()> {
#[cfg(feature = "tauri")]
{
let proxy_state = crate::ProxyState::get().await?;
// proxy_state
// .app
// .emit("internet", internet)
// .map_err(ProxyError::from)?;
internet.emit(proxy_state.app.app_handle())?;
proxy_state
.app
.emit(
InternetPayload::NAME,
internet
)
.map_err(ProxyError::from)?;
}
Ok(())
@ -217,25 +220,18 @@ pub async fn send_process(
#[cfg(feature = "tauri")]
{
let proxy_state = crate::ProxyState::get().await?;
// proxy_state
// .app
// .emit(
// "process",
// ProcessPayload {
// uuid,
// pid,
// event,
// message: message.to_string(),
// },
// )
// .map_err(ProxyError::from)?;
ProcessPayload {
uuid,
pid,
event,
message: message.to_string(),
}
.emit(proxy_state.app.app_handle())?;
proxy_state
.app
.emit(
ProcessPayload::NAME,
ProcessPayload {
uuid,
pid,
event,
message: message.to_string(),
},
)
.map_err(ProxyError::from)?;
}
Ok(())
@ -251,27 +247,19 @@ pub async fn send_cluster(
{
let path = cluster_path.full_path().await?;
let proxy_state = crate::ProxyState::get().await?;
// proxy_state
// .app
// .emit(
// "cluster",
// ClusterPayload {
// uuid,
// cluster_path: cluster_path.clone(),
// path,
// name: name.to_string(),
// event,
// },
// )
// .map_err(ProxyError::from)?;
ClusterPayload {
uuid,
cluster_path: cluster_path.clone(),
path,
name: name.to_string(),
event,
}
.emit(proxy_state.app.app_handle())?;
proxy_state
.app
.emit(
ClusterPayload::NAME,
ClusterPayload {
uuid,
cluster_path: cluster_path.clone(),
path,
name: name.to_string(),
event,
},
)
.map_err(ProxyError::from)?;
}
Ok(())

View file

@ -317,13 +317,15 @@ pub async fn launch_minecraft(
None => Command::new(&java_version.path),
};
let env_args = Vec::from(env_args);
let existing = processor::get_uuids_by_cluster_path(cluster.cluster_path()).await?;
if let Some(uuid) = existing.first() {
return Err(anyhow::anyhow!(
"cluster {} is already running ({uuid})",
cluster.cluster_path()
)
.into());
if !state.settings.read().await.allow_parallel_running_clusters {
let existing = processor::get_uuids_by_cluster_path(cluster.cluster_path()).await?;
if let Some(uuid) = existing.first() {
return Err(anyhow::anyhow!(
"cluster {} is already running ({uuid})",
cluster.cluster_path()
)
.into());
}
}
command
@ -462,6 +464,7 @@ pub async fn launch_minecraft(
command,
post_hook,
censors,
Some(credentials.id)
)
.await
}

View file

@ -1,6 +1,6 @@
//! Handles child processes in a unified API
use chrono::{DateTime, Utc};
use chrono::{DateTime, TimeZone, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
@ -119,6 +119,9 @@ pub struct ProcessorCache {
pub cluster_path: ClusterPath,
/// A post hook to be run once a process has completed.
pub post: Option<String>,
/// The [`Uuid`] of the Minecraft account it was started by.
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<Uuid>,
}
impl ChildType {
@ -136,6 +139,7 @@ impl ChildType {
uuid: Uuid,
cluster_path: ClusterPath,
post: Option<String>,
user: Option<Uuid>,
) -> crate::Result<()> {
let pid = match self {
ChildType::ChildProcess(child) => child.id().unwrap_or(0),
@ -164,6 +168,7 @@ impl ChildType {
exe,
cluster_path,
post,
user
};
let path = state.directories.caches_dir().await.join(PROCESSOR_FILE);
@ -264,6 +269,10 @@ pub struct ProcessorChild {
pub current_child: Arc<RwLock<ChildType>>,
/// When this process was last updated in [`Utc`].
pub last_updated: DateTime<Utc>,
/// When this process was last updated in [`Utc`].
pub started_at: DateTime<Utc>,
/// What [`Uuid`] this process is running under.
pub user: Option<Uuid>,
}
impl Processor {
@ -422,6 +431,7 @@ impl Processor {
mut command: Command,
post: Option<String>,
censors: HashMap<String, String>,
user: Option<Uuid>,
) -> crate::Result<Arc<RwLock<ProcessorChild>>> {
let proc = command.spawn().map_err(IOError::from)?;
let process = ChildType::ChildProcess(proc);
@ -429,7 +439,7 @@ impl Processor {
.id()
.ok_or_else(|| anyhow::anyhow!("process failed, couldn't get pid"))?;
process
.cache(uuid, cluster_path.clone(), post.clone())
.cache(uuid, cluster_path.clone(), post.clone(), user)
.await?;
let current_child = Arc::new(RwLock::new(process));
@ -449,6 +459,8 @@ impl Processor {
current_child,
manager,
last_updated,
started_at: Utc::now(),
user,
};
let child = Arc::new(RwLock::new(child));
self.0.insert(uuid, child.clone());
@ -511,7 +523,7 @@ impl Processor {
.id()
.ok_or_else(|| anyhow::anyhow!("process failed, couldnt get pid"))?;
process
.cache(cache.uuid, cache.cluster_path.clone(), cache.post.clone())
.cache(cache.uuid, cache.cluster_path.clone(), cache.post.clone(), cache.user)
.await?;
let current_child = Arc::new(RwLock::new(process));
let manager = Some(tokio::spawn(Self::manager(
@ -534,9 +546,11 @@ impl Processor {
let child = ProcessorChild {
uuid: cache.uuid,
cluster_path: cache.cluster_path,
started_at: Utc.timestamp_opt(cache.start_time as i64, 0).single().ok_or(anyhow::anyhow!("couldn't convert processor cache timestamp to Utc"))?, // TODO: Look into this
current_child,
manager,
last_updated,
user: cache.user,
};
let child = Arc::new(RwLock::new(child));

View file

@ -21,6 +21,9 @@ pub struct Settings {
/// A global fullscreen Minecraft state.
#[serde(default)]
pub force_fullscreen: bool,
/// Whether to allow launching the same cluster under the same account.
#[serde(default)]
pub allow_parallel_running_clusters: bool,
/// A global default [`Resolution`] for Minecraft.
pub resolution: Resolution,
/// A global [`JavaVersions`] list and default version.
@ -95,6 +98,7 @@ impl Settings {
hide_close_prompt: true,
disable_animations: false,
force_fullscreen: false,
allow_parallel_running_clusters: false,
resolution: Resolution::default(),
java_versions: JavaVersions::new(),
memory: Memory::default(),