🔨 chore: merge

Signed-off-by: Pauline <git@ethanlibs.co>
This commit is contained in:
Pauline 2024-08-17 07:36:47 +02:00
commit 43f16ceecf
No known key found for this signature in database
GPG key ID: 722BB181973296AE
39 changed files with 1372 additions and 1017 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> {

File diff suppressed because it is too large Load diff

View file

@ -18,7 +18,7 @@ function App(props: ParentProps) {
return (
<GlobalContexts>
<main class="h-screen max-h-screen min-h-screen w-full flex flex-col overflow-hidden bg-primary text-fg-primary">
<main class="h-screen max-h-screen min-h-screen w-full flex flex-col overflow-hidden bg-page text-fg-primary">
<WindowFrame />
<div class="flex flex-col px-8">
<Navbar />

View file

@ -5,7 +5,7 @@ function ScrollableContainer(props: ParentProps) {
return (
<div class="w-[calc(100%+14px)] flex flex-1 flex-col overflow-hidden">
<OverlayScrollbarsComponent class="os-hide-horizontal-scrollbar relative flex-1">
<div class="absolute h-full w-[calc(100%-14px)] flex flex-col gap-1">
<div class="absolute h-full w-[calc(100%-14px)] flex flex-col gap-2">
{props.children}
</div>
</OverlayScrollbarsComponent>

View file

@ -10,7 +10,7 @@ export type SettingsRowProps = ParentProps & {
function SettingsRow(props: SettingsRowProps) {
return (
<div
class="flex flex-row items-center gap-3.5 rounded-xl bg-component-bg p-4"
class="bg-page-elevated flex flex-row items-center gap-3.5 rounded-xl p-4"
classList={{
'bg-component-bg-disabled': props.disabled,
'text-fg-primary-disabled': props.disabled,

View file

@ -26,12 +26,14 @@ 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 (
<div class="w-52 flex flex-col">
<div class="w-56 flex flex-col pr-2">
<For each={Object.keys(props.links)}>
{section => (
<div class="flex flex-col gap-y-2">
@ -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 border hover:bg-component-bg-hover active:bg-component-bg-pressed ${isActive(link[2], link[3]) ? 'bg-component-bg border-gray-05' : 'border-transparent'}`
}
>
<span class="flex flex-row items-center gap-x-3 *:w-5">

View file

@ -15,7 +15,7 @@ interface TitlebarButtonProps {
function TitlebarButton(props: TitlebarButtonProps) {
return (
<button class="group h-8 w-8 flex items-center justify-center" onClick={e => props.onClick(e)}>
<div class="rounded-lg p-1 group-hover:bg-primary">
<div class="rounded-lg p-1 group-hover:bg-page">
<props.icon class={`w-[18px] h-[18px] stroke-slate ${props.danger ? 'group-hover:stroke-danger' : 'group-hover:stroke-white'}`} />
</div>
</button>
@ -51,7 +51,7 @@ function WindowFrame() {
});
return (
<div data-tauri-drag-region class="z-[99999] h-8 w-screen flex flex-row items-center justify-between gap-0.5 bg-secondary pr-0.5">
<div data-tauri-drag-region class="z-[99999] h-8 w-screen flex flex-row items-center justify-between gap-0.5 bg-page-elevated pr-0.5">
<div class="flex flex-row items-center">
<TitlebarButton icon={ChevronLeftIcon} onClick={() => history.back()} />
</div>

View file

@ -28,7 +28,7 @@ type ButtonToggleProps = ButtonProps & {
};
Button.Toggle = (props: ButtonToggleProps) => {
const [split, rest] = splitProps(props, ['checked', 'onChecked', 'onClick', 'class']);
const [split, rest] = splitProps(props, ['checked', 'onChecked', 'onClick']);
const [checked, setChecked] = createSignal(untrack(() => split.checked?.()) || false);
createEffect(on(() => split.checked?.(), newValue => setChecked(newValue || false)));
@ -43,7 +43,7 @@ Button.Toggle = (props: ButtonToggleProps) => {
<Button
onClick={(e) => {
toggle();
// @ts-expect-error -- type error which i cba to resolve
// @ts-expect-error -- JSX doesn't seem to use the same type
split.onClick?.(e);
}}
aria-checked={checked()}

View file

@ -10,7 +10,7 @@
}
.dropdown_element {
@apply bg-secondary rounded-lg border border-gray-05 p-1 shadow-md shadow-black/30 h-full;
@apply bg-page-elevated rounded-lg border border-gray-05 p-1 shadow-md shadow-black/30 h-full;
}
.list {

View file

@ -60,9 +60,8 @@ SelectList.Row = (props: SelectListRowProps) => {
if (props.index !== undefined)
setSelected(props.index);
if (split.onClick)
// @ts-expect-error -- la
split.onClick(e);
// @ts-expect-error -- JSX doesn't seem to use the same type
split.onClick?.(e);
}}
class={`${styles.row} ${props.index !== undefined && selected() === props.index ? styles.selected : ''} ${split.class || ''}`}
>

View file

@ -10,8 +10,8 @@
@apply pointer-events-none;
@apply opacity-0 transition-opacity;
@apply absolute left-1/2 -translate-x-1/2 top-0 -translate-y-6/5;
@apply min-w-44 w-max max-w-52;
@apply w-max max-w-52;
@apply backdrop-blur-lg rounded-md bg-black/50 p-2 text-center text-sm text-wrap text-white shadow shadow-black/30;
}
}
}

View file

@ -1,23 +1,79 @@
import { For, createMemo } from 'solid-js';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-solid';
import { Index, Show, createMemo, createSignal, untrack } from 'solid-js';
import { OverlayScrollbarsComponent, type OverlayScrollbarsComponentRef } from 'overlayscrollbars-solid';
import type { Ref } from '@solid-primitives/refs';
import { AlignBottom01Icon } from '@untitled-theme/icons-solid';
import type { OnUpdatedEventListenerArgs, OverlayScrollbars } from 'overlayscrollbars';
import Button from '../base/Button';
import styles from './FormattedLog.module.scss';
interface FormattedLogProps {
log: string;
log?: string | undefined;
codeRef?: Ref<HTMLElement>;
enableAutoScroll?: boolean;
};
function FormattedLog(props: FormattedLogProps) {
const lines = createMemo(() => props.log.split('\n'));
// TODO(perf): Do a "infinite scroll" method of rendering the log. Adding 15918590815 dom elements at once lags the render thread for a bit
const lines = createMemo(() => (props.log?.trim().split('\n') || []));
// eslint-disable-next-line solid/reactivity -- Shouldn't really be tracked
const [shouldScroll, setShouldScroll] = createSignal(props.enableAutoScroll === true);
let overlayScrollbars!: OverlayScrollbarsComponentRef;
function autoScrollUpdated(instance: OverlayScrollbars, _args: OnUpdatedEventListenerArgs) {
if (untrack(shouldScroll) === true)
instance.elements().scrollOffsetElement.scrollTop = instance.elements().scrollOffsetElement.scrollHeight;
}
function autoScrollInitialized(instance: OverlayScrollbars) {
instance.elements().scrollEventElement.addEventListener('wheel', () => {
toggleAutoScroll(false);
});
}
function toggleAutoScroll(value: boolean) {
setShouldScroll(value);
if (value === true)
overlayScrollbars.osInstance()?.update(true);
}
return (
<div class="mt-2 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()}>
{line => <Line line={line} />}
</For>
</code>
</OverlayScrollbarsComponent>
<div class="relative h-full flex flex-1 flex-col rounded-md bg-component-bg p-2">
<Show when={props.enableAutoScroll === true}>
<div class="absolute right-0 top-0 z-1 h-6 w-full flex flex-row justify-end border border-gray-05 rounded-t-md bg-page p-px">
<Button.Toggle
buttonStyle="icon"
children={<AlignBottom01Icon />}
checked={shouldScroll}
onChecked={toggleAutoScroll}
class="h-full! rounded-md! py-1!"
/>
</div>
</Show>
<div
class={`${props.enableAutoScroll === true ? 'mt-6' : ''} h-full flex flex-1 overflow-auto font-medium font-mono`}
>
<OverlayScrollbarsComponent
ref={overlayScrollbars}
events={{
updated: autoScrollUpdated,
initialized: autoScrollInitialized,
}}
class="relative h-full flex-1"
>
<code ref={props.codeRef} class={styles.log}>
<Index each={lines()}>
{(line, index) => {
if (index === lines().length - 1)
return <></>;
return <Line line={line()} />;
}}
</Index>
</code>
</OverlayScrollbarsComponent>
</div>
</div>
);
}
@ -41,10 +97,11 @@ interface LineProps {
* It only matches `CHAT` if it is present.
* This is an **optional** capture group.
*/
const REGEX_PATTERN = /\[(\d+:\d+:\d+)\] \[(.[^(\n\r/\u2028\u2029]*)\/(\w+)\]: (?:\[(CHAT)\])?/;
const REGEX_PATTERN = /\[(\d+:\d+:\d+)\] \[(.[^(\n\r/\u2028\u2029]*)\/(\w+)\]:? (?:\[(CHAT)\])?/;
function Line(props: LineProps) {
export function Line(props: LineProps) {
const format = (line: string) => {
line = line.replace(/§./g, '');
const prefix = line.match(REGEX_PATTERN);
const isEmpty = line.trim() === '';

View file

@ -51,9 +51,8 @@ function ClusterCover(props: ClusterCoverProps) {
src={image()}
onError={(e) => {
e.currentTarget.src = split.override ? convertFileSrc(split.override) : defaultCover;
if (split.onError)
// @ts-expect-error -- jsx stuff
split.onError!(e);
// @ts-expect-error -- JSX doesn't seem to use the same type
split.onError?.(e);
}}
/>
</Wrapper>

View file

@ -36,7 +36,7 @@ function ContextMenu(props: ContextMenuProps) {
visible={props.visible}
setVisible={props.setVisible}
>
<div ref={setRef} class="border border-gray-05 rounded-xl bg-secondary p-1 shadow-black/30 shadow-md">
<div ref={setRef} class="border border-gray-05 rounded-xl bg-page-elevated p-1 shadow-black/30 shadow-md">
<div class="flex flex-col gap-y-1 text-fg-primary">
{props.children}
</div>

View file

@ -127,7 +127,7 @@ export type ModalProps = {
function Modal(props: ModalProps) {
return (
<div ref={mergeRefs(props.ref)} class="min-w-xs flex flex-col gap-y-2 border border-white/5 rounded-lg bg-primary p-4 text-center">
<div ref={mergeRefs(props.ref)} class="min-w-xs flex flex-col gap-y-2 border border-white/5 rounded-lg bg-page p-4 text-center">
{props.children}
</div>
);
@ -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

@ -60,7 +60,7 @@ function AccountPopup(props: Popup.PopupProps) {
return (
<Popup {...props}>
<div class="w-72 border border-gray-10 rounded-xl bg-secondary p-2 shadow-black/30 shadow-md">
<div class="w-72 border border-gray-10 rounded-xl bg-page-elevated p-2 shadow-black/30 shadow-md">
<div class="flex flex-col gap-y-2 text-fg-primary">
<Show when={controller.defaultAccount() !== null || controller.defaultAccount() !== undefined}>
<AccountComponent

View file

@ -173,7 +173,7 @@ export function createClusterStep(props: CreateClusterStepType): () => JSX.Eleme
}
return (
<div class="min-w-sm flex flex-col rounded-lg bg-primary text-center">
<div class="min-w-sm flex flex-col rounded-lg bg-page text-center">
<div class="relative h-25 flex">
<div class="absolute left-0 top-0 h-full w-full">
<img class="h-full w-full rounded-t-lg" src={HeaderImage} alt="Header Image" />

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

@ -35,7 +35,7 @@ function NotificationPopup(props: Popup.PopupProps) {
return (
<Popup {...props}>
<div class="w-96 border border-gray-10 rounded-xl bg-secondary p-2 shadow-black/30 shadow-md">
<div class="w-96 border border-gray-10 rounded-xl bg-page-elevated p-2 shadow-black/30 shadow-md">
<div class="overflow-hidden transition-height" ref={parent}>
<div class="flex flex-col items-stretch justify-start gap-2 text-start" ref={inner}>
<p class="px-2 pt-1 text-2lg">Notifications</p>

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

@ -66,7 +66,7 @@ function BrowserMain() {
return (
<div class="relative h-full flex flex-1 flex-col gap-2">
<div class="sticky top-0 z-10 flex flex-row justify-between bg-primary">
<div class="sticky top-0 z-10 flex flex-row justify-between bg-page">
<div class="h-8 flex flex-row gap-2">
<TextField

View file

@ -1,46 +1,44 @@
import { type Params, useNavigate, useSearchParams } from '@solidjs/router';
import { createEffect, createSignal, onCleanup, onMount } from 'solid-js';
import { type Params, useSearchParams } from '@solidjs/router';
import { createSignal, onCleanup, onMount } from 'solid-js';
import type { UnlistenFn } from '@tauri-apps/api/event';
import ClusterRoot from './ClusterRoot';
import { SlashOctagonIcon } from '@untitled-theme/icons-solid';
import { render } from 'solid-js/web';
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 FormattedLog, { Line } 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';
import useCommand from '~ui/hooks/useCommand';
import FormattedLog from '~ui/components/content/FormattedLog';
interface ClusterGameParams extends Params {
process_uuid: string;
started_at: string;
pid: string;
user: string;
};
function ClusterGame() {
const [cluster] = useClusterContext();
const [params] = useSearchParams<ClusterGameParams>();
const navigate = useNavigate();
const [log] = useCommand(bridge.commands.getClusterLog, cluster()!.uuid, 'latest.log');
const [isRunning, setIsRunning] = createSignal(true);
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());
});
let codeRef!: HTMLElement;
onMount(async () => {
const unlisten = await bridge.events.processPayload.listen(({ payload }) => {
if (payload.event === 'finished' && payload.uuid === params.process_uuid && cluster() !== undefined)
ClusterRoot.open(navigate, cluster()!.uuid);
const unlisten = await bridge.events.processPayload.listen((event) => {
if (event.payload.uuid !== params.process_uuid)
return;
if (event.payload.event === 'logging')
render(() => <Line line={event.payload.message} />, codeRef);
else if (event.payload.event === 'finished')
setIsRunning(false);
});
setUnlisten(() => unlisten);
@ -50,39 +48,82 @@ function ClusterGame() {
unlisten()?.();
});
const startedAtFormatted = (startedAt: number) => {
const date = new Date(startedAt);
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"
timeLeft={1}
/>
));
function killProcess(force: boolean = false) {
if (params.process_uuid !== undefined) {
if (force === true) {
bridge.commands.killProcess(params.process_uuid);
return;
}
killModal.show();
}
}
const date = new Date(params.started_at!);
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:
<h1>{isRunning() ? 'Game Running' : 'Game Stopped'}</h1>
<div class="flex flex-1 flex-col gap-y-4">
<div class="flex flex-col gap-y-2">
<div class="flex flex-row">
<div>
<Tooltip text={date.toLocaleString()}>
Started:
{' '}
<strong>
<TimeAgo timestamp={date.getTime()} />
</strong>
</Tooltip>
</div>
</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
codeRef={el => codeRef = el}
log={log()?.trim()}
enableAutoScroll={true}
/>
<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)}
disabled={!isRunning()}
/>
</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({
@ -263,7 +258,7 @@ function Banner(props: BannerProps) {
}
return (
<div class="h-37 flex flex-row gap-x-2.5 rounded-xl bg-component-bg p-2.5">
<div class="bg-page-elevated h-37 flex flex-row gap-x-2.5 rounded-xl p-2.5">
<div class="relative aspect-ratio-video h-full min-w-57 w-57 overflow-hidden border border-gray-10 rounded-lg">
<Show when={props.editMode()}>
<div

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();
@ -83,8 +88,9 @@ function ClusterSidebar() {
});
onMount(async () => {
const unlisten = await bridge.events.processPayload.listen(() => {
refetchProcesses();
const unlisten = await bridge.events.processPayload.listen(({ payload }) => {
if (payload.event === 'started' || payload.event === 'finished')
refetchProcesses();
});
setListener(() => unlisten);
@ -106,13 +112,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';
@ -48,12 +48,10 @@ export function createSetting<T>(initial: T | undefined | null, fallback?: T): C
checkGlobal();
});
// @ts-expect-error -- aaa
// @ts-expect-error -- Can't be bothered to fix this type issue
const set: Setter<T> = (value) => {
// @ts-expect-error -- aaa
setRaw(value);
// @ts-expect-error -- aaa
setValue(value);
setRaw(value!);
setValue(value!);
checkGlobal();
};
@ -64,8 +62,7 @@ export function createSetting<T>(initial: T | undefined | null, fallback?: T): C
setValue(() => fallback);
// @ts-expect-error -- aaa
setRaw(() => (raw || null));
setRaw(() => (raw || null)!);
checkGlobal();
};
@ -192,12 +189,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 +213,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 +352,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 +371,7 @@ function PageSettings() {
// Launcher
hide_on_launch: hideOnLaunch.get(),
allow_parallel_running_clusters: allowParallelClusters.get(),
// Process
init_hooks: {
@ -368,10 +387,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

@ -24,6 +24,10 @@ export default defineConfig({
sans: 'Poppins',
},
borderRadius: {
md: '0.438rem', // 7px
},
fontSize: {
// Based off 16px
'xs': '0.688rem',
@ -107,7 +111,12 @@ export default defineConfig({
trace: '#FDFDFD',
},
primary: '#11171C',
page: {
DEFAULT: '#11171c',
elevated: '#151c22',
pressed: '#0e1317',
},
secondary: '#192026',
},
extend: {
@ -123,10 +132,6 @@ export default defineConfig({
transparent: 'transparent',
},
borderRadius: {
mdlg: '0.438rem',
},
// animation: {
// 'fade-in': 'fadeIn 150ms easeOutExpo forwards',
// 'fade-out': 'fadeOut 150ms easeOutExpo forwards',

View file

@ -6,7 +6,7 @@ import solid from 'vite-plugin-solid';
import solidSvg from 'vite-plugin-solid-svg';
import paths from 'vite-tsconfig-paths';
import unocss from 'unocss/vite';
import sentry from '@sentry/vite-plugin';
import * as sentry from '@sentry/vite-plugin';
export default defineConfig(async ({ mode }) => {
process.env = { ...process.env, ...loadEnv(mode, process.cwd(), '') };

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

@ -231,6 +231,7 @@ pub enum ProcessPayloadType {
Started,
Modified,
Finished,
Logging,
}
#[cfg_attr(feature = "tauri", derive(specta::Type, tauri_specta::Event))]

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

@ -20,7 +20,7 @@ pub const DUMMY_REPLACE_NEWLINE: &str = "\n";
// =========== Authentication ===========
/// Our Discord RPC client ID.
pub const DISCORD_RPC_CLIENT_ID: &str = "1234567890000000"; // todo
pub const DISCORD_RPC_CLIENT_ID: &str = "1274084193193955408";
/// Our Microsoft client ID.
pub const MICROSOFT_CLIENT_ID: &str = "9eac3a4e-8cdd-43ef-863e-49cd601b1f03";
/// Mojang/Microsoft client ID.

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

@ -2,7 +2,7 @@
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use discord_rich_presence::activity::{Activity, Assets};
use discord_rich_presence::activity::{Activity, Assets, Button};
use discord_rich_presence::{DiscordIpc, DiscordIpcClient};
use tokio::sync::RwLock;
@ -59,11 +59,16 @@ impl DiscordRPC {
return Ok(());
}
let activity = Activity::new().state(msg).assets(
Assets::new()
.large_image("polyfrost")
.large_text("Polyfrost Logo"),
);
let activity = Activity::new()
.state(msg)
.buttons(vec![
Button::new("Download", "https://polyfrost.org/"),
])
.assets(
Assets::new()
.large_image("polyfrost_logo_512")
.large_text("Polyfrost Logo"),
);
let mut client: tokio::sync::RwLockWriteGuard<'_, DiscordIpcClient> =
self.client.write().await;

View file

@ -1,8 +1,10 @@
//! Handles child processes in a unified API
use chrono::{DateTime, Utc};
use chrono::{DateTime, TimeZone, Utc};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncBufReadExt, BufReader};
use std::collections::HashMap;
use std::process::Stdio;
use std::sync::Arc;
use tokio::process::{Child, Command};
use tokio::sync::RwLock;
@ -119,6 +121,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 +141,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 +170,7 @@ impl ChildType {
exe,
cluster_path,
post,
user
};
let path = state.directories.caches_dir().await.join(PROCESSOR_FILE);
@ -264,6 +271,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,14 +433,47 @@ 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);
let pid = process
command
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null());
let mut proc = command.spawn().map_err(IOError::from)?;
let pid = proc
.id()
.ok_or_else(|| anyhow::anyhow!("process failed, couldn't get pid"))?;
let stdout = proc.stdout.take().unwrap();
let stderr = proc.stderr.take().unwrap();
tokio::spawn(async move {
let mut stdout = BufReader::new(stdout).lines();
let mut stderr = BufReader::new(stderr).lines();
while let Ok(line) = tokio::select! {
line = stdout.next_line() => line,
line = stderr.next_line() => line,
} {
if let Some(line) = line {
let mut censored = line.clone();
for (key, value) in censors.iter() {
censored = censored.replace(key, value);
}
if let Err(err) = send_process(uuid, pid, ProcessPayloadType::Logging, &censored).await {
tracing::warn!("failed to send process log: {}", err);
};
}
}
});
let process = ChildType::ChildProcess(proc);
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 +493,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 +557,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 +580,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(),