feat: ClusterGame semi-works

This commit is contained in:
LynithDev 2024-08-02 17:35:27 +02:00
parent 199d7ed22c
commit 568e2c438a
15 changed files with 252 additions and 80 deletions

View file

@ -107,7 +107,7 @@ pub async fn remove_cluster(uuid: Uuid) -> Result<(), String> {
#[tauri::command]
pub async fn run_cluster(uuid: Uuid) -> Result<(Uuid, u32), String> {
let path = ClusterPath::find_by_uuid(uuid).await?;
let c_lock = cluster::run(&path).await?;
let c_lock = cluster::run_default(&path).await?;
let p_uuid = c_lock.read().await.uuid;
let p_pid = c_lock

View file

@ -42,6 +42,8 @@ macro_rules! collect_commands {
get_running_clusters,
get_processes_by_path,
kill_process,
is_cluster_running,
get_pid_by_uuid,
// Settings
get_settings,
set_settings,

View file

@ -8,12 +8,24 @@ pub async fn get_running_clusters() -> Result<Vec<Cluster>, String> {
Ok(processor::get_running_clusters().await?)
}
#[specta::specta]
#[tauri::command]
pub async fn is_cluster_running(uuid: Uuid) -> Result<bool, String> {
Ok(processor::is_cluster_running(uuid).await?)
}
#[specta::specta]
#[tauri::command]
pub async fn get_processes_by_path(path: ClusterPath) -> Result<Vec<Uuid>, String> {
Ok(processor::get_uuids_by_cluster_path(path).await?)
}
#[specta::specta]
#[tauri::command]
pub async fn get_pid_by_uuid(uuid: Uuid) -> Result<u32, String> {
Ok(processor::get_pid_by_uuid(uuid).await?)
}
#[specta::specta]
#[tauri::command]
pub async fn kill_process(uuid: Uuid) -> Result<(), String> {

View file

@ -0,0 +1,53 @@
import { type ParentProps, createSignal, onCleanup, onMount } from 'solid-js';
type TimeProps = ParentProps & {
timestamp: number;
};
export function TimeAgo(props: TimeProps) {
const formatter = new Intl.RelativeTimeFormat(navigator.language, { numeric: 'auto' });
const units = {
year: 24 * 60 * 60 * 1000 * 365,
month: 24 * 60 * 60 * 1000 * 365 / 12,
day: 24 * 60 * 60 * 1000,
hour: 60 * 60 * 1000,
minute: 60 * 1000,
second: 1000,
};
const getRelativeTime = (timestamp: number): string => {
const elapsed = timestamp - Date.now();
for (const [unit, ms] of Object.entries(units))
if (Math.abs(elapsed) > ms || unit === 'second')
return formatter.format(Math.round(elapsed / ms), unit as Intl.RelativeTimeFormatUnit);
return 'now';
};
return <InternalTime {...props} format={getRelativeTime} />;
}
type InternalTimeProps = TimeProps & {
format: (time: number) => string;
};
function InternalTime(props: InternalTimeProps) {
const [time, setTime] = createSignal('');
const [intervalId, setIntervalId] = createSignal<NodeJS.Timeout | undefined>(undefined);
onMount(() => {
const loop = () => {
setTime(props.format(props.timestamp));
};
loop();
setIntervalId(setInterval(loop, 1000));
});
onCleanup(() => {
clearInterval(intervalId()!);
});
return <span>{time()}</span>;
}

View file

@ -4,7 +4,7 @@ import { For, createEffect, splitProps } from 'solid-js';
type SidebarProps = ParentProps & {
base: string;
links: Record<string, ([JSX.Element, string, string] | undefined)[]>;
links: Record<string, ([JSX.Element, string, string, URLSearchParams?] | undefined)[]>;
};
function Sidebar(props: SidebarProps) {
@ -16,8 +16,13 @@ function Sidebar(props: SidebarProps) {
throw new Error('Base should not end with a slash');
});
function goto(href: string) {
const url = `${props.base}${href}${location.search.toString()}`;
function goto(href: string, params?: URLSearchParams) {
const currParams = new URLSearchParams(location.search);
if (params)
for (const [key, value] of params)
currParams.set(key, value);
const url = `${props.base}${href}?${currParams.toString()}`;
navigate(url);
}
@ -40,7 +45,7 @@ function Sidebar(props: SidebarProps) {
return (
<a
onClick={() => goto(link[2])}
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-gray-05' : ''}`
}

View file

@ -1,47 +0,0 @@
import { type ParentProps, createSignal, onCleanup, onMount } from 'solid-js';
type TimeAgoProps = ParentProps & {
timestamp: number;
};
function TimeAgo(props: TimeAgoProps) {
const [time, setTime] = createSignal('');
const [intervalId, setIntervalId] = createSignal<NodeJS.Timeout | undefined>(undefined);
onMount(() => {
const formatter = new Intl.RelativeTimeFormat(navigator.language, { numeric: 'auto' });
const units = {
year: 24 * 60 * 60 * 1000 * 365,
month: 24 * 60 * 60 * 1000 * 365 / 12,
day: 24 * 60 * 60 * 1000,
hour: 60 * 60 * 1000,
minute: 60 * 1000,
second: 1000,
};
const getRelativeTime = (timestamp: number): string => {
const elapsed = timestamp - Date.now();
for (const [unit, ms] of Object.entries(units))
if (Math.abs(elapsed) > ms || unit === 'second')
return formatter.format(Math.round(elapsed / ms), unit as Intl.RelativeTimeFormatUnit);
return 'now';
};
const loop = () => {
setTime(getRelativeTime(props.timestamp));
};
loop();
setIntervalId(setInterval(loop, 1000));
});
onCleanup(() => {
clearInterval(intervalId()!);
});
return <span>{time()}</span>;
}
export default TimeAgo;

View file

@ -18,7 +18,7 @@ function Toggle(props: ToggleProps) {
return (
<div
onClick={() => toggle()}
class={`w-[40px] h-[22px] p-3 flex flex-row relative rounded-full transition-colors overflow-hidden ${checked() ? 'bg-brand' : 'bg-gray-10'}${` ${split.class}` ?? ''}`}
class={`w-[40px] h-[22px] p-3 flex flex-row relative rounded-full transition-colors overflow-hidden ${checked() ? 'bg-brand' : 'bg-gray-10'}${` ${split.class}` || ''}`}
{...rest}
>
<div class={`w-[16px] h-[16px] rounded-full mx-1 left-0 top-1/2 -translate-y-1/2 transition-transform bg-white absolute ${checked() ? 'translate-x-full' : 'translate-x-0'}`} />

View file

@ -1,4 +1,5 @@
import { For, createMemo } from 'solid-js';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-solid';
import styles from './FormattedLog.module.scss';
interface FormattedLogProps {
@ -9,11 +10,15 @@ function FormattedLog(props: FormattedLogProps) {
const lines = createMemo(() => props.log.split('\n'));
return (
<code class={styles.log}>
<For each={lines()}>
{line => <Line line={line} />}
</For>
</code>
<div class="bg-component-bg flex flex-1 h-full font-mono font-medium overflow-auto p-2 rounded-md mt-2">
<OverlayScrollbarsComponent class="flex-1 h-full relative">
<code class={styles.log}>
<For each={lines()}>
{line => <Line line={line} />}
</For>
</code>
</OverlayScrollbarsComponent>
</div>
);
}

View file

@ -1,6 +1,6 @@
import { type JSX, Match, Show, Switch, createEffect, createSignal } from 'solid-js';
import { InfoCircleIcon } from '@untitled-theme/icons-solid';
import TimeAgo from '../../TimeAgo';
import { TimeAgo } from '../../DynamicTime';
import { PausableTimer } from '~utils/PausableTimer';
type NotificationComponentProps = Core.Notification & {

View file

@ -1,20 +1,88 @@
import ScrollableContainer from '~ui/components/ScrollableContainer';
import { type Params, useNavigate, useSearchParams } from '@solidjs/router';
import { createEffect, createSignal, onCleanup, onMount } from 'solid-js';
import type { UnlistenFn } from '@tauri-apps/api/event';
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';
interface ClusterGameParams extends Params {
process_uuid: string;
};
function ClusterGame() {
const _cluster = useClusterContext();
const [cluster] = useClusterContext();
const [params] = useSearchParams<ClusterGameParams>();
const navigate = useNavigate();
// TODO: Save cluster settings
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 }) => {
if (payload.event === 'finished' && payload.uuid === params.process_uuid && cluster() !== undefined)
ClusterRoot.open(navigate, cluster()!.uuid);
});
setUnlisten(() => unlisten);
});
onCleanup(() => {
unlisten()?.();
});
const startedAtFormatted = (startedAt: number) => {
const date = new Date(startedAt);
return date.toLocaleString();
};
return (
<Sidebar.Page>
<h1>Game Running</h1>
<ScrollableContainer>
<p>TODO</p>
</ScrollableContainer>
<div class="flex flex-col gap-y-2">
<div class="flex flex-row">
<Tooltip text={startedAtFormatted(startedAt() || 0)}>
Started:
{' '}
<strong>
<TimeAgo timestamp={startedAt() || 0} />
</strong>
</Tooltip>
</div>
<p>
PID:
{' '}
<strong>{pid() || 0}</strong>
</p>
</div>
<FormattedLog log="aaa" />
</Sidebar.Page>
);
}
ClusterGame.buildUrl = function (uuid: string, process_uuid: string): URLSearchParams {
return new URLSearchParams({ id: uuid, process_uuid });
};
export default ClusterGame;

View file

@ -1,5 +1,4 @@
import { For, Show, createEffect, createSignal, untrack } from 'solid-js';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-solid';
import { LinkExternal01Icon, Upload01Icon } from '@untitled-theme/icons-solid';
import { open } from '@tauri-apps/plugin-shell';
import useCommand, { tryResult } from '~ui/hooks/useCommand';
@ -109,11 +108,7 @@ function ClusterLogs() {
</div>
<Show when={logContent() !== null} fallback={<span>No logs were found.</span>}>
<div class="bg-component-bg flex flex-1 h-full font-mono font-medium overflow-auto p-2 rounded-md mt-2">
<OverlayScrollbarsComponent class="flex-1 h-full relative">
<FormattedLog log={logContent()!} />
</OverlayScrollbarsComponent>
</div>
<FormattedLog log={logContent()!} />
</Show>
</div>
</div>

View file

@ -1,6 +1,7 @@
import { type Navigator, Route, useSearchParams } from '@solidjs/router';
import type { ParentProps } from 'solid-js';
import { type Navigator, Route, useIsRouting, useSearchParams } from '@solidjs/router';
import { type ParentProps, 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';
import AnimatedRoutes from '../../components/AnimatedRoutes';
import ErrorBoundary from '../../components/ErrorBoundary';
@ -12,6 +13,9 @@ import ClusterSettings from './ClusterSettings';
import ClusterGame from './ClusterGame';
import useClusterContext, { ClusterProvider } from '~ui/hooks/useCluster';
import { supportsMods } from '~utils/helpers';
import { tryResult } from '~ui/hooks/useCommand';
import { bridge } from '~imports';
import PlayerHead from '~ui/components/game/PlayerHead';
function ClusterRoutes() {
return (
@ -53,12 +57,40 @@ ClusterRoot.open = function (navigate: Navigator, uuid: string) {
};
ClusterRoot.launch = function (navigate: Navigator, uuid: string) {
// TODO: Launch game page
navigate(`/clusters/game?id=${uuid}&launch=true`);
tryResult(bridge.commands.runCluster, uuid).then(([process_uuid]) => {
navigate(`/clusters/game?${ClusterGame.buildUrl(uuid, process_uuid).toString()}`);
});
};
function ClusterSidebar() {
const [cluster] = useClusterContext();
const [cluster, { refetch: refetchCluster }] = useClusterContext();
const [listener, setListener] = createSignal<UnlistenFn>();
const [runningProcesses, { refetch: refetchProcesses }] = createResource([], async () => {
const c = cluster();
if (!c || typeof c.path !== 'string')
return [];
return tryResult(bridge.commands.getProcessesByPath, c.path);
});
const isRouting = useIsRouting();
createEffect(() => {
if (isRouting())
refetchCluster();
});
onMount(async () => {
const unlisten = await bridge.events.processPayload.listen(() => {
refetchProcesses();
});
setListener(() => unlisten);
});
onCleanup(() => {
listener?.()?.();
});
return (
<Sidebar
@ -66,13 +98,22 @@ function ClusterSidebar() {
links={{
Cluster: [
[<EyeIcon />, 'Overview', '/'],
(supportsMods(cluster()) ? [<PackagePlusIcon />, 'Mods', '/mods'] : undefined),
(supportsMods(cluster?.()) ? [<PackagePlusIcon />, 'Mods', '/mods'] : undefined),
[<Image03Icon />, 'Screenshots', '/screenshots'],
[<Globe04Icon />, 'Worlds', '/worlds'],
[<File06Icon />, 'Logs', '/logs'],
[<Settings04Icon />, 'Game Settings', '/settings'],
// TODO: Add game page CONDITIONALLY
],
// 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 />;
return [icon, `Process #${index + 1}`, '/game', ClusterGame.buildUrl(cluster()!.uuid, process_uuid)];
}),
}
: {}),
}}
/>
);

View file

@ -140,7 +140,7 @@ pub async fn launch_and_authenticate() -> onelauncher::Result<()> {
State::sync().await?;
println!("running minecraft");
let c_lock = cluster::run(&cluster).await?;
let c_lock = cluster::run_default(&cluster).await?;
let uuid = c_lock.read().await.uuid;
let pid = c_lock.read().await.current_child.read().await.id();

View file

@ -85,7 +85,7 @@ pub async fn list_grouped(clear: Option<bool>) -> crate::Result<HashMap<String,
/// run a Minecraft [`Cluster`] using the default credentials.
#[tracing::instrument]
pub async fn run(path: &ClusterPath) -> crate::Result<Arc<RwLock<ProcessorChild>>> {
pub async fn run_default(path: &ClusterPath) -> crate::Result<Arc<RwLock<ProcessorChild>>> {
let state = State::get().await?;
let default = {
let mut users = state.users.write().await;
@ -98,6 +98,21 @@ pub async fn run(path: &ClusterPath) -> crate::Result<Arc<RwLock<ProcessorChild>
run_credentials(path, &default).await
}
/// run a Minecraft [`Cluster`] using [`MinecraftCredentials`] for authentication.
/// returns an [`Arc`] pointer to [`RwLock`] to [`ProcessorChild`].
#[tracing::instrument(skip(creds))]
#[onelauncher_macros::memory]
pub async fn run(
path: &ClusterPath,
creds: Option<&MinecraftCredentials>
) -> crate::Result<Arc<RwLock<ProcessorChild>>> {
if let Some(creds) = creds {
run_credentials(path, creds).await
} else {
run_default(path).await
}
}
/// run a Minecraft [`Cluster`] using [`MinecraftCredentials`] for authentication.
/// returns an [`Arc`] pointer to [`RwLock`] to [`ProcessorChild`].
#[tracing::instrument(skip(creds))]

View file

@ -50,6 +50,13 @@ pub async fn get_running_clusters() -> crate::Result<Vec<Cluster>> {
processor.running_clusters().await
}
/// check if a cluster is running by its [`Uuid`].
#[tracing::instrument]
pub async fn is_cluster_running(uuid: Uuid) -> crate::Result<bool> {
let clusters = get_running_clusters().await?;
Ok(clusters.iter().any(|cluster| cluster.uuid == uuid))
}
/// get all processes by a [`ClusterPath`].
#[tracing::instrument]
pub async fn get_uuids_by_cluster_path(cluster_path: ClusterPath) -> crate::Result<Vec<Uuid>> {
@ -105,3 +112,19 @@ pub async fn wait_for(process: &mut ProcessorChild) -> crate::Result<()> {
Ok(())
}
/// get process pid by its [`Uuid`].
#[tracing::instrument]
pub async fn get_pid_by_uuid(uuid: Uuid) -> crate::Result<u32> {
let state = State::get().await?;
let processor = state.processor.read().await;
Ok(processor.get(uuid)
.ok_or(anyhow::anyhow!("process not found"))?
.read()
.await
.current_child
.read()
.await
.id()
.ok_or(anyhow::anyhow!("process not found"))?)
}